diff --git a/craft/src/templates/definitions.tokens-B.test.ts b/craft/src/templates/definitions.tokens-B.test.ts new file mode 100644 index 0000000..10623ab --- /dev/null +++ b/craft/src/templates/definitions.tokens-B.test.ts @@ -0,0 +1,137 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness'; +import { allTemplates } from './index'; +import type { TemplateComponent } from './definitions'; +import { buildNodeTree } from '../utils/craft-tree'; +import { templateComponentToTreeNode } from './apply-template'; +import { exportBodyHtml, exportToHtml } from '../utils/html-export'; +import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext'; +import { tokenRef } from '../utils/tokens'; + +/** + * Group B templates (#44): every colour/font that is one of the template's own + * `design` values must be a `var(--wsb-*)` reference in component props, so + * changing Site Design after applying the template restyles it. + */ +const IDS = ['medical', 'portfolio', 'photography', 'content-creator'] as const; +const COLOR_KEYS = [ + 'primaryColor', 'secondaryColor', 'accentColor', 'backgroundColor', 'textColor', 'mutedTextColor', +] as const; +const FONT_KEYS = ['headingFont', 'bodyFont'] as const; + +/** + * Design hexes that still appear in the exported first-page body because a + * COMPONENT hard-codes them (not the template): CTASection's heading/button + * (#ffffff, #18181b -- it has no colour props), FeaturesGrid's card + * background (#ffffff) and title (#18181b). Pinned as exact counts so a + * template that re-introduces a literal still fails; removing them needs a + * component change (out of scope for the template pass, tracked in the report). + */ +const COMPONENT_DEFAULT_HEX: Record> = { + medical: { '#ffffff': 5 }, + portfolio: { '#18181b': 4, '#ffffff': 2 }, + photography: {}, + 'content-creator': {}, +}; + +function* strings(v: unknown): Generator { + if (typeof v === 'string') yield v; + else if (Array.isArray(v)) for (const x of v) yield* strings(x); + else if (v && typeof v === 'object') for (const x of Object.values(v)) yield* strings(x); +} +function* walk(comps: TemplateComponent[]): Generator { + for (const c of comps) { + yield c; + if (c.children) yield* walk(c.children); + } +} + +let harness: EditorHarness | null = null; +afterEach(() => { + harness?.unmount(); + harness = null; +}); + +describe.each(IDS)('template %s is token-driven', (id) => { + const tpl = allTemplates.find((t) => t.id === id)!; + const design = tpl.design as Record; + const colors = COLOR_KEYS.map((k) => design[k]).filter(Boolean).map((c) => c.toLowerCase()); + const fonts = FONT_KEYS.map((k) => design[k]).filter(Boolean); + const firstPage = tpl.pages[0].content.components; + + test('sanity: the design declares colours, a muted role and fonts', () => { + expect(tpl).toBeDefined(); + expect(colors.length).toBeGreaterThanOrEqual(5); + expect(design.mutedTextColor).toBeTruthy(); + expect(fonts.length).toBe(2); + }); + + test('no component prop (header, footer, any page) hard-codes a design colour or font', () => { + const all = [ + ...tpl.header.components, + ...tpl.footer.components, + ...tpl.pages.flatMap((p) => p.content.components), + ]; + const offenders: string[] = []; + for (const c of walk(all)) { + for (const s of strings(c.props)) { + const lower = s.toLowerCase(); + for (const col of colors) if (lower.includes(col)) offenders.push(`${c.type}: ${s.slice(0, 80)}`); + for (const f of fonts) if (s.trim() === f) offenders.push(`${c.type}: ${s}`); + } + } + expect(offenders).toEqual([]); + }); + + test('applies through templateComponentToTreeNode + buildNodeTree in a real harness', () => { + harness = renderEditorHarness(); + expect(() => { + harness!.act(() => { + for (const comp of firstPage) { + harness!.actions.addNodeTree(buildNodeTree(harness!.query, templateComponentToTreeNode(comp)), 'ROOT'); + } + }); + }).not.toThrow(); + + const { html } = exportBodyHtml(harness.getSerialized()); + expect(html).toContain(tokenRef('primary')); + const known = COMPONENT_DEFAULT_HEX[id]; + if (!(design.primaryColor.toLowerCase() in known)) { + expect(html.toLowerCase()).not.toContain(design.primaryColor.toLowerCase()); + } + }); + + test('exported document defines the template primary in :root and the body no longer carries the old hexes', () => { + harness = renderEditorHarness(); + harness.act(() => { + for (const comp of firstPage) { + harness!.actions.addNodeTree(buildNodeTree(harness!.query, templateComponentToTreeNode(comp)), 'ROOT'); + } + }); + const { html } = exportToHtml(harness.getSerialized(), { + design: { ...DEFAULT_SITE_DESIGN, ...tpl.design }, + }); + const root = /:root\{([^}]*)\}/.exec(html); + expect(root).not.toBeNull(); + expect(root![1]).toContain(`--wsb-primary:${design.primaryColor}`); + const body = html.slice(html.indexOf(' { + harness = renderEditorHarness(); + harness.act(() => { + for (const comp of firstPage) { + harness!.actions.addNodeTree(buildNodeTree(harness!.query, templateComponentToTreeNode(comp)), 'ROOT'); + } + }); + const ser = harness.getSerialized(); + const a = exportToHtml(ser, { design: { ...DEFAULT_SITE_DESIGN, ...tpl.design } }).html; + const b = exportToHtml(ser, { design: { ...DEFAULT_SITE_DESIGN, ...tpl.design, primaryColor: '#123abc' } }).html; + expect(b).toContain('--wsb-primary:#123abc'); + expect(b.slice(b.indexOf('