From a1f500369dbbf1d9051ecb0175d84933eada9ca5 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 29 Oct 2026 17:44:55 +0545 Subject: [PATCH] fix(a11y): clear the known contrast entries in the stories sweep The orange badge tones laid a translucent primary wash over whatever was below, so on a selected sidebar row the two washes stacked and the label fell to 4.38:1. They now sit on --primary-tint with --primary-tint-foreground, a static hex pair per mode that the contrast gate checks like every other fill (4.78:1 light, 4.92:1 dark). The measured fallback treated every textarea as painting, so the prompt input's transparent field hid the mirror that shows its words. A text field now paints only what its style draws: its box, its value or placeholder, and a selection. Where a field's glyphs paint nothing, the elements drawing its words that axe did not judge are sampled too. The known file maps each accepted violation to why it stands: the disabled prompt's half-opacity mirror (inactive controls are exempt from WCAG 1.4.3) and the avatar orbit, whose masked ring covers the initials in a way the sampler cannot see through. --- e2e/contrast-dom.ts | 76 ++++++++++++++++++++++++++++------- e2e/contrast-fixtures.spec.ts | 55 +++++++++++++++++++++++++ e2e/contrast.ts | 5 ++- e2e/stories-a11y.known.json | 28 ++++--------- e2e/stories-a11y.spec.ts | 6 +-- scripts/contrast-test.mjs | 2 + src/theme.css | 10 +++++ src/ui/badge.tsx | 10 +++-- 8 files changed, 149 insertions(+), 43 deletions(-) diff --git a/e2e/contrast-dom.ts b/e2e/contrast-dom.ts index e8a62ecf..0ec09e6a 100644 --- a/e2e/contrast-dom.ts +++ b/e2e/contrast-dom.ts @@ -15,10 +15,13 @@ import type { * resolved by the browser, with the hit-test stack at points across the * text so paint outside those layers is found, and so each point is * measured only on the boxes under it. `judged` lists the selectors of every - * node axe's color-contrast rule judged on the page. `null` where an element or - * ancestor paints in a way the layers cannot follow: a filter, a blend mode, - * or a clipped background that is not one plain gradient. A mask is not - * followed: like axe, the layers take it as fully opaque. + * node axe's color-contrast rule judged on the page. Where transparent glyphs + * lie over other elements' words (a mirror painting a field's text), the + * samples of those elements that axe did not judge follow the targets'. + * `null` where an element or ancestor paints in a way the layers cannot + * follow: a filter, a blend mode, or a clipped background that is not one + * plain gradient. A mask is not followed: like axe, the layers take it as + * fully opaque. */ export async function contrastSamples( page: Page, @@ -104,6 +107,36 @@ export async function contrastSamples( (child) => child.nodeType === Node.TEXT_NODE && glyphs(child.textContent), ); + // A text field paints its box like any element, and its glyphs only + // where it shows them: the value in its fill colour, or the + // placeholder while it is empty, plus a selection's highlight. Other + // fields draw a native widget. The caret is ignored: a hairline that + // blinks. + const TEXT_INPUTS = new Set(["text", "search", "email", "url", "tel"]); + const textField = ( + element: Element, + ): element is HTMLInputElement | HTMLTextAreaElement => + element instanceof HTMLTextAreaElement || + (element instanceof HTMLInputElement && TEXT_INPUTS.has(element.type)); + const fieldGlyphs = (field: HTMLInputElement | HTMLTextAreaElement) => { + if ( + document.activeElement === field && + field.selectionStart !== field.selectionEnd + ) + return true; + if (field.value === "") + return ( + glyphs(field.placeholder) && + resolve(getComputedStyle(field, "::placeholder").color)[3] > 0 + ); + const style = getComputedStyle(field); + return ( + glyphs(field.value) && + resolve( + style.getPropertyValue("-webkit-text-fill-color") || style.color, + )[3] > 0 + ); + }; // An element's own box at a hit point, its pseudo-elements aside. const paintOf = (element: Element): PaintEntry["paint"] => { if (transparent(element)) return "none"; @@ -119,8 +152,9 @@ export async function contrastSamples( const sides = ["top", "right", "bottom", "left"] as const; const paints = element instanceof SVGElement || - REPLACED.has(element.tagName) || - ownGlyphs(element) || + (textField(element) + ? fieldGlyphs(element) + : REPLACED.has(element.tagName) || ownGlyphs(element)) || resolve(style.backgroundColor)[3] > 0 || style.backgroundImage !== "none" || style.backdropFilter !== "none" || @@ -183,7 +217,8 @@ export async function contrastSamples( pseudoOnly.textContent = "* { pointer-events: none !important; } *::before, *::after { pointer-events: auto !important; }"; - // Elements whose text axe's color-contrast judges in its own right. + // Elements whose text is judged in its own right: by axe's + // color-contrast, or here, as a drawer queued below. const judgedElements = new Set( judgedSelectors.flatMap((selector) => { const element = document.querySelector(selector); @@ -272,7 +307,7 @@ export async function contrastSamples( for (const node of stack.slice(textAt + 1)) if ( !node.contains(element) && - ownGlyphs(node) && + (textField(node) ? fieldGlyphs(node) : ownGlyphs(node)) && !transparent(node) ) drawers.add(node); @@ -339,9 +374,9 @@ export async function contrastSamples( return { points, drawers }; }; - return targets.map((selector) => { - const element = document.querySelector(selector); - if (element === null) return null; + // Drawers under transparent glyphs that nothing judged yet. + const queued: Element[] = []; + const sample = (element: Element): ContrastSample | null => { // How many ancestors out from the text each element is: its `box`. const depth = new Map(); for ( @@ -423,20 +458,33 @@ export async function contrastSamples( ), }; const { points, drawers } = pointsOf(element, depth); + // Glyphs painting nothing show the drawers' words instead, so each + // drawer gets a verdict of its own. + if (fill[3] === 0 && clip === undefined) + for (const drawer of drawers) + if (!judgedElements.has(drawer)) { + judgedElements.add(drawer); + queued.push(drawer); + } return { text, fill, stroke, ...(clip === undefined ? {} : { clip }), - mirrored: - drawers.size > 0 && - [...drawers].every((drawer) => judgedElements.has(drawer)), + mirrored: drawers.size > 0, layers, points, fontSizePx: Number.parseFloat(own.fontSize), fontWeight: Number(own.fontWeight), }; + }; + + const samples = targets.map((selector) => { + const element = document.querySelector(selector); + return element === null ? null : sample(element); }); + for (const drawer of queued) samples.push(sample(drawer)); + return samples; }, [selectors, judged] as const, ); diff --git a/e2e/contrast-fixtures.spec.ts b/e2e/contrast-fixtures.spec.ts index b393e7ed..7af38e67 100644 --- a/e2e/contrast-fixtures.spec.ts +++ b/e2e/contrast-fixtures.spec.ts @@ -38,6 +38,18 @@ const placed = (markup: string) => ], ] as const; +// Words with a textarea laid over them, the textarea styled by `style` and +// holding `value`: the shape of a field over the mirror showing its text. +const underField = ( + color: string, + style: string, + value: string, + attributes = "", +) => + page( + `
Ask the agent
`, + ); + // A chip with its own white ground, rotated, whose later lines spill onto // black: client rects bound the turned chip from outside. const chip = (degrees: number, align: string) => @@ -86,6 +98,11 @@ const NOT_VOUCHED = [ `
Caption
`, ), ], + // #ccc on white is ~1.6:1; the field's own glyphs paint nothing. + [ + "a faint mirror under a field with transparent text", + underField("#ccc", "color:transparent", "Ask the agent"), + ], [ "an ancestor ::before painting an image", page( @@ -130,6 +147,10 @@ const VOUCHED = [ `

Generating 2 images

`, ), ], + [ + "a mirror under a field with transparent text", + underField("#333", "color:transparent", "Ask the agent"), + ], [ "text under an empty stretched button", page( @@ -144,6 +165,40 @@ for (const [name, html] of VOUCHED) expect(verdict).toMatchObject({ kind: "measured", pass: true }); }); +// Axe settles these pages itself, ignoring the field over the words, so +// only the fallback's verdict is pinned: a field painting its own value or +// placeholder over other text leaves that text unmeasured. +const FIELD_OVER_TEXT = [ + ["its value", underField("#333", "color:#000", "Typed over")], + ["its placeholder", underField("#333", "", "", 'placeholder="Typed over"')], +] as const; + +for (const [name, html] of FIELD_OVER_TEXT) + test(`the contrast fallback does not measure text under a field showing ${name}`, async ({ + page: tab, + }) => { + const { verdict } = await verdictFor(tab, html); + expect(verdict).toEqual({ kind: "unmeasured" }); + }); + +// A field's own glyphs paint nothing, so its words are the mirror's: the +// fallback samples the mirror too when axe gave it no verdict. +test("the contrast fallback measures the mirror under a transparent field", async ({ + page: tab, +}) => { + await tab.setContent( + underField("#ccc", "color:transparent", "Ask the agent", 'id="f"'), + ); + const [fieldSample = null, mirrorSample = null, ...rest] = + await contrastSamples(tab, ["#f"], []); + expect(judgeContrast(fieldSample)).toEqual({ kind: "mirrored" }); + expect(judgeContrast(mirrorSample)).toMatchObject({ + kind: "measured", + pass: false, + }); + expect(rest).toEqual([]); +}); + // The verdict does not depend on where the text starts: every geometry read // happens where the sampler scrolled it to. const SAME_ANYWHERE = [ diff --git a/e2e/contrast.ts b/e2e/contrast.ts index d799eef3..f5cd3481 100644 --- a/e2e/contrast.ts +++ b/e2e/contrast.ts @@ -95,8 +95,9 @@ export type ContrastSample = { readonly stroke?: { readonly width: number; readonly color: RGBA }; readonly clip?: ClippedBackground; /** - * For a field whose own glyphs are transparent: every element drawing - * glyphs under it (a mirror painting the words) is one axe judges itself. + * For a field whose own glyphs are transparent: elements draw glyphs + * under it (a mirror painting the words), and each is judged in its own + * right, by axe or by the fallback sampling the ones axe skips. */ readonly mirrored?: boolean; /** The element's and every ancestor's layers, from the element out to the root. */ diff --git a/e2e/stories-a11y.known.json b/e2e/stories-a11y.known.json index 28a2cbb4..66ac273d 100644 --- a/e2e/stories-a11y.known.json +++ b/e2e/stories-a11y.known.json @@ -1,20 +1,8 @@ -[ - "chat-prompt-input--command-suggestions (dark): color-contrast-unmeasured", - "chat-prompt-input--command-suggestions (light): color-contrast-unmeasured", - "chat-prompt-input--disabled (dark): color-contrast-unmeasured", - "chat-prompt-input--disabled (light): color-contrast-unmeasured", - "chat-prompt-input--long-draft (dark): color-contrast-unmeasured", - "chat-prompt-input--long-draft (light): color-contrast-unmeasured", - "chat-prompt-input--replying (dark): color-contrast-unmeasured", - "chat-prompt-input--replying (light): color-contrast-unmeasured", - "chat-prompt-input--suggestions-open (dark): color-contrast-unmeasured", - "chat-prompt-input--suggestions-open (light): color-contrast-unmeasured", - "chat-prompt-input--with-tokens (dark): color-contrast-unmeasured", - "chat-prompt-input--with-tokens (light): color-contrast-unmeasured", - "primitives-avatar--orbit (dark): color-contrast-unmeasured", - "primitives-avatar--orbit (light): color-contrast-unmeasured", - "primitives-avatar--stack-with-status (dark): color-contrast-unmeasured", - "primitives-avatar--stack-with-status (light): color-contrast-unmeasured", - "primitives-sidebar-panel--channels-panel (dark): color-contrast-measured", - "primitives-sidebar-panel--channels-panel (light): color-contrast-measured" -] +{ + "chat-prompt-input--disabled (dark): color-contrast-measured": "The mirror that shows a disabled field's words is faded to half opacity; text in an inactive control is exempt from WCAG 1.4.3.", + "chat-prompt-input--disabled (light): color-contrast-measured": "The mirror that shows a disabled field's words is faded to half opacity; text in an inactive control is exempt from WCAG 1.4.3.", + "primitives-avatar--orbit (dark): color-contrast-unmeasured": "The orbit ring is positioned over the initials and hollowed by a mask; hit-testing finds its whole box and the sampler takes a mask as opaque, so the ground under the initials cannot be read.", + "primitives-avatar--orbit (light): color-contrast-unmeasured": "The orbit ring is positioned over the initials and hollowed by a mask; hit-testing finds its whole box and the sampler takes a mask as opaque, so the ground under the initials cannot be read.", + "primitives-avatar--stack-with-status (dark): color-contrast-unmeasured": "The orbit ring is positioned over the initials and hollowed by a mask; hit-testing finds its whole box and the sampler takes a mask as opaque, so the ground under the initials cannot be read.", + "primitives-avatar--stack-with-status (light): color-contrast-unmeasured": "The orbit ring is positioned over the initials and hollowed by a mask; hit-testing finds its whole box and the sampler takes a mask as opaque, so the ground under the initials cannot be read." +} diff --git a/e2e/stories-a11y.spec.ts b/e2e/stories-a11y.spec.ts index 533cd678..2b6fd959 100644 --- a/e2e/stories-a11y.spec.ts +++ b/e2e/stories-a11y.spec.ts @@ -7,9 +7,9 @@ import known from "./stories-a11y.known.json" with { type: "json" }; const STORIES = "http://localhost:61000"; -// Existing violations recorded as a baseline to fix later; only a violation -// not listed here fails. -const KNOWN = new Set(known); +// Violations accepted, each keyed by story, theme and rule, with why; only a +// violation not listed here fails. +const KNOWN = new Set(Object.keys(known)); test("every story is free of serious and critical axe violations", async ({ page, diff --git a/scripts/contrast-test.mjs b/scripts/contrast-test.mjs index c2f8be22..09fd92d7 100644 --- a/scripts/contrast-test.mjs +++ b/scripts/contrast-test.mjs @@ -36,6 +36,8 @@ const REQUIRED = [ "primary-foreground", "primary-active", "primary-emphasis", + "primary-tint", + "primary-tint-foreground", "secondary", "secondary-foreground", "muted", diff --git a/src/theme.css b/src/theme.css index a1b9d7de..ec87825d 100644 --- a/src/theme.css +++ b/src/theme.css @@ -25,6 +25,8 @@ --primary-emphasis is orange as *text or border* on the ground, where the fill orange does not qualify (#e98428 on white is only 2.44:1). + --primary-tint is a pale orange fill for a chip, with the emphasis orange + as its label: an opaque hex, so it reads the same on any ground beneath it. --input is the control-boundary token and clears the 3:1 UI-component threshold on both the page and the card; --border stays soft and decorative. @@ -65,6 +67,8 @@ --primary-foreground: #2b2627; --primary-active: #d4791f; --primary-emphasis: #9a5416; + --primary-tint: #f1e9e1; + --primary-tint-foreground: #9a5416; --secondary: #c5d2de; --secondary-foreground: #2b2627; --muted: #eceff1; @@ -135,6 +139,8 @@ --primary-foreground: #000000; --primary-active: #d4791f; --primary-emphasis: #e98428; + --primary-tint: #3a2d26; + --primary-tint-foreground: #e98428; --secondary: #2d455c; --secondary-foreground: #e4d5bc; --muted: #1a1718; @@ -187,6 +193,8 @@ --primary-foreground: #000000; --primary-active: #d4791f; --primary-emphasis: #e98428; + --primary-tint: #3a2d26; + --primary-tint-foreground: #e98428; --secondary: #2d455c; --secondary-foreground: #e4d5bc; --muted: #1a1718; @@ -300,6 +308,8 @@ --color-primary-foreground: var(--primary-foreground); --color-primary-active: var(--primary-active); --color-primary-emphasis: var(--primary-emphasis); + --color-primary-tint: var(--primary-tint); + --color-primary-tint-foreground: var(--primary-tint-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); diff --git a/src/ui/badge.tsx b/src/ui/badge.tsx index 9077300f..c6f6b3c0 100644 --- a/src/ui/badge.tsx +++ b/src/ui/badge.tsx @@ -6,7 +6,9 @@ import { cn } from "../lib/utils.js"; // A compact label chip. Tones are semantic, not decorative: `accent` is the one // orange tone and — like the primary button — should stay rare on a screen. // Orange here is `primary-emphasis`, the orange that clears text contrast on the -// page and card grounds; the fill orange does not. +// page and card grounds; the fill orange does not. The chip's ground is the +// opaque `primary-tint`, so a badge on a tinted ground (a selected row) keeps +// its contrast. const badgeVariants = cva( "inline-flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-[10px] font-semibold tracking-[0.08em] uppercase", { @@ -14,16 +16,16 @@ const badgeVariants = cva( tone: { neutral: "border-border bg-muted text-muted-foreground", accent: - "border-primary-emphasis/40 bg-primary/10 text-primary-emphasis", + "border-primary-emphasis/40 bg-primary-tint text-primary-tint-foreground", info: "border-accent bg-accent text-accent-foreground", success: "border-success bg-success text-success-foreground", // Warning reuses accent amber until a dedicated warning pair lands in theme. warning: - "border-primary-emphasis/40 bg-primary/10 text-primary-emphasis", + "border-primary-emphasis/40 bg-primary-tint text-primary-tint-foreground", danger: "border-destructive bg-destructive text-destructive-foreground", shared: "border-accent bg-accent text-accent-foreground", access: - "border-primary-emphasis/40 bg-primary/10 text-primary-emphasis", + "border-primary-emphasis/40 bg-primary-tint text-primary-tint-foreground", }, }, defaultVariants: { tone: "neutral" },