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
@@ -12,8 +12,15 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
12
12
  import { tmpdir } from 'node:os'
13
13
  import { dirname, resolve } from 'node:path'
14
14
  // eslint-disable-next-line -- the plugin is plain JS; the ledger + id set are named exports beside it
15
- import basaltPlugin, { KNOWN_RULE_IDS, PLUGIN_RULE_GRACE } from './oxlint-plugin.js'
16
- import { GUARD_RULES } from '../src/guard/index.ts'
15
+ import basaltPlugin, {
16
+ CTL_THEME_TAGS,
17
+ KNOWN_RULE_IDS,
18
+ PLUGIN_RULE_ADVISORY,
19
+ PLUGIN_RULE_GRACE,
20
+ } from './oxlint-plugin.js'
21
+ import { CTL_THEME } from '../src/theme/ctl-theme'
22
+ import { GUARD_RULES, PLUGIN_RULE_IDS } from '../src/guard/index.ts'
23
+ import { PLUGIN_RULE_ID_LIST, SURFACES } from '../src/surfaces.ts'
17
24
 
18
25
  const PLUGIN_PATH = resolve(import.meta.dirname, 'oxlint-plugin.js')
19
26
  const OXLINT_BIN = resolve(import.meta.dirname, '..', '..', '..', 'node_modules', '.bin', 'oxlint')
