Files
iptvnator/apps/web/src/m3-theme.scss
T
4grayandClaude Fable 5.1 e6ef8cdd54 perf(web): take Angular Material off the startup path (#1931)
Takes Angular Material off the renderer's startup path: renderer.initialBytes
1,596,201 -> 1,344,067 bytes (-252,134, 15.8%) at the branch point; merged with
master, where #1928 lands the form-field defaults per component, the branch
measures 1,345,807 (-188,797, 12.3% against master's baseline). The bundler
keeps each module in one chunk, so one eager import of any export carries the
module's whole code; each item removes the last eager importer of one Material
module.

- EPG progress and update panels: eager host + `@defer (when visible)` view
  with an `@error` notice; the startup spinner is static markup copied from
  mat-spinner.
- LazyMatDialog: the confirm dialog service, player service, parental lock
  prompt and shell actions resolve MatDialog on first use.
- The theme emits tokens for the 21 imported components only, through a
  `$material-components` list and meta.apply; tools/nx/check-material-theme-scope.mjs
  (CI) keeps it in step with the imports in both directions.
- No provideAnimations(): Material 22 animates with CSS and reads
  prefers-reduced-motion itself; apps/electron-backend-e2e/src/material-motion.e2e.ts
  proves dialogs, selects, menus, snack-bars and tooltips animate in and out and
  stop under reduced motion. Its renderer had deferred every DOM removal to the
  end of the tick, so the catalog list now tracks the grid offset from scroll
  events and the player settings panel decides "focus was inside" in a
  microtask, both with specs that detach the view first.
- The snack-bar stays, and with it button, the icon-button chunk and the CDK
  overlay (about 190 KB): root services inject it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 00:08:58 +02:00

428 lines
16 KiB
SCSS

@use 'sass:map';
@use 'sass:meta';
@use '@angular/material' as mat;
@use 'styles';
@include mat.elevation-classes();
@include mat.app-background();
// ─── Typography ─────────────────────────────────────────────────────────────
// 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,
);
@include mat.typography-hierarchy($my-typography);
// ─── M3 Themes ───────────────────────────────────────────────────────────────
// One primary color across the app. Azure is the closest M3 prebuilt to the
// product's "active/selection" blue (#2f7bff / #78adff), so checkboxes, radio
// buttons, raised CTAs, etc. now read as the same blue as rail selection.
//
// Cyan stays on tertiary so the EPG "now" indicator (which uses Material
// tertiary surfaces) keeps its existing visual role.
$light-theme: mat.define-theme(
(
color: (
theme-type: light,
primary: mat.$azure-palette,
tertiary: mat.$cyan-palette,
),
typography: (
plain-family: $app-font-stack,
brand-family: $app-font-stack,
),
)
);
$dark-theme: mat.define-theme(
(
color: (
theme-type: dark,
primary: mat.$azure-palette,
tertiary: mat.$cyan-palette,
),
typography: (
plain-family: $app-font-stack,
brand-family: $app-font-stack,
),
)
);
// ─── Themed components ───────────────────────────────────────────────────────
// Only the Material components the app imports, in Material's own order.
// `mat.all-component-themes` also emitted about 24 KB of tokens for 17
// components nothing imports. Add an entry with a new
// `@angular/material/<entry>` import and remove it with the last one:
// `pnpm run styles:material-theme-scope:validate` (CI) checks both
// directions, and `core` (ripples, options, pseudo-checkboxes) always stays.
$material-components: (
core,
card,
progress-bar,
tooltip,
form-field,
input,
select,
dialog,
slide-toggle,
slider,
menu,
list,
checkbox,
button,
icon-button,
fab,
snack-bar,
progress-spinner,
button-toggle,
divider,
icon
);
@mixin component-themes($theme) {
@each $component in $material-components {
@include meta.apply(
meta.get-mixin('#{$component}-theme', 'mat'),
$theme
);
}
}
@mixin component-colors($theme) {
@each $component in $material-components {
@include meta.apply(
meta.get-mixin('#{$component}-color', 'mat'),
$theme
);
}
}
// ─── Apply Themes ────────────────────────────────────────────────────────────
html {
@include 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;
--app-header-bg: #eef0f3;
--app-content-bg: #f5f6f8;
--app-rail-border: transparent;
--app-on-surface: #1d1b1e;
--app-hover-overlay: rgba(0, 0, 0, 0.06);
--app-search-bg: rgba(255, 255, 255, 0.7);
--app-search-border: rgba(0, 0, 0, 0.1);
--app-selection-color: #2f7bff;
--app-selection-on-color: #eef4ff;
--app-selection-surface: color-mix(
in srgb,
var(--app-selection-color) 14%,
transparent
);
--app-selection-surface-strong: color-mix(
in srgb,
var(--app-selection-color) 22%,
transparent
);
--app-selection-border: color-mix(
in srgb,
var(--app-selection-color) 52%,
transparent
);
--app-selection-glow: color-mix(
in srgb,
var(--app-selection-color) 34%,
transparent
);
// Watch progress in app chrome (rail cards, catalog capsules, the hero).
// Over video the player's fixed `--pc-progress` applies instead.
--app-progress-color: var(--app-selection-color);
// Keyboard focus ring (`libs/ui/styles/_focus-ring.scss`), drawn 2px
// outside the focused element. A deeper blue than the selection colour:
// at least 3.6:1 on every light surface and selection tint, where
// #2f7bff falls to 2.6:1 on the stronger tint.
--app-focus-ring: #1d63e0;
// Live / on-air accent (broadcast indicator)
--app-live-color: #d12c2c;
// Done / watched status (episode watched toggle)
--app-success-color: #15803d;
// Content area
--app-content-shadow: -2px 4px 12px rgba(0, 0, 0, 0.06);
// Widget surfaces — no borders, layered backgrounds
--app-widget-bg: #ffffff;
--app-widget-header-bg: #fafbfc;
--app-widget-border: transparent;
--app-widget-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.04);
--app-widget-header-border: rgba(0, 0, 0, 0.06);
--app-card-hover-bg: #f0f2f5;
--app-separator: rgba(0, 0, 0, 0.14);
// Typography — subdued, not pure black
--app-heading-color: #1a1d23;
--app-body-color: #4a5060;
--app-muted-color: #8891a4;
--app-eyebrow-color: #7c8495;
// Provider accent colors
--app-provider-xtream: #2f7bff;
--app-provider-stalker: #10b981;
--app-provider-m3u: #d97706;
// Subscription-expiry chips (dashboard source cards). Text/surface pairs
// are picked per theme to keep >= 4.5:1 small-text contrast against the
// composited chip background.
--app-expiry-warn-text: #8a5300;
--app-expiry-warn-surface: color-mix(in srgb, #d97706 16%, transparent);
--app-expiry-expired-text: #b3261e;
--app-expiry-expired-surface: color-mix(in srgb, #f44336 14%, transparent);
// Hero
--app-accent-gradient: linear-gradient(
135deg,
rgba(47, 123, 255, 0.08) 0%,
rgba(92, 214, 255, 0.05) 100%
);
--app-hero-surface: rgba(47, 123, 255, 0.04);
// Detail / hero call to action. The dark theme's light primary (#f2f4f8
// on #0a0e14) inverts to a dark button on the light theme, so it reads as
// the one solid CTA on either page background.
--app-cta-bg: #1a1d23;
--app-cta-fg: #f5f6f8;
--app-cta-hover-bg: #2a2f3a;
--app-cta-meta-fg: rgba(245, 246, 248, 0.68);
// Star rating chip: a deep amber, since the chip's 12px text needs
// 4.5:1 over hero artwork under the light scrim (#a16207 measured 3.4:1).
--app-rating-color: #7a4a00;
.dark-theme {
@include 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)
// Key principle: clearly distinct surface layers using graphite steps
// Darkest → lightest: #0d0f12 → #141720 → #1c2030 → #222840
// App shell & navigation
--app-shell-bg: #0d0f12;
--app-rail-bg: #111318;
--app-header-bg: #111318;
--app-content-bg: #161a22;
--app-rail-border: transparent;
--app-on-surface: #e6e1e5;
--app-hover-overlay: rgba(255, 255, 255, 0.08);
--app-search-bg: rgba(255, 255, 255, 0.05);
--app-search-border: rgba(255, 255, 255, 0.09);
--app-selection-color: #78adff;
--app-selection-on-color: #071225;
--app-selection-surface: color-mix(
in srgb,
var(--app-selection-color) 18%,
transparent
);
--app-selection-surface-strong: color-mix(
in srgb,
var(--app-selection-color) 28%,
transparent
);
--app-selection-border: color-mix(
in srgb,
var(--app-selection-color) 62%,
transparent
);
--app-selection-glow: color-mix(
in srgb,
var(--app-selection-color) 44%,
transparent
);
// Re-declared so it resolves against this context's selection colour
// rather than inheriting the light value computed on `html`.
--app-progress-color: var(--app-selection-color);
// Keyboard focus ring: at least 4.1:1 on every dark surface and
// selection tint.
--app-focus-ring: #8cbaff;
// Live / on-air accent (broadcast indicator)
--app-live-color: #ff8f8f;
// Done / watched status (episode watched toggle)
--app-success-color: #4ade80;
// Content area
--app-content-shadow: -4px 8px 20px rgba(0, 0, 0, 0.18);
// Widget surfaces — NO border, surface contrast does the work
// Widgets sit on content-bg (#161a22), so widget-bg is notably lighter
--app-widget-bg: #1e2330;
--app-widget-header-bg: #232840;
--app-widget-border: transparent;
--app-widget-shadow: none;
--app-widget-header-border: rgba(255, 255, 255, 0.05);
--app-card-hover-bg: #252b3a;
--app-separator: rgba(255, 255, 255, 0.14);
// Typography — NOT pure white. Warm graphite-tinted whites
--app-heading-color: #d8dce8; // soft off-white, not #fff
--app-body-color: #8b93a8; // muted blue-grey
--app-muted-color: #5c6478; // dimmed
--app-eyebrow-color: #6b7494; // eyebrow labels
// Provider accents (slightly brighter for dark bg)
--app-provider-xtream: #78adff;
--app-provider-stalker: #34d399;
--app-provider-m3u: #fbbf24;
// Subscription-expiry chips — brighter text for the dark surfaces;
// expired matches the --app-live-color accent family.
--app-expiry-warn-text: #fbbf24;
--app-expiry-warn-surface: color-mix(in srgb, #fbbf24 16%, transparent);
--app-expiry-expired-text: #ff8f8f;
--app-expiry-expired-surface: color-mix(
in srgb,
#ff8f8f 16%,
transparent
);
// Hero section
--app-accent-gradient: linear-gradient(
135deg,
rgba(120, 173, 255, 0.12) 0%,
rgba(92, 214, 255, 0.07) 100%
);
--app-hero-surface: rgba(30, 35, 48, 0.9);
// Detail / hero call to action (light primary) and rating accent
--app-cta-bg: #f2f4f8;
--app-cta-fg: #0a0e14;
--app-cta-hover-bg: #ffffff;
--app-cta-meta-fg: #5a6274;
--app-rating-color: #ffcf5c;
}
}
// ─── Global refined outlined inputs ──────────────────────────────────────────
// Applied app-wide AFTER the component themes so these overrides win the
// cascade. Pairs with the outline + dynamic MAT_FORM_FIELD_DEFAULT_OPTIONS
// that provideFormFieldDefaults() (@iptvnator/ui/components) sets on each
// component rendering a form field.
$app-refined-input-density: mat.define-theme((
density: (scale: -2),
));
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 {
@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
// here (not via a custom property on html) so var(--app-widget-bg)
// resolves at the wrapper — inside .dark-theme — rather than being
// locked to the light-mode value at html-level inheritance.
.mat-form-field-appearance-outline .mat-mdc-text-field-wrapper {
background: color-mix(in srgb, var(--app-widget-bg) 85%, white 7%);
border-radius: 10px;
}
.mat-mdc-form-field .mat-mdc-input-element,
.mat-mdc-form-field .mat-mdc-select-value {
font-size: 13.5px;
}
// ─── Destructive actions ───────────────────────────────────────────────
// Material only emits `.mat-warn` button colors for M2 themes, so the
// `warn` color input 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;
margin-top: 2px;
.mat-mdc-form-field-hint-wrapper,
.mat-mdc-form-field-error-wrapper {
padding: 0 12px;
}
.mat-mdc-form-field-hint {
color: var(--app-muted-color);
}
}
}