fix(ui): welcome screens and drop overlay follow the app theme (#1886)

* fix(ui): welcome screens and drop overlay follow the app theme

The welcome dashboard, the empty Sources page and the playlist drop
overlay switched on `prefers-color-scheme`, so with the app set to dark on
a light OS (or light on a dark OS) they painted the other theme's colours.
They now read `--app-*` tokens, which follow the `.dark-theme` class set
from Settings, and the hard-coded blues are derived from the selection
colour (a local "strong" accent mixed toward the heading ink keeps chips
and filled labels at 4.5:1 in both themes).

White rgba() fills that vanished in the light theme (season empty panel,
catalog refinement chips and menu divider, Xtream archive banner and
disabled paginator icons, shell download-activity track, search field)
now use the widget surface, on-surface mixes or the search tokens.

Reads of custom properties that nothing declares are fixed: the release
notes error, the search-layout empty state and the collection reload dim
now use declared tokens; unset hooks are replaced by their fallback value.

New guard `pnpm run styles:theme-references:validate` (CI) rejects
undeclared var() reads and prefers-color-scheme outside the settings
resolver. Electron E2E os-color-scheme.e2e.ts flips the OS scheme under
each explicit app theme and checks colours, contrast and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): retry the rejected-drop comparison when the card dismisses mid-read

The rejection hides itself after 1.8s, and the OS-scheme comparison reads
the overlay twice with an emulateMedia call between. A slow runner could
lose the card between the reads; the comparison now drops again until both
reads see it. A colour that follows the OS still fails every attempt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): measure welcome text contrast from rendered pixels

The feature and source cards paint gradients, which a backgroundColor
walk ignores, so card titles, descriptions and chips could pass while
falling short on the actual card. Every text on the three surfaces is now
measured against the pixels under it, as the filled button labels were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tools): scan only runtime sources in the theme-references guard

The guard read every tracked file under apps/ and libs/ except specs, so
an E2E, mock-server or test-helper declaration could satisfy a runtime
var() read that nothing in the app declares (dashboard-rail-focus.e2e.ts
sets --cover-rail-width), and a test-only read could fail the check. It
now skips spec/test/e2e files, test helpers and stubs, testing projects,
the E2E and mock-server apps and the marketing website.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(xtream): set live paginator tokens through mat.paginator-overrides

The live layout hand-declared --mat-paginator-* tokens, which the UI
guidelines route through the overrides mixin so a mistyped name fails the
build instead of silently doing nothing. Same values, same output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tools): skip every test-only naming convention in the theme guard

The runtime scan still read .stub, .harness, .fixtures, .spec-stubs,
.spec-helpers, .spec-fixtures, test-setup and test-double sources, and
test-stubs/ or *fixtures/ directories, so a declaration in one could mask
an undeclared runtime read. A file is now test-only when a dot segment
after its name marks it (spec, stub, fixture, harness, mock, spec-*,
test-*, *-fixtures), its stem is a test bootstrap, or it sits in a test
directory. Runtime names such as xtream-connection-test.service.ts stay
in the scan; no runtime source imports an excluded file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): keep welcome card text legible while hovered

