@godxjp/ui 32.5.0 → 32.7.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 (46) hide show
  1. package/DESIGN.md +210 -0
  2. package/README.md +3 -2
  3. package/agent/DESIGN.md +210 -0
  4. package/agent/START-HERE.md +25 -29
  5. package/agent/anti-ai-tells.json +56 -2
  6. package/agent/components/Anchor.json +1 -1
  7. package/agent/components/ChatSuggestion.json +1 -1
  8. package/agent/components/LegalDocumentShell.json +7 -1
  9. package/agent/components/PageContainer.json +1 -2
  10. package/agent/components/Progress.json +1 -1
  11. package/agent/components-index.json +1 -1
  12. package/agent/components.json +11 -6
  13. package/agent/index.json +11 -5
  14. package/agent/llms.txt +7 -6
  15. package/agent/patterns/badge-coloring.json +2 -2
  16. package/agent/patterns/common-fixes.json +1 -1
  17. package/agent/patterns/confirm-destructive.json +2 -2
  18. package/agent/patterns/inertia-detail-page.json +2 -2
  19. package/agent/patterns/inertia-list-page.json +2 -2
  20. package/agent/patterns/page-sections.json +5 -3
  21. package/agent/patterns/settings-section-rows.json +2 -2
  22. package/agent/patterns-index.json +9 -7
  23. package/agent/patterns.json +16 -14
  24. package/agent/tokens.json +12 -0
  25. package/dist/components/data-entry/select.js +5 -4
  26. package/dist/components/layout/page-container.js +14 -8
  27. package/dist/contracts/measurement.json +1 -1
  28. package/dist/i18n/messages/en.json +2 -1
  29. package/dist/i18n/messages/ja.json +2 -1
  30. package/dist/i18n/messages/vi.json +2 -1
  31. package/dist/lab/legal-document-shell.js +7 -3
  32. package/dist/props/components/layout.prop.d.ts +13 -1
  33. package/dist/props/registry.d.ts +4 -0
  34. package/dist/props/registry.js +5 -0
  35. package/dist/styles/layers.json +1 -1
  36. package/dist/styles/layout.css +26 -0
  37. package/dist/styles.css +1 -1
  38. package/dist/tokens/components/legal-document.css +4 -0
  39. package/docs/CONSUMER-RULES.md +6 -5
  40. package/docs/README.md +1 -1
  41. package/docs/migrations/v32.md +10 -5
  42. package/package.json +3 -2
  43. package/scripts/_agent-setup.mjs +149 -85
  44. package/scripts/cli.mjs +8 -0
  45. package/scripts/consumer-rule.md +41 -77
  46. package/scripts/ui-audit.mjs +189 -29
@@ -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
 
@@ -83,13 +77,12 @@ ngày: năm thứ cần đều ĐÃ CÓ và vẫn bị dựng lại bằng thứ
83
77
  | một hàng LÀ liên kết (thay cho nút rời) | `<ListRow asChild>` |
84
78
  | kẻ ô từng ngày trong lịch | `<Calendar bordered>` |
85
79
  | dải giữa hai vùng, tự kẻ theo VỊ TRÍ | `<CardBar>` (`border` để ép khi xếp chồng) |
