@godxjp/ui 32.5.0 → 32.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.
@@ -1,9 +1,10 @@
1
- # @godxjp/ui — the ten consumer rules
1
+ # @godxjp/ui — the consumer rules
2
2
 
3
- Read this once; the audit enforces it. Everything else in `docs/` is for contributors.
3
+ Read this once; the audit enforces it. What the audit cannot see (how a screen is composed, what it
4
+ should look like) is in the package's `DESIGN.md`. Everything else in `docs/` is for contributors.
4
5
 
5
6
  1. Load styles with `import "@godxjp/ui/styles.css"` (precompiled, no Tailwind needed) or `@import "@godxjp/ui/styles"` (Tailwind v4). Neither ships font files: fonts are opt-in since v32 (`pnpm add @fontsource/noto-sans-jp @fontsource/m-plus-2`, then `@import "@godxjp/ui/styles/fonts.css"` after the styles import; the GoDX preset does this for you). Never cherry-pick `*-layout.css`.
6
- 2. **Mount `<AppProvider defaultLocale="…">` once, at the root.** Without it the kit's OWN strings (dialog close/cancel/delete, counters, picker labels) fall to the resting locale `vi` and pickers to UTC — measured in a Japanese app as Vietnamese buttons, silently (gh#1005). A development build now warns once when a kit component renders outside it; `syncI18nLocale(locale, fallback)` is the escape for an app that truly cannot mount the provider. Every page is `<PageContainer title subtitle extra footer>`; its sections are spaced by the page. Group items inside a section with `<Flex direction="col" gap>` or `<ResponsiveGrid>`.
7
+ 2. **Mount `<AppProvider defaultLocale="…">` once, at the root.** Without it the kit's OWN strings (dialog close/cancel/delete, counters, picker labels) fall to the resting locale `vi` and pickers to UTC — measured in a Japanese app as Vietnamese buttons, silently (gh#1005). A development build now warns once when a kit component renders outside it; `syncI18nLocale(locale, fallback)` is the escape for an app that truly cannot mount the provider. Every page is `<PageContainer title extra footer>` (a `subtitle` only when it adds a fact the title does not); its sections are spaced by the page. Group items inside a section with `<Flex direction="col" gap>` or `<ResponsiveGrid>`.
7
8
  3. No Tailwind layout on your own elements: no `flex`, `grid`, `gap-*`, `p-*`, `m-*`, `space-*`. Rows are `<Flex>` (default row), stacks are `<Flex direction="col">`, grids are `<ResponsiveGrid>`.
8
9
  4. No hand-rolled surfaces: no `rounded-* border bg-*` divs. A box is `Card`, a pill is `Badge`, a person is `Avatar`, a row is `ListRow`, a label/value pair is `Descriptions`, an empty area is `EmptyState`, a read-only sample of a colour the USER chose is `Swatch`. A LIST of those rows is `<Flex as="ul" marker="none" direction="col" gap="none">` with `<ListRow as="li">` children — `marker="none"` keeps the `<ul>`, the `<li>` semantics and the gap token while dropping the bullet and the indent. Never a raw `<ul>`/`<ol>` (it carries no gap token), never `<div role="list">` + `<div role="listitem">`, and never a wrapper around each row: the divider is `:not(:last-child)` among siblings, so a row alone in its own wrapper is always the last one and EVERY divider disappears silently. A bulleted prose list is the same `<Flex as="ul">` without `marker`.
9
10
  5. Real controls only: `Button`, `Input`, `Select`, `Textarea`, `Checkbox`… never raw `<button>`/`<input>`; a labelled control lives in `<FormField label>`. A Select outside a form takes `width="auto"`. **A disabled control's reason is visible text, never a tooltip** — see below. **Fields live in a `<Form layout="horizontal" labelWidth controlWidth>`** — never a group of `FormField`s without one, never a hand-rolled `<Flex>` row of fields (a row that belongs together is `<SpaceCompact>` or `<Form columns>`); size each control for its content with `controlWidth` (GOV.UK text-input width: a port ~7rem, a short enum ~10rem); three or more fields is a page, not a Dialog. The audit rules are `formfield-needs-form`, `dialog-form-too-big` and `select-width-hint`. **A case the kit cannot express is an issue, not a hand-roll:** open it on godx-jp/godxjp-ui, use the nearest valid composition, and mark it `// TODO(godxjp-ui#<n>)` (gh#998). **One action row, one Button size:** every `Button` under one parent takes the same `size` — a missing size is `default`, and `icon-sm` pairs with `sm`, `icon-xs` with `xs`, `icon` with default. Never `size="sm"` beside a default Button. The audit rules are `mixed-button-size` (static) and `mixed-button-height` (visual-audit, within 0.5px) (gh#1084).
package/docs/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  | Doc | Purpose |
4
4
  | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
5
- | [CONSUMER-RULES.md](./CONSUMER-RULES.md) | **Start here if you build an app with @godxjp/ui** — the ten rules the audit enforces (styles entry, layout owners, real primitives, tokens) |
5
+ | [CONSUMER-RULES.md](./CONSUMER-RULES.md) | **Start here if you build an app with @godxjp/ui** — the rules the audit enforces (styles entry, layout owners, real primitives, tokens) |
6
6
  | [DEVELOPMENT.md](./DEVELOPMENT.md) | **Contributor guideline** — role/boundary, architecture, how to add/extend, verify |
