diff --git a/.changes/dashboard-hero-light-legibility.md b/.changes/dashboard-hero-light-legibility.md new file mode 100644 index 000000000..f0dd60918 --- /dev/null +++ b/.changes/dashboard-hero-light-legibility.md @@ -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. diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts index 51eb37830..745b78058 100644 --- a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -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 { - // 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 { - 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 { await expect(episodeCard).toBeVisible({ timeout: 20000 }); await episodeCard.click(); } - -async function toggleFavoriteForChannel( - page: Page, - title: string -): Promise { - 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/ - ); -} diff --git a/apps/electron-backend-e2e/src/dashboard-e2e-flows.ts b/apps/electron-backend-e2e/src/dashboard-e2e-flows.ts new file mode 100644 index 000000000..208900940 --- /dev/null +++ b/apps/electron-backend-e2e/src/dashboard-e2e-flows.ts @@ -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 { + // 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 { + 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 { + 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/ + ); +} diff --git a/apps/electron-backend-e2e/src/dashboard-hero-legibility.e2e.ts b/apps/electron-backend-e2e/src/dashboard-hero-legibility.e2e.ts new file mode 100644 index 000000000..7accbb72e --- /dev/null +++ b/apps/electron-backend-e2e/src/dashboard-hero-legibility.e2e.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/theme-contrast.ts b/apps/electron-backend-e2e/src/theme-contrast.ts index ab8ac2cf0..895698d31 100644 --- a/apps/electron-backend-e2e/src/theme-contrast.ts +++ b/apps/electron-backend-e2e/src/theme-contrast.ts @@ -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 { + 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']; + }); + } +} diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index 439ccfd9e..f37d6d652 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -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); diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 692771f36..96caaf115 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -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 diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts index bbc15e4d0..c94f0f1f7 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts @@ -221,7 +221,6 @@ export class DashboardHeroSlidesPresenter { { backdropUrl: item.backdrop_url || extras?.backdropUrl || null, posterUrl: item.poster_url, - title: item.title, }, this.failedImages() ); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html index 078bd04e7..cb0d80a23 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html @@ -26,14 +26,7 @@ [class.hero__backdrop--live]="slide.contentType === 'live'" [style.--hero-hue]="slide.accentHue" > - + @if (slide.backdropUrl; as backdropUrl) {

{{ slide.reasonLabelKey | translate }}

-

{{ slide.title }}

+

{{ slide.title }}

@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 }) + " > @@ -196,6 +195,22 @@ } + +

+ {{ + 'WORKSPACE.DASHBOARD.HERO_SLIDE_POSITION' + | translate + : { index: activeIndex() + 1, count: slides().length } + }} + {{ active.title }} +

@if (slides().length > 1) {
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss index d7a580fd7..07ce89e4e 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss @@ -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 { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts index 3e63eb74b..68000d5f4 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts @@ -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( + '[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. diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts index 6f210ac20..4171992bb 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts @@ -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; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index a842cb511..29406c54e 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -3,6 +3,11 @@ [class.rails-page--empty]="ready() && !hasPlaylists()" data-test-id="dashboard-page" > + +

+ {{ 'WORKSPACE.SHELL.RAIL_DASHBOARD' | translate }} +

@if (data.playlistsLoaded() && !hasPlaylists()) { { 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'); }); });