basalt-ui 1.24.0 → 1.26.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 (467) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +25 -0
  3. package/MIGRATING.md +547 -21
  4. package/README.md +46 -33
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +155 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +113 -96
  10. package/agent/rules/basalt-tokens.md +126 -288
  11. package/agent/skills/basalt-app/SKILL.md +70 -195
  12. package/agent/skills/basalt-charts/SKILL.md +54 -234
  13. package/agent/skills/basalt-design/SKILL.md +56 -248
  14. package/agent/templates/CLAUDE-block.md.tpl +31 -40
  15. package/agent/templates/DESIGN.md.tpl +22 -51
  16. package/configs/oxlint-plugin.js +1275 -31
  17. package/configs/oxlint-plugin.test.ts +947 -11
  18. package/configs/oxlint.json +11 -3
  19. package/dist/charts/cursor/resolve.d.ts +18 -0
  20. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  21. package/dist/charts/cursor/resolve.js +15 -6
  22. package/dist/charts/cursor/scope.d.ts +6 -3
  23. package/dist/charts/cursor/scope.d.ts.map +1 -1
  24. package/dist/charts/cursor/store.d.ts +5 -1
  25. package/dist/charts/cursor/store.d.ts.map +1 -1
  26. package/dist/charts/cursor/store.js +4 -4
  27. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  28. package/dist/charts/hooks/useChartCursor.js +9 -8
  29. package/dist/charts/index.d.ts +2 -2
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/primitives/ChartCard.d.ts +34 -9
  32. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.js +29 -114
  34. package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
  35. package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
  36. package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
  37. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  38. package/dist/charts/sparklines/BarSparkline.js +18 -4
  39. package/dist/cli/check-coverage.test.d.ts +2 -0
  40. package/dist/cli/check-coverage.test.d.ts.map +1 -0
  41. package/dist/cli/index.d.ts +67 -4
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +225 -7
  44. package/dist/connectivity/connectivity-indicator.d.ts +4 -1
  45. package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
  46. package/dist/connectivity/connectivity-indicator.js +1 -1
  47. package/dist/content/index.d.ts +5 -3
  48. package/dist/content/index.d.ts.map +1 -1
  49. package/dist/content/index.js +4 -2
  50. package/dist/content/prose.module.css +7 -28
  51. package/dist/controls/actions.d.ts +169 -0
  52. package/dist/controls/actions.d.ts.map +1 -0
  53. package/dist/controls/actions.js +287 -0
  54. package/dist/controls/actions.module.css +62 -0
  55. package/dist/controls/actions.module.css.d.ts +13 -0
  56. package/dist/controls/actions.test.d.ts +2 -0
  57. package/dist/controls/actions.test.d.ts.map +1 -0
  58. package/dist/controls/actions.type-guard.test.d.ts +2 -0
  59. package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
  60. package/dist/controls/compare-filter.d.ts +41 -0
  61. package/dist/controls/compare-filter.d.ts.map +1 -0
  62. package/dist/controls/compare-filter.js +23 -0
  63. package/dist/controls/control-group.d.ts +19 -0
  64. package/dist/controls/control-group.d.ts.map +1 -0
  65. package/dist/controls/control-group.js +20 -0
  66. package/dist/controls/control-group.test.d.ts +2 -0
  67. package/dist/controls/control-group.test.d.ts.map +1 -0
  68. package/dist/controls/controls.module.css +542 -0
  69. package/dist/controls/controls.module.css.d.ts +35 -0
  70. package/dist/controls/controls.router.test.d.ts +2 -0
  71. package/dist/controls/controls.router.test.d.ts.map +1 -0
  72. package/dist/controls/enum-filter.d.ts +30 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +91 -0
  75. package/dist/controls/filter-context.d.ts +55 -0
  76. package/dist/controls/filter-context.d.ts.map +1 -0
  77. package/dist/controls/filter-context.js +69 -0
  78. package/dist/controls/filter-pill.d.ts +28 -0
  79. package/dist/controls/filter-pill.d.ts.map +1 -0
  80. package/dist/controls/filter-pill.js +70 -0
  81. package/dist/controls/filter-set.d.ts +13 -0
  82. package/dist/controls/filter-set.d.ts.map +1 -0
  83. package/dist/controls/filter-set.js +125 -0
  84. package/dist/controls/filter-set.test.d.ts +2 -0
  85. package/dist/controls/filter-set.test.d.ts.map +1 -0
  86. package/dist/controls/filter-sheet.d.ts +117 -0
  87. package/dist/controls/filter-sheet.d.ts.map +1 -0
  88. package/dist/controls/filter-sheet.js +146 -0
  89. package/dist/controls/glyphs.d.ts +27 -0
  90. package/dist/controls/glyphs.d.ts.map +1 -0
  91. package/dist/controls/glyphs.js +49 -0
  92. package/dist/controls/index.d.ts +19 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +44 -0
  95. package/dist/controls/multi-select-filter.d.ts +23 -0
  96. package/dist/controls/multi-select-filter.d.ts.map +1 -0
  97. package/dist/controls/multi-select-filter.js +89 -0
  98. package/dist/controls/peer-boundary.test.d.ts +2 -0
  99. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  100. package/dist/controls/props-contract.test.d.ts +2 -0
  101. package/dist/controls/props-contract.test.d.ts.map +1 -0
  102. package/dist/controls/range-filter.d.ts +29 -0
  103. package/dist/controls/range-filter.d.ts.map +1 -0
  104. package/dist/controls/range-filter.js +120 -0
  105. package/dist/controls/search-filter.d.ts +10 -0
  106. package/dist/controls/search-filter.d.ts.map +1 -0
  107. package/dist/controls/search-filter.js +81 -0
  108. package/dist/controls/select-filter.d.ts +74 -0
  109. package/dist/controls/select-filter.d.ts.map +1 -0
  110. package/dist/controls/select-filter.js +10 -0
  111. package/dist/controls/sync-button.d.ts +38 -0
  112. package/dist/controls/sync-button.d.ts.map +1 -0
  113. package/dist/controls/sync-button.js +119 -0
  114. package/dist/controls/sync-button.module.css +63 -0
  115. package/dist/controls/sync-button.module.css.d.ts +11 -0
  116. package/dist/controls/sync-button.test.d.ts +2 -0
  117. package/dist/controls/sync-button.test.d.ts.map +1 -0
  118. package/dist/controls/toggle-filter.d.ts +9 -0
  119. package/dist/controls/toggle-filter.d.ts.map +1 -0
  120. package/dist/controls/toggle-filter.js +42 -0
  121. package/dist/controls/view-tabs.d.ts +23 -0
  122. package/dist/controls/view-tabs.d.ts.map +1 -0
  123. package/dist/controls/view-tabs.js +59 -0
  124. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  125. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  126. package/dist/controls-dates/date-range-picker.js +18 -0
  127. package/dist/controls-dates/index.d.ts +9 -0
  128. package/dist/controls-dates/index.d.ts.map +1 -0
  129. package/dist/controls-dates/index.js +4 -0
  130. package/dist/dashboard/delta-badge.d.ts +6 -12
  131. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  132. package/dist/dashboard/delta-badge.js +1 -50
  133. package/dist/dashboard/query-state.d.ts +1 -1
  134. package/dist/dashboard/settings-section.d.ts +6 -4
  135. package/dist/dashboard/settings-section.d.ts.map +1 -1
  136. package/dist/dashboard/settings-section.js +23 -16
  137. package/dist/dashboard/settings-section.module.css +10 -0
  138. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  139. package/dist/dashboard/settings-section.test.d.ts +2 -0
  140. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  141. package/dist/dashboard/stat-card.d.ts +44 -9
  142. package/dist/dashboard/stat-card.d.ts.map +1 -1
  143. package/dist/dashboard/stat-card.js +80 -48
  144. package/dist/dashboard/stat-card.module.css +82 -0
  145. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  146. package/dist/data/data-table.d.ts +102 -20
  147. package/dist/data/data-table.d.ts.map +1 -1
  148. package/dist/data/data-table.js +166 -65
  149. package/dist/data/table.d.ts +1 -1
  150. package/dist/data/table.d.ts.map +1 -1
  151. package/dist/guard/grace.test.d.ts +20 -0
  152. package/dist/guard/grace.test.d.ts.map +1 -0
  153. package/dist/guard/index.d.ts +66 -3
  154. package/dist/guard/index.d.ts.map +1 -1
  155. package/dist/guard/index.js +82 -7
  156. package/dist/guard/types.d.ts +3 -3
  157. package/dist/guard/types.d.ts.map +1 -1
  158. package/dist/index.d.ts +12 -2
  159. package/dist/index.d.ts.map +1 -1
  160. package/dist/index.js +13 -10
  161. package/dist/nav/types.d.ts +78 -2
  162. package/dist/nav/types.d.ts.map +1 -1
  163. package/dist/notifications/bell.d.ts +4 -1
  164. package/dist/notifications/bell.d.ts.map +1 -1
  165. package/dist/notifications/index.d.ts +1 -1
  166. package/dist/router-tanstack/field.d.ts +14 -0
  167. package/dist/router-tanstack/field.d.ts.map +1 -0
  168. package/dist/router-tanstack/field.js +4 -0
  169. package/dist/router-tanstack/index.d.ts +6 -2
  170. package/dist/router-tanstack/index.d.ts.map +1 -1
  171. package/dist/router-tanstack/index.js +4 -0
  172. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  173. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  175. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  176. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  177. package/dist/router-tanstack/search-param-store.js +16 -45
  178. package/dist/router-tanstack/search-store.d.ts +81 -0
  179. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.js +187 -0
  181. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  182. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  183. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  184. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  185. package/dist/router-tanstack/use-nav.d.ts +0 -2
  186. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  187. package/dist/router-tanstack/use-nav.js +34 -12
  188. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  189. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  190. package/dist/section/index.d.ts +2 -0
  191. package/dist/section/index.d.ts.map +1 -0
  192. package/dist/section/index.js +4 -0
  193. package/dist/section/section.d.ts +24 -0
  194. package/dist/section/section.d.ts.map +1 -0
  195. package/dist/section/section.js +126 -0
  196. package/dist/section/section.module.css +104 -0
  197. package/dist/section/section.module.css.d.ts +11 -0
  198. package/dist/section/section.test.d.ts +2 -0
  199. package/dist/section/section.test.d.ts.map +1 -0
  200. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  201. package/dist/shell/app-breadcrumbs.js +18 -15
  202. package/dist/shell/app-header.module.css +77 -37
  203. package/dist/shell/app-header.module.css.d.ts +2 -1
  204. package/dist/shell/app-mobile-nav.d.ts +9 -2
  205. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  206. package/dist/shell/app-mobile-nav.js +98 -11
  207. package/dist/shell/app-mobile-nav.module.css +8 -0
  208. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  209. package/dist/shell/app-sidebar.d.ts +55 -28
  210. package/dist/shell/app-sidebar.d.ts.map +1 -1
  211. package/dist/shell/app-sidebar.js +116 -67
  212. package/dist/shell/app-sidebar.module.css +303 -20
  213. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  214. package/dist/shell/index.d.ts +55 -21
  215. package/dist/shell/index.d.ts.map +1 -1
  216. package/dist/shell/index.js +40 -18
  217. package/dist/shell/mobile-nav-model.d.ts +13 -2
  218. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  219. package/dist/shell/mobile-nav-model.js +15 -1
  220. package/dist/shell/nav-count-badge.d.ts +1 -1
  221. package/dist/shell/page-bar.d.ts +51 -0
  222. package/dist/shell/page-bar.d.ts.map +1 -0
  223. package/dist/shell/page-bar.js +154 -0
  224. package/dist/shell/page-bar.module.css +178 -0
  225. package/dist/shell/page-bar.module.css.d.ts +17 -0
  226. package/dist/shell/page-bar.test.d.ts +2 -0
  227. package/dist/shell/page-bar.test.d.ts.map +1 -0
  228. package/dist/shell/sidebar-block-model.d.ts +48 -0
  229. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  230. package/dist/shell/sidebar-block-model.js +39 -0
  231. package/dist/shell/sidebar-blocks.d.ts +32 -0
  232. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  233. package/dist/shell/sidebar-blocks.js +169 -0
  234. package/dist/shell/sidebar-search.d.ts +13 -1
  235. package/dist/shell/sidebar-search.d.ts.map +1 -1
  236. package/dist/shell/sidebar-search.js +34 -1
  237. package/dist/shell/sidebar-search.module.css +26 -0
  238. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  239. package/dist/state/fields.d.ts +392 -0
  240. package/dist/state/fields.d.ts.map +1 -0
  241. package/dist/state/fields.js +369 -0
  242. package/dist/state/fields.test.d.ts +2 -0
  243. package/dist/state/fields.test.d.ts.map +1 -0
  244. package/dist/state/persisted.d.ts +48 -0
  245. package/dist/state/persisted.d.ts.map +1 -0
  246. package/dist/state/persisted.js +105 -0
  247. package/dist/state.d.ts +11 -56
  248. package/dist/state.d.ts.map +1 -1
  249. package/dist/state.js +14 -120
  250. package/dist/styles.css +13 -0
  251. package/dist/surfaces.d.ts +106 -24
  252. package/dist/surfaces.d.ts.map +1 -1
  253. package/dist/surfaces.js +155 -19
  254. package/dist/theme/controls.module.css +16 -1
  255. package/dist/theme/ctl-theme.d.ts +32 -0
  256. package/dist/theme/ctl-theme.d.ts.map +1 -0
  257. package/dist/theme/ctl-theme.js +54 -0
  258. package/dist/theme/ctl-theme.test.d.ts +2 -0
  259. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  260. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  261. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  262. package/dist/theme/divider-law.test.d.ts +2 -0
  263. package/dist/theme/divider-law.test.d.ts.map +1 -0
  264. package/dist/theme/icon-slot.d.ts +44 -0
  265. package/dist/theme/icon-slot.d.ts.map +1 -0
  266. package/dist/theme/icon-slot.js +21 -0
  267. package/dist/theme/icon-slot.module.css +47 -0
  268. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  269. package/dist/theme/icon-slot.test.d.ts +2 -0
  270. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  271. package/dist/theme/index.d.ts +1 -0
  272. package/dist/theme/index.d.ts.map +1 -1
  273. package/dist/theme/index.js +82 -5
  274. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  275. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  276. package/dist/theme/segmented-control.module.css +20 -0
  277. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  278. package/dist/tokens/index.d.ts +12 -0
  279. package/dist/tokens/index.d.ts.map +1 -1
  280. package/dist/tokens/index.js +38 -8
  281. package/dist/tokens/palette.d.ts +97 -61
  282. package/dist/tokens/palette.d.ts.map +1 -1
  283. package/dist/tokens/palette.js +76 -38
  284. package/dist/tokens.css +10 -2
  285. package/dist/widget-header/delta-badge.d.ts +13 -0
  286. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  287. package/dist/widget-header/delta-badge.js +29 -0
  288. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  289. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  290. package/dist/widget-header/index.d.ts +3 -0
  291. package/dist/widget-header/index.d.ts.map +1 -0
  292. package/dist/widget-header/index.js +6 -0
  293. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  294. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  295. package/dist/widget-header/widget-header.d.ts +37 -0
  296. package/dist/widget-header/widget-header.d.ts.map +1 -0
  297. package/dist/widget-header/widget-header.js +77 -0
  298. package/dist/widget-header/widget-header.module.css +335 -0
  299. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  300. package/dist/widget-header/widget-header.test.d.ts +2 -0
  301. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  302. package/llms.txt +30 -17
  303. package/package.json +14 -1
  304. package/src/charts/cursor/resolve.test.ts +29 -1
  305. package/src/charts/cursor/resolve.ts +38 -6
  306. package/src/charts/cursor/scope.tsx +6 -3
  307. package/src/charts/cursor/store.test.ts +19 -1
  308. package/src/charts/cursor/store.ts +10 -5
  309. package/src/charts/hooks/useChartCursor.ts +16 -8
  310. package/src/charts/index.ts +2 -2
  311. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  312. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  313. package/src/charts/primitives/ChartCard.tsx +86 -146
  314. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  315. package/src/cli/check-coverage.test.ts +297 -0
  316. package/src/cli/check-theme.test.ts +38 -14
  317. package/src/cli/index.ts +392 -16
  318. package/src/cli/init-sync.test.ts +97 -0
  319. package/src/connectivity/connectivity-indicator.tsx +16 -9
  320. package/src/content/index.ts +16 -3
  321. package/src/content/prose.module.css +7 -28
  322. package/src/content/prose.module.css.test.ts +8 -24
  323. package/src/controls/actions.module.css +62 -0
  324. package/src/controls/actions.module.css.d.ts +13 -0
  325. package/src/controls/actions.test.tsx +252 -0
  326. package/src/controls/actions.tsx +655 -0
  327. package/src/controls/actions.type-guard.test.ts +63 -0
  328. package/src/controls/compare-filter.tsx +64 -0
  329. package/src/controls/control-group.test.tsx +229 -0
  330. package/src/controls/control-group.tsx +63 -0
  331. package/src/controls/controls.module.css +542 -0
  332. package/src/controls/controls.module.css.d.ts +35 -0
  333. package/src/controls/controls.router.test.tsx +637 -0
  334. package/src/controls/enum-filter.tsx +159 -0
  335. package/src/controls/filter-context.tsx +153 -0
  336. package/src/controls/filter-pill.tsx +120 -0
  337. package/src/controls/filter-set.test.tsx +472 -0
  338. package/src/controls/filter-set.tsx +225 -0
  339. package/src/controls/filter-sheet.tsx +285 -0
  340. package/src/controls/glyphs.tsx +81 -0
  341. package/src/controls/index.ts +48 -0
  342. package/src/controls/multi-select-filter.tsx +140 -0
  343. package/src/controls/peer-boundary.test.ts +85 -0
  344. package/src/controls/props-contract.test.tsx +106 -0
  345. package/src/controls/range-filter.tsx +184 -0
  346. package/src/controls/search-filter.tsx +123 -0
  347. package/src/controls/select-filter.tsx +89 -0
  348. package/src/controls/sync-button.module.css +63 -0
  349. package/src/controls/sync-button.module.css.d.ts +11 -0
  350. package/src/controls/sync-button.test.tsx +183 -0
  351. package/src/controls/sync-button.tsx +197 -0
  352. package/src/controls/toggle-filter.tsx +60 -0
  353. package/src/controls/view-tabs.tsx +110 -0
  354. package/src/controls-dates/date-range-picker.tsx +42 -0
  355. package/src/controls-dates/index.ts +8 -0
  356. package/src/dashboard/delta-badge.tsx +4 -74
  357. package/src/dashboard/query-state.tsx +1 -1
  358. package/src/dashboard/settings-section.module.css +10 -0
  359. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  360. package/src/dashboard/settings-section.test.tsx +56 -0
  361. package/src/dashboard/settings-section.tsx +33 -30
  362. package/src/dashboard/stat-card.module.css +82 -0
  363. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  364. package/src/dashboard/stat-card.test.tsx +67 -1
  365. package/src/dashboard/stat-card.tsx +209 -68
  366. package/src/data/data-table.test.tsx +282 -6
  367. package/src/data/data-table.tsx +429 -94
  368. package/src/data/table.ts +2 -0
  369. package/src/guard/check-source.test.ts +126 -13
  370. package/src/guard/grace.test.ts +149 -0
  371. package/src/guard/index.ts +172 -29
  372. package/src/guard/types.ts +4 -2
  373. package/src/index.ts +46 -5
  374. package/src/nav/types.ts +84 -2
  375. package/src/notifications/bell.tsx +6 -3
  376. package/src/notifications/index.ts +1 -1
  377. package/src/router-tanstack/field.ts +31 -0
  378. package/src/router-tanstack/index.ts +24 -2
  379. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  380. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  381. package/src/router-tanstack/search-param-store.ts +42 -159
  382. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  383. package/src/router-tanstack/search-store.test.ts +418 -0
  384. package/src/router-tanstack/search-store.ts +380 -0
  385. package/src/router-tanstack/use-nav.test.tsx +187 -0
  386. package/src/router-tanstack/use-nav.ts +72 -14
  387. package/src/section/index.ts +1 -0
  388. package/src/section/section.module.css +104 -0
  389. package/src/section/section.module.css.d.ts +11 -0
  390. package/src/section/section.test.tsx +204 -0
  391. package/src/section/section.tsx +220 -0
  392. package/src/shell/app-breadcrumbs.tsx +44 -31
  393. package/src/shell/app-header.module.css +77 -37
  394. package/src/shell/app-header.module.css.d.ts +2 -1
  395. package/src/shell/app-mobile-nav.module.css +8 -0
  396. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  397. package/src/shell/app-mobile-nav.tsx +147 -8
  398. package/src/shell/app-sidebar-account.tsx +2 -2
  399. package/src/shell/app-sidebar.module.css +303 -20
  400. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  401. package/src/shell/app-sidebar.test.tsx +456 -41
  402. package/src/shell/app-sidebar.tsx +276 -140
  403. package/src/shell/index.test.tsx +291 -5
  404. package/src/shell/index.tsx +137 -38
  405. package/src/shell/mobile-nav-model.test.ts +228 -7
  406. package/src/shell/mobile-nav-model.ts +53 -3
  407. package/src/shell/nav-count-badge.tsx +1 -1
  408. package/src/shell/page-bar.module.css +178 -0
  409. package/src/shell/page-bar.module.css.d.ts +17 -0
  410. package/src/shell/page-bar.test.tsx +316 -0
  411. package/src/shell/page-bar.tsx +315 -0
  412. package/src/shell/sidebar-block-model.ts +83 -0
  413. package/src/shell/sidebar-blocks.tsx +301 -0
  414. package/src/shell/sidebar-search.module.css +26 -0
  415. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  416. package/src/shell/sidebar-search.tsx +71 -2
  417. package/src/state/fields.test.tsx +258 -0
  418. package/src/state/fields.ts +892 -0
  419. package/src/state/persisted.ts +211 -0
  420. package/src/state.ts +46 -240
  421. package/src/styles.css +13 -0
  422. package/src/surfaces.ts +203 -36
  423. package/src/theme/controls.module.css +16 -1
  424. package/src/theme/ctl-theme.test.tsx +275 -0
  425. package/src/theme/ctl-theme.tsx +138 -0
  426. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  427. package/src/theme/density-relations.test.ts +106 -51
  428. package/src/theme/divider-law.test.ts +259 -0
  429. package/src/theme/icon-slot.module.css +47 -0
  430. package/src/theme/icon-slot.module.css.d.ts +8 -0
  431. package/src/theme/icon-slot.test.tsx +275 -0
  432. package/src/theme/icon-slot.tsx +59 -0
  433. package/src/theme/index.ts +165 -3
  434. package/src/theme/layout-rhythm-css.test.ts +72 -0
  435. package/src/theme/radius.test.ts +11 -1
  436. package/src/theme/segmented-control.module.css +20 -0
  437. package/src/theme/shadow-surfaces.test.ts +20 -7
  438. package/src/theme/spacing.test.ts +75 -27
  439. package/src/theme/use-basalt-spacing.ts +2 -2
  440. package/src/tokens/density.test.ts +7 -25
  441. package/src/tokens/index.ts +48 -8
  442. package/src/tokens/palette.ts +107 -73
  443. package/src/widget-header/delta-badge.test.tsx +60 -0
  444. package/src/widget-header/delta-badge.tsx +57 -0
  445. package/src/widget-header/index.ts +2 -0
  446. package/src/widget-header/no-mantine.test.ts +33 -0
  447. package/src/widget-header/widget-header.module.css +335 -0
  448. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  449. package/src/widget-header/widget-header.test.tsx +187 -0
  450. package/src/widget-header/widget-header.tsx +168 -0
  451. package/agent/rules/basalt-agent.md +0 -452
  452. package/agent/rules/basalt-app.md +0 -246
  453. package/agent/rules/basalt-commands.md +0 -320
  454. package/agent/rules/basalt-content.md +0 -409
  455. package/agent/rules/basalt-data.md +0 -269
  456. package/agent/rules/basalt-forms.md +0 -186
  457. package/agent/rules/basalt-notifications.md +0 -264
  458. package/agent/rules/basalt-query.md +0 -187
  459. package/agent/rules/basalt-router.md +0 -336
  460. package/dist/content/article-filter-bar.d.ts +0 -40
  461. package/dist/content/article-filter-bar.d.ts.map +0 -1
  462. package/dist/content/article-filter-bar.js +0 -56
  463. package/dist/shell/page-header.d.ts +0 -13
  464. package/dist/shell/page-header.d.ts.map +0 -1
  465. package/dist/shell/page-header.js +0 -23
  466. package/src/content/article-filter-bar.tsx +0 -88
  467. package/src/shell/page-header.tsx +0 -35
