fix(dashboard): keep the hero height stable across slides (#1855)

* fix(dashboard): keep the hero height stable across slides

The hero laid out only the shown slide, so every automatic rotation
between slides of different heights resized the banner and moved every
rail below it by 7px, every 8 s on an idle dashboard (a layout-shift
score of 0.005 per rotation cycle).

Every slide's content now sits in the same grid cell at the bottom of
the banner. The hero is therefore as tall as its tallest slide whichever
one is shown, and it still grows for an unusually full slide. Inactive
slides are inert and visibility: hidden, and carry no test hooks. Enter
on the hero follows the shown slide's primary action.

The rotation dots keep one fixed width. The active pill is the same
18px bar with its clip-path opened, so a slide change no longer moves
the neighbouring dots or the pill itself.

The legibility E2E now records which nodes shift. It allows under 0.001
in all, with nothing inside the hero moving. It measures 0 on this
branch and fails on master (0.0054).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-09 19:36:07 +02:00
1 parent 7bd7ff5ac7
commit f6ad98255e
8 files changed
+241 -65

No files matched your search

@@ -119,7 +119,9 @@ async function showSlide(page: Page, index: number): Promise<Locator> {
page.evaluate(() =>
[
document.querySelector('.hero__backdrop--active'),
document.querySelector('.hero__content'),
document.querySelector(
'[data-test-id=dashboard-hero-slide]'
),
].every(
(element) =>
element &&
@@ -135,9 +137,16 @@ async function showSlide(page: Page, index: number): Promise<Locator> {
return page.getByTestId('dashboard-hero-slide');
}
/** Sum of non-input layout shifts while the hero runs through every slide
* on its own, at a shortened interval. */
async function rotationLayoutShift(page: Page): Promise<number> {
interface RotationShift {
/** Sum of the non-input layout-shift scores. */
score: number;
/** What moved and by how much, e.g. `LIB-DASHBOARD-RAIL 0,7`. */
sources: string[];
}
/** Non-input layout shifts while the hero runs through every slide on its
* own, at a shortened interval. */
async function rotationLayoutShift(page: Page): Promise<RotationShift> {
const hero = page.getByTestId('dashboard-hero');
const dots = page.getByTestId('dashboard-hero-dot');
const count = await dots.count();
@@ -147,18 +156,38 @@ async function rotationLayoutShift(page: Page): Promise<number> {
'--hero-rotation-ms',
'600ms'
);
const shifts: number[] = [];
const shifts: RotationShift = { score: 0, sources: [] };
new PerformanceObserver((list) => {
for (const entry of list.getEntries() as (PerformanceEntry & {
value: number;
hadRecentInput: boolean;
sources: {
node: Node | null;
previousRect: DOMRectReadOnly;
currentRect: DOMRectReadOnly;
}[];
})[]) {
if (!entry.hadRecentInput) {
shifts.push(entry.value);
if (entry.hadRecentInput) {
continue;
}
shifts.score += entry.value;
for (const {
node,
previousRect,
currentRect,
} of entry.sources) {
const name =
node instanceof Element
? [node.nodeName, ...node.classList].join('.')
: (node?.nodeName ?? 'removed');
const dx = Math.round(currentRect.x - previousRect.x);
const dy = Math.round(currentRect.y - previousRect.y);
shifts.sources.push(`${name} ${dx},${dy}`);
}
}
}).observe({ type: 'layout-shift' });
(window as unknown as { __heroShifts: number[] }).__heroShifts = shifts;
(window as unknown as { __heroShifts: RotationShift }).__heroShifts =
shifts;
});
// Back to the first slide after one full cycle, then a quiet moment.
const first = await dots.evaluateAll((all) =>
@@ -172,19 +201,37 @@ async function rotationLayoutShift(page: Page): Promise<number> {
);
}
await page.waitForTimeout(500);
return page.evaluate(() =>
(window as unknown as { __heroShifts: number[] }).__heroShifts.reduce(
(sum, value) => sum + value,
0
)
return page.evaluate(
() =>
(window as unknown as { __heroShifts: RotationShift }).__heroShifts
);
}
/** Adds what a TMDB-enriched slide shows: a rating chip and an overview. */
/** 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> {
await slide.evaluate((content, overview) => {
const chip = content.querySelector('.hero__pill');
if (chip) {
if (chip && !content.querySelector('.meta-chip--rating')) {
const rating = chip.cloneNode() as HTMLElement;
rating.classList.add('meta-chip--rating');
rating.textContent = '★ 7.4';
@@ -287,15 +334,16 @@ test.describe('Dashboard hero legibility', () => {
).toHaveCount(1);
// An unattended rotation, counted like the launch journey's
// settled layout-shift counter (non-input shifts only). Slides of
// different heights still resize the hero by a few pixels and
// move the rails below (0.005 here, 0.013 before this change);
// a scrim or heading that reflowed the slide would add lines.
const shift = await rotationLayoutShift(page);
results.push(`rotation layout shift ${shift.toFixed(3)}`);
expect(shift).toBeLessThan(0.02);
// settled layout-shift counter (non-input shifts only), moves
// nothing. The hero lays out every slide and is as tall as the
// tallest; with only the shown slide in flow, slides of different
// 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.
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);
@@ -304,7 +352,7 @@ test.describe('Dashboard hero legibility', () => {
// The narrow layout is the dashboard container's
// ≤720px query, not the window width.
const narrow = await page
.locator('.hero__content')
.getByTestId('dashboard-hero-slide')
.evaluate(
(content) =>
getComputedStyle(content).maxWidth === 'none'
@@ -342,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),
},
};
});