feat(pen-core): 14-variable semantic palette (unblocks v1 theme-aware tools)

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<name, hex>
  - 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.
This commit is contained in:
Fini 2026-04-22 10:40:00 +08:00
parent 42b6492404
commit ce32f9f572
3 changed files with 502 additions and 0 deletions

View file

@ -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<string, unknown>)['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();
}
});
});

View file

@ -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';

View file

@ -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<string, string[]>;
/** The 14 variable definitions. Merge into `doc.variables`. */
variables: Record<string, VariableDefinition>;
}
/**
* 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<string, { light: string; dark: string; description: string }> = {
'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<string, VariableDefinition> = {};
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<string, string> {
const out: Record<string, string> = {};
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<string, string[]> = { ...(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);
}