@conscience-technology/ctds 0.4.0 → 0.4.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.
@@ -0,0 +1,371 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Kanban — 가로 트랙(Board) + 컬럼(Column) + 카드(Card)의 3-레벨 compound.
5
+ *
6
+ * 레벨별 축:
7
+ * Kanban (board)
8
+ * columnWidth — 컬럼 1장 width. 기본 var(--kanban-column-width) = 320px
9
+ * gap — 컬럼 사이 가로 간격. 기본 space-3
10
+ * scroll — 가로 스크롤 on/off. 기본 true. false면 flex-wrap으로 반응형.
11
+ *
12
+ * Kanban.Column
13
+ * chrome — line (border) | filled (bg-muted) | plain (chrome 없음)
14
+ * accent — none | dot | bar. 상태 색 신호 위치.
15
+ * tone — neutral | primary | success | warning | danger | info
16
+ * * layout — stack (기본) | wrap
17
+ * · stack(권장): header만 자체 chrome을 가지고 카드 스택은
18
+ * chrome 없이 그 아래 자유롭게 쌓이는 "얹어진" 형태.
19
+ * · wrap(비권장): header와 카드가 한 쉘 안에 함께 들어있는
20
+ * "감싸는" 형태. 전통적 칸반이지만 카드가 그릇에 갇혀 답답함.
21
+ *
22
+ * Kanban.Card
23
+ * content은 Card.Cover · Card.Header · Card.Title · Card.Description
24
+ * · Card.Meta · Card.Footer slot으로 자유 조립.
25
+ * interactive — href/onClick 자동 hover (ListItem과 동일)
26
+ *
27
+ * DnD는 호출부가 바깥에서 dnd-kit로 감싸는 opt-in. 시각 먼저, 상호작용은 선택.
28
+ */
29
+
30
+ import {
31
+ forwardRef,
32
+ type AnchorHTMLAttributes,
33
+ type HTMLAttributes,
34
+ type ReactNode,
35
+ type Ref,
36
+ } from "react";
37
+
38
+ import styles from "./Kanban.module.css";
39
+
40
+ /* ──────────────────────────────────────────────────────
41
+ Kanban (board)
42
+ ──────────────────────────────────────────────────── */
43
+
44
+ export type KanbanProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
45
+ /** 컬럼 1장 width. CSS 길이 문자열(e.g. "280px", "320px"). */
46
+ columnWidth?: string;
47
+ /** 컬럼 사이 가로 간격. CSS 길이. 기본 var(--space-3). */
48
+ gap?: string;
49
+ /** 가로 스크롤. 기본 true. false면 flex-wrap으로 반응형. */
50
+ scroll?: boolean;
51
+ children?: ReactNode;
52
+ };
53
+
54
+ const KanbanRoot = forwardRef<HTMLDivElement, KanbanProps>(function Kanban(
55
+ { columnWidth, gap, scroll = true, className, style, children, ...rest },
56
+ ref,
57
+ ) {
58
+ const rootClass = [
59
+ styles.board,
60
+ scroll ? styles.scroll : styles.noscroll,
61
+ className ?? "",
62
+ ]
63
+ .filter(Boolean)
64
+ .join(" ");
65
+
66
+ const cssVars: Record<string, string> = {};
67
+ if (columnWidth) cssVars["--kanban-column-width"] = columnWidth;
68
+ if (gap) cssVars["--kanban-column-gap"] = gap;
69
+
70
+ return (
71
+ <div
72
+ ref={ref}
73
+ className={rootClass}
74
+ style={{ ...cssVars, ...style }}
75
+ {...rest}
76
+ >
77
+ {children}
78
+ </div>
79
+ );
80
+ });
81
+ KanbanRoot.displayName = "Kanban";
82
+
83
+ /* ──────────────────────────────────────────────────────
84
+ Column
85
+ ──────────────────────────────────────────────────── */
86
+
87
+ export type KanbanColumnChrome = "line" | "filled" | "plain";
88
+ export type KanbanColumnAccent = "none" | "dot";
89
+ export type KanbanColumnTone =
90
+ | "neutral"
91
+ | "primary"
92
+ | "success"
93
+ | "warning"
94
+ | "danger"
95
+ | "info";
96
+ export type KanbanColumnLayout = "wrap" | "stack";
97
+
98
+ export type KanbanColumnProps = Omit<HTMLAttributes<HTMLElement>, "title"> & {
99
+ title?: ReactNode;
100
+ count?: number | string;
101
+ action?: ReactNode;
102
+ footer?: ReactNode;
103
+ chrome?: KanbanColumnChrome;
104
+ accent?: KanbanColumnAccent;
105
+ tone?: KanbanColumnTone;
106
+ layout?: KanbanColumnLayout;
107
+ children?: ReactNode;
108
+ };
109
+
110
+ const Column = forwardRef<HTMLElement, KanbanColumnProps>(function KanbanColumn(
111
+ {
112
+ title,
113
+ count,
114
+ action,
115
+ footer,
116
+ chrome = "line",
117
+ accent = "none",
118
+ tone = "neutral",
119
+ layout = "stack",
120
+ className,
121
+ children,
122
+ ...rest
123
+ },
124
+ ref,
125
+ ) {
126
+ const cls = [
127
+ styles.column,
128
+ styles[`layout-${layout}`],
129
+ styles[`chrome-${chrome}`],
130
+ styles[`accent-${accent}`],
131
+ className ?? "",
132
+ ]
133
+ .filter(Boolean)
134
+ .join(" ");
135
+
136
+ const showHeader =
137
+ title !== undefined ||
138
+ count !== undefined ||
139
+ accent !== "none" ||
140
+ action !== undefined;
141
+
142
+ return (
143
+ <div
144
+ ref={ref as Ref<HTMLDivElement>}
145
+ className={cls}
146
+ data-tone={tone}
147
+ {...(rest as HTMLAttributes<HTMLDivElement>)}
148
+ >
149
+ {showHeader && (
150
+ <div className={styles.columnHeader}>
151
+ {accent !== "none" && (
152
+ <span className={styles.accentMark} aria-hidden data-accent={accent} />
153
+ )}
154
+ {title !== undefined && (
155
+ <span className={styles.columnTitle}>{title}</span>
156
+ )}
157
+ {count !== undefined && (
158
+ <span className={styles.columnCount}>{count}</span>
159
+ )}
160
+ {action !== undefined && (
161
+ <span className={styles.columnAction}>{action}</span>
162
+ )}
163
+ </div>
164
+ )}
165
+ <div className={styles.columnBody}>{children}</div>
166
+ {footer !== undefined && (
167
+ <div className={styles.columnFooter}>{footer}</div>
168
+ )}
169
+ </div>
170
+ );
171
+ });
172
+ Column.displayName = "Kanban.Column";
173
+
174
+ /* ──────────────────────────────────────────────────────
175
+ Card
176
+ ──────────────────────────────────────────────────── */
177
+
178
+ type CardCommon = {
179
+ children?: ReactNode;
180
+ };
181
+
182
+ type CardDivProps = CardCommon &
183
+ Omit<HTMLAttributes<HTMLDivElement>, keyof CardCommon> & { href?: undefined };
184
+
185
+ type CardAnchorProps = CardCommon &
186
+ Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof CardCommon> & {
187
+ href: string;
188
+ };
189
+
190
+ export type KanbanCardProps = CardDivProps | CardAnchorProps;
191
+
192
+ const Card = forwardRef<HTMLElement, KanbanCardProps>(function KanbanCard(
193
+ props,
194
+ ref,
195
+ ) {
196
+ const {
197
+ className,
198
+ children,
199
+ ...rest
200
+ } = props as CardCommon & { className?: string } & Record<string, unknown>;
201
+
202
+ const isLink = typeof (props as CardAnchorProps).href === "string";
203
+ const isInteractive =
204
+ isLink ||
205
+ typeof (props as HTMLAttributes<HTMLElement>).onClick === "function";
206
+
207
+ const cls = [
208
+ styles.card,
209
+ isInteractive ? styles.cardInteractive : "",
210
+ className ?? "",
211
+ ]
212
+ .filter(Boolean)
213
+ .join(" ");
214
+
215
+ if (isLink) {
216
+ return (
217
+ <a
218
+ ref={ref as Ref<HTMLAnchorElement>}
219
+ className={cls}
220
+ {...(rest as AnchorHTMLAttributes<HTMLAnchorElement>)}
221
+ href={(props as CardAnchorProps).href}
222
+ >
223
+ {children}
224
+ </a>
225
+ );
226
+ }
227
+
228
+ return (
229
+ <div
230
+ ref={ref as Ref<HTMLDivElement>}
231
+ className={cls}
232
+ {...(rest as HTMLAttributes<HTMLDivElement>)}
233
+ >
234
+ {children}
235
+ </div>
236
+ );
237
+ });
238
+ Card.displayName = "Kanban.Card";
239
+
240
+ /* ── Card slots ─────────────────────────────────────── */
241
+
242
+ type DivProps = HTMLAttributes<HTMLDivElement>;
243
+
244
+ const CardCover = forwardRef<HTMLDivElement, DivProps>(function KanbanCardCover(
245
+ { className, children, ...rest },
246
+ ref,
247
+ ) {
248
+ return (
249
+ <div
250
+ ref={ref}
251
+ className={[styles.cover, className ?? ""].filter(Boolean).join(" ")}
252
+ {...rest}
253
+ >
254
+ {children}
255
+ </div>
256
+ );
257
+ });
258
+ CardCover.displayName = "Kanban.Card.Cover";
259
+
260
+ const CardHeader = forwardRef<HTMLDivElement, DivProps>(function KanbanCardHeader(
261
+ { className, children, ...rest },
262
+ ref,
263
+ ) {
264
+ return (
265
+ <div
266
+ ref={ref}
267
+ className={[styles.cardHeader, className ?? ""].filter(Boolean).join(" ")}
268
+ {...rest}
269
+ >
270
+ {children}
271
+ </div>
272
+ );
273
+ });
274
+ CardHeader.displayName = "Kanban.Card.Header";
275
+
276
+ const CardTitle = forwardRef<HTMLDivElement, DivProps>(function KanbanCardTitle(
277
+ { className, children, ...rest },
278
+ ref,
279
+ ) {
280
+ return (
281
+ <div
282
+ ref={ref}
283
+ className={[styles.cardTitle, className ?? ""].filter(Boolean).join(" ")}
284
+ {...rest}
285
+ >
286
+ {children}
287
+ </div>
288
+ );
289
+ });
290
+ CardTitle.displayName = "Kanban.Card.Title";
291
+
292
+ const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
293
+ function KanbanCardDescription({ className, children, ...rest }, ref) {
294
+ return (
295
+ <p
296
+ ref={ref}
297
+ className={[styles.cardDescription, className ?? ""]
298
+ .filter(Boolean)
299
+ .join(" ")}
300
+ {...rest}
301
+ >
302
+ {children}
303
+ </p>
304
+ );
305
+ },
306
+ );
307
+ CardDescription.displayName = "Kanban.Card.Description";
308
+
309
+ const CardMeta = forwardRef<HTMLDivElement, DivProps>(function KanbanCardMeta(
310
+ { className, children, ...rest },
311
+ ref,
312
+ ) {
313
+ return (
314
+ <div
315
+ ref={ref}
316
+ className={[styles.cardMeta, className ?? ""].filter(Boolean).join(" ")}
317
+ {...rest}
318
+ >
319
+ {children}
320
+ </div>
321
+ );
322
+ });
323
+ CardMeta.displayName = "Kanban.Card.Meta";
324
+
325
+ const CardFooter = forwardRef<HTMLDivElement, DivProps>(function KanbanCardFooter(
326
+ { className, children, ...rest },
327
+ ref,
328
+ ) {
329
+ return (
330
+ <div
331
+ ref={ref}
332
+ className={[styles.cardFooter, className ?? ""].filter(Boolean).join(" ")}
333
+ {...rest}
334
+ >
335
+ {children}
336
+ </div>
337
+ );
338
+ });
339
+ CardFooter.displayName = "Kanban.Card.Footer";
340
+
341
+ /* ──────────────────────────────────────────────────────
342
+ compound export
343
+ ──────────────────────────────────────────────────── */
344
+
345
+ type CardCompound = typeof Card & {
346
+ Cover: typeof CardCover;
347
+ Header: typeof CardHeader;
348
+ Title: typeof CardTitle;
349
+ Description: typeof CardDescription;
350
+ Meta: typeof CardMeta;
351
+ Footer: typeof CardFooter;
352
+ };
353
+
354
+ const KanbanCard = Card as CardCompound;
355
+ KanbanCard.Cover = CardCover;
356
+ KanbanCard.Header = CardHeader;
357
+ KanbanCard.Title = CardTitle;
358
+ KanbanCard.Description = CardDescription;
359
+ KanbanCard.Meta = CardMeta;
360
+ KanbanCard.Footer = CardFooter;
361
+
362
+ type KanbanCompound = typeof KanbanRoot & {
363
+ Column: typeof Column;
364
+ Card: typeof KanbanCard;
365
+ };
366
+
367
+ const Kanban = KanbanRoot as KanbanCompound;
368
+ Kanban.Column = Column;
369
+ Kanban.Card = KanbanCard;
370
+
371
+ export { Kanban };
@@ -0,0 +1,10 @@
1
+ export { Kanban } from "./Kanban";
2
+ export type {
3
+ KanbanProps,
4
+ KanbanColumnProps,
5
+ KanbanColumnChrome,
6
+ KanbanColumnAccent,
7
+ KanbanColumnTone,
8
+ KanbanColumnLayout,
9
+ KanbanCardProps,
10
+ } from "./Kanban";
@@ -0,0 +1,109 @@
1
+ /* Root ─────────────────────────────────────────────────
2
+ grid-area로 slot 배치 — DOM 순서와 무관하게 Title+Actions가 같은 줄에
3
+ 서고 Eyebrow/Lead/Meta는 그 위아래로 자동 정렬된다. 이 트릭이 compound
4
+ API의 '자식 생략 가능' 요구와 맞물려서 가장 안정적. 빈 slot은 grid가
5
+ 공간을 안 먹음(자식이 없는 행은 암묵적으로 0).
6
+
7
+ 세로 간격(eyebrow→title→lead→meta)은 row-gap으로.
8
+ PageHeader → 다음 섹션 간격은 **상위 wrapper gap이 담당** — 벤더가
9
+ margin-bottom으로 공급하지 않는다. margin trick 금지, group wrapper + gap만
10
+ ([[feedback_page_differential_rhythm]], [[feedback_no_double_spacing]]). */
11
+ .root {
12
+ display: grid;
13
+ grid-template-columns: minmax(0, 1fr) auto;
14
+ grid-template-areas:
15
+ "eyebrow eyebrow"
16
+ "title actions"
17
+ "lead lead"
18
+ "meta meta";
19
+ row-gap: var(--space-1);
20
+ align-items: start;
21
+ }
22
+
23
+ /* Size — 타이포 사이즈만 스왑. 외부 간격은 소비처 상위 gap. */
24
+ .root[data-size="sm"] .title {
25
+ font-size: var(--text-xl);
26
+ line-height: var(--lh-xl);
27
+ }
28
+ .root[data-size="sm"] .lead {
29
+ font-size: var(--text-sm);
30
+ line-height: var(--lh-sm);
31
+ }
32
+
33
+ .root[data-size="md"] .title {
34
+ font-size: var(--text-2xl);
35
+ line-height: var(--lh-2xl);
36
+ }
37
+ .root[data-size="md"] .lead {
38
+ font-size: var(--text-base);
39
+ line-height: var(--lh-base);
40
+ }
41
+
42
+ .root[data-size="lg"] .title {
43
+ font-size: var(--text-3xl);
44
+ line-height: var(--lh-3xl);
45
+ }
46
+ .root[data-size="lg"] .lead {
47
+ font-size: var(--text-base);
48
+ line-height: var(--lh-base);
49
+ }
50
+
51
+ /* Eyebrow ──────────────────────────────────────────────
52
+ 작은 상단 라벨. muted + small, uppercase/wide tracking 금지. */
53
+ .eyebrow {
54
+ grid-area: eyebrow;
55
+ color: var(--fg-subtle);
56
+ font-size: var(--text-sm);
57
+ line-height: var(--lh-sm);
58
+ font-weight: var(--font-weight-medium);
59
+ }
60
+
61
+ /* Title ─────────────────────────────────────────────────
62
+ semibold, letter-spacing은 globals의 .page-title 리듬 승계. */
63
+ .title {
64
+ grid-area: title;
65
+ color: var(--fg);
66
+ font-weight: var(--font-weight-semibold);
67
+ letter-spacing: var(--tracking-tighter);
68
+ margin: 0;
69
+ min-width: 0;
70
+ }
71
+
72
+ /* Lead ──────────────────────────────────────────────────
73
+ muted 본문. */
74
+ .lead {
75
+ grid-area: lead;
76
+ color: var(--fg-muted);
77
+ margin: 0;
78
+ max-width: 72ch;
79
+ }
80
+
81
+ /* Meta ──────────────────────────────────────────────────
82
+ Badge·날짜·author 등. 가로 묶음, gap은 space-2 (작은 컴포넌트 사이 규칙은
83
+ 이웃 간 긴밀한 묶음에 적용되고, meta는 서로 다른 '정보 조각'이라 space-2).
84
+
85
+ row-gap(space-1)이 title↔lead에 맞춰 꽤 좁아져 있는데, Meta는 '본문'이 아니라
86
+ '별도 정보 묶음'이라 그 간격이 어색하게 좁다. 윗 slot(lead/title/eyebrow 중
87
+ 존재하는 것)과의 사이만 추가 margin-top으로 벌림. */
88
+ .meta {
89
+ grid-area: meta;
90
+ display: flex;
91
+ flex-wrap: wrap;
92
+ align-items: center;
93
+ gap: var(--space-2);
94
+ color: var(--fg-muted);
95
+ font-size: var(--text-sm);
96
+ line-height: var(--lh-sm);
97
+ margin-top: var(--space-2);
98
+ }
99
+
100
+ /* Actions ───────────────────────────────────────────────
101
+ Title과 같은 줄, 오른쪽 끝에 pin.
102
+ 자식 사이 gap은 작은 컴포넌트 간 규칙대로 space-1. */
103
+ .actions {
104
+ grid-area: actions;
105
+ display: flex;
106
+ align-items: center;
107
+ gap: var(--space-1);
108
+ align-self: center;
109
+ }
@@ -0,0 +1,112 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type HTMLAttributes } from "react";
4
+
5
+ import { cn } from "../_cn";
6
+ import styles from "./PageHeader.module.css";
7
+
8
+ /* Root ─────────────────────────────────────────────────
9
+ 페이지 body 첫 블록의 head. "이 페이지가 뭔지"를 담는 자리.
10
+ Topbar(chrome)와 역할이 다르다 — Topbar는 지금 어디(breadcrumb)·공용 액션,
11
+ PageHeader는 h1·eyebrow·lead·메타·페이지 액션.
12
+
13
+ 레이아웃은 수직 스택이지만 Actions가 들어오면 Title과 같은 가로줄로
14
+ 자동 분리 (가장 흔한 패턴). Lead/Meta는 Title 아래에 계속 수직. */
15
+
16
+ export type PageHeaderSize = "sm" | "md" | "lg";
17
+
18
+ export interface PageHeaderProps extends HTMLAttributes<HTMLElement> {
19
+ size?: PageHeaderSize;
20
+ }
21
+
22
+ const PageHeaderRoot = forwardRef<HTMLElement, PageHeaderProps>(function PageHeaderRoot(
23
+ { size = "md", className, children, ...rest },
24
+ ref,
25
+ ) {
26
+ return (
27
+ <header
28
+ ref={ref}
29
+ className={cn(styles.root, className)}
30
+ data-size={size}
31
+ {...rest}
32
+ >
33
+ {children}
34
+ </header>
35
+ );
36
+ });
37
+
38
+ /* Eyebrow — 선택. 작은 상단 라벨("Overview · Billing" 같은 카테고리 표시).
39
+ uppercase + wide tracking 조합은 금지, muted 색 + 작은 사이즈로만. */
40
+ const PageHeaderEyebrow = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
41
+ function PageHeaderEyebrow({ className, children, ...rest }, ref) {
42
+ return (
43
+ <div ref={ref} className={cn(styles.eyebrow, className)} {...rest}>
44
+ {children}
45
+ </div>
46
+ );
47
+ },
48
+ );
49
+
50
+ /* Title — 필수. <h1> 자동.
51
+ Actions가 같은 부모 안에 있으면 titleRow에 묶여 가로 분리된다. */
52
+ const PageHeaderTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
53
+ function PageHeaderTitle({ className, children, ...rest }, ref) {
54
+ return (
55
+ <h1 ref={ref} className={cn(styles.title, className)} {...rest}>
56
+ {children}
57
+ </h1>
58
+ );
59
+ },
60
+ );
61
+
62
+ /* Lead — 선택. muted 본문 1~2문장. em dash·ellipsis 금지. */
63
+ const PageHeaderLead = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
64
+ function PageHeaderLead({ className, children, ...rest }, ref) {
65
+ return (
66
+ <p ref={ref} className={cn(styles.lead, className)} {...rest}>
67
+ {children}
68
+ </p>
69
+ );
70
+ },
71
+ );
72
+
73
+ /* Meta — 선택. Badge·날짜·author 등 작은 메타 조각 가로 묶음.
74
+ gap은 space-2 (meta 토큰 사이는 작은 컴포넌트 사이가 아니라 '정보 묶음' 사이). */
75
+ const PageHeaderMeta = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
76
+ function PageHeaderMeta({ className, children, ...rest }, ref) {
77
+ return (
78
+ <div ref={ref} className={cn(styles.meta, className)} {...rest}>
79
+ {children}
80
+ </div>
81
+ );
82
+ },
83
+ );
84
+
85
+ /* Actions — 선택. Title과 같은 줄로 오른쪽 끝에 pin.
86
+ 자식 사이 gap은 작은 컴포넌트 간 규칙대로 space-1. */
87
+ const PageHeaderActions = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
88
+ function PageHeaderActions({ className, children, ...rest }, ref) {
89
+ return (
90
+ <div ref={ref} className={cn(styles.actions, className)} {...rest}>
91
+ {children}
92
+ </div>
93
+ );
94
+ },
95
+ );
96
+
97
+ type PageHeaderComponent = typeof PageHeaderRoot & {
98
+ Eyebrow: typeof PageHeaderEyebrow;
99
+ Title: typeof PageHeaderTitle;
100
+ Lead: typeof PageHeaderLead;
101
+ Meta: typeof PageHeaderMeta;
102
+ Actions: typeof PageHeaderActions;
103
+ };
104
+
105
+ const PageHeader = PageHeaderRoot as PageHeaderComponent;
106
+ PageHeader.Eyebrow = PageHeaderEyebrow;
107
+ PageHeader.Title = PageHeaderTitle;
108
+ PageHeader.Lead = PageHeaderLead;
109
+ PageHeader.Meta = PageHeaderMeta;
110
+ PageHeader.Actions = PageHeaderActions;
111
+
112
+ export { PageHeader };
@@ -0,0 +1,2 @@
1
+ export { PageHeader } from "./PageHeader";
2
+ export type { PageHeaderProps, PageHeaderSize } from "./PageHeader";
@@ -34,6 +34,7 @@ export * from "./FileIcon";
34
34
  export * from "./FilterButton";
