@conscience-technology/ctds 0.4.3 → 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.
Files changed (32) hide show
  1. package/README.md +15 -0
  2. package/assets/components/ChatInput/ChatInput.module.css +67 -14
  3. package/assets/components/ChatInput/ChatInput.tsx +17 -1
  4. package/assets/components/ChatInput/SendButton.tsx +18 -1
  5. package/assets/components/ChatInput/index.ts +3 -0
  6. package/assets/components/Onboarding/OnboardingPage.tsx +1 -0
  7. package/assets/components/TabStrip/TabStrip.module.css +358 -0
  8. package/assets/components/TabStrip/TabStrip.tsx +396 -0
  9. package/assets/components/TabStrip/index.ts +7 -0
  10. package/assets/components/Toolbar/Toolbar.module.css +34 -7
  11. package/assets/components/Toolbar/Toolbar.tsx +9 -3
  12. package/assets/components/Toolbar/index.ts +1 -1
  13. package/assets/components/_data.ts +5 -3
  14. package/assets/components/index.ts +1 -0
  15. package/assets/prompts/ASSEMBLY.md +11 -0
  16. package/assets/prompts/axes/icon.md +3 -0
  17. package/assets/prompts/axes/voice.md +7 -4
  18. package/assets/prompts/components/callout.md +17 -11
  19. package/assets/prompts/components/chat-input.md +17 -3
  20. package/assets/prompts/components/list-item.md +22 -0
  21. package/assets/prompts/components/tab-strip.md +144 -0
  22. package/assets/prompts/components/toolbar.md +5 -2
  23. package/assets/prompts/components/topbar.md +1 -0
  24. package/assets/prompts/primitives/scroll.md +8 -1
  25. package/assets/prompts/recipes/page-header.md +29 -15
  26. package/assets/skills/ctds-wireframe/SKILL.md +5 -0
  27. package/package.json +4 -4
  28. package/src/commands/init.js +63 -8
  29. package/src/utils/detect.js +14 -2
  30. package/src/utils/patch.js +134 -24
  31. package/assets/prompts/themes/focus.md +0 -72
  32. /package/assets/prompts/{axes/cascade.md → CASCADE.md} +0 -0
@@ -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";
@@ -488,7 +488,7 @@ export const shadows: [string, string, string][] = [
488
488
  ["shadow-sm", "var(--shadow-sm)", "Card 기본"],
489
489
  ["shadow-md", "var(--shadow-md)", "Dropdown, Popover"],
490
490
  ["shadow-lg", "var(--shadow-lg)", "Modal"],
491
- ["shadow-xl", "var(--shadow-xl)",],
491
+ ["shadow-xl", "var(--shadow-xl)", "Command Palette"],
492
492
  ["shadow-inner", "var(--shadow-inner)", "눌린 상태"],
493
493
  ];
494
494
 
@@ -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
  },
@@ -648,7 +649,8 @@ export const components: { id: string; title: string; items: string[] }[] = [
648
649
  id: "overlay",
649
650
  title: "Overlay / Menu",
650
651
  items: [
651
- "Context Menu", "Dropdown Menu", "Disclosure", "Collapsible", "Toggle Group",
652
+ "Context Menu", "Dropdown Menu",
653
+ "Disclosure", "Collapsible", "Toggle Group",
652
654
  ],
653
655
  },
