@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.
- package/assets/components/ChatInput/ChatInput.module.css +14 -14
- package/assets/components/Drawer/Drawer.module.css +1 -3
- package/assets/components/Onboarding/Onboarding.module.css +31 -1
- package/assets/components/Onboarding/OnboardingPage.tsx +124 -46
- package/assets/components/Onboarding/wizardData.ts +47 -12
- package/assets/components/TableHeader/TableHeader.module.css +22 -8
- package/assets/components/TableHeader/TableHeader.tsx +4 -1
- package/assets/components/TableRow/TableRow.module.css +68 -10
- package/assets/components/TableRow/TableRow.tsx +12 -5
- package/assets/components/TableRowEdit/TableRowEdit.module.css +12 -3
- package/assets/prompts/components/chat-input.md +1 -0
- package/assets/prompts/primitives/scroll.md +69 -0
- package/package.json +1 -1
|
@@ -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-
|
|
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-
|
|
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-
|
|
92
|
-
padding: var(--space-3) var(--space-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
135
|
-
padding: var(--space-2) var(--space-
|
|
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-
|
|
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:
|
|
183
|
-
height:
|
|
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:
|
|
269
|
-
height:
|
|
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별 자리·테두리 —
|
|
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(--
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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:
|
|
232
|
-
|
|
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 &&
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
<
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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 =
|
|
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: "
|
|
195
|
-
title: "
|
|
196
|
-
hint: "
|
|
197
|
-
examples: "
|
|
198
|
-
sidebar: "
|
|
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: "
|
|
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
|
|
52
|
-
콘텐츠가 테이블 좌/우 경계에 바짝 붙어 "선 사이의 데이터" 톤 유지.
|
|
53
|
-
plate에선 .cell 기본 padding-inline(--space-2)을 유지. */
|
|
51
|
+
/* line variant 셀 좌우 패딩 — TableRow.line과 짝. 콘텐츠가 셀 경계에서 떨어짐. */
|
|
54
52
|
.line .cell {
|
|
55
|
-
padding-inline:
|
|
53
|
+
padding-inline: var(--space-2);
|
|
56
54
|
}
|
|
57
55
|
|
|
58
|
-
/*
|
|
59
|
-
.line {
|
|
60
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
54
|
-
|
|
55
|
-
plate에서는 .cell 기본 padding-inline을 유지해 셀 간 간격·hover 영역 확보. */
|
|
53
|
+
/* line variant 내부 셀 좌우 패딩. 콘텐츠가 셀 경계(세로선 위치)에서 떨어져
|
|
54
|
+
있도록 안쪽에 숨구멍을 둔다. */
|
|
56
55
|
.line .cell {
|
|
57
|
-
padding-inline:
|
|
56
|
+
padding-inline: var(--space-2);
|
|
58
57
|
}
|
|
59
58
|
|
|
60
|
-
/*
|
|
61
|
-
콘텐츠가
|
|
62
|
-
.line {
|
|
63
|
-
|
|
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:
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
-
/*
|
|
20
|
-
|
|
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
|
+
|