Skip to content

Commit 02bae79

Browse files
MohabMohieclaude
andauthored
Extend #837 fix to statusChip and audienceLane h2, per issue item 2 (#853)
Issue #837 flagged .statusChip and .audienceLane h2 as sharing .heroBrand's root cause (primary text on a composited/translucent background) and worth fixing in the same follow-up. Measured both with the existing pixel-sampling harness (both themes): - .statusChip (11.52px/600 -- normal-text 4.5:1 threshold, not large-text): light 2.75:1 FAIL, dark 8.57:1 PASS. - .audienceLane h2 (20px/700 -- large-text 3:1 threshold): light 3.04:1 PASS, dark 8.33:1 PASS. .audienceLane h2 needed no change (real measured PASS in both themes) -- only a regression guard. .statusChip's light-theme failure can't be closed by adjusting only the backdrop while keeping primary text: primary's own luminance caps the achievable ratio at ~3.99:1 against ANY background (even pure black), below the 4.5:1 this normal-size text needs. Repointed text to --site-color-muted (measured: light 9.04:1, dark 15.15:1), matching the #832 precedent for this exact "token-value-infeasible" shape of problem. Kept a brand-primary accent as a non-text UI border (solid `--site-color-primary`, WCAG 1.4.11's 3:1 non-text threshold, not the 4.5:1 text threshold) so the chip isn't fully degraded to grey. Renamed tests/e2e/hero-contrast.spec.js to composited-contrast.spec.js and generalized its measurement helper to cover all three elements. Also fixed a real false-positive risk found while extending it: the homepage's scroll-triggered reveal animation (opacity 0->1 over 540ms+stagger, src/pages/index.module.css:971-991) can leave a freshly-scrolled-to element still fading in (or, on first load, fully transparent) at screenshot time, silently sampling the wrong pixels. Added an explicit poll for the reveal to finish before sampling, matching homepage.spec.js's own reveal-state pattern; this also fixed the heroBrand sample points, which the finer block-average sampling revealed were 2px from the plate edge (safe under the old single-pixel probe, not safe under a 6px averaging block) -- moved them into the horizontal padding gutter at vertical mid-height, where the pill's border-radius hasn't cut in. Confirmed RED (2.75:1) for statusChip against the unfixed CSS, then GREEN after the fix. Full composited-contrast.spec.js + homepage.spec.js suite green (single-worker; a parallel-worker run showed one pre-existing, unrelated homepage.spec.js flake that passes reliably in isolation). Claude-Session: https://claude.ai/code/session_011YHoYVC5KYCDMmGtwLsEHk Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a013fe3 commit 02bae79

3 files changed

Lines changed: 193 additions & 115 deletions

File tree

