diff --git a/apps/portal-shell/public/logos/apf-small.png b/apps/portal-shell/public/logos/apf-small.png new file mode 100644 index 0000000..8a5b564 Binary files /dev/null and b/apps/portal-shell/public/logos/apf-small.png differ diff --git a/apps/portal-shell/src/app/app.scss b/apps/portal-shell/src/app/app.scss index d8810e0..07a87a9 100644 --- a/apps/portal-shell/src/app/app.scss +++ b/apps/portal-shell/src/app/app.scss @@ -21,6 +21,12 @@ min-width: 0; overflow-y: auto; background-color: #f9fafb; + color: #111827; + + :where(.dark) & { + background-color: #0b1220; + color: #e5e7eb; + } } // Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully diff --git a/apps/portal-shell/src/app/components/header/header.html b/apps/portal-shell/src/app/components/header/header.html index 8d6b684..aaba46d 100644 --- a/apps/portal-shell/src/app/components/header/header.html +++ b/apps/portal-shell/src/app/components/header/header.html @@ -1,12 +1,14 @@ -
+
@@ -38,25 +40,26 @@ -
+
@if (!collapsed()) { -

- Role: Anonymous +

+ Role: + Anonymous

} diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.scss b/apps/portal-shell/src/app/components/sidebar/sidebar.scss index 4f8dece..2460f6d 100644 --- a/apps/portal-shell/src/app/components/sidebar/sidebar.scss +++ b/apps/portal-shell/src/app/components/sidebar/sidebar.scss @@ -44,14 +44,27 @@ background-color 0.12s ease-out, color 0.12s ease-out; + :where(.dark) & { + color: #e5e7eb; + } + &:hover { background-color: #f3f4f6; color: var(--color-brand-primary-500); + + :where(.dark) & { + background-color: #1f2937; + color: var(--color-brand-primary-300); + } } &:focus-visible { outline: 2px solid var(--color-brand-primary-500); outline-offset: 2px; + + :where(.dark) & { + outline-color: var(--color-brand-primary-300); + } } } @@ -63,6 +76,15 @@ background-color: var(--color-brand-primary-600); color: #fff; } + + :where(.dark) & { + background-color: var(--color-brand-primary-400); + + &:hover { + background-color: var(--color-brand-primary-300); + color: #0b1620; + } + } } .sidebar__label { diff --git a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.html b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.html new file mode 100644 index 0000000..e49de55 --- /dev/null +++ b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.html @@ -0,0 +1,30 @@ + + + + + diff --git a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.scss b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.scss new file mode 100644 index 0000000..5e7faf2 --- /dev/null +++ b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.scss @@ -0,0 +1,72 @@ +// CDK Menu ships no opinions on visual styling — we colour, frame and +// align it here. The width pin keeps the three items aligned even +// when the labels grow under translation. dark-mode surface mirrors +// the rest of the shell (gray-900 background, gray-800 border). + +.theme-switcher__menu { + min-width: 11rem; + margin-top: 0.25rem; + padding: 0.25rem; + background-color: #fff; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -2px rgba(0, 0, 0, 0.05); + + :where(.dark) & { + background-color: #111827; + border-color: #1f2937; + } +} + +.theme-switcher__item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + min-height: 2.5rem; + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + background: transparent; + border: 0; + color: #1f2937; + font-size: 0.875rem; + text-align: left; + cursor: pointer; + transition: + background-color 0.12s ease-out, + color 0.12s ease-out; + + :where(.dark) & { + color: #f3f4f6; + } + + &:hover { + background-color: #f3f4f6; + color: var(--color-brand-primary-500); + + :where(.dark) & { + background-color: #1f2937; + color: var(--color-brand-primary-300); + } + } + + &:focus-visible { + outline: 2px solid var(--color-brand-primary-500); + outline-offset: -2px; + } +} + +.theme-switcher__label { + flex: 1 1 auto; +} + +.theme-switcher__item--active { + font-weight: 600; + color: var(--color-brand-primary-500); + + :where(.dark) & { + color: var(--color-brand-primary-300); + } +} diff --git a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.spec.ts b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.spec.ts new file mode 100644 index 0000000..033170c --- /dev/null +++ b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.spec.ts @@ -0,0 +1,37 @@ +import { TestBed } from '@angular/core/testing'; +import { LayoutStateService } from '../../state/layout-state.service'; +import { ThemeSwitcher } from './theme-switcher'; + +describe('ThemeSwitcher', () => { + beforeEach(async () => { + localStorage.clear(); + document.documentElement.classList.remove('dark'); + await TestBed.configureTestingModule({ imports: [ThemeSwitcher] }).compileComponents(); + }); + + it('renders a trigger button announcing the current mode', async () => { + const fixture = TestBed.createComponent(ThemeSwitcher); + await fixture.whenStable(); + const button = (fixture.nativeElement as HTMLElement).querySelector( + 'button[aria-haspopup="menu"]', + ) as HTMLButtonElement | null; + expect(button).not.toBeNull(); + expect(button?.getAttribute('aria-label')).toContain('System'); + }); + + it('updates the trigger label and effective theme when a mode is selected', async () => { + const layout = TestBed.inject(LayoutStateService); + const fixture = TestBed.createComponent(ThemeSwitcher); + fixture.detectChanges(); + await fixture.whenStable(); + + layout.setThemeMode('dark'); + fixture.detectChanges(); + await fixture.whenStable(); + const button = (fixture.nativeElement as HTMLElement).querySelector( + 'button[aria-haspopup="menu"]', + ) as HTMLButtonElement | null; + expect(button?.getAttribute('aria-label')).toContain('Dark'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); +}); diff --git a/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.ts b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.ts new file mode 100644 index 0000000..f4d1ae0 --- /dev/null +++ b/apps/portal-shell/src/app/components/theme-switcher/theme-switcher.ts @@ -0,0 +1,55 @@ +import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { Icon, type IconName } from '../icon/icon'; +import { LayoutStateService, type ThemeMode } from '../../state/layout-state.service'; + +/** + * Light / dark / auto color-scheme switcher rendered in the header. + * + * The trigger is a single icon button whose glyph reflects the + * currently SELECTED mode (sun / moon / monitor — not the effective + * theme), so the user can tell which mode they're in even when `auto` + * happens to resolve to the same scheme as a manual pick. Clicking + * the button opens a CDK-driven menu (proper roving tabindex, + * Escape/click-outside dismissal) with the three options; the + * currently-selected one gets a check mark. + * + * State lives in `LayoutStateService` — this component is a thin view + * over `themeMode()` + `setThemeMode()`. + */ + +interface ModeOption { + readonly mode: ThemeMode; + readonly label: string; + readonly icon: IconName; +} + +const MODE_OPTIONS: readonly ModeOption[] = [ + { mode: 'light', label: 'Light', icon: 'sun' }, + { mode: 'dark', label: 'Dark', icon: 'moon' }, + { mode: 'auto', label: 'System', icon: 'monitor' }, +]; + +@Component({ + selector: 'app-theme-switcher', + imports: [CdkMenu, CdkMenuItem, CdkMenuTrigger, Icon], + templateUrl: './theme-switcher.html', + styleUrl: './theme-switcher.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ThemeSwitcher { + private readonly layout = inject(LayoutStateService); + + protected readonly options = MODE_OPTIONS; + protected readonly mode = this.layout.themeMode; + protected readonly currentIcon = computed( + () => MODE_OPTIONS.find((o) => o.mode === this.mode())?.icon ?? 'monitor', + ); + protected readonly currentLabel = computed( + () => MODE_OPTIONS.find((o) => o.mode === this.mode())?.label ?? 'System', + ); + + protected select(mode: ThemeMode): void { + this.layout.setThemeMode(mode); + } +} diff --git a/apps/portal-shell/src/app/pages/accessibility/accessibility.html b/apps/portal-shell/src/app/pages/accessibility/accessibility.html index fcdad37..765c498 100644 --- a/apps/portal-shell/src/app/pages/accessibility/accessibility.html +++ b/apps/portal-shell/src/app/pages/accessibility/accessibility.html @@ -1,18 +1,26 @@
-

{{ copy().pageTitle }}

+

+ {{ copy().pageTitle }} +

-

{{ copy().introTitle }}

-

{{ copy().introBody }}

+

+ {{ copy().introTitle }} +

+

+ {{ copy().introBody }} +

-

+

{{ copy().panelNoticeTitle }}

-

{{ copy().panelNoticeBody }}

+

+ {{ copy().panelNoticeBody }} +

diff --git a/apps/portal-shell/src/app/pages/home/home.html b/apps/portal-shell/src/app/pages/home/home.html index 5cac0a2..bc06bfd 100644 --- a/apps/portal-shell/src/app/pages/home/home.html +++ b/apps/portal-shell/src/app/pages/home/home.html @@ -1,30 +1,36 @@
-

Welcome to APF Portal

-

+

+ Welcome to APF Portal +

+

This is the placeholder home page for the APF Portal. Real content lands once the comms team and the accessibility review panel sign off on the copy.

-

System status

+

+ System status +

@if (isLoading()) { -

Checking the backend…

+

+ Checking the backend… +

} @else if (health(); as h) {
-
Service
-
{{ h.service }}
-
Version
-
{{ h.version }}
-
Uptime
-
{{ h.uptimeSeconds }} s
-
Status
-
{{ h.status }}
+
Service
+
{{ h.service }}
+
Version
+
{{ h.version }}
+
Uptime
+
{{ h.uptimeSeconds }} s
+
Status
+
{{ h.status }}
} @else if (isError()) { -

+

Backend unreachable. The BFF dev server may not be running, or CORS is misconfigured.

} diff --git a/apps/portal-shell/src/app/state/layout-state.service.spec.ts b/apps/portal-shell/src/app/state/layout-state.service.spec.ts index 767e19a..c031795 100644 --- a/apps/portal-shell/src/app/state/layout-state.service.spec.ts +++ b/apps/portal-shell/src/app/state/layout-state.service.spec.ts @@ -4,40 +4,91 @@ import { LayoutStateService } from './layout-state.service'; describe('LayoutStateService', () => { beforeEach(() => { localStorage.clear(); + document.documentElement.classList.remove('dark'); TestBed.resetTestingModule(); }); - it('defaults to expanded when no preference is persisted', () => { - const service = TestBed.inject(LayoutStateService); - expect(service.sidebarCollapsed()).toBe(false); + describe('sidebar state', () => { + it('defaults to expanded when no preference is persisted', () => { + const service = TestBed.inject(LayoutStateService); + expect(service.sidebarCollapsed()).toBe(false); + }); + + it('initialises from the persisted preference', () => { + localStorage.setItem('portal-shell:sidebar-collapsed', '1'); + const service = TestBed.inject(LayoutStateService); + expect(service.sidebarCollapsed()).toBe(true); + }); + + it('toggleSidebar flips the value and persists it', () => { + const service = TestBed.inject(LayoutStateService); + expect(service.sidebarCollapsed()).toBe(false); + + service.toggleSidebar(); + expect(service.sidebarCollapsed()).toBe(true); + TestBed.tick(); + expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1'); + + service.toggleSidebar(); + expect(service.sidebarCollapsed()).toBe(false); + TestBed.tick(); + expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('0'); + }); + + it('setSidebarCollapsed writes the explicit value', () => { + const service = TestBed.inject(LayoutStateService); + service.setSidebarCollapsed(true); + expect(service.sidebarCollapsed()).toBe(true); + TestBed.tick(); + expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1'); + }); }); - it('initialises from the persisted preference', () => { - localStorage.setItem('portal-shell:sidebar-collapsed', '1'); - const service = TestBed.inject(LayoutStateService); - expect(service.sidebarCollapsed()).toBe(true); - }); + describe('theme state', () => { + it('defaults to auto when no preference is persisted', () => { + const service = TestBed.inject(LayoutStateService); + expect(service.themeMode()).toBe('auto'); + }); - it('toggleSidebar flips the value and persists it', () => { - const service = TestBed.inject(LayoutStateService); - expect(service.sidebarCollapsed()).toBe(false); + it('initialises from the persisted theme mode', () => { + localStorage.setItem('portal-shell:theme-mode', 'dark'); + const service = TestBed.inject(LayoutStateService); + expect(service.themeMode()).toBe('dark'); + }); - service.toggleSidebar(); - expect(service.sidebarCollapsed()).toBe(true); - TestBed.tick(); - expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1'); + it('ignores an invalid persisted value and falls back to auto', () => { + localStorage.setItem('portal-shell:theme-mode', 'sepia' as unknown as string); + const service = TestBed.inject(LayoutStateService); + expect(service.themeMode()).toBe('auto'); + }); - service.toggleSidebar(); - expect(service.sidebarCollapsed()).toBe(false); - TestBed.tick(); - expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('0'); - }); + it('setThemeMode persists the choice and toggles the .dark class on ', () => { + const service = TestBed.inject(LayoutStateService); + const html = document.documentElement; - it('setSidebarCollapsed writes the explicit value', () => { - const service = TestBed.inject(LayoutStateService); - service.setSidebarCollapsed(true); - expect(service.sidebarCollapsed()).toBe(true); - TestBed.tick(); - expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1'); + service.setThemeMode('dark'); + TestBed.tick(); + expect(localStorage.getItem('portal-shell:theme-mode')).toBe('dark'); + expect(html.classList.contains('dark')).toBe(true); + expect(service.effectiveTheme()).toBe('dark'); + + service.setThemeMode('light'); + TestBed.tick(); + expect(localStorage.getItem('portal-shell:theme-mode')).toBe('light'); + expect(html.classList.contains('dark')).toBe(false); + expect(service.effectiveTheme()).toBe('light'); + }); + + it('resolves auto via the system preference (jsdom defaults to light)', () => { + const service = TestBed.inject(LayoutStateService); + service.setThemeMode('auto'); + TestBed.tick(); + expect(service.themeMode()).toBe('auto'); + // jsdom's matchMedia reports no `prefers-color-scheme: dark` match, + // so 'auto' resolves to light. We assert the resolution path — + // not the literal value of the system preference. + expect(service.effectiveTheme()).toBe('light'); + expect(document.documentElement.classList.contains('dark')).toBe(false); + }); }); }); diff --git a/apps/portal-shell/src/app/state/layout-state.service.ts b/apps/portal-shell/src/app/state/layout-state.service.ts index 549c77f..5374377 100644 --- a/apps/portal-shell/src/app/state/layout-state.service.ts +++ b/apps/portal-shell/src/app/state/layout-state.service.ts @@ -1,38 +1,84 @@ -import { Injectable, effect, signal } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { Injectable, computed, effect, inject, signal } from '@angular/core'; /** * Shell-level layout state shared by the header and the sidebar. * - * Currently a single piece of state: whether the sidebar is collapsed - * to its icon-only rail. The collapsed state is persisted in - * `localStorage` so it survives reloads without a backend round-trip. + * v1 surfaces two settings: + * - `sidebarCollapsed`: whether the sidebar is collapsed to its + * icon-only rail. The header reads it to size its logo zone in + * lock-step with the sidebar. + * - `themeMode`: the user's color-scheme preference among + * `light` / `dark` / `auto`. `effectiveTheme` resolves it to a + * concrete `'light' | 'dark'` by consulting the system preference + * when in `auto` mode, and reacts to OS-level theme changes. + * A side-effect toggles the `.dark` class on so every + * `dark:` Tailwind utility flips at once. * - * Lives outside of `` so that `` can also - * read it — that's what lets the header's logo zone size itself to - * match the sidebar width, and stay perfectly aligned with the rail - * below it. As more cross-cutting shell state lands (density, theme, - * panel pinning, …), it joins here rather than spreading across - * sibling components. + * Both pieces of state persist in `localStorage` so the user's + * preferences survive a reload without a backend round-trip. + * + * Lives outside of the components that consume it so multiple + * surfaces stay synchronised without prop-drilling, and so the + * service is the natural home for future cross-cutting shell state + * (density, RTL, panel pinning, ...). */ -const STORAGE_KEY = 'portal-shell:sidebar-collapsed'; +const SIDEBAR_STORAGE_KEY = 'portal-shell:sidebar-collapsed'; +const THEME_STORAGE_KEY = 'portal-shell:theme-mode'; +const DARK_CLASS = 'dark'; + +export type ThemeMode = 'light' | 'dark' | 'auto'; @Injectable({ providedIn: 'root' }) export class LayoutStateService { - private readonly _sidebarCollapsed = signal(readPersistedCollapsed()); + private readonly document = inject(DOCUMENT); + + private readonly _sidebarCollapsed = signal(readPersistedSidebarCollapsed()); + private readonly _themeMode = signal(readPersistedThemeMode()); + private readonly _systemPrefersDark = signal(systemPrefersDark(this.document)); readonly sidebarCollapsed = this._sidebarCollapsed.asReadonly(); + readonly themeMode = this._themeMode.asReadonly(); + readonly effectiveTheme = computed<'light' | 'dark'>(() => { + const mode = this._themeMode(); + if (mode === 'auto') { + return this._systemPrefersDark() ? 'dark' : 'light'; + } + return mode; + }); constructor() { effect(() => { const value = this._sidebarCollapsed(); try { - localStorage.setItem(STORAGE_KEY, value ? '1' : '0'); + localStorage.setItem(SIDEBAR_STORAGE_KEY, value ? '1' : '0'); } catch { // localStorage can throw in private-mode contexts; the UI // still works, the preference just doesn't persist. } }); + + effect(() => { + const mode = this._themeMode(); + try { + localStorage.setItem(THEME_STORAGE_KEY, mode); + } catch { + // See above. + } + }); + + effect(() => { + const theme = this.effectiveTheme(); + const root = this.document.documentElement; + if (theme === 'dark') { + root.classList.add(DARK_CLASS); + } else { + root.classList.remove(DARK_CLASS); + } + }); + + this.watchSystemPreference(); } toggleSidebar(): void { @@ -42,12 +88,47 @@ export class LayoutStateService { setSidebarCollapsed(value: boolean): void { this._sidebarCollapsed.set(value); } + + setThemeMode(mode: ThemeMode): void { + this._themeMode.set(mode); + } + + private watchSystemPreference(): void { + const mql = this.document.defaultView?.matchMedia?.('(prefers-color-scheme: dark)'); + if (!mql) { + return; + } + const listener = (e: MediaQueryListEvent) => this._systemPrefersDark.set(e.matches); + // `addEventListener` is the modern API; older Safari only exposes + // the deprecated `addListener` — guarded for robustness. + if (typeof mql.addEventListener === 'function') { + mql.addEventListener('change', listener); + } else { + mql.addListener(listener); + } + } } -function readPersistedCollapsed(): boolean { +function readPersistedSidebarCollapsed(): boolean { try { - return localStorage.getItem(STORAGE_KEY) === '1'; + return localStorage.getItem(SIDEBAR_STORAGE_KEY) === '1'; } catch { return false; } } + +function readPersistedThemeMode(): ThemeMode { + try { + const raw = localStorage.getItem(THEME_STORAGE_KEY); + if (raw === 'light' || raw === 'dark' || raw === 'auto') { + return raw; + } + } catch { + // fall through + } + return 'auto'; +} + +function systemPrefersDark(doc: Document): boolean { + return doc.defaultView?.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; +} diff --git a/apps/portal-shell/src/styles.css b/apps/portal-shell/src/styles.css index 2b1d81a..f94bca5 100644 --- a/apps/portal-shell/src/styles.css +++ b/apps/portal-shell/src/styles.css @@ -9,6 +9,16 @@ @import 'tailwindcss'; +/* + * Class-based dark mode. Tailwind v4 default is media-query-driven + * (`prefers-color-scheme`); we override it so a `.dark` class on + * (set by `LayoutStateService` from the user's explicit + * preference, or computed from the system preference in `auto` mode) + * is the single switch. `&:where(.dark, .dark *)` keeps specificity + * neutral so utility ordering doesn't shift. + */ +@custom-variant dark (&:where(.dark, .dark *)); + /* * Brand palette — APF charte graphique. * diff --git a/package.json b/package.json index fb70ef3..c91f1db 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "webpack-cli": "^5.1.4" }, "dependencies": { + "@angular/cdk": "~21.2.10", "@angular/common": "~21.2.0", "@angular/compiler": "~21.2.0", "@angular/core": "~21.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7830029..787996c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: .: dependencies: + '@angular/cdk': + specifier: ~21.2.10 + version: 21.2.10(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.12(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) '@angular/common': specifier: ~21.2.0 version: 21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) @@ -575,6 +578,14 @@ packages: vitest: optional: true + '@angular/cdk@21.2.10': + resolution: {integrity: sha512-yfCzUFNfeSMNnCkc0P5Pozqz1EViDe9KLPQyHVY/hApsNgBWvckpA+ZEWgKNfAf72f8bUJvZoHejaSMGYrpvuw==} + peerDependencies: + '@angular/common': ^21.0.0 || ^22.0.0 + '@angular/core': ^21.0.0 || ^22.0.0 + '@angular/platform-browser': ^21.0.0 || ^22.0.0 + rxjs: ^6.5.3 || ^7.4.0 + '@angular/cli@21.2.10': resolution: {integrity: sha512-ezf9LM0GgexG2l8ae/uN4fUyxGqeFEH9iu30mUMU5dwow76aK+b4Abuf5eSuR0F8zTLEA3ZUEYywI+gajbAUuA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -10383,6 +10394,15 @@ snapshots: - tsx - yaml + '@angular/cdk@21.2.10(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.12(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': + dependencies: + '@angular/common': 21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 21.2.12(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2)) + parse5: 8.0.1 + rxjs: 7.8.2 + tslib: 2.8.1 + '@angular/cli@21.2.10(@types/node@24.12.3)(chokidar@5.0.0)': dependencies: '@angular-devkit/architect': 0.2102.10(chokidar@5.0.0)