@conscience-technology/ctds 0.3.4 → 0.4.1
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/Button/Button.module.css +2 -2
- package/assets/components/EventListItem/EventListItem.module.css +220 -0
- package/assets/components/EventListItem/EventListItem.tsx +205 -0
- package/assets/components/EventListItem/index.ts +8 -0
- package/assets/components/Icon/Icon.tsx +80 -41
- package/assets/components/Kanban/Kanban.module.css +339 -0
- package/assets/components/Kanban/Kanban.tsx +371 -0
- package/assets/components/Kanban/index.ts +10 -0
- package/assets/components/ListItem/ListItem.module.css +91 -211
- package/assets/components/ListItem/ListItem.tsx +130 -154
- package/assets/components/ListItem/index.ts +2 -2
- package/assets/components/MetricCard/MetricCard.module.css +5 -5
- package/assets/components/PageHeader/PageHeader.module.css +109 -0
- package/assets/components/PageHeader/PageHeader.tsx +112 -0
- package/assets/components/PageHeader/index.ts +2 -0
- package/assets/components/Sidebar/Sidebar.module.css +81 -4
- package/assets/components/Sidebar/Sidebar.tsx +11 -2
- package/assets/components/SplitButton/SplitButton.module.css +3 -3
- package/assets/components/Stepper/Stepper.module.css +196 -52
- package/assets/components/Stepper/Stepper.tsx +68 -13
- package/assets/components/Switch/Switch.module.css +1 -9
- package/assets/components/Topbar/Topbar.module.css +33 -5
- package/assets/components/Topbar/Topbar.tsx +62 -7
- package/assets/components/_data.ts +11 -4
- package/assets/components/index.ts +2 -0
- package/assets/components/tokens.css +23 -10
- package/assets/prompts/ASSEMBLY.md +219 -0
- package/assets/prompts/PRINCIPLES.md +70 -0
- package/assets/prompts/axes/icon.md +7 -1
- package/assets/prompts/axes/palette.md +11 -0
- package/assets/prompts/components/_variants.md +5 -1
- package/assets/prompts/components/callout.md +3 -3
- package/assets/prompts/components/event-list-item.md +103 -0
- package/assets/prompts/components/generated-image.md +80 -0
- package/assets/prompts/components/hint.md +57 -0
- package/assets/prompts/components/kanban.md +167 -0
- package/assets/prompts/components/list-item.md +182 -204
- package/assets/prompts/components/metric-card.md +2 -2
- package/assets/prompts/components/page-header.md +155 -0
- package/assets/prompts/components/toast.md +1 -0
- package/assets/prompts/components/topbar.md +77 -59
- package/assets/prompts/recipes/README.md +102 -0
- package/assets/prompts/recipes/card-row.md +58 -0
- package/assets/prompts/recipes/form-footer.md +76 -0
- package/assets/prompts/recipes/meta-line.md +61 -0
- package/assets/prompts/recipes/page-banner.md +121 -0
- package/assets/prompts/recipes/page-header.md +93 -0
- package/assets/prompts/recipes/section-header.md +90 -0
- package/assets/prompts/themes/focus.md +72 -0
- package/assets/skills/ctds/SKILL.md +1 -0
- package/assets/skills/ctds-page/SKILL.md +12 -2
- package/assets/skills/ctds-wireframe/SKILL.md +35 -1
- 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
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* 축:
|
|
7
|
+
* size — 세로 여백감. md(기본, 작업/자원 리스트 밀도) | sm (활동 로그·알림 스트림 타이트)
|
|
8
|
+
* align — leading 세로 정렬. center(기본) | top (2줄 이상 본문용)
|
|
9
|
+
* interactive — hover/focus 피드백 (href 또는 onClick 있을 때 자동)
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* (B) verb+target — "박지훈 · 댓글 남김 · ERP 2.0 사이드바"
|
|
11
|
+
* chrome 없음 — bg·border·radius 자체 공급 X. 리스트 분리는 부모가 담당.
|
|
12
|
+
* 블록 외형(배경·테두리로 상태 신호)이 필요하면 Callout 사용.
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
36
|
-
export type
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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 =
|
|
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[
|
|
114
|
-
|
|
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
|
-
{
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
{...
|
|
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-
|
|
212
|
-
{...
|
|
111
|
+
data-size={size}
|
|
112
|
+
{...(rest as HTMLAttributes<HTMLDivElement>)}
|
|
213
113
|
>
|
|
214
114
|
{content}
|
|
215
115
|
</div>
|
|
216
116
|
);
|
|
217
117
|
});
|
|
218
118
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
export { ListItem };
|
|
119
|
+
ListItemRoot.displayName = "ListItem";
|
|
222
120
|
|
|
223
121
|
/* ──────────────────────────────────────────────────────
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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(
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
export const ListItemSlot = Slot;
|
|
238
|
+
export { ListItem, ListItemLeadingIcon };
|
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
|
@@ -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 };
|