From 03dfafd68b4ce441058100b41bc034a205563871 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:02:57 +0200 Subject: [PATCH] fix(player): pin overlay reds to the --pc-* palette (#1759) --- .changes/playback-overlay-reds.md | 6 + docs/architecture/iptvnator-ui-guidelines.md | 4 +- docs/architecture/player-controls-contract.md | 7 +- .../player-controls.component.scss | 16 +- .../player-controls.palette.spec.ts | 150 ++++++++++++++++++ .../player-timeline.component.scss | 6 +- 6 files changed, 181 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..e721e8735 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,13 @@ 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. +// A disabled button (loading, e.g. a reconnect mid-recording) keeps the muted +// disabled colour, so it does not look clickable. +.player-controls__record-button--active:not([disabled]), +.player-controls__record-button--active:not([disabled]) 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..62bee3157 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts @@ -0,0 +1,150 @@ +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' +); + +// Comments are dropped so prose that names a token can neither trip nor +// satisfy the checks below. A `//` counts only after whitespace, which keeps +// the `://` of a URL intact. +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|\s)\/\/[^\n]*/g, '$1'); +} + +const STYLE_SOURCES = new Map( + readdirSync(CONTROLS_DIR) + .filter((file) => file.endsWith('.scss')) + .map((file) => [ + file, + stripComments(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([]); + expect( + stripComments('// names --mat-sys-error\n/* and --mat-sys-x */') + ).not.toContain('--mat-sys-'); + expect(stripComments("url('https://example.com/a.svg')")).toBe( + "url('https://example.com/a.svg')" + ); + }); + + 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:not\(\[disabled\]\),\s*\.player-controls__record-button--active:not\(\[disabled\]\) mat-icon\s*\{[^}]*color:\s*var\(--pc-danger\)/ + ); + }); + + it('lets a disabled record button keep its muted colour while recording', () => { + // The red is `!important`, so an unscoped rule would also beat the + // disabled icon colour: a recording that reconnects (loading, so the + // button is disabled) would show a red glyph that cannot be clicked. + const activeRecordSelectors = [ + ...HOST_STYLES.matchAll(/([^{}]*)\{[^}]*var\(--pc-danger\)/g), + ].flatMap(([, selectors]) => + selectors + .split(',') + .map((selector) => selector.trim()) + .filter((selector) => + selector.includes('player-controls__record-button--active') + ) + ); + + expect(activeRecordSelectors).toEqual([ + '.player-controls__record-button--active:not([disabled])', + '.player-controls__record-button--active:not([disabled]) mat-icon', + ]); + expect(HOST_STYLES).toMatch( + /:host :is\(button\[mat-icon-button\]\[disabled\]\) mat-icon\s*\{[^}]*color:\s*var\(--pc-text-tertiary\);/ + ); + }); + + 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;