basalt-ui 1.30.2 → 1.31.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 (334) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +31 -0
  3. package/MIGRATING.md +172 -12
  4. package/agent/rules/basalt-controls.md +19 -19
  5. package/agent/rules/basalt-mantine.md +1 -1
  6. package/configs/oxlint-plugin.js +254 -4
  7. package/configs/oxlint-plugin.test.ts +145 -2
  8. package/configs/oxlint.json +2 -1
  9. package/dist/agent-chat/composer.d.ts.map +1 -1
  10. package/dist/agent-chat/composer.js +41 -43
  11. package/dist/agent-chat/thread-workspace.d.ts +2 -2
  12. package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
  13. package/dist/agent-chat/thread-workspace.js +3 -4
  14. package/dist/agent-chat/use-keyboard-inset.d.ts +2 -0
  15. package/dist/agent-chat/use-keyboard-inset.d.ts.map +1 -0
  16. package/dist/agent-chat/use-keyboard-inset.js +34 -0
  17. package/dist/charts/cursor/touch-pin.d.ts +45 -0
  18. package/dist/charts/cursor/touch-pin.d.ts.map +1 -0
  19. package/dist/charts/cursor/touch-pin.js +79 -0
  20. package/dist/charts/hooks/use-discrete-cursor.d.ts +66 -0
  21. package/dist/charts/hooks/use-discrete-cursor.d.ts.map +1 -0
  22. package/dist/charts/hooks/use-discrete-cursor.js +198 -0
  23. package/dist/charts/hooks/use-hysteresis.d.ts +18 -0
  24. package/dist/charts/hooks/use-hysteresis.d.ts.map +1 -0
  25. package/dist/charts/hooks/use-hysteresis.js +12 -0
  26. package/dist/charts/hooks/useBandPlot.d.ts +7 -0
  27. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -1
  28. package/dist/charts/hooks/useBandPlot.js +62 -18
  29. package/dist/charts/hooks/useChartCursor.d.ts +40 -11
  30. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  31. package/dist/charts/hooks/useChartCursor.js +102 -11
  32. package/dist/charts/index.d.ts +0 -1
  33. package/dist/charts/index.d.ts.map +1 -1
  34. package/dist/charts/index.js +0 -10
  35. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  36. package/dist/charts/kinds/BandStrip.js +54 -41
  37. package/dist/charts/kinds/Donut.d.ts +9 -3
  38. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  39. package/dist/charts/kinds/Donut.js +110 -48
  40. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  41. package/dist/charts/kinds/DualPanel.js +145 -131
  42. package/dist/charts/kinds/Heatmap.d.ts +7 -0
  43. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  44. package/dist/charts/kinds/Heatmap.js +37 -16
  45. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  46. package/dist/charts/kinds/MirroredBars.js +83 -68
  47. package/dist/charts/kinds/donut-layout.d.ts +47 -0
  48. package/dist/charts/kinds/donut-layout.d.ts.map +1 -0
  49. package/dist/charts/kinds/donut-layout.js +52 -0
  50. package/dist/charts/layout/auto-margin.d.ts +7 -2
  51. package/dist/charts/layout/auto-margin.d.ts.map +1 -1
  52. package/dist/charts/layout/auto-margin.js +12 -3
  53. package/dist/charts/layout/end-labels.d.ts +64 -0
  54. package/dist/charts/layout/end-labels.d.ts.map +1 -0
  55. package/dist/charts/layout/end-labels.js +76 -0
  56. package/dist/charts/primitives/Axes.d.ts +32 -4
  57. package/dist/charts/primitives/Axes.d.ts.map +1 -1
  58. package/dist/charts/primitives/Axes.js +62 -16
  59. package/dist/charts/primitives/CartesianChart.d.ts +7 -6
  60. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  61. package/dist/charts/primitives/CartesianChart.js +271 -125
  62. package/dist/charts/primitives/ChartCard.d.ts +0 -14
  63. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  64. package/dist/charts/primitives/ChartCard.js +74 -21
  65. package/dist/charts/primitives/ChartFrame.d.ts +28 -22
  66. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  67. package/dist/charts/primitives/ChartFrame.js +133 -34
  68. package/dist/charts/primitives/ChartLegend.d.ts +19 -15
  69. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  70. package/dist/charts/primitives/ChartLegend.js +212 -71
  71. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  72. package/dist/charts/primitives/ChartTooltip.js +4 -3
  73. package/dist/charts/primitives/Crosshair.js +2 -2
  74. package/dist/charts/primitives/HoverOverlay.d.ts +6 -1
  75. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  76. package/dist/charts/primitives/HoverOverlay.js +37 -26
  77. package/dist/charts/primitives/HoverOverlay.module.css +13 -0
  78. package/dist/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  79. package/dist/charts/primitives/chart-card-context.d.ts +29 -0
  80. package/dist/charts/primitives/chart-card-context.d.ts.map +1 -0
  81. package/dist/charts/primitives/chart-card-context.js +13 -0
  82. package/dist/charts/primitives/chart-frame-layout.d.ts +93 -113
  83. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  84. package/dist/charts/primitives/chart-frame-layout.js +76 -29
  85. package/dist/charts/primitives/chart-layout.d.ts +112 -0
  86. package/dist/charts/primitives/chart-layout.d.ts.map +1 -0
  87. package/dist/charts/primitives/chart-layout.js +175 -0
  88. package/dist/charts/primitives/chart-tier.d.ts +13 -11
  89. package/dist/charts/primitives/chart-tier.d.ts.map +1 -1
  90. package/dist/charts/primitives/chart-tier.js +42 -13
  91. package/dist/charts/series.d.ts +4 -1
  92. package/dist/charts/series.d.ts.map +1 -1
  93. package/dist/charts/utils/ticks.d.ts +27 -8
  94. package/dist/charts/utils/ticks.d.ts.map +1 -1
  95. package/dist/charts/utils/ticks.js +35 -11
  96. package/dist/common/errors.d.ts +11 -1
  97. package/dist/common/errors.d.ts.map +1 -1
  98. package/dist/common/errors.js +16 -2
  99. package/dist/common/hit-floor.d.ts +10 -0
  100. package/dist/common/hit-floor.d.ts.map +1 -0
  101. package/dist/common/hit-floor.js +6 -0
  102. package/dist/{shell → common}/isomorphic-layout-effect.d.ts +4 -3
  103. package/dist/common/isomorphic-layout-effect.d.ts.map +1 -0
  104. package/dist/common/use-measured-widths.d.ts +37 -0
  105. package/dist/common/use-measured-widths.d.ts.map +1 -0
  106. package/dist/common/use-measured-widths.js +51 -0
  107. package/dist/common/use-media-query.d.ts +6 -0
  108. package/dist/common/use-media-query.d.ts.map +1 -0
  109. package/dist/common/use-media-query.js +40 -0
  110. package/dist/common/validate.d.ts +9 -0
  111. package/dist/common/validate.d.ts.map +1 -1
  112. package/dist/common/validate.js +9 -4
  113. package/dist/content/article-layout.d.ts +4 -2
  114. package/dist/content/article-layout.d.ts.map +1 -1
  115. package/dist/content/article-layout.js +43 -3
  116. package/dist/content/article-layout.module.css +45 -4
  117. package/dist/content/article-layout.module.css.d.ts +3 -0
  118. package/dist/content/code-block.d.ts.map +1 -1
  119. package/dist/content/code-block.js +40 -2
  120. package/dist/content/code-block.module.css +32 -2
  121. package/dist/content/prose.module.css +13 -8
  122. package/dist/controls/actions.d.ts +29 -6
  123. package/dist/controls/actions.d.ts.map +1 -1
  124. package/dist/controls/actions.js +158 -27
  125. package/dist/controls/actions.module.css +14 -1
  126. package/dist/controls/actions.module.css.d.ts +1 -0
  127. package/dist/controls/controls.module.css +8 -31
  128. package/dist/controls/enum-filter.js +2 -2
  129. package/dist/controls/filter-context.d.ts +10 -0
  130. package/dist/controls/filter-context.d.ts.map +1 -1
  131. package/dist/controls/filter-context.js +2 -0
  132. package/dist/controls/filter-pill.d.ts.map +1 -1
  133. package/dist/controls/filter-pill.js +1 -0
  134. package/dist/controls/index.d.ts +1 -1
  135. package/dist/controls/index.d.ts.map +1 -1
  136. package/dist/controls/index.js +0 -2
  137. package/dist/controls/multi-select-filter.js +2 -2
  138. package/dist/controls/sync-button.module.css +1 -1
  139. package/dist/dashboard/section.d.ts.map +1 -1
  140. package/dist/dashboard/section.js +1 -0
  141. package/dist/dashboard/section.module.css +1 -26
  142. package/dist/dashboard/stat-card.d.ts +1 -1
  143. package/dist/dashboard/stat-card.d.ts.map +1 -1
  144. package/dist/dashboard/stat-card.js +4 -1
  145. package/dist/dashboard/stat-card.module.css +6 -9
  146. package/dist/dashboard/stat-group.module.css +16 -4
  147. package/dist/dashboard/widget-grid.d.ts.map +1 -1
  148. package/dist/dashboard/widget-header.d.ts +14 -4
  149. package/dist/dashboard/widget-header.d.ts.map +1 -1
  150. package/dist/dashboard/widget-header.js +23 -6
  151. package/dist/dashboard/widget-header.module.css +145 -133
  152. package/dist/dashboard/widget-header.module.css.d.ts +2 -0
  153. package/dist/data/data-table.d.ts +23 -0
  154. package/dist/data/data-table.d.ts.map +1 -1
  155. package/dist/data/data-table.js +339 -141
  156. package/dist/data/data-table.module.css +94 -0
  157. package/dist/data/data-table.module.css.d.ts +11 -0
  158. package/dist/data/virtual-list.d.ts.map +1 -1
  159. package/dist/data/virtual-list.js +10 -0
  160. package/dist/forms/form-layout.d.ts.map +1 -1
  161. package/dist/forms/form-layout.js +3 -3
  162. package/dist/forms/form-layout.module.css +30 -5
  163. package/dist/forms/form-layout.module.css.d.ts +1 -0
  164. package/dist/guard/index.d.ts +8 -2
  165. package/dist/guard/index.d.ts.map +1 -1
  166. package/dist/guard/index.js +67 -2
  167. package/dist/guard/types.d.ts +3 -3
  168. package/dist/guard/types.d.ts.map +1 -1
  169. package/dist/index.d.ts +1 -1
  170. package/dist/index.d.ts.map +1 -1
  171. package/dist/index.js +3 -1
  172. package/dist/provider/index.d.ts +20 -1
  173. package/dist/provider/index.d.ts.map +1 -1
  174. package/dist/provider/index.js +22 -2
  175. package/dist/shell/app-brand.d.ts.map +1 -1
  176. package/dist/shell/app-brand.js +1 -0
  177. package/dist/shell/app-brand.module.css +2 -2
  178. package/dist/shell/app-header.module.css +18 -8
  179. package/dist/shell/app-main.module.css +54 -3
  180. package/dist/shell/app-main.module.css.d.ts +1 -0
  181. package/dist/shell/app-mobile-nav.module.css +16 -4
  182. package/dist/shell/app-sidebar.module.css +4 -6
  183. package/dist/shell/index.d.ts +12 -0
  184. package/dist/shell/index.d.ts.map +1 -1
  185. package/dist/shell/index.js +57 -16
  186. package/dist/shell/mobile-nav-model.d.ts +1 -1
  187. package/dist/shell/page-aside.d.ts +22 -3
  188. package/dist/shell/page-aside.d.ts.map +1 -1
  189. package/dist/shell/page-aside.js +27 -13
  190. package/dist/shell/page-bar.d.ts.map +1 -1
  191. package/dist/shell/page-bar.js +10 -5
  192. package/dist/shell/page-bar.module.css +10 -34
  193. package/dist/shell/use-breakpoint.d.ts +3 -4
  194. package/dist/shell/use-breakpoint.d.ts.map +1 -1
  195. package/dist/shell/use-breakpoint.js +3 -25
  196. package/dist/shell/use-size-class.d.ts +6 -0
  197. package/dist/shell/use-size-class.d.ts.map +1 -0
  198. package/dist/shell/use-size-class.js +19 -0
  199. package/dist/state/persisted-or-local.d.ts +4 -1
  200. package/dist/state/persisted-or-local.d.ts.map +1 -1
  201. package/dist/state/persisted-or-local.js +4 -3
  202. package/dist/state/persisted.d.ts +10 -0
  203. package/dist/state/persisted.d.ts.map +1 -1
  204. package/dist/state/persisted.js +44 -14
  205. package/dist/styles.css +41 -0
  206. package/dist/surfaces.d.ts +4 -4
  207. package/dist/surfaces.d.ts.map +1 -1
  208. package/dist/surfaces.js +11 -3
  209. package/dist/theme/controls.module.css +7 -0
  210. package/dist/theme/ctl-theme.d.ts +1 -0
  211. package/dist/theme/ctl-theme.d.ts.map +1 -1
  212. package/dist/theme/ctl-theme.js +12 -4
  213. package/dist/theme/floating.module.css +36 -0
  214. package/dist/theme/floating.module.css.d.ts +3 -0
  215. package/dist/theme/index.d.ts +2 -2
  216. package/dist/theme/index.d.ts.map +1 -1
  217. package/dist/theme/index.js +34 -5
  218. package/dist/theme/nav-link.module.css +7 -0
  219. package/dist/tokens/index.d.ts +7 -16
  220. package/dist/tokens/index.d.ts.map +1 -1
  221. package/dist/tokens/index.js +23 -28
  222. package/dist/tokens/palette.d.ts +5 -27
  223. package/dist/tokens/palette.d.ts.map +1 -1
  224. package/dist/tokens/palette.js +8 -30
  225. package/dist/tokens/size-classes.d.ts +40 -0
  226. package/dist/tokens/size-classes.d.ts.map +1 -0
  227. package/dist/tokens/size-classes.js +29 -0
  228. package/dist/tokens.css +8 -4
  229. package/llms.txt +3 -3
  230. package/package.json +17 -17
  231. package/src/agent-chat/composer.tsx +74 -47
  232. package/src/agent-chat/thread-workspace.tsx +8 -11
  233. package/src/agent-chat/use-keyboard-inset.ts +68 -0
  234. package/src/charts/cursor/touch-pin.ts +138 -0
  235. package/src/charts/hooks/use-discrete-cursor.ts +335 -0
  236. package/src/charts/hooks/use-hysteresis.ts +26 -0
  237. package/src/charts/hooks/useBandPlot.ts +90 -27
  238. package/src/charts/hooks/useChartCursor.ts +211 -26
  239. package/src/charts/index.ts +0 -8
  240. package/src/charts/kinds/BandStrip.tsx +11 -1
  241. package/src/charts/kinds/Donut.tsx +153 -62
  242. package/src/charts/kinds/DualPanel.tsx +19 -4
  243. package/src/charts/kinds/Heatmap.tsx +61 -22
  244. package/src/charts/kinds/MirroredBars.tsx +15 -3
  245. package/src/charts/kinds/donut-layout.ts +82 -0
  246. package/src/charts/layout/auto-margin.ts +24 -5
  247. package/src/charts/layout/end-labels.ts +135 -0
  248. package/src/charts/primitives/Axes.tsx +112 -21
  249. package/src/charts/primitives/CartesianChart.tsx +282 -68
  250. package/src/charts/primitives/ChartCard.tsx +141 -48
  251. package/src/charts/primitives/ChartFrame.tsx +243 -60
  252. package/src/charts/primitives/ChartLegend.tsx +257 -73
  253. package/src/charts/primitives/ChartTooltip.tsx +8 -3
  254. package/src/charts/primitives/Crosshair.tsx +2 -2
  255. package/src/charts/primitives/HoverOverlay.module.css +13 -0
  256. package/src/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  257. package/src/charts/primitives/HoverOverlay.tsx +15 -4
  258. package/src/charts/primitives/chart-card-context.ts +37 -0
  259. package/src/charts/primitives/chart-frame-layout.ts +214 -177
  260. package/src/charts/primitives/chart-layout.ts +318 -0
  261. package/src/charts/primitives/chart-tier.tsx +66 -21
  262. package/src/charts/series.ts +4 -1
  263. package/src/charts/utils/ticks.ts +95 -19
  264. package/src/common/errors.ts +25 -1
  265. package/src/common/hit-floor.ts +9 -0
  266. package/src/{shell → common}/isomorphic-layout-effect.ts +4 -3
  267. package/src/common/use-measured-widths.ts +88 -0
  268. package/src/common/use-media-query.ts +56 -0
  269. package/src/common/validate.ts +17 -4
  270. package/src/content/article-layout.module.css +45 -4
  271. package/src/content/article-layout.module.css.d.ts +3 -0
  272. package/src/content/article-layout.tsx +60 -5
  273. package/src/content/code-block.module.css +32 -2
  274. package/src/content/code-block.tsx +47 -3
  275. package/src/content/prose.module.css +13 -8
  276. package/src/controls/actions.module.css +14 -1
  277. package/src/controls/actions.module.css.d.ts +1 -0
  278. package/src/controls/actions.tsx +223 -75
  279. package/src/controls/controls.module.css +8 -31
  280. package/src/controls/enum-filter.tsx +2 -2
  281. package/src/controls/filter-context.tsx +11 -0
  282. package/src/controls/filter-pill.tsx +1 -0
  283. package/src/controls/index.ts +0 -1
  284. package/src/controls/multi-select-filter.tsx +2 -2
  285. package/src/controls/sync-button.module.css +1 -1
  286. package/src/dashboard/section.module.css +1 -26
  287. package/src/dashboard/section.tsx +1 -0
  288. package/src/dashboard/stat-card.module.css +6 -9
  289. package/src/dashboard/stat-card.tsx +6 -3
  290. package/src/dashboard/stat-group.module.css +16 -4
  291. package/src/dashboard/widget-grid.tsx +7 -6
  292. package/src/dashboard/widget-header.module.css +145 -133
  293. package/src/dashboard/widget-header.module.css.d.ts +2 -0
  294. package/src/dashboard/widget-header.tsx +45 -11
  295. package/src/data/data-table.module.css +94 -0
  296. package/src/data/data-table.module.css.d.ts +11 -0
  297. package/src/data/data-table.tsx +503 -164
  298. package/src/data/virtual-list.tsx +31 -0
  299. package/src/forms/form-layout.module.css +30 -5
  300. package/src/forms/form-layout.module.css.d.ts +1 -0
  301. package/src/forms/form-layout.tsx +12 -10
  302. package/src/guard/index.ts +183 -3
  303. package/src/guard/types.ts +3 -2
  304. package/src/index.ts +2 -0
  305. package/src/provider/index.tsx +53 -1
  306. package/src/shell/app-brand.module.css +2 -2
  307. package/src/shell/app-brand.tsx +1 -0
  308. package/src/shell/app-header.module.css +18 -8
  309. package/src/shell/app-main.module.css +54 -3
  310. package/src/shell/app-main.module.css.d.ts +1 -0
  311. package/src/shell/app-mobile-nav.module.css +16 -4
  312. package/src/shell/app-sidebar.module.css +4 -6
  313. package/src/shell/index.tsx +200 -129
  314. package/src/shell/mobile-nav-model.ts +1 -1
  315. package/src/shell/page-aside.tsx +70 -21
  316. package/src/shell/page-bar.module.css +10 -34
  317. package/src/shell/page-bar.tsx +10 -5
  318. package/src/shell/use-breakpoint.ts +9 -45
  319. package/src/shell/use-size-class.ts +31 -0
  320. package/src/state/persisted-or-local.ts +10 -4
  321. package/src/state/persisted.ts +76 -17
  322. package/src/styles.css +41 -0
  323. package/src/surfaces.ts +9 -1
  324. package/src/theme/controls.module.css +7 -0
  325. package/src/theme/ctl-theme.tsx +15 -3
  326. package/src/theme/floating.module.css +36 -0
  327. package/src/theme/floating.module.css.d.ts +3 -0
  328. package/src/theme/index.ts +46 -8
  329. package/src/theme/nav-link.module.css +7 -0
  330. package/src/tokens/index.ts +20 -30
  331. package/src/tokens/palette.ts +6 -28
  332. package/src/tokens/size-classes.ts +46 -0
  333. package/dist/shell/isomorphic-layout-effect.d.ts.map +0 -1
  334. /package/dist/{shell → common}/isomorphic-layout-effect.js +0 -0
