basalt-ui 1.29.1 → 1.30.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 (119) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/MIGRATING.md +701 -369
  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/primitives/ChartCard.d.ts.map +1 -1
  12. package/dist/charts/primitives/ChartCard.js +2 -2
  13. package/dist/charts/primitives/ChartFrame.d.ts +23 -3
  14. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  15. package/dist/charts/primitives/ChartFrame.js +4 -4
  16. package/dist/charts/primitives/chart-frame-layout.d.ts +55 -0
  17. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  18. package/dist/charts/primitives/chart-frame-layout.js +16 -0
  19. package/dist/cli/check-theme.d.ts.map +1 -1
  20. package/dist/cli/check-theme.js +6 -0
  21. package/dist/cli/doctor.d.ts +10 -4
  22. package/dist/cli/doctor.d.ts.map +1 -1
  23. package/dist/cli/doctor.js +28 -1
  24. package/dist/cli/help.d.ts.map +1 -1
  25. package/dist/cli/help.js +2 -1
  26. package/dist/cli/index.d.ts +12 -0
  27. package/dist/cli/index.d.ts.map +1 -1
  28. package/dist/cli/index.js +29 -0
  29. package/dist/cli/init.d.ts.map +1 -1
  30. package/dist/cli/init.js +4 -15
  31. package/dist/cli/sync.d.ts.map +1 -1
  32. package/dist/cli/sync.js +8 -14
  33. package/dist/controls/actions.d.ts.map +1 -1
  34. package/dist/controls/actions.js +12 -2
  35. package/dist/dashboard/section.module.css +15 -7
  36. package/dist/dashboard/stat-card.d.ts.map +1 -1
  37. package/dist/dashboard/stat-card.js +7 -2
  38. package/dist/dashboard/stat-group.d.ts +8 -2
  39. package/dist/dashboard/stat-group.d.ts.map +1 -1
  40. package/dist/dashboard/stat-group.js +23 -15
  41. package/dist/dashboard/stat-group.module.css +85 -9
  42. package/dist/dashboard/stat-group.module.css.d.ts +1 -0
  43. package/dist/dashboard/widget-grid.d.ts.map +1 -1
  44. package/dist/dashboard/widget-grid.js +4 -5
  45. package/dist/dashboard/widget-grid.module.css +64 -21
  46. package/dist/dashboard/widget-grid.module.css.d.ts +1 -0
  47. package/dist/guard/index.d.ts +19 -2
  48. package/dist/guard/index.d.ts.map +1 -1
  49. package/dist/guard/index.js +27 -4
  50. package/dist/index.d.ts +9 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +2 -0
  53. package/dist/query-client.d.ts +20 -2
  54. package/dist/query-client.d.ts.map +1 -1
  55. package/dist/shell/app-brand.module.css +12 -3
  56. package/dist/shell/app-header.module.css +49 -9
  57. package/dist/shell/app-main.module.css +24 -4
  58. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  59. package/dist/shell/app-mobile-nav.js +98 -51
  60. package/dist/shell/app-mobile-nav.module.css +99 -11
  61. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  62. package/dist/shell/app-sidebar.module.css +55 -2
  63. package/dist/shell/index.d.ts.map +1 -1
  64. package/dist/shell/index.js +3 -3
  65. package/dist/shell/mobile-nav-model.d.ts +26 -7
  66. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  67. package/dist/shell/mobile-nav-model.js +12 -3
  68. package/dist/shell/page-aside.module.css +8 -6
  69. package/dist/shell/page-bar.d.ts +13 -0
  70. package/dist/shell/page-bar.d.ts.map +1 -1
  71. package/dist/shell/page-bar.js +13 -3
  72. package/dist/shell/page-bar.module.css +20 -12
  73. package/dist/shell/page-title.module.css +3 -1
  74. package/dist/theme/use-basalt-spacing.d.ts.map +1 -1
  75. package/dist/tokens/index.d.ts.map +1 -1
  76. package/dist/tokens/index.js +51 -7
  77. package/dist/tokens/palette.d.ts +98 -17
  78. package/dist/tokens/palette.d.ts.map +1 -1
  79. package/dist/tokens/palette.js +110 -18
  80. package/dist/tokens.css +17 -5
  81. package/llms.txt +2 -2
  82. package/package.json +1 -1
  83. package/src/charts/primitives/ChartCard.tsx +12 -7
  84. package/src/charts/primitives/ChartFrame.tsx +30 -15
  85. package/src/charts/primitives/chart-frame-layout.ts +86 -0
  86. package/src/cli/check-theme.ts +13 -0
  87. package/src/cli/doctor.ts +49 -8
  88. package/src/cli/help.ts +5 -3
  89. package/src/cli/index.ts +49 -0
  90. package/src/cli/init.ts +6 -27
  91. package/src/cli/sync.ts +11 -22
  92. package/src/controls/actions.tsx +51 -0
  93. package/src/dashboard/section.module.css +15 -7
  94. package/src/dashboard/stat-card.tsx +7 -2
  95. package/src/dashboard/stat-group.module.css +85 -9
  96. package/src/dashboard/stat-group.module.css.d.ts +1 -0
  97. package/src/dashboard/stat-group.tsx +40 -20
  98. package/src/dashboard/widget-grid.module.css +64 -21
  99. package/src/dashboard/widget-grid.module.css.d.ts +1 -0
  100. package/src/dashboard/widget-grid.tsx +30 -12
  101. package/src/guard/index.ts +79 -11
  102. package/src/index.ts +9 -0
  103. package/src/query-client.ts +17 -5
  104. package/src/shell/app-brand.module.css +12 -3
  105. package/src/shell/app-header.module.css +49 -9
  106. package/src/shell/app-main.module.css +24 -4
  107. package/src/shell/app-mobile-nav.module.css +99 -11
  108. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  109. package/src/shell/app-mobile-nav.tsx +225 -83
  110. package/src/shell/app-sidebar.module.css +55 -2
  111. package/src/shell/index.tsx +26 -6
  112. package/src/shell/mobile-nav-model.ts +37 -9
  113. package/src/shell/page-aside.module.css +8 -6
  114. package/src/shell/page-bar.module.css +20 -12
  115. package/src/shell/page-bar.tsx +39 -5
  116. package/src/shell/page-title.module.css +3 -1
  117. package/src/theme/use-basalt-spacing.ts +8 -6
  118. package/src/tokens/index.ts +90 -6
  119. package/src/tokens/palette.ts +111 -18
