From 572034f3bed006f111d4b3d5b74b7e0b3bf977bc Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:02:50 +0200 Subject: [PATCH] fix(ui): declare Material system tokens and migrate dead --mdc overrides (#1775) --- .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 | 196 ++++++++++++++++++ apps/web/src/m3-theme.scss | 87 +++++++- apps/web/src/styles.scss | 19 +- 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 | 25 +-- .../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 | 86 ++++++++ .../check-material-token-overrides.test.mjs | 78 +++++++ tools/performance/journey-baselines.json | 6 +- 28 files changed, 581 insertions(+), 88 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 f5944354e..f86e9c4be 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 + # Nx rejects a non-parallel task with continuous dependencies, and # the Playwright plugin infers serve dependencies only when CI is # unset, so this checks both the local and the CI inference. 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..bcadf0f9d --- /dev/null +++ b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts @@ -0,0 +1,196 @@ +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); +} + +/** 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); +} + +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' + ); + + // The dark surface belongs to the app host. Components that + // carry `dark-theme` for its tokens (the fullscreen channel + // panel, the diagnostic's alternative sources) keep their own. + const backgrounds = await page.evaluate(() => { + const nested = document.createElement('div'); + nested.className = 'dark-theme'; + nested.style.background = 'rgb(22, 27, 36)'; + document.body.appendChild(nested); + const result = { + nested: getComputedStyle(nested).backgroundColor, + body: getComputedStyle(document.body).backgroundColor, + }; + nested.remove(); + return result; + }); + expect(backgrounds.nested, `nested in ${theme} theme`).toBe( + 'rgb(22, 27, 36)' + ); + if (theme === 'dark') { + expect(backgrounds.body).toBe( + await resolveColor(page, 'var(--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); + + // The typography tokens carry the font stack through + // --app-font-family. A dangling var() voids the whole `font` + // shorthand; body text would still inherit the same family, + // so check the token's own size and weight. + expect( + await page.evaluate(() => { + const probe = document.createElement('span'); + probe.style.font = 'var(--mat-sys-label-small)'; + document.body.appendChild(probe); + const style = getComputedStyle(probe); + const font = { + family: style.fontFamily, + size: Math.round(parseFloat(style.fontSize)), + weight: style.fontWeight, + }; + probe.remove(); + return font; + }), + `label-small typography in ${theme} theme` + ).toEqual({ + family: expect.stringMatching(/^"?DM Sans"?,/), + size: 11, + weight: '500', + }); + } + 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..e5190fc8a 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -6,8 +6,16 @@ @include mat.app-background(); // ─── Typography ───────────────────────────────────────────────────────────── -$app-font-stack: +// The stack is declared once on html; every Material typography token and the +// typography hierarchy read it through the variable. Inlined, the 103-char +// stack was copied into ~90 declarations (about 7 KB of the initial CSS). +$app-font-stack-value: "'DM Sans', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; +$app-font-stack: var(--app-font-family); + +html { + --app-font-family: #{$app-font-stack-value}; +} $my-typography: mat.m2-define-typography-config( $font-family: $app-font-stack, @@ -53,6 +61,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 +149,9 @@ html { .dark-theme { @include mat.all-component-colors($dark-theme); + // Only the color roles differ per theme: the elevation shadows are + // the same black in both, so html's declarations already apply here. + @include mat.system-level-colors($dark-theme); // ── Dark graphite theme ──────────────────────────────────────────── // Inspired by both Slack (sidebar distinction) and GitButler (data density) @@ -228,16 +250,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 +286,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..c0c09dafb 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'; @@ -163,8 +164,14 @@ textarea, } // ─── Dark Theme ─────────────────────────────────────────────────────────────── -.dark-theme { +// Only the app host takes the dark surface: components such as the fullscreen +// channel panel and the playback diagnostic's alternative sources carry +// `dark-theme` for its tokens while painting their own surfaces. +body.dark-theme { background: var(--mat-sys-surface) !important; +} + +.dark-theme { color-scheme: dark; // Refined scrollbar for dark mode @@ -236,9 +243,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..6b75089d2 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 @@ -116,13 +117,10 @@ visibility 0s linear 220ms; } -// The controls' glass fill over the video. The panel carries `dark-theme` for its tokens, and the app's global -// `.dark-theme { background: var(--mat-sys-surface) !important }` -// (apps/web/src/styles.scss) would replace this gradient with the flat -// surface — or with no background at all where that token is not defined — -// so the compound selector and `!important` are required to out-rank it. +// The controls' glass fill over the video. The panel carries `dark-theme` for +// its tokens only; the app's dark surface is scoped to body.dark-theme. .fullscreen-channel-panel.dark-theme { - background: rgba(12, 16, 23, 0.72) !important; + background: rgba(12, 16, 23, 0.72); } .fullscreen-channel-panel--open { @@ -253,12 +251,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 251cba78c..92aa55fde 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", "e2e:task-graphs:test": "node --test tools/nx/check-e2e-task-graphs.test.mjs", "e2e:task-graphs:check": "node tools/nx/check-e2e-task-graphs.mjs", "e2e:task-graphs:validate": "pnpm run e2e:task-graphs:test && pnpm run e2e:task-graphs:check", diff --git a/tools/nx/check-material-token-overrides.mjs b/tools/nx/check-material-token-overrides.mjs new file mode 100644 index 000000000..8c21a3ffb --- /dev/null +++ b/tools/nx/check-material-token-overrides.mjs @@ -0,0 +1,86 @@ +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; +} + +/** Tracked files under `rootDir` that the guard reads, at any depth. */ +export function listScannedFiles(rootDir) { + // No shell: `cmd.exe` treats single quotes as literal characters, so a + // POSIX-quoted pathspec reaches git intact on Windows and matches nothing. + return execFileSync('git', ['ls-files', ...SCANNED_PATHSPECS], { + cwd: rootDir, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }) + .trim() + .split('\n') + .filter(Boolean) + .filter(isScanned); +} + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === + path.resolve(fileURLToPath(import.meta.url)); + +if (isMain) { + const rootDir = process.cwd(); + const files = listScannedFiles(rootDir); + + 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..7025fb060 --- /dev/null +++ b/tools/nx/check-material-token-overrides.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { + findDeadMaterialTokens, + listScannedFiles, +} 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), []); +}); + +test('selects tracked app and lib sources at any depth, but not specs', async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'mat-token-guard-')); + const git = (...args) => + execFileSync('git', args, { cwd: rootDir, stdio: 'pipe' }); + try { + git('init', '-q'); + const files = { + 'libs/ui/feature/src/lib/deep/panel.component.scss': '', + 'libs/ui/feature/src/lib/deep/panel.component.html': '', + 'libs/ui/feature/src/lib/deep/panel.component.ts': '', + 'libs/ui/feature/src/lib/deep/panel.component.spec.ts': '', + 'apps/web/src/styles.scss': '', + 'apps/web/src/vendor.css': '', + 'tools/outside.scss': '', + }; + for (const [file, content] of Object.entries(files)) { + await mkdir(path.dirname(path.join(rootDir, file)), { + recursive: true, + }); + await writeFile(path.join(rootDir, file), content); + } + git('add', '.'); + // Untracked files are not part of the checkout CI sees. + await writeFile(path.join(rootDir, 'apps/web/src/untracked.scss'), ''); + + assert.deepEqual(listScannedFiles(rootDir).sort(), [ + 'apps/web/src/styles.scss', + 'apps/web/src/vendor.css', + 'libs/ui/feature/src/lib/deep/panel.component.html', + 'libs/ui/feature/src/lib/deep/panel.component.scss', + 'libs/ui/feature/src/lib/deep/panel.component.ts', + ]); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } +}); diff --git a/tools/performance/journey-baselines.json b/tools/performance/journey-baselines.json index b1f555bc3..56c319574 100644 --- a/tools/performance/journey-baselines.json +++ b/tools/performance/journey-baselines.json @@ -3,11 +3,11 @@ "journeys": { "launch": { "renderer.initialBytes": { - "value": 1598232, + "value": 1605046, "unit": "bytes", "slack": 4096, - "updatedAt": "2026-09-27", - "evidencePr": 1734, + "updatedAt": "2026-10-01", + "evidencePr": 1775, "measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes" } }