@conscience-technology/ctds 0.1.6 → 0.1.7

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-4) 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;
@@ -143,7 +143,7 @@
143
143
  outline: none;
144
144
  color: var(--fg-strong);
145
145
  font: inherit;
146
- font-size: var(--text-base);
146
+ font-size: var(--text-sm);
147
147
  line-height: 1.5;
148
148
  }
149
149
  .singleField::placeholder {
@@ -179,8 +179,8 @@
179
179
  opacity: 0.4;
180
180
  }
181
181
  .attachIcon {
182
- width: 68%;
183
- height: 68%;
182
+ width: 50%;
183
+ height: 50%;
184
184
  }
185
185
 
186
186
  /* model + send */
@@ -265,8 +265,8 @@
265
265
  cursor: not-allowed;
266
266
  }
267
267
  .sendIcon {
268
- width: 55%;
269
- height: 55%;
268
+ width: 44%;
269
+ height: 44%;
270
270
  }
271
271
 
272
272
  /* 파일 input 숨김 */
@@ -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으로 시각 무게 유지. */
@@ -260,13 +263,24 @@
260
263
  }
261
264
 
262
265
  /* Select-all cell (leading checkbox) ─────────────────────
263
- 체크박스 자체가 width/height를 가져 중앙 정렬만 하면 됨 — 좌우 패딩 불필요. */
266
+ line variant: 좌측 정렬. plate variant: 가운데 정렬. TableRow와 짝 맞춤.
267
+ position: sticky — TableRow.selectCell과 짝. 좌측 고정으로 가로스크롤에도 유지. */
264
268
  .selectCell {
269
+ position: sticky;
270
+ left: 0;
271
+ z-index: var(--z-raise);
265
272
  display: inline-flex;
266
273
  align-items: center;
267
274
  justify-content: flex-start;
268
275
  flex-shrink: 0;
276
+ width: var(--iconbtn-h-lg);
277
+ background: var(--bg);
278
+ }
279
+
280
+ .plate .selectCell {
281
+ justify-content: center;
269
282
  width: var(--control-h-sm);
283
+ background: var(--bg-subtle);
270
284
  }
271
285
 
