From c34690d08bdda1d61d329453c06f9f45e3c84e93 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:58:55 -0800 Subject: [PATCH] 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 --- .../panels/right/styles/ColorPickTools.tsx | 89 +++++++++ .../right/styles/color-fields.pick.test.tsx | 176 ++++++++++++++++++ craft/src/panels/right/styles/shared.tsx | 50 ++++- craft/src/styles/editor.css | 15 ++ craft/src/utils/color-input.test.ts | 24 +++ craft/src/utils/color-input.ts | 22 +++ 6 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 craft/src/panels/right/styles/ColorPickTools.tsx create mode 100644 craft/src/panels/right/styles/color-fields.pick.test.tsx create mode 100644 craft/src/utils/color-input.test.ts create mode 100644 craft/src/utils/color-input.ts diff --git a/craft/src/panels/right/styles/ColorPickTools.tsx b/craft/src/panels/right/styles/ColorPickTools.tsx new file mode 100644 index 0000000..41538d5 --- /dev/null +++ b/craft/src/panels/right/styles/ColorPickTools.tsx @@ -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 = ({ 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 ( +
+
+ + {screenPickKind() === 'eyedropper' && ( + + )} + + +
+ {msg &&
{msg}
} + {imageOpen && ( + { applyHex(hex); setImageOpen(false); }} + onClose={() => setImageOpen(false)} + /> + )} +
+ ); +}; diff --git a/craft/src/panels/right/styles/color-fields.pick.test.tsx b/craft/src/panels/right/styles/color-fields.pick.test.tsx new file mode 100644 index 0000000..019874d --- /dev/null +++ b/craft/src/panels/right/styles/color-fields.pick.test.tsx @@ -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(); + 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({ui}); }); + 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('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(); + 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(); + 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( {}} />, [BRAND]); + expect(chip()!.textContent).toBe('Logo blue'); + expect(hexBox().value).toBe('#15202c'); + }); + test('unlabelled brand colour -> Brand N', () => { + render( {}} />, [{ ...BRAND, label: '' }]); + expect(chip()!.textContent).toBe('Brand 2'); + }); + test('gradient custom builder parses a brand ref', () => { + render( {}} />, [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('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( {}} />); + 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( {}} />); + 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( {}} />); + 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( {}} />); + 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(); + 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(); + 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(); + 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( {}} />); + 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(); + 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( {}} contrastWith="#ffffff" />); + expect(container!.querySelector('.contrast-note--fail')).not.toBeNull(); + }); + test('passing pair and gradient background show nothing / unchecked only', () => { + render( {}} contrastWith="#ffffff" />); + expect(container!.querySelector('.contrast-note')).toBeNull(); + }); + test('the suggestion applies through onChange', () => { + const onChange = vi.fn(); + render(); + act(() => { (container!.querySelector('.contrast-note__use') as HTMLButtonElement).click(); }); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0]).toMatch(/^#[0-9a-f]{6}$/); + }); +}); diff --git a/craft/src/panels/right/styles/shared.tsx b/craft/src/panels/right/styles/shared.tsx index a5ca0b0..674423a 100644 --- a/craft/src/panels/right/styles/shared.tsx +++ b/craft/src/panels/right/styles/shared.tsx @@ -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 = ({ children }) => ( ); +/** 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 ?
Enter a hex colour like #3b82f6 (3, 4, 6 or 8 digits).
: null; + interface ColorSwatchGridProps { colors: { label: string; value: string }[]; activeValue: string | undefined; @@ -82,7 +101,7 @@ interface ColorSwatchGridProps { } export const ColorSwatchGrid: React.FC = ({ colors, activeValue, onSelect, onContinuous, showTokens = true }) => { const { resolved, nativeValue, tokenLabel } = useResolvedColor(activeValue); - const hex = useDebouncedCommit(resolved, onSelect); + const { hex, invalid } = useColorText(resolved, onSelect); return (
{showTokens && } @@ -104,6 +123,8 @@ export const ColorSwatchGrid: React.FC = ({ colors, active />
+ +
{colors.map((c) => (
); }; diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index 26aa5af..9a3f9f1 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -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; } diff --git a/craft/src/utils/color-input.test.ts b/craft/src/utils/color-input.test.ts new file mode 100644 index 0000000..9384133 --- /dev/null +++ b/craft/src/utils/color-input.test.ts @@ -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: '' }); + }); +}); diff --git a/craft/src/utils/color-input.ts b/craft/src/utils/color-input.ts new file mode 100644 index 0000000..226a292 --- /dev/null +++ b/craft/src/utils/color-input.ts @@ -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 }; +}