@ngxsmk/theme 1.0.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.
@@ -0,0 +1,678 @@
1
+ import { DOCUMENT } from '@angular/common';
2
+ import * as i0 from '@angular/core';
3
+ import { inject, signal, computed, effect, Injectable } from '@angular/core';
4
+
5
+ /**
6
+ * Design token model for the NGXSMK universal theme engine.
7
+ *
8
+ * A `ThemeConfig` is the single source of truth for a theme. The token
9
+ * engine transforms it into platform outputs (CSS custom properties today;
10
+ * SCSS/Tailwind/JSON exporters build on the same model).
11
+ */
12
+
13
+ function hexToHsl(hex) {
14
+ const value = hex.replace('#', '');
15
+ const full = value.length === 3
16
+ ? value
17
+ .split('')
18
+ .map((c) => c + c)
19
+ .join('')
20
+ : value;
21
+ if (!/^[0-9a-fA-F]{6}$/.test(full)) {
22
+ throw new Error(`Invalid hex color: "${hex}"`);
23
+ }
24
+ const r = parseInt(full.slice(0, 2), 16) / 255;
25
+ const g = parseInt(full.slice(2, 4), 16) / 255;
26
+ const b = parseInt(full.slice(4, 6), 16) / 255;
27
+ const max = Math.max(r, g, b);
28
+ const min = Math.min(r, g, b);
29
+ const delta = max - min;
30
+ const l = (max + min) / 2;
31
+ let h = 0;
32
+ let s = 0;
33
+ if (delta !== 0) {
34
+ s = delta / (1 - Math.abs(2 * l - 1));
35
+ switch (max) {
36
+ case r:
37
+ h = 60 * (((g - b) / delta) % 6);
38
+ break;
39
+ case g:
40
+ h = 60 * ((b - r) / delta + 2);
41
+ break;
42
+ default:
43
+ h = 60 * ((r - g) / delta + 4);
44
+ }
45
+ }
46
+ if (h < 0)
47
+ h += 360;
48
+ return { h, s: s * 100, l: l * 100 };
49
+ }
50
+ function hslToHex({ h, s, l }) {
51
+ const sn = s / 100;
52
+ const ln = l / 100;
53
+ const c = (1 - Math.abs(2 * ln - 1)) * sn;
54
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
55
+ const m = ln - c / 2;
56
+ let rgb;
57
+ if (h < 60)
58
+ rgb = [c, x, 0];
59
+ else if (h < 120)
60
+ rgb = [x, c, 0];
61
+ else if (h < 180)
62
+ rgb = [0, c, x];
63
+ else if (h < 240)
64
+ rgb = [0, x, c];
65
+ else if (h < 300)
66
+ rgb = [x, 0, c];
67
+ else
68
+ rgb = [c, 0, x];
69
+ const toHex = (v) => Math.round((v + m) * 255)
70
+ .toString(16)
71
+ .padStart(2, '0');
72
+ return `#${toHex(rgb[0])}${toHex(rgb[1])}${toHex(rgb[2])}`.toUpperCase();
73
+ }
74
+ /** Shift a color's lightness by `delta` percentage points (clamped 0–100). */
75
+ function shiftLightness(hex, delta) {
76
+ const hsl = hexToHsl(hex);
77
+ return hslToHex({ ...hsl, l: Math.min(100, Math.max(0, hsl.l + delta)) });
78
+ }
79
+ /** Rotate a color's hue by `degrees`, keeping saturation and lightness. */
80
+ function rotateHue(hex, degrees) {
81
+ const hsl = hexToHsl(hex);
82
+ return hslToHex({ ...hsl, h: (((hsl.h + degrees) % 360) + 360) % 360 });
83
+ }
84
+ /**
85
+ * Lightness stops for each scale step. Tuned so that step 500/600 stays close
86
+ * to typical brand-color lightness and the extremes remain usable as tinted
87
+ * backgrounds (50–100) and readable text (900–950).
88
+ */
89
+ const LIGHTNESS_STOPS = {
90
+ 50: 97,
91
+ 100: 94,
92
+ 200: 87,
93
+ 300: 78,
94
+ 400: 66,
95
+ 500: 56,
96
+ 600: 48,
97
+ 700: 40,
98
+ 800: 32,
99
+ 900: 25,
100
+ 950: 16,
101
+ };
102
+ /**
103
+ * Derive a full 50–950 color scale from a single brand hex.
104
+ *
105
+ * The input color keeps its exact value at the step whose lightness stop is
106
+ * nearest to the input's own lightness, so `scale[step]` round-trips brand
107
+ * colors instead of shifting them.
108
+ */
109
+ function deriveScale(baseHex) {
110
+ const base = hexToHsl(baseHex);
111
+ let nearest = 500;
112
+ let nearestDelta = Number.POSITIVE_INFINITY;
113
+ for (const [step, stop] of Object.entries(LIGHTNESS_STOPS)) {
114
+ const delta = Math.abs(stop - base.l);
115
+ if (delta < nearestDelta) {
116
+ nearestDelta = delta;
117
+ nearest = Number(step);
118
+ }
119
+ }
120
+ const scale = {};
121
+ for (const [step, stop] of Object.entries(LIGHTNESS_STOPS)) {
122
+ const key = Number(step);
123
+ scale[key] =
124
+ key === nearest
125
+ ? hslToHex(base)
126
+ : hslToHex({ h: base.h, s: base.s, l: stop });
127
+ }
128
+ return scale;
129
+ }
130
+
131
+ /**
132
+ * Cool blue-grey neutral scale matching the Astryx (Meta) design language.
133
+ * Light steps carry a subtle blue tint; the darkest steps flatten toward
134
+ * a near-neutral charcoal so dark-mode surfaces read like Astryx's
135
+ * (#111112 body / #1F1F22 card) rather than a saturated navy.
136
+ */
137
+ const DEFAULT_NEUTRAL = {
138
+ 50: '#F1F4F7',
139
+ 100: '#E4E9EE',
140
+ 200: '#CCD3DB',
141
+ 300: '#A4B0BC',
142
+ 400: '#778593',
143
+ 500: '#4E606F',
144
+ 600: '#3A4650',
145
+ 700: '#2A333B',
146
+ 800: '#1F1F22',
147
+ 900: '#18181A',
148
+ 950: '#111112',
149
+ };
150
+ const DEFAULT_SEMANTIC = {
151
+ success: '#0D8626',
152
+ warning: '#E9AF08',
153
+ error: '#E3193B',
154
+ info: '#0064E0',
155
+ };
156
+ const DEFAULT_FONT_FAMILY = {
157
+ sans: "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif",
158
+ mono: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
159
+ };
160
+ const DEFAULT_TYPOGRAPHY = {
161
+ display: {
162
+ large: { size: '3.5625rem', lineHeight: '4rem', weight: '700' },
163
+ medium: { size: '2.8125rem', lineHeight: '3.25rem', weight: '700' },
164
+ small: { size: '2.25rem', lineHeight: '2.75rem', weight: '700' },
165
+ },
166
+ headline: {
167
+ large: { size: '2rem', lineHeight: '2.5rem', weight: '600' },
168
+ medium: { size: '1.75rem', lineHeight: '2.25rem', weight: '600' },
169
+ small: { size: '1.5rem', lineHeight: '2rem', weight: '600' },
170
+ },
171
+ title: {
172
+ large: { size: '1.375rem', lineHeight: '1.75rem', weight: '600' },
173
+ medium: { size: '1.125rem', lineHeight: '1.5rem', weight: '600' },
174
+ small: { size: '1rem', lineHeight: '1.5rem', weight: '600' },
175
+ },
176
+ body: {
177
+ large: { size: '1rem', lineHeight: '1.5rem', weight: '400' },
178
+ medium: { size: '0.875rem', lineHeight: '1.25rem', weight: '400' },
179
+ small: { size: '0.75rem', lineHeight: '1rem', weight: '400' },
180
+ },
181
+ label: {
182
+ large: { size: '0.875rem', lineHeight: '1.25rem', weight: '500' },
183
+ medium: { size: '0.75rem', lineHeight: '1rem', weight: '500' },
184
+ small: { size: '0.6875rem', lineHeight: '1rem', weight: '500' },
185
+ },
186
+ };
187
+ /**
188
+ * Standard height for single-line text controls (input, select, number-input,
189
+ * combobox, input-group, power-search, multi-select). All controls in the
190
+ * family use this so they read as one consistent size.
191
+ */
192
+ const CONTROL_HEIGHT = '2.5rem';
193
+ const SPACING = {
194
+ '0': '0px',
195
+ '0-5': '0.125rem',
196
+ '1': '0.25rem',
197
+ '1-5': '0.375rem',
198
+ '2': '0.5rem',
199
+ '3': '0.75rem',
200
+ '4': '1rem',
201
+ '5': '1.25rem',
202
+ '6': '1.5rem',
203
+ '8': '2rem',
204
+ '10': '2.5rem',
205
+ '12': '3rem',
206
+ '16': '4rem',
207
+ '20': '5rem',
208
+ '24': '6rem',
209
+ '32': '8rem',
210
+ };
211
+ /**
212
+ * Astryx radius scale: inner 4px, element 8px (buttons/inputs, the base),
213
+ * container 12px (cards), then progressively larger through 28px "page"
214
+ * rounding. Deliberately generous — Astryx reads soft and rounded.
215
+ */
216
+ const RADIUS = {
217
+ none: '0px',
218
+ sm: '0.25rem', // 4px — inner (nested chips, checkboxes)
219
+ md: '0.5rem', // 8px — element (buttons, inputs) — default base
220
+ lg: '0.75rem', // 12px — container (cards, popovers)
221
+ xl: '1rem', // 16px
222
+ '2xl': '1.25rem', // 20px
223
+ '3xl': '1.75rem', // 28px — page / hero surfaces
224
+ full: '9999px',
225
+ };
226
+ /**
227
+ * Soft, diffuse, layered shadows in the Astryx style (two stacked blurs,
228
+ * low alpha). Overlay CSS resolves these to slightly stronger values in
229
+ * dark mode via the elevation tokens on each surface.
230
+ */
231
+ const SHADOW = {
232
+ sm: '0 1px 1px rgb(0 0 0 / 0.08), 0 2px 8px rgb(0 0 0 / 0.08)',
233
+ md: '0 1px 2px rgb(0 0 0 / 0.1), 0 2px 12px rgb(0 0 0 / 0.1)',
234
+ lg: '0 2px 2px rgb(0 0 0 / 0.1), 0 8px 24px rgb(0 0 0 / 0.12)',
235
+ xl: '0 4px 6px rgb(0 0 0 / 0.1), 0 12px 32px rgb(0 0 0 / 0.16)',
236
+ '2xl': '0 8px 12px rgb(0 0 0 / 0.14), 0 24px 48px rgb(0 0 0 / 0.22)',
237
+ };
238
+ /**
239
+ * Stacking ladder for overlay components. Anchored popups sit lowest;
240
+ * tooltips always win. Components must use these instead of ad-hoc values
241
+ * so overlays never fight each other.
242
+ */
243
+ const Z_INDEX = {
244
+ dropdown: '1000',
245
+ sticky: '1100',
246
+ banner: '1200',
247
+ overlay: '1300',
248
+ modal: '1400',
249
+ popover: '1500',
250
+ toast: '1600',
251
+ tooltip: '1700',
252
+ };
253
+ const DURATION = {
254
+ instant: '0ms',
255
+ fast: '100ms',
256
+ normal: '200ms',
257
+ slow: '300ms',
258
+ slower: '500ms',
259
+ };
260
+ const EASING = {
261
+ linear: 'linear',
262
+ in: 'cubic-bezier(0.4, 0, 1, 1)',
263
+ out: 'cubic-bezier(0, 0, 0.2, 1)',
264
+ 'in-out': 'cubic-bezier(0.4, 0, 0.2, 1)',
265
+ emphasized: 'cubic-bezier(0.2, 0, 0, 1)',
266
+ };
267
+
268
+ /**
269
+ * Identity helper that gives `ngxsmk.config.ts` files full type inference:
270
+ *
271
+ * ```ts
272
+ * import { defineConfig } from '@ngxsmk/theme';
273
+ * export default defineConfig({ brand: { primary: '#7C3AED' } });
274
+ * ```
275
+ */
276
+ function defineConfig(config) {
277
+ return config;
278
+ }
279
+ /** Merge a user config with defaults and derive all color scales. */
280
+ function resolveTheme(config) {
281
+ return {
282
+ name: config.name ?? 'custom',
283
+ brandBase: config.brand.primary,
284
+ brand: config.brand.primaryScale ?? deriveScale(config.brand.primary),
285
+ neutral: config.neutral ?? DEFAULT_NEUTRAL,
286
+ semantic: { ...DEFAULT_SEMANTIC, ...config.semantic },
287
+ fontFamily: { ...DEFAULT_FONT_FAMILY, ...config.typography?.fontFamily },
288
+ typography: DEFAULT_TYPOGRAPHY,
289
+ spacing: SPACING,
290
+ radius: RADIUS,
291
+ radiusBase: config.borderRadius ?? 'md',
292
+ shadow: SHADOW,
293
+ duration: DURATION,
294
+ easing: EASING,
295
+ zIndex: Z_INDEX,
296
+ darkMode: {
297
+ strategy: config.darkMode?.strategy ?? 'class',
298
+ className: config.darkMode?.className ?? 'dark',
299
+ },
300
+ overrides: {
301
+ light: config.overrides?.light ?? {},
302
+ dark: config.overrides?.dark ?? {},
303
+ },
304
+ };
305
+ }
306
+
307
+ function scaleVars(prefix, scale) {
308
+ const vars = {};
309
+ for (const [step, value] of Object.entries(scale)) {
310
+ vars[`--ngxsmk-color-${prefix}-${step}`] = value;
311
+ }
312
+ return vars;
313
+ }
314
+ /** Tokens that do not change between light and dark mode. */
315
+ function staticVars(theme) {
316
+ const vars = {
317
+ ...scaleVars('brand', theme.brand),
318
+ ...scaleVars('neutral', theme.neutral),
319
+ '--ngxsmk-font-sans': theme.fontFamily.sans,
320
+ '--ngxsmk-font-mono': theme.fontFamily.mono,
321
+ '--ngxsmk-radius-base': theme.radius[theme.radiusBase],
322
+ '--ngxsmk-control-height': CONTROL_HEIGHT,
323
+ // Component-specific Tokens (Buttons)
324
+ '--ngxsmk-button-bg': 'var(--ngxsmk-color-primary)',
325
+ '--ngxsmk-button-color': 'var(--ngxsmk-color-on-primary)',
326
+ '--ngxsmk-button-font-family': 'var(--ngxsmk-font-sans)',
327
+ '--ngxsmk-button-font-weight': '500',
328
+ '--ngxsmk-button-radius': 'var(--ngxsmk-radius-base)',
329
+ '--ngxsmk-button-disabled-opacity': '0.5',
330
+ // Component-specific Tokens (Cards)
331
+ '--ngxsmk-card-bg': 'var(--ngxsmk-color-surface)',
332
+ '--ngxsmk-card-radius': 'var(--ngxsmk-radius-xl)',
333
+ '--ngxsmk-card-shadow': 'var(--ngxsmk-shadow-sm)',
334
+ '--ngxsmk-card-border-color': 'var(--ngxsmk-color-outline)',
335
+ // Component-specific Tokens (Accordion)
336
+ '--ngxsmk-accordion-bg': 'var(--ngxsmk-color-surface)',
337
+ '--ngxsmk-accordion-radius': 'var(--ngxsmk-radius-lg)',
338
+ '--ngxsmk-accordion-border-color': 'var(--ngxsmk-color-outline)',
339
+ // Component-specific Tokens (Progress)
340
+ '--ngxsmk-progress-height': '0.5rem',
341
+ '--ngxsmk-progress-bg': 'var(--ngxsmk-color-surface-variant)',
342
+ '--ngxsmk-progress-color': 'var(--ngxsmk-color-primary)',
343
+ '--ngxsmk-progress-radius': 'var(--ngxsmk-radius-full)',
344
+ // Component-specific Tokens (Switch)
345
+ '--ngxsmk-switch-width': '2.25rem',
346
+ '--ngxsmk-switch-height': '1.25rem',
347
+ '--ngxsmk-switch-radius': 'var(--ngxsmk-radius-full)',
348
+ '--ngxsmk-switch-bg': 'var(--ngxsmk-color-outline-strong)',
349
+ '--ngxsmk-switch-thumb-size': '0.875rem',
350
+ '--ngxsmk-switch-thumb-bg': 'var(--ngxsmk-color-surface)',
351
+ '--ngxsmk-switch-checked-bg': 'var(--ngxsmk-color-primary)',
352
+ };
353
+ for (const [key, value] of Object.entries(theme.spacing)) {
354
+ vars[`--ngxsmk-space-${key}`] = value;
355
+ }
356
+ for (const [key, value] of Object.entries(theme.radius)) {
357
+ vars[`--ngxsmk-radius-${key}`] = value;
358
+ }
359
+ for (const [key, value] of Object.entries(theme.shadow)) {
360
+ vars[`--ngxsmk-shadow-${key}`] = value;
361
+ }
362
+ for (const [key, value] of Object.entries(theme.duration)) {
363
+ vars[`--ngxsmk-duration-${key}`] = value;
364
+ }
365
+ for (const [key, value] of Object.entries(theme.easing)) {
366
+ vars[`--ngxsmk-ease-${key}`] = value;
367
+ }
368
+ for (const [key, value] of Object.entries(theme.zIndex)) {
369
+ vars[`--ngxsmk-z-${key}`] = value;
370
+ }
371
+ const sizes = { large: 'lg', medium: 'md', small: 'sm' };
372
+ for (const [role, styles] of Object.entries(theme.typography)) {
373
+ for (const [size, suffix] of Object.entries(sizes)) {
374
+ const style = styles[size];
375
+ vars[`--ngxsmk-text-${role}-${suffix}-size`] = style.size;
376
+ vars[`--ngxsmk-text-${role}-${suffix}-line`] = style.lineHeight;
377
+ vars[`--ngxsmk-text-${role}-${suffix}-weight`] = style.weight;
378
+ }
379
+ }
380
+ // Extra-small body text for dense metadata rows; below the smallest
381
+ // TypeRoleSize so it is emitted outside the role loop.
382
+ vars['--ngxsmk-text-body-xs-size'] = '0.6875rem';
383
+ vars['--ngxsmk-text-body-xs-line'] = '1rem';
384
+ vars['--ngxsmk-text-body-xs-weight'] = '400';
385
+ return vars;
386
+ }
387
+ /** Semantic role tokens for light mode. */
388
+ function lightVars(theme) {
389
+ const vars = {
390
+ 'color-scheme': 'light',
391
+ '--ngxsmk-color-primary': theme.brandBase,
392
+ '--ngxsmk-color-on-primary': '#FFFFFF',
393
+ '--ngxsmk-color-primary-hover': shiftLightness(theme.brandBase, -8),
394
+ '--ngxsmk-color-primary-active': shiftLightness(theme.brandBase, -14),
395
+ '--ngxsmk-color-primary-container': theme.brand[100],
396
+ '--ngxsmk-color-on-primary-container': theme.brand[900],
397
+ // Astryx: cool-grey body, white cards floating on top, hairline borders.
398
+ '--ngxsmk-color-background': theme.neutral[50],
399
+ '--ngxsmk-color-on-background': '#0A1317',
400
+ '--ngxsmk-color-surface': '#FFFFFF',
401
+ '--ngxsmk-color-on-surface': '#0A1317',
402
+ '--ngxsmk-color-surface-variant': theme.neutral[100],
403
+ '--ngxsmk-color-on-surface-variant': theme.neutral[500],
404
+ '--ngxsmk-color-surface-hover': 'rgb(5 54 89 / 0.05)',
405
+ '--ngxsmk-color-surface-active': 'rgb(5 54 89 / 0.1)',
406
+ '--ngxsmk-color-surface-container': '#FFFFFF',
407
+ '--ngxsmk-color-outline': 'rgb(5 54 89 / 0.1)',
408
+ '--ngxsmk-color-outline-strong': theme.neutral[200],
409
+ '--ngxsmk-color-outline-variant': 'rgb(5 54 89 / 0.06)',
410
+ '--ngxsmk-color-ring': theme.brandBase,
411
+ '--ngxsmk-color-backdrop': 'rgb(1 18 40 / 0.4)',
412
+ '--ngxsmk-shadow-focus': `0 0 0 2px ${theme.brand[100]}, 0 0 0 4px color-mix(in srgb, ${theme.brandBase} 45%, transparent)`,
413
+ };
414
+ const tertiary = deriveScale(rotateHue(theme.brandBase, 64));
415
+ vars['--ngxsmk-color-tertiary'] = tertiary[500];
416
+ vars['--ngxsmk-color-on-tertiary'] = '#FFFFFF';
417
+ vars['--ngxsmk-color-tertiary-container'] = tertiary[100];
418
+ vars['--ngxsmk-color-on-tertiary-container'] = tertiary[900];
419
+ for (const [name, hex] of Object.entries(theme.semantic)) {
420
+ const scale = deriveScale(hex);
421
+ vars[`--ngxsmk-color-${name}`] = hex;
422
+ vars[`--ngxsmk-color-on-${name}`] = '#FFFFFF';
423
+ vars[`--ngxsmk-color-${name}-container`] = scale[100];
424
+ vars[`--ngxsmk-color-on-${name}-container`] = scale[800];
425
+ }
426
+ return { ...vars, ...theme.overrides.light };
427
+ }
428
+ /** Semantic role tokens for dark mode. */
429
+ function darkVars(theme) {
430
+ const vars = {
431
+ 'color-scheme': 'dark',
432
+ '--ngxsmk-color-primary': theme.brand[400],
433
+ '--ngxsmk-color-on-primary': '#FFFFFF',
434
+ '--ngxsmk-color-primary-hover': theme.brand[300],
435
+ '--ngxsmk-color-primary-active': theme.brand[200],
436
+ '--ngxsmk-color-primary-container': theme.brand[900],
437
+ '--ngxsmk-color-on-primary-container': theme.brand[100],
438
+ // Astryx dark: near-neutral charcoal body, slightly lighter card,
439
+ // lighter still for popovers; hairline light-on-dark borders.
440
+ '--ngxsmk-color-background': '#111112',
441
+ '--ngxsmk-color-on-background': '#DFE2E5',
442
+ '--ngxsmk-color-surface': '#1F1F22',
443
+ '--ngxsmk-color-on-surface': '#DFE2E5',
444
+ '--ngxsmk-color-surface-variant': '#28292C',
445
+ '--ngxsmk-color-on-surface-variant': '#AAAFB5',
446
+ '--ngxsmk-color-surface-hover': 'rgb(255 255 255 / 0.05)',
447
+ '--ngxsmk-color-surface-active': 'rgb(255 255 255 / 0.1)',
448
+ '--ngxsmk-color-surface-container': '#28292C',
449
+ '--ngxsmk-color-outline': 'rgb(242 244 246 / 0.1)',
450
+ '--ngxsmk-color-outline-strong': '#494D53',
451
+ '--ngxsmk-color-outline-variant': 'rgb(242 244 246 / 0.06)',
452
+ '--ngxsmk-color-ring': theme.brand[400],
453
+ '--ngxsmk-color-backdrop': 'rgb(17 17 18 / 0.6)',
454
+ '--ngxsmk-shadow-focus': `0 0 0 2px color-mix(in srgb, ${theme.brand[400]} 25%, transparent), 0 0 0 4px color-mix(in srgb, ${theme.brand[400]} 45%, transparent)`,
455
+ };
456
+ const tertiary = deriveScale(rotateHue(theme.brandBase, 64));
457
+ vars['--ngxsmk-color-tertiary'] = tertiary[400];
458
+ vars['--ngxsmk-color-on-tertiary'] = tertiary[950];
459
+ vars['--ngxsmk-color-tertiary-container'] = tertiary[900];
460
+ vars['--ngxsmk-color-on-tertiary-container'] = tertiary[100];
461
+ for (const [name, hex] of Object.entries(theme.semantic)) {
462
+ const scale = deriveScale(hex);
463
+ vars[`--ngxsmk-color-${name}`] = scale[400];
464
+ vars[`--ngxsmk-color-on-${name}`] = scale[950];
465
+ vars[`--ngxsmk-color-${name}-container`] = scale[950];
466
+ vars[`--ngxsmk-color-on-${name}-container`] = scale[300];
467
+ }
468
+ return { ...vars, ...theme.overrides.dark };
469
+ }
470
+ function block(selector, vars, indent = '') {
471
+ const lines = Object.entries(vars).map(([name, value]) => `${indent} ${name}: ${value};`);
472
+ return `${indent}${selector} {\n${lines.join('\n')}\n${indent}}`;
473
+ }
474
+ /**
475
+ * Transform a theme config into a CSS stylesheet of `--ngxsmk-*` custom
476
+ * properties. Emits static tokens plus light-mode roles on `:root`, and
477
+ * dark-mode roles according to the configured dark-mode strategy.
478
+ */
479
+ function buildThemeCss(config) {
480
+ const theme = 'brand' in config && 'spacing' in config
481
+ ? config
482
+ : resolveTheme(config);
483
+ const root = block(':root', { ...staticVars(theme), ...lightVars(theme) });
484
+ const dark = darkVars(theme);
485
+ let darkBlock;
486
+ switch (theme.darkMode.strategy) {
487
+ case 'media':
488
+ darkBlock = `@media (prefers-color-scheme: dark) {\n${block(':root', dark, ' ')}\n}`;
489
+ break;
490
+ case 'system': {
491
+ // `light-dark()` support is per-property; the class block still serves
492
+ // as the override hook, so `system` behaves as media + class.
493
+ const cn = theme.darkMode.className;
494
+ darkBlock = [
495
+ `@media (prefers-color-scheme: dark) {\n${block(':root', dark, ' ')}\n}`,
496
+ block(`:root.${cn}, :root.${cn} body, .${cn}`, dark),
497
+ ].join('\n\n');
498
+ break;
499
+ }
500
+ default: {
501
+ // `:root.<class>` (specificity 0,2,0) plus a `body` variant defend the
502
+ // dark tokens against third-party stylesheets that redefine `--ngxsmk-*`
503
+ // on a plain `:root` (0,1,0) — those would otherwise win by source order
504
+ // when injected into <head> after this sheet. The bare `.<class>` keeps
505
+ // scoped (non-root) dark regions working.
506
+ const cn = theme.darkMode.className;
507
+ darkBlock = block(`:root.${cn}, :root.${cn} body, .${cn}`, dark);
508
+ }
509
+ }
510
+ return `/* Generated by @ngxsmk/theme — do not edit by hand. */\n${root}\n\n${darkBlock}\n`;
511
+ }
512
+
513
+ /**
514
+ * Default preset: Astryx (Meta) blue. Light-mode primary is the exact
515
+ * Astryx accent (#0064E0); dark mode brightens it to #2694FE via the
516
+ * scale, matching astryx.atmeta.com.
517
+ */
518
+ const astryxPreset = {
519
+ name: 'astryx',
520
+ brand: {
521
+ primary: '#0064E0',
522
+ primaryScale: {
523
+ 50: '#E7F1FE',
524
+ 100: '#CFE3FD',
525
+ 200: '#9AC6FC',
526
+ 300: '#5CA5FB',
527
+ 400: '#2694FE',
528
+ 500: '#0064E0',
529
+ 600: '#0057C2',
530
+ 700: '#0046A0',
531
+ 800: '#00397F',
532
+ 900: '#002C63',
533
+ 950: '#001A3D',
534
+ },
535
+ },
536
+ };
537
+ /** Retained alias so existing violet-themed demos keep working. */
538
+ const violetPreset = {
539
+ name: 'violet',
540
+ brand: { primary: '#7C3AED' },
541
+ };
542
+ /** Monochrome preset in the spirit of shadcn/ui's default look. */
543
+ const neutralPreset = {
544
+ name: 'neutral',
545
+ brand: {
546
+ primary: '#18181B',
547
+ // A neutral brand cannot be derived from lightness alone — pin the scale.
548
+ primaryScale: {
549
+ 50: '#FAFAFA',
550
+ 100: '#F4F4F5',
551
+ 200: '#E4E4E7',
552
+ 300: '#D4D4D8',
553
+ 400: '#A1A1AA',
554
+ 500: '#71717A',
555
+ 600: '#3F3F46',
556
+ 700: '#27272A',
557
+ 800: '#1F1F23',
558
+ 900: '#18181B',
559
+ 950: '#09090B',
560
+ },
561
+ },
562
+ overrides: {
563
+ // A near-black brand must invert in dark mode to stay visible.
564
+ dark: {
565
+ '--ngxsmk-color-primary': '#FAFAFA',
566
+ '--ngxsmk-color-on-primary': '#18181B',
567
+ '--ngxsmk-color-primary-hover': '#E4E4E7',
568
+ '--ngxsmk-color-primary-active': '#D4D4D8',
569
+ '--ngxsmk-color-ring': '#D4D4D8',
570
+ },
571
+ },
572
+ };
573
+ const emeraldPreset = {
574
+ name: 'emerald',
575
+ brand: { primary: '#059669' },
576
+ };
577
+ const rosePreset = {
578
+ name: 'rose',
579
+ brand: { primary: '#E11D48' },
580
+ };
581
+ const presets = {
582
+ astryx: astryxPreset,
583
+ violet: violetPreset,
584
+ neutral: neutralPreset,
585
+ emerald: emeraldPreset,
586
+ rose: rosePreset,
587
+ };
588
+
589
+ const STORAGE_KEY = 'ngxsmk-theme-mode';
590
+ const STYLE_ID = 'ngxsmk-theme';
591
+ /**
592
+ * Runtime theme control: dark-mode switching (class strategy) and dynamic
593
+ * theme application by injecting generated token CSS into the document.
594
+ *
595
+ * SSR-safe: all DOM access goes through the injected document and degrades
596
+ * gracefully when `window`/`matchMedia` are unavailable.
597
+ */
598
+ class NgxsmkThemeService {
599
+ document = inject(DOCUMENT);
600
+ /** User preference: explicit light/dark or follow the OS. */
601
+ mode = signal(this.restoreMode(), /* @ts-ignore */
602
+ ...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
603
+ systemDark = signal(this.matchSystemDark(), /* @ts-ignore */
604
+ ...(ngDevMode ? [{ debugName: "systemDark" }] : /* istanbul ignore next */ []));
605
+ /** Effective darkness after resolving `system` against the OS setting. */
606
+ isDark = computed(() => this.mode() === 'system' ? this.systemDark() : this.mode() === 'dark', /* @ts-ignore */
607
+ ...(ngDevMode ? [{ debugName: "isDark" }] : /* istanbul ignore next */ []));
608
+ constructor() {
609
+ const media = this.document.defaultView?.matchMedia?.('(prefers-color-scheme: dark)');
610
+ media?.addEventListener('change', (e) => this.systemDark.set(e.matches));
611
+ effect(() => {
612
+ this.document.documentElement.classList.toggle('dark', this.isDark());
613
+ });
614
+ }
615
+ setMode(mode) {
616
+ this.mode.set(mode);
617
+ try {
618
+ this.document.defaultView?.localStorage?.setItem(STORAGE_KEY, mode);
619
+ }
620
+ catch {
621
+ // Storage unavailable (SSR, privacy mode) — preference is session-only.
622
+ }
623
+ }
624
+ toggle() {
625
+ this.setMode(this.isDark() ? 'light' : 'dark');
626
+ }
627
+ /**
628
+ * Apply a theme at runtime. Generates token CSS from the config and swaps
629
+ * it into a dedicated `<style>` element, overriding the build-time theme.
630
+ */
631
+ applyTheme(config) {
632
+ const head = this.document.head;
633
+ let style = this.document.getElementById(STYLE_ID);
634
+ if (!style) {
635
+ style = this.document.createElement('style');
636
+ style.id = STYLE_ID;
637
+ head.appendChild(style);
638
+ }
639
+ style.textContent = buildThemeCss(config);
640
+ }
641
+ /** Remove a runtime-applied theme, falling back to the build-time CSS. */
642
+ clearTheme() {
643
+ this.document.getElementById(STYLE_ID)?.remove();
644
+ }
645
+ restoreMode() {
646
+ try {
647
+ const stored = this.document.defaultView?.localStorage?.getItem(STORAGE_KEY);
648
+ if (stored === 'light' || stored === 'dark' || stored === 'system') {
649
+ return stored;
650
+ }
651
+ }
652
+ catch {
653
+ // Fall through to default.
654
+ }
655
+ return 'system';
656
+ }
657
+ matchSystemDark() {
658
+ return (this.document.defaultView?.matchMedia?.('(prefers-color-scheme: dark)')
659
+ .matches ?? false);
660
+ }
661
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxsmkThemeService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
662
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxsmkThemeService, providedIn: 'root' });
663
+ }
664
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: NgxsmkThemeService, decorators: [{
665
+ type: Injectable,
666
+ args: [{ providedIn: 'root' }]
667
+ }], ctorParameters: () => [] });
668
+
669
+ /*
670
+ * Public API Surface of @ngxsmk/theme
671
+ */
672
+
673
+ /**
674
+ * Generated bundle index. Do not edit.
675
+ */
676
+
677
+ export { CONTROL_HEIGHT, DEFAULT_FONT_FAMILY, DEFAULT_NEUTRAL, DEFAULT_SEMANTIC, DEFAULT_TYPOGRAPHY, DURATION, EASING, NgxsmkThemeService, RADIUS, SHADOW, SPACING, Z_INDEX, astryxPreset, buildThemeCss, defineConfig, deriveScale, emeraldPreset, hexToHsl, hslToHex, neutralPreset, presets, resolveTheme, rosePreset, rotateHue, shiftLightness, violetPreset };
678
+ //# sourceMappingURL=ngxsmk-theme.mjs.map