design.swatches [{id,label,hex,brand,slot}]; brand ones are published as
--wsb-brand-N appended inside :root (canvas and export share buildTokenVarDecls).
Sites without brand colours emit identical bytes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
137 lines
5.4 KiB
TypeScript
137 lines
5.4 KiB
TypeScript
import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext';
|
|
import { cssValue } from './escape';
|
|
import { brandTokenDecls, resolveBrandRef } from './swatches';
|
|
|
|
/**
|
|
* 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 brand = resolveBrandRef(v, design.swatches);
|
|
if (brand !== null) return brand;
|
|
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';
|
|
// Descendant rules are `:where()`-wrapped (zero specificity) to match the
|
|
// published `h1..h6{}` / `a{}` rules: with a plain `.canvas-device-frame a`
|
|
// (0,1,1) a component's own class rule (0,1,0) would lose in the editor but
|
|
// win when published.
|
|
const CANVAS_ZERO = `:where(${CANVAS_SCOPE})`;
|
|
const CANVAS_TOKEN_BASE_CSS =
|
|
`${CANVAS_SCOPE}{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}` +
|
|
`${CANVAS_ZERO} :where(h1,h2,h3,h4,h5,h6){font-family:var(--wsb-heading-font)}` +
|
|
`${CANVAS_ZERO} :where(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 {
|
|
return `${CANVAS_SCOPE}{${buildTokenVarDecls(design)}}${CANVAS_TOKEN_BASE_CSS}`;
|
|
}
|
|
|
|
/**
|
|
* The 16 `--wsb-*` declarations (`;`-joined) plus any published brand colours (`;--wsb-brand-N:#hex`, see swatches.ts), shared by the canvas and the
|
|
* export `:root` block. A blank/whitespace token falls back to its default: an
|
|
* empty custom property would invalidate every var() that reads it (mirrors the
|
|
* backend sb_token_css). Values are sanitised through `cssValue()`.
|
|
*/
|
|
export function buildTokenVarDecls(design: SiteDesign): string {
|
|
return TOKEN_VARS.map(([varName, key]) => {
|
|
const raw = String(design[key] ?? '').trim();
|
|
return `${varName}:${cssValue(raw !== '' ? raw : (DEFAULT_SITE_DESIGN[key] as string))}`;
|
|
}).join(';') + brandTokenDecls(design.swatches);
|
|
}
|