@reopt-ai/opt-ui 1.4.1

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 (64) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/COMPONENT_CATALOG.md +3000 -0
  3. package/LICENSE +21 -0
  4. package/README.md +244 -0
  5. package/dist/chunk-3GWWZKX7.js +38 -0
  6. package/dist/chunk-AFF2HPE5.cjs +5008 -0
  7. package/dist/chunk-ELWICXYY.js +4745 -0
  8. package/dist/chunk-N4NDU5ET.cjs +4745 -0
  9. package/dist/chunk-ONE3C5RV.cjs +38 -0
  10. package/dist/chunk-QWBHD54V.js +3218 -0
  11. package/dist/chunk-RBM2RNC2.js +5008 -0
  12. package/dist/chunk-X5WCXJAF.cjs +3218 -0
  13. package/dist/core/index.cjs +324 -0
  14. package/dist/core/index.d.cts +5 -0
  15. package/dist/core/index.d.ts +5 -0
  16. package/dist/core/index.js +324 -0
  17. package/dist/docs/01-getting-started.md +129 -0
  18. package/dist/docs/02-components/01-core.md +1841 -0
  19. package/dist/docs/02-components/02-visuals.md +11 -0
  20. package/dist/docs/02-components/03-shells.md +1361 -0
  21. package/dist/docs/02-components/04-surfaces.md +11 -0
  22. package/dist/docs/02-components/index.md +106 -0
  23. package/dist/docs/03-recipes/01-forms.md +471 -0
  24. package/dist/docs/03-recipes/02-dashboards.md +397 -0
  25. package/dist/docs/03-recipes/03-layouts.md +424 -0
  26. package/dist/docs/04-theming.md +232 -0
  27. package/dist/docs/05-migration/01-breaking-changes.md +117 -0
  28. package/dist/docs/05-migration/02-formstore.md +336 -0
  29. package/dist/docs/06-troubleshooting.md +119 -0
  30. package/dist/docs/index.md +71 -0
  31. package/dist/id-registry.cjs +1875 -0
  32. package/dist/id-registry.d.cts +27 -0
  33. package/dist/id-registry.d.ts +27 -0
  34. package/dist/id-registry.js +1875 -0
  35. package/dist/id-registry.json +3799 -0
  36. package/dist/index-BZ_lBlO1.d.ts +474 -0
  37. package/dist/index-BuvxoWHf.d.cts +474 -0
  38. package/dist/index-DlAcuvQp.d.cts +1686 -0
  39. package/dist/index-Slu5hOj1.d.ts +1686 -0
  40. package/dist/index.cjs +9959 -0
  41. package/dist/index.d.cts +2351 -0
  42. package/dist/index.d.ts +2351 -0
  43. package/dist/index.js +9959 -0
  44. package/dist/meta.cjs +6898 -0
  45. package/dist/meta.d.cts +36 -0
  46. package/dist/meta.d.ts +36 -0
  47. package/dist/meta.js +6869 -0
  48. package/dist/shells/index.cjs +65 -0
  49. package/dist/shells/index.d.cts +5 -0
  50. package/dist/shells/index.d.ts +5 -0
  51. package/dist/shells/index.js +65 -0
  52. package/dist/tailwind.css +401 -0
  53. package/dist/theme/presets/default.css +355 -0
  54. package/dist/theme/presets/minimal.css +354 -0
  55. package/dist/theme/presets/mono-dark.css +354 -0
  56. package/dist/theme/presets/natural.css +181 -0
  57. package/dist/theme/presets/pro.css +354 -0
  58. package/dist/types-D4-0lwaE.d.cts +298 -0
  59. package/dist/types-D4-0lwaE.d.ts +298 -0
  60. package/dist/visuals/index.cjs +4 -0
  61. package/dist/visuals/index.d.cts +1 -0
  62. package/dist/visuals/index.d.ts +1 -0
  63. package/dist/visuals/index.js +4 -0
  64. package/package.json +165 -0
