feat(pen-core/variables): resolver-side fallback to default palette (P1.6)

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.
This commit is contained in:
Fini 2026-04-29 09:50:13 +08:00
parent f18f94da84
commit 8a8151d35c
4 changed files with 143 additions and 7 deletions

View file

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

View file

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

View file

@ -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: `{ <token-name>: { 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<string, FallbackEntry> = (() => {
const lightHex = getSemanticPaletteHex(SEMANTIC_PALETTE_THEME_LIGHT);
const darkHex = getSemanticPaletteHex(SEMANTIC_PALETTE_THEME_DARK);
const { variables } = getSemanticPalette();
const fallback: Record<string, FallbackEntry> = {};
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;
})();

View file

@ -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<string, VariableDefinition>;
type Theme = Record<string, string>;
@ -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)) {