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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-29 20:48:55 +02:00
1 parent 963a431bb7
commit 0590eeec15
6 files changed
+46 -4

No files matched your search

@@ -0,0 +1,8 @@
---
type: perf
area: dashboard
---
Live programme progress bars on the dashboard now advance without redrawing
the whole page, so a dashboard left open with recent live channels uses less
CPU and GPU.
@@ -141,7 +141,7 @@
aria-valuemax="100"
[attr.aria-valuenow]="slide.progress"
>
<i [style.width.%]="slide.progress"></i>
<i [style.--hero-progress]="slide.progress"></i>
</span>
}
@@ -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 {
@@ -126,7 +126,7 @@
"
>
<i
[style.width.%]="
[style.--live-progress]="
card.nowPlayingProgress ?? 0
"
></i>
@@ -727,10 +727,19 @@
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 view, not an animated width: the
// transform transition runs on the compositor, so each live-EPG tick
// no longer re-lays out the whole page for every transition frame.
transform: translateX(calc(var(--live-progress, 0) * 1% - 100%));
transition: transform 0.4s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
&--idle i {
@@ -335,6 +335,26 @@ 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<HTMLElement>(
'.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([