From 150c84065bbaf0cdc3f2962f86da900321f0e3e0 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:57:17 -0800 Subject: [PATCH] TokenSwatchRow: saved colours after the site tokens (brand = live ring + token ref) Co-Authored-By: Claude Sonnet 5.5 --- .../right/styles/TokenSwatchRow.test.tsx | 58 +++++++++++++++++++ .../panels/right/styles/TokenSwatchRow.tsx | 30 ++++++++++ craft/src/styles/editor.css | 8 +++ 3 files changed, 96 insertions(+) diff --git a/craft/src/panels/right/styles/TokenSwatchRow.test.tsx b/craft/src/panels/right/styles/TokenSwatchRow.test.tsx index 789430a..bb5e186 100644 --- a/craft/src/panels/right/styles/TokenSwatchRow.test.tsx +++ b/craft/src/panels/right/styles/TokenSwatchRow.test.tsx @@ -100,4 +100,62 @@ describe('TokenSwatchRow', () => { act(() => { ctx.current!.updateDesign({ primaryColor: '#ff0000' }); }); expect(swatches()[0].style.background).toBe('rgb(255, 0, 0)'); }); + + describe('saved colours', () => { + const saved = [ + { id: 'a', label: 'Logo blue', hex: '#15202c', brand: true, slot: 2 }, + { id: 'b', label: 'Sand', hex: '#d2b48c', brand: false }, + ]; + const withSaved = (value: string | undefined, onSelect: (v: string) => void) => { + const ctx: { current: SiteDesignContextValue | null } = { current: null }; + render( + + + + , + ); + act(() => { ctx.current!.updateDesign({ swatches: saved }); }); + }; + const savedBtns = () => Array.from(container!.querySelectorAll('button[data-swatch]')); + + test('shown after the 11 token swatches; title = label + hex; brand shows a live ring', () => { + withSaved(undefined, () => {}); + const all = swatches(); + expect(all.length).toBe(COLOR_TOKENS.length + 2); + expect(all.slice(0, COLOR_TOKENS.length).every((b) => b.dataset.token)).toBe(true); + const [brand, plain] = savedBtns(); + expect(brand.title).toContain('Logo blue'); + expect(brand.title).toContain('#15202c'); + expect(brand.title).toContain('var(--wsb-brand-2)'); + expect(brand.classList.contains('token-swatch--live')).toBe(true); + expect(brand.dataset.live).toBe('true'); + expect(plain.title).toBe('Sand #d2b48c'); + expect(plain.classList.contains('token-swatch--live')).toBe(false); + expect(brand.style.background).toBe('rgb(21, 32, 44)'); + }); + + test('brand swatch selects var(--wsb-brand-N); plain selects its hex', () => { + const onSelect = vi.fn(); + withSaved(undefined, onSelect); + const [brand, plain] = savedBtns(); + act(() => { brand.click(); }); + act(() => { plain.click(); }); + expect(onSelect.mock.calls.map((c) => c[0])).toEqual(['var(--wsb-brand-2)', '#d2b48c']); + }); + + test('pressed when the value equals what the swatch stores', () => { + withSaved('var(--wsb-brand-2)', () => {}); + expect(savedBtns().map((b) => b.getAttribute('aria-pressed'))).toEqual(['true', 'false']); + }); + + test('plain hex swatch pressed on a case-insensitive hex match', () => { + withSaved('#D2B48C', () => {}); + expect(savedBtns().map((b) => b.getAttribute('aria-pressed'))).toEqual(['false', 'true']); + }); + + test('the group is labelled', () => { + withSaved(undefined, () => {}); + expect(container!.querySelector('[role="group"]')!.getAttribute('aria-label')).toBe('Site colors'); + }); + }); }); diff --git a/craft/src/panels/right/styles/TokenSwatchRow.tsx b/craft/src/panels/right/styles/TokenSwatchRow.tsx index 9a73d79..d362fb5 100644 --- a/craft/src/panels/right/styles/TokenSwatchRow.tsx +++ b/craft/src/panels/right/styles/TokenSwatchRow.tsx @@ -1,6 +1,14 @@ import React from 'react'; import { useSiteDesign } from '../../../state/SiteDesignContext'; import { COLOR_TOKENS, TokenKey, tokenRef, resolveTokenValue } from '../../../utils/tokens'; +import { swatchValue, normalizeHex, type SavedSwatch } from '../../../utils/swatches'; + +/** Hover/aria text for a saved swatch: label + hex (+ the live token for brand colours). */ +export function savedSwatchTitle(s: SavedSwatch): string { + const base = s.label.trim() ? `${s.label.trim()} ${s.hex}` : s.hex; + const ref = swatchValue(s); + return ref !== s.hex ? `${base} (live brand colour, ${ref})` : base; +} export interface TokenSwatchRowProps { /** The control's current value; the swatch whose `var(--wsb-*)` ref equals it is shown pressed. */ @@ -25,6 +33,8 @@ export const TokenSwatchRow: React.FC = ({ }) => { const { design } = useSiteDesign(); const current = value?.trim(); + const saved: SavedSwatch[] = Array.isArray(design.swatches) ? design.swatches : []; + const currentHex = current ? normalizeHex(current) : null; return (
{label}
@@ -46,6 +56,26 @@ export const TokenSwatchRow: React.FC = ({ /> ); })} + {saved.map((sw) => { + const stored = swatchValue(sw); + const live = stored !== sw.hex; + const pressed = live ? current === stored : !!currentHex && currentHex === sw.hex.toLowerCase(); + const title = savedSwatchTitle(sw); + return ( +
); diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index 270ddba..26aa5af 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -3382,3 +3382,11 @@ body { .guided-regenerate-btn:hover { background: var(--color-bg-hover); } .guided-regenerate-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 1px; } /* /#followups */ + +/* Saved colours: a "live" brand swatch gets a double ring */ +.token-swatch--live { + box-shadow: 0 0 0 2px var(--color-bg-surface, #18181b), 0 0 0 3px var(--color-accent); +} +.token-swatch--live.active { + box-shadow: 0 0 0 2px var(--color-bg-surface, #18181b), 0 0 0 4px var(--color-accent); +}