@conscience-technology/ctds 0.3.1 → 0.3.2

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.
@@ -1351,3 +1351,189 @@
1351
1351
  border-radius: var(--radius-sm);
1352
1352
  }
1353
1353
 
1354
+ /* ── LayoutBuilder (shell preview) ────────────────────── */
1355
+
1356
+ .layoutGrid {
1357
+ display: grid;
1358
+ grid-template-columns: minmax(var(--space-12), var(--space-16)) 1fr;
1359
+ gap: var(--space-5);
1360
+ min-height: 0;
1361
+ }
1362
+ .layoutOptions {
1363
+ display: flex;
1364
+ flex-direction: column;
1365
+ gap: var(--space-4);
1366
+ min-width: 0;
1367
+ }
1368
+ .optionGroup {
1369
+ display: flex;
1370
+ flex-direction: column;
1371
+ gap: var(--space-2);
1372
+ min-width: 0;
1373
+ }
1374
+ .optionGroupLabel {
1375
+ font-size: var(--text-sm);
1376
+ font-weight: var(--font-weight-medium);
1377
+ color: var(--fg-strong);
1378
+ }
1379
+ .optionList {
1380
+ display: flex;
1381
+ flex-direction: column;
1382
+ gap: var(--space-1);
1383
+ }
1384
+ .optionItem {
1385
+ display: flex;
1386
+ align-items: center;
1387
+ min-height: var(--control-h-md);
1388
+ padding: 0 var(--space-3);
1389
+ border: var(--hairline) solid var(--border);
1390
+ border-radius: var(--radius-md);
1391
+ background: transparent;
1392
+ color: var(--fg);
1393
+ font: inherit;
1394
+ font-size: var(--text-sm);
1395
+ text-align: left;
1396
+ cursor: pointer;
1397
+ transition: var(--transition-colors);
1398
+ }
1399
+ .optionItem:hover {
1400
+ background: var(--hover);
1401
+ }
1402
+ .optionItem[data-active] {
1403
+ background: var(--secondary-bg);
1404
+ border-color: var(--border-strong);
1405
+ color: var(--fg-strong);
1406
+ }
1407
+ .field {
1408
+ display: flex;
1409
+ flex-direction: column;
1410
+ gap: var(--space-2);
1411
+ min-width: 0;
1412
+ }
1413
+ .fieldLabel {
1414
+ font-size: var(--text-sm);
1415
+ font-weight: var(--font-weight-medium);
1416
+ color: var(--fg-strong);
1417
+ }
1418
+ .fieldControl {
1419
+ min-height: var(--control-h-md);
1420
+ padding: 0 var(--space-3);
1421
+ border: var(--hairline) solid var(--border);
1422
+ border-radius: var(--radius-md);
1423
+ background: transparent;
1424
+ color: var(--fg);
1425
+ font: inherit;
1426
+ font-size: var(--text-sm);
1427
+ transition: var(--transition-colors);
1428
+ }
1429
+ .fieldControl:focus-visible {
1430
+ outline: none;
1431
+ box-shadow: var(--ring);
1432
+ }
1433
+
1434
+ .mini {
1435
+ display: grid;
1436
+ min-height: var(--space-16);
1437
+ border-radius: var(--radius-lg);
1438
+ background: var(--bg-subtle);
1439
+ overflow: hidden;
1440
+ grid-template-areas:
1441
+ "sidebar body"
1442
+ "sidebar body";
1443
+ grid-template-columns: var(--space-12) 1fr;
1444
+ grid-template-rows: 1fr;
1445
+ }
1446
+ .mini[data-topbar="true"] {
1447
+ grid-template-areas:
1448
+ "sidebar topbar"
1449
+ "sidebar body";
1450
+ grid-template-rows: auto 1fr;
1451
+ }
1452
+ .mini[data-sidebar="none"] {
1453
+ grid-template-areas:
1454
+ "body body"
1455
+ "body body";
1456
+ grid-template-columns: 1fr;
1457
+ }
1458
+ .mini[data-sidebar="none"][data-topbar="true"] {
1459
+ grid-template-areas:
1460
+ "topbar topbar"
1461
+ "body body";
1462
+ }
1463
+ .mini[data-sidebar="dual"] {
1464
+ grid-template-areas:
1465
+ "sidebar body sidebar-right"
1466
+ "sidebar body sidebar-right";
1467
+ grid-template-columns: var(--space-12) 1fr var(--space-12);
1468
+ }
1469
+ .mini[data-sidebar="dual"][data-topbar="true"] {
1470
+ grid-template-areas:
1471
+ "sidebar topbar sidebar-right"
1472
+ "sidebar body sidebar-right";
1473
+ }
1474
+
1475
+ .miniTopbar {
1476
+ grid-area: topbar;
1477
+ display: flex;
1478
+ align-items: center;
1479
+ gap: var(--space-2);
1480
+ padding: 0 var(--space-4);
1481
+ min-height: var(--control-h-md);
1482
+ background: var(--bg);
1483
+ border-bottom: var(--hairline) solid var(--border);
1484
+ }
1485
+ .miniSidebar {
1486
+ grid-area: sidebar;
1487
+ display: flex;
1488
+ flex-direction: column;
1489
+ gap: var(--space-1);
1490
+ padding: var(--space-3);
1491
+ background: var(--bg);
1492
+ border-right: var(--hairline) solid var(--border);
1493
+ }
1494
+ .miniSidebarRight {
1495
+ grid-area: sidebar-right;
1496
+ border-right: none;
1497
+ border-left: var(--hairline) solid var(--border);
1498
+ }
1499
+ .miniBody {
1500
+ grid-area: body;
1501
+ display: flex;
1502
+ align-items: center;
1503
+ justify-content: center;
1504
+ padding: var(--space-4);
1505
+ color: var(--fg-muted);
1506
+ font-size: var(--text-sm);
1507
+ text-align: center;
1508
+ }
1509
+ .miniBrand {
1510
+ font-size: var(--text-sm);
1511
+ font-weight: var(--font-weight-medium);
1512
+ color: var(--fg-strong);
1513
+ }
1514
+ .miniBrandSymbol {
1515
+ width: var(--space-5);
1516
+ height: var(--space-5);
1517
+ border-radius: var(--radius-sm);
1518
+ object-fit: cover;
1519
+ flex-shrink: 0;
1520
+ }
1521
+ .miniBrandImg {
1522
+ height: var(--space-5);
1523
+ max-width: var(--space-12);
1524
+ object-fit: contain;
1525
+ }
1526
+ .miniNavItem {
1527
+ display: flex;
1528
+ align-items: center;
1529
+ min-height: var(--control-h-sm);
1530
+ padding: 0 var(--space-2);
1531
+ border-radius: var(--radius-sm);
1532
+ color: var(--fg);
1533
+ font-size: var(--text-sm);
1534
+ }
1535
+ .miniNavItem[data-active] {
1536
+ background: var(--secondary-bg);
1537
+ color: var(--fg-strong);
1538
+ }
1539
+
@@ -1,20 +1,21 @@
1
1
  /* Root ─────────────────────────────────────────────────
2
2
  세 영역 구조: Header(상단 고정, flex:0) · Body(flex:1, 세로 스크롤) · Footer(하단 고정, flex:0).
3
- padding-inline은 두지 않음 — Body가 스크롤 컨테이너라, root에 좌우 padding을 두면
4
- 스크롤바가 padding 안쪽에 생겨 "오른쪽 끝에 붙지 않는" 꼴이 됨.
5
-
6
- 좌우/상하 축 단일화 규칙 (중요):
7
- Sidebar 내부 모든 좌우 padding은 `--sidebar-pad-x`, 모든 상하 padding은
8
- `--sidebar-pad-y` 하나로 통일. 두 토큰 값은 동일(--space-4) — 가로/세로가
9
- 같은 숨돌림을 가져 영역별로 상하 padding이 달라 보이는 어긋남 방지.
10
- Header·Section·Separator·Footer·Body에 삽입하는 바깥 블록 전부 동일 축.
11
- Body 자체엔 padding-inline 금지(스크롤바 가장자리 밀어냄).
12
- Section·Header·Footer는 각자 `padding-inline: var(--sidebar-pad-x)` +
13
- `padding-block: var(--sidebar-pad-y)`로 복제. */
3
+
4
+ 패딩 공급 원칙 (중요):
5
+ Sidebar 벤더는 좌우/상하 패딩을 공급하지 않는다. root/Header/Body/Footer wrapper
6
+ 어디에도 padding-inline·padding-block을 깔지 않음. 이유 — Body가 스크롤 컨테이너라
7
+ wrapper에 패딩을 깔면 스크롤바가 가장자리와 떨어지고, 벤더가 자동 공급하면
8
+ 소비처가 그걸 모르고 또 깔아 이중 패딩이 나는 함정이 반복됨.
9
+
10
+ 공급 책임은 소비처에 있음. 두 가지 패턴:
11
+ - 패턴 1 (단일 축): 세 영역이 모두 같은 축을 쓰면, 각 영역의 콘텐츠 래퍼에
12
+ 동일한 padding-inline을 하나씩 깔거나 가장 큰 그룹 하나에 공급.
13
+ - 패턴 2 (섹션별 다른 축): 각 섹션(nav 묶음·profile 등)이 자기 padding을 깐다.
14
+ Minimal/Aurora 템플릿이 이 패턴.
15
+
16
+ Separator의 좌우 inset은 `--separator-inset-x` 변수로 노출(기본 0 = full-bleed).
17
+ 소비처가 inset이 필요하면 토큰 override. */
14
18
  .root {
15
- /* 좌우/상하 축 토큰 — 전 영역이 공유. 두 값은 동일. */
16
- --sidebar-pad-x: var(--space-4);
17
- --sidebar-pad-y: var(--space-4);
18
19
  display: flex;
19
20
  flex-direction: column;
20
21
  width: var(--sidebar-w);
@@ -27,21 +28,14 @@
27
28
  percent-height가 auto로 떨어져 Body가 0px로 붕괴한다. */
28
29
  align-self: stretch;
29
30
  min-height: 0;
30
- /* padding-block을 root에 두면 Footer 없는 구성에서 Body(flex:1) 아래로
31
- 그 패딩만큼 빈 공간이 남는다. 상/하 숨돌림은 Header/Body/Footer가
32
- 각자 담당(Header padding-block, Body padding-top, Footer padding-block). */
33
- transition:
34
- width var(--duration-base) var(--ease-out),
35
- padding var(--duration-base) var(--ease-out);
31
+ transition: width var(--duration-base) var(--ease-out);
36
32
  }
