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

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