From 0524e72b9f0ecb597f57a89e8f57a0fb07b7ce8e Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:03:02 +0200 Subject: [PATCH] perf(dashboard): fill the hero rotation dot on the compositor (#1758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(dashboard): fill the hero rotation dot on the compositor The active hero dot animated `width` 0 → 18px for every 8 s rotation, so an idle dashboard with two or more slides ran style, layout and paint on every frame. The fill is now a full-width bar that slides in with `transform` under the pill's rounded clip. The `animationend` advance, the pause and reduced-motion behaviour are unchanged. Measured on the E2E build (visible, four slides, 120 s): layouts 10,405-10,677 -> 366-369, renderer process CPU 14.2-16.4 s -> 3.7-4.1 s, GPU process CPU 14.8-18.8 s -> 14.0-14.8 s. Co-Authored-By: Claude Opus 5.5 * test(e2e): drive the real hero rotation animation The unit specs dispatch `animationend` on the dot span by hand, so a fill whose real event no longer reached the handler would still pass. The new Electron spec shortens `--hero-rotation-ms` and checks that the running `::before` fill advances the slide, that pause holds it and that Play resumes it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .changes/dashboard-hero-idle-rotation.md | 8 ++ .../src/dashboard-hero-rotation.e2e.ts | 80 +++++++++++++++++++ docs/architecture/idle-work-audit-2026-09.md | 4 +- docs/architecture/workspace-dashboard.md | 8 +- .../lib/rails/dashboard-hero.component.scss | 23 ++++-- .../rails/dashboard-hero.component.spec.ts | 26 ++++++ 6 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 .changes/dashboard-hero-idle-rotation.md create mode 100644 apps/electron-backend-e2e/src/dashboard-hero-rotation.e2e.ts diff --git a/.changes/dashboard-hero-idle-rotation.md b/.changes/dashboard-hero-idle-rotation.md new file mode 100644 index 000000000..eb3375970 --- /dev/null +++ b/.changes/dashboard-hero-idle-rotation.md @@ -0,0 +1,8 @@ +--- +type: perf +area: dashboard +--- + +The dashboard's rotating banner uses much less CPU while you leave it on +screen: the progress dot under it no longer makes the page re-lay itself out +on every frame. diff --git a/apps/electron-backend-e2e/src/dashboard-hero-rotation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-hero-rotation.e2e.ts new file mode 100644 index 000000000..11434ab03 --- /dev/null +++ b/apps/electron-backend-e2e/src/dashboard-hero-rotation.e2e.ts @@ -0,0 +1,80 @@ +import { Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + expect, + goToDashboard, + launchElectronApp, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; + +/** Index of the dot marked `aria-current`, i.e. the slide on screen. */ +async function activeDotIndex(page: Page): Promise { + return page + .getByTestId('dashboard-hero-dot') + .evaluateAll((dots) => + dots.findIndex((dot) => dot.getAttribute('aria-current') === 'true') + ); +} + +test.describe('Dashboard hero rotation', () => { + // The unit tests dispatch `animationend` by hand. This drives the real + // CSS fill animation, whose `animationend` comes from the fill's + // `::before` and must still reach the span's handler. + test('advances when the dot fill animation ends and holds while paused', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + + try { + // Xtream "recently added" titles alone give the hero several slides. + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + await goToDashboard(page); + + const hero = page.getByTestId('dashboard-hero'); + await expect(hero).toBeVisible(); + await expect + .poll(() => page.getByTestId('dashboard-hero-dot').count()) + .toBeGreaterThanOrEqual(2); + // Keep the pointer off the hero: hovering it pauses the rotation. + await page.mouse.move(1, 1); + // A short interval so the test does not wait out 8 s per slide. + await hero.evaluate((element) => + (element as HTMLElement).style.setProperty( + '--hero-rotation-ms', + '600ms' + ) + ); + + const first = await activeDotIndex(page); + expect(first).toBeGreaterThanOrEqual(0); + await expect + .poll(() => activeDotIndex(page), { timeout: 10_000 }) + .not.toBe(first); + + const pause = page.getByTestId('dashboard-hero-pause'); + await pause.click(); + await expect(pause).toHaveAttribute('aria-pressed', 'true'); + await page.mouse.move(1, 1); + const paused = await activeDotIndex(page); + // Several intervals: a running fill would have advanced by now. + await page.waitForTimeout(2_000); + expect(await activeDotIndex(page)).toBe(paused); + + await pause.click(); + await expect(pause).toHaveAttribute('aria-pressed', 'false'); + await page.mouse.move(1, 1); + await expect + .poll(() => activeDotIndex(page), { timeout: 10_000 }) + .not.toBe(paused); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/docs/architecture/idle-work-audit-2026-09.md b/docs/architecture/idle-work-audit-2026-09.md index 9a6123c35..bffa76340 100644 --- a/docs/architecture/idle-work-audit-2026-09.md +++ b/docs/architecture/idle-work-audit-2026-09.md @@ -183,11 +183,11 @@ code. Costs are for the dev build unless noted. ### Added after the measurement: rotating hero Not in the measured counts above; found by reading the code on master after -#1725. +#1725. The rotation row was measured later, when its follow-up landed. | Source | What it does | Period | Cost per firing | Justified | Evidence | Follow-up | | --- | --- | --- | --- | --- | --- | --- | -| [dashboard-hero.component.scss:558](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss) `hero-dot-fill` and the `(animationend)="onRotationTick()"` at [dashboard-hero.component.html:233](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html) | The active rotation dot animates `width` 0 → 18 px over `HERO_ROTATION_MS` (8 s); its `animationend` advances the slide, which starts the next fill and the backdrop's 8 s `transform` transition | Continuous while the dashboard is visible with two or more hero slides, unless the rotation is paused or reduced motion is on | A layout pass on every animation frame, because `width` is a layout property (not measured) | **Partly.** The rotation is a feature. Animating a layout property for it is not, and it runs on an otherwise idle page. | Static | **Own thread.** Animate the fill with a compositor-only `transform: scaleX()`, keep the `animationend` advance, and decide whether rotation should pause on an idle or hidden dashboard. | +| [dashboard-hero.component.scss:579](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss) `hero-dot-fill` and the `(animationend)="onRotationTick()"` at [dashboard-hero.component.html:233](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html) | The active rotation dot animated `width` 0 → 18 px over `HERO_ROTATION_MS` (8 s); its `animationend` advances the slide, which starts the next fill and the backdrop's 8 s `transform` transition | Continuous while the dashboard is visible with two or more hero slides, unless the rotation is paused or reduced motion is on | Measured afterwards (visible, four slides, 120 Hz display): about 10,500 layouts, 16 s renderer and 17 s GPU process CPU per 120 s; about 0.3 s each while paused | **Partly.** The rotation is a feature. Animating a layout property for it is not, and it runs on an otherwise idle page. | Measured after the fact | **Done.** The fill now animates `transform` only: about 370 layouts (the slide switches), 3.7 s renderer and 14 s GPU process CPU per 120 s. The GPU figure is the compositor drawing every vsync for any running animation, so going lower means animating less, not animating differently; whether rotation should stop on an idle dashboard is open. | | [dashboard-live-epg.presenter.ts:121](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts) `heroLiveCandidates` (lookup) and :191 (pinned portal keys); limits in [dashboard-hero-slides.utils.ts:14–15](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.ts) | Up to five hero live candidates (three favourites, two recent) join the 30 s XMLTV "now on air" lookup, and are pinned for the portal live-EPG queue even when their rails are hidden or scrolled away | 30 s XMLTV heartbeat while the hero is enabled and any candidate exists; portal sync on the same tick | More lookup keys per `GET_CURRENT_PROGRAMS_BATCH` IPC and SQL, and portal `get_short_epg`-style requests for pinned Xtream/Stalker candidates when stale (not measured) | **Yes** for the programme shown on the hero; the 30 s re-ask of unchanged programmes is not | Static | Covered by #1722: the clock re-asks only when a programme ended, a key has no answer, the answer is five minutes old, or the guide changed, and it pauses while hidden. | ### Checked and not periodic at idle diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index a749d9c77..76c2f6256 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -127,13 +127,19 @@ extras (backdrop, rating, genres, overview, year) come from `DashboardHeroTmdbService` per featured title and vanish when TMDB is off. Rotation is the active dot's CSS fill animation (8 s); its `animationend` -advances. Hover, focus inside the hero and the pause button pause it; an +advances. The fill animates `transform` only (a bar sliding in under the +pill's rounded clip), so it runs on the compositor; animating `width` there +cost a style, layout and paint pass on every frame of an idle dashboard. +Hover, focus inside the hero and the pause button pause it; an explicit Play clears the hover/focus pause until they re-arm; under `prefers-reduced-motion` nothing auto-advances. The active slide is tracked 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-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. ## Rail Contract diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss index 42055bebd..e42788f7a 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss @@ -552,28 +552,41 @@ width: 0; height: 6px; border-radius: 3px; - background: var(--hero-heading); + overflow: hidden; pointer-events: none; + // The fill is a full-width bar that slides in from the left under the + // pill's rounded clip. Only `transform` animates, so the compositor runs + // it: animating `width` meant style, layout and paint on every frame for + // as long as an idle dashboard stayed on screen. Its `animationend` + // reaches the span (the originating element) and advances the slide. + &::before { + content: ''; + display: block; + height: 100%; + border-radius: inherit; + background: var(--hero-heading); + } + .hero__dot--active & { width: 18px; } - .hero--rotating .hero__dot--active & { + .hero--rotating .hero__dot--active &::before { animation: hero-dot-fill var(--hero-rotation-ms, 8000ms) linear both; } - .hero--paused .hero__dot--active & { + .hero--paused .hero__dot--active &::before { animation-play-state: paused; } } @keyframes hero-dot-fill { from { - width: 0; + transform: translateX(-100%); } to { - width: 18px; + transform: none; } } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts index f149b7fa0..58a3fa020 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { signal } from '@angular/core'; import { TestBed, type ComponentFixture } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; @@ -276,3 +278,27 @@ describe('DashboardHeroComponent', () => { ).toBeNull(); }); }); + +describe('DashboardHeroComponent rotation animation', () => { + const styles = readFileSync( + resolve(__dirname, 'dashboard-hero.component.scss'), + 'utf8' + ); + const keyframes = (name: string) => + new RegExp(`@keyframes ${name}\\s*\\{([\\s\\S]*?)\\n\\}`).exec( + styles + )?.[1] ?? ''; + + // The fill runs for the whole time an idle dashboard is on screen: a + // layout property here means style, layout and paint on every frame. + it('fills the active dot with a compositor-only animation', () => { + const frames = keyframes('hero-dot-fill'); + const properties = Array.from( + frames.matchAll(/^\s*([a-z-]+)\s*:/gm), + (match) => match[1] + ); + + expect(properties.length).toBeGreaterThan(0); + expect(new Set(properties)).toEqual(new Set(['transform'])); + }); +});