@conscience-technology/ctds 0.0.0-stage → 0.1.0

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 (289) hide show
  1. package/README.md +128 -2
  2. package/assets/components/AppFrame/AppFrame.module.css +177 -0
  3. package/assets/components/AppFrame/AppFrame.tsx +93 -0
  4. package/assets/components/AppFrame/index.ts +6 -0
  5. package/assets/components/AttachmentChip/AttachmentChip.module.css +71 -0
  6. package/assets/components/AttachmentChip/AttachmentChip.tsx +74 -0
  7. package/assets/components/AttachmentChip/index.ts +2 -0
  8. package/assets/components/AttachmentThumb/AttachmentThumb.module.css +107 -0
  9. package/assets/components/AttachmentThumb/AttachmentThumb.tsx +91 -0
  10. package/assets/components/AttachmentThumb/index.ts +2 -0
  11. package/assets/components/Avatar/Avatar.module.css +167 -0
  12. package/assets/components/Avatar/Avatar.tsx +228 -0
  13. package/assets/components/Avatar/index.ts +2 -0
  14. package/assets/components/AvatarUpload/AvatarUpload.module.css +130 -0
  15. package/assets/components/AvatarUpload/AvatarUpload.tsx +139 -0
  16. package/assets/components/AvatarUpload/index.ts +2 -0
  17. package/assets/components/Badge/Badge.module.css +76 -0
  18. package/assets/components/Badge/Badge.tsx +68 -0
  19. package/assets/components/Badge/index.ts +2 -0
  20. package/assets/components/Breadcrumb/Breadcrumb.module.css +213 -0
  21. package/assets/components/Breadcrumb/Breadcrumb.tsx +279 -0
  22. package/assets/components/Breadcrumb/index.ts +8 -0
  23. package/assets/components/Button/Button.module.css +216 -0
  24. package/assets/components/Button/Button.tsx +149 -0
  25. package/assets/components/Button/index.ts +2 -0
  26. package/assets/components/Callout/Callout.module.css +146 -0
  27. package/assets/components/Callout/Callout.tsx +138 -0
  28. package/assets/components/Callout/index.ts +2 -0
  29. package/assets/components/ChatInput/ChatInput.module.css +241 -0
  30. package/assets/components/ChatInput/ChatInput.tsx +322 -0
  31. package/assets/components/ChatInput/ModelSelector.tsx +40 -0
  32. package/assets/components/ChatInput/SendButton.tsx +41 -0
  33. package/assets/components/ChatInput/index.ts +10 -0
  34. package/assets/components/Checkbox/Checkbox.module.css +66 -0
  35. package/assets/components/Checkbox/Checkbox.tsx +36 -0
  36. package/assets/components/Checkbox/index.ts +2 -0
  37. package/assets/components/Chip/Chip.module.css +128 -0
  38. package/assets/components/Chip/Chip.tsx +103 -0
  39. package/assets/components/Chip/index.ts +2 -0
  40. package/assets/components/ChoiceCard/ChoiceCard.module.css +142 -0
  41. package/assets/components/ChoiceCard/ChoiceCard.tsx +132 -0
  42. package/assets/components/ChoiceCard/index.ts +2 -0
  43. package/assets/components/ContextMenu/ContextMenu.module.css +102 -0
  44. package/assets/components/ContextMenu/ContextMenu.tsx +392 -0
  45. package/assets/components/ContextMenu/index.ts +9 -0
  46. package/assets/components/DatePicker/Calendar.tsx +234 -0
  47. package/assets/components/DatePicker/DESIGN_NOTES.md +106 -0
  48. package/assets/components/DatePicker/DESIGN_NOTES_TIME.md +125 -0
  49. package/assets/components/DatePicker/DatePicker.module.css +295 -0
  50. package/assets/components/DatePicker/DatePicker.tsx +387 -0
  51. package/assets/components/DatePicker/TimeSelector.tsx +69 -0
  52. package/assets/components/DatePicker/index.ts +8 -0
  53. package/assets/components/DatePicker/presets.ts +80 -0
  54. package/assets/components/DatePicker/utils.ts +148 -0
  55. package/assets/components/Disclosure/Disclosure.module.css +73 -0
  56. package/assets/components/Disclosure/Disclosure.tsx +129 -0
  57. package/assets/components/Disclosure/index.ts +6 -0
  58. package/assets/components/Drawer/Drawer.module.css +257 -0
  59. package/assets/components/Drawer/Drawer.tsx +126 -0
  60. package/assets/components/Drawer/index.ts +7 -0
  61. package/assets/components/EmptyState/EmptyState.module.css +102 -0
  62. package/assets/components/EmptyState/EmptyState.tsx +80 -0
  63. package/assets/components/EmptyState/index.ts +2 -0
  64. package/assets/components/FileIcon/FileIcon.module.css +66 -0
  65. package/assets/components/FileIcon/FileIcon.tsx +84 -0
  66. package/assets/components/FileIcon/fileExtMap.ts +112 -0
  67. package/assets/components/FileIcon/index.ts +9 -0
  68. package/assets/components/FilterButton/FilterButton.module.css +130 -0
  69. package/assets/components/FilterButton/FilterButton.tsx +94 -0
  70. package/assets/components/FilterButton/index.ts +2 -0
  71. package/assets/components/GalleryItem/GalleryItem.module.css +220 -0
  72. package/assets/components/GalleryItem/GalleryItem.tsx +163 -0
  73. package/assets/components/GalleryItem/index.ts +7 -0
  74. package/assets/components/Icon/Icon.tsx +616 -0
  75. package/assets/components/Icon/context.tsx +39 -0
  76. package/assets/components/Icon/index.ts +4 -0
  77. package/assets/components/IconButton/IconButton.module.css +109 -0
  78. package/assets/components/IconButton/IconButton.tsx +110 -0
  79. package/assets/components/IconButton/index.ts +2 -0
  80. package/assets/components/Input/Input.module.css +338 -0
  81. package/assets/components/Input/Input.tsx +371 -0
  82. package/assets/components/Input/index.ts +2 -0
  83. package/assets/components/ListItem/ListItem.module.css +318 -0
  84. package/assets/components/ListItem/ListItem.tsx +262 -0
  85. package/assets/components/ListItem/index.ts +9 -0
  86. package/assets/components/MenuItem/MenuItem.module.css +437 -0
  87. package/assets/components/MenuItem/MenuItem.tsx +529 -0
  88. package/assets/components/MenuItem/index.ts +8 -0
  89. package/assets/components/MetricCard/MetricCard.module.css +153 -0
  90. package/assets/components/MetricCard/MetricCard.tsx +140 -0
  91. package/assets/components/MetricCard/index.ts +2 -0
  92. package/assets/components/Modal/Modal.module.css +175 -0
  93. package/assets/components/Modal/Modal.tsx +98 -0
  94. package/assets/components/Modal/index.ts +2 -0
  95. package/assets/components/Onboarding/DocsIndex.tsx +82 -0
  96. package/assets/components/Onboarding/LayoutBuilder.tsx +160 -0
  97. package/assets/components/Onboarding/Onboarding.module.css +1394 -0
  98. package/assets/components/Onboarding/OnboardingAxisPanel.tsx +515 -0
  99. package/assets/components/Onboarding/OnboardingPage.tsx +1193 -0
  100. package/assets/components/Onboarding/PresetsGallery.tsx +65 -0
  101. package/assets/components/Onboarding/actions.ts +250 -0
  102. package/assets/components/Onboarding/index.ts +8 -0
  103. package/assets/components/Onboarding/palette.css +40 -0
  104. package/assets/components/Onboarding/wizardData.ts +265 -0
  105. package/assets/components/Pagination/Pagination.module.css +82 -0
  106. package/assets/components/Pagination/Pagination.tsx +248 -0
  107. package/assets/components/Pagination/index.ts +6 -0
  108. package/assets/components/ProgressBar/ProgressBar.module.css +64 -0
  109. package/assets/components/ProgressBar/ProgressBar.tsx +54 -0
  110. package/assets/components/ProgressBar/index.ts +2 -0
  111. package/assets/components/QuotationBar/QuotationBar.module.css +61 -0
  112. package/assets/components/QuotationBar/QuotationBar.tsx +48 -0
  113. package/assets/components/QuotationBar/index.ts +2 -0
  114. package/assets/components/Radio/Radio.module.css +63 -0
  115. package/assets/components/Radio/Radio.tsx +33 -0
  116. package/assets/components/Radio/index.ts +2 -0
  117. package/assets/components/Select/Select.module.css +151 -0
  118. package/assets/components/Select/Select.tsx +425 -0
  119. package/assets/components/Select/index.ts +10 -0
  120. package/assets/components/SelectableCard/SelectableCard.module.css +177 -0
  121. package/assets/components/SelectableCard/SelectableCard.tsx +83 -0
  122. package/assets/components/SelectableCard/index.ts +2 -0
  123. package/assets/components/Shortcut/Shortcut.module.css +56 -0
  124. package/assets/components/Shortcut/Shortcut.tsx +156 -0
  125. package/assets/components/Shortcut/index.ts +2 -0
  126. package/assets/components/Sidebar/Sidebar.module.css +242 -0
  127. package/assets/components/Sidebar/Sidebar.tsx +264 -0
  128. package/assets/components/Sidebar/index.ts +7 -0
  129. package/assets/components/SidebarSplit/SidebarSplit.module.css +126 -0
  130. package/assets/components/SidebarSplit/SidebarSplit.tsx +222 -0
  131. package/assets/components/SidebarSplit/index.ts +6 -0
  132. package/assets/components/Slider/Slider.module.css +120 -0
  133. package/assets/components/Slider/Slider.tsx +88 -0
  134. package/assets/components/Slider/index.ts +2 -0
  135. package/assets/components/SplitButton/SplitButton.module.css +190 -0
  136. package/assets/components/SplitButton/SplitButton.tsx +145 -0
  137. package/assets/components/SplitButton/index.ts +6 -0
  138. package/assets/components/Stepper/Stepper.module.css +182 -0
  139. package/assets/components/Stepper/Stepper.tsx +185 -0
  140. package/assets/components/Stepper/index.ts +2 -0
  141. package/assets/components/Switch/Switch.module.css +116 -0
  142. package/assets/components/Switch/Switch.tsx +58 -0
  143. package/assets/components/Switch/index.ts +2 -0
  144. package/assets/components/TableCell/TableCell.module.css +137 -0
  145. package/assets/components/TableCell/TableCellAvatar.tsx +42 -0
  146. package/assets/components/TableCell/TableCellBadge.tsx +23 -0
  147. package/assets/components/TableCell/TableCellIcon.tsx +39 -0
  148. package/assets/components/TableCell/TableCellImage.tsx +49 -0
  149. package/assets/components/TableCell/TableCellLink.tsx +33 -0
  150. package/assets/components/TableCell/TableCellMeter.tsx +55 -0
  151. package/assets/components/TableCell/TableCellNumber.tsx +43 -0
  152. package/assets/components/TableCell/TableCellText.tsx +30 -0
  153. package/assets/components/TableCell/index.ts +23 -0
  154. package/assets/components/TableHeader/TableHeader.module.css +287 -0
  155. package/assets/components/TableHeader/TableHeader.tsx +590 -0
  156. package/assets/components/TableHeader/index.ts +9 -0
  157. package/assets/components/TableRow/TableRow.module.css +290 -0
  158. package/assets/components/TableRow/TableRow.tsx +253 -0
  159. package/assets/components/TableRow/index.ts +8 -0
  160. package/assets/components/TableRowEdit/TableRowEdit.module.css +126 -0
  161. package/assets/components/TableRowEdit/TableRowEdit.tsx +247 -0
  162. package/assets/components/TableRowEdit/index.ts +2 -0
  163. package/assets/components/TableRowSelect/TableRowSelect.module.css +23 -0
  164. package/assets/components/TableRowSelect/TableRowSelect.tsx +175 -0
  165. package/assets/components/TableRowSelect/index.ts +2 -0
  166. package/assets/components/Tabs/Tabs.module.css +234 -0
  167. package/assets/components/Tabs/Tabs.tsx +426 -0
  168. package/assets/components/Tabs/index.ts +9 -0
  169. package/assets/components/Textarea/Textarea.module.css +318 -0
  170. package/assets/components/Textarea/Textarea.tsx +426 -0
  171. package/assets/components/Textarea/index.ts +7 -0
  172. package/assets/components/ThemeController.tsx +271 -0
  173. package/assets/components/TimePicker/TimePicker.module.css +317 -0
  174. package/assets/components/TimePicker/TimePicker.tsx +821 -0
  175. package/assets/components/TimePicker/index.ts +8 -0
  176. package/assets/components/TimePicker/utils.ts +115 -0
  177. package/assets/components/Toast/Toast.module.css +264 -0
  178. package/assets/components/Toast/Toast.tsx +243 -0
  179. package/assets/components/Toast/index.ts +15 -0
  180. package/assets/components/Toolbar/Toolbar.module.css +72 -0
  181. package/assets/components/Toolbar/Toolbar.tsx +75 -0
  182. package/assets/components/Toolbar/index.ts +2 -0
  183. package/assets/components/Tooltip/Tooltip.module.css +42 -0
  184. package/assets/components/Tooltip/Tooltip.tsx +332 -0
  185. package/assets/components/Tooltip/index.ts +2 -0
  186. package/assets/components/Topbar/Topbar.module.css +26 -0
  187. package/assets/components/Topbar/Topbar.tsx +47 -0
  188. package/assets/components/Topbar/index.ts +2 -0
  189. package/assets/components/_cn.ts +3 -0
  190. package/assets/components/_color.ts +48 -0
  191. package/assets/components/_data.ts +659 -0
  192. package/assets/components/_utils.css +19 -0
  193. package/assets/components/axisOptions.ts +24 -0
  194. package/assets/components/tokens.css +742 -0
  195. package/assets/prompts/GUIDE.md +251 -0
  196. package/assets/prompts/OVERRIDE.md +170 -0
  197. package/assets/prompts/PRINCIPLES.md +130 -0
  198. package/assets/prompts/axes/density.md +38 -0
  199. package/assets/prompts/axes/font.md +36 -0
  200. package/assets/prompts/axes/hue.md +36 -0
  201. package/assets/prompts/axes/icon.md +48 -0
  202. package/assets/prompts/axes/mode.md +50 -0
  203. package/assets/prompts/axes/motion.md +77 -0
  204. package/assets/prompts/axes/palette.md +35 -0
  205. package/assets/prompts/axes/shape.md +33 -0
  206. package/assets/prompts/axes/voice.md +54 -0
  207. package/assets/prompts/components/README.md +84 -0
  208. package/assets/prompts/components/app-frame.md +94 -0
  209. package/assets/prompts/components/attachment-chip.md +46 -0
  210. package/assets/prompts/components/attachment-thumb.md +49 -0
  211. package/assets/prompts/components/avatar.md +86 -0
  212. package/assets/prompts/components/badge.md +104 -0
  213. package/assets/prompts/components/breadcrumb.md +77 -0
  214. package/assets/prompts/components/button.md +99 -0
  215. package/assets/prompts/components/callout.md +81 -0
  216. package/assets/prompts/components/chat-input.md +84 -0
  217. package/assets/prompts/components/checkbox.md +46 -0
  218. package/assets/prompts/components/chip.md +84 -0
  219. package/assets/prompts/components/choice-card.md +127 -0
  220. package/assets/prompts/components/context-menu.md +145 -0
  221. package/assets/prompts/components/date-picker.md +104 -0
  222. package/assets/prompts/components/drawer.md +152 -0
  223. package/assets/prompts/components/empty-state.md +69 -0
  224. package/assets/prompts/components/file-icon.md +73 -0
  225. package/assets/prompts/components/filter-button.md +42 -0
  226. package/assets/prompts/components/form-field.md +74 -0
  227. package/assets/prompts/components/gallery-item.md +158 -0
  228. package/assets/prompts/components/icon-button.md +82 -0
  229. package/assets/prompts/components/icon.md +68 -0
  230. package/assets/prompts/components/input.md +95 -0
  231. package/assets/prompts/components/list-item.md +235 -0
  232. package/assets/prompts/components/menu-item.md +234 -0
  233. package/assets/prompts/components/metric-card.md +132 -0
  234. package/assets/prompts/components/modal.md +131 -0
  235. package/assets/prompts/components/pagination.md +92 -0
  236. package/assets/prompts/components/progress-bar.md +61 -0
  237. package/assets/prompts/components/quotation-bar.md +39 -0
  238. package/assets/prompts/components/radio.md +41 -0
  239. package/assets/prompts/components/select.md +104 -0
  240. package/assets/prompts/components/selectable-card.md +67 -0
  241. package/assets/prompts/components/shortcut.md +76 -0
  242. package/assets/prompts/components/sidebar.md +107 -0
  243. package/assets/prompts/components/slider.md +89 -0
  244. package/assets/prompts/components/split-button.md +71 -0
  245. package/assets/prompts/components/stepper.md +93 -0
  246. package/assets/prompts/components/switch.md +86 -0
  247. package/assets/prompts/components/table-cell.md +87 -0
  248. package/assets/prompts/components/table-header.md +157 -0
  249. package/assets/prompts/components/table-row-edit.md +129 -0
  250. package/assets/prompts/components/table-row-select.md +82 -0
  251. package/assets/prompts/components/table-row.md +156 -0
  252. package/assets/prompts/components/tabs.md +122 -0
  253. package/assets/prompts/components/textarea.md +105 -0
  254. package/assets/prompts/components/toast.md +149 -0
  255. package/assets/prompts/components/tooltip.md +71 -0
  256. package/assets/prompts/components/topbar.md +148 -0
  257. package/assets/prompts/primitives/emerge.md +43 -0
  258. package/assets/prompts/primitives/fade.md +31 -0
  259. package/assets/prompts/primitives/scale.md +32 -0
  260. package/assets/prompts/primitives/scroll.md +75 -0
  261. package/assets/prompts/primitives/slide.md +31 -0
  262. package/assets/prompts/themes/_SCHEMA.md +135 -0
  263. package/assets/prompts/themes/_TEMPLATE.md +73 -0
  264. package/assets/prompts/themes/expressive.md +118 -0
  265. package/assets/prompts/themes/pure.md +87 -0
  266. package/assets/prompts/themes/spare.md +73 -0
  267. package/assets/prompts/tokens/breakpoint.md +29 -0
  268. package/assets/prompts/tokens/color.md +78 -0
  269. package/assets/prompts/tokens/radius.md +33 -0
  270. package/assets/prompts/tokens/shadow.md +35 -0
  271. package/assets/prompts/tokens/spacing.md +86 -0
  272. package/assets/prompts/tokens/typography.md +76 -0
  273. package/assets/prompts/tokens/z-index.md +35 -0
  274. package/assets/public/expressive.png +0 -0
  275. package/assets/public/pure.png +0 -0
  276. package/assets/public/spare.png +0 -0
  277. package/assets/skill/SKILL.md +36 -0
  278. package/bin/ctds.js +76 -0
  279. package/package.json +35 -4
  280. package/src/commands/init.js +215 -0
  281. package/src/commands/update.js +275 -0
  282. package/src/utils/assets.js +78 -0
  283. package/src/utils/deps.js +51 -0
  284. package/src/utils/detect.js +47 -0
  285. package/src/utils/diff.js +52 -0
  286. package/src/utils/hash.js +16 -0
  287. package/src/utils/log.js +41 -0
  288. package/src/utils/meta.js +73 -0
  289. package/src/utils/prompt.js +28 -0
@@ -0,0 +1,1193 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import { Badge } from "../Badge";
5
+ import { Button } from "../Button";
6
+ import { Icon } from "../Icon";
7
+ import { FilterButton } from "../FilterButton";
8
+ import { Input } from "../Input";
9
+ import { SelectableCard } from "../SelectableCard";
10
+ import { Slider } from "../Slider";
11
+ import { Stepper } from "../Stepper";
12
+ import { hexToOklch, isValidHex } from "../_color";
13
+ import { palettePresets, type PaletteValue } from "../_data";
14
+ import { useTheme } from "../ThemeController";
15
+ import { OnboardingAxisPanel } from "./OnboardingAxisPanel";
16
+ import { applyOnboarding, checkExistingFiles } from "./actions";
17
+ import { PresetsGallery } from "./PresetsGallery";
18
+ import { LayoutBuilder } from "./LayoutBuilder";
19
+ import { DocsIndex } from "./DocsIndex";
20
+ import {
21
+ DENSITY_CHOICES,
22
+ LAYOUT_CHOICES,
23
+ PALETTE_CHOICES,
24
+ THEME_CHOICES,
25
+ THEME_PRESETS,
26
+ THEME_VIBE_FALLBACK,
27
+ VIBE_CHOICES,
28
+ type DensityChoiceId,
29
+ type LayoutId,
30
+ type ThemeId,
31
+ type VibeId,
32
+ } from "./wizardData";
33
+ import styles from "./Onboarding.module.css";
34
+ import "./palette.css";
35
+
36
+ type StepKey = "theme" | "vibe" | "color" | "density" | "layout" | "summary";
37
+
38
+ type Answers = {
39
+ theme: ThemeId | null;
40
+ vibe: VibeId | null;
41
+ palette: PaletteValue | null;
42
+ hue: number | null;
43
+ hueSource: "preset" | "hex" | null;
44
+ brandHex: string;
45
+ density: DensityChoiceId | null;
46
+ layout: LayoutId | null;
47
+ };
48
+
49
+ const STEPS: { key: StepKey; title: string; hint: string }[] = [
50
+ { key: "theme", title: "디자인 스타일", hint: "시작 번들. 축 디폴트 + DESIGN.md가 함께 적용." },
51
+ { key: "vibe", title: "프로덕트 분위기", hint: "코너 · 모션 · 강조색이 세트로 결정." },
52
+ { key: "density", title: "정보 밀도", hint: "한 화면에 얼마나 빽빽하게 담을지." },
53
+ { key: "color", title: "컬러 & 톤", hint: "팔레트 선택 + 브랜드 색." },
54
+ { key: "layout", title: "레이아웃", hint: "사이드바 · 탑바 조합." },
55
+ { key: "summary", title: "요약 & 적용", hint: "선택을 확인하고 프로젝트에 반영." },
56
+ ];
57
+
58
+ export function OnboardingPage() {
59
+ const {
60
+ setShape,
61
+ setMotionMode,
62
+ setPalette,
63
+ setHue,
64
+ setDensity,
65
+ applyPreset,
66
+ voice,
67
+ focusRing,
68
+ } = useTheme();
69
+ const [mode, setMode] = useState<"wizard" | "free">("wizard");
70
+ const [stepIdx, setStepIdx] = useState(0);
71
+ /** 적용 확인 다이얼로그 상태. 적용 진행 중/완료/실패까지 담는다.
72
+ * confirm 단계에서 existing(이미 있는 파일 목록)이 비어있지 않으면 "덮어쓸래?" 플로우. */
73
+ const [applyState, setApplyState] = useState<
74
+ | { phase: "idle" }
75
+ | { phase: "confirm"; existing: string[] }
76
+ | { phase: "applying" }
77
+ | { phase: "done"; files: string[] }
78
+ | { phase: "error"; message: string; files: string[] }
79
+ >({ phase: "idle" });
80
+ const [answers, setAnswers] = useState<Answers>({
81
+ theme: "none",
82
+ vibe: "balanced",
83
+ palette: "standard",
84
+ hue: 210,
85
+ hueSource: "preset",
86
+ brandHex: "",
87
+ density: "default",
88
+ layout: "topbar-sidebar",
89
+ });
90
+
91
+ const applyTheme = (t: ThemeId) => {
92
+ setAnswers((a) => ({ ...a, theme: t }));
93
+ // "none" 은 이후 축을 사용자가 직접 선택.
94
+ // pure/spare/expressive는 prompts/themes/<slug>.md의 Preset을 자동 매핑하고
95
+ // vibe/density도 테마가 커버하므로 answers에 미리 세팅 → vibe 단계 skip 가능.
96
+ if (t === "none") return;
97
+ const preset = THEME_PRESETS[t];
98
+ const vibeFallback = THEME_VIBE_FALLBACK[t];
99
+ applyPreset(preset);
100
+ setAnswers((a) => ({
101
+ ...a,
102
+ theme: t,
103
+ vibe: vibeFallback,
104
+ density: preset.density,
105
+ }));
106
+ };
107
+
108
+ const applyVibe = (v: VibeId) => {
109
+ const c = VIBE_CHOICES.find((x) => x.id === v);
110
+ if (!c) return;
111
+ setShape(c.values.shape);
112
+ setMotionMode(c.values.motion);
113
+ // serious vibe는 palette=mono를 승계.
114
+ if (c.values.palette) {
115
+ setPalette(c.values.palette);
116
+ setAnswers((a) => ({ ...a, vibe: v, palette: c.values.palette! }));
117
+ return;
118
+ }
119
+ setAnswers((a) => ({ ...a, vibe: v }));
120
+ };
121
+ const applyPalette = (v: PaletteValue) => {
122
+ setPalette(v);
123
+ setAnswers((a) => ({ ...a, palette: v }));
124
+ };
125
+ const applyHue = (h: number) => {
126
+ setHue(h);
127
+ setAnswers((a) => ({ ...a, hue: h, hueSource: "preset", brandHex: "" }));
128
+ };
129
+ /**
130
+ * hex 입력 시 그 색을 그대로 재현.
131
+ * 축이 L/C는 palette preset에서, hue는 slider에서 오므로 L/C에 가장 가까운
132
+ * preset을 매칭하고 base hue를 preset.hueShift만큼 역보정한다.
133
+ * 결과 oklch(L_preset, C_preset, baseHue + hueShift)가 입력 hex와 가장 근접.
134
+ */
135
+ const applyBrandHex = (hex: string) => {
136
+ const trimmed = hex.trim();
137
+ if (!trimmed) {
138
+ // hex 비우기 — 수동 모드 복귀. 축은 그대로 두고 answers만 정리.
139
+ setAnswers((a) => ({ ...a, brandHex: "", hueSource: "preset" }));
140
+ return;
141
+ }
142
+ if (!isValidHex(trimmed)) {
143
+ setAnswers((a) => ({ ...a, brandHex: trimmed }));
144
+ return;
145
+ }
146
+ const oklch = hexToOklch(trimmed);
147
+ if (!oklch) {
148
+ setAnswers((a) => ({ ...a, brandHex: trimmed }));
149
+ return;
150
+ }
151
+ // L/C 평면에서 가장 가까운 preset (chroma 가중치 ↑ — chroma가 톤 성격을 더 좌우)
152
+ const nearest = palettePresets.reduce((best, p) => {
153
+ const d =
154
+ (p.lightness - oklch.l) ** 2 + ((p.chroma - oklch.c) * 2) ** 2;
155
+ const bestD =
156
+ (best.lightness - oklch.l) ** 2 + ((best.chroma - oklch.c) * 2) ** 2;
157
+ return d < bestD ? p : best;
158
+ }, palettePresets[0]);
159
+ const baseHue = ((Math.round(oklch.h - nearest.hueShift) % 360) + 360) % 360;
160
+ setPalette(nearest.id);
161
+ setHue(baseHue);
162
+ setAnswers((a) => ({
163
+ ...a,
164
+ brandHex: trimmed,
165
+ palette: nearest.id,
166
+ hue: baseHue,
167
+ hueSource: "hex",
168
+ }));
169
+ };
170
+ const applyDensity = (v: DensityChoiceId) => {
171
+ setDensity(v);
172
+ setAnswers((a) => ({ ...a, density: v }));
173
+ };
174
+
175
+ /** 적용 흐름: 1) 확인 다이얼로그 open (기존 파일 조회) → 2) 사용자 확인 → 3) 미리보기 적용 + 파일 쓰기 */
176
+ const openApplyConfirm = async () => {
177
+ const { existing } = await checkExistingFiles();
178
+ setApplyState({ phase: "confirm", existing });
179
+ };
180
+
181
+ const runApply = async () => {
182
+ const vibe = VIBE_CHOICES.find((c) => c.id === answers.vibe);
183
+ const paletteChoice = PALETTE_CHOICES.find((c) => c.id === answers.palette);
184
+ const density = DENSITY_CHOICES.find((c) => c.id === answers.density);
185
+ if (!vibe || !paletteChoice || !density || !answers.theme) {
186
+ setApplyState({ phase: "error", message: "선택값이 비어있습니다", files: [] });
187
+ return;
188
+ }
189
+ // confirm 단계에서 기존 파일 발견했었다면 overwrite=true로 명시 승인 전달
190
+ const overwrite =
191
+ applyState.phase === "confirm" && applyState.existing.length > 0;
192
+ setApplyState({ phase: "applying" });
193
+ const hue = answers.hue ?? 210;
194
+ // 1) 브라우저 미리보기 반영
195
+ applyPreset({
196
+ mode: "light",
197
+ palette: paletteChoice.id,
198
+ hue,
199
+ shape: vibe.values.shape,
200
+ density: density.id,
201
+ motion: vibe.values.motion,
202
+ iconFamily: "phosphor",
203
+ lang: "ko",
204
+ });
205
+ // 2) 파일 쓰기 (server action)
206
+ const result = await applyOnboarding(
207
+ {
208
+ theme: answers.theme,
209
+ mode: "light",
210
+ palette: paletteChoice.id,
211
+ hue,
212
+ accentHue: null,
213
+ shape: vibe.values.shape,
214
+ density: density.id,
215
+ motion: vibe.values.motion,
216
+ iconFamily: "phosphor",
217
+ voice,
218
+ lang: "ko",
219
+ focusRing,
220
+ appliedAt: new Date().toISOString(),
221
+ },
222
+ { overwrite }
223
+ );
224
+ if (result.ok) {
225
+ setApplyState({ phase: "done", files: result.files });
226
+ } else {
227
+ setApplyState({
228
+ phase: "error",
229
+ message: result.error,
230
+ files: result.files,
231
+ });
232
+ }
233
+ };
234
+
235
+ // 페이지 진입 시 default answers를 축에 즉시 반영 (첫 렌더 톤 통일).
236
+ useEffect(() => {
237
+ if (answers.vibe) applyVibe(answers.vibe);
238
+ if (answers.palette) setPalette(answers.palette);
239
+ if (answers.hue != null) setHue(answers.hue);
240
+ if (answers.density) setDensity(answers.density);
241
+ // eslint-disable-next-line react-hooks/exhaustive-deps
242
+ }, []);
243
+
244
+ const step = STEPS[stepIdx];
245
+ const canGoNext = useMemo(() => {
246
+ if (step.key === "theme") return answers.theme != null;
247
+ if (step.key === "vibe") return answers.vibe != null;
248
+ if (step.key === "color") return answers.palette != null;
249
+ if (step.key === "density") return answers.density != null;
250
+ if (step.key === "layout") return answers.layout != null;
251
+ return true;
252
+ }, [step.key, answers]);
253
+
254
+ if (mode === "free") {
255
+ return <FreeMode onBack={() => setMode("wizard")} />;
256
+ }
257
+
258
+ return (
259
+ <div className={styles.shell} data-palette="standard">
260
+ <Rail
261
+ stepIdx={stepIdx}
262
+ answers={answers}
263
+ onStepClick={(idx) => {
264
+ if (idx <= stepIdx || isStepReachable(idx, answers)) setStepIdx(idx);
265
+ }}
266
+ onFreeMode={() => setMode("free")}
267
+ />
268
+
269
+ <div className={styles.content}>
270
+ <div className={styles.contentInner}>
271
+ <header className={styles.stepHeader}>
272
+ <span className={styles.stepProgress}>
273
+ {stepIdx + 1} / {STEPS.length}
274
+ </span>
275
+ <h1 className={styles.stepTitle}>{stepTitle(step.key)}</h1>
276
+ <p className={styles.stepLead}>{stepLead(step.key)}</p>
277
+ </header>
278
+
279
+ <div className={styles.stepBody}>
280
+ {step.key === "theme" && (
281
+ <ThemeStep value={answers.theme} onChange={applyTheme} />
282
+ )}
283
+ {step.key === "vibe" && (
284
+ <VibeStep value={answers.vibe} onChange={applyVibe} />
285
+ )}
286
+ {step.key === "color" && (
287
+ <ColorStep
288
+ palette={answers.palette}
289
+ hue={answers.hue ?? 210}
290
+ hueSource={answers.hueSource}
291
+ brandHex={answers.brandHex}
292
+ onPalette={applyPalette}
293
+ onHueChange={applyHue}
294
+ onBrandHex={applyBrandHex}
295
+ />
296
+ )}
297
+ {step.key === "density" && (
298
+ <DensityStep value={answers.density} onChange={applyDensity} />
299
+ )}
300
+ {step.key === "layout" && (
301
+ <LayoutStep
302
+ value={answers.layout}
303
+ onChange={(v) => setAnswers((a) => ({ ...a, layout: v }))}
304
+ />
305
+ )}
306
+ {step.key === "summary" && <SummaryStep answers={answers} />}
307
+ </div>
308
+
309
+ <footer className={styles.stepFoot}>
310
+ <div className={styles.stepFootActions}>
311
+ <Button
312
+ variant="secondary"
313
+ size="lg"
314
+ disabled={stepIdx === 0}
315
+ onClick={() => setStepIdx((i) => Math.max(0, i - 1))}
316
+ >
317
+ Back
318
+ </Button>
319
+ <Button
320
+ variant="primary"
321
+ size="lg"
322
+ disabled={!canGoNext}
323
+ onClick={() => {
324
+ if (stepIdx === STEPS.length - 1) {
325
+ openApplyConfirm();
326
+ return;
327
+ }
328
+ setStepIdx((i) => nextStepIdx(i, answers));
329
+ }}
330
+ rightIcon={
331
+ stepIdx === STEPS.length - 1 ? undefined : <Icon name="arrow-right" />
332
+ }
333
+ >
334
+ {stepIdx === STEPS.length - 1 ? "이 설정 적용" : "Next"}
335
+ </Button>
336
+ </div>
337
+ </footer>
338
+ </div>
339
+ </div>
340
+
341
+ {applyState.phase !== "idle" && (
342
+ <ApplyDialog
343
+ state={applyState}
344
+ onCancel={() => setApplyState({ phase: "idle" })}
345
+ onConfirm={runApply}
346
+ />
347
+ )}
348
+ </div>
349
+ );
350
+ }
351
+
352
+ // ────────────────────────────────────────────────────────
353
+ // Apply dialog — confirm / applying / done / error
354
+ // ────────────────────────────────────────────────────────
355
+
356
+ type ApplyState =
357
+ | { phase: "confirm"; existing: string[] }
358
+ | { phase: "applying" }
359
+ | { phase: "done"; files: string[] }
360
+ | { phase: "error"; message: string; files: string[] };
361
+
362
+ function ApplyDialog({
363
+ state,
364
+ onCancel,
365
+ onConfirm,
366
+ }: {
367
+ state: ApplyState;
368
+ onCancel: () => void;
369
+ onConfirm: () => void;
370
+ }) {
371
+ return (
372
+ <div className={styles.applyBackdrop} onClick={state.phase === "applying" ? undefined : onCancel}>
373
+ <div
374
+ className={styles.applyModal}
375
+ onClick={(e) => e.stopPropagation()}
376
+ role="dialog"
377
+ aria-modal="true"
378
+ >
379
+ {state.phase === "confirm" && (
380
+ <>
381
+ <h2 className={styles.applyTitle}>
382
+ {state.existing.length > 0
383
+ ? "기존 설정을 덮어쓸까요?"
384
+ : "이 설정을 프로젝트에 적용할까요?"}
385
+ </h2>
386
+ {state.existing.length > 0 ? (
387
+ <p className={styles.applyLead}>
388
+ 이미 다음 파일이 존재합니다. 적용하면 <strong>덮어쓰여집니다</strong>.
389
+ 커밋되지 않은 변경이 있는지 확인하세요.
390
+ </p>
391
+ ) : (
392
+ <p className={styles.applyLead}>
393
+ 프로젝트 루트에 다음 두 파일이 생성됩니다. 모두 로컬 파일 작업이고 되돌릴 수 있어요.
394
+ </p>
395
+ )}
396
+ <ul className={styles.applyFileList}>
397
+ <li>
398
+ <code>ctds.config.json</code>
399
+ <span className={styles.applyFileHint}>
400
+ {state.existing.includes("ctds.config.json")
401
+ ? "기존 파일 덮어씀 · 축 값 JSON"
402
+ : "축 값 JSON (AI 에이전트·ThemeController가 참조)"}
403
+ </span>
404
+ </li>
405
+ <li>
406
+ <code>DESIGN.md</code>
407
+ <span className={styles.applyFileHint}>
408
+ {state.existing.includes("DESIGN.md")
409
+ ? "기존 파일 덮어씀 · 테마·축 산문 요약"
410
+ : "테마·축 산문 요약"}
411
+ </span>
412
+ </li>
413
+ </ul>
414
+ <div className={styles.applyActions}>
415
+ <Button variant="secondary" size="md" onClick={onCancel}>
416
+ 취소
417
+ </Button>
418
+ <Button variant="primary" size="md" onClick={onConfirm}>
419
+ {state.existing.length > 0 ? "덮어쓰고 적용" : "적용"}
420
+ </Button>
421
+ </div>
422
+ </>
423
+ )}
424
+ {state.phase === "applying" && (
425
+ <>
426
+ <h2 className={styles.applyTitle}>적용 중…</h2>
427
+ <p className={styles.applyLead}>파일을 쓰는 중입니다.</p>
428
+ </>
429
+ )}
430
+ {state.phase === "done" && (
431
+ <>
432
+ <h2 className={styles.applyTitle}>적용 완료</h2>
433
+ <p className={styles.applyLead}>다음 파일이 생성/수정됐어요.</p>
434
+ <ul className={styles.applyFileList}>
435
+ {state.files.map((f) => (
436
+ <li key={f}>
437
+ <code>{f}</code>
438
+ </li>
439
+ ))}
440
+ </ul>
441
+ <p className={styles.applyLead}>
442
+ 이제 Claude Code 등 AI 에이전트가 <code>ctds.config.json</code>·<code>DESIGN.md</code>를 참조해
443
+ 이 프로젝트의 축 값에 맞춰 코드를 생성할 수 있습니다.
444
+ </p>
445
+ <div className={styles.applyActions}>
446
+ <Button variant="primary" size="md" onClick={onCancel}>
447
+ 닫기
448
+ </Button>
449
+ </div>
450
+ </>
451
+ )}
452
+ {state.phase === "error" && (
453
+ <>
454
+ <h2 className={styles.applyTitle}>적용 실패</h2>
455
+ <p className={styles.applyLead}>
456
+ 파일 쓰기 중 오류가 발생했어요. 미리보기(브라우저 톤)는 적용됐지만 파일은 저장되지 않았습니다.
457
+ </p>
458
+ <pre className={styles.applyError}>{state.message}</pre>
459
+ {state.files.length > 0 && (
460
+ <>
461
+ <p className={styles.applyLead}>그 전에 성공한 파일:</p>
462
+ <ul className={styles.applyFileList}>
463
+ {state.files.map((f) => (
464
+ <li key={f}>
465
+ <code>{f}</code>
466
+ </li>
467
+ ))}
468
+ </ul>
469
+ </>
470
+ )}
471
+ <div className={styles.applyActions}>
472
+ <Button variant="secondary" size="md" onClick={onCancel}>
473
+ 닫기
474
+ </Button>
475
+ </div>
476
+ </>
477
+ )}
478
+ </div>
479
+ </div>
480
+ );
481
+ }
482
+
483
+ // ────────────────────────────────────────────────────────
484
+
485
+ function stepTitle(k: StepKey): string {
486
+ if (k === "theme") return "디자인 스타일을 적용할까요?";
487
+ if (k === "vibe") return "이 프로덕트는 어떤 톤인가요?";
488
+ if (k === "color") return "컬러는 어떻게 갈까요?";
489
+ if (k === "density") return "화면에 정보가 얼마나 많나요?";
490
+ if (k === "layout") return "어떤 레이아웃이 어울릴까요?";
491
+ return "이렇게 설정할게요";
492
+ }
493
+
494
+ function stepLead(k: StepKey): string {
495
+ if (k === "theme")
496
+ return "스타일을 고르면 축 디폴트와 DESIGN.md가 함께 적용돼요. 축은 뒤에서 자유롭게 바꿀 수 있어요.";
497
+ if (k === "vibe") return "가장 가까운 하나만 골라주세요. Shape · Motion · Icon Weight가 세트로 결정돼요.";
498
+ if (k === "color")
499
+ return "먼저 팔레트를 고르고, 그 다음 브랜드 색을 슬라이더로 조정하거나 hex로 직접 넣어주세요.";
500
+ if (k === "density") return "한 화면에 담을 정보량. Density 축이 결정돼요.";
501
+ if (k === "layout") return "쓰임새에 맞는 사이드바·탑바 조합.";
502
+ return "선택을 확인하고 프로젝트에 적용하세요. 언제든 자유 조정으로 세부 축을 바꿀 수 있어요.";
503
+ }
504
+
505
+ /**
506
+ * step 도달 가능 여부. STEPS 순서가 바뀌어도 안전하도록 key 기반으로 판정.
507
+ * 각 step에 필요한 answers 필드를 매핑.
508
+ */
509
+ const STEP_REQUIRES: Record<StepKey, (keyof Answers)[]> = {
510
+ theme: [],
511
+ vibe: ["theme"],
512
+ density: ["theme", "vibe"],
513
+ color: ["theme", "vibe", "density"],
514
+ layout: ["theme", "vibe", "density", "palette"],
515
+ summary: ["theme", "vibe", "density", "palette", "layout"],
516
+ };
517
+
518
+ function isStepReachable(idx: number, a: Answers): boolean {
519
+ const step = STEPS[idx];
520
+ if (!step) return false;
521
+ return STEP_REQUIRES[step.key].every((k) => a[k] != null);
522
+ }
523
+
524
+ /**
525
+ * 다음 step 인덱스 계산. 특수 룰:
526
+ * - theme 단계에서 none이 아닌 테마를 고르면 vibe 단계는 건너뜀 (테마가 vibe 축을 커버).
527
+ * 그 외엔 선형 +1.
528
+ */
529
+ function nextStepIdx(current: number, a: Answers): number {
530
+ const curr = STEPS[current]?.key;
531
+ if (curr === "theme" && a.theme && a.theme !== "none") {
532
+ const colorIdx = STEPS.findIndex((s) => s.key === "color");
533
+ if (colorIdx > current) return colorIdx;
534
+ }
535
+ return Math.min(STEPS.length - 1, current + 1);
536
+ }
537
+
538
+ // ────────────────────────────────────────────────────────
539
+
540
+ function Rail({
541
+ stepIdx,
542
+ answers,
543
+ onStepClick,
544
+ onFreeMode,
545
+ }: {
546
+ stepIdx: number;
547
+ answers: Answers;
548
+ onStepClick: (i: number) => void;
549
+ onFreeMode: () => void;
550
+ }) {
551
+ return (
552
+ <aside className={styles.rail}>
553
+ <div className={styles.railBrand} aria-hidden>
554
+ <Icon name="palette" size={18} />
555
+ </div>
556
+
557
+ <Stepper
558
+ className={styles.railSteps}
559
+ activeIndex={stepIdx}
560
+ onStepClick={onStepClick}
561
+ >
562
+ {STEPS.map((s, i) => (
563
+ <Stepper.Item
564
+ key={s.key}
565
+ title={s.title}
566
+ hint={s.hint}
567
+ disabled={i > stepIdx && !isStepReachable(i, answers)}
568
+ />
569
+ ))}
570
+ </Stepper>
571
+
572
+ <div className={styles.railFoot}>
573
+ <span>CTDS 온보딩</span>
574
+ <button
575
+ type="button"
576
+ className={styles.railFootBtn}
577
+ onClick={onFreeMode}
578
+ >
579
+ 자유 조정
580
+ <Icon name="arrow-right" size={12} />
581
+ </button>
582
+ </div>
583
+ </aside>
584
+ );
585
+ }
586
+
587
+ // ────────────────────────────────────────────────────────
588
+
589
+ /**
590
+ * 각 테마 미리보기 이미지. null이면 placeholder 블록 표시.
591
+ * 이미지는 web/public/onboarding/<slug>.png 에 둔다.
592
+ */
593
+ const THEME_PREVIEW_IMG: Record<ThemeId, string | null> = {
594
+ none: null,
595
+ pure: "/onboarding/pure.png",
596
+ spare: "/onboarding/spare.png",
597
+ expressive: "/onboarding/expressive.png",
598
+ };
599
+
600
+ function ThemeStep({
601
+ value,
602
+ onChange,
603
+ }: {
604
+ value: ThemeId | null;
605
+ onChange: (v: ThemeId) => void;
606
+ }) {
607
+ const [hovered, setHovered] = useState<ThemeId | null>(null);
608
+ const [cursor, setCursor] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
609
+
610
+ const handleMove = (e: React.MouseEvent) => {
611
+ setCursor({ x: e.clientX, y: e.clientY });
612
+ };
613
+
614
+ // "none"은 사진이 어차피 없으니 호버 미리보기 생략.
615
+ const showPreview = hovered !== null && hovered !== "none";
616
+ const previewSrc = hovered ? THEME_PREVIEW_IMG[hovered] : null;
617
+
618
+ // 커서 오른쪽 아래로 offset. 화면 밖 넘치면 왼쪽/위로 뒤집기.
619
+ const OFFSET_X = 20;
620
+ const OFFSET_Y = 20;
621
+ const PREVIEW_W = 384; // CSS와 동기 (space-24 * 4 = 96 * 4)
622
+ const PREVIEW_H = 288; // CSS와 동기 (space-24 * 3)
623
+ let left = cursor.x + OFFSET_X;
624
+ let top = cursor.y + OFFSET_Y;
625
+ if (typeof window !== "undefined") {
626
+ if (left + PREVIEW_W > window.innerWidth) left = cursor.x - OFFSET_X - PREVIEW_W;
627
+ if (top + PREVIEW_H > window.innerHeight) top = cursor.y - OFFSET_Y - PREVIEW_H;
628
+ }
629
+
630
+ return (
631
+ <>
632
+ <div className={styles.choiceList}>
633
+ {THEME_CHOICES.map((c) => (
634
+ <div
635
+ key={c.id}
636
+ onMouseEnter={() => setHovered(c.id)}
637
+ onMouseLeave={() => setHovered((prev) => (prev === c.id ? null : prev))}
638
+ onMouseMove={handleMove}
639
+ >
640
+ <SelectableCard
641
+ mode="radio"
642
+ name="onb-theme"
643
+ value={c.id}
644
+ checked={value === c.id}
645
+ onChange={() => onChange(c.id)}
646
+ title={
647
+ c.badge ? (
648
+ <span className={styles.themeTitleRow}>
649
+ <Badge size="sm" variant="soft" status={c.badgeStatus}>
650
+ {c.badge}
651
+ </Badge>
652
+ <span>{c.title}</span>
653
+ </span>
654
+ ) : (
655
+ c.title
656
+ )
657
+ }
658
+ description={c.hint}
659
+ />
660
+ </div>
661
+ ))}
662
+ </div>
663
+
664
+ <div
665
+ className={styles.themeHoverLayer}
666
+ data-visible={showPreview || undefined}
667
+ style={{ transform: `translate(${left}px, ${top}px)` }}
668
+ aria-hidden
669
+ >
670
+ {previewSrc ? (
671
+ // eslint-disable-next-line @next/next/no-img-element
672
+ <img src={previewSrc} alt="" className={styles.themeHoverImg} />
673
+ ) : (
674
+ <div className={styles.themeHoverPlaceholder}>
675
+ <span className={styles.themeHoverPlaceholderLabel}>
676
+ {hovered ?? ""}
677
+ </span>
678
+ <span className={styles.themeHoverPlaceholderHint}>
679
+ preview image TBD
680
+ </span>
681
+ </div>
682
+ )}
683
+ </div>
684
+ </>
685
+ );
686
+ }
687
+
688
+ function VibeStep({
689
+ value,
690
+ onChange,
691
+ }: {
692
+ value: VibeId | null;
693
+ onChange: (v: VibeId) => void;
694
+ }) {
695
+ return (
696
+ <div className={styles.choiceList}>
697
+ {VIBE_CHOICES.map((c) => (
698
+ <SelectableCard
699
+ key={c.id}
700
+ mode="radio"
701
+ name="onb-vibe"
702
+ value={c.id}
703
+ checked={value === c.id}
704
+ onChange={() => onChange(c.id)}
705
+ title={c.title}
706
+ description={c.hint}
707
+ />
708
+ ))}
709
+ </div>
710
+ );
711
+ }
712
+
713
+ function ColorStep({
714
+ palette,
715
+ hue,
716
+ hueSource,
717
+ brandHex,
718
+ onPalette,
719
+ onHueChange,
720
+ onBrandHex,
721
+ }: {
722
+ palette: PaletteValue | null;
723
+ hue: number;
724
+ hueSource: "preset" | "hex" | null;
725
+ brandHex: string;
726
+ onPalette: (v: PaletteValue) => void;
727
+ onHueChange: (v: number) => void;
728
+ onBrandHex: (hex: string) => void;
729
+ }) {
730
+ // hex가 유효하게 들어와 있으면 상단 컨트롤 비활성, 입력 섹션은 펼침.
731
+ const hexLocked = hueSource === "hex" && brandHex.trim().length > 0;
732
+ const [hexOpen, setHexOpen] = useState(hexLocked);
733
+ useEffect(() => {
734
+ if (hexLocked) setHexOpen(true);
735
+ }, [hexLocked]);
736
+
737
+ const hexInvalid =
738
+ brandHex.trim().length > 0 &&
739
+ (!isValidHex(brandHex.trim()) || hexToOklch(brandHex.trim()) == null);
740
+
741
+ const activePalette = palettePresets.find((p) => p.id === palette) ?? palettePresets[1];
742
+ const swatchColor = `oklch(${activePalette.lightness} ${activePalette.chroma} ${hue + activePalette.hueShift})`;
743
+ const activeHint = PALETTE_CHOICES.find((c) => c.id === palette)?.hint;
744
+
745
+ return (
746
+ <div className={styles.colorStep}>
747
+ <section className={styles.colorSection}>
748
+ <div className={styles.colorSectionHead}>
749
+ <span className={styles.colorSectionTitle}>팔레트 선택</span>
750
+ {activeHint && <span className={styles.colorSectionHint}>{activeHint}</span>}
751
+ </div>
752
+ <div className={styles.paletteRow} role="radiogroup" aria-label="팔레트 선택">
753
+ {PALETTE_CHOICES.map((c) => {
754
+ const preset = palettePresets.find((p) => p.id === c.id);
755
+ return (
756
+ <FilterButton
757
+ key={c.id}
758
+ size="md"
759
+ selected={palette === c.id}
760
+ disabled={hexLocked}
761
+ onClick={() => onPalette(c.id)}
762
+ >
763
+ {preset?.label ?? c.id}
764
+ </FilterButton>
765
+ );
766
+ })}
767
+ </div>
768
+ </section>
769
+
770
+ <section className={styles.colorSection}>
771
+ <span className={styles.colorSectionTitle}>브랜드 색</span>
772
+
773
+ <div className={styles.hueSliderBlock} data-locked={hexLocked || undefined}>
774
+ <span
775
+ className={styles.hueSliderSwatch}
776
+ style={{ background: swatchColor }}
777
+ aria-hidden
778
+ />
779
+ <Slider
780
+ min={0}
781
+ max={360}
782
+ value={hue}
783
+ onValueChange={onHueChange}
784
+ disabled={hexLocked}
785
+ aria-label="hue"
786
+ />
787
+ <span className={styles.hueSliderValue}>{hue}°</span>
788
+ </div>
789
+
790
+ <div className={styles.hexDisclosure}>
791
+ <button
792
+ type="button"
793
+ className={styles.hexToggle}
794
+ onClick={() => setHexOpen((v) => !v)}
795
+ aria-expanded={hexOpen}
796
+ >
797
+ <Icon name={hexOpen ? "caret-down" : "caret-right"} size={12} />
798
+ <span>브랜드 hex 직접 입력</span>
799
+ <span className={styles.hexOptionalTag}>선택</span>
800
+ </button>
801
+
802
+ {hexOpen && (
803
+ <div className={styles.hexBlock}>
804
+ <Input
805
+ size="md"
806
+ width="fill"
807
+ placeholder="#3B82F6"
808
+ value={brandHex}
809
+ onChange={(e) => onBrandHex(e.target.value)}
810
+ invalid={hexInvalid}
811
+ prefix={
812
+ <span
813
+ className={styles.hexBigSwatch}
814
+ style={{
815
+ background:
816
+ brandHex && !hexInvalid
817
+ ? brandHex.startsWith("#")
818
+ ? brandHex
819
+ : `#${brandHex}`
820
+ : "transparent",
821
+ }}
822
+ aria-hidden
823
+ />
824
+ }
825
+ />
826
+ <span className={styles.hexHelp}>
827
+ {hexLocked
828
+ ? `${activePalette.label} 톤으로 매칭돼요`
829
+ : hexInvalid
830
+ ? "hex 형식이 올바르지 않아요"
831
+ : "입력한 색을 그대로 재현해요"}
832
+ </span>
833
+ </div>
834
+ )}
835
+ </div>
836
+ </section>
837
+ </div>
838
+ );
839
+ }
840
+
841
+ function DensityStep({
842
+ value,
843
+ onChange,
844
+ }: {
845
+ value: DensityChoiceId | null;
846
+ onChange: (v: DensityChoiceId) => void;
847
+ }) {
848
+ return (
849
+ <div className={styles.choiceList}>
850
+ {DENSITY_CHOICES.map((c) => (
851
+ <SelectableCard
852
+ key={c.id}
853
+ mode="radio"
854
+ name="onb-density"
855
+ value={c.id}
856
+ checked={value === c.id}
857
+ onChange={() => onChange(c.id)}
858
+ title={c.title}
859
+ description={c.hint}
860
+ />
861
+ ))}
862
+ </div>
863
+ );
864
+ }
865
+
866
+ function LayoutStep({
867
+ value,
868
+ onChange,
869
+ }: {
870
+ value: LayoutId | null;
871
+ onChange: (v: LayoutId) => void;
872
+ }) {
873
+ const [hovered, setHovered] = useState<LayoutId | null>(null);
874
+ const [cursor, setCursor] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
875
+
876
+ const handleMove = (e: React.MouseEvent) => {
877
+ setCursor({ x: e.clientX, y: e.clientY });
878
+ };
879
+
880
+ const hoveredChoice = hovered ? LAYOUT_CHOICES.find((c) => c.id === hovered) : null;
881
+
882
+ // ThemeStep과 동일한 커서 추적 로직.
883
+ const OFFSET_X = 20;
884
+ const OFFSET_Y = 20;
885
+ const PREVIEW_W = 384;
886
+ const PREVIEW_H = 256;
887
+ let left = cursor.x + OFFSET_X;
888
+ let top = cursor.y + OFFSET_Y;
889
+ if (typeof window !== "undefined") {
890
+ if (left + PREVIEW_W > window.innerWidth) left = cursor.x - OFFSET_X - PREVIEW_W;
891
+ if (top + PREVIEW_H > window.innerHeight) top = cursor.y - OFFSET_Y - PREVIEW_H;
892
+ }
893
+
894
+ return (
895
+ <>
896
+ <div className={styles.choiceList}>
897
+ {LAYOUT_CHOICES.map((c) => (
898
+ <div
899
+ key={c.id}
900
+ onMouseEnter={() => setHovered(c.id)}
901
+ onMouseLeave={() => setHovered((prev) => (prev === c.id ? null : prev))}
902
+ onMouseMove={handleMove}
903
+ >
904
+ <SelectableCard
905
+ mode="radio"
906
+ name="onb-layout"
907
+ value={c.id}
908
+ checked={value === c.id}
909
+ onChange={() => onChange(c.id)}
910
+ title={c.title}
911
+ description={c.hint}
912
+ />
913
+ </div>
914
+ ))}
915
+ </div>
916
+
917
+ <div
918
+ className={styles.themeHoverLayer}
919
+ data-visible={hoveredChoice ? true : undefined}
920
+ style={{ transform: `translate(${left}px, ${top}px)` }}
921
+ aria-hidden
922
+ >
923
+ {hoveredChoice && <LayoutWireframe sidebar={hoveredChoice.sidebar} topbar={hoveredChoice.topbar} />}
924
+ </div>
925
+ </>
926
+ );
927
+ }
928
+
929
+ /**
930
+ * 레이아웃 호버 미리보기 와이어프레임.
931
+ * 핵심 구조 — 탑바는 사이드바 "위"가 아니라 사이드바 "옆" (콘텐츠 영역 위에만).
932
+ * 즉 전체 레이아웃은 [좌사이드바 | (탑바 + 콘텐츠 세로)] 식 가로 분할.
933
+ */
934
+ function LayoutWireframe({
935
+ sidebar,
936
+ topbar,
937
+ }: {
938
+ sidebar: "left" | "right" | "dual" | "none";
939
+ topbar: boolean;
940
+ }) {
941
+ return (
942
+ <div className={styles.layoutWire}>
943
+ {(sidebar === "left" || sidebar === "dual") && <div className={styles.layoutWireSidebar} />}
944
+ <div className={styles.layoutWireMain}>
945
+ {topbar && <div className={styles.layoutWireTopbar} />}
946
+ <div className={styles.layoutWireContent} />
947
+ </div>
948
+ {(sidebar === "right" || sidebar === "dual") && <div className={styles.layoutWireSidebar} />}
949
+ </div>
950
+ );
951
+ }
952
+
953
+ // ────────────────────────────────────────────────────────
954
+
955
+ function SummaryStep({ answers }: { answers: Answers }) {
956
+ const [advancedOpen, setAdvancedOpen] = useState(false);
957
+
958
+ const theme = THEME_CHOICES.find((c) => c.id === answers.theme);
959
+ const vibe = VIBE_CHOICES.find((c) => c.id === answers.vibe);
960
+ const paletteChoice = PALETTE_CHOICES.find((c) => c.id === answers.palette);
961
+ const density = DENSITY_CHOICES.find((c) => c.id === answers.density);
962
+ const layout = LAYOUT_CHOICES.find((c) => c.id === answers.layout);
963
+
964
+ const hue = answers.hue ?? 210;
965
+
966
+ return (
967
+ <>
968
+ <div className={styles.summaryGrid}>
969
+ <SummaryCard
970
+ label="디자인 스타일"
971
+ value={theme?.title ?? "—"}
972
+ preview={
973
+ theme?.badge ? (
974
+ <Badge size="sm" variant="soft" status={theme.badgeStatus}>
975
+ {theme.badge}
976
+ </Badge>
977
+ ) : (
978
+ <span className={styles.summaryPreviewMuted}>Custom</span>
979
+ )
980
+ }
981
+ />
982
+ <SummaryCard
983
+ label="분위기"
984
+ value={vibe?.title ?? "—"}
985
+ preview={
986
+ vibe && (
987
+ <div className={styles.vibePreview} data-summary>
988
+ <span
989
+ className={styles.vibePreviewBtn}
990
+ data-shape={vibe.values.shape}
991
+ aria-hidden
992
+ />
993
+ <span
994
+ className={styles.vibePreviewChip}
995
+ data-shape={vibe.values.shape}
996
+ data-motion={vibe.values.motion}
997
+ aria-hidden
998
+ />
999
+ <span
1000
+ className={styles.vibePreviewChip}
1001
+ data-shape={vibe.values.shape}
1002
+ data-motion={vibe.values.motion}
1003
+ data-variant="ghost"
1004
+ aria-hidden
1005
+ />
1006
+ </div>
1007
+ )
1008
+ }
1009
+ />
1010
+ <SummaryCard
1011
+ label="컬러 & 톤"
1012
+ value={paletteChoice?.title ?? "—"}
1013
+ preview={
1014
+ <div className={styles.colorPreview} aria-hidden>
1015
+ <span
1016
+ className={styles.colorPreviewSwatch}
1017
+ style={{ background: `oklch(0.55 0.14 ${hue})` }}
1018
+ />
1019
+ <span
1020
+ className={styles.colorPreviewSwatch}
1021
+ data-tone="soft"
1022
+ style={{ background: `oklch(0.78 0.08 ${hue})` }}
1023
+ />
1024
+ <span
1025
+ className={styles.colorPreviewSwatch}
1026
+ data-tone="deep"
1027
+ style={{ background: `oklch(0.38 0.10 ${hue})` }}
1028
+ />
1029
+ </div>
1030
+ }
1031
+ />
1032
+ <SummaryCard
1033
+ label="정보 밀도"
1034
+ value={density?.title ?? "—"}
1035
+ preview={
1036
+ density && (
1037
+ <div
1038
+ className={styles.densityPreview}
1039
+ data-density={density.id}
1040
+ aria-hidden
1041
+ >
1042
+ <span />
1043
+ <span />
1044
+ <span />
1045
+ <span />
1046
+ </div>
1047
+ )
1048
+ }
1049
+ />
1050
+ <SummaryCard
1051
+ label="레이아웃"
1052
+ value={layout?.title ?? "—"}
1053
+ preview={
1054
+ layout && (
1055
+ <div
1056
+ className={styles.layoutPreview}
1057
+ data-sidebar={layout.sidebar}
1058
+ data-topbar={layout.topbar ? "on" : "off"}
1059
+ aria-hidden
1060
+ >
1061
+ {(layout.sidebar === "left" || layout.sidebar === "dual") && (
1062
+ <span className={styles.layoutPreviewRail} />
1063
+ )}
1064
+ <span className={styles.layoutPreviewMain}>
1065
+ {layout.topbar && <span className={styles.layoutPreviewBar} />}
1066
+ <span className={styles.layoutPreviewContent} />
1067
+ </span>
1068
+ {(layout.sidebar === "right" || layout.sidebar === "dual") && (
1069
+ <span className={styles.layoutPreviewRail} />
1070
+ )}
1071
+ </div>
1072
+ )
1073
+ }
1074
+ />
1075
+ </div>
1076
+
1077
+ <p className={styles.summaryHint}>
1078
+ 한 번 적용한 뒤 축을 바꾸면 이미 쌓인 컴포넌트·레이아웃이 자동 보정되지 않아요. 지금 신중히 결정해주세요.
1079
+ </p>
1080
+
1081
+ <div className={styles.summaryAdvanced}>
1082
+ <button
1083
+ type="button"
1084
+ className={styles.summaryAdvancedToggle}
1085
+ onClick={() => setAdvancedOpen((v) => !v)}
1086
+ aria-expanded={advancedOpen}
1087
+ >
1088
+ <span>고급 설정</span>
1089
+ <Icon name={advancedOpen ? "caret-down" : "caret-right"} size={16} />
1090
+
1091
+ </button>
1092
+ {advancedOpen && (
1093
+ <div className={styles.summaryAdvancedBody}>
1094
+ <OnboardingAxisPanel variant="wide" themeId={answers.theme} />
1095
+ </div>
1096
+ )}
1097
+ </div>
1098
+
1099
+ </>
1100
+ );
1101
+ }
1102
+
1103
+ function SummaryCard({
1104
+ label,
1105
+ value,
1106
+ preview,
1107
+ }: {
1108
+ label: string;
1109
+ value: string;
1110
+ preview?: React.ReactNode;
1111
+ }) {
1112
+ return (
1113
+ <div className={styles.summaryCard}>
1114
+ <div className={styles.summaryCardPreview}>{preview}</div>
1115
+ <div className={styles.summaryCardText}>
1116
+ <span className={styles.summaryLabel}>{label}</span>
1117
+ <span className={styles.summaryValue}>{value}</span>
1118
+ </div>
1119
+ </div>
1120
+ );
1121
+ }
1122
+
1123
+ // ────────────────────────────────────────────────────────
1124
+ // Free mode: 위저드 밖 자유 조정 (기존 갤러리 뷰 재사용)
1125
+ // ────────────────────────────────────────────────────────
1126
+
1127
+ function FreeMode({ onBack }: { onBack: () => void }) {
1128
+ const [tab, setTab] = useState<"presets" | "layout" | "docs">("presets");
1129
+
1130
+ return (
1131
+ <div className={styles.freeShell} data-palette="standard">
1132
+ <div className={styles.freeAxis}>
1133
+ <div className={styles.freeAxisScroll}>
1134
+ <div className={styles.freeAxisInner}>
1135
+ <OnboardingAxisPanel />
1136
+ </div>
1137
+ </div>
1138
+ </div>
1139
+ <div className={styles.freeContent}>
1140
+ <div className={styles.freeContentInner}>
1141
+ <div className={styles.freeHeader}>
1142
+ <div>
1143
+ <h1 className={styles.freeTitle}>자유 조정</h1>
1144
+ <p className={styles.freeLead}>
1145
+ 좌측 축을 직접 조작하거나 아래에서 프리셋·레이아웃·문서를 골라 보세요.
1146
+ </p>
1147
+ </div>
1148
+ <Button variant="ghost" size="sm" onClick={onBack}>
1149
+ ← 위저드로
1150
+ </Button>
1151
+ </div>
1152
+
1153
+ <FreeTabs value={tab} onChange={setTab} />
1154
+
1155
+ {tab === "presets" && <PresetsGallery />}
1156
+ {tab === "layout" && <LayoutBuilder />}
1157
+ {tab === "docs" && <DocsIndex />}
1158
+ </div>
1159
+ </div>
1160
+ </div>
1161
+ );
1162
+ }
1163
+
1164
+ function FreeTabs({
1165
+ value,
1166
+ onChange,
1167
+ }: {
1168
+ value: "presets" | "layout" | "docs";
1169
+ onChange: (v: "presets" | "layout" | "docs") => void;
1170
+ }) {
1171
+ const tabs: { id: "presets" | "layout" | "docs"; label: string }[] = [
1172
+ { id: "presets", label: "프리셋" },
1173
+ { id: "layout", label: "레이아웃" },
1174
+ { id: "docs", label: "컴포넌트 문서" },
1175
+ ];
1176
+ return (
1177
+ <div className={styles.segment} role="tablist">
1178
+ {tabs.map((t) => (
1179
+ <button
1180
+ key={t.id}
1181
+ type="button"
1182
+ className={styles.segmentItem}
1183
+ data-active={value === t.id || undefined}
1184
+ onClick={() => onChange(t.id)}
1185
+ role="tab"
1186
+ aria-selected={value === t.id}
1187
+ >
1188
+ {t.label}
1189
+ </button>
1190
+ ))}
1191
+ </div>
1192
+ );
1193
+ }