Merge feat/color-system into release/sitesmith-colors
Conflicts: package.json (kept both test:browser:mobile and the colour scripts); head-parity test (kept E_icons from mobile-parity/main AND the E_brand_swatches / F_swatches_no_brand cases from color-system; pinned bytes re-derived later against the merged WHP script). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
6decf8cdb0
50 files changed
+4448
-31
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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 = `<!doctype html><html><head><style>
|
||||
body{margin:0;font:16px sans-serif}
|
||||
.canvas-device-frame{--wsb-primary:#3b82f6;background:#ffffff;color:#111827;padding:10px;width:700px}
|
||||
.box{height:60px;margin:6px 0;padding:4px}
|
||||
</style></head><body>
|
||||
<div class="canvas-device-frame">
|
||||
<div id="tok" class="box" style="background:var(--wsb-primary);color:#ffffff">token bg</div>
|
||||
<div id="brand" class="box" style="background:#ff6600;color:#111827">brand bg</div>
|
||||
<div id="sand" class="box" style="background:rgba(200,176,138,1);color:#111827">swatch bg</div>
|
||||
<div id="plain" class="box" style="background:#123456;color:#fedcba;border:3px solid #0a0b0c">plain + border</div>
|
||||
<div id="outer" class="box" style="background:#112233;color:#445566"><div id="nested" style="color:#778899"><span id="deep">nested transparent</span></div></div>
|
||||
<div id="alpha" class="box" style="background:rgba(18,52,86,.5);color:#111827">alpha</div>
|
||||
<div data-zone-preview="header" style="pointer-events:none;user-select:none"><div id="zone" class="box" style="background:#15202c;color:#ffffff">header zone preview (pointer-events:none, like the editor's header/footer preview)</div></div>
|
||||
<p id="txt" style="color:#6b7280;margin:6px 0">muted text on frame bg</p>
|
||||
</div>
|
||||
<div id="chrome" style="padding:10px">editor chrome</div>
|
||||
</body></html>`;
|
||||
|
||||
// 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);
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
/**
|
||||
* Used by scripts/e2e-color-system.mjs: reads {title,css,html,headCode,design} JSON on stdin and prints what the
|
||||
* BACKEND publishes (generateCompiledHTML in api/site-builder.php). Usage: php e2e-color-system-backend.php <backend repo dir>
|
||||
*/
|
||||
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'));
|
||||
@@ -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 || {} };
|
||||
}
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
@@ -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('</head>', () => `<script>window.WHP_CONFIG=${JSON.stringify(cfg)};</script></head>`);
|
||||
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}` };
|
||||
}
|
||||
@@ -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=<dir> 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('</head>')[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);
|
||||
@@ -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('<!doctype html><title>t</title><body></body>'); }
|
||||
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);
|
||||
@@ -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(
|
||||
<EditorConfigProvider config={{ user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1, siteDomain: 'example.com', siteName: 'E', backUrl: '/', isRoot: false }}>
|
||||
<SaveGuardProvider><PageProvider><SiteDesignProvider><Probe /><SiteDesignPanel /></SiteDesignProvider></PageProvider></SaveGuardProvider>
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
}
|
||||
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<HTMLButtonElement>('.contrast-note__use')!;
|
||||
h.act(() => { use.click(); });
|
||||
expect(design.design.textColor).toMatch(/^#[0-9a-f]{6}$/);
|
||||
expect(design.design.textColor).not.toBe('#eeeeee');
|
||||
});
|
||||
});
|
||||
@@ -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<ColorFieldProps> = ({ label, value, onChange }) => (
|
||||
<ColorPickerField label={label} value={value} onChange={onChange} showTokens={false} />
|
||||
);
|
||||
const ColorField: React.FC<ColorFieldProps> = ({ 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 (
|
||||
<ColorPickerField
|
||||
label={label} value={value} onChange={onChange} showTokens={false}
|
||||
contrastWith={other}
|
||||
contrastRole={pair === 'button-primary' ? 'bg' : 'fg'}
|
||||
contrastLabel={p?.label}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Try a palette ---------- */
|
||||
|
||||
@@ -299,6 +316,7 @@ export const SiteDesignPanel: React.FC = () => {
|
||||
</div>
|
||||
<ColorField
|
||||
label="Primary Color"
|
||||
pair="button-primary"
|
||||
value={design.primaryColor}
|
||||
onChange={(v) => updateDesign({ primaryColor: v })}
|
||||
/>
|
||||
@@ -324,6 +342,7 @@ export const SiteDesignPanel: React.FC = () => {
|
||||
/>
|
||||
<ColorField
|
||||
label="Link Color"
|
||||
pair="link-bg"
|
||||
value={design.linkColor}
|
||||
onChange={(v) => updateDesign({ linkColor: v })}
|
||||
/>
|
||||
@@ -335,6 +354,7 @@ export const SiteDesignPanel: React.FC = () => {
|
||||
<>
|
||||
<ColorField
|
||||
label="Primary Color"
|
||||
pair="button-primary"
|
||||
value={design.primaryColor}
|
||||
onChange={(v) => updateDesign({ primaryColor: v })}
|
||||
/>
|
||||
@@ -370,11 +390,13 @@ export const SiteDesignPanel: React.FC = () => {
|
||||
/>
|
||||
<ColorField
|
||||
label="Text Color"
|
||||
pair="text-bg"
|
||||
value={design.textColor}
|
||||
onChange={(v) => updateDesign({ textColor: v })}
|
||||
/>
|
||||
<ColorField
|
||||
label="Muted Text Color"
|
||||
pair="muted-bg"
|
||||
value={design.mutedTextColor}
|
||||
onChange={(v) => updateDesign({ mutedTextColor: v })}
|
||||
/>
|
||||
@@ -400,6 +422,7 @@ export const SiteDesignPanel: React.FC = () => {
|
||||
/>
|
||||
<ColorField
|
||||
label="Link Color"
|
||||
pair="link-bg"
|
||||
value={design.linkColor}
|
||||
onChange={(v) => updateDesign({ linkColor: v })}
|
||||
/>
|
||||
@@ -420,6 +443,8 @@ export const SiteDesignPanel: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<SwatchManager />
|
||||
|
||||
{/* Reset button */}
|
||||
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--color-border)' }}>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { describe, test, expect, vi, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
|
||||
import { PageProvider, usePages } from '../../state/PageContext';
|
||||
import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue } from '../../state/SiteDesignContext';
|
||||
import { CanvasTokenStyle } from '../../editor/CanvasTokenStyle';
|
||||
import { SwatchManager } from './SwatchManager';
|
||||
|
||||
const pickFromCanvas = vi.fn();
|
||||
const pickFromScreen = vi.fn();
|
||||
let screenKind: 'eyedropper' | 'none' = 'none';
|
||||
vi.mock('../../utils/color-pick', async (orig) => {
|
||||
const real = await orig<typeof import('../../utils/color-pick')>();
|
||||
return { ...real, pickFromCanvas: (...a: unknown[]) => pickFromCanvas(...a), pickFromScreen: (...a: unknown[]) => pickFromScreen(...a), screenPickKind: () => screenKind };
|
||||
});
|
||||
vi.mock('./styles/PaletteFromImageModal', () => ({
|
||||
PaletteFromImageModal: (p: { onAdd: (h: string) => void; onClose: () => void }) => (
|
||||
<div data-testid="palette-modal">
|
||||
<button data-testid="palette-add-two" onClick={() => { p.onAdd('#111111'); p.onAdd('#222222'); }}>add two</button>
|
||||
<button onClick={p.onClose}>close</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
let h: EditorHarness;
|
||||
let design: SiteDesignContextValue;
|
||||
let pages: ReturnType<typeof usePages>;
|
||||
const Probe: React.FC = () => { design = useSiteDesign(); pages = usePages(); return null; };
|
||||
|
||||
afterEach(() => { h?.unmount(); document.body.innerHTML = ''; pickFromCanvas.mockReset(); pickFromScreen.mockReset(); screenKind = 'none'; });
|
||||
|
||||
const BRAND = { id: 'b1', label: 'Logo blue', hex: '#15202c', brand: true, slot: 1 };
|
||||
const PLAIN = { id: 'p1', label: 'Sand', hex: '#d2b48c', brand: false };
|
||||
|
||||
function mount(swatches: unknown[] = []) {
|
||||
h = renderEditorHarness();
|
||||
h.mountChild(
|
||||
<PageProvider>
|
||||
<SiteDesignProvider>
|
||||
<Probe />
|
||||
<CanvasTokenStyle />
|
||||
<SwatchManager />
|
||||
</SiteDesignProvider>
|
||||
</PageProvider>,
|
||||
);
|
||||
h.act(() => { design.updateDesign({ swatches: swatches as never }); });
|
||||
}
|
||||
const q = (sel: string) => h.container.querySelector<HTMLElement>(sel)!;
|
||||
const qa = (sel: string) => Array.from(h.container.querySelectorAll<HTMLElement>(sel));
|
||||
const row = (id: string) => q(`[data-swatch-row="${id}"]`);
|
||||
function type(input: HTMLInputElement | HTMLSelectElement, value: string) {
|
||||
const proto = input instanceof HTMLSelectElement ? window.HTMLSelectElement.prototype : window.HTMLInputElement.prototype;
|
||||
Object.getOwnPropertyDescriptor(proto, 'value')!.set!.call(input, value);
|
||||
input.dispatchEvent(new Event(input instanceof HTMLSelectElement ? 'change' : 'input', { bubbles: true }));
|
||||
}
|
||||
const click = (el: HTMLElement) => h.act(() => { el.click(); });
|
||||
const flush = async () => { await h.act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
|
||||
const btn = (text: string) => qa('button').find((b) => b.textContent?.trim() === text) as HTMLButtonElement | undefined;
|
||||
const hexes = () => design.design.swatches.map((s) => s.hex);
|
||||
const tokenStyle = () => document.querySelector('style[data-wsb-canvas-tokens]')!.textContent ?? '';
|
||||
const st = (c: string) => JSON.stringify({ ROOT: { props: { style: { backgroundColor: c, color: c } } } });
|
||||
function seedPages() {
|
||||
h.act(() => {
|
||||
pages.setPagesCraftState([
|
||||
{ id: 'home', name: 'Home', slug: 'index', craftState: st('#fff') },
|
||||
{ id: 'p2', name: 'About', slug: 'about', craftState: st('var(--wsb-brand-1)') },
|
||||
]);
|
||||
pages.setActivePageIdDirect('home');
|
||||
pages.setHeaderCraftState(st('var(--wsb-brand-1)'));
|
||||
});
|
||||
}
|
||||
|
||||
describe('Saved colours list', () => {
|
||||
test('lists swatches with label + hex', () => {
|
||||
mount([BRAND, PLAIN]);
|
||||
expect(qa('[data-swatch-row]').length).toBe(2);
|
||||
expect((row('b1').querySelector('[data-field="label"]') as HTMLInputElement).value).toBe('Logo blue');
|
||||
expect((row('b1').querySelector('[data-field="hex"]') as HTMLInputElement).value).toBe('#15202c');
|
||||
expect((row('p1').querySelector('[data-field="label"]') as HTMLInputElement).maxLength).toBe(40);
|
||||
});
|
||||
|
||||
test('add colour: typed hex (no #) + optional label; invalid is rejected with an inline error', () => {
|
||||
mount();
|
||||
h.act(() => { type(q('[data-field="new-hex"]') as HTMLInputElement, 'zz'); });
|
||||
click(btn('Add colour')!);
|
||||
expect(hexes()).toEqual([]);
|
||||
expect(q('[role="alert"]').textContent).toMatch(/hex/i);
|
||||
h.act(() => { type(q('[data-field="new-hex"]') as HTMLInputElement, 'ABC'); type(q('[data-field="new-label"]') as HTMLInputElement, 'Accent glow'); });
|
||||
click(btn('Add colour')!);
|
||||
expect(design.design.swatches.map((s) => [s.hex, s.label])).toEqual([['#abc', 'Accent glow']]);
|
||||
});
|
||||
|
||||
test('editing the hex commits valid values (normalised) and never commits invalid ones', () => {
|
||||
mount([PLAIN]);
|
||||
const input = () => row('p1').querySelector('[data-field="hex"]') as HTMLInputElement;
|
||||
h.act(() => { type(input(), '#12'); });
|
||||
expect(hexes()).toEqual(['#d2b48c']);
|
||||
expect(row('p1').querySelector('[role="alert"]')).not.toBeNull();
|
||||
h.act(() => { type(input(), 'ABCDEF'); });
|
||||
expect(hexes()).toEqual(['#abcdef']);
|
||||
expect(row('p1').querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
|
||||
test('renaming works', () => {
|
||||
mount([PLAIN]);
|
||||
h.act(() => { type(row('p1').querySelector('[data-field="label"]') as HTMLInputElement, 'Beach'); });
|
||||
expect(design.design.swatches[0].label).toBe('Beach');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Brand toggle', () => {
|
||||
test('marking brand shows the live badge and token name; the 7th is refused with a message', () => {
|
||||
const six = Array.from({ length: 6 }, (_, i) => ({ id: `s${i}`, label: '', hex: '#00000' + i, brand: false }));
|
||||
mount([...six, PLAIN]);
|
||||
for (let i = 0; i < 6; i++) click(row(`s${i}`).querySelector('[data-action="brand"]') as HTMLElement);
|
||||
expect(design.design.swatches.filter((s) => s.brand).map((s) => s.slot)).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
expect(row('s0').textContent).toContain('live');
|
||||
expect((row('s0').querySelector('[data-testid="live-badge"]') as HTMLElement).title).toContain('var(--wsb-brand-1)');
|
||||
click(row('p1').querySelector('[data-action="brand"]') as HTMLElement);
|
||||
expect(design.design.swatches.find((s) => s.id === 'p1')!.brand).toBe(false);
|
||||
expect(h.container.textContent).toContain('You can have up to 6 brand colours');
|
||||
});
|
||||
|
||||
test('editing a brand swatch hex recolours the canvas token style live', () => {
|
||||
mount([BRAND]);
|
||||
expect(tokenStyle()).toContain('--wsb-brand-1:#15202c');
|
||||
h.act(() => { type(row('b1').querySelector('[data-field="hex"]') as HTMLInputElement, '#ff00aa'); });
|
||||
expect(tokenStyle()).toContain('--wsb-brand-1:#ff00aa');
|
||||
expect(tokenStyle()).not.toContain('#15202c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deleting / un-branding a referenced brand colour', () => {
|
||||
test('delete: asks, states the count, and on confirm rewrites every reference to the hex', () => {
|
||||
mount([BRAND, PLAIN]);
|
||||
seedPages();
|
||||
click(row('b1').querySelector('[data-action="delete"]') as HTMLElement);
|
||||
expect(hexes()).toEqual(['#15202c', '#d2b48c']); // nothing yet
|
||||
const dlg = q('[role="alertdialog"]');
|
||||
expect(dlg.textContent).toMatch(/4 places/) /* 2 states x (background + colour) */;
|
||||
expect(dlg.textContent).toContain('#15202c');
|
||||
click(dlg.querySelector('[data-action="confirm"]') as HTMLElement);
|
||||
expect(hexes()).toEqual(['#d2b48c']);
|
||||
expect(pages.pages.find((p) => p.id === 'p2')!.craftState).toBe(st('#15202c'));
|
||||
expect(pages.headerPage.craftState).toBe(st('#15202c'));
|
||||
});
|
||||
|
||||
test('cancel leaves everything alone', () => {
|
||||
mount([BRAND]);
|
||||
seedPages();
|
||||
click(row('b1').querySelector('[data-action="delete"]') as HTMLElement);
|
||||
click(q('[role="alertdialog"] [data-action="cancel"]'));
|
||||
expect(hexes()).toEqual(['#15202c']);
|
||||
expect(pages.headerPage.craftState).toContain('var(--wsb-brand-1)');
|
||||
expect(q('[data-swatch-row="b1"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('un-brand: same guard; confirm keeps the swatch (plain) and rewrites references', () => {
|
||||
mount([BRAND]);
|
||||
seedPages();
|
||||
click(row('b1').querySelector('[data-action="brand"]') as HTMLElement);
|
||||
expect(design.design.swatches[0].brand).toBe(true);
|
||||
click(q('[role="alertdialog"] [data-action="confirm"]'));
|
||||
expect(design.design.swatches[0].brand).toBe(false);
|
||||
expect(pages.pages.find((p) => p.id === 'p2')!.craftState).toBe(st('#15202c'));
|
||||
});
|
||||
|
||||
test('an unreferenced brand colour deletes without asking; a plain one never asks', () => {
|
||||
mount([BRAND, PLAIN]);
|
||||
click(row('p1').querySelector('[data-action="delete"]') as HTMLElement);
|
||||
expect(hexes()).toEqual(['#15202c']);
|
||||
click(row('b1').querySelector('[data-action="delete"]') as HTMLElement);
|
||||
expect(q('[role="alertdialog"]')).toBeNull();
|
||||
expect(hexes()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Use as site colour', () => {
|
||||
test('select a slot -> confirmation naming the whole site -> sets that design colour', () => {
|
||||
mount([PLAIN]);
|
||||
h.act(() => { type(row('p1').querySelector('select[aria-label="Use as site colour"]') as HTMLSelectElement, 'primary'); });
|
||||
const dlg = q('[role="alertdialog"]');
|
||||
expect(dlg.textContent).toMatch(/recolou?rs? the whole site/i);
|
||||
expect(dlg.textContent).toContain('Primary');
|
||||
expect(design.design.primaryColor).toBe('#3b82f6');
|
||||
click(dlg.querySelector('[data-action="confirm"]') as HTMLElement);
|
||||
expect(design.design.primaryColor).toBe('#d2b48c');
|
||||
});
|
||||
test('cancel does nothing', () => {
|
||||
mount([PLAIN]);
|
||||
h.act(() => { type(row('p1').querySelector('select[aria-label="Use as site colour"]') as HTMLSelectElement, 'bg'); });
|
||||
click(q('[role="alertdialog"] [data-action="cancel"]'));
|
||||
expect(design.design.backgroundColor).toBe('#ffffff');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pick buttons', () => {
|
||||
test('Pick from page: a single colour is added as "Picked colour"', async () => {
|
||||
mount();
|
||||
pickFromCanvas.mockResolvedValue({ ok: true, hex: '#010203', source: 'canvas', candidates: [{ kind: 'background', hex: '#010203' }] });
|
||||
await h.act(async () => { btn('Pick from page')!.click(); });
|
||||
await flush();
|
||||
expect(design.design.swatches.map((s) => [s.hex, s.label])).toEqual([['#010203', 'Picked colour']]);
|
||||
});
|
||||
test('differing candidates open a chooser with match notes; picking one adds it', async () => {
|
||||
mount();
|
||||
pickFromCanvas.mockResolvedValue({
|
||||
ok: true, hex: '#0f172a', source: 'canvas',
|
||||
candidates: [
|
||||
{ kind: 'background', hex: '#0f172a', match: { kind: 'token', ref: 'var(--wsb-primary)', label: 'Primary' } },
|
||||
{ kind: 'text', hex: '#ffffff' },
|
||||
],
|
||||
});
|
||||
await h.act(async () => { btn('Pick from page')!.click(); });
|
||||
await flush();
|
||||
expect(hexes()).toEqual([]);
|
||||
const chooser = q('[data-testid="pick-chooser"]');
|
||||
expect(chooser.textContent).toContain('matches Primary');
|
||||
expect(chooser.textContent).toMatch(/Background/);
|
||||
expect(chooser.textContent).toMatch(/Text/);
|
||||
click(qa('[data-testid="pick-chooser"] button').find((b) => /Text/.test(b.textContent!))!);
|
||||
expect(hexes()).toEqual(['#ffffff']);
|
||||
expect(q('[data-testid="pick-chooser"]')).toBeNull();
|
||||
});
|
||||
test('a pick that matches an already-saved colour does not duplicate it', async () => {
|
||||
mount([BRAND]);
|
||||
pickFromCanvas.mockResolvedValue({ ok: true, hex: '#15202c', source: 'canvas', candidates: [{ kind: 'background', hex: '#15202c', match: { kind: 'swatch', ref: 'var(--wsb-brand-1)', label: 'Logo blue' } }] });
|
||||
await h.act(async () => { btn('Pick from page')!.click(); });
|
||||
await flush();
|
||||
expect(hexes()).toEqual(['#15202c']);
|
||||
expect(h.container.textContent).toContain('Logo blue');
|
||||
});
|
||||
test('Pick from screen exists only with the eyedropper; otherwise the hint shows', async () => {
|
||||
mount();
|
||||
expect(btn('Pick from screen')).toBeUndefined();
|
||||
expect(h.container.textContent).toContain('Your browser cannot sample outside the builder. Use Pick from page or Pick from image.');
|
||||
h.unmount();
|
||||
screenKind = 'eyedropper';
|
||||
mount();
|
||||
expect(h.container.textContent).not.toContain('Your browser cannot sample');
|
||||
pickFromScreen.mockResolvedValue({ ok: true, hex: '#a1b2c3', source: 'eyedropper' });
|
||||
await h.act(async () => { btn('Pick from screen')!.click(); });
|
||||
await flush();
|
||||
expect(hexes()).toEqual(['#a1b2c3']);
|
||||
});
|
||||
test('a cancelled pick adds nothing and says nothing', async () => {
|
||||
mount();
|
||||
pickFromCanvas.mockResolvedValue({ ok: false, reason: 'cancelled', message: 'Colour picking was cancelled.' });
|
||||
await h.act(async () => { btn('Pick from page')!.click(); });
|
||||
await flush();
|
||||
expect(hexes()).toEqual([]);
|
||||
expect(h.container.textContent).not.toContain('cancelled');
|
||||
});
|
||||
test('Palette from image: several adds in one go all land; never applied to the site', () => {
|
||||
mount();
|
||||
click(btn('Palette from image')!);
|
||||
click(q('[data-testid="palette-add-two"]'));
|
||||
expect(hexes()).toEqual(['#111111', '#222222']);
|
||||
expect(design.design.primaryColor).toBe('#3b82f6');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useSiteDesign, type SiteDesign } from '../../state/SiteDesignContext';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import { COLOR_TOKENS, TOKEN_VARS, type TokenKey } from '../../utils/tokens';
|
||||
import {
|
||||
addSwatch, countBrandRefUses, findSwatchByHex, MAX_BRAND_COLORS, MAX_SWATCHES, normalizeHex,
|
||||
removeSwatch, replaceBrandRef, setSwatchBrand, swatchValue, updateSwatch, type SavedSwatch,
|
||||
} from '../../utils/swatches';
|
||||
import { pickFromCanvas, pickFromScreen, screenPickKind, type CanvasCandidate, type CandidateKind } from '../../utils/color-pick';
|
||||
import { ImagePickModal } from './styles/ImagePickModal';
|
||||
import { PaletteFromImageModal } from './styles/PaletteFromImageModal';
|
||||
import { inputStyle } from './styles/shared';
|
||||
|
||||
const KIND_LABEL: Record<CandidateKind, string> = { background: 'Background', text: 'Text', border: 'Border' };
|
||||
const HEX_HINT = 'Enter a hex colour like #3b82f6 (3, 4, 6 or 8 digits).';
|
||||
const NO_SCREEN_HINT = 'Your browser cannot sample outside the builder. Use Pick from page or Pick from image.';
|
||||
|
||||
const designKeyOf = (key: TokenKey): keyof SiteDesign =>
|
||||
TOKEN_VARS.find(([v]) => v === `--wsb-${key}`)![1];
|
||||
|
||||
type Confirm =
|
||||
| { kind: 'delete' | 'unbrand'; id: string; count: number }
|
||||
| { kind: 'slot'; id: string; token: TokenKey };
|
||||
|
||||
const smallBtn: React.CSSProperties = {
|
||||
fontSize: 10, lineHeight: 1.2, padding: '3px 7px', cursor: 'pointer',
|
||||
color: 'var(--color-text-muted)', background: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)',
|
||||
};
|
||||
|
||||
/* ---------- one row ---------- */
|
||||
|
||||
interface RowProps {
|
||||
sw: SavedSwatch;
|
||||
confirm: Confirm | null;
|
||||
design: SiteDesign;
|
||||
onLabel: (v: string) => void;
|
||||
onHex: (hex: string) => void;
|
||||
onBrand: () => void;
|
||||
onDelete: () => void;
|
||||
onSlot: (token: TokenKey) => void;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const SwatchRow: React.FC<RowProps> = ({ sw, confirm, onLabel, onHex, onBrand, onDelete, onSlot, onConfirm, onCancel }) => {
|
||||
const [text, setText] = useState(sw.hex);
|
||||
useEffect(() => { if (normalizeHex(text) !== sw.hex) setText(sw.hex); }, [sw.hex]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const valid = normalizeHex(text) !== null;
|
||||
const live = swatchValue(sw) !== sw.hex;
|
||||
|
||||
const change = (v: string) => {
|
||||
setText(v);
|
||||
const h = normalizeHex(v);
|
||||
if (h) onHex(h);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-swatch-row={sw.id} style={{ padding: '6px 0', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span aria-hidden style={{ width: 22, height: 22, borderRadius: '50%', flex: '0 0 22px', border: '2px solid var(--color-border)', background: sw.hex }} />
|
||||
<input
|
||||
type="text" data-field="label" aria-label="Colour label" maxLength={40} value={sw.label}
|
||||
placeholder="Label (e.g. Logo blue)" onChange={(e) => onLabel(e.target.value)}
|
||||
style={{ ...inputStyle, flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<button type="button" data-action="delete" aria-label="Delete colour" title="Delete colour" onClick={onDelete} style={smallBtn}>Delete</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 5, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="text" data-field="hex" aria-label="Hex colour" value={text} placeholder="#000000"
|
||||
onChange={(e) => change(e.target.value)}
|
||||
style={{ ...inputStyle, width: 92, fontFamily: 'monospace', borderColor: valid ? undefined : '#ef4444' }}
|
||||
/>
|
||||
<button
|
||||
type="button" data-action="brand" aria-pressed={sw.brand}
|
||||
title={sw.brand ? 'Live brand colour: editing it recolours everything that uses it' : 'Make this a live brand colour'}
|
||||
onClick={onBrand}
|
||||
style={{ ...smallBtn, ...(sw.brand ? { color: '#fff', background: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : null) }}
|
||||
>
|
||||
Brand
|
||||
</button>
|
||||
{live && (
|
||||
<span data-testid="live-badge" title={swatchValue(sw)} style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-accent)' }}>live</span>
|
||||
)}
|
||||
<select
|
||||
aria-label="Use as site colour" value="" style={{ ...inputStyle, width: 'auto', flex: 1, minWidth: 110, fontSize: 11 }}
|
||||
onChange={(e) => { if (e.target.value) onSlot(e.target.value as TokenKey); }}
|
||||
>
|
||||
<option value="">Use as site colour…</option>
|
||||
{COLOR_TOKENS.map((t) => <option key={t.key} value={t.key}>{t.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{!valid && <div role="alert" className="color-hex-error">{HEX_HINT}</div>}
|
||||
{confirm && (
|
||||
<div
|
||||
role="alertdialog" aria-label="Confirm colour change"
|
||||
style={{ marginTop: 6, padding: 8, borderRadius: 'var(--radius-sm)', border: '1px solid rgba(239,68,68,0.4)', background: 'rgba(239,68,68,0.06)', fontSize: 11, lineHeight: 1.5, color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
<div>
|
||||
{confirm.kind === 'slot' ? (
|
||||
<>This recolours the whole site: every place that uses <strong>{COLOR_TOKENS.find((t) => t.key === confirm.token)!.label}</strong> becomes <strong>{sw.hex}</strong>.</>
|
||||
) : (
|
||||
<>
|
||||
{sw.label.trim() ? <strong>{sw.label.trim()}</strong> : 'This brand colour'} is used in {confirm.count} place{confirm.count === 1 ? '' : 's'}.
|
||||
{' '}If you {confirm.kind === 'delete' ? 'delete it' : 'turn off Brand'}, those places keep the fixed colour <strong>{sw.hex}</strong> but stop following this swatch.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
|
||||
<button type="button" data-action="confirm" onClick={onConfirm} style={{ ...smallBtn, color: '#fff', background: '#ef4444', borderColor: '#ef4444' }}>
|
||||
{confirm.kind === 'slot' ? 'Recolour site' : confirm.kind === 'delete' ? 'Replace and delete' : 'Replace and turn off'}
|
||||
</button>
|
||||
<button type="button" data-action="cancel" onClick={onCancel} style={smallBtn}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- the section ---------- */
|
||||
|
||||
export const SwatchManager: React.FC = () => {
|
||||
const { design, updateDesign } = useSiteDesign();
|
||||
const { siteStateStrings, rewriteSiteStates } = usePages();
|
||||
const swatches: SavedSwatch[] = Array.isArray(design.swatches) ? design.swatches : [];
|
||||
// Several adds can land in one tick (palette "Add all"): chain on the latest list, not the render's.
|
||||
const latest = useRef(swatches);
|
||||
latest.current = swatches;
|
||||
|
||||
const [newHex, setNewHex] = useState('');
|
||||
const [newLabel, setNewLabel] = useState('');
|
||||
const [addError, setAddError] = useState(false);
|
||||
const [note, setNote] = useState('');
|
||||
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
||||
const [chooser, setChooser] = useState<CanvasCandidate[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [imageOpen, setImageOpen] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
|
||||
const commit = (list: SavedSwatch[]) => { latest.current = list; updateDesign({ swatches: list }); };
|
||||
|
||||
const add = (hex: string, label: string): boolean => {
|
||||
const next = addSwatch(latest.current, hex, label);
|
||||
if (next === latest.current) return false;
|
||||
commit(next);
|
||||
return true;
|
||||
};
|
||||
|
||||
const addPicked = (hex: string) => {
|
||||
setChooser(null);
|
||||
const have = findSwatchByHex(latest.current, hex);
|
||||
if (have) { setNote(`Already saved as "${have.label.trim() || have.hex}".`); return; }
|
||||
setNote(add(hex, 'Picked colour') ? '' : `You can save up to ${MAX_SWATCHES} colours.`);
|
||||
};
|
||||
|
||||
const submitNew = () => {
|
||||
const h = normalizeHex(newHex);
|
||||
if (!h) { setAddError(true); return; }
|
||||
if (!add(h, newLabel)) { setNote(`You can save up to ${MAX_SWATCHES} colours.`); return; }
|
||||
setNewHex(''); setNewLabel(''); setAddError(false); setNote('');
|
||||
};
|
||||
|
||||
const usesOf = (slot: number): number =>
|
||||
siteStateStrings().reduce((n, s) => n + countBrandRefUses(s, slot), 0) + countBrandRefUses(design.headCode || '', slot);
|
||||
|
||||
/** Rewrites every reference to the brand slot into the literal hex (pages, header, footer, global sections, live canvas, head code). */
|
||||
const freezeRefs = (sw: SavedSwatch): Partial<SiteDesign> => {
|
||||
const slot = sw.slot as number;
|
||||
rewriteSiteStates((s) => replaceBrandRef(s, slot, sw.hex));
|
||||
const head = replaceBrandRef(design.headCode || '', slot, sw.hex);
|
||||
return head !== design.headCode ? { headCode: head } : {};
|
||||
};
|
||||
|
||||
const onBrand = (sw: SavedSwatch) => {
|
||||
setNote('');
|
||||
if (!sw.brand) {
|
||||
const next = setSwatchBrand(latest.current, sw.id, true);
|
||||
if (next === latest.current) setNote(`You can have up to ${MAX_BRAND_COLORS} brand colours`);
|
||||
else commit(next);
|
||||
return;
|
||||
}
|
||||
const count = usesOf(sw.slot as number);
|
||||
if (count > 0) setConfirm({ kind: 'unbrand', id: sw.id, count });
|
||||
else commit(setSwatchBrand(latest.current, sw.id, false));
|
||||
};
|
||||
|
||||
const onDelete = (sw: SavedSwatch) => {
|
||||
setNote('');
|
||||
const count = sw.brand && typeof sw.slot === 'number' ? usesOf(sw.slot) : 0;
|
||||
if (count > 0) setConfirm({ kind: 'delete', id: sw.id, count });
|
||||
else commit(removeSwatch(latest.current, sw.id));
|
||||
};
|
||||
|
||||
const doConfirm = () => {
|
||||
const c = confirm;
|
||||
if (!c) return;
|
||||
const sw = latest.current.find((s) => s.id === c.id);
|
||||
setConfirm(null);
|
||||
if (!sw) return;
|
||||
if (c.kind === 'slot') {
|
||||
updateDesign({ [designKeyOf(c.token)]: sw.hex } as Partial<SiteDesign>);
|
||||
return;
|
||||
}
|
||||
const extra = freezeRefs(sw);
|
||||
const list = c.kind === 'delete' ? removeSwatch(latest.current, sw.id) : setSwatchBrand(latest.current, sw.id, false);
|
||||
latest.current = list;
|
||||
updateDesign({ swatches: list, ...extra });
|
||||
};
|
||||
|
||||
const pickPage = async () => {
|
||||
setNote(''); setChooser(null); setBusy(true);
|
||||
try {
|
||||
const r = await pickFromCanvas({ design });
|
||||
if (!r.ok) { if (r.reason !== 'cancelled') setNote(r.message); return; }
|
||||
const distinct = new Set(r.candidates.map((c) => c.hex));
|
||||
if (distinct.size > 1) setChooser(r.candidates);
|
||||
else addPicked(r.hex);
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const pickScreen = async () => {
|
||||
setNote(''); setBusy(true);
|
||||
try {
|
||||
const r = await pickFromScreen();
|
||||
if (r.ok) addPicked(r.hex);
|
||||
else if (r.reason !== 'cancelled') setNote(r.message);
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const hasScreen = screenPickKind() === 'eyedropper';
|
||||
|
||||
return (
|
||||
<div className="guided-section" data-testid="swatch-manager">
|
||||
<label className="guided-section-label">Saved colours</label>
|
||||
<p style={{ fontSize: 10, color: 'var(--color-text-dim)', margin: '0 0 6px', lineHeight: 1.4 }}>
|
||||
Labelled colours that appear in every colour field. Mark one as a Brand colour and editing it recolours everything that uses it.
|
||||
</p>
|
||||
|
||||
{swatches.length === 0 && <div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 6 }}>No saved colours yet.</div>}
|
||||
{swatches.map((sw) => (
|
||||
<SwatchRow
|
||||
key={sw.id}
|
||||
sw={sw}
|
||||
design={design}
|
||||
confirm={confirm && confirm.id === sw.id ? confirm : null}
|
||||
onLabel={(v) => commit(updateSwatch(latest.current, sw.id, { label: v }))}
|
||||
onHex={(h) => commit(updateSwatch(latest.current, sw.id, { hex: h }))}
|
||||
onBrand={() => onBrand(sw)}
|
||||
onDelete={() => onDelete(sw)}
|
||||
onSlot={(token) => { setNote(''); setConfirm({ kind: 'slot', id: sw.id, token }); }}
|
||||
onConfirm={doConfirm}
|
||||
onCancel={() => setConfirm(null)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||
<input
|
||||
type="text" data-field="new-hex" aria-label="New colour hex" placeholder="#hex" value={newHex}
|
||||
onChange={(e) => { setNewHex(e.target.value); setAddError(false); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitNew(); }}
|
||||
style={{ ...inputStyle, width: 82, fontFamily: 'monospace', borderColor: addError ? '#ef4444' : undefined }}
|
||||
/>
|
||||
<input
|
||||
type="text" data-field="new-label" aria-label="New colour label" placeholder="Label (optional)" maxLength={40} value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitNew(); }}
|
||||
style={{ ...inputStyle, flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<button type="button" className="color-tool-btn" onClick={submitNew}>Add colour</button>
|
||||
</div>
|
||||
{addError && <div role="alert" className="color-hex-error">{HEX_HINT}</div>}
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 8 }}>
|
||||
<button type="button" className="color-tool-btn" disabled={busy} onClick={pickPage}>Pick from page</button>
|
||||
{hasScreen && <button type="button" className="color-tool-btn" disabled={busy} onClick={pickScreen}>Pick from screen</button>}
|
||||
<button type="button" className="color-tool-btn" onClick={() => setImageOpen(true)}>Pick from image</button>
|
||||
<button type="button" className="color-tool-btn" onClick={() => setPaletteOpen(true)}>Palette from image</button>
|
||||
</div>
|
||||
{!hasScreen && <div style={{ fontSize: 10, color: 'var(--color-text-dim)', marginTop: 4, lineHeight: 1.4 }}>{NO_SCREEN_HINT}</div>}
|
||||
|
||||
{chooser && (
|
||||
<div data-testid="pick-chooser" style={{ marginTop: 6, padding: 6, border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)' }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>Which colour from that spot?</div>
|
||||
{chooser.map((c) => (
|
||||
<button
|
||||
key={c.kind} type="button" onClick={() => addPicked(c.hex)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', textAlign: 'left', marginBottom: 3, ...smallBtn, fontSize: 11, color: 'var(--color-text)' }}
|
||||
>
|
||||
<span aria-hidden style={{ width: 16, height: 16, flex: '0 0 16px', borderRadius: 3, border: '1px solid rgba(255,255,255,0.55)', background: c.hex }} />
|
||||
<span>{KIND_LABEL[c.kind]}</span>
|
||||
<code>{c.hex}</code>
|
||||
{c.match && <em style={{ opacity: 0.8 }}>matches {c.match.label}</em>}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={() => setChooser(null)} style={smallBtn}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{note && <div role="status" style={{ fontSize: 11, color: 'var(--color-warning, #d97706)', marginTop: 6 }}>{note}</div>}
|
||||
|
||||
{imageOpen && <ImagePickModal onPick={(hex) => { addPicked(hex); setImageOpen(false); }} onClose={() => setImageOpen(false)} />}
|
||||
{paletteOpen && (
|
||||
<PaletteFromImageModal
|
||||
existingHexes={swatches.map((s) => s.hex)}
|
||||
onAdd={(hex, label) => { add(hex, label ?? ''); }}
|
||||
onClose={() => setPaletteOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from './shared';
|
||||
import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
|
||||
import { OverrideDot } from './OverrideDot';
|
||||
import { isLargeText } from '../../../utils/effective-background';
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
@@ -89,6 +90,10 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
||||
activeValue={style.backgroundColor as string}
|
||||
onSelect={setButtonColor}
|
||||
onContinuous={setButtonColorContinuous}
|
||||
contrastWith={(style.color as string) || ''}
|
||||
contrastRole="bg"
|
||||
contrastLarge={isLargeText(style.fontSize as string, style.fontWeight as string)}
|
||||
contrastLabel="Button text on its colour"
|
||||
/>
|
||||
</div>
|
||||
<TextInputField
|
||||
@@ -142,11 +147,16 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
||||
label="Hover Background"
|
||||
value={nodeProps.hoverBg || ''}
|
||||
onChange={(v) => setPropContinuous('hoverBg', v)}
|
||||
contrastWith={nodeProps.hoverColor || ''}
|
||||
contrastRole="bg"
|
||||
contrastLabel="Hover text on its colour"
|
||||
/>
|
||||
<ColorPickerField
|
||||
label="Hover Text Color"
|
||||
value={nodeProps.hoverColor || ''}
|
||||
onChange={(v) => setPropContinuous('hoverColor', v)}
|
||||
contrastWith={nodeProps.hoverBg || (style.backgroundColor as string) || ''}
|
||||
contrastLabel="Hover text on its colour"
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
|
||||
@@ -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<ColorPickToolsProps> = ({ 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 (
|
||||
<div className="color-pick-tools" style={{ marginTop: 6 }}>
|
||||
<div className="color-pick-tools__row" style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
<button type="button" className="color-tool-btn" disabled={busy} onClick={() => run('page')}>Pick from page</button>
|
||||
{screenPickKind() === 'eyedropper' && (
|
||||
<button type="button" className="color-tool-btn" disabled={busy} onClick={() => run('screen')}>Pick from screen</button>
|
||||
)}
|
||||
<button type="button" className="color-tool-btn" onClick={() => setImageOpen(true)}>Pick from image</button>
|
||||
<button
|
||||
type="button"
|
||||
className="color-tool-btn"
|
||||
disabled={!canSave}
|
||||
title={alreadySaved ? 'This colour is already saved' : 'Add this colour to the site\'s saved colours'}
|
||||
onClick={() => { if (literal && canSave) updateDesign({ swatches: addSwatch(swatches, literal, '') }); }}
|
||||
>
|
||||
Save colour
|
||||
</button>
|
||||
</div>
|
||||
{msg && <div role="status" className="color-pick-tools__msg" style={{ fontSize: 11, color: 'var(--color-warning, #d97706)', marginTop: 4 }}>{msg}</div>}
|
||||
{imageOpen && (
|
||||
<ImagePickModal
|
||||
onPick={(hex) => { applyHex(hex); setImageOpen(false); }}
|
||||
onClose={() => setImageOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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(<SiteDesignProvider>{ui}</SiteDesignProvider>); });
|
||||
}
|
||||
afterEach(() => {
|
||||
if (!container) return;
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('ContrastNote', () => {
|
||||
test('renders nothing when the pair passes', () => {
|
||||
render(<ContrastNote fg="#000000" bg="#ffffff" />);
|
||||
expect(container!.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
test('renders nothing for an unresolved colour', () => {
|
||||
render(<ContrastNote fg="var(--nope)" bg="#ffffff" />);
|
||||
expect(container!.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
test('fail shows ratio and threshold in a status region', () => {
|
||||
render(<ContrastNote fg="#aaaaaa" bg="#ffffff" />);
|
||||
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(<ContrastNote fg="#cccccc" bg="#ffffff" large label="Heading" />);
|
||||
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(<ContrastNote fg="#aaaaaa" bg="#ffffff" onUseSuggestion={cb} />);
|
||||
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(<ContrastNote fg="#ffffff" bg="linear-gradient(red, blue)" />);
|
||||
expect(container!.querySelector('[role="status"]')!.textContent).toBe('Contrast not checked (gradient)');
|
||||
});
|
||||
});
|
||||
@@ -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<ContrastNoteProps> = ({ 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 (
|
||||
<div
|
||||
className="contrast-note contrast-note--unchecked"
|
||||
role="status"
|
||||
style={{ fontSize: 11, lineHeight: 1.4, color: 'var(--color-text-muted, #8b8b96)' }}
|
||||
>
|
||||
Contrast not checked ({v.reason})
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fix = onUseSuggestion ? suggestFix(fg, bg, design, { large }) : null;
|
||||
return (
|
||||
<div
|
||||
className="contrast-note contrast-note--fail"
|
||||
role="status"
|
||||
style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 6, fontSize: 11, lineHeight: 1.4, color: 'var(--color-warning, #d97706)' }}
|
||||
>
|
||||
<span>
|
||||
{label ? `${label}: ` : ''}Low contrast {v.ratio!.toFixed(2)}:1 (needs {v.required}:1)
|
||||
</span>
|
||||
{fix && (
|
||||
<button
|
||||
type="button"
|
||||
className="contrast-note__use"
|
||||
onClick={() => 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}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<typeof import('../../../utils/image-sample')>('../../../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<typeof vi.fn>;
|
||||
const loadMock = loadImageForSampling as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
// 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(<ImagePickModal onPick={vi.fn()} onClose={vi.fn()} />);
|
||||
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(<ImagePickModal onPick={onPick} onClose={onClose} initialSrc="/sel.png" />);
|
||||
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(<ImagePickModal onPick={onPick} onClose={onClose} initialSrc="/sel.png" />);
|
||||
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(<ImagePickModal onPick={vi.fn()} onClose={vi.fn()} initialSrc="https://x.test/a.png" />);
|
||||
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(<ImagePickModal onPick={vi.fn()} onClose={onClose} />);
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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<SampleState>({ 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 }) => (
|
||||
<div data-testid="image-assets" style={assetGridStyle}>
|
||||
{assets.map((a) => (
|
||||
<div
|
||||
key={a.url}
|
||||
{...clickableProps(() => 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'}` }}
|
||||
>
|
||||
<img src={a.url} alt={a.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
</div>
|
||||
))}
|
||||
{assets.length === 0 && (
|
||||
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>
|
||||
No images uploaded yet. Upload one from an Image block first.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Uploaded images for the picker, loaded once on mount. */
|
||||
export function useImageAssets(): Asset[] {
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
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<ImagePickModalProps> = ({ onPick, onClose, initialSrc }) => {
|
||||
const assets = useImageAssets();
|
||||
const [src, setSrc] = useState<string | null>(initialSrc || null);
|
||||
const state = useSampledImage(src);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const zoomRef = useRef<HTMLCanvasElement>(null);
|
||||
const [hover, setHover] = useState<string>('');
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'ready' && canvasRef.current) state.img.draw(canvasRef.current);
|
||||
}, [state]);
|
||||
|
||||
const locate = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
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<HTMLCanvasElement>) => {
|
||||
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<HTMLCanvasElement>) => {
|
||||
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 (
|
||||
<Modal open onClose={onClose} backdropProps={{ role: 'dialog', 'aria-modal': true, 'aria-label': 'Pick a colour from an image' } as React.HTMLAttributes<HTMLDivElement>}>
|
||||
<div style={modalPanelStyle} data-testid="image-pick-modal">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<h3 style={modalTitleStyle}>Pick a colour from an image</h3>
|
||||
<button type="button" style={modalBtnStyle} onClick={onClose} data-testid="image-pick-close">Close</button>
|
||||
</div>
|
||||
<ImageAssetList assets={assets} selected={src} onSelect={setSrc} />
|
||||
{state.status === 'loading' && <p style={{ color: '#a1a1aa' }}>Loading image...</p>}
|
||||
{state.status === 'failed' && <div role="alert" style={errorStyle} data-testid="image-pick-error">{state.message}</div>}
|
||||
{src && state.status !== 'failed' && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
data-testid="image-pick-canvas"
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={() => setHover('')}
|
||||
onClick={onClick}
|
||||
style={{ display: state.status === 'ready' ? 'block' : 'none', maxWidth: '100%', maxHeight: 280, margin: '0 auto', cursor: 'crosshair', border: '1px solid #3f3f46', borderRadius: 4 }}
|
||||
/>
|
||||
{state.status === 'ready' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 8 }}>
|
||||
<canvas ref={zoomRef} width={72} height={72} aria-hidden="true" style={{ width: 72, height: 72, border: '1px solid #3f3f46', borderRadius: 4, imageRendering: 'pixelated', background: '#09090b' }} />
|
||||
<span data-testid="image-pick-swatch" style={{ width: 28, height: 28, borderRadius: 4, border: '1px solid #3f3f46', background: hover || 'transparent' }} />
|
||||
<code data-testid="image-pick-hex" style={{ fontSize: 13 }}>{hover || 'Move over the image'}</code>
|
||||
</div>
|
||||
)}
|
||||
{note && <p style={{ color: '#fbbf24', margin: '6px 0 0' }}>{note}</p>}
|
||||
</div>
|
||||
)}
|
||||
{!src && <p style={{ color: '#a1a1aa', marginTop: 10 }}>Choose an uploaded image above, then click a spot in it to use that colour.</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -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<string, any>): {
|
||||
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<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const { setProp, setProps, setPropStyle, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
|
||||
@@ -520,6 +540,7 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
||||
label={f.label}
|
||||
value={nodeProps[f.key] || f.fallback}
|
||||
onChange={(v) => setPropContinuous(f.key, v)}
|
||||
{...navContrast(f.key, nodeProps)}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
|
||||
@@ -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<typeof import('../../../utils/image-sample')>('../../../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<typeof vi.fn>;
|
||||
const loadMock = loadImageForSampling as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
// 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(<PaletteFromImageModal onAdd={onAdd} onClose={vi.fn()} existingHexes={[]} initialSrc="/a.png" />);
|
||||
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(<PaletteFromImageModal onAdd={onAdd} onClose={vi.fn()} existingHexes={[]} initialSrc="/a.png" />);
|
||||
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(<PaletteFromImageModal onAdd={vi.fn()} onClose={vi.fn()} existingHexes={['#FF0000']} initialSrc="/a.png" />);
|
||||
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(<PaletteFromImageModal onAdd={onAdd} onClose={vi.fn()} existingHexes={['#ff0000']} initialSrc="/a.png" />);
|
||||
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(<PaletteFromImageModal onAdd={vi.fn()} onClose={vi.fn()} existingHexes={[]} />);
|
||||
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(<PaletteFromImageModal onAdd={vi.fn()} onClose={vi.fn()} existingHexes={[]} initialSrc="https://x.test/a.png" />);
|
||||
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(<PaletteFromImageModal onAdd={vi.fn()} onClose={onClose} existingHexes={[]} />);
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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<PaletteFromImageModalProps> = ({ onAdd, onClose, existingHexes, initialSrc }) => {
|
||||
const assets = useImageAssets();
|
||||
const [src, setSrc] = useState<string | null>(initialSrc || null);
|
||||
const [count, setCount] = useState(6);
|
||||
const [added, setAdded] = useState<string[]>([]);
|
||||
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 (
|
||||
<Modal open onClose={onClose} backdropProps={{ role: 'dialog', 'aria-modal': true, 'aria-label': 'Palette from an image' } as React.HTMLAttributes<HTMLDivElement>}>
|
||||
<div style={modalPanelStyle} data-testid="palette-from-image-modal">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<h3 style={modalTitleStyle}>Palette from an image</h3>
|
||||
<button type="button" style={modalBtnStyle} onClick={onClose} data-testid="palette-close">Close</button>
|
||||
</div>
|
||||
<ImageAssetList assets={assets} selected={src} onSelect={setSrc} />
|
||||
{!src && <p style={{ color: '#a1a1aa', marginTop: 10 }}>Choose an uploaded image to see its main colours. Nothing is added until you press Add.</p>}
|
||||
{state.status === 'loading' && <p style={{ color: '#a1a1aa' }}>Reading colours...</p>}
|
||||
{state.status === 'failed' && <div role="alert" style={errorStyle} data-testid="palette-error">{state.message}</div>}
|
||||
{state.status === 'ready' && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<label htmlFor="palette-count">Colours</label>
|
||||
<select id="palette-count" data-testid="palette-count" value={count} onChange={(e) => setCount(Number(e.target.value))} className="guided-input" style={{ width: 60 }}>
|
||||
{[5, 6, 7, 8].map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button type="button" style={modalBtnStyle} onClick={addAll} disabled={remaining === 0} data-testid="palette-add-all">Add all</button>
|
||||
</div>
|
||||
{colours.length === 0 && <p style={{ color: '#a1a1aa' }}>No colours found. This image may be fully transparent.</p>}
|
||||
{colours.map((hex) => {
|
||||
const done = taken.has(hex);
|
||||
return (
|
||||
<div key={hex} data-testid={`palette-row-${hex}`} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '4px 0' }}>
|
||||
<span style={{ width: 28, height: 28, borderRadius: 4, border: '1px solid #3f3f46', background: hex }} />
|
||||
<code style={{ flex: 1, fontSize: 13 }}>{hex}</code>
|
||||
<button type="button" style={{ ...modalBtnStyle, opacity: done ? 0.6 : 1 }} disabled={done} onClick={() => add(hex)} data-testid={`palette-add-${hex}`}>
|
||||
{done ? 'Added' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -243,8 +243,8 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
|
||||
<ColorPickerField label="Period Text Color" value={nodeProps.subColor || '#64748b'} onChange={(v) => setPropContinuous('subColor', v)} />
|
||||
<ColorPickerField label="Feature Text Color" value={nodeProps.featColor || '#4b5563'} onChange={(v) => setPropContinuous('featColor', v)} />
|
||||
<ColorPickerField label="Checkmark Color" value={nodeProps.checkColor || '#10b981'} onChange={(v) => setPropContinuous('checkColor', v)} />
|
||||
<ColorPickerField label="Button Background" value={nodeProps.btnBg || nodeProps.featuredBg || tokenRef('primary')} onChange={(v) => setPropContinuous('btnBg', v)} />
|
||||
<ColorPickerField label="Button Text Color" value={nodeProps.btnColor || '#ffffff'} onChange={(v) => setPropContinuous('btnColor', v)} />
|
||||
<ColorPickerField label="Button Background" value={nodeProps.btnBg || nodeProps.featuredBg || tokenRef('primary')} onChange={(v) => setPropContinuous('btnBg', v)} contrastWith={nodeProps.btnColor || '#ffffff'} contrastRole="bg" contrastLabel="Button text on its colour" />
|
||||
<ColorPickerField label="Button Text Color" value={nodeProps.btnColor || '#ffffff'} onChange={(v) => setPropContinuous('btnColor', v)} contrastWith={nodeProps.btnBg || nodeProps.featuredBg || tokenRef('primary')} contrastLabel="Button text on its colour" />
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Box model: margin, padding, border, shadow, opacity */}
|
||||
|
||||
@@ -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<StylePanelProps> = ({ selectedId, nodeProp
|
||||
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
|
||||
|
||||
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<StylePanelProps> = ({ 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
|
||||
@@ -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(
|
||||
<SiteDesignProvider>
|
||||
<DesignProbe out={ctx} />
|
||||
<TokenSwatchRow value={value} onSelect={onSelect} />
|
||||
</SiteDesignProvider>,
|
||||
);
|
||||
act(() => { ctx.current!.updateDesign({ swatches: saved }); });
|
||||
};
|
||||
const savedBtns = () => Array.from(container!.querySelectorAll<HTMLButtonElement>('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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<TokenSwatchRowProps> = ({
|
||||
}) => {
|
||||
const { design } = useSiteDesign();
|
||||
const current = value?.trim();
|
||||
const saved: SavedSwatch[] = Array.isArray(design.swatches) ? design.swatches : [];
|
||||
const currentHex = current ? normalizeHex(current) : null;
|
||||
return (
|
||||
<div className="token-swatch-row" role="group" aria-label={label}>
|
||||
<div className="token-swatch-label">{label}</div>
|
||||
@@ -46,6 +56,26 @@ export const TokenSwatchRow: React.FC<TokenSwatchRowProps> = ({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{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 (
|
||||
<button
|
||||
key={sw.id}
|
||||
type="button"
|
||||
className={`token-swatch token-swatch--saved${live ? ' token-swatch--live' : ''}${pressed ? ' active' : ''}`}
|
||||
style={{ background: sw.hex }}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-pressed={pressed}
|
||||
data-swatch={sw.id}
|
||||
data-live={live ? 'true' : undefined}
|
||||
onClick={() => onSelect(stored)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<typeof import('../../../utils/color-pick')>();
|
||||
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(<SiteDesignProvider><Probe />{ui}</SiteDesignProvider>); });
|
||||
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<HTMLInputElement>('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(<ColorPickerField label="C" value="#000000" onChange={onChange} />);
|
||||
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(<ColorSwatchGrid colors={[]} activeValue="#000000" onSelect={onSelect} />);
|
||||
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(<ColorPickerField label="C" value="var(--wsb-brand-2)" onChange={() => {}} />, [BRAND]);
|
||||
expect(chip()!.textContent).toBe('Logo blue');
|
||||
expect(hexBox().value).toBe('#15202c');
|
||||
});
|
||||
test('unlabelled brand colour -> Brand N', () => {
|
||||
render(<ColorPickerField label="C" value="var(--wsb-brand-2)" onChange={() => {}} />, [{ ...BRAND, label: '' }]);
|
||||
expect(chip()!.textContent).toBe('Brand 2');
|
||||
});
|
||||
test('gradient custom builder parses a brand ref', () => {
|
||||
render(<GradientSwatchGrid activeValue="linear-gradient(135deg, var(--wsb-brand-2) 0%, #764ba2 100%)" onSelect={() => {}} />, [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<HTMLInputElement>('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(<ColorPickerField label="C" value="#abcdef" onChange={() => {}} />);
|
||||
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(<ColorPickerField label="C" value="var(--wsb-primary)" onChange={() => {}} />);
|
||||
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(<ColorPickerField label="C" value="#000000" onChange={() => {}} />);
|
||||
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(<ColorPickerField label="C" value="#000000" onChange={() => {}} />);
|
||||
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(<ColorPickerField label="C" value="#000000" onChange={onChange} />);
|
||||
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(<ColorPickerField label="C" value="#000000" onChange={onChange} />);
|
||||
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(<ColorPickerField label="C" value="#000000" onChange={onChange} showTokens={false} />);
|
||||
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(<ColorPickerField label="C" value="#000000" onChange={() => {}} />);
|
||||
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(<ColorPickerField label="C" value="#000000" onChange={onChange} />);
|
||||
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(<ColorPickerField label="C" value="#eeeeee" onChange={() => {}} contrastWith="#ffffff" />);
|
||||
expect(container!.querySelector('.contrast-note--fail')).not.toBeNull();
|
||||
});
|
||||
test('passing pair and gradient background show nothing / unchecked only', () => {
|
||||
render(<ColorPickerField label="C" value="#000000" onChange={() => {}} contrastWith="#ffffff" />);
|
||||
expect(container!.querySelector('.contrast-note')).toBeNull();
|
||||
});
|
||||
test('the suggestion applies through onChange', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ColorPickerField label="C" value="#eeeeee" onChange={onChange} contrastWith="#ffffff" />);
|
||||
act(() => { (container!.querySelector('.contrast-note__use') as HTMLButtonElement).click(); });
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange.mock.calls[0][0]).toMatch(/^#[0-9a-f]{6}$/);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>, nodes: string[] = []) => ({
|
||||
type: { resolvedName: name }, isCanvas: name === 'Container', props, displayName: name, custom: {}, hidden: false, nodes, linkedNodes: {}, parent,
|
||||
});
|
||||
const state = (rootStyle: Record<string, string>) => 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(<SiteDesignProvider>{ui}</SiteDesignProvider>);
|
||||
}
|
||||
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' }), <TextStylePanel selectedId="t1" nodeProps={{ text: 'hi', style: { color: '#ffffff' } }} />);
|
||||
expect(allNotes()).toEqual([]);
|
||||
h.unmount();
|
||||
mount(state({ backgroundColor: '#ffffff' }), <TextStylePanel selectedId="t1" nodeProps={{ text: 'hi', style: { color: '#eeeeee' } }} />);
|
||||
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({}), <TextStylePanel selectedId="t1" nodeProps={{ text: 'hi', style: { color: '#eeeeee' } }} />);
|
||||
expect(failNotes().length).toBe(1);
|
||||
});
|
||||
test('a gradient background is reported as not checked, never judged', () => {
|
||||
mount(state({ background: 'linear-gradient(90deg,#000,#fff)' }), <TextStylePanel selectedId="t1" nodeProps={{ text: 'hi', style: { color: '#777777' } }} />);
|
||||
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({}), <TextStylePanel selectedId="t1" nodeProps={{ text: 'hi', style: { color: '#949494', fontSize: '32px' } }} />);
|
||||
expect(failNotes()).toEqual([]);
|
||||
h.unmount();
|
||||
mount(state({}), <TextStylePanel selectedId="t1" nodeProps={{ text: 'hi', style: { color: '#949494', fontSize: '16px' } }} />);
|
||||
expect(failNotes().length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Button text vs button colour', () => {
|
||||
test('flags a failing pair on the Button Color control', () => {
|
||||
mount(state({}), <ButtonStylePanel selectedId="t1" nodeProps={{ text: 'Go', style: { backgroundColor: '#ffff00', color: '#ffffff' } }} />);
|
||||
expect(failNotes().some((t) => t.includes('Button text on its colour'))).toBe(true);
|
||||
});
|
||||
test('a passing pair is silent', () => {
|
||||
mount(state({}), <ButtonStylePanel selectedId="t1" nodeProps={{ text: 'Go', style: { backgroundColor: '#1d4ed8', color: '#ffffff' } }} />);
|
||||
expect(failNotes()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pricing button text vs background', () => {
|
||||
test('flags btnColor on btnBg', () => {
|
||||
mount(state({}), <PricingStylePanel selectedId="t1" nodeProps={{ btnBg: '#ffee00', btnColor: '#ffffff', plans: [] }} />);
|
||||
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({}), <NavStylePanel selectedId="t1" nodeProps={{ backgroundColor: '#ffffff', textColor: '#eeeeee', links: [] }} />);
|
||||
expect(failNotes().some((t) => t.includes('Text on background'))).toBe(true);
|
||||
});
|
||||
test('Menu link colours (no known background) get no note', () => {
|
||||
mount(state({}), <NavStylePanel selectedId="t1" nodeProps={{ linkColor: '#fafafa', links: [] }} />);
|
||||
expect(failNotes()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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 ? <span className="token-chip" title={`Linked to site colour: ${label}`}>{label}</span> : null;
|
||||
@@ -69,6 +89,16 @@ export const SectionLabel: React.FC<SectionLabelProps> = ({ children }) => (
|
||||
<label className="guided-section-label">{children}</label>
|
||||
);
|
||||
|
||||
/** 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 ? <div role="alert" className="color-hex-error">Enter a hex colour like #3b82f6 (3, 4, 6 or 8 digits).</div> : 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<ColorSwatchGridProps> = ({ colors, activeValue, onSelect, onContinuous, showTokens = true }) => {
|
||||
export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ 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 (
|
||||
<div>
|
||||
{showTokens && <TokenSwatchRow value={activeValue} onSelect={onSelect} />}
|
||||
@@ -104,6 +139,17 @@ export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, active
|
||||
/>
|
||||
<TokenChip label={tokenLabel} />
|
||||
</div>
|
||||
<HexError show={invalid} />
|
||||
<ColorPickTools value={activeValue || ''} onPick={onSelect} allowRefs={showTokens} />
|
||||
{contrastWith && (
|
||||
<ContrastNote
|
||||
fg={contrastRole === 'fg' ? (activeValue || '') : contrastWith}
|
||||
bg={contrastRole === 'fg' ? contrastWith : (activeValue || '')}
|
||||
large={contrastLarge}
|
||||
label={contrastLabel}
|
||||
onUseSuggestion={contrastRole === 'fg' ? onSelect : undefined}
|
||||
/>
|
||||
)}
|
||||
<div className="preset-grid">
|
||||
{colors.map((c) => (
|
||||
<button
|
||||
@@ -168,7 +214,7 @@ interface GradientSwatchGridProps {
|
||||
/* Parse "linear-gradient(135deg, #aaa 0%, #bbb 100%)" into parts */
|
||||
function parseGradient(val: string | undefined): { angle: number; from: string; to: string } {
|
||||
if (!val || val === 'none') return { angle: 135, from: '#667eea', to: '#764ba2' };
|
||||
const m = val.match(/linear-gradient\(\s*(\d+)deg\s*,\s*(#[0-9a-fA-F]{3,8}|var\(--wsb-[a-z-]+\))\s*(?:\d+%?)?\s*,\s*(#[0-9a-fA-F]{3,8}|var\(--wsb-[a-z-]+\))/);
|
||||
const m = val.match(/linear-gradient\(\s*(\d+)deg\s*,\s*(#[0-9a-fA-F]{3,8}|var\(--wsb-[a-z0-9-]+\))\s*(?:\d+%?)?\s*,\s*(#[0-9a-fA-F]{3,8}|var\(--wsb-[a-z0-9-]+\))/);
|
||||
if (m) return { angle: parseInt(m[1]), from: m[2], to: m[3] };
|
||||
return { angle: 135, from: '#667eea', to: '#764ba2' };
|
||||
}
|
||||
@@ -363,10 +409,18 @@ interface ColorPickerFieldProps {
|
||||
/** Show the site-colour (token) row. Default true; Site Design's own
|
||||
* colours pass false (they are the token SOURCE and must store hex). */
|
||||
showTokens?: boolean;
|
||||
/** The colour this one is read against (text on a background, or the reverse). Renders an inline contrast hint. */
|
||||
contrastWith?: string;
|
||||
/** Large text / UI part: the 3:1 threshold instead of 4.5:1. */
|
||||
contrastLarge?: boolean;
|
||||
/** Prefix for the hint ("Link on background"). */
|
||||
contrastLabel?: string;
|
||||
/** Which side of the pair this field is. Default 'fg' (this colour is the text). */
|
||||
contrastRole?: 'fg' | 'bg';
|
||||
}
|
||||
export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value, onChange, onContinuous, showTokens = true }) => {
|
||||
export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ 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 (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>{label}</label>
|
||||
@@ -389,6 +443,17 @@ export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value
|
||||
/>
|
||||
<TokenChip label={tokenLabel} />
|
||||
</div>
|
||||
<HexError show={invalid} />
|
||||
<ColorPickTools value={value} onPick={onChange} allowRefs={showTokens} />
|
||||
{contrastWith && (
|
||||
<ContrastNote
|
||||
fg={contrastRole === 'fg' ? value : contrastWith}
|
||||
bg={contrastRole === 'fg' ? contrastWith : value}
|
||||
large={contrastLarge}
|
||||
label={contrastLabel}
|
||||
onUseSuggestion={contrastRole === 'fg' ? onChange : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<typeof usePages>;
|
||||
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(<PageProvider><Probe /></PageProvider>); });
|
||||
}
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -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<PageContextValue>({
|
||||
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<GlobalLibrary>(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<GlobalLibrary>(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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import type { SavedSwatch } from '../utils/swatches';
|
||||
|
||||
export interface SiteDesign {
|
||||
// Basic
|
||||
@@ -27,6 +28,9 @@ export interface SiteDesign {
|
||||
|
||||
/** Site-wide favicon URL (one per site, not per-page). Empty string = none. */
|
||||
favicon: string;
|
||||
|
||||
/** Site-wide saved colours (labelled hex). Those marked `brand` are published as live `--wsb-brand-N` tokens. */
|
||||
swatches: SavedSwatch[];
|
||||
}
|
||||
|
||||
export interface SiteDesignContextValue {
|
||||
@@ -56,6 +60,7 @@ export const DEFAULT_SITE_DESIGN: SiteDesign = {
|
||||
navStyle: 'light',
|
||||
headCode: '',
|
||||
favicon: '',
|
||||
swatches: [],
|
||||
};
|
||||
|
||||
const SiteDesignContext = createContext<SiteDesignContextValue>({
|
||||
|
||||
@@ -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; }
|
||||
@@ -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: '' });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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> = {}): 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<string, unknown>).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<string, unknown>).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 = `
|
||||
<div class="canvas-device-frame" style="background-color:rgb(255,255,255);color:rgb(17,24,39)">
|
||||
<section id="card" style="background-color:rgb(59,130,246);color:rgb(255,255,255);border:2px solid rgb(1,2,3)">
|
||||
<span id="inner">hi</span>
|
||||
</section>
|
||||
<p id="plain" style="color:rgb(10,20,30)">x</p>
|
||||
</div>
|
||||
<div id="outside">out</div>`;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<PickResult> {
|
||||
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<PickResult> {
|
||||
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<CanvasPickResult> {
|
||||
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<CanvasPickResult>((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;
|
||||
});
|
||||
}
|
||||
@@ -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<typeof d>) => ({ ...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']);
|
||||
});
|
||||
});
|
||||
@@ -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') },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { effectiveBackgroundOf, isLargeText } from './effective-background';
|
||||
|
||||
const n = (parent: string | null, props: Record<string, unknown> = {}) => ({ 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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, any> } }
|
||||
|
||||
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<string, NodeLike>, id: string): string | null {
|
||||
const seen = new Set<string>();
|
||||
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<string, unknown>;
|
||||
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);
|
||||
}
|
||||
@@ -21,12 +21,17 @@ const PINNED_HEAD: Record<string, string> = {
|
||||
E_icons: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Icons</title>\n <meta property=\"og:title\" content=\"Icons\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/icons\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css\" integrity=\"sha384-qrALq7+6jBOZIQsNnT6xGkMDru64qD6uTlDra39xrt2SoXl4pO3FX6Roz/RpR/BS\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\">\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/v4-shims.min.css\" integrity=\"sha384-BOBgwI2GEcZ4l/UA/+WOVnLChe2S4c7UhUCtdmX1m0v7Wdyk0S/tDoJOBci2ywvl\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\">\n <style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}[class^=\"cols_\"]{flex-direction:column!important;align-items:stretch!important}nav[class^=\"nav_\"],nav[class^=\"nav_\"] .navbar-links{flex-wrap:wrap}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>",
|
||||
};
|
||||
|
||||
PINNED_HEAD.E_brand_swatches = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Brand</title>\n <meta property=\"og:title\" content=\"Brand\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px;--wsb-brand-1:#FFcc00;--wsb-brand-3:#0A7}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>";
|
||||
PINNED_HEAD.F_swatches_no_brand = PINNED_HEAD.A_defaults;
|
||||
|
||||
const PINNED_BODY: Record<string, string> = {
|
||||
A_defaults: "<div></div>",
|
||||
B_full: "<div data-animation=\"fade-in\" data-animation-delay=\"0.3\"></div>",
|
||||
C_unknown_font: "<div></div>",
|
||||
D_body_font_and_blank_tokens: "<div style=\"font-family:Montserrat, sans-serif\"></div>",
|
||||
E_icons: "<i class=\"fa fa-star\"></i><i class=\"fa-solid fa-star\"></i>",
|
||||
E_brand_swatches: "<div></div>",
|
||||
F_swatches_no_brand: "<div></div>",
|
||||
};
|
||||
|
||||
|
||||
@@ -83,6 +88,30 @@ const CASES: Record<string, { state: string; opts: ExportOptions }> = {
|
||||
state: root({}),
|
||||
opts: { title: 'Icons', includeFonts: true, canonicalUrl: U + 'icons', fontScanHtml: '<i class="fa fa-star"></i><i class="fa-solid fa-star"></i>' },
|
||||
},
|
||||
// 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('</head>') + '</head>'.length);
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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<SampledImage | SampleFailure> {
|
||||
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<void>((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<ImageData, 'data' | 'width' | 'height'>, 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])}`;
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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<number, number>();
|
||||
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);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, any> | 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<string>();
|
||||
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;
|
||||
|
||||
@@ -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>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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-<slot>`
|
||||
* so editing it later recolours every field that references it.
|
||||
*
|
||||
* The backend mirror is libs/SiteBuilderHead.php `sb_brand_token_decls`; the
|
||||
* validity rules here and there MUST stay identical (pinned by the head parity
|
||||
* tests on both sides).
|
||||
*/
|
||||
export interface SavedSwatch {
|
||||
id: string;
|
||||
/** What the colour is used for ("Logo blue"). May be empty. */
|
||||
label: string;
|
||||
/** Literal hex: #rgb, #rgba, #rrggbb or #rrggbbaa. */
|
||||
hex: string;
|
||||
/** True = published as a live token var(--wsb-brand-<slot>). */
|
||||
brand: boolean;
|
||||
/** Stable brand slot 1..MAX_BRAND_COLORS; only meaningful when `brand`. */
|
||||
slot?: number;
|
||||
}
|
||||
|
||||
export const MAX_BRAND_COLORS = 6;
|
||||
export const MAX_SWATCHES = 40;
|
||||
|
||||
const HEX_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
const BRAND_REF_RE = /^var\(--wsb-brand-([1-6])\)$/;
|
||||
|
||||
/** True for a valid #rgb / #rgba / #rrggbb / #rrggbbaa literal (surrounding space ignored). */
|
||||
export function isValidHex(v: unknown): v is string {
|
||||
return typeof v === 'string' && HEX_RE.test(v.trim());
|
||||
}
|
||||
|
||||
/** User input -> canonical lowercase hex ('#' optional on input), or null when invalid. */
|
||||
export function normalizeHex(input: string): string | null {
|
||||
let v = String(input ?? '').trim();
|
||||
if (v === '') return null;
|
||||
if (v[0] !== '#') v = '#' + v;
|
||||
return HEX_RE.test(v) ? v.toLowerCase() : null;
|
||||
}
|
||||
|
||||
export const brandTokenRef = (slot: number): string => `var(--wsb-brand-${slot})`;
|
||||
|
||||
/** Slot (1..6) of a `var(--wsb-brand-N)` reference, else null. */
|
||||
export function brandSlotOf(v: unknown): number | null {
|
||||
if (typeof v !== 'string') return null;
|
||||
const m = BRAND_REF_RE.exec(v.trim());
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
/** The brand swatches that get published: brand===true, integer slot 1..6, valid hex; first per slot wins; slot order. */
|
||||
export function publishedBrandSwatches(swatches: unknown): Array<{ slot: number; hex: string }> {
|
||||
if (!Array.isArray(swatches)) return [];
|
||||
const bySlot = new Map<number, string>();
|
||||
for (const s of swatches) {
|
||||
if (!s || typeof s !== 'object') continue;
|
||||
const { brand, slot, hex } = s as Record<string, unknown>;
|
||||
if (brand !== true) continue;
|
||||
if (typeof slot !== 'number' || !Number.isInteger(slot) || slot < 1 || slot > MAX_BRAND_COLORS) continue;
|
||||
if (!isValidHex(hex)) continue;
|
||||
if (!bySlot.has(slot)) bySlot.set(slot, hex.trim());
|
||||
}
|
||||
return [...bySlot.entries()].sort((a, b) => a[0] - b[0]).map(([slot, hex]) => ({ slot, hex }));
|
||||
}
|
||||
|
||||
/** The brand slot's current hex, or null when no published brand colour holds that slot. */
|
||||
export function brandHexForSlot(swatches: unknown, slot: number): string | null {
|
||||
return publishedBrandSwatches(swatches).find((b) => b.slot === slot)?.hex ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `;--wsb-brand-1:#...;--wsb-brand-3:#...` (leading `;`, slot order) appended
|
||||
* to the token declaration list, or '' when there are none -- so sites without
|
||||
* brand colours emit exactly the bytes they always did. The hex is already
|
||||
* validated to `#`+hexdigits, so it needs no further escaping.
|
||||
*/
|
||||
export function brandTokenDecls(swatches: unknown): string {
|
||||
return publishedBrandSwatches(swatches).map((b) => `;--wsb-brand-${b.slot}:${b.hex}`).join('');
|
||||
}
|
||||
|
||||
/** The value a field should store when this swatch is picked: the live token for brand colours, the hex otherwise. */
|
||||
export function swatchValue(s: SavedSwatch): string {
|
||||
return s.brand && publishedBrandSwatches([s]).length === 1 ? brandTokenRef(s.slot as number) : s.hex;
|
||||
}
|
||||
|
||||
export function freeBrandSlot(swatches: SavedSwatch[]): number | null {
|
||||
const used = new Set(swatches.filter((s) => s.brand && typeof s.slot === 'number').map((s) => s.slot));
|
||||
for (let i = 1; i <= MAX_BRAND_COLORS; i++) if (!used.has(i)) return i;
|
||||
return null;
|
||||
}
|
||||
|
||||
let idCounter = 0;
|
||||
export function newSwatchId(): string {
|
||||
idCounter += 1;
|
||||
return `sw-${Date.now().toString(36)}-${idCounter.toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** Adds a swatch (hex normalised). Returns the same array when the hex is invalid or the cap is hit. */
|
||||
export function addSwatch(list: SavedSwatch[], hex: string, label = '', brand = false): SavedSwatch[] {
|
||||
const h = normalizeHex(hex);
|
||||
if (!h || list.length >= MAX_SWATCHES) return list;
|
||||
const sw: SavedSwatch = { id: newSwatchId(), label: label.trim().slice(0, 40), hex: h, brand: false };
|
||||
const next = [...list, sw];
|
||||
return brand ? setSwatchBrand(next, sw.id, true) : next;
|
||||
}
|
||||
|
||||
export function updateSwatch(list: SavedSwatch[], id: string, patch: { label?: string; hex?: string }): SavedSwatch[] {
|
||||
return list.map((s) => {
|
||||
if (s.id !== id) return s;
|
||||
const out = { ...s };
|
||||
if (patch.label !== undefined) out.label = patch.label.slice(0, 40);
|
||||
if (patch.hex !== undefined) {
|
||||
const h = normalizeHex(patch.hex);
|
||||
if (h) out.hex = h;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
export function removeSwatch(list: SavedSwatch[], id: string): SavedSwatch[] {
|
||||
return list.filter((s) => s.id !== id);
|
||||
}
|
||||
|
||||
/** Marks/unmarks a swatch as brand. Marking takes the lowest free slot; returns the list unchanged when all 6 are used. */
|
||||
export function setSwatchBrand(list: SavedSwatch[], id: string, brand: boolean): SavedSwatch[] {
|
||||
const target = list.find((s) => s.id === id);
|
||||
if (!target) return list;
|
||||
if (!brand) return list.map((s) => (s.id === id ? { id: s.id, label: s.label, hex: s.hex, brand: false } : s));
|
||||
if (target.brand && typeof target.slot === 'number') return list;
|
||||
const slot = freeBrandSlot(list);
|
||||
if (slot === null) return list;
|
||||
return list.map((s) => (s.id === id ? { ...s, brand: true, slot } : s));
|
||||
}
|
||||
|
||||
/** Resolves a field value to the hex it will paint, for brand refs; null when not a brand ref or unresolved. */
|
||||
export function resolveBrandRef(v: string, swatches: unknown): string | null {
|
||||
const slot = brandSlotOf(v);
|
||||
return slot === null ? null : brandHexForSlot(swatches, slot);
|
||||
}
|
||||
|
||||
/** Matching saved swatch for a raw colour (case-insensitive hex, alpha-aware) - brand ones first. */
|
||||
export function findSwatchByHex(list: SavedSwatch[], hex: string): SavedSwatch | undefined {
|
||||
const h = normalizeHex(hex);
|
||||
if (!h) return undefined;
|
||||
const same = list.filter((s) => s.hex.toLowerCase() === h);
|
||||
return same.find((s) => s.brand) ?? same[0];
|
||||
}
|
||||
|
||||
/** How many times `value` (a ref or hex) is used in a serialized state string. */
|
||||
export function countBrandRefUses(serialized: string, slot: number): number {
|
||||
const needle = brandTokenRef(slot);
|
||||
let n = 0, i = 0;
|
||||
while ((i = serialized.indexOf(needle, i)) !== -1) { n++; i += needle.length; }
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces every `var(--wsb-brand-<slot>)` 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);
|
||||
}
|
||||
@@ -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<string, keyof SiteDesign>(
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user