@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.
Files changed (326) hide show
  1. package/README.md +8 -2
  2. package/dist/app/theme-axes.d.ts +2 -0
  3. package/dist/app/theme-axes.js +45 -0
  4. package/dist/components/charts/chart-cartesian.d.ts +2 -1
  5. package/dist/components/charts/chart-cartesian.js +52 -3
  6. package/dist/components/charts/chart-category-axis.d.ts +55 -0
  7. package/dist/components/charts/chart-category-axis.js +93 -0
  8. package/dist/components/charts/chart-frame.d.ts +10 -1
  9. package/dist/components/charts/chart-frame.js +19 -3
  10. package/dist/components/charts/compact-bar-trend.d.ts +1 -1
  11. package/dist/components/charts/compact-bar-trend.js +2 -0
  12. package/dist/components/charts/pie-chart.d.ts +1 -1
  13. package/dist/components/charts/pie-chart.js +12 -1
  14. package/dist/components/charts/recharts-peer.d.ts +49 -0
  15. package/dist/components/charts/recharts-peer.js +45 -0
  16. package/dist/components/data-display/card.d.ts +14 -3
  17. package/dist/components/data-display/card.js +22 -4
  18. package/dist/components/data-display/code-block.js +6 -2
  19. package/dist/components/data-display/collapsible.d.ts +23 -4
  20. package/dist/components/data-display/collapsible.js +119 -4
  21. package/dist/components/data-display/data-table.d.ts +39 -4
  22. package/dist/components/data-display/data-table.js +802 -270
  23. package/dist/components/data-display/descriptions.d.ts +27 -6
  24. package/dist/components/data-display/descriptions.js +58 -17
  25. package/dist/components/data-display/index.d.ts +5 -1
  26. package/dist/components/data-display/index.js +4 -0
  27. package/dist/components/data-display/legend.d.ts +15 -0
  28. package/dist/components/data-display/legend.js +11 -0
  29. package/dist/components/data-display/list-row.d.ts +10 -1
  30. package/dist/components/data-display/list-row.js +8 -2
  31. package/dist/components/data-display/popover.d.ts +60 -7
  32. package/dist/components/data-display/popover.js +268 -39
  33. package/dist/components/data-display/progress.d.ts +50 -2
  34. package/dist/components/data-display/progress.js +57 -9
  35. package/dist/components/data-display/range-timeline.d.ts +38 -0
  36. package/dist/components/data-display/range-timeline.js +161 -0
  37. package/dist/components/data-display/scroll-area.js +13 -2
  38. package/dist/components/data-display/service-launcher-card.js +10 -10
  39. package/dist/components/data-display/table.d.ts +11 -3
  40. package/dist/components/data-display/table.js +48 -18
  41. package/dist/components/data-display/tree-list.js +4 -9
  42. package/dist/components/data-entry/calendar.d.ts +2 -2
  43. package/dist/components/data-entry/calendar.js +79 -33
  44. package/dist/components/data-entry/cascader.d.ts +1 -1
  45. package/dist/components/data-entry/cascader.js +188 -24
  46. package/dist/components/data-entry/checkbox.d.ts +19 -6
  47. package/dist/components/data-entry/checkbox.js +55 -16
  48. package/dist/components/data-entry/choice-option.d.ts +1 -1
  49. package/dist/components/data-entry/color-picker.d.ts +1 -1
  50. package/dist/components/data-entry/color-picker.js +17 -5
  51. package/dist/components/data-entry/control-appearance.d.ts +64 -0
  52. package/dist/components/data-entry/control-appearance.js +39 -0
  53. package/dist/components/data-entry/control-surface.d.ts +61 -0
  54. package/dist/components/data-entry/control-surface.js +39 -0
  55. package/dist/components/data-entry/date-picker.d.ts +1 -1
  56. package/dist/components/data-entry/date-picker.js +344 -113
  57. package/dist/components/data-entry/date-range-picker.d.ts +2 -2
  58. package/dist/components/data-entry/date-range-picker.js +278 -140
  59. package/dist/components/data-entry/field.js +0 -1
  60. package/dist/components/data-entry/form-field.d.ts +1 -1
  61. package/dist/components/data-entry/form-field.js +39 -4
  62. package/dist/components/data-entry/form.d.ts +4 -0
  63. package/dist/components/data-entry/form.js +5 -3
  64. package/dist/components/data-entry/index.d.ts +7 -3
  65. package/dist/components/data-entry/index.js +10 -1
  66. package/dist/components/data-entry/input-otp.d.ts +1 -1
  67. package/dist/components/data-entry/input.d.ts +12 -34
  68. package/dist/components/data-entry/input.js +92 -24
  69. package/dist/components/data-entry/label.d.ts +3 -2
  70. package/dist/components/data-entry/label.js +23 -10
  71. package/dist/components/data-entry/month-picker.d.ts +2 -2
  72. package/dist/components/data-entry/month-picker.js +47 -10
  73. package/dist/components/data-entry/month-range-picker.d.ts +2 -2
  74. package/dist/components/data-entry/month-range-picker.js +51 -11
  75. package/dist/components/data-entry/number-input.d.ts +7 -0
  76. package/dist/components/data-entry/number-input.js +147 -98
  77. package/dist/components/data-entry/password-input.d.ts +1 -1
  78. package/dist/components/data-entry/radio.d.ts +1 -1
  79. package/dist/components/data-entry/radio.js +61 -13
  80. package/dist/components/data-entry/search-input.d.ts +1 -1
  81. package/dist/components/data-entry/search-input.js +16 -2
  82. package/dist/components/data-entry/search-select.d.ts +2 -2
  83. package/dist/components/data-entry/search-select.js +209 -78
  84. package/dist/components/data-entry/select.d.ts +22 -4
  85. package/dist/components/data-entry/select.js +215 -163
  86. package/dist/components/data-entry/slider.d.ts +9 -1
  87. package/dist/components/data-entry/slider.js +87 -9
  88. package/dist/components/data-entry/switch.d.ts +3 -0
  89. package/dist/components/data-entry/switch.js +29 -3
  90. package/dist/components/data-entry/textarea.d.ts +14 -56
  91. package/dist/components/data-entry/textarea.js +80 -33
  92. package/dist/components/data-entry/time-picker.d.ts +2 -2
  93. package/dist/components/data-entry/time-picker.js +333 -109
  94. package/dist/components/data-entry/time-range-picker.d.ts +5 -0
  95. package/dist/components/data-entry/time-range-picker.js +89 -0
  96. package/dist/components/data-entry/transfer.d.ts +1 -1
  97. package/dist/components/data-entry/transfer.js +99 -31
  98. package/dist/components/data-entry/tree-select-strategy.d.ts +1 -1
  99. package/dist/components/data-entry/tree-select.d.ts +1 -1
  100. package/dist/components/data-entry/tree-select.js +197 -92
  101. package/dist/components/data-entry/tree-utils.d.ts +1 -1
  102. package/dist/components/data-entry/tree-utils.js +7 -14
  103. package/dist/components/data-entry/upload-files.d.ts +2 -0
  104. package/dist/components/data-entry/upload-files.js +31 -0
  105. package/dist/components/data-entry/upload-request.d.ts +4 -0
  106. package/dist/components/data-entry/upload-request.js +53 -0
  107. package/dist/components/data-entry/upload-types.d.ts +28 -0
  108. package/dist/components/data-entry/upload-types.js +2 -0
  109. package/dist/components/data-entry/upload.d.ts +2 -2
  110. package/dist/components/data-entry/upload.js +482 -117
  111. package/dist/components/feedback/dialog.d.ts +105 -38
  112. package/dist/components/feedback/dialog.js +268 -193
  113. package/dist/components/feedback/overlay-close-focus.d.ts +31 -0
  114. package/dist/components/feedback/overlay-close-focus.js +31 -0
  115. package/dist/components/feedback/overlay-header-tone.d.ts +1 -1
  116. package/dist/components/feedback/sheet.d.ts +53 -11
  117. package/dist/components/feedback/sheet.js +150 -81
  118. package/dist/components/feedback/tooltip.d.ts +51 -7
  119. package/dist/components/feedback/tooltip.js +107 -25
  120. package/dist/components/general/button.d.ts +1 -1
  121. package/dist/components/general/button.js +7 -3
  122. package/dist/components/general/index.d.ts +1 -0
  123. package/dist/components/general/index.js +2 -0
  124. package/dist/components/general/logo.d.ts +17 -0
  125. package/dist/components/general/logo.js +22 -16
  126. package/dist/components/general/typography.d.ts +3 -0
  127. package/dist/components/general/typography.js +15 -1
  128. package/dist/components/general/visually-hidden.d.ts +3 -0
  129. package/dist/components/general/visually-hidden.js +9 -0
  130. package/dist/components/layout/app-launcher.d.ts +34 -0
  131. package/dist/components/layout/app-launcher.js +228 -0
  132. package/dist/components/layout/app-shell.d.ts +3 -1
  133. package/dist/components/layout/app-shell.js +69 -12
  134. package/dist/components/layout/aspect-ratio.js +0 -1
  135. package/dist/components/layout/auth-divider.js +0 -1
  136. package/dist/components/layout/breadcrumb.d.ts +14 -2
  137. package/dist/components/layout/breadcrumb.js +46 -4
  138. package/dist/components/layout/flex.d.ts +1 -1
  139. package/dist/components/layout/flex.js +41 -3
  140. package/dist/components/layout/index.d.ts +3 -0
  141. package/dist/components/layout/index.js +5 -1
  142. package/dist/components/layout/nav-surface.d.ts +26 -0
  143. package/dist/components/layout/nav-surface.js +17 -0
  144. package/dist/components/layout/org-switcher.d.ts +5 -1
  145. package/dist/components/layout/org-switcher.js +25 -4
  146. package/dist/components/layout/page-container.d.ts +1 -1
  147. package/dist/components/layout/page-container.js +5 -3
  148. package/dist/components/layout/responsive-grid.d.ts +16 -2
  149. package/dist/components/layout/responsive-grid.js +29 -2
  150. package/dist/components/layout/separator.js +0 -1
  151. package/dist/components/layout/sidebar.js +4 -1
  152. package/dist/components/layout/split-pane.d.ts +14 -1
  153. package/dist/components/layout/split-pane.js +26 -13
  154. package/dist/components/layout/topbar-item.d.ts +3 -0
  155. package/dist/components/layout/topbar-item.js +19 -5
  156. package/dist/components/layout/topbar.d.ts +1 -1
  157. package/dist/components/layout/topbar.js +27 -6
  158. package/dist/components/navigation/app-setting-picker.js +21 -2
  159. package/dist/components/navigation/app-setting-toggle.d.ts +16 -0
  160. package/dist/components/navigation/app-setting-toggle.js +96 -0
  161. package/dist/components/navigation/dropdown-menu.d.ts +227 -18
  162. package/dist/components/navigation/dropdown-menu.js +402 -118
  163. package/dist/components/navigation/index.d.ts +2 -0
  164. package/dist/components/navigation/index.js +2 -0
  165. package/dist/components/navigation/pagination-utils.d.ts +2 -1
  166. package/dist/components/navigation/pagination.d.ts +2 -2
  167. package/dist/components/navigation/pagination.js +155 -83
  168. package/dist/components/navigation/steps.d.ts +2 -2
  169. package/dist/components/navigation/steps.js +29 -4
  170. package/dist/components/navigation/tabs.d.ts +29 -16
  171. package/dist/components/navigation/tabs.js +374 -144
  172. package/dist/components/ui/accordion.d.ts +50 -5
  173. package/dist/components/ui/accordion.js +239 -33
  174. package/dist/components/ui/aspect-ratio.d.ts +23 -2
  175. package/dist/components/ui/aspect-ratio.js +15 -13
  176. package/dist/components/ui/avatar.d.ts +29 -4
  177. package/dist/components/ui/avatar.js +111 -25
  178. package/dist/components/ui/hover-card.d.ts +42 -4
  179. package/dist/components/ui/hover-card.js +183 -27
  180. package/dist/components/ui/input-otp.d.ts +26 -28
  181. package/dist/components/ui/input-otp.js +50 -7
  182. package/dist/components/ui/label.js +0 -1
  183. package/dist/components/ui/password-input.d.ts +36 -2
  184. package/dist/components/ui/password-input.js +27 -7
  185. package/dist/components/ui/rating.d.ts +25 -0
  186. package/dist/components/ui/rating.js +67 -27
  187. package/dist/components/ui/segmented.d.ts +12 -3
  188. package/dist/components/ui/segmented.js +17 -2
  189. package/dist/components/ui/separator.d.ts +12 -2
  190. package/dist/components/ui/separator.js +30 -9
  191. package/dist/components/ui/tag-input.d.ts +45 -0
  192. package/dist/components/ui/tag-input.js +109 -27
  193. package/dist/components/ui/toggle-group.d.ts +50 -5
  194. package/dist/components/ui/toggle-group.js +79 -20
  195. package/dist/components/ui/toggle.d.ts +31 -5
  196. package/dist/components/ui/toggle.js +42 -3
  197. package/dist/form/form-context.d.ts +29 -0
  198. package/dist/form/form-context.js +44 -4
  199. package/dist/form/form-field-array.d.ts +22 -0
  200. package/dist/form/form-field-array.js +50 -0
  201. package/dist/form/form-field-control.d.ts +2 -1
  202. package/dist/form/form-field-control.js +116 -40
  203. package/dist/form/form-root.d.ts +2 -1
  204. package/dist/form/form-root.js +142 -23
  205. package/dist/form/index.d.ts +3 -1
  206. package/dist/form/index.js +12 -1
  207. package/dist/i18n/messages/en.json +64 -5
  208. package/dist/i18n/messages/ja.json +64 -5
  209. package/dist/i18n/messages/vi.json +64 -5
  210. package/dist/inertia/index.d.ts +20 -0
  211. package/dist/inertia/index.js +60 -1
  212. package/dist/lib/control-styles.d.ts +25 -3
  213. package/dist/lib/control-styles.js +9 -3
  214. package/dist/lib/datetime/picker-format.d.ts +8 -0
  215. package/dist/lib/datetime/picker-format.js +54 -0
  216. package/dist/lib/slot.d.ts +32 -0
  217. package/dist/lib/slot.js +22 -0
  218. package/dist/lib/variants.d.ts +22 -3
  219. package/dist/lib/variants.js +56 -1
  220. package/dist/props/components/app.prop.d.ts +25 -1
  221. package/dist/props/components/charts.prop.d.ts +21 -0
  222. package/dist/props/components/data-display.prop.d.ts +72 -4
  223. package/dist/props/components/data-entry.prop.d.ts +718 -46
  224. package/dist/props/components/form.prop.d.ts +142 -4
  225. package/dist/props/components/general.prop.d.ts +17 -1
  226. package/dist/props/components/index.d.ts +3 -3
  227. package/dist/props/components/layout.prop.d.ts +281 -11
  228. package/dist/props/components/navigation.prop.d.ts +114 -4
  229. package/dist/props/registry.d.ts +397 -11
  230. package/dist/props/registry.js +526 -11
  231. package/dist/props/vocabulary/content.prop.d.ts +1 -1
  232. package/dist/props/vocabulary/data.prop.d.ts +179 -1
  233. package/dist/props/vocabulary/index.d.ts +5 -5
  234. package/dist/props/vocabulary/interaction.prop.d.ts +68 -3
  235. package/dist/props/vocabulary/layout.prop.d.ts +57 -1
  236. package/dist/props/vocabulary/navigation.prop.d.ts +40 -0
  237. package/dist/props/vocabulary/shared.prop.d.ts +33 -0
  238. package/dist/styles/badge-layout.css +2 -1
  239. package/dist/styles/card-layout.css +26 -4
  240. package/dist/styles/chart-layout.css +15 -0
  241. package/dist/styles/control.css +657 -15
  242. package/dist/styles/core.css +5 -2
  243. package/dist/styles/data-display-layout.css +313 -6
  244. package/dist/styles/data-entry-layout.css +13 -0
  245. package/dist/styles/focus-ring.css +4 -0
  246. package/dist/styles/index.css +5 -2
  247. package/dist/styles/layout.css +327 -4
  248. package/dist/styles/navigation-layout.css +227 -0
  249. package/dist/styles/shell-layout.css +369 -13
  250. package/dist/styles/table-layout.css +95 -6
  251. package/dist/styles/text-layout.css +10 -4
  252. package/dist/tokens/base.css +2 -1
  253. package/dist/tokens/components/badge.css +1 -0
  254. package/dist/tokens/components/chart.css +6 -0
  255. package/dist/tokens/components/control.css +79 -2
  256. package/dist/tokens/components/data-display.css +20 -0
  257. package/dist/tokens/components/descriptions.css +11 -0
  258. package/dist/tokens/components/flex.css +8 -0
  259. package/dist/tokens/components/navigation.css +55 -0
  260. package/dist/tokens/components/shell.css +53 -3
  261. package/dist/tokens/components/table.css +14 -0
  262. package/dist/tokens/foundation.css +2 -0
  263. package/dist/tokens/semantic/layout.css +9 -0
  264. package/docs/COMPONENTS.md +9 -3
  265. package/docs/CONSUMER-RULES.md +13 -0
  266. package/docs/DESIGN-AUTHORITY.md +145 -83
  267. package/docs/DEVELOPMENT.md +4 -3
  268. package/docs/FORMS.md +151 -6
  269. package/docs/FRAME-COVERAGE-REPORT.md +32 -17
  270. package/docs/README.md +15 -15
  271. package/docs/STANDARDS-vocabulary-tokens.md +1 -1
  272. package/docs/TESTING.md +15 -6
  273. package/docs/WHAT-BELONGS-HERE.md +179 -0
  274. package/docs/charts/cjk-category-axis.tsx +81 -0
  275. package/docs/data-display/card/index.tsx +22 -0
  276. package/docs/data-display/data-table/examples/antd-parity.tsx +257 -0
  277. package/docs/data-display/data-table/index.tsx +23 -0
  278. package/docs/data-display/descriptions.tsx +41 -0
  279. package/docs/data-display/legend.tsx +145 -0
  280. package/docs/data-display/progress.tsx +32 -0
  281. package/docs/data-display/scroll-area.tsx +31 -25
  282. package/docs/data-display/table.tsx +104 -45
  283. package/docs/data-display/timeline.tsx +41 -0
  284. package/docs/data-entry/date-picker.tsx +85 -0
  285. package/docs/data-entry/date-range-picker.tsx +26 -0
  286. package/docs/data-entry/form-dynamic-fields.tsx +199 -0
  287. package/docs/data-entry/form.tsx +74 -4
  288. package/docs/data-entry/input-otp.tsx +24 -0
  289. package/docs/data-entry/input.tsx +46 -1
  290. package/docs/data-entry/month-range-picker.tsx +1 -1
  291. package/docs/data-entry/segmented.tsx +1 -1
  292. package/docs/data-entry/select.tsx +29 -2
  293. package/docs/data-entry/switch.tsx +41 -0
  294. package/docs/data-entry/textarea.tsx +38 -0
  295. package/docs/data-entry/time-picker.tsx +85 -1
  296. package/docs/data-entry/time-range-picker.tsx +55 -0
  297. package/docs/data-entry/transfer.tsx +17 -0
  298. package/docs/data-entry/upload.tsx +56 -12
  299. package/docs/feedback/sheet.tsx +1 -1
  300. package/docs/feedback/tooltip.tsx +1 -1
  301. package/docs/general/activity.tsx +2 -2
  302. package/docs/general/button/index.tsx +14 -1
  303. package/docs/general/typography.tsx +14 -1
  304. package/docs/layout/app-launcher.tsx +151 -0
  305. package/docs/layout/app-shell-arrangements.tsx +225 -0
  306. package/docs/layout/app-shell.tsx +11 -0
  307. package/docs/layout/aspect-ratio.tsx +1 -1
  308. package/docs/layout/flex.tsx +50 -0
  309. package/docs/layout/responsive-grid.tsx +21 -1
  310. package/docs/layout/topbar.tsx +5 -9
  311. package/docs/navigation/app-setting-picker.tsx +37 -1
  312. package/docs/navigation/app-setting-toggle.tsx +111 -0
  313. package/docs/navigation/breadcrumb.tsx +48 -0
  314. package/docs/navigation/dropdown-menu.tsx +21 -0
  315. package/docs/navigation/pagination.tsx +52 -0
  316. package/docs/navigation/steps.tsx +37 -0
  317. package/docs/navigation/tabs.tsx +97 -1
  318. package/docs/query/button-refetch.tsx +1 -0
  319. package/package.json +28 -8
  320. package/scripts/_agent-setup.mjs +182 -3
  321. package/scripts/consumer-rule.md +98 -0
  322. package/scripts/guinea-pig-skill.md +322 -0
  323. package/scripts/init-guinea-pig.mjs +84 -0
  324. package/scripts/postinstall.mjs +13 -2
  325. package/scripts/ui-audit.mjs +351 -37
  326. /package/dist/tokens/{antd.generated.css → derived.css} +0 -0
