diff --git a/.changes/performance-zoneless-change-detection.md b/.changes/performance-zoneless-change-detection.md new file mode 100644 index 000000000..85b46ab19 --- /dev/null +++ b/.changes/performance-zoneless-change-detection.md @@ -0,0 +1,8 @@ +--- +type: perf +area: performance +--- + +The app does less background work and starts with less code: screens now +update only when something they show changes, instead of after every timer, +network reply or click anywhere in the app. diff --git a/.changes/ui-kit-global-layer.md b/.changes/ui-kit-global-layer.md new file mode 100644 index 000000000..cef96ffda --- /dev/null +++ b/.changes/ui-kit-global-layer.md @@ -0,0 +1,8 @@ +--- +type: fix +area: ui +--- + +Buttons that delete or discard data now show as a red tint with red text +instead of a solid red block, in both themes. The option lists of the Settings +dropdowns use the same compact, bordered panel as the app's menus. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 098c2e082..67a88f82b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -348,9 +348,12 @@ jobs: --only launch/main.modulesRegisteredBeforeWindow --only launch/renderer.layoutShiftScore --only launch/renderer.layoutShiftScoreSettled + --only launch/renderer.cdTicksToFirstCard + --only launch/renderer.cdTicksIdle30s --only open-source/main.mockHttpRequestsToSettled --only open-source/renderer.ipcCallsToFirstPage --only open-source/renderer.layoutShiftScore + --only open-source/renderer.cdTicksToFirstPage --only playback/renderer.httpRequestsToPlaying --only playback/renderer.layoutShiftScore --only search/renderer.ipcCallsPerSearch diff --git a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts index e50145ea3..8b1fc7488 100644 --- a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts +++ b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts @@ -331,6 +331,7 @@ test('@epg @xtream @electron stacks the programme dialog actions on a phone', as ) as { WORKSPACE: { SHELL: { RAIL_LIVE: string } }; EPG: { + PREVIOUS_DAY: string; PROGRAM_DIALOG: { SHOW_PROGRAM_DETAILS: string }; TIMELINE: { WATCH_FROM_START: string }; }; @@ -365,6 +366,15 @@ test('@epg @xtream @electron stacks the programme dialog actions on a phone', as .first() .click(); const guide = app.mainWindow.locator('app-epg-list-view'); + await expect(guide.locator('[data-when="now"]').first()).toBeVisible(); + // The mock's 30-minute slots run relative to now, so in the first half + // hour after UTC midnight today holds only the programme on air: step + // back a day for a finished one. + if ((await guide.locator('[data-when="past"]').count()) === 0) { + await guide + .getByRole('button', { name: fr.EPG.PREVIOUS_DAY }) + .click(); + } await expect(guide.locator('[data-when="past"]').first()).toBeVisible(); // The window cannot shrink below its desktop minimum, so emulate a diff --git a/apps/electron-backend-e2e/src/menu-keyboard.ts b/apps/electron-backend-e2e/src/menu-keyboard.ts new file mode 100644 index 000000000..3b42d2e62 --- /dev/null +++ b/apps/electron-backend-e2e/src/menu-keyboard.ts @@ -0,0 +1,21 @@ +import { expect, Locator, Page } from '@playwright/test'; + +/** + * Wait until keyboard focus has entered the open `mat-menu`, and return it. + * + * Material moves focus to the first item after the next render, which the + * zoneless scheduler runs on a later task than the keydown that opened the + * menu. The panel is visible before that, so a navigation key sent as soon + * as it appears still reaches the trigger and the menu never sees it. + */ +export async function waitForMenuFocus(page: Page): Promise { + const menu = page.getByRole('menu'); + await expect(menu).toBeVisible(); + await expect + .poll( + () => menu.evaluate((el) => el.contains(document.activeElement)), + { message: 'focus inside the open menu' } + ) + .toBe(true); + return menu; +} diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts index e6bfbdabe..567bf70e8 100644 --- a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -10,7 +10,6 @@ import { JOURNEY_CD_TICK_COUNTER_KEY } from './journey-renderer-probe'; interface TargetConfiguration { configurations?: Record>; - defaultConfiguration?: string; dependsOn?: unknown; executor?: unknown; options?: Record; @@ -163,12 +162,7 @@ test('only the web performance build installs the tick counter the journeys read for (const [name, configuration] of Object.entries( webProject.targets['build'].configurations ?? {} )) { - if ( - name === 'electron-performance' || - name === 'electron-performance-zoneless' - ) { - continue; - } + if (name === 'electron-performance') continue; assert.doesNotMatch( JSON.stringify(configuration['fileReplacements'] ?? []), /environment\.performance/, @@ -186,12 +180,7 @@ test('only development web builds provide the NgRx store devtools', () => { replace: 'apps/web/src/environments/store-devtools.providers.ts', with: 'apps/web/src/environments/store-devtools.providers.dev.ts', }; - // electron-e2e-zoneless is electron-e2e plus the zoneless swap. - const developmentConfigurations = new Set([ - 'development', - 'electron-e2e', - 'electron-e2e-zoneless', - ]); + const developmentConfigurations = new Set(['development', 'electron-e2e']); assert.match( readFileSync(join(environments, 'store-devtools.providers.ts'), 'utf8'), @@ -230,42 +219,23 @@ test('only development web builds provide the NgRx store devtools', () => { } }); -// Plan item C6 measures zoneless change detection behind a build-time flag: -// each *-zoneless configuration is its base configuration plus one swap of -// the change-detection providers, and nothing else selects that swap. -test('the zoneless flag is opt-in through the *-zoneless web configurations only', () => { - const configurations = webProject.targets['build'].configurations ?? {}; - const zonelessReplacement = { - replace: 'apps/web/src/environments/change-detection.providers.ts', - with: 'apps/web/src/environments/change-detection.providers.zoneless.ts', - }; +// Plan item C6: the renderer runs zoneless. A zone.js polyfill or a zone +// provider would bring back a tick after every macrotask and make the +// change-detection tick counters racy again. +test('the web app bootstraps zoneless without the zone.js polyfill', () => { + const build = webProject.targets['build']; + const configurations = Object.entries(build.configurations ?? {}); - for (const base of ['electron-performance', 'electron-e2e']) { - const baseConfiguration = configurations[base]; - const zoneless = configurations[`${base}-zoneless`]; - assert.ok(zoneless, `web:build must define ${base}-zoneless`); - assert.ok(baseConfiguration, `web:build must define ${base}`); - const { fileReplacements: baseReplacements, ...baseRest } = - baseConfiguration; - const { fileReplacements, ...rest } = zoneless; - assert.deepEqual(rest, baseRest, base); - assert.deepEqual(fileReplacements, [ - ...((baseReplacements as unknown[] | undefined) ?? []), - zonelessReplacement, - ]); + assert.equal(build.options?.['polyfills'], undefined); + for (const [name, configuration] of configurations) { + assert.equal(configuration['polyfills'], undefined, name); } - for (const [name, configuration] of Object.entries(configurations)) { - if (name.endsWith('-zoneless')) continue; - assert.doesNotMatch( - JSON.stringify(configuration['fileReplacements'] ?? []), - /change-detection\.providers/, - name - ); - } - assert.equal( - webProject.targets['build'].defaultConfiguration, - 'production' + const appConfig = readFileSync( + join(workspaceRoot, 'apps/web/src/app/app.config.ts'), + 'utf8' ); + assert.match(appConfig, /provideZonelessChangeDetection\(\)/); + assert.doesNotMatch(appConfig, /provideZoneChangeDetection/); }); test('the resolved web build cache output is the renderer directory', () => { diff --git a/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts index 4ace9dbce..43a799b6d 100644 --- a/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts +++ b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts @@ -30,6 +30,7 @@ import { writeTemporaryM3uFile, xtreamMockServer, } from './electron-test-fixtures'; +import { waitForMenuFocus } from './menu-keyboard'; import { routePlayableStreams, startAndConfirmPlayback, @@ -572,8 +573,7 @@ test.describe('Electron Playlist Switcher', () => { await expect(search).toBeFocused(); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Shift+F10'); - const rowMenu = page.locator('.mat-mdc-menu-panel'); - await expect(rowMenu).toBeVisible(); + const rowMenu = await waitForMenuFocus(page); await page.keyboard.press('End'); await expect( rowMenu.getByRole('menuitem', { name: /Remove/ }) diff --git a/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts b/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts index 231278419..1a1a8e45b 100644 --- a/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts +++ b/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts @@ -9,6 +9,7 @@ import { waitForM3uCatalog, writeTemporaryM3uFile, } from './electron-test-fixtures'; +import { waitForMenuFocus } from './menu-keyboard'; // --------------------------------------------------------------------------- // A source row opens from the keyboard. Each row has its own Tab stop: the @@ -217,6 +218,7 @@ test.describe('Electron Sources keyboard', () => { name: 'Remove playlist', }); await expect(remove).toBeVisible(); + await waitForMenuFocus(page); await page.keyboard.press('End'); await expect(remove).toBeFocused(); await page.keyboard.press('Enter'); diff --git a/apps/electron-backend-e2e/src/theme-tokens.e2e.ts b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts index 4db1cecd8..fb368c4fc 100644 --- a/apps/electron-backend-e2e/src/theme-tokens.e2e.ts +++ b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts @@ -47,16 +47,27 @@ async function outlineContrast( }, fillSelector); } -/** Resolves a CSS color (including var() and color-mix()) to rgb()/rgba(). */ -async function resolveColor(page: Page, value: string): Promise { - return page.evaluate((css) => { - const probe = document.createElement('div'); - probe.style.color = css; - document.body.appendChild(probe); - const color = getComputedStyle(probe).color; - probe.remove(); - return color; - }, value); +/** + * Resolves a CSS color (including var() and color-mix()) the way the given + * property serializes it, so it compares with a computed `color` or + * `backgroundColor`. + */ +async function resolveColor( + page: Page, + value: string, + property: 'color' | 'backgroundColor' = 'color' +): Promise { + return page.evaluate( + ({ css, prop }) => { + const probe = document.createElement('div'); + probe.style[prop] = css; + document.body.appendChild(probe); + const color = getComputedStyle(probe)[prop]; + probe.remove(); + return color; + }, + { css: value, prop: property } + ); } async function systemVariable(page: Page, name: string): Promise { @@ -200,7 +211,7 @@ test.describe('Theme tokens', () => { } }); - test('@theme @electron destructive actions name the action and use the error color', async ({ + test('@theme @electron destructive actions name the action and use the danger tint', async ({ dataDir, }) => { const app = await launchElectronApp(dataDir); @@ -211,18 +222,35 @@ test.describe('Theme tokens', () => { for (const theme of ['light', 'dark'] as const) { await applyTheme(page, theme); - const error = await resolveColor(page, 'var(--mat-sys-error)'); + const dangerText = await resolveColor( + page, + 'var(--app-danger-text)' + ); + const dangerSurface = await resolveColor( + page, + 'var(--app-danger-surface)', + 'backgroundColor' + ); const trigger = page.getByRole('button', { name: 'Clear EPG data', exact: true, }); // The `warn` color input was a no-op with M3: the trigger - // rendered in the primary color. + // rendered in the primary color. The kit draws a destructive + // button as a red tint, never a solid error fill, and the + // outlined trigger carries the same tint as a filled one. await expect .poll(() => trigger.evaluate((el) => getComputedStyle(el).color) ) - .toBe(error); + .toBe(dangerText); + await expect + .poll(() => + trigger.evaluate( + (el) => getComputedStyle(el).backgroundColor + ) + ) + .toBe(dangerSurface); await trigger.click(); const dialog = page.locator('mat-dialog-container'); @@ -239,7 +267,12 @@ test.describe('Theme tokens', () => { (el) => getComputedStyle(el).backgroundColor ) ) - .toBe(error); + .toBe(dangerSurface); + await expect + .poll(() => + confirm.evaluate((el) => getComputedStyle(el).color) + ) + .toBe(dangerText); await dialog .getByRole('button', { name: 'Cancel', exact: true }) diff --git a/apps/web/project.json b/apps/web/project.json index 6de9984ff..bed6822c7 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -19,7 +19,6 @@ "base": "dist/apps/web", "browser": "" }, - "polyfills": ["zone.js"], "tsConfig": "apps/web/tsconfig.app.json", "inlineStyleLanguage": "scss", "serviceWorker": "ngsw-config.json", @@ -149,31 +148,6 @@ } ] }, - "electron-performance-zoneless": { - "baseHref": "./", - "serviceWorker": false, - "optimization": { - "scripts": true, - "styles": { - "minify": true, - "inlineCritical": false, - "removeSpecialComments": true - }, - "fonts": true - }, - "outputHashing": "all", - "sourceMap": true, - "fileReplacements": [ - { - "replace": "apps/web/src/environments/environment.ts", - "with": "apps/web/src/environments/environment.performance.ts" - }, - { - "replace": "apps/web/src/environments/change-detection.providers.ts", - "with": "apps/web/src/environments/change-detection.providers.zoneless.ts" - } - ] - }, "electron-e2e": { "baseHref": "./", "serviceWorker": false, @@ -186,23 +160,6 @@ "with": "apps/web/src/environments/store-devtools.providers.dev.ts" } ] - }, - "electron-e2e-zoneless": { - "baseHref": "./", - "serviceWorker": false, - "optimization": false, - "extractLicenses": false, - "sourceMap": true, - "fileReplacements": [ - { - "replace": "apps/web/src/environments/store-devtools.providers.ts", - "with": "apps/web/src/environments/store-devtools.providers.dev.ts" - }, - { - "replace": "apps/web/src/environments/change-detection.providers.ts", - "with": "apps/web/src/environments/change-detection.providers.zoneless.ts" - } - ] } }, "defaultConfiguration": "production" diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 90a0b8c44..a4ba1da64 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -8,7 +8,12 @@ import { FullscreenOverlayContainer, OverlayContainer, } from '@angular/cdk/overlay'; -import { ApplicationConfig, inject, importProvidersFrom } from '@angular/core'; +import { + ApplicationConfig, + inject, + importProvidersFrom, + provideZonelessChangeDetection, +} from '@angular/core'; import { provideRouter, withComponentInputBinding } from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; import { provideEffects } from '@ngrx/effects'; @@ -30,7 +35,6 @@ import { DataService, } from '@iptvnator/services'; import { dbConfig } from '@iptvnator/shared/interfaces'; -import { changeDetectionProviders } from '../environments/change-detection.providers'; import { storeDevtoolsProviders } from '../environments/store-devtools.providers'; import { routes } from './app.routes'; import { ElectronService } from './services/electron.service'; @@ -103,7 +107,7 @@ export function DataFactory() { export const appConfig: ApplicationConfig = { providers: [ - ...changeDetectionProviders, + provideZonelessChangeDetection(), provideRouter(routes, withComponentInputBinding()), // No provideAnimations(): nothing declares an Angular animation // trigger, and Material 22 animates with CSS, reading diff --git a/apps/web/src/app/settings/settings-unload-guard.service.ts b/apps/web/src/app/settings/settings-unload-guard.service.ts index c487f04a3..847b5a661 100644 --- a/apps/web/src/app/settings/settings-unload-guard.service.ts +++ b/apps/web/src/app/settings/settings-unload-guard.service.ts @@ -1,4 +1,4 @@ -import { inject, Injectable, NgZone, OnDestroy } from '@angular/core'; +import { inject, Injectable, OnDestroy } from '@angular/core'; import { AppUpdateInstallService } from '../services/app-update-install.service'; import { SettingsForm } from './settings-form.utils'; @@ -39,7 +39,6 @@ export interface SettingsUnloadGuardHost { */ @Injectable() export class SettingsUnloadGuardService implements OnDestroy { - private readonly zone = inject(NgZone); private readonly installService = inject(AppUpdateInstallService); private host: SettingsUnloadGuardHost | null = null; private unsubscribeCloseRequests: (() => void) | null = null; @@ -78,7 +77,7 @@ export class SettingsUnloadGuardService implements OnDestroy { this.unsubscribeCloseRequests = window.electron?.onWindowCloseRequested?.((requestId) => { this.latestCloseRequestId = requestId; - this.zone.run(() => void this.handleCloseRequest('close')); + void this.handleCloseRequest('close'); }) ?? null; // Whatever surface installs an app update — the settings About @@ -141,9 +140,7 @@ export class SettingsUnloadGuardService implements OnDestroy { this.host = null; } - private readonly beforeUnloadHandler = ( - event: BeforeUnloadEvent - ): void => { + private readonly beforeUnloadHandler = (event: BeforeUnloadEvent): void => { if (!this.host?.form.dirty) { return; } @@ -160,7 +157,7 @@ export class SettingsUnloadGuardService implements OnDestroy { // handler entirely — so this can only be a reload: ask, then // re-trigger it. setTimeout(() => { - this.zone.run(() => void this.handleCloseRequest('reload')); + void this.handleCloseRequest('reload'); }); } }; diff --git a/apps/web/src/environments/change-detection-tick-counter.ts b/apps/web/src/environments/change-detection-tick-counter.ts index 98e759546..071fecec2 100644 --- a/apps/web/src/environments/change-detection-tick-counter.ts +++ b/apps/web/src/environments/change-detection-tick-counter.ts @@ -9,11 +9,11 @@ import { ApplicationRef } from '@angular/core'; * * Angular's own hook, `ɵsetProfiler`, is reachable only through the dev-mode * `window.ng` global, which the optimized build does not publish. Every - * tick, whether scheduled by zone.js (`onMicrotaskEmpty`), the zoneless - * scheduler or an explicit `ApplicationRef.tick()`, goes through the - * internal `ApplicationRef._tick`, which is also where Angular emits the - * profiler's `ChangeDetectionStart`. Counting its calls therefore matches - * the profiler's count and stays comparable across the zoneless migration. + * tick, whether scheduled by the zoneless scheduler, an `afterNextRender` + * hook or an explicit `ApplicationRef.tick()`, goes through the internal + * `ApplicationRef._tick`, which is also where Angular emits the profiler's + * `ChangeDetectionStart`. Counting its calls therefore matches the + * profiler's count; it did so under zone.js too, before the zoneless flip. * Contract: docs/architecture/performance-journeys.md. */ export const CHANGE_DETECTION_TICK_COUNTER_KEY = '__iptvnatorCdTicks'; diff --git a/apps/web/src/environments/change-detection.providers.ts b/apps/web/src/environments/change-detection.providers.ts deleted file mode 100644 index d0cf2caa0..000000000 --- a/apps/web/src/environments/change-detection.providers.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { - EnvironmentProviders, - provideZoneChangeDetection, -} from '@angular/core'; - -// Change detection for every build: zone.js schedules the ticks. The -// *-zoneless build configurations replace this file with -// change-detection.providers.zoneless.ts while plan item C6 measures -// zoneless change detection; see docs/architecture/zoneless-migration.md. -export const changeDetectionProviders: EnvironmentProviders[] = [ - provideZoneChangeDetection({ eventCoalescing: true }), -]; diff --git a/apps/web/src/environments/change-detection.providers.zoneless.ts b/apps/web/src/environments/change-detection.providers.zoneless.ts deleted file mode 100644 index 30e71ff06..000000000 --- a/apps/web/src/environments/change-detection.providers.zoneless.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { - EnvironmentProviders, - provideZonelessChangeDetection, -} from '@angular/core'; - -// Swapped in for change-detection.providers.ts by the *-zoneless build -// configurations only. zone.js stays in the polyfills until the flip, so -// Angular logs NG0914 in these builds; nothing patches through it. -export const changeDetectionProviders: EnvironmentProviders[] = [ - provideZonelessChangeDetection(), -]; diff --git a/apps/web/src/environments/store-devtools.providers.dev.ts b/apps/web/src/environments/store-devtools.providers.dev.ts index 4776bc9f5..1564cf9e7 100644 --- a/apps/web/src/environments/store-devtools.providers.dev.ts +++ b/apps/web/src/environments/store-devtools.providers.dev.ts @@ -1,8 +1,8 @@ import { EnvironmentProviders } from '@angular/core'; import { provideStoreDevtools } from '@ngrx/store-devtools'; -// Swapped in for store-devtools.providers.ts by the development, -// electron-e2e and electron-e2e-zoneless build configurations only. +// Swapped in for store-devtools.providers.ts by the development and +// electron-e2e build configurations only. export const storeDevtoolsProviders: EnvironmentProviders[] = [ provideStoreDevtools({ maxAge: 25 }), ]; diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index f45f696a8..891c238fd 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -3,7 +3,8 @@ @use '@angular/material' as mat; @use 'styles'; -@include mat.elevation-classes(); +// No `mat.elevation-classes()`: nothing in the app uses the `.mat-elevation-z*` +// utilities, and the 25 rules cost 4.4 KB of the initial stylesheet. @include mat.app-background(); // ─── Typography ───────────────────────────────────────────────────────────── @@ -407,39 +408,104 @@ html { font-size: 13.5px; } + // A select stays inside a form; its option panel follows the kit menu: + // a bordered 10px panel with 6px padding, 36px rows at 13px and the + // chosen row on the list-selection tokens. The overrides sit on the + // panel, which renders in the overlay container inside the theme + // context, so the `--app-*` values resolve per theme. The selector is + // doubled because Material sets the panel's padding and attached + // corners from `div.mat-mdc-select-panel` and + // `.mat-mdc-select-panel-above div.mat-mdc-select-panel`, injected after + // the global stylesheet. + div.mat-mdc-select-panel.mat-mdc-select-panel { + @include mat.select-overrides( + ( + panel-background-color: var(--app-widget-bg), + container-elevation-shadow: 0 18px 40px rgba(0, 0, 0, 0.35), + ) + ); + @include mat.option-overrides( + ( + label-text-size: 13px, + label-text-color: var(--app-heading-color), + selected-state-label-text-color: var(--app-list-selected-fg), + selected-state-layer-color: var(--app-list-selected-bg), + hover-state-layer-color: + color-mix(in srgb, var(--app-on-surface) 8%, transparent), + focus-state-layer-color: + color-mix(in srgb, var(--app-on-surface) 10%, transparent), + ) + ); + padding: 6px; + border: 1px solid var(--app-line-strong); + border-radius: 10px; + + .mat-mdc-option { + min-height: 36px; + padding: 0 10px; + border-radius: 6px; + } + } + // ─── 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. + // discard user data opt in with this class instead. The kit draws them as + // a red tint (`--app-danger-*`, declared per theme context above), never + // a solid error fill: hover changes the fill only, the ripple is off and + // a disabled button is the same button at 40% opacity. .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), + filled-container-color: var(--app-danger-surface), + filled-disabled-container-color: var(--app-danger-surface), + filled-label-text-color: var(--app-danger-text), + filled-disabled-label-text-color: var(--app-danger-text), + filled-state-layer-color: var(--app-danger-text), + filled-hover-state-layer-opacity: 0.08, + filled-focus-state-layer-opacity: 0.08, + filled-pressed-state-layer-opacity: 0.12, + filled-ripple-color: transparent, + text-label-text-color: var(--app-danger-text), + text-disabled-label-text-color: var(--app-danger-text), + text-state-layer-color: var(--app-danger-text), + text-hover-state-layer-opacity: 0.08, + text-focus-state-layer-opacity: 0.08, + text-pressed-state-layer-opacity: 0.12, + text-ripple-color: transparent, + outlined-label-text-color: var(--app-danger-text), + outlined-disabled-label-text-color: var(--app-danger-text), + outlined-outline-color: transparent, + outlined-disabled-outline-color: transparent, + outlined-state-layer-color: var(--app-danger-text), + outlined-hover-state-layer-opacity: 0.08, + outlined-focus-state-layer-opacity: 0.08, + outlined-pressed-state-layer-opacity: 0.12, + outlined-ripple-color: 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), + icon-color: var(--app-danger-text), + disabled-icon-color: var(--app-danger-text), + state-layer-color: var(--app-danger-text), + hover-state-layer-opacity: 0.08, + focus-state-layer-opacity: 0.08, + pressed-state-layer-opacity: 0.12, + ripple-color: transparent, ) ); + + // The kit has no outlined buttons: an outlined destructive button + // carries the same tint as a filled one. + &.mat-mdc-outlined-button { + background-color: var(--app-danger-surface); + } + + &[disabled], + &.mat-mdc-button-disabled { + opacity: 0.4; + } } .mat-mdc-form-field-subscript-wrapper { diff --git a/apps/web/src/polyfills-test.ts b/apps/web/src/polyfills-test.ts deleted file mode 100644 index aa09a9ff6..000000000 --- a/apps/web/src/polyfills-test.ts +++ /dev/null @@ -1 +0,0 @@ -import 'zone.js'; diff --git a/apps/web/src/polyfills.ts b/apps/web/src/polyfills.ts deleted file mode 100644 index b40974e69..000000000 --- a/apps/web/src/polyfills.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * This file includes polyfills needed by Angular and is loaded before the app. - * You can add your own extra polyfills to this file. - * - * This file is divided into 2 sections: - * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. - * 2. Application imports. Files imported after ZoneJS that should be loaded before your main - * file. - * - * The current setup is for so-called "evergreen" browsers; the last versions of browsers that - * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), - * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. - * - * Learn more in https://angular.io/guide/browser-support - */ - -/*************************************************************************************************** - * BROWSER POLYFILLS - */ - -/** - * By default, zone.js will patch all possible macroTask and DomEvents - * user can disable parts of macroTask/DomEvents patch by setting following flags - * because those flags need to be set before `zone.js` being loaded, and webpack - * will put import in the top of bundle, so user need to create a separate file - * in this directory (for example: zone-flags.ts), and put the following flags - * into that file, and then add the following code before importing zone.js. - * import './zone-flags.ts'; - * - * The flags allowed in zone-flags.ts are listed here. - * - * The following flags will work for all browsers. - * - * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame - * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick - * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames - * - * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js - * with the following flag, it will bypass `zone.js` patch for IE/Edge - * - * (window as any).__Zone_enable_cross_context_check = true; - * - */ - -/*************************************************************************************************** - * Zone JS is required by default for Angular itself. - */ -import 'zone.js'; // Included with Angular CLI. - -/*************************************************************************************************** - * APPLICATION IMPORTS - */ diff --git a/apps/web/src/setup-jest.ts b/apps/web/src/setup-jest.ts deleted file mode 100644 index 1100b3e8a..000000000 --- a/apps/web/src/setup-jest.ts +++ /dev/null @@ -1 +0,0 @@ -import 'jest-preset-angular/setup-jest'; diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index c196cd71e..11d43ed18 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -1,6 +1,7 @@ @use '@angular/material' as mat; @use '../../../libs/ui/components/src/lib/resizable/resizable.scss'; @use '../../../libs/ui/styles/focus-ring'; +@use '../../../libs/ui/styles/app-controls' as controls; @use './settings-theme'; @use './cover-size'; @@ -238,10 +239,20 @@ body.dark-theme { overflow-x: hidden !important; } -.cdk-overlay-backdrop { +// Modal overlays blur the page behind their backdrop. The CDK gives a dialog +// the dark backdrop class by default; menus, selects and popovers open over +// `cdk-overlay-transparent-backdrop`, which sits at opacity 0 and stays a +// plain layer without a filter of its own. +.cdk-overlay-dark-backdrop { backdrop-filter: blur(4px); } +// UI-kit menu panel: ``. The panel renders in the +// overlay container, outside every component view, so the rule is global. +.mat-mdc-menu-panel.app-menu { + @include controls.menu-panel; +} + // Command palette dialog — the palette-shell renders its own background, border, // radius and shadow, so strip MDC's dialog surface chrome to avoid a darker // halo peeking out behind the rounded corners. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index cbe3c7595..c9455db57 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -22,6 +22,54 @@ Use it when changing existing views or introducing new list-based UI in the work 5. Scroll ownership must be explicit. Headers stay visible. Lists scroll. Do not let nested panes compete for scroll. +## Angular Material's Role + +Decision of 2026-10-12. The repository has no ADR folder; decisions live in +the subsystem's document. + +**Decision.** + +- Angular Material stays as the behaviour and accessibility layer: dialog, + menu, select (inside forms only), form field and input, snack-bar, tooltip, + slide-toggle, checkbox, progress and icon. +- The look comes from the IPTVnator design kit, applied through + `mat.*-overrides()` on the `--app-*` tokens in one place: the shared + partials under `libs/ui/styles/` (the control mixins live in + `_app-controls.scss`), not per-component `::ng-deep`. +- A kit component that Material lacks is built on CDK primitives (overlay, + a11y key managers), as the playlist switcher popover is. +- A plain action button may become a native `