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:
parent
f18f94da84
commit
8a8151d35c
77
packages/pen-core/src/__tests__/fallback-equivalence.test.ts
Normal file
77
packages/pen-core/src/__tests__/fallback-equivalence.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
50
packages/pen-core/src/variables/default-palette-fallback.ts
Normal file
50
packages/pen-core/src/variables/default-palette-fallback.ts
Normal 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;
|
||||
})();
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue