diff --git a/apps/electron-backend-e2e/src/dashboard-hero-legibility.e2e.ts b/apps/electron-backend-e2e/src/dashboard-hero-legibility.e2e.ts index e3d301bd4..5c4e906ac 100644 --- a/apps/electron-backend-e2e/src/dashboard-hero-legibility.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-hero-legibility.e2e.ts @@ -207,6 +207,25 @@ async function rotationLayoutShift(page: Page): Promise { ); } +/** One unattended rotation moves next to nothing on the page, and nothing + * at all inside the hero. */ +async function expectStableRotation( + page: Page, + label: string, + results: string[] +): Promise { + const shift = await rotationLayoutShift(page); + const sources = shift.sources.join('; '); + results.push( + `${label} rotation layout shift ${shift.score.toFixed(5)} [${sources}]` + ); + expect(shift.score, `${label}: ${sources}`).toBeLessThan(0.001); + expect( + shift.sources.filter((source) => source.includes('hero__')), + `${label}: nothing in the hero moves` + ).toEqual([]); +} + /** Adds what a TMDB-enriched slide shows: a rating chip and an overview. * Once per slide: a slide keeps its content while another one is shown. */ async function enrichSlide(slide: Locator): Promise { @@ -321,18 +340,10 @@ test.describe('Dashboard hero legibility', () => { // heights resized it and moved every rail below by 7px (a score // of 0.005). Every rotation dot keeps its width, so the active // one no longer pushes its neighbours either. - const shift = await rotationLayoutShift(page); - results.push( - `rotation layout shift ${shift.score.toFixed(5)} ` + - `[${shift.sources.join('; ')}]` - ); - expect(shift.score, shift.sources.join('; ')).toBeLessThan(0.001); - expect( - shift.sources.filter((source) => source.includes('hero__')), - 'nothing in the hero moves' - ).toEqual([]); + await expectStableRotation(page, 'wide', results); - await page.getByTestId('dashboard-hero-pause').click(); + const pause = page.getByTestId('dashboard-hero-pause'); + await pause.click(); const kinds = await slideKinds(page); for (const theme of ['light', 'dark'] as const) { await applyTheme(page, theme); @@ -379,6 +390,12 @@ test.describe('Dashboard hero legibility', () => { } } } + + // Again at the narrow width, where slides wrap the most, now that + // every slide also carries a rating and a two-line overview. + await pause.click(); + await expect(pause).toHaveAttribute('aria-pressed', 'false'); + await expectStableRotation(page, 'narrow enriched', results); } finally { const report = testInfo.outputPath('contrast.txt'); writeFileSync(report, results.join('\n')); diff --git a/apps/electron-backend-e2e/src/theme-contrast.ts b/apps/electron-backend-e2e/src/theme-contrast.ts index 895698d31..5bf7f4269 100644 --- a/apps/electron-backend-e2e/src/theme-contrast.ts +++ b/apps/electron-backend-e2e/src/theme-contrast.ts @@ -228,7 +228,15 @@ export async function measureBackdropTextContrast( } const range = document.createRange(); range.selectNodeContents(element); - const box = range.getBoundingClientRect(); + // The line boxes, cut to the element's own box: a line-clamped or + // ellipsized text has line boxes past it that never show, and those + // would measure whatever sits there (the next row's pills). + const lines = range.getBoundingClientRect(); + const own = element.getBoundingClientRect(); + const left = Math.max(lines.left, own.left); + const top = Math.max(lines.top, own.top); + const right = Math.min(lines.right, own.right); + const bottom = Math.min(lines.bottom, own.bottom); const style = (element as HTMLElement).style; const previous = style.getPropertyValue('color'); style.setProperty('color', 'transparent', 'important'); @@ -238,10 +246,10 @@ export async function measureBackdropTextContrast( // Whole pixels inside the line boxes, clear of glyph edges that // spill past them. clip: { - x: Math.ceil(box.left), - y: Math.ceil(box.top), - width: Math.max(1, Math.floor(box.width) - 1), - height: Math.max(1, Math.floor(box.height) - 1), + x: Math.ceil(left), + y: Math.ceil(top), + width: Math.max(1, Math.floor(right - left) - 1), + height: Math.max(1, Math.floor(bottom - top) - 1), }, }; }); diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index dc4ff5432..48af3488b 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -193,8 +193,9 @@ slide hooks mark the shown slide only; the inert slides carry none. `dashboard-hero-rotation.e2e.ts` drives the real fill animation (with a shortened `--hero-rotation-ms`) to prove its `animationend` still advances and that pause holds the slide. `dashboard-hero-legibility.e2e.ts` sums the -layout shifts of one unattended rotation: under 0.001 in all, and none -inside the hero. +layout shifts of an unattended rotation, at the wide width and again at the +narrow one once every slide carries a rating and an overview: under 0.001 in +all, and none inside the hero. ## Rail Contract