@ojiepermana/angular-theme 22.0.35 → 22.0.36

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.
Files changed (58) hide show
  1. package/README.md +38 -19
  2. package/fesm2022/ojiepermana-angular-theme-layout-wrapper.mjs +10 -6
  3. package/fesm2022/ojiepermana-angular-theme-styles.mjs +356 -53
  4. package/package.json +3 -3
  5. package/styles/README.md +10 -3
  6. package/styles/css/{seasonal/base → base}/tailwind.css +9 -5
  7. package/styles/css/{seasonal/base → base}/theme.css +1 -6
  8. package/styles/css/{seasonal/base → base}/tokens.css +26 -16
  9. package/styles/css/color/amber.css +50 -0
  10. package/styles/css/color/blue.css +50 -0
  11. package/styles/css/color/brand.css +16 -0
  12. package/styles/css/color/cyan.css +50 -0
  13. package/styles/css/color/emerald.css +50 -0
  14. package/styles/css/color/fuchsia.css +50 -0
  15. package/styles/css/color/green.css +50 -0
  16. package/styles/css/color/index.css +19 -0
  17. package/styles/css/color/indigo.css +50 -0
  18. package/styles/css/color/lime.css +50 -0
  19. package/styles/css/color/orange.css +50 -0
  20. package/styles/css/color/pink.css +50 -0
  21. package/styles/css/color/purple.css +50 -0
  22. package/styles/css/color/red.css +50 -0
  23. package/styles/css/color/rose.css +50 -0
  24. package/styles/css/color/sky.css +50 -0
  25. package/styles/css/color/teal.css +50 -0
  26. package/styles/css/color/violet.css +50 -0
  27. package/styles/css/color/yellow.css +50 -0
  28. package/styles/css/index.css +15 -6
  29. package/styles/css/neutral/gray.css +34 -0
  30. package/styles/css/neutral/index.css +11 -0
  31. package/styles/css/neutral/mauve.css +34 -0
  32. package/styles/css/neutral/mist.css +34 -0
  33. package/styles/css/neutral/neutral.css +34 -0
  34. package/styles/css/neutral/olive.css +34 -0
  35. package/styles/css/neutral/slate.css +34 -0
  36. package/styles/css/neutral/stone.css +34 -0
  37. package/styles/css/neutral/taupe.css +34 -0
  38. package/styles/css/neutral/zinc.css +34 -0
  39. package/styles/css/radius/index.css +29 -0
  40. package/styles/css/space/index.css +24 -0
  41. package/types/ojiepermana-angular-theme-layout-wrapper.d.ts +6 -6
  42. package/types/ojiepermana-angular-theme-styles.d.ts +168 -37
  43. package/styles/css/seasonal/ied/package.css +0 -4
  44. package/styles/css/seasonal/ied/theme.css +0 -78
  45. package/styles/css/seasonal/imlek/components.css +0 -87
  46. package/styles/css/seasonal/imlek/package.css +0 -6
  47. package/styles/css/seasonal/imlek/tailwind.css +0 -144
  48. package/styles/css/seasonal/imlek/theme.css +0 -95
  49. package/styles/css/seasonal/imlek/tokens.css +0 -152
  50. package/styles/css/seasonal/index.css +0 -6
  51. package/styles/css/seasonal/natal/package.css +0 -4
  52. package/styles/css/seasonal/natal/theme.css +0 -78
  53. package/styles/css/seasonal/new-year/package.css +0 -4
  54. package/styles/css/seasonal/new-year/theme.css +0 -78
  55. package/styles/css/seasonal/ramadhan/package.css +0 -4
  56. package/styles/css/seasonal/ramadhan/theme.css +0 -78
  57. /package/styles/css/{seasonal/base → base}/components.css +0 -0
  58. /package/styles/css/{seasonal/base → base}/package.css +0 -0
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @ojiepermana/angular-theme
2
2
 
3
3
  Theme layer for the `@ojiepermana/angular` design system: a runtime provider
4
- (mode / season), design tokens, and the Tailwind v4 CSS that the components are
5
- styled against.
4
+ (mode / color / neutral / brand), design tokens, and the Tailwind v4 CSS that the
5
+ components are styled against.
6
6
 
