basalt-ui 1.29.2 → 1.30.1

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 (149) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/MIGRATING.md +901 -366
  3. package/README.md +23 -2
  4. package/agent/rules/basalt-controls.md +34 -34
  5. package/agent/skills/basalt-app/SKILL.md +4 -2
  6. package/agent/templates/query-client.ts.tpl +18 -4
  7. package/configs/lefthook.yml +51 -19
  8. package/configs/oxlint-plugin.js +210 -84
  9. package/configs/oxlint-plugin.test.ts +182 -5
  10. package/configs/oxlint.json +4 -4
  11. package/dist/charts/index.d.ts +1 -1
  12. package/dist/charts/index.d.ts.map +1 -1
  13. package/dist/charts/kinds/BandStrip.d.ts +5 -3
  14. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  15. package/dist/charts/kinds/Bars.d.ts +4 -2
  16. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  17. package/dist/charts/kinds/Donut.d.ts +4 -2
  18. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  19. package/dist/charts/kinds/DualPanel.d.ts +4 -2
  20. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  21. package/dist/charts/kinds/Heatmap.d.ts +5 -2
  22. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  23. package/dist/charts/kinds/MirroredBars.d.ts +4 -2
  24. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  25. package/dist/charts/kinds/MultiLine.d.ts +4 -2
  26. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  27. package/dist/charts/kinds/StackedArea.d.ts +4 -2
  28. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  29. package/dist/charts/kinds/ZonedLine.d.ts +4 -2
  30. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  31. package/dist/charts/primitives/CartesianChart.d.ts +4 -1
  32. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  34. package/dist/charts/primitives/ChartCard.js +2 -2
  35. package/dist/charts/primitives/ChartFrame.d.ts +25 -4
  36. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  37. package/dist/charts/primitives/ChartFrame.js +23 -16
  38. package/dist/charts/primitives/chart-frame-layout.d.ts +115 -6
  39. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  40. package/dist/charts/primitives/chart-frame-layout.js +46 -6
  41. package/dist/cli/check-theme.d.ts.map +1 -1
  42. package/dist/cli/check-theme.js +6 -0
  43. package/dist/cli/doctor.d.ts +10 -4
  44. package/dist/cli/doctor.d.ts.map +1 -1
  45. package/dist/cli/doctor.js +28 -1
  46. package/dist/cli/help.d.ts.map +1 -1
  47. package/dist/cli/help.js +2 -1
  48. package/dist/cli/index.d.ts +12 -0
  49. package/dist/cli/index.d.ts.map +1 -1
  50. package/dist/cli/index.js +29 -0
  51. package/dist/cli/init.d.ts.map +1 -1
  52. package/dist/cli/init.js +4 -15
  53. package/dist/cli/sync.d.ts.map +1 -1
  54. package/dist/cli/sync.js +8 -14
  55. package/dist/controls/actions.d.ts.map +1 -1
  56. package/dist/controls/actions.js +12 -2
  57. package/dist/dashboard/section.module.css +15 -7
  58. package/dist/dashboard/stat-card.d.ts.map +1 -1
  59. package/dist/dashboard/stat-card.js +7 -2
  60. package/dist/dashboard/stat-group.d.ts +8 -2
  61. package/dist/dashboard/stat-group.d.ts.map +1 -1
  62. package/dist/dashboard/stat-group.js +23 -15
  63. package/dist/dashboard/stat-group.module.css +85 -9
  64. package/dist/dashboard/stat-group.module.css.d.ts +1 -0
  65. package/dist/dashboard/widget-grid.d.ts.map +1 -1
  66. package/dist/dashboard/widget-grid.js +4 -5
  67. package/dist/dashboard/widget-grid.module.css +64 -21
  68. package/dist/dashboard/widget-grid.module.css.d.ts +1 -0
  69. package/dist/guard/index.d.ts +19 -2
  70. package/dist/guard/index.d.ts.map +1 -1
  71. package/dist/guard/index.js +27 -4
  72. package/dist/index.d.ts +9 -0
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +2 -0
  75. package/dist/shell/app-brand.module.css +12 -3
  76. package/dist/shell/app-header.module.css +49 -9
  77. package/dist/shell/app-main.module.css +24 -4
  78. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  79. package/dist/shell/app-mobile-nav.js +98 -51
  80. package/dist/shell/app-mobile-nav.module.css +99 -11
  81. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  82. package/dist/shell/app-sidebar.module.css +55 -2
  83. package/dist/shell/index.d.ts.map +1 -1
  84. package/dist/shell/index.js +3 -3
  85. package/dist/shell/mobile-nav-model.d.ts +26 -7
  86. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  87. package/dist/shell/mobile-nav-model.js +12 -3
  88. package/dist/shell/page-aside.module.css +8 -6
  89. package/dist/shell/page-bar.d.ts +13 -0
  90. package/dist/shell/page-bar.d.ts.map +1 -1
  91. package/dist/shell/page-bar.js +13 -3
  92. package/dist/shell/page-bar.module.css +20 -12
  93. package/dist/shell/page-title.module.css +3 -1
  94. package/dist/theme/use-basalt-spacing.d.ts.map +1 -1
  95. package/dist/tokens/index.d.ts.map +1 -1
  96. package/dist/tokens/index.js +51 -7
  97. package/dist/tokens/palette.d.ts +98 -17
  98. package/dist/tokens/palette.d.ts.map +1 -1
  99. package/dist/tokens/palette.js +110 -18
  100. package/dist/tokens.css +17 -5
  101. package/llms.txt +2 -2
  102. package/package.json +1 -1
  103. package/src/charts/index.ts +1 -0
  104. package/src/charts/kinds/BandStrip.tsx +5 -3
  105. package/src/charts/kinds/Bars.tsx +4 -2
  106. package/src/charts/kinds/Donut.tsx +4 -2
  107. package/src/charts/kinds/DualPanel.tsx +4 -2
  108. package/src/charts/kinds/Heatmap.tsx +5 -2
  109. package/src/charts/kinds/MirroredBars.tsx +4 -2
  110. package/src/charts/kinds/MultiLine.tsx +4 -2
  111. package/src/charts/kinds/StackedArea.tsx +4 -2
  112. package/src/charts/kinds/ZonedLine.tsx +4 -2
  113. package/src/charts/primitives/CartesianChart.tsx +4 -1
  114. package/src/charts/primitives/ChartCard.tsx +12 -7
  115. package/src/charts/primitives/ChartFrame.tsx +51 -37
  116. package/src/charts/primitives/chart-frame-layout.ts +172 -13
  117. package/src/cli/check-theme.ts +13 -0
  118. package/src/cli/doctor.ts +49 -8
  119. package/src/cli/help.ts +5 -3
  120. package/src/cli/index.ts +49 -0
  121. package/src/cli/init.ts +6 -27
  122. package/src/cli/sync.ts +11 -22
  123. package/src/controls/actions.tsx +51 -0
  124. package/src/dashboard/section.module.css +15 -7
  125. package/src/dashboard/stat-card.tsx +7 -2
  126. package/src/dashboard/stat-group.module.css +85 -9
  127. package/src/dashboard/stat-group.module.css.d.ts +1 -0
  128. package/src/dashboard/stat-group.tsx +40 -20
  129. package/src/dashboard/widget-grid.module.css +64 -21
  130. package/src/dashboard/widget-grid.module.css.d.ts +1 -0
  131. package/src/dashboard/widget-grid.tsx +30 -12
  132. package/src/guard/index.ts +79 -11
  133. package/src/index.ts +9 -0
  134. package/src/shell/app-brand.module.css +12 -3
  135. package/src/shell/app-header.module.css +49 -9
  136. package/src/shell/app-main.module.css +24 -4
  137. package/src/shell/app-mobile-nav.module.css +99 -11
  138. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  139. package/src/shell/app-mobile-nav.tsx +225 -83
  140. package/src/shell/app-sidebar.module.css +55 -2
  141. package/src/shell/index.tsx +26 -6
  142. package/src/shell/mobile-nav-model.ts +37 -9
  143. package/src/shell/page-aside.module.css +8 -6
  144. package/src/shell/page-bar.module.css +20 -12
  145. package/src/shell/page-bar.tsx +39 -5
  146. package/src/shell/page-title.module.css +3 -1
  147. package/src/theme/use-basalt-spacing.ts +8 -6
  148. package/src/tokens/index.ts +90 -6
  149. package/src/tokens/palette.ts +111 -18
