TokenSwatchRow: saved colours after the site tokens (brand = live ring + token ref)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 09:57:17 -08:00
1 parent 9c61f41079
commit 150c84065b
3 files changed
+96

No files matched your search

@@ -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(
<SiteDesignProvider>
<DesignProbe out={ctx} />
<TokenSwatchRow value={value} onSelect={onSelect} />
</SiteDesignProvider>,
);
act(() => { ctx.current!.updateDesign({ swatches: saved }); });
};
const savedBtns = () => Array.from(container!.querySelectorAll<HTMLButtonElement>('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');
});
});
});
@@ -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<TokenSwatchRowProps> = ({
}) => {
const { design } = useSiteDesign();
const current = value?.trim();
const saved: SavedSwatch[] = Array.isArray(design.swatches) ? design.swatches : [];
const currentHex = current ? normalizeHex(current) : null;
return (
<div className="token-swatch-row" role="group" aria-label={label}>
<div className="token-swatch-label">{label}</div>
@@ -46,6 +56,26 @@ export const TokenSwatchRow: React.FC<TokenSwatchRowProps> = ({
/>
);
})}
{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 (
<button
key={sw.id}
type="button"
className={`token-swatch token-swatch--saved${live ? ' token-swatch--live' : ''}${pressed ? ' active' : ''}`}
style={{ background: sw.hex }}
title={title}
aria-label={title}
aria-pressed={pressed}
data-swatch={sw.id}
data-live={live ? 'true' : undefined}
onClick={() => onSelect(stored)}
/>
);
})}
</div>
</div>
);
+8
View File
@@ -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);
}