7
7
  ```bash
8
8
  bun add @ojiepermana/angular-theme
@@ -11,13 +11,13 @@ bun add @ojiepermana/angular-theme
11
11
 
12
12
  ## Entry points
13
13
 
14
- | Import path | Contents |
15
- | ---------------------------------------- | --------------------------------------------- |
16
- | `@ojiepermana/angular-theme/styles` | `provideUiTheme`, `ThemeModeService`, … |
17
- | `@ojiepermana/angular-theme/layout` | Layout shell building blocks |
18
- | `@ojiepermana/angular-theme/page` | Page-level scaffolding |
19
- | `@ojiepermana/angular-theme/theme.css` | Base tokens + component styles (CSS) |
20
- | `@ojiepermana/angular-theme/styles/css/*`| Raw CSS assets (seasonal themes, Tailwind map)|
14
+ | Import path | Contents |
15
+ | ----------------------------------------- | ------------------------------------------------------- |
16
+ | `@ojiepermana/angular-theme/styles` | `provideUiTheme`, `ThemeModeService`, … |
17
+ | `@ojiepermana/angular-theme/layout` | Layout shell building blocks |
18
+ | `@ojiepermana/angular-theme/page` | Page-level scaffolding |
19
+ | `@ojiepermana/angular-theme/theme.css` | Base tokens + component styles (CSS) |
20
+ | `@ojiepermana/angular-theme/styles/css/*` | Raw CSS assets (color + neutral palettes, Tailwind map) |
21
21
 
22
22
  ## Tailwind v4 setup
23
23
 
@@ -26,9 +26,9 @@ stylesheet:
26
26
 
27
27
  ```css
28
28
  /* styles.css */
29
- @import '@ojiepermana/angular-theme/theme.css'; /* base tokens + component styles */
29
+ @import '@ojiepermana/angular-theme/styles/css/index.css'; /* base + all color + neutral palettes */
30
30
  @import 'tailwindcss';
31
- @import '@ojiepermana/angular-theme/styles/css/seasonal/base/tailwind.css'; /* maps tokens → bg-primary, text-foreground, rounded-md, … */
31
+ @import '@ojiepermana/angular-theme/styles/css/base/tailwind.css'; /* maps tokens → bg-primary, bg-brand, text-foreground, … */
32
32
  ```
33
33
 
34
34
  Requires Tailwind CSS `^4.3.0`.
@@ -39,20 +39,39 @@ Requires Tailwind CSS `^4.3.0`.
39
39
  import { provideUiTheme } from '@ojiepermana/angular-theme/styles';
40
40
 
41
41
  export const appConfig = {
42
- providers: [provideUiTheme({ mode: 'light', season: 'base' })],
42
+ providers: [
43
+ provideUiTheme({
44
+ mode: 'light',
45
+ color: 'base', // accent palette (base, red…rose, brand)
46
+ neutral: 'base', // gray family (base, slate, gray, zinc, …)
47
+ brand: { color: '221 83% 53%', foreground: '0 0% 100%' }, // consumer brand
48
+ }),
49
+ ],
43
50
  };
44
51
  ```
45
52
 
46
53
  - `mode` — bootstraps `ThemeModeService` and persists the default mode.
47
- - `season` — bootstraps `ThemeSeasonService` and persists the default season.
48
- - `seasonalCssUrl` — resolver to lazy-load a season's CSS at runtime instead of
49
- bundling every season up front.
54
+ - `color` — bootstraps `ThemeColorService`; initial accent palette (`<html theme-color>`).
55
+ - `neutral` — initial neutral family (`<html theme-neutral>`); composes with any accent.
56
+ - `brand` bootstraps `ThemeBrandService`; sets `--brand` / `bg-brand` and the
57
+ `theme-color='brand'` accent preset. Settable at runtime via `setBrand()`.
50
58
 
51
- ### Seasonal themes
59
+ A persisted choice (localStorage `theme-color` / `theme-neutral` / `theme-brand`)
60
+ always wins over the configured default.
52
61
 
53
- `theme.css` bundles only the **base** tokens/components (`index.css`). Seasonal
54
- variants live under `styles/css/seasonal/<season>/package.css` and can be imported
55
- manually, or loaded at runtime via the `seasonalCssUrl` option of `provideUiTheme`.
62
+ ### Color system (FluxUI-style)
63
+
64
+ Two independent axes switch at runtime via attribute selectors on `<html>`:
65
+
66
+ - **accent** (`theme-color`) — `base` (core), `red … rose`, and `brand`. Each
67
+ re-tints the full palette. `base` = no override.
68
+ - **neutral** (`theme-neutral`) — `base` (core), `slate`, `gray`, `zinc`, `neutral`,
69
+ `stone`, `mauve`, `olive`, `mist`, `taupe`. Overrides only the gray family and is
70
+ layered after accent so it wins the shared neutral tokens — letting you pair any
71
+ accent with any neutral.
72
+
73
+ `styles/css/index.css` bundles the core base theme plus every accent and neutral
74
+ palette, so switching needs no runtime CSS loading.
56
75
 
57
76
  ### Material Symbols icons (opt-in)
58
77
 
@@ -129,11 +129,13 @@ class LayoutNavSidebar {
129
129
  ...(ngDevMode ? [{ debugName: "brand" }] : /* istanbul ignore next */ []));
130
130
  user = input.required(/* @ts-ignore */
131
131
  ...(ngDevMode ? [{ debugName: "user" }] : /* istanbul ignore next */ []));
132
- /** Appearance dari shell (`[appearance]`); `flat` menambahkan border kanan pemisah konten. */
132
+ /** Appearance dari shell (`[appearance]`); menentukan ketebalan border kanan pemisah konten. */
133
133
  appearance = input('flat', /* @ts-ignore */
134
134
  ...(ngDevMode ? [{ debugName: "appearance" }] : /* istanbul ignore next */ []));
