test(dashboard): guard the hero rotation at the narrow width too

The rotation guard ran only at the wide width, before any slide was
enriched. It now runs a second unattended rotation after the contrast
pass, at the narrow width, where slides wrap the most and every slide
carries a rating and a two-line overview. On master's hero that second
rotation also fails, because its dots move.

measureBackdropTextContrast clipped its screenshot to the range of the
element's line boxes. A title cut by the 2-line clamp has line boxes
below the visible box, so the probe measured the pill row there and
scored about 1.0. The Xtream mock gives the recently-added slide a
different title per run, so this failed whenever a long one came up.
The clip is now the line boxes intersected with the element's own box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-08 18:59:20 +02:00
1 parent 86e4576fc5
commit a12d554448
3 files changed
+44 -18

No files matched your search

@@ -207,6 +207,25 @@ async function rotationLayoutShift(page: Page): Promise<RotationShift> {
);
}
/** 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<void> {
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<void> {
@@ -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'));
@@ -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),
},
};
});
+3 -2
View File
@@ -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