Colour fields: hex validation, brand chips, pick tools, save colour, inline contrast prop; gradient parse accepts brand refs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 09:58:55 -08:00
1 parent 150c84065b
commit c34690d08b
6 files changed
+371 -5

No files matched your search

@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { useSiteDesign } from '../../../state/SiteDesignContext';
import { addSwatch, findSwatchByHex, normalizeHex, MAX_SWATCHES } from '../../../utils/swatches';
import { matchSiteColor, pickFromCanvas, pickFromScreen, screenPickKind } from '../../../utils/color-pick';
import { resolveTokenValue } from '../../../utils/tokens';
import { ImagePickModal } from './ImagePickModal';
export interface ColorPickToolsProps {
/** The field's current value (hex, token ref or brand ref). */
value: string;
/** Receives the picked value: a site/brand reference (when `allowRefs` and one matches) or the raw hex. */
onPick: (value: string) => void;
/**
* Whether a pick may become a `var(--wsb-*)` reference. False for the Site
* Design colours themselves: they are the token SOURCE, a reference there
* would be a cycle.
*/
allowRefs?: boolean;
}
/**
* Compact "get this colour from somewhere" row shown under every colour
* field: pick from the page, from the screen (Chromium only), from an image,
* and save the current colour to the site's saved colours.
*/
export const ColorPickTools: React.FC<ColorPickToolsProps> = ({ value, onPick, allowRefs = true }) => {
const { design, updateDesign } = useSiteDesign();
const [msg, setMsg] = useState('');
const [busy, setBusy] = useState(false);
const [imageOpen, setImageOpen] = useState(false);
const swatches = Array.isArray(design.swatches) ? design.swatches : [];
const literal = normalizeHex(resolveTokenValue(value || '', design));
const alreadySaved = !!literal && !!findSwatchByHex(swatches, literal);
const canSave = !!literal && !alreadySaved && swatches.length < MAX_SWATCHES;
const applyHex = (hex: string) => {
const ref = allowRefs ? matchSiteColor(hex, design)?.ref : undefined;
onPick(ref ?? hex);
};
const run = async (kind: 'page' | 'screen') => {
setMsg('');
setBusy(true);
try {
if (kind === 'page') {
const r = await pickFromCanvas({ design });
if (r.ok) {
const cand = r.candidates.find((c) => c.hex === r.hex && c.match);
onPick(allowRefs && cand?.match ? cand.match.ref : r.hex);
} else if (r.reason !== 'cancelled') setMsg(r.message);
} else {
const r = await pickFromScreen();
if (r.ok) applyHex(r.hex);
else if (r.reason !== 'cancelled') setMsg(r.message);
}
} finally {
setBusy(false);
}
};
return (
<div className="color-pick-tools" style={{ marginTop: 6 }}>
<div className="color-pick-tools__row" style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
<button type="button" className="color-tool-btn" disabled={busy} onClick={() => run('page')}>Pick from page</button>
{screenPickKind() === 'eyedropper' && (
<button type="button" className="color-tool-btn" disabled={busy} onClick={() => run('screen')}>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"
disabled={!canSave}
title={alreadySaved ? 'This colour is already saved' : 'Add this colour to the site\'s saved colours'}
onClick={() => { if (literal && canSave) updateDesign({ swatches: addSwatch(swatches, literal, '') }); }}
>
Save colour
</button>
</div>
{msg && <div role="status" className="color-pick-tools__msg" style={{ fontSize: 11, color: 'var(--color-warning, #d97706)', marginTop: 4 }}>{msg}</div>}
{imageOpen && (
<ImagePickModal
onPick={(hex) => { applyHex(hex); setImageOpen(false); }}
onClose={() => setImageOpen(false)}
/>
)}
</div>
);
};
@@ -0,0 +1,176 @@
import { describe, test, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { ColorPickerField, ColorSwatchGrid, GradientSwatchGrid } from './shared';
import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue } from '../../../state/SiteDesignContext';
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 };
});
let container: HTMLDivElement | null = null;
let root: Root;
const ctx: { current: SiteDesignContextValue | null } = { current: null };
const Probe: React.FC = () => { ctx.current = useSiteDesign(); return null; };
function render(ui: React.ReactElement, swatches?: unknown[]) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => { root = createRoot(container!); root.render(<SiteDesignProvider><Probe />{ui}</SiteDesignProvider>); });
if (swatches) act(() => { ctx.current!.updateDesign({ swatches: swatches as never }); });
}
afterEach(() => {
vi.useRealTimers();
pickFromCanvas.mockReset(); pickFromScreen.mockReset(); screenKind = 'none';
if (!container) return;
act(() => { root.unmount(); });
container.remove();
container = null;
});
const hexBox = () => container!.querySelector<HTMLInputElement>('input[type="text"]')!;
const chip = () => container!.querySelector('.token-chip');
const btn = (text: string) => Array.from(container!.querySelectorAll('button')).find((b) => b.textContent?.trim() === text) as HTMLButtonElement | undefined;
function type(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
const BRAND = { id: 'b1', label: 'Logo blue', hex: '#15202c', brand: true, slot: 2 };
describe('hex validation', () => {
test('invalid text is not committed and shows a red hint; valid text clears it and commits normalised', () => {
vi.useFakeTimers();
const onChange = vi.fn();
render(<ColorPickerField label="C" value="#000000" onChange={onChange} />);
act(() => { type(hexBox(), '#12'); });
act(() => { vi.advanceTimersByTime(400); });
expect(onChange).not.toHaveBeenCalled();
const err = container!.querySelector('[role="alert"]')!;
expect(err.textContent).toMatch(/hex/i);
act(() => { type(hexBox(), 'ABCDEF'); });
act(() => { vi.advanceTimersByTime(400); });
expect(onChange).toHaveBeenCalledWith('#abcdef');
expect(container!.querySelector('[role="alert"]')).toBeNull();
});
test('ColorSwatchGrid validates too', () => {
vi.useFakeTimers();
const onSelect = vi.fn();
render(<ColorSwatchGrid colors={[]} activeValue="#000000" onSelect={onSelect} />);
act(() => { type(hexBox(), 'zzz'); });
act(() => { vi.advanceTimersByTime(400); });
expect(onSelect).not.toHaveBeenCalled();
expect(container!.querySelector('[role="alert"]')).not.toBeNull();
});
});
describe('brand references', () => {
test('chip shows the swatch label, or "Brand N" when unlabelled; the hex box shows the live hex', () => {
render(<ColorPickerField label="C" value="var(--wsb-brand-2)" onChange={() => {}} />, [BRAND]);
expect(chip()!.textContent).toBe('Logo blue');
expect(hexBox().value).toBe('#15202c');
});
test('unlabelled brand colour -> Brand N', () => {
render(<ColorPickerField label="C" value="var(--wsb-brand-2)" onChange={() => {}} />, [{ ...BRAND, label: '' }]);
expect(chip()!.textContent).toBe('Brand 2');
});
test('gradient custom builder parses a brand ref', () => {
render(<GradientSwatchGrid activeValue="linear-gradient(135deg, var(--wsb-brand-2) 0%, #764ba2 100%)" onSelect={() => {}} />, [BRAND]);
act(() => { btn('Custom Gradient')?.click() ?? Array.from(container!.querySelectorAll('button')).find((b) => b.textContent?.includes('Custom Gradient'))!.click(); });
const boxes = Array.from(container!.querySelectorAll<HTMLInputElement>('input[type="text"]')).map((i) => i.value);
expect(boxes).toContain('#15202c');
});
});
describe('save colour', () => {
test('adds the field literal hex to the saved colours, then disables', () => {
render(<ColorPickerField label="C" value="#abcdef" onChange={() => {}} />);
const b = btn('Save colour')!;
expect(b.disabled).toBe(false);
act(() => { b.click(); });
expect(ctx.current!.design.swatches.map((s) => s.hex)).toEqual(['#abcdef']);
expect(btn('Save colour')!.disabled).toBe(true);
});
test('a token value saves its resolved hex; a gradient cannot be saved', () => {
render(<ColorPickerField label="C" value="var(--wsb-primary)" onChange={() => {}} />);
act(() => { btn('Save colour')!.click(); });
expect(ctx.current!.design.swatches[0].hex).toBe('#3b82f6');
});
});
describe('pick tools', () => {
test('Pick from screen only exists where the screen eyedropper does', () => {
render(<ColorPickerField label="C" value="#000000" onChange={() => {}} />);
expect(btn('Pick from page')).toBeDefined();
expect(btn('Pick from image')).toBeDefined();
expect(btn('Pick from screen')).toBeUndefined();
act(() => root.unmount()); container!.remove(); container = null;
screenKind = 'eyedropper';
render(<ColorPickerField label="C" value="#000000" onChange={() => {}} />);
expect(btn('Pick from screen')).toBeDefined();
});
test('Pick from page uses the site/brand reference when the pick matches one', async () => {
const onChange = vi.fn();
pickFromCanvas.mockResolvedValue({ ok: true, hex: '#15202c', source: 'canvas', candidates: [{ kind: 'background', hex: '#15202c', match: { kind: 'swatch', ref: 'var(--wsb-brand-2)', label: 'Logo blue' } }] });
render(<ColorPickerField label="C" value="#000000" onChange={onChange} />);
await act(async () => { btn('Pick from page')!.click(); });
await flush();
expect(onChange).toHaveBeenCalledWith('var(--wsb-brand-2)');
});
test('without a match the raw hex is used', async () => {
const onChange = vi.fn();
pickFromCanvas.mockResolvedValue({ ok: true, hex: '#010203', source: 'canvas', candidates: [{ kind: 'background', hex: '#010203' }] });
render(<ColorPickerField label="C" value="#000000" onChange={onChange} />);
await act(async () => { btn('Pick from page')!.click(); });
await flush();
expect(onChange).toHaveBeenCalledWith('#010203');
});
test('Site Design token fields (showTokens=false) never store a token ref from a pick', async () => {
const onChange = vi.fn();
pickFromCanvas.mockResolvedValue({ ok: true, hex: '#3b82f6', source: 'canvas', candidates: [{ kind: 'background', hex: '#3b82f6', match: { kind: 'token', ref: 'var(--wsb-primary)', label: 'Primary' } }] });
render(<ColorPickerField label="C" value="#000000" onChange={onChange} showTokens={false} />);
await act(async () => { btn('Pick from page')!.click(); });
await flush();
expect(onChange).toHaveBeenCalledWith('#3b82f6');
});
test('a failed pick shows the message; a cancelled one is silent', async () => {
pickFromCanvas.mockResolvedValue({ ok: false, reason: 'error', message: 'Could not read it.' });
render(<ColorPickerField label="C" value="#000000" onChange={() => {}} />);
await act(async () => { btn('Pick from page')!.click(); });
await flush();
expect(container!.textContent).toContain('Could not read it.');
});
test('Pick from screen applies the eyedropper hex', async () => {
screenKind = 'eyedropper';
const onChange = vi.fn();
pickFromScreen.mockResolvedValue({ ok: true, hex: '#a1b2c3', source: 'eyedropper' });
render(<ColorPickerField label="C" value="#000000" onChange={onChange} />);
await act(async () => { btn('Pick from screen')!.click(); });
await flush();
expect(onChange).toHaveBeenCalledWith('#a1b2c3');
});
});
describe('inline contrast', () => {
test('ColorPickerField renders a ContrastNote when contrastWith is given and the pair fails', () => {
render(<ColorPickerField label="C" value="#eeeeee" onChange={() => {}} contrastWith="#ffffff" />);
expect(container!.querySelector('.contrast-note--fail')).not.toBeNull();
});
test('passing pair and gradient background show nothing / unchecked only', () => {
render(<ColorPickerField label="C" value="#000000" onChange={() => {}} contrastWith="#ffffff" />);
expect(container!.querySelector('.contrast-note')).toBeNull();
});
test('the suggestion applies through onChange', () => {
const onChange = vi.fn();
render(<ColorPickerField label="C" value="#eeeeee" onChange={onChange} contrastWith="#ffffff" />);
act(() => { (container!.querySelector('.contrast-note__use') as HTMLButtonElement).click(); });
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange.mock.calls[0][0]).toMatch(/^#[0-9a-f]{6}$/);
});
});
+45 -5
View File
@@ -13,6 +13,10 @@ import { useSiteDesign, DEFAULT_SITE_DESIGN, SiteDesign } from '../../../state/S
import { COLOR_TOKENS, resolveTokenValue, tokenKeyOf, tokenRef } from '../../../utils/tokens';
import { isLightColor } from '../../../utils/color';
import { TokenSwatchRow } from './TokenSwatchRow';
import { ColorPickTools } from './ColorPickTools';
import { ContrastNote } from './ContrastNote';
import { parseColorInput } from '../../../utils/color-input';
import { brandSlotOf } from '../../../utils/swatches';
import { useDevice } from '../../../state/DeviceContext';
import { RESPONSIVE_STYLE_KEYS } from '../../../utils/responsive-style';
import type { DeviceMode } from '../../../types';
@@ -34,7 +38,12 @@ export function useResolvedColor(value: string | undefined): { resolved: string;
const v = value || '';
const resolved = resolveTokenValue(v, design);
const key = tokenKeyOf(v);
const tokenLabel = key ? (COLOR_TOKENS.find((t) => t.key === key)?.label ?? key) : null;
let tokenLabel: string | null = key ? (COLOR_TOKENS.find((t) => t.key === key)?.label ?? key) : null;
const slot = brandSlotOf(v);
if (slot !== null) {
const sw = (Array.isArray(design.swatches) ? design.swatches : []).find((s) => s.brand && s.slot === slot);
tokenLabel = sw?.label.trim() || `Brand ${slot}`;
}
const nativeValue = /^#[0-9a-fA-F]{6}$/.test(resolved) ? resolved : '#000000';
return { resolved, nativeValue, tokenLabel };
}
@@ -69,6 +78,16 @@ export const SectionLabel: React.FC<SectionLabelProps> = ({ children }) => (
<label className="guided-section-label">{children}</label>
);
/** Hex text box state shared by ColorPickerField / ColorSwatchGrid: only valid colour text is committed; invalid text keeps showing with a red hint. */
function useColorText(resolved: string, commit: (v: string) => void) {
const guarded = (v: string) => { const r = parseColorInput(v); if (r.ok) commit(r.value); };
const hex = useDebouncedCommit(resolved, guarded);
const invalid = !parseColorInput(hex.local).ok;
return { hex, invalid };
}
const HexError: React.FC<{ show: boolean }> = ({ show }) =>
show ? <div role="alert" className="color-hex-error">Enter a hex colour like #3b82f6 (3, 4, 6 or 8 digits).</div> : null;
interface ColorSwatchGridProps {
colors: { label: string; value: string }[];
activeValue: string | undefined;
@@ -82,7 +101,7 @@ interface ColorSwatchGridProps {
}
export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, activeValue, onSelect, onContinuous, showTokens = true }) => {
const { resolved, nativeValue, tokenLabel } = useResolvedColor(activeValue);
const hex = useDebouncedCommit(resolved, onSelect);
const { hex, invalid } = useColorText(resolved, onSelect);
return (
<div>
{showTokens && <TokenSwatchRow value={activeValue} onSelect={onSelect} />}
@@ -104,6 +123,8 @@ export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, active
/>
<TokenChip label={tokenLabel} />
</div>
<HexError show={invalid} />
<ColorPickTools value={activeValue || ''} onPick={onSelect} allowRefs={showTokens} />
<div className="preset-grid">
{colors.map((c) => (
<button
@@ -168,7 +189,7 @@ interface GradientSwatchGridProps {
/* Parse "linear-gradient(135deg, #aaa 0%, #bbb 100%)" into parts */
function parseGradient(val: string | undefined): { angle: number; from: string; to: string } {
if (!val || val === 'none') return { angle: 135, from: '#667eea', to: '#764ba2' };
const m = val.match(/linear-gradient\(\s*(\d+)deg\s*,\s*(#[0-9a-fA-F]{3,8}|var\(--wsb-[a-z-]+\))\s*(?:\d+%?)?\s*,\s*(#[0-9a-fA-F]{3,8}|var\(--wsb-[a-z-]+\))/);
const m = val.match(/linear-gradient\(\s*(\d+)deg\s*,\s*(#[0-9a-fA-F]{3,8}|var\(--wsb-[a-z0-9-]+\))\s*(?:\d+%?)?\s*,\s*(#[0-9a-fA-F]{3,8}|var\(--wsb-[a-z0-9-]+\))/);
if (m) return { angle: parseInt(m[1]), from: m[2], to: m[3] };
return { angle: 135, from: '#667eea', to: '#764ba2' };
}
@@ -363,10 +384,18 @@ interface ColorPickerFieldProps {
/** Show the site-colour (token) row. Default true; Site Design's own
* colours pass false (they are the token SOURCE and must store hex). */
showTokens?: boolean;
/** The colour this one is read against (text on a background, or the reverse). Renders an inline contrast hint. */
contrastWith?: string;
/** Large text / UI part: the 3:1 threshold instead of 4.5:1. */
contrastLarge?: boolean;
/** Prefix for the hint ("Link on background"). */
contrastLabel?: string;
/** Which side of the pair this field is. Default 'fg' (this colour is the text). */
contrastRole?: 'fg' | 'bg';
}
export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value, onChange, onContinuous, showTokens = true }) => {
export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value, onChange, onContinuous, showTokens = true, contrastWith, contrastLarge, contrastLabel, contrastRole = 'fg' }) => {
const { resolved, nativeValue, tokenLabel } = useResolvedColor(value);
const hex = useDebouncedCommit(resolved, onChange);
const { hex, invalid } = useColorText(resolved, onChange);
return (
<div style={sectionGap}>
<label style={labelStyle}>{label}</label>
@@ -389,6 +418,17 @@ export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value
/>
<TokenChip label={tokenLabel} />
</div>
<HexError show={invalid} />
<ColorPickTools value={value} onPick={onChange} allowRefs={showTokens} />
{contrastWith && (
<ContrastNote
fg={contrastRole === 'fg' ? value : contrastWith}
bg={contrastRole === 'fg' ? contrastWith : value}
large={contrastLarge}
label={contrastLabel}
onUseSuggestion={contrastRole === 'fg' ? onChange : undefined}
/>
)}
</div>
);
};
+15
View File
@@ -3390,3 +3390,18 @@ body {
.token-swatch--live.active {
box-shadow: 0 0 0 2px var(--color-bg-surface, #18181b), 0 0 0 4px var(--color-accent);
}
/* Colour pick tools (under every colour field) */
.color-tool-btn {
font-size: 10px;
line-height: 1.2;
padding: 3px 7px;
color: var(--color-text-muted);
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.color-tool-btn:hover:not(:disabled) { color: var(--color-text); border-color: var(--color-text-muted); }
.color-tool-btn:disabled { opacity: 0.5; cursor: default; }
.color-hex-error { font-size: 11px; color: #ef4444; margin-top: 3px; }
+24
View File
@@ -0,0 +1,24 @@
import { describe, test, expect } from 'vitest';
import { parseColorInput } from './color-input';
describe('parseColorInput', () => {
test('hex with or without #, any case, 3/4/6/8 digits, normalised', () => {
expect(parseColorInput('#ABCDEF')).toEqual({ ok: true, value: '#abcdef' });
expect(parseColorInput('abc')).toEqual({ ok: true, value: '#abc' });
expect(parseColorInput(' 12345678 ')).toEqual({ ok: true, value: '#12345678' });
expect(parseColorInput('#abcd')).toEqual({ ok: true, value: '#abcd' });
});
test('rejects partial / garbage hex', () => {
for (const bad of ['#12', '#12345', '#1234567', '#gggggg', 'red!', '#', '12 34', 'url(x)', 'var(--x)']) {
expect(parseColorInput(bad).ok, bad).toBe(false);
}
});
test('keeps site token and brand refs, transparent, rgb()/hsl(); empty clears', () => {
expect(parseColorInput('var(--wsb-primary)')).toEqual({ ok: true, value: 'var(--wsb-primary)' });
expect(parseColorInput('var(--wsb-brand-2)')).toEqual({ ok: true, value: 'var(--wsb-brand-2)' });
expect(parseColorInput('Transparent')).toEqual({ ok: true, value: 'transparent' });
expect(parseColorInput('rgba(0, 0, 0, 0.5)')).toEqual({ ok: true, value: 'rgba(0, 0, 0, 0.5)' });
expect(parseColorInput('hsl(120 40% 50%)')).toEqual({ ok: true, value: 'hsl(120 40% 50%)' });
expect(parseColorInput('')).toEqual({ ok: true, value: '' });
});
});
+22
View File
@@ -0,0 +1,22 @@
import { normalizeHex } from './swatches';
import { isTokenRef } from './tokens';
/**
* What a colour text box may commit. Hex (with or without '#', normalised to
* lowercase '#...'), site/brand token refs, `transparent`, simple rgb()/hsl()
* functions, or empty (a deliberate clear). Anything else is not committed.
*/
export type ColorInputResult = { ok: true; value: string } | { ok: false };
const FN_RE = /^(?:rgb|rgba|hsl|hsla)\(\s*[-\d.%\s,/]+\)$/i;
export function parseColorInput(text: string): ColorInputResult {
const t = String(text ?? '').trim();
if (t === '') return { ok: true, value: '' };
const isRef: boolean = isTokenRef(t);
if (isRef) return { ok: true, value: t };
if (t.toLowerCase() === 'transparent') return { ok: true, value: 'transparent' };
if (FN_RE.test(t)) return { ok: true, value: t };
const h = normalizeHex(t);
return h ? { ok: true, value: h } : { ok: false };
}