135
- /** `flat` border kanan; selaras border bawah navbar (`border-[1.5px] border-border`). */
136
- shellClass = computed(() => this.appearance() === 'flat' ? 'border-r-[1.5px] border-border' : '', /* @ts-ignore */
135
+ /** Border kanan pemisah; ketebalan ikut appearance: `border-rail` 1.5px, `flat` 1px. */
136
+ shellClass = computed(() => this.appearance() === 'border-rail'
137
+ ? 'border-r-[1.5px] border-border'
138
+ : 'border-r border-border', /* @ts-ignore */
137
139
  ...(ngDevMode ? [{ debugName: "shellClass" }] : /* istanbul ignore next */ []));
138
140
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.3", ngImport: i0, type: LayoutNavSidebar, deps: [], target: i0.ɵɵFactoryTarget.Component });
139
141
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.3", type: LayoutNavSidebar, isStandalone: true, selector: "LayoutNavSidebar", inputs: { brand: { classPropertyName: "brand", publicName: "brand", isSignal: true, isRequired: true, transformFunction: null }, user: { classPropertyName: "user", publicName: "user", isSignal: true, isRequired: true, transformFunction: null }, appearance: { classPropertyName: "appearance", publicName: "appearance", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "contents" }, ngImport: i0, template: `
@@ -181,11 +183,13 @@ class LayoutNavDockbar {
181
183
  ...(ngDevMode ? [{ debugName: "brand" }] : /* istanbul ignore next */ []));
182
184
  user = input.required(/* @ts-ignore */
183
185
  ...(ngDevMode ? [{ debugName: "user" }] : /* istanbul ignore next */ []));
184
- /** Appearance dari shell (`[appearance]`); `flat` menambahkan border kanan pemisah konten. */
186
+ /** Appearance dari shell (`[appearance]`); menentukan ketebalan border kanan pemisah konten. */
185
187
  appearance = input('flat', /* @ts-ignore */
186
188
  ...(ngDevMode ? [{ debugName: "appearance" }] : /* istanbul ignore next */ []));
187
- /** `flat` border kanan; selaras border bawah navbar (`border-[1.5px] border-border`). */
188
- shellClass = computed(() => this.appearance() === 'flat' ? 'border-r-[1.5px] border-border' : '', /* @ts-ignore */
189
+ /** Border kanan pemisah; ketebalan ikut appearance: `border-rail` 1.5px, `flat` 1px. */
190
+ shellClass = computed(() => this.appearance() === 'border-rail'
191
+ ? 'border-r-[1.5px] border-border'
192
+ : 'border-r border-border', /* @ts-ignore */
189
193
  ...(ngDevMode ? [{ debugName: "shellClass" }] : /* istanbul ignore next */ []));
190
194
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.3", ngImport: i0, type: LayoutNavDockbar, deps: [], target: i0.ɵɵFactoryTarget.Component });
191
195
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.3", type: LayoutNavDockbar, isStandalone: true, selector: "LayoutNavDockbar", inputs: { brand: { classPropertyName: "brand", publicName: "brand", isSignal: true, isRequired: true, transformFunction: null }, user: { classPropertyName: "user", publicName: "user", isSignal: true, isRequired: true, transformFunction: null }, appearance: { classPropertyName: "appearance", publicName: "appearance", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "contents" }, ngImport: i0, template: `
@@ -7,25 +7,103 @@ const THEME_OPTIONS = new InjectionToken('THEME_OPTIONS');
7
7
 
8
8
  const THEME_MODES = ['light', 'dark', 'system'];
9
9
  const RESOLVED_THEME_MODES = ['light', 'dark'];
10
- const THEME_SEASONS = ['base', 'imlek', 'ramadhan', 'ied', 'natal', 'new-year'];
10
+ /**
11
+ * FluxUI-style accent palettes (axis `theme-color`). `base` = core theme (no
12
+ * override); `brand` = the consumer's `--brand` color. Others re-tint the full
13
+ * palette. Order mirrors the FluxUI accent picker (base…rose) + `brand`.
14
+ */
15
+ const THEME_COLORS = [
16
+ 'base',
17
+ 'red',
18
+ 'orange',
19
+ 'amber',
20
+ 'yellow',
21
+ 'lime',
22
+ 'green',
23
+ 'emerald',
24
+ 'teal',
25
+ 'cyan',
26
+ 'sky',
27
+ 'blue',
28
+ 'indigo',
29
+ 'violet',
30
+ 'purple',
31
+ 'fuchsia',
32
+ 'pink',
33
+ 'rose',
34
+ 'brand',
35
+ ];
36
+ /**
37
+ * Neutral (gray) families (axis `theme-neutral`). `base` = core theme neutrals.
38
+ * Overrides only the gray family, composing with any accent.
39
+ */
40
+ const THEME_NEUTRALS = [
41
+ 'base',
42
+ 'slate',
43
+ 'gray',
44
+ 'zinc',
45
+ 'neutral',
46
+ 'stone',
47
+ 'mauve',
48
+ 'olive',
49
+ 'mist',
50
+ 'taupe',
51
+ ];
52
+ /**
53
+ * Corner radius presets (axis `theme-radius`). Each drives the single
54
+ * `--radius-base` knob; the whole `--radius-*` scale + `rounded-*` utilities
55
+ * follow. `md` = the 0.625rem default; `full` is the pill extreme.
56
+ */
57
+ const THEME_RADII = ['none', 'sm', 'md', 'lg', 'xl', 'full'];
58
+ /**
59
+ * Spacing density presets (axis `theme-space`). Each drives the single
60
+ * `--spacing-base` knob; every `p-*`/`m-*`/`gap-*`/`w-*`/`h-*` utility follows.
61
+ * `normal` = the 0.25rem default (no-op baseline).
62
+ */
63
+ const THEME_SPACES = ['compact', 'normal', 'relaxed', 'spacious'];
11
64
  const DEFAULT_THEME_MODE = 'system';
12
- const DEFAULT_THEME_SEASON = 'base';
65
+ const DEFAULT_THEME_COLOR = 'base';
66
+ const DEFAULT_THEME_NEUTRAL = 'base';
67
+ const DEFAULT_THEME_RADIUS = 'md';
68
+ const DEFAULT_THEME_SPACE = 'normal';
13
69
  const THEME_MODE_STORAGE_KEY = 'theme-mode';
14
- const THEME_SEASON_STORAGE_KEY = 'theme-season';
70
+ const THEME_COLOR_STORAGE_KEY = 'theme-color';
71
+ const THEME_NEUTRAL_STORAGE_KEY = 'theme-neutral';
72
+ const THEME_BRAND_STORAGE_KEY = 'theme-brand';
73
+ const THEME_RADIUS_STORAGE_KEY = 'theme-radius';
74
+ const THEME_SPACE_STORAGE_KEY = 'theme-space';
15
75
  function isThemeMode(value) {
16
76
  return typeof value === 'string' && THEME_MODES.includes(value);
17
77
  }
18
78
  function isResolvedThemeMode(value) {
19
79
  return typeof value === 'string' && RESOLVED_THEME_MODES.includes(value);
20
80
  }
21
- function isThemeSeason(value) {
22
- return typeof value === 'string' && THEME_SEASONS.includes(value);
81
+ function isThemeColor(value) {
82
+ return typeof value === 'string' && THEME_COLORS.includes(value);
83
+ }
84
+ function isThemeNeutral(value) {
85
+ return typeof value === 'string' && THEME_NEUTRALS.includes(value);
86
+ }
87
+ function isThemeRadius(value) {
88
+ return typeof value === 'string' && THEME_RADII.includes(value);
89
+ }
90
+ function isThemeSpace(value) {
91
+ return typeof value === 'string' && THEME_SPACES.includes(value);
23
92
  }
24
93
  function normalizeThemeMode(value) {
25
94
  return isThemeMode(value) ? value : DEFAULT_THEME_MODE;
26
95
  }
27
- function normalizeThemeSeason(value) {
28
- return isThemeSeason(value) ? value : DEFAULT_THEME_SEASON;
96
+ function normalizeThemeColor(value) {
97
+ return isThemeColor(value) ? value : DEFAULT_THEME_COLOR;
98
+ }
99
+ function normalizeThemeNeutral(value) {
100
+ return isThemeNeutral(value) ? value : DEFAULT_THEME_NEUTRAL;
101
+ }
102
+ function normalizeThemeRadius(value) {
103
+ return isThemeRadius(value) ? value : DEFAULT_THEME_RADIUS;
104
+ }
105
+ function normalizeThemeSpace(value) {
106
+ return isThemeSpace(value) ? value : DEFAULT_THEME_SPACE;
29
107
  }
30
108
 
31
109
  class ThemeModeService {
@@ -127,67 +205,170 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.3", ngImpor
127
205
  type: Service
128
206
  }], ctorParameters: () => [] });
129
207
 
130
- class ThemeSeasonService {
208
+ const DEFAULT_BRAND_FOREGROUND = '0 0% 100%';
209
+ /**
210
+ * Consumer-configurable brand color. Writes `--brand` / `--brand-foreground` as
211
+ * inline custom properties on `<html>` so `bg-brand` / `text-brand-foreground`
212
+ * and the `theme-color='brand'` accent preset resolve to it.
213
+ *
214
+ * Default comes from `provideUiTheme({ brand })`; `setBrand()` overrides at
215
+ * runtime and persists. When unset, `--brand` falls back to `var(--primary)`.
216
+ */
217
+ class ThemeBrandService {
131
218
  documentRef = inject(DOCUMENT, { optional: true });
132
219
  options = inject(THEME_OPTIONS, { optional: true });
133
- seasonState = signal(DEFAULT_THEME_SEASON, /* @ts-ignore */
134
- ...(ngDevMode ? [{ debugName: "seasonState" }] : /* istanbul ignore next */ []));
135
- season = this.seasonState.asReadonly();
220
+ brandState = signal(null, /* @ts-ignore */
221
+ ...(ngDevMode ? [{ debugName: "brandState" }] : /* istanbul ignore next */ []));
222
+ brand = this.brandState.asReadonly();
136
223
  constructor() {
137
224
  this.ensureDefaults();
138
225
  effect(() => {
139
- const season = this.seasonState();
140
- this.persistSeason(season);
141
- this.applySeasonAttribute(season);
142
- this.ensureSeasonStylesheet(season);
226
+ const brand = this.brandState();
227
+ this.persist(brand);
228
+ this.applyBrand(brand);
143
229
  });
144
230
  }
145
- setSeason(season) {
146
- this.seasonState.set(season);
231
+ /** Set the brand color. `color`/`foreground` are HSL triplets (e.g. `'221 83% 53%'`). */
232
+ setBrand(color, foreground = DEFAULT_BRAND_FOREGROUND) {
233
+ this.brandState.set({ color, foreground });
234
+ }
235
+ /** Clear the consumer brand → `--brand` falls back to `var(--primary)`. */
236
+ clearBrand() {
237
+ this.brandState.set(null);
147
238
  }
148
239
  ensureDefaults() {
149
- const defaultSeason = normalizeThemeSeason(this.options?.season);
150
- const storedSeason = this.readStorage(THEME_SEASON_STORAGE_KEY) ?? defaultSeason;
151
- this.seasonState.set(normalizeThemeSeason(storedSeason));
240
+ const stored = this.readStoredBrand();
241
+ if (stored) {
242
+ this.brandState.set(stored);
243
+ return;
244
+ }
245
+ const fromOptions = this.normalizeOption(this.options?.brand);
246
+ if (fromOptions) {
247
+ this.brandState.set(fromOptions);
248
+ }
152
249
  }
153
- persistSeason(season) {
154
- this.writeStorage(THEME_SEASON_STORAGE_KEY, season);
250
+ normalizeOption(brand) {
251
+ if (!brand) {
252
+ return null;
253
+ }
254
+ if (typeof brand === 'string') {
255
+ return { color: brand, foreground: DEFAULT_BRAND_FOREGROUND };
256
+ }
257
+ return { color: brand.color, foreground: brand.foreground ?? DEFAULT_BRAND_FOREGROUND };
155
258
  }
156
- applySeasonAttribute(season) {
259
+ applyBrand(brand) {
157
260
  const root = this.documentRef?.documentElement;
158
261
  if (!root) {
159
262
  return;
160
263
  }
161
- root.setAttribute('theme-season', season);
162
- }
163
- /**
164
- * Lazily loads the active season's stylesheet via `<link>` when the app
165
- * configured `seasonalCssUrl`, so only base CSS ships in the main bundle.
166
- */
167
- ensureSeasonStylesheet(season) {
168
- const doc = this.documentRef;
169
- const resolveUrl = this.options?.seasonalCssUrl;
170
- if (!doc || !resolveUrl) {
264
+ if (!brand) {
265
+ root.style.removeProperty('--brand');
266
+ root.style.removeProperty('--brand-foreground');
171
267
  return;
172
268
  }
173
- const linkId = 'theme-season-styles';
174
- const existing = doc.getElementById(linkId);
175
- const url = season === 'base' ? null : resolveUrl(season);
176
- if (!url) {
177
- existing?.remove();
178
- return;
269
+ root.style.setProperty('--brand', brand.color);
270
+ root.style.setProperty('--brand-foreground', brand.foreground);
271
+ }
272
+ storage() {
273
+ try {
274
+ return this.documentRef?.defaultView?.localStorage ?? null;
275
+ }
276
+ catch {
277
+ return null;
278
+ }
279
+ }
280
+ readStoredBrand() {
281
+ try {
282
+ const raw = this.storage()?.getItem(THEME_BRAND_STORAGE_KEY);
283
+ if (!raw) {
284
+ return null;
285
+ }
286
+ const parsed = JSON.parse(raw);
287
+ if (typeof parsed?.color === 'string') {
288
+ return {
289
+ color: parsed.color,
290
+ foreground: typeof parsed.foreground === 'string' ? parsed.foreground : DEFAULT_BRAND_FOREGROUND,
291
+ };
292
+ }
293
+ return null;
179
294
  }
180
- if (existing) {
181
- if (existing.getAttribute('href') !== url) {
182
- existing.setAttribute('href', url);
295
+ catch {
296
+ return null;
297
+ }
298
+ }
299
+ persist(brand) {
300
+ try {
301
+ const storage = this.storage();
302
+ if (!storage) {
303
+ return;
304
+ }
305
+ if (!brand) {
306
+ storage.removeItem(THEME_BRAND_STORAGE_KEY);
307
+ return;
183
308
  }
309
+ storage.setItem(THEME_BRAND_STORAGE_KEY, JSON.stringify(brand));
310
+ }
311
+ catch {
184
312
  return;
185
313
  }
186
- const link = doc.createElement('link');
187
- link.id = linkId;
188
- link.rel = 'stylesheet';
189
- link.href = url;
190
- doc.head.appendChild(link);
314
+ }
315
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.3", ngImport: i0, type: ThemeBrandService, deps: [], target: i0.ɵɵFactoryTarget.Service });
316
+ static ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.0.3", ngImport: i0, type: ThemeBrandService });
317
+ }
318
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.3", ngImport: i0, type: ThemeBrandService, decorators: [{
319
+ type: Service
320
+ }], ctorParameters: () => [] });
321
+
322
+ /**
323
+ * Drives the two FluxUI-style color axes:
324
+ * - accent → `<html theme-color>` (full tinted palette per color)
325
+ * - neutral → `<html theme-neutral>` (gray family only)
326
+ *
327
+ * Both persist to localStorage and seed from `provideUiTheme({ color, neutral })`
328
+ * (a persisted choice wins over the configured default). `base` on either axis
329
+ * means "no override" — the core base theme applies.
330
+ */
331
+ class ThemeColorService {
332
+ documentRef = inject(DOCUMENT, { optional: true });
333
+ options = inject(THEME_OPTIONS, { optional: true });
334
+ colorState = signal(DEFAULT_THEME_COLOR, /* @ts-ignore */
335
+ ...(ngDevMode ? [{ debugName: "colorState" }] : /* istanbul ignore next */ []));
336
+ neutralState = signal(DEFAULT_THEME_NEUTRAL, /* @ts-ignore */
337
+ ...(ngDevMode ? [{ debugName: "neutralState" }] : /* istanbul ignore next */ []));
338
+ color = this.colorState.asReadonly();
339
+ neutral = this.neutralState.asReadonly();
340
+ constructor() {
341
+ this.ensureDefaults();
342
+ effect(() => {
343
+ const color = this.colorState();
344
+ this.persist(THEME_COLOR_STORAGE_KEY, color);
345
+ this.applyAttribute('theme-color', color);
346
+ });
347
+ effect(() => {
348
+ const neutral = this.neutralState();
349
+ this.persist(THEME_NEUTRAL_STORAGE_KEY, neutral);
350
+ this.applyAttribute('theme-neutral', neutral);
351
+ });
352
+ }
353
+ setColor(color) {
354
+ this.colorState.set(color);
355
+ }
356
+ setNeutral(neutral) {
357
+ this.neutralState.set(neutral);
358
+ }
359
+ ensureDefaults() {
360
+ // A configured brand with no explicit accent defaults the accent to 'brand'
361
+ // so the consumer's brand color becomes the primary (FluxUI-style).
362
+ const configuredColor = this.options?.color ?? (this.options?.brand ? 'brand' : undefined);
363
+ const defaultColor = normalizeThemeColor(configuredColor);
364
+ const storedColor = this.readStorage(THEME_COLOR_STORAGE_KEY) ?? defaultColor;
365
+ this.colorState.set(normalizeThemeColor(storedColor));
366
+ const defaultNeutral = normalizeThemeNeutral(this.options?.neutral);
367
+ const storedNeutral = this.readStorage(THEME_NEUTRAL_STORAGE_KEY) ?? defaultNeutral;
368
+ this.neutralState.set(normalizeThemeNeutral(storedNeutral));
369
+ }
370
+ applyAttribute(attribute, value) {
371
+ this.documentRef?.documentElement?.setAttribute(attribute, value);
191
372
  }
192
373
  storage() {
193
374
  try {
@@ -205,7 +386,7 @@ class ThemeSeasonService {
205
386
  return null;
206
387
  }
207
388
  }
208
- writeStorage(key, value) {
389
+ persist(key, value) {
209
390
  try {
210
391
  this.storage()?.setItem(key, value);
211
392
  }
@@ -213,10 +394,129 @@ class ThemeSeasonService {
213
394
  return;
214
395
  }
215
396
  }
216
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.3", ngImport: i0, type: ThemeSeasonService, deps: [], target: i0.ɵɵFactoryTarget.Service });
217
- static ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.0.3", ngImport: i0, type: ThemeSeasonService });
397
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.3", ngImport: i0, type: ThemeColorService, deps: [], target: i0.ɵɵFactoryTarget.Service });
398
+ static ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.0.3", ngImport: i0, type: ThemeColorService });
399
+ }
400
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.3", ngImport: i0, type: ThemeColorService, decorators: [{
401
+ type: Service
402
+ }], ctorParameters: () => [] });
403
+
404
+ /**
405
+ * Corner radius axis. Writes `<html theme-radius>`; each preset CSS sets the
406
+ * single `--radius-base` knob so the whole `--radius-*` scale + `rounded-*`
407
+ * utilities rescale. Persists to localStorage and seeds from
408
+ * `provideUiTheme({ radius })` (a persisted choice wins over the default).
409
+ */
410
+ class ThemeRadiusService {
411
+ documentRef = inject(DOCUMENT, { optional: true });
412
+ options = inject(THEME_OPTIONS, { optional: true });
413
+ radiusState = signal(DEFAULT_THEME_RADIUS, /* @ts-ignore */
414
+ ...(ngDevMode ? [{ debugName: "radiusState" }] : /* istanbul ignore next */ []));
415
+ radius = this.radiusState.asReadonly();
416
+ constructor() {
417
+ this.ensureDefaults();
418
+ effect(() => {
419
+ const radius = this.radiusState();
420
+ this.persist(radius);
421
+ this.documentRef?.documentElement?.setAttribute('theme-radius', radius);
422
+ });
423
+ }
424
+ setRadius(radius) {
425
+ this.radiusState.set(radius);
426
+ }
427
+ ensureDefaults() {
428
+ const fallback = normalizeThemeRadius(this.options?.radius);
429
+ const stored = this.readStorage() ?? fallback;
430
+ this.radiusState.set(normalizeThemeRadius(stored));
431
+ }
432
+ storage() {
433
+ try {
434
+ return this.documentRef?.defaultView?.localStorage ?? null;
435
+ }
436
+ catch {
437
+ return null;
438
+ }
439
+ }
440
+ readStorage() {
441
+ try {
442
+ return this.storage()?.getItem(THEME_RADIUS_STORAGE_KEY) ?? null;
443
+ }
444
+ catch {
445
+ return null;
446
+ }
447
+ }
448
+ persist(radius) {
449
+ try {
450
+ this.storage()?.setItem(THEME_RADIUS_STORAGE_KEY, radius);
451
+ }
452
+ catch {
453
+ return;
454
+ }
455
+ }
456
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.3", ngImport: i0, type: ThemeRadiusService, deps: [], target: i0.ɵɵFactoryTarget.Service });
457
+ static ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.0.3", ngImport: i0, type: ThemeRadiusService });
458
+ }
459
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.3", ngImport: i0, type: ThemeRadiusService, decorators: [{
460
+ type: Service
461
+ }], ctorParameters: () => [] });
462
+
463
+ /**
464
+ * Spacing density axis. Writes `<html theme-space>`; each preset CSS sets the
465
+ * single `--spacing-base` knob, which Tailwind's `--spacing` derives from, so
466
+ * every `p-*`/`m-*`/`gap-*`/`w-*`/`h-*` utility rescales. Persists to
467
+ * localStorage and seeds from `provideUiTheme({ space })` (a persisted choice
468
+ * wins over the default).
469
+ */
470
+ class ThemeSpaceService {
471
+ documentRef = inject(DOCUMENT, { optional: true });
472
+ options = inject(THEME_OPTIONS, { optional: true });
473
+ spaceState = signal(DEFAULT_THEME_SPACE, /* @ts-ignore */
474
+ ...(ngDevMode ? [{ debugName: "spaceState" }] : /* istanbul ignore next */ []));
475
+ space = this.spaceState.asReadonly();
476
+ constructor() {
477
+ this.ensureDefaults();
478
+ effect(() => {
479
+ const space = this.spaceState();
480
+ this.persist(space);
481
+ this.documentRef?.documentElement?.setAttribute('theme-space', space);
482
+ });
483
+ }
484
+ setSpace(space) {
485
+ this.spaceState.set(space);
486
+ }
487
+ ensureDefaults() {
488
+ const fallback = normalizeThemeSpace(this.options?.space);
489
+ const stored = this.readStorage() ?? fallback;
490
+ this.spaceState.set(normalizeThemeSpace(stored));
491
+ }
492
+ storage() {
493
+ try {
494
+ return this.documentRef?.defaultView?.localStorage ?? null;
495
+ }
496
+ catch {
497
+ return null;
498
+ }
499
+ }
500
+ readStorage() {
501
+ try {
502
+ return this.storage()?.getItem(THEME_SPACE_STORAGE_KEY) ?? null;
503
+ }
504
+ catch {
505
+ return null;
506
+ }
507
+ }
508
+ persist(space) {
509
+ try {
510
+ this.storage()?.setItem(THEME_SPACE_STORAGE_KEY, space);
511
+ }
512
+ catch {
513
+ return;
514
+ }
515
+ }
516
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.3", ngImport: i0, type: ThemeSpaceService, deps: [], target: i0.ɵɵFactoryTarget.Service });
517
+ static ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.0.3", ngImport: i0, type: ThemeSpaceService });
218
518
  }
