fix(ui): declare Material system tokens and migrate dead --mdc overrides (#1775)

This commit is contained in:
4gray authored and GitHub committed 2026-10-01 18:02:50 +02:00
1 parent e4cf48fdc2
commit 572034f3be
28 files changed
+581 -88

No files matched your search

+6
View File
@@ -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.
+6 -3
View File
@@ -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.
+3
View File
@@ -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.
@@ -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<number> {
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<string> {
return page.evaluate((css) => {
const probe = document.createElement('div');
probe.style.color = css;
document.body.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
}, value);
}
async function systemVariable(page: Page, name: string): Promise<string> {
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<string, string> = {};
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);
}
});
});
+77 -10
View File
@@ -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;
+15 -4
View File
@@ -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
+19 -7
View File
@@ -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.
@@ -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
@@ -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;
@@ -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;
@@ -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 {
@@ -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;
}
@@ -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));
}
}
@@ -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));
}
@@ -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));
}
}
@@ -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 {
@@ -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;
@@ -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;
@@ -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 ────────────────────────────────────────────────────────────────
@@ -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 {
@@ -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,
@@ -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;
@@ -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;
@@ -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;
+3
View File
@@ -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",
@@ -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.`
);
}
}
@@ -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 });
}
});
+3 -3
View File
@@ -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"
}
}