mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
* fix(ui): one focus ring colour everywhere via the shared mixin #1866 gave the app a keyboard focus ring token (`--app-focus-ring`, at least 3:1 on every surface), but 59 component rings still drew their own colour: the selection blue under several names (`--app-selection-color`, `--mat-sys-primary`, `--app-selection-border`, the EPG's `$accent-blue`, `--embedded-mpv-accent`, `--apd-accent`, the hero's `--accent-color`), the heading colour, or the overlay's literal text colour. The selection blue falls to 2.6:1 on the stronger selection tint. - Every one now includes `focus-ring.focus-ring-declarations`, keeping its offset; the projects that newly import `libs/ui/styles` declare `ui-styles`. - The mixin reads the token alone: it is declared on `html` in both themes, and the fallback chain cost bytes in every ring. - `tools/nx/check-focus-ring-colour.mjs` joins `styles:focus-visible: validate`: an outline in a focus rule must use the token or, over video, the player's `--pc-*` palette. Three deliberate exceptions are listed with their reasons, and a stale one is reported. On master it reports these 59 rings. - The keyboard-focus E2E asserts each ring's colour equals the token where it is drawn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ui): search fields and shadow rings take the focus colour too; guard reads them Local review (Greptile P2): the ring-colour guard read only `outline`, so a focus indicator drawn as a shadow or a border kept any colour. Search fields showed focus as a `--mat-sys-primary`/selection border with a 12-16% halo, the EPG guide's keyboard cell as an inset `$accent-blue` shadow, and the downloads cards tinted their artwork border with the M3 primary. - Every one now uses `--app-focus-ring` (the halos and tints keep their strength through `color-mix()`); over video the panel's search border mixes the overlay's own ring colour. - The guard reads a focus rule's outline, its unblurred ring or line shadows and its border colours. Neutral boundaries (separator and widget-border tokens, transparent, currentColor) and blurred lift shadows are not indicators. On the previous commit it reports these 21 declarations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tooling): check every focus colour on its own; count exceptions only where they excuse a ring Greptile on #1882 (2×P2): - The guard joined a declaration's colours and accepted the lot when the token appeared anywhere, so `border-color: var(--app-focus-ring) red` or a `color-mix()` of the token and red passed. It now splits each declaration into plain colours, every `color-mix()` argument included, and checks each one: the token, the player palette or a neutral boundary. An outline or shadow ring without a colour is drawn in the text colour, which only a border may keep. - An exception counted as used when its value appeared in any declaration (the diagnostic's amber is also a text colour), so a stale one was never reported. It now counts only where it excuses an off-token focus indicator. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): the row a search result reveals takes the app focus ring Codex on #1882 (P2): choosing a settings search result with Enter focuses its row by script, and the keypress keeps `:focus-visible`, so the row's 60%-transparent selection outline was a real keyboard focus ring under 3:1, not the passive marker its guard exception described. The row now includes the shared mixin (keeping its 12px radius), the exception is gone, and the keyboard-focus E2E reveals a row with Enter and asserts the app ring on it; with the old rule it fails on the 60% colour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tooling): accept focus colour tokens by exact name Greptile (local, P2): the guard matched `--app-focus-ring` and the neutral boundary tokens by prefix, so `var(--app-focus-ring-other, red)` or `var(--app-separator-strong)` passed. It now reads the property a `var()` names and accepts exactly `--app-focus-ring`, a `--pc-*` palette token or one of the four neutral boundary tokens; their fallbacks are never drawn, since the tokens are always declared, so they are not checked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tooling): check the body of a focus mixin, the shared ring included Greptile (local, P2): a mixin body has no selector and its includes are not expanded, so the shared `focus-ring-declarations` itself escaped the colour check; turning it red would change every ring and pass. The walker now names the mixin a declaration sits in, and the colour guard treats the body of a mixin whose name mentions focus as a focus rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tooling): read logical border sides in the focus colour guard Greptile on #1882 (P2): only `border`, `border-color` and the physical sides were read, so `border-inline-start: 2px solid red` in a focus rule passed. The guard now reads every colour-carrying border property: the shorthand and the physical and logical sides, with or without `-color`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tooling): an exception excuses its own colour, not the declaration Greptile (local, P2): an exception matched the whole declaration, so in the player stylesheet `box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px red`, or the white mixed with red, passed. Exceptions now apply to each plain colour, by exact value, like every other check; an exception counts as used only where it excuses one of a focus indicator's colours. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tooling): tell a var() or calc() width from the ring colour Greptile (local, P2): `outline: var(--ring-width) solid var(--app-focus- ring)` failed the guard, since any non-length token counted as a colour. Math functions now count as lengths, and a shorthand with one colour slot takes its literal colour, else an accepted token (the other `var()`s are widths), else reports every candidate it cannot prove; `border-color` still checks a colour per side. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tooling): read a shadow ring whose spread is computed Codex (local, P2): with `box-shadow: 0 0 0 calc(1px + 1px) red` or a `var()` spread, only three literal zeros were left as lengths, so the shadow looked flat and was skipped. A shadow is now skipped only when it is provably not a ring: a literal non-zero blur in the third place, or every length a literal zero with no `var()` that could be a spread. The test with a variable-width token ring now asserts it is read, not skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tooling): read shadow lengths by type, with the colour first or no blur Greptile (local, P2): the guard took a shadow's first three tokens as its lengths, so `0 2px` (a line in the text colour, no blur) was skipped as blurred and a colour-first lift shadow was read as a ring. Lengths are now read by type: a colour sits before or after them, never between, and a missing blur is zero. A `var()` counts as a possible length, so a shadow is skipped only when the first three possible lengths prove a blur, or every length and the first four possible ones are zero. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tooling): a negated focus condition does not make a focus rule Greptile (local, P2): the colour guard read `:focus-visible` inside `:not()` as a focus rule, so a hover style such as `.button:hover:not(:focus-visible) { border-color: red; }` failed. The selector is now tested without its `:not()` arguments; a focus condition elsewhere, `:has()` included, still makes a focus rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tooling): read a width in any CSS length unit Greptile (local, P2): only px, em and rem were lengths, so `outline: 1pt solid var(--app-focus-ring)` took `1pt` for the colour and failed. Every CSS length unit (absolute, font-relative, viewport and container) now counts as a length. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ui): the open-in-playlist chip rings in the app focus colour Codex on #1882 (P2): the chip's inline component styles drew its focus ring in `--app-selection-color`, out of the colour guard's reach (it reads stylesheets). It was the only inline-style focus ring in the repository; it now uses `--app-focus-ring` like every other ring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(ui): move the open-in-playlist chip styles into a stylesheet Codex on #1882 (P2) suggested this over scanning TS inline styles: the chip was the only component with a focus ring in inline `styles`, which the colour guard does not read. Its styles now live in `open-in-playlist-chip.component.scss` unchanged, the ring through the shared `focus-ring-declarations` mixin, so the guard covers it (a drifted colour there is reported). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
340 lines
13 KiB
JavaScript
340 lines
13 KiB
JavaScript
import { execFileSync } from 'node:child_process';
|
|
import { readFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import {
|
|
removesOutline,
|
|
splitTopLevel,
|
|
walkDeclarations,
|
|
withoutNegations,
|
|
} from './check-focus-visible.mjs';
|
|
|
|
/**
|
|
* Every keyboard focus indicator is drawn in the app ring colour: the
|
|
* `focus-ring-declarations` mixin from `libs/ui/styles/_focus-ring.scss`, or
|
|
* `var(--app-focus-ring)` (also inside `color-mix()`, where every other
|
|
* colour mixed in must be neutral). Indicators drawn in
|
|
* their own colour drifted from it: the selection blue many components used
|
|
* falls to 2.6:1 on the stronger selection tint, where the token keeps 3:1
|
|
* on every surface. A focus rule's outline, its ring- or line-shaped shadows
|
|
* (unblurred) and its border colours are checked; a neutral boundary and a
|
|
* blurred lift shadow are not indicators. Surfaces over video keep the
|
|
* player's `--pc-*` palette; the few deliberate exceptions are listed below.
|
|
*/
|
|
|
|
/** Every app and library stylesheet. */
|
|
const SCANNED_PATHSPECS = [
|
|
':(glob)apps/web/src/**/*.scss',
|
|
':(glob)libs/**/*.scss',
|
|
];
|
|
|
|
/** A rule that styles focus. */
|
|
const FOCUS_SELECTOR =
|
|
/:focus(?:-visible|-within)?\b|\.is-focused\b|\.cdk-keyboard-focused\b/i;
|
|
|
|
/** The custom property a `var()` reads (its fallback aside), or null. */
|
|
function propertyRead(colour) {
|
|
return /^var\(\s*(--[\w-]+)\s*[,)]/i.exec(colour)?.[1] ?? null;
|
|
}
|
|
|
|
/**
|
|
* The app ring token, or the player's own palette over video, by exact
|
|
* name: `--app-focus-ring-other` is not the token. A fallback is never
|
|
* drawn, since these tokens are always declared, so it is not checked.
|
|
*/
|
|
function isAppRing(colour) {
|
|
const property = propertyRead(colour);
|
|
return property === '--app-focus-ring' || /^--pc-[\w-]+$/.test(property);
|
|
}
|
|
|
|
/** Boundary tokens that do not signal focus. */
|
|
const NEUTRAL_TOKENS = new Set([
|
|
'--app-separator',
|
|
'--app-widget-border',
|
|
'--app-rail-border',
|
|
'--app-search-border',
|
|
]);
|
|
|
|
/** A boundary colour that does not signal focus. */
|
|
function isNeutral(colour) {
|
|
return (
|
|
/^(?:transparent|none)$/i.test(colour) ||
|
|
NEUTRAL_TOKENS.has(propertyRead(colour))
|
|
);
|
|
}
|
|
|
|
/** Text colour: fine for a border, a colour of its own for a ring. */
|
|
const TEXT_COLOUR = /^(?:currentcolor|inherit)$/i;
|
|
|
|
/** A literal length in any CSS unit (or a unitless zero/number). */
|
|
const LENGTH =
|
|
/^-?(?:\d+|\d*\.\d+)(?:px|em|rem|pt|pc|in|cm|mm|q|ch|ex|ic|lh|rlh|cap|vw|vh|vi|vb|vmin|vmax|[sld]v(?:w|h|i|b|min|max)|cq(?:w|h|i|b|min|max))?$/i;
|
|
const WIDTH_KEYWORD = /^(?:thin|medium|thick)$/i;
|
|
const LINE_STYLE =
|
|
/^(?:auto|none|hidden|solid|dashed|dotted|double|groove|ridge|inset|outset)$/i;
|
|
|
|
/** The plain colours of a colour expression: each `color-mix()` argument. */
|
|
export function colourAtoms(expression) {
|
|
return splitTopLevel(expression, /\s/).flatMap((token) => {
|
|
const mix = /^color-mix\((.*)\)$/is.exec(token);
|
|
if (!mix) return [token];
|
|
// The first argument is the colour space; each other one is a
|
|
// colour with an optional percentage on either side.
|
|
return splitTopLevel(mix[1], /,/)
|
|
.slice(1)
|
|
.flatMap((argument) =>
|
|
colourAtoms(
|
|
argument.replace(/(?:^|\s)-?[\d.]+%(?=\s|$)/g, ' ').trim()
|
|
)
|
|
);
|
|
});
|
|
}
|
|
|
|
/** A length computed by a math function: `calc(1px + 1px)`, `max(…)`. */
|
|
const LENGTH_FUNCTION = /^(?:calc|min|max|clamp)\(/i;
|
|
|
|
/** Tokens of a value that may name colours: not lengths, widths or styles. */
|
|
function colourTokens(value) {
|
|
return splitTopLevel(value, /\s/).filter(
|
|
(token) =>
|
|
!LENGTH.test(token) &&
|
|
!LENGTH_FUNCTION.test(token) &&
|
|
!WIDTH_KEYWORD.test(token) &&
|
|
!LINE_STYLE.test(token)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The colour of a shorthand with one colour slot (an outline, a border, a
|
|
* shadow), where a `var()` may hold a width or a style instead. A literal
|
|
* colour is the colour; without one, an accepted token is (the others are
|
|
* widths); otherwise every candidate is reported, since none is proven.
|
|
*/
|
|
function shorthandColour(tokens) {
|
|
const literal = tokens.filter((token) => !/^var\(/i.test(token));
|
|
if (literal.length) return literal;
|
|
const accepted = tokens.filter(
|
|
(token) => isAppRing(token) || isNeutral(token)
|
|
);
|
|
return accepted.length ? accepted : tokens;
|
|
}
|
|
|
|
/**
|
|
* The plain colours a focus rule's declaration draws its indicator with, or
|
|
* an empty list when it draws none: an outline, the shadows that are rings
|
|
* or lines (no blur, with a spread or an offset), and border colours. An
|
|
* outline or shadow without a colour is drawn in `currentcolor`.
|
|
*/
|
|
export function indicatorColours(declaration) {
|
|
const match = /^([a-z-]+)\s*:\s*(.+?)\s*(?:!important)?$/i.exec(
|
|
declaration
|
|
);
|
|
if (!match) return [];
|
|
const property = match[1].toLowerCase();
|
|
const value = match[2];
|
|
if (/^outline(?:-color)?$/.test(property)) {
|
|
if (removesOutline(declaration)) return [];
|
|
const tokens = colourTokens(value);
|
|
const colours =
|
|
property === 'outline' ? shorthandColour(tokens) : tokens;
|
|
return (colours.length ? colours : ['currentcolor']).flatMap(
|
|
colourAtoms
|
|
);
|
|
}
|
|
if (property === 'box-shadow') {
|
|
return splitTopLevel(value, /,/).flatMap((shadow) => {
|
|
const tokens = splitTopLevel(shadow, /\s/).filter(
|
|
(token) => token.toLowerCase() !== 'inset'
|
|
);
|
|
const isLength = (token) =>
|
|
LENGTH.test(token) || LENGTH_FUNCTION.test(token);
|
|
// Lengths by type, not place: the colour comes first or last,
|
|
// never between the lengths, and a missing blur is zero. A
|
|
// `var()` may be a length or the colour, so it counts as a
|
|
// possible length. Not a ring only when provably so: the first
|
|
// three possible lengths are literal with a non-zero blur (a
|
|
// lift shadow), or every length is a literal zero and so are the
|
|
// first four possible lengths (or there is no `var()`).
|
|
const literal = (token) => LENGTH.test(token);
|
|
const zero = (token) => literal(token) && parseFloat(token) === 0;
|
|
const lengths = tokens.filter(isLength);
|
|
const possible = tokens.filter(
|
|
(token) => isLength(token) || /^var\(/i.test(token)
|
|
);
|
|
const blurred =
|
|
possible.length >= 3 &&
|
|
possible.slice(0, 3).every(literal) &&
|
|
parseFloat(possible[2]) !== 0;
|
|
const flat =
|
|
lengths.every(zero) &&
|
|
(possible.length === lengths.length ||
|
|
possible.slice(0, 4).every(zero));
|
|
if (blurred || flat) return [];
|
|
const colours = shorthandColour(
|
|
tokens.filter((token) => !isLength(token))
|
|
);
|
|
return (colours.length ? colours : ['currentcolor']).flatMap(
|
|
colourAtoms
|
|
);
|
|
});
|
|
}
|
|
// Every border that carries a colour: the shorthand, the physical and
|
|
// logical sides (`border-inline-start`, `border-block-end-color`…).
|
|
if (
|
|
/^border(?:-(?:top|right|bottom|left|(?:block|inline)(?:-(?:start|end))?))?(?:-color)?$/.test(
|
|
property
|
|
)
|
|
) {
|
|
// `border-color` lists a colour per side; the other borders have one.
|
|
const tokens = colourTokens(value);
|
|
const colours = /-color$/.test(property)
|
|
? tokens
|
|
: shorthandColour(tokens);
|
|
return colours.flatMap(colourAtoms);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/** Rings that deliberately differ, matched by file and value. */
|
|
export const RING_EXCEPTIONS = [
|
|
{
|
|
file: 'libs/ui/playback/src/lib/player-controls/player-controls.component.scss',
|
|
value: '#ffffff',
|
|
reason: 'the play button rings white around its own blue fill',
|
|
},
|
|
{
|
|
file: 'libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss',
|
|
value: '#ffb24c',
|
|
reason: "the diagnostic's amber accent on its near-black scrim (a spec loads this stylesheet as raw CSS)",
|
|
},
|
|
];
|
|
|
|
/** The exception that allows this exact colour in this file, if any. */
|
|
function exceptionFor(file, colour) {
|
|
return RING_EXCEPTIONS.find(
|
|
(exception) =>
|
|
exception.file === file &&
|
|
exception.value.toLowerCase() === colour.toLowerCase()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Focus indicators in one stylesheet, each with the colours it draws that
|
|
* are not the app ring, the player palette or a neutral boundary, before
|
|
* exceptions apply.
|
|
*/
|
|
function offTokenIndicators(file, source) {
|
|
return walkDeclarations(source).flatMap(
|
|
({ selectors, declaration, line, mixin }) => {
|
|
// A focus mixin's body (the shared `focus-ring-declarations`)
|
|
// draws rings wherever it is included.
|
|
// `:hover:not(:focus-visible)` excludes focus: only a focus
|
|
// condition outside `:not()` makes a focus rule.
|
|
const focus =
|
|
selectors.find((selector) =>
|
|
FOCUS_SELECTOR.test(withoutNegations(selector))
|
|
) ?? (mixin && /focus/i.test(mixin) ? `@mixin ${mixin}` : null);
|
|
if (!focus) return [];
|
|
const border = /^border/i.test(declaration);
|
|
const colours = indicatorColours(declaration).filter(
|
|
(colour) =>
|
|
!isAppRing(colour) &&
|
|
!isNeutral(colour) &&
|
|
!(border && TEXT_COLOUR.test(colour))
|
|
);
|
|
return colours.length
|
|
? [{ file, line, selector: focus, declaration, colours }]
|
|
: [];
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Focus indicators in one stylesheet that bypass the app ring colour. An
|
|
* exception excuses its own colour only: another colour in the same
|
|
* declaration (a second shadow, a mix) is still reported.
|
|
*/
|
|
export function findOffTokenRings(file, source) {
|
|
return offTokenIndicators(file, source)
|
|
.filter(({ colours }) =>
|
|
colours.some((colour) => !exceptionFor(file, colour))
|
|
)
|
|
.map(({ colours, ...indicator }) => indicator);
|
|
}
|
|
|
|
/**
|
|
* Exceptions that no longer excuse any off-token focus colour, so the list
|
|
* cannot rot: the same value elsewhere (a text colour) does not count.
|
|
*/
|
|
export function findUnusedExceptions(sources) {
|
|
const used = new Set(
|
|
sources.flatMap(({ file, source }) =>
|
|
offTokenIndicators(file, source)
|
|
.flatMap(({ colours }) =>
|
|
colours.map((colour) => exceptionFor(file, colour))
|
|
)
|
|
.filter(Boolean)
|
|
)
|
|
);
|
|
return RING_EXCEPTIONS.filter((exception) => !used.has(exception));
|
|
}
|
|
|
|
/** Tracked app and library stylesheets under `rootDir`. */
|
|
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);
|
|
}
|
|
|
|
/** Every problem across the stylesheets, as printable lines. */
|
|
export function checkFocusRingColour(sources) {
|
|
return [
|
|
...sources.flatMap(({ file, source }) =>
|
|
findOffTokenRings(file, source).map(
|
|
({ file: at, line, selector, declaration }) =>
|
|
`${at}:${line} \`${selector}\` sets \`${declaration}\``
|
|
)
|
|
),
|
|
...findUnusedExceptions(sources).map(
|
|
({ file, value }) =>
|
|
`${file}: the exception for \`${value}\` matches nothing; remove it from RING_EXCEPTIONS`
|
|
),
|
|
];
|
|
}
|
|
|
|
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 problems = checkFocusRingColour(sources);
|
|
if (problems.length > 0) {
|
|
console.error(
|
|
'Focus rings bypass the app ring. Use `@include focus-ring.focus-ring-declarations` (libs/ui/styles/_focus-ring.scss) or `var(--app-focus-ring)`:'
|
|
);
|
|
for (const problem of problems) console.error(`- ${problem}`);
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log(
|
|
`Checked ${files.length} stylesheets; every focus ring uses the app ring.`
|
|
);
|
|
}
|
|
}
|