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:
parent
cfabfe8a3d
commit
f18f94da84
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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):`);
|
||||
|
|
|
|||
Loading…
Reference in a new issue