Files
iptvnator/tools/nx/check-focus-visible.mjs
T
4grayandClaude Opus 5.5 17a2b1b3b0 fix(ui): visible keyboard focus everywhere via a global focus-visible fallback (#1866)
* fix(ui): visible keyboard focus everywhere via a global focus-visible fallback

The global stylesheet removed the outline from every input, button,
textarea and `:focus`, so Tab users saw no focus on about 110 raw buttons:
detail actions, season tabs, chips, title results, list rows.

- Remove the outline only under `:focus:not(:focus-visible)` and draw a
  fallback ring on every other `:focus-visible` element. Both rules sit
  in `:where()`, so any component that styles its own focus still wins.
- One recipe: `focus-ring-declarations` moves to
  `libs/ui/styles/_focus-ring.scss` and reads `--app-focus-ring`, declared
  per theme (light #1d63e0, dark #8cbaff) with at least 3:1 on every app
  surface and selection tint; `m3-theme.spec.ts` measures it. The card
  ring, the detail actions, the portal sidebar and both context panels
  use the mixin (the panels' selection-blue ring fell to 2.97:1 on the
  active item).
- Material Tab stops (buttons, switches, button toggles, checkboxes) take
  the ring over their 12% focus state layer; menu items and options keep
  Material's highlight.
- Surfaces over video (player controls, vendor chrome, fullscreen panels,
  Up Next rail) point the ring at the player's text colour.
- The selected season tab drew its border with `outline`, which outranks
  the fallback; it is a spread shadow now.
- Guard: `pnpm run styles:focus-visible:validate` (CI) rejects a global
  `outline: none` on an unscoped selector and a missing fallback.
- Electron E2E `keyboard-focus-ring.e2e.ts` tabs through the detail
  actions and season tabs, a catalog grid with its refinement chips, the
  Sources list and Settings in both themes: one ring per stop, 3:1 where
  measurable, none after a click.

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

* fix(ui): keep the player ring in the fullscreen panel; tighten the focus guard and E2E

Local review round 1 (Codex P2, Greptile 3×P2):

- The fullscreen channel panel carries `dark-theme`, whose context declares
  the theme ring again, so its own controls ringed in #8cbaff. Point the
  token back at the player's text colour on `.fullscreen-channel-panel
  .dark-theme`; the web series-playback E2E checks the close button's ring.
- The guard took a container-scoped rule (`.panel :focus-visible`), a mixin
  body or a media query as the global fallback. The fallback is now the
  whole selector, outside any at-rule.
- The keyboard-focus E2E now fails a ring that an `overflow: hidden`
  ancestor (up to the scroll container) cuts.

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

* fix(ui): show focus where a component removes its outline; catch invisible rings in the guard

GitHub review round on #1866 (Codex P2, Greptile P2):

- A component rule that sets `outline: none` outranks the zero-specificity
  fallback. Re-audited every one: two hid a Tab stop with nothing else to
  show. The EPG list row (`role="button"`) now draws an inset ring, and the
  command palette underlines its search row while the field has focus.
  The others already show focus on the field's wrapper, on another
  element, or as a highlight inside an arrow-key composite (the "…" menu,
  the guide's search listbox); the guidelines now state the rule.
- The guard read only a bare zero or `none` as a removal. It now reads a
  zero width, a `none`/`hidden` style or a transparent colour anywhere in
  the shorthand or longhands, so `outline: 0 solid transparent` is
  reported and `outline: 2px solid transparent` is no fallback.
- The keyboard-focus E2E walks the live EPG list and the command palette
  too, and reads an inset or offset shadow line as a ring.

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

* fix(player): keep --pc-text a literal palette colour

Master's palette spec (#1854) reads `--pc-text` from the controls host as
a literal; this branch had made it `#{palette.$text}`, failing "sets the
timeline label on the dense glass" on the merge ref. The host declares the
literal again, and a spec keeps the palette's `$text` (the focus ring
token for surfaces beside the host) equal to it.

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

* build(epg): declare the ui-styles dependency of the list row's focus ring

The EPG list row now `@use`s `libs/ui/styles/_focus-ring.scss`; Nx cannot
infer a Sass import, so `ui-epg` declares `ui-styles` like the other
consumers of the shared partials.

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

* fix(tooling): a styleless outline shorthand hides focus too

`outline: 2px` or `outline: red` resets the style to `none`, yet the guard
counted either as a visible fallback. A shorthand without a drawn style
(or a `var()` that may carry one) now counts as removing the outline, as
do `initial` and `unset`.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:10:24 +02:00

320 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. */
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. */
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.
*/
export function walkDeclarations(source) {
const text = stripScssComments(source);
const items = [];
const stack = [{ selectors: [], conditional: false }];
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];
if (/^@mixin\b/i.test(value))
stack.push({ selectors: [], conditional: true });
else if (value.startsWith('@'))
stack.push({ selectors: parent.selectors, conditional: true });
else
stack.push({
selectors: resolveSelectors(parent.selectors, value),
conditional: parent.conditional,
});
} else if (char === ';' || char === '}') {
const { value, offset } = statement(i);
if (value) {
const { selectors, conditional } = stack[stack.length - 1];
items.push({
selectors,
conditional,
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.`
);
}
}