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>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 09:49:53 -08:00
1 parent ab7a10073b
commit faecc76384
4 files changed
+277 -2

No files matched your search

+5
View File
@@ -1,4 +1,5 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import type { SavedSwatch } from '../utils/swatches';
export interface SiteDesign {
// Basic
@@ -27,6 +28,9 @@ export interface SiteDesign {
/** Site-wide favicon URL (one per site, not per-page). Empty string = none. */
favicon: string;
/** Site-wide saved colours (labelled hex). Those marked `brand` are published as live `--wsb-brand-N` tokens. */
swatches: SavedSwatch[];
}
export interface SiteDesignContextValue {
@@ -56,6 +60,7 @@ export const DEFAULT_SITE_DESIGN: SiteDesign = {
navStyle: 'light',
headCode: '',
favicon: '',
swatches: [],
};
const SiteDesignContext = createContext<SiteDesignContextValue>({
+113
View File
@@ -0,0 +1,113 @@
import { describe, test, expect } from 'vitest';
import {
isValidHex, normalizeHex, brandSlotOf, publishedBrandSwatches, brandTokenDecls, swatchValue,
addSwatch, setSwatchBrand, updateSwatch, removeSwatch, resolveBrandRef, findSwatchByHex, MAX_BRAND_COLORS,
type SavedSwatch,
} from './swatches';
import { resolveTokenValue, buildCanvasTokenCss } from './tokens';
import { buildTokenCss } from './html-export';
import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
const sw = (o: Partial<SavedSwatch>): SavedSwatch => ({ id: 'x', label: '', hex: '#112233', brand: false, ...o });
describe('hex validation', () => {
test('accepts the 4 widths, rejects the rest', () => {
for (const h of ['#abc', '#abcd', '#aabbcc', '#aabbccdd', '#ABCDEF']) expect(isValidHex(h)).toBe(true);
for (const h of ['abc', '#ab', '#abcde', '#gggggg', '', 'red', '#aabbccddee', 42, undefined]) expect(isValidHex(h as any)).toBe(false);
});
test('normalizeHex lowercases and adds #', () => {
expect(normalizeHex(' 0F172A ')).toBe('#0f172a');
expect(normalizeHex('#ABC')).toBe('#abc');
expect(normalizeHex('zzz')).toBeNull();
expect(normalizeHex('')).toBeNull();
});
});
describe('brand token publishing rules (mirror of PHP sb_brand_token_decls)', () => {
test('orders by slot, skips invalid, first slot wins', () => {
const list: any[] = [
sw({ id: 'a', brand: true, slot: 3, hex: '#00ff00' }),
sw({ id: 'b', brand: true, slot: 1, hex: '#ff0000' }),
sw({ id: 'c', brand: false, slot: 2, hex: '#0000ff' }),
sw({ id: 'd', brand: true, slot: 4, hex: 'nope' }),
sw({ id: 'e', brand: true, slot: 7, hex: '#123456' }),
sw({ id: 'f', brand: true, slot: 1.5, hex: '#123456' }),
sw({ id: 'g', brand: true, slot: 3, hex: '#999999' }),
sw({ id: 'h', brand: 'yes' as any, slot: 5, hex: '#123456' }),
null, 'str',
];
expect(brandTokenDecls(list)).toBe(';--wsb-brand-1:#ff0000;--wsb-brand-3:#00ff00');
});
test('nothing for missing/empty/non-array', () => {
expect(brandTokenDecls(undefined)).toBe('');
expect(brandTokenDecls([])).toBe('');
expect(brandTokenDecls({})).toBe('');
});
});
describe('site without brand colours renders byte-identical', () => {
test('export and canvas css unchanged by an empty / non-brand swatch list', () => {
const base = buildTokenCss(DEFAULT_SITE_DESIGN);
expect(buildTokenCss({ ...DEFAULT_SITE_DESIGN, swatches: [sw({ brand: false })] })).toBe(base);
expect(buildTokenCss({ ...DEFAULT_SITE_DESIGN, swatches: undefined as any })).toBe(base);
expect(base).not.toContain('brand');
const canvas = buildCanvasTokenCss(DEFAULT_SITE_DESIGN);
expect(buildCanvasTokenCss({ ...DEFAULT_SITE_DESIGN, swatches: [sw({ brand: false })] })).toBe(canvas);
});
test('brand colours land inside :root after button-radius in both emitters', () => {
const d = { ...DEFAULT_SITE_DESIGN, swatches: [sw({ brand: true, slot: 2, hex: '#15202c' })] };
expect(buildTokenCss(d)).toContain('--wsb-button-radius:8px;--wsb-brand-2:#15202c}');
expect(buildCanvasTokenCss(d)).toContain('--wsb-button-radius:8px;--wsb-brand-2:#15202c}');
});
});
describe('refs and resolution', () => {
test('brandSlotOf', () => {
expect(brandSlotOf('var(--wsb-brand-2)')).toBe(2);
expect(brandSlotOf(' var(--wsb-brand-6) ')).toBe(6);
expect(brandSlotOf('var(--wsb-brand-7)')).toBeNull();
expect(brandSlotOf('var(--wsb-primary)')).toBeNull();
});
test('resolveTokenValue resolves brand refs from design.swatches', () => {
const d = { ...DEFAULT_SITE_DESIGN, swatches: [sw({ brand: true, slot: 2, hex: '#15202c' })] };
expect(resolveTokenValue('var(--wsb-brand-2)', d)).toBe('#15202c');
expect(resolveBrandRef('var(--wsb-brand-2)', d.swatches)).toBe('#15202c');
expect(resolveTokenValue('var(--wsb-brand-3)', d)).toBe('var(--wsb-brand-3)'); // unresolved stays as-is
});
test('swatchValue: token for live brand, hex otherwise', () => {
expect(swatchValue(sw({ brand: true, slot: 4 }))).toBe('var(--wsb-brand-4)');
expect(swatchValue(sw({ brand: true }))).toBe('#112233'); // no slot -> not published -> literal
expect(swatchValue(sw({}))).toBe('#112233');
});
});
describe('list operations', () => {
test('add / label / hex edit / remove', () => {
let l: SavedSwatch[] = [];
l = addSwatch(l, 'AABBCC', ' Logo blue ');
expect(l).toHaveLength(1);
expect(l[0]).toMatchObject({ hex: '#aabbcc', label: 'Logo blue', brand: false });
expect(addSwatch(l, 'bogus')).toBe(l);
l = updateSwatch(l, l[0].id, { label: 'Header', hex: '#0f172a' });
expect(l[0]).toMatchObject({ label: 'Header', hex: '#0f172a' });
expect(updateSwatch(l, l[0].id, { hex: 'zzzz' })[0].hex).toBe('#0f172a');
expect(removeSwatch(l, l[0].id)).toEqual([]);
});
test('brand toggle takes lowest free slot, stable, capped at 6', () => {
let l: SavedSwatch[] = [];
for (let i = 0; i < 7; i++) l = addSwatch(l, '#00000' + i, `c${i}`, true);
expect(l.filter((s) => s.brand).map((s) => s.slot)).toEqual([1, 2, 3, 4, 5, 6]);
expect(l[6].brand).toBe(false);
expect(MAX_BRAND_COLORS).toBe(6);
l = setSwatchBrand(l, l[1].id, false);
expect(l[1].slot).toBeUndefined();
l = setSwatchBrand(l, l[6].id, true);
expect(l[6].slot).toBe(2);
expect(l[0].slot).toBe(1); // untouched slots never shift
});
test('findSwatchByHex is case-insensitive and prefers brand', () => {
const l = [sw({ id: '1', hex: '#aabbcc' }), sw({ id: '2', hex: '#aabbcc', brand: true, slot: 1 })];
expect(findSwatchByHex(l, '#AABBCC')?.id).toBe('2');
expect(findSwatchByHex(l, '#000')).toBeUndefined();
});
});
+154
View File
@@ -0,0 +1,154 @@
/**
* Saved colour swatches (the site's own labelled colours) and "brand colours":
* a swatch marked `brand` is published as a live token `--wsb-brand-<slot>`
* so editing it later recolours every field that references it.
*
* The backend mirror is libs/SiteBuilderHead.php `sb_brand_token_decls`; the
* validity rules here and there MUST stay identical (pinned by the head parity
* tests on both sides).
*/
export interface SavedSwatch {
id: string;
/** What the colour is used for ("Logo blue"). May be empty. */
label: string;
/** Literal hex: #rgb, #rgba, #rrggbb or #rrggbbaa. */
hex: string;
/** True = published as a live token var(--wsb-brand-<slot>). */
brand: boolean;
/** Stable brand slot 1..MAX_BRAND_COLORS; only meaningful when `brand`. */
slot?: number;
}
export const MAX_BRAND_COLORS = 6;
export const MAX_SWATCHES = 40;
const HEX_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
const BRAND_REF_RE = /^var\(--wsb-brand-([1-6])\)$/;
/** True for a valid #rgb / #rgba / #rrggbb / #rrggbbaa literal (surrounding space ignored). */
export function isValidHex(v: unknown): v is string {
return typeof v === 'string' && HEX_RE.test(v.trim());
}
/** User input -> canonical lowercase hex ('#' optional on input), or null when invalid. */
export function normalizeHex(input: string): string | null {
let v = String(input ?? '').trim();
if (v === '') return null;
if (v[0] !== '#') v = '#' + v;
return HEX_RE.test(v) ? v.toLowerCase() : null;
}
export const brandTokenRef = (slot: number): string => `var(--wsb-brand-${slot})`;
/** Slot (1..6) of a `var(--wsb-brand-N)` reference, else null. */
export function brandSlotOf(v: unknown): number | null {
if (typeof v !== 'string') return null;
const m = BRAND_REF_RE.exec(v.trim());
return m ? Number(m[1]) : null;
}
/** The brand swatches that get published: brand===true, integer slot 1..6, valid hex; first per slot wins; slot order. */
export function publishedBrandSwatches(swatches: unknown): Array<{ slot: number; hex: string }> {
if (!Array.isArray(swatches)) return [];
const bySlot = new Map<number, string>();
for (const s of swatches) {
if (!s || typeof s !== 'object') continue;
const { brand, slot, hex } = s as Record<string, unknown>;
if (brand !== true) continue;
if (typeof slot !== 'number' || !Number.isInteger(slot) || slot < 1 || slot > MAX_BRAND_COLORS) continue;
if (!isValidHex(hex)) continue;
if (!bySlot.has(slot)) bySlot.set(slot, hex.trim());
}
return [...bySlot.entries()].sort((a, b) => a[0] - b[0]).map(([slot, hex]) => ({ slot, hex }));
}
/** The brand slot's current hex, or null when no published brand colour holds that slot. */
export function brandHexForSlot(swatches: unknown, slot: number): string | null {
return publishedBrandSwatches(swatches).find((b) => b.slot === slot)?.hex ?? null;
}
/**
* `;--wsb-brand-1:#...;--wsb-brand-3:#...` (leading `;`, slot order) appended
* to the token declaration list, or '' when there are none -- so sites without
* brand colours emit exactly the bytes they always did. The hex is already
* validated to `#`+hexdigits, so it needs no further escaping.
*/
export function brandTokenDecls(swatches: unknown): string {
return publishedBrandSwatches(swatches).map((b) => `;--wsb-brand-${b.slot}:${b.hex}`).join('');
}
/** The value a field should store when this swatch is picked: the live token for brand colours, the hex otherwise. */
export function swatchValue(s: SavedSwatch): string {
return s.brand && publishedBrandSwatches([s]).length === 1 ? brandTokenRef(s.slot as number) : s.hex;
}
export function freeBrandSlot(swatches: SavedSwatch[]): number | null {
const used = new Set(swatches.filter((s) => s.brand && typeof s.slot === 'number').map((s) => s.slot));
for (let i = 1; i <= MAX_BRAND_COLORS; i++) if (!used.has(i)) return i;
return null;
}
let idCounter = 0;
export function newSwatchId(): string {
idCounter += 1;
return `sw-${Date.now().toString(36)}-${idCounter.toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
}
/** Adds a swatch (hex normalised). Returns the same array when the hex is invalid or the cap is hit. */
export function addSwatch(list: SavedSwatch[], hex: string, label = '', brand = false): SavedSwatch[] {
const h = normalizeHex(hex);
if (!h || list.length >= MAX_SWATCHES) return list;
const sw: SavedSwatch = { id: newSwatchId(), label: label.trim().slice(0, 40), hex: h, brand: false };
const next = [...list, sw];
return brand ? setSwatchBrand(next, sw.id, true) : next;
}
export function updateSwatch(list: SavedSwatch[], id: string, patch: { label?: string; hex?: string }): SavedSwatch[] {
return list.map((s) => {
if (s.id !== id) return s;
const out = { ...s };
if (patch.label !== undefined) out.label = patch.label.slice(0, 40);
if (patch.hex !== undefined) {
const h = normalizeHex(patch.hex);
if (h) out.hex = h;
}
return out;
});
}
export function removeSwatch(list: SavedSwatch[], id: string): SavedSwatch[] {
return list.filter((s) => s.id !== id);
}
/** Marks/unmarks a swatch as brand. Marking takes the lowest free slot; returns the list unchanged when all 6 are used. */
export function setSwatchBrand(list: SavedSwatch[], id: string, brand: boolean): SavedSwatch[] {
const target = list.find((s) => s.id === id);
if (!target) return list;
if (!brand) return list.map((s) => (s.id === id ? { id: s.id, label: s.label, hex: s.hex, brand: false } : s));
if (target.brand && typeof target.slot === 'number') return list;
const slot = freeBrandSlot(list);
if (slot === null) return list;
return list.map((s) => (s.id === id ? { ...s, brand: true, slot } : s));
}
/** Resolves a field value to the hex it will paint, for brand refs; null when not a brand ref or unresolved. */
export function resolveBrandRef(v: string, swatches: unknown): string | null {
const slot = brandSlotOf(v);
return slot === null ? null : brandHexForSlot(swatches, slot);
}
/** Matching saved swatch for a raw colour (case-insensitive hex, alpha-aware) - brand ones first. */
export function findSwatchByHex(list: SavedSwatch[], hex: string): SavedSwatch | undefined {
const h = normalizeHex(hex);
if (!h) return undefined;
const same = list.filter((s) => s.hex.toLowerCase() === h);
return same.find((s) => s.brand) ?? same[0];
}
/** How many times `value` (a ref or hex) is used in a serialized state string. */
export function countBrandRefUses(serialized: string, slot: number): number {
const needle = brandTokenRef(slot);
let n = 0, i = 0;
while ((i = serialized.indexOf(needle, i)) !== -1) { n++; i += needle.length; }
return n;
}
+5 -2
View File
@@ -1,5 +1,6 @@
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
@@ -90,6 +91,8 @@ export function tokenKeyOf(v: unknown): TokenKey | null {
/** 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;
@@ -120,7 +123,7 @@ export function buildCanvasTokenCss(design: SiteDesign): string {
}
/**
* The 16 `--wsb-*` declarations (`;`-joined), shared by the canvas and the
* 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()`.
@@ -129,5 +132,5 @@ 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(';');
}).join(';') + brandTokenDecls(design.swatches);
}