From 88765c7bf0522f013ea141923187f19faeb18633 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:52:47 +0200 Subject: [PATCH 1/3] chore(workspace): drop leftover global-favorites-list template and styles (#1935) The component moved to @iptvnator/portal/shared/ui, which owns its own template and stylesheet. The workspace shell kept only a re-export shim, but the old .html and .scss files stayed behind and were never referenced again. Delete the two orphaned files. Co-authored-by: Claude Fable 5.1 --- .../global-favorites-list.component.html | 59 -------------- .../global-favorites-list.component.scss | 79 ------------------- 2 files changed, 138 deletions(-) delete mode 100644 libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.html delete mode 100644 libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.scss diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.html b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.html deleted file mode 100644 index f931545d7..000000000 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.html +++ /dev/null @@ -1,59 +0,0 @@ -
- - search - - -
- - diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.scss b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.scss deleted file mode 100644 index b39baa81e..000000000 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.scss +++ /dev/null @@ -1,79 +0,0 @@ -@use '@angular/material' as mat; - -:host { - display: flex; - flex-direction: column; - height: 100%; - min-height: 0; - overflow: hidden; -} - -// ─── Search ──────────────────────────────────────────────────────────────────── - -.search-panel { - margin-top: 10px; - display: flex; - justify-content: center; - margin-bottom: -10px; - flex-shrink: 0; -} - -.search-bar { - width: 90%; - @include mat.form-field-density(-5); -} - -:host ::ng-deep .search-bar div { - border-radius: 20px; -} - -:host ::ng-deep .search-bar div[matformfieldlineripple], -:host ::ng-deep .search-bar .mat-mdc-form-field-subscript-wrapper { - display: none; -} - -// ─── List ────────────────────────────────────────────────────────────────────── - -.nav-list { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - padding: 8px 8px 12px; - display: flex; - flex-direction: column; - gap: 2px; -} - -// ─── Empty state ─────────────────────────────────────────────────────────────── - -.empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 48px 24px; - text-align: center; - color: var(--mat-sys-on-surface-variant); - - .empty-icon { - font-size: 48px; - height: 48px; - width: 48px; - margin-bottom: 16px; - opacity: 0.5; - } - - .empty-title { - font-size: 1.05rem; - font-weight: 500; - letter-spacing: -0.01em; - margin: 0 0 8px; - } - - .empty-hint { - font-size: 0.85rem; - opacity: 0.65; - margin: 0; - max-width: 260px; - } -} From 3b2af73baafd53cfdbb8b059dbadf080a1dbdf3e Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:53:22 +0200 Subject: [PATCH 2/3] 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 * style(ui): one global kit layer for menus, select panels and destructive buttons - `` 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 * 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 --------- Co-authored-by: Claude Fable 5.1 --- .changes/ui-kit-global-layer.md | 8 ++ .../electron-backend-e2e/src/menu-keyboard.ts | 21 ++++ .../src/playlist-switcher.e2e.ts | 4 +- .../src/sources-keyboard.e2e.ts | 2 + .../src/theme-tokens.e2e.ts | 63 +++++++--- apps/web/src/m3-theme.scss | 110 ++++++++++++++---- apps/web/src/styles.scss | 13 ++- docs/architecture/iptvnator-ui-guidelines.md | 77 +++++++++++- .../lib/source-row/source-row.component.scss | 7 -- .../workspace-sources.component.scss | 7 -- 10 files changed, 253 insertions(+), 59 deletions(-) create mode 100644 .changes/ui-kit-global-layer.md create mode 100644 apps/electron-backend-e2e/src/menu-keyboard.ts diff --git a/.changes/ui-kit-global-layer.md b/.changes/ui-kit-global-layer.md new file mode 100644 index 000000000..cef96ffda --- /dev/null +++ b/.changes/ui-kit-global-layer.md @@ -0,0 +1,8 @@ +--- +type: fix +area: ui +--- + +Buttons that delete or discard data now show as a red tint with red text +instead of a solid red block, in both themes. The option lists of the Settings +dropdowns use the same compact, bordered panel as the app's menus. diff --git a/apps/electron-backend-e2e/src/menu-keyboard.ts b/apps/electron-backend-e2e/src/menu-keyboard.ts new file mode 100644 index 000000000..3b42d2e62 --- /dev/null +++ b/apps/electron-backend-e2e/src/menu-keyboard.ts @@ -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 { + 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; +} diff --git a/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts index 4ace9dbce..43a799b6d 100644 --- a/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts +++ b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts @@ -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/ }) diff --git a/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts b/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts index 231278419..1a1a8e45b 100644 --- a/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts +++ b/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts @@ -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'); diff --git a/apps/electron-backend-e2e/src/theme-tokens.e2e.ts b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts index 4db1cecd8..fb368c4fc 100644 --- a/apps/electron-backend-e2e/src/theme-tokens.e2e.ts +++ b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts @@ -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 { - 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 { + 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 { @@ -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 }) diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index f45f696a8..891c238fd 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -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 { diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index c196cd71e..11d43ed18 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -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: ``. 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. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index cbe3c7595..c9455db57 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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 `