diff --git a/craft/package.json b/craft/package.json
index f8d4921..bb63423 100644
--- a/craft/package.json
+++ b/craft/package.json
@@ -14,6 +14,8 @@
"test:browser:bg": "node scripts/e2e-background.mjs",
"test:browser:canvas": "node scripts/e2e-canvas-parity.mjs",
"test:browser:mobile": "node scripts/e2e-mobile-parity.mjs",
+ "test:browser:colorpick": "node scripts/e2e-color-pick.mjs",
+ "test:browser:colors": "node scripts/e2e-color-system.mjs",
"test:unit:watch": "vitest"
},
"dependencies": {
diff --git a/craft/scripts/e2e-color-pick.mjs b/craft/scripts/e2e-color-pick.mjs
new file mode 100644
index 0000000..11180b0
--- /dev/null
+++ b/craft/scripts/e2e-color-pick.mjs
@@ -0,0 +1,175 @@
+#!/usr/bin/env node
+/**
+ * Real-browser test (headless Firefox AND Chromium) for src/utils/color-pick.ts:
+ * pickFromCanvas must return the exact computed hexes + site/brand matches,
+ * Esc must cancel, a pick click must NOT reach the clicked element (no craft
+ * node selection), and EyeDropper availability is reported per browser.
+ * npm run test:browser:colorpick (CHROMIUM_PATH / FIREFOX_PATH override binaries)
+ */
+import { build } from 'esbuild';
+import { chromium, firefox } from '@playwright/test';
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+import http from 'node:http';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const out = await build({
+ entryPoints: [path.join(root, 'src/utils/color-pick.ts')],
+ bundle: true, write: false, format: 'iife', globalName: 'cp', platform: 'browser', logLevel: 'error',
+});
+const js = out.outputFiles[0].text;
+
+const design = {
+ primaryColor: '#3b82f6', secondaryColor: '#64748b', accentColor: '#f59e0b', linkColor: '#2563eb',
+ successColor: '#16a34a', warningColor: '#d97706', errorColor: '#dc2626',
+ backgroundColor: '#ffffff', textColor: '#111827', mutedTextColor: '#6b7280', borderColor: '#e5e7eb',
+ swatches: [
+ { id: 'b1', label: 'Logo orange', hex: '#FF6600', brand: true, slot: 3 },
+ { id: 'p1', label: 'Sand', hex: '#c8b08a', brand: false },
+ ],
+};
+
+const html = `
+
+
token bg
+
brand bg
+
swatch bg
+
plain + border
+
+
alpha
+
header zone preview (pointer-events:none, like the editor's header/footer preview)
+
muted text on frame bg
+
+editor chrome
+`;
+
+// Served over http://localhost (a secure context) so EyeDropper availability is reported truthfully.
+const server = http.createServer((_q, r) => { r.setHeader('content-type', 'text/html'); r.end(html); });
+await new Promise((res) => server.listen(0, '127.0.0.1', res));
+const url = `http://localhost:${server.address().port}/`;
+
+async function run(name, launcher, exe) {
+ const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
+ const page = await browser.newPage({ viewport: { width: 900, height: 900 } });
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(String(e)));
+ await page.goto(url);
+ await page.addScriptTag({ content: js });
+ await page.evaluate((d) => {
+ window.__design = d;
+ window.__leaks = [];
+ for (const el of document.querySelectorAll('.canvas-device-frame, .canvas-device-frame *'))
+ for (const t of ['click', 'mousedown', 'mouseup', 'pointerdown', 'pointerup'])
+ el.addEventListener(t, () => window.__leaks.push(t + '#' + el.id));
+ }, design);
+
+ let failed = 0;
+ const check = (label, ok, detail) => {
+ if (!ok) failed++;
+ console.log(`[${name}] ${ok ? 'PASS' : 'FAIL'} ${label}${detail !== undefined ? ': ' + detail : ''}`);
+ };
+ const eq = (label, got, want) => check(label, JSON.stringify(got) === JSON.stringify(want), JSON.stringify(got) + (JSON.stringify(got) === JSON.stringify(want) ? '' : ' expected ' + JSON.stringify(want)));
+
+ async function pick(id, { hover } = {}) {
+ await page.evaluate(() => { window.__res = null; cp.pickFromCanvas({ design: window.__design }).then((r) => { window.__res = r; }); });
+ const box = await page.locator('#' + id).boundingBox();
+ const x = box.x + 8, y = box.y + 8;
+ await page.mouse.move(x, y);
+ let tip = null;
+ if (hover) tip = await page.evaluate(() => document.querySelector('[data-wsb-pick=tip]')?.textContent ?? null);
+ await page.mouse.click(x, y);
+ await page.waitForFunction(() => window.__res !== null, null, { timeout: 3000 });
+ return { res: await page.evaluate(() => window.__res), tip };
+ }
+ const cand = (res, k) => res.candidates.find((c) => c.kind === k);
+
+ // 1. token-driven background (CSS variable)
+ let { res, tip } = await pick('tok', { hover: true });
+ eq('token bg: hex', res.hex, '#3b82f6');
+ eq('token bg: background match', cand(res, 'background').match, { kind: 'token', ref: 'var(--wsb-primary)', label: 'Primary' });
+ eq('token bg: text candidate', cand(res, 'text').hex, '#ffffff');
+ check('token bg: hover tooltip shows live colour', tip === '#3b82f6', String(tip));
+ eq('token bg: no border candidate', cand(res, 'border'), undefined);
+
+ // 2. brand swatch (case-insensitive hex)
+ ({ res } = await pick('brand'));
+ eq('brand: hex', res.hex, '#ff6600');
+ eq('brand: match', cand(res, 'background').match, { kind: 'swatch', ref: 'var(--wsb-brand-3)', label: 'Logo orange' });
+
+ // 3. plain saved swatch via rgba(...,1)
+ ({ res } = await pick('sand'));
+ eq('swatch: hex', res.hex, '#c8b08a');
+ eq('swatch: match', cand(res, 'background').match, { kind: 'swatch', ref: '#c8b08a', label: 'Sand' });
+
+ // 4. plain bg + text + border, no match
+ ({ res } = await pick('plain'));
+ eq('plain: hex = bg', res.hex, '#123456');
+ eq('plain: text', cand(res, 'text').hex, '#fedcba');
+ eq('plain: border', cand(res, 'border').hex, '#0a0b0c');
+ eq('plain: no matches', res.candidates.map((c) => c.match), [undefined, undefined, undefined]);
+
+ // 5. nested transparent child: text colour wins, background = painted ancestor
+ ({ res } = await pick('deep'));
+ eq('nested: hex = inherited text colour', res.hex, '#778899');
+ eq('nested: background = nearest painted ancestor', cand(res, 'background').hex, '#112233');
+ eq('nested: no border', cand(res, 'border'), undefined);
+
+ // 6. alpha background -> 8-digit hex
+ ({ res } = await pick('alpha'));
+ eq('alpha: hex', res.hex, '#12345680');
+
+ // 7. text on frame bg (frame background is the painted ancestor, matches Background token)
+ ({ res } = await pick('txt'));
+ eq('text: hex', res.hex, '#6b7280');
+ eq('text: text match', cand(res, 'text').match, { kind: 'token', ref: 'var(--wsb-muted)', label: 'Muted text' });
+ eq('text: background match', cand(res, 'background').match, { kind: 'token', ref: 'var(--wsb-bg)', label: 'Background' });
+
+ // 7b. the editor's header/footer zone previews are pointer-events:none: they must still be pickable
+ ({ res } = await pick('zone'));
+ eq('zone preview: hex', res.hex, '#15202c');
+ eq('zone preview: text candidate', cand(res, 'text').hex, '#ffffff');
+
+ // 8. none of those pick clicks reached the elements (no node selection)
+ eq('clicks never reached targets', await page.evaluate(() => window.__leaks), []);
+
+ // 9. Esc cancels; overlay cleaned up
+ await page.evaluate(() => { window.__res = null; cp.pickFromCanvas({ design: window.__design }).then((r) => { window.__res = r; }); });
+ await page.keyboard.press('Escape');
+ await page.waitForFunction(() => window.__res !== null, null, { timeout: 3000 });
+ const esc = await page.evaluate(() => window.__res);
+ check('Esc cancels', esc.ok === false && esc.reason === 'cancelled', JSON.stringify(esc));
+ eq('overlay removed', await page.evaluate(() => document.querySelectorAll('[data-wsb-pick]').length), 0);
+
+ // 10. click outside the frame cancels
+ await page.evaluate(() => { window.__res = null; cp.pickFromCanvas({ design: window.__design }).then((r) => { window.__res = r; }); });
+ const cb = await page.locator('#chrome').boundingBox();
+ await page.mouse.click(cb.x + 5, cb.y + 5);
+ await page.waitForFunction(() => window.__res !== null, null, { timeout: 3000 });
+ check('outside click cancels', (await page.evaluate(() => window.__res)).reason === 'cancelled');
+
+ // 11. after a pick finishes, normal clicks flow again
+ await page.evaluate(() => { window.__leaks.length = 0; });
+ await page.locator('#tok').click();
+ check('clicks flow normally after picking', (await page.evaluate(() => window.__leaks)).includes('click#tok'));
+
+ // 12. EyeDropper availability (report)
+ const eye = await page.evaluate(() => ({ present: 'EyeDropper' in window, available: cp.eyedropperAvailable(), kind: cp.screenPickKind() }));
+ console.log(`[${name}] INFO EyeDropper: window.EyeDropper=${eye.present} eyedropperAvailable()=${eye.available} screenPickKind()=${eye.kind}`);
+ check('secure context', await page.evaluate(() => isSecureContext));
+ if (name === 'firefox') check('firefox: no screen pick (verdict C)', eye.kind === 'none');
+ else check('chromium: screenPickKind matches window.EyeDropper', eye.kind === (eye.present ? 'eyedropper' : 'none'));
+
+ if (errors.length) { failed++; console.log('page errors:', errors.join('\n')); }
+ await browser.close();
+ return failed;
+}
+let total = 0;
+total += await run('chromium', chromium, process.env.CHROMIUM_PATH);
+total += await run('firefox', firefox, process.env.FIREFOX_PATH);
+server.close();
+console.log(total ? `FAILED (${total})` : 'ALL PASS');
+process.exit(total ? 1 : 0);
diff --git a/craft/scripts/e2e-color-system-backend.php b/craft/scripts/e2e-color-system-backend.php
new file mode 100644
index 0000000..7687e00
--- /dev/null
+++ b/craft/scripts/e2e-color-system-backend.php
@@ -0,0 +1,14 @@
+
+ */
+ob_start();
+define('SB_API_LIBRARY_ONLY', true);
+define('AUTH_USER', 'alice');
+define('HOME_DIR', '/docker/users/alice');
+require rtrim($argv[1], '/') . '/web-files/api/site-builder.php';
+ob_end_clean();
+$in = json_decode(stream_get_contents(STDIN), true);
+$U = 'https://example.com/';
+echo generateCompiledHTML($in['title'], $in['css'], $in['html'], $in['headCode'], [], '', $in['design'], $U, sb_canonical_url($U, 'index.html'));
diff --git a/craft/scripts/e2e-color-system-entry.ts b/craft/scripts/e2e-color-system-entry.ts
new file mode 100644
index 0000000..33a0c2e
--- /dev/null
+++ b/craft/scripts/e2e-color-system-entry.ts
@@ -0,0 +1,25 @@
+// Bundled (esbuild) by scripts/e2e-color-system.mjs and run in Node: what a publish of a saved project.json
+// emits through the FRONTEND path (renderPublished = the editor Preview document), plus the pieces the
+// BACKEND path receives (the composed body HTML), so the two heads can be compared.
+import { exportBodyHtml, exportComposedPreviewHtml } from '../src/utils/html-export';
+
+function bodies(project: any, pageIdx: number) {
+ const page = project.pages_craft_state[pageIdx];
+ const hdr = project.header_craft_state ? exportBodyHtml(project.header_craft_state, 'header').html : '';
+ const ftr = project.footer_craft_state ? exportBodyHtml(project.footer_craft_state, 'footer').html : '';
+ const body = exportBodyHtml(page.craftState).html;
+ return { page, composed: hdr + body + ftr };
+}
+
+export function renderPublished(project: any, pageIdx: number): string {
+ const { page, composed } = bodies(project, pageIdx);
+ return exportComposedPreviewHtml(page.craftState, composed, {
+ title: page.name, includeFonts: true, headCode: project.design?.headCode, design: project.design,
+ });
+}
+
+/** The inputs handlePublish feeds generateCompiledHTML for a page. */
+export function backendInputs(project: any, pageIdx: number) {
+ const { page, composed } = bodies(project, pageIdx);
+ return { title: page.name, css: '', html: composed, headCode: project.design?.headCode || '', design: project.design || {} };
+}
diff --git a/craft/scripts/e2e-color-system-fixture.mjs b/craft/scripts/e2e-color-system-fixture.mjs
new file mode 100644
index 0000000..9653c65
--- /dev/null
+++ b/craft/scripts/e2e-color-system-fixture.mjs
@@ -0,0 +1,31 @@
+/** Project fixture for the colour-system e2e: an OLS-Digital-like dark header (#0f172a) with a logo box (#15202c) and a small page. */
+const node = (name, props, parent, nodes = [], isCanvas = false) => ({
+ type: { resolvedName: name }, isCanvas, props, displayName: name, custom: {}, parent, hidden: false, nodes, linkedNodes: {},
+});
+const base = { anchorId: '', cssId: '', cssClass: '', animation: '', animationDelay: '0', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false };
+const box = (id, style, aiName) => ({ style: { padding: '20px', minHeight: '60px', ...style }, tag: 'div', fullWidth: false, contentWidth: 'full', ...base, aiName, node_id: id });
+
+export function buildProject() {
+ const header = {
+ ROOT: node('Container', box('hdr-root', { backgroundColor: '#0f172a' }, 'Header'), null, ['logo-box'], true),
+ 'logo-box': node('Container', box('logo-box', { backgroundColor: '#15202c', minHeight: '50px' }, 'Logo box'), 'ROOT', [], true),
+ };
+ const page = {
+ ROOT: node('Container', box('page-root', {}, 'Root'), null, ['target-box', 'copy'], true),
+ // The element the brand colour is applied to.
+ 'target-box': node('Container', box('target-box', { backgroundColor: '#ffffff', minHeight: '80px' }, 'Target box'), 'ROOT', [], true),
+ copy: node('TextBlock', { text: 'Hello colour system', html: '', style: { fontSize: '16px', color: '#111111' }, ...base, aiName: 'Copy', node_id: 'copy' }, 'ROOT'),
+ };
+ return {
+ site_id: 1, name: 'Colours', html: '', css: '', pages: [], header_html: '', footer_html: '', head_code: '',
+ craft_state: '', header_craft_state: JSON.stringify(header), footer_craft_state: '',
+ pages_craft_state: [{ id: 'home', name: 'Home', slug: 'index', craftState: JSON.stringify(page) }],
+ design: {
+ primaryColor: '#3b82f6', secondaryColor: '#8b5cf6', accentColor: '#10b981', headingFont: 'Inter, sans-serif', bodyFont: 'Inter, sans-serif',
+ linkColor: '#3b82f6', successColor: '#10b981', warningColor: '#f59e0b', errorColor: '#ef4444', backgroundColor: '#ffffff',
+ textColor: '#1f2937', mutedTextColor: '#6b7280', borderColor: '#e5e7eb', borderRadius: '8px', buttonFont: 'Inter, sans-serif',
+ buttonRadius: '8px', navStyle: 'light', headCode: '', favicon: '',
+ },
+ extras: {},
+ };
+}
diff --git a/craft/scripts/e2e-color-system-server.mjs b/craft/scripts/e2e-color-system-server.mjs
new file mode 100644
index 0000000..6655d41
--- /dev/null
+++ b/craft/scripts/e2e-color-system-server.mjs
@@ -0,0 +1,106 @@
+/**
+ * Mock WHP site-builder API + static server for the real-editor colour-system e2e
+ * (scripts/e2e-color-system.mjs). Unlike the parity mock it STORES what the editor
+ * saves (so a reload restores it), and serves a same-origin PNG through the
+ * list_assets / upload_asset endpoints in the shape the real api/site-builder.php returns.
+ */
+import http from 'node:http';
+import fs from 'node:fs';
+import path from 'node:path';
+import zlib from 'node:zlib';
+
+function crc32(buf) {
+ let c, crc = 0xffffffff;
+ for (let n = 0; n < buf.length; n++) {
+ c = (crc ^ buf[n]) & 0xff;
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ crc = (crc >>> 8) ^ c;
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+}
+function chunk(type, data) {
+ const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
+ const td = Buffer.concat([Buffer.from(type), data]);
+ const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
+ return Buffer.concat([len, td, crc]);
+}
+/** w*h RGB PNG; px(x,y) -> [r,g,b]. */
+export function makePng(w, h, px) {
+ const raw = Buffer.alloc((w * 3 + 1) * h);
+ for (let y = 0; y < h; y++) {
+ raw[y * (w * 3 + 1)] = 0;
+ for (let x = 0; x < w; x++) {
+ const [r, g, b] = px(x, y);
+ const o = y * (w * 3 + 1) + 1 + x * 3;
+ raw[o] = r; raw[o + 1] = g; raw[o + 2] = b;
+ }
+ }
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
+ return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
+}
+
+/** 100x100 test image: four flat quadrants + a few colour bands so palette extraction has >= 5 colours. */
+export const TEST_PNG_COLORS = {
+ tl: [255, 0, 0], tr: [0, 0, 255], bl: [0, 160, 0], br: [255, 200, 0],
+ band1: [128, 0, 128], band2: [0, 200, 200], band3: [40, 40, 40],
+};
+export function testPng() {
+ const C = TEST_PNG_COLORS;
+ return makePng(100, 100, (x, y) => {
+ if (y >= 90) return x < 34 ? C.band1 : x < 67 ? C.band2 : C.band3;
+ return y < 45 ? (x < 50 ? C.tl : C.tr) : (x < 50 ? C.bl : C.br);
+ });
+}
+
+export async function startServer({ buildDir, project, siteId = 1, extraAssets = [] }) {
+ const state = { project, rev: 1, saves: 0, lastSaveBody: null, assets: [{ name: 'test.png', type: 'image/png', buf: testPng() }] };
+ const mime = { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.png': 'image/png', '.svg': 'image/svg+xml' };
+ const json = (res, o, code = 200) => { res.statusCode = code; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(o)); };
+ const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname === '/api/site-builder.php') {
+ const a = u.searchParams.get('action');
+ if (a === 'load') return json(res, { success: true, rev: 'r' + state.rev, limits: {}, project: state.project });
+ if (a === 'list_assets') {
+ return json(res, { success: true, assets: [...extraAssets, ...state.assets.map((s) => ({ name: s.name, url: `/api/assets-proxy/?site_id=${siteId}&filename=${encodeURIComponent(s.name)}`, type: s.type, size: s.buf.length, modified: 1 }))] });
+ }
+ if (a === 'save' && req.method === 'POST') {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
+ const { base_rev, client_id, force_empty, force_overwrite, ...proj } = body;
+ state.project = proj; state.lastSaveBody = body; state.saves++; state.rev++;
+ json(res, { success: true, rev: 'r' + state.rev });
+ });
+ return;
+ }
+ if (a === 'upload_asset' && req.method === 'POST') {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => json(res, { success: false, error: 'uploads not supported by the test mock' }, 400));
+ return;
+ }
+ return json(res, { success: true });
+ }
+ if (u.pathname === '/api/assets-proxy/') {
+ const a = state.assets.find((s) => s.name === u.searchParams.get('filename'));
+ if (!a) { res.statusCode = 404; return res.end('nf'); }
+ res.setHeader('content-type', a.type); return res.end(a.buf);
+ }
+ const p = u.pathname === '/' ? '/index.html' : u.pathname;
+ if (p === '/index.html') {
+ const cfg = { user: 't', apiUrl: '/api/site-builder.php', csrfToken: 'x', siteId, siteDomain: 'example.test', siteName: 'Colours', backUrl: '/', isRoot: false };
+ const h = fs.readFileSync(path.join(buildDir, 'index.html'), 'utf8').replace('', () => ``);
+ res.setHeader('content-type', 'text/html'); return res.end(h);
+ }
+ const f = path.join(buildDir, p);
+ if (f.startsWith(buildDir) && fs.existsSync(f) && fs.statSync(f).isFile()) {
+ res.setHeader('content-type', mime[path.extname(f)] || 'application/octet-stream'); return res.end(fs.readFileSync(f));
+ }
+ res.statusCode = 404; res.end('nf');
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return { server, state, base: `http://localhost:${server.address().port}` };
+}
diff --git a/craft/scripts/e2e-color-system.mjs b/craft/scripts/e2e-color-system.mjs
new file mode 100644
index 0000000..79fa70d
--- /dev/null
+++ b/craft/scripts/e2e-color-system.mjs
@@ -0,0 +1,437 @@
+#!/usr/bin/env node
+/**
+ * REAL-EDITOR end-to-end test of the whole site colour system, headless Firefox AND Chromium.
+ * Mounts the real editor (vite build + a mock WHP API that STORES saves, so reload restores them) and drives
+ * the real UI: hex entry, pick from page / image, palette from image, persistence across reload, live brand
+ * recolour (canvas == published by the frontend export == published by the backend generateCompiledHTML),
+ * brand cap + guarded delete, contrast notes, and EyeDropper availability.
+ *
+ * npm run test:browser:colors
+ *
+ * Env: CHROMIUM_PATH / FIREFOX_PATH override the Playwright-pinned binaries
+ * COLOR_SHOTS= write Firefox screenshots (shot-*.png) there
+ * WHP_BACKEND_DIR backend worktree for the generateCompiledHTML leg (default /workspace/whp-wt-color-system)
+ */
+import { chromium, firefox } from '@playwright/test';
+import { build as esbuild } from 'esbuild';
+import { execFileSync } from 'node:child_process';
+import http from 'node:http';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { startServer, testPng, TEST_PNG_COLORS } from './e2e-color-system-server.mjs';
+import { buildProject } from './e2e-color-system-fixture.mjs';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const BACKEND_DIR = process.env.WHP_BACKEND_DIR || '/workspace/whp-wt-color-system';
+const BASE_COMMIT = 'ab7a100';
+const SHOTS = process.env.COLOR_SHOTS || '';
+if (SHOTS) fs.mkdirSync(SHOTS, { recursive: true });
+
+// ---------------------------------------------------------------- frontend exporter (HEAD, and the pre-feature base commit)
+async function loadExporter(entryDir, tag) {
+ const out = await esbuild({
+ entryPoints: [path.join(entryDir, 'scripts', tag === 'base' ? 'e2e-canvas-export-entry.ts' : 'e2e-color-system-entry.ts')],
+ bundle: true, write: false, format: 'esm', platform: 'node', jsx: 'automatic', logLevel: 'error', absWorkingDir: entryDir,
+ define: { 'process.env.NODE_ENV': '"production"', __EDITOR_BUILD__: '"test"' },
+ banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" },
+ });
+ const f = path.join(os.tmpdir(), `wsb-colors-export-${tag}-${process.pid}.mjs`);
+ fs.writeFileSync(f, out.outputFiles[0].text);
+ const m = await import(f);
+ fs.rmSync(f, { force: true });
+ return m;
+}
+const head = await loadExporter(root, 'head');
+let baseExporter = null;
+const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsb-colors-base-'));
+try {
+ const top = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: root }).toString().trim();
+ const prefix = path.relative(top, root); // 'craft'
+ execFileSync('bash', ['-c', `git -C '${top}' archive ${BASE_COMMIT} ${prefix}/src ${prefix}/scripts/e2e-canvas-export-entry.ts ${prefix}/package.json ${prefix}/tsconfig.json | tar -x -C '${baseDir}'`]);
+ fs.symlinkSync(path.join(root, 'node_modules'), path.join(baseDir, prefix, 'node_modules'));
+ baseExporter = await loadExporter(path.join(baseDir, prefix), 'base');
+} catch (e) { console.log('NOTE base-commit exporter unavailable:', String(e.message).split('\n')[0]); }
+
+function backendHtml(project, idx = 0) {
+ const inp = head.backendInputs(project, idx);
+ return execFileSync('php', [path.join(root, 'scripts/e2e-color-system-backend.php'), BACKEND_DIR], { input: JSON.stringify(inp), maxBuffer: 1 << 26 }).toString();
+}
+const haveBackend = fs.existsSync(path.join(BACKEND_DIR, 'web-files/api/site-builder.php'));
+const rootDecl = (html) => (html.match(/:root\{[^}]*\}/) || [''])[0];
+
+// ---------------------------------------------------------------- build the real editor once
+const buildDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsb-colors-'));
+execFileSync(path.join(root, 'node_modules/.bin/vite'), ['build', '--outDir', buildDir, '--emptyOutDir', '--logLevel', 'error'], { cwd: root, stdio: 'inherit' });
+
+// A second origin serving the same PNG without CORS headers = "an image from another website".
+const remote = http.createServer((_q, r) => { r.setHeader('content-type', 'image/png'); r.end(testPng()); });
+await new Promise((r) => remote.listen(0, '127.0.0.1', r));
+const REMOTE_URL = `http://127.0.0.1:${remote.address().port}/remote.png`;
+
+const toHex = (rgb) => {
+ const m = /rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)/.exec(rgb);
+ return m ? '#' + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('') : rgb;
+};
+const hx = (c) => '#' + c.map((n) => n.toString(16).padStart(2, '0')).join('');
+
+// ---------------------------------------------------------------- driver
+async function run(name, launcher, exe) {
+ const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
+ const ctx = await browser.newContext({ viewport: { width: 1500, height: 1000 } });
+ await ctx.route(/^https?:\/\/(?!127\.0\.0\.1|localhost)/, (r) => r.abort());
+ const { server, state, base } = await startServer({
+ buildDir, project: buildProject(),
+ extraAssets: [{ name: 'remote.png', url: REMOTE_URL, type: 'image/png', size: 1, modified: 2 }],
+ });
+ const page = await ctx.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(String(e)));
+ const dialogs = [];
+ page.on('dialog', (d) => { dialogs.push(d.message()); d.dismiss().catch(() => {}); });
+
+ let failed = 0;
+ const check = (label, ok, detail) => {
+ if (!ok) failed++;
+ console.log(`[${name}] ${ok ? 'PASS' : 'FAIL'} ${label}${detail !== undefined ? ': ' + detail : ''}`);
+ };
+ const eq = (label, got, want) => {
+ const ok = JSON.stringify(got) === JSON.stringify(want);
+ check(label, ok, ok ? JSON.stringify(got) : `${JSON.stringify(got)} expected ${JSON.stringify(want)}`);
+ };
+ const shot = async (file) => { if (SHOTS && name === 'firefox') await page.screenshot({ path: path.join(SHOTS, file) }); };
+ const settle = (ms = 250) => page.waitForTimeout(ms);
+
+ // ---- page helpers
+ const frame = '.canvas-device-frame';
+ const targetSel = `${frame} [data-craft-container][style*="min-height: 80px"]`;
+ const logoSel = `${frame} [data-zone-preview=header] > div > div`;
+ const sm = page.locator('[data-testid=swatch-manager]');
+ const openSite = async () => { await page.click('#right-tab-site'); await sm.scrollIntoViewIfNeeded(); await settle(); };
+ const openElement = async () => { await page.click('#right-tab-element'); await settle(); };
+ const rows = () => sm.locator('[data-swatch-row]');
+ const rowData = async () => rows().evaluateAll((els) => els.map((e) => ({
+ id: e.getAttribute('data-swatch-row'),
+ label: e.querySelector('[data-field=label]').value,
+ hex: e.querySelector('[data-field=hex]').value,
+ brand: e.querySelector('[data-action=brand]').getAttribute('aria-pressed') === 'true',
+ live: !!e.querySelector('[data-testid=live-badge]'),
+ })));
+ const loadEditor = async () => {
+ await page.goto(base + '/');
+ await page.waitForSelector(`${frame} [data-craft-container]`, { timeout: 20000 });
+ await settle(1200);
+ };
+ const selectTarget = async () => { await page.locator(targetSel).click({ position: { x: 30, y: 30 } }); await settle(400); await page.click('#right-tab-element'); await settle(300); };
+ const fieldOf = (label) => page.locator('.panel-right .guided-section', { hasText: label }).first();
+ const canvasBg = (sel) => page.locator(sel).evaluate((e) => getComputedStyle(e).backgroundColor);
+ const save = async () => {
+ const before = state.saves;
+ await page.getByRole('button', { name: 'Save Draft' }).click();
+ await page.waitForFunction(() => /Saved/.test(document.body.innerText), null, { timeout: 8000 }).catch(() => {});
+ for (let i = 0; i < 40 && state.saves === before; i++) await settle(150);
+ check('save went through the editor save path', state.saves > before, `saves=${state.saves}`);
+ };
+ const pageNodes = () => JSON.parse(state.project.pages_craft_state[0].craftState);
+
+ // ================================================================= 8 (first: needs only a loaded page) EyeDropper
+ await loadEditor();
+ const eye = await page.evaluate(() => ({ present: 'EyeDropper' in window, secure: isSecureContext }));
+ await openSite();
+ const screenBtnsSite = await sm.getByRole('button', { name: 'Pick from screen' }).count();
+ console.log(`[${name}] INFO window.EyeDropper present=${eye.present} secureContext=${eye.secure}`);
+ if (name === 'firefox') eq('8 Firefox: Pick from screen button ABSENT', screenBtnsSite, 0);
+ else eq(`8 Chromium: Pick from screen present only if window.EyeDropper (EyeDropper=${eye.present})`, screenBtnsSite, eye.present ? 1 : 0);
+
+ // ================================================================= 1 Hex entry
+ await sm.locator('[data-field=new-hex]').fill('zz');
+ await sm.getByRole('button', { name: 'Add colour' }).click();
+ await settle();
+ const hexErr = await sm.locator('[role=alert].color-hex-error').allInnerTexts();
+ eq('1 invalid hex "zz" rejected with message', hexErr, ['Enter a hex colour like #3b82f6 (3, 4, 6 or 8 digits).']);
+ eq('1 invalid hex added no row', (await rowData()).length, 0);
+ await sm.locator('[data-field=new-hex]').fill('#15202C');
+ await sm.locator('[data-field=new-label]').fill('Logo box');
+ await sm.getByRole('button', { name: 'Add colour' }).click();
+ await settle();
+ let rd = await rowData();
+ eq('1 "#15202C" + label added, hex normalised', rd.map((r) => [r.label, r.hex]), [['Logo box', '#15202c']]);
+ eq('1 error cleared after a valid add', await sm.locator('[role=alert].color-hex-error').count(), 0);
+ for (const [typed, want] of [['#abc', '#abc'], ['#11223344', '#11223344'], ['f60', '#f60']]) {
+ await sm.locator('[data-field=new-hex]').fill(typed);
+ await sm.getByRole('button', { name: 'Add colour' }).click();
+ await settle(150);
+ rd = await rowData();
+ eq(`1 form "${typed}" accepted`, rd[rd.length - 1].hex, want);
+ }
+ // keep only Logo box
+ for (let i = 0; i < 3; i++) { await rows().last().locator('[data-action=delete]').click(); await settle(150); }
+ eq('1 cleanup leaves only Logo box', (await rowData()).map((r) => r.hex), ['#15202c']);
+
+ // ================================================================= 2 Pick from page
+ const logoBox = await page.locator(logoSel).first().boundingBox();
+ const hdrHex = await page.locator(`${frame} [data-zone-preview=header] > div`).first().evaluate((e) => getComputedStyle(e).backgroundColor);
+ eq('2 fixture: header paints #0f172a', toHex(hdrHex), '#0f172a');
+ const designBefore = JSON.stringify(await page.evaluate(() => null));
+ const stylesBefore = await page.locator(targetSel).getAttribute('style');
+ await sm.getByRole('button', { name: 'Pick from page' }).click();
+ await settle(300);
+ await page.mouse.move(logoBox.x + logoBox.width / 2, logoBox.y + logoBox.height / 2);
+ await settle(200);
+ const tipTxt = await page.evaluate(() => document.querySelector('[data-wsb-pick=tip]')?.textContent ?? null);
+ check('2 pick mode shows a live tooltip while hovering the logo box', !!tipTxt && /#15202c/i.test(tipTxt), String(tipTxt));
+ await shot('shot-canvas-pick-mode-tooltip.png');
+ await page.mouse.click(logoBox.x + logoBox.width / 2, logoBox.y + logoBox.height / 2);
+ await settle(400);
+ const chooser = sm.locator('[data-testid=pick-chooser]');
+ const chooserCount = await chooser.count();
+ check('2 pick from page then offers the chooser (bg + text colours differ)', chooserCount === 1);
+ const chooserBtns = chooserCount ? await chooser.locator('button').evaluateAll((bs) => bs.map((b) => b.innerText.replace(/\s+/g, ' ').trim())) : [];
+ const logoComputed = toHex(await page.locator(logoSel).first().evaluate((e) => getComputedStyle(e).backgroundColor));
+ const bgBtn = chooserBtns.find((t) => /^Background/.test(t)) || '';
+ check('2 chooser background candidate equals the element computed colour', bgBtn.includes(logoComputed), `${logoComputed} :: ${JSON.stringify(chooserBtns)}`);
+ check('2 chooser offers the match to the saved colour "Logo box"', /matches Logo box/.test(bgBtn), bgBtn);
+ await shot('shot-canvas-pick-chooser.png');
+ await chooser.getByRole('button', { name: /^Background/ }).click();
+ await settle(300);
+ rd = await rowData();
+ eq('2 picking an already-saved colour adds no duplicate', rd.length, 1);
+ check('2 note says it is already saved', /Already saved as "Logo box"/.test(await sm.locator('[role=status]').innerText()));
+ await openElement();
+ check('2 the pick click did NOT select a craft node', /Select something on the canvas/.test(await page.locator('.panel-right').innerText()));
+ eq('2 target box untouched', await page.locator(targetSel).getAttribute('style'), stylesBefore);
+
+ // token match: the page's white target box == the Background token
+ await openSite();
+ const tb = await page.locator(targetSel).boundingBox();
+ await sm.getByRole('button', { name: 'Pick from page' }).click();
+ await settle(300);
+ await page.mouse.click(tb.x + 30, tb.y + 30);
+ await settle(400);
+ // white bg + inherited text colour -> chooser; the Background candidate must report the token match
+ const ch2 = await sm.locator('[data-testid=pick-chooser] button').evaluateAll((bs) => bs.map((b) => b.innerText.replace(/\s+/g, ' ').trim()));
+ const tbComputed = toHex(await page.locator(targetSel).evaluate((e) => getComputedStyle(e).backgroundColor));
+ check('2 token match offered when the picked colour equals a site colour', ch2.some((t) => t.includes(tbComputed) && /matches Background/.test(t)), JSON.stringify(ch2));
+ await sm.locator('[data-testid=pick-chooser]').getByRole('button', { name: 'Cancel' }).click();
+ await settle(200);
+ eq('2 still no extra swatch after cancel', (await rowData()).length, 1);
+
+ // ================================================================= 3 Pick from image + palette
+ await sm.getByRole('button', { name: 'Pick from image' }).click();
+ await page.waitForSelector('[data-testid=image-pick-modal]');
+ await page.locator('[data-testid=image-asset-test\\.png]').click();
+ const cv = page.locator('[data-testid=image-pick-canvas]');
+ await page.waitForFunction(() => { const c = document.querySelector('[data-testid=image-pick-canvas]'); return c && c.style.display !== 'none' && c.width > 0; });
+ const cb = await cv.boundingBox();
+ // known pixel: 75% across, 20% down = top-right quadrant (blue)
+ const px = cb.x + cb.width * 0.75, py = cb.y + cb.height * 0.2;
+ await page.mouse.move(px, py);
+ await settle(200);
+ eq('3 hover shows the exact pixel hex', (await page.locator('[data-testid=image-pick-hex]').innerText()), hx(TEST_PNG_COLORS.tr));
+ await shot('shot-pick-from-image-modal.png');
+ await page.mouse.click(px, py);
+ await settle(300);
+ rd = await rowData();
+ eq('3 clicking the pixel saved exactly that colour', rd.map((r) => r.hex), ['#15202c', hx(TEST_PNG_COLORS.tr)]);
+ check('3 modal closed after the pick', (await page.locator('[data-testid=image-pick-modal]').count()) === 0);
+ // other quadrant for another exact value
+ await sm.getByRole('button', { name: 'Pick from image' }).click();
+ await page.locator('[data-testid=image-asset-test\\.png]').click();
+ await page.waitForFunction(() => { const c = document.querySelector('[data-testid=image-pick-canvas]'); return c && c.style.display !== 'none' && c.width > 0; });
+ const cb2 = await page.locator('[data-testid=image-pick-canvas]').boundingBox();
+ await page.mouse.click(cb2.x + cb2.width * 0.25, cb2.y + cb2.height * 0.75);
+ await settle(300);
+ rd = await rowData();
+ eq('3 bottom-left pixel exact', rd[rd.length - 1].hex, hx(TEST_PNG_COLORS.bl));
+ // cross-origin error
+ await sm.getByRole('button', { name: 'Pick from image' }).click();
+ await page.locator('[data-testid=image-asset-remote\\.png]').click();
+ await page.waitForSelector('[data-testid=image-pick-error]');
+ const xo = await page.locator('[data-testid=image-pick-error]').innerText();
+ check('3 cross-origin src shows the plain-English error', /comes from another website/.test(xo) && /Upload it to this site first/.test(xo), xo);
+ await page.locator('[data-testid=image-pick-close]').click();
+ // palette
+ const swatchesBeforePalette = (await rowData()).map((r) => r.hex);
+ await sm.getByRole('button', { name: 'Palette from image' }).click();
+ await page.waitForSelector('[data-testid=palette-from-image-modal]');
+ await page.locator('[data-testid=palette-from-image-modal] [data-testid=image-asset-test\\.png]').click();
+ await page.waitForSelector('[data-testid^=palette-row-]');
+ const palHexes = await page.locator('[data-testid^=palette-row-]').evaluateAll((els) => els.map((e) => e.getAttribute('data-testid').replace('palette-row-', '')));
+ check('3 palette shows 5-8 swatches', palHexes.length >= 5 && palHexes.length <= 8, `${palHexes.length}: ${palHexes.join(' ')}`);
+ eq('3 palette auto-applied nothing (design unchanged)', (await rowData()).map((r) => r.hex), swatchesBeforePalette);
+ await shot('shot-palette-from-image-modal.png');
+ const addHex = palHexes.find((h) => !swatchesBeforePalette.includes(h));
+ await page.locator(`[data-testid="palette-add-${addHex}"]`).click();
+ await settle(250);
+ eq('3 Add adds exactly that swatch', (await rowData()).map((r) => r.hex), [...swatchesBeforePalette, addHex]);
+ await page.locator('[data-testid=palette-close]').click();
+ // drop everything except Logo box
+ while ((await rows().count()) > 1) { await rows().last().locator('[data-action=delete]').click(); await settle(120); }
+
+ // ================================================================= 4 Persistence
+ await sm.locator('[data-field=new-hex]').fill('#ff6600');
+ await sm.locator('[data-field=new-label]').fill('Brand orange');
+ await sm.getByRole('button', { name: 'Add colour' }).click();
+ await settle(200);
+ await rows().last().locator('[data-action=brand]').click();
+ await sm.locator('[data-field=new-hex]').fill('#c8b08a');
+ await sm.locator('[data-field=new-label]').fill('Sand');
+ await sm.getByRole('button', { name: 'Add colour' }).click();
+ await settle(250);
+ const before = await rowData();
+ eq('4 three swatches before save (one brand)', before.map((r) => [r.label, r.hex, r.brand]), [['Logo box', '#15202c', false], ['Brand orange', '#ff6600', true], ['Sand', '#c8b08a', false]]);
+ // a low-contrast site colour so the Site panel shows a note in the screenshot (defaults already do)
+ await sm.scrollIntoViewIfNeeded();
+ await shot('shot-site-design-saved-colours.png');
+ await save();
+ const stored = state.project.design?.swatches;
+ check('4 stored project.json contains design.swatches', Array.isArray(stored) && stored.length === 3, JSON.stringify(stored));
+ const orangeStored = (stored || []).find((s) => s.label === 'Brand orange');
+ eq('4 stored brand swatch (hex, brand, slot)', orangeStored && [orangeStored.hex, orangeStored.brand, orangeStored.slot], ['#ff6600', true, 1]);
+ const sandStored = (stored || []).find((s) => s.label === 'Sand');
+ eq('4 stored plain swatch', sandStored && [sandStored.hex, sandStored.brand, sandStored.slot], ['#c8b08a', false, undefined]);
+ await loadEditor();
+ await openSite();
+ const after = await rowData();
+ eq('4 reload: panel restores labels/hex/brand', after.map((r) => [r.label, r.hex, r.brand]), before.map((r) => [r.label, r.hex, r.brand]));
+ eq('4 reload: brand swatch shows the live badge, others not', after.map((r) => r.live), [false, true, false]);
+ await selectTarget();
+ const bgField = fieldOf('Background Color');
+ const savedBtns = await bgField.locator('[data-swatch]').evaluateAll((bs) => bs.map((b) => [b.getAttribute('aria-label'), b.getAttribute('data-live')]));
+ eq('4 reload: saved swatch buttons present in the Background colour field', savedBtns, [['Logo box #15202c', null], ['Brand orange #ff6600 (live brand colour, var(--wsb-brand-1))', 'true'], ['Sand #c8b08a', null]]);
+ await bgField.scrollIntoViewIfNeeded();
+ await shot('shot-colour-field-saved-swatches-and-pick-tools.png');
+
+ // ================================================================= 5 Brand live recolour: canvas == frontend export == backend
+ await bgField.locator('[data-live=true]').click();
+ await settle(400);
+ eq('5 canvas: element paints the brand colour', toHex(await canvasBg(targetSel)), '#ff6600');
+ check('5 element stores the live token (inline style)', /background-color:\s*var\(--wsb-brand-1\)/.test(await page.locator(targetSel).getAttribute('style') || ''), await page.locator(targetSel).getAttribute('style'));
+ await openSite();
+ const orangeRow = rows().nth(1);
+ await orangeRow.locator('[data-field=hex]').fill('#00aa55');
+ await settle(500);
+ eq('5 canvas element recoloured to the new brand hex', toHex(await canvasBg(targetSel)), '#00aa55');
+ await save();
+ const nodes = pageNodes();
+ eq('5 stored value on the node is the token, not the hex', nodes['target-box'].props.style.backgroundColor, 'var(--wsb-brand-1)');
+ eq('5 stored brand swatch hex updated', state.project.design.swatches.find((s) => s.slot === 1).hex, '#00aa55');
+ const canvasRgb = await canvasBg(targetSel);
+
+ const publishedA = head.renderPublished(state.project, 0);
+ const pub = await ctx.newPage();
+ const bgOf = async (html) => {
+ await pub.setContent(html, { waitUntil: 'domcontentloaded' });
+ return pub.evaluate(() => {
+ const e = [...document.querySelectorAll('div')].find((d) => d.style.minHeight === '80px');
+ return e ? getComputedStyle(e).backgroundColor : null;
+ });
+ };
+ eq('5 published (frontend export): identical to the canvas colour', await bgOf(publishedA), canvasRgb);
+ eq('5 published (frontend export) is the new brand colour', toHex(await bgOf(publishedA)), '#00aa55');
+ if (haveBackend) {
+ const publishedB = backendHtml(state.project, 0);
+ eq('5 published (backend generateCompiledHTML): identical to the canvas colour', await bgOf(publishedB), canvasRgb);
+ const da = rootDecl(publishedA), db = rootDecl(publishedB);
+ check('5 heads :root brand declaration byte-identical (frontend vs backend)', da === db && da.includes(';--wsb-brand-1:#00aa55'), da === db ? da.slice(-60) : `\n a=${da}\n b=${db}`);
+ } else console.log(`[${name}] SKIP backend leg: ${BACKEND_DIR} not found`);
+
+ // site WITHOUT brand colours: heads identical to the pre-feature baseline
+ const plain = buildProject();
+ plain.design.swatches = [{ id: 'x', label: 'Plain', hex: '#c8b08a', brand: false }];
+ const noDesign = buildProject();
+ const pa = head.renderPublished(noDesign, 0);
+ check('5 no-brand site: published head has no "brand" substring', !/brand/i.test(pa.split('')[0]));
+ const pplain = head.renderPublished(plain, 0);
+ eq('5 non-brand swatches change nothing in the published document', pplain, pa);
+ if (haveBackend) eq('5 no-brand site: frontend :root == backend :root', rootDecl(pa), rootDecl(backendHtml(noDesign, 0)));
+ if (baseExporter) {
+ eq(`5 no-brand site: published document identical to the pre-feature baseline (${BASE_COMMIT})`, pa === baseExporter.renderPublished(noDesign, 0), true);
+ } else console.log(`[${name}] SKIP baseline comparison`);
+ await pub.close();
+
+ // ================================================================= 6 Cap + guarded delete
+ await openSite();
+ // brand slots so far: 1 (orange). Mark Logo box + Sand brand => 3. Add three more => 6.
+ await rows().nth(0).locator('[data-action=brand]').click();
+ await rows().nth(2).locator('[data-action=brand]').click();
+ for (const [h, l] of [['#111111', 'Ink'], ['#222222', 'Slate'], ['#333333', 'Coal']]) {
+ await sm.locator('[data-field=new-hex]').fill(h);
+ await sm.locator('[data-field=new-label]').fill(l);
+ await sm.getByRole('button', { name: 'Add colour' }).click();
+ await settle(150);
+ await rows().last().locator('[data-action=brand]').click();
+ await settle(100);
+ }
+ rd = await rowData();
+ eq('6 six brand colours', rd.filter((r) => r.brand).length, 6);
+ await sm.locator('[data-field=new-hex]').fill('#444444');
+ await sm.locator('[data-field=new-label]').fill('Seventh');
+ await sm.getByRole('button', { name: 'Add colour' }).click();
+ await settle(150);
+ await rows().last().locator('[data-action=brand]').click();
+ await settle(200);
+ eq('6 7th brand colour refused with the message', await sm.locator('[role=status]').innerText(), 'You can have up to 6 brand colours');
+ eq('6 the 7th stays a plain colour', (await rowData()).filter((r) => r.brand).length, 6);
+
+ // deleting a referenced brand colour asks first (orange row = index 1, used by the target box)
+ await rows().nth(1).locator('[data-action=delete]').click();
+ await settle(250);
+ const dlg = sm.locator('[role=alertdialog]');
+ check('6 deleting a referenced brand colour asks for confirmation', (await dlg.count()) === 1);
+ const dlgText = (await dlg.innerText()).replace(/\s+/g, ' ');
+ check('6 confirmation names the swatch, the use count and the fixed colour', /Brand orange is used in 1 place/.test(dlgText) && /#00aa55/.test(dlgText), dlgText);
+ eq('6 nothing deleted before confirming', (await rowData()).length, 7);
+ eq('6 canvas colour unchanged before confirming', toHex(await canvasBg(targetSel)), '#00aa55');
+ await dlg.getByRole('button', { name: 'Cancel' }).click();
+ await settle(150);
+ eq('6 Cancel keeps the swatch', (await rowData()).length, 7);
+ await rows().nth(1).locator('[data-action=delete]').click();
+ await settle(150);
+ await dlg.getByRole('button', { name: 'Replace and delete' }).click();
+ await settle(500);
+ eq('6 swatch removed after confirming', (await rowData()).some((r) => r.label === 'Brand orange'), false);
+ eq('6 canvas element keeps its colour', toHex(await canvasBg(targetSel)), '#00aa55');
+ const styleAfter = await page.locator(targetSel).getAttribute('style');
+ check('6 canvas element now holds the literal hex (no brand var)', /background-color:\s*(#00aa55|rgb\(0, 170, 85\))/i.test(styleAfter || '') && !/wsb-brand-1/.test(styleAfter || ''), styleAfter);
+ await save();
+ eq('6 saved node holds the literal hex', pageNodes()['target-box'].props.style.backgroundColor, '#00aa55');
+ eq('6 saved swatches no longer contain Brand orange', state.project.design.swatches.some((s) => s.label === 'Brand orange'), false);
+ eq('6 published page (frontend) still paints the colour', toHex(await (async () => { const p2 = await ctx.newPage(); await p2.setContent(head.renderPublished(state.project, 0)); const v = await p2.evaluate(() => getComputedStyle([...document.querySelectorAll('div')].find((d) => d.style.minHeight === '80px')).backgroundColor); await p2.close(); return v; })()), '#00aa55');
+
+ // ================================================================= 7 Contrast
+ await page.locator(`${frame} p`).click();
+ await settle(400);
+ await page.click('#right-tab-element');
+ await settle(300);
+ const tf = fieldOf('Text Color');
+ await tf.locator('input[type=text]').first().fill('#ffffff');
+ await tf.locator('input[type=text]').first().press('Enter');
+ await settle(500);
+ const note = await page.locator('.panel-right .contrast-note--fail').allInnerTexts();
+ check('7 white text on a light background shows the inline warning with the ratio', note.length >= 1 && /Low contrast 1\.00:1 \(needs 4\.5:1\)/.test(note[0]), JSON.stringify(note));
+ await page.locator('.panel-right').evaluate((e) => e.scrollTo?.(0, 0));
+ await shot('shot-text-field-inline-contrast.png');
+ await openSite();
+ const siteNotes = await page.locator('.panel-right .contrast-note--fail').allInnerTexts();
+ check('7 Site Design shows the site-level contrast notes', siteNotes.some((t) => /Button text on primary: Low contrast 3\.68:1/.test(t)), JSON.stringify(siteNotes.map((t) => t.replace(/\s+/g, ' '))));
+ await shot('shot-site-design-contrast-notes.png');
+
+ if (errors.length) { failed++; console.log(`[${name}] FAIL page errors:\n` + errors.join('\n')); }
+ await browser.close();
+ server.close();
+ return failed;
+}
+
+let total = 0;
+try {
+ total += await run('chromium', chromium, process.env.CHROMIUM_PATH);
+ total += await run('firefox', firefox, process.env.FIREFOX_PATH);
+} finally {
+ remote.close();
+ fs.rmSync(buildDir, { recursive: true, force: true });
+ fs.rmSync(baseDir, { recursive: true, force: true });
+}
+console.log(total ? `FAILED (${total})` : 'ALL PASS');
+process.exit(total ? 1 : 0);
diff --git a/craft/scripts/e2e-image-sample.mjs b/craft/scripts/e2e-image-sample.mjs
new file mode 100644
index 0000000..c239cdb
--- /dev/null
+++ b/craft/scripts/e2e-image-sample.mjs
@@ -0,0 +1,123 @@
+#!/usr/bin/env node
+/**
+ * Real-browser check (headless Firefox AND Chromium) for the colour picker's pixel reading:
+ * src/utils/image-sample.ts + palette-extract.ts. jsdom has no canvas, so this bundles them with
+ * esbuild, serves a generated PNG from one origin (the page's own) and from a second port
+ * (cross-origin), and asserts exact pixel colours, the extracted palette, and the cross-origin
+ * failure message.
+ *
+ * node scripts/e2e-image-sample.mjs (CHROMIUM_PATH / FIREFOX_PATH override binaries)
+ */
+import { chromium, firefox } from '@playwright/test';
+import { build as esbuild } from 'esbuild';
+import http from 'node:http';
+import zlib from 'node:zlib';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const W = 1024, H = 512;
+const RED = [200, 30, 30], BLUE = [20, 20, 200], GREEN = [0, 160, 0];
+
+// ---- tiny PNG encoder (RGBA, filter 0)
+function crc32(buf) {
+ let c, crc = ~0;
+ for (let n = 0; n < buf.length; n++) { c = (crc ^ buf[n]) & 255; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; crc = (crc >>> 8) ^ c; }
+ return ~crc >>> 0;
+}
+function chunk(type, data) {
+ const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
+ const td = Buffer.concat([Buffer.from(type), data]);
+ const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
+ return Buffer.concat([len, td, crc]);
+}
+function png() {
+ const raw = Buffer.alloc((W * 4 + 1) * H);
+ for (let y = 0; y < H; y++) {
+ raw[y * (W * 4 + 1)] = 0;
+ for (let x = 0; x < W; x++) {
+ const c = x < W / 2 ? RED : y < H / 2 ? BLUE : GREEN;
+ raw.set([...c, 255], y * (W * 4 + 1) + 1 + x * 4);
+ }
+ }
+ const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(W, 0); ihdr.writeUInt32BE(H, 4); ihdr[8] = 8; ihdr[9] = 6;
+ return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
+}
+const PNG = png();
+
+// ---- bundle the units under test
+const bundle = await esbuild({
+ stdin: {
+ contents: "export * from './src/utils/image-sample'; export * from './src/utils/palette-extract';",
+ resolveDir: root, loader: 'ts',
+ },
+ bundle: true, write: false, format: 'iife', globalName: 'T', platform: 'browser', logLevel: 'error',
+});
+const BUNDLE = bundle.outputFiles[0].text;
+
+// ---- servers: page + same-origin image on A; image only on B (cross-origin)
+const serve = (withPage) => http.createServer((req, res) => {
+ if (withPage && req.url === '/page.html') { res.setHeader('content-type', 'text/html'); return res.end('t'); }
+ if (withPage && req.url === '/bundle.js') { res.setHeader('content-type', 'text/javascript'); return res.end(BUNDLE); }
+ if (req.url.startsWith('/img.png')) { res.setHeader('content-type', 'image/png'); return res.end(PNG); }
+ res.statusCode = 404; res.end();
+});
+const A = serve(true), B = serve(false);
+await Promise.all([new Promise((r) => A.listen(0, '127.0.0.1', r)), new Promise((r) => B.listen(0, '127.0.0.1', r))]);
+const pa = A.address().port, pb = B.address().port;
+
+let failures = 0;
+const check = (name, ok, extra = '') => { console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' ' + extra : ''}`); if (!ok) failures++; };
+const hex = (c) => '#' + c.map((n) => n.toString(16).padStart(2, '0')).join('');
+
+const targets = [
+ ['chromium', chromium, process.env.CHROMIUM_PATH],
+ ['firefox', firefox, process.env.FIREFOX_PATH],
+].filter(([n]) => !process.env.ONLY || process.env.ONLY === n);
+
+for (const [name, type, exe] of targets) {
+ console.log(`--- ${name}`);
+ const browser = await type.launch({ headless: true, ...(exe ? { executablePath: exe } : {}) });
+ const page = await browser.newPage();
+ await page.goto(`http://127.0.0.1:${pa}/page.html`);
+ await page.addScriptTag({ url: `/bundle.js` });
+ const r = await page.evaluate(async ({ pa, pb }) => {
+ const out = {};
+ const same = await T.loadImageForSampling(`/img.png`);
+ out.sameOk = same.ok;
+ if (same.ok) {
+ out.size = [same.width, same.height];
+ const d = same.imageData;
+ out.red = T.sampleAt(d, 10, 10);
+ out.blue = T.sampleAt(d, 400, 10);
+ out.green = T.sampleAt(d, 400, 200);
+ out.palette = T.extractPalette(d.data);
+ const c = document.createElement('canvas');
+ same.draw(c);
+ out.drawn = [c.width, c.height];
+ } else out.sameMsg = same.message;
+ const abs = await T.loadImageForSampling(`http://127.0.0.1:${pa}/img.png`);
+ out.absSameOk = abs.ok;
+ const cross = await T.loadImageForSampling(`http://127.0.0.1:${pb}/img.png`);
+ out.cross = cross;
+ const bad = await T.loadImageForSampling(`/missing.png`);
+ out.bad = bad;
+ return out;
+ }, { pa, pb });
+ check('same-origin image loads', r.sameOk, r.sameMsg || '');
+ check('downscaled to 512x256', JSON.stringify(r.size) === '[512,256]', JSON.stringify(r.size));
+ check('draw() sizes canvas', JSON.stringify(r.drawn) === '[512,256]');
+ check('pixel red exact', r.red === hex(RED), r.red);
+ check('pixel blue exact', r.blue === hex(BLUE), r.blue);
+ check('pixel green exact', r.green === hex(GREEN), r.green);
+ check('palette = red, blue, green in population order (red 50%, blue/green 25%)',
+ r.palette[0] === hex(RED) && r.palette.length === 3 && r.palette.includes(hex(BLUE)) && r.palette.includes(hex(GREEN)), JSON.stringify(r.palette));
+ check('absolute same-origin URL loads', r.absSameOk);
+ check('cross-origin refused', r.cross.ok === false && r.cross.reason === 'cross-origin');
+ check('cross-origin message exact', r.cross.message === 'That image comes from another website, so the browser will not let the builder read its colours. Upload it to this site first, then pick from the uploaded copy.');
+ check('missing image -> error', r.bad.ok === false && r.bad.reason === 'error', r.bad.message);
+ await browser.close();
+}
+A.close(); B.close();
+console.log(failures ? `\n${failures} FAILED` : '\nall passed');
+process.exit(failures ? 1 : 0);
diff --git a/craft/src/panels/right/SiteDesignPanel.colours.test.tsx b/craft/src/panels/right/SiteDesignPanel.colours.test.tsx
new file mode 100644
index 0000000..9a72547
--- /dev/null
+++ b/craft/src/panels/right/SiteDesignPanel.colours.test.tsx
@@ -0,0 +1,62 @@
+import { describe, test, expect, afterEach } from 'vitest';
+import React from 'react';
+import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
+import { EditorConfigProvider } from '../../state/EditorConfigContext';
+import { PageProvider } from '../../state/PageContext';
+import { SaveGuardProvider } from '../../state/SaveGuardContext';
+import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue } from '../../state/SiteDesignContext';
+import { SiteDesignPanel } from './SiteDesignPanel';
+
+let h: EditorHarness;
+let design: SiteDesignContextValue;
+const Probe: React.FC = () => { design = useSiteDesign(); return null; };
+afterEach(() => { h?.unmount(); document.body.innerHTML = ''; });
+
+function mount() {
+ h = renderEditorHarness();
+ h.mountChild(
+
+
+ ,
+ );
+}
+const advanced = () => h.act(() => { Array.from(h.container.querySelectorAll('button')).find((b) => b.textContent?.trim() === 'advanced')!.click(); });
+const notes = () => Array.from(h.container.querySelectorAll('.contrast-note--fail')).map((n) => n.textContent ?? '');
+
+describe('SiteDesignPanel colours', () => {
+ test('has a Saved colours section with the pick buttons', () => {
+ mount();
+ expect(h.container.textContent).toContain('Saved colours');
+ expect(Array.from(h.container.querySelectorAll('button')).some((b) => b.textContent === 'Palette from image')).toBe(true);
+ });
+
+ test('defaults: only the genuinely failing default pairs (white on #3b82f6, #3b82f6 link on white = 3.68:1) are called out', () => {
+ mount();
+ advanced();
+ const n = notes();
+ expect(n.length).toBe(2);
+ expect(n[0]).toContain('Button text on primary: Low contrast 3.68:1');
+ expect(n[1]).toContain('Link on background: Low contrast 3.68:1');
+ });
+
+ test('low-contrast text / muted / link / primary-button pairs are called out where picked', () => {
+ mount();
+ h.act(() => { design.updateDesign({ textColor: '#eeeeee', mutedTextColor: '#f0f0f0', linkColor: '#fafafa', primaryColor: '#ffffff' }); });
+ advanced();
+ const all = notes().join(' | ');
+ expect(all).toContain('Text on background: Low contrast');
+ expect(all).toContain('Muted text on background: Low contrast');
+ expect(all).toContain('Link on background: Low contrast');
+ expect(all).toContain('Button text on primary: Low contrast');
+ });
+
+ test('"Use #xxxxxx" on the text colour applies the suggestion', () => {
+ mount();
+ h.act(() => { design.updateDesign({ textColor: '#eeeeee' }); });
+ advanced();
+ const use = h.container.querySelector('.contrast-note__use')!;
+ h.act(() => { use.click(); });
+ expect(design.design.textColor).toMatch(/^#[0-9a-f]{6}$/);
+ expect(design.design.textColor).not.toBe('#eeeeee');
+ });
+});
diff --git a/craft/src/panels/right/SiteDesignPanel.tsx b/craft/src/panels/right/SiteDesignPanel.tsx
index efa7311..030a595 100644
--- a/craft/src/panels/right/SiteDesignPanel.tsx
+++ b/craft/src/panels/right/SiteDesignPanel.tsx
@@ -9,6 +9,8 @@ import { AssetPicker } from '../../ui/AssetPicker';
import { inputStyle, ColorPickerField } from './styles/shared';
import { openProfileWizard } from '../sitesmith/ProfileWizard';
import { PALETTES, paletteUpdates, paletteMatches } from '../../constants/palettes';
+import { SwatchManager } from './SwatchManager';
+import { siteColorPairs } from '../../utils/contrast';
type DesignTab = 'basic' | 'advanced';
@@ -20,11 +22,26 @@ interface ColorFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
+ /** Id of a site colour pair (utils/contrast siteColorPairs) this field belongs to. */
+ pair?: 'text-bg' | 'muted-bg' | 'link-bg' | 'button-primary';
}
-const ColorField: React.FC = ({ label, value, onChange }) => (
-
-);
+const ColorField: React.FC = ({ label, value, onChange, pair }) => {
+ const { design } = useSiteDesign();
+ const p = pair ? siteColorPairs(design).find((x) => x.id === pair) : undefined;
+ // The other side of the pair is a hex in the design (these fields are the token source).
+ // Text/muted/link are the foreground against the background; the button pair is the
+ // primary colour (the background of the button) against the page background (its text).
+ const other = p ? design.backgroundColor : undefined;
+ return (
+
+ );
+};
/* ---------- Try a palette ---------- */
@@ -299,6 +316,7 @@ export const SiteDesignPanel: React.FC = () => {
updateDesign({ primaryColor: v })}
/>
@@ -324,6 +342,7 @@ export const SiteDesignPanel: React.FC = () => {
/>
updateDesign({ linkColor: v })}
/>
@@ -335,6 +354,7 @@ export const SiteDesignPanel: React.FC = () => {
<>
updateDesign({ primaryColor: v })}
/>
@@ -370,11 +390,13 @@ export const SiteDesignPanel: React.FC = () => {
/>
updateDesign({ textColor: v })}
/>
updateDesign({ mutedTextColor: v })}
/>
@@ -400,6 +422,7 @@ export const SiteDesignPanel: React.FC = () => {
/>
updateDesign({ linkColor: v })}
/>
@@ -420,6 +443,8 @@ export const SiteDesignPanel: React.FC = () => {
>
)}
+
+
{/* Reset button */}
= ({ selectedId, nodePr
label="Hover Background"
value={nodeProps.hoverBg || ''}
onChange={(v) => setPropContinuous('hoverBg', v)}
+ contrastWith={nodeProps.hoverColor || ''}
+ contrastRole="bg"
+ contrastLabel="Hover text on its colour"
/>
setPropContinuous('hoverColor', v)}
+ contrastWith={nodeProps.hoverBg || (style.backgroundColor as string) || ''}
+ contrastLabel="Hover text on its colour"
/>
diff --git a/craft/src/panels/right/styles/ColorPickTools.tsx b/craft/src/panels/right/styles/ColorPickTools.tsx
new file mode 100644
index 0000000..41538d5
--- /dev/null
+++ b/craft/src/panels/right/styles/ColorPickTools.tsx
@@ -0,0 +1,89 @@
+import React, { useState } from 'react';
+import { useSiteDesign } from '../../../state/SiteDesignContext';
+import { addSwatch, findSwatchByHex, normalizeHex, MAX_SWATCHES } from '../../../utils/swatches';
+import { matchSiteColor, pickFromCanvas, pickFromScreen, screenPickKind } from '../../../utils/color-pick';
+import { resolveTokenValue } from '../../../utils/tokens';
+import { ImagePickModal } from './ImagePickModal';
+
+export interface ColorPickToolsProps {
+ /** The field's current value (hex, token ref or brand ref). */
+ value: string;
+ /** Receives the picked value: a site/brand reference (when `allowRefs` and one matches) or the raw hex. */
+ onPick: (value: string) => void;
+ /**
+ * Whether a pick may become a `var(--wsb-*)` reference. False for the Site
+ * Design colours themselves: they are the token SOURCE, a reference there
+ * would be a cycle.
+ */
+ allowRefs?: boolean;
+}
+
+/**
+ * Compact "get this colour from somewhere" row shown under every colour
+ * field: pick from the page, from the screen (Chromium only), from an image,
+ * and save the current colour to the site's saved colours.
+ */
+export const ColorPickTools: React.FC = ({ value, onPick, allowRefs = true }) => {
+ const { design, updateDesign } = useSiteDesign();
+ const [msg, setMsg] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [imageOpen, setImageOpen] = useState(false);
+ const swatches = Array.isArray(design.swatches) ? design.swatches : [];
+
+ const literal = normalizeHex(resolveTokenValue(value || '', design));
+ const alreadySaved = !!literal && !!findSwatchByHex(swatches, literal);
+ const canSave = !!literal && !alreadySaved && swatches.length < MAX_SWATCHES;
+
+ const applyHex = (hex: string) => {
+ const ref = allowRefs ? matchSiteColor(hex, design)?.ref : undefined;
+ onPick(ref ?? hex);
+ };
+
+ const run = async (kind: 'page' | 'screen') => {
+ setMsg('');
+ setBusy(true);
+ try {
+ if (kind === 'page') {
+ const r = await pickFromCanvas({ design });
+ if (r.ok) {
+ const cand = r.candidates.find((c) => c.hex === r.hex && c.match);
+ onPick(allowRefs && cand?.match ? cand.match.ref : r.hex);
+ } else if (r.reason !== 'cancelled') setMsg(r.message);
+ } else {
+ const r = await pickFromScreen();
+ if (r.ok) applyHex(r.hex);
+ else if (r.reason !== 'cancelled') setMsg(r.message);
+ }
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+ run('page')}>Pick from page
+ {screenPickKind() === 'eyedropper' && (
+ run('screen')}>Pick from screen
+ )}
+ setImageOpen(true)}>Pick from image
+ { if (literal && canSave) updateDesign({ swatches: addSwatch(swatches, literal, '') }); }}
+ >
+ Save colour
+
+
+ {msg &&
{msg}
}
+ {imageOpen && (
+
{ applyHex(hex); setImageOpen(false); }}
+ onClose={() => setImageOpen(false)}
+ />
+ )}
+
+ );
+};
diff --git a/craft/src/panels/right/styles/ContrastNote.test.tsx b/craft/src/panels/right/styles/ContrastNote.test.tsx
new file mode 100644
index 0000000..3ad4f3e
--- /dev/null
+++ b/craft/src/panels/right/styles/ContrastNote.test.tsx
@@ -0,0 +1,59 @@
+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 { ContrastNote } from './ContrastNote';
+import { SiteDesignProvider } from '../../../state/SiteDesignContext';
+
+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;
+});
+
+describe('ContrastNote', () => {
+ test('renders nothing when the pair passes', () => {
+ render();
+ expect(container!.innerHTML).toBe('');
+ });
+
+ test('renders nothing for an unresolved colour', () => {
+ render();
+ expect(container!.innerHTML).toBe('');
+ });
+
+ test('fail shows ratio and threshold in a status region', () => {
+ render();
+ const el = container!.querySelector('[role="status"]')!;
+ expect(el.textContent).toBe('Low contrast 2.32:1 (needs 4.5:1)');
+ expect(container!.querySelector('button')).toBeNull();
+ });
+
+ test('large text uses the 3:1 threshold; label prefixes', () => {
+ render();
+ expect(container!.textContent).toBe('Heading: Low contrast 1.61:1 (needs 3:1)');
+ });
+
+ test('suggestion button calls back with a passing hex', () => {
+ const cb = vi.fn();
+ render();
+ const btn = container!.querySelector('button')!;
+ expect(btn.textContent).toMatch(/^Use #[0-9a-f]{6}$/);
+ act(() => { btn.click(); });
+ expect(cb).toHaveBeenCalledTimes(1);
+ expect(cb.mock.calls[0][0]).toMatch(/^#[0-9a-f]{6}$/);
+ });
+
+ test('gradient background shows a muted not-checked note', () => {
+ render();
+ expect(container!.querySelector('[role="status"]')!.textContent).toBe('Contrast not checked (gradient)');
+ });
+});
diff --git a/craft/src/panels/right/styles/ContrastNote.tsx b/craft/src/panels/right/styles/ContrastNote.tsx
new file mode 100644
index 0000000..9df4e6a
--- /dev/null
+++ b/craft/src/panels/right/styles/ContrastNote.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import { useSiteDesign } from '../../../state/SiteDesignContext';
+import { checkContrast, suggestFix } from '../../../utils/contrast';
+
+export interface ContrastNoteProps {
+ fg: string;
+ bg: string;
+ large?: boolean;
+ label?: string;
+ onUseSuggestion?: (hex: string) => void;
+}
+
+/**
+ * Compact inline contrast hint. Silent when the pair passes or cannot be
+ * judged for a reason not worth mentioning; amber (not alarm red) on failure;
+ * muted "not checked" for gradient / image backgrounds.
+ */
+export const ContrastNote: React.FC = ({ fg, bg, large, label, onUseSuggestion }) => {
+ const { design } = useSiteDesign();
+ const v = checkContrast(fg, bg, design, { large });
+
+ if (v.status === 'pass') return null;
+
+ if (v.status === 'unchecked') {
+ if (v.reason !== 'gradient' && v.reason !== 'image') return null;
+ return (
+
+ Contrast not checked ({v.reason})
+
+ );
+ }
+
+ const fix = onUseSuggestion ? suggestFix(fg, bg, design, { large }) : null;
+ return (
+
+
+ {label ? `${label}: ` : ''}Low contrast {v.ratio!.toFixed(2)}:1 (needs {v.required}:1)
+
+ {fix && (
+ onUseSuggestion!(fix.hex)}
+ title={`Use ${fix.hex} (${fix.ratio.toFixed(1)}:1)`}
+ style={{ font: 'inherit', fontWeight: 600, color: 'inherit', background: 'none', border: '1px solid currentColor', borderRadius: 4, padding: '0 6px', cursor: 'pointer' }}
+ >
+ Use {fix.hex}
+
+ )}
+
+ );
+};
diff --git a/craft/src/panels/right/styles/ImagePickModal.test.tsx b/craft/src/panels/right/styles/ImagePickModal.test.tsx
new file mode 100644
index 0000000..3de1e5c
--- /dev/null
+++ b/craft/src/panels/right/styles/ImagePickModal.test.tsx
@@ -0,0 +1,88 @@
+import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
+import React from 'react';
+import { createRoot, Root } from 'react-dom/client';
+import { act } from 'react-dom/test-utils';
+
+vi.mock('../../../utils/assets', () => ({ listAssets: vi.fn(), uploadAsset: vi.fn() }));
+vi.mock('../../../utils/image-sample', async () => {
+ const actual = await vi.importActual('../../../utils/image-sample');
+ return { ...actual, loadImageForSampling: vi.fn() };
+});
+import { listAssets } from '../../../utils/assets';
+import { loadImageForSampling, CROSS_ORIGIN_MESSAGE } from '../../../utils/image-sample';
+import { ImagePickModal } from './ImagePickModal';
+
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+const listMock = listAssets as unknown as ReturnType;
+const loadMock = loadImageForSampling as unknown as ReturnType;
+
+// 2x1 image: left red, right transparent
+const imageData = { width: 2, height: 1, data: new Uint8ClampedArray([255, 0, 0, 255, 0, 0, 0, 0]) };
+const okImage = () => ({ ok: true, width: 2, height: 1, imageData, draw: vi.fn() });
+
+let container: HTMLDivElement;
+let root: Root;
+async function render(ui: React.ReactElement) {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ await act(async () => { root = createRoot(container); root.render(ui); });
+ await act(async () => {});
+}
+afterEach(() => { act(() => root.unmount()); container.remove(); vi.clearAllMocks(); });
+beforeEach(() => { vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); listMock.mockResolvedValue([{ name: 'a.png', url: '/a.png', type: 'image/png' }]); });
+const q = (id: string) => document.querySelector(`[data-testid="${id}"]`) as HTMLElement | null;
+
+function mouse(el: HTMLElement, type: string, clientX: number) {
+ el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 100, height: 50, right: 100, bottom: 50, x: 0, y: 0, toJSON() {} }) as DOMRect;
+ act(() => { el.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX, clientY: 10 })); });
+}
+
+describe('ImagePickModal', () => {
+ test('lists uploaded assets and loads the chosen one', async () => {
+ loadMock.mockResolvedValue(okImage());
+ await render();
+ expect(q('image-asset-a.png')).toBeTruthy();
+ expect(loadMock).not.toHaveBeenCalled();
+ await act(async () => { q('image-asset-a.png')!.click(); });
+ expect(loadMock).toHaveBeenCalledWith('/a.png');
+ expect(q('image-pick-canvas')).toBeTruthy();
+ });
+
+ test('initialSrc loads immediately; hover shows hex; click picks and closes', async () => {
+ loadMock.mockResolvedValue(okImage());
+ const onPick = vi.fn(), onClose = vi.fn();
+ await render();
+ expect(loadMock).toHaveBeenCalledWith('/sel.png');
+ const canvas = q('image-pick-canvas')!;
+ mouse(canvas, 'mousemove', 10);
+ expect(q('image-pick-hex')!.textContent).toBe('#ff0000');
+ mouse(canvas, 'click', 10);
+ expect(onPick).toHaveBeenCalledWith('#ff0000');
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ test('clicking a transparent pixel does not pick', async () => {
+ loadMock.mockResolvedValue(okImage());
+ const onPick = vi.fn(), onClose = vi.fn();
+ await render();
+ mouse(q('image-pick-canvas')!, 'click', 90);
+ expect(onPick).not.toHaveBeenCalled();
+ expect(onClose).not.toHaveBeenCalled();
+ expect(container.ownerDocument.body.textContent).toContain('transparent');
+ });
+
+ test('loader failure is shown as role=alert', async () => {
+ loadMock.mockResolvedValue({ ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE });
+ await render();
+ const alert = document.querySelector('[role="alert"]');
+ expect(alert?.textContent).toBe(CROSS_ORIGIN_MESSAGE);
+ expect(q('image-pick-canvas')).toBeNull();
+ });
+
+ test('Escape closes', async () => {
+ const onClose = vi.fn();
+ await render();
+ act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); });
+ expect(onClose).toHaveBeenCalled();
+ });
+});
diff --git a/craft/src/panels/right/styles/ImagePickModal.tsx b/craft/src/panels/right/styles/ImagePickModal.tsx
new file mode 100644
index 0000000..34614ac
--- /dev/null
+++ b/craft/src/panels/right/styles/ImagePickModal.tsx
@@ -0,0 +1,169 @@
+import React, { useEffect, useRef, useState, CSSProperties } from 'react';
+import { Modal } from '../../../ui/Modal';
+import { listAssets, Asset } from '../../../utils/assets';
+import { clickableProps } from '../../../utils/a11y';
+import { loadImageForSampling, sampleAt, SampledImage } from '../../../utils/image-sample';
+
+export type SampleState =
+ | { status: 'idle' }
+ | { status: 'loading' }
+ | { status: 'ready'; img: SampledImage }
+ | { status: 'failed'; message: string };
+
+/** Load `src` for pixel reading; re-runs when `src` changes, ignoring stale results. */
+export function useSampledImage(src: string | null): SampleState {
+ const [state, setState] = useState({ status: 'idle' });
+ useEffect(() => {
+ if (!src) { setState({ status: 'idle' }); return; }
+ let live = true;
+ setState({ status: 'loading' });
+ loadImageForSampling(src).then((r) => {
+ if (!live) return;
+ setState(r.ok ? { status: 'ready', img: r } : { status: 'failed', message: r.message });
+ });
+ return () => { live = false; };
+ }, [src]);
+ return state;
+}
+
+/** Uploaded-image thumbnails; `selected` highlights the current one. */
+export const ImageAssetList: React.FC<{
+ assets: Asset[];
+ selected: string | null;
+ onSelect: (url: string) => void;
+}> = ({ assets, selected, onSelect }) => (
+
+ {assets.map((a) => (
+
onSelect(a.url))}
+ data-testid={`image-asset-${a.name}`}
+ title={a.name}
+ style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', aspectRatio: '1', border: `2px solid ${a.url === selected ? '#3b82f6' : 'transparent'}` }}
+ >
+

+
+ ))}
+ {assets.length === 0 && (
+
+ No images uploaded yet. Upload one from an Image block first.
+
+ )}
+
+);
+
+/** Uploaded images for the picker, loaded once on mount. */
+export function useImageAssets(): Asset[] {
+ const [assets, setAssets] = useState([]);
+ useEffect(() => {
+ let live = true;
+ listAssets('image').then((a) => { if (live) setAssets(a); }).catch(() => {});
+ return () => { live = false; };
+ }, []);
+ return assets;
+}
+
+export const modalPanelStyle: CSSProperties = {
+ background: '#18181b', border: '1px solid #3f3f46', borderRadius: 8, padding: 16,
+ color: '#e4e4e7', width: 460, maxWidth: '92vw', maxHeight: '88vh', overflowY: 'auto', fontSize: 12,
+};
+export const modalTitleStyle: CSSProperties = { fontSize: 15, fontWeight: 600, margin: 0 };
+export const modalBtnStyle: CSSProperties = {
+ padding: '6px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#27272a', color: '#e4e4e7',
+};
+export const errorStyle: CSSProperties = {
+ margin: '10px 0 0', padding: '8px 10px', borderRadius: 4, background: '#3f1d1d', border: '1px solid #7f1d1d', color: '#fecaca', fontSize: 12,
+};
+const assetGridStyle: CSSProperties = {
+ maxHeight: 140, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 4, background: '#09090b', borderRadius: 6, padding: 4,
+};
+
+export interface ImagePickModalProps {
+ onPick: (hex: string) => void;
+ onClose: () => void;
+ /** The selected image's src, offered as the starting image. */
+ initialSrc?: string;
+}
+
+const ZOOM = 9;
+
+export const ImagePickModal: React.FC = ({ onPick, onClose, initialSrc }) => {
+ const assets = useImageAssets();
+ const [src, setSrc] = useState(initialSrc || null);
+ const state = useSampledImage(src);
+ const canvasRef = useRef(null);
+ const zoomRef = useRef(null);
+ const [hover, setHover] = useState('');
+ const [note, setNote] = useState('');
+
+ useEffect(() => {
+ if (state.status === 'ready' && canvasRef.current) state.img.draw(canvasRef.current);
+ }, [state]);
+
+ const locate = (e: React.MouseEvent) => {
+ if (state.status !== 'ready') return null;
+ const rect = e.currentTarget.getBoundingClientRect();
+ if (!rect.width || !rect.height) return null;
+ return {
+ x: Math.floor(((e.clientX - rect.left) / rect.width) * state.img.width),
+ y: Math.floor(((e.clientY - rect.top) / rect.height) * state.img.height),
+ };
+ };
+
+ const onMove = (e: React.MouseEvent) => {
+ const p = locate(e);
+ if (!p || state.status !== 'ready') return;
+ setHover(sampleAt(state.img.imageData, p.x, p.y));
+ setNote('');
+ const z = zoomRef.current?.getContext?.('2d');
+ if (z && canvasRef.current) {
+ z.imageSmoothingEnabled = false;
+ z.clearRect(0, 0, 72, 72);
+ z.drawImage(canvasRef.current, p.x - 4, p.y - 4, ZOOM, ZOOM, 0, 0, 72, 72);
+ }
+ };
+
+ const onClick = (e: React.MouseEvent) => {
+ const p = locate(e);
+ if (!p || state.status !== 'ready') return;
+ const hex = sampleAt(state.img.imageData, p.x, p.y);
+ if (!hex) { setNote('That spot is transparent. Click a coloured part of the image.'); return; }
+ onPick(hex);
+ onClose();
+ };
+
+ return (
+ }>
+
+
+
Pick a colour from an image
+ Close
+
+
+ {state.status === 'loading' &&
Loading image...
}
+ {state.status === 'failed' &&
{state.message}
}
+ {src && state.status !== 'failed' && (
+
+
+ )}
+ {!src &&
Choose an uploaded image above, then click a spot in it to use that colour.
}
+
+
+ );
+};
diff --git a/craft/src/panels/right/styles/NavStylePanel.tsx b/craft/src/panels/right/styles/NavStylePanel.tsx
index e360321..3204a18 100644
--- a/craft/src/panels/right/styles/NavStylePanel.tsx
+++ b/craft/src/panels/right/styles/NavStylePanel.tsx
@@ -188,6 +188,26 @@ const PageLinkRow: React.FC<{ link: NavLink; pages: PageData[]; onHide: (pageId:
};
/* ---------- NAV / MENU / LOGO / FOOTER ---------- */
+/**
+ * Inline-contrast props for the nav family's colour pairs that are plainly
+ * seen together: text vs the bar's own background, CTA text vs the CTA colour.
+ * Menu's link colours have no known background here, so they get none.
+ */
+export function navContrast(key: string, nodeProps: Record): {
+ contrastWith?: string; contrastRole?: 'fg' | 'bg'; contrastLabel?: string;
+} {
+ const bar = nodeProps.backgroundColor || '#ffffff';
+ const text = nodeProps.textColor || '#18181b';
+ const cta = (nodeProps.ctaColor !== undefined ? nodeProps.ctaColor : nodeProps.ctaBgColor) || 'var(--wsb-primary)';
+ if (key === 'textColor' && nodeProps.backgroundColor !== undefined) return { contrastWith: bar, contrastLabel: 'Text on background' };
+ if (key === 'backgroundColor' && nodeProps.textColor !== undefined) return { contrastWith: text, contrastRole: 'bg', contrastLabel: 'Text on background' };
+ if (key === 'ctaTextColor' && (nodeProps.ctaColor !== undefined || nodeProps.ctaBgColor !== undefined)) return { contrastWith: cta, contrastLabel: 'Button text on its colour' };
+ if (key === 'ctaColor' || key === 'ctaBgColor') {
+ if (nodeProps.ctaTextColor !== undefined) return { contrastWith: nodeProps.ctaTextColor || '#ffffff', contrastRole: 'bg', contrastLabel: 'Button text on its colour' };
+ }
+ return {};
+}
+
export const NavStylePanel: React.FC = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const { setProp, setProps, setPropStyle, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
@@ -520,6 +540,7 @@ export const NavStylePanel: React.FC = ({ selectedId, nodeProps
label={f.label}
value={nodeProps[f.key] || f.fallback}
onChange={(v) => setPropContinuous(f.key, v)}
+ {...navContrast(f.key, nodeProps)}
/>
))}
diff --git a/craft/src/panels/right/styles/PaletteFromImageModal.test.tsx b/craft/src/panels/right/styles/PaletteFromImageModal.test.tsx
new file mode 100644
index 0000000..2316bd7
--- /dev/null
+++ b/craft/src/panels/right/styles/PaletteFromImageModal.test.tsx
@@ -0,0 +1,100 @@
+import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
+import React from 'react';
+import { createRoot, Root } from 'react-dom/client';
+import { act } from 'react-dom/test-utils';
+
+vi.mock('../../../utils/assets', () => ({ listAssets: vi.fn(), uploadAsset: vi.fn() }));
+vi.mock('../../../utils/image-sample', async () => {
+ const actual = await vi.importActual('../../../utils/image-sample');
+ return { ...actual, loadImageForSampling: vi.fn() };
+});
+import { listAssets } from '../../../utils/assets';
+import { loadImageForSampling, CROSS_ORIGIN_MESSAGE } from '../../../utils/image-sample';
+import { PaletteFromImageModal } from './PaletteFromImageModal';
+
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+const listMock = listAssets as unknown as ReturnType;
+const loadMock = loadImageForSampling as unknown as ReturnType;
+
+// 10 px: 5 red, 3 blue, 2 green
+function image() {
+ const px: number[] = [];
+ const push = (c: number[], n: number) => { for (let i = 0; i < n; i++) px.push(...c, 255); };
+ push([255, 0, 0], 5); push([0, 0, 255], 3); push([0, 160, 0], 2);
+ return { ok: true, width: 10, height: 1, imageData: { width: 10, height: 1, data: new Uint8ClampedArray(px) }, draw: vi.fn() };
+}
+
+let container: HTMLDivElement;
+let root: Root;
+async function render(ui: React.ReactElement) {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ await act(async () => { root = createRoot(container); root.render(ui); });
+ await act(async () => {});
+}
+afterEach(() => { act(() => root.unmount()); container.remove(); vi.clearAllMocks(); });
+beforeEach(() => { listMock.mockResolvedValue([{ name: 'a.png', url: '/a.png', type: 'image/png' }]); });
+const q = (id: string) => document.querySelector(`[data-testid="${id}"]`) as HTMLButtonElement | null;
+const click = (el: HTMLElement | null) => act(() => { el!.click(); });
+
+describe('PaletteFromImageModal', () => {
+ test('nothing is applied automatically', async () => {
+ loadMock.mockResolvedValue(image());
+ const onAdd = vi.fn();
+ await render();
+ expect(q('palette-row-#ff0000')).toBeTruthy();
+ expect(onAdd).not.toHaveBeenCalled();
+ });
+
+ test('swatches in population order, Add calls onAdd then shows Added', async () => {
+ loadMock.mockResolvedValue(image());
+ const onAdd = vi.fn();
+ await render();
+ const rows = [...document.querySelectorAll('[data-testid^="palette-row-"]')].map((r) => r.getAttribute('data-testid'));
+ expect(rows).toEqual(['palette-row-#ff0000', 'palette-row-#0000ff', 'palette-row-#00a000']);
+ click(q('palette-add-#0000ff'));
+ expect(onAdd).toHaveBeenCalledWith('#0000ff');
+ expect(q('palette-add-#0000ff')!.disabled).toBe(true);
+ expect(q('palette-add-#0000ff')!.textContent).toBe('Added');
+ });
+
+ test('existing hexes are disabled / Added (case-insensitive)', async () => {
+ loadMock.mockResolvedValue(image());
+ await render();
+ expect(q('palette-add-#ff0000')!.disabled).toBe(true);
+ expect(q('palette-add-#ff0000')!.textContent).toBe('Added');
+ expect(q('palette-add-#0000ff')!.disabled).toBe(false);
+ });
+
+ test('Add all adds only the not-yet-added colours', async () => {
+ loadMock.mockResolvedValue(image());
+ const onAdd = vi.fn();
+ await render();
+ click(q('palette-add-all'));
+ expect(onAdd.mock.calls.map((c) => c[0])).toEqual(['#0000ff', '#00a000']);
+ expect(q('palette-add-all')!.disabled).toBe(true);
+ });
+
+ test('picking a listed asset loads it', async () => {
+ loadMock.mockResolvedValue(image());
+ await render();
+ expect(q('palette-row-#ff0000')).toBeNull();
+ await act(async () => { q('image-asset-a.png')!.click(); });
+ expect(loadMock).toHaveBeenCalledWith('/a.png');
+ expect(q('palette-row-#ff0000')).toBeTruthy();
+ });
+
+ test('loader failure shown as role=alert, no swatches', async () => {
+ loadMock.mockResolvedValue({ ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE });
+ await render();
+ expect(document.querySelector('[role="alert"]')?.textContent).toBe(CROSS_ORIGIN_MESSAGE);
+ expect(document.querySelector('[data-testid^="palette-row-"]')).toBeNull();
+ });
+
+ test('Escape closes', async () => {
+ const onClose = vi.fn();
+ await render();
+ act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); });
+ expect(onClose).toHaveBeenCalled();
+ });
+});
diff --git a/craft/src/panels/right/styles/PaletteFromImageModal.tsx b/craft/src/panels/right/styles/PaletteFromImageModal.tsx
new file mode 100644
index 0000000..9db32e3
--- /dev/null
+++ b/craft/src/panels/right/styles/PaletteFromImageModal.tsx
@@ -0,0 +1,78 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { Modal } from '../../../ui/Modal';
+import { extractPalette } from '../../../utils/palette-extract';
+import {
+ ImageAssetList, useImageAssets, useSampledImage,
+ modalPanelStyle, modalTitleStyle, modalBtnStyle, errorStyle,
+} from './ImagePickModal';
+
+export interface PaletteFromImageModalProps {
+ onAdd: (hex: string, label?: string) => void;
+ onClose: () => void;
+ existingHexes: string[];
+ /** The selected image's src, offered as the starting image. */
+ initialSrc?: string;
+}
+
+export const PaletteFromImageModal: React.FC = ({ onAdd, onClose, existingHexes, initialSrc }) => {
+ const assets = useImageAssets();
+ const [src, setSrc] = useState(initialSrc || null);
+ const [count, setCount] = useState(6);
+ const [added, setAdded] = useState([]);
+ const state = useSampledImage(src);
+
+ useEffect(() => { setAdded([]); }, [src]);
+
+ const colours = useMemo(
+ () => (state.status === 'ready' ? extractPalette(state.img.imageData.data, { count }) : []),
+ [state, count],
+ );
+ const taken = useMemo(() => new Set([...existingHexes, ...added].map((h) => h.toLowerCase())), [existingHexes, added]);
+
+ const add = (hex: string) => {
+ onAdd(hex);
+ setAdded((a) => [...a, hex]);
+ };
+ const addAll = () => colours.filter((h) => !taken.has(h)).forEach(add);
+ const remaining = colours.filter((h) => !taken.has(h)).length;
+
+ return (
+ }>
+
+
+
Palette from an image
+ Close
+
+
+ {!src &&
Choose an uploaded image to see its main colours. Nothing is added until you press Add.
}
+ {state.status === 'loading' &&
Reading colours...
}
+ {state.status === 'failed' &&
{state.message}
}
+ {state.status === 'ready' && (
+
+
+
+
+
+ Add all
+
+ {colours.length === 0 &&
No colours found. This image may be fully transparent.
}
+ {colours.map((hex) => {
+ const done = taken.has(hex);
+ return (
+
+
+ {hex}
+ add(hex)} data-testid={`palette-add-${hex}`}>
+ {done ? 'Added' : 'Add'}
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+};
diff --git a/craft/src/panels/right/styles/PricingStylePanel.tsx b/craft/src/panels/right/styles/PricingStylePanel.tsx
index 6d9b52d..d80fece 100644
--- a/craft/src/panels/right/styles/PricingStylePanel.tsx
+++ b/craft/src/panels/right/styles/PricingStylePanel.tsx
@@ -243,8 +243,8 @@ export const PricingStylePanel: React.FC = ({ selectedId, nodeP
setPropContinuous('subColor', v)} />
setPropContinuous('featColor', v)} />
setPropContinuous('checkColor', v)} />
- setPropContinuous('btnBg', v)} />
- setPropContinuous('btnColor', v)} />
+ setPropContinuous('btnBg', v)} contrastWith={nodeProps.btnColor || '#ffffff'} contrastRole="bg" contrastLabel="Button text on its colour" />
+ setPropContinuous('btnColor', v)} contrastWith={nodeProps.btnBg || nodeProps.featuredBg || tokenRef('primary')} contrastLabel="Button text on its colour" />
{/* Box model: margin, padding, border, shadow, opacity */}
diff --git a/craft/src/panels/right/styles/TextStylePanel.tsx b/craft/src/panels/right/styles/TextStylePanel.tsx
index 2151847..09fa93a 100644
--- a/craft/src/panels/right/styles/TextStylePanel.tsx
+++ b/craft/src/panels/right/styles/TextStylePanel.tsx
@@ -25,9 +25,11 @@ import {
sectionGap,
labelStyle,
useNodeProp,
+ useEffectiveBackground,
} from './shared';
import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
+import { isLargeText } from '../../../utils/effective-background';
/* Text-transform is a small fixed enum with no natural home in the shared
foundation presets (constants/presets.ts is import-only for this
@@ -67,6 +69,7 @@ export const TextStylePanel: React.FC = ({ selectedId, nodeProp
const dot = (key: string | readonly string[]) => ;
const { setProp, setPropStyle, setProps, setPropStyleContinuous } = useNodeProp(selectedId);
+ const effectiveBg = useEffectiveBackground(selectedId);
const isItalic = style.fontStyle === 'italic';
const isUnderline = style.textDecoration === 'underline';
@@ -79,6 +82,9 @@ export const TextStylePanel: React.FC = ({ selectedId, nodeProp
colors={TEXT_COLORS}
activeValue={style.color as string}
onSelect={(v) => setPropStyle('color', v)}
+ contrastWith={effectiveBg}
+ contrastLarge={isLargeText(style.fontSize as string, style.fontWeight as string)}
+ contrastLabel="Text on its background"
/>
diff --git a/craft/src/panels/right/styles/TokenSwatchRow.test.tsx b/craft/src/panels/right/styles/TokenSwatchRow.test.tsx
index 789430a..bb5e186 100644
--- a/craft/src/panels/right/styles/TokenSwatchRow.test.tsx
+++ b/craft/src/panels/right/styles/TokenSwatchRow.test.tsx
@@ -100,4 +100,62 @@ describe('TokenSwatchRow', () => {
act(() => { ctx.current!.updateDesign({ primaryColor: '#ff0000' }); });
expect(swatches()[0].style.background).toBe('rgb(255, 0, 0)');
});
+
+ describe('saved colours', () => {
+ const saved = [
+ { id: 'a', label: 'Logo blue', hex: '#15202c', brand: true, slot: 2 },
+ { id: 'b', label: 'Sand', hex: '#d2b48c', brand: false },
+ ];
+ const withSaved = (value: string | undefined, onSelect: (v: string) => void) => {
+ const ctx: { current: SiteDesignContextValue | null } = { current: null };
+ render(
+
+
+
+ ,
+ );
+ act(() => { ctx.current!.updateDesign({ swatches: saved }); });
+ };
+ const savedBtns = () => Array.from(container!.querySelectorAll
('button[data-swatch]'));
+
+ test('shown after the 11 token swatches; title = label + hex; brand shows a live ring', () => {
+ withSaved(undefined, () => {});
+ const all = swatches();
+ expect(all.length).toBe(COLOR_TOKENS.length + 2);
+ expect(all.slice(0, COLOR_TOKENS.length).every((b) => b.dataset.token)).toBe(true);
+ const [brand, plain] = savedBtns();
+ expect(brand.title).toContain('Logo blue');
+ expect(brand.title).toContain('#15202c');
+ expect(brand.title).toContain('var(--wsb-brand-2)');
+ expect(brand.classList.contains('token-swatch--live')).toBe(true);
+ expect(brand.dataset.live).toBe('true');
+ expect(plain.title).toBe('Sand #d2b48c');
+ expect(plain.classList.contains('token-swatch--live')).toBe(false);
+ expect(brand.style.background).toBe('rgb(21, 32, 44)');
+ });
+
+ test('brand swatch selects var(--wsb-brand-N); plain selects its hex', () => {
+ const onSelect = vi.fn();
+ withSaved(undefined, onSelect);
+ const [brand, plain] = savedBtns();
+ act(() => { brand.click(); });
+ act(() => { plain.click(); });
+ expect(onSelect.mock.calls.map((c) => c[0])).toEqual(['var(--wsb-brand-2)', '#d2b48c']);
+ });
+
+ test('pressed when the value equals what the swatch stores', () => {
+ withSaved('var(--wsb-brand-2)', () => {});
+ expect(savedBtns().map((b) => b.getAttribute('aria-pressed'))).toEqual(['true', 'false']);
+ });
+
+ test('plain hex swatch pressed on a case-insensitive hex match', () => {
+ withSaved('#D2B48C', () => {});
+ expect(savedBtns().map((b) => b.getAttribute('aria-pressed'))).toEqual(['false', 'true']);
+ });
+
+ test('the group is labelled', () => {
+ withSaved(undefined, () => {});
+ expect(container!.querySelector('[role="group"]')!.getAttribute('aria-label')).toBe('Site colors');
+ });
+ });
});
diff --git a/craft/src/panels/right/styles/TokenSwatchRow.tsx b/craft/src/panels/right/styles/TokenSwatchRow.tsx
index 9a73d79..d362fb5 100644
--- a/craft/src/panels/right/styles/TokenSwatchRow.tsx
+++ b/craft/src/panels/right/styles/TokenSwatchRow.tsx
@@ -1,6 +1,14 @@
import React from 'react';
import { useSiteDesign } from '../../../state/SiteDesignContext';
import { COLOR_TOKENS, TokenKey, tokenRef, resolveTokenValue } from '../../../utils/tokens';
+import { swatchValue, normalizeHex, type SavedSwatch } from '../../../utils/swatches';
+
+/** Hover/aria text for a saved swatch: label + hex (+ the live token for brand colours). */
+export function savedSwatchTitle(s: SavedSwatch): string {
+ const base = s.label.trim() ? `${s.label.trim()} ${s.hex}` : s.hex;
+ const ref = swatchValue(s);
+ return ref !== s.hex ? `${base} (live brand colour, ${ref})` : base;
+}
export interface TokenSwatchRowProps {
/** The control's current value; the swatch whose `var(--wsb-*)` ref equals it is shown pressed. */
@@ -25,6 +33,8 @@ export const TokenSwatchRow: React.FC = ({
}) => {
const { design } = useSiteDesign();
const current = value?.trim();
+ const saved: SavedSwatch[] = Array.isArray(design.swatches) ? design.swatches : [];
+ const currentHex = current ? normalizeHex(current) : null;
return (
{label}
@@ -46,6 +56,26 @@ export const TokenSwatchRow: React.FC
= ({
/>
);
})}
+ {saved.map((sw) => {
+ const stored = swatchValue(sw);
+ const live = stored !== sw.hex;
+ const pressed = live ? current === stored : !!currentHex && currentHex === sw.hex.toLowerCase();
+ const title = savedSwatchTitle(sw);
+ return (
+ onSelect(stored)}
+ />
+ );
+ })}
);
diff --git a/craft/src/panels/right/styles/color-fields.pick.test.tsx b/craft/src/panels/right/styles/color-fields.pick.test.tsx
new file mode 100644
index 0000000..019874d
--- /dev/null
+++ b/craft/src/panels/right/styles/color-fields.pick.test.tsx
@@ -0,0 +1,176 @@
+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 { ColorPickerField, ColorSwatchGrid, GradientSwatchGrid } from './shared';
+import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue } from '../../../state/SiteDesignContext';
+
+const pickFromCanvas = vi.fn();
+const pickFromScreen = vi.fn();
+let screenKind: 'eyedropper' | 'none' = 'none';
+vi.mock('../../../utils/color-pick', async (orig) => {
+ const real = await orig();
+ return { ...real, pickFromCanvas: (...a: unknown[]) => pickFromCanvas(...a), pickFromScreen: (...a: unknown[]) => pickFromScreen(...a), screenPickKind: () => screenKind };
+});
+
+let container: HTMLDivElement | null = null;
+let root: Root;
+const ctx: { current: SiteDesignContextValue | null } = { current: null };
+const Probe: React.FC = () => { ctx.current = useSiteDesign(); return null; };
+function render(ui: React.ReactElement, swatches?: unknown[]) {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ act(() => { root = createRoot(container!); root.render({ui}); });
+ if (swatches) act(() => { ctx.current!.updateDesign({ swatches: swatches as never }); });
+}
+afterEach(() => {
+ vi.useRealTimers();
+ pickFromCanvas.mockReset(); pickFromScreen.mockReset(); screenKind = 'none';
+ if (!container) return;
+ act(() => { root.unmount(); });
+ container.remove();
+ container = null;
+});
+
+const hexBox = () => container!.querySelector('input[type="text"]')!;
+const chip = () => container!.querySelector('.token-chip');
+const btn = (text: string) => Array.from(container!.querySelectorAll('button')).find((b) => b.textContent?.trim() === text) as HTMLButtonElement | undefined;
+function type(input: HTMLInputElement, value: string) {
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
+ setter.call(input, value);
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+}
+const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
+
+const BRAND = { id: 'b1', label: 'Logo blue', hex: '#15202c', brand: true, slot: 2 };
+
+describe('hex validation', () => {
+ test('invalid text is not committed and shows a red hint; valid text clears it and commits normalised', () => {
+ vi.useFakeTimers();
+ const onChange = vi.fn();
+ render();
+ act(() => { type(hexBox(), '#12'); });
+ act(() => { vi.advanceTimersByTime(400); });
+ expect(onChange).not.toHaveBeenCalled();
+ const err = container!.querySelector('[role="alert"]')!;
+ expect(err.textContent).toMatch(/hex/i);
+ act(() => { type(hexBox(), 'ABCDEF'); });
+ act(() => { vi.advanceTimersByTime(400); });
+ expect(onChange).toHaveBeenCalledWith('#abcdef');
+ expect(container!.querySelector('[role="alert"]')).toBeNull();
+ });
+ test('ColorSwatchGrid validates too', () => {
+ vi.useFakeTimers();
+ const onSelect = vi.fn();
+ render();
+ act(() => { type(hexBox(), 'zzz'); });
+ act(() => { vi.advanceTimersByTime(400); });
+ expect(onSelect).not.toHaveBeenCalled();
+ expect(container!.querySelector('[role="alert"]')).not.toBeNull();
+ });
+});
+
+describe('brand references', () => {
+ test('chip shows the swatch label, or "Brand N" when unlabelled; the hex box shows the live hex', () => {
+ render( {}} />, [BRAND]);
+ expect(chip()!.textContent).toBe('Logo blue');
+ expect(hexBox().value).toBe('#15202c');
+ });
+ test('unlabelled brand colour -> Brand N', () => {
+ render( {}} />, [{ ...BRAND, label: '' }]);
+ expect(chip()!.textContent).toBe('Brand 2');
+ });
+ test('gradient custom builder parses a brand ref', () => {
+ render( {}} />, [BRAND]);
+ act(() => { btn('Custom Gradient')?.click() ?? Array.from(container!.querySelectorAll('button')).find((b) => b.textContent?.includes('Custom Gradient'))!.click(); });
+ const boxes = Array.from(container!.querySelectorAll('input[type="text"]')).map((i) => i.value);
+ expect(boxes).toContain('#15202c');
+ });
+});
+
+describe('save colour', () => {
+ test('adds the field literal hex to the saved colours, then disables', () => {
+ render( {}} />);
+ const b = btn('Save colour')!;
+ expect(b.disabled).toBe(false);
+ act(() => { b.click(); });
+ expect(ctx.current!.design.swatches.map((s) => s.hex)).toEqual(['#abcdef']);
+ expect(btn('Save colour')!.disabled).toBe(true);
+ });
+ test('a token value saves its resolved hex; a gradient cannot be saved', () => {
+ render( {}} />);
+ act(() => { btn('Save colour')!.click(); });
+ expect(ctx.current!.design.swatches[0].hex).toBe('#3b82f6');
+ });
+});
+
+describe('pick tools', () => {
+ test('Pick from screen only exists where the screen eyedropper does', () => {
+ render( {}} />);
+ expect(btn('Pick from page')).toBeDefined();
+ expect(btn('Pick from image')).toBeDefined();
+ expect(btn('Pick from screen')).toBeUndefined();
+ act(() => root.unmount()); container!.remove(); container = null;
+ screenKind = 'eyedropper';
+ render( {}} />);
+ expect(btn('Pick from screen')).toBeDefined();
+ });
+ test('Pick from page uses the site/brand reference when the pick matches one', async () => {
+ const onChange = vi.fn();
+ pickFromCanvas.mockResolvedValue({ ok: true, hex: '#15202c', source: 'canvas', candidates: [{ kind: 'background', hex: '#15202c', match: { kind: 'swatch', ref: 'var(--wsb-brand-2)', label: 'Logo blue' } }] });
+ render();
+ await act(async () => { btn('Pick from page')!.click(); });
+ await flush();
+ expect(onChange).toHaveBeenCalledWith('var(--wsb-brand-2)');
+ });
+ test('without a match the raw hex is used', async () => {
+ const onChange = vi.fn();
+ pickFromCanvas.mockResolvedValue({ ok: true, hex: '#010203', source: 'canvas', candidates: [{ kind: 'background', hex: '#010203' }] });
+ render();
+ await act(async () => { btn('Pick from page')!.click(); });
+ await flush();
+ expect(onChange).toHaveBeenCalledWith('#010203');
+ });
+ test('Site Design token fields (showTokens=false) never store a token ref from a pick', async () => {
+ const onChange = vi.fn();
+ pickFromCanvas.mockResolvedValue({ ok: true, hex: '#3b82f6', source: 'canvas', candidates: [{ kind: 'background', hex: '#3b82f6', match: { kind: 'token', ref: 'var(--wsb-primary)', label: 'Primary' } }] });
+ render();
+ await act(async () => { btn('Pick from page')!.click(); });
+ await flush();
+ expect(onChange).toHaveBeenCalledWith('#3b82f6');
+ });
+ test('a failed pick shows the message; a cancelled one is silent', async () => {
+ pickFromCanvas.mockResolvedValue({ ok: false, reason: 'error', message: 'Could not read it.' });
+ render( {}} />);
+ await act(async () => { btn('Pick from page')!.click(); });
+ await flush();
+ expect(container!.textContent).toContain('Could not read it.');
+ });
+ test('Pick from screen applies the eyedropper hex', async () => {
+ screenKind = 'eyedropper';
+ const onChange = vi.fn();
+ pickFromScreen.mockResolvedValue({ ok: true, hex: '#a1b2c3', source: 'eyedropper' });
+ render();
+ await act(async () => { btn('Pick from screen')!.click(); });
+ await flush();
+ expect(onChange).toHaveBeenCalledWith('#a1b2c3');
+ });
+});
+
+describe('inline contrast', () => {
+ test('ColorPickerField renders a ContrastNote when contrastWith is given and the pair fails', () => {
+ render( {}} contrastWith="#ffffff" />);
+ expect(container!.querySelector('.contrast-note--fail')).not.toBeNull();
+ });
+ test('passing pair and gradient background show nothing / unchecked only', () => {
+ render( {}} contrastWith="#ffffff" />);
+ expect(container!.querySelector('.contrast-note')).toBeNull();
+ });
+ test('the suggestion applies through onChange', () => {
+ const onChange = vi.fn();
+ render();
+ act(() => { (container!.querySelector('.contrast-note__use') as HTMLButtonElement).click(); });
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange.mock.calls[0][0]).toMatch(/^#[0-9a-f]{6}$/);
+ });
+});
diff --git a/craft/src/panels/right/styles/contrast.panels.test.tsx b/craft/src/panels/right/styles/contrast.panels.test.tsx
new file mode 100644
index 0000000..5970f72
--- /dev/null
+++ b/craft/src/panels/right/styles/contrast.panels.test.tsx
@@ -0,0 +1,83 @@
+import { describe, test, expect, afterEach } from 'vitest';
+import React from 'react';
+import { renderEditorHarness, EditorHarness } from '../../../test-utils/editorHarness';
+import { SiteDesignProvider } from '../../../state/SiteDesignContext';
+import { TextStylePanel } from './TextStylePanel';
+import { ButtonStylePanel } from './ButtonStylePanel';
+import { PricingStylePanel } from './PricingStylePanel';
+import { NavStylePanel } from './NavStylePanel';
+
+let h: EditorHarness;
+afterEach(() => { h?.unmount(); document.body.innerHTML = ''; });
+
+const node = (parent: string | null, name: string, props: Record, nodes: string[] = []) => ({
+ type: { resolvedName: name }, isCanvas: name === 'Container', props, displayName: name, custom: {}, hidden: false, nodes, linkedNodes: {}, parent,
+});
+const state = (rootStyle: Record) => JSON.stringify({
+ ROOT: node(null, 'Container', { style: rootStyle, tag: 'div' }, ['t1']),
+ t1: node('ROOT', 'TextBlock', { text: 'hi', style: {} }),
+});
+
+function mount(initialState: string, ui: React.ReactNode) {
+ h = renderEditorHarness({ initialState });
+ h.mountChild({ui});
+}
+const failNotes = () => Array.from(h.container.querySelectorAll('.contrast-note--fail')).map((n) => n.textContent ?? '');
+const allNotes = () => Array.from(h.container.querySelectorAll('.contrast-note')).map((n) => n.textContent ?? '');
+
+describe('Text / Heading colour vs its effective background', () => {
+ test('uses the nearest ancestor background: light text on a dark parent passes, on white it fails', () => {
+ mount(state({ backgroundColor: '#0f172a' }), );
+ expect(allNotes()).toEqual([]);
+ h.unmount();
+ mount(state({ backgroundColor: '#ffffff' }), );
+ expect(failNotes().length).toBe(1);
+ expect(failNotes()[0]).toContain('Text on its background');
+ });
+ test('no background anywhere -> the site Background token (white by default)', () => {
+ mount(state({}), );
+ expect(failNotes().length).toBe(1);
+ });
+ test('a gradient background is reported as not checked, never judged', () => {
+ mount(state({ background: 'linear-gradient(90deg,#000,#fff)' }), );
+ expect(failNotes()).toEqual([]);
+ expect(allNotes().join('')).toContain('not checked');
+ });
+ test('large text uses the 3:1 threshold', () => {
+ // #949494 on white = ~3.03:1: fails normal, passes large.
+ mount(state({}), );
+ expect(failNotes()).toEqual([]);
+ h.unmount();
+ mount(state({}), );
+ expect(failNotes().length).toBe(1);
+ });
+});
+
+describe('Button text vs button colour', () => {
+ test('flags a failing pair on the Button Color control', () => {
+ mount(state({}), );
+ expect(failNotes().some((t) => t.includes('Button text on its colour'))).toBe(true);
+ });
+ test('a passing pair is silent', () => {
+ mount(state({}), );
+ expect(failNotes()).toEqual([]);
+ });
+});
+
+describe('Pricing button text vs background', () => {
+ test('flags btnColor on btnBg', () => {
+ mount(state({}), );
+ expect(failNotes().some((t) => t.includes('Button text on its colour'))).toBe(true);
+ });
+});
+
+describe('Navbar / Footer text vs background', () => {
+ test('flags textColor on backgroundColor', () => {
+ mount(state({}), );
+ expect(failNotes().some((t) => t.includes('Text on background'))).toBe(true);
+ });
+ test('Menu link colours (no known background) get no note', () => {
+ mount(state({}), );
+ expect(failNotes()).toEqual([]);
+ });
+});
diff --git a/craft/src/panels/right/styles/shared.tsx b/craft/src/panels/right/styles/shared.tsx
index a5ca0b0..6d446a0 100644
--- a/craft/src/panels/right/styles/shared.tsx
+++ b/craft/src/panels/right/styles/shared.tsx
@@ -13,6 +13,11 @@ import { useSiteDesign, DEFAULT_SITE_DESIGN, SiteDesign } from '../../../state/S
import { COLOR_TOKENS, resolveTokenValue, tokenKeyOf, tokenRef } from '../../../utils/tokens';
import { isLightColor } from '../../../utils/color';
import { TokenSwatchRow } from './TokenSwatchRow';
+import { ColorPickTools } from './ColorPickTools';
+import { ContrastNote } from './ContrastNote';
+import { parseColorInput } from '../../../utils/color-input';
+import { effectiveBackgroundOf } from '../../../utils/effective-background';
+import { brandSlotOf } from '../../../utils/swatches';
import { useDevice } from '../../../state/DeviceContext';
import { RESPONSIVE_STYLE_KEYS } from '../../../utils/responsive-style';
import type { DeviceMode } from '../../../types';
@@ -34,11 +39,26 @@ export function useResolvedColor(value: string | undefined): { resolved: string;
const v = value || '';
const resolved = resolveTokenValue(v, design);
const key = tokenKeyOf(v);
- const tokenLabel = key ? (COLOR_TOKENS.find((t) => t.key === key)?.label ?? key) : null;
+ let tokenLabel: string | null = key ? (COLOR_TOKENS.find((t) => t.key === key)?.label ?? key) : null;
+ const slot = brandSlotOf(v);
+ if (slot !== null) {
+ const sw = (Array.isArray(design.swatches) ? design.swatches : []).find((s) => s.brand && s.slot === slot);
+ tokenLabel = sw?.label.trim() || `Brand ${slot}`;
+ }
const nativeValue = /^#[0-9a-fA-F]{6}$/.test(resolved) ? resolved : '#000000';
return { resolved, nativeValue, tokenLabel };
}
+/**
+ * The background the selected node's text is read against (see
+ * utils/effective-background.ts), else the site Background token. Defensive
+ * about a mocked `useEditor` in unit tests.
+ */
+export function useEffectiveBackground(selectedId: string): string {
+ const r = useEditor((state) => ({ bg: effectiveBackgroundOf(state.nodes as never, selectedId) })) as unknown as { bg?: string | null } | undefined;
+ return r?.bg || tokenRef('bg');
+}
+
/** Small "linked to a site colour" badge shown beside the hex box. */
export const TokenChip: React.FC<{ label: string | null }> = ({ label }) =>
label ? {label} : null;
@@ -69,6 +89,16 @@ export const SectionLabel: React.FC = ({ children }) => (
);
+/** Hex text box state shared by ColorPickerField / ColorSwatchGrid: only valid colour text is committed; invalid text keeps showing with a red hint. */
+function useColorText(resolved: string, commit: (v: string) => void) {
+ const guarded = (v: string) => { const r = parseColorInput(v); if (r.ok) commit(r.value); };
+ const hex = useDebouncedCommit(resolved, guarded);
+ const invalid = !parseColorInput(hex.local).ok;
+ return { hex, invalid };
+}
+const HexError: React.FC<{ show: boolean }> = ({ show }) =>
+ show ? Enter a hex colour like #3b82f6 (3, 4, 6 or 8 digits).
: null;
+
interface ColorSwatchGridProps {
colors: { label: string; value: string }[];
activeValue: string | undefined;
@@ -79,10 +109,15 @@ interface ColorSwatchGridProps {
onContinuous?: (value: string) => void;
/** Show the site-colour (token) row first. Default true. */
showTokens?: boolean;
+ /** Same inline contrast hint as ColorPickerField. */
+ contrastWith?: string;
+ contrastLarge?: boolean;
+ contrastLabel?: string;
+ contrastRole?: 'fg' | 'bg';
}
-export const ColorSwatchGrid: React.FC = ({ colors, activeValue, onSelect, onContinuous, showTokens = true }) => {
+export const ColorSwatchGrid: React.FC = ({ colors, activeValue, onSelect, onContinuous, showTokens = true, contrastWith, contrastLarge, contrastLabel, contrastRole = 'fg' }) => {
const { resolved, nativeValue, tokenLabel } = useResolvedColor(activeValue);
- const hex = useDebouncedCommit(resolved, onSelect);
+ const { hex, invalid } = useColorText(resolved, onSelect);
return (
{showTokens && }
@@ -104,6 +139,17 @@ export const ColorSwatchGrid: React.FC = ({ colors, active
/>
+
+
+ {contrastWith && (
+
+ )}
{colors.map((c) => (
= ({ label, value, onChange, onContinuous, showTokens = true }) => {
+export const ColorPickerField: React.FC = ({ label, value, onChange, onContinuous, showTokens = true, contrastWith, contrastLarge, contrastLabel, contrastRole = 'fg' }) => {
const { resolved, nativeValue, tokenLabel } = useResolvedColor(value);
- const hex = useDebouncedCommit(resolved, onChange);
+ const { hex, invalid } = useColorText(resolved, onChange);
return (
@@ -389,6 +443,17 @@ export const ColorPickerField: React.FC = ({ label, value
/>
+
+
+ {contrastWith && (
+
+ )}
);
};
diff --git a/craft/src/state/PageContext.rewriteSiteStates.test.tsx b/craft/src/state/PageContext.rewriteSiteStates.test.tsx
new file mode 100644
index 0000000..8576a7c
--- /dev/null
+++ b/craft/src/state/PageContext.rewriteSiteStates.test.tsx
@@ -0,0 +1,73 @@
+import { describe, test, expect, vi, beforeEach } from 'vitest';
+import React from 'react';
+import { createRoot, Root } from 'react-dom/client';
+import { act } from 'react-dom/test-utils';
+import { PageProvider, usePages } from './PageContext';
+import { replaceBrandRef, countBrandRefUses } from '../utils/swatches';
+
+/* Same mocked-craft pattern as PageContext.orphan-repair-wiring.test.tsx. */
+let serializeReturn = '{}';
+const deserializeMock = vi.fn();
+vi.mock('@craftjs/core', () => ({
+ useEditor: () => ({
+ query: { serialize: () => serializeReturn },
+ actions: { deserialize: deserializeMock, history: { clear: () => {} } },
+ }),
+}));
+
+let container: HTMLDivElement;
+let root: Root;
+let ctx: ReturnType;
+const Probe: React.FC = () => { ctx = usePages(); return null; };
+
+beforeEach(() => { deserializeMock.mockClear(); });
+
+const st = (c: string) => JSON.stringify({ ROOT: { props: { style: { backgroundColor: c } } } });
+
+function mount() {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ act(() => { root = createRoot(container); root.render(); });
+}
+
+describe('rewriteSiteStates / siteStateStrings', () => {
+ test('rewrites stored pages + header + footer and the live canvas; counts see the live canvas', async () => {
+ mount();
+ act(() => {
+ ctx.setPagesCraftState([
+ { id: 'home', name: 'Home', slug: 'index', craftState: st('STALE') },
+ { id: 'p2', name: 'About', slug: 'about', craftState: st('var(--wsb-brand-1)') },
+ ]);
+ ctx.setActivePageIdDirect('home');
+ ctx.setHeaderCraftState(st('var(--wsb-brand-1)'));
+ ctx.setFooterCraftState(st('#fff'));
+ });
+ serializeReturn = st('var(--wsb-brand-1)'); // live canvas of "home"
+ const total = ctx.siteStateStrings().reduce((n, s) => n + countBrandRefUses(s, 1), 0);
+ expect(total).toBe(3); // live home + about + header (stale home string is NOT counted)
+
+ let changed = 0;
+ act(() => { changed = ctx.rewriteSiteStates((s) => replaceBrandRef(s, 1, '#0f172a')); });
+ expect(changed).toBe(3); // about, header, live home
+ expect(ctx.pages.find((p) => p.id === 'p2')!.craftState).toContain('#0f172a');
+ expect(ctx.headerPage.craftState).toContain('#0f172a');
+ expect(ctx.footerPage.craftState).toBe(st('#fff'));
+ expect(ctx.pages[0].craftState).toContain('#0f172a');
+ expect(ctx.pages[0].craftState).not.toContain('STALE');
+ await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
+ const loaded = deserializeMock.mock.calls.map((c) => String(c[0]));
+ expect(loaded.some((s) => s.includes('#0f172a') && !s.includes('--wsb-brand-1'))).toBe(true);
+ act(() => root.unmount());
+ });
+
+ test('no references -> nothing changes', () => {
+ mount();
+ act(() => { ctx.setPagesCraftState([{ id: 'home', name: 'Home', slug: 'index', craftState: st('#fff') }]); ctx.setActivePageIdDirect('home'); });
+ serializeReturn = st('#fff');
+ let changed = -1;
+ act(() => { changed = ctx.rewriteSiteStates((s) => replaceBrandRef(s, 2, '#000000')); });
+ expect(changed).toBe(0);
+ expect(deserializeMock).not.toHaveBeenCalled();
+ act(() => root.unmount());
+ });
+});
diff --git a/craft/src/state/PageContext.tsx b/craft/src/state/PageContext.tsx
index c7f7fea..2acce10 100644
--- a/craft/src/state/PageContext.tsx
+++ b/craft/src/state/PageContext.tsx
@@ -57,6 +57,19 @@ interface PageContextValue {
* in the new page's slot.
*/
pendingCanvasState: () => string | null;
+ /**
+ * Every serialized craft state the site holds right now: all pages
+ * (the active one as the LIVE canvas), header, footer and global-section
+ * masters (as JSON). For read-only scans such as brand-colour usage counts.
+ */
+ siteStateStrings: () => string[];
+ /**
+ * Rewrites every stored state (pages, header, footer, global masters) and the
+ * live canvas through `transform`. The canvas change is one undoable step.
+ * Returns how many slots changed; marks the editor dirty through the state
+ * changes it makes.
+ */
+ rewriteSiteStates: (transform: (state: string) => string) => number;
switchPage: (pageId: string) => void;
editHeader: () => void;
editFooter: () => void;
@@ -277,6 +290,8 @@ const PageContext = createContext({
finishGlobal: () => NO_COMMIT,
globalZoneSaveView: () => null,
pendingCanvasState: () => null,
+ siteStateStrings: () => [],
+ rewriteSiteStates: () => 0,
switchPage: () => {},
editHeader: () => {},
editFooter: () => {},
@@ -502,6 +517,24 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const pendingCanvasState = useCallback((): string | null => pendingLoadRef.current.state, []);
+ const siteStateStrings = useCallback((): string[] => {
+ const cur = activePageIdRef.current;
+ const out: string[] = [];
+ let live: string | null = null;
+ try { live = query.serialize(); } catch { live = null; }
+ const slot = (id: string, stored: string | null) => {
+ const v = id === cur && live ? live : stored;
+ if (v) out.push(v);
+ };
+ for (const p of pagesRef.current) slot(p.id, p.craftState);
+ slot(HEADER_ID, headerPageRef.current.craftState);
+ slot(FOOTER_ID, footerPageRef.current.craftState);
+ if (isGlobalZoneId(cur) && live) out.push(live);
+ const lib = extras.get(GLOBAL_SECTIONS_KEY, {});
+ for (const m of Object.values(lib)) out.push(JSON.stringify(m.nodes));
+ return out;
+ }, [extras, query]);
+
/** Save whatever is on the current Frame back to the right state slot */
const saveCurrentState = useCallback(() => {
const currentId = activePageIdRef.current;
@@ -587,6 +620,56 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
[actions],
);
+ const rewriteSiteStates = useCallback((transform: (state: string) => string): number => {
+ const cur = activePageIdRef.current;
+ let live: string | null = null;
+ try { live = query.serialize(); } catch { live = null; }
+ const liveNext = live ? transform(live) : null;
+ const liveChanged = !!live && liveNext !== live;
+ const apply = (id: string, stored: string | null): string | null => {
+ if (id === cur && live && !isGlobalZoneId(cur)) return liveNext;
+ return stored ? transform(stored) : stored;
+ };
+ let changed = 0;
+ const nextPages = pagesRef.current.map((p) => {
+ const n = apply(p.id, p.craftState);
+ if (n === p.craftState) return p;
+ changed++;
+ return { ...p, craftState: n };
+ });
+ if (nextPages.some((p, i) => p !== pagesRef.current[i])) {
+ pagesRef.current = nextPages;
+ setPages(nextPages);
+ }
+ for (const [id, ref, set] of [[HEADER_ID, headerPageRef, setHeaderPage], [FOOTER_ID, footerPageRef, setFooterPage]] as const) {
+ const c = ref.current;
+ const n = apply(id, c.craftState);
+ if (n !== c.craftState) {
+ changed++;
+ ref.current = { ...c, craftState: n };
+ set(ref.current);
+ }
+ }
+ const lib = extras.get(GLOBAL_SECTIONS_KEY, {});
+ let libChanged = false;
+ const nextLib: GlobalLibrary = {};
+ for (const [k, m] of Object.entries(lib)) {
+ const before = JSON.stringify(m.nodes);
+ const after = transform(before);
+ if (after === before) { nextLib[k] = m; continue; }
+ libChanged = true;
+ changed++;
+ nextLib[k] = { ...m, nodes: JSON.parse(after) };
+ }
+ if (libChanged) extras.set(GLOBAL_SECTIONS_KEY, nextLib);
+ if (liveChanged && liveNext) {
+ if (isGlobalZoneId(cur)) changed++;
+ const fallback = cur === HEADER_ID ? EMPTY_HEADER : cur === FOOTER_ID ? EMPTY_FOOTER : EMPTY_CANVAS;
+ loadState(liveNext, fallback, { keepHistory: true });
+ }
+ return changed;
+ }, [extras, query, loadState]);
+
/**
* #57: leave the master zone for `targetId`. The master is committed (and
* every stored slot synced) first; the target is then loaded as it was
@@ -1049,6 +1132,8 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
finishGlobal,
globalZoneSaveView,
pendingCanvasState,
+ siteStateStrings,
+ rewriteSiteStates,
switchPage,
editHeader,
editFooter,
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/styles/editor.css b/craft/src/styles/editor.css
index 3577622..740c3ab 100644
--- a/craft/src/styles/editor.css
+++ b/craft/src/styles/editor.css
@@ -3389,3 +3389,26 @@ body {
.guided-regenerate-btn:hover { background: var(--color-bg-hover); }
.guided-regenerate-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 1px; }
/* /#followups */
+
+/* Saved colours: a "live" brand swatch gets a double ring */
+.token-swatch--live {
+ box-shadow: 0 0 0 2px var(--color-bg-surface, #18181b), 0 0 0 3px var(--color-accent);
+}
+.token-swatch--live.active {
+ box-shadow: 0 0 0 2px var(--color-bg-surface, #18181b), 0 0 0 4px var(--color-accent);
+}
+
+/* Colour pick tools (under every colour field) */
+.color-tool-btn {
+ font-size: 10px;
+ line-height: 1.2;
+ padding: 3px 7px;
+ color: var(--color-text-muted);
+ background: var(--color-bg-elevated);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+}
+.color-tool-btn:hover:not(:disabled) { color: var(--color-text); border-color: var(--color-text-muted); }
+.color-tool-btn:disabled { opacity: 0.5; cursor: default; }
+.color-hex-error { font-size: 11px; color: #ef4444; margin-top: 3px; }
diff --git a/craft/src/utils/color-input.test.ts b/craft/src/utils/color-input.test.ts
new file mode 100644
index 0000000..9384133
--- /dev/null
+++ b/craft/src/utils/color-input.test.ts
@@ -0,0 +1,24 @@
+import { describe, test, expect } from 'vitest';
+import { parseColorInput } from './color-input';
+
+describe('parseColorInput', () => {
+ test('hex with or without #, any case, 3/4/6/8 digits, normalised', () => {
+ expect(parseColorInput('#ABCDEF')).toEqual({ ok: true, value: '#abcdef' });
+ expect(parseColorInput('abc')).toEqual({ ok: true, value: '#abc' });
+ expect(parseColorInput(' 12345678 ')).toEqual({ ok: true, value: '#12345678' });
+ expect(parseColorInput('#abcd')).toEqual({ ok: true, value: '#abcd' });
+ });
+ test('rejects partial / garbage hex', () => {
+ for (const bad of ['#12', '#12345', '#1234567', '#gggggg', 'red!', '#', '12 34', 'url(x)', 'var(--x)']) {
+ expect(parseColorInput(bad).ok, bad).toBe(false);
+ }
+ });
+ test('keeps site token and brand refs, transparent, rgb()/hsl(); empty clears', () => {
+ expect(parseColorInput('var(--wsb-primary)')).toEqual({ ok: true, value: 'var(--wsb-primary)' });
+ expect(parseColorInput('var(--wsb-brand-2)')).toEqual({ ok: true, value: 'var(--wsb-brand-2)' });
+ expect(parseColorInput('Transparent')).toEqual({ ok: true, value: 'transparent' });
+ expect(parseColorInput('rgba(0, 0, 0, 0.5)')).toEqual({ ok: true, value: 'rgba(0, 0, 0, 0.5)' });
+ expect(parseColorInput('hsl(120 40% 50%)')).toEqual({ ok: true, value: 'hsl(120 40% 50%)' });
+ expect(parseColorInput('')).toEqual({ ok: true, value: '' });
+ });
+});
diff --git a/craft/src/utils/color-input.ts b/craft/src/utils/color-input.ts
new file mode 100644
index 0000000..226a292
--- /dev/null
+++ b/craft/src/utils/color-input.ts
@@ -0,0 +1,22 @@
+import { normalizeHex } from './swatches';
+import { isTokenRef } from './tokens';
+
+/**
+ * What a colour text box may commit. Hex (with or without '#', normalised to
+ * lowercase '#...'), site/brand token refs, `transparent`, simple rgb()/hsl()
+ * functions, or empty (a deliberate clear). Anything else is not committed.
+ */
+export type ColorInputResult = { ok: true; value: string } | { ok: false };
+
+const FN_RE = /^(?:rgb|rgba|hsl|hsla)\(\s*[-\d.%\s,/]+\)$/i;
+
+export function parseColorInput(text: string): ColorInputResult {
+ const t = String(text ?? '').trim();
+ if (t === '') return { ok: true, value: '' };
+ const isRef: boolean = isTokenRef(t);
+ if (isRef) return { ok: true, value: t };
+ if (t.toLowerCase() === 'transparent') return { ok: true, value: 'transparent' };
+ if (FN_RE.test(t)) return { ok: true, value: t };
+ const h = normalizeHex(t);
+ return h ? { ok: true, value: h } : { ok: false };
+}
diff --git a/craft/src/utils/color-pick.test.ts b/craft/src/utils/color-pick.test.ts
new file mode 100644
index 0000000..0c5c17a
--- /dev/null
+++ b/craft/src/utils/color-pick.test.ts
@@ -0,0 +1,163 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it } from 'vitest';
+import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext';
+import {
+ eyedropperAvailable, parseComputedColor, pickFromCanvas, pickFromScreen, pickWithEyeDropper, rgbToHex, screenPickKind,
+} from './color-pick';
+
+const design = (over: Partial = {}): SiteDesign => ({ ...DEFAULT_SITE_DESIGN, ...over });
+
+describe('rgbToHex / parseComputedColor', () => {
+ it('rgbToHex', () => {
+ expect(rgbToHex(255, 0, 16)).toBe('#ff0010');
+ expect(rgbToHex(0, 0, 0, 1)).toBe('#000000');
+ expect(rgbToHex(18, 52, 86, 0.5)).toBe('#12345680');
+ expect(rgbToHex(300, -4, 1.4)).toBe('#ff0001');
+ });
+ it('parses computed rgb()/rgba() in comma and space syntax', () => {
+ expect(parseComputedColor('rgb(18, 52, 86)')).toEqual({ hex: '#123456', alpha: 1 });
+ expect(parseComputedColor('rgba(18, 52, 86, 0.5)')).toEqual({ hex: '#12345680', alpha: 0.5 });
+ expect(parseComputedColor('rgb(18 52 86 / 50%)')).toEqual({ hex: '#12345680', alpha: 0.5 });
+ expect(parseComputedColor('rgba(0, 0, 0, 0)')).toEqual({ hex: '#00000000', alpha: 0 });
+ expect(parseComputedColor('transparent')).toEqual({ hex: '#00000000', alpha: 0 });
+ expect(parseComputedColor('rgba(18, 52, 86, 1)')).toEqual({ hex: '#123456', alpha: 1 });
+ });
+ it('rejects junk', () => {
+ expect(parseComputedColor('')).toBeNull();
+ expect(parseComputedColor('red')).toBeNull();
+ expect(parseComputedColor('var(--x)')).toBeNull();
+ });
+});
+
+describe('eyedropper / screen kind', () => {
+ afterEach(() => { delete (window as unknown as Record).EyeDropper; });
+ it('reports unsupported when window.EyeDropper is absent', async () => {
+ expect(eyedropperAvailable()).toBe(false);
+ expect(screenPickKind()).toBe('none');
+ const r = await pickWithEyeDropper();
+ expect(r).toMatchObject({ ok: false, reason: 'unsupported' });
+ expect(await pickFromScreen()).toMatchObject({ ok: false, reason: 'unsupported' });
+ });
+ it('uses a native EyeDropper (lowercased hex) and maps abort to cancelled', async () => {
+ (window as unknown as Record).EyeDropper = class {
+ open(o?: { signal?: AbortSignal }) {
+ return new Promise((res, rej) => {
+ if (o?.signal) o.signal.addEventListener('abort', () => rej(new DOMException('x', 'AbortError')));
+ else res({ sRGBHex: '#ABCDEF' });
+ });
+ }
+ };
+ expect(eyedropperAvailable()).toBe(true);
+ expect(screenPickKind()).toBe('eyedropper');
+ expect(await pickWithEyeDropper()).toEqual({ ok: true, hex: '#abcdef', source: 'eyedropper' });
+ const ac = new AbortController();
+ const p = pickWithEyeDropper(ac.signal);
+ ac.abort();
+ expect(await p).toMatchObject({ ok: false, reason: 'cancelled' });
+ });
+});
+
+describe('pickFromCanvas (jsdom, inline styles)', () => {
+ afterEach(() => { document.body.innerHTML = ''; document.head.querySelectorAll('style[data-wsb-pick]').forEach((s) => s.remove()); });
+ const mount = () => {
+ document.body.innerHTML = `
+
+ out
`;
+ return (id: string) => document.getElementById(id)!;
+ };
+ const click = (el: Element) => el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, clientX: 1, clientY: 1 }));
+
+ it('returns background/text/border candidates with default hex = painted background', async () => {
+ const $ = mount();
+ const p = pickFromCanvas({ design: design({ primaryColor: '#3b82f6' }) });
+ click($('card'));
+ const r = await p;
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.source).toBe('canvas');
+ expect(r.hex).toBe('#3b82f6');
+ const by = Object.fromEntries(r.candidates.map((c) => [c.kind, c]));
+ expect(by.background.hex).toBe('#3b82f6');
+ expect(by.background.match).toMatchObject({ kind: 'token', ref: 'var(--wsb-primary)' });
+ expect(by.text.hex).toBe('#ffffff');
+ expect(by.border.hex).toBe('#010203');
+ expect(by.border.match).toBeUndefined();
+ });
+
+ it('non-painting element: hex is the text colour; background is the nearest painted ancestor', async () => {
+ const $ = mount();
+ const p = pickFromCanvas({ design: design() });
+ click($('inner'));
+ const r = await p;
+ if (!r.ok) throw new Error('expected ok');
+ expect(r.hex).toBe('#ffffff');
+ expect(r.candidates.find((c) => c.kind === 'background')!.hex).toBe('#3b82f6');
+ expect(r.candidates.find((c) => c.kind === 'border')).toBeUndefined();
+ });
+
+ it('prefers a published brand swatch over a token match', async () => {
+ const $ = mount();
+ const d = design({
+ primaryColor: '#3b82f6',
+ swatches: [{ id: 'a', label: 'Logo blue', hex: '#3B82F6', brand: true, slot: 2 }],
+ });
+ const p = pickFromCanvas({ design: d });
+ click($('card'));
+ const r = await p;
+ if (!r.ok) throw new Error('expected ok');
+ expect(r.candidates[0].match).toEqual({ kind: 'swatch', ref: 'var(--wsb-brand-2)', label: 'Logo blue' });
+ });
+
+ it('matches a non-brand swatch by hex ref', async () => {
+ const $ = mount();
+ const p = pickFromCanvas({ design: design({ swatches: [{ id: 'a', label: 'Sky', hex: '#0a141e', brand: false }] }) });
+ click($('plain'));
+ const r = await p;
+ if (!r.ok) throw new Error('expected ok');
+ expect(r.hex).toBe('#0a141e');
+ expect(r.candidates.find((c) => c.kind === 'text')!.match).toEqual({ kind: 'swatch', ref: '#0a141e', label: 'Sky' });
+ });
+
+ it('swallows the click (capture phase) so craft nodes are not selected', async () => {
+ const $ = mount();
+ let leaked = 0;
+ $('card').addEventListener('click', () => leaked++);
+ $('card').addEventListener('mousedown', () => leaked++);
+ const p = pickFromCanvas({ design: design() });
+ $('card').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
+ click($('card'));
+ await p;
+ expect(leaked).toBe(0);
+ });
+
+ it('Esc cancels, abort cancels, outside click cancels', async () => {
+ mount();
+ let p = pickFromCanvas({ design: design() });
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ expect(await p).toMatchObject({ ok: false, reason: 'cancelled' });
+
+ const ac = new AbortController();
+ p = pickFromCanvas({ design: design(), signal: ac.signal });
+ ac.abort();
+ expect(await p).toMatchObject({ ok: false, reason: 'cancelled' });
+
+ p = pickFromCanvas({ design: design() });
+ click(document.getElementById('outside')!);
+ expect(await p).toMatchObject({ ok: false, reason: 'cancelled' });
+ });
+
+ it('no frame -> error; cleans up its overlay and listeners', async () => {
+ expect(await pickFromCanvas({ design: design() })).toMatchObject({ ok: false, reason: 'error' });
+ const $ = mount();
+ const p = pickFromCanvas({ design: design() });
+ expect(document.querySelector('[data-wsb-pick]')).not.toBeNull();
+ click($('plain'));
+ await p;
+ expect(document.querySelector('[data-wsb-pick]')).toBeNull();
+ });
+});
diff --git a/craft/src/utils/color-pick.ts b/craft/src/utils/color-pick.ts
new file mode 100644
index 0000000..fc357bb
--- /dev/null
+++ b/craft/src/utils/color-pick.ts
@@ -0,0 +1,225 @@
+/**
+ * "Pick a colour from anywhere" engines (no React). Two sources:
+ * - the native EyeDropper API (Chromium only; Firefox/Safari have none), and
+ * - pickFromCanvas: an in-page pick mode over the editor canvas
+ * (`.canvas-device-frame`) that reads the clicked element's COMPUTED colours.
+ * Firefox has no screen-sampling fallback: see
+ * scratchpad/color/firefox-spike-verdict.md (tab capture is not viable there).
+ */
+import type { SiteDesign } from '../state/SiteDesignContext';
+import { COLOR_TOKENS, resolveTokenValue, tokenRef } from './tokens';
+import { findSwatchByHex, normalizeHex, swatchValue, type SavedSwatch } from './swatches';
+
+export type PickFailReason = 'cancelled' | 'unsupported' | 'cross-origin' | 'error';
+export type PickResult =
+ | { ok: true; hex: string; source: 'canvas' | 'image' | 'eyedropper' | 'tab-capture' }
+ | { ok: false; reason: PickFailReason; message: string };
+
+export type CandidateKind = 'background' | 'text' | 'border';
+export interface PickMatch { kind: 'token' | 'swatch'; ref: string; label: string }
+export interface CanvasCandidate { kind: CandidateKind; hex: string; match?: PickMatch }
+export type CanvasPickResult =
+ | { ok: true; hex: string; source: 'canvas'; candidates: CanvasCandidate[] }
+ | { ok: false; reason: PickFailReason; message: string };
+
+const fail = (reason: PickFailReason, message: string): { ok: false; reason: PickFailReason; message: string } => ({ ok: false, reason, message });
+const CANCELLED = 'Colour picking was cancelled.';
+
+// ------------------------------------------------------------------ colour parsing
+const h2 = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
+
+/** r,g,b 0-255 (+ optional alpha 0-1) -> lowercase #rrggbb, or #rrggbbaa only when alpha < 1. */
+export function rgbToHex(r: number, g: number, b: number, a = 1): string {
+ const base = `#${h2(r)}${h2(g)}${h2(b)}`;
+ return a >= 1 ? base : base + h2(a * 255);
+}
+
+const RGB_RE = /^rgba?\(\s*([\d.]+)(?:\s*,\s*|\s+)([\d.]+)(?:\s*,\s*|\s+)([\d.]+)(?:\s*(?:,|\/)\s*([\d.]+)(%?))?\s*\)$/i;
+
+/** Parses a COMPUTED colour (`rgb()`/`rgba()`/`transparent`). Null for anything else. */
+export function parseComputedColor(v: string): { hex: string; alpha: number } | null {
+ const s = String(v ?? '').trim();
+ if (s.toLowerCase() === 'transparent') return { hex: '#00000000', alpha: 0 };
+ const m = RGB_RE.exec(s);
+ if (!m) return null;
+ let alpha = m[4] === undefined ? 1 : Number(m[4]);
+ if (m[5] === '%') alpha /= 100;
+ alpha = Math.max(0, Math.min(1, alpha));
+ return { hex: rgbToHex(Number(m[1]), Number(m[2]), Number(m[3]), alpha), alpha };
+}
+
+/** #rgb/#rgba -> #rrggbb[aa]; lowercase; null when invalid. */
+function canonHex(v: string): string | null {
+ const h = normalizeHex(v);
+ if (!h) return null;
+ return h.length === 4 || h.length === 5 ? '#' + [...h.slice(1)].map((c) => c + c).join('') : h;
+}
+
+// ------------------------------------------------------------------ site/brand matching
+/** Does `hex` equal a brand swatch, design token or saved swatch? Brand swatch > token > plain swatch. */
+export function matchSiteColor(hex: string, design: SiteDesign): PickMatch | undefined {
+ const want = canonHex(hex);
+ if (!want) return undefined;
+ const swatches: SavedSwatch[] = Array.isArray(design.swatches) ? design.swatches : [];
+ const canon = swatches.map((s) => ({ ...s, hex: canonHex(s.hex) ?? s.hex }));
+ const sw = findSwatchByHex(canon, want);
+ const brandSw = sw && sw.brand ? sw : undefined;
+ const label = (s: SavedSwatch) => s.label.trim() || s.hex;
+ if (brandSw) {
+ const ref = swatchValue(brandSw);
+ if (ref !== brandSw.hex) return { kind: 'swatch', ref, label: label(brandSw) };
+ }
+ for (const t of COLOR_TOKENS) {
+ const val = canonHex(resolveTokenValue(tokenRef(t.key), design));
+ if (val === want) return { kind: 'token', ref: tokenRef(t.key), label: t.label };
+ }
+ if (sw) return { kind: 'swatch', ref: swatchValue(sw), label: label(sw) };
+ return undefined;
+}
+
+// ------------------------------------------------------------------ native EyeDropper
+interface EyeDropperLike { open(o?: { signal?: AbortSignal }): Promise<{ sRGBHex: string }> }
+type EyeDropperCtor = new () => EyeDropperLike;
+const eyeCtor = (): EyeDropperCtor | undefined =>
+ typeof window === 'undefined' ? undefined : (window as unknown as { EyeDropper?: EyeDropperCtor }).EyeDropper;
+
+export function eyedropperAvailable(): boolean {
+ return typeof eyeCtor() === 'function';
+}
+
+export async function pickWithEyeDropper(signal?: AbortSignal): Promise {
+ const Ctor = eyeCtor();
+ if (!Ctor) return fail('unsupported', "This browser can't pick colours from the screen.");
+ try {
+ const r = await new Ctor().open(signal ? { signal } : undefined);
+ const hex = normalizeHex(r.sRGBHex);
+ return hex ? { ok: true, hex, source: 'eyedropper' } : fail('error', 'The eyedropper returned an unreadable colour.');
+ } catch (e) {
+ const name = (e as { name?: string })?.name;
+ if (name === 'AbortError' || signal?.aborted) return fail('cancelled', CANCELLED);
+ return fail('error', 'The eyedropper could not pick a colour.');
+ }
+}
+
+/** What "pick from the screen" can do here. Firefox: none (tab capture is not viable, see the spike verdict). */
+export function screenPickKind(): 'eyedropper' | 'tab-capture' | 'none' {
+ return eyedropperAvailable() ? 'eyedropper' : 'none';
+}
+
+export function pickFromScreen(signal?: AbortSignal): Promise {
+ return pickWithEyeDropper(signal);
+}
+
+// ------------------------------------------------------------------ canvas pick mode
+function readCandidates(el: Element, frame: Element, design: SiteDesign): { candidates: CanvasCandidate[]; hex: string | null } {
+ const win = el.ownerDocument.defaultView!;
+ const cs = win.getComputedStyle(el);
+ const out: CanvasCandidate[] = [];
+ const add = (kind: CandidateKind, hex: string) => { const m = matchSiteColor(hex, design); out.push(m ? { kind, hex, match: m } : { kind, hex }); };
+
+ const own = parseComputedColor(cs.backgroundColor);
+ const ownPaints = !!own && own.alpha > 0;
+ // Nearest painted background: the element, else its ancestors up to (and including) the frame.
+ let bg: string | null = null;
+ for (let n: Element | null = el; n; n = n === frame ? null : n.parentElement) {
+ const c = parseComputedColor(win.getComputedStyle(n).backgroundColor);
+ if (c && c.alpha > 0) { bg = c.hex; break; }
+ }
+ if (bg) add('background', bg);
+ const txt = parseComputedColor(cs.color);
+ if (txt) add('text', txt.hex);
+ const bw = parseFloat(cs.borderTopWidth) || 0;
+ const bc = parseComputedColor(cs.borderTopColor);
+ if (bw > 0 && cs.borderTopStyle !== 'none' && cs.borderTopStyle !== 'hidden' && bc && bc.alpha > 0) add('border', bc.hex);
+
+ const primary = ownPaints && bg ? bg : (txt?.hex ?? bg);
+ return { candidates: out, hex: primary };
+}
+
+const BLOCKED = ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'dblclick', 'auxclick', 'contextmenu', 'dragstart', 'touchstart', 'touchend'];
+
+let activeCancel: (() => void) | null = null;
+
+export function pickFromCanvas(opts: { frameSelector?: string; design: SiteDesign; signal?: AbortSignal }): Promise {
+ const { design, signal } = opts;
+ const selector = opts.frameSelector ?? '.canvas-device-frame';
+ if (typeof document === 'undefined') return Promise.resolve(fail('unsupported', 'Colour picking needs a browser.'));
+ const found = document.querySelector(selector);
+ if (!found) return Promise.resolve(fail('error', "Couldn't find the page canvas to pick from."));
+ if (signal?.aborted) return Promise.resolve(fail('cancelled', CANCELLED));
+ const frame: Element = found;
+ activeCancel?.();
+
+ return new Promise((resolve) => {
+ const style = document.createElement('style');
+ style.setAttribute('data-wsb-pick', 'style');
+ // The header/footer zone previews are pointer-events:none in the editor (so they never take clicks); while
+ // picking they must be hit-testable, or the header and its logo box could never be picked.
+ style.textContent = `${selector},${selector} *{cursor:crosshair !important}${selector} [data-zone-preview],${selector} [data-zone-preview] *{pointer-events:auto !important}`;
+ const outline = document.createElement('div');
+ outline.setAttribute('data-wsb-pick', 'outline');
+ outline.style.cssText = 'position:fixed;pointer-events:none;z-index:2147483646;outline:2px solid #fff;box-shadow:0 0 0 2px #1d4ed8;display:none';
+ const tip = document.createElement('div');
+ tip.setAttribute('data-wsb-pick', 'tip');
+ tip.style.cssText = 'position:fixed;pointer-events:none;z-index:2147483647;display:none;align-items:center;gap:6px;padding:3px 7px;border-radius:6px;background:#111827;color:#fff;font:12px/1.2 system-ui,sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.4)';
+ const chip = document.createElement('span');
+ chip.style.cssText = 'width:14px;height:14px;border-radius:3px;border:1px solid #fff;display:inline-block';
+ const tipText = document.createElement('span');
+ tip.append(chip, tipText);
+ document.head.appendChild(style);
+ document.body.append(outline, tip);
+
+ let done = false;
+ const inFrame = (t: EventTarget | null): t is Element => t instanceof Element && frame.contains(t);
+ const finish = (r: CanvasPickResult) => {
+ if (done) return;
+ done = true;
+ for (const t of BLOCKED) window.removeEventListener(t, block, true);
+ window.removeEventListener('click', onClick, true);
+ window.removeEventListener('mousemove', onMove, true);
+ window.removeEventListener('keydown', onKey, true);
+ signal?.removeEventListener('abort', onAbort);
+ style.remove(); outline.remove(); tip.remove();
+ if (activeCancel === cancel) activeCancel = null;
+ resolve(r);
+ };
+ const cancel = () => finish(fail('cancelled', CANCELLED));
+ const swallow = (e: Event) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); };
+ function block(e: Event) {
+ if (inFrame(e.target)) swallow(e);
+ else if (e.type === 'mousedown' || e.type === 'pointerdown') cancel();
+ }
+ function onClick(e: Event) {
+ if (!inFrame(e.target)) { cancel(); return; }
+ swallow(e);
+ const { candidates, hex } = readCandidates(e.target, frame, design);
+ if (!hex) { finish(fail('error', "Couldn't read a colour from that spot.")); return; }
+ finish({ ok: true, hex, source: 'canvas', candidates });
+ }
+ function onMove(e: Event) {
+ const t = e.target;
+ if (!inFrame(t)) { outline.style.display = 'none'; tip.style.display = 'none'; return; }
+ const r = t.getBoundingClientRect();
+ Object.assign(outline.style, { display: 'block', left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` });
+ const { hex } = readCandidates(t, frame, design);
+ const me = e as MouseEvent;
+ if (hex) {
+ chip.style.background = hex;
+ tipText.textContent = hex;
+ Object.assign(tip.style, { display: 'flex', left: `${(me.clientX || 0) + 14}px`, top: `${(me.clientY || 0) + 14}px` });
+ }
+ }
+ function onKey(e: Event) {
+ if ((e as KeyboardEvent).key === 'Escape') { swallow(e); cancel(); }
+ }
+
+ function onAbort() { cancel(); }
+
+ for (const t of BLOCKED) window.addEventListener(t, block, true);
+ window.addEventListener('click', onClick, true);
+ window.addEventListener('mousemove', onMove, true);
+ window.addEventListener('keydown', onKey, true);
+ signal?.addEventListener('abort', onAbort);
+ activeCancel = cancel;
+ });
+}
diff --git a/craft/src/utils/contrast.test.ts b/craft/src/utils/contrast.test.ts
new file mode 100644
index 0000000..e067034
--- /dev/null
+++ b/craft/src/utils/contrast.test.ts
@@ -0,0 +1,62 @@
+import { describe, test, expect } from 'vitest';
+import { checkContrast, suggestFix, siteColorPairs } from './contrast';
+import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
+
+const d = DEFAULT_SITE_DESIGN;
+const design = (o: Partial) => ({ ...d, ...o });
+
+describe('checkContrast', () => {
+ test('black on white passes AAA 21:1', () => {
+ expect(checkContrast('#000', '#fff', d)).toEqual({ status: 'pass', ratio: 21, required: 4.5, level: 'AAA' });
+ });
+ test('light grey on white fails', () => {
+ const v = checkContrast('#aaaaaa', '#ffffff', d);
+ expect(v).toMatchObject({ status: 'fail', required: 4.5, level: 'fail' });
+ expect(v.ratio).toBe(2.32);
+ });
+ test('large text uses 3:1', () => {
+ expect(checkContrast('#888888', '#ffffff', d).status).toBe('fail'); // 3.54
+ expect(checkContrast('#888888', '#ffffff', d, { large: true })).toMatchObject({ status: 'pass', required: 3, level: 'AA-large' });
+ });
+ test('resolves tokens and brand tokens', () => {
+ const dd = design({ textColor: '#ffffff', backgroundColor: '#ffffff' });
+ expect(checkContrast('var(--wsb-text)', 'var(--wsb-bg)', dd).status).toBe('fail');
+ const b = design({ swatches: [{ id: 's1', label: 'Brand', hex: '#000000', brand: true, slot: 1 } as any] });
+ expect(checkContrast('var(--wsb-brand-1)', '#ffffff', b)).toMatchObject({ status: 'pass', ratio: 21 });
+ });
+ test('rgb() and semi-transparent hex composite over bg', () => {
+ expect(checkContrast('rgb(0, 0, 0)', 'rgb(255,255,255)', d).ratio).toBe(21);
+ // ~50% black over white (unrounded 127.5 grey) -> 4.0
+ expect(checkContrast('#00000080', '#ffffff', d).ratio).toBe(4);
+ });
+ test('unchecked reasons', () => {
+ expect(checkContrast('#000', 'linear-gradient(red, blue)', d)).toMatchObject({ status: 'unchecked', reason: 'gradient' });
+ expect(checkContrast('#000', 'url(a.png)', d)).toMatchObject({ status: 'unchecked', reason: 'image' });
+ expect(checkContrast('#000', 'transparent', d).reason).toBe('transparent');
+ expect(checkContrast('#000', '#ffffff80', d).reason).toBe('transparent');
+ expect(checkContrast('var(--nope)', '#fff', d).reason).toBe('unresolved colour');
+ });
+});
+
+describe('suggestFix', () => {
+ test('keeps hue and passes', () => {
+ const s = suggestFix('#93c5fd', '#ffffff', d)!;
+ expect(s.ratio).toBeGreaterThanOrEqual(4.5);
+ expect(checkContrast(s.hex, '#ffffff', d).status).toBe('pass');
+ expect(s.hex).toMatch(/^#[0-9a-f]{6}$/);
+ });
+ test('lightens on dark backgrounds', () => {
+ const s = suggestFix('#333333', '#111111', d)!;
+ expect(checkContrast(s.hex, '#111111', d).status).toBe('pass');
+ });
+ test('null when passing or unchecked', () => {
+ expect(suggestFix('#000', '#fff', d)).toBeNull();
+ expect(suggestFix('#000', 'linear-gradient(red,blue)', d)).toBeNull();
+ });
+});
+
+describe('siteColorPairs', () => {
+ test('lists text, muted, link and primary button pairs', () => {
+ expect(siteColorPairs(d).map((p) => p.id)).toEqual(['text-bg', 'muted-bg', 'link-bg', 'button-primary']);
+ });
+});
diff --git a/craft/src/utils/contrast.ts b/craft/src/utils/contrast.ts
new file mode 100644
index 0000000..1dde6b5
--- /dev/null
+++ b/craft/src/utils/contrast.ts
@@ -0,0 +1,159 @@
+import type { SiteDesign } from '../state/SiteDesignContext';
+import { resolveTokenValue, tokenRef } from './tokens';
+import type { RGB } from './color';
+
+/* ---------- WCAG 2.x contrast checking (pure) ----------
+ Only flat, fully-known colour pairs are judged. Gradients, images,
+ transparent/unknown backgrounds and unresolved tokens come back as
+ 'unchecked' with a reason -- never as a guess. */
+
+export type ContrastVerdict = {
+ status: 'pass' | 'fail' | 'unchecked';
+ /** Contrast ratio, 2 decimals. Absent when unchecked. */
+ ratio?: number;
+ required: 4.5 | 3;
+ /** For unchecked: 'gradient' | 'image' | 'transparent' | 'unresolved colour'. */
+ reason?: string;
+ level: 'AAA' | 'AA' | 'AA-large' | 'fail';
+};
+
+type RGBA = { rgb: RGB; a: number };
+
+function parseRgba(input: string): RGBA | null {
+ const v = input.trim().toLowerCase();
+ let m = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(v);
+ if (m) {
+ let h = m[1];
+ if (h.length <= 4) h = h.split('').map((c) => c + c).join('');
+ const a = h.length === 8 ? parseInt(h.slice(6), 16) / 255 : 1;
+ return { rgb: [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)], a };
+ }
+ m = /^rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)\s*(?:[,/]\s*([\d.]+%?)\s*)?\)$/.exec(v);
+ if (m) {
+ let a = 1;
+ if (m[4] !== undefined) a = m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
+ if (Number.isNaN(a)) return null;
+ return { rgb: [Math.min(255, +m[1]), Math.min(255, +m[2]), Math.min(255, +m[3])], a: Math.min(1, a) };
+ }
+ return null;
+}
+
+function lum([r, g, b]: RGB): number {
+ const f = (c: number) => {
+ const s = c / 255;
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
+ };
+ return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
+}
+
+const ratioOf = (a: RGB, b: RGB) => {
+ const la = lum(a), lb = lum(b);
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
+};
+
+const hex2 = (n: number) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, '0');
+const toHex = ([r, g, b]: RGB) => `#${hex2(r)}${hex2(g)}${hex2(b)}`;
+const round2 = (n: number) => Math.round(n * 100) / 100;
+
+type Resolved = { ok: true; c: RGBA } | { ok: false; reason: string };
+
+function resolveColour(value: string, design: SiteDesign): Resolved {
+ const v = resolveTokenValue(String(value ?? ''), design).trim();
+ if (/gradient/i.test(v)) return { ok: false, reason: 'gradient' };
+ if (/url\(/i.test(v)) return { ok: false, reason: 'image' };
+ if (v.toLowerCase() === 'transparent') return { ok: false, reason: 'transparent' };
+ const c = parseRgba(v);
+ if (!c) return { ok: false, reason: 'unresolved colour' };
+ if (c.a <= 0) return { ok: false, reason: 'transparent' };
+ return { ok: true, c };
+}
+
+/** Resolves both sides to opaque RGB (fg composited over bg), or the reason it cannot. */
+function resolvePair(fg: string, bg: string, design: SiteDesign): { fg: RGB; bg: RGB } | { reason: string } {
+ const b = resolveColour(bg, design);
+ if (!b.ok) return { reason: b.reason };
+ if (b.c.a < 1) return { reason: 'transparent' }; // translucent over an unknown layer
+ const f = resolveColour(fg, design);
+ if (!f.ok) return { reason: f.reason };
+ const a = f.c.a;
+ const comp = f.c.rgb.map((c, i) => c * a + b.c.rgb[i] * (1 - a)) as RGB;
+ return { fg: comp, bg: b.c.rgb };
+}
+
+export function checkContrast(fg: string, bg: string, design: SiteDesign, opts?: { large?: boolean }): ContrastVerdict {
+ const required: 4.5 | 3 = opts?.large ? 3 : 4.5;
+ const p = resolvePair(fg, bg, design);
+ if ('reason' in p) return { status: 'unchecked', required, reason: p.reason, level: 'fail' };
+ const raw = ratioOf(p.fg, p.bg);
+ const level: ContrastVerdict['level'] = raw >= 7 ? 'AAA' : raw >= 4.5 ? 'AA' : raw >= 3 ? 'AA-large' : 'fail';
+ return { status: raw >= required ? 'pass' : 'fail', ratio: round2(raw), required, level };
+}
+
+/* ---- HSL lightness walk ---- */
+function rgbToHsl([r, g, b]: RGB): [number, number, number] {
+ r /= 255; g /= 255; b /= 255;
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
+ const l = (max + min) / 2;
+ if (max === min) return [0, 0, l];
+ const d = max - min;
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+ let h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
+ return [h / 6, s, l];
+}
+function hslToRgb([h, s, l]: [number, number, number]): RGB {
+ if (s === 0) return [l * 255, l * 255, l * 255];
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+ const p = 2 * l - q;
+ const f = (t: number) => {
+ if (t < 0) t += 1;
+ if (t > 1) t -= 1;
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
+ if (t < 1 / 2) return q;
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
+ return p;
+ };
+ return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255];
+}
+
+/**
+ * A readable replacement for `fg` on `bg`: the same hue with lightness walked
+ * toward the better-contrasting extreme, falling back to black/white. Null when
+ * the pair is unchecked, already passes, or nothing reaches the threshold.
+ */
+export function suggestFix(fg: string, bg: string, design: SiteDesign, opts?: { large?: boolean }): { hex: string; ratio: number } | null {
+ const required = opts?.large ? 3 : 4.5;
+ const p = resolvePair(fg, bg, design);
+ if ('reason' in p) return null;
+ if (ratioOf(p.fg, p.bg) >= required) return null;
+ const [h, s, l] = rgbToHsl(p.fg);
+ const darker = lum(p.bg) > 0.179; // light-ish surface -> darken the text
+ for (let i = 1; i <= 100; i++) {
+ const nl = darker ? l - i / 100 : l + i / 100;
+ if (nl < 0 || nl > 1) break;
+ const hex = toHex(hslToRgb([h, s, nl]));
+ const rgb = parseRgba(hex)!.rgb;
+ const r = ratioOf(rgb, p.bg);
+ if (r >= required) return { hex, ratio: round2(r) };
+ }
+ for (const hex of ['#000000', '#ffffff']) {
+ const r = ratioOf(parseRgba(hex)!.rgb, p.bg);
+ if (r >= required) return { hex, ratio: round2(r) };
+ }
+ return null;
+}
+
+export interface SiteColorPair { id: string; label: string; fg: string; bg: string; large?: boolean }
+
+/**
+ * Site-level colour pairs that are always seen together. The primary button
+ * pair mirrors ButtonLink's craft defaults (text `var(--wsb-bg)` on
+ * `var(--wsb-primary)`, 16px/600 -> normal text).
+ */
+export function siteColorPairs(_design: SiteDesign): SiteColorPair[] {
+ return [
+ { id: 'text-bg', label: 'Text on background', fg: tokenRef('text'), bg: tokenRef('bg') },
+ { id: 'muted-bg', label: 'Muted text on background', fg: tokenRef('muted'), bg: tokenRef('bg') },
+ { id: 'link-bg', label: 'Link on background', fg: tokenRef('link'), bg: tokenRef('bg') },
+ { id: 'button-primary', label: 'Button text on primary', fg: tokenRef('bg'), bg: tokenRef('primary') },
+ ];
+}
diff --git a/craft/src/utils/effective-background.test.ts b/craft/src/utils/effective-background.test.ts
new file mode 100644
index 0000000..5a3f10a
--- /dev/null
+++ b/craft/src/utils/effective-background.test.ts
@@ -0,0 +1,52 @@
+import { describe, test, expect } from 'vitest';
+import { effectiveBackgroundOf, isLargeText } from './effective-background';
+
+const n = (parent: string | null, props: Record = {}) => ({ data: { parent, props } });
+
+describe('effectiveBackgroundOf', () => {
+ test("uses the node's own flat background first", () => {
+ const nodes = { ROOT: n(null, { style: { backgroundColor: '#fff' } }), t: n('ROOT', { style: { backgroundColor: '#123456' } }) };
+ expect(effectiveBackgroundOf(nodes, 't')).toBe('#123456');
+ });
+ test('else the nearest ancestor with a background (style or direct prop)', () => {
+ const nodes = {
+ ROOT: n(null, { style: { backgroundColor: '#ffffff' } }),
+ sec: n('ROOT', { backgroundColor: 'var(--wsb-primary)' }),
+ col: n('sec', { style: {} }),
+ t: n('col', {}),
+ };
+ expect(effectiveBackgroundOf(nodes, 't')).toBe('var(--wsb-primary)');
+ });
+ test('a gradient / image background is returned as-is so the contrast check reports "not checked"', () => {
+ const nodes = {
+ ROOT: n(null, {}),
+ hero: n('ROOT', { style: { backgroundImage: 'url(a.jpg)' } }),
+ t: n('hero', {}),
+ };
+ expect(effectiveBackgroundOf(nodes, 't')).toMatch(/url\(/);
+ const g = { ROOT: n(null, {}), s: n('ROOT', { style: { background: 'linear-gradient(90deg,#000,#fff)' } }), t: n('s', {}) };
+ expect(effectiveBackgroundOf(g, 't')).toMatch(/gradient/);
+ });
+ test('transparent / blank backgrounds are skipped; nothing found -> null', () => {
+ const nodes = { ROOT: n(null, { style: { backgroundColor: 'transparent' } }), t: n('ROOT', { style: { backgroundColor: '' } }) };
+ expect(effectiveBackgroundOf(nodes, 't')).toBeNull();
+ });
+ test('unknown id / cyclic parents do not hang', () => {
+ expect(effectiveBackgroundOf({}, 'x')).toBeNull();
+ const cyc = { a: n('b', {}), b: n('a', {}) };
+ expect(effectiveBackgroundOf(cyc, 'a')).toBeNull();
+ });
+});
+
+describe('isLargeText (WCAG: >=24px, or >=18.66px bold)', () => {
+ test('px / rem / em', () => {
+ expect(isLargeText('24px', '400')).toBe(true);
+ expect(isLargeText('23px', '400')).toBe(false);
+ expect(isLargeText('19px', '700')).toBe(true);
+ expect(isLargeText('18px', 'bold')).toBe(false);
+ expect(isLargeText('1.5rem', undefined)).toBe(true);
+ expect(isLargeText('2em', '400')).toBe(true);
+ expect(isLargeText(undefined, undefined)).toBe(false);
+ expect(isLargeText('large', '400')).toBe(false);
+ });
+});
diff --git a/craft/src/utils/effective-background.ts b/craft/src/utils/effective-background.ts
new file mode 100644
index 0000000..2b04797
--- /dev/null
+++ b/craft/src/utils/effective-background.ts
@@ -0,0 +1,45 @@
+/**
+ * The background a text element is actually read against, found cheaply from
+ * the Craft node tree. Bounded rule (spec: "own text colour vs own background"):
+ * 1. the element's own flat background, else
+ * 2. the nearest ancestor that declares one (style.backgroundColor or a
+ * direct backgroundColor prop), else
+ * 3. null (the caller falls back to the site Background token).
+ * A gradient / image on the way is returned verbatim so the check reports
+ * "not checked" instead of guessing. No stacking of translucent layers, no
+ * overlay inference, no computed styles.
+ */
+interface NodeLike { data?: { parent?: string | null; props?: Record } }
+
+const blank = (v: unknown): boolean => {
+ if (typeof v !== 'string') return true;
+ const t = v.trim().toLowerCase();
+ return t === '' || t === 'transparent' || t === 'none' || t === 'inherit' || t === 'initial';
+};
+const paintsImage = (v: unknown): v is string => typeof v === 'string' && /gradient|url\(/i.test(v);
+
+export function effectiveBackgroundOf(nodes: Record, id: string): string | null {
+ const seen = new Set();
+ let cur: string | null | undefined = id;
+ while (cur && !seen.has(cur) && nodes[cur]) {
+ seen.add(cur);
+ const props = nodes[cur].data?.props ?? {};
+ const style = (props.style ?? {}) as Record;
+ for (const v of [style.backgroundImage, style.background]) if (paintsImage(v)) return v;
+ if (!blank(style.backgroundColor)) return String(style.backgroundColor);
+ if (!blank(props.backgroundColor)) return String(props.backgroundColor);
+ if (paintsImage(props.backgroundColor)) return props.backgroundColor;
+ cur = nodes[cur].data?.parent;
+ }
+ return null;
+}
+
+/** WCAG "large text": >= 24px, or >= 18.66px (14pt) and bold. rem/em count as 16px. */
+export function isLargeText(fontSize: string | undefined, fontWeight: string | number | undefined): boolean {
+ const m = /^\s*([\d.]+)\s*(px|rem|em)\s*$/i.exec(String(fontSize ?? ''));
+ if (!m) return false;
+ const px = parseFloat(m[1]) * (m[2].toLowerCase() === 'px' ? 1 : 16);
+ const w = String(fontWeight ?? '').toLowerCase();
+ const bold = w === 'bold' || w === 'bolder' || Number(w) >= 700;
+ return px >= 24 || (px >= 18.66 && bold);
+}
diff --git a/craft/src/utils/html-export.head-parity.test.ts b/craft/src/utils/html-export.head-parity.test.ts
index c17baa0..ce22d9e 100644
--- a/craft/src/utils/html-export.head-parity.test.ts
+++ b/craft/src/utils/html-export.head-parity.test.ts
@@ -21,12 +21,17 @@ const PINNED_HEAD: Record = {
E_icons: "\n\n\n \n \n Icons\n \n \n \n \n \n \n \n \n \n",
};
+PINNED_HEAD.E_brand_swatches = "\n\n\n \n \n Brand\n \n \n \n \n \n \n \n";
+PINNED_HEAD.F_swatches_no_brand = PINNED_HEAD.A_defaults;
+
const PINNED_BODY: Record = {
A_defaults: "",
B_full: "",
C_unknown_font: "",
D_body_font_and_blank_tokens: "",
E_icons: "",
+ E_brand_swatches: "",
+ F_swatches_no_brand: "",
};
@@ -83,6 +88,30 @@ const CASES: Record = {
state: root({}),
opts: { title: 'Icons', includeFonts: true, canonicalUrl: U + 'icons', fontScanHtml: '' },
},
+ // Brand colours (design.swatches): same inputs as the WHP script's E / F cases.
+ E_brand_swatches: {
+ state: root({}),
+ opts: {
+ title: 'Brand', includeFonts: true, canonicalUrl: U,
+ design: merged({ swatches: [
+ { id: 'a', label: 'Sea', hex: '#0A7', brand: true, slot: 3 },
+ { id: 'b', label: 'Sun', hex: '#FFcc00', brand: true, slot: 1 },
+ { id: 'c', label: 'Grey', hex: '#888888', brand: false },
+ { id: 'd', label: 'Bad', hex: 'red', brand: true, slot: 2 },
+ ] }),
+ },
+ },
+ F_swatches_no_brand: {
+ state: root({}),
+ opts: {
+ title: "Joe's Pizza & Grill", includeFonts: true, canonicalUrl: U,
+ design: merged({ swatches: [
+ { id: 'c', label: 'Grey', hex: '#888888', brand: false },
+ { id: 'd', label: 'Bad', hex: 'red', brand: true, slot: 2 },
+ { id: 'e', label: 'Far', hex: '#123456', brand: true, slot: 7 },
+ ] }),
+ },
+ },
};
const headOf = (doc: string) => doc.slice(0, doc.indexOf('') + ''.length);
diff --git a/craft/src/utils/image-sample.test.ts b/craft/src/utils/image-sample.test.ts
new file mode 100644
index 0000000..cb854df
--- /dev/null
+++ b/craft/src/utils/image-sample.test.ts
@@ -0,0 +1,86 @@
+import { describe, test, expect } from 'vitest';
+import { loadImageForSampling, sampleAt, isSameOrigin, CROSS_ORIGIN_MESSAGE } from './image-sample';
+
+const ORIGIN = 'http://site.test';
+
+function fakeImg(w: number, h: number, fail = false) {
+ const img: any = { naturalWidth: w, naturalHeight: h, width: w, height: h, onload: null, onerror: null };
+ Object.defineProperty(img, 'src', { set() { setTimeout(() => (fail ? img.onerror?.() : img.onload?.()), 0); } });
+ return img as HTMLImageElement;
+}
+function fakeCanvas(opts: { throwOnRead?: string; noCtx?: boolean } = {}) {
+ const log: any = {};
+ const c: any = {
+ width: 0, height: 0,
+ getContext: () => opts.noCtx ? null : ({
+ drawImage: (...a: unknown[]) => { log.draw = a; },
+ getImageData: (_x: number, _y: number, w: number, h: number) => {
+ if (opts.throwOnRead) { const e: any = new Error('tainted'); e.name = opts.throwOnRead; throw e; }
+ return { width: w, height: h, data: new Uint8ClampedArray(w * h * 4) };
+ },
+ }),
+ };
+ return { c: c as HTMLCanvasElement, log };
+}
+
+describe('isSameOrigin', () => {
+ test('relative, absolute same, other origin, data', () => {
+ expect(isSameOrigin('/a.png', ORIGIN)).toBe(true);
+ expect(isSameOrigin('a.png', ORIGIN)).toBe(true);
+ expect(isSameOrigin('http://site.test/x/a.png', ORIGIN)).toBe(true);
+ expect(isSameOrigin('http://site.test:81/a.png', ORIGIN)).toBe(false);
+ expect(isSameOrigin('https://cdn.example.com/a.png', ORIGIN)).toBe(false);
+ expect(isSameOrigin('//cdn.example.com/a.png', ORIGIN)).toBe(false);
+ expect(isSameOrigin('data:image/png;base64,AAAA', ORIGIN)).toBe(true);
+ });
+});
+
+describe('sampleAt', () => {
+ const data = new Uint8ClampedArray([255, 0, 16, 255, 1, 2, 3, 0]);
+ const id = { data, width: 2, height: 1 };
+ test('hex of pixel', () => expect(sampleAt(id, 0, 0)).toBe('#ff0010'));
+ test('transparent -> empty', () => expect(sampleAt(id, 1, 0)).toBe(''));
+ test('out of bounds -> empty', () => { expect(sampleAt(id, 2, 0)).toBe(''); expect(sampleAt(id, -1, 0)).toBe(''); });
+});
+
+describe('loadImageForSampling', () => {
+ test('cross-origin refused without loading', async () => {
+ let created = false;
+ const r = await loadImageForSampling('https://other.test/a.png', { origin: ORIGIN, createImage: () => { created = true; return fakeImg(1, 1); } });
+ expect(r).toEqual({ ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE });
+ expect(created).toBe(false);
+ });
+ test('downscales longest side to 512', async () => {
+ const { c } = fakeCanvas();
+ const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(2048, 1024), createCanvas: () => c });
+ expect(r.ok && [r.width, r.height]).toEqual([512, 256]);
+ });
+ test('small image keeps its size; draw sizes target canvas', async () => {
+ const { c } = fakeCanvas();
+ const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(40, 30), createCanvas: () => c });
+ expect(r.ok).toBe(true);
+ if (r.ok) {
+ const t = fakeCanvas().c;
+ r.draw(t);
+ expect([t.width, t.height]).toEqual([40, 30]);
+ }
+ });
+ test('SecurityError -> cross-origin', async () => {
+ const { c } = fakeCanvas({ throwOnRead: 'SecurityError' });
+ const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(4, 4), createCanvas: () => c });
+ expect(r).toMatchObject({ ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE });
+ });
+ test('other read error -> error', async () => {
+ const { c } = fakeCanvas({ throwOnRead: 'Weird' });
+ const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(4, 4), createCanvas: () => c });
+ expect(r).toMatchObject({ ok: false, reason: 'error' });
+ });
+ test('load error -> error', async () => {
+ const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(4, 4, true), createCanvas: () => fakeCanvas().c });
+ expect(r).toMatchObject({ ok: false, reason: 'error' });
+ });
+ test('no 2d context -> error', async () => {
+ const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(4, 4), createCanvas: () => fakeCanvas({ noCtx: true }).c });
+ expect(r).toMatchObject({ ok: false, reason: 'error' });
+ });
+});
diff --git a/craft/src/utils/image-sample.ts b/craft/src/utils/image-sample.ts
new file mode 100644
index 0000000..e10505b
--- /dev/null
+++ b/craft/src/utils/image-sample.ts
@@ -0,0 +1,104 @@
+/* Read pixel colours out of an image via canvas. Same-origin only: a cross-origin
+ image would taint the canvas, so we refuse up front with a plain-English message
+ instead of trying. */
+
+export type PickFailReason = 'cancelled' | 'unsupported' | 'cross-origin' | 'error';
+export type PickResult =
+ | { ok: true; hex: string; source: 'canvas' | 'image' | 'eyedropper' | 'tab-capture' }
+ | { ok: false; reason: PickFailReason; message: string };
+
+export const CROSS_ORIGIN_MESSAGE =
+ 'That image comes from another website, so the browser will not let the builder read its colours. Upload it to this site first, then pick from the uploaded copy.';
+
+const MAX_SIDE = 512;
+
+export type SampledImage = {
+ ok: true;
+ width: number;
+ height: number;
+ imageData: ImageData;
+ /** Paint the (downscaled) image onto a canvas, resizing the canvas to match. */
+ draw(canvas: HTMLCanvasElement): void;
+};
+export type SampleFailure = { ok: false; reason: 'cross-origin' | 'error'; message: string };
+
+/** Injection points so the logic is testable where jsdom has no canvas. */
+export interface SamplingDeps {
+ origin?: string;
+ createImage?: () => HTMLImageElement;
+ createCanvas?: () => HTMLCanvasElement;
+}
+
+function resolve(src: string, origin: string): URL | null {
+ try { return new URL(src, origin + '/'); } catch { return null; }
+}
+
+/** True when `src` (resolved against the page) shares the page's origin. data:/blob: count as same-origin. */
+export function isSameOrigin(src: string, origin: string = location.origin): boolean {
+ if (/^(data|blob):/i.test(src.trim())) return true;
+ const u = resolve(src.trim(), origin);
+ return !!u && u.origin === origin;
+}
+
+function isSecurityError(e: unknown): boolean {
+ return !!e && typeof e === 'object' && (e as { name?: string }).name === 'SecurityError';
+}
+
+export async function loadImageForSampling(src: string, deps: SamplingDeps = {}): Promise {
+ const origin = deps.origin ?? location.origin;
+ if (!isSameOrigin(src, origin)) {
+ return { ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE };
+ }
+ const img = deps.createImage ? deps.createImage() : new Image();
+ try {
+ await new Promise((res, rej) => {
+ img.onload = () => res();
+ img.onerror = () => rej(new Error('load'));
+ img.src = src;
+ });
+ } catch {
+ return { ok: false, reason: 'error', message: 'That image could not be loaded.' };
+ }
+ const nw = img.naturalWidth || img.width;
+ const nh = img.naturalHeight || img.height;
+ if (!nw || !nh) return { ok: false, reason: 'error', message: 'That image has no size, so its colours cannot be read.' };
+ const scale = Math.min(1, MAX_SIDE / Math.max(nw, nh));
+ const width = Math.max(1, Math.round(nw * scale));
+ const height = Math.max(1, Math.round(nh * scale));
+ const canvas = deps.createCanvas ? deps.createCanvas() : document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d', { willReadFrequently: true } as CanvasRenderingContext2DSettings);
+ if (!ctx) return { ok: false, reason: 'error', message: 'This browser cannot read image colours.' };
+ let imageData: ImageData;
+ try {
+ ctx.drawImage(img, 0, 0, width, height);
+ imageData = ctx.getImageData(0, 0, width, height);
+ } catch (e) {
+ if (isSecurityError(e)) return { ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE };
+ return { ok: false, reason: 'error', message: 'The image colours could not be read.' };
+ }
+ return {
+ ok: true,
+ width,
+ height,
+ imageData,
+ draw(target) {
+ target.width = width;
+ target.height = height;
+ target.getContext('2d')?.drawImage(canvas, 0, 0);
+ },
+ };
+}
+
+const hex2 = (n: number) => n.toString(16).padStart(2, '0');
+
+/** Lowercase #rrggbb of the pixel at (x, y); '' when out of bounds or transparent (alpha < 128). */
+export function sampleAt(imageData: Pick, x: number, y: number): string {
+ const px = Math.floor(x), py = Math.floor(y);
+ if (px < 0 || py < 0 || px >= imageData.width || py >= imageData.height) return '';
+ const i = (py * imageData.width + px) * 4;
+ const d = imageData.data;
+ if (d[i + 3] < 128) return '';
+ return `#${hex2(d[i])}${hex2(d[i + 1])}${hex2(d[i + 2])}`;
+}
diff --git a/craft/src/utils/palette-extract.test.ts b/craft/src/utils/palette-extract.test.ts
new file mode 100644
index 0000000..5f57247
--- /dev/null
+++ b/craft/src/utils/palette-extract.test.ts
@@ -0,0 +1,63 @@
+import { describe, test, expect } from 'vitest';
+import { extractPalette } from './palette-extract';
+
+type RGBA = [number, number, number, number];
+function buf(runs: Array<[RGBA, number]>): Uint8ClampedArray {
+ const total = runs.reduce((n, [, c]) => n + c, 0);
+ const out = new Uint8ClampedArray(total * 4);
+ let i = 0;
+ for (const [px, c] of runs) for (let k = 0; k < c; k++) { out.set(px, i); i += 4; }
+ return out;
+}
+
+describe('extractPalette', () => {
+ test('three flat bands -> those colours in population order', () => {
+ const p = extractPalette(buf([[[0, 0, 255, 255], 100], [[255, 0, 0, 255], 500], [[0, 200, 0, 255], 300]]));
+ expect(p).toEqual(['#ff0000', '#00c800', '#0000ff']);
+ });
+ test('single colour -> one entry, never fabricates', () => {
+ expect(extractPalette(buf([[[10, 20, 30, 255], 50]]))).toEqual(['#0a141e']);
+ });
+ test('two colours -> two entries', () => {
+ expect(extractPalette(buf([[[255, 255, 255, 255], 50], [[0, 0, 0, 255], 20]], ))).toEqual(['#ffffff', '#000000']);
+ });
+ test('near-duplicates are merged, keeping the more populous', () => {
+ const p = extractPalette(buf([[[200, 30, 30, 255], 400], [[203, 32, 29, 255], 100], [[20, 20, 200, 255], 200]]));
+ expect(p).toHaveLength(2);
+ expect(p[0]).toMatch(/^#c[89a-f]/);
+ expect(p[1]).toBe('#1414c8');
+ });
+ test('all transparent -> []', () => {
+ expect(extractPalette(buf([[[255, 0, 0, 0], 100]]))).toEqual([]);
+ expect(extractPalette([])).toEqual([]);
+ });
+ test('transparent pixels (alpha<128) are ignored', () => {
+ expect(extractPalette(buf([[[255, 0, 0, 10], 1000], [[0, 128, 0, 255], 5]]))).toEqual(['#008000']);
+ });
+ test('gradient -> at most count distinct valid hexes', () => {
+ const px: number[] = [];
+ for (let i = 0; i < 256; i++) px.push(i, 255 - i, 128, 255);
+ const p = extractPalette(px, { count: 6 });
+ expect(p.length).toBeGreaterThan(1);
+ expect(p.length).toBeLessThanOrEqual(6);
+ expect(new Set(p).size).toBe(p.length);
+ p.forEach((h) => expect(h).toMatch(/^#[0-9a-f]{6}$/));
+ });
+ test('deterministic', () => {
+ const px = new Uint8ClampedArray(4 * 5000);
+ for (let i = 0; i < 5000; i++) { px[i * 4] = (i * 37) % 256; px[i * 4 + 1] = (i * 91) % 256; px[i * 4 + 2] = (i * 13) % 256; px[i * 4 + 3] = 255; }
+ expect(extractPalette(px)).toEqual(extractPalette(px));
+ });
+ test('count is clamped to 5..8 (default 6)', () => {
+ const px = new Uint8ClampedArray(4 * 4000);
+ for (let i = 0; i < 4000; i++) { px[i * 4] = (i * 53) % 256; px[i * 4 + 1] = (i * 97) % 256; px[i * 4 + 2] = (i * 29) % 256; px[i * 4 + 3] = 255; }
+ expect(extractPalette(px).length).toBeLessThanOrEqual(6);
+ expect(extractPalette(px, { count: 100 }).length).toBeLessThanOrEqual(8);
+ expect(extractPalette(px, { count: 100 }).length).toBeGreaterThan(6);
+ expect(extractPalette(px, { count: 1 }).length).toBeLessThanOrEqual(5);
+ expect(extractPalette(px, { count: 1 }).length).toBeGreaterThanOrEqual(5);
+ });
+ test('ignoreTransparent:false keeps low-alpha pixels', () => {
+ expect(extractPalette(buf([[[255, 0, 0, 10], 10]]), { ignoreTransparent: false })).toEqual(['#ff0000']);
+ });
+});
diff --git a/craft/src/utils/palette-extract.ts b/craft/src/utils/palette-extract.ts
new file mode 100644
index 0000000..b2e6028
--- /dev/null
+++ b/craft/src/utils/palette-extract.ts
@@ -0,0 +1,99 @@
+/* Dominant-colour extraction (median cut). Pure and DOM-free; deterministic. */
+
+export interface ExtractPaletteOptions {
+ /** Wanted colours, clamped to 5..8. Default 6. */
+ count?: number;
+ /** Skip pixels with alpha < 128. Default true. */
+ ignoreTransparent?: boolean;
+}
+
+const MAX_SAMPLES = 40000;
+const MIN_DELTA_E = 12;
+
+const hex2 = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
+
+function toLab(r: number, g: number, b: number): [number, number, number] {
+ const lin = (c: number) => { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
+ const R = lin(r), G = lin(g), B = lin(b);
+ const f = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
+ const x = f((0.4124 * R + 0.3576 * G + 0.1805 * B) / 0.95047);
+ const y = f(0.2126 * R + 0.7152 * G + 0.0722 * B);
+ const z = f((0.0193 * R + 0.1192 * G + 0.9505 * B) / 1.08883);
+ return [116 * y - 16, 500 * (x - y), 200 * (y - z)];
+}
+
+interface Entry { rgb: number; n: number }
+interface Box { e: Entry[]; pop: number }
+
+function channel(rgb: number, ch: number): number { return (rgb >> (16 - ch * 8)) & 255; }
+
+function range(box: Box): { ch: number; span: number } {
+ const lo = [255, 255, 255], hi = [0, 0, 0];
+ for (const { rgb } of box.e) for (let c = 0; c < 3; c++) {
+ const v = channel(rgb, c);
+ if (v < lo[c]) lo[c] = v;
+ if (v > hi[c]) hi[c] = v;
+ }
+ let ch = 0, span = -1;
+ for (let c = 0; c < 3; c++) if (hi[c] - lo[c] > span) { span = hi[c] - lo[c]; ch = c; }
+ return { ch, span };
+}
+
+function split(box: Box): [Box, Box] {
+ const { ch } = range(box);
+ const sorted = [...box.e].sort((a, b) => channel(a.rgb, ch) - channel(b.rgb, ch) || a.rgb - b.rgb);
+ let acc = 0;
+ let cut = 1;
+ for (let i = 0; i < sorted.length - 1; i++) {
+ acc += sorted[i].n;
+ cut = i + 1;
+ if (acc * 2 >= box.pop) break;
+ }
+ const a = sorted.slice(0, cut), b = sorted.slice(cut);
+ const sum = (x: Entry[]) => x.reduce((s, y) => s + y.n, 0);
+ return [{ e: a, pop: sum(a) }, { e: b, pop: sum(b) }];
+}
+
+/** Dominant colours as lowercase #rrggbb, most dominant first. Returns fewer than
+ * `count` for flat/transparent images; never invents colours. */
+export function extractPalette(rgba: Uint8ClampedArray | number[], opts: ExtractPaletteOptions = {}): string[] {
+ const want = Math.max(5, Math.min(8, Math.round(opts.count ?? 6)));
+ const ignoreT = opts.ignoreTransparent !== false;
+ const pixels = Math.floor(rgba.length / 4);
+ const step = Math.max(1, Math.ceil(pixels / MAX_SAMPLES));
+ const hist = new Map();
+ for (let p = 0; p < pixels; p += step) {
+ const i = p * 4;
+ if (ignoreT && rgba[i + 3] < 128) continue;
+ const key = ((rgba[i] & 255) << 16) | ((rgba[i + 1] & 255) << 8) | (rgba[i + 2] & 255);
+ hist.set(key, (hist.get(key) || 0) + 1);
+ }
+ if (hist.size === 0) return [];
+ const entries: Entry[] = [...hist].map(([rgb, n]) => ({ rgb, n })).sort((a, b) => a.rgb - b.rgb);
+ let boxes: Box[] = [{ e: entries, pop: entries.reduce((s, x) => s + x.n, 0) }];
+ const target = want * 2;
+ while (boxes.length < target) {
+ let best = -1, bestScore = -1;
+ boxes.forEach((b, i) => {
+ if (b.e.length < 2) return;
+ const score = b.pop * (range(b).span > 0 ? 1 : 0);
+ if (score > bestScore) { bestScore = score; best = i; }
+ });
+ if (best < 0 || bestScore <= 0) break;
+ const [a, b] = split(boxes[best]);
+ boxes.splice(best, 1, a, b);
+ }
+ const cols = boxes.map((b) => {
+ let r = 0, g = 0, bl = 0;
+ for (const { rgb, n } of b.e) { r += channel(rgb, 0) * n; g += channel(rgb, 1) * n; bl += channel(rgb, 2) * n; }
+ const R = Math.round(r / b.pop), G = Math.round(g / b.pop), B = Math.round(bl / b.pop);
+ return { hex: `#${hex2(R)}${hex2(G)}${hex2(B)}`, pop: b.pop, lab: toLab(R, G, B) };
+ }).sort((a, b) => b.pop - a.pop || (a.hex < b.hex ? -1 : 1));
+ const kept: typeof cols = [];
+ for (const c of cols) {
+ const dup = kept.some((k) => Math.hypot(k.lab[0] - c.lab[0], k.lab[1] - c.lab[1], k.lab[2] - c.lab[2]) < MIN_DELTA_E);
+ if (!dup) kept.push(c);
+ if (kept.length >= want) break;
+ }
+ return kept.map((k) => k.hex);
+}
diff --git a/craft/src/utils/publish-checks.test.ts b/craft/src/utils/publish-checks.test.ts
index 15a6eb6..2c84534 100644
--- a/craft/src/utils/publish-checks.test.ts
+++ b/craft/src/utils/publish-checks.test.ts
@@ -133,7 +133,8 @@ describe('runPublishChecks', () => {
test('token colours resolve through the design (text token on bg token)', () => {
const design = { ...DEFAULT_SITE_DESIGN, textColor: '#ffffff', backgroundColor: '#ffffff' };
const cs = runPublishChecks({ pages: [page({ nodes: sec('var(--wsb-bg)', text('var(--wsb-text)')) })], design });
- expect(ids(cs)).toEqual(['contrast:p1:t']);
+ // (the same white-on-white design also trips the site-level text/background pair)
+ expect(ids(cs)).toEqual(['contrast:p1:t', 'contrast-site:text-bg']);
const ok = runPublishChecks({ pages: [page({ nodes: sec('var(--wsb-bg)', text('var(--wsb-text)')) })], design: { ...design, textColor: '#111111' } });
expect(ok).toEqual([]);
});
@@ -151,8 +152,11 @@ describe('runPublishChecks', () => {
});
test('unknown / gradient / image backgrounds and unparseable colours are skipped', () => {
- expect(run([page({ nodes: sec('linear-gradient(#fff,#fff)', text('#ffffff')) })])).toEqual([]);
- expect(run([page({ nodes: sec('#ffffff', text('#ffffff'), { bgImage: 'a.png' }) })])).toEqual([]);
+ // gradient / image are never judged; they surface only as one info line
+ const gr = run([page({ nodes: sec('linear-gradient(#fff,#fff)', text('#ffffff')) })]);
+ expect(gr.map((c) => [c.id, c.severity])).toEqual([['contrast-unchecked:p1', 'info']]);
+ expect(gr[0].message).toContain('1 element(s)');
+ expect(ids(run([page({ nodes: sec('#ffffff', text('#ffffff'), { bgImage: 'a.png' }) })]))).toEqual(['contrast-unchecked:p1']);
expect(run([page({ nodes: sec('#ffffff', text('inherit')) })])).toEqual([]);
expect(run([page({ nodes: { t: text('#ffffff'), h: h1 } })])).toEqual([]);
});
@@ -165,4 +169,48 @@ describe('runPublishChecks', () => {
expect(cs).toHaveLength(MAX_CHECKS);
expect(new Set(ids(cs)).size).toBe(MAX_CHECKS);
});
+
+ describe('contrast (colour system)', () => {
+ const btn = (color: string, bg: string, extra: any = {}): N => ({ type: 'ButtonLink', props: { text: 'Go', href: '/x', style: { color, backgroundColor: bg, ...extra } } });
+
+ test('button text vs button background: fail warns, pass does not', () => {
+ expect(ids(run([page({ nodes: { h: h1, b: btn('#ffffff', '#fde047') } })]))).toEqual(['contrast:p1:b']);
+ expect(run([page({ nodes: { h: h1, b: btn('#ffffff', '#111111') } })])).toEqual([]);
+ });
+
+ test('button with tokens and brand tokens resolves', () => {
+ const design = { ...DEFAULT_SITE_DESIGN, swatches: [{ id: 's', label: 'B', hex: '#fde047', brand: true, slot: 1 } as any] };
+ const cs = runPublishChecks({ pages: [page({ nodes: { h: h1, b: btn('#ffffff', 'var(--wsb-brand-1)') } })], design });
+ expect(ids(cs)).toEqual(['contrast:p1:b']);
+ });
+
+ test('bold 18.66px+ button counts as large text (3:1)', () => {
+ // #8a8a8a on white ~3.4:1
+ expect(ids(run([page({ nodes: { h: h1, b: btn('#8a8a8a', '#ffffff', { fontSize: '16px', fontWeight: '600' }) } })]))).toEqual(['contrast:p1:b']);
+ expect(run([page({ nodes: { h: h1, b: btn('#8a8a8a', '#ffffff', { fontSize: '19px', fontWeight: '700' }) } })])).toEqual([]);
+ });
+
+ test('identical failing button pairs on a page warn once', () => {
+ const cs = run([page({ nodes: { h: h1, b1: btn('#ffffff', '#fde047'), b2: btn('#ffffff', '#fde047') } })]);
+ expect(cs).toHaveLength(1);
+ });
+
+ test('button with a gradient background is counted as not checked', () => {
+ const cs = run([page({ nodes: { h: h1, b: btn('#ffffff', 'linear-gradient(red, blue)') } })]);
+ expect(cs.map((c) => [c.id, c.severity])).toEqual([['contrast-unchecked:p1', 'info']]);
+ });
+
+ test('site text vs background: low contrast warns with site-design fix and no page', () => {
+ const design = { ...DEFAULT_SITE_DESIGN, textColor: '#cccccc', mutedTextColor: '#dddddd' };
+ const cs = runPublishChecks({ pages: [page({ nodes: { h: h1 } })], design });
+ expect(cs.map((c) => c.id)).toEqual(['contrast-site:text-bg', 'contrast-site:muted-bg']);
+ expect(cs[0]).toMatchObject({ severity: 'warn', fix: 'site-design' });
+ expect(cs[0].pageId).toBeUndefined();
+ });
+
+ test('site pair with a gradient or unresolved colour is not judged', () => {
+ const design = { ...DEFAULT_SITE_DESIGN, backgroundColor: 'linear-gradient(red, blue)', textColor: '#ffffff' };
+ expect(runPublishChecks({ pages: [page({ nodes: { h: h1 } })], design })).toEqual([]);
+ });
+ });
});
diff --git a/craft/src/utils/publish-checks.ts b/craft/src/utils/publish-checks.ts
index 7b64e97..55f6ecb 100644
--- a/craft/src/utils/publish-checks.ts
+++ b/craft/src/utils/publish-checks.ts
@@ -2,6 +2,7 @@ import type { SiteDesign } from '../state/SiteDesignContext';
import type { PageSeo } from '../types';
import { resolveTokenValue } from './tokens';
import { parseColor, type RGB } from './color';
+import { checkContrast, siteColorPairs } from './contrast';
import { PLACEHOLDER_SRC } from '../components/media/ImageBlock';
import { normalizeCtas } from '../components/sections/_cta-helpers';
@@ -136,6 +137,36 @@ function effectiveBackground(nodes: SNodes, id: string, design: SiteDesign): RGB
return null;
}
+type BgSource = { css: string } | { reason: 'gradient' | 'image' } | null;
+
+/** Like effectiveBackground, but reports WHY a background is not a flat colour. */
+function effectiveBgSource(nodes: SNodes, id: string, design: SiteDesign): BgSource {
+ let cur = nodes[id]?.parent;
+ while (cur && nodes[cur]) {
+ const p = nodes[cur].props ?? {};
+ if (p.bgType && p.bgType !== 'color' && p.bgType !== 'solid') return { reason: p.bgType === 'image' ? 'image' : 'gradient' };
+ if (p.bgImage) return { reason: 'image' };
+ const raw = p.style?.backgroundColor || p.bgColor;
+ if (typeof raw === 'string' && raw.trim()) {
+ const resolved = resolveTokenValue(raw, design);
+ if (/gradient/i.test(resolved)) return { reason: 'gradient' };
+ if (/url\(/i.test(resolved)) return { reason: 'image' };
+ if (parseColor(resolved)) return { css: resolved };
+ }
+ cur = nodes[cur].parent;
+ }
+ return null;
+}
+
+/** WCAG "large text": >= 24px, or >= 18.66px and bold. */
+function isLargeText(style: Record | undefined): boolean {
+ const size = pxSize(style?.fontSize);
+ if (size === null) return false;
+ const w = style?.fontWeight;
+ const bold = w === 'bold' || w === 'bolder' || (Number(w) >= 700);
+ return size >= 24 || (size >= 18.66 && bold);
+}
+
// ---------- helpers ----------
const isEmptyHref = (h: unknown) => typeof h !== 'string' || h.trim() === '' || h.trim() === '#';
@@ -233,6 +264,8 @@ export function runPublishChecks(input: PublishChecksInput): Check[] {
out.push({ id: `h1-multi:${page.id}`, severity: 'warn', message: `${where} has ${h1s} H1 headings (use one)`, pageId: page.id });
}
+ let uncheckedCount = 0;
+ const seenButtonPairs = new Set();
for (const id of ids) {
const n = nodes[id];
const name = n.type?.resolvedName;
@@ -246,29 +279,65 @@ export function runPublishChecks(input: PublishChecksInput): Check[] {
pageId: page.id, nodeId: id, fix: 'select',
});
}
- } else if (name === 'Heading' || name === 'TextBlock') {
+ } else if (name === 'Heading' || name === 'TextBlock' || name === 'ButtonLink') {
+ const isBtn = name === 'ButtonLink';
const colorRaw = p.style?.color;
if (typeof colorRaw !== 'string' || !colorRaw) continue;
- const fg = parseColor(resolveTokenValue(colorRaw, design));
- const bg = fg ? effectiveBackground(nodes, id, design) : null;
- if (!fg || !bg) continue;
- const size = pxSize(p.style?.fontSize);
- const large = size !== null && size >= 24;
- const ratio = contrastRatio(fg, bg);
- const txt = label(plain(p.text ?? p.html), '');
- if (ratio < (large ? 3 : 4.5)) {
- out.push({
- id: `contrast:${page.id}:${id}`, severity: 'warn',
- message: `${where}: low-contrast ${name === 'Heading' ? 'heading' : 'text'}${txt ? ' ' + txt : ''} (${ratio.toFixed(1)}:1, needs ${large ? 3 : 4.5}:1)`,
- pageId: page.id, nodeId: id, fix: 'select',
- });
+ let bgCss: string | null;
+ if (isBtn) {
+ const raw = p.style?.backgroundColor;
+ bgCss = typeof raw === 'string' && raw.trim() ? raw : null;
+ } else {
+ const src = effectiveBgSource(nodes, id, design);
+ if (src && 'reason' in src) { uncheckedCount++; continue; }
+ bgCss = src ? src.css : null;
}
+ if (!bgCss) continue;
+ const large = isLargeText(p.style);
+ const v = checkContrast(colorRaw, bgCss, design, { large });
+ if (v.status === 'unchecked') {
+ if (v.reason === 'gradient' || v.reason === 'image') uncheckedCount++;
+ continue;
+ }
+ if (v.status !== 'fail') continue;
+ const pairKey = `${resolveTokenValue(colorRaw, design).toLowerCase()}|${resolveTokenValue(bgCss, design).toLowerCase()}|${large}`;
+ if (isBtn) {
+ if (seenButtonPairs.has(pairKey)) continue;
+ seenButtonPairs.add(pairKey);
+ }
+ const txt = label(plain(p.text ?? p.html), '');
+ const kind = isBtn ? 'button' : name === 'Heading' ? 'heading' : 'text';
+ out.push({
+ id: `contrast:${page.id}:${id}`, severity: 'warn',
+ message: `${where}: low-contrast ${kind}${txt ? ' ' + txt : ''} (${v.ratio!.toFixed(1)}:1, needs ${v.required}:1)`,
+ pageId: page.id, nodeId: id, fix: 'select',
+ });
}
}
+ if (uncheckedCount > 0) {
+ out.push({
+ id: `contrast-unchecked:${page.id}`, severity: 'info',
+ message: `${where}: contrast not checked for ${uncheckedCount} element(s) with gradient/image backgrounds`,
+ pageId: page.id,
+ });
+ }
+
linkChecks(nodes, ids, page.id, where, out);
});
+ // Site-level pairs that are always seen together (text / muted text on the site background).
+ for (const pair of siteColorPairs(design)) {
+ if (pair.id !== 'text-bg' && pair.id !== 'muted-bg') continue;
+ const v = checkContrast(pair.fg, pair.bg, design, { large: pair.large });
+ if (v.status !== 'fail') continue;
+ out.push({
+ id: `contrast-site:${pair.id}`, severity: 'warn',
+ message: `Site design: ${pair.label.toLowerCase()} is low contrast (${v.ratio!.toFixed(1)}:1, needs ${v.required}:1)`,
+ fix: 'site-design',
+ });
+ }
+
for (const zone of zones) {
const nodes = parseState(zone.craftState);
if (!nodes) continue;
diff --git a/craft/src/utils/swatches.test.ts b/craft/src/utils/swatches.test.ts
new file mode 100644
index 0000000..b7ca927
--- /dev/null
+++ b/craft/src/utils/swatches.test.ts
@@ -0,0 +1,145 @@
+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();
+ });
+});
+
+describe('isTokenRef accepts brand refs (digits in the name)', () => {
+ test('brand refs are token refs but not fixed-slot keys', async () => {
+ const { isTokenRef, tokenKeyOf } = await import('./tokens');
+ expect(isTokenRef('var(--wsb-brand-2)')).toBe(true);
+ expect(tokenKeyOf('var(--wsb-brand-2)')).toBeNull();
+ });
+});
+
+describe('replaceBrandRef / countBrandRefUses', () => {
+ test('replaces every exact reference of the slot with the literal hex, leaving other slots alone', async () => {
+ const { replaceBrandRef, countBrandRefUses } = await import('./swatches');
+ const st = '{"a":{"props":{"c":"var(--wsb-brand-1)","g":"linear-gradient(90deg, var(--wsb-brand-1) 0%, var(--wsb-brand-12x))","d":"var(--wsb-brand-2)"}}}';
+ expect(countBrandRefUses(st, 1)).toBe(2);
+ const out = replaceBrandRef(st, 1, '#0f172a');
+ expect(out).toContain('"c":"#0f172a"');
+ expect(out).toContain('linear-gradient(90deg, #0f172a 0%');
+ expect(out).toContain('var(--wsb-brand-2)');
+ expect(countBrandRefUses(out, 1)).toBe(0);
+ expect(() => JSON.parse(out)).not.toThrow();
+ });
+ test('returns the identical string when nothing references the slot', async () => {
+ const { replaceBrandRef } = await import('./swatches');
+ const st = '{"a":"#fff"}';
+ expect(replaceBrandRef(st, 3, '#000000')).toBe(st);
+ });
+ test('refuses an invalid hex (returns input unchanged)', async () => {
+ const { replaceBrandRef } = await import('./swatches');
+ const st = '"var(--wsb-brand-1)"';
+ expect(replaceBrandRef(st, 1, 'red')).toBe(st);
+ });
+});
diff --git a/craft/src/utils/swatches.ts b/craft/src/utils/swatches.ts
new file mode 100644
index 0000000..6a36894
--- /dev/null
+++ b/craft/src/utils/swatches.ts
@@ -0,0 +1,167 @@
+/**
+ * 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;
+}
+
+/**
+ * Replaces every `var(--wsb-brand-)` in a serialized state string with
+ * the swatch's literal hex, so un-branding/deleting a brand colour never leaves
+ * an element without a colour. `--wsb-brand-1` must not match `--wsb-brand-12`
+ * (the closing paren is part of the needle). Unchanged string when the hex is
+ * invalid or there is nothing to replace.
+ */
+export function replaceBrandRef(serialized: string, slot: number, hex: string): string {
+ const h = normalizeHex(hex);
+ if (!h) return serialized;
+ return serialized.split(brandTokenRef(slot)).join(h);
+}
diff --git a/craft/src/utils/tokens.ts b/craft/src/utils/tokens.ts
index 3ba315c..2e17474 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
@@ -63,7 +64,7 @@ export const COLOR_TOKENS: ReadonlyArray<{ key: TokenKey; label: string }> = [
{ key: 'error', label: 'Error' },
];
-const TOKEN_REF_RE = /^var\(--wsb-([a-z-]+)\)$/;
+const TOKEN_REF_RE = /^var\(--wsb-([a-z0-9-]+)\)$/;
// TokenKey -> SiteDesign key, derived from TOKEN_VARS so there is one source.
const DESIGN_KEY_BY_TOKEN = new Map(
@@ -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);
}