src/pages/index.module.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,10 +368,10 @@
368368
align-items: center;
369369
margin-left: auto;
370370
padding: 0.1rem 0.55rem;
371-
border: 1px solid rgba(var(--site-color-primary-rgb), 0.45);
371+
border: 1px solid var(--site-color-primary);
372372
border-radius: 999px;
373373
background: rgba(var(--site-color-primary-rgb), 0.1);
374-
color: var(--site-color-primary);
374+
color: var(--site-color-muted);
375375
font-size: var(--site-font-caption);
376376
font-weight: var(--site-font-weight-medium);
377377
letter-spacing: 0.14em;
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
const {expect, test} = require('@playwright/test');
2+
3+
// Regression guard for #837. Three elements render brand-primary text over a
4+
// composited/blended background (a multi-layer CSS gradient, or a translucent
5+
// rgba() fill stacked on one) that tests/design-contrast.test.js structurally
6+
// cannot audit -- that test only checks solid (fg token, bg token) pairs parsed
7+
// straight out of src/css/custom.css, with no way to represent "the actual
8+
// blended pixel color behind this element" once gradients/opacity are involved:
9+
//
10+
// - `.heroBrand` (src/pages/index.module.css .heroBrand) -- the "SHAFT"
11+
// wordmark on `.hero`'s composited gradient/glow background.
12+
// - `.statusChip` (src/pages/index.module.css .statusChip) -- the "Pass" /
13+
// "evidence attached" pill, whose own rgba(primary, 0.1) fill sits on
14+
// `.codePanel`/`.handledPanel`'s deep-alt background.
15+
// - `.audienceLane h2` (src/pages/index.module.css .audienceLane) -- lane
16+
// titles on a translucent rgba(deep-alt, 0.5) fill over `.audienceSection`'s
17+
// deep-to-deep-alt gradient.
18+
//
19+
// This closes that gap by rendering the real homepage and pixel-sampling the
20+
// actual composited output via Playwright screenshot + canvas getImageData --
21+
// the same technique used to originally discover the `.heroBrand` failure
22+
// (issue #837) and to measure the other two (#837 item 2).
23+
24+
function relativeLuminance({r, g, b}) {
25+
const channel = (c) => {
26+
const s = c / 255;
27+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
28+
};
29+
const [rl, gl, bl] = [r, g, b].map(channel);
30+
return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl;
31+
}
32+
33+
function contrastRatio(a, b) {
34+
const lA = relativeLuminance(a);
35+
const lB = relativeLuminance(b);
36+
const [lighter, darker] = lA >= lB ? [lA, lB] : [lB, lA];
37+
return (lighter + 0.05) / (darker + 0.05);
38+
}
39+
40+
// WCAG 2.x SC 1.4.3: 3:1 for text >=24px, or >=18.66px (14pt) at bold (>=700) weight;
41+
// 4.5:1 otherwise. Computed dynamically from the real measured font metrics (not
42+
// hardcoded) so a future font-size/weight change that crosses the large-text
43+
// exemption boundary is also caught, in either direction.
44+
function requiredRatio(fontSizePx, fontWeight) {
45+
const isBold = fontWeight >= 700;
46+
const isLarge = fontSizePx >= 24 || (isBold && fontSizePx >= 18.66);
47+
return isLarge ? 3 : 4.5;
48+
}
49+
50+
// Measures the real rendered contrast of `selector`'s text against the actual
51+
// composited pixels behind it. `samplePoints(rect)` returns CSS-px viewport
52+
// coordinates -- picked per element to land on background and clear of glyph
53+
// ink/rounded corners (see each call site). Each point is averaged over a
54+
// small block (not a single pixel) to damp getImageData's 8-bit quantization
55+
// noise, which otherwise swings near-threshold ratios by several hundredths.
56+
async function measureContrast(page, selector, samplePoints) {
57+
const locator = page.locator(selector).first();
58+
await locator.scrollIntoViewIfNeeded();
59+
60+
// Sections below the hero use a scroll-triggered reveal (`[data-reveal]` /
61+
// `.reveal`, src/pages/index.module.css:971-991): opacity 0 -> 1 over a
62+
// 540ms transition (plus up to 240ms of stagger delay) once scrolled into
63+
// view. Sampling before that settles reads a still-fading-in (or, on a
64+
// fresh page load, still fully transparent/white) element instead of its
65+
// real composited color. Poll for the reveal to finish instead of guessing
66+
// a fixed wait, matching tests/e2e/homepage.spec.js's own reveal-state checks.
67+
await expect.poll(() =>
68+
locator.evaluate((el) => {
69+
const revealRoot = el.closest('[data-reveal]');
70+
return revealRoot ? getComputedStyle(revealRoot).opacity : '1';
71+
}),
72+
).toBe('1');
73+
74+
const info = await locator.evaluate((el) => {
75+
const rect = el.getBoundingClientRect();
76+
const style = getComputedStyle(el);
77+
const colorMatch = style.color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
78+
return {
79+
rect: {x: rect.x, y: rect.y, width: rect.width, height: rect.height},
80+
fg: {r: +colorMatch[1], g: +colorMatch[2], b: +colorMatch[3]},
81+
fontSizePx: parseFloat(style.fontSize),
82+
fontWeight: parseFloat(style.fontWeight),
83+
};
84+
});
85+
86+
const screenshot = (await page.screenshot()).toString('base64');
87+
const points = samplePoints(info.rect);
88+
89+
const bgSamples = await page.evaluate(async ({screenshot, points}) => {
90+
const img = new Image();
91+
img.src = `data:image/png;base64,${screenshot}`;
92+
await new Promise((resolve, reject) => {
93+
img.onload = resolve;
94+
img.onerror = reject;
95+
});
96+
const canvas = document.createElement('canvas');
97+
canvas.width = img.naturalWidth;
98+
canvas.height = img.naturalHeight;
99+
const ctx = canvas.getContext('2d');
100+
ctx.drawImage(img, 0, 0);
101+
102+
const blockSize = 6;
103+
return points.map(({x, y}) => {
104+
const d = ctx.getImageData(
105+
Math.round(x - blockSize / 2),
106+
Math.round(y - blockSize / 2),
107+
blockSize,
108+
blockSize,
109+
).data;
110+
let r = 0, g = 0, b = 0;
111+
const n = d.length / 4;
112+
for (let i = 0; i < d.length; i += 4) {
113+
r += d[i]; g += d[i + 1]; b += d[i + 2];
114+
}
115+
return {r: r / n, g: g / n, b: b / n};
116+
});
117+
}, {screenshot, points});
118+
119+
const bg = {
120+
r: bgSamples.reduce((s, p) => s + p.r, 0) / bgSamples.length,
121+
g: bgSamples.reduce((s, p) => s + p.g, 0) / bgSamples.length,
122+
b: bgSamples.reduce((s, p) => s + p.b, 0) / bgSamples.length,
123+
};
124+
125+
return {
126+
ratio: contrastRatio(info.fg, bg),
127+
required: requiredRatio(info.fontSizePx, info.fontWeight),
128+
fg: info.fg,
129+
bg,
130+
};
131+
}
132+
133+
async function assertClearsContrast(page, label, selector, samplePoints) {
134+
const light = await measureContrast(page, selector, samplePoints);
135+
expect(
136+
light.ratio,
137+
`[light] ${label}: fg rgb(${light.fg.r},${light.fg.g},${light.fg.b}) on measured bg ` +
138+
`rgb(${light.bg.r.toFixed(1)},${light.bg.g.toFixed(1)},${light.bg.b.toFixed(1)}) = ` +
139+
`${light.ratio.toFixed(2)}:1, needs >= ${light.required}:1`,
140+
).toBeGreaterThanOrEqual(light.required);
141+
142+
await page.getByLabel(/Switch between dark and light mode/).click();
143+
await page.waitForTimeout(200);
144+
145+
const dark = await measureContrast(page, selector, samplePoints);
146+
expect(
147+
dark.ratio,
148+
`[dark] ${label}: fg rgb(${dark.fg.r},${dark.fg.g},${dark.fg.b}) on measured bg ` +
149+
`rgb(${dark.bg.r.toFixed(1)},${dark.bg.g.toFixed(1)},${dark.bg.b.toFixed(1)}) = ` +
150+
`${dark.ratio.toFixed(2)}:1, needs >= ${dark.required}:1`,
151+
).toBeGreaterThanOrEqual(dark.required);
152+
}
153+
154+
test.beforeEach(async ({page}) => {
155+
await page.setViewportSize({width: 1280, height: 900});
156+
await page.goto('/');
157+
});
158+
159+
test('hero wordmark clears WCAG AA contrast against its real composited background', async ({page}) => {
160+
// Sample the plate's horizontal padding gutters (before "S" / after "T") at
161+
// vertical mid-height: at the pill's vertical center the `border-radius: 999px`
162+
// curvature hasn't cut in yet (rounding only bites near the top/bottom), so
163+
// this stays on the flat solid plate and clear of the glyph ink regardless of
164+
// sample-block size.
165+
const samplePoints = (rect) => {
166+
const midY = rect.y + rect.height / 2;
167+
return [{x: rect.x + 4, y: midY}, {x: rect.x + rect.width - 4, y: midY}];
168+
};
169+
await assertClearsContrast(page, '.heroBrand', 'a[class*="heroBrand"]', samplePoints);
170+
});
171+
172+
test('status chip clears WCAG AA contrast against its real composited background', async ({page}) => {
173+
// Sample the pill's left/right interior edges at vertical mid-height: inside
174+
// the rounded pill's straight side, clear of the short uppercase label.
175+
const samplePoints = (rect) => {
176+
const midY = rect.y + rect.height / 2;
177+
return [{x: rect.x + 3, y: midY}, {x: rect.x + rect.width - 3, y: midY}];
178+
};
179+
await assertClearsContrast(page, '.statusChip', '[class*="statusChip"]', samplePoints);
180+
});
181+
182+
test('audience lane heading clears WCAG AA contrast against its real composited background', async ({page}) => {
183+
// The h2 is a block-level grid cell wider than its own text run; sample its
184+
// empty right-hand margin and a thin sliver above the cap-height, both clear
185+
// of the glyphs regardless of the lane's actual heading text.
186+
const samplePoints = (rect) => [
187+
{x: rect.x + rect.width - 5, y: rect.y + rect.height / 2},
188+
{x: rect.x + rect.width - 5, y: rect.y + 2},
189+
];
190+
await assertClearsContrast(page, '.audienceLane h2', '[class*="audienceLane"] h2', samplePoints);
191+
});

tests/e2e/hero-contrast.spec.js

Lines changed: 0 additions & 113 deletions
This file was deleted.

0 commit comments

Comments
 (0)