@@ -0,0 +1,424 @@
1
+ ---
2
+ title: "Layout Recipes"
3
+ description: "opt-ui 레이아웃 조합 패턴 3종 — AppShell, DataExplorer, Settings"
4
+ related:
5
+ links:
6
+ - ../02-components/01-core.md
7
+ - ../02-components/03-shells.md
8
+ ---
9
+
10
+ <!-- AI agent hint: Surface/페이지 루트에 직접 space-y-* 사용 금지. 반드시 SurfaceLayout 또는 createSurface 사용. -->
11
+
12
+ # Layout Recipes
13
+
14
+ Copy-paste-ready layout composition patterns.
15
+ All imports from `@reopt-ai/opt-ui`.
16
+
17
+ ## 1. App Shell with Sidebar
18
+
19
+ Full application chrome: header, collapsible sidebar, main content area.
20
+
21
+ ```tsx
22
+ "use client";
23
+
24
+ import { useState } from "react";
25
+ import {
26
+ AppShell,
27
+ Sidebar,
28
+ useSidebar,
29
+ PageHeader,
30
+ Button,
31
+ Logo,
32
+ } from "@reopt-ai/opt-ui";
33
+
34
+ interface NavLink {
35
+ id: string;
36
+ label: string;
37
+ href: string;
38
+ icon: React.ReactNode;
39
+ }
40
+
41
+ function SidebarNav({ links }: { links: NavLink[] }) {
42
+ const { collapsed } = useSidebar();
43
+
44
+ return (
45
+ <>
46
+ {links.map((link) => (
47
+ <a
48
+ key={link.id}
49
+ href={link.href}
50
+ className="text-text-secondary hover:text-text-primary hover:bg-bg-subtle gap-element flex items-center rounded-md px-3 py-2 text-sm transition-colors"
51
+ >
52
+ {link.icon}
53
+ {!collapsed && <span>{link.label}</span>}
54
+ </a>
55
+ ))}
56
+ </>
57
+ );
58
+ }
59
+
60
+ export function AppLayout({ children }: { children: React.ReactNode }) {
61
+ const navLinks: NavLink[] = [
62
+ { id: "dashboard", label: "대시보드", href: "/", icon: <span>D</span> },
63
+ {
64
+ id: "projects",
65
+ label: "프로젝트",
66
+ href: "/projects",
67
+ icon: <span>P</span>,
68
+ },
69
+ { id: "settings", label: "설정", href: "/settings", icon: <span>S</span> },
70
+ ];
71
+
72
+ return (
73
+ <AppShell>
74
+ <AppShell.Header>
75
+ <div className="gap-group flex items-center">
76
+ <Logo text="MyApp" />
77
+ <div className="flex-1" />
78
+ <Button variant="ghost" size="sm">
79
+ 로그아웃
80
+ </Button>
81
+ </div>
82
+ </AppShell.Header>
83
+
84
+ <AppShell.Sidebar>
85
+ <Sidebar collapsible defaultCollapsed={false} label="메인 내비게이션">
86
+ <Sidebar.Header>
87
+ <Sidebar.Trigger />
88
+ </Sidebar.Header>
89
+
90
+ <Sidebar.Content>
91
+ <SidebarNav links={navLinks} />
92
+ </Sidebar.Content>
93
+
94
+ <Sidebar.Footer>
95
+ <div className="text-text-tertiary text-xs">v2.4.0</div>
96
+ </Sidebar.Footer>
97
+ </Sidebar>
98
+ </AppShell.Sidebar>
99
+
100
+ <AppShell.Content>
101
+ <div className="p-6">{children}</div>
102
+ </AppShell.Content>
103
+ </AppShell>
104
+ );
105
+ }
106
+ ```
107
+
108
+ ## 2. Data Explorer Page
109
+
110
+ FilterBar + DataTable + Pagination in a SurfaceLayout.
111
+
112
+ ```tsx
113
+ "use client";
114
+
115
+ import { useState, useMemo } from "react";
116
+ import {
117
+ SurfaceLayout,
118
+ PageHeader,
119
+ FilterBar,
120
+ DataTable,
121
+ Pagination,
122
+ Button,
123
+ type FilterGroupDef,
124
+ type ColumnDef,
125
+ } from "@reopt-ai/opt-ui";
126
+
127
+ interface User {
128
+ id: string;
129
+ name: string;
130
+ email: string;
131
+ role: "admin" | "editor" | "viewer";
132
+ status: "active" | "inactive";
133
+ lastLogin: string;
134
+ }
135
+
136
+ const allUsers: User[] = Array.from({ length: 48 }, (_, i) => ({
137
+ id: `u${i + 1}`,
138
+ name: `사용자 ${i + 1}`,
139
+ email: `user${i + 1}@example.com`,
140
+ role: (["admin", "editor", "viewer"] as const)[i % 3],
141
+ status: i % 5 === 0 ? "inactive" : "active",
142
+ lastLogin: new Date(Date.now() - i * 86400000).toLocaleDateString("ko-KR"),
143
+ }));
144
+
145
+ const columns: ColumnDef<User>[] = [
146
+ { id: "name", header: "이름", accessor: "name", sortable: true },
147
+ { id: "email", header: "이메일", accessor: "email" },
148
+ { id: "role", header: "역할", accessor: "role", sortable: true },
149
+ {
150
+ id: "status",
151
+ header: "상태",
152
+ accessor: (u) => (
153
+ <span
154
+ className={
155
+ u.status === "active" ? "text-success-fg" : "text-text-tertiary"
156
+ }
157
+ >
158
+ {u.status === "active" ? "활성" : "비활성"}
159
+ </span>
160
+ ),
161
+ },
162
+ { id: "lastLogin", header: "최근 로그인", accessor: "lastLogin" },
163
+ ];
164
+
165
+ const filters: FilterGroupDef[] = [
166
+ { id: "search", label: "검색", type: "search", value: "" },
167
+ {
168
+ id: "role",
169
+ label: "역할",
170
+ type: "select",
171
+ options: [
172
+ { id: "all", label: "전체", value: "" },
173
+ { id: "admin", label: "Admin", value: "admin" },
174
+ { id: "editor", label: "Editor", value: "editor" },
175
+ { id: "viewer", label: "Viewer", value: "viewer" },
176
+ ],
177
+ value: "",
178
+ },
179
+ { id: "status", label: "상태", type: "toggle", value: false },
180
+ ];
181
+
182
+ const PAGE_SIZE = 10;
183
+
184
+ export function UserExplorerPage() {
185
+ const [page, setPage] = useState(1);
186
+ const [search, setSearch] = useState("");
187
+ const [roleFilter, setRoleFilter] = useState("");
188
+ const [activeOnly, setActiveOnly] = useState(false);
189
+
190
+ const filtered = useMemo(() => {
191
+ let result = allUsers;
192
+ if (search) {
193
+ const q = search.toLowerCase();
194
+ result = result.filter(
195
+ (u) => u.name.toLowerCase().includes(q) || u.email.includes(q),
196
+ );
197
+ }
198
+ if (roleFilter) {
199
+ result = result.filter((u) => u.role === roleFilter);
200
+ }
201
+ if (activeOnly) {
202
+ result = result.filter((u) => u.status === "active");
203
+ }
204
+ return result;
205
+ }, [search, roleFilter, activeOnly]);
206
+
207
+ const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
208
+
209
+ const handleFilterChange = (
210
+ id: string,
211
+ value: string | string[] | boolean,
212
+ ) => {
213
+ if (id === "search") setSearch(value as string);
214
+ if (id === "role") setRoleFilter(value as string);
215
+ if (id === "status") setActiveOnly(value as boolean);
216
+ setPage(1);
217
+ };
218
+
219
+ return (
220
+ <SurfaceLayout>
221
+ <PageHeader
222
+ title="사용자 관리"
223
+ description={`총 ${filtered.length}명`}
224
+ actions={<Button>사용자 추가</Button>}
225
+ />
226
+
227
+ <FilterBar
228
+ filters={filters}
229
+ onFilterChange={handleFilterChange}
230
+ onClearAll={() => {
231
+ setSearch("");
232
+ setRoleFilter("");
233
+ setActiveOnly(false);
234
+ setPage(1);
235
+ }}
236
+ />
237
+
238
+ <DataTable
239
+ data={paged}
240
+ columns={columns}
241
+ keyExtractor={(u) => u.id}
242
+ sortable
243
+ onRowClick={(u) => console.log("선택:", u.id)}
244
+ />
245
+
246
+ <Pagination
247
+ totalItems={filtered.length}
248
+ pageSize={PAGE_SIZE}
249
+ currentPage={page}
250
+ onPageChange={setPage}
251
+ />
252
+ </SurfaceLayout>
253
+ );
254
+ }
255
+ ```
256
+
257
+ ## 3. Settings Page with Vertical Tabs
258
+
259
+ SurfaceLayout with VerticalTabs for section navigation and SettingsForm for each tab.
260
+
261
+ ```tsx
262
+ "use client";
263
+
264
+ import {
265
+ SurfaceLayout,
266
+ PageHeader,
267
+ VerticalTabs,
268
+ SettingsForm,
269
+ type TabDef,
270
+ type FormFieldDef,
271
+ } from "@reopt-ai/opt-ui";
272
+
273
+ const generalFields: FormFieldDef[] = [
274
+ {
275
+ name: "siteName",
276
+ label: "사이트 이름",
277
+ type: "text",
278
+ placeholder: "My App",
279
+ },
280
+ {
281
+ name: "siteUrl",
282
+ label: "사이트 URL",
283
+ type: "text",
284
+ placeholder: "https://...",
285
+ },
286
+ {
287
+ name: "language",
288
+ label: "기본 언어",
289
+ type: "select",
290
+ options: [
291
+ { value: "ko", label: "한국어" },
292
+ { value: "en", label: "English" },
293
+ { value: "ja", label: "日本語" },
294
+ ],
295
+ },
296
+ {
297
+ name: "description",
298
+ label: "사이트 설명",
299
+ type: "textarea",
300
+ placeholder: "사이트에 대한 간단한 설명",
301
+ rows: 3,
302
+ },
303
+ ];
304
+
305
+ const notificationFields: FormFieldDef[] = [
306
+ {
307
+ name: "emailNotify",
308
+ label: "이메일 알림",
309
+ type: "switch",
310
+ description: "중요 업데이트를 이메일로 수신합니다",
311
+ },
312
+ {
313
+ name: "pushNotify",
314
+ label: "푸시 알림",
315
+ type: "switch",
316
+ description: "브라우저 푸시 알림을 허용합니다",
317
+ },
318
+ {
319
+ name: "digestFreq",
320
+ label: "요약 주기",
321
+ type: "select",
322
+ options: [
323
+ { value: "daily", label: "매일" },
324
+ { value: "weekly", label: "매주" },
325
+ { value: "never", label: "사용 안 함" },
326
+ ],
327
+ visible: (values) => values.emailNotify === true,
328
+ },
329
+ ];
330
+
331
+ const securityFields: FormFieldDef[] = [
332
+ {
333
+ name: "twoFactor",
334
+ label: "2단계 인증",
335
+ type: "switch",
336
+ description: "로그인 시 추가 인증을 요구합니다",
337
+ },
338
+ {
339
+ name: "sessionTimeout",
340
+ label: "세션 만료 (분)",
341
+ type: "number",
342
+ placeholder: "30",
343
+ },
344
+ {
345
+ name: "ipWhitelist",
346
+ label: "IP 허용 목록",
347
+ type: "tags",
348
+ placeholder: "IP 주소 입력 후 Enter",
349
+ },
350
+ ];
351
+
352
+ const tabs: TabDef[] = [
353
+ {
354
+ id: "general",
355
+ label: "일반",
356
+ content: (
357
+ <SettingsForm
358
+ fields={generalFields}
359
+ onSubmit={(v) => console.log("general", v)}
360
+ labels={{ submitButton: "저장" }}
361
+ />
362
+ ),
363
+ },
364
+ {
365
+ id: "notifications",
366
+ label: "알림",
367
+ content: (
368
+ <SettingsForm
369
+ fields={notificationFields}
370
+ onSubmit={(v) => console.log("notifications", v)}
371
+ labels={{ submitButton: "저장" }}
372
+ />
373
+ ),
374
+ },
375
+ {
376
+ id: "security",
377
+ label: "보안",
378
+ content: (
379
+ <SettingsForm
380
+ fields={securityFields}
381
+ onSubmit={(v) => console.log("security", v)}
382
+ labels={{ submitButton: "저장" }}
383
+ />
384
+ ),
385
+ },
386
+ ];
387
+
388
+ export function SettingsPage() {
389
+ return (
390
+ <SurfaceLayout>
391
+ <PageHeader title="설정" description="프로젝트 설정을 관리합니다" />
392
+
393
+ <VerticalTabs
394
+ tabs={tabs}
395
+ defaultTabId="general"
396
+ labels={{ ariaLabel: "설정 카테고리" }}
397
+ />
398
+ </SurfaceLayout>
399
+ );
400
+ }
401
+ ```
402
+
403
+ ## Layout Conventions
404
+
405
+ | Element | Approach |
406
+ | -------------- | ------------------------------------------------------------------------- |
407
+ | Page root | `SurfaceLayout` (provides `gap-section` between children) |
408
+ | App chrome | `AppShell` + `AppShell.Header/Sidebar/Content/Footer` |
409
+ | Sidebar nav | `Sidebar` with `Sidebar.Header/Content/Footer` sub-components |
410
+ | Page title | `PageHeader` with optional `description` and `actions` slot |
411
+ | Section grids | `gap-group` between columns, `gap-element` for tight spacing |
412
+ | Tab navigation | `VerticalTabs` for left-side tabs, `ContentTabs` for horizontal |
413
+ | Pagination | `Pagination` below `DataTable` with `totalItems`/`pageSize`/`currentPage` |
414
+ | Loading state | `SurfaceLayout loading={true}` wraps content with `LoadingOverlay` |
415
+
416
+ ### Semantic Spacing Quick Reference
417
+
418
+ ```
419
+ gap-section -> 24px (between major sections)
420
+ gap-group -> 16px (between related items)
421
+ gap-element -> 8px (between tightly coupled elements)
422
+ ```
423
+
424
+ CSS variables: `--opt-space-section`, `--opt-space-group`, `--opt-space-element`.
@@ -0,0 +1,232 @@
1
+ ---
2
+ title: "Theming"
3
+ description: "opt-ui 테마 시스템 — 5개 프리셋, CSS 변수, 다크 모드, 커스텀 테마 생성 가이드"
4
+ related:
5
+ links:
6
+ - 01-getting-started.md
7
+ - 02-components/01-core.md
8
+ ---
9
+
10
+ <!-- AI agent hint: 색상을 하드코딩하지 말 것. bg-zinc-100 대신 bg-[var(--opt-bg-subtle)] 또는 시맨틱 토큰 사용. 스페이싱도 gap-4 대신 gap-section/gap-group/gap-element 사용. -->
11
+
12
+ # Theming
13
+
14
+ opt-ui uses a CSS custom properties (CSS variables) based theme system with 6 built-in presets, each supporting light and dark modes.
15
+
16
+ ## Presets
17
+
18
+ | Preset | Description |
19
+ | ----------- | ---------------------------------------------------- |
20
+ | `default` | Clean, neutral design with Geist Sans typography |
21
+ | `minimal` | Stripped-down, high-contrast, sharp edges |
22
+ | `natural` | Warm tones with Playfair Display + Lora (light only) |
23
+ | `pro` | Neon blue accents, mono headings, dense spacing |
24
+ | `mono-dark` | OLED-friendly black, neon cyan accent, mono headings |
25
+
26
+ > Previous versions also shipped `corporate` and `playful`; both have been
27
+ > removed. Persisted localStorage values are silently migrated to `default`.
28
+ > Re-create their visual feel via the generated theme builder if needed.
29
+
30
+ ## Applying a Preset
31
+
32
+ Import the CSS (already included via `@reopt-ai/opt-ui/tailwind.css`):
33
+
34
+ ```css
35
+ @import "tailwindcss";
36
+ @import "@reopt-ai/opt-ui/tailwind.css";
37
+ ```
38
+
39
+ Individual preset files are also available:
40
+
41
+ ```css
42
+ @import "@reopt-ai/opt-ui/theme/presets/default.css";
43
+ @import "@reopt-ai/opt-ui/theme/presets/mono-dark.css";
44
+ ```
45
+
46
+ ## OptThemeProvider
47
+
48
+ Wrap your app root with `OptThemeProvider` to enable theme switching:
49
+
50
+ ```tsx
51
+ import { OptThemeProvider } from "@reopt-ai/opt-ui";
52
+
53
+ <OptThemeProvider defaultPreset="default">{children}</OptThemeProvider>;
54
+ ```
55
+
56
+ ### Controlling the theme
57
+
58
+ ```tsx
59
+ import { useOptTheme } from "@reopt-ai/opt-ui";
60
+
61
+ function ThemeControls() {
62
+ const { preset, setPreset, mode, setMode } = useOptTheme();
63
+
64
+ return (
65
+ <>
66
+ <select value={preset} onChange={(e) => setPreset(e.target.value)}>
67
+ <option value="default">Default</option>
68
+ <option value="minimal">Minimal</option>
69
+ <option value="natural">Natural</option>
70
+ <option value="pro">Pro</option>
71
+ <option value="mono-dark">Mono Dark</option>
72
+ </select>
73
+
74
+ <button onClick={() => setMode(mode === "dark" ? "light" : "dark")}>
75
+ Toggle Dark Mode
76
+ </button>
77
+ </>
78
+ );
79
+ }
80
+ ```
81
+
82
+ Or use the built-in `ThemeSwitcher` component:
83
+
84
+ ```tsx
85
+ import { ThemeSwitcher } from "@reopt-ai/opt-ui";
86
+
87
+ <ThemeSwitcher /> // Full grid with mode toggle
88
+ <ThemeSwitcher variant="compact" /> // Inline horizontal layout
89
+ ```
90
+
91
+ ## CSS Variables
92
+
93
+ Presets define variables on `[data-theme="preset"]` (light) and `[data-theme="preset-dark"]` (dark) selectors.
94
+
95
+ ### Surface & Background
96
+
97
+ | Variable | Description |
98
+ | ----------------------- | ------------------------ |
99
+ | `--opt-surface` | Primary surface color |
100
+ | `--opt-surface-raised` | Elevated surface (cards) |
101
+ | `--opt-surface-overlay` | Overlay/modal surface |
102
+ | `--opt-surface-input` | Input background |
103
+ | `--opt-bg` | Page background |
104
+ | `--opt-bg-subtle` | Subtle background tint |
105
+ | `--opt-bg-muted` | Muted background |
106
+
107
+ ### Text
108
+
109
+ | Variable | Description |
110
+ | ---------------------- | ------------------- |
111
+ | `--opt-text` | Primary text color |
112
+ | `--opt-text-secondary` | Secondary text |
113
+ | `--opt-text-tertiary` | Tertiary/muted text |
114
+
115
+ ### Accent & Status
116
+
117
+ | Variable | Description |
118
+ | --------------------- | -------------------- |
119
+ | `--opt-accent` | Primary accent color |
120
+ | `--opt-accent-hover` | Accent hover state |
121
+ | `--opt-accent-subtle` | Accent subtle tint |
122
+ | `--opt-accent-fg` | Text on accent |
123
+ | `--opt-success` | Success color |
124
+ | `--opt-warning` | Warning color |
125
+ | `--opt-danger` | Danger/error color |
126
+ | `--opt-info` | Info color |
127
+
128
+ ### Border & Focus
129
+
130
+ | Variable | Description |
131
+ | --------------------- | ------------------ |
132
+ | `--opt-border` | Default border |
133
+ | `--opt-border-hover` | Border hover state |
134
+ | `--opt-border-subtle` | Subtle border |
135
+ | `--opt-ring` | Focus ring color |
136
+ | `--opt-ring-offset` | Focus ring offset |
137
+
138
+ ### Spacing
139
+
140
+ | Variable | Value | Tailwind class |
141
+ | --------------------- | -------- | -------------- |
142
+ | `--opt-space-section` | `1.5rem` | `gap-section` |
143
+ | `--opt-space-group` | `1rem` | `gap-group` |
144
+ | `--opt-space-element` | `0.5rem` | `gap-element` |
145
+
146
+ ### Typography
147
+
148
+ | Variable | Description |
149
+ | ------------------------ | ---------------------- |
150
+ | `--opt-font-heading` | Heading font family |
151
+ | `--opt-font-body` | Body font family |
152
+ | `--opt-tracking-heading` | Heading letter spacing |
153
+ | `--opt-weight-heading` | Heading font weight |
154
+
155
+ ### Radius & Shadow
156
+
157
+ | Variable | Description |
158
+ | ----------------- | ------------- |
159
+ | `--opt-radius-sm` | Small radius |
160
+ | `--opt-radius-md` | Medium radius |
161
+ | `--opt-radius-lg` | Large radius |
162
+ | `--opt-shadow-sm` | Small shadow |
163
+ | `--opt-shadow-md` | Medium shadow |
164
+ | `--opt-shadow-lg` | Large shadow |
165
+
166
+ ### Chart
167
+
168
+ | Variable | Description |
169
+ | --------------- | ------------- |
170
+ | `--opt-chart-1` | Chart color 1 |
171
+ | `--opt-chart-2` | Chart color 2 |
172
+ | `--opt-chart-3` | Chart color 3 |
173
+ | `--opt-chart-4` | Chart color 4 |
174
+ | `--opt-chart-5` | Chart color 5 |
175
+
176
+ ## Dark Mode
177
+
178
+ Dark mode uses the `[data-theme="preset-dark"]` selector. The compound theme system applies automatically when using `OptThemeProvider`.
179
+
180
+ ```css
181
+ /* Light mode */
182
+ [data-theme="default"] {
183
+ --opt-surface: #ffffff;
184
+ --opt-text: hsl(0 0% 20%);
185
+ }
186
+
187
+ /* Dark mode */
188
+ [data-theme="default-dark"] {
189
+ --opt-surface: hsl(204 4% 16%);
190
+ --opt-text: hsl(0 0% 82%);
191
+ }
192
+ ```
193
+
194
+ Tailwind `dark:` classes work automatically via:
195
+
196
+ ```css
197
+ @custom-variant dark (&:where([data-theme$="-dark"], ...));
198
+ ```
199
+
200
+ ## Custom Preset
201
+
202
+ Create a custom preset by defining a new `[data-theme]` selector with your variables:
203
+
204
+ ```css
205
+ /* my-preset.css */
206
+ [data-theme="my-brand"] {
207
+ --opt-surface: #fafafa;
208
+ --opt-accent: hsl(262 83% 58%);
209
+ --opt-accent-hover: hsl(262 83% 50%);
210
+ --opt-accent-fg: #ffffff;
211
+ --opt-text: hsl(0 0% 15%);
212
+ --opt-text-secondary: hsl(0 0% 40%);
213
+ --opt-border: rgba(0, 0, 0, 0.12);
214
+ --opt-ring: hsl(262 83% 58%);
215
+ /* ... override other variables as needed */
216
+ }
217
+
218
+ [data-theme="my-brand-dark"] {
219
+ --opt-surface: hsl(262 10% 12%);
220
+ --opt-accent: hsl(262 83% 65%);
221
+ --opt-text: hsl(0 0% 85%);
222
+ /* ... dark mode overrides */
223
+ }
224
+ ```
225
+
226
+ Import it in your global CSS and use it with `OptThemeProvider`:
227
+
228
+ ```tsx
229
+ <OptThemeProvider defaultPreset="my-brand">{children}</OptThemeProvider>
230
+ ```
231
+
232
+ > Tip: Copy an existing preset file (e.g., `default.css`) as a starting point and modify the values. All variables should be defined for consistent behavior.