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:
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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user