@godxjp/ui 20.0.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 (250) 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 +12 -1
  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/data-table.d.ts +39 -4
  20. package/dist/components/data-display/data-table.js +802 -270
  21. package/dist/components/data-display/descriptions.d.ts +27 -6
  22. package/dist/components/data-display/descriptions.js +58 -17
  23. package/dist/components/data-display/index.d.ts +2 -0
  24. package/dist/components/data-display/index.js +2 -0
  25. package/dist/components/data-display/list-row.d.ts +10 -1
  26. package/dist/components/data-display/list-row.js +7 -1
  27. package/dist/components/data-display/popover.d.ts +2 -2
  28. package/dist/components/data-display/popover.js +61 -11
  29. package/dist/components/data-display/range-timeline.d.ts +38 -0
  30. package/dist/components/data-display/range-timeline.js +161 -0
  31. package/dist/components/data-display/scroll-area.js +13 -2
  32. package/dist/components/data-display/service-launcher-card.js +10 -10
  33. package/dist/components/data-display/table.d.ts +11 -3
  34. package/dist/components/data-display/table.js +48 -18
  35. package/dist/components/data-display/tree-list.js +4 -9
  36. package/dist/components/data-entry/calendar.d.ts +2 -2
  37. package/dist/components/data-entry/calendar.js +12 -1
  38. package/dist/components/data-entry/cascader.d.ts +1 -1
  39. package/dist/components/data-entry/cascader.js +188 -24
  40. package/dist/components/data-entry/color-picker.d.ts +1 -1
  41. package/dist/components/data-entry/color-picker.js +17 -5
  42. package/dist/components/data-entry/control-appearance.d.ts +64 -0
  43. package/dist/components/data-entry/control-appearance.js +39 -0
  44. package/dist/components/data-entry/control-surface.d.ts +61 -0
  45. package/dist/components/data-entry/control-surface.js +39 -0
  46. package/dist/components/data-entry/date-picker.d.ts +1 -1
  47. package/dist/components/data-entry/date-picker.js +344 -113
  48. package/dist/components/data-entry/date-range-picker.d.ts +1 -1
  49. package/dist/components/data-entry/date-range-picker.js +278 -140
  50. package/dist/components/data-entry/form-field.d.ts +1 -1
  51. package/dist/components/data-entry/form-field.js +39 -4
  52. package/dist/components/data-entry/form.d.ts +4 -0
  53. package/dist/components/data-entry/form.js +4 -2
  54. package/dist/components/data-entry/index.d.ts +7 -3
  55. package/dist/components/data-entry/index.js +10 -1
  56. package/dist/components/data-entry/input-otp.d.ts +1 -1
  57. package/dist/components/data-entry/input.d.ts +12 -34
  58. package/dist/components/data-entry/input.js +92 -24
  59. package/dist/components/data-entry/month-picker.d.ts +1 -1
  60. package/dist/components/data-entry/month-picker.js +47 -10
  61. package/dist/components/data-entry/month-range-picker.d.ts +1 -1
  62. package/dist/components/data-entry/month-range-picker.js +51 -11
  63. package/dist/components/data-entry/number-input.d.ts +7 -0
  64. package/dist/components/data-entry/number-input.js +147 -98
  65. package/dist/components/data-entry/password-input.d.ts +1 -1
  66. package/dist/components/data-entry/radio.d.ts +1 -1
  67. package/dist/components/data-entry/radio.js +61 -13
  68. package/dist/components/data-entry/search-input.d.ts +1 -1
  69. package/dist/components/data-entry/search-input.js +16 -2
  70. package/dist/components/data-entry/search-select.d.ts +2 -2
  71. package/dist/components/data-entry/search-select.js +209 -78
  72. package/dist/components/data-entry/select.d.ts +22 -4
  73. package/dist/components/data-entry/select.js +215 -163
  74. package/dist/components/data-entry/slider.d.ts +9 -1
  75. package/dist/components/data-entry/slider.js +87 -9
  76. package/dist/components/data-entry/switch.d.ts +3 -0
  77. package/dist/components/data-entry/switch.js +29 -3
  78. package/dist/components/data-entry/textarea.d.ts +14 -56
  79. package/dist/components/data-entry/textarea.js +80 -33
  80. package/dist/components/data-entry/time-picker.d.ts +2 -2
  81. package/dist/components/data-entry/time-picker.js +333 -109
  82. package/dist/components/data-entry/time-range-picker.d.ts +5 -0
  83. package/dist/components/data-entry/time-range-picker.js +89 -0
  84. package/dist/components/data-entry/transfer.d.ts +1 -1
  85. package/dist/components/data-entry/transfer.js +86 -28
  86. package/dist/components/data-entry/tree-select.d.ts +1 -1
  87. package/dist/components/data-entry/tree-select.js +201 -113
  88. package/dist/components/data-entry/tree-utils.js +7 -14
  89. package/dist/components/data-entry/upload-files.d.ts +2 -0
  90. package/dist/components/data-entry/upload-files.js +31 -0
  91. package/dist/components/data-entry/upload-request.d.ts +4 -0
  92. package/dist/components/data-entry/upload-request.js +53 -0
  93. package/dist/components/data-entry/upload-types.d.ts +28 -0
  94. package/dist/components/data-entry/upload-types.js +2 -0
  95. package/dist/components/data-entry/upload.d.ts +2 -2
  96. package/dist/components/data-entry/upload.js +475 -115
  97. package/dist/components/feedback/dialog.js +16 -19
  98. package/dist/components/general/button.d.ts +1 -1
  99. package/dist/components/general/button.js +5 -1
  100. package/dist/components/general/index.d.ts +1 -0
  101. package/dist/components/general/index.js +2 -0
  102. package/dist/components/general/typography.d.ts +3 -0
  103. package/dist/components/general/typography.js +15 -1
  104. package/dist/components/general/visually-hidden.d.ts +3 -0
  105. package/dist/components/general/visually-hidden.js +9 -0
  106. package/dist/components/layout/app-launcher.d.ts +34 -0
  107. package/dist/components/layout/app-launcher.js +228 -0
  108. package/dist/components/layout/app-shell.d.ts +2 -0
  109. package/dist/components/layout/app-shell.js +20 -7
  110. package/dist/components/layout/breadcrumb.d.ts +14 -2
  111. package/dist/components/layout/breadcrumb.js +46 -4
  112. package/dist/components/layout/flex.d.ts +1 -1
  113. package/dist/components/layout/flex.js +23 -2
  114. package/dist/components/layout/index.d.ts +3 -0
  115. package/dist/components/layout/index.js +5 -1
  116. package/dist/components/layout/org-switcher.js +20 -1
  117. package/dist/components/layout/page-container.d.ts +1 -1
  118. package/dist/components/layout/page-container.js +5 -3
  119. package/dist/components/layout/responsive-grid.d.ts +16 -2
  120. package/dist/components/layout/responsive-grid.js +29 -2
  121. package/dist/components/layout/topbar-item.d.ts +3 -0
  122. package/dist/components/layout/topbar-item.js +18 -3
  123. package/dist/components/layout/topbar.d.ts +1 -1
  124. package/dist/components/layout/topbar.js +27 -6
  125. package/dist/components/navigation/dropdown-menu.d.ts +30 -2
  126. package/dist/components/navigation/dropdown-menu.js +68 -11
  127. package/dist/components/navigation/pagination.d.ts +2 -2
  128. package/dist/components/navigation/pagination.js +155 -83
  129. package/dist/components/navigation/steps.d.ts +2 -2
  130. package/dist/components/navigation/steps.js +29 -4
  131. package/dist/components/navigation/tabs.d.ts +5 -33
  132. package/dist/components/navigation/tabs.js +232 -64
  133. package/dist/components/ui/input-otp.d.ts +26 -28
  134. package/dist/components/ui/input-otp.js +50 -7
  135. package/dist/components/ui/password-input.d.ts +36 -2
  136. package/dist/components/ui/password-input.js +27 -7
  137. package/dist/components/ui/rating.d.ts +25 -0
  138. package/dist/components/ui/rating.js +67 -27
  139. package/dist/components/ui/segmented.d.ts +9 -0
  140. package/dist/components/ui/segmented.js +17 -2
  141. package/dist/components/ui/separator.d.ts +4 -0
  142. package/dist/components/ui/separator.js +13 -0
  143. package/dist/components/ui/tag-input.d.ts +45 -0
  144. package/dist/components/ui/tag-input.js +109 -27
  145. package/dist/form/form-context.d.ts +29 -0
  146. package/dist/form/form-context.js +44 -4
  147. package/dist/form/form-field-array.d.ts +22 -0
  148. package/dist/form/form-field-array.js +50 -0
  149. package/dist/form/form-field-control.d.ts +2 -1
  150. package/dist/form/form-field-control.js +116 -40
  151. package/dist/form/form-root.d.ts +2 -1
  152. package/dist/form/form-root.js +142 -23
  153. package/dist/form/index.d.ts +3 -1
  154. package/dist/form/index.js +12 -1
  155. package/dist/i18n/messages/en.json +56 -5
  156. package/dist/i18n/messages/ja.json +56 -5
  157. package/dist/i18n/messages/vi.json +56 -5
  158. package/dist/inertia/index.d.ts +20 -0
  159. package/dist/inertia/index.js +60 -1
  160. package/dist/lib/control-styles.d.ts +25 -3
  161. package/dist/lib/control-styles.js +9 -3
  162. package/dist/lib/datetime/picker-format.d.ts +8 -0
  163. package/dist/lib/datetime/picker-format.js +54 -0
  164. package/dist/props/components/charts.prop.d.ts +21 -0
  165. package/dist/props/components/data-display.prop.d.ts +55 -4
  166. package/dist/props/components/data-entry.prop.d.ts +687 -43
  167. package/dist/props/components/form.prop.d.ts +142 -4
  168. package/dist/props/components/general.prop.d.ts +17 -1
  169. package/dist/props/components/index.d.ts +2 -2
  170. package/dist/props/components/layout.prop.d.ts +163 -6
  171. package/dist/props/components/navigation.prop.d.ts +113 -3
  172. package/dist/props/registry.d.ts +376 -10
  173. package/dist/props/registry.js +493 -9
  174. package/dist/props/vocabulary/data.prop.d.ts +179 -1
  175. package/dist/props/vocabulary/index.d.ts +4 -4
  176. package/dist/props/vocabulary/interaction.prop.d.ts +51 -2
  177. package/dist/props/vocabulary/navigation.prop.d.ts +40 -0
  178. package/dist/props/vocabulary/shared.prop.d.ts +33 -0
  179. package/dist/styles/badge-layout.css +2 -1
  180. package/dist/styles/card-layout.css +26 -4
  181. package/dist/styles/chart-layout.css +15 -0
  182. package/dist/styles/control.css +515 -14
  183. package/dist/styles/data-display-layout.css +241 -6
  184. package/dist/styles/data-entry-layout.css +13 -0
  185. package/dist/styles/layout.css +221 -4
  186. package/dist/styles/navigation-layout.css +227 -0
  187. package/dist/styles/shell-layout.css +229 -5
  188. package/dist/styles/table-layout.css +95 -6
  189. package/dist/styles/text-layout.css +5 -0
  190. package/dist/tokens/base.css +1 -0
  191. package/dist/tokens/components/badge.css +1 -0
  192. package/dist/tokens/components/chart.css +6 -0
  193. package/dist/tokens/components/control.css +79 -2
  194. package/dist/tokens/components/data-display.css +9 -0
  195. package/dist/tokens/components/descriptions.css +11 -0
  196. package/dist/tokens/components/flex.css +8 -0
  197. package/dist/tokens/components/navigation.css +55 -0
  198. package/dist/tokens/components/shell.css +50 -2
  199. package/dist/tokens/components/table.css +14 -0
  200. package/dist/tokens/semantic/layout.css +9 -0
  201. package/docs/CONSUMER-RULES.md +13 -0
  202. package/docs/DESIGN-AUTHORITY.md +43 -6
  203. package/docs/DEVELOPMENT.md +4 -3
  204. package/docs/FORMS.md +151 -6
  205. package/docs/FRAME-COVERAGE-REPORT.md +29 -16
  206. package/docs/README.md +1 -1
  207. package/docs/STANDARDS-vocabulary-tokens.md +1 -1
  208. package/docs/TESTING.md +15 -6
  209. package/docs/charts/cjk-category-axis.tsx +81 -0
  210. package/docs/data-display/card/index.tsx +22 -0
  211. package/docs/data-display/data-table/examples/antd-parity.tsx +257 -0
  212. package/docs/data-display/data-table/index.tsx +23 -0
  213. package/docs/data-display/descriptions.tsx +41 -0
  214. package/docs/data-display/scroll-area.tsx +31 -25
  215. package/docs/data-display/table.tsx +104 -45
  216. package/docs/data-display/timeline.tsx +41 -0
  217. package/docs/data-entry/date-picker.tsx +85 -0
  218. package/docs/data-entry/date-range-picker.tsx +26 -0
  219. package/docs/data-entry/form-dynamic-fields.tsx +199 -0
  220. package/docs/data-entry/form.tsx +74 -4
  221. package/docs/data-entry/input-otp.tsx +24 -0
  222. package/docs/data-entry/input.tsx +46 -1
  223. package/docs/data-entry/month-range-picker.tsx +1 -1
  224. package/docs/data-entry/select.tsx +27 -0
  225. package/docs/data-entry/switch.tsx +41 -0
  226. package/docs/data-entry/textarea.tsx +38 -0
  227. package/docs/data-entry/time-picker.tsx +85 -1
  228. package/docs/data-entry/time-range-picker.tsx +55 -0
  229. package/docs/data-entry/transfer.tsx +17 -0
  230. package/docs/data-entry/upload.tsx +56 -12
  231. package/docs/feedback/tooltip.tsx +1 -1
  232. package/docs/general/activity.tsx +2 -2
  233. package/docs/general/button/index.tsx +14 -1
  234. package/docs/general/typography.tsx +14 -1
  235. package/docs/layout/app-launcher.tsx +151 -0
  236. package/docs/layout/app-shell.tsx +11 -0
  237. package/docs/layout/flex.tsx +50 -0
  238. package/docs/layout/responsive-grid.tsx +21 -1
  239. package/docs/layout/topbar.tsx +5 -9
  240. package/docs/navigation/app-setting-picker.tsx +11 -0
  241. package/docs/navigation/breadcrumb.tsx +48 -0
  242. package/docs/navigation/dropdown-menu.tsx +21 -0
  243. package/docs/navigation/pagination.tsx +52 -0
  244. package/docs/navigation/steps.tsx +37 -0
  245. package/docs/navigation/tabs.tsx +97 -1
  246. package/docs/query/button-refetch.tsx +1 -0
  247. package/package.json +19 -2
  248. package/scripts/_agent-setup.mjs +19 -2
  249. package/scripts/init-guinea-pig.mjs +26 -4
  250. package/scripts/ui-audit.mjs +243 -30
