From 8a8151d35cbcf1de588830b5422b793b37cb961e Mon Sep 17 00:00:00 2001 From: Fini Date: Wed, 29 Apr 2026 09:50:13 +0800 Subject: [PATCH] feat(pen-core/variables): resolver-side fallback to default palette (P1.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce DEFAULT_PALETTE_FALLBACK (56-entry map built from semantic-palette at module init) and wire it into resolveVariableRef so that v1 'system' mode on an un-seeded doc resolves to canonical hex/numeric defaults rather than undefined — fulfilling the equivalence guarantee from spec §5.3. Update modal-shell-v1 test to reflect the new contract: un-seeded doc + Mode:Dark now yields #1E293B (color-surface dark) instead of undefined. --- .../__tests__/fallback-equivalence.test.ts | 77 +++++++++++++++++++ .../src/__tests__/modal-shell-v1.test.ts | 11 ++- .../src/variables/default-palette-fallback.ts | 50 ++++++++++++ packages/pen-core/src/variables/resolve.ts | 12 ++- 4 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 packages/pen-core/src/__tests__/fallback-equivalence.test.ts create mode 100644 packages/pen-core/src/variables/default-palette-fallback.ts diff --git a/packages/pen-core/src/__tests__/fallback-equivalence.test.ts b/packages/pen-core/src/__tests__/fallback-equivalence.test.ts new file mode 100644 index 000000000..ea4de07b8 --- /dev/null +++ b/packages/pen-core/src/__tests__/fallback-equivalence.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { resolveVariableRef } from '../variables/resolve.js'; + +describe('resolver-side fallback (un-seeded doc, P1.6)', () => { + const emptyVars = {}; + + describe('color tokens fall back to default light/dark hex', () => { + it('color ref no theme → light hex', () => { + expect(resolveVariableRef('$color-text-primary', emptyVars, undefined)).toBe('#0F172A'); + }); + it('color ref Mode: Light → light hex', () => { + expect(resolveVariableRef('$color-text-primary', emptyVars, { Mode: 'Light' })).toBe( + '#0F172A', + ); + }); + it('color ref Mode: Dark → dark hex', () => { + expect(resolveVariableRef('$color-text-primary', emptyVars, { Mode: 'Dark' })).toBe( + '#F1F5F9', + ); + }); + it('alert color ref Mode: Light', () => { + expect(resolveVariableRef('$color-info-bg', emptyVars, { Mode: 'Light' })).toBe('#DBEAFE'); + }); + it('alert color ref Mode: Dark', () => { + expect(resolveVariableRef('$color-info-bg', emptyVars, { Mode: 'Dark' })).toBe('#1E3A8A'); + }); + it('chart color ref (single value, theme-agnostic)', () => { + expect(resolveVariableRef('$color-chart-1', emptyVars, undefined)).toBe('#3B82F6'); + expect(resolveVariableRef('$color-chart-1', emptyVars, { Mode: 'Dark' })).toBe('#3B82F6'); + }); + }); + + describe('typography tokens fall back to default numeric values', () => { + it('size ref → number', () => { + expect(resolveVariableRef('$type-h1-size', emptyVars, undefined)).toBe(24); + }); + it('weight ref → number', () => { + expect(resolveVariableRef('$type-h1-weight', emptyVars, undefined)).toBe(600); + }); + it('line-height ref → number', () => { + expect(resolveVariableRef('$type-h1-line-height', emptyVars, undefined)).toBe(1.2); + }); + it('letterSpacing ref → number', () => { + expect(resolveVariableRef('$type-display-letter-spacing', emptyVars, undefined)).toBe(-0.5); + }); + }); + + describe('spacing tokens fall back to default numeric values', () => { + it('spacing-3 → 12', () => { + expect(resolveVariableRef('$spacing-3', emptyVars, undefined)).toBe(12); + }); + it('spacing-5 → 24', () => { + expect(resolveVariableRef('$spacing-5', emptyVars, undefined)).toBe(24); + }); + }); + + describe('radius tokens fall back to default numeric values', () => { + it('radius-md → 8', () => { + expect(resolveVariableRef('$radius-md', emptyVars, undefined)).toBe(8); + }); + }); + + describe('un-known refs still return undefined', () => { + it('non-existent ref → undefined', () => { + expect(resolveVariableRef('$nonexistent-token', emptyVars, undefined)).toBeUndefined(); + }); + }); + + describe('seeded vars take precedence over fallback', () => { + it('user-set ref overrides default', () => { + const vars = { + 'color-text-primary': { type: 'color' as const, value: '#FF0000' }, + }; + expect(resolveVariableRef('$color-text-primary', vars, undefined)).toBe('#FF0000'); + }); + }); +}); diff --git a/packages/pen-core/src/__tests__/modal-shell-v1.test.ts b/packages/pen-core/src/__tests__/modal-shell-v1.test.ts index 7761de1cb..5b80dac10 100644 --- a/packages/pen-core/src/__tests__/modal-shell-v1.test.ts +++ b/packages/pen-core/src/__tests__/modal-shell-v1.test.ts @@ -188,16 +188,15 @@ describe('buildModalShellV1', () => { expect(resolveColorRef(getFillColor(sub)!, doc.variables!, { Mode: 'Dark' })).toBe('#94A3B8'); }); - it('WITHOUT palette seeded: refs resolve to undefined (caller is responsible)', () => { + it('WITHOUT palette seeded: refs resolve to DEFAULT_PALETTE_FALLBACK values (P1.6)', () => { const doc = createEmptyDocument(); // no palette applied const tree = buildModalShellV1({ title: 'T', theme: 'system' }) as unknown as Frame; const cardFill = getFillColor(findByRole(tree, 'modal-shell-card')!)!; const resolved = resolveColorRef(cardFill, doc.variables ?? {}, { Mode: 'Dark' }); - // The resolver returns undefined when a $ref points nowhere. - // This is the "document-setup bug" case the builder - // intentionally doesn't guard against (caller's - // responsibility per the v1 theme contract). - expect(resolved).toBeUndefined(); + // P1.6: resolver now falls back to DEFAULT_PALETTE_FALLBACK so that + // v1 'system' mode on an un-seeded doc is byte-equivalent to v0 hex + // output (spec §5.3). $color-surface dark = #1E293B. + expect(resolved).toBe('#1E293B'); }); }); diff --git a/packages/pen-core/src/variables/default-palette-fallback.ts b/packages/pen-core/src/variables/default-palette-fallback.ts new file mode 100644 index 000000000..5e177e195 --- /dev/null +++ b/packages/pen-core/src/variables/default-palette-fallback.ts @@ -0,0 +1,50 @@ +import { + getSemanticPaletteHex, + getSemanticPalette, + SEMANTIC_PALETTE_THEME_LIGHT, + SEMANTIC_PALETTE_THEME_DARK, +} from './semantic-palette.js'; + +/** + * Built-in default values for every token in the semantic palette. + * `resolveVariableRef` consults this map when `vars[refName]` is missing, + * making "v1 'system' mode + un-seeded doc" byte-equivalent to "v0 hex output" + * (per spec §5.3). + * + * Shape: `{ : { light?, dark?, single? } }` + * - Light/dark color tokens have `{ light, dark }` (theme-axis tokens) + * - Chart color tokens have `{ single: string }` (single-value color) + * - Typography/spacing/radius tokens have `{ single: number }` + */ +export interface FallbackEntry { + light?: string; + dark?: string; + single?: string | number; +} + +export const DEFAULT_PALETTE_FALLBACK: Record = (() => { + const lightHex = getSemanticPaletteHex(SEMANTIC_PALETTE_THEME_LIGHT); + const darkHex = getSemanticPaletteHex(SEMANTIC_PALETTE_THEME_DARK); + const { variables } = getSemanticPalette(); + const fallback: Record = {}; + + for (const [name, def] of Object.entries(variables)) { + const lightVal = lightHex[name]; + const darkVal = darkHex[name]; + + if (typeof lightVal === 'string' && typeof darkVal === 'string' && lightVal !== darkVal) { + // Theme-axis color token: { light, dark } + fallback[name] = { light: lightVal, dark: darkVal }; + } else if (typeof lightVal === 'string') { + // Single-value color token (chart palette) — same in both modes + fallback[name] = { single: lightVal }; + } else { + // Numeric token (typography / spacing / radius) — read from variables + const val = def.value; + if (typeof val === 'number') { + fallback[name] = { single: val }; + } + } + } + return fallback; +})(); diff --git a/packages/pen-core/src/variables/resolve.ts b/packages/pen-core/src/variables/resolve.ts index 3ae209619..92d0c3ff7 100644 --- a/packages/pen-core/src/variables/resolve.ts +++ b/packages/pen-core/src/variables/resolve.ts @@ -8,6 +8,7 @@ import type { PenNode } from '@zseven-w/pen-types'; import type { PenFill, PenStroke, PenEffect } from '@zseven-w/pen-types'; import type { VariableDefinition, ThemedValue } from '@zseven-w/pen-types'; +import { DEFAULT_PALETTE_FALLBACK } from './default-palette-fallback.js'; type Vars = Record; type Theme = Record; @@ -62,7 +63,16 @@ export function resolveVariableRef( if (!ref.startsWith('$')) return undefined; const name = ref.slice(1); const def = variables[name]; - if (!def) return undefined; + if (!def) { + // P1.6: fall back to DEFAULT_PALETTE_FALLBACK when vars doesn't have the token + const fallback = DEFAULT_PALETTE_FALLBACK[name]; + if (fallback !== undefined) { + if (fallback.single !== undefined) return fallback.single; + const mode = activeTheme?.Mode ?? 'Light'; + return mode === 'Dark' ? fallback.dark : fallback.light; + } + return undefined; + } const val = def.value; if (Array.isArray(val)) {