Files
iptvnator/tools/nx/check-focus-visible.test.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

288 lines
9.5 KiB
JavaScript

import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
import {
checkFocusVisible,
expandBranches,
findBlanketOutlineRemovals,
hasFocusVisibleFallback,
isBlanketSelector,
removesOutline,
listScannedFiles,
} from './check-focus-visible.mjs';
const rootDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..'
);
const fallback = [
':where(:focus:not(:focus-visible)) {',
' outline: none;',
'}',
':where(:focus-visible) {',
' @include focus-ring.focus-ring-declarations;',
'}',
].join('\n');
test('reports the old blanket rule once per unscoped selector', () => {
const source = [
'input,',
'button,',
'textarea,',
':focus {',
' outline: none;',
'}',
].join('\n');
assert.deepEqual(
findBlanketOutlineRemovals('styles.scss', source).map(
({ line, selector }) => `${line} ${selector}`
),
['5 input', '5 button', '5 textarea', '5 :focus']
);
});
test('reports every way of hiding the outline, nested or not', () => {
const source = [
'*:focus { outline: 0 !important; }',
'button:focus { outline-style: none; }',
'a { outline-width: 0; }',
'[tabindex] :focus { outline-color: transparent; }',
'.dark-theme {',
' :focus { outline: 0px; }',
' @media (min-width: 600px) {',
' & :focus-visible { outline: none; }',
' }',
'}',
'@mixin hide { :focus { outline: none; } }',
].join('\n');
assert.deepEqual(
findBlanketOutlineRemovals('a.scss', source).map(
({ selector }) => selector
),
[
'*:focus',
'button:focus',
'a',
'[tabindex] :focus',
'.dark-theme :focus',
'.dark-theme :focus-visible',
':focus',
]
);
});
test('reads an invisible outline from any part of the shorthand', () => {
for (const hidden of [
'outline: 0 solid transparent',
'outline: 2px solid transparent',
'outline: 1px solid rgba(0, 0, 0, 0)',
'outline: 1px solid rgb(0 0 0 / 0%)',
'outline: thin dotted #0000',
'outline: 0.0em solid red',
'outline-color: transparent',
'outline-width: 0px',
'outline-style: hidden',
// A shorthand without a style resets it to `none`.
'outline: 2px',
'outline: 2px #1d63e0',
'outline: red',
'outline: initial',
'outline-style: unset',
]) {
assert.equal(removesOutline(hidden), true, hidden);
}
for (const visible of [
'outline: 2px solid var(--app-focus-ring)',
// A zero blue channel is not a zero alpha.
'outline: 2px solid rgb(255, 0, 0)',
'outline: 2px solid rgba(0, 0, 0, 0.5)',
'outline: 2px solid #ff000080',
'outline: auto',
'outline: dashed',
'outline: var(--app-focus-outline)',
'outline-offset: 0',
]) {
assert.equal(removesOutline(visible), false, visible);
}
// Both checks use it: a transparent ring hides focus and is no fallback.
const problems = checkFocusVisible([
{
file: 'f.scss',
source: [
':focus { outline: 0 solid transparent; }',
':focus-visible { outline: 2px solid transparent; }',
':focus-visible { outline: 2px; }',
].join('\n'),
},
]);
assert.deepEqual(problems, [
'f.scss:1 `:focus` sets `outline: 0 solid transparent`',
'f.scss:2 `:focus-visible` sets `outline: 2px solid transparent`',
'f.scss:3 `:focus-visible` sets `outline: 2px`',
'No global `:focus-visible` rule draws the fallback outline (`@include focus-ring.focus-ring-declarations`).',
]);
});
test('accepts removals that keep visible focus or target scoped elements', () => {
const source = [
':where(:focus:not(:focus-visible)) { outline: none; }',
':focus:not(:focus-visible) { outline: 0; }',
'.setting-item:focus { outline: none; }',
'input.search-field { outline: none; }',
'button[mat-icon-button]:focus { outline: none; }',
'#player:focus { outline: none; }',
'.panel { &:focus { outline: none; } }',
'::-webkit-scrollbar { outline: none; }',
':focus-visible { outline: 2px solid red; }',
].join('\n');
assert.deepEqual(findBlanketOutlineRemovals('b.scss', source), []);
});
test('expands :is() and :where() so one unscoped branch is still caught', () => {
assert.deepEqual(expandBranches(':is(input, .x):focus'), [
'input:focus',
'.x:focus',
]);
assert.equal(isBlanketSelector(':is(input, .x):focus'), true);
assert.equal(isBlanketSelector(':where(.x, [data-y]):focus'), false);
// A class inside :not() does not scope the element.
assert.equal(isBlanketSelector('button:not(.mat-button)'), true);
});
test('ignores commented-out rules and strings that look like rules', () => {
const source = [
'// :focus { outline: none; }',
'/* button { outline: 0; } */',
".icon { content: '{ outline: none; }'; }",
fallback,
].join('\n');
assert.deepEqual(checkFocusVisible([{ file: 'c.scss', source }]), []);
});
test('requires a :focus-visible fallback that draws an outline', () => {
const missing = checkFocusVisible([
{
file: 'd.scss',
source: ':where(:focus:not(:focus-visible)) { outline: none; }',
},
]);
assert.equal(missing.length, 1);
assert.match(missing[0], /No global `:focus-visible` rule/);
assert.equal(hasFocusVisibleFallback(fallback), true);
assert.equal(
hasFocusVisibleFallback(
':focus-visible { outline: 2px solid var(--ring); }'
),
true
);
// A scoped rule is not the global fallback: not on the focused element,
// not on a container's descendants, not nested in one.
for (const scoped of [
'.card:focus-visible { outline: 2px solid red; }',
'.panel :focus-visible { outline: 2px solid red; }',
'.dark-theme { :focus-visible { outline: 2px solid red; } }',
'.panel { :where(:focus-visible) { @include focus-ring.ring; } }',
]) {
assert.equal(hasFocusVisibleFallback(scoped), false, scoped);
}
// Nor is one that may never reach the page.
for (const conditional of [
'@mixin ring { :focus-visible { outline: 2px solid red; } }',
'@media print { :focus-visible { outline: 2px solid red; } }',
]) {
assert.equal(hasFocusVisibleFallback(conditional), false, conditional);
}
assert.equal(
hasFocusVisibleFallback('*:focus-visible { outline: 2px solid red; }'),
true
);
// Neither is one that removes the outline, which is also reported.
const removed = checkFocusVisible([
{ file: 'e.scss', source: ':focus-visible { outline: none; }' },
]);
assert.equal(removed.length, 2);
assert.match(
removed[0],
/^e\.scss:1 `:focus-visible` sets `outline: none`$/
);
});
test('the fallback may live in another scanned stylesheet', () => {
assert.deepEqual(
checkFocusVisible([
{
file: 'apps/web/src/m3-theme.scss',
source: 'html { color: red; }',
},
{ file: 'apps/web/src/styles.scss', source: fallback },
]),
[]
);
});
test('selects only the tracked top-level web stylesheets', async () => {
const tempDir = await mkdtemp(
path.join(os.tmpdir(), 'focus-visible-guard-')
);
const git = (...args) =>
execFileSync('git', args, { cwd: tempDir, stdio: 'pipe' });
try {
git('init', '-q');
const files = [
'apps/web/src/styles.scss',
'apps/web/src/_settings-theme.scss',
'apps/web/src/app/settings/settings.component.scss',
'apps/web/src/main.ts',
'libs/ui/styles/_focus-ring.scss',
];
for (const file of files) {
await mkdir(path.dirname(path.join(tempDir, file)), {
recursive: true,
});
await writeFile(path.join(tempDir, file), '');
}
git('add', '.');
await writeFile(path.join(tempDir, 'apps/web/src/untracked.scss'), '');
assert.deepEqual(listScannedFiles(tempDir).sort(), [
'apps/web/src/_settings-theme.scss',
'apps/web/src/styles.scss',
]);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
test('the repository global stylesheets pass, and the old rule would not', async () => {
const sources = await Promise.all(
listScannedFiles(rootDir).map(async (file) => ({
file,
source: await readFile(path.join(rootDir, file), 'utf8'),
}))
);
assert.ok(sources.some(({ file }) => file === 'apps/web/src/styles.scss'));
assert.deepEqual(checkFocusVisible(sources), []);
const reverted = sources.map(({ file, source }) =>
file === 'apps/web/src/styles.scss'
? {
file,
source: `${source}\ninput, button, textarea, :focus { outline: none; }\n`,
}
: { file, source }
);
assert.equal(checkFocusVisible(reverted).length, 4);
});