perf(dashboard): fill the hero rotation dot on the compositor (#1758)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-09-30 20:03:02 +02:00
1 parent ec8b931dbf
commit 0524e72b9f
6 files changed
+141 -8

No files matched your search

@@ -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;
}
}
@@ -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']));
});
});