diff --git a/craft/src/templates/definitions.tokens-C.test.ts b/craft/src/templates/definitions.tokens-C.test.ts new file mode 100644 index 0000000..108ee4a --- /dev/null +++ b/craft/src/templates/definitions.tokens-C.test.ts @@ -0,0 +1,126 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import { allTemplates } from './definitions'; +import type { TemplateComponent, TemplateDefinition } from './definitions'; +import { templateComponentToTreeNode } from './apply-template'; +import { buildNodeTree } from '../utils/craft-tree'; +import { exportBodyHtml, exportToHtml } from '../utils/html-export'; +import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext'; +import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness'; + +/** + * #44 group C: event-conference, resume-cv, blog, wedding must reference + * design tokens (`var(--wsb-*)`) instead of re-stating their own `design` + * palette / fonts in component props, so changing Site Design restyles them. + */ + +const IDS = ['event-conference', 'resume-cv', 'blog', 'wedding']; +const templates: TemplateDefinition[] = IDS.map((id) => allTemplates.find((t) => t.id === id)!); + +/** The template's own palette + fonts that must now be token refs. */ +function designValues(tpl: TemplateDefinition): string[] { + const d = tpl.design as Record; + const keys = [ + 'primaryColor', 'secondaryColor', 'accentColor', 'backgroundColor', 'textColor', + 'mutedTextColor', 'borderColor', 'linkColor', 'headingFont', 'bodyFont', + ]; + return keys.map((k) => d[k]).filter((v): v is string => typeof v === 'string').map((v) => v.toLowerCase()); +} + +/** Every string leaf of props (recursive), excluding visible copy (`text`, `heading`, ...). */ +const COPY_KEYS = new Set(['text', 'heading', 'subtitle', 'description', 'title', 'label', 'placeholder', 'tagline']); +function styleStrings(v: unknown, out: string[] = [], key = ''): string[] { + if (typeof v === 'string') { + if (!COPY_KEYS.has(key)) out.push(v); + } else if (Array.isArray(v)) { + v.forEach((x) => styleStrings(x, out, key)); + } else if (v && typeof v === 'object') { + for (const [k, x] of Object.entries(v)) styleStrings(x, out, k); + } + return out; +} + +function walk(comps: TemplateComponent[], fn: (c: TemplateComponent) => void) { + for (const c of comps) { + fn(c); + if (c.children) walk(c.children, fn); + } +} + +function allTrees(tpl: TemplateDefinition): { where: string; comps: TemplateComponent[] }[] { + return [ + { where: 'header', comps: tpl.header.components }, + { where: 'footer', comps: tpl.footer.components }, + ...tpl.pages.map((p) => ({ where: `page:${p.slug}`, comps: p.content.components })), + ]; +} + +function offenders(tpl: TemplateDefinition): string[] { + const vals = designValues(tpl); + const bad: string[] = []; + for (const { where, comps } of allTrees(tpl)) { + walk(comps, (c) => { + for (const s of styleStrings(c.props)) { + const low = s.toLowerCase(); + // substring check so gradients / shorthand values cannot smuggle a hex back in + if (vals.some((v) => low.includes(v))) bad.push(`${tpl.id} ${where} ${c.type}: ${s}`); + } + }); + } + return bad; +} + +let harness: EditorHarness | null = null; +afterEach(() => { + harness?.unmount(); + harness = null; +}); + +describe.each(templates)('tokens-C: $id', (tpl) => { + test('template exists and declares the design roles it uses', () => { + expect(tpl).toBeDefined(); + expect(tpl.design.primaryColor).toMatch(/^#/); + expect(tpl.design.mutedTextColor).toMatch(/^#/); + }); + + test('no prop/style value re-states a design colour or font (all token refs)', () => { + expect(offenders(tpl)).toEqual([]); + }); + + test('token refs are actually used (not just stripped)', () => { + const all = allTrees(tpl).flatMap(({ comps }) => { + const s: string[] = []; + walk(comps, (c) => s.push(...styleStrings(c.props))); + return s; + }); + expect(all.some((s) => s === 'var(--wsb-primary)')).toBe(true); + expect(all.some((s) => s.startsWith('var(--wsb-') && s.endsWith('font)'))).toBe( + tpl.id !== 'resume-cv', + ); + }); + + test('applies through buildNodeTree in a real harness without throwing and exports tokens', () => { + harness = renderEditorHarness(); + const page = tpl.pages[0]; + expect(() => { + harness!.act(() => { + for (const comp of page.content.components) { + const tree = buildNodeTree(harness!.query, templateComponentToTreeNode(comp)); + harness!.actions.addNodeTree(tree, 'ROOT'); + } + }); + }).not.toThrow(); + + const serialized = harness.getSerialized(); + const { html: body } = exportBodyHtml(serialized); + expect(body).toContain('var(--wsb-primary)'); + expect(body.toLowerCase()).not.toContain(tpl.design.primaryColor!.toLowerCase()); + + // Visual parity: :root carries the template primary, body no longer has the hex. + const design = { ...DEFAULT_SITE_DESIGN, ...tpl.design }; + const { html: doc } = exportToHtml(serialized, { design }); + const root = doc.match(/:root\{[^}]*\}/)?.[0] ?? ''; + expect(root.toLowerCase()).toContain(`--wsb-primary:${tpl.design.primaryColor!.toLowerCase()}`); + const bodyOnly = doc.slice(doc.indexOf('