@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,426 @@
1
+ "use client";
2
+
3
+ import {
4
+ forwardRef,
5
+ useCallback,
6
+ useEffect,
7
+ useId,
8
+ useLayoutEffect,
9
+ useRef,
10
+ useState,
11
+ type ChangeEvent,
12
+ type KeyboardEvent,
13
+ type PointerEvent as ReactPointerEvent,
14
+ type ReactNode,
15
+ type TextareaHTMLAttributes,
16
+ } from "react";
17
+
18
+ import { Icon } from "../Icon";
19
+ import styles from "./Textarea.module.css";
20
+
21
+ export type TextareaSize = "sm" | "md" | "lg";
22
+ export type TextareaVariant = "outline" | "filled" | "line";
23
+ /**
24
+ * 가로 폭 정책 — Input과 동일.
25
+ * "fill"(기본) 부모 폭 채움 · "md"=280px · "lg"=400px · number=px.
26
+ */
27
+ export type TextareaWidth = "fill" | "md" | "lg" | number;
28
+
29
+ export interface TextareaProps
30
+ extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "size" | "width"> {
31
+ size?: TextareaSize;
32
+ variant?: TextareaVariant;
33
+ /** 폼 검증 실패. warning이 있으면 자동으로 true. */
34
+ invalid?: boolean;
35
+ /** 가로 폭. 기본 "fill". */
36
+ width?: TextareaWidth;
37
+ /** 라벨 텍스트. */
38
+ label?: ReactNode;
39
+ /** 중립 톤 도움말. */
40
+ description?: ReactNode;
41
+ /** 경고 메시지. 있으면 invalid로 취급. */
42
+ warning?: ReactNode;
43
+ /**
44
+ * 내용에 맞춰 자동으로 높이 조정.
45
+ * true면 사용자 리사이즈 핸들은 감춰지고, maxRows에 도달하면 스크롤이 붙는다.
46
+ */
47
+ autoResize?: boolean;
48
+ /** autoResize 하한. 기본 rows(=3). */
49
+ minRows?: number;
50
+ /**
51
+ * autoResize 상한(줄 수). 넘으면 세로 스크롤.
52
+ * autoResize 없이 minRows/rows만 있으면 커스텀 핸들로 드래그해서 늘릴 수 있다.
53
+ */
54
+ maxRows?: number;
55
+ /** 글자수 카운터 노출. maxLength가 있으면 "n / max" 포맷. */
56
+ showCount?: boolean;
57
+ }
58
+
59
+ const sizeClass: Record<TextareaSize, string> = {
60
+ sm: styles.sizeSm,
61
+ md: styles.sizeMd,
62
+ lg: styles.sizeLg,
63
+ };
64
+
65
+ const variantClass: Record<TextareaVariant, string> = {
66
+ outline: styles.outline,
67
+ filled: styles.filled,
68
+ line: styles.line,
69
+ };
70
+
71
+ const labelSizeClass: Record<TextareaSize, string> = {
72
+ sm: styles.labelSm,
73
+ md: styles.labelMd,
74
+ lg: styles.labelLg,
75
+ };
76
+
77
+ /* line-height + padding + border 합산해 chrome 높이(px)를 얻는다. */
78
+ function computeMetrics(el: HTMLTextAreaElement) {
79
+ const cs = getComputedStyle(el);
80
+ const lh = parseFloat(cs.lineHeight);
81
+ const pt = parseFloat(cs.paddingTop);
82
+ const pb = parseFloat(cs.paddingBottom);
83
+ const bt = parseFloat(cs.borderTopWidth);
84
+ const bb = parseFloat(cs.borderBottomWidth);
85
+ return { lh, chrome: pt + pb + bt + bb };
86
+ }
87
+
88
+ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
89
+ (
90
+ {
91
+ className,
92
+ size = "md",
93
+ variant = "outline",
94
+ invalid: invalidProp,
95
+ width = "fill",
96
+ label,
97
+ description,
98
+ warning,
99
+ autoResize = true,
100
+ minRows,
101
+ maxRows,
102
+ showCount = false,
103
+ rows = 3,
104
+ disabled,
105
+ readOnly,
106
+ required,
107
+ value,
108
+ defaultValue,
109
+ onChange,
110
+ maxLength,
111
+ id,
112
+ ...props
113
+ },
114
+ ref
115
+ ) => {
116
+ const innerRef = useRef<HTMLTextAreaElement>(null);
117
+ const rootRef = useRef<HTMLDivElement>(null);
118
+
119
+ const hasWarning = warning != null && warning !== false;
120
+ const invalid = invalidProp ?? hasWarning;
121
+
122
+ const autoId = useId();
123
+ const textareaId = id ?? autoId;
124
+ const descriptionId = description != null ? `${textareaId}-desc` : undefined;
125
+ const warningId = hasWarning ? `${textareaId}-warn` : undefined;
126
+ const describedBy =
127
+ [warningId, descriptionId].filter(Boolean).join(" ") || undefined;
128
+
129
+ const [uncontrolledValue, setUncontrolledValue] = useState<string>(
130
+ String(defaultValue ?? "")
131
+ );
132
+
133
+ const isControlled = value !== undefined;
134
+ const currentValue = isControlled ? String(value ?? "") : uncontrolledValue;
135
+
136
+ const handleChange = useCallback(
137
+ (e: ChangeEvent<HTMLTextAreaElement>) => {
138
+ if (!isControlled) setUncontrolledValue(e.target.value);
139
+ onChange?.(e);
140
+ },
141
+ [isControlled, onChange]
142
+ );
143
+
144
+ /* autoResize: 컨텐츠 높이 → element.style.height 반영.
145
+ max에 도달하면 overflow-y=auto 로 스크롤. */
146
+ useLayoutEffect(() => {
147
+ if (!autoResize) return;
148
+ const el = innerRef.current;
149
+ if (!el) return;
150
+
151
+ const { lh, chrome } = computeMetrics(el);
152
+ const minR = minRows ?? rows ?? 3;
153
+ const minH = lh * minR + chrome;
154
+ const maxH = maxRows != null ? lh * maxRows + chrome : Infinity;
155
+
156
+ el.style.height = "auto";
157
+ const next = Math.min(Math.max(el.scrollHeight, minH), maxH);
158
+ el.style.height = `${next}px`;
159
+ el.style.overflowY = el.scrollHeight > maxH ? "auto" : "hidden";
160
+ }, [autoResize, currentValue, minRows, maxRows, rows, size]);
161
+
162
+ /* Manual 모드 — rows + minRows/maxRows로 min/max height를 계산해두고,
163
+ 핸들 드래그로 el.style.height를 갱신. */
164
+ const [dragging, setDragging] = useState(false);
165
+ const dragState = useRef<{
166
+ startY: number;
167
+ startH: number;
168
+ minH: number;
169
+ maxH: number;
170
+ } | null>(null);
171
+
172
+ const applyManualBounds = useCallback(() => {
173
+ if (autoResize) return;
174
+ const el = innerRef.current;
175
+ if (!el) return;
176
+ const { lh, chrome } = computeMetrics(el);
177
+ const minR = minRows ?? rows ?? 3;
178
+ el.style.minHeight = `${lh * minR + chrome}px`;
179
+ if (maxRows != null) {
180
+ el.style.maxHeight = `${lh * maxRows + chrome}px`;
181
+ } else {
182
+ el.style.maxHeight = "";
183
+ }
184
+ }, [autoResize, minRows, maxRows, rows]);
185
+
186
+ useLayoutEffect(() => {
187
+ applyManualBounds();
188
+ }, [applyManualBounds, size]);
189
+
190
+ const onHandlePointerDown = useCallback(
191
+ (e: ReactPointerEvent<HTMLDivElement>) => {
192
+ const el = innerRef.current;
193
+ if (!el || autoResize || disabled || readOnly) return;
194
+ e.preventDefault();
195
+ const { lh, chrome } = computeMetrics(el);
196
+ const minR = minRows ?? rows ?? 3;
197
+ const minH = lh * minR + chrome;
198
+ const maxH = maxRows != null ? lh * maxRows + chrome : Infinity;
199
+ dragState.current = {
200
+ startY: e.clientY,
201
+ startH: el.offsetHeight,
202
+ minH,
203
+ maxH,
204
+ };
205
+ setDragging(true);
206
+ document.body.classList.add("textarea-resizing");
207
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
208
+ },
209
+ [autoResize, disabled, readOnly, minRows, maxRows, rows]
210
+ );
211
+
212
+ const onHandlePointerMove = useCallback(
213
+ (e: ReactPointerEvent<HTMLDivElement>) => {
214
+ const state = dragState.current;
215
+ const el = innerRef.current;
216
+ if (!state || !el) return;
217
+ const dy = e.clientY - state.startY;
218
+ const next = Math.min(
219
+ Math.max(state.startH + dy, state.minH),
220
+ state.maxH
221
+ );
222
+ el.style.height = `${next}px`;
223
+ },
224
+ []
225
+ );
226
+
227
+ const onHandlePointerUp = useCallback(
228
+ (e: ReactPointerEvent<HTMLDivElement>) => {
229
+ if (!dragState.current) return;
230
+ dragState.current = null;
231
+ setDragging(false);
232
+ document.body.classList.remove("textarea-resizing");
233
+ try {
234
+ (e.target as HTMLElement).releasePointerCapture(e.pointerId);
235
+ } catch {
236
+ /* pointer가 이미 해제된 경우 */
237
+ }
238
+ },
239
+ []
240
+ );
241
+
242
+ /* 키보드 조작 — Arrow Up/Down으로 한 줄씩. 접근성. */
243
+ const onHandleKeyDown = useCallback(
244
+ (e: KeyboardEvent<HTMLDivElement>) => {
245
+ const el = innerRef.current;
246
+ if (!el || autoResize || disabled || readOnly) return;
247
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
248
+ e.preventDefault();
249
+ const { lh, chrome } = computeMetrics(el);
250
+ const minR = minRows ?? rows ?? 3;
251
+ const minH = lh * minR + chrome;
252
+ const maxH = maxRows != null ? lh * maxRows + chrome : Infinity;
253
+ const step = lh;
254
+ const cur = el.offsetHeight;
255
+ const next = Math.min(
256
+ Math.max(cur + (e.key === "ArrowDown" ? step : -step), minH),
257
+ maxH
258
+ );
259
+ el.style.height = `${next}px`;
260
+ },
261
+ [autoResize, disabled, readOnly, minRows, maxRows, rows]
262
+ );
263
+
264
+ useEffect(() => {
265
+ return () => {
266
+ document.body.classList.remove("textarea-resizing");
267
+ };
268
+ }, []);
269
+
270
+ /* ref 병합 */
271
+ useEffect(() => {
272
+ if (typeof ref === "function") ref(innerRef.current);
273
+ else if (ref)
274
+ (ref as React.MutableRefObject<HTMLTextAreaElement | null>).current =
275
+ innerRef.current;
276
+ }, [ref]);
277
+
278
+ const widthStyle: string | undefined =
279
+ width === "fill"
280
+ ? undefined
281
+ : width === "md"
282
+ ? "var(--input-w-md)"
283
+ : width === "lg"
284
+ ? "var(--input-w-lg)"
285
+ : `${width}px`;
286
+
287
+ const rootClass = [
288
+ styles.root,
289
+ variantClass[variant],
290
+ sizeClass[size],
291
+ width === "fill" ? styles.fill : "",
292
+ autoResize ? styles.autoResize : "",
293
+ className ?? "",
294
+ ]
295
+ .filter(Boolean)
296
+ .join(" ");
297
+
298
+ const rootStyle: React.CSSProperties | undefined = widthStyle
299
+ ? { width: widthStyle }
300
+ : undefined;
301
+
302
+ const count = currentValue.length;
303
+ const over = maxLength != null && count > maxLength;
304
+
305
+ const showHandle = !autoResize && !disabled && !readOnly;
306
+
307
+ const control = (
308
+ <div
309
+ ref={rootRef}
310
+ className={rootClass}
311
+ style={rootStyle}
312
+ data-invalid={invalid || undefined}
313
+ data-disabled={disabled || undefined}
314
+ data-readonly={readOnly || undefined}
315
+ onClick={(e) => {
316
+ /* 핸들 클릭까지 focus로 넘기지 않도록 */
317
+ if ((e.target as HTMLElement).dataset.textareaHandle) return;
318
+ innerRef.current?.focus();
319
+ }}
320
+ >
321
+ <textarea
322
+ ref={innerRef}
323
+ id={textareaId}
324
+ className={styles.textarea}
325
+ rows={rows}
326
+ value={isControlled ? currentValue : undefined}
327
+ defaultValue={isControlled ? undefined : defaultValue}
328
+ onChange={handleChange}
329
+ disabled={disabled}
330
+ readOnly={readOnly}
331
+ required={required}
332
+ maxLength={maxLength}
333
+ aria-invalid={invalid || undefined}
334
+ aria-describedby={describedBy}
335
+ {...props}
336
+ />
337
+
338
+ {showHandle && (
339
+ <div
340
+ className={styles.handle}
341
+ data-textarea-handle="true"
342
+ data-active={dragging || undefined}
343
+ role="separator"
344
+ aria-orientation="horizontal"
345
+ aria-label="높이 조절"
346
+ tabIndex={0}
347
+ onPointerDown={onHandlePointerDown}
348
+ onPointerMove={onHandlePointerMove}
349
+ onPointerUp={onHandlePointerUp}
350
+ onPointerCancel={onHandlePointerUp}
351
+ onKeyDown={onHandleKeyDown}
352
+ />
353
+ )}
354
+ </div>
355
+ );
356
+
357
+ const footer = showCount ? (
358
+ <div
359
+ className={[styles.footer, over ? styles.countOver : ""]
360
+ .filter(Boolean)
361
+ .join(" ")}
362
+ >
363
+ {maxLength != null ? `${count} / ${maxLength}` : count}
364
+ </div>
365
+ ) : null;
366
+
367
+ if (label == null && description == null && !hasWarning && !footer) {
368
+ return (
369
+ <>
370
+ {control}
371
+ {footer}
372
+ </>
373
+ );
374
+ }
375
+
376
+ const fieldStyle: React.CSSProperties | undefined = widthStyle
377
+ ? { width: widthStyle }
378
+ : undefined;
379
+
380
+ return (
381
+ <div
382
+ className={styles.field}
383
+ style={fieldStyle}
384
+ data-disabled={disabled || undefined}
385
+ >
386
+ {label != null && (
387
+ <label
388
+ htmlFor={textareaId}
389
+ className={[styles.label, labelSizeClass[size]].join(" ")}
390
+ >
391
+ {label}
392
+ {required && (
393
+ <span aria-hidden className={styles.requiredMark}>
394
+ *
395
+ </span>
396
+ )}
397
+ </label>
398
+ )}
399
+
400
+ {control}
401
+
402
+ {footer}
403
+
404
+ {hasWarning && (
405
+ <p id={warningId} role="alert" className={styles.errorText}>
406
+ <span className={styles.messageIcon} aria-hidden>
407
+ <Icon name="warning-circle" size={14} />
408
+ </span>
409
+ {warning}
410
+ </p>
411
+ )}
412
+
413
+ {description != null && !hasWarning && (
414
+ <p id={descriptionId} className={styles.description}>
415
+ <span className={styles.messageIcon} aria-hidden>
416
+ <Icon name="info" size={14} />
417
+ </span>
418
+ {description}
419
+ </p>
420
+ )}
421
+ </div>
422
+ );
423
+ }
424
+ );
425
+
426
+ Textarea.displayName = "Textarea";
@@ -0,0 +1,7 @@
1
+ export { Textarea } from "./Textarea";
2
+ export type {
3
+ TextareaProps,
4
+ TextareaSize,
5
+ TextareaVariant,
6
+ TextareaWidth,
7
+ } from "./Textarea";
@@ -0,0 +1,271 @@
1
+ "use client";
2
+
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
4
+ import type {
5
+ DensityValue,
6
+ IconFamily,
7
+ LangValue,
8
+ MotionValue,
9
+ PaletteValue,
10
+ ShapeValue,
11
+ ThemePreset,
12
+ VoiceValue,
13
+ } from "./_data";
14
+ import { IconAxisProvider } from "./Icon";
15
+
16
+ type ThemeMode = "light" | "dark";
17
+ export type FocusRingValue = "on" | "off";
18
+
19
+ /**
20
+ * ctds.config.json의 shape. `ctds init` 후 사용자가 /ctds 적용하면 이 모양으로 쓰임.
21
+ * 모든 필드가 optional — 부분 설정만 있어도 override로 작동, 나머지는 ThemeController
22
+ * 기본값 유지. 알 수 없는 필드는 무시.
23
+ */
24
+ export type InitialConfig = {
25
+ mode?: ThemeMode;
26
+ palette?: PaletteValue;
27
+ hue?: number;
28
+ accentHue?: number | null;
29
+ shape?: ShapeValue;
30
+ density?: DensityValue;
31
+ motion?: MotionValue;
32
+ iconFamily?: IconFamily;
33
+ voice?: VoiceValue;
34
+ lang?: LangValue;
35
+ focusRing?: FocusRingValue;
36
+ };
37
+
38
+ export type ThemeState = {
39
+ theme: ThemeMode;
40
+ setTheme: (v: ThemeMode) => void;
41
+ toggleTheme: () => void;
42
+ palette: PaletteValue;
43
+ setPalette: (v: PaletteValue) => void;
44
+ hue: number;
45
+ setHue: (v: number) => void;
46
+ /** 보조 hue. null이면 미설정(secondary variant는 primary hue로 폴백). */
47
+ accentHue: number | null;
48
+ setAccentHue: (v: number | null) => void;
49
+ shape: ShapeValue;
50
+ setShape: (v: ShapeValue) => void;
51
+ density: DensityValue;
52
+ setDensity: (v: DensityValue) => void;
53
+ motionMode: MotionValue;
54
+ setMotionMode: (v: MotionValue) => void;
55
+ iconFamily: IconFamily;
56
+ setIconFamily: (v: IconFamily) => void;
57
+ lang: LangValue;
58
+ setLang: (v: LangValue) => void;
59
+ focusRing: FocusRingValue;
60
+ setFocusRing: (v: FocusRingValue) => void;
61
+ voice: VoiceValue;
62
+ setVoice: (v: VoiceValue) => void;
63
+ applyPreset: (preset: ThemePreset) => void;
64
+ reset: () => void;
65
+ };
66
+
67
+ const ThemeContext = createContext<ThemeState | null>(null);
68
+
69
+ export function useTheme(): ThemeState {
70
+ const ctx = useContext(ThemeContext);
71
+ if (!ctx) throw new Error("useTheme must be used within ThemeController");
72
+ return ctx;
73
+ }
74
+
75
+ /**
76
+ * ThemeController 사용법:
77
+ * <ThemeController>...</ThemeController> — 기본값에서 시작
78
+ * <ThemeController initialConfig={ctdsConfig}>...</ThemeController>
79
+ * → ctds.config.json을 import해서 넘기면 SSR 첫 렌더부터 그 값으로 뜸.
80
+ * mount 후 localStorage가 있으면 localStorage가 우선 (사용자가 바꾼 값 유지).
81
+ */
82
+ export function ThemeController({
83
+ children,
84
+ initialConfig,
85
+ }: {
86
+ children: React.ReactNode;
87
+ initialConfig?: InitialConfig;
88
+ }) {
89
+ const ic = initialConfig ?? {};
90
+ const [theme, setTheme] = useState<ThemeMode>(ic.mode ?? "light");
91
+ const [palette, setPalette] = useState<PaletteValue>(ic.palette ?? "standard");
92
+ const [hue, setHue] = useState<number>(ic.hue ?? 210);
93
+ const [accentHue, setAccentHue] = useState<number | null>(ic.accentHue ?? null);
94
+ const [shape, setShape] = useState<ShapeValue>(ic.shape ?? "standard");
95
+ const [density, setDensity] = useState<DensityValue>(ic.density ?? "default");
96
+ const [motionMode, setMotionMode] = useState<MotionValue>(ic.motion ?? "standard");
97
+ const [iconFamily, setIconFamily] = useState<IconFamily>(ic.iconFamily ?? "phosphor");
98
+ const [lang, setLang] = useState<LangValue>(ic.lang ?? "ko");
99
+ const [focusRing, setFocusRing] = useState<FocusRingValue>(ic.focusRing ?? "off");
100
+ const [voice, setVoice] = useState<VoiceValue>(ic.voice ?? "neutral");
101
+ const [hydrated, setHydrated] = useState(false);
102
+
103
+ // Init from localStorage — 반드시 첫 렌더는 서버와 동일한 default로 두고
104
+ // mount 후 한 번에 실제 값으로 스왑한다. 그래야 hydration mismatch가 안 난다.
105
+ useEffect(() => {
106
+ try {
107
+ const saved =
108
+ (localStorage.getItem("ctds-mode") as ThemeMode | null) ||
109
+ (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
110
+ setTheme(saved);
111
+ const savedPalette = localStorage.getItem("ctds-palette") as PaletteValue | null;
112
+ if (savedPalette) setPalette(savedPalette);
113
+ const savedHue = localStorage.getItem("ctds-hue");
114
+ if (savedHue) setHue(Number(savedHue));
115
+ const savedAccentHue = localStorage.getItem("ctds-accent-hue");
116
+ if (savedAccentHue && savedAccentHue !== "") setAccentHue(Number(savedAccentHue));
117
+ // ctds-accent는 축에서 제거됨. 과거값은 정리.
118
+ try {
119
+ localStorage.removeItem("ctds-accent");
120
+ } catch {}
121
+ const savedShape = localStorage.getItem("ctds-shape") as ShapeValue | null;
122
+ if (savedShape) setShape(savedShape);
123
+ const savedDensity = localStorage.getItem("ctds-density") as DensityValue | null;
124
+ if (savedDensity) setDensity(savedDensity);
125
+ const savedMotion = localStorage.getItem("ctds-motion") as MotionValue | null;
126
+ const motionMigration: Record<string, MotionValue> = {
127
+ expressive: "bouncy",
128
+ subtle: "calm",
129
+ };
130
+ const validMotion: MotionValue[] = ["bouncy", "standard", "calm", "none"];
131
+ if (savedMotion) {
132
+ const next = motionMigration[savedMotion as string] ?? savedMotion;
133
+ if (validMotion.includes(next as MotionValue)) setMotionMode(next as MotionValue);
134
+ }
135
+ const savedIconFamily = localStorage.getItem("ctds-icon-family") as IconFamily | null;
136
+ if (savedIconFamily) setIconFamily(savedIconFamily);
137
+ // ctds-icon-weight는 축에서 제거됨. 과거값은 정리.
138
+ try {
139
+ localStorage.removeItem("ctds-icon-weight");
140
+ } catch {}
141
+ const savedLang = localStorage.getItem("ctds-lang") as LangValue | null;
142
+ const validLang: LangValue[] = ["ko", "en"];
143
+ if (savedLang && validLang.includes(savedLang)) setLang(savedLang);
144
+ const savedFocusRing = localStorage.getItem("ctds-focus-ring") as FocusRingValue | null;
145
+ if (savedFocusRing === "on" || savedFocusRing === "off") setFocusRing(savedFocusRing);
146
+ const savedVoice = localStorage.getItem("ctds-voice") as VoiceValue | null;
147
+ const validVoice: VoiceValue[] = ["formal", "neutral", "casual"];
148
+ if (savedVoice && validVoice.includes(savedVoice)) setVoice(savedVoice);
149
+ try {
150
+ localStorage.removeItem("ctds-typography");
151
+ } catch {}
152
+ } catch {}
153
+ setHydrated(true);
154
+ }, []);
155
+
156
+ // Sync -> document + persist
157
+ // hydrated 전에는 스킵 — head 스크립트가 이미 실제 값으로 attr을 세팅해뒀고,
158
+ // 우리는 마운트 후 state가 실제 값으로 스왑된 뒤부터 관리한다.
159
+ useEffect(() => {
160
+ if (!hydrated) return;
161
+ document.documentElement.setAttribute("data-mode", theme);
162
+ document.documentElement.setAttribute("data-palette", palette);
163
+ document.documentElement.removeAttribute("data-accent");
164
+ document.documentElement.setAttribute("data-shape", shape);
165
+ document.documentElement.setAttribute("data-density", density);
166
+ document.documentElement.setAttribute("data-motion", motionMode);
167
+ document.documentElement.setAttribute("data-icon-family", iconFamily);
168
+ document.documentElement.removeAttribute("data-icon-weight");
169
+ document.documentElement.setAttribute("data-lang", lang);
170
+ document.documentElement.setAttribute("data-focus-ring", focusRing);
171
+ document.documentElement.setAttribute("data-voice", voice);
172
+ document.documentElement.removeAttribute("data-typography");
173
+ document.documentElement.lang = lang;
174
+ document.documentElement.style.setProperty("--hue", String(hue));
175
+ if (accentHue == null) {
176
+ document.documentElement.style.removeProperty("--secondary-hue");
177
+ } else {
178
+ document.documentElement.style.setProperty("--secondary-hue", String(accentHue));
179
+ }
180
+ try {
181
+ localStorage.setItem("ctds-mode", theme);
182
+ localStorage.setItem("ctds-palette", palette);
183
+ localStorage.setItem("ctds-shape", shape);
184
+ localStorage.setItem("ctds-density", density);
185
+ localStorage.setItem("ctds-motion", motionMode);
186
+ localStorage.setItem("ctds-icon-family", iconFamily);
187
+ localStorage.setItem("ctds-lang", lang);
188
+ localStorage.setItem("ctds-focus-ring", focusRing);
189
+ localStorage.setItem("ctds-voice", voice);
190
+ localStorage.setItem("ctds-hue", String(hue));
191
+ if (accentHue == null) localStorage.removeItem("ctds-accent-hue");
192
+ else localStorage.setItem("ctds-accent-hue", String(accentHue));
193
+ } catch {}
194
+ }, [hydrated, theme, palette, hue, accentHue, shape, density, motionMode, iconFamily, lang, focusRing, voice]);
195
+
196
+ const toggleTheme = useCallback(
197
+ () => setTheme((t) => (t === "dark" ? "light" : "dark")),
198
+ []
199
+ );
200
+
201
+ const applyPreset = useCallback((p: ThemePreset) => {
202
+ // Voice is Theme-independent — intentionally not reset by presets.
203
+ setTheme(p.mode);
204
+ setPalette(p.palette);
205
+ setHue(p.hue);
206
+ setAccentHue(p.accentHue ?? null);
207
+ setShape(p.shape);
208
+ setDensity(p.density);
209
+ setMotionMode(p.motion);
210
+ setIconFamily(p.iconFamily);
211
+ setLang(p.lang);
212
+ }, []);
213
+
214
+ const reset = useCallback(() => {
215
+ setTheme("light");
216
+ setPalette("standard");
217
+ setHue(210);
218
+ setAccentHue(null);
219
+ setShape("standard");
220
+ setDensity("default");
221
+ setMotionMode("standard");
222
+ setIconFamily("phosphor");
223
+ setLang("ko");
224
+ setFocusRing("off");
225
+ setVoice("neutral");
226
+ }, []);
227
+
228
+ const value = useMemo<ThemeState>(
229
+ () => ({
230
+ theme,
231
+ setTheme,
232
+ toggleTheme,
233
+ palette,
234
+ setPalette,
235
+ hue,
236
+ setHue,
237
+ accentHue,
238
+ setAccentHue,
239
+ shape,
240
+ setShape,
241
+ density,
242
+ setDensity,
243
+ motionMode,
244
+ setMotionMode,
245
+ iconFamily,
246
+ setIconFamily,
247
+ lang,
248
+ setLang,
249
+ focusRing,
250
+ setFocusRing,
251
+ voice,
252
+ setVoice,
253
+ applyPreset,
254
+ reset,
255
+ }),
256
+ [theme, palette, hue, accentHue, shape, density, motionMode, iconFamily, lang, focusRing, voice, toggleTheme, applyPreset, reset]
257
+ );
258
+
259
+ // Icon 축을 Icon 라이브러리 컨텍스트에도 브로드캐스트 (아이콘마다 MutationObserver 제거용)
260
+ // weight 축은 제거됨. Icon 라이브러리는 default 'regular' 그대로.
261
+ const iconAxisValue = useMemo(
262
+ () => ({ family: iconFamily, weight: "regular" as const }),
263
+ [iconFamily]
264
+ );
265
+
266
+ return (
267
+ <ThemeContext.Provider value={value}>
268
+ <IconAxisProvider value={iconAxisValue}>{children}</IconAxisProvider>
269
+ </ThemeContext.Provider>
270
+ );
271
+ }