@ngxsmk/theme 1.3.1 → 2.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.
@@ -1,6 +1,6 @@
1
1
  import { DOCUMENT } from '@angular/common';
2
2
  import * as i0 from '@angular/core';
3
- import { inject, signal, computed, effect, Injectable } from '@angular/core';
3
+ import { inject, Injectable, signal, computed, effect } from '@angular/core';
4
4
 
5
5
  /**
6
6
  * Design token model for the NGXSMK universal theme engine.
@@ -10,7 +10,7 @@ import { inject, signal, computed, effect, Injectable } from '@angular/core';
10
10
  * SCSS/Tailwind/JSON exporters build on the same model).
11
11
  */
12
12
 
13
- function hexToHsl(hex) {
13
+ function hexToRgb(hex) {
14
14
  const value = hex.replace('#', '');
15
15
  const full = value.length === 3
16
16
  ? value
@@ -24,90 +24,80 @@ function hexToHsl(hex) {
24
24
  const r = parseInt(full.slice(0, 2), 16) / 255;
25
25
  const g = parseInt(full.slice(2, 4), 16) / 255;
26
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
- }
27
+ return { r, g, b };
28
+ }
29
+ function srgbToLinear(c) {
30
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
31
+ }
32
+ function linearToSrgb(c) {
33
+ return c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
34
+ }
35
+ function hexToOklch(hex) {
36
+ const { r: sr, g: sg, b: sb } = hexToRgb(hex);
37
+ const r = srgbToLinear(sr);
38
+ const g = srgbToLinear(sg);
39
+ const b = srgbToLinear(sb);
40
+ const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
41
+ const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
42
+ const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
43
+ const l_ = Math.cbrt(l);
44
+ const m_ = Math.cbrt(m);
45
+ const s_ = Math.cbrt(s);
46
+ const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
47
+ const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
48
+ const b_ = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
49
+ const C = Math.sqrt(a * a + b_ * b_);
50
+ let h = (Math.atan2(b_, a) * 180) / Math.PI;
46
51
  if (h < 0)
47
52
  h += 360;
48
- return { h, s: s * 100, l: l * 100 };
53
+ return { l: L, c: C, h };
49
54
  }
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();
55
+ function oklchToHex({ l: L, c: C, h }) {
56
+ const hRad = (h * Math.PI) / 180;
57
+ const a = C * Math.cos(hRad);
58
+ const b_ = C * Math.sin(hRad);
59
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b_;
60
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b_;
61
+ const s_ = L - 0.0894841775 * a - 1.291485548 * b_;
62
+ const l = l_ * l_ * l_;
63
+ const m = m_ * m_ * m_;
64
+ const s = s_ * s_ * s_;
65
+ const r = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
66
+ const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
67
+ const b = -0.0041960863 * l - 0.7034186148 * m + 1.707614701 * s;
68
+ const rClamped = Math.min(255, Math.max(0, Math.round(linearToSrgb(r) * 255)));
69
+ const gClamped = Math.min(255, Math.max(0, Math.round(linearToSrgb(g) * 255)));
70
+ const bClamped = Math.min(255, Math.max(0, Math.round(linearToSrgb(b) * 255)));
71
+ const toHex = (v) => v.toString(16).padStart(2, '0');
72
+ return `#${toHex(rClamped)}${toHex(gClamped)}${toHex(bClamped)}`.toUpperCase();
73
73
  }
74
- /** Shift a color's lightness by `delta` percentage points (clamped 0–100). */
74
+ /** Shift a color's lightness by `delta` (clamped 0–1). */
75
75
  function shiftLightness(hex, delta) {
76
- const hsl = hexToHsl(hex);
77
- return hslToHex({ ...hsl, l: Math.min(100, Math.max(0, hsl.l + delta)) });
76
+ const oklch = hexToOklch(hex);
77
+ return oklchToHex({ ...oklch, l: Math.min(1.0, Math.max(0.0, oklch.l + delta)) });
78
78
  }
79
- /** Rotate a color's hue by `degrees`, keeping saturation and lightness. */
79
+ /** Rotate a color's hue by `degrees`, keeping chroma and lightness. */
80
80
  function rotateHue(hex, degrees) {
81
- const hsl = hexToHsl(hex);
82
- return hslToHex({ ...hsl, h: (((hsl.h + degrees) % 360) + 360) % 360 });
81
+ const oklch = hexToOklch(hex);
82
+ return oklchToHex({ ...oklch, h: (((oklch.h + degrees) % 360) + 360) % 360 });
83
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
- */
84
+ /** Lightness stops for each scale step in OKLCH (0–1). */
89
85
  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,
86
+ 50: 0.97,
87
+ 100: 0.94,
88
+ 200: 0.87,
89
+ 300: 0.78,
90
+ 400: 0.66,
91
+ 500: 0.56,
92
+ 600: 0.48,
93
+ 700: 0.4,
94
+ 800: 0.32,
95
+ 900: 0.25,
96
+ 950: 0.16,
101
97
  };
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
- */
98
+ /** Derive a full 50–950 color scale from a single brand hex. */
109
99
  function deriveScale(baseHex) {
110
- const base = hexToHsl(baseHex);
100
+ const base = hexToOklch(baseHex);
111
101
  let nearest = 500;
112
102
  let nearestDelta = Number.POSITIVE_INFINITY;
113
103
  for (const [step, stop] of Object.entries(LIGHTNESS_STOPS)) {
@@ -120,7 +110,14 @@ function deriveScale(baseHex) {
120
110
  const scale = {};
121
111
  for (const [step, stop] of Object.entries(LIGHTNESS_STOPS)) {
122
112
  const key = Number(step);
123
- scale[key] = key === nearest ? hslToHex(base) : hslToHex({ h: base.h, s: base.s, l: stop });
113
+ if (key === nearest) {
114
+ scale[key] = oklchToHex(base);
115
+ }
116
+ else {
117
+ // Perceptually scale chroma using a sine taper so that extremes (50/950) don't clip/saturate unnaturally.
118
+ const scaleChroma = base.c * Math.sin(Math.PI * stop);
119
+ scale[key] = oklchToHex({ l: stop, c: scaleChroma, h: base.h });
120
+ }
124
121
  }
125
122
  return scale;
126
123
  }
@@ -151,7 +148,7 @@ const DEFAULT_SEMANTIC = {
151
148
  };
152
149
  const DEFAULT_FONT_FAMILY = {
153
150
  sans: "'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif",
154
- mono: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
151
+ mono: "'Geist Mono', 'Geist Mono Fallback', monospace",
155
152
  };
156
153
  const DEFAULT_TYPOGRAPHY = {
157
154
  display: {
@@ -216,6 +213,20 @@ const TRACKING = {
216
213
  normal: '0',
217
214
  wide: '0.08em',
218
215
  };
216
+ /** Font-weight tokens — standalone scale for direct use outside typography presets. */
217
+ const FONT_WEIGHT = {
218
+ regular: '400',
219
+ medium: '500',
220
+ semibold: '600',
221
+ bold: '700',
222
+ };
223
+ /** Line-height tokens — unitless multipliers for flexible leading control. */
224
+ const LEADING = {
225
+ none: '1',
226
+ tight: '1.25',
227
+ normal: '1.5',
228
+ relaxed: '1.625',
229
+ };
219
230
  const SPACING = {
220
231
  '0': '0px',
221
232
  '0-5': '0.125rem',
@@ -342,51 +353,34 @@ function scaleVars(prefix, scale) {
342
353
  }
343
354
  return vars;
344
355
  }
345
- /** Tokens that do not change between light and dark mode. */
346
- function staticVars(theme) {
347
- const vars = {
348
- ...scaleVars('brand', theme.brand),
349
- ...scaleVars('secondary', theme.secondary),
350
- ...scaleVars('neutral', theme.neutral),
351
- '--ngxsmk-font-sans': theme.fontFamily.sans,
352
- '--ngxsmk-font-mono': theme.fontFamily.mono,
353
- '--ngxsmk-radius-base': theme.radius[theme.radiusBase],
354
- '--ngxsmk-control-height': CONTROL_HEIGHT,
355
- // Motion Tokens
356
- '--ngxsmk-motion-duration': 'var(--ngxsmk-duration-normal)',
357
- '--ngxsmk-motion-ease': 'var(--ngxsmk-ease-in-out)',
358
- // Interactive-state motion: shared lift/press transforms. Neutralized to
359
- // `none` under prefers-reduced-motion (see buildThemeCss).
360
- '--ngxsmk-hover-lift': 'translateY(-1px)',
361
- '--ngxsmk-press-scale': 'scale(0.98)',
362
- // Focus ring: THE focus indicator for every component. Aliases the
363
- // mode-aware --ngxsmk-shadow-focus; the error variant derives from the
364
- // semantic error role so invalid controls ring red in both modes.
365
- '--ngxsmk-focus-ring': 'var(--ngxsmk-shadow-focus)',
366
- '--ngxsmk-focus-ring-error': '0 0 0 2px color-mix(in srgb, var(--ngxsmk-color-error) 15%, transparent), ' +
367
- '0 0 0 4px color-mix(in srgb, var(--ngxsmk-color-error) 45%, transparent)',
368
- // Component-specific Tokens (Buttons)
356
+ /** Default CSS custom properties for each component, emitted once in :root. */
357
+ const COMPONENT_TOKEN_DEFAULTS = {
358
+ button: {
369
359
  '--ngxsmk-button-bg': 'var(--ngxsmk-color-primary)',
370
360
  '--ngxsmk-button-color': 'var(--ngxsmk-color-on-primary)',
371
361
  '--ngxsmk-button-font-family': 'var(--ngxsmk-font-sans)',
372
362
  '--ngxsmk-button-font-weight': '500',
373
363
  '--ngxsmk-button-radius': 'var(--ngxsmk-radius-base)',
374
364
  '--ngxsmk-button-disabled-opacity': '0.5',
375
- // Component-specific Tokens (Cards)
365
+ },
366
+ card: {
376
367
  '--ngxsmk-card-bg': 'var(--ngxsmk-color-surface)',
377
368
  '--ngxsmk-card-radius': 'var(--ngxsmk-radius-xl)',
378
369
  '--ngxsmk-card-shadow': 'var(--ngxsmk-shadow-sm)',
379
370
  '--ngxsmk-card-border-color': 'var(--ngxsmk-color-outline)',
380
- // Component-specific Tokens (Accordion)
371
+ },
372
+ accordion: {
381
373
  '--ngxsmk-accordion-bg': 'var(--ngxsmk-color-surface)',
382
374
  '--ngxsmk-accordion-radius': 'var(--ngxsmk-radius-lg)',
383
375
  '--ngxsmk-accordion-border-color': 'var(--ngxsmk-color-outline)',
384
- // Component-specific Tokens (Progress)
376
+ },
377
+ progress: {
385
378
  '--ngxsmk-progress-height': '0.5rem',
386
379
  '--ngxsmk-progress-bg': 'var(--ngxsmk-color-surface-variant)',
387
380
  '--ngxsmk-progress-color': 'var(--ngxsmk-color-primary)',
388
381
  '--ngxsmk-progress-radius': 'var(--ngxsmk-radius-full)',
389
- // Component-specific Tokens (Switch)
382
+ },
383
+ switch: {
390
384
  '--ngxsmk-switch-width': '2.25rem',
391
385
  '--ngxsmk-switch-height': '1.25rem',
392
386
  '--ngxsmk-switch-radius': 'var(--ngxsmk-radius-full)',
@@ -394,6 +388,74 @@ function staticVars(theme) {
394
388
  '--ngxsmk-switch-thumb-size': '0.875rem',
395
389
  '--ngxsmk-switch-thumb-bg': 'var(--ngxsmk-color-surface)',
396
390
  '--ngxsmk-switch-checked-bg': 'var(--ngxsmk-color-primary)',
391
+ },
392
+ spreadsheet: {
393
+ '--ngxsmk-spreadsheet-bg': 'var(--ngxsmk-color-surface)',
394
+ '--ngxsmk-spreadsheet-header-bg': 'var(--ngxsmk-color-surface-variant)',
395
+ '--ngxsmk-spreadsheet-header-color': 'var(--ngxsmk-color-on-surface-variant)',
396
+ '--ngxsmk-spreadsheet-border': 'var(--ngxsmk-color-outline)',
397
+ '--ngxsmk-spreadsheet-grid-color': 'var(--ngxsmk-color-outline-variant)',
398
+ '--ngxsmk-spreadsheet-hover-bg': 'var(--ngxsmk-color-surface-hover)',
399
+ '--ngxsmk-spreadsheet-selected-bg': 'color-mix(in srgb, var(--ngxsmk-color-primary) 12%, transparent)',
400
+ '--ngxsmk-spreadsheet-active-border': 'var(--ngxsmk-color-primary)',
401
+ '--ngxsmk-spreadsheet-font': 'var(--ngxsmk-font-sans)',
402
+ '--ngxsmk-spreadsheet-font-mono': 'var(--ngxsmk-font-mono)',
403
+ '--ngxsmk-spreadsheet-row-height': '2.25rem',
404
+ '--ngxsmk-spreadsheet-header-height': '2.5rem',
405
+ '--ngxsmk-spreadsheet-cell-padding': '6px 10px',
406
+ '--ngxsmk-spreadsheet-cell-font-size': 'var(--ngxsmk-text-body-sm-size)',
407
+ '--ngxsmk-spreadsheet-scrollbar-size': '8px',
408
+ '--ngxsmk-spreadsheet-shadow': 'var(--ngxsmk-shadow-sm)',
409
+ '--ngxsmk-spreadsheet-radius': 'var(--ngxsmk-radius-lg)',
410
+ '--ngxsmk-spreadsheet-edit-bg': 'var(--ngxsmk-color-surface)',
411
+ '--ngxsmk-spreadsheet-edit-border': 'var(--ngxsmk-color-primary)',
412
+ '--ngxsmk-spreadsheet-row-hover-bg': 'var(--ngxsmk-color-surface-hover)',
413
+ '--ngxsmk-spreadsheet-col-hover-bg': 'color-mix(in srgb, var(--ngxsmk-color-primary) 6%, transparent)',
414
+ '--ngxsmk-spreadsheet-frozen-shadow': '2px 0 4px rgba(0,0,0,0.08)',
415
+ '--ngxsmk-spreadsheet-selection-range': 'color-mix(in srgb, var(--ngxsmk-color-primary) 8%, transparent)',
416
+ },
417
+ inputGroup: {
418
+ '--ngxsmk-input-group-bg': 'var(--ngxsmk-color-surface)',
419
+ '--ngxsmk-input-group-border': 'var(--ngxsmk-color-outline-strong)',
420
+ '--ngxsmk-input-group-radius': 'var(--ngxsmk-radius-md)',
421
+ '--ngxsmk-input-group-shadow': 'none',
422
+ '--ngxsmk-input-group-focus-border': 'var(--ngxsmk-color-primary)',
423
+ '--ngxsmk-input-group-focus-shadow': '0 0 0 3px color-mix(in srgb, var(--ngxsmk-color-primary) 15%, transparent)',
424
+ '--ngxsmk-input-group-error-border': 'var(--ngxsmk-color-error)',
425
+ '--ngxsmk-input-group-error-focus-shadow': '0 0 0 3px color-mix(in srgb, var(--ngxsmk-color-error) 15%, transparent)',
426
+ '--ngxsmk-input-group-success-border': 'var(--ngxsmk-color-success)',
427
+ '--ngxsmk-input-group-warning-border': 'var(--ngxsmk-color-warning)',
428
+ '--ngxsmk-input-group-height': '2.5rem',
429
+ '--ngxsmk-input-group-padding': '0.75rem',
430
+ '--ngxsmk-input-group-gap': 'var(--ngxsmk-space-2)',
431
+ '--ngxsmk-input-group-font': 'var(--ngxsmk-font-sans)',
432
+ },
433
+ };
434
+ /** Tokens that do not change between light and dark mode. */
435
+ function staticVars(theme) {
436
+ const vars = {
437
+ ...scaleVars('brand', theme.brand),
438
+ ...scaleVars('secondary', theme.secondary),
439
+ ...scaleVars('neutral', theme.neutral),
440
+ '--ngxsmk-font-sans': theme.fontFamily.sans,
441
+ '--ngxsmk-font-mono': theme.fontFamily.mono,
442
+ '--ngxsmk-radius-base': theme.radius[theme.radiusBase],
443
+ '--ngxsmk-control-height': CONTROL_HEIGHT,
444
+ // Motion Tokens
445
+ '--ngxsmk-motion-duration': 'var(--ngxsmk-duration-normal)',
446
+ '--ngxsmk-motion-ease': 'var(--ngxsmk-ease-in-out)',
447
+ // Interactive-state motion: shared lift/press transforms. Neutralized to
448
+ // `none` under prefers-reduced-motion (see buildThemeCss).
449
+ '--ngxsmk-hover-lift': 'translateY(-1px)',
450
+ '--ngxsmk-press-scale': 'scale(0.98)',
451
+ // Focus ring: THE focus indicator for every component. Aliases the
452
+ // mode-aware --ngxsmk-shadow-focus; the error variant derives from the
453
+ // semantic error role so invalid controls ring red in both modes.
454
+ '--ngxsmk-focus-ring': 'var(--ngxsmk-shadow-focus)',
455
+ '--ngxsmk-focus-ring-error': '0 0 0 2px color-mix(in srgb, var(--ngxsmk-color-error) 15%, transparent), ' +
456
+ '0 0 0 4px color-mix(in srgb, var(--ngxsmk-color-error) 45%, transparent)',
457
+ // Component-specific token defaults
458
+ ...Object.values(COMPONENT_TOKEN_DEFAULTS).reduce((merged, tokens) => ({ ...merged, ...tokens }), {}),
397
459
  };
398
460
  for (const [key, value] of Object.entries(CONTROL_HEIGHTS)) {
399
461
  vars[`--ngxsmk-control-height-${key}`] = value;
@@ -407,6 +469,12 @@ function staticVars(theme) {
407
469
  for (const [key, value] of Object.entries(TRACKING)) {
408
470
  vars[`--ngxsmk-tracking-${key}`] = value;
409
471
  }
472
+ for (const [key, value] of Object.entries(FONT_WEIGHT)) {
473
+ vars[`--ngxsmk-font-weight-${key}`] = value;
474
+ }
475
+ for (const [key, value] of Object.entries(LEADING)) {
476
+ vars[`--ngxsmk-leading-${key}`] = value;
477
+ }
410
478
  for (const [key, value] of Object.entries(theme.spacing)) {
411
479
  vars[`--ngxsmk-space-${key}`] = value;
412
480
  }
@@ -479,6 +547,19 @@ function lightVars(theme) {
479
547
  '--ngxsmk-color-ring': theme.brandBase,
480
548
  '--ngxsmk-color-backdrop': 'rgb(1 18 40 / 0.4)',
481
549
  '--ngxsmk-shadow-focus': `0 0 0 2px ${theme.brand[100]}, 0 0 0 4px color-mix(in srgb, ${theme.brandBase} 45%, transparent)`,
550
+ // Categorical chart palette — a fixed 8-hue qualitative set (NOT derived
551
+ // from the brand, so adjacent series stay distinguishable across themes),
552
+ // stepped for the light surface. Validated for the light chart surface:
553
+ // CVD worst-adjacent ΔE 24.2, chroma/lightness in-band. Charts read these
554
+ // via getComputedStyle; consumers can override any slot to rebrand series.
555
+ '--ngxsmk-chart-1': '#2A78D6',
556
+ '--ngxsmk-chart-2': '#1BAF7A',
557
+ '--ngxsmk-chart-3': '#EDA100',
558
+ '--ngxsmk-chart-4': '#008300',
559
+ '--ngxsmk-chart-5': '#4A3AA7',
560
+ '--ngxsmk-chart-6': '#E34948',
561
+ '--ngxsmk-chart-7': '#E87BA4',
562
+ '--ngxsmk-chart-8': '#EB6834',
482
563
  };
483
564
  const tertiary = deriveScale(rotateHue(theme.brandBase, 64));
484
565
  vars['--ngxsmk-color-tertiary'] = tertiary[500];
@@ -533,6 +614,18 @@ function darkVars(theme) {
533
614
  '--ngxsmk-color-ring': theme.brand[400],
534
615
  '--ngxsmk-color-backdrop': 'rgb(17 17 18 / 0.6)',
535
616
  '--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)`,
617
+ // Categorical chart palette — the same eight hues as light, re-stepped for
618
+ // the dark surface (not a different palette). Validated for the dark chart
619
+ // surface: all slots ≥ 3:1 contrast, CVD worst-adjacent ΔE 10.3 (floor band,
620
+ // relies on the legend/labels charts already render for secondary encoding).
621
+ '--ngxsmk-chart-1': '#3987E5',
622
+ '--ngxsmk-chart-2': '#199E70',
623
+ '--ngxsmk-chart-3': '#C98500',
624
+ '--ngxsmk-chart-4': '#008300',
625
+ '--ngxsmk-chart-5': '#9085E9',
626
+ '--ngxsmk-chart-6': '#E66767',
627
+ '--ngxsmk-chart-7': '#D55181',
628
+ '--ngxsmk-chart-8': '#D95926',
536
629
  };
537
630
  const tertiary = deriveScale(rotateHue(theme.brandBase, 64));
538
631
  vars['--ngxsmk-color-tertiary'] = tertiary[400];
@@ -661,19 +754,174 @@ const presets = {
661
754
  rose: rosePreset,
662
755
  };
663
756
 
664
- const STORAGE_KEY = 'ngxsmk-theme-mode';
757
+ /**
758
+ * Maps NGXSMK semantic color roles to Ionic's color variable namespace.
759
+ *
760
+ * Ionic expects:
761
+ * --ion-color-primary, --ion-color-primary-rgb, --ion-color-primary-shade,
762
+ * --ion-color-primary-tint, --ion-color-secondary, etc.
763
+ *
764
+ * This adapter produces the subset Ionic needs for theming.
765
+ */
766
+ const ionicVarsAdapter = {
767
+ name: 'ionic',
768
+ vars(theme) {
769
+ const vars = {};
770
+ // Primary
771
+ vars['--ion-color-primary'] = theme.brandBase;
772
+ vars['--ion-color-primary-rgb'] = hexToRgbStr(theme.brandBase);
773
+ vars['--ion-color-primary-shade'] = shiftLightnessHex(theme.brandBase, -14);
774
+ vars['--ion-color-primary-tint'] = shiftLightnessHex(theme.brandBase, 8);
775
+ // Secondary
776
+ vars['--ion-color-secondary'] = theme.secondaryBase;
777
+ vars['--ion-color-secondary-rgb'] = hexToRgbStr(theme.secondaryBase);
778
+ vars['--ion-color-secondary-shade'] = shiftLightnessHex(theme.secondaryBase, -14);
779
+ vars['--ion-color-secondary-tint'] = shiftLightnessHex(theme.secondaryBase, 8);
780
+ // Tertiary (derived from brand hue + 64)
781
+ const tertiary = deriveScale(rotateHue(theme.brandBase, 64));
782
+ vars['--ion-color-tertiary'] = tertiary[500];
783
+ vars['--ion-color-tertiary-rgb'] = hexToRgbStr(tertiary[500]);
784
+ vars['--ion-color-tertiary-shade'] = tertiary[600];
785
+ vars['--ion-color-tertiary-tint'] = tertiary[400];
786
+ // Semantic status colors
787
+ for (const [name, hex] of Object.entries(theme.semantic)) {
788
+ vars[`--ion-color-${name}`] = hex;
789
+ vars[`--ion-color-${name}-rgb`] = hexToRgbStr(hex);
790
+ }
791
+ // Background / surface
792
+ vars['--ion-background-color'] = theme.neutral[50];
793
+ vars['--ion-background-color-rgb'] = hexToRgbStr(theme.neutral[50]);
794
+ vars['--ion-text-color'] = '#0A1317';
795
+ vars['--ion-text-color-rgb'] = '10, 19, 23';
796
+ // Typography
797
+ vars['--ion-font-family'] = theme.fontFamily.sans;
798
+ // Border radius (Ionic uses --ion-border-radius)
799
+ vars['--ion-border-radius'] = theme.radius[theme.radiusBase];
800
+ return vars;
801
+ },
802
+ };
803
+ /** Default NGXSMK adapter — emits `--ngxsmk-*` variables. */
804
+ const cssVarsAdapter = {
805
+ name: 'ngxsmk',
806
+ vars: () => ({}), // Not used directly — buildThemeCss handles this
807
+ };
808
+ // ─── Helpers ──────────────────────────────────────────────
809
+ function hexToRgbStr(hex) {
810
+ const h = hex.replace('#', '');
811
+ const r = parseInt(h.substring(0, 2), 16);
812
+ const g = parseInt(h.substring(2, 4), 16);
813
+ const b = parseInt(h.substring(4, 6), 16);
814
+ return `${r}, ${g}, ${b}`;
815
+ }
816
+ function shiftLightnessHex(hex, amount) {
817
+ // Simple hex lightness shift — import from color.ts would be circular
818
+ const h = hex.replace('#', '');
819
+ let r = parseInt(h.substring(0, 2), 16);
820
+ let g = parseInt(h.substring(2, 4), 16);
821
+ let b = parseInt(h.substring(4, 6), 16);
822
+ if (amount > 0) {
823
+ r = Math.min(255, r + Math.round((255 - r) * (amount / 100)));
824
+ g = Math.min(255, g + Math.round((255 - g) * (amount / 100)));
825
+ b = Math.min(255, b + Math.round((255 - b) * (amount / 100)));
826
+ }
827
+ else {
828
+ const factor = 1 + amount / 100;
829
+ r = Math.max(0, Math.round(r * factor));
830
+ g = Math.max(0, Math.round(g * factor));
831
+ b = Math.max(0, Math.round(b * factor));
832
+ }
833
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
834
+ }
835
+
665
836
  const STYLE_ID = 'ngxsmk-theme';
837
+ const IONIC_STYLE_ID = 'ngxsmk-ionic-theme';
838
+ /**
839
+ * Injects generated theme CSS into the document at runtime.
840
+ *
841
+ * Decoupled from dark-mode switching so consumers who only need
842
+ * token injection (e.g. the CLI theme-preview) don't pull in
843
+ * class-toggle / localStorage / matchMedia logic.
844
+ *
845
+ * SSR-safe: all DOM access goes through the injected document.
846
+ */
847
+ class NgxThemeInjectorService {
848
+ constructor() {
849
+ this.document = inject(DOCUMENT);
850
+ }
851
+ /**
852
+ * Apply a theme at runtime. Generates token CSS from the config and swaps
853
+ * it into a dedicated `<style>` element, overriding the build-time theme.
854
+ *
855
+ * @param adapter Optional output adapter. Defaults to the standard NGXSMK
856
+ * `--ngxsmk-*` CSS variables. Pass `ionicVarsAdapter` to also generate
857
+ * `--ion-*` variables for Ionic components.
858
+ */
859
+ applyTheme(config, adapter) {
860
+ const head = this.document.head;
861
+ let style = this.document.getElementById(STYLE_ID);
862
+ if (!style) {
863
+ style = this.document.createElement('style');
864
+ style.id = STYLE_ID;
865
+ head.appendChild(style);
866
+ }
867
+ let css = buildThemeCss(config);
868
+ if (adapter) {
869
+ const resolved = resolveTheme(config);
870
+ const platformVars = adapter.vars(resolved);
871
+ css += '\n' + this.buildAdapterCss(adapter.name, platformVars);
872
+ }
873
+ style.textContent = css;
874
+ }
875
+ /**
876
+ * Apply Ionic-specific token variables alongside the standard NGXSMK theme.
877
+ * Generates `--ion-*` variables derived from the same `ThemeConfig`.
878
+ */
879
+ applyIonicTheme(config) {
880
+ const head = this.document.head;
881
+ let style = this.document.getElementById(IONIC_STYLE_ID);
882
+ if (!style) {
883
+ style = this.document.createElement('style');
884
+ style.id = IONIC_STYLE_ID;
885
+ head.appendChild(style);
886
+ }
887
+ const resolved = resolveTheme(config);
888
+ const vars = ionicVarsAdapter.vars(resolved);
889
+ style.textContent = this.buildAdapterCss('ionic', vars);
890
+ }
891
+ /** Remove a runtime-applied theme, falling back to the build-time CSS. */
892
+ clearTheme() {
893
+ this.document.getElementById(STYLE_ID)?.remove();
894
+ }
895
+ /** Remove the Ionic adapter stylesheet. */
896
+ clearIonicTheme() {
897
+ this.document.getElementById(IONIC_STYLE_ID)?.remove();
898
+ }
899
+ buildAdapterCss(adapterName, vars) {
900
+ const lines = Object.entries(vars).map(([k, v]) => ` ${k}: ${v};`);
901
+ return `/* @ngxsmk/theme — ${adapterName} adapter */\n:root {\n${lines.join('\n')}\n}`;
902
+ }
903
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.0", ngImport: i0, type: NgxThemeInjectorService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
904
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.0", ngImport: i0, type: NgxThemeInjectorService, providedIn: 'root' }); }
905
+ }
906
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.0", ngImport: i0, type: NgxThemeInjectorService, decorators: [{
907
+ type: Injectable,
908
+ args: [{ providedIn: 'root' }]
909
+ }] });
910
+
911
+ const STORAGE_KEY = 'ngxsmk-theme-mode';
666
912
  /**
667
913
  * Runtime theme control: dark-mode switching (class strategy) and dynamic
668
914
  * theme application by injecting generated token CSS into the document.
669
915
  *
916
+ * CSS injection is delegated to {@link NgxThemeInjectorService} for
917
+ * consumers who only need token injection without dark-mode logic.
918
+ *
670
919
  * SSR-safe: all DOM access goes through the injected document and degrades
671
920
  * gracefully when `window`/`matchMedia` are unavailable.
672
921
  */
673
922
  class NgxsmkThemeService {
674
923
  constructor() {
675
- // Inject DOCUMENT first so that restoreMode() and matchSystemDark()
676
- // can safely access it when the signals are initialised below.
924
+ this.injector = inject(NgxThemeInjectorService);
677
925
  this.document = inject(DOCUMENT);
678
926
  this.mode = signal(this.restoreMode());
679
927
  this.systemDark = signal(this.matchSystemDark());
@@ -696,23 +944,21 @@ class NgxsmkThemeService {
696
944
  toggle() {
697
945
  this.setMode(this.isDark() ? 'light' : 'dark');
698
946
  }
699
- /**
700
- * Apply a theme at runtime. Generates token CSS from the config and swaps
701
- * it into a dedicated `<style>` element, overriding the build-time theme.
702
- */
703
- applyTheme(config) {
704
- const head = this.document.head;
705
- let style = this.document.getElementById(STYLE_ID);
706
- if (!style) {
707
- style = this.document.createElement('style');
708
- style.id = STYLE_ID;
709
- head.appendChild(style);
710
- }
711
- style.textContent = buildThemeCss(config);
947
+ /** @see NgxThemeInjectorService.applyTheme */
948
+ applyTheme(config, adapter) {
949
+ this.injector.applyTheme(config, adapter);
712
950
  }
713
- /** Remove a runtime-applied theme, falling back to the build-time CSS. */
951
+ /** @see NgxThemeInjectorService.applyIonicTheme */
952
+ applyIonicTheme(config) {
953
+ this.injector.applyIonicTheme(config);
954
+ }
955
+ /** @see NgxThemeInjectorService.clearTheme */
714
956
  clearTheme() {
715
- this.document.getElementById(STYLE_ID)?.remove();
957
+ this.injector.clearTheme();
958
+ }
959
+ /** @see NgxThemeInjectorService.clearIonicTheme */
960
+ clearIonicTheme() {
961
+ this.injector.clearIonicTheme();
716
962
  }
717
963
  restoreMode() {
718
964
  try {
@@ -745,5 +991,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.0", ngImpor
745
991
  * Generated bundle index. Do not edit.
746
992
  */
747
993
 
748
- export { CONTROL_HEIGHT, CONTROL_HEIGHTS, DEFAULT_FONT_FAMILY, DEFAULT_NEUTRAL, DEFAULT_SEMANTIC, DEFAULT_TYPOGRAPHY, DURATION, EASING, ICON_SIZE, NgxsmkThemeService, OPACITY, RADIUS, SHADOW, SPACING, TRACKING, Z_INDEX, buildThemeCss, defineConfig, deriveScale, emeraldPreset, hexToHsl, hslToHex, neutralPreset, presets, resolveTheme, rosePreset, rotateHue, shiftLightness, violetPreset };
994
+ export { CONTROL_HEIGHT, CONTROL_HEIGHTS, DEFAULT_FONT_FAMILY, DEFAULT_NEUTRAL, DEFAULT_SEMANTIC, DEFAULT_TYPOGRAPHY, DURATION, EASING, FONT_WEIGHT, ICON_SIZE, LEADING, NgxThemeInjectorService, NgxsmkThemeService, OPACITY, RADIUS, SHADOW, SPACING, TRACKING, Z_INDEX, buildThemeCss, cssVarsAdapter, defineConfig, deriveScale, emeraldPreset, hexToOklch, ionicVarsAdapter, neutralPreset, oklchToHex, presets, resolveTheme, rosePreset, rotateHue, shiftLightness, violetPreset };
749
995
  //# sourceMappingURL=ngxsmk-theme.mjs.map