@conscience-technology/ctds 0.1.9 → 0.1.11

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 (77) hide show
  1. package/assets/components/AIMessage/AIMessage.module.css +129 -0
  2. package/assets/components/AIMessage/AIMessage.tsx +104 -0
  3. package/assets/components/AIMessage/index.ts +2 -0
  4. package/assets/components/AttachmentChip/AttachmentChip.module.css +3 -1
  5. package/assets/components/AttachmentThumb/AttachmentThumb.module.css +13 -7
  6. package/assets/components/AttachmentThumb/AttachmentThumb.tsx +4 -1
  7. package/assets/components/Bubble/Bubble.module.css +65 -0
  8. package/assets/components/Bubble/Bubble.tsx +63 -0
  9. package/assets/components/Bubble/index.ts +2 -0
  10. package/assets/components/Button/Button.module.css +15 -2
  11. package/assets/components/Button/Button.tsx +13 -0
  12. package/assets/components/Calendar/Calendar.module.css +163 -0
  13. package/assets/components/Calendar/Calendar.tsx +323 -0
  14. package/assets/components/Calendar/index.ts +2 -0
  15. package/assets/components/ChatInput/ChatInput.module.css +82 -45
  16. package/assets/components/ChatInput/ChatInput.tsx +46 -34
  17. package/assets/components/ChatInput/ModelSelector.tsx +23 -27
  18. package/assets/components/ChatInput/index.ts +0 -1
  19. package/assets/components/CodeBlock/CodeBlock.module.css +79 -0
  20. package/assets/components/CodeBlock/CodeBlock.tsx +71 -0
  21. package/assets/components/CodeBlock/index.ts +2 -0
  22. package/assets/components/ContextMenu/ContextMenu.tsx +10 -1
  23. package/assets/components/DropdownMenu/DropdownMenu.module.css +26 -0
  24. package/assets/components/DropdownMenu/DropdownMenu.tsx +367 -0
  25. package/assets/components/DropdownMenu/index.ts +9 -0
  26. package/assets/components/EventChip/EventChip.module.css +57 -0
  27. package/assets/components/EventChip/EventChip.tsx +41 -0
  28. package/assets/components/EventChip/index.ts +2 -0
  29. package/assets/components/Icon/Icon.tsx +3 -0
  30. package/assets/components/IdentityButton/IdentityButton.module.css +119 -0
  31. package/assets/components/IdentityButton/IdentityButton.tsx +113 -0
  32. package/assets/components/IdentityButton/index.ts +6 -0
  33. package/assets/components/MenuItem/MenuItem.module.css +67 -2
  34. package/assets/components/MenuItem/MenuItem.tsx +22 -2
  35. package/assets/components/MessageActions/MessageActions.module.css +43 -0
  36. package/assets/components/MessageActions/MessageActions.tsx +94 -0
  37. package/assets/components/MessageActions/index.ts +6 -0
  38. package/assets/components/MessageQuote/MessageQuote.module.css +29 -0
  39. package/assets/components/MessageQuote/MessageQuote.tsx +33 -0
  40. package/assets/components/MessageQuote/index.ts +2 -0
  41. package/assets/components/MessageSources/MessageSources.module.css +58 -0
  42. package/assets/components/MessageSources/MessageSources.tsx +64 -0
  43. package/assets/components/MessageSources/index.ts +2 -0
  44. package/assets/components/MessageTable/MessageTable.module.css +59 -0
  45. package/assets/components/MessageTable/MessageTable.tsx +21 -0
  46. package/assets/components/MessageTable/index.ts +2 -0
  47. package/assets/components/QuotationBar/QuotationBar.module.css +2 -2
  48. package/assets/components/Sidebar/Sidebar.module.css +3 -57
  49. package/assets/components/Sidebar/Sidebar.tsx +18 -21
  50. package/assets/components/Spinner/LottiePlayer.tsx +82 -0
  51. package/assets/components/Spinner/Spinner.module.css +170 -0
  52. package/assets/components/Spinner/Spinner.tsx +117 -0
  53. package/assets/components/Spinner/index.ts +10 -0
  54. package/assets/components/Spinner/lottieColor.ts +106 -0
  55. package/assets/components/Spinner/lottieManifest.ts +100 -0
  56. package/assets/components/Tabs/Tabs.module.css +3 -3
  57. package/assets/components/UserMessage/UserMessage.module.css +41 -0
  58. package/assets/components/UserMessage/UserMessage.tsx +66 -0
  59. package/assets/components/UserMessage/index.ts +2 -0
  60. package/assets/components/_data.ts +18 -4
  61. package/assets/components/tokens.css +20 -0
  62. package/assets/prompts/components/ai-message.md +45 -0
  63. package/assets/prompts/components/bubble.md +56 -0
  64. package/assets/prompts/components/calendar.md +116 -0
  65. package/assets/prompts/components/code-block.md +35 -0
  66. package/assets/prompts/components/dropdown-menu.md +145 -0
  67. package/assets/prompts/components/event-chip.md +74 -0
  68. package/assets/prompts/components/identity-button.md +168 -0
  69. package/assets/prompts/components/message-actions.md +49 -0
  70. package/assets/prompts/components/message-quote.md +26 -0
  71. package/assets/prompts/components/message-sources.md +33 -0
  72. package/assets/prompts/components/message-table.md +27 -0
  73. package/assets/prompts/components/sidebar.md +48 -3
  74. package/assets/prompts/components/spinner.md +109 -0
  75. package/assets/prompts/components/user-message.md +45 -0
  76. package/assets/prompts/primitives/scroll.md +22 -11
  77. package/package.json +1 -1
