From faecc76384702341ed02af9ca97caa9e72c8dc5b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:49:53 -0800 Subject: [PATCH] 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 --- craft/src/state/SiteDesignContext.tsx | 5 + craft/src/utils/swatches.test.ts | 113 +++++++++++++++++++ craft/src/utils/swatches.ts | 154 ++++++++++++++++++++++++++ craft/src/utils/tokens.ts | 7 +- 4 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 craft/src/utils/swatches.test.ts create mode 100644 craft/src/utils/swatches.ts diff --git a/craft/src/state/SiteDesignContext.tsx b/craft/src/state/SiteDesignContext.tsx index 9aa501a..35a4f46 100644 --- a/craft/src/state/SiteDesignContext.tsx +++ b/craft/src/state/SiteDesignContext.tsx @@ -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({ diff --git a/craft/src/utils/swatches.test.ts b/craft/src/utils/swatches.test.ts new file mode 100644 index 0000000..260e671 --- /dev/null +++ b/craft/src/utils/swatches.test.ts @@ -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 => ({ 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(); + }); +}); diff --git a/craft/src/utils/swatches.ts b/craft/src/utils/swatches.ts new file mode 100644 index 0000000..147adf9 --- /dev/null +++ b/craft/src/utils/swatches.ts @@ -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-` + * 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-). */ + 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(); + for (const s of swatches) { + if (!s || typeof s !== 'object') continue; + const { brand, slot, hex } = s as Record; + 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; +} diff --git a/craft/src/utils/tokens.ts b/craft/src/utils/tokens.ts index 3ba315c..4947d67 100644 --- a/craft/src/utils/tokens.ts +++ b/craft/src/utils/tokens.ts @@ -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); }