ngx-theme-stack 1.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # ngx-theme-stack 🎨
2
2
 
3
- A complete and lightweight solution for managing themes (light, dark, system, and custom) in **Angular** applications. Built with performance, accessibility, and SSR (Server-Side Rendering) support in mind.
3
+ ![ngx-theme-stack banner](./banner.png)
4
+
5
+ A simple and powerful headless theme manager for **Angular**. Built for performance and SSR support.
4
6
 
5
7
  ## 🚀 Features
6
8
 
@@ -10,6 +12,7 @@ A complete and lightweight solution for managing themes (light, dark, system, an
10
12
  - 🛠️ **Highly Customizable**: Support for custom themes, class prefixes, and configurable storage.
11
13
  - 🧱 **Modern Architecture**: Powered by Angular Signals for maximum reactivity and performance.
12
14
  - 🌍 **SSR Ready**: Safe to use in Server-Side Rendering environments.
15
+ - 🚫 **Zero Flicker**: Includes an optimized anti-flash script and the **Critters Trick** strategy to prevent theme jumps and network requests on load.
13
16
 
14
17
  ## 📦 Installation
15
18
 
@@ -23,26 +26,29 @@ ng add ngx-theme-stack
23
26
 
24
27
  When running `ng add`, you will be presented with two configuration options:
25
28
 
26
- 1. **Quick Mode**:
29
+ 1. **Quick Mode**:
27
30
  - Applies default configuration instantly.
28
31
  - Initial theme: `system`.
29
32
  - Apply mode: `class` (adds the theme class to the `<html>` element).
30
33
  - Available themes: `['light', 'dark', 'system']`.
34
+ - **Strategy**: `critters` (Zero-flash via CSS inlining).
31
35
 
32
36
  2. **Custom Mode**:
33
37
  - Choose which themes to include (e.g., if you have a `blue` or `high-contrast` theme).
34
38
  - Configure the default theme upon app startup.
35
39
  - Change the `localStorage` key where the theme choice is saved.
36
40
  - Decide how to apply themes: via classes (`class`), attributes (`data-theme`), or both.
41
+ - **Pick your strategy**: `critters` for modern SSR/SSG apps or `blocking` for standard CSS loading.
37
42
 
38
43
  ## 🏗️ Architecture & Extensibility
39
44
 
40
45
  The library is designed to be flexible. The **`CoreThemeService`** is the foundation:
41
46
 
42
- - **Solid Base:** Manages state (`Signal`), persistence (`localStorage`), system detection (`matchMedia`), and safe DOM manipulation (SSR compatible).
43
- - **Extensibility:** You can inject `CoreThemeService` to build your own custom services or components with specific business logic.
47
+ - **Solid Base:** Manages state (`Signal`), persistence (`localStorage`), system detection (`matchMedia`), and safe DOM manipulation (SSR compatible).
48
+ - **Extensibility:** You can inject `CoreThemeService` to build your own custom services or components with specific business logic.
44
49
 
45
50
  ### Utility Services (Ready to Use)
51
+
46
52
  For common use cases, we include three services with predefined logic:
47
53
 
48
54
  1. **`ThemeToggleService`**: A simple binary switch between `light` and `dark`.
@@ -53,18 +59,18 @@ For common use cases, we include three services with predefined logic:
53
59
 
54
60
  ## ⚙️ Supported Versions
55
61
 
56
- | Angular Version | Support |
57
- | :--- | :--- |
58
- | **Angular 21** | ✅ Stable |
59
- | **Angular 20** | ✅ Stable |
60
- | **Angular 19** | ✅ Stable |
61
- | **Angular 18** | ✅ Stable |
62
+ | Angular Version | Support |
63
+ | :-------------- | :-------- |
64
+ | **Angular 21** | ✅ Stable |
65
+ | **Angular 20** | ✅ Stable |
66
+ | **Angular 19** | ✅ Stable |
67
+ | **Angular 18** | ✅ Stable |
62
68
 
63
69
  ## 🛠️ Basic Usage
64
70
 
65
- ### CoreThemeService
71
+ ### CoreThemeService API
66
72
 
67
- This is the main service managing the theme state.
73
+ The foundational service managing the theme state. It exposes pure Angular Signals and a solid minimal API.
68
74
 
69
75
  ```typescript
70
76
  import { inject } from '@angular/core';
@@ -72,14 +78,29 @@ import { CoreThemeService } from 'ngx-theme-stack';
72
78
 
73
79
  @Component({ ... })
74
80
  export class MyComponent {
75
- private themeService = inject(CoreThemeService);
81
+ themeService = inject(CoreThemeService);
82
+
83
+ /* --- 📊 Reactive Signals --- */
84
+
85
+ // The exact theme chosen by the user ('dark', 'light', 'system', etc.)
86
+ selectedTheme = this.themeService.selectedTheme;
87
+
88
+ // The theme finally applied to the DOM (resolves 'system' to 'dark' or 'light')
89
+ resolvedTheme = this.themeService.resolvedTheme;
76
90
 
77
- // Reactive signals
78
- isDark = this.themeService.isDark; // boolean (true/false)
79
- selectedTheme = this.themeService.selectedTheme; // 'light' | 'dark' | 'system' | ...
91
+ // Helper boolean signals evaluating the applied theme
92
+ isDark = this.themeService.isDark;
93
+ isLight = this.themeService.isLight;
94
+ isSystem = this.themeService.isSystem;
80
95
 
81
- changeTheme(theme: string) {
82
- this.themeService.setTheme(theme);
96
+ // True after the first browser render. Great for preventing SSR flickering!
97
+ isHydrated = this.themeService.isHydrated;
98
+
99
+ /* --- 🛠️ Methods --- */
100
+
101
+ changeTheme(newTheme: string) {
102
+ // Validates, applies to the DOM, and saves to localStorage
103
+ this.themeService.setTheme(newTheme);
83
104
  }
84
105
  }
85
106
  ```
@@ -94,10 +115,8 @@ import { ThemeToggleService } from 'ngx-theme-stack';
94
115
  @Component({
95
116
  selector: 'app-theme-toggle',
96
117
  template: `
97
- <button (click)="toggle.toggle()">
98
- Switch to {{ toggle.isDark() ? 'Light' : 'Dark' }}
99
- </button>
100
- `
118
+ <button (click)="toggle.toggle()">Switch to {{ toggle.isDark() ? 'Light' : 'Dark' }}</button>
119
+ `,
101
120
  })
102
121
  export class ThemeToggleComponent {
103
122
  protected toggle = inject(ThemeToggleService);
@@ -106,26 +125,91 @@ export class ThemeToggleComponent {
106
125
 
107
126
  ## 🎨 Styling
108
127
 
109
- By default, the library adds the theme name as a class or attribute to the `<html>` element. Use this in your global styles:
128
+ The `ng add` command automatically creates a **`src/themes.css`** file in your project. This is where you should define your theme-specific CSS variables.
129
+
130
+ The library targets the `<html>` element. Based on your configured `mode`, you should define your variables like this:
110
131
 
111
132
  ```css
112
- /* Using Classes (Default) */
113
- html.dark {
114
- background-color: #121212;
115
- color: white;
133
+ /* src/themes.css */
134
+
135
+ /* Using Classes (Default Mode) */
136
+ :root,
137
+ .light {
138
+ --bg-color: #ffffff;
139
+ --text-color: #333333;
116
140
  }
117
141
 
118
- html.light {
119
- background-color: #ffffff;
120
- color: #333;
142
+ .dark {
143
+ --bg-color: #121212;
144
+ --text-color: #ffffff;
121
145
  }
122
146
 
123
- /* Using Attributes */
124
- [data-theme='blue'] {
125
- --primary-color: #0000ff;
147
+ /* Using Attributes (Attribute Mode) */
148
+ [data-theme='sunset'] {
149
+ --bg-color: #ff5f6d;
150
+ --text-color: #ffffff;
151
+ }
152
+
153
+ /* Base styles using the variables */
154
+ body {
155
+ background-color: var(--bg-color);
156
+ color: var(--text-color);
157
+ transition: background-color 0.3s ease;
126
158
  }
127
159
  ```
128
160
 
161
+ ## 🌪️ Tailwind CSS v4 Integration
162
+
163
+ If you are using **Tailwind CSS v4**, you can achieve a much cleaner HTML by mapping your `themes.css` variables to your Tailwind theme. This avoids cluttering your components with `dark:` variants.
164
+
165
+ ### 1. Configure Custom Variants
166
+
167
+ In your main `styles.css`, define how Tailwind should detect your themes:
168
+
169
+ ```css
170
+ /* src/styles.css */
171
+ @import 'tailwindcss';
172
+
173
+ /* If using Class mode */
174
+ @custom-variant dark (&:where(.dark, .dark *));
175
+
176
+ /* If using Attribute mode */
177
+ @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
178
+ ```
179
+
180
+ ### 2. Map Semantic Variables
181
+
182
+ Extend your Tailwind theme using the variables defined in `themes.css`:
183
+
184
+ ```css
185
+ @theme {
186
+ --color-main-bg: var(--bg-color);
187
+ --color-main-text: var(--text-color);
188
+ --color-card-bg: var(--card-bg);
189
+ }
190
+ ```
191
+
192
+ ### 3. Usage in Components
193
+
194
+ Now, instead of writing `<div class="bg-white dark:bg-black">`, you simply write:
195
+
196
+ ```html
197
+ <div class="bg-main-bg text-main-text shadow-xl">
198
+ <!-- This automatically changes colors based on the active theme -->
199
+ </div>
200
+ ```
201
+
202
+ This approach keeps your UI code clean, semantic, and fully synchronized with `ngx-theme-stack`.
203
+
204
+ ## ⚡ Performance Strategies
205
+
206
+ `ngx-theme-stack` offers two ways to handle the initial theme application to prevent that annoying white flash:
207
+
208
+ 1. **Critters (Default)**: Best for SSR/Static sites. It uses hidden markers to trick the Angular builder into inlining all your theme CSS variables directly in the HTML `<head>`. Result: **Zero network requests for CSS variables.**
209
+ 2. **Blocking**: Best for standard SPAs. It loads the `themes.css` file as a traditional blocking resource.
210
+
211
+ The `ng-add` schematic helps you configure the right one automatically.
212
+
129
213
  ## 📄 License
130
214
 
131
215
  [MIT](./LICENSE)
@@ -1,35 +1,139 @@
1
1
  import { isPlatformBrowser } from '@angular/common';
2
2
  import * as i0 from '@angular/core';
3
- import { InjectionToken, inject, DestroyRef, DOCUMENT, PLATFORM_ID, signal, computed, effect, Injectable } from '@angular/core';
3
+ import { InjectionToken, inject, DestroyRef, DOCUMENT, PLATFORM_ID, signal, computed, effect, afterNextRender, Injectable } from '@angular/core';
4
4
 
5
- /** Built-in themes. All other values are considered custom themes. */
5
+ /**
6
+ * Base error class for `ngx-theme-stack`.
7
+ *
8
+ * Thrown when the library configuration is invalid.
9
+ * Consumers can use `instanceof NgxThemeStackError` to catch only
10
+ * errors originating from this library.
11
+ *
12
+ * @example
13
+ * try {
14
+ * bootstrapApplication(AppComponent, appConfig);
15
+ * } catch (e) {
16
+ * if (e instanceof NgxThemeStackError) {
17
+ * console.error('Bad ngx-theme-stack config:', e.message);
18
+ * }
19
+ * }
20
+ */
21
+ class NgxThemeStackError extends Error {
22
+ name = 'NgxThemeStackError';
23
+ constructor(message) {
24
+ super(`[ngx-theme-stack] ${message}`);
25
+ // Restore prototype chain (required when targeting ES5 or older)
26
+ Object.setPrototypeOf(this, new.target.prototype);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Runtime list of built-in themes.
32
+ *
33
+ * Lives here (and not in config/index.ts) because it defines a type:
34
+ * config/index.ts already imports from types.ts, so placing DEFAULT_THEMES
35
+ * here avoids any circular dependency.
36
+ *
37
+ * ⚠ KEEP IN SYNC with the duplicate in:
38
+ * projects/ngx-theme-stack/schematics/ng-add/constants.ts → DEFAULT_THEMES
39
+ *
40
+ * Schematics compile to CommonJS and cannot import from this ESM file,
41
+ * so the values are intentionally duplicated. Change both at the same time.
42
+ */
6
43
  const DEFAULT_THEMES = ['system', 'light', 'dark'];
7
44
 
8
45
  /**
9
46
  * ⚠ ATTENTION: SHARED CONFIGURATION VALUES
10
47
  *
11
- * These values MUST match the schematic defaults in:
12
- * projects/ngx-theme-stack/schematics/ng-add/index.ts
48
+ * These defaults MUST match the schematic defaults in:
49
+ * projects/ngx-theme-stack/schematics/ng-add/constants.ts → DEFAULTS
13
50
  *
14
- * If you change any of these, you MUST also update the schematic's DEFAULTS
15
- * constant so 'ng add' continues to provide correct hints and clean code.
51
+ * Schematics compile to CommonJS and cannot import from this ESM file,
52
+ * so the values are intentionally duplicated. Change both at the same time.
53
+ *
54
+ * If you change defaults here, also update:
55
+ * schematics/ng-add/constants.ts → DEFAULTS + DEFAULT_THEMES
16
56
  */
17
57
  const DEFAULT_NG_CONFIG = {
18
- theme: 'system',
58
+ defaultTheme: 'system',
19
59
  storageKey: 'ngx-theme-stack-theme',
20
60
  mode: 'class',
61
+ strategy: 'critters',
21
62
  themes: [...DEFAULT_THEMES],
22
63
  };
64
+ // The token uses NgConfig<string> because Angular DI resolves types at runtime
65
+ // and cannot carry generic parameters. Type-safety is enforced at the
66
+ // provideThemeStack() call site instead.
23
67
  const NGX_THEME_STACK_CONFIG = new InjectionToken('NGX_THEME_STACK_CONFIG', {
24
68
  factory: () => DEFAULT_NG_CONFIG,
25
69
  });
26
70
  /**
27
- * Helper function to provide Theme Stack configuration.
71
+ * Provides Theme Stack configuration to Angular's DI system.
72
+ *
73
+ * Custom `themes` are **merged** with the built-in defaults
74
+ * (`'light'`, `'dark'`, `'system'`), so you never lose the base themes.
75
+ *
76
+ * **Defaults:**
77
+ * - `themes`: `['light', 'dark', 'system']`
78
+ * - `defaultTheme`: `'system'`
79
+ * - `storageKey`: `'ngx-theme-stack-theme'`
80
+ * - `mode`: `'class'`
81
+ * - `strategy`: `'critters'`
82
+
83
+ *
84
+ * The type parameter `T` is **inferred automatically** from the `themes` array
85
+ * when passed as a `const` — no need to specify it manually.
86
+ *
87
+ * @typeParam T - Custom theme string literals, inferred from the `themes` option.
88
+ *
89
+ * @param config - Optional partial configuration. Omitted fields fall back to
90
+ * {@link DEFAULT_NG_CONFIG}.
91
+ *
92
+ * @throws {@link NgxThemeStackError}
93
+ * - If any entry in `themes` is an empty or whitespace-only string.
94
+ * - If `defaultTheme` is not present in the resolved (merged) themes array.
95
+ * - If `storageKey` is an empty or whitespace-only string.
96
+ *
97
+ * @example
98
+ * // Default — uses built-in themes and sensible defaults
99
+ * provideThemeStack()
100
+ *
101
+ * @example
102
+ * // SSR/SSG Optimization — uses Critters inlining strategy
103
+ * provideThemeStack({
104
+ * strategy: 'critters',
105
+ * mode: 'class',
106
+ * })
107
+ *
108
+ * @example
109
+ * // Closed union: TypeScript infers 'sepia' | 'ocean' from the array
110
+ * provideThemeStack({
111
+ * themes: ['sepia', 'ocean'] as const,
112
+ * defaultTheme: 'sepia', // ✅ in resolved themes
113
+ * // defaultTheme: 'nope', // ❌ throws NgxThemeStackError at runtime
114
+ * })
115
+ *
116
+ * @example
117
+ * // Custom storage key and mode
118
+ * provideThemeStack({
119
+ * storageKey: 'my-app-theme',
120
+ * mode: 'attribute',
121
+ * })
28
122
  */
29
123
  function provideThemeStack(config = {}) {
124
+ config.themes?.forEach((t) => {
125
+ if (t.trim() === '')
126
+ throw new NgxThemeStackError('Theme cannot be empty or whitespace.');
127
+ });
30
128
  const themes = config.themes
31
129
  ? Array.from(new Set([...DEFAULT_NG_CONFIG.themes, ...config.themes]))
32
130
  : DEFAULT_NG_CONFIG.themes;
131
+ if (config.defaultTheme && !themes.includes(config.defaultTheme)) {
132
+ throw new NgxThemeStackError(`"defaultTheme" must be one of the resolved themes: [${themes.join(', ')}].`);
133
+ }
134
+ if (config.storageKey !== undefined && config.storageKey.trim() === '') {
135
+ throw new NgxThemeStackError('"storageKey" cannot be empty or whitespace.');
136
+ }
33
137
  return {
34
138
  provide: NGX_THEME_STACK_CONFIG,
35
139
  useValue: {
@@ -53,10 +157,21 @@ class CoreThemeService {
53
157
  #document = inject(DOCUMENT);
54
158
  #isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
55
159
  // ── Theme configuration ───────────────────────────────────────────────────
56
- /** List of available themes for Select/Cycle services. Defaults to ['light', 'dark', 'system']. */
160
+ /**
161
+ * The initial stored theme read from localStorage.
162
+ * This is used to determine the initial theme of the application.
163
+ */
164
+ #initialStoredTheme = this.readStoredTheme();
165
+ /** List of available themes for Select/Cycle services. Defaults to ['system', 'light', 'dark']. */
57
166
  availableThemes = this.#config.themes;
58
167
  /** Internal Set for O(1) existence checks. */
59
168
  #validThemes = new Set(this.availableThemes);
169
+ /**
170
+ * The anti-flash class to remove from the host element.
171
+ * Internal mechanism to bridge the gap between the blocking script's
172
+ * initial DOM state and Angular's first effect run.
173
+ */
174
+ #antiFlashClass = null;
60
175
  // ── System preference ─────────────────────────────────────────────────────
61
176
  /** MediaQueryList for OS color scheme, created once and reused. Null in SSR. */
62
177
  #mediaQuery = this.#isBrowser
@@ -68,22 +183,43 @@ class CoreThemeService {
68
183
  /** The theme explicitly selected by the user. May be `'system'`. */
69
184
  selectedTheme = this.#selectedTheme.asReadonly();
70
185
  /** Resolved theme applied to the DOM. Always `'dark'` or `'light'` (or custom) — never `'system'`. */
71
- userTheme = computed(() => {
186
+ resolvedTheme = computed(() => {
72
187
  const theme = this.#selectedTheme();
73
188
  return theme === 'system' ? this.#systemPreference() : theme;
74
- }, ...(ngDevMode ? [{ debugName: "userTheme" }] : /* istanbul ignore next */ []));
189
+ }, ...(ngDevMode ? [{ debugName: "resolvedTheme" }] : /* istanbul ignore next */ []));
75
190
  /** Whether the currently applied theme is dark. */
76
- isDark = computed(() => this.userTheme() === 'dark', ...(ngDevMode ? [{ debugName: "isDark" }] : /* istanbul ignore next */ []));
191
+ isDark = computed(() => this.resolvedTheme() === 'dark', ...(ngDevMode ? [{ debugName: "isDark" }] : /* istanbul ignore next */ []));
77
192
  /** Whether the currently applied theme is light. */
78
- isLight = computed(() => this.userTheme() === 'light', ...(ngDevMode ? [{ debugName: "isLight" }] : /* istanbul ignore next */ []));
193
+ isLight = computed(() => this.resolvedTheme() === 'light', ...(ngDevMode ? [{ debugName: "isLight" }] : /* istanbul ignore next */ []));
194
+ /** Whether the currently applied theme is system. */
195
+ isSystem = computed(() => this.selectedTheme() === 'system', ...(ngDevMode ? [{ debugName: "isSystem" }] : /* istanbul ignore next */ []));
196
+ /**
197
+ * Whether the service has completed client-side initialization.
198
+ *
199
+ * `false` during SSR and on the very first render pass before the initial theme
200
+ * is resolved from `localStorage`. Becomes `true` immediately after the
201
+ * first browser render pass.
202
+ *
203
+ * **Important:** Guard template elements that display `selectedTheme()` or
204
+ * `resolvedTheme()` behind this signal to prevent hydration-mismatch flashes
205
+ * (e.g. if the server renders the default 'system' but the user has 'dark' stored).
206
+ *
207
+ * @example
208
+ * ```html
209
+ * {{ theme.isHydrated() ? theme.selectedTheme() : '...' }}
210
+ * ```
211
+ */
212
+ isHydrated = signal(false, ...(ngDevMode ? [{ debugName: "isHydrated" }] : /* istanbul ignore next */ []));
79
213
  // ── Event handler ─────────────────────────────────────────────────────────
80
214
  #onSystemPreferenceChange = () => this.#systemPreference.set(this.resolveSystemPreference());
81
215
  // ── Lifecycle ─────────────────────────────────────────────────────────────
82
216
  constructor() {
217
+ this.captureAntiFlashClass();
83
218
  if (this.#isBrowser && this.#selectedTheme() === 'system') {
84
219
  this.startSystemThemeListener();
85
220
  }
86
- effect(() => this.applyThemeToDOM(this.userTheme()));
221
+ effect(() => this.applyThemeToDOM(this.resolvedTheme()));
222
+ afterNextRender(() => this.isHydrated.set(true));
87
223
  this.#destroyRef.onDestroy(() => this.stopSystemThemeListener());
88
224
  }
89
225
  // ── Public API ────────────────────────────────────────────────────────────
@@ -91,18 +227,20 @@ class CoreThemeService {
91
227
  * Changes the active theme.
92
228
  *
93
229
  * Persists the choice explicitly so that switching e.g. from `'system'` to
94
- * `'light'` is saved even when the resolved `userTheme` did not change
230
+ * `'light'` is saved even when the resolved theme did not change
95
231
  * (system preference was already `'light'`).
96
232
  *
97
233
  * @param theme - The theme to apply: `'dark'`, `'light'`, `'system'`, or a custom theme name.
98
234
  * @throws If `theme` is not a valid theme according to library configuration.
99
235
  */
100
236
  setTheme(theme) {
101
- if (!this.#isBrowser)
102
- return;
103
237
  if (!this.#validThemes.has(theme)) {
104
- throw new Error(`[ngx-theme-stack] Invalid theme: "${theme}". Valid values are: ${[...this.#validThemes].join(', ')}.`);
238
+ throw new NgxThemeStackError(`Invalid theme: "${theme}". Valid values are: ${[...this.#validThemes].join(', ')}.`);
105
239
  }
240
+ if (!this.#isBrowser)
241
+ return;
242
+ if (theme === this.#selectedTheme())
243
+ return;
106
244
  if (theme === 'system') {
107
245
  this.#systemPreference.set(this.resolveSystemPreference());
108
246
  this.startSystemThemeListener();
@@ -118,9 +256,11 @@ class CoreThemeService {
118
256
  return this.#mediaQuery?.matches ? 'dark' : 'light';
119
257
  }
120
258
  resolveInitialTheme() {
121
- if (!this.#isBrowser)
122
- return this.#config.theme;
123
- return this.readStoredTheme() ?? this.#config.theme;
259
+ const theme = this.#initialStoredTheme;
260
+ if (theme && this.#validThemes.has(theme)) {
261
+ return theme;
262
+ }
263
+ return this.#config.defaultTheme;
124
264
  }
125
265
  startSystemThemeListener() {
126
266
  if (!this.#mediaQuery)
@@ -131,26 +271,28 @@ class CoreThemeService {
131
271
  stopSystemThemeListener() {
132
272
  this.#mediaQuery?.removeEventListener('change', this.#onSystemPreferenceChange);
133
273
  }
134
- applyThemeToDOM(userTheme) {
274
+ applyThemeToDOM(theme) {
135
275
  if (!this.#isBrowser)
136
276
  return;
137
277
  const host = this.#document.documentElement;
278
+ if (this.#antiFlashClass) {
279
+ host.classList.remove(this.#antiFlashClass);
280
+ this.#antiFlashClass = null;
281
+ }
138
282
  const { mode } = this.#config;
139
283
  if (mode === 'attribute' || mode === 'both') {
140
- this.applyThemeAttribute(host, userTheme);
284
+ this.applyThemeAttribute(host, theme);
141
285
  }
142
286
  if (mode === 'class' || mode === 'both') {
143
- this.applyThemeClasses(host, userTheme);
287
+ this.applyThemeClasses(host, theme);
144
288
  }
145
- this.applyColorSchemeHint(host, userTheme);
289
+ this.applyColorSchemeHint(host, theme);
146
290
  }
147
291
  applyThemeAttribute(host, theme) {
148
292
  host.setAttribute('data-theme', theme);
149
293
  }
150
294
  applyThemeClasses(host, theme) {
151
- for (const t of this.availableThemes) {
152
- host.classList.remove(t);
153
- }
295
+ host.classList.remove(...this.availableThemes);
154
296
  host.classList.add(theme);
155
297
  }
156
298
  applyColorSchemeHint(host, theme) {
@@ -160,13 +302,24 @@ class CoreThemeService {
160
302
  }
161
303
  host.style.removeProperty('color-scheme');
162
304
  }
305
+ captureAntiFlashClass() {
306
+ if (!this.#isBrowser || !this.#initialStoredTheme)
307
+ return;
308
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(this.#initialStoredTheme)) {
309
+ this.#antiFlashClass = null;
310
+ return;
311
+ }
312
+ if (this.#initialStoredTheme === 'system') {
313
+ this.#antiFlashClass = this.resolveSystemPreference();
314
+ return;
315
+ }
316
+ this.#antiFlashClass = this.#initialStoredTheme;
317
+ }
163
318
  readStoredTheme() {
164
- try {
165
- const stored = localStorage.getItem(this.#config.storageKey);
166
- if (stored && this.#validThemes.has(stored)) {
167
- return stored;
168
- }
319
+ if (!this.#isBrowser)
169
320
  return null;
321
+ try {
322
+ return localStorage.getItem(this.#config.storageKey);
170
323
  }
171
324
  catch (e) {
172
325
  console.warn('[ngx-theme-stack] Could not read theme from localStorage.', e);
@@ -174,6 +327,8 @@ class CoreThemeService {
174
327
  }
175
328
  }
176
329
  saveTheme(theme) {
330
+ if (!this.#isBrowser)
331
+ return;
177
332
  try {
178
333
  localStorage.setItem(this.#config.storageKey, theme);
179
334
  }
@@ -192,23 +347,30 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.7", ngImpor
192
347
  /**
193
348
  * Convenience service for cycling through themes in a fixed order.
194
349
  *
195
- * Default cycle: `'light'` → `'dark'` → `'system'` → `'light'` → ...
350
+ * Default cycle: `'system'` → `'light'` → `'dark'` → `'system'` → ...
196
351
  *
197
352
  * Use this when you want to offer users a single button that rotates
198
353
  * through all available theme options.
199
354
  */
200
355
  class ThemeCycleService {
201
356
  #core = inject(CoreThemeService);
202
- /** List of all configured themes for cycling. Defaults to ['light', 'dark', 'system']. */
357
+ /** List of all configured themes for cycling. Defaults to `['light', 'dark', 'system']`. */
203
358
  #cycle = this.#core.availableThemes;
204
359
  /** The theme explicitly selected by the user. May be `'system'`. */
205
360
  selectedTheme = this.#core.selectedTheme;
206
- /** Resolved theme applied to the DOM. Always concrete — never `'system'`. */
207
- userTheme = this.#core.userTheme;
208
- /** Whether the currently applied theme is dark. */
361
+ /** Resolved theme currently applied to the DOM. Always concrete — never `'system'`. */
362
+ resolvedTheme = this.#core.resolvedTheme;
363
+ /** Whether the currently applied theme is `'dark'`. */
209
364
  isDark = this.#core.isDark;
210
- /** Whether the currently applied theme is light. */
365
+ /** Whether the currently applied theme is `'light'`. */
211
366
  isLight = this.#core.isLight;
367
+ /** Whether the user has explicitly selected `'system'` preference. */
368
+ isSystem = this.#core.isSystem;
369
+ /**
370
+ * Whether the service has completed client-side initialization and
371
+ * resolved the real persisted theme. Use to prevent hydration flashes.
372
+ */
373
+ isHydrated = this.#core.isHydrated.asReadonly();
212
374
  /**
213
375
  * Advances to the next theme in the cycle.
214
376
  *
@@ -239,16 +401,23 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.7", ngImpor
239
401
  */
240
402
  class ThemeSelectService {
241
403
  #core = inject(CoreThemeService);
242
- /** List of all configured themes. Defaults to ['light', 'dark', 'system']. */
404
+ /** List of all configured themes. Defaults to `['light', 'dark', 'system']`. */
243
405
  availableThemes = this.#core.availableThemes;
244
406
  /** The theme explicitly selected by the user. May be `'system'`. */
245
407
  selectedTheme = this.#core.selectedTheme;
246
- /** Resolved theme applied to the DOM. Always concrete — never `'system'`. */
247
- userTheme = this.#core.userTheme;
248
- /** Whether the currently applied theme is dark. */
408
+ /** Resolved theme currently applied to the DOM. Always concrete — never `'system'`. */
409
+ resolvedTheme = this.#core.resolvedTheme;
410
+ /** Whether the currently applied theme is `'dark'`. */
249
411
  isDark = this.#core.isDark;
250
- /** Whether the currently applied theme is light. */
412
+ /** Whether the currently applied theme is `'light'`. */
251
413
  isLight = this.#core.isLight;
414
+ /** Whether the user has explicitly selected `'system'` preference. */
415
+ isSystem = this.#core.isSystem;
416
+ /**
417
+ * Whether the service has completed client-side initialization and
418
+ * resolved the real persisted theme. Use to prevent hydration flashes.
419
+ */
420
+ isHydrated = this.#core.isHydrated.asReadonly();
252
421
  /**
253
422
  * Applies the given theme.
254
423
  *
@@ -274,12 +443,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.7", ngImpor
274
443
  */
275
444
  class ThemeToggleService {
276
445
  #core = inject(CoreThemeService);
277
- /** Resolved theme applied to the DOM. Always concrete — never `'system'`. */
278
- userTheme = this.#core.userTheme;
279
- /** Whether the currently applied theme is dark. */
446
+ /** Resolved theme currently applied to the DOM. Always concrete — never `'system'`. */
447
+ resolvedTheme = this.#core.resolvedTheme;
448
+ /** The theme explicitly selected by the user. May be `'system'`. */
449
+ selectedTheme = this.#core.selectedTheme;
450
+ /** Whether the currently applied theme is `'dark'`. */
280
451
  isDark = this.#core.isDark;
281
- /** Whether the currently applied theme is light. */
452
+ /** Whether the currently applied theme is `'light'`. */
282
453
  isLight = this.#core.isLight;
454
+ /** Whether the user has explicitly selected `'system'` preference. */
455
+ isSystem = this.#core.isSystem;
456
+ /**
457
+ * Whether the service has completed client-side initialization and
458
+ * resolved the real persisted theme. Use to prevent hydration flashes.
459
+ */
460
+ isHydrated = this.#core.isHydrated.asReadonly();
283
461
  /**
284
462
  * Toggles between `'dark'` and `'light'`.
285
463
  *
@@ -287,7 +465,7 @@ class ThemeToggleService {
287
465
  * Otherwise (including `'system'`), switches to `'dark'`.
288
466
  */
289
467
  toggle() {
290
- const next = this.#core.selectedTheme() === 'dark' ? 'light' : 'dark';
468
+ const next = this.#core.resolvedTheme() === 'dark' ? 'light' : 'dark';
291
469
  this.#core.setTheme(next);
292
470
  }
293
471
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.7", ngImport: i0, type: ThemeToggleService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
@@ -306,5 +484,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.7", ngImpor
306
484
  * Generated bundle index. Do not edit.
307
485
  */
308
486
 
309
- export { CoreThemeService, DEFAULT_NG_CONFIG, DEFAULT_THEMES, NGX_THEME_STACK_CONFIG, ThemeCycleService, ThemeSelectService, ThemeToggleService, provideThemeStack };
487
+ export { CoreThemeService, DEFAULT_NG_CONFIG, DEFAULT_THEMES, NGX_THEME_STACK_CONFIG, NgxThemeStackError, ThemeCycleService, ThemeSelectService, ThemeToggleService, provideThemeStack };
310
488
  //# sourceMappingURL=ngx-theme-stack.mjs.map