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
@@ -106,45 +106,27 @@ function settingsRows(items) {
106
106
  ...item.active !== void 0 && { active: item.active }
107
107
  }));
108
108
  }
109
- const menuRow = (item, depth) => {
110
- const onClick = (event) => {
111
- if (item.disabled) {
112
- event.preventDefault();
113
- return;
109
+ function deepestScrolledDescendant(root) {
110
+ if (root === null) return null;
111
+ let best = null;
112
+ let bestDepth = -1;
113
+ for (const node of root.querySelectorAll("*")) {
114
+ if (node.scrollTop <= 0) continue;
115
+ if (node.getClientRects().length === 0) continue;
116
+ let depth = 0;
117
+ for (let parent = node.parentElement; parent !== null; parent = parent.parentElement) depth += 1;
118
+ if (depth > bestDepth) {
119
+ best = node;
120
+ bestDepth = depth;
114
121
  }
115
- item.onClick?.(event);
116
- };
117
- const shared = {
118
- className: depth > 0 ? `${classes.menuItem} ${classes.menuItemNested}` : classes.menuItem,
119
- leftSection: item.icon,
120
- disabled: Boolean(item.disabled),
121
- // Mantine emits `data-disabled` (a STYLING hook) and nothing else, so the ARIA state is ours
122
- // to set — a disabled destination still renders (§2.3 rule 11), so it has to announce itself.
123
- "aria-disabled": item.disabled || void 0,
124
- "aria-current": item.active ? "page" : void 0,
125
- rightSection: item.count ? /* @__PURE__ */ jsx(NavCountBadge, { count: item.count }) : void 0,
126
- onClick
127
- };
128
- const Anchor = item.Anchor;
129
- if (Anchor && !item.disabled) {
130
- return /* @__PURE__ */ jsx(Menu.Item, { component: Anchor, ...shared, children: item.label }, item.key);
131
122
  }
132
- return /* @__PURE__ */ jsx(
133
- Menu.Item,
134
- {
135
- component: "a",
136
- ...item.href !== void 0 && !item.disabled && { href: item.href },
137
- ...shared,
138
- children: item.label
139
- },
140
- item.key
141
- );
142
- };
123
+ return best;
124
+ }
143
125
  const rows = (items, depth, render) => items.flatMap((item) => [render(item, depth), ...rows(item.children ?? [], depth + 1, render)]);
144
- const menuActionRow = (row) => /* @__PURE__ */ jsx(
126
+ const menuActionRow = (row, sectionStart = false) => /* @__PURE__ */ jsx(
145
127
  Menu.Item,
146
128
  {
147
- className: classes.menuItem,
129
+ className: sectionStart ? `${classes.menuItem} ${classes.menuSectionStart}` : classes.menuItem,
148
130
  leftSection: row.icon,
149
131
  rightSection: row.active ? /* @__PURE__ */ jsx(IconCheck, {}) : void 0,
150
132
  ...row.danger ? { color: "red" } : {},
@@ -170,24 +152,49 @@ function MobileNav({
170
152
  const reduceMotion = useReducedMotion();
171
153
  const { step } = useBasaltSpacing();
172
154
  const close = () => setOpenKey(null);
173
- const scrollToTop = () => {
174
- const target = config?.getScrollElement?.() ?? document.querySelector(`[${SCROLLPORT_ATTRIBUTE}]`) ?? document.scrollingElement ?? document.documentElement;
155
+ const scrollElementToTop = (target) => {
156
+ if (!target || target.scrollTop <= 0) return false;
175
157
  target.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" });
158
+ return true;
159
+ };
160
+ const scrollToTop = () => {
161
+ const declared = config?.getScrollElement?.() ?? document.querySelector(`[${SCROLLPORT_ATTRIBUTE}]`);
162
+ if (scrollElementToTop(declared)) return true;
163
+ if (scrollElementToTop(deepestScrolledDescendant(declared ?? document.body))) return true;
164
+ return scrollElementToTop(document.scrollingElement ?? document.documentElement);
165
+ };
166
+ const onActivate = (item, event) => {
167
+ if (item.disabled) {
168
+ event.preventDefault();
169
+ return false;
170
+ }
171
+ if (item.active === true && scrollToTop()) event.preventDefault();
172
+ item.onClick?.(event);
173
+ return true;
176
174
  };
177
175
  const extraRows = [...accountRows(account), ...settingsRows(settingsMenuItems)];
178
176
  const moreBlocks = (blocks ?? []).filter(
179
177
  (block) => block.kind !== "custom" && sidebarBlockMobile(block) === "more" && (block.kind !== "list" || block.items.length > 0)
180
178
  );
181
179
  const listBlocks = moreBlocks.filter((block) => block.kind === "list");
180
+ const tailRule = (slot) => moreBlocks.length + extraRows.length > 0 && slotHasRows(slot);
182
181
  const activateBlock = (block) => {
183
182
  close();
184
183
  if (block.kind === "list") {
185
- setOpenBlockKey(block.key);
184
+ requestAnimationFrame(() => setOpenBlockKey(block.key));
186
185
  return;
187
186
  }
188
187
  block.onClick?.();
189
188
  };
190
- const menuBlockRow = (block) => /* @__PURE__ */ jsx(Menu.Item, { className: classes.menuItem, onClick: () => activateBlock(block), children: blockRowLabel(block) }, block.key);
189
+ const menuBlockRow = (block, sectionStart = false) => /* @__PURE__ */ jsx(
190
+ Menu.Item,
191
+ {
192
+ className: sectionStart ? `${classes.menuItem} ${classes.menuSectionStart}` : classes.menuItem,
193
+ onClick: () => activateBlock(block),
194
+ children: blockRowLabel(block)
195
+ },
196
+ block.key
197
+ );
191
198
  const sheetBlockRow = (block) => /* @__PURE__ */ jsx(
192
199
  NavLink,
193
200
  {
@@ -238,24 +245,20 @@ function MobileNav({
238
245
  ] }),
239
246
  /* @__PURE__ */ jsx(Text, { className: classes.label, children: slot.short })
240
247
  ] });
241
- const surfaceTab = (slot, onClick) => /* @__PURE__ */ jsx(
248
+ const surfaceTab = (slot, onClick, expanded) => /* @__PURE__ */ jsx(
242
249
  UnstyledButton,
243
250
  {
244
251
  className: classes.tab,
245
252
  "data-active": slot.active || void 0,
246
253
  "aria-current": slot.active ? "page" : void 0,
247
254
  "aria-label": slot.label,
248
- ...onClick ? { onClick } : {},
255
+ ...onClick ? { onClick, "aria-haspopup": "dialog", "aria-expanded": Boolean(expanded) } : {},
249
256
  children: tabInner(slot)
250
257
  }
251
258
  );
252
259
  const linkTab = (slot) => {
253
260
  const onClick = (e) => {
254
- if (slot.active) {
255
- e.preventDefault();
256
- scrollToTop();
257
- }
258
- slot.item.onClick?.(e);
261
+ onActivate(slot.item, e);
259
262
  };
260
263
  const shared = {
261
264
  className: classes.tab,
@@ -279,9 +282,38 @@ function MobileNav({
279
282
  slot.key
280
283
  );
281
284
  };
285
+ const menuRow = (item, depth) => {
286
+ const shared = {
287
+ className: depth > 0 ? `${classes.menuItem} ${classes.menuItemNested}` : classes.menuItem,
288
+ leftSection: item.icon,
289
+ disabled: Boolean(item.disabled),
290
+ // Mantine emits `data-disabled` (a STYLING hook) and nothing else, so the ARIA state is ours
291
+ // to set — a disabled destination still renders (§2.3 rule 11), so it has to announce itself.
292
+ "aria-disabled": item.disabled || void 0,
293
+ "aria-current": item.active ? "page" : void 0,
294
+ rightSection: item.count ? /* @__PURE__ */ jsx(NavCountBadge, { count: item.count }) : void 0,
295
+ onClick: (event) => {
296
+ onActivate(item, event);
297
+ }
298
+ };
299
+ const Anchor = item.Anchor;
300
+ if (Anchor && !item.disabled) {
301
+ return /* @__PURE__ */ jsx(Menu.Item, { component: Anchor, ...shared, children: item.label }, item.key);
302
+ }
303
+ return /* @__PURE__ */ jsx(
304
+ Menu.Item,
305
+ {
306
+ component: "a",
307
+ ...item.href !== void 0 && !item.disabled && { href: item.href },
308
+ ...shared,
309
+ children: item.label
310
+ },
311
+ item.key
312
+ );
313
+ };
282
314
  const sheetRow = (item, depth) => {
283
315
  const onClick = (e) => {
284
- item.onClick?.(e);
316
+ if (!onActivate(item, e)) return;
285
317
  close();
286
318
  };
287
319
  const shared = {
@@ -335,7 +367,7 @@ function MobileNav({
335
367
  onChange: (opened) => setOpenKey(opened ? slot.key : null),
336
368
  position: slot.isMore ? "top-end" : "top",
337
369
  offset: 8,
338
- width: step.mobileNavMenuWidth,
370
+ width: slot.isMore ? "min(78vw, 300px)" : step.mobileNavMenuWidth,
339
371
  withinPortal: true,
340
372
  zIndex: 400,
341
373
  middlewares: MENU_MIDDLEWARES,
@@ -358,22 +390,29 @@ function MobileNav({
358
390
  ...group.label ? [/* @__PURE__ */ jsx(Menu.Label, { children: group.label }, `${group.key}-label`)] : [],
359
391
  ...rows(group.items, 0, menuRow)
360
392
  ]),
361
- slot.isMore && moreBlocks.length + extraRows.length > 0 && slotHasRows(slot) ? /* @__PURE__ */ jsx(Menu.Divider, {}) : null,
362
- slot.isMore ? moreBlocks.map(menuBlockRow) : null,
363
- slot.isMore ? extraRows.map(menuActionRow) : null
393
+ slot.isMore ? moreBlocks.map((block, index) => menuBlockRow(block, tailRule(slot) && index === 0)) : null,
394
+ slot.isMore ? extraRows.map(
395
+ (row, index) => menuActionRow(row, tailRule(slot) && moreBlocks.length === 0 && index === 0)
396
+ ) : null
364
397
  ] })
365
398
  ]
366
399
  },
367
400
  slot.key
368
401
  );
369
402
  const sheetSlot = (slot) => /* @__PURE__ */ jsxs(Fragment2, { children: [
370
- surfaceTab(slot, () => setOpenKey(slot.key)),
403
+ surfaceTab(
404
+ slot,
405
+ () => setOpenKey(openKey === slot.key ? null : slot.key),
406
+ openKey === slot.key
407
+ ),
371
408
  /* @__PURE__ */ jsx(
372
409
  Drawer,
373
410
  {
374
411
  opened: openKey === slot.key,
375
412
  onClose: close,
376
413
  position: "bottom",
414
+ zIndex: 400,
415
+ overlayProps: { zIndex: 400 },
377
416
  padding: "md",
378
417
  title: slot.label,
379
418
  classNames: {
@@ -384,6 +423,10 @@ function MobileNav({
384
423
  transitionProps: {
385
424
  transition: "slide-up",
386
425
  duration: reduceMotion ? 0 : 220,
426
+ // Asymmetric on purpose: the entrance may take its time, the EXIT is a hit-test window.
427
+ // Mantine's default exit equals the duration, so a dismissed sheet left 220ms of live
428
+ // overlay over the bar — long enough to eat the next tab tap.
429
+ exitDuration: reduceMotion ? 0 : 120,
387
430
  timingFunction: "cubic-bezier(0.32, 0.72, 0, 1)"
388
431
  },
389
432
  children: /* @__PURE__ */ jsx(ScrollArea.Autosize, { mah: "62dvh", type: "scroll", offsetScrollbars: "present", scrollbarSize: 8, children: /* @__PURE__ */ jsxs(Stack, { gap: "var(--vx-space-sidebar-section-gap)", children: [
@@ -403,6 +446,8 @@ function MobileNav({
403
446
  opened: openBlockKey === block.key,
404
447
  onClose: () => setOpenBlockKey(null),
405
448
  position: "bottom",
449
+ zIndex: 400,
450
+ overlayProps: { zIndex: 400 },
406
451
  padding: "md",
407
452
  title: block.label,
408
453
  classNames: {
@@ -413,6 +458,8 @@ function MobileNav({
413
458
  transitionProps: {
414
459
  transition: "slide-up",
415
460
  duration: reduceMotion ? 0 : 220,
461
+ // Same reasoning as `sheetSlot`'s Drawer — a long exit leaves a live overlay over the bar.
462
+ exitDuration: reduceMotion ? 0 : 120,
416
463
  timingFunction: "cubic-bezier(0.32, 0.72, 0, 1)"
417
464
  },
418
465
  children: /* @__PURE__ */ jsx(ScrollArea.Autosize, { mah: "62dvh", type: "scroll", offsetScrollbars: "present", scrollbarSize: 8, children: /* @__PURE__ */ jsx(Stack, { gap: 1, children: block.items.map(blockItemRow) }) })
@@ -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
@@ -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
  /*
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/shell/index.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/shell/index.tsx"],"names":[],"mappings":"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"}
@@ -69,7 +69,6 @@ function collapseStore(key) {
69
69
  collapseStores.set(key, store);
70
70
  return store;
71
71
  }
72
- const SHELL_INSET = "sm";
73
72
  function BasaltShell(props) {
74
73
  assertRequiredProps("BasaltShell", props, ["brand", "sections"]);
75
74
  return /* @__PURE__ */ jsx(PageBarProvider, { globalActions: props.globalActions ?? [], children: /* @__PURE__ */ jsx(AsideProvider, { children: /* @__PURE__ */ jsx(ShellFrame, { ...props }) }) });
@@ -92,6 +91,7 @@ function ShellFrame({
92
91
  style
93
92
  }) {
94
93
  const { step } = useBasaltSpacing();
94
+ const inset = { base: step.appShellInsetMobile, sm: step.appShellInset };
95
95
  const aside = useAsideRegion();
96
96
  const [storedCollapsed, setStoredCollapsed] = collapseStore(storageKey)();
97
97
  const isCollapseControlled = collapsedProp !== void 0;
@@ -136,11 +136,11 @@ function ShellFrame({
136
136
  collapsed: { desktop: !aside.claimed, mobile: true }
137
137
  },
138
138
  footer: { height: { base: step.mobileNavBarHeight, sm: 0 } },
139
- padding: SHELL_INSET,
139
+ padding: inset,
140
140
  className: cx(mainClasses.shell, className),
141
141
  ...style !== void 0 && { style },
142
142
  children: [
143
- /* @__PURE__ */ jsx(AppShell.Header, { px: SHELL_INSET, children: /* @__PURE__ */ jsxs("div", { className: headerClasses.bar, children: [
143
+ /* @__PURE__ */ jsx(AppShell.Header, { px: inset, children: /* @__PURE__ */ jsxs("div", { className: headerClasses.bar, children: [
144
144
  /* @__PURE__ */ jsx("div", { className: brandClasses.zone, "data-collapsed": collapsed || void 0, children: /* @__PURE__ */ jsx(AppBrand, { brand, collapsed, onToggleCollapse: toggleCollapse }) }),
145
145
  /* @__PURE__ */ jsx("div", { className: headerClasses.lead, children: /* @__PURE__ */ jsx(AppBreadcrumbs, { ...activeCrumb }) }),
146
146
  /* @__PURE__ */ jsx(PageBarOutlet, { className: headerClasses.pageBar }),
@@ -9,19 +9,38 @@
9
9
  * The law it implements is the interaction model, not a heuristic: a slot is a DESTINATION, so a
10
10
  * tap navigates with no overlay to dismiss. An overlay exists only where a slot genuinely holds
11
11
  * more than one destination, and its surface is INFERRED from how many rows it holds rather than
12
- * configured — six rows is the arithmetic ceiling for a menu that pops out of a 56px bar without
13
- * ever rendering below the fold (6 x 44px + 8px padding = 272px against 415px of headroom on the
14
- * smallest supported viewport), so past that the surface becomes a bottom sheet.
12
+ * configured — up to `menuMax` rows it is a content-sized menu popping out of the tab, past that a
13
+ * bottom sheet.
14
+ *
15
+ * WHAT KEEPS THE MENU ABOVE THE FOLD IS CSS, NOT THIS CONSTANT. `.menuDropdown` declares
16
+ * `max-height: min(72dvh, 560px)` + `overflow-y: auto` + `overscroll-behavior: contain`, so the
17
+ * dropdown is bounded at ANY row count and scrolls inside itself past it. Measured at 390x844 the
18
+ * headroom above the bar is 772px (bar top 788, less the 8px menu offset and the 8px shift
19
+ * padding), so the 560px cap uses 73% of it; on the smallest supported viewport (320x568) the
20
+ * headroom is ~496px and 72dvh is 409px — the cap is binding on every supported phone, and the row
21
+ * count never is. The old arithmetic here ("6 x 44px + 8px padding against 415px of headroom") was
22
+ * stale in almost every number: rows are 40px (`--vx-space-mobile-nav-row-height`), the dropdown's
23
+ * own padding is `2 * --vx-space-stack-xs` = 8px, and the headroom figures above are the measured
24
+ * ones.
25
+ *
26
+ * `menuMax` therefore survives for the case a menu genuinely should not hold: a 30-destination nav
27
+ * still deserves a sheet, where a title, a close button and 62dvh of scroll read better than a
28
+ * tall popover hanging off one tab.
15
29
  */
16
30
  import type { MobileNavConfig, MobileNavModel, SidebarBlock, SidebarSection } from './nav-types.js';
17
31
  /** Hard cap on bar slots, INCLUDING More. Five 72px slots is the widest a 360px viewport holds. */
18
32
  export declare const MOBILE_MAX_TABS_DEFAULT = 5;
19
33
  /**
20
- * Rows a slot may hold before it becomes a sheet instead of a menu. Arithmetic, not taste — see
21
- * the module doc: six rows is the last count a bottom-anchored, non-flipping menu fits above the
22
- * bar on the smallest supported viewport.
34
+ * Rows a slot may hold before it becomes a sheet instead of a menu.
35
+ *
36
+ * It was 6 on the theory that the count was what kept the menu above the fold. It is not — the
37
+ * dropdown's own `max-height` is (module doc), at every row count. What 6 actually did was flip
38
+ * the playground's ~11-row More slot into a full Drawer, which is the "big door on mobile" the
39
+ * owner reported. 12 keeps every realistic More surface a compact popover and leaves the sheet
40
+ * for the nav that has genuinely outgrown one. `mobileNav.menuMax` remains the consumer opt-out
41
+ * in both directions.
23
42
  */
24
- export declare const MOBILE_MENU_MAX_DEFAULT = 6;
43
+ export declare const MOBILE_MENU_MAX_DEFAULT = 12;
25
44
  /** The overflow slot's reserved key. A slot like any other — it just exists only when needed. */
26
45
  export declare const MOBILE_MORE_KEY = "__more";
27
46
  export type ProjectMobileNavOptions = {
@@ -1 +1 @@
1
- {"version":3,"file":"mobile-nav-model.d.ts","sourceRoot":"","sources":["../../src/shell/mobile-nav-model.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,KAAK,EACV,eAAe,EAEf,cAAc,EAGd,YAAY,EAEZ,cAAc,EACf,MAAM,aAAa,CAAA;AAGpB,mGAAmG;AACnG,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,iGAAiG;AACjG,eAAO,MAAM,eAAe,WAAW,CAAA;AAEvC,MAAM,MAAM,uBAAuB,GAAG;IACpC,MAAM,CAAC,EAAE,eAAe,GAAG,SAAS,CAAA;IACpC;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB,CAAA;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,YAAY,EAAE,GAAG,SAAS,GAAG,MAAM,CAQjF;AAwVD;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,cAAc,EAAE,EAC1B,IAAI,CAAC,EAAE,uBAAuB,GAC7B,cAAc,CA2EhB"}
1
+ {"version":3,"file":"mobile-nav-model.d.ts","sourceRoot":"","sources":["../../src/shell/mobile-nav-model.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,OAAO,KAAK,EACV,eAAe,EAEf,cAAc,EAGd,YAAY,EAEZ,cAAc,EACf,MAAM,aAAa,CAAA;AAGpB,mGAAmG;AACnG,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC;;;;;;;;;GASG;AACH,eAAO,MAAM,uBAAuB,KAAK,CAAA;AAEzC,iGAAiG;AACjG,eAAO,MAAM,eAAe,WAAW,CAAA;AAEvC,MAAM,MAAM,uBAAuB,GAAG;IACpC,MAAM,CAAC,EAAE,eAAe,GAAG,SAAS,CAAA;IACpC;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB,CAAA;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,YAAY,EAAE,GAAG,SAAS,GAAG,MAAM,CAQjF;AA8VD;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,cAAc,EAAE,EAC1B,IAAI,CAAC,EAAE,uBAAuB,GAC7B,cAAc,CA8EhB"}
@@ -1,6 +1,6 @@
1
1
  import { sidebarBlockMobile } from "./sidebar-block-model.js";
2
2
  const MOBILE_MAX_TABS_DEFAULT = 5;
3
- const MOBILE_MENU_MAX_DEFAULT = 6;
3
+ const MOBILE_MENU_MAX_DEFAULT = 12;
4
4
  const MOBILE_MORE_KEY = "__more";
5
5
  function blockRowCount(blocks) {
6
6
  if (blocks === void 0) return 0;
@@ -117,7 +117,13 @@ function sectionCandidate(section, menuMax) {
117
117
  // The section's own identity wins when it declares one — the consumer configured the
118
118
  // SLOT — and falls back to the single destination's icon when it does not.
119
119
  icon: mobile?.icon ?? section.icon ?? only.icon,
120
- active: hasActiveDestination(only),
120
+ // `isActiveDestination`, not the rollup — this collapse produced a LINK, and a link slot
121
+ // covers exactly the one key it navigates to, exactly as `itemCandidate` does. The two
122
+ // predicates agree TODAY (this branch needs `rows === 1`, `countRows` counts descendants,
123
+ // so `only` is childless whenever it is taken) — which is the point: activeness now reads
124
+ // the slot's own `covered` set instead of a non-local fact about what `rows === 1`
125
+ // implies. Widen the collapse and the rollup would light a tab whose tap lands elsewhere.
126
+ active: isActiveDestination(only),
121
127
  item: only
122
128
  };
123
129
  }
@@ -252,7 +258,10 @@ function projectMobileNav(sections, opts) {
252
258
  label: only.label,
253
259
  short: only.short ?? only.label,
254
260
  icon: only.icon,
255
- active: hasActiveDestination(only),
261
+ // Same as `sectionCandidate`'s collapse, for the same reason and with the same caveat: a
262
+ // LINK slot reads only the destination its tap reaches, and `rows === 1` makes the two
263
+ // predicates agree today — state the law locally rather than inherit it.
264
+ active: isActiveDestination(only),
256
265
  item: only
257
266
  });
258
267
  return { slots };