@@ -13,7 +13,10 @@
13
13
  * WHERE it renders is decided by context and viewport, never by a prop:
14
14
  *
15
15
  * - **Inside `BasaltShell`, from `sm` up** the panel portals into `AppShell.Aside`, folding to a
16
- * `appShellAsideRailWidth` rail with one expand button.
16
+ * `appShellAsideRailWidth` rail with one expand button. It DOCKS (pushes main) only while main
17
+ * keeps its floor width; otherwise it opens as an overlay over main and starts folded. The overlay
18
+ * is a persistent panel closed by its own fold button, or by Escape (the shell's keyboard escape
19
+ * hatch — `shell/index.tsx`'s `asideOverlay` effect) — it is not click-away dismissable.
17
20
  * - **Below `sm`, with a `PageBar` that renders a row 2**, the panel PROJECTS into that row: it
18
21
  * registers its title and glyph with the page-bar slot, renders no node of its own, and row 2
19
22
  * draws one `Panel` pill opening a `FilterSheet` its children portal into. One node at a time —
@@ -43,18 +46,17 @@
43
46
  * read stays in JS — through `useSyncExternalStore`, so SSR, hydration and the first paint agree.
44
47
  * Recorded in `docs/CONTROLS-SPEC.md` §1 (C9) and `docs/ASIDE-SPEC.md` §0.
45
48
  */
46
- import { createContext, useCallback, useContext, useMemo, useState } from 'react'
49
+ import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
47
50
  import type { ReactNode } from 'react'
48
51
  import { createPortal } from 'react-dom'
49
- import { useMantineTheme } from '@mantine/core'
50
52
  import { cx } from '../common/props'
51
53
  import type { BasaltProps, SlotStylesProps } from '../common/props'
52
54
  import { assertRequiredProps } from '../common/validate'
53
55
  import { usePersistedOrLocal } from '../state/persisted-or-local'
54
56
  import { FilterSetScope } from '../controls/filter-context'
55
57
  import { useAsidePanelSlot } from './page-bar'
56
- import { useIsomorphicLayoutEffect } from './isomorphic-layout-effect'
57
- import { useMediaQueryMatches } from './use-breakpoint'
58
+ import { useIsomorphicLayoutEffect } from '../common/isomorphic-layout-effect'
59
+ import { useSizeClass } from './use-size-class'
58
60
  import classes from './page-aside.module.css'
59
61
 
60
62
  type AsideRegion = {
@@ -70,6 +72,14 @@ type AsideRegion = {
70
72
  folded: boolean
71
73
  claim: () => () => void
72
74
  publishFolded: (folded: boolean) => void
75
+ /**
76
+ * Bumped by `requestClose` — never 0 again once a close has been requested, so `PageAside` can
77
+ * treat "changed since mount" as "fold now" without a ref of its own. `ShellFrame` is the one
78
+ * caller, on Escape while the aside renders as an overlay.
79
+ */
80
+ closeSignal: number
81
+ /** Asks the claiming `PageAside` to fold — the keyboard escape hatch for the overlay form. */
82
+ requestClose: () => void
73
83
  }
74
84
 
75
85
  const NO_REGION: AsideRegion = {
@@ -80,6 +90,8 @@ const NO_REGION: AsideRegion = {
80
90
  folded: false,
81
91
  claim: () => () => {},
82
92
  publishFolded: () => {},
93
+ closeSignal: 0,
94
+ requestClose: () => {},
83
95
  }
84
96
 
85
97
  const AsideContext = createContext<AsideRegion>(NO_REGION)
@@ -92,6 +104,7 @@ export function AsideProvider({ children }: { children: ReactNode }): ReactNode
92
104
  const [target, setTarget] = useState<HTMLElement | null>(null)
93
105
  const [claims, setClaims] = useState(0)
94
106
  const [folded, setFolded] = useState(false)
107
+ const [closeSignal, setCloseSignal] = useState(0)
95
108
 
96
109
  const claim = useCallback(() => {
97
110
  setClaims((n) => n + 1)
@@ -102,6 +115,8 @@ export function AsideProvider({ children }: { children: ReactNode }): ReactNode
102
115
  }
103
116
  }, [])
104
117
 
118
+ const requestClose = useCallback(() => setCloseSignal((n) => n + 1), [])
119
+
105
120
  const value: AsideRegion = {
106
121
  target,
107
122
  setTarget,
@@ -110,17 +125,28 @@ export function AsideProvider({ children }: { children: ReactNode }): ReactNode
110
125
  folded,
111
126
  claim,
112
127
  publishFolded: setFolded,
128
+ closeSignal,
129
+ requestClose,
113
130
  }
114
131
 
115
132
  return <AsideContext.Provider value={value}>{children}</AsideContext.Provider>
116
133
  }
117
134
 
118
- /** Internal — the region's state, read by `BasaltShell` to size `AppShell.Aside`. */
119
- export function useAsideRegion(): Pick<AsideRegion, 'claimed' | 'folded'> {
120
- const { claimed, folded } = useContext(AsideContext)
121
- return { claimed, folded }
135
+ /** Internal — the region's state, read by `BasaltShell` to size `AppShell.Aside` and to request a
136
+ * close (the Escape hatch on the overlay form). */
137
+ export function useAsideRegion(): Pick<AsideRegion, 'claimed' | 'folded' | 'requestClose'> {
138
+ const { claimed, folded, requestClose } = useContext(AsideContext)
139
+ return { claimed, folded, requestClose }
122
140
  }
123
141
 
142
+ /**
143
+ * Internal — whether an OPEN aside would push the main column (expanded, main keeps its floor)
144
+ * rather than overlay it. Provided by `ShellFrame`, which alone knows the navbar width, from the
145
+ * same render that computes it: a state published after mount would make the first pass read
146
+ * "cannot dock" and fold a default-open aside for one commit.
147
+ */
148
+ export const AsideDocksContext = createContext(false)
149
+
124
150
  /** Internal — the node inside `AppShell.Aside` the active page's panel portals into. */
125
151
  export function AsideOutlet({ className }: { className?: string }) {
126
152
  const { setTarget } = useContext(AsideContext)
@@ -136,7 +162,10 @@ export type PageAsideProps = BasaltProps &
136
162
  title: string
137
163
  /** Persists the fold at `basalt:aside:<persistKey>`. Omit for an unpersisted fold. */
138
164
  persistKey?: string
139
- /** Fold state on first render, respected only while nothing is persisted. @default false */
165
+ /**
166
+ * The fold while the user has not chosen one — an explicit (persisted or toggled) choice always
167
+ * wins. @default open where the aside docks beside main, folded where it would overlay it
168
+ */
140
169
  defaultFolded?: boolean
141
170
  children: ReactNode
142
171
  }
@@ -214,26 +243,25 @@ export function PageAside(props: PageAsideProps): ReactNode {
214
243
  assertRequiredProps('PageAside', props, ['title'], {
215
244
  title: 'it names the region — it is the header text AND the `aria-label` on the landmark.',
216
245
  })
217
- const { title, persistKey, defaultFolded = false, children, className, style, classNames } = props
218
- const { target, inShell, claim, publishFolded } = useContext(AsideContext)
246
+ const { title, persistKey, defaultFolded, children, className, style, classNames } = props
247
+ const { target, inShell, claim, publishFolded, closeSignal } = useContext(AsideContext)
248
+ const docks = useContext(AsideDocksContext)
219
249
  // Destructured, not held as one object: the hook returns a fresh literal every render, and the
220
250
  // claim effect below would then re-run (claim → release → claim) forever. `claimPanel` is a
221
251
  // `useCallback` on the provider, so it is the stable half.
222
252
  const { host: panelHost, claim: claimPanel, target: panelTarget } = useAsidePanelSlot()
223
- const theme = useMantineTheme()
224
- // `sm` is the only breakpoint (`docs/CONTROLS-SPEC.md` §2), read off the theme so a consumer that
225
- // retunes it moves the aside with it. THE declared C9 exception (see this file's header): the
226
- // read is JS because the two projections are two portal targets under two filter surfaces, and a
227
- // CSS twin would double-mount stateful children. `useSyncExternalStore` is what keeps SSR, the
228
- // hydration pass and the first client paint agreeing on which single node exists.
229
- const desktop = useMediaQueryMatches(`(min-width: ${theme.breakpoints.sm})`, true)
253
+ // THE declared C9 exception (see this file's header): the read is JS because the two projections
254
+ // are two portal targets under two filter surfaces, and a CSS twin would double-mount stateful
255
+ // children. `useSizeClass` (`useSyncExternalStore`) keeps SSR, the hydration pass and the first
256
+ // client paint agreeing on which single node exists.
257
+ const isCompact = useSizeClass() === 'compact'
230
258
  const [folded, setFolded] = usePersistedOrLocal({
231
259
  scope: 'aside',
232
260
  persistKey,
233
- initial: defaultFolded,
261
+ initial: defaultFolded ?? !docks,
234
262
  })
235
263
 
236
- const portalled = inShell && desktop
264
+ const portalled = inShell && !isCompact
237
265
  // Below `sm` the panel goes into the page bar's row 2 IF there is one. Without a row 2 there is
238
266
  // nowhere to put a trigger, so the honest in-flow form (wave 1) stays.
239
267
  const projected = !portalled && panelHost
@@ -268,6 +296,27 @@ export function PageAside(props: PageAsideProps): ReactNode {
268
296
  publishFolded(folded)
269
297
  }, [portalled, folded, publishFolded])
270
298
 
299
+ // The Escape hatch (`shell/index.tsx`'s `asideOverlay` effect): `closeSignal` is monotonic and
300
+ // shared across every `PageAside` the shell ever mounts, so "past the initial 0" is the wrong
301
+ // question for any instance mounted AFTER the first Escape elsewhere in the app's lifetime — it
302
+ // would read the ALREADY-bumped signal as "a close was requested" on its very first commit and
303
+ // fold before the user ever asked. `closeSignalBaseline` captures the value THIS instance mounted
304
+ // with, so only a change AFTER that point — a close requested while this instance is live — folds
305
+ // it; a later-mounted instance still respects its own `defaultFolded`/persisted state. Guarded on
306
+ // `portalled`, matching where `ShellFrame` can even observe an overlay to escape out of.
307
+ //
308
+ // The baseline ADVANCES to every `closeSignal` it acts on — not just at mount. Without that, a
309
+ // portalled false→true transition (the aside leaving and rejoining the shell, e.g. a viewport
310
+ // resize crossing `sm`) re-runs this effect with `closeSignal` still sitting on the value the
311
+ // FIRST Escape already bumped it to, which the baseline — frozen at its pre-Escape value — reads
312
+ // as "a close was just requested" all over again and folds an aside the user had since reopened.
313
+ const closeSignalBaseline = useRef(closeSignal)
314
+ useIsomorphicLayoutEffect(() => {
315
+ if (!portalled || closeSignal === closeSignalBaseline.current) return
316
+ closeSignalBaseline.current = closeSignal
317
+ setFolded(true)
318
+ }, [portalled, closeSignal])
319
+
271
320
  useIsomorphicLayoutEffect(() => {
272
321
  if (!projected) return
273
322
  return claimPanel(panelClaim)
@@ -46,6 +46,8 @@
46
46
  }
47
47
 
48
48
  .row1 {
49
+ /* Fills the header's elastic outlet, so `.row1End` below is the ROOM the action fold measures. */
50
+ width: 100%;
49
51
  display: flex;
50
52
  flex-wrap: nowrap;
51
53
  align-items: center;
@@ -188,42 +190,16 @@
188
190
  }
189
191
 
190
192
  /*
191
- * Law C15 — every touch target inside a home is at least `touchControlHeight` (36px at level 0)
192
- * below `sm`, while the VISIBLE box stays the 30px `ctl` tier. `::after` and not `::before`:
193
- * Mantine's Button owns `::before` for its loading overlay (`@mantine/core/styles/Button.css`),
194
- * so a hit area drawn there would fight it. `pointer-events` stays default so the overlay is the
195
- * one receiving the tap.
196
- *
197
- * `overflow: visible` is LOAD-BEARING, not tidying. Mantine's Button and ActionIcon roots both set
198
- * `overflow: hidden` (`Button.css`, `ActionIcon.css`), which clips an overlay that by definition
199
- * extends past the 30px box — so without this the 36px target simply does not exist and a tap 3px
200
- * above the control misses. What `overflow: hidden` buys those roots is clipping the loading
201
- * overlay's blur, and that overlay is only painted under `[data-loading]`, which is excluded below.
193
+ * Law C15 — controls in a home adopt the shared `[data-basalt-hit]` overlay (`styles.css`) through
194
+ * `data-basalt-hit-scope` on `.row1`/`.row2`; `--vx-hit-gap` caps the expansion at the real gap so
195
+ * neighbours' hit boxes never overlap. The VISIBLE box stays the 30px `ctl` tier.
202
196
  */
203
- @media (max-width: 47.99375em) {
204
- .row1 :is(button, a),
205
- .row2 :is(button, a) {
206
- position: relative;
207
- }
208
-
209
- .row1 :is(button, a):not([data-loading]),
210
- .row2 :is(button, a):not([data-loading]) {
211
- overflow: visible;
212
- }
213
-
214
- .row1 :is(button, a)::after,
215
- .row2 :is(button, a)::after {
216
- content: '';
217
- position: absolute;
218
- top: 50%;
219
- left: 50%;
220
- translate: -50% -50%;
221
- width: 100%;
222
- height: 100%;
223
- min-width: var(--vx-space-touch-control-height);
224
- min-height: var(--vx-space-touch-control-height);
225
- }
197
+ .row1,
198
+ .row2 {
199
+ --vx-hit-gap: var(--vx-space-control-gap);
200
+ }
226
201
 
202
+ @media (max-width: 52.49375em) {
227
203
  /*
228
204
  * Row 2's phone form: two declared lines, tabs then pills — not a wrap (law C7). `.pills` folds
229
205
  * every overflow into `Filters (n)` at any width, EXCEPT when it holds nothing visible: below `sm`
@@ -28,13 +28,18 @@ import { createPortal } from 'react-dom'
28
28
  import { cx } from '../common/props'
29
29
  import type { BasaltProps, SlotStylesProps } from '../common/props'
30
30
  import { CtlSlot } from '../theme'
31
- import { BarActionRow, BarExtrasProvider, globalActionAsBarAction } from '../controls/actions'
31
+ import {
32
+ BarActionRow,
33
+ BarExtrasProvider,
34
+ PAGE_BAR_END_ATTR,
35
+ globalActionAsBarAction,
36
+ } from '../controls/actions'
32
37
  import { FilterPill } from '../controls/filter-pill'
33
38
  import { FilterSheet } from '../controls/filter-sheet'
34
39
  import { SyncButton } from '../controls/sync-button'
35
40
  import type { ActionGroupProps, BarAction, GlobalAction } from '../controls/actions'
36
41
  import type { SyncButtonProps } from '../controls/sync-button'
37
- import { useIsomorphicLayoutEffect } from './isomorphic-layout-effect'
42
+ import { useIsomorphicLayoutEffect } from '../common/isomorphic-layout-effect'
38
43
  import classes from './page-bar.module.css'
39
44
  import asideClasses from './page-aside.module.css'
40
45
 
@@ -388,7 +393,7 @@ export function PageBar({
388
393
  }, [inShell, hasRow2, claimPanelHost])
389
394
 
390
395
  const row1 = hasRow1 ? (
391
- <div className={cx(classes.row1, classNames?.row1)}>
396
+ <div className={cx(classes.row1, classNames?.row1)} data-basalt-hit-scope>
392
397
  {hasLead && (
393
398
  <div className={classes.lead}>
394
399
  {icon !== undefined && (
@@ -399,7 +404,7 @@ export function PageBar({
399
404
  {title !== undefined && <h1 className={classes.title}>{title}</h1>}
400
405
  </div>
401
406
  )}
402
- <div className={classes.row1End}>
407
+ <div className={classes.row1End} {...{ [PAGE_BAR_END_ATTR]: '' }}>
403
408
  {(actions !== undefined || filtersEndActions.length > 0 || sync !== undefined) && (
404
409
  <CtlSlot>
405
410
  {/* THE row-1 group: the only `host: 'page'` instance, so the shell's `mobile: 'more'`
@@ -430,7 +435,7 @@ export function PageBar({
430
435
  const hasPills = filters !== undefined || panel !== null || filtersEndActions.length > 0
431
436
 
432
437
  const row2 = hasRow2 ? (
433
- <div className={cx(classes.row2, classNames?.row2)}>
438
+ <div className={cx(classes.row2, classNames?.row2)} data-basalt-hit-scope>
434
439
  {tabs !== undefined && (
435
440
  <div className={classes.tabs} data-basalt-page-bar-line="tabs">
436
441
  <CtlSlot>{tabs}</CtlSlot>
@@ -1,21 +1,12 @@
1
1
  /**
2
- * `useBreakpoint` — one media-query hook over the theme's own breakpoints, SSR/hydration-safe.
3
- *
4
- * Built on `useSyncExternalStore`, NOT `@mantine/hooks`' `useMediaQuery` — that hook reads
5
- * `matchMedia` inside a `useState` initializer, so on the server it returns its `fallback` and on
6
- * the client's very first render it returns the REAL match; when that render is a hydration pass
7
- * the two disagree silently (a server-rendered desktop layout hydrating on a phone against a
8
- * client tree that already moved). `useSyncExternalStore`'s `getServerSnapshot` makes React render
9
- * the server's answer during hydration and correct itself immediately after — the documented,
10
- * warning-free version of the same fix. `page-aside.tsx`'s own former private
11
- * `useMediaQueryMatches` carried this exact rationale; it now lives here and page-aside composes
12
- * this hook instead (C5 consolidation — one media-query hook, not two).
2
+ * `useBreakpoint` — DEPRECATED (on notice in `MIGRATING.md`, removed next minor): use
3
+ * `useSizeClass` from `./use-size-class`. One media-query hook over the theme's own breakpoints,
4
+ * built on the shared `useMediaQuery` (`common/use-media-query`) — SSR/hydration-safe.
13
5
  *
14
6
  * `edge` defaults to `'min'` (`"at least this wide"`); pass `'max'` for `"narrower than"`. The
15
7
  * `fallback` (what the server, and a shim with no `matchMedia`, sees) defaults to the answer that
16
8
  * assumes the NARROW side of the query — `false` for `min`, `true` for `max` — since a component
17
- * library cannot know a consumer's real device mix; a call site with its own rationale (e.g.
18
- * `page-aside.tsx`'s desktop-first portal fallback) overrides it via `options.fallback`.
9
+ * library cannot know a consumer's real device mix; `options.fallback` overrides it.
19
10
  *
20
11
  * @example
21
12
  * import { useBreakpoint } from 'basalt-ui'
@@ -24,43 +15,16 @@
24
15
  * const isNarrow = !useBreakpoint('sm') // < theme.breakpoints.sm
25
16
  * const isWide = useBreakpoint('lg') // >= theme.breakpoints.lg
26
17
  */
27
- import { useCallback, useMemo, useSyncExternalStore } from 'react'
28
18
  import { useMantineTheme } from '@mantine/core'
19
+ import { useMediaQuery } from '../common/use-media-query'
29
20
 
30
21
  export type BreakpointName = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
31
22
 
32
- /** `window.matchMedia`, or `null` where there is no window and on a shim that does not ship it. */
33
- function mediaQueryList(query: string): MediaQueryList | null {
34
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return null
35
- return window.matchMedia(query)
36
- }
37
-
38
23
  /**
39
- * Internal — the raw media-query read. Exported (not `default`) so `page-aside.tsx` can compose
40
- * it directly for its `(min-width: theme.breakpoints.sm)` desktop check without re-deriving the
41
- * query string `useBreakpoint` builds for the fixed `BreakpointName` union.
24
+ * @deprecated Use `useSizeClass` — removed next minor (see `MIGRATING.md`). `theme.breakpoints` is
25
+ * now derived: `sm` moved 48em → 52.5em and `md`/`lg`/`xl` all sit at 75em (`xs`/`sm` collapse,
26
+ * `md`/`lg`/`xl` collapse).
42
27
  */
43
- export function useMediaQueryMatches(query: string, fallback: boolean): boolean {
44
- // ONE `MediaQueryList` per query, held across renders — `matchMedia()` allocates a fresh live
45
- // object on every call, and `getSnapshot` runs on every render AND after every store
46
- // notification, so calling it there would churn an object per read while `subscribe` listened
47
- // on a different instance than `getSnapshot` measured.
48
- const list = useMemo(() => mediaQueryList(query), [query])
49
- const subscribe = useCallback(
50
- (onStoreChange: () => void) => {
51
- if (list === null) return () => {}
52
- list.addEventListener('change', onStoreChange)
53
- return () => {
54
- list.removeEventListener('change', onStoreChange)
55
- }
56
- },
57
- [list],
58
- )
59
- const getSnapshot = useCallback(() => list?.matches ?? fallback, [list, fallback])
60
- const getServerSnapshot = useCallback(() => fallback, [fallback])
61
- return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
62
- }
63
-
64
28
  export function useBreakpoint(
65
29
  name: BreakpointName,
66
30
  edge: 'min' | 'max' = 'min',
@@ -69,5 +33,5 @@ export function useBreakpoint(
69
33
  const theme = useMantineTheme()
70
34
  const bp = theme.breakpoints[name]
71
35
  const fallback = options?.fallback ?? edge === 'max'
72
- return useMediaQueryMatches(`(${edge}-width: ${bp})`, fallback)
36
+ return useMediaQuery(`(${edge}-width: ${bp})`, fallback)
73
37
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `useSizeClass` — the viewport axis of the responsive law (`docs/DESIGN-CORE.md` § Layout,
3
+ * elevation, shapes):
4
+ * `'compact' | 'medium' | 'expanded'`, for the JS reads CSS cannot express (portal targets,
5
+ * mount-one-of-two branches). Shell-only — components size by container, not by this.
6
+ *
7
+ * Built on `useMediaQuery` (`common/use-media-query`), one call per class boundary. The server
8
+ * snapshot is `<BasaltProvider sizeClassHint>` (default `'compact'`), so SSR, the hydration pass
9
+ * and the first client paint agree, and the real class lands one commit later — no first-frame
10
+ * flash from a `matchMedia` read inside a `useState` initializer.
11
+ */
12
+ import { createContext, useContext } from 'react'
13
+ import { useMediaQuery } from '../common/use-media-query'
14
+ import { SIZE_CLASSES, toEm } from '../tokens/size-classes'
15
+
16
+ export type SizeClass = keyof typeof SIZE_CLASSES
17
+
18
+ /** Provided by `BasaltProvider`'s `sizeClassHint`; the server snapshot. `undefined` = no provider. */
19
+ export const SizeClassHintContext = createContext<SizeClass | undefined>(undefined)
20
+
21
+ export function useSizeClass(): SizeClass {
22
+ const hint = useContext(SizeClassHintContext) ?? 'compact'
23
+ const isMedium = useMediaQuery(`(min-width: ${toEm(SIZE_CLASSES.medium)})`, hint !== 'compact')
24
+ const isExpanded = useMediaQuery(
25
+ `(min-width: ${toEm(SIZE_CLASSES.expanded)})`,
26
+ hint === 'expanded',
27
+ )
28
+ if (isExpanded) return 'expanded'
29
+ if (isMedium) return 'medium'
30
+ return 'compact'
31
+ }
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * Not exported from `../state` — this is an internal hook, not part of the `./state` surface.
15
15
  */
16
- import { useMemo, useState } from 'react'
16
+ import { useCallback, useMemo, useState } from 'react'
17
17
  import { createPersistedState } from './persisted'
18
18
 
19
19
  /** The key an unpersisted caller parks on — never read, because that branch returns local state. */
@@ -28,10 +28,14 @@ export function usePersistedOrLocal<T>({
28
28
  scope: string
29
29
  /** Persist under `basalt:<scope>:<persistKey>`. Omitted → local state, nothing written. */
30
30
  persistKey: string | undefined
31
- /** First-render value, respected only while nothing is persisted. */
31
+ /**
32
+ * The value while the user has not chosen one — unset, NOT "written as this". It may change
33
+ * between renders (a size-class default) and the flag follows until a set call pins it.
34
+ */
32
35
  initial: T
33
36
  }): readonly [T, (next: T) => void] {
34
- const [local, setLocal] = useState(initial)
37
+ // Boxed so "never set" (`null`) stays distinct from a set value of any `T`, falsy ones included.
38
+ const [local, setLocal] = useState<{ value: T } | null>(null)
35
39
  // `createPersistedState` is a per-key module FACTORY, so it is memoized rather than called during
36
40
  // render — the same reason `shell/index.tsx` memoizes its collapse store.
37
41
  const usePersisted = useMemo(
@@ -44,7 +48,9 @@ export function usePersistedOrLocal<T>({
44
48
  [scope, persistKey, initial],
45
49
  )
46
50
  const [persisted, setPersisted] = usePersisted()
51
+ // Stable across renders like the persisted setter — a value-only setter, so no updater form.
52
+ const setLocalValue = useCallback((next: T) => setLocal({ value: next }), [])
47
53
 
48
54
  if (persistKey !== undefined) return [persisted, setPersisted] as const
49
- return [local, setLocal] as const
55
+ return [local === null ? initial : local.value, setLocalValue] as const
50
56
  }
@@ -16,6 +16,9 @@ const noopSubscribe =
16
16
  (_cb: () => void): (() => void) =>
17
17
  () => {}
18
18
 
19
+ // Nothing has been written as far as the server can tell.
20
+ const serverIsSet = (): boolean => false
21
+
19
22
  export type PersistedStateOptions<T> = {
20
23
  /** localStorage key (will be namespaced as `basalt:<key>`). */
21
24
  readonly key: string
@@ -81,40 +84,59 @@ function isEnvelope(raw: unknown): raw is Envelope {
81
84
  )
82
85
  }
83
86
 
84
- /** Parse a raw localStorage string (or null) into a value, falling back to `initial` on any miss. */
85
- function parseStorage<T>(raw: string | null, opts: PersistedStateOptions<T>): T {
86
- if (raw === null) return opts.initial
87
+ /**
88
+ * One parse pass over a raw localStorage string: the value AND whether the store ACCEPTED what it
89
+ * found. `accepted` is false for a miss, garbage, a wrong version with no `migrate`, or a value the
90
+ * schema rejects — every case that falls back to `initial`, so `isSet` never claims a choice that
91
+ * was not honoured.
92
+ */
93
+ function parseStorage<T>(
94
+ raw: string | null,
95
+ opts: PersistedStateOptions<T>,
96
+ ): { value: T; accepted: boolean } {
97
+ const miss = { value: opts.initial, accepted: false }
98
+ if (raw === null) return miss
87
99
  try {
88
100
  const parsed: unknown = JSON.parse(raw)
89
- if (!isEnvelope(parsed)) return opts.initial
101
+ if (!isEnvelope(parsed)) return miss
90
102
 
91
103
  let value: unknown = parsed.value
92
104
 
93
105
  if (parsed.v !== opts.version) {
94
- value = opts.migrate ? opts.migrate(parsed.value, parsed.v) : opts.initial
106
+ if (!opts.migrate) return miss
107
+ value = opts.migrate(parsed.value, parsed.v)
95
108
  }
96
109
 
97
110
  if (opts.schema) {
98
111
  const result = opts.schema['~standard'].validate(value)
99
112
  // Async schema — can't await in sync storage path; fall back to initial
100
- if (result instanceof Promise) return opts.initial
101
- if (result.issues !== undefined) return opts.initial
113
+ if (result instanceof Promise) return miss
114
+ if (result.issues !== undefined) return miss
102
115
  // Narrowed to SuccessResult<T> — value is T
103
- return result.value
116
+ return { value: result.value, accepted: true }
104
117
  }
105
118
 
106
- return value as T
119
+ return { value: value as T, accepted: true }
107
120
  } catch {
108
- return opts.initial
121
+ return miss
109
122
  }
110
123
  }
111
124
 
125
+ /**
126
+ * Values a write could not persist (storage blocked, full, private mode), by namespaced key. An
127
+ * explicit user choice then still wins for the rest of the session instead of snapping back to the
128
+ * default; a successful write or a `storage` event for the key drops the entry.
129
+ */
130
+ const unpersisted = new Map<string, { value: unknown }>()
131
+
112
132
  function writeEnvelope(storageKey: string, version: number, value: unknown): void {
113
133
  try {
114
134
  const envelope: Envelope = { v: version, value }
115
135
  window.localStorage.setItem(storageKey, JSON.stringify(envelope))
136
+ unpersisted.delete(storageKey)
116
137
  } catch {
117
- // Silently fail (storage full, private browsing, etc.)
138
+ // Storage full or blocked: keep the value in memory rather than lose the choice.
139
+ unpersisted.set(storageKey, { value })
118
140
  }
119
141
  }
120
142
 
@@ -137,6 +159,25 @@ function writeStorage<T>(opts: PersistedStateOptions<T>, next: T): void {
137
159
  export function createPersistedState<T>(
138
160
  opts: PersistedStateOptions<T>,
139
161
  ): () => readonly [T, (next: T) => void] {
162
+ const useStore = createPersistedStore(opts)
163
+ return function usePersistedState() {
164
+ const [value, setValue] = useStore()
165
+ return [value, setValue] as const
166
+ }
167
+ }
168
+
169
+ /**
170
+ * `createPersistedState` plus a third tuple element: `isSet`, false until a value is written or a
171
+ * stored one is accepted (a stale-version or schema-invalid envelope is NOT set).
172
+ * `value` alone cannot say — an unset key and an explicit write of `initial` both read as `initial`,
173
+ * and a default that depends on context (the shell's size-class collapse default) may apply to the
174
+ * first only.
175
+ *
176
+ * @internal Not exported from the `basalt-ui/state` barrel.
177
+ */
178
+ export function createPersistedStore<T>(
179
+ opts: PersistedStateOptions<T>,
180
+ ): () => readonly [T, (next: T) => void, boolean] {
140
181
  const storageKey = `basalt:${opts.key}`
141
182
 
142
183
  const subscribe = (cb: () => void): (() => void) => {
@@ -148,7 +189,10 @@ export function createPersistedState<T>(
148
189
  // when the last one leaves (cleanup semantics preserved).
149
190
  if (channel.handler === null) {
150
191
  channel.handler = (e: StorageEvent): void => {
151
- if (e.key === storageKey) notify(storageKey)
192
+ if (e.key !== storageKey) return
193
+ // Another tab's write (or removal) is newer than anything this tab failed to persist.
194
+ unpersisted.delete(storageKey)
195
+ notify(storageKey)
152
196
  }
153
197
  window.addEventListener('storage', channel.handler)
154
198
  }
@@ -171,9 +215,12 @@ export function createPersistedState<T>(
171
215
  // the object/array case (chat history, form drafts).
172
216
  let cachedRaw: string | null = null
173
217
  let cachedValue: T = opts.initial
218
+ let cachedIsSet = false
174
219
  let primed = false
175
220
 
176
221
  const getSnapshot = (): T => {
222
+ const override = unpersisted.get(storageKey)
223
+ if (override !== undefined) return override.value as T
177
224
  let raw: string | null
178
225
  try {
179
226
  raw = window.localStorage.getItem(storageKey)
@@ -181,11 +228,17 @@ export function createPersistedState<T>(
181
228
  raw = null
182
229
  }
183
230
  if (primed && raw === cachedRaw) return cachedValue
231
+ const parsed = parseStorage(raw, opts)
184
232
  cachedRaw = raw
185
- cachedValue = parseStorage(raw, opts)
233
+ cachedValue = parsed.value
234
+ cachedIsSet = parsed.accepted
186
235
  primed = true
187
236
  return cachedValue
188
237
  }
238
+ const getIsSet = (): boolean => {
239
+ getSnapshot()
240
+ return unpersisted.has(storageKey) || cachedIsSet
241
+ }
189
242
  const getServerSnapshot = (): T => opts.initial
190
243
 
191
244
  const setState = (next: T): void => {
@@ -198,15 +251,21 @@ export function createPersistedState<T>(
198
251
  // Detect SSR once at creation time — the environment doesn't change between renders.
199
252
  const isServer = typeof window === 'undefined'
200
253
 
201
- return function usePersistedState(): readonly [T, (next: T) => void] {
254
+ return function usePersistedStore(): readonly [T, (next: T) => void, boolean] {
255
+ // SSR guard — subscribe must not reference `window` on the server.
256
+ const listen = isServer ? noopSubscribe : subscribe
202
257
  const value = useSyncExternalStore<T>(
203
- // SSR guard — subscribe must not reference `window` on the server.
204
- isServer ? noopSubscribe : subscribe,
258
+ listen,
205
259
  isServer ? getServerSnapshot : getSnapshot,
206
260
  getServerSnapshot,
207
261
  )
262
+ const isSet = useSyncExternalStore<boolean>(
263
+ listen,
264
+ isServer ? serverIsSet : getIsSet,
265
+ serverIsSet,
266
+ )
208
267
 
209
- return [value, setState] as const
268
+ return [value, setState, isSet] as const
210
269
  }
211
270
  }
212
271