@conscience-technology/ctds 0.1.6 → 0.1.8

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.
@@ -39,7 +39,7 @@
39
39
  display: grid;
40
40
  grid-template-columns: repeat(3, minmax(0, 1fr));
41
41
  gap: var(--space-1);
42
- padding: var(--space-3) var(--space-3) var(--space-1);
42
+ padding: var(--space-3) var(--space-4) var(--space-1);
43
43
  }
44
44
  /* Thumb은 셀 안에서 자체 폭 유지(좌측 정렬), chip은 셀 폭 채움. */
45
45
  .attachmentCell {
@@ -57,7 +57,7 @@
57
57
  /* Quotation */
58
58
  .quotation {
59
59
  display: block;
60
- padding: var(--space-3) var(--space-3) var(--space-1);
60
+ padding: var(--space-3) var(--space-4) var(--space-1);
61
61
  }
62
62
  /* attachments 아래에 quotation이 오면 상단 패딩 제거 (attachments bottom이 책임) */
63
63
  .attachments + .quotation {
@@ -88,8 +88,8 @@
88
88
  - 긴 글: wrapper scroll 발생 → 글이 상·하 padding 영역을 통과해 root border에서 자연스레 잘림. */
89
89
  .multiScroll {
90
90
  overflow-y: auto;
91
- max-height: calc(var(--text-base) * 1.5 * 8); /* ~8 lines */
92
- padding: var(--space-3) var(--space-3) 0;
91
+ max-height: calc(var(--text-sm) * 1.5 * 8); /* ~8 lines */
92
+ padding: var(--space-3) var(--space-4) 0;
93
93
  }
94
94
  /* attachments/quotation 아래에 오면 상단 패딩 절반 (이미 attachments/quotation에 bottom padding 있음) */
95
95
  .attachments + .body .multiScroll,
@@ -103,7 +103,7 @@
103
103
  display: block;
104
104
  width: 100%;
105
105
  min-width: 0;
106
- min-height: calc(var(--text-base) * 1.5);
106
+ min-height: calc(var(--text-sm) * 1.5);
107
107
  padding: 0 0 var(--space-3);
108
108
  background: transparent;
109
109
  border: 0;
@@ -111,7 +111,7 @@
111
111
  resize: none;
112
112
  color: var(--fg-strong);
113
113
  font: inherit;
114
- font-size: var(--text-base);
114
+ font-size: var(--text-sm);
115
115
  line-height: 1.5;
116
116
  overflow: hidden;
117
117
  }
@@ -124,15 +124,15 @@
124
124
  align-items: center;
125
125
  justify-content: space-between;
126
126
  gap: var(--space-1);
127
- padding: 0 var(--space-3) var(--space-2);
127
+ padding: 0 var(--space-2) var(--space-2);
128
128
  }
129
129
 
130
130
  /* SingleLine */
131
131
  .shape-single .body {
132
132
  flex-direction: row;
133
133
  align-items: center;
134
- gap: var(--space-1);
135
- padding: var(--space-2) var(--space-3);
134
+ gap: var(--space-0_5);
135
+ padding: var(--space-2) var(--space-2);
136
136
  }
137
137
  .singleField {
138
138
  flex: 1 1 auto;
@@ -141,14 +141,19 @@
141
141
  background: transparent;
142
142
  border: 0;
143
143
  outline: none;
144
+ resize: none;
145
+ overflow: hidden;
144
146
  color: var(--fg-strong);
145
147
  font: inherit;
146
- font-size: var(--text-base);
148
+ font-size: var(--text-sm);
147
149
  line-height: 1.5;
148
150
  }
149
151
  .singleField::placeholder {
150
152
  color: var(--fg-muted);
151
153
  }
154
+ .singleField::placeholder {
155
+ color: var(--fg-muted);
156
+ }
152
157
 
153
158
  /* Attach 버튼 */
154
159
  .attachBtn {
@@ -179,8 +184,8 @@
179
184
  opacity: 0.4;
180
185
  }
181
186
  .attachIcon {
182
- width: 68%;
183
- height: 68%;
187
+ width: 50%;
188
+ height: 50%;
184
189
  }
185
190
 
186
191
  /* model + send */
@@ -265,8 +270,8 @@
265
270
  cursor: not-allowed;
266
271
  }
267
272
  .sendIcon {
268
- width: 55%;
269
- height: 55%;
273
+ width: 44%;
274
+ height: 44%;
270
275
  }
271
276
 
272
277
  /* 파일 input 숨김 */
@@ -108,11 +108,10 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
108
108
  const canSend = !disabled && (value.trim().length > 0 || attachments.length > 0);
109
109
  const showStop = isStreaming && !!onStop;
110
110
 
