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,32 +1,40 @@
1
1
  /**
2
2
  * BasaltShell — the application shell (collapsible sidebar rail + slim top bar + mobile bottom
3
- * nav). Grounded in argo's app-shell: the `SidebarItem`/`SidebarSection` types come verbatim from
4
- * `apps/dashboard/src/components/app-shell/app-sidebar.tsx`, and the layout mirrors the `AppShell`
5
- * composition in `apps/dashboard/src/routes/__root.tsx`.
3
+ * bar). Grounded in argo's app-shell; the layout mirrors the `AppShell` composition in
4
+ * `apps/dashboard/src/routes/__root.tsx`.
5
+ *
6
+ * ONE nav definition drives both halves. `sections` renders the desktop sidebar, and
7
+ * `projectMobileNav` projects the SAME sections onto the mobile bar — so a destination is declared
8
+ * once and `SidebarItem.mobile` decides where it lands. There is no mobile sidebar drawer: below
9
+ * the `sm` breakpoint the navbar is permanently collapsed and the bottom bar IS the nav, which is
10
+ * why the More surface must also carry the account and settings rows the sidebar footer holds on
11
+ * desktop.
6
12
  *
7
13
  * Router-agnostic: argo's router coupling (typed `navigate`, `useMatchRoute` active detection, the
8
14
  * sidebar-collapse zustand store) stays consumer-side. The consumer resolves `item.active`,
9
- * `item.onClick`, `item.badge` and (optionally) passes a `renderNavLink` so its router `<Link>`
10
- * renders each nav row. The breadcrumb is derived from the active item across `sections`, not from a
11
- * router hook. Collapse is persisted via `@mantine/hooks` `useLocalStorage` keyed by `storageKey`.
15
+ * `item.onClick`, `item.badge`/`item.count` and supplies `item.Anchor` — its router `<Link>`,
16
+ * which basalt HOSTS rather than delegating rendering to. The breadcrumb is derived from the
17
+ * active item across `sections`, not from a router hook. Collapse is persisted via
18
+ * `@mantine/hooks` `useLocalStorage` keyed by `storageKey`.
12
19
  */
13
- import { AppShell, Badge } from '@mantine/core'
14
- import { useDisclosure, useLocalStorage } from '@mantine/hooks'
20
+ import { AppShell } from '@mantine/core'
21
+ import { useLocalStorage } from '@mantine/hooks'
22
+ import { useMemo } from 'react'
15
23
  import type { MouseEvent, ReactNode } from 'react'
16
24
  import { AppSidebar } from './app-sidebar'
17
- import type { NavLinkRenderer } from './app-sidebar'
18
- import { MobileNav } from './app-mobile-nav'
19
- import type { MobileNavItem, MobileNavSection } from './app-mobile-nav'
25
+ import { MobileNav, accountRowCount } from './app-mobile-nav'
26
+ import { projectMobileNav } from './mobile-nav-model'
20
27
  import { AppBreadcrumbs } from './app-breadcrumbs'
21
- import type { BreadcrumbLinkRenderer } from './app-breadcrumbs'
22
28
  import { PageActionsOutlet, PageHeaderProvider } from './page-header'
23
29
  import type { BasaltAccountProps } from './account-types'
24
30
  import type { SidebarSearchConfig } from './sidebar-search'
25
- import { VX } from '../tokens'
31
+ import type { MobileNavConfig, NavAnchor, SidebarItem, SidebarSection } from '../nav/types'
26
32
  import { useBasaltSpacing } from '../theme'
27
33
  import headerClasses from './app-header.module.css'
34
+ import mobileNavClasses from './app-mobile-nav.module.css'
28
35
 
29
- export { AppSidebar, type AppSidebarProps, type NavLinkRenderer } from './app-sidebar'
36
+ export { AppSidebar, type AppSidebarProps } from './app-sidebar'
37
+ export { NavCountBadge } from './nav-count-badge'
30
38
  export { SidebarSearch, type SidebarSearchConfig } from './sidebar-search'
31
39
  export { SidebarAccount } from './app-sidebar-account'
