Site Design: SwatchManager (saved colours, brand toggle, guarded delete/un-brand, use-as-site-colour, pick + palette)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 10:00:51 -08:00
1 parent c34690d08b
commit faaa8a87a0
2 files changed
+574

No files matched your search

@@ -0,0 +1,261 @@
import { describe, test, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
import { PageProvider, usePages } from '../../state/PageContext';
import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue } from '../../state/SiteDesignContext';
import { CanvasTokenStyle } from '../../editor/CanvasTokenStyle';
import { SwatchManager } from './SwatchManager';
const pickFromCanvas = vi.fn();
const pickFromScreen = vi.fn();
let screenKind: 'eyedropper' | 'none' = 'none';
vi.mock('../../utils/color-pick', async (orig) => {
const real = await orig<typeof import('../../utils/color-pick')>();
return { ...real, pickFromCanvas: (...a: unknown[]) => pickFromCanvas(...a), pickFromScreen: (...a: unknown[]) => pickFromScreen(...a), screenPickKind: () => screenKind };
});
vi.mock('./styles/PaletteFromImageModal', () => ({
PaletteFromImageModal: (p: { onAdd: (h: string) => void; onClose: () => void }) => (
<div data-testid="palette-modal">
<button data-testid="palette-add-two" onClick={() => { p.onAdd('#111111'); p.onAdd('#222222'); }}>add two</button>
<button onClick={p.onClose}>close</button>
</div>
),
}));
let h: EditorHarness;
let design: SiteDesignContextValue;
let pages: ReturnType<typeof usePages>;
const Probe: React.FC = () => { design = useSiteDesign(); pages = usePages(); return null; };
afterEach(() => { h?.unmount(); document.body.innerHTML = ''; pickFromCanvas.mockReset(); pickFromScreen.mockReset(); screenKind = 'none'; });
const BRAND = { id: 'b1', label: 'Logo blue', hex: '#15202c', brand: true, slot: 1 };
const PLAIN = { id: 'p1', label: 'Sand', hex: '#d2b48c', brand: false };
function mount(swatches: unknown[] = []) {
h = renderEditorHarness();
h.mountChild(
<PageProvider>
<SiteDesignProvider>
<Probe />
<CanvasTokenStyle />
<SwatchManager />
</SiteDesignProvider>
</PageProvider>,
);
h.act(() => { design.updateDesign({ swatches: swatches as never }); });
}
const q = (sel: string) => h.container.querySelector<HTMLElement>(sel)!;
const qa = (sel: string) => Array.from(h.container.querySelectorAll<HTMLElement>(sel));
const row = (id: string) => q(`[data-swatch-row="${id}"]`);
function type(input: HTMLInputElement | HTMLSelectElement, value: string) {
const proto = input instanceof HTMLSelectElement ? window.HTMLSelectElement.prototype : window.HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, 'value')!.set!.call(input, value);
input.dispatchEvent(new Event(input instanceof HTMLSelectElement ? 'change' : 'input', { bubbles: true }));
}
const click = (el: HTMLElement) => h.act(() => { el.click(); });
const flush = async () => { await h.act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
const btn = (text: string) => qa('button').find((b) => b.textContent?.trim() === text) as HTMLButtonElement | undefined;
const hexes = () => design.design.swatches.map((s) => s.hex);
const tokenStyle = () => document.querySelector('style[data-wsb-canvas-tokens]')!.textContent ?? '';
const st = (c: string) => JSON.stringify({ ROOT: { props: { style: { backgroundColor: c, color: c } } } });
function seedPages() {
h.act(() => {
pages.setPagesCraftState([
{ id: 'home', name: 'Home', slug: 'index', craftState: st('#fff') },
{ id: 'p2', name: 'About', slug: 'about', craftState: st('var(--wsb-brand-1)') },
]);
pages.setActivePageIdDirect('home');
pages.setHeaderCraftState(st('var(--wsb-brand-1)'));
});
}
describe('Saved colours list', () => {
test('lists swatches with label + hex', () => {
mount([BRAND, PLAIN]);
expect(qa('[data-swatch-row]').length).toBe(2);
expect((row('b1').querySelector('[data-field="label"]') as HTMLInputElement).value).toBe('Logo blue');
expect((row('b1').querySelector('[data-field="hex"]') as HTMLInputElement).value).toBe('#15202c');
expect((row('p1').querySelector('[data-field="label"]') as HTMLInputElement).maxLength).toBe(40);
});
test('add colour: typed hex (no #) + optional label; invalid is rejected with an inline error', () => {
mount();
h.act(() => { type(q('[data-field="new-hex"]') as HTMLInputElement, 'zz'); });
click(btn('Add colour')!);
expect(hexes()).toEqual([]);
expect(q('[role="alert"]').textContent).toMatch(/hex/i);
h.act(() => { type(q('[data-field="new-hex"]') as HTMLInputElement, 'ABC'); type(q('[data-field="new-label"]') as HTMLInputElement, 'Accent glow'); });
click(btn('Add colour')!);
expect(design.design.swatches.map((s) => [s.hex, s.label])).toEqual([['#abc', 'Accent glow']]);
});
test('editing the hex commits valid values (normalised) and never commits invalid ones', () => {
mount([PLAIN]);
const input = () => row('p1').querySelector('[data-field="hex"]') as HTMLInputElement;
h.act(() => { type(input(), '#12'); });
expect(hexes()).toEqual(['#d2b48c']);
expect(row('p1').querySelector('[role="alert"]')).not.toBeNull();
h.act(() => { type(input(), 'ABCDEF'); });
expect(hexes()).toEqual(['#abcdef']);
expect(row('p1').querySelector('[role="alert"]')).toBeNull();
});
test('renaming works', () => {
mount([PLAIN]);
h.act(() => { type(row('p1').querySelector('[data-field="label"]') as HTMLInputElement, 'Beach'); });
expect(design.design.swatches[0].label).toBe('Beach');
});
});
describe('Brand toggle', () => {
test('marking brand shows the live badge and token name; the 7th is refused with a message', () => {
const six = Array.from({ length: 6 }, (_, i) => ({ id: `s${i}`, label: '', hex: '#00000' + i, brand: false }));
mount([...six, PLAIN]);
for (let i = 0; i < 6; i++) click(row(`s${i}`).querySelector('[data-action="brand"]') as HTMLElement);
expect(design.design.swatches.filter((s) => s.brand).map((s) => s.slot)).toEqual([1, 2, 3, 4, 5, 6]);
expect(row('s0').textContent).toContain('live');
expect((row('s0').querySelector('[data-testid="live-badge"]') as HTMLElement).title).toContain('var(--wsb-brand-1)');
click(row('p1').querySelector('[data-action="brand"]') as HTMLElement);
expect(design.design.swatches.find((s) => s.id === 'p1')!.brand).toBe(false);
expect(h.container.textContent).toContain('You can have up to 6 brand colours');
});
test('editing a brand swatch hex recolours the canvas token style live', () => {
mount([BRAND]);
expect(tokenStyle()).toContain('--wsb-brand-1:#15202c');
h.act(() => { type(row('b1').querySelector('[data-field="hex"]') as HTMLInputElement, '#ff00aa'); });
expect(tokenStyle()).toContain('--wsb-brand-1:#ff00aa');
expect(tokenStyle()).not.toContain('#15202c');
});
});
describe('Deleting / un-branding a referenced brand colour', () => {
test('delete: asks, states the count, and on confirm rewrites every reference to the hex', () => {
mount([BRAND, PLAIN]);
seedPages();
click(row('b1').querySelector('[data-action="delete"]') as HTMLElement);
expect(hexes()).toEqual(['#15202c', '#d2b48c']); // nothing yet
const dlg = q('[role="alertdialog"]');
expect(dlg.textContent).toMatch(/4 places/) /* 2 states x (background + colour) */;
expect(dlg.textContent).toContain('#15202c');
click(dlg.querySelector('[data-action="confirm"]') as HTMLElement);
expect(hexes()).toEqual(['#d2b48c']);
expect(pages.pages.find((p) => p.id === 'p2')!.craftState).toBe(st('#15202c'));
expect(pages.headerPage.craftState).toBe(st('#15202c'));
});
test('cancel leaves everything alone', () => {
mount([BRAND]);
seedPages();
click(row('b1').querySelector('[data-action="delete"]') as HTMLElement);
click(q('[role="alertdialog"] [data-action="cancel"]'));
expect(hexes()).toEqual(['#15202c']);
expect(pages.headerPage.craftState).toContain('var(--wsb-brand-1)');
expect(q('[data-swatch-row="b1"]')).not.toBeNull();
});
test('un-brand: same guard; confirm keeps the swatch (plain) and rewrites references', () => {
mount([BRAND]);
seedPages();
click(row('b1').querySelector('[data-action="brand"]') as HTMLElement);
expect(design.design.swatches[0].brand).toBe(true);
click(q('[role="alertdialog"] [data-action="confirm"]'));
expect(design.design.swatches[0].brand).toBe(false);
expect(pages.pages.find((p) => p.id === 'p2')!.craftState).toBe(st('#15202c'));
});
test('an unreferenced brand colour deletes without asking; a plain one never asks', () => {
mount([BRAND, PLAIN]);
click(row('p1').querySelector('[data-action="delete"]') as HTMLElement);
expect(hexes()).toEqual(['#15202c']);
click(row('b1').querySelector('[data-action="delete"]') as HTMLElement);
expect(q('[role="alertdialog"]')).toBeNull();
expect(hexes()).toEqual([]);
});
});
describe('Use as site colour', () => {
test('select a slot -> confirmation naming the whole site -> sets that design colour', () => {
mount([PLAIN]);
h.act(() => { type(row('p1').querySelector('select[aria-label="Use as site colour"]') as HTMLSelectElement, 'primary'); });
const dlg = q('[role="alertdialog"]');
expect(dlg.textContent).toMatch(/recolou?rs? the whole site/i);
expect(dlg.textContent).toContain('Primary');
expect(design.design.primaryColor).toBe('#3b82f6');
click(dlg.querySelector('[data-action="confirm"]') as HTMLElement);
expect(design.design.primaryColor).toBe('#d2b48c');
});
test('cancel does nothing', () => {
mount([PLAIN]);
h.act(() => { type(row('p1').querySelector('select[aria-label="Use as site colour"]') as HTMLSelectElement, 'bg'); });
click(q('[role="alertdialog"] [data-action="cancel"]'));
expect(design.design.backgroundColor).toBe('#ffffff');
});
});
describe('Pick buttons', () => {
test('Pick from page: a single colour is added as "Picked colour"', async () => {
mount();
pickFromCanvas.mockResolvedValue({ ok: true, hex: '#010203', source: 'canvas', candidates: [{ kind: 'background', hex: '#010203' }] });
await h.act(async () => { btn('Pick from page')!.click(); });
await flush();
expect(design.design.swatches.map((s) => [s.hex, s.label])).toEqual([['#010203', 'Picked colour']]);
});
test('differing candidates open a chooser with match notes; picking one adds it', async () => {
mount();
pickFromCanvas.mockResolvedValue({
ok: true, hex: '#0f172a', source: 'canvas',
candidates: [
{ kind: 'background', hex: '#0f172a', match: { kind: 'token', ref: 'var(--wsb-primary)', label: 'Primary' } },
{ kind: 'text', hex: '#ffffff' },
],
});
await h.act(async () => { btn('Pick from page')!.click(); });
await flush();
expect(hexes()).toEqual([]);
const chooser = q('[data-testid="pick-chooser"]');
expect(chooser.textContent).toContain('matches Primary');
expect(chooser.textContent).toMatch(/Background/);
expect(chooser.textContent).toMatch(/Text/);
click(qa('[data-testid="pick-chooser"] button').find((b) => /Text/.test(b.textContent!))!);
expect(hexes()).toEqual(['#ffffff']);
expect(q('[data-testid="pick-chooser"]')).toBeNull();
});
test('a pick that matches an already-saved colour does not duplicate it', async () => {
mount([BRAND]);
pickFromCanvas.mockResolvedValue({ ok: true, hex: '#15202c', source: 'canvas', candidates: [{ kind: 'background', hex: '#15202c', match: { kind: 'swatch', ref: 'var(--wsb-brand-1)', label: 'Logo blue' } }] });
await h.act(async () => { btn('Pick from page')!.click(); });
await flush();
expect(hexes()).toEqual(['#15202c']);
expect(h.container.textContent).toContain('Logo blue');
});
test('Pick from screen exists only with the eyedropper; otherwise the hint shows', async () => {
mount();
expect(btn('Pick from screen')).toBeUndefined();
expect(h.container.textContent).toContain('Your browser cannot sample outside the builder. Use Pick from page or Pick from image.');
h.unmount();
screenKind = 'eyedropper';
mount();
expect(h.container.textContent).not.toContain('Your browser cannot sample');
pickFromScreen.mockResolvedValue({ ok: true, hex: '#a1b2c3', source: 'eyedropper' });
await h.act(async () => { btn('Pick from screen')!.click(); });
await flush();
expect(hexes()).toEqual(['#a1b2c3']);
});
test('a cancelled pick adds nothing and says nothing', async () => {
mount();
pickFromCanvas.mockResolvedValue({ ok: false, reason: 'cancelled', message: 'Colour picking was cancelled.' });
await h.act(async () => { btn('Pick from page')!.click(); });
await flush();
expect(hexes()).toEqual([]);
expect(h.container.textContent).not.toContain('cancelled');
});
test('Palette from image: several adds in one go all land; never applied to the site', () => {
mount();
click(btn('Palette from image')!);
click(q('[data-testid="palette-add-two"]'));
expect(hexes()).toEqual(['#111111', '#222222']);
expect(design.design.primaryColor).toBe('#3b82f6');
});
});
+313
View File
@@ -0,0 +1,313 @@
import React, { useEffect, useRef, useState } from 'react';
import { useSiteDesign, type SiteDesign } from '../../state/SiteDesignContext';
import { usePages } from '../../state/PageContext';
import { COLOR_TOKENS, TOKEN_VARS, type TokenKey } from '../../utils/tokens';
import {
addSwatch, countBrandRefUses, findSwatchByHex, MAX_BRAND_COLORS, MAX_SWATCHES, normalizeHex,
removeSwatch, replaceBrandRef, setSwatchBrand, swatchValue, updateSwatch, type SavedSwatch,
} from '../../utils/swatches';
import { pickFromCanvas, pickFromScreen, screenPickKind, type CanvasCandidate, type CandidateKind } from '../../utils/color-pick';
import { ImagePickModal } from './styles/ImagePickModal';
import { PaletteFromImageModal } from './styles/PaletteFromImageModal';
import { inputStyle } from './styles/shared';
const KIND_LABEL: Record<CandidateKind, string> = { background: 'Background', text: 'Text', border: 'Border' };
const HEX_HINT = 'Enter a hex colour like #3b82f6 (3, 4, 6 or 8 digits).';
const NO_SCREEN_HINT = 'Your browser cannot sample outside the builder. Use Pick from page or Pick from image.';
const designKeyOf = (key: TokenKey): keyof SiteDesign =>
TOKEN_VARS.find(([v]) => v === `--wsb-${key}`)![1];
type Confirm =
| { kind: 'delete' | 'unbrand'; id: string; count: number }
| { kind: 'slot'; id: string; token: TokenKey };
const smallBtn: React.CSSProperties = {
fontSize: 10, lineHeight: 1.2, padding: '3px 7px', cursor: 'pointer',
color: 'var(--color-text-muted)', background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)',
};
/* ---------- one row ---------- */
interface RowProps {
sw: SavedSwatch;
confirm: Confirm | null;
design: SiteDesign;
onLabel: (v: string) => void;
onHex: (hex: string) => void;
onBrand: () => void;
onDelete: () => void;
onSlot: (token: TokenKey) => void;
onConfirm: () => void;
onCancel: () => void;
}
const SwatchRow: React.FC<RowProps> = ({ sw, confirm, onLabel, onHex, onBrand, onDelete, onSlot, onConfirm, onCancel }) => {
const [text, setText] = useState(sw.hex);
useEffect(() => { if (normalizeHex(text) !== sw.hex) setText(sw.hex); }, [sw.hex]); // eslint-disable-line react-hooks/exhaustive-deps
const valid = normalizeHex(text) !== null;
const live = swatchValue(sw) !== sw.hex;
const change = (v: string) => {
setText(v);
const h = normalizeHex(v);
if (h) onHex(h);
};
return (
<div data-swatch-row={sw.id} style={{ padding: '6px 0', borderBottom: '1px solid var(--color-border)' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span aria-hidden style={{ width: 22, height: 22, borderRadius: '50%', flex: '0 0 22px', border: '2px solid var(--color-border)', background: sw.hex }} />
<input
type="text" data-field="label" aria-label="Colour label" maxLength={40} value={sw.label}
placeholder="Label (e.g. Logo blue)" onChange={(e) => onLabel(e.target.value)}
style={{ ...inputStyle, flex: 1, minWidth: 0 }}
/>
<button type="button" data-action="delete" aria-label="Delete colour" title="Delete colour" onClick={onDelete} style={smallBtn}>Delete</button>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 5, flexWrap: 'wrap' }}>
<input
type="text" data-field="hex" aria-label="Hex colour" value={text} placeholder="#000000"
onChange={(e) => change(e.target.value)}
style={{ ...inputStyle, width: 92, fontFamily: 'monospace', borderColor: valid ? undefined : '#ef4444' }}
/>
<button
type="button" data-action="brand" aria-pressed={sw.brand}
title={sw.brand ? 'Live brand colour: editing it recolours everything that uses it' : 'Make this a live brand colour'}
onClick={onBrand}
style={{ ...smallBtn, ...(sw.brand ? { color: '#fff', background: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : null) }}
>
Brand
</button>
{live && (
<span data-testid="live-badge" title={swatchValue(sw)} style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-accent)' }}>live</span>
)}
<select
aria-label="Use as site colour" value="" style={{ ...inputStyle, width: 'auto', flex: 1, minWidth: 110, fontSize: 11 }}
onChange={(e) => { if (e.target.value) onSlot(e.target.value as TokenKey); }}
>
<option value="">Use as site colour…</option>
{COLOR_TOKENS.map((t) => <option key={t.key} value={t.key}>{t.label}</option>)}
</select>
</div>
{!valid && <div role="alert" className="color-hex-error">{HEX_HINT}</div>}
{confirm && (
<div
role="alertdialog" aria-label="Confirm colour change"
style={{ marginTop: 6, padding: 8, borderRadius: 'var(--radius-sm)', border: '1px solid rgba(239,68,68,0.4)', background: 'rgba(239,68,68,0.06)', fontSize: 11, lineHeight: 1.5, color: 'var(--color-text-muted)' }}
>
<div>
{confirm.kind === 'slot' ? (
<>This recolours the whole site: every place that uses <strong>{COLOR_TOKENS.find((t) => t.key === confirm.token)!.label}</strong> becomes <strong>{sw.hex}</strong>.</>
) : (
<>
{sw.label.trim() ? <strong>{sw.label.trim()}</strong> : 'This brand colour'} is used in {confirm.count} place{confirm.count === 1 ? '' : 's'}.
{' '}If you {confirm.kind === 'delete' ? 'delete it' : 'turn off Brand'}, those places keep the fixed colour <strong>{sw.hex}</strong> but stop following this swatch.
</>
)}
</div>
<div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
<button type="button" data-action="confirm" onClick={onConfirm} style={{ ...smallBtn, color: '#fff', background: '#ef4444', borderColor: '#ef4444' }}>
{confirm.kind === 'slot' ? 'Recolour site' : confirm.kind === 'delete' ? 'Replace and delete' : 'Replace and turn off'}
</button>
<button type="button" data-action="cancel" onClick={onCancel} style={smallBtn}>Cancel</button>
</div>
</div>
)}
</div>
);
};
/* ---------- the section ---------- */
export const SwatchManager: React.FC = () => {
const { design, updateDesign } = useSiteDesign();
const { siteStateStrings, rewriteSiteStates } = usePages();
const swatches: SavedSwatch[] = Array.isArray(design.swatches) ? design.swatches : [];
// Several adds can land in one tick (palette "Add all"): chain on the latest list, not the render's.
const latest = useRef(swatches);
latest.current = swatches;
const [newHex, setNewHex] = useState('');
const [newLabel, setNewLabel] = useState('');
const [addError, setAddError] = useState(false);
const [note, setNote] = useState('');
const [confirm, setConfirm] = useState<Confirm | null>(null);
const [chooser, setChooser] = useState<CanvasCandidate[] | null>(null);
const [busy, setBusy] = useState(false);
const [imageOpen, setImageOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const commit = (list: SavedSwatch[]) => { latest.current = list; updateDesign({ swatches: list }); };
const add = (hex: string, label: string): boolean => {
const next = addSwatch(latest.current, hex, label);
if (next === latest.current) return false;
commit(next);
return true;
};
const addPicked = (hex: string) => {
setChooser(null);
const have = findSwatchByHex(latest.current, hex);
if (have) { setNote(`Already saved as "${have.label.trim() || have.hex}".`); return; }
setNote(add(hex, 'Picked colour') ? '' : `You can save up to ${MAX_SWATCHES} colours.`);
};
const submitNew = () => {
const h = normalizeHex(newHex);
if (!h) { setAddError(true); return; }
if (!add(h, newLabel)) { setNote(`You can save up to ${MAX_SWATCHES} colours.`); return; }
setNewHex(''); setNewLabel(''); setAddError(false); setNote('');
};
const usesOf = (slot: number): number =>
siteStateStrings().reduce((n, s) => n + countBrandRefUses(s, slot), 0) + countBrandRefUses(design.headCode || '', slot);
/** Rewrites every reference to the brand slot into the literal hex (pages, header, footer, global sections, live canvas, head code). */
const freezeRefs = (sw: SavedSwatch): Partial<SiteDesign> => {
const slot = sw.slot as number;
rewriteSiteStates((s) => replaceBrandRef(s, slot, sw.hex));
const head = replaceBrandRef(design.headCode || '', slot, sw.hex);
return head !== design.headCode ? { headCode: head } : {};
};
const onBrand = (sw: SavedSwatch) => {
setNote('');
if (!sw.brand) {
const next = setSwatchBrand(latest.current, sw.id, true);
if (next === latest.current) setNote(`You can have up to ${MAX_BRAND_COLORS} brand colours`);
else commit(next);
return;
}
const count = usesOf(sw.slot as number);
if (count > 0) setConfirm({ kind: 'unbrand', id: sw.id, count });
else commit(setSwatchBrand(latest.current, sw.id, false));
};
const onDelete = (sw: SavedSwatch) => {
setNote('');
const count = sw.brand && typeof sw.slot === 'number' ? usesOf(sw.slot) : 0;
if (count > 0) setConfirm({ kind: 'delete', id: sw.id, count });
else commit(removeSwatch(latest.current, sw.id));
};
const doConfirm = () => {
const c = confirm;
if (!c) return;
const sw = latest.current.find((s) => s.id === c.id);
setConfirm(null);
if (!sw) return;
if (c.kind === 'slot') {
updateDesign({ [designKeyOf(c.token)]: sw.hex } as Partial<SiteDesign>);
return;
}
const extra = freezeRefs(sw);
const list = c.kind === 'delete' ? removeSwatch(latest.current, sw.id) : setSwatchBrand(latest.current, sw.id, false);
latest.current = list;
updateDesign({ swatches: list, ...extra });
};
const pickPage = async () => {
setNote(''); setChooser(null); setBusy(true);
try {
const r = await pickFromCanvas({ design });
if (!r.ok) { if (r.reason !== 'cancelled') setNote(r.message); return; }
const distinct = new Set(r.candidates.map((c) => c.hex));
if (distinct.size > 1) setChooser(r.candidates);
else addPicked(r.hex);
} finally { setBusy(false); }
};
const pickScreen = async () => {
setNote(''); setBusy(true);
try {
const r = await pickFromScreen();
if (r.ok) addPicked(r.hex);
else if (r.reason !== 'cancelled') setNote(r.message);
} finally { setBusy(false); }
};
const hasScreen = screenPickKind() === 'eyedropper';
return (
<div className="guided-section" data-testid="swatch-manager">
<label className="guided-section-label">Saved colours</label>
<p style={{ fontSize: 10, color: 'var(--color-text-dim)', margin: '0 0 6px', lineHeight: 1.4 }}>
Labelled colours that appear in every colour field. Mark one as a Brand colour and editing it recolours everything that uses it.
</p>
{swatches.length === 0 && <div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 6 }}>No saved colours yet.</div>}
{swatches.map((sw) => (
<SwatchRow
key={sw.id}
sw={sw}
design={design}
confirm={confirm && confirm.id === sw.id ? confirm : null}
onLabel={(v) => commit(updateSwatch(latest.current, sw.id, { label: v }))}
onHex={(h) => commit(updateSwatch(latest.current, sw.id, { hex: h }))}
onBrand={() => onBrand(sw)}
onDelete={() => onDelete(sw)}
onSlot={(token) => { setNote(''); setConfirm({ kind: 'slot', id: sw.id, token }); }}
onConfirm={doConfirm}
onCancel={() => setConfirm(null)}
/>
))}
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<input
type="text" data-field="new-hex" aria-label="New colour hex" placeholder="#hex" value={newHex}
onChange={(e) => { setNewHex(e.target.value); setAddError(false); }}
onKeyDown={(e) => { if (e.key === 'Enter') submitNew(); }}
style={{ ...inputStyle, width: 82, fontFamily: 'monospace', borderColor: addError ? '#ef4444' : undefined }}
/>
<input
type="text" data-field="new-label" aria-label="New colour label" placeholder="Label (optional)" maxLength={40} value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitNew(); }}
style={{ ...inputStyle, flex: 1, minWidth: 0 }}
/>
<button type="button" className="color-tool-btn" onClick={submitNew}>Add colour</button>
</div>
{addError && <div role="alert" className="color-hex-error">{HEX_HINT}</div>}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 8 }}>
<button type="button" className="color-tool-btn" disabled={busy} onClick={pickPage}>Pick from page</button>
{hasScreen && <button type="button" className="color-tool-btn" disabled={busy} onClick={pickScreen}>Pick from screen</button>}
<button type="button" className="color-tool-btn" onClick={() => setImageOpen(true)}>Pick from image</button>
<button type="button" className="color-tool-btn" onClick={() => setPaletteOpen(true)}>Palette from image</button>
</div>
{!hasScreen && <div style={{ fontSize: 10, color: 'var(--color-text-dim)', marginTop: 4, lineHeight: 1.4 }}>{NO_SCREEN_HINT}</div>}
{chooser && (
<div data-testid="pick-chooser" style={{ marginTop: 6, padding: 6, border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)' }}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>Which colour from that spot?</div>
{chooser.map((c) => (
<button
key={c.kind} type="button" onClick={() => addPicked(c.hex)}
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', textAlign: 'left', marginBottom: 3, ...smallBtn }}
>
<span aria-hidden style={{ width: 14, height: 14, borderRadius: 3, border: '1px solid var(--color-border)', background: c.hex }} />
<span>{KIND_LABEL[c.kind]}</span>
<code>{c.hex}</code>
{c.match && <em style={{ opacity: 0.8 }}>matches {c.match.label}</em>}
</button>
))}
<button type="button" onClick={() => setChooser(null)} style={smallBtn}>Cancel</button>
</div>
)}
{note && <div role="status" style={{ fontSize: 11, color: 'var(--color-warning, #d97706)', marginTop: 6 }}>{note}</div>}
{imageOpen && <ImagePickModal onPick={(hex) => { addPicked(hex); setImageOpen(false); }} onClose={() => setImageOpen(false)} />}
{paletteOpen && (
<PaletteFromImageModal
existingHexes={swatches.map((s) => s.hex)}
onAdd={(hex, label) => { add(hex, label ?? ''); }}
onClose={() => setPaletteOpen(false)}
/>
)}
</div>
);
};