272
286
  /* 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]);
@@ -50,19 +50,23 @@
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;
63
+ }
64
+ .line .cellSlot:last-child .cell {
65
+ padding-inline-end: 0;
64
66
  }
65
67
 
68
+ /* 셀 간 추가 column-gap 없음 — 셀 자체 좌우 padding이 셀 사이 간격을 모두 담당. */
69
+
66
70
  /* ── Variant: plate ────────────────────────────────────────
67
71
  라운드 라인. 셀 간격감이 느껴지되 행 자체는 pill처럼 보임.
68
72
  hover/active 배경은 라운드 안쪽에서만 발생.
@@ -279,12 +283,66 @@ a.root:not(.disabled):focus-visible,
279
283
  }
280
284
 
281
285
  /* ── Select cell (leading checkbox) ────────────────────
282
- 체크박스가 자체 크기를 가져 중앙 정렬만 하면 됨 — 좌우 패딩 불필요. */
286
+ line variant: 좌측 정렬 — 테이블이 경계에 바짝 붙는 톤이라 체크박스도 좌측에 붙임.
287
+ plate variant: 가운데 정렬 — pill 안쪽 좌 padding(space-1)과 체크박스 사이가 너무
288
+ 붙어 보이는 걸 방지.
289
+
290
+ position: sticky — 좌측 pinned 컬럼과 짝. 가로스크롤 시에도 체크박스가 좌측에
291
+ 유지되도록. 가로스크롤 없으면 sticky는 아무 효과도 안 내므로 모든 테이블에
292
+ 안전하게 적용 가능.
293
+ 배경(--bg)과 z-index — sticky 상태에서 뒤 셀 콘텐츠가 비치지 않도록. */
283
294
  .selectCell {
284
- position: relative;
295
+ position: sticky;
296
+ left: 0;
297
+ z-index: var(--z-raise);
285
298
  display: inline-flex;
286
299
  align-items: center;
287
300
  justify-content: flex-start;
288
301
  flex-shrink: 0;
302
+ width: var(--iconbtn-h-lg);
303
+ background: var(--bg);
304
+ }
305
+
306
+ /* row hover/active 톤을 selectCell에도 — ::before 오버레이로 깔아 opacity transition
307
+ 가능하게. background 직접 변경은 gradient 때문에 CSS가 보간을 못 해서 즉시 교체돼
308
+ row 트랜지션과 안 맞춰짐. opacity transition은 row의 background transition과 동일한
309
+ duration/ease라 자연스레 함께 움직임. */
310
+ .selectCell::before,
311
+ .cellSlot[data-pinned]::before {
312
+ content: "";
313
+ position: absolute;
314
+ inset: 0;
315
+ background: var(--hover);
316
+ opacity: 0;
317
+ pointer-events: none;
318
+ transition: opacity var(--duration-slow) var(--ease-out);
319
+ }
320
+
321
+ a.root:not(.disabled):hover .selectCell::before,
322
+ .root[tabindex]:not(.disabled):hover .selectCell::before,
323
+ .active .selectCell::before,
324
+ a.root:not(.disabled):hover .cellSlot[data-pinned]::before,
325
+ .root[tabindex]:not(.disabled):hover .cellSlot[data-pinned]::before,
326
+ .active .cellSlot[data-pinned]::before {
327
+ opacity: 1;
328
+ }
329
+
330
+ /* selectCell 안쪽 콘텐츠(Checkbox)는 ::before 위에 올라가야 함. */
331
+ .selectCell > * {
332
+ position: relative;
333
+ z-index: var(--z-raise);
334
+ }
335
+
336
+ /* plate: 체크박스 영역엔 bg 깔지 않음 — plate pill 자체가 bg를 가져 selectCell이
337
+ 별도 bg면 pill 안에 네모가 뜸. sticky는 유지하되 투명으로. */
338
+ .plate .selectCell {
339
+ justify-content: center;
289
340
  width: var(--control-h-sm);
341
+ background: transparent;
342
+ }
343
+
344
+ a.plate.root:not(.disabled):hover .selectCell,
345
+ .plate.root[tabindex]:not(.disabled):hover .selectCell,
346
+ .plate.active .selectCell {
347
+ background: transparent;
290
348
  }
@@ -11,10 +11,11 @@ import {
11
11
  } from "react";
12
12
 
13
13
  import { Checkbox } from "../Checkbox";
14
- import type {
15
- TableHeaderAlign,
16
- TableHeaderColumn,
17
- TableHeaderVariant,
14
+ import {
15
+ pinnedStyle,
16
+ type TableHeaderAlign,
17
+ type TableHeaderColumn,
18
+ type TableHeaderVariant,
18
19
  } from "../TableHeader";
19
20
  import styles from "./TableRow.module.css";
20
21
 
@@ -183,10 +184,16 @@ function TableRowInner<T>(
183
184
  minWidth: toCssSize(col.minWidth) ?? cssWidth,
184
185
  flex: col.flex ? "1 1 0" : undefined,
185
186
  justifyContent: alignToJustify(col.align),
187
+ ...pinnedStyle(col, orderedKeys, columnMap, widths ?? {}),
186
188
  };
187
189
  const content = col.render ? col.render(data) : (data as Record<string, ReactNode>)?.[col.key];
188
190
  return (
189
- <div key={col.key} className={styles.cellSlot} style={slotStyle}>
191
+ <div
192
+ key={col.key}
193
+ data-pinned={col.pinned || undefined}
194
+ className={styles.cellSlot}
195
+ style={slotStyle}
196
+ >
190
197
  <div role="cell" className={styles.cell} data-align={col.align ?? "start"}>
191
198
  <span className={styles.cellInner}>{content}</span>
192
199
  </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 {
@@ -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.7",
4
4
  "description": "CTDS — 하이브리드 디자인 시스템 설치 · 온보딩 CLI",
5
5
  "type": "module",
6
6
  "bin": {