Colour picking: native EyeDropper + in-canvas pick mode with brand/token matching, real-browser e2e

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 09:54:33 -08:00
1 parent 54920dff08
commit 179aaebe49
4 files changed
+556

No files matched your search

+1
View File
@@ -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": {
+169
View File
@@ -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 = `<!doctype html><html><head><style>
body{margin:0;font:16px sans-serif}
.canvas-device-frame{--wsb-primary:#3b82f6;background:#ffffff;color:#111827;padding:10px;width:700px}
.box{height:60px;margin:6px 0;padding:4px}
</style></head><body>
<div class="canvas-device-frame">
<div id="tok" class="box" style="background:var(--wsb-primary);color:#ffffff">token bg</div>
<div id="brand" class="box" style="background:#ff6600;color:#111827">brand bg</div>
<div id="sand" class="box" style="background:rgba(200,176,138,1);color:#111827">swatch bg</div>
<div id="plain" class="box" style="background:#123456;color:#fedcba;border:3px solid #0a0b0c">plain + border</div>
<div id="outer" class="box" style="background:#112233;color:#445566"><div id="nested" style="color:#778899"><span id="deep">nested transparent</span></div></div>
<div id="alpha" class="box" style="background:rgba(18,52,86,.5);color:#111827">alpha</div>
<p id="txt" style="color:#6b7280;margin:6px 0">muted text on frame bg</p>
</div>
<div id="chrome" style="padding:10px">editor chrome</div>
</body></html>`;
// Served over http://localhost (a secure context) so EyeDropper availability is reported truthfully.
const server = http.createServer((_q, r) => { r.setHeader('content-type', 'text/html'); r.end(html); });
await new Promise((res) => server.listen(0, '127.0.0.1', res));
const url = `http://localhost:${server.address().port}/`;
async function run(name, launcher, exe) {
const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
const page = await browser.newPage({ viewport: { width: 900, height: 900 } });
const errors = [];
page.on('pageerror', (e) => errors.push(String(e)));
await page.goto(url);
await page.addScriptTag({ content: js });
await page.evaluate((d) => {
window.__design = d;
window.__leaks = [];
for (const el of document.querySelectorAll('.canvas-device-frame, .canvas-device-frame *'))
for (const t of ['click', 'mousedown', 'mouseup', 'pointerdown', 'pointerup'])
el.addEventListener(t, () => window.__leaks.push(t + '#' + el.id));
}, design);
let failed = 0;
const check = (label, ok, detail) => {
if (!ok) failed++;
console.log(`[${name}] ${ok ? 'PASS' : 'FAIL'} ${label}${detail !== undefined ? ': ' + detail : ''}`);
};
const eq = (label, got, want) => check(label, JSON.stringify(got) === JSON.stringify(want), JSON.stringify(got) + (JSON.stringify(got) === JSON.stringify(want) ? '' : ' expected ' + JSON.stringify(want)));
async function pick(id, { hover } = {}) {
await page.evaluate(() => { window.__res = null; cp.pickFromCanvas({ design: window.__design }).then((r) => { window.__res = r; }); });
const box = await page.locator('#' + id).boundingBox();
const x = box.x + 8, y = box.y + 8;
await page.mouse.move(x, y);
let tip = null;
if (hover) tip = await page.evaluate(() => document.querySelector('[data-wsb-pick=tip]')?.textContent ?? null);
await page.mouse.click(x, y);
await page.waitForFunction(() => window.__res !== null, null, { timeout: 3000 });
return { res: await page.evaluate(() => window.__res), tip };
}
const cand = (res, k) => res.candidates.find((c) => c.kind === k);
// 1. token-driven background (CSS variable)
let { res, tip } = await pick('tok', { hover: true });
eq('token bg: hex', res.hex, '#3b82f6');
eq('token bg: background match', cand(res, 'background').match, { kind: 'token', ref: 'var(--wsb-primary)', label: 'Primary' });
eq('token bg: text candidate', cand(res, 'text').hex, '#ffffff');
check('token bg: hover tooltip shows live colour', tip === '#3b82f6', String(tip));
eq('token bg: no border candidate', cand(res, 'border'), undefined);
// 2. brand swatch (case-insensitive hex)
({ res } = await pick('brand'));
eq('brand: hex', res.hex, '#ff6600');
eq('brand: match', cand(res, 'background').match, { kind: 'swatch', ref: 'var(--wsb-brand-3)', label: 'Logo orange' });
// 3. plain saved swatch via rgba(...,1)
({ res } = await pick('sand'));
eq('swatch: hex', res.hex, '#c8b08a');
eq('swatch: match', cand(res, 'background').match, { kind: 'swatch', ref: '#c8b08a', label: 'Sand' });
// 4. plain bg + text + border, no match
({ res } = await pick('plain'));
eq('plain: hex = bg', res.hex, '#123456');
eq('plain: text', cand(res, 'text').hex, '#fedcba');
eq('plain: border', cand(res, 'border').hex, '#0a0b0c');
eq('plain: no matches', res.candidates.map((c) => c.match), [undefined, undefined, undefined]);
// 5. nested transparent child: text colour wins, background = painted ancestor
({ res } = await pick('deep'));
eq('nested: hex = inherited text colour', res.hex, '#778899');
eq('nested: background = nearest painted ancestor', cand(res, 'background').hex, '#112233');
eq('nested: no border', cand(res, 'border'), undefined);
// 6. alpha background -> 8-digit hex
({ res } = await pick('alpha'));
eq('alpha: hex', res.hex, '#12345680');
// 7. text on frame bg (frame background is the painted ancestor, matches Background token)
({ res } = await pick('txt'));
eq('text: hex', res.hex, '#6b7280');
eq('text: text match', cand(res, 'text').match, { kind: 'token', ref: 'var(--wsb-muted)', label: 'Muted text' });
eq('text: background match', cand(res, 'background').match, { kind: 'token', ref: 'var(--wsb-bg)', label: 'Background' });
// 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);
+163
View File
@@ -0,0 +1,163 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest';
import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext';
import {
eyedropperAvailable, parseComputedColor, pickFromCanvas, pickFromScreen, pickWithEyeDropper, rgbToHex, screenPickKind,
} from './color-pick';
const design = (over: Partial<SiteDesign> = {}): SiteDesign => ({ ...DEFAULT_SITE_DESIGN, ...over });
describe('rgbToHex / parseComputedColor', () => {
it('rgbToHex', () => {
expect(rgbToHex(255, 0, 16)).toBe('#ff0010');
expect(rgbToHex(0, 0, 0, 1)).toBe('#000000');
expect(rgbToHex(18, 52, 86, 0.5)).toBe('#12345680');
expect(rgbToHex(300, -4, 1.4)).toBe('#ff0001');
});
it('parses computed rgb()/rgba() in comma and space syntax', () => {
expect(parseComputedColor('rgb(18, 52, 86)')).toEqual({ hex: '#123456', alpha: 1 });
expect(parseComputedColor('rgba(18, 52, 86, 0.5)')).toEqual({ hex: '#12345680', alpha: 0.5 });
expect(parseComputedColor('rgb(18 52 86 / 50%)')).toEqual({ hex: '#12345680', alpha: 0.5 });
expect(parseComputedColor('rgba(0, 0, 0, 0)')).toEqual({ hex: '#00000000', alpha: 0 });
expect(parseComputedColor('transparent')).toEqual({ hex: '#00000000', alpha: 0 });
expect(parseComputedColor('rgba(18, 52, 86, 1)')).toEqual({ hex: '#123456', alpha: 1 });
});
it('rejects junk', () => {
expect(parseComputedColor('')).toBeNull();
expect(parseComputedColor('red')).toBeNull();
expect(parseComputedColor('var(--x)')).toBeNull();
});
});
describe('eyedropper / screen kind', () => {
afterEach(() => { delete (window as unknown as Record<string, unknown>).EyeDropper; });
it('reports unsupported when window.EyeDropper is absent', async () => {
expect(eyedropperAvailable()).toBe(false);
expect(screenPickKind()).toBe('none');
const r = await pickWithEyeDropper();
expect(r).toMatchObject({ ok: false, reason: 'unsupported' });
expect(await pickFromScreen()).toMatchObject({ ok: false, reason: 'unsupported' });
});
it('uses a native EyeDropper (lowercased hex) and maps abort to cancelled', async () => {
(window as unknown as Record<string, unknown>).EyeDropper = class {
open(o?: { signal?: AbortSignal }) {
return new Promise((res, rej) => {
if (o?.signal) o.signal.addEventListener('abort', () => rej(new DOMException('x', 'AbortError')));
else res({ sRGBHex: '#ABCDEF' });
});
}
};
expect(eyedropperAvailable()).toBe(true);
expect(screenPickKind()).toBe('eyedropper');
expect(await pickWithEyeDropper()).toEqual({ ok: true, hex: '#abcdef', source: 'eyedropper' });
const ac = new AbortController();
const p = pickWithEyeDropper(ac.signal);
ac.abort();
expect(await p).toMatchObject({ ok: false, reason: 'cancelled' });
});
});
describe('pickFromCanvas (jsdom, inline styles)', () => {
afterEach(() => { document.body.innerHTML = ''; document.head.querySelectorAll('style[data-wsb-pick]').forEach((s) => s.remove()); });
const mount = () => {
document.body.innerHTML = `
<div class="canvas-device-frame" style="background-color:rgb(255,255,255);color:rgb(17,24,39)">
<section id="card" style="background-color:rgb(59,130,246);color:rgb(255,255,255);border:2px solid rgb(1,2,3)">
<span id="inner">hi</span>
</section>
<p id="plain" style="color:rgb(10,20,30)">x</p>
</div>
<div id="outside">out</div>`;
return (id: string) => document.getElementById(id)!;
};
const click = (el: Element) => el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, clientX: 1, clientY: 1 }));
it('returns background/text/border candidates with default hex = painted background', async () => {
const $ = mount();
const p = pickFromCanvas({ design: design({ primaryColor: '#3b82f6' }) });
click($('card'));
const r = await p;
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.source).toBe('canvas');
expect(r.hex).toBe('#3b82f6');
const by = Object.fromEntries(r.candidates.map((c) => [c.kind, c]));
expect(by.background.hex).toBe('#3b82f6');
expect(by.background.match).toMatchObject({ kind: 'token', ref: 'var(--wsb-primary)' });
expect(by.text.hex).toBe('#ffffff');
expect(by.border.hex).toBe('#010203');
expect(by.border.match).toBeUndefined();
});
it('non-painting element: hex is the text colour; background is the nearest painted ancestor', async () => {
const $ = mount();
const p = pickFromCanvas({ design: design() });
click($('inner'));
const r = await p;
if (!r.ok) throw new Error('expected ok');
expect(r.hex).toBe('#ffffff');
expect(r.candidates.find((c) => c.kind === 'background')!.hex).toBe('#3b82f6');
expect(r.candidates.find((c) => c.kind === 'border')).toBeUndefined();
});
it('prefers a published brand swatch over a token match', async () => {
const $ = mount();
const d = design({
primaryColor: '#3b82f6',
swatches: [{ id: 'a', label: 'Logo blue', hex: '#3B82F6', brand: true, slot: 2 }],
});
const p = pickFromCanvas({ design: d });
click($('card'));
const r = await p;
if (!r.ok) throw new Error('expected ok');
expect(r.candidates[0].match).toEqual({ kind: 'swatch', ref: 'var(--wsb-brand-2)', label: 'Logo blue' });
});
it('matches a non-brand swatch by hex ref', async () => {
const $ = mount();
const p = pickFromCanvas({ design: design({ swatches: [{ id: 'a', label: 'Sky', hex: '#0a141e', brand: false }] }) });
click($('plain'));
const r = await p;
if (!r.ok) throw new Error('expected ok');
expect(r.hex).toBe('#0a141e');
expect(r.candidates.find((c) => c.kind === 'text')!.match).toEqual({ kind: 'swatch', ref: '#0a141e', label: 'Sky' });
});
it('swallows the click (capture phase) so craft nodes are not selected', async () => {
const $ = mount();
let leaked = 0;
$('card').addEventListener('click', () => leaked++);
$('card').addEventListener('mousedown', () => leaked++);
const p = pickFromCanvas({ design: design() });
$('card').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
click($('card'));
await p;
expect(leaked).toBe(0);
});
it('Esc cancels, abort cancels, outside click cancels', async () => {
mount();
let p = pickFromCanvas({ design: design() });
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(await p).toMatchObject({ ok: false, reason: 'cancelled' });
const ac = new AbortController();
p = pickFromCanvas({ design: design(), signal: ac.signal });
ac.abort();
expect(await p).toMatchObject({ ok: false, reason: 'cancelled' });
p = pickFromCanvas({ design: design() });
click(document.getElementById('outside')!);
expect(await p).toMatchObject({ ok: false, reason: 'cancelled' });
});
it('no frame -> error; cleans up its overlay and listeners', async () => {
expect(await pickFromCanvas({ design: design() })).toMatchObject({ ok: false, reason: 'error' });
const $ = mount();
const p = pickFromCanvas({ design: design() });
expect(document.querySelector('[data-wsb-pick]')).not.toBeNull();
click($('plain'));
await p;
expect(document.querySelector('[data-wsb-pick]')).toBeNull();
});
});
+223
View File
@@ -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<PickResult> {
const Ctor = eyeCtor();
if (!Ctor) return fail('unsupported', "This browser can't pick colours from the screen.");
try {
const r = await new Ctor().open(signal ? { signal } : undefined);
const hex = normalizeHex(r.sRGBHex);
return hex ? { ok: true, hex, source: 'eyedropper' } : fail('error', 'The eyedropper returned an unreadable colour.');
} catch (e) {
const name = (e as { name?: string })?.name;
if (name === 'AbortError' || signal?.aborted) return fail('cancelled', CANCELLED);
return fail('error', 'The eyedropper could not pick a colour.');
}
}
/** What "pick from the screen" can do here. Firefox: none (tab capture is not viable, see the spike verdict). */
export function screenPickKind(): 'eyedropper' | 'tab-capture' | 'none' {
return eyedropperAvailable() ? 'eyedropper' : 'none';
}
export function pickFromScreen(signal?: AbortSignal): Promise<PickResult> {
return pickWithEyeDropper(signal);
}
// ------------------------------------------------------------------ canvas pick mode
function readCandidates(el: Element, frame: Element, design: SiteDesign): { candidates: CanvasCandidate[]; hex: string | null } {
const win = el.ownerDocument.defaultView!;
const cs = win.getComputedStyle(el);
const out: CanvasCandidate[] = [];
const add = (kind: CandidateKind, hex: string) => { const m = matchSiteColor(hex, design); out.push(m ? { kind, hex, match: m } : { kind, hex }); };
const own = parseComputedColor(cs.backgroundColor);
const ownPaints = !!own && own.alpha > 0;
// Nearest painted background: the element, else its ancestors up to (and including) the frame.
let bg: string | null = null;
for (let n: Element | null = el; n; n = n === frame ? null : n.parentElement) {
const c = parseComputedColor(win.getComputedStyle(n).backgroundColor);
if (c && c.alpha > 0) { bg = c.hex; break; }
}
if (bg) add('background', bg);
const txt = parseComputedColor(cs.color);
if (txt) add('text', txt.hex);
const bw = parseFloat(cs.borderTopWidth) || 0;
const bc = parseComputedColor(cs.borderTopColor);
if (bw > 0 && cs.borderTopStyle !== 'none' && cs.borderTopStyle !== 'hidden' && bc && bc.alpha > 0) add('border', bc.hex);
const primary = ownPaints && bg ? bg : (txt?.hex ?? bg);
return { candidates: out, hex: primary };
}
const BLOCKED = ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'dblclick', 'auxclick', 'contextmenu', 'dragstart', 'touchstart', 'touchend'];
let activeCancel: (() => void) | null = null;
export function pickFromCanvas(opts: { frameSelector?: string; design: SiteDesign; signal?: AbortSignal }): Promise<CanvasPickResult> {
const { design, signal } = opts;
const selector = opts.frameSelector ?? '.canvas-device-frame';
if (typeof document === 'undefined') return Promise.resolve(fail('unsupported', 'Colour picking needs a browser.'));
const found = document.querySelector(selector);
if (!found) return Promise.resolve(fail('error', "Couldn't find the page canvas to pick from."));
if (signal?.aborted) return Promise.resolve(fail('cancelled', CANCELLED));
const frame: Element = found;
activeCancel?.();
return new Promise<CanvasPickResult>((resolve) => {
const style = document.createElement('style');
style.setAttribute('data-wsb-pick', 'style');
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;
});
}