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

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

+ + {swatches.length === 0 &&
No saved colours yet.
} + {swatches.map((sw) => ( + commit(updateSwatch(latest.current, sw.id, { label: v }))} + onHex={(h) => commit(updateSwatch(latest.current, sw.id, { hex: h }))} + onBrand={() => onBrand(sw)} + onDelete={() => onDelete(sw)} + onSlot={(token) => { setNote(''); setConfirm({ kind: 'slot', id: sw.id, token }); }} + onConfirm={doConfirm} + onCancel={() => setConfirm(null)} + /> + ))} + +
+ { setNewHex(e.target.value); setAddError(false); }} + onKeyDown={(e) => { if (e.key === 'Enter') submitNew(); }} + style={{ ...inputStyle, width: 82, fontFamily: 'monospace', borderColor: addError ? '#ef4444' : undefined }} + /> + setNewLabel(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') submitNew(); }} + style={{ ...inputStyle, flex: 1, minWidth: 0 }} + /> + +
+ {addError &&
{HEX_HINT}
} + +
+ + {hasScreen && } + + +
+ {!hasScreen &&
{NO_SCREEN_HINT}
} + + {chooser && ( +
+
Which colour from that spot?
+ {chooser.map((c) => ( + + ))} + +
+ )} + + {note &&
{note}
} + + {imageOpen && { addPicked(hex); setImageOpen(false); }} onClose={() => setImageOpen(false)} />} + {paletteOpen && ( + s.hex)} + onAdd={(hex, label) => { add(hex, label ?? ''); }} + onClose={() => setPaletteOpen(false)} + /> + )} +
+ ); +};