From ce32f9f572351e243bcd22650454ba51faa76945 Mon Sep 17 00:00:00 2001 From: Fini Date: Wed, 22 Apr 2026 10:40:00 +0800 Subject: [PATCH] feat(pen-core): 14-variable semantic palette (unblocks v1 theme-aware tools) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the canonical 14-token palette called for in the dark-theme audit (openpencil-docs/superpowers/notes/2026-04-22-dark-theme- defaults-audit.md §role clusters). Every token has paired Light + Dark values on a single `Mode` theme axis. API surface: - getSemanticPalette() → {themes, variables} for merge - getSemanticPaletteHex(mode='Light') → flat Record - applySemanticPalette(doc) → non-destructive merge (user- defined variables + theme axes WIN on collision; palette is purely additive) - hasSemanticPalette(doc) → runtime check for v1 tools before emitting \$color-* refs - getSemanticPaletteDescription(name) → human-readable string for token-picker UI - SEMANTIC_PALETTE_NAMES + theme-axis constants exported The 14 tokens: - Surfaces: color-surface, color-surface-2, color-surface-3, color-bg-deep - Borders: color-border, color-border-strong - Text: color-text-primary, color-text-body, color-text-muted, color-text-subtle - Semantic: color-accent, color-destructive, color-success - Other: color-scrim (with alpha for modal backdrop) Intentionally NOT wired into createEmptyDocument(). Seeding by default would alter every existing document on re-save and violate the v0 byte-parity contract (the whole point of the audit). v1 tools will call applySemanticPalette(doc) as a pre- flight, OR the app shell offers a "enable dark theme" user action that triggers the apply. 29 tests cover: palette shape (14 variables, 2 themed values each, hex-formatted, light≠dark), hex getter for both modes, apply non-mutation + user-var-wins-on-collision + additive theme-axis merge, hasSemanticPalette (empty / full / partial), and full round-trip through the existing resolveVariableRef / resolveColorRef paths for every palette name. --- .../src/__tests__/semantic-palette.test.ts | 274 ++++++++++++++++++ packages/pen-core/src/index.ts | 13 + .../src/variables/semantic-palette.ts | 215 ++++++++++++++ 3 files changed, 502 insertions(+) create mode 100644 packages/pen-core/src/__tests__/semantic-palette.test.ts create mode 100644 packages/pen-core/src/variables/semantic-palette.ts diff --git a/packages/pen-core/src/__tests__/semantic-palette.test.ts b/packages/pen-core/src/__tests__/semantic-palette.test.ts new file mode 100644 index 000000000..26a6567f0 --- /dev/null +++ b/packages/pen-core/src/__tests__/semantic-palette.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect } from 'vitest'; +import type { PenDocument, VariableDefinition, ThemedValue } from '@zseven-w/pen-types'; +import { + applySemanticPalette, + getSemanticPalette, + getSemanticPaletteDescription, + getSemanticPaletteHex, + hasSemanticPalette, + SEMANTIC_PALETTE_NAMES, + resolveVariableRef, + resolveColorRef, + createEmptyDocument, +} from '../index.js'; + +/** + * 14-variable semantic palette — the prerequisite for v1 theme- + * aware element tools. See dark-theme audit note for the rationale: + * `openpencil-docs/superpowers/notes/2026-04-22-dark-theme-defaults- + * audit.md`. + */ + +describe('getSemanticPalette', () => { + it('returns 14 variables', () => { + const p = getSemanticPalette(); + expect(Object.keys(p.variables).length).toBe(14); + }); + + it('defines the Mode theme axis with Light + Dark', () => { + const p = getSemanticPalette(); + expect(p.themes.Mode).toEqual(['Light', 'Dark']); + }); + + it('every variable is type="color"', () => { + const p = getSemanticPalette(); + for (const [name, def] of Object.entries(p.variables)) { + expect((def as VariableDefinition).type, `${name} type`).toBe('color'); + } + }); + + it('every variable has ThemedValue[] with exactly light + dark entries', () => { + const p = getSemanticPalette(); + for (const [name, def] of Object.entries(p.variables)) { + const value = (def as VariableDefinition).value; + expect(Array.isArray(value), `${name} value is array`).toBe(true); + const arr = value as ThemedValue[]; + expect(arr.length, `${name} has 2 themed values`).toBe(2); + expect(arr[0].theme).toEqual({ Mode: 'Light' }); + expect(arr[1].theme).toEqual({ Mode: 'Dark' }); + } + }); + + it('includes every required semantic role', () => { + const p = getSemanticPalette(); + // The 14 required names from the audit doc + const required = [ + 'color-surface', + 'color-surface-2', + 'color-surface-3', + 'color-bg-deep', + 'color-border', + 'color-border-strong', + 'color-text-primary', + 'color-text-body', + 'color-text-muted', + 'color-text-subtle', + 'color-accent', + 'color-destructive', + 'color-success', + 'color-scrim', + ]; + for (const name of required) { + expect(p.variables[name], `missing: ${name}`).toBeDefined(); + } + }); + + it('light and dark values differ for every color (real theme support, not stub)', () => { + const p = getSemanticPalette(); + for (const [name, def] of Object.entries(p.variables)) { + const values = (def as VariableDefinition).value as ThemedValue[]; + expect(values[0].value, `${name} has distinct light vs dark`).not.toBe(values[1].value); + } + }); + + it('every value is a well-formed hex color', () => { + const p = getSemanticPalette(); + // Accept #RRGGBB (6) or #RRGGBBAA (8 — for scrims with alpha) + const hexRe = /^#[0-9A-F]{6}([0-9A-F]{2})?$/i; + for (const [name, def] of Object.entries(p.variables)) { + const values = (def as VariableDefinition).value as ThemedValue[]; + for (const v of values) { + expect(typeof v.value).toBe('string'); + expect((v.value as string).match(hexRe), `${name} ${JSON.stringify(v.theme)}`).toBeTruthy(); + } + } + }); +}); + +describe('getSemanticPaletteHex', () => { + it('default mode is Light', () => { + const hex = getSemanticPaletteHex(); + expect(hex['color-surface']).toBe('#FFFFFF'); + expect(hex['color-accent']).toBe('#2563EB'); + }); + + it('Light mode returns light hex values', () => { + const hex = getSemanticPaletteHex('Light'); + expect(hex['color-bg-deep']).toBe('#F8FAFC'); + expect(hex['color-text-primary']).toBe('#0F172A'); + }); + + it('Dark mode returns dark hex values', () => { + const hex = getSemanticPaletteHex('Dark'); + expect(hex['color-surface']).toBe('#1E293B'); + expect(hex['color-bg-deep']).toBe('#0F172A'); + expect(hex['color-text-primary']).toBe('#F1F5F9'); + expect(hex['color-accent']).toBe('#60A5FA'); + }); + + it('returns all 14 names', () => { + const hex = getSemanticPaletteHex(); + expect(Object.keys(hex).length).toBe(14); + for (const name of SEMANTIC_PALETTE_NAMES) { + expect(hex[name]).toBeDefined(); + } + }); +}); + +describe('getSemanticPaletteDescription', () => { + it('returns a human-readable string for every variable', () => { + for (const name of SEMANTIC_PALETTE_NAMES) { + const desc = getSemanticPaletteDescription(name); + expect(desc, `${name} description`).toBeTruthy(); + expect(desc!.length).toBeGreaterThan(5); + } + }); + + it('returns undefined for unknown variable', () => { + expect(getSemanticPaletteDescription('no-such-var')).toBeUndefined(); + }); +}); + +describe('applySemanticPalette', () => { + it('seeds an empty document with palette + theme axis', () => { + const doc: PenDocument = createEmptyDocument(); + const out = applySemanticPalette(doc); + expect(Object.keys(out.variables ?? {}).length).toBe(14); + expect(out.themes?.Mode).toEqual(['Light', 'Dark']); + }); + + it('does NOT mutate the input document', () => { + const doc: PenDocument = createEmptyDocument(); + const before = JSON.stringify(doc); + applySemanticPalette(doc); + expect(JSON.stringify(doc)).toBe(before); + }); + + it('user-defined variables WIN on collision', () => { + const doc: PenDocument = { + version: '1.0.0', + children: [], + variables: { + 'color-accent': { type: 'color', value: '#FF0000' }, + }, + }; + const out = applySemanticPalette(doc); + expect(out.variables!['color-accent'].value).toBe('#FF0000'); + // All other palette vars still added + expect(out.variables!['color-surface']).toBeDefined(); + expect(Object.keys(out.variables!).length).toBe(14); + }); + + it('user-defined theme axis WINS (does not overwrite existing Mode)', () => { + const doc: PenDocument = { + version: '1.0.0', + children: [], + themes: { Mode: ['High-Contrast', 'Normal'] }, + }; + const out = applySemanticPalette(doc); + expect(out.themes!.Mode).toEqual(['High-Contrast', 'Normal']); + }); + + it('adds Mode axis if missing, preserves other axes', () => { + const doc: PenDocument = { + version: '1.0.0', + children: [], + themes: { Density: ['Compact', 'Comfortable'] }, + }; + const out = applySemanticPalette(doc); + expect(out.themes!.Density).toEqual(['Compact', 'Comfortable']); + expect(out.themes!.Mode).toEqual(['Light', 'Dark']); + }); + + it('applies cleanly to a doc without variables or themes', () => { + const doc: PenDocument = { version: '1.0.0', children: [] }; + const out = applySemanticPalette(doc); + expect(Object.keys(out.variables!).length).toBe(14); + expect(out.themes!.Mode).toEqual(['Light', 'Dark']); + }); +}); + +describe('hasSemanticPalette', () => { + it('empty document → false', () => { + expect(hasSemanticPalette(createEmptyDocument())).toBe(false); + }); + + it('after applySemanticPalette → true', () => { + const doc = applySemanticPalette(createEmptyDocument()); + expect(hasSemanticPalette(doc)).toBe(true); + }); + + it('partial palette (missing one variable) → false', () => { + const full = applySemanticPalette(createEmptyDocument()); + const partial = { + ...full, + variables: { ...full.variables }, + }; + delete (partial.variables as Record)['color-accent']; + expect(hasSemanticPalette(partial)).toBe(false); + }); +}); + +describe('palette resolves through resolveVariableRef', () => { + it('Light theme: $color-accent → #2563EB', () => { + const p = getSemanticPalette(); + const v = resolveVariableRef('$color-accent', p.variables, { Mode: 'Light' }); + expect(v).toBe('#2563EB'); + }); + + it('Dark theme: $color-accent → #60A5FA', () => { + const p = getSemanticPalette(); + const v = resolveVariableRef('$color-accent', p.variables, { Mode: 'Dark' }); + expect(v).toBe('#60A5FA'); + }); + + it('no active theme: defaults to first (Light) value', () => { + const p = getSemanticPalette(); + const v = resolveVariableRef('$color-accent', p.variables); + expect(v).toBe('#2563EB'); + }); + + it('resolveColorRef on a $ref produces the resolved hex (Dark)', () => { + const p = getSemanticPalette(); + const hex = resolveColorRef('$color-surface', p.variables, { Mode: 'Dark' }); + expect(hex).toBe('#1E293B'); + }); + + it('resolveColorRef on a non-ref passes through unchanged', () => { + const p = getSemanticPalette(); + const hex = resolveColorRef('#CAFE00', p.variables, { Mode: 'Dark' }); + expect(hex).toBe('#CAFE00'); + }); + + it('every palette variable resolves to its documented light/dark value', () => { + const p = getSemanticPalette(); + const light = getSemanticPaletteHex('Light'); + const dark = getSemanticPaletteHex('Dark'); + for (const name of SEMANTIC_PALETTE_NAMES) { + const lightResolved = resolveVariableRef(`$${name}`, p.variables, { Mode: 'Light' }); + const darkResolved = resolveVariableRef(`$${name}`, p.variables, { Mode: 'Dark' }); + expect(lightResolved, `${name} light`).toBe(light[name]); + expect(darkResolved, `${name} dark`).toBe(dark[name]); + } + }); +}); + +describe('SEMANTIC_PALETTE_NAMES', () => { + it('is readonly 14-length array matching palette keys', () => { + expect(SEMANTIC_PALETTE_NAMES.length).toBe(14); + const p = getSemanticPalette(); + for (const name of SEMANTIC_PALETTE_NAMES) { + expect(p.variables[name]).toBeDefined(); + } + }); +}); diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index 61949d6f1..145f14cb9 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -39,6 +39,19 @@ export { resolveNodeForCanvas, } from './variables/resolve.js'; export { replaceVariableRefsInTree } from './variables/replace-refs.js'; +export { + applySemanticPalette, + getSemanticPalette, + getSemanticPaletteDescription, + getSemanticPaletteHex, + hasSemanticPalette, + SEMANTIC_PALETTE_NAMES, + SEMANTIC_PALETTE_THEME_AXIS, + SEMANTIC_PALETTE_THEME_DARK, + SEMANTIC_PALETTE_THEME_LIGHT, + type SemanticPalette, + type SemanticPaletteMode, +} from './variables/semantic-palette.js'; // Normalization export { normalizePenDocument } from './normalize.js'; diff --git a/packages/pen-core/src/variables/semantic-palette.ts b/packages/pen-core/src/variables/semantic-palette.ts new file mode 100644 index 000000000..5f1c8c1d4 --- /dev/null +++ b/packages/pen-core/src/variables/semantic-palette.ts @@ -0,0 +1,215 @@ +import type { PenDocument, VariableDefinition } from '@zseven-w/pen-types'; + +/** + * 14-variable semantic palette for theme-aware element tools. + * + * 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. + * + * 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. + * + * This module is PURELY declarative — it does NOT mutate + * `createEmptyDocument()`'s default output. v0 element tools + * keep emitting hex literals (v0 byte-parity contract). v1 + * tools will `resolveVariableRef('$color-surface', doc.variables, + * doc.themes ? {Mode: 'Dark'} : undefined)` to pick the right + * shade at build time. + * + * To apply the palette to an existing document: + * + * ```ts + * const themed = applySemanticPalette(doc); + * ``` + * + * To get the resolved hex values for a specific mode (useful for + * tests and tools that need a concrete color without full + * document context): + * + * ```ts + * const light = getSemanticPaletteHex('Light'); + * // { 'color-surface': '#FFFFFF', 'color-accent': '#2563EB', ... } + * ``` + */ + +export const SEMANTIC_PALETTE_THEME_AXIS = 'Mode' as const; +export const SEMANTIC_PALETTE_THEME_LIGHT = 'Light' as const; +export const SEMANTIC_PALETTE_THEME_DARK = 'Dark' as const; + +export type SemanticPaletteMode = + | typeof SEMANTIC_PALETTE_THEME_LIGHT + | typeof SEMANTIC_PALETTE_THEME_DARK; + +export interface SemanticPalette { + /** The `Mode: ['Light', 'Dark']` theme axis. Merge into `doc.themes`. */ + themes: Record; + /** The 14 variable definitions. Merge into `doc.variables`. */ + variables: Record; +} + +/** + * Raw light/dark value table. Source of truth — both + * `getSemanticPalette()` and `getSemanticPaletteHex()` derive from + * this. Keeping it as a plain object (not a function) makes the + * palette trivially scanable in code review + reduces the risk of + * light/dark getting out of sync inside a function body. + */ +const PALETTE: Record = { + 'color-surface': { + light: '#FFFFFF', + dark: '#1E293B', + description: 'Primary surface — card, modal, tooltip background', + }, + 'color-surface-2': { + light: '#F1F5F9', + dark: '#334155', + description: 'Secondary surface — chip, input, hover background', + }, + 'color-surface-3': { + light: '#F3F4F6', + dark: '#475569', + description: 'Tertiary surface — pressed, deeper-nested background', + }, + 'color-bg-deep': { + light: '#F8FAFC', + dark: '#0F172A', + description: 'Page background, skeleton hosts', + }, + 'color-border': { + light: '#E2E8F0', + dark: '#334155', + description: 'Dividers, input strokes, card outlines', + }, + 'color-border-strong': { + light: '#CBD5E1', + dark: '#475569', + description: 'Dashed chart-placeholder border', + }, + 'color-text-primary': { + light: '#0F172A', + dark: '#F1F5F9', + description: 'Headlines, active page numbers', + }, + 'color-text-body': { + light: '#334155', + dark: '#CBD5E1', + description: 'Body paragraphs, nav labels', + }, + 'color-text-muted': { + light: '#64748B', + dark: '#94A3B8', + description: 'Secondary text, timestamps, placeholders', + }, + 'color-text-subtle': { + light: '#94A3B8', + dark: '#64748B', + description: 'Tertiary text, disabled states', + }, + 'color-accent': { + light: '#2563EB', + dark: '#60A5FA', + description: 'Primary brand, active pill, focus ring', + }, + 'color-destructive': { + light: '#EF4444', + dark: '#F87171', + description: 'Delete actions, error state, trending-down', + }, + 'color-success': { + light: '#10B981', + dark: '#34D399', + description: 'Trending-up, "Online" status', + }, + 'color-scrim': { + light: '#00000080', + dark: '#00000099', + description: 'Modal backdrop', + }, +}; + +export const SEMANTIC_PALETTE_NAMES = Object.keys(PALETTE) as readonly (keyof typeof PALETTE)[]; + +/** Return the 14-variable palette as a `{themes, variables}` pair. */ +export function getSemanticPalette(): SemanticPalette { + const variables: Record = {}; + for (const [name, { light, dark }] of Object.entries(PALETTE)) { + variables[name] = { + type: 'color', + value: [ + { value: light, theme: { [SEMANTIC_PALETTE_THEME_AXIS]: SEMANTIC_PALETTE_THEME_LIGHT } }, + { value: dark, theme: { [SEMANTIC_PALETTE_THEME_AXIS]: SEMANTIC_PALETTE_THEME_DARK } }, + ], + }; + } + return { + themes: { + [SEMANTIC_PALETTE_THEME_AXIS]: [SEMANTIC_PALETTE_THEME_LIGHT, SEMANTIC_PALETTE_THEME_DARK], + }, + variables, + }; +} + +/** + * Return just the resolved hex values for one mode. Useful when a + * caller needs a concrete color without carrying a full PenDocument + * context (e.g. test fixtures, static codegen). + */ +export function getSemanticPaletteHex( + mode: SemanticPaletteMode = SEMANTIC_PALETTE_THEME_LIGHT, +): Record { + const out: Record = {}; + for (const [name, entry] of Object.entries(PALETTE)) { + out[name] = mode === SEMANTIC_PALETTE_THEME_DARK ? entry.dark : entry.light; + } + return out; +} + +/** + * Return a human-readable description of a palette variable, for + * tooling UI (token picker) and documentation generation. + */ +export function getSemanticPaletteDescription(name: string): string | undefined { + return PALETTE[name]?.description; +} + +/** + * Merge the semantic palette into a PenDocument's `variables` and + * `themes` fields. Returns a new document; does NOT mutate the + * input. Existing variables/themes with the same names WIN — the + * palette is purely additive when there's a collision, so a + * document that has already defined `color-accent` differently + * keeps its version. + */ +export function applySemanticPalette(doc: PenDocument): PenDocument { + const palette = getSemanticPalette(); + const mergedVariables = { ...palette.variables, ...(doc.variables ?? {}) }; + const mergedThemes: Record = { ...(doc.themes ?? {}) }; + for (const [axis, modes] of Object.entries(palette.themes)) { + if (!mergedThemes[axis]) { + mergedThemes[axis] = [...modes]; + } + } + return { + ...doc, + variables: mergedVariables, + themes: mergedThemes, + }; +} + +/** + * Return `true` iff the document's variables include all 14 + * palette names. Useful as a runtime check before a v1 tool emits + * `$color-*` refs — if the doc lacks the palette, the tool should + * fall back to light-theme hex literals OR call + * `applySemanticPalette()` to seed it. + */ +export function hasSemanticPalette(doc: PenDocument): boolean { + const vars = doc.variables ?? {}; + return SEMANTIC_PALETTE_NAMES.every((name) => vars[name] !== undefined); +}