ContrastNote: inline low-contrast hint with suggestion

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 09:53:05 -08:00
1 parent 453268a31c
commit e4ba07b47e
2 files changed
+119

No files matched your search

@@ -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(<SiteDesignProvider>{ui}</SiteDesignProvider>); });
}
afterEach(() => {
if (!container) return;
act(() => { root.unmount(); });
container.remove();
container = null;
});
describe('ContrastNote', () => {
test('renders nothing when the pair passes', () => {
render(<ContrastNote fg="#000000" bg="#ffffff" />);
expect(container!.innerHTML).toBe('');
});
test('renders nothing for an unresolved colour', () => {
render(<ContrastNote fg="var(--nope)" bg="#ffffff" />);
expect(container!.innerHTML).toBe('');
});
test('fail shows ratio and threshold in a status region', () => {
render(<ContrastNote fg="#aaaaaa" bg="#ffffff" />);
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(<ContrastNote fg="#cccccc" bg="#ffffff" large label="Heading" />);
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(<ContrastNote fg="#aaaaaa" bg="#ffffff" onUseSuggestion={cb} />);
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(<ContrastNote fg="#ffffff" bg="linear-gradient(red, blue)" />);
expect(container!.querySelector('[role="status"]')!.textContent).toBe('Contrast not checked (gradient)');
});
});
@@ -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<ContrastNoteProps> = ({ 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 (
<div
className="contrast-note contrast-note--unchecked"
role="status"
style={{ fontSize: 11, lineHeight: 1.4, color: 'var(--color-text-muted, #8b8b96)' }}
>
Contrast not checked ({v.reason})
</div>
);
}
const fix = onUseSuggestion ? suggestFix(fg, bg, design, { large }) : null;
return (
<div
className="contrast-note contrast-note--fail"
role="status"
style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 6, fontSize: 11, lineHeight: 1.4, color: 'var(--color-warning, #d97706)' }}
>
<span>
{label ? `${label}: ` : ''}Low contrast {v.ratio!.toFixed(2)}:1 (needs {v.required}:1)
</span>
{fix && (
<button
type="button"
className="contrast-note__use"
onClick={() => onUseSuggestion!(fix.hex)}
title={`Use ${fix.hex} (${fix.ratio.toFixed(1)}:1)`}
style={{ font: 'inherit', fontWeight: 600, color: 'inherit', background: 'none', border: '1px solid currentColor', borderRadius: 4, padding: '0 6px', cursor: 'pointer' }}
>
Use {fix.hex}
</button>
)}
</div>
);
};