From dcf9c92fee5f4c69234b473ffce739fe92e6c295 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 10:01:21 -0800 Subject: [PATCH] Site Design panel: Saved colours section + inline contrast on text/muted/link/primary-button Co-Authored-By: Claude Sonnet 5.5 --- .../right/SiteDesignPanel.colours.test.tsx | 62 +++++++++++++++++++ craft/src/panels/right/SiteDesignPanel.tsx | 31 +++++++++- 2 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 craft/src/panels/right/SiteDesignPanel.colours.test.tsx diff --git a/craft/src/panels/right/SiteDesignPanel.colours.test.tsx b/craft/src/panels/right/SiteDesignPanel.colours.test.tsx new file mode 100644 index 0000000..9a72547 --- /dev/null +++ b/craft/src/panels/right/SiteDesignPanel.colours.test.tsx @@ -0,0 +1,62 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import React from 'react'; +import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness'; +import { EditorConfigProvider } from '../../state/EditorConfigContext'; +import { PageProvider } from '../../state/PageContext'; +import { SaveGuardProvider } from '../../state/SaveGuardContext'; +import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue } from '../../state/SiteDesignContext'; +import { SiteDesignPanel } from './SiteDesignPanel'; + +let h: EditorHarness; +let design: SiteDesignContextValue; +const Probe: React.FC = () => { design = useSiteDesign(); return null; }; +afterEach(() => { h?.unmount(); document.body.innerHTML = ''; }); + +function mount() { + h = renderEditorHarness(); + h.mountChild( + + + , + ); +} +const advanced = () => h.act(() => { Array.from(h.container.querySelectorAll('button')).find((b) => b.textContent?.trim() === 'advanced')!.click(); }); +const notes = () => Array.from(h.container.querySelectorAll('.contrast-note--fail')).map((n) => n.textContent ?? ''); + +describe('SiteDesignPanel colours', () => { + test('has a Saved colours section with the pick buttons', () => { + mount(); + expect(h.container.textContent).toContain('Saved colours'); + expect(Array.from(h.container.querySelectorAll('button')).some((b) => b.textContent === 'Palette from image')).toBe(true); + }); + + test('defaults: only the genuinely failing default pairs (white on #3b82f6, #3b82f6 link on white = 3.68:1) are called out', () => { + mount(); + advanced(); + const n = notes(); + expect(n.length).toBe(2); + expect(n[0]).toContain('Button text on primary: Low contrast 3.68:1'); + expect(n[1]).toContain('Link on background: Low contrast 3.68:1'); + }); + + test('low-contrast text / muted / link / primary-button pairs are called out where picked', () => { + mount(); + h.act(() => { design.updateDesign({ textColor: '#eeeeee', mutedTextColor: '#f0f0f0', linkColor: '#fafafa', primaryColor: '#ffffff' }); }); + advanced(); + const all = notes().join(' | '); + expect(all).toContain('Text on background: Low contrast'); + expect(all).toContain('Muted text on background: Low contrast'); + expect(all).toContain('Link on background: Low contrast'); + expect(all).toContain('Button text on primary: Low contrast'); + }); + + test('"Use #xxxxxx" on the text colour applies the suggestion', () => { + mount(); + h.act(() => { design.updateDesign({ textColor: '#eeeeee' }); }); + advanced(); + const use = h.container.querySelector('.contrast-note__use')!; + h.act(() => { use.click(); }); + expect(design.design.textColor).toMatch(/^#[0-9a-f]{6}$/); + expect(design.design.textColor).not.toBe('#eeeeee'); + }); +}); diff --git a/craft/src/panels/right/SiteDesignPanel.tsx b/craft/src/panels/right/SiteDesignPanel.tsx index efa7311..030a595 100644 --- a/craft/src/panels/right/SiteDesignPanel.tsx +++ b/craft/src/panels/right/SiteDesignPanel.tsx @@ -9,6 +9,8 @@ import { AssetPicker } from '../../ui/AssetPicker'; import { inputStyle, ColorPickerField } from './styles/shared'; import { openProfileWizard } from '../sitesmith/ProfileWizard'; import { PALETTES, paletteUpdates, paletteMatches } from '../../constants/palettes'; +import { SwatchManager } from './SwatchManager'; +import { siteColorPairs } from '../../utils/contrast'; type DesignTab = 'basic' | 'advanced'; @@ -20,11 +22,26 @@ interface ColorFieldProps { label: string; value: string; onChange: (value: string) => void; + /** Id of a site colour pair (utils/contrast siteColorPairs) this field belongs to. */ + pair?: 'text-bg' | 'muted-bg' | 'link-bg' | 'button-primary'; } -const ColorField: React.FC = ({ label, value, onChange }) => ( - -); +const ColorField: React.FC = ({ label, value, onChange, pair }) => { + const { design } = useSiteDesign(); + const p = pair ? siteColorPairs(design).find((x) => x.id === pair) : undefined; + // The other side of the pair is a hex in the design (these fields are the token source). + // Text/muted/link are the foreground against the background; the button pair is the + // primary colour (the background of the button) against the page background (its text). + const other = p ? design.backgroundColor : undefined; + return ( + + ); +}; /* ---------- Try a palette ---------- */ @@ -299,6 +316,7 @@ export const SiteDesignPanel: React.FC = () => { updateDesign({ primaryColor: v })} /> @@ -324,6 +342,7 @@ export const SiteDesignPanel: React.FC = () => { /> updateDesign({ linkColor: v })} /> @@ -335,6 +354,7 @@ export const SiteDesignPanel: React.FC = () => { <> updateDesign({ primaryColor: v })} /> @@ -370,11 +390,13 @@ export const SiteDesignPanel: React.FC = () => { /> updateDesign({ textColor: v })} /> updateDesign({ mutedTextColor: v })} /> @@ -400,6 +422,7 @@ export const SiteDesignPanel: React.FC = () => { /> updateDesign({ linkColor: v })} /> @@ -420,6 +443,8 @@ export const SiteDesignPanel: React.FC = () => { )} + + {/* Reset button */}