@highpixel-co/palda-design-system 0.4.1 → 0.6.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 (38) hide show
  1. package/README.md +21 -2
  2. package/dist/{chunk-GPCPGABI.js → chunk-6JZPK2W5.js} +10 -2
  3. package/dist/{chunk-Q4UXASZH.js → chunk-QW2SD3XV.js} +13 -5
  4. package/dist/guide/LICENSE +21 -0
  5. package/dist/guide/NOTICE +29 -0
  6. package/dist/guide/README.md +57 -0
  7. package/dist/guide/catalog/components.yml +272 -0
  8. package/dist/guide/catalog/drafts.yml +333 -0
  9. package/dist/guide/catalog/icons.yml +309 -0
  10. package/dist/guide/catalog/patterns.yml +132 -0
  11. package/dist/guide/catalog/tokens.yml +14 -0
  12. package/dist/guide/docs/ACCESSIBILITY.md +36 -0
  13. package/dist/guide/docs/AI_UI_DESIGNER_HANDOFF.md +258 -0
  14. package/dist/guide/docs/COMPONENT_POLICY.md +105 -0
  15. package/dist/guide/docs/CONSUMER_GUIDE.md +131 -0
  16. package/dist/guide/docs/CONTENT.md +148 -0
  17. package/dist/guide/docs/DESIGN_GRAMMAR.md +480 -0
  18. package/dist/guide/docs/DESIGN_PRINCIPLES.md +263 -0
  19. package/dist/guide/docs/FIGMA_ALIGNMENT_DELTA.md +141 -0
  20. package/dist/guide/docs/FIGMA_NAME_MAPPING.md +84 -0
  21. package/dist/guide/docs/FIGMA_WORKFLOW.md +33 -0
  22. package/dist/guide/docs/ICON_POLICY.md +175 -0
  23. package/dist/guide/docs/LAYOUT.md +221 -0
  24. package/dist/guide/docs/PATTERN_POLICY.md +13 -0
  25. package/dist/guide/docs/TOKEN_POLICY.md +266 -0
  26. package/dist/guide/icons/manifest.json +572 -0
  27. package/dist/harness/check.mjs +330 -0
  28. package/dist/harness/cli.mjs +88 -0
  29. package/dist/harness/metadata.json +1325 -0
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.js +2 -2
  32. package/dist/patterns.d.ts +15 -2
  33. package/dist/patterns.js +2 -2
  34. package/dist/scripts/check-examples.mjs +406 -0
  35. package/dist/styles.css +11 -0
  36. package/dist/ui.d.ts +6 -1
  37. package/dist/ui.js +1 -1
  38. package/package.json +13 -2
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { AlarmIcon, AlertIcon, ArrowLeftIcon, ArrowRightIcon, BlankIcon, CableIcon, CameraIcon, CardIcon, CheckIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon, CloseIcon, CopyIcon, DashboardIcon, ExternalLinkIcon, EyeIcon, EyeOffIcon, FilterIcon, GiftIcon, HelpIcon, HistoryIcon, HomeIcon, ImageIcon, InfoIcon, KeyIcon, LightbulbIcon, LinkIcon, LoadingIcon, LockIcon, LogoutIcon, MenuIcon, MessageIcon, MonitorIcon, MoreVerticalIcon, MousePointerClickIcon, PauseIcon, PencilIcon, PersonIcon, PhoneIcon, PlusIcon, ProductIcon, RefreshIcon, ReportIcon, SearchIcon, SendIcon, ShieldCheckIcon, SlidersIcon, SmartphoneIcon, SparklesIcon, StarIcon, StoreIcon, SuccessIcon, ThumbsUpIcon, TimerIcon, TrashIcon, TrendingUpIcon, TruckIcon, TypeIcon, UnplugIcon, UploadIcon } from './icons.js';
2
- export { AppShell, AppShellProps, BottomBar, BottomBarProps, Card, CardProps, CardTone, EmptyState, EmptyStateProps, FormSection, FormSectionProps, ListRow, ListRowProps, LivePreview, LivePreviewProps, LivePreviewStatus } from './patterns.js';
2
+ export { AppShell, AppShellProps, BottomBar, BottomBarProps, Card, CardProps, CardTone, EmptyState, EmptyStateProps, FormSection, FormSectionProps, ListRow, ListRowEmphasis, ListRowProps, LivePreview, LivePreviewProps, LivePreviewStatus } from './patterns.js';
3
3
  export { Alert, AlertProps, AlertVariant, Badge, BadgeProps, BadgeSize, BadgeVariant, Button, ButtonProps, ButtonSize, ButtonVariant, Checkbox, CheckboxProps, Chip, ChipProps, ChipSize, ChipVariant, DropdownButton, DropdownButtonAlign, DropdownButtonItem, DropdownButtonProps, DropdownButtonVariant, IconButton, IconButtonProps, IconButtonSize, IconButtonVariant, Link, LinkProps, LinkSize, LinkVariant, Modal, ModalActions, ModalActionsProps, ModalProps, NavigationButton, NavigationButtonProps, ProgressBar, ProgressBarProps, Radio, RadioProps, Switch, SwitchProps, SwitchSize, Tab, TabProps, TextField, TextFieldProps, TextFieldSize, TextFieldVariant, ToastOptions, ToastVariant, Toaster, ToasterPlacement, ToasterProps, Tooltip, TooltipPlacement, TooltipProps, toast } from './ui.js';