@@ -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";
@@ -43,10 +52,15 @@ const EMOJI_FLAG = /\p{Regional_Indicator}/u;
43
52
  * single-line elements. Rules carrying `spansElement: true` are matched against the whole file
44
53
  * instead (line number derived from the match offset), so wrapping no longer hides a violation.
45
54
  */
46
- const ATTRS = String.raw`[^>]*(?:=>[^>]*)*`;
55
+ const ATTRS = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|=>|[^>"'])*`;
47
56
 
48
57
  /**
49
- * @type {{id:string, severity:'error'|'warn', test:RegExp, message:string, standard?:string, exempt?:RegExp}[]}
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.
50
64
  *
51
65
  * `exempt` is an optional SECOND escape a rule may declare, matched against the ORIGINAL (un-blanked)
52
66
  * line or the one above it. `ui-audit-disable-line <id>` silences any rule; an `exempt` marker is for
@@ -62,6 +76,7 @@ const ATTRS = String.raw`[^>]*(?:=>[^>]*)*`;
62
76
  const RULES = [
63
77
  {
64
78
  id: "no-utility-spacing",
79
+ classOnly: true,
65
80
  scope: "consumer",
66
81
  severity: "error",
67
82
  // Any Tailwind spacing step on the consumer's own markup. Arbitrary values ([13px]) have their
@@ -111,12 +126,14 @@ const RULES = [
111
126
  },
112
127
  {
113
128
  id: "no-space-xy",
129
+ classOnly: true,
114
130
  severity: "error",
115
131
  test: /\bspace-[xy]-\d/,
116
132
  message: "Use <Stack>/<Inline> with gap, not space-x/y-* (rules §5).",
117
133
  },
118
134
  {
119
135
  id: "no-raw-palette-color",
136
+ classOnly: true,
120
137
  severity: "error",
121
138
  test: new RegExp(
122
139
  `\\b(bg|text|border|ring|fill|stroke|from|to|via|divide|outline)-(${PALETTE})-\\d`,
@@ -151,7 +168,7 @@ const RULES = [
151
168
  severity: "error",
152
169
  spansElement: true,
153
170
  test: new RegExp(
154
- "<(?:Checkbox\\.Group|Upload|Cascader|TreeSelect|Transfer|SearchSelect|DatePicker|" +
171
+ "<(?:Checkbox\\.Group|Upload|Cascader|TreeSelect|Transfer|Select|SearchSelect|DatePicker|" +
155
172
  `DateRangePicker|TimePicker|ColorPicker|LocalePicker|TimezonePicker|DateFormatPicker|TimeFormatPicker)\\b${ATTRS}\\bonChange=`,
156
173
  "g",
157
174
  ),
@@ -160,6 +177,7 @@ const RULES = [
160
177
  },
161
178
  {
162
179
  id: "no-arbitrary-hex",
180
+ classOnly: true,
163
181
  severity: "error",
164
182
  test: /(bg|text|border|ring|fill|stroke|from|to|via)-\[#[0-9a-fA-F]{3,8}\]/,
165
183
  message: "No hardcoded hex colors in className; use design-system tokens (rules §4).",
@@ -169,6 +187,7 @@ const RULES = [
169
187
  // (they start with a letter), so token-driven values stay legal (rules §4–§5).
170
188
  {
171
189
  id: "no-arbitrary-spacing",
190
+ classOnly: true,
172
191
  severity: "error",
173
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/,
174
193
  message:
@@ -176,6 +195,7 @@ const RULES = [
176
195
  },
177
196
  {
178
197
  id: "no-arbitrary-size",
198
+ classOnly: true,
179
199
  severity: "error",
180
200
  // `min-w-[…]` / `min-h-[…]` are allowed: a MINIMUM dimension is the legit responsive
181
201
  // pattern (horizontal-scroll tables `<Table className="min-w-[720px]">`, collapse
@@ -187,6 +207,7 @@ const RULES = [
187
207
  },
188
208
  {
189
209
  id: "no-arbitrary-typography",
210
+ classOnly: true,
190
211
  severity: "error",
191
212
  test: /\b(text|leading|tracking|font)-\[-?\.?\d/,
192
213
  message:
@@ -194,6 +215,7 @@ const RULES = [
194
215
  },
195
216
  {
196
217
  id: "no-arbitrary-radius",
218
+ classOnly: true,
197
219
  severity: "error",
198
220
  test: /\brounded(?:-[a-z]+)?-\[-?\.?\d/,
199
221
  message: "No arbitrary radius (rounded-[6px]…). Use rounded-sm/md/lg radius tokens (rules §4).",
@@ -232,6 +254,7 @@ const RULES = [
232
254
  },
233
255
  {
234
256
  id: "no-raw-select",
257
+ scope: "consumer-control",
235
258
  severity: "error",
236
259
  test: /<select[\s>]/,
237
260
  message: "Use <Select> from @godxjp/ui, not a raw <select> (rules §3).",
@@ -244,18 +267,22 @@ const RULES = [
244
267
  },
245
268
  {
246
269
  id: "no-raw-textarea",
270
+ scope: "consumer-control",
247
271
  severity: "warn",
248
272
  test: /<textarea[\s>]/,
249
273
  message: "Use <Textarea> from @godxjp/ui, not a raw <textarea> (rules §3).",
250
274
  },
251
275
  {
252
276
  id: "no-raw-input",
277
+ scope: "consumer-control",
253
278
  severity: "error",
254
- test: /<input[\s>]/,
279
+ spansElement: true,
280
+ test: new RegExp(`<input\\b(?!${ATTRS}\\btype=["']hidden["'])${ATTRS}>`, "g"),
255
281
  message: "Use <Input> from @godxjp/ui, not a raw <input> (rules §3).",
256
282
  },
257
283
  {
258
284
  id: "no-raw-button",
285
+ scope: "consumer-control",
259
286
  severity: "error",
260
287
  test: /<button[\s>]/,
261
288
  message: "Use <Button> from @godxjp/ui, not a raw <button> (rules §3).",
@@ -270,6 +297,7 @@ const RULES = [
270
297
  },
271
298
  {
272
299
  id: "no-dark-color-override",
300
+ classOnly: true,
273
301
  severity: "warn",
274
302
  test: /\bdark:(bg|text|border|ring|fill|stroke)-/,
275
303
  message: "Don't add dark: color overrides — semantic tokens already adapt (rules §4).",
@@ -291,6 +319,7 @@ const RULES = [
291
319
  },
292
320
  {
293
321
  id: "raw-white-black",
322
+ classOnly: true,
294
323
  severity: "warn",
295
324
  test: /\b(bg|text|border)-(white|black)\b/,
296
325
  message:
@@ -316,10 +345,12 @@ const RULES = [
316
345
  },
317
346
  {
318
347
  id: "no-physical-direction",
348
+ classOnly: true,
319
349
  severity: "warn",
320
350
  // Physical-edge utilities break RTL. Logical equivalents: ms-/me-/ps-/pe-, start-/end-,
321
351
  // text-start/end, border-s/e, rounded-s/e. `-mx-`/`-px-` (both edges) are RTL-safe → not matched.
322
- 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/,
323
354
  standard: "W3C CSS Logical Properties & Values L1 · WCAG 2.2 (1.3.2 / reflow)",
324
355
  message:
325
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.",
@@ -357,9 +388,14 @@ const RULES = [
357
388
  },
358
389
  {
359
390
  id: "hardcoded-currency",
391
+ matches: currencyMatches,
360
392
  severity: "warn",
361
393
  // A currency glyph glued to an interpolation in JSX text: `>¥{amount}` / `>{x}円`.
362
- test: /(?:>[\s]*[¥$€£₫]\s*\{)|(?:\}\s*円)/,
394
+ spansElement: true,
395
+ test: new RegExp(
396
+ `<(?:[A-Za-z][\\w.:]*(?:\\s${ATTRS})?)?(?<!=)>[^<>{}]*[¥$€£₫]\\s*\\{|\\}\\s*円\\s*</`,
397
+ "g",
398
+ ),
363
399
  standard: "ISO 4217 · ECMA-402 Intl.NumberFormat",
364
400
  message:
365
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.",
@@ -390,7 +426,12 @@ const RULES = [
390
426
  id: "no-em-dash-in-copy",
391
427
  severity: "warn",
392
428
  // Em-dash (U+2014) in JSX text — the reference design uses the middot `·` for JP/EN pairs and calm copy.
393
- 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
+ ),
394
435
  standard: "@godxjp/ui reference-design typography (best-ux) · Unicode punctuation",
395
436
  message:
396
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.",
@@ -474,6 +515,128 @@ function stripComments(src) {
474
515
  return out;
475
516
  }
476
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
+
477
640
  /** A finding is suppressed by `ui-audit-disable-line <rule>` on the same line, or
478
641
  * `ui-audit-disable-next-line <rule>` on the line above (explicit rule id required). */
479
642
  function isSuppressed(ruleId, sameLine, prevLine) {
@@ -524,6 +687,33 @@ function walk(dir, acc = []) {
524
687
  return acc;
525
688
  }
526
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
+
527
717
  // Structural: a <Card> (without p-0) whose first child is body content rather than a Card
528
718
  // sub-component sits FLUSH (no padding). Per-line regexes can't see across lines, so this is a
529
719
  // whole-file pass. The body must be wrapped in <CardContent> (titles in <CardHeader>).
@@ -553,13 +743,7 @@ const SIBLING_CARDS = /^([ \t]*)<\/Card>\s*\n\1<Card\b/gm;
553
743
  * utilities are the design system itself (that is where `flex`/`gap-*` are supposed to live), so
554
744
  * those rules are skipped when the CWD package is @godxjp/ui — unless `--consumer` forces them.
555
745
  */
556
- const SELF = (() => {
557
- try {
558
- return JSON.parse(readFileSync(join(CWD, "package.json"), "utf8")).name === "@godxjp/ui";
559
- } catch {
560
- return false;
561
- }
562
- })();
746
+
563
747
  const ACTIVE_RULES =
564
748
  SELF && !args.includes("--consumer") ? RULES.filter((r) => r.scope !== "consumer") : RULES;
565
749
 
@@ -568,17 +752,41 @@ let filesScanned = 0;
568
752
  for (const dir of SCAN_DIRS) {
569
753
  for (const file of walk(isAbsolute(dir) ? dir : join(CWD, dir))) {
570
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;
571
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;
572
764
  const content = readFileSync(file, "utf8");
573
765
  const origLines = content.split("\n");
574
766
  const scanContent = stripComments(content); // comments blanked; strings + line numbers kept
575
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");
576
781
  scanLines.forEach((line, i) => {
577
- 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;
578
785
  if (rule.spansElement) continue; // matched over the whole file below, not line by line
786
+ const target = rule.classOnly ? (classLines[i] ?? "") : line;
579
787
  if (
580
- (typeof rule.test === "function" ? rule.test(line) : rule.test.test(line)) &&
581
- !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) &&
582
790
  !isExempt(rule, origLines[i], origLines[i - 1])
583
791
  ) {
584
792
  findings.push({
@@ -596,11 +804,20 @@ for (const dir of SCAN_DIRS) {
596
804
  // Element-spanning rules: matched against the WHOLE file, because a JSX element prettier wrapped
597
805
  // over five lines is invisible to a line-by-line scan. Same offset→line-number idiom as the
598
806
  // block rules below.
599
- for (const rule of ACTIVE_RULES) {
600
- if (!rule.spansElement) continue;
601
- for (const match of scanContent.matchAll(rule.test)) {
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;
602
819
  const lineNo = scanContent.slice(0, match.index).split("\n").length;
603
- if (isSuppressed(rule.id, origLines[lineNo - 1], origLines[lineNo - 2])) continue;
820
+ if (suppressed(rule.id, lineNo - 1)) continue;
604
821
  if (isExempt(rule, origLines[lineNo - 1], origLines[lineNo - 2])) continue;
605
822
  findings.push({
606
823
  file: rel,
@@ -613,10 +830,10 @@ for (const dir of SCAN_DIRS) {
613
830
  });
614
831
  }
615
832
  }
833
+ if (!isJsx) continue;
616
834
  for (const match of scanContent.matchAll(CARD_FLUSH)) {
617
835
  const lineNo = scanContent.slice(0, match.index).split("\n").length;
618
- if (isSuppressed("card-needs-content", origLines[lineNo - 1], origLines[lineNo - 2]))
619
- continue;
836
+ if (suppressed("card-needs-content", lineNo - 1)) continue;
620
837
  findings.push({
621
838
  file: rel,
622
839
  line: lineNo,
@@ -633,8 +850,7 @@ for (const dir of SCAN_DIRS) {
633
850
  ) {
634
851
  for (const match of scanContent.matchAll(SIBLING_CARDS)) {
635
852
  const lineNo = scanContent.slice(0, match.index).split("\n").length + 1;
636
- if (isSuppressed("sibling-cards-need-flex", origLines[lineNo - 1], origLines[lineNo - 2]))
637
- continue;
853
+ if (suppressed("sibling-cards-need-flex", lineNo - 1)) continue;
638
854
  findings.push({
639
855
  file: rel,
640
856
  line: lineNo,
@@ -648,10 +864,7 @@ for (const dir of SCAN_DIRS) {
648
864
  }
649
865
  for (const match of scanContent.matchAll(BARE_FIELD)) {
650
866
  const lineNo = scanContent.slice(0, match.index).split("\n").length;
651
- if (
652
- isSuppressed("bare-control-needs-formfield", origLines[lineNo - 1], origLines[lineNo - 2])
653
- )
654
- continue;
867
+ if (suppressed("bare-control-needs-formfield", lineNo - 1)) continue;
655
868
  findings.push({
656
869
  file: rel,
657
870
  line: lineNo,