Merge W0-tokens-core (Refs #44)

This commit is contained in:
shadowdao committed 2026-10-07 11:20:21 -07:00
commit 42700f911f
10 files changed
+602 -22

No files matched your search

+2
View File
@@ -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<AppProps> = ({ whpConfig }) => {
<EditorConfigProvider config={whpConfig}>
<SaveGuardProvider>
<SiteDesignProvider>
<CanvasTokenStyle />
<SaveAwareEditor>
<PageProvider>
<SitesmithProvider>
@@ -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(
<SiteDesignProvider>
<CanvasTokenStyle />
<DesignProbe out={ctx} />
</SiteDesignProvider>,
);
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 <App> (standalone mode)', () => {
test('the real App renders exactly one canvas token <style> alongside the device frame', { timeout: 30000 }, async () => {
ensureJsdomShims();
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => {
root.render(<App whpConfig={null} />);
});
expect(container.querySelector('.canvas-device-frame')).not.toBeNull();
const styles = container.querySelectorAll('style[data-wsb-canvas-tokens]');
expect(styles.length).toBe(1);
expect(styles[0].textContent).toContain('.canvas-device-frame{--wsb-primary:#3b82f6;');
} finally {
act(() => root.unmount());
container.remove();
}
});
});
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import { useSiteDesign } from '../state/SiteDesignContext';
import { buildCanvasTokenCss } from '../utils/tokens';
/**
* Defines the site's `--wsb-*` design-token variables on the editor canvas
* (scoped to `.canvas-device-frame`, never `:root`, so editor chrome is
* unaffected). Without it a component value of `var(--wsb-primary)` would
* render with no colour in the editor -- the published head defines the
* variables, the editor document otherwise does not. Re-renders on every
* design change, which is what makes token-backed values recolour instantly.
*/
export const CanvasTokenStyle: React.FC = () => {
const { design } = useSiteDesign();
return <style data-wsb-canvas-tokens="">{buildCanvasTokenCss(design)}</style>;
};
@@ -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<HTMLButtonElement>('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(
<SiteDesignProvider>
<TokenSwatchRow onSelect={() => {}} />
</SiteDesignProvider>,
);
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(
<SiteDesignProvider>
<TokenSwatchRow value="var(--wsb-accent)" onSelect={() => {}} />
</SiteDesignProvider>,
);
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(
<SiteDesignProvider>
<TokenSwatchRow value="#3b82f6" onSelect={() => {}} />
</SiteDesignProvider>,
);
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(
<SiteDesignProvider>
<TokenSwatchRow onSelect={onSelect} />
</SiteDesignProvider>,
);
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(
<SiteDesignProvider>
<DesignProbe out={ctx} />
<TokenSwatchRow
label="Brand"
tokens={[{ key: 'primary', label: 'P' }, { key: 'text', label: 'T' }]}
onSelect={() => {}}
/>
</SiteDesignProvider>,
);
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)');
});
});
@@ -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-<key>)`), 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<TokenSwatchRowProps> = ({
value,
onSelect,
tokens = COLOR_TOKENS,
label = 'Site colors',
}) => {
const { design } = useSiteDesign();
const current = value?.trim();
return (
<div className="token-swatch-row" role="group" aria-label={label}>
<div className="token-swatch-label">{label}</div>
<div className="token-swatches">
{tokens.map(({ key, label: tokenLabel }) => {
const ref = tokenRef(key);
const pressed = current === ref;
return (
<button
key={key}
type="button"
className={`token-swatch${pressed ? ' active' : ''}`}
style={{ background: resolveTokenValue(ref, design) }}
title={tokenLabel}
aria-label={tokenLabel}
aria-pressed={pressed}
data-token={key}
onClick={() => onSelect(ref)}
/>
);
})}
</div>
</div>
);
};
+46
View File
@@ -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;
}
+45
View File
@@ -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}</style><script>',
};
const html = exportToHtml(emptyRoot, { title: 'T', design }).html;
expect(html.length).toBe(2452);
expect(await sha(html)).toBe('c561538b7ca64c8bac8b2cd02ff65053a866f7891ceeed97c956ea79153d1338');
});
test('default :root token block is the exact pre-move string', () => {
const html = exportToHtml(emptyRoot, { title: 'T' }).html;
expect(html).toContain(
':root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}',
);
});
});
+5 -22
View File
@@ -3,6 +3,7 @@ import { cssPropsToString } from './style-helpers';
import { escapeHtml, escapeAttr, cssValue, safeImageUrl } from './escape';
import { sanitizeContainerTag } from '../components/layout/Container';
import { SiteDesign, DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
import { TOKEN_VARS } from './tokens';
export interface ExportOptions {
title?: string;
@@ -218,28 +219,10 @@ export function buildFontsLink(design?: SiteDesign): string {
// ---------- Design-token CSS-variable block (contract §2) ----------
// Ordered [cssVarName, SiteDesign key] pairs -- exact order/names from the
// PKG-H shared contract §2. Both the frontend (here) and the backend
// (site-builder.php) emit this same 16-line block for the same data so
// editor Preview === published output.
const TOKEN_VARS: Array<[string, keyof SiteDesign]> = [
['--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'],
];
// TOKEN_VARS (ordered [cssVarName, SiteDesign key] pairs, contract §2) now
// lives in ./tokens so the editor canvas can share it. Both the frontend
// (here) and the backend (site-builder.php) emit this same 16-line block for
// the same data so editor Preview === published output.
// Fixed token-base CSS (contract §2) -- makes the tokens actually take effect
// globally; component inline styles still override via specificity.
+137
View File
@@ -0,0 +1,137 @@
import { describe, test, expect } from 'vitest';
import {
TOKEN_VARS,
COLOR_TOKENS,
tokenRef,
isTokenRef,
tokenKeyOf,
resolveTokenValue,
buildCanvasTokenCss,
} from './tokens';
import { buildTokenCss } from './html-export';
import { DEFAULT_SITE_DESIGN, SiteDesign } from '../state/SiteDesignContext';
describe('TOKEN_VARS', () => {
test('is the 16-pair contract §2 list in backend order', () => {
expect(TOKEN_VARS.map(([v]) => v)).toEqual([
'--wsb-primary', '--wsb-secondary', '--wsb-accent', '--wsb-link',
'--wsb-success', '--wsb-warning', '--wsb-error', '--wsb-bg',
'--wsb-text', '--wsb-muted', '--wsb-border', '--wsb-radius',
'--wsb-heading-font', '--wsb-body-font', '--wsb-button-font', '--wsb-button-radius',
]);
expect(TOKEN_VARS.map(([, k]) => k)).toEqual([
'primaryColor', 'secondaryColor', 'accentColor', 'linkColor',
'successColor', 'warningColor', 'errorColor', 'backgroundColor',
'textColor', 'mutedTextColor', 'borderColor', 'borderRadius',
'headingFont', 'bodyFont', 'buttonFont', 'buttonRadius',
]);
});
});
describe('COLOR_TOKENS', () => {
test('palette order, colour tokens only', () => {
expect(COLOR_TOKENS.map((t) => t.key)).toEqual([
'primary', 'secondary', 'accent', 'link', 'text', 'muted', 'bg', 'border', 'success', 'warning', 'error',
]);
for (const t of COLOR_TOKENS) expect(t.label).toBeTruthy();
});
});
describe('tokenRef / isTokenRef / tokenKeyOf', () => {
test('tokenRef builds a var() reference', () => {
expect(tokenRef('primary')).toBe('var(--wsb-primary)');
expect(tokenRef('button-radius')).toBe('var(--wsb-button-radius)');
});
test('isTokenRef accepts exact refs (trimmed) and rejects everything else', () => {
expect(isTokenRef('var(--wsb-primary)')).toBe(true);
expect(isTokenRef('var(--wsb-primary) ')).toBe(true);
expect(isTokenRef(' var(--wsb-heading-font)')).toBe(true);
expect(isTokenRef('#3b82f6')).toBe(false);
expect(isTokenRef('var(--x)')).toBe(false);
expect(isTokenRef('var(--wsb-primary, #fff)')).toBe(false);
expect(isTokenRef('1px solid var(--wsb-border)')).toBe(false);
expect(isTokenRef('var(--wsb-Primary)')).toBe(false);
expect(isTokenRef(undefined)).toBe(false);
expect(isTokenRef(42)).toBe(false);
});
test('tokenKeyOf maps known refs and nulls unknown keys / non-refs', () => {
expect(tokenKeyOf('var(--wsb-primary)')).toBe('primary');
expect(tokenKeyOf(' var(--wsb-muted) ')).toBe('muted');
expect(tokenKeyOf('var(--wsb-heading-font)')).toBe('heading-font');
expect(tokenKeyOf('var(--wsb-nope)')).toBeNull();
expect(tokenKeyOf('#fff')).toBeNull();
expect(tokenKeyOf(null)).toBeNull();
});
});
describe('resolveTokenValue', () => {
const design: SiteDesign = { ...DEFAULT_SITE_DESIGN, accentColor: '#abcdef', mutedTextColor: '#123456' };
test('token refs resolve to the design value', () => {
expect(resolveTokenValue('var(--wsb-accent)', design)).toBe('#abcdef');
expect(resolveTokenValue('var(--wsb-muted)', design)).toBe('#123456');
expect(resolveTokenValue('var(--wsb-bg)', design)).toBe(design.backgroundColor);
expect(resolveTokenValue('var(--wsb-body-font)', design)).toBe('Inter, sans-serif');
});
test('every colour token resolves to its own design key (no cross-wiring)', () => {
const distinct = { ...DEFAULT_SITE_DESIGN } as SiteDesign;
TOKEN_VARS.forEach(([, k], i) => {
(distinct as unknown as Record<string, string>)[k] = `v${i}`;
});
TOKEN_VARS.forEach(([varName], i) => {
expect(resolveTokenValue(`var(${varName})`, distinct)).toBe(`v${i}`);
});
});
test('non-token input is returned unchanged', () => {
expect(resolveTokenValue('#3b82f6', design)).toBe('#3b82f6');
expect(resolveTokenValue('var(--wsb-nope)', design)).toBe('var(--wsb-nope)');
expect(resolveTokenValue('', design)).toBe('');
});
});
describe('buildCanvasTokenCss', () => {
test('declares all 16 vars scoped to .canvas-device-frame, never :root', () => {
const css = buildCanvasTokenCss(DEFAULT_SITE_DESIGN);
expect(css.startsWith('.canvas-device-frame{--wsb-primary:#3b82f6;')).toBe(true);
for (const [varName, key] of TOKEN_VARS) {
expect(css).toContain(`${varName}:${DEFAULT_SITE_DESIGN[key]}`);
}
expect(css).not.toContain(':root');
// Exact var block -- same declarations, same order, as the export's :root block.
const exportVars = /^:root\{([^}]*)\}/.exec(buildTokenCss(DEFAULT_SITE_DESIGN))![1];
expect(css).toContain(`.canvas-device-frame{${exportVars}}`);
});
test('emits the canvas-scoped token base CSS', () => {
const css = buildCanvasTokenCss(DEFAULT_SITE_DESIGN);
expect(css).toContain(
'.canvas-device-frame{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}' +
'.canvas-device-frame h1,.canvas-device-frame h2,.canvas-device-frame h3,.canvas-device-frame h4,.canvas-device-frame h5,.canvas-device-frame h6{font-family:var(--wsb-heading-font)}' +
'.canvas-device-frame a{color:var(--wsb-link)}',
);
// No unscoped selectors leak into the editor chrome.
expect(css).not.toMatch(/(^|\})(body|h1|a)\{/);
});
test('font stacks with commas/spaces/quotes survive (quotes stripped as in buildTokenCss)', () => {
const css = buildCanvasTokenCss({ ...DEFAULT_SITE_DESIGN, headingFont: '"Playfair Display", Georgia, serif' });
expect(css).toContain('--wsb-heading-font:Playfair Display, Georgia, serif;');
});
test('values with } or < are stripped -- no rule / <style> breakout', () => {
const css = buildCanvasTokenCss({
...DEFAULT_SITE_DESIGN,
primaryColor: 'red}body{display:none',
accentColor: '#fff</style><script>alert(1)</script>',
});
expect(css).not.toContain('</style>');
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);
});
});
+117
View File
@@ -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<readonly [`--wsb-${TokenKey}`, keyof SiteDesign]> = [
['--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<string, keyof SiteDesign>(
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-<name>)` -- 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 / `<style>`.
*/
export function buildCanvasTokenCss(design: SiteDesign): string {
const vars = TOKEN_VARS.map(([varName, key]) => `${varName}:${cssValue(design[key] as string)}`).join(';');
return `${CANVAS_SCOPE}{${vars}}${CANVAS_TOKEN_BASE_CSS}`;
}