diff --git a/.changes/ui-watch-progress-colour.md b/.changes/ui-watch-progress-colour.md
new file mode 100644
index 000000000..900803ea6
--- /dev/null
+++ b/.changes/ui-watch-progress-colour.md
@@ -0,0 +1,9 @@
+---
+type: fix
+area: ui
+---
+
+Watch progress has one colour again: your theme's accent on dashboard cards
+and catalog posters, and the player's blue everywhere inside the player, so
+the next episode's bar no longer turns red in the Up Next rail and the
+fullscreen episode list.
diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss
index 8044b7b89..a3c36fd3a 100644
--- a/apps/web/src/m3-theme.scss
+++ b/apps/web/src/m3-theme.scss
@@ -104,6 +104,9 @@ html {
var(--app-selection-color) 34%,
transparent
);
+ // Watch progress in app chrome (rail cards, catalog capsules, the hero).
+ // Over video the player's fixed `--pc-progress` applies instead.
+ --app-progress-color: var(--app-selection-color);
// Live / on-air accent (broadcast indicator)
--app-live-color: #d12c2c;
@@ -190,6 +193,9 @@ html {
var(--app-selection-color) 44%,
transparent
);
+ // Re-declared so it resolves against this context's selection colour
+ // rather than inheriting the light value computed on `html`.
+ --app-progress-color: var(--app-selection-color);
// Live / on-air accent (broadcast indicator)
--app-live-color: #ff8f8f;
diff --git a/apps/web/src/m3-theme.spec.ts b/apps/web/src/m3-theme.spec.ts
new file mode 100644
index 000000000..bdacc25f6
--- /dev/null
+++ b/apps/web/src/m3-theme.spec.ts
@@ -0,0 +1,53 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+// A custom property that reads another one is resolved where it is declared,
+// so a token derived from a per-theme colour has to be declared again inside
+// `.dark-theme`; inherited from `html`, it would keep the light value there.
+const THEME = readFileSync(
+ resolve(process.cwd(), 'apps/web/src/m3-theme.scss'),
+ 'utf8'
+)
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/(^|\s)\/\/[^\n]*/g, '$1');
+
+/** The body of the brace block that opens at `start` (an index of `{`). */
+function block(source: string, start: number): string {
+ let depth = 0;
+ for (let index = start; index < source.length; index++) {
+ if (source[index] === '{') depth++;
+ if (source[index] === '}' && --depth === 0) {
+ return source.slice(start + 1, index);
+ }
+ }
+ throw new Error('Unbalanced braces in m3-theme.scss');
+}
+
+/** Light declarations (the `html` block minus `.dark-theme`) and dark ones. */
+function themeContexts(): { light: string; dark: string } {
+ const darkStart = THEME.indexOf('.dark-theme {');
+ const htmlStart = THEME.lastIndexOf('html {', darkStart);
+ expect(darkStart).toBeGreaterThan(-1);
+ expect(htmlStart).toBeGreaterThan(-1);
+
+ const html = block(THEME, THEME.indexOf('{', htmlStart));
+ const dark = block(THEME, THEME.indexOf('{', darkStart));
+ return { light: html.replace(dark, ''), dark };
+}
+
+describe('m3-theme watch-progress token', () => {
+ it('declares --app-progress-color from the selection colour in both themes', () => {
+ const { light, dark } = themeContexts();
+ const progress =
+ /--app-progress-color:\s*var\(--app-selection-color\);/;
+ const selection = /--app-selection-color:\s*#[0-9a-f]{6};/i;
+
+ for (const context of [light, dark]) {
+ expect(context).toMatch(selection);
+ expect(context).toMatch(progress);
+ }
+ expect(light.match(selection)?.[0]).not.toBe(
+ dark.match(selection)?.[0]
+ );
+ });
+});
diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md
index e34f48ff6..38751413c 100644
--- a/docs/architecture/embedded-inline-playback.md
+++ b/docs/architecture/embedded-inline-playback.md
@@ -211,7 +211,8 @@ becomes an "Up Next" episode rail (`app-up-next-rail`,
`libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.ts`).
The rail lists the currently playing episode (highlighted, click-inert)
followed by the rest of its season and a spillover into the following
-seasons, with per-episode watch-progress bars from playback positions.
+seasons, with per-episode watch-progress bars from playback positions,
+drawn in the player's `--pc-progress` like the dock's Up next card.
- Data flow: the hosts (Xtream `SerialDetailsComponent`, Stalker
`StalkerSeriesViewComponent`) build the entries with
diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md
index 154d88a68..66ca06bdd 100644
--- a/docs/architecture/iptvnator-ui-guidelines.md
+++ b/docs/architecture/iptvnator-ui-guidelines.md
@@ -525,6 +525,39 @@ a cache read open and checks text contrast across live theme changes.
Channel preview progress and EPG current-program progress should stay visually aligned.
+### Watch progress colour
+
+A title's watch progress (its resume share, `progressPercent`) has exactly one
+colour per context, and never a literal of its own:
+
+- **App chrome** — dashboard rail cards and the hero, catalog grids and season
+ episodes (`app-progress-capsule`, and the season list rows' own fill):
+ `--app-progress-color`, declared per theme in `apps/web/src/m3-theme.scss`
+ as that theme's `--app-selection-color`, and declared again inside
+ `.dark-theme` because a derived custom property resolves where it is
+ declared. The capsule's green from 90 % marks a finished title; it is a
+ status, not progress.
+- **Over video** — the dock timeline, the Up next card, the Up Next rail and
+ the fullscreen episode panel: the player's fixed `--pc-progress` (accent
+ blue `#4f8eff`), never an app token, because the player palette is
+ theme-independent (see Player And EPG Theme Boundaries). An episode
+ therefore reads the same in every player surface. The rail and the episode
+ panel render beside the controls host, outside its `--pc-*` scope, so they
+ declare the token on their own `:host` with the `progress-token` mixin of
+ `libs/ui/playback/src/lib/player-controls/_player-palette.scss`.
+- ArtPlayer's legacy skin takes a colour string, not a custom property, so it
+ gets `PLAYER_PROGRESS_COLOR` (`player-palette.ts`), which a spec pins to the
+ Sass value.
+- Live programme progress next to a LIVE marker (the dashboard's live rail and
+ live hero slides) keeps `--app-live-color`; EPG programme progress keeps the
+ fill described below.
+
+Specs hold the rule in each owning project: `m3-theme.spec.ts` (web: the token
+in both theme contexts), `player-progress.palette.spec.ts` (ui-playback),
+`progress-capsule.component.spec.ts` and
+`season-container.progress-colour.spec.ts` (components) and
+`dashboard-progress-colour.spec.ts` (workspace-dashboard-feature).
+
### Track
- Height:
diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md
index 4bbdd20fd..6924e2712 100644
--- a/docs/architecture/player-controls-contract.md
+++ b/docs/architecture/player-controls-contract.md
@@ -317,6 +317,10 @@ 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.
+Watch progress reads `--pc-progress`, the accent blue declared by the
+`progress-token` mixin of `_player-palette.scss`; the Up Next rail and the
+fullscreen episode panel include the same mixin, so one episode's bar has
+one colour in every player surface (UI guidelines, "Watch progress colour").
- **Timeline row**: current time (`--pc-font-mono`, tabular) · drawn track
(`.player-controls__timeline-track` with one segment and an accent fill,
@@ -406,7 +410,7 @@ the native-view legacy dock keeps its plain slider.
Near the end of a series episode the dock shows an **"Up next" card**
(`app-player-up-next-card`, `data-test-id="player-controls-up-next"`) in the
bottom-right corner above the controls: the next episode's still (or its
-`S01E03` label as a tile), a 3px accent progress line when it was partly
+`S01E03` label as a tile), a 3px `--pc-progress` line when it was partly
watched, "Up next · in 7 min" and the title. The host supplies the item
through the optional `upNext` input (`PlayerUpNextItem { label, title,
thumbnailUrl, progressPercent }`); `ControlsUpNext` decides when it shows —
@@ -879,7 +883,7 @@ be.
rows: a 16:9 still or, without one, a large numeral tile so the no-TMDB
case still looks designed; the `S01E03` label, runtime, watched check or
"Now playing" marker; the title (label as fallback); a 3-line clamped
- overview when there is one; a progress bar on the thumbnail. Rows are
+ overview when there is one; a `--pc-progress` bar on the thumbnail. Rows are
buttons inside `
`s of a ``, so their button role survives for
assistive technology. A loading season shows a spinner row, a loaded empty
one the season-empty copy, and an unanswered one a "could not be loaded"
@@ -1652,6 +1656,8 @@ libs/ui/playback/src/lib/player-controls/
├── player-controls.component.ts
├── player-controls.component.html
├── player-controls.component.scss
+├── _player-palette.scss
+├── player-palette.ts
├── controls-feedback.ts
├── controls-format.utils.ts
├── controls-layout.ts
diff --git a/libs/ui/components/src/lib/progress-capsule/progress-capsule.component.spec.ts b/libs/ui/components/src/lib/progress-capsule/progress-capsule.component.spec.ts
new file mode 100644
index 000000000..6732b4d52
--- /dev/null
+++ b/libs/ui/components/src/lib/progress-capsule/progress-capsule.component.spec.ts
@@ -0,0 +1,47 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { ProgressCapsuleComponent } from './progress-capsule.component';
+
+describe('ProgressCapsuleComponent', () => {
+ let fixture: ComponentFixture;
+
+ function render(progress: number): HTMLElement {
+ fixture = TestBed.createComponent(ProgressCapsuleComponent);
+ fixture.componentRef.setInput('progress', progress);
+ fixture.detectChanges();
+ return fixture.nativeElement as HTMLElement;
+ }
+
+ it('fills the capsule to the watched share', () => {
+ const host = render(42);
+
+ expect(
+ host.querySelector('.progress-capsule__fill')?.style
+ .width
+ ).toBe('42%');
+ expect(host.querySelector('.progress-capsule--watched')).toBeNull();
+ });
+
+ it('marks an item watched from 90 %', () => {
+ expect(
+ render(90).querySelector('.progress-capsule--watched')
+ ).not.toBeNull();
+ });
+
+ // The capsule sits in app chrome (catalog grids, season episodes), so its
+ // fill is the theme's watch-progress token, like the dashboard rail card.
+ it('draws progress with the app watch-progress token', () => {
+ const source = readFileSync(
+ resolve(
+ process.cwd(),
+ 'libs/ui/components/src/lib/progress-capsule/progress-capsule.component.ts'
+ ),
+ 'utf8'
+ );
+ const fill = source.match(/&__fill\s*\{([^}]*)\}/)?.[1] ?? '';
+
+ expect(fill).toMatch(/background:\s*var\(--app-progress-color\);/);
+ expect(source).not.toMatch(/#e50914|#ff4d4d/i);
+ });
+});
diff --git a/libs/ui/components/src/lib/progress-capsule/progress-capsule.component.ts b/libs/ui/components/src/lib/progress-capsule/progress-capsule.component.ts
index 943f9a878..c1394d165 100644
--- a/libs/ui/components/src/lib/progress-capsule/progress-capsule.component.ts
+++ b/libs/ui/components/src/lib/progress-capsule/progress-capsule.component.ts
@@ -34,11 +34,7 @@ import {
&__fill {
height: 100%;
- background: linear-gradient(
- 90deg,
- #e50914 0%,
- #ff4d4d 100%
- );
+ background: var(--app-progress-color);
transition: width 0.3s ease-out;
border-radius: 0 2px 2px 0;
}
diff --git a/libs/ui/components/src/lib/season-container/season-container.component.scss b/libs/ui/components/src/lib/season-container/season-container.component.scss
index ac8c8df6a..3ea92289a 100644
--- a/libs/ui/components/src/lib/season-container/season-container.component.scss
+++ b/libs/ui/components/src/lib/season-container/season-container.component.scss
@@ -671,7 +671,7 @@ app-season-tabs {
&__progress-fill {
height: 100%;
- background: var(--mat-sys-primary);
+ background: var(--app-progress-color);
border-radius: 2px;
transition: width 0.3s ease;
}
diff --git a/libs/ui/components/src/lib/season-container/season-container.progress-colour.spec.ts b/libs/ui/components/src/lib/season-container/season-container.progress-colour.spec.ts
new file mode 100644
index 000000000..a8728c0dd
--- /dev/null
+++ b/libs/ui/components/src/lib/season-container/season-container.progress-colour.spec.ts
@@ -0,0 +1,27 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+// Watch progress in app chrome reads one theme token in both season view
+// modes; the list rows draw their own fill rather than the grid's capsule
+// (see the UI guidelines' Progress Bars section).
+const DIR = resolve(
+ process.cwd(),
+ 'libs/ui/components/src/lib/season-container'
+);
+
+function read(file: string): string {
+ return readFileSync(resolve(DIR, file), 'utf8');
+}
+
+describe('season container watch-progress colour', () => {
+ it('fills list rows with the app watch-progress token', () => {
+ const styles = read('season-container.component.scss');
+ const listItem = styles.slice(styles.indexOf('.episode-list-item {'));
+ const fill = listItem.match(/&__progress-fill\s*\{([^}]*)\}/)?.[1];
+
+ expect(read('season-container.component.html')).toContain(
+ 'class="episode-list-item__progress-fill"'
+ );
+ expect(fill).toMatch(/background:\s*var\(--app-progress-color\);/);
+ });
+});
diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts
index 58a724723..81cddc8b0 100644
--- a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts
+++ b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts
@@ -287,6 +287,8 @@ describe('ArtPlayerComponent', () => {
setting: true,
fullscreen: true,
fullscreenWeb: true,
+ // The skin's progress bar matches the shared dock's fill.
+ theme: '#4f8eff',
})
);
// The app-level legacy shortcuts own the keyboard instead of
diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts
index fd216f783..208b36c4f 100644
--- a/libs/ui/playback/src/lib/art-player/art-player.component.ts
+++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts
@@ -15,6 +15,7 @@ import {
import Artplayer from 'artplayer';
import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
import { releaseVideoPictureInPicture } from '../player-controls/web-video-picture-in-picture-lifecycle';
+import { PLAYER_PROGRESS_COLOR } from '../player-controls/player-palette';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
import {
type LegacyPlayerShortcuts,
@@ -179,7 +180,9 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
playsInline: true,
backdrop: true,
mutex: true,
- theme: '#ff0000',
+ // Colours the vendor chrome (legacy skin only; shared controls
+ // hide it) with the player's progress colour, not a stray red.
+ theme: PLAYER_PROGRESS_COLOR,
...buildArtPlayerChrome(this.sharedControls),
customType: sourceSession.customType,
});
diff --git a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.scss b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.scss
index ba06cf187..2ec56938c 100644
--- a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.scss
+++ b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.scss
@@ -1,7 +1,12 @@
+@use '../player-controls/player-palette' as palette;
+
// Stamped into the fullscreen panel's body, which is its single flex child;
// the list below is the body's only scroll owner. The panel wears the
// `dark-theme` context class, so the light-on-dark palette here is fixed.
+// The panel sits beside the controls host, outside its `--pc-*` scope, so it
+// declares the player's progress colour itself.
:host {
+ @include palette.progress-token;
display: flex;
flex-direction: column;
min-height: 0;
@@ -238,7 +243,7 @@
.episode-panel__progress-bar {
display: block;
height: 100%;
- background: #e50914;
+ background: var(--pc-progress);
}
// ─── Text ────────────────────────────────────────────────────────────────────
diff --git a/libs/ui/playback/src/lib/player-controls/_player-palette.scss b/libs/ui/playback/src/lib/player-controls/_player-palette.scss
new file mode 100644
index 000000000..d33d887c5
--- /dev/null
+++ b/libs/ui/playback/src/lib/player-controls/_player-palette.scss
@@ -0,0 +1,19 @@
+// Player colours that surfaces outside the controls host need too. The
+// controls declare their `--pc-*` palette on `app-player-controls`' `:host`;
+// the Up Next rail and the fullscreen episode panel render beside that host,
+// not inside it, so they include `progress-token` on their own `:host` to
+// draw watch progress in the same colour as the dock.
+//
+// ArtPlayer's vendor chrome takes the same value as a string at construction:
+// `PLAYER_PROGRESS_COLOR` in `player-palette.ts`, pinned to `$accent-blue` by
+// `player-progress.palette.spec.ts`.
+
+// Accent blue: the primary action and watch progress over video.
+$accent-blue: #4f8eff;
+
+// `--pc-progress`, the watch-progress fill over video. Literal like the rest
+// of the overlay palette: the player is theme-independent, and the app's
+// selection blue would change with the theme and fight the video.
+@mixin progress-token {
+ --pc-progress: #{$accent-blue};
+}
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 7bd311009..a8ca40172 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
@@ -1,10 +1,15 @@
+@use 'player-palette' as palette;
+
:host {
// Overlay palette. Deliberately literal and theme-independent: the shared
// controls always sit on video, and the UI guidelines keep them on one
// light-on-dark palette in both app themes. The values are the design
// tokens of the "Hybrid" controls concept (accent blue = primary action
// and progress; cyan = something is on; violet = a value was changed).
- --pc-accent-blue: #4f8eff;
+ // Accent blue and the progress fill come from the shared player palette,
+ // which the Up Next rail and the fullscreen episode panel also read.
+ --pc-accent-blue: #{palette.$accent-blue};
+ @include palette.progress-token;
// Fills that carry a white glyph (play, active tune) use deeper blues:
// white on #4f8eff is only 3.2:1, too close to the 3:1 floor once a
// 1x display antialiases the glyph. These give 4.4:1 and 5.3:1.
diff --git a/libs/ui/playback/src/lib/player-controls/player-palette.ts b/libs/ui/playback/src/lib/player-controls/player-palette.ts
new file mode 100644
index 000000000..2644137fb
--- /dev/null
+++ b/libs/ui/playback/src/lib/player-controls/player-palette.ts
@@ -0,0 +1,7 @@
+/**
+ * The player's watch-progress colour (`--pc-progress`) for code that needs a
+ * colour string rather than a CSS custom property, such as ArtPlayer's
+ * `theme` option. It must equal `$accent-blue` in `_player-palette.scss`;
+ * `player-progress.palette.spec.ts` keeps the two in step.
+ */
+export const PLAYER_PROGRESS_COLOR = '#4f8eff';
diff --git a/libs/ui/playback/src/lib/player-controls/player-progress.palette.spec.ts b/libs/ui/playback/src/lib/player-controls/player-progress.palette.spec.ts
new file mode 100644
index 000000000..4e44a840e
--- /dev/null
+++ b/libs/ui/playback/src/lib/player-controls/player-progress.palette.spec.ts
@@ -0,0 +1,156 @@
+import { readdirSync, readFileSync } from 'node:fs';
+import { relative, resolve, sep } from 'node:path';
+import { PLAYER_PROGRESS_COLOR } from './player-palette';
+
+// Watch progress inside the player is one colour: the fixed `--pc-progress`,
+// never the app theme and never a literal of its own. The same episode must
+// read the same in the dock's Up next card, the Up Next rail and the
+// fullscreen episode panel (see the UI guidelines' Progress Bars section).
+const LIB_DIR = resolve(process.cwd(), 'libs/ui/playback/src/lib');
+const CONTROLS_DIR = resolve(LIB_DIR, 'player-controls');
+
+function stripComments(source: string): string {
+ return source
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/(^|\s)\/\/[^\n]*/g, '$1');
+}
+
+function listSources(dir: string, extension: string): string[] {
+ return readdirSync(dir, { recursive: true, encoding: 'utf8' })
+ .filter((file) => file.endsWith(extension))
+ .filter((file) => !/\.spec(-[a-z]+)?\.ts$/.test(file))
+ .map((file) => resolve(dir, file));
+}
+
+function read(file: string): string {
+ return stripComments(readFileSync(file, 'utf8'));
+}
+
+/** The declarations of the first rule whose selector list is exactly `selector`. */
+function ruleBody(source: string, selector: string): string {
+ const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ return (
+ source.match(
+ new RegExp(`(?:^|[}\\s])${escaped}\\s*\\{([^}]*)\\}`)
+ )?.[1] ?? ''
+ );
+}
+
+const PALETTE = read(resolve(CONTROLS_DIR, '_player-palette.scss'));
+const STYLESHEETS = listSources(LIB_DIR, '.scss');
+
+const PROGRESS_FILLS: ReadonlyArray<[file: string, selector: string]> = [
+ [
+ 'player-controls/player-timeline.component.scss',
+ '.player-controls__timeline-fill',
+ ],
+ [
+ 'player-controls/player-up-next-card.component.scss',
+ '.player-up-next__progress-bar',
+ ],
+ [
+ 'portal-inline-player/up-next-rail.component.scss',
+ '.up-next__progress-bar',
+ ],
+ [
+ 'fullscreen-episode-panel/fullscreen-episode-panel.component.scss',
+ '.episode-panel__progress-bar',
+ ],
+];
+
+describe('player watch-progress colour', () => {
+ it('draws every progress fill inside the player with --pc-progress', () => {
+ for (const [file, selector] of PROGRESS_FILLS) {
+ expect({
+ file,
+ body: ruleBody(read(resolve(LIB_DIR, file)), selector),
+ }).toEqual({
+ file,
+ body: expect.stringMatching(
+ /background:\s*var\(--pc-progress\);/
+ ),
+ });
+ }
+ });
+
+ it('leaves no other BEM progress fill on a colour of its own', () => {
+ const fills = STYLESHEETS.flatMap((file) =>
+ [
+ ...read(file).matchAll(
+ /([^{}]*__progress-(?:bar|fill))\s*\{([^}]*)\}/g
+ ),
+ ].map(([, selector, body]) => ({
+ fill: `${relative(LIB_DIR, file)} ${selector.trim()}`,
+ background: body.match(
+ /background(?:-color)?:\s*([^;]+);/
+ )?.[1],
+ }))
+ );
+
+ expect(fills.length).toBeGreaterThanOrEqual(3);
+ expect(
+ fills.filter(
+ ({ background }) => background !== 'var(--pc-progress)'
+ )
+ ).toEqual([]);
+ });
+
+ it('declares --pc-progress wherever it is read outside the controls host', () => {
+ // A top-level `:host` block runs to the first unindented `}`; Sass
+ // interpolation (`#{...}`) inside it would end `ruleBody` too early.
+ const declaresOnHost = (source: string) =>
+ /@include palette\.progress-token;/.test(
+ source.match(/^:host\s*\{([\s\S]*?)^\}/m)?.[1] ?? ''
+ );
+
+ // The host declares the palette its own children (timeline, card) read.
+ expect(
+ declaresOnHost(
+ read(resolve(CONTROLS_DIR, 'player-controls.component.scss'))
+ )
+ ).toBe(true);
+
+ const readers = STYLESHEETS.filter(
+ (file) =>
+ !file.startsWith(CONTROLS_DIR + sep) &&
+ read(file).includes('var(--pc-progress')
+ );
+ expect(readers.map((file) => relative(LIB_DIR, file)).sort()).toEqual([
+ 'fullscreen-episode-panel/fullscreen-episode-panel.component.scss',
+ 'portal-inline-player/up-next-rail.component.scss',
+ ]);
+ for (const file of readers) {
+ expect({ file, declared: declaresOnHost(read(file)) }).toEqual({
+ file,
+ declared: true,
+ });
+ }
+ });
+
+ it('gives ArtPlayer the same colour as the Sass palette', () => {
+ const accent = PALETTE.match(/\$accent-blue:\s*(#[0-9a-f]{6});/i)?.[1];
+
+ expect(accent).toBeDefined();
+ expect(PALETTE).toMatch(
+ /@mixin progress-token\s*\{\s*--pc-progress:\s*#\{\$accent-blue\};\s*\}/
+ );
+ expect(PLAYER_PROGRESS_COLOR.toLowerCase()).toBe(accent?.toLowerCase());
+ expect(
+ read(resolve(LIB_DIR, 'art-player/art-player.component.ts'))
+ ).toMatch(/theme:\s*PLAYER_PROGRESS_COLOR,/);
+ });
+
+ it('keeps the old progress reds out of the player', () => {
+ const sources = [
+ ...STYLESHEETS,
+ ...listSources(LIB_DIR, '.ts'),
+ ...listSources(LIB_DIR, '.html'),
+ ];
+ const offenders = sources
+ .filter((file) => /#e50914|#ff0000\b/i.test(read(file)))
+ .map((file) => relative(LIB_DIR, file));
+
+ expect(sources.length).toBeGreaterThan(50);
+ expect(offenders).toEqual([]);
+ });
+});
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 ebd986e32..f659b3b76 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
@@ -63,7 +63,7 @@
.player-controls__timeline-fill {
height: 100%;
- background: var(--pc-accent-blue);
+ background: var(--pc-progress);
border-radius: inherit;
}
@@ -75,7 +75,8 @@
margin: -7px 0 0 -7px;
border-radius: 50%;
background: #ffffff;
- box-shadow: 0 0 0 5px rgba(79, 142, 255, 0.3);
+ box-shadow: 0 0 0 5px
+ color-mix(in srgb, var(--pc-progress) 30%, transparent);
pointer-events: none;
transition: transform 120ms ease-out;
}
@@ -228,6 +229,7 @@
width: 11px;
height: 11px;
margin: -5.5px 0 0 -5.5px;
- box-shadow: 0 0 0 4px rgba(79, 142, 255, 0.3);
+ box-shadow: 0 0 0 4px
+ color-mix(in srgb, var(--pc-progress) 30%, transparent);
}
}
diff --git a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss
index 61d18bded..3351ff015 100644
--- a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss
+++ b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss
@@ -106,7 +106,7 @@
.player-up-next__progress-bar {
display: block;
height: 100%;
- background: var(--pc-accent-blue, #4f8eff);
+ background: var(--pc-progress);
}
.player-up-next__meta {
diff --git a/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.scss b/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.scss
index b6c695c58..88a754d13 100644
--- a/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.scss
+++ b/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.scss
@@ -1,7 +1,11 @@
+@use '../player-controls/player-palette' as palette;
+
// Opaque panel docked on the stage's right column — it sits on top of the
// black stage (or the ambient fill), so its own dark surface works for both
-// app themes without :host-context branching.
+// app themes without :host-context branching. Watch progress takes the
+// player's `--pc-progress`, so an episode's bar matches the dock's Up next card.
:host {
+ @include palette.progress-token;
display: block;
height: 100%;
min-height: 0;
@@ -112,7 +116,7 @@
.up-next__progress-bar {
display: block;
height: 100%;
- background: #e50914;
+ background: var(--pc-progress);
}
.up-next__meta {
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss
index e42788f7a..bd214058b 100644
--- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss
+++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss
@@ -342,7 +342,7 @@
width: 100%;
height: 100%;
border-radius: inherit;
- background: var(--app-selection-color, #78adff);
+ background: var(--app-progress-color);
// A full-width fill slid into place, neither a width nor animated: a
// live-EPG tick moves it by under one percent, so an animation was
// invisible yet cost a layout pass (width) or a burst of compositor
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-progress-colour.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-progress-colour.spec.ts
new file mode 100644
index 000000000..52abf34b0
--- /dev/null
+++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-progress-colour.spec.ts
@@ -0,0 +1,40 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+// Watch progress in app chrome reads one theme token, so a title's bar on a
+// rail card, in the hero and in a catalog grid is the same colour. Live
+// programme progress keeps the live accent (see the UI guidelines).
+function styles(file: string): string {
+ return readFileSync(resolve(__dirname, file), 'utf8');
+}
+
+/** The declarations of the first `selector { … }` rule, nesting excluded. */
+function ruleBody(source: string, selector: RegExp): string {
+ return (
+ source.match(new RegExp(`${selector.source}\\s*\\{([^}]*)\\}`))?.[1] ??
+ ''
+ );
+}
+
+describe('dashboard watch-progress colour', () => {
+ it('fills the rail card artwork bar with the watch-progress token', () => {
+ const rail = styles('dashboard-rail.component.scss');
+ const bar = rail.slice(rail.indexOf('.rail__art-progress {'));
+
+ expect(ruleBody(bar, /\bi/)).toMatch(
+ /background:\s*var\(--app-progress-color\);/
+ );
+ });
+
+ it('fills the hero resume bar with the token and live bars with the live accent', () => {
+ const hero = styles('dashboard-hero.component.scss');
+ const bar = hero.slice(hero.indexOf('.hero__progress {'));
+
+ expect(ruleBody(bar, /\bi/)).toMatch(
+ /background:\s*var\(--app-progress-color\);/
+ );
+ expect(ruleBody(bar, /&--live i/)).toMatch(
+ /background:\s*var\(--app-live-color\b/
+ );
+ });
+});
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss
index 608f761df..67841af2a 100644
--- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss
+++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss
@@ -273,7 +273,7 @@
i {
display: block;
height: 100%;
- background: var(--app-selection-color, #78adff);
+ background: var(--app-progress-color);
transition: width 0.4s ease;
}
}