111
- /* autoResize for multi — textarea는 content에 맞춰 자람(scroll 아님).
112
- 외부 scroll wrapper가 max-height를 책임 → 넘치면 wrapper가 스크롤.
113
- 룰: 스크롤이 wrapper에 생기므로 wrapper padding이 "트랙 역할" (padding 안쪽까지 흘러 border에서 잘림). */
111
+ /* autoResize — multi/single 둘 다. textarea는 content에 맞춰 자람(scroll 아님).
112
+ 외부 scroll wrapper(multi)가 max-height 책임. single은 wrapper 없이 textarea가 직접 자람.
113
+ Shift+Enter로 줄바꿈 → textarea가 즉시 자라고, SingleLine도 자연스레 늘어남. */
114
114
  useLayoutEffect(() => {
115
- if (shape !== "multi") return;
116
115
  const el = taRef.current;
117
116
  if (!el) return;
118
117
  el.style.height = "auto";
@@ -122,8 +121,8 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
122
121
  const handleKeyDown = useCallback(
123
122
  (e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => {
124
123
  if (e.key !== "Enter") return;
125
- if (e.shiftKey) return; // multi: 줄바꿈 허용
126
- if (shape === "multi" && e.nativeEvent.isComposing) return;
124
+ if (e.shiftKey) return; // Shift+Enter: multi/single 모두 줄바꿈 허용
125
+ if (e.nativeEvent.isComposing) return; // 한글 조합 중 전송 방지
127
126
  e.preventDefault();
128
127
  if (showStop) {
129
128
  onStop?.();
@@ -131,7 +130,7 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
131
130
  onSend();
132
131
  }
133
132
  },
134
- [shape, canSend, showStop, onSend, onStop]
133
+ [canSend, showStop, onSend, onStop]
135
134
  );
136
135
 
137
136
  const handleSendClick = useCallback(() => {
@@ -277,14 +276,15 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
277
276
  {shape === "single" ? (
278
277
  <>
279
278
  {AttachButton}
280
- <input
281
- type="text"
279
+ <textarea
280
+ ref={taRef}
282
281
  className={styles.singleField}
283
282
  value={value}
284
283
  onChange={(e) => onChange(e.target.value)}
285
284
  onKeyDown={handleKeyDown}
286
285
  placeholder={placeholder}
287
286
  disabled={disabled}
287
+ rows={1}
288
288
  aria-label={placeholder}
289
289
  />
290
290
  {ModelAndSend}
@@ -33,14 +33,13 @@
33
33
  will-change: transform, opacity;
34
34
  }
35
35
 
36
- /* side별 자리·테두리 — 바깥쪽 radius는 떼고 안쪽만 둥글게.
36
+ /* side별 자리·테두리 — 가장자리에 붙는 패널이라 radius 없음(각진 모서리).
37
37
  공통: 세로 full-height. 폭은 size로 결정. */
38
38
  .content[data-side="right"] {
39
39
  top: 0;
40
40
  right: 0;
41
41
  bottom: 0;
42
42
  border-right: 0;
43
- border-radius: var(--radius-lg) 0 0 var(--radius-lg);
44
43
  animation: drawer-in-right var(--duration-slow) var(--ease-emphasized);
45
44
  }
46
45
  .content[data-side="right"][data-state="closed"] {
@@ -52,7 +51,6 @@
52
51
  left: 0;
53
52
  bottom: 0;
54
53
  border-left: 0;
55
- border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
56
54
  animation: drawer-in-left var(--duration-slow) var(--ease-emphasized);
57
55
  }
58
56
  .content[data-side="left"][data-state="closed"] {
@@ -667,6 +667,21 @@
667
667
  background: color-mix(in oklab, var(--onb-fg) 8%, transparent);
668
668
  border-radius: var(--radius-xs);
669
669
  }
670
+ .layoutPreview[data-frame="topbar-first"] {
671
+ flex-direction: column;
672
+ }
673
+ .layoutPreviewBody {
674
+ flex: 1;
675
+ display: flex;
676
+ gap: var(--space-0_5);
677
+ min-height: 0;
678
+ }
679
+ .layoutPreviewTabs {
680
+ height: var(--space-1_5);
681
+ flex-shrink: 0;
682
+ background: color-mix(in oklab, var(--onb-fg) 14%, transparent);
683
+ border-radius: var(--radius-xs);
684
+ }
670
685
 
671
686
  /* 밀도 카드 — row 4개로 간격 비주얼 */
672
687
  .densityPreview {
@@ -1388,7 +1403,22 @@
1388
1403
  .layoutWireContent {
1389
1404
  flex: 1;
1390
1405
  background: var(--bg-subtle);
1391
- border: var(--hairline) solid var(--border);
1406
+ border-radius: var(--radius-sm);
1407
+ }
1408
+ /* topbar-first: 탑바가 가로 전체, 아래로 사이드바+콘텐츠 */
1409
+ .layoutWire[data-frame="topbar-first"] {
1410
+ flex-direction: column;
1411
+ }
1412
+ .layoutWireBody {
1413
+ flex: 1;
1414
+ display: flex;
1415
+ gap: var(--space-2);
1416
+ min-height: 0;
1417
+ }
1418
+ .layoutWireTabs {
1419
+ height: var(--space-4);
1420
+ flex-shrink: 0;
1421
+ background: color-mix(in oklab, var(--primary) 25%, var(--bg) 75%);
1392
1422
  border-radius: var(--radius-sm);
1393
1423
  }
1394
1424
 
@@ -205,31 +205,44 @@ export function OnboardingPage() {
205
205
  lang: "ko",
206
206
  });
207
207
  // 2) 파일 쓰기 (server action)
208
- const result = await applyOnboarding(
209
- {
210
- theme: answers.theme,
211
- mode: "light",
212
- palette: paletteChoice.id,
213
- hue,
214
- accentHue: null,
215
- shape: vibe.values.shape,
216
- density: density.id,
217
- motion: vibe.values.motion,
218
- iconFamily: "phosphor",
219
- voice,
220
- lang: "ko",
221
- focusRing,
222
- appliedAt: new Date().toISOString(),
223
- },
224
- { overwrite }
225
- );
226
- if (result.ok) {
227
- setApplyState({ phase: "done", files: result.files });
228
- } else {
208
+ // server action이 reject되면(네트워크 끊김·배포환경·내부 throw 등) applying에 멈춰서
209
+ // 무한로딩처럼 보이므로 반드시 try/catch로 감싸서 error phase로 떨어뜨린다.
210
+ try {
211
+ const result = await applyOnboarding(
212
+ {
213
+ theme: answers.theme,
214
+ mode: "light",
215
+ palette: paletteChoice.id,
216
+ hue,
217
+ accentHue: null,
218
+ shape: vibe.values.shape,
219
+ density: density.id,
220
+ motion: vibe.values.motion,
221
+ iconFamily: "phosphor",
222
+ voice,
223
+ lang: "ko",
224
+ focusRing,
225
+ appliedAt: new Date().toISOString(),
226
+ },
227
+ { overwrite }
228
+ );
229
+ if (result.ok) {
230
+ setApplyState({ phase: "done", files: result.files });
231
+ } else {
232
+ setApplyState({
233
+ phase: "error",
234
+ message: result.error,
235
+ files: result.files,
236
+ });
237
+ }
238
+ } catch (e) {
229
239
  setApplyState({
230
240
  phase: "error",
231
- message: result.error,
232
- files: result.files,
241
+ message:
242
+ e instanceof Error
243
+ ? `적용 중 예외: ${e.message}`
244
+ : `적용 중 알 수 없는 오류: ${String(e)}`,
245
+ files: [],
233
246
  });
234
247
  }
235
248
  };
@@ -890,6 +903,10 @@ function LayoutStep({
890
903
  };
891
904
 
892
905
  const hoveredChoice = hovered ? LAYOUT_CHOICES.find((c) => c.id === hovered) : null;
906
+ const hoveredSidebar = hoveredChoice?.sidebar ?? "none";
907
+ const hoveredTopbar = hoveredChoice?.topbar ?? false;
908
+ const hoveredFrameLayout = hoveredChoice?.appFrameLayout ?? "sidebar-first";
909
+ const hoveredTabs = hoveredChoice?.tabs ?? false;
893
910
 
894
911
  // ThemeStep과 동일한 커서 추적 로직.
895
912
  const OFFSET_X = 20;
@@ -932,7 +949,14 @@ function LayoutStep({
932
949
  style={{ transform: `translate(${left}px, ${top}px)` }}
933
950
  aria-hidden
934
951
  >
935
- {hoveredChoice && <LayoutWireframe sidebar={hoveredChoice.sidebar} topbar={hoveredChoice.topbar} />}
952
+ {hoveredChoice && (
953
+ <LayoutWireframe
954
+ sidebar={hoveredSidebar}
955
+ topbar={hoveredTopbar}
956
+ frameLayout={hoveredFrameLayout}
957
+ tabs={hoveredTabs}
958
+ />
959
+ )}
936
960
  </div>
937
961
  </>
938
962
  );
@@ -940,24 +964,48 @@ function LayoutStep({
940
964
 
941
965
  /**
942
966
  * 레이아웃 호버 미리보기 와이어프레임.
943
- * 핵심 구조 — 탑바는 사이드바 "위"가 아니라 사이드바 "옆" (콘텐츠 영역 위에만).
944
- * 즉 전체 레이아웃은 [좌사이드바 | (탑바 + 콘텐츠 세로)] 식 가로 분할.
967
+ * frameLayout에 따라 두 가지 구조:
968
+ * - sidebar-first: [좌사이드바(세로 전체) | (탑바 + 콘텐츠 세로)] — Figma/Notion
969
+ * - topbar-first: [탑바(가로 전체); (좌사이드바 | 콘텐츠)] — GitHub/Vercel
970
+ * tabs=true면 탑바 아래 탭 바 한 줄 추가.
945
971
  */
946
972
  function LayoutWireframe({
947
973
  sidebar,
948
974
  topbar,
975
+ frameLayout,
976
+ tabs,
949
977
  }: {
950
978
  sidebar: "left" | "right" | "dual" | "none";
951
979
  topbar: boolean;
980
+ frameLayout: "sidebar-first" | "topbar-first";
981
+ tabs: boolean;
952
982
  }) {
983
+ const hasLeft = sidebar === "left" || sidebar === "dual";
984
+ const hasRight = sidebar === "right" || sidebar === "dual";
985
+
986
+ if (topbar && frameLayout === "topbar-first") {
987
+ return (
988
+ <div className={styles.layoutWire} data-frame="topbar-first">
989
+ <div className={styles.layoutWireTopbar} />
990
+ {tabs && <div className={styles.layoutWireTabs} />}
991
+ <div className={styles.layoutWireBody}>
992
+ {hasLeft && <div className={styles.layoutWireSidebar} />}
993
+ <div className={styles.layoutWireContent} />
994
+ {hasRight && <div className={styles.layoutWireSidebar} />}
995
+ </div>
996
+ </div>
997
+ );
998
+ }
999
+
953
1000
  return (
954
- <div className={styles.layoutWire}>
955
- {(sidebar === "left" || sidebar === "dual") && <div className={styles.layoutWireSidebar} />}
1001
+ <div className={styles.layoutWire} data-frame="sidebar-first">
1002
+ {hasLeft && <div className={styles.layoutWireSidebar} />}
956
1003
  <div className={styles.layoutWireMain}>
957
1004
  {topbar && <div className={styles.layoutWireTopbar} />}
1005
+ {tabs && <div className={styles.layoutWireTabs} />}
958
1006
  <div className={styles.layoutWireContent} />
959
1007
  </div>
960
- {(sidebar === "right" || sidebar === "dual") && <div className={styles.layoutWireSidebar} />}
1008
+ {hasRight && <div className={styles.layoutWireSidebar} />}
961
1009
  </div>
962
1010
  );
963
1011
  }
@@ -1064,23 +1112,12 @@ function SummaryStep({ answers }: { answers: Answers }) {
1064
1112
  value={layout?.title ?? "—"}
1065
1113
  preview={
1066
1114
  layout && (
1067
- <div
1068
- className={styles.layoutPreview}
1069
- data-sidebar={layout.sidebar}
1070
- data-topbar={layout.topbar ? "on" : "off"}
1071
- aria-hidden
1072
- >
1073
- {(layout.sidebar === "left" || layout.sidebar === "dual") && (
1074
- <span className={styles.layoutPreviewRail} />
1075
- )}
1076
- <span className={styles.layoutPreviewMain}>
1077
- {layout.topbar && <span className={styles.layoutPreviewBar} />}
1078
- <span className={styles.layoutPreviewContent} />
1079
- </span>
1080
- {(layout.sidebar === "right" || layout.sidebar === "dual") && (
1081
- <span className={styles.layoutPreviewRail} />
1082
- )}
1083
- </div>
1115
+ <LayoutPreviewMini
1116
+ sidebar={layout.sidebar}
1117
+ topbar={layout.topbar}
1118
+ frameLayout={layout.appFrameLayout ?? "sidebar-first"}
1119
+ tabs={layout.tabs ?? false}
1120
+ />
1084
1121
  )
1085
1122
  }
1086
1123
  />
@@ -1112,6 +1149,47 @@ function SummaryStep({ answers }: { answers: Answers }) {
1112
1149
  );
1113
1150
  }
1114
1151
 
1152
+ function LayoutPreviewMini({
1153
+ sidebar,
1154
+ topbar,
1155
+ frameLayout,
1156
+ tabs,
1157
+ }: {
1158
+ sidebar: "left" | "right" | "dual" | "none";
1159
+ topbar: boolean;
1160
+ frameLayout: "sidebar-first" | "topbar-first";
1161
+ tabs: boolean;
1162
+ }) {
1163
+ const hasLeft = sidebar === "left" || sidebar === "dual";
1164
+ const hasRight = sidebar === "right" || sidebar === "dual";
1165
+
1166
+ if (topbar && frameLayout === "topbar-first") {
1167
+ return (
1168
+ <div className={styles.layoutPreview} data-frame="topbar-first" aria-hidden>
1169
+ <span className={styles.layoutPreviewBar} />
1170
+ {tabs && <span className={styles.layoutPreviewTabs} />}
1171
+ <span className={styles.layoutPreviewBody}>
1172
+ {hasLeft && <span className={styles.layoutPreviewRail} />}
1173
+ <span className={styles.layoutPreviewContent} />
1174
+ {hasRight && <span className={styles.layoutPreviewRail} />}
1175
+ </span>
1176
+ </div>
1177
+ );
1178
+ }
1179
+
1180
+ return (
1181
+ <div className={styles.layoutPreview} data-frame="sidebar-first" aria-hidden>
1182
+ {hasLeft && <span className={styles.layoutPreviewRail} />}
1183
+ <span className={styles.layoutPreviewMain}>
1184
+ {topbar && <span className={styles.layoutPreviewBar} />}
1185
+ {tabs && <span className={styles.layoutPreviewTabs} />}
1186
+ <span className={styles.layoutPreviewContent} />
1187
+ </span>
1188
+ {hasRight && <span className={styles.layoutPreviewRail} />}
1189
+ </div>
1190
+ );
1191
+ }
1192
+
1115
1193
  function SummaryCard({
1116
1194
  label,
1117
1195
  value,
@@ -10,7 +10,12 @@ import type {
10
10
  export type ThemeId = "none" | "pure" | "spare" | "expressive";
11
11
  export type VibeId = "balanced" | "playful" | "serious";
12
12
  export type DensityChoiceId = DensityValue;
13
- export type LayoutId = "sidebar-only" | "topbar-sidebar" | "dual-sidebar";
13
+ export type LayoutId =
14
+ | "sidebar-only"
15
+ | "topbar-sidebar"
16
+ | "dual-sidebar"
17
+ | "topbar-over-sidebar"
18
+ | "topbar-tabs";
14
19
 
15
20
  // ── 디자인 스타일(테마) ─────────────────────────────────
16
21
  // 테마를 고르면 뒤의 축(vibe/density 등) 디폴트가 묶여서 결정됩니다.
@@ -187,33 +192,63 @@ export type LayoutChoice = {
187
192
  examples: string;
188
193
  sidebar: "left" | "right" | "dual" | "none";
189
194
  topbar: boolean;
195
+ /**
196
+ * 탑바가 사이드바와 어떻게 걸치는지. AppFrame의 layout prop과 1:1.
197
+ * - "sidebar-first": 사이드바가 세로 전체, 탑바는 콘텐츠 영역 위에만. Linear/Slack/Figma.
198
+ * - "topbar-first": 탑바가 가로 전체, 사이드바는 그 아래로. GitHub/Vercel/Jira.
199
+ * topbar=false일 때는 의미 없음.
200
+ */
201
+ appFrameLayout?: "sidebar-first" | "topbar-first";
202
+ /** 탑바 아래에 탭 바가 있는지. GitHub repo 네비처럼 섹션 전환이 탭으로. */
203
+ tabs?: boolean;
190
204
  };
191
205
 
192
206
  export const LAYOUT_CHOICES: LayoutChoice[] = [
193
207
  {
194
- id: "dual-sidebar",
195
- title: "옵션이 많은 정교한 편집 도구",
196
- hint: "왼쪽은 파일·레이어 트리, 오른쪽은 속성 패널. 도구 자체가 UI의 주인공.",
197
- examples: "Figma, Framer, VS Code, Blender",
198
- sidebar: "dual",
208
+ id: "topbar-sidebar",
209
+ title: "사이드바가 세로 전체, 탑바는 오른쪽에만",
210
+ hint: "사이드바가 항상 보이는 작업 공간. 섹션 트리가 깊고 자주 오간다.",
211
+ examples: "Notion, Linear, Slack, Figma",
212
+ sidebar: "left",
199
213
  topbar: true,
214
+ appFrameLayout: "sidebar-first",
200
215
  },
201
216
  {
202
- id: "topbar-sidebar",
203
- title: "탐색 경로가 중요한 문서·앱",
204
- hint: "상단에 워크스페이스/브래드크럼, 왼쪽에 섹션 트리. 어디에 있는지가 늘 명확해야 함.",
205
- examples: "Notion, GitHub, Linear (프로젝트 뷰), Confluence",
217
+ id: "topbar-over-sidebar",
218
+ title: "탑바가 가로 전체, 그 아래 사이드바",
219
+ hint: "로고·검색·계정이 전역에 자리. 사이드바는 현재 섹션 안쪽 네비.",
220
+ examples: "GitHub, Vercel, Jira, Jenkins",
206
221
  sidebar: "left",
207
222
  topbar: true,
223
+ appFrameLayout: "topbar-first",
208
224
  },
209
225
  {
210
226
  id: "sidebar-only",
211
- title: "메뉴는 짧고 콘텐츠가 주인공",
212
- hint: "네비게이션은 왼쪽에 최소한으로. 상단 바 없이 콘텐츠에 몰입.",
227
+ title: "사이드바만, 탑바 없음",
228
+ hint: "왼쪽에 최소 네비. 상단 바 없이 콘텐츠에 몰입.",
213
229
  examples: "Linear (이슈 뷰), Vercel Dashboard, Slack",
214
230
  sidebar: "left",
215
231
  topbar: false,
216
232
  },
233
+ {
234
+ id: "dual-sidebar",
235
+ title: "양쪽 사이드바 + 탑바",
236
+ hint: "왼쪽 트리, 오른쪽 속성 패널. 도구 자체가 UI의 주인공인 편집기.",
237
+ examples: "Figma, Framer, VS Code, Blender",
238
+ sidebar: "dual",
239
+ topbar: true,
240
+ appFrameLayout: "sidebar-first",
241
+ },
242
+ {
243
+ id: "topbar-tabs",
244
+ title: "탑바 아래 탭으로 섹션 전환",
245
+ hint: "사이드바 없이 상단 탑바 + 탭 한 줄. 섹션 수가 적고 깊이가 얕을 때.",
246
+ examples: "GitHub repo, Stripe Dashboard, Vercel 프로젝트",
247
+ sidebar: "none",
248
+ topbar: true,
249
+ appFrameLayout: "topbar-first",
250
+ tabs: true,
251
+ },
217
252
  ];
218
253
 
219
254
  /**
@@ -48,18 +48,21 @@
48
48
  min-height: var(--control-h-md);
49
49
  }
50
50
 
51
- /* line variant는 셀 좌우 패딩을 먹지 않는다 — TableRow.line과 짝.
52
- 콘텐츠가 테이블 좌/우 경계에 바짝 붙어 "선 사이의 데이터" 톤 유지.
53
- plate에선 .cell 기본 padding-inline(--space-2)을 유지. */
51
+ /* line variant 셀 좌우 패딩 — TableRow.line과 짝. 콘텐츠가 셀 경계에서 떨어짐. */
54
52
  .line .cell {
55
- padding-inline: 0;
53
+ padding-inline: var(--space-2);
56
54
  }
57
55
 
58
- /* line variant 셀 간 숨 쉴 공간. plate는 셀 자체 padding-inline으로 간격 확보. */
59
- .line {
60
- column-gap: var(--space-2);
56
+ /* 첫/마지막 셀의 가장자리 padding만 0 — 테이블 좌/우 끝과 콘텐츠 바짝 붙게. */
57
+ .line .cellSlot:first-child .cell {
58
+ padding-inline-start: 0;
59
+ }
60
+ .line .cellSlot:last-child .cell {
61
+ padding-inline-end: 0;
61
62
  }
62
63
 
64
+ /* 셀 간 column-gap 없음 — 셀 자체 padding이 간격 담당. */
65
+
63
66
  /* variant: plate ─────────────────────────────────────────────
64
67
  pill 형태. 컨테이너 자체에 radius + padding, 셀 hover 배경이 라운드 안쪽에서 발생.
65
68
  min-height으로 시각 무게 유지. */
@@ -89,6 +92,18 @@
89
92
  background: var(--bg);
90
93
  }
91
94
 
95
+ /* pinned 경계 — hairline으로 "고정 영역" 분리. TableRow와 짝. */
96
+ .cellSlot[data-pinned="left"] {
97
+ border-right: var(--hairline) solid var(--border);
98
+ }
99
+ .cellSlot[data-pinned="right"] {
100
+ border-left: var(--hairline) solid var(--border);
101
+ }
102
+
103
+ .selectCell {
104
+ border-right: var(--hairline) solid var(--border);
105
+ }
106
+
92
107
  /* Cell (columnheader) ─────────────────────────────────────── */
93
108
  /* width:100%로 슬롯 폭을 그대로 채워 라벨/tick/handle 정렬이 슬롯 경계에 맞춰 계산됨.
94
109
  padding-inline은 hover/drag hit 영역이 콘텐츠에 바짝 붙지 않도록 필수.
@@ -260,13 +275,24 @@
260
275
  }
261
276
 
262
277
  /* Select-all cell (leading checkbox) ─────────────────────
263
- 체크박스 자체가 width/height를 가져 중앙 정렬만 하면 됨 — 좌우 패딩 불필요. */
278
+ line variant: 좌측 정렬. plate variant: 가운데 정렬. TableRow와 짝 맞춤.
279
+ position: sticky — TableRow.selectCell과 짝. 좌측 고정으로 가로스크롤에도 유지. */
264
280
  .selectCell {
281
+ position: sticky;
282
+ left: 0;
283
+ z-index: var(--z-raise);
265
284
  display: inline-flex;
266
285
  align-items: center;
267
286
  justify-content: flex-start;
268
287
  flex-shrink: 0;
288
+ width: var(--iconbtn-h-lg);
289
+ background: var(--bg);
290
+ }
291
+
292
+ .plate .selectCell {
293
+ justify-content: center;
269
294
  width: var(--control-h-sm);
295
+ background: var(--bg-subtle);
270
296
  }
271
297
 
272
298
  /* Column manager cell (trailing IconButton) ────────────── */
@@ -142,6 +142,9 @@ export function pinnedStyle(
142
142
  orderedKeys: string[],
143
143
  columnMap: Map<string, TableHeaderColumn>,
144
144
  widths: Record<string, number>,
145
+ /* selectable일 때 selectCell이 좌측에 먼저 sticky로 깔려 있음 — 그 폭만큼
146
+ 좌측 pinned column들의 left offset을 밀어줘야 겹치지 않음. */
147
+ leftOffsetBase = 0,
145
148
  ): CSSProperties {
146
149
  if (!column.pinned) return {};
147
150
  const resolveWidth = (key: string): number => {
@@ -153,7 +156,7 @@ export function pinnedStyle(
153
156
  };
154
157
  const idx = orderedKeys.indexOf(column.key);
155
158
  if (column.pinned === "left") {
156
- let offset = 0;
159
+ let offset = leftOffsetBase;
157
160
  for (let i = 0; i < idx; i++) {
158
161
  const c = columnMap.get(orderedKeys[i]);
159
162
  if (c?.pinned === "left") offset += resolveWidth(orderedKeys[i]);
@@ -391,6 +394,25 @@ const TableHeader = forwardRef<HTMLDivElement, TableHeaderProps>(function TableH
391
394
  .map((k) => columnMap.get(k))
392
395
  .filter((c): c is TableHeaderColumn => !!c);
393
396
 
397
+ /* selectCell이 렌더되면 그 실제 폭을 측정해 좌측 pinned columns의 left offset에
398
+ 더해준다. CSS 토큰(--iconbtn-h-lg / --control-h-sm) + space-scale 반영이 자동으로
399
+ 흡수되므로 하드코딩이나 CSS 변수 파싱 없이 처리 가능. */
400
+ const selectCellRef = useRef<HTMLDivElement>(null);
401
+ const [selectCellWidth, setSelectCellWidth] = useState(0);
402
+ useEffect(() => {
403
+ if (!selectable) {
404
+ setSelectCellWidth(0);
405
+ return;
406
+ }
407
+ const el = selectCellRef.current;
408
+ if (!el) return;
409
+ const update = () => setSelectCellWidth(el.getBoundingClientRect().width);
410
+ update();
411
+ const ro = new ResizeObserver(update);
412
+ ro.observe(el);
413
+ return () => ro.disconnect();
414
+ }, [selectable]);
415
+
394
416
  /* Sort ─────────────────────────────────────────────────── */
395
417
  const toggleSort = useCallback(
396
418
  (key: string) => {
@@ -494,7 +516,7 @@ const TableHeader = forwardRef<HTMLDivElement, TableHeaderProps>(function TableH
494
516
  const cells = (
495
517
  <>
496
518
  {selectable && (
497
- <div className={styles.selectCell} role="columnheader">
519
+ <div ref={selectCellRef} className={styles.selectCell} role="columnheader">
498
520
  <Checkbox
499
521
  checked={selectAllChecked}
500
522
  onChange={(e) => onSelectAllChange?.(e.currentTarget.checked)}
@@ -521,7 +543,7 @@ const TableHeader = forwardRef<HTMLDivElement, TableHeaderProps>(function TableH
521
543
  className={styles.cellSlot}
522
544
  style={{
523
545
  ...cellSlotStyle(col, widths[col.key]),
524
- ...pinnedStyle(col, order, columnMap, widths),
546
+ ...pinnedStyle(col, order, columnMap, widths, selectCellWidth),
525
547
  }}
526
548
  >
527
549
  <Cell
@@ -548,7 +570,7 @@ const TableHeader = forwardRef<HTMLDivElement, TableHeaderProps>(function TableH
548
570
  className={styles.cellSlot}
549
571
  style={{
550
572
  ...cellSlotStyle(col, widths[col.key]),
551
- ...pinnedStyle(col, order, columnMap, widths),
573
+ ...pinnedStyle(col, order, columnMap, widths, selectCellWidth),
552
574
  }}
553
575
  >
554
576
  <Cell
@@ -50,18 +50,22 @@
50
50
  border-bottom: none;
51
51
  }
52
52
 
53
- /* line variant는 셀 자체 좌우 패딩을 먹지 않는다 — Header line과 짝.
54
- 콘텐츠가 테이블 좌/우 경계에 바짝 붙어 "선 사이의 데이터" 톤 유지.
55
- plate에서는 .cell 기본 padding-inline을 유지해 셀 간 간격·hover 영역 확보. */
53
+ /* line variant 내부 셀 좌우 패딩. 콘텐츠가 셀 경계(세로선 위치)에서 떨어져
54
+ 있도록 안쪽에 숨구멍을 둔다. */
56
55
  .line .cell {
57
- padding-inline: 0;
56
+ padding-inline: var(--space-2);
58
57
  }
59
58
 
60
- /* line variant는 셀 사이에 최소한의 숨 쉴 공간(gap)을 둔다 — 셀 패딩이 0이라
61
- 콘텐츠가 서로 바짝 붙는 걸 방지. plate는 셀 자체 패딩으로 간격이 생겨 불필요. */
62
- .line {
63
- column-gap: var(--space-2);
59
+ /* 첫 셀의 왼쪽 / 마지막 셀의 오른쪽만 테이블 가장자리에 바짝 붙게 padding 0.
60
+ 가장자리 콘텐츠가 shell 경계에서 띄워 보이지 않도록. */
61
+ .line .cellSlot:first-child .cell {
62
+ padding-inline-start: 0;
64
63
  }
64
+ .line .cellSlot:last-child .cell {
65
+ padding-inline-end: 0;
66
+ }
67
+
68
+ /* 셀 간 추가 column-gap 없음 — 셀 자체 좌우 padding이 셀 사이 간격을 모두 담당. */
65
69
 
66
70
  /* ── Variant: plate ────────────────────────────────────────
67
71
  라운드 라인. 셀 간격감이 느껴지되 행 자체는 pill처럼 보임.
@@ -141,6 +145,21 @@ a.root:not(.disabled):focus-visible,
141
145
  contain: none;
142
146
  }
143
147
 
148
+ /* pinned 경계 — scroll shadow 대신 상시 hairline으로 "여기까지 고정 영역"을 분명히.
149
+ 좌측 pinned → 우측 경계에 세로 hairline. 우측 pinned → 좌측 경계. */
150
+ .cellSlot[data-pinned="left"] {
151
+ border-right: var(--hairline) solid var(--border);
152
+ }
153
+ .cellSlot[data-pinned="right"] {
154
+ border-left: var(--hairline) solid var(--border);
155
+ }
156
+
157
+ /* selectable + pinned 조합일 때 selectCell이 "실질적 좌측 pinned의 선두" 역할이라
158
+ selectCell에도 우측 hairline을 그어 경계 톤 일치. */
159
+ .selectCell {
160
+ border-right: var(--hairline) solid var(--border);
161
+ }
162
+
144
163
  /* ── Cell ───────────────────────────────────────────────
145
164
  Header의 .cell과 1:1 동일. slot 안쪽 width:100%로 가득 채우는 콘텐츠 그릇.
146
165
  display:inline-flex 는 Header 그대로 따라감 — flex vs inline-flex 차이가
@@ -279,12 +298,66 @@ a.root:not(.disabled):focus-visible,
279
298
  }
280
299
 
281
300
  /* ── Select cell (leading checkbox) ────────────────────
282
- 체크박스가 자체 크기를 가져 중앙 정렬만 하면 됨 — 좌우 패딩 불필요. */
301
+ line variant: 좌측 정렬 — 테이블이 경계에 바짝 붙는 톤이라 체크박스도 좌측에 붙임.
302
+ plate variant: 가운데 정렬 — pill 안쪽 좌 padding(space-1)과 체크박스 사이가 너무
303
+ 붙어 보이는 걸 방지.
304
+
305
+ position: sticky — 좌측 pinned 컬럼과 짝. 가로스크롤 시에도 체크박스가 좌측에
306
+ 유지되도록. 가로스크롤 없으면 sticky는 아무 효과도 안 내므로 모든 테이블에
307
+ 안전하게 적용 가능.
308
+ 배경(--bg)과 z-index — sticky 상태에서 뒤 셀 콘텐츠가 비치지 않도록. */
283
309
  .selectCell {
284
- position: relative;
310
+ position: sticky;
311
+ left: 0;
312
+ z-index: var(--z-raise);
285
313
  display: inline-flex;
286
314
  align-items: center;
287
315
  justify-content: flex-start;
288
316
  flex-shrink: 0;
317
+ width: var(--iconbtn-h-lg);
318
+ background: var(--bg);
319
+ }
320
+
321
+ /* row hover/active 톤을 selectCell에도 — ::before 오버레이로 깔아 opacity transition
322
+ 가능하게. background 직접 변경은 gradient 때문에 CSS가 보간을 못 해서 즉시 교체돼
323
+ row 트랜지션과 안 맞춰짐. opacity transition은 row의 background transition과 동일한
324
+ duration/ease라 자연스레 함께 움직임. */
325
+ .selectCell::before,
326
+ .cellSlot[data-pinned]::before {
327
+ content: "";
328
+ position: absolute;
329
+ inset: 0;
330
+ background: var(--hover);
331
+ opacity: 0;
332
+ pointer-events: none;
333
+ transition: opacity var(--duration-slow) var(--ease-out);
334
+ }
335
+
336
+ a.root:not(.disabled):hover .selectCell::before,
337
+ .root[tabindex]:not(.disabled):hover .selectCell::before,
338
+ .active .selectCell::before,
339
+ a.root:not(.disabled):hover .cellSlot[data-pinned]::before,
340
+ .root[tabindex]:not(.disabled):hover .cellSlot[data-pinned]::before,
341
+ .active .cellSlot[data-pinned]::before {
342
+ opacity: 1;
343
+ }
344
+
345
+ /* selectCell 안쪽 콘텐츠(Checkbox)는 ::before 위에 올라가야 함. */
346
+ .selectCell > * {
347
+ position: relative;
348
+ z-index: var(--z-raise);
349
+ }
350
+
351
+ /* plate: 체크박스 영역엔 bg 깔지 않음 — plate pill 자체가 bg를 가져 selectCell이
352
+ 별도 bg면 pill 안에 네모가 뜸. sticky는 유지하되 투명으로. */
353
+ .plate .selectCell {
354
+ justify-content: center;
289
355
  width: var(--control-h-sm);
356
+ background: transparent;
357
+ }
358
+
359
+ a.plate.root:not(.disabled):hover .selectCell,
360
+ .plate.root[tabindex]:not(.disabled):hover .selectCell,
361
+ .plate.active .selectCell {
362
+ background: transparent;
290
363
  }
@@ -2,6 +2,9 @@
2
2
 
3
3
  import {
4
4
  forwardRef,
5
+ useEffect,
6
+ useRef,
7
+ useState,
5
8
  type AnchorHTMLAttributes,
6
9
  type CSSProperties,
7
10
  type HTMLAttributes,
@@ -11,10 +14,11 @@ import {
11
14
  } from "react";
12
15
 
13
16
  import { Checkbox } from "../Checkbox";
14
- import type {
15
- TableHeaderAlign,
16
- TableHeaderColumn,
17
- TableHeaderVariant,
17
+ import {
18
+ pinnedStyle,
19
+ type TableHeaderAlign,
20
+ type TableHeaderColumn,
21
+ type TableHeaderVariant,
18
22
  } from "../TableHeader";
19
23
  import styles from "./TableRow.module.css";
20
24
 
@@ -142,6 +146,25 @@ function TableRowInner<T>(
142
146
  const orderedKeys = resolveOrder(columns, order);
143
147
  const columnMap = new Map(columns.map((c) => [c.key, c]));
144
148
 
149
+ /* selectCell 폭을 측정해 좌측 pinned column의 left offset에 반영.
150
+ Header와 동일한 로직 — selectCell이 sticky left:0이라 pinned column들이 그 뒤에
151
+ 위치하려면 폭만큼 밀어야 함. */
152
+ const selectCellRef = useRef<HTMLDivElement>(null);
153
+ const [selectCellWidth, setSelectCellWidth] = useState(0);
154
+ useEffect(() => {
155
+ if (!selectable) {
156
+ setSelectCellWidth(0);
157
+ return;
158
+ }
159
+ const el = selectCellRef.current;
160
+ if (!el) return;
161
+ const update = () => setSelectCellWidth(el.getBoundingClientRect().width);
162
+ update();
163
+ const ro = new ResizeObserver(update);
164
+ ro.observe(el);
165
+ return () => ro.disconnect();
166
+ }, [selectable]);
167
+
145
168
  const rootClass = [
146
169
  styles.root,
147
170
  variant === "plate" ? styles.plate : styles.line,
@@ -162,7 +185,7 @@ function TableRowInner<T>(
162
185
  const cells = (
163
186
  <>
164
187
  {selectable && (
165
- <div className={styles.selectCell} onClick={stop}>
188
+ <div ref={selectCellRef} className={styles.selectCell} onClick={stop}>
166
189
  <Checkbox
167
190
  checked={selected}
168
191
  disabled={disabled}
@@ -183,10 +206,16 @@ function TableRowInner<T>(
183
206
  minWidth: toCssSize(col.minWidth) ?? cssWidth,
184
207
  flex: col.flex ? "1 1 0" : undefined,
185
208
  justifyContent: alignToJustify(col.align),
209
+ ...pinnedStyle(col, orderedKeys, columnMap, widths ?? {}, selectCellWidth),
186
210
  };
187
211
  const content = col.render ? col.render(data) : (data as Record<string, ReactNode>)?.[col.key];
188
212
  return (
189
- <div key={col.key} className={styles.cellSlot} style={slotStyle}>
213
+ <div
214
+ key={col.key}
215
+ data-pinned={col.pinned || undefined}
216
+ className={styles.cellSlot}
217
+ style={slotStyle}
218
+ >
190
219
  <div role="cell" className={styles.cell} data-align={col.align ?? "start"}>
191
220
  <span className={styles.cellInner}>{content}</span>
192
221
  </div>
@@ -16,9 +16,8 @@
16
16
  .edit {
17
17
  cursor: default;
18
18
  border-bottom: var(--hairline) solid var(--border);
19
- /* TableHeader(.line)와 동일한 column-gap. gap이 다르면 각 셀 폭이 오른쪽으로
20
- 갈수록 어긋나 헤더 resize handle 위치와 데이터 Row 세로선이 안 맞음. */
21
- column-gap: var(--space-2);
19
+ /* column-gap 없음 — 셀 자체 padding-inline이 간격 담당. TableHeader/TableRow
20
+ line과 동일 공식으로 통일(gap 쓰면 셀 폭이 오른쪽으로 갈수록 어긋남). */
22
21
  }
23
22
 
24
23
  /* ── Size ─────────────────────────────────────────────────
@@ -42,6 +41,16 @@
42
41
  -webkit-mask-image: none;
43
42
  }
44
43
 
44
+ /* 첫/마지막 셀 가장자리 padding만 0 — 테이블 좌/우 끝과 콘텐츠 바짝 붙게. */
45
+ .edit > :first-child .editCell,
46
+ .edit > :first-child .readonlyCell {
47
+ padding-inline-start: 0;
48
+ }
49
+ .edit > :last-child .editCell,
50
+ .edit > :last-child .readonlyCell {
51
+ padding-inline-end: 0;
52
+ }
53
+
45
54
  /* ── 세로 구분선 ────────────────────────────────────────── */
46
55
  .editCell[data-divider]::before,
47
56
  .readonlyCell[data-divider]::before {
@@ -598,7 +598,7 @@ export const componentSlugs: Record<string, string> = {
598
598
 
599
599
  /** 작업중 — 사이드바에서 접근은 가능하지만 "작업중" 뱃지로 표시해 사용 자제 유도. */
600
600
  export const componentsInProgress: Set<string> = new Set([
601
- "Table Row",
601
+ "Drawer",
602
602
  "Chat Input",
603
603
  ]);
604
604
 
@@ -25,6 +25,7 @@ shape 애매하면 `multi`, radius 애매하면 `rounded`. maxRows 애매하면
25
25
  - [[components/file-icon]] / [[components/attachment-thumb]] / [[components/attachment-chip]] / [[components/quotation-bar]] — 조립 요소
26
26
  - [[components/message-bubble]] — 짝 컴포넌트 (후속)
27
27
  - [[components/context-menu]] — 모델 선택 메뉴를 소비처에서 조립할 때
28
+ - [[primitives/scroll]] — "Textarea / 단일 콘텐츠 스크롤" 섹션. 긴 글 스크롤 시 글이 root border에서 clip되도록 wrapper padding-top + textarea padding-bottom 패턴 사용. 레이아웃 변경 시 반드시 그 규약 유지.
28
29
 
29
30
  ### Props 시그니처
30
31
  ```tsx
@@ -73,3 +73,72 @@
73
73
  padding: var(--space-2);
74
74
  }
75
75
  ```
76
+
77
+ ---
78
+
79
+ ## Textarea / 단일 콘텐츠 스크롤
80
+
81
+ 리스트가 아니라 **텍스트 하나**가 자라서 스크롤되는 경우(채팅 입력창, 코드 에디터, Markdown preview 등). 리스트 규약과 다른 **특수 패턴** 필요 — 브라우저가 scroll 컨테이너의 `padding-bottom`을 scroll 영역에 포함시키지 않는 알려진 이슈(Chrome/Safari/Firefox 공통) 때문.
82
+
83
+ ### 왜 특수한가
84
+ - 리스트는 "items wrapper에 padding"이면 끝 — 각 item 자체가 콘텐츠라 scroll content의 일부.
85
+ - textarea는 자신이 scroll 컨테이너면 **padding-top은 scroll track에 포함**(글이 거기까지 흘러옴)되지만, **padding-bottom은 scroll content 바깥**이 되어 "글이 bottom padding 앞에서 미리 잘리는" 현상이 생김.
86
+
87
+ ### 금지 패턴 (실제 ChatInput 작업 중 겪은 삽질)
88
+
89
+ ❌ **"root 전체에 padding"** — 글이 root padding 안쪽에서만 보이고 scroll clip이 root 안쪽 border에서 생겨 어색:
90
+ ```css
91
+ .chatInput { padding: var(--space-2); }
92
+ .textarea { overflow-y: auto; }
93
+ ```
94
+
95
+ ❌ **"textarea 자체가 scroll + 상하 padding"** — padding-bottom이 scroll 밖이라 작동 안 함:
96
+ ```css
97
+ .textarea { overflow-y: auto; padding: var(--space-1); }
98
+ ```
99
+
100
+ ### 정답 패턴 (ChatInput이 쓰는 구조)
101
+
102
+ 1. **root는 `padding: 0; overflow: hidden`** — scroll clip이 root border에서 생기도록.
103
+ 2. **별도 wrapper div(`.scroll`)에 overflow-y: auto + padding-top만**.
104
+ 3. **textarea는 flat, `padding-bottom`만** — 이게 scroll content의 일부가 됨.
105
+
106
+ ```css
107
+ .root {
108
+ padding: 0;
109
+ overflow: hidden; /* radius 안쪽에서 scroll clip */
110
+ border-radius: var(--radius-sm);
111
+ border: var(--hairline) solid var(--border);
112
+ }
113
+
114
+ .scroll {
115
+ overflow-y: auto;
116
+ max-height: calc(var(--text-base) * 1.5 * 8);
117
+ padding: var(--space-3) var(--space-3) 0; /* top만 */
118
+ }
119
+
120
+ .textarea {
121
+ display: block;
122
+ width: 100%;
123
+ padding: 0 0 var(--space-3); /* bottom만 */
124
+ background: transparent;
125
+ border: 0;
126
+ outline: none;
127
+ resize: none;
128
+ overflow: hidden; /* 자체 스크롤 금지 */
129
+ font: inherit;
130
+ line-height: 1.5;
131
+ }
132
+ ```
133
+
134
+ 이 구조에서 짧은 글은 wrapper 안에 스크롤 없음, padding이 "숨 공간"처럼 보임.
135
+ 긴 글은:
136
+ - wrapper max-height 초과 → wrapper 스크롤
137
+ - 아래로 스크롤 시 글이 wrapper padding-top을 통과해 **root 상단 border에서 자연스레 clip**
138
+ - 끝까지 스크롤 시 글이 textarea padding-bottom을 통과해 **root 하단 border에서 자연스레 clip**
139
+
140
+ ### 함께 적용되는 레이아웃 룰
141
+
142
+ - `root`에 다른 섹션(attachment bar, footer 등)이 함께 있으면 각 섹션이 **자기 상하 padding을 책임**. `root`는 어떤 상·하 패딩도 걸지 않음.
143
+ - textarea JS autoResize는 `textarea.style.height = textarea.scrollHeight` 패턴. `scrollHeight`가 padding 포함이라 그대로 됨.
144
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conscience-technology/ctds",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "CTDS — 하이브리드 디자인 시스템 설치 · 온보딩 CLI",
5
5
  "type": "module",
6
6
  "bin": {