@conscience-technology/ctds 0.4.4 → 0.4.5

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.
@@ -125,7 +125,7 @@
125
125
  box-sizing: border-box;
126
126
  }
127
127
  .multiField::placeholder {
128
- color: var(--fg-muted);
128
+ color: var(--fg-subtle);
129
129
  }
130
130
  /* attachments/quotation 아래에선 상단 padding 압축 (이미 attachments/quotation이 상단 책임). */
131
131
  .attachments + .body .multiField,
@@ -200,7 +200,7 @@
200
200
  box-sizing: border-box;
201
201
  }
202
202
  .singleField::placeholder {
203
- color: var(--fg-muted);
203
+ color: var(--fg-subtle);
204
204
  }
205
205
 
206
206
  /* Attach 버튼 */
@@ -287,14 +287,15 @@
287
287
  padding: var(--space-1_5);
288
288
  }
289
289
 
290
- /* Send — Shape 축 반응. Button sm과 동일한 radius. */
290
+ /* Send — Shape 축 반응. Button sm과 동일한 radius.
291
+ variant는 solid(기본)/ghost/soft 세 톤. 비활성→활성 전이는 색만 바뀜. */
291
292
  .sendBtn {
292
293
  display: inline-flex;
293
294
  align-items: center;
294
295
  justify-content: center;
295
296
  padding: 0;
296
297
  border: 0;
297
- background: var(--bg-subtle);
298
+ background: transparent;
298
299
  color: var(--fg-muted);
299
300
  border-radius: var(--radius-sm);
300
301
  cursor: pointer;
@@ -303,13 +304,6 @@
303
304
  flex: 0 0 auto;
304
305
  transition: var(--transition-colors);
305
306
  }
306
- .sendBtn[data-active] {
307
- background: var(--primary);
308
- color: var(--bg);
309
- }
310
- .sendBtn:hover:not(:disabled) {
311
- opacity: 0.9;
312
- }
313
307
  .sendBtn:focus-visible {
314
308
  outline: none;
315
309
  box-shadow: var(--ring);
@@ -317,15 +311,61 @@
317
311
  .sendBtn:disabled {
318
312
  cursor: not-allowed;
319
313
  }
320
- /* 비활성(= 입력 비어있어 보낼 수 없음) 상태 아이콘을 연하게.
321
- data-active일 땐 primary 배경이라 색 유지. */
322
- .sendBtn:not([data-active]) {
314
+
315
+ /* solid — 현재 기본. 비활성=bg-subtle, 활성=primary + 흰 아이콘. */
316
+ .sendBtn[data-variant="solid"] {
317
+ background: var(--bg-subtle);
318
+ }
319
+ .sendBtn[data-variant="solid"]:not([data-active]) {
323
320
  color: var(--fg-disabled);
324
321
  }
322
+ .sendBtn[data-variant="solid"][data-active] {
323
+ background: var(--primary);
324
+ color: var(--bg);
325
+ }
326
+ .sendBtn[data-variant="solid"]:hover:not(:disabled) {
327
+ opacity: 0.9;
328
+ }
329
+
330
+ /* ghost — 투명. 아이콘만 보임. 비활성=fg-disabled, 활성=fg-strong.
331
+ hover에서만 bg-subtle이 살짝 들어와 클릭 가능성 신호. */
332
+ .sendBtn[data-variant="ghost"] {
333
+ background: transparent;
334
+ }
335
+ .sendBtn[data-variant="ghost"]:not([data-active]) {
336
+ color: var(--fg-disabled);
337
+ }
338
+ .sendBtn[data-variant="ghost"][data-active] {
339
+ color: var(--fg-strong);
340
+ }
341
+ .sendBtn[data-variant="ghost"]:hover:not(:disabled) {
342
+ background: var(--bg-subtle);
343
+ }
344
+
345
+ /* soft — 늘 은은한 bg-subtle. 활성이어도 색 강조 없이 fg만 진해짐.
346
+ 조용한 자리(사이드바 바닥 flush 등)에서 send 존재감을 낮추고 싶을 때. */
347
+ .sendBtn[data-variant="soft"] {
348
+ background: var(--bg-subtle);
349
+ }
350
+ .sendBtn[data-variant="soft"]:not([data-active]) {
351
+ color: var(--fg-disabled);
352
+ }
353
+ .sendBtn[data-variant="soft"][data-active] {
354
+ color: var(--fg-strong);
355
+ }
356
+ .sendBtn[data-variant="soft"]:hover:not(:disabled) {
357
+ opacity: 0.9;
358
+ }
359
+
325
360
  .sendIcon {
326
361
  width: 44%;
327
362
  height: 44%;
328
363
  }
364
+ /* return(↵) 아이콘은 화살표가 작게 그려져 있어 조금 키워야 눈에 들어옴. */
365
+ .sendBtn[data-icon="return"] .sendIcon {
366
+ width: 60%;
367
+ height: 60%;
368
+ }
329
369
 
330
370
  /* 파일 input 숨김 */
331
371
  .hiddenFile {
@@ -17,11 +17,13 @@ import { AttachmentThumb } from "@/components/ctds/AttachmentThumb";
17
17
  import { DropdownMenu } from "@/components/ctds/DropdownMenu";
18
18
  import { QuotationBar } from "@/components/ctds/QuotationBar";
19
19
  import { ModelSelector } from "./ModelSelector";
20
- import { SendButton } from "./SendButton";
20
+ import { SendButton, type SendButtonIcon, type SendButtonVariant } from "./SendButton";
21
21
  import styles from "./ChatInput.module.css";
22
22
 
23
23
  export type ChatInputShape = "multi" | "single";
24
24
  export type ChatInputVariant = "framed" | "flush";
25
+ export type ChatInputSendVariant = SendButtonVariant;
26
+ export type ChatInputSendIcon = SendButtonIcon;
25
27
 
26
28
  /** 첨부 공용 타입 — ChatInput·Message 양쪽에서 재사용. */
27
29
  export interface ChatAttachmentImage {
@@ -72,6 +74,11 @@ export interface ChatInputProps {
72
74
  selectedModel?: string;
73
75
  onSelectModel?: (id: string) => void;
74
76
 
77
+ /** Send 버튼 톤. solid(기본)=primary bg + 흰 아이콘, ghost=투명·아이콘만, soft=bg-subtle 은은. */
78
+ sendVariant?: ChatInputSendVariant;
79
+ /** Send 아이콘 모양. 스트리밍 중엔 자동으로 square. */
80
+ sendIcon?: ChatInputSendIcon;
81
+
75
82
  placeholder?: string;
76
83
  disabled?: boolean;
77
84
  /** multiLine일 때 textarea max rows. 넘으면 세로 스크롤. */
@@ -101,6 +108,8 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
101
108
  models,
102
109
  selectedModel,
103
110
  onSelectModel,
111
+ sendVariant = "solid",
112
+ sendIcon = "arrow-up",
104
113
  placeholder = "Ask me anything…",
105
114
  disabled = false,
106
115
  maxRows = 8,
@@ -233,6 +242,8 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
233
242
  streaming={showStop}
234
243
  disabled={disabled && !showStop}
235
244
  onClick={handleSendClick}
245
+ variant={sendVariant}
246
+ icon={sendIcon}
236
247
  />
237
248
  </div>
238
249
  );
@@ -3,10 +3,15 @@
3
3
  import type { MouseEvent } from "react";
4
4
  import {
5
5
  ArrowUp as PhArrowUp,
6
+ ArrowElbowDownLeft as PhReturn,
7
+ PaperPlaneTilt as PhPaperPlane,
6
8
  Square as PhSquare,
7
9
  } from "@phosphor-icons/react";
8
10
  import styles from "./ChatInput.module.css";
9
11
 
12
+ export type SendButtonVariant = "solid" | "ghost" | "soft";
13
+ export type SendButtonIcon = "arrow-up" | "return" | "paper-plane";
14
+
10
15
  interface SendButtonProps {
11
16
  /** 입력에 텍스트/첨부가 있어 send 가능한지. */
12
17
  active: boolean;
@@ -14,6 +19,10 @@ interface SendButtonProps {
14
19
  streaming?: boolean;
15
20
  disabled?: boolean;
16
21
  onClick: (e: MouseEvent<HTMLButtonElement>) => void;
22
+ /** 시각 톤. solid=primary bg(기본), ghost=투명 아이콘만, soft=bg-subtle 은은 */
23
+ variant?: SendButtonVariant;
24
+ /** 전송 아이콘 모양. 스트리밍 중엔 자동으로 square로 교체 */
25
+ icon?: SendButtonIcon;
17
26
  }
18
27
 
19
28
  export function SendButton({
@@ -21,11 +30,19 @@ export function SendButton({
21
30
  streaming = false,
22
31
  disabled,
23
32
  onClick,
33
+ variant = "solid",
34
+ icon = "arrow-up",
24
35
  }: SendButtonProps) {
36
+ const SendIconCmp =
37
+ icon === "return" ? PhReturn : icon === "paper-plane" ? PhPaperPlane : PhArrowUp;
38
+ const iconWeight = icon === "return" ? "regular" : "bold";
39
+
25
40
  return (
26
41
  <button
27
42
  type="button"
28
43
  className={styles.sendBtn}
44
+ data-variant={variant}
45
+ data-icon={icon}
29
46
  data-active={active || streaming || undefined}
30
47
  disabled={disabled}
31
48
  onClick={onClick}
@@ -34,7 +51,7 @@ export function SendButton({
34
51
  {streaming ? (
35
52
  <PhSquare className={styles.sendIcon} weight="fill" />
36
53
  ) : (
37
- <PhArrowUp className={styles.sendIcon} weight="bold" />
54
+ <SendIconCmp className={styles.sendIcon} weight={iconWeight} />
38
55
  )}
39
56
  </button>
40
57
  );
@@ -3,6 +3,8 @@ export type {
3
3
  ChatInputProps,
4
4
  ChatInputShape,
5
5
  ChatInputVariant,
6
+ ChatInputSendVariant,
7
+ ChatInputSendIcon,
6
8
  ChatInputModel,
7
9
  ChatAttachment,
8
10
  ChatAttachmentImage,
@@ -0,0 +1,358 @@
1
+ /* TabStrip — 브라우저/문서 탭 (Chrome · Figma · VSCode 식).
2
+ 각 탭 = 독립 문서. 섹션 전환 Tabs와 역할 분리.
3
+
4
+ 활성 표시 = bg-muted 상시 배경 (pill 아님, hover 색이 그대로 lock).
5
+ 비활성 탭 = 투명 배경, hover 시 bg-muted.
6
+
7
+ 축 반응:
8
+ ─ Density → --control-h-* (탭 높이), --space-scale (gap/padding)
9
+ ─ Shape → 탭 radius (--radius-sm · soft에서도 과하지 않게)
10
+ ─ Motion → hover·active 전환 duration/easing
11
+ */
12
+
13
+ /* Root ─────────────────────────────────────────────────────── */
14
+ .root {
15
+ display: inline-flex;
16
+ align-items: center;
17
+ min-width: 0;
18
+ max-width: 100%;
19
+ gap: var(--space-0_5);
20
+ }
21
+
22
+ .track {
23
+ display: inline-flex;
24
+ align-items: center;
25
+ gap: var(--space-0_5);
26
+ min-width: 0;
27
+ overflow-x: auto;
28
+ scrollbar-width: none;
29
+ }
30
+ .track::-webkit-scrollbar {
31
+ display: none;
32
+ }
33
+
34
+ /* Sizes — Button·Tabs 계열과 동일한 --control-h-* 기반 */
35
+ .size_sm .tab { height: var(--control-h-sm); }
36
+ .size_md .tab { height: var(--control-h-md); }
37
+
38
+ /* Tab ──────────────────────────────────────────────────────── */
39
+ .tab {
40
+ position: relative;
41
+ display: inline-flex;
42
+ align-items: center;
43
+ gap: var(--space-1_5);
44
+ padding-inline: var(--space-2_5);
45
+ border-radius: var(--radius-sm);
46
+ color: var(--fg-muted);
47
+ background: transparent;
48
+ font-family: var(--label-font);
49
+ font-weight: var(--label-weight);
50
+ letter-spacing: var(--label-tracking);
51
+ line-height: var(--lh-tight);
52
+ white-space: nowrap;
53
+ cursor: pointer;
54
+ user-select: none;
55
+ flex-shrink: 0;
56
+ min-width: 0;
57
+ max-width: 20ch;
58
+ overflow: hidden;
59
+ transition:
60
+ background var(--duration-slow) var(--ease-out),
61
+ color var(--duration-slow) var(--ease-out),
62
+ box-shadow var(--duration-fast) var(--ease-out);
63
+ }
64
+ .size_sm .tab {
65
+ font-size: var(--label-sm-size);
66
+ padding-inline: var(--space-2);
67
+ gap: var(--space-1);
68
+ max-width: 20ch;
69
+ }
70
+ .size_md .tab { font-size: var(--label-md-size); }
71
+
72
+ .tab:focus-visible {
73
+ outline: none;
74
+ box-shadow: var(--ring-shadow);
75
+ position: relative;
76
+ z-index: var(--z-docked);
77
+ }
78
+ .tab[data-disabled="true"] {
79
+ cursor: not-allowed;
80
+ opacity: 0.5;
81
+ }
82
+ .tab:hover:not([data-disabled="true"]) {
83
+ background: var(--bg-muted);
84
+ color: var(--fg);
85
+ }
86
+ .tab[data-state="active"] {
87
+ background: var(--bg-muted);
88
+ color: var(--fg-strong);
89
+ }
90
+
91
+ /* Leading slot — Icon·사진·svg 전부 ReactNode로 허용. 톤만 조절. */
92
+ .leading {
93
+ display: inline-flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ flex-shrink: 0;
97
+ opacity: 0.75;
98
+ transition: opacity var(--duration-slow) var(--ease-out);
99
+ }
100
+ .leading > svg,
101
+ .leading > img {
102
+ display: block;
103
+ }
104
+ .tab:hover:not([data-disabled="true"]) .leading,
105
+ .tab[data-state="active"] .leading {
106
+ opacity: 1;
107
+ }
108
+
109
+ /* Label — overflow 숨김 컨테이너. 안쪽 .labelInner가 marquee 대상.
110
+ 오른쪽 edge fade는 closeFade(hover 시)와 labelFade(기본)가 담당. */
111
+ .label {
112
+ display: block;
113
+ min-width: 0;
114
+ flex: 1 1 auto;
115
+ overflow: hidden;
116
+ }
117
+
118
+ /* 기본 상태 (hover 아님)에서 라벨 오른쪽 edge를 bg로 자연스럽게 녹임.
119
+ closable·non-closable 모두 적용. 활성/hover가 되면 closeFade가 덮으니 거의 안 보임. */
120
+ .labelFade {
121
+ position: absolute;
122
+ top: 0;
123
+ bottom: 0;
124
+ right: 0;
125
+ width: var(--space-6);
126
+ pointer-events: none;
127
+ background: linear-gradient(to right, transparent 0%, var(--bg) 100%);
128
+ z-index: var(--z-base);
129
+ }
130
+ .tab[data-state="active"] .labelFade,
131
+ .tab:hover:not([data-disabled="true"]) .labelFade {
132
+ background: linear-gradient(to right, transparent 0%, var(--bg-muted) 100%);
133
+ }
134
+
135
+ .labelInner {
136
+ display: inline-flex;
137
+ align-items: center;
138
+ white-space: nowrap;
139
+ will-change: transform;
140
+ }
141
+
142
+ .labelPrimary {
143
+ display: inline-block;
144
+ white-space: nowrap;
145
+ }
146
+
147
+ /* Marquee — 전광판 스타일 loop.
148
+ 라벨이 overflow되는 탭은 .labelInner가 "라벨 + gap + 라벨" 복제본을 가진다
149
+ (React에서 data-overflow="true" 세팅 시 복제본이 visible). 첫 번째 라벨이
150
+ 왼쪽으로 완전히 빠지는 시점에 두 번째 라벨이 translateX(0) 자리에 자연스레
151
+ 이어 받아 끊김 없이 반복된다.
152
+
153
+ --marquee-shift (px 수치)와 --marquee-duration (CSS time)은 React에서
154
+ ResizeObserver로 측정해 inline style로 설정. 짧은 라벨은 overflow 안 되므로
155
+ data-overflow가 안 붙고, keyframe도 적용되지 않는다. */
156
+ .tab[data-overflow="true"]:hover:not([data-disabled="true"]) .labelInner {
157
+ animation: marquee-loop var(--marquee-duration, var(--duration-slower)) var(--ease-linear) var(--marquee-delay, var(--duration-slower)) infinite;
158
+ }
159
+ .tab:not(:hover) .labelInner,
160
+ .tab:not([data-overflow="true"]) .labelInner {
161
+ animation: none;
162
+ transform: translateX(0);
163
+ }
164
+
165
+ @keyframes marquee-loop {
166
+ 0% {
167
+ transform: translateX(0);
168
+ }
169
+ 100% {
170
+ transform: translateX(calc(var(--marquee-shift, 0) * -1px));
171
+ }
172
+ }
173
+
174
+ /* 복제본 — overflow 시에만 보이고, 평상시엔 숨겨 레이아웃·측정에 영향 없음. */
175
+ .labelDupe {
176
+ display: inline-block;
177
+ padding-inline-start: var(--space-6);
178
+ visibility: hidden;
179
+ }
180
+ .tab[data-overflow="true"] .labelDupe {
181
+ visibility: visible;
182
+ }
183
+
184
+ @media (prefers-reduced-motion: reduce) {
185
+ .tab[data-overflow="true"]:hover:not([data-disabled="true"]) .labelInner {
186
+ animation: none;
187
+ }
188
+ }
189
+
190
+ /* Close button — absolute로 오른쪽에 띄워 라벨 위를 그라데이션으로 덮으며 등장.
191
+ 활성·hover 아닐 땐 opacity 0, 그라데이션 fade도 opacity 0.
192
+
193
+ 폭 구성: 왼쪽은 fade 구간, 오른쪽은 x 뒤를 가려주는 solid 구간.
194
+ solid 구간이 없으면 x 뒤로 흐르는 라벨이 비쳐 보임. */
195
+ .closeFade {
196
+ position: absolute;
197
+ top: 0;
198
+ bottom: 0;
199
+ right: 0;
200
+ width: calc(var(--control-h-xs) + var(--space-6));
201
+ pointer-events: none;
202
+ background: linear-gradient(
203
+ to right,
204
+ transparent 0%,
205
+ var(--bg-muted) 50%,
206
+ var(--bg-muted) 100%
207
+ );
208
+ opacity: 0;
209
+ transition: opacity var(--duration-slow) var(--ease-in-out);
210
+ z-index: var(--z-raise);
211
+ }
212
+
213
+ .closeButton {
214
+ position: absolute;
215
+ top: 50%;
216
+ right: var(--space-1);
217
+ transform: translateY(-50%);
218
+ display: inline-flex;
219
+ align-items: center;
220
+ justify-content: center;
221
+ flex-shrink: 0;
222
+ width: var(--control-h-xs);
223
+ height: var(--control-h-xs);
224
+ padding: 0;
225
+ border: 0;
226
+ border-radius: var(--radius-xs);
227
+ background: transparent;
228
+ color: var(--fg-muted);
229
+ cursor: pointer;
230
+ opacity: 0;
231
+ transition:
232
+ background var(--duration-fast) var(--ease-out),
233
+ color var(--duration-fast) var(--ease-out),
234
+ opacity var(--duration-slow) var(--ease-in-out);
235
+ z-index: var(--z-docked);
236
+ }
237
+ .tab:hover:not([data-disabled="true"]) .closeButton,
238
+ .tab:focus-within .closeButton {
239
+ opacity: 1;
240
+ }
241
+ .tab:hover:not([data-disabled="true"]) .closeFade,
242
+ .tab:focus-within .closeFade {
243
+ opacity: 1;
244
+ }
245
+ .closeButton:hover {
246
+ background: var(--hover);
247
+ color: var(--fg-strong);
248
+ }
249
+ .closeButton:focus-visible {
250
+ outline: none;
251
+ opacity: 1;
252
+ box-shadow: var(--ring-shadow);
253
+ }
254
+
255
+ /* Trailing slot — track 바깥 오른쪽. 소비처가 ReactNode로 자유 조립 (+·...·없음).
256
+ TabStrip 전체 hover·focus-within에서만 fade-in. 공간은 늘 차지해 흔들림 없음. */
257
+ .trailing {
258
+ display: inline-flex;
259
+ align-items: center;
260
+ flex-shrink: 0;
261
+ opacity: 0;
262
+ transition: opacity var(--duration-slow) var(--ease-in-out);
263
+ }
264
+ .root:hover .trailing,
265
+ .root:focus-within .trailing {
266
+ opacity: 1;
267
+ }
268
+
269
+ /* ───────────────────────────────────────────────────────────
270
+ Variants — ghost(default) · soft · segmented
271
+ 기본 .tab 규칙은 ghost 기준. 여기선 variant별 override만.
272
+ */
273
+
274
+ /* Soft ──────────────────────────────────────────────────────
275
+ 모든 탭에 연한 bg 상시. 버튼처럼 "모양이 미리 잡힌" 느낌.
276
+ 비활성=bg-subtle · hover=hover mix(subtle→muted 사이) · 활성=bg-muted. */
277
+ .variant_soft .track {
278
+ gap: var(--space-1);
279
+ }
280
+ .variant_soft .tab {
281
+ background: var(--bg-subtle);
282
+ }
283
+ .variant_soft .tab:hover:not([data-disabled="true"]) {
284
+ background: color-mix(in oklab, var(--bg-subtle) 50%, var(--bg-muted) 50%);
285
+ color: var(--fg);
286
+ }
287
+ .variant_soft .tab[data-state="active"] {
288
+ background: var(--bg-muted);
289
+ color: var(--fg-strong);
290
+ }
291
+ /* labelFade: 비활성 상태는 bg-subtle로 녹여야 자연스러움. */
292
+ .variant_soft .labelFade {
293
+ background: linear-gradient(to right, transparent 0%, var(--bg-subtle) 100%);
294
+ }
295
+ .variant_soft .tab:hover:not([data-disabled="true"]) .labelFade {
296
+ background: linear-gradient(to right, transparent 0%, color-mix(in oklab, var(--bg-subtle) 50%, var(--bg-muted) 50%) 100%);
297
+ }
298
+
299
+ /* Segmented ──────────────────────────────────────────────────
300
+ Chrome·Figma 식 — 탭은 네모칸, 사이에 세로 hairline. 활성·hover 탭의
301
+ 왼쪽 hairline, 그리고 활성·hover 탭 오른쪽 이웃 탭의 왼쪽 hairline을
302
+ 숨겨 "구분선이 활성 탭에 닿지 않게" 처리.
303
+ gap은 아주 작게(--space-0_5) 둬 hairline이 숨을 때 탭이 완전히 붙어보이지 않게. */
304
+ .variant_segmented .track {
305
+ gap: var(--space-0_5);
306
+ }
307
+ .variant_segmented .tab {
308
+ border-radius: var(--radius-xs);
309
+ position: relative;
310
+ }
311
+ .variant_segmented .tab::before {
312
+ content: "";
313
+ position: absolute;
314
+ left: 0;
315
+ top: 50%;
316
+ transform: translateY(-50%);
317
+ width: var(--hairline);
318
+ height: calc(100% - var(--space-3));
319
+ background: var(--border);
320
+ transition: opacity var(--duration-slow) var(--ease-out);
321
+ }
322
+ /* 첫 탭은 왼쪽 hairline 없음. */
323
+ .variant_segmented .tab:first-child::before {
324
+ opacity: 0;
325
+ }
326
+ /* 활성·hover 탭 자신의 왼쪽 hairline 숨김. */
327
+ .variant_segmented .tab:hover:not([data-disabled="true"])::before,
328
+ .variant_segmented .tab[data-state="active"]::before {
329
+ opacity: 0;
330
+ }
331
+ /* 활성·hover 탭 바로 오른쪽 이웃의 왼쪽 hairline도 숨김 (경계가 활성 영역에 안 닿게). */
332
+ .variant_segmented .tab:hover:not([data-disabled="true"]) + .tab::before,
333
+ .variant_segmented .tab[data-state="active"] + .tab::before {
334
+ opacity: 0;
335
+ }
336
+
337
+ /* ───────────────────────────────────────────────────────────
338
+ Trailing slot variant별 처리
339
+ 소비처가 넣는 자식 요소(IconButton, Button, 등) 자체 시각은 건드리지 않고,
340
+ wrapper 쪽에서 간격·디바이더만 담당.
341
+ */
342
+ .variant_segmented {
343
+ gap: var(--space-2);
344
+ }
345
+ .variant_segmented .trailing {
346
+ position: relative;
347
+ padding-inline-start: var(--space-2);
348
+ }
349
+ .variant_segmented .trailing::before {
350
+ content: "";
351
+ position: absolute;
352
+ left: 0;
353
+ top: 50%;
354
+ transform: translateY(-50%);
355
+ width: var(--hairline);
356
+ height: calc(100% - var(--space-3));
357
+ background: var(--border);
358
+ }
@@ -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";
@@ -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
@@ -0,0 +1,144 @@
1
+ # TabStrip
2
+
3
+ ### 원칙
4
+ 브라우저·에디터·디자인툴 상단에 늘어선 **"열려있는 문서/파일 탭"** 전용 컴포넌트. 각 탭은 **독립 문서·독립 라우트**를 가리킨다 — Chrome의 창 탭, Figma의 파일 탭, VSCode의 에디터 탭이 전부 같은 자리. 이름은 Chromium/VSCode/Telerik이 공통으로 쓰는 업계 표준어 `TabStrip`.
5
+
6
+ [[components/tabs]]와 역할이 **완전히 다르다**. Tabs는 "같은 화면 안 섹션 전환"(URL `?tab=activity`), TabStrip은 "열린 N개 중 하나 선택"(각 탭 = 라우트). 섞어 쓰지 말 것 — 한 화면에 TabStrip이 있고 그 아래 Tabs가 있는 건 OK (브라우저 탭 밑에 설정 섹션 탭 있는 꼴).
7
+
8
+ 활성 표시는 **pill·underline이 아니라 bg-muted 배경 상시 유지** — hover 색(bg-muted)이 활성 탭에서 lock된 꼴. "hover되는 자리가 현재 자리"라는 직관.
9
+
10
+ ### Do
11
+ - **각 탭 = 독립 문서/라우트**. 라우팅이나 외부 상태 저장소에 반영 (`?doc=portfolio` 또는 workspace store).
12
+ - **leadingIcon에 자유롭게** — [[components/icon]], `<img>`(파일 썸네일), `<svg>`(브랜드 로고) 전부 ReactNode로 받는다. Figma 파일 탭의 작은 썸네일, VSCode 파일 아이콘 패턴.
13
+ - **닫기는 `closable` prop으로**. x 버튼은 `absolute`로 오른쪽에 띄워 **왼쪽에서 bg-muted 그라데이션으로 라벨 위를 덮으며 등장** — Chrome 긴 탭 패턴. **hover 시에만** fade-in (활성 탭도 hover 아니면 숨김) — 평상시 시각 노이즈 최소화.
14
+ - **긴 라벨은 hover 시 marquee loop** — 라벨이 overflow되면 짧은 지연 후 왼쪽으로 **전광판처럼 끊김 없이** 흐른다 (텍스트 복제본이 뒤를 이어받음). ResizeObserver로 overflow 감지, 짧은 라벨은 자동으로 안 움직임. `prefers-reduced-motion`에선 비활성.
15
+ - **trailing slot은 자유 조립**. `trailing` prop에 ReactNode를 넣으면 탭 묶음 오른쪽에 자리잡음 — `<IconButton icon={<Icon name="plus" />} />` (새 탭), `<IconButton icon={<Icon name="dots-three" />} />` (더보기), 또는 아무것도 안 넣기. TabStrip hover·focus-within 시에만 fade-in.
16
+ - **자리 중립** — Topbar 바로 아래 2번째 줄에 flush로 두거나, Topbar의 center slot에 넣는다. 소비처가 자리를 결정.
17
+ - **키보드**: ← → Home End 지원. Enter/Space로 선택. 활성 탭만 `tabIndex=0` (roving).
18
+ - **라벨은 1줄 cap** — `max-width: 20ch`(md), `16ch`(sm)에서 ellipsis. 문서명이 길면 Tooltip으로 보완.
19
+
20
+ ### Don't
21
+ - **섹션 전환**에 TabStrip 쓰지 말 것 — 그건 [[components/tabs]] underline/segmented/text. 역할 뒤집으면 "탭 안에 탭" 혼동.
22
+ - **활성 색을 `--primary`로 덮지 않는다** — 활성은 `--fg-strong` + `--bg-muted` 무채. 브랜드 컬러는 CTA와 위계가 충돌 ([[components/tabs]]와 동일 원칙).
23
+ - **라벨 가로 폭을 숫자(px)로 고정 금지** — 폰트 축이 바뀌면 깨짐. `ch` 단위 사용.
24
+ - **x 아이콘을 상시 노출하지 말 것** — 시각 노이즈. hover·active에만, 활성 탭은 늘 보이는 게 맞다.
25
+ - **`<svg>` 인라인 금지** — leadingIcon은 Icon·이미지·브랜드 svg는 OK지만 "아이콘용 raw svg"는 [[feedback_no_inline_svg]].
26
+ - **trailing에 생짜 `<button>` 넣지 말 것** — Button·IconButton·DropdownMenu 트리거 등 CTDS 컴포넌트 경유. 자리별 간격·디바이더는 wrapper가 담당하니 소비처는 버튼만 넘김.
27
+ - **탭 사이에 가운뎃점·대시 구분자 금지** — [[feedback_no_bullet_separator]]. 탭 자체가 구분된 블록.
28
+
29
+ ### Fallback
30
+ 자리 애매하면 **Topbar 아래 2번째 줄**(shell 2단), size 애매하면 **md** (shell chrome에서도 sm보다 md가 안정적 — Topbar와 같은 리듬).
31
+
32
+ ### 관련
33
+ - [[components/tabs]] — 역할 분리. Tabs=섹션 전환, TabStrip=문서 전환
34
+ - [[components/topbar]] — TabStrip이 가장 자주 조립되는 shell. 아래 줄 또는 center slot
35
+ - [[components/icon]] · [[components/file-icon]] — leadingIcon 자리 (파일 타입 썸네일로 FileIcon 자주 조합)
36
+ - [[components/tooltip]] — 라벨이 ellipsis될 때 전체 문서명 노출
37
+ - [[axes/density]] — `--control-h-*`가 탭 높이에 매핑
38
+ - [[axes/shape]] — 탭 radius가 Shape 축에 반응
39
+ - [[axes/motion]] — hover·active 전환 duration
40
+ - [[tokens/color]] — `--bg-muted`(활성·hover 배경), `--fg-muted` → `--fg-strong` 대비
41
+
42
+ ### Props 시그니처
43
+ ```tsx
44
+ <TabStrip
45
+ variant="ghost | soft | segmented" // default: ghost
46
+ size="sm | md" // default: md
47
+ value={string} // controlled
48
+ defaultValue={string} // uncontrolled 초기값
49
+ onValueChange={(v) => void}
50
+ trailing={<ReactNode>} // 오른쪽 slot. IconButton·DropdownMenu 등 자유 조립
51
+ >
52
+ <TabStrip.Tab
53
+ value="revana"
54
+ leadingIcon={<Icon name="file-text" />}
55
+ closable
56
+ onClose={() => removeTab("revana")}
57
+ closeLabel="탭 닫기"
58
+ >
59
+ Revana (copy 2)
60
+ </TabStrip.Tab>
61
+ <TabStrip.Tab
62
+ value="portfolio"
63
+ leadingIcon={<img src="/figma.svg" alt="" />}
64
+ closable
65
+ onClose={() => removeTab("portfolio")}
66
+ >
67
+ Portfolio
68
+ </TabStrip.Tab>
69
+ </TabStrip>
70
+ ```
71
+
72
+ ### 상태 매트릭스
73
+ | 상태 | 배경 | 라벨 색 | leading opacity | x + fade | 라벨 marquee |
74
+ |---|---|---|---|---|---|
75
+ | default (inactive) | transparent | `--fg-muted` | 0.75 | opacity 0 | 정지 |
76
+ | hover (inactive) | `--bg-muted` | `--fg` | 1 | fade-in | 지연 후 loop (오버플로우 시) |
77
+ | active (no hover) | `--bg-muted` 상시 | `--fg-strong` | 1 | opacity 0 | 정지 |
78
+ | active + hover | `--bg-muted` | `--fg-strong` | 1 | fade-in | 지연 후 loop (오버플로우 시) |
79
+ | disabled | transparent | opacity 0.5 | 0.5 | opacity 0 | 정지 |
80
+ | focus-visible | — | — | — | `--ring-shadow` | — |
81
+
82
+ ### Variant 가이드
83
+ | variant | 결 | 언제 |
84
+ |---|---|---|
85
+ | `ghost` (default) | 투명 + hover/active bg | shell chrome 기본. 가장 가벼움. 주변 UI와 조용히 어울림. |
86
+ | `soft` | 모든 탭에 연한 bg 상시, 활성이 조금 더 진함 | 탭이 **독립 블록**으로 보여야 할 때. 선택 자리가 또렷. Button 그룹과 리듬 맞음. |
87
+ | `segmented` | 탭 사이 세로 hairline으로 칸 구분 | 브라우저·에디터의 **문서 탭** 표준. 탭 경계가 또렷하고 전통적. |
88
+
89
+ ### Size 가이드
90
+ | size | 언제 |
91
+ |---|---|
92
+ | `sm` | 밀도 승부처 (사이드바 내부 docked, 좁은 패널) |
93
+ | `md` | 기본. Topbar 아래 shell 2번째 줄 |
94
+
95
+ ### 조합 예시
96
+
97
+ ```tsx
98
+ // Topbar 아래 줄 (Figma·Chrome 패턴)
99
+ <Topbar>
100
+ <Topbar.Leading><IdentityButton …/></Topbar.Leading>
101
+ <Topbar.Trailing>…</Topbar.Trailing>
102
+ </Topbar>
103
+ <div className="shell-row-2">
104
+ <TabStrip
105
+ value={active}
106
+ onValueChange={setActive}
107
+ trailing={
108
+ <IconButton
109
+ variant="ghost"
110
+ icon={<Icon name="plus" />}
111
+ aria-label="새 문서"
112
+ onClick={handleNewDoc}
113
+ />
114
+ }
115
+ >
116
+ {docs.map((d) => (
117
+ <TabStrip.Tab
118
+ key={d.id}
119
+ value={d.id}
120
+ leadingIcon={<FileIcon ext={d.ext} />}
121
+ closable
122
+ onClose={() => closeDoc(d.id)}
123
+ >
124
+ {d.title}
125
+ </TabStrip.Tab>
126
+ ))}
127
+ </TabStrip>
128
+ </div>
129
+ ```
130
+
131
+ ```tsx
132
+ // Topbar center slot (탭 2~4개로 적을 때)
133
+ <Topbar>
134
+ <Topbar.Leading><IdentityButton …/></Topbar.Leading>
135
+ <Topbar.Center>
136
+ <TabStrip value={view} onValueChange={setView} size="sm">
137
+ <TabStrip.Tab value="design">Design</TabStrip.Tab>
138
+ <TabStrip.Tab value="code">Code</TabStrip.Tab>
139
+ <TabStrip.Tab value="specs">Specs</TabStrip.Tab>
140
+ </TabStrip>
141
+ </Topbar.Center>
142
+ <Topbar.Trailing>…</Topbar.Trailing>
143
+ </Topbar>
144
+ ```
@@ -1,14 +1,16 @@
1
1
  # Toolbar
2
2
 
3
3
  ### 원칙
4
- IconButton 중심의 action 줄. 에디터·캔버스·갤러리 상단, 또는 "뜨 있는" 알약으로 콘텐츠 위에 떠서 액션을 모으는 자리. 단일 size — toolbar 안의 버튼은 전부 같은 사이즈여야 한다.
4
+ IconButton 중심의 action 줄. 에디터·캔버스·갤러리 상단, 또는 "뜨 있는" 알약으로 콘텐츠 위에 떠서 액션을 모으는 자리. toolbar 안의 버튼은 전부 같은 사이즈 — Toolbar의 `size` prop이 안쪽 IconButton 히트박스·아이콘을 한 번에 결정.
5
5
 
6
6
  ### Do
7
7
  - `variant="floating"` 기본 — bg-elevated + shadow-md + radius-xl, 콘텐츠 위에 떠있는 알약. 캔버스·에디터·이미지 뷰어의 상단 중앙.
8
8
  - `variant="attached"`는 콘텐츠에 붙은 bar — transparent + 하단 hairline. 페이지 상단에 "탭 아래 액션 바"로 쓸 때.
9
9
  - 관련 액션은 `<Toolbar.Group>`으로 묶는다 — 내부는 좁은 gap, 그룹 간은 `<Toolbar.Divider>`.
10
10
  - 좌·우 정렬이 필요하면 `<Toolbar.Spacer>`로 공간 밀기 — flex:1.
11
- - toolbar 안엔 IconButton size="sm"이 기본. floating variant는 size="md"까지도 OK.
11
+ - `size="md"`(기본) — 히트박스 32·아이콘 20. 캔버스·에디터 상단 표준 자리.
12
+ - `size="sm"` — 히트박스 24·아이콘 16. 사이드바 바닥·인라인 bar처럼 밀도 승부처.
13
+ - 안쪽 IconButton은 size prop을 넘기지 않는다 — Toolbar가 CSS 변수로 자동 매칭.
12
14
 
13
15
  ### Don't
14
16
  - toolbar 안에 label 긴 Button을 섞지 않는다 — IconButton + Tooltip이 toolbar 패턴. 긴 label이 필요하면 그 자리는 toolbar가 아니라 액션 영역.
@@ -20,6 +22,7 @@ IconButton 중심의 action 줄. 에디터·캔버스·갤러리 상단, 또는
20
22
  ```tsx
21
23
  <Toolbar
22
24
  variant?="floating | attached" // default: floating
25
+ size?="sm | md" // default: md (히트박스 32·아이콘 20)
23
26
  stretch?={boolean} // default: variant로 자동 (attached=true, floating=false)
24
27
  role?={string} // default: "toolbar"
25
28
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conscience-technology/ctds",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "CTDS — 하이브리드 디자인 시스템 설치 · 온보딩 CLI (shadcn식 코드 생성기)",
5
5
  "type": "module",
6
6
  "sideEffects": [