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:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-11 10:40:08 +02:00
1 parent afd7a5f331
commit 7abf479de9
62 files changed
+1073 -223

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
---
type: fix
area: ui
---
Focus rings now share one colour across the app, chosen to stay clearly
visible on every surface in both themes, including selected rows and
highlighted items where some rings used to fade into the background.
+3 -1
View File
@@ -409,7 +409,9 @@ jobs:
- name: Validate Angular Material token overrides
run: pnpm run styles:material-tokens:validate
- name: Validate the global keyboard focus ring
# The global :focus-visible fallback, and one ring colour for
# every component (tools/nx/check-focus-ring-colour.mjs).
- name: Validate the keyboard focus ring
run: pnpm run styles:focus-visible:validate
# The icon font renders an unknown ligature name as plain text.
@@ -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"]')
@@ -1,3 +1,5 @@
@use '../../../../../libs/ui/styles/focus-ring';
// About page: version hero, update status, community links and support.
app-settings-about-section {
display: contents;
@@ -142,8 +144,7 @@ app-settings-about-section {
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
@include focus-ring.focus-ring-declarations($offset: -2px);
}
}
@@ -1,3 +1,5 @@
@use '../../../../../libs/ui/styles/focus-ring';
// EPG page: the offset stepper and the sources list. Shared row and card
// styles come from settings.component.scss (ViewEncapsulation.None).
app-settings-epg-section {
@@ -15,7 +17,7 @@ app-settings-epg-section {
overflow: hidden;
&:focus-within {
border-color: var(--app-selection-color);
border-color: var(--app-focus-ring);
}
input {
@@ -60,8 +62,7 @@ app-settings-epg-section {
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
@include focus-ring.focus-ring-declarations($offset: -2px);
}
}
@@ -1,3 +1,5 @@
@use '../../../../../libs/ui/styles/focus-ring';
:host {
display: block;
}
@@ -29,8 +31,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
@include focus-ring.focus-ring-declarations($offset: -2px);
border-radius: 8px;
}
}
@@ -1,3 +1,5 @@
@use '../../../../../libs/ui/styles/focus-ring';
// Settings page: one content column of titled cards. Every row is a label
// column plus one right-aligned control column, so selects, switches and
// segmented controls line up down the page. Tokens and the Material token
@@ -202,7 +204,9 @@
justify-content: flex-end;
}
// Row revealed from settings search or the command palette.
// Row revealed from settings search or the command palette. Choosing a
// result with Enter focuses the row by script and keeps `:focus-visible`,
// so it shows the app ring like any keyboard focus.
.setting-item[data-setting-id],
.settings-card[data-setting-id],
.settings-foot[data-setting-id] {
@@ -213,9 +217,7 @@
}
&:focus-visible {
outline: 2px solid
color-mix(in srgb, var(--app-selection-color) 60%, transparent);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
border-radius: 12px;
}
}
@@ -378,8 +380,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 1px;
@include focus-ring.focus-ring-declarations($offset: 1px);
}
}
+1 -2
View File
@@ -309,6 +309,5 @@ body.dark-theme {
}
.channel-scroll-focus:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
@include focus-ring.focus-ring-declarations($offset: -2px);
}
+17 -9
View File
@@ -286,8 +286,14 @@ host by its padding and border on the first move.
- **Token.** `--app-focus-ring` is declared per theme in `m3-theme.scss`,
deeper than the selection blue in light, so it keeps 3:1 on every app
surface and selection tint; `apps/web/src/m3-theme.spec.ts` measures it.
- **A ring of your own.** A component that needs a different ring styles its
`:focus-visible` with the mixin rather than a literal colour. Draw it on
- **A ring of your own.** A component that needs the ring elsewhere or at
another offset includes the mixin (`$offset`); a stylesheet a spec loads
as raw CSS uses `var(--app-focus-ring)`. The colour is always the token,
for every focus indicator: `tools/nx/check-focus-ring-colour.mjs`, part of
the same validate script, reads a focus rule's outline, its unblurred ring
or line shadows and its border colours, and rejects any colour other than
the token (also inside `color-mix()`), the player's `--pc-*` palette or a
neutral boundary, except the few exceptions it lists with their reasons. Draw it on
the element that owns `overflow: hidden` (the cards' `card-focus-ring`),
or inside the element (a negative offset) when a clipping parent would cut
it. When an ancestor or a box shadow draws the ring, set `outline: none` on
@@ -296,10 +302,11 @@ host by its padding and border on the first move.
fallback, so the element loses its ring (use `border` or a spread
`box-shadow`, as the selected season tab does).
- **Removing the outline.** A component rule that sets `outline: none` also
outranks the fallback, so it must show focus another way: a `:focus-within`
change on a search field's wrapper (the command palette underlines its
row), a ring drawn on another element, or a highlight on an item inside an
arrow-key composite.
outranks the fallback, so it must show focus another way: a search field's
wrapper takes a token border on `:focus-within`, optionally with a soft
token halo (the command palette underlines its row instead), a ring drawn
on another element, or a highlight on an item inside an arrow-key
composite.
- **Material.** Buttons, switches, button toggles and checkboxes take the
ring over their 12% focus state layer (global rules in `styles.scss`).
Menu items and select options keep Material's highlight, since arrow keys,
@@ -310,9 +317,10 @@ host by its padding and border on the first move.
`_player-palette.scss`.
- **Check.** `keyboard-focus-ring.e2e.ts` (Electron) tabs through the detail
actions and season tabs, a catalog grid with its refinement chips, the
Sources list and Settings in both themes. Each stop must draw exactly one
ring, uncut by an ancestor's `overflow: hidden`, at 3:1 where it sits on a
flat colour, and a click must draw none.
Sources list, the live EPG list, Settings and the command palette in both
themes. Each stop must draw exactly one ring in the token's colour, uncut
by an ancestor's `overflow: hidden`, at 3:1 where it sits on a flat
colour, and a click must draw none.
## Keyboard Scrolling and Channel Focus
@@ -5,6 +5,7 @@
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:playlist-import", "type:feature"],
"implicitDependencies": ["ui-styles"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
@@ -1,3 +1,5 @@
@use '../../../../../../ui/styles/focus-ring';
.apd {
--apd-border: var(--app-widget-header-border);
--apd-heading: var(--app-heading-color);
@@ -123,8 +125,7 @@
}
&:focus-visible {
outline: 2px solid var(--apd-accent);
outline-offset: 1px;
@include focus-ring.focus-ring-declarations($offset: 1px);
}
}
@@ -1,3 +1,5 @@
@use '../../../../../../ui/styles/focus-ring';
:host {
display: block;
margin: 4px 0 12px;
@@ -49,8 +51,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
&__icon {
@@ -187,8 +188,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
}
}
@@ -1,3 +1,5 @@
@use '../../../../../../../ui/styles/focus-ring';
// Rendered only inside the fullscreen channel panel, which is always dark
// (it carries the `dark-theme` context), so the colours are tuned for that
// surface rather than for both themes.
@@ -53,8 +55,7 @@
}
.m3u-fullscreen-channel-list__view:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 1px;
@include focus-ring.focus-ring-declarations($offset: 1px);
}
.m3u-fullscreen-channel-list__view--active,
@@ -1,3 +1,5 @@
@use '../../../../../../ui/styles/focus-ring';
:host {
display: block;
width: 100%;
@@ -279,7 +281,7 @@
}
.search-container:focus-within {
border-color: var(--mat-sys-primary);
border-color: var(--app-focus-ring);
background: var(--mat-sys-surface-container-highest);
}
@@ -367,8 +369,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-border, var(--mat-sys-primary));
outline-offset: 1px;
@include focus-ring.focus-ring-declarations($offset: 1px);
}
}
@@ -5,6 +5,7 @@
"prefix": "lib",
"projectType": "library",
"tags": ["scope:portal", "domain:portal-shared", "type:feature"],
"implicitDependencies": ["ui-styles"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
@@ -1,5 +1,6 @@
@use '@angular/material' as mat;
@use '../../../../shared/ui/src/lib/styles/content-grid' as grid;
@use '../../../../../ui/styles/focus-ring';
:host {
display: block;
@@ -47,7 +48,7 @@
.download-library__artwork {
border-color: color-mix(
in srgb,
var(--mat-sys-primary) 38%,
var(--app-focus-ring) 38%,
var(--app-separator)
);
box-shadow: 0 12px 28px
@@ -238,8 +239,7 @@
button:focus-visible {
border-radius: 6px;
outline: 2px solid var(--mat-sys-primary);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
button:disabled {
@@ -1,3 +1,5 @@
@use '../../../../../ui/styles/focus-ring';
:host {
display: block;
container-type: inline-size;
@@ -284,8 +286,7 @@
}
button:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
button:disabled {
@@ -1,3 +1,5 @@
@use '../../../../../ui/styles/focus-ring';
:host {
display: block;
width: 100%;
@@ -126,8 +128,7 @@ mat-dialog-content {
}
button:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
button:disabled {
@@ -1,3 +1,5 @@
@use '../../../../../ui/styles/focus-ring';
:host {
display: flex;
width: 100%;
@@ -207,8 +209,7 @@
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
&.is-selected {
@@ -1,3 +1,5 @@
@use '../../../../../../ui/styles/focus-ring';
:host {
display: block;
height: 100%;
@@ -98,9 +100,7 @@ button,
}
&:focus-visible {
outline: 2px solid
var(--app-selection-border, var(--mat-sys-primary, currentColor));
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
}
@@ -43,7 +43,7 @@
&:focus-within .recording-library__artwork {
border-color: color-mix(
in srgb,
var(--mat-sys-primary) 55%,
var(--app-focus-ring) 55%,
var(--app-separator, var(--mat-sys-outline-variant))
);
transform: translateY(-2px);
@@ -1,3 +1,5 @@
@use '../../../../../../../ui/styles/focus-ring';
:host {
display: block;
--rail-gap: var(--cover-gap, 16px);
@@ -94,8 +96,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #2f7bff);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
}
@@ -226,8 +227,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 3px;
@include focus-ring.focus-ring-declarations($offset: 3px);
}
&--left {
@@ -0,0 +1,50 @@
@use '../../../../../../../ui/styles/focus-ring';
:host {
display: inline-flex;
flex: 0 1 auto;
min-width: 0;
max-width: 220px;
}
/* Same pill geometry as the EPG toolbar's own icon buttons. */
.open-in-playlist-chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
min-width: 0;
max-width: 100%;
padding: 0 12px 0 10px;
border-radius: 999px;
background: var(--app-widget-header-bg);
border: 1px solid var(--app-separator);
color: color-mix(in srgb, var(--app-on-surface) 82%, transparent);
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
mat-icon {
flex: 0 0 auto;
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover {
background: var(--app-card-hover-bg);
color: var(--app-on-surface);
}
&:focus-visible {
@include focus-ring.focus-ring-declarations;
}
}
.open-in-playlist-chip__name {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@@ -37,57 +37,7 @@ import { TranslateModule } from '@ngx-translate/core';
}}</span>
</button>
`,
styles: `
:host {
display: inline-flex;
flex: 0 1 auto;
min-width: 0;
max-width: 220px;
}
/* Same pill geometry as the EPG toolbar's own icon buttons. */
.open-in-playlist-chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
min-width: 0;
max-width: 100%;
padding: 0 12px 0 10px;
border-radius: 999px;
background: var(--app-widget-header-bg);
border: 1px solid var(--app-separator);
color: color-mix(in srgb, var(--app-on-surface) 82%, transparent);
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
mat-icon {
flex: 0 0 auto;
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover {
background: var(--app-card-hover-bg);
color: var(--app-on-surface);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
}
}
.open-in-playlist-chip__name {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
`,
styleUrl: './open-in-playlist-chip.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OpenInPlaylistChipComponent {
@@ -1,3 +1,5 @@
@use '../../../../../../ui/styles/focus-ring';
// App tokens only: the theme declares no Material system colours. The
// adult-chip red is local, with a value per theme (>= 4.5:1 on its tint).
:host {
@@ -59,7 +61,7 @@
cursor: text;
&:focus-within {
border-color: var(--app-selection-color);
border-color: var(--app-focus-ring);
}
&__icon {
@@ -126,11 +128,10 @@
background-color: var(--app-card-hover-bg);
}
// The global reset strips every button outline; an inset ring keeps
// the focused row visible inside the list's clipped scroll box.
// An inset ring keeps the focused row visible inside the list's
// clipped scroll box.
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
@include focus-ring.focus-ring-declarations($offset: -2px);
}
&:not(:last-child) {
@@ -59,9 +59,9 @@
box-shadow 0.15s ease;
&:focus-within {
border-color: var(--mat-sys-primary);
border-color: var(--app-focus-ring);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
color-mix(in srgb, var(--app-focus-ring) 12%, transparent);
}
}
@@ -61,8 +61,9 @@
box-shadow 0.15s ease;
&:focus-within {
border-color: var(--app-selection-border);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-selection-color) 16%, transparent);
border-color: var(--app-focus-ring);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--app-focus-ring) 16%, transparent);
}
mat-icon {
@@ -1,3 +1,5 @@
@use '../../../../../styles/focus-ring';
:host {
display: block;
width: 100%;
@@ -71,8 +73,7 @@
app-region: no-drag;
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
border-radius: 4px;
}
@@ -49,9 +49,9 @@
box-shadow 0.15s ease;
&:focus-within {
border-color: var(--mat-sys-primary);
border-color: var(--app-focus-ring);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
color-mix(in srgb, var(--app-focus-ring) 12%, transparent);
}
}
@@ -2,6 +2,7 @@
@use '../../../../../styles/portal-sidebar';
@use '../../../../../styles/panel-header' as panel;
@use '../../../../../styles/nav-list';
@use '../../../../../styles/focus-ring';
@use '../../styles/channel-list-layout' as layout;
:host {
@@ -109,9 +110,9 @@
box-shadow 0.15s ease;
&:focus-within {
border-color: var(--mat-sys-primary);
border-color: var(--app-focus-ring);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
color-mix(in srgb, var(--app-focus-ring) 12%, transparent);
}
}
@@ -342,8 +343,7 @@
&:focus-visible {
color: var(--app-heading-color);
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
@include focus-ring.focus-ring-declarations($offset: -2px);
}
mat-icon {
@@ -1,4 +1,5 @@
@use '../styles/skeleton-shimmer' as shimmer;
@use '../../../../styles/focus-ring';
// ============================================================================
// Detail hero — cinematic layout shared by movie and series pages
@@ -124,8 +125,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
}
@@ -350,8 +350,7 @@
}
&:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
border-radius: 2px;
}
}
@@ -1,3 +1,5 @@
@use '../../../../styles/focus-ring';
:host {
display: block;
}
@@ -19,8 +21,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
}
}
@@ -1,3 +1,5 @@
@use '../../../../styles/focus-ring';
:host {
display: flex;
flex-direction: column;
@@ -39,8 +41,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
border-radius: 2px;
}
}
@@ -1,3 +1,5 @@
@use '../../../../styles/focus-ring';
:host {
display: block;
min-width: 0;
@@ -27,8 +29,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
mat-icon {
@@ -1,3 +1,5 @@
@use '../../../../styles/focus-ring';
:host {
display: inline-flex;
align-items: center;
@@ -45,8 +47,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 1px;
@include focus-ring.focus-ring-declarations($offset: 1px);
border-radius: 2px;
}
}
@@ -1,3 +1,5 @@
@use '../../../../styles/focus-ring';
:host {
display: block;
}
@@ -22,8 +24,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
}
@@ -1,3 +1,5 @@
@use '../../../../styles/focus-ring';
:host {
display: block;
}
@@ -91,8 +93,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #2f7bff);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
}
}
@@ -1,3 +1,5 @@
@use '../../../../styles/focus-ring';
:host {
display: block;
}
@@ -102,8 +104,7 @@
}
&:focus-visible {
outline: 2px solid var(--text-primary, #fff);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
}
}
@@ -1,3 +1,5 @@
@use '../../../../styles/focus-ring';
// ============================================================================
// Portal detail shell — two-state (browse ↔ watch) layout + page scroll owner
// ============================================================================
@@ -36,8 +38,7 @@
scrollbar-width: auto;
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
@include focus-ring.focus-ring-declarations($offset: -2px);
}
}
@@ -1,4 +1,5 @@
@use '@angular/material' as mat;
@use '../../../../styles/focus-ring';
// ============================================================================
// Episode item — list row and grid card of the season container
@@ -47,8 +48,7 @@
cursor: pointer;
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
@include focus-ring.focus-ring-declarations($offset: -2px);
}
&[aria-disabled='true'] {
@@ -269,8 +269,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
@include focus-ring.focus-ring-declarations($offset: -2px);
}
&:disabled {
@@ -1,4 +1,5 @@
@use 'source-tag' as tag;
@use '../../../../styles/focus-ring';
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
@@ -151,7 +152,6 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
}
@@ -1,4 +1,5 @@
@use 'source-tag' as tag;
@use '../../../../styles/focus-ring';
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
@@ -133,8 +134,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
}
}
@@ -201,7 +201,6 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
}
@@ -1,3 +1,5 @@
@use '../../../../styles/focus-ring';
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
// The popover is height-capped by its overlay to the space beside the anchor,
@@ -136,7 +138,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
background: var(--app-card-hover-bg);
&:focus-within {
border-color: var(--app-selection-color);
border-color: var(--app-focus-ring);
}
}
@@ -236,8 +238,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
}
@@ -278,8 +279,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
cursor: pointer;
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
}
@@ -1,3 +1,5 @@
@use '../../../../styles/focus-ring';
// Floats above all app content so the window stays controllable while
// dialogs or the multi-EPG overlay are open — same behavior as the macOS
// traffic lights. The host is a manual popover (see component) because CDK
@@ -72,8 +74,7 @@
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: -2px;
@include focus-ring.focus-ring-declarations($offset: -2px);
}
&.close:hover {
@@ -1,4 +1,5 @@
@use '../epg-theme' as *;
@use '../../../../styles/focus-ring';
:host {
display: flex;
@@ -13,7 +14,7 @@
background: var(--app-selection-surface);
}
&.is-focused {
box-shadow: inset 0 0 0 2px $accent-blue;
box-shadow: inset 0 0 0 2px var(--app-focus-ring);
}
}
@@ -43,8 +44,7 @@
color: $text-primary;
&:focus-visible {
outline: 2px solid $accent-blue;
outline-offset: -2px;
@include focus-ring.focus-ring-declarations($offset: -2px);
}
:host(.is-active) &::before {
@@ -184,8 +184,7 @@
}
&.is-focused,
&:focus-visible {
outline: 2px solid $accent-blue;
outline-offset: 1px;
@include focus-ring.focus-ring-declarations($offset: 1px);
z-index: 3;
}
&.is-past {
@@ -1,4 +1,5 @@
@use '../epg-theme' as *;
@use '../../../../styles/focus-ring';
:host {
display: block;
@@ -89,8 +90,7 @@
background: $surface-2;
}
&:focus-visible {
outline: 2px solid $accent-blue;
outline-offset: 1px;
@include focus-ring.focus-ring-declarations($offset: 1px);
}
}
@@ -165,8 +165,7 @@
cursor: default;
}
&:has(input:focus-visible) i {
outline: 2px solid $accent-blue;
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
}
@@ -232,7 +231,7 @@
}
}
&:focus-within {
border-color: $accent-blue;
border-color: var(--app-focus-ring);
}
}
@@ -1,4 +1,5 @@
@use '../epg-theme' as *;
@use '../../../../styles/focus-ring';
:host {
box-sizing: border-box;
@@ -79,8 +80,7 @@
}
&:focus-visible {
outline: 2px solid $accent-blue;
outline-offset: 1px;
@include focus-ring.focus-ring-declarations($offset: 1px);
z-index: 5;
}
@@ -1,4 +1,5 @@
@use '../epg-theme' as *;
@use '../../../../styles/focus-ring';
/* This app has no global border-box reset; the timeline relies on it. */
.epg-timeline,
@@ -190,8 +191,7 @@
}
&:focus-visible {
outline: 2px solid $accent-blue;
outline-offset: 1px;
@include focus-ring.focus-ring-declarations($offset: 1px);
}
}
@@ -1,3 +1,5 @@
@use '../../../../styles/focus-ring';
:host {
display: block;
min-width: 0;
@@ -83,8 +85,7 @@
}
.embedded-mpv-dock-panel__chip:focus-visible {
outline: 2px solid var(--embedded-mpv-accent);
outline-offset: 1px;
@include focus-ring.focus-ring-declarations($offset: 1px);
}
.embedded-mpv-dock-panel__chip--selected {
@@ -1,3 +1,5 @@
@use '../../../../styles/focus-ring';
:host {
display: block;
height: 100%;
@@ -341,8 +343,7 @@
}
.embedded-mpv-player__slider:focus-visible {
outline: 2px solid var(--embedded-mpv-accent);
outline-offset: 4px;
@include focus-ring.focus-ring-declarations($offset: 4px);
border-radius: 4px;
}
@@ -466,8 +467,7 @@
}
.embedded-mpv-player__controls button[mat-icon-button]:focus-visible {
outline: 2px solid var(--embedded-mpv-accent);
outline-offset: -2px;
@include focus-ring.focus-ring-declarations($offset: -2px);
}
// These surfaces cover video, so their foreground and scrim stay paired.
@@ -193,7 +193,7 @@
}
.fullscreen-channel-panel__search:focus-within {
border-color: rgba(255, 255, 255, 0.28);
border-color: color-mix(in srgb, var(--app-focus-ring) 28%, transparent);
background: rgba(255, 255, 255, 0.1);
}
@@ -1,4 +1,5 @@
@use '../player-controls/player-palette' as palette;
@use '../../../../styles/focus-ring';
// Stamped into the fullscreen panel's body, which is its single flex child;
// the list below is the body's only scroll owner. The panel wears the
@@ -128,8 +129,7 @@
}
&:focus-visible {
outline: 2px solid #e7ecf3;
outline-offset: -2px;
@include focus-ring.focus-ring-declarations($offset: -2px);
}
mat-icon {
@@ -163,8 +163,7 @@
}
&:focus-visible {
outline: 2px solid #e7ecf3;
outline-offset: -2px;
@include focus-ring.focus-ring-declarations($offset: -2px);
}
}
+5 -5
View File
@@ -1,11 +1,11 @@
// The app's keyboard focus ring. `apps/web/src/styles.scss` draws it on
// every `:focus-visible` element that sets no outline of its own; components
// include it where the ring belongs on another element (the card that owns
// `overflow: hidden`, for one). `--app-focus-ring` is declared per theme in
// `apps/web/src/m3-theme.scss`; surfaces over video point it at the
// player's text colour (`_player-palette.scss`).
// `overflow: hidden`, for one). `--app-focus-ring` is declared on `html` for
// both themes in `apps/web/src/m3-theme.scss`, so it needs no fallback;
// surfaces over video point it at the player's text colour
// (`_player-palette.scss`).
@mixin focus-ring-declarations($offset: 2px) {
outline: 2px solid
var(--app-focus-ring, var(--app-selection-color, #2f7bff));
outline: 2px solid var(--app-focus-ring);
outline-offset: $offset;
}
@@ -9,6 +9,7 @@
// reverse, with the same geometry.
@use '../../../../../../ui/styles/detail-view-actions' as detail-actions;
@use '../../../../../../ui/styles/focus-ring';
@use '../../../../../../ui/styles/skeleton-shimmer' as shimmer;
// The host renders nothing when there is no slide and no skeleton; with
@@ -76,8 +77,7 @@
color: var(--hero-heading);
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: -2px;
@include focus-ring.focus-ring-declarations($offset: -2px);
}
}
@@ -453,8 +453,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
&:active {
@@ -542,8 +541,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 1px;
@include focus-ring.focus-ring-declarations($offset: 1px);
}
}
@@ -595,8 +593,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: -2px;
@include focus-ring.focus-ring-declarations($offset: -2px);
border-radius: 6px;
}
@@ -1,4 +1,5 @@
@use '@angular/material' as mat;
@use '../../../../../../ui/styles/focus-ring';
// Everything that sets the rail's height comes from the geometry partial,
// which the loading skeleton (`lib-dashboard-rail-skeleton`) shares.
@use 'dashboard-rail-geometry' as geometry;
@@ -65,8 +66,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #2f7bff);
outline-offset: 2px;
@include focus-ring.focus-ring-declarations;
}
}
@@ -168,8 +168,7 @@
border-radius: inherit;
&:focus-visible {
outline: 2px solid var(--app-selection-color, #2f7bff);
outline-offset: 3px;
@include focus-ring.focus-ring-declarations($offset: 3px);
}
}
@@ -459,8 +458,7 @@
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 3px;
@include focus-ring.focus-ring-declarations($offset: 3px);
}
&--left {
@@ -523,8 +521,7 @@
border-radius: inherit;
&:focus-visible {
outline: 2px solid var(--app-selection-color, #2f7bff);
outline-offset: 3px;
@include focus-ring.focus-ring-declarations($offset: 3px);
}
}
@@ -106,9 +106,9 @@
box-shadow 0.15s ease;
&:focus-within {
border-color: var(--mat-sys-primary);
border-color: var(--app-focus-ring);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
color-mix(in srgb, var(--app-focus-ring) 12%, transparent);
}
}
@@ -1,3 +1,5 @@
@use '../../../../../../ui/styles/focus-ring';
// Height-capped by the overlay to the space below the anchor: the embedded
// panel's own list scrolls, header and footer stay put.
:host {
@@ -55,7 +57,6 @@
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: -2px;
@include focus-ring.focus-ring-declarations($offset: -2px);
}
}
@@ -1,3 +1,5 @@
@use '../../../../../../../../ui/styles/focus-ring';
:host {
display: block;
min-width: 0;
@@ -58,9 +60,9 @@
app-region: no-drag;
&:focus-within {
border-color: var(--mat-sys-primary);
border-color: var(--app-focus-ring);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
color-mix(in srgb, var(--app-focus-ring) 12%, transparent);
}
mat-icon {
@@ -182,8 +184,7 @@
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: 1px;
@include focus-ring.focus-ring-declarations($offset: 1px);
}
}
}
+2 -2
View File
@@ -50,8 +50,8 @@
"styles:material-tokens:test": "node --test tools/nx/check-material-token-overrides.test.mjs",
"styles:material-tokens:check": "node tools/nx/check-material-token-overrides.mjs",
"styles:material-tokens:validate": "pnpm run styles:material-tokens:test && pnpm run styles:material-tokens:check",
"styles:focus-visible:test": "node --test tools/nx/check-focus-visible.test.mjs",
"styles:focus-visible:check": "node tools/nx/check-focus-visible.mjs",
"styles:focus-visible:test": "node --test tools/nx/check-focus-visible.test.mjs tools/nx/check-focus-ring-colour.test.mjs",
"styles:focus-visible:check": "node tools/nx/check-focus-visible.mjs && node tools/nx/check-focus-ring-colour.mjs",
"styles:focus-visible:validate": "pnpm run styles:focus-visible:test && pnpm run styles:focus-visible:check",
"styles:icon-ligatures:test": "node --test tools/nx/check-icon-ligatures.test.mjs",
"styles:icon-ligatures:check": "node tools/nx/check-icon-ligatures.mjs",
+339
View File
@@ -0,0 +1,339 @@
import { execFileSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
removesOutline,
splitTopLevel,
walkDeclarations,
withoutNegations,
} from './check-focus-visible.mjs';
/**
* Every keyboard focus indicator is drawn in the app ring colour: the
* `focus-ring-declarations` mixin from `libs/ui/styles/_focus-ring.scss`, or
* `var(--app-focus-ring)` (also inside `color-mix()`, where every other
* colour mixed in must be neutral). Indicators drawn in
* their own colour drifted from it: the selection blue many components used
* falls to 2.6:1 on the stronger selection tint, where the token keeps 3:1
* on every surface. A focus rule's outline, its ring- or line-shaped shadows
* (unblurred) and its border colours are checked; a neutral boundary and a
* blurred lift shadow are not indicators. Surfaces over video keep the
* player's `--pc-*` palette; the few deliberate exceptions are listed below.
*/
/** Every app and library stylesheet. */
const SCANNED_PATHSPECS = [
':(glob)apps/web/src/**/*.scss',
':(glob)libs/**/*.scss',
];
/** A rule that styles focus. */
const FOCUS_SELECTOR =
/:focus(?:-visible|-within)?\b|\.is-focused\b|\.cdk-keyboard-focused\b/i;
/** The custom property a `var()` reads (its fallback aside), or null. */
function propertyRead(colour) {
return /^var\(\s*(--[\w-]+)\s*[,)]/i.exec(colour)?.[1] ?? null;
}
/**
* The app ring token, or the player's own palette over video, by exact
* name: `--app-focus-ring-other` is not the token. A fallback is never
* drawn, since these tokens are always declared, so it is not checked.
*/
function isAppRing(colour) {
const property = propertyRead(colour);
return property === '--app-focus-ring' || /^--pc-[\w-]+$/.test(property);
}
/** Boundary tokens that do not signal focus. */
const NEUTRAL_TOKENS = new Set([
'--app-separator',
'--app-widget-border',
'--app-rail-border',
'--app-search-border',
]);
/** A boundary colour that does not signal focus. */
function isNeutral(colour) {
return (
/^(?:transparent|none)$/i.test(colour) ||
NEUTRAL_TOKENS.has(propertyRead(colour))
);
}
/** Text colour: fine for a border, a colour of its own for a ring. */
const TEXT_COLOUR = /^(?:currentcolor|inherit)$/i;
/** A literal length in any CSS unit (or a unitless zero/number). */
const LENGTH =
/^-?(?:\d+|\d*\.\d+)(?:px|em|rem|pt|pc|in|cm|mm|q|ch|ex|ic|lh|rlh|cap|vw|vh|vi|vb|vmin|vmax|[sld]v(?:w|h|i|b|min|max)|cq(?:w|h|i|b|min|max))?$/i;
const WIDTH_KEYWORD = /^(?:thin|medium|thick)$/i;
const LINE_STYLE =
/^(?:auto|none|hidden|solid|dashed|dotted|double|groove|ridge|inset|outset)$/i;
/** The plain colours of a colour expression: each `color-mix()` argument. */
export function colourAtoms(expression) {
return splitTopLevel(expression, /\s/).flatMap((token) => {
const mix = /^color-mix\((.*)\)$/is.exec(token);
if (!mix) return [token];
// The first argument is the colour space; each other one is a
// colour with an optional percentage on either side.
return splitTopLevel(mix[1], /,/)
.slice(1)
.flatMap((argument) =>
colourAtoms(
argument.replace(/(?:^|\s)-?[\d.]+%(?=\s|$)/g, ' ').trim()
)
);
});
}
/** A length computed by a math function: `calc(1px + 1px)`, `max(…)`. */
const LENGTH_FUNCTION = /^(?:calc|min|max|clamp)\(/i;
/** Tokens of a value that may name colours: not lengths, widths or styles. */
function colourTokens(value) {
return splitTopLevel(value, /\s/).filter(
(token) =>
!LENGTH.test(token) &&
!LENGTH_FUNCTION.test(token) &&
!WIDTH_KEYWORD.test(token) &&
!LINE_STYLE.test(token)
);
}
/**
* The colour of a shorthand with one colour slot (an outline, a border, a
* shadow), where a `var()` may hold a width or a style instead. A literal
* colour is the colour; without one, an accepted token is (the others are
* widths); otherwise every candidate is reported, since none is proven.
*/
function shorthandColour(tokens) {
const literal = tokens.filter((token) => !/^var\(/i.test(token));
if (literal.length) return literal;
const accepted = tokens.filter(
(token) => isAppRing(token) || isNeutral(token)
);
return accepted.length ? accepted : tokens;
}
/**
* The plain colours a focus rule's declaration draws its indicator with, or
* an empty list when it draws none: an outline, the shadows that are rings
* or lines (no blur, with a spread or an offset), and border colours. An
* outline or shadow without a colour is drawn in `currentcolor`.
*/
export function indicatorColours(declaration) {
const match = /^([a-z-]+)\s*:\s*(.+?)\s*(?:!important)?$/i.exec(
declaration
);
if (!match) return [];
const property = match[1].toLowerCase();
const value = match[2];
if (/^outline(?:-color)?$/.test(property)) {
if (removesOutline(declaration)) return [];
const tokens = colourTokens(value);
const colours =
property === 'outline' ? shorthandColour(tokens) : tokens;
return (colours.length ? colours : ['currentcolor']).flatMap(
colourAtoms
);
}
if (property === 'box-shadow') {
return splitTopLevel(value, /,/).flatMap((shadow) => {
const tokens = splitTopLevel(shadow, /\s/).filter(
(token) => token.toLowerCase() !== 'inset'
);
const isLength = (token) =>
LENGTH.test(token) || LENGTH_FUNCTION.test(token);
// Lengths by type, not place: the colour comes first or last,
// never between the lengths, and a missing blur is zero. A
// `var()` may be a length or the colour, so it counts as a
// possible length. Not a ring only when provably so: the first
// three possible lengths are literal with a non-zero blur (a
// lift shadow), or every length is a literal zero and so are the
// first four possible lengths (or there is no `var()`).
const literal = (token) => LENGTH.test(token);
const zero = (token) => literal(token) && parseFloat(token) === 0;
const lengths = tokens.filter(isLength);
const possible = tokens.filter(
(token) => isLength(token) || /^var\(/i.test(token)
);
const blurred =
possible.length >= 3 &&
possible.slice(0, 3).every(literal) &&
parseFloat(possible[2]) !== 0;
const flat =
lengths.every(zero) &&
(possible.length === lengths.length ||
possible.slice(0, 4).every(zero));
if (blurred || flat) return [];
const colours = shorthandColour(
tokens.filter((token) => !isLength(token))
);
return (colours.length ? colours : ['currentcolor']).flatMap(
colourAtoms
);
});
}
// Every border that carries a colour: the shorthand, the physical and
// logical sides (`border-inline-start`, `border-block-end-color`…).
if (
/^border(?:-(?:top|right|bottom|left|(?:block|inline)(?:-(?:start|end))?))?(?:-color)?$/.test(
property
)
) {
// `border-color` lists a colour per side; the other borders have one.
const tokens = colourTokens(value);
const colours = /-color$/.test(property)
? tokens
: shorthandColour(tokens);
return colours.flatMap(colourAtoms);
}
return [];
}
/** Rings that deliberately differ, matched by file and value. */
export const RING_EXCEPTIONS = [
{
file: 'libs/ui/playback/src/lib/player-controls/player-controls.component.scss',
value: '#ffffff',
reason: 'the play button rings white around its own blue fill',
},
{
file: 'libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss',
value: '#ffb24c',
reason: "the diagnostic's amber accent on its near-black scrim (a spec loads this stylesheet as raw CSS)",
},
];
/** The exception that allows this exact colour in this file, if any. */
function exceptionFor(file, colour) {
return RING_EXCEPTIONS.find(
(exception) =>
exception.file === file &&
exception.value.toLowerCase() === colour.toLowerCase()
);
}
/**
* Focus indicators in one stylesheet, each with the colours it draws that
* are not the app ring, the player palette or a neutral boundary, before
* exceptions apply.
*/
function offTokenIndicators(file, source) {
return walkDeclarations(source).flatMap(
({ selectors, declaration, line, mixin }) => {
// A focus mixin's body (the shared `focus-ring-declarations`)
// draws rings wherever it is included.
// `:hover:not(:focus-visible)` excludes focus: only a focus
// condition outside `:not()` makes a focus rule.
const focus =
selectors.find((selector) =>
FOCUS_SELECTOR.test(withoutNegations(selector))
) ?? (mixin && /focus/i.test(mixin) ? `@mixin ${mixin}` : null);
if (!focus) return [];
const border = /^border/i.test(declaration);
const colours = indicatorColours(declaration).filter(
(colour) =>
!isAppRing(colour) &&
!isNeutral(colour) &&
!(border && TEXT_COLOUR.test(colour))
);
return colours.length
? [{ file, line, selector: focus, declaration, colours }]
: [];
}
);
}
/**
* Focus indicators in one stylesheet that bypass the app ring colour. An
* exception excuses its own colour only: another colour in the same
* declaration (a second shadow, a mix) is still reported.
*/
export function findOffTokenRings(file, source) {
return offTokenIndicators(file, source)
.filter(({ colours }) =>
colours.some((colour) => !exceptionFor(file, colour))
)
.map(({ colours, ...indicator }) => indicator);
}
/**
* Exceptions that no longer excuse any off-token focus colour, so the list
* cannot rot: the same value elsewhere (a text colour) does not count.
*/
export function findUnusedExceptions(sources) {
const used = new Set(
sources.flatMap(({ file, source }) =>
offTokenIndicators(file, source)
.flatMap(({ colours }) =>
colours.map((colour) => exceptionFor(file, colour))
)
.filter(Boolean)
)
);
return RING_EXCEPTIONS.filter((exception) => !used.has(exception));
}
/** Tracked app and library 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',
maxBuffer: 32 * 1024 * 1024,
})
.trim()
.split('\n')
.filter(Boolean);
}
/** Every problem across the stylesheets, as printable lines. */
export function checkFocusRingColour(sources) {
return [
...sources.flatMap(({ file, source }) =>
findOffTokenRings(file, source).map(
({ file: at, line, selector, declaration }) =>
`${at}:${line} \`${selector}\` sets \`${declaration}\``
)
),
...findUnusedExceptions(sources).map(
({ file, value }) =>
`${file}: the exception for \`${value}\` matches nothing; remove it from RING_EXCEPTIONS`
),
];
}
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 = checkFocusRingColour(sources);
if (problems.length > 0) {
console.error(
'Focus rings bypass the app ring. Use `@include focus-ring.focus-ring-declarations` (libs/ui/styles/_focus-ring.scss) or `var(--app-focus-ring)`:'
);
for (const problem of problems) console.error(`- ${problem}`);
process.exitCode = 1;
} else {
console.log(
`Checked ${files.length} stylesheets; every focus ring uses the app ring.`
);
}
}
+406
View File
@@ -0,0 +1,406 @@
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 {
RING_EXCEPTIONS,
checkFocusRingColour,
colourAtoms,
findOffTokenRings,
findUnusedExceptions,
indicatorColours,
listScannedFiles,
} from './check-focus-ring-colour.mjs';
const rootDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..'
);
test('reports focus rings drawn in their own colour, nested or in a mixin', () => {
const source = [
'.chip {',
' &:focus-visible {',
' outline: 2px solid var(--app-selection-color);',
' outline-offset: 2px;',
' }',
'}',
'.row:focus-within { outline-color: #2f7bff; }',
'.cell.is-focused { outline: 2px solid $accent-blue; }',
'@mixin ring { &:focus-visible { outline: 1px dashed red; } }',
].join('\n');
assert.deepEqual(
findOffTokenRings('a.scss', source).map(
({ line, selector, declaration }) =>
`${line} ${selector} | ${declaration}`
),
[
'3 .chip:focus-visible | outline: 2px solid var(--app-selection-color)',
'7 .row:focus-within | outline-color: #2f7bff',
'8 .cell.is-focused | outline: 2px solid $accent-blue',
'9 &:focus-visible | outline: 1px dashed red',
]
);
});
test('accepts the app ring, the player palette, removals and decorative outlines', () => {
const source = [
'.a:focus-visible { @include focus-ring.focus-ring-declarations; }',
'.b:focus-visible { outline: 2px solid var(--app-focus-ring); }',
// A stylesheet a spec loads as raw CSS uses the token directly.
'.c:focus-visible { outline: 2px solid var(--app-focus-ring, #1d63e0); }',
// Over video the overlay keeps its own palette.
':host :is(button:focus-visible) { outline: 2px solid var(--pc-text); }',
// Hiding the outline is the other guard's business.
'.d:focus { outline: none; }',
'.e:focus-visible { outline: 0 solid transparent; }',
// Not a focus rule: a decorative outline.
'.panel { outline: 1px solid rgba(255, 255, 255, 0.04); }',
].join('\n');
assert.deepEqual(findOffTokenRings('b.scss', source), []);
});
test('reads ring-shaped shadows and border colours as focus indicators', () => {
const source = [
'.search:focus-within {',
' border-color: var(--mat-sys-primary);',
' box-shadow: 0 0 0 3px',
' color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);',
'}',
'.cell.is-focused { box-shadow: inset 0 0 0 2px $accent-blue; }',
'.field:focus-within { border: 1px solid #2f7bff; }',
'.tab:focus-visible { box-shadow: inset 0 -2px 0 var(--app-selection-color); }',
].join('\n');
assert.deepEqual(
findOffTokenRings('c.scss', source).map(({ line }) => line),
[2, 3, 6, 7, 8]
);
});
test('leaves neutral boundaries, lift shadows and token colours alone', () => {
const source = [
'.row:focus-within { border-color: var(--app-separator); }',
'.row:focus-within { border-color: transparent; border-radius: 8px; }',
'.card:focus-within { box-shadow: 0 12px 28px rgba(0, 0, 0, 0.14); }',
'.search:focus-within {',
' border-color: var(--app-focus-ring);',
' box-shadow: 0 0 0 3px',
' color-mix(in srgb, var(--app-focus-ring) 12%, transparent);',
'}',
':host(:focus-visible) {',
' box-shadow: 0 0 0 2px var(--pc-text), 0 12px 32px rgba(0, 0, 0, 0.45);',
'}',
].join('\n');
assert.deepEqual(findOffTokenRings('d.scss', source), []);
// The blurred shadow in a mixed list is not an indicator.
assert.deepEqual(
indicatorColours(
'box-shadow: 0 0 0 2px var(--pc-text), 0 12px 32px rgba(0, 0, 0, 0.45)'
),
['var(--pc-text)']
);
});
test('checks every colour on its own, inside color-mix() too', () => {
assert.deepEqual(
colourAtoms(
'var(--app-focus-ring) color-mix(in srgb, red 40%, var(--app-separator))'
),
['var(--app-focus-ring)', 'red', 'var(--app-separator)']
);
const source = [
// Two sides in the token, two in red.
'.a:focus-visible { border-color: var(--app-focus-ring) red; }',
'.b:focus-within {',
' box-shadow: 0 0 0 3px',
' color-mix(in srgb, var(--app-focus-ring) 40%, red);',
'}',
// No colour: the ring is drawn in the text colour.
'.c:focus-visible { outline: 2px solid; }',
'.d:focus-visible { box-shadow: 0 0 0 2px; }',
// The token beside a neutral boundary, or mixed with one.
'.e:focus-within { border-color: var(--app-focus-ring) var(--app-separator); }',
'.f:focus-within { border-color: color-mix(in srgb, var(--app-focus-ring) 38%, var(--app-separator)); }',
// A border may keep the text colour.
'.g:focus-within { border-color: currentColor; }',
].join('\n');
assert.deepEqual(
findOffTokenRings('e.scss', source).map(({ line }) => line),
[1, 3, 6, 7]
);
});
test('counts an exception as used only where it excuses a focus indicator', () => {
const diagnostic = RING_EXCEPTIONS.find(({ value }) => value === '#ffb24c');
const others = RING_EXCEPTIONS.filter(
(exception) => exception !== diagnostic
).map(({ file, value }) => ({
file,
source: `.x:focus-visible { outline: 2px solid ${value}; }`,
}));
// The amber stays as a text colour after its ring moved to the token.
const textOnly = {
file: diagnostic.file,
source: [
'.label { color: #ffb24c; }',
'.button:focus-visible { outline: 2px solid var(--app-focus-ring); }',
].join('\n'),
};
assert.deepEqual(findUnusedExceptions([...others, textOnly]), [diagnostic]);
assert.deepEqual(
findUnusedExceptions([
...others,
{
file: diagnostic.file,
source: '.button:focus-visible { outline: 2px solid #ffb24c; }',
},
]),
[]
);
});
test('accepts a token only by its exact name', () => {
const source = [
'.a:focus-visible { outline: 2px solid var(--app-focus-ring-other, red); }',
'.b:focus-within { border-color: var(--app-separator-strong); }',
'.c:focus-visible { outline: 2px solid var(--app-focus-ring); }',
'.d:focus-visible { outline: 2px solid var( --app-focus-ring , #1d63e0); }',
'.e:focus-visible { outline: 2px solid var(--pc-text, #e7ecf3); }',
'.f:focus-within { border-color: var(--app-separator, var(--mat-sys-outline-variant)); }',
].join('\n');
assert.deepEqual(
findOffTokenRings('f.scss', source).map(({ line }) => line),
[1, 2]
);
});
test('checks the body of a focus mixin, the shared ring included', () => {
const source = [
'@mixin focus-ring-declarations($offset: 2px) {',
' outline: 2px solid red;',
' outline-offset: $offset;',
'}',
// Not a focus mixin: a decorative outline.
'@mixin panel-frame { outline: 1px solid rgba(255, 255, 255, 0.04); }',
].join('\n');
assert.deepEqual(
findOffTokenRings('libs/ui/styles/_focus-ring.scss', source).map(
({ line, selector }) => `${line} ${selector}`
),
['2 @mixin focus-ring-declarations']
);
});
test('reads logical border sides as well as physical ones', () => {
const source = [
'.a:focus-visible { border-inline-start: 2px solid red; }',
'.b:focus-visible { border-block-color: #2f7bff; }',
'.c:focus-visible { border-inline-end-color: var(--mat-sys-primary); }',
'.d:focus-visible { border-block-start: 2px solid var(--app-focus-ring); }',
// Not a colour: the radius of a logical corner.
'.e:focus-visible { border-start-start-radius: 4px; }',
].join('\n');
assert.deepEqual(
findOffTokenRings('g.scss', source).map(({ line }) => line),
[1, 2, 3]
);
});
test('tells a width given through var() or calc() from the colour', () => {
const source = [
'.a:focus-visible { outline: var(--ring-width) solid var(--app-focus-ring); }',
'.b:focus-visible { outline: calc(1px + 1px) solid var(--app-focus-ring); }',
'.c:focus-within { border: var(--ring-width) solid var(--app-focus-ring); }',
'.d:focus-visible { box-shadow: 0 0 0 var(--ring-width) var(--app-focus-ring); }',
// The colour is still checked when the width is a variable.
'.e:focus-visible { outline: var(--ring-width) solid red; }',
'.f:focus-visible { outline: var(--ring-width) solid var(--mat-sys-primary); }',
'.g:focus-visible { outline: max(2px, 0.1em) solid #2f7bff; }',
].join('\n');
assert.deepEqual(
findOffTokenRings('h.scss', source).map(({ line }) => line),
[5, 6, 7]
);
});
test('reads a shadow ring whose spread is computed', () => {
const source = [
'.a:focus-visible { box-shadow: 0 0 0 calc(1px + 1px) red; }',
'.b:focus-visible { box-shadow: 0 0 0 var(--ring-width) red; }',
'.c:focus-visible { box-shadow: 0 0 0 var(--ring-width) var(--mat-sys-primary); }',
// Checked and accepted, not skipped as flat.
'.d:focus-visible { box-shadow: 0 0 0 var(--ring-width) var(--app-focus-ring); }',
// Provably not a ring: flat, or a lift shadow with a literal blur.
'.e:focus-visible { box-shadow: 0 0 0 0 red; }',
'.f:focus-within { box-shadow: 0 12px 28px red; }',
].join('\n');
assert.deepEqual(
findOffTokenRings('i.scss', source).map(({ line }) => line),
[1, 2, 3]
);
assert.deepEqual(
indicatorColours(
'box-shadow: 0 0 0 var(--ring-width) var(--app-focus-ring)'
),
['var(--app-focus-ring)']
);
});
test('reads shadow lengths by type, with the colour first or the blur left out', () => {
const source = [
// No blur and no colour: a line in the text colour.
'.a:focus-visible { box-shadow: 0 2px; }',
// The colour first, then a ring.
'.b:focus-visible { box-shadow: red 0 0 0 2px; }',
// The colour first, then a lift shadow with a literal blur.
'.c:focus-within { box-shadow: rgba(0, 0, 0, 0.5) 0 12px 28px; }',
'.d:focus-visible { box-shadow: var(--app-focus-ring) 0 0 0 2px; }',
// A lift shadow whose colour is a variable.
'.e:focus-within { box-shadow: 0 12px 28px var(--shadow-colour); }',
// Four literal zeros: the variable can only be the colour.
'.f:focus-visible { box-shadow: 0 0 0 0 var(--mat-sys-primary); }',
].join('\n');
assert.deepEqual(
findOffTokenRings('j.scss', source).map(({ line }) => line),
[1, 2]
);
});
test('ignores a focus condition that a rule negates', () => {
const source = [
// A hover style that excludes keyboard focus.
'.a:hover:not(:focus-visible) { border-color: red; }',
// A negation elsewhere does not hide a real focus condition.
'.b:not(.disabled):focus-visible { border-color: red; }',
// A focus condition inside :has() still makes a focus rule.
'.c:has(> button:focus-visible) { outline: 2px solid red; }',
].join('\n');
assert.deepEqual(
findOffTokenRings('k.scss', source).map(({ line }) => line),
[2, 3]
);
});
test('reads a width in any CSS length unit', () => {
const source = [
'.a:focus-visible { outline: 1pt solid var(--app-focus-ring); }',
'.b:focus-visible { outline: 0.1ch solid var(--app-focus-ring); }',
'.c:focus-within { border: 0.5mm solid var(--app-focus-ring); }',
'.d:focus-visible { box-shadow: 0 0 0 0.2vmin var(--app-focus-ring); }',
'.e:focus-visible { outline: 2pt solid red; }',
].join('\n');
assert.deepEqual(
findOffTokenRings('l.scss', source).map(({ line }) => line),
[5]
);
});
test('lets a listed exception through only in its own file', () => {
const [exception] = RING_EXCEPTIONS;
const source = `.x:focus-visible { outline: 2px solid ${exception.value}; }`;
assert.deepEqual(findOffTokenRings(exception.file, source), []);
assert.equal(findOffTokenRings('elsewhere.scss', source).length, 1);
});
test('excuses only the excepted colour, not the rest of the declaration', () => {
const player = RING_EXCEPTIONS.find(({ value }) => value === '#ffffff');
const source = [
'.a:focus-visible { outline: 2px solid #ffffff; }',
'.b:focus-visible { outline: 2px solid #FFFFFF; }',
// A second ring in another colour beside the excepted one.
'.c:focus-visible { box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px red; }',
// The excepted colour mixed with another one.
'.d:focus-visible { outline: 2px solid color-mix(in srgb, #ffffff 50%, red); }',
].join('\n');
assert.deepEqual(
findOffTokenRings(player.file, source).map(({ line }) => line),
[3, 4]
);
});
test('reports an exception that no longer matches anything', () => {
const sources = RING_EXCEPTIONS.slice(1).map(({ file, value }) => ({
file,
source: `.x:focus-visible { outline: 2px solid ${value}; }`,
}));
assert.deepEqual(
findUnusedExceptions(sources).map(({ file }) => file),
[RING_EXCEPTIONS[0].file]
);
assert.match(
checkFocusRingColour(sources).at(-1),
/matches nothing; remove it from RING_EXCEPTIONS$/
);
});
test('selects every tracked app and library stylesheet', async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'focus-ring-colour-'));
const git = (...args) =>
execFileSync('git', args, { cwd: tempDir, stdio: 'pipe' });
try {
git('init', '-q');
const files = [
'apps/web/src/styles.scss',
'apps/web/src/app/settings/settings.component.scss',
'apps/website/src/styles/global.scss',
'libs/ui/components/src/lib/deep/row.component.scss',
'libs/ui/components/src/lib/deep/row.component.ts',
];
for (const file of files) {
await mkdir(path.dirname(path.join(tempDir, file)), {
recursive: true,
});
await writeFile(path.join(tempDir, file), '');
}
git('add', '.');
assert.deepEqual(listScannedFiles(tempDir).sort(), [
'apps/web/src/app/settings/settings.component.scss',
'apps/web/src/styles.scss',
'libs/ui/components/src/lib/deep/row.component.scss',
]);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
test('the repository stylesheets pass, and one drifted ring would not', async () => {
const sources = await Promise.all(
listScannedFiles(rootDir).map(async (file) => ({
file,
source: await readFile(path.join(rootDir, file), 'utf8'),
}))
);
assert.deepEqual(checkFocusRingColour(sources), []);
const drifted = [
...sources,
{
file: 'libs/ui/components/src/lib/new/new.component.scss',
source: '.new:focus-visible { outline: 2px solid var(--app-selection-color); }',
},
];
assert.equal(checkFocusRingColour(drifted).length, 1);
});
+12 -9
View File
@@ -33,7 +33,7 @@ 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) {
export function splitTopLevel(text, separator) {
const parts = [];
let depth = 0;
let start = 0;
@@ -120,7 +120,7 @@ function subjectOf(selector) {
}
/** A compound without its `:not(...)` arguments. */
function withoutNegations(compound) {
export function withoutNegations(compound) {
let out = '';
for (let i = 0; i < compound.length; i += 1) {
if (/^:not\(/i.test(compound.slice(i))) {
@@ -173,12 +173,13 @@ function lineAt(source, index) {
* 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.
* `@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 }];
const stack = [{ selectors: [], conditional: false, mixin: null }];
let start = 0;
let quote = '';
let interpolation = 0;
@@ -205,22 +206,24 @@ export function walkDeclarations(source) {
else if (char === '{') {
const { value } = statement(i);
const parent = stack[stack.length - 1];
if (/^@mixin\b/i.test(value))
stack.push({ selectors: [], conditional: true });
const mixin = /^@mixin\s+([\w-]+)/i.exec(value)?.[1];
if (mixin) stack.push({ selectors: [], conditional: true, mixin });
else if (value.startsWith('@'))
stack.push({ selectors: parent.selectors, conditional: true });
stack.push({ ...parent, conditional: true });
else
stack.push({
...parent,
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];
const { selectors, conditional, mixin } =
stack[stack.length - 1];
items.push({
selectors,
conditional,
mixin,
declaration: value.replace(/\s+/g, ' '),
line: lineAt(text, offset),
});