32
40
  export type {
@@ -39,46 +47,35 @@ export type {
39
47
  BasaltAccountActions,
40
48
  BasaltAccountProps,
41
49
  } from './account-types'
50
+ export { MobileNav, type MobileNavProps } from './app-mobile-nav'
42
51
  export {
43
- MobileNav,
44
- type MobileNavItem,
45
- type MobileNavSection,
46
- type MobileNavLinkRenderer,
47
- } from './app-mobile-nav'
48
- export { AppBreadcrumbs, type BreadcrumbLinkRenderer } from './app-breadcrumbs'
52
+ projectMobileNav,
53
+ type ProjectMobileNavOptions,
54
+ MOBILE_MAX_TABS_DEFAULT,
55
+ MOBILE_MENU_MAX_DEFAULT,
56
+ MOBILE_MORE_KEY,
57
+ } from './mobile-nav-model'
58
+ export { AppBreadcrumbs } from './app-breadcrumbs'
49
59
  export { PageHeaderProvider, PageActions, PageActionsOutlet } from './page-header'
50
60
 
51
- /** A single sidebar destination. Grounded verbatim in argo's `SidebarItem`. */
52
- export type SidebarItem = {
53
- key: string
54
- label: string
55
- /** Short label for the mobile bottom-nav; falls back to `label`. */
56
- short?: string
57
- /** Whether this destination appears in the mobile bottom-nav. */
58
- mobile?: boolean
59
- icon: ReactNode
60
- href?: string
61
- active?: boolean
62
- disabled?: boolean
63
- onClick?: (e: MouseEvent) => void
64
- badge?: ReactNode
65
- /** Nested sub-navigation items — surfaced in a hover popover and inline when active. */
66
- children?: SidebarItem[]
67
- }
68
-
69
- /** A labelled group of sidebar items. Grounded verbatim in argo's `SidebarSection`. */
70
- export type SidebarSection = {
71
- label: string
72
- items: SidebarItem[]
73
- /** Group icon, used by the mobile tabs. */
74
- icon?: ReactNode
75
- /** Desktop: render a clickable group header that collapses its items. */
76
- collapsible?: boolean
77
- /** Initial collapsed state when `collapsible`. */
78
- defaultCollapsed?: boolean
79
- /** `false` excludes the section from the primary mobile group tabs. */
80
- mobileTab?: boolean
81
- }
61
+ /**
62
+ * The shared nav vocabulary lives in `src/nav/types.ts` so the headless router bridge can `import
63
+ * type` it without reaching into the Mantine layer. Re-exported here so `SidebarItem` /
64
+ * `SidebarSection` keep resolving straight from `basalt-ui`, exactly as before.
65
+ */
66
+ export type {
67
+ NavAnchor,
68
+ NavAnchorProps,
69
+ NavMobilePlacement,
70
+ NavSectionMobile,
71
+ SidebarItem,
72
+ SidebarSection,
73
+ MobileNavConfig,
74
+ MobileNavGroup,
75
+ MobileNavModel,
76
+ MobileNavSlot,
77
+ MobileNavSurface,
78
+ } from '../nav/types'
82
79
 
83
80
  /** Brand identity shown in the sidebar header (logo + name). */