@@ -35,6 +42,15 @@ beforeEach(() => {
35
42
  'basalt/chart-legend-literal': 'error',
36
43
  'basalt/shadow-basalt-export': 'error',
37
44
  'basalt/hand-rolled-shell': 'error',
45
+ 'basalt/raw-scroll-container': 'error',
46
+ 'basalt/hand-rolled-filter': 'error',
47
+ 'basalt/control-outside-home': 'error',
48
+ 'basalt/control-size-literal': 'error',
49
+ 'basalt/page-bar-budget': 'error',
50
+ 'basalt/in-body-page-title': 'error',
51
+ 'basalt/responsive-twin': 'error',
52
+ 'basalt/search-literal-link': 'error',
53
+ 'basalt/use-search-from-literal': 'error',
38
54
  'basalt/visx-boundary': 'error',
39
55
  'basalt/visx-tooltip': 'error',
40
56
  'basalt/token-layer-boundary': 'error',
@@ -1152,32 +1168,134 @@ export const C = () => <NavLink label="Settings" />\n`,
1152
1168
 
1153
1169
  // ── grace ledger ↔ shipped preset ────────────────────────────────────────────
1154
1170
 
1171
+ type GraceEntry = { since: string; promote: string; why: string }
1172
+
1173
+ /**
1174
+ * Tiny local semver compare — no dependency. Assumes plain `x.y.z`, no pre-release identifiers.
1175
+ * Compares each component numerically, not lexically, so `'1.9.0' < '1.10.0'` and
1176
+ * `'1.3.0' < '1.26.0'` — a string compare would get both backwards.
1177
+ */
1178
+ function compareSemver(a: string, b: string): number {
1179
+ const partsA = a.split('.').map(Number)
1180
+ const partsB = b.split('.').map(Number)
1181
+ for (let i = 0; i < 3; i++) {
1182
+ const diff = (partsA[i] ?? 0) - (partsB[i] ?? 0)
1183
+ if (diff !== 0) return diff > 0 ? 1 : -1
1184
+ }
1185
+ return 0
1186
+ }
1187
+
1188
+ /**
1189
+ * The C16 gate itself (docs/CONTROLS-SPEC.md §1), extracted so it can be exercised against a
1190
+ * synthetic ledger as well as the real `PLUGIN_RULE_GRACE` — an `it.each` over the real ledger ran
1191
+ * zero assertions while it was empty, which proved nothing about the gate actually firing. Throws
1192
+ * naming the first offending entry when either invariant breaks: `since` must precede `promote`
1193
+ * for every entry, and no entry's `promote` may be `<=` the given version.
1194
+ *
1195
+ * `version` is `package.json`'s, i.e. the version already published, so this half can only fail
1196
+ * AFTER the release that shipped a due entry — `release.yml` runs no tests and the
1197
+ * `chore: release … [skip ci]` commit skips CI. The pre-release half is
1198
+ * `scripts/check-grace.ts`, run by `scripts/release.sh` against the version the dry run computed.
1199
+ */
1200
+ function assertGraceLedger(ledger: Record<string, GraceEntry>, version: string): void {
1201
+ for (const [id, entry] of Object.entries(ledger)) {
1202
+ if (compareSemver(entry.since, entry.promote) >= 0) {
1203
+ throw new Error(
1204
+ `${id}: \`since\` (${entry.since}) must be before \`promote\` (${entry.promote}).`,
1205
+ )
1206
+ }
1207
+ if (compareSemver(version, entry.promote) >= 0) {
1208
+ throw new Error(
1209
+ `basalt/${id}: shipped preset is at ${version}, which has reached its promote version ` +
1210
+ `${entry.promote} — promote to error or extend \`promote\` with a reason.`,
1211
+ )
1212
+ }
1213
+ }
1214
+ }
1215
+
1216
+ describe('compareSemver', () => {
1217
+ it('orders multi-digit components numerically, not lexically', () => {
1218
+ expect(compareSemver('1.9.0', '1.10.0')).toBeLessThan(0)
1219
+ expect(compareSemver('1.10.0', '1.9.0')).toBeGreaterThan(0)
1220
+ expect(compareSemver('1.3.0', '1.26.0')).toBeLessThan(0)
1221
+ expect(compareSemver('1.26.0', '1.3.0')).toBeGreaterThan(0)
1222
+ })
1223
+
1224
+ it('treats equal versions as equal', () => {
1225
+ expect(compareSemver('1.25.0', '1.25.0')).toBe(0)
1226
+ })
1227
+ })
1228
+
1229
+ describe('assertGraceLedger', () => {
1230
+ it('does not throw on a well-formed entry not yet due', () => {
1231
+ const ledger = { 'fake-rule': { since: '1.0.0', promote: '2.0.0', why: 'synthetic' } }
1232
+ expect(() => assertGraceLedger(ledger, '1.25.0')).not.toThrow()
1233
+ })
1234
+
1235
+ it('throws once the given version reaches promote', () => {
1236
+ const ledger = { 'fake-rule': { since: '1.0.0', promote: '1.25.0', why: 'synthetic' } }
1237
+ expect(() => assertGraceLedger(ledger, '1.25.0')).toThrow(/fake-rule/)
1238
+ })
1239
+
1240
+ it('throws once the given version is past promote', () => {
1241
+ const ledger = { 'fake-rule': { since: '1.0.0', promote: '1.9.0', why: 'synthetic' } }
1242
+ expect(() => assertGraceLedger(ledger, '1.10.0')).toThrow(/fake-rule/)
1243
+ })
1244
+
1245
+ it('throws when since does not precede promote', () => {
1246
+ const ledger = { 'fake-rule': { since: '1.5.0', promote: '1.5.0', why: 'synthetic' } }
1247
+ expect(() => assertGraceLedger(ledger, '1.0.0')).toThrow(/since/)
1248
+ })
1249
+ })
1250
+
1155
1251
  describe('PLUGIN_RULE_GRACE', () => {
1156
1252
  const shipped = JSON.parse(readFileSync(resolve(import.meta.dirname, 'oxlint.json'), 'utf8')) as {
1157
1253
  rules: Record<string, unknown>
1158
1254
  }
1159
- const graceIds = Object.keys(PLUGIN_RULE_GRACE)
1255
+ const pkgVersion = (
1256
+ JSON.parse(readFileSync(resolve(import.meta.dirname, '..', 'package.json'), 'utf8')) as {
1257
+ version: string
1258
+ }
1259
+ ).version
1260
+ const graceEntries = Object.entries(PLUGIN_RULE_GRACE) as [string, GraceEntry][]
1261
+ const advisoryEntries = Object.entries(PLUGIN_RULE_ADVISORY) as [
1262
+ string,
1263
+ { since: string; why: string },
1264
+ ][]
1265
+ const graceIds = graceEntries.map(([id]) => id)
1266
+ const advisoryIds = advisoryEntries.map(([id]) => id)
1160
1267
  const shippedBasaltIds = Object.keys(shipped.rules).filter((id) => id.startsWith('basalt/'))
1161
1268
 
1162
1269
  // The mechanism whose ABSENCE let three rules sit at `warn` for up to twelve minors with nothing
1163
1270
  // tracking them. Deleting a ledger entry IS the promotion, and this test makes flipping the
1164
1271
  // shipped level part of the same commit.
1165
- it('every ledger entry is warn in the shipped preset', () => {
1166
- for (const id of graceIds) expect(shipped.rules[`basalt/${id}`]).toBe('warn')
1272
+ it('every grace and advisory entry is warn in the shipped preset', () => {
1273
+ for (const id of [...graceIds, ...advisoryIds])
1274
+ expect(shipped.rules[`basalt/${id}`]).toBe('warn')
1167
1275
  })
1168
1276
 
1169
- it('every shipped rule NOT in the ledger is error', () => {
1277
+ it('every shipped rule NOT in either ledger is error', () => {
1170
1278
  for (const id of shippedBasaltIds) {
1171
- if (graceIds.includes(id.slice('basalt/'.length))) continue
1279
+ const bareId = id.slice('basalt/'.length)
1280
+ if (graceIds.includes(bareId) || advisoryIds.includes(bareId)) continue
1172
1281
  expect([id, shipped.rules[id]]).toEqual([id, 'error'])
1173
1282
  }
1174
1283
  })
1175
1284
 
1176
- it('every ledger entry carries a written promotion note', () => {
1177
- for (const id of graceIds) {
1178
- expect((PLUGIN_RULE_GRACE as Record<string, string>)[id]?.length ?? 0).toBeGreaterThan(40)
1285
+ it('every entry carries a written why', () => {
1286
+ for (const [id, entry] of [...graceEntries, ...advisoryEntries]) {
1287
+ if (entry.why.length <= 40)
1288
+ throw new Error(`${id}: \`why\` must be longer than 40 characters.`)
1289
+ expect(entry.why.length).toBeGreaterThan(40)
1179
1290
  }
1180
1291
  })
1292
+
1293
+ // The C16 version gate, run against the REAL ledger and REAL package version — covers `since` <
1294
+ // `promote` and "no entry has reached its promote version" in one call. ADVISORY entries are
1295
+ // exempt by design (see `assertGraceLedger`'s synthetic coverage above for the gate itself).
1296
+ it('passes the C16 gate against the real ledger and package version', () => {
1297
+ expect(() => assertGraceLedger(Object.fromEntries(graceEntries), pkgVersion)).not.toThrow()
1298
+ })
1181
1299
  })
1182
1300
 
1183
1301
  // ── chart-legend-literal: the .map() half ────────────────────────────────────
@@ -1287,10 +1405,29 @@ describe('KNOWN_RULE_IDS', () => {
1287
1405
  }
1288
1406
  })
1289
1407
 
1408
+ // Deduped, because ONE id is deliberately in both registries: `in-body-page-title` is a plugin
1409
+ // rule AND a guard kind (law C8 has an AST half and a text half), so one `theme-allow
1410
+ // in-body-page-title — <why>` waives both lanes rather than needing a different word per lane.
1290
1411
  it('contains nothing else — it is exactly the union of the two registries', () => {
1291
- const expected = [...Object.keys(basaltPlugin.rules), ...Object.keys(GUARD_RULES)].toSorted()
1412
+ const expected = [
1413
+ ...new Set([...Object.keys(basaltPlugin.rules), ...Object.keys(GUARD_RULES)]),
1414
+ ].toSorted()
1292
1415
  expect([...KNOWN_RULE_IDS].toSorted()).toEqual(expected)
1293
1416
  })
1417
+
1418
+ it('shares exactly one id between the two registries — the C8 pair', () => {
1419
+ const shared = Object.keys(basaltPlugin.rules).filter((id) => Object.hasOwn(GUARD_RULES, id))
1420
+ expect(shared).toEqual(['in-body-page-title'])
1421
+ })
1422
+
1423
+ // The guard's own copy (`PLUGIN_RULE_IDS`) is what `check-theme --audit-allows` classifies an
1424
+ // annotation against: an id in neither registry reads as a typo and is reported unscoped. A new
1425
+ // plugin rule missing from it would make every waiver naming it look like a mistake.
1426
+ it('every plugin rule is classifiable by the guard — its own id set or a guard kind', () => {
1427
+ for (const id of Object.keys(basaltPlugin.rules)) {
1428
+ expect([id, PLUGIN_RULE_IDS.has(id) || Object.hasOwn(GUARD_RULES, id)]).toEqual([id, true])
1429
+ }
1430
+ })
1294
1431
  })
1295
1432
 
1296
1433
  // ── theme-allow fails closed on a typo ─────────────────────────────────────────────────────────
@@ -1335,3 +1472,802 @@ describe('theme-allow with an unrecognized rule id', () => {
1335
1472
  expect(code).toBe(0)
1336
1473
  })
1337
1474
  })
1475
+
1476
+ // ── the control-home rules (docs/CONTROLS-SPEC.md §6) ────────────────────────
1477
+
1478
+ const MANTINE_IMPORT = `import { Button, ScrollArea, SegmentedControl, Select } from '@mantine/core'\n`
1479
+
1480
+ /**
1481
+ * The home components, imported from basalt — the OWNER-tag provenance every slot rule now requires.
1482
+ *
1483
+ * Not decoration: `Section` is one of the most common local component names there is, and matching
1484
+ * the bare tag made a consumer's own `<Section actions={…}>` an `error`-level tiered home. A fixture
1485
+ * that renders `<PageBar>` out of thin air proves nothing a real consumer file would hit, so the
1486
+ * fixtures import what they render.
1487
+ */
1488
+ const BASALT_IMPORT = `import { ChartCard, PageBar, Section, SettingsSection } from 'basalt-ui'\n`
1489
+
1490
+ describe('basalt/hand-rolled-filter', () => {
1491
+ it('flags a raw Mantine Select handed to a PageBar filters slot', () => {
1492
+ const { code, rules } = run(
1493
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <PageBar filters={<Select data={[]} />} />\n`,
1494
+ )
1495
+ expect(code).toBe(1)
1496
+ expect(rules).toContain('hand-rolled-filter')
1497
+ })
1498
+
1499
+ it('flags it through a hoisted binding — the argo headerExtra shape', () => {
1500
+ const { code, rules } = run(
1501
+ `${BASALT_IMPORT}${MANTINE_IMPORT}const pills = <Select data={[]} />\n` +
1502
+ `export const C = () => <PageBar filters={pills} />\n`,
1503
+ )
1504
+ expect(code).toBe(1)
1505
+ expect(rules).toContain('hand-rolled-filter')
1506
+ })
1507
+
1508
+ it('flags one inside an ARRAY slot value (filtersEnd={[…]})', () => {
1509
+ const { rules } = run(
1510
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <PageBar filtersEnd={[<Select key="a" data={[]} />]} />\n`,
1511
+ )
1512
+ expect(rules).toContain('hand-rolled-filter')
1513
+ })
1514
+
1515
+ // The body form is the whole reason the walk stops at the slot ATTRIBUTE: a Section's children
1516
+ // are a page region, not a control home, and a form there is C1's third home.
1517
+ // `control-outside-home` DOES report this one (a Section body is not a home either) — which is
1518
+ // the point of the split: the body form is a warn about placement, never an error about the slot.
1519
+ it("does NOT flag a Select in a Section's CHILDREN — the body form", () => {
1520
+ const { rules } = run(
1521
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <Section title="Filters">\n <Select data={[]} />\n </Section>\n)\n`,
1522
+ )
1523
+ expect(rules).not.toContain('hand-rolled-filter')
1524
+ })
1525
+
1526
+ it("does NOT flag a slot prop on a tag that is not a home (a consumer's own Toolbar)", () => {
1527
+ const { rules } = run(
1528
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <Toolbar filters={<Select data={[]} />} />\n`,
1529
+ )
1530
+ expect(rules).not.toContain('hand-rolled-filter')
1531
+ })
1532
+
1533
+ it('does NOT flag a same-named component that is not the Mantine binding', () => {
1534
+ const { code, rules } = run(
1535
+ `${BASALT_IMPORT}import { Select } from './my-select'\nexport const C = () => <PageBar filters={<Select data={[]} />} />\n`,
1536
+ )
1537
+ expect(code).toBe(0)
1538
+ expect(rules).not.toContain('hand-rolled-filter')
1539
+ })
1540
+
1541
+ it('does NOT flag a bound basalt control in the same slot', () => {
1542
+ const { code, rules } = run(
1543
+ `${BASALT_IMPORT}export const C = () => <PageBar filters={<RangeFilter field={f} />} />\n`,
1544
+ )
1545
+ expect(code).toBe(0)
1546
+ expect(rules).not.toContain('hand-rolled-filter')
1547
+ })
1548
+
1549
+ // The settled reading of law C1's THIRD home (docs/CONTROLS-SPEC.md §5/§6): a form row keeps
1550
+ // Mantine's `md` tier, so a raw input bound to a setting is right there — and `control` /
1551
+ // `SettingsRow` are absent from the slot set for exactly that reason.
1552
+ it("does NOT flag a raw Select in a SettingsRow's control — the form-row home", () => {
1553
+ const { code, rules } = run(
1554
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <SettingsSection title="S">\n <SettingsRow label="Channel" control={<Select data={[]} />} />\n </SettingsSection>\n)\n`,
1555
+ )
1556
+ expect(code).toBe(0)
1557
+ expect(rules).not.toContain('hand-rolled-filter')
1558
+ expect(rules).not.toContain('control-outside-home')
1559
+ })
1560
+
1561
+ it("still flags one in a SettingsSection's actions header slot — that IS tiered", () => {
1562
+ const { rules } = run(
1563
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <SettingsSection title="S" actions={<Select data={[]} />} />\n`,
1564
+ )
1565
+ expect(rules).toContain('hand-rolled-filter')
1566
+ })
1567
+
1568
+ it('flags a member tag whose ROOT binding is Mantine (Chip.Group)', () => {
1569
+ const { rules } = run(
1570
+ `${BASALT_IMPORT}import { Chip } from '@mantine/core'\nexport const C = () => <PageBar filters={<Chip.Group multiple />} />\n`,
1571
+ )
1572
+ expect(rules).toContain('hand-rolled-filter')
1573
+ })
1574
+
1575
+ it('honors a scoped theme-allow', () => {
1576
+ const { rules } = run(
1577
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <PageBar\n filters={\n // theme-allow hand-rolled-filter — not a store field\n <Select data={[]} />\n }\n />\n)\n`,
1578
+ )
1579
+ expect(rules).not.toContain('hand-rolled-filter')
1580
+ })
1581
+
1582
+ it('honors a theme-allow-file declaration', () => {
1583
+ const { rules } = run(
1584
+ `// theme-allow-file hand-rolled-filter — a legacy page, migrating next sprint\n` +
1585
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <PageBar filters={<Select data={[]} />} />\n`,
1586
+ )
1587
+ expect(rules).not.toContain('hand-rolled-filter')
1588
+ })
1589
+
1590
+ // Colocating a trigger with its overlay in the slot is how a "create" modal and a "custom range"
1591
+ // popover are written. The overlay's contents portal out of the slot and are already declared
1592
+ // non-homes by `control-outside-home`, so the same code was exempt outside a slot and an `error`
1593
+ // inside one.
1594
+ it('does NOT flag a Select inside a Modal colocated in an actions slot', () => {
1595
+ const { code, rules } = run(
1596
+ `${BASALT_IMPORT}import { Button, Modal, Select } from '@mantine/core'\n` +
1597
+ `export const C = () => (\n <WidgetHeader\n actions={\n <>\n <Button onClick={open}>New</Button>\n <Modal opened>\n <Select data={[]} />\n </Modal>\n </>\n }\n />\n)\n`,
1598
+ )
1599
+ expect(code).toBe(0)
1600
+ expect(rules).not.toContain('hand-rolled-filter')
1601
+ })
1602
+
1603
+ it('does NOT flag one inside a Popover.Dropdown colocated in a filters slot', () => {
1604
+ const { rules } = run(
1605
+ `${BASALT_IMPORT}import { Button, Popover, Select } from '@mantine/core'\n` +
1606
+ `export const C = () => (\n <Section\n title="T"\n filters={\n <Popover>\n <Popover.Target>\n <Button>Custom range</Button>\n </Popover.Target>\n <Popover.Dropdown>\n <Select data={[]} />\n </Popover.Dropdown>\n </Popover>\n }\n />\n)\n`,
1607
+ )
1608
+ expect(rules).not.toContain('hand-rolled-filter')
1609
+ })
1610
+
1611
+ it('STILL flags a bare sibling Select in the same slot as an exempt overlay', () => {
1612
+ const { rules } = run(
1613
+ `${BASALT_IMPORT}import { Modal, Select } from '@mantine/core'\n` +
1614
+ `export const C = () => (\n <PageBar\n filters={\n <>\n <Select data={[]} />\n <Modal opened>\n <Select data={[]} />\n </Modal>\n </>\n }\n />\n)\n`,
1615
+ )
1616
+ expect(rules).toContain('hand-rolled-filter')
1617
+ })
1618
+
1619
+ // `Select as MantineSelect` is the canonical way a consumer wraps a Mantine component — the exact
1620
+ // case the provenance comment worried about — and keying membership on the LOCAL name made it
1621
+ // invisible.
1622
+ it('flags an ALIASED Mantine import in a slot', () => {
1623
+ const { code, rules } = run(
1624
+ `${BASALT_IMPORT}import { Select as MantineSelect } from '@mantine/core'\n` +
1625
+ `export const C = () => <PageBar filters={<MantineSelect data={[]} />} />\n`,
1626
+ )
1627
+ expect(code).toBe(1)
1628
+ expect(rules).toContain('hand-rolled-filter')
1629
+ })
1630
+
1631
+ it('flags a NAMESPACE-imported Mantine control in a slot', () => {
1632
+ const { rules } = run(
1633
+ `${BASALT_IMPORT}import * as M from '@mantine/core'\n` +
1634
+ `export const C = () => <PageBar filters={<M.SegmentedControl data={[]} />} />\n`,
1635
+ )
1636
+ expect(rules).toContain('hand-rolled-filter')
1637
+ })
1638
+
1639
+ it('flags an aliased MEMBER tag (Chip as C → C.Group)', () => {
1640
+ const { rules } = run(
1641
+ `${BASALT_IMPORT}import { Chip as Ch } from '@mantine/core'\n` +
1642
+ `export const C = () => <PageBar filters={<Ch.Group multiple />} />\n`,
1643
+ )
1644
+ expect(rules).toContain('hand-rolled-filter')
1645
+ })
1646
+
1647
+ // `control-outside-home` DOES report this one — a local `Section` is not a home, so the control
1648
+ // has none. Only this rule's silence is asserted; the exit code belongs to that warn.
1649
+ it("does NOT flag a slot on a consumer's own Section — provenance is on the OWNER too", () => {
1650
+ const { rules } = run(
1651
+ `import { Section } from './layout/section'\n${MANTINE_IMPORT}` +
1652
+ `export const C = () => <Section actions={<Select data={[]} />} />\n`,
1653
+ )
1654
+ expect(rules).not.toContain('hand-rolled-filter')
1655
+ })
1656
+ })
1657
+
1658
+ describe('basalt/control-outside-home', () => {
1659
+ it('flags a raw Mantine Select in no home at all', () => {
1660
+ const { code, rules } = run(
1661
+ `${MANTINE_IMPORT}export const C = () => <div><Select data={[]} /></div>\n`,
1662
+ )
1663
+ expect(code).toBe(1)
1664
+ expect(rules).toContain('control-outside-home')
1665
+ })
1666
+
1667
+ it('does NOT flag one inside a home slot — that is hand-rolled-filter s business', () => {
1668
+ const { rules } = run(
1669
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <PageBar filters={<Select data={[]} />} />\n`,
1670
+ )
1671
+ expect(rules).not.toContain('control-outside-home')
1672
+ })
1673
+
1674
+ it.each(['SettingsRow', 'Modal', 'Drawer', 'Composer'])(
1675
+ 'does NOT flag one under a declared non-home (%s)',
1676
+ (host) => {
1677
+ const { rules } = run(
1678
+ `${MANTINE_IMPORT}export const C = () => <${host}><Select data={[]} /></${host}>\n`,
1679
+ )
1680
+ expect(rules).not.toContain('control-outside-home')
1681
+ },
1682
+ )
1683
+
1684
+ it('does NOT flag one under a member-expression non-home (Menu.Dropdown)', () => {
1685
+ const { rules } = run(
1686
+ `${MANTINE_IMPORT}export const C = () => <Menu.Dropdown><Select data={[]} /></Menu.Dropdown>\n`,
1687
+ )
1688
+ expect(rules).not.toContain('control-outside-home')
1689
+ })
1690
+
1691
+ it('does NOT flag anything in a file that imports @mantine/form — a form is the third home', () => {
1692
+ const { code, rules } = run(
1693
+ `${MANTINE_IMPORT}import { useForm } from '@mantine/form'\n` +
1694
+ `export const C = () => <div><Select data={[]} /></div>\n`,
1695
+ )
1696
+ expect(code).toBe(0)
1697
+ expect(rules).not.toContain('control-outside-home')
1698
+ })
1699
+
1700
+ // The owner exemption, `hand-rolled-plot`'s shape: the module that IS the bound control cannot be
1701
+ // told to use one.
1702
+ // `shadow-basalt-export` reports the same fixture (a local `ViewTabs` collides with the shipped
1703
+ // one), so only this rule's own silence is asserted — the exit code belongs to that rule.
1704
+ it('does NOT flag the module that DEFINES a basalt control', () => {
1705
+ const { rules } = run(
1706
+ `${MANTINE_IMPORT}export function ViewTabs() {\n return <SegmentedControl data={[]} />\n}\n`,
1707
+ )
1708
+ expect(rules).not.toContain('control-outside-home')
1709
+ })
1710
+
1711
+ it('flags an ALIASED Mantine import with no home', () => {
1712
+ const { code, rules } = run(
1713
+ `import { Select as MantineSelect } from '@mantine/core'\n` +
1714
+ `export const C = () => <div><MantineSelect data={[]} /></div>\n`,
1715
+ )
1716
+ expect(code).toBe(1)
1717
+ expect(rules).toContain('control-outside-home')
1718
+ })
1719
+
1720
+ it('flags a NAMESPACE-imported Mantine control with no home', () => {
1721
+ const { rules } = run(
1722
+ `import * as M from '@mantine/core'\n` +
1723
+ `export const C = () => <div><M.SegmentedControl data={[]} /></div>\n`,
1724
+ )
1725
+ expect(rules).toContain('control-outside-home')
1726
+ })
1727
+
1728
+ it('honors a scoped theme-allow', () => {
1729
+ const { rules } = run(
1730
+ `${MANTINE_IMPORT}export const C = () => (\n <div>\n {/* theme-allow control-outside-home — a one-off admin picker */}\n <Select data={[]} />\n </div>\n)\n`,
1731
+ )
1732
+ expect(rules).not.toContain('control-outside-home')
1733
+ })
1734
+ })
1735
+
1736
+ describe('basalt/control-size-literal', () => {
1737
+ it.each(['size="xs"', 'w={200}', 'fullWidth', 'visibleFrom="sm"', 'hiddenFrom="sm"'])(
1738
+ 'flags %s on an element inside a home slot',
1739
+ (prop) => {
1740
+ const { code, rules } = run(
1741
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <PageBar actions={<Button ${prop}>Go</Button>} />\n`,
1742
+ )
1743
+ expect(code).toBe(1)
1744
+ expect(rules).toContain('control-size-literal')
1745
+ },
1746
+ )
1747
+
1748
+ it('flags it through a hoisted slot binding too', () => {
1749
+ const { rules } = run(
1750
+ `${BASALT_IMPORT}${MANTINE_IMPORT}const acts = <Button size="xs">Go</Button>\n` +
1751
+ `export const C = () => <PageBar actions={acts} />\n`,
1752
+ )
1753
+ expect(rules).toContain('control-size-literal')
1754
+ })
1755
+
1756
+ it("does NOT flag a size prop in a SettingsRow's control — the form tier is Mantine's md", () => {
1757
+ const { code, rules } = run(
1758
+ `${MANTINE_IMPORT}export const C = () => (\n <SettingsRow label="L" control={<Button size="compact-sm">Go</Button>} />\n)\n`,
1759
+ )
1760
+ expect(code).toBe(0)
1761
+ expect(rules).not.toContain('control-size-literal')
1762
+ })
1763
+
1764
+ it('does NOT flag a size prop on the HOME itself', () => {
1765
+ const { code, rules } = run(
1766
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <PageBar w={200} actions={<Button>Go</Button>} />\n`,
1767
+ )
1768
+ expect(code).toBe(0)
1769
+ expect(rules).not.toContain('control-size-literal')
1770
+ })
1771
+
1772
+ it("does NOT flag a size prop in a Section's CHILDREN", () => {
1773
+ const { code, rules } = run(
1774
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <Section title="T">\n <Button size="xs">Go</Button>\n </Section>\n)\n`,
1775
+ )
1776
+ expect(code).toBe(0)
1777
+ expect(rules).not.toContain('control-size-literal')
1778
+ })
1779
+
1780
+ // The rule only reaches what the slot's own MantineThemeProvider re-tiers. An icon's `size` IS
1781
+ // its only sizing API and the slot theme cannot touch an SVG, so "drop the prop" produced a 24px
1782
+ // icon — on the shape the spec's own reference example writes.
1783
+ it("does NOT flag an icon's size inside a slot — the slot theme cannot size an SVG", () => {
1784
+ const { code, rules } = run(
1785
+ `${BASALT_IMPORT}${MANTINE_IMPORT}import { IconDownload } from '@tabler/icons-react'\n` +
1786
+ `export const C = () => (\n <PageBar actions={<Button leftSection={<IconDownload size={14} />}>Export</Button>} />\n)\n`,
1787
+ )
1788
+ expect(code).toBe(0)
1789
+ expect(rules).not.toContain('control-size-literal')
1790
+ })
1791
+
1792
+ it('does NOT flag a Badge or Loader size in a Section actions slot', () => {
1793
+ const { code, rules } = run(
1794
+ `${BASALT_IMPORT}import { Badge, Loader } from '@mantine/core'\n` +
1795
+ `export const C = ({ n, busy }) => (\n <Section title="T" actions={[<Badge key="c" size="sm">{n}</Badge>, busy ? <Loader key="l" size={14} /> : null]} />\n)\n`,
1796
+ )
1797
+ expect(code).toBe(0)
1798
+ expect(rules).not.toContain('control-size-literal')
1799
+ })
1800
+
1801
+ it("does NOT flag a Modal's own size, or an input's size inside that Modal", () => {
1802
+ const { code, rules } = run(
1803
+ `${BASALT_IMPORT}import { Button, Modal, TextInput } from '@mantine/core'\n` +
1804
+ `export const C = () => (\n <WidgetHeader\n actions={\n <>\n <Button>New</Button>\n <Modal opened size="lg">\n <TextInput size="md" />\n </Modal>\n </>\n }\n />\n)\n`,
1805
+ )
1806
+ expect(code).toBe(0)
1807
+ expect(rules).not.toContain('control-size-literal')
1808
+ })
1809
+
1810
+ it('STILL flags a raw filter size in the slot beside those exemptions', () => {
1811
+ const { rules } = run(
1812
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <PageBar filters={<Select size="xs" data={[]} />} />\n)\n`,
1813
+ )
1814
+ expect(rules).toContain('control-size-literal')
1815
+ })
1816
+
1817
+ it('flags an ALIASED Mantine Button size in a slot', () => {
1818
+ const { rules } = run(
1819
+ `${BASALT_IMPORT}import { Button as Btn } from '@mantine/core'\n` +
1820
+ `export const C = () => <PageBar actions={<Btn size="xs">Go</Btn>} />\n`,
1821
+ )
1822
+ expect(rules).toContain('control-size-literal')
1823
+ })
1824
+
1825
+ it("does NOT flag a size prop in a slot on a consumer's own Section", () => {
1826
+ const { code, rules } = run(
1827
+ `import { Section } from './layout/section'\n${MANTINE_IMPORT}` +
1828
+ `export const C = () => <Section actions={<Button size="sm">Go</Button>} />\n`,
1829
+ )
1830
+ expect(code).toBe(0)
1831
+ expect(rules).not.toContain('control-size-literal')
1832
+ })
1833
+
1834
+ it('honors a scoped theme-allow', () => {
1835
+ const { rules } = run(
1836
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <PageBar\n actions={\n <Button\n // theme-allow control-size-literal — a deliberate 24px tag\n size="xs"\n >\n Go\n </Button>\n }\n />\n)\n`,
1837
+ )
1838
+ expect(rules).not.toContain('control-size-literal')
1839
+ })
1840
+ })
1841
+
1842
+ // `control-size-literal`'s scope is a copy of the theme's own re-tiering list, because a plain-JS
1843
+ // plugin shipped to consumers cannot import Mantine-coupled TS. This is the pin that makes the copy
1844
+ // safe: a component added to (or dropped from) CTL_THEME without the rule following is a rule
1845
+ // telling someone to drop a prop nothing else sets — or silently ignoring one that matters.
1846
+ describe('CTL_THEME_TAGS ↔ CTL_THEME', () => {
1847
+ it('is exactly the component map the slot theme re-tiers', () => {
1848
+ expect([...(CTL_THEME_TAGS as Set<string>)].toSorted()).toEqual(
1849
+ Object.keys(CTL_THEME.components ?? {}).toSorted(),
1850
+ )
1851
+ })
1852
+ })
1853
+
1854
+ describe('basalt/page-bar-budget', () => {
1855
+ it('flags two PageBars in the SAME returned tree — the pair that mounts together', () => {
1856
+ const { code, rules } = run(
1857
+ `${BASALT_IMPORT}export const C = () => (\n <>\n <PageBar title="A" />\n <PageBar title="B" />\n </>\n)\n`,
1858
+ )
1859
+ expect(code).toBe(1)
1860
+ expect(rules).toContain('page-bar-budget')
1861
+ })
1862
+
1863
+ // The two false positives the per-FILE count produced. Neither renders two bars at once, so
1864
+ // nothing races for the portal node — and the only escape was a theme-allow on correct code.
1865
+ it('does NOT flag an early-return loading state and the loaded bar', () => {
1866
+ const { code, rules } = run(
1867
+ `${BASALT_IMPORT}export const C = ({ data }) => {\n if (!data) return <PageBar title="Jobs" />\n return <PageBar title="Jobs" actions={{ primary: { key: 'r', label: 'Run' } }} />\n}\n`,
1868
+ )
1869
+ expect(code).toBe(0)
1870
+ expect(rules).not.toContain('page-bar-budget')
1871
+ })
1872
+
1873
+ it('does NOT flag two page components exported from one file', () => {
1874
+ const { code, rules } = run(
1875
+ `${BASALT_IMPORT}export const ListPage = () => <PageBar title="Jobs" />\nexport const DetailPage = () => <PageBar title="Job" />\n`,
1876
+ )
1877
+ expect(code).toBe(0)
1878
+ expect(rules).not.toContain('page-bar-budget')
1879
+ })
1880
+
1881
+ it("does NOT flag a consumer's own PageBar-named component", () => {
1882
+ const { code, rules } = run(
1883
+ `import { PageBar } from './chrome/page-bar'\nexport const C = () => (\n <>\n <PageBar title="A" />\n <PageBar title="B" />\n </>\n)\n`,
1884
+ )
1885
+ expect(code).toBe(0)
1886
+ expect(rules).not.toContain('page-bar-budget')
1887
+ })
1888
+
1889
+ it('flags a 5-entry actions.secondary array', () => {
1890
+ const five = [1, 2, 3, 4, 5].map((n) => `{ key: '${n}', label: '${n}' }`).join(', ')
1891
+ const { code, rules } = run(
1892
+ `${BASALT_IMPORT}export const C = () => <PageBar actions={{ secondary: [${five}] }} />\n`,
1893
+ )
1894
+ expect(code).toBe(1)
1895
+ expect(rules).toContain('page-bar-budget')
1896
+ })
1897
+
1898
+ it('does NOT flag a 4-entry actions.secondary array', () => {
1899
+ const four = [1, 2, 3, 4].map((n) => `{ key: '${n}', label: '${n}' }`).join(', ')
1900
+ const { code, rules } = run(
1901
+ `${BASALT_IMPORT}export const C = () => <PageBar actions={{ secondary: [${four}] }} />\n`,
1902
+ )
1903
+ expect(code).toBe(0)
1904
+ expect(rules).not.toContain('page-bar-budget')
1905
+ })
1906
+
1907
+ it('flags a 4-entry Section actions array (budget 3)', () => {
1908
+ const four = [1, 2, 3, 4].map((n) => `{ key: '${n}', label: '${n}' }`).join(', ')
1909
+ const { rules } = run(
1910
+ `${BASALT_IMPORT}export const C = () => <Section actions={[${four}]} />\n`,
1911
+ )
1912
+ expect(rules).toContain('page-bar-budget')
1913
+ })
1914
+
1915
+ it('flags a second variant="filled" inside one slot', () => {
1916
+ const { rules } = run(
1917
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <PageBar actions={<><Button variant="filled">A</Button><Button variant="filled">B</Button></>} />\n)\n`,
1918
+ )
1919
+ expect(rules).toContain('page-bar-budget')
1920
+ })
1921
+
1922
+ it('does NOT flag one filled action beside a default one', () => {
1923
+ const { code, rules } = run(
1924
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <PageBar actions={<><Button variant="filled">A</Button><Button variant="default">B</Button></>} />\n)\n`,
1925
+ )
1926
+ expect(code).toBe(0)
1927
+ expect(rules).not.toContain('page-bar-budget')
1928
+ })
1929
+
1930
+ // The `filled` count is a PRIMARY-ACTION count: a filled Badge is Mantine's default Badge
1931
+ // variant and the ordinary way a header states a count (law C11 asks for one), and an overlay's
1932
+ // submit button is not in the bar at all.
1933
+ it('does NOT flag a filled Badge beside the one filled Button', () => {
1934
+ const { code, rules } = run(
1935
+ `${BASALT_IMPORT}import { Badge, Button } from '@mantine/core'\n` +
1936
+ `export const C = () => (\n <Section title="T" actions={<><Button variant="filled">Run</Button><Badge variant="filled">3</Badge></>} />\n)\n`,
1937
+ )
1938
+ expect(code).toBe(0)
1939
+ expect(rules).not.toContain('page-bar-budget')
1940
+ })
1941
+
1942
+ it('does NOT flag a filled ThemeIcon beside the one filled Button', () => {
1943
+ const { rules } = run(
1944
+ `${BASALT_IMPORT}import { Button, ThemeIcon } from '@mantine/core'\n` +
1945
+ `export const C = () => (\n <Section title="T" actions={<><Button variant="filled">Run</Button><ThemeIcon variant="filled" /></>} />\n)\n`,
1946
+ )
1947
+ expect(rules).not.toContain('page-bar-budget')
1948
+ })
1949
+
1950
+ it("does NOT flag a Modal's own filled submit button colocated in the slot", () => {
1951
+ const { rules } = run(
1952
+ `${BASALT_IMPORT}import { Button, Modal } from '@mantine/core'\n` +
1953
+ `export const C = () => (\n <PageBar actions={<><Button variant="filled">New</Button><Modal opened><Button variant="filled">Save</Button></Modal></>} />\n)\n`,
1954
+ )
1955
+ expect(rules).not.toContain('page-bar-budget')
1956
+ })
1957
+
1958
+ it('honors a scoped theme-allow', () => {
1959
+ const { rules } = run(
1960
+ `${BASALT_IMPORT}export const C = () => (\n <>\n <PageBar title="A" />\n {/* theme-allow page-bar-budget — the print-only variant */}\n <PageBar title="B" />\n </>\n)\n`,
1961
+ )
1962
+ expect(rules).not.toContain('page-bar-budget')
1963
+ })
1964
+ })
1965
+
1966
+ describe('basalt/in-body-page-title', () => {
1967
+ it.each([1, 2])('flags <Title order={%i}> in the body', (order) => {
1968
+ const { code, rules } = run(`export const C = () => <Title order={${order}}>Page</Title>\n`)
1969
+ expect(code).toBe(1)
1970
+ expect(rules).toContain('in-body-page-title')
1971
+ })
1972
+
1973
+ it('does NOT flag order={3} — a section heading', () => {
1974
+ const { code, rules } = run(`export const C = () => <Title order={3}>Section</Title>\n`)
1975
+ expect(code).toBe(0)
1976
+ expect(rules).not.toContain('in-body-page-title')
1977
+ })
1978
+
1979
+ it.each(['Prose', 'ArticleLayout', 'Modal', 'Drawer'])(
1980
+ 'does NOT flag one under %s — that is a document heading',
1981
+ (host) => {
1982
+ const { rules } = run(
1983
+ `export const C = () => <${host}><Title order={1}>Doc</Title></${host}>\n`,
1984
+ )
1985
+ expect(rules).not.toContain('in-body-page-title')
1986
+ },
1987
+ )
1988
+
1989
+ it('does NOT flag one under a content/ path segment', () => {
1990
+ const { code, rules } = run(
1991
+ `export const C = () => <Title order={1}>Doc</Title>\n`,
1992
+ 'content/page.tsx',
1993
+ )
1994
+ expect(code).toBe(0)
1995
+ expect(rules).not.toContain('in-body-page-title')
1996
+ })
1997
+
1998
+ it('honors a scoped theme-allow', () => {
1999
+ const { rules } = run(
2000
+ `// theme-allow in-body-page-title — a shell-less print view\nexport const C = () => <Title order={1}>Page</Title>\n`,
2001
+ )
2002
+ expect(rules).not.toContain('in-body-page-title')
2003
+ })
2004
+ })
2005
+
2006
+ describe('basalt/responsive-twin', () => {
2007
+ it('flags the same control mounted under visibleFrom and hiddenFrom', () => {
2008
+ const { code, rules } = run(
2009
+ `export const C = () => (\n <div>\n <SegmentedControl visibleFrom="sm" data={[]} />\n <SegmentedControl hiddenFrom="sm" data={[]} />\n </div>\n)\n`,
2010
+ )
2011
+ expect(code).toBe(1)
2012
+ expect(rules).toContain('responsive-twin')
2013
+ })
2014
+
2015
+ it('flags it when the control sits inside each half rather than being it', () => {
2016
+ const { rules } = run(
2017
+ `export const C = () => (\n <div>\n <Group visibleFrom="sm"><ViewTabs field={f} /></Group>\n <Stack hiddenFrom="sm"><ViewTabs field={f} /></Stack>\n </div>\n)\n`,
2018
+ )
2019
+ expect(rules).toContain('responsive-twin')
2020
+ })
2021
+
2022
+ it('does NOT flag two halves on DIFFERENT breakpoints', () => {
2023
+ const { code, rules } = run(
2024
+ `export const C = () => (\n <div>\n <SegmentedControl visibleFrom="sm" data={[]} />\n <SegmentedControl hiddenFrom="md" data={[]} />\n </div>\n)\n`,
2025
+ )
2026
+ expect(code).toBe(0)
2027
+ expect(rules).not.toContain('responsive-twin')
2028
+ })
2029
+
2030
+ it('does NOT flag a pair holding DIFFERENT controls', () => {
2031
+ const { rules } = run(
2032
+ `export const C = () => (\n <div>\n <SegmentedControl visibleFrom="sm" data={[]} />\n <Select hiddenFrom="sm" data={[]} />\n </div>\n)\n`,
2033
+ )
2034
+ expect(rules).not.toContain('responsive-twin')
2035
+ })
2036
+
2037
+ // Same fixture caveat as control-outside-home's owner test: the local `ViewTabs` name is itself a
2038
+ // `shadow-basalt-export` finding, so only this rule's silence is asserted.
2039
+ it('does NOT flag the module that DEFINES a basalt control — the CSS swap lives there', () => {
2040
+ const { rules } = run(
2041
+ `export function ViewTabs() {\n return (\n <div>\n <SegmentedControl visibleFrom="sm" data={[]} />\n <SegmentedControl hiddenFrom="sm" data={[]} />\n </div>\n )\n}\n`,
2042
+ )
2043
+ expect(rules).not.toContain('responsive-twin')
2044
+ })
2045
+
2046
+ it('honors a scoped theme-allow', () => {
2047
+ const { rules } = run(
2048
+ `export const C = () => (\n <div>\n <SegmentedControl visibleFrom="sm" data={[]} />\n {/* theme-allow responsive-twin — two genuinely different data sets */}\n <SegmentedControl hiddenFrom="sm" data={[]} />\n </div>\n)\n`,
2049
+ )
2050
+ expect(rules).not.toContain('responsive-twin')
2051
+ })
2052
+ })
2053
+
2054
+ describe('basalt/search-literal-link', () => {
2055
+ const NAV = (search: string) =>
2056
+ `export const nav = defineNav({\n sections: [\n { id: 'a', label: 'A', link: linkOptions({ to: '/dashboard'${search} }) },\n ],\n})\n`
2057
+
2058
+ it('flags a `search` object literal inside defineNav', () => {
2059
+ const { code, rules } = run(NAV(", search: { window: '30d' }"), 'nav.ts')
2060
+ expect(code).toBe(1)
2061
+ expect(rules).toContain('search-literal-link')
2062
+ })
2063
+
2064
+ it('flags it inside navGroup too', () => {
2065
+ const { rules } = run(
2066
+ `export const g = navGroup({ id: 'g', label: 'G' }, [\n { id: 'x', label: 'X', link: linkOptions({ to: '/d', search: { window: '30d' } }) },\n])\n`,
2067
+ 'nav.ts',
2068
+ )
2069
+ expect(rules).toContain('search-literal-link')
2070
+ })
2071
+
2072
+ it('does NOT flag `search: store.linkSearch` passed by reference', () => {
2073
+ const { code, rules } = run(NAV(', search: dashboardFilters.linkSearch'), 'nav.ts')
2074
+ expect(code).toBe(0)
2075
+ expect(rules).not.toContain('search-literal-link')
2076
+ })
2077
+
2078
+ it('does NOT flag a linkOptions literal OUTSIDE a nav definition', () => {
2079
+ const { code, rules } = run(
2080
+ `export const target = linkOptions({ to: '/d', search: { window: '30d' } })\n`,
2081
+ 'nav.ts',
2082
+ )
2083
+ expect(code).toBe(0)
2084
+ expect(rules).not.toContain('search-literal-link')
2085
+ })
2086
+
2087
+ it('honors a scoped theme-allow', () => {
2088
+ const { rules } = run(
2089
+ `export const nav = defineNav({\n sections: [\n {\n id: 'a',\n label: 'A',\n link: linkOptions({\n to: '/d',\n // theme-allow search-literal-link — a fixed report link, deliberately pinned\n search: { window: '30d' },\n }),\n },\n ],\n})\n`,
2090
+ 'nav.ts',
2091
+ )
2092
+ expect(rules).not.toContain('search-literal-link')
2093
+ })
2094
+ })
2095
+
2096
+ describe('basalt/use-search-from-literal', () => {
2097
+ it('flags useSearch({ from: <literal> })', () => {
2098
+ const { code, rules } = run(
2099
+ `export const useRange = () => useSearch({ from: '/dashboard' })\n`,
2100
+ 'hooks.ts',
2101
+ )
2102
+ expect(code).toBe(1)
2103
+ expect(rules).toContain('use-search-from-literal')
2104
+ })
2105
+
2106
+ it('does NOT flag useSearch({ strict: false })', () => {
2107
+ const { code, rules } = run(
2108
+ `export const useRange = () => useSearch({ strict: false })\n`,
2109
+ 'hooks.ts',
2110
+ )
2111
+ expect(code).toBe(0)
2112
+ expect(rules).not.toContain('use-search-from-literal')
2113
+ })
2114
+
2115
+ it('honors a scoped theme-allow', () => {
2116
+ const { rules } = run(
2117
+ `export const useRange = () =>\n useSearch({\n // theme-allow use-search-from-literal — a route-owned loader, never rendered elsewhere\n from: '/dashboard',\n })\n`,
2118
+ 'hooks.ts',
2119
+ )
2120
+ expect(rules).not.toContain('use-search-from-literal')
2121
+ })
2122
+ })
2123
+
2124
+ // ── raw-scroll-container — the C7 widening ───────────────────────────────────
2125
+
2126
+ describe('basalt/raw-scroll-container — the home widening (C7)', () => {
2127
+ it('flags overflowX inside a home slot', () => {
2128
+ const { rules } = run(
2129
+ `${BASALT_IMPORT}export const C = () => <PageBar filters={<div style={{ overflowX: 'auto' }} />} />\n`,
2130
+ )
2131
+ expect(rules).toContain('raw-scroll-container')
2132
+ })
2133
+
2134
+ // The C7 widening is about a sideways-scrolling row of CONTROLS, which only ever lives in a slot.
2135
+ // Counting the body made every wide table and every horizontally scrolling code block on a page
2136
+ // an `error` carrying that message — the exact pattern this rule's own comment calls legitimate.
2137
+ it('does NOT flag overflowX in a Section BODY — page content, not a control row', () => {
2138
+ const { code, rules } = run(
2139
+ `${BASALT_IMPORT}export const C = () => (\n <Section title="Raw rows">\n <div style={{ overflowX: 'scroll' }}>\n <table />\n </div>\n </Section>\n)\n`,
2140
+ )
2141
+ expect(code).toBe(0)
2142
+ expect(rules).not.toContain('raw-scroll-container')
2143
+ })
2144
+
2145
+ it('does NOT flag <ScrollArea scrollbars="x"> around a wide table in a ChartCard body', () => {
2146
+ const { rules } = run(
2147
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <ChartCard title="T">\n <ScrollArea scrollbars="x">\n <table />\n </ScrollArea>\n </ChartCard>\n)\n`,
2148
+ )
2149
+ expect(rules).not.toContain('raw-scroll-container')
2150
+ })
2151
+
2152
+ it("does NOT flag a slot on a consumer's own Section — not a basalt home", () => {
2153
+ const { code, rules } = run(
2154
+ `import { Section } from './layout/section'\n${MANTINE_IMPORT}` +
2155
+ `export const C = () => <Section actions={<ScrollArea scrollbars="x" />} />\n`,
2156
+ )
2157
+ expect(code).toBe(0)
2158
+ expect(rules).not.toContain('raw-scroll-container')
2159
+ })
2160
+
2161
+ it("does NOT flag a consumer's own ScrollArea in a basalt slot", () => {
2162
+ const { rules } = run(
2163
+ `${BASALT_IMPORT}import { ScrollArea } from './ui/scroll-area'\n` +
2164
+ `export const C = () => <PageBar filters={<ScrollArea scrollbars="x" />} />\n`,
2165
+ )
2166
+ expect(rules).not.toContain('raw-scroll-container')
2167
+ })
2168
+
2169
+ it('flags <ScrollArea scrollbars="x"> inside a home slot', () => {
2170
+ const { rules } = run(
2171
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <PageBar filters={<ScrollArea scrollbars="x" />} />\n`,
2172
+ )
2173
+ expect(rules).toContain('raw-scroll-container')
2174
+ })
2175
+
2176
+ // The original doctrine, unchanged: a horizontal bar outside a home reserves no gutter width, so
2177
+ // a code block or a pinned-column table stays its own legitimate pattern.
2178
+ it('does NOT flag overflowX outside a home', () => {
2179
+ const { code, rules } = run(`const s = { overflowX: 'auto' }\n`, 'lib.ts')
2180
+ expect(code).toBe(0)
2181
+ expect(rules).not.toContain('raw-scroll-container')
2182
+ })
2183
+
2184
+ it('does NOT flag <ScrollArea scrollbars="x"> outside a home', () => {
2185
+ const { code, rules } = run(
2186
+ `${MANTINE_IMPORT}export const C = () => <ScrollArea scrollbars="x" />\n`,
2187
+ )
2188
+ expect(code).toBe(0)
2189
+ expect(rules).not.toContain('raw-scroll-container')
2190
+ })
2191
+
2192
+ it('honors a scoped theme-allow on the widened half', () => {
2193
+ const { rules } = run(
2194
+ `${BASALT_IMPORT}export const C = () => (\n <Section\n title="T"\n actions={\n // theme-allow raw-scroll-container — a pinned-column table owns its own scroll node\n <div style={{ overflowX: 'auto' }} />\n }\n />\n)\n`,
2195
+ )
2196
+ expect(rules).not.toContain('raw-scroll-container')
2197
+ })
2198
+ })
2199
+
2200
+ // ── shadow-basalt-export — the rename table ──────────────────────────────────
2201
+
2202
+ describe('basalt/shadow-basalt-export — SHADOW_ALIASES', () => {
2203
+ it.each([
2204
+ ['PageHeader', 'PageBar'],
2205
+ ['WindowSelector', 'RangeFilter'],
2206
+ ['SectionTitle', 'Section'],
2207
+ ['RefreshButton', 'SyncButton'],
2208
+ ['HeroCard', 'StatCard'],
2209
+ ])('flags a local %s and names %s as what it forks', (local, canonical) => {
2210
+ const { code, rules, output } = run(`export function ${local}() {\n return null\n}\n`)
2211
+ expect(code).toBe(1)
2212
+ expect(rules).toContain('shadow-basalt-export')
2213
+ expect(output).toContain(canonical)
2214
+ })
2215
+
2216
+ it('flags the const form too', () => {
2217
+ const { rules } = run(`export const FilterBar = () => null\n`)
2218
+ expect(rules).toContain('shadow-basalt-export')
2219
+ })
2220
+
2221
+ it('does NOT flag a name that is neither an export nor an alias', () => {
2222
+ const { code, rules } = run(`export const RangePickerish = () => null\n`)
2223
+ expect(code).toBe(0)
2224
+ expect(rules).not.toContain('shadow-basalt-export')
2225
+ })
2226
+
2227
+ it('is waivable with a scoped theme-allow', () => {
2228
+ const { rules } = run(
2229
+ `// theme-allow shadow-basalt-export — a marketing hero, unrelated to StatCard\nexport const HeroCard = () => null\n`,
2230
+ )
2231
+ expect(rules).not.toContain('shadow-basalt-export')
2232
+ })
2233
+ })
2234
+
2235
+ // ── SURFACES ↔ the plugin registry ───────────────────────────────────────────
2236
+
2237
+ describe('PLUGIN_RULE_ID_LIST ↔ the plugin', () => {
2238
+ // surfaces.ts cannot import the plugin (it is dependency-free by contract, and the plugin is a
2239
+ // standalone .js loaded out of a consumer's node_modules), so the list is hand-maintained and
2240
+ // THIS is the gate that keeps it honest.
2241
+ it('is exactly the plugin s registered rule ids', () => {
2242
+ expect([...PLUGIN_RULE_ID_LIST].toSorted()).toEqual(Object.keys(basaltPlugin.rules).toSorted())
2243
+ })
2244
+
2245
+ it('maps every plugin rule to exactly one surface', () => {
2246
+ const owners = new Map<string, string[]>()
2247
+ for (const [key, spec] of Object.entries(SURFACES)) {
2248
+ if (spec.kind !== 'doctrine') continue
2249
+ for (const id of spec.pluginRules) owners.set(id, [...(owners.get(id) ?? []), key])
2250
+ }
2251
+ for (const id of PLUGIN_RULE_ID_LIST) {
2252
+ expect([id, owners.get(id)?.length ?? 0]).toEqual([id, 1])
2253
+ }
2254
+ // …and no surface claims an id the plugin does not register (the type already forbids it; this
2255
+ // catches a list entry deleted without its surface).
2256
+ for (const id of owners.keys()) {
2257
+ expect([id, (PLUGIN_RULE_ID_LIST as readonly string[]).includes(id)]).toEqual([id, true])
2258
+ }
2259
+ })
2260
+
2261
+ it("puts the shell rule on the root barrel's surface (wave 6)", () => {
2262
+ expect(SURFACES['.'].pluginRules).toContain('hand-rolled-shell')
2263
+ })
2264
+
2265
+ it('gives ./controls the control-tier rules', () => {
2266
+ expect([...SURFACES['./controls'].pluginRules].toSorted()).toEqual([
2267
+ 'control-outside-home',
2268
+ 'control-size-literal',
2269
+ 'hand-rolled-filter',
2270
+ 'responsive-twin',
2271
+ ])
2272
+ })
2273
+ })