35
35
  export * from "./GalleryItem";
36
36
  export * from "./GeneratedImage";
37
+ export * from "./Hint";
37
38
  export * from "./Icon";
38
39
  export * from "./IconButton";
39
40
  export * from "./IdentityButton";
@@ -0,0 +1,57 @@
1
+ # Hint
2
+
3
+ ### 원칙
4
+ 흐름 속에 스며드는 **참고 한 줄~두 줄**. 아이콘 하나와 muted 텍스트로만 구성된 가장 가벼운 안내. 페이지 폭을 차지하지 않고, 톤도 신호도 띠지 않는다. 자리의 성격은 "이 자리에서 알아야 할 것"의 각주 — [[components/callout]]처럼 액션으로 유도하지 않고, [[recipes/page-banner]]처럼 상태를 외치지 않는다.
5
+
6
+ ### Do
7
+ - 아이콘은 **1개 고정**, text는 1~2줄. 길어지면 Callout 또는 별도 블록으로.
8
+ - 톤 색 금지 — `fg-muted` 하나만. tone·variant 축 없음.
9
+ - 아이콘 수직 정렬은 **첫 줄 캡 하이트 중앙** — flex align-items 트릭 대신 `display: inline` + `vertical-align`로 line box 안에 흘려넣기 (내부 구현 책임).
10
+ - 폼 필드 helper text는 [[components/form-field]]의 자체 helper를 쓴다. Hint는 **블록 단위 안내** — 폼 밖, 섹션 안 설명.
11
+
12
+ ### Don't
13
+ - [HARD] 액션 버튼을 넣지 않는다 — 액션이 필요하면 [[components/callout]].
14
+ - [HARD] 톤 신호(warning·destructive·success 배경)를 띠지 않는다 — 신호가 필요하면 [[components/callout]] 또는 [[recipes/page-banner]].
15
+ - [HARD] 배경 + 테두리 금지 ([[PRINCIPLES]] §5b). Hint는 **배경·테두리 둘 다 없음**이 기본.
16
+ - [SOFT] 3줄 이상 텍스트 금지 — 길어지면 섹션 자체가 "안내 블록"이 돼야 함. Hint는 각주 성격 유지.
17
+ - [SOFT] 리스트·강조 같은 서식 금지. 평문만.
18
+
19
+ ### Fallback
20
+ 아이콘이 애매하면 `info` 또는 `question`. 자리가 코드/기술 설명이면 `brackets-square`.
21
+
22
+ ### 관련
23
+ - [[components/callout]] — 톤·액션 있는 안내 블록. Hint는 액션 없음.
24
+ - [[recipes/page-banner]] — 페이지 폭 상태 바. Hint는 흐름 속 각주.
25
+ - [[components/form-field]] — 폼 helper text 전용 자리.
26
+ - [[tokens/color]] — fg-muted
27
+ - [[tokens/typography]] — text-sm 기본, line-height 1.6
28
+
29
+ ### Props 시그니처
30
+ ```tsx
31
+ <Hint
32
+ icon={IconName} // default: "info"
33
+ size="sm | md" // default: "sm" (앱 리듬 기본)
34
+ >{children}</Hint>
35
+ ```
36
+
37
+ ### Variant 매트릭스
38
+ variant 축 **없음**. Hint는 "참고 각주" 성격 하나만 — 톤·강도 다 제거해서 가장 조용함.
39
+
40
+ | size | font-size | line-height | icon size | 자리 |
41
+ |---|---|---|---|---|
42
+ | `sm` | `--text-sm` | 1.6 | 16 | 섹션 안 각주 (기본) |
43
+ | `md` | `--text-base` | 1.6 | 20 | 페이지 폭 참고 블록 |
44
+
45
+ ### 조합 예시
46
+ ```tsx
47
+ <Hint icon="brackets-square">
48
+ 문서 등록은 추출·정리까지 자동 처리합니다. AI 작업 계획을 요청하고 승인한 뒤 실행을 요청하면,
49
+ 연결된 실행기가 코드 변경안과 테스트 결과를 작성합니다.
50
+ </Hint>
51
+ ```
52
+
53
+ ### 구현 노트 (벤더 코드에만 적용)
54
+ 아이콘 수직 정렬이 **반복 함정**이라 명시:
55
+ - flex `align-items: center/flex-start` + `margin-top` 보정 조합은 **금지** — line-height 바뀔 때마다 어긋남
56
+ - 내부 구현은 **`display: inline` + `vertical-align: text-bottom`** (또는 유사 line-box 정렬) 조합으로 자동 정렬
57
+ - line-height는 `1.6` 고정 (muted 다줄 가독성 — [[tokens/spacing]] "텍스트 gap·line-height" 기본 1.5보다 한 눈금 높음)