@@ -0,0 +1,9 @@
1
+ export { DropdownMenu } from "./DropdownMenu";
2
+ export type {
3
+ ContentProps as DropdownMenuContentProps,
4
+ SubContentProps as DropdownMenuSubContentProps,
5
+ ItemProps as DropdownMenuItemProps,
6
+ CheckboxItemProps as DropdownMenuCheckboxItemProps,
7
+ RadioItemProps as DropdownMenuRadioItemProps,
8
+ SubTriggerProps as DropdownMenuSubTriggerProps,
9
+ } from "./DropdownMenu";
@@ -0,0 +1,57 @@
1
+ /* EventChip ──────────────────────────────────────────────────
2
+ 캘린더 셀 안에서 "하루에 쌓이는 일정 하나"를 표현하는 작은 블록.
3
+ Chip/Badge와 결은 비슷하지만 역할이 다름 — 셀 안에 세로로 여러 개가 쌓이는
4
+ 정보 토큰. 가로 폭은 부모(cell)에 꽉 차고, 넘치는 title은 ellipsis. */
5
+ .root {
6
+ display: flex;
7
+ align-items: center;
8
+ gap: var(--space-1);
9
+ min-width: 0;
10
+ padding: var(--space-1) var(--space-1_5);
11
+ border-radius: var(--radius-xs);
12
+ background: var(--bg-muted);
13
+ color: var(--fg);
14
+ font-family: var(--font-normal);
15
+ font-size: var(--text-xs);
16
+ line-height: 1.2;
17
+ }
18
+
19
+ .root[data-faded] { opacity: 0.5; }
20
+
21
+ .title {
22
+ min-width: 0;
23
+ flex: 1 1 0;
24
+ overflow: hidden;
25
+ text-overflow: ellipsis;
26
+ white-space: nowrap;
27
+ }
28
+
29
+ /* Tones — Badge soft와 동일 토큰 체계. 색은 의미 신호로만 쓴다. */
30
+ .root[data-tone="default"] {
31
+ background: var(--bg-muted);
32
+ color: var(--fg);
33
+ }
34
+ .root[data-tone="neutral"] {
35
+ background: var(--secondary-bg);
36
+ color: var(--secondary-fg);
37
+ }
38
+ .root[data-tone="accent"] {
39
+ background: var(--primary-subtle);
40
+ color: var(--secondary-fg);
41
+ }
42
+ .root[data-tone="success"] {
43
+ background: var(--success-subtle);
44
+ color: var(--success);
45
+ }
46
+ .root[data-tone="warning"] {
47
+ background: var(--warning-subtle);
48
+ color: var(--warning);
49
+ }
50
+ .root[data-tone="danger"] {
51
+ background: var(--destructive-subtle);
52
+ color: var(--destructive);
53
+ }
54
+ .root[data-tone="info"] {
55
+ background: var(--info-subtle);
56
+ color: var(--info);
57
+ }
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type HTMLAttributes } from "react";
4
+
5
+ import styles from "./EventChip.module.css";
6
+
7
+ export type EventChipTone =
8
+ | "default"
9
+ | "accent"
10
+ | "success"
11
+ | "warning"
12
+ | "danger"
13
+ | "info"
14
+ | "neutral";
15
+
16
+ export type EventChipProps = Omit<HTMLAttributes<HTMLDivElement>, "title"> & {
17
+ title: string;
18
+ tone?: EventChipTone;
19
+ /** outside 월·과거 등으로 흐리게 표시. */
20
+ faded?: boolean;
21
+ };
22
+
23
+ const EventChip = forwardRef<HTMLDivElement, EventChipProps>(function EventChip(
24
+ { className, title, tone = "default", faded, ...rest },
25
+ ref,
26
+ ) {
27
+ const cls = [styles.root, className].filter(Boolean).join(" ");
28
+ return (
29
+ <div
30
+ ref={ref}
31
+ className={cls}
32
+ data-tone={tone}
33
+ data-faded={faded || undefined}
34
+ {...rest}
35
+ >
36
+ <span className={styles.title}>{title}</span>
37
+ </div>
38
+ );
39
+ });
40
+
41
+ export { EventChip };
@@ -0,0 +1,2 @@
1
+ export { EventChip } from "./EventChip";
2
+ export type { EventChipProps, EventChipTone } from "./EventChip";
@@ -78,6 +78,7 @@ import {
78
78
  CalendarDots as PhCalendarDots,
79
79
  Clock as PhClock,
80
80
  UploadSimple as PhUploadSimple,
81
+ DownloadSimple as PhDownloadSimple,
81
82
  IdentificationCard as PhIdentificationCard,
82
83
  Table as PhTable,
83
84
  GridFour as PhGridFour,
@@ -258,6 +259,7 @@ export type IconName =
258
259
  | "calendar-dots"
259
260
  | "clock"
260
261
  | "upload-simple"
262
+ | "download-simple"
261
263
  | "identification-card"
262
264
  | "table"
263
265
  | "grid-four"
@@ -370,6 +372,7 @@ const phosphorSet: Partial<Record<IconName, AnyComp>> = {
370
372
  "calendar-dots": PhCalendarDots,
371
373
  clock: PhClock,
372
374
  "upload-simple": PhUploadSimple,
375
+ "download-simple": PhDownloadSimple,
373
376
  "identification-card": PhIdentificationCard,
374
377
  table: PhTable,
375
378
  "grid-four": PhGridFour,
@@ -0,0 +1,119 @@
1
+ /* IdentityButton — 식별 가능한 대상(워크스페이스·프로필·프로젝트·조직 등)을
2
+ "현재 선택 상태"로 보여주면서 전환 가능함을 암시하는 가로 행.
3
+ Avatar/leading + 2줄 라벨(name + sub) + trailing(caret/IconButton) 구조. */
4
+
5
+ .root {
6
+ display: inline-flex;
7
+ align-items: center;
8
+ gap: var(--space-2);
9
+ min-width: 0;
10
+ width: 100%;
11
+ padding: var(--space-1) var(--space-2);
12
+ border: 0;
13
+ background: transparent;
14
+ color: var(--fg);
15
+ cursor: pointer;
16
+ text-align: start;
17
+ font-family: var(--label-font);
18
+ border-radius: var(--radius-sm);
19
+ transition: var(--transition-colors);
20
+ }
21
+
22
+ .root:disabled {
23
+ cursor: not-allowed;
24
+ opacity: 0.5;
25
+ }
26
+
27
+ /* size — control-h 토큰에 space-1(4px)만큼 숨돌림 추가.
28
+ Avatar + 2줄 라벨이 상하에 꽉 차 보이지 않도록 아주 살짝 여유. Density scale은 그대로 따라감. */
29
+ .size-sm { min-height: calc(var(--control-h-sm) + var(--space-1)); }
30
+ .size-md { min-height: calc(var(--control-h-md) + var(--space-1)); }
31
+ .size-lg { min-height: calc(var(--control-h-lg) + var(--space-1)); }
32
+
33
+ /* 1줄 모드(sub 없음) — 2줄 기준 min-height가 과하게 보이지 않고
34
+ 오히려 라벨이 가운데 떠서 상하 여백이 부족해 보이는 걸 방지. padding-block으로 숨돌림. */
35
+ .root[data-single-line] {
36
+ padding-block: var(--space-2);
37
+ }
38
+
39
+ /* variant — 기본은 ghost(투명 bg, hover만). muted는 항상 subtle bg. */
40
+ .variant-ghost {
41
+ background: transparent;
42
+ }
43
+ .variant-ghost:hover:not(:disabled) {
44
+ background: var(--hover);
45
+ }
46
+ .variant-ghost:focus-visible {
47
+ outline: none;
48
+ background: var(--hover);
49
+ }
50
+
51
+ .variant-muted {
52
+ background: var(--hover);
53
+ }
54
+ .variant-muted:hover:not(:disabled) {
55
+ background: var(--bg-muted);
56
+ }
57
+ .variant-muted:focus-visible {
58
+ outline: none;
59
+ background: var(--bg-muted);
60
+ }
61
+
62
+ /* active — 현재 선택 (스위처 리스트에서). hover가 덮지 않도록 bg 우선. */
63
+ .root[data-active] {
64
+ background: var(--bg-muted);
65
+ }
66
+ .root[data-active] .name {
67
+ color: var(--fg-strong);
68
+ font-weight: var(--font-weight-medium);
69
+ }
70
+
71
+ /* ── 슬롯 ───────────────────────────────────────────── */
72
+ .leading {
73
+ display: inline-flex;
74
+ align-items: center;
75
+ flex: 0 0 auto;
76
+ }
77
+
78
+ .labels {
79
+ display: flex;
80
+ flex-direction: column;
81
+ justify-content: center;
82
+ min-width: 0;
83
+ flex: 1 1 auto;
84
+ }
85
+
86
+ .name {
87
+ font-size: var(--label-sm-size);
88
+ letter-spacing: var(--label-tracking);
89
+ line-height: var(--lh-xs);
90
+ font-weight: var(--font-weight-medium);
91
+ color: var(--fg);
92
+ white-space: nowrap;
93
+ overflow: hidden;
94
+ text-overflow: ellipsis;
95
+ }
96
+
97
+ .sub {
98
+ font-size: var(--text-xs);
99
+ line-height: var(--lh-xs);
100
+ font-weight: var(--font-weight-regular);
101
+ color: var(--fg-muted);
102
+ white-space: nowrap;
103
+ overflow: hidden;
104
+ text-overflow: ellipsis;
105
+ }
106
+
107
+ .trailing {
108
+ display: inline-flex;
109
+ align-items: center;
110
+ flex: 0 0 auto;
111
+ color: var(--fg-muted);
112
+ }
113
+
114
+ /* ── collapsed — leading 하나만 ─────────────────────── */
115
+ .root[data-collapsed] {
116
+ justify-content: center;
117
+ gap: 0;
118
+ padding-inline: var(--space-1);
119
+ }
@@ -0,0 +1,113 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type ReactNode } from "react";
4
+ import { Slot } from "@radix-ui/react-slot";
5
+
6
+ import { Avatar, type AvatarProps } from "../Avatar";
7
+ import styles from "./IdentityButton.module.css";
8
+
9
+ export type IdentityButtonSize = "sm" | "md" | "lg";
10
+ export type IdentityButtonVariant = "ghost" | "muted";
11
+
12
+ export interface IdentityButtonProps {
13
+ size?: IdentityButtonSize;
14
+ variant?: IdentityButtonVariant;
15
+ /** 왼쪽 식별 요소. Avatar props를 패스스루하거나 ReactNode(Icon 등) 직접 삽입. 둘 다 생략 가능. */
16
+ avatar?: Omit<AvatarProps, "size" | "name" | "alt"> & { name?: string; alt?: string };
17
+ leading?: ReactNode;
18
+ /** 1줄 라벨. 메인 식별자. */
19
+ name: ReactNode;
20
+ /** 2번째 줄 — 메타·역할·설명. 생략 시 1줄 모드. */
21
+ sub?: ReactNode;
22
+ /** 오른쪽 trailing — caret-down, IconButton, Badge 등. collapsed일 땐 숨김. */
23
+ trailing?: ReactNode;
24
+ /** 선택 상태 (switcher 리스트의 현재 선택 등). */
25
+ active?: boolean;
26
+ disabled?: boolean;
27
+ /** icon-only 모드. leading/avatar만 남기고 라벨·sub·trailing 숨김. Sidebar가 자동 주입. */
28
+ collapsed?: boolean;
29
+ /** Radix Slot — DropdownMenu.Trigger 등 상위 트리거에 위임. */
30
+ asChild?: boolean;
31
+ className?: string;
32
+ onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
33
+ "aria-label"?: string;
34
+ "aria-haspopup"?: boolean | "menu" | "dialog" | "listbox" | "tree" | "grid";
35
+ "aria-expanded"?: boolean;
36
+ }
37
+
38
+ const sizeToAvatar: Record<IdentityButtonSize, AvatarProps["size"]> = {
39
+ sm: "sm",
40
+ md: "md",
41
+ lg: "md",
42
+ };
43
+
44
+ const IdentityButton = forwardRef<HTMLButtonElement, IdentityButtonProps>(
45
+ function IdentityButton(
46
+ {
47
+ size = "md",
48
+ variant = "ghost",
49
+ avatar,
50
+ leading,
51
+ name,
52
+ sub,
53
+ trailing,
54
+ active,
55
+ disabled,
56
+ collapsed,
57
+ asChild,
58
+ className,
59
+ onClick,
60
+ ...a11y
61
+ },
62
+ ref,
63
+ ) {
64
+ const Comp: React.ElementType = asChild ? Slot : "button";
65
+
66
+ const classes = [
67
+ styles.root,
68
+ styles[`size-${size}`],
69
+ styles[`variant-${variant}`],
70
+ className ?? "",
71
+ ]
72
+ .filter(Boolean)
73
+ .join(" ");
74
+
75
+ const leadingNode =
76
+ leading ??
77
+ (avatar ? (
78
+ <Avatar
79
+ size={sizeToAvatar[size]}
80
+ name={typeof name === "string" ? name : undefined}
81
+ alt={typeof name === "string" ? name : undefined}
82
+ {...avatar}
83
+ />
84
+ ) : null);
85
+
86
+ return (
87
+ <Comp
88
+ ref={ref}
89
+ type={asChild ? undefined : "button"}
90
+ className={classes}
91
+ data-active={active || undefined}
92
+ data-collapsed={collapsed || undefined}
93
+ data-single-line={!sub || undefined}
94
+ disabled={disabled}
95
+ onClick={onClick}
96
+ {...a11y}
97
+ >
98
+ {leadingNode && <span className={styles.leading}>{leadingNode}</span>}
99
+ {!collapsed && (
100
+ <span className={styles.labels}>
101
+ <span className={styles.name}>{name}</span>
102
+ {sub && <span className={styles.sub}>{sub}</span>}
103
+ </span>
104
+ )}
105
+ {!collapsed && trailing && (
106
+ <span className={styles.trailing}>{trailing}</span>
107
+ )}
108
+ </Comp>
109
+ );
110
+ },
111
+ );
112
+
113
+ export { IdentityButton };
@@ -0,0 +1,6 @@
1
+ export { IdentityButton } from "./IdentityButton";
2
+ export type {
3
+ IdentityButtonProps,
4
+ IdentityButtonSize,
5
+ IdentityButtonVariant,
6
+ } from "./IdentityButton";
@@ -151,16 +151,37 @@
151
151
  flex-shrink: 0;
152
152
  }