@@ -9,7 +9,14 @@
9
9
  align-items: stretch;
10
10
  width: 100%;
11
11
  height: 100%;
12
- background: var(--mantine-color-body);
12
+ /* NOT `--mantine-color-body`: that is the EXACT fill the scrollport above paints (both resolve
13
+ * to `--vx-surface-bg`), so the only thing separating the bar from the page was the region
14
+ * seam's 9%-alpha hairline — invisible on a phone, which is the "it could also have a border"
15
+ * report. `--vx-surface-panel` is a derived step above bg on both schemes and is the same
16
+ * relation `.sheet` already uses, so the edge is a real tonal one with no new token, no drop
17
+ * shadow (DESIGN-CORE pins depth to three tokens, none of them an upward-cast footer shadow)
18
+ * and no `withBorder` on the shell region (basalt-mantine.md forbids drawing that ourselves). */
19
+ background-color: var(--vx-surface-panel);
13
20
  -webkit-tap-highlight-color: transparent;
14
21
  /* NO padding-bottom / env() here: Mantine's own AppShell `.footer` rule already does
15
22
  * `height: calc(var(--app-shell-footer-height) + env(safe-area-inset-bottom))` +
@@ -90,7 +97,8 @@
90
97
  height: 8px;
91
98
  border-radius: 999px;
92
99
  background-color: var(--vx-accent);
93
- box-shadow: 0 0 0 2px var(--mantine-color-body);
100
+ /* The halo punches the dot out of whatever the BAR paints, so it tracks `.bar`'s fill. */
101
+ box-shadow: 0 0 0 2px var(--vx-surface-panel);
94
102
  }
95
103
 
