Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 62 additions & 14 deletions e2e/contrast-dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand All @@ -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" ||
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<Element, number>();
for (
Expand Down Expand Up @@ -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,
);
Expand Down
55 changes: 55 additions & 0 deletions e2e/contrast-fixtures.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<div style="position:relative;width:320px;background:#fff"><div id="t" style="color:${color};padding:4px">Ask the agent</div><textarea ${attributes} style="position:absolute;inset:0;margin:0;padding:4px;border:0;background:transparent;resize:none;font:inherit;${style}">${value}</textarea></div>`,
);

// 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) =>
Expand Down Expand Up @@ -86,6 +98,11 @@ const NOT_VOUCHED = [
`<div style="position:relative;width:300px;height:60px"><img src="${DARK}" style="position:absolute;inset:0;width:100%;height:100%"><div style="position:absolute;inset:0;background:rgba(255,255,255,.5);pointer-events:none"></div><span id="t" style="position:relative;color:#767676">Caption</span></div>`,
),
],
// #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(
Expand Down Expand Up @@ -130,6 +147,10 @@ const VOUCHED = [
`<p id="t" style="color:#fff;-webkit-text-fill-color:transparent;background-image:linear-gradient(90deg,#333,#000);background-clip:text;-webkit-background-clip:text;display:inline-block">Generating 2 images</p>`,
),
],
[
"a mirror under a field with transparent text",
underField("#333", "color:transparent", "Ask the agent"),
],
[
"text under an empty stretched button",
page(
Expand All @@ -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 = [
Expand Down
5 changes: 3 additions & 2 deletions e2e/contrast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
28 changes: 8 additions & 20 deletions e2e/stories-a11y.known.json
Original file line number Diff line number Diff line change
@@ -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."
}
6 changes: 3 additions & 3 deletions e2e/stories-a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(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,
Expand Down
2 changes: 2 additions & 0 deletions scripts/contrast-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const REQUIRED = [
"primary-foreground",
"primary-active",
"primary-emphasis",
"primary-tint",
"primary-tint-foreground",
"secondary",
"secondary-foreground",
"muted",
Expand Down
10 changes: 10 additions & 0 deletions src/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 6 additions & 4 deletions src/ui/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,26 @@ 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",
{
variants: {
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" },
Expand Down
Loading