perf(web): render the radio play control as an icon button, not a fab (#1938)

The audio player's play/pause control was the app's only mat-fab. It is now
a mat-icon-button drawn as the same 56px primary circle (icon-button
overrides: 56px state layer, 32px white icon, 50% shape, selection-colour
fill, hover scale), so the eager button chunk no longer carries the fab
directives and the theme no longer emits the fab tokens:
renderer.initialBytes 1,349,048 -> 1,329,249 bytes (-19,799: -13,799 in
button.mjs, -6,028 in the stylesheet), measured locally against the merge
base. The fab also reset the keyboard focus ring with `outline: none`; the
icon button keeps the app ring.

- tools/nx/check-material-theme-scope.mjs: `button` brings button and
  icon-button; `fab` is required only while a runtime template renders
  mat-fab / mat-mini-fab (matFab / matMiniFab), found through the component
  templates the way check-form-field-defaults.mjs resolves them. That
  resolution moves to tools/nx/component-templates.mjs, shared by both.
  Templates know HTML comments only, so the scan strips just those: a `//`
  in text or an attribute value no longer hides a fab on the same line.
- apps/web/src/m3-theme.scss drops `fab` from $material-components.
- apps/web-e2e: the Stalker radio test checks the control's size, shape,
  fill, icon and keyboard focus ring.
- journey-baselines.json: renderer.initialBytes 1,345,807 -> 1,329,249.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-10-14 19:51:34 +02:00
1 parent 7550a22a41
commit ced7386dfa
12 files changed
+389 -64

No files matched your search

+8
View File
@@ -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.
@@ -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<string> {
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<void> {
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'),
});
}
+3
View File
@@ -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
+3 -1
View File
@@ -65,6 +65,9 @@ $dark-theme: mat.define-theme(
// `@angular/material/<entry>` 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,
+4 -1
View File
@@ -136,7 +136,10 @@ Everything else is lazy by these rules:
and the dark colours. Add an entry with a new `@angular/material/<entry>`
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
@@ -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 {
@@ -86,8 +86,8 @@ import { PlaybackHistoryConfirmation } from '../playback-history/playback-histor
</button>
<button
mat-icon-button
class="play-btn"
mat-fab
[matTooltip]="
(playState() === 'play'
? 'AUDIO_PLAYER.PAUSE'
+7 -45
View File
@@ -4,6 +4,12 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
import {
componentMetadata,
componentTemplate,
property,
} from './component-templates.mjs';
/**
* The app's `<mat-form-field>` defaults (outline, dynamic subscript) are
* provided per component, not in the root injector: importing
@@ -25,43 +31,6 @@ function isScanned(file) {
return !/\.(spec|test)\.[cm]?[jt]s$/.test(file);
}
/** The object literal of an `@Component({...})` decorator, if `node` is one. */
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;
}
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;
}
function isProviderCall(node) {
return (
ts.isCallExpression(node) &&
@@ -125,14 +94,7 @@ export async function checkComponentSource(file, source, readTemplate) {
visitCalls(sourceFile);
for (const { node, metadata, provides } of components) {
const inline = property(metadata, 'template');
const url = property(metadata, 'templateUrl');
let template = inline ? literalText(inline) : undefined;
if (template === undefined && url && ts.isStringLiteralLike(url)) {
template = await readTemplate(
path.posix.join(path.posix.dirname(file), url.text)
);
}
const template = await componentTemplate(metadata, file, readTemplate);
if (provides || !/<mat-form-field\b/.test(template ?? '')) continue;
findings.push({
file,
+65 -8
View File
@@ -4,6 +4,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { isScanned, stripComments } from './check-theme-references.mjs';
import { componentTemplates } from './component-templates.mjs';
/**
* `apps/web/src/m3-theme.scss` themes only the Material components the app
@@ -12,7 +13,9 @@ import { isScanned, stripComments } from './check-theme-references.mjs';
* keeps the list in step with the imports in both directions: a component a
* new `@angular/material/<entry>` import brings in must be themed, or it
* renders with no colours at all, and a component whose last import went
* must leave the list.
* must leave the list. The fab theme follows the templates instead: the
* button chunk carries its directives either way, so only a rendered
* `mat-fab` or `mat-mini-fab` earns its tokens.
*/
/** Angular renderer sources that load the theme (the remote control has its own styles). */
@@ -40,7 +43,7 @@ export const THEMES_BY_ENTRY_POINT = new Map([
['autocomplete', ['autocomplete']],
['badge', ['badge']],
['bottom-sheet', ['bottom-sheet']],
['button', ['button', 'icon-button', 'fab']],
['button', ['button', 'icon-button']],
['button-toggle', ['button-toggle']],
['card', ['card']],
['checkbox', ['checkbox']],
@@ -98,8 +101,55 @@ export function usedEntryPoints(sources) {
return importers;
}
/** The theme names the imported entry points need, each with a reason. */
export function requiredThemes(importers) {
/**
* Themes a rendered element, not an import, calls for. The button entry
* point carries the fab directives whether or not a template uses them, but
* their tokens are about 6 KB of the initial stylesheet that only an element
* with one of the fab attribute selectors reads. The pattern matches that
* attribute on an element, never in text, a class value or a comment.
*/
export const THEMES_BY_TEMPLATE = new Map([
[
'fab',
/<[a-z][^\s<>/]*(?:[^<>"']|"[^"]*"|'[^']*')*?\s(mat-fab|mat-mini-fab|matFab|matMiniFab)(?=[\s/>=])/i,
],
]);
/**
* An Angular template knows HTML comments only: `//` there is text or part
* of a URL, never a comment, so the source-code rules of `stripComments`
* would blank a real element after it.
*/
function stripHtmlComments(template) {
return template.replace(/<!--[\s\S]*?-->/g, ' ');
}
/**
* The template-gated themes the runtime templates render, each with the
* first component template that renders the element and the attribute it
* carries. `readTemplate` reads a `templateUrl` relative to its component.
*/
export async function templateThemeUsers(sources, readTemplate) {
const users = new Map();
for (const { file, source } of sources) {
if (users.size === THEMES_BY_TEMPLATE.size) break;
const templates = await componentTemplates(file, source, readTemplate);
for (const template of templates) {
const markup = stripHtmlComments(template);
for (const [name, element] of THEMES_BY_TEMPLATE) {
const match = users.has(name) ? null : element.exec(markup);
if (match) users.set(name, { file, selector: match[1] });
}
}
}
return users;
}
/**
* The theme names the imported entry points and the rendered templates
* need, each with a reason.
*/
export function requiredThemes(importers, templateUsers = new Map()) {
const required = new Map(
ALWAYS_THEMED.map((name) => [name, 'always themed'])
);
@@ -113,6 +163,9 @@ export function requiredThemes(importers) {
}
}
}
for (const [name, { file, selector }] of templateUsers) {
if (!required.has(name)) required.set(name, `${selector} in ${file}`);
}
return required;
}
@@ -180,7 +233,11 @@ if (isMain) {
source: await readFile(path.join(rootDir, file), 'utf8'),
}))
);
const required = requiredThemes(usedEntryPoints(sources));
const readTemplate = (file) => readFile(path.join(rootDir, file), 'utf8');
const required = requiredThemes(
usedEntryPoints(sources),
await templateThemeUsers(sources, readTemplate)
);
const scope = themedComponents(
await readFile(path.join(rootDir, THEME_FILE), 'utf8')
);
@@ -188,7 +245,7 @@ if (isMain) {
if (unthemed.length > 0) {
console.error(
`Material components the app imports but ${THEME_FILE} does not theme; they render without colours. Add them to $material-components:`
`Material components the app imports or renders but ${THEME_FILE} does not theme; they render without colours. Add them to $material-components:`
);
for (const [name, reason] of unthemed) {
console.error(`- ${name} (${reason})`);
@@ -197,7 +254,7 @@ if (isMain) {
}
if (unused.length > 0) {
console.error(
`Material components ${THEME_FILE} themes although no runtime source imports them; their tokens only add to the initial stylesheet. Remove them from $material-components:`
`Material components ${THEME_FILE} themes although no runtime source imports or renders them; their tokens only add to the initial stylesheet. Remove them from $material-components:`
);
for (const name of unused) {
console.error(`- ${name}`);
@@ -208,7 +265,7 @@ if (isMain) {
console.log(
scope.all
? `Checked ${files.length} renderer sources; ${THEME_FILE} themes every Material component through all-component-themes.`
: `Checked ${files.length} renderer sources; ${THEME_FILE} themes exactly the ${scope.themed.size} Material components they import.`
: `Checked ${files.length} renderer sources; ${THEME_FILE} themes exactly the ${scope.themed.size} Material components they use.`
);
}
}
+113 -1
View File
@@ -4,6 +4,7 @@ import { test } from 'node:test';
import {
compareThemeScope,
requiredThemes,
templateThemeUsers,
themedComponents,
usedEntryPoints,
} from './check-material-theme-scope.mjs';
@@ -77,12 +78,123 @@ test('requiredThemes always keeps core and expands the button entry point', () =
['core', 'always themed'],
['button', '@angular/material/button in a.ts'],
['icon-button', '@angular/material/button in a.ts'],
['fab', '@angular/material/button in a.ts'],
['tooltip', '@angular/material/tooltip in b.ts'],
]
);
});
test('requiredThemes adds the themes rendered templates call for', () => {
const required = requiredThemes(
new Map([['button', 'a.ts']]),
new Map([['fab', { file: 'b.component.ts', selector: 'mat-mini-fab' }]])
);
assert.deepEqual(
[...required],
[
['core', 'always themed'],
['button', '@angular/material/button in a.ts'],
['icon-button', '@angular/material/button in a.ts'],
['fab', 'mat-mini-fab in b.component.ts'],
]
);
});
test('templateThemeUsers finds a fab element in the first template that renders one', async () => {
const templates = {
'libs/b/b.component.html':
'<a [disabled]="count > 1" matFab href="#">Up</a>',
};
const users = await templateThemeUsers(
[
{
file: 'libs/a/a.component.ts',
source: lines(
'@Component({',
' template: `',
' <!-- <button mat-fab></button> -->',
' <p title="a > b">Use mat-fab or matMiniFab here</p>',
' <button class="mat-fab mat-mini-fab">mat-fab</button>',
' <button mat-fab-like matFabulous></button>',
' `,',
'})',
'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: `<button mat-mini-fab (click)="go()"></button>`,',
'})',
'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: `',
' <!-- <button mat-fab></button> -->',
' <span>now // later</span> <a href="https://x/up" mat-fab>Up</a>',
' <button title="Help // tips" mat-mini-fab></button>',
' `,',
'})',
'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: `<button mat-icon-button class="play-btn"></button>`,',
'})',
'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(
+91
View File
@@ -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);
}
+3 -3
View File
@@ -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": {