@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.
- package/CHANGELOG.md +40 -0
- package/COMPONENT_CATALOG.md +3000 -0
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/dist/chunk-3GWWZKX7.js +38 -0
- package/dist/chunk-AFF2HPE5.cjs +5008 -0
- package/dist/chunk-ELWICXYY.js +4745 -0
- package/dist/chunk-N4NDU5ET.cjs +4745 -0
- package/dist/chunk-ONE3C5RV.cjs +38 -0
- package/dist/chunk-QWBHD54V.js +3218 -0
- package/dist/chunk-RBM2RNC2.js +5008 -0
- package/dist/chunk-X5WCXJAF.cjs +3218 -0
- package/dist/core/index.cjs +324 -0
- package/dist/core/index.d.cts +5 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +324 -0
- package/dist/docs/01-getting-started.md +129 -0
- package/dist/docs/02-components/01-core.md +1841 -0
- package/dist/docs/02-components/02-visuals.md +11 -0
- package/dist/docs/02-components/03-shells.md +1361 -0
- package/dist/docs/02-components/04-surfaces.md +11 -0
- package/dist/docs/02-components/index.md +106 -0
- package/dist/docs/03-recipes/01-forms.md +471 -0
- package/dist/docs/03-recipes/02-dashboards.md +397 -0
- package/dist/docs/03-recipes/03-layouts.md +424 -0
- package/dist/docs/04-theming.md +232 -0
- package/dist/docs/05-migration/01-breaking-changes.md +117 -0
- package/dist/docs/05-migration/02-formstore.md +336 -0
- package/dist/docs/06-troubleshooting.md +119 -0
- package/dist/docs/index.md +71 -0
- package/dist/id-registry.cjs +1875 -0
- package/dist/id-registry.d.cts +27 -0
- package/dist/id-registry.d.ts +27 -0
- package/dist/id-registry.js +1875 -0
- package/dist/id-registry.json +3799 -0
- package/dist/index-BZ_lBlO1.d.ts +474 -0
- package/dist/index-BuvxoWHf.d.cts +474 -0
- package/dist/index-DlAcuvQp.d.cts +1686 -0
- package/dist/index-Slu5hOj1.d.ts +1686 -0
- package/dist/index.cjs +9959 -0
- package/dist/index.d.cts +2351 -0
- package/dist/index.d.ts +2351 -0
- package/dist/index.js +9959 -0
- package/dist/meta.cjs +6898 -0
- package/dist/meta.d.cts +36 -0
- package/dist/meta.d.ts +36 -0
- package/dist/meta.js +6869 -0
- package/dist/shells/index.cjs +65 -0
- package/dist/shells/index.d.cts +5 -0
- package/dist/shells/index.d.ts +5 -0
- package/dist/shells/index.js +65 -0
- package/dist/tailwind.css +401 -0
- package/dist/theme/presets/default.css +355 -0
- package/dist/theme/presets/minimal.css +354 -0
- package/dist/theme/presets/mono-dark.css +354 -0
- package/dist/theme/presets/natural.css +181 -0
- package/dist/theme/presets/pro.css +354 -0
- package/dist/types-D4-0lwaE.d.cts +298 -0
- package/dist/types-D4-0lwaE.d.ts +298 -0
- package/dist/visuals/index.cjs +4 -0
- package/dist/visuals/index.d.cts +1 -0
- package/dist/visuals/index.d.ts +1 -0
- package/dist/visuals/index.js +4 -0
- 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.
|