@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 +368 -115
- package/dist/index.d.mts +65 -26
- package/dist/index.d.ts +65 -26
- package/dist/index.js +84 -64
- package/dist/index.mjs +83 -64
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
|
-
# Theme Engine
|
|
1
|
+
# 🎨 Theme Engine
|
|
2
2
|
|
|
3
|
-
Theme system for
|
|
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
|
+

|
|
8
|
+

|
|
9
|
+

|
|
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
|
-
|
|
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
|
|
50
|
+
@import '@fakhrirafiki/theme-engine/styles';
|
|
19
51
|
```
|
|
20
52
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
- If you use Tailwind v4, you will typically also want:
|
|
53
|
+
✅ Tailwind v4 (recommended order):
|
|
24
54
|
|
|
25
55
|
```css
|
|
26
|
-
@import
|
|
27
|
-
@import
|
|
56
|
+
@import 'tailwindcss';
|
|
57
|
+
@import '@fakhrirafiki/theme-engine/styles';
|
|
28
58
|
|
|
29
59
|
@custom-variant dark (&:is(.dark *));
|
|
30
60
|
```
|
|
31
61
|
|
|
32
|
-
|
|
62
|
+
ℹ️ Not using Tailwind v4?
|
|
33
63
|
|
|
34
64
|
```css
|
|
35
|
-
@import
|
|
36
|
-
@import
|
|
37
|
-
@import
|
|
38
|
-
@import
|
|
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 {
|
|
47
|
-
import
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
95
|
+
## 🧑💻 Usage
|
|
71
96
|
|
|
72
|
-
|
|
97
|
+
Toggle mode:
|
|
73
98
|
|
|
74
99
|
```tsx
|
|
75
|
-
|
|
100
|
+
'use client';
|
|
76
101
|
|
|
77
|
-
import {
|
|
102
|
+
import { useThemeEngine } from '@fakhrirafiki/theme-engine';
|
|
78
103
|
|
|
79
|
-
export function
|
|
80
|
-
|
|
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
|
-
|
|
119
|
+
Pick a theme preset by ID:
|
|
85
120
|
|
|
86
121
|
```tsx
|
|
87
|
-
|
|
122
|
+
'use client';
|
|
88
123
|
|
|
89
|
-
import {
|
|
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={() =>
|
|
96
|
-
<button onClick={() =>
|
|
97
|
-
<
|
|
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
|
-
|
|
139
|
+
💡 Want typed autocomplete (built-in IDs + your custom IDs)? Use a generic:
|
|
106
140
|
|
|
107
141
|
```tsx
|
|
108
|
-
|
|
142
|
+
'use client';
|
|
109
143
|
|
|
110
|
-
import {
|
|
111
|
-
import { customPresets } from
|
|
144
|
+
import { ThemePresets, useThemeEngine } from '@fakhrirafiki/theme-engine';
|
|
145
|
+
import { customPresets } from './custom-theme-presets';
|
|
112
146
|
|
|
113
|
-
export function
|
|
114
|
-
const {
|
|
147
|
+
export function TypedPresetButtons() {
|
|
148
|
+
const { applyThemeById } = useThemeEngine<ThemePresets<typeof customPresets>>();
|
|
115
149
|
|
|
116
150
|
return (
|
|
117
151
|
<div>
|
|
118
|
-
<button onClick={() =>
|
|
119
|
-
<button onClick={() =>
|
|
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
|
-
|
|
159
|
+
---
|
|
128
160
|
|
|
129
|
-
|
|
130
|
-
"use client";
|
|
161
|
+
## Concepts
|
|
131
162
|
|
|
132
|
-
|
|
163
|
+
### Mode vs preset
|
|
133
164
|
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
168
|
+
### SSR & flashes
|
|
140
169
|
|
|
141
|
-
|
|
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
|
-
|
|
174
|
+
### Persistence
|
|
144
175
|
|
|
145
|
-
|
|
146
|
-
import { getPresetIds, getPresetById } from "@fakhrirafiki/theme-engine";
|
|
176
|
+
By default:
|
|
147
177
|
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
189
|
+
---
|
|
153
190
|
|
|
154
|
-
|
|
191
|
+
## 🧩 Custom presets (recommended)
|
|
155
192
|
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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:
|
|
166
|
-
foreground:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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:
|
|
176
|
-
foreground:
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
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
|
-
|
|
253
|
+
Notes:
|
|
198
254
|
|
|
199
255
|
- Custom presets are validated in `ThemeProvider`.
|
|
200
|
-
- Invalid custom presets are skipped
|
|
201
|
-
-
|
|
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
|
-
|
|
259
|
+
---
|
|
204
260
|
|
|
205
|
-
|
|
261
|
+
## 🎁 Built-in presets
|
|
206
262
|
|
|
207
|
-
|
|
208
|
-
- Preset is stored in `localStorage['theme-preset']`.
|
|
263
|
+
The package ships with a built-in preset collection:
|
|
209
264
|
|
|
210
|
-
|
|
265
|
+
```ts
|
|
266
|
+
import { getPresetIds, getPresetById } from '@fakhrirafiki/theme-engine';
|
|
211
267
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
{children}
|
|
215
|
-
</ThemeProvider>
|
|
268
|
+
const ids = getPresetIds();
|
|
269
|
+
const modernMinimal = getPresetById('modern-minimal');
|
|
216
270
|
```
|
|
217
271
|
|
|
218
|
-
|
|
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` |
|
|
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
|
|
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` |
|
|
239
|
-
| Shadows | `shadow-sm`, `shadow-md`, `shadow-xl` | `--shadow-*` |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
504
|
+
Ensure your `globals.css` imports `@fakhrirafiki/theme-engine/styles` (and Tailwind v4 is configured if you rely on Tailwind utilities).
|
|
252
505
|
|
|
253
|
-
|
|
506
|
+
---
|
|
254
507
|
|
|
255
508
|
## License
|
|
256
509
|
|