219
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.3", ngImport: i0, type: ThemeSeasonService, decorators: [{
519
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.3", ngImport: i0, type: ThemeSpaceService, decorators: [{
220
520
  type: Service
221
521
  }], ctorParameters: () => [] });
222
522
 
@@ -235,7 +535,10 @@ function provideUiTheme(options = {}) {
235
535
  inject(MaterialSymbolsService).ensureLoaded();
236
536
  }
237
537
  inject(ThemeModeService);
238
- inject(ThemeSeasonService);
538
+ inject(ThemeColorService);
539
+ inject(ThemeBrandService);
540
+ inject(ThemeRadiusService);
541
+ inject(ThemeSpaceService);
239
542
  }),
240
543
  ]);
241
544
  }
@@ -244,4 +547,4 @@ function provideUiTheme(options = {}) {
244
547
  * Generated bundle index. Do not edit.
245
548
  */
246
549
 
247
- export { DEFAULT_THEME_MODE, DEFAULT_THEME_SEASON, RESOLVED_THEME_MODES, THEME_MODES, THEME_MODE_STORAGE_KEY, THEME_OPTIONS, THEME_SEASONS, THEME_SEASON_STORAGE_KEY, ThemeModeService, ThemeSeasonService, isResolvedThemeMode, isThemeMode, isThemeSeason, normalizeThemeMode, normalizeThemeSeason, provideUiTheme };
550
+ export { DEFAULT_THEME_COLOR, DEFAULT_THEME_MODE, DEFAULT_THEME_NEUTRAL, DEFAULT_THEME_RADIUS, DEFAULT_THEME_SPACE, RESOLVED_THEME_MODES, THEME_BRAND_STORAGE_KEY, THEME_COLORS, THEME_COLOR_STORAGE_KEY, THEME_MODES, THEME_MODE_STORAGE_KEY, THEME_NEUTRALS, THEME_NEUTRAL_STORAGE_KEY, THEME_OPTIONS, THEME_RADII, THEME_RADIUS_STORAGE_KEY, THEME_SPACES, THEME_SPACE_STORAGE_KEY, ThemeBrandService, ThemeColorService, ThemeModeService, ThemeRadiusService, ThemeSpaceService, isResolvedThemeMode, isThemeColor, isThemeMode, isThemeNeutral, isThemeRadius, isThemeSpace, normalizeThemeColor, normalizeThemeMode, normalizeThemeNeutral, normalizeThemeRadius, normalizeThemeSpace, provideUiTheme };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ojiepermana/angular-theme",
3
- "version": "22.0.35",
3
+ "version": "22.0.36",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/edsis/angular.git"
@@ -13,8 +13,8 @@
13
13
  "@angular/common": ">=22.0.0",
14
14
  "@angular/core": ">=22.0.0",
15
15
  "@angular/router": ">=22.0.0",
16
- "@ojiepermana/angular-navigation": "^22.0.35",
17
- "@ojiepermana/angular-component": "^22.0.35",
16
+ "@ojiepermana/angular-navigation": "^22.0.36",
17
+ "@ojiepermana/angular-component": "^22.0.36",
18
18
  "rxjs": ">=7.8.0"
19
19
  },