@@ -7,7 +7,8 @@
7
7
  * page used to cost a tap to open a sheet plus a tap to pick a row. An overlay now exists only
8
8
  * where a slot genuinely holds more than one destination (the trailing "More" slot, or an opt-in
9
9
  * group slot), and its surface is INFERRED from the row count rather than configured: a content-
10
- * sized `Menu` that pops out of the tab up to `menuMax` rows, a bottom `Drawer` past it.
10
+ * sized `Menu` that pops out of the tab up to `menuMax` rows, a bottom `Drawer` past it. The menu
11
+ * is bounded by its own `max-height`, so `menuMax` decides taste, never whether it fits.
11
12
  *
12
13
  * This component paints a finished `MobileNavModel` and owns exactly one piece of state — which
13
14
  * slot's surface is open. Every selection decision lives in `mobile-nav-model.ts`, which is pure.
@@ -45,8 +46,9 @@ import classes from './app-mobile-nav.module.css'
45
46
 
46
47
  /**
47
48
  * Floating-ui middlewares for a tab menu. `flip: false` is correct, not lazy: flipping a
48
- * bottom-anchored menu puts it UNDER the footer, off-screen — `menuMax` is what guarantees it fits
49
- * above the bar (§2.2). `shift.padding` is a viewport inset in px, not a layout spacing token.
49
+ * bottom-anchored menu puts it UNDER the footer, off-screen. What guarantees it fits above the bar
50
+ * is `.menuDropdown`'s own `max-height` (see the CSS module) — NOT `menuMax`, which is only the
51
+ * menu-vs-sheet threshold. `shift.padding` is a viewport inset in px, not a layout spacing token.
50
52
  */
51
53
  const MENU_MIDDLEWARES = { flip: false, shift: { padding: 8 } }
52
54
 
@@ -198,49 +200,36 @@ function settingsRows(items: SettingsMenuItem[] | undefined): ActionRow[] {
198
200
  }))
199
201
  }
200
202
 
