@conscience-technology/ctds 0.4.4 → 0.4.6

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.
@@ -0,0 +1,396 @@
1
+ "use client";
2
+
3
+ import {
4
+ cloneElement,
5
+ createContext,
6
+ forwardRef,
7
+ isValidElement,
8
+ useCallback,
9
+ useContext,
10
+ useEffect,
11
+ useId,
12
+ useMemo,
13
+ useRef,
14
+ useState,
15
+ type CSSProperties,
16
+ type HTMLAttributes,
17
+ type KeyboardEvent,
18
+ type MouseEvent,
19
+ type ReactElement,
20
+ type ReactNode,
21
+ } from "react";
22
+
23
+ import { Icon } from "../Icon/Icon";
24
+ import styles from "./TabStrip.module.css";
25
+
26
+ export type TabStripSize = "sm" | "md";
27
+ export type TabStripVariant = "ghost" | "soft" | "segmented";
28
+
29
+ type TabStripContextValue = {
30
+ value: string | undefined;
31
+ setValue: (v: string) => void;
32
+ size: TabStripSize;
33
+ variant: TabStripVariant;
34
+ baseId: string;
35
+ registerTab: (value: string, node: HTMLDivElement | null) => void;
36
+ focusNext: (currentValue: string, dir: 1 | -1) => void;
37
+ focusEdge: (edge: "first" | "last") => void;
38
+ };
39
+
40
+ const TabStripContext = createContext<TabStripContextValue | null>(null);
41
+
42
+ function useTabStripContext(component: string): TabStripContextValue {
43
+ const ctx = useContext(TabStripContext);
44
+ if (!ctx) {
45
+ throw new Error(`${component} must be rendered inside <TabStrip>`);
46
+ }
47
+ return ctx;
48
+ }
49
+
50
+ const ICON_SIZE: Record<TabStripSize, number> = {
51
+ sm: 14,
52
+ md: 16,
53
+ };
54
+
55
+ const CLOSE_ICON_SIZE: Record<TabStripSize, number> = {
56
+ sm: 12,
57
+ md: 14,
58
+ };
59
+
60
+ function sizedIcon(node: ReactNode, size: number): ReactNode {
61
+ if (!isValidElement(node)) return node;
62
+ const el = node as ReactElement<{ size?: number | string }>;
63
+ if (el.props.size !== undefined) return el;
64
+ return cloneElement(el, { size });
65
+ }
66
+
67
+ // ────────────────────────────────────────────────────────────
68
+ // Root
69
+ // ────────────────────────────────────────────────────────────
70
+
71
+ export interface TabStripProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
72
+ size?: TabStripSize;
73
+ /** 시각 variant.
74
+ * - `ghost` (default): 투명 + hover·active에서 bg-muted. 가장 가볍고 shell chrome 기본.
75
+ * - `soft`: 연한 bg 상시 (버튼처럼 모양이 잡혀 있고, 선택이 조금 더 진함).
76
+ * - `segmented`: 브라우저처럼 탭 사이 세로 hairline으로 칸 구분. */
77
+ variant?: TabStripVariant;
78
+ /** controlled */
79
+ value?: string;
80
+ /** uncontrolled 초기값 */
81
+ defaultValue?: string;
82
+ onValueChange?: (value: string) => void;
83
+ /** 탭 묶음 오른쪽 slot. "+" 버튼, "..." 더보기, 또는 아무것도 안 넣을 수 있음.
84
+ * TabStrip hover·focus-within 시에만 자연스럽게 fade-in. */
85
+ trailing?: ReactNode;
86
+ children: ReactNode;
87
+ }
88
+
89
+ const Root = forwardRef<HTMLDivElement, TabStripProps>(
90
+ (
91
+ {
92
+ className,
93
+ size = "md",
94
+ variant = "ghost",
95
+ value: valueProp,
96
+ defaultValue,
97
+ onValueChange,
98
+ trailing,
99
+ children,
100
+ ...rest
101
+ },
102
+ ref,
103
+ ) => {
104
+ const baseId = useId();
105
+ const [uncontrolled, setUncontrolled] = useState<string | undefined>(defaultValue);
106
+ const isControlled = valueProp !== undefined;
107
+ const value = isControlled ? valueProp : uncontrolled;
108
+
109
+ const tabsRef = useRef<Map<string, HTMLDivElement>>(new Map());
110
+
111
+ const registerTab = useCallback((v: string, node: HTMLDivElement | null) => {
112
+ if (node) tabsRef.current.set(v, node);
113
+ else tabsRef.current.delete(v);
114
+ }, []);
115
+
116
+ const setValue = useCallback(
117
+ (v: string) => {
118
+ if (!isControlled) setUncontrolled(v);
119
+ onValueChange?.(v);
120
+ },
121
+ [isControlled, onValueChange],
122
+ );
123
+
124
+ const orderedTabs = useCallback(() => {
125
+ const nodes = Array.from(tabsRef.current.values());
126
+ nodes.sort((a, b) =>
127
+ a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
128
+ );
129
+ return nodes;
130
+ }, []);
131
+
132
+ const focusNext = useCallback(
133
+ (currentValue: string, dir: 1 | -1) => {
134
+ const nodes = orderedTabs();
135
+ if (nodes.length === 0) return;
136
+ const idx = nodes.findIndex((n) => n.dataset.value === currentValue);
137
+ if (idx === -1) return;
138
+ const nextIdx = (idx + dir + nodes.length) % nodes.length;
139
+ nodes[nextIdx].focus();
140
+ },
141
+ [orderedTabs],
142
+ );
143
+
144
+ const focusEdge = useCallback(
145
+ (edge: "first" | "last") => {
146
+ const nodes = orderedTabs();
147
+ if (nodes.length === 0) return;
148
+ (edge === "first" ? nodes[0] : nodes[nodes.length - 1]).focus();
149
+ },
150
+ [orderedTabs],
151
+ );
152
+
153
+ const ctx = useMemo<TabStripContextValue>(
154
+ () => ({ value, setValue, size, variant, baseId, registerTab, focusNext, focusEdge }),
155
+ [value, setValue, size, variant, baseId, registerTab, focusNext, focusEdge],
156
+ );
157
+
158
+ const rootClass = [
159
+ styles.root,
160
+ styles[`size_${size}`],
161
+ styles[`variant_${variant}`],
162
+ className ?? "",
163
+ ]
164
+ .filter(Boolean)
165
+ .join(" ");
166
+
167
+ return (
168
+ <TabStripContext.Provider value={ctx}>
169
+ <div
170
+ ref={ref}
171
+ className={rootClass}
172
+ data-size={size}
173
+ data-variant={variant}
174
+ role="tablist"
175
+ aria-orientation="horizontal"
176
+ {...rest}
177
+ >
178
+ <div className={styles.track}>{children}</div>
179
+ {trailing && <div className={styles.trailing}>{trailing}</div>}
180
+ </div>
181
+ </TabStripContext.Provider>
182
+ );
183
+ },
184
+ );
185
+ Root.displayName = "TabStrip";
186
+
187
+ // ────────────────────────────────────────────────────────────
188
+ // Tab
189
+ // ────────────────────────────────────────────────────────────
190
+
191
+ export interface TabStripTabProps
192
+ extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "onClick"> {
193
+ value: string;
194
+ /** 탭 왼쪽에 들어가는 요소. Icon, 사진(<img>), svg 뭐든 ReactNode. */
195
+ leadingIcon?: ReactNode;
196
+ /** true면 hover 시 x 버튼 노출, 클릭 시 onClose 호출. */
197
+ closable?: boolean;
198
+ onClose?: () => void;
199
+ /** 탭 라벨 클릭. */
200
+ onSelect?: () => void;
201
+ /** 닫기 버튼 aria-label (기본 "탭 닫기"). */
202
+ closeLabel?: string;
203
+ disabled?: boolean;
204
+ }
205
+
206
+ const Tab = forwardRef<HTMLDivElement, TabStripTabProps>(
207
+ (
208
+ {
209
+ className,
210
+ value,
211
+ leadingIcon,
212
+ closable,
213
+ onClose,
214
+ onSelect,
215
+ closeLabel = "탭 닫기",
216
+ disabled,
217
+ children,
218
+ onKeyDown,
219
+ ...rest
220
+ },
221
+ ref,
222
+ ) => {
223
+ const {
224
+ value: selected,
225
+ setValue,
226
+ size,
227
+ baseId,
228
+ registerTab,
229
+ focusNext,
230
+ focusEdge,
231
+ } = useTabStripContext("TabStrip.Tab");
232
+ const isActive = selected === value;
233
+
234
+ const setRef = useCallback(
235
+ (node: HTMLDivElement | null) => {
236
+ registerTab(value, node);
237
+ if (typeof ref === "function") ref(node);
238
+ else if (ref) (ref as { current: HTMLDivElement | null }).current = node;
239
+ },
240
+ [ref, registerTab, value],
241
+ );
242
+
243
+ const handleClick = () => {
244
+ if (disabled) return;
245
+ setValue(value);
246
+ onSelect?.();
247
+ };
248
+
249
+ const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
250
+ onKeyDown?.(e);
251
+ if (e.defaultPrevented) return;
252
+ switch (e.key) {
253
+ case "ArrowRight":
254
+ e.preventDefault();
255
+ focusNext(value, 1);
256
+ break;
257
+ case "ArrowLeft":
258
+ e.preventDefault();
259
+ focusNext(value, -1);
260
+ break;
261
+ case "Home":
262
+ e.preventDefault();
263
+ focusEdge("first");
264
+ break;
265
+ case "End":
266
+ e.preventDefault();
267
+ focusEdge("last");
268
+ break;
269
+ case "Enter":
270
+ case " ":
271
+ e.preventDefault();
272
+ handleClick();
273
+ break;
274
+ }
275
+ };
276
+
277
+ const handleCloseClick = (e: MouseEvent<HTMLButtonElement>) => {
278
+ e.stopPropagation();
279
+ onClose?.();
280
+ };
281
+
282
+ // Marquee loop (전광판) — 라벨 "본체 + gap + 복제본" 중 본체 폭만큼 왼쪽으로
283
+ // 흐르면 복제본이 자연스럽게 translateX(0) 자리에 와있어 끊김 없이 반복된다.
284
+ // 측정: 본체(.labelPrimary)의 offsetWidth + gap(--space-6 ≈ 24px, 토큰 반영).
285
+ // 짧은 라벨은 overflow 안 되므로 isOverflow=false → animation 미적용.
286
+ //
287
+ // 속도: 라벨 폭에 비례해 ~60px/sec. 긴 라벨도 꼴보기 좋게.
288
+ const labelOuterRef = useRef<HTMLSpanElement | null>(null);
289
+ const labelPrimaryRef = useRef<HTMLSpanElement | null>(null);
290
+ const [marquee, setMarquee] = useState<{
291
+ shift: number;
292
+ duration: string;
293
+ overflow: boolean;
294
+ } | null>(null);
295
+
296
+ useEffect(() => {
297
+ const outer = labelOuterRef.current;
298
+ const primary = labelPrimaryRef.current;
299
+ if (!outer || !primary) return;
300
+ const measure = () => {
301
+ const primaryWidth = primary.offsetWidth;
302
+ const outerWidth = outer.clientWidth;
303
+ const overflow = primaryWidth > outerWidth + 1; // 서브픽셀 여유
304
+ if (!overflow) {
305
+ setMarquee({ shift: 0, duration: "0ms", overflow: false });
306
+ return;
307
+ }
308
+ // gap은 CSS .labelDupe의 padding-inline-start(--space-6)와 맞춤 (≈24px).
309
+ const gap = 24;
310
+ const shift = primaryWidth + gap;
311
+ // 60px/sec 흐름. 1초당 60px 이동.
312
+ const duration = Math.round((shift / 60) * 1000);
313
+ setMarquee({ shift, duration: `${duration}ms`, overflow: true });
314
+ };
315
+ measure();
316
+ const ro = new ResizeObserver(measure);
317
+ ro.observe(outer);
318
+ ro.observe(primary);
319
+ return () => ro.disconnect();
320
+ }, [children]);
321
+
322
+ const tabStyle: CSSProperties | undefined = marquee?.overflow
323
+ ? ({
324
+ "--marquee-shift": `${marquee.shift}`,
325
+ "--marquee-duration": marquee.duration,
326
+ "--marquee-delay": "500ms",
327
+ } as CSSProperties)
328
+ : undefined;
329
+
330
+ const cls = [styles.tab, className ?? ""].filter(Boolean).join(" ");
331
+
332
+ return (
333
+ <div
334
+ ref={setRef}
335
+ className={cls}
336
+ role="tab"
337
+ id={`${baseId}-tab-${value}`}
338
+ aria-selected={isActive}
339
+ aria-disabled={disabled || undefined}
340
+ tabIndex={isActive ? 0 : -1}
341
+ data-value={value}
342
+ data-state={isActive ? "active" : "inactive"}
343
+ data-disabled={disabled ? "true" : undefined}
344
+ data-closable={closable ? "true" : undefined}
345
+ data-overflow={marquee?.overflow ? "true" : undefined}
346
+ style={tabStyle}
347
+ onClick={handleClick}
348
+ onKeyDown={handleKeyDown}
349
+ {...rest}
350
+ >
351
+ {leadingIcon && (
352
+ <span className={styles.leading} aria-hidden>
353
+ {sizedIcon(leadingIcon, ICON_SIZE[size])}
354
+ </span>
355
+ )}
356
+ <span ref={labelOuterRef} className={styles.label}>
357
+ <span className={styles.labelInner}>
358
+ <span ref={labelPrimaryRef} className={styles.labelPrimary}>
359
+ {children}
360
+ </span>
361
+ <span className={styles.labelDupe} aria-hidden>
362
+ {children}
363
+ </span>
364
+ </span>
365
+ </span>
366
+ <span className={styles.labelFade} aria-hidden />
367
+ {closable && (
368
+ <>
369
+ <span className={styles.closeFade} aria-hidden />
370
+ <button
371
+ type="button"
372
+ tabIndex={-1}
373
+ className={styles.closeButton}
374
+ aria-label={closeLabel}
375
+ onClick={handleCloseClick}
376
+ >
377
+ <Icon name="x" size={CLOSE_ICON_SIZE[size]} />
378
+ </button>
379
+ </>
380
+ )}
381
+ </div>
382
+ );
383
+ },
384
+ );
385
+ Tab.displayName = "TabStrip.Tab";
386
+
387
+ // ────────────────────────────────────────────────────────────
388
+
389
+ type TabStripCompound = typeof Root & {
390
+ Tab: typeof Tab;
391
+ };
392
+
393
+ const TabStrip = Root as TabStripCompound;
394
+ TabStrip.Tab = Tab;
395
+
396
+ export { TabStrip };
@@ -0,0 +1,7 @@
1
+ export { TabStrip } from "./TabStrip";
2
+ export type {
3
+ TabStripProps,
4
+ TabStripTabProps,
5
+ TabStripSize,
6
+ TabStripVariant,
7
+ } from "./TabStrip";
@@ -7,18 +7,36 @@
7
7
  기본 24 → 28. 아이콘 자체 크기는 그대로 두고 주변 여유만 확장해서
8
8
  클릭 영역이 넉넉해지도록. */
9
9
  .root {
10
- --iconbtn-h-md: var(--control-h-sm);
11
10
  display: inline-flex;
12
11
  align-items: center;
13
12
  gap: var(--space-1);
14
- min-height: var(--control-h-md);
15
13
  width: max-content;
16
- padding-block: var(--space-1_5);
17
- padding-inline: var(--space-2);
18
14
  color: var(--fg);
19
15
  box-sizing: border-box;
20
16
  }
21
17
 
18
+ /* Size ──────────────────────────────────────────────
19
+ 두 사이즈 모두 아이콘 18(16 + 2)로 통일하고 히트박스만 다르게 — 간격을 좁혀서
20
+ md가 커 보이고 sm이 작아 보이는 체감 문제 완화.
21
+ md — 히트 30 · 아이콘 18 (32·20보다 한 스텝 다운)
22
+ sm — 히트 28 · 아이콘 18 (히트만 2 작음)
23
+ scale에 없는 중간값(30·28·18)은 토큰 조합으로 합성 — Density 축에 비례. */
24
+ .root[data-size="md"] {
25
+ --iconbtn-h-md: calc(var(--control-h-sm) - var(--space-0_5));
26
+ --icon-size-sm: calc(var(--icon-size-md) - var(--space-0_5));
27
+ min-height: calc(var(--control-h-md) - var(--space-0_5));
28
+ padding-block: var(--space-1_5);
29
+ padding-inline: var(--space-2);
30
+ }
31
+ .root[data-size="sm"] {
32
+ --iconbtn-h-md: calc(var(--control-h-xs) + var(--space-1));
33
+ --icon-size-sm: calc(var(--icon-size-md) - var(--space-0_5));
34
+ min-height: calc(var(--control-h-xs) + var(--space-1));
35
+ padding-block: var(--space-1);
36
+ padding-inline: var(--space-1_5);
37
+ gap: var(--space-0_5);
38
+ }
39
+
22
40
  /* stretch=true : 콘텐츠 폭이 아니라 부모 폭을 꽉 채움 (attached의 기본값). */
