mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
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'];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+5
@@ -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'"
|
||||
|
||||
+2
-4
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user