From faecc76384702341ed02af9ca97caa9e72c8dc5b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:49:53 -0800 Subject: [PATCH 01/19] Site colours: saved swatches + live brand-colour tokens in design model design.swatches [{id,label,hex,brand,slot}]; brand ones are published as --wsb-brand-N appended inside :root (canvas and export share buildTokenVarDecls). Sites without brand colours emit identical bytes. Co-Authored-By: Claude Sonnet 5.5 --- craft/src/state/SiteDesignContext.tsx | 5 + craft/src/utils/swatches.test.ts | 113 +++++++++++++++++++ craft/src/utils/swatches.ts | 154 ++++++++++++++++++++++++++ craft/src/utils/tokens.ts | 7 +- 4 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 craft/src/utils/swatches.test.ts create mode 100644 craft/src/utils/swatches.ts diff --git a/craft/src/state/SiteDesignContext.tsx b/craft/src/state/SiteDesignContext.tsx index 9aa501a..35a4f46 100644 --- a/craft/src/state/SiteDesignContext.tsx +++ b/craft/src/state/SiteDesignContext.tsx @@ -1,4 +1,5 @@ import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +import type { SavedSwatch } from '../utils/swatches'; export interface SiteDesign { // Basic @@ -27,6 +28,9 @@ export interface SiteDesign { /** Site-wide favicon URL (one per site, not per-page). Empty string = none. */ favicon: string; + + /** Site-wide saved colours (labelled hex). Those marked `brand` are published as live `--wsb-brand-N` tokens. */ + swatches: SavedSwatch[]; } export interface SiteDesignContextValue { @@ -56,6 +60,7 @@ export const DEFAULT_SITE_DESIGN: SiteDesign = { navStyle: 'light', headCode: '', favicon: '', + swatches: [], }; const SiteDesignContext = createContext({ diff --git a/craft/src/utils/swatches.test.ts b/craft/src/utils/swatches.test.ts new file mode 100644 index 0000000..260e671 --- /dev/null +++ b/craft/src/utils/swatches.test.ts @@ -0,0 +1,113 @@ +import { describe, test, expect } from 'vitest'; +import { + isValidHex, normalizeHex, brandSlotOf, publishedBrandSwatches, brandTokenDecls, swatchValue, + addSwatch, setSwatchBrand, updateSwatch, removeSwatch, resolveBrandRef, findSwatchByHex, MAX_BRAND_COLORS, + type SavedSwatch, +} from './swatches'; +import { resolveTokenValue, buildCanvasTokenCss } from './tokens'; +import { buildTokenCss } from './html-export'; +import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext'; + +const sw = (o: Partial): SavedSwatch => ({ id: 'x', label: '', hex: '#112233', brand: false, ...o }); + +describe('hex validation', () => { + test('accepts the 4 widths, rejects the rest', () => { + for (const h of ['#abc', '#abcd', '#aabbcc', '#aabbccdd', '#ABCDEF']) expect(isValidHex(h)).toBe(true); + for (const h of ['abc', '#ab', '#abcde', '#gggggg', '', 'red', '#aabbccddee', 42, undefined]) expect(isValidHex(h as any)).toBe(false); + }); + test('normalizeHex lowercases and adds #', () => { + expect(normalizeHex(' 0F172A ')).toBe('#0f172a'); + expect(normalizeHex('#ABC')).toBe('#abc'); + expect(normalizeHex('zzz')).toBeNull(); + expect(normalizeHex('')).toBeNull(); + }); +}); + +describe('brand token publishing rules (mirror of PHP sb_brand_token_decls)', () => { + test('orders by slot, skips invalid, first slot wins', () => { + const list: any[] = [ + sw({ id: 'a', brand: true, slot: 3, hex: '#00ff00' }), + sw({ id: 'b', brand: true, slot: 1, hex: '#ff0000' }), + sw({ id: 'c', brand: false, slot: 2, hex: '#0000ff' }), + sw({ id: 'd', brand: true, slot: 4, hex: 'nope' }), + sw({ id: 'e', brand: true, slot: 7, hex: '#123456' }), + sw({ id: 'f', brand: true, slot: 1.5, hex: '#123456' }), + sw({ id: 'g', brand: true, slot: 3, hex: '#999999' }), + sw({ id: 'h', brand: 'yes' as any, slot: 5, hex: '#123456' }), + null, 'str', + ]; + expect(brandTokenDecls(list)).toBe(';--wsb-brand-1:#ff0000;--wsb-brand-3:#00ff00'); + }); + test('nothing for missing/empty/non-array', () => { + expect(brandTokenDecls(undefined)).toBe(''); + expect(brandTokenDecls([])).toBe(''); + expect(brandTokenDecls({})).toBe(''); + }); +}); + +describe('site without brand colours renders byte-identical', () => { + test('export and canvas css unchanged by an empty / non-brand swatch list', () => { + const base = buildTokenCss(DEFAULT_SITE_DESIGN); + expect(buildTokenCss({ ...DEFAULT_SITE_DESIGN, swatches: [sw({ brand: false })] })).toBe(base); + expect(buildTokenCss({ ...DEFAULT_SITE_DESIGN, swatches: undefined as any })).toBe(base); + expect(base).not.toContain('brand'); + const canvas = buildCanvasTokenCss(DEFAULT_SITE_DESIGN); + expect(buildCanvasTokenCss({ ...DEFAULT_SITE_DESIGN, swatches: [sw({ brand: false })] })).toBe(canvas); + }); + test('brand colours land inside :root after button-radius in both emitters', () => { + const d = { ...DEFAULT_SITE_DESIGN, swatches: [sw({ brand: true, slot: 2, hex: '#15202c' })] }; + expect(buildTokenCss(d)).toContain('--wsb-button-radius:8px;--wsb-brand-2:#15202c}'); + expect(buildCanvasTokenCss(d)).toContain('--wsb-button-radius:8px;--wsb-brand-2:#15202c}'); + }); +}); + +describe('refs and resolution', () => { + test('brandSlotOf', () => { + expect(brandSlotOf('var(--wsb-brand-2)')).toBe(2); + expect(brandSlotOf(' var(--wsb-brand-6) ')).toBe(6); + expect(brandSlotOf('var(--wsb-brand-7)')).toBeNull(); + expect(brandSlotOf('var(--wsb-primary)')).toBeNull(); + }); + test('resolveTokenValue resolves brand refs from design.swatches', () => { + const d = { ...DEFAULT_SITE_DESIGN, swatches: [sw({ brand: true, slot: 2, hex: '#15202c' })] }; + expect(resolveTokenValue('var(--wsb-brand-2)', d)).toBe('#15202c'); + expect(resolveBrandRef('var(--wsb-brand-2)', d.swatches)).toBe('#15202c'); + expect(resolveTokenValue('var(--wsb-brand-3)', d)).toBe('var(--wsb-brand-3)'); // unresolved stays as-is + }); + test('swatchValue: token for live brand, hex otherwise', () => { + expect(swatchValue(sw({ brand: true, slot: 4 }))).toBe('var(--wsb-brand-4)'); + expect(swatchValue(sw({ brand: true }))).toBe('#112233'); // no slot -> not published -> literal + expect(swatchValue(sw({}))).toBe('#112233'); + }); +}); + +describe('list operations', () => { + test('add / label / hex edit / remove', () => { + let l: SavedSwatch[] = []; + l = addSwatch(l, 'AABBCC', ' Logo blue '); + expect(l).toHaveLength(1); + expect(l[0]).toMatchObject({ hex: '#aabbcc', label: 'Logo blue', brand: false }); + expect(addSwatch(l, 'bogus')).toBe(l); + l = updateSwatch(l, l[0].id, { label: 'Header', hex: '#0f172a' }); + expect(l[0]).toMatchObject({ label: 'Header', hex: '#0f172a' }); + expect(updateSwatch(l, l[0].id, { hex: 'zzzz' })[0].hex).toBe('#0f172a'); + expect(removeSwatch(l, l[0].id)).toEqual([]); + }); + test('brand toggle takes lowest free slot, stable, capped at 6', () => { + let l: SavedSwatch[] = []; + for (let i = 0; i < 7; i++) l = addSwatch(l, '#00000' + i, `c${i}`, true); + expect(l.filter((s) => s.brand).map((s) => s.slot)).toEqual([1, 2, 3, 4, 5, 6]); + expect(l[6].brand).toBe(false); + expect(MAX_BRAND_COLORS).toBe(6); + l = setSwatchBrand(l, l[1].id, false); + expect(l[1].slot).toBeUndefined(); + l = setSwatchBrand(l, l[6].id, true); + expect(l[6].slot).toBe(2); + expect(l[0].slot).toBe(1); // untouched slots never shift + }); + test('findSwatchByHex is case-insensitive and prefers brand', () => { + const l = [sw({ id: '1', hex: '#aabbcc' }), sw({ id: '2', hex: '#aabbcc', brand: true, slot: 1 })]; + expect(findSwatchByHex(l, '#AABBCC')?.id).toBe('2'); + expect(findSwatchByHex(l, '#000')).toBeUndefined(); + }); +}); diff --git a/craft/src/utils/swatches.ts b/craft/src/utils/swatches.ts new file mode 100644 index 0000000..147adf9 --- /dev/null +++ b/craft/src/utils/swatches.ts @@ -0,0 +1,154 @@ +/** + * Saved colour swatches (the site's own labelled colours) and "brand colours": + * a swatch marked `brand` is published as a live token `--wsb-brand-` + * so editing it later recolours every field that references it. + * + * The backend mirror is libs/SiteBuilderHead.php `sb_brand_token_decls`; the + * validity rules here and there MUST stay identical (pinned by the head parity + * tests on both sides). + */ +export interface SavedSwatch { + id: string; + /** What the colour is used for ("Logo blue"). May be empty. */ + label: string; + /** Literal hex: #rgb, #rgba, #rrggbb or #rrggbbaa. */ + hex: string; + /** True = published as a live token var(--wsb-brand-). */ + brand: boolean; + /** Stable brand slot 1..MAX_BRAND_COLORS; only meaningful when `brand`. */ + slot?: number; +} + +export const MAX_BRAND_COLORS = 6; +export const MAX_SWATCHES = 40; + +const HEX_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; +const BRAND_REF_RE = /^var\(--wsb-brand-([1-6])\)$/; + +/** True for a valid #rgb / #rgba / #rrggbb / #rrggbbaa literal (surrounding space ignored). */ +export function isValidHex(v: unknown): v is string { + return typeof v === 'string' && HEX_RE.test(v.trim()); +} + +/** User input -> canonical lowercase hex ('#' optional on input), or null when invalid. */ +export function normalizeHex(input: string): string | null { + let v = String(input ?? '').trim(); + if (v === '') return null; + if (v[0] !== '#') v = '#' + v; + return HEX_RE.test(v) ? v.toLowerCase() : null; +} + +export const brandTokenRef = (slot: number): string => `var(--wsb-brand-${slot})`; + +/** Slot (1..6) of a `var(--wsb-brand-N)` reference, else null. */ +export function brandSlotOf(v: unknown): number | null { + if (typeof v !== 'string') return null; + const m = BRAND_REF_RE.exec(v.trim()); + return m ? Number(m[1]) : null; +} + +/** The brand swatches that get published: brand===true, integer slot 1..6, valid hex; first per slot wins; slot order. */ +export function publishedBrandSwatches(swatches: unknown): Array<{ slot: number; hex: string }> { + if (!Array.isArray(swatches)) return []; + const bySlot = new Map(); + for (const s of swatches) { + if (!s || typeof s !== 'object') continue; + const { brand, slot, hex } = s as Record; + if (brand !== true) continue; + if (typeof slot !== 'number' || !Number.isInteger(slot) || slot < 1 || slot > MAX_BRAND_COLORS) continue; + if (!isValidHex(hex)) continue; + if (!bySlot.has(slot)) bySlot.set(slot, hex.trim()); + } + return [...bySlot.entries()].sort((a, b) => a[0] - b[0]).map(([slot, hex]) => ({ slot, hex })); +} + +/** The brand slot's current hex, or null when no published brand colour holds that slot. */ +export function brandHexForSlot(swatches: unknown, slot: number): string | null { + return publishedBrandSwatches(swatches).find((b) => b.slot === slot)?.hex ?? null; +} + +/** + * `;--wsb-brand-1:#...;--wsb-brand-3:#...` (leading `;`, slot order) appended + * to the token declaration list, or '' when there are none -- so sites without + * brand colours emit exactly the bytes they always did. The hex is already + * validated to `#`+hexdigits, so it needs no further escaping. + */ +export function brandTokenDecls(swatches: unknown): string { + return publishedBrandSwatches(swatches).map((b) => `;--wsb-brand-${b.slot}:${b.hex}`).join(''); +} + +/** The value a field should store when this swatch is picked: the live token for brand colours, the hex otherwise. */ +export function swatchValue(s: SavedSwatch): string { + return s.brand && publishedBrandSwatches([s]).length === 1 ? brandTokenRef(s.slot as number) : s.hex; +} + +export function freeBrandSlot(swatches: SavedSwatch[]): number | null { + const used = new Set(swatches.filter((s) => s.brand && typeof s.slot === 'number').map((s) => s.slot)); + for (let i = 1; i <= MAX_BRAND_COLORS; i++) if (!used.has(i)) return i; + return null; +} + +let idCounter = 0; +export function newSwatchId(): string { + idCounter += 1; + return `sw-${Date.now().toString(36)}-${idCounter.toString(36)}-${Math.random().toString(36).slice(2, 6)}`; +} + +/** Adds a swatch (hex normalised). Returns the same array when the hex is invalid or the cap is hit. */ +export function addSwatch(list: SavedSwatch[], hex: string, label = '', brand = false): SavedSwatch[] { + const h = normalizeHex(hex); + if (!h || list.length >= MAX_SWATCHES) return list; + const sw: SavedSwatch = { id: newSwatchId(), label: label.trim().slice(0, 40), hex: h, brand: false }; + const next = [...list, sw]; + return brand ? setSwatchBrand(next, sw.id, true) : next; +} + +export function updateSwatch(list: SavedSwatch[], id: string, patch: { label?: string; hex?: string }): SavedSwatch[] { + return list.map((s) => { + if (s.id !== id) return s; + const out = { ...s }; + if (patch.label !== undefined) out.label = patch.label.slice(0, 40); + if (patch.hex !== undefined) { + const h = normalizeHex(patch.hex); + if (h) out.hex = h; + } + return out; + }); +} + +export function removeSwatch(list: SavedSwatch[], id: string): SavedSwatch[] { + return list.filter((s) => s.id !== id); +} + +/** Marks/unmarks a swatch as brand. Marking takes the lowest free slot; returns the list unchanged when all 6 are used. */ +export function setSwatchBrand(list: SavedSwatch[], id: string, brand: boolean): SavedSwatch[] { + const target = list.find((s) => s.id === id); + if (!target) return list; + if (!brand) return list.map((s) => (s.id === id ? { id: s.id, label: s.label, hex: s.hex, brand: false } : s)); + if (target.brand && typeof target.slot === 'number') return list; + const slot = freeBrandSlot(list); + if (slot === null) return list; + return list.map((s) => (s.id === id ? { ...s, brand: true, slot } : s)); +} + +/** Resolves a field value to the hex it will paint, for brand refs; null when not a brand ref or unresolved. */ +export function resolveBrandRef(v: string, swatches: unknown): string | null { + const slot = brandSlotOf(v); + return slot === null ? null : brandHexForSlot(swatches, slot); +} + +/** Matching saved swatch for a raw colour (case-insensitive hex, alpha-aware) - brand ones first. */ +export function findSwatchByHex(list: SavedSwatch[], hex: string): SavedSwatch | undefined { + const h = normalizeHex(hex); + if (!h) return undefined; + const same = list.filter((s) => s.hex.toLowerCase() === h); + return same.find((s) => s.brand) ?? same[0]; +} + +/** How many times `value` (a ref or hex) is used in a serialized state string. */ +export function countBrandRefUses(serialized: string, slot: number): number { + const needle = brandTokenRef(slot); + let n = 0, i = 0; + while ((i = serialized.indexOf(needle, i)) !== -1) { n++; i += needle.length; } + return n; +} diff --git a/craft/src/utils/tokens.ts b/craft/src/utils/tokens.ts index 3ba315c..4947d67 100644 --- a/craft/src/utils/tokens.ts +++ b/craft/src/utils/tokens.ts @@ -1,5 +1,6 @@ import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext'; import { cssValue } from './escape'; +import { brandTokenDecls, resolveBrandRef } from './swatches'; /** * Design-token vocabulary. A token reference (`var(--wsb-primary)`) is a @@ -90,6 +91,8 @@ export function tokenKeyOf(v: unknown): TokenKey | null { /** Resolves a token reference to the design's concrete value; anything else is returned unchanged. */ export function resolveTokenValue(v: string, design: SiteDesign): string { + const brand = resolveBrandRef(v, design.swatches); + if (brand !== null) return brand; const key = tokenKeyOf(v); if (!key) return v; return design[DESIGN_KEY_BY_TOKEN.get(key)!] as string; @@ -120,7 +123,7 @@ export function buildCanvasTokenCss(design: SiteDesign): string { } /** - * The 16 `--wsb-*` declarations (`;`-joined), shared by the canvas and the + * The 16 `--wsb-*` declarations (`;`-joined) plus any published brand colours (`;--wsb-brand-N:#hex`, see swatches.ts), shared by the canvas and the * export `:root` block. A blank/whitespace token falls back to its default: an * empty custom property would invalidate every var() that reads it (mirrors the * backend sb_token_css). Values are sanitised through `cssValue()`. @@ -129,5 +132,5 @@ export function buildTokenVarDecls(design: SiteDesign): string { return TOKEN_VARS.map(([varName, key]) => { const raw = String(design[key] ?? '').trim(); return `${varName}:${cssValue(raw !== '' ? raw : (DEFAULT_SITE_DESIGN[key] as string))}`; - }).join(';'); + }).join(';') + brandTokenDecls(design.swatches); } From c388be7fe7cf77fa06eeca40b06b699a95542829 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:50:25 -0800 Subject: [PATCH 02/19] Head parity: pin brand-colour swatch cases (E, F) from the WHP script Co-Authored-By: Claude Sonnet 5.5 --- .../src/utils/html-export.head-parity.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/craft/src/utils/html-export.head-parity.test.ts b/craft/src/utils/html-export.head-parity.test.ts index 1e2ec4a..f41faa2 100644 --- a/craft/src/utils/html-export.head-parity.test.ts +++ b/craft/src/utils/html-export.head-parity.test.ts @@ -20,11 +20,16 @@ const PINNED_HEAD: Record = { D_body_font_and_blank_tokens: "\n\n\n \n \n Services\n \n \n \n \n \n \n \n", }; +PINNED_HEAD.E_brand_swatches = "\n\n\n \n \n Brand\n \n \n \n \n \n \n \n"; +PINNED_HEAD.F_swatches_no_brand = PINNED_HEAD.A_defaults; + const PINNED_BODY: Record = { A_defaults: "
", B_full: "
", C_unknown_font: "
", D_body_font_and_blank_tokens: "
", + E_brand_swatches: "
", + F_swatches_no_brand: "
", }; @@ -77,6 +82,30 @@ const CASES: Record = { design: merged({ accentColor: '', textColor: ' #111111 ' }), }, }, + // Brand colours (design.swatches): same inputs as the WHP script's E / F cases. + E_brand_swatches: { + state: root({}), + opts: { + title: 'Brand', includeFonts: true, canonicalUrl: U, + design: merged({ swatches: [ + { id: 'a', label: 'Sea', hex: '#0A7', brand: true, slot: 3 }, + { id: 'b', label: 'Sun', hex: '#FFcc00', brand: true, slot: 1 }, + { id: 'c', label: 'Grey', hex: '#888888', brand: false }, + { id: 'd', label: 'Bad', hex: 'red', brand: true, slot: 2 }, + ] }), + }, + }, + F_swatches_no_brand: { + state: root({}), + opts: { + title: "Joe's Pizza & Grill", includeFonts: true, canonicalUrl: U, + design: merged({ swatches: [ + { id: 'c', label: 'Grey', hex: '#888888', brand: false }, + { id: 'd', label: 'Bad', hex: 'red', brand: true, slot: 2 }, + { id: 'e', label: 'Far', hex: '#123456', brand: true, slot: 7 }, + ] }), + }, + }, }; const headOf = (doc: string) => doc.slice(0, doc.indexOf('') + ''.length); From 1190a9826506922a98aa13f1edf0eaf97f022dcf Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:51:32 -0800 Subject: [PATCH 03/19] tokens: isTokenRef accepts brand token names (digits) Co-Authored-By: Claude Sonnet 5.5 --- craft/src/utils/swatches.test.ts | 8 ++++++++ craft/src/utils/tokens.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/craft/src/utils/swatches.test.ts b/craft/src/utils/swatches.test.ts index 260e671..9b8d2aa 100644 --- a/craft/src/utils/swatches.test.ts +++ b/craft/src/utils/swatches.test.ts @@ -111,3 +111,11 @@ describe('list operations', () => { 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(); + }); +}); diff --git a/craft/src/utils/tokens.ts b/craft/src/utils/tokens.ts index 4947d67..2e17474 100644 --- a/craft/src/utils/tokens.ts +++ b/craft/src/utils/tokens.ts @@ -64,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( From e09a87d2a61ddb7effbdf007046174f08f667d62 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:51:47 -0800 Subject: [PATCH 04/19] Site colours: palette extraction + same-origin image sampling utils Co-Authored-By: Claude Sonnet 5.5 --- craft/src/utils/image-sample.test.ts | 86 ++++++++++++++++++++ craft/src/utils/image-sample.ts | 104 ++++++++++++++++++++++++ craft/src/utils/palette-extract.test.ts | 63 ++++++++++++++ craft/src/utils/palette-extract.ts | 99 ++++++++++++++++++++++ 4 files changed, 352 insertions(+) create mode 100644 craft/src/utils/image-sample.test.ts create mode 100644 craft/src/utils/image-sample.ts create mode 100644 craft/src/utils/palette-extract.test.ts create mode 100644 craft/src/utils/palette-extract.ts diff --git a/craft/src/utils/image-sample.test.ts b/craft/src/utils/image-sample.test.ts new file mode 100644 index 0000000..cb854df --- /dev/null +++ b/craft/src/utils/image-sample.test.ts @@ -0,0 +1,86 @@ +import { describe, test, expect } from 'vitest'; +import { loadImageForSampling, sampleAt, isSameOrigin, CROSS_ORIGIN_MESSAGE } from './image-sample'; + +const ORIGIN = 'http://site.test'; + +function fakeImg(w: number, h: number, fail = false) { + const img: any = { naturalWidth: w, naturalHeight: h, width: w, height: h, onload: null, onerror: null }; + Object.defineProperty(img, 'src', { set() { setTimeout(() => (fail ? img.onerror?.() : img.onload?.()), 0); } }); + return img as HTMLImageElement; +} +function fakeCanvas(opts: { throwOnRead?: string; noCtx?: boolean } = {}) { + const log: any = {}; + const c: any = { + width: 0, height: 0, + getContext: () => opts.noCtx ? null : ({ + drawImage: (...a: unknown[]) => { log.draw = a; }, + getImageData: (_x: number, _y: number, w: number, h: number) => { + if (opts.throwOnRead) { const e: any = new Error('tainted'); e.name = opts.throwOnRead; throw e; } + return { width: w, height: h, data: new Uint8ClampedArray(w * h * 4) }; + }, + }), + }; + return { c: c as HTMLCanvasElement, log }; +} + +describe('isSameOrigin', () => { + test('relative, absolute same, other origin, data', () => { + expect(isSameOrigin('/a.png', ORIGIN)).toBe(true); + expect(isSameOrigin('a.png', ORIGIN)).toBe(true); + expect(isSameOrigin('http://site.test/x/a.png', ORIGIN)).toBe(true); + expect(isSameOrigin('http://site.test:81/a.png', ORIGIN)).toBe(false); + expect(isSameOrigin('https://cdn.example.com/a.png', ORIGIN)).toBe(false); + expect(isSameOrigin('//cdn.example.com/a.png', ORIGIN)).toBe(false); + expect(isSameOrigin('data:image/png;base64,AAAA', ORIGIN)).toBe(true); + }); +}); + +describe('sampleAt', () => { + const data = new Uint8ClampedArray([255, 0, 16, 255, 1, 2, 3, 0]); + const id = { data, width: 2, height: 1 }; + test('hex of pixel', () => expect(sampleAt(id, 0, 0)).toBe('#ff0010')); + test('transparent -> empty', () => expect(sampleAt(id, 1, 0)).toBe('')); + test('out of bounds -> empty', () => { expect(sampleAt(id, 2, 0)).toBe(''); expect(sampleAt(id, -1, 0)).toBe(''); }); +}); + +describe('loadImageForSampling', () => { + test('cross-origin refused without loading', async () => { + let created = false; + const r = await loadImageForSampling('https://other.test/a.png', { origin: ORIGIN, createImage: () => { created = true; return fakeImg(1, 1); } }); + expect(r).toEqual({ ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE }); + expect(created).toBe(false); + }); + test('downscales longest side to 512', async () => { + const { c } = fakeCanvas(); + const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(2048, 1024), createCanvas: () => c }); + expect(r.ok && [r.width, r.height]).toEqual([512, 256]); + }); + test('small image keeps its size; draw sizes target canvas', async () => { + const { c } = fakeCanvas(); + const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(40, 30), createCanvas: () => c }); + expect(r.ok).toBe(true); + if (r.ok) { + const t = fakeCanvas().c; + r.draw(t); + expect([t.width, t.height]).toEqual([40, 30]); + } + }); + test('SecurityError -> cross-origin', async () => { + const { c } = fakeCanvas({ throwOnRead: 'SecurityError' }); + const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(4, 4), createCanvas: () => c }); + expect(r).toMatchObject({ ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE }); + }); + test('other read error -> error', async () => { + const { c } = fakeCanvas({ throwOnRead: 'Weird' }); + const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(4, 4), createCanvas: () => c }); + expect(r).toMatchObject({ ok: false, reason: 'error' }); + }); + test('load error -> error', async () => { + const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(4, 4, true), createCanvas: () => fakeCanvas().c }); + expect(r).toMatchObject({ ok: false, reason: 'error' }); + }); + test('no 2d context -> error', async () => { + const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(4, 4), createCanvas: () => fakeCanvas({ noCtx: true }).c }); + expect(r).toMatchObject({ ok: false, reason: 'error' }); + }); +}); diff --git a/craft/src/utils/image-sample.ts b/craft/src/utils/image-sample.ts new file mode 100644 index 0000000..e10505b --- /dev/null +++ b/craft/src/utils/image-sample.ts @@ -0,0 +1,104 @@ +/* Read pixel colours out of an image via canvas. Same-origin only: a cross-origin + image would taint the canvas, so we refuse up front with a plain-English message + instead of trying. */ + +export type PickFailReason = 'cancelled' | 'unsupported' | 'cross-origin' | 'error'; +export type PickResult = + | { ok: true; hex: string; source: 'canvas' | 'image' | 'eyedropper' | 'tab-capture' } + | { ok: false; reason: PickFailReason; message: string }; + +export const CROSS_ORIGIN_MESSAGE = + 'That image comes from another website, so the browser will not let the builder read its colours. Upload it to this site first, then pick from the uploaded copy.'; + +const MAX_SIDE = 512; + +export type SampledImage = { + ok: true; + width: number; + height: number; + imageData: ImageData; + /** Paint the (downscaled) image onto a canvas, resizing the canvas to match. */ + draw(canvas: HTMLCanvasElement): void; +}; +export type SampleFailure = { ok: false; reason: 'cross-origin' | 'error'; message: string }; + +/** Injection points so the logic is testable where jsdom has no canvas. */ +export interface SamplingDeps { + origin?: string; + createImage?: () => HTMLImageElement; + createCanvas?: () => HTMLCanvasElement; +} + +function resolve(src: string, origin: string): URL | null { + try { return new URL(src, origin + '/'); } catch { return null; } +} + +/** True when `src` (resolved against the page) shares the page's origin. data:/blob: count as same-origin. */ +export function isSameOrigin(src: string, origin: string = location.origin): boolean { + if (/^(data|blob):/i.test(src.trim())) return true; + const u = resolve(src.trim(), origin); + return !!u && u.origin === origin; +} + +function isSecurityError(e: unknown): boolean { + return !!e && typeof e === 'object' && (e as { name?: string }).name === 'SecurityError'; +} + +export async function loadImageForSampling(src: string, deps: SamplingDeps = {}): Promise { + const origin = deps.origin ?? location.origin; + if (!isSameOrigin(src, origin)) { + return { ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE }; + } + const img = deps.createImage ? deps.createImage() : new Image(); + try { + await new Promise((res, rej) => { + img.onload = () => res(); + img.onerror = () => rej(new Error('load')); + img.src = src; + }); + } catch { + return { ok: false, reason: 'error', message: 'That image could not be loaded.' }; + } + const nw = img.naturalWidth || img.width; + const nh = img.naturalHeight || img.height; + if (!nw || !nh) return { ok: false, reason: 'error', message: 'That image has no size, so its colours cannot be read.' }; + const scale = Math.min(1, MAX_SIDE / Math.max(nw, nh)); + const width = Math.max(1, Math.round(nw * scale)); + const height = Math.max(1, Math.round(nh * scale)); + const canvas = deps.createCanvas ? deps.createCanvas() : document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d', { willReadFrequently: true } as CanvasRenderingContext2DSettings); + if (!ctx) return { ok: false, reason: 'error', message: 'This browser cannot read image colours.' }; + let imageData: ImageData; + try { + ctx.drawImage(img, 0, 0, width, height); + imageData = ctx.getImageData(0, 0, width, height); + } catch (e) { + if (isSecurityError(e)) return { ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE }; + return { ok: false, reason: 'error', message: 'The image colours could not be read.' }; + } + return { + ok: true, + width, + height, + imageData, + draw(target) { + target.width = width; + target.height = height; + target.getContext('2d')?.drawImage(canvas, 0, 0); + }, + }; +} + +const hex2 = (n: number) => n.toString(16).padStart(2, '0'); + +/** Lowercase #rrggbb of the pixel at (x, y); '' when out of bounds or transparent (alpha < 128). */ +export function sampleAt(imageData: Pick, x: number, y: number): string { + const px = Math.floor(x), py = Math.floor(y); + if (px < 0 || py < 0 || px >= imageData.width || py >= imageData.height) return ''; + const i = (py * imageData.width + px) * 4; + const d = imageData.data; + if (d[i + 3] < 128) return ''; + return `#${hex2(d[i])}${hex2(d[i + 1])}${hex2(d[i + 2])}`; +} diff --git a/craft/src/utils/palette-extract.test.ts b/craft/src/utils/palette-extract.test.ts new file mode 100644 index 0000000..5f57247 --- /dev/null +++ b/craft/src/utils/palette-extract.test.ts @@ -0,0 +1,63 @@ +import { describe, test, expect } from 'vitest'; +import { extractPalette } from './palette-extract'; + +type RGBA = [number, number, number, number]; +function buf(runs: Array<[RGBA, number]>): Uint8ClampedArray { + const total = runs.reduce((n, [, c]) => n + c, 0); + const out = new Uint8ClampedArray(total * 4); + let i = 0; + for (const [px, c] of runs) for (let k = 0; k < c; k++) { out.set(px, i); i += 4; } + return out; +} + +describe('extractPalette', () => { + test('three flat bands -> those colours in population order', () => { + const p = extractPalette(buf([[[0, 0, 255, 255], 100], [[255, 0, 0, 255], 500], [[0, 200, 0, 255], 300]])); + expect(p).toEqual(['#ff0000', '#00c800', '#0000ff']); + }); + test('single colour -> one entry, never fabricates', () => { + expect(extractPalette(buf([[[10, 20, 30, 255], 50]]))).toEqual(['#0a141e']); + }); + test('two colours -> two entries', () => { + expect(extractPalette(buf([[[255, 255, 255, 255], 50], [[0, 0, 0, 255], 20]], ))).toEqual(['#ffffff', '#000000']); + }); + test('near-duplicates are merged, keeping the more populous', () => { + const p = extractPalette(buf([[[200, 30, 30, 255], 400], [[203, 32, 29, 255], 100], [[20, 20, 200, 255], 200]])); + expect(p).toHaveLength(2); + expect(p[0]).toMatch(/^#c[89a-f]/); + expect(p[1]).toBe('#1414c8'); + }); + test('all transparent -> []', () => { + expect(extractPalette(buf([[[255, 0, 0, 0], 100]]))).toEqual([]); + expect(extractPalette([])).toEqual([]); + }); + test('transparent pixels (alpha<128) are ignored', () => { + expect(extractPalette(buf([[[255, 0, 0, 10], 1000], [[0, 128, 0, 255], 5]]))).toEqual(['#008000']); + }); + test('gradient -> at most count distinct valid hexes', () => { + const px: number[] = []; + for (let i = 0; i < 256; i++) px.push(i, 255 - i, 128, 255); + const p = extractPalette(px, { count: 6 }); + expect(p.length).toBeGreaterThan(1); + expect(p.length).toBeLessThanOrEqual(6); + expect(new Set(p).size).toBe(p.length); + p.forEach((h) => expect(h).toMatch(/^#[0-9a-f]{6}$/)); + }); + test('deterministic', () => { + const px = new Uint8ClampedArray(4 * 5000); + for (let i = 0; i < 5000; i++) { px[i * 4] = (i * 37) % 256; px[i * 4 + 1] = (i * 91) % 256; px[i * 4 + 2] = (i * 13) % 256; px[i * 4 + 3] = 255; } + expect(extractPalette(px)).toEqual(extractPalette(px)); + }); + test('count is clamped to 5..8 (default 6)', () => { + const px = new Uint8ClampedArray(4 * 4000); + for (let i = 0; i < 4000; i++) { px[i * 4] = (i * 53) % 256; px[i * 4 + 1] = (i * 97) % 256; px[i * 4 + 2] = (i * 29) % 256; px[i * 4 + 3] = 255; } + expect(extractPalette(px).length).toBeLessThanOrEqual(6); + expect(extractPalette(px, { count: 100 }).length).toBeLessThanOrEqual(8); + expect(extractPalette(px, { count: 100 }).length).toBeGreaterThan(6); + expect(extractPalette(px, { count: 1 }).length).toBeLessThanOrEqual(5); + expect(extractPalette(px, { count: 1 }).length).toBeGreaterThanOrEqual(5); + }); + test('ignoreTransparent:false keeps low-alpha pixels', () => { + expect(extractPalette(buf([[[255, 0, 0, 10], 10]]), { ignoreTransparent: false })).toEqual(['#ff0000']); + }); +}); diff --git a/craft/src/utils/palette-extract.ts b/craft/src/utils/palette-extract.ts new file mode 100644 index 0000000..b2e6028 --- /dev/null +++ b/craft/src/utils/palette-extract.ts @@ -0,0 +1,99 @@ +/* Dominant-colour extraction (median cut). Pure and DOM-free; deterministic. */ + +export interface ExtractPaletteOptions { + /** Wanted colours, clamped to 5..8. Default 6. */ + count?: number; + /** Skip pixels with alpha < 128. Default true. */ + ignoreTransparent?: boolean; +} + +const MAX_SAMPLES = 40000; +const MIN_DELTA_E = 12; + +const hex2 = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'); + +function toLab(r: number, g: number, b: number): [number, number, number] { + const lin = (c: number) => { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; + const R = lin(r), G = lin(g), B = lin(b); + const f = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116); + const x = f((0.4124 * R + 0.3576 * G + 0.1805 * B) / 0.95047); + const y = f(0.2126 * R + 0.7152 * G + 0.0722 * B); + const z = f((0.0193 * R + 0.1192 * G + 0.9505 * B) / 1.08883); + return [116 * y - 16, 500 * (x - y), 200 * (y - z)]; +} + +interface Entry { rgb: number; n: number } +interface Box { e: Entry[]; pop: number } + +function channel(rgb: number, ch: number): number { return (rgb >> (16 - ch * 8)) & 255; } + +function range(box: Box): { ch: number; span: number } { + const lo = [255, 255, 255], hi = [0, 0, 0]; + for (const { rgb } of box.e) for (let c = 0; c < 3; c++) { + const v = channel(rgb, c); + if (v < lo[c]) lo[c] = v; + if (v > hi[c]) hi[c] = v; + } + let ch = 0, span = -1; + for (let c = 0; c < 3; c++) if (hi[c] - lo[c] > span) { span = hi[c] - lo[c]; ch = c; } + return { ch, span }; +} + +function split(box: Box): [Box, Box] { + const { ch } = range(box); + const sorted = [...box.e].sort((a, b) => channel(a.rgb, ch) - channel(b.rgb, ch) || a.rgb - b.rgb); + let acc = 0; + let cut = 1; + for (let i = 0; i < sorted.length - 1; i++) { + acc += sorted[i].n; + cut = i + 1; + if (acc * 2 >= box.pop) break; + } + const a = sorted.slice(0, cut), b = sorted.slice(cut); + const sum = (x: Entry[]) => x.reduce((s, y) => s + y.n, 0); + return [{ e: a, pop: sum(a) }, { e: b, pop: sum(b) }]; +} + +/** Dominant colours as lowercase #rrggbb, most dominant first. Returns fewer than + * `count` for flat/transparent images; never invents colours. */ +export function extractPalette(rgba: Uint8ClampedArray | number[], opts: ExtractPaletteOptions = {}): string[] { + const want = Math.max(5, Math.min(8, Math.round(opts.count ?? 6))); + const ignoreT = opts.ignoreTransparent !== false; + const pixels = Math.floor(rgba.length / 4); + const step = Math.max(1, Math.ceil(pixels / MAX_SAMPLES)); + const hist = new Map(); + for (let p = 0; p < pixels; p += step) { + const i = p * 4; + if (ignoreT && rgba[i + 3] < 128) continue; + const key = ((rgba[i] & 255) << 16) | ((rgba[i + 1] & 255) << 8) | (rgba[i + 2] & 255); + hist.set(key, (hist.get(key) || 0) + 1); + } + if (hist.size === 0) return []; + const entries: Entry[] = [...hist].map(([rgb, n]) => ({ rgb, n })).sort((a, b) => a.rgb - b.rgb); + let boxes: Box[] = [{ e: entries, pop: entries.reduce((s, x) => s + x.n, 0) }]; + const target = want * 2; + while (boxes.length < target) { + let best = -1, bestScore = -1; + boxes.forEach((b, i) => { + if (b.e.length < 2) return; + const score = b.pop * (range(b).span > 0 ? 1 : 0); + if (score > bestScore) { bestScore = score; best = i; } + }); + if (best < 0 || bestScore <= 0) break; + const [a, b] = split(boxes[best]); + boxes.splice(best, 1, a, b); + } + const cols = boxes.map((b) => { + let r = 0, g = 0, bl = 0; + for (const { rgb, n } of b.e) { r += channel(rgb, 0) * n; g += channel(rgb, 1) * n; bl += channel(rgb, 2) * n; } + const R = Math.round(r / b.pop), G = Math.round(g / b.pop), B = Math.round(bl / b.pop); + return { hex: `#${hex2(R)}${hex2(G)}${hex2(B)}`, pop: b.pop, lab: toLab(R, G, B) }; + }).sort((a, b) => b.pop - a.pop || (a.hex < b.hex ? -1 : 1)); + const kept: typeof cols = []; + for (const c of cols) { + const dup = kept.some((k) => Math.hypot(k.lab[0] - c.lab[0], k.lab[1] - c.lab[1], k.lab[2] - c.lab[2]) < MIN_DELTA_E); + if (!dup) kept.push(c); + if (kept.length >= want) break; + } + return kept.map((k) => k.hex); +} From 39e954bd770a8c8f392d1e9ca909c3fd5db09352 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:51:57 -0800 Subject: [PATCH 05/19] Contrast: pure WCAG checkContrast/suggestFix/siteColorPairs Co-Authored-By: Claude Sonnet 5.5 --- craft/src/utils/contrast.test.ts | 62 ++++++++++++ craft/src/utils/contrast.ts | 159 +++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 craft/src/utils/contrast.test.ts create mode 100644 craft/src/utils/contrast.ts diff --git a/craft/src/utils/contrast.test.ts b/craft/src/utils/contrast.test.ts new file mode 100644 index 0000000..e067034 --- /dev/null +++ b/craft/src/utils/contrast.test.ts @@ -0,0 +1,62 @@ +import { describe, test, expect } from 'vitest'; +import { checkContrast, suggestFix, siteColorPairs } from './contrast'; +import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext'; + +const d = DEFAULT_SITE_DESIGN; +const design = (o: Partial) => ({ ...d, ...o }); + +describe('checkContrast', () => { + test('black on white passes AAA 21:1', () => { + expect(checkContrast('#000', '#fff', d)).toEqual({ status: 'pass', ratio: 21, required: 4.5, level: 'AAA' }); + }); + test('light grey on white fails', () => { + const v = checkContrast('#aaaaaa', '#ffffff', d); + expect(v).toMatchObject({ status: 'fail', required: 4.5, level: 'fail' }); + expect(v.ratio).toBe(2.32); + }); + test('large text uses 3:1', () => { + expect(checkContrast('#888888', '#ffffff', d).status).toBe('fail'); // 3.54 + expect(checkContrast('#888888', '#ffffff', d, { large: true })).toMatchObject({ status: 'pass', required: 3, level: 'AA-large' }); + }); + test('resolves tokens and brand tokens', () => { + const dd = design({ textColor: '#ffffff', backgroundColor: '#ffffff' }); + expect(checkContrast('var(--wsb-text)', 'var(--wsb-bg)', dd).status).toBe('fail'); + const b = design({ swatches: [{ id: 's1', label: 'Brand', hex: '#000000', brand: true, slot: 1 } as any] }); + expect(checkContrast('var(--wsb-brand-1)', '#ffffff', b)).toMatchObject({ status: 'pass', ratio: 21 }); + }); + test('rgb() and semi-transparent hex composite over bg', () => { + expect(checkContrast('rgb(0, 0, 0)', 'rgb(255,255,255)', d).ratio).toBe(21); + // ~50% black over white (unrounded 127.5 grey) -> 4.0 + expect(checkContrast('#00000080', '#ffffff', d).ratio).toBe(4); + }); + test('unchecked reasons', () => { + expect(checkContrast('#000', 'linear-gradient(red, blue)', d)).toMatchObject({ status: 'unchecked', reason: 'gradient' }); + expect(checkContrast('#000', 'url(a.png)', d)).toMatchObject({ status: 'unchecked', reason: 'image' }); + expect(checkContrast('#000', 'transparent', d).reason).toBe('transparent'); + expect(checkContrast('#000', '#ffffff80', d).reason).toBe('transparent'); + expect(checkContrast('var(--nope)', '#fff', d).reason).toBe('unresolved colour'); + }); +}); + +describe('suggestFix', () => { + test('keeps hue and passes', () => { + const s = suggestFix('#93c5fd', '#ffffff', d)!; + expect(s.ratio).toBeGreaterThanOrEqual(4.5); + expect(checkContrast(s.hex, '#ffffff', d).status).toBe('pass'); + expect(s.hex).toMatch(/^#[0-9a-f]{6}$/); + }); + test('lightens on dark backgrounds', () => { + const s = suggestFix('#333333', '#111111', d)!; + expect(checkContrast(s.hex, '#111111', d).status).toBe('pass'); + }); + test('null when passing or unchecked', () => { + expect(suggestFix('#000', '#fff', d)).toBeNull(); + expect(suggestFix('#000', 'linear-gradient(red,blue)', d)).toBeNull(); + }); +}); + +describe('siteColorPairs', () => { + test('lists text, muted, link and primary button pairs', () => { + expect(siteColorPairs(d).map((p) => p.id)).toEqual(['text-bg', 'muted-bg', 'link-bg', 'button-primary']); + }); +}); diff --git a/craft/src/utils/contrast.ts b/craft/src/utils/contrast.ts new file mode 100644 index 0000000..1dde6b5 --- /dev/null +++ b/craft/src/utils/contrast.ts @@ -0,0 +1,159 @@ +import type { SiteDesign } from '../state/SiteDesignContext'; +import { resolveTokenValue, tokenRef } from './tokens'; +import type { RGB } from './color'; + +/* ---------- WCAG 2.x contrast checking (pure) ---------- + Only flat, fully-known colour pairs are judged. Gradients, images, + transparent/unknown backgrounds and unresolved tokens come back as + 'unchecked' with a reason -- never as a guess. */ + +export type ContrastVerdict = { + status: 'pass' | 'fail' | 'unchecked'; + /** Contrast ratio, 2 decimals. Absent when unchecked. */ + ratio?: number; + required: 4.5 | 3; + /** For unchecked: 'gradient' | 'image' | 'transparent' | 'unresolved colour'. */ + reason?: string; + level: 'AAA' | 'AA' | 'AA-large' | 'fail'; +}; + +type RGBA = { rgb: RGB; a: number }; + +function parseRgba(input: string): RGBA | null { + const v = input.trim().toLowerCase(); + let m = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(v); + if (m) { + let h = m[1]; + if (h.length <= 4) h = h.split('').map((c) => c + c).join(''); + const a = h.length === 8 ? parseInt(h.slice(6), 16) / 255 : 1; + return { rgb: [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)], a }; + } + m = /^rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)\s*(?:[,/]\s*([\d.]+%?)\s*)?\)$/.exec(v); + if (m) { + let a = 1; + if (m[4] !== undefined) a = m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]); + if (Number.isNaN(a)) return null; + return { rgb: [Math.min(255, +m[1]), Math.min(255, +m[2]), Math.min(255, +m[3])], a: Math.min(1, a) }; + } + return null; +} + +function lum([r, g, b]: RGB): number { + const f = (c: number) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }; + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); +} + +const ratioOf = (a: RGB, b: RGB) => { + const la = lum(a), lb = lum(b); + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); +}; + +const hex2 = (n: number) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, '0'); +const toHex = ([r, g, b]: RGB) => `#${hex2(r)}${hex2(g)}${hex2(b)}`; +const round2 = (n: number) => Math.round(n * 100) / 100; + +type Resolved = { ok: true; c: RGBA } | { ok: false; reason: string }; + +function resolveColour(value: string, design: SiteDesign): Resolved { + const v = resolveTokenValue(String(value ?? ''), design).trim(); + if (/gradient/i.test(v)) return { ok: false, reason: 'gradient' }; + if (/url\(/i.test(v)) return { ok: false, reason: 'image' }; + if (v.toLowerCase() === 'transparent') return { ok: false, reason: 'transparent' }; + const c = parseRgba(v); + if (!c) return { ok: false, reason: 'unresolved colour' }; + if (c.a <= 0) return { ok: false, reason: 'transparent' }; + return { ok: true, c }; +} + +/** Resolves both sides to opaque RGB (fg composited over bg), or the reason it cannot. */ +function resolvePair(fg: string, bg: string, design: SiteDesign): { fg: RGB; bg: RGB } | { reason: string } { + const b = resolveColour(bg, design); + if (!b.ok) return { reason: b.reason }; + if (b.c.a < 1) return { reason: 'transparent' }; // translucent over an unknown layer + const f = resolveColour(fg, design); + if (!f.ok) return { reason: f.reason }; + const a = f.c.a; + const comp = f.c.rgb.map((c, i) => c * a + b.c.rgb[i] * (1 - a)) as RGB; + return { fg: comp, bg: b.c.rgb }; +} + +export function checkContrast(fg: string, bg: string, design: SiteDesign, opts?: { large?: boolean }): ContrastVerdict { + const required: 4.5 | 3 = opts?.large ? 3 : 4.5; + const p = resolvePair(fg, bg, design); + if ('reason' in p) return { status: 'unchecked', required, reason: p.reason, level: 'fail' }; + const raw = ratioOf(p.fg, p.bg); + const level: ContrastVerdict['level'] = raw >= 7 ? 'AAA' : raw >= 4.5 ? 'AA' : raw >= 3 ? 'AA-large' : 'fail'; + return { status: raw >= required ? 'pass' : 'fail', ratio: round2(raw), required, level }; +} + +/* ---- HSL lightness walk ---- */ +function rgbToHsl([r, g, b]: RGB): [number, number, number] { + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + const l = (max + min) / 2; + if (max === min) return [0, 0, l]; + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4; + return [h / 6, s, l]; +} +function hslToRgb([h, s, l]: [number, number, number]): RGB { + if (s === 0) return [l * 255, l * 255, l * 255]; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + const f = (t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255]; +} + +/** + * A readable replacement for `fg` on `bg`: the same hue with lightness walked + * toward the better-contrasting extreme, falling back to black/white. Null when + * the pair is unchecked, already passes, or nothing reaches the threshold. + */ +export function suggestFix(fg: string, bg: string, design: SiteDesign, opts?: { large?: boolean }): { hex: string; ratio: number } | null { + const required = opts?.large ? 3 : 4.5; + const p = resolvePair(fg, bg, design); + if ('reason' in p) return null; + if (ratioOf(p.fg, p.bg) >= required) return null; + const [h, s, l] = rgbToHsl(p.fg); + const darker = lum(p.bg) > 0.179; // light-ish surface -> darken the text + for (let i = 1; i <= 100; i++) { + const nl = darker ? l - i / 100 : l + i / 100; + if (nl < 0 || nl > 1) break; + const hex = toHex(hslToRgb([h, s, nl])); + const rgb = parseRgba(hex)!.rgb; + const r = ratioOf(rgb, p.bg); + if (r >= required) return { hex, ratio: round2(r) }; + } + for (const hex of ['#000000', '#ffffff']) { + const r = ratioOf(parseRgba(hex)!.rgb, p.bg); + if (r >= required) return { hex, ratio: round2(r) }; + } + return null; +} + +export interface SiteColorPair { id: string; label: string; fg: string; bg: string; large?: boolean } + +/** + * Site-level colour pairs that are always seen together. The primary button + * pair mirrors ButtonLink's craft defaults (text `var(--wsb-bg)` on + * `var(--wsb-primary)`, 16px/600 -> normal text). + */ +export function siteColorPairs(_design: SiteDesign): SiteColorPair[] { + return [ + { id: 'text-bg', label: 'Text on background', fg: tokenRef('text'), bg: tokenRef('bg') }, + { id: 'muted-bg', label: 'Muted text on background', fg: tokenRef('muted'), bg: tokenRef('bg') }, + { id: 'link-bg', label: 'Link on background', fg: tokenRef('link'), bg: tokenRef('bg') }, + { id: 'button-primary', label: 'Button text on primary', fg: tokenRef('bg'), bg: tokenRef('primary') }, + ]; +} From fb9ac0e6c494e8d6a03498547119d8efa8c8b4e7 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:52:40 -0800 Subject: [PATCH 06/19] Site colours: real-browser check for image sampling + palette (Firefox + Chromium) Co-Authored-By: Claude Sonnet 5.5 --- craft/scripts/e2e-image-sample.mjs | 123 +++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 craft/scripts/e2e-image-sample.mjs diff --git a/craft/scripts/e2e-image-sample.mjs b/craft/scripts/e2e-image-sample.mjs new file mode 100644 index 0000000..c239cdb --- /dev/null +++ b/craft/scripts/e2e-image-sample.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * Real-browser check (headless Firefox AND Chromium) for the colour picker's pixel reading: + * src/utils/image-sample.ts + palette-extract.ts. jsdom has no canvas, so this bundles them with + * esbuild, serves a generated PNG from one origin (the page's own) and from a second port + * (cross-origin), and asserts exact pixel colours, the extracted palette, and the cross-origin + * failure message. + * + * node scripts/e2e-image-sample.mjs (CHROMIUM_PATH / FIREFOX_PATH override binaries) + */ +import { chromium, firefox } from '@playwright/test'; +import { build as esbuild } from 'esbuild'; +import http from 'node:http'; +import zlib from 'node:zlib'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const W = 1024, H = 512; +const RED = [200, 30, 30], BLUE = [20, 20, 200], GREEN = [0, 160, 0]; + +// ---- tiny PNG encoder (RGBA, filter 0) +function crc32(buf) { + let c, crc = ~0; + for (let n = 0; n < buf.length; n++) { c = (crc ^ buf[n]) & 255; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; crc = (crc >>> 8) ^ c; } + return ~crc >>> 0; +} +function chunk(type, data) { + const len = Buffer.alloc(4); len.writeUInt32BE(data.length); + const td = Buffer.concat([Buffer.from(type), data]); + const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td)); + return Buffer.concat([len, td, crc]); +} +function png() { + const raw = Buffer.alloc((W * 4 + 1) * H); + for (let y = 0; y < H; y++) { + raw[y * (W * 4 + 1)] = 0; + for (let x = 0; x < W; x++) { + const c = x < W / 2 ? RED : y < H / 2 ? BLUE : GREEN; + raw.set([...c, 255], y * (W * 4 + 1) + 1 + x * 4); + } + } + const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(W, 0); ihdr.writeUInt32BE(H, 4); ihdr[8] = 8; ihdr[9] = 6; + return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]); +} +const PNG = png(); + +// ---- bundle the units under test +const bundle = await esbuild({ + stdin: { + contents: "export * from './src/utils/image-sample'; export * from './src/utils/palette-extract';", + resolveDir: root, loader: 'ts', + }, + bundle: true, write: false, format: 'iife', globalName: 'T', platform: 'browser', logLevel: 'error', +}); +const BUNDLE = bundle.outputFiles[0].text; + +// ---- servers: page + same-origin image on A; image only on B (cross-origin) +const serve = (withPage) => http.createServer((req, res) => { + if (withPage && req.url === '/page.html') { res.setHeader('content-type', 'text/html'); return res.end('t'); } + if (withPage && req.url === '/bundle.js') { res.setHeader('content-type', 'text/javascript'); return res.end(BUNDLE); } + if (req.url.startsWith('/img.png')) { res.setHeader('content-type', 'image/png'); return res.end(PNG); } + res.statusCode = 404; res.end(); +}); +const A = serve(true), B = serve(false); +await Promise.all([new Promise((r) => A.listen(0, '127.0.0.1', r)), new Promise((r) => B.listen(0, '127.0.0.1', r))]); +const pa = A.address().port, pb = B.address().port; + +let failures = 0; +const check = (name, ok, extra = '') => { console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' ' + extra : ''}`); if (!ok) failures++; }; +const hex = (c) => '#' + c.map((n) => n.toString(16).padStart(2, '0')).join(''); + +const targets = [ + ['chromium', chromium, process.env.CHROMIUM_PATH], + ['firefox', firefox, process.env.FIREFOX_PATH], +].filter(([n]) => !process.env.ONLY || process.env.ONLY === n); + +for (const [name, type, exe] of targets) { + console.log(`--- ${name}`); + const browser = await type.launch({ headless: true, ...(exe ? { executablePath: exe } : {}) }); + const page = await browser.newPage(); + await page.goto(`http://127.0.0.1:${pa}/page.html`); + await page.addScriptTag({ url: `/bundle.js` }); + const r = await page.evaluate(async ({ pa, pb }) => { + const out = {}; + const same = await T.loadImageForSampling(`/img.png`); + out.sameOk = same.ok; + if (same.ok) { + out.size = [same.width, same.height]; + const d = same.imageData; + out.red = T.sampleAt(d, 10, 10); + out.blue = T.sampleAt(d, 400, 10); + out.green = T.sampleAt(d, 400, 200); + out.palette = T.extractPalette(d.data); + const c = document.createElement('canvas'); + same.draw(c); + out.drawn = [c.width, c.height]; + } else out.sameMsg = same.message; + const abs = await T.loadImageForSampling(`http://127.0.0.1:${pa}/img.png`); + out.absSameOk = abs.ok; + const cross = await T.loadImageForSampling(`http://127.0.0.1:${pb}/img.png`); + out.cross = cross; + const bad = await T.loadImageForSampling(`/missing.png`); + out.bad = bad; + return out; + }, { pa, pb }); + check('same-origin image loads', r.sameOk, r.sameMsg || ''); + check('downscaled to 512x256', JSON.stringify(r.size) === '[512,256]', JSON.stringify(r.size)); + check('draw() sizes canvas', JSON.stringify(r.drawn) === '[512,256]'); + check('pixel red exact', r.red === hex(RED), r.red); + check('pixel blue exact', r.blue === hex(BLUE), r.blue); + check('pixel green exact', r.green === hex(GREEN), r.green); + check('palette = red, blue, green in population order (red 50%, blue/green 25%)', + r.palette[0] === hex(RED) && r.palette.length === 3 && r.palette.includes(hex(BLUE)) && r.palette.includes(hex(GREEN)), JSON.stringify(r.palette)); + check('absolute same-origin URL loads', r.absSameOk); + check('cross-origin refused', r.cross.ok === false && r.cross.reason === 'cross-origin'); + check('cross-origin message exact', r.cross.message === 'That image comes from another website, so the browser will not let the builder read its colours. Upload it to this site first, then pick from the uploaded copy.'); + check('missing image -> error', r.bad.ok === false && r.bad.reason === 'error', r.bad.message); + await browser.close(); +} +A.close(); B.close(); +console.log(failures ? `\n${failures} FAILED` : '\nall passed'); +process.exit(failures ? 1 : 0); From 453268a31c2abaf9e9ea93370bbcc811790cca29 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:52:44 -0800 Subject: [PATCH 07/19] Publish checks: button + site-level contrast via checkContrast, unchecked info line Co-Authored-By: Claude Sonnet 5.5 --- craft/src/utils/publish-checks.test.ts | 54 +++++++++++++- craft/src/utils/publish-checks.ts | 97 ++++++++++++++++++++++---- 2 files changed, 134 insertions(+), 17 deletions(-) diff --git a/craft/src/utils/publish-checks.test.ts b/craft/src/utils/publish-checks.test.ts index 15a6eb6..2c84534 100644 --- a/craft/src/utils/publish-checks.test.ts +++ b/craft/src/utils/publish-checks.test.ts @@ -133,7 +133,8 @@ describe('runPublishChecks', () => { test('token colours resolve through the design (text token on bg token)', () => { const design = { ...DEFAULT_SITE_DESIGN, textColor: '#ffffff', backgroundColor: '#ffffff' }; const cs = runPublishChecks({ pages: [page({ nodes: sec('var(--wsb-bg)', text('var(--wsb-text)')) })], design }); - expect(ids(cs)).toEqual(['contrast:p1:t']); + // (the same white-on-white design also trips the site-level text/background pair) + expect(ids(cs)).toEqual(['contrast:p1:t', 'contrast-site:text-bg']); const ok = runPublishChecks({ pages: [page({ nodes: sec('var(--wsb-bg)', text('var(--wsb-text)')) })], design: { ...design, textColor: '#111111' } }); expect(ok).toEqual([]); }); @@ -151,8 +152,11 @@ describe('runPublishChecks', () => { }); test('unknown / gradient / image backgrounds and unparseable colours are skipped', () => { - expect(run([page({ nodes: sec('linear-gradient(#fff,#fff)', text('#ffffff')) })])).toEqual([]); - expect(run([page({ nodes: sec('#ffffff', text('#ffffff'), { bgImage: 'a.png' }) })])).toEqual([]); + // gradient / image are never judged; they surface only as one info line + const gr = run([page({ nodes: sec('linear-gradient(#fff,#fff)', text('#ffffff')) })]); + expect(gr.map((c) => [c.id, c.severity])).toEqual([['contrast-unchecked:p1', 'info']]); + expect(gr[0].message).toContain('1 element(s)'); + expect(ids(run([page({ nodes: sec('#ffffff', text('#ffffff'), { bgImage: 'a.png' }) })]))).toEqual(['contrast-unchecked:p1']); expect(run([page({ nodes: sec('#ffffff', text('inherit')) })])).toEqual([]); expect(run([page({ nodes: { t: text('#ffffff'), h: h1 } })])).toEqual([]); }); @@ -165,4 +169,48 @@ describe('runPublishChecks', () => { expect(cs).toHaveLength(MAX_CHECKS); expect(new Set(ids(cs)).size).toBe(MAX_CHECKS); }); + + describe('contrast (colour system)', () => { + const btn = (color: string, bg: string, extra: any = {}): N => ({ type: 'ButtonLink', props: { text: 'Go', href: '/x', style: { color, backgroundColor: bg, ...extra } } }); + + test('button text vs button background: fail warns, pass does not', () => { + expect(ids(run([page({ nodes: { h: h1, b: btn('#ffffff', '#fde047') } })]))).toEqual(['contrast:p1:b']); + expect(run([page({ nodes: { h: h1, b: btn('#ffffff', '#111111') } })])).toEqual([]); + }); + + test('button with tokens and brand tokens resolves', () => { + const design = { ...DEFAULT_SITE_DESIGN, swatches: [{ id: 's', label: 'B', hex: '#fde047', brand: true, slot: 1 } as any] }; + const cs = runPublishChecks({ pages: [page({ nodes: { h: h1, b: btn('#ffffff', 'var(--wsb-brand-1)') } })], design }); + expect(ids(cs)).toEqual(['contrast:p1:b']); + }); + + test('bold 18.66px+ button counts as large text (3:1)', () => { + // #8a8a8a on white ~3.4:1 + expect(ids(run([page({ nodes: { h: h1, b: btn('#8a8a8a', '#ffffff', { fontSize: '16px', fontWeight: '600' }) } })]))).toEqual(['contrast:p1:b']); + expect(run([page({ nodes: { h: h1, b: btn('#8a8a8a', '#ffffff', { fontSize: '19px', fontWeight: '700' }) } })])).toEqual([]); + }); + + test('identical failing button pairs on a page warn once', () => { + const cs = run([page({ nodes: { h: h1, b1: btn('#ffffff', '#fde047'), b2: btn('#ffffff', '#fde047') } })]); + expect(cs).toHaveLength(1); + }); + + test('button with a gradient background is counted as not checked', () => { + const cs = run([page({ nodes: { h: h1, b: btn('#ffffff', 'linear-gradient(red, blue)') } })]); + expect(cs.map((c) => [c.id, c.severity])).toEqual([['contrast-unchecked:p1', 'info']]); + }); + + test('site text vs background: low contrast warns with site-design fix and no page', () => { + const design = { ...DEFAULT_SITE_DESIGN, textColor: '#cccccc', mutedTextColor: '#dddddd' }; + const cs = runPublishChecks({ pages: [page({ nodes: { h: h1 } })], design }); + expect(cs.map((c) => c.id)).toEqual(['contrast-site:text-bg', 'contrast-site:muted-bg']); + expect(cs[0]).toMatchObject({ severity: 'warn', fix: 'site-design' }); + expect(cs[0].pageId).toBeUndefined(); + }); + + test('site pair with a gradient or unresolved colour is not judged', () => { + const design = { ...DEFAULT_SITE_DESIGN, backgroundColor: 'linear-gradient(red, blue)', textColor: '#ffffff' }; + expect(runPublishChecks({ pages: [page({ nodes: { h: h1 } })], design })).toEqual([]); + }); + }); }); diff --git a/craft/src/utils/publish-checks.ts b/craft/src/utils/publish-checks.ts index 7b64e97..55f6ecb 100644 --- a/craft/src/utils/publish-checks.ts +++ b/craft/src/utils/publish-checks.ts @@ -2,6 +2,7 @@ import type { SiteDesign } from '../state/SiteDesignContext'; import type { PageSeo } from '../types'; import { resolveTokenValue } from './tokens'; import { parseColor, type RGB } from './color'; +import { checkContrast, siteColorPairs } from './contrast'; import { PLACEHOLDER_SRC } from '../components/media/ImageBlock'; import { normalizeCtas } from '../components/sections/_cta-helpers'; @@ -136,6 +137,36 @@ function effectiveBackground(nodes: SNodes, id: string, design: SiteDesign): RGB return null; } +type BgSource = { css: string } | { reason: 'gradient' | 'image' } | null; + +/** Like effectiveBackground, but reports WHY a background is not a flat colour. */ +function effectiveBgSource(nodes: SNodes, id: string, design: SiteDesign): BgSource { + let cur = nodes[id]?.parent; + while (cur && nodes[cur]) { + const p = nodes[cur].props ?? {}; + if (p.bgType && p.bgType !== 'color' && p.bgType !== 'solid') return { reason: p.bgType === 'image' ? 'image' : 'gradient' }; + if (p.bgImage) return { reason: 'image' }; + const raw = p.style?.backgroundColor || p.bgColor; + if (typeof raw === 'string' && raw.trim()) { + const resolved = resolveTokenValue(raw, design); + if (/gradient/i.test(resolved)) return { reason: 'gradient' }; + if (/url\(/i.test(resolved)) return { reason: 'image' }; + if (parseColor(resolved)) return { css: resolved }; + } + cur = nodes[cur].parent; + } + return null; +} + +/** WCAG "large text": >= 24px, or >= 18.66px and bold. */ +function isLargeText(style: Record | undefined): boolean { + const size = pxSize(style?.fontSize); + if (size === null) return false; + const w = style?.fontWeight; + const bold = w === 'bold' || w === 'bolder' || (Number(w) >= 700); + return size >= 24 || (size >= 18.66 && bold); +} + // ---------- helpers ---------- const isEmptyHref = (h: unknown) => typeof h !== 'string' || h.trim() === '' || h.trim() === '#'; @@ -233,6 +264,8 @@ export function runPublishChecks(input: PublishChecksInput): Check[] { out.push({ id: `h1-multi:${page.id}`, severity: 'warn', message: `${where} has ${h1s} H1 headings (use one)`, pageId: page.id }); } + let uncheckedCount = 0; + const seenButtonPairs = new Set(); for (const id of ids) { const n = nodes[id]; const name = n.type?.resolvedName; @@ -246,29 +279,65 @@ export function runPublishChecks(input: PublishChecksInput): Check[] { pageId: page.id, nodeId: id, fix: 'select', }); } - } else if (name === 'Heading' || name === 'TextBlock') { + } else if (name === 'Heading' || name === 'TextBlock' || name === 'ButtonLink') { + const isBtn = name === 'ButtonLink'; const colorRaw = p.style?.color; if (typeof colorRaw !== 'string' || !colorRaw) continue; - const fg = parseColor(resolveTokenValue(colorRaw, design)); - const bg = fg ? effectiveBackground(nodes, id, design) : null; - if (!fg || !bg) continue; - const size = pxSize(p.style?.fontSize); - const large = size !== null && size >= 24; - const ratio = contrastRatio(fg, bg); - const txt = label(plain(p.text ?? p.html), ''); - if (ratio < (large ? 3 : 4.5)) { - out.push({ - id: `contrast:${page.id}:${id}`, severity: 'warn', - message: `${where}: low-contrast ${name === 'Heading' ? 'heading' : 'text'}${txt ? ' ' + txt : ''} (${ratio.toFixed(1)}:1, needs ${large ? 3 : 4.5}:1)`, - pageId: page.id, nodeId: id, fix: 'select', - }); + let bgCss: string | null; + if (isBtn) { + const raw = p.style?.backgroundColor; + bgCss = typeof raw === 'string' && raw.trim() ? raw : null; + } else { + const src = effectiveBgSource(nodes, id, design); + if (src && 'reason' in src) { uncheckedCount++; continue; } + bgCss = src ? src.css : null; } + if (!bgCss) continue; + const large = isLargeText(p.style); + const v = checkContrast(colorRaw, bgCss, design, { large }); + if (v.status === 'unchecked') { + if (v.reason === 'gradient' || v.reason === 'image') uncheckedCount++; + continue; + } + if (v.status !== 'fail') continue; + const pairKey = `${resolveTokenValue(colorRaw, design).toLowerCase()}|${resolveTokenValue(bgCss, design).toLowerCase()}|${large}`; + if (isBtn) { + if (seenButtonPairs.has(pairKey)) continue; + seenButtonPairs.add(pairKey); + } + const txt = label(plain(p.text ?? p.html), ''); + const kind = isBtn ? 'button' : name === 'Heading' ? 'heading' : 'text'; + out.push({ + id: `contrast:${page.id}:${id}`, severity: 'warn', + message: `${where}: low-contrast ${kind}${txt ? ' ' + txt : ''} (${v.ratio!.toFixed(1)}:1, needs ${v.required}:1)`, + pageId: page.id, nodeId: id, fix: 'select', + }); } } + if (uncheckedCount > 0) { + out.push({ + id: `contrast-unchecked:${page.id}`, severity: 'info', + message: `${where}: contrast not checked for ${uncheckedCount} element(s) with gradient/image backgrounds`, + pageId: page.id, + }); + } + linkChecks(nodes, ids, page.id, where, out); }); + // Site-level pairs that are always seen together (text / muted text on the site background). + for (const pair of siteColorPairs(design)) { + if (pair.id !== 'text-bg' && pair.id !== 'muted-bg') continue; + const v = checkContrast(pair.fg, pair.bg, design, { large: pair.large }); + if (v.status !== 'fail') continue; + out.push({ + id: `contrast-site:${pair.id}`, severity: 'warn', + message: `Site design: ${pair.label.toLowerCase()} is low contrast (${v.ratio!.toFixed(1)}:1, needs ${v.required}:1)`, + fix: 'site-design', + }); + } + for (const zone of zones) { const nodes = parseState(zone.craftState); if (!nodes) continue; From e4ba07b47e7b33215bb40230b363f41d361674c2 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:53:05 -0800 Subject: [PATCH 08/19] ContrastNote: inline low-contrast hint with suggestion Co-Authored-By: Claude Sonnet 5.5 --- .../panels/right/styles/ContrastNote.test.tsx | 59 ++++++++++++++++++ .../src/panels/right/styles/ContrastNote.tsx | 60 +++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 craft/src/panels/right/styles/ContrastNote.test.tsx create mode 100644 craft/src/panels/right/styles/ContrastNote.tsx diff --git a/craft/src/panels/right/styles/ContrastNote.test.tsx b/craft/src/panels/right/styles/ContrastNote.test.tsx new file mode 100644 index 0000000..3ad4f3e --- /dev/null +++ b/craft/src/panels/right/styles/ContrastNote.test.tsx @@ -0,0 +1,59 @@ +import { describe, test, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { ContrastNote } from './ContrastNote'; +import { SiteDesignProvider } from '../../../state/SiteDesignContext'; + +let container: HTMLDivElement | null = null; +let root: Root; +function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { root = createRoot(container!); root.render({ui}); }); +} +afterEach(() => { + if (!container) return; + act(() => { root.unmount(); }); + container.remove(); + container = null; +}); + +describe('ContrastNote', () => { + test('renders nothing when the pair passes', () => { + render(); + expect(container!.innerHTML).toBe(''); + }); + + test('renders nothing for an unresolved colour', () => { + render(); + expect(container!.innerHTML).toBe(''); + }); + + test('fail shows ratio and threshold in a status region', () => { + render(); + const el = container!.querySelector('[role="status"]')!; + expect(el.textContent).toBe('Low contrast 2.32:1 (needs 4.5:1)'); + expect(container!.querySelector('button')).toBeNull(); + }); + + test('large text uses the 3:1 threshold; label prefixes', () => { + render(); + expect(container!.textContent).toBe('Heading: Low contrast 1.61:1 (needs 3:1)'); + }); + + test('suggestion button calls back with a passing hex', () => { + const cb = vi.fn(); + render(); + const btn = container!.querySelector('button')!; + expect(btn.textContent).toMatch(/^Use #[0-9a-f]{6}$/); + act(() => { btn.click(); }); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.calls[0][0]).toMatch(/^#[0-9a-f]{6}$/); + }); + + test('gradient background shows a muted not-checked note', () => { + render(); + expect(container!.querySelector('[role="status"]')!.textContent).toBe('Contrast not checked (gradient)'); + }); +}); diff --git a/craft/src/panels/right/styles/ContrastNote.tsx b/craft/src/panels/right/styles/ContrastNote.tsx new file mode 100644 index 0000000..9df4e6a --- /dev/null +++ b/craft/src/panels/right/styles/ContrastNote.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { useSiteDesign } from '../../../state/SiteDesignContext'; +import { checkContrast, suggestFix } from '../../../utils/contrast'; + +export interface ContrastNoteProps { + fg: string; + bg: string; + large?: boolean; + label?: string; + onUseSuggestion?: (hex: string) => void; +} + +/** + * Compact inline contrast hint. Silent when the pair passes or cannot be + * judged for a reason not worth mentioning; amber (not alarm red) on failure; + * muted "not checked" for gradient / image backgrounds. + */ +export const ContrastNote: React.FC = ({ fg, bg, large, label, onUseSuggestion }) => { + const { design } = useSiteDesign(); + const v = checkContrast(fg, bg, design, { large }); + + if (v.status === 'pass') return null; + + if (v.status === 'unchecked') { + if (v.reason !== 'gradient' && v.reason !== 'image') return null; + return ( +
+ Contrast not checked ({v.reason}) +
+ ); + } + + const fix = onUseSuggestion ? suggestFix(fg, bg, design, { large }) : null; + return ( +
+ + {label ? `${label}: ` : ''}Low contrast {v.ratio!.toFixed(2)}:1 (needs {v.required}:1) + + {fix && ( + + )} +
+ ); +}; From 54920dff0856ed3f2ce5e196f6b627c0bf2d902b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:53:13 -0800 Subject: [PATCH 09/19] Site colours: pick-from-image and palette-from-image modals Co-Authored-By: Claude Sonnet 5.5 --- .../right/styles/ImagePickModal.test.tsx | 88 +++++++++ .../panels/right/styles/ImagePickModal.tsx | 169 ++++++++++++++++++ .../styles/PaletteFromImageModal.test.tsx | 100 +++++++++++ .../right/styles/PaletteFromImageModal.tsx | 78 ++++++++ 4 files changed, 435 insertions(+) create mode 100644 craft/src/panels/right/styles/ImagePickModal.test.tsx create mode 100644 craft/src/panels/right/styles/ImagePickModal.tsx create mode 100644 craft/src/panels/right/styles/PaletteFromImageModal.test.tsx create mode 100644 craft/src/panels/right/styles/PaletteFromImageModal.tsx diff --git a/craft/src/panels/right/styles/ImagePickModal.test.tsx b/craft/src/panels/right/styles/ImagePickModal.test.tsx new file mode 100644 index 0000000..3de1e5c --- /dev/null +++ b/craft/src/panels/right/styles/ImagePickModal.test.tsx @@ -0,0 +1,88 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +vi.mock('../../../utils/assets', () => ({ listAssets: vi.fn(), uploadAsset: vi.fn() })); +vi.mock('../../../utils/image-sample', async () => { + const actual = await vi.importActual('../../../utils/image-sample'); + return { ...actual, loadImageForSampling: vi.fn() }; +}); +import { listAssets } from '../../../utils/assets'; +import { loadImageForSampling, CROSS_ORIGIN_MESSAGE } from '../../../utils/image-sample'; +import { ImagePickModal } from './ImagePickModal'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +const listMock = listAssets as unknown as ReturnType; +const loadMock = loadImageForSampling as unknown as ReturnType; + +// 2x1 image: left red, right transparent +const imageData = { width: 2, height: 1, data: new Uint8ClampedArray([255, 0, 0, 255, 0, 0, 0, 0]) }; +const okImage = () => ({ ok: true, width: 2, height: 1, imageData, draw: vi.fn() }); + +let container: HTMLDivElement; +let root: Root; +async function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + await act(async () => { root = createRoot(container); root.render(ui); }); + await act(async () => {}); +} +afterEach(() => { act(() => root.unmount()); container.remove(); vi.clearAllMocks(); }); +beforeEach(() => { vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); listMock.mockResolvedValue([{ name: 'a.png', url: '/a.png', type: 'image/png' }]); }); +const q = (id: string) => document.querySelector(`[data-testid="${id}"]`) as HTMLElement | null; + +function mouse(el: HTMLElement, type: string, clientX: number) { + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 100, height: 50, right: 100, bottom: 50, x: 0, y: 0, toJSON() {} }) as DOMRect; + act(() => { el.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX, clientY: 10 })); }); +} + +describe('ImagePickModal', () => { + test('lists uploaded assets and loads the chosen one', async () => { + loadMock.mockResolvedValue(okImage()); + await render(); + expect(q('image-asset-a.png')).toBeTruthy(); + expect(loadMock).not.toHaveBeenCalled(); + await act(async () => { q('image-asset-a.png')!.click(); }); + expect(loadMock).toHaveBeenCalledWith('/a.png'); + expect(q('image-pick-canvas')).toBeTruthy(); + }); + + test('initialSrc loads immediately; hover shows hex; click picks and closes', async () => { + loadMock.mockResolvedValue(okImage()); + const onPick = vi.fn(), onClose = vi.fn(); + await render(); + expect(loadMock).toHaveBeenCalledWith('/sel.png'); + const canvas = q('image-pick-canvas')!; + mouse(canvas, 'mousemove', 10); + expect(q('image-pick-hex')!.textContent).toBe('#ff0000'); + mouse(canvas, 'click', 10); + expect(onPick).toHaveBeenCalledWith('#ff0000'); + expect(onClose).toHaveBeenCalled(); + }); + + test('clicking a transparent pixel does not pick', async () => { + loadMock.mockResolvedValue(okImage()); + const onPick = vi.fn(), onClose = vi.fn(); + await render(); + mouse(q('image-pick-canvas')!, 'click', 90); + expect(onPick).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(container.ownerDocument.body.textContent).toContain('transparent'); + }); + + test('loader failure is shown as role=alert', async () => { + loadMock.mockResolvedValue({ ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE }); + await render(); + const alert = document.querySelector('[role="alert"]'); + expect(alert?.textContent).toBe(CROSS_ORIGIN_MESSAGE); + expect(q('image-pick-canvas')).toBeNull(); + }); + + test('Escape closes', async () => { + const onClose = vi.fn(); + await render(); + act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); }); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/craft/src/panels/right/styles/ImagePickModal.tsx b/craft/src/panels/right/styles/ImagePickModal.tsx new file mode 100644 index 0000000..34614ac --- /dev/null +++ b/craft/src/panels/right/styles/ImagePickModal.tsx @@ -0,0 +1,169 @@ +import React, { useEffect, useRef, useState, CSSProperties } from 'react'; +import { Modal } from '../../../ui/Modal'; +import { listAssets, Asset } from '../../../utils/assets'; +import { clickableProps } from '../../../utils/a11y'; +import { loadImageForSampling, sampleAt, SampledImage } from '../../../utils/image-sample'; + +export type SampleState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'ready'; img: SampledImage } + | { status: 'failed'; message: string }; + +/** Load `src` for pixel reading; re-runs when `src` changes, ignoring stale results. */ +export function useSampledImage(src: string | null): SampleState { + const [state, setState] = useState({ status: 'idle' }); + useEffect(() => { + if (!src) { setState({ status: 'idle' }); return; } + let live = true; + setState({ status: 'loading' }); + loadImageForSampling(src).then((r) => { + if (!live) return; + setState(r.ok ? { status: 'ready', img: r } : { status: 'failed', message: r.message }); + }); + return () => { live = false; }; + }, [src]); + return state; +} + +/** Uploaded-image thumbnails; `selected` highlights the current one. */ +export const ImageAssetList: React.FC<{ + assets: Asset[]; + selected: string | null; + onSelect: (url: string) => void; +}> = ({ assets, selected, onSelect }) => ( +
+ {assets.map((a) => ( +
onSelect(a.url))} + data-testid={`image-asset-${a.name}`} + title={a.name} + style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', aspectRatio: '1', border: `2px solid ${a.url === selected ? '#3b82f6' : 'transparent'}` }} + > + {a.name} +
+ ))} + {assets.length === 0 && ( +

+ No images uploaded yet. Upload one from an Image block first. +

+ )} +
+); + +/** Uploaded images for the picker, loaded once on mount. */ +export function useImageAssets(): Asset[] { + const [assets, setAssets] = useState([]); + useEffect(() => { + let live = true; + listAssets('image').then((a) => { if (live) setAssets(a); }).catch(() => {}); + return () => { live = false; }; + }, []); + return assets; +} + +export const modalPanelStyle: CSSProperties = { + background: '#18181b', border: '1px solid #3f3f46', borderRadius: 8, padding: 16, + color: '#e4e4e7', width: 460, maxWidth: '92vw', maxHeight: '88vh', overflowY: 'auto', fontSize: 12, +}; +export const modalTitleStyle: CSSProperties = { fontSize: 15, fontWeight: 600, margin: 0 }; +export const modalBtnStyle: CSSProperties = { + padding: '6px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#27272a', color: '#e4e4e7', +}; +export const errorStyle: CSSProperties = { + margin: '10px 0 0', padding: '8px 10px', borderRadius: 4, background: '#3f1d1d', border: '1px solid #7f1d1d', color: '#fecaca', fontSize: 12, +}; +const assetGridStyle: CSSProperties = { + maxHeight: 140, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 4, background: '#09090b', borderRadius: 6, padding: 4, +}; + +export interface ImagePickModalProps { + onPick: (hex: string) => void; + onClose: () => void; + /** The selected image's src, offered as the starting image. */ + initialSrc?: string; +} + +const ZOOM = 9; + +export const ImagePickModal: React.FC = ({ onPick, onClose, initialSrc }) => { + const assets = useImageAssets(); + const [src, setSrc] = useState(initialSrc || null); + const state = useSampledImage(src); + const canvasRef = useRef(null); + const zoomRef = useRef(null); + const [hover, setHover] = useState(''); + const [note, setNote] = useState(''); + + useEffect(() => { + if (state.status === 'ready' && canvasRef.current) state.img.draw(canvasRef.current); + }, [state]); + + const locate = (e: React.MouseEvent) => { + if (state.status !== 'ready') return null; + const rect = e.currentTarget.getBoundingClientRect(); + if (!rect.width || !rect.height) return null; + return { + x: Math.floor(((e.clientX - rect.left) / rect.width) * state.img.width), + y: Math.floor(((e.clientY - rect.top) / rect.height) * state.img.height), + }; + }; + + const onMove = (e: React.MouseEvent) => { + const p = locate(e); + if (!p || state.status !== 'ready') return; + setHover(sampleAt(state.img.imageData, p.x, p.y)); + setNote(''); + const z = zoomRef.current?.getContext?.('2d'); + if (z && canvasRef.current) { + z.imageSmoothingEnabled = false; + z.clearRect(0, 0, 72, 72); + z.drawImage(canvasRef.current, p.x - 4, p.y - 4, ZOOM, ZOOM, 0, 0, 72, 72); + } + }; + + const onClick = (e: React.MouseEvent) => { + const p = locate(e); + if (!p || state.status !== 'ready') return; + const hex = sampleAt(state.img.imageData, p.x, p.y); + if (!hex) { setNote('That spot is transparent. Click a coloured part of the image.'); return; } + onPick(hex); + onClose(); + }; + + return ( + }> +
+
+

Pick a colour from an image

+ +
+ + {state.status === 'loading' &&

Loading image...

} + {state.status === 'failed' &&
{state.message}
} + {src && state.status !== 'failed' && ( +
+ 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' && ( +
+
+ )} + {note &&

{note}

} +
+ )} + {!src &&

Choose an uploaded image above, then click a spot in it to use that colour.

} +
+
+ ); +}; diff --git a/craft/src/panels/right/styles/PaletteFromImageModal.test.tsx b/craft/src/panels/right/styles/PaletteFromImageModal.test.tsx new file mode 100644 index 0000000..2316bd7 --- /dev/null +++ b/craft/src/panels/right/styles/PaletteFromImageModal.test.tsx @@ -0,0 +1,100 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +vi.mock('../../../utils/assets', () => ({ listAssets: vi.fn(), uploadAsset: vi.fn() })); +vi.mock('../../../utils/image-sample', async () => { + const actual = await vi.importActual('../../../utils/image-sample'); + return { ...actual, loadImageForSampling: vi.fn() }; +}); +import { listAssets } from '../../../utils/assets'; +import { loadImageForSampling, CROSS_ORIGIN_MESSAGE } from '../../../utils/image-sample'; +import { PaletteFromImageModal } from './PaletteFromImageModal'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +const listMock = listAssets as unknown as ReturnType; +const loadMock = loadImageForSampling as unknown as ReturnType; + +// 10 px: 5 red, 3 blue, 2 green +function image() { + const px: number[] = []; + const push = (c: number[], n: number) => { for (let i = 0; i < n; i++) px.push(...c, 255); }; + push([255, 0, 0], 5); push([0, 0, 255], 3); push([0, 160, 0], 2); + return { ok: true, width: 10, height: 1, imageData: { width: 10, height: 1, data: new Uint8ClampedArray(px) }, draw: vi.fn() }; +} + +let container: HTMLDivElement; +let root: Root; +async function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + await act(async () => { root = createRoot(container); root.render(ui); }); + await act(async () => {}); +} +afterEach(() => { act(() => root.unmount()); container.remove(); vi.clearAllMocks(); }); +beforeEach(() => { listMock.mockResolvedValue([{ name: 'a.png', url: '/a.png', type: 'image/png' }]); }); +const q = (id: string) => document.querySelector(`[data-testid="${id}"]`) as HTMLButtonElement | null; +const click = (el: HTMLElement | null) => act(() => { el!.click(); }); + +describe('PaletteFromImageModal', () => { + test('nothing is applied automatically', async () => { + loadMock.mockResolvedValue(image()); + const onAdd = vi.fn(); + await render(); + expect(q('palette-row-#ff0000')).toBeTruthy(); + expect(onAdd).not.toHaveBeenCalled(); + }); + + test('swatches in population order, Add calls onAdd then shows Added', async () => { + loadMock.mockResolvedValue(image()); + const onAdd = vi.fn(); + await render(); + const rows = [...document.querySelectorAll('[data-testid^="palette-row-"]')].map((r) => r.getAttribute('data-testid')); + expect(rows).toEqual(['palette-row-#ff0000', 'palette-row-#0000ff', 'palette-row-#00a000']); + click(q('palette-add-#0000ff')); + expect(onAdd).toHaveBeenCalledWith('#0000ff'); + expect(q('palette-add-#0000ff')!.disabled).toBe(true); + expect(q('palette-add-#0000ff')!.textContent).toBe('Added'); + }); + + test('existing hexes are disabled / Added (case-insensitive)', async () => { + loadMock.mockResolvedValue(image()); + await render(); + expect(q('palette-add-#ff0000')!.disabled).toBe(true); + expect(q('palette-add-#ff0000')!.textContent).toBe('Added'); + expect(q('palette-add-#0000ff')!.disabled).toBe(false); + }); + + test('Add all adds only the not-yet-added colours', async () => { + loadMock.mockResolvedValue(image()); + const onAdd = vi.fn(); + await render(); + click(q('palette-add-all')); + expect(onAdd.mock.calls.map((c) => c[0])).toEqual(['#0000ff', '#00a000']); + expect(q('palette-add-all')!.disabled).toBe(true); + }); + + test('picking a listed asset loads it', async () => { + loadMock.mockResolvedValue(image()); + await render(); + expect(q('palette-row-#ff0000')).toBeNull(); + await act(async () => { q('image-asset-a.png')!.click(); }); + expect(loadMock).toHaveBeenCalledWith('/a.png'); + expect(q('palette-row-#ff0000')).toBeTruthy(); + }); + + test('loader failure shown as role=alert, no swatches', async () => { + loadMock.mockResolvedValue({ ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE }); + await render(); + expect(document.querySelector('[role="alert"]')?.textContent).toBe(CROSS_ORIGIN_MESSAGE); + expect(document.querySelector('[data-testid^="palette-row-"]')).toBeNull(); + }); + + test('Escape closes', async () => { + const onClose = vi.fn(); + await render(); + act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); }); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/craft/src/panels/right/styles/PaletteFromImageModal.tsx b/craft/src/panels/right/styles/PaletteFromImageModal.tsx new file mode 100644 index 0000000..9db32e3 --- /dev/null +++ b/craft/src/panels/right/styles/PaletteFromImageModal.tsx @@ -0,0 +1,78 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Modal } from '../../../ui/Modal'; +import { extractPalette } from '../../../utils/palette-extract'; +import { + ImageAssetList, useImageAssets, useSampledImage, + modalPanelStyle, modalTitleStyle, modalBtnStyle, errorStyle, +} from './ImagePickModal'; + +export interface PaletteFromImageModalProps { + onAdd: (hex: string, label?: string) => void; + onClose: () => void; + existingHexes: string[]; + /** The selected image's src, offered as the starting image. */ + initialSrc?: string; +} + +export const PaletteFromImageModal: React.FC = ({ onAdd, onClose, existingHexes, initialSrc }) => { + const assets = useImageAssets(); + const [src, setSrc] = useState(initialSrc || null); + const [count, setCount] = useState(6); + const [added, setAdded] = useState([]); + const state = useSampledImage(src); + + useEffect(() => { setAdded([]); }, [src]); + + const colours = useMemo( + () => (state.status === 'ready' ? extractPalette(state.img.imageData.data, { count }) : []), + [state, count], + ); + const taken = useMemo(() => new Set([...existingHexes, ...added].map((h) => h.toLowerCase())), [existingHexes, added]); + + const add = (hex: string) => { + onAdd(hex); + setAdded((a) => [...a, hex]); + }; + const addAll = () => colours.filter((h) => !taken.has(h)).forEach(add); + const remaining = colours.filter((h) => !taken.has(h)).length; + + return ( + }> +
+
+

