@conscience-technology/ctds 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/assets/components/Button/Button.module.css +2 -2
  2. package/assets/components/EventListItem/EventListItem.module.css +220 -0
  3. package/assets/components/EventListItem/EventListItem.tsx +205 -0
  4. package/assets/components/EventListItem/index.ts +8 -0
  5. package/assets/components/Icon/Icon.tsx +80 -41
  6. package/assets/components/ListItem/ListItem.module.css +91 -211
  7. package/assets/components/ListItem/ListItem.tsx +130 -154
  8. package/assets/components/ListItem/index.ts +2 -2
  9. package/assets/components/MetricCard/MetricCard.module.css +5 -5
  10. package/assets/components/Sidebar/Sidebar.module.css +81 -4
  11. package/assets/components/Sidebar/Sidebar.tsx +11 -2
  12. package/assets/components/SplitButton/SplitButton.module.css +3 -3
  13. package/assets/components/Stepper/Stepper.module.css +196 -52
  14. package/assets/components/Stepper/Stepper.tsx +68 -13
  15. package/assets/components/Switch/Switch.module.css +1 -9
  16. package/assets/components/Topbar/Topbar.module.css +33 -5
  17. package/assets/components/Topbar/Topbar.tsx +62 -7
  18. package/assets/components/_data.ts +11 -4
  19. package/assets/components/index.ts +2 -0
  20. package/assets/components/tokens.css +23 -10
  21. package/assets/prompts/ASSEMBLY.md +219 -0
  22. package/assets/prompts/PRINCIPLES.md +70 -0
  23. package/assets/prompts/axes/icon.md +7 -1
  24. package/assets/prompts/axes/palette.md +11 -0
  25. package/assets/prompts/components/_variants.md +5 -1
  26. package/assets/prompts/components/callout.md +3 -3
  27. package/assets/prompts/components/event-list-item.md +103 -0
  28. package/assets/prompts/components/generated-image.md +80 -0
  29. package/assets/prompts/components/list-item.md +182 -204
  30. package/assets/prompts/components/metric-card.md +2 -2
  31. package/assets/prompts/components/toast.md +1 -0
  32. package/assets/prompts/components/topbar.md +77 -59
  33. package/assets/prompts/themes/focus.md +72 -0
  34. package/assets/skills/ctds/SKILL.md +1 -0
  35. package/assets/skills/ctds-page/SKILL.md +12 -2
  36. package/assets/skills/ctds-wireframe/SKILL.md +35 -1
  37. package/package.json +2 -4
@@ -1,201 +1,102 @@
1
1
  "use client";
2
2
 
3
3
  /**
4
- * ListItem — 앞에 아바타/아이콘이 달린 리스트 한 줄 단위. 피드·알림·인박스·활동 로그를 포괄.
4
+ * ListItem — 3구역(leading / content / trailing) 고정 뼈대 + content 내부 자유 조립.
5
5
  *
6
- * anatomy 고정: [leading] [from (body | verb+target+surface)] [meta/trailing] [hoverActions]
7
- * 자체 chrome(border/padding/bg) 없음 — 리스트 분리는 부모 레이아웃이 담당.
6
+ * 축:
7
+ * size — 세로 여백감. md(기본, 작업/자원 리스트 밀도) | sm (활동 로그·알림 스트림 타이트)
8
+ * align — leading 세로 정렬. center(기본) | top (2줄 이상 본문용)
9
+ * interactive — hover/focus 피드백 (href 또는 onClick 있을 때 자동)
8
10
  *
9
- * 서술 모드 배타:
10
- * (A) body — "회계팀 — 외주비 결재 요청"
11
- * (B) verb+target — "박지훈 · 댓글 남김 · ERP 2.0 사이드바"
11
+ * chrome 없음 — bg·border·radius 자체 공급 X. 리스트 분리는 부모가 담당.
12
+ * 블록 외형(배경·테두리로 상태 신호)이 필요하면 Callout 사용.
12
13
  *
13
- * variant는 default만 바꾼다 (anatomy는 공통):
14
- * activity — 과거 서술. unread false 기본
15
- * notification — 처리 필요. unread true 기본
16
- * mention — 멘션/댓글. unread true 기본
17
- * system — 사람 아닌 서비스 이벤트
14
+ * content 안에서 쓰는 subcomponents:
15
+ * ListItem.Title 메인 제목 (앞에 Badge 등 inline 자유)
16
+ * ListItem.Description muted 서브 텍스트
17
+ * ListItem.Meta 하단 meta 라인 (타임스탬프·상태)
18
+ * ListItem.MetaDivider hairline 세로 구분자
19
+ *
20
+ * 일정·이벤트처럼 날짜가 leading 아이덴티티인 자리는 [[EventListItem]] 사용.
18
21
  */