7
7
  | [COMPONENTS.md](./COMPONENTS.md) | Component catalog by taxonomy group |
8
8
  | [PROPS-VOCABULARY.md](./PROPS-VOCABULARY.md) | Atomic prop types (`*Prop` suffix) |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "32.5.0",
4
- "godxUiMcp": "32.5.0",
3
+ "version": "32.6.0",
4
+ "godxUiMcp": "32.6.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -28,6 +28,7 @@
28
28
  "description": "React UI framework built on design tokens: accessible components, a controlled prop vocabulary, and a machine-readable catalog for coding agents.",
29
29
  "files": [
30
30
  "LICENSE",
31
+ "DESIGN.md",
31
32
  "agent",
32
33
  "dist",
33
34
  "scripts/ui-audit.mjs",
@@ -167,87 +167,60 @@ function resyncVersionStamp(text) {
167
167
  * appended repo-specific sections keeps them.
168
168
  */
169
169
  export const WORKFLOW_MD = `${STAMP(KIT_VERSION)}
170
- # @godxjp/ui — mandatory workflow (read every session)
171
-
172
- You are building UI in an app that uses @godxjp/ui. Follow this EVERY time you create
173
- or change a component, page, or form — no exceptions.
174
-
175
- ## The rules the audit enforces (docs/CONSUMER-RULES.md in node_modules/@godxjp/ui)
176
- - Styles: \`@import "@godxjp/ui/styles"\` or \`@godxjp/ui/styles/core\` (no fonts). Never cherry-pick layers.
177
- - Layout: sections of a page are spaced by \`PageContainer\`; siblings go in \`<Flex direction="col" gap>\` /
170
+ # @godxjp/ui — workflow (read every session)
171
+
172
+ Follow this every time you create or change a page, component or form.
173
+
174
+ ## Before JSX
175
+ 1. **Design target.** Read \`node_modules/@godxjp/ui/DESIGN.md\` (MCP \`get_design\`): tokens,
176
+ scarcity rules, screen anatomy, do/don't.
177
+ 2. **Route.** \`route_consumer_task "<task>"\` → \`compose-a-screen\` for app screens. The
178
+ marketing skills (soft, minimalist, brutalist, gpt-tasteskill, imagegen-*) are for landing
179
+ pages only.
180
+ 3. **Exemplar.** \`get_exemplar\` with archetype list | detail | form | settings | dashboard |
181
+ approval. Match its structure; it is the visual target.
182
+ 4. **Discover, then look up.** \`search_components "<area>"\` first, \`get_component <Name>\`
183
+ second — \`get_component\` cannot find a name you do not already have (a consumer hand-rolled
184
+ the shipped \`OrgSwitcher\` and \`TopbarItem\` this way). Code already in this repo is not the
185
+ authority: search even when a neighbouring file seems to answer. Never guess a prop.
186
+
187
+ ## Contract (ui-audit enforces it; full text in node_modules/@godxjp/ui/docs/CONSUMER-RULES.md)
188
+ - Styles: \`@import "@godxjp/ui/styles"\` (or \`import "@godxjp/ui/styles.css"\`); fonts are opt-in
189
+ via \`@godxjp/ui/styles/fonts.css\`. Never cherry-pick layers.
190
+ - Layout: \`PageContainer\` spaces sections; siblings go in \`<Flex direction="col" gap>\` /
178
191
  \`<ResponsiveGrid>\`. No \`flex\` / \`grid\` / \`gap-*\` / \`p-*\` / \`m-*\` on your own elements.
179
- - Surfaces: \`Card\` / \`Badge\` / \`Avatar\` / \`ListRow\` / \`Descriptions\` / \`EmptyState\` — never a
180
- \`rounded border\` div. Text is \`<Text tone size weight truncate>\`, never utility classes.
181
-
182
- ## Before writing UI
183
- 1. **DISCOVERY BEFORE LOOKUP — in this order, every time.**
184
- 1. \`search_components "<the area you are about to build>"\` FIRST. This is the only call that
185
- answers "what already exists here". Ask it before the first line of JSX, not after.
186
- 2. \`get_component <Name>\` second, once you know the name.
187
- \`get_component\` answers "how do I use X" and is USELESS for finding X — you can only look up a
188
- name you already have. Skipping step 1 is how a consumer hand-rolled an organization switcher
189
- the package ships (\`OrgSwitcher\`) and built three topbar cells out of Buttons instead of
190
- \`TopbarItem\`: both were one \`search_components\` call away, and neither was ever searched for.
191
- **Existing code in this repo is NOT the authority.** Reading a neighbouring file and copying it
192
- is the most common way this rule gets skipped — measured: of four fresh agents given a vague UI
193
- task, all four answered correctly but two never called the MCP at all, saying the answer was
194
- already in the code. They were only right because that code had just been fixed; a week earlier
195
- the same files hand-rolled a switcher and put Buttons in topbar slots, and both agents would
196
- have copied it. Search the catalog even when a local file looks like it already answers you.
197
- Also \`list_audit_rules\`, \`list_visual_checks\`. Never guess a prop; never hand-roll what a
198
- primitive already does.
199
- 2. **Real primitives only.** No raw \`<input>/<select>/<button>/<textarea>/<table>\`, no
200
- styled-div fakes. A labelled control ALWAYS goes in \`<FormField label=…>\` (it owns the
201
- label↔control wiring, error/aria, AND the field rhythm). Never pair a bare \`<Label>\`
202
- with an \`<Input>\`.
203
- 3. **Tokens, not literals.** Semantic colour tokens (bg-primary, text-muted-foreground),
204
- token spacing/size/radius/type — no raw palette (bg-blue-600), no hex, no arbitrary
205
- \`[13px]\`. No emoji in product UI. Logical CSS (ms-/me-/ps-/pe-) for RTL.
206
-
207
- ## After writing UI
208
- 4. **Audit (runs automatically too).** \`node node_modules/@godxjp/ui/scripts/ui-audit.mjs\`
209
- — fix every finding. A PostToolUse hook also runs it on each edit and feeds findings back.
210
- 5. **Before a visual review**, run the runtime audit against the running app:
211
- \`node node_modules/@godxjp/ui/scripts/visual-audit.mjs <url>\` (axe + contrast + layout).
192
+ - Surfaces: \`Card\` / \`Badge\` / \`Avatar\` / \`ListRow\` / \`Descriptions\` / \`EmptyState\`, never a
193
+ \`rounded border\` div. Text is \`<Text tone size weight>\`.
194
+ - Real controls only; a labelled control sits in \`<FormField label>\` inside a \`<Form>\`.
195
+ - Semantic tokens only: no hex, palette names, arbitrary \`[13px]\`, emoji. Logical CSS (ms-/me-).
196
+ - Screen composition (what the audit cannot see) is in \`.ai/rules/godxjp-ui.md\`.
197
+
198
+ ## After JSX
199
+ 5. \`node node_modules/@godxjp/ui/scripts/ui-audit.mjs <files>\` — zero errors (a PostToolUse hook
200
+ also runs it on each edit).
201
+ 6. Screenshot at 390 / 768 / 1280 next to the exemplar, list the top 3 differences, fix them,
202
+ repeat — at most 3 rounds.
203
+ 7. Before a visual review: \`node node_modules/@godxjp/ui/scripts/visual-audit.mjs <url>\`
204
+ (axe + contrast + layout).
212
205
  `;
213
206
 
214
207
  /** Delimited block appended to the consumer's CLAUDE.md — loaded into the agent's context
215
- * every turn (the most reliable "ensure it reads the rules"). Markers keep it idempotent. */
216
- export const CLAUDE_MD_BLOCK = `<!-- godxjp-ui:start (managed by @godxjp/ui — edit .claude/godxjp-ui-workflow.md instead) -->
208
+ * every turn, so it carries only the default path and points at the workflow for the rest.
209
+ * Markers keep it idempotent. */
210
+ export const CLAUDE_MD_BLOCK = `<!-- godxjp-ui:start (managed by @godxjp/ui — rewritten by npx godxjp-ui sync-rules) -->
217
211
  ${STAMP(KIT_VERSION)}
218
- ## @godxjp/ui — mandatory UI workflow (do NOT skip)
219
-
220
- This app uses @godxjp/ui. EVERY time you build or change UI:
221
-
222
- 0. **Ten rules** — read \`node_modules/@godxjp/ui/docs/CONSUMER-RULES.md\` once. Styles via
223
- \`@godxjp/ui/styles\` or \`styles/core\` (never cherry-pick layers). Page sections are spaced by
224
- \`PageContainer\`; siblings go in \`<Flex direction="col" gap>\` / \`<ResponsiveGrid>\`; no
225
- \`flex\`/\`grid\`/\`gap-*\`/\`p-*\`/\`m-*\` on your own elements; boxes are \`Card\`/\`Badge\`/
226
- \`ListRow\`/\`Descriptions\`, never a \`rounded border\` div; text is \`<Text tone size weight>\`.
227
- 1. **DISCOVERY BEFORE LOOKUP — in this order, every time.**
228
- 1. \`search_components "<the area you are about to build>"\` FIRST. This is the only call that
229
- answers "what already exists here". Ask it before the first line of JSX, not after.
230
- 2. \`get_component <Name>\` second, once you know the name.
231
- \`get_component\` answers "how do I use X" and is USELESS for finding X — you can only look up a
232
- name you already have. Skipping step 1 is how a consumer hand-rolled an organization switcher
233
- the package ships (\`OrgSwitcher\`) and built three topbar cells out of Buttons instead of
234
- \`TopbarItem\`: both were one \`search_components\` call away, and neither was ever searched for.
235
- **Existing code in this repo is NOT the authority.** Reading a neighbouring file and copying it
236
- is the most common way this rule gets skipped — measured: of four fresh agents given a vague UI
237
- task, all four answered correctly but two never called the MCP at all, saying the answer was
238
- already in the code. They were only right because that code had just been fixed; a week earlier
239
- the same files hand-rolled a switcher and put Buttons in topbar slots, and both agents would
240
- have copied it. Search the catalog even when a local file looks like it already answers you.
241
- Also \`list_audit_rules\`, \`list_visual_checks\`. Never guess a prop; never hand-roll what a
242
- primitive already does.
243
- 2. **Real primitives only.** No raw \`<input>/<select>/<button>/<textarea>/<table>\`. A labelled
244
- control ALWAYS goes in \`<FormField label=…>\` — never a bare \`<Label>\`+\`<Input>\`. Semantic
245
- tokens, not raw palette/hex/arbitrary values. No emoji in product UI. Logical CSS for RTL.
246
- 3. **Scan after writing.** \`node node_modules/@godxjp/ui/scripts/ui-audit.mjs <files>\` and fix
247
- every finding (a PostToolUse hook runs this automatically and feeds findings back). Before a
248
- visual review: \`node node_modules/@godxjp/ui/scripts/visual-audit.mjs <url>\` (axe + layout).
249
-
250
- Full guide: \`.claude/godxjp-ui-workflow.md\`.
212
+ ## @godxjp/ui — UI workflow
213
+
214
+ Before any JSX for a screen:
215
+ 1. Read \`node_modules/@godxjp/ui/DESIGN.md\` (MCP \`get_design\`).
216
+ 2. \`route_consumer_task\` → \`compose-a-screen\`; \`search_components\` before hand-rolling anything.
217
+ 3. \`get_exemplar\` (list | detail | form | settings | dashboard | approval) — match its structure.
218
+
219
+ After building:
220
+ 4. \`node node_modules/@godxjp/ui/scripts/ui-audit.mjs <files>\` — zero errors.
221
+ 5. Screenshot at 390 / 768 / 1280 beside the exemplar; fix the top 3 differences; max 3 rounds.
222
+
223
+ Rules: \`node_modules/@godxjp/ui/docs/CONSUMER-RULES.md\`. Details: \`.claude/godxjp-ui-workflow.md\`.
251
224
  <!-- godxjp-ui:end -->
252
225
  `;
253
226
 
@@ -1,39 +1,33 @@
1
1
  # @godxjp/ui
2
2
 
3
- > **Tệp này do gói `@godxjp/ui` sở hữu.** Cài gói không tự ghi gì cả: nội dung chỉ được ghi lại
4
- > khi bạn chạy `npx godxjp-ui sync-rules` (thêm `--dry-run` để xem trước). Chạy lại sau mỗi lần
5
- > nâng cấp. Dấu `<!-- godxjp-ui:version -->` luôn được đồng bộ với phiên bản
6
- > gói đang cài — kể cả khi thân tệp không đổi giữa hai bản.
7
- > Đừng sửa ở đây — luật của riêng kho thuộc về một tệp khác trong `.ai/rules/`,
8
- > và index sẽ nạp cả hai. (Khác với `.claude/skills/.../SKILL.md`, nơi mục §8
9
- > trở đi là của kho và được giữ lại.)
10
-
11
- Đây là **danh sách kiểm** bắn mỗi lần chạm một tệp UI. Lý do đầy đủ nằm ở
12
- `docs/CONSUMER-RULES.md` (10 luật) và, với kho chuột bạch, ở
13
- `.claude/skills/godx-ui-guinea-pig/SKILL.md`.
14
-
15
- ## Loading styles: one entry, fonts are opt-in
16
-
17
- ```ts
18
- import "@godxjp/ui/styles.css"; // precompiled, no Tailwind needed
19
- ```
3
+ > **Tệp do gói `@godxjp/ui` sở hữu**, ghi lại mỗi lần chạy `npx godxjp-ui sync-rules`. Đừng sửa ở
4
+ > đây — luật riêng của kho thuộc về một tệp khác trong `.ai/rules/`.
5
+
6
+ Danh sách kiểm bắn mỗi lần chạm một tệp UI. Luật đầy đủ: `docs/CONSUMER-RULES.md`.
7
+
8
+ ## Styles và theme
20
9
 
21
10
  ```css
22
- @import "@godxjp/ui/styles"; /* Tailwind v4: every component layer, NO font files */
23
- @import "@godxjp/ui/styles/fonts.css"; /* optional, after the styles import: the bundled faces */
11
+ @import "@godxjp/ui/styles"; /* or: import "@godxjp/ui/styles.css" — no Tailwind needed */
24
12
  ```
25
13
 
26
- Since v32 the styles entry no longer bundles fonts. A Japanese app that wants the bundled faces
27
- installs them and adds the fonts import (the GoDX preset does both for you):
14
+ Fonts are opt-in (`@godxjp/ui/styles/fonts.css`, after the styles import). Never cherry-pick
15
+ `*-layout.css` layers. The default `--primary` is a neutral placeholder: set `--primary` once in
16
+ your theme, or import a preset (`@godxjp/ui/themes/godx.css`). Details: `docs/CUSTOMER-THEMING.md`.
28
17
 
29
- ```bash
30
- pnpm add @fontsource/noto-sans-jp @fontsource/m-plus-2
31
- ```
18
+ ## Screen composition (ui-audit không thấy những lỗi này)
32
19
 
33
- An app that supplies its own face (next/font, self-host, a system stack) skips the fonts import and
34
- names its face with `--font-sans-base`. Never cherry-pick individual `*-layout.css` layers: that
35
- breaks the layer-order contract, which is not the same thing as choosing an entry point. Reasons
36
- and measurements are in `docs/CUSTOMER-THEMING.md`.
20
+ - Most sections are a `Heading` + content, NOT a `Card`. A Card is for a genuinely separate
21
+ surface (a table, a form block), never a frame around every section.
22
+ - No `subtitle` that restates the `title`. Leave it out unless it adds a fact.
23
+ - Filters sit ABOVE the table they filter, never after it.
24
+ - KPI tiles (`StatCard`) only on dashboards, 3–4 at most. A list page opens with its list.
25
+ - `EmptyState` = one sentence + one action. Inside a Card it has no icon.
26
+ - `Alert` only for something the user must act on now; not a page decoration.
27
+ - Tiers / categories / roles render in ONE neutral tone (`<Badge variant="outline">`); colour
28
+ is for status.
29
+ - Vary spacing by level: `xs` inside a group, `sm` within a row, `md` between items, `lg`
30
+ between blocks. One gap everywhere reads as generated.
37
31
 
38
32
  ## Bố cục chuẩn của platform: BA CỘT, và ba cột là BA PHẠM VI
39
33
 
@@ -85,11 +79,10 @@ ngày: năm thứ cần đều ĐÃ CÓ và vẫn bị dựng lại bằng thứ
85
79
  | dải giữa hai vùng, tự kẻ theo VỊ TRÍ | `<CardBar>` (`border` để ép khi xếp chồng) |
86
80
  | chip "điều kiện đang bật" có dấu × để bỏ | `<Badge onRemove>` — KHÔNG phải `TagInput` |
87
81
  | dải tab nằm TRONG đầu Card | `<Card tabList activeTabKey onTabChange>` |
88
- | tiêu đề / đoạn văn / liên kết trong văn bản | `<Title>` · `<Paragraph>` · `<Link>` (`Text` 33 prop) |
82
+ | tiêu đề / đoạn văn / liên kết trong văn bản | `<Heading level>` · `<Text as="p">` · `<Link>` |
89
83
  | nút nổi góc màn (quay lên đầu, hành động nhanh) | `<FloatButton>` |
90
84
  | khung xương của một FORM khi đang tải | `<SkeletonForm>` |
91
85
  | ô màu chỉ để xem, màu do NGƯỜI DÙNG chọn | `<Swatch>` |
92
- | ảnh đại diện của một tệp / bản xem trước | `<Thumbnail>` |
93
86
  | danh sách "có gì trong gói này" | `<FeatureList>` |
94
87
  | panel kéo giãn được (chia đôi màn) | `<DraggablePanel>` |
95
88
  | màn hội thoại AI | `<Welcome>` · `<Conversations>` · `<Attachments>` · `<ThoughtChain>` · `<Actions>` |
@@ -120,48 +113,19 @@ godx-mailer dính bốn lỗi form trong một ngày, cả bốn đều là kit
120
113
  Ca kit không diễn đạt được thì **mở issue ở godx-jp/godxjp-ui**, tạm dùng cách
121
114
  hợp lệ gần nhất kèm `// TODO(godxjp-ui#<n>)` — không tự chế.
122
115
 
123
- ## Dialog và AlertDialog là MỘT họ — `variant` là lối chuẩn
124
-
125
- Đừng với tay sang 12 export `AlertDialog*` nữa. Chúng **vẫn chạy y như cũ** (gỡ
126
- là breaking change) nhưng là **LỐI CŨ**: đo được 26 export với **12 cặp trùng
127
- tên** và **0** phần chỉ `AlertDialog` mới có. antd — thẩm quyền bề mặt prop của
128
- gói này — chỉ có MỘT `Modal`, và mức nguy hiểm ở đó là một PROP.
116
+ ## Xác nhận nguy hiểm: `Dialog variant="destructive"`
129
117
 
130
118
  ```tsx
131
- <DialogContent variant="destructive"> {/* thay cho <AlertDialogContent> */}
119
+ <DialogContent variant="destructive"> {/* không dùng họ AlertDialog* — lối cũ */}
132
120
  ```
133
121
 
134
- Một prop ấy quyết định ba thứ đi cùng nhau: `role="alertdialog"`, click ra ngoài
135
- KHÔNG đóng, nút chính nhấn mạnh destructive (và ✕ mặc định tắt). Esc VẪN đóng —
136
- y như `AlertDialogContent` trước nay. Đặt `variant` ở `DialogRoot` thì cả cây kế
137
- thừa.
138
-
139
- ## antd là CHUẨN — thiếu gì thì port 100%, đừng tự thiết kế
140
-
141
- `docs/DESIGN-AUTHORITY.md` của gói: **nơi antd đặt tên cho một năng lực, gói này
142
- lấy nguyên tên và nguyên ngữ nghĩa của antd.** Một năng lực còn thiếu được port
143
- từ antd **100% TRƯỚC** — tên, prop, ngữ nghĩa — rồi mới cải tiến. Không thiết kế
144
- lại trước, không port một nửa.
145
-
146
- Nghĩa là với consumer: thấy thiếu prop thì **mở issue kèm tên antd của nó**
147
- (`sorter`, `closable`, `okType`…), đừng đề xuất một cái tên mới và đừng tự vẽ
148
- lại bằng class tiện ích. Ba trục cố ý lệch khỏi antd đều đã ghi lý do trong
149
- DESIGN-AUTHORITY (logical thay cho `left/right`, từ vựng giá trị của gói này,
150
- `density` thay cho `size`) — lệch thêm thì phải viết ra ở đó.
151
-
152
- ## Catalog giờ chở CẢ luật bố cục — nhưng phải hỏi mới có
153
-
154
- Mục này từng nói "catalog chở PROP, không chở LUẬT BỐ CỤC", với `CardBar` làm
155
- bằng chứng: một prop (`extra`), không dòng nào nói nó tự kẻ theo vị trí. **Bằng
156
- chứng ấy đã hết đúng.** `CardBar` nay có 6 prop, trong đó `border` (`"none" |
157
- "block-start" | "block-end" | "both"`) ép được đường kẻ khi xếp chồng, và cả
158
- `Card` lẫn `CardBar` trong catalog đều nói ra luật vị trí (đầu: kẻ dưới · cuối:
159
- kẻ trên · giữa: cả hai).
160
-
161
- Nên luật hiện hành là: **hỏi `get_component` trước** — nay nó thường trả lời cả
162
- hình dạng lẫn luật. Còn khi `usage`/`description` im lặng về bố cục thì mới mở
163
- `node_modules/@godxjp/ui/src/styles/*-layout.css` của component ấy ra đọc; chú
164
- thích trong đó vẫn là bản đầy đủ nhất.
122
+ Một prop: `role="alertdialog"`, click ra ngoài không đóng, nút chính nhấn mạnh destructive.
123
+ Thiếu prop thì mở issue kèm tên antd của nó (`docs/DESIGN-AUTHORITY.md`), đừng tự đặt tên.
124
+
125
+ ## Luật bố cục: hỏi `get_component` trước
126
+
127
+ `usage`/`description` thường nói cả luật vị trí (vd. `CardBar` tự kẻ theo vị trí, `border` để
128
+ ép). Khi catalog im lặng thì mới đọc `node_modules/@godxjp/ui/src/styles/*-layout.css`.
165
129
 
166
130
  ## Card không lồng Card
167
131
 
@@ -295,6 +295,22 @@ const isAsChildSlot = (content, index) => {
295
295
  const rawTagOutsideAsChildSlot = (tag) => (content) =>
296
296
  [...content.matchAll(rawTag(tag))].filter((match) => !isAsChildSlot(content, match.index));
297
297
 
298
+ /*
299
+ * ADMIN-SCREEN TELLS (gh#1294). Consumer screens built only from correct primitives still read as
300
+ * generated, through structure: a Card per section, a Card in a Card, a KPI tile row above a
301
+ * list, an icon EmptyState inside a Card, a wall of Badges or Alerts. Each is sometimes right, so
302
+ * these are WARNINGS. The per-file limits were set from four consumer repos (ql, platform,
303
+ * godx-task, godx-content: 511 files importing @godxjp/ui, 2026-10-14) to flag the densest
304
+ * ~10–15% of the files that use the tag at all:
305
+ * Card ≥5 → 43 of 336 files (12.8%)
306
+ * Badge ≥5 → 28 of 211 (13.3%)
307
+ * Alert ≥4 → 27 of 200 (13.5%)
308
+ */
309
+ const CARD_LIMIT = 5;
310
+ const BADGE_LIMIT = 5;
311
+ const ALERT_LIMIT = 4;
312
+ const STATCARD_ROW = 3;
313
+
298
314
  /**
299
315
  * @type {{id:string, severity:'error'|'warn', test:RegExp, message:string, standard?:string,
300
316
  * exempt?:RegExp, classOnly?:boolean}[]}
@@ -915,6 +931,64 @@ const RULES = [
915
931
  message:
916
932
  "Buttons in one row mix sizes — every Button under one parent takes the same `size` (a missing size is `default`; `icon-sm` pairs with `sm`, `icon-xs` with `xs`, `icon` with default). Pick the row's size and apply it to every button in it (docs/CONSUMER-RULES.md §5).",
917
933
  },
934
+ // Admin-screen tells (gh#1294) — structure, not decoration; limits measured, see CARD_LIMIT.
935
+ {
936
+ id: "nested-card",
937
+ replacement: "Heading + Flex inside the one Card",
938
+ scope: "consumer",
939
+ severity: "warn",
940
+ spansElement: true,
941
+ matches: nestedCardMatches,
942
+ message:
943
+ "A Card (or StatCard) inside a Card — two borders for one boundary. Group inside the outer Card with a Heading and Flex/Separator; keep Card for a real surface boundary.",
944
+ },
945
+ {
946
+ id: "card-per-section",
947
+ replacement: "Heading + Flex direction=col per section",
948
+ scope: "consumer",
949
+ severity: "warn",
950
+ spansElement: true,
951
+ matches: (source) => densityMatches(source, ["Card"], CARD_LIMIT),
952
+ message: `${CARD_LIMIT}+ Cards in one file — a Card per section. Mark sections with a Heading and section spacing (PageContainer children, Flex gap="lg"); keep Card for a real boundary (a table surface, a selectable item).`,
953
+ },
954
+ {
955
+ id: "kpi-row-off-dashboard",
956
+ replacement: "drop the StatCard row; put counts in the table header or Pagination total",
957
+ scope: "consumer",
958
+ severity: "warn",
959
+ spansElement: true,
960
+ matches: kpiRowMatches,
961
+ message: `${STATCARD_ROW}+ StatCards outside a dashboard — a row count is not a KPI. Keep StatCard rows for a dashboard with comparison or trend (delta); a list shows its total in Pagination showTotal.`,
962
+ },
963
+ {
964
+ id: "empty-state-icon-in-card",
965
+ replacement: 'EmptyState title + action, no icon (or variant="compact")',
966
+ scope: "consumer",
967
+ severity: "warn",
968
+ spansElement: true,
969
+ matches: emptyStateIconInCardMatches,
970
+ message:
971
+ 'An icon EmptyState inside a Card. In an admin surface an empty state is one sentence and one action: drop `icon`, or use variant="compact".',
972
+ },
973
+ {
974
+ id: "badge-wall",
975
+ replacement: "plain text for categories; Badge only for status",
976
+ scope: "consumer",
977
+ severity: "warn",
978
+ spansElement: true,
979
+ matches: (source) => densityMatches(source, ["Badge", "Tag"], BADGE_LIMIT),
980
+ message: `${BADGE_LIMIT}+ Badge/Tag in one file. Badge is for status; render categories, tiers and scopes as plain text, and keep one status column per table.`,
981
+ },
982
+ {
983
+ id: "alert-per-page",
984
+ replacement:
985
+ "one Alert for the page's blocking state; inline FormField errors and toasts for the rest",
986
+ scope: "consumer",
987
+ severity: "warn",
988
+ spansElement: true,
989
+ matches: (source) => densityMatches(source, ["Alert"], ALERT_LIMIT),
990
+ message: `${ALERT_LIMIT}+ Alerts in one file. An Alert is for a state that blocks or changes the task; field errors belong in FormField, results in a toast, and standing info in plain text.`,
991
+ },
918
992
  ];
919
993
 
920
994
  /**
@@ -1554,9 +1628,11 @@ const BUTTON_ROW_TRANSPARENT = new Set([
1554
1628
  "TooltipTrigger",
1555
1629
  ]);
1556
1630
 
1557
- /** A Button's `size` literal from its OPENING tag's own attributes (not a nested element's). */
1558
- function buttonSizeOf(openTag) {
1559
- // Blank every `{…}` body so `icon={<Plus size="sm" />}` cannot answer for the Button.
1631
+ /**
1632
+ * An opening tag with every `{…}` body blanked (the `{` kept), so `icon={<Plus size="sm" />}`
1633
+ * cannot answer for the element that owns the attribute.
1634
+ */
1635
+ function topLevelAttrs(openTag) {
1560
1636
  let top = "";
1561
1637
  let depth = 0;
1562
1638
  let quote = "";
@@ -1573,7 +1649,12 @@ function buttonSizeOf(openTag) {
1573
1649
  top += depth ? (ch === "{" && depth === 1 ? "{" : " ") : ch;
1574
1650
  if (ch === "}") depth--;
1575
1651
  }
1576
- const at = /\bsize\s*=\s*/.exec(top);
1652
+ return top;
1653
+ }
1654
+
1655
+ /** A Button's `size` literal from its OPENING tag's own attributes (not a nested element's). */
1656
+ function buttonSizeOf(openTag) {
1657
+ const at = /\bsize\s*=\s*/.exec(topLevelAttrs(openTag));
1577
1658
  if (!at) return "default";
1578
1659
  const rest = openTag.slice(at.index + at[0].length);
1579
1660
  const lit = /^(?:"([^"]*)"|'([^']*)'|\{\s*(?:"([^"]*)"|'([^']*)'|`([^`$]*)`)\s*\})/.exec(rest);
@@ -1587,15 +1668,14 @@ const buttonSizeFamily = (size) => {
1587
1668
  };
1588
1669
 
1589
1670
  /**
1590
- * `mixed-button-size`: group every <Button> by its nearest non-transparent JSX parent and yield
1591
- * one match per group whose size families differ. A tag stack over the whole file; an element in
1592
- * an attribute (`actions={<>…</>}`) gets the attribute's owner as its parent, kept apart from the
1593
- * owner's children. A `<` glued to an identifier outside JSX children is a TS generic.
1671
+ * Every JSX opening tag in the file, with the stack of elements open around it. A tag stack over
1672
+ * the whole file; an element in an attribute (`actions={<>…</>}`) gets the attribute's owner as an
1673
+ * `attr: true` frame, kept apart from the owner's children. A `<` glued to an identifier outside
1674
+ * JSX children is a TS generic. `stack` is live: read it before asking for the next element.
1594
1675
  */
1595
- function* mixedButtonSizeMatches(source) {
1676
+ function* jsxElements(source) {
1596
1677
  const stack = []; // { tag, id, attr }
1597
1678
  const pending = []; // opening tags whose attributes are still being scanned
1598
- const groups = new Map();
1599
1679
  let nextId = 0;
1600
1680
  const settle = (upTo) => {
1601
1681
  while (pending.length && pending.at(-1).end < upTo) {
@@ -1625,21 +1705,31 @@ function* mixedButtonSizeMatches(source) {
1625
1705
  const end = jsxOpeningEnd(source, m.index);
1626
1706
  if (end >= source.length) continue;
1627
1707
  const openTag = source.slice(m.index, end + 1);
1628
- if (tag === "Button") {
1629
- const parent = stack.findLast((f) => f.attr || !BUTTON_ROW_TRANSPARENT.has(f.tag));
1630
- const size = buttonSizeOf(openTag);
1631
- if (parent && size !== null) {
1632
- (groups.get(parent.id) ?? groups.set(parent.id, []).get(parent.id)).push({
1633
- size,
1634
- index: m.index,
1635
- openTag,
1636
- });
1637
- }
1638
- }
1708
+ yield { tag, index: m.index, openTag, stack };
1639
1709
  const frame = { tag, id: nextId++, attr: true };
1640
1710
  stack.push(frame);
1641
1711
  pending.push({ end, frame, selfClose: /\/\s*>$/.test(openTag) });
1642
1712
  }
1713
+ }
1714
+
1715
+ /**
1716
+ * `mixed-button-size`: group every <Button> by its nearest non-transparent JSX parent and yield
1717
+ * one match per group whose size families differ.
1718
+ */
1719
+ function* mixedButtonSizeMatches(source) {
1720
+ const groups = new Map();
1721
+ for (const { tag, index, openTag, stack } of jsxElements(source)) {
1722
+ if (tag !== "Button") continue;
1723
+ const parent = stack.findLast((f) => f.attr || !BUTTON_ROW_TRANSPARENT.has(f.tag));
1724
+ const size = buttonSizeOf(openTag);
1725
+ if (parent && size !== null) {
1726
+ (groups.get(parent.id) ?? groups.set(parent.id, []).get(parent.id)).push({
1727
+ size,
1728
+ index,
1729
+ openTag,
1730
+ });
1731
+ }
1732
+ }
1643
1733
  for (const group of groups.values()) {
1644
1734
  const families = new Set(group.map((b) => buttonSizeFamily(b.size)));
1645
1735
  if (group.length < 2 || families.size < 2) continue;
@@ -1650,6 +1740,46 @@ function* mixedButtonSizeMatches(source) {
1650
1740
  }
1651
1741
  }
1652
1742
 
1743
+ /** One finding per file, at the `limit`-th occurrence of any tag in `tags`, naming the count. */
1744
+ function* densityMatches(source, tags, limit) {
1745
+ const hits = [];
1746
+ for (const { tag, index } of jsxElements(source)) if (tags.includes(tag)) hits.push(index);
1747
+ if (hits.length >= limit) yield { 0: `${hits.length} in this file`, index: hits[limit - 1] };
1748
+ }
1749
+
1750
+ /** A Card or StatCard opened while another Card is open, in children or in an attribute. */
1751
+ function* nestedCardMatches(source) {
1752
+ for (const { tag, index, openTag, stack } of jsxElements(source)) {
1753
+ if ((tag === "Card" || tag === "StatCard") && stack.some((f) => f.tag === "Card")) {
1754
+ yield { 0: openTag, index };
1755
+ }
1756
+ }
1757
+ }
1758
+
1759
+ /** ≥3 StatCards in a file whose path does not say it is a dashboard. */
1760
+ function* kpiRowMatches(source, rel = "") {
1761
+ if (/dashboard/i.test(rel)) return;
1762
+ yield* densityMatches(source, ["StatCard"], STATCARD_ROW);
1763
+ }
1764
+
1765
+ /** An EmptyState that draws an icon (not `icon={null}`, not compact) inside a Card. */
1766
+ function* emptyStateIconInCardMatches(source) {
1767
+ for (const { tag, index, openTag, stack } of jsxElements(source)) {
1768
+ if (tag !== "EmptyState" || !stack.some((f) => f.tag === "Card" || f.tag === "CardContent")) {
1769
+ continue;
1770
+ }
1771
+ const top = topLevelAttrs(openTag);
1772
+ const icon = /\bicon\s*=\s*/.exec(top);
1773
+ if (!icon || /^\{\s*(?:null|undefined)\s*\}/.test(openTag.slice(icon.index + icon[0].length))) {
1774
+ continue;
1775
+ }
1776
+ const variant = /\bvariant\s*=\s*/.exec(top);
1777
+ const rest = variant ? openTag.slice(variant.index + variant[0].length) : "";
1778
+ if (/^(?:"compact"|'compact'|\{\s*["'`]compact["'`]\s*\})/.test(rest)) continue;
1779
+ yield { 0: openTag, index };
1780
+ }
1781
+ }
1782
+
1653
1783
  // Structural: a <Card> (without p-0) whose first child is body content rather than a Card
1654
1784
  // sub-component sits FLUSH (no padding). Per-line regexes can't see across lines, so this is a
1655
1785
  // whole-file pass. The body must be wrapped in <CardContent> (titles in <CardHeader>).
@@ -2065,7 +2195,7 @@ for (const dir of SCAN_DIRS) {
2065
2195
  for (const rule of fileRules) {
2066
2196
  if (!rule.spansElement || !isJsx) continue;
2067
2197
  for (const match of rule.matches
2068
- ? rule.matches(scanContent)
2198
+ ? rule.matches(scanContent, rel)
2069
2199
  : scanContent.matchAll(rule.test)) {
2070
2200
  // A comparison `>` inside a JSX prop is not the end of its opening tag.
2071
2201
  if (