4
4
  import 'react';
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  FormSection,
8
8
  ListRow,
9
9
  LivePreview
10
- } from "./chunk-Q4UXASZH.js";
10
+ } from "./chunk-QW2SD3XV.js";
11
11
  import "./chunk-EYFL232L.js";
12
12
  import {
13
13
  Alert,
@@ -29,7 +29,7 @@ import {
29
29
  Toaster,
30
30
  Tooltip,
31
31
  toast
32
- } from "./chunk-GPCPGABI.js";
32
+ } from "./chunk-6JZPK2W5.js";
33
33
  import {
34
34
  AlarmIcon,
35
35
  AlertIcon,
@@ -112,11 +112,21 @@ interface FormSectionProps {
112
112
  */
113
113
  declare function FormSection({ children, description, error, footer, title }: FormSectionProps): react.JSX.Element;
114
114
 
115
+ type ListRowEmphasis = 'default' | 'strong';
115
116
  interface ListRowProps {
116
117
  /** 누를 수 있는 행 오른쪽 끝의 chevron. `onClick`이 있으면 기본으로 붙는다. */
117
118
  chevron?: boolean;
118
119
  children?: ReactNode;
119
120
  description?: ReactNode;
121
+ /**
122
+ * 행이 놓인 층. 크기를 고르는 자리가 아니라 이 목록이 화면의 본문인지를 밝히는 자리다.
123
+ * - `default`(기본) — 제목 `body2`(14) · 설명 `label`(12). 곁·요약·참조 나열이다.
124
+ * - `strong` — 제목 `body1`(16) · 설명 `body2`(14). 선언서의 본문 문장이 가리키는 목록
125
+ * 하나에만 쓴다. 한 화면에 `strong` 목록은 하나다.
126
+ *
127
+ * `tone="filled"`와 함께 쓰지 않는다 — 곁이 본문 크기를 갖는 일이다 (0032, 0034).
128
+ */
129
+ emphasis?: ListRowEmphasis;
120
130
  /** 제목 앞 아이콘이나 썸네일. */
121
131
  leading?: ReactNode;
122
132
  /** 자리를 잡아 두는 로딩 표시. 켜면 내용 대신 뼈대를 그린다. */
@@ -140,9 +150,12 @@ interface ListRowProps {
140
150
  *
141
151
  * `onClick`을 주면 누를 수 있는 행이 되어 button으로 그려지고 오른쪽 끝에 chevron이 붙는다.
142
152
  *
153
+ * 이 목록이 화면의 본문이면 `emphasis="strong"`으로 제목·설명을 한 칸씩 올린다 (0034). 곁·요약
154
+ * 나열은 기본값 그대로 둔다.
155
+ *
143
156
  * 목록 전체가 비었을 때와 실패했을 때는 이 컴포넌트가 아니라 `EmptyState`를 쓴다.
144
157
  */
145
- declare function ListRow({ chevron, children, description, leading, loading, onClick, selected, title, tone, trailing, }: ListRowProps): react.JSX.Element;
158
+ declare function ListRow({ chevron, children, description, emphasis, leading, loading, onClick, selected, title, tone, trailing, }: ListRowProps): react.JSX.Element;
146
159
 
147
160
  type LivePreviewStatus = 'ready' | 'loading' | 'empty' | 'error';
148
161
  interface LivePreviewProps {
@@ -159,4 +172,4 @@ interface LivePreviewProps {
159
172
  */
160
173
  declare function LivePreview({ children, message, status, title }: LivePreviewProps): react.JSX.Element;
161
174
 
162
- export { AppShell, type AppShellProps, BottomBar, type BottomBarProps, Card, type CardProps, type CardTone, EmptyState, type EmptyStateProps, FormSection, type FormSectionProps, ListRow, type ListRowProps, LivePreview, type LivePreviewProps, type LivePreviewStatus };
175
+ export { AppShell, type AppShellProps, BottomBar, type BottomBarProps, Card, type CardProps, type CardTone, EmptyState, type EmptyStateProps, FormSection, type FormSectionProps, ListRow, type ListRowEmphasis, type ListRowProps, LivePreview, type LivePreviewProps, type LivePreviewStatus };
package/dist/patterns.js CHANGED
@@ -6,8 +6,8 @@ import {
6
6
  FormSection,
7
7
  ListRow,
8
8
  LivePreview
9
- } from "./chunk-Q4UXASZH.js";
10
- import "./chunk-GPCPGABI.js";
9
+ } from "./chunk-QW2SD3XV.js";
10
+ import "./chunk-6JZPK2W5.js";
11
11
  import "./chunk-TWYJ2UTC.js";
12
12
  export {
13
13
  AppShell,
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 화면 선언서와 시안 마크업을 검사한다.
4
+ *
5
+ * 화면은 그리기 전에 본문을 한 문장으로 선언하고, 선언에 들지 않은 것은 곁으로 내린다
6
+ * (`docs/decisions/0022-screens-declare-their-body.md`). 선언이 산문 규칙으로만 있으면
7
+ * 화면을 들일 때 빠지기 쉬워서, 셀 수 있는 부분만 검사로 둔다.
8
+ *
9
+ * 대상이 둘이다.
10
+ *
11
+ * 1. 승인 화면 문서(`examples/approved/*.md`) — 선언서의 항목과 개수.
12
+ * 2. 시안 HTML(`preview/`·`examples/`의 `.html`) — 같은 선언서 + 바디 블록의 소속 표시.
13
+ *
14
+ * 2번은 0022 §적용이 "강제되지 않는 것"에 남겨 둔 곁의 규칙들이다. 당시 근거는 "화면 코드가
15
+ * 이 저장소에 없어 셀 대상이 없다"였는데, 시안 HTML이 그 대상이다 — 시안 CSS를 `check:css`로
16
+ * 올린 것과 같은 자리다(`docs/COMPONENT_POLICY.md` §시안은 HTML로 먼저 확정한다).
17
+ * `AGENTS.md` §항상("반복되는 오류는 검사로 승격한다")을 따른다.
18
+ *
19
+ * 실제로 새어 나갔다 — 스토어 알림톡 시안이 결제·구독 요약(명백한 곁)을 `곁: 없음`이라고
20
+ * 선언해 놓고 `Card` + 행 넷 + 버튼으로 그렸다. 규칙 셋을 전부 어겼는데 선언서만 보는 검사는
21
+ * 통과했다.
22
+ *
23
+ * 곁의 규칙과 별개로 **상태를 누가 드는가**도 한 가지 센다. 행 하나의 실패를 섹션 배너로 올리면
24
+ * 같은 사실을 두 번 말하고, 면적이 큰 배너가 본문보다 강해진다
25
+ * (`docs/decisions/0024-state-belongs-to-the-nearest-owner.md`). 스토어 알림톡 시안이 실제로
26
+ * 그렇게 그려져 목록이 눈에 안 들어왔다.
27
+ *
28
+ * 읽지 못하는 것은 그대로 리뷰가 받는다 — 본문 문장의 폭과, 곁이 폭에 밀려 접히는지다. 곁의
29
+ * 규칙 1(세로로 한 줄)은 마크업으로 이미 갈라 둔 경우만 잡는다. 곁의 자리도 세지 않는다 —
30
+ * 0022가 자리를 정하지 않고, 본문보다 먼저 오는 것도 규칙 위반이 아니다
31
+ * (`docs/DESIGN_GRAMMAR.md` §자리는 시스템이 정하지 않는다).
32
+ */
33
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
34
+ import { dirname, join, relative, resolve } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+
37
+ const DEFAULT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
38
+
39
+ /** 선언서는 문서 맨 위의 표다. 첫 칸이 항목 이름이고 백틱은 있어도 없어도 된다. */
40
+ const ROW = /^\|([^|]*)\|([^|]*)\|/;
41
+ /** 표의 구분선(`| --- | --- |`)은 항목이 아니다. */
42
+ const DIVIDER = /^[\s:-]*$/;
43
+
44
+ /**
45
+ * 주 액션의 자리. `없음`은 고르는 행위가 본문 안에서 끝나는 화면이다 — 목록을 훑고 행을 눌러
46
+ * 들어가는 화면은 행이 곧 액션이라 헤더나 하단에 세울 것이 없다
47
+ * (`docs/decisions/0033-a-screen-may-have-no-primary-action.md`). 행 자체는 여전히 필수라
48
+ * "적기를 잊은 것"과 "정말 없는 것"이 갈린다.
49
+ */
50
+ const PRIMARY_ACTION_PLACES = new Set(['헤더 우측', 'BottomBar', '없음']);
51
+
52
+ /** 시안을 찾는 곳. `preview/`는 untracked라 CI에서는 비어 있고 로컬에서만 걸린다. */
53
+ const SCREEN_DIRS = ['preview', 'examples'];
54
+
55
+ /** 셸의 바디 밴드. 이 안의 직계 자식이 화면의 블록이다 (`docs/LAYOUT.md` §콘텐츠 세로 순서). */
56
+ const BODY_BAND_CLASS = 'palda-app-shell__band--body';
57
+
58
+ /** 바디 직계 자식이 밝히는 소속. 순서도 이 순서다 — 헤더 → 본문 → 곁. */
59
+ const ROLE_ATTRIBUTE = /data-screen-role\s*=\s*"([^"]*)"/;
60
+ const ROLES = ['헤더', '본문', '곁'];
61
+
62
+ /**
63
+ * 곁이 쓰지 않는 pattern 클래스 (0022 규칙 2 — 본문이 쓰는 pattern을 쓰지 않는다).
64
+ * 본문이 무엇을 쓰는지와 무관하게 pattern 자체가 "이 화면이 하는 일"을 담는 도구다.
65
+ * 곁에 남는 표현은 텍스트와 `Link`다(0022 §대가).
66
+ */
67
+ const BODY_PATTERN_CLASSES = [
68
+ 'palda-card',
69
+ 'palda-form-section',
70
+ 'palda-list-row',
71
+ 'palda-empty-state',
72
+ 'palda-live-preview',
73
+ 'palda-bottom-bar',
74
+ ];
75
+
76
+ /**
77
+ * 곁이 쓰지 않는 강조 채움 (0022 규칙 3 — 강조 예산은 본문이 갖는다).
78
+ * 액션의 채움만 본다. 상태 배지는 강조 예산을 쓰지 않는다
79
+ * (`docs/DESIGN_PRINCIPLES.md` §4의 "상태 색은 이 한도에 들어가지 않는다").
80
+ */
81
+ const EMPHASIS_CLASSES = [
82
+ 'palda-button--primary',
83
+ 'palda-button--accent',
84
+ 'palda-chip--primary',
85
+ 'palda-chip--accent',
86
+ ];
87
+
88
+ /**
89
+ * 상태를 드는 자리 (0024 — 상태는 가장 가까운 자리가 든다).
90
+ * 한 섹션이 둘을 같이 쓰면 행 하나의 실패를 섹션이 대신 말한 것이다. `\b`가 `_`를 경계로 보지
91
+ * 않아 `palda-form-section__error`는 섹션 클래스로 세지 않는다.
92
+ */
93
+ const FORM_SECTION_CLASS = 'palda-form-section';
94
+ const SECTION_FAILURE_CLASS = 'palda-form-section__error';
95
+ const ROW_FAILURE_CLASS = 'palda-badge--danger';
96
+
97
+ /** 닫는 태그가 없는 요소. 깊이를 올리지 않는다. */
98
+ const VOID_ELEMENTS = new Set([
99
+ 'area',
100
+ 'base',
101
+ 'br',
102
+ 'col',
103
+ 'embed',
104
+ 'hr',
105
+ 'img',
106
+ 'input',
107
+ 'link',
108
+ 'meta',
109
+ 'param',
110
+ 'source',
111
+ 'track',
112
+ 'wbr',
113
+ ]);
114
+
115
+ function cell(text) {
116
+ return text.replace(/`/g, '').trim();
117
+ }
118
+
119
+ /** 선언서 표의 행을 읽는다. 마크다운이든 HTML 주석 안이든 서식이 같다. */
120
+ function declarationRows(source) {
121
+ const rows = [];
122
+ for (const line of source.split('\n')) {
123
+ const match = ROW.exec(line.trim());
124
+ if (!match || DIVIDER.test(match[1])) continue;
125
+ rows.push({ label: cell(match[1]), value: cell(match[2]) });
126
+ }
127
+ return rows;
128
+ }
129
+
130
+ /** 선언서의 셀 수 있는 부분 — 항목이 있는지, 본문이 하나인지, 주 액션 값이 둘 중 하나인지. */
131
+ function checkDeclaration(rows, where, problems) {
132
+ const body = rows.filter((row) => row.label === '본문');
133
+ const primary = rows.filter((row) => row.label === '주 액션');
134
+
135
+ if (body.length === 0) {
136
+ problems.push(`${where}: 선언서에 \`본문\` 행이 없다`);
137
+ } else if (body.length > 1) {
138
+ problems.push(`${where}: 본문이 ${body.length}개다 — 하나여야 하고, 둘이면 화면을 쪼갠다`);
139
+ }
140
+
141
+ if (primary.length === 0) {
142
+ problems.push(`${where}: 선언서에 \`주 액션\` 행이 없다`);
143
+ } else if (primary.length > 1) {
144
+ problems.push(`${where}: 주 액션이 ${primary.length}개다 — 자리는 하나다`);
145
+ } else if (!PRIMARY_ACTION_PLACES.has(primary[0].value)) {
146
+ problems.push(
147
+ `${where}: 주 액션 자리가 \`${primary[0].value}\`다 — ${[...PRIMARY_ACTION_PLACES]
148
+ .map((place) => `\`${place}\``)
149
+ .join(' 또는 ')} 중 하나여야 한다`,
150
+ );
151
+ }
152
+
153
+ /** `곁` 행에 `없음`이라고 적으면 곁이 0개라는 뜻이다. */
154
+ const asides = rows
155
+ .filter((row) => row.label === '곁')
156
+ .filter((row) => row.value !== '' && row.value !== '없음');
157
+
158
+ return { declared: body.length > 0, asides: asides.length };
159
+ }
160
+
161
+ /** 주석을 같은 길이의 공백으로 지운다. 주석 안의 예시 마크업이 태그로 세지지 않게 한다. */
162
+ function stripComments(html) {
163
+ return html.replace(/<!--[\s\S]*?-->/g, (match) => ' '.repeat(match.length));
164
+ }
165
+
166
+ function scanTags(html) {
167
+ const TAG = /<(\/)?([a-zA-Z][\w-]*)((?:'[^']*'|"[^"]*"|[^'">])*)>/g;
168
+ const tags = [];
169
+ let match;
170
+ while ((match = TAG.exec(html)) !== null) {
171
+ const attributes = match[3] ?? '';
172
+ tags.push({
173
+ closing: match[1] === '/',
174
+ name: match[2].toLowerCase(),
175
+ attributes,
176
+ selfClosing: attributes.trimEnd().endsWith('/'),
177
+ start: match.index,
178
+ end: TAG.lastIndex,
179
+ });
180
+ }
181
+ return tags;
182
+ }
183
+
184
+ /**
185
+ * 셸 바디 밴드의 직계 자식을 순서대로 돌려준다.
186
+ * 밴드가 없으면 `null`이다 — 컴포넌트 시안은 화면이 아니라 이 검사를 받지 않는다.
187
+ */
188
+ function bodyBlocks(html) {
189
+ const source = stripComments(html);
190
+ const tags = scanTags(source);
191
+ const band = tags.findIndex((tag) => !tag.closing && tag.attributes.includes(BODY_BAND_CLASS));
192
+ if (band < 0) return null;
193
+
194
+ const blocks = [];
195
+ let depth = 0;
196
+ let open = null;
197
+
198
+ for (let index = band + 1; index < tags.length; index += 1) {
199
+ const tag = tags[index];
200
+
201
+ if (tag.closing) {
202
+ // 밴드 자신이 닫혔다.
203
+ if (depth === 0) break;
204
+ depth -= 1;
205
+ if (depth === 0 && open) {
206
+ blocks.push({ tag: open, html: source.slice(open.start, tag.end) });
207
+ open = null;
208
+ }
209
+ continue;
210
+ }
211
+
212
+ if (depth === 0) {
213
+ if (tag.selfClosing || VOID_ELEMENTS.has(tag.name)) {
214
+ blocks.push({ tag, html: source.slice(tag.start, tag.end) });
215
+ continue;
216
+ }
217
+ open = tag;
218
+ }
219
+ if (!tag.selfClosing && !VOID_ELEMENTS.has(tag.name)) depth += 1;
220
+ }
221
+
222
+ return blocks;
223
+ }
224
+
225
+ /**
226
+ * 블록 안의 `.palda-form-section` 요소를 HTML 조각으로 하나씩 돌려준다.
227
+ * 섹션은 중첩되지 않지만, 안쪽 태그의 닫는 순서에 속지 않도록 깊이를 센다.
228
+ */
229
+ function formSections(html) {
230
+ const tags = scanTags(html);
231
+ const sections = [];
232
+ const isSection = new RegExp(`\\b${FORM_SECTION_CLASS}\\b`);
233
+
234
+ for (let index = 0; index < tags.length; index += 1) {
235
+ const open = tags[index];
236
+ if (open.closing || !isSection.test(open.attributes)) continue;
237
+
238
+ let depth = 0;
239
+ for (let cursor = index; cursor < tags.length; cursor += 1) {
240
+ const tag = tags[cursor];
241
+ if (tag.closing) {
242
+ depth -= 1;
243
+ if (depth === 0) {
244
+ sections.push(html.slice(open.start, tag.end));
245
+ break;
246
+ }
247
+ continue;
248
+ }
249
+ if (!tag.selfClosing && !VOID_ELEMENTS.has(tag.name)) depth += 1;
250
+ }
251
+ }
252
+
253
+ return sections;
254
+ }
255
+
256
+ /** 바디 블록의 소속 표시와 곁의 규칙을 센다. */
257
+ function checkBlocks(blocks, declaredAsides, where, problems) {
258
+ const roles = [];
259
+
260
+ for (const [index, block] of blocks.entries()) {
261
+ const match = ROLE_ATTRIBUTE.exec(block.tag.attributes);
262
+ const role = match ? match[1].trim() : null;
263
+
264
+ if (!role) {
265
+ problems.push(
266
+ `${where}: 바디 ${index + 1}번째 블록(<${block.tag.name}>)에 data-screen-role이 없다 — ${ROLES.map((name) => `\`${name}\``).join('·')} 중 하나를 밝힌다`,
267
+ );
268
+ roles.push(null);
269
+ continue;
270
+ }
271
+ if (!ROLES.includes(role)) {
272
+ problems.push(
273
+ `${where}: 바디 ${index + 1}번째 블록의 data-screen-role이 \`${role}\`다 — ${ROLES.map((name) => `\`${name}\``).join('·')} 중 하나여야 한다`,
274
+ );
275
+ roles.push(null);
276
+ continue;
277
+ }
278
+ roles.push(role);
279
+
280
+ if (role !== '곁') continue;
281
+
282
+ // 규칙 2 — 본문이 쓰는 pattern을 쓰지 않는다.
283
+ for (const pattern of BODY_PATTERN_CLASSES) {
284
+ if (new RegExp(`\\b${pattern}\\b`).test(block.html)) {
285
+ problems.push(
286
+ `${where}: 곁이 \`${pattern}\`을 쓴다 — 곁은 본문이 쓰는 pattern을 쓰지 않는다 (0022 규칙 2)`,
287
+ );
288
+ }
289
+ }
290
+
291
+ // 규칙 3 — 강조 예산을 쓰지 않는다.
292
+ for (const emphasis of EMPHASIS_CLASSES) {
293
+ if (new RegExp(`\\b${emphasis}\\b`).test(block.html)) {
294
+ problems.push(
295
+ `${where}: 곁이 \`${emphasis}\`를 쓴다 — 강조 예산은 본문이 갖는다 (0022 규칙 3)`,
296
+ );
297
+ }
298
+ }
299
+ }
300
+
301
+ // 상태는 가장 가까운 자리가 든다 (0024). 소속과 무관하게 바디 전체를 본다.
302
+ for (const block of blocks) {
303
+ for (const section of formSections(block.html)) {
304
+ if (!section.includes(SECTION_FAILURE_CLASS)) continue;
305
+ if (!new RegExp(`\\b${ROW_FAILURE_CLASS}\\b`).test(section)) continue;
306
+ problems.push(
307
+ `${where}: 한 섹션이 \`${SECTION_FAILURE_CLASS}\` 배너와 \`${ROW_FAILURE_CLASS}\` 배지로 같은 실패를 두 번 말한다 — 행 하나의 실패는 그 행이 든다 (0024)`,
308
+ );
309
+ }
310
+ }
311
+
312
+ // 헤더는 하나이고 맨 앞이다 (`docs/LAYOUT.md` §콘텐츠 세로 순서).
313
+ const headers = roles.filter((role) => role === '헤더').length;
314
+ if (headers > 1) {
315
+ problems.push(`${where}: 페이지 헤더가 ${headers}개다 — 하나다`);
316
+ } else if (headers === 1 && roles[0] !== '헤더') {
317
+ problems.push(`${where}: 페이지 헤더가 첫 블록이 아니다 — 바디의 맨 위다`);
318
+ }
319
+
320
+ // 본문은 블록 여러 개로 나뉘어도 되지만 사이가 갈리지 않는다. 선언서의 본문은 한 문장이고,
321
+ // 그 한 문장이 곁을 사이에 두고 두 덩어리로 읽히면 화면이 둘이다.
322
+ const bodyIndexes = roles.flatMap((role, index) => (role === '본문' ? [index] : []));
323
+ if (
324
+ bodyIndexes.length > 1 &&
325
+ bodyIndexes[bodyIndexes.length - 1] - bodyIndexes[0] + 1 !== bodyIndexes.length
326
+ ) {
327
+ problems.push(
328
+ `${where}: 본문 블록 사이에 다른 것이 끼어 있다 — 본문은 여러 블록이어도 붙어 있어야 한다`,
329
+ );
330
+ }
331
+
332
+ // 선언되지 않은 곁은 화면에 들어오지 못한다. 곁이 몇 개여야 하는지는 세지 않는다 — 기능이
333
+ // 다르면 블록도 다르고(0032 규칙 1), 개수는 기능 수가 정한다.
334
+ const drawn = roles.filter((role) => role === '곁').length;
335
+ if (drawn !== declaredAsides) {
336
+ problems.push(
337
+ `${where}: 곁이 선언서에 ${declaredAsides}개인데 화면에 ${drawn}개다 — 적히지 않은 곁은 화면에 들어오지 못한다`,
338
+ );
339
+ }
340
+ }
341
+
342
+ function htmlFiles(dir, out = []) {
343
+ for (const name of readdirSync(dir)) {
344
+ const full = join(dir, name);
345
+ if (statSync(full).isDirectory()) htmlFiles(full, out);
346
+ else if (name.endsWith('.html')) out.push(full);
347
+ }
348
+ return out;
349
+ }
350
+
351
+ export function validateScreenHtml(source, where, requireScreen = false) {
352
+ const problems = [];
353
+ const blocks = bodyBlocks(source);
354
+ if (blocks === null) {
355
+ if (requireScreen) problems.push(`${where}: DS 셸 바디가 없는 HTML은 화면 검사 대상이 아니다`);
356
+ return problems;
357
+ }
358
+ const rows = declarationRows(source);
359
+ if (rows.length === 0) {
360
+ problems.push(`${where}: 화면 선언 표가 없다 — 파일 맨 위 주석에 둔다`);
361
+ return problems;
362
+ }
363
+ const { asides } = checkDeclaration(rows, where, problems);
364
+ checkBlocks(blocks, asides, where, problems);
365
+ return problems;
366
+ }
367
+
368
+ export function validateExamples(root = DEFAULT_ROOT) {
369
+ const problems = [];
370
+
371
+ // 1. 승인 화면 문서 — 선언서만 본다. 화면은 시안 HTML이 갖는다.
372
+ const approvedDir = join(root, 'examples/approved');
373
+ if (existsSync(approvedDir)) {
374
+ for (const name of readdirSync(approvedDir)) {
375
+ if (!name.endsWith('.md') || name === 'README.md') continue;
376
+ const file = join(approvedDir, name);
377
+ checkDeclaration(declarationRows(readFileSync(file, 'utf8')), relative(root, file), problems);
378
+ }
379
+ }
380
+
381
+ // 2. 시안 HTML — 선언서 + 바디 블록의 소속.
382
+ for (const dir of SCREEN_DIRS) {
383
+ const full = join(root, dir);
384
+ if (!existsSync(full)) continue;
385
+
386
+ for (const file of htmlFiles(full)) {
387
+ const source = readFileSync(file, 'utf8');
388
+ const where = relative(root, file);
389
+ problems.push(...validateScreenHtml(source, where));
390
+ }
391
+ }
392
+
393
+ return problems;
394
+ }
395
+
396
+ function main() {
397
+ const problems = validateExamples(process.env.DS_ROOT ?? DEFAULT_ROOT);
398
+ if (problems.length > 0) {
399
+ console.error(`화면 선언·시안 불일치 ${problems.length}건\n`);
400
+ for (const problem of problems) console.error(` ${problem}`);
401
+ process.exit(1);
402
+ }
403
+ console.log('화면 선언·시안 검사 통과');
404
+ }
405
+
406
+ if (process.argv[1] === fileURLToPath(import.meta.url)) main();
package/dist/styles.css CHANGED
@@ -1355,6 +1355,11 @@
1355
1355
  justify-content: center;