19
22
 
20
23
  import {
21
- Children,
22
- cloneElement,
23
24
  forwardRef,
24
- isValidElement,
25
25
  type AnchorHTMLAttributes,
26
26
  type HTMLAttributes,
27
- type ReactElement,
28
27
  type ReactNode,
29
28
  type Ref,
30
29
  } from "react";
31
- import { Slot } from "@radix-ui/react-slot";
32
30
 
33
31
  import styles from "./ListItem.module.css";
34
32
 
35
- export type ListItemVariant = "activity" | "notification" | "mention" | "system";
36
- export type ListItemLayout = "inline" | "stacked";
33
+ export type ListItemSize = "sm" | "md";
34
+ export type ListItemAlign = "center" | "top";
37
35
 
38
36
  type Common = {
39
- // 왼쪽
40
37
  leading?: ReactNode;
41
- /**
42
- * 읽음/안읽음 신호.
43
- * - undefined (default): 중립 톤. 리드/언리드 개념 없는 자리(활동 로그·히스토리).
44
- * - true: 안 읽음. body/from 강조 + meta primary.
45
- * - false: 읽음. 전체 톤 한두 단계 다운.
46
- */
47
- unread?: boolean;
48
-
49
- // 본문
50
- from?: ReactNode;
51
- body?: ReactNode;
52
- verb?: ReactNode;
53
- target?: ReactNode;
54
- surface?: ReactNode;
55
-
56
- // 메타
57
- meta?: ReactNode;
58
38
  trailing?: ReactNode;
59
- hoverActions?: ReactNode;
60
-
61
- // 레이아웃·상호작용
62
- variant?: ListItemVariant;
63
- layout?: ListItemLayout;
64
- asChild?: boolean;
39
+ size?: ListItemSize;
40
+ align?: ListItemAlign;
41
+ children?: ReactNode;
65
42
  };
66
43
 
67
44
  type DivProps = Common &
68
- Omit<HTMLAttributes<HTMLDivElement>, keyof Common> & {
69
- href?: undefined;
70
- };
45
+ Omit<HTMLAttributes<HTMLDivElement>, keyof Common> & { href?: undefined };
71
46
 
72
47
  type AnchorProps = Common &
73
- Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof Common> & {
74
- href: string;
75
- };
48
+ Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof Common> & { href: string };
76
49
 
77
50
  export type ListItemProps = DivProps | AnchorProps;
78
51
 
