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:
parent
42b6492404
commit
ce32f9f572
274
packages/pen-core/src/__tests__/semantic-palette.test.ts
Normal file
274
packages/pen-core/src/__tests__/semantic-palette.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
215
packages/pen-core/src/variables/semantic-palette.ts
Normal file
215
packages/pen-core/src/variables/semantic-palette.ts
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue