basalt-ui 1.18.0 → 1.19.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 (92) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/agent/rules/basalt-mantine.md +71 -6
  4. package/agent/rules/basalt-router.md +129 -30
  5. package/agent/rules/basalt-tokens.md +2 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +8 -0
  9. package/dist/nav/types.d.ts +110 -0
  10. package/dist/nav/types.d.ts.map +1 -0
  11. package/dist/nav/types.js +0 -0
  12. package/dist/router-tanstack/index.d.ts +9 -2
  13. package/dist/router-tanstack/index.d.ts.map +1 -1
  14. package/dist/router-tanstack/index.js +7 -0
  15. package/dist/router-tanstack/nav.d.ts +163 -0
  16. package/dist/router-tanstack/nav.d.ts.map +1 -0
  17. package/dist/router-tanstack/nav.js +38 -0
  18. package/dist/router-tanstack/nav.test.d.ts +2 -0
  19. package/dist/router-tanstack/nav.test.d.ts.map +1 -0
  20. package/dist/router-tanstack/search-param-store.d.ts +19 -9
  21. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  22. package/dist/router-tanstack/use-nav.d.ts +30 -0
  23. package/dist/router-tanstack/use-nav.d.ts.map +1 -0
  24. package/dist/router-tanstack/use-nav.js +71 -0
  25. package/dist/shell/app-breadcrumbs.d.ts +7 -9
  26. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  27. package/dist/shell/app-breadcrumbs.js +8 -4
  28. package/dist/shell/app-mobile-nav.d.ts +23 -24
  29. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  30. package/dist/shell/app-mobile-nav.js +291 -69
  31. package/dist/shell/app-mobile-nav.module.css +191 -11
  32. package/dist/shell/app-mobile-nav.module.css.d.ts +11 -2
  33. package/dist/shell/app-mobile-nav.test.d.ts +2 -0
  34. package/dist/shell/app-mobile-nav.test.d.ts.map +1 -0
  35. package/dist/shell/app-sidebar.d.ts +3 -28
  36. package/dist/shell/app-sidebar.d.ts.map +1 -1
  37. package/dist/shell/app-sidebar.js +40 -174
  38. package/dist/shell/app-sidebar.module.css +3 -2
  39. package/dist/shell/app-sidebar.module.css.d.ts +0 -2
  40. package/dist/shell/index.d.ts +22 -65
  41. package/dist/shell/index.d.ts.map +1 -1
  42. package/dist/shell/index.js +38 -76
  43. package/dist/shell/mobile-nav-model.d.ts +41 -0
  44. package/dist/shell/mobile-nav-model.d.ts.map +1 -0
  45. package/dist/shell/mobile-nav-model.js +244 -0
  46. package/dist/shell/mobile-nav-model.test.d.ts +2 -0
  47. package/dist/shell/mobile-nav-model.test.d.ts.map +1 -0
  48. package/dist/shell/nav-count-badge.d.ts +11 -0
  49. package/dist/shell/nav-count-badge.d.ts.map +1 -0
  50. package/dist/shell/nav-count-badge.js +29 -0
  51. package/dist/surfaces.d.ts +1 -1
  52. package/dist/surfaces.js +1 -1
  53. package/dist/theme/index.d.ts.map +1 -1
  54. package/dist/theme/index.js +8 -7
  55. package/dist/theme/nav-link.module.css +4 -2
  56. package/dist/tokens/index.d.ts.map +1 -1
  57. package/dist/tokens/index.js +2 -0
  58. package/dist/tokens/palette.d.ts +15 -0
  59. package/dist/tokens/palette.d.ts.map +1 -1
  60. package/dist/tokens/palette.js +13 -0
  61. package/dist/tokens.css +2 -0
  62. package/llms.txt +3 -3
  63. package/package.json +1 -1
  64. package/src/index.ts +15 -5
  65. package/src/nav/types.ts +115 -0
  66. package/src/router-tanstack/index.ts +21 -2
  67. package/src/router-tanstack/nav.test.ts +111 -0
  68. package/src/router-tanstack/nav.ts +205 -0
  69. package/src/router-tanstack/search-param-store.ts +19 -9
  70. package/src/router-tanstack/use-nav.ts +146 -0
  71. package/src/shell/app-breadcrumbs.tsx +21 -19
  72. package/src/shell/app-mobile-nav.module.css +191 -11
  73. package/src/shell/app-mobile-nav.module.css.d.ts +11 -2
  74. package/src/shell/app-mobile-nav.test.tsx +403 -0
  75. package/src/shell/app-mobile-nav.tsx +449 -104
  76. package/src/shell/app-sidebar.module.css +3 -2
  77. package/src/shell/app-sidebar.module.css.d.ts +0 -2
  78. package/src/shell/app-sidebar.test.tsx +6 -10
  79. package/src/shell/app-sidebar.tsx +49 -206
  80. package/src/shell/index.test.tsx +62 -7
  81. package/src/shell/index.tsx +105 -164
  82. package/src/shell/mobile-nav-model.test.ts +296 -0
  83. package/src/shell/mobile-nav-model.ts +389 -0
  84. package/src/shell/nav-count-badge.tsx +39 -0
  85. package/src/surfaces.ts +1 -1
  86. package/src/theme/density-relations.test.ts +19 -1
  87. package/src/theme/index.ts +8 -7
  88. package/src/theme/nav-link.module.css +4 -2
  89. package/src/theme/spacing.test.ts +11 -0
  90. package/src/tokens/density.test.ts +2 -0
  91. package/src/tokens/index.ts +8 -5
  92. package/src/tokens/palette.ts +16 -0
