basalt-ui 1.25.0 → 1.27.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 (476) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +26 -0
  3. package/MIGRATING.md +815 -23
  4. package/README.md +45 -32
  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 +160 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +118 -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 +1465 -36
  17. package/configs/oxlint-plugin.test.ts +1190 -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 +39 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +93 -0
  75. package/dist/controls/filter-context.d.ts +60 -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 +20 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +46 -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/number-filter.d.ts +26 -0
  99. package/dist/controls/number-filter.d.ts.map +1 -0
  100. package/dist/controls/number-filter.js +143 -0
  101. package/dist/controls/peer-boundary.test.d.ts +2 -0
  102. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  103. package/dist/controls/props-contract.test.d.ts +2 -0
  104. package/dist/controls/props-contract.test.d.ts.map +1 -0
  105. package/dist/controls/range-filter.d.ts +43 -0
  106. package/dist/controls/range-filter.d.ts.map +1 -0
  107. package/dist/controls/range-filter.js +135 -0
  108. package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
  109. package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
  110. package/dist/controls/search-filter.d.ts +10 -0
  111. package/dist/controls/search-filter.d.ts.map +1 -0
  112. package/dist/controls/search-filter.js +81 -0
  113. package/dist/controls/select-filter.d.ts +74 -0
  114. package/dist/controls/select-filter.d.ts.map +1 -0
  115. package/dist/controls/select-filter.js +10 -0
  116. package/dist/controls/sync-button.d.ts +38 -0
  117. package/dist/controls/sync-button.d.ts.map +1 -0
  118. package/dist/controls/sync-button.js +119 -0
  119. package/dist/controls/sync-button.module.css +63 -0
  120. package/dist/controls/sync-button.module.css.d.ts +11 -0
  121. package/dist/controls/sync-button.test.d.ts +2 -0
  122. package/dist/controls/sync-button.test.d.ts.map +1 -0
  123. package/dist/controls/toggle-filter.d.ts +9 -0
  124. package/dist/controls/toggle-filter.d.ts.map +1 -0
  125. package/dist/controls/toggle-filter.js +42 -0
  126. package/dist/controls/view-tabs.d.ts +23 -0
  127. package/dist/controls/view-tabs.d.ts.map +1 -0
  128. package/dist/controls/view-tabs.js +59 -0
  129. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  130. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  131. package/dist/controls-dates/date-range-picker.js +18 -0
  132. package/dist/controls-dates/index.d.ts +9 -0
  133. package/dist/controls-dates/index.d.ts.map +1 -0
  134. package/dist/controls-dates/index.js +4 -0
  135. package/dist/dashboard/delta-badge.d.ts +6 -12
  136. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  137. package/dist/dashboard/delta-badge.js +1 -50
  138. package/dist/dashboard/index.d.ts +1 -1
  139. package/dist/dashboard/index.d.ts.map +1 -1
  140. package/dist/dashboard/index.js +3 -1
  141. package/dist/dashboard/settings-section.d.ts +6 -4
  142. package/dist/dashboard/settings-section.d.ts.map +1 -1
  143. package/dist/dashboard/settings-section.js +23 -16
  144. package/dist/dashboard/settings-section.module.css +10 -0
  145. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  146. package/dist/dashboard/settings-section.test.d.ts +2 -0
  147. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  148. package/dist/dashboard/stat-card.d.ts +95 -9
  149. package/dist/dashboard/stat-card.d.ts.map +1 -1
  150. package/dist/dashboard/stat-card.js +108 -49
  151. package/dist/dashboard/stat-card.module.css +153 -0
  152. package/dist/dashboard/stat-card.module.css.d.ts +15 -0
  153. package/dist/data/data-table.d.ts +37 -15
  154. package/dist/data/data-table.d.ts.map +1 -1
  155. package/dist/data/data-table.js +96 -58
  156. package/dist/guard/grace.test.d.ts +20 -0
  157. package/dist/guard/grace.test.d.ts.map +1 -0
  158. package/dist/guard/index.d.ts +66 -3
  159. package/dist/guard/index.d.ts.map +1 -1
  160. package/dist/guard/index.js +78 -7
  161. package/dist/guard/types.d.ts +3 -3
  162. package/dist/guard/types.d.ts.map +1 -1
  163. package/dist/index.d.ts +13 -3
  164. package/dist/index.d.ts.map +1 -1
  165. package/dist/index.js +13 -10
  166. package/dist/nav/types.d.ts +78 -2
  167. package/dist/nav/types.d.ts.map +1 -1
  168. package/dist/notifications/bell.d.ts +4 -1
  169. package/dist/notifications/bell.d.ts.map +1 -1
  170. package/dist/notifications/index.d.ts +1 -1
  171. package/dist/router-tanstack/field.d.ts +14 -0
  172. package/dist/router-tanstack/field.d.ts.map +1 -0
  173. package/dist/router-tanstack/field.js +4 -0
  174. package/dist/router-tanstack/index.d.ts +6 -2
  175. package/dist/router-tanstack/index.d.ts.map +1 -1
  176. package/dist/router-tanstack/index.js +4 -0
  177. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  178. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  179. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  180. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  181. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  182. package/dist/router-tanstack/search-param-store.js +16 -45
  183. package/dist/router-tanstack/search-store.d.ts +84 -0
  184. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  185. package/dist/router-tanstack/search-store.js +271 -0
  186. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  187. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  188. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  189. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  190. package/dist/router-tanstack/use-nav.d.ts +0 -2
  191. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  192. package/dist/router-tanstack/use-nav.js +34 -12
  193. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  194. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  195. package/dist/section/index.d.ts +2 -0
  196. package/dist/section/index.d.ts.map +1 -0
  197. package/dist/section/index.js +4 -0
  198. package/dist/section/section.d.ts +24 -0
  199. package/dist/section/section.d.ts.map +1 -0
  200. package/dist/section/section.js +126 -0
  201. package/dist/section/section.module.css +104 -0
  202. package/dist/section/section.module.css.d.ts +11 -0
  203. package/dist/section/section.test.d.ts +2 -0
  204. package/dist/section/section.test.d.ts.map +1 -0
  205. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  206. package/dist/shell/app-breadcrumbs.js +18 -15
  207. package/dist/shell/app-header.module.css +77 -37
  208. package/dist/shell/app-header.module.css.d.ts +2 -1
  209. package/dist/shell/app-mobile-nav.d.ts +9 -2
  210. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  211. package/dist/shell/app-mobile-nav.js +98 -11
  212. package/dist/shell/app-mobile-nav.module.css +8 -0
  213. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  214. package/dist/shell/app-sidebar.d.ts +55 -28
  215. package/dist/shell/app-sidebar.d.ts.map +1 -1
  216. package/dist/shell/app-sidebar.js +116 -67
  217. package/dist/shell/app-sidebar.module.css +303 -20
  218. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  219. package/dist/shell/index.d.ts +55 -21
  220. package/dist/shell/index.d.ts.map +1 -1
  221. package/dist/shell/index.js +40 -18
  222. package/dist/shell/mobile-nav-model.d.ts +13 -2
  223. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  224. package/dist/shell/mobile-nav-model.js +15 -1
  225. package/dist/shell/nav-count-badge.d.ts +1 -1
  226. package/dist/shell/page-bar.d.ts +51 -0
  227. package/dist/shell/page-bar.d.ts.map +1 -0
  228. package/dist/shell/page-bar.js +154 -0
  229. package/dist/shell/page-bar.module.css +178 -0
  230. package/dist/shell/page-bar.module.css.d.ts +17 -0
  231. package/dist/shell/page-bar.test.d.ts +2 -0
  232. package/dist/shell/page-bar.test.d.ts.map +1 -0
  233. package/dist/shell/sidebar-block-model.d.ts +48 -0
  234. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  235. package/dist/shell/sidebar-block-model.js +39 -0
  236. package/dist/shell/sidebar-blocks.d.ts +32 -0
  237. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  238. package/dist/shell/sidebar-blocks.js +169 -0
  239. package/dist/shell/sidebar-search.d.ts +13 -1
  240. package/dist/shell/sidebar-search.d.ts.map +1 -1
  241. package/dist/shell/sidebar-search.js +34 -1
  242. package/dist/shell/sidebar-search.module.css +26 -0
  243. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  244. package/dist/state/fields.d.ts +565 -0
  245. package/dist/state/fields.d.ts.map +1 -0
  246. package/dist/state/fields.js +405 -0
  247. package/dist/state/fields.test.d.ts +2 -0
  248. package/dist/state/fields.test.d.ts.map +1 -0
  249. package/dist/state/persisted.d.ts +60 -0
  250. package/dist/state/persisted.d.ts.map +1 -0
  251. package/dist/state/persisted.js +123 -0
  252. package/dist/state/persisted.test.d.ts +2 -0
  253. package/dist/state/persisted.test.d.ts.map +1 -0
  254. package/dist/state.d.ts +11 -56
  255. package/dist/state.d.ts.map +1 -1
  256. package/dist/state.js +14 -120
  257. package/dist/styles.css +13 -0
  258. package/dist/surfaces.d.ts +106 -24
  259. package/dist/surfaces.d.ts.map +1 -1
  260. package/dist/surfaces.js +156 -19
  261. package/dist/theme/controls.module.css +16 -1
  262. package/dist/theme/ctl-theme.d.ts +32 -0
  263. package/dist/theme/ctl-theme.d.ts.map +1 -0
  264. package/dist/theme/ctl-theme.js +54 -0
  265. package/dist/theme/ctl-theme.test.d.ts +2 -0
  266. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  267. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  268. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  269. package/dist/theme/divider-law.test.d.ts +2 -0
  270. package/dist/theme/divider-law.test.d.ts.map +1 -0
  271. package/dist/theme/icon-slot.d.ts +44 -0
  272. package/dist/theme/icon-slot.d.ts.map +1 -0
  273. package/dist/theme/icon-slot.js +21 -0
  274. package/dist/theme/icon-slot.module.css +47 -0
  275. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  276. package/dist/theme/icon-slot.test.d.ts +2 -0
  277. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  278. package/dist/theme/index.d.ts +1 -0
  279. package/dist/theme/index.d.ts.map +1 -1
  280. package/dist/theme/index.js +82 -5
  281. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  282. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  283. package/dist/theme/segmented-control.module.css +20 -0
  284. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  285. package/dist/tokens/index.d.ts +12 -0
  286. package/dist/tokens/index.d.ts.map +1 -1
  287. package/dist/tokens/index.js +38 -8
  288. package/dist/tokens/palette.d.ts +97 -61
  289. package/dist/tokens/palette.d.ts.map +1 -1
  290. package/dist/tokens/palette.js +76 -38
  291. package/dist/tokens.css +10 -2
  292. package/dist/widget-header/delta-badge.d.ts +13 -0
  293. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  294. package/dist/widget-header/delta-badge.js +29 -0
  295. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  296. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  297. package/dist/widget-header/index.d.ts +3 -0
  298. package/dist/widget-header/index.d.ts.map +1 -0
  299. package/dist/widget-header/index.js +6 -0
  300. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  301. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  302. package/dist/widget-header/widget-header.d.ts +67 -0
  303. package/dist/widget-header/widget-header.d.ts.map +1 -0
  304. package/dist/widget-header/widget-header.js +89 -0
  305. package/dist/widget-header/widget-header.module.css +350 -0
  306. package/dist/widget-header/widget-header.module.css.d.ts +25 -0
  307. package/dist/widget-header/widget-header.test.d.ts +2 -0
  308. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  309. package/llms.txt +30 -17
  310. package/package.json +14 -1
  311. package/src/charts/cursor/resolve.test.ts +29 -1
  312. package/src/charts/cursor/resolve.ts +38 -6
  313. package/src/charts/cursor/scope.tsx +6 -3
  314. package/src/charts/cursor/store.test.ts +19 -1
  315. package/src/charts/cursor/store.ts +10 -5
  316. package/src/charts/hooks/useChartCursor.ts +16 -8
  317. package/src/charts/index.ts +2 -2
  318. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  319. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  320. package/src/charts/primitives/ChartCard.tsx +86 -146
  321. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  322. package/src/cli/check-coverage.test.ts +297 -0
  323. package/src/cli/check-theme.test.ts +38 -14
  324. package/src/cli/index.ts +392 -16
  325. package/src/cli/init-sync.test.ts +97 -0
  326. package/src/connectivity/connectivity-indicator.tsx +16 -9
  327. package/src/content/index.ts +16 -3
  328. package/src/content/prose.module.css +7 -28
  329. package/src/content/prose.module.css.test.ts +8 -24
  330. package/src/controls/actions.module.css +62 -0
  331. package/src/controls/actions.module.css.d.ts +13 -0
  332. package/src/controls/actions.test.tsx +252 -0
  333. package/src/controls/actions.tsx +655 -0
  334. package/src/controls/actions.type-guard.test.ts +63 -0
  335. package/src/controls/compare-filter.tsx +64 -0
  336. package/src/controls/control-group.test.tsx +229 -0
  337. package/src/controls/control-group.tsx +63 -0
  338. package/src/controls/controls.module.css +542 -0
  339. package/src/controls/controls.module.css.d.ts +35 -0
  340. package/src/controls/controls.router.test.tsx +1053 -0
  341. package/src/controls/enum-filter.tsx +170 -0
  342. package/src/controls/filter-context.tsx +158 -0
  343. package/src/controls/filter-pill.tsx +120 -0
  344. package/src/controls/filter-set.test.tsx +472 -0
  345. package/src/controls/filter-set.tsx +225 -0
  346. package/src/controls/filter-sheet.tsx +285 -0
  347. package/src/controls/glyphs.tsx +81 -0
  348. package/src/controls/index.ts +49 -0
  349. package/src/controls/multi-select-filter.tsx +140 -0
  350. package/src/controls/number-filter.tsx +291 -0
  351. package/src/controls/peer-boundary.test.ts +85 -0
  352. package/src/controls/props-contract.test.tsx +137 -0
  353. package/src/controls/range-filter.tsx +237 -0
  354. package/src/controls/range-filter.type-guard.test.tsx +122 -0
  355. package/src/controls/search-filter.tsx +123 -0
  356. package/src/controls/select-filter.tsx +89 -0
  357. package/src/controls/sync-button.module.css +63 -0
  358. package/src/controls/sync-button.module.css.d.ts +11 -0
  359. package/src/controls/sync-button.test.tsx +183 -0
  360. package/src/controls/sync-button.tsx +197 -0
  361. package/src/controls/toggle-filter.tsx +60 -0
  362. package/src/controls/view-tabs.tsx +110 -0
  363. package/src/controls-dates/date-range-picker.tsx +42 -0
  364. package/src/controls-dates/index.ts +8 -0
  365. package/src/dashboard/delta-badge.tsx +4 -74
  366. package/src/dashboard/index.ts +6 -1
  367. package/src/dashboard/settings-section.module.css +10 -0
  368. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  369. package/src/dashboard/settings-section.test.tsx +56 -0
  370. package/src/dashboard/settings-section.tsx +33 -30
  371. package/src/dashboard/stat-card.module.css +153 -0
  372. package/src/dashboard/stat-card.module.css.d.ts +15 -0
  373. package/src/dashboard/stat-card.test.tsx +298 -1
  374. package/src/dashboard/stat-card.tsx +289 -67
  375. package/src/data/data-table.test.tsx +96 -11
  376. package/src/data/data-table.tsx +228 -84
  377. package/src/guard/check-source.test.ts +159 -13
  378. package/src/guard/grace.test.ts +158 -0
  379. package/src/guard/index.ts +185 -29
  380. package/src/guard/types.ts +4 -2
  381. package/src/index.ts +51 -5
  382. package/src/nav/types.ts +84 -2
  383. package/src/notifications/bell.tsx +6 -3
  384. package/src/notifications/index.ts +1 -1
  385. package/src/router-tanstack/field.ts +35 -0
  386. package/src/router-tanstack/index.ts +28 -2
  387. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  388. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  389. package/src/router-tanstack/search-param-store.ts +42 -159
  390. package/src/router-tanstack/search-store.router.test.tsx +882 -0
  391. package/src/router-tanstack/search-store.test.ts +578 -0
  392. package/src/router-tanstack/search-store.ts +546 -0
  393. package/src/router-tanstack/use-nav.test.tsx +187 -0
  394. package/src/router-tanstack/use-nav.ts +72 -14
  395. package/src/section/index.ts +1 -0
  396. package/src/section/section.module.css +104 -0
  397. package/src/section/section.module.css.d.ts +11 -0
  398. package/src/section/section.test.tsx +204 -0
  399. package/src/section/section.tsx +220 -0
  400. package/src/shell/app-breadcrumbs.tsx +44 -31
  401. package/src/shell/app-header.module.css +77 -37
  402. package/src/shell/app-header.module.css.d.ts +2 -1
  403. package/src/shell/app-mobile-nav.module.css +8 -0
  404. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  405. package/src/shell/app-mobile-nav.tsx +147 -8
  406. package/src/shell/app-sidebar-account.tsx +2 -2
  407. package/src/shell/app-sidebar.module.css +303 -20
  408. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  409. package/src/shell/app-sidebar.test.tsx +456 -41
  410. package/src/shell/app-sidebar.tsx +276 -140
  411. package/src/shell/index.test.tsx +291 -5
  412. package/src/shell/index.tsx +137 -38
  413. package/src/shell/mobile-nav-model.test.ts +228 -7
  414. package/src/shell/mobile-nav-model.ts +53 -3
  415. package/src/shell/nav-count-badge.tsx +1 -1
  416. package/src/shell/page-bar.module.css +178 -0
  417. package/src/shell/page-bar.module.css.d.ts +17 -0
  418. package/src/shell/page-bar.test.tsx +316 -0
  419. package/src/shell/page-bar.tsx +315 -0
  420. package/src/shell/sidebar-block-model.ts +83 -0
  421. package/src/shell/sidebar-blocks.tsx +301 -0
  422. package/src/shell/sidebar-search.module.css +26 -0
  423. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  424. package/src/shell/sidebar-search.tsx +71 -2
  425. package/src/state/fields.test.tsx +512 -0
  426. package/src/state/fields.ts +1150 -0
  427. package/src/state/persisted.test.ts +91 -0
  428. package/src/state/persisted.ts +261 -0
  429. package/src/state.ts +52 -240
  430. package/src/styles.css +13 -0
  431. package/src/surfaces.ts +204 -36
  432. package/src/theme/controls.module.css +16 -1
  433. package/src/theme/ctl-theme.test.tsx +275 -0
  434. package/src/theme/ctl-theme.tsx +138 -0
  435. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  436. package/src/theme/density-relations.test.ts +106 -51
  437. package/src/theme/divider-law.test.ts +259 -0
  438. package/src/theme/icon-slot.module.css +47 -0
  439. package/src/theme/icon-slot.module.css.d.ts +8 -0
  440. package/src/theme/icon-slot.test.tsx +275 -0
  441. package/src/theme/icon-slot.tsx +59 -0
  442. package/src/theme/index.ts +165 -3
  443. package/src/theme/layout-rhythm-css.test.ts +72 -0
  444. package/src/theme/radius.test.ts +11 -1
  445. package/src/theme/segmented-control.module.css +20 -0
  446. package/src/theme/shadow-surfaces.test.ts +20 -7
  447. package/src/theme/spacing.test.ts +75 -27
  448. package/src/theme/use-basalt-spacing.ts +2 -2
  449. package/src/tokens/density.test.ts +7 -25
  450. package/src/tokens/index.ts +48 -8
  451. package/src/tokens/palette.ts +107 -73
  452. package/src/widget-header/delta-badge.test.tsx +60 -0
  453. package/src/widget-header/delta-badge.tsx +57 -0
  454. package/src/widget-header/index.ts +2 -0
  455. package/src/widget-header/no-mantine.test.ts +33 -0
  456. package/src/widget-header/widget-header.module.css +350 -0
  457. package/src/widget-header/widget-header.module.css.d.ts +25 -0
  458. package/src/widget-header/widget-header.test.tsx +187 -0
  459. package/src/widget-header/widget-header.tsx +213 -0
  460. package/agent/rules/basalt-agent.md +0 -452
  461. package/agent/rules/basalt-app.md +0 -246
  462. package/agent/rules/basalt-commands.md +0 -320
  463. package/agent/rules/basalt-content.md +0 -409
  464. package/agent/rules/basalt-data.md +0 -329
  465. package/agent/rules/basalt-forms.md +0 -186
  466. package/agent/rules/basalt-notifications.md +0 -264
  467. package/agent/rules/basalt-query.md +0 -187
  468. package/agent/rules/basalt-router.md +0 -336
  469. package/dist/content/article-filter-bar.d.ts +0 -40
  470. package/dist/content/article-filter-bar.d.ts.map +0 -1
  471. package/dist/content/article-filter-bar.js +0 -56
  472. package/dist/shell/page-header.d.ts +0 -13
  473. package/dist/shell/page-header.d.ts.map +0 -1
  474. package/dist/shell/page-header.js +0 -23
  475. package/src/content/article-filter-bar.tsx +0 -88
  476. package/src/shell/page-header.tsx +0 -35
