mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
adb4889b0f
commit
cccb7bff1c
27 files changed
+448
-77
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-2
@@ -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;
|
||||
|
||||
+8
-3
@@ -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 {
|
||||
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
|
||||
+2
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-2
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
+10
-6
@@ -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 {
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
-2
@@ -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;
|
||||
|
||||
@@ -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), []);
|
||||
});
|
||||
Reference in new issue
Block a user