@godxjp/ui 19.6.0 → 20.1.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/README.md +8 -2
- package/dist/app/theme-axes.d.ts +2 -0
- package/dist/app/theme-axes.js +45 -0
- package/dist/components/charts/chart-cartesian.d.ts +2 -1
- package/dist/components/charts/chart-cartesian.js +52 -3
- package/dist/components/charts/chart-category-axis.d.ts +55 -0
- package/dist/components/charts/chart-category-axis.js +93 -0
- package/dist/components/charts/chart-frame.d.ts +10 -1
- package/dist/components/charts/chart-frame.js +19 -3
- package/dist/components/charts/compact-bar-trend.d.ts +1 -1
- package/dist/components/charts/compact-bar-trend.js +2 -0
- package/dist/components/charts/pie-chart.d.ts +1 -1
- package/dist/components/charts/pie-chart.js +12 -1
- package/dist/components/charts/recharts-peer.d.ts +49 -0
- package/dist/components/charts/recharts-peer.js +45 -0
- package/dist/components/data-display/card.d.ts +14 -3
- package/dist/components/data-display/card.js +22 -4
- package/dist/components/data-display/code-block.js +6 -2
- package/dist/components/data-display/collapsible.d.ts +23 -4
- package/dist/components/data-display/collapsible.js +119 -4
- package/dist/components/data-display/data-table.d.ts +39 -4
- package/dist/components/data-display/data-table.js +802 -270
- package/dist/components/data-display/descriptions.d.ts +27 -6
- package/dist/components/data-display/descriptions.js +58 -17
- package/dist/components/data-display/index.d.ts +5 -1
- package/dist/components/data-display/index.js +4 -0
- package/dist/components/data-display/legend.d.ts +15 -0
- package/dist/components/data-display/legend.js +11 -0
- package/dist/components/data-display/list-row.d.ts +10 -1
- package/dist/components/data-display/list-row.js +8 -2
- package/dist/components/data-display/popover.d.ts +60 -7
- package/dist/components/data-display/popover.js +268 -39
- package/dist/components/data-display/progress.d.ts +50 -2
- package/dist/components/data-display/progress.js +57 -9
- package/dist/components/data-display/range-timeline.d.ts +38 -0
- package/dist/components/data-display/range-timeline.js +161 -0
- package/dist/components/data-display/scroll-area.js +13 -2
- package/dist/components/data-display/service-launcher-card.js +10 -10
- package/dist/components/data-display/table.d.ts +11 -3
- package/dist/components/data-display/table.js +48 -18
- package/dist/components/data-display/tree-list.js +4 -9
- package/dist/components/data-entry/calendar.d.ts +2 -2
- package/dist/components/data-entry/calendar.js +79 -33
- package/dist/components/data-entry/cascader.d.ts +1 -1
- package/dist/components/data-entry/cascader.js +188 -24
- package/dist/components/data-entry/checkbox.d.ts +19 -6
- package/dist/components/data-entry/checkbox.js +55 -16
- package/dist/components/data-entry/choice-option.d.ts +1 -1
- package/dist/components/data-entry/color-picker.d.ts +1 -1
- package/dist/components/data-entry/color-picker.js +17 -5
- package/dist/components/data-entry/control-appearance.d.ts +64 -0
- package/dist/components/data-entry/control-appearance.js +39 -0
- package/dist/components/data-entry/control-surface.d.ts +61 -0
- package/dist/components/data-entry/control-surface.js +39 -0
- package/dist/components/data-entry/date-picker.d.ts +1 -1
- package/dist/components/data-entry/date-picker.js +344 -113
- package/dist/components/data-entry/date-range-picker.d.ts +2 -2
- package/dist/components/data-entry/date-range-picker.js +278 -140
- package/dist/components/data-entry/field.js +0 -1
- package/dist/components/data-entry/form-field.d.ts +1 -1
- package/dist/components/data-entry/form-field.js +39 -4
- package/dist/components/data-entry/form.d.ts +4 -0
- package/dist/components/data-entry/form.js +5 -3
- package/dist/components/data-entry/index.d.ts +7 -3
- package/dist/components/data-entry/index.js +10 -1
- package/dist/components/data-entry/input-otp.d.ts +1 -1
- package/dist/components/data-entry/input.d.ts +12 -34
- package/dist/components/data-entry/input.js +92 -24
- package/dist/components/data-entry/label.d.ts +3 -2
- package/dist/components/data-entry/label.js +23 -10
- package/dist/components/data-entry/month-picker.d.ts +2 -2
- package/dist/components/data-entry/month-picker.js +47 -10
- package/dist/components/data-entry/month-range-picker.d.ts +2 -2
- package/dist/components/data-entry/month-range-picker.js +51 -11
- package/dist/components/data-entry/number-input.d.ts +7 -0
- package/dist/components/data-entry/number-input.js +147 -98
- package/dist/components/data-entry/password-input.d.ts +1 -1
- package/dist/components/data-entry/radio.d.ts +1 -1
- package/dist/components/data-entry/radio.js +61 -13
- package/dist/components/data-entry/search-input.d.ts +1 -1
- package/dist/components/data-entry/search-input.js +16 -2
- package/dist/components/data-entry/search-select.d.ts +2 -2
- package/dist/components/data-entry/search-select.js +209 -78
- package/dist/components/data-entry/select.d.ts +22 -4
- package/dist/components/data-entry/select.js +215 -163
- package/dist/components/data-entry/slider.d.ts +9 -1
- package/dist/components/data-entry/slider.js +87 -9
- package/dist/components/data-entry/switch.d.ts +3 -0
- package/dist/components/data-entry/switch.js +29 -3
- package/dist/components/data-entry/textarea.d.ts +14 -56
- package/dist/components/data-entry/textarea.js +80 -33
- package/dist/components/data-entry/time-picker.d.ts +2 -2
- package/dist/components/data-entry/time-picker.js +333 -109
- package/dist/components/data-entry/time-range-picker.d.ts +5 -0
- package/dist/components/data-entry/time-range-picker.js +89 -0
- package/dist/components/data-entry/transfer.d.ts +1 -1
- package/dist/components/data-entry/transfer.js +99 -31
- package/dist/components/data-entry/tree-select-strategy.d.ts +1 -1
- package/dist/components/data-entry/tree-select.d.ts +1 -1
- package/dist/components/data-entry/tree-select.js +197 -92
- package/dist/components/data-entry/tree-utils.d.ts +1 -1
- package/dist/components/data-entry/tree-utils.js +7 -14
- package/dist/components/data-entry/upload-files.d.ts +2 -0
- package/dist/components/data-entry/upload-files.js +31 -0
- package/dist/components/data-entry/upload-request.d.ts +4 -0
- package/dist/components/data-entry/upload-request.js +53 -0
- package/dist/components/data-entry/upload-types.d.ts +28 -0
- package/dist/components/data-entry/upload-types.js +2 -0
- package/dist/components/data-entry/upload.d.ts +2 -2
- package/dist/components/data-entry/upload.js +482 -117
- package/dist/components/feedback/dialog.d.ts +105 -38
- package/dist/components/feedback/dialog.js +268 -193
- package/dist/components/feedback/overlay-close-focus.d.ts +31 -0
- package/dist/components/feedback/overlay-close-focus.js +31 -0
- package/dist/components/feedback/overlay-header-tone.d.ts +1 -1
- package/dist/components/feedback/sheet.d.ts +53 -11
- package/dist/components/feedback/sheet.js +150 -81
- package/dist/components/feedback/tooltip.d.ts +51 -7
- package/dist/components/feedback/tooltip.js +107 -25
- package/dist/components/general/button.d.ts +1 -1
- package/dist/components/general/button.js +7 -3
- package/dist/components/general/index.d.ts +1 -0
- package/dist/components/general/index.js +2 -0
- package/dist/components/general/logo.d.ts +17 -0
- package/dist/components/general/logo.js +22 -16
- package/dist/components/general/typography.d.ts +3 -0
- package/dist/components/general/typography.js +15 -1
- package/dist/components/general/visually-hidden.d.ts +3 -0
- package/dist/components/general/visually-hidden.js +9 -0
- package/dist/components/layout/app-launcher.d.ts +34 -0
- package/dist/components/layout/app-launcher.js +228 -0
- package/dist/components/layout/app-shell.d.ts +3 -1
- package/dist/components/layout/app-shell.js +69 -12
- package/dist/components/layout/aspect-ratio.js +0 -1
- package/dist/components/layout/auth-divider.js +0 -1
- package/dist/components/layout/breadcrumb.d.ts +14 -2
- package/dist/components/layout/breadcrumb.js +46 -4
- package/dist/components/layout/flex.d.ts +1 -1
- package/dist/components/layout/flex.js +41 -3
- package/dist/components/layout/index.d.ts +3 -0
- package/dist/components/layout/index.js +5 -1
- package/dist/components/layout/nav-surface.d.ts +26 -0
- package/dist/components/layout/nav-surface.js +17 -0
- package/dist/components/layout/org-switcher.d.ts +5 -1
- package/dist/components/layout/org-switcher.js +25 -4
- package/dist/components/layout/page-container.d.ts +1 -1
- package/dist/components/layout/page-container.js +5 -3
- package/dist/components/layout/responsive-grid.d.ts +16 -2
- package/dist/components/layout/responsive-grid.js +29 -2
- package/dist/components/layout/separator.js +0 -1
- package/dist/components/layout/sidebar.js +4 -1
- package/dist/components/layout/split-pane.d.ts +14 -1
- package/dist/components/layout/split-pane.js +26 -13
- package/dist/components/layout/topbar-item.d.ts +3 -0
- package/dist/components/layout/topbar-item.js +19 -5
- package/dist/components/layout/topbar.d.ts +1 -1
- package/dist/components/layout/topbar.js +27 -6
- package/dist/components/navigation/app-setting-picker.js +21 -2
- package/dist/components/navigation/app-setting-toggle.d.ts +16 -0
- package/dist/components/navigation/app-setting-toggle.js +96 -0
- package/dist/components/navigation/dropdown-menu.d.ts +227 -18
- package/dist/components/navigation/dropdown-menu.js +402 -118
- package/dist/components/navigation/index.d.ts +2 -0
- package/dist/components/navigation/index.js +2 -0
- package/dist/components/navigation/pagination-utils.d.ts +2 -1
- package/dist/components/navigation/pagination.d.ts +2 -2
- package/dist/components/navigation/pagination.js +155 -83
- package/dist/components/navigation/steps.d.ts +2 -2
- package/dist/components/navigation/steps.js +29 -4
- package/dist/components/navigation/tabs.d.ts +29 -16
- package/dist/components/navigation/tabs.js +374 -144
- package/dist/components/ui/accordion.d.ts +50 -5
- package/dist/components/ui/accordion.js +239 -33
- package/dist/components/ui/aspect-ratio.d.ts +23 -2
- package/dist/components/ui/aspect-ratio.js +15 -13
- package/dist/components/ui/avatar.d.ts +29 -4
- package/dist/components/ui/avatar.js +111 -25
- package/dist/components/ui/hover-card.d.ts +42 -4
- package/dist/components/ui/hover-card.js +183 -27
- package/dist/components/ui/input-otp.d.ts +26 -28
- package/dist/components/ui/input-otp.js +50 -7
- package/dist/components/ui/label.js +0 -1
- package/dist/components/ui/password-input.d.ts +36 -2
- package/dist/components/ui/password-input.js +27 -7
- package/dist/components/ui/rating.d.ts +25 -0
- package/dist/components/ui/rating.js +67 -27
- package/dist/components/ui/segmented.d.ts +12 -3
- package/dist/components/ui/segmented.js +17 -2
- package/dist/components/ui/separator.d.ts +12 -2
- package/dist/components/ui/separator.js +30 -9
- package/dist/components/ui/tag-input.d.ts +45 -0
- package/dist/components/ui/tag-input.js +109 -27
- package/dist/components/ui/toggle-group.d.ts +50 -5
- package/dist/components/ui/toggle-group.js +79 -20
- package/dist/components/ui/toggle.d.ts +31 -5
- package/dist/components/ui/toggle.js +42 -3
- package/dist/form/form-context.d.ts +29 -0
- package/dist/form/form-context.js +44 -4
- package/dist/form/form-field-array.d.ts +22 -0
- package/dist/form/form-field-array.js +50 -0
- package/dist/form/form-field-control.d.ts +2 -1
- package/dist/form/form-field-control.js +116 -40
- package/dist/form/form-root.d.ts +2 -1
- package/dist/form/form-root.js +142 -23
- package/dist/form/index.d.ts +3 -1
- package/dist/form/index.js +12 -1
- package/dist/i18n/messages/en.json +64 -5
- package/dist/i18n/messages/ja.json +64 -5
- package/dist/i18n/messages/vi.json +64 -5
- package/dist/inertia/index.d.ts +20 -0
- package/dist/inertia/index.js +60 -1
- package/dist/lib/control-styles.d.ts +25 -3
- package/dist/lib/control-styles.js +9 -3
- package/dist/lib/datetime/picker-format.d.ts +8 -0
- package/dist/lib/datetime/picker-format.js +54 -0
- package/dist/lib/slot.d.ts +32 -0
- package/dist/lib/slot.js +22 -0
- package/dist/lib/variants.d.ts +22 -3
- package/dist/lib/variants.js +56 -1
- package/dist/props/components/app.prop.d.ts +25 -1
- package/dist/props/components/charts.prop.d.ts +21 -0
- package/dist/props/components/data-display.prop.d.ts +72 -4
- package/dist/props/components/data-entry.prop.d.ts +718 -46
- package/dist/props/components/form.prop.d.ts +142 -4
- package/dist/props/components/general.prop.d.ts +17 -1
- package/dist/props/components/index.d.ts +3 -3
- package/dist/props/components/layout.prop.d.ts +281 -11
- package/dist/props/components/navigation.prop.d.ts +114 -4
- package/dist/props/registry.d.ts +397 -11
- package/dist/props/registry.js +526 -11
- package/dist/props/vocabulary/content.prop.d.ts +1 -1
- package/dist/props/vocabulary/data.prop.d.ts +179 -1
- package/dist/props/vocabulary/index.d.ts +5 -5
- package/dist/props/vocabulary/interaction.prop.d.ts +68 -3
- package/dist/props/vocabulary/layout.prop.d.ts +57 -1
- package/dist/props/vocabulary/navigation.prop.d.ts +40 -0
- package/dist/props/vocabulary/shared.prop.d.ts +33 -0
- package/dist/styles/badge-layout.css +2 -1
- package/dist/styles/card-layout.css +26 -4
- package/dist/styles/chart-layout.css +15 -0
- package/dist/styles/control.css +657 -15
- package/dist/styles/core.css +5 -2
- package/dist/styles/data-display-layout.css +313 -6
- package/dist/styles/data-entry-layout.css +13 -0
- package/dist/styles/focus-ring.css +4 -0
- package/dist/styles/index.css +5 -2
- package/dist/styles/layout.css +327 -4
- package/dist/styles/navigation-layout.css +227 -0
- package/dist/styles/shell-layout.css +369 -13
- package/dist/styles/table-layout.css +95 -6
- package/dist/styles/text-layout.css +10 -4
- package/dist/tokens/base.css +2 -1
- package/dist/tokens/components/badge.css +1 -0
- package/dist/tokens/components/chart.css +6 -0
- package/dist/tokens/components/control.css +79 -2
- package/dist/tokens/components/data-display.css +20 -0
- package/dist/tokens/components/descriptions.css +11 -0
- package/dist/tokens/components/flex.css +8 -0
- package/dist/tokens/components/navigation.css +55 -0
- package/dist/tokens/components/shell.css +53 -3
- package/dist/tokens/components/table.css +14 -0
- package/dist/tokens/foundation.css +2 -0
- package/dist/tokens/semantic/layout.css +9 -0
- package/docs/COMPONENTS.md +9 -3
- package/docs/CONSUMER-RULES.md +13 -0
- package/docs/DESIGN-AUTHORITY.md +145 -83
- package/docs/DEVELOPMENT.md +4 -3
- package/docs/FORMS.md +151 -6
- package/docs/FRAME-COVERAGE-REPORT.md +32 -17
- package/docs/README.md +15 -15
- package/docs/STANDARDS-vocabulary-tokens.md +1 -1
- package/docs/TESTING.md +15 -6
- package/docs/WHAT-BELONGS-HERE.md +179 -0
- package/docs/charts/cjk-category-axis.tsx +81 -0
- package/docs/data-display/card/index.tsx +22 -0
- package/docs/data-display/data-table/examples/antd-parity.tsx +257 -0
- package/docs/data-display/data-table/index.tsx +23 -0
- package/docs/data-display/descriptions.tsx +41 -0
- package/docs/data-display/legend.tsx +145 -0
- package/docs/data-display/progress.tsx +32 -0
- package/docs/data-display/scroll-area.tsx +31 -25
- package/docs/data-display/table.tsx +104 -45
- package/docs/data-display/timeline.tsx +41 -0
- package/docs/data-entry/date-picker.tsx +85 -0
- package/docs/data-entry/date-range-picker.tsx +26 -0
- package/docs/data-entry/form-dynamic-fields.tsx +199 -0
- package/docs/data-entry/form.tsx +74 -4
- package/docs/data-entry/input-otp.tsx +24 -0
- package/docs/data-entry/input.tsx +46 -1
- package/docs/data-entry/month-range-picker.tsx +1 -1
- package/docs/data-entry/segmented.tsx +1 -1
- package/docs/data-entry/select.tsx +29 -2
- package/docs/data-entry/switch.tsx +41 -0
- package/docs/data-entry/textarea.tsx +38 -0
- package/docs/data-entry/time-picker.tsx +85 -1
- package/docs/data-entry/time-range-picker.tsx +55 -0
- package/docs/data-entry/transfer.tsx +17 -0
- package/docs/data-entry/upload.tsx +56 -12
- package/docs/feedback/sheet.tsx +1 -1
- package/docs/feedback/tooltip.tsx +1 -1
- package/docs/general/activity.tsx +2 -2
- package/docs/general/button/index.tsx +14 -1
- package/docs/general/typography.tsx +14 -1
- package/docs/layout/app-launcher.tsx +151 -0
- package/docs/layout/app-shell-arrangements.tsx +225 -0
- package/docs/layout/app-shell.tsx +11 -0
- package/docs/layout/aspect-ratio.tsx +1 -1
- package/docs/layout/flex.tsx +50 -0
- package/docs/layout/responsive-grid.tsx +21 -1
- package/docs/layout/topbar.tsx +5 -9
- package/docs/navigation/app-setting-picker.tsx +37 -1
- package/docs/navigation/app-setting-toggle.tsx +111 -0
- package/docs/navigation/breadcrumb.tsx +48 -0
- package/docs/navigation/dropdown-menu.tsx +21 -0
- package/docs/navigation/pagination.tsx +52 -0
- package/docs/navigation/steps.tsx +37 -0
- package/docs/navigation/tabs.tsx +97 -1
- package/docs/query/button-refetch.tsx +1 -0
- package/package.json +28 -8
- package/scripts/_agent-setup.mjs +182 -3
- package/scripts/consumer-rule.md +98 -0
- package/scripts/guinea-pig-skill.md +322 -0
- package/scripts/init-guinea-pig.mjs +84 -0
- package/scripts/postinstall.mjs +13 -2
- package/scripts/ui-audit.mjs +351 -37
- /package/dist/tokens/{antd.generated.css → derived.css} +0 -0
package/scripts/ui-audit.mjs
CHANGED
|
@@ -9,13 +9,22 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
|
9
9
|
import { isAbsolute, join, relative } from "node:path";
|
|
10
10
|
|
|
11
11
|
const CWD = process.cwd();
|
|
12
|
+
const SELF = (() => {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(readFileSync(join(CWD, "package.json"), "utf8")).name === "@godxjp/ui";
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
})();
|
|
12
19
|
const args = process.argv.slice(2);
|
|
13
20
|
const asJson = args.includes("--format") && args[args.indexOf("--format") + 1] === "json";
|
|
14
21
|
const quiet = args.includes("--quiet");
|
|
15
22
|
const dirArgs = args.filter((a) => !a.startsWith("--") && a !== "json");
|
|
16
23
|
const SCAN_DIRS = dirArgs.length
|
|
17
24
|
? dirArgs
|
|
18
|
-
:
|
|
25
|
+
: SELF
|
|
26
|
+
? ["src", "docs"]
|
|
27
|
+
: ["resources/js/components", "resources/js/pages", "resources/js/layouts"];
|
|
19
28
|
|
|
20
29
|
const PALETTE =
|
|
21
30
|
"red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|gray|grey|slate|zinc|neutral|stone";
|
|
@@ -29,7 +38,29 @@ const EMOJI = /\p{Extended_Pictographic}/u;
|
|
|
29
38
|
const EMOJI_FLAG = /\p{Regional_Indicator}/u;
|
|
30
39
|
|
|
31
40
|
/**
|
|
32
|
-
*
|
|
41
|
+
* The attribute run of a JSX OPEN TAG, for rules that must see the whole element rather than one
|
|
42
|
+
* class name. Two things a plain `[^>]*` gets wrong, both of them the "green because it measured
|
|
43
|
+
* nothing" shape:
|
|
44
|
+
*
|
|
45
|
+
* 1. `[^>]` stops at the `>` of an arrow function, so `<Button onClick={() => go()} size="icon">`
|
|
46
|
+
* was invisible while the identical `<Button size="icon" onClick={() => go()}>` was flagged.
|
|
47
|
+
* A rule that depends on ATTRIBUTE ORDER is a rule an author flips by moving a prop — the same
|
|
48
|
+
* defect `no-hand-rolled-surface` had with CLASS order. `(?:=>[^>]*)*` lets the run step over
|
|
49
|
+
* an arrow and keep going; the tag's own `>` still ends it.
|
|
50
|
+
* 2. The scanner below feeds rules ONE LINE at a time, and prettier wraps any element wider than
|
|
51
|
+
* printWidth onto several lines. Every rule anchored on `<Tag …>` therefore only ever saw
|
|
52
|
+
* single-line elements. Rules carrying `spansElement: true` are matched against the whole file
|
|
53
|
+
* instead (line number derived from the match offset), so wrapping no longer hides a violation.
|
|
54
|
+
*/
|
|
55
|
+
const ATTRS = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|=>|[^>"'])*`;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @type {{id:string, severity:'error'|'warn', test:RegExp, message:string, standard?:string,
|
|
59
|
+
* exempt?:RegExp, classOnly?:boolean}[]}
|
|
60
|
+
*
|
|
61
|
+
* `classOnly` marks a rule whose pattern is a CLASS NAME (`gap-3`, `bg-red-500`, `pr-4`). Those are
|
|
62
|
+
* matched against the class-expression mask (see `classExpressionsOnly`), never the raw line, so
|
|
63
|
+
* product copy, an i18n value or a JSX text node that merely spells a utility is not a finding.
|
|
33
64
|
*
|
|
34
65
|
* `exempt` is an optional SECOND escape a rule may declare, matched against the ORIGINAL (un-blanked)
|
|
35
66
|
* line or the one above it. `ui-audit-disable-line <id>` silences any rule; an `exempt` marker is for
|
|
@@ -45,6 +76,7 @@ const EMOJI_FLAG = /\p{Regional_Indicator}/u;
|
|
|
45
76
|
const RULES = [
|
|
46
77
|
{
|
|
47
78
|
id: "no-utility-spacing",
|
|
79
|
+
classOnly: true,
|
|
48
80
|
scope: "consumer",
|
|
49
81
|
severity: "error",
|
|
50
82
|
// Any Tailwind spacing step on the consumer's own markup. Arbitrary values ([13px]) have their
|
|
@@ -70,18 +102,38 @@ const RULES = [
|
|
|
70
102
|
severity: "warn",
|
|
71
103
|
// rounded + border/bg on the consumer's own element = a fake Card / Badge / Avatar / ListRow
|
|
72
104
|
// that drifts from the real ones (a 38px account pill beside 32px controls).
|
|
73
|
-
|
|
105
|
+
//
|
|
106
|
+
// ORDER-INDEPENDENT, and it has to be: the old single regex required `rounded` to appear
|
|
107
|
+
// BEFORE `border`/`bg-`, so the identical element passed or failed depending on how the
|
|
108
|
+
// classes happened to be sorted — and prettier's Tailwind plugin sorts them. A `bg-current …
|
|
109
|
+
// rounded-full` dot was clean, `prettier --write` reordered it to `rounded-full bg-current`,
|
|
110
|
+
// and the warning appeared with no source change. A rule a formatter can flip is a rule
|
|
111
|
+
// nobody can trust.
|
|
112
|
+
test: (line) => {
|
|
113
|
+
const m = line.match(/className=(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`)/);
|
|
114
|
+
if (!m) return false;
|
|
115
|
+
const cls = m[1] ?? m[2] ?? m[3] ?? "";
|
|
116
|
+
// A MARKER is not a surface. `size-1` is 4px — a dot, a pip, a status bead. The primitives
|
|
117
|
+
// this rule points at (Card, Badge, Avatar, ListRow, Descriptions, EmptyState) all start at
|
|
118
|
+
// control height, so none of them can express one, and flagging it sends the reader looking
|
|
119
|
+
// for a component that does not exist. The cut-off is `size-2` / 8px: below that there is no
|
|
120
|
+
// room for the padding and type that make something a surface.
|
|
121
|
+
if (/\b(?:size|[wh])-(?:0\.5|1|1\.5|2)\b/.test(cls)) return false;
|
|
122
|
+
return /\brounded(?:-(?:full|sm|md|lg|xl|2xl))?\b/.test(cls) && /\b(?:border|bg-)/.test(cls);
|
|
123
|
+
},
|
|
74
124
|
message:
|
|
75
125
|
"Hand-rolled surface (rounded + border/bg) — use Card, Badge, Avatar, ListRow, Descriptions or EmptyState so height, padding and radius come from the tokens (docs/CONSUMER-RULES.md §4).",
|
|
76
126
|
},
|
|
77
127
|
{
|
|
78
128
|
id: "no-space-xy",
|
|
129
|
+
classOnly: true,
|
|
79
130
|
severity: "error",
|
|
80
131
|
test: /\bspace-[xy]-\d/,
|
|
81
132
|
message: "Use <Stack>/<Inline> with gap, not space-x/y-* (rules §5).",
|
|
82
133
|
},
|
|
83
134
|
{
|
|
84
135
|
id: "no-raw-palette-color",
|
|
136
|
+
classOnly: true,
|
|
85
137
|
severity: "error",
|
|
86
138
|
test: new RegExp(
|
|
87
139
|
`\\b(bg|text|border|ring|fill|stroke|from|to|via|divide|outline)-(${PALETTE})-\\d`,
|
|
@@ -92,11 +144,15 @@ const RULES = [
|
|
|
92
144
|
{
|
|
93
145
|
id: "status-tone-not-variant",
|
|
94
146
|
severity: "error",
|
|
147
|
+
spansElement: true,
|
|
95
148
|
// Only the tone-driven status components (Badge/Tag/StatCard) are wrong here — they expose a
|
|
96
149
|
// `tone` prop and reserve `variant` for STRUCTURE (default|secondary|outline). Button, Alert,
|
|
97
150
|
// DropdownMenuItem, ContextMenuItem, AlertDialog etc. legitimately use `variant` for emphasis,
|
|
98
151
|
// so they must NOT be flagged.
|
|
99
|
-
test:
|
|
152
|
+
test: new RegExp(
|
|
153
|
+
`<(?:Badge|Tag|StatCard)\\b${ATTRS}\\bvariant=["'](?:success|warning|destructive|info|neutral)["']`,
|
|
154
|
+
"g",
|
|
155
|
+
),
|
|
100
156
|
message:
|
|
101
157
|
"Badge/Tag/StatCard status uses tone, not variant (variant is structural: default|secondary|outline). Use tone='success|warning|destructive|info|neutral'.",
|
|
102
158
|
},
|
|
@@ -110,12 +166,18 @@ const RULES = [
|
|
|
110
166
|
{
|
|
111
167
|
id: "value-callback-on-value-change",
|
|
112
168
|
severity: "error",
|
|
113
|
-
|
|
169
|
+
spansElement: true,
|
|
170
|
+
test: new RegExp(
|
|
171
|
+
"<(?:Checkbox\\.Group|Upload|Cascader|TreeSelect|Transfer|Select|SearchSelect|DatePicker|" +
|
|
172
|
+
`DateRangePicker|TimePicker|ColorPicker|LocalePicker|TimezonePicker|DateFormatPicker|TimeFormatPicker)\\b${ATTRS}\\bonChange=`,
|
|
173
|
+
"g",
|
|
174
|
+
),
|
|
114
175
|
message:
|
|
115
176
|
"Abstract value components use onValueChange, not onChange. Reserve onChange for DOM events.",
|
|
116
177
|
},
|
|
117
178
|
{
|
|
118
179
|
id: "no-arbitrary-hex",
|
|
180
|
+
classOnly: true,
|
|
119
181
|
severity: "error",
|
|
120
182
|
test: /(bg|text|border|ring|fill|stroke|from|to|via)-\[#[0-9a-fA-F]{3,8}\]/,
|
|
121
183
|
message: "No hardcoded hex colors in className; use design-system tokens (rules §4).",
|
|
@@ -125,6 +187,7 @@ const RULES = [
|
|
|
125
187
|
// (they start with a letter), so token-driven values stay legal (rules §4–§5).
|
|
126
188
|
{
|
|
127
189
|
id: "no-arbitrary-spacing",
|
|
190
|
+
classOnly: true,
|
|
128
191
|
severity: "error",
|
|
129
192
|
test: /\b(p|px|py|pt|pb|pl|pr|pe|ps|m|mx|my|mt|mb|ml|mr|me|ms|gap|gap-x|gap-y|inset|inset-x|inset-y|top|right|bottom|left)-\[-?\.?\d/,
|
|
130
193
|
message:
|
|
@@ -132,6 +195,7 @@ const RULES = [
|
|
|
132
195
|
},
|
|
133
196
|
{
|
|
134
197
|
id: "no-arbitrary-size",
|
|
198
|
+
classOnly: true,
|
|
135
199
|
severity: "error",
|
|
136
200
|
// `min-w-[…]` / `min-h-[…]` are allowed: a MINIMUM dimension is the legit responsive
|
|
137
201
|
// pattern (horizontal-scroll tables `<Table className="min-w-[720px]">`, collapse
|
|
@@ -143,6 +207,7 @@ const RULES = [
|
|
|
143
207
|
},
|
|
144
208
|
{
|
|
145
209
|
id: "no-arbitrary-typography",
|
|
210
|
+
classOnly: true,
|
|
146
211
|
severity: "error",
|
|
147
212
|
test: /\b(text|leading|tracking|font)-\[-?\.?\d/,
|
|
148
213
|
message:
|
|
@@ -150,6 +215,7 @@ const RULES = [
|
|
|
150
215
|
},
|
|
151
216
|
{
|
|
152
217
|
id: "no-arbitrary-radius",
|
|
218
|
+
classOnly: true,
|
|
153
219
|
severity: "error",
|
|
154
220
|
test: /\brounded(?:-[a-z]+)?-\[-?\.?\d/,
|
|
155
221
|
message: "No arbitrary radius (rounded-[6px]…). Use rounded-sm/md/lg radius tokens (rules §4).",
|
|
@@ -188,6 +254,7 @@ const RULES = [
|
|
|
188
254
|
},
|
|
189
255
|
{
|
|
190
256
|
id: "no-raw-select",
|
|
257
|
+
scope: "consumer-control",
|
|
191
258
|
severity: "error",
|
|
192
259
|
test: /<select[\s>]/,
|
|
193
260
|
message: "Use <Select> from @godxjp/ui, not a raw <select> (rules §3).",
|
|
@@ -200,18 +267,22 @@ const RULES = [
|
|
|
200
267
|
},
|
|
201
268
|
{
|
|
202
269
|
id: "no-raw-textarea",
|
|
270
|
+
scope: "consumer-control",
|
|
203
271
|
severity: "warn",
|
|
204
272
|
test: /<textarea[\s>]/,
|
|
205
273
|
message: "Use <Textarea> from @godxjp/ui, not a raw <textarea> (rules §3).",
|
|
206
274
|
},
|
|
207
275
|
{
|
|
208
276
|
id: "no-raw-input",
|
|
277
|
+
scope: "consumer-control",
|
|
209
278
|
severity: "error",
|
|
210
|
-
|
|
279
|
+
spansElement: true,
|
|
280
|
+
test: new RegExp(`<input\\b(?!${ATTRS}\\btype=["']hidden["'])${ATTRS}>`, "g"),
|
|
211
281
|
message: "Use <Input> from @godxjp/ui, not a raw <input> (rules §3).",
|
|
212
282
|
},
|
|
213
283
|
{
|
|
214
284
|
id: "no-raw-button",
|
|
285
|
+
scope: "consumer-control",
|
|
215
286
|
severity: "error",
|
|
216
287
|
test: /<button[\s>]/,
|
|
217
288
|
message: "Use <Button> from @godxjp/ui, not a raw <button> (rules §3).",
|
|
@@ -219,12 +290,14 @@ const RULES = [
|
|
|
219
290
|
{
|
|
220
291
|
id: "card-manual-padding",
|
|
221
292
|
severity: "error",
|
|
222
|
-
|
|
293
|
+
spansElement: true,
|
|
294
|
+
test: new RegExp(`<Card\\b${ATTRS}\\bp-[1-9]`, "g"),
|
|
223
295
|
message:
|
|
224
296
|
"Don't hand-roll padding on <Card> (className='p-4'…) — wrap the body in <CardContent>. (p-0 for a full-bleed table is fine.)",
|
|
225
297
|
},
|
|
226
298
|
{
|
|
227
299
|
id: "no-dark-color-override",
|
|
300
|
+
classOnly: true,
|
|
228
301
|
severity: "warn",
|
|
229
302
|
test: /\bdark:(bg|text|border|ring|fill|stroke)-/,
|
|
230
303
|
message: "Don't add dark: color overrides — semantic tokens already adapt (rules §4).",
|
|
@@ -232,7 +305,8 @@ const RULES = [
|
|
|
232
305
|
{
|
|
233
306
|
id: "manual-field-error",
|
|
234
307
|
severity: "warn",
|
|
235
|
-
|
|
308
|
+
spansElement: true,
|
|
309
|
+
test: new RegExp(`<p${ATTRS}className="[^"]*text-(?:xs|sm)[^"]*text-destructive`, "g"),
|
|
236
310
|
message:
|
|
237
311
|
"Field errors should use <FormField error=…>, not a hand-rolled <p> (rules §1). OK only for checkbox/radio groups.",
|
|
238
312
|
},
|
|
@@ -245,6 +319,7 @@ const RULES = [
|
|
|
245
319
|
},
|
|
246
320
|
{
|
|
247
321
|
id: "raw-white-black",
|
|
322
|
+
classOnly: true,
|
|
248
323
|
severity: "warn",
|
|
249
324
|
test: /\b(bg|text|border)-(white|black)\b/,
|
|
250
325
|
message:
|
|
@@ -270,10 +345,12 @@ const RULES = [
|
|
|
270
345
|
},
|
|
271
346
|
{
|
|
272
347
|
id: "no-physical-direction",
|
|
348
|
+
classOnly: true,
|
|
273
349
|
severity: "warn",
|
|
274
350
|
// Physical-edge utilities break RTL. Logical equivalents: ms-/me-/ps-/pe-, start-/end-,
|
|
275
351
|
// text-start/end, border-s/e, rounded-s/e. `-mx-`/`-px-` (both edges) are RTL-safe → not matched.
|
|
276
|
-
|
|
352
|
+
exempt: /rtl-ignore:\s*\S.{10,}/,
|
|
353
|
+
test: /(?<![\w-])-?(ml|mr|pl|pr|left|right)-(?:\d|\[|auto|px|full)|(?<![\w-])(?:rounded-[tb]?[lr]|border-[lr]|text-(?:left|right))\b/,
|
|
277
354
|
standard: "W3C CSS Logical Properties & Values L1 · WCAG 2.2 (1.3.2 / reflow)",
|
|
278
355
|
message:
|
|
279
356
|
"Use LOGICAL direction utilities for RTL safety: ms-/me-/ps-/pe-, start-/end-, text-start/end, border-s/e, rounded-s/e — not physical ml-/mr-/pl-/pr-/left-/right-/text-left|right.",
|
|
@@ -281,9 +358,13 @@ const RULES = [
|
|
|
281
358
|
{
|
|
282
359
|
id: "icon-button-needs-name",
|
|
283
360
|
severity: "warn",
|
|
361
|
+
spansElement: true,
|
|
284
362
|
// An icon-only Button (size="icon") with no author-supplied accessible name. A combobox/icon
|
|
285
363
|
// button's name is computed from author (aria-label / aria-labelledby / title), not glyph content.
|
|
286
|
-
test:
|
|
364
|
+
test: new RegExp(
|
|
365
|
+
`<Button\\b(?=${ATTRS}\\bsize=["']icon["'])(?!${ATTRS}\\b(?:aria-label|aria-labelledby|title)=)${ATTRS}>`,
|
|
366
|
+
"g",
|
|
367
|
+
),
|
|
287
368
|
standard: "WCAG 2.2 SC 4.1.2 · 1.1.1 · WAI-ARIA 1.2",
|
|
288
369
|
message:
|
|
289
370
|
"Icon-only <Button size=\"icon\"> needs an accessible name — add aria-label={t('…')}. The icon is decorative (aria-hidden); the name comes from the author, not the glyph.",
|
|
@@ -291,7 +372,8 @@ const RULES = [
|
|
|
291
372
|
{
|
|
292
373
|
id: "img-needs-alt",
|
|
293
374
|
severity: "warn",
|
|
294
|
-
|
|
375
|
+
spansElement: true,
|
|
376
|
+
test: new RegExp(`<img\\b(?!${ATTRS}\\balt=)${ATTRS}>`, "g"),
|
|
295
377
|
standard: "WCAG 2.2 SC 1.1.1 · HTML Living Standard (WHATWG)",
|
|
296
378
|
message:
|
|
297
379
|
'Every <img> needs an alt attribute (alt="" for purely decorative images). Prefer the <Avatar>/<AspectRatio> primitives for product imagery.',
|
|
@@ -306,9 +388,14 @@ const RULES = [
|
|
|
306
388
|
},
|
|
307
389
|
{
|
|
308
390
|
id: "hardcoded-currency",
|
|
391
|
+
matches: currencyMatches,
|
|
309
392
|
severity: "warn",
|
|
310
393
|
// A currency glyph glued to an interpolation in JSX text: `>¥{amount}` / `>{x}円`.
|
|
311
|
-
|
|
394
|
+
spansElement: true,
|
|
395
|
+
test: new RegExp(
|
|
396
|
+
`<(?:[A-Za-z][\\w.:]*(?:\\s${ATTRS})?)?(?<!=)>[^<>{}]*[¥$€£₫]\\s*\\{|\\}\\s*円\\s*</`,
|
|
397
|
+
"g",
|
|
398
|
+
),
|
|
312
399
|
standard: "ISO 4217 · ECMA-402 Intl.NumberFormat",
|
|
313
400
|
message:
|
|
314
401
|
"Don't hand-format currency (¥{amount}). Use Intl.NumberFormat(locale, { style: 'currency', currency }) — ISO 4217 code drives the symbol and minor units per locale.",
|
|
@@ -339,7 +426,12 @@ const RULES = [
|
|
|
339
426
|
id: "no-em-dash-in-copy",
|
|
340
427
|
severity: "warn",
|
|
341
428
|
// Em-dash (U+2014) in JSX text — the reference design uses the middot `·` for JP/EN pairs and calm copy.
|
|
342
|
-
|
|
429
|
+
spansElement: true,
|
|
430
|
+
test: new RegExp(
|
|
431
|
+
`<(?:[A-Za-z][\\w.:]*(?:\\s${ATTRS})?)?>[^<>]*[A-Za-z0-9-ヿ一-鿿]\\s*—\\s*[A-Za-z0-9-ヿ一-鿿]|` +
|
|
432
|
+
String.raw`\b(?:label|title|placeholder|description|aria-label)=["'][^"']*[A-Za-z0-9-ヿ一-鿿]\s*—\s*[A-Za-z0-9-ヿ一-鿿][^"']*["']`,
|
|
433
|
+
"g",
|
|
434
|
+
),
|
|
343
435
|
standard: "@godxjp/ui reference-design typography (best-ux) · Unicode punctuation",
|
|
344
436
|
message:
|
|
345
437
|
"No em-dash (—) in product copy. Use a middot `·` for JP/EN label pairs, or restructure into two calm sentences. Keep copy factual and quiet.",
|
|
@@ -423,6 +515,128 @@ function stripComments(src) {
|
|
|
423
515
|
return out;
|
|
424
516
|
}
|
|
425
517
|
|
|
518
|
+
/** End index (inclusive) of the string / template literal opening at `i`. `${…}` is stepped over as
|
|
519
|
+
* a brace group, so a quote nested inside an interpolation never closes the template early. */
|
|
520
|
+
function endOfString(src, i) {
|
|
521
|
+
const quote = src[i];
|
|
522
|
+
for (let j = i + 1; j < src.length; j++) {
|
|
523
|
+
const c = src[j];
|
|
524
|
+
if (c === "\\") j++;
|
|
525
|
+
else if (quote === "`" && c === "$" && src[j + 1] === "{") {
|
|
526
|
+
const close = matchBracket(src, j + 1);
|
|
527
|
+
if (close < 0) return src.length - 1;
|
|
528
|
+
j = close;
|
|
529
|
+
} else if (c === quote) return j;
|
|
530
|
+
}
|
|
531
|
+
return src.length - 1;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** End index (inclusive) of the bracket group opening at `i`, or -1 when it never closes. */
|
|
535
|
+
function matchBracket(src, i) {
|
|
536
|
+
const CLOSER = { "{": "}", "[": "]", "(": ")" };
|
|
537
|
+
const stack = [CLOSER[src[i]]];
|
|
538
|
+
for (let j = i + 1; j < src.length; j++) {
|
|
539
|
+
const c = src[j];
|
|
540
|
+
if (c === '"' || c === "'" || c === "`") j = endOfString(src, j);
|
|
541
|
+
else if (CLOSER[c]) stack.push(CLOSER[c]);
|
|
542
|
+
else if (c === stack[stack.length - 1]) {
|
|
543
|
+
stack.pop();
|
|
544
|
+
if (stack.length === 0) return j;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return -1;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** The value that starts at `i`: a literal or bracket group in full, otherwise the rest of the line
|
|
551
|
+
* (`= base + " p-2"`, a ternary) — enough to see the classes, never the next statement. */
|
|
552
|
+
function valueEnd(src, i) {
|
|
553
|
+
const c = src[i];
|
|
554
|
+
if (c === '"' || c === "'" || c === "`") return endOfString(src, i);
|
|
555
|
+
if (c === "{" || c === "[" || c === "(") {
|
|
556
|
+
const close = matchBracket(src, i);
|
|
557
|
+
return close < 0 ? src.length - 1 : close;
|
|
558
|
+
}
|
|
559
|
+
const nl = src.indexOf("\n", i);
|
|
560
|
+
return nl < 0 ? src.length - 1 : nl - 1;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** A call that BUILDS a class list — its arguments are classes wherever they are written. */
|
|
564
|
+
const CLASS_HELPER = /(?<![\w$.])(?:cn|clsx|classnames|classNames|cx|cva|tv|twMerge|twJoin)\s*\(/g;
|
|
565
|
+
/** `className={…}` / `wrapperClass = "…"` / `const flexGapClass: Record<…> = {…}` — an assignment
|
|
566
|
+
* (JSX attribute, variable, default) whose NAME says class. The optional `: …` is a type annotation. */
|
|
567
|
+
const CLASS_ASSIGN = /(?<![\w$.])([A-Za-z_$][\w$]*)\s*(?::[^=;\n]*?)?=(?!=)\s*/g;
|
|
568
|
+
/** `className: "…"` — the same, written as an object key (a DataTable column, a slots map). */
|
|
569
|
+
const CLASS_KEY = /(?<![\w$.])([A-Za-z_$][\w$]*)\s*:\s*/g;
|
|
570
|
+
/**
|
|
571
|
+
* The names a class list is actually bound to, as a SUFFIX so every prefix works: `className`,
|
|
572
|
+
* `rowClassName`, `baseClass`, `cellClasses` — plus the three conventional aliases for a class MAP,
|
|
573
|
+
* `statusStyles` / `badgeVariants` / `toneVariant`, which are how a palette of class strings is
|
|
574
|
+
* written when it is not inside `cva()`. A class list bound to a name outside this set (`const map =
|
|
575
|
+
* { … }`) is NOT scanned — that is the cost of the mask, and the reason the set errs wide.
|
|
576
|
+
*/
|
|
577
|
+
const CLASS_NAMED = /(?:class(?:es|name|names)?|styles?|variants?)$/i;
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Blank everything that is NOT a class expression, keeping newlines so line numbers stay true.
|
|
581
|
+
*
|
|
582
|
+
* The `classOnly` rules are plain regexes over source text, so before this pass any line that merely
|
|
583
|
+
* CONTAINED a utility-shaped word was reported: product copy, an i18n value, a test fixture, a JSX
|
|
584
|
+
* text node (`<Text>văn bản JSX nhắc gap-3</Text>`). The bar is zero errors, so a false positive is
|
|
585
|
+
* not a nuisance — it blocks the review. What survives the mask is the three places a class can
|
|
586
|
+
* actually be written: a class-named binding's value, a class-named object key's value, and the
|
|
587
|
+
* arguments of a class-building helper. The NAME stays inside the region, so rules that anchor on
|
|
588
|
+
* `className="` keep matching.
|
|
589
|
+
*/
|
|
590
|
+
function classExpressionsOnly(src) {
|
|
591
|
+
const keep = new Uint8Array(src.length);
|
|
592
|
+
const mark = (from, to) => {
|
|
593
|
+
for (let i = Math.max(from, 0); i <= Math.min(to, src.length - 1); i++) keep[i] = 1;
|
|
594
|
+
};
|
|
595
|
+
for (const m of src.matchAll(CLASS_HELPER)) {
|
|
596
|
+
const close = matchBracket(src, m.index + m[0].length - 1);
|
|
597
|
+
mark(m.index, close < 0 ? src.length - 1 : close);
|
|
598
|
+
}
|
|
599
|
+
for (const pattern of [CLASS_ASSIGN, CLASS_KEY]) {
|
|
600
|
+
for (const m of src.matchAll(pattern)) {
|
|
601
|
+
if (!CLASS_NAMED.test(m[1])) continue;
|
|
602
|
+
mark(m.index, valueEnd(src, m.index + m[0].length));
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
const out = Array.from(src, (c, i) => (keep[i] || c === "\n" ? c : " "));
|
|
606
|
+
return out.join("");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** `ui-audit-disable-begin <rule> — <why>` … `ui-audit-disable-end <rule>`: ONE marker for a whole
|
|
610
|
+
* region instead of one per line (prettier wrapping a 24-item literal used to cost 24 identical
|
|
611
|
+
* comments, so the formatter was being bent to satisfy the linter). The reason is MANDATORY — a
|
|
612
|
+
* marker without `— <12+ chars>` is ignored and the finding stands, so an opt-out always says why.
|
|
613
|
+
* A block that is never closed runs to the end of the file. */
|
|
614
|
+
const DISABLE_BEGIN = /ui-audit-disable-begin\s+([a-z][a-z0-9-]*)\s*[—:]\s*(\S[^\n]{11,})/;
|
|
615
|
+
const DISABLE_END = /ui-audit-disable-end\s+([a-z][a-z0-9-]*)/;
|
|
616
|
+
|
|
617
|
+
/** @returns {(ruleId: string, lineIndex: number) => boolean} */
|
|
618
|
+
function blockSuppressions(lines) {
|
|
619
|
+
/** @type {Map<string, {from:number, to:number}[]>} */
|
|
620
|
+
const ranges = new Map();
|
|
621
|
+
/** @type {Map<string, number>} */
|
|
622
|
+
const open = new Map();
|
|
623
|
+
const close = (ruleId, to) => {
|
|
624
|
+
const from = open.get(ruleId);
|
|
625
|
+
open.delete(ruleId);
|
|
626
|
+
if (!ranges.has(ruleId)) ranges.set(ruleId, []);
|
|
627
|
+
ranges.get(ruleId).push({ from, to });
|
|
628
|
+
};
|
|
629
|
+
lines.forEach((line, i) => {
|
|
630
|
+
const begin = DISABLE_BEGIN.exec(line);
|
|
631
|
+
if (begin && !open.has(begin[1])) open.set(begin[1], i);
|
|
632
|
+
const end = DISABLE_END.exec(line);
|
|
633
|
+
if (end && open.has(end[1])) close(end[1], i);
|
|
634
|
+
});
|
|
635
|
+
for (const ruleId of [...open.keys()]) close(ruleId, lines.length - 1);
|
|
636
|
+
return (ruleId, lineIndex) =>
|
|
637
|
+
(ranges.get(ruleId) ?? []).some((r) => lineIndex >= r.from && lineIndex <= r.to);
|
|
638
|
+
}
|
|
639
|
+
|
|
426
640
|
/** A finding is suppressed by `ui-audit-disable-line <rule>` on the same line, or
|
|
427
641
|
* `ui-audit-disable-next-line <rule>` on the line above (explicit rule id required). */
|
|
428
642
|
function isSuppressed(ruleId, sameLine, prevLine) {
|
|
@@ -473,19 +687,52 @@ function walk(dir, acc = []) {
|
|
|
473
687
|
return acc;
|
|
474
688
|
}
|
|
475
689
|
|
|
690
|
+
/** Match real JSX text after a balanced opening tag, including props with comparisons. */
|
|
691
|
+
function* currencyMatches(source) {
|
|
692
|
+
for (const opening of source.matchAll(/<(?:[A-Za-z][\w.:]*\b|(?=>))/g)) {
|
|
693
|
+
const end = jsxOpeningEnd(source, opening.index);
|
|
694
|
+
const text = source.slice(end + 1).match(/^[^<>{}]*[¥$€£₫]\s*\{/);
|
|
695
|
+
if (text) yield { 0: source.slice(opening.index, end + 1) + text[0], index: opening.index };
|
|
696
|
+
}
|
|
697
|
+
yield* source.matchAll(/\}\s*円\s*</g);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Find the tag boundary without confusing a comparison, callback or string for markup. */
|
|
701
|
+
function jsxOpeningEnd(source, start) {
|
|
702
|
+
let braces = 0;
|
|
703
|
+
let quote = "";
|
|
704
|
+
for (let i = start + 1; i < source.length; i++) {
|
|
705
|
+
const char = source[i];
|
|
706
|
+
if (quote) {
|
|
707
|
+
if (char === "\\") i++;
|
|
708
|
+
else if (char === quote) quote = "";
|
|
709
|
+
} else if (char === '"' || char === "'" || char === "`") quote = char;
|
|
710
|
+
else if (char === "{") braces++;
|
|
711
|
+
else if (char === "}") braces--;
|
|
712
|
+
else if (char === ">" && braces === 0) return i;
|
|
713
|
+
}
|
|
714
|
+
return source.length;
|
|
715
|
+
}
|
|
716
|
+
|
|
476
717
|
// Structural: a <Card> (without p-0) whose first child is body content rather than a Card
|
|
477
718
|
// sub-component sits FLUSH (no padding). Per-line regexes can't see across lines, so this is a
|
|
478
719
|
// whole-file pass. The body must be wrapped in <CardContent> (titles in <CardHeader>).
|
|
479
|
-
const CARD_FLUSH =
|
|
480
|
-
|
|
720
|
+
const CARD_FLUSH = new RegExp(
|
|
721
|
+
`<Card(?!${ATTRS}\\bp-0\\b)(?:\\s${ATTRS})?>\\s*<(?!CardContent|CardHeader|CardCover|CardFooter|CardBar|\\/Card)`,
|
|
722
|
+
"g",
|
|
723
|
+
);
|
|
481
724
|
|
|
482
725
|
// Structural: a bare <label>/<Label> paired with a TEXT control (its sibling) instead of a
|
|
483
726
|
// <FormField>. FormField OWNS the label↔control association (htmlFor/id), aria-describedby/
|
|
484
727
|
// error wiring, AND the field rhythm (label gap + field spacing) — a hand-rolled Label+Input
|
|
485
728
|
// loses all of it (the cramped login-form failure mode). Checkbox/Radio/Switch use Field/Label
|
|
486
729
|
// legitimately, so they are NOT matched. Whole-file pass (the pair spans lines).
|
|
487
|
-
const BARE_FIELD =
|
|
488
|
-
|
|
730
|
+
const BARE_FIELD = new RegExp(
|
|
731
|
+
`<(?:label|Label)\\b${ATTRS}>[\\s\\S]{0,240}?</(?:label|Label)>\\s*` +
|
|
732
|
+
"<(?:Input|Select|Textarea|NumberInput|SearchInput|SearchSelect|DatePicker|DateRangePicker|" +
|
|
733
|
+
"TimePicker|MonthPicker|MonthRangePicker|Cascader|TreeSelect|input)\\b",
|
|
734
|
+
"g",
|
|
735
|
+
);
|
|
489
736
|
|
|
490
737
|
// Two <Card> siblings written back to back at the same indentation with nothing between them.
|
|
491
738
|
// Direct children of PageContainer are spaced by the page; anywhere else they touch.
|
|
@@ -496,29 +743,50 @@ const SIBLING_CARDS = /^([ \t]*)<\/Card>\s*\n\1<Card\b/gm;
|
|
|
496
743
|
* utilities are the design system itself (that is where `flex`/`gap-*` are supposed to live), so
|
|
497
744
|
* those rules are skipped when the CWD package is @godxjp/ui — unless `--consumer` forces them.
|
|
498
745
|
*/
|
|
499
|
-
|
|
500
|
-
try {
|
|
501
|
-
return JSON.parse(readFileSync(join(CWD, "package.json"), "utf8")).name === "@godxjp/ui";
|
|
502
|
-
} catch {
|
|
503
|
-
return false;
|
|
504
|
-
}
|
|
505
|
-
})();
|
|
746
|
+
|
|
506
747
|
const ACTIVE_RULES =
|
|
507
748
|
SELF && !args.includes("--consumer") ? RULES.filter((r) => r.scope !== "consumer") : RULES;
|
|
508
749
|
|
|
509
750
|
const findings = [];
|
|
751
|
+
let filesScanned = 0;
|
|
510
752
|
for (const dir of SCAN_DIRS) {
|
|
511
753
|
for (const file of walk(isAbsolute(dir) ? dir : join(CWD, dir))) {
|
|
512
754
|
const rel = relative(CWD, file);
|
|
755
|
+
// Framework test support is executable fixture markup, not a shipped product screen.
|
|
756
|
+
if (SELF && !args.includes("--consumer") && rel.startsWith("src/test/")) continue;
|
|
757
|
+
filesScanned += 1;
|
|
758
|
+
// A primitive implements native controls; asking Input to render Input recurses.
|
|
759
|
+
// Consumer applications and executable docs still receive these composition checks.
|
|
760
|
+
const fileRules =
|
|
761
|
+
SELF && !args.includes("--consumer") && rel.startsWith("src/components/")
|
|
762
|
+
? ACTIVE_RULES.filter((rule) => rule.scope !== "consumer-control")
|
|
763
|
+
: ACTIVE_RULES;
|
|
513
764
|
const content = readFileSync(file, "utf8");
|
|
514
765
|
const origLines = content.split("\n");
|
|
515
766
|
const scanContent = stripComments(content); // comments blanked; strings + line numbers kept
|
|
516
767
|
const scanLines = scanContent.split("\n");
|
|
768
|
+
// `classOnly` rules read this instead — everything but a real class expression blanked.
|
|
769
|
+
const classLines = classExpressionsOnly(scanContent).split("\n");
|
|
770
|
+
const inDisabledBlock = blockSuppressions(origLines);
|
|
771
|
+
/** Both opt-outs, by line index: the per-line markers and the reason-carrying block. */
|
|
772
|
+
const suppressed = (ruleId, i) =>
|
|
773
|
+
isSuppressed(ruleId, origLines[i], origLines[i - 1]) || inDisabledBlock(ruleId, i);
|
|
774
|
+
const isJsx = file.endsWith(".tsx");
|
|
775
|
+
// This compiler output intentionally resolves CSS variables to email-safe literals.
|
|
776
|
+
// gen-email-tokens.mjs --check verifies it against its canonical token sources.
|
|
777
|
+
const compiledEmailTokens =
|
|
778
|
+
SELF &&
|
|
779
|
+
rel === "src/email/tokens.generated.ts" &&
|
|
780
|
+
content.startsWith("// AUTO-GENERATED by scripts/gen-email-tokens.mjs");
|
|
517
781
|
scanLines.forEach((line, i) => {
|
|
518
|
-
for (const rule of
|
|
782
|
+
for (const rule of fileRules) {
|
|
783
|
+
if (!isJsx && rule.scope === "consumer-control") continue;
|
|
784
|
+
if (compiledEmailTokens && rule.id === "no-off-scale-token-value") continue;
|
|
785
|
+
if (rule.spansElement) continue; // matched over the whole file below, not line by line
|
|
786
|
+
const target = rule.classOnly ? (classLines[i] ?? "") : line;
|
|
519
787
|
if (
|
|
520
|
-
rule.test.test(
|
|
521
|
-
!
|
|
788
|
+
(typeof rule.test === "function" ? rule.test(target) : rule.test.test(target)) &&
|
|
789
|
+
!suppressed(rule.id, i) &&
|
|
522
790
|
!isExempt(rule, origLines[i], origLines[i - 1])
|
|
523
791
|
) {
|
|
524
792
|
findings.push({
|
|
@@ -533,10 +801,39 @@ for (const dir of SCAN_DIRS) {
|
|
|
533
801
|
}
|
|
534
802
|
}
|
|
535
803
|
});
|
|
804
|
+
// Element-spanning rules: matched against the WHOLE file, because a JSX element prettier wrapped
|
|
805
|
+
// over five lines is invisible to a line-by-line scan. Same offset→line-number idiom as the
|
|
806
|
+
// block rules below.
|
|
807
|
+
for (const rule of fileRules) {
|
|
808
|
+
if (!rule.spansElement || !isJsx) continue;
|
|
809
|
+
for (const match of rule.matches
|
|
810
|
+
? rule.matches(scanContent)
|
|
811
|
+
: scanContent.matchAll(rule.test)) {
|
|
812
|
+
// A comparison `>` inside a JSX prop is not the end of its opening tag.
|
|
813
|
+
if (
|
|
814
|
+
(rule.id === "hardcoded-currency" || rule.id === "no-em-dash-in-copy") &&
|
|
815
|
+
match[0].startsWith("<") &&
|
|
816
|
+
jsxOpeningEnd(scanContent, match.index) >= match.index + match[0].length
|
|
817
|
+
)
|
|
818
|
+
continue;
|
|
819
|
+
const lineNo = scanContent.slice(0, match.index).split("\n").length;
|
|
820
|
+
if (suppressed(rule.id, lineNo - 1)) continue;
|
|
821
|
+
if (isExempt(rule, origLines[lineNo - 1], origLines[lineNo - 2])) continue;
|
|
822
|
+
findings.push({
|
|
823
|
+
file: rel,
|
|
824
|
+
line: lineNo,
|
|
825
|
+
rule: rule.id,
|
|
826
|
+
severity: rule.severity,
|
|
827
|
+
standard: rule.standard,
|
|
828
|
+
message: rule.message,
|
|
829
|
+
snippet: match[0].replace(/\s+/g, " ").slice(0, 120),
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
if (!isJsx) continue;
|
|
536
834
|
for (const match of scanContent.matchAll(CARD_FLUSH)) {
|
|
537
835
|
const lineNo = scanContent.slice(0, match.index).split("\n").length;
|
|
538
|
-
if (
|
|
539
|
-
continue;
|
|
836
|
+
if (suppressed("card-needs-content", lineNo - 1)) continue;
|
|
540
837
|
findings.push({
|
|
541
838
|
file: rel,
|
|
542
839
|
line: lineNo,
|
|
@@ -553,8 +850,7 @@ for (const dir of SCAN_DIRS) {
|
|
|
553
850
|
) {
|
|
554
851
|
for (const match of scanContent.matchAll(SIBLING_CARDS)) {
|
|
555
852
|
const lineNo = scanContent.slice(0, match.index).split("\n").length + 1;
|
|
556
|
-
if (
|
|
557
|
-
continue;
|
|
853
|
+
if (suppressed("sibling-cards-need-flex", lineNo - 1)) continue;
|
|
558
854
|
findings.push({
|
|
559
855
|
file: rel,
|
|
560
856
|
line: lineNo,
|
|
@@ -568,10 +864,7 @@ for (const dir of SCAN_DIRS) {
|
|
|
568
864
|
}
|
|
569
865
|
for (const match of scanContent.matchAll(BARE_FIELD)) {
|
|
570
866
|
const lineNo = scanContent.slice(0, match.index).split("\n").length;
|
|
571
|
-
if (
|
|
572
|
-
isSuppressed("bare-control-needs-formfield", origLines[lineNo - 1], origLines[lineNo - 2])
|
|
573
|
-
)
|
|
574
|
-
continue;
|
|
867
|
+
if (suppressed("bare-control-needs-formfield", lineNo - 1)) continue;
|
|
575
868
|
findings.push({
|
|
576
869
|
file: rel,
|
|
577
870
|
line: lineNo,
|
|
@@ -589,7 +882,28 @@ for (const dir of SCAN_DIRS) {
|
|
|
589
882
|
const errors = findings.filter((f) => f.severity === "error");
|
|
590
883
|
const warnings = findings.filter((f) => f.severity === "warn");
|
|
591
884
|
|
|
592
|
-
|
|
885
|
+
// A run that opened NO file is not a clean run. The default SCAN_DIRS are a consumer's
|
|
886
|
+
// `resources/js/{components,pages,layouts}`; `walk()` swallows ENOENT and returns [], so in any
|
|
887
|
+
// tree without that layout — this repo included — `pnpm audit` printed
|
|
888
|
+
// "✓ No UI-standardization violations found." and exited 0 having read nothing at all.
|
|
889
|
+
if (filesScanned === 0) {
|
|
890
|
+
const message =
|
|
891
|
+
`ui-audit scanned 0 files — none of [${SCAN_DIRS.join(", ")}] exists (or all were filtered). ` +
|
|
892
|
+
`Pass the directories to scan, e.g. \`node scripts/ui-audit.mjs src docs\`. ` +
|
|
893
|
+
`Reporting a clean audit without opening a file is not a result.`;
|
|
894
|
+
if (asJson) {
|
|
895
|
+
process.stdout.write(
|
|
896
|
+
JSON.stringify({ summary: null, error: message, findings: [] }, null, 2) + "\n",
|
|
897
|
+
);
|
|
898
|
+
} else {
|
|
899
|
+
console.error(`✗ ${message}`);
|
|
900
|
+
}
|
|
901
|
+
process.exitCode = 2;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (filesScanned === 0) {
|
|
905
|
+
// already reported above
|
|
906
|
+
} else if (asJson) {
|
|
593
907
|
process.stdout.write(
|
|
594
908
|
JSON.stringify(
|
|
595
909
|
{ summary: { errors: errors.length, warnings: warnings.length }, findings },
|
|
@@ -622,4 +936,4 @@ if (asJson) {
|
|
|
622
936
|
}
|
|
623
937
|
|
|
624
938
|
// See the note above --rules: exitCode, so a large JSON report drains fully.
|
|
625
|
-
process.exitCode = errors.length > 0 ? 1 : 0;
|
|
939
|
+
if (filesScanned > 0) process.exitCode = errors.length > 0 ? 1 : 0;
|
|
File without changes
|