153
153
 
154
- /* 아이콘(svg 직접 자식) 또는 IconButton(button 자식)이 들어온 슬롯 → 아이콘 크기 정사각.
155
- → Icon만 넣은 슬롯과 IconButton 슬롯의 아이콘 중심선이 자동 정렬됨. */
154
+ /* 시각 자리(svg · img · picture · IconButton)가 들어온 슬롯은 --menu-icon-size 정사각으로
155
+ 수렴시킨다. 이렇게 해야 사용자가 Icon 외에 커스텀 svg · 브랜드 로고 PNG · Avatar 등
156
+ 무엇을 넣어도 라인 높이와 좌측 정렬이 어긋나지 않는다 — 슬롯의 계약은 "아이콘 자리".
157
+ 내부 콘텐츠는 아래 object-fit 규칙으로 비율 유지된 채 슬롯에 꽉 채워진다. */
156
158
  .leadingItem:has(> svg),
157
159
  .trailingItem:has(> svg),
160
+ .leadingItem:has(> img),
161
+ .trailingItem:has(> img),
162
+ .leadingItem:has(> picture),
163
+ .trailingItem:has(> picture),
158
164
  .leadingItem:has(> button),
159
165
  .trailingItem:has(> button) {
160
166
  width: var(--menu-icon-size);
161
167
  height: var(--menu-icon-size);
162
168
  }
