Site Design panel: Saved colours section + inline contrast on text/muted/link/primary-button

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 10:01:21 -08:00
1 parent faaa8a87a0
commit dcf9c92fee
2 files changed
+90 -3

No files matched your search

@@ -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(
<EditorConfigProvider config={{ user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1, siteDomain: 'example.com', siteName: 'E', backUrl: '/', isRoot: false }}>
<SaveGuardProvider><PageProvider><SiteDesignProvider><Probe /><SiteDesignPanel /></SiteDesignProvider></PageProvider></SaveGuardProvider>
</EditorConfigProvider>,
);
}
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<HTMLButtonElement>('.contrast-note__use')!;
h.act(() => { use.click(); });
expect(design.design.textColor).toMatch(/^#[0-9a-f]{6}$/);
expect(design.design.textColor).not.toBe('#eeeeee');
});
});
+28 -3
View File
@@ -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<ColorFieldProps> = ({ label, value, onChange }) => (
<ColorPickerField label={label} value={value} onChange={onChange} showTokens={false} />
);
const ColorField: React.FC<ColorFieldProps> = ({ 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 (
<ColorPickerField
label={label} value={value} onChange={onChange} showTokens={false}
contrastWith={other}
contrastRole={pair === 'button-primary' ? 'bg' : 'fg'}
contrastLabel={p?.label}
/>
);
};
/* ---------- Try a palette ---------- */
@@ -299,6 +316,7 @@ export const SiteDesignPanel: React.FC = () => {
</div>
<ColorField
label="Primary Color"
pair="button-primary"
value={design.primaryColor}
onChange={(v) => updateDesign({ primaryColor: v })}
/>
@@ -324,6 +342,7 @@ export const SiteDesignPanel: React.FC = () => {
/>
<ColorField
label="Link Color"
pair="link-bg"
value={design.linkColor}
onChange={(v) => updateDesign({ linkColor: v })}
/>
@@ -335,6 +354,7 @@ export const SiteDesignPanel: React.FC = () => {
<>
<ColorField
label="Primary Color"
pair="button-primary"
value={design.primaryColor}
onChange={(v) => updateDesign({ primaryColor: v })}
/>
@@ -370,11 +390,13 @@ export const SiteDesignPanel: React.FC = () => {
/>
<ColorField
label="Text Color"
pair="text-bg"
value={design.textColor}
onChange={(v) => updateDesign({ textColor: v })}
/>
<ColorField
label="Muted Text Color"
pair="muted-bg"
value={design.mutedTextColor}
onChange={(v) => updateDesign({ mutedTextColor: v })}
/>
@@ -400,6 +422,7 @@ export const SiteDesignPanel: React.FC = () => {
/>
<ColorField
label="Link Color"
pair="link-bg"
value={design.linkColor}
onChange={(v) => updateDesign({ linkColor: v })}
/>
@@ -420,6 +443,8 @@ export const SiteDesignPanel: React.FC = () => {
</>
)}
<SwatchManager />
{/* Reset button */}
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--color-border)' }}>
<button