@@ -161,9 +161,9 @@ describe('Fix 4 — every interactive target clears the WCAG 2.5.8 24px floor at
161
161
  }
162
162
  })
163
163
 
164
- test('the mobile header actions row (appHeaderMobileActionsHeight)', () => {
164
+ test('the page bar row (pageBarRowHeight — the mobile header actions row it replaced is gone)', () => {
165
165
  for (const level of ALL_LEVELS) {
166
- expect(deriveSpacing(level).step.appHeaderMobileActionsHeight).toBeGreaterThanOrEqual(24)
166
+ expect(deriveSpacing(level).step.pageBarRowHeight).toBeGreaterThanOrEqual(24)
167
167
  }
168
168
  })
169
169
 
@@ -183,6 +183,15 @@ describe('Fix 4 — every interactive target clears the WCAG 2.5.8 24px floor at
183
183
  }
184
184
  })
185
185
 
186
+ // `sheetRowHeight`'s own doc calls it "same floor family as `mobileNavRowHeight`" — same touch-only
187
+ // surface (the `Filters (n)` drawer, the mobile kebab sheet), same Apple HIG 44pt / WCAG 2.5.5 AAA
188
+ // floor, so it must clear it at every level too, not just coincidentally at level 0.
189
+ test('a Filters/kebab sheet row never drops below its own 44px floor', () => {
190
+ for (const level of ALL_LEVELS) {
191
+ expect(deriveSpacing(level).step.sheetRowHeight).toBeGreaterThanOrEqual(44)
192
+ }
193
+ })
194
+
186
195
  test('the resolved control/input height (size="md" Button/ActionIcon/Input) clears the floor too', () => {
187
196
  for (const level of ALL_LEVELS) {
188
197
  const { anchors } = deriveSpacing(level)
@@ -190,6 +199,25 @@ describe('Fix 4 — every interactive target clears the WCAG 2.5.8 24px floor at
190
199
  expect(anchors.inputHeight).toBeGreaterThanOrEqual(24)
191
200
  }
192
201
  })
202
+
203
+ // C15 (`docs/CONTROLS-SPEC.md` §5): "every touch target inside a home is ≥36px below `sm` (floor
204
+ // 30 at density −3)". Without the floor the 1 + 0.1*level multiplier takes 36 -> 25 at level -3,
205
+ // well under even the 24px WCAG floor above.
206
+ test('touchControlHeight never drops below its own 30px floor (C15)', () => {
207
+ for (const level of ALL_LEVELS) {
208
+ expect(deriveSpacing(level).anchors.touchControlHeight).toBeGreaterThanOrEqual(30)
209
+ }
210
+ expect(deriveSpacing(-3).anchors.touchControlHeight).toBe(30)
211
+ })
212
+
213
+ test('the remaining controls-tier anchors clear their own floors at every level', () => {
214
+ for (const level of ALL_LEVELS) {
215
+ const { anchors } = deriveSpacing(level)
216
+ expect(anchors.controlHeightTag).toBeGreaterThanOrEqual(18)
217
+ expect(anchors.controlHeightWidget).toBeGreaterThanOrEqual(22)
218
+ expect(anchors.controlHeightCtl).toBeGreaterThanOrEqual(28)
219
+ }
220
+ })
193
221
  })
