mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
* 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>
235 lines
7.9 KiB
JavaScript
235 lines
7.9 KiB
JavaScript
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.`
|
|
);
|
|
}
|
|
}
|