diff --git a/.changes/a11y-radio-play-focus-ring.md b/.changes/a11y-radio-play-focus-ring.md new file mode 100644 index 000000000..b74ddb129 --- /dev/null +++ b/.changes/a11y-radio-play-focus-ring.md @@ -0,0 +1,8 @@ +--- +type: fix +area: a11y +--- + +The radio player's play and pause button now shows the keyboard focus ring +when you tab to it, like every other control, and the app loads a little less +style code at startup. diff --git a/apps/web-e2e/src/stalker-radio-player.fixture.ts b/apps/web-e2e/src/stalker-radio-player.fixture.ts new file mode 100644 index 000000000..58a0238ad --- /dev/null +++ b/apps/web-e2e/src/stalker-radio-player.fixture.ts @@ -0,0 +1,78 @@ +import type { Page } from '@playwright/test'; +import { pressTab } from './e2e-helpers'; +import { expect } from './fixtures'; + +/** The computed colour a custom property resolves to on this page. */ +async function computedColor(page: Page, property: string): Promise { + return page.evaluate((name) => { + const probe = document.createElement('span'); + probe.style.color = `var(${name})`; + document.body.append(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }, property); +} + +/** + * The radio player's play/pause control is a `mat-icon-button` drawn as the + * 56px primary circle the fab used to draw (the fab tokens alone were about + * 6 KB of the initial stylesheet), with the icon centred, and keyboard focus + * draws the app ring that the fab reset with `outline: none`. + */ +export async function verifyRadioPlayControl( + page: Page, + browserName: string +): Promise { + const player = page.locator('app-audio-player'); + const play = player.locator('.play-btn'); + await expect(play).toBeVisible(); + await expect(play).toHaveClass(/mat-mdc-icon-button/); + await expect(play).not.toHaveClass(/mat-mdc-fab/); + + const style = await play.evaluate((button) => { + const icon = button.querySelector('mat-icon') as HTMLElement; + const box = button.getBoundingClientRect(); + const iconBox = icon.getBoundingClientRect(); + const { width, height, borderRadius, backgroundColor } = + getComputedStyle(button); + const centre = (rect: DOMRect) => [ + rect.left + rect.width / 2, + rect.top + rect.height / 2, + ]; + const [x, y] = centre(box); + const [iconX, iconY] = centre(iconBox); + return { + width, + height, + borderRadius, + backgroundColor, + iconColor: getComputedStyle(icon).color, + iconFontSize: getComputedStyle(icon).fontSize, + iconOffset: [Math.round(iconX - x), Math.round(iconY - y)], + }; + }); + expect(style).toEqual({ + width: '56px', + height: '56px', + borderRadius: '50%', + backgroundColor: await computedColor(page, '--app-selection-color'), + iconColor: 'rgb(255, 255, 255)', + iconFontSize: '32px', + iconOffset: [0, 0], + }); + + await player.locator('.skip-btn').first().focus(); + await pressTab(page, browserName); + await expect(play).toBeFocused(); + const ring = await play.evaluate((button) => { + const { outlineStyle, outlineWidth, outlineColor } = + getComputedStyle(button); + return { outlineStyle, outlineWidth, outlineColor }; + }); + expect(ring).toEqual({ + outlineStyle: 'solid', + outlineWidth: '2px', + outlineColor: await computedColor(page, '--app-focus-ring'), + }); +} diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 525fe7935..114ac09a1 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -18,6 +18,7 @@ import { import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture'; import { verifyStalkerOpenInPlaylist } from './stalker-open-in-playlist.fixture'; import { playFirstItvChannel } from './stalker-itv-playback.fixture'; +import { verifyRadioPlayControl } from './stalker-radio-player.fixture'; import { AUTH_REJECTED_MAC, BACKEND_PROXY, @@ -296,6 +297,7 @@ test('@stalker ITV playback survives a category switch', async ({ page }) => { test('@stalker radio — stations use the inline audio player without EPG', async ({ page, + browserName, }) => { await addStalkerPortal(page); @@ -333,6 +335,7 @@ test('@stalker radio — stations use the inline audio player without EPG', asyn await expect(page.locator('app-audio-player')).toBeVisible({ timeout: 20_000, }); + await verifyRadioPlayControl(page, browserName); // Level 2: only the categories rail folds; the channels header turns // its title into a category dropdown that opens the same rail as a diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index ff8683705..f45f696a8 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -65,6 +65,9 @@ $dark-theme: mat.define-theme( // `@angular/material/` import and remove it with the last one: // `pnpm run styles:material-theme-scope:validate` (CI) checks both // directions, and `core` (ripples, options, pseudo-checkboxes) always stays. +// `fab` follows the templates instead: the button import carries its +// directives either way, so it is listed only while a template renders +// `mat-fab` or `mat-mini-fab` (its tokens alone were about 6 KB). $material-components: ( core, card, @@ -81,7 +84,6 @@ $material-components: ( checkbox, button, icon-button, - fab, snack-bar, progress-spinner, button-toggle, diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 13972e2f6..cbe3c7595 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -136,7 +136,10 @@ Everything else is lazy by these rules: and the dark colours. Add an entry with a new `@angular/material/` import and remove it with the last one; `pnpm run styles:material-theme-scope:validate` (CI) checks both directions - (`core` always stays, `button` brings `icon-button` and `fab`). + (`core` always stays, `button` brings `icon-button`; `fab` is listed only + while a runtime template renders `mat-fab` or `mat-mini-fab`, since the + button import carries the directives either way and the fab tokens alone + are about 6 KB). - **No animations module.** Nothing declares an Angular animation trigger and Material 22 animates with CSS, deciding from `prefers-reduced-motion` itself, so `app.config.ts` does not call `provideAnimations()`; the Electron diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.scss b/libs/ui/playback/src/lib/audio-player/audio-player.component.scss index 919f3991c..d56977c9d 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.scss +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.scss @@ -205,15 +205,24 @@ // Play / Pause uses the unified app primary, not the station's dominant color // — same blue as every other CTA so radio doesn't feel like a different app. +// An icon button at the fab's 56px, filled: the fab tokens were about 6 KB of +// the initial stylesheet for this one control, and the fab also reset the +// keyboard focus ring (`outline: none` on focus) that the icon button keeps. .play-btn { - @include mat.fab-overrides( + @include mat.icon-button-overrides( ( - container-color: var(--app-selection-color, var(--mat-sys-primary)), - foreground-color: #fff, + state-layer-size: 56px, + icon-size: 32px, + icon-color: #fff, container-shape: 50%, + state-layer-color: #fff, + ripple-color: color-mix(in srgb, #fff 12%, transparent), ) ); - box-shadow: none !important; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--app-selection-color, var(--mat-sys-primary)); transition: transform 0.2s ease; &:hover { diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts index 43924325a..514ba86a4 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts @@ -86,8 +86,8 @@ import { PlaybackHistoryConfirmation } from '../playback-history/playback-histor ', + ' ', + ' `,', + '})', + 'export class A {}' + ), + }, + { + file: 'libs/b/b.component.ts', + source: lines( + '@Component({', + " templateUrl: './b.component.html',", + '})', + 'export class B {}' + ), + }, + { + file: 'libs/c/c.component.ts', + source: lines( + '@Component({', + ' template: ``,', + '})', + 'export class C {}' + ), + }, + ], + async (file) => templates[file] + ); + + assert.deepEqual( + [...users], + [['fab', { file: 'libs/b/b.component.ts', selector: 'matFab' }]] + ); +}); + +test('templateThemeUsers strips HTML comments only: a // in text or a URL hides nothing', async () => { + const users = await templateThemeUsers( + [ + { + file: 'libs/e/e.component.ts', + source: lines( + '@Component({', + ' template: `', + ' ', + ' now // later Up', + ' ', + ' `,', + '})', + 'export class E {}' + ), + }, + ], + async () => undefined + ); + + assert.deepEqual( + [...users], + [['fab', { file: 'libs/e/e.component.ts', selector: 'mat-fab' }]] + ); +}); + +test('templateThemeUsers finds nothing without a rendered fab', async () => { + const users = await templateThemeUsers( + [ + { + file: 'libs/d/d.component.ts', + source: lines( + '@Component({', + ' template: ``,', + '})', + 'export class D {}' + ), + }, + { file: 'libs/d/d.service.ts', source: 'export const fab = 1;' }, + ], + async () => undefined + ); + + assert.deepEqual([...users], []); +}); + test('themedComponents reads the list, direct includes and the all-components mixin', () => { assert.deepEqual( themedComponents( diff --git a/tools/nx/component-templates.mjs b/tools/nx/component-templates.mjs new file mode 100644 index 000000000..c311dbfb0 --- /dev/null +++ b/tools/nx/component-templates.mjs @@ -0,0 +1,91 @@ +import path from 'node:path'; +import ts from 'typescript'; + +/** + * The templates the app renders, found from the `@Component` decorators of + * its TypeScript sources: an inline `template` literal, or the file a + * `templateUrl` names. A guard that asks "does the app render this + * element?" reads these, not every `.html` file: an orphaned template that + * no component names never reaches a user. + */ + +/** The object literal of an `@Component({...})` decorator, if `node` is one. */ +export function componentMetadata(node) { + if ( + !ts.isDecorator(node) || + !ts.isCallExpression(node.expression) || + !ts.isIdentifier(node.expression.expression) || + node.expression.expression.text !== 'Component' + ) { + return undefined; + } + const [metadata] = node.expression.arguments; + return metadata && ts.isObjectLiteralExpression(metadata) + ? metadata + : undefined; +} + +/** The initializer of the `name` property of a metadata literal. */ +export function property(metadata, name) { + return metadata.properties.find( + (entry) => + ts.isPropertyAssignment(entry) && + (ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name)) && + entry.name.text === name + )?.initializer; +} + +/** Literal text of a quoted, backticked or interpolated template. */ +function literalText(node) { + if (ts.isStringLiteralLike(node)) return node.text; + if (ts.isTemplateExpression(node)) { + return [ + node.head.text, + ...node.templateSpans.map((span) => span.literal.text), + ].join(''); + } + return undefined; +} + +/** + * The template text of one `@Component` metadata literal: its inline + * `template`, or else the file its `templateUrl` names, which `readTemplate` + * reads by a path relative to the component's `file`. Undefined when the + * component declares neither as a literal. + */ +export async function componentTemplate(metadata, file, readTemplate) { + const inline = property(metadata, 'template'); + const template = inline ? literalText(inline) : undefined; + const url = property(metadata, 'templateUrl'); + if (template !== undefined || !url || !ts.isStringLiteralLike(url)) { + return template; + } + return readTemplate(path.posix.join(path.posix.dirname(file), url.text)); +} + +/** + * The template text of every `@Component` in a TypeScript source, in + * declaration order. A source without a component decorator is not parsed. + */ +export async function componentTemplates(file, source, readTemplate) { + if (!source.includes('@Component')) return []; + const sourceFile = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + true + ); + const components = []; + const visit = (node) => { + const metadata = componentMetadata(node); + if (metadata) components.push(metadata); + ts.forEachChild(node, visit); + }; + visit(sourceFile); + const templates = await Promise.all( + components.map((metadata) => + componentTemplate(metadata, file, readTemplate) + ) + ); + return templates.filter((template) => template !== undefined); +} diff --git a/tools/performance/journey-baselines.json b/tools/performance/journey-baselines.json index f30d5f522..66efb2e33 100644 --- a/tools/performance/journey-baselines.json +++ b/tools/performance/journey-baselines.json @@ -3,11 +3,11 @@ "journeys": { "launch": { "renderer.initialBytes": { - "value": 1345807, + "value": 1329249, "unit": "bytes", "slack": 4096, - "updatedAt": "2026-10-13", - "evidencePr": 1931, + "updatedAt": "2026-10-14", + "evidencePr": 1938, "measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes" }, "renderer.ipcCallsToFirstCard": {