86
- | chip "điều kiện đang bật" có dấu × để bỏ | `<Badge onRemove>` — KHÔNG phải `TagInput` |
80
+ | chip "điều kiện đang bật" có dấu × để bỏ | `<Badge onRemove>` — KHÔNG phải `Select mode="tags"` |
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>` |
@@ -98,11 +91,11 @@ ngày: năm thứ cần đều ĐÃ CÓ và vẫn bị dựng lại bằng thứ
98
91
  Lỗi không phải "đoán sai tên prop" mà là **cho rằng nó không tồn tại nên không
99
92
  hỏi**.
100
93
 
101
- ## `AppProvider` là bắt buộc — thiếu nó, chuỗi của kit nói tiếng Việt
94
+ ## `AppProvider` là bắt buộc — thiếu nó, chuỗi của kit không theo ngôn ngữ của app
102
95
 
103
96
  Mount `<AppProvider defaultLocale="ja">` MỘT lần ở gốc app. Thiếu nó, chuỗi của chính kit (nút
104
- đóng/hủy/xóa của dialog, upload, badge đếm, nhãn picker) rơi về locale nghỉ `vi` và picker rơi về
105
- UTC — godx-mailer đo được nút tiếng Việt giữa UI tiếng Nhật, không một cảnh báo (gh#1005). Bản dev
97
+ đóng/hủy/xóa của dialog, upload, badge đếm, nhãn picker) rơi về locale nghỉ — `<html lang>` nếu
98
+ locale đó đã đăng ký, không thì `en` — và picker rơi về UTC (gh#1005). Bản dev
106
99
  giờ cảnh báo MỘT lần khi component của kit render ngoài provider. Không mount được provider thì gọi
107
100
  `syncI18nLocale(locale, fallback)` lúc khởi động.
108
101
 
@@ -111,57 +104,28 @@ giờ cảnh báo MỘT lần khi component của kit render ngoài provider. Kh
111
104
  godx-mailer dính bốn lỗi form trong một ngày, cả bốn đều là kit ĐÃ CÓ đồ đúng
112
105
  (gh#998). Giờ ui-audit chặn:
113
106
 
114
- | Sai | Đúng | rule |
115
- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ---------------------- |
116
- | ≥2 `FormField` không có `Form`, hoặc xếp hàng bằng `<Flex>` | `<Form layout="horizontal" labelWidth controlWidth>`; hàng = `SpaceCompact` / `Form columns` | `formfield-needs-form` |
117
- | ≥3 `FormField` trong `DialogBody` | page riêng (route riêng) | `dialog-form-too-big` |
118
- | `Select` hai lựa chọn kéo full width | `controlWidth` trên field hoặc một lần trên `Form` | `select-width-hint` |
107
+ | Sai | Đúng | rule |
108
+ | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ---------------------- |
109
+ | ≥2 `FormField` không có `Form`, hoặc xếp hàng bằng `<Flex>` | `<Form layout="horizontal" labelWidth controlWidth>`; hàng = `<Flex attached>` / `Form columns` | `formfield-needs-form` |
110
+ | ≥3 `FormField` trong `DialogBody` | page riêng (route riêng) | `dialog-form-too-big` |
111
+ | `Select` hai lựa chọn kéo full width | `controlWidth` trên field hoặc một lần trên `Form` | `select-width-hint` |
119
112
 
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
 
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { spawnSync } from "node:child_process";
9
9
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
10
- import { isAbsolute, join, relative } from "node:path";
10
+ import { dirname, isAbsolute, join, relative } from "node:path";
11
11
 
12
12
  const CWD = process.cwd();
13
13
  const SELF = (() => {
@@ -18,6 +18,29 @@ const SELF = (() => {
18
18
  }
19
19
  })();
20
20
  const args = process.argv.slice(2);
21
+
22
+ // Before anything reads the arguments: `--help` used to be filtered out as a flag and ran a full
23
+ // audit of the working directory instead (gh#1296).
24
+ if (args.includes("--help") || args.includes("-h")) {
25
+ console.log(`usage: ui-audit [dir|file …] [--changed [--base=<ref>]] [--format json] [--quiet]
26
+ ui-audit --rules
27
+
28
+ Static UI-standardization audit of @godxjp/ui consumer source. Exits 1 on any error.
29
+ Run it from the package that owns the files (a workspace package works; the installed
30
+ @godxjp/ui is found up the node_modules chain). Same as: npx @godxjp/ui audit …
31
+
32
+ dir|file … what to scan (default: the conventional UI directories under the cwd)
33
+ --changed scan what this branch changed (committed, staged, untracked)
34
+ --base=<ref> the base for --changed (default origin/HEAD, then origin/main)
35
+ --format json machine-readable findings
36
+ --quiet errors only
37
+ --rules print the rule catalog as JSON and exit
38
+ --consumer library repo only: apply the consumer rules inside @godxjp/ui itself.
39
+ A consumer app never needs it; there those rules always apply.
40
+ -h, --help this text`);
41
+ process.exit(0);
42
+ }
43
+
21
44
  const asJson = args.includes("--format") && args[args.indexOf("--format") + 1] === "json";
22
45
  const quiet = args.includes("--quiet");
23
46
  const dirArgs = args.filter((a) => !a.startsWith("--") && a !== "json");
@@ -295,6 +318,22 @@ const isAsChildSlot = (content, index) => {
295
318
  const rawTagOutsideAsChildSlot = (tag) => (content) =>
296
319
  [...content.matchAll(rawTag(tag))].filter((match) => !isAsChildSlot(content, match.index));
297
320
 
321
+ /*
322
+ * ADMIN-SCREEN TELLS (gh#1294). Consumer screens built only from correct primitives still read as
323
+ * generated, through structure: a Card per section, a Card in a Card, a KPI tile row above a
324
+ * list, an icon EmptyState inside a Card, a wall of Badges or Alerts. Each is sometimes right, so
325
+ * these are WARNINGS. The per-file limits were set from four consumer repos (ql, platform,
326
+ * godx-task, godx-content: 511 files importing @godxjp/ui, 2026-10-14) to flag the densest
327
+ * ~10–15% of the files that use the tag at all:
328
+ * Card ≥5 → 43 of 336 files (12.8%)
329
+ * Badge ≥5 → 28 of 211 (13.3%)
330
+ * Alert ≥4 → 27 of 200 (13.5%)
331
+ */
332
+ const CARD_LIMIT = 5;
333
+ const BADGE_LIMIT = 5;
334
+ const ALERT_LIMIT = 4;
335
+ const STATCARD_ROW = 3;
336
+
298
337
  /**
299
338
  * @type {{id:string, severity:'error'|'warn', test:RegExp, message:string, standard?:string,
300
339
  * exempt?:RegExp, classOnly?:boolean}[]}
@@ -915,6 +954,64 @@ const RULES = [
915
954
  message:
916
955
  "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
956
  },
957
+ // Admin-screen tells (gh#1294) — structure, not decoration; limits measured, see CARD_LIMIT.
958
+ {
959
+ id: "nested-card",
960
+ replacement: "Heading + Flex inside the one Card",
961
+ scope: "consumer",
962
+ severity: "warn",
963
+ spansElement: true,
964
+ matches: nestedCardMatches,
965
+ message:
966
+ "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.",
967
+ },
968
+ {
969
+ id: "card-per-section",
970
+ replacement: "Heading + Flex direction=col per section",
971
+ scope: "consumer",
972
+ severity: "warn",
973
+ spansElement: true,
974
+ matches: (source) => densityMatches(source, ["Card"], CARD_LIMIT),
975
+ 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).`,
976
+ },
977
+ {
978
+ id: "kpi-row-off-dashboard",
979
+ replacement: "drop the StatCard row; put counts in the table header or Pagination total",
980
+ scope: "consumer",
981
+ severity: "warn",
982
+ spansElement: true,
983
+ matches: kpiRowMatches,
984
+ 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.`,
985
+ },
986
+ {
987
+ id: "empty-state-icon-in-card",
988
+ replacement: 'EmptyState title + action, no icon (or variant="compact")',
989
+ scope: "consumer",
990
+ severity: "warn",
991
+ spansElement: true,
992
+ matches: emptyStateIconInCardMatches,
993
+ message:
994
+ '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".',
995
+ },
996
+ {
997
+ id: "badge-wall",
998
+ replacement: "plain text for categories; Badge only for status",
999
+ scope: "consumer",
1000
+ severity: "warn",
1001
+ spansElement: true,
1002
+ matches: (source) => densityMatches(source, ["Badge", "Tag"], BADGE_LIMIT),
1003
+ 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.`,
1004
+ },
1005
+ {
1006
+ id: "alert-per-page",
1007
+ replacement:
1008
+ "one Alert for the page's blocking state; inline FormField errors and toasts for the rest",
1009
+ scope: "consumer",
1010
+ severity: "warn",
1011
+ spansElement: true,
1012
+ matches: (source) => densityMatches(source, ["Alert"], ALERT_LIMIT),
1013
+ 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.`,
1014
+ },
918
1015
  ];
919
1016
 
920
1017
  /**
@@ -1554,9 +1651,11 @@ const BUTTON_ROW_TRANSPARENT = new Set([
1554
1651
  "TooltipTrigger",
1555
1652
  ]);
1556
1653
 
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.
1654
+ /**
1655
+ * An opening tag with every `{…}` body blanked (the `{` kept), so `icon={<Plus size="sm" />}`
1656
+ * cannot answer for the element that owns the attribute.
1657
+ */
1658
+ function topLevelAttrs(openTag) {
1560
1659
  let top = "";
1561
1660
  let depth = 0;
1562
1661
  let quote = "";
@@ -1573,7 +1672,12 @@ function buttonSizeOf(openTag) {
1573
1672
  top += depth ? (ch === "{" && depth === 1 ? "{" : " ") : ch;
1574
1673
  if (ch === "}") depth--;
1575
1674
  }
1576
- const at = /\bsize\s*=\s*/.exec(top);
1675
+ return top;
1676
+ }
1677
+
1678
+ /** A Button's `size` literal from its OPENING tag's own attributes (not a nested element's). */
1679
+ function buttonSizeOf(openTag) {
1680
+ const at = /\bsize\s*=\s*/.exec(topLevelAttrs(openTag));
1577
1681
  if (!at) return "default";
1578
1682
  const rest = openTag.slice(at.index + at[0].length);
1579
1683
  const lit = /^(?:"([^"]*)"|'([^']*)'|\{\s*(?:"([^"]*)"|'([^']*)'|`([^`$]*)`)\s*\})/.exec(rest);
@@ -1587,15 +1691,14 @@ const buttonSizeFamily = (size) => {
1587
1691
  };
1588
1692
 
1589
1693
  /**
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.
1694
+ * Every JSX opening tag in the file, with the stack of elements open around it. A tag stack over
1695
+ * the whole file; an element in an attribute (`actions={<>…</>}`) gets the attribute's owner as an
1696
+ * `attr: true` frame, kept apart from the owner's children. A `<` glued to an identifier outside
1697
+ * JSX children is a TS generic. `stack` is live: read it before asking for the next element.
1594
1698
  */
1595
- function* mixedButtonSizeMatches(source) {
1699
+ function* jsxElements(source) {
1596
1700
  const stack = []; // { tag, id, attr }
1597
1701
  const pending = []; // opening tags whose attributes are still being scanned
1598
- const groups = new Map();
1599
1702
  let nextId = 0;
1600
1703
  const settle = (upTo) => {
1601
1704
  while (pending.length && pending.at(-1).end < upTo) {
@@ -1625,21 +1728,31 @@ function* mixedButtonSizeMatches(source) {
1625
1728
  const end = jsxOpeningEnd(source, m.index);
1626
1729
  if (end >= source.length) continue;
1627
1730
  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
- }
1731
+ yield { tag, index: m.index, openTag, stack };
1639
1732
  const frame = { tag, id: nextId++, attr: true };
1640
1733
  stack.push(frame);
1641
1734
  pending.push({ end, frame, selfClose: /\/\s*>$/.test(openTag) });
1642
1735
  }
1736
+ }
1737
+
1738
+ /**
1739
+ * `mixed-button-size`: group every <Button> by its nearest non-transparent JSX parent and yield
1740
+ * one match per group whose size families differ.
1741
+ */
1742
+ function* mixedButtonSizeMatches(source) {
1743
+ const groups = new Map();
1744
+ for (const { tag, index, openTag, stack } of jsxElements(source)) {
1745
+ if (tag !== "Button") continue;
1746
+ const parent = stack.findLast((f) => f.attr || !BUTTON_ROW_TRANSPARENT.has(f.tag));
1747
+ const size = buttonSizeOf(openTag);
1748
+ if (parent && size !== null) {
1749
+ (groups.get(parent.id) ?? groups.set(parent.id, []).get(parent.id)).push({
1750
+ size,
1751
+ index,
1752
+ openTag,
1753
+ });
1754
+ }
1755
+ }
1643
1756
  for (const group of groups.values()) {
1644
1757
  const families = new Set(group.map((b) => buttonSizeFamily(b.size)));
1645
1758
  if (group.length < 2 || families.size < 2) continue;
@@ -1650,6 +1763,46 @@ function* mixedButtonSizeMatches(source) {
1650
1763
  }
1651
1764
  }
1652
1765
 
1766
+ /** One finding per file, at the `limit`-th occurrence of any tag in `tags`, naming the count. */
1767
+ function* densityMatches(source, tags, limit) {
1768
+ const hits = [];
1769
+ for (const { tag, index } of jsxElements(source)) if (tags.includes(tag)) hits.push(index);
1770
+ if (hits.length >= limit) yield { 0: `${hits.length} in this file`, index: hits[limit - 1] };
1771
+ }
1772
+
1773
+ /** A Card or StatCard opened while another Card is open, in children or in an attribute. */
1774
+ function* nestedCardMatches(source) {
1775
+ for (const { tag, index, openTag, stack } of jsxElements(source)) {
1776
+ if ((tag === "Card" || tag === "StatCard") && stack.some((f) => f.tag === "Card")) {
1777
+ yield { 0: openTag, index };
1778
+ }
1779
+ }
1780
+ }
1781
+
1782
+ /** ≥3 StatCards in a file whose path does not say it is a dashboard. */
1783
+ function* kpiRowMatches(source, rel = "") {
1784
+ if (/dashboard/i.test(rel)) return;
1785
+ yield* densityMatches(source, ["StatCard"], STATCARD_ROW);
1786
+ }
1787
+
1788
+ /** An EmptyState that draws an icon (not `icon={null}`, not compact) inside a Card. */
1789
+ function* emptyStateIconInCardMatches(source) {
1790
+ for (const { tag, index, openTag, stack } of jsxElements(source)) {
1791
+ if (tag !== "EmptyState" || !stack.some((f) => f.tag === "Card" || f.tag === "CardContent")) {
1792
+ continue;
1793
+ }
1794
+ const top = topLevelAttrs(openTag);
1795
+ const icon = /\bicon\s*=\s*/.exec(top);
1796
+ if (!icon || /^\{\s*(?:null|undefined)\s*\}/.test(openTag.slice(icon.index + icon[0].length))) {
1797
+ continue;
1798
+ }
1799
+ const variant = /\bvariant\s*=\s*/.exec(top);
1800
+ const rest = variant ? openTag.slice(variant.index + variant[0].length) : "";
1801
+ if (/^(?:"compact"|'compact'|\{\s*["'`]compact["'`]\s*\})/.test(rest)) continue;
1802
+ yield { 0: openTag, index };
1803
+ }
1804
+ }
1805
+
1653
1806
  // Structural: a <Card> (without p-0) whose first child is body content rather than a Card
1654
1807
  // sub-component sits FLUSH (no padding). Per-line regexes can't see across lines, so this is a
1655
1808
  // whole-file pass. The body must be wrapped in <CardContent> (titles in <CardHeader>).
@@ -1769,13 +1922,20 @@ function staleOwnedRules() {
1769
1922
  const contents = readFileSync(target, "utf8");
1770
1923
  const stamped = /<!-- godxjp-ui:version ([^\s]+) -->/.exec(contents)?.[1];
1771
1924
 
1925
+ // Up the node_modules chain, as Node resolves it: a workspace package has its @godxjp/ui hoisted
1926
+ // to the repo root, and reading only `CWD/node_modules` reported a correct install as
1927
+ // `owned-rules-unknown` (gh#1296). The package's `exports` map does not expose package.json,
1928
+ // so `require.resolve` cannot do this walk for us.
1772
1929
  let installed;
1773
- try {
1774
- installed = JSON.parse(
1775
- readFileSync(join(CWD, "node_modules", "@godxjp", "ui", "package.json"), "utf8"),
1776
- ).version;
1777
- } catch {
1778
- installed = undefined;
1930
+ for (let dir = CWD; installed === undefined; dir = dirname(dir)) {
1931
+ try {
1932
+ installed = JSON.parse(
1933
+ readFileSync(join(dir, "node_modules", "@godxjp", "ui", "package.json"), "utf8"),
1934
+ ).version;
1935
+ } catch {
1936
+ // Not here — keep walking.
1937
+ }
1938
+ if (dirname(dir) === dir) break;
1779
1939
  }
1780
1940
 
1781
1941
  if (!stamped || !installed) {
@@ -2065,7 +2225,7 @@ for (const dir of SCAN_DIRS) {
2065
2225
  for (const rule of fileRules) {
2066
2226
  if (!rule.spansElement || !isJsx) continue;
2067
2227
  for (const match of rule.matches
2068
- ? rule.matches(scanContent)
2228
+ ? rule.matches(scanContent, rel)
2069
2229
  : scanContent.matchAll(rule.test)) {
2070
2230
  // A comparison `>` inside a JSX prop is not the end of its opening tag.
2071
2231
  if (