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

+72 -5
View File
@@ -22,6 +22,54 @@ Use it when changing existing views or introducing new list-based UI in the work
5. Scroll ownership must be explicit.
Headers stay visible. Lists scroll. Do not let nested panes compete for scroll.
## Angular Material's Role
Decision of 2026-10-12. The repository has no ADR folder; decisions live in
the subsystem's document.
**Decision.**
- Angular Material stays as the behaviour and accessibility layer: dialog,
menu, select (inside forms only), form field and input, snack-bar, tooltip,
slide-toggle, checkbox, progress and icon.
- The look comes from the IPTVnator design kit, applied through
`mat.*-overrides()` on the `--app-*` tokens in one place: the shared
partials under `libs/ui/styles/` (the control mixins live in
`_app-controls.scss`), not per-component `::ng-deep`.
- A kit component that Material lacks is built on CDK primitives (overlay,
a11y key managers), as the playlist switcher popover is.
- A plain action button may become a native `<button>` with the kit mixins.
- No wholesale replacement of Material. Re-evaluate Angular's headless
`@angular/aria` (developer preview as of v21; not installed) for menu,
listbox and combobox once it is stable.
**Why** (production build, `renderer.initialBytes` 1,597,763 bytes):
- Material's JavaScript is about 203 KB (12.7% of the initial bytes) and the
CDK's about 126 KB (7.9%). Any replacement keeps the CDK, and the CDK is
where the behaviour lives.
- In the global CSS the `--mat-*` tokens are 96 KB raw but about 11 KB
gzipped, and Material's own rules are 9 KB. 23.6 KB of the tokens belong to
17 components the app never imports, because `mat.all-component-themes`
emits every component.
- The startup cost came from a few eager imports (the form-field defaults
token in `app.config.ts`, the startup, update and EPG-progress components,
the confirm-dialog service), not from Material itself. Those are gone (see
Material off the startup path below): `renderer.initialBytes` went from
1,597,763 to 1,345,807 with Material still in place.
- Rewriting dialog focus trapping, menu keyboard handling, select listboxes
and snack-bar live regions would cost months and accessibility risk for no
visible gain.
**Consequences.**
- Material upgrades can change behaviour. Seen in v22: the menu panel has no
max-height; a submenu is offset by the parent's first direct
`mat-menu-item`, so a trigger that follows `role="group"` rows is wrapped
in `<div role="none">`; overlay panes are `position: static` inside the
bounding box.
- Keep E2E coverage on menus and dialogs.
## Canonical References
- Channel row:
@@ -78,8 +126,9 @@ in `apps/web/src/m3-theme.scss`):
active source's row on Sources and in the switcher (see Selection Pattern)
- `--app-line-strong` — popover and menu borders, a step above
`--app-widget-header-border`
- `--app-danger-text` / `--app-danger-surface` — a failing source's tag and
destructive menu items; `--app-neutral-tag-surface` for a neutral tag
- `--app-danger-text` / `--app-danger-surface` — a failing source's tag,
destructive menu items and `.app-destructive-button`;
`--app-neutral-tag-surface` for a neutral tag
- `--app-font-mono` — URLs and keyboard hints (JetBrains Mono, 400/500)
- `--app-popover-shadow` / `--app-popover-backdrop` — the switcher popover
@@ -243,6 +292,15 @@ selection blue on its own tint is 3.3:1) and the selection colour in dark.
## Single-Choice Menus And Status Chips
A `<mat-menu class="app-menu">` gets the kit panel from one global rule in
`styles.scss` (`menu-panel` in `libs/ui/styles/_app-controls.scss`): a
bordered 10px panel with 6px padding, 36px rows at 13px with 16px icons, the
checked row on the list-selection tokens, a destructive row marked
`app-menu-item--danger` after a divider, and a shortcut hint in a trailing
`kbd.app-menu-item__kbd`. The panel renders in the overlay container, outside
every component view, so a component never copies the mixin into its own
stylesheet.
A `mat-menu` that picks one value, such as a sort order or a rating
threshold, uses `appMenuItemRadio` and `appMenuItemRadioCheck` from
`@iptvnator/ui/components`:
@@ -1097,14 +1155,17 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`.
- **Destructive actions.** Material only emits `warn` button colors for M2
themes, so the `color` input is a no-op here. A button that removes or
discards user data uses the global `.app-destructive-button` class from
`m3-theme.scss` (error/on-error tokens per theme, for filled, text,
outlined and icon buttons), as the unsaved-changes dialog's Discard does.
`m3-theme.scss`, as the unsaved-changes dialog's Discard does. It is the
kit's red tint on the `--app-danger-*` tokens per theme (tinted fill and
red text for filled and outlined buttons, red text for text and icon
buttons), never a solid error fill; hover changes the fill only, and a
disabled button is the same button at 40% opacity.
Confirmations go through `DialogService.openConfirmDialog` with a
translated verb as the required `confirmLabel` ("Remove playlist",
"Clear") and `tone: 'destructive'` when the action loses data; the dismiss
defaults to "Cancel". Never confirm with "Yes"/"No". When the verb itself
is "Cancel …", pass `cancelLabel` "Close" so the two buttons do not read
alike. `theme-tokens.e2e.ts` checks the label and the error fill in both
alike. `theme-tokens.e2e.ts` checks the label and the danger tint in both
themes.
- **One control per action.** A page offers one control for a destructive
action. Clearing a favorites or recently viewed tab goes through
@@ -1123,6 +1184,12 @@ provide the defaults, because the token import there would load Material's
form field and `@angular/forms` on startup. `pnpm run
ui:form-field-defaults:validate` (in CI) reports a missing provider.
A `mat-select` belongs inside a form. Its option panel is styled once in
`m3-theme.scss` to match the kit menu (a bordered 10px panel, 36px rows at
13px, the chosen row on the list-selection tokens). A picker in a toolbar is
not a select: it is a secondary button with a chevron that opens an
`app-menu`.
The add-source forms (M3U URL, Xtream, Stalker) and the edit dialog share one
vocabulary, so a field reads the same wherever it appears: