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 */}