From 5dffc569fff393e7a58654758c70da2e13b93280 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Fri, 7 Aug 2026 16:52:46 +0500 Subject: [PATCH] Warn on screen 02 when the palette neutral fails contrast Add neutralContrastIssue() with a 7:1 ink floor and a 3:1 primary separation floor, re-run it from renderPreview(), and paint the palette hint in vermilion while the working neutral is bad. The warning clears as soon as the neutral is fixed. Prepared with AI assistance (Cursor). Co-authored-by: Cursor --- picker/scripts/color.js | 34 ++++++++++++++++++++++++++++++ picker/scripts/palette-picker.js | 36 ++++++++++++++++++++++++++++---- picker/styles/picker.css | 4 ++++ tests/picker-server.test.mjs | 12 +++++++++++ 4 files changed, 82 insertions(+), 4 deletions(-) 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)]);