96
104
  /* `text-overflow: clip`, never an ellipsis — a truncated word with a "…" in a 72px tab reads as
@@ -106,10 +114,25 @@
106
114
  }
107
115
 
108
116
  /* The menu pops UP out of its tab and is configured `flip: false` — flipping a bottom-anchored
109
- * menu would put it under the footer. `menuMax` is what guarantees it fits (§2.2). */
117
+ * menu would put it under the footer, so something has to guarantee it fits. That something is the
118
+ * `max-height` below, not `menuMax` (§2.2 as it used to be written). */
110
119
  .menuDropdown {
111
- padding: 4px;
112
- max-height: min(60dvh, 360px);
120
+ padding: var(--vx-space-stack-xs);
121
+ /* THIS is what guarantees the menu never renders below the fold — not `menuMax`, which is only
122
+ * the menu-vs-sheet taste threshold (`mobile-nav-model.ts`'s module doc has the accounting).
123
+ *
124
+ * `min(62dvh, 440px)`, down from `min(72dvh, 560px)`. The old pair fit — but fitting was never
125
+ * the whole ask: MEASURED in headless Chrome at 390x844 the dropdown rendered 300x560 at
126
+ * y=221..781, i.e. 66% of the screen, which reads as a panel rather than as the "subtle, small
127
+ * menu" the More surface is supposed to be. At 440 the same phone gets 52%.
128
+ *
129
+ * The accounting, at both ends of the supported range. 390x844: headroom above the bar is 772px,
130
+ * 62dvh resolves to 523px, so the 440px term binds — 57% of the headroom. 320x568: headroom is
131
+ * ~496px, 62dvh resolves to 352px, so THAT term binds — 71% of the headroom. Either way the
132
+ * binding term is one of the two written here and it lands under the headroom, which is the
133
+ * property that matters: the cap, not `menuMax`, is the guarantee. Past it the dropdown scrolls
134
+ * inside itself instead of growing, so a long More list costs scroll, never fold. */
135
+ max-height: min(62dvh, 440px);
113
136
  overflow-y: auto;
114
137
  overscroll-behavior: contain;
115
138
  }
@@ -135,13 +158,16 @@
135
158
  /*
136
159
  * A disabled destination still RENDERS in the menu (§2.3 rule 11) — it just must not be reachable.
137
160
  * Mantine's `Menu.css` styles `[data-disabled]` with a dimmed color and nothing else, and the row
138
- * is polymorphic (`component="a"`), so the native `disabled` attribute does not apply either: with
139
- * no rule here the row stays fully clickable. `pointer-events: none` is the real guard; `menuRow`
140
- * carries a matching handler guard for the paths pointers do not cover (keyboard, synthetic
141
- * clicks, a consumer stylesheet that resets this).
161
+ * is polymorphic (`component="a"`), so the native `disabled` attribute does not apply either.
162
+ *
163
+ * `pointer-events: none` USED to be the guard here and is deliberately gone. It made the row
164
+ * swallow a tap with zero feedback — no `:active`, no press state, nothing — which on a phone is
165
+ * indistinguishable from a button that is simply broken, and "I tap and nothing happens" is
166
+ * literally the report this change answers. The guard is now entirely in the handlers (`menuRow`,
167
+ * `sheetRow`), which cover the pointer path and every path a pointer never takes; the row still
168
+ * depresses under a finger and still goes nowhere, which is the honest version of dead.
142
169
  */
143
170
  .menuItem[data-disabled] {
144
- pointer-events: none;
145
171
  cursor: not-allowed;
146
172
  }
147
173
 
@@ -151,7 +177,10 @@
151
177
  padding-inline-start: calc(var(--mantine-spacing-md) + 12px) !important;
152
178
  }
153
179
 