@@ -102,6 +102,15 @@ export const KNOWN_RULE_IDS = new Set([
102
102
  'hand-rolled-shell',
103
103
  'shadow-basalt-export',
104
104
  'raw-scroll-container',
105
+ 'hand-rolled-filter',
106
+ 'control-outside-home',
107
+ 'control-size-literal',
108
+ 'page-bar-budget',
109
+ 'responsive-twin',
110
+ 'search-literal-link',
111
+ 'use-search-from-literal',
112
+ // `in-body-page-title` is BOTH a plugin rule and a guard kind — one id, two lanes, so one
113
+ // annotation waives both. It is listed once, below, with the guard kinds.
105
114
  'visx-boundary',
106
115
  'visx-tooltip',
107
116
  'token-layer-boundary',
@@ -136,6 +145,8 @@ export const KNOWN_RULE_IDS = new Set([
136
145
  'css-raw-surface',
137
146
  'inline-font-size',
138
147
  'hidden-inline-style',
148
+ 'in-body-page-title',
149
+ 'raw-selection-control',
139
150
  ])
140
151
 
141
152
  const ALLOW_RULE_TOKEN = /^(?:basalt\/)?([a-z][a-z0-9-]*)(?=$|[\s,:—–])/
@@ -654,12 +665,42 @@ const RAW_SCROLL_CONTAINER_MESSAGE =
654
665
  'node (BasaltStickToBottom, BasaltVirtualList) — add a theme-allow comment there. ' +
655
666
  '(basalt/raw-scroll-container)'
656
667
 
657
- // `overflowX` is deliberately absent: a horizontal bar doesn't reserve gutter width in a chrome
658
- // column, so a horizontally-scrolling code block or pinned-column table is its own legitimate
659
- // pattern, not a ScrollArea candidate. Only the vertical axis carries the doctrine.
668
+ // `overflowX` is absent from the UNIVERSAL set for the original reason: a horizontal bar doesn't
669
+ // reserve gutter width in a chrome column, so a horizontally-scrolling code block or pinned-column
670
+ // table is its own legitimate pattern, not a ScrollArea candidate. It IS policed inside a home,
671
+ // where the doctrine is the opposite one — see RAW_SCROLL_CONTAINER_HOME_MESSAGE.
660
672
  const OVERFLOW_KEYS = new Set(['overflow', 'overflowY'])
661
673
  const SCROLLING_VALUES = new Set(['auto', 'scroll'])
662
674
 
675
+ const RAW_SCROLL_CONTAINER_HOME_MESSAGE =
676
+ 'Horizontal scroll region inside a home — a page bar, a section header and a chart card never ' +
677
+ 'scroll sideways and never wrap: overflow folds into a `More` menu (actions) or a `Filters (n)` ' +
678
+ 'sheet (filters), computed by basalt from the typed BarAction[] (law C7). A sideways-scrolling ' +
679
+ 'row of controls is the shape that law replaces. (basalt/raw-scroll-container)'
680
+
681
+ /**
682
+ * The basalt home SLOT this node is written in — the owner binding of the enclosing `actions` /
683
+ * `filters` / `tabs` / `sync` / `filtersEnd` attribute, or undefined.
684
+ *
685
+ * The C7 widening is scoped to slot VALUES, not to "anywhere under a `Section`/`ChartCard`". It
686
+ * used to accept any such ancestor, body children included, which made every horizontally
687
+ * scrolling `<Table>`, `<pre>` or `CodeBlock` on a page an `error` carrying the C7 message about
688
+ * "a sideways-scrolling row of controls" — a claim that was false for exactly the pattern this
689
+ * rule's own comment above calls legitimate. A section BODY is page content, not a control row, so
690
+ * it is out of scope again, and the walk stops at the slot ATTRIBUTE (the boundary `SLOT_ATTRS`
691
+ * draws for every control rule).
692
+ *
693
+ * Scoped by ANCESTRY only, with no hoisted-binding lookup: a style object hoisted to a `const` has
694
+ * no JSX ancestor at all, so a hoisted `{ overflowX: 'auto' }` handed to a slot is a known false
695
+ * negative here — the same gap `chart-legend-literal` documents for a hoisted `items` array, and
696
+ * for the same reason (following it means local flow analysis for a bypass nobody reaches by
697
+ * accident).
698
+ */
699
+ function enclosingHomeOwner(node) {
700
+ const attr = enclosingSlotAttribute(node)
701
+ return attr === undefined ? undefined : slotOwnerBinding(attr)
702
+ }
703
+
663
704
  /**
664
705
  * Reports a `style` object property that turns a node into its own scroll container. Whether a raw
665
706
  * scroll box is wrong depends on who owns the scroll node, which no AST check can see — so the
@@ -668,6 +709,16 @@ const SCROLLING_VALUES = new Set(['auto', 'scroll'])
668
709
  * `BasaltVirtualList`, `ThreadTranscript`'s virtualized pane) declares that ownership with the
669
710
  * comment and moves on. Severity went `off` → `warn` (1.12.0) → `error` (1.13.0) once every live
670
711
  * site in the repo carried that declaration; the opt-out mechanism is identical at either level.
712
+ *
713
+ * **The C7 widening (wave 6) inherits that `error` with no grace runway, and that is a LIMITATION,
714
+ * not a decision.** oxlint severity is per rule ID, so a widened branch cannot ship `warn` while
715
+ * the incumbent branch stays `error` — the only way to buy a runway would have been a second rule
716
+ * id, which is the bundled-vs-split trade the three boundary rules were split apart over, in the
717
+ * opposite direction. What the widening does instead is stay narrow: `overflowX` and
718
+ * `ScrollArea scrollbars="x"` are policed ONLY inside a basalt home SLOT (see
719
+ * {@link enclosingHomeOwner}), which is where C7 actually applies, so the set of newly-rejected
720
+ * code is small enough to fix rather than schedule. A consumer who disagrees turns the whole rule
721
+ * down in their own config.
671
722
  */
672
723
  const rawScrollContainer = {
673
724
  meta: {
@@ -678,15 +729,57 @@ const rawScrollContainer = {
678
729
  schema: [],
679
730
  },
680
731
  create(context) {
732
+ const homes = new Set()
733
+ const mantineImports = new Map()
734
+ const ownTree = isBasaltOwnSource(getFilename(context))
735
+ // Both halves report at `Program:exit`: whether the enclosing `Section` is BASALT's is an
736
+ // import-list question, and the ancestry that finds the slot is only walkable during the visit.
737
+ const candidates = []
738
+
681
739
  return {
740
+ ImportDeclaration(node) {
741
+ collectBasaltImports(node, homes, ownTree)
742
+ collectMantineImports(node, mantineImports)
743
+ },
682
744
  Property(node) {
683
745
  const key = node.key
684
746
  const keyName =
685
747
  key.type === 'Identifier' ? key.name : key.type === 'Literal' ? key.value : undefined
686
- if (typeof keyName !== 'string' || !OVERFLOW_KEYS.has(keyName)) return
748
+ if (typeof keyName !== 'string') return
749
+ const universal = OVERFLOW_KEYS.has(keyName)
750
+ if (!universal && keyName !== 'overflowX') return
687
751
  if (!isStringLiteral(node.value) || !SCROLLING_VALUES.has(node.value.value)) return
688
- if (hasThemeAllow(context, node, 'raw-scroll-container')) return
689
- context.report({ node, message: RAW_SCROLL_CONTAINER_MESSAGE })
752
+ candidates.push({ node, universal, home: universal ? undefined : enclosingHomeOwner(node) })
753
+ },
754
+ // `<ScrollArea scrollbars="x">` — the same horizontal scroll region written as a component.
755
+ // Only inside a home, exactly like the `overflowX` half above; a ScrollArea is otherwise the
756
+ // shape this whole rule steers people TOWARD. Mantine-provenanced like every other tag test:
757
+ // a consumer's own `ScrollArea` wrapper is not the component this doctrine is about.
758
+ JSXAttribute(node) {
759
+ if (node.name?.name !== 'scrollbars') return
760
+ const value = unwrapExpressionContainer(node.value)
761
+ if (!isStringLiteral(value) || value.value !== 'x') return
762
+ const owner = node.parent
763
+ if (owner === null || owner === undefined || owner.type !== 'JSXOpeningElement') return
764
+ candidates.push({
765
+ node,
766
+ universal: false,
767
+ home: enclosingHomeOwner(node),
768
+ name: owner.name,
769
+ })
770
+ },
771
+ 'Program:exit'() {
772
+ for (const { node, universal, home, name } of candidates) {
773
+ // The C7 widening: only inside a BASALT home slot (see enclosingHomeOwner).
774
+ if (!universal && (home === undefined || !homes.has(home))) continue
775
+ if (name !== undefined && resolveMantineTag(name, mantineImports) !== 'ScrollArea')
776
+ continue
777
+ if (hasThemeAllow(context, node, 'raw-scroll-container')) continue
778
+ context.report({
779
+ node,
780
+ message: universal ? RAW_SCROLL_CONTAINER_MESSAGE : RAW_SCROLL_CONTAINER_HOME_MESSAGE,
781
+ })
782
+ }
690
783
  },
691
784
  }
692
785
  },
@@ -1463,6 +1556,48 @@ const BASALT_EXPORTS = (() => {
1463
1556
  return names
1464
1557
  })()
1465
1558
 
1559
+ /**
1560
+ * Renames that ARE the collision, spelled out because the name no longer collides.
1561
+ *
1562
+ * `BASALT_EXPORTS` catches a fork that kept basalt's name. The commoner shape is a fork that
1563
+ * renamed it: argo's `PageHeader`, linewatch's `WindowSelector`, rb's `HeroCard`. Each of these is
1564
+ * the same composite under a different word, so the tripwire has to name the words — and unlike the
1565
+ * barrel read, this table is hand-maintained by construction: there is no artifact to derive
1566
+ * "someone else's word for `Section`" from. Keep it in step with `docs/CONTROLS-SPEC.md` §6.
1567
+ *
1568
+ * Advisory, permanently (see {@link PLUGIN_RULE_ADVISORY}): a name is evidence, never proof.
1569
+ */
1570
+ const SHADOW_ALIASES = {
1571
+ PageSection: 'Section',
1572
+ SectionTitle: 'Section',
1573
+ SectionHeading: 'Section',
1574
+ WindowSelector: 'RangeFilter',
1575
+ RangeSelector: 'RangeFilter',
1576
+ DateFilter: 'RangeFilter',
1577
+ ViewSwitch: 'ViewTabs',
1578
+ ViewToggle: 'ViewTabs',
1579
+ RefreshButton: 'SyncButton',
1580
+ SyncControl: 'SyncButton',
1581
+ SyncStatusButton: 'SyncButton',
1582
+ PageHeader: 'PageBar',
1583
+ FilterBar: 'PageBar',
1584
+ HeroCard: 'StatCard',
1585
+ HeroStats: 'StatCard',
1586
+ ButtonGroup: 'ControlGroup',
1587
+ ButtonRow: 'ControlGroup',
1588
+ JoinedButtons: 'ControlGroup',
1589
+ }
1590
+
1591
+ function shadowAliasMessage(name, canonical) {
1592
+ return (
1593
+ `Local component '${name}' is a renamed '${canonical}' — basalt ships that composite, and a ` +
1594
+ 'renamed fork is the shape the name-collision half of this rule can never see (laws C8/C12). ' +
1595
+ `Import ${canonical} from 'basalt-ui' (or 'basalt-ui/controls') instead of re-rolling it; if ` +
1596
+ 'this genuinely is a different component, rename it and the rule goes quiet. ' +
1597
+ '(basalt/shadow-basalt-export)'
1598
+ )
1599
+ }
1600
+
1466
1601
  function shadowBasaltExportMessage(name) {
1467
1602
  return (
1468
1603
  `Local component '${name}' collides with the basalt-ui export of the same name — import it ` +
@@ -1502,9 +1637,13 @@ const shadowBasaltExport = {
1502
1637
  if (filename.length === 0 || !isBasaltScopedFile(filename)) return {}
1503
1638
 
1504
1639
  const report = (node, name) => {
1505
- if (typeof name !== 'string' || !BASALT_EXPORTS.has(name)) return
1640
+ if (typeof name !== 'string') return
1641
+ const alias = Object.hasOwn(SHADOW_ALIASES, name) ? SHADOW_ALIASES[name] : undefined
1642
+ if (!BASALT_EXPORTS.has(name) && alias === undefined) return
1506
1643
  if (hasThemeAllow(context, node, 'shadow-basalt-export')) return
1507
- context.report({ node, message: shadowBasaltExportMessage(name) })
1644
+ const message =
1645
+ alias === undefined ? shadowBasaltExportMessage(name) : shadowAliasMessage(name, alias)
1646
+ context.report({ node, message })
1508
1647
  }
1509
1648
 
1510
1649
  return {
@@ -1600,35 +1739,1132 @@ const handRolledShell = {
1600
1739
  },
1601
1740
  }
1602
1741
 
1742
+ // ── Control homes — the shared slot-ancestry walk (C1/C5, docs/CONTROLS-SPEC.md §6) ─────────────
1743
+
1744
+ /**
1745
+ * The slot props a control enters a TIERED home through (law C1). A control is only ever IN one of
1746
+ * these homes by being the value of one of these props — never by sitting in a home's children,
1747
+ * which is the body form (a `Section`'s own content, a `SettingsSection`'s rows) and carries no tier
1748
+ * obligation at all.
1749
+ *
1750
+ * **`control` is deliberately absent, and `SettingsRow` with it** (see {@link SLOT_OWNER_TAGS}):
1751
+ * `SettingsRow.control` is law C1's THIRD home, the form row, and a form keeps Mantine's own `md`
1752
+ * tier (`controlHeight` 42 — `docs/CONTROLS-SPEC.md` §5, unchanged). So a raw `Select` bound to a
1753
+ * setting is legitimate there and a `size` literal there is not a tier violation; only
1754
+ * `control-outside-home` cares about a settings row at all, and it treats it as a home
1755
+ * (`CONTROL_HOST_TAGS`) so nothing fires inside one. The two tier rules would otherwise have told
1756
+ * every settings page in every consumer to drop a prop that is what keeps the row at the form tier
1757
+ * — `src/dashboard/settings-section.tsx` wraps `actions` in `CtlSlot`, never `control`.
1758
+ */
1759
+ const SLOT_ATTRS = new Set(['actions', 'filters', 'tabs', 'sync', 'filtersEnd'])
1760
+
1761
+ /**
1762
+ * The elements whose slot props ARE tiered homes. All THREE halves of the test matter: `actions` on
1763
+ * a consumer's own `<Toolbar actions={…}>` is not a basalt home, a `Section`'s CHILDREN are not a
1764
+ * slot (so the walk stops at the slot ATTRIBUTE, never at the element), and the tag must resolve to
1765
+ * a component imported FROM basalt ({@link collectBasaltImports}) — `Section` is one of the most
1766
+ * common local component names there is, and matching it by bare name made a consumer's own
1767
+ * section an `error`-level tiered home.
1768
+ *
1769
+ * `SettingsSection` is here for its `actions` header slot (`CtlSlot`-wrapped); `SettingsRow` is
1770
+ * NOT — its `control` is the form-row home and keeps Mantine's `md` tier. See {@link SLOT_ATTRS}.
1771
+ */
1772
+ const SLOT_OWNER_TAGS = new Set([
1773
+ 'PageBar',
1774
+ 'Section',
1775
+ 'WidgetHeader',
1776
+ 'ChartCard',
1777
+ 'StatCard',
1778
+ 'BasaltDataTable',
1779
+ 'SettingsSection',
1780
+ 'FilterSet',
1781
+ ])
1782
+
1783
+ /**
1784
+ * Raw Mantine selection controls — the shape a store-bound basalt filter replaces. Membership is
1785
+ * checked against the file's `@mantine/*` IMPORTS, not the bare tag name: a consumer's own
1786
+ * `<Select>` (a wrapper, a re-export, a different component entirely) is not the Mantine one, and
1787
+ * these rules ship to consumers.
1788
+ */
1789
+ const RAW_FILTER_TAGS = new Set([
1790
+ 'SegmentedControl',
1791
+ 'Select',
1792
+ 'MultiSelect',
1793
+ 'NativeSelect',
1794
+ 'DatePickerInput',
1795
+ 'DateInput',
1796
+ 'TagsInput',
1797
+ 'Chip.Group',
1798
+ ])
1799
+
1800
+ /** The bound controls a home is SUPPOSED to hold — `basalt-ui/controls`, each over a FieldHandle. */
1801
+ const BOUND_TAGS = new Set([
1802
+ 'RangeFilter',
1803
+ 'CompareFilter',
1804
+ 'SelectFilter',
1805
+ 'MultiSelectFilter',
1806
+ 'SearchFilter',
1807
+ 'ToggleFilter',
1808
+ 'ViewTabs',
1809
+ ])
1810
+
1811
+ /**
1812
+ * The names whose DECLARATION means this file defines a basalt control rather than consuming one —
1813
+ * the owner exemption, the same shape as `hand-rolled-plot`'s `notesOwnerDefinition`: a rule that
1814
+ * says "use a bound control" cannot fire inside the module that IS the bound control.
1815
+ */
1816
+ const CONTROL_OWNER_NAMES = new Set([
1817
+ ...BOUND_TAGS,
1818
+ 'FilterSet',
1819
+ 'FilterPill',
1820
+ 'SyncButton',
1821
+ 'ActionGroup',
1822
+ 'OverflowMenu',
1823
+ 'CtlSlot',
1824
+ ])
1825
+
1826
+ /**
1827
+ * Where a raw selection control legitimately lives outside a TIERED home: a settings row, an
1828
+ * overlay, a composer. Every one of these is a place the tier does not apply, so the advice ("move
1829
+ * it into a home slot") would be wrong rather than merely unwelcome.
1830
+ *
1831
+ * `SettingsRow` is the form row — law C1's third home, not an exception to C1 — so
1832
+ * `control-outside-home` treating it as a home is the whole of the enforcement a settings page
1833
+ * gets, and the two tier rules deliberately do not reach inside it (see {@link SLOT_ATTRS}).
1834
+ *
1835
+ * Declared with the shared slot walk rather than beside `control-outside-home` because three rules
1836
+ * read it: that rule's ancestry exemption, and {@link hostedInsideSlot} for the two tier rules.
1837
+ */
1838
+ const CONTROL_HOST_TAGS = new Set([
1839
+ 'SettingsRow',
1840
+ 'Modal',
1841
+ 'Drawer',
1842
+ 'Popover.Dropdown',
1843
+ 'Menu.Dropdown',
1844
+ 'Composer',
1845
+ ])
1846
+
1847
+ /** How far an ancestry walk climbs before giving up — a JSX tree this deep is not a slot value. */
1848
+ const ANCESTRY_MAX_DEPTH = 60
1849
+
1850
+ /** A JSX tag name as written: `Select`, or `Chip.Group` for a member tag. */
1851
+ function jsxTagName(nameNode) {
1852
+ if (nameNode === null || nameNode === undefined) return undefined
1853
+ if (typeof nameNode.name === 'string') return nameNode.name
1854
+ const object = jsxTagName(nameNode.object)
1855
+ const property = nameNode.property?.name
1856
+ if (typeof object !== 'string' || typeof property !== 'string') return undefined
1857
+ return `${object}.${property}`
1858
+ }
1859
+
1860
+ /** The BINDING a JSX tag resolves through — `Chip` for `<Chip.Group>`, `Select` for `<Select>`. */
1861
+ function jsxRootName(nameNode) {
1862
+ if (nameNode === null || nameNode === undefined) return undefined
1863
+ if (typeof nameNode.name === 'string') return nameNode.name
1864
+ return jsxRootName(nameNode.object)
1865
+ }
1866
+
1867
+ /** The value a namespace import (`import * as M from '@mantine/core'`) records for its local name. */
1868
+ const MANTINE_NAMESPACE = Symbol('mantine-namespace')
1869
+
1870
+ /**
1871
+ * Local name → the `@mantine/*` name it was imported UNDER — the provenance test the tag rules
1872
+ * apply, and a MAP rather than a set because the two are not the same string. `Select as
1873
+ * MantineSelect` is the canonical way a consumer wraps a Mantine component, and keying membership
1874
+ * on the local name meant `<MantineSelect value onChange/>` in a slot — the exact C1/C3 violation
1875
+ * `hand-rolled-filter` exists for — was invisible. A namespace import records
1876
+ * {@link MANTINE_NAMESPACE} so `<M.SegmentedControl>` resolves through the member path instead.
1877
+ */
1878
+ function collectMantineImports(node, into) {
1879
+ const source = node.source?.value
1880
+ if (typeof source !== 'string' || !source.startsWith('@mantine/')) return
1881
+ for (const spec of node.specifiers ?? []) {
1882
+ const local = spec.local?.name
1883
+ if (typeof local !== 'string') continue
1884
+ if (spec.type === 'ImportNamespaceSpecifier') into.set(local, MANTINE_NAMESPACE)
1885
+ else into.set(local, spec.imported?.name ?? spec.imported?.value ?? local)
1886
+ }
1887
+ }
1888
+
1889
+ /**
1890
+ * The `@mantine/*` name a JSX tag resolves to, or undefined when the tag is not a Mantine binding.
1891
+ *
1892
+ * `<Select>` → `Select`; `import { Select as MantineSelect }` + `<MantineSelect>` → `Select`;
1893
+ * `import { Chip as C }` + `<C.Group>` → `Chip.Group`; `import * as M` + `<M.SegmentedControl>` →
1894
+ * `SegmentedControl`. Resolution already implies provenance, so a caller tests the RESULT against
1895
+ * `RAW_FILTER_TAGS` and needs no separate import-membership check.
1896
+ */
1897
+ function resolveMantineTag(nameNode, mantineImports) {
1898
+ const tag = jsxTagName(nameNode)
1899
+ const root = jsxRootName(nameNode)
1900
+ if (tag === undefined || root === undefined) return undefined
1901
+ const imported = mantineImports.get(root)
1902
+ if (imported === undefined) return undefined
1903
+ const member = tag.slice(root.length)
1904
+ if (imported !== MANTINE_NAMESPACE) return `${imported}${member}`
1905
+ return member === '' ? undefined : member.slice(1)
1906
+ }
1907
+
1908
+ /** The package's own `src/` — where a RELATIVE import of `WidgetHeader` is still basalt's own. */
1909
+ const BASALT_OWN_SRC = `${resolvePath(PACKAGE_ROOT, 'src')}/`
1910
+
1911
+ /** `basalt-ui` and every published subpath — the sources a home component is imported FROM. */
1912
+ const BASALT_IMPORT_SOURCE = /^basalt-ui(?:\/|$)/
1913
+
1914
+ /** A relative specifier — `./section`, `../widget-header`. */
1915
+ const RELATIVE_IMPORT_SOURCE = /^\.\.?\//
1916
+
1917
+ /** Is the linted file part of basalt's OWN source tree — the dogfood side of the owner test? */
1918
+ function isBasaltOwnSource(filename) {
1919
+ return filename.startsWith(BASALT_OWN_SRC)
1920
+ }
1921
+
1922
+ /**
1923
+ * Local names that resolve to a basalt HOME component — the owner-tag counterpart of
1924
+ * {@link collectMantineImports}, and for the same reason: `Section` is one of the most common local
1925
+ * component names there is (a marketing page, a docs layout), and reading a consumer's own
1926
+ * `<Section actions={…}>` as a tiered home made two `error`-level rules fire on code basalt has no
1927
+ * claim over.
1928
+ *
1929
+ * A relative import counts only inside basalt's own `src/` (`ownTree`), where `section.tsx` and
1930
+ * `data-table.tsx` render `<WidgetHeader actions={…}>`/`<FilterSet>` through `../widget-header`
1931
+ * rather than through the package name — without that half the dogfood surface goes silent, which
1932
+ * is the blind spot that let the 1.4.0 regression reach consumers.
1933
+ *
1934
+ * Known accepted false negative: a consumer re-exporting basalt's `Section` through their own
1935
+ * barrel (`from '@/ui'`) — the same gap the Mantine provenance test already accepts.
1936
+ */
1937
+ function collectBasaltImports(node, into, ownTree) {
1938
+ const source = node.source?.value
1939
+ if (typeof source !== 'string') return
1940
+ if (!BASALT_IMPORT_SOURCE.test(source) && !(ownTree && RELATIVE_IMPORT_SOURCE.test(source))) {
1941
+ return
1942
+ }
1943
+ for (const spec of node.specifiers ?? []) {
1944
+ if (spec.local?.name !== undefined) into.add(spec.local.name)
1945
+ }
1946
+ }
1947
+
1948
+ /**
1949
+ * The BINDING a home's slot prop hangs off — `PageBar` for `<PageBar filters={…}>` — or undefined
1950
+ * when `attr` is not a slot-shaped prop on a home-shaped tag.
1951
+ *
1952
+ * Provenance (is that `Section` basalt's?) is deliberately NOT tested here: the answer needs the
1953
+ * file's import list, which is only complete at `Program:exit`, while the ancestry a caller walks to
1954
+ * reach this attribute is only walkable during the visit. So the walk captures the NAME and the
1955
+ * caller resolves it against {@link collectBasaltImports}'s set afterwards.
1956
+ */
1957
+ function slotOwnerBinding(attr) {
1958
+ if (attr === null || attr === undefined || attr.type !== 'JSXAttribute') return undefined
1959
+ const name = attr.name?.name
1960
+ if (typeof name !== 'string' || !SLOT_ATTRS.has(name)) return undefined
1961
+ const owner = attr.parent
1962
+ if (owner === null || owner === undefined || owner.type !== 'JSXOpeningElement') return undefined
1963
+ if (!SLOT_OWNER_TAGS.has(jsxTagName(owner.name) ?? '')) return undefined
1964
+ return jsxRootName(owner.name)
1965
+ }
1966
+
1967
+ /** The home slot attribute that ENCLOSES `node`, or undefined. Stops at the attribute, never the element. */
1968
+ function enclosingSlotAttribute(node) {
1969
+ let current = node.parent
1970
+ for (let depth = 0; current !== null && current !== undefined; depth++) {
1971
+ if (depth > ANCESTRY_MAX_DEPTH || current.type === 'Program') return undefined
1972
+ if (slotOwnerBinding(current) !== undefined) return current
1973
+ current = current.parent
1974
+ }
1975
+ return undefined
1976
+ }
1977
+
1978
+ /**
1979
+ * Does an OVERLAY (or a settings row) sit between `node` and the home slot it is written in?
1980
+ *
1981
+ * Colocating a trigger with its overlay in a slot — a `New` button beside the `<Modal>` it opens, a
1982
+ * `Custom range` button beside its `<Popover.Dropdown>` — is the ordinary way to write both, and
1983
+ * the overlay's own contents are NOT in the bar: they portal out of it, they are not tiered by the
1984
+ * slot's `MantineThemeProvider`, and `control-outside-home` already declares every one of these a
1985
+ * non-home ({@link CONTROL_HOST_TAGS}). Before this test the same overlay was exempt outside a slot
1986
+ * and an `error` inside one.
1987
+ *
1988
+ * The walk stops at the slot attribute so an overlay ABOVE the home (a whole `PageBar` rendered
1989
+ * inside a `Modal`) does not exempt the bar's own controls.
1990
+ */
1991
+ function hostedInsideSlot(node) {
1992
+ let current = node.parent
1993
+ for (let depth = 0; current !== null && current !== undefined; depth++) {
1994
+ if (depth > ANCESTRY_MAX_DEPTH || current.type === 'Program') return false
1995
+ if (
1996
+ current.type === 'JSXElement' &&
1997
+ CONTROL_HOST_TAGS.has(jsxTagName(current.openingElement?.name) ?? '')
1998
+ ) {
1999
+ return true
2000
+ }
2001
+ if (current.type === 'JSXAttribute' && SLOT_ATTRS.has(current.name?.name ?? '')) return false
2002
+ current = current.parent
2003
+ }
2004
+ return false
2005
+ }
2006
+
2007
+ /** The name of the nearest enclosing `const x = …` binding — the hoisted-slot-value lookup. */
2008
+ function enclosingDeclaratorName(node) {
2009
+ let current = node.parent
2010
+ for (let depth = 0; current !== null && current !== undefined; depth++) {
2011
+ if (depth > ANCESTRY_MAX_DEPTH || current.type === 'Program') return undefined
2012
+ if (current.type === 'VariableDeclarator') {
2013
+ return typeof current.id?.name === 'string' ? current.id.name : undefined
2014
+ }
2015
+ current = current.parent
2016
+ }
2017
+ return undefined
2018
+ }
2019
+
2020
+ /** True when any JSX ELEMENT ancestor of `node` opens with one of `tags`. */
2021
+ function hasAncestorTag(node, tags) {
2022
+ let current = node.parent
2023
+ for (let depth = 0; current !== null && current !== undefined; depth++) {
2024
+ if (depth > ANCESTRY_MAX_DEPTH || current.type === 'Program') return false
2025
+ if (current.type === 'JSXElement' && tags.has(jsxTagName(current.openingElement?.name) ?? '')) {
2026
+ return true
2027
+ }
2028
+ current = current.parent
2029
+ }
2030
+ return false
2031
+ }
2032
+
2033
+ /** Identifiers a slot attribute's value resolves through — `filters={pills}`, `actions={[a, b]}`. */
2034
+ function collectSlotValueIdentifiers(node, into, depth = 0) {
2035
+ if (node === null || node === undefined || depth > 8) return
2036
+ if (node.type === 'Identifier') {
2037
+ into.add(node.name)
2038
+ return
2039
+ }
2040
+ if (node.type === 'ArrayExpression') {
2041
+ for (const el of node.elements) collectSlotValueIdentifiers(el, into, depth + 1)
2042
+ return
2043
+ }
2044
+ if (node.type === 'ConditionalExpression') {
2045
+ collectSlotValueIdentifiers(node.consequent, into, depth + 1)
2046
+ collectSlotValueIdentifiers(node.alternate, into, depth + 1)
2047
+ return
2048
+ }
2049
+ if (node.type === 'LogicalExpression') {
2050
+ collectSlotValueIdentifiers(node.left, into, depth + 1)
2051
+ collectSlotValueIdentifiers(node.right, into, depth + 1)
2052
+ }
2053
+ }
2054
+
2055
+ /**
2056
+ * The shared "am I inside a home slot" resolver, and the one piece of state every control rule
2057
+ * needs the WHOLE file to answer: a hoisted `const pills = <Select …/>` handed to `filters={pills}`
2058
+ * further down is inside that slot (argo's hoisted `headerExtra`, `cost-over-time.tsx:54-68`), and
2059
+ * the binding is only known to be slot-bound once the attribute has been seen.
2060
+ *
2061
+ * So the ancestry facts are CAPTURED during the visit — while `node.parent` is walkable — and
2062
+ * resolved at `Program:exit`, when the slot-bound identifier set is complete. `capture()` returns a
2063
+ * descriptor, `resolve()` turns it into a verdict; nothing re-walks the tree after traversal ends.
2064
+ *
2065
+ * The descriptor holds the owner BINDING NAME rather than a boolean for the same reason: whether
2066
+ * that `Section` is basalt's is answered by the import list (`noteImport`), which is also only
2067
+ * complete at exit.
2068
+ */
2069
+ function createSlotContext(context) {
2070
+ const homes = new Set()
2071
+ const ownTree = isBasaltOwnSource(getFilename(context))
2072
+ /** Hoisted identifier → the owner BINDING of the slot it was handed to. */
2073
+ const slotBoundIdentifiers = new Map()
2074
+ return {
2075
+ /** Feed every ImportDeclaration here — this is where the owner provenance comes from. */
2076
+ noteImport(node) {
2077
+ collectBasaltImports(node, homes, ownTree)
2078
+ },
2079
+ /** Feed every JSXAttribute here — a slot attribute contributes its value's identifiers. */
2080
+ note(attr) {
2081
+ const owner = slotOwnerBinding(attr)
2082
+ if (owner === undefined) return
2083
+ const identifiers = new Set()
2084
+ collectSlotValueIdentifiers(unwrapExpressionContainer(attr.value), identifiers)
2085
+ for (const id of identifiers) slotBoundIdentifiers.set(id, owner)
2086
+ },
2087
+ /** Snapshot `node`'s ancestry while it is walkable — the owner NAME, resolved later. */
2088
+ capture(node) {
2089
+ const attr = enclosingSlotAttribute(node)
2090
+ return {
2091
+ directOwner: attr === undefined ? undefined : slotOwnerBinding(attr),
2092
+ declaratorName: enclosingDeclaratorName(node),
2093
+ }
2094
+ },
2095
+ /** Is a captured node inside a BASALT home slot — directly, or via a hoisted binding? */
2096
+ resolve(captured) {
2097
+ if (captured.directOwner !== undefined) return homes.has(captured.directOwner)
2098
+ if (captured.declaratorName === undefined) return false
2099
+ const hoistedOwner = slotBoundIdentifiers.get(captured.declaratorName)
2100
+ return hoistedOwner !== undefined && homes.has(hoistedOwner)
2101
+ },
2102
+ /** Does `name` resolve to a basalt home component in this file? */
2103
+ isHome(name) {
2104
+ return name !== undefined && homes.has(name)
2105
+ },
2106
+ }
2107
+ }
2108
+
2109
+ /** Collects an owner-definition flag for the control layer — see {@link CONTROL_OWNER_NAMES}. */
2110
+ function createControlOwnerProbe() {
2111
+ const state = { definesControl: false }
2112
+ const note = (name) => {
2113
+ if (typeof name === 'string' && CONTROL_OWNER_NAMES.has(name)) state.definesControl = true
2114
+ }
2115
+ return {
2116
+ state,
2117
+ visitors: {
2118
+ FunctionDeclaration(node) {
2119
+ note(node.id?.name)
2120
+ },
2121
+ VariableDeclarator(node) {
2122
+ note(node.id?.name)
2123
+ },
2124
+ },
2125
+ }
2126
+ }
2127
+
2128
+ // ── Rule 16 — hand-rolled-filter ────────────────────────────────────────────────────────────────
2129
+
2130
+ const HAND_ROLLED_FILTER_MESSAGE =
2131
+ 'Raw Mantine selection control inside a home slot — a filter or tab in a PageBar / Section / ' +
2132
+ 'WidgetHeader slot takes a `field` (a FieldHandle) and owns both the URL write and the ' +
2133
+ 'localStorage mirror, so it cannot carry value/onChange at all (laws C1–C3). Use ' +
2134
+ 'RangeFilter / SelectFilter / MultiSelectFilter / SearchFilter / ToggleFilter / ViewTabs from ' +
2135
+ "basalt-ui/controls. Two shapes are NOT this rule: a control in a home's CHILDREN (a Section " +
2136
+ "body form), and a SettingsRow's `control` — the form-row home, where a raw input at Mantine's " +
2137
+ 'md tier is the right answer. (basalt/hand-rolled-filter)'
2138
+
2139
+ /**
2140
+ * A raw Mantine selection control handed to a home slot (laws C1/C3).
2141
+ *
2142
+ * Ships `error` from the start, unlike its sibling `control-outside-home`: "this element is inside
2143
+ * a slot" is a structural fact with no heuristic in it, and the wave-1..4 migrations left no
2144
+ * incumbents. Two shapes are deliberately outside the rule: the body form (the ancestry walk stops
2145
+ * at the slot ATTRIBUTE, so a `<Select>` in a `Section`'s children never reaches it) and
2146
+ * `SettingsRow.control`, the form-row home — see {@link SLOT_ATTRS} for why a raw input is right
2147
+ * there.
2148
+ */
2149
+ const handRolledFilter = {
2150
+ meta: {
2151
+ type: 'problem',
2152
+ docs: { description: 'Disallow a raw Mantine selection control inside a basalt home slot.' },
2153
+ schema: [],
2154
+ },
2155
+ create(context) {
2156
+ if (isTestFile(context)) return {}
2157
+ const slots = createSlotContext(context)
2158
+ const mantineImports = new Map()
2159
+ const candidates = []
2160
+
2161
+ return {
2162
+ ImportDeclaration(node) {
2163
+ collectMantineImports(node, mantineImports)
2164
+ slots.noteImport(node)
2165
+ },
2166
+ JSXAttribute(node) {
2167
+ slots.note(node)
2168
+ },
2169
+ JSXOpeningElement(node) {
2170
+ // Cheap pre-filter: the tag as WRITTEN, or any Mantine binding (an alias resolves at exit).
2171
+ const root = jsxRootName(node.name)
2172
+ if (!RAW_FILTER_TAGS.has(jsxTagName(node.name) ?? '') && !mantineImports.has(root)) return
2173
+ candidates.push({
2174
+ node,
2175
+ name: node.name,
2176
+ captured: slots.capture(node),
2177
+ hosted: hostedInsideSlot(node),
2178
+ })
2179
+ },
2180
+ 'Program:exit'() {
2181
+ for (const { node, name, captured, hosted } of candidates) {
2182
+ const tag = resolveMantineTag(name, mantineImports)
2183
+ if (tag === undefined || !RAW_FILTER_TAGS.has(tag)) continue
2184
+ if (hosted || !slots.resolve(captured)) continue
2185
+ if (hasThemeAllow(context, node, 'hand-rolled-filter')) continue
2186
+ context.report({ node, message: HAND_ROLLED_FILTER_MESSAGE })
2187
+ }
2188
+ },
2189
+ }
2190
+ },
2191
+ }
2192
+
2193
+ // ── Rule 17 — control-outside-home ──────────────────────────────────────────────────────────────
2194
+
2195
+ const CONTROL_OUTSIDE_HOME_MESSAGE =
2196
+ 'Raw Mantine selection control with no home — a filter, tab or action belongs in exactly one of ' +
2197
+ 'the three homes (a PageBar / Section / WidgetHeader slot, or a form row), and a home is entered ' +
2198
+ 'through a slot prop (law C1). A settings row, an overlay (Modal/Drawer/Popover/Menu) and a ' +
2199
+ 'form (@mantine/form) are the declared non-homes and never report. ' +
2200
+ '(basalt/control-outside-home)'
2201
+
2202
+ /**
2203
+ * The cross-file half of C1, and the one rule here that is openly a HEURISTIC: "this control has
2204
+ * no home" is a claim about layout intent, which no AST can see. It ships `warn` for that reason
2205
+ * and stays warn until the playground and the five consumer repos run it with ≤3 waivers
2206
+ * (docs/CONTROLS-SPEC.md §9 wave 7).
2207
+ *
2208
+ * Three exemptions carry the false-positive load: an overlay/settings-row ancestor, a file that
2209
+ * imports `@mantine/form` (a form is the third home and its inputs are not filters), and the owner
2210
+ * exemption — a file DEFINING a basalt control cannot be told to use one.
2211
+ */
2212
+ const controlOutsideHome = {
2213
+ meta: {
2214
+ type: 'suggestion',
2215
+ docs: { description: 'Warn on a raw Mantine selection control that is in no basalt home.' },
2216
+ schema: [],
2217
+ },
2218
+ create(context) {
2219
+ if (isTestFile(context)) return {}
2220
+ const slots = createSlotContext(context)
2221
+ const owner = createControlOwnerProbe()
2222
+ const mantineImports = new Map()
2223
+ const candidates = []
2224
+ let importsMantineForm = false
2225
+
2226
+ return {
2227
+ ...owner.visitors,
2228
+ ImportDeclaration(node) {
2229
+ collectMantineImports(node, mantineImports)
2230
+ slots.noteImport(node)
2231
+ if ((node.source?.value ?? '') === '@mantine/form') importsMantineForm = true
2232
+ },
2233
+ JSXAttribute(node) {
2234
+ slots.note(node)
2235
+ },
2236
+ JSXOpeningElement(node) {
2237
+ // Cheap pre-filter: the tag as WRITTEN, or any Mantine binding (an alias resolves at exit).
2238
+ const root = jsxRootName(node.name)
2239
+ if (!RAW_FILTER_TAGS.has(jsxTagName(node.name) ?? '') && !mantineImports.has(root)) return
2240
+ candidates.push({
2241
+ node,
2242
+ name: node.name,
2243
+ captured: slots.capture(node),
2244
+ hosted: hasAncestorTag(node, CONTROL_HOST_TAGS),
2245
+ })
2246
+ },
2247
+ 'Program:exit'() {
2248
+ if (importsMantineForm || owner.state.definesControl) return
2249
+ for (const { node, name, captured, hosted } of candidates) {
2250
+ const tag = resolveMantineTag(name, mantineImports)
2251
+ if (tag === undefined || !RAW_FILTER_TAGS.has(tag)) continue
2252
+ if (hosted || slots.resolve(captured)) continue
2253
+ if (hasThemeAllow(context, node, 'control-outside-home')) continue
2254
+ context.report({ node, message: CONTROL_OUTSIDE_HOME_MESSAGE })
2255
+ }
2256
+ },
2257
+ }
2258
+ },
2259
+ }
2260
+
2261
+ // ── Rule 18 — control-size-literal ──────────────────────────────────────────────────────────────
2262
+
2263
+ /** The props a home already owns — the tier, the width, and the responsive swap (laws C5/C9). */
2264
+ const CONTROL_SIZE_ATTRS = new Set(['size', 'w', 'fullWidth', 'visibleFrom', 'hiddenFrom'])
2265
+
2266
+ const CONTROL_SIZE_LITERAL_MESSAGE =
2267
+ 'Size/width/breakpoint prop inside a tiered home slot — the HOME sets the tier (`size="ctl"` = 30px, ' +
2268
+ "mounted by the slot's own MantineThemeProvider), the control owns its own responsive swap, and " +
2269
+ 'the overflow fold is computed by basalt from typed data. A `size`/`w`/`fullWidth`/`visibleFrom`/' +
2270
+ '`hiddenFrom` written here fights all three (laws C5/C7/C9) — drop the prop. ' +
2271
+ '(basalt/control-size-literal)'
2272
+
2273
+ /**
2274
+ * The elements the slot's own `MantineThemeProvider` actually re-tiers, beyond the raw filters: a
2275
+ * `Button size="xs"` dropped into `PageBar.actions` is the same defect as a `Select size="xs"` there
2276
+ * — one control in the bar that is not 30px.
2277
+ *
2278
+ * This file is plain JS shipped to consumers and `src/theme/ctl-theme.tsx` is Mantine-coupled
2279
+ * TypeScript it may not import, so the list is written out here — and PINNED to
2280
+ * `CTL_THEME.components` by `oxlint-plugin.test.ts`, which can import both. Drift fails that test
2281
+ * rather than silently re-tiering a component the rule still polices (or the reverse).
2282
+ *
2283
+ * The scope IS the rule: an `<IconDownload size={14}/>`, a count `<Badge size="sm">`, a
2284
+ * `<Loader size={14}/>` or a `<Modal size="lg">` colocated in a slot is NOT sized by the slot theme,
2285
+ * so "drop the prop" produced a 24px icon and a message whose promise ("the HOME sets the tier") was
2286
+ * false for every one of them.
2287
+ *
2288
+ * `Switch` USED to be on that not-sized list and is now on this one, together with `Radio`,
2289
+ * `Checkbox` and the three `*Group`s: the tier gained their `-ctl` vars in the same minor this rule
2290
+ * ships in (`theme/ctl-theme.tsx`, `theme/index.ts`'s `ctlSizeVars`), because a 20px indicator beside
2291
+ * the tier's 13.5px option label was what every filter popover and the mobile sheet rendered. The
2292
+ * grace runway in {@link PLUGIN_RULE_GRACE} is unchanged — the rule has never shipped at any level,
2293
+ * so widening it before its first release restarts nothing.
2294
+ */
2295
+ export const CTL_THEME_TAGS = new Set([
2296
+ 'Button',
2297
+ 'ActionIcon',
2298
+ 'Input',
2299
+ 'TextInput',
2300
+ 'Select',
2301
+ 'MultiSelect',
2302
+ 'SegmentedControl',
2303
+ 'NativeSelect',
2304
+ 'Radio',
2305
+ 'RadioGroup',
2306
+ 'Checkbox',
2307
+ 'CheckboxGroup',
2308
+ 'Switch',
2309
+ 'SwitchGroup',
2310
+ ])
2311
+
2312
+ /**
2313
+ * A size/width/breakpoint prop on a control the slot theme tiers. The slot's hoisted
2314
+ * `MantineThemeProvider` already resolves the tier with no prop. A `SettingsRow`'s `control` is NOT
2315
+ * a tiered slot (see {@link SLOT_ATTRS}): a form row keeps Mantine's `md`, so the `size` written
2316
+ * there is load-bearing rather than redundant.
2317
+ *
2318
+ * Scoped to `RAW_FILTER_TAGS` ∪ `BOUND_TAGS` (basalt's own controls, by name) ∪ a Mantine-imported
2319
+ * {@link CTL_THEME_TAGS} member, and skipped under an overlay ({@link hostedInsideSlot}) — see
2320
+ * `CTL_THEME_TAGS` for what firing on everything in the slot cost.
2321
+ */
2322
+ const controlSizeLiteral = {
2323
+ meta: {
2324
+ type: 'suggestion',
2325
+ docs: { description: 'Warn on a size/width/breakpoint prop written inside a home slot.' },
2326
+ schema: [],
2327
+ },
2328
+ create(context) {
2329
+ if (isTestFile(context)) return {}
2330
+ const slots = createSlotContext(context)
2331
+ const mantineImports = new Map()
2332
+ const candidates = []
2333
+
2334
+ return {
2335
+ ImportDeclaration(node) {
2336
+ collectMantineImports(node, mantineImports)
2337
+ slots.noteImport(node)
2338
+ },
2339
+ JSXAttribute(node) {
2340
+ slots.note(node)
2341
+ const name = node.name?.name
2342
+ if (typeof name !== 'string' || !CONTROL_SIZE_ATTRS.has(name)) return
2343
+ const owner = node.parent
2344
+ if (owner === null || owner === undefined || owner.type !== 'JSXOpeningElement') return
2345
+ candidates.push({
2346
+ node,
2347
+ name: owner.name,
2348
+ captured: slots.capture(node),
2349
+ hosted: hostedInsideSlot(node),
2350
+ })
2351
+ },
2352
+ 'Program:exit'() {
2353
+ for (const { node, name, captured, hosted } of candidates) {
2354
+ const written = jsxTagName(name) ?? ''
2355
+ const tiered =
2356
+ RAW_FILTER_TAGS.has(written) ||
2357
+ BOUND_TAGS.has(written) ||
2358
+ CTL_THEME_TAGS.has(resolveMantineTag(name, mantineImports) ?? '')
2359
+ if (!tiered || hosted) continue
2360
+ if (!slots.resolve(captured)) continue
2361
+ if (hasThemeAllow(context, node, 'control-size-literal')) continue
2362
+ context.report({ node, message: CONTROL_SIZE_LITERAL_MESSAGE })
2363
+ }
2364
+ },
2365
+ }
2366
+ },
2367
+ }
2368
+
2369
+ // ── Rule 19 — page-bar-budget ───────────────────────────────────────────────────────────────────
2370
+
2371
+ /** `PageBar.actions.secondary` — 4, because the one `primary` makes 5 (law C6). */
2372
+ const PAGE_BAR_SECONDARY_BUDGET = 4
2373
+ /** A `Section`'s `actions` — 3 (law C6). */
2374
+ const SECTION_ACTION_BUDGET = 3
2375
+
2376
+ const PAGE_BAR_BUDGET_MESSAGES = {
2377
+ duplicate:
2378
+ 'Second PageBar in the same returned tree — a page has exactly one page bar, and row 1 portals ' +
2379
+ 'into the app shell header, so two bars mounted TOGETHER race for the same node (law C6). An ' +
2380
+ 'early-return loading state and two page components in one file each get their own tree and ' +
2381
+ 'never report. (basalt/page-bar-budget)',
2382
+ secondary:
2383
+ `More than ${PAGE_BAR_SECONDARY_BUDGET} secondary PageBar actions — the bar holds ≤5 entries ` +
2384
+ 'including the one `primary`, and everything past that folds into the `More` menu basalt ' +
2385
+ 'computes from the typed BarAction[] (laws C6/C7). Move the rest into a `kind: "menu"` entry. ' +
2386
+ '(basalt/page-bar-budget)',
2387
+ section:
2388
+ `More than ${SECTION_ACTION_BUDGET} Section actions — a section header holds ≤3 (law C6). ` +
2389
+ '(basalt/page-bar-budget)',
2390
+ filled:
2391
+ 'Second filled Button/ActionIcon inside one slot — a home has exactly one primary action, and ' +
2392
+ 'two filled buttons side by side name neither (law C6). A filled Badge/ThemeIcon (a count) and ' +
2393
+ "an overlay's own submit button are not actions in the bar and never report. " +
2394
+ '(basalt/page-bar-budget)',
2395
+ }
2396
+
2397
+ /** The two tags a `variant="filled"` makes a PRIMARY ACTION — a Badge is a count, not an action. */
2398
+ const PRIMARY_ACTION_TAGS = new Set(['Button', 'ActionIcon'])
2399
+
2400
+ /**
2401
+ * The JSX TREE `node` is returned in — the nearest `ReturnStatement`, concise arrow body, or
2402
+ * `const` binding, whichever the walk reaches first.
2403
+ *
2404
+ * This is what "two PageBars race for the same node" actually requires: they must MOUNT together.
2405
+ * Counting per FILE made an `if (!data) return <PageBar title="Jobs"/>` loading state, and two page
2406
+ * components exported from one file, an `error` on code that never renders two bars — with a
2407
+ * `theme-allow` on correct code as the only escape.
2408
+ */
2409
+ function enclosingReturnedTree(node) {
2410
+ let current = node.parent
2411
+ for (let depth = 0; current !== null && current !== undefined; depth++) {
2412
+ if (depth > ANCESTRY_MAX_DEPTH || current.type === 'Program') return undefined
2413
+ if (current.type === 'ReturnStatement' || current.type === 'VariableDeclarator') return current
2414
+ if (current.type === 'ArrowFunctionExpression' && current.body?.type !== 'BlockStatement') {
2415
+ return current
2416
+ }
2417
+ current = current.parent
2418
+ }
2419
+ return undefined
2420
+ }
2421
+
2422
+ /** The `actions` attribute of a JSXOpeningElement, unwrapped, or undefined. */
2423
+ function actionsAttributeValue(node) {
2424
+ for (const attr of node.attributes ?? []) {
2425
+ if (attr.type !== 'JSXAttribute' || attr.name?.name !== 'actions') continue
2426
+ return { attr, value: unwrapExpressionContainer(attr.value) }
2427
+ }
2428
+ return undefined
2429
+ }
2430
+
2431
+ /**
2432
+ * The four countable halves of C6. `error` from the start: every one of them is arithmetic over a
2433
+ * literal the file wrote down, with no heuristic and no layout intent to guess at.
2434
+ */
2435
+ const pageBarBudget = {
2436
+ meta: {
2437
+ type: 'problem',
2438
+ docs: { description: 'Enforce the PageBar / Section action budgets and the single primary.' },
2439
+ schema: [],
2440
+ },
2441
+ create(context) {
2442
+ if (isTestFile(context)) return {}
2443
+ const slots = createSlotContext(context)
2444
+ const mantineImports = new Map()
2445
+ const pageBars = []
2446
+ const findings = []
2447
+ /** Slot attribute node → its owner binding + the `variant="filled"` attributes inside it. */
2448
+ const filledPerSlot = new Map()
2449
+
2450
+ return {
2451
+ ImportDeclaration(node) {
2452
+ collectMantineImports(node, mantineImports)
2453
+ slots.noteImport(node)
2454
+ },
2455
+ JSXOpeningElement(node) {
2456
+ const tag = jsxTagName(node.name)
2457
+ const owner = jsxRootName(node.name)
2458
+ if (tag === 'PageBar') {
2459
+ pageBars.push({ node, owner, tree: enclosingReturnedTree(node) })
2460
+ const actions = actionsAttributeValue(node)
2461
+ if (actions?.value?.type === 'ObjectExpression') {
2462
+ for (const prop of actions.value.properties) {
2463
+ if (prop.type !== 'Property' || propertyKeyName(prop) !== 'secondary') continue
2464
+ if (prop.value?.type !== 'ArrayExpression') continue
2465
+ if (prop.value.elements.length <= PAGE_BAR_SECONDARY_BUDGET) continue
2466
+ findings.push({ node: prop, owner, message: PAGE_BAR_BUDGET_MESSAGES.secondary })
2467
+ }
2468
+ }
2469
+ return
2470
+ }
2471
+ if (tag !== 'Section') return
2472
+ const actions = actionsAttributeValue(node)
2473
+ if (actions?.value?.type !== 'ArrayExpression') return
2474
+ if (actions.value.elements.length <= SECTION_ACTION_BUDGET) return
2475
+ findings.push({ node: actions.attr, owner, message: PAGE_BAR_BUDGET_MESSAGES.section })
2476
+ },
2477
+ JSXAttribute(node) {
2478
+ slots.note(node)
2479
+ if (node.name?.name !== 'variant') return
2480
+ const value = unwrapExpressionContainer(node.value)
2481
+ if (!isStringLiteral(value) || value.value !== 'filled') return
2482
+ // An overlay's own submit button is not an action in the bar — see hostedInsideSlot.
2483
+ if (hostedInsideSlot(node)) return
2484
+ const element = node.parent
2485
+ if (element === null || element === undefined || element.type !== 'JSXOpeningElement')
2486
+ return
2487
+ const slot = enclosingSlotAttribute(node)
2488
+ if (slot === undefined) return
2489
+ const entry = filledPerSlot.get(slot) ?? { owner: slotOwnerBinding(slot), written: [] }
2490
+ entry.written.push({ node, name: element.name })
2491
+ filledPerSlot.set(slot, entry)
2492
+ },
2493
+ 'Program:exit'() {
2494
+ /** Returned tree → the PageBars mounted in it. Two in one tree mount together; see C6. */
2495
+ const barsPerTree = new Map()
2496
+ for (const bar of pageBars) {
2497
+ if (!slots.isHome(bar.owner)) continue
2498
+ barsPerTree.set(bar.tree, [...(barsPerTree.get(bar.tree) ?? []), bar])
2499
+ }
2500
+ for (const bars of barsPerTree.values()) {
2501
+ for (const { node, owner } of bars.slice(1)) {
2502
+ findings.push({ node, owner, message: PAGE_BAR_BUDGET_MESSAGES.duplicate })
2503
+ }
2504
+ }
2505
+ for (const { owner, written } of filledPerSlot.values()) {
2506
+ const actions = written.filter(({ name }) =>
2507
+ PRIMARY_ACTION_TAGS.has(resolveMantineTag(name, mantineImports) ?? ''),
2508
+ )
2509
+ for (const { node } of actions.slice(1)) {
2510
+ findings.push({ node, owner, message: PAGE_BAR_BUDGET_MESSAGES.filled })
2511
+ }
2512
+ }
2513
+ for (const { node, owner, message } of findings) {
2514
+ if (!slots.isHome(owner)) continue
2515
+ if (hasThemeAllow(context, node, 'page-bar-budget')) continue
2516
+ context.report({ node, message })
2517
+ }
2518
+ },
2519
+ }
2520
+ },
2521
+ }
2522
+
2523
+ // ── Rule 20 — in-body-page-title ────────────────────────────────────────────────────────────────
2524
+
2525
+ /** Where an `order={1|2}` Title is a DOCUMENT heading rather than a page title. */
2526
+ const PAGE_TITLE_HOST_TAGS = new Set(['Prose', 'ArticleLayout', 'Modal', 'Drawer'])
2527
+
2528
+ const IN_BODY_PAGE_TITLE_MESSAGE =
2529
+ 'In-body page title — the page is named ONCE, by the breadcrumb (`staticData.title`) or by ' +
2530
+ '`PageBar.title` in a shell-less app, and every section/card/table title is a `WidgetHeader` ' +
2531
+ '(law C8). An `<Title order={1|2}>` in the body is a second, drifting name for the same page. ' +
2532
+ 'Prose / ArticleLayout / an overlay and anything under a `content/` path are document headings ' +
2533
+ 'and never report. (basalt/in-body-page-title)'
2534
+
2535
+ /** The `order` prop as a number literal, or undefined. */
2536
+ function titleOrderOf(node) {
2537
+ for (const attr of node.attributes ?? []) {
2538
+ if (attr.type !== 'JSXAttribute' || attr.name?.name !== 'order') continue
2539
+ const value = unwrapExpressionContainer(attr.value)
2540
+ return isNumericLiteral(value) ? value.value : undefined
2541
+ }
2542
+ return undefined
2543
+ }
2544
+
2545
+ /**
2546
+ * The AST half of C8. Its text-level twin is the `in-body-page-title` GUARD KIND — same id in both
2547
+ * lanes on purpose, so one `theme-allow in-body-page-title — <why>` waives both rather than
2548
+ * needing a different word per enforcement surface.
2549
+ */
2550
+ const inBodyPageTitle = {
2551
+ meta: {
2552
+ type: 'suggestion',
2553
+ docs: { description: 'Warn on an in-body <Title order={1|2}> outside prose/overlay context.' },
2554
+ schema: [],
2555
+ },
2556
+ create(context) {
2557
+ if (isTestFile(context) || hasPathSegment(getFilename(context), 'content')) return {}
2558
+
2559
+ return {
2560
+ JSXOpeningElement(node) {
2561
+ if (jsxTagName(node.name) !== 'Title') return
2562
+ const order = titleOrderOf(node)
2563
+ if (order !== 1 && order !== 2) return
2564
+ if (hasAncestorTag(node, PAGE_TITLE_HOST_TAGS)) return
2565
+ if (hasThemeAllow(context, node, 'in-body-page-title')) return
2566
+ context.report({ node, message: IN_BODY_PAGE_TITLE_MESSAGE })
2567
+ },
2568
+ }
2569
+ },
2570
+ }
2571
+
2572
+ // ── Rule 21 — responsive-twin ───────────────────────────────────────────────────────────────────
2573
+
2574
+ const RESPONSIVE_TWIN_MESSAGE =
2575
+ 'The same control mounted twice under visibleFrom/hiddenFrom — a responsive swap belongs to the ' +
2576
+ 'CONTROL, which owns one mount and switches its own presentation in CSS (law C9). Two mounts are ' +
2577
+ 'two states: the hidden half keeps its own value, its own focus and its own store writes. ' +
2578
+ 'Render it once. (basalt/responsive-twin)'
2579
+
2580
+ /** The breakpoint a `visibleFrom` / `hiddenFrom` prop names, as a string literal. */
2581
+ function breakpointAttr(element, attrName) {
2582
+ for (const attr of element.openingElement?.attributes ?? []) {
2583
+ if (attr.type !== 'JSXAttribute' || attr.name?.name !== attrName) continue
2584
+ const value = unwrapExpressionContainer(attr.value)
2585
+ return isStringLiteral(value) ? value.value : undefined
2586
+ }
2587
+ return undefined
2588
+ }
2589
+
2590
+ /** Every control tag rendered anywhere in `node`'s subtree, itself included. */
2591
+ function subtreeControlTags(node, into = new Set(), depth = 0) {
2592
+ if (node === null || node === undefined || depth > 16) return into
2593
+ if (node.type === 'JSXElement') {
2594
+ const tag = jsxTagName(node.openingElement?.name)
2595
+ if (tag !== undefined && (RAW_FILTER_TAGS.has(tag) || BOUND_TAGS.has(tag))) into.add(tag)
2596
+ for (const child of node.children ?? []) subtreeControlTags(child, into, depth + 1)
2597
+ return into
2598
+ }
2599
+ if (node.type === 'JSXFragment') {
2600
+ for (const child of node.children ?? []) subtreeControlTags(child, into, depth + 1)
2601
+ return into
2602
+ }
2603
+ if (node.type === 'JSXExpressionContainer')
2604
+ return subtreeControlTags(node.expression, into, depth + 1)
2605
+ if (node.type === 'ConditionalExpression') {
2606
+ subtreeControlTags(node.consequent, into, depth + 1)
2607
+ return subtreeControlTags(node.alternate, into, depth + 1)
2608
+ }
2609
+ if (node.type === 'LogicalExpression') {
2610
+ subtreeControlTags(node.left, into, depth + 1)
2611
+ return subtreeControlTags(node.right, into, depth + 1)
2612
+ }
2613
+ return into
2614
+ }
2615
+
2616
+ /**
2617
+ * Law C9 — linewatch shipped three doubled controls this way. The pair test is deliberately narrow:
2618
+ * two SIBLING elements, one `visibleFrom="X"` and one `hiddenFrom="X"` on the SAME breakpoint, whose
2619
+ * subtrees both render the same control tag. A file that DEFINES a basalt control is exempt — the
2620
+ * one legitimate double mount is the CSS swap inside the control itself.
2621
+ */
2622
+ const responsiveTwin = {
2623
+ meta: {
2624
+ type: 'suggestion',
2625
+ docs: { description: 'Warn on one control mounted twice under visibleFrom/hiddenFrom.' },
2626
+ schema: [],
2627
+ },
2628
+ create(context) {
2629
+ if (isTestFile(context)) return {}
2630
+ const owner = createControlOwnerProbe()
2631
+ const twins = []
2632
+
2633
+ const inspectChildren = (node) => {
2634
+ const children = (node.children ?? []).filter((child) => child.type === 'JSXElement')
2635
+ for (let i = 0; i < children.length; i++) {
2636
+ for (let j = i + 1; j < children.length; j++) {
2637
+ const a = children[i]
2638
+ const b = children[j]
2639
+ const visibleA = breakpointAttr(a, 'visibleFrom')
2640
+ const hiddenA = breakpointAttr(a, 'hiddenFrom')
2641
+ const visibleB = breakpointAttr(b, 'visibleFrom')
2642
+ const hiddenB = breakpointAttr(b, 'hiddenFrom')
2643
+ const paired =
2644
+ (visibleA !== undefined && visibleA === hiddenB) ||
2645
+ (hiddenA !== undefined && hiddenA === visibleB)
2646
+ if (!paired) continue
2647
+ const tagsA = subtreeControlTags(a)
2648
+ if (tagsA.size === 0) continue
2649
+ const shared = [...subtreeControlTags(b)].some((tag) => tagsA.has(tag))
2650
+ if (!shared) continue
2651
+ twins.push(b)
2652
+ }
2653
+ }
2654
+ }
2655
+
2656
+ return {
2657
+ ...owner.visitors,
2658
+ JSXElement: inspectChildren,
2659
+ JSXFragment: inspectChildren,
2660
+ 'Program:exit'() {
2661
+ if (owner.state.definesControl) return
2662
+ for (const node of twins) {
2663
+ if (hasThemeAllow(context, node, 'responsive-twin')) continue
2664
+ context.report({ node, message: RESPONSIVE_TWIN_MESSAGE })
2665
+ }
2666
+ },
2667
+ }
2668
+ },
2669
+ }
2670
+
2671
+ // ── Rule 22 — search-literal-link ───────────────────────────────────────────────────────────────
2672
+
2673
+ /** The two calls that build a typed nav definition — `search:` inside one is a nav link's search. */
2674
+ const NAV_DEFINITION_CALLEES = new Set(['defineNav', 'navGroup'])
2675
+
2676
+ const SEARCH_LITERAL_LINK_MESSAGE =
2677
+ "Object literal as a nav link's `search` — it PINS those values on every click, so a nav link " +
2678
+ 'always navigates back to the fallback and the persisted selection is silently discarded (law ' +
2679
+ "C10; argo's reader had zero call sites because of exactly this). Pass `store.linkSearch` BY " +
2680
+ 'REFERENCE instead — never a literal, never `search: true`, never a global link callback. ' +
2681
+ '(basalt/search-literal-link)'
2682
+
2683
+ /** True when `node` sits inside a `defineNav()` / `navGroup()` argument. */
2684
+ function insideNavDefinition(node) {
2685
+ let current = node.parent
2686
+ for (let depth = 0; current !== null && current !== undefined; depth++) {
2687
+ if (depth > ANCESTRY_MAX_DEPTH || current.type === 'Program') return false
2688
+ if (
2689
+ current.type === 'CallExpression' &&
2690
+ current.callee?.type === 'Identifier' &&
2691
+ NAV_DEFINITION_CALLEES.has(current.callee.name)
2692
+ ) {
2693
+ return true
2694
+ }
2695
+ current = current.parent
2696
+ }
2697
+ return false
2698
+ }
2699
+
2700
+ const searchLiteralLink = {
2701
+ meta: {
2702
+ type: 'suggestion',
2703
+ docs: { description: 'Warn on a `search:` object literal inside defineNav/navGroup.' },
2704
+ schema: [],
2705
+ },
2706
+ create(context) {
2707
+ return {
2708
+ CallExpression(node) {
2709
+ if (node.callee?.type !== 'Identifier' || node.callee.name !== 'linkOptions') return
2710
+ const arg0 = node.arguments?.[0]
2711
+ if (arg0 === undefined || arg0.type !== 'ObjectExpression') return
2712
+ for (const prop of arg0.properties) {
2713
+ if (prop.type !== 'Property' || propertyKeyName(prop) !== 'search') continue
2714
+ if (prop.value?.type !== 'ObjectExpression') continue
2715
+ if (!insideNavDefinition(node)) continue
2716
+ if (hasThemeAllow(context, prop, 'search-literal-link')) continue
2717
+ context.report({ node: prop, message: SEARCH_LITERAL_LINK_MESSAGE })
2718
+ }
2719
+ },
2720
+ }
2721
+ },
2722
+ }
2723
+
2724
+ // ── Rule 23 — use-search-from-literal ───────────────────────────────────────────────────────────
2725
+
2726
+ const USE_SEARCH_FROM_LITERAL_MESSAGE =
2727
+ "useSearch({ from: '<route>' }) — a `from` literal pins the reader to ONE route id, so the same " +
2728
+ 'component throws the moment it renders on a sibling or child route. A store field reads ' +
2729
+ '`useSearch({ strict: false })` internally, which sees the merged search of every matched route ' +
2730
+ '(law C10) — take the value as a prop, or read it through the store field. ' +
2731
+ '(basalt/use-search-from-literal)'
2732
+
2733
+ const useSearchFromLiteral = {
2734
+ meta: {
2735
+ type: 'suggestion',
2736
+ docs: { description: "Warn on useSearch({ from: '<literal>' })." },
2737
+ schema: [],
2738
+ },
2739
+ create(context) {
2740
+ return {
2741
+ CallExpression(node) {
2742
+ const callee = node.callee
2743
+ const name =
2744
+ callee?.type === 'MemberExpression' ? callee.property?.name : (callee?.name ?? undefined)
2745
+ if (name !== 'useSearch') return
2746
+ const arg0 = node.arguments?.[0]
2747
+ if (arg0 === undefined || arg0.type !== 'ObjectExpression') return
2748
+ for (const prop of arg0.properties) {
2749
+ if (prop.type !== 'Property' || propertyKeyName(prop) !== 'from') continue
2750
+ if (!isStringLiteral(prop.value)) continue
2751
+ if (hasThemeAllow(context, prop, 'use-search-from-literal')) continue
2752
+ context.report({ node: prop, message: USE_SEARCH_FROM_LITERAL_MESSAGE })
2753
+ }
2754
+ },
2755
+ }
2756
+ },
2757
+ }
2758
+
1603
2759
  // ── Grace ledger ────────────────────────────────────────────────────────────────────────────────
1604
2760
 
2761
+ /**
2762
+ * @typedef {{ since: string; promote: string; why: string }} GraceEntry
2763
+ * @typedef {{ since: string; why: string }} AdvisoryEntry
2764
+ */
2765
+
1605
2766
  /**
1606
2767
  * Plugin rules still inside their grace minor — `warn` in the shipped consumer preset, `error`
1607
- * repo-locally, with the promotion note as the value. This is the plugin's counterpart to
1608
- * `src/guard`'s `GRACE_PERIOD_KINDS`, and it exists because its ABSENCE is what let three rules sit
1609
- * at `warn` for up to twelve minors with nothing tracking them: `configs/oxlint.json` is generated
1610
- * and its top-level keys are fixed by oxlint's own parser, so the ledger cannot live there.
1611
- *
1612
- * `oxlint-plugin.test.ts` asserts the two agree in both directions — a rule listed here must be
1613
- * `warn` in the shipped preset, and one absent from here must be `error`. Deleting an entry IS the
1614
- * promotion, and the test makes the config change mandatory in the same commit.
2768
+ * repo-locally. This is the plugin's counterpart to `src/guard`'s `GRACE_PERIOD_KINDS`, and it
2769
+ * exists because its ABSENCE is what let three rules sit at `warn` for up to twelve minors with
2770
+ * nothing tracking them: `configs/oxlint.json` is generated and its top-level keys are fixed by
2771
+ * oxlint's own parser, so the ledger cannot live there.
2772
+ *
2773
+ * `oxlint-plugin.test.ts` asserts the two agree in both directions — a rule listed here or in
2774
+ * {@link PLUGIN_RULE_ADVISORY} must be `warn` in the shipped preset, and one absent from both must
2775
+ * be `error`. Deleting a `PLUGIN_RULE_GRACE` entry IS the promotion, and the test makes the config
2776
+ * change mandatory in the same commit.
2777
+ *
2778
+ * Each entry is `{ since, promote, why }` (semver strings, C16 — `docs/CONTROLS-SPEC.md` §1): a
2779
+ * version-gated test fails the build once `package.json`'s version reaches `promote` while the
2780
+ * entry is still here — the four entries that used to live here (D4) sat at `warn` for up to five
2781
+ * minors because a bare promotion-note string carried no expiry a test could check. The test gate
2782
+ * fires on the version already published; `scripts/check-grace.ts` is the other end of it, run by
2783
+ * `scripts/release.sh` against the version the dry run COMPUTED, so a release that would ship a
2784
+ * due entry still at `warn` is refused before it is cut (the release commit is `[skip ci]` and
2785
+ * runs no tests, so the test alone would only notice on the next unrelated push).
2786
+ *
2787
+ * @type {Record<string, GraceEntry>}
1615
2788
  */
1616
2789
  export const PLUGIN_RULE_GRACE = {
1617
- 'hand-rolled-plot':
1618
- 'widened AGAIN in the round-5 guard minor (file scope must now be spelled `theme-allow-file`, ' +
1619
- 'so the named-with-a-reason shape every consumer wrote at 1.20.0 is a NODE waiver and the ' +
1620
- 'remaining assembly nodes report) — grace restarts with the widening, per the "a rule the ' +
1621
- 'current minor widens does not promote in that minor" doctrine. Promote one minor after ' +
1622
- 'consumers have moved their file declarations to `theme-allow-file`.',
1623
- 'chart-legend-literal':
1624
- 'widened in the round-4 guard minor (a `.map()` over a non-series array now counts) — promote ' +
1625
- 'to error one minor later, for the same reason as hand-rolled-plot above.',
1626
- 'shadow-basalt-export':
1627
- 'new in the round-4 guard minor, widened in round-5 (reads every published barrel, not just ' +
1628
- 'the root one, so a chart-layer fork is visible) — promote to error one minor later, if ever. ' +
1629
- 'A name collision is strong evidence and not proof, and renaming defeats it outright, so this ' +
1630
- 'may be a permanent warn.',
1631
- 'hand-rolled-shell': 'new in the round-4 guard minor — promote to error one minor later.',
2790
+ 'control-outside-home': {
2791
+ since: '1.26.0',
2792
+ promote: '1.27.0',
2793
+ why:
2794
+ 'the wave-6 control guards (docs/CONTROLS-SPEC.md §6). The one openly HEURISTIC rule of the ' +
2795
+ 'set — "this control has no home" is a claim about layout intent, so its false-positive load ' +
2796
+ 'is carried by three exemptions (overlay/settings-row ancestor, @mantine/form, owner ' +
2797
+ 'definition) rather than by certainty. Promotion is additionally gated on running the ' +
2798
+ 'shipped preset over argo, linewatch, image-share, rb, image-gen and the playground with ≤3 ' +
2799
+ 'total waivers (§9 wave 7).',
2800
+ },
2801
+ 'control-size-literal': {
2802
+ since: '1.26.0',
2803
+ promote: '1.27.0',
2804
+ why:
2805
+ 'the wave-6 control guards (docs/CONTROLS-SPEC.md §6). Rejects a prop that was correct one ' +
2806
+ 'minor ago — every `size="xs"` written into a bar before the tier existed — so it takes the ' +
2807
+ 'grace minor the doctrine exists for. Every wave ships in ONE minor (1.26.0), so the whole ' +
2808
+ 'set promotes together in 1.27.0 — one runway, not one per wave.',
2809
+ },
2810
+ 'in-body-page-title': {
2811
+ since: '1.26.0',
2812
+ promote: '1.27.0',
2813
+ why:
2814
+ 'the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C8). Its text-level twin is the ' +
2815
+ 'guard kind of the SAME id, which carries the same dates in GRACE_PERIOD_KINDS — one law, ' +
2816
+ 'two lanes, one promotion. Warn because an in-body h1/h2 is a judgement about which name ' +
2817
+ 'the page already has, and the fix is a route/breadcrumb change rather than a prop edit.',
2818
+ },
2819
+ 'responsive-twin': {
2820
+ since: '1.26.0',
2821
+ promote: '1.27.0',
2822
+ why:
2823
+ 'the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C9 — linewatch shipped three ' +
2824
+ 'doubled controls). Warn for one minor because the fix is a real edit (delete one mount, ' +
2825
+ 'move the swap into the control), not a prop removal, and the pair test is a heuristic over ' +
2826
+ 'sibling structure.',
2827
+ },
2828
+ 'search-literal-link': {
2829
+ since: '1.26.0',
2830
+ promote: '1.27.0',
2831
+ why:
2832
+ 'the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C10 — argo nav.tsx:132 pinned the ' +
2833
+ 'fallback on every click, which is why its reader had zero call sites). Warn for one minor ' +
2834
+ 'because the fix needs a store to exist at the call site, which is a wave-2 dependency for ' +
2835
+ 'consumers still on the old enum-only pair.',
2836
+ },
2837
+ 'use-search-from-literal': {
2838
+ since: '1.26.0',
2839
+ promote: '1.27.0',
2840
+ why:
2841
+ 'the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C10). Same runway as ' +
2842
+ 'search-literal-link and for the same reason: the remedy is `createSearchStore` + a prop, ' +
2843
+ 'so a consumer needs the wave-2 store before the finding is actionable.',
2844
+ },
2845
+ }
2846
+
2847
+ /**
2848
+ * Plugin rules that stay `warn` in the shipped preset PERMANENTLY — outside the C16 version gate,
2849
+ * and not expected to ever promote. `oxlint-plugin.test.ts` still requires `warn` in the shipped
2850
+ * preset and a `why` long enough to justify it, but skips these when checking `promote`.
2851
+ *
2852
+ * `shadow-basalt-export` is the one entry: a name collision is strong evidence a component was
2853
+ * forked rather than imported, but not proof, and renaming the fork defeats the check outright — so
2854
+ * there is no version at which flipping it to `error` is safe to promise in advance.
2855
+ *
2856
+ * @type {Record<string, AdvisoryEntry>}
2857
+ */
2858
+ export const PLUGIN_RULE_ADVISORY = {
2859
+ 'shadow-basalt-export': {
2860
+ since: '1.20.0',
2861
+ why:
2862
+ 'new in the round-4 guard minor (1.20.0), widened in round-5 (1.21.0 — reads every ' +
2863
+ 'published barrel, not just the root one, so a chart-layer fork is visible), narrowed at ' +
2864
+ '1.22.0 (`isBasaltScopedFile` plus a component-shaped declaration). A name collision is ' +
2865
+ 'strong evidence and not proof, and renaming defeats it outright, so this stays a permanent ' +
2866
+ 'warn rather than a grace entry with no honest promote date.',
2867
+ },
1632
2868
  }
1633
2869
 
1634
2870
  // ── Plugin export ───────────────────────────────────────────────────────────────────────────────
@@ -1645,6 +2881,14 @@ export default {
1645
2881
  'shadow-basalt-export': shadowBasaltExport,
1646
2882
  'hand-rolled-shell': handRolledShell,
1647
2883
  'raw-scroll-container': rawScrollContainer,
2884
+ 'hand-rolled-filter': handRolledFilter,
2885
+ 'control-outside-home': controlOutsideHome,
2886
+ 'control-size-literal': controlSizeLiteral,
2887
+ 'page-bar-budget': pageBarBudget,
2888
+ 'in-body-page-title': inBodyPageTitle,
2889
+ 'responsive-twin': responsiveTwin,
2890
+ 'search-literal-link': searchLiteralLink,
2891
+ 'use-search-from-literal': useSearchFromLiteral,
1648
2892
  'visx-boundary': visxBoundary,
1649
2893
  'visx-tooltip': visxTooltip,
1650
2894
  'token-layer-boundary': tokenLayerBoundary,