@@ -1,7 +1,11 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
- import { Box, Drawer, NavLink, Stack, Text, UnstyledButton } from "@mantine/core";
3
- import { useState } from "react";
2
+ import { Drawer, Menu, NavLink, ScrollArea, Stack, Text, UnstyledButton } from "@mantine/core";
3
+ import { useReducedMotion } from "@mantine/hooks";
4
+ import { Fragment as Fragment2, useState } from "react";
5
+ import { NavCountBadge } from "./nav-count-badge.js";
6
+ import { useBasaltSpacing } from "../theme/index.js";
4
7
  import classes from "./app-mobile-nav.module.css";
8
+ const MENU_MIDDLEWARES = { flip: false, shift: { padding: 8 } };
5
9
  function IconMore() {
6
10
  return /* @__PURE__ */ jsxs(
7
11
  "svg",
@@ -24,83 +28,301 @@ function IconMore() {
24
28
  }
25
29
  );
26
30
  }
31
+ function accountRows(account) {
32
+ if (!account || account.state.status === "loading") return [];
33
+ const actions = account.actions;
34
+ if (account.state.status === "unauthenticated") {
35
+ return [{ key: "basalt-account-sign-in", label: "Sign in", onClick: actions?.onSignIn }];
36
+ }
37
+ const rows2 = [];
38
+ if (actions?.onManageAccount) {
39
+ rows2.push({
40
+ key: "basalt-account-manage",
41
+ label: "Account settings",
42
+ onClick: actions.onManageAccount
43
+ });
44
+ }
45
+ if (actions?.onManageBilling) {
46
+ rows2.push({
47
+ key: "basalt-account-billing",
48
+ label: "Billing & payment",
49
+ onClick: actions.onManageBilling
50
+ });
51
+ }
52
+ if (account.state.plan?.isFree && actions?.onUpgrade) {
53
+ rows2.push({ key: "basalt-account-upgrade", label: "Upgrade", onClick: actions.onUpgrade });
54
+ }
55
+ for (const item of actions?.extraMenuItems ?? []) {
56
+ rows2.push({
57
+ key: item.key,
58
+ label: item.label,
59
+ icon: item.icon,
60
+ ...item.danger !== void 0 && { danger: item.danger },
61
+ onClick: item.onClick
62
+ });
63
+ }
64
+ if (actions?.onSignOut) {
65
+ const onSignOut = actions.onSignOut;
66
+ rows2.push({
67
+ key: "basalt-account-sign-out",
68
+ label: "Sign out",
69
+ danger: true,
70
+ onClick: () => void onSignOut()
71
+ });
72
+ }
73
+ return rows2;
74
+ }
75
+ function accountRowCount(account) {
76
+ return accountRows(account).length;
77
+ }
78
+ function settingsRows(items) {
79
+ return (items ?? []).map((item) => ({
80
+ key: item.key,
81
+ label: item.label,
82
+ icon: item.icon,
83
+ onClick: item.onClick
84
+ }));
85
+ }
86
+ const menuRow = (item, depth) => {
87
+ const onClick = (event) => {
88
+ if (item.disabled) {
89
+ event.preventDefault();
90
+ return;
91
+ }
92
+ item.onClick?.(event);
93
+ };
94
+ const shared = {
95
+ className: depth > 0 ? `${classes.menuItem} ${classes.menuItemNested}` : classes.menuItem,
96
+ leftSection: item.icon,
97
+ disabled: Boolean(item.disabled),
98
+ // Mantine emits `data-disabled` (a STYLING hook) and nothing else, so the ARIA state is ours
99
+ // to set — a disabled destination still renders (§2.3 rule 11), so it has to announce itself.
100
+ "aria-disabled": item.disabled || void 0,
101
+ "aria-current": item.active ? "page" : void 0,
102
+ rightSection: item.count ? /* @__PURE__ */ jsx(NavCountBadge, { count: item.count }) : void 0,
103
+ onClick
104
+ };
105
+ const Anchor = item.Anchor;
106
+ if (Anchor && !item.disabled) {
107
+ return /* @__PURE__ */ jsx(Menu.Item, { component: Anchor, ...shared, children: item.label }, item.key);
108
+ }
109
+ return /* @__PURE__ */ jsx(
110
+ Menu.Item,
111
+ {
112
+ component: "a",
113
+ ...item.href !== void 0 && !item.disabled && { href: item.href },
114
+ ...shared,
115
+ children: item.label
116
+ },
117
+ item.key
118
+ );
119
+ };
120
+ const rows = (items, depth, render) => items.flatMap((item) => [render(item, depth), ...rows(item.children ?? [], depth + 1, render)]);
121
+ const menuActionRow = (row) => /* @__PURE__ */ jsx(
122
+ Menu.Item,
123
+ {
124
+ className: classes.menuItem,
125
+ leftSection: row.icon,
126
+ ...row.danger ? { color: "red" } : {},
127
+ onClick: (event) => row.onClick?.(event),
128
+ children: row.label
129
+ },
130
+ row.key
131
+ );
132
+ const sheetGroupLabel = (group) => group.label ? /* @__PURE__ */ jsx(Text, { component: "div", className: classes.menuLabel, children: group.label }) : null;
27
133
  function MobileNav({
28
- sections,
29
- onOpenMore,
30
- renderNavLink
134
+ model,
135
+ config,
136
+ account,
137
+ settingsMenuItems
31
138
  }) {
32
- const [openSection, setOpenSection] = useState(null);
33
- return /* @__PURE__ */ jsxs(Fragment, { children: [
34
- /* @__PURE__ */ jsxs("nav", { className: classes.bar, children: [
35
- sections.map((section) => /* @__PURE__ */ jsxs(
36
- UnstyledButton,
37
- {
38
- onClick: () => setOpenSection(section),
39
- className: classes.tab,
40
- "data-active": section.active || void 0,
41
- "aria-current": section.active ? "page" : void 0,
42
- "aria-label": section.label,
43
- children: [
44
- section.icon,
45
- /* @__PURE__ */ jsx(Text, { className: classes.label, children: section.label })
46
- ]
47
- },
48
- section.key
49
- )),
50
- /* @__PURE__ */ jsxs(UnstyledButton, { onClick: onOpenMore, className: classes.tab, "aria-label": "More", children: [
51
- /* @__PURE__ */ jsx(IconMore, {}),
52
- /* @__PURE__ */ jsx(Text, { className: classes.label, children: "More" })
53
- ] })
139
+ const [openKey, setOpenKey] = useState(null);
140
+ const reduceMotion = useReducedMotion();
141
+ const { step } = useBasaltSpacing();
142
+ const close = () => setOpenKey(null);
143
+ const scrollToTop = () => {
144
+ const target = config?.getScrollElement?.() ?? document.scrollingElement ?? document.documentElement;
145
+ target.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" });
146
+ };
147
+ const extraRows = [...accountRows(account), ...settingsRows(settingsMenuItems)];
148
+ const hasCount = (slot) => slot.kind === "link" ? Boolean(slot.item.count) : slot.groups.some((g) => groupHasCount(g.items));
149
+ const tabInner = (slot) => /* @__PURE__ */ jsxs(Fragment, { children: [
150
+ /* @__PURE__ */ jsxs("span", { className: classes.tabIcon, children: [
151
+ slot.icon ?? (slot.kind !== "link" && slot.isMore ? /* @__PURE__ */ jsx(IconMore, {}) : null),
152
+ hasCount(slot) ? /* @__PURE__ */ jsx("span", { className: classes.tabDot, "aria-hidden": true }) : null
54
153
  ] }),
154
+ /* @__PURE__ */ jsx(Text, { className: classes.label, children: slot.short })
155
+ ] });
156
+ const surfaceTab = (slot, onClick) => /* @__PURE__ */ jsx(
157
+ UnstyledButton,
158
+ {
159
+ className: classes.tab,
160
+ "data-active": slot.active || void 0,
161
+ "aria-current": slot.active ? "page" : void 0,
162
+ "aria-label": slot.label,
163
+ ...onClick ? { onClick } : {},
164
+ children: tabInner(slot)
165
+ }
166
+ );
167
+ const linkTab = (slot) => {
168
+ const onClick = (e) => {
169
+ if (slot.active) {
170
+ e.preventDefault();
171
+ scrollToTop();
172
+ }
173
+ slot.item.onClick?.(e);
174
+ };
175
+ const shared = {
176
+ className: classes.tab,
177
+ "data-active": slot.active || void 0,
178
+ "aria-current": slot.active ? "page" : void 0,
179
+ "aria-label": slot.label,
180
+ onClick
181
+ };
182
+ const Anchor = slot.item.Anchor;
183
+ if (Anchor) {
184
+ return /* @__PURE__ */ jsx(UnstyledButton, { component: Anchor, ...shared, children: tabInner(slot) }, slot.key);
185
+ }
186
+ return /* @__PURE__ */ jsx(
187
+ UnstyledButton,
188
+ {
189
+ component: "a",
190
+ ...slot.item.href !== void 0 && { href: slot.item.href },
191
+ ...shared,
192
+ children: tabInner(slot)
193
+ },
194
+ slot.key
195
+ );
196
+ };
197
+ const sheetRow = (item, depth) => {
198
+ const onClick = (e) => {
199
+ item.onClick?.(e);
200
+ close();
201
+ };
202
+ const shared = {
203
+ classNames: {
204
+ root: depth > 0 ? `${classes.row} ${classes.rowNested}` : classes.row
205
+ },
206
+ label: item.label,
207
+ leftSection: item.icon,
208
+ rightSection: item.count ? /* @__PURE__ */ jsx(NavCountBadge, { count: item.count }) : void 0,
209
+ active: Boolean(item.active),
210
+ disabled: Boolean(item.disabled),
211
+ // Same as the menu row above: `NavLink`'s `disabled` is a `mod`, i.e. `data-disabled` only.
212
+ "aria-disabled": item.disabled || void 0,
213
+ "aria-current": item.active ? "page" : void 0,
214
+ onClick
215
+ };
216
+ const Anchor = item.Anchor;
217
+ if (Anchor && !item.disabled) {
218
+ return /* @__PURE__ */ jsx(NavLink, { component: Anchor, ...shared }, item.key);
219
+ }
220
+ return /* @__PURE__ */ jsx(
221
+ NavLink,
222
+ {
223
+ component: "a",
224
+ ...item.href !== void 0 && !item.disabled && { href: item.href },
225
+ ...shared
226
+ },
227
+ item.key
228
+ );
229
+ };
230
+ const sheetActionRow = (row) => /* @__PURE__ */ jsx(
231
+ NavLink,
232
+ {
233
+ classNames: { root: classes.row },
234
+ label: row.label,
235
+ leftSection: row.icon,
236
+ ...row.danger ? { color: "red" } : {},
237
+ onClick: (event) => {
238
+ row.onClick?.(event);
239
+ close();
240
+ }
241
+ },
242
+ row.key
243
+ );
244
+ const menuSlot = (slot) => /* @__PURE__ */ jsxs(
245
+ Menu,
246
+ {
247
+ opened: openKey === slot.key,
248
+ onChange: (opened) => setOpenKey(opened ? slot.key : null),
249
+ position: slot.isMore ? "top-end" : "top",
250
+ offset: 8,
251
+ width: step.mobileNavMenuWidth,
252
+ withinPortal: true,
253
+ zIndex: 400,
254
+ middlewares: MENU_MIDDLEWARES,
255
+ trapFocus: true,
256
+ returnFocus: true,
257
+ closeOnItemClick: true,
258
+ closeOnClickOutside: true,
259
+ closeOnEscape: true,
260
+ menuItemTabIndex: 0,
261
+ transitionProps: {
262
+ transition: slot.isMore ? "pop-bottom-right" : "pop",
263
+ duration: reduceMotion ? 0 : 140,
264
+ exitDuration: reduceMotion ? 0 : 100
265
+ },
266
+ classNames: { dropdown: classes.menuDropdown, label: classes.menuLabel },
267
+ children: [
268
+ /* @__PURE__ */ jsx(Menu.Target, { children: surfaceTab(slot) }),
269
+ /* @__PURE__ */ jsxs(Menu.Dropdown, { children: [
270
+ slot.groups.flatMap((group) => [
271
+ ...group.label ? [/* @__PURE__ */ jsx(Menu.Label, { children: group.label }, `${group.key}-label`)] : [],
272
+ ...rows(group.items, 0, menuRow)
273
+ ]),
274
+ slot.isMore && extraRows.length > 0 && slotHasRows(slot) ? /* @__PURE__ */ jsx(Menu.Divider, {}) : null,
275
+ slot.isMore ? extraRows.map(menuActionRow) : null,
276
+ slot.isMore ? config?.moreExtra : null
277
+ ] })
278
+ ]
279
+ },
280
+ slot.key
281
+ );
282
+ const sheetSlot = (slot) => /* @__PURE__ */ jsxs(Fragment2, { children: [
283
+ surfaceTab(slot, () => setOpenKey(slot.key)),
55
284
  /* @__PURE__ */ jsx(
56
285
  Drawer,
57
286
  {
58
- opened: openSection !== null,
59
- onClose: () => setOpenSection(null),
287
+ opened: openKey === slot.key,
288
+ onClose: close,
60
289
  position: "bottom",
61
- size: "auto",
62
290
  padding: "md",
63
- title: openSection?.label,
64
- classNames: { content: classes.sheet, title: classes.sheetTitle },
65
- children: /* @__PURE__ */ jsx(Stack, { gap: 2, children: openSection?.items.map((item) => {
66
- const handleClick = (e) => {
67
- item.onClick?.(e);
68
- setOpenSection(null);
69
- };
70
- const active = Boolean(item.active);
71
- if (renderNavLink) {
72
- return /* @__PURE__ */ jsx(Box, { onClick: () => setOpenSection(null), children: renderNavLink(item, { active }) }, item.key);
73
- }
74
- return item.href ? /* @__PURE__ */ jsx(
75
- NavLink,
76
- {
77
- classNames: { root: classes.row },
78
- component: "a",
79
- href: item.href,
80
- label: item.label,
81
- leftSection: item.icon,
82
- active,
83
- onClick: handleClick
84
- },
85
- item.key
86
- ) : /* @__PURE__ */ jsxs(
87
- UnstyledButton,
88
- {
89
- onClick: handleClick,
90
- className: classes.row,
91
- "data-active": active || void 0,
92
- children: [
93
- item.icon,
94
- /* @__PURE__ */ jsx(Text, { className: classes.rowLabel, children: item.label })
95
- ]
96
- },
97
- item.key
98
- );
99
- }) })
291
+ title: slot.label,
292
+ classNames: {
293
+ content: classes.sheet,
294
+ title: classes.sheetTitle,
295
+ header: classes.sheetHeader
296
+ },
297
+ transitionProps: {
298
+ transition: "slide-up",
299
+ duration: reduceMotion ? 0 : 220,
300
+ timingFunction: "cubic-bezier(0.32, 0.72, 0, 1)"
301
+ },
302
+ children: /* @__PURE__ */ jsx(ScrollArea.Autosize, { mah: "62dvh", type: "scroll", children: /* @__PURE__ */ jsxs(Stack, { gap: "xs", children: [
303
+ slot.groups.map((group) => /* @__PURE__ */ jsxs(Stack, { gap: 2, children: [
304
+ sheetGroupLabel(group),
305
+ rows(group.items, 0, sheetRow)
306
+ ] }, group.key)),
307
+ slot.isMore && extraRows.length > 0 ? /* @__PURE__ */ jsx(Stack, { gap: 2, children: extraRows.map(sheetActionRow) }) : null,
308
+ slot.isMore ? config?.moreExtra : null
309
+ ] }) })
100
310
  }
101
311
  )
102
- ] });
312
+ ] }, slot.key);
313
+ return /* @__PURE__ */ jsx("nav", { className: classes.bar, "aria-label": "Primary", children: model.slots.map((slot) => {
314
+ if (slot.kind === "link") return linkTab(slot);
315
+ if (slot.kind === "menu") return menuSlot(slot);
316
+ return sheetSlot(slot);
317
+ }) });
318
+ }
319
+ function slotHasRows(slot) {
320
+ return slot.groups.some((group) => group.items.length > 0);
321
+ }
322
+ function groupHasCount(items) {
323
+ return items.some((item) => Boolean(item.count) || groupHasCount(item.children ?? []));
103
324
  }
104
325
  export {
105
- MobileNav
326
+ MobileNav,
327
+ accountRowCount
106
328
  };
@@ -1,3 +1,8 @@
1
+ /*
2
+ * The mobile bottom bar. Slots are DESTINATIONS: a tap navigates, so the bar must read as a tab
3
+ * bar rather than as chrome — hence a pill behind the icon for the active slot instead of a
4
+ * full-tab fill, and a press that depresses the icon without moving the hit box.
5
+ */
1
6
  .bar {
2
7
  display: flex;
3
8
  align-items: stretch;
@@ -5,6 +10,21 @@
5
10
  height: 100%;
6
11
  background: var(--mantine-color-body);
7
12
  border-top: 1px solid var(--mantine-color-default-border);
13
+ -webkit-tap-highlight-color: transparent;
14
+ /* NO padding-bottom / env() here: Mantine's own AppShell `.footer` rule already does
15
+ * `height: calc(var(--app-shell-footer-height) + env(safe-area-inset-bottom))` +
16
+ * `padding-bottom: env(safe-area-inset-bottom)`. Adding it here double-counts the inset. */
17
+ }
18
+
19
+ /* Mantine sets --app-shell-footer-OFFSET to the raw height WITHOUT the inset, so Main's
20
+ * padding-bottom is short by exactly one inset. This is the only real safe-area gap. */
21
+ @media (max-width: 47.99375em) {
22
+ .mainSafeArea {
23
+ padding-bottom: calc(
24
+ var(--app-shell-footer-offset, 0rem) + var(--app-shell-padding) +
25
+ env(safe-area-inset-bottom, 0px)
26
+ );
27
+ }
8
28
  }
9
29
 
10
30
  .tab {
@@ -16,18 +36,127 @@
16
36
  justify-content: center;
17
37
  gap: var(--vx-space-mobile-nav-tab-gap);
18
38
  color: var(--mantine-color-dimmed);
39
+ /* Kills the 300ms double-tap-zoom delay and the text-selection flash on a fast repeat tap. */
40
+ touch-action: manipulation;
41
+ user-select: none;
42
+ }
43
+
44
+ .tabIcon {
45
+ position: relative;
46
+ display: inline-flex;
47
+ align-items: center;
48
+ justify-content: center;
49
+ /* Deliberately sub-scale: the active pill hugs a 24px glyph, so it is a shape inset rather than
50
+ * layout spacing — the xs..xl scale has no step this tight. */
51
+ padding: 2px 12px; /* theme-allow */
52
+ border-radius: var(--vx-radius-ctrl);
53
+ transition: transform 140ms cubic-bezier(0.32, 0.72, 0, 1);
54
+ }
55
+
56
+ /* A consumer's sidebar icons are typically 18px; the bar normalizes them in CSS so the same
57
+ * `SidebarItem.icon` renders at slot size with zero consumer change. */
58
+ .tabIcon > svg {
59
+ width: var(--vx-space-mobile-nav-icon-size);
60
+ height: var(--vx-space-mobile-nav-icon-size);
19
61
  }
20
62
 
21
- /* Active = neutral surface fill + plain text color. Never the identity blue (DESIGN.md). */
63
+ .tab:active .tabIcon {
64
+ transform: scale(0.9);
65
+ }
66
+
67
+ /* Active = a neutral pill behind the ICON only, plus plain text color. A full-tab fill is what
68
+ * makes the bar read as chrome instead of a tab bar. Never the identity blue (DESIGN.md). */
22
69
  .tab[data-active] {
23
70
  color: var(--mantine-color-text);
71
+ }
72
+
73
+ .tab[data-active] .tabIcon {
24
74
  background-color: color-mix(in srgb, var(--vx-ink) 10%, transparent);
25
75
  }
26
76
 
77
+ .tab[data-active] .label {
78
+ font-weight: 600;
79
+ }
80
+
81
+ /* Unread dot. This is a BADGE, not the active state — the "never the identity blue" rule above
82
+ * governs the active fill and is unchanged. A count GLYPH is unreadable in a 56px slot. */
83
+ .tabDot {
84
+ position: absolute;
85
+ top: 0;
86
+ inset-inline-end: 8px;
87
+ width: 8px;
88
+ height: 8px;
89
+ border-radius: 999px;
90
+ background-color: var(--vx-accent);
91
+ box-shadow: 0 0 0 2px var(--mantine-color-body);
92
+ }
93
+
94
+ /* `text-overflow: clip`, never an ellipsis — a truncated word with a "…" in a 72px tab reads as
95
+ * broken rather than as abbreviated. `SidebarItem.short` is the fix, not the ellipsis. */
27
96
  .label {
28
97
  font-size: var(--vx-text-micro);
29
98
  line-height: 1;
30
99
  font-weight: 500;
100
+ max-width: 100%;
101
+ overflow: hidden;
102
+ text-overflow: clip;
103
+ white-space: nowrap;
104
+ }
105
+
106
+ /* The menu pops UP out of its tab and is configured `flip: false` — flipping a bottom-anchored
107
+ * menu would put it under the footer. `menuMax` is what guarantees it fits (§2.2). */
108
+ .menuDropdown {
109
+ padding: 4px;
110
+ max-height: min(60dvh, 360px);
111
+ overflow-y: auto;
112
+ overscroll-behavior: contain;
113
+ }
114
+
115
+ /* The theme's `Menu.extend` pins `styles.item` padding + font-size to the dense DESKTOP values
116
+ * INLINE, so a touch-sized mobile row needs `!important` — same cascade problem, same reason, as
117
+ * `.row` below. */
118
+ .menuItem {
119
+ min-height: var(--vx-space-mobile-nav-row-height) !important;
120
+ align-items: center;
121
+ }
122
+
123
+ .menuItem :global(.mantine-Menu-itemLabel) {
124
+ font-size: var(--mantine-font-size-md) !important;
125
+ }
126
+
127
+ /* An active destination reads the same in the More surface as it does in the sheet. */
128
+ .menuItem[aria-current='page'] {
129
+ background-color: color-mix(in srgb, var(--vx-ink) 13%, transparent);
130
+ font-weight: 600;
131
+ }
132
+
133
+ /*
134
+ * A disabled destination still RENDERS in the menu (§2.3 rule 11) — it just must not be reachable.
135
+ * Mantine's `Menu.css` styles `[data-disabled]` with a dimmed color and nothing else, and the row
136
+ * is polymorphic (`component="a"`), so the native `disabled` attribute does not apply either: with
137
+ * no rule here the row stays fully clickable. `pointer-events: none` is the real guard; `menuRow`
138
+ * carries a matching handler guard for the paths pointers do not cover (keyboard, synthetic
139
+ * clicks, a consumer stylesheet that resets this).
140
+ */
141
+ .menuItem[data-disabled] {
142
+ pointer-events: none;
143
+ cursor: not-allowed;
144
+ }
145
+
146
+ /* Nesting survives to mobile — a child destination is an indented row, not a sibling of its own
147
+ * parent (which is what today's flattened projection makes it). */
148
+ .menuItemNested {
149
+ padding-inline-start: calc(var(--mantine-spacing-md) + 12px) !important;
150
+ }
151
+
152
+ /* Overflow section heading inside the menu — inherits the theme's mono micro-label. */
153
+ .menuLabel {
154
+ font-family: var(--basalt-font-mono);
155
+ font-size: var(--vx-text-micro);
156
+ font-weight: 500;
157
+ text-transform: uppercase;
158
+ letter-spacing: 0.08em;
159
+ color: var(--vx-faint);
31
160
  }
32
161
 
33
162
  /*
@@ -39,6 +168,29 @@
39
168
  box-shadow: var(--vx-shadow-card);
40
169
  border-top-left-radius: var(--mantine-radius-lg);
41
170
  border-top-right-radius: var(--mantine-radius-lg);
171
+ /* A Drawer is NOT an AppShell section, so Mantine adds no inset of its own here. */
172
+ padding-bottom: env(safe-area-inset-bottom, 0px);
173
+
174
+ /*
175
+ * THE `--drawer-size-auto` TRAP — do not "fix" this by restoring `size="auto"` on the Drawer.
176
+ * `getSize('auto', 'drawer-size')` (Mantine's `get-size.mjs`) resolves ANY non-numeric size to
177
+ * `var(--drawer-size-auto)`, and `@mantine/core` defines that custom property NOWHERE — grepped
178
+ * the full shipped `styles.css`. On a bottom Drawer that dead var flows straight into
179
+ * `--drawer-height`, so `size="auto"` was never wired to anything; the sheet rendered at
180
+ * `calc(100% - offset*2)`, i.e. the full viewport, no matter how little content it held. That is
181
+ * why this component no longer passes a `size` prop at all — it would resolve to *some* value,
182
+ * but this rule below overrides Mantine's `height`/`max-height` regardless of what that value is.
183
+ *
184
+ * This works with NO `!important`: `packages/basalt-ui/styles.css` wraps every Mantine rule in
185
+ * `@layer mantine` (verified against the installed `styles.layer.css`), and this CSS module is
186
+ * unlayered — an unlayered rule always wins over a layered one, independent of specificity or
187
+ * source order. `height: auto` lets the sheet hug its content; `max-height` is a hard ceiling on
188
+ * the whole sheet (header + body), sized with headroom ABOVE the `ScrollArea.Autosize`'s
189
+ * `mah="62dvh"` in the JSX — that autosize cap is what actually decides when the sheet starts
190
+ * scrolling internally, so the two never fight over which one owns "the maximum".
191
+ */
192
+ height: auto;
193
+ max-height: min(70dvh, 100%);
42
194
  }
43
195
 
44
196
  /* Sheet title = a micro-label (docs/DESIGN-SPEC.md §3), matching the sidebar's `.sectionLabel`. */
@@ -51,7 +203,21 @@
51
203
  color: var(--vx-faint);
52
204
  }
53
205
 
54
- /* Large tap rows inside the group sheet. NavLink rows reuse the sidebar's neutral active fill.
206
+ /*
207
+ * Mantine's `ModalBase` header forces a `min-height` of `calc(3.75rem * var(--mantine-scale))`
208
+ * (60px) — sized for a real dialog title, not a 10px micro-label, and a big share of "unnecessarily
209
+ * big". `--vx-space-mobile-nav-row-height` pins it to the same 44px touch-target floor as every
210
+ * other row in this bar instead. The header's close button IS the dismiss affordance now — no
211
+ * separate grabber: there was never swipe-to-dismiss behind it (Mantine ships no drag primitive),
212
+ * so a plain decorative bar bought nothing an explicit, focusable close control doesn't already
213
+ * give for free, including to keyboard/screen-reader users the grabber never reached.
214
+ */
215
+ .sheetHeader {
216
+ min-height: var(--vx-space-mobile-nav-row-height);
217
+ padding: 0 var(--mantine-spacing-md);
218
+ }
219
+
220
+ /* Large tap rows inside the sheet. NavLink rows reuse the sidebar's neutral active fill.
55
221
  * `padding` needs `!important`: the theme's `NavLink.extend` forces the dense desktop row padding
56
222
  * (6px 10px) INLINE on every NavLink root, which would shrink these touch targets otherwise. */
57
223
  .row {
@@ -59,13 +225,23 @@
59
225
  align-items: center;
60
226
  gap: var(--mantine-spacing-sm);
61
227
  width: 100%;
228
+ min-height: var(--vx-space-mobile-nav-row-height);
62
229
  padding: var(--mantine-spacing-sm) var(--mantine-spacing-md) !important;
63
230
  border-radius: var(--mantine-radius-sm);
64
231
  color: var(--mantine-color-text);
232
+ touch-action: manipulation;
233
+ user-select: none;
234
+ }
235
+
236
+ /* Hover must be gated: on touch an ungated :hover leaves the last-tapped row stuck lit. */
237
+ @media (hover: hover) {
238
+ .row:hover {
239
+ background-color: color-mix(in srgb, var(--vx-ink) 8%, transparent);
240
+ }
65
241
  }
66
242
 
67
- .row:hover {
68
- background-color: color-mix(in srgb, var(--vx-ink) 8%, transparent);
243
+ .row:active {
244
+ background-color: color-mix(in srgb, var(--vx-ink) 10%, transparent);
69
245
  }
70
246
 
71
247
  /*
@@ -76,8 +252,7 @@
76
252
  * `classNames` are always injected, so the icon needs its own `!important` override here to hold
77
253
  * the mobile-only "never accent" doctrine.
78
254
  */
79
- .row[data-active],
80
- .row[data-active]:hover {
255
+ .row[data-active] {
81
256
  --nl-color: var(--mantine-color-text);
82
257
  color: var(--mantine-color-text);
83
258
  background-color: color-mix(in srgb, var(--vx-ink) 13%, transparent);
@@ -88,13 +263,18 @@
88
263
  color: var(--mantine-color-text) !important;
89
264
  }
90
265
 
91
- .rowLabel {
92
- font-size: var(--mantine-font-size-md);
93
- line-height: 1.2;
266
+ .rowNested {
267
+ padding-inline-start: calc(var(--mantine-spacing-md) + 12px) !important;
94
268
  }
95
269
 
96
- /* NavLink-rendered rows match `.rowLabel` — the theme pins the label at the dense desktop 13px
97
- * INLINE (`styles.label`), so `!important` is required to restore the mobile size. */
270
+ /* Sheet rows read at the mobile body size — the theme pins the label at the dense desktop 13px
271
+ * INLINE (`styles.label`), so `!important` is required to restore it. */
98
272
  .row :global(.mantine-NavLink-label) {
99
273
  font-size: var(--mantine-font-size-md) !important;
100
274
  }
275
+
276
+ @media (prefers-reduced-motion: reduce) {
277
+ .tabIcon {
278
+ transition: none;
279
+ }
280
+ }
@@ -1,14 +1,23 @@
1
1
  /**
2
2
  * Typed CSS-module declaration (the S4 CSS-modules path). Named string props — not an index
3
- * signature — so dot access stays verbatim under the package's strict flags.
3
+ * signature — so dot access stays verbatim under the package's strict flags. HAND-MAINTAINED:
4
+ * there is no generator, so a new class in `app-mobile-nav.module.css` must be added here too.
4
5
  */
5
6
  declare const classes: {
6
7
  readonly bar: string
8
+ readonly mainSafeArea: string
7
9
  readonly tab: string
10
+ readonly tabIcon: string
11
+ readonly tabDot: string
8
12
  readonly label: string
13
+ readonly menuDropdown: string
14
+ readonly menuItem: string
15
+ readonly menuItemNested: string
16
+ readonly menuLabel: string
9
17
  readonly sheet: string
10
18
  readonly sheetTitle: string
19
+ readonly sheetHeader: string
11
20
  readonly row: string
12
- readonly rowLabel: string
21
+ readonly rowNested: string
13
22
  }
14
23
  export default classes
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=app-mobile-nav.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-mobile-nav.test.d.ts","sourceRoot":"","sources":["../../src/shell/app-mobile-nav.test.tsx"],"names":[],"mappings":""}