@@ -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
- : ["resources/js/components", "resources/js/pages", "resources/js/layouts"];
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
- * @type {{id:string, severity:'error'|'warn', test:RegExp, message:string, standard?:string, exempt?:RegExp}[]}
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
- test: /className=(?:"[^"]*|'[^']*|\{`[^`]*)\brounded(?:-(?:full|sm|md|lg|xl|2xl))?\b[^"'`]*\b(?:border|bg-)/,
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: /<(?:Badge|Tag|StatCard)\b[^>]*\bvariant=["'](?:success|warning|destructive|info|neutral)["']/,
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
- test: /<(?:Checkbox\.Group|Upload|Cascader|TreeSelect|Transfer|SearchSelect|DatePicker|DateRangePicker|TimePicker|ColorPicker|LocalePicker|TimezonePicker|DateFormatPicker|TimeFormatPicker)\b[^>]*\bonChange=/,
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
- test: /<input[\s>]/,
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
- test: /<Card\b[^>]*\bp-[1-9]/,
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
- test: /<p[^>]*className="[^"]*text-(xs|sm)[^"]*text-destructive/,
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
- test: /\b-?(ml|mr|pl|pr|left|right)-(?:\d|\[|auto|px|full)|\b(?:rounded-[tb]?[lr]|border-[lr]|text-(?:left|right))\b/,
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: /<Button\b(?=[^>]*\bsize=["']icon["'])(?![^>]*\b(?:aria-label|aria-labelledby|title)=)[^>]*>/,
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
- test: /<img\b(?![^>]*\balt=)[^>]*>/,
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
- test: /(?:>[\s]*[¥$€£₫]\s*\{)|(?:\}\s*円)/,
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
- test: /[A-Za-z0-9぀-ヿ一-鿿]\s*—\s*[A-Za-z0-9぀-ヿ一-鿿]/,
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
- /<Card(?![^>]*\bp-0\b)(?:\s[^>]*)?>\s*<(?!CardContent|CardHeader|CardCover|CardFooter|CardBar|\/Card)/g;
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
- /<(?:label|Label)\b[^>]*>[\s\S]{0,240}?<\/(?:label|Label)>\s*<(?:Input|Select|Textarea|NumberInput|SearchInput|SearchSelect|DatePicker|DateRangePicker|TimePicker|MonthPicker|MonthRangePicker|Cascader|TreeSelect|input)\b/g;
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
- const SELF = (() => {
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 ACTIVE_RULES) {
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(line) &&
521
- !isSuppressed(rule.id, origLines[i], origLines[i - 1]) &&
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 (isSuppressed("card-needs-content", origLines[lineNo - 1], origLines[lineNo - 2]))
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 (isSuppressed("sibling-cards-need-flex", origLines[lineNo - 1], origLines[lineNo - 2]))
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
- if (asJson) {
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