84
81
  export type BrandConfig = {
@@ -103,25 +100,21 @@ export type SettingsMenuItem = {
103
100
  export type BasaltShellProps = {
104
101
  /** Brand identity for the sidebar header. */
105
102
  brand: BrandConfig
106
- /** Grouped nav sections rendered in the sidebar (and projected to the mobile bottom nav). */
103
+ /** Grouped nav sections — the ONE definition, rendered as the sidebar and projected to the bar. */
107
104
  sections: SidebarSection[]
108
105
  /**
109
- * Optional consumer link renderer (e.g. a router `<Link>`). Receives the precomputed `active`
110
- * flag. When omitted, items fall back to a plain `<a href>` + `item.onClick`. This is the router
111
- * seam — no router primitive is imported by the shell.
106
+ * Mobile bar tuning. Every field optional — the defaults ARE the design, and a nav with nothing
107
+ * configured still produces a working bar (see `projectMobileNav`'s zero-config fallback).
112
108
  */
113
- renderNavLink?: NavLinkRenderer
109
+ mobileNav?: MobileNavConfig
114
110
  /** Persistent, shell-owned top-bar slot (timer, refresh, notifications). */
115
111
  globalActions?: ReactNode
116
- /** Extra content appended to the sidebar footer, beside the settings menu (mobile close, etc.). */
117
- sidebarFooterExtra?: ReactNode
118
112
  /**
119
- * Arbitrary content appended after `sections` inside the sidebar's nav scroll region (a tree, a
120
- * filter panel, a project list, …). Hidden on the collapsed desktop rail; still present in the
121
- * mobile drawer, which opens at full width — see `AppSidebarProps.navExtra`'s JSDoc for why.
122
- * `AppSidebar` renders once and serves both the desktop rail and the mobile drawer (collapse
123
- * styling is gated behind a `min-width: sm` media query, not a second render), so this slot
124
- * reaches both.
113
+ * DESKTOP ONLY. Arbitrary content appended after `sections` inside the sidebar's nav scroll
114
+ * region (a tree, a filter panel, a project list, …), for anything a set of `SidebarItem`s can't
115
+ * express. Hidden on the collapsed desktop rail; the sidebar itself no longer exists below the
116
+ * `sm` breakpoint, so this slot has no mobile representation — put mobile-reachable extras in
117
+ * `mobileNav.moreExtra` instead.
125
118
  */
126
119
  sidebarNavExtra?: ReactNode
127
120
  /** Entries appended to the sidebar settings menu. */
@@ -151,59 +144,19 @@ export type BasaltShellProps = {
151
144
  * a no-op when omitted.
152
145
  */
153
146
  onCollapsedChange?: (collapsed: boolean) => void
154
- /**
155
- * Optional router link renderer for the breadcrumb parent segment. When provided, the parent
156
- * breadcrumb label is rendered through this callback (e.g. a TanStack `<Link>`) instead of a
157
- * plain `<a href>`, enabling client-side navigation.
158
- */
159
- renderBreadcrumbLink?: BreadcrumbLinkRenderer | undefined
160
147
  /** Page content. */
161
148
  children?: ReactNode
162
149
  }
163
150
 
164
- /**
165
- * Sidebar nav count badge (docs/DESIGN-SPEC.md §5): mono 10.5px, ink-8% bg, radius 5, height 16,
166
- * padding 0 5px, muted text; `marginLeft: auto` pins it to the row end on any render path.
167
- * `styles` (inline) rather than a token color prop, since none of Mantine's variant/color
168
- * combinations land on the ink-tint idiom. Returns `null` for a zero/empty count so the badge slot
169
- * stays clean ("ink earns its color", DESIGN.md).
170
- */
171
- export function NavCountBadge({ count }: { count: number }) {
172
- if (!count) return null
173
- return (
174
- <Badge
175
- size="sm"
176
- styles={{
177
- root: {
178
- backgroundColor: 'color-mix(in srgb, var(--vx-ink) 8%, transparent)',
179
- color: 'var(--vx-muted)',
180
- fontFamily: 'var(--basalt-font-mono)',
181
- fontSize: VX.text.micro,
182
- fontWeight: 500,
183
- height: 16,
184
- padding: '0 5px',
185
- marginLeft: 'auto',
186
- borderRadius: 'var(--vx-radius-tight)',
187
- },
188
- }}
189
- >
190
- {count}
191
- </Badge>
192
- )
193
- }
194
-
195
- /** Active nav item across all sections → `{ section, parent?, parentHref?, page }` for the breadcrumb. */
196
- function findActiveCrumb(
197
- sections: SidebarSection[],
198
- ):
199
- | { section: string; parent?: string | undefined; parentHref?: string | undefined; page: string }
200
- | undefined {
151
+ /** Active nav item across all sections → the breadcrumb's `{ section, parent?, …, page }`. */
152
+ function findActiveCrumb(sections: SidebarSection[]): ActiveCrumb | undefined {
201
153
  for (const section of sections) {
202
154
  const found = findActiveWithParent(section.items)
203
155
  if (found)
204
156
  return {
205
157
  section: section.label,
206
158
  parent: found.parent,
159
+ parentAnchor: found.parentAnchor,
207
160
  parentHref: found.parentHref,
208
161
  page: found.page,
209
162
  }
@@ -211,72 +164,46 @@ function findActiveCrumb(
211
164
  return undefined
212
165
  }
213
166
 
214
- /** Recursively search for the deepest active item, returning the parent label + href when nested. */
167
+ type ActiveCrumb = {
168
+ section: string
169
+ parent?: string | undefined
170
+ parentAnchor?: NavAnchor | undefined
171
+ parentHref?: string | undefined
172
+ page: string
173
+ }
174
+
175
+ /**
176
+ * Recursively search for the deepest active item, returning the parent's label and its ROUTER
177
+ * ANCHOR (not just an href) when nested — the crumb navigates client-side through the same seam
178
+ * every nav row uses, so no second render callback is needed for it.
179
+ */
215
180
  function findActiveWithParent(
216
181
  items: SidebarItem[],
217
182
  parentLabel?: string | undefined,
183
+ parentAnchor?: NavAnchor | undefined,
218
184
  parentHref?: string | undefined,
219
- ): { parent?: string | undefined; parentHref?: string | undefined; page: string } | undefined {
185
+ ): Omit<ActiveCrumb, 'section'> | undefined {
220
186
  for (const item of items) {
221
187
  // Recurse into children first — deeper active match wins over a prefix-matched parent.
222
188
  if (item.children) {
223
- const found = findActiveWithParent(item.children, item.label, item.href)
189
+ const found = findActiveWithParent(item.children, item.label, item.Anchor, item.href)
224
190
  if (found) return found
225
191
  }
226
- if (item.active) return { parent: parentLabel, parentHref, page: item.label }
192
+ if (item.active) return { parent: parentLabel, parentAnchor, parentHref, page: item.label }
227
193
  }
228
194
  return undefined
229
195
  }
230
196
 
231
- /**
232
- * Mobile bottom-nav projection: one tab per (non-`mobileTab:false`) section, each raising a bottom
233
- * sheet of that section's destinations. Children are flattened into the section's item list so
234
- * subpages appear in the mobile drawer.
235
- */
236
- function toMobileSections(sections: SidebarSection[], closeMobile: () => void): MobileNavSection[] {
237
- return sections
238
- .filter((s) => s.mobileTab !== false)
239
- .map((s) => ({
240
- key: s.label,
241
- label: s.label,
242
- icon: s.icon,
243
- active: s.items.some((i) => i.active),
244
- items: flattenSidebarItems(s.items).map(
245
- (i): MobileNavItem => ({
246
- key: i.key,
247
- label: i.label,
248
- icon: i.icon,
249
- ...(i.href !== undefined && { href: i.href }),
250
- ...(i.active !== undefined && { active: i.active }),
251
- onClick: (e: MouseEvent) => {
252
- i.onClick?.(e)
253
- closeMobile()
254
- },
255
- }),
256
- ),
257
- }))
258
- }
259
-
260
- /** Flatten a tree of SidebarItems (including children) into a single-level array. */
261
- function flattenSidebarItems(items: SidebarItem[]): SidebarItem[] {
262
- return items.flatMap((item) => [
263
- item,
264
- ...(item.children ? flattenSidebarItems(item.children) : []),
265
- ])
266
- }
267
-
268
197
  export function BasaltShell({
269
198
  brand,
270
199
  sections,
271
- renderNavLink,
200
+ mobileNav,
272
201
  globalActions,
273
- sidebarFooterExtra,
274
202
  sidebarNavExtra,
275
203
  settingsMenuItems,
276
204
  storageKey = 'basalt-sidebar-collapsed',
277
205
  collapsed: collapsedProp,
278
206
  onCollapsedChange,
279
- renderBreadcrumbLink,
280
207
  account,
281
208
  search,
282
209
  children,
@@ -286,7 +213,6 @@ export function BasaltShell({
286
213
  // (controls, the search trigger/avatar, nav labels) already track density, so a fixed literal
287
214
  // container squeezes progressively worse as density rises.
288
215
  const { step } = useBasaltSpacing()
289
- const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] = useDisclosure()
290
216
  const [storedCollapsed, setStoredCollapsed] = useLocalStorage({
291
217
  key: storageKey,
292
218
  defaultValue: false,
@@ -303,7 +229,18 @@ export function BasaltShell({
303
229
  }
304
230
 
305
231
  const activeCrumb = findActiveCrumb(sections)
306
- const mobileSections = toMobileSections(sections, closeMobile)
232
+ // The account row and every settings entry render as flat rows in the More surface (there is no
233
+ // mobile sidebar to reach them through any more), so they count toward BOTH `needsMore` and the
234
+ // menu-vs-sheet threshold. `accountRowCount` is the SAME function that renders those rows, not a
235
+ // second estimate of them: an account is worth 0 rows while `loading` and up to seven once
236
+ // authenticated, and §2.2's whole guarantee (`menuMax` rows fit the headroom above the bar, and
237
+ // the menu runs `flip: false` so it cannot escape upward) is arithmetic over this number.
238
+ const extraMoreRows =
239
+ accountRowCount(account) + (settingsMenuItems?.length ?? 0) + (mobileNav?.moreExtra ? 1 : 0)
240
+ const model = useMemo(
241
+ () => projectMobileNav(sections, { config: mobileNav, extraMoreRows }),
242
+ [sections, mobileNav, extraMoreRows],
243
+ )
307
244
 
308
245
  return (
309
246
  <PageHeaderProvider>
@@ -319,15 +256,18 @@ export function BasaltShell({
319
256
  sm: collapsed ? step.appShellNavbarRailWidth : step.appShellNavbarWidth,
320
257
  },
321
258
  breakpoint: 'sm',
322
- collapsed: { mobile: !mobileOpened },
259
+ // No mobile sidebar drawer, ever — the bottom bar is the entire mobile nav.
260
+ collapsed: { mobile: true },
323
261
  }}
324
- footer={{ height: { base: 52, sm: 0 } }}
262
+ // A plain number, NOT a `calc(... + env(safe-area-inset-bottom))` string: Mantine's own
263
+ // `.footer` rule already adds the inset to both the height and the padding (§2.7).
264
+ footer={{ height: { base: step.mobileNavBarHeight, sm: 0 } }}
325
265
  padding="sm"
326
266
  >
327
267
  <AppShell.Header px="md" withBorder={false}>
328
268
  <div className={headerClasses.bar}>
329
269
  <div className={headerClasses.lead}>
330
- <AppBreadcrumbs {...activeCrumb} renderBreadcrumbLink={renderBreadcrumbLink} />
270
+ <AppBreadcrumbs {...activeCrumb} />
331
271
  </div>
332
272
  <PageActionsOutlet className={headerClasses.pageActions} />
333
273
  {globalActions && <div className={headerClasses.global}>{globalActions}</div>}
@@ -340,23 +280,24 @@ export function BasaltShell({
340
280
  sections={sections}
341
281
  collapsed={collapsed}
342
282
  onToggleCollapse={toggleCollapse}
343
- onClose={closeMobile}
344
- footerExtra={sidebarFooterExtra}
345
283
  navExtra={sidebarNavExtra}
346
- {...(renderNavLink !== undefined && { renderNavLink })}
347
284
  {...(settingsMenuItems !== undefined && { settingsMenuItems })}
348
285
  {...(account !== undefined && { account })}
349
286
  {...(search !== undefined && { search })}
350
287
  />
351
288
  </AppShell.Navbar>
352
289
 
353
- <AppShell.Main>{children}</AppShell.Main>
290
+ {/* `mainSafeArea` closes the one real safe-area gap: Mantine sets
291
+ * `--app-shell-footer-offset` to the RAW footer height, so Main's own padding-bottom is
292
+ * short by exactly `env(safe-area-inset-bottom)` (§2.7). */}
293
+ <AppShell.Main className={mobileNavClasses.mainSafeArea}>{children}</AppShell.Main>
354
294
 
355
- <AppShell.Footer hiddenFrom="sm" p={0}>
295
+ <AppShell.Footer hiddenFrom="sm" p={0} withBorder={false}>
356
296
  <MobileNav
357
- sections={mobileSections}
358
- onOpenMore={toggleMobile}
359
- {...(renderNavLink !== undefined && { renderNavLink })}
297
+ model={model}
298
+ config={mobileNav}
299
+ {...(account !== undefined && { account })}
300
+ {...(settingsMenuItems !== undefined && { settingsMenuItems })}
360
301
  />
361
302
  </AppShell.Footer>
362
303
  </AppShell>