From 8bf59014f00349ab6cc4fa381adf0828dc89a2cb Mon Sep 17 00:00:00 2001 From: Fini Date: Wed, 29 Apr 2026 09:50:16 +0800 Subject: [PATCH] feat(modal-shell-v1): full token coverage via resolveTheme (P2.2) --- .../src/__tests__/modal-shell-v1.test.ts | 91 +++++++++++++++++++ .../src/element-builders/modal-shell-v1.ts | 79 +++++++--------- 2 files changed, 125 insertions(+), 45 deletions(-) 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 5b80dac10..7d0e1fe86 100644 --- a/packages/pen-core/src/__tests__/modal-shell-v1.test.ts +++ b/packages/pen-core/src/__tests__/modal-shell-v1.test.ts @@ -200,6 +200,97 @@ describe('buildModalShellV1', () => { }); }); + describe('theme=system — full token coverage (P2.2 retrofit)', () => { + it("title fontSize is '$type-h2-size' ref in system mode", () => { + const t = buildModalShellV1({ title: 'T', theme: 'system' }) as unknown as Frame & { + children: Array<{ children: Array<{ fontSize: unknown }> }>; + }; + const card = findByRole(t as unknown as Frame, 'modal-shell-card') as unknown as Frame & { + children: Array<{ fontSize: unknown }>; + }; + const title = card.children![0] as unknown as { fontSize: unknown }; + expect((title as { fontSize: unknown }).fontSize).toBe('$type-h2-size'); + }); + + it("subtitle fontSize is '$type-body-size' ref in system mode", () => { + const t = buildModalShellV1({ + title: 'T', + subtitle: 'S', + theme: 'system', + }) as unknown as Frame; + const sub = findByRole(t, 'modal-subtitle') as unknown as { fontSize: unknown }; + expect(sub.fontSize).toBe('$type-body-size'); + }); + + it("subtitle lineHeight is '$type-body-line-height' ref in system mode", () => { + const t = buildModalShellV1({ + title: 'T', + subtitle: 'S', + theme: 'system', + }) as unknown as Frame; + const sub = findByRole(t, 'modal-subtitle') as unknown as { lineHeight: unknown }; + expect(sub.lineHeight).toBe('$type-body-line-height'); + }); + + it("card gap is '$spacing-3' ref in system mode", () => { + const t = buildModalShellV1({ title: 'T', theme: 'system' }) as unknown as Frame; + const card = findByRole(t, 'modal-shell-card') as unknown as { gap: unknown }; + expect(card.gap).toBe('$spacing-3'); + }); + + it("card default padding is '$spacing-5' ref in system mode (no override)", () => { + const t = buildModalShellV1({ title: 'T', theme: 'system' }) as unknown as Frame; + const card = findByRole(t, 'modal-shell-card') as unknown as { padding: unknown }; + expect(card.padding).toBe('$spacing-5'); + }); + + it('card explicit card_padding override stays numeric even in system mode', () => { + const t = buildModalShellV1({ + title: 'T', + theme: 'system', + card_padding: 32, + }) as unknown as Frame; + const card = findByRole(t, 'modal-shell-card') as unknown as { padding: unknown }; + expect(card.padding).toBe(32); + }); + + it('cornerRadius stays 16 hardcoded in system mode (builder-private value)', () => { + const t = buildModalShellV1({ title: 'T', theme: 'system' }) as unknown as Frame; + const card = findByRole(t, 'modal-shell-card') as unknown as { cornerRadius: unknown }; + expect(card.cornerRadius).toBe(16); + }); + }); + + describe('v0 byte-parity (light mode) — typography/spacing must match v0 hardcoded values', () => { + it('title fontSize=20, fontWeight=600 in light mode (h2 values, v0 parity)', () => { + const t = buildModalShellV1({ title: 'T' }) as unknown as Frame; + const title = findByRole(t, 'modal-title') as unknown as { + fontSize: unknown; + fontWeight: unknown; + }; + expect(title.fontSize).toBe(20); + expect(title.fontWeight).toBe(600); + }); + + it('subtitle fontSize=14, fontWeight=400, lineHeight=1.5 in light mode (body values, v0 parity)', () => { + const t = buildModalShellV1({ title: 'T', subtitle: 'S' }) as unknown as Frame; + const sub = findByRole(t, 'modal-subtitle') as unknown as { + fontSize: unknown; + fontWeight: unknown; + lineHeight: unknown; + }; + expect(sub.fontSize).toBe(14); + expect(sub.fontWeight).toBe(400); + expect(sub.lineHeight).toBe(1.5); + }); + + it('card gap=12 in light mode (s3 value, v0 parity)', () => { + const t = buildModalShellV1({ title: 'T' }) as unknown as Frame; + const card = findByRole(t, 'modal-shell-card') as unknown as { gap: unknown }; + expect(card.gap).toBe(12); + }); + }); + describe('param handling consistent with v0', () => { it('card_width clamps to min 280', () => { const t = buildModalShellV1({ title: 'T', card_width: 100 }) as unknown as Frame & { diff --git a/packages/pen-core/src/element-builders/modal-shell-v1.ts b/packages/pen-core/src/element-builders/modal-shell-v1.ts index 75a5b4911..966d9fd1d 100644 --- a/packages/pen-core/src/element-builders/modal-shell-v1.ts +++ b/packages/pen-core/src/element-builders/modal-shell-v1.ts @@ -1,6 +1,7 @@ +import { resolveTheme, type V1Theme } from './resolve-theme.js'; import type { ElementTree } from './helpers.js'; -export type ModalShellV1Theme = 'light' | 'dark' | 'system'; +export type ModalShellV1Theme = V1Theme; export interface ModalShellV1Params { /** Modal dialog title (rendered as heading inside the card). */ @@ -38,40 +39,14 @@ export interface ModalShellV1Params { theme?: ModalShellV1Theme; } -interface ResolvedColors { - cardFill: string; - titleFill: string | null; - subtitleFill: string; -} - /** - * Resolve theme to the concrete color strings the builder emits. - * Returns either hex literals (light/dark) or `$color-*` refs - * (system). `titleFill: null` means "don't emit a fill" — - * match v0 behavior where title was unstyled (picks up the - * default text color). + * In light mode: titleFill=null to match v0 behavior (title had no fill). + * In dark/system: titleFill is a concrete color or ref. */ -function resolveTheme(theme: ModalShellV1Theme): ResolvedColors { - if (theme === 'system') { - return { - cardFill: '$color-surface', - titleFill: '$color-text-primary', - subtitleFill: '$color-text-muted', - }; - } - if (theme === 'dark') { - return { - cardFill: '#1E293B', // color-surface Dark - titleFill: '#F1F5F9', // color-text-primary Dark - subtitleFill: '#94A3B8', // color-text-muted Dark - }; - } - // Default: light — byte-parity with v0 - return { - cardFill: '#FFFFFF', - titleFill: null, // v0 emitted no fill on the title text - subtitleFill: '#64748B', - }; +function getTitleFill(theme: ModalShellV1Theme): string | null { + if (theme === 'light') return null; // v0 parity: title unstyled + const t = resolveTheme(theme); + return t.colors.textPrimary; } /** @@ -89,21 +64,32 @@ function resolveTheme(theme: ModalShellV1Theme): ResolvedColors { */ export function buildModalShellV1(params: ModalShellV1Params): ElementTree { const cardWidth = Math.max(280, Math.floor(params.card_width ?? 400)); - const cardPadding = Math.max(12, Math.floor(params.card_padding ?? 24)); const scrimOpacity = Math.max(0, Math.min(1, params.scrim_opacity ?? 0.5)); const theme = params.theme ?? 'light'; - const colors = resolveTheme(theme); + const t = resolveTheme(theme); + const titleFill = getTitleFill(theme); + + // Padding: user-supplied override takes priority (as a concrete number); + // when absent and in 'system' mode, emit the $spacing-5 ref (24px). + // In light/dark mode always use the concrete number (v0 byte-parity for light). + const rawPadding = params.card_padding; + const cardPadding: number | string = + rawPadding !== undefined + ? Math.max(12, Math.floor(rawPadding)) + : theme === 'system' + ? (t.spacing.s5 as string) + : 24; const titleNode: ElementTree = { type: 'text', name: 'Title', role: 'modal-title', content: params.title, - fontSize: 20, - fontWeight: 600, + fontSize: t.typography.h2Size, + fontWeight: t.typography.h2Weight, }; - if (colors.titleFill !== null) { - titleNode.fill = [{ type: 'solid', color: colors.titleFill }]; + if (titleFill !== null) { + titleNode.fill = [{ type: 'solid', color: titleFill }]; } const cardChildren: ElementTree[] = [titleNode]; @@ -113,10 +99,10 @@ export function buildModalShellV1(params: ModalShellV1Params): ElementTree { name: 'Subtitle', role: 'modal-subtitle', content: params.subtitle, - fontSize: 14, - fontWeight: 400, - lineHeight: 1.5, - fill: [{ type: 'solid', color: colors.subtitleFill }], + fontSize: t.typography.bodySize, + fontWeight: t.typography.bodyWeight, + lineHeight: t.typography.bodyLineHeight, + fill: [{ type: 'solid', color: t.colors.textMuted }], }); } @@ -140,11 +126,14 @@ export function buildModalShellV1(params: ModalShellV1Params): ElementTree { role: 'modal-shell-card', width: cardWidth, height: 'fit_content', + // cornerRadius=16 is a builder-private value not in the token system + // (closest token $radius-lg=12 has different semantics). Kept hardcoded + // across all themes per spec §3.4 / v0 byte-parity contract. cornerRadius: 16, padding: cardPadding, layout: 'vertical', - gap: 12, - fill: [{ type: 'solid', color: colors.cardFill }], + gap: t.spacing.s3, + fill: [{ type: 'solid', color: t.colors.surface }], effects: [ { type: 'shadow',