fix(player): pin overlay reds to the --pc-* palette (#1759)

This commit is contained in:
4gray authored and GitHub committed 2026-09-30 07:02:57 +02:00
1 parent 963a431bb7
commit 03dfafd68b
6 files changed
+181 -8

No files matched your search

+6
View File
@@ -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.
+3 -1
View File
@@ -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,
@@ -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.
@@ -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.
@@ -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);
});
});
@@ -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;