@highpixel-co/palda-design-system 0.6.0 → 0.8.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.
- package/dist/{chunk-QW2SD3XV.js → chunk-3OXUONDZ.js} +22 -6
- package/dist/{chunk-6JZPK2W5.js → chunk-PWAQFOM3.js} +105 -42
- package/dist/guide/README.md +3 -3
- package/dist/guide/catalog/components.yml +71 -4
- package/dist/guide/catalog/drafts.yml +46 -0
- package/dist/guide/catalog/patterns.yml +11 -4
- package/dist/guide/docs/AI_UI_DESIGNER_HANDOFF.md +3 -3
- package/dist/guide/docs/COMPONENT_POLICY.md +2 -2
- package/dist/guide/docs/CONTENT.md +1 -1
- package/dist/guide/docs/DESIGN_GRAMMAR.md +126 -26
- package/dist/guide/docs/DESIGN_PRINCIPLES.md +10 -10
- package/dist/guide/docs/FIGMA_ALIGNMENT_DELTA.md +4 -4
- package/dist/guide/docs/FIGMA_NAME_MAPPING.md +4 -4
- package/dist/guide/docs/FIGMA_WORKFLOW.md +3 -3
- package/dist/guide/docs/ICON_POLICY.md +3 -3
- package/dist/guide/docs/LAYOUT.md +17 -11
- package/dist/guide/docs/TOKEN_POLICY.md +5 -5
- package/dist/harness/check.mjs +13 -7
- package/dist/harness/metadata.json +36 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -2
- package/dist/patterns.d.ts +12 -1
- package/dist/patterns.js +2 -2
- package/dist/scripts/check-examples.mjs +253 -10
- package/dist/styles.css +100 -41
- package/dist/ui.d.ts +67 -5
- package/dist/ui.js +5 -1
- package/package.json +1 -1
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
- 이름은 실제 값이 아니라 역할을 표현한다. 예: `color.action.primary.background`.
|
|
8
8
|
- 컴포넌트 CSS에는 `var(--token)` 참조만 쓴다. 시안에 적힌 리터럴 값(`#54B336`, `36px`)을 그대로 옮기지 않는다.
|
|
9
9
|
- 필요한 token이 없으면 추가하지 말고 먼저 확인받는다. 값을 지어내지 않는다. 과거에 있던 token이라도 복원은 확인 대상이다.
|
|
10
|
-
- **값이 어긋나면 코드가 옳다.** 저장소가 SSOT이고([`decisions/0001`](https://github.com/highpixel-co/palda-design-system/blob/
|
|
10
|
+
- **값이 어긋나면 코드가 옳다.** 저장소가 SSOT이고([`decisions/0001`](https://github.com/highpixel-co/palda-design-system/blob/abee826d0afda16bd3776f015931e0a820a8876e/docs/decisions/0001-design-system-ssot.md), 자산별 경계는 [`decisions/0016`](https://github.com/highpixel-co/palda-design-system/blob/abee826d0afda16bd3776f015931e0a820a8876e/docs/decisions/0016-asset-ssot-boundary.md)), Figma와 다른 자리는 코드를 고치지 않고 [`FIGMA_ALIGNMENT_DELTA.md`](FIGMA_ALIGNMENT_DELTA.md)에 Figma가 바뀌어야 할 목록으로 적는다. 이름이 어긋나는 자리는 [`FIGMA_NAME_MAPPING.md`](FIGMA_NAME_MAPPING.md)에 있다.
|
|
11
11
|
- 저장소의 token 값은 확정치로 취급한다. 명도 대비 미달처럼 품질 문제를 발견하면 보고하되, 컴포넌트에서 다른 색으로 바꾸거나 값 변경을 반복해서 제안하지 않는다.
|
|
12
12
|
- 보더 두께는 예외다. 시스템 전체가 항상 `1px`이며 token으로 만들지 않고 CSS에 직접 쓴다.
|
|
13
13
|
- `tokens/generated/`는 직접 수정하지 않는다. `tokens/*.tokens.json`을 고치고 `pnpm tokens:build`로 다시 만든 뒤, 소스와 생성 파일을 함께 커밋한다. 생성 파일이 빠지면 `pnpm check:generated`가 실패한다.
|
|
@@ -178,7 +178,7 @@ focus에서 `border/focus`로 구분한다.
|
|
|
178
178
|
`Card`·`ListRow`) 화면이 다시 고르는 것은 페이지 제목과 **본문 목록 여부** 둘이다.
|
|
179
179
|
|
|
180
180
|
**행은 두 칸이고 그 목록이 화면의 본문인지가 고른다**
|
|
181
|
-
([`decisions/0034`](https://github.com/highpixel-co/palda-design-system/blob/
|
|
181
|
+
([`decisions/0034`](https://github.com/highpixel-co/palda-design-system/blob/abee826d0afda16bd3776f015931e0a820a8876e/docs/decisions/0034-the-body-list-lifts-one-step.md)). 곁·요약·참조 나열은 제목
|
|
182
182
|
`body2`(14) + 설명 `label`(12) 그대로이고, 선언서의 본문 문장이 가리키는 목록만 한 칸씩 올라가
|
|
183
183
|
제목 `body1`(16) + 설명 `body2`(14)가 된다. `ListRow`의 `emphasis`가 그것이고 기본값은 올리지
|
|
184
184
|
않는 쪽이다. **한 화면에 올린 목록은 하나다.**
|
|
@@ -227,11 +227,11 @@ DropdownButton은 `medium`이다.
|
|
|
227
227
|
|
|
228
228
|
컨트롤은 `sm`, 컨테이너는 `md`, 큰 면은 `lg`가 기준이다. **목록의 행은 컨트롤이 아니라 `md`다** —
|
|
229
229
|
행은 눌리는 컨트롤이 아니라 읽는 줄이고, 제목이 `line-height/100`이라 `sm`으로는 디바이더가 글자에
|
|
230
|
-
붙는다([`decisions/0019`](https://github.com/highpixel-co/palda-design-system/blob/
|
|
230
|
+
붙는다([`decisions/0019`](https://github.com/highpixel-co/palda-design-system/blob/abee826d0afda16bd3776f015931e0a820a8876e/docs/decisions/0019-list-row-vertical-inset.md)).
|
|
231
231
|
|
|
232
232
|
**페이지를 조립하는 여백과 폭은 이 스케일이 아니라 `--layout-*`이다.** 컴포넌트 안쪽을 재는
|
|
233
233
|
`--space-*`와 이름 공간이 갈라져 있다. 고르는 규칙은 [`LAYOUT.md`](LAYOUT.md), 근거는
|
|
234
|
-
[`decisions/0008`](https://github.com/highpixel-co/palda-design-system/blob/
|
|
234
|
+
[`decisions/0008`](https://github.com/highpixel-co/palda-design-system/blob/abee826d0afda16bd3776f015931e0a820a8876e/docs/decisions/0008-layout-scale-separate-from-component-scale.md)에 있다.
|
|
235
235
|
`gap/xl`이 layout이 아니라 여기 있는 것은 성격이 "요소 사이"로 같고 값도 이미 primitive에 있어서다.
|
|
236
236
|
|
|
237
237
|
primitive를 직접 쓰는 자리가 둘 있다. focus 링의 `outline-offset`은 `space/1`을 쓴다. 안쪽도
|
|
@@ -259,7 +259,7 @@ primitive를 직접 쓰는 자리가 둘 있다. focus 링의 `outline-offset`
|
|
|
259
259
|
|
|
260
260
|
`pnpm check:css`가 막는 것은 정의되지 않은 token 참조, `1px`이 아닌 보더·아웃라인 두께,
|
|
261
261
|
`:focus-visible` 대신 쓴 `:focus` 셋이다. 위 표의 선택 규칙 자체는 검사하지 않으므로 사람과
|
|
262
|
-
에이전트가 지킨다. 같은 실수가 반복되면 [`AGENTS.md`](https://github.com/highpixel-co/palda-design-system/blob/
|
|
262
|
+
에이전트가 지킨다. 같은 실수가 반복되면 [`AGENTS.md`](https://github.com/highpixel-co/palda-design-system/blob/abee826d0afda16bd3776f015931e0a820a8876e/AGENTS.md) §항상의 "반복되는 오류는
|
|
263
263
|
검사로 승격한다"에 따라 검사로 올린다.
|
|
264
264
|
|
|
265
265
|
시안에 없는 값은 임의로 정하지 않고 확인받는다. 위 표에서 아직 쓰는 곳이 없다고 적힌 항목은
|
package/dist/harness/check.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
-
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
3
|
import yaml from 'js-yaml';
|
|
4
4
|
import ts from 'typescript';
|
|
5
|
-
import { validateScreenHtml } from '../scripts/check-examples.mjs';
|
|
5
|
+
import { siblingIndex, validateScreenHtml } from '../scripts/check-examples.mjs';
|
|
6
6
|
|
|
7
7
|
const PACKAGE = '@highpixel-co/palda-design-system';
|
|
8
8
|
const EXCEPTION_RULES = ['primitive-import', 'icon-import', 'opaque-import'];
|
|
@@ -316,15 +316,21 @@ export function checkScreens(projectRoot, config) {
|
|
|
316
316
|
const root = resolve(projectRoot);
|
|
317
317
|
validateConfig(root, config);
|
|
318
318
|
const scan = collect(root, config.screens ?? ['preview'], (name) => name.endsWith('.html'));
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
319
|
+
// 자매 대조는 짝이 필요해서 전부 읽어 색인을 만든 뒤에 검사한다.
|
|
320
|
+
const screens = scan.files.map((fullPath) => ({
|
|
321
|
+
name: basename(fullPath, '.html'),
|
|
322
|
+
source: readFileSync(fullPath, 'utf8'),
|
|
323
|
+
file: normalize(relative(root, fullPath)),
|
|
324
|
+
}));
|
|
325
|
+
const siblings = siblingIndex(screens);
|
|
326
|
+
const problems = screens.flatMap(({ source, file }) =>
|
|
327
|
+
validateScreenHtml(source, file, true, siblings).map((message) => ({
|
|
322
328
|
file,
|
|
323
329
|
line: 1,
|
|
324
330
|
rule: 'screen',
|
|
325
331
|
symbol: '',
|
|
326
332
|
message,
|
|
327
|
-
}))
|
|
328
|
-
|
|
333
|
+
})),
|
|
334
|
+
);
|
|
329
335
|
return { files: scan.files.length, excluded: scan.excluded, exceptions: 0, drafts: [], problems };
|
|
330
336
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"packageVersion": "0.
|
|
4
|
-
"sourceRevision": "
|
|
3
|
+
"packageVersion": "0.8.0",
|
|
4
|
+
"sourceRevision": "abee826d0afda16bd3776f015931e0a820a8876e",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
7
7
|
"values": [
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
"SearchIcon",
|
|
74
74
|
"SendIcon",
|
|
75
75
|
"ShieldCheckIcon",
|
|
76
|
+
"Skeleton",
|
|
76
77
|
"SlidersIcon",
|
|
77
78
|
"SmartphoneIcon",
|
|
78
79
|
"SparklesIcon",
|
|
@@ -82,6 +83,7 @@
|
|
|
82
83
|
"Switch",
|
|
83
84
|
"Tab",
|
|
84
85
|
"TextField",
|
|
86
|
+
"Thumbnail",
|
|
85
87
|
"ThumbsUpIcon",
|
|
86
88
|
"TimerIcon",
|
|
87
89
|
"Toaster",
|
|
@@ -130,14 +132,21 @@
|
|
|
130
132
|
"ModalActionsProps",
|
|
131
133
|
"ModalProps",
|
|
132
134
|
"NavigationButtonProps",
|
|
135
|
+
"NavigationButtonSize",
|
|
133
136
|
"ProgressBarProps",
|
|
134
137
|
"RadioProps",
|
|
138
|
+
"SkeletonProps",
|
|
139
|
+
"SkeletonShape",
|
|
140
|
+
"SkeletonText",
|
|
141
|
+
"SkeletonWidth",
|
|
135
142
|
"SwitchProps",
|
|
136
143
|
"SwitchSize",
|
|
137
144
|
"TabProps",
|
|
138
145
|
"TextFieldProps",
|
|
139
146
|
"TextFieldSize",
|
|
140
147
|
"TextFieldVariant",
|
|
148
|
+
"ThumbnailProps",
|
|
149
|
+
"ThumbnailSize",
|
|
141
150
|
"ToastOptions",
|
|
142
151
|
"ToastVariant",
|
|
143
152
|
"ToasterPlacement",
|
|
@@ -161,9 +170,11 @@
|
|
|
161
170
|
"NavigationButton",
|
|
162
171
|
"ProgressBar",
|
|
163
172
|
"Radio",
|
|
173
|
+
"Skeleton",
|
|
164
174
|
"Switch",
|
|
165
175
|
"Tab",
|
|
166
176
|
"TextField",
|
|
177
|
+
"Thumbnail",
|
|
167
178
|
"Toaster",
|
|
168
179
|
"Tooltip",
|
|
169
180
|
"toast"
|
|
@@ -194,14 +205,21 @@
|
|
|
194
205
|
"ModalActionsProps",
|
|
195
206
|
"ModalProps",
|
|
196
207
|
"NavigationButtonProps",
|
|
208
|
+
"NavigationButtonSize",
|
|
197
209
|
"ProgressBarProps",
|
|
198
210
|
"RadioProps",
|
|
211
|
+
"SkeletonProps",
|
|
212
|
+
"SkeletonShape",
|
|
213
|
+
"SkeletonText",
|
|
214
|
+
"SkeletonWidth",
|
|
199
215
|
"SwitchProps",
|
|
200
216
|
"SwitchSize",
|
|
201
217
|
"TabProps",
|
|
202
218
|
"TextFieldProps",
|
|
203
219
|
"TextFieldSize",
|
|
204
220
|
"TextFieldVariant",
|
|
221
|
+
"ThumbnailProps",
|
|
222
|
+
"ThumbnailSize",
|
|
205
223
|
"ToastOptions",
|
|
206
224
|
"ToastVariant",
|
|
207
225
|
"ToasterPlacement",
|
|
@@ -589,6 +607,14 @@
|
|
|
589
607
|
"Radio"
|
|
590
608
|
]
|
|
591
609
|
},
|
|
610
|
+
{
|
|
611
|
+
"key": "skeleton",
|
|
612
|
+
"kind": "ui",
|
|
613
|
+
"status": "draft",
|
|
614
|
+
"exports": [
|
|
615
|
+
"Skeleton"
|
|
616
|
+
]
|
|
617
|
+
},
|
|
592
618
|
{
|
|
593
619
|
"key": "switch",
|
|
594
620
|
"kind": "ui",
|
|
@@ -613,6 +639,14 @@
|
|
|
613
639
|
"TextField"
|
|
614
640
|
]
|
|
615
641
|
},
|
|
642
|
+
{
|
|
643
|
+
"key": "thumbnail",
|
|
644
|
+
"kind": "ui",
|
|
645
|
+
"status": "draft",
|
|
646
|
+
"exports": [
|
|
647
|
+
"Thumbnail"
|
|
648
|
+
]
|
|
649
|
+
},
|
|
616
650
|
{
|
|
617
651
|
"key": "toast",
|
|
618
652
|
"kind": "ui",
|
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
2
|
export { AppShell, AppShellProps, BottomBar, BottomBarProps, Card, CardProps, CardTone, EmptyState, EmptyStateProps, FormSection, FormSectionProps, ListRow, ListRowEmphasis, ListRowProps, LivePreview, LivePreviewProps, LivePreviewStatus } from './patterns.js';
|
|
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';
|
|
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, NavigationButtonSize, ProgressBar, ProgressBarProps, Radio, RadioProps, Skeleton, SkeletonProps, SkeletonShape, SkeletonText, SkeletonWidth, Switch, SwitchProps, SwitchSize, Tab, TabProps, TextField, TextFieldProps, TextFieldSize, TextFieldVariant, Thumbnail, ThumbnailProps, ThumbnailSize, 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-
|
|
10
|
+
} from "./chunk-3OXUONDZ.js";
|
|
11
11
|
import "./chunk-EYFL232L.js";
|
|
12
12
|
import {
|
|
13
13
|
Alert,
|
|
@@ -23,13 +23,15 @@ import {
|
|
|
23
23
|
NavigationButton,
|
|
24
24
|
ProgressBar,
|
|
25
25
|
Radio,
|
|
26
|
+
Skeleton,
|
|
26
27
|
Switch,
|
|
27
28
|
Tab,
|
|
28
29
|
TextField,
|
|
30
|
+
Thumbnail,
|
|
29
31
|
Toaster,
|
|
30
32
|
Tooltip,
|
|
31
33
|
toast
|
|
32
|
-
} from "./chunk-
|
|
34
|
+
} from "./chunk-PWAQFOM3.js";
|
|
33
35
|
import {
|
|
34
36
|
AlarmIcon,
|
|
35
37
|
AlertIcon,
|
|
@@ -163,6 +165,7 @@ export {
|
|
|
163
165
|
SearchIcon,
|
|
164
166
|
SendIcon,
|
|
165
167
|
ShieldCheckIcon,
|
|
168
|
+
Skeleton,
|
|
166
169
|
SlidersIcon,
|
|
167
170
|
SmartphoneIcon,
|
|
168
171
|
SparklesIcon,
|
|
@@ -172,6 +175,7 @@ export {
|
|
|
172
175
|
Switch,
|
|
173
176
|
Tab,
|
|
174
177
|
TextField,
|
|
178
|
+
Thumbnail,
|
|
175
179
|
ThumbsUpIcon,
|
|
176
180
|
TimerIcon,
|
|
177
181
|
Toaster,
|
package/dist/patterns.d.ts
CHANGED
|
@@ -95,6 +95,7 @@ interface EmptyStateProps {
|
|
|
95
95
|
}
|
|
96
96
|
declare function EmptyState({ actionLabel, description, icon, onAction, title }: EmptyStateProps): react.JSX.Element;
|
|
97
97
|
|
|
98
|
+
type FormSectionTone = 'plain' | 'filled';
|
|
98
99
|
interface FormSectionProps {
|
|
99
100
|
children: ReactNode;
|
|
100
101
|
description?: ReactNode;
|
|
@@ -103,14 +104,24 @@ interface FormSectionProps {
|
|
|
103
104
|
/** 섹션 아래 액션. 저장·초기화 버튼을 넣는다. */
|
|
104
105
|
footer?: ReactNode;
|
|
105
106
|
title: ReactNode;
|
|
107
|
+
/**
|
|
108
|
+
* 섹션 내용의 채움. `Card`·`ListRow`의 `tone`과 같은 축이고 같은 값이다.
|
|
109
|
+
* - `plain`(기본) — 면이 없다. 제목과 여백만으로 갈린다.
|
|
110
|
+
* - `filled` — **내용(`children`)만** 옅은 회색 필에 담는다. 제목·설명·오류·footer는 캔버스에
|
|
111
|
+
* 남는다. 섹션이 여럿 쌓여 제목과 여백만으로 묶음이 안 갈릴 때 쓴다.
|
|
112
|
+
*/
|
|
113
|
+
tone?: FormSectionTone;
|
|
106
114
|
}
|
|
107
115
|
/**
|
|
108
116
|
* 입력 필드를 제목 아래로 묶는 폼의 한 덩어리다.
|
|
109
117
|
*
|
|
110
118
|
* 필드 사이 간격만 잡고 필드 자체는 그리지 않는다. 라벨과 컨트롤의 간격은 `TextField`가 갖고
|
|
111
119
|
* 있으므로 여기서 다시 주지 않는다.
|
|
120
|
+
*
|
|
121
|
+
* `tone="filled"`은 내용만 면에 담고 제목은 캔버스에 남긴다. 제목까지 담으면 곁(0032)과 같은
|
|
122
|
+
* 모양이 되어 곁이 본문에 흡수되기 때문이다. 갈래 셋의 비교는 `preview/section-fill.html`에 있다.
|
|
112
123
|
*/
|
|
113
|
-
declare function FormSection({ children, description, error, footer, title }: FormSectionProps): react.JSX.Element;
|
|
124
|
+
declare function FormSection({ children, description, error, footer, title, tone, }: FormSectionProps): react.JSX.Element;
|
|
114
125
|
|
|
115
126
|
type ListRowEmphasis = 'default' | 'strong';
|
|
116
127
|
interface ListRowProps {
|
package/dist/patterns.js
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
* (`docs/DESIGN_GRAMMAR.md` §자리는 시스템이 정하지 않는다).
|
|
32
32
|
*/
|
|
33
33
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
34
|
-
import { dirname, join, relative, resolve } from 'node:path';
|
|
34
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
35
35
|
import { fileURLToPath } from 'node:url';
|
|
36
36
|
|
|
37
37
|
const DEFAULT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -94,6 +94,46 @@ const FORM_SECTION_CLASS = 'palda-form-section';
|
|
|
94
94
|
const SECTION_FAILURE_CLASS = 'palda-form-section__error';
|
|
95
95
|
const ROW_FAILURE_CLASS = 'palda-badge--danger';
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* 본문을 여러 묶음으로 가르는 축 (`decisions/0023`·`0037`). 선언서가 밝히고 마크업이 따라온다.
|
|
99
|
+
*
|
|
100
|
+
* 같은 종류가 여러 갈래로 나뉘면 `탭`이고, 서로 다른 종류가 나열되면 `섹션`이다. 판정은 사람이
|
|
101
|
+
* 하지만 **고른 결과와 마크업이 어긋나는 것**은 셀 수 있다 — 갈래를 제목 여러 개로 세워 놓고
|
|
102
|
+
* 탭이라 적거나, 탭을 그려 놓고 섹션이라 적는 경우다.
|
|
103
|
+
*/
|
|
104
|
+
const GROUPING_LABEL = '갈래';
|
|
105
|
+
const GROUPING_VALUES = ['탭', '섹션', '탭+섹션', '없음'];
|
|
106
|
+
const TABLIST = /role\s*=\s*"tablist"/;
|
|
107
|
+
/** 섹션 하나를 여는 자리. `--plain`·`--filled`가 붙어도 한 번만 센다. */
|
|
108
|
+
const SECTION_OPEN = /class="palda-form-section[\s"]/g;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 섹션의 채움 (`decisions/0037`). 면은 갈라야 할 형제가 있을 때만 켜고, 면 위에 다른 면을
|
|
112
|
+
* 얹지 않는다. 요약 행(`ListRow tone="filled"`)은 컴포넌트가 스스로 한 단계 올리므로 여기서
|
|
113
|
+
* 세지 않는다 — 세는 것은 **면이 스스로 못 피하는 겹침**뿐이다.
|
|
114
|
+
*/
|
|
115
|
+
const SECTION_FILLED_CLASS = 'palda-form-section--filled';
|
|
116
|
+
const NESTED_SURFACE_CLASS = 'palda-card';
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 면을 켜야 하는데 안 켠 자리 (`decisions/0037`).
|
|
120
|
+
*
|
|
121
|
+
* 0037은 "제목과 여백만으로 안 갈릴 때"를 조건으로 두고, §적용에서 그 갈림을 못 센다고 남겨
|
|
122
|
+
* 뒀다. 몇 px 차이부터 안 갈리는지 수치가 없어서다. **다만 차이가 0인 경우는 셀 수 있다** —
|
|
123
|
+
* 섹션 제목과 그 안 행 제목이 같은 텍스트 스타일 클래스를 그대로 쓰고 있으면, 갈릴 여지가
|
|
124
|
+
* 애초에 없다.
|
|
125
|
+
*
|
|
126
|
+
* 0034를 적용해 본문 목록을 한 칸 올린 화면(`emphasis="strong"` → 제목 `body1`)이 전부 이
|
|
127
|
+
* 상태가 된다. 섹션 제목도 `body1`이라 두 글자가 문자열까지 같아진다. 2026-09-06 연동 도우미
|
|
128
|
+
* 시안이 그렇게 나왔고, 0037이 예로 든 내 계정 화면(16 대 14)보다 심한 판이라 승격했다.
|
|
129
|
+
*
|
|
130
|
+
* 2px 차이는 여전히 못 센다. 그것은 리뷰가 본다.
|
|
131
|
+
*/
|
|
132
|
+
const TYPOGRAPHY_CLASS = /\bpalda-text-[a-z0-9-]+\b/;
|
|
133
|
+
const SECTION_HEADING_CLASS = /palda-form-section__header[\s\S]*?<h2[^>]*\bclass="([^"]*)"/;
|
|
134
|
+
const SECTION_FIELDS_CLASS = 'palda-form-section__fields';
|
|
135
|
+
const SECTION_FOOTER_CLASS = 'palda-form-section__footer';
|
|
136
|
+
|
|
97
137
|
/** 닫는 태그가 없는 요소. 깊이를 올리지 않는다. */
|
|
98
138
|
const VOID_ELEMENTS = new Set([
|
|
99
139
|
'area',
|
|
@@ -144,18 +184,36 @@ function checkDeclaration(rows, where, problems) {
|
|
|
144
184
|
problems.push(`${where}: 주 액션이 ${primary.length}개다 — 자리는 하나다`);
|
|
145
185
|
} else if (!PRIMARY_ACTION_PLACES.has(primary[0].value)) {
|
|
146
186
|
problems.push(
|
|
147
|
-
`${where}: 주 액션 자리가 \`${primary[0].value}
|
|
187
|
+
`${where}: 주 액션 자리가 \`${primary[0].value}\`이다 — ${[...PRIMARY_ACTION_PLACES]
|
|
148
188
|
.map((place) => `\`${place}\``)
|
|
149
189
|
.join(' 또는 ')} 중 하나여야 한다`,
|
|
150
190
|
);
|
|
151
191
|
}
|
|
152
192
|
|
|
193
|
+
/** 본문을 무엇으로 가르는지. 적는 행위가 탭이냐 섹션이냐의 판단을 남긴다 (0023). */
|
|
194
|
+
const grouping = rows.filter((row) => row.label === GROUPING_LABEL);
|
|
195
|
+
if (grouping.length === 0) {
|
|
196
|
+
problems.push(
|
|
197
|
+
`${where}: 선언서에 \`${GROUPING_LABEL}\` 행이 없다 — ${GROUPING_VALUES.map((value) => `\`${value}\``).join('·')} 중 하나를 밝힌다`,
|
|
198
|
+
);
|
|
199
|
+
} else if (grouping.length > 1) {
|
|
200
|
+
problems.push(`${where}: ${GROUPING_LABEL}가 ${grouping.length}개다 — 축은 하나다`);
|
|
201
|
+
} else if (!GROUPING_VALUES.includes(grouping[0].value)) {
|
|
202
|
+
problems.push(
|
|
203
|
+
`${where}: ${GROUPING_LABEL}가 \`${grouping[0].value}\`이다 — ${GROUPING_VALUES.map((value) => `\`${value}\``).join('·')} 중 하나여야 한다`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
153
207
|
/** `곁` 행에 `없음`이라고 적으면 곁이 0개라는 뜻이다. */
|
|
154
208
|
const asides = rows
|
|
155
209
|
.filter((row) => row.label === '곁')
|
|
156
210
|
.filter((row) => row.value !== '' && row.value !== '없음');
|
|
157
211
|
|
|
158
|
-
return {
|
|
212
|
+
return {
|
|
213
|
+
declared: body.length > 0,
|
|
214
|
+
asides: asides.length,
|
|
215
|
+
grouping: grouping.length === 1 ? grouping[0].value : null,
|
|
216
|
+
};
|
|
159
217
|
}
|
|
160
218
|
|
|
161
219
|
/** 주석을 같은 길이의 공백으로 지운다. 주석 안의 예시 마크업이 태그로 세지지 않게 한다. */
|
|
@@ -270,7 +328,7 @@ function checkBlocks(blocks, declaredAsides, where, problems) {
|
|
|
270
328
|
}
|
|
271
329
|
if (!ROLES.includes(role)) {
|
|
272
330
|
problems.push(
|
|
273
|
-
`${where}: 바디 ${index + 1}번째 블록의 data-screen-role이 \`${role}
|
|
331
|
+
`${where}: 바디 ${index + 1}번째 블록의 data-screen-role이 \`${role}\`이다 — ${ROLES.map((name) => `\`${name}\``).join('·')} 중 하나여야 한다`,
|
|
274
332
|
);
|
|
275
333
|
roles.push(null);
|
|
276
334
|
continue;
|
|
@@ -348,7 +406,169 @@ function htmlFiles(dir, out = []) {
|
|
|
348
406
|
return out;
|
|
349
407
|
}
|
|
350
408
|
|
|
351
|
-
|
|
409
|
+
/**
|
|
410
|
+
* 화면의 섹션 이름. `FormSection`의 헤더에 있는 `h2`가 그것이다 — 화면이 무엇으로 나뉘는지를
|
|
411
|
+
* 사람이 읽는 자리와 같다. 주석 안의 예시 마크업은 세지 않으므로 주석을 먼저 지운다.
|
|
412
|
+
*/
|
|
413
|
+
const SECTION_HEADING = /palda-form-section__header[\s\S]*?<h2[^>]*>([^<]*)<\/h2>/g;
|
|
414
|
+
|
|
415
|
+
/** 뒤에 붙는 개수 표기는 값이라 이름이 아니다 — `상품 템플릿 3/10`과 `상품 템플릿`은 같다. */
|
|
416
|
+
const stripCount = (name) => name.replace(/\s*\d+\s*\/\s*\d+\s*$/, '').trim();
|
|
417
|
+
|
|
418
|
+
function screenSections(source) {
|
|
419
|
+
return [...stripComments(source).matchAll(SECTION_HEADING)]
|
|
420
|
+
.map((match) => stripCount(match[1]))
|
|
421
|
+
.filter(Boolean);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* 선언 주석에 등록한 자매와의 차이. `1. [섹션]` 또는 `1. [이쪽] ↔ [저쪽]` 형태다.
|
|
426
|
+
* 적는 행위가 판단을 남긴다 — 왜 갈라졌는지가 그 자리에 남아 다음에 다시 판단하지 않는다.
|
|
427
|
+
*/
|
|
428
|
+
const REGISTERED_DIFF = /^\s*\d+\.\s*\[([^\]]+)\](?:\s*↔\s*\[([^\]]+)\])?/gm;
|
|
429
|
+
|
|
430
|
+
function registeredDiffs(source) {
|
|
431
|
+
return new Set(
|
|
432
|
+
[...source.matchAll(REGISTERED_DIFF)].flatMap((match) =>
|
|
433
|
+
[match[1], match[2]].filter(Boolean).map(stripCount),
|
|
434
|
+
),
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const siblingOf = (rows) => rows.find((row) => row.label === '자매')?.value || null;
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* 자매로 선언한 두 화면의 섹션 집합을 비교한다 (`decisions/0036`).
|
|
442
|
+
* 차이를 금지하지 않고 **적지 않은 차이**를 막는다. 문자열 집합 비교라 판단이 들어가지 않는다.
|
|
443
|
+
*/
|
|
444
|
+
function checkSiblings(source, where, rows, siblings, problems) {
|
|
445
|
+
const name = siblingOf(rows);
|
|
446
|
+
if (!name) return;
|
|
447
|
+
|
|
448
|
+
const mate = siblings.get(name);
|
|
449
|
+
if (mate === undefined) {
|
|
450
|
+
problems.push(`${where}: 자매로 적은 \`${name}\`을 찾을 수 없다 — 같은 시안 디렉터리에 둔다`);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// 짝이 맞는지는 상대의 `자매` 행이 나를 가리키는지다. 한쪽만 적으면 반대편이 모른다.
|
|
455
|
+
const self = basename(where, '.html');
|
|
456
|
+
const back = siblingOf(declarationRows(mate));
|
|
457
|
+
if (back !== self) {
|
|
458
|
+
problems.push(
|
|
459
|
+
`${where}: 자매 선언이 짝이 안 맞는다 — \`${name}\`의 자매는 \`${back ?? '없음'}\`이다. 양쪽 다 적는다`,
|
|
460
|
+
);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const theirs = new Set(screenSections(mate));
|
|
465
|
+
const registered = registeredDiffs(source);
|
|
466
|
+
for (const section of screenSections(source)) {
|
|
467
|
+
if (theirs.has(section) || registered.has(section)) continue;
|
|
468
|
+
problems.push(
|
|
469
|
+
`${where}: 섹션 [${section}]이 자매(${name})에 없는데 선언에 등록되지 않았다 — 차이는 \`1. [${section}]\` 형태로 적는다`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* 선언한 갈래와 마크업이 맞는지 (0023). 탭 줄은 `role="tablist"`로, 섹션 나열은 형제
|
|
476
|
+
* `FormSection` 둘 이상으로 읽는다. 섹션 하나는 나누는 축이 아니라 그냥 본문이다.
|
|
477
|
+
*/
|
|
478
|
+
function checkGrouping(blocks, declared, where, problems) {
|
|
479
|
+
if (!declared) return;
|
|
480
|
+
|
|
481
|
+
const html = blocks.map((block) => block.html).join('\n');
|
|
482
|
+
const drawnTabs = TABLIST.test(html);
|
|
483
|
+
const drawnSections = (html.match(SECTION_OPEN) ?? []).length >= 2;
|
|
484
|
+
const expectTabs = declared.includes('탭');
|
|
485
|
+
const expectSections = declared.includes('섹션');
|
|
486
|
+
|
|
487
|
+
if (expectTabs && !drawnTabs) {
|
|
488
|
+
problems.push(
|
|
489
|
+
`${where}: ${GROUPING_LABEL}를 \`${declared}\`로 적었는데 본문에 \`role="tablist"\`가 없다 — 탭 줄은 쓰는 쪽이 만든다 (0023)`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
if (!expectTabs && drawnTabs) {
|
|
493
|
+
problems.push(
|
|
494
|
+
`${where}: 본문이 탭으로 갈리는데 ${GROUPING_LABEL}가 \`${declared}\`이다 — 같은 종류의 갈래를 탭이 가른다면 그렇게 적는다`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
if (expectSections && !drawnSections) {
|
|
498
|
+
problems.push(
|
|
499
|
+
`${where}: ${GROUPING_LABEL}를 \`${declared}\`로 적었는데 형제 섹션이 둘 미만이다 — 섹션 하나는 나누는 축이 아니다`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
if (!expectSections && drawnSections) {
|
|
503
|
+
problems.push(
|
|
504
|
+
`${where}: 본문이 섹션 여럿으로 나뉘는데 ${GROUPING_LABEL}가 \`${declared}\`이다 — 같은 종류의 갈래라면 제목을 여러 개 세우는 대신 탭으로 가른다 (0023)`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* 섹션의 내용 자리. `__fields`부터 `__footer` 앞까지다 — 면이 붙는 자리가 거기 하나이고,
|
|
511
|
+
* 제목·오류·footer는 면 밖에 남는다 (0037).
|
|
512
|
+
*/
|
|
513
|
+
function sectionFields(section) {
|
|
514
|
+
const start = section.indexOf(SECTION_FIELDS_CLASS);
|
|
515
|
+
if (start === -1) return '';
|
|
516
|
+
const footer = section.indexOf(SECTION_FOOTER_CLASS, start);
|
|
517
|
+
return section.slice(start, footer === -1 ? undefined : footer);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** 섹션의 채움이 제 조건에서 켜졌는지 (0037). */
|
|
521
|
+
function checkSectionFill(blocks, where, problems) {
|
|
522
|
+
const html = blocks.map((block) => block.html).join('\n');
|
|
523
|
+
const sections = formSections(html);
|
|
524
|
+
const filled = sections.filter((section) =>
|
|
525
|
+
new RegExp(`\\b${SECTION_FILLED_CLASS}\\b`).test(section.slice(0, section.indexOf('>') + 1)),
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
// 켜야 하는데 안 켠 자리. 형제가 있고, 제목과 내용의 텍스트 스타일이 문자열까지 같으면
|
|
529
|
+
// 여백 말고 갈릴 것이 없다. 형제가 하나뿐이면 애초에 켜지 못하므로(아래) 세지 않는다.
|
|
530
|
+
if (sections.length >= 2) {
|
|
531
|
+
for (const section of sections) {
|
|
532
|
+
const head = section.slice(0, section.indexOf('>') + 1);
|
|
533
|
+
if (new RegExp(`\\b${SECTION_FILLED_CLASS}\\b`).test(head)) continue;
|
|
534
|
+
|
|
535
|
+
const heading = SECTION_HEADING_CLASS.exec(section)?.[1] ?? '';
|
|
536
|
+
const style = TYPOGRAPHY_CLASS.exec(heading)?.[0];
|
|
537
|
+
if (!style) continue;
|
|
538
|
+
|
|
539
|
+
const fields = sectionFields(section);
|
|
540
|
+
// 내용이 이미 덩어리면 면이 할 일이 없다. 카드 그리드가 그 자리다 (0037) — 아래에서
|
|
541
|
+
// 면을 켜지 못하게 막는 것과 같은 표시를 쓴다. 결제의 `요금제`·`구매 수량`이 그렇다.
|
|
542
|
+
if (new RegExp(`\\b${NESTED_SURFACE_CLASS}\\b`).test(fields)) continue;
|
|
543
|
+
|
|
544
|
+
if (new RegExp(`\\b${style}\\b`).test(fields)) {
|
|
545
|
+
problems.push(
|
|
546
|
+
`${where}: 섹션 제목과 그 내용이 같은 \`${style}\`인데 면이 없다 — 제목과 여백만으로 갈리지 않는다 (0037)`,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (filled.length === 0) return;
|
|
553
|
+
|
|
554
|
+
// 면은 형제를 가르는 장치다. 혼자면 가를 것이 없고 곁만 본문에 흡수된다.
|
|
555
|
+
if (sections.length < 2) {
|
|
556
|
+
problems.push(
|
|
557
|
+
`${where}: 섹션이 하나인데 \`${SECTION_FILLED_CLASS}\`이다 — 면은 갈라야 할 형제가 있을 때 켠다 (0037)`,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// 내용이 이미 덩어리면 면이 한 겹 겹친다. 카드 그리드가 그 자리다.
|
|
562
|
+
for (const section of filled) {
|
|
563
|
+
if (new RegExp(`\\b${NESTED_SURFACE_CLASS}\\b`).test(section)) {
|
|
564
|
+
problems.push(
|
|
565
|
+
`${where}: \`${SECTION_FILLED_CLASS}\` 안에 \`${NESTED_SURFACE_CLASS}\`가 있다 — 내용이 이미 덩어리면 면을 주지 않는다 (0037)`,
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function validateScreenHtml(source, where, requireScreen = false, siblings = null) {
|
|
352
572
|
const problems = [];
|
|
353
573
|
const blocks = bodyBlocks(source);
|
|
354
574
|
if (blocks === null) {
|
|
@@ -360,11 +580,25 @@ export function validateScreenHtml(source, where, requireScreen = false) {
|
|
|
360
580
|
problems.push(`${where}: 화면 선언 표가 없다 — 파일 맨 위 주석에 둔다`);
|
|
361
581
|
return problems;
|
|
362
582
|
}
|
|
363
|
-
const { asides } = checkDeclaration(rows, where, problems);
|
|
583
|
+
const { asides, grouping } = checkDeclaration(rows, where, problems);
|
|
364
584
|
checkBlocks(blocks, asides, where, problems);
|
|
585
|
+
checkGrouping(blocks, grouping, where, problems);
|
|
586
|
+
checkSectionFill(blocks, where, problems);
|
|
587
|
+
// 자매는 짝이 있어야 셀 수 있다. 파일 하나만 주면 이 검사는 건너뛴다.
|
|
588
|
+
if (siblings) checkSiblings(source, where, rows, siblings, problems);
|
|
365
589
|
return problems;
|
|
366
590
|
}
|
|
367
591
|
|
|
592
|
+
/**
|
|
593
|
+
* 자매를 찾는 색인. 키는 확장자를 뺀 파일 이름이고, 선언서가 그 이름으로 상대를 가리킨다.
|
|
594
|
+
* 이름이 겹치면 먼저 읽은 쪽이 남는다 — 시안 이름은 화면 하나를 가리키므로 겹치지 않는다.
|
|
595
|
+
*/
|
|
596
|
+
export function siblingIndex(entries) {
|
|
597
|
+
const index = new Map();
|
|
598
|
+
for (const { name, source } of entries) if (!index.has(name)) index.set(name, source);
|
|
599
|
+
return index;
|
|
600
|
+
}
|
|
601
|
+
|
|
368
602
|
export function validateExamples(root = DEFAULT_ROOT) {
|
|
369
603
|
const problems = [];
|
|
370
604
|
|
|
@@ -378,18 +612,27 @@ export function validateExamples(root = DEFAULT_ROOT) {
|
|
|
378
612
|
}
|
|
379
613
|
}
|
|
380
614
|
|
|
381
|
-
// 2. 시안 HTML — 선언서 + 바디 블록의
|
|
615
|
+
// 2. 시안 HTML — 선언서 + 바디 블록의 소속 + 자매의 섹션 집합.
|
|
616
|
+
// 자매는 짝이 필요해서 전부 읽어 색인을 만든 뒤에 검사한다.
|
|
617
|
+
const screens = [];
|
|
382
618
|
for (const dir of SCREEN_DIRS) {
|
|
383
619
|
const full = join(root, dir);
|
|
384
620
|
if (!existsSync(full)) continue;
|
|
385
621
|
|
|
386
622
|
for (const file of htmlFiles(full)) {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
623
|
+
screens.push({
|
|
624
|
+
name: basename(file, '.html'),
|
|
625
|
+
source: readFileSync(file, 'utf8'),
|
|
626
|
+
where: relative(root, file),
|
|
627
|
+
});
|
|
390
628
|
}
|
|
391
629
|
}
|
|
392
630
|
|
|
631
|
+
const siblings = siblingIndex(screens);
|
|
632
|
+
for (const screen of screens) {
|
|
633
|
+
problems.push(...validateScreenHtml(screen.source, screen.where, false, siblings));
|
|
634
|
+
}
|
|
635
|
+
|
|
393
636
|
return problems;
|
|
394
637
|
}
|
|
395
638
|
|