37
33
 
38
34
  .root[data-collapsed] {
39
- --sidebar-pad-x: var(--space-2);
40
- --sidebar-pad-y: var(--space-2);
41
35
  width: var(--sidebar-w-collapsed);
42
36
  }
43
37
 
44
- /* Header / Footer — 좌우는 공통 `--sidebar-pad-x` 축. 상하는 영역별 리듬이 다름. */
38
+ /* Header / Footer — 레이아웃만. 좌우/상하 padding은 소비처가 공급. */
45
39
  .header,
46
40
  .footer {
47
41
  display: flex;
@@ -49,26 +43,12 @@
49
43
  gap: var(--space-2);
50
44
  min-height: var(--control-h-md);
51
45
  flex: 0 0 auto;
52
- padding-inline: var(--sidebar-pad-x);
53
- }
54
-
55
- /* Header — 위 숨돌림을 더 크게(--space-3), 아래는 Brand와 Body 상단 fade mask가
56
- 받으므로 짧게(--space-1). Body `padding-top: --space-3`이 Header bottom을 보완. */
57
- .header {
58
- padding-top: var(--space-3);
59
- padding-bottom: var(--space-1);
60
46
  }
61
47
 
62
- /* Footer — 상하 패딩을 좌우 축(--sidebar-pad-y = --sidebar-pad-x)과 동일하게.
63
- Profile(IdentityButton)의 안쪽 콘텐츠가 Footer border와 사이드바 좌/우에서
64
- 모두 같은 거리를 두도록 사방 균형. */
65
48
  /* Footer — 기본은 선 없음(사이드바 안쪽 ghost 리듬 유지). Body가 스크롤
66
49
  가능할 때만(data-overflow) 상단 hairline을 켬 — 스크롤되는 콘텐츠가 Footer
67
- 뒤로 깔릴 때 분리가 필요하기 때문. 짧은 메뉴에서는 선이 없어 깔끔.
68
- line-heavy theme(spare 등)은 소비처에서 항상 켜고 싶을 수 있는데, 그땐
69
- `[data-sidebar-always-divider]` 등 상위 토글로 해결 — 기본은 overflow 조건부. */
50
+ 뒤로 깔릴 때 분리가 필요하기 때문. 짧은 메뉴에서는 선이 없어 깔끔. */
70
51
  .footer {
71
- padding-block: var(--sidebar-pad-y);
72
52
  margin-top: auto;
73
53
  }
