mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -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>
323 lines
12 KiB
JavaScript
323 lines
12 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 { stripScssComments } from './check-stylesheet-inputs.mjs';
|
|
|
|
/**
|
|
* The web app's global stylesheets used to remove the outline from every
|
|
* `input`, `button`, `textarea` and `:focus`, so keyboard users saw no focus
|
|
* at all. A global rule may remove the outline only where focus is not
|
|
* visible (`:focus:not(:focus-visible)`), or on an element the selector
|
|
* scopes by class, id or attribute; and a bare `:focus-visible` rule must
|
|
* draw the fallback ring. Component styles are out of scope: Angular scopes
|
|
* them, and they may replace the ring with an indicator of their own.
|
|
*/
|
|
|
|
/** The global entry stylesheets and the partials beside them. */
|
|
const SCANNED_PATHSPECS = [':(glob)apps/web/src/*.scss'];
|
|
|
|
const OUTLINE_DECLARATION =
|
|
/^(outline(?:-style|-width|-color)?)\s*:\s*(.+?)\s*(?:!important)?$/i;
|
|
|
|
/** A zero length: `0`, `0px`, `0.0em`. */
|
|
const ZERO_WIDTH = /^[+-]?0*\.?0+(?:[a-z]+)?$/i;
|
|
/** `none`, `hidden`, or a keyword that resets the outline to `none`. */
|
|
const NO_STYLE = /^(?:none|hidden|initial|unset)$/i;
|
|
/** A style that draws, or a `var()` that may carry one. */
|
|
const DRAWN_STYLE =
|
|
/^(?:auto|solid|dashed|dotted|double|groove|ridge|inset|outset|inherit|revert|revert-layer|var\(.*\))$/i;
|
|
/** `transparent`, or a colour whose alpha (4th or slashed) is zero. */
|
|
const NO_COLOR =
|
|
/^(?:transparent|#[0-9a-f]{3}0|#[0-9a-f]{6}00|(?:rgb|hsl)a?\((?:[^,()]+,){3}\s*0*\.?0+%?\s*\)|(?:rgb|hsl)a?\([^/()]*\/\s*0*\.?0+%?\s*\))$/i;
|
|
|
|
/** Splits `text` on `separator` outside parentheses and brackets. */
|
|
export function splitTopLevel(text, separator) {
|
|
const parts = [];
|
|
let depth = 0;
|
|
let start = 0;
|
|
for (let i = 0; i < text.length; i += 1) {
|
|
const char = text[i];
|
|
if (char === '(' || char === '[') depth += 1;
|
|
else if (char === ')' || char === ']') depth -= 1;
|
|
else if (depth === 0 && separator.test(char)) {
|
|
parts.push(text.slice(start, i));
|
|
start = i + 1;
|
|
}
|
|
}
|
|
parts.push(text.slice(start));
|
|
return parts.map((part) => part.trim()).filter(Boolean);
|
|
}
|
|
|
|
/** Resolves a nested selector list against its parent selectors. */
|
|
function resolveSelectors(parents, selectorList) {
|
|
const own = splitTopLevel(selectorList, /,/);
|
|
if (parents.length === 0) return own;
|
|
return parents.flatMap((parent) =>
|
|
own.map((selector) =>
|
|
selector.includes('&')
|
|
? selector.replaceAll('&', parent)
|
|
: `${parent} ${selector}`
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether an `outline*` declaration leaves no visible outline: a `none` or
|
|
* `hidden` style, a zero width or a transparent colour, in the longhands or
|
|
* anywhere in the shorthand (`outline: 0 solid transparent`). A shorthand
|
|
* without a style (`outline: 2px`, `outline: red`) resets it to `none`.
|
|
*/
|
|
export function removesOutline(declaration) {
|
|
const match = OUTLINE_DECLARATION.exec(declaration);
|
|
if (!match) return false;
|
|
const property = match[1].toLowerCase();
|
|
const tokens = splitTopLevel(match[2], /\s/);
|
|
const invisible = {
|
|
'outline-style': (token) => NO_STYLE.test(token),
|
|
'outline-width': (token) => ZERO_WIDTH.test(token),
|
|
'outline-color': (token) => NO_COLOR.test(token),
|
|
outline: (token) =>
|
|
NO_STYLE.test(token) ||
|
|
ZERO_WIDTH.test(token) ||
|
|
NO_COLOR.test(token),
|
|
}[property];
|
|
const styleless =
|
|
property === 'outline' &&
|
|
!tokens.some((token) => DRAWN_STYLE.test(token));
|
|
return styleless || tokens.some(invisible);
|
|
}
|
|
|
|
/** Whether a declaration draws a visible outline. */
|
|
function drawsOutline(declaration) {
|
|
return /^outline\s*:/i.test(declaration) && !removesOutline(declaration);
|
|
}
|
|
|
|
/** Expands `:is()` / `:where()` / `:matches()` lists into plain branches. */
|
|
export function expandBranches(selector) {
|
|
const match = /:(?:is|where|matches)\(/i.exec(selector);
|
|
if (!match) return [selector];
|
|
const open = match.index + match[0].length;
|
|
let depth = 1;
|
|
let close = open;
|
|
while (close < selector.length && depth > 0) {
|
|
if (selector[close] === '(') depth += 1;
|
|
else if (selector[close] === ')') depth -= 1;
|
|
close += 1;
|
|
}
|
|
const head = selector.slice(0, match.index);
|
|
const tail = selector.slice(close);
|
|
return splitTopLevel(selector.slice(open, close - 1), /,/).flatMap(
|
|
(branch) => expandBranches(`${head}${branch}${tail}`)
|
|
);
|
|
}
|
|
|
|
/** The last compound selector, the element the rule actually styles. */
|
|
function subjectOf(selector) {
|
|
const compounds = splitTopLevel(selector, /[\s>+~]/);
|
|
return compounds[compounds.length - 1] ?? '';
|
|
}
|
|
|
|
/** A compound without its `:not(...)` arguments. */
|
|
export function withoutNegations(compound) {
|
|
let out = '';
|
|
for (let i = 0; i < compound.length; i += 1) {
|
|
if (/^:not\(/i.test(compound.slice(i))) {
|
|
let depth = 0;
|
|
for (; i < compound.length; i += 1) {
|
|
if (compound[i] === '(') depth += 1;
|
|
else if (compound[i] === ')' && --depth === 0) break;
|
|
}
|
|
} else {
|
|
out += compound[i];
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Whether removing the outline here strips it from elements nothing else
|
|
* styles: the subject has no class, id or attribute of its own, and does
|
|
* not limit the removal to focus that is not visible.
|
|
*/
|
|
export function isBlanketSelector(selector) {
|
|
return expandBranches(selector).some((branch) => {
|
|
const subject = subjectOf(branch);
|
|
if (subject.includes('::')) return false;
|
|
if (/:not\(\s*:focus-visible\s*\)/i.test(subject)) return false;
|
|
return !/[.#[]/.test(withoutNegations(subject));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Whether a selector is the global `:focus-visible` fallback: the whole
|
|
* selector, not only its subject, so `.panel :focus-visible` (which leaves
|
|
* everything outside the panel without a ring) is not one.
|
|
*/
|
|
function isFallbackSelector(selector) {
|
|
return expandBranches(selector).some((branch) =>
|
|
/^\*?:focus-visible$/i.test(branch.replace(/\s+/g, ''))
|
|
);
|
|
}
|
|
|
|
/** One line number per offset, for reports. */
|
|
function lineAt(source, index) {
|
|
let line = 1;
|
|
for (let i = 0; i < index; i += 1) if (source[i] === '\n') line += 1;
|
|
return line;
|
|
}
|
|
|
|
/**
|
|
* Walks the rules of a stylesheet: each declaration and `@include` with the
|
|
* fully resolved selectors it applies to. Mixin bodies are walked as if they
|
|
* were included at the top level, the widest place a caller could use them.
|
|
* `conditional` marks a declaration inside an at-rule (`@mixin`, `@media`,
|
|
* `@if`, an `@include` content block…), which may never reach the page;
|
|
* `mixin` names the mixin whose body holds it.
|
|
*/
|
|
export function walkDeclarations(source) {
|
|
const text = stripScssComments(source);
|
|
const items = [];
|
|
const stack = [{ selectors: [], conditional: false, mixin: null }];
|
|
let start = 0;
|
|
let quote = '';
|
|
let interpolation = 0;
|
|
const statement = (end) => {
|
|
const raw = text.slice(start, end);
|
|
const value = raw.trim();
|
|
const offset = start + raw.indexOf(value);
|
|
start = end + 1;
|
|
return { value, offset };
|
|
};
|
|
for (let i = 0; i < text.length; i += 1) {
|
|
const char = text[i];
|
|
if (quote) {
|
|
if (char === '\\') i += 1;
|
|
else if (char === quote) quote = '';
|
|
continue;
|
|
}
|
|
if (char === '"' || char === "'") quote = char;
|
|
else if (text.startsWith('#{', i)) {
|
|
interpolation += 1;
|
|
i += 1;
|
|
} else if (interpolation > 0 && char === '}') interpolation -= 1;
|
|
else if (interpolation > 0) continue;
|
|
else if (char === '{') {
|
|
const { value } = statement(i);
|
|
const parent = stack[stack.length - 1];
|
|
const mixin = /^@mixin\s+([\w-]+)/i.exec(value)?.[1];
|
|
if (mixin) stack.push({ selectors: [], conditional: true, mixin });
|
|
else if (value.startsWith('@'))
|
|
stack.push({ ...parent, conditional: true });
|
|
else
|
|
stack.push({
|
|
...parent,
|
|
selectors: resolveSelectors(parent.selectors, value),
|
|
});
|
|
} else if (char === ';' || char === '}') {
|
|
const { value, offset } = statement(i);
|
|
if (value) {
|
|
const { selectors, conditional, mixin } =
|
|
stack[stack.length - 1];
|
|
items.push({
|
|
selectors,
|
|
conditional,
|
|
mixin,
|
|
declaration: value.replace(/\s+/g, ' '),
|
|
line: lineAt(text, offset),
|
|
});
|
|
}
|
|
if (char === '}' && stack.length > 1) stack.pop();
|
|
}
|
|
}
|
|
return items;
|
|
}
|
|
|
|
/** Blanket outline removals in one stylesheet. */
|
|
export function findBlanketOutlineRemovals(file, source) {
|
|
return walkDeclarations(source).flatMap(
|
|
({ selectors, declaration, line }) =>
|
|
removesOutline(declaration)
|
|
? selectors.filter(isBlanketSelector).map((selector) => ({
|
|
file,
|
|
line,
|
|
selector,
|
|
declaration,
|
|
}))
|
|
: []
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether a stylesheet always draws the bare `:focus-visible` fallback ring:
|
|
* an unconditional rule, not one in a mixin body nobody may include or under
|
|
* a media query.
|
|
*/
|
|
export function hasFocusVisibleFallback(source) {
|
|
return walkDeclarations(source).some(
|
|
({ selectors, conditional, declaration }) =>
|
|
!conditional &&
|
|
selectors.some(isFallbackSelector) &&
|
|
(drawsOutline(declaration) ||
|
|
/^@include\s+[\w.-]*focus-ring/i.test(declaration))
|
|
);
|
|
}
|
|
|
|
/** Tracked global 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',
|
|
})
|
|
.trim()
|
|
.split('\n')
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/** Every problem across the global stylesheets, as printable lines. */
|
|
export function checkFocusVisible(sources) {
|
|
const problems = sources.flatMap(({ file, source }) =>
|
|
findBlanketOutlineRemovals(file, source).map(
|
|
({ file: at, line, selector, declaration }) =>
|
|
`${at}:${line} \`${selector}\` sets \`${declaration}\``
|
|
)
|
|
);
|
|
if (!sources.some(({ source }) => hasFocusVisibleFallback(source))) {
|
|
problems.push(
|
|
'No global `:focus-visible` rule draws the fallback outline (`@include focus-ring.focus-ring-declarations`).'
|
|
);
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
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 = checkFocusVisible(sources);
|
|
if (problems.length > 0) {
|
|
console.error(
|
|
'Global styles hide keyboard focus. Remove the outline only under `:focus:not(:focus-visible)` or on a scoped selector, and keep the `:focus-visible` fallback ring:'
|
|
);
|
|
for (const problem of problems) console.error(`- ${problem}`);
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log(
|
|
`Checked ${files.length} global stylesheets; keyboard focus keeps its fallback ring.`
|
|
);
|
|
}
|
|
}
|