style(ui): ADR and one global kit layer for menus, select panels and destructive buttons (#1936)

* docs(ui): record the Angular Material role decision in the UI guidelines

Angular Material stays the behaviour and accessibility layer; the look comes
from the design kit through mat.*-overrides() on --app-* tokens in the shared
partials, kit-only components build on CDK, and plain actions may become
native buttons. Records the measured Material and CDK share of the initial
bytes, why a replacement would not pay, and the v22 behaviour changes to
keep covered in E2E.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* style(ui): one global kit layer for menus, select panels and destructive buttons

- `<mat-menu class="app-menu">` gets the kit panel from one rule in
  styles.scss; the two lazy components drop their `::ng-deep` copies.
- Select option panels follow the same panel: bordered, 10px radius, 6px
  padding, 36px rows at 13px, the chosen row on the list-selection tokens.
- `.app-destructive-button` is the kit's red tint on the `--app-danger-*`
  tokens (tinted fill, red text, no ripple, 40% opacity when disabled)
  instead of a solid error fill, for filled, text, outlined and icon
  buttons.
- The backdrop blur is scoped to the dark backdrop the CDK gives modal
  overlays; menus, selects and popovers open over a transparent backdrop
  that no longer matches the rule.
- `mat.elevation-classes()` is dropped: nothing uses `.mat-elevation-z*`,
  and its 25 rules cost 4.4 KB of the initial stylesheet. The stack now
  measures 1,595,843 initial bytes (was 1,597,764).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(e2e): wait for focus to enter an opened menu before navigating it

Material focuses a menu's first item after the next render, which the
zoneless scheduler runs on a later task than the keydown that opened it.
The panel is visible before that, so End sent as soon as it appeared still
reached the trigger: on the Linux runner the Sources row-menu delete test
then found "Remove playlist" inactive on every retry. `waitForMenuFocus`
polls for the active element inside the open menu; the Sources keyboard
and switcher keyboard tests use it before their first navigation key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

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:53:22 +02:00
1 parent 88765c7bf0
commit 3b2af73baa
10 files changed
+253 -59

No files matched your search

@@ -0,0 +1,21 @@
import { expect, Locator, Page } from '@playwright/test';
/**
* Wait until keyboard focus has entered the open `mat-menu`, and return it.
*
* Material moves focus to the first item after the next render, which the
* zoneless scheduler runs on a later task than the keydown that opened the
* menu. The panel is visible before that, so a navigation key sent as soon
* as it appears still reaches the trigger and the menu never sees it.
*/
export async function waitForMenuFocus(page: Page): Promise<Locator> {
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await expect
.poll(
() => menu.evaluate((el) => el.contains(document.activeElement)),
{ message: 'focus inside the open menu' }
)
.toBe(true);
return menu;
}
@@ -30,6 +30,7 @@ import {
writeTemporaryM3uFile,
xtreamMockServer,
} from './electron-test-fixtures';
import { waitForMenuFocus } from './menu-keyboard';
import {
routePlayableStreams,
startAndConfirmPlayback,
@@ -572,8 +573,7 @@ test.describe('Electron Playlist Switcher', () => {
await expect(search).toBeFocused();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Shift+F10');
const rowMenu = page.locator('.mat-mdc-menu-panel');
await expect(rowMenu).toBeVisible();
const rowMenu = await waitForMenuFocus(page);
await page.keyboard.press('End');
await expect(
rowMenu.getByRole('menuitem', { name: /Remove/ })
@@ -9,6 +9,7 @@ import {
waitForM3uCatalog,
writeTemporaryM3uFile,
} from './electron-test-fixtures';
import { waitForMenuFocus } from './menu-keyboard';
// ---------------------------------------------------------------------------
// A source row opens from the keyboard. Each row has its own Tab stop: the
@@ -217,6 +218,7 @@ test.describe('Electron Sources keyboard', () => {
name: 'Remove playlist',
});
await expect(remove).toBeVisible();
await waitForMenuFocus(page);
await page.keyboard.press('End');
await expect(remove).toBeFocused();
await page.keyboard.press('Enter');
@@ -47,16 +47,27 @@ async function outlineContrast(
}, fillSelector);
}
/** Resolves a CSS color (including var() and color-mix()) to rgb()/rgba(). */
async function resolveColor(page: Page, value: string): Promise<string> {
return page.evaluate((css) => {
const probe = document.createElement('div');
probe.style.color = css;
document.body.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
}, value);
/**
* Resolves a CSS color (including var() and color-mix()) the way the given
* property serializes it, so it compares with a computed `color` or
* `backgroundColor`.
*/
async function resolveColor(
page: Page,
value: string,
property: 'color' | 'backgroundColor' = 'color'
): Promise<string> {
return page.evaluate(
({ css, prop }) => {
const probe = document.createElement('div');
probe.style[prop] = css;
document.body.appendChild(probe);
const color = getComputedStyle(probe)[prop];
probe.remove();
return color;
},
{ css: value, prop: property }
);
}
async function systemVariable(page: Page, name: string): Promise<string> {
@@ -200,7 +211,7 @@ test.describe('Theme tokens', () => {
}
});
test('@theme @electron destructive actions name the action and use the error color', async ({
test('@theme @electron destructive actions name the action and use the danger tint', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
@@ -211,18 +222,35 @@ test.describe('Theme tokens', () => {
for (const theme of ['light', 'dark'] as const) {
await applyTheme(page, theme);
const error = await resolveColor(page, 'var(--mat-sys-error)');
const dangerText = await resolveColor(
page,
'var(--app-danger-text)'
);
const dangerSurface = await resolveColor(
page,
'var(--app-danger-surface)',
'backgroundColor'
);
const trigger = page.getByRole('button', {
name: 'Clear EPG data',
exact: true,
});
// The `warn` color input was a no-op with M3: the trigger
// rendered in the primary color.
// rendered in the primary color. The kit draws a destructive
// button as a red tint, never a solid error fill, and the
// outlined trigger carries the same tint as a filled one.
await expect
.poll(() =>
trigger.evaluate((el) => getComputedStyle(el).color)
)
.toBe(error);
.toBe(dangerText);
await expect
.poll(() =>
trigger.evaluate(
(el) => getComputedStyle(el).backgroundColor
)
)
.toBe(dangerSurface);
await trigger.click();
const dialog = page.locator('mat-dialog-container');
@@ -239,7 +267,12 @@ test.describe('Theme tokens', () => {
(el) => getComputedStyle(el).backgroundColor
)
)
.toBe(error);
.toBe(dangerSurface);
await expect
.poll(() =>
confirm.evaluate((el) => getComputedStyle(el).color)
)
.toBe(dangerText);
await dialog
.getByRole('button', { name: 'Cancel', exact: true })
+88 -22
View File
@@ -3,7 +3,8 @@
@use '@angular/material' as mat;
@use 'styles';
@include mat.elevation-classes();
// No `mat.elevation-classes()`: nothing in the app uses the `.mat-elevation-z*`
// utilities, and the 25 rules cost 4.4 KB of the initial stylesheet.
@include mat.app-background();
// ─── Typography ─────────────────────────────────────────────────────────────
@@ -407,39 +408,104 @@ html {
font-size: 13.5px;
}
// A select stays inside a form; its option panel follows the kit menu:
// a bordered 10px panel with 6px padding, 36px rows at 13px and the
// chosen row on the list-selection tokens. The overrides sit on the
// panel, which renders in the overlay container inside the theme
// context, so the `--app-*` values resolve per theme. The selector is
// doubled because Material sets the panel's padding and attached
// corners from `div.mat-mdc-select-panel` and
// `.mat-mdc-select-panel-above div.mat-mdc-select-panel`, injected after
// the global stylesheet.
div.mat-mdc-select-panel.mat-mdc-select-panel {
@include mat.select-overrides(
(
panel-background-color: var(--app-widget-bg),
container-elevation-shadow: 0 18px 40px rgba(0, 0, 0, 0.35),
)
);
@include mat.option-overrides(
(
label-text-size: 13px,
label-text-color: var(--app-heading-color),
selected-state-label-text-color: var(--app-list-selected-fg),
selected-state-layer-color: var(--app-list-selected-bg),
hover-state-layer-color:
color-mix(in srgb, var(--app-on-surface) 8%, transparent),
focus-state-layer-color:
color-mix(in srgb, var(--app-on-surface) 10%, transparent),
)
);
padding: 6px;
border: 1px solid var(--app-line-strong);
border-radius: 10px;
.mat-mdc-option {
min-height: 36px;
padding: 0 10px;
border-radius: 6px;
}
}
// ─── Destructive actions ───────────────────────────────────────────────
// Material only emits `.mat-warn` button colors for M2 themes, so the
// `warn` color input is a no-op with this M3 theme. Buttons that remove or
// discard user data opt in with this class instead; the error tokens are
// declared per theme context above, so light and dark each get their own
// error/on-error pair.
// discard user data opt in with this class instead. The kit draws them as
// a red tint (`--app-danger-*`, declared per theme context above), never
// a solid error fill: hover changes the fill only, the ripple is off and
// a disabled button is the same button at 40% opacity.
.app-destructive-button {
@include mat.button-overrides(
(
filled-container-color: var(--mat-sys-error),
filled-label-text-color: var(--mat-sys-on-error),
filled-state-layer-color: var(--mat-sys-on-error),
filled-ripple-color:
color-mix(in srgb, var(--mat-sys-on-error) 12%, transparent),
text-label-text-color: var(--mat-sys-error),
text-state-layer-color: var(--mat-sys-error),
text-ripple-color:
color-mix(in srgb, var(--mat-sys-error) 12%, transparent),
outlined-label-text-color: var(--mat-sys-error),
outlined-outline-color: var(--mat-sys-error),
outlined-state-layer-color: var(--mat-sys-error),
outlined-ripple-color:
color-mix(in srgb, var(--mat-sys-error) 12%, transparent),
filled-container-color: var(--app-danger-surface),
filled-disabled-container-color: var(--app-danger-surface),
filled-label-text-color: var(--app-danger-text),
filled-disabled-label-text-color: var(--app-danger-text),
filled-state-layer-color: var(--app-danger-text),
filled-hover-state-layer-opacity: 0.08,
filled-focus-state-layer-opacity: 0.08,
filled-pressed-state-layer-opacity: 0.12,
filled-ripple-color: transparent,
text-label-text-color: var(--app-danger-text),
text-disabled-label-text-color: var(--app-danger-text),
text-state-layer-color: var(--app-danger-text),
text-hover-state-layer-opacity: 0.08,
text-focus-state-layer-opacity: 0.08,
text-pressed-state-layer-opacity: 0.12,
text-ripple-color: transparent,
outlined-label-text-color: var(--app-danger-text),
outlined-disabled-label-text-color: var(--app-danger-text),
outlined-outline-color: transparent,
outlined-disabled-outline-color: transparent,
outlined-state-layer-color: var(--app-danger-text),
outlined-hover-state-layer-opacity: 0.08,
outlined-focus-state-layer-opacity: 0.08,
outlined-pressed-state-layer-opacity: 0.12,
outlined-ripple-color: transparent,
)
);
@include mat.icon-button-overrides(
(
icon-color: var(--mat-sys-error),
state-layer-color: var(--mat-sys-error),
ripple-color:
color-mix(in srgb, var(--mat-sys-error) 12%, transparent),
icon-color: var(--app-danger-text),
disabled-icon-color: var(--app-danger-text),
state-layer-color: var(--app-danger-text),
hover-state-layer-opacity: 0.08,
focus-state-layer-opacity: 0.08,
pressed-state-layer-opacity: 0.12,
ripple-color: transparent,
)
);
// The kit has no outlined buttons: an outlined destructive button
// carries the same tint as a filled one.
&.mat-mdc-outlined-button {
background-color: var(--app-danger-surface);
}
&[disabled],
&.mat-mdc-button-disabled {
opacity: 0.4;
}
}
.mat-mdc-form-field-subscript-wrapper {
+12 -1
View File
@@ -1,6 +1,7 @@
@use '@angular/material' as mat;
@use '../../../libs/ui/components/src/lib/resizable/resizable.scss';
@use '../../../libs/ui/styles/focus-ring';
@use '../../../libs/ui/styles/app-controls' as controls;
@use './settings-theme';
@use './cover-size';
@@ -238,10 +239,20 @@ body.dark-theme {
overflow-x: hidden !important;
}
.cdk-overlay-backdrop {
// Modal overlays blur the page behind their backdrop. The CDK gives a dialog
// the dark backdrop class by default; menus, selects and popovers open over
// `cdk-overlay-transparent-backdrop`, which sits at opacity 0 and stays a
// plain layer without a filter of its own.
.cdk-overlay-dark-backdrop {
backdrop-filter: blur(4px);
}
// UI-kit menu panel: `<mat-menu class="app-menu">`. The panel renders in the
// overlay container, outside every component view, so the rule is global.
.mat-mdc-menu-panel.app-menu {
@include controls.menu-panel;
}
// Command palette dialog — the palette-shell renders its own background, border,
// radius and shadow, so strip MDC's dialog surface chrome to avoid a darker
// halo peeking out behind the rounded corners.