mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46: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
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+3
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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 {
|
||||
|
||||
+3
-3
@@ -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);
|
||||
|
||||
+4
-4
@@ -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 {
|
||||
|
||||
+50
@@ -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;
|
||||
}
|
||||
+1
-51
@@ -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 {
|
||||
|
||||
+6
-5
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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 {
|
||||
|
||||
+3
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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.
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+3
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user