From 4343150170b7e5debfe78ef88d863327b5bf8b07 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:16:57 +0200 Subject: [PATCH] perf(dashboard): place live EPG progress fills without layout or animation (#1721) * perf(dashboard): slide live EPG progress fills with a compositor transform The live-programme bars on channel rail cards and the hero animated their width over 0.4 s whenever the 30 s live-EPG tick moved them. Width is a layout property, so each tick re-laid out the whole document for about 24 frames, also while the window was minimized. Slide a full-width fill with translateX driven by a --live-progress custom property instead; Chromium runs that transition on the compositor. Reduced motion drops it. Co-Authored-By: Claude Opus 5.5 * test(dashboard): format the live progress spec * perf(dashboard): stop animating the live EPG progress fills A live-EPG tick moves the bar by under one percent, so the 0.4 s transform transition was invisible but still produced a burst of compositor frames on every tick. Place the fill without a transition. Co-Authored-By: Claude Opus 5.5 * test(dashboard): pin the hero progress fill to its custom property The hero moved into DashboardHeroComponent (#1738); its progress bar gets the same transform fill as the rail cards. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../dashboard-live-progress-compositor.md | 8 ++++++ .../lib/rails/dashboard-hero.component.html | 2 +- .../lib/rails/dashboard-hero.component.scss | 7 ++++- .../rails/dashboard-hero.component.spec.ts | 13 +++++++++ .../lib/rails/dashboard-rail.component.html | 2 +- .../lib/rails/dashboard-rail.component.scss | 7 ++++- .../rails/dashboard-rail.component.spec.ts | 28 +++++++++++++++++++ 7 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 .changes/dashboard-live-progress-compositor.md diff --git a/.changes/dashboard-live-progress-compositor.md b/.changes/dashboard-live-progress-compositor.md new file mode 100644 index 000000000..d6d7d6ae1 --- /dev/null +++ b/.changes/dashboard-live-progress-compositor.md @@ -0,0 +1,8 @@ +--- +type: perf +area: dashboard +--- + +Live programme progress bars on the dashboard now move without animating or +redrawing the whole page, so a dashboard left open with recent live channels +uses less CPU and GPU. diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html index 49f632790..3336ca5f3 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html @@ -141,7 +141,7 @@ aria-valuemax="100" [attr.aria-valuenow]="slide.progress" > - + } 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 408d15802..42055bebd 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 @@ -339,10 +339,15 @@ i { display: block; + width: 100%; height: 100%; border-radius: inherit; background: var(--app-selection-color, #78adff); - transition: width 0.4s ease; + // A full-width fill slid into place, neither a width nor animated: a + // live-EPG tick moves it by under one percent, so an animation was + // invisible yet cost a layout pass (width) or a burst of compositor + // frames (transform transition) on every tick. + transform: translateX(calc(var(--hero-progress, 0) * 1% - 100%)); } &--live i { 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 c05b4250f..f149b7fa0 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 @@ -254,6 +254,19 @@ describe('DashboardHeroComponent', () => { ).toBe('/workspace/a'); }); + it('positions the progress fill through a custom property, never its width', () => { + // Animating width re-lays out the page on every live-EPG tick; the + // stylesheet slides the fill with a transform driven by this property. + slides.set([ + { ...slide('a', 'Live'), contentType: 'live', progress: 42 }, + ]); + render(); + + const fill = host().querySelector('.hero__progress i'); + expect(fill?.style.getPropertyValue('--hero-progress')).toBe('42'); + expect(fill?.style.width).toBe(''); + }); + it('renders nothing when there is nothing to feature', () => { slides.set([]); render(); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index 2dff0ea0d..649460d5b 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -126,7 +126,7 @@ " > diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss index 485e34ced..0816357bb 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss @@ -727,10 +727,15 @@ i { display: block; + width: 100%; height: 100%; background: var(--app-live-color, #ff8f8f); border-radius: 2px; - transition: width 0.4s ease; + // A full-width fill slid into place, neither a width nor animated: a + // live-EPG tick moves it by under one percent, so an animation was + // invisible yet cost a layout pass (width) or a burst of compositor + // frames (transform transition) on every tick. + transform: translateX(calc(var(--live-progress, 0) * 1% - 100%)); } &--idle i { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts index f5f748fdb..5d84cb49c 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts @@ -335,6 +335,34 @@ describe('DashboardRailComponent', () => { expect(visible.at(-1)).toEqual(['a', 'b']); }); + it('positions the live progress fill through a custom property, never its width', async () => { + // Animating width re-lays out the page for every frame of the + // transition on each EPG tick; the stylesheet slides the fill with + // a compositor transform driven by this property instead. + installObservers(false); + const { fixture } = await render([ + card({ + id: 'known', + contentType: 'live', + nowPlayingProgress: 37.5, + }), + card({ id: 'unknown', contentType: 'live' }), + ]); + const fills = Array.from( + ( + fixture.nativeElement as HTMLElement + ).querySelectorAll('.rail__channel-progress i') + ); + expect(fills).toHaveLength(2); + expect(fills[0].style.getPropertyValue('--live-progress')).toBe( + '37.5' + ); + expect(fills[1].style.getPropertyValue('--live-progress')).toBe( + '0' + ); + expect(fills.map((fill) => fill.style.width)).toEqual(['', '']); + }); + it('shows the placeholder only while a live card is pending its first answer', async () => { installObservers(false); const { fixture } = await render([