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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-01 06:43:00 +02:00
1 parent adb4889b0f
commit cccb7bff1c
27 files changed
+448 -77

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
- name: Typecheck web and Electron entry points
run: pnpm run typecheck:ci
@@ -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<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);
}
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'
);
// 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);
}
});
});
+67 -9
View File
@@ -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;
+8 -3
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';
@@ -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
+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
@@ -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 {
@@ -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",
"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",
@@ -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.`
);
}
}
@@ -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), []);
});