1356
1356
  width: 24px;
1357
1357
  }
1358
+ .palda-tab__badge {
1359
+ align-items: center;
1360
+ display: inline-flex;
1361
+ flex-shrink: 0;
1362
+ }
1358
1363
  .palda-tab__label {
1359
1364
  white-space: nowrap;
1360
1365
  }
@@ -2112,6 +2117,12 @@ button.palda-list-row:focus-visible {
2112
2117
  height: var(--palda-font-size-xs);
2113
2118
  width: 70%;
2114
2119
  }
2120
+ .palda-list-row--strong .palda-list-row__skeleton--title {
2121
+ height: var(--palda-font-size-md);
2122
+ }
2123
+ .palda-list-row--strong .palda-list-row__skeleton--description {
2124
+ height: var(--palda-font-size-sm);
2125
+ }
2115
2126
  @keyframes palda-list-row-pulse {
2116
2127
  from {
2117
2128
  opacity: 1;
package/dist/ui.d.ts CHANGED
@@ -174,10 +174,15 @@ interface TabProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'role'
174
174
  children: ReactNode;
175
175
  /** 라벨 앞 아이콘. 시안 기준 24px 고정이다. */
176
176
  icon?: ReactNode;
177
+ /**
178
+ * 라벨 뒤 배지. 그룹별 개수처럼 탭을 열지 않고도 보여야 하는 수를 싣는다.
179
+ * `Badge`를 넣는 자리이며 크기는 `sm`을 쓴다 — 라벨이 Body2(14px)라 md(28px)는 라벨보다 커진다.
180
+ */
181
+ badge?: ReactNode;
177
182
  /** 지금 보고 있는 탭. `aria-selected`로 함께 노출된다. */
178
183
  selected?: boolean;
179
184
  }
180
- declare function Tab({ children, className, icon, selected, ...props }: TabProps): react.JSX.Element;
185
+ declare function Tab({ children, className, icon, badge, selected, ...props }: TabProps): react.JSX.Element;
181
186
 
182
187
  type TextFieldVariant = 'outline' | 'accent' | 'subtle';
183
188
  type TextFieldSize = 'md' | 'sm';
package/dist/ui.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  Toaster,
20
20
  Tooltip,
21
21
  toast
22
- } from "./chunk-GPCPGABI.js";
22
+ } from "./chunk-6JZPK2W5.js";
23
23
  import "./chunk-TWYJ2UTC.js";