23
41
  .root[data-stretch] {
24
42
  display: flex;
@@ -28,14 +46,23 @@
28
46
  /* Variants ──────────────────────────────────────────── */
29
47
 
30
48
  /* floating — 떠 있는 알약.
31
- bg-elevated + 얕은 그림자 + 큰 radius. 콘텐츠 위에 살짝 떠 있는 느낌.
32
- 무게감은 hairline이 쥐고, 그림자는 분리감만 더한다. */
49
+ bg-elevated + 얕은 그림자 + Shape 축에 또렷하게 반응하는 radius.
50
+ 무게감은 hairline이 쥐고, 그림자는 분리감만 더한다.
51
+ Shape 축별 체감 폭을 크게 — crisp는 각지게(sm 4px), standard는 중간(lg 12px),
52
+ soft는 알약(full). scale 하나로 묶으면 axis별 체감이 평평해지는 자리라
53
+ data-shape 선택자로 Toolbar만 로컬 override. */
33
54
  .root[data-variant="floating"] {
34
55
  background: var(--bg-elevated, var(--bg));
35
56
  border: var(--hairline) solid var(--border);
36
- border-radius: var(--radius-xl);
57
+ border-radius: var(--radius-lg);
37
58
  box-shadow: var(--shadow-sm);
38
59
  }
60
+ [data-shape="crisp"] .root[data-variant="floating"] {
61
+ border-radius: var(--radius-sm);
62
+ }
63
+ [data-shape="soft"] .root[data-variant="floating"] {
64
+ border-radius: var(--radius-2xl);
65
+ }
39
66
 
40
67
  /* attached — 콘텐츠에 붙은 bar.
41
68
  자체 bg 없음 + 하단 hairline만. 에디터 상단·문서 chrome. */
@@ -6,20 +6,25 @@ import { cn } from "../_cn";
6
6
  import styles from "./Toolbar.module.css";
7
7
 
8
8
  /* Root ─────────────────────────────────────────────────
9
- 툴바 — IconButton 중심의 action 줄. 단일 size.
9
+ 툴바 — IconButton 중심의 action 줄.
10
10
  variant:
11
11
  - floating : 떠 있는 알약(bg-elevated + shadow-md + radius-xl)
12
- - attached : 콘텐츠에 붙은 bar (transparent + 하단 hairline) */
12
+ - attached : 콘텐츠에 붙은 bar (transparent + 하단 hairline)
13
+ size:
14
+ - md(기본) : 히트박스 32 · 아이콘 20 — 표준 자리
15
+ - sm : 히트박스 24 · 아이콘 16 — 밀도 승부처(사이드바 바닥·인라인 bar) */
13
16
  export type ToolbarVariant = "floating" | "attached";
17
+ export type ToolbarSize = "sm" | "md";
14
18
 
15
19
  export interface ToolbarProps extends HTMLAttributes<HTMLElement> {
16
20
  variant?: ToolbarVariant;
21
+ size?: ToolbarSize;
17
22
  /** 가로 꽉 채울지(attached에 어울림) vs 콘텐츠 폭(floating). 기본은 variant로 자동 결정. */
18
23
  stretch?: boolean;
19
24
  }
20
25
 
21
26
  const ToolbarRoot = forwardRef<HTMLDivElement, ToolbarProps>(function ToolbarRoot(
22
- { variant = "floating", stretch, className, children, role = "toolbar", ...rest },
27
+ { variant = "floating", size = "md", stretch, className, children, role = "toolbar", ...rest },
23
28
  ref,
24
29
  ) {
25
30
  const resolvedStretch = stretch ?? variant === "attached";
@@ -29,6 +34,7 @@ const ToolbarRoot = forwardRef<HTMLDivElement, ToolbarProps>(function ToolbarRoo
29
34
  role={role}
30
35
  className={cn(styles.root, className)}
31
36
  data-variant={variant}
37
+ data-size={size}
32
38
  data-stretch={resolvedStretch || undefined}
33
39
  {...rest}
34
40
  >
@@ -1,2 +1,2 @@
1
1
  export { Toolbar } from "./Toolbar";
2
- export type { ToolbarProps, ToolbarVariant } from "./Toolbar";
2
+ export type { ToolbarProps, ToolbarVariant, ToolbarSize } from "./Toolbar";
@@ -546,6 +546,7 @@ export const componentSlugs: Record<string, string> = {
546
546
  "Date Picker": "input/date-picker",
547
547
  "Time Picker": "input/time-picker",
548
548
  Tabs: "nav/tabs",
549
+ "Tab Strip": "nav/tab-strip",
549
550
  Breadcrumb: "nav/breadcrumb",
550
551
  Sidebar: "nav/sidebar",
551
552
  "Sidebar Split": "nav/sidebar-split",
@@ -632,7 +633,7 @@ export const components: { id: string; title: string; items: string[] }[] = [
632
633
  id: "nav",
633
634
  title: "Navigation",
634
635
  items: [
635
- "App Frame", "Page Header", "Tabs", "Breadcrumb", "Sidebar", "Sidebar Split", "Topbar", "Identity Button", "Pagination",
636
+ "App Frame", "Page Header", "Tabs", "Tab Strip", "Breadcrumb", "Sidebar", "Sidebar Split", "Topbar", "Identity Button", "Pagination",
636
637
  "Stepper",
637
638
  ],
638
639
  },
@@ -70,6 +70,7 @@ export * from "./TableRow";
70
70
  export * from "./TableRowEdit";
71
71
  export * from "./TableRowSelect";
72
72
  export * from "./Tabs";
73
+ export * from "./TabStrip";
73
74
  export * from "./Textarea";
74
75
  export * from "./TimePicker";
75
76
  export * from "./Toast";
@@ -214,6 +214,7 @@ page (gap: space-10) ← 그룹 사이 넓게
214
214
  조립할 때 사람·AI 모두 자주 미끄러지는 자리. 안티패턴 메모리와 쌍을 이룬다.
215
215
 
216
216
  - **"페이지 맨 위에 Tabs만"** — Tabs 위에 PageHeader(h1/breadcrumb)가 먼저 와야 한다 ([antipattern-tabs-without-title])
217
+ - **"브라우저·노션처럼 다중 탭" 요청에 Tabs로 조립** — Tabs는 섹션 전환 전용(한 화면 안 뷰 토글). "열려있는 문서 N개 중 하나 선택"(각 탭 = 라우트, 추가/닫기)은 전부 [[components/tab-strip]]. Chrome·Figma·VSCode·노션 탭 UI 요청은 즉시 TabStrip으로. 수제 조립·Tabs 변형 금지
217
218
  - **"워크스페이스 스위처를 수제 Button+Dropdown으로 조립"** — 전용 IdentityButton + DropdownMenu 사용 ([antipattern-workspace-switcher])
218
219
  - **"Topbar leading에만 요소 몰림"** — leading/center/trailing 3구역 균형. leading·trailing 짝 필수 ([[components/topbar]])
219
220
  - **"Sidebar 2단 쉘을 SidebarSplit으로"** — 바깥 flex로 Rail+Sidebar 직접 조립 ([feedback-sidebar-scroll])
@@ -16,11 +16,18 @@ AI 채팅의 입력 영역. **첨부물 바 → 인용 바 → 입력 본문**(t
16
16
  - 첨부·인용·메시지 상태를 ChatInput 안에서 관리하지 않는다 — controlled 전용.
17
17
  - 모델 선택 메뉴를 내부에서 열지 않는다 — 현재는 라벨 클릭 시 다음 모델로 토글만. 실제 ContextMenu는 소비처에서 조립 (ChatInput의 책임 과잉 방지).
18
18
  - SingleLine에서 모델 라벨을 끝까지 노출하지 않는다 — 10자 넘으면 자동 compact(아이콘만).
19
- - Send 버튼 색을 변경하지 않는다 — 비활성=`bg-subtle` / 활성·스트리밍=`primary`로 고정.
19
+ - Send 버튼 색을 임의로 override하지 않는다 — 톤 조정은 `sendVariant` prop(`solid` | `ghost` | `soft`)으로만. 각 variant 안에서 비활성→활성 색 전환은 고정 규약.
20
20
  - `flush`를 떠있는 자리(페이지 중앙·오버레이)에 쓰지 않는다 — 외곽선 없이 공중에 뜨면 영역이 흐려짐. 반드시 shell 바닥·바텀바처럼 바깥 경계가 이미 존재하는 자리에만.
21
21
 
22
+ ### Send 버튼 variant
23
+ - `sendVariant="solid"` *(기본)* — 비활성=`bg-subtle` / 활성·스트리밍=`primary` + 흰 아이콘. ChatGPT·Claude 류 떠있는 네모 버튼.
24
+ - `sendVariant="ghost"` — 배경 투명, 아이콘만. 비활성=`fg-disabled` / 활성=`fg-strong`. hover에서만 `bg-subtle` 살짝. 흰 캔버스에 녹아드는 깔끔한 자리(가벼운 질문창·검색봉·툴 안쪽 작은 입력).
25
+ - `sendVariant="soft"` — 늘 `bg-subtle` 배경, 활성이어도 색 강조 없이 fg만 진해짐. 조용한 자리(사이드바 바닥 flush, 패널 바텀바)에서 send 존재감을 낮추고 싶을 때.
26
+ - `sendIcon="arrow-up"` *(기본)* — 전송 뉘앙스, 범용. `"return"`(↵) — 폼 submit·키보드 힌트. `"paper-plane"` — 메신저 느낌(iMessage/카톡 류). 스트리밍 중엔 아이콘 선택과 무관하게 자동으로 square로 교체.
27
+ - 셋 다 활성 상태의 **아이콘 색 규약**은 고정 — ghost·soft는 fg-strong, solid만 흰색. 임의로 primary 색 아이콘 넣지 않는다.
28
+
22
29
  ### Fallback
23
- shape 애매하면 `multi`. variant 애매하면 `framed`. maxRows 애매하면 `8`.
30
+ shape 애매하면 `multi`. variant 애매하면 `framed`. sendVariant 애매하면 `solid`. sendIcon 애매하면 `arrow-up`. maxRows 애매하면 `8`.
24
31
 
25
32
  ### 관련
26
33
  - [[components/file-icon]] / [[components/attachment-thumb]] / [[components/attachment-item]] / [[components/quotation-bar]] — 조립 요소
@@ -51,6 +58,9 @@ shape 애매하면 `multi`. variant 애매하면 `framed`. maxRows 애매하면
51
58
  selectedModel={string}
52
59
  onSelectModel={(id) => void}
53
60
 
61
+ sendVariant="solid | ghost | soft" // default: solid
62
+ sendIcon="arrow-up | return | paper-plane" // default: arrow-up
63
+
54
64
  placeholder="Ask me anything…"
55
65
  disabled={boolean}
56
66
  maxRows={number} // multi 전용, default 8