diff --git a/craft/src/App.tsx b/craft/src/App.tsx index d300f58..0499f3a 100644 --- a/craft/src/App.tsx +++ b/craft/src/App.tsx @@ -6,6 +6,7 @@ import { componentResolver } from './components/resolver'; import { WhpConfig } from './types'; import { EditorConfigProvider } from './state/EditorConfigContext'; import { SiteDesignProvider } from './state/SiteDesignContext'; +import { CanvasTokenStyle } from './editor/CanvasTokenStyle'; import { SaveGuardProvider, useSaveGuard } from './state/SaveGuardContext'; import { PageProvider } from './state/PageContext'; import { SitesmithProvider, useSitesmithModal } from './state/SitesmithContext'; @@ -37,6 +38,7 @@ export const App: React.FC = ({ whpConfig }) => { + diff --git a/craft/src/editor/CanvasTokenStyle.test.tsx b/craft/src/editor/CanvasTokenStyle.test.tsx new file mode 100644 index 0000000..eac6fd4 --- /dev/null +++ b/craft/src/editor/CanvasTokenStyle.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { describe, test, expect, afterEach } from 'vitest'; +import { createRoot } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { renderEditorHarness, EditorHarness, ensureJsdomShims } from '../test-utils/editorHarness'; +import { App } from '../App'; +import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue } from '../state/SiteDesignContext'; +import { CanvasTokenStyle } from './CanvasTokenStyle'; + +let h: EditorHarness | null = null; +afterEach(() => { + h?.unmount(); + h = null; +}); + +/** Grabs the live SiteDesign context so the test can drive updateDesign(). */ +const DesignProbe: React.FC<{ out: { current: SiteDesignContextValue | null } }> = ({ out }) => { + out.current = useSiteDesign(); + return null; +}; + +const styleText = () => { + const els = document.querySelectorAll('style[data-wsb-canvas-tokens]'); + expect(els.length).toBe(1); + return els[0].textContent ?? ''; +}; + +describe('CanvasTokenStyle (real editor harness)', () => { + test('defines the token vars in the document and follows updateDesign()', () => { + h = renderEditorHarness(); + const ctx: { current: SiteDesignContextValue | null } = { current: null }; + h.mountChild( + + + + , + ); + + const before = styleText(); + expect(before).toContain('.canvas-device-frame{--wsb-primary:#3b82f6;'); + expect(before).toContain('--wsb-heading-font:Inter, sans-serif'); + expect(before).not.toContain(':root'); + + h.act(() => { + ctx.current!.updateDesign({ primaryColor: '#ff0000', linkColor: '#00ff00' }); + }); + + const after = styleText(); + expect(after).toContain('--wsb-primary:#ff0000'); + expect(after).toContain('--wsb-link:#00ff00'); + expect(after).not.toContain('--wsb-primary:#3b82f6'); + + h.act(() => { + ctx.current!.resetToDefaults(); + }); + expect(styleText()).toContain('--wsb-primary:#3b82f6'); + }); +}); + +describe('CanvasTokenStyle is mounted by (standalone mode)', () => { + test('the real App renders exactly one canvas token ; +}; diff --git a/craft/src/panels/right/styles/TokenSwatchRow.test.tsx b/craft/src/panels/right/styles/TokenSwatchRow.test.tsx new file mode 100644 index 0000000..789430a --- /dev/null +++ b/craft/src/panels/right/styles/TokenSwatchRow.test.tsx @@ -0,0 +1,103 @@ +import { describe, test, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { TokenSwatchRow } from './TokenSwatchRow'; +import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue } from '../../../state/SiteDesignContext'; +import { COLOR_TOKENS } from '../../../utils/tokens'; + +/* Same DOM-harness pattern as shared-controls.test.tsx (no @testing-library/react). */ +let container: HTMLDivElement | null = null; +let root: Root; + +function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container!); + root.render(ui); + }); +} + +afterEach(() => { + if (!container) return; + act(() => { root.unmount(); }); + container.remove(); + container = null; +}); + +const swatches = () => Array.from(container!.querySelectorAll('button.token-swatch')); + +const DesignProbe: React.FC<{ out: { current: SiteDesignContextValue | null } }> = ({ out }) => { + out.current = useSiteDesign(); + return null; +}; + +describe('TokenSwatchRow', () => { + test('one labelled button per colour token, painted with the design value', () => { + render( + + {}} /> + , + ); + const btns = swatches(); + expect(btns.map((b) => b.getAttribute('aria-label'))).toEqual(COLOR_TOKENS.map((t) => t.label)); + expect(btns.map((b) => b.title)).toEqual(COLOR_TOKENS.map((t) => t.label)); + // jsdom normalises hex to rgb() + expect(btns[0].style.background).toBe('rgb(59, 130, 246)'); // primary #3b82f6 + expect(btns[1].style.background).toBe('rgb(139, 92, 246)'); // secondary #8b5cf6 + expect(container!.querySelector('[role="group"]')!.getAttribute('aria-label')).toBe('Site colors'); + expect(container!.textContent).toContain('Site colors'); + }); + + test('only the swatch matching value is pressed', () => { + render( + + {}} /> + , + ); + const pressed = swatches().filter((b) => b.getAttribute('aria-pressed') === 'true'); + expect(pressed.map((b) => b.dataset.token)).toEqual(['accent']); + expect(pressed[0].classList.contains('active')).toBe(true); + }); + + test('a raw colour value presses nothing', () => { + render( + + {}} /> + , + ); + expect(swatches().some((b) => b.getAttribute('aria-pressed') === 'true')).toBe(false); + }); + + test('clicking a swatch selects the token reference, not the resolved colour', () => { + const onSelect = vi.fn(); + render( + + + , + ); + const secondary = swatches().find((b) => b.dataset.token === 'secondary')!; + act(() => { secondary.click(); }); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith('var(--wsb-secondary)'); + }); + + test('custom tokens list and label; swatches recolour when the design changes', () => { + const ctx: { current: SiteDesignContextValue | null } = { current: null }; + render( + + + {}} + /> + , + ); + expect(swatches().map((b) => b.dataset.token)).toEqual(['primary', 'text']); + expect(container!.querySelector('[role="group"]')!.getAttribute('aria-label')).toBe('Brand'); + act(() => { ctx.current!.updateDesign({ primaryColor: '#ff0000' }); }); + expect(swatches()[0].style.background).toBe('rgb(255, 0, 0)'); + }); +}); diff --git a/craft/src/panels/right/styles/TokenSwatchRow.tsx b/craft/src/panels/right/styles/TokenSwatchRow.tsx new file mode 100644 index 0000000..9a73d79 --- /dev/null +++ b/craft/src/panels/right/styles/TokenSwatchRow.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { useSiteDesign } from '../../../state/SiteDesignContext'; +import { COLOR_TOKENS, TokenKey, tokenRef, resolveTokenValue } from '../../../utils/tokens'; + +export interface TokenSwatchRowProps { + /** The control's current value; the swatch whose `var(--wsb-*)` ref equals it is shown pressed. */ + value?: string; + /** Called with the token reference (`var(--wsb-)`), never the resolved colour. */ + onSelect(v: string): void; + /** Tokens to offer, in display order. Defaults to every colour token. */ + tokens?: ReadonlyArray<{ key: TokenKey; label: string }>; + label?: string; +} + +/** + * Palette-first colour row: one swatch per site design token, painted with + * the token's current value. Picking a swatch stores the token REFERENCE so + * the element follows later Site Design changes. + */ +export const TokenSwatchRow: React.FC = ({ + value, + onSelect, + tokens = COLOR_TOKENS, + label = 'Site colors', +}) => { + const { design } = useSiteDesign(); + const current = value?.trim(); + return ( +
+
{label}
+
+ {tokens.map(({ key, label: tokenLabel }) => { + const ref = tokenRef(key); + const pressed = current === ref; + return ( +
+
+ ); +}; diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index da24a41..e63d997 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -2054,3 +2054,49 @@ body { padding: 4px 12px; } .conflict-modal-actions button:disabled { opacity: 0.5; cursor: default; } + +/* W0-tokens: token swatches */ +.token-swatch-row { + margin-bottom: 8px; +} + +.token-swatch-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); + margin-bottom: 4px; +} + +.token-swatches { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.token-swatch { + width: 22px; + height: 22px; + padding: 0; + border-radius: 50%; + border: 2px solid var(--color-border); + cursor: pointer; + transition: border-color var(--transition-fast), transform var(--transition-fast), + box-shadow var(--transition-fast); +} + +.token-swatch:hover { + border-color: var(--color-text-muted); + transform: scale(1.1); +} + +.token-swatch.active { + border-color: var(--color-accent); + box-shadow: 0 0 0 2px var(--color-accent-subtle); +} + +.token-swatch:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} diff --git a/craft/src/utils/html-export.test.ts b/craft/src/utils/html-export.test.ts index 5ba8867..9753099 100644 --- a/craft/src/utils/html-export.test.ts +++ b/craft/src/utils/html-export.test.ts @@ -543,3 +543,48 @@ describe('bounce keyframe ends at opacity:1 (fix-anim-image A)', () => { expect(html).toContain('@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}'); }); }); + +/** + * W0-tokens-core: TOKEN_VARS moved from this module into utils/tokens.ts. + * The export head must be byte-identical before/after that move -- the + * hashes below were captured from the pre-move code (commit 97ccbee) for the + * same inputs, so any drift in the :root token block, its order, or the + * cssValue() sanitising fails here. + */ +describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () => { + const sha = async (s: string) => { + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)); + return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, '0')).join(''); + }; + const emptyRoot = JSON.stringify({ + ROOT: { + type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style: {} }, + displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {}, + }, + }); + + test('default design document hash matches the pre-move baseline', async () => { + const html = exportToHtml(emptyRoot, { title: 'T' }).html; + expect(html.length).toBe(2164); + expect(await sha(html)).toBe('f50f2940798bda064c11e65d111004355034acd240e03cfd7e6fd5cfe98bf3b6'); + }); + + test('custom + hostile design document hash matches the pre-move baseline', async () => { + const design: SiteDesign = { + ...DEFAULT_SITE_DESIGN, + primaryColor: '#ff0000', + headingFont: '"Playfair Display", serif', + accentColor: 'red}', + }); + expect(css).not.toContain(''); + expect(css).not.toContain('<'); + expect(css).toContain('--wsb-primary:redbodydisplay:none;'); + // Exactly the expected number of rule bodies: vars block + base + headings + links. + expect(css.split('}').length - 1).toBe(4); + }); +}); diff --git a/craft/src/utils/tokens.ts b/craft/src/utils/tokens.ts new file mode 100644 index 0000000..253608c --- /dev/null +++ b/craft/src/utils/tokens.ts @@ -0,0 +1,117 @@ +import type { SiteDesign } from '../state/SiteDesignContext'; +import { cssValue } from './escape'; + +/** + * Design-token vocabulary. A token reference (`var(--wsb-primary)`) is a + * first-class style VALUE: components may store it in props, the editor + * canvas defines the variables (CanvasTokenStyle), and the exported head + * defines them in `:root` (html-export.ts `buildTokenCss`). + */ +export type TokenKey = + | 'primary' + | 'secondary' + | 'accent' + | 'link' + | 'success' + | 'warning' + | 'error' + | 'bg' + | 'text' + | 'muted' + | 'border' + | 'radius' + | 'heading-font' + | 'body-font' + | 'button-font' + | 'button-radius'; + +// Ordered [cssVarName, SiteDesign key] pairs -- exact order/names from the +// PKG-H shared contract §2. Both the frontend (html-export.ts) and the +// backend (site-builder.php) emit this same 16-line block for the same data +// so editor Preview === published output. Do not reorder. +export const TOKEN_VARS: ReadonlyArray = [ + ['--wsb-primary', 'primaryColor'], + ['--wsb-secondary', 'secondaryColor'], + ['--wsb-accent', 'accentColor'], + ['--wsb-link', 'linkColor'], + ['--wsb-success', 'successColor'], + ['--wsb-warning', 'warningColor'], + ['--wsb-error', 'errorColor'], + ['--wsb-bg', 'backgroundColor'], + ['--wsb-text', 'textColor'], + ['--wsb-muted', 'mutedTextColor'], + ['--wsb-border', 'borderColor'], + ['--wsb-radius', 'borderRadius'], + ['--wsb-heading-font', 'headingFont'], + ['--wsb-body-font', 'bodyFont'], + ['--wsb-button-font', 'buttonFont'], + ['--wsb-button-radius', 'buttonRadius'], +]; + +/** Colour tokens in swatch-palette order (brand first, then neutrals, then status). */ +export const COLOR_TOKENS: ReadonlyArray<{ key: TokenKey; label: string }> = [ + { key: 'primary', label: 'Primary' }, + { key: 'secondary', label: 'Secondary' }, + { key: 'accent', label: 'Accent' }, + { key: 'link', label: 'Link' }, + { key: 'text', label: 'Text' }, + { key: 'muted', label: 'Muted text' }, + { key: 'bg', label: 'Background' }, + { key: 'border', label: 'Border' }, + { key: 'success', label: 'Success' }, + { key: 'warning', label: 'Warning' }, + { key: 'error', label: 'Error' }, +]; + +const TOKEN_REF_RE = /^var\(--wsb-([a-z-]+)\)$/; + +// TokenKey -> SiteDesign key, derived from TOKEN_VARS so there is one source. +const DESIGN_KEY_BY_TOKEN = new Map( + TOKEN_VARS.map(([varName, designKey]) => [varName.slice('--wsb-'.length), designKey]), +); + +/** `tokenRef('primary')` -> `'var(--wsb-primary)'`. */ +export function tokenRef(key: TokenKey): string { + return `var(--wsb-${key})`; +} + +/** True when `v` (trimmed) is exactly `var(--wsb-)` -- no fallback, no extra text. */ +export function isTokenRef(v: unknown): v is string { + return typeof v === 'string' && TOKEN_REF_RE.test(v.trim()); +} + +/** The TokenKey of a token reference, or null for non-refs and unknown `--wsb-*` names. */ +export function tokenKeyOf(v: unknown): TokenKey | null { + if (typeof v !== 'string') return null; + const m = TOKEN_REF_RE.exec(v.trim()); + if (!m || !DESIGN_KEY_BY_TOKEN.has(m[1])) return null; + return m[1] as TokenKey; +} + +/** Resolves a token reference to the design's concrete value; anything else is returned unchanged. */ +export function resolveTokenValue(v: string, design: SiteDesign): string { + const key = tokenKeyOf(v); + if (!key) return v; + return design[DESIGN_KEY_BY_TOKEN.get(key)!] as string; +} + +// Canvas-scoped equivalent of html-export's TOKEN_BASE_CSS. Scoped to the +// device frame (not :root) so the editor chrome never inherits site +// fonts/colours. +const CANVAS_SCOPE = '.canvas-device-frame'; +const CANVAS_TOKEN_BASE_CSS = + `${CANVAS_SCOPE}{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}` + + ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].map((h) => `${CANVAS_SCOPE} ${h}`).join(',') + + `{font-family:var(--wsb-heading-font)}` + + `${CANVAS_SCOPE} a{color:var(--wsb-link)}`; + +/** + * Defines the 16 `--wsb-*` variables on the editor canvas frame, plus the + * canvas-scoped token base CSS. Every value is sanitised through `cssValue()` + * exactly as `buildTokenCss` does -- it sits raw inside a rule body, so a `}` + * or `<` in a design value could otherwise break out of the rule / `