194
222
 
195
223
  describe('Fix 5 — scaleSpace floors a sub-1 base at 1 (the inverted-clamp regression)', () => {
@@ -224,14 +252,14 @@ function oldLawFlatten(level: number): Record<string, number> {
224
252
  for (const [k, v] of Object.entries(SPACE_SCALE)) scale[k] = scaleSpace(v, level, multiplier)
225
253
  const step: Record<string, number> = {}
226
254
  for (const [k, v] of Object.entries(SPACE_STEP)) {
227
- if (k === 'stickyHeaderClearance' || k === 'stickyHeaderClearanceMobile') continue
255
+ if (k === 'stickyHeaderClearance') continue
228
256
  step[k] = scaleSpace(v, level, multiplier)
229
257
  }
230
258
  step['sidebarSearchTriggerHeight'] = Math.max(24, step['sidebarSearchTriggerHeight']!)
231
259
  step['mobileNavBarHeight'] = Math.max(48, step['mobileNavBarHeight']!)
232
260
  step['mobileNavRowHeight'] = Math.max(44, step['mobileNavRowHeight']!)
261
+ step['sheetRowHeight'] = Math.max(44, step['sheetRowHeight']!)
233
262
  step['stickyHeaderClearance'] = step['appShellHeaderHeight']! + anchors['stackMd']!
234
- step['stickyHeaderClearanceMobile'] = step['appShellHeaderMobileHeight']! + anchors['stackMd']!
235
263
  return { ...anchors, ...scale, ...step }
236
264
  }
237
265
 
@@ -298,10 +326,10 @@ describe("Fix 7 — the knob's resolution is honest at every notch", () => {
298
326
  expect(deriveSpacing(3).anchors.inputHeight).toBe(55)
299
327
  expect(deriveSpacing(-3).anchors.rowInsetX).toBe(7)
300
328
  expect(deriveSpacing(3).anchors.rowInsetX).toBe(13)
301
- // `stickyHeaderClearance`/`stickyHeaderClearanceMobile` are EXCLUDED here on purpose — since both
302
- // now DERIVE from their own AppShell header instead of scaling off an independent base (see
303
- // `deriveSpacing`'s doc, third bullet, Decision 3), neither reproduced the old ±5 envelope to
304
- // begin with; their own law is asserted by the dedicated describe block in `tokens/density.test.ts`
329
+ // `stickyHeaderClearance` is EXCLUDED here on purpose — since it now DERIVES from the AppShell
330
+ // header instead of scaling off an independent base (see `deriveSpacing`'s doc, third bullet),
331
+ // it never reproduced the old ±5 envelope to
332
+ // begin with; its own law is asserted by the dedicated describe block in `tokens/density.test.ts`
305
333
  // (specific low/zero/high values) AND by the "Fix 8" describe block below in this same file
306
334
  // (the generic `clearance = header + anchors.stackMd` relation, looped over every level).
307
335
  expect(deriveSpacing(-3).rowLineHeight).toBe(1.25)
@@ -309,32 +337,18 @@ describe("Fix 7 — the knob's resolution is honest at every notch", () => {
309
337
  })
310
338
  })
311
339
 
312
- describe('Fix 8 — the sticky-header clearance clears its OWN breakpoint header, exactly (Decision 3 — desktop/mobile split)', () => {
313
- test('desktop clearance = appShellHeaderHeight + stackMd, exactly, at every level', () => {
340
+ describe('Fix 8 — the sticky-header clearance clears the AppShell header, exactly', () => {
341
+ test('clearance = appShellHeaderHeight + stackMd, exactly, at every level', () => {
314
342
  for (const level of ALL_LEVELS) {
315
343
  const { step, anchors } = deriveSpacing(level)
316
344
  expect(step.stickyHeaderClearance).toBe(step.appShellHeaderHeight + anchors.stackMd)
317
345
  }
318
346
  })
319
347
 
320
- test('mobile clearance = appShellHeaderMobileHeight + stackMd, exactly, at every level', () => {
321
- for (const level of ALL_LEVELS) {
322
- const { step, anchors } = deriveSpacing(level)
323
- expect(step.stickyHeaderClearanceMobile).toBe(
324
- step.appShellHeaderMobileHeight + anchors.stackMd,
325
- )
326
- }
327
- })
328
-
329
- test('mobile clearance exceeds desktop clearance at every level — catches a swap of the two formulas', () => {
330
- // Both formulas add the SAME `anchors.stackMd` breathing room, so this reduces to
331
- // `mobileHeader > desktopHeader`, which holds at every level today — but a bug that swapped
332
- // which header feeds which key would flip this comparison immediately.
333
- for (const level of ALL_LEVELS) {
334
- const { step } = deriveSpacing(level)
335
- expect(step.stickyHeaderClearanceMobile).toBeGreaterThan(step.stickyHeaderClearance)
336
- }
337
- })
348
+ // The "mobile clearance = appShellHeaderMobileHeight + stackMd" and "mobile exceeds desktop" tests
349
+ // that used to sit here are DELETED, not renamed: since 1.26.0 the AppShell header is ONE 48px row
350
+ // at every viewport (law C14, `docs/CONTROLS-SPEC.md` §2.1), so `stickyHeaderClearanceMobile` and
351
+ // the `appShellHeaderMobileHeight` it derived from no longer exist to assert anything about.
338
352
 
339
353
  // The "both breathing-room margins (clearance minus own header) are monotonically non-decreasing"
340
354
  // check used to live here as its own test — deleted, not merely renamed: the two tests above this
@@ -362,34 +376,29 @@ describe('Fix 6 — BasaltShell AppShell dimensions track density', () => {
362
376
  }
363
377
  })
364
378
 
365
- test("mobile header row-1 budget never falls below Mantine's static default ActionIcon (22px)", () => {
366
- // The 22px floor is `--ai-size-sm` — verified directly against the INSTALLED Mantine source
367
- // (`@mantine/core/styles/ActionIcon.css`: `--ai-size-xs: 18px; --ai-size-sm: 22px; --ai-size-md:
368
- // 28px; ...`), not recalled from memory. The budget at every level in `[-3, 3]` under the current
369
- // law: 23 / 26 / 28 / 32 / 36 / 38 / 41 (level -3 through +3) — never below the floor, with the
370
- // smallest margin (1px) at level -3, not at -2 as sometimes assumed by eye.
379
+ test('the header is ONE row at every viewport, and it holds a touch target below sm', () => {
380
+ // The invariant this replaced was a BUDGET: `appShellHeaderMobileHeight` was a documented SUM
381
+ // (row 1 + `SPACE_SCALE.sm` + `appHeaderMobileActionsHeight`), so row 1 was whatever the total
382
+ // left over, and raising an addend without raising the total silently ate the target-size margin
383
+ // — which is exactly what the component-roominess retune did (`SPACE_SCALE.sm` 12 -> 13 drove
384
+ // level -3 to 22px, flush against Mantine's 22px `--ai-size-sm` floor).
371
385
  //
372
- // Row 1 is the REMAINDER of `appShellHeaderMobileHeight`'s documented sum (row 1 + `SPACE_SCALE.sm`
373
- // + `appHeaderMobileActionsHeight`), so raising an addend without raising the total takes the
374
- // difference straight out of this budget. That is exactly what the component-roominess retune did
375
- // — `SPACE_SCALE.sm` 12 -> 13 briefly drove level -3 to 22, flush against the floor — and why the
376
- // mobile header went 96 -> 97 in the same change. See that constant's JSDoc (`tokens/palette.ts`):
377
- // it names this test as the thing that measures the drift, and computing the header from its
378
- // addends as the fix that would make the drift impossible.
386
+ // 1.26.0 deletes the sum instead of re-tuning it: `PageBar` folds the page's overflow into a
387
+ // kebab and moves filters/tabs into the page flow, so there is no second mobile row to reserve
388
+ // (law C14) and the header is `appShellHeaderHeight` at EVERY width. Row 1's budget is therefore
389
+ // the whole header, and the thing worth asserting is what law C15 actually promises — that a
390
+ // `touchControlHeight` target fits inside it below `sm`.
379
391
  for (const level of ALL_LEVELS) {
380
- const { step, scale } = deriveSpacing(level)
381
- const budget = step.appShellHeaderMobileHeight - step.appHeaderMobileActionsHeight - scale.sm
382
- expect(budget).toBeGreaterThanOrEqual(22)
392
+ const { step, anchors } = deriveSpacing(level)
393
+ expect(step.appShellHeaderHeight).toBeGreaterThanOrEqual(anchors.touchControlHeight)
394
+ expect(anchors.touchControlHeight).toBeGreaterThanOrEqual(anchors.controlHeightCtl)
383
395
  }
384
396
  })
385
397
 
386
- test('the mobile header row-1 budget is monotonically non-decreasing across levels', () => {
387
- const budgets = ALL_LEVELS.map((level) => {
388
- const { step, scale } = deriveSpacing(level)
389
- return step.appShellHeaderMobileHeight - step.appHeaderMobileActionsHeight - scale.sm
390
- })
391
- for (let i = 1; i < budgets.length; i++) {
392
- expect(budgets[i]).toBeGreaterThanOrEqual(budgets[i - 1]!)
398
+ test('the header row is monotonically non-decreasing across levels', () => {
399
+ const heights = ALL_LEVELS.map((level) => deriveSpacing(level).step.appShellHeaderHeight)
400
+ for (let i = 1; i < heights.length; i++) {
401
+ expect(heights[i]).toBeGreaterThanOrEqual(heights[i - 1]!)
393
402
  }
394
403
  })
395
404
 
@@ -449,3 +458,49 @@ describe('Fix 9 — the Mantine spacing scale keeps its strict xs < sm < md < lg
449
458
  }
450
459
  })
451
460
  })
461
+
462
+ describe('Fix 10 — ctl SegmentedControl matches the ctl Button/Input box at every level, not a fixed 30px', () => {
463
+ // `SegmentedControl.extend`'s function-form `styles` only sets a label `minHeight` when
464
+ // `props.size === 'ctl'` — everything else (`sm`/`md` in a form) must fall through untouched.
465
+ const stylesFn = baseTheme.components?.['SegmentedControl']?.styles as
466
+ | ((t: MantineTheme, props: { size?: string }, ctx: never) => Record<string, unknown>)
467
+ | undefined
468
+
469
+ test('a non-ctl size (e.g. the form default) gets no label override', () => {
470
+ const result = stylesFn?.(theme, { size: 'md' }, {} as never)
471
+ expect(
472
+ (result?.['label'] as Record<string, unknown> | undefined)?.['minHeight'],
473
+ ).toBeUndefined()
474
+ })
475
+
476
+ test("size='ctl' gets an inline-flex, vertically centred label — Mantine's label is `display: block`, so a bare minHeight alone would leave the text top-aligned", () => {
477
+ const result = stylesFn?.(theme, { size: 'ctl' }, {} as never)
478
+ const label = result?.['label'] as Record<string, unknown> | undefined
479
+ expect(label?.['display']).toBe('inline-flex')
480
+ expect(label?.['alignItems']).toBe('center')
481
+ })
482
+
483
+ test('the label minHeight formula, plus the root track padding on both sides, reconstructs EXACTLY the same scaled expression as `--button-height-ctl` — the algebraic identity that makes the two controls the same height at every density level, not just level 0', () => {
484
+ const result = stylesFn?.(theme, { size: 'ctl' }, {} as never)
485
+ const label = result?.['label'] as Record<string, unknown> | undefined
486
+ const minHeight = label?.['minHeight']
487
+ expect(typeof minHeight).toBe('string')
488
+ const rootPadding = 2 * SPACE_FIXED.segmentedTrackInset
489
+ expect(minHeight).toBe(
490
+ `calc(var(--vx-space-control-height-ctl) * var(--mantine-scale) - ${rootPadding}px)`,
491
+ )
492
+ // `--button-height-ctl` (`ctlSizeVars` in theme/index.ts) is the un-subtracted form — adding the
493
+ // root's own `2 * segmentedTrackInset` padding back to the label's minHeight must reproduce it,
494
+ // for every level, since neither term is level-dependent (the level only changes the CSS var's
495
+ // RESOLVED value at paint time, never this formula).
496
+ for (const level of ALL_LEVELS) {
497
+ // `anchors.controlHeightCtl` is the JS-side value `--vx-space-control-height-ctl` resolves to
498
+ // at this level (scale = 1, the JS-derivation default) — substituting it into both formulas
499
+ // must land on the same total.
500
+ const { anchors } = deriveSpacing(level)
501
+ const labelHeight = anchors.controlHeightCtl - rootPadding
502
+ const totalHeight = labelHeight + rootPadding
503
+ expect(totalHeight).toBe(anchors.controlHeightCtl)
504
+ }
505
+ })
506
+ })
@@ -0,0 +1,259 @@
1
+ /**
2
+ * The divider law — a mechanical inventory of every HORIZONTAL hairline basalt draws
3
+ * (`docs/DESIGN-SPEC.md` §8: depth is a shadow, and a border-as-border is reserved for a layout
4
+ * divider).
5
+ *
6
+ * The bug this file exists to prevent, measured on the playground's `Controls (mobile)` page at
7
+ * 390px: the page carried five rules in its first 320px — a table box whose top edge sat directly
8
+ * under the table's own `WidgetHeader`, its bottom edge on the last row's own hairline, and a
9
+ * section rule 8px from the next one. None of them divided anything. Two rules stack into a band,
10
+ * and a band under a heading says "the heading is not part of what follows", which is the opposite
11
+ * of what a section header is for.
12
+ *
13
+ * So the law has two halves and this file pins both:
14
+ *
15
+ * 1. **A header never draws a rule under itself** — in the CHROME lane (shell, controls, dashboard,
16
+ * data, widget-header, theme). The reader already has the heading's type scale, its weight and
17
+ * the space below it; a line adds a fourth channel saying the same thing and costs a band.
18
+ * 2. **A between-rows family always drops its LAST rule** — the line after the final row is an
19
+ * outer border pretending to be a separator, and it is the line that lands on top of whatever
20
+ * frame the container already has.
21
+ *
22
+ * The guard is an INVENTORY, not a pattern match: every horizontal hairline in every CSS module has
23
+ * to be listed below with a `kind` and a reason, so a seventh one added next month fails here and
24
+ * gets classified rather than shipping unexamined. That is the same idiom `theme/border-coverage.test.ts`
25
+ * uses for Mantine's stock borders, applied to the ones basalt writes itself.
26
+ *
27
+ * Not in scope, deliberately: the `border` SHORTHAND (a box around a control or a card — that is an
28
+ * edge, not a rule, and `border-coverage.test.ts` owns it), vertical hairlines (a column rule
29
+ * divides two regions by construction), and Mantine's own `inset 0 -1px 0` redraw of a STICKY table
30
+ * head's `border-bottom`, which is a row line drawn as a shadow because `border-collapse: collapse`
31
+ * drops borders on sticky cells — it appears and disappears with `withRowBorders`, so it is the same
32
+ * line every other row gets and not a header rule.
33
+ */
34
+ import { describe, expect, test } from 'bun:test'
35
+ import { Glob } from 'bun'
36
+ import { readFileSync } from 'node:fs'
37
+ import { join } from 'node:path'
38
+
39
+ const SRC = join(import.meta.dir, '..')
40
+
41
+ type Hairline = { readonly file: string; readonly selector: string; readonly decl: string }
42
+
43
+ /**
44
+ * Every `border-{top,bottom,block-start,block-end}` and every 1px inset `box-shadow` in a CSS
45
+ * module, with the selector it sits under. A hand-rolled scanner rather than a regex per file: the
46
+ * SELECTOR is half the classification (`.header` vs `.rows > *:not(:last-child)` are the two
47
+ * opposite verdicts), and a regex over declarations alone cannot see it.
48
+ */
49
+ function scanHairlines(): { rules: Hairline[]; drops: Hairline[] } {
50
+ const rules: Hairline[] = []
51
+ const drops: Hairline[] = []
52
+ const files = [...new Glob('**/*.module.css').scanSync({ cwd: SRC })].sort()
53
+ for (const file of files) {
54
+ const css = readFileSync(join(SRC, file), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
55
+ const stack: string[] = []
56
+ let buf = ''
57
+ for (const ch of css) {
58
+ if (ch === '{') {
59
+ stack.push(buf.trim().replace(/\s+/g, ' '))
60
+ buf = ''
61
+ continue
62
+ }
63
+ if (ch === '}') {
64
+ stack.pop()
65
+ buf = ''
66
+ continue
67
+ }
68
+ if (ch !== ';') {
69
+ buf += ch
70
+ continue
71
+ }
72
+ const decl = buf.trim().replace(/\s+/g, ' ')
73
+ buf = ''
74
+ const border = /^border-(?:top|bottom|block-start|block-end):\s*(.+)$/.exec(decl)
75
+ const selector = stack.join(' >> ')
76
+ if (border !== null) {
77
+ ;(border[1] === 'none' ? drops : rules).push({ file, selector, decl })
78
+ continue
79
+ }
80
+ if (decl.startsWith('box-shadow:') && decl.includes('inset') && /\b-?1px\b/.test(decl)) {
81
+ rules.push({ file, selector, decl })
82
+ }
83
+ }
84
+ }
85
+ return { rules, drops }
86
+ }
87
+
88
+ /**
89
+ * `between-rows` — the line sits BETWEEN two members of a list and the family drops its last one.
90
+ * `region-boundary` — it separates two layout regions that are not a header and its content (the
91
+ * mobile bar and the page it floats over, an article's body and its footer).
92
+ * `prose-typography` — an AUTHORED document, not app chrome: an `<hr>` the author wrote, the rule
93
+ * under an article's `h2`, a code block's filename bar. A document's conventions are the reader's
94
+ * expectations there (`docs/CONTENT-SPEC.md`), and `content/**` is out of the chrome lane for
95
+ * exactly that reason. Nothing in this kind may appear outside `content/`.
96
+ */
97
+ type Kind = 'between-rows' | 'region-boundary' | 'prose-typography'
98
+
99
+ const LEDGER: readonly { file: string; selector: string; kind: Kind; why: string }[] = [
100
+ {
101
+ file: 'shell/app-mobile-nav.module.css',
102
+ selector: '.bar',
103
+ kind: 'region-boundary',
104
+ why: 'The mobile bar is a fixed region floating over a scrolling page — the rule is the only thing that says the page ends there, and it is the boundary between two regions, not a header and its content.',
105
+ },
106
+ {
107
+ file: 'controls/controls.module.css',
108
+ selector:
109
+ '.sheetOption + .sheetOption, .sheetDisclosureBody + .sheetOption, .sheetOption + .sheetDisclosureBody',
110
+ kind: 'between-rows',
111
+ why: "The filter sheet's option rows. Written as an adjacent-sibling rule so the FIRST row has no top line — the group label above it is a header and gets no rule (C15).",
112
+ },
113
+ {
114
+ file: 'controls/controls.module.css',
115
+ selector: '.sheetRow',
116
+ kind: 'between-rows',
117
+ why: 'A 44px sheet row. Paired with `.sheetRow:last-child { border-bottom: none }` below, which the last-rule assertion checks.',
118
+ },
119
+ {
120
+ file: 'dashboard/settings-section.module.css',
121
+ selector: '.rows > *:not(:last-child)',
122
+ kind: 'between-rows',
123
+ why: 'A settings list. The `:not(:last-child)` IS the last-rule drop, written into the selector rather than as a second rule.',
124
+ },
125
+ {
126
+ file: 'content/article-layout.module.css',
127
+ selector: '.header',
128
+ kind: 'prose-typography',
129
+ why: "An article's masthead rule — the title-block convention of a printed document, and the one place a reader expects a line under a heading.",
130
+ },
131
+ {
132
+ file: 'content/article-layout.module.css',
133
+ selector: '.footer',
134
+ kind: 'region-boundary',
135
+ why: 'Prev/next navigation below the article body — two regions, and the rule is above the footer rather than under a heading.',
136
+ },
137
+ {
138
+ file: 'content/guide.module.css',
139
+ selector: '.footer',
140
+ kind: 'region-boundary',
141
+ why: 'Same shape as the article footer: the boundary between a document and the navigation after it.',
142
+ },
143
+ {
144
+ file: 'content/prose.module.css',
145
+ selector: '.root hr',
146
+ kind: 'prose-typography',
147
+ why: 'An `<hr>` the AUTHOR wrote in their markdown. basalt does not get to drop a divider the document asked for.',
148
+ },
149
+ {
150
+ file: 'content/prose.module.css',
151
+ selector: '.root.article h2',
152
+ kind: 'prose-typography',
153
+ why: "The `article` prose variant's section rule — a long-form document convention, scoped to that variant and never applied to app chrome.",
154
+ },
155
+ {
156
+ file: 'content/code-block.module.css',
157
+ selector: '.header',
158
+ kind: 'prose-typography',
159
+ why: "A code block's filename/language bar. The block is a quoted artifact inside a document, and the bar is its frame's top row rather than a section heading.",
160
+ },
161
+ ]
162
+
163
+ const CHROME_LANES = ['shell/', 'controls/', 'dashboard/', 'data/', 'widget-header/', 'theme/']
164
+
165
+ function isChrome(file: string): boolean {
166
+ return CHROME_LANES.some((lane) => file.startsWith(lane))
167
+ }
168
+
169
+ const { rules, drops } = scanHairlines()
170
+
171
+ describe('every horizontal hairline is inventoried and classified', () => {
172
+ test('no hairline exists that the ledger does not name', () => {
173
+ const listed = new Set(LEDGER.map((e) => `${e.file}|${e.selector}`))
174
+ const unlisted = rules
175
+ .filter((r) => !listed.has(`${r.file}|${r.selector}`))
176
+ .map((r) => `${r.file} → ${r.selector} { ${r.decl} }`)
177
+ // Do NOT silence this by adding an entry with a hand-wave. Decide which `kind` the line is: a
178
+ // `between-rows` family (then add the last-rule drop too), a `region-boundary`, or — only inside
179
+ // `content/**` — `prose-typography`. A rule under a heading in the chrome lane is none of the
180
+ // three, and the fix is to delete it and let the type scale and the space do the work.
181
+ expect(unlisted).toEqual([])
182
+ })
183
+
184
+ test('the ledger names nothing that no longer exists', () => {
185
+ const found = new Set(rules.map((r) => `${r.file}|${r.selector}`))
186
+ expect(LEDGER.filter((e) => !found.has(`${e.file}|${e.selector}`)).map((e) => e.file)).toEqual(
187
+ [],
188
+ )
189
+ })
190
+
191
+ test('`prose-typography` is confined to content/ — app chrome has no authored-document lane', () => {
192
+ expect(
193
+ LEDGER.filter((e) => e.kind === 'prose-typography' && !e.file.startsWith('content/')).map(
194
+ (e) => `${e.file} → ${e.selector}`,
195
+ ),
196
+ ).toEqual([])
197
+ })
198
+
199
+ test('every ledger entry carries a real reason', () => {
200
+ expect(LEDGER.filter((e) => e.why.length < 40).map((e) => e.selector)).toEqual([])
201
+ })
202
+ })
203
+
204
+ describe('a header never draws a rule under itself in the chrome lane', () => {
205
+ // The five class names the chrome lane uses for "the heading row of a thing" — `WidgetHeader`'s
206
+ // root/titleRow/subtitle, `Section`'s, the filter sheet's, the table toolbar's. A `border-bottom`
207
+ // on any of them is the exact regression this describe block exists for.
208
+ const HEADER_SELECTOR =
209
+ /^\.(?:root|header|titleRow|title|subtitle|sheetHeader|sheetTitle|toolbar|thead|head)\b/
210
+
211
+ test('no chrome-lane hairline sits under a header-shaped selector', () => {
212
+ const offenders = rules
213
+ .filter((r) => isChrome(r.file) && HEADER_SELECTOR.test(r.selector))
214
+ .map((r) => `${r.file} → ${r.selector} { ${r.decl} }`)
215
+ expect(offenders).toEqual([])
216
+ })
217
+
218
+ test('the chrome lane holds ONLY between-rows and region-boundary lines', () => {
219
+ const kinds = LEDGER.filter((e) => isChrome(e.file)).map((e) => e.kind)
220
+ expect([...new Set(kinds)].sort()).toEqual(['between-rows', 'region-boundary'])
221
+ })
222
+ })
223
+
224
+ describe('a between-rows family drops its last rule', () => {
225
+ for (const entry of LEDGER.filter((e) => e.kind === 'between-rows')) {
226
+ test(`${entry.file} → ${entry.selector}`, () => {
227
+ // Either the selector itself excludes the last member (`:not(:last-child)`, or an
228
+ // adjacent-sibling combinator, which draws the line as the SECOND row's top edge and so has no
229
+ // trailing rule by construction), or the module carries an explicit `border: none` drop.
230
+ const selfLimiting = /:not\(:last-child\)|\+/.test(entry.selector)
231
+ const dropped = drops.some(
232
+ (d) => d.file === entry.file && d.selector.startsWith(`${entry.selector}:last`),
233
+ )
234
+ expect(selfLimiting || dropped).toBe(true)
235
+ })
236
+ }
237
+
238
+ test('the themed Table drops its last body row — the rule that used to land on the outer box', () => {
239
+ // `BasaltDataTable`'s `withTableBorder` went to `false` by default in the same minor (see
240
+ // `data/data-table.test.tsx`); while both shipped, the two lines sat on top of each other and the
241
+ // doubling hid which of them was drawing it. `tbody` scoped so a `tfoot` totals row keeps the
242
+ // BETWEEN rule above it.
243
+ const css = readFileSync(join(SRC, 'theme/controls.module.css'), 'utf8')
244
+ expect(css).toContain('html .tableRoot tbody tr:last-of-type')
245
+ expect(drops).toContainEqual({
246
+ file: 'theme/controls.module.css',
247
+ selector: 'html .tableRoot tbody tr:last-of-type',
248
+ decl: 'border-bottom: none',
249
+ })
250
+ })
251
+
252
+ test('the themed Accordion drops its last item — the same law, already shipped', () => {
253
+ expect(drops).toContainEqual({
254
+ file: 'theme/controls.module.css',
255
+ selector: '.accordionItem:last-of-type',
256
+ decl: 'border-bottom: none',
257
+ })
258
+ })
259
+ })
@@ -0,0 +1,47 @@
1
+ /*
2
+ * The framework's ONE icon box (`docs/CONTROLS-SPEC.md` §3) — see `icon-slot.tsx` for why every
3
+ * `icon` prop in the public API renders through it.
4
+ *
5
+ * The box is a FIXED square, not a shrink-to-fit inline box: `width`/`height` are declared on the
6
+ * slot itself so the row's geometry is decided by the SLOT and never by the glyph the caller
7
+ * happened to pass. `--vx-space-icon-size` is the host's override hook (a `WidgetHeader` tier, a
8
+ * collapsed rail), defaulting to the 16px the control tier draws; it is deliberately NOT a
9
+ * `--vx-space-*` token declared by `tokens/index.ts` — no home needs it globally, and a token would
10
+ * invite a call site to set it.
11
+ */
12
+ .slot {
13
+ display: inline-flex;
14
+ flex: none;
15
+ align-items: center;
16
+ justify-content: center;
17
+ width: var(--vx-space-icon-size, 1rem);
18
+ height: var(--vx-space-icon-size, 1rem);
19
+ /* The glyph is `display: block` below, so the slot has no text to lead — a residual line box is
20
+ * what pushed a 16px icon a fraction below the optical centre of a 30px button. */
21
+ line-height: 0;
22
+ }
23
+
24
+ /*
25
+ * `width`/`height: 100%` is what makes ANY consumer glyph land on the tier. An inline `<svg>`
26
+ * carries its own `width`/`height` attributes (14, 20, 24 — or none at all, which paints at the
27
+ * `viewBox`'s intrinsic size, or 300×150 with no `viewBox` either), and those attributes are
28
+ * PRESENTATION attributes: they lose to any CSS declaration. So the slot restates them from the
29
+ * box, and the caller's own numbers stop mattering.
30
+ *
31
+ * `display: block` removes the baseline the glyph would otherwise sit on. An inline `<svg>` aligns
32
+ * its own bottom edge to the parent's text baseline, which leaves the descender gap BELOW it inside
33
+ * the box — the icon then paints high or low of centre by a fraction of the line box, and by a
34
+ * DIFFERENT fraction per font stack. `object-fit: contain` keeps a non-square glyph whole rather
35
+ * than stretching it to the square box.
36
+ *
37
+ * `img` and `picture` are covered too: an icon slot filled from a sprite sheet or a remote asset is
38
+ * the same geometry problem, and a consumer's icon set is as likely to be one as the other.
39
+ */
40
+ .slot > svg,
41
+ .slot > img,
42
+ .slot > picture {
43
+ display: block;
44
+ width: 100%;
45
+ height: 100%;
46
+ object-fit: contain;
47
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Typed CSS-module declaration (the S4 CSS-modules path). Named string props — not an index
3
+ * signature — so dot access stays verbatim under the package's strict flags.
4
+ */
5
+ declare const classes: {
6
+ readonly slot: string
7
+ }
8
+ export default classes