154
- /* Overflow section heading inside the menu — inherits the theme's mono micro-label. */
180
+ /* Overflow section heading, in the menu AND in the sheet — inherits the theme's mono micro-label.
181
+ * The inset matches the rows' own (`--vx-space-row-inset-x`) so the heading and the labels under
182
+ * it share one left edge; the vertical padding is asymmetric on purpose — a heading belongs to
183
+ * what follows it, so it sits closer to its own rows than to the section above. */
155
184
  .menuLabel {
156
185
  font-family: var(--basalt-font-mono);
157
186
  font-size: var(--vx-text-micro);
@@ -159,6 +188,65 @@
159
188
  text-transform: uppercase;
160
189
  letter-spacing: 0.08em;
161
190
  color: var(--vx-faint);
191
+ padding: 6px var(--vx-space-row-inset-x) 2px;
192
+ }
193
+
194
+ /*
195
+ * The popover's SECTION RHYTHM — what replaced the single `Menu.Divider`, and it is SPACE, not a
196
+ * line.
197
+ *
198
+ * The Drawer's bulk was never its row height (40px is a WCAG 2.5.5 floor pinned by
199
+ * `theme/density-relations.test.ts`); it was the stack rhythm around the rows — a 12px section
200
+ * gap, 18px of Drawer padding and a header row. A popover with one hairline through the middle and
201
+ * no rhythm at all is the other extreme, so each group opens its own band instead.
202
+ *
203
+ * A `border-top: 1px solid var(--vx-divider)` here was the obvious move and is deliberately NOT
204
+ * what shipped: `theme/divider-law.test.ts` inventories every horizontal hairline in the package
205
+ * and classifies it as `between-rows`, `region-boundary` or (inside `content/**` only)
206
+ * `prose-typography` — and it says in as many words that a rule under a HEADING in the chrome lane
207
+ * is none of the three, and that the fix is to delete it and let the type scale and the space do
208
+ * the work. A mono uppercase micro-label at `--vx-faint` over its own band of air is already a
209
+ * section boundary; a line under it would be the second one.
210
+ *
211
+ * `:not(:first-child)` keeps the first group flush with the dropdown's own padding instead of
212
+ * opening a band under the top edge with nothing above it — the same "a separator separates"
213
+ * property the guarded `Menu.Divider` used to carry in JSX. Scoped to `.menuDropdown` because
214
+ * `.menuLabel` is also the SHEET's group heading (`sheetGroupLabel`), where each label is its
215
+ * group Stack's first child and the rhythm is the Drawer's own to draw.
216
+ *
217
+ * The band is 6/4, down from 8/8 — the second half of the same "still too big" verdict the
218
+ * `max-height` above answers, and the cheaper half. MEASURED per heading at 8/8: 8px margin + 8px
219
+ * padding-top + 2px padding-bottom + a 17px line = a 35px box, and there are FIVE of them in the
220
+ * shipped More surface — 175px, 28% of the whole dropdown spent on five words. 6/4 takes each
221
+ * banded heading to 29px and hands 24px back to the rows. It does NOT touch the row height (40px
222
+ * is the WCAG 2.5.5 floor named at the top of this comment) and it does not add the line the
223
+ * paragraph above rules out: this is the same space that argument asked to carry the boundary,
224
+ * spent tighter.
225
+ */
226
+ .menuDropdown .menuLabel:not(:first-child) {
227
+ margin-top: 6px;
228
+ padding-top: 4px;
229
+ }
230
+
231
+ /* The same band, on the first row of the UNLABELLED tail (blocks + account + settings) — the one
232
+ * boundary in the More surface with no heading to open it. Margin rather than padding: a row is a
233
+ * touch target with a `min-height` floor and symmetric inset, so growing one side of it would make
234
+ * that row read as a different size from its siblings. */
235
+ .menuDropdown .menuSectionStart {
236
+ margin-top: 8px;
237
+ }
238
+
239
+ /*
240
+ * A sticky heading over a scrolling dropdown must be OPAQUE. `theme/index.ts` documents the same
241
+ * fix for the Drawer's header: basalt dropped that background to `transparent`, which reads fine
242
+ * until rows actually scroll under it and then shows every one of them straight through the title.
243
+ * `--vx-surface-overlay` is the dropdown's own fill, so the heading matches the box it sits in.
244
+ */
245
+ .menuDropdown .menuLabel {
246
+ position: sticky;
247
+ top: 0;
248
+ z-index: 1;
249
+ background-color: var(--vx-surface-overlay);
162
250
  }
163
251
 
164
252
  /*
@@ -13,6 +13,7 @@ declare const classes: {
13
13
  readonly menuItem: string
14
14
  readonly menuItemNested: string
15
15
  readonly menuLabel: string
16
+ readonly menuSectionStart: string
16
17
  readonly sheet: string
17
18
  readonly sheetTitle: string
18
19
  readonly sheetHeader: string
@@ -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
  >