fix(pen-core/variables): remove 4 extra tokens, add merge map per spec §3.1

The 4 extra single-value tokens (color-accent-dark, color-info-surface,
color-warning-text-strong, color-danger-text-strong) introduced in P1.1.6
violated spec §3.1 / §7.4 — those hex were INTENDED to merge into existing
tokens with ≤ 5% accepted color drift, not become new tokens.

Replaced with MERGE_MAP in measure-v0-hex-coverage.ts that tracks the 4
near-shade redirections (#1D4ED8→color-accent, #EFF6FF→color-info-bg,
#B45309→color-warning-text, #B91C1C→color-danger-text). Cover rate
calculation now reports direct + merge breakdown.

Final palette token count: 56 (28 color + 18 type + 2 letterSpacing +
5 spacing + 3 radius). Cover rate: 28 direct + 4 merge = 32/32 = 100.0%.
This commit is contained in:
Fini 2026-04-29 09:50:12 +08:00
parent cfabfe8a3d
commit f18f94da84
3 changed files with 76 additions and 45 deletions

View file

@ -407,12 +407,12 @@ describe('chart color tokens (6 single-value)', () => {
});
describe('palette count snapshot (color additions complete)', () => {
it('palette has exactly 32 color-type variables', () => {
it('palette has exactly 28 color-type variables (14 base + 8 alert + 6 chart)', () => {
const p = getSemanticPalette();
const colorCount = Object.values(p.variables).filter(
(d) => (d as VariableDefinition).type === 'color',
).length;
expect(colorCount).toBe(32);
expect(colorCount).toBe(28);
});
it('SEMANTIC_PALETTE_NAMES matches total variable count', () => {
@ -485,9 +485,9 @@ describe('typography tokens (18 numeric single-value)', () => {
}
});
it('palette has at least 50 tokens (32 color + 18 type)', () => {
it('palette has at least 46 tokens (28 color + 18 type)', () => {
const p = getSemanticPalette();
expect(Object.keys(p.variables).length).toBeGreaterThanOrEqual(50);
expect(Object.keys(p.variables).length).toBeGreaterThanOrEqual(46);
});
});
@ -512,9 +512,9 @@ describe('letterSpacing tokens (2 sparse numeric)', () => {
expect(def.value).toBe(1.5);
});
it('palette total grows to 52 (50 + 2 letterSpacing)', () => {
it('palette total grows to 48 (46 + 2 letterSpacing)', () => {
const p = getSemanticPalette();
expect(Object.keys(p.variables).length).toBeGreaterThanOrEqual(52);
expect(Object.keys(p.variables).length).toBeGreaterThanOrEqual(48);
});
});
@ -552,9 +552,9 @@ describe('spacing tokens (5 numeric)', () => {
}
});
it('palette total grows to 57 (52 + 5 spacing)', () => {
it('palette total grows to ≥ 53 (48 base+type+letterSpacing + 5 spacing)', () => {
const p = getSemanticPalette();
expect(Object.keys(p.variables).length).toBeGreaterThanOrEqual(57);
expect(Object.keys(p.variables).length).toBeGreaterThanOrEqual(53);
});
});
@ -590,9 +590,9 @@ describe('radius tokens (3 numeric)', () => {
}
});
it('palette total is 60 (57 + 3 radius) — all P1 tokens complete', () => {
it('palette total is 56 (53 + 3 radius) — all P1 tokens complete', () => {
const p = getSemanticPalette();
expect(Object.keys(p.variables).length).toBe(60);
// 32 color + 18 type + 2 letterSpacing + 5 spacing + 3 radius = 60
expect(Object.keys(p.variables).length).toBe(56);
// 28 color + 18 type + 2 letterSpacing + 5 spacing + 3 radius = 56
});
});

View file

@ -1,19 +1,28 @@
import type { PenDocument, VariableDefinition } from '@zseven-w/pen-types';
/**
* 14-variable semantic palette for theme-aware element tools.
* 56-token semantic palette (color + typography + spacing + radius)
* for theme-aware element tools.
*
* Composition (P1 token system):
* - 14 base color tokens (surface, border, text, accent, scrim) light/dark themed
* - 8 alert color tokens (info/success/warning/danger × bg/text) light/dark themed
* - 6 chart color tokens (color-chart-1..6) single-value (no theme axis)
* - 18 typography tokens (size + weight + line-height × 6 roles) numeric
* - 2 letterSpacing tokens (display, uppercase-label) numeric
* - 5 spacing tokens (spacing-1..5 = 4/8/12/16/24 px) numeric
* - 3 radius tokens (radius-sm/md/lg = 4/8/12 px) numeric
*
* Inventory rationale lives in
* `openpencil-docs/superpowers/notes/2026-04-22-dark-theme-defaults-audit.md`.
* That doc surveys the 30 hex literals in v0 element builders and
* clusters them into these 14 semantic tokens enough coverage
* for every theme-dependent surface, small enough to stay
* memorable.
* `openpencil-docs/superpowers/notes/2026-04-22-dark-theme-defaults-audit.md`
* and `2026-05-03-v0-hex-inventory.md`. v0 hex coverage is verified by
* `scripts/measure-v0-hex-coverage.ts` 32 semantic hex (post §3.4
* builder-private exclusion), 28 directly covered + 4 covered via merge map
* ( 5% color drift accepted per spec §3.1 / §7.4).
*
* Every variable ships with BOTH light and dark values keyed on
* a single theme axis `Mode`. Callers who want a single-theme
* document (no switching) can drop the `themes` field and the
* resolver will pick the first (light) value.
* Theme-aware variables ship with BOTH light and dark values keyed on
* a single theme axis `Mode`. Single-value entries (chart, numeric)
* carry just the value with no theme axis.
*
* This module is PURELY declarative it does NOT mutate
* `createEmptyDocument()`'s default output. v0 element tools
@ -189,24 +198,6 @@ const PALETTE: Record<string, PaletteEntry> = {
description: 'Danger / error alert text / icon',
},
// ── Extended alert/accent shades (4 semantic-but-uncovered hex) ──────────
'color-accent-dark': {
single: '#1D4ED8',
description: 'Darker accent — featured badge text, active pill on white',
},
'color-info-surface': {
single: '#EFF6FF',
description: 'Ultra-light info surface — featured pricing card highlight bg',
},
'color-warning-text-strong': {
single: '#B45309',
description: 'Stronger warning text — tag "warning" label on light bg',
},
'color-danger-text-strong': {
single: '#B91C1C',
description: 'Stronger danger text — tag "error" label on light bg',
},
// ── Typography: size + weight + line-height × 6 roles (18 numeric) ───────
'type-display-size': { single: 64, description: 'Display text — font size (px)' },
'type-display-weight': { single: 700, description: 'Display text — font weight' },

View file

@ -32,6 +32,32 @@ const BUILDER_PRIVATE_HEX = new Set<string>(
].map((h) => h.toUpperCase()),
);
/**
* §3.1 + §7.4 Merge map hex values that map to existing semantic tokens
* with 5% accepted color drift, rather than introducing new tokens.
*
* Rationale: per spec §3.1 line 75 + §7.4 line 287, semantic tokens stay
* intentionally small. Near-shade variants (e.g. blue-700 vs blue-600) are
* absorbed into the closest existing token instead of expanding the palette.
* v1 builders will emit `$<token>` for these hex inputs, accepting a small
* shade drift in exchange for a stable, memorable palette.
*
* Per-entry color drift (visual ΔE, approximate):
* - #1D4ED8 (blue-700) $color-accent (#2563EB blue-600) ~3% drift
* - #EFF6FF (blue-50) $color-info-bg (#DBEAFE blue-100) ~4% drift
* - #B45309 (amber-700) $color-warning-text (#92400E amber-800) ~3% drift
* - #B91C1C (red-700) $color-danger-text (#991B1B red-800) ~3% drift
*/
const MERGE_MAP: Record<string, string> = {
'#1D4ED8': 'color-accent',
'#EFF6FF': 'color-info-bg',
'#B45309': 'color-warning-text',
'#B91C1C': 'color-danger-text',
};
const MERGE_MAP_NORMALIZED: Record<string, string> = Object.fromEntries(
Object.entries(MERGE_MAP).map(([h, t]) => [h.toUpperCase(), t]),
);
function extractHexFromFile(path: string): string[] {
const content = readFileSync(path, 'utf-8');
return Array.from(content.matchAll(HEX_REGEX), (m) => m[0].toUpperCase());
@ -73,16 +99,30 @@ function main() {
.map((h) => h.toUpperCase()),
]);
const covered = [...v0HexSet].filter((h) => tokenHexSet.has(h)).sort();
const uncovered = [...v0HexSet].filter((h) => !tokenHexSet.has(h)).sort();
const coverRate = covered.length / v0HexSet.size;
// Direct cover: hex literal exists verbatim in palette
const directCovered = [...v0HexSet].filter((h) => tokenHexSet.has(h)).sort();
// Merge cover: hex literal mapped to an existing token via §3.1 / §7.4 merge map
const mergeCovered = [...v0HexSet]
.filter((h) => !tokenHexSet.has(h) && h in MERGE_MAP_NORMALIZED)
.sort();
// Total cover = direct merge
const totalCoveredSet = new Set<string>([...directCovered, ...mergeCovered]);
const uncovered = [...v0HexSet].filter((h) => !totalCoveredSet.has(h)).sort();
const coverRate = totalCoveredSet.size / v0HexSet.size;
console.log(`v0 distinct hex literals (raw): ${v0HexSetRaw.size}`);
console.log(`§3.4 builder-private excluded: ${excludedCount}`);
console.log(`v0 semantic hex (post-exclusion): ${v0HexSet.size}`);
console.log(`covered by palette: ${covered.length}`);
console.log(`uncovered: ${uncovered.length}`);
console.log(`semantic cover rate: ${(coverRate * 100).toFixed(1)}%`);
console.log(` direct cover: ${directCovered.length}`);
console.log(
` merge cover (${mergeCovered.length} of ${Object.keys(MERGE_MAP).length} merge-map entries):`,
);
for (const hex of mergeCovered) {
console.log(` ${hex}$${MERGE_MAP_NORMALIZED[hex]}`);
}
console.log(
` total cover: ${totalCoveredSet.size}/${v0HexSet.size} = ${(coverRate * 100).toFixed(1)}%`,
);
if (uncovered.length > 0) {
console.log(`\nUncovered semantic hex (with usage):`);