79
- /* unread는 명시적으로 넘긴 경우에만 신호. variant는 default를 강제하지 않는다 — 리드/언리드 개념이 없는 자리에서도 자유롭게 쓰기 위함. */
80
-
81
- const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem(
52
+ const ListItemRoot = forwardRef<HTMLElement, ListItemProps>(function ListItem(
82
53
  props,
83
54
  ref,
84
55
  ) {
85
56
  const {
86
57
  leading,
87
- unread,
88
- from,
89
- body,
90
- verb,
91
- target,
92
- surface,
93
- meta,
94
58
  trailing,
95
- hoverActions,
96
- variant = "activity",
97
- layout = "inline",
98
- asChild,
59
+ size = "md",
60
+ align = "center",
99
61
  className,
62
+ children,
100
63
  ...rest
101
64
  } = props as Common & { className?: string } & Record<string, unknown>;
102
65
 
103
- const isActionMode = verb !== undefined || target !== undefined;
104
66
  const isLink = typeof (props as AnchorProps).href === "string";
105
- const isInteractive = isLink || typeof (props as HTMLAttributes<HTMLElement>).onClick === "function";
106
-
107
- // read-state class — unread 명시적으로 넘겼을 때만 신호. undefined면 중립.
108
- const readStateClass =
109
- unread === true ? styles.unread : unread === false ? styles.read : "";
67
+ const isInteractive =
68
+ isLink || typeof (props as HTMLAttributes<HTMLElement>).onClick === "function";
110
69
 
111
70
  const rootClass = [
112
71
  styles.root,
113
- styles[variant],
114
- layout === "stacked" ? styles.stacked : styles.inline,
115
- readStateClass,
72
+ styles[`size-${size}`],
73
+ styles[`align-${align}`],
116
74
  isInteractive ? styles.interactive : "",
117
75
  className ?? "",
118
76
  ]
119
77
  .filter(Boolean)
120
78
  .join(" ");
121
79
 
122
- const leadingNode = leading ? (
123
- <span className={styles.leading} aria-hidden>
124
- {leading}
125
- </span>
126
- ) : null;
127
-
128
- // 본문 — 서술 모드 배타. verb/target이 하나라도 있으면 action 모드.
129
- // layout=stacked이면 두 줄로 분리: 1줄째 from(+verb+target+surface), 2줄째 body
130
- const isStacked = layout === "stacked";
131
- const bodyNode = isActionMode ? (
132
- isStacked ? (
133
- <>
134
- <span className={styles.actionBody}>
135
- {from && <span className={styles.from}>{from}</span>}
136
- {verb && <span className={styles.verb}>{verb}</span>}
137
- {target && <span className={styles.target}>{target}</span>}
138
- {surface && <span className={styles.surface}>{surface}</span>}
139
- </span>
140
- {body && <span className={styles.body}>{body}</span>}
141
- </>
142
- ) : (
143
- <span className={styles.actionBody}>
144
- {from && <span className={styles.from}>{from}</span>}
145
- {verb && <span className={styles.verb}>{verb}</span>}
146
- {target && <span className={styles.target}>{target}</span>}
147
- {surface && <span className={styles.surface}>{surface}</span>}
148
- </span>
149
- )
150
- ) : isStacked ? (
151
- <>
152
- {from && <span className={styles.from}>{from}</span>}
153
- {body && <span className={styles.body}>{body}</span>}
154
- </>
155
- ) : (
156
- <span className={styles.narrativeBody}>
157
- {from && <span className={styles.from}>{from}</span>}
158
- {body && <span className={styles.body}>{body}</span>}
159
- </span>
160
- );
161
-
162
- // trailing: 명시 trailing 우선, 없으면 meta
163
- const trailingNode = trailing !== undefined ? (
164
- <span className={styles.trailing}>{trailing}</span>
165
- ) : meta !== undefined ? (
166
- <span className={styles.meta}>{meta}</span>
167
- ) : null;
168
-
169
- // hoverActions: 배열이면 간격 유지하며 렌더
170
- const hoverNode = hoverActions ? (
171
- <span className={styles.hoverActions}>
172
- {Children.map(hoverActions, (child) =>
173
- isValidElement(child) ? child : child,
174
- )}
175
- </span>
176
- ) : null;
177
-
178
80
  const content = (
179
81
  <>
180
- {leadingNode}
181
- <span className={styles.content}>{bodyNode}</span>
182
- {hoverNode}
183
- {trailingNode}
82
+ {leading !== undefined && leading !== null && (
83
+ <span className={styles.leading} aria-hidden>
84
+ {leading}
85
+ </span>
86
+ )}
87
+ <span className={styles.content}>{children}</span>
88
+ {trailing !== undefined && trailing !== null && (
89
+ <span className={styles.trailing}>{trailing}</span>
90
+ )}
184
91
  </>
185
92
  );
186
93
 
187
- if (asChild) {
188
- const only = Children.only(rest.children) as ReactElement<HTMLAttributes<HTMLElement>>;
189
- return cloneElement(only, { className: rootClass, ...rest }, content);
190
- }
191
-
192
94
  if (isLink) {
193
- const { children, ...anchorRest } = rest as AnchorHTMLAttributes<HTMLAnchorElement>;
194
95
  return (
195
96
  <a
196
97
  ref={ref as Ref<HTMLAnchorElement>}
197
98
  className={rootClass}
198
- {...anchorRest}
99
+ {...(rest as AnchorHTMLAttributes<HTMLAnchorElement>)}
199
100
  href={(props as AnchorProps).href}
200
101
  >
201
102
  {content}
@@ -203,29 +104,86 @@ const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem(
203
104
  );
204
105
  }
205
106
 
206
- const { children, ...divRest } = rest as HTMLAttributes<HTMLDivElement>;
207
107
  return (
208
108
  <div
209
109
  ref={ref as Ref<HTMLDivElement>}
210
110
  className={rootClass}
211
- data-variant={variant}
212
- {...divRest}
111
+ data-size={size}
112
+ {...(rest as HTMLAttributes<HTMLDivElement>)}
213
113
  >
214
114
  {content}
215
115
  </div>
216
116
  );
217
117
  });
218
118
 
219
- ListItem.displayName = "ListItem";
220
-
221
- export { ListItem };
119
+ ListItemRoot.displayName = "ListItem";
222
120
 
223
121
  /* ──────────────────────────────────────────────────────
224
- ListItemLeadingIcon — "배경색 + 아이콘" leading 타입.
122
+ content subcomponents
123
+ ──────────────────────────────────────────────────── */
124
+
125
+ type SpanProps = HTMLAttributes<HTMLSpanElement>;
126
+ type DivOnlyProps = HTMLAttributes<HTMLDivElement>;
127
+
128
+ const Title = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
129
+ function ListItemTitle({ className, children, ...rest }, ref) {
130
+ return (
131
+ <div
132
+ ref={ref as unknown as Ref<HTMLHeadingElement>}
133
+ className={[styles.title, className ?? ""].filter(Boolean).join(" ")}
134
+ {...(rest as HTMLAttributes<HTMLDivElement>)}
135
+ >
136
+ {children}
137
+ </div>
138
+ );
139
+ },
140
+ );
141
+ Title.displayName = "ListItem.Title";
225
142
 
226
- Avatar/Icon 사이에 섞어도 정렬이 흔들리지 않도록 같은 사이즈 트랙(xs/sm/md/lg)에 올림.
227
- tone 프리셋으로 bg/fg 쌍을 한 번에, 혹은 CSS 변수(--leading-icon-bg / --leading-icon-fg)로
228
- 세밀 조절. 아이콘 자체는 자식으로 받아 Icon/커스텀 노드 모두 허용.
143
+ const Description = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
144
+ function ListItemDescription({ className, children, ...rest }, ref) {
145
+ return (
146
+ <p
147
+ ref={ref}
148
+ className={[styles.description, className ?? ""].filter(Boolean).join(" ")}
149
+ {...rest}
150
+ >
151
+ {children}
152
+ </p>
153
+ );
154
+ },
155
+ );
156
+ Description.displayName = "ListItem.Description";
157
+
158
+ const Meta = forwardRef<HTMLDivElement, DivOnlyProps>(function ListItemMeta(
159
+ { className, children, ...rest },
160
+ ref,
161
+ ) {
162
+ return (
163
+ <div ref={ref} className={[styles.meta, className ?? ""].filter(Boolean).join(" ")} {...rest}>
164
+ {children}
165
+ </div>
166
+ );
167
+ });
168
+ Meta.displayName = "ListItem.Meta";
169
+
170
+ const MetaDivider = forwardRef<HTMLSpanElement, SpanProps>(function ListItemMetaDivider(
171
+ { className, ...rest },
172
+ ref,
173
+ ) {
174
+ return (
175
+ <span
176
+ ref={ref}
177
+ aria-hidden
178
+ className={[styles.metaDivider, className ?? ""].filter(Boolean).join(" ")}
179
+ {...rest}
180
+ />
181
+ );
182
+ });
183
+ MetaDivider.displayName = "ListItem.MetaDivider";
184
+
185
+ /* ──────────────────────────────────────────────────────
186
+ LeadingIcon — "배경색 + 아이콘" leading 타입. 기존 유지.
229
187
  ──────────────────────────────────────────────────── */
