@conscience-technology/ctds 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +254 -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,91 @@
1
+ "use client";
2
+
3
+ import type { CSSProperties, HTMLAttributes, MouseEvent } from "react";
4
+ import { ImageSquare as PhImageSquare, X as PhX } from "@phosphor-icons/react";
5
+ import styles from "./AttachmentThumb.module.css";
6
+
7
+ export type AttachmentThumbSize = "sm" | "md" | "lg";
8
+
9
+ export interface AttachmentThumbProps
10
+ extends Omit<HTMLAttributes<HTMLSpanElement>, "children" | "onError"> {
11
+ /** 이미지 소스. 없으면 image glyph placeholder (블루톤 L/D pair). */
12
+ src?: string;
13
+ alt?: string;
14
+ /** sm=32 / md=40 / lg=56. 기본 md. */
15
+ size?: AttachmentThumbSize;
16
+ /** 로딩 중 pulse. src 디코딩 전·업로드 처리 중. */
17
+ loading?: boolean;
18
+ /** 지정 시 hover에서 코너에 X 노출. */
19
+ onRemove?: (e: MouseEvent<HTMLButtonElement>) => void;
20
+ }
21
+
22
+ const sizeClass: Record<AttachmentThumbSize, string> = {
23
+ sm: styles.sizeSm,
24
+ md: styles.sizeMd,
25
+ lg: styles.sizeLg,
26
+ };
27
+
28
+ /* placeholder 블루톤 L/D pair — FileIcon 제너릭 그룹과 같은 패턴.
29
+ 브랜드 자산 범주 — CSS 토큰으로 추상화 X. inline style로 주입해 CSS 린터 범위 밖. */
30
+ const PLACEHOLDER_VARS = {
31
+ "--at-ph-bg-light": "#D5E8FD",
32
+ "--at-ph-fg-light": "#2D7FD3",
33
+ "--at-ph-bg-dark": "#1E3A5F",
34
+ "--at-ph-fg-dark": "#C5DFF8",
35
+ } as CSSProperties;
36
+
37
+ export function AttachmentThumb({
38
+ src,
39
+ alt = "",
40
+ size = "md",
41
+ loading = false,
42
+ onRemove,
43
+ className,
44
+ style,
45
+ ...rest
46
+ }: AttachmentThumbProps) {
47
+ const isPlaceholder = !loading && !src;
48
+
49
+ const cls = [
50
+ styles.root,
51
+ sizeClass[size],
52
+ loading
53
+ ? styles.loading
54
+ : src
55
+ ? styles.hasImage
56
+ : styles.placeholder,
57
+ className,
58
+ ]
59
+ .filter(Boolean)
60
+ .join(" ");
61
+
62
+ const mergedStyle: CSSProperties = isPlaceholder
63
+ ? { ...PLACEHOLDER_VARS, ...style }
64
+ : style ?? {};
65
+
66
+ return (
67
+ <span
68
+ className={cls}
69
+ style={mergedStyle}
70
+ data-removable={onRemove ? "" : undefined}
71
+ {...rest}
72
+ >
73
+ {loading ? null : src ? (
74
+ <img src={src} alt={alt} className={styles.img} />
75
+ ) : (
76
+ <PhImageSquare className={styles.glyph} weight="regular" aria-hidden />
77
+ )}
78
+
79
+ {onRemove && !loading ? (
80
+ <button
81
+ type="button"
82
+ className={styles.removeBtn}
83
+ onClick={onRemove}
84
+ aria-label="첨부 제거"
85
+ >
86
+ <PhX className={styles.removeIcon} weight="bold" />
87
+ </button>
88
+ ) : null}
89
+ </span>
90
+ );
91
+ }
@@ -0,0 +1,2 @@
1
+ export { AttachmentThumb } from "./AttachmentThumb";
2
+ export type { AttachmentThumbProps, AttachmentThumbSize } from "./AttachmentThumb";
@@ -0,0 +1,167 @@
1
+ /* Avatar ───────────────────────────────────────────────────────
2
+ 사람/팀/봇을 시각화하는 라벨.
3
+ 이미지 > 이니셜 > 아이콘 순으로 폴백(안쪽 내용만 바뀌고 컨테이너는 동일).
4
+
5
+ shape prop:
6
+ - circle(기본): 사람·개인 계정. 항상 --radius-full.
7
+ - rounded: 팀·조직·봇·서비스. size별로 radius 스왑(Shape 축을 밟음).
8
+ 두 모양이 나란히 있으면 "사람 vs 아닌 것"이 한눈에 구분됨. */
9
+ .root {
10
+ display: inline-flex;
11
+ align-items: center;
12
+ justify-content: center;
13
+ flex-shrink: 0;
14
+ background: var(--_avatar-bg, var(--bg-muted));
15
+ color: var(--_avatar-fg, var(--fg));
16
+ font-family: var(--label-font);
17
+ font-weight: var(--font-weight-semibold);
18
+ line-height: 1;
19
+ letter-spacing: 0; /* 이니셜은 tracking 0 — narrow letter 조합에서 시각 균형 */
20
+ user-select: none;
21
+ }
22
+
23
+ /* Shape ─────────────────────────────────────────────────── */
24
+ .circle { border-radius: var(--radius-full); }
25
+
26
+ /* rounded는 size에 맞춰 radius 크기가 자연스럽게 커짐.
27
+ xs/sm은 컨테이너 자체가 작아 xs radius, md는 sm, lg는 md, xl은 lg. */
28
+ .rounded.xs { border-radius: var(--radius-xs); }
29
+ .rounded.sm { border-radius: var(--radius-sm); }
30
+ .rounded.md { border-radius: var(--radius-md); }
31
+ .rounded.lg { border-radius: var(--radius-lg); }
32
+ .rounded.xl { border-radius: var(--radius-xl); }
33
+
34
+ /* Sizes ────────────────────────────────────────────────────
35
+ space-* 스케일에 붙여 다른 컨트롤과 정렬 유지.
36
+ xs는 Chip 내부용, sm/md는 리스트/일반, lg는 프로필 헤더 등. */
37
+ /* 이니셜 폰트는 컨테이너 폭의 40% — 사이즈가 바뀌어도 시각 비율 유지. */
38
+ .xs {
39
+ width: var(--space-4); /* 16 */
40
+ height: var(--space-4);
41
+ font-size: calc(var(--space-4) * 0.5); /* xs만 최소 가독성 위해 살짝 크게 */
42
+ }
43
+ .sm {
44
+ width: var(--space-6); /* 24 */
45
+ height: var(--space-6);
46
+ font-size: calc(var(--space-6) * 0.48);
47
+ }
48
+ .md {
49
+ width: var(--space-8); /* 32 */
50
+ height: var(--space-8);
51
+ font-size: calc(var(--space-8) * 0.48);
52
+ }
53
+ .lg {
54
+ width: var(--space-12); /* 48 */
55
+ height: var(--space-12);
56
+ font-size: calc(var(--space-12) * 0.48);
57
+ }
58
+ .xl {
59
+ width: var(--space-24); /* 96 */
60
+ height: var(--space-24);
61
+ font-size: calc(var(--space-24) * 0.48);
62
+ }
63
+
64
+ /* Content slots ──────────────────────────────────────────── */
65
+ .root {
66
+ position: relative;
67
+ overflow: visible; /* status 뱃지가 밖으로 삐져나올 수 있게 */
68
+ }
69
+ /* 내부 이미지 모서리는 shape를 따라야 하므로 안쪽 clip은 image에만 적용 */
70
+ .image {
71
+ position: absolute;
72
+ inset: 0;
73
+ width: 100%;
74
+ height: 100%;
75
+ object-fit: cover;
76
+ display: block;
77
+ opacity: 0;
78
+ border-radius: inherit; /* root의 shape를 그대로 따라감 */
79
+ }
80
+ .image[data-loaded="true"] { opacity: 1; }
81
+ .initials {
82
+ display: inline-flex;
83
+ align-items: center;
84
+ justify-content: center;
85
+ color: inherit;
86
+ }
87
+ .icon {
88
+ display: inline-flex;
89
+ align-items: center;
90
+ justify-content: center;
91
+ color: var(--fg-muted);
92
+ }
93
+ .icon > svg {
94
+ display: block;
95
+ }
96
+
97
+ /* Status badge ───────────────────────────────────────────────
98
+ 접속 상태 표시용 작은 동그라미. 우하단 고정.
99
+ - online: --success
100
+ - offline: --fg-muted (톤 다운)
101
+ 테두리는 --bg 색 ring으로, 아바타와 시각적으로 분리.
102
+ 크기·offset은 각 size별 공간 토큰으로 매핑. */
103
+ .status {
104
+ position: absolute;
105
+ right: 0;
106
+ bottom: 0;
107
+ width: var(--_status-size, var(--space-2));
108
+ height: var(--_status-size, var(--space-2));
109
+ border-radius: var(--radius-full);
110
+ box-shadow: 0 0 0 var(--_status-ring, var(--space-0_5)) var(--bg);
111
+ pointer-events: none;
112
+ }
113
+ .status[data-status="online"] { background: var(--success); }
114
+ .status[data-status="offline"] { background: var(--fg-disabled); }
115
+
116
+ /* size별 뱃지 지름 — 컨테이너의 ~25~30% 범위.
117
+ ring은 작은 사이즈에선 hairline, 큰 사이즈에선 공간 토큰. */
118
+ .xs .status { --_status-size: var(--space-1_5); --_status-ring: var(--hairline); } /* 6 */
119
+ .sm .status { --_status-size: var(--space-2); --_status-ring: var(--hairline); } /* 8 */
120
+ .md .status { --_status-size: var(--space-2_5); --_status-ring: var(--space-0_5); } /* 10 */
121
+ .lg .status { --_status-size: var(--space-3); --_status-ring: var(--space-0_5); } /* 12 */
122
+ .xl .status { --_status-size: var(--space-6); --_status-ring: var(--space-1); } /* 24 */
123
+
124
+ /* rounded shape는 모서리가 각지므로 뱃지가 모서리 바깥으로 살짝 나가도록 */
125
+ .rounded .status {
126
+ right: calc(var(--_status-size) * -0.2);
127
+ bottom: calc(var(--_status-size) * -0.2);
128
+ }
129
+
130
+ /* ── AvatarGroup ────────────────────────────────────────────
131
+ 가로 겹침. 왼쪽 아바타가 앞에 오도록 z-index 역순. bg 링으로 경계 분리.
132
+ 겹침 offset은 각 아바타 폭의 ~30%. size별로 자동 스케일. */
133
+ .group {
134
+ display: inline-flex;
135
+ flex-direction: row;
136
+ align-items: center;
137
+ isolation: isolate;
138
+ width: fit-content;
139
+ }
140
+
141
+ .groupItem {
142
+ position: relative;
143
+ z-index: var(--stack-order, 0);
144
+ box-shadow: 0 0 0 var(--space-0_5) var(--bg);
145
+ }
146
+
147
+ .groupItem + .groupItem {
148
+ margin-left: calc(var(--space-1) * -1);
149
+ }
150
+
151
+ /* size별 overlap 강도 조정 */
152
+ .group.xs .groupItem + .groupItem { margin-left: calc(var(--space-1) * -1); } /* -4 */
153
+ .group.sm .groupItem + .groupItem { margin-left: calc(var(--space-1_5) * -1); } /* -6 */
154
+ .group.md .groupItem + .groupItem { margin-left: calc(var(--space-2) * -1); } /* -8 */
155
+ .group.lg .groupItem + .groupItem { margin-left: calc(var(--space-3) * -1); } /* -12 */
156
+ .group.xl .groupItem + .groupItem { margin-left: calc(var(--space-6) * -1); } /* -24 */
157
+
158
+ .overflow {
159
+ background: var(--bg-muted);
160
+ color: var(--fg-subtle);
161
+ font-weight: var(--font-weight-medium);
162
+ }
163
+ .overflow.xs { font-size: calc(var(--space-4) * 0.42); }
164
+ .overflow.sm { font-size: calc(var(--space-6) * 0.36); }
165
+ .overflow.md { font-size: calc(var(--space-8) * 0.36); }
166
+ .overflow.lg { font-size: calc(var(--space-12) * 0.36); }
167
+ .overflow.xl { font-size: calc(var(--space-24) * 0.36); }
@@ -0,0 +1,228 @@
1
+ "use client";
2
+
3
+ import {
4
+ Children,
5
+ cloneElement,
6
+ forwardRef,
7
+ isValidElement,
8
+ useState,
9
+ type HTMLAttributes,
10
+ type ReactElement,
11
+ type ReactNode,
12
+ } from "react";
13
+
14
+ import { Icon } from "../Icon";
15
+ import styles from "./Avatar.module.css";
16
+
17
+ export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
18
+ export type AvatarShape = "circle" | "rounded";
19
+ export type AvatarStatus = "online" | "offline";
20
+
21
+ export type AvatarProps = HTMLAttributes<HTMLSpanElement> & {
22
+ size?: AvatarSize;
23
+ /** circle(기본) — 사람·개인 계정. rounded — 팀·조직·봇·서비스 아이덴티티. */
24
+ shape?: AvatarShape;
25
+ /** 이미지 URL. 로드 실패 시 이니셜 또는 아이콘으로 폴백. */
26
+ src?: string;
27
+ /** 접근성 대체 텍스트. 이미지 alt이자 스크린리더 라벨. */
28
+ alt?: string;
29
+ /** 이름 문자열. src 없으면 여기서 이니셜 추출. */
30
+ name?: string;
31
+ /** 이니셜을 직접 지정. name보다 우선. */
32
+ initials?: string;
33
+ /** 이미지·이니셜 대신 아이콘. */
34
+ icon?: ReactNode;
35
+ /** 접속 상태 뱃지. online — 접속중, offline — 미접속. 미지정 시 뱃지 없음. */
36
+ status?: AvatarStatus;
37
+ };
38
+
39
+ const sizeClass: Record<AvatarSize, string> = {
40
+ xs: styles.xs,
41
+ sm: styles.sm,
42
+ md: styles.md,
43
+ lg: styles.lg,
44
+ xl: styles.xl,
45
+ };
46
+
47
+ const shapeClass: Record<AvatarShape, string> = {
48
+ circle: styles.circle,
49
+ rounded: styles.rounded,
50
+ };
51
+
52
+ const ICON_SIZE: Record<AvatarSize, number> = {
53
+ xs: 10,
54
+ sm: 14,
55
+ md: 18,
56
+ lg: 24,
57
+ xl: 40,
58
+ };
59
+
60
+ /* 이름에서 이니셜 뽑기.
61
+ - 한글은 첫 글자 1개 ("김희영" → "김")
62
+ - 영문은 첫 단어의 앞 2글자: 대문자 + 소문자 ("Kim Heeyoung" → "Ki", "AB" → "Ab")
63
+ - 실패 시 "?" */
64
+ function extractInitials(name: string): string {
65
+ const trimmed = name.trim();
66
+ if (!trimmed) return "?";
67
+ if (/[ㄱ-힣]/.test(trimmed[0])) return trimmed[0];
68
+ const first = trimmed.split(/\s+/)[0] ?? "";
69
+ if (!first) return "?";
70
+ return (first[0].toUpperCase() + (first[1] ?? "").toLowerCase()) || "?";
71
+ }
72
+
73
+ /* seed 문자열 → 0~7 안정적 해시.
74
+ 같은 이름이면 같은 색, 다른 이름은 되도록 다른 색. */
75
+ const TINT_COUNT = 10;
76
+ function tintIndex(seed: string): number {
77
+ let h = 0;
78
+ for (let i = 0; i < seed.length; i++) {
79
+ h = (h * 31 + seed.charCodeAt(i)) >>> 0;
80
+ }
81
+ return h % TINT_COUNT;
82
+ }
83
+
84
+ function sizedIcon(node: ReactNode, size: number): ReactNode {
85
+ if (!isValidElement(node)) return node;
86
+ const el = node as ReactElement<{ size?: number | string }>;
87
+ if (el.props.size !== undefined) return el;
88
+ return cloneElement(el, { size });
89
+ }
90
+
91
+ const Avatar = forwardRef<HTMLSpanElement, AvatarProps>(
92
+ (
93
+ { className, size = "md", shape = "circle", src, alt, name, initials, icon, status, style, ...rest },
94
+ ref,
95
+ ) => {
96
+ /* 이미지는 로드 완료 전까진 감추고 이니셜/아이콘을 아래에 깔아둔다.
97
+ onError로 실패 감지도 하지만, 드물게 발동 안 하는 케이스(DNS 실패 등)에 대비해
98
+ "성공했을 때만 이미지 표시" 전략으로 폴백 UI가 항상 안전. */
99
+ const [imgLoaded, setImgLoaded] = useState(false);
100
+ const [imgFailed, setImgFailed] = useState(false);
101
+
102
+ const cls = [styles.root, sizeClass[size], shapeClass[shape], className ?? ""]
103
+ .filter(Boolean)
104
+ .join(" ");
105
+
106
+ const hasUsableImage = src && !imgFailed;
107
+ const resolvedInitials = initials ?? (name ? extractInitials(name) : undefined);
108
+ const ariaLabel = alt ?? name;
109
+
110
+ const tintSeed = resolvedInitials
111
+ ? (name ?? initials ?? resolvedInitials)
112
+ : undefined;
113
+ const tintStyle: React.CSSProperties | undefined = tintSeed
114
+ ? {
115
+ ["--_avatar-bg" as string]: `var(--avatar-tint-${tintIndex(tintSeed)}-bg)`,
116
+ ["--_avatar-fg" as string]: `var(--avatar-tint-${tintIndex(tintSeed)}-fg)`,
117
+ }
118
+ : undefined;
119
+
120
+ return (
121
+ <span
122
+ ref={ref}
123
+ className={cls}
124
+ role="img"
125
+ aria-label={ariaLabel}
126
+ style={{ ...tintStyle, ...style }}
127
+ {...rest}
128
+ >
129
+ {resolvedInitials ? (
130
+ <span className={styles.initials} aria-hidden>
131
+ {resolvedInitials}
132
+ </span>
133
+ ) : icon ? (
134
+ <span className={styles.icon} aria-hidden>
135
+ {sizedIcon(icon, ICON_SIZE[size])}
136
+ </span>
137
+ ) : (
138
+ <span className={styles.icon} aria-hidden>
139
+ <Icon name="user" size={ICON_SIZE[size]} />
140
+ </span>
141
+ )}
142
+ {hasUsableImage && (
143
+ <img
144
+ className={styles.image}
145
+ src={src}
146
+ alt=""
147
+ data-loaded={imgLoaded ? "true" : "false"}
148
+ onLoad={() => setImgLoaded(true)}
149
+ onError={() => setImgFailed(true)}
150
+ />
151
+ )}
152
+ {status && (
153
+ <span
154
+ className={styles.status}
155
+ data-status={status}
156
+ aria-label={status === "online" ? "접속중" : "미접속"}
157
+ />
158
+ )}
159
+ </span>
160
+ );
161
+ },
162
+ );
163
+
164
+ Avatar.displayName = "Avatar";
165
+
166
+ /* ── AvatarGroup ──────────────────────────────────────────
167
+ 여러 Avatar를 겹쳐 표시. max로 잘라내고 나머지는 "+N" 카운터로. */
168
+ export type AvatarGroupProps = HTMLAttributes<HTMLSpanElement> & {
169
+ size?: AvatarSize;
170
+ shape?: AvatarShape;
171
+ /** 넘치면 카운터로 접힘. */
172
+ max?: number;
173
+ children: ReactNode;
174
+ };
175
+
176
+ const AvatarGroup = forwardRef<HTMLSpanElement, AvatarGroupProps>(
177
+ ({ className, size = "md", shape = "circle", max, children, ...rest }, ref) => {
178
+ const all = Children.toArray(children).filter(isValidElement) as ReactElement<AvatarProps>[];
179
+ const visible = max !== undefined ? all.slice(0, max) : all;
180
+ const overflow = max !== undefined ? all.length - visible.length : 0;
181
+
182
+ const cls = [styles.group, sizeClass[size], className ?? ""]
183
+ .filter(Boolean)
184
+ .join(" ");
185
+
186
+ const total = visible.length + (overflow > 0 ? 1 : 0);
187
+
188
+ return (
189
+ <span ref={ref} className={cls} {...rest}>
190
+ {visible.map((child, i) =>
191
+ cloneElement(child, {
192
+ key: child.key ?? i,
193
+ size,
194
+ shape,
195
+ className: [styles.groupItem, child.props.className ?? ""].filter(Boolean).join(" "),
196
+ style: {
197
+ ...(child.props.style ?? {}),
198
+ ["--stack-order" as string]: total - i,
199
+ },
200
+ }),
201
+ )}
202
+ {overflow > 0 && (
203
+ <span
204
+ className={[
205
+ styles.root,
206
+ sizeClass[size],
207
+ shapeClass[shape],
208
+ styles.groupItem,
209
+ styles.overflow,
210
+ ]
211
+ .filter(Boolean)
212
+ .join(" ")}
213
+ role="img"
214
+ aria-label={`외 ${overflow}명`}
215
+ >
216
+ <span className={styles.initials} aria-hidden>
217
+ +{overflow}
218
+ </span>
219
+ </span>
220
+ )}
221
+ </span>
222
+ );
223
+ },
224
+ );
225
+
226
+ AvatarGroup.displayName = "AvatarGroup";
227
+
228
+ export { Avatar, AvatarGroup };
@@ -0,0 +1,2 @@
1
+ export { Avatar, AvatarGroup } from "./Avatar";
2
+ export type { AvatarProps, AvatarGroupProps, AvatarSize, AvatarShape, AvatarStatus } from "./Avatar";
@@ -0,0 +1,130 @@
1
+ /* AvatarUpload ────────────────────────────────────────────────
2
+ Avatar를 감싸는 폼 컨트롤. 표시 로직은 Avatar에 위임하고,
3
+ 여기선 파일 선택·삭제 인터랙션과 오버레이 UI만 담당.
4
+
5
+ 구조: <div>
6
+ <button.trigger> ← 클릭 시 <input type=file>
7
+ <Avatar />
8
+ <span.overlay /> ← hover 시 하단에 카메라
9
+ </button>
10
+ <button.remove /> ← src 있을 때만, 우상단 X
11
+ <input type=file .input /> ← 시각 숨김
12
+ </div> */
13
+ .root {
14
+ position: relative;
15
+ display: inline-flex;
16
+ flex-shrink: 0;
17
+ }
18
+
19
+ .trigger {
20
+ position: relative;
21
+ padding: 0;
22
+ border: 0;
23
+ background: transparent;
24
+ cursor: pointer;
25
+ overflow: hidden;
26
+ transition:
27
+ box-shadow var(--duration-fast) var(--ease-out);
28
+ }
29
+ /* trigger는 안쪽 Avatar와 border-radius를 맞춰야 focus ring이 정확한 모양으로 감쌈.
30
+ circle이면 full, rounded면 Avatar와 동일한 size별 radius. */
31
+ .circle .trigger { border-radius: var(--radius-full); }
32
+ .rounded.xs .trigger,
33
+ .rounded.sm .trigger { border-radius: var(--radius-xs); }
34
+ .rounded.md .trigger { border-radius: var(--radius-sm); }
35
+ .rounded.lg .trigger { border-radius: var(--radius-md); }
36
+ .rounded.xl .trigger { border-radius: var(--radius-lg); }
37
+ .trigger:focus-visible {
38
+ outline: none;
39
+ box-shadow: var(--ring-shadow);
40
+ }
41
+ .trigger:disabled {
42
+ cursor: not-allowed;
43
+ }
44
+
45
+ /* Overlay ─────────────────────────────────────────────────
46
+ 하단 반쪽에만 오버레이. 기본 숨김, hover/focus에서 등장.
47
+ translateY로 slide-up 애니 — 즉시 등장보다 프로필 사진을 덜 가리는 인상.
48
+ scrim/scrim-fg 토큰으로 mode 무관 다크 표면. */
49
+ .overlay {
50
+ position: absolute;
51
+ left: 0;
52
+ right: 0;
53
+ bottom: 0;
54
+ height: 50%;
55
+ display: inline-flex;
56
+ align-items: center;
57
+ justify-content: center;
58
+ background: var(--scrim);
59
+ color: var(--scrim-fg);
60
+ opacity: 0;
61
+ transform: translateY(20%);
62
+ pointer-events: none;
63
+ transition:
64
+ opacity var(--duration-base) var(--ease-out),
65
+ transform var(--duration-base) var(--ease-out);
66
+ }
67
+ .trigger:hover .overlay,
68
+ .trigger:focus-visible .overlay {
69
+ opacity: 1;
70
+ transform: translateY(0);
71
+ }
72
+
73
+ /* Remove button ───────────────────────────────────────────
74
+ 우상단. X. Avatar 원 바깥으로 살짝 튀어나오게 배치해서
75
+ "부착된 액션"임을 명확히. z-docked로 오버레이보다 위. */
76
+ .remove {
77
+ position: absolute;
78
+ top: 0;
79
+ right: 0;
80
+ display: inline-flex;
81
+ align-items: center;
82
+ justify-content: center;
83
+ padding: 0;
84
+ border: 1px solid var(--border);
85
+ background: var(--bg);
86
+ color: var(--fg);
87
+ border-radius: var(--radius-full);
88
+ cursor: pointer;
89
+ transition:
90
+ border-color var(--duration-fast) var(--ease-out),
91
+ box-shadow var(--duration-fast) var(--ease-out);
92
+ z-index: var(--z-docked);
93
+ }
94
+ .remove:hover:not(:disabled) {
95
+ border-color: var(--border-strong);
96
+ box-shadow: var(--shadow-sm);
97
+ }
98
+ .remove:focus-visible {
99
+ outline: none;
100
+ box-shadow: var(--ring-shadow);
101
+ }
102
+ .remove:disabled {
103
+ cursor: not-allowed;
104
+ opacity: 0.5;
105
+ }
106
+
107
+ /* Remove 크기 · offset은 Avatar size별로 스왑. 원 바깥으로 살짝 튀어나오도록. */
108
+ .xs .remove { width: var(--space-3); height: var(--space-3); transform: translate(25%, -25%); }
109
+ .sm .remove { width: var(--space-4); height: var(--space-4); transform: translate(25%, -25%); }
110
+ .md .remove { width: var(--space-4); height: var(--space-4); transform: translate(25%, -25%); }
111
+ .lg .remove { width: var(--space-5); height: var(--space-5); transform: translate(20%, -20%); }
112
+ .xl .remove { width: var(--space-6); height: var(--space-6); transform: translate(15%, -15%); }
113
+
114
+ /* Hidden native input — 표준 visually-hidden 유틸을 클래스로 인라인 */
115
+ .input {
116
+ position: absolute;
117
+ width: var(--space-0_5); /* 시각 완전 숨김 — 실제 크기는 무의미 */
118
+ height: var(--space-0_5);
119
+ padding: 0;
120
+ overflow: hidden;
121
+ clip: rect(0, 0, 0, 0);
122
+ white-space: nowrap;
123
+ border: 0;
124
+ opacity: 0;
125
+ pointer-events: none;
126
+ }
127
+
128
+ /* Disabled ─────────────────────────────────────────────── */
129
+ .disabled .trigger { opacity: 0.5; }
130
+ .disabled .overlay { display: none; }