654
656
  {
@@ -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";
@@ -47,6 +47,17 @@ anatomy = padding·gap·radius·border 전략. 이걸 복제하면 벤더가 축
47
47
  - **Icon 이름**은 `web/components/Icon/Icon.tsx`의 phosphor 레지스트리에서 확인 — 레시피/다른 문서에 등장하는 이름이 실제 등재된 이름이라는 보장 없음
48
48
  - **`"use client"` 전염**: 쓰는 벤더 중 하나라도 `"use client"`면 조립하는 **page 자체도 `"use client"`**. compound(`Stepper.Item`·`Card.Header` 등)를 Server Component에서 접근하면 **런타임 undefined**(Next 15/React 19 경계 이슈). 현재 Button/Icon/IconButton/Stepper 등 대다수 vendor가 client — 사실상 조립 page는 모두 client로 가는 쪽이 안전
49
49
 
50
+ **벤더 수정 vs 로컬 파생 결정 트리** ([[feedback_no_vendor_modification]], [[feedback_local_derivative_over_handrolled]]):
51
+
52
+ 벤더에 "비슷하지만 그대로는 안 되는" 자리를 만들 때 네 갈래 중 하나를 **글로 적어 결정**한다 — 수제(div+span 하드코딩) 로 빠지는 순간 토큰·여백·사이즈 리듬이 전부 깨진다.
53
+
54
+ 1. **벤더 variant/size 로 커버됨** → 그대로 import, 변경 금지.
55
+ 2. **커버 안 됨 + 다른 사용처에도 쓰일 패턴** → `ctds-component` 스킬로 넘겨 **벤더에 새 variant/size 신설**. Layer 1(코드)과 Layer 2(문서) 쌍으로 수정. 영향 범위 평가 필수.
56
+ 3. **커버 안 됨 + 번역/페이지 이 자리에만 필요** → **로컬 파생**: 가장 비슷한 벤더 코드(JSX + CSS Module)를 소비 프로젝트 로컬 경로(`web/app/_components/` 또는 `app/components/<page>/_parts/`)에 **다른 이름**으로 복사. 토큰·height·padding·gap·radius 기본값은 **건드리지 말고 유지** — 조정은 추가 slot/위치/variant 수준. 벤더 패키지(`packages/ctds-cli/assets/`, `node_modules/ctds/`) 는 **절대 수정 금지**.
57
+ 4. **벤더에 비슷한 것도 없고 레시피에도 없음** → 사용자에게 보고. 수제 조립로 임의 결정 금지.
58
+
59
+ **왜 로컬 파생이 수제보다 통일성이 좋은가**: 벤더의 토큰 선택을 그대로 베낀 상태로 시작하므로 Shape/Density/Hue 축 스왑에 자동 반응. 수제는 padding `12px`·gap `8px` 같은 직관값이 CTDS space scale 과 미묘하게 어긋나거나 하드코딩되고, 축 스왑에서 그 자리만 깨진다.
60
+
50
61
  ### 3단계 — 레시피를 확인한다
51
62
 
52
63
  `prompts/recipes/`에 **자리별 조립 레시피**가 있다. 맞는 레시피가 있으면 그 토큰·구조 그대로 쓴다.
@@ -16,11 +16,14 @@ Weight는 축이 아니라 `<Icon weight="..." />` prop (컴포넌트별 자유
16
16
  - 아이콘은 항상 의미 기반 이름으로 참조: `<Icon name="check" />`.
17
17
  - 프로젝트 시작 시 Family 하나 결정. 리스킨 아닌 이상 안 바꾼다.
18
18
  - 활성/선택 상태 표시엔 `weight=fill`, 그 외에는 `weight=regular`.
19
+ - **단순한 상징 우선** — Button/MenuItem 라벨이 이미 의미를 담고 있으니, 아이콘은 1초 안에 분류되는 "모양 꼬리표" 역할. 조합형 메타포(`envelope-plus`, `pencil-simple` 등)보다 원형 상징(`plus`, `x`, `check`, `search`, `trash`, `arrow-right`)이 우선 ([[feedback_simple_icons]]).
19
20
 
20
21
  ### Don't
21
22
  - 한 페이지에서 두 Family를 섞지 않는다.
22
23
  - 아이콘을 SVG로 하드코딩하지 않는다. 항상 `<Icon />` 경유.
23
24
  - Lucide에서 `weight=fill`을 요구하지 않는다 (원본이 fill 미제공).
25
+ - 조합형 메타포로 의미를 "그림으로 설명" 하지 않는다 — "새 메시지" 는 `plus` 지 `envelope-plus`/`pencil-simple` 가 아니다. "제출" 은 `arrow-right` 지 `paper-plane` 이 아니다. 라벨에 의미를 넣고 아이콘은 보편 상징.
26
+ - 예외: Sidebar·네비처럼 **아이콘 혼자 뜻을 담아야 하는 자리**는 구체 아이콘 OK (`gear`, `house`, `bell`).
24
27
 
25
28
  ### Fallback
26
29
  Family는 `phosphor`, Weight는 `regular`.
@@ -24,10 +24,13 @@ Theme과 독립 — editorial이든 mechanical이든 어떤 Theme에도 어떤 V
24
24
  - 접근성 문구(에러 설명, 필수 필드 안내, 스크린리더 라벨)는 Voice와 무관하게 **명확성 우선**.
25
25
  `casual`이어도 필수 정보를 희석하지 않는다 (예: "비밀번호는 필수입니다" 수준은 유지).
26
26
  - Voice에 CSS 변수나 토큰을 할당하지 않는다. 코드 레벨 영향은 `data-voice` 속성 노출뿐.
27
- - **제목·헤딩·버튼 라벨·메뉴명에 em dash(—), en dash(–), 하이픈(-)으로 부제를 잇지 않는다.**
28
- 모든 Voice 공통 하드 룰. 문서·UI 카피·AI 생성물 전부 해당.
29
- 나쁜 예: "온보딩 — 테마를 고릅니다", "설정 — 기본값", "저장 — 완료".
30
- 대신: 한 줄 제목으로 끝내거나(`"테마 고르기"`), 부제가 꼭 필요하면 별도 하위 텍스트로 분리.
27
+ - **[HARD] em dash(—), en dash(–), 생략부호(…), 수사적 큰따옴표 전면 금지.** ([[feedback_no_em_dash]])
28
+ 모든 Voice 공통 하드 룰. 범위는 **제목·헤딩·버튼 라벨·메뉴명뿐 아니라 본문·테이블 셀·설명·툴팁·에러 메시지까지 모든 사람 UI 카피** 전부. 문서·UI 카피·AI 생성물 전부 해당.
29
+ 나쁜 예:
30
+ - 제목: "온보딩 — 테마를 고릅니다", "설정 — 기본값", "저장 — 완료"
31
+ - 본문: "작업이 완료되었습니다 — 다시 시작하려면...", "결과는 다음과 같습니다…"
32
+ - 테이블 셀: "완료 — 2026년 10월 15일"
33
+ 대신: 한 줄 제목으로 끝내거나(`"테마 고르기"`), 부제가 꼭 필요하면 별도 하위 텍스트로 분리. 본문에서는 쉼표·마침표·괄호로 흐름을 끊고, "…" 대신 **"기다리는 중"** 처럼 상태를 명시.
31
34
 
32
35
  ### Fallback
33
36
  `neutral`.