From e4ba07b47e7b33215bb40230b363f41d361674c2 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:53:05 -0800 Subject: [PATCH] ContrastNote: inline low-contrast hint with suggestion Co-Authored-By: Claude Sonnet 5.5 --- .../panels/right/styles/ContrastNote.test.tsx | 59 ++++++++++++++++++ .../src/panels/right/styles/ContrastNote.tsx | 60 +++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 craft/src/panels/right/styles/ContrastNote.test.tsx create mode 100644 craft/src/panels/right/styles/ContrastNote.tsx diff --git a/craft/src/panels/right/styles/ContrastNote.test.tsx b/craft/src/panels/right/styles/ContrastNote.test.tsx new file mode 100644 index 0000000..3ad4f3e --- /dev/null +++ b/craft/src/panels/right/styles/ContrastNote.test.tsx @@ -0,0 +1,59 @@ +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 { ContrastNote } from './ContrastNote'; +import { SiteDesignProvider } from '../../../state/SiteDesignContext'; + +let container: HTMLDivElement | null = null; +let root: Root; +function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { root = createRoot(container!); root.render({ui}); }); +} +afterEach(() => { + if (!container) return; + act(() => { root.unmount(); }); + container.remove(); + container = null; +}); + +describe('ContrastNote', () => { + test('renders nothing when the pair passes', () => { + render(); + expect(container!.innerHTML).toBe(''); + }); + + test('renders nothing for an unresolved colour', () => { + render(); + expect(container!.innerHTML).toBe(''); + }); + + test('fail shows ratio and threshold in a status region', () => { + render(); + const el = container!.querySelector('[role="status"]')!; + expect(el.textContent).toBe('Low contrast 2.32:1 (needs 4.5:1)'); + expect(container!.querySelector('button')).toBeNull(); + }); + + test('large text uses the 3:1 threshold; label prefixes', () => { + render(); + expect(container!.textContent).toBe('Heading: Low contrast 1.61:1 (needs 3:1)'); + }); + + test('suggestion button calls back with a passing hex', () => { + const cb = vi.fn(); + render(); + const btn = container!.querySelector('button')!; + expect(btn.textContent).toMatch(/^Use #[0-9a-f]{6}$/); + act(() => { btn.click(); }); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.calls[0][0]).toMatch(/^#[0-9a-f]{6}$/); + }); + + test('gradient background shows a muted not-checked note', () => { + render(); + expect(container!.querySelector('[role="status"]')!.textContent).toBe('Contrast not checked (gradient)'); + }); +}); diff --git a/craft/src/panels/right/styles/ContrastNote.tsx b/craft/src/panels/right/styles/ContrastNote.tsx new file mode 100644 index 0000000..9df4e6a --- /dev/null +++ b/craft/src/panels/right/styles/ContrastNote.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { useSiteDesign } from '../../../state/SiteDesignContext'; +import { checkContrast, suggestFix } from '../../../utils/contrast'; + +export interface ContrastNoteProps { + fg: string; + bg: string; + large?: boolean; + label?: string; + onUseSuggestion?: (hex: string) => void; +} + +/** + * Compact inline contrast hint. Silent when the pair passes or cannot be + * judged for a reason not worth mentioning; amber (not alarm red) on failure; + * muted "not checked" for gradient / image backgrounds. + */ +export const ContrastNote: React.FC = ({ fg, bg, large, label, onUseSuggestion }) => { + const { design } = useSiteDesign(); + const v = checkContrast(fg, bg, design, { large }); + + if (v.status === 'pass') return null; + + if (v.status === 'unchecked') { + if (v.reason !== 'gradient' && v.reason !== 'image') return null; + return ( +
+ Contrast not checked ({v.reason}) +
+ ); + } + + const fix = onUseSuggestion ? suggestFix(fg, bg, design, { large }) : null; + return ( +
+ + {label ? `${label}: ` : ''}Low contrast {v.ratio!.toFixed(2)}:1 (needs {v.required}:1) + + {fix && ( + + )} +
+ ); +};