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
+
+
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 = `
+
+ 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;
+ });
+}