fix(dashboard): hero legibility in the light theme and stable page heading (#1811)

UI-24 from the UI consistency audit.

- No-artwork slides paint their gradient in CSS from the slide hue: a light
  tint in the light theme, unchanged near-black in the dark one. The dark
  gradient under the light page-coloured scrim read as a grey slab.
- The side scrim holds 88% of the page colour up to the slide's right edge
  (inset + min(560px, 55%)), so the end of a full slide no longer sits on
  about 45%.
- Narrow layout (container <= 720px): a full-bleed 90% scrim behind the text
  block, a scrim-coloured text shadow, and an entrance without a fade so
  that scrim never flashes the art on a rotation.
- --hero-body is 85% of the heading colour (was 72%).
- Light --app-rating-color #a16207 -> #7a4a00: measured 3.36:1 on the chip
  over artwork, now 5.10:1. The details pages share the chip and token.
- Buttons cap at the slide width and end long labels in an ellipsis.
- The page gets one visually hidden h1 ("Dashboard"); slide titles are h2.
- One live region outside the re-created slide announces slide changes;
  progress bars are named and VOD ones read "N% watched"; dots are 24px.

dashboard-hero-legibility.e2e.ts replaces every image with a checkerboard
and measures each piece of slide text from the screen in both themes, wide
and narrow, for backdrop, poster, no-artwork and live slides. On master the
worst cases were 2.35:1 (body text) and 2.65:1 (pills).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-04 16:51:58 +02:00
1 parent 26ca8e2cb0
commit 7a629f5fe5
14 files changed
+743 -120

No files matched your search

@@ -0,0 +1,10 @@
---
type: fix
area: dashboard
---
The dashboard hero stays readable in the light theme: titles without artwork
get a light tinted backdrop instead of a dark slab, text keeps a solid backing
over busy artwork (also in narrow windows), and the star rating is darker.
Screen readers now find one stable "Dashboard" page heading and hear slide
changes they make.
@@ -29,6 +29,11 @@ import {
routePlayableStreams,
startAndConfirmPlayback,
} from './playable-stream-fixture';
import {
addCurrentDetailToFavorites,
goBackFromDetail,
toggleFavoriteForChannel,
} from './dashboard-e2e-flows';
test.describe('Dashboard Activation', () => {
test('opens live favorites in the collection route and movies/series in global collection detail views from the dashboard', async ({
@@ -305,38 +310,6 @@ function dashboardRailCardByTitle(
.first();
}
async function goBackFromDetail(page: Page): Promise<void> {
// Return to the list: the header's Back is route-level in browse and
// watch alike (closing the player is the bar's own Close button).
const backButton = page.getByTestId('workspace-header-back');
await expect(backButton).toBeVisible({ timeout: 20000 });
try {
await backButton.click({ timeout: 5000 });
} catch {
await backButton.evaluate((button: HTMLButtonElement) =>
button.click()
);
}
}
// By accessible name, not class: the Xtream movie detail's favorite control is
// an icon-only button that carries its label in aria-label, while series and
// Stalker details still use the labeled variant. This matches both.
async function addCurrentDetailToFavorites(page: Page): Promise<void> {
const addButton = page
.getByRole('button', { name: /add to favorites/i })
.first();
await expect(addButton).toBeVisible({ timeout: 20000 });
await addButton.click();
await expect(
page.getByRole('button', { name: /remove from favorites/i }).first()
).toBeVisible({
timeout: 20000,
});
}
async function expectInlineCollectionDetail(
page: Page,
params: {
@@ -383,20 +356,3 @@ async function playFirstSeriesEpisode(page: Page): Promise<void> {
await expect(episodeCard).toBeVisible({ timeout: 20000 });
await episodeCard.click();
}
async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
const item = page
.locator('[data-test-id="channel-item"]')
.filter({ hasText: title })
.first();
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
/star/
);
}
@@ -0,0 +1,54 @@
import type { Page } from '@playwright/test';
import { expect } from './electron-test-fixtures';
// Steps that put content on the dashboard: favourites from the Live TV list
// and from a detail page, and the way back from that detail page.
export async function goBackFromDetail(page: Page): Promise<void> {
// Return to the list: the header's Back is route-level in browse and
// watch alike (closing the player is the bar's own Close button).
const backButton = page.getByTestId('workspace-header-back');
await expect(backButton).toBeVisible({ timeout: 20000 });
try {
await backButton.click({ timeout: 5000 });
} catch {
await backButton.evaluate((button: HTMLButtonElement) =>
button.click()
);
}
}
// By accessible name, not class: the Xtream movie detail's favorite control is
// an icon-only button that carries its label in aria-label, while series and
// Stalker details still use the labeled variant. This matches both.
export async function addCurrentDetailToFavorites(page: Page): Promise<void> {
const addButton = page
.getByRole('button', { name: /add to favorites/i })
.first();
await expect(addButton).toBeVisible({ timeout: 20000 });
await addButton.click();
await expect(
page.getByRole('button', { name: /remove from favorites/i }).first()
).toBeVisible({
timeout: 20000,
});
}
export async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
const item = page
.locator('[data-test-id="channel-item"]')
.filter({ hasText: title })
.first();
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
/star/
);
}
@@ -0,0 +1,355 @@
import type { Locator, Page } from '@playwright/test';
import { writeFileSync } from 'node:fs';
import sharp = require('sharp');
import {
addXtreamPortal,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expect,
goToDashboard,
launchElectronApp,
openWorkspaceSection,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
fetchXtreamLiveFixture,
fetchXtreamSeriesFixture,
fetchXtreamVodFixture,
getXtreamTitle,
} from './portal-mock-fixtures';
import { applyTheme, measureBackdropTextContrast } from './theme-contrast';
import {
addCurrentDetailToFavorites,
goBackFromDetail,
toggleFavoriteForChannel,
} from './dashboard-e2e-flows';
// ---------------------------------------------------------------------------
// The dashboard hero's text must stay legible over any artwork, in both
// themes and in the narrow layout where the slide spans the whole width.
// Every mock image is replaced by a black-and-white checkerboard, the worst
// case for either theme's scrim; series images fail, so the favourited
// series falls back to the generated gradient. Each slide kind (16:9
// backdrop, blurred poster, no artwork, live channel) is measured from the
// screen at a wide and a narrow content width.
// ---------------------------------------------------------------------------
type SlideKind = 'backdrop' | 'poster' | 'fallback' | 'live';
const widths = { wide: 1280, narrow: 760 } as const;
const minimumContrast = 4.5;
const xtreamCredentials = {
username: defaultXtreamUsername,
password: defaultXtreamPassword,
};
/** A full TMDB slide has a rating and a two-line overview; the mock has no
* TMDB, so the measurement adds both, styled by the hero's own rules. */
const sampleOverview =
'A long synopsis that wraps onto a second line, so the body text of a ' +
'fully enriched slide is measured where it really sits over the artwork.';
async function busyArtwork(): Promise<Buffer> {
const width = 1280;
const height = 720;
const square = 40;
const pixels = Buffer.alloc(width * height * 3);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const white =
(Math.floor(x / square) + Math.floor(y / square)) % 2 === 0;
pixels.fill(
white ? 255 : 0,
(y * width + x) * 3,
(y * width + x) * 3 + 3
);
}
}
return sharp(pixels, { raw: { width, height, channels: 3 } })
.png()
.toBuffer();
}
/** Serves the checkerboard for every mock image except series artwork,
* which fails: an image the page has already shown is reused from memory,
* so the failure has to be in place before the series is first opened. */
async function routeArtwork(page: Page): Promise<void> {
const image = await busyArtwork();
await page.route(
(url) => url.hostname === 'picsum.photos',
(route) =>
/\/seed\/series-/.test(route.request().url())
? route.fulfill({ status: 404, body: '' })
: route.fulfill({
status: 200,
contentType: 'image/png',
body: image,
})
);
}
async function slideKinds(page: Page): Promise<SlideKind[]> {
return page.locator('.hero__backdrop').evaluateAll((backdrops) =>
backdrops.map((backdrop): SlideKind => {
if (backdrop.classList.contains('hero__backdrop--live')) {
return 'live';
}
if (!backdrop.querySelector('.hero__backdrop-image')) {
return 'fallback';
}
return backdrop.classList.contains('hero__backdrop--poster')
? 'poster'
: 'backdrop';
})
);
}
async function showSlide(page: Page, index: number): Promise<Locator> {
const dot = page.getByTestId('dashboard-hero-dot').nth(index);
await dot.click();
await expect(dot).toHaveAttribute('aria-current', 'true');
// The backdrop crossfade and the slide's entrance have finished (not the
// image's slow zoom, which never changes what is under the text).
await expect
.poll(() =>
page.evaluate(() =>
[
document.querySelector('.hero__backdrop--active'),
document.querySelector('.hero__content'),
].every(
(element) =>
element &&
element
.getAnimations()
.every(
(animation) => animation.playState !== 'running'
)
)
)
)
.toBe(true);
return page.getByTestId('dashboard-hero-slide');
}
/** Sum of non-input layout shifts while the hero runs through every slide
* on its own, at a shortened interval. */
async function rotationLayoutShift(page: Page): Promise<number> {
const hero = page.getByTestId('dashboard-hero');
const dots = page.getByTestId('dashboard-hero-dot');
const count = await dots.count();
await page.mouse.move(1, 1);
await hero.evaluate((element) => {
(element as HTMLElement).style.setProperty(
'--hero-rotation-ms',
'600ms'
);
const shifts: number[] = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries() as (PerformanceEntry & {
value: number;
hadRecentInput: boolean;
})[]) {
if (!entry.hadRecentInput) {
shifts.push(entry.value);
}
}
}).observe({ type: 'layout-shift' });
(window as unknown as { __heroShifts: number[] }).__heroShifts = shifts;
});
// Back to the first slide after one full cycle, then a quiet moment.
const first = await dots.evaluateAll((all) =>
all.findIndex((dot) => dot.getAttribute('aria-current') === 'true')
);
for (let step = 1; step <= count; step++) {
await expect(dots.nth((first + step) % count)).toHaveAttribute(
'aria-current',
'true',
{ timeout: 5_000 }
);
}
await page.waitForTimeout(500);
return page.evaluate(() =>
(window as unknown as { __heroShifts: number[] }).__heroShifts.reduce(
(sum, value) => sum + value,
0
)
);
}
/** Adds what a TMDB-enriched slide shows: a rating chip and an overview. */
async function enrichSlide(slide: Locator): Promise<void> {
await slide.evaluate((content, overview) => {
const chip = content.querySelector('.hero__pill');
if (chip) {
const rating = chip.cloneNode() as HTMLElement;
rating.classList.add('meta-chip--rating');
rating.textContent = '★ 7.4';
chip.before(rating);
}
// The overview takes the title's view encapsulation attribute, so the
// hero's `.hero__description` rule styles it.
const title = content.querySelector('.hero__title');
const scope = Array.from(title?.attributes ?? []).find((attribute) =>
attribute.name.startsWith('_ngcontent')
);
const actions = content.querySelector('.hero__actions');
if (scope && actions && !content.querySelector('.hero__description')) {
const description = document.createElement('p');
description.setAttribute(scope.name, '');
description.className = 'hero__description';
description.textContent = overview;
actions.before(description);
}
}, sampleOverview);
await expect(slide.locator('.hero__description')).toHaveCount(1);
}
/** Every piece of slide text a viewer reads, one element per colour. */
function slideTexts(slide: Locator): Record<string, Locator> {
return {
eyebrow: slide.locator('.hero__eyebrow > span:not(.hero__eyebrow-sep)'),
title: slide.locator('.hero__title'),
pill: slide.locator('.hero__pill'),
programme: slide.locator('.hero__programme'),
description: slide.locator('.hero__description'),
button: slide.locator('.hero__button > span'),
};
}
test.describe('Dashboard hero legibility', () => {
test('keeps slide text at 4.5:1 over any artwork in both themes and widths', async ({
dataDir,
request,
}, testInfo) => {
test.setTimeout(240_000);
await resetMockServers(request, ['xtream']);
const live = await fetchXtreamLiveFixture(request, xtreamCredentials);
const vod = await fetchXtreamVodFixture(request, xtreamCredentials);
const series = await fetchXtreamSeriesFixture(
request,
xtreamCredentials
);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
const results: string[] = [];
try {
await routeArtwork(page);
await page.setViewportSize({ width: widths.wide, height: 800 });
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
// Live slide: a favourite channel with a programme on air.
await openWorkspaceSection(page, 'Live TV');
await clickCategoryByNameExact(page, live.categoryName);
await toggleFavoriteForChannel(page, getXtreamTitle(live.items[0]));
// Backdrop slide: favouriting from the detail page stores the
// movie's 16:9 backdrop.
await page
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryByNameExact(page, vod.categoryName);
await clickFirstGridListCard(page);
await addCurrentDetailToFavorites(page);
await goBackFromDetail(page);
// No-artwork slide: series images fail, poster and backdrop.
await page
.getByRole('link', { name: 'Series', exact: true })
.click();
await clickCategoryByNameExact(page, series.categoryName);
await clickFirstGridListCard(page);
await addCurrentDetailToFavorites(page);
await goToDashboard(page);
await expect(page.getByTestId('dashboard-hero')).toBeVisible({
timeout: 20_000,
});
// Poster-only slides come from the Xtream "recently added" list.
await expect
.poll(async () => [...new Set(await slideKinds(page))].sort(), {
timeout: 20_000,
})
.toEqual(['backdrop', 'fallback', 'live', 'poster']);
// One stable page heading; the rotating slide title is an h2.
await expect(page.locator('h1')).toHaveCount(1);
await expect(page.getByTestId('dashboard-page-heading')).toHaveText(
'Dashboard'
);
await expect(
page
.getByTestId('dashboard-hero-slide')
.locator('h2.hero__title')
).toHaveCount(1);
// An unattended rotation, counted like the launch journey's
// settled layout-shift counter (non-input shifts only). Slides of
// different heights still resize the hero by a few pixels and
// move the rails below (0.005 here, 0.013 before this change);
// a scrim or heading that reflowed the slide would add lines.
const shift = await rotationLayoutShift(page);
results.push(`rotation layout shift ${shift.toFixed(3)}`);
expect(shift).toBeLessThan(0.02);
await page.getByTestId('dashboard-hero-pause').click();
const kinds = await slideKinds(page);
for (const theme of ['light', 'dark'] as const) {
await applyTheme(page, theme);
for (const [layout, width] of Object.entries(widths)) {
await page.setViewportSize({ width, height: 800 });
// The narrow layout is the dashboard container's
// ≤720px query, not the window width.
const narrow = await page
.locator('.hero__content')
.evaluate(
(content) =>
getComputedStyle(content).maxWidth === 'none'
);
expect(narrow).toBe(layout === 'narrow');
for (const [index, kind] of kinds.entries()) {
const slide = await showSlide(page, index);
await enrichSlide(slide);
await page.mouse.move(1, 1);
const name = `${theme}-${layout}-${kind}`;
const shot = testInfo.outputPath(`hero-${name}.png`);
await page
.getByTestId('dashboard-hero')
.screenshot({ path: shot });
await testInfo.attach(name, {
path: shot,
contentType: 'image/png',
});
for (const [part, texts] of Object.entries(
slideTexts(slide)
)) {
for (const text of await texts.all()) {
const ratio = await measureBackdropTextContrast(
page,
text
);
results.push(
`${name} ${part} ${ratio.toFixed(2)}`
);
expect
.soft(ratio, `${name} ${part}`)
.toBeGreaterThanOrEqual(minimumContrast);
}
}
}
}
}
} finally {
const report = testInfo.outputPath('contrast.txt');
writeFileSync(report, results.join('\n'));
await testInfo.attach('contrast', {
path: report,
contentType: 'text/plain',
});
await closeElectronApp(app);
}
});
});
@@ -198,3 +198,98 @@ export async function expectSkeletonContrast(
).toBeGreaterThanOrEqual(1.3);
}
}
/** Contrast of an element's own text against whatever is painted behind it
* (images, gradient scrims, translucent chips), read back from the screen.
* The text is made transparent for the capture, so its `text-shadow` stays
* in the backdrop it is meant to support; its CSS colour (with alpha and
* ancestor opacity) is then composited over every pixel under its line
* boxes. Returns the worst ratio, so one dark patch of artwork under one
* letter fails it. */
export async function measureBackdropTextContrast(
page: Page,
text: Locator
): Promise<number> {
await expect(text).toBeVisible();
const probe = await text.evaluate((element) => {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = getComputedStyle(element).color;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
let alpha = a / 255;
for (
let node: Element | null = element;
node;
node = node.parentElement
) {
alpha *= Number(getComputedStyle(node).opacity);
}
const range = document.createRange();
range.selectNodeContents(element);
const box = range.getBoundingClientRect();
const style = (element as HTMLElement).style;
const previous = style.getPropertyValue('color');
style.setProperty('color', 'transparent', 'important');
(element as HTMLElement).dataset['contrastPrevious'] = previous;
return {
color: [r, g, b, alpha],
// Whole pixels inside the line boxes, clear of glyph edges that
// spill past them.
clip: {
x: Math.ceil(box.left),
y: Math.ceil(box.top),
width: Math.max(1, Math.floor(box.width) - 1),
height: Math.max(1, Math.floor(box.height) - 1),
},
};
});
try {
const { data, info } = await sharp(
await page.screenshot({ clip: probe.clip })
)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const [fr, fg, fb, fa] = probe.color;
const luminance = (r: number, g: number, b: number) =>
[r, g, b]
.map((value) => {
const s = value / 255;
return s <= 0.04045
? s / 12.92
: ((s + 0.055) / 1.055) ** 2.4;
})
.reduce(
(sum, value, index) =>
sum + value * [0.2126, 0.7152, 0.0722][index],
0
);
let worst = Infinity;
for (let i = 0; i < data.length; i += info.channels) {
const [br, bg, bb] = [data[i], data[i + 1], data[i + 2]];
const front = luminance(
fr * fa + br * (1 - fa),
fg * fa + bg * (1 - fa),
fb * fa + bb * (1 - fa)
);
const back = luminance(br, bg, bb);
worst = Math.min(
worst,
(Math.max(front, back) + 0.05) / (Math.min(front, back) + 0.05)
);
}
return worst;
} finally {
await text.evaluate((element) => {
const style = (element as HTMLElement).style;
const previous = (element as HTMLElement).dataset[
'contrastPrevious'
];
style.removeProperty('color');
if (previous) style.setProperty('color', previous);
delete (element as HTMLElement).dataset['contrastPrevious'];
});
}
}
+3 -2
View File
@@ -157,8 +157,9 @@ html {
--app-cta-fg: #f5f6f8;
--app-cta-hover-bg: #2a2f3a;
--app-cta-meta-fg: rgba(245, 246, 248, 0.68);
// Star rating chip: amber on white needs a deeper tone than gold on dark.
--app-rating-color: #a16207;
// Star rating chip: a deep amber, since the chip's 12px text needs
// 4.5:1 over hero artwork under the light scrim (#a16207 measured 3.4:1).
--app-rating-color: #7a4a00;
.dark-theme {
@include mat.all-component-colors($dark-theme);
+27 -1
View File
@@ -137,7 +137,33 @@ 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`).
(`light-primary-button` from `libs/ui/styles`). With no artwork at all the
stage is a gradient in the title's hue (`--hero-hue`), light in the light
theme and near-black in the dark one; a dark gradient under the light
theme's page-coloured scrim read as a grey slab behind dark text.
Legibility: slide text stays at 4.5:1 or more over any artwork. The side
scrim holds 88% of the page colour up to the slide's right edge
(`--hero-text-edge`: the inset plus `min(560px, 55%)`, the slide's own
`max-width`) before it opens onto the art. In the narrow layout (`dashboard`
container ≤ 720px) the slide spans the width, so a full-bleed scrim sits
behind the text block (90%, fading in just above the eyebrow), the copy gets
a scrim-coloured text shadow, and the slide enters without a fade so that
scrim never flashes the art on a rotation. Body text is 85% of the heading
colour; the rating chip uses `--app-rating-color`, set per theme in
`m3-theme.scss`. Buttons end long labels in an ellipsis.
`dashboard-hero-legibility.e2e.ts` replaces every image with a black-and-white
checkerboard and measures each piece of slide text from the screen in both
themes, at a wide and a narrow width, for a backdrop, a blurred-poster, a
no-artwork and a live slide.
Semantics: the page has one stable, visually hidden `h1` ("Dashboard",
`dashboard-page-heading`); each slide title is an `h2`, like the rail titles.
Slide changes are announced by one polite live region
(`dashboard-hero-announcement`, position and title) that lives outside the
re-created slide and is silent while the slides rotate on their own. A
slide's progress bar is named after its title (a live slide: the programme)
and a title's reads "N% watched". The dots are 24px targets (WCAG 2.5.8).
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
@@ -221,7 +221,6 @@ export class DashboardHeroSlidesPresenter {
{
backdropUrl: item.backdrop_url || extras?.backdropUrl || null,
posterUrl: item.poster_url,
title: item.title,
},
this.failedImages()
);
@@ -26,14 +26,7 @@
[class.hero__backdrop--live]="slide.contentType === 'live'"
[style.--hero-hue]="slide.accentHue"
>
<span
class="hero__backdrop-fallback"
[style.background]="
slide.contentType === 'live'
? null
: slide.fallbackBackdropBackground
"
></span>
<span class="hero__backdrop-fallback"></span>
@if (slide.backdropUrl; as backdropUrl) {
<img
class="hero__backdrop-image"
@@ -80,7 +73,6 @@
count: slides().length,
}
"
[attr.aria-live]="canRotate() && !paused() ? 'off' : 'polite'"
>
<p class="hero__eyebrow">
<span
@@ -92,7 +84,7 @@
<span>{{ slide.reasonLabelKey | translate }}</span>
</p>
<h1 class="hero__title">{{ slide.title }}</h1>
<h2 class="hero__title">{{ slide.title }}</h2>
<p class="hero__meta" data-test-id="dashboard-hero-badges">
@if (slide.episodeBadge) {
@@ -154,6 +146,13 @@
aria-valuemin="0"
aria-valuemax="100"
[attr.aria-valuenow]="slide.progress"
[attr.aria-label]="slide.programmeTitle ?? slide.title"
[attr.aria-valuetext]="
slide.contentType === 'live'
? null
: ('WORKSPACE.DASHBOARD.PERCENT_WATCHED'
| translate: { value: slide.progress })
"
>
<i [style.--hero-progress]="slide.progress"></i>
</span>
@@ -196,6 +195,22 @@
</div>
</div>
}
<!-- Outside the @for: a live region has to be in the page before
its text changes, or the change is not announced. Silent while
the slides rotate on their own. -->
<p
class="visually-hidden"
data-test-id="dashboard-hero-announcement"
aria-atomic="true"
[attr.aria-live]="canRotate() && !paused() ? 'off' : 'polite'"
>
{{
'WORKSPACE.DASHBOARD.HERO_SLIDE_POSITION'
| translate
: { index: activeIndex() + 1, count: slides().length }
}}
{{ active.title }}
</p>
@if (slides().length > 1) {
<div class="hero__controls">
@@ -24,13 +24,22 @@
var(--dashboard-gutter, 28px) +
max(0px, (100cqw - var(--dashboard-max-width, 1440px)) / 2)
);
// The slide's widest extent, and its right edge measured from the
// hero's left edge: the scrim holds its text-safe opacity up to there.
--hero-text-width: min(560px, 55%);
--hero-text-edge: calc(var(--hero-inset) + var(--hero-text-width));
--hero-scrim: var(--app-content-bg, #161a22);
--hero-heading: var(--app-heading-color, #d8dce8);
--hero-body: color-mix(
in srgb,
var(--app-heading-color, #d8dce8) 72%,
var(--app-heading-color, #d8dce8) 85%,
var(--hero-scrim)
);
// Saturation and lightness of the no-artwork gradient. Light by
// default: a near-black gradient under the light theme's page-coloured
// scrim turned into a grey slab behind dark text.
--hero-fallback-from: 55% 90%;
--hero-fallback-to: 60% 78%;
--hero-pill-bg: color-mix(
in srgb,
var(--app-on-surface, #e6e1e5) 12%,
@@ -61,6 +70,11 @@
}
}
:host-context(.dark-theme) .hero {
--hero-fallback-from: 50% 15%;
--hero-fallback-to: 80% 5%;
}
// ── Backdrops ──────────────────────────────────────────────────────────────
.hero__stage {
position: absolute;
@@ -88,6 +102,15 @@
height: 100%;
}
// No artwork at all: a gradient tinted by the title's hue (`--hero-hue`).
.hero__backdrop-fallback {
background: linear-gradient(
135deg,
hsl(var(--hero-hue) var(--hero-fallback-from)) 0%,
hsl(calc(var(--hero-hue) + 60) var(--hero-fallback-to)) 100%
);
}
.hero__backdrop-image {
display: block;
object-fit: cover;
@@ -173,6 +196,9 @@
}
}
// Text never sits on less than 88% of the page colour: the side gradient
// holds that up to the slide's right edge and only then opens onto the art.
// Fixed stops at 30% / 60% left the end of a full-width slide on ~45%.
.hero__scrim {
position: absolute;
inset: 0;
@@ -180,8 +206,10 @@
linear-gradient(
90deg,
color-mix(in srgb, var(--hero-scrim) 96%, transparent) 0%,
color-mix(in srgb, var(--hero-scrim) 85%, transparent) 30%,
color-mix(in srgb, var(--hero-scrim) 35%, transparent) 60%,
color-mix(in srgb, var(--hero-scrim) 88%, transparent)
var(--hero-text-edge),
color-mix(in srgb, var(--hero-scrim) 35%, transparent)
calc(var(--hero-text-edge) + 18%),
transparent 100%
),
linear-gradient(
@@ -205,7 +233,7 @@
flex-direction: column;
align-items: flex-start;
gap: 12px;
max-width: min(560px, 55%);
max-width: var(--hero-text-width);
animation: hero-content-in 420ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
@@ -220,6 +248,17 @@
}
}
// The narrow layout's entrance: no fade, because the slide carries its own
// scrim there and a fading scrim would flash the art on every rotation.
@keyframes hero-content-rise {
from {
transform: translateY(8px);
}
to {
transform: none;
}
}
.hero__eyebrow {
display: flex;
align-items: center;
@@ -356,6 +395,10 @@
padding: 0 18px 0 14px;
box-sizing: border-box;
border-radius: 10px;
// A long label (or translation) ends in an ellipsis instead of pushing
// the button past a narrow slide; the icon keeps its size.
max-width: 100%;
min-width: 0;
font-size: 13px;
font-weight: 600;
line-height: 1;
@@ -366,7 +409,14 @@
box-shadow 0.15s ease,
transform 0.15s ease;
> span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
mat-icon {
flex: 0 0 auto;
width: 20px;
height: 20px;
font-size: 20px;
@@ -471,15 +521,16 @@
.hero__dots {
display: flex;
align-items: center;
gap: 2px;
}
// 6px dots inside a 20px hit area; the active one stretches into a pill
// whose fill runs for one rotation interval.
// 6px dots inside a 24px hit area (WCAG 2.5.8 target size), 2px apart; the
// active one stretches into a pill whose fill runs for one rotation interval.
.hero__dot {
display: grid;
place-items: center;
width: 20px;
height: 20px;
width: 24px;
height: 24px;
padding: 0;
border: 0;
background: transparent;
@@ -516,7 +567,7 @@
}
&--active {
width: 30px;
width: 32px;
&::before {
width: 18px;
@@ -527,8 +578,8 @@
.hero__dot-fill {
grid-area: 1 / 1;
justify-self: start;
// Aligns with the 18px pill centred in the 30px button.
margin-left: 6px;
// Aligns with the 18px pill centred in the 32px button.
margin-left: 7px;
width: 0;
height: 6px;
border-radius: 3px;
@@ -632,6 +683,29 @@
// Clears the rotation controls, which move to the top here.
padding: 64px 0 28px;
max-width: none;
animation-name: hero-content-rise;
text-shadow: 0 1px 2px
color-mix(in srgb, var(--hero-scrim) 80%, transparent);
}
// The slide spans the whole width here, over the art: the full-bleed
// scrim behind the text block keeps it on at least 90% of the page
// colour, fading in just above the eyebrow.
.hero__content::before {
content: '';
position: absolute;
z-index: -1;
inset: 24px calc(-1 * var(--hero-inset)) 0;
background: linear-gradient(
180deg,
transparent,
color-mix(in srgb, var(--hero-scrim) 90%, transparent) 40px
);
pointer-events: none;
}
.hero__button {
text-shadow: none;
}
.hero__controls {
@@ -27,8 +27,6 @@ const slide = (id: string, title: string): DashboardHeroSlide => ({
progress: null,
accentHue: 200,
backdropSource: 'fallback',
fallbackBackdropBackground: 'none',
fallbackPosterBackground: 'none',
hasBackdrop: false,
primaryAction: {
labelKey: 'WORKSPACE.DASHBOARD.HERO_CONTINUE',
@@ -46,7 +44,7 @@ describe('DashboardHeroComponent', () => {
const host = () => fixture.nativeElement as HTMLElement;
const activeTitle = () =>
host().querySelector('[data-test-id=dashboard-hero-slide] h1')
host().querySelector('[data-test-id=dashboard-hero-slide] h2')
?.textContent;
const dots = () =>
Array.from(
@@ -256,6 +254,78 @@ describe('DashboardHeroComponent', () => {
).toBe('/workspace/a');
});
it('titles each slide with an h2 and leaves the h1 to the page', () => {
render();
expect(host().querySelector('h1')).toBeNull();
expect(activeTitle()).toBe('First');
});
it('announces slide changes through one live region that outlives the slides', () => {
// A live region inserted together with its text is not announced,
// so it must not belong to the slide that the @for re-creates.
render();
const announcement = () =>
host().querySelector<HTMLElement>(
'[data-test-id=dashboard-hero-announcement]'
);
const region = announcement();
expect(region).not.toBeNull();
expect(
region?.closest('[data-test-id=dashboard-hero-slide]')
).toBeNull();
expect(
host()
.querySelector('[data-test-id=dashboard-hero-slide]')
?.hasAttribute('aria-live')
).toBe(false);
// Rotating on its own: silent.
expect(region?.getAttribute('aria-live')).toBe('off');
expect(region?.textContent).toContain('First');
const section = host().querySelector(
'[data-test-id=dashboard-hero]'
) as HTMLElement;
section.dispatchEvent(new Event('focusin'));
dots()[1].click();
fixture.detectChanges();
expect(announcement()).toBe(region);
expect(region?.getAttribute('aria-live')).toBe('polite');
expect(region?.getAttribute('aria-atomic')).toBe('true');
expect(region?.textContent).toContain('Second');
});
it('names the progress bar after what is progressing', () => {
slides.set([
{ ...slide('a', 'Movie'), progress: 42 },
{
...slide('b', 'Channel'),
contentType: 'live',
programmeTitle: 'Evening programme',
progress: 10,
},
]);
render();
const bar = () =>
host().querySelector(
'[data-test-id=dashboard-hero-slide] [role=progressbar]'
);
expect(bar()?.getAttribute('aria-label')).toBe('Movie');
// The translate pipe echoes the key in this test bed.
expect(bar()?.getAttribute('aria-valuetext')).toBe(
'WORKSPACE.DASHBOARD.PERCENT_WATCHED'
);
dots()[1].click();
fixture.detectChanges();
expect(bar()?.getAttribute('aria-label')).toBe('Evening programme');
// Elapsed share of a live programme: the plain percentage.
expect(bar()?.hasAttribute('aria-valuetext')).toBe(false);
expect(bar()?.getAttribute('aria-valuenow')).toBe('10');
});
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.
@@ -40,7 +40,7 @@ export interface DashboardHeroSlide extends DashboardHeroArtwork {
readonly description: string | null;
/** 0-100: watched share, or elapsed share of the live programme */
readonly progress: number | null;
/** Title-derived hue for the live slide's glow (0-359) */
/** Title-derived hue (0-359): the live glow and the no-artwork gradient */
readonly accentHue: number;
readonly primaryAction: DashboardHeroAction;
readonly secondaryAction: DashboardHeroAction | null;
@@ -51,14 +51,11 @@ export type DashboardHeroBackdropSource = 'backdrop' | 'poster' | 'fallback';
export interface DashboardHeroArtworkInput {
readonly backdropUrl?: string | null;
readonly posterUrl?: string | null;
readonly title: string;
}
export interface DashboardHeroArtwork {
readonly backdropUrl?: string;
readonly backdropSource: DashboardHeroBackdropSource;
readonly fallbackBackdropBackground: string;
readonly fallbackPosterBackground: string;
readonly hasBackdrop: boolean;
readonly posterUrl?: string;
}
@@ -85,49 +82,17 @@ export function resolveDashboardHeroArtwork(
return {
backdropUrl,
backdropSource,
fallbackBackdropBackground: buildFallbackBackground(
item.title,
50,
15,
80,
5,
60
),
fallbackPosterBackground: buildFallbackBackground(
item.title,
40,
25,
50,
15,
40
),
hasBackdrop: backdropSource === 'backdrop',
posterUrl,
};
}
function buildFallbackBackground(
title: string,
saturationA: number,
lightnessA: number,
saturationB: number,
lightnessB: number,
hueOffset: number
): string {
const hue = calculateHue(title || 'placeholder');
const h2 = (hue + hueOffset) % 360;
return `linear-gradient(135deg, hsl(${hue}, ${saturationA}%, ${lightnessA}%) 0%, hsl(${h2}, ${saturationB}%, ${lightnessB}%) 100%)`;
}
/** Stable hue for a title, shared by every generated fallback surface. */
export function dashboardHeroHue(text: string): number {
return calculateHue(text || 'placeholder');
}
function calculateHue(text: string): number {
const key = text || 'placeholder';
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
for (let i = 0; i < key.length; i++) {
hash = key.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}
return Math.abs(hash) % 360;
@@ -3,6 +3,11 @@
[class.rails-page--empty]="ready() && !hasPlaylists()"
data-test-id="dashboard-page"
>
<!-- The page's one stable h1: the rail already names the page, and the
hero's rotating title is only the current slide (an h2). -->
<h1 class="visually-hidden" data-test-id="dashboard-page-heading">
{{ 'WORKSPACE.SHELL.RAIL_DASHBOARD' | translate }}
</h1>
@if (data.playlistsLoaded() && !hasPlaylists()) {
<app-empty-state
[type]="'welcome-dashboard'"
@@ -146,7 +146,6 @@ describe('buildDashboardContinueWatchingActions', () => {
describe('resolveDashboardHeroArtwork', () => {
const item = {
title: 'Broken Hero',
posterUrl: 'https://images.example.test/poster.jpg',
backdropUrl: 'https://images.example.test/backdrop.jpg',
};
@@ -173,7 +172,8 @@ describe('resolveDashboardHeroArtwork', () => {
});
});
it('uses generated fallback artwork when backdrop and poster both fail', () => {
// The stylesheet paints the no-artwork gradient from the slide's hue.
it('reports no artwork when backdrop and poster both fail', () => {
const artwork = resolveDashboardHeroArtwork(item, {
[item.backdropUrl]: true,
[item.posterUrl]: true,
@@ -185,8 +185,6 @@ describe('resolveDashboardHeroArtwork', () => {
posterUrl: undefined,
backdropUrl: undefined,
});
expect(artwork.fallbackBackdropBackground).toContain('linear-gradient');
expect(artwork.fallbackPosterBackground).toContain('linear-gradient');
});
});