163
169
 
170
+ /* img · picture · 그리고 그 내부 img 는 슬롯에 꽉 채우되 비율 보존.
171
+ svg는 Icon 컴포넌트가 자체 width/height를 가지므로 손대지 않는다 — 손대면
172
+ stroke 두께가 틀어진다. */
173
+ .leadingItem > img,
174
+ .trailingItem > img,
175
+ .leadingItem > picture,
176
+ .trailingItem > picture,
177
+ .leadingItem > picture > img,
178
+ .trailingItem > picture > img {
179
+ width: 100%;
180
+ height: 100%;
181
+ object-fit: cover;
182
+ display: block;
183
+ }
184
+
164
185
  /* IconButton(button)은 --menu-hitbox-size 크기로 삐져나가되 negative margin으로
165
186
  부모 슬롯의 시각 폭은 --menu-icon-size로 유지, hitbox만 되찾음.
166
187
  IconButton 자체가 --control-h-sm(28) 크기로 오는 걸 MenuItem 안에선 hitbox 토큰으로
@@ -188,6 +209,50 @@
188
209
  text-overflow: ellipsis;
189
210
  }
190
211
 
212
+ /* 2줄 레이아웃 — description prop이 있을 때만 활성.
213
+ title + description을 하나의 flex column으로 묶어 그리드의 가운데 열에 자리잡는다.
214
+ 줄바꿈을 허용(white-space normal)해 긴 설명도 자연스럽게 흐르도록. */
215
+ .labelStack {
216
+ display: flex;
217
+ flex-direction: column;
218
+ min-width: 0;
219
+ gap: var(--space-0_5);
220
+ white-space: normal;
221
+ overflow: hidden;
222
+ }
223
+
224
+ .labelStack .label {
225
+ color: inherit;
226
+ }
227
+
228
+ .description {
229
+ color: var(--fg-subtle);
230
+ font-size: var(--text-xs);
231
+ font-weight: var(--font-weight-regular);
232
+ line-height: var(--lh-tight);
233
+ letter-spacing: var(--label-tracking);
234
+ min-width: 0;
235
+ overflow: hidden;
236
+ text-overflow: ellipsis;
237
+ }
238
+
239
+ /* 2줄 모드: leading/trailing 슬롯은 첫 줄(title) 베이스라인에 맞춰 상단 정렬.
240
+ root가 grid라 align-items: start로 바꾸면 전체 열이 모두 상단 정렬되고,
241
+ label 묶음은 column이라 자연히 위에서부터 쌓이므로 title과 아이콘이 수평 정렬된다.
242
+ trailing(shortcut 등)도 title 라인에 같이 올라붙어 2줄 중 어느 쪽도 아닌 이상한 중앙에
243
+ 걸리지 않는다. */
244
+ .root.hasDescription {
245
+ align-items: start;
246
+ }
247
+
248
+ /* 아이콘·IconButton이 title 라인에 정확히 정렬되도록 슬롯 높이를 title 라인 높이에 맞춘다.
249
+ label은 inline-flex라 자체 line-box를 가진다 — 그 라인에 아이콘 중심을 맞추기 위해
250
+ 슬롯 자체는 그대로 두되 align-items 변화로 상단 기준이 되므로 자연스럽게 맞음.
251
+ 추가로 수직 패딩을 살짝 얹어 2줄 블록이 너무 빡빡해 보이지 않게 한다. */
252
+ .root.hasDescription {
253
+ padding-block: var(--space-1_5);
254
+ }
255
+
191
256
  /* Shortcut ────────────────────────────────────────────────────
192
257
  trailing 슬롯에 shortcut prop이 넘어오면 별도 Shortcut 컴포넌트가 렌더링됨.
193
258
  슬롯 정사각 규칙(.trailingItem:has(> svg | > button))에 걸리지 않으므로
@@ -46,6 +46,12 @@ export type MenuItemProps = Omit<HTMLAttributes<HTMLDivElement>, "onClick"> & {
46
46
 
47
47
  /** 오른쪽 끝 단축키 텍스트 (예: "⌘K"). 내부에서 trailingActions 맨 앞에 자동 삽입. */
