diff --git a/.changes/ui-one-focus-ring-colour.md b/.changes/ui-one-focus-ring-colour.md new file mode 100644 index 000000000..f15425d18 --- /dev/null +++ b/.changes/ui-one-focus-ring-colour.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 807706849..66cbc9a91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. diff --git a/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts b/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts index b66519946..80ff98a98 100644 --- a/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts +++ b/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts @@ -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 { } 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 { 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 { 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 { ? 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 { + 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"]') diff --git a/apps/web/src/app/settings/settings-about-section.component.scss b/apps/web/src/app/settings/settings-about-section.component.scss index 1c5bd5cf6..d628b2fe0 100644 --- a/apps/web/src/app/settings/settings-about-section.component.scss +++ b/apps/web/src/app/settings/settings-about-section.component.scss @@ -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); } } diff --git a/apps/web/src/app/settings/settings-epg-section.component.scss b/apps/web/src/app/settings/settings-epg-section.component.scss index 0fd3a8e2d..5b5912629 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.scss +++ b/apps/web/src/app/settings/settings-epg-section.component.scss @@ -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); } } diff --git a/apps/web/src/app/settings/settings-search-results.component.scss b/apps/web/src/app/settings/settings-search-results.component.scss index 58b9c295a..36572eed2 100644 --- a/apps/web/src/app/settings/settings-search-results.component.scss +++ b/apps/web/src/app/settings/settings-search-results.component.scss @@ -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; } } diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index f9b88dab5..61e825477 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -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); } } diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 83a12c6fd..ffe72b68c 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -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); } diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 7a74e77f9..83a0685db 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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 diff --git a/libs/playlist/import/feature/project.json b/libs/playlist/import/feature/project.json index 202bb9525..743c74e38 100644 --- a/libs/playlist/import/feature/project.json +++ b/libs/playlist/import/feature/project.json @@ -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", diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss index 7ae4bc42a..dc2692729 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss @@ -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); } } diff --git a/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.scss b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.scss index a1f9c6b23..3b06fa75f 100644 --- a/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.scss +++ b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.scss @@ -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; } } } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/m3u-fullscreen-channel-list.component.scss b/libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/m3u-fullscreen-channel-list.component.scss index 78e2e05f2..a591b2ddf 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/m3u-fullscreen-channel-list.component.scss +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/fullscreen-channel-list/m3u-fullscreen-channel-list.component.scss @@ -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, diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss index 3ab758990..0e17b7f06 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss @@ -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); } } diff --git a/libs/portal/downloads/feature/project.json b/libs/portal/downloads/feature/project.json index 5c8325819..360019ca5 100644 --- a/libs/portal/downloads/feature/project.json +++ b/libs/portal/downloads/feature/project.json @@ -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", diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.scss b/libs/portal/downloads/feature/src/lib/download-library.component.scss index fed710064..b59bd7ec7 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.scss +++ b/libs/portal/downloads/feature/src/lib/download-library.component.scss @@ -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 { diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.scss b/libs/portal/downloads/feature/src/lib/download-queue.component.scss index a05e5bc4e..42e6b43e5 100644 --- a/libs/portal/downloads/feature/src/lib/download-queue.component.scss +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.scss @@ -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 { diff --git a/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.scss b/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.scss index 2bb439f23..2a6cf40af 100644 --- a/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.scss +++ b/libs/portal/downloads/feature/src/lib/downloaded-series-dialog.component.scss @@ -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 { diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.scss b/libs/portal/downloads/feature/src/lib/downloads.component.scss index 7406dab80..ad4802d94 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.scss +++ b/libs/portal/downloads/feature/src/lib/downloads.component.scss @@ -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 { diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.scss b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.scss index cc8012a3c..5208cbd71 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.scss +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.scss @@ -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; } } diff --git a/libs/portal/downloads/feature/src/lib/recording-library.component.scss b/libs/portal/downloads/feature/src/lib/recording-library.component.scss index cbf174edf..1f57d3cc5 100644 --- a/libs/portal/downloads/feature/src/lib/recording-library.component.scss +++ b/libs/portal/downloads/feature/src/lib/recording-library.component.scss @@ -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); diff --git a/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.scss b/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.scss index 66bb5cffb..f4575fb01 100644 --- a/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.scss +++ b/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.scss @@ -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 { diff --git a/libs/portal/shared/ui/src/lib/components/open-in-playlist-chip/open-in-playlist-chip.component.scss b/libs/portal/shared/ui/src/lib/components/open-in-playlist-chip/open-in-playlist-chip.component.scss new file mode 100644 index 000000000..6239f1c05 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/open-in-playlist-chip/open-in-playlist-chip.component.scss @@ -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; +} diff --git a/libs/portal/shared/ui/src/lib/components/open-in-playlist-chip/open-in-playlist-chip.component.ts b/libs/portal/shared/ui/src/lib/components/open-in-playlist-chip/open-in-playlist-chip.component.ts index ace8783fc..8d3bbc139 100644 --- a/libs/portal/shared/ui/src/lib/components/open-in-playlist-chip/open-in-playlist-chip.component.ts +++ b/libs/portal/shared/ui/src/lib/components/open-in-playlist-chip/open-in-playlist-chip.component.ts @@ -37,57 +37,7 @@ import { TranslateModule } from '@ngx-translate/core'; }} `, - 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 { diff --git a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.scss b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.scss index 6cda75f1b..665d2a3eb 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.scss +++ b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.scss @@ -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) { diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss index ac78f2c7d..f3b71da69 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss @@ -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); } } diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss index f40e533a3..3f531d462 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss @@ -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 { diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss index 6eb0a9f0c..9008a13f5 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss @@ -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; } diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.scss b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.scss index 176f7964f..e7d8e7861 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.scss @@ -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); } } diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss index e6d9892fe..5d988c697 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss @@ -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 { diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index de5af1dc0..895b2b84e 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -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; } } diff --git a/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.scss b/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.scss index 0a369721b..32919d41a 100644 --- a/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.scss +++ b/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.scss @@ -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; } } } diff --git a/libs/ui/components/src/lib/detail-ui/detail-credits.component.scss b/libs/ui/components/src/lib/detail-ui/detail-credits.component.scss index 688fb91a1..1010d1c04 100644 --- a/libs/ui/components/src/lib/detail-ui/detail-credits.component.scss +++ b/libs/ui/components/src/lib/detail-ui/detail-credits.component.scss @@ -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; } } diff --git a/libs/ui/components/src/lib/detail-ui/detail-rail.component.scss b/libs/ui/components/src/lib/detail-ui/detail-rail.component.scss index b3c2bb4f5..16dbb21c9 100644 --- a/libs/ui/components/src/lib/detail-ui/detail-rail.component.scss +++ b/libs/ui/components/src/lib/detail-ui/detail-rail.component.scss @@ -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 { diff --git a/libs/ui/components/src/lib/detail-ui/meta-chip.component.scss b/libs/ui/components/src/lib/detail-ui/meta-chip.component.scss index 7a0e4390b..e7014b2c0 100644 --- a/libs/ui/components/src/lib/detail-ui/meta-chip.component.scss +++ b/libs/ui/components/src/lib/detail-ui/meta-chip.component.scss @@ -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; } } diff --git a/libs/ui/components/src/lib/detail-ui/similar-rail.component.scss b/libs/ui/components/src/lib/detail-ui/similar-rail.component.scss index ffe90ffa2..a5f0cdc9c 100644 --- a/libs/ui/components/src/lib/detail-ui/similar-rail.component.scss +++ b/libs/ui/components/src/lib/detail-ui/similar-rail.component.scss @@ -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; } } diff --git a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss index 2a121a3d1..d53084f0a 100644 --- a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss +++ b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss @@ -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; } } } diff --git a/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss index 070f9d06f..58baa67ec 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss +++ b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss @@ -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; } } } diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss index 03a220312..53a6d257d 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss @@ -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); } } diff --git a/libs/ui/components/src/lib/season-container/episode-item.component.scss b/libs/ui/components/src/lib/season-container/episode-item.component.scss index 6cbddd4fb..972b2bfe7 100644 --- a/libs/ui/components/src/lib/season-container/episode-item.component.scss +++ b/libs/ui/components/src/lib/season-container/episode-item.component.scss @@ -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 { diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.scss b/libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.scss index 64315b79a..31762c62e 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.scss +++ b/libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.scss @@ -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; } } diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-row.component.scss b/libs/ui/components/src/lib/vod-sources/vod-source-row.component.scss index 04b6f3073..5000efd4b 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-source-row.component.scss +++ b/libs/ui/components/src/lib/vod-sources/vod-source-row.component.scss @@ -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; } } diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.scss b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.scss index 9dc07c35e..b031328cd 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.scss +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.scss @@ -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; } } diff --git a/libs/ui/components/src/lib/window-controls/window-controls.component.scss b/libs/ui/components/src/lib/window-controls/window-controls.component.scss index 22c72625a..538c3ff90 100644 --- a/libs/ui/components/src/lib/window-controls/window-controls.component.scss +++ b/libs/ui/components/src/lib/window-controls/window-controls.component.scss @@ -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 { diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.scss b/libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.scss index b3f8427a9..0a7eb0fa6 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.scss +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.scss @@ -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 { diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.scss b/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.scss index ac94f1c29..e39129efb 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.scss +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.scss @@ -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); } } diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss index 7f44db599..02d9052b0 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss @@ -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; } diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss index 5ecac5bac..7316cf0c5 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss @@ -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); } } diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.scss b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.scss index 2c58dfd22..371995604 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.scss +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-dock-panel.component.scss @@ -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 { diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss index ccb225b06..e4c708981 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss @@ -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. diff --git a/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.scss b/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.scss index 02f0d8561..f41d5ef4a 100644 --- a/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.scss +++ b/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.scss @@ -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); } diff --git a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.scss b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.scss index bfbfaa51f..0792a674c 100644 --- a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.scss +++ b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.scss @@ -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); } } diff --git a/libs/ui/styles/_focus-ring.scss b/libs/ui/styles/_focus-ring.scss index bc0204a70..ca4187238 100644 --- a/libs/ui/styles/_focus-ring.scss +++ b/libs/ui/styles/_focus-ring.scss @@ -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; } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss index 29c1d293a..34fc6053d 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss @@ -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; } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss index 21902003c..e951f6711 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss @@ -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); } } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.scss b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.scss index 142a1f396..2d2c38d0c 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.scss @@ -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); } } diff --git a/libs/workspace/shell/feature/src/lib/workspace-live-categories-popover/workspace-live-categories-popover.component.scss b/libs/workspace/shell/feature/src/lib/workspace-live-categories-popover/workspace-live-categories-popover.component.scss index 3a572cd05..95d6eb2a9 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-live-categories-popover/workspace-live-categories-popover.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-live-categories-popover/workspace-live-categories-popover.component.scss @@ -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); } } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss index 6ebbbdc01..af9125b2b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss @@ -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); } } } diff --git a/package.json b/package.json index 56bcb81c6..184242033 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tools/nx/check-focus-ring-colour.mjs b/tools/nx/check-focus-ring-colour.mjs new file mode 100644 index 000000000..ccebe6bb6 --- /dev/null +++ b/tools/nx/check-focus-ring-colour.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.` + ); + } +} diff --git a/tools/nx/check-focus-ring-colour.test.mjs b/tools/nx/check-focus-ring-colour.test.mjs new file mode 100644 index 000000000..f8b684fa4 --- /dev/null +++ b/tools/nx/check-focus-ring-colour.test.mjs @@ -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); +}); diff --git a/tools/nx/check-focus-visible.mjs b/tools/nx/check-focus-visible.mjs index e051424bd..ec1db657a 100644 --- a/tools/nx/check-focus-visible.mjs +++ b/tools/nx/check-focus-visible.mjs @@ -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), });