From f18f94da8429538ab34b7ee37a77a1381c2c6ed8 Mon Sep 17 00:00:00 2001 From: Fini Date: Wed, 29 Apr 2026 09:50:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(pen-core/variables):=20remove=204=20extra?= =?UTF-8?q?=20tokens,=20add=20merge=20map=20per=20spec=20=C2=A73.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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%. --- .../src/__tests__/semantic-palette.test.ts | 22 ++++---- .../src/variables/semantic-palette.ts | 47 +++++++---------- scripts/measure-v0-hex-coverage.ts | 52 ++++++++++++++++--- 3 files changed, 76 insertions(+), 45 deletions(-) diff --git a/packages/pen-core/src/__tests__/semantic-palette.test.ts b/packages/pen-core/src/__tests__/semantic-palette.test.ts index dedb8d380..afce26106 100644 --- a/packages/pen-core/src/__tests__/semantic-palette.test.ts +++ b/packages/pen-core/src/__tests__/semantic-palette.test.ts @@ -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 }); }); diff --git a/packages/pen-core/src/variables/semantic-palette.ts b/packages/pen-core/src/variables/semantic-palette.ts index 3ea009d21..cd2dacb92 100644 --- a/packages/pen-core/src/variables/semantic-palette.ts +++ b/packages/pen-core/src/variables/semantic-palette.ts @@ -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 = { 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' }, diff --git a/scripts/measure-v0-hex-coverage.ts b/scripts/measure-v0-hex-coverage.ts index e61aad9db..6f7fc2074 100644 --- a/scripts/measure-v0-hex-coverage.ts +++ b/scripts/measure-v0-hex-coverage.ts @@ -32,6 +32,32 @@ const BUILDER_PRIVATE_HEX = new Set( ].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 `$` 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 = { + '#1D4ED8': 'color-accent', + '#EFF6FF': 'color-info-bg', + '#B45309': 'color-warning-text', + '#B91C1C': 'color-danger-text', +}; +const MERGE_MAP_NORMALIZED: Record = 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([...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):`);