diff --git a/libs/labs/package.json b/libs/labs/package.json
index 7e0f7e2..fc97c89 100644
--- a/libs/labs/package.json
+++ b/libs/labs/package.json
@@ -27,6 +27,7 @@
"@angular/youtube-player": "^21.2.6",
"@atlasng/analytics": "0.0.6",
"@atlasng/common": "0.0.6",
+ "@atlasng/core": "0.0.6",
"@atlasng/design-system": "0.0.6"
},
"devDependencies": {
diff --git a/libs/labs/theme-preference/README.md b/libs/labs/theme-preference/README.md
new file mode 100644
index 0000000..f85ce5d
--- /dev/null
+++ b/libs/labs/theme-preference/README.md
@@ -0,0 +1,52 @@
+# @atlasng/labs/theme-preference
+
+Experimental theme-preference controls and browser-state management for AtlasNG applications.
+
+## Usage
+
+Configure the service once with the application's light and dark theme classes:
+
+```ts
+import { ApplicationConfig } from '@angular/core';
+import { provideThemePreference } from '@atlasng/labs/theme-preference';
+
+export const appConfig: ApplicationConfig = {
+ providers: [
+ provideThemePreference({
+ lightThemeClass: 'hra-light-theme',
+ darkThemeClass: 'hra-dark-theme',
+ }),
+ ],
+};
+```
+
+Bind either presentational variant to the service:
+
+```ts
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { ThemePreferenceMenu, ThemePreferenceSelector, ThemePreferenceService } from '@atlasng/labs/theme-preference';
+
+@Component({
+ selector: 'app-appearance-settings',
+ imports: [ThemePreferenceMenu, ThemePreferenceSelector],
+ template: `
+
+
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AppearanceSettings {
+ protected readonly themePreference = inject(ThemePreferenceService);
+}
+```
+
+The default preference is `system`. The service follows device color-scheme changes, persists explicit choices,
+sets `data-theme` and `color-scheme` on the document root, and applies the configured theme class.
+
+## Variants
+
+- `ThemePreferenceSelector` renders the three preferences as a Material button-toggle group.
+- `ThemePreferenceMenu` renders a compact Material icon button and menu suitable for application headers. Its menu
+ opens below and before the trigger by default, and its position can be customized with `xPosition`, `yPosition`,
+ and `overlapTrigger`.
diff --git a/libs/labs/theme-preference/ng-package.json b/libs/labs/theme-preference/ng-package.json
new file mode 100644
index 0000000..c781f0d
--- /dev/null
+++ b/libs/labs/theme-preference/ng-package.json
@@ -0,0 +1,5 @@
+{
+ "lib": {
+ "entryFile": "src/index.ts"
+ }
+}
diff --git a/libs/labs/theme-preference/src/index.ts b/libs/labs/theme-preference/src/index.ts
new file mode 100644
index 0000000..05820c3
--- /dev/null
+++ b/libs/labs/theme-preference/src/index.ts
@@ -0,0 +1,8 @@
+export {
+ provideThemePreference,
+ ThemePreferenceService,
+ type ThemePreferenceConfig,
+} from './lib/theme-preference.service';
+export { ThemePreferenceMenu } from './lib/theme-preference-menu';
+export { ThemePreferenceSelector } from './lib/theme-preference-selector';
+export { type ResolvedTheme, type ThemePreference } from './lib/theme-preference';
diff --git a/libs/labs/theme-preference/src/lib/theme-preference-menu.html b/libs/labs/theme-preference/src/lib/theme-preference-menu.html
new file mode 100644
index 0000000..0605796
--- /dev/null
+++ b/libs/labs/theme-preference/src/lib/theme-preference-menu.html
@@ -0,0 +1,40 @@
+
+
+
+ @for (option of options; track option.value) {
+
+ }
+
diff --git a/libs/labs/theme-preference/src/lib/theme-preference-menu.scss b/libs/labs/theme-preference/src/lib/theme-preference-menu.scss
new file mode 100644
index 0000000..aa04861
--- /dev/null
+++ b/libs/labs/theme-preference/src/lib/theme-preference-menu.scss
@@ -0,0 +1,24 @@
+:host {
+ display: inline-block;
+}
+
+.ang-theme-preference-menu-item-selected {
+ color: var(--mat-sys-on-secondary-container);
+ background-color: var(--mat-sys-secondary-container);
+}
+
+.ang-theme-preference-menu-item-content {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1.5rem;
+ width: 100%;
+}
+
+.ang-theme-preference-menu-check {
+ margin: 0;
+}
+
+.ang-theme-preference-menu-check-hidden {
+ visibility: hidden;
+}
diff --git a/libs/labs/theme-preference/src/lib/theme-preference-menu.spec.ts b/libs/labs/theme-preference/src/lib/theme-preference-menu.spec.ts
new file mode 100644
index 0000000..8507ce3
--- /dev/null
+++ b/libs/labs/theme-preference/src/lib/theme-preference-menu.spec.ts
@@ -0,0 +1,97 @@
+import { Component } from '@angular/core';
+import { render, screen, within } from '@testing-library/angular';
+import userEvent from '@testing-library/user-event';
+import { ThemePreference } from './theme-preference';
+import { ThemePreferenceMenu } from './theme-preference-menu';
+
+@Component({
+ imports: [ThemePreferenceMenu],
+ template: ``,
+})
+class ThemePreferenceMenuHost {
+ /** Preference bound to the menu component. */
+ preference: ThemePreference = 'system';
+}
+
+describe('ThemePreferenceMenu', () => {
+ it('renders a Material icon-button menu trigger', async () => {
+ await render(ThemePreferenceMenu);
+
+ const trigger = screen.getByRole('button', { name: 'Choose theme preference' });
+ expect(trigger).toBeVisible();
+ expect(within(trigger).getByRole('img', { hidden: true })).toHaveAttribute('data-mat-icon-name', 'brightness_4');
+ });
+
+ it('shows the default Material tooltip on hover', async () => {
+ const user = userEvent.setup();
+ await render(ThemePreferenceMenu);
+
+ await user.hover(screen.getByRole('button', { name: 'Choose theme preference' }));
+
+ expect(await screen.findAllByText('Theme settings')).toHaveLength(2);
+ });
+
+ it('shows the default Material tooltip on keyboard focus', async () => {
+ const user = userEvent.setup();
+ await render(ThemePreferenceMenu);
+
+ await user.tab();
+
+ expect(screen.getByRole('button', { name: 'Choose theme preference' })).toHaveFocus();
+ expect(await screen.findAllByText('Theme settings')).toHaveLength(2);
+ });
+
+ it('renders all theme preferences and marks the device setting by default', async () => {
+ const user = userEvent.setup();
+ await render(ThemePreferenceMenu);
+
+ await user.click(screen.getByRole('button', { name: 'Choose theme preference' }));
+
+ expect(screen.getByRole('menuitemradio', { name: 'Light mode' })).toHaveAttribute('aria-checked', 'false');
+ expect(screen.getByRole('menuitemradio', { name: 'Dark mode' })).toHaveAttribute('aria-checked', 'false');
+ const deviceOption = screen.getByRole('menuitemradio', { name: 'Device settings' });
+ expect(deviceOption).toHaveAttribute('aria-checked', 'true');
+ expect(
+ within(deviceOption)
+ .getAllByRole('img', { hidden: true })
+ .map((icon) => icon.getAttribute('data-mat-icon-name')),
+ ).toEqual(['devices', 'check']);
+ });
+
+ it('updates the bound preference when the user chooses an option', async () => {
+ const user = userEvent.setup();
+ const { fixture } = await render(ThemePreferenceMenuHost);
+
+ await user.click(screen.getByRole('button', { name: 'Choose theme preference' }));
+ await user.click(screen.getByRole('menuitemradio', { name: 'Light mode' }));
+
+ expect(fixture.componentInstance.preference).toBe('light');
+ });
+
+ it('reserves check-icon space and shows the check only for the selected option', async () => {
+ const user = userEvent.setup();
+ await render(ThemePreferenceMenu, { inputs: { preference: 'dark' } });
+
+ await user.click(screen.getByRole('button', { name: 'Choose theme preference' }));
+
+ const lightOption = screen.getByRole('menuitemradio', { name: 'Light mode' });
+ const darkOption = screen.getByRole('menuitemradio', { name: 'Dark mode' });
+ const deviceOption = screen.getByRole('menuitemradio', { name: 'Device settings' });
+ expect(darkOption).toHaveAttribute('aria-checked', 'true');
+ expect(within(lightOption).getAllByRole('img', { hidden: true })[1]).not.toBeVisible();
+ expect(within(darkOption).getAllByRole('img', { hidden: true })[1]).toBeVisible();
+ expect(within(deviceOption).getAllByRole('img', { hidden: true })[1]).not.toBeVisible();
+ });
+
+ it('supports a disabled trigger', async () => {
+ await render(ThemePreferenceMenu, { inputs: { disabled: true } });
+
+ expect(screen.getByRole('button', { name: 'Choose theme preference' })).toBeDisabled();
+ });
+
+ it('supports a custom accessible trigger label', async () => {
+ await render(ThemePreferenceMenu, { inputs: { ariaLabel: 'Change application theme' } });
+
+ expect(screen.getByRole('button', { name: 'Change application theme' })).toBeVisible();
+ });
+});
diff --git a/libs/labs/theme-preference/src/lib/theme-preference-menu.stories.ts b/libs/labs/theme-preference/src/lib/theme-preference-menu.stories.ts
new file mode 100644
index 0000000..9f931e5
--- /dev/null
+++ b/libs/labs/theme-preference/src/lib/theme-preference-menu.stories.ts
@@ -0,0 +1,89 @@
+import { MatToolbarModule } from '@angular/material/toolbar';
+import { argsToTemplate, Meta, moduleMetadata, StoryObj } from '@storybook/angular';
+import { ThemePreferenceMenu } from './theme-preference-menu';
+
+const meta: Meta = {
+ title: 'Labs/Theme Preference/Menu',
+ component: ThemePreferenceMenu,
+ args: {
+ preference: 'system',
+ disabled: false,
+ ariaLabel: 'Choose theme preference',
+ tooltip: 'Theme settings',
+ xPosition: 'before',
+ yPosition: 'below',
+ overlapTrigger: false,
+ },
+ argTypes: {
+ preference: {
+ control: 'select',
+ options: ['light', 'dark', 'system'],
+ description: 'Selected theme preference.',
+ },
+ disabled: {
+ control: 'boolean',
+ description: 'Whether the menu trigger is disabled.',
+ },
+ ariaLabel: {
+ control: 'text',
+ description: 'Accessible name for the menu trigger.',
+ },
+ tooltip: {
+ control: 'text',
+ description: 'Tooltip displayed for the menu trigger.',
+ },
+ xPosition: {
+ control: 'select',
+ options: ['before', 'after'],
+ description: 'Preferred horizontal menu position.',
+ },
+ yPosition: {
+ control: 'select',
+ options: ['above', 'below'],
+ description: 'Preferred vertical menu position.',
+ },
+ overlapTrigger: {
+ control: 'boolean',
+ description: 'Whether the menu overlaps its trigger.',
+ },
+ },
+ render: (args) => ({
+ props: args,
+ template: ``,
+ }),
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Light: Story = {
+ args: { preference: 'light' },
+};
+
+export const Dark: Story = {
+ args: { preference: 'dark' },
+};
+
+export const HeaderPlacement: Story = {
+ decorators: [
+ moduleMetadata({
+ imports: [MatToolbarModule],
+ }),
+ ],
+ render: (args) => ({
+ props: args,
+ styles: ['.story-title { flex: 1 1 auto; }'],
+ template: `
+
+ Application
+
+
+ `,
+ }),
+};
+
+export const Disabled: Story = {
+ args: { disabled: true },
+};
diff --git a/libs/labs/theme-preference/src/lib/theme-preference-menu.ts b/libs/labs/theme-preference/src/lib/theme-preference-menu.ts
new file mode 100644
index 0000000..8a5c571
--- /dev/null
+++ b/libs/labs/theme-preference/src/lib/theme-preference-menu.ts
@@ -0,0 +1,74 @@
+import { booleanAttribute, ChangeDetectionStrategy, Component, input, model } from '@angular/core';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatMenuModule, MenuPositionX, MenuPositionY } from '@angular/material/menu';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { ThemePreference } from './theme-preference';
+
+/** Display information for a theme preference menu item. */
+interface ThemePreferenceMenuOption {
+ /** Value emitted when the option is selected. */
+ readonly value: ThemePreference;
+
+ /** Public-facing option label. */
+ readonly label: string;
+
+ /** Material icon displayed before the option label. */
+ readonly icon: string;
+}
+
+/** Theme preference options in their display order. */
+const THEME_PREFERENCE_MENU_OPTIONS: readonly ThemePreferenceMenuOption[] = [
+ { value: 'light', label: 'Light mode', icon: 'light_mode' },
+ { value: 'dark', label: 'Dark mode', icon: 'dark_mode' },
+ { value: 'system', label: 'Device settings', icon: 'devices' },
+];
+
+/**
+ * Compact icon-button menu for choosing a light, dark, or device-controlled theme preference.
+ *
+ * The menu defaults to opening below and before its trigger, which suits actions placed at the
+ * right edge of application headers. Angular Material can flip this position to remain onscreen.
+ */
+@Component({
+ selector: 'ang-theme-preference-menu',
+ imports: [MatButtonModule, MatIconModule, MatMenuModule, MatTooltipModule],
+ templateUrl: './theme-preference-menu.html',
+ styleUrl: './theme-preference-menu.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: { class: 'ang-theme-preference-menu' },
+})
+export class ThemePreferenceMenu {
+ /** Selected theme preference. */
+ readonly preference = model('system');
+
+ /** Whether the menu trigger is disabled. */
+ readonly disabled = input(false, { transform: booleanAttribute });
+
+ /** Accessible name applied to the icon-button trigger. */
+ readonly ariaLabel = input('Choose theme preference');
+
+ /** Tooltip displayed for the icon-button trigger. */
+ readonly tooltip = input('Theme settings');
+
+ /** Horizontal menu position relative to the trigger. */
+ readonly xPosition = input('before');
+
+ /** Vertical menu position relative to the trigger. */
+ readonly yPosition = input('below');
+
+ /** Whether the menu panel overlaps its trigger. */
+ readonly overlapTrigger = input(false, { transform: booleanAttribute });
+
+ /** Ordered menu options rendered by the template. */
+ protected readonly options = THEME_PREFERENCE_MENU_OPTIONS;
+
+ /**
+ * Updates the preference selected by the user.
+ *
+ * @param preference Selected theme preference.
+ */
+ protected selectPreference(preference: ThemePreference): void {
+ this.preference.set(preference);
+ }
+}
diff --git a/libs/labs/theme-preference/src/lib/theme-preference-selector.html b/libs/labs/theme-preference/src/lib/theme-preference-selector.html
new file mode 100644
index 0000000..a36c20f
--- /dev/null
+++ b/libs/labs/theme-preference/src/lib/theme-preference-selector.html
@@ -0,0 +1,10 @@
+
+ Light
+ Dark
+ Device settings
+
diff --git a/libs/labs/theme-preference/src/lib/theme-preference-selector.scss b/libs/labs/theme-preference/src/lib/theme-preference-selector.scss
new file mode 100644
index 0000000..48bb062
--- /dev/null
+++ b/libs/labs/theme-preference/src/lib/theme-preference-selector.scss
@@ -0,0 +1,3 @@
+:host {
+ display: inline-block;
+}
diff --git a/libs/labs/theme-preference/src/lib/theme-preference-selector.spec.ts b/libs/labs/theme-preference/src/lib/theme-preference-selector.spec.ts
new file mode 100644
index 0000000..3955b56
--- /dev/null
+++ b/libs/labs/theme-preference/src/lib/theme-preference-selector.spec.ts
@@ -0,0 +1,52 @@
+import { Component } from '@angular/core';
+import { render, screen } from '@testing-library/angular';
+import userEvent from '@testing-library/user-event';
+import { ThemePreference } from './theme-preference';
+import { ThemePreferenceSelector } from './theme-preference-selector';
+
+@Component({
+ imports: [ThemePreferenceSelector],
+ template: ``,
+})
+class ThemePreferenceSelectorHost {
+ /** Preference bound to the presentational selector. */
+ preference: ThemePreference = 'system';
+}
+
+describe('ThemePreferenceSelector', () => {
+ it('selects the device setting by default', async () => {
+ await render(ThemePreferenceSelector);
+
+ expect(screen.getByRole('radio', { name: 'Device settings' })).toBeChecked();
+ });
+
+ it('renders an initial preference', async () => {
+ await render(ThemePreferenceSelector, { inputs: { preference: 'light' } });
+
+ expect(screen.getByRole('radio', { name: 'Light' })).toBeChecked();
+ });
+
+ it('emits the preference selected by the user', async () => {
+ const user = userEvent.setup();
+ const { fixture } = await render(ThemePreferenceSelectorHost);
+
+ await user.click(screen.getByRole('radio', { name: 'Dark' }));
+
+ expect(fixture.componentInstance.preference).toBe('dark');
+ expect(screen.getByRole('radio', { name: 'Dark' })).toBeChecked();
+ });
+
+ it('disables every preference option', async () => {
+ await render(ThemePreferenceSelector, { inputs: { disabled: true } });
+
+ expect(screen.getByRole('radio', { name: 'Light' })).toBeDisabled();
+ expect(screen.getByRole('radio', { name: 'Dark' })).toBeDisabled();
+ expect(screen.getByRole('radio', { name: 'Device settings' })).toBeDisabled();
+ });
+
+ it('applies a custom accessible group label', async () => {
+ await render(ThemePreferenceSelector, { inputs: { ariaLabel: 'Application color scheme' } });
+
+ expect(screen.getByRole('radiogroup', { name: 'Application color scheme' })).toBeVisible();
+ });
+});
diff --git a/libs/labs/theme-preference/src/lib/theme-preference-selector.stories.ts b/libs/labs/theme-preference/src/lib/theme-preference-selector.stories.ts
new file mode 100644
index 0000000..4f805e8
--- /dev/null
+++ b/libs/labs/theme-preference/src/lib/theme-preference-selector.stories.ts
@@ -0,0 +1,44 @@
+import { Meta, StoryObj } from '@storybook/angular';
+import { ThemePreferenceSelector } from './theme-preference-selector';
+
+const meta: Meta = {
+ title: 'Labs/Theme Preference/Selector',
+ component: ThemePreferenceSelector,
+ args: {
+ preference: 'system',
+ disabled: false,
+ ariaLabel: 'Theme preference',
+ },
+ argTypes: {
+ preference: {
+ control: 'select',
+ options: ['light', 'dark', 'system'],
+ description: 'Selected theme preference.',
+ },
+ disabled: {
+ control: 'boolean',
+ description: 'Whether users can change the preference.',
+ },
+ ariaLabel: {
+ control: 'text',
+ description: 'Accessible name for the preference group.',
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Light: Story = {
+ args: { preference: 'light' },
+};
+
+export const Dark: Story = {
+ args: { preference: 'dark' },
+};
+
+export const Disabled: Story = {
+ args: { disabled: true },
+};
diff --git a/libs/labs/theme-preference/src/lib/theme-preference-selector.ts b/libs/labs/theme-preference/src/lib/theme-preference-selector.ts
new file mode 100644
index 0000000..9cb26e7
--- /dev/null
+++ b/libs/labs/theme-preference/src/lib/theme-preference-selector.ts
@@ -0,0 +1,39 @@
+import { booleanAttribute, ChangeDetectionStrategy, Component, input, model } from '@angular/core';
+import { MatButtonToggleChange, MatButtonToggleModule } from '@angular/material/button-toggle';
+import { isThemePreference, ThemePreference } from './theme-preference';
+
+/**
+ * Presents light, dark, and device-controlled theme preferences as a single-select control.
+ *
+ * This component is intentionally presentational. Consumers are responsible for persisting
+ * and applying changes, typically through {@link ThemePreferenceService}.
+ */
+@Component({
+ selector: 'ang-theme-preference-selector',
+ imports: [MatButtonToggleModule],
+ templateUrl: './theme-preference-selector.html',
+ styleUrl: './theme-preference-selector.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: { class: 'ang-theme-preference-selector' },
+})
+export class ThemePreferenceSelector {
+ /** Selected theme preference. */
+ readonly preference = model('system');
+
+ /** Whether users can change the selected preference. */
+ readonly disabled = input(false, { transform: booleanAttribute });
+
+ /** Accessible name applied to the single-select toggle group. */
+ readonly ariaLabel = input('Theme preference');
+
+ /**
+ * Updates the preference when Angular Material reports a valid selection.
+ *
+ * @param event Toggle-group change event.
+ */
+ protected handlePreferenceChange(event: MatButtonToggleChange): void {
+ if (isThemePreference(event.value)) {
+ this.preference.set(event.value);
+ }
+ }
+}
diff --git a/libs/labs/theme-preference/src/lib/theme-preference.service.spec.ts b/libs/labs/theme-preference/src/lib/theme-preference.service.spec.ts
new file mode 100644
index 0000000..6bd48e7
--- /dev/null
+++ b/libs/labs/theme-preference/src/lib/theme-preference.service.spec.ts
@@ -0,0 +1,133 @@
+import { DOCUMENT } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { LOCAL_STORAGE, WINDOW } from '@atlasng/core';
+import { provideThemePreference, ThemePreferenceService } from './theme-preference.service';
+
+describe('ThemePreferenceService', () => {
+ const storageKey = 'test-theme-preference';
+ let mediaQueryListener: ((event: MediaQueryListEvent) => void) | undefined;
+ let mediaQuery: MediaQueryList;
+ let storage: Storage;
+
+ function setup({ dark = false, stored }: { dark?: boolean; stored?: string } = {}): ThemePreferenceService {
+ storage = {
+ length: 0,
+ clear: vi.fn(),
+ getItem: vi.fn(() => stored ?? null),
+ key: vi.fn(),
+ removeItem: vi.fn(),
+ setItem: vi.fn(),
+ } as unknown as Storage;
+
+ mediaQuery = {
+ matches: dark,
+ media: '(prefers-color-scheme: dark)',
+ onchange: null,
+ addEventListener: vi.fn((_type: string, listener: EventListenerOrEventListenerObject) => {
+ mediaQueryListener = listener as (event: MediaQueryListEvent) => void;
+ }),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ };
+
+ const window = { matchMedia: vi.fn(() => mediaQuery) } as unknown as Window;
+ TestBed.configureTestingModule({
+ providers: [
+ { provide: WINDOW, useValue: window },
+ { provide: LOCAL_STORAGE, useValue: storage },
+ provideThemePreference({
+ storageKey,
+ lightThemeClass: 'test-light-theme',
+ darkThemeClass: 'test-dark-theme',
+ }),
+ ],
+ });
+
+ return TestBed.inject(ThemePreferenceService);
+ }
+
+ afterEach(() => {
+ const root = TestBed.inject(DOCUMENT).documentElement;
+ root.removeAttribute('data-theme');
+ root.style.removeProperty('color-scheme');
+ root.classList.remove('test-light-theme', 'test-dark-theme');
+ TestBed.resetTestingModule();
+ mediaQueryListener = undefined;
+ });
+
+ it('uses the device setting by default', () => {
+ const service = setup({ dark: true });
+
+ expect(service.preference()).toBe('system');
+ expect(service.resolvedTheme()).toBe('dark');
+ expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
+ expect(document.documentElement).toHaveClass('test-dark-theme');
+ expect(document.documentElement).toHaveStyle({ colorScheme: 'dark' });
+ });
+
+ it('restores a valid persisted preference', () => {
+ const service = setup({ dark: false, stored: 'dark' });
+
+ expect(service.preference()).toBe('dark');
+ expect(service.resolvedTheme()).toBe('dark');
+ });
+
+ it('ignores an invalid persisted preference', () => {
+ const service = setup({ dark: false, stored: 'sepia' });
+
+ expect(service.preference()).toBe('system');
+ expect(service.resolvedTheme()).toBe('light');
+ });
+
+ it('persists and applies an explicit preference', () => {
+ const service = setup({ dark: false });
+
+ service.setPreference('dark');
+
+ expect(storage.setItem).toHaveBeenCalledWith(storageKey, 'dark');
+ expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
+ expect(document.documentElement).toHaveClass('test-dark-theme');
+ expect(document.documentElement).not.toHaveClass('test-light-theme');
+ });
+
+ it('tracks device changes while the system preference is active', () => {
+ const service = setup({ dark: false });
+
+ mediaQueryListener?.({ matches: true } as MediaQueryListEvent);
+
+ expect(service.resolvedTheme()).toBe('dark');
+ expect(document.documentElement).toHaveClass('test-dark-theme');
+ });
+
+ it('does not replace an explicit preference when the device setting changes', () => {
+ const service = setup({ dark: false });
+ service.setPreference('light');
+
+ mediaQueryListener?.({ matches: true } as MediaQueryListEvent);
+
+ expect(service.preference()).toBe('light');
+ expect(service.resolvedTheme()).toBe('light');
+ expect(document.documentElement).toHaveClass('test-light-theme');
+ });
+
+ it('resets to the device preference and removes the persisted override', () => {
+ const service = setup({ dark: true, stored: 'light' });
+
+ service.resetPreference();
+
+ expect(service.preference()).toBe('system');
+ expect(service.resolvedTheme()).toBe('dark');
+ expect(storage.removeItem).toHaveBeenCalledWith(storageKey);
+ expect(document.documentElement).toHaveClass('test-dark-theme');
+ });
+
+ it('removes the media-query listener when destroyed', () => {
+ setup();
+
+ TestBed.resetTestingModule();
+
+ expect(mediaQuery.removeEventListener).toHaveBeenCalledWith('change', mediaQueryListener);
+ });
+});
diff --git a/libs/labs/theme-preference/src/lib/theme-preference.service.ts b/libs/labs/theme-preference/src/lib/theme-preference.service.ts
new file mode 100644
index 0000000..a071b15
--- /dev/null
+++ b/libs/labs/theme-preference/src/lib/theme-preference.service.ts
@@ -0,0 +1,234 @@
+import {
+ DestroyRef,
+ inject,
+ Injectable,
+ InjectionToken,
+ Provider,
+ RendererFactory2,
+ signal,
+ computed,
+} from '@angular/core';
+import { DOCUMENT, LOCAL_STORAGE, WINDOW } from '@atlasng/core';
+import { isThemePreference, ResolvedTheme, ThemePreference } from './theme-preference';
+
+/** Default media query used to resolve the device color scheme. */
+const DARK_MODE_MEDIA_QUERY = '(prefers-color-scheme: dark)';
+
+/** Default browser-storage key for the selected theme preference. */
+const DEFAULT_STORAGE_KEY = '__atlasng_theme_preference__';
+
+/**
+ * Configuration for {@link ThemePreferenceService}.
+ */
+export interface ThemePreferenceConfig {
+ /** Storage backend used for persistence, or `false` to disable persistence. */
+ storage?: Storage | false;
+
+ /** Storage key used to persist the selected preference. */
+ storageKey?: string;
+
+ /** Optional CSS class applied when the resolved theme is light. */
+ lightThemeClass?: string;
+
+ /** Optional CSS class applied when the resolved theme is dark. */
+ darkThemeClass?: string;
+
+ /** Document-root attribute that receives the resolved theme, or `false` to disable it. */
+ themeAttribute?: string | false;
+}
+
+/** Internal configuration token for the theme-preference service. */
+const THEME_PREFERENCE_CONFIG = new InjectionToken('THEME_PREFERENCE_CONFIG', {
+ providedIn: 'root',
+ factory: () => ({}),
+});
+
+/** Fully resolved configuration used internally by the service. */
+interface ResolvedThemePreferenceConfig {
+ readonly storage: Storage | false;
+ readonly storageKey: string;
+ readonly lightThemeClass?: string;
+ readonly darkThemeClass?: string;
+ readonly themeAttribute: string | false;
+}
+
+/**
+ * Provides application-specific theme class and persistence configuration.
+ *
+ * @param config Theme-preference configuration.
+ * @returns Provider for the theme-preference configuration token.
+ */
+export function provideThemePreference(config: ThemePreferenceConfig): Provider {
+ return { provide: THEME_PREFERENCE_CONFIG, useValue: config };
+}
+
+/**
+ * Resolves injected configuration with browser-aware defaults.
+ *
+ * @returns Fully resolved theme-preference configuration.
+ */
+function injectThemePreferenceConfig(): ResolvedThemePreferenceConfig {
+ const config = inject(THEME_PREFERENCE_CONFIG);
+ return {
+ storage: config.storage ?? inject(LOCAL_STORAGE) ?? false,
+ storageKey: config.storageKey ?? DEFAULT_STORAGE_KEY,
+ lightThemeClass: config.lightThemeClass,
+ darkThemeClass: config.darkThemeClass,
+ themeAttribute: config.themeAttribute ?? 'data-theme',
+ };
+}
+
+/**
+ * Persists a user's theme preference, resolves device settings, and applies the active theme.
+ */
+@Injectable({ providedIn: 'root' })
+export class ThemePreferenceService {
+ /** Window abstraction used for device-theme detection. */
+ readonly #window = inject(WINDOW);
+
+ /** Document abstraction whose root element receives the active theme. */
+ readonly #document = inject(DOCUMENT);
+
+ /** Renderer used for document-root attributes, classes, and styles. */
+ readonly #renderer = inject(RendererFactory2).createRenderer(null, null);
+
+ /** Resolved service configuration. */
+ readonly #config = injectThemePreferenceConfig();
+
+ /** Writable user preference state. */
+ readonly #preference = signal('system');
+
+ /** Writable device-theme state. */
+ readonly #deviceTheme = signal('light');
+
+ /** Media-query object used to observe device-theme changes when supported. */
+ readonly #darkModeQuery =
+ typeof this.#window.matchMedia === 'function' ? this.#window.matchMedia(DARK_MODE_MEDIA_QUERY) : undefined;
+
+ /** The preference explicitly selected by the user. */
+ readonly preference = this.#preference.asReadonly();
+
+ /** The concrete light or dark theme currently applied to the application. */
+ readonly resolvedTheme = computed(() => {
+ const preference = this.#preference();
+ return preference === 'system' ? this.#deviceTheme() : preference;
+ });
+
+ /** Initializes persisted state, device detection, and document theming. */
+ constructor() {
+ this.#deviceTheme.set(this.#darkModeQuery?.matches ? 'dark' : 'light');
+ this.#loadPreference();
+ this.#listenForDeviceThemeChanges();
+ this.#applyResolvedTheme();
+ }
+
+ /**
+ * Saves and applies a theme preference.
+ *
+ * @param preference Preference selected by the user.
+ */
+ setPreference(preference: ThemePreference): void {
+ this.#preference.set(preference);
+ this.#savePreference(preference);
+ this.#applyResolvedTheme();
+ }
+
+ /** Restores device-controlled theming and removes the persisted override. */
+ resetPreference(): void {
+ this.#preference.set('system');
+ this.#removeStoredPreference();
+ this.#applyResolvedTheme();
+ }
+
+ /** Loads a valid persisted preference when storage is available. */
+ #loadPreference(): void {
+ const { storage, storageKey } = this.#config;
+ if (!storage) {
+ return;
+ }
+
+ try {
+ const storedPreference = storage.getItem(storageKey);
+ if (isThemePreference(storedPreference)) {
+ this.#preference.set(storedPreference);
+ }
+ } catch {
+ // Storage can become unavailable after feature detection; retain the device default.
+ }
+ }
+
+ /**
+ * Persists the current preference when storage is available.
+ *
+ * @param preference Preference to persist.
+ */
+ #savePreference(preference: ThemePreference): void {
+ const { storage, storageKey } = this.#config;
+ if (!storage) {
+ return;
+ }
+
+ try {
+ storage.setItem(storageKey, preference);
+ } catch {
+ // Ignore storage failures such as private-mode restrictions or exhausted quota.
+ }
+ }
+
+ /** Removes a persisted preference when storage is available. */
+ #removeStoredPreference(): void {
+ const { storage, storageKey } = this.#config;
+ if (!storage) {
+ return;
+ }
+
+ try {
+ storage.removeItem(storageKey);
+ } catch {
+ // Ignore storage failures and continue using the in-memory device preference.
+ }
+ }
+
+ /** Registers and tears down device-theme change handling. */
+ #listenForDeviceThemeChanges(): void {
+ const query = this.#darkModeQuery;
+ if (!query) {
+ return;
+ }
+
+ const handler = (event: MediaQueryListEvent): void => {
+ this.#deviceTheme.set(event.matches ? 'dark' : 'light');
+ if (this.#preference() === 'system') {
+ this.#applyResolvedTheme();
+ }
+ };
+
+ query.addEventListener('change', handler);
+ inject(DestroyRef).onDestroy(() => query.removeEventListener('change', handler));
+ }
+
+ /** Applies the resolved theme to the document root. */
+ #applyResolvedTheme(): void {
+ const root = this.#document.documentElement;
+ const theme = this.resolvedTheme();
+ const { darkThemeClass, lightThemeClass, themeAttribute } = this.#config;
+
+ if (themeAttribute) {
+ this.#renderer.setAttribute(root, themeAttribute, theme);
+ }
+
+ this.#renderer.setStyle(root, 'color-scheme', theme);
+
+ if (lightThemeClass) {
+ this.#renderer.removeClass(root, lightThemeClass);
+ }
+ if (darkThemeClass) {
+ this.#renderer.removeClass(root, darkThemeClass);
+ }
+
+ const activeThemeClass = theme === 'light' ? lightThemeClass : darkThemeClass;
+ if (activeThemeClass) {
+ this.#renderer.addClass(root, activeThemeClass);
+ }
+ }
+}
diff --git a/libs/labs/theme-preference/src/lib/theme-preference.ts b/libs/labs/theme-preference/src/lib/theme-preference.ts
new file mode 100644
index 0000000..a684f43
--- /dev/null
+++ b/libs/labs/theme-preference/src/lib/theme-preference.ts
@@ -0,0 +1,15 @@
+/** Theme preference choices exposed to users. */
+export type ThemePreference = 'light' | 'dark' | 'system';
+
+/** Concrete color schemes that can be applied to the document. */
+export type ResolvedTheme = Exclude;
+
+/**
+ * Determines whether an unknown value is a supported theme preference.
+ *
+ * @param value Value to validate.
+ * @returns Whether the value is a supported theme preference.
+ */
+export function isThemePreference(value: unknown): value is ThemePreference {
+ return value === 'light' || value === 'dark' || value === 'system';
+}
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 6eefdc1..28422e7 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -54,7 +54,8 @@
"@atlasng/labs/section-header": ["./libs/labs/section-header/src/index.ts"],
"@atlasng/labs/skip-to-content-button": ["./libs/labs/skip-to-content-button/src/index.ts"],
"@atlasng/labs/table": ["./libs/labs/table/src/index.ts"],
- "@atlasng/labs/youtube-player": ["./libs/labs/youtube-player/src/index.ts"]
+ "@atlasng/labs/youtube-player": ["./libs/labs/youtube-player/src/index.ts"],
+ "@atlasng/labs/theme-preference": ["libs/labs/theme-preference/src/index.ts"]
}
},
"exclude": ["node_modules", "tmp"],