Merge W0-tokens-core (Refs #44)
This commit is contained in:
commit
42700f911f
10 files changed
+602
-22
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in new issue
Block a user