mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
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>
This commit is contained in:
1 parent
afd7a5f331
commit
7abf479de9
62 files changed
+1073
-223
No files matched your search
@@ -40,6 +40,8 @@ type Ring = {
|
||||
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[] };
|
||||
@@ -185,6 +187,21 @@ async function installProbe(page: Page): Promise<void> {
|
||||
}
|
||||
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;
|
||||
@@ -259,6 +276,7 @@ async function installProbe(page: Page): Promise<void> {
|
||||
was.offset < 0
|
||||
),
|
||||
clippedBy: was.clippedBy,
|
||||
token: tokenAt(on),
|
||||
});
|
||||
}
|
||||
if (was.shadow && was.shadow !== now.shadow) {
|
||||
@@ -273,6 +291,7 @@ async function installProbe(page: Page): Promise<void> {
|
||||
was.shadow.endsWith(' inset')
|
||||
),
|
||||
clippedBy: null,
|
||||
token: tokenAt(on),
|
||||
});
|
||||
}
|
||||
if (was.border && was.border !== now.border) {
|
||||
@@ -288,6 +307,7 @@ async function installProbe(page: Page): Promise<void> {
|
||||
? null
|
||||
: contrastOf(on, focused, true),
|
||||
clippedBy: null,
|
||||
token: tokenAt(on),
|
||||
});
|
||||
}
|
||||
return rings;
|
||||
@@ -344,6 +364,11 @@ function expectOneVisibleRing(stops: Stop[], context: string): void {
|
||||
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,
|
||||
@@ -353,6 +378,32 @@ function expectOneVisibleRing(stops: Stop[], context: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -538,6 +589,25 @@ test.describe('Keyboard focus ring', () => {
|
||||
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"]')
|
||||
|
||||
Reference in new issue
Block a user