24
24
  export {
25
25
  Alert,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@highpixel-co/palda-design-system",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Palda product design tokens, icons, React components, and patterns",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,6 +11,12 @@
11
11
  ],
12
12
  "main": "./dist/index.js",
13
13
  "types": "./dist/index.d.ts",
14
+ "bin": {
15
+ "palda-ds": "./dist/harness/cli.mjs"
16
+ },
17
+ "engines": {
18
+ "node": ">=22.12.0"
19
+ },
14
20
  "exports": {
15
21
  ".": {
16
22
  "types": "./dist/index.d.ts",
@@ -32,7 +38,8 @@
32
38
  "types": "./dist/tokens.d.ts",
33
39
  "import": "./dist/tokens.js"
34
40
  },
35
- "./styles.css": "./dist/styles.css"
41
+ "./styles.css": "./dist/styles.css",
42
+ "./catalog": "./dist/harness/metadata.json"
36
43
  },
37
44
  "sideEffects": [
38
45
  "./dist/styles.css"
@@ -52,5 +59,9 @@
52
59
  "publishConfig": {
53
60
  "registry": "https://registry.npmjs.org/",
54
61
  "access": "public"
62
+ },
63
+ "dependencies": {
64
+ "js-yaml": "4.3.0",
65
+ "typescript": "6.0.3"
55
66
  }
56
67
  }