Files
site-builder/craft/src/utils/tokens.ts
T
shadowdaoandClaude Sonnet 5.5 faecc76384 Site colours: saved swatches + live brand-colour tokens in design model
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>
2026-11-01 09:49:53 -08:00

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);
}