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

+20 -4
View File
@@ -108,6 +108,16 @@ Render rules:
`clamp(320px, 42vh, 520px)` so the first rail starts above the fold, and uses
`--app-content-bg` as its scrim so it dissolves into the page in both themes.
Height: that clamp is a floor, not a fixed height. Every slide's content is
laid out in the same grid cell at the bottom of the banner, so it is as tall
as its tallest slide whichever one is shown, and an unusually full slide or a
long translation grows it instead of being cut. Only the active slide is
shown; the others are `inert` and `visibility: hidden`. When only the shown
slide was in flow, each automatic rotation between slides of different
heights resized the banner and moved every rail below it, every 8 s on an idle
dashboard. Late data (TMDB extras, the live slide's first EPG answer, the next
programme) can still grow the tallest slide, once, when it arrives.
Slides (`pickDashboardHeroSources`, at most four, stable order, each title
once):
@@ -161,9 +171,11 @@ Semantics: the page has one stable, visually hidden `h1` ("Dashboard",
`dashboard-page-heading`); each slide title is an `h2`, like the rail titles.
Slide changes are announced by one polite live region
(`dashboard-hero-announcement`, position and title) that lives outside the
re-created slide and is silent while the slides rotate on their own. A
slides and is silent while the slides rotate on their own. A
slide's progress bar is named after its title (a live slide: the programme)
and a title's reads "N% watched". The dots are 24px targets (WCAG 2.5.8).
and a title's reads "N% watched". The dots are 26 × 24px targets (WCAG 2.5.8)
of one fixed width; the active pill is the same 18px bar with its
`clip-path` opened, so a slide change moves no dot.
Rotation is the active dot's CSS fill animation (8 s); its `animationend`
advances. The fill animates `transform` only (a bar sliding in under the
@@ -176,10 +188,14 @@ region: ←/→ switch slides and Enter follows the primary action. The active s
by id, so a late live slide never moves the user off the current one. Test
hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`),
`dashboard-hero-dot`, `dashboard-hero-pause`,
`dashboard-hero-primary-action`, `dashboard-hero-secondary-action`.
`dashboard-hero-primary-action`, `dashboard-hero-secondary-action`. The
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.
and that pause holds the slide. `dashboard-hero-legibility.e2e.ts` sums the
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