74
54
 
@@ -78,8 +58,7 @@
78
58
 
79
59
  /* Body — 세로 스크롤 영역.
80
60
  여러 개를 두면 각각 독립 스크롤 트랙. flex:1로 남은 공간을 균등 분배.
81
- 상단 fade mask로 Header와 자연스럽게 이어짐 — 하단은 Footer border로 끊음.
82
- padding-bottom으로 Footer 바로 위에 숨돌릴 여백 — scroll 끝에서 마지막 항목이 border에 치이지 않도록. */
61
+ 상하/좌우 padding은 소비처가 Body 안쪽 콘텐츠 래퍼에 공급. */
83
62
  .body {
84
63
  display: flex;
85
64
  flex-direction: column;
@@ -88,43 +67,32 @@
88
67
  min-height: 0;
89
68
  overflow-y: auto;
90
69
  overflow-x: hidden;
91
- /* 'none': 스크롤 체인 차단 + 바운스/러버밴드 자체도 억제.
92
- 스크롤이 생길 콘텐츠면 자기 영역에서만 스크롤, 콘텐츠가 짧으면 바운스 없음. */
70
+ /* 'none': 스크롤 체인 차단 + 바운스/러버밴드 자체도 억제. */
93
71
  overscroll-behavior-y: none;
94
- /* Header 바로 아래 숨돌림 — Footer보다 짧게. Header padding-bottom(--space-1)과
95
- 합쳐서 Body 첫 항목이 Header와 적당히 분리되어 보이는 리듬. */
96
- padding-top: var(--space-2);
97
- /* padding-inline 금지 — 스크롤바가 가장자리에 안 붙음.
98
- 좌우 축은 Section·Separator·외부 삽입 블록이 `--sidebar-pad-x`로 각자 담당. */
72
+ /* padding-inline 금지 — 스크롤바가 가장자리와 떨어짐. 좌우 축은 소비처가
73
+ Body 안쪽 콘텐츠 래퍼(nav/섹션 리스트 등)에 깐다. */
74
+ /* padding-top·padding-bottom도 두지 않음 — Header 아래 숨돌림, Footer 위
75
+ 마지막 항목 숨돌림 모두 소비처가 안쪽 래퍼의 padding-block으로 공급. */
99
76
  /* mask-image 금지 — Body 안에 "고정 상단 블록(railTop 등 primary CTA + 검색)"을
100
77
  꽂는 소비처가 많아서 fade가 걸리면 그 블록 상단이 흐려져 "밝은 입체 바"
101
- 착시를 만듦(특히 primary solid 버튼에서 심함). Header와의 경계는
102
- Header border 혹은 Header padding-bottom으로 처리. */
103
- /* padding-bottom은 두지 않음 — Footer가 있으면 Footer 자체 padding이 받고,
104
- 없으면 콘텐츠가 사이드바 끝까지 자연스럽게 흐르게. */
78
+ 착시를 만듦(특히 primary solid 버튼에서 심함). */
105
79
  }
106
80
 
107
81
  /* Body 자식이 flex column에서 수축돼 header가 뭉개지는 걸 방지.
108
- 각 섹션은 자기 콘텐츠 높이 유지, Body가 overflow-y로 스크롤.
109
- 좌우 축도 여기서 공급 — Body 자체엔 padding-inline 금지(스크롤바 가장자리
110
- 밀어냄). SidebarSection 안 쓰고 MenuItem.Section 등을 Body 직접 자식으로
111
- 꽂는 소비처(docs 사이드바)에서도 좌우 리듬 유지. SidebarSection은 자기
112
- .section에서 같은 토큰 값으로 padding-inline을 걸므로 결과가 동일 — 중복 아님. */
82
+ 좌우/상하 padding 공급은 **하지 않음** — 소비처 책임. */
113
83
  .body > * {
114
84
  flex: 0 0 auto;
115
- padding-inline: var(--sidebar-pad-x);
116
85
  }
117
86
 
118
- /* Brand ───────────────────────────────────────────────
119
- symbol은 항상, wordmark는 expanded일 때만 렌더.
87
+ /* Brand preset ─────────────────────────────────────────
88
+ Header 안에 꽂히는 preset. symbol은 항상, wordmark는 expanded일 때만 렌더.
120
89
  상한선 토큰으로 max-height 강제 — 사용처가 그 안에서 자유롭게 크기 지정.
121
- 자식 svg/img/text 어떤 형태든 잘리지 않고 상한만 존중. */
90
+ 좌우/상하 padding은 소비처가 공급. */
122
91
  .brand {
123
92
  display: flex;
124
93
  align-items: center;
125
94
  gap: var(--space-2);
126
95
  min-height: var(--control-h-md);
127
- /* padding-inline 없음 — Header가 `--sidebar-pad-x`로 좌우 축 담당. */
128
96
  }
129
97
 
130
98
  .brandSymbol {
@@ -161,26 +129,19 @@
161
129
  height: auto;
162
130
  }
163
131
 
164
- /* Profile ──────────────────────────────────────────────
165
- IdentityButton의 preset 래퍼. Footer의 좌우/상하 padding(`--sidebar-pad-x/y`)은
166
- 그대로 유지 — 안쪽 Avatar/라벨이 Section 아이템과 동일 축에 정렬됨.
132
+ /* Profile preset — Footer 안에 꽂히는 preset. 좌우/상하 padding은 소비처가 공급.
167
133
  IdentityButton 기본이 inline-flex라 Footer flex 안에서 콘텐츠 폭에 머무름 →
168
- display: flex + width 100%로 가로만 끝까지 받게 하고, 세로는 늘리지 않음
169
- (flex: 1 1 auto로 세로 당기면 Footer bottom padding이 Profile 안으로 흡수돼
170
- 아래 패딩이 "없어 보임"). */
134
+ display: flex + width 100%로 가로만 끝까지 받게 하고, 세로는 늘리지 않음. */
171
135
  .profile {
172
136
  display: flex;
173
137
  width: 100%;
174
138
  }
175
139
 
176
- /* Section ────────────────────────────────────────────
177
- 스크롤 트랙(.body) 안쪽의 "items" 레이어. 좌우 padding은 여기가 담당.
178
- .body 자체에 두면 스크롤바가 padding 안쪽으로 밀려 좌/우 끝에 안 붙음. */
140
+ /* Section preset — Body 안쪽에 꽂히는 preset. 좌우/상하 padding은 소비처가 공급. */
179
141
  .section {
180
142
  display: flex;
181
143
  flex-direction: column;
182
144
  gap: var(--space-1);
183
- padding-inline: var(--sidebar-pad-x);
184
145
  }
185
146
 
186
147
  .sectionItems {
@@ -190,11 +151,12 @@
190
151
  }
191
152
 
192
153
  /* Separator ──────────────────────────────────────────
193
- 스크롤 트랙(.body) 바로 아래 자식. section의 padding-inline과 폭 맞추기 위해
194
- 같은 inline padding을 margin으로 복제 — 라인은 아이템과 좌/우 정렬. */
154
+ 기본은 full-bleed(좌우 가장자리까지). 소비처가 inset이 필요하면
155
+ `--separator-inset-x` 토큰 override. */
195
156
  .separator {
196
157
  height: var(--hairline);
197
- margin: var(--space-1) var(--sidebar-pad-x);
158
+ margin-block: var(--space-1);
159
+ margin-inline: var(--separator-inset-x, 0);
198
160
  background: var(--border);
199
161
  }
200
162
 
@@ -61,7 +61,11 @@ const SidebarRoot = forwardRef<HTMLElement, SidebarProps>(function SidebarRoot(
61
61
  /* Header ───────────────────────────────────────────── */
62
62
  function SidebarHeader({ children, className, ...rest }: HTMLAttributes<HTMLDivElement>) {
63
63
  return (
64
- <div className={[styles.header, className ?? ""].filter(Boolean).join(" ")} {...rest}>
64
+ <div
65
+ data-sidebar-slot="header"
66
+ className={[styles.header, className ?? ""].filter(Boolean).join(" ")}
67
+ {...rest}
68
+ >
65
69
  {children}
66
70
  </div>
67
71
  );
@@ -95,6 +99,7 @@ function SidebarBody({ children, className, ...rest }: HTMLAttributes<HTMLDivEle
95
99
  return (
96
100
  <div
97
101
  ref={ref}
102
+ data-sidebar-slot="body"
98
103
  className={[styles.body, className ?? ""].filter(Boolean).join(" ")}
99
104
  {...rest}
100
105
  >
@@ -116,7 +121,11 @@ export interface SidebarBrandProps extends HTMLAttributes<HTMLDivElement> {
116
121
  function SidebarBrand({ symbol, wordmark, className, ...rest }: SidebarBrandProps) {
117
122
  const { collapsed } = useSidebar();
118
123
  return (
119
- <div className={[styles.brand, className ?? ""].filter(Boolean).join(" ")} {...rest}>
124
+ <div
125
+ data-sidebar-slot="brand"
126
+ className={[styles.brand, className ?? ""].filter(Boolean).join(" ")}
127
+ {...rest}
128
+ >
120
129
  <div className={styles.brandSymbol}>{symbol}</div>
121
130
  {!collapsed && wordmark && (
122
131
  <div className={styles.brandWordmark}>{wordmark}</div>
@@ -132,6 +141,7 @@ function SidebarFooter({ children, className, ...rest }: HTMLAttributes<HTMLDivE
132
141
  const { bodyOverflow } = useSidebar();
133
142
  return (
134
143
  <div
144
+ data-sidebar-slot="footer"
135
145
  className={[styles.footer, className ?? ""].filter(Boolean).join(" ")}
136
146
  data-overflow={bodyOverflow || undefined}
137
147
  {...rest}
@@ -177,6 +187,7 @@ function SidebarProfile({
177
187
  className={[styles.profile, className ?? ""].filter(Boolean).join(" ")}
178
188
  aria-label={collapsed ? name : undefined}
179
189
  onClick={onClick}
190
+ data-sidebar-slot="profile"
180
191
  />
181
192
  );
182
193
  }
@@ -191,6 +202,7 @@ function SidebarSection({ title, className, children, ...rest }: SidebarSectionP
191
202
  const { collapsed } = useSidebar();
192
203
  return (
193
204
  <div
205
+ data-sidebar-slot="section"
194
206
  className={[styles.section, className ?? ""].filter(Boolean).join(" ")}
195
207
  role="group"
196
208
  aria-label={title}
@@ -210,6 +222,7 @@ function SidebarSection({ title, className, children, ...rest }: SidebarSectionP
210
222
  function SidebarSeparator({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
211
223
  return (
212
224
  <div
225
+ data-sidebar-slot="separator"
213
226
  role="separator"
214
227
  className={[styles.separator, className ?? ""].filter(Boolean).join(" ")}
215
228
  {...rest}
@@ -17,3 +17,108 @@
17
17
  white-space: nowrap;
18
18
  border: 0;
19
19
  }
20
+
21
+ /* ── emerge — 앵커(트리거)에서 튀어나오는 등장 ──────────────
22
+ overlay(Tooltip/Popover/Dropdown/Menu 등)가 data-side 값에 따라
23
+ "버튼에서 멀어지는 방향으로" 슬라이드 + 스케일 인.
24
+ Radix의 --radix-*-content-transform-origin과 결합해 앵커 엣지에서 자라남.
25
+ 컴포넌트가 아래 클래스를 붙이거나, [data-state="open"][data-side] 셀렉터로 직접 매칭. */
26
+ @keyframes m-emerge-from-top {
27
+ from { opacity: 0; transform: translateY(calc(-4px * var(--motion-distance-scale))) scale(0.96); }
28
+ to { opacity: 1; transform: translateY(0) scale(1); }
29
+ }
30
+ @keyframes m-emerge-from-bottom {
31
+ from { opacity: 0; transform: translateY(calc(4px * var(--motion-distance-scale))) scale(0.96); }
32
+ to { opacity: 1; transform: translateY(0) scale(1); }
33
+ }
34
+ @keyframes m-emerge-from-left {
35
+ from { opacity: 0; transform: translateX(calc(-4px * var(--motion-distance-scale))) scale(0.96); }
36
+ to { opacity: 1; transform: translateX(0) scale(1); }
37
+ }
38
+ @keyframes m-emerge-from-right {
39
+ from { opacity: 0; transform: translateX(calc(4px * var(--motion-distance-scale))) scale(0.96); }
40
+ to { opacity: 1; transform: translateX(0) scale(1); }
41
+ }
42
+ .m-emerge-from-top { animation: m-emerge-from-top var(--duration-fast) var(--ease-out); }
43
+ .m-emerge-from-bottom { animation: m-emerge-from-bottom var(--duration-fast) var(--ease-out); }
44
+ .m-emerge-from-left { animation: m-emerge-from-left var(--duration-fast) var(--ease-out); }
45
+ .m-emerge-from-right { animation: m-emerge-from-right var(--duration-fast) var(--ease-out); }
46
+
47
+ /* ── recede — emerge의 대칭 프리미티브. 앵커 쪽으로 살짝 물러나며 사라짐 ──
48
+ overlay 닫힘용. 이동량은 emerge의 절반(2px) — 닫힘은 짧고 담백하게. */
49
+ @keyframes m-recede-to-top {
50
+ from { opacity: 1; transform: translateY(0) scale(1); }
51
+ to { opacity: 0; transform: translateY(calc(-2px * var(--motion-distance-scale))) scale(0.98); }
52
+ }
53
+ @keyframes m-recede-to-bottom {
54
+ from { opacity: 1; transform: translateY(0) scale(1); }
55
+ to { opacity: 0; transform: translateY(calc(2px * var(--motion-distance-scale))) scale(0.98); }
56
+ }
57
+ @keyframes m-recede-to-left {
58
+ from { opacity: 1; transform: translateX(0) scale(1); }
59
+ to { opacity: 0; transform: translateX(calc(-2px * var(--motion-distance-scale))) scale(0.98); }
60
+ }
61
+ @keyframes m-recede-to-right {
62
+ from { opacity: 1; transform: translateX(0) scale(1); }
63
+ to { opacity: 0; transform: translateX(calc(2px * var(--motion-distance-scale))) scale(0.98); }
64
+ }
65
+ .m-recede-to-top { animation: m-recede-to-top var(--duration-fast) var(--ease-in); }
66
+ .m-recede-to-bottom { animation: m-recede-to-bottom var(--duration-fast) var(--ease-in); }
67
+ .m-recede-to-left { animation: m-recede-to-left var(--duration-fast) var(--ease-in); }
68
+ .m-recede-to-right { animation: m-recede-to-right var(--duration-fast) var(--ease-in); }
69
+
70
+ /* ── m-float — 플로팅 서페이스(오버레이·팝오버·툴팁·메뉴·셀렉트·데이트픽커…) 공통 in/out ──
71
+ 원칙: "떠오르는 표면"은 항상 이 유틸 하나로 등장·퇴장한다. 컴포넌트마다 다른 애니메이션 규칙을
72
+ 만들지 말고, 반드시 Radix Content(또는 그 상응 요소)의 className에 "m-float"을 추가한다.
73
+
74
+ Radix는 오버레이 Content에 자동으로 다음 attribute를 부여한다:
75
+ data-state = "open" | "closed" | "delayed-open" (Tooltip은 hover 지연으로 delayed-open 사용)
76
+ data-side = "top" | "bottom" | "left" | "right" (position=popper일 때)
77
+ → 이 유틸은 두 attribute 조합만으로 매칭되므로 컴포넌트 CSS Module 안에서 반복 정의할 필요 없음.
78
+
79
+ 왜 전역 클래스인가:
80
+ Turbopack의 CSS Modules는 `animation: <name>` shorthand의 keyframe identifier를 로컬 스코프로
81
+ rewrite한다(예: m-emerge-from-top → xItvXq__m-emerge-from-top). 우회하려면 컴포넌트마다
82
+ `--anim` 변수로 이름을 넘기는 보일러플레이트가 필요했다. 규칙 자체를 _utils.css로 올리면
83
+ 스코핑을 아예 회피할 수 있어 각 컴포넌트는 클래스 문자열 하나만 붙이면 된다.
84
+
85
+ 왜 globals.css가 아니라 _utils.css인가:
86
+ globals.css는 로컬 Next 앱 전용이고 ctds npm 패키지엔 번들되지 않는다. 반면 _utils.css는
87
+ tokens.css가 import하고 tokens.css는 소비자 globals.css에 자동 패치로 끼워지므로,
88
+ `ctds init`로 설치한 소비자 앱에서도 .m-float이 그대로 로드된다.
89
+
90
+ 방향 규칙:
91
+ - open → 앵커 반대편에서 자라남 (data-side=bottom → 트리거 아래 → 위쪽에서 내려옴)
92
+ - close → 앵커 쪽으로 되감김 (data-side=bottom → 위쪽으로 물러남)
93
+ 시간·곡선(모두 Motion 축 토큰 경유):
94
+ - open : --duration-base + --ease-emphasized (정착감 있는 등장)
95
+ - close : --duration-fast + --ease-in (담백한 이탈, 이동량 절반) */
96
+ .m-float {
97
+ will-change: transform, opacity;
98
+ }
99
+ .m-float[data-state="open"][data-side="top"],
100
+ .m-float[data-state="delayed-open"][data-side="top"] { animation: m-emerge-from-bottom var(--duration-base) var(--ease-emphasized); }
101
+ .m-float[data-state="open"][data-side="bottom"],
102
+ .m-float[data-state="delayed-open"][data-side="bottom"] { animation: m-emerge-from-top var(--duration-base) var(--ease-emphasized); }
103
+ .m-float[data-state="open"][data-side="left"],
104
+ .m-float[data-state="delayed-open"][data-side="left"] { animation: m-emerge-from-right var(--duration-base) var(--ease-emphasized); }
105
+ .m-float[data-state="open"][data-side="right"],
106
+ .m-float[data-state="delayed-open"][data-side="right"] { animation: m-emerge-from-left var(--duration-base) var(--ease-emphasized); }
107
+
108
+ .m-float[data-state="closed"][data-side="top"] { animation: m-recede-to-bottom var(--duration-fast) var(--ease-in); }
109
+ .m-float[data-state="closed"][data-side="bottom"] { animation: m-recede-to-top var(--duration-fast) var(--ease-in); }
110
+ .m-float[data-state="closed"][data-side="left"] { animation: m-recede-to-right var(--duration-fast) var(--ease-in); }
111
+ .m-float[data-state="closed"][data-side="right"] { animation: m-recede-to-left var(--duration-fast) var(--ease-in); }
112
+
113
+ /* data-side가 없는 경우(Tooltip이 아직 위치 계산 전, 또는 popper가 아닌 경우) 폴백: 페이드+살짝 스케일 */
114
+ .m-float[data-state="open"]:not([data-side]),
115
+ .m-float[data-state="delayed-open"]:not([data-side]) {
116
+ animation: m-emerge-from-top var(--duration-base) var(--ease-emphasized);
117
+ }
118
+ .m-float[data-state="closed"]:not([data-side]) {
119
+ animation: m-recede-to-top var(--duration-fast) var(--ease-in);
120
+ }
121
+
122
+ @media (prefers-reduced-motion: reduce) {
123
+ .m-float { animation: none !important; }
124
+ }
@@ -11,6 +11,7 @@
11
11
  - **URL 계층이 그대로 라벨** — 사이트맵의 실제 위치를 그대로 반영. 계층에 없는 임의 카테고리를 만들어 붙이지 않는다.
12
12
  - **모바일에서는 축약** — 첫 항목 + `Breadcrumb.Ellipsis` + 마지막 두 항목. 6개 이상이면 축약이 필수.
13
13
  - **Next.js `<Link>` 등 라우터**는 `<Breadcrumb.Link asChild>`로 감싼다. 스타일 유지한 채 SPA 네비게이션 위임.
14
+ - **형제 전환이 필요한 segment**는 `Breadcrumb.Menu`로. ▾ caret이 붙은 트리거를 눌러 같은 레벨의 다른 페이지(프로젝트·워크스페이스·브랜치 등)로 바로 이동. 현재 페이지 자리에 쓸 때는 `active` 필수 — `aria-current="page"`와 `--fg-strong` 톤을 자동으로 맞춘다.
14
15
  - **접근성**: 루트가 자동으로 `<nav aria-label="Breadcrumb">` + `<ol>`, 마지막 항목에 `aria-current="page"`를 붙인다. separator는 `aria-hidden`.
15
16
 
16
17
  ### Don't
@@ -20,6 +21,7 @@
20
21
  - **활성 색을 `--primary`로 덮지 않는다** — 활성은 `--fg-strong`(무채). 브랜드 컬러를 여기 쓰면 CTA와 위계가 충돌.
21
22
  - **아이콘 남발 금지** — 첫 항목 외에 아이콘 붙이면 라벨 스캔이 무너진다.
22
23
  - **draggable/sortable 만들지 않는다** — 계층은 URL이 정한다. UI에서 조작할 대상이 아님.
24
+ - **`Breadcrumb.Menu`를 탭/필터로 쓰지 않는다** — Menu는 "같은 레벨의 다른 페이지로 URL 이동" 전용. 같은 페이지 안에서 뷰만 바꾸는 건 [[components/tabs]].
23
25
 
24
26
  ### Fallback
25
27
  - 라우터 결정 애매하면 기본 `<a href>` 렌더. 프레임워크 붙일 때 `asChild`로 교체.
@@ -41,10 +43,15 @@
41
43
  separator={<Icon name="caret-right" />} // default: caret-right icon
42
44
  >
43
45
  <Breadcrumb.Link href="/" leftIcon={<Icon name="house" />}>Home</Breadcrumb.Link>
44
- <Breadcrumb.Link asChild>
45
- <Link href="/components">Components</Link>
46
- </Breadcrumb.Link>
47
- <Breadcrumb.Ellipsis />
46
+ <Breadcrumb.Menu
47
+ items={[
48
+ { label: "Alpha", href: "/p/alpha", active: true },
49
+ { label: "Beta", href: "/p/beta" },
50
+ ]}
51
+ >
52
+ Alpha
53
+ </Breadcrumb.Menu>
54
+ <Breadcrumb.Ellipsis items={[...]} />
48
55
  <Breadcrumb.Current>Breadcrumb</Breadcrumb.Current>
49
56
  </Breadcrumb>
50
57
  ```
@@ -55,6 +62,7 @@
55
62
  | `Breadcrumb.Link` | 상위 계층 링크. Button xs · ghost 톤. hover 시 `--hover` 표면. | 마지막 항목엔 쓰지 않음. |
56
63
  | `Breadcrumb.Current` | 현재 페이지. `--fg-strong` 텍스트, hover 반응 없음. | `href`·`onClick` 붙이지 않음. |
57
64
  | `Breadcrumb.Ellipsis` | 축약 트리거. 항상 클릭 가능. [[components/select]]와 동일한 팝오버 표면으로 숨긴 항목 리스트를 연다. | `items`에 5개 이상 넣지 않는다 — 그러면 축약이 아니라 실질적 서브메뉴가 됨. 정적(클릭 불가) 마커는 지원하지 않는다. |
65
+ | `Breadcrumb.Menu` | 형제 전환 트리거. ▾ caret이 붙은 segment를 눌러 같은 레벨 페이지로 이동. `active=true`로 현재 페이지 자리에도 쓸 수 있다 (aria-current + fg-strong 자동). | 아이콘 단독 모드 지원 안 함 — 트리거 라벨 필수. 탭 전환용으로 쓰지 않는다. |
58
66
 
59
67
  ### 조합 예시
60
68
  ```tsx
@@ -74,4 +82,36 @@
74
82
  </Breadcrumb.Link>
75
83
  <Breadcrumb.Current>Breadcrumb</Breadcrumb.Current>
76
84
  </Breadcrumb>
85
+
86
+ // 프로젝트 스위처 — Menu로 형제 전환
87
+ <Breadcrumb aria-label="프로젝트 경로">
88
+ <Breadcrumb.Link asChild leftIcon={<Icon name="user" />}>
89
+ <Link href="/">Workspace</Link>
90
+ </Breadcrumb.Link>
91
+ <Breadcrumb.Menu
92
+ items={[
93
+ { label: "Alpha", href: "/p/alpha", active: true },
94
+ { label: "Beta", href: "/p/beta" },
95
+ { label: "Gamma", href: "/p/gamma" },
96
+ ]}
97
+ >
98
+ Alpha
99
+ </Breadcrumb.Menu>
100
+ <Breadcrumb.Current>Settings</Breadcrumb.Current>
101
+ </Breadcrumb>
102
+
103
+ // 현재 페이지 자체가 메뉴 — active=true로 aria-current="page" 유지
104
+ <Breadcrumb aria-label="브랜치 경로">
105
+ <Breadcrumb.Link href="/">Repo</Breadcrumb.Link>
106
+ <Breadcrumb.Menu
107
+ active
108
+ items={[
109
+ { label: "main", onSelect: () => switchBranch("main"), active: true },
110
+ { label: "develop", onSelect: () => switchBranch("develop") },
111
+ { label: "staging", onSelect: () => switchBranch("staging") },
112
+ ]}
113
+ >
114
+ main
115
+ </Breadcrumb.Menu>
116
+ </Breadcrumb>
77
117
  ```