At the 16%/10% hover tint the dark theme's body text on a feature card
measured 4.45:1. The hover fill steps up to 10%/6% instead (4.78:1 in
dark, 6.6:1 in light); lift, border and shadow carry the rest. The E2E
now measures feature and source card text while hovered, in both themes;
with the old tint it fails at 4.45.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-11 12:22:23 +02:00
1 parent 197fbee781
commit 43358e10f2
32 files changed
+1033 -334

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: ui
---
The welcome screen, the empty Sources page and the drop-a-playlist overlay now follow the theme chosen in Settings instead of the system's light or dark mode. Panels, borders and the search box that were invisible in the light theme now show, and a few labels that fell back to the wrong colour use the theme's colours.
+4 -3
View File
@@ -15,13 +15,14 @@ description: Use when changing IPTVnator SCSS tokens, shared layout mixins, port
`libs/ui/styles/_detail-view-actions.scss`
- UI policy and migration debt: `docs/architecture/iptvnator-ui-guidelines.md`
The index is a barrel, not a configured Sass include path. Production
consumers currently use relative `@use` paths to the needed partial.
The index is a barrel, not a Sass include path: consumers `@use` each
partial by relative path.
## Token Boundary
- App-owned surfaces, text, separators, hover states, selections, and provider
accents use `--app-*` tokens from `m3-theme.scss`.
accents use `--app-*` tokens from `m3-theme.scss`, switched by
`.dark-theme`, not `prefers-color-scheme`.
- Use `--app-selection-on-color` for foregrounds placed on the selection
accent; do not assume white has enough contrast in both themes.
- Angular Material mixins and Material-component overrides may use Material
+5
View File
@@ -418,6 +418,11 @@ jobs:
- name: Validate Material icon names
run: pnpm run styles:icon-ligatures:validate
# A var() read nothing declares always falls back, and a
# prefers-color-scheme query follows the OS, not the app theme.
- name: Validate theme references
run: pnpm run styles:theme-references:validate
# A tooltip is not an accessible name: an icon-only <button> needs
# aria-label, an aria-label binding or aria-labelledby.
- name: Validate icon-only button names
@@ -0,0 +1,334 @@
import type { Locator, Page, TestInfo } from '@playwright/test';
import {
closeElectronApp,
expect,
goToDashboard,
launchElectronApp,
openSettings,
openSources,
saveSettings,
test,
} from './electron-test-fixtures';
import { measureBackdropTextContrast } from './theme-contrast';
type Theme = 'light' | 'dark';
const THEMES: readonly Theme[] = ['dark', 'light'];
const COLOR_PROPERTIES = [
'color',
'background-color',
'background-image',
'border-top-color',
'fill',
'stroke',
'box-shadow',
];
/** Every colour the subtree paints, keyed by element and pseudo-element. */
async function paintedColors(scope: Locator): Promise<Record<string, string>> {
return scope.evaluate((root, properties) => {
const colors: Record<string, string> = {};
[root, ...Array.from(root.querySelectorAll('*'))].forEach(
(element, index) => {
const name = `${index}:${element.tagName.toLowerCase()}.${(
element.getAttribute('class') ?? ''
)
.trim()
.replace(/\s+/g, '.')}`;
for (const pseudo of ['', '::before']) {
const style = getComputedStyle(element, pseudo || null);
for (const property of properties) {
colors[`${name}${pseudo} ${property}`] =
style.getPropertyValue(property);
}
}
}
);
return colors;
}, COLOR_PROPERTIES);
}
/** Lets entrance fades finish, so contrast reads the settled page. */
async function settleAnimations(page: Page): Promise<void> {
await page.mouse.move(1, 1);
await finishTransitions(page);
}
/** Waits for the finite animations and transitions now running. */
async function finishTransitions(page: Page): Promise<void> {
await page.evaluate(() =>
Promise.all(
document
.getAnimations()
.filter(
(animation) =>
animation.effect?.getComputedTiming().iterations !==
Infinity
)
.map((animation) => animation.finished.catch(() => undefined))
)
);
}
interface Surface {
name: string;
scope: Locator;
/**
* Text measured against the rendered pixels under it, so gradient cards,
* tinted chips and filled buttons all count.
*/
texts: Locator[];
/** Cards whose hover changes their fill, with the text to re-measure. */
hovered?: { card: Locator; texts: Locator[] }[];
}
/**
* Picks an explicit app theme in Settings and saves it. A fresh profile
* follows the System theme, whose OS listener would rewrite the theme class
* as soon as the OS scheme changes.
*/
async function chooseAppTheme(page: Page, theme: Theme): Promise<void> {
await openSettings(page);
await page
.getByTestId(theme === 'dark' ? 'DARK_THEME' : 'LIGHT_THEME')
.click();
await saveSettings(page);
await expect(page.locator('body')).toHaveClass(
theme === 'dark' ? /dark-theme/ : /^(?!.*dark-theme)/
);
// The "saved" snackbar would cover the screenshots.
await expect(page.locator('mat-snack-bar-container')).toBeHidden({
timeout: 15_000,
});
}
/**
* The surface takes its colours from the app theme alone: flipping the OS
* colour scheme changes nothing, and text stays legible where the app and
* the OS disagree. Returns the colours for the cross-theme comparison.
*/
async function expectFollowsAppTheme(
page: Page,
testInfo: TestInfo,
theme: Theme,
surface: Surface
): Promise<Record<string, string>> {
const osScheme: Theme = theme === 'dark' ? 'light' : 'dark';
await page.emulateMedia({ colorScheme: theme });
const underMatchingOs = await paintedColors(surface.scope);
await page.emulateMedia({ colorScheme: osScheme });
expect(
await paintedColors(surface.scope),
`${surface.name}: app ${theme} theme on a ${osScheme} OS`
).toEqual(underMatchingOs);
await settleAnimations(page);
const shot = testInfo.outputPath(
`${surface.name}-app-${theme}-os-${osScheme}.png`
);
await page.screenshot({ path: shot });
await testInfo.attach(`${surface.name} app ${theme} on ${osScheme} OS`, {
path: shot,
contentType: 'image/png',
});
for (const [index, text] of surface.texts.entries()) {
expect(
await measureBackdropTextContrast(page, text),
`${surface.name}: text ${index} in the ${theme} theme`
).toBeGreaterThanOrEqual(4.5);
}
for (const { card, texts } of surface.hovered ?? []) {
await card.hover();
await finishTransitions(page);
for (const [index, text] of texts.entries()) {
expect(
await measureBackdropTextContrast(page, text),
`${surface.name}: hovered card text ${index} in the ${theme} theme`
).toBeGreaterThanOrEqual(4.5);
}
await page.mouse.move(1, 1);
}
return underMatchingOs;
}
/** Starts (or, with `drop`, finishes) a file drag over the workspace. */
async function dragFileOverWorkspace(
page: Page,
fileName: string,
drop = false
): Promise<void> {
await page.evaluate(
({ name, dropFile }) => {
const target = document.querySelector('.workspace-shell');
if (!target) throw new Error('Workspace drop target not found.');
const dataTransfer = new DataTransfer();
dataTransfer.items.add(new File(['#EXTM3U'], name));
const types = dropFile
? ['dragenter', 'dragover', 'drop']
: ['dragenter', 'dragover'];
for (const type of types) {
target.dispatchEvent(
new DragEvent(type, {
bubbles: true,
cancelable: true,
dataTransfer,
})
);
}
},
{ name: fileName, dropFile: drop }
);
}
test.describe('App theme over the OS colour scheme', () => {
test('@theme @electron welcome screens and the drop overlay follow the app theme, not the OS', async ({
dataDir,
}, testInfo) => {
test.setTimeout(150_000);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
const colors: Record<string, Partial<Record<Theme, unknown>>> = {};
const record = (name: string, theme: Theme, value: unknown) => {
colors[name] = { ...colors[name], [theme]: value };
};
try {
for (const theme of THEMES) {
await page.emulateMedia({ colorScheme: theme });
await chooseAppTheme(page, theme);
await goToDashboard(page);
const dashboard = page.locator(
'app-empty-state .welcome-dashboard'
);
await expect(dashboard).toBeVisible();
record(
'welcome-dashboard',
theme,
await expectFollowsAppTheme(page, testInfo, theme, {
name: 'welcome-dashboard',
scope: dashboard,
texts: [
dashboard.locator('.welcome-dashboard__tagline'),
dashboard.locator(
'.welcome-dashboard__description'
),
dashboard.locator('.feature-card__title').first(),
dashboard.locator('.feature-card__desc').first(),
dashboard.locator(
'.welcome-dashboard__cta .mdc-button__label'
),
],
hovered: [
{
card: dashboard
.locator('.feature-card')
.first(),
texts: [
dashboard
.locator('.feature-card__title')
.first(),
dashboard
.locator('.feature-card__desc')
.first(),
],
},
],
})
);
await openSources(page);
const sources = page.locator(
'app-empty-state .welcome-sources'
);
await expect(sources).toBeVisible();
record(
'welcome-sources',
theme,
await expectFollowsAppTheme(page, testInfo, theme, {
name: 'welcome-sources',
scope: sources,
texts: [
sources.locator('.welcome-sources__body'),
sources.locator('.source-card__name').first(),
sources.locator('.source-card__needs').first(),
sources.locator('.source-card__chip').first(),
sources
.locator('.source-card__cta .mdc-button__label')
.first(),
],
hovered: [
{
card: sources.locator('.source-card').first(),
texts: [
sources
.locator('.source-card__name')
.first(),
sources
.locator('.source-card__needs')
.first(),
sources
.locator('.source-card__chip')
.first(),
],
},
],
})
);
const overlay = page.locator('app-playlist-drop-overlay');
const card = overlay.locator('.drop-overlay__card');
await dragFileOverWorkspace(page, 'channels.m3u');
await expect(card).toBeVisible();
record(
'drop-overlay',
theme,
await expectFollowsAppTheme(page, testInfo, theme, {
name: 'drop-overlay',
scope: overlay,
texts: [
card.locator('.drop-overlay__title'),
card.locator('.drop-overlay__body'),
],
})
);
await page.keyboard.press('Escape');
await expect(card).toBeHidden();
// The rejection dismisses itself after 1.8s. Both reads must
// see it, so a slow runner that loses it between them drops
// again; a colour that follows the OS fails every attempt.
await expect(async () => {
await page.emulateMedia({ colorScheme: theme });
await dragFileOverWorkspace(page, 'notes.txt', true);
await expect(card).toHaveClass(/is-rejected/, {
timeout: 1_000,
});
const rejected = await paintedColors(overlay);
await page.emulateMedia({
colorScheme: theme === 'dark' ? 'light' : 'dark',
});
const underOtherOs = await paintedColors(overlay);
expect(
await card.getAttribute('class'),
'rejection still shown after both reads'
).toMatch(/is-rejected/);
expect(
underOtherOs,
`rejected drop: app ${theme} theme`
).toEqual(rejected);
record('rejected drop', theme, rejected);
}).toPass({ timeout: 20_000 });
await page.keyboard.press('Escape');
await expect(card).toBeHidden();
}
for (const [name, byTheme] of Object.entries(colors)) {
expect(byTheme.light, `${name}: light vs dark`).not.toEqual(
byTheme.dark
);
}
} finally {
await page.emulateMedia({ colorScheme: null });
await closeElectronApp(app);
}
});
});
@@ -243,7 +243,7 @@ function decorateReleaseNotesHtml(html: string): string {
}
.release-notes-dialog__error--failed {
color: var(--app-error-color, #ef4444);
color: var(--mat-sys-error);
}
.release-notes-dialog__error p {
@@ -88,6 +88,20 @@ tokens, so the retired `--mdc-*` names compile but do nothing;
a spec loads as raw CSS cannot use Sass modules; it declares the `--mat-*`
token directly and says why.
The app theme is the `.dark-theme` class that `SettingsService` puts on
`<body>`; a "System" choice resolves the OS preference there and nowhere
else. Component styles never use a `prefers-color-scheme` query, which
follows the OS: with the app set to dark on a light OS it paints light colours
into the dark theme. Read `--app-*` tokens, which switch with the class, or
use `:host-context(.dark-theme)` for a rule that only one theme needs. The
class is on `<body>`, not the root, so `:root:not(.dark-theme)` always
matches. On a surface, a hairline or thin fill written as white `rgba()`
vanishes on light backgrounds: mix `--app-on-surface` at the same alpha, and
use `--app-widget-bg` for a card. Every `var(--name)` read needs a
declaration somewhere, or it silently falls back;
`pnpm run styles:theme-references:validate` (CI) rejects both undeclared
reads and colour-scheme queries.
Existing hard-coded layout and selection colors are migration debt, not
patterns to copy.
@@ -2,6 +2,28 @@
display: block;
width: 100%;
height: 100%;
// Every colour here follows the app theme class through --app-* tokens,
// never the OS colour scheme. The strong accent is the selection colour
// pulled toward the theme's heading ink: deeper in light, lighter in
// dark, so small accent text keeps 4.5:1 on the tinted cards in both
// themes, and so does --app-selection-on-color across the whole fill.
// Declared on the host, inside .dark-theme, so they resolve with that
// theme's values.
--empty-state-accent-strong: color-mix(
in srgb,
var(--app-selection-color) 60%,
var(--app-heading-color)
);
--empty-state-accent-fill: linear-gradient(
135deg,
color-mix(
in srgb,
var(--app-selection-color) 80%,
var(--app-heading-color)
),
var(--empty-state-accent-strong)
);
}
.empty-state {
@@ -21,7 +43,11 @@
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-weight: 500;
margin: 0 0 12px 0;
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
background: linear-gradient(
135deg,
var(--app-selection-color) 0%,
var(--empty-state-accent-strong) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -36,7 +62,7 @@
font-size: 0.95rem;
line-height: 1.6;
margin: 0 0 32px 0;
opacity: 0.7;
color: var(--app-body-color);
}
}
@@ -55,6 +81,7 @@
.illustration {
width: 120px;
height: 120px;
color: var(--app-body-color);
.search-circle,
.search-handle {
@@ -86,7 +113,7 @@
font-size: 72px;
width: 72px;
height: 72px;
color: #60a5fa;
color: var(--app-selection-color);
opacity: 0.85;
}
@@ -101,7 +101,6 @@ const SOURCE_CARDS: readonly SourceCard[] = [
'./empty-state.welcome-dashboard.scss',
'./empty-state.welcome-sources.scss',
'./empty-state.responsive.scss',
'./empty-state.themes.scss',
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatButtonModule, MatIcon, TranslatePipe],
@@ -1,38 +0,0 @@
@media (prefers-color-scheme: light) {
.empty-state {
.title {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.description {
opacity: 0.8;
color: #64748b;
}
}
.no-results-state {
.illustration {
opacity: 0.3;
.search-circle,
.search-handle,
.empty-box,
.empty-line {
stroke: #64748b;
}
}
}
.no-data-state {
.empty-icon {
color: #2563eb;
}
.secondary-button {
color: #2563eb;
}
}
}
@@ -14,6 +14,16 @@
animation: fadeIn 0.5s ease-out;
min-width: 0;
min-height: 0;
--welcome-grid-line: color-mix(
in srgb,
var(--app-selection-color) 4%,
transparent
);
--welcome-grid-diagonal: color-mix(
in srgb,
var(--app-selection-color) 2%,
transparent
);
&::before {
content: '';
@@ -24,22 +34,22 @@
0deg,
transparent,
transparent 39px,
rgba(59, 130, 246, 0.03) 39px,
rgba(59, 130, 246, 0.03) 41px
var(--welcome-grid-line) 39px,
var(--welcome-grid-line) 41px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 39px,
rgba(59, 130, 246, 0.03) 39px,
rgba(59, 130, 246, 0.03) 41px
var(--welcome-grid-line) 39px,
var(--welcome-grid-line) 41px
),
repeating-linear-gradient(
45deg,
transparent,
transparent 79px,
rgba(59, 130, 246, 0.015) 79px,
rgba(59, 130, 246, 0.015) 81px
var(--welcome-grid-diagonal) 79px,
var(--welcome-grid-diagonal) 81px
);
mask-image: linear-gradient(
to bottom,
@@ -85,30 +95,33 @@
.welcome-dashboard__illustration {
width: clamp(96px, 16vh, 160px);
height: clamp(96px, 16vh, 160px);
filter: drop-shadow(0 10px 30px rgba(59, 130, 246, 0.2));
color: #60a5fa;
filter: drop-shadow(
0 10px 30px
color-mix(in srgb, var(--app-selection-color) 20%, transparent)
);
color: var(--app-selection-color);
display: block;
.icon-background {
fill: #3b82f6;
fill: currentColor;
opacity: 0.15;
}
.monitor-base,
.monitor-stand {
fill: #3b82f6;
fill: var(--empty-state-accent-strong);
}
.screen-border {
stroke: #3b82f6;
stroke: var(--empty-state-accent-strong);
}
.wifi-dot {
fill: #60a5fa;
fill: currentColor;
}
.wifi-arc {
stroke: #60a5fa;
stroke: currentColor;
opacity: 0.7;
&.arc-1 { opacity: 0.9; }
@@ -122,7 +135,11 @@
font-weight: 600;
font-size: clamp(1.4rem, 3.4vh, 2.1rem);
margin: 0;
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
background: linear-gradient(
135deg,
var(--app-selection-color) 0%,
var(--empty-state-accent-strong) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -133,6 +150,7 @@
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: clamp(0.95rem, 1.9vh, 1.05rem);
font-weight: 500;
color: var(--app-heading-color);
opacity: 0.85;
margin: 0;
line-height: 1.45;
@@ -141,7 +159,7 @@
.welcome-dashboard__description {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: clamp(0.8125rem, 1.7vh, 0.9375rem);
opacity: 0.65;
color: var(--app-body-color);
margin: 0;
line-height: 1.5;
max-width: 480px;
@@ -149,13 +167,14 @@
.welcome-dashboard__cta {
margin-top: 8px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
background: var(--empty-state-accent-fill);
color: var(--app-selection-on-color);
font-weight: 500;
font-size: clamp(0.875rem, 1.8vh, 1rem);
padding: 10px 28px;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
box-shadow: 0 4px 16px
color-mix(in srgb, var(--app-selection-color) 30%, transparent);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
@@ -168,7 +187,8 @@
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 24px rgba(59, 130, 246, 0.4);
box-shadow: 0 6px 24px
color-mix(in srgb, var(--app-selection-color) 40%, transparent);
}
&:active {
@@ -200,10 +220,11 @@
padding: clamp(12px, 2vh, 18px) clamp(10px, 1.5vw, 14px);
background: linear-gradient(
135deg,
rgba(96, 165, 250, 0.08),
rgba(37, 99, 235, 0.04)
color-mix(in srgb, var(--app-selection-color) 8%, transparent),
color-mix(in srgb, var(--app-selection-color) 4%, transparent)
);
border: 1px solid rgba(96, 165, 250, 0.18);
border: 1px solid
color-mix(in srgb, var(--app-selection-color) 18%, transparent);
border-radius: 14px;
backdrop-filter: blur(8px);
transform: rotate(-2deg);
@@ -222,17 +243,30 @@
&:hover {
transform: rotate(0deg) translateY(-4px) scale(1.04);
box-shadow: 0 10px 24px rgba(59, 130, 246, 0.22);
border-color: rgba(96, 165, 250, 0.4);
box-shadow: 0 10px 24px
color-mix(in srgb, var(--app-selection-color) 22%, transparent);
border-color: color-mix(
in srgb,
var(--app-selection-color) 40%,
transparent
);
// A light step up from the resting 8%/4%: at 16% the dark
// theme's body text fell under 4.5:1 on the hovered card. The
// lift, border and shadow carry the rest of the hover.
background: linear-gradient(
135deg,
rgba(96, 165, 250, 0.16),
rgba(37, 99, 235, 0.1)
color-mix(in srgb, var(--app-selection-color) 10%, transparent),
color-mix(in srgb, var(--app-selection-color) 6%, transparent)
);
.feature-card__icon-wrap {
transform: scale(1.08);
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.4);
box-shadow: 0 6px 18px
color-mix(
in srgb,
var(--app-selection-color) 40%,
transparent
);
}
}
}
@@ -244,8 +278,9 @@
width: 40px;
height: 40px;
border-radius: 11px;
background: linear-gradient(135deg, #3b82f6, #2563eb);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
background: var(--empty-state-accent-fill);
box-shadow: 0 4px 12px
color-mix(in srgb, var(--app-selection-color) 30%, transparent);
transition:
transform 0.25s ease,
box-shadow 0.25s ease;
@@ -256,7 +291,7 @@
font-size: 22px;
width: 22px;
height: 22px;
color: white;
color: var(--app-selection-on-color);
}
.feature-card__title {
@@ -271,7 +306,7 @@
.feature-card__desc {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.78125rem;
opacity: 0.7;
color: var(--app-body-color);
margin: 0;
line-height: 1.35;
word-break: break-word;
@@ -332,91 +367,3 @@
}
}
}
@media (prefers-color-scheme: light) {
.welcome-dashboard {
&::before {
background-image:
repeating-linear-gradient(
0deg,
transparent,
transparent 39px,
rgba(37, 99, 235, 0.04) 39px,
rgba(37, 99, 235, 0.04) 41px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 39px,
rgba(37, 99, 235, 0.04) 39px,
rgba(37, 99, 235, 0.04) 41px
),
repeating-linear-gradient(
45deg,
transparent,
transparent 79px,
rgba(37, 99, 235, 0.02) 79px,
rgba(37, 99, 235, 0.02) 81px
);
}
.welcome-dashboard__title {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.welcome-dashboard__tagline {
color: #1e3a8a;
opacity: 0.85;
}
.welcome-dashboard__description {
color: #64748b;
opacity: 0.85;
}
.welcome-dashboard__illustration {
filter: drop-shadow(0 10px 30px rgba(37, 99, 235, 0.15));
color: #3b82f6;
.icon-background {
fill: #2563eb;
opacity: 0.12;
}
.monitor-base,
.monitor-stand {
fill: #2563eb;
}
.screen-border {
stroke: #2563eb;
}
.wifi-dot {
fill: #3b82f6;
}
.wifi-arc {
stroke: #3b82f6;
}
}
.feature-card {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.06), rgba(29, 78, 216, 0.03));
border-color: rgba(37, 99, 235, 0.15);
&:hover {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.12), rgba(29, 78, 216, 0.06));
border-color: rgba(37, 99, 235, 0.3);
}
}
.feature-card__desc {
color: #475569;
opacity: 0.85;
}
}
}
@@ -36,7 +36,7 @@
font-size: 72px;
width: 72px;
height: 72px;
color: #60a5fa;
color: var(--app-selection-color);
opacity: 0.85;
}
@@ -45,7 +45,11 @@
font-weight: 500;
font-size: 1.5rem;
margin: 0 0 8px 0;
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
background: linear-gradient(
135deg,
var(--app-selection-color) 0%,
var(--empty-state-accent-strong) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -55,7 +59,7 @@
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.95rem;
line-height: 1.6;
opacity: 0.7;
color: var(--app-body-color);
margin: 0;
}
@@ -77,10 +81,11 @@
padding: clamp(14px, 2.2vh, 22px);
background: linear-gradient(
145deg,
rgba(96, 165, 250, 0.08),
rgba(37, 99, 235, 0.04)
color-mix(in srgb, var(--app-selection-color) 8%, transparent),
color-mix(in srgb, var(--app-selection-color) 4%, transparent)
);
border: 1px solid rgba(96, 165, 250, 0.2);
border: 1px solid
color-mix(in srgb, var(--app-selection-color) 20%, transparent);
border-radius: 16px;
transition:
transform 0.2s ease,
@@ -90,8 +95,13 @@
&:hover {
transform: translateY(-3px);
box-shadow: 0 12px 28px rgba(59, 130, 246, 0.22);
border-color: rgba(96, 165, 250, 0.4);
box-shadow: 0 12px 28px
color-mix(in srgb, var(--app-selection-color) 22%, transparent);
border-color: color-mix(
in srgb,
var(--app-selection-color) 40%,
transparent
);
}
}
@@ -106,7 +116,7 @@
font-size: 28px;
width: 28px;
height: 28px;
color: #60a5fa;
color: var(--app-selection-color);
flex-shrink: 0;
}
@@ -122,7 +132,7 @@
.source-card__needs {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.8125rem;
opacity: 0.75;
color: var(--app-body-color);
margin: 0;
line-height: 1.45;
min-height: 2.5em;
@@ -144,9 +154,14 @@
font-weight: 500;
padding: 3px 9px;
border-radius: 999px;
background: rgba(96, 165, 250, 0.14);
color: #93c5fd;
border: 1px solid rgba(96, 165, 250, 0.22);
background: color-mix(
in srgb,
var(--app-selection-color) 14%,
transparent
);
color: var(--empty-state-accent-strong);
border: 1px solid
color-mix(in srgb, var(--app-selection-color) 22%, transparent);
text-transform: uppercase;
letter-spacing: 0.04em;
}
@@ -154,13 +169,14 @@
.source-card__cta {
margin-top: auto;
align-self: stretch;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
background: var(--empty-state-accent-fill);
color: var(--app-selection-on-color);
font-weight: 500;
font-size: 0.875rem;
padding: 8px 18px;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.25);
box-shadow: 0 4px 12px
color-mix(in srgb, var(--app-selection-color) 25%, transparent);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
@@ -175,7 +191,8 @@
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.35);
box-shadow: 0 6px 18px
color-mix(in srgb, var(--app-selection-color) 35%, transparent);
}
}
}
@@ -212,48 +229,3 @@
}
}
}
@media (prefers-color-scheme: light) {
.welcome-sources {
.welcome-sources__icon {
color: #2563eb;
}
.welcome-sources__title {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.welcome-sources__body {
color: #64748b;
opacity: 0.85;
}
.source-card {
background: linear-gradient(145deg, rgba(37, 99, 235, 0.06), rgba(29, 78, 216, 0.03));
border-color: rgba(37, 99, 235, 0.15);
&:hover {
background: linear-gradient(145deg, rgba(37, 99, 235, 0.1), rgba(29, 78, 216, 0.05));
border-color: rgba(37, 99, 235, 0.3);
}
}
.source-card__icon {
color: #2563eb;
}
.source-card__needs {
color: #475569;
opacity: 0.9;
}
.source-card__chip {
background: rgba(37, 99, 235, 0.08);
color: #1d4ed8;
border-color: rgba(37, 99, 235, 0.18);
}
}
}
@@ -95,7 +95,7 @@
max-width: 220px;
padding: 0 10px;
background: var(--mat-sys-surface-container);
border: 1px solid rgba(255, 255, 255, 0.06);
border: 1px solid color-mix(in srgb, var(--app-on-surface) 6%, transparent);
font-size: 0.78rem;
font-weight: 600;
}
@@ -125,7 +125,7 @@
justify-self: end;
padding: 0 12px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.08);
border: 1px solid color-mix(in srgb, var(--app-on-surface) 8%, transparent);
app-region: no-drag;
}
@@ -157,7 +157,7 @@
.refine-menu-divider {
height: 1px;
margin: 6px 12px;
background: rgba(255, 255, 255, 0.08);
background: color-mix(in srgb, var(--app-on-surface) 8%, transparent);
}
@container category-content (max-width: 920px) {
@@ -121,7 +121,7 @@
font-size: 1.05rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--text-color);
color: var(--app-heading-color);
}
p {
@@ -142,7 +142,7 @@
opacity: 0.8;
mat-icon {
color: var(--primary-color);
color: var(--app-selection-color);
opacity: 0.6;
}
}
@@ -6,6 +6,8 @@
flex-direction: column;
height: 100%;
overflow: hidden;
// Shared with unified-live-tab, which renders inside this page.
--collection-reload-dim-opacity: 0.6;
}
.skeleton-list {
@@ -47,7 +47,7 @@
}
app-grid-list.all-items-grid {
--cover-grid-min-width: var(--live-channel-grid-min-width, 148px);
--cover-grid-min-width: 148px;
display: block;
flex: 1;
@@ -67,16 +67,24 @@ mat-paginator {
// Its own container already wraps internally, so letting it shrink turns
// a clipped next-page arrow into a second line of the paginator itself.
min-width: 0;
--mat-paginator-container-size: 40px;
--mat-paginator-enabled-icon-color: var(
--app-body-color,
var(--mat-sys-on-surface-variant)
@include mat.paginator-overrides(
(
container-size: 40px,
enabled-icon-color: var(
--app-body-color,
var(--mat-sys-on-surface-variant)
),
disabled-icon-color: color-mix(
in srgb,
var(--app-on-surface) 18%,
transparent
),
)
);
--mat-paginator-disabled-icon-color: rgba(255, 255, 255, 0.18);
}
app-grid-list.live-all-items-grid {
--cover-grid-min-width: var(--live-channel-grid-min-width, 148px);
--cover-grid-min-width: 148px;
display: block;
flex: 1;
@@ -98,17 +106,9 @@ app-grid-list.live-all-items-grid {
margin: 12px 12px 0;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.04),
rgba(255, 255, 255, 0)
),
rgba(127, 127, 127, 0.05);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 10px 20px -20px rgba(0, 0, 0, 0.35);
border: 1px solid color-mix(in srgb, var(--app-on-surface) 8%, transparent);
background: var(--app-widget-bg);
box-shadow: var(--app-widget-shadow);
}
.archive-unavailable-banner__title {
@@ -9,22 +9,13 @@
width: min(100%, 520px);
margin: 0 auto;
padding: 32px 28px;
border: 1px solid rgba(255, 255, 255, 0.08);
// The theme's panel surface (white in light, raised slate in dark) and
// on-surface hairlines, so the panel shows in both themes; white tints
// vanished on the light content background.
border: 1px solid color-mix(in srgb, var(--app-on-surface) 8%, transparent);
border-radius: 16px;
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0.03)
),
radial-gradient(
circle at top,
rgba(255, 255, 255, 0.06),
transparent 60%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 18px 40px rgba(0, 0, 0, 0.22);
background: var(--app-widget-bg);
box-shadow: var(--app-widget-shadow);
text-align: center;
&__icon {
@@ -33,9 +24,10 @@
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.52);
background: color-mix(in srgb, var(--app-on-surface) 5%, transparent);
border: 1px solid
color-mix(in srgb, var(--app-on-surface) 8%, transparent);
color: color-mix(in srgb, var(--app-on-surface) 52%, transparent);
mat-icon {
font-size: 30px;
@@ -61,18 +53,11 @@
}
&--season {
border-color: rgba(255, 255, 255, 0.1);
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.07),
rgba(255, 255, 255, 0.04)
),
radial-gradient(
circle at top,
rgba(255, 255, 255, 0.08),
transparent 65%
);
border-color: color-mix(
in srgb,
var(--app-on-surface) 10%,
transparent
);
}
}
@@ -2,7 +2,7 @@
// rather than a global class: the row and the copy-row are separate components
// under emulated encapsulation, and each needs the rules inside its own scope.
@mixin source-tag-base {
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
$font-mono: ui-monospace, 'SF Mono', Menlo, monospace;
.source-tag {
display: inline-flex;
@@ -1,7 +1,7 @@
@use 'source-tag' as tag;
@use '../../../../styles/focus-ring';
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
$font-mono: ui-monospace, 'SF Mono', Menlo, monospace;
:host {
display: block;
@@ -1,7 +1,7 @@
@use 'source-tag' as tag;
@use '../../../../styles/focus-ring';
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
$font-mono: ui-monospace, 'SF Mono', Menlo, monospace;
:host {
display: block;
@@ -1,4 +1,4 @@
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
$font-mono: ui-monospace, 'SF Mono', Menlo, monospace;
:host {
@@ -1,6 +1,6 @@
@use '../../../../styles/focus-ring';
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
$font-mono: ui-monospace, 'SF Mono', Menlo, monospace;
// The popover is height-capped by its overlay to the space beside the anchor,
// so everything from the host down must let the list — and only the list —
+1 -1
View File
@@ -26,4 +26,4 @@ $accent-live: var(--app-live-color);
// theme's red and near-white behind the dark theme's pink — so it clears 4.5:1
// either way, where a fixed dark literal only worked in the light theme.
$on-live: var(--app-content-bg);
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
$font-mono: ui-monospace, 'SF Mono', Menlo, monospace;
@@ -7,7 +7,7 @@
background: $surface-1;
color: $text-primary;
border: 1px solid $line-strong;
border-radius: var(--r-lg, 14px);
border-radius: 14px;
overflow: hidden;
box-shadow: var(--app-widget-shadow);
@@ -251,7 +251,7 @@
.mdc-dialog__surface {
background: transparent !important;
box-shadow: none !important;
border-radius: var(--r-lg, 14px) !important;
border-radius: 14px !important;
overflow: visible;
}
}
@@ -484,7 +484,7 @@
width: 248px;
background: $surface-1;
border: 1px solid $line-strong;
border-radius: var(--r-md, 10px);
border-radius: 10px;
box-shadow: var(--app-widget-shadow);
padding: 13px 14px;
pointer-events: none;
@@ -92,7 +92,7 @@
.web-player-diagnostic__meta {
margin-top: 9px;
color: rgb(255 255 255 / 48%);
font-family: var(--app-monospace-font, monospace);
font-family: monospace;
font-size: 0.82rem;
line-height: 1.35;
overflow-wrap: anywhere;
@@ -71,7 +71,7 @@ $channel-progress-line-height: 1.3;
/// Live channel cards: wider and much shorter than a poster.
@mixin channel-track-tokens {
--rail-card-width: var(--channel-rail-width, 280px);
--rail-card-width: 280px;
--rail-gap: 10px;
}
@@ -14,10 +14,14 @@
animation: drop-overlay-fade 0.15s ease-out;
}
// Colours come from the app theme tokens, so the overlay follows the theme
// chosen in Settings rather than the OS colour scheme. The scrim stays dark
// in both themes, as a modal backdrop does; the card takes the theme's
// widget surface with a selection (or, on rejection, error) tint.
.drop-overlay__backdrop {
position: absolute;
inset: 0;
background: rgba(2, 6, 23, 0.6);
background: color-mix(in srgb, var(--mat-sys-scrim) 50%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
@@ -32,28 +36,33 @@
padding: clamp(28px, 5vh, 48px) clamp(28px, 5vw, 56px);
min-width: 320px;
max-width: min(520px, 88vw);
background: linear-gradient(
145deg,
rgba(96, 165, 250, 0.12),
rgba(37, 99, 235, 0.08)
background: color-mix(
in srgb,
var(--app-selection-color) 8%,
var(--app-widget-bg)
);
border: 2px dashed rgba(96, 165, 250, 0.55);
border: 2px dashed var(--app-selection-border);
border-radius: 18px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
color: #f1f5f9;
color: var(--app-heading-color);
animation: drop-overlay-scale 0.18s ease-out;
&.is-rejected {
border-color: rgba(248, 113, 113, 0.55);
background: linear-gradient(
145deg,
rgba(248, 113, 113, 0.12),
rgba(220, 38, 38, 0.08)
border-color: var(--mat-sys-error);
background: color-mix(
in srgb,
var(--mat-sys-error) 8%,
var(--app-widget-bg)
);
.drop-overlay__icon-wrap {
background: linear-gradient(135deg, #f87171, #dc2626);
box-shadow: 0 8px 22px rgba(220, 38, 38, 0.4);
background: var(--mat-sys-error);
box-shadow: 0 8px 22px
color-mix(in srgb, var(--mat-sys-error) 40%, transparent);
}
.drop-overlay__icon {
color: var(--mat-sys-on-error);
}
}
}
@@ -65,8 +74,9 @@
width: 64px;
height: 64px;
border-radius: 18px;
background: linear-gradient(135deg, #3b82f6, #2563eb);
box-shadow: 0 8px 22px rgba(59, 130, 246, 0.45);
background: var(--app-selection-color);
box-shadow: 0 8px 22px
color-mix(in srgb, var(--app-selection-color) 45%, transparent);
margin-bottom: 4px;
}
@@ -74,7 +84,7 @@
font-size: 36px;
width: 36px;
height: 36px;
color: white;
color: var(--app-selection-on-color);
}
.drop-overlay__title {
@@ -122,28 +132,3 @@
animation: drop-overlay-fade 0.1s ease-out;
}
}
@media (prefers-color-scheme: light) {
.drop-overlay__backdrop {
background: rgba(15, 23, 42, 0.45);
}
.drop-overlay__card {
background: linear-gradient(
145deg,
rgba(255, 255, 255, 0.96),
rgba(241, 245, 249, 0.96)
);
color: #0f172a;
border-color: rgba(37, 99, 235, 0.55);
&.is-rejected {
background: linear-gradient(
145deg,
rgba(254, 226, 226, 0.96),
rgba(252, 165, 165, 0.92)
);
border-color: rgba(220, 38, 38, 0.6);
}
}
}
@@ -277,7 +277,7 @@
width: 18px;
height: 2px;
border-radius: 1px;
background: rgba(255, 255, 255, 0.08);
background: color-mix(in srgb, var(--app-on-surface) 8%, transparent);
overflow: hidden;
pointer-events: none;
z-index: 10;
+3
View File
@@ -56,6 +56,9 @@
"styles:icon-ligatures:test": "node --test tools/nx/check-icon-ligatures.test.mjs",
"styles:icon-ligatures:check": "node tools/nx/check-icon-ligatures.mjs",
"styles:icon-ligatures:validate": "pnpm run styles:icon-ligatures:test && pnpm run styles:icon-ligatures:check",
"styles:theme-references:test": "node --test tools/nx/check-theme-references.test.mjs",
"styles:theme-references:check": "node tools/nx/check-theme-references.mjs",
"styles:theme-references:validate": "pnpm run styles:theme-references:test && pnpm run styles:theme-references:check",
"a11y:icon-buttons:test": "node --test tools/nx/check-icon-button-names.test.mjs",
"a11y:icon-buttons:check": "node tools/nx/check-icon-button-names.mjs",
"a11y:icon-buttons:validate": "pnpm run a11y:icon-buttons:test && pnpm run a11y:icon-buttons:check",
+234
View File
@@ -0,0 +1,234 @@
import { execFileSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Two ways a style silently stops following the app theme:
*
* - A `var(--name)` read whose property nothing declares resolves to its
* fallback, or to nothing, without an error: a renamed or removed token
* quietly changes what users see.
* - A `prefers-color-scheme` query follows the OS, while the app theme is the
* `.dark-theme` class set from Settings. With the app set to dark on a light
* OS such a rule paints light-theme colours into the dark theme.
*/
/** Source files whose styles or templates can read custom properties. */
const SCANNED_PATHSPECS = [
'apps/*.scss',
'apps/*.css',
'apps/*.ts',
'apps/*.html',
'libs/*.scss',
'libs/*.css',
'libs/*.ts',
'libs/*.html',
];
/** Material and CDK declare these in their own stylesheets. */
const EXTERNAL_PREFIX = /^--(mat|mdc|cdk)-/;
/** Properties declared by a third-party stylesheet the app loads. */
const EXTERNAL_PROPERTIES = new Map([['--art-subtitle-bottom', 'artplayer']]);
/**
* Resolves the "system" theme setting to the app class; the one place that
* may ask for the OS colour scheme.
*/
const COLOR_SCHEME_RESOLVER = 'apps/web/src/app/services/settings.service.ts';
const NAME = '--[A-Za-z0-9_-]+';
const READ = new RegExp(`var\\(\\s*(${NAME})`, 'g');
const DECLARATIONS = [
// `--name: value` in a stylesheet, style attribute or style string. A BEM
// modifier before a pseudo-class (`.row--current:hover`, `&--open:focus`)
// is a selector, not a declaration.
new RegExp(`(?<![\\w&-])(${NAME})\\s*:`, 'g'),
// `style.setProperty('--name', …)`, possibly across lines.
new RegExp(`setProperty\\(\\s*['"\`](${NAME})['"\`]`, 'g'),
// `[style.--name]="…"` and `'[style.--name]': …` host bindings, with or
// without a unit suffix (`[style.--name.px]`).
new RegExp(`\\[style\\.(${NAME})(?:\\.[a-z%]+)?\\]`, 'g'),
// `{ '--name': value }` style objects.
new RegExp(`['"](${NAME})['"]\\s*:`, 'g'),
];
const COLOR_SCHEME_QUERY = /prefers-color-scheme/g;
/**
* Sources that never ship in the app. Their reads are not user-visible, and a
* declaration in one (an E2E `setProperty`, a test stub) would mask a runtime
* read that nothing in the app declares. The marketing website is a separate
* site that themes itself from the OS.
*/
const NON_RUNTIME_PATHS = [
/^apps\/[^/]+-(e2e|mock-server)\//,
/^apps\/website\//,
/(^|\/)(testing|test-stubs|__mocks__|__tests__|[^/]*fixtures)\//,
];
/**
* A dot segment after the file's name that marks test code: `.spec.ts`,
* `.stub.ts`, `.harness.ts`, `.spec-stubs.ts`, `.test-harness.ts`,
* `.xtream-fixtures.ts`. The name itself never counts, so runtime files
* such as `xtream-connection-test.service.ts` stay in the scan.
*/
const TEST_SEGMENT =
/^(spec|test|e2e|stories|stubs?|fixtures?|harness|mocks?|fakes?)$|^(spec|test|e2e)-|-fixtures$/;
/** Test bootstrap files named by their stem alone. */
const TEST_STEM = /^(test-setup|polyfills-test)$|-test-double$/;
export function isScanned(file) {
if (NON_RUNTIME_PATHS.some((pattern) => pattern.test(file))) return false;
const [stem, ...segments] = file
.split('/')
.pop()
.replace(/\.[cm]?[jt]s$|\.s?css$|\.html$/, '')
.split('.');
return (
!TEST_STEM.test(stem) &&
!segments.some((segment) => TEST_SEGMENT.test(segment))
);
}
/**
* Blanks comments, keeping every newline so reported lines stay exact. A
* `//` only starts a comment at a line start or after whitespace, so URLs
* such as `https://…` survive.
*/
export function stripComments(source) {
const blank = (match) => match.replace(/[^\n]/g, ' ');
return source
.replace(/\/\*[\s\S]*?\*\//g, blank)
.replace(/<!--[\s\S]*?-->/g, blank)
.replace(
/(^|[ \t])\/\/[^\n]*/gm,
(match, lead) => lead + blank(match.slice(lead.length))
);
}
/** Maps a character offset in `source` to its 1-based line number. */
function lineLocator(source) {
const starts = [0];
for (
let i = source.indexOf('\n');
i !== -1;
i = source.indexOf('\n', i + 1)
) {
starts.push(i + 1);
}
return (index) => {
let low = 0;
let high = starts.length - 1;
while (low < high) {
const mid = (low + high + 1) >> 1;
if (starts[mid] <= index) low = mid;
else high = mid - 1;
}
return low + 1;
};
}
export function collectReferences(file, source) {
const code = stripComments(source);
const lineAt = lineLocator(code);
const reads = [];
const declared = new Set();
for (const match of code.matchAll(READ)) {
reads.push({ file, line: lineAt(match.index), name: match[1] });
}
for (const pattern of DECLARATIONS) {
for (const match of code.matchAll(pattern)) {
declared.add(match[1]);
}
}
return { reads, declared };
}
/** Reads of properties that no scanned source or known stylesheet declares. */
export function findUndeclaredReads(sources) {
const reads = [];
const declared = new Set();
for (const { file, source } of sources) {
const references = collectReferences(file, source);
reads.push(...references.reads);
for (const name of references.declared) declared.add(name);
}
return reads.filter(
({ name }) =>
!declared.has(name) &&
!EXTERNAL_PREFIX.test(name) &&
!EXTERNAL_PROPERTIES.has(name)
);
}
export function findColorSchemeQueries(file, source) {
if (file === COLOR_SCHEME_RESOLVER) return [];
const code = stripComments(source);
const lineAt = lineLocator(code);
return [...code.matchAll(COLOR_SCHEME_QUERY)].map((match) => ({
file,
line: lineAt(match.index),
}));
}
/** Tracked files under `rootDir` that the guard reads, at any depth. */
export function listScannedFiles(rootDir) {
// No shell: `cmd.exe` treats single quotes as literal characters, so a
// POSIX-quoted pathspec reaches git intact on Windows and matches nothing.
return execFileSync('git', ['ls-files', ...SCANNED_PATHSPECS], {
cwd: rootDir,
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024,
})
.trim()
.split('\n')
.filter(Boolean)
.filter(isScanned);
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) ===
path.resolve(fileURLToPath(import.meta.url));
if (isMain) {
const rootDir = process.cwd();
const files = listScannedFiles(rootDir);
const sources = await Promise.all(
files.map(async (file) => ({
file,
source: await readFile(path.join(rootDir, file), 'utf8'),
}))
);
const undeclared = findUndeclaredReads(sources);
const colorSchemeQueries = sources.flatMap(({ file, source }) =>
findColorSchemeQueries(file, source)
);
if (undeclared.length > 0) {
console.error(
'Custom properties read with var() but declared nowhere; they always fall back. Use a declared --app-* token, or declare the property:'
);
for (const { file, line, name } of undeclared) {
console.error(`- ${file}:${line} ${name}`);
}
process.exitCode = 1;
}
if (colorSchemeQueries.length > 0) {
console.error(
'prefers-color-scheme follows the OS, not the app theme. Use --app-* tokens, or :host-context(.dark-theme) for a dark-only rule:'
);
for (const { file, line } of colorSchemeQueries) {
console.error(`- ${file}:${line}`);
}
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(
`Checked ${files.length} source files; every var() read is declared and no style follows the OS colour scheme.`
);
}
}
+223
View File
@@ -0,0 +1,223 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import {
findColorSchemeQueries,
findUndeclaredReads,
isScanned,
listScannedFiles,
stripComments,
} from './check-theme-references.mjs';
const lines = (...rows) => rows.join('\n');
test('reports a var() read that nothing declares, with its location', () => {
const sources = [
{
file: 'a.scss',
source: lines(
'.title {',
' color: var(--app-heading-color);',
' border-color: var(--settings-muted, #888);',
'}'
),
},
{ file: 'theme.scss', source: 'html { --app-heading-color: #111; }' },
];
assert.deepEqual(findUndeclaredReads(sources), [
{ file: 'a.scss', line: 3, name: '--settings-muted' },
]);
});
test('accepts properties declared in another file, from TS or by a binding', () => {
const sources = [
{
file: 'reads.scss',
source: lines(
'.a { width: var(--overlay-width, 400px); }',
'.b { box-shadow: 0 0 4px var(--radio-accent); }',
'.c { flex: 0 0 var(--guide-col); }',
'.d { left: var(--rail-offset); }',
'.e { color: var(--chip-color); }',
'.f { opacity: var(--dim-opacity); }'
),
},
{
file: 'player.component.ts',
source: lines(
'el.style.setProperty(',
" '--radio-accent',",
' color',
');',
"document.documentElement.style.setProperty('--overlay-width', w);",
"host: { '[style.--rail-offset]': 'offset()' },",
"const style = { '--chip-color': accent };"
),
},
{
file: 'guide.component.html',
source: '<div [style.--guide-col.px]="width"></div>',
},
{ file: 'page.scss', source: ':host {\n --dim-opacity: 0.6;\n}' },
];
assert.deepEqual(findUndeclaredReads(sources), []);
});
test('a BEM modifier before a pseudo-class is not a declaration', () => {
const sources = [
{
file: 'row.scss',
source: lines(
'.row--current:hover { color: var(--current); }',
'.row { &--open:focus { color: var(--open); } }'
),
},
];
assert.deepEqual(
findUndeclaredReads(sources).map(({ name }) => name),
['--current', '--open']
);
});
test('ignores comments, Material internals and known third-party properties', () => {
const sources = [
{
file: 'b.scss',
source: lines(
'// var(--from-a-comment) is not a read.',
'/* nor var(--from-a-block',
' comment) */',
'.a { color: var(--mat-sys-error); }',
'.b { border-color: var(--mdc-outline, red); }',
'.c { bottom: var(--art-subtitle-bottom); }',
'.d { background: url(https://example.test/a.png); }'
),
},
{
file: 'c.html',
source: '<!-- <div style="color: var(--in-html-comment)"> -->',
},
];
assert.deepEqual(findUndeclaredReads(sources), []);
});
test('a comment is blanked without moving later lines', () => {
const source = lines('/* one', 'two */ a', 'b // c', 'https://d');
const stripped = stripComments(source);
assert.equal(stripped.split('\n').length, 4);
assert.equal(stripped.split('\n')[1].trim(), 'a');
assert.equal(stripped.split('\n')[2].trim(), 'b');
assert.equal(stripped.split('\n')[3], 'https://d');
});
test('reports prefers-color-scheme in app styles, outside the settings resolver', () => {
const query = lines(
'.card { color: var(--app-heading-color); }',
'@media (prefers-color-scheme: light) {',
' .card { color: #111; }',
'}'
);
assert.deepEqual(
findColorSchemeQueries('libs/ui/card/card.component.scss', query),
[{ file: 'libs/ui/card/card.component.scss', line: 2 }]
);
assert.deepEqual(
findColorSchemeQueries(
'apps/web/src/app/services/settings.service.ts',
"window.matchMedia('(prefers-color-scheme: dark)')"
),
[]
);
assert.deepEqual(
findColorSchemeQueries(
'libs/ui/card/card.component.scss',
'// Not @media (prefers-color-scheme: light): it follows the OS.'
),
[]
);
});
test('skips every test-only naming convention but keeps runtime look-alikes', () => {
for (const file of [
'apps/web/src/app/services/electron.service.stub.ts',
'apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts',
'apps/web/src/test-setup.ts',
'apps/web/src/polyfills-test.ts',
'libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.fixtures.ts',
'libs/portal/xtream/feature/src/lib/serial-details/serial-details.harness.ts',
'libs/portal/stalker/feature/src/lib/view/view.test-harness.ts',
'libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts',
'libs/ui/playback/src/lib/art-player/art-player.component.spec-fixtures.ts',
'libs/ui/playback/src/lib/player-controls/stream-stats.spec-helpers.ts',
'libs/ui/playback/src/lib/shaka-engine/shaka-player-test-double.ts',
'libs/services/src/lib/playlist-backup.xtream-fixtures.ts',
'libs/shared/marketing-fixtures/src/index.ts',
'libs/shared/database/src/lib/testing/connection-upgrade.ts',
]) {
assert.equal(isScanned(file), false, file);
}
for (const file of [
'libs/services/src/lib/xtream-connection-test.service.ts',
'libs/services/src/lib/xtream-connection-test-state.ts',
'libs/portal/shared/util/src/lib/collection/collection-helpers.ts',
'libs/ui/playback/src/lib/player-controls/web-video-controls.media-helpers.ts',
'libs/ui/components/src/lib/panel/panel.component.scss',
]) {
assert.equal(isScanned(file), true, file);
}
});
test('selects tracked runtime sources at any depth, not tests, E2E, mocks or the website', async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'theme-ref-guard-'));
const git = (...args) =>
execFileSync('git', args, { cwd: rootDir, stdio: 'pipe' });
try {
git('init', '-q');
const files = {
'libs/ui/feature/src/lib/deep/panel.component.scss': '',
'libs/ui/feature/src/lib/deep/panel.component.html': '',
'libs/ui/feature/src/lib/deep/panel.component.ts': '',
'libs/ui/feature/src/lib/deep/panel.component.spec.ts': '',
'libs/ui/feature/src/lib/deep/panel.test-helpers.ts': '',
'libs/ui/feature/src/lib/deep/panel.test-stubs.ts': '',
'libs/shared/testing/src/index.ts': '',
'apps/web/src/styles.scss': '',
'apps/web/src/vendor.css': '',
'apps/electron-backend-e2e/src/rail.e2e.ts': '',
'apps/electron-backend-e2e/src/theme-contrast.ts': '',
'apps/web-e2e/src/recent.e2e-support.ts': '',
'apps/xtream-mock-server/src/main.ts': '',
'apps/website/src/styles/global.css': '',
'tools/outside.scss': '',
};
for (const [file, content] of Object.entries(files)) {
await mkdir(path.dirname(path.join(rootDir, file)), {
recursive: true,
});
await writeFile(path.join(rootDir, file), content);
}
git('add', '.');
// Untracked files are not part of the checkout CI sees.
await writeFile(path.join(rootDir, 'apps/web/src/untracked.scss'), '');
assert.deepEqual(listScannedFiles(rootDir).sort(), [
'apps/web/src/styles.scss',
'apps/web/src/vendor.css',
'libs/ui/feature/src/lib/deep/panel.component.html',
'libs/ui/feature/src/lib/deep/panel.component.scss',
'libs/ui/feature/src/lib/deep/panel.component.ts',
]);
} finally {
await rm(rootDir, { recursive: true, force: true });
}
});