@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.
- package/DESIGN.md +210 -0
- package/README.md +3 -2
- package/agent/DESIGN.md +210 -0
- package/agent/START-HERE.md +25 -29
- package/agent/anti-ai-tells.json +56 -2
- package/agent/components/Anchor.json +1 -1
- package/agent/components/ChatSuggestion.json +1 -1
- package/agent/components/LegalDocumentShell.json +7 -1
- package/agent/components/PageContainer.json +1 -2
- package/agent/components/Progress.json +1 -1
- package/agent/components-index.json +1 -1
- package/agent/components.json +11 -6
- package/agent/index.json +11 -5
- package/agent/llms.txt +7 -6
- package/agent/patterns/badge-coloring.json +2 -2
- package/agent/patterns/common-fixes.json +1 -1
- package/agent/patterns/confirm-destructive.json +2 -2
- package/agent/patterns/inertia-detail-page.json +2 -2
- package/agent/patterns/inertia-list-page.json +2 -2
- package/agent/patterns/page-sections.json +5 -3
- package/agent/patterns/settings-section-rows.json +2 -2
- package/agent/patterns-index.json +9 -7
- package/agent/patterns.json +16 -14
- package/agent/tokens.json +12 -0
- package/dist/components/data-entry/select.js +5 -4
- package/dist/components/layout/page-container.js +14 -8
- package/dist/contracts/measurement.json +1 -1
- package/dist/i18n/messages/en.json +2 -1
- package/dist/i18n/messages/ja.json +2 -1
- package/dist/i18n/messages/vi.json +2 -1
- package/dist/lab/legal-document-shell.js +7 -3
- package/dist/props/components/layout.prop.d.ts +13 -1
- package/dist/props/registry.d.ts +4 -0
- package/dist/props/registry.js +5 -0
- package/dist/styles/layers.json +1 -1
- package/dist/styles/layout.css +26 -0
- package/dist/styles.css +1 -1
- package/dist/tokens/components/legal-document.css +4 -0
- package/docs/CONSUMER-RULES.md +6 -5
- package/docs/README.md +1 -1
- package/docs/migrations/v32.md +10 -5
- package/package.json +3 -2
- package/scripts/_agent-setup.mjs +149 -85
- package/scripts/cli.mjs +8 -0
- package/scripts/consumer-rule.md +41 -77
- package/scripts/ui-audit.mjs +189 -29
package/scripts/consumer-rule.md
CHANGED
|
@@ -1,39 +1,33 @@
|
|
|
1
1
|
# @godxjp/ui
|
|
2
2
|
|
|
3
|
-
> **Tệp
|
|
4
|
-
>
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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"; /*
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 `
|
|
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 | `<
|
|
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
|
|
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ỉ
|
|
105
|
-
|
|
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
|
|
115
|
-
| ----------------------------------------------------------- |
|
|
116
|
-
| ≥2 `FormField` không có `Form`, hoặc xếp hàng bằng `<Flex>` | `<Form layout="horizontal" labelWidth controlWidth>`; hàng =
|
|
117
|
-
| ≥3 `FormField` trong `DialogBody` | page riêng (route riêng)
|
|
118
|
-
| `Select` hai lựa chọn kéo full width | `controlWidth` trên field hoặc một lần trên `Form`
|
|
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
|
-
##
|
|
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"> {/*
|
|
119
|
+
<DialogContent variant="destructive"> {/* không dùng họ AlertDialog* — lối cũ */}
|
|
132
120
|
```
|
|
133
121
|
|
|
134
|
-
Một prop
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
package/scripts/ui-audit.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
1558
|
-
|
|
1559
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1591
|
-
*
|
|
1592
|
-
*
|
|
1593
|
-
*
|
|
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*
|
|
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
|
-
|
|
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
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
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 (
|