mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -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
@@ -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