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
@@ -0,0 +1,350 @@
1
+ /*
2
+ * WidgetHeader — the one heading primitive behind every section/card/table title
3
+ * (docs/CONTROLS-SPEC.md §2.2). Plain elements only — no `@mantine/*` anywhere in this folder —
4
+ * so `ChartCard` (inside the `charts/` boundary) can compose it without tripping
5
+ * `basalt/token-layer-boundary` / `check-dist-layering.mjs`.
6
+ *
7
+ * `--vx-space-section-header-height` / `--vx-space-widget-header-height` / `--vx-space-control-gap`
8
+ * are density-tracking anchors landing alongside this wave (docs/CONTROLS-SPEC.md §5) — referenced
9
+ * by name with rem fallbacks at their level-0 px value (36/28/6), same idiom as ChartCard's
10
+ * `--mantine-spacing-*` fallbacks.
11
+ *
12
+ * Layout: the title row holds title/icon/info/count/actions only. `value` + `delta` render on
13
+ * their own hero-metric row directly beneath it — for BOTH tiers, never inline with the title.
14
+ */
15
+
16
+ .root {
17
+ display: flex;
18
+ flex-direction: column;
19
+ }
20
+
21
+ .titleRow {
22
+ display: flex;
23
+ align-items: center;
24
+ gap: var(--vx-space-control-gap, 0.375rem);
25
+ }
26
+
27
+ /*
28
+ * Below `sm` the title row WRAPS, which is the safety net for an `actions` slot too wide to sit
29
+ * beside the title. `flex-wrap` is the right tool precisely because it is conditional: a 30px kebab
30
+ * fits and stays on the row, while a control whose hypothetical size does not fit takes its own line
31
+ * instead of squeezing the heading to an ellipsis. No breakpoint arithmetic, no max-width fraction,
32
+ * and no prop — the row measures the actual content.
33
+ *
34
+ * `row-gap` is the 8px rhythm step, so a wrapped slot reads as the line under the title rather than
35
+ * as a second header. `.actions` keeps its `margin-inline-start: auto`, which on a wrapped line makes
36
+ * it right-aligned — the same edge it holds when it is on the row.
37
+ */
38
+ @media (max-width: 47.99375em) {
39
+ .titleRow {
40
+ flex-wrap: wrap;
41
+ row-gap: var(--vx-space-stack-sm);
42
+ }
43
+ }
44
+
45
+ .root[data-tier='section'] .titleRow {
46
+ min-height: var(--vx-space-section-header-height, 2.25rem);
47
+ }
48
+
49
+ .root[data-tier='widget'] .titleRow {
50
+ min-height: var(--vx-space-widget-header-height, 1.75rem);
51
+ }
52
+
53
+ /*
54
+ * Section → h2, widget → h3, and the two tiers now read DIFFERENTLY on purpose — they did not, and
55
+ * that is what flattened the page.
56
+ *
57
+ * A `section` heading names a region of the page: 15px, weight 600, INK. A `widget` heading labels a
58
+ * number that is itself the thing being read (a KPI card's hero value, a chart card's total), so it
59
+ * is the quieter of the pair — one step down and MUTED, which is what lets the 24px mono value under
60
+ * it carry the card. Both keep the shared head-font 88% stretch.
61
+ *
62
+ * Rendered identically (15px ink both) the hero value competed with its own label, four cards in a
63
+ * row read as four headings, and the section that grouped them read as one more of the same.
64
+ */
65
+ .heading {
66
+ display: inline-flex;
67
+ align-items: center;
68
+ gap: 5px;
69
+ margin: 0;
70
+ min-width: 0;
71
+ font-family: var(--basalt-font-head);
72
+ font-stretch: 88%;
73
+ font-weight: 550;
74
+ font-size: var(--vx-text-md);
75
+ color: var(--vx-ink);
76
+ }
77
+
78
+ .root[data-tier='section'] .heading {
79
+ font-weight: 600;
80
+ }
81
+
82
+ .root[data-tier='widget'] .heading {
83
+ font-size: var(--vx-text-sm);
84
+ color: var(--vx-muted);
85
+ }
86
+
87
+ .titleText {
88
+ overflow: hidden;
89
+ text-overflow: ellipsis;
90
+ white-space: nowrap;
91
+ }
92
+
93
+ /*
94
+ * The icon slot PINS its glyph's box. A consumer's icon arrives at whatever size its own component
95
+ * chose (the playground's is 18px), and an 18px mark beside a 13.5px title is a bullet, not a label
96
+ * icon — so the tier states the size here rather than asking every call site to pass one.
97
+ */
98
+ /*
99
+ * The heading's icon box carries COLOUR ONLY — geometry belongs to `theme/icon-slot.module.css`,
100
+ * which this class is composed onto (`IconSlot className={classes.icon}`). The per-tier SIZE is set
101
+ * as the slot's own box var below rather than as a `> svg` rule, so it survives whatever the caller's
102
+ * glyph declares for itself: a `width`/`height` attribute is a presentation attribute and loses to
103
+ * the slot's CSS, while a `> svg` rule scoped to one component only ever fixed that component.
104
+ */
105
+ .icon {
106
+ color: var(--vx-faint);
107
+ }
108
+
109
+ .root[data-tier='widget'] .icon {
110
+ --vx-space-icon-size: 0.875rem;
111
+ }
112
+
113
+ .root[data-tier='section'] .icon {
114
+ --vx-space-icon-size: 1rem;
115
+ }
116
+
117
+ /* Mono count tag after the title (C11 — every table/list states its count in its header). Its box is
118
+ * the `tag` tier (20px), so it never grows the header row it sits in. */
119
+ .count {
120
+ display: inline-flex;
121
+ flex-shrink: 0;
122
+ align-items: center;
123
+ /* Exactly the `tag` tier (20px), not a floor: the mono `VX.text.xs` line box plus
124
+ * `badgeInset`-y is 23px on its own, so a `min-height` changed nothing and the tag rendered 3px
125
+ * taller than the tier it is named for — enough to grow a 36px section-header row. `line-height: 1`
126
+ * is what lets the box be the stated height instead of the text's. */
127
+ /* `--mantine-scale`-aware, same reconstruction every other ctl/tag-tier box uses (`theme/index.ts`'s
128
+ * `ctlSizeVars`) — read raw, this box stayed fixed while the Badge/Button beside it in the same
129
+ * header scaled with `theme.scale`. */
130
+ height: calc(var(--vx-space-control-height-tag, 1.25rem) * var(--mantine-scale, 1));
131
+ line-height: 1;
132
+ border-radius: var(--vx-radius-ctrl);
133
+ background: var(--vx-surface-subtle);
134
+ padding: var(--vx-space-badge-inset-y) var(--vx-space-badge-inset-x);
135
+ font-family: var(--basalt-font-mono);
136
+ font-size: var(--vx-text-xs);
137
+ font-weight: 500;
138
+ color: var(--vx-faint);
139
+ }
140
+
141
+ /*
142
+ * The hero-metric row — value + delta, directly under the title row, for both tiers. The delta rides
143
+ * 4px off the value, not 8: it is a suffix ON the number (`$168,920 +47%`), and at 8px it read as a
144
+ * second, unrelated chip in the row. `baseline` alignment is what keeps the mono numeral and the
145
+ * badge's own smaller mono text sitting on one line.
146
+ */
147
+ /*
148
+ * The hero-metric row: the value, then its `DeltaBadge`.
149
+ *
150
+ * The gap is the 8px vertical-rhythm step (`--vx-space-stack-sm`), not the 4px it shipped as. 4px is
151
+ * the rhythm's SMALLEST step — the distance between a label and the thing it labels — and at that
152
+ * distance the badge read as a superscript hanging off the numeral rather than as a second fact
153
+ * beside it. It is a token and not a literal so it tracks the density knob with every other spacing
154
+ * in the header (`deriveSpacing`, `tokens/palette.ts`); `theme/layout-rhythm-css.test.ts`'s sibling
155
+ * assertion in `widget-header.test.tsx` pins it against exactly the literal it used to be.
156
+ *
157
+ * `align-items: center`, not `baseline`. The badge is a BOX with its own padding and a mono 11.5px
158
+ * label, so aligning its text baseline to a 24px KPI numeral's baseline put the box itself low —
159
+ * its top edge sat below the numeral's x-height and its bottom edge hung under the numeral's
160
+ * baseline. A chip beside a numeral is centred on the numeral, the same law the `count` tag in the
161
+ * title row above already follows.
162
+ */
163
+ .metrics {
164
+ display: flex;
165
+ flex-wrap: nowrap;
166
+ align-items: center;
167
+ gap: var(--vx-space-stack-sm);
168
+ margin-top: 2px;
169
+ min-width: 0;
170
+ }
171
+
172
+ /*
173
+ * Below `sm` the metric row WRAPS, so the badge drops under the value instead of ellipsizing it.
174
+ * `nowrap` plus the `.value` clamp below is right on desktop — a `+18.9%` cannot ellipsize and read,
175
+ * a numeral can — but a KPI's numeral IS the content, and in a 2-up card grid at 390px
176
+ * `$1294.9k ▲18.9%` rendered as `$129… ▲18.9%`. Truncating the number to protect the delta about the
177
+ * number is the wrong way round. Conditional like the title row above it: a short value keeps the
178
+ * badge beside it, and only a value that would otherwise clip gives up the line.
179
+ */
180
+ @media (max-width: 47.99375em) {
181
+ .metrics {
182
+ flex-wrap: wrap;
183
+ row-gap: 2px;
184
+ }
185
+ }
186
+
187
+ /* The value is the row's shrinkable half and the delta badge is not: a pre-formatted numeral can
188
+ * ellipsize and still read, a `+18.9%` cannot. Without the clamp the badge grew past the header box
189
+ * and painted over `StatCard`'s 72px sparkline slot beside it. */
190
+ .value {
191
+ min-width: 0;
192
+ overflow: hidden;
193
+ text-overflow: ellipsis;
194
+ white-space: nowrap;
195
+ font-family: var(--basalt-font-mono);
196
+ font-size: var(--vx-text-kpi);
197
+ font-weight: 600;
198
+ letter-spacing: -0.02em;
199
+ color: var(--vx-ink);
200
+ }
201
+
202
+ /*
203
+ * The unit beside the hero value. Mono like the number it qualifies (so `412 TSS` sits on one
204
+ * baseline grid), `--vx-text-sm` and `--vx-muted` so it never competes with the numeral — the whole
205
+ * point is that a unit is the SMALL half of the pair. `flex-shrink: 0`: the value is the row's
206
+ * shrinkable half (see `.value`), and a unit that ellipsized to `TS…` would state nothing.
207
+ */
208
+ .unit {
209
+ flex-shrink: 0;
210
+ align-self: baseline;
211
+ font-family: var(--basalt-font-mono);
212
+ font-size: var(--vx-text-sm);
213
+ font-weight: 500;
214
+ color: var(--vx-muted);
215
+ }
216
+
217
+ /* The C1/C5 home for header actions — always pinned right, regardless of what else is present. */
218
+ .actions {
219
+ display: inline-flex;
220
+ flex-shrink: 0;
221
+ align-items: center;
222
+ gap: var(--vx-space-control-gap, 0.375rem);
223
+ margin-inline-start: auto;
224
+ }
225
+
226
+ /* Directly under the title row, 4px off it — 1px put it inside the title's own line box, so a
227
+ * section with both read as a two-line heading rather than a heading plus a note. */
228
+ .subtitle {
229
+ margin-top: 4px;
230
+ font-size: var(--vx-text-sm);
231
+ font-weight: 400;
232
+ color: var(--vx-muted);
233
+ }
234
+
235
+ /* `data-placement` is read by wave-3 composers wanting a `bleed` layout (docs/CONTROLS-SPEC.md
236
+ * §2.2's `sparklinePlacement` composer mapping) — this primitive only ever sets `right`. */
237
+ .sparkline {
238
+ margin-top: 4px;
239
+ }
240
+
241
+ /* ── info glyph ─────────────────────────────────────────────────────────────────────────────── */
242
+
243
+ .info {
244
+ position: relative;
245
+ display: inline-flex;
246
+ flex-shrink: 0;
247
+ align-items: center;
248
+ }
249
+
250
+ .infoTrigger {
251
+ display: inline-flex;
252
+ align-items: center;
253
+ appearance: none;
254
+ margin: 0;
255
+ padding: 0;
256
+ border: none;
257
+ background: none;
258
+ color: var(--vx-faint);
259
+ cursor: help;
260
+ }
261
+
262
+ .infoTrigger:hover,
263
+ .infoTrigger:focus-visible,
264
+ .infoTrigger[data-open='true'] {
265
+ color: var(--vx-muted);
266
+ }
267
+
268
+ /*
269
+ * The tooltip bubble reads like every other floating surface — panel bg + `shadow-card` + card
270
+ * radius (docs/DESIGN-SPEC.md §5). It opens DOWNWARD by convention, into whatever sits below the
271
+ * header. `pointer-events: none` so it can never swallow the pointer on its way back to the
272
+ * trigger's `mouseleave`. Mantine-free by design (this whole folder is), so it is a plain
273
+ * state-driven bubble rather than a Mantine Tooltip — which is also what makes it composable from
274
+ * inside the `charts/` boundary.
275
+ */
276
+ .infoBubble {
277
+ position: absolute;
278
+ top: calc(100% + 7px);
279
+ left: 0;
280
+ z-index: 10;
281
+ width: max-content;
282
+ max-width: 260px;
283
+ padding: 7px 10px;
284
+ border-radius: var(--vx-radius-card);
285
+ background-color: var(--vx-surface-panel);
286
+ box-shadow: var(--vx-shadow-card);
287
+ color: var(--vx-muted);
288
+ font-size: var(--vx-text-sm);
289
+ font-weight: 400;
290
+ line-height: 1.4;
291
+ white-space: normal;
292
+ pointer-events: none;
293
+ }
294
+
295
+ /* ── DeltaBadge ─────────────────────────────────────────────────────────────────────────────── */
296
+
297
+ .deltaBadge {
298
+ display: inline-flex;
299
+ flex: none;
300
+ white-space: nowrap;
301
+ align-items: center;
302
+ gap: 3px;
303
+ border-radius: var(--vx-radius-ctrl);
304
+ padding: var(--vx-space-badge-inset-y) var(--vx-space-badge-inset-x);
305
+ font-family: var(--basalt-font-mono);
306
+ font-size: var(--vx-text-xs);
307
+ font-weight: 600;
308
+ line-height: 1;
309
+ }
310
+
311
+ .deltaGlyph {
312
+ font-size: 0.8em;
313
+ }
314
+
315
+ .deltaPeriod {
316
+ margin-inline-start: 4px;
317
+ font-weight: 500;
318
+ opacity: 0.6;
319
+ }
320
+
321
+ /*
322
+ * Law C15 — the `actions` slot is the tier-2/tier-3 home, and its controls are 30px (`ctl`) or 24px
323
+ * (`icon`). Below `sm` an `::after` overlay lifts the HIT area to
324
+ * `--vx-space-touch-control-height` (36px at level 0) without moving the visible box, the same
325
+ * overlay `shell/page-bar.module.css` draws for the bar. `overflow: visible` is load-bearing:
326
+ * Mantine's Button/ActionIcon roots are `overflow: hidden`, which clips an overlay that by
327
+ * definition extends past the box — the clipping only exists for their loading overlay, painted
328
+ * under `[data-loading]`, which is excluded here.
329
+ */
330
+ @media (max-width: 47.99375em) {
331
+ .actions :is(button, a) {
332
+ position: relative;
333
+ }
334
+
335
+ .actions :is(button, a):not([data-loading]) {
336
+ overflow: visible;
337
+ }
338
+
339
+ .actions :is(button, a)::after {
340
+ content: '';
341
+ position: absolute;
342
+ top: 50%;
343
+ left: 50%;
344
+ translate: -50% -50%;
345
+ width: 100%;
346
+ height: 100%;
347
+ min-width: var(--vx-space-touch-control-height, 2.25rem);
348
+ min-height: var(--vx-space-touch-control-height, 2.25rem);
349
+ }
350
+ }
@@ -0,0 +1,25 @@
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 root: string
7
+ readonly titleRow: string
8
+ readonly heading: string
9
+ readonly titleText: string
10
+ readonly icon: string
11
+ readonly count: string
12
+ readonly metrics: string
13
+ readonly value: string
14
+ readonly unit: string
15
+ readonly actions: string
16
+ readonly subtitle: string
17
+ readonly sparkline: string
18
+ readonly info: string
19
+ readonly infoTrigger: string
20
+ readonly infoBubble: string
21
+ readonly deltaBadge: string
22
+ readonly deltaGlyph: string
23
+ readonly deltaPeriod: string
24
+ }
25
+ export default classes
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `WidgetHeader` — the tier/heading contract, the count tag, the actions slot, and the info glyph's
3
+ * accessibility contract (docs/CONTROLS-SPEC.md §2.2). Mantine-free, so components mount with no
4
+ * `MantineProvider` at all (unlike `stat-card.test.tsx`'s `renderToStaticMarkup` harness).
5
+ *
6
+ * CSS-module class hashes are unavailable under `bun test` (see `app-mobile-nav.test.tsx`'s doc) —
7
+ * nothing here selects on `classes.*`; every query goes through role/accessible-name/text content.
8
+ */
9
+ import { fireEvent, render, screen } from '@testing-library/react'
10
+ import { describe, expect, test } from 'bun:test'
11
+ import { readFileSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+ import { WidgetHeader } from './widget-header'
14
+
15
+ const CSS = readFileSync(join(import.meta.dir, 'widget-header.module.css'), 'utf8')
16
+
17
+ /** The declaration block of one class in this module — CSS-module hashes are unavailable under
18
+ * `bun test`, so the rules themselves are read from the file (same idiom as
19
+ * `theme/layout-rhythm-css.test.ts` and `controls/filter-set.test.tsx`). */
20
+ function block(selector: string): string {
21
+ const start = CSS.indexOf(`${selector} {`)
22
+ expect(start).toBeGreaterThan(-1)
23
+ return CSS.slice(start, CSS.indexOf('}', start))
24
+ }
25
+
26
+ describe('the hero-metric row — value → delta spacing and alignment', () => {
27
+ test('the value→badge gap is the 8px rhythm step, and a TOKEN so density tracks it', () => {
28
+ // It shipped as `gap: 4px`. 4px is the rhythm's smallest step — label-to-thing distance — and at
29
+ // that distance the DeltaBadge read as a superscript hanging off the numeral. A literal would
30
+ // also have frozen the gap while every other spacing in the header moved with the density knob.
31
+ const metrics = block('.metrics')
32
+ expect(metrics).toContain('gap: var(--vx-space-stack-sm)')
33
+ expect(metrics).not.toContain('gap: 4px')
34
+ expect(metrics).not.toMatch(/gap:\s*\d/)
35
+ })
36
+
37
+ test('the badge is CENTRED on the value, not baseline-aligned to it', () => {
38
+ // A DeltaBadge is a padded box with an 11.5px mono label; aligning its TEXT baseline to a 24px
39
+ // KPI numeral's baseline put the box itself low — top edge under the numeral's x-height, bottom
40
+ // edge hanging below its baseline. The `count` tag in the title row already centres for the
41
+ // same reason.
42
+ const metrics = block('.metrics')
43
+ expect(metrics).toContain('align-items: center')
44
+ expect(metrics).not.toContain('align-items: baseline')
45
+ })
46
+ })
47
+
48
+ describe('tier picks the heading level', () => {
49
+ test('section renders an h2', () => {
50
+ render(<WidgetHeader tier="section" title="Overview" />)
51
+ expect(screen.getByRole('heading', { level: 2, name: 'Overview' })).toBeDefined()
52
+ })
53
+
54
+ test('widget renders an h3', () => {
55
+ render(<WidgetHeader tier="widget" title="Active Users" />)
56
+ expect(screen.getByRole('heading', { level: 3, name: 'Active Users' })).toBeDefined()
57
+ })
58
+ })
59
+
60
+ test('count renders a mono tag after the title', () => {
61
+ render(<WidgetHeader tier="section" title="Recent orders" count={42} />)
62
+ expect(screen.getByText('42')).toBeDefined()
63
+ })
64
+
65
+ test('count={0} renders "0" — a real count, distinct from an omitted one', () => {
66
+ render(<WidgetHeader tier="section" title="Recent orders" count={0} />)
67
+ expect(screen.getByText('0')).toBeDefined()
68
+ })
69
+
70
+ test('omitting count renders no tag', () => {
71
+ render(<WidgetHeader tier="section" title="Recent orders" />)
72
+ expect(screen.queryByText('0')).toBeNull()
73
+ })
74
+
75
+ describe('the actions slot renders only when passed', () => {
76
+ test('absent by default', () => {
77
+ render(<WidgetHeader tier="widget" title="Active Users" />)
78
+ expect(screen.queryByRole('button', { name: 'Menu' })).toBeNull()
79
+ })
80
+
81
+ test('present when given', () => {
82
+ render(
83
+ <WidgetHeader
84
+ tier="widget"
85
+ title="Active Users"
86
+ actions={<button type="button">Menu</button>}
87
+ />,
88
+ )
89
+ expect(screen.getByRole('button', { name: 'Menu' })).toBeDefined()
90
+ })
91
+ })
92
+
93
+ describe('the info glyph', () => {
94
+ const INFO = 'Measured over the trailing 30 days.'
95
+
96
+ test("is named `More information` — the text is NOT the button's accessible name", () => {
97
+ render(<WidgetHeader tier="widget" title="Uptime" info={INFO} />)
98
+ expect(screen.getByRole('button', { name: 'More information' })).toBeDefined()
99
+ expect(screen.queryByRole('button', { name: INFO })).toBeNull()
100
+ })
101
+
102
+ test("leaves the heading's accessible name as the title ALONE", () => {
103
+ // The glyph used to live inside the heading, so an `info` paragraph was read out with the title
104
+ // in every headings list. This is the assertion that keeps it outside.
105
+ render(<WidgetHeader tier="widget" title="Uptime" info={INFO} />)
106
+ const heading = screen.getByRole('heading', { level: 3 })
107
+ expect(heading.textContent).toBe('Uptime')
108
+ expect(heading.querySelector('button')).toBeNull()
109
+ expect(screen.getByRole('heading', { level: 3, name: 'Uptime' })).toBeDefined()
110
+ })
111
+
112
+ test('the bubble is closed until asked, and describes the trigger while open', () => {
113
+ render(<WidgetHeader tier="section" title="Uptime" info={INFO} />)
114
+ const trigger = screen.getByRole('button', { name: 'More information' })
115
+ expect(trigger.getAttribute('aria-describedby')).toBeNull()
116
+ expect(screen.queryByRole('tooltip')).toBeNull()
117
+
118
+ fireEvent.focus(trigger)
119
+ const bubble = screen.getByRole('tooltip')
120
+ expect(bubble.textContent).toBe(INFO)
121
+ expect(trigger.getAttribute('aria-describedby')).toBe(bubble.id)
122
+ })
123
+
124
+ test('KEYBOARD reaches it — focus opens, Escape and blur close', () => {
125
+ // A `title` attribute alone rendered on hover only, which is the whole reason this bubble exists.
126
+ render(<WidgetHeader tier="widget" title="Uptime" info={INFO} />)
127
+ const trigger = screen.getByRole('button', { name: 'More information' })
128
+
129
+ fireEvent.focus(trigger)
130
+ expect(screen.getByRole('tooltip')).toBeDefined()
131
+
132
+ fireEvent.keyDown(trigger, { key: 'Escape' })
133
+ expect(screen.queryByRole('tooltip')).toBeNull()
134
+
135
+ fireEvent.focus(trigger)
136
+ fireEvent.blur(trigger)
137
+ expect(screen.queryByRole('tooltip')).toBeNull()
138
+ })
139
+
140
+ test('a pointer-down outside closes it', () => {
141
+ render(<WidgetHeader tier="widget" title="Uptime" info={INFO} />)
142
+ fireEvent.click(screen.getByRole('button', { name: 'More information' }))
143
+ expect(screen.getByRole('tooltip')).toBeDefined()
144
+
145
+ fireEvent.pointerDown(document.body)
146
+ expect(screen.queryByRole('tooltip')).toBeNull()
147
+ })
148
+ })
149
+
150
+ test('omitting info renders no glyph', () => {
151
+ render(<WidgetHeader tier="widget" title="Uptime" />)
152
+ expect(screen.queryByRole('button')).toBeNull()
153
+ })
154
+
155
+ test('value + delta render together, delta via DeltaBadge', () => {
156
+ render(<WidgetHeader tier="widget" title="Active Users" value="12,483" delta={4.2} />)
157
+ expect(screen.getByText('12,483')).toBeDefined()
158
+ expect(screen.getByText('4.2%')).toBeDefined()
159
+ })
160
+
161
+ describe('value + delta render on their own row under the title, for both tiers', () => {
162
+ for (const tier of ['section', 'widget'] as const) {
163
+ test(tier, () => {
164
+ render(<WidgetHeader tier={tier} title="Active Users" value="12,483" delta={4.2} />)
165
+ const valueNode = screen.getByText('12,483')
166
+ // Never inline with the title — the heading holds title/icon/info/count/actions only.
167
+ expect(valueNode.closest('h2, h3')).toBeNull()
168
+ })
169
+ }
170
+ })
171
+
172
+ test('subtitle renders below the title row', () => {
173
+ render(<WidgetHeader tier="section" title="Profile" subtitle="Your public identity." />)
174
+ expect(screen.getByText('Your public identity.')).toBeDefined()
175
+ })
176
+
177
+ test('the sparkline slot carries a data-placement for a future bleed layout', () => {
178
+ render(
179
+ <WidgetHeader
180
+ tier="widget"
181
+ title="Active Users"
182
+ sparkline={<span data-testid="spark">trend</span>}
183
+ />,
184
+ )
185
+ const spark = screen.getByTestId('spark')
186
+ expect(spark.closest('[data-placement]')?.getAttribute('data-placement')).toBe('right')
187
+ })