@fakhrirafiki/theme-engine 0.4.7 → 0.4.9

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,33 @@
1
- # Theme Engine
1
+ # 🎨 Theme Engine
2
2
 
3
- Theme system for React: **mode** (`light | dark | system`) + **color presets** (CSS variables) with optional View Transition ripple.
3
+ Theme system for **Next.js (App Router)**: mode (`light | dark | system`) + theme presets (semantic tokens via CSS variables).
4
+
5
+ > ✅ Opinionated defaults, minimal setup, and TypeScript autocomplete that “just works”.
6
+
7
+ ![npm version](https://img.shields.io/npm/v/@fakhrirafiki/theme-engine)
8
+ ![npm downloads](https://img.shields.io/npm/dm/@fakhrirafiki/theme-engine)
9
+ ![license](https://img.shields.io/npm/l/@fakhrirafiki/theme-engine)
10
+
11
+ ## ✨ Why use this?
12
+
13
+ - ⚡ **Fast setup**: 1 CSS import + 1 provider
14
+ - 🌓 **Mode support**: `light | dark | system` (with View Transition ripple when supported)
15
+ - 🎨 **Theme presets**: built-in presets + your own presets
16
+ - 🧠 **DX-first**: `useThemeEngine()` for everything
17
+ - 🧩 **Tailwind v4 friendly**: `@theme inline` tokens included (works with shadcn-style semantic tokens)
18
+
19
+ ## 📚 Table of contents
20
+
21
+ - [Install](#-install)
22
+ - [Quick Start (Nextjs App Router)](#-quick-start-nextjs-app-router)
23
+ - [Usage](#-usage)
24
+ - [Custom presets](#-custom-presets-recommended)
25
+ - [Built-in presets](#-built-in-presets)
26
+ - [Tailwind tokens](#-tailwind-tokens-you-get)
27
+ - [Components](#-components)
28
+ - [Recipe: ThemePresetSelect](#-recipe-themepresetselect-simple-list)
29
+ - [API reference](#-api-reference)
30
+ - [Troubleshooting](#-troubleshooting)
4
31
 
5
32
  ## Install
6
33
 
@@ -8,45 +35,49 @@ Theme system for React: **mode** (`light | dark | system`) + **color presets** (
8
35
  pnpm add @fakhrirafiki/theme-engine
9
36
  ```
10
37
 
11
- ## Setup (Next.js App Router)
38
+ > Using npm/yarn?
39
+ >
40
+ > - `npm i @fakhrirafiki/theme-engine`
41
+ > - `yarn add @fakhrirafiki/theme-engine`
42
+
43
+ ## 🚀 Quick Start (Next.js App Router)
12
44
 
13
45
  ### 1) Import CSS once
14
46
 
15
- In `app/globals.css`:
47
+ In `src/app/globals.css`:
16
48
 
17
49
  ```css
18
- @import "@fakhrirafiki/theme-engine/styles";
50
+ @import '@fakhrirafiki/theme-engine/styles';
19
51
  ```
20
52
 
21
- Notes:
22
-
23
- - If you use Tailwind v4, you will typically also want:
53
+ ✅ Tailwind v4 (recommended order):
24
54
 
25
55
  ```css
26
- @import "tailwindcss";
27
- @import "@fakhrirafiki/theme-engine/styles";
56
+ @import 'tailwindcss';
57
+ @import '@fakhrirafiki/theme-engine/styles';
28
58
 
29
59
  @custom-variant dark (&:is(.dark *));
30
60
  ```
31
61
 
32
- - `@fakhrirafiki/theme-engine/styles` includes Tailwind v4 directives (`@theme inline`). If you are not using Tailwind v4, import only the non-tailwind CSS modules instead:
62
+ ℹ️ Not using Tailwind v4?
33
63
 
34
64
  ```css
35
- @import "@fakhrirafiki/theme-engine/styles/base.css";
36
- @import "@fakhrirafiki/theme-engine/styles/animations.css";
37
- @import "@fakhrirafiki/theme-engine/styles/components.css";
38
- @import "@fakhrirafiki/theme-engine/styles/utilities.css";
65
+ @import '@fakhrirafiki/theme-engine/styles/base.css';
66
+ @import '@fakhrirafiki/theme-engine/styles/animations.css';
67
+ @import '@fakhrirafiki/theme-engine/styles/components.css';
68
+ @import '@fakhrirafiki/theme-engine/styles/utilities.css';
39
69
  ```
40
70
 
41
- ### 2) Wrap with `ThemeProvider`
71
+ ### 2) Wrap your app with `ThemeProvider`
42
72
 
43
- In `app/layout.tsx`:
73
+ In `src/app/layout.tsx`:
44
74
 
45
75
  ```tsx
46
- import { ThemeProvider } from "@fakhrirafiki/theme-engine";
47
- import "./globals.css";
76
+ import type { ReactNode } from 'react';
77
+ import { ThemeProvider } from '@fakhrirafiki/theme-engine';
78
+ import './globals.css';
48
79
 
49
- export default function RootLayout({ children }: { children: React.ReactNode }) {
80
+ export default function RootLayout({ children }: { children: ReactNode }) {
50
81
  return (
51
82
  <html lang="en" suppressHydrationWarning>
52
83
  <body>
@@ -59,133 +90,158 @@ export default function RootLayout({ children }: { children: React.ReactNode })
59
90
  }
60
91
  ```
61
92
 
62
- Notes:
63
-
64
- - By default, `ThemeProvider` injects `ThemeScript` for preset restoration (to reduce flashes).
65
- - `ThemeScript` restores **preset colors** only (it does not set the `dark`/`light` class).
66
- - `defaultPreset="..."` pre-hydration only works for **built-in presets** (because `ThemeScript` uses `getPresetById()` internally). Custom `defaultPreset` still works after hydration.
67
-
68
- ## Day-to-day usage
93
+ ### 3) Use it
69
94
 
70
- ### Toggle dark/light mode
95
+ ## 🧑‍💻 Usage
71
96
 
72
- Use the ready-made button:
97
+ Toggle mode:
73
98
 
74
99
  ```tsx
75
- "use client";
100
+ 'use client';
76
101
 
77
- import { ThemeToggle } from "@fakhrirafiki/theme-engine";
102
+ import { useThemeEngine } from '@fakhrirafiki/theme-engine';
78
103
 
79
- export function HeaderThemeToggle() {
80
- return <ThemeToggle size="md" variant="ghost" />;
104
+ export function ModeButtons() {
105
+ const { mode, setDarkMode, toggleDarkMode } = useThemeEngine();
106
+
107
+ return (
108
+ <div>
109
+ <button onClick={() => setDarkMode('system')}>System</button>
110
+ <button onClick={() => setDarkMode('light')}>Light</button>
111
+ <button onClick={() => setDarkMode('dark')}>Dark</button>
112
+ <button onClick={() => toggleDarkMode()}>Toggle</button>
113
+ <div>Current: {mode}</div>
114
+ </div>
115
+ );
81
116
  }
82
117
  ```
83
118
 
84
- Or control it yourself:
119
+ Pick a theme preset by ID:
85
120
 
86
121
  ```tsx
87
- "use client";
122
+ 'use client';
88
123
 
89
- import { useTheme } from "@fakhrirafiki/theme-engine";
124
+ import { useThemeEngine } from '@fakhrirafiki/theme-engine';
125
+
126
+ export function PresetButtons() {
127
+ const { applyThemeById, clearTheme, currentTheme } = useThemeEngine();
90
128
 
91
- export function ModeButtons() {
92
- const { mode, setMode, toggleMode } = useTheme();
93
129
  return (
94
130
  <div>
95
- <button onClick={() => setMode("system")}>System</button>
96
- <button onClick={() => setMode("light")}>Light</button>
97
- <button onClick={() => setMode("dark")}>Dark</button>
98
- <button onClick={() => toggleMode()}>Toggle</button>
99
- <div>Current: {mode}</div>
131
+ <button onClick={() => applyThemeById('modern-minimal')}>Modern Minimal</button>
132
+ <button onClick={() => clearTheme()}>Reset</button>
133
+ <div>Active: {currentTheme?.presetName ?? 'Default'}</div>
100
134
  </div>
101
135
  );
102
136
  }
103
137
  ```
104
138
 
105
- ### Pick a preset (by ID)
139
+ 💡 Want typed autocomplete (built-in IDs + your custom IDs)? Use a generic:
106
140
 
107
141
  ```tsx
108
- "use client";
142
+ 'use client';
109
143
 
110
- import { useTypedTheme } from "@fakhrirafiki/theme-engine";
111
- import { customPresets } from "./custom-theme-presets";
144
+ import { ThemePresets, useThemeEngine } from '@fakhrirafiki/theme-engine';
145
+ import { customPresets } from './custom-theme-presets';
112
146
 
113
- export function PresetButtons() {
114
- const { setThemePresetById, clearPreset, currentPreset } = useTypedTheme(customPresets);
147
+ export function TypedPresetButtons() {
148
+ const { applyThemeById } = useThemeEngine<ThemePresets<typeof customPresets>>();
115
149
 
116
150
  return (
117
151
  <div>
118
- <button onClick={() => setThemePresetById("my-brand")}>My Brand</button>
119
- <button onClick={() => setThemePresetById("modern-minimal")}>Modern Minimal</button>
120
- <button onClick={() => clearPreset()}>Reset</button>
121
- <div>Active: {currentPreset?.presetName ?? "Default"}</div>
152
+ <button onClick={() => applyThemeById('my-brand')}>My Brand</button>
153
+ <button onClick={() => applyThemeById('modern-minimal')}>Modern Minimal</button>
122
154
  </div>
123
155
  );
124
156
  }
125
157
  ```
126
158
 
127
- ### Use the animated preset picker
159
+ ---
128
160
 
129
- ```tsx
130
- "use client";
161
+ ## Concepts
131
162
 
132
- import { ThemePresetButtons } from "@fakhrirafiki/theme-engine";
163
+ ### Mode vs preset
133
164
 
134
- export function PresetPicker() {
135
- return <ThemePresetButtons />;
136
- }
137
- ```
165
+ - 🌓 **Mode** controls the `<html>` class (`.light` / `.dark`) and `color-scheme`.
166
+ - 🎨 **Preset** controls semantic design tokens (CSS variables like `--background`, `--primary`, etc).
138
167
 
139
- ## Presets
168
+ ### SSR & flashes
140
169
 
141
- ### Built-in presets
170
+ - `ThemeProvider` injects a small pre-hydration script to restore **preset colors** before hydration (reduces flashes).
171
+ - The pre-hydration script restores **preset colors only** (it does not set the `.dark` / `.light` class).
172
+ - `defaultPreset="..."` pre-hydration only works for **built-in presets**. Custom `defaultPreset` still works after hydration.
142
173
 
143
- The package ships with a built-in preset collection:
174
+ ### Persistence
144
175
 
145
- ```tsx
146
- import { getPresetIds, getPresetById } from "@fakhrirafiki/theme-engine";
176
+ By default:
147
177
 
148
- const ids = getPresetIds();
149
- const modernMinimal = getPresetById("modern-minimal");
178
+ - Mode is stored in `localStorage['theme-engine-theme']`
179
+ - Preset is stored in `localStorage['theme-preset']`
180
+
181
+ If you run multiple apps on the same domain, override the keys:
182
+
183
+ ```tsx
184
+ <ThemeProvider modeStorageKey="my-app:mode" presetStorageKey="my-app:preset">
185
+ {children}
186
+ </ThemeProvider>
150
187
  ```
151
188
 
152
- ### Custom presets (recommended pattern)
189
+ ---
153
190
 
154
- Create your presets in TweakCN-compatible format and pass them into `ThemeProvider`:
191
+ ## 🧩 Custom presets (recommended)
155
192
 
156
- ```tsx
157
- import { ThemeProvider, type TweakCNThemePreset } from "@fakhrirafiki/theme-engine";
193
+ Create presets in TweakCN-compatible format and pass them into `ThemeProvider`.
194
+
195
+ ✅ Tip: use `satisfies` to preserve literal keys for TS autocomplete:
196
+
197
+ ### 🎛️ Get a brand theme from TweakCN (recommended)
158
198
 
159
- // Tip: use `satisfies` (instead of `Record<string, ...>`) to keep literal keys for TS autocomplete
160
- const customPresets = {
161
- "my-brand": {
162
- label: "My Brand",
199
+ The fastest way to create a great-looking preset is to use the TweakCN editor:
200
+
201
+ - https://tweakcn.com/editor/theme
202
+
203
+ Pick a theme, tweak the colors, then copy the preset output and paste it into your `customPresets` object (it matches the `TweakCNThemePreset` shape).
204
+
205
+ ```ts
206
+ import { type TweakCNThemePreset } from '@fakhrirafiki/theme-engine';
207
+
208
+ export const customPresets = {
209
+ 'my-brand': {
210
+ label: 'My Brand',
163
211
  styles: {
164
212
  light: {
165
- background: "#ffffff",
166
- foreground: "#111827",
167
- card: "#ffffff",
168
- "card-foreground": "#111827",
169
- primary: "#2563eb",
170
- "primary-foreground": "#ffffff",
171
- secondary: "#e5e7eb",
172
- "secondary-foreground": "#111827",
213
+ background: '#ffffff',
214
+ foreground: '#111827',
215
+ primary: '#2563eb',
216
+ 'primary-foreground': '#ffffff',
217
+ secondary: '#e5e7eb',
218
+ 'secondary-foreground': '#111827',
219
+ card: '#ffffff',
220
+ 'card-foreground': '#111827',
173
221
  },
174
222
  dark: {
175
- background: "#0b1020",
176
- foreground: "#f9fafb",
177
- card: "#111827",
178
- "card-foreground": "#f9fafb",
179
- primary: "#60a5fa",
180
- "primary-foreground": "#0b1020",
181
- secondary: "#1f2937",
182
- "secondary-foreground": "#f9fafb",
223
+ background: '#0b1020',
224
+ foreground: '#f9fafb',
225
+ primary: '#60a5fa',
226
+ 'primary-foreground': '#0b1020',
227
+ secondary: '#1f2937',
228
+ 'secondary-foreground': '#f9fafb',
229
+ card: '#111827',
230
+ 'card-foreground': '#f9fafb',
183
231
  },
184
232
  },
185
233
  },
186
234
  } satisfies Record<string, TweakCNThemePreset>;
235
+ ```
236
+
237
+ Then in your providers/layout:
238
+
239
+ ```tsx
240
+ import type { ReactNode } from 'react';
241
+ import { ThemeProvider } from '@fakhrirafiki/theme-engine';
242
+ import { customPresets } from './custom-theme-presets';
187
243
 
188
- export function AppRoot({ children }: { children: React.ReactNode }) {
244
+ export function AppProviders({ children }: { children: ReactNode }) {
189
245
  return (
190
246
  <ThemeProvider customPresets={customPresets} defaultPreset="my-brand">
191
247
  {children}
@@ -194,28 +250,28 @@ export function AppRoot({ children }: { children: React.ReactNode }) {
194
250
  }
195
251
  ```
196
252
 
197
- Validation behavior:
253
+ Notes:
198
254
 
199
255
  - Custom presets are validated in `ThemeProvider`.
200
- - Invalid custom presets are skipped; warnings are allowed.
201
- - You can validate manually via `validateCustomPresets()` / `logValidationResult()`.
256
+ - Invalid custom presets are skipped (warnings/errors are logged on `localhost`).
257
+ - Preset values can be `H S% L%`, `hsl(...)`, `#hex`, `rgb(...)`, and modern CSS colors like `oklch(...)` (they are normalized internally).
202
258
 
203
- ## Persistence keys
259
+ ---
204
260
 
205
- By default:
261
+ ## 🎁 Built-in presets
206
262
 
207
- - Mode is stored in `localStorage['theme-engine-theme']`.
208
- - Preset is stored in `localStorage['theme-preset']`.
263
+ The package ships with a built-in preset collection:
209
264
 
210
- If you run multiple apps on the same domain, override the keys:
265
+ ```ts
266
+ import { getPresetIds, getPresetById } from '@fakhrirafiki/theme-engine';
211
267
 
212
- ```tsx
213
- <ThemeProvider modeStorageKey="my-app:mode" presetStorageKey="my-app:preset">
214
- {children}
215
- </ThemeProvider>
268
+ const ids = getPresetIds();
269
+ const modernMinimal = getPresetById('modern-minimal');
216
270
  ```
217
271
 
218
- ## Tailwind tokens you get
272
+ ---
273
+
274
+ ## 🎨 Tailwind tokens you get
219
275
 
220
276
  After importing `@fakhrirafiki/theme-engine/styles`, you can use semantic tokens like:
221
277
 
@@ -229,28 +285,225 @@ After importing `@fakhrirafiki/theme-engine/styles`, you can use semantic tokens
229
285
  | Muted | `bg-muted`, `text-muted-foreground` | `--muted`, `--muted-foreground` | Subtle backgrounds / helper text |
230
286
  | Accent | `bg-accent`, `text-accent-foreground` | `--accent`, `--accent-foreground` | Emphasis (not status colors) |
231
287
  | Destructive | `bg-destructive`, `text-destructive-foreground` | `--destructive`, `--destructive-foreground` | Danger actions |
232
- | Borders / focus | `border-border`, `border-input`, `ring-ring` | `--border`, `--input`, `--ring` | Also used by `outline-ring/50` in `tailwind.css` |
288
+ | Borders / focus | `border-border`, `border-input`, `ring-ring` | `--border`, `--input`, `--ring` | Used by `outline-ring/50` too |
233
289
  | Charts | `bg-chart-1`, `text-chart-2` | `--chart-1` ... `--chart-5` | Data viz palettes |
234
290
  | Sidebar | `bg-sidebar`, `text-sidebar-foreground`, `bg-sidebar-primary`, `border-sidebar-border` | `--sidebar-*` | Handy for dashboard layouts |
235
- | Status accents | `bg-accent-success`, `text-accent-danger-foreground` | `--accent-<name>`, `--accent-<name>-foreground` | Optional: only if your preset defines `accent-*` variables |
291
+ | Status accents | `bg-accent-success`, `text-accent-danger-foreground` | `--accent-<name>`, `--accent-<name>-foreground` | Optional: only if preset defines `accent-*` |
236
292
  | Radius scale | `rounded-sm`, `rounded-md`, `rounded-lg`, `rounded-xl` | `--radius-sm`, `--radius-md`, `--radius-lg`, `--radius-xl` | Derived from `--radius` |
237
293
  | Tracking scale | `tracking-tighter`, `tracking-wide` | `--tracking-*` | Derived from `--letter-spacing` |
238
- | Fonts | `font-sans`, `font-serif`, `font-mono` | `--font-sans`, `--font-serif`, `--font-mono` | Set in `base.css`, overridable by presets |
239
- | Shadows | `shadow-sm`, `shadow-md`, `shadow-xl` | `--shadow-*` | Shadow scale derived from `--shadow-*` knobs |
294
+ | Fonts | `font-sans`, `font-serif`, `font-mono` | `--font-sans`, `--font-serif`, `--font-mono` | Defaults in `base.css` |
295
+ | Shadows | `shadow-sm`, `shadow-md`, `shadow-xl` | `--shadow-*` | Derived from `--shadow-*` knobs |
296
+
297
+ ---
298
+
299
+ ## 🧱 Components
300
+
301
+ ### `ThemeToggle`
302
+
303
+ Ready-made mode toggle button (with View Transition ripple when supported).
304
+
305
+ ```tsx
306
+ 'use client';
307
+
308
+ import { ThemeToggle } from '@fakhrirafiki/theme-engine';
309
+
310
+ export function HeaderThemeToggle() {
311
+ return <ThemeToggle size="md" variant="ghost" />;
312
+ }
313
+ ```
314
+
315
+ ### `ThemePresetButtons`
316
+
317
+ Animated preset picker (shows custom presets first, then built-ins):
318
+
319
+ ```tsx
320
+ 'use client';
321
+
322
+ import { ThemePresetButtons } from '@fakhrirafiki/theme-engine';
323
+
324
+ export function PresetPicker() {
325
+ return <ThemePresetButtons />;
326
+ }
327
+ ```
328
+
329
+ ### 🧾 Recipe: `ThemePresetSelect` (simple list)
330
+
331
+ Want a simple, scrollable preset list (e.g. for a settings modal)? Copy-paste this component and style it however you like.
332
+
333
+ > Note: this snippet uses Tailwind utility classes. If you don’t use Tailwind, replace the classes with your own styles/UI components.
240
334
 
241
- ## Troubleshooting
335
+ ```tsx
336
+ 'use client';
337
+
338
+ import { formatColor, useThemeEngine } from '@fakhrirafiki/theme-engine';
339
+
340
+ type ThemePresetSelectProps = {
341
+ allowedPresetIds?: string[];
342
+ };
343
+
344
+ export function ThemePresetSelect({
345
+ allowedPresetIds = [
346
+ 'modern-minimal',
347
+ 'violet-bloom',
348
+ 'vercel',
349
+ 'mono',
350
+ ],
351
+ }: ThemePresetSelectProps) {
352
+ const { currentTheme, applyThemeById, availablePresets, resolvedMode } = useThemeEngine();
353
+
354
+ const presets = allowedPresetIds
355
+ .map((id) => {
356
+ const preset = availablePresets[id];
357
+ if (!preset) return null;
358
+ return { id, label: preset.label };
359
+ })
360
+ .filter((preset): preset is { id: string; label: string } => preset !== null);
361
+
362
+ const getPreviewColors = (presetId: string): string[] => {
363
+ const preset = availablePresets[presetId];
364
+ if (!preset) return [];
365
+
366
+ const scheme = resolvedMode === 'dark' ? preset.styles.dark : preset.styles.light;
367
+ const primary = (scheme as any).primary as string | undefined;
368
+ const secondary = (scheme as any).secondary as string | undefined;
369
+ const accent = (scheme as any).accent as string | undefined;
370
+
371
+ return [primary, secondary, accent].filter(Boolean) as string[];
372
+ };
373
+
374
+ return (
375
+ <div className="mt-4 max-h-[70vh] space-y-2 overflow-y-auto pr-1">
376
+ {presets.map((preset) => {
377
+ const isActive = currentTheme?.presetId === preset.id;
378
+ const previewColors = getPreviewColors(preset.id).slice(0, 3);
379
+
380
+ return (
381
+ <button
382
+ key={preset.id}
383
+ type="button"
384
+ className={`w-full rounded-full border px-3 py-2 text-xs transition-colors ${
385
+ isActive
386
+ ? 'border-primary/70 bg-primary/10 text-foreground'
387
+ : 'border-border bg-muted/40 text-muted-foreground hover:border-muted-foreground/40 hover:bg-muted/60'
388
+ }`}
389
+ onClick={() => applyThemeById(preset.id)}
390
+ >
391
+ <span className="flex items-center justify-between gap-3">
392
+ <span className="flex items-center gap-2">
393
+ {previewColors.length > 0 && (
394
+ <span className="flex gap-1">
395
+ {previewColors.map((color, index) => (
396
+ <span
397
+ key={index}
398
+ className="inline-block h-2.5 w-2.5 rounded-full border border-foreground/10 shadow-sm"
399
+ style={{ backgroundColor: formatColor(color, 'hex') }}
400
+ />
401
+ ))}
402
+ </span>
403
+ )}
404
+
405
+ <span className="text-xs font-medium text-foreground">{preset.label}</span>
406
+ </span>
407
+
408
+ {isActive && (
409
+ <span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-foreground">
410
+ Aktif
411
+ </span>
412
+ )}
413
+ </span>
414
+ </button>
415
+ );
416
+ })}
417
+
418
+ {presets.length === 0 && <p className="text-xs text-muted-foreground">Belum ada tema yang tersedia.</p>}
419
+ </div>
420
+ );
421
+ }
422
+ ```
423
+
424
+ ---
425
+
426
+ ## 🧾 API Reference
427
+
428
+ ### `ThemeProvider`
429
+
430
+ ```ts
431
+ <ThemeProvider
432
+ defaultMode="system"
433
+ defaultPreset="modern-minimal"
434
+ modeStorageKey="theme-engine-theme"
435
+ presetStorageKey="theme-preset"
436
+ customPresets={customPresets}
437
+ />
438
+ ```
439
+
440
+ | Prop | Type | Default | Description |
441
+ | --- | --- | --- | --- |
442
+ | `children` | `ReactNode` | required | React subtree |
443
+ | `defaultMode` | `Mode` | `'system'` | Used when no persisted value |
444
+ | `defaultPreset` | `BuiltInPresetId \| keyof customPresets` | `undefined` | Default preset (see SSR note) |
445
+ | `modeStorageKey` | `string` | `'theme-engine-theme'` | `localStorage` key for mode |
446
+ | `presetStorageKey` | `string` | `'theme-preset'` | `localStorage` key for preset |
447
+ | `customPresets` | `Record<string, TweakCNThemePreset>` | `undefined` | Add your own presets (can override built-ins by ID) |
448
+ | `Pre-hydration script` | n/a | always on | `ThemeProvider` always injects a pre-hydration script for preset restoration |
449
+
450
+ ### `useThemeEngine()`
242
451
 
243
- ### `useTheme must be used within a ThemeProvider`
452
+ Signature:
453
+
454
+ ```ts
455
+ useThemeEngine<TCustomPresets = undefined>()
456
+ ```
457
+
458
+ To get typed custom preset IDs:
459
+
460
+ ```ts
461
+ useThemeEngine<ThemePresets<typeof customPresets>>()
462
+ ```
463
+
464
+ Return fields:
465
+
466
+ | Field | Type | Description |
467
+ | --- | --- | --- |
468
+ | `darkMode` | `boolean` | `resolvedMode === 'dark'` |
469
+ | `mode` | `'light' \| 'dark' \| 'system'` | Current user preference |
470
+ | `resolvedMode` | `'light' \| 'dark'` | Resolved mode (never `system`) |
471
+ | `setDarkMode` | `(mode: Mode) => void` | Set `light \| dark \| system` |
472
+ | `toggleDarkMode` | `(coords?: { x: number; y: number }) => void` | Toggles light/dark (and exits `system`) |
473
+ | `applyThemeById` | `(id: ThemeId) => void` | Apply a preset by ID (alias: `applyPresetById`) |
474
+ | `clearTheme` | `() => void` | Clear preset and fall back to `defaultPreset` if provided (alias: `clearPreset`) |
475
+ | `currentTheme` | `{ presetId; presetName; colors; appliedAt } \| null` | Current preset (alias: `currentPreset`) |
476
+ | `isUsingDefaultPreset` | `boolean` | Whether current preset equals `defaultPreset` |
477
+ | `availablePresets` | `Record<string, TweakCNThemePreset>` | Built-in + custom |
478
+ | `builtInPresets` | `Record<string, TweakCNThemePreset>` | Built-in only |
479
+ | `customPresets` | `Record<string, TweakCNThemePreset>` | Custom only |
480
+
481
+ ### Utilities
482
+
483
+ | Export | Description |
484
+ | --- | --- |
485
+ | `formatColor(color, format)` | Converts a color string into `hsl`/`rgb`/`hex` |
486
+ | `withAlpha(hslTriplet, alpha)` | Adds alpha to an HSL triplet |
487
+
488
+ ---
489
+
490
+ ## 🩹 Troubleshooting
491
+
492
+ ### `useThemeEngine must be used within a ThemeProvider`
244
493
 
245
494
  Wrap your component tree with `ThemeProvider` (and ensure the component is a client component).
246
495
 
496
+ Note: the thrown error string might mention `useTheme` because `useThemeEngine()` uses it internally.
497
+
247
498
  ### Preset doesn’t apply on refresh
248
499
 
249
- If you render `ThemeScript` manually (using `disableScript`), make sure both use the same `presetStorageKey`.
500
+ `ThemeProvider` injects a pre-hydration script automatically. Avoid injecting another preset-restoration script manually (you may end up with duplicates).
501
+
502
+ ### Styles don’t load / components look unstyled
250
503
 
251
- ### `ThemePresetButtons` breaks
504
+ Ensure your `globals.css` imports `@fakhrirafiki/theme-engine/styles` (and Tailwind v4 is configured if you rely on Tailwind utilities).
252
505
 
253
- Ensure you imported `@fakhrirafiki/theme-engine/styles` (or at least `animations.css` + `components.css`).
506
+ ---
254
507
 
255
508
  ## License
256
509