feat(dashboard): align the cinematic hero with the details pages

The hero's primary is the same light Continue/Play as the details pages
and its chips are the shared meta chip. A title without a 16:9 backdrop
gets the blurred poster alone as the stage (the sharp key-art copy is
gone; a live channel's logo still sits on the right). Series titles drop
their season marker. The hero is a focusable region: ←/→ switch slides,
Enter follows the primary action, and rotation pauses while the document
is hidden.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-02 08:55:51 +02:00
1 parent c2f5d67120
commit aac8021553
9 files changed
+193 -58

No files matched your search

@@ -0,0 +1,6 @@
---
type: feature
area: workspace
---
The dashboard hero now shares the details pages' look: the same light Continue button and chips, a series title without its "(Season 1)" marker, and the blurred poster alone as the backdrop when a title has none. The hero can be focused: ← and → switch slides, Enter follows the main action, and rotation pauses while the window is hidden.
+11 -3
View File
@@ -132,13 +132,21 @@ discovery slides open the detail page; live slides open the channel. TMDB
extras (backdrop, rating, genres, overview, year) come from
`DashboardHeroTmdbService` per featured title and vanish when TMDB is off.
Artwork: a title's backdrop, else its poster blurred and scaled past the
edges (no second, sharp copy); a live channel's logo sits on the right as key
art over its own wash. Series titles drop their season marker
(`splitSeasonSuffix`) — the `S1·E1` chip names the season. Chips are
`app-meta-chip`; the primary is the details pages' light primary
(`light-primary-button` from `libs/ui/styles`).
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
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
Hover, focus inside the hero, a hidden document 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 hero is a focusable
region: ←/→ switch slides and Enter follows the primary action. 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`,
@@ -5,6 +5,7 @@
"prefix": "lib",
"projectType": "library",
"tags": ["scope:workspace", "domain:workspace", "type:feature"],
"implicitDependencies": ["ui-styles"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
@@ -230,6 +230,21 @@ describe('DashboardHeroSlidesPresenter', () => {
);
});
it('drops a season marker from a series title and leaves live titles alone', () => {
recentItems.set([
{ ...series, title: 'Big Pharma (1 сезон)' },
watchedMovie,
]);
candidates.set([
{ origin: 'favorite', item: { ...channel, title: 'Sport S01' } },
]);
const presenter = create();
const titles = presenter.slides().map((slide) => slide.title);
expect(titles).toContain('Big Pharma');
expect(titles).not.toContain('Big Pharma (1 сезон)');
expect(titles).toContain('Sport S01');
});
it('resumes an unfinished series and offers a detail-only way in', () => {
const [resume] = create().slides();
@@ -11,7 +11,10 @@ import {
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util';
import {
isPortalPlaybackWatched,
splitSeasonSuffix,
} from '@iptvnator/portal/shared/util';
import {
playlistDisplayLabel,
resolvePortalActivityWatchKind,
@@ -228,7 +231,9 @@ export class DashboardHeroSlidesPresenter {
id: `${source.kind}:${dashboardHeroItemKey(item)}`,
kind: source.kind,
contentType: item.type,
title: item.title,
// "Большая фарма (1 сезон)" → "Большая фарма": the episode badge
// already names the season.
title: isLive ? item.title : splitSeasonSuffix(item.title).title,
// Watch kind: a Stalker embedded-VOD show routes as a movie
typeLabelKey:
TYPE_LABEL_KEYS[
@@ -3,6 +3,7 @@
class="hero"
data-test-id="dashboard-hero"
aria-roledescription="carousel"
tabindex="0"
[attr.aria-label]="'WORKSPACE.DASHBOARD.HERO_LABEL' | translate"
[class.hero--paused]="paused()"
[class.hero--rotating]="canRotate()"
@@ -10,6 +11,7 @@
(mouseleave)="setHovered(false)"
(focusin)="onFocusIn()"
(focusout)="onFocusOut($event)"
(keydown)="onHeroKeydown($event)"
>
<!-- Every slide's backdrop stays mounted so switching crossfades
and the next image is already decoded. -->
@@ -43,9 +45,13 @@
"
(error)="presenter.markImageFailed(backdropUrl)"
/>
<!-- No 16:9 backdrop: the sharp poster (or channel
logo) becomes key art on the right. -->
@if (slide.backdropSource === 'poster') {
<!-- A channel logo is not a backdrop: it sits on the
right as the slide's artwork. A title's poster
only washes the stage, blurred. -->
@if (
slide.backdropSource === 'poster' &&
slide.contentType === 'live'
) {
<img
class="hero__backdrop-keyart"
[src]="backdropUrl"
@@ -90,31 +96,39 @@
<p class="hero__meta" data-test-id="dashboard-hero-badges">
@if (slide.episodeBadge) {
<span class="hero__pill">{{ slide.episodeBadge }}</span>
<app-meta-chip class="hero__pill">{{
slide.episodeBadge
}}</app-meta-chip>
}
@if (slide.rating) {
<span class="hero__pill hero__pill--rating"
>★ {{ slide.rating }}</span
<app-meta-chip class="hero__pill" variant="rating"
>★ {{ slide.rating }}</app-meta-chip
>
}
@for (genre of slide.genres; track genre) {
<span class="hero__pill">{{ genre }}</span>
<app-meta-chip class="hero__pill">{{
genre
}}</app-meta-chip>
}
@if (slide.category) {
<span class="hero__pill">{{ slide.category }}</span>
<app-meta-chip class="hero__pill">{{
slide.category
}}</app-meta-chip>
}
@if (slide.timeRange) {
<span class="hero__pill hero__pill--time">{{
<app-meta-chip class="hero__pill hero__pill--time">{{
slide.timeRange
}}</span>
}}</app-meta-chip>
}
@if (slide.year) {
<span class="hero__pill">{{ slide.year }}</span>
<app-meta-chip class="hero__pill">{{
slide.year
}}</app-meta-chip>
}
@if (slide.source) {
<span class="hero__pill hero__pill--source">{{
<app-meta-chip class="hero__pill hero__pill--source">{{
slide.source
}}</span>
}}</app-meta-chip>
}
</p>
@@ -8,6 +8,8 @@
// light theme gets a light scrim with dark text and the dark theme the
// reverse, with the same geometry.
@use '../../../../../../ui/styles/detail-view-actions' as detail-actions;
// The host renders nothing when there is no slide and no skeleton; with
// `display: contents` it then adds no flex gap to the rails page either.
:host {
@@ -34,13 +36,6 @@
var(--app-on-surface, #e6e1e5) 12%,
transparent
);
// Gold in the dark theme, a deeper amber on the light one: mixed with
// the theme's heading colour rather than detected per theme.
--hero-rating: color-mix(
in srgb,
#f5b400 60%,
var(--app-heading-color, #d8dce8)
);
position: relative;
// A floor, not a fixed height: the slide sits in normal flow at the
@@ -59,6 +54,11 @@
overflow: hidden;
isolation: isolate;
color: var(--hero-heading);
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: -2px;
}
}
// ── Backdrops ──────────────────────────────────────────────────────────────
@@ -101,19 +101,18 @@
}
// No 16:9 backdrop (Xtream/Stalker often lack one): the poster, blurred and
// scaled, washes its colours across the whole stage…
// scaled past its edges, is the backdrop. No second, sharp copy of it.
.hero__backdrop--poster .hero__backdrop-image {
filter: blur(48px) saturate(1.4);
transform: scale(1.35);
opacity: 0.8;
filter: blur(24px) saturate(1.2);
transform: scale(1.12);
}
.hero__backdrop--poster.hero__backdrop--active .hero__backdrop-image {
transform: scale(1.3);
transform: scale(1.1);
}
// …and the sharp poster fills the right-hand side as key art, faded into
// that wash, so the slide still shows the actual title artwork.
// A live channel's logo fills the right-hand side as key art, faded into
// its own blurred wash, so the slide still shows the channel's artwork.
.hero__backdrop-keyart {
position: absolute;
top: 0;
@@ -278,25 +277,12 @@
}
.hero__pill {
padding: 3px 8px;
border-radius: 999px;
background: var(--hero-pill-bg);
color: var(--hero-heading);
white-space: nowrap;
&--rating {
color: var(--hero-rating);
font-weight: 600;
}
&--time {
font-variant-numeric: tabular-nums;
}
&--source {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
color: var(--hero-body);
}
}
@@ -396,19 +382,13 @@
transform: translateY(1px);
}
// The same light primary as the details pages' Continue / Play.
&--primary {
background: var(--app-selection-color, #78adff);
color: var(--app-selection-on-color, #071225);
box-shadow: 0 6px 18px -8px
var(--app-selection-glow, rgba(120, 173, 255, 0.6));
@include detail-actions.light-primary-button;
&:hover {
background: color-mix(
in srgb,
var(--app-selection-color, #78adff) 88%,
#fff
);
}
height: 38px;
padding: 0 18px 0 14px;
font-size: 13px;
}
&--secondary {
@@ -277,6 +277,68 @@ describe('DashboardHeroComponent', () => {
host().querySelector('[data-test-id=dashboard-hero]')
).toBeNull();
});
it('switches slides with the arrow keys and follows Enter on the hero itself', () => {
render();
const section = host().querySelector(
'[data-test-id=dashboard-hero]'
) as HTMLElement;
expect(section.getAttribute('tabindex')).toBe('0');
section.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
);
fixture.detectChanges();
expect(activeTitle()).toBe('Second');
section.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })
);
fixture.detectChanges();
expect(activeTitle()).toBe('First');
const primary = host().querySelector<HTMLElement>(
'[data-test-id=dashboard-hero-primary-action]'
) as HTMLElement;
const clicked = jest.fn();
primary.addEventListener('click', (event) => {
event.preventDefault();
clicked();
});
section.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
);
expect(clicked).toHaveBeenCalledTimes(1);
// Keys pressed inside a control belong to that control.
const dot = dots()[0];
dot.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
);
expect(clicked).toHaveBeenCalledTimes(1);
});
it('pauses the rotation while the document is hidden', () => {
const visibility = jest
.spyOn(document, 'visibilityState', 'get')
.mockReturnValue('visible');
render();
const section = host().querySelector(
'[data-test-id=dashboard-hero]'
) as HTMLElement;
expect(section.classList).not.toContain('hero--paused');
visibility.mockReturnValue('hidden');
document.dispatchEvent(new Event('visibilitychange'));
fixture.detectChanges();
expect(section.classList).toContain('hero--paused');
visibility.mockReturnValue('visible');
document.dispatchEvent(new Event('visibilitychange'));
fixture.detectChanges();
expect(section.classList).not.toContain('hero--paused');
visibility.mockRestore();
});
});
describe('DashboardHeroComponent rotation animation', () => {
@@ -10,6 +10,7 @@ import {
import { MatIcon } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { MetaChipComponent } from '@iptvnator/ui/components';
import { DashboardHeroSlidesPresenter } from './dashboard-hero-slides.presenter';
import { HERO_ROTATION_MS } from './dashboard-hero-slides.utils';
import type { DashboardHeroSlide } from './dashboard-hero.utils';
@@ -29,10 +30,13 @@ const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
*
* The active slide is tracked by id, so a slide that arrives late (the live
* slide waits for its EPG answer) never yanks the user off the current one.
*
* The hero is a focusable region: ←/→ switch slides, Enter follows the
* primary action. Rotation also pauses while the document is hidden.
*/
@Component({
selector: 'lib-dashboard-hero',
imports: [MatIcon, RouterLink, TranslatePipe],
imports: [MatIcon, MetaChipComponent, RouterLink, TranslatePipe],
templateUrl: './dashboard-hero.component.html',
styleUrl: './dashboard-hero.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -74,6 +78,7 @@ export class DashboardHeroComponent {
});
private readonly hovered = signal(false);
private readonly focusWithin = signal(false);
private readonly documentHidden = signal(false);
readonly userPaused = signal(false);
readonly reducedMotion = signal(false);
@@ -93,10 +98,23 @@ export class DashboardHeroComponent {
);
readonly paused = computed(
() => this.userPaused() || this.hovered() || this.focusWithin()
() =>
this.userPaused() ||
this.hovered() ||
this.focusWithin() ||
this.documentHidden()
);
constructor() {
if (typeof document !== 'undefined') {
const onVisibility = () =>
this.documentHidden.set(document.visibilityState === 'hidden');
onVisibility();
document.addEventListener('visibilitychange', onVisibility);
inject(DestroyRef).onDestroy(() =>
document.removeEventListener('visibilitychange', onVisibility)
);
}
const media =
typeof window !== 'undefined' && window.matchMedia
? window.matchMedia(REDUCED_MOTION_QUERY)
@@ -156,6 +174,32 @@ export class DashboardHeroComponent {
}
}
/**
* On the hero region itself: ←/→ switch slides, Enter follows the
* primary action. Buttons and dots inside keep their own keys.
*/
onHeroKeydown(event: KeyboardEvent): void {
if (event.target !== event.currentTarget) {
return;
}
const count = this.slides().length;
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
if (count < 2) {
return;
}
event.preventDefault();
const step = event.key === 'ArrowRight' ? 1 : -1;
this.show((this.activeIndex() + step + count) % count);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
(event.currentTarget as HTMLElement)
.querySelector<HTMLElement>('.hero__button--primary')
?.click();
}
}
/** Roving arrows between the dots, like a tab list. */
onDotKeydown(event: KeyboardEvent, index: number): void {
const count = this.slides().length;