Palette from an image

+ +
+ + {!src &&

Choose an uploaded image to see its main colours. Nothing is added until you press Add.

} + {state.status === 'loading' &&

Reading colours...

} + {state.status === 'failed' &&
{state.message}
} + {state.status === 'ready' && ( +
+
+ + + + +
+ {colours.length === 0 &&

No colours found. This image may be fully transparent.

} + {colours.map((hex) => { + const done = taken.has(hex); + return ( +
+ + {hex} + +
+ ); + })} +
+ )} +
+
+ ); +}; From 179aaebe499cb0aad39019ca93d38ee7967a8c49 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:54:33 -0800 Subject: [PATCH 10/19] Colour picking: native EyeDropper + in-canvas pick mode with brand/token matching, real-browser e2e Co-Authored-By: Claude Sonnet 5.5 --- craft/package.json | 1 + craft/scripts/e2e-color-pick.mjs | 169 ++++++++++++++++++++++ craft/src/utils/color-pick.test.ts | 163 +++++++++++++++++++++ craft/src/utils/color-pick.ts | 223 +++++++++++++++++++++++++++++ 4 files changed, 556 insertions(+) create mode 100644 craft/scripts/e2e-color-pick.mjs create mode 100644 craft/src/utils/color-pick.test.ts create mode 100644 craft/src/utils/color-pick.ts diff --git a/craft/package.json b/craft/package.json index 5789f0c..094c141 100644 --- a/craft/package.json +++ b/craft/package.json @@ -13,6 +13,7 @@ "test:unit": "vitest run", "test:browser:bg": "node scripts/e2e-background.mjs", "test:browser:canvas": "node scripts/e2e-canvas-parity.mjs", + "test:browser:colorpick": "node scripts/e2e-color-pick.mjs", "test:unit:watch": "vitest" }, "dependencies": { diff --git a/craft/scripts/e2e-color-pick.mjs b/craft/scripts/e2e-color-pick.mjs new file mode 100644 index 0000000..5ab08ec --- /dev/null +++ b/craft/scripts/e2e-color-pick.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node +/** + * Real-browser test (headless Firefox AND Chromium) for src/utils/color-pick.ts: + * pickFromCanvas must return the exact computed hexes + site/brand matches, + * Esc must cancel, a pick click must NOT reach the clicked element (no craft + * node selection), and EyeDropper availability is reported per browser. + * npm run test:browser:colorpick (CHROMIUM_PATH / FIREFOX_PATH override binaries) + */ +import { build } from 'esbuild'; +import { chromium, firefox } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import http from 'node:http'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const out = await build({ + entryPoints: [path.join(root, 'src/utils/color-pick.ts')], + bundle: true, write: false, format: 'iife', globalName: 'cp', platform: 'browser', logLevel: 'error', +}); +const js = out.outputFiles[0].text; + +const design = { + primaryColor: '#3b82f6', secondaryColor: '#64748b', accentColor: '#f59e0b', linkColor: '#2563eb', + successColor: '#16a34a', warningColor: '#d97706', errorColor: '#dc2626', + backgroundColor: '#ffffff', textColor: '#111827', mutedTextColor: '#6b7280', borderColor: '#e5e7eb', + swatches: [ + { id: 'b1', label: 'Logo orange', hex: '#FF6600', brand: true, slot: 3 }, + { id: 'p1', label: 'Sand', hex: '#c8b08a', brand: false }, + ], +}; + +const html = ` +
+
token bg
+
brand bg
+
swatch bg
+
plain + border
+
nested transparent
+
alpha
+

muted text on frame bg

+
+
editor chrome
+`; + +// Served over http://localhost (a secure context) so EyeDropper availability is reported truthfully. +const server = http.createServer((_q, r) => { r.setHeader('content-type', 'text/html'); r.end(html); }); +await new Promise((res) => server.listen(0, '127.0.0.1', res)); +const url = `http://localhost:${server.address().port}/`; + +async function run(name, launcher, exe) { + const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) }); + const page = await browser.newPage({ viewport: { width: 900, height: 900 } }); + const errors = []; + page.on('pageerror', (e) => errors.push(String(e))); + await page.goto(url); + await page.addScriptTag({ content: js }); + await page.evaluate((d) => { + window.__design = d; + window.__leaks = []; + for (const el of document.querySelectorAll('.canvas-device-frame, .canvas-device-frame *')) + for (const t of ['click', 'mousedown', 'mouseup', 'pointerdown', 'pointerup']) + el.addEventListener(t, () => window.__leaks.push(t + '#' + el.id)); + }, design); + + let failed = 0; + const check = (label, ok, detail) => { + if (!ok) failed++; + console.log(`[${name}] ${ok ? 'PASS' : 'FAIL'} ${label}${detail !== undefined ? ': ' + detail : ''}`); + }; + const eq = (label, got, want) => check(label, JSON.stringify(got) === JSON.stringify(want), JSON.stringify(got) + (JSON.stringify(got) === JSON.stringify(want) ? '' : ' expected ' + JSON.stringify(want))); + + async function pick(id, { hover } = {}) { + await page.evaluate(() => { window.__res = null; cp.pickFromCanvas({ design: window.__design }).then((r) => { window.__res = r; }); }); + const box = await page.locator('#' + id).boundingBox(); + const x = box.x + 8, y = box.y + 8; + await page.mouse.move(x, y); + let tip = null; + if (hover) tip = await page.evaluate(() => document.querySelector('[data-wsb-pick=tip]')?.textContent ?? null); + await page.mouse.click(x, y); + await page.waitForFunction(() => window.__res !== null, null, { timeout: 3000 }); + return { res: await page.evaluate(() => window.__res), tip }; + } + const cand = (res, k) => res.candidates.find((c) => c.kind === k); + + // 1. token-driven background (CSS variable) + let { res, tip } = await pick('tok', { hover: true }); + eq('token bg: hex', res.hex, '#3b82f6'); + eq('token bg: background match', cand(res, 'background').match, { kind: 'token', ref: 'var(--wsb-primary)', label: 'Primary' }); + eq('token bg: text candidate', cand(res, 'text').hex, '#ffffff'); + check('token bg: hover tooltip shows live colour', tip === '#3b82f6', String(tip)); + eq('token bg: no border candidate', cand(res, 'border'), undefined); + + // 2. brand swatch (case-insensitive hex) + ({ res } = await pick('brand')); + eq('brand: hex', res.hex, '#ff6600'); + eq('brand: match', cand(res, 'background').match, { kind: 'swatch', ref: 'var(--wsb-brand-3)', label: 'Logo orange' }); + + // 3. plain saved swatch via rgba(...,1) + ({ res } = await pick('sand')); + eq('swatch: hex', res.hex, '#c8b08a'); + eq('swatch: match', cand(res, 'background').match, { kind: 'swatch', ref: '#c8b08a', label: 'Sand' }); + + // 4. plain bg + text + border, no match + ({ res } = await pick('plain')); + eq('plain: hex = bg', res.hex, '#123456'); + eq('plain: text', cand(res, 'text').hex, '#fedcba'); + eq('plain: border', cand(res, 'border').hex, '#0a0b0c'); + eq('plain: no matches', res.candidates.map((c) => c.match), [undefined, undefined, undefined]); + + // 5. nested transparent child: text colour wins, background = painted ancestor + ({ res } = await pick('deep')); + eq('nested: hex = inherited text colour', res.hex, '#778899'); + eq('nested: background = nearest painted ancestor', cand(res, 'background').hex, '#112233'); + eq('nested: no border', cand(res, 'border'), undefined); + + // 6. alpha background -> 8-digit hex + ({ res } = await pick('alpha')); + eq('alpha: hex', res.hex, '#12345680'); + + // 7. text on frame bg (frame background is the painted ancestor, matches Background token) + ({ res } = await pick('txt')); + eq('text: hex', res.hex, '#6b7280'); + eq('text: text match', cand(res, 'text').match, { kind: 'token', ref: 'var(--wsb-muted)', label: 'Muted text' }); + eq('text: background match', cand(res, 'background').match, { kind: 'token', ref: 'var(--wsb-bg)', label: 'Background' }); + + // 8. none of those pick clicks reached the elements (no node selection) + eq('clicks never reached targets', await page.evaluate(() => window.__leaks), []); + + // 9. Esc cancels; overlay cleaned up + await page.evaluate(() => { window.__res = null; cp.pickFromCanvas({ design: window.__design }).then((r) => { window.__res = r; }); }); + await page.keyboard.press('Escape'); + await page.waitForFunction(() => window.__res !== null, null, { timeout: 3000 }); + const esc = await page.evaluate(() => window.__res); + check('Esc cancels', esc.ok === false && esc.reason === 'cancelled', JSON.stringify(esc)); + eq('overlay removed', await page.evaluate(() => document.querySelectorAll('[data-wsb-pick]').length), 0); + + // 10. click outside the frame cancels + await page.evaluate(() => { window.__res = null; cp.pickFromCanvas({ design: window.__design }).then((r) => { window.__res = r; }); }); + const cb = await page.locator('#chrome').boundingBox(); + await page.mouse.click(cb.x + 5, cb.y + 5); + await page.waitForFunction(() => window.__res !== null, null, { timeout: 3000 }); + check('outside click cancels', (await page.evaluate(() => window.__res)).reason === 'cancelled'); + + // 11. after a pick finishes, normal clicks flow again + await page.evaluate(() => { window.__leaks.length = 0; }); + await page.locator('#tok').click(); + check('clicks flow normally after picking', (await page.evaluate(() => window.__leaks)).includes('click#tok')); + + // 12. EyeDropper availability (report) + const eye = await page.evaluate(() => ({ present: 'EyeDropper' in window, available: cp.eyedropperAvailable(), kind: cp.screenPickKind() })); + console.log(`[${name}] INFO EyeDropper: window.EyeDropper=${eye.present} eyedropperAvailable()=${eye.available} screenPickKind()=${eye.kind}`); + check('secure context', await page.evaluate(() => isSecureContext)); + if (name === 'firefox') check('firefox: no screen pick (verdict C)', eye.kind === 'none'); + else check('chromium: screenPickKind matches window.EyeDropper', eye.kind === (eye.present ? 'eyedropper' : 'none')); + + if (errors.length) { failed++; console.log('page errors:', errors.join('\n')); } + await browser.close(); + return failed; +} +let total = 0; +total += await run('chromium', chromium, process.env.CHROMIUM_PATH); +total += await run('firefox', firefox, process.env.FIREFOX_PATH); +server.close(); +console.log(total ? `FAILED (${total})` : 'ALL PASS'); +process.exit(total ? 1 : 0); diff --git a/craft/src/utils/color-pick.test.ts b/craft/src/utils/color-pick.test.ts new file mode 100644 index 0000000..0c5c17a --- /dev/null +++ b/craft/src/utils/color-pick.test.ts @@ -0,0 +1,163 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; +import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext'; +import { + eyedropperAvailable, parseComputedColor, pickFromCanvas, pickFromScreen, pickWithEyeDropper, rgbToHex, screenPickKind, +} from './color-pick'; + +const design = (over: Partial = {}): SiteDesign => ({ ...DEFAULT_SITE_DESIGN, ...over }); + +describe('rgbToHex / parseComputedColor', () => { + it('rgbToHex', () => { + expect(rgbToHex(255, 0, 16)).toBe('#ff0010'); + expect(rgbToHex(0, 0, 0, 1)).toBe('#000000'); + expect(rgbToHex(18, 52, 86, 0.5)).toBe('#12345680'); + expect(rgbToHex(300, -4, 1.4)).toBe('#ff0001'); + }); + it('parses computed rgb()/rgba() in comma and space syntax', () => { + expect(parseComputedColor('rgb(18, 52, 86)')).toEqual({ hex: '#123456', alpha: 1 }); + expect(parseComputedColor('rgba(18, 52, 86, 0.5)')).toEqual({ hex: '#12345680', alpha: 0.5 }); + expect(parseComputedColor('rgb(18 52 86 / 50%)')).toEqual({ hex: '#12345680', alpha: 0.5 }); + expect(parseComputedColor('rgba(0, 0, 0, 0)')).toEqual({ hex: '#00000000', alpha: 0 }); + expect(parseComputedColor('transparent')).toEqual({ hex: '#00000000', alpha: 0 }); + expect(parseComputedColor('rgba(18, 52, 86, 1)')).toEqual({ hex: '#123456', alpha: 1 }); + }); + it('rejects junk', () => { + expect(parseComputedColor('')).toBeNull(); + expect(parseComputedColor('red')).toBeNull(); + expect(parseComputedColor('var(--x)')).toBeNull(); + }); +}); + +describe('eyedropper / screen kind', () => { + afterEach(() => { delete (window as unknown as Record).EyeDropper; }); + it('reports unsupported when window.EyeDropper is absent', async () => { + expect(eyedropperAvailable()).toBe(false); + expect(screenPickKind()).toBe('none'); + const r = await pickWithEyeDropper(); + expect(r).toMatchObject({ ok: false, reason: 'unsupported' }); + expect(await pickFromScreen()).toMatchObject({ ok: false, reason: 'unsupported' }); + }); + it('uses a native EyeDropper (lowercased hex) and maps abort to cancelled', async () => { + (window as unknown as Record).EyeDropper = class { + open(o?: { signal?: AbortSignal }) { + return new Promise((res, rej) => { + if (o?.signal) o.signal.addEventListener('abort', () => rej(new DOMException('x', 'AbortError'))); + else res({ sRGBHex: '#ABCDEF' }); + }); + } + }; + expect(eyedropperAvailable()).toBe(true); + expect(screenPickKind()).toBe('eyedropper'); + expect(await pickWithEyeDropper()).toEqual({ ok: true, hex: '#abcdef', source: 'eyedropper' }); + const ac = new AbortController(); + const p = pickWithEyeDropper(ac.signal); + ac.abort(); + expect(await p).toMatchObject({ ok: false, reason: 'cancelled' }); + }); +}); + +describe('pickFromCanvas (jsdom, inline styles)', () => { + afterEach(() => { document.body.innerHTML = ''; document.head.querySelectorAll('style[data-wsb-pick]').forEach((s) => s.remove()); }); + const mount = () => { + document.body.innerHTML = ` +
+
+ hi +
+

x

+
+
out
`; + return (id: string) => document.getElementById(id)!; + }; + const click = (el: Element) => el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, clientX: 1, clientY: 1 })); + + it('returns background/text/border candidates with default hex = painted background', async () => { + const $ = mount(); + const p = pickFromCanvas({ design: design({ primaryColor: '#3b82f6' }) }); + click($('card')); + const r = await p; + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.source).toBe('canvas'); + expect(r.hex).toBe('#3b82f6'); + const by = Object.fromEntries(r.candidates.map((c) => [c.kind, c])); + expect(by.background.hex).toBe('#3b82f6'); + expect(by.background.match).toMatchObject({ kind: 'token', ref: 'var(--wsb-primary)' }); + expect(by.text.hex).toBe('#ffffff'); + expect(by.border.hex).toBe('#010203'); + expect(by.border.match).toBeUndefined(); + }); + + it('non-painting element: hex is the text colour; background is the nearest painted ancestor', async () => { + const $ = mount(); + const p = pickFromCanvas({ design: design() }); + click($('inner')); + const r = await p; + if (!r.ok) throw new Error('expected ok'); + expect(r.hex).toBe('#ffffff'); + expect(r.candidates.find((c) => c.kind === 'background')!.hex).toBe('#3b82f6'); + expect(r.candidates.find((c) => c.kind === 'border')).toBeUndefined(); + }); + + it('prefers a published brand swatch over a token match', async () => { + const $ = mount(); + const d = design({ + primaryColor: '#3b82f6', + swatches: [{ id: 'a', label: 'Logo blue', hex: '#3B82F6', brand: true, slot: 2 }], + }); + const p = pickFromCanvas({ design: d }); + click($('card')); + const r = await p; + if (!r.ok) throw new Error('expected ok'); + expect(r.candidates[0].match).toEqual({ kind: 'swatch', ref: 'var(--wsb-brand-2)', label: 'Logo blue' }); + }); + + it('matches a non-brand swatch by hex ref', async () => { + const $ = mount(); + const p = pickFromCanvas({ design: design({ swatches: [{ id: 'a', label: 'Sky', hex: '#0a141e', brand: false }] }) }); + click($('plain')); + const r = await p; + if (!r.ok) throw new Error('expected ok'); + expect(r.hex).toBe('#0a141e'); + expect(r.candidates.find((c) => c.kind === 'text')!.match).toEqual({ kind: 'swatch', ref: '#0a141e', label: 'Sky' }); + }); + + it('swallows the click (capture phase) so craft nodes are not selected', async () => { + const $ = mount(); + let leaked = 0; + $('card').addEventListener('click', () => leaked++); + $('card').addEventListener('mousedown', () => leaked++); + const p = pickFromCanvas({ design: design() }); + $('card').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + click($('card')); + await p; + expect(leaked).toBe(0); + }); + + it('Esc cancels, abort cancels, outside click cancels', async () => { + mount(); + let p = pickFromCanvas({ design: design() }); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(await p).toMatchObject({ ok: false, reason: 'cancelled' }); + + const ac = new AbortController(); + p = pickFromCanvas({ design: design(), signal: ac.signal }); + ac.abort(); + expect(await p).toMatchObject({ ok: false, reason: 'cancelled' }); + + p = pickFromCanvas({ design: design() }); + click(document.getElementById('outside')!); + expect(await p).toMatchObject({ ok: false, reason: 'cancelled' }); + }); + + it('no frame -> error; cleans up its overlay and listeners', async () => { + expect(await pickFromCanvas({ design: design() })).toMatchObject({ ok: false, reason: 'error' }); + const $ = mount(); + const p = pickFromCanvas({ design: design() }); + expect(document.querySelector('[data-wsb-pick]')).not.toBeNull(); + click($('plain')); + await p; + expect(document.querySelector('[data-wsb-pick]')).toBeNull(); + }); +}); diff --git a/craft/src/utils/color-pick.ts b/craft/src/utils/color-pick.ts new file mode 100644 index 0000000..ba0c6a4 --- /dev/null +++ b/craft/src/utils/color-pick.ts @@ -0,0 +1,223 @@ +/** + * "Pick a colour from anywhere" engines (no React). Two sources: + * - the native EyeDropper API (Chromium only; Firefox/Safari have none), and + * - pickFromCanvas: an in-page pick mode over the editor canvas + * (`.canvas-device-frame`) that reads the clicked element's COMPUTED colours. + * Firefox has no screen-sampling fallback: see + * scratchpad/color/firefox-spike-verdict.md (tab capture is not viable there). + */ +import type { SiteDesign } from '../state/SiteDesignContext'; +import { COLOR_TOKENS, resolveTokenValue, tokenRef } from './tokens'; +import { findSwatchByHex, normalizeHex, swatchValue, type SavedSwatch } from './swatches'; + +export type PickFailReason = 'cancelled' | 'unsupported' | 'cross-origin' | 'error'; +export type PickResult = + | { ok: true; hex: string; source: 'canvas' | 'image' | 'eyedropper' | 'tab-capture' } + | { ok: false; reason: PickFailReason; message: string }; + +export type CandidateKind = 'background' | 'text' | 'border'; +export interface PickMatch { kind: 'token' | 'swatch'; ref: string; label: string } +export interface CanvasCandidate { kind: CandidateKind; hex: string; match?: PickMatch } +export type CanvasPickResult = + | { ok: true; hex: string; source: 'canvas'; candidates: CanvasCandidate[] } + | { ok: false; reason: PickFailReason; message: string }; + +const fail = (reason: PickFailReason, message: string): { ok: false; reason: PickFailReason; message: string } => ({ ok: false, reason, message }); +const CANCELLED = 'Colour picking was cancelled.'; + +// ------------------------------------------------------------------ colour parsing +const h2 = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'); + +/** r,g,b 0-255 (+ optional alpha 0-1) -> lowercase #rrggbb, or #rrggbbaa only when alpha < 1. */ +export function rgbToHex(r: number, g: number, b: number, a = 1): string { + const base = `#${h2(r)}${h2(g)}${h2(b)}`; + return a >= 1 ? base : base + h2(a * 255); +} + +const RGB_RE = /^rgba?\(\s*([\d.]+)(?:\s*,\s*|\s+)([\d.]+)(?:\s*,\s*|\s+)([\d.]+)(?:\s*(?:,|\/)\s*([\d.]+)(%?))?\s*\)$/i; + +/** Parses a COMPUTED colour (`rgb()`/`rgba()`/`transparent`). Null for anything else. */ +export function parseComputedColor(v: string): { hex: string; alpha: number } | null { + const s = String(v ?? '').trim(); + if (s.toLowerCase() === 'transparent') return { hex: '#00000000', alpha: 0 }; + const m = RGB_RE.exec(s); + if (!m) return null; + let alpha = m[4] === undefined ? 1 : Number(m[4]); + if (m[5] === '%') alpha /= 100; + alpha = Math.max(0, Math.min(1, alpha)); + return { hex: rgbToHex(Number(m[1]), Number(m[2]), Number(m[3]), alpha), alpha }; +} + +/** #rgb/#rgba -> #rrggbb[aa]; lowercase; null when invalid. */ +function canonHex(v: string): string | null { + const h = normalizeHex(v); + if (!h) return null; + return h.length === 4 || h.length === 5 ? '#' + [...h.slice(1)].map((c) => c + c).join('') : h; +} + +// ------------------------------------------------------------------ site/brand matching +/** Does `hex` equal a brand swatch, design token or saved swatch? Brand swatch > token > plain swatch. */ +export function matchSiteColor(hex: string, design: SiteDesign): PickMatch | undefined { + const want = canonHex(hex); + if (!want) return undefined; + const swatches: SavedSwatch[] = Array.isArray(design.swatches) ? design.swatches : []; + const canon = swatches.map((s) => ({ ...s, hex: canonHex(s.hex) ?? s.hex })); + const sw = findSwatchByHex(canon, want); + const brandSw = sw && sw.brand ? sw : undefined; + const label = (s: SavedSwatch) => s.label.trim() || s.hex; + if (brandSw) { + const ref = swatchValue(brandSw); + if (ref !== brandSw.hex) return { kind: 'swatch', ref, label: label(brandSw) }; + } + for (const t of COLOR_TOKENS) { + const val = canonHex(resolveTokenValue(tokenRef(t.key), design)); + if (val === want) return { kind: 'token', ref: tokenRef(t.key), label: t.label }; + } + if (sw) return { kind: 'swatch', ref: swatchValue(sw), label: label(sw) }; + return undefined; +} + +// ------------------------------------------------------------------ native EyeDropper +interface EyeDropperLike { open(o?: { signal?: AbortSignal }): Promise<{ sRGBHex: string }> } +type EyeDropperCtor = new () => EyeDropperLike; +const eyeCtor = (): EyeDropperCtor | undefined => + typeof window === 'undefined' ? undefined : (window as unknown as { EyeDropper?: EyeDropperCtor }).EyeDropper; + +export function eyedropperAvailable(): boolean { + return typeof eyeCtor() === 'function'; +} + +export async function pickWithEyeDropper(signal?: AbortSignal): Promise { + const Ctor = eyeCtor(); + if (!Ctor) return fail('unsupported', "This browser can't pick colours from the screen."); + try { + const r = await new Ctor().open(signal ? { signal } : undefined); + const hex = normalizeHex(r.sRGBHex); + return hex ? { ok: true, hex, source: 'eyedropper' } : fail('error', 'The eyedropper returned an unreadable colour.'); + } catch (e) { + const name = (e as { name?: string })?.name; + if (name === 'AbortError' || signal?.aborted) return fail('cancelled', CANCELLED); + return fail('error', 'The eyedropper could not pick a colour.'); + } +} + +/** What "pick from the screen" can do here. Firefox: none (tab capture is not viable, see the spike verdict). */ +export function screenPickKind(): 'eyedropper' | 'tab-capture' | 'none' { + return eyedropperAvailable() ? 'eyedropper' : 'none'; +} + +export function pickFromScreen(signal?: AbortSignal): Promise { + return pickWithEyeDropper(signal); +} + +// ------------------------------------------------------------------ canvas pick mode +function readCandidates(el: Element, frame: Element, design: SiteDesign): { candidates: CanvasCandidate[]; hex: string | null } { + const win = el.ownerDocument.defaultView!; + const cs = win.getComputedStyle(el); + const out: CanvasCandidate[] = []; + const add = (kind: CandidateKind, hex: string) => { const m = matchSiteColor(hex, design); out.push(m ? { kind, hex, match: m } : { kind, hex }); }; + + const own = parseComputedColor(cs.backgroundColor); + const ownPaints = !!own && own.alpha > 0; + // Nearest painted background: the element, else its ancestors up to (and including) the frame. + let bg: string | null = null; + for (let n: Element | null = el; n; n = n === frame ? null : n.parentElement) { + const c = parseComputedColor(win.getComputedStyle(n).backgroundColor); + if (c && c.alpha > 0) { bg = c.hex; break; } + } + if (bg) add('background', bg); + const txt = parseComputedColor(cs.color); + if (txt) add('text', txt.hex); + const bw = parseFloat(cs.borderTopWidth) || 0; + const bc = parseComputedColor(cs.borderTopColor); + if (bw > 0 && cs.borderTopStyle !== 'none' && cs.borderTopStyle !== 'hidden' && bc && bc.alpha > 0) add('border', bc.hex); + + const primary = ownPaints && bg ? bg : (txt?.hex ?? bg); + return { candidates: out, hex: primary }; +} + +const BLOCKED = ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'dblclick', 'auxclick', 'contextmenu', 'dragstart', 'touchstart', 'touchend']; + +let activeCancel: (() => void) | null = null; + +export function pickFromCanvas(opts: { frameSelector?: string; design: SiteDesign; signal?: AbortSignal }): Promise { + const { design, signal } = opts; + const selector = opts.frameSelector ?? '.canvas-device-frame'; + if (typeof document === 'undefined') return Promise.resolve(fail('unsupported', 'Colour picking needs a browser.')); + const found = document.querySelector(selector); + if (!found) return Promise.resolve(fail('error', "Couldn't find the page canvas to pick from.")); + if (signal?.aborted) return Promise.resolve(fail('cancelled', CANCELLED)); + const frame: Element = found; + activeCancel?.(); + + return new Promise((resolve) => { + const style = document.createElement('style'); + style.setAttribute('data-wsb-pick', 'style'); + style.textContent = `${selector},${selector} *{cursor:crosshair !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; + }); +} From 9c61f410797e60868d6d0b7a0139539c8d20e209 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:56:55 -0800 Subject: [PATCH 11/19] Site colours: replaceBrandRef + PageContext siteStateStrings/rewriteSiteStates for brand-ref rewriting Co-Authored-By: Claude Sonnet 5.5 --- .../PageContext.rewriteSiteStates.test.tsx | 73 ++++++++++++++++ craft/src/state/PageContext.tsx | 85 +++++++++++++++++++ craft/src/utils/swatches.test.ts | 24 ++++++ craft/src/utils/swatches.ts | 13 +++ 4 files changed, 195 insertions(+) create mode 100644 craft/src/state/PageContext.rewriteSiteStates.test.tsx diff --git a/craft/src/state/PageContext.rewriteSiteStates.test.tsx b/craft/src/state/PageContext.rewriteSiteStates.test.tsx new file mode 100644 index 0000000..8576a7c --- /dev/null +++ b/craft/src/state/PageContext.rewriteSiteStates.test.tsx @@ -0,0 +1,73 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { PageProvider, usePages } from './PageContext'; +import { replaceBrandRef, countBrandRefUses } from '../utils/swatches'; + +/* Same mocked-craft pattern as PageContext.orphan-repair-wiring.test.tsx. */ +let serializeReturn = '{}'; +const deserializeMock = vi.fn(); +vi.mock('@craftjs/core', () => ({ + useEditor: () => ({ + query: { serialize: () => serializeReturn }, + actions: { deserialize: deserializeMock, history: { clear: () => {} } }, + }), +})); + +let container: HTMLDivElement; +let root: Root; +let ctx: ReturnType; +const Probe: React.FC = () => { ctx = usePages(); return null; }; + +beforeEach(() => { deserializeMock.mockClear(); }); + +const st = (c: string) => JSON.stringify({ ROOT: { props: { style: { backgroundColor: c } } } }); + +function mount() { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { root = createRoot(container); root.render(); }); +} + +describe('rewriteSiteStates / siteStateStrings', () => { + test('rewrites stored pages + header + footer and the live canvas; counts see the live canvas', async () => { + mount(); + act(() => { + ctx.setPagesCraftState([ + { id: 'home', name: 'Home', slug: 'index', craftState: st('STALE') }, + { id: 'p2', name: 'About', slug: 'about', craftState: st('var(--wsb-brand-1)') }, + ]); + ctx.setActivePageIdDirect('home'); + ctx.setHeaderCraftState(st('var(--wsb-brand-1)')); + ctx.setFooterCraftState(st('#fff')); + }); + serializeReturn = st('var(--wsb-brand-1)'); // live canvas of "home" + const total = ctx.siteStateStrings().reduce((n, s) => n + countBrandRefUses(s, 1), 0); + expect(total).toBe(3); // live home + about + header (stale home string is NOT counted) + + let changed = 0; + act(() => { changed = ctx.rewriteSiteStates((s) => replaceBrandRef(s, 1, '#0f172a')); }); + expect(changed).toBe(3); // about, header, live home + expect(ctx.pages.find((p) => p.id === 'p2')!.craftState).toContain('#0f172a'); + expect(ctx.headerPage.craftState).toContain('#0f172a'); + expect(ctx.footerPage.craftState).toBe(st('#fff')); + expect(ctx.pages[0].craftState).toContain('#0f172a'); + expect(ctx.pages[0].craftState).not.toContain('STALE'); + await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); + const loaded = deserializeMock.mock.calls.map((c) => String(c[0])); + expect(loaded.some((s) => s.includes('#0f172a') && !s.includes('--wsb-brand-1'))).toBe(true); + act(() => root.unmount()); + }); + + test('no references -> nothing changes', () => { + mount(); + act(() => { ctx.setPagesCraftState([{ id: 'home', name: 'Home', slug: 'index', craftState: st('#fff') }]); ctx.setActivePageIdDirect('home'); }); + serializeReturn = st('#fff'); + let changed = -1; + act(() => { changed = ctx.rewriteSiteStates((s) => replaceBrandRef(s, 2, '#000000')); }); + expect(changed).toBe(0); + expect(deserializeMock).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); +}); diff --git a/craft/src/state/PageContext.tsx b/craft/src/state/PageContext.tsx index abc9276..b75baf5 100644 --- a/craft/src/state/PageContext.tsx +++ b/craft/src/state/PageContext.tsx @@ -57,6 +57,19 @@ interface PageContextValue { * in the new page's slot. */ pendingCanvasState: () => string | null; + /** + * Every serialized craft state the site holds right now: all pages + * (the active one as the LIVE canvas), header, footer and global-section + * masters (as JSON). For read-only scans such as brand-colour usage counts. + */ + siteStateStrings: () => string[]; + /** + * Rewrites every stored state (pages, header, footer, global masters) and the + * live canvas through `transform`. The canvas change is one undoable step. + * Returns how many slots changed; marks the editor dirty through the state + * changes it makes. + */ + rewriteSiteStates: (transform: (state: string) => string) => number; switchPage: (pageId: string) => void; editHeader: () => void; editFooter: () => void; @@ -277,6 +290,8 @@ const PageContext = createContext({ finishGlobal: () => NO_COMMIT, globalZoneSaveView: () => null, pendingCanvasState: () => null, + siteStateStrings: () => [], + rewriteSiteStates: () => 0, switchPage: () => {}, editHeader: () => {}, editFooter: () => {}, @@ -502,6 +517,24 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => const pendingCanvasState = useCallback((): string | null => pendingLoadRef.current.state, []); + const siteStateStrings = useCallback((): string[] => { + const cur = activePageIdRef.current; + const out: string[] = []; + let live: string | null = null; + try { live = query.serialize(); } catch { live = null; } + const slot = (id: string, stored: string | null) => { + const v = id === cur && live ? live : stored; + if (v) out.push(v); + }; + for (const p of pagesRef.current) slot(p.id, p.craftState); + slot(HEADER_ID, headerPageRef.current.craftState); + slot(FOOTER_ID, footerPageRef.current.craftState); + if (isGlobalZoneId(cur) && live) out.push(live); + const lib = extras.get(GLOBAL_SECTIONS_KEY, {}); + for (const m of Object.values(lib)) out.push(JSON.stringify(m.nodes)); + return out; + }, [extras, query]); + /** Save whatever is on the current Frame back to the right state slot */ const saveCurrentState = useCallback(() => { const currentId = activePageIdRef.current; @@ -587,6 +620,56 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => [actions], ); + const rewriteSiteStates = useCallback((transform: (state: string) => string): number => { + const cur = activePageIdRef.current; + let live: string | null = null; + try { live = query.serialize(); } catch { live = null; } + const liveNext = live ? transform(live) : null; + const liveChanged = !!live && liveNext !== live; + const apply = (id: string, stored: string | null): string | null => { + if (id === cur && live && !isGlobalZoneId(cur)) return liveNext; + return stored ? transform(stored) : stored; + }; + let changed = 0; + const nextPages = pagesRef.current.map((p) => { + const n = apply(p.id, p.craftState); + if (n === p.craftState) return p; + changed++; + return { ...p, craftState: n }; + }); + if (nextPages.some((p, i) => p !== pagesRef.current[i])) { + pagesRef.current = nextPages; + setPages(nextPages); + } + for (const [id, ref, set] of [[HEADER_ID, headerPageRef, setHeaderPage], [FOOTER_ID, footerPageRef, setFooterPage]] as const) { + const c = ref.current; + const n = apply(id, c.craftState); + if (n !== c.craftState) { + changed++; + ref.current = { ...c, craftState: n }; + set(ref.current); + } + } + const lib = extras.get(GLOBAL_SECTIONS_KEY, {}); + let libChanged = false; + const nextLib: GlobalLibrary = {}; + for (const [k, m] of Object.entries(lib)) { + const before = JSON.stringify(m.nodes); + const after = transform(before); + if (after === before) { nextLib[k] = m; continue; } + libChanged = true; + changed++; + nextLib[k] = { ...m, nodes: JSON.parse(after) }; + } + if (libChanged) extras.set(GLOBAL_SECTIONS_KEY, nextLib); + if (liveChanged && liveNext) { + if (isGlobalZoneId(cur)) changed++; + const fallback = cur === HEADER_ID ? EMPTY_HEADER : cur === FOOTER_ID ? EMPTY_FOOTER : EMPTY_CANVAS; + loadState(liveNext, fallback, { keepHistory: true }); + } + return changed; + }, [extras, query, loadState]); + /** * #57: leave the master zone for `targetId`. The master is committed (and * every stored slot synced) first; the target is then loaded as it was @@ -1049,6 +1132,8 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => finishGlobal, globalZoneSaveView, pendingCanvasState, + siteStateStrings, + rewriteSiteStates, switchPage, editHeader, editFooter, diff --git a/craft/src/utils/swatches.test.ts b/craft/src/utils/swatches.test.ts index 9b8d2aa..b7ca927 100644 --- a/craft/src/utils/swatches.test.ts +++ b/craft/src/utils/swatches.test.ts @@ -119,3 +119,27 @@ describe('isTokenRef accepts brand refs (digits in the name)', () => { expect(tokenKeyOf('var(--wsb-brand-2)')).toBeNull(); }); }); + +describe('replaceBrandRef / countBrandRefUses', () => { + test('replaces every exact reference of the slot with the literal hex, leaving other slots alone', async () => { + const { replaceBrandRef, countBrandRefUses } = await import('./swatches'); + const st = '{"a":{"props":{"c":"var(--wsb-brand-1)","g":"linear-gradient(90deg, var(--wsb-brand-1) 0%, var(--wsb-brand-12x))","d":"var(--wsb-brand-2)"}}}'; + expect(countBrandRefUses(st, 1)).toBe(2); + const out = replaceBrandRef(st, 1, '#0f172a'); + expect(out).toContain('"c":"#0f172a"'); + expect(out).toContain('linear-gradient(90deg, #0f172a 0%'); + expect(out).toContain('var(--wsb-brand-2)'); + expect(countBrandRefUses(out, 1)).toBe(0); + expect(() => JSON.parse(out)).not.toThrow(); + }); + test('returns the identical string when nothing references the slot', async () => { + const { replaceBrandRef } = await import('./swatches'); + const st = '{"a":"#fff"}'; + expect(replaceBrandRef(st, 3, '#000000')).toBe(st); + }); + test('refuses an invalid hex (returns input unchanged)', async () => { + const { replaceBrandRef } = await import('./swatches'); + const st = '"var(--wsb-brand-1)"'; + expect(replaceBrandRef(st, 1, 'red')).toBe(st); + }); +}); diff --git a/craft/src/utils/swatches.ts b/craft/src/utils/swatches.ts index 147adf9..6a36894 100644 --- a/craft/src/utils/swatches.ts +++ b/craft/src/utils/swatches.ts @@ -152,3 +152,16 @@ export function countBrandRefUses(serialized: string, slot: number): number { while ((i = serialized.indexOf(needle, i)) !== -1) { n++; i += needle.length; } return n; } + +/** + * Replaces every `var(--wsb-brand-)` in a serialized state string with + * the swatch's literal hex, so un-branding/deleting a brand colour never leaves + * an element without a colour. `--wsb-brand-1` must not match `--wsb-brand-12` + * (the closing paren is part of the needle). Unchanged string when the hex is + * invalid or there is nothing to replace. + */ +export function replaceBrandRef(serialized: string, slot: number, hex: string): string { + const h = normalizeHex(hex); + if (!h) return serialized; + return serialized.split(brandTokenRef(slot)).join(h); +} From 150c84065bbaf0cdc3f2962f86da900321f0e3e0 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:57:17 -0800 Subject: [PATCH 12/19] TokenSwatchRow: saved colours after the site tokens (brand = live ring + token ref) Co-Authored-By: Claude Sonnet 5.5 --- .../right/styles/TokenSwatchRow.test.tsx | 58 +++++++++++++++++++ .../panels/right/styles/TokenSwatchRow.tsx | 30 ++++++++++ craft/src/styles/editor.css | 8 +++ 3 files changed, 96 insertions(+) diff --git a/craft/src/panels/right/styles/TokenSwatchRow.test.tsx b/craft/src/panels/right/styles/TokenSwatchRow.test.tsx index 789430a..bb5e186 100644 --- a/craft/src/panels/right/styles/TokenSwatchRow.test.tsx +++ b/craft/src/panels/right/styles/TokenSwatchRow.test.tsx @@ -100,4 +100,62 @@ describe('TokenSwatchRow', () => { act(() => { ctx.current!.updateDesign({ primaryColor: '#ff0000' }); }); expect(swatches()[0].style.background).toBe('rgb(255, 0, 0)'); }); + + describe('saved colours', () => { + const saved = [ + { id: 'a', label: 'Logo blue', hex: '#15202c', brand: true, slot: 2 }, + { id: 'b', label: 'Sand', hex: '#d2b48c', brand: false }, + ]; + const withSaved = (value: string | undefined, onSelect: (v: string) => void) => { + const ctx: { current: SiteDesignContextValue | null } = { current: null }; + render( + + + + , + ); + act(() => { ctx.current!.updateDesign({ swatches: saved }); }); + }; + const savedBtns = () => Array.from(container!.querySelectorAll('button[data-swatch]')); + + test('shown after the 11 token swatches; title = label + hex; brand shows a live ring', () => { + withSaved(undefined, () => {}); + const all = swatches(); + expect(all.length).toBe(COLOR_TOKENS.length + 2); + expect(all.slice(0, COLOR_TOKENS.length).every((b) => b.dataset.token)).toBe(true); + const [brand, plain] = savedBtns(); + expect(brand.title).toContain('Logo blue'); + expect(brand.title).toContain('#15202c'); + expect(brand.title).toContain('var(--wsb-brand-2)'); + expect(brand.classList.contains('token-swatch--live')).toBe(true); + expect(brand.dataset.live).toBe('true'); + expect(plain.title).toBe('Sand #d2b48c'); + expect(plain.classList.contains('token-swatch--live')).toBe(false); + expect(brand.style.background).toBe('rgb(21, 32, 44)'); + }); + + test('brand swatch selects var(--wsb-brand-N); plain selects its hex', () => { + const onSelect = vi.fn(); + withSaved(undefined, onSelect); + const [brand, plain] = savedBtns(); + act(() => { brand.click(); }); + act(() => { plain.click(); }); + expect(onSelect.mock.calls.map((c) => c[0])).toEqual(['var(--wsb-brand-2)', '#d2b48c']); + }); + + test('pressed when the value equals what the swatch stores', () => { + withSaved('var(--wsb-brand-2)', () => {}); + expect(savedBtns().map((b) => b.getAttribute('aria-pressed'))).toEqual(['true', 'false']); + }); + + test('plain hex swatch pressed on a case-insensitive hex match', () => { + withSaved('#D2B48C', () => {}); + expect(savedBtns().map((b) => b.getAttribute('aria-pressed'))).toEqual(['false', 'true']); + }); + + test('the group is labelled', () => { + withSaved(undefined, () => {}); + expect(container!.querySelector('[role="group"]')!.getAttribute('aria-label')).toBe('Site colors'); + }); + }); }); diff --git a/craft/src/panels/right/styles/TokenSwatchRow.tsx b/craft/src/panels/right/styles/TokenSwatchRow.tsx index 9a73d79..d362fb5 100644 --- a/craft/src/panels/right/styles/TokenSwatchRow.tsx +++ b/craft/src/panels/right/styles/TokenSwatchRow.tsx @@ -1,6 +1,14 @@ import React from 'react'; import { useSiteDesign } from '../../../state/SiteDesignContext'; import { COLOR_TOKENS, TokenKey, tokenRef, resolveTokenValue } from '../../../utils/tokens'; +import { swatchValue, normalizeHex, type SavedSwatch } from '../../../utils/swatches'; + +/** Hover/aria text for a saved swatch: label + hex (+ the live token for brand colours). */ +export function savedSwatchTitle(s: SavedSwatch): string { + const base = s.label.trim() ? `${s.label.trim()} ${s.hex}` : s.hex; + const ref = swatchValue(s); + return ref !== s.hex ? `${base} (live brand colour, ${ref})` : base; +} export interface TokenSwatchRowProps { /** The control's current value; the swatch whose `var(--wsb-*)` ref equals it is shown pressed. */ @@ -25,6 +33,8 @@ export const TokenSwatchRow: React.FC = ({ }) => { const { design } = useSiteDesign(); const current = value?.trim(); + const saved: SavedSwatch[] = Array.isArray(design.swatches) ? design.swatches : []; + const currentHex = current ? normalizeHex(current) : null; return (
{label}
@@ -46,6 +56,26 @@ export const TokenSwatchRow: React.FC = ({ /> ); })} + {saved.map((sw) => { + const stored = swatchValue(sw); + const live = stored !== sw.hex; + const pressed = live ? current === stored : !!currentHex && currentHex === sw.hex.toLowerCase(); + const title = savedSwatchTitle(sw); + return ( +
); diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index 270ddba..26aa5af 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -3382,3 +3382,11 @@ 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); +} From c34690d08bdda1d61d329453c06f9f45e3c84e93 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:58:55 -0800 Subject: [PATCH 13/19] Colour fields: hex validation, brand chips, pick tools, save colour, inline contrast prop; gradient parse accepts brand refs Co-Authored-By: Claude Sonnet 5.5 --- .../panels/right/styles/ColorPickTools.tsx | 89 +++++++++ .../right/styles/color-fields.pick.test.tsx | 176 ++++++++++++++++++ craft/src/panels/right/styles/shared.tsx | 50 ++++- craft/src/styles/editor.css | 15 ++ craft/src/utils/color-input.test.ts | 24 +++ craft/src/utils/color-input.ts | 22 +++ 6 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 craft/src/panels/right/styles/ColorPickTools.tsx create mode 100644 craft/src/panels/right/styles/color-fields.pick.test.tsx create mode 100644 craft/src/utils/color-input.test.ts create mode 100644 craft/src/utils/color-input.ts diff --git a/craft/src/panels/right/styles/ColorPickTools.tsx b/craft/src/panels/right/styles/ColorPickTools.tsx new file mode 100644 index 0000000..41538d5 --- /dev/null +++ b/craft/src/panels/right/styles/ColorPickTools.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import { useSiteDesign } from '../../../state/SiteDesignContext'; +import { addSwatch, findSwatchByHex, normalizeHex, MAX_SWATCHES } from '../../../utils/swatches'; +import { matchSiteColor, pickFromCanvas, pickFromScreen, screenPickKind } from '../../../utils/color-pick'; +import { resolveTokenValue } from '../../../utils/tokens'; +import { ImagePickModal } from './ImagePickModal'; + +export interface ColorPickToolsProps { + /** The field's current value (hex, token ref or brand ref). */ + value: string; + /** Receives the picked value: a site/brand reference (when `allowRefs` and one matches) or the raw hex. */ + onPick: (value: string) => void; + /** + * Whether a pick may become a `var(--wsb-*)` reference. False for the Site + * Design colours themselves: they are the token SOURCE, a reference there + * would be a cycle. + */ + allowRefs?: boolean; +} + +/** + * Compact "get this colour from somewhere" row shown under every colour + * field: pick from the page, from the screen (Chromium only), from an image, + * and save the current colour to the site's saved colours. + */ +export const ColorPickTools: React.FC = ({ value, onPick, allowRefs = true }) => { + const { design, updateDesign } = useSiteDesign(); + const [msg, setMsg] = useState(''); + const [busy, setBusy] = useState(false); + const [imageOpen, setImageOpen] = useState(false); + const swatches = Array.isArray(design.swatches) ? design.swatches : []; + + const literal = normalizeHex(resolveTokenValue(value || '', design)); + const alreadySaved = !!literal && !!findSwatchByHex(swatches, literal); + const canSave = !!literal && !alreadySaved && swatches.length < MAX_SWATCHES; + + const applyHex = (hex: string) => { + const ref = allowRefs ? matchSiteColor(hex, design)?.ref : undefined; + onPick(ref ?? hex); + }; + + const run = async (kind: 'page' | 'screen') => { + setMsg(''); + setBusy(true); + try { + if (kind === 'page') { + const r = await pickFromCanvas({ design }); + if (r.ok) { + const cand = r.candidates.find((c) => c.hex === r.hex && c.match); + onPick(allowRefs && cand?.match ? cand.match.ref : r.hex); + } else if (r.reason !== 'cancelled') setMsg(r.message); + } else { + const r = await pickFromScreen(); + if (r.ok) applyHex(r.hex); + else if (r.reason !== 'cancelled') setMsg(r.message); + } + } finally { + setBusy(false); + } + }; + + return ( +
+
+ + {screenPickKind() === 'eyedropper' && ( + + )} + + +
+ {msg &&
{msg}
} + {imageOpen && ( + { applyHex(hex); setImageOpen(false); }} + onClose={() => setImageOpen(false)} + /> + )} +
+ ); +}; diff --git a/craft/src/panels/right/styles/color-fields.pick.test.tsx b/craft/src/panels/right/styles/color-fields.pick.test.tsx new file mode 100644 index 0000000..019874d --- /dev/null +++ b/craft/src/panels/right/styles/color-fields.pick.test.tsx @@ -0,0 +1,176 @@ +import { describe, test, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { ColorPickerField, ColorSwatchGrid, GradientSwatchGrid } from './shared'; +import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue } from '../../../state/SiteDesignContext'; + +const pickFromCanvas = vi.fn(); +const pickFromScreen = vi.fn(); +let screenKind: 'eyedropper' | 'none' = 'none'; +vi.mock('../../../utils/color-pick', async (orig) => { + const real = await orig(); + return { ...real, pickFromCanvas: (...a: unknown[]) => pickFromCanvas(...a), pickFromScreen: (...a: unknown[]) => pickFromScreen(...a), screenPickKind: () => screenKind }; +}); + +let container: HTMLDivElement | null = null; +let root: Root; +const ctx: { current: SiteDesignContextValue | null } = { current: null }; +const Probe: React.FC = () => { ctx.current = useSiteDesign(); return null; }; +function render(ui: React.ReactElement, swatches?: unknown[]) { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { root = createRoot(container!); root.render({ui}); }); + if (swatches) act(() => { ctx.current!.updateDesign({ swatches: swatches as never }); }); +} +afterEach(() => { + vi.useRealTimers(); + pickFromCanvas.mockReset(); pickFromScreen.mockReset(); screenKind = 'none'; + if (!container) return; + act(() => { root.unmount(); }); + container.remove(); + container = null; +}); + +const hexBox = () => container!.querySelector('input[type="text"]')!; +const chip = () => container!.querySelector('.token-chip'); +const btn = (text: string) => Array.from(container!.querySelectorAll('button')).find((b) => b.textContent?.trim() === text) as HTMLButtonElement | undefined; +function type(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!; + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); +} +const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); }; + +const BRAND = { id: 'b1', label: 'Logo blue', hex: '#15202c', brand: true, slot: 2 }; + +describe('hex validation', () => { + test('invalid text is not committed and shows a red hint; valid text clears it and commits normalised', () => { + vi.useFakeTimers(); + const onChange = vi.fn(); + render(); + act(() => { type(hexBox(), '#12'); }); + act(() => { vi.advanceTimersByTime(400); }); + expect(onChange).not.toHaveBeenCalled(); + const err = container!.querySelector('[role="alert"]')!; + expect(err.textContent).toMatch(/hex/i); + act(() => { type(hexBox(), 'ABCDEF'); }); + act(() => { vi.advanceTimersByTime(400); }); + expect(onChange).toHaveBeenCalledWith('#abcdef'); + expect(container!.querySelector('[role="alert"]')).toBeNull(); + }); + test('ColorSwatchGrid validates too', () => { + vi.useFakeTimers(); + const onSelect = vi.fn(); + render(); + act(() => { type(hexBox(), 'zzz'); }); + act(() => { vi.advanceTimersByTime(400); }); + expect(onSelect).not.toHaveBeenCalled(); + expect(container!.querySelector('[role="alert"]')).not.toBeNull(); + }); +}); + +describe('brand references', () => { + test('chip shows the swatch label, or "Brand N" when unlabelled; the hex box shows the live hex', () => { + render( {}} />, [BRAND]); + expect(chip()!.textContent).toBe('Logo blue'); + expect(hexBox().value).toBe('#15202c'); + }); + test('unlabelled brand colour -> Brand N', () => { + render( {}} />, [{ ...BRAND, label: '' }]); + expect(chip()!.textContent).toBe('Brand 2'); + }); + test('gradient custom builder parses a brand ref', () => { + render( {}} />, [BRAND]); + act(() => { btn('Custom Gradient')?.click() ?? Array.from(container!.querySelectorAll('button')).find((b) => b.textContent?.includes('Custom Gradient'))!.click(); }); + const boxes = Array.from(container!.querySelectorAll('input[type="text"]')).map((i) => i.value); + expect(boxes).toContain('#15202c'); + }); +}); + +describe('save colour', () => { + test('adds the field literal hex to the saved colours, then disables', () => { + render( {}} />); + const b = btn('Save colour')!; + expect(b.disabled).toBe(false); + act(() => { b.click(); }); + expect(ctx.current!.design.swatches.map((s) => s.hex)).toEqual(['#abcdef']); + expect(btn('Save colour')!.disabled).toBe(true); + }); + test('a token value saves its resolved hex; a gradient cannot be saved', () => { + render( {}} />); + act(() => { btn('Save colour')!.click(); }); + expect(ctx.current!.design.swatches[0].hex).toBe('#3b82f6'); + }); +}); + +describe('pick tools', () => { + test('Pick from screen only exists where the screen eyedropper does', () => { + render( {}} />); + expect(btn('Pick from page')).toBeDefined(); + expect(btn('Pick from image')).toBeDefined(); + expect(btn('Pick from screen')).toBeUndefined(); + act(() => root.unmount()); container!.remove(); container = null; + screenKind = 'eyedropper'; + render( {}} />); + expect(btn('Pick from screen')).toBeDefined(); + }); + test('Pick from page uses the site/brand reference when the pick matches one', async () => { + const onChange = vi.fn(); + pickFromCanvas.mockResolvedValue({ ok: true, hex: '#15202c', source: 'canvas', candidates: [{ kind: 'background', hex: '#15202c', match: { kind: 'swatch', ref: 'var(--wsb-brand-2)', label: 'Logo blue' } }] }); + render(); + await act(async () => { btn('Pick from page')!.click(); }); + await flush(); + expect(onChange).toHaveBeenCalledWith('var(--wsb-brand-2)'); + }); + test('without a match the raw hex is used', async () => { + const onChange = vi.fn(); + pickFromCanvas.mockResolvedValue({ ok: true, hex: '#010203', source: 'canvas', candidates: [{ kind: 'background', hex: '#010203' }] }); + render(); + await act(async () => { btn('Pick from page')!.click(); }); + await flush(); + expect(onChange).toHaveBeenCalledWith('#010203'); + }); + test('Site Design token fields (showTokens=false) never store a token ref from a pick', async () => { + const onChange = vi.fn(); + pickFromCanvas.mockResolvedValue({ ok: true, hex: '#3b82f6', source: 'canvas', candidates: [{ kind: 'background', hex: '#3b82f6', match: { kind: 'token', ref: 'var(--wsb-primary)', label: 'Primary' } }] }); + render(); + await act(async () => { btn('Pick from page')!.click(); }); + await flush(); + expect(onChange).toHaveBeenCalledWith('#3b82f6'); + }); + test('a failed pick shows the message; a cancelled one is silent', async () => { + pickFromCanvas.mockResolvedValue({ ok: false, reason: 'error', message: 'Could not read it.' }); + render( {}} />); + await act(async () => { btn('Pick from page')!.click(); }); + await flush(); + expect(container!.textContent).toContain('Could not read it.'); + }); + test('Pick from screen applies the eyedropper hex', async () => { + screenKind = 'eyedropper'; + const onChange = vi.fn(); + pickFromScreen.mockResolvedValue({ ok: true, hex: '#a1b2c3', source: 'eyedropper' }); + render(); + await act(async () => { btn('Pick from screen')!.click(); }); + await flush(); + expect(onChange).toHaveBeenCalledWith('#a1b2c3'); + }); +}); + +describe('inline contrast', () => { + test('ColorPickerField renders a ContrastNote when contrastWith is given and the pair fails', () => { + render( {}} contrastWith="#ffffff" />); + expect(container!.querySelector('.contrast-note--fail')).not.toBeNull(); + }); + test('passing pair and gradient background show nothing / unchecked only', () => { + render( {}} contrastWith="#ffffff" />); + expect(container!.querySelector('.contrast-note')).toBeNull(); + }); + test('the suggestion applies through onChange', () => { + const onChange = vi.fn(); + render(); + act(() => { (container!.querySelector('.contrast-note__use') as HTMLButtonElement).click(); }); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0]).toMatch(/^#[0-9a-f]{6}$/); + }); +}); diff --git a/craft/src/panels/right/styles/shared.tsx b/craft/src/panels/right/styles/shared.tsx index a5ca0b0..674423a 100644 --- a/craft/src/panels/right/styles/shared.tsx +++ b/craft/src/panels/right/styles/shared.tsx @@ -13,6 +13,10 @@ 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 { brandSlotOf } from '../../../utils/swatches'; import { useDevice } from '../../../state/DeviceContext'; import { RESPONSIVE_STYLE_KEYS } from '../../../utils/responsive-style'; import type { DeviceMode } from '../../../types'; @@ -34,7 +38,12 @@ 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 }; } @@ -69,6 +78,16 @@ export const SectionLabel: React.FC = ({ children }) => ( ); +/** Hex text box state shared by ColorPickerField / ColorSwatchGrid: only valid colour text is committed; invalid text keeps showing with a red hint. */ +function useColorText(resolved: string, commit: (v: string) => void) { + const guarded = (v: string) => { const r = parseColorInput(v); if (r.ok) commit(r.value); }; + const hex = useDebouncedCommit(resolved, guarded); + const invalid = !parseColorInput(hex.local).ok; + return { hex, invalid }; +} +const HexError: React.FC<{ show: boolean }> = ({ show }) => + show ?
Enter a hex colour like #3b82f6 (3, 4, 6 or 8 digits).
: null; + interface ColorSwatchGridProps { colors: { label: string; value: string }[]; activeValue: string | undefined; @@ -82,7 +101,7 @@ interface ColorSwatchGridProps { } export const ColorSwatchGrid: React.FC = ({ colors, activeValue, onSelect, onContinuous, showTokens = true }) => { const { resolved, nativeValue, tokenLabel } = useResolvedColor(activeValue); - const hex = useDebouncedCommit(resolved, onSelect); + const { hex, invalid } = useColorText(resolved, onSelect); return (
{showTokens && } @@ -104,6 +123,8 @@ export const ColorSwatchGrid: React.FC = ({ colors, active />
+ +
{colors.map((c) => (
); }; diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index 26aa5af..9a3f9f1 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -3390,3 +3390,18 @@ body { .token-swatch--live.active { box-shadow: 0 0 0 2px var(--color-bg-surface, #18181b), 0 0 0 4px var(--color-accent); } + +/* Colour pick tools (under every colour field) */ +.color-tool-btn { + font-size: 10px; + line-height: 1.2; + padding: 3px 7px; + color: var(--color-text-muted); + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + cursor: pointer; +} +.color-tool-btn:hover:not(:disabled) { color: var(--color-text); border-color: var(--color-text-muted); } +.color-tool-btn:disabled { opacity: 0.5; cursor: default; } +.color-hex-error { font-size: 11px; color: #ef4444; margin-top: 3px; } diff --git a/craft/src/utils/color-input.test.ts b/craft/src/utils/color-input.test.ts new file mode 100644 index 0000000..9384133 --- /dev/null +++ b/craft/src/utils/color-input.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from 'vitest'; +import { parseColorInput } from './color-input'; + +describe('parseColorInput', () => { + test('hex with or without #, any case, 3/4/6/8 digits, normalised', () => { + expect(parseColorInput('#ABCDEF')).toEqual({ ok: true, value: '#abcdef' }); + expect(parseColorInput('abc')).toEqual({ ok: true, value: '#abc' }); + expect(parseColorInput(' 12345678 ')).toEqual({ ok: true, value: '#12345678' }); + expect(parseColorInput('#abcd')).toEqual({ ok: true, value: '#abcd' }); + }); + test('rejects partial / garbage hex', () => { + for (const bad of ['#12', '#12345', '#1234567', '#gggggg', 'red!', '#', '12 34', 'url(x)', 'var(--x)']) { + expect(parseColorInput(bad).ok, bad).toBe(false); + } + }); + test('keeps site token and brand refs, transparent, rgb()/hsl(); empty clears', () => { + expect(parseColorInput('var(--wsb-primary)')).toEqual({ ok: true, value: 'var(--wsb-primary)' }); + expect(parseColorInput('var(--wsb-brand-2)')).toEqual({ ok: true, value: 'var(--wsb-brand-2)' }); + expect(parseColorInput('Transparent')).toEqual({ ok: true, value: 'transparent' }); + expect(parseColorInput('rgba(0, 0, 0, 0.5)')).toEqual({ ok: true, value: 'rgba(0, 0, 0, 0.5)' }); + expect(parseColorInput('hsl(120 40% 50%)')).toEqual({ ok: true, value: 'hsl(120 40% 50%)' }); + expect(parseColorInput('')).toEqual({ ok: true, value: '' }); + }); +}); diff --git a/craft/src/utils/color-input.ts b/craft/src/utils/color-input.ts new file mode 100644 index 0000000..226a292 --- /dev/null +++ b/craft/src/utils/color-input.ts @@ -0,0 +1,22 @@ +import { normalizeHex } from './swatches'; +import { isTokenRef } from './tokens'; + +/** + * What a colour text box may commit. Hex (with or without '#', normalised to + * lowercase '#...'), site/brand token refs, `transparent`, simple rgb()/hsl() + * functions, or empty (a deliberate clear). Anything else is not committed. + */ +export type ColorInputResult = { ok: true; value: string } | { ok: false }; + +const FN_RE = /^(?:rgb|rgba|hsl|hsla)\(\s*[-\d.%\s,/]+\)$/i; + +export function parseColorInput(text: string): ColorInputResult { + const t = String(text ?? '').trim(); + if (t === '') return { ok: true, value: '' }; + const isRef: boolean = isTokenRef(t); + if (isRef) return { ok: true, value: t }; + if (t.toLowerCase() === 'transparent') return { ok: true, value: 'transparent' }; + if (FN_RE.test(t)) return { ok: true, value: t }; + const h = normalizeHex(t); + return h ? { ok: true, value: h } : { ok: false }; +} From faaa8a87a04bd94fd16f1eda3344b5c07efac3fb Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 10:00:51 -0800 Subject: [PATCH 14/19] Site Design: SwatchManager (saved colours, brand toggle, guarded delete/un-brand, use-as-site-colour, pick + palette) Co-Authored-By: Claude Sonnet 5.5 --- craft/src/panels/right/SwatchManager.test.tsx | 261 +++++++++++++++ craft/src/panels/right/SwatchManager.tsx | 313 ++++++++++++++++++ 2 files changed, 574 insertions(+) create mode 100644 craft/src/panels/right/SwatchManager.test.tsx create mode 100644 craft/src/panels/right/SwatchManager.tsx diff --git a/craft/src/panels/right/SwatchManager.test.tsx b/craft/src/panels/right/SwatchManager.test.tsx new file mode 100644 index 0000000..be63755 --- /dev/null +++ b/craft/src/panels/right/SwatchManager.test.tsx @@ -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(); + 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 }) => ( +
+ + +
+ ), +})); + +let h: EditorHarness; +let design: SiteDesignContextValue; +let pages: ReturnType; +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( + + + + + + + , + ); + h.act(() => { design.updateDesign({ swatches: swatches as never }); }); +} +const q = (sel: string) => h.container.querySelector(sel)!; +const qa = (sel: string) => Array.from(h.container.querySelectorAll(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'); + }); +}); diff --git a/craft/src/panels/right/SwatchManager.tsx b/craft/src/panels/right/SwatchManager.tsx new file mode 100644 index 0000000..c7e9509 --- /dev/null +++ b/craft/src/panels/right/SwatchManager.tsx @@ -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 = { 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 = ({ 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 ( +
+
+ + onLabel(e.target.value)} + style={{ ...inputStyle, flex: 1, minWidth: 0 }} + /> + +
+
+ change(e.target.value)} + style={{ ...inputStyle, width: 92, fontFamily: 'monospace', borderColor: valid ? undefined : '#ef4444' }} + /> + + {live && ( + live + )} + +
+ {!valid &&
{HEX_HINT}
} + {confirm && ( +
+
+ {confirm.kind === 'slot' ? ( + <>This recolours the whole site: every place that uses {COLOR_TOKENS.find((t) => t.key === confirm.token)!.label} becomes {sw.hex}. + ) : ( + <> + {sw.label.trim() ? {sw.label.trim()} : '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 {sw.hex} but stop following this swatch. + + )} +
+
+ + +
+
+ )} +
+ ); +}; + +/* ---------- 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(null); + const [chooser, setChooser] = useState(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 => { + 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); + 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 ( +
+ +

+ Labelled colours that appear in every colour field. Mark one as a Brand colour and editing it recolours everything that uses it. +

+ + {swatches.length === 0 &&
No saved colours yet.
} + {swatches.map((sw) => ( + 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)} + /> + ))} + +
+ { setNewHex(e.target.value); setAddError(false); }} + onKeyDown={(e) => { if (e.key === 'Enter') submitNew(); }} + style={{ ...inputStyle, width: 82, fontFamily: 'monospace', borderColor: addError ? '#ef4444' : undefined }} + /> + setNewLabel(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') submitNew(); }} + style={{ ...inputStyle, flex: 1, minWidth: 0 }} + /> + +
+ {addError &&
{HEX_HINT}
} + +
+ + {hasScreen && } + + +
+ {!hasScreen &&
{NO_SCREEN_HINT}
} + + {chooser && ( +
+
Which colour from that spot?
+ {chooser.map((c) => ( + + ))} + +
+ )} + + {note &&
{note}
} + + {imageOpen && { addPicked(hex); setImageOpen(false); }} onClose={() => setImageOpen(false)} />} + {paletteOpen && ( + s.hex)} + onAdd={(hex, label) => { add(hex, label ?? ''); }} + onClose={() => setPaletteOpen(false)} + /> + )} +
+ ); +}; From dcf9c92fee5f4c69234b473ffce739fe92e6c295 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 10:01:21 -0800 Subject: [PATCH 15/19] Site Design panel: Saved colours section + inline contrast on text/muted/link/primary-button Co-Authored-By: Claude Sonnet 5.5 --- .../right/SiteDesignPanel.colours.test.tsx | 62 +++++++++++++++++++ craft/src/panels/right/SiteDesignPanel.tsx | 31 +++++++++- 2 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 craft/src/panels/right/SiteDesignPanel.colours.test.tsx diff --git a/craft/src/panels/right/SiteDesignPanel.colours.test.tsx b/craft/src/panels/right/SiteDesignPanel.colours.test.tsx new file mode 100644 index 0000000..9a72547 --- /dev/null +++ b/craft/src/panels/right/SiteDesignPanel.colours.test.tsx @@ -0,0 +1,62 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import React from 'react'; +import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness'; +import { EditorConfigProvider } from '../../state/EditorConfigContext'; +import { PageProvider } from '../../state/PageContext'; +import { SaveGuardProvider } from '../../state/SaveGuardContext'; +import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue } from '../../state/SiteDesignContext'; +import { SiteDesignPanel } from './SiteDesignPanel'; + +let h: EditorHarness; +let design: SiteDesignContextValue; +const Probe: React.FC = () => { design = useSiteDesign(); return null; }; +afterEach(() => { h?.unmount(); document.body.innerHTML = ''; }); + +function mount() { + h = renderEditorHarness(); + h.mountChild( + + + , + ); +} +const advanced = () => h.act(() => { Array.from(h.container.querySelectorAll('button')).find((b) => b.textContent?.trim() === 'advanced')!.click(); }); +const notes = () => Array.from(h.container.querySelectorAll('.contrast-note--fail')).map((n) => n.textContent ?? ''); + +describe('SiteDesignPanel colours', () => { + test('has a Saved colours section with the pick buttons', () => { + mount(); + expect(h.container.textContent).toContain('Saved colours'); + expect(Array.from(h.container.querySelectorAll('button')).some((b) => b.textContent === 'Palette from image')).toBe(true); + }); + + test('defaults: only the genuinely failing default pairs (white on #3b82f6, #3b82f6 link on white = 3.68:1) are called out', () => { + mount(); + advanced(); + const n = notes(); + expect(n.length).toBe(2); + expect(n[0]).toContain('Button text on primary: Low contrast 3.68:1'); + expect(n[1]).toContain('Link on background: Low contrast 3.68:1'); + }); + + test('low-contrast text / muted / link / primary-button pairs are called out where picked', () => { + mount(); + h.act(() => { design.updateDesign({ textColor: '#eeeeee', mutedTextColor: '#f0f0f0', linkColor: '#fafafa', primaryColor: '#ffffff' }); }); + advanced(); + const all = notes().join(' | '); + expect(all).toContain('Text on background: Low contrast'); + expect(all).toContain('Muted text on background: Low contrast'); + expect(all).toContain('Link on background: Low contrast'); + expect(all).toContain('Button text on primary: Low contrast'); + }); + + test('"Use #xxxxxx" on the text colour applies the suggestion', () => { + mount(); + h.act(() => { design.updateDesign({ textColor: '#eeeeee' }); }); + advanced(); + const use = h.container.querySelector('.contrast-note__use')!; + h.act(() => { use.click(); }); + expect(design.design.textColor).toMatch(/^#[0-9a-f]{6}$/); + expect(design.design.textColor).not.toBe('#eeeeee'); + }); +}); diff --git a/craft/src/panels/right/SiteDesignPanel.tsx b/craft/src/panels/right/SiteDesignPanel.tsx index efa7311..030a595 100644 --- a/craft/src/panels/right/SiteDesignPanel.tsx +++ b/craft/src/panels/right/SiteDesignPanel.tsx @@ -9,6 +9,8 @@ import { AssetPicker } from '../../ui/AssetPicker'; import { inputStyle, ColorPickerField } from './styles/shared'; import { openProfileWizard } from '../sitesmith/ProfileWizard'; import { PALETTES, paletteUpdates, paletteMatches } from '../../constants/palettes'; +import { SwatchManager } from './SwatchManager'; +import { siteColorPairs } from '../../utils/contrast'; type DesignTab = 'basic' | 'advanced'; @@ -20,11 +22,26 @@ interface ColorFieldProps { label: string; value: string; onChange: (value: string) => void; + /** Id of a site colour pair (utils/contrast siteColorPairs) this field belongs to. */ + pair?: 'text-bg' | 'muted-bg' | 'link-bg' | 'button-primary'; } -const ColorField: React.FC = ({ label, value, onChange }) => ( - -); +const ColorField: React.FC = ({ label, value, onChange, pair }) => { + const { design } = useSiteDesign(); + const p = pair ? siteColorPairs(design).find((x) => x.id === pair) : undefined; + // The other side of the pair is a hex in the design (these fields are the token source). + // Text/muted/link are the foreground against the background; the button pair is the + // primary colour (the background of the button) against the page background (its text). + const other = p ? design.backgroundColor : undefined; + return ( + + ); +}; /* ---------- Try a palette ---------- */ @@ -299,6 +316,7 @@ export const SiteDesignPanel: React.FC = () => { updateDesign({ primaryColor: v })} /> @@ -324,6 +342,7 @@ export const SiteDesignPanel: React.FC = () => { /> updateDesign({ linkColor: v })} /> @@ -335,6 +354,7 @@ export const SiteDesignPanel: React.FC = () => { <> updateDesign({ primaryColor: v })} /> @@ -370,11 +390,13 @@ export const SiteDesignPanel: React.FC = () => { /> updateDesign({ textColor: v })} /> updateDesign({ mutedTextColor: v })} /> @@ -400,6 +422,7 @@ export const SiteDesignPanel: React.FC = () => { /> updateDesign({ linkColor: v })} /> @@ -420,6 +443,8 @@ export const SiteDesignPanel: React.FC = () => { )} + + {/* Reset button */}
= ({ selectedId, nodePr label="Hover Background" value={nodeProps.hoverBg || ''} onChange={(v) => setPropContinuous('hoverBg', v)} + contrastWith={nodeProps.hoverColor || ''} + contrastRole="bg" + contrastLabel="Hover text on its colour" /> setPropContinuous('hoverColor', v)} + contrastWith={nodeProps.hoverBg || (style.backgroundColor as string) || ''} + contrastLabel="Hover text on its colour" /> diff --git a/craft/src/panels/right/styles/NavStylePanel.tsx b/craft/src/panels/right/styles/NavStylePanel.tsx index e360321..3204a18 100644 --- a/craft/src/panels/right/styles/NavStylePanel.tsx +++ b/craft/src/panels/right/styles/NavStylePanel.tsx @@ -188,6 +188,26 @@ const PageLinkRow: React.FC<{ link: NavLink; pages: PageData[]; onHide: (pageId: }; /* ---------- NAV / MENU / LOGO / FOOTER ---------- */ +/** + * Inline-contrast props for the nav family's colour pairs that are plainly + * seen together: text vs the bar's own background, CTA text vs the CTA colour. + * Menu's link colours have no known background here, so they get none. + */ +export function navContrast(key: string, nodeProps: Record): { + contrastWith?: string; contrastRole?: 'fg' | 'bg'; contrastLabel?: string; +} { + const bar = nodeProps.backgroundColor || '#ffffff'; + const text = nodeProps.textColor || '#18181b'; + const cta = (nodeProps.ctaColor !== undefined ? nodeProps.ctaColor : nodeProps.ctaBgColor) || 'var(--wsb-primary)'; + if (key === 'textColor' && nodeProps.backgroundColor !== undefined) return { contrastWith: bar, contrastLabel: 'Text on background' }; + if (key === 'backgroundColor' && nodeProps.textColor !== undefined) return { contrastWith: text, contrastRole: 'bg', contrastLabel: 'Text on background' }; + if (key === 'ctaTextColor' && (nodeProps.ctaColor !== undefined || nodeProps.ctaBgColor !== undefined)) return { contrastWith: cta, contrastLabel: 'Button text on its colour' }; + if (key === 'ctaColor' || key === 'ctaBgColor') { + if (nodeProps.ctaTextColor !== undefined) return { contrastWith: nodeProps.ctaTextColor || '#ffffff', contrastRole: 'bg', contrastLabel: 'Button text on its colour' }; + } + return {}; +} + export const NavStylePanel: React.FC = ({ selectedId, nodeProps }) => { const { actions } = useEditor(); const { setProp, setProps, setPropStyle, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId); @@ -520,6 +540,7 @@ export const NavStylePanel: React.FC = ({ selectedId, nodeProps label={f.label} value={nodeProps[f.key] || f.fallback} onChange={(v) => setPropContinuous(f.key, v)} + {...navContrast(f.key, nodeProps)} /> ))} diff --git a/craft/src/panels/right/styles/PricingStylePanel.tsx b/craft/src/panels/right/styles/PricingStylePanel.tsx index 6d9b52d..d80fece 100644 --- a/craft/src/panels/right/styles/PricingStylePanel.tsx +++ b/craft/src/panels/right/styles/PricingStylePanel.tsx @@ -243,8 +243,8 @@ export const PricingStylePanel: React.FC = ({ selectedId, nodeP setPropContinuous('subColor', v)} /> setPropContinuous('featColor', v)} /> setPropContinuous('checkColor', v)} /> - setPropContinuous('btnBg', v)} /> - setPropContinuous('btnColor', v)} /> + setPropContinuous('btnBg', v)} contrastWith={nodeProps.btnColor || '#ffffff'} contrastRole="bg" contrastLabel="Button text on its colour" /> + setPropContinuous('btnColor', v)} contrastWith={nodeProps.btnBg || nodeProps.featuredBg || tokenRef('primary')} contrastLabel="Button text on its colour" /> {/* Box model: margin, padding, border, shadow, opacity */} diff --git a/craft/src/panels/right/styles/TextStylePanel.tsx b/craft/src/panels/right/styles/TextStylePanel.tsx index 2151847..09fa93a 100644 --- a/craft/src/panels/right/styles/TextStylePanel.tsx +++ b/craft/src/panels/right/styles/TextStylePanel.tsx @@ -25,9 +25,11 @@ import { sectionGap, labelStyle, useNodeProp, + useEffectiveBackground, } from './shared'; import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel'; import { OverrideDot } from './OverrideDot'; +import { isLargeText } from '../../../utils/effective-background'; /* Text-transform is a small fixed enum with no natural home in the shared foundation presets (constants/presets.ts is import-only for this @@ -67,6 +69,7 @@ export const TextStylePanel: React.FC = ({ selectedId, nodeProp const dot = (key: string | readonly string[]) => ; const { setProp, setPropStyle, setProps, setPropStyleContinuous } = useNodeProp(selectedId); + const effectiveBg = useEffectiveBackground(selectedId); const isItalic = style.fontStyle === 'italic'; const isUnderline = style.textDecoration === 'underline'; @@ -79,6 +82,9 @@ export const TextStylePanel: React.FC = ({ selectedId, nodeProp colors={TEXT_COLORS} activeValue={style.color as string} onSelect={(v) => setPropStyle('color', v)} + contrastWith={effectiveBg} + contrastLarge={isLargeText(style.fontSize as string, style.fontWeight as string)} + contrastLabel="Text on its background" />
diff --git a/craft/src/panels/right/styles/contrast.panels.test.tsx b/craft/src/panels/right/styles/contrast.panels.test.tsx new file mode 100644 index 0000000..5970f72 --- /dev/null +++ b/craft/src/panels/right/styles/contrast.panels.test.tsx @@ -0,0 +1,83 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import React from 'react'; +import { renderEditorHarness, EditorHarness } from '../../../test-utils/editorHarness'; +import { SiteDesignProvider } from '../../../state/SiteDesignContext'; +import { TextStylePanel } from './TextStylePanel'; +import { ButtonStylePanel } from './ButtonStylePanel'; +import { PricingStylePanel } from './PricingStylePanel'; +import { NavStylePanel } from './NavStylePanel'; + +let h: EditorHarness; +afterEach(() => { h?.unmount(); document.body.innerHTML = ''; }); + +const node = (parent: string | null, name: string, props: Record, nodes: string[] = []) => ({ + type: { resolvedName: name }, isCanvas: name === 'Container', props, displayName: name, custom: {}, hidden: false, nodes, linkedNodes: {}, parent, +}); +const state = (rootStyle: Record) => JSON.stringify({ + ROOT: node(null, 'Container', { style: rootStyle, tag: 'div' }, ['t1']), + t1: node('ROOT', 'TextBlock', { text: 'hi', style: {} }), +}); + +function mount(initialState: string, ui: React.ReactNode) { + h = renderEditorHarness({ initialState }); + h.mountChild({ui}); +} +const failNotes = () => Array.from(h.container.querySelectorAll('.contrast-note--fail')).map((n) => n.textContent ?? ''); +const allNotes = () => Array.from(h.container.querySelectorAll('.contrast-note')).map((n) => n.textContent ?? ''); + +describe('Text / Heading colour vs its effective background', () => { + test('uses the nearest ancestor background: light text on a dark parent passes, on white it fails', () => { + mount(state({ backgroundColor: '#0f172a' }), ); + expect(allNotes()).toEqual([]); + h.unmount(); + mount(state({ backgroundColor: '#ffffff' }), ); + expect(failNotes().length).toBe(1); + expect(failNotes()[0]).toContain('Text on its background'); + }); + test('no background anywhere -> the site Background token (white by default)', () => { + mount(state({}), ); + expect(failNotes().length).toBe(1); + }); + test('a gradient background is reported as not checked, never judged', () => { + mount(state({ background: 'linear-gradient(90deg,#000,#fff)' }), ); + expect(failNotes()).toEqual([]); + expect(allNotes().join('')).toContain('not checked'); + }); + test('large text uses the 3:1 threshold', () => { + // #949494 on white = ~3.03:1: fails normal, passes large. + mount(state({}), ); + expect(failNotes()).toEqual([]); + h.unmount(); + mount(state({}), ); + expect(failNotes().length).toBe(1); + }); +}); + +describe('Button text vs button colour', () => { + test('flags a failing pair on the Button Color control', () => { + mount(state({}), ); + expect(failNotes().some((t) => t.includes('Button text on its colour'))).toBe(true); + }); + test('a passing pair is silent', () => { + mount(state({}), ); + expect(failNotes()).toEqual([]); + }); +}); + +describe('Pricing button text vs background', () => { + test('flags btnColor on btnBg', () => { + mount(state({}), ); + expect(failNotes().some((t) => t.includes('Button text on its colour'))).toBe(true); + }); +}); + +describe('Navbar / Footer text vs background', () => { + test('flags textColor on backgroundColor', () => { + mount(state({}), ); + expect(failNotes().some((t) => t.includes('Text on background'))).toBe(true); + }); + test('Menu link colours (no known background) get no note', () => { + mount(state({}), ); + expect(failNotes()).toEqual([]); + }); +}); diff --git a/craft/src/panels/right/styles/shared.tsx b/craft/src/panels/right/styles/shared.tsx index 674423a..6d446a0 100644 --- a/craft/src/panels/right/styles/shared.tsx +++ b/craft/src/panels/right/styles/shared.tsx @@ -16,6 +16,7 @@ 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'; @@ -48,6 +49,16 @@ export function useResolvedColor(value: string | undefined): { resolved: string; return { resolved, nativeValue, tokenLabel }; } +/** + * The background the selected node's text is read against (see + * utils/effective-background.ts), else the site Background token. Defensive + * about a mocked `useEditor` in unit tests. + */ +export function useEffectiveBackground(selectedId: string): string { + const r = useEditor((state) => ({ bg: effectiveBackgroundOf(state.nodes as never, selectedId) })) as unknown as { bg?: string | null } | undefined; + return r?.bg || tokenRef('bg'); +} + /** Small "linked to a site colour" badge shown beside the hex box. */ export const TokenChip: React.FC<{ label: string | null }> = ({ label }) => label ? {label} : null; @@ -98,8 +109,13 @@ interface ColorSwatchGridProps { onContinuous?: (value: string) => void; /** Show the site-colour (token) row first. Default true. */ showTokens?: boolean; + /** Same inline contrast hint as ColorPickerField. */ + contrastWith?: string; + contrastLarge?: boolean; + contrastLabel?: string; + contrastRole?: 'fg' | 'bg'; } -export const ColorSwatchGrid: React.FC = ({ colors, activeValue, onSelect, onContinuous, showTokens = true }) => { +export const ColorSwatchGrid: React.FC = ({ colors, activeValue, onSelect, onContinuous, showTokens = true, contrastWith, contrastLarge, contrastLabel, contrastRole = 'fg' }) => { const { resolved, nativeValue, tokenLabel } = useResolvedColor(activeValue); const { hex, invalid } = useColorText(resolved, onSelect); return ( @@ -125,6 +141,15 @@ export const ColorSwatchGrid: React.FC = ({ colors, active
+ {contrastWith && ( + + )}
{colors.map((c) => (