diff --git a/picker/scripts/color.js b/picker/scripts/color.js index 4bbd35075..b520ec8bd 100644 --- a/picker/scripts/color.js +++ b/picker/scripts/color.js @@ -112,3 +112,37 @@ export function readableOn(accent, ground, target = 4.5) { } return darker ? '#000000' : '#FFFFFF'; } + +// Floors for judging the neutral on screen 02. The neutral paints the large +// surfaces of every preview, so two pairs matter: the fixed swatch inks that +// set text on it, and the primary fills that sit on it. +const NEUTRAL_INK_FLOOR = 7; +const NEUTRAL_PRIMARY_FLOOR = 3; + +/** + * One warning sentence when the palette's neutral will cause contrast + * trouble, or null when it is safe. + * + * Check 1: the better of the two fixed inks must reach 7:1 (the WCAG AAA + * body-text figure) on the neutral. The inks are near-black and near-white, + * so 4.5:1 is nearly impossible to fail; 7:1 is the floor that catches + * mid-tone neutrals which leave no headroom for muted and secondary text. + * + * Check 2: the primary must reach 3:1 (WCAG 1.4.11 non-text contrast) + * against the neutral, because primary button fills and accents sit directly + * on neutral surfaces and readableOn() only rescues text, never fills. + */ +export function neutralContrastIssue({ neutral, primary }) { + const surface = relativeLuminance(neutral); + const bestInk = Math.max( + ratio(INK_DARK_LUMINANCE, surface), + ratio(INK_LIGHT_LUMINANCE, surface), + ); + if (bestInk < NEUTRAL_INK_FLOOR) { + return 'This neutral is a mid-tone: even the strongest text ink stays below 7:1 on it, so type will strain on every surface. Pick a near-white or near-black neutral.'; + } + if (ratio(relativeLuminance(primary), surface) < NEUTRAL_PRIMARY_FLOOR) { + return 'Your primary sits under 3:1 against this neutral, so buttons and accents will blend into the surfaces behind them. Push the two further apart.'; + } + return null; +} diff --git a/picker/scripts/palette-picker.js b/picker/scripts/palette-picker.js index 1a1c6edcf..e6cc9ae19 100644 --- a/picker/scripts/palette-picker.js +++ b/picker/scripts/palette-picker.js @@ -1,4 +1,4 @@ -import { contrastInk, contrastInkHex, formatOklch, hexToOklch, oklchToHex, readableOn, seedToRoles } from './color.js'; +import { contrastInk, contrastInkHex, formatOklch, hexToOklch, neutralContrastIssue, oklchToHex, readableOn, seedToRoles } from './color.js'; const ROLES = ['primary', 'secondary', 'tertiary', 'neutral']; const screen = document.querySelector('[data-screen="02"]'); @@ -286,6 +286,7 @@ function renderPreview() { if (!cards.length) return; for (const role of ROLES) preview.style.setProperty(`--pv-${role}`, state().colors[role]); preview.style.setProperty('--pv-n-ink', contrastInk(state().colors.neutral)); + syncNeutralWarning(); } /* The prefix exists for the strategy stage, which needs the committed colors @@ -1175,15 +1176,42 @@ function loadCustomFace({ heading, body }) { } } -function setActiveRole(role) { - if (hint.textContent === hint.dataset[role]) return; +/* The hint shows one of three things, in priority order: the neutral + contrast warning while the working neutral is bad, the hovered or edited + role's guidance, or the idle instruction. The warning wins so an edit that + breaks the neutral is reported at the moment it happens, and the role copy + comes back on its own the moment the neutral is fixed. */ +let hintRole = 'idle'; +let neutralWarning = null; + +function hintCopy() { + return neutralWarning ?? hint.dataset[hintRole] ?? hint.dataset.idle; +} + +function paintHint() { + const wanted = hintCopy(); + if (hint.textContent === wanted) { + hint.classList.toggle('is-warning', Boolean(neutralWarning)); + return; + } hint.classList.add('is-changing'); setTimeout(() => { - hint.textContent = hint.dataset[role]; + hint.textContent = hintCopy(); + hint.classList.toggle('is-warning', Boolean(neutralWarning)); hint.classList.remove('is-changing'); }, 90); } +function setActiveRole(role) { + hintRole = role; + paintHint(); +} + +function syncNeutralWarning() { + neutralWarning = cards.length ? neutralContrastIssue(state().colors) : null; + paintHint(); +} + function setColor(role, hex, detached = true) { const itemState = state(); itemState.colors[role] = hex.toUpperCase(); diff --git a/picker/styles/picker.css b/picker/styles/picker.css index 571012a56..4268affa8 100644 --- a/picker/styles/picker.css +++ b/picker/styles/picker.css @@ -7207,6 +7207,10 @@ body.picker-page { opacity: 0; } +.picker-palette-hint.is-warning { + color: var(--ks-vermilion); +} + .picker-select { width: auto; } diff --git a/tests/picker-server.test.mjs b/tests/picker-server.test.mjs index 9e582d0b8..12d35e5c2 100644 --- a/tests/picker-server.test.mjs +++ b/tests/picker-server.test.mjs @@ -328,6 +328,18 @@ test('picker color math round-trips sRGB and clips out-of-gamut OKLCH', async () assert.equal(contrastInk('#8D7352'), 'var(--pk-ink-dark)'); }); +test('neutral contrast issue flags mid-tones and low primary separation', async () => { + const { neutralContrastIssue } = await import(colorModule); + // The fixture cue palette: near-white neutral under a deep green primary. + assert.equal(neutralContrastIssue({ neutral: '#F2EFE8', primary: '#1E4A42' }), null); + // Near-black neutral under a light primary is the other healthy shape. + assert.equal(neutralContrastIssue({ neutral: '#141414', primary: '#E8C36A' }), null); + // A mid-tone neutral: neither fixed ink reaches 7:1 on it. + assert.match(neutralContrastIssue({ neutral: '#777777', primary: '#1E4A42' }) ?? '', /near-white or near-black/); + // A primary that melts into the neutral fails the 3:1 separation check. + assert.match(neutralContrastIssue({ neutral: '#F2EFE8', primary: '#E8E4DC' }) ?? '', /3:1/); +}); + test('rejects raw, encoded, and double-encoded path traversal', async (t) => { const fixture = await createFixture(); const server = await startPicker(fixture.cwd, ['--port', String(portBase + 20)]);