201
- /** One destination row, in a `Menu` dropdown. `depth` indents a nested child. */
202
- const menuRow = (item: SidebarItem, depth: number): ReactElement => {
203
- // `.menuItem[data-disabled] { pointer-events: none }` is the primary guard, but it cannot be the
204
- // only one: Mantine's `MenuItem` composes the CALLER's `onClick` first and only then checks
205
- // `data-disabled` itself, so anything that reaches the node without a pointer — a keyboard
206
- // activation, a synthetic click, a consumer stylesheet resetting `pointer-events` — would still
207
- // fire the destination's handler on a row the projection deliberately rendered dead (§2.9).
208
- const onClick = (event: MouseEvent<HTMLElement>) => {
209
- if (item.disabled) {
210
- event.preventDefault()
211
- return
203
+ /**
204
+ * The deepest element inside `root` that is CURRENTLY scrolled, or `null`.
205
+ *
206
+ * `common/scroll-parent.ts`'s `scrollParentOf` asks the opposite question — which ANCESTOR scrolls
207
+ * a given element — and this twin deliberately stays local rather than joining it there. That
208
+ * module is a shared vocabulary with several consumers and one meaning ("which box scrolls this");
209
+ * this is a scroll-to-top TARGET search with exactly one call site, a visibility rule and a
210
+ * deepest-wins tiebreak that only make sense for it. Per the consolidation doctrine a new shared
211
+ * export needs a named consumer outside the module that wanted it; this one does not have a second.
212
+ *
213
+ * Deepest wins because nested scrollers nest by specificity: a page scroller holding a thread pane
214
+ * means the pane is the surface the user is looking at. Visibility is part of "actually scrolled" —
215
+ * a hidden box keeps its `scrollTop` forever — but `getClientRects()` is only asked of the handful
216
+ * of nodes that pass the cheap `scrollTop` test, never of the whole tree.
217
+ */
218
+ function deepestScrolledDescendant(root: ParentNode | null): HTMLElement | null {
219
+ if (root === null) return null
220
+ let best: HTMLElement | null = null
221
+ let bestDepth = -1
222
+ for (const node of root.querySelectorAll<HTMLElement>('*')) {
223
+ if (node.scrollTop <= 0) continue
224
+ if (node.getClientRects().length === 0) continue
225
+ let depth = 0
226
+ for (let parent = node.parentElement; parent !== null; parent = parent.parentElement) depth += 1
227
+ if (depth > bestDepth) {
228
+ best = node
229
+ bestDepth = depth
212
230
  }
213
- item.onClick?.(event)
214
- }
215
- const shared = {
216
- className: depth > 0 ? `${classes.menuItem} ${classes.menuItemNested}` : classes.menuItem,
217
- leftSection: item.icon,
218
- disabled: Boolean(item.disabled),
219
- // Mantine emits `data-disabled` (a STYLING hook) and nothing else, so the ARIA state is ours
220
- // to set — a disabled destination still renders (§2.3 rule 11), so it has to announce itself.
221
- 'aria-disabled': item.disabled || undefined,
222
- 'aria-current': item.active ? ('page' as const) : undefined,
223
- rightSection: item.count ? <NavCountBadge count={item.count} /> : undefined,
224
- onClick,
225
231
  }
226
- const Anchor = item.Anchor
227
- if (Anchor && !item.disabled) {
228
- return (
229
- <Menu.Item key={item.key} component={Anchor} {...shared}>
230
- {item.label}
231
- </Menu.Item>
232
- )
233
- }
234
- return (
235
- <Menu.Item
236
- key={item.key}
237
- component="a"
238
- {...(item.href !== undefined && !item.disabled && { href: item.href })}
239
- {...shared}
240
- >
241
- {item.label}
242
- </Menu.Item>
243
- )
232
+ return best
244
233
  }
245
234
 
246
235
  /** Depth-first: parent, then its children as indented rows — nesting survives to mobile. */
@@ -251,11 +240,12 @@ const rows = (
251
240
  ): ReactElement[] =>
252
241
  items.flatMap((item) => [render(item, depth), ...rows(item.children ?? [], depth + 1, render)])
253
242
 
254
- /** An account/settings row, in a `Menu` dropdown. */
255
- const menuActionRow = (row: ActionRow) => (
243
+ /** An account/settings row, in a `Menu` dropdown. `sectionStart` draws the tail's own top rule
244
+ * (see `tailRule` and `menuSlot`) when this is the first row of the unlabelled tail. */
245
+ const menuActionRow = (row: ActionRow, sectionStart = false) => (
256
246
  <Menu.Item
257
247
  key={row.key}
258
- className={classes.menuItem}
248
+ className={sectionStart ? `${classes.menuItem} ${classes.menuSectionStart}` : classes.menuItem}
259
249
  leftSection={row.icon}
260
250
  rightSection={row.active ? <IconCheck /> : undefined}
261
251
  {...(row.danger ? { color: 'red' } : {})}
@@ -296,20 +286,63 @@ export function MobileNav({
296
286
 
297
287
  const close = () => setOpenKey(null)
298
288
 
289
+ /** Scrolls `target` only if it has somewhere to go. The return value is the whole point — see
290
+ * `scrollToTop`: "I asked it to scroll" and "it scrolled" are different facts. */
291
+ const scrollElementToTop = (target: Element | null | undefined): boolean => {
292
+ if (!target || target.scrollTop <= 0) return false
293
+ target.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' })
294
+ return true
295
+ }
296
+
299
297
  /**
300
298
  * §2.5 — re-tapping the ACTIVE slot scrolls to top instead of pushing a redundant history entry.
301
299
  *
302
- * `AppShell.Main` is the shell's scrollport (`app-main.module.css`), so `document.scrollingElement`
303
- * scrolls nothing inside a shell and the tap read as dead. The declared handle comes FIRST after
304
- * the consumer's own override, and the document stays the fallback for a shell-less app.
300
+ * RETURNS WHETHER ANYTHING ACTUALLY SCROLLED, and that is the fix for the reported dead tap. The
301
+ * old version resolved `[data-basalt-scrollport]` (= `AppShell.Main`) and scrolled it
302
+ * unconditionally, while the caller had ALREADY suppressed the navigation. On any page whose
303
+ * real scroller is an INNER element — `/agent`'s thread panes, `/data`'s
304
+ * `Table.ScrollContainer`, `/content`'s article body — Main's own `scrollTop` is 0, so the scroll
305
+ * moved nothing and the navigation was gone too: a 100% dead tap, on exactly the pages that have
306
+ * the most to scroll.
307
+ *
308
+ * So the search widens and the answer is honest. The consumer's handle wins, then the declared
309
+ * scrollport, then the deepest inner scroller that is genuinely scrolled, then the document for a
310
+ * shell-less app. A `false` here means the caller must let the navigation through — a redundant
311
+ * same-route navigation is strictly better than silence.
305
312
  */
306
- const scrollToTop = () => {
307
- const target =
308
- config?.getScrollElement?.() ??
309
- document.querySelector(`[${SCROLLPORT_ATTRIBUTE}]`) ??
310
- document.scrollingElement ??
311
- document.documentElement
312
- target.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' })
313
+ const scrollToTop = (): boolean => {
314
+ const declared =
315
+ config?.getScrollElement?.() ?? document.querySelector(`[${SCROLLPORT_ATTRIBUTE}]`)
316
+ if (scrollElementToTop(declared)) return true
317
+ if (scrollElementToTop(deepestScrolledDescendant(declared ?? document.body))) return true
318
+ return scrollElementToTop(document.scrollingElement ?? document.documentElement)
319
+ }
320
+
321
+ /**
322
+ * §2.5 + §2.9 in ONE place, because the three row builders used to disagree about what a tap on
323
+ * a row MEANS. `linkTab` implemented the active-retap rule and `menuRow`/`sheetRow` did not, so
324
+ * a row inside the More surface rendered `aria-current="page"` and then navigated to the route
325
+ * it was already on — the router no-ops, the surface closes, nothing moves. Since More is where
326
+ * most destinations live the moment the bar is full, that was the MORE common dead tap of the
327
+ * two. `sheetRow` was also missing the disabled guard `menuRow` carries.
328
+ *
329
+ * Order is load-bearing: the scroll is ATTEMPTED FIRST and the navigation is suppressed only if
330
+ * something really moved (`scrollToTop`'s doc). Returns `false` when the tap was swallowed by
331
+ * the disabled guard, so a caller owning a surface knows not to dismiss it.
332
+ */
333
+ const onActivate = (item: SidebarItem, event: MouseEvent<HTMLElement>): boolean => {
334
+ // Mantine emits `data-disabled` as a STYLING hook and the rows are polymorphic
335
+ // (`component="a"`/the consumer's Anchor), so the native `disabled` attribute never applies —
336
+ // and `MenuItem` composes the CALLER's `onClick` before checking `data-disabled` itself. The
337
+ // CSS no longer sets `pointer-events: none` (a row that swallows a tap with no press state is
338
+ // indistinguishable from a broken one), which makes this the ONLY guard, on every path.
339
+ if (item.disabled) {
340
+ event.preventDefault()
341
+ return false
342
+ }
343
+ if (item.active === true && scrollToTop()) event.preventDefault()
344
+ item.onClick?.(event)
345
+ return true
313
346
  }
314
347
 
315
348
  const extraRows = [...accountRows(account), ...settingsRows(settingsMenuItems)]
@@ -326,18 +359,38 @@ export function MobileNav({
326
359
  )
327
360
  const listBlocks = moreBlocks.filter((block): block is SidebarListBlock => block.kind === 'list')
328
361
 
362
+ /**
363
+ * Whether the More surface's UNLABELLED tail (blocks + account + settings) opens its own band —
364
+ * the guard the deleted `Menu.Divider` used to carry inline. A separator separates: with no
365
+ * destination rows above it, a More slot raised purely by those rows would open a band under the
366
+ * dropdown's top edge with nothing on the other side of it.
367
+ */
368
+ const tailRule = (slot: SurfaceSlot): boolean =>
369
+ moreBlocks.length + extraRows.length > 0 && slotHasRows(slot)
370
+
329
371
  /** A list block raises its nested sheet; a progress block just fires its own handler. */
330
372
  const activateBlock = (block: (typeof moreBlocks)[number]) => {
331
373
  close()
332
374
  if (block.kind === 'list') {
333
- setOpenBlockKey(block.key)
375
+ // ONE FRAME, deliberately. `close()` starts the Menu's `returnFocus` (it restores focus to
376
+ // the More tab) while the nested Drawer traps focus on mount — in the same handler the two
377
+ // race, and focus lands wherever the loser wrote last, which is how a tap on a block row
378
+ // could leave the sheet open with focus back on the bar behind it. Deferring the open by one
379
+ // rAF lets the return finish before the trap starts.
380
+ requestAnimationFrame(() => setOpenBlockKey(block.key))
334
381
  return
335
382
  }
336
383
  block.onClick?.()
337
384
  }
338
385
 
339
- const menuBlockRow = (block: (typeof moreBlocks)[number]) => (
340
- <Menu.Item key={block.key} className={classes.menuItem} onClick={() => activateBlock(block)}>
386
+ const menuBlockRow = (block: (typeof moreBlocks)[number], sectionStart = false) => (
387
+ <Menu.Item
388
+ key={block.key}
389
+ className={
390
+ sectionStart ? `${classes.menuItem} ${classes.menuSectionStart}` : classes.menuItem
391
+ }
392
+ onClick={() => activateBlock(block)}
393
+ >
341
394
  {blockRowLabel(block)}
342
395
  </Menu.Item>
343
396
  )
@@ -411,17 +464,25 @@ export function MobileNav({
411
464
  )
412
465
 
413
466
  /**
414
- * The trigger for a `menu`/`sheet` slot. A plain button: Mantine's `Menu.Target` clones
415
- * `aria-haspopup`/`aria-expanded`/`aria-controls` onto it (`withRoles` default), so hand-setting
416
- * them here would double them up (§2.9).
467
+ * The trigger for a `menu`/`sheet` slot.
468
+ *
469
+ * The two paths differ in who owns the ARIA, and the guard below is what keeps them apart. On
470
+ * the MENU path the button is a `Menu.Target` child, so Mantine clones
471
+ * `aria-haspopup`/`aria-expanded`/`aria-controls` onto it (`withRoles` default) and setting them
472
+ * here would double them up (§2.9). On the SHEET path there is no `Menu.Target` and no clone at
473
+ * all, so the trigger announced nothing — a button that opens a dialog and says so only to
474
+ * sighted users. `onClick` is present on exactly the sheet path, which is why it is the
475
+ * discriminator rather than a second parameter that could disagree with reality.
417
476
  */
418
- const surfaceTab = (slot: SurfaceSlot, onClick?: () => void) => (
477
+ const surfaceTab = (slot: SurfaceSlot, onClick?: () => void, expanded?: boolean) => (
419
478
  <UnstyledButton
420
479
  className={classes.tab}
421
480
  data-active={slot.active || undefined}
422
481
  aria-current={slot.active ? 'page' : undefined}
423
482
  aria-label={slot.label}
424
- {...(onClick ? { onClick } : {})}
483
+ {...(onClick
484
+ ? { onClick, 'aria-haspopup': 'dialog' as const, 'aria-expanded': Boolean(expanded) }
485
+ : {})}
425
486
  >
426
487
  {tabInner(slot)}
427
488
  </UnstyledButton>
@@ -429,14 +490,12 @@ export function MobileNav({
429
490
 
430
491
  /** A `link` slot: the consumer's router anchor IS the tab, so preload/back/middle-click all work. */
431
492
  const linkTab = (slot: LinkSlot) => {
493
+ // VERIFIED in @tanstack/react-router: `Link` composes the caller's handler FIRST and returns
494
+ // early when `defaultPrevented`, so `onActivate` suppresses navigation without touching
495
+ // preload. A link slot's `active` and its item's `active` are the same fact by construction —
496
+ // every path that builds one reads `isActiveDestination` (`mobile-nav-model.ts`).
432
497
  const onClick = (e: MouseEvent<HTMLElement>) => {
433
- // VERIFIED in @tanstack/react-router: `Link` composes the caller's handler FIRST and returns
434
- // early when `defaultPrevented`, so this suppresses navigation without touching preload.
435
- if (slot.active) {
436
- e.preventDefault()
437
- scrollToTop()
438
- }
439
- slot.item.onClick?.(e)
498
+ onActivate(slot.item, e)
440
499
  }
441
500
  const shared = {
442
501
  className: classes.tab,
@@ -465,10 +524,53 @@ export function MobileNav({
465
524
  )
466
525
  }
467
526
 
527
+ /**
528
+ * One destination row, in a `Menu` dropdown. `depth` indents a nested child.
529
+ *
530
+ * Lives INSIDE the component (it used to be a module-level const) for one reason: it needs
531
+ * `onActivate`, which needs `scrollToTop`, which needs `config` and `reduceMotion`. Recreating
532
+ * it per render costs what `sheetRow` already costs and buys all three row builders one handler.
533
+ */
534
+ const menuRow = (item: SidebarItem, depth: number): ReactElement => {
535
+ const shared = {
536
+ className: depth > 0 ? `${classes.menuItem} ${classes.menuItemNested}` : classes.menuItem,
537
+ leftSection: item.icon,
538
+ disabled: Boolean(item.disabled),
539
+ // Mantine emits `data-disabled` (a STYLING hook) and nothing else, so the ARIA state is ours
540
+ // to set — a disabled destination still renders (§2.3 rule 11), so it has to announce itself.
541
+ 'aria-disabled': item.disabled || undefined,
542
+ 'aria-current': item.active ? ('page' as const) : undefined,
543
+ rightSection: item.count ? <NavCountBadge count={item.count} /> : undefined,
544
+ onClick: (event: MouseEvent<HTMLElement>) => {
545
+ onActivate(item, event)
546
+ },
547
+ }
548
+ const Anchor = item.Anchor
549
+ if (Anchor && !item.disabled) {
550
+ return (
551
+ <Menu.Item key={item.key} component={Anchor} {...shared}>
552
+ {item.label}
553
+ </Menu.Item>
554
+ )
555
+ }
556
+ return (
557
+ <Menu.Item
558
+ key={item.key}
559
+ component="a"
560
+ {...(item.href !== undefined && !item.disabled && { href: item.href })}
561
+ {...shared}
562
+ >
563
+ {item.label}
564
+ </Menu.Item>
565
+ )
566
+ }
567
+
468
568
  /** One destination row, in the sheet. 44px minimum, per the touch-target floor. */
469
569
  const sheetRow = (item: SidebarItem, depth: number): ReactElement => {
470
570
  const onClick = (e: MouseEvent<HTMLElement>) => {
471
- item.onClick?.(e)
571
+ // The disabled guard `menuRow` always had, now shared — a dead row must not dismiss the
572
+ // sheet either, or the tap reads as "it did something" while going nowhere.
573
+ if (!onActivate(item, e)) return
472
574
  close()
473
575
  }
474
576
  const shared = {
@@ -522,7 +624,12 @@ export function MobileNav({
522
624
  onChange={(opened) => setOpenKey(opened ? slot.key : null)}
523
625
  position={slot.isMore ? 'top-end' : 'top'}
524
626
  offset={8}
525
- width={step.mobileNavMenuWidth}
627
+ // Only the More dropdown widens. `mobileNavMenuWidth` (232px) is sized for a section slot's
628
+ // short destination labels; More also carries block rows like `Awaiting action · 3` and
629
+ // account rows like `Billing & payment`, which wrap or clip at that width. A viewport-
630
+ // relative cap rather than a second token: it is a fraction of the phone it opens on, and it
631
+ // is the only surface in the bar whose contents are not the consumer's nav labels.
632
+ width={slot.isMore ? 'min(78vw, 300px)' : step.mobileNavMenuWidth}
526
633
  withinPortal
527
634
  // AppShell itself sits at z-index 100 (`getDefaultZIndex('app')`), so the menu must clear it.
528
635
  zIndex={400}
@@ -550,25 +657,50 @@ export function MobileNav({
550
657
  : []),
551
658
  ...rows(group.items, 0, menuRow),
552
659
  ])}
553
- {/* A separator separates. With nothing above it — a More slot raised purely by the block,
554
- account and settings rows — it would render as the dropdown's first child, a rule under
555
- the top edge. Both sides have to exist. */}
556
- {slot.isMore && moreBlocks.length + extraRows.length > 0 && slotHasRows(slot) ? (
557
- <Menu.Divider />
558
- ) : null}
559
- {slot.isMore ? moreBlocks.map(menuBlockRow) : null}
560
- {slot.isMore ? extraRows.map(menuActionRow) : null}
660
+ {/* The `Menu.Divider` node that used to sit here is gone: section rhythm is SPACE opened
661
+ by each group's own heading (`.menuDropdown .menuLabel:not(:first-child)` in the CSS
662
+ module, which explains why it is space and not a line — `theme/divider-law.test.ts`
663
+ bans a hairline under a heading in the chrome lane). That is what makes the popover
664
+ read as sections instead of as one long list, which is the structure the More surface
665
+ needed a whole Drawer to get. The unlabelled tail (blocks + account + settings) has no
666
+ heading to open its band, so its FIRST row carries `.menuSectionStart` instead. The old
667
+ guard survives verbatim in `tailRule`: a separator separates, so with nothing above it
668
+ — a More slot raised purely by those rows — no band opens at all. */}
669
+ {slot.isMore
670
+ ? moreBlocks.map((block, index) => menuBlockRow(block, tailRule(slot) && index === 0))
671
+ : null}
672
+ {slot.isMore
673
+ ? extraRows.map((row, index) =>
674
+ menuActionRow(row, tailRule(slot) && moreBlocks.length === 0 && index === 0),
675
+ )
676
+ : null}
561
677
  </Menu.Dropdown>
562
678
  </Menu>
563
679
  )
564
680
 
565
681
  const sheetSlot = (slot: SurfaceSlot) => (
566
682
  <Fragment key={slot.key}>
567
- {surfaceTab(slot, () => setOpenKey(slot.key))}
683
+ {/* A real TOGGLE, not a setter. It used to be `() => setOpenKey(slot.key)`, so re-tapping an
684
+ open sheet's own tab did nothing at all — while the menu path, which gets Mantine's
685
+ disclosure behaviour for free, closed on the second tap. Same trigger, same gesture, two
686
+ different answers. */}
687
+ {surfaceTab(
688
+ slot,
689
+ () => setOpenKey(openKey === slot.key ? null : slot.key),
690
+ openKey === slot.key,
691
+ )}
568
692
  <Drawer
569
693
  opened={openKey === slot.key}
570
694
  onClose={close}
571
695
  position="bottom"
696
+ // The bar is inside `AppShell.Footer` at z-index 100 and Mantine's modal default is 200, so
697
+ // the Drawer's OVERLAY covered the bar. Combined with `lockScroll` and a 220ms exit, that
698
+ // overlay stayed hit-testable after `onClose`: with a sheet open, a tap on another tab
699
+ // dismissed the sheet and did nothing else, and a quick second tap was eaten by the exiting
700
+ // overlay. Matching the Menu's 400 puts the bar and its surfaces on one layer, and a
701
+ // shorter exit shrinks the window in which a closing overlay can still swallow a tap.
702
+ zIndex={400}
703
+ overlayProps={{ zIndex: 400 }}
572
704
  // NO `size` prop — `size="auto"` used to be here, but it is a no-op on a bottom Drawer in
573
705
  // Mantine 9.3.0 (see the `.sheet` rule in the CSS module for the full trap). The sheet's
574
706
  // actual height/max-height comes entirely from that unlayered CSS rule, which overrides
@@ -589,6 +721,10 @@ export function MobileNav({
589
721
  transitionProps={{
590
722
  transition: 'slide-up',
591
723
  duration: reduceMotion ? 0 : 220,
724
+ // Asymmetric on purpose: the entrance may take its time, the EXIT is a hit-test window.
725
+ // Mantine's default exit equals the duration, so a dismissed sheet left 220ms of live
726
+ // overlay over the bar — long enough to eat the next tab tap.
727
+ exitDuration: reduceMotion ? 0 : 120,
592
728
  timingFunction: 'cubic-bezier(0.32, 0.72, 0, 1)',
593
729
  }}
594
730
  >
@@ -636,6 +772,10 @@ export function MobileNav({
636
772
  opened={openBlockKey === block.key}
637
773
  onClose={() => setOpenBlockKey(null)}
638
774
  position="bottom"
775
+ // Same layer as `sheetSlot`'s Drawer and the Menu — see that one's note. This sheet is
776
+ // reached FROM the More surface, so it inherits the same overlay-over-the-bar problem.
777
+ zIndex={400}
778
+ overlayProps={{ zIndex: 400 }}
639
779
  padding="md"
640
780
  title={block.label}
641
781
  classNames={{
@@ -646,6 +786,8 @@ export function MobileNav({
646
786
  transitionProps={{
647
787
  transition: 'slide-up',
648
788
  duration: reduceMotion ? 0 : 220,
789
+ // Same reasoning as `sheetSlot`'s Drawer — a long exit leaves a live overlay over the bar.
790
+ exitDuration: reduceMotion ? 0 : 120,
649
791
  timingFunction: 'cubic-bezier(0.32, 0.72, 0, 1)',
650
792
  }}
651
793
  >
@@ -142,12 +142,50 @@
142
142
 
143
143
  /* Re-apply the bled inset INSIDE the viewport, so rows keep their exact 10px/10px column while the
144
144
  * bar floats clear of them in the gutter. Static padding, not Mantine's `offsetScrollbars`: the
145
- * latter pads only while overflowing, which reflows the rows the moment a section expands. */
145
+ * latter pads only while overflowing, which reflows the rows the moment a section expands.
146
+ *
147
+ * `padding-block-end` is the scroll END-STOP: with only the inline inset, an overflowing column
148
+ * cut its last row flush against the viewport edge, which reads as a label whose list is CLIPPED
149
+ * rather than as something that scrolls. One `region-gap` of air below the last row is the same
150
+ * number the search slot opens at the column's other end.
151
+ *
152
+ * The end-stop is necessary and NOT sufficient, and the mask below is the other half. MEASURED at
153
+ * 1512x945 with the playground's nav: `.navScroll` is `flex: 1; min-height: 0` between the search
154
+ * slot and a `flex-shrink: 0` footer, so any nav taller than the leftover column overflows — and
155
+ * the end-stop only pads the BOTTOM OF THE CONTENT, which the reader sees exactly once, at the
156
+ * scroll end. Everywhere else the viewport's own edge cuts whatever row happens to cross it, and
157
+ * `.footer`'s progress block starts one `region-gap` under that cut. Two hard edges 12px apart:
158
+ * the column reads BROKEN rather than scrolled. Nothing else says otherwise, because the
159
+ * ScrollArea is `type="hover"` — at rest there is no bar at all.
160
+ *
161
+ * So: a scroll fade on the viewport's bottom edge, and deliberately NOT the other candidate, a
162
+ * `--vx-divider` seam on `.footer`. A rule there would answer a question nobody asked — the footer
163
+ * boundary is already carried by its own `padding-top` and the account row's inset (see `.footer`
164
+ * below) — and it would answer the real one WRONG: a hairline drawn across a row that is severed
165
+ * mid-glyph makes the severing crisper, not legible. A fade is the only one of the two that says
166
+ * "there is more, and it moves". It also keeps this module out of `theme/divider-law.test.ts`'s
167
+ * inventory, which is the correct outcome and not a dodge: that ledger exists so every hairline is
168
+ * a line someone chose, and this one would have been chosen to stand in for a scrollbar.
169
+ *
170
+ * The ramp is one `region-gap` tall, which is what pairs it with the end-stop instead of fighting
171
+ * it: at the scroll END those 12px hold the end-stop's padding — empty — so the last row lands
172
+ * fully opaque, and a column that does not overflow fades nothing but its own trailing air. Only
173
+ * mid-scroll, where a real row crosses the edge, does the fade have anything to dim.
174
+ *
175
+ * `black`/`transparent` here are alpha, not color: a mask reads only the alpha channel, so these
176
+ * carry no design meaning and deliberately are NOT `--vx-*` tokens. Do not route them through the
177
+ * palette in a token sweep. */
146
178
  .navViewport {
147
179
  padding-inline-end: var(--vx-space-row-inset-x);
180
+ padding-block-end: var(--vx-space-sidebar-region-gap);
181
+ mask-image: linear-gradient(
182
+ to bottom,
183
+ black calc(100% - var(--vx-space-sidebar-region-gap)),
184
+ transparent
185
+ );
148
186
  }
149
187
 
150
- /* Sections separate by SPACING only — 15px between sections, nothing above the first (the brand
188
+ /* Sections separate by SPACING only — 16px between sections, nothing above the first (the brand
151
189
  * band's centred `min-height` already opens the region's air, and the optional search slot's own
152
190
  * `padding-block` opens more when it renders). */
153
191
  .nav > div + div {
@@ -156,9 +194,24 @@
156
194
 
157
195
  /*
158
196
  * Footer region — no border; the account row's own top padding supplies the separation.
197
+ *
198
+ * The region owns its own air on both axes, because the `Stack` wrapping it is `gap={0}`
199
+ * (`app-sidebar.tsx`) — the footer's rhythm is single-sourced HERE, so the whole answer is
200
+ * readable in one place rather than split between a prop and a stylesheet. Without it a
201
+ * bottom-placed block (a `kind: 'progress'` block defaults to `'bottom'`) stacked FLUSH on the
202
+ * settings and account rows and on the scroll region above, which is the "cramped" footer.
203
+ *
204
+ * `region-gap` (12) above the cluster, `stack-sm` (8) between its parts — deliberately tighter
205
+ * than the nav's `section-gap` (16): the footer is ONE pinned cluster whose rows belong together,
206
+ * not a list of peer sections.
159
207
  */
160
208
  .footer {
161
209
  flex-shrink: 0;
210
+ padding-top: var(--vx-space-sidebar-region-gap);
211
+ }
212
+
213
+ .footer > * + * {
214
+ margin-top: var(--vx-space-stack-sm);
162
215
  }
163
216
 
164
217
  /*
@@ -291,10 +291,6 @@ function collapseStore(key: string): () => readonly [boolean, (next: boolean) =>
291
291
  return store
292
292
  }
293
293
 
294
- // The shared inset for the shell padding and the header's inline padding, so the breadcrumb's
295
- // left edge and the global actions' right edge land on the card column's edges.
296
- const SHELL_INSET = 'sm' as const
297
-
298
294
  /**
299
295
  * The shell's two page-level regions are providers, and both wrap the frame rather than living
300
296
  * inside it: `PageBarProvider` owns the header portal and the single-kebab claim, `AsideProvider`
@@ -334,6 +330,30 @@ function ShellFrame({
334
330
  // (controls, the search trigger/avatar, nav labels) already track density, so a fixed literal
335
331
  // container squeezes progressively worse as density rises.
336
332
  const { step } = useBasaltSpacing()
333
+ // THE PAGE GUTTER, responsive — the shared inset for `AppShell`'s own padding and the header's
334
+ // inline padding, so the breadcrumb's left edge and the global actions' right edge land on the
335
+ // card column's edges. It used to be the literal `'sm'` spacing key, which is 13px at EVERY
336
+ // width: 13 on a 360px phone and 13 on a 2560px monitor, with no token behind it. Now it is the
337
+ // `appShellInset` / `appShellInsetMobile` pair (see their entries in `tokens/palette.ts`).
338
+ //
339
+ // On a 390px phone that is 8 a side instead of 13 — 10px more line width, and 10px less vertical
340
+ // chrome, since `padding` is Main's vertical inset too. From `sm` up it is 20, which is the x the
341
+ // sidebar's nav icon column already sits at, so the brand mark, the nav icons and the page's
342
+ // first column all start on one vertical line.
343
+ //
344
+ // VERIFIED against the installed Mantine 9.3 source, because both props take the object by a
345
+ // DIFFERENT road and both had to be checked:
346
+ // - `AppShell padding`: `AppShellMediaStyles/assign-padding-variables.mjs` puts `base` in the
347
+ // `:root` rule and every other key in `get-variables.mjs`'s MIN-width block
348
+ // (`(min-width: <breakpoint>)`), so the pair is mobile-first and needs no max-width twin.
349
+ // - `AppShell.Header px`: a Box style prop, `Box/style-props/parse-style-props.mjs` — same
350
+ // `{ base, … }` shape, same `(min-width: theme.breakpoints.sm)` query, emitted on a generated
351
+ // class. Both resolve `sm` from `theme.breakpoints`, so the two gutters step at one width.
352
+ // A NUMBER on either road is CONVERTED by Mantine's `rem()` — 8 becomes
353
+ // `calc(0.5rem * var(--mantine-scale))` — rather than looked up, which is exactly why these are
354
+ // numbers: a spacing KEY resolves to `var(--mantine-spacing-sm)` and hands the gutter back to
355
+ // Mantine's own scale, which is where the 13px came from.
356
+ const inset = { base: step.appShellInsetMobile, sm: step.appShellInset }
337
357
  const aside = useAsideRegion()
338
358
  const [storedCollapsed, setStoredCollapsed] = collapseStore(storageKey)()
339
359
  // Controlled/uncontrolled seam (item 19): an explicit `collapsed` prop overrides the internal
@@ -410,7 +430,7 @@ function ShellFrame({
410
430
  // A plain number, NOT a `calc(... + env(safe-area-inset-bottom))` string: Mantine's own
411
431
  // `.footer` rule already adds the inset to both the height and the padding (§2.7).
412
432
  footer={{ height: { base: step.mobileNavBarHeight, sm: 0 } }}
413
- padding={SHELL_INSET}
433
+ padding={inset}
414
434
  // `mainClasses.shell` is not decoration — see its rule for why the root has to establish a
415
435
  // block formatting context now that Main is offset with margins.
416
436
  className={cx(mainClasses.shell, className)}
@@ -419,7 +439,7 @@ function ShellFrame({
419
439
  {/* Region seams (docs/DESIGN-SPEC.md §5, §8 #12): Mantine's `[data-with-border]` painted in
420
440
  * `--vx-divider` by the theme's `AppShell.extend({ vars })`. No shell module draws a region
421
441
  * edge; never opt a section back out of its border here. */}
422
- <AppShell.Header px={SHELL_INSET}>
442
+ <AppShell.Header px={inset}>
423
443
  <div className={headerClasses.bar}>
424
444
  {/* The header's LEADING ZONE — exactly `--app-shell-navbar-offset` wide, so its trailing
425
445
  * edge lands on the sidebar|main seam and the breadcrumb after it starts on Main's own