20
20
  "dependencies": {
package/styles/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # Theme
2
2
 
3
- `@ojiepermana/angular-theme/styles` menyediakan provider untuk mode, season, dan preload aset tema global.
3
+ `@ojiepermana/angular-theme/styles` menyediakan provider untuk mode, warna (accent +
4
+ neutral, gaya FluxUI), brand color, dan preload aset tema global.
4
5
 
5
6
  ```ts
6
7
  import { provideUiTheme } from '@ojiepermana/angular-theme/styles';
@@ -9,7 +10,9 @@ export const appConfig = {
9
10
  providers: [
10
11
  provideUiTheme({
11
12
  mode: 'light',
12
- season: 'base',
13
+ color: 'base', // accent: base, red…rose, brand
14
+ neutral: 'base', // gray family: base, slate, gray, zinc, …
15
+ brand: '221 83% 53%', // warna brand consumer (HSL triplet)
13
16
  }),
14
17
  ],
15
18
  };
@@ -18,8 +21,12 @@ export const appConfig = {
18
21
  ## API Notes
19
22
 
20
23
  - `mode` mem-bootstrap `ThemeModeService` dan default mode yang disimpan di storage.
21
- - `season` mem-bootstrap `ThemeSeasonService` dan default season yang disimpan di storage.
24
+ - `color` mem-bootstrap `ThemeColorService`; accent palette awal (`<html theme-color>`).
25
+ - `neutral` keluarga neutral awal (`<html theme-neutral>`); berkomposisi dengan accent.
26
+ - `brand` mem-bootstrap `ThemeBrandService`; set `--brand` / utility `bg-brand` dan preset
27
+ accent `theme-color='brand'`. Bisa diubah runtime via `setBrand()`, persist di storage.
22
28
  - `icons.materialSymbols` mengontrol preload stylesheet Material Symbols pada bootstrap aplikasi.
29
+ - Pilihan yang dipersist (`theme-color` / `theme-neutral` / `theme-brand`) menang atas default.
23
30
 
24
31
  ## Default Behavior
25
32