Files
iptvnator/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts
T
4grayandClaude Opus 5.5 7abf479de9 fix(ui): one focus ring colour everywhere via the shared mixin (#1882)
* 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>
2026-10-11 10:40:08 +02:00

639 lines
24 KiB
TypeScript

import type { APIRequestContext, Locator, Page } from '@playwright/test';
import {
addXtreamPortal,
channelItemByTitle,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expect,
launchElectronApp,
openSettings,
openSettingsSection,
openSources,
openWorkspaceSection,
resetMockServers,
saveSettings,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import { fetchXtreamLiveFixture, getXtreamTitle } from './portal-mock-fixtures';
import { applyTheme } from './theme-contrast';
// ---------------------------------------------------------------------------
// Keyboard focus is visible everywhere. The global stylesheet used to strip
// the outline from every input, button and `:focus`; it now draws a fallback
// ring on `:focus-visible`, and a component with an indicator of its own
// keeps it. Every Tab stop below must show exactly one ring, on itself or on
// the ancestor or sibling that draws it for it (a content card, a checkbox,
// a select's form-field outline), in both themes, at 3:1 against what it
// sits on where that is a flat colour, and not cut by an ancestor's
// `overflow: hidden`. A mouse click leaves no ring.
// ---------------------------------------------------------------------------
type Ring = {
kind: 'outline' | 'box-shadow' | 'field-outline';
on: string;
color: string;
/** Null where the ring sits on artwork or a gradient. */
contrast: number | null;
/** The `overflow: hidden` ancestor that cuts the outline, if any. */
clippedBy: string | null;
/** `--app-focus-ring` where the ring is drawn. */
token: string;
};
type Stop = { element: string; label: string; rings: Ring[] };
type Probe = {
capture(): { element: string; label: string };
settle(): Ring[];
clicked(): { element: string; focusVisible: boolean; outline: string };
};
declare global {
interface Window {
__focusRingProbe?: Probe;
}
}
/** Calls one method of the probe installed in the page. */
function callProbe<M extends keyof Probe>(
page: Page,
method: M
): Promise<ReturnType<Probe[M]>> {
return page.evaluate((name) => {
const probe = window.__focusRingProbe;
if (!probe) throw new Error('The focus ring probe is not installed.');
return probe[name]() as ReturnType<Probe[M]>;
}, method);
}
/**
* Installs the in-page probe. `capture` snapshots the rings around the
* focused element; `settle`, run once focus has moved on, reports the rings
* that went away with it, i.e. the ones that focus drew.
*/
async function installProbe(page: Page): Promise<void> {
await page.evaluate(() => {
if (window.__focusRingProbe) return;
const canvas = document.createElement('canvas').getContext('2d', {
willReadFrequently: true,
}) as CanvasRenderingContext2D;
const transparent = (color: string) =>
color === 'transparent' || /,\s*0\)$/.test(color);
const ringOf = (el: Element) => {
const style = getComputedStyle(el);
const outline =
style.outlineStyle !== 'none' &&
parseFloat(style.outlineWidth) > 0 &&
!transparent(style.outlineColor)
? style.outlineColor
: '';
// A focus ring or line drawn as an unblurred shadow: a spread
// ring (`<color> 0 0 0 2px`) or an offset line, inset or not.
const line =
/(rgba?\([^)]*\)) (-?[\d.]+)px (-?[\d.]+)px 0px(?: (-?[\d.]+)px)?( inset)?/.exec(
style.boxShadow
);
const shadow =
line &&
[line[2], line[3], line[4] ?? '0'].some(
(length) => parseFloat(length) !== 0
)
? line[0]
: '';
return {
outline,
offset: parseFloat(style.outlineOffset) || 0,
shadow,
// A Material form field shows focus on its outline's border.
border: el.classList.contains('mdc-notched-outline__leading')
? `${style.borderTopWidth} ${style.borderTopColor}`
: '',
};
};
const name = (el: Element) =>
el.tagName.toLowerCase() +
[...el.classList]
.filter((c) => !c.startsWith('ng-') && !c.startsWith('cdk-'))
.slice(0, 2)
.map((c) => `.${c}`)
.join('');
/** Luminance of `colors` painted in order over white. */
const paint = (colors: string[]) => {
canvas.fillStyle = '#fff';
canvas.fillRect(0, 0, 1, 1);
for (const color of colors) {
canvas.fillStyle = color;
canvas.fillRect(0, 0, 1, 1);
}
const [r, g, b] = Array.from(
canvas.getImageData(0, 0, 1, 1).data.slice(0, 3)
).map((channel) => {
const c = channel / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
/** Background layers from the root down to `el`, or null on art. */
const layersUnder = (el: Element | null) => {
const layers: string[] = [];
for (let node = el; node; node = node.parentElement) {
const style = getComputedStyle(node);
if (style.backgroundImage !== 'none') return null;
layers.unshift(style.backgroundColor);
}
return layers;
};
/**
* The ancestor whose `overflow: hidden` cuts `el`'s outline, looking
* up to the nearest scroll container: past it, whether a ring at the
* edge shows depends on the scroll position, not on the styles.
*/
const clipperOf = (el: Element) => {
const style = getComputedStyle(el);
if (style.outlineStyle === 'none') return null;
const reach = Math.max(
0,
(parseFloat(style.outlineOffset) || 0) +
parseFloat(style.outlineWidth)
);
const box = el.getBoundingClientRect();
for (let node = el.parentElement; node; node = node.parentElement) {
const clip = getComputedStyle(node);
const axes = [clip.overflowX, clip.overflowY];
if (axes.some((axis) => axis === 'auto' || axis === 'scroll')) {
return null;
}
if (
!axes.some((axis) => axis === 'hidden' || axis === 'clip')
) {
continue;
}
const edge = node.getBoundingClientRect();
const inset = (side: string) =>
parseFloat(clip.getPropertyValue(`border-${side}-width`)) -
0.5;
if (
box.left - reach < edge.left + inset('left') ||
box.top - reach < edge.top + inset('top') ||
box.right + reach > edge.right - inset('right') ||
box.bottom + reach > edge.bottom - inset('bottom')
) {
return name(node);
}
}
return null;
};
/** The app ring colour in `el`'s context, as `rgb(…)`. */
const tokenAt = (el: Element) => {
const hex = getComputedStyle(el)
.getPropertyValue('--app-focus-ring')
.trim();
canvas.fillStyle = '#000';
canvas.fillStyle = hex;
const [r, g, b] = Array.from(
(() => {
canvas.fillRect(0, 0, 1, 1);
return canvas.getImageData(0, 0, 1, 1).data;
})()
);
return hex ? `rgb(${r}, ${g}, ${b})` : '';
};
const contrastOf = (on: Element, color: string, inside: boolean) => {
const layers = layersUnder(inside ? on : on.parentElement);
if (!layers) return null;
const back = paint(layers);
const ring = paint([...layers, color]);
return (
(Math.max(back, ring) + 0.05) / (Math.min(back, ring) + 0.05)
);
};
let pending: {
candidates: Element[];
before: (ReturnType<typeof ringOf> & {
clippedBy: string | null;
})[];
} | null = null;
window.__focusRingProbe = {
capture() {
const el = document.activeElement ?? document.body;
const candidates = [el];
for (
let node = el.parentElement, depth = 0;
node && depth < 3;
node = node.parentElement, depth++
) {
candidates.push(node);
}
for (const sibling of el.parentElement?.children ?? []) {
if (sibling !== el) candidates.push(sibling);
}
const fieldOutline = el
.closest('.mat-mdc-form-field')
?.querySelector('.mdc-notched-outline__leading');
if (fieldOutline) candidates.push(fieldOutline);
// Geometry is read while focused: moving on may scroll.
pending = {
candidates,
before: candidates.map((candidate) => ({
...ringOf(candidate),
clippedBy: clipperOf(candidate),
})),
};
return {
element: name(el),
label: (
el.getAttribute('aria-label') ??
el.textContent ??
''
)
.replace(/\s+/g, ' ')
.trim()
.slice(0, 40),
};
},
settle() {
if (!pending) return [];
const { candidates, before } = pending;
pending = null;
return candidates.flatMap((on, index) => {
const was = before[index];
const now = ringOf(on);
const rings: Ring[] = [];
if (was.outline && was.outline !== now.outline) {
rings.push({
kind: 'outline',
on: name(on),
color: was.outline,
contrast: contrastOf(
on,
was.outline,
was.offset < 0
),
clippedBy: was.clippedBy,
token: tokenAt(on),
});
}
if (was.shadow && was.shadow !== now.shadow) {
const color = was.shadow.replace(/\) .*$/, ')');
rings.push({
kind: 'box-shadow',
on: name(on),
color,
contrast: contrastOf(
on,
color,
was.shadow.endsWith(' inset')
),
clippedBy: null,
token: tokenAt(on),
});
}
if (was.border && was.border !== now.border) {
const color = getComputedStyle(on).borderTopColor;
const focused = was.border.replace(/^\S+ /, '');
rings.push({
kind: 'field-outline',
on: name(on),
color: focused,
// Against the field's own fill, inside the line.
contrast:
focused === color
? null
: contrastOf(on, focused, true),
clippedBy: null,
token: tokenAt(on),
});
}
return rings;
});
},
clicked() {
const el = document.activeElement ?? document.body;
return {
element: name(el),
focusVisible: el.matches(':focus-visible'),
outline: ringOf(el).outline,
};
},
};
});
}
/**
* Tabs from `from` through `area`, up to `limit` stops, and reports the
* rings each stop drew. `from` is entered from the keyboard (Tab, then
* Shift+Tab) so it matches `:focus-visible` like the stops after it.
*/
async function tabThrough(
page: Page,
area: Locator,
from: Locator,
limit: number
): Promise<Stop[]> {
await installProbe(page);
await from.focus();
await page.keyboard.press('Tab');
await page.keyboard.press('Shift+Tab');
await expect(from).toBeFocused();
const stops: Stop[] = [];
while (stops.length < limit) {
const inside = await area.evaluate((root) =>
root.contains(document.activeElement)
);
if (!inside) break;
const focused = await callProbe(page, 'capture');
await page.keyboard.press('Tab');
const rings = await callProbe(page, 'settle');
stops.push({ ...focused, rings });
}
return stops;
}
/** Every stop shows one uncut ring with 3:1 against a flat background. */
function expectOneVisibleRing(stops: Stop[], context: string): void {
expect(stops.length, `${context}: Tab stops`).toBeGreaterThan(1);
for (const stop of stops) {
const where = `${context}: ${stop.element} "${stop.label}"`;
expect(stop.rings, where).toHaveLength(1);
const [ring] = stop.rings;
expect(ring.clippedBy, `${where} ring clipped`).toBeNull();
// One ring colour everywhere. A select shows focus on its Material
// form-field outline, which keeps the theme's field colours.
if (ring.kind !== 'field-outline') {
expect(ring.color, `${where} ring colour`).toBe(ring.token);
}
if (ring.contrast !== null) {
expect(
ring.contrast,
`${where} ring contrast`
).toBeGreaterThanOrEqual(3);
}
}
}
/** The focused element's outline, and whether focus counts as visible. */
function ringOfFocused(
page: Page
): Promise<{ focusVisible: boolean; outline: string }> {
return page.evaluate(() => {
const el = document.activeElement ?? document.body;
const style = getComputedStyle(el);
return {
focusVisible: el.matches(':focus-visible'),
outline: `${style.outlineStyle} ${style.outlineWidth} ${style.outlineColor}`,
};
});
}
/** `--app-focus-ring` at the focused element, as `rgb(…)`. */
function focusRingRgb(page: Page): Promise<string> {
return page.evaluate(() => {
const probe = document.createElement('span');
probe.style.color = 'var(--app-focus-ring)';
(document.activeElement ?? document.body).appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
});
}
/** A mouse click focuses `target` without a ring. */
async function expectNoRingAfterClick(
page: Page,
target: Locator,
context: string
): Promise<void> {
await installProbe(page);
await target.click();
const clicked = await callProbe(page, 'clicked');
expect(clicked, `${context}: focus after a click`).toEqual({
element: clicked.element,
focusVisible: false,
outline: '',
});
}
async function openFirstSeries(page: Page): Promise<void> {
await openWorkspaceSection(page, 'Series');
await clickFirstGridListCard(page);
await expect(page.locator('[data-test-id="episodes-heading"]')).toBeVisible(
{ timeout: 20_000 }
);
}
async function openMoviesWithRatingFilter(page: Page): Promise<void> {
await openWorkspaceSection(page, 'Movies');
const view = page.locator('app-category-content-view');
await expect(view.locator('mat-card').first()).toBeVisible({
timeout: 20_000,
});
await view.locator('.refine-action').click();
await page
.getByRole('menuitemradio', { name: /^\s*5\.0 and higher/ })
.click();
await expect(view.locator('.rating-refinement-chip')).toBeVisible();
}
/** Switches the guide to its list view and opens a live channel's EPG. */
async function openLiveEpgList(page: Page, request: APIRequestContext) {
const fixture = await fetchXtreamLiveFixture(request, {
username: defaultXtreamUsername,
password: defaultXtreamPassword,
});
const channel = fixture.items[0];
if (!channel) throw new Error('Expected a live channel in the mock.');
// The mock's live streams redirect to an external demo stream; the
// guide needs none of it, so keep that request pending.
await page.route('https://test-streams.mux.dev/**', () => undefined);
await openSettings(page);
await openSettingsSection(page, 'epg');
await page.getByTestId('epg-view-mode-list').click();
await saveSettings(page);
await openWorkspaceSection(page, 'Live TV');
await clickCategoryByNameExact(page, fixture.categoryName);
await channelItemByTitle(page, getXtreamTitle(channel)).first().click();
const guide = page.locator('app-epg-list-view');
await expect(guide.locator('app-epg-list-view-row').first()).toBeVisible({
timeout: 20_000,
});
return guide;
}
/** Opens the command palette from the keyboard. */
async function openCommandPalette(page: Page): Promise<Locator> {
await page.locator('body').focus();
await page.keyboard.press(
`${process.platform === 'darwin' ? 'Meta' : 'Control'}+K`
);
const palette = page.locator(
'mat-dialog-container app-workspace-command-palette'
);
await expect(palette).toBeVisible();
return palette;
}
test.describe('Keyboard focus ring', () => {
test('@electron @theme every Tab stop shows one visible ring in both themes, a click none', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
const report: Record<string, Stop[]> = {};
try {
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
const walk = async (
context: string,
area: Locator,
from: Locator,
limit: number
) => {
for (const theme of ['light', 'dark'] as const) {
await applyTheme(page, theme);
const stops = await tabThrough(page, area, from, limit);
report[`${context} (${theme})`] = stops;
expectOneVisibleRing(stops, `${context} in ${theme}`);
}
};
// Detail page: the hero's action row, then the season tabs.
await openFirstSeries(page);
const shell = page.locator('app-portal-detail-shell');
await walk(
'detail actions and season tabs',
shell,
shell.locator('app-detail-action-button button').first(),
14
);
expect(
report['detail actions and season tabs (dark)'].map(
(stop) => stop.element
)
).toContain('button.season-tabs__pill');
const pill = shell.locator('.season-tabs__pill').first();
await expectNoRingAfterClick(page, pill, 'season tab');
await expectNoRingAfterClick(
page,
shell.locator('.mat-button-toggle-button').last(),
'episode view toggle'
);
// A catalog grid and its refinement chips. Closing the refine
// menu with a click hands focus back to its trigger by script:
// still pointer focus, so no ring.
await openMoviesWithRatingFilter(page);
await installProbe(page);
expect(await callProbe(page, 'clicked')).toEqual({
element: expect.stringContaining('mat-mdc-button-base'),
focusVisible: false,
outline: '',
});
const view = page.locator('app-category-content-view');
await walk(
'catalog refinements and grid',
view,
view.locator('.rating-refinement-chip'),
6
);
// Sources: the type filters, then the list's row actions.
await openSources(page);
await expect(
page.locator('app-playlist-item').first()
).toBeVisible();
await walk(
'sources list',
page.locator('app-workspace-shell'),
page
.locator('app-workspace-sources-filters-panel button')
.first(),
12
);
expect(
report['sources list (dark)'].map((stop) => stop.label)
).toContain('Check again');
// The live EPG list: its rows are Tab stops (`role="button"`)
// with their own inset ring, then the row's info button.
const guide = await openLiveEpgList(page, request);
await walk(
'epg list',
guide,
guide.locator('app-epg-list-view-row').first(),
4
);
expect(
report['epg list (dark)'].map((stop) => stop.element)
).toContainEqual(expect.stringMatching(/^app-epg-list-view-row\b/));
// Settings: the section navigation, then the first page's
// controls (selects, switches, the theme switcher).
await openSettings(page);
const firstSection = page
.locator('[data-test-id^="settings-section-"]')
.first();
await walk(
'settings',
page.locator('app-workspace-shell'),
firstSection,
24
);
// A search result chosen with Enter focuses its row by script;
// the keypress keeps `:focus-visible`, so the row shows the ring.
await applyTheme(page, 'light');
const search = page.locator(
'app-workspace-shell-header input[type="search"]'
);
await search.fill('subtitles');
await page
.getByTestId('settings-search-result-show-captions')
.focus();
await page.keyboard.press('Enter');
const revealed = page.locator('[data-setting-id="show-captions"]');
await expect(revealed).toBeFocused();
expect(await ringOfFocused(page)).toEqual({
focusVisible: true,
outline: `solid 2px ${await focusRingRgb(page)}`,
});
const toggle = page
.getByTestId('settings-container')
.locator('mat-slide-toggle [role="switch"]')
.first();
await expectNoRingAfterClick(page, toggle, 'settings switch');
await toggle.click();
// The command palette: its search field draws no outline, so the
// row underlines it; then the commands.
const palette = await openCommandPalette(page);
await walk(
'command palette',
palette,
palette.locator('.palette-search input'),
3
);
await page.keyboard.press('Escape');
await expect(palette).toBeHidden();
} finally {
// Every stop and the rings it drew, for a failure on CI.
await test.info().attach('focus-stops.json', {
body: JSON.stringify(report, null, 2),
contentType: 'application/json',
});
await closeElectronApp(app);
}
});
});