48
48
  shortcut?: string;
49
+ /**
50
+ * 라벨 아래 보조 설명 한 줄. 지정 시 아이템이 2줄 레이아웃이 되고
51
+ * leading 아이콘은 첫 줄 베이스라인에 맞춰 상단 정렬된다.
52
+ * GPT/Claude/Gemini 선택처럼 모델·옵션에 짧은 특징을 덧붙이는 자리.
53
+ */
54
+ description?: ReactNode;
49
55
  /** true면 오른쪽 끝에 caret-right 인디케이터. 내부에서 trailingActions 맨 뒤에 자동 삽입. */
50
56
  hasSubmenu?: boolean;
51
57
 
@@ -281,6 +287,7 @@ const MenuItem = forwardRef<HTMLDivElement, MenuItemProps>(
281
287
  leadingActions = [],
282
288
  trailingActions = [],
283
289
  shortcut,
290
+ description,
284
291
  hasSubmenu = false,
285
292
  checked = false,
286
293
  active = false,
@@ -352,12 +359,16 @@ const MenuItem = forwardRef<HTMLDivElement, MenuItemProps>(
352
359
  const hasLeading = finalLeading.length > 0;
353
360
  const hasTrailing = finalTrailing.length > 0;
354
361
 
362
+ /* description은 collapsed 상태에선 의미 없음 — 아이콘만 남는 자리에 보조 설명은 숨긴다. */
363
+ const hasDescription = !effectiveCollapsed && description != null && description !== false;
364
+
355
365
  const rootClass = [
356
366
  styles.root,
357
367
  sizeClass[size],
358
368
  variantClass[variant],
359
369
  active ? styles.active : "",
360
370
  hasLeading ? styles.hasLeading : "",
371
+ hasDescription ? styles.hasDescription : "",
361
372
  className ?? "",
362
373
  ]
363
374
  .filter(Boolean)
@@ -427,10 +438,19 @@ const MenuItem = forwardRef<HTMLDivElement, MenuItemProps>(
427
438
  )}
428
439
  {asChild ? (
429
440
  /* collapsed면 사용자의 children(라벨 텍스트)은 접어두고 leading만 남긴다.
430
- asChild(예: <Link>Home</Link>)에서도 링크는 유지되며 내용만 아이콘으로 대체. */
441
+ asChild(예: <Link>Home</Link>)에서도 링크는 유지되며 내용만 아이콘으로 대체.
442
+ asChild는 Link 등으로 전체를 감싸는 패턴이라 description은 지원하지 않는다. */
431
443
  <Slottable>{effectiveCollapsed ? null : children}</Slottable>
432
444
  ) : (
433
- !effectiveCollapsed && <span className={styles.label}>{children}</span>
445
+ !effectiveCollapsed &&
446
+ (hasDescription ? (
447
+ <span className={styles.labelStack}>
448
+ <span className={styles.label}>{children}</span>
449
+ <span className={styles.description}>{description}</span>
450
+ </span>
451
+ ) : (
452
+ <span className={styles.label}>{children}</span>
453
+ ))
434
454
  )}
435
455
  {hasTrailing && (
436
456
  <span className={styles.trailing}>
@@ -0,0 +1,43 @@
1
+ /* MessageActions — 메시지 하단 액션 아이콘 바.
2
+ UserMessage·AIMessage·MessageState 공용. fg-muted 톤, hover 시 fg-strong. */
3
+
4
+ .root {
5
+ display: inline-flex;
6
+ align-items: center;
7
+ gap: var(--space-1);
8
+ }
9
+ .root[data-align="end"] {
10
+ justify-content: flex-end;
11
+ }
12
+ .root[data-align="start"] {
13
+ justify-content: flex-start;
14
+ }
15
+
16
+ .btn {
17
+ display: inline-flex;
18
+ align-items: center;
19
+ justify-content: center;
20
+ padding: 0;
21
+ border: 0;
22
+ background: transparent;
23
+ color: var(--fg-muted);
24
+ border-radius: var(--radius-sm);
25
+ cursor: pointer;
26
+ width: var(--space-6);
27
+ height: var(--space-6);
28
+ flex: 0 0 auto;
29
+ transition: var(--transition-colors);
30
+ }
31
+ .btn:hover {
32
+ background: var(--bg-subtle);
33
+ color: var(--fg-strong);
34
+ }
35
+ .btn:focus-visible {
36
+ outline: none;
37
+ box-shadow: var(--ring);
38
+ }
39
+
40
+ .icon {
41
+ width: 65%;
42
+ height: 65%;
43
+ }
@@ -0,0 +1,94 @@
1
+ "use client";
2
+
3
+ import type { HTMLAttributes, MouseEvent, ReactNode } from "react";
4
+ import {
5
+ Copy as PhCopy,
6
+ PencilSimple as PhPencil,
7
+ ArrowClockwise as PhArrowClockwise,
8
+ ShareNetwork as PhShare,
9
+ TrashSimple as PhTrash,
10
+ ThumbsUp as PhThumbsUp,
11
+ ThumbsDown as PhThumbsDown,
12
+ } from "@phosphor-icons/react";
13
+ import { Tooltip } from "@/components/ctds/Tooltip";
14
+ import styles from "./MessageActions.module.css";
15
+
16
+ export type MessageActionKind =
17
+ | "copy"
18
+ | "edit"
19
+ | "regenerate"
20
+ | "share"
21
+ | "delete"
22
+ | "like"
23
+ | "dislike";
24
+
25
+ const ICON: Record<MessageActionKind, React.ComponentType<any>> = {
26
+ copy: PhCopy,
27
+ edit: PhPencil,
28
+ regenerate: PhArrowClockwise,
29
+ share: PhShare,
30
+ delete: PhTrash,
31
+ like: PhThumbsUp,
32
+ dislike: PhThumbsDown,
33
+ };
34
+
35
+ const LABEL: Record<MessageActionKind, string> = {
36
+ copy: "복사",
37
+ edit: "수정",
38
+ regenerate: "다시 생성",
39
+ share: "공유",
40
+ delete: "삭제",
41
+ like: "좋아요",
42
+ dislike: "싫어요",
43
+ };
44
+
45
+ export interface MessageAction {
46
+ kind: MessageActionKind;
47
+ onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
48
+ /** label override. 기본은 kind에 매핑된 한국어. */
49
+ label?: string;
50
+ /** 커스텀 아이콘 — kind가 알 수 없는 액션일 때. */
51
+ icon?: ReactNode;
52
+ }
53
+
54
+ export interface MessageActionsProps extends HTMLAttributes<HTMLDivElement> {
55
+ actions: MessageAction[];
56
+ /** 메시지 아래 왼/오른 정렬. UserMessage=end, AIMessage=start. */
57
+ align?: "start" | "end";
58
+ }
59
+
60
+ export function MessageActions({
61
+ actions,
62
+ align = "start",
63
+ className,
64
+ ...rest
65
+ }: MessageActionsProps) {
66
+ const cls = [styles.root, className].filter(Boolean).join(" ");
67
+
68
+ return (
69
+ <div
70
+ className={cls}
71
+ data-align={align}
72
+ role="toolbar"
73
+ aria-label="메시지 액션"
74
+ {...rest}
75
+ >
76
+ {actions.map((a, i) => {
77
+ const Icon = ICON[a.kind] ?? null;
78
+ const label = a.label ?? LABEL[a.kind] ?? a.kind;
79
+ return (
80
+ <Tooltip key={`${a.kind}-${i}`} content={label}>
81
+ <button
82
+ type="button"
83
+ className={styles.btn}
84
+ onClick={a.onClick}
85
+ aria-label={label}
86
+ >
87
+ {a.icon ?? (Icon ? <Icon className={styles.icon} weight="regular" aria-hidden /> : null)}
88
+ </button>
89
+ </Tooltip>
90
+ );
91
+ })}
92
+ </div>
93
+ );
94
+ }
@@ -0,0 +1,6 @@
1
+ export { MessageActions } from "./MessageActions";
2
+ export type {
3
+ MessageActionsProps,
4
+ MessageAction,
5
+ MessageActionKind,
6
+ } from "./MessageActions";