From cdb61960eecad3fcc540557a75f8c24af1747ccc Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 29 Sep 2026 22:04:22 +0200 Subject: [PATCH] fix(player): pin overlay reds to the --pc-* palette The shared player overlay keeps a fixed, theme-independent palette, but its LIVE badge, record button and recording status still read the theme error token. The theme declares no system tokens yet, so the literal fallbacks won. Once it does, the dark LIVE badge becomes white on #ffb4ab (1.7:1) and the light recording text #ba1a1a on the glass (3.0:1). - Add --pc-live (#d32f2f, white label 5.0:1; the old #e53935 was 4.2:1) and --pc-danger (#ff5252, 6.2:1 on the glass) to the palette. - Colour the active record glyph as well. The icon-button rule sets the mat-icon colour directly, so the red on the button never reached it. - Add a spec that rejects --mat-sys-* in the player-controls styles and checks both reds against their contrast floors. Co-Authored-By: Claude Opus 5.5 --- .changes/playback-overlay-reds.md | 6 + docs/architecture/iptvnator-ui-guidelines.md | 4 +- docs/architecture/player-controls-contract.md | 7 +- .../player-controls.component.scss | 14 ++- .../player-controls.palette.spec.ts | 111 ++++++++++++++++++ .../player-timeline.component.scss | 6 +- 6 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 .changes/playback-overlay-reds.md create mode 100644 libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts diff --git a/.changes/playback-overlay-reds.md b/.changes/playback-overlay-reds.md new file mode 100644 index 000000000..6e724ba07 --- /dev/null +++ b/.changes/playback-overlay-reds.md @@ -0,0 +1,6 @@ +--- +type: fix +area: playback +--- + +The player's LIVE badge uses a slightly deeper red so its label is easier to read, and the record button now turns red while a recording runs. Both reds stay the same in the light and dark themes. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 9397e23f7..3033a67cd 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -89,7 +89,9 @@ app text. Loader/stall and transient feedback overlays own a light foreground and dark scrim because they cover video. Video viewports remain black in both themes and fullscreen; frame-copy and built-in shared controls keep their light-on-dark overlay palette — the fixed `--pc-*` token set of the shared -dock (accent blue, cyan, violet and a light text ramp), never the app theme. +dock (accent blue, cyan, violet, the `--pc-live` / `--pc-danger` reds and a +light text ramp), never the app theme. The overlay styles in +`player-controls/` never read a `--mat-sys-*` token. EPG timeline, list, empty states and programme details use the library-local `libs/ui/epg/src/lib/_epg-theme.scss` palette, based on app surfaces, separators, diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index b1a770685..2417b6953 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -309,8 +309,11 @@ of its own: a timeline row above a three-column control row, sitting directly on the video over the bottom scrim. The palette is a fixed set of `--pc-*` custom properties on `:host` — accent blue `#4f8eff` for the primary action and progress, cyan `#5cd6ff` for "something is on", violet -`#b599ff` for "a value was changed", and the `#e7ecf3` / `#9aa3b2` / -`#6b7384` text ramp. They are literal on purpose: the overlay is +`#b599ff` for "a value was changed", the `#e7ecf3` / `#9aa3b2` / +`#6b7384` text ramp, and two reds: `--pc-live` `#d32f2f` fills the LIVE +badge (white label 5.0:1), and `--pc-danger` `#ff5252` colours the active +record glyph and the recording status (6.2:1 on the glass over a black +frame). They are literal on purpose: the overlay is theme-independent (see the UI guidelines' player theme boundary), and the app's `--app-selection-color` is a different blue that would fight the video. diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss index 880a52c58..fa798f490 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss @@ -12,6 +12,13 @@ --pc-accent-blue-hover: #2a66d6; --pc-accent-cyan: #5cd6ff; --pc-accent-violet: #b599ff; + // Reds, literal like the rest: the theme's error colour changes with the + // theme, and its dark-theme tone is too pale to carry a white label. + // White on the LIVE fill is 5.0:1; the earlier #e53935 gave only 4.2:1, + // under the 4.5:1 text floor. The record glyph and the recording status + // text read 6.2:1 on the glass over a black frame. + --pc-live: #d32f2f; + --pc-danger: #ff5252; --pc-text: #e7ecf3; --pc-text-secondary: #9aa3b2; --pc-text-tertiary: #6b7384; @@ -316,8 +323,11 @@ gap: 4px; } -.player-controls__record-button--active { - color: var(--mat-sys-error, #ff5252) !important; +// The glyph is the only visible part of the button, and the icon-button rule +// below colours the mat-icon directly, so the red must name the icon too. +.player-controls__record-button--active, +.player-controls__record-button--active mat-icon { + color: var(--pc-danger) !important; } // Value chips (subtitles, speed): the two settings a viewer changes mid-film. diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts new file mode 100644 index 000000000..9e938af25 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts @@ -0,0 +1,111 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// The overlay sits on video in both app themes, so its colours come from the +// fixed `--pc-*` palette. A theme token read here stays invisible only while +// the theme leaves it undeclared; once declared, the overlay follows the theme. +const CONTROLS_DIR = resolve( + process.cwd(), + 'libs/ui/playback/src/lib/player-controls' +); + +const STYLE_SOURCES = new Map( + readdirSync(CONTROLS_DIR) + .filter((file) => file.endsWith('.scss')) + .map((file) => [ + file, + readFileSync(resolve(CONTROLS_DIR, file), 'utf8'), + ]) +); + +const HOST_STYLES = STYLE_SOURCES.get('player-controls.component.scss') ?? ''; +const TIMELINE_STYLES = + STYLE_SOURCES.get('player-timeline.component.scss') ?? ''; + +type Rgb = [number, number, number]; + +function paletteColor(token: string): { rgb: Rgb; alpha: number } { + const value = + HOST_STYLES.match(new RegExp(`${token}:\\s*([^;]+);`))?.[1].trim() ?? + ''; + const hex = value.match(/^#([0-9a-f]{6})$/i)?.[1]; + if (hex) { + const rgb = [0, 2, 4].map((offset) => + Number.parseInt(hex.slice(offset, offset + 2), 16) + ) as Rgb; + return { rgb, alpha: 1 }; + } + const rgba = value.match(/^rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)$/); + if (rgba) { + const rgb = rgba.slice(1, 4).map(Number) as Rgb; + return { rgb, alpha: Number(rgba[4]) }; + } + throw new Error(`${token} is not a literal palette colour: "${value}"`); +} + +function contrastRatio(foreground: Rgb, background: Rgb): number { + const luminance = (rgb: Rgb): number => { + const [red, green, blue] = rgb.map((channel) => { + const normalized = channel / 255; + return normalized <= 0.04045 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4; + }); + return red * 0.2126 + green * 0.7152 + blue * 0.0722; + }; + const values = [luminance(foreground), luminance(background)].sort( + (left, right) => right - left + ); + return (values[0] + 0.05) / (values[1] + 0.05); +} + +describe('player controls overlay palette', () => { + it('never reads the app theme system tokens', () => { + expect([...STYLE_SOURCES.keys()]).toEqual( + expect.arrayContaining([ + 'player-controls.component.scss', + 'player-timeline.component.scss', + ]) + ); + const offenders = [...STYLE_SOURCES] + .filter(([, source]) => source.includes('--mat-sys-')) + .map(([file]) => file); + + expect(offenders).toEqual([]); + }); + + it('draws the LIVE badge and the recording reds from the palette', () => { + expect(TIMELINE_STYLES).toMatch( + /\.player-controls__live-badge\s*\{[^}]*background:\s*var\(--pc-live\);/ + ); + expect(TIMELINE_STYLES).toMatch( + /\.player-controls__recording-status--active\s*\{[^}]*color:\s*var\(--pc-danger\);/ + ); + expect(TIMELINE_STYLES).toMatch( + /\.player-controls__recording-status--active mat-icon\s*\{[^}]*color:\s*var\(--pc-danger\);/ + ); + // The icon-button rule colours the mat-icon itself, so a red set only + // on the button never reaches the glyph. + expect(HOST_STYLES).toMatch( + /\.player-controls__record-button--active,\s*\.player-controls__record-button--active mat-icon\s*\{[^}]*color:\s*var\(--pc-danger\)/ + ); + }); + + it('keeps the palette reds readable on video', () => { + const white: Rgb = [255, 255, 255]; + const glass = paletteColor('--pc-glass-bg'); + const glassOnBlackFrame = glass.rgb.map( + (channel) => channel * glass.alpha + ) as Rgb; + + // The white LIVE label is small bold text: the 4.5:1 text floor. + expect( + contrastRatio(white, paletteColor('--pc-live').rgb) + ).toBeGreaterThanOrEqual(4.5); + // "REC 0:12" is text too, so it gets the text floor as well, not + // just the 3:1 a glyph would need. + expect( + contrastRatio(paletteColor('--pc-danger').rgb, glassOnBlackFrame) + ).toBeGreaterThanOrEqual(4.5); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss b/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss index bd6275d44..ebd986e32 100644 --- a/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss @@ -141,12 +141,12 @@ } .player-controls__recording-status--active { - color: var(--mat-sys-error, #ff5252); + color: var(--pc-danger); font-weight: 700; } .player-controls__recording-status--active mat-icon { - color: var(--mat-sys-error, #ff5252); + color: var(--pc-danger); } .player-controls__live-badge { @@ -156,7 +156,7 @@ padding: 2px 8px; border-radius: 4px; color: #ffffff; - background: var(--mat-sys-error, #e53935); + background: var(--pc-live); font-size: 0.66rem; font-weight: 700; letter-spacing: 0.08em;