From cccb7bff1cc641c029b3911c86c1bbc974bfc42b Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 30 Sep 2026 22:17:21 +0200 Subject: [PATCH] fix(ui): declare Material system tokens and migrate dead --mdc overrides The theme is built with the legacy mat.define-theme config, whose component mixins never declare the --mat-sys-* system variables, so every app style that read one resolved to nothing. Declare them with mat.system-level-* for the light (html) and dark (.dark-theme) contexts. Material 22 reads only --mat-* component tokens, so the remaining --mdc-* overrides compiled but did nothing. Move them to the mat.*-overrides() mixins (which reject unknown names at build time) and add a CI guard, styles:material-tokens:validate, that rejects retired --mdc-* names. Where a now-resolving token changed a shipped surface for the worse, keep the shipped look: - form fields keep the theme's outline roles (the hairline app token gave inputs a 1.4:1 boundary) and default text sizes (a 13px field floated a 10px label); - M3U and collection live views and the radio hero sit on --app-content-bg like the Xtream/Stalker live views, instead of the M3 neutral surface; - the rail brand stays untiled. Adds the global .app-destructive-button class (adopted by the follow-up confirmation change) and the theme-tokens Electron E2E, and updates the token policy in the UI guidelines and the theme skill. Co-Authored-By: Claude Opus 5.5 --- .changes/ui-material-system-tokens.md | 6 + .codex/skills/iptvnator-theme-style/SKILL.md | 9 +- .github/workflows/ci.yml | 3 + .../src/theme-tokens.e2e.ts | 135 ++++++++++++++++++ apps/web/src/m3-theme.scss | 76 ++++++++-- apps/web/src/styles.scss | 11 +- docs/architecture/iptvnator-ui-guidelines.md | 26 +++- .../video-player/video-player.component.scss | 12 +- .../category-content-view.component.scss | 9 +- .../src/lib/download-library.component.scss | 3 +- .../unified-collection-page.component.scss | 11 +- .../unified-live-tab.component.scss | 8 +- .../category-management-dialog.component.scss | 3 +- .../channel-details-dialog.component.scss | 9 +- .../group-management-dialog.component.scss | 3 +- .../src/lib/resizable/resizable.scss | 10 +- .../epg-item-description.component.scss | 2 - .../epg-progress-panel.component.scss | 9 +- .../audio-player/audio-player.component.scss | 34 +++-- .../fullscreen-channel-panel.component.scss | 16 ++- .../playback-diagnostic-panel.component.scss | 4 +- ...layback-navigation-controls.component.scss | 3 +- .../lib/rails/dashboard-rail.component.scss | 3 +- .../workspace-shell-rail.component.scss | 2 - package.json | 3 + tools/nx/check-material-token-overrides.mjs | 81 +++++++++++ .../check-material-token-overrides.test.mjs | 34 +++++ 27 files changed, 448 insertions(+), 77 deletions(-) create mode 100644 .changes/ui-material-system-tokens.md create mode 100644 apps/electron-backend-e2e/src/theme-tokens.e2e.ts create mode 100644 tools/nx/check-material-token-overrides.mjs create mode 100644 tools/nx/check-material-token-overrides.test.mjs diff --git a/.changes/ui-material-system-tokens.md b/.changes/ui-material-system-tokens.md new file mode 100644 index 000000000..8c5c7f868 --- /dev/null +++ b/.changes/ui-material-system-tokens.md @@ -0,0 +1,6 @@ +--- +type: fix +area: ui +--- + +Several theme styles that had silently stopped applying are back in both light and dark themes: rounded input fields, the red settings-error toast, panel borders and separators, and surface colors in lists, menus and dialogs. diff --git a/.codex/skills/iptvnator-theme-style/SKILL.md b/.codex/skills/iptvnator-theme-style/SKILL.md index 1f4e62418..bd52b37b7 100644 --- a/.codex/skills/iptvnator-theme-style/SKILL.md +++ b/.codex/skills/iptvnator-theme-style/SKILL.md @@ -25,9 +25,12 @@ consumers currently use relative `@use` paths to the needed partial. - Use `--app-selection-on-color` for foregrounds placed on the selection accent; do not assume white has enough contrast in both themes. - Angular Material mixins and Material-component overrides may use Material - tokens. Outside Material-owned components, use a `--mat-sys-*` token only - after proving it is emitted in both light and dark contexts and supplying a - real app-token or literal fallback. + tokens. `m3-theme.scss` declares `--mat-sys-*` for both theme contexts; use + them outside Material components only for roles without an app token. +- Set component tokens through `mat.*-overrides()`; retired `--mdc-*` names + do nothing and `pnpm run styles:material-tokens:validate` rejects them. +- Destructive buttons use `.app-destructive-button` (`color="warn"` is a no-op + with M3). - Local semantic status colors are acceptable. Existing hard-coded layout, selection, and EPG surface colors are migration debt, not precedent. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b05476d9c..79b7e6d4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -375,6 +375,9 @@ jobs: - name: Validate stylesheet Nx inputs run: pnpm run styles:inputs:validate + - name: Validate Angular Material token overrides + run: pnpm run styles:material-tokens:validate + - name: Typecheck web and Electron entry points run: pnpm run typecheck:ci diff --git a/apps/electron-backend-e2e/src/theme-tokens.e2e.ts b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts new file mode 100644 index 000000000..ffa1e4006 --- /dev/null +++ b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts @@ -0,0 +1,135 @@ +import type { Locator, Page } from '@playwright/test'; +import { + closeElectronApp, + expect, + launchElectronApp, + openSettings, + test, +} from './electron-test-fixtures'; +import { applyTheme } from './theme-contrast'; + +/** + * WCAG contrast between an element's outline colour and the fill inside it, + * both composited over the backgrounds behind the field. + */ +async function outlineContrast( + outline: Locator, + fillSelector: string +): Promise { + return outline.evaluate((el, selector) => { + const ctx = document.createElement('canvas').getContext('2d')!; + const field = el.closest('mat-form-field')!; + const layers = [ + getComputedStyle(field.querySelector(selector)!).backgroundColor, + ]; + for (let node = field.parentElement; node; node = node.parentElement) { + layers.unshift(getComputedStyle(node).backgroundColor); + } + const paint = (colors: string[]) => { + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, 1, 1); + for (const color of colors) { + ctx.fillStyle = color; + ctx.fillRect(0, 0, 1, 1); + } + const [r, g, b] = Array.from( + ctx.getImageData(0, 0, 1, 1).data.slice(0, 3) + ).map((channel) => { + const c = channel / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const fill = paint(layers); + const line = paint([...layers, getComputedStyle(el).borderTopColor]); + return (Math.max(fill, line) + 0.05) / (Math.min(fill, line) + 0.05); + }, fillSelector); +} + +async function systemVariable(page: Page, name: string): Promise { + return page.evaluate( + (variable) => + getComputedStyle(document.body).getPropertyValue(variable).trim(), + name + ); +} + +test.describe('Theme tokens', () => { + test('@theme @electron declares Material system variables and applies app token overrides in both themes', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + try { + await openSettings(page); + + const surfaces: Record = {}; + for (const theme of ['light', 'dark'] as const) { + await applyTheme(page, theme); + + // The legacy define-theme config never emits these; app + // styles that read them used to resolve to nothing. + for (const variable of [ + '--mat-sys-surface', + '--mat-sys-on-surface', + '--mat-sys-outline-variant', + '--mat-sys-error', + '--mat-sys-corner-medium', + '--mat-sys-body-medium', + ]) { + expect( + await systemVariable(page, variable), + `${variable} in ${theme} theme` + ).not.toBe(''); + } + surfaces[theme] = await systemVariable( + page, + '--mat-sys-surface' + ); + + // Form-field overrides must reach Material's --mat-* tokens + // (the retired --mdc-* names were ignored): the outline takes + // the app's 10px shape instead of the 4px M3 default. + const outline = page + .locator( + 'mat-form-field.mat-form-field-appearance-outline .mdc-notched-outline__leading' + ) + .first(); + await expect(outline).toBeVisible(); + await expect + .poll(() => + outline.evaluate( + (el) => getComputedStyle(el).borderTopLeftRadius + ) + ) + .toBe('10px'); + // The outline is the field's only boundary (WCAG 1.4.11). + expect( + await outlineContrast( + outline, + '.mat-mdc-text-field-wrapper' + ), + `field outline contrast in ${theme} theme` + ).toBeGreaterThanOrEqual(3); + + // A floated label inherits the field's text size and renders + // at 75% of it; it must stay legible. + const floated = page + .locator('mat-form-field .mdc-floating-label--float-above') + .first(); + await expect(floated).toBeVisible(); + expect( + await floated.evaluate((el) => { + const style = getComputedStyle(el); + const scale = new DOMMatrixReadOnly(style.transform).a; + return parseFloat(style.fontSize) * scale; + }), + `floated label size in ${theme} theme` + ).toBeGreaterThanOrEqual(11); + } + expect(surfaces['light']).not.toBe(surfaces['dark']); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index 235010a23..6acf2e08e 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -53,6 +53,17 @@ $dark-theme: mat.define-theme( html { @include mat.all-component-themes($light-theme); + // The theme is built with the legacy `define-theme` config, whose + // component mixins emit resolved component tokens but never declare the + // `--mat-sys-*` system variables. App styles reference those variables + // directly, so declare them explicitly for each theme context; without + // this every such reference silently resolves to nothing. + @include mat.system-level-colors($light-theme); + @include mat.system-level-typography($light-theme); + @include mat.system-level-elevation($light-theme); + @include mat.system-level-shape($light-theme); + @include mat.system-level-state($light-theme); + // ── Light theme design tokens ───────────────────────────────────────── --app-shell-bg: #eef0f3; --app-rail-bg: #eef0f3; @@ -130,6 +141,8 @@ html { .dark-theme { @include mat.all-component-colors($dark-theme); + @include mat.system-level-colors($dark-theme); + @include mat.system-level-elevation($dark-theme); // ── Dark graphite theme ──────────────────────────────────────────── // Inspired by both Slack (sidebar distinction) and GitButler (data density) @@ -228,16 +241,26 @@ $app-refined-input-density: mat.define-theme(( html { @include mat.form-field-density($app-refined-input-density); + // Declared on the field itself (not on html) so the var(--app-*) values + // resolve inside .dark-theme instead of being inherited as light values. + // The resting and hover outlines keep the theme's outline roles: the app + // hairline tokens fall far under the 3:1 boundary contrast a field needs. + // Labels use the body token, which clears 4.5:1 in both themes. + // Text sizes stay on the theme defaults: this theme leaves the floated + // label's size token undeclared, so it inherits the field's text size and + // scales it to 75%; a 13px field would float a 10px label. .mat-mdc-form-field { - --mdc-outlined-text-field-outline-color: var(--app-widget-header-border); - --mdc-outlined-text-field-hover-outline-color: var(--app-muted-color); - --mdc-outlined-text-field-focus-outline-color: var(--app-selection-color); - --mdc-outlined-text-field-container-shape: 10px; - --mdc-outlined-text-field-label-text-color: var(--app-muted-color); - --mdc-outlined-text-field-input-text-color: var(--app-heading-color); - --mdc-outlined-text-field-caret-color: var(--app-selection-color); - --mdc-outlined-text-field-label-text-size: 13px; - --mdc-outlined-text-field-input-text-size: 13.5px; + @include mat.form-field-overrides( + ( + outlined-focus-outline-color: var(--app-selection-color), + outlined-container-shape: 10px, + outlined-label-text-color: var(--app-body-color), + outlined-hover-label-text-color: var(--app-body-color), + outlined-focus-label-text-color: var(--app-selection-color), + outlined-input-text-color: var(--app-heading-color), + outlined-caret-color: var(--app-selection-color), + ) + ); } // Inputs sit slightly above the surrounding surface. color-mix is inlined @@ -254,6 +277,41 @@ html { font-size: 13.5px; } + // ─── Destructive actions ─────────────────────────────────────────────── + // Material only emits `.mat-warn` button colors for M2 themes, so + // `color="warn"` 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. + .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), + ) + ); + @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), + ) + ); + } + .mat-mdc-form-field-subscript-wrapper { font-size: 11px; letter-spacing: 0.01em; diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 8b326b5a0..03382e810 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; @use '../../../libs/ui/components/src/lib/resizable/resizable.scss'; @use './settings-theme'; @use './cover-size'; @@ -236,9 +237,13 @@ textarea, // Failures (settings could not be saved/loaded) must not read as the usual // neutral confirmation toast. .mat-mdc-snack-bar-container.settings-snackbar--error { - --mdc-snackbar-container-color: var(--mat-sys-error-container); - --mdc-snackbar-supporting-text-color: var(--mat-sys-on-error-container); - --mat-snack-bar-button-color: var(--mat-sys-on-error-container); + @include mat.snack-bar-overrides( + ( + container-color: var(--mat-sys-error-container), + supporting-text-color: var(--mat-sys-on-error-container), + button-color: var(--mat-sys-on-error-container), + ) + ); } // WCAG-friendly visually-hidden helper — content stays in the diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 20ebe310c..877f48b17 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -68,14 +68,26 @@ in `apps/web/src/m3-theme.scss`): Angular Material mixins and Material-component overrides may use the tokens owned by that component. Outside a Material-owned component, prefer the -app-owned tokens above. A `--mat-sys-*` reference is acceptable there only -after the built light and dark theme contexts both prove that it is emitted, -and it must still have a real app-token or literal fallback, for example: -`var(--mat-sys-surface-container, var(--app-widget-bg))`. +app-owned tokens above. -Several existing app surfaces still reference Material system tokens without -that proof or use hard-coded layout/selection colors. Treat those references -as migration debt, not patterns to copy. +Both themes are built with the legacy `mat.define-theme` config, whose +component mixins never declare the `--mat-sys-*` system variables. The theme +therefore adds the `mat.system-level-*` mixins for the light (`html`) and dark +(`.dark-theme`) contexts, and `apps/electron-backend-e2e/src/theme-tokens.e2e.ts` +asserts they resolve in both. Use a `--mat-sys-*` token for Material-derived +roles that have no app token (error, outline, surface containers); keep app +chrome on `--app-*`. + +Set Material component tokens through the component's `mat.*-overrides()` +mixin: it rejects unknown names at build time, where a hand-written `--mat-*` +declaration with a typo fails silently. Material 22 reads only `--mat-*` +tokens, so the retired `--mdc-*` names compile but do nothing; +`pnpm run styles:material-tokens:validate` (CI) rejects them. A stylesheet that +a spec loads as raw CSS cannot use Sass modules; it declares the `--mat-*` +token directly and says why. + +Existing hard-coded layout and selection colors are migration debt, not +patterns to copy. Do not hardcode unrelated accent colors for selected state when these tokens already exist. diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss index 1e3516285..f47a0cce7 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss @@ -4,7 +4,7 @@ :host { min-height: 0; - background: var(--mat-sys-surface); + background: var(--app-content-bg); } .sidebar { @@ -40,14 +40,14 @@ .content-container { min-width: 0; - background: var(--mat-sys-surface); + background: var(--app-content-bg); } .video-player { background: #000; &:has(> app-audio-player) { - background: var(--mat-sys-surface); + background: var(--app-content-bg); } > app-audio-player, @@ -61,8 +61,8 @@ } .epg { - border-top: 1px solid var(--mat-sys-outline-variant); - background: var(--mat-sys-surface); + border-top: 1px solid var(--app-separator); + background: var(--app-content-bg); } .epg-content { @@ -126,7 +126,7 @@ // whole point of the bottom-drawer layout, so release the cap. max-height: none; border-right: none; - border-top: 1px solid var(--mat-sys-outline-variant); + border-top: 1px solid var(--app-separator); border-bottom: none; // Sidebar collapse uses width=0 on desktop; on mobile the rail flips diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss index 65143b8f9..928cb24f9 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; @use '../../../../../../ui/styles/panel-header' as panel; :host { @@ -74,8 +75,12 @@ min-height: 32px; border-radius: 999px; color: var(--app-body-color, var(--mat-sys-on-surface-variant)); - --mdc-text-button-label-text-color: currentColor; - --mat-text-button-state-layer-color: currentColor; + @include mat.button-overrides( + ( + text-label-text-color: currentColor, + text-state-layer-color: currentColor, + ) + ); mat-icon { width: 18px; diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.scss b/libs/portal/downloads/feature/src/lib/download-library.component.scss index 015d10b6f..f6ef58daf 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.scss +++ b/libs/portal/downloads/feature/src/lib/download-library.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; @use '../../../../shared/ui/src/lib/styles/content-grid' as grid; :host { @@ -153,7 +154,7 @@ height: 32px; padding: 4px; color: #fff; - --mdc-icon-button-state-layer-size: 32px; + @include mat.icon-button-overrides((state-layer-size: 32px)); &::before { position: absolute; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss index 2f4a35c8b..ee656f062 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; @use '../../styles/skeleton'; :host { @@ -42,9 +43,13 @@ left: 0; right: 0; bottom: -1px; - --mdc-linear-progress-track-height: 2px; - --mdc-linear-progress-active-indicator-height: 2px; - --mdc-linear-progress-active-indicator-color: var(--app-selection-color); + @include mat.progress-bar-overrides( + ( + track-height: 2px, + active-indicator-height: 2px, + active-indicator-color: var(--app-selection-color), + ) + ); } .collection-content { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss index 601b4957f..882e9482d 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss @@ -18,7 +18,7 @@ } .content-container { - background: var(--mat-sys-surface); + background: var(--app-content-bg); &:has(> app-portal-empty-state) { align-items: center; @@ -33,7 +33,7 @@ &--radio { position: relative; - background: var(--mat-sys-surface); + background: var(--app-content-bg); } > app-vjs-player, @@ -61,8 +61,8 @@ } .epg { - border-top: 1px solid var(--mat-sys-outline-variant); - background: var(--mat-sys-surface); + border-top: 1px solid var(--app-separator); + background: var(--app-content-bg); display: flex; flex-direction: column; } diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss index 3aeb313cb..ac78f2c7d 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; ::ng-deep .mat-mdc-dialog-content { overflow: hidden !important; } @@ -134,7 +135,7 @@ } mat-checkbox { - --mdc-checkbox-state-layer-size: 28px; + @include mat.checkbox-overrides((state-layer-size: 28px)); } } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.scss index 7b53595d4..f030d5d0e 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; :host { display: block; color: var(--app-body-color, var(--mat-sys-on-surface)); @@ -227,8 +228,12 @@ flex-shrink: 0; width: 32px; height: 32px; - --mdc-icon-button-state-layer-size: 32px; - --mdc-icon-button-icon-size: 18px; + @include mat.icon-button-overrides( + ( + state-layer-size: 32px, + icon-size: 18px, + ) + ); color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); } diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.scss b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.scss index 8e12d6b10..176f7964f 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; .mat-mdc-dialog-content { display: flex; gap: 10px; @@ -124,7 +125,7 @@ } mat-checkbox { - --mdc-checkbox-state-layer-size: 28px; + @include mat.checkbox-overrides((state-layer-size: 28px)); } } diff --git a/libs/ui/components/src/lib/resizable/resizable.scss b/libs/ui/components/src/lib/resizable/resizable.scss index 68d6d1d1c..a7b816fa9 100644 --- a/libs/ui/components/src/lib/resizable/resizable.scss +++ b/libs/ui/components/src/lib/resizable/resizable.scss @@ -94,8 +94,8 @@ body.resizing-active { &:active &__line { width: 2px; - background: var(--mdc-theme-primary, #6366f1); - box-shadow: 0 0 12px rgba(99, 102, 241, 0.3); + background: var(--app-selection-color, #2f7bff); + box-shadow: 0 0 12px var(--app-selection-glow, rgba(47, 123, 255, 0.3)); } // Grip dots indicator @@ -129,7 +129,7 @@ body.resizing-active { opacity: 1; span { - background: var(--mdc-theme-primary, #6366f1); + background: var(--app-selection-color, #2f7bff); transform: scale(1.2); } } @@ -161,8 +161,8 @@ body.resizing-active { } &:active &__line { - background: var(--mdc-theme-primary, #6366f1); - box-shadow: 0 0 12px rgba(99, 102, 241, 0.2); + background: var(--app-selection-color, #2f7bff); + box-shadow: 0 0 12px var(--app-selection-glow, rgba(47, 123, 255, 0.2)); } &__grip { diff --git a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss index c24280448..b6b926e49 100644 --- a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss +++ b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss @@ -247,8 +247,6 @@ panel class from EPG_PROGRAMME_DIALOG_CONFIG so no other dialog loses its surface while this one is open. */ ::ng-deep .epg-programme-dialog-panel .mat-mdc-dialog-container { - --mdc-dialog-container-color: transparent; - .mat-mdc-dialog-surface, .mdc-dialog__surface { background: transparent !important; diff --git a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss index 66c053a5f..52eadd600 100644 --- a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss +++ b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; :host { // Theme-aware tokens — map to app design tokens --epg-panel-bg: var(--app-widget-bg); @@ -28,8 +29,12 @@ // but fits inside the row padding (6px on each side, the tightest row has // 8px), so touch and motor-impaired users keep a forgiving hit area. @mixin compact-icon-button($size, $icon-size) { - --mdc-icon-button-state-layer-size: #{$size}; - --mdc-icon-button-icon-size: #{$icon-size}; + @include mat.icon-button-overrides( + ( + state-layer-size: $size, + icon-size: $icon-size, + ) + ); width: $size; height: $size; padding: 0; 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 148235b42..919f3991c 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 @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; // ─── Radio Player — cinematic hero layout ──────────────────────────────────── // Mirrors the content-hero/vod-details pattern: blurred backdrop, vignette, // floating artwork + controls centred in the available space. @@ -15,7 +16,7 @@ width: 100%; height: 100%; overflow: hidden; - background: var(--mat-sys-surface); + background: var(--app-content-bg); } // ─── Blurred backdrop (station logo, blown up) ────────────────────────────── @@ -205,13 +206,13 @@ // 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. .play-btn { - --mdc-fab-container-color: var(--app-selection-color, var(--mat-sys-primary)); - --mat-fab-container-color: var(--app-selection-color, var(--mat-sys-primary)); - --mdc-fab-icon-color: #fff; - --mat-fab-foreground-color: #fff; - --mat-fab-icon-color: #fff; - --mat-fab-container-shape: 50%; - --mdc-fab-container-shape: 50%; + @include mat.fab-overrides( + ( + container-color: var(--app-selection-color, var(--mat-sys-primary)), + foreground-color: #fff, + container-shape: 50%, + ) + ); box-shadow: none !important; transition: transform 0.2s ease; @@ -243,13 +244,16 @@ .vol-slider { flex: 1; - --mdc-slider-active-track-color: var(--app-selection-color, var(--mat-sys-primary)); - --mdc-slider-handle-color: var(--app-selection-color, var(--mat-sys-primary)); - --mat-slider-active-track-color: var(--app-selection-color, var(--mat-sys-primary)); - --mat-slider-handle-color: var(--app-selection-color, var(--mat-sys-primary)); - --mdc-slider-inactive-track-color: var(--mat-sys-outline-variant); - --mdc-slider-handle-width: 12px; - --mdc-slider-handle-height: 12px; + @include mat.slider-overrides( + ( + active-track-color: + var(--app-selection-color, var(--mat-sys-primary)), + handle-color: var(--app-selection-color, var(--mat-sys-primary)), + inactive-track-color: var(--mat-sys-outline-variant), + handle-width: 12px, + handle-height: 12px, + ) + ); } // ─── Entrance ──────────────────────────────────────────────────────────────── diff --git a/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.scss b/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.scss index f97d532cf..71803f4d1 100644 --- a/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.scss +++ b/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; // Global styles on purpose (ViewEncapsulation.None): the host page's list is // stamped into the panel body through an ng-template, so its nodes carry the // host's scoping attribute and an emulated `.body > *` rule could never reach @@ -253,12 +254,15 @@ padding: 4px; border-radius: 10px; color: #e7ecf3; - --mdc-icon-button-icon-color: currentColor; - --mat-icon-button-icon-color: currentColor; - --mat-icon-button-state-layer-size: 32px; - --mat-icon-button-container-shape: 10px; - --mat-icon-button-icon-size: 20px; - --mat-icon-button-hover-state-layer-opacity: 0; + @include mat.icon-button-overrides( + ( + icon-color: currentColor, + state-layer-size: 32px, + container-shape: 10px, + icon-size: 20px, + hover-state-layer-opacity: 0, + ) + ); } .fullscreen-channel-panel__close mat-icon { diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss index d8633cfc7..7ee9db7a5 100644 --- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss @@ -229,7 +229,9 @@ } .web-player-diagnostic__player-card mat-spinner { - --mdc-circular-progress-active-indicator-color: currentColor; + // Plain token instead of mat.progress-spinner-overrides(): the component + // spec loads this file as raw CSS, so it must stay free of Sass modules. + --mat-progress-spinner-active-indicator-color: currentColor; } .web-player-diagnostic__player-card:focus-visible, diff --git a/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.scss b/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.scss index c8fd3ae5a..eb4db7f3e 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.scss +++ b/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; :host { position: absolute; right: 50%; @@ -26,7 +27,7 @@ } .series-playback-navigation-controls__button { - --mdc-icon-button-state-layer-size: 40px; + @include mat.icon-button-overrides((state-layer-size: 40px)); width: 40px; height: 40px; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss index 0816357bb..608f761df 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; :host { display: block; --rail-card-width: var(--cover-rail-width, 172px); @@ -307,7 +308,7 @@ transition: opacity 0.15s ease, transform 0.15s ease; - --mdc-icon-button-state-layer-size: 30px; + @include mat.icon-button-overrides((state-layer-size: 30px)); mat-icon { font-size: 18px; diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.scss b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.scss index 635913d5d..f1076624b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.scss @@ -100,8 +100,6 @@ .brand { margin-bottom: 10px; - background: var(--mat-sys-primary-container); - color: var(--mat-sys-on-primary-container); width: 40px; height: 40px; border-radius: 12px; diff --git a/package.json b/package.json index 7e944d655..1ada0a350 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,9 @@ "styles:inputs:test": "node --test tools/nx/check-stylesheet-inputs.test.mjs", "styles:inputs:check": "node tools/nx/check-stylesheet-inputs.mjs", "styles:inputs:validate": "pnpm run styles:inputs:test && pnpm run styles:inputs:check", + "styles:material-tokens:test": "node --test tools/nx/check-material-token-overrides.test.mjs", + "styles:material-tokens:check": "node tools/nx/check-material-token-overrides.mjs", + "styles:material-tokens:validate": "pnpm run styles:material-tokens:test && pnpm run styles:material-tokens:check", "coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs tools/coverage/e2e-shard-reports.test.mjs tools/coverage/coverage-run-pool.test.mjs tools/coverage/unit-coverage-scope.test.mjs", "coverage:unit:ci": "node tools/coverage/run-tier-a-coverage.mjs", "coverage:merge": "node tools/coverage/merge-coverage.mjs", diff --git a/tools/nx/check-material-token-overrides.mjs b/tools/nx/check-material-token-overrides.mjs new file mode 100644 index 000000000..ed79a6809 --- /dev/null +++ b/tools/nx/check-material-token-overrides.mjs @@ -0,0 +1,81 @@ +import { execFileSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Angular Material 19 renamed every `--mdc-*` component token to `--mat-*`, + * and Material 22 reads none of the old names. An `--mdc-*` declaration + * therefore compiles, looks intentional, and silently does nothing; a + * `var(--mdc-*)` read always resolves to its fallback. Component tokens must + * be set through the `mat.*-overrides()` mixins, which reject unknown names at + * build time. + */ +const DEAD_MATERIAL_TOKEN = /--mdc-[a-z0-9-]+/g; + +/** Source files whose styles or templates can carry component tokens. */ +const SCANNED_PATHSPECS = [ + 'apps/*.scss', + 'apps/*.css', + 'apps/*.ts', + 'apps/*.html', + 'libs/*.scss', + 'libs/*.css', + 'libs/*.ts', + 'libs/*.html', +]; + +/** Tests and this guard's own fixtures may name the retired prefix. */ +function isScanned(file) { + return !/\.(spec|test)\.[cm]?[jt]s$/.test(file); +} + +export function findDeadMaterialTokens(file, source) { + const findings = []; + source.split('\n').forEach((line, index) => { + for (const match of line.matchAll(DEAD_MATERIAL_TOKEN)) { + findings.push({ file, line: index + 1, token: match[0] }); + } + }); + return findings; +} + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === + path.resolve(fileURLToPath(import.meta.url)); + +if (isMain) { + const rootDir = process.cwd(); + // No shell: `cmd.exe` treats single quotes as literal characters, so a + // POSIX-quoted pathspec reaches git intact on Windows and matches nothing. + const files = execFileSync('git', ['ls-files', ...SCANNED_PATHSPECS], { + cwd: rootDir, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }) + .trim() + .split('\n') + .filter(Boolean) + .filter(isScanned); + + const findings = []; + for (const file of files) { + const source = await readFile(path.join(rootDir, file), 'utf8'); + findings.push(...findDeadMaterialTokens(file, source)); + } + + if (findings.length > 0) { + console.error( + 'Retired Angular Material --mdc-* tokens found. Material 22 ignores them; use the matching mat.*-overrides() mixin instead:' + ); + for (const { file, line, token } of findings) { + console.error(`- ${file}:${line} ${token}`); + } + process.exitCode = 1; + } else { + console.log( + `Checked ${files.length} source files; no retired --mdc-* Material tokens remain.` + ); + } +} diff --git a/tools/nx/check-material-token-overrides.test.mjs b/tools/nx/check-material-token-overrides.test.mjs new file mode 100644 index 000000000..e37077365 --- /dev/null +++ b/tools/nx/check-material-token-overrides.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { findDeadMaterialTokens } from './check-material-token-overrides.mjs'; + +test('reports retired --mdc-* declarations and reads with their location', () => { + const source = [ + '.field {', + ' --mdc-outlined-text-field-container-shape: 10px;', + ' color: var(--mdc-theme-primary, #6366f1);', + '}', + ].join('\n'); + + assert.deepEqual(findDeadMaterialTokens('a.scss', source), [ + { + file: 'a.scss', + line: 2, + token: '--mdc-outlined-text-field-container-shape', + }, + { file: 'a.scss', line: 3, token: '--mdc-theme-primary' }, + ]); +}); + +test('accepts current --mat-* tokens and override mixins', () => { + const source = [ + '.field {', + ' @include mat.form-field-overrides((outlined-container-shape: 10px));', + ' --mat-icon-button-state-layer-size: 32px;', + ' color: var(--mat-sys-error);', + '}', + ].join('\n'); + + assert.deepEqual(findDeadMaterialTokens('b.scss', source), []); +});