230
188
 
231
189
  export type ListItemLeadingIconSize = "xs" | "sm" | "md" | "lg" | "auto";
@@ -244,8 +202,13 @@ export type ListItemLeadingIconProps = Omit<HTMLAttributes<HTMLSpanElement>, "ch
244
202
  };
245
203
 
246
204
  const ListItemLeadingIcon = forwardRef<HTMLSpanElement, ListItemLeadingIconProps>(
247
- function ListItemLeadingIcon({ size = "sm", tone = "neutral", className, children, ...rest }, ref) {
248
- const cls = [styles.leadingIcon, styles[size], className ?? ""].filter(Boolean).join(" ");
205
+ function ListItemLeadingIcon(
206
+ { size = "sm", tone = "neutral", className, children, ...rest },
207
+ ref,
208
+ ) {
209
+ const cls = [styles.leadingIcon, styles[`li-${size}`], className ?? ""]
210
+ .filter(Boolean)
211
+ .join(" ");
249
212
  return (
250
213
  <span ref={ref} aria-hidden {...rest} className={cls} data-tone={tone}>
251
214
  {children}
@@ -253,10 +216,23 @@ const ListItemLeadingIcon = forwardRef<HTMLSpanElement, ListItemLeadingIconProps
253
216
  );
254
217
  },
255
218
  );
256
-
257
219
  ListItemLeadingIcon.displayName = "ListItemLeadingIcon";
258
220
 
259
- export { ListItemLeadingIcon };
221
+ /* ──────────────────────────────────────────────────────
222
+ compound export
223
+ ──────────────────────────────────────────────────── */
224
+
225
+ type ListItemCompound = typeof ListItemRoot & {
226
+ Title: typeof Title;
227
+ Description: typeof Description;
228
+ Meta: typeof Meta;
229
+ MetaDivider: typeof MetaDivider;
230
+ };
231
+
232
+ const ListItem = ListItemRoot as ListItemCompound;
233
+ ListItem.Title = Title;
234
+ ListItem.Description = Description;
235
+ ListItem.Meta = Meta;
236
+ ListItem.MetaDivider = MetaDivider;
260
237
 
261
- // Slot export (asChild 패턴 지원이 필요한 경우 상위에서 사용)
262
- export const ListItemSlot = Slot;
238
+ export { ListItem, ListItemLeadingIcon };
@@ -1,8 +1,8 @@
1
1
  export { ListItem, ListItemLeadingIcon } from "./ListItem";
2
2
  export type {
3
3
  ListItemProps,
4
- ListItemVariant,
5
- ListItemLayout,
4
+ ListItemSize,
5
+ ListItemAlign,
6
6
  ListItemLeadingIconProps,
7
7
  ListItemLeadingIconSize,
8
8
  ListItemLeadingIconTone,
@@ -1,11 +1,11 @@
1
1
  /*
2
2
  * MetricCard — 전부 CTDS 토큰 경유.
3
3
  * variant:
4
- * line — border만 (조밀한 그리드, 기본)
4
+ * line — border + 은은한 shadow-sm (조밀한 그리드, 기본)
5
5
  * soft — bg-subtle 표면 (경계 대신 톤)
6
6
  * bare — border·bg·padding 모두 제거. pure 좌표 전용.
7
7
  * 부모 container가 hairline/톤으로 분리를 담당하는 자리에서만 쓴다.
8
- * 그림자 금지. interactive: href 있을 때만 hover/focus ring.
8
+ * interactive: href 있을 때만 hover/focus ring.
9
9
  */
10
10
 
11
11
  .root {
@@ -17,20 +17,20 @@
17
17
  min-width: 0;
18
18
  }
19
19
 
20
- /* line/soft는 자체 chrome(padding+radius+bg/border)을 가진다. */
20
+ /* line/soft는 자체 chrome(padding+radius)을 가진다.
21
+ line = hairline만, soft = 톤 배경만 — 둘 다 섞지 않는다. */
21
22
  .line,
22
23
  .soft {
23
24
  padding: var(--space-5) var(--space-5);
24
25
  border-radius: var(--radius-md);
25
- background: var(--bg);
26
26
  }
27
27
 
28
28
  .line {
29
29
  border: 1px solid var(--border);
30
+ box-shadow: var(--shadow-sm);
30
31
  }
31
32
 
32
33
  .soft {
33
- border: 1px solid transparent;
34
34
  background: var(--bg-subtle);
35
35
  }
36
36
 
@@ -73,9 +73,9 @@
73
73
  Body 안쪽 콘텐츠 래퍼(nav/섹션 리스트 등)에 깐다. */
74
74
  /* padding-top·padding-bottom도 두지 않음 — Header 아래 숨돌림, Footer 위
75
75
  마지막 항목 숨돌림 모두 소비처가 안쪽 래퍼의 padding-block으로 공급. */
76
- /* mask-image 금지 — Body 안에 "고정 상단 블록(railTop 등 primary CTA + 검색)"을
77
- 꽂는 소비처가 많아서 fade가 걸리면 그 블록 상단이 흐려져 "밝은 입체 바"
78
- 착시를 만듦(특히 primary solid 버튼에서 심함). */
76
+ /* mask-image 기본 off. 켜고 싶으면 Sidebar.Body의 `fade` prop 사용.
77
+ top fade는 Body 안에 "고정 상단 블록(primary CTA + 검색)"을 꽂는 소비처에선
78
+ 그 블록 상단이 흐려져 "밝은 입체 바" 착시를 만들므로 주의. */
79
79
  }
80
80
 
81
81
  /* Body 자식이 flex column에서 수축돼 header가 뭉개지는 걸 방지.
@@ -84,15 +84,92 @@
84
84
  flex: 0 0 auto;
85
85
  }
86
86
 
87
+ /* fade mask opt-in — Body에 `fade="top|bottom|both"` prop을 주면 켜짐.
88
+ 페이드 크기는 `--sidebar-body-fade-size` 토큰(기본 --space-3). 소비처가 늘릴 수 있음.
89
+ 주의: top fade는 Body 안에 고정 상단 블록(primary CTA 등)이 없을 때만 안전.
90
+ 있으면 그 블록 상단이 깎여 "흰 입체 바" 착시가 생김. */
91
+ .body[data-fade] {
92
+ --sidebar-body-fade-size: var(--space-3);
93
+ }
94
+
95
+ /* mask-image의 #000은 색이 아니라 "완전 불투명" alpha marker — lint-hardcoded ignore. */
96
+ .body[data-fade="top"] {
97
+ mask-image: linear-gradient(
98
+ to bottom,
99
+ transparent 0,
100
+ #000 var(--sidebar-body-fade-size), /* lint-hardcoded: ignore */
101
+ #000 100% /* lint-hardcoded: ignore */
102
+ );
103
+ -webkit-mask-image: linear-gradient(
104
+ to bottom,
105
+ transparent 0,
106
+ #000 var(--sidebar-body-fade-size), /* lint-hardcoded: ignore */
107
+ #000 100% /* lint-hardcoded: ignore */
108
+ );
109
+ }
110
+
111
+ .body[data-fade="bottom"] {
112
+ mask-image: linear-gradient(
113
+ to bottom,
114
+ #000 0, /* lint-hardcoded: ignore */
115
+ #000 calc(100% - var(--sidebar-body-fade-size)), /* lint-hardcoded: ignore */
116
+ transparent 100%
117
+ );
118
+ -webkit-mask-image: linear-gradient(
119
+ to bottom,
120
+ #000 0, /* lint-hardcoded: ignore */
121
+ #000 calc(100% - var(--sidebar-body-fade-size)), /* lint-hardcoded: ignore */
122
+ transparent 100%
123
+ );
124
+ }
125
+
126
+ .body[data-fade="both"] {
127
+ mask-image: linear-gradient(
128
+ to bottom,
129
+ transparent 0,
130
+ #000 var(--sidebar-body-fade-size), /* lint-hardcoded: ignore */
131
+ #000 calc(100% - var(--sidebar-body-fade-size)), /* lint-hardcoded: ignore */
132
+ transparent 100%
133
+ );
134
+ -webkit-mask-image: linear-gradient(
135
+ to bottom,
136
+ transparent 0,
137
+ #000 var(--sidebar-body-fade-size), /* lint-hardcoded: ignore */
138
+ #000 calc(100% - var(--sidebar-body-fade-size)), /* lint-hardcoded: ignore */
139
+ transparent 100%
140
+ );
141
+ }
142
+
87
143
  /* Brand preset ─────────────────────────────────────────
88
144
  Header 안에 꽂히는 preset. symbol은 항상, wordmark는 expanded일 때만 렌더.
89
145
  상한선 토큰으로 max-height 강제 — 사용처가 그 안에서 자유롭게 크기 지정.
90
- 좌우/상하 padding은 소비처가 공급. */
146
+ Header 자체 좌우/상하 padding은 소비처가 공급(래퍼 축).
147
+ Brand 안쪽 좌우 padding은 벤더가 공급 — MenuItem이 자체 padding-inline을
148
+ 가지므로 Brand symbol이 아래 MenuItem 아이콘과 같은 X축에 서려면
149
+ Brand도 동일한 inner padding을 가져야 함(아니면 symbol이 라벨 영역과 anti-flush).
150
+ width: 100% + collapsed 중앙 정렬: Brand가 Header 폭을 전부 받아야 collapsed일 때
151
+ justify-content: center가 "Header 가로 중앙"으로 유효해짐. */
91
152
  .brand {
92
153
  display: flex;
93
154
  align-items: center;
94
155
  gap: var(--space-2);
95
156
  min-height: var(--control-h-md);
157
+ /* MenuItem이 자체 padding-inline(space-2)을 가지므로 Brand symbol이 아래
158
+ 라벨들의 아이콘과 같은 X축에 서려면 Brand도 동일한 inner padding을 가져야 함.
159
+ Header 안에 Brand + 다른 자식이 공존하는 소비처가 있으므로 width는 건드리지
160
+ 않고 inner padding만 공급. */
161
+ padding-inline: var(--space-2);
162
+ box-sizing: border-box;
163
+ }
164
+
165
+ /* collapsed일 땐 wordmark가 사라져 symbol 하나만 남으므로 Header 폭을 전부 받아
166
+ 가로 중앙 정렬. 안 그러면 Brand가 symbol 폭에만 머물러 Header 좌측에 붙음.
167
+ inner padding도 제거 — collapsed Header는 자체적으로 좁고 symbol 1개만 있어
168
+ 추가 안쪽 여백이 필요 없음. */
169
+ .root[data-collapsed] .brand {
170
+ width: 100%;
171
+ justify-content: center;
172
+ padding-inline: 0;
96
173
  }
97
174
 
98
175
  .brandSymbol {
@@ -75,8 +75,16 @@ function SidebarHeader({ children, className, ...rest }: HTMLAttributes<HTMLDivE
75
75
  Header와 Footer 사이의 세로 스크롤 영역.
76
76
  여러 개를 두면 각각 독립적인 스크롤 트랙이 됨.
77
77
  ResizeObserver로 scrollHeight > clientHeight 감지 → Footer가 자동으로
78
- 상단 hairline을 켬. 메뉴가 적으면 선 없음, 많으면 자연스럽게 분리선 등장. */
79
- function SidebarBody({ children, className, ...rest }: HTMLAttributes<HTMLDivElement>) {
78
+ 상단 hairline을 켬. 메뉴가 적으면 선 없음, 많으면 자연스럽게 분리선 등장.
79
+
80
+ fade: 스크롤 트랙 상/하단 소프트 fade mask. "top" | "bottom" | "both" | "none".
81
+ 기본 "none" — 켤 때만 켠다(소비처가 Body 안에 고정 상단 블록을 두면 top fade가
82
+ 그 블록을 깎아 "흰 입체 바" 착시를 만드므로 opt-in). */
83
+ export interface SidebarBodyProps extends HTMLAttributes<HTMLDivElement> {
84
+ fade?: "top" | "bottom" | "both" | "none";
85
+ }
86
+
87
+ function SidebarBody({ children, className, fade = "none", ...rest }: SidebarBodyProps) {
80
88
  const ref = useRef<HTMLDivElement>(null);
81
89
  const { setBodyOverflow } = useSidebar();
82
90
 
@@ -100,6 +108,7 @@ function SidebarBody({ children, className, ...rest }: HTMLAttributes<HTMLDivEle
100
108
  <div
101
109
  ref={ref}
102
110
  data-sidebar-slot="body"
111
+ data-fade={fade !== "none" ? fade : undefined}
103
112
  className={[styles.body, className ?? ""].filter(Boolean).join(" ")}
104
113
  {...rest}
105
114
  >
@@ -94,9 +94,9 @@
94
94
  .sm .menu { padding-inline: var(--space-1_5); }
95
95
 
96
96
  .md { --split-h: var(--control-h-md); --btn-icon-size: var(--space-4); }
97
- .md .part { font-size: var(--text-sm); gap: var(--space-1); }
98
- .md .main { padding-inline: var(--space-2); }
99
- .md .menu { padding-inline: var(--space-1_5); }
97
+ .md .part { font-size: var(--text-sm); gap: var(--space-1_5); }
98
+ .md .main { padding-inline: var(--space-3); }
99
+ .md .menu { padding-inline: var(--space-2); }
100
100
 
101
101
  .lg { --split-h: var(--control-h-lg); --btn-icon-size: var(--space-4); }
102
102
  .lg .part { font-size: var(--text-sm); gap: var(--space-1_5); }