basalt-ui 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/agent/rules/basalt-charts.md +12 -0
  4. package/agent/rules/basalt-mantine.md +71 -6
  5. package/agent/rules/basalt-router.md +129 -30
  6. package/agent/rules/basalt-tokens.md +2 -1
  7. package/agent/skills/basalt-design/SKILL.md +2 -2
  8. package/dist/charts/kinds/DualPanel.d.ts +7 -0
  9. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  10. package/dist/charts/kinds/DualPanel.js +10 -3
  11. package/dist/charts/primitives/CartesianChart.d.ts +17 -0
  12. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  13. package/dist/charts/primitives/CartesianChart.js +5 -2
  14. package/dist/charts/primitives/ChartTooltip.d.ts +6 -1
  15. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  16. package/dist/charts/primitives/ChartTooltip.js +2 -1
  17. package/dist/charts/series.d.ts +5 -0
  18. package/dist/charts/series.d.ts.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +8 -0
  22. package/dist/nav/types.d.ts +110 -0
  23. package/dist/nav/types.d.ts.map +1 -0
  24. package/dist/nav/types.js +0 -0
  25. package/dist/router-tanstack/index.d.ts +9 -2
  26. package/dist/router-tanstack/index.d.ts.map +1 -1
  27. package/dist/router-tanstack/index.js +7 -0
  28. package/dist/router-tanstack/nav.d.ts +163 -0
  29. package/dist/router-tanstack/nav.d.ts.map +1 -0
  30. package/dist/router-tanstack/nav.js +38 -0
  31. package/dist/router-tanstack/nav.test.d.ts +2 -0
  32. package/dist/router-tanstack/nav.test.d.ts.map +1 -0
  33. package/dist/router-tanstack/search-param-store.d.ts +19 -9
  34. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  35. package/dist/router-tanstack/use-nav.d.ts +30 -0
  36. package/dist/router-tanstack/use-nav.d.ts.map +1 -0
  37. package/dist/router-tanstack/use-nav.js +71 -0
  38. package/dist/shell/app-breadcrumbs.d.ts +7 -9
  39. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  40. package/dist/shell/app-breadcrumbs.js +8 -4
  41. package/dist/shell/app-mobile-nav.d.ts +23 -24
  42. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  43. package/dist/shell/app-mobile-nav.js +290 -68
  44. package/dist/shell/app-mobile-nav.module.css +166 -11
  45. package/dist/shell/app-mobile-nav.module.css.d.ts +11 -2
  46. package/dist/shell/app-mobile-nav.test.d.ts +2 -0
  47. package/dist/shell/app-mobile-nav.test.d.ts.map +1 -0
  48. package/dist/shell/app-sidebar.d.ts +3 -28
  49. package/dist/shell/app-sidebar.d.ts.map +1 -1
  50. package/dist/shell/app-sidebar.js +40 -174
  51. package/dist/shell/app-sidebar.module.css +3 -2
  52. package/dist/shell/app-sidebar.module.css.d.ts +0 -2
  53. package/dist/shell/index.d.ts +22 -65
  54. package/dist/shell/index.d.ts.map +1 -1
  55. package/dist/shell/index.js +38 -76
  56. package/dist/shell/mobile-nav-model.d.ts +41 -0
  57. package/dist/shell/mobile-nav-model.d.ts.map +1 -0
  58. package/dist/shell/mobile-nav-model.js +244 -0
  59. package/dist/shell/mobile-nav-model.test.d.ts +2 -0
  60. package/dist/shell/mobile-nav-model.test.d.ts.map +1 -0
  61. package/dist/shell/nav-count-badge.d.ts +11 -0
  62. package/dist/shell/nav-count-badge.d.ts.map +1 -0
  63. package/dist/shell/nav-count-badge.js +29 -0
  64. package/dist/surfaces.d.ts +1 -1
  65. package/dist/surfaces.js +1 -1
  66. package/dist/theme/index.d.ts.map +1 -1
  67. package/dist/theme/index.js +8 -7
  68. package/dist/theme/nav-link.module.css +4 -2
  69. package/dist/tokens/index.d.ts.map +1 -1
  70. package/dist/tokens/index.js +2 -0
  71. package/dist/tokens/palette.d.ts +15 -0
  72. package/dist/tokens/palette.d.ts.map +1 -1
  73. package/dist/tokens/palette.js +13 -0
  74. package/dist/tokens.css +2 -0
  75. package/llms.txt +3 -3
  76. package/package.json +1 -1
  77. package/src/charts/kinds/DualPanel.test.tsx +81 -1
  78. package/src/charts/kinds/DualPanel.tsx +26 -4
  79. package/src/charts/primitives/CartesianChart.test.tsx +173 -1
  80. package/src/charts/primitives/CartesianChart.tsx +29 -5
  81. package/src/charts/primitives/ChartTooltip.test.tsx +22 -1
  82. package/src/charts/primitives/ChartTooltip.tsx +7 -1
  83. package/src/charts/series.ts +5 -0
  84. package/src/index.ts +15 -5
  85. package/src/nav/types.ts +115 -0
  86. package/src/router-tanstack/index.ts +21 -2
  87. package/src/router-tanstack/nav.test.ts +111 -0
  88. package/src/router-tanstack/nav.ts +205 -0
  89. package/src/router-tanstack/search-param-store.ts +19 -9
  90. package/src/router-tanstack/use-nav.ts +146 -0
  91. package/src/shell/app-breadcrumbs.tsx +21 -19
  92. package/src/shell/app-mobile-nav.module.css +166 -11
  93. package/src/shell/app-mobile-nav.module.css.d.ts +11 -2
  94. package/src/shell/app-mobile-nav.test.tsx +369 -0
  95. package/src/shell/app-mobile-nav.tsx +436 -102
  96. package/src/shell/app-sidebar.module.css +3 -2
  97. package/src/shell/app-sidebar.module.css.d.ts +0 -2
  98. package/src/shell/app-sidebar.test.tsx +6 -10
  99. package/src/shell/app-sidebar.tsx +49 -206
  100. package/src/shell/index.test.tsx +62 -7
  101. package/src/shell/index.tsx +105 -164
  102. package/src/shell/mobile-nav-model.test.ts +296 -0
  103. package/src/shell/mobile-nav-model.ts +389 -0
  104. package/src/shell/nav-count-badge.tsx +39 -0
  105. package/src/surfaces.ts +1 -1
  106. package/src/theme/density-relations.test.ts +19 -1
  107. package/src/theme/index.ts +8 -7
  108. package/src/theme/nav-link.module.css +4 -2
  109. package/src/theme/spacing.test.ts +11 -0
  110. package/src/tokens/density.test.ts +2 -0
  111. package/src/tokens/index.ts +8 -5
  112. package/src/tokens/palette.ts +16 -0
@@ -0,0 +1,296 @@
1
+ /**
2
+ * `projectMobileNav` — the slot-selection law, one test per numbered rule.
3
+ *
4
+ * This is the highest-value suite in the mobile-nav rewrite: every decision the bottom bar makes
5
+ * (which destinations get a slot, which slot navigates and which raises a surface, whether that
6
+ * surface is a menu or a sheet, what falls into More) is decided here and painted by
7
+ * `app-mobile-nav.tsx` without a single further choice. Pinning the projection therefore pins the
8
+ * interaction model itself, with no renderer, no DOM and no timing — a rule that regresses fails
9
+ * as a value comparison rather than as a flaky query against a portalled dropdown.
10
+ *
11
+ * Two regressions in particular are what this file exists to keep dead:
12
+ * - a slot that raises a full surface to show ONE row (§2.2 cardinality inference), and
13
+ * - a `disabled` destination that is silently dropped instead of rendered disabled (rule 11).
14
+ */
15
+ import { describe, expect, test } from 'bun:test'
16
+ import { MOBILE_MORE_KEY, projectMobileNav } from './mobile-nav-model'
17
+ import type { ProjectMobileNavOptions } from './mobile-nav-model'
18
+ import type { MobileNavGroup, MobileNavModel, MobileNavSlot, SidebarItem } from '../nav/types'
19
+
20
+ // ── fixtures ─────────────────────────────────────────────────────────────────────────────────────
21
+
22
+ /** A destination. `icon: null` keeps the whole suite JSX-free — the model never reads the icon. */
23
+ function item(key: string, extra: Partial<SidebarItem> = {}): SidebarItem {
24
+ return { key, label: key.toUpperCase(), icon: null, ...extra }
25
+ }
26
+
27
+ /** `n` plain destinations named `d1…dn`, the shape every cardinality case counts rows against. */
28
+ function items(n: number, extra: Partial<SidebarItem> = {}): SidebarItem[] {
29
+ return Array.from({ length: n }, (_, i) => item(`d${i + 1}`, extra))
30
+ }
31
+
32
+ const keys = (model: MobileNavModel): string[] => model.slots.map((slot) => slot.key)
33
+ const kinds = (model: MobileNavModel): string[] => model.slots.map((slot) => slot.kind)
34
+
35
+ function slotAt(model: MobileNavModel, index: number): MobileNavSlot {
36
+ const slot = model.slots[index]
37
+ if (!slot) throw new Error(`expected a slot at index ${index}, got ${model.slots.length} slots`)
38
+ return slot
39
+ }
40
+
41
+ /** The trailing More slot, asserted to exist and to be a surface (a `link` More is rule 10's
42
+ * collapse case and is asserted by key/kind directly where it is the point). */
43
+ function moreSlot(model: MobileNavModel): Extract<MobileNavSlot, { groups: MobileNavGroup[] }> {
44
+ const slot = model.slots.at(-1)
45
+ if (!slot || slot.kind === 'link') throw new Error('expected a menu/sheet More slot last')
46
+ expect(slot.key).toBe(MOBILE_MORE_KEY)
47
+ expect(slot.isMore).toBe(true)
48
+ return slot
49
+ }
50
+
51
+ /** Every destination key a surface slot renders, depth-first — the reachability assertion. */
52
+ function rowKeys(groups: readonly MobileNavGroup[]): string[] {
53
+ const out: string[] = []
54
+ const walk = (list: readonly SidebarItem[]): void => {
55
+ for (const row of list) {
56
+ out.push(row.key)
57
+ walk(row.children ?? [])
58
+ }
59
+ }
60
+ for (const group of groups) walk(group.items)
61
+ return out
62
+ }
63
+
64
+ /**
65
+ * Runs the projection with `console.warn` captured. The DEV warnings are part of the contract
66
+ * (rules 11 and 13 are "warn, then do the safe thing"), and swallowing them here also keeps the
67
+ * suite's output clean — the cases below deliberately trigger them.
68
+ */
69
+ function projectQuietly(
70
+ sections: Parameters<typeof projectMobileNav>[0],
71
+ opts?: ProjectMobileNavOptions,
72
+ ): { model: MobileNavModel; warnings: string[] } {
73
+ const warnings: string[] = []
74
+ const original = console.warn
75
+ console.warn = (...args: unknown[]) => {
76
+ warnings.push(args.map(String).join(' '))
77
+ }
78
+ try {
79
+ return { model: projectMobileNav(sections, opts), warnings }
80
+ } finally {
81
+ console.warn = original
82
+ }
83
+ }
84
+
85
+ // ── the law ──────────────────────────────────────────────────────────────────────────────────────
86
+
87
+ describe('projectMobileNav', () => {
88
+ /** Rule 6 — the bar must be usable with the consumer having configured nothing at all. */
89
+ test('1. zero-config fallback takes the first maxTabs-1 non-disabled top-level destinations', () => {
90
+ const model = projectMobileNav([
91
+ {
92
+ label: 'Main',
93
+ items: [
94
+ item('a'),
95
+ item('b', { disabled: true }),
96
+ item('c'),
97
+ item('d'),
98
+ item('e'),
99
+ item('f'),
100
+ ],
101
+ },
102
+ ])
103
+
104
+ // Four link slots (maxTabs 5 minus the More the leftovers force), the disabled one skipped.
105
+ expect(keys(model)).toEqual(['a', 'c', 'd', 'e', MOBILE_MORE_KEY])
106
+ expect(kinds(model)).toEqual(['link', 'link', 'link', 'link', 'menu'])
107
+ expect(rowKeys(moreSlot(model).groups)).toEqual(['b', 'f'])
108
+ })
109
+
110
+ /** Rules 8-9 — nothing left over, no account, no settings: the bar fills all five slots. */
111
+ test('2. no overflow and no extra rows yields maxTabs slots and NO More', () => {
112
+ const model = projectMobileNav([{ label: 'Main', items: items(5, { mobile: 'tab' }) }])
113
+
114
+ expect(model.slots).toHaveLength(5)
115
+ expect(kinds(model)).toEqual(['link', 'link', 'link', 'link', 'link'])
116
+ expect(keys(model)).not.toContain(MOBILE_MORE_KEY)
117
+ })
118
+
119
+ /** Rules 9 + 13 — the slice is what creates the overflow, and it says so in DEV. */
120
+ test('3. six tab requests become four slots plus More, with the last two in overflow', () => {
121
+ const { model, warnings } = projectQuietly([
122
+ { label: 'Main', items: items(6, { mobile: 'tab' }) },
123
+ ])
124
+
125
+ expect(keys(model)).toEqual(['d1', 'd2', 'd3', 'd4', MOBILE_MORE_KEY])
126
+ expect(rowKeys(moreSlot(model).groups)).toEqual(['d5', 'd6'])
127
+ expect(warnings.some((line) => line.includes('6 slot candidates for 4'))).toBe(true)
128
+ })
129
+
130
+ /** Rule 5 — the one named escape hatch, and it REPLACES the declarative pass rather than
131
+ * reordering its result: `a` asks for a tab and still does not get one. */
132
+ test('4. config.tabs replaces every item placement and preserves the given order', () => {
133
+ const model = projectMobileNav(
134
+ [
135
+ {
136
+ label: 'Main',
137
+ items: [item('a', { mobile: 'tab' }), item('b', { mobile: 'tab' }), item('c'), item('d')],
138
+ },
139
+ ],
140
+ { config: { tabs: ['d', 'b'] } },
141
+ )
142
+
143
+ expect(keys(model)).toEqual(['d', 'b', MOBILE_MORE_KEY])
144
+ expect(rowKeys(moreSlot(model).groups)).toEqual(['a', 'c'])
145
+ })
146
+
147
+ /** §2.2 — a group of one IS a destination. This is the case that raises a full-viewport drawer
148
+ * for a single row today, and the reason surface inference is cardinality, not configuration. */
149
+ test('5. a mobile:{tab:true} section holding one destination collapses to a link slot', () => {
150
+ const model = projectMobileNav([
151
+ { label: 'Main', items: [item('a')] },
152
+ { label: 'Admin', mobile: { tab: true }, items: [item('settings')] },
153
+ ])
154
+
155
+ const slot = slotAt(model, 0)
156
+ expect(slot.kind).toBe('link')
157
+ expect(slot.key).toBe('settings')
158
+ // The SLOT is what the consumer configured, so the section's label names it.
159
+ expect(slot.label).toBe('Admin')
160
+ if (slot.kind !== 'link') throw new Error('unreachable')
161
+ expect(slot.item.key).toBe('settings')
162
+ })
163
+
164
+ /** §2.2 — the same section, twice, differing only in how many rows it holds. */
165
+ test('6. a group slot of 4 destinations is a menu; of 9 it is a sheet', () => {
166
+ const small = projectMobileNav([{ label: 'Reports', mobile: { tab: true }, items: items(4) }])
167
+ const large = projectMobileNav([{ label: 'Reports', mobile: { tab: true }, items: items(9) }])
168
+
169
+ expect(slotAt(small, 0).kind).toBe('menu')
170
+ expect(slotAt(large, 0).kind).toBe('sheet')
171
+ })
172
+
173
+ /** The threshold is arithmetic (6 rows x 44px + 8px fits above a 56px bar on a 568px viewport),
174
+ * so the boundary itself is worth pinning — off-by-one here renders a menu below the fold. */
175
+ test('7. menuMax is inclusive: exactly menuMax rows is a menu, one more is a sheet', () => {
176
+ const atLimit = projectMobileNav([{ label: 'Reports', mobile: { tab: true }, items: items(6) }])
177
+ const overLimit = projectMobileNav([
178
+ { label: 'Reports', mobile: { tab: true }, items: items(7) },
179
+ ])
180
+
181
+ expect(slotAt(atLimit, 0).kind).toBe('menu')
182
+ expect(slotAt(overLimit, 0).kind).toBe('sheet')
183
+
184
+ // And the threshold is a knob, not a constant: lowering it moves the same six rows to a sheet.
185
+ const tightened = projectMobileNav(
186
+ [{ label: 'Reports', mobile: { tab: true }, items: items(6) }],
187
+ { config: { menuMax: 5 } },
188
+ )
189
+ expect(slotAt(tightened, 0).kind).toBe('sheet')
190
+ })
191
+
192
+ /**
193
+ * Rule 8 + §11 risk 6 — account and settings rows are only reachable from More now that the
194
+ * full-height sidebar drawer is gone, so they must count toward the menu-vs-sheet threshold.
195
+ * The model cannot see them (they are Mantine chrome, not `SidebarItem`s); the shell counts.
196
+ */
197
+ test('8. extraMoreRows push a six-destination More over the threshold into a sheet', () => {
198
+ const sections = [{ label: 'Main', items: [item('a', { mobile: 'tab' }), ...items(6)] }]
199
+
200
+ expect(slotAt(projectMobileNav(sections), 1).kind).toBe('menu')
201
+ expect(slotAt(projectMobileNav(sections, { extraMoreRows: 1 }), 1).kind).toBe('sheet')
202
+ })
203
+
204
+ /** Rule 1 — a section's `mobile: false` wins over every item inside it, everywhere. */
205
+ test('9. mobile:false on a section hides all its destinations from slots AND from overflow', () => {
206
+ const model = projectMobileNav([
207
+ { label: 'Main', items: [item('a')] },
208
+ {
209
+ label: 'Internal',
210
+ mobile: false,
211
+ items: [item('secret'), item('debug', { mobile: 'tab' })],
212
+ },
213
+ ])
214
+
215
+ expect(keys(model)).toEqual(['a'])
216
+ expect(JSON.stringify(model)).not.toContain('secret')
217
+ expect(JSON.stringify(model)).not.toContain('debug')
218
+ })
219
+
220
+ /**
221
+ * Rule 11 — THE REGRESSION THIS DESIGN FIXES. Today's projection drops `disabled` outright and
222
+ * ships a live row; the law is the opposite: never a tab (a tab that cannot be tapped is a dead
223
+ * 72px of bar), always a row, always rendered disabled.
224
+ */
225
+ test('10. a disabled destination is never a link slot but survives into the More rows', () => {
226
+ const { model, warnings } = projectQuietly([
227
+ {
228
+ label: 'Main',
229
+ items: [item('a', { mobile: 'tab' }), item('x', { mobile: 'tab', disabled: true })],
230
+ },
231
+ ])
232
+
233
+ expect(keys(model)).toEqual(['a', MOBILE_MORE_KEY])
234
+ const more = moreSlot(model)
235
+ expect(rowKeys(more.groups)).toEqual(['x'])
236
+ expect(more.groups[0]?.items[0]?.disabled).toBe(true)
237
+ expect(warnings.some((line) => line.includes('"x"') && line.includes('disabled'))).toBe(true)
238
+ })
239
+
240
+ /** Rule 2 — nesting is a lookup-order concern, not a rendering one: the tree reaches the rows
241
+ * intact, so a child no longer reads as a sibling of its own parent on mobile. */
242
+ test('11. nested children are preserved as a tree in the overflow rows, not flattened away', () => {
243
+ const model = projectMobileNav([
244
+ {
245
+ label: 'Main',
246
+ items: [
247
+ item('a', { mobile: 'tab' }),
248
+ item('reports', { children: [item('daily'), item('weekly')] }),
249
+ ],
250
+ },
251
+ ])
252
+
253
+ const more = moreSlot(model)
254
+ const reports = more.groups[0]?.items[0]
255
+ expect(reports?.key).toBe('reports')
256
+ expect(reports?.children?.map((child) => child.key)).toEqual(['daily', 'weekly'])
257
+ // Depth-first, parent before children — the order the renderer indents against.
258
+ expect(rowKeys(more.groups)).toEqual(['reports', 'daily', 'weekly'])
259
+ })
260
+
261
+ /** Rule 12 — today's `items.some(i => i.active)` leaves the tab dark while a child route is
262
+ * open, which reads as "you are nowhere". A slot is active when ANY destination under it is. */
263
+ test('12. a slot is active when a NESTED child is the active destination', () => {
264
+ const model = projectMobileNav([
265
+ {
266
+ label: 'Main',
267
+ items: [
268
+ item('reports', { mobile: 'tab', children: [item('daily', { active: true })] }),
269
+ item('other', { mobile: 'tab' }),
270
+ ],
271
+ },
272
+ ])
273
+
274
+ expect(slotAt(model, 0).active).toBe(true)
275
+ expect(slotAt(model, 1).active).toBe(false)
276
+ })
277
+
278
+ /** §2.4 — a five-slot bar is ~72px wide, so the bar label is `short` when the consumer supplied
279
+ * one and the full label otherwise. Both paths, one test. */
280
+ test('13. the slot label is `short`, falling back to `label`', () => {
281
+ const model = projectMobileNav([
282
+ {
283
+ label: 'Main',
284
+ items: [
285
+ item('dashboard', { mobile: 'tab', label: 'Dashboard', short: 'Dash' }),
286
+ item('activity', { mobile: 'tab', label: 'Activity' }),
287
+ ],
288
+ },
289
+ ])
290
+
291
+ expect(slotAt(model, 0).short).toBe('Dash')
292
+ // `label` stays the FULL text — it is the accessible name, which is never abbreviated.
293
+ expect(slotAt(model, 0).label).toBe('Dashboard')
294
+ expect(slotAt(model, 1).short).toBe('Activity')
295
+ })
296
+ })
@@ -0,0 +1,389 @@
1
+ /**
2
+ * The mobile-bar projection — `SidebarSection[]` + `MobileNavConfig` → `MobileNavModel`.
3
+ *
4
+ * PURE BY CONTRACT: no React, no Mantine, no DOM, no JSX. Everything the bottom bar decides —
5
+ * which destinations get a slot, which slot is a plain navigation link and which raises a surface,
6
+ * whether that surface is a menu or a sheet, what falls into the overflow "More" slot — is decided
7
+ * here and unit-testable with no renderer. `app-mobile-nav.tsx` only paints the result.
8
+ *
9
+ * The law it implements is the interaction model, not a heuristic: a slot is a DESTINATION, so a
10
+ * tap navigates with no overlay to dismiss. An overlay exists only where a slot genuinely holds
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.
15
+ */
16
+ import type {
17
+ MobileNavConfig,
18
+ MobileNavGroup,
19
+ MobileNavModel,
20
+ MobileNavSlot,
21
+ NavMobilePlacement,
22
+ SidebarItem,
23
+ SidebarSection,
24
+ } from '../nav/types'
25
+
26
+ /** Hard cap on bar slots, INCLUDING More. Five 72px slots is the widest a 360px viewport holds. */
27
+ export const MOBILE_MAX_TABS_DEFAULT = 5
28
+
29
+ /**
30
+ * Rows a slot may hold before it becomes a sheet instead of a menu. Arithmetic, not taste — see
31
+ * the module doc: six rows is the last count a bottom-anchored, non-flipping menu fits above the
32
+ * bar on the smallest supported viewport.
33
+ */
34
+ export const MOBILE_MENU_MAX_DEFAULT = 6
35
+
36
+ /** The overflow slot's reserved key. A slot like any other — it just exists only when needed. */
37
+ export const MOBILE_MORE_KEY = '__more'
38
+
39
+ export type ProjectMobileNavOptions = {
40
+ config?: MobileNavConfig | undefined
41
+ /**
42
+ * Extra rows the More surface will hold — account + settings + `moreExtra`. Drives both
43
+ * `needsMore` and the menu-vs-sheet threshold. The model cannot see those rows (they are
44
+ * Mantine-rendered chrome, not `SidebarItem`s), so the shell counts them and passes the total.
45
+ */
46
+ extraMoreRows?: number
47
+ }
48
+
49
+ const DEV = process.env['NODE_ENV'] !== 'production'
50
+
51
+ /**
52
+ * §2.3 rule 1. A section's `mobile: false` wins over every item inside it; otherwise the item's own
53
+ * field decides, with the shorthand booleans normalized and `undefined` meaning "reachable, but not
54
+ * from the bar" — the default that makes a nav work with nothing configured.
55
+ */
56
+ function placementOf(item: SidebarItem, sectionHidden: boolean): NavMobilePlacement {
57
+ if (sectionHidden) return 'hidden'
58
+ const mobile = item.mobile
59
+ if (mobile === undefined) return 'more'
60
+ if (mobile === true) return 'tab'
61
+ if (mobile === false) return 'hidden'
62
+ return mobile
63
+ }
64
+
65
+ /** A section opts its whole subtree out of mobile with `mobile: false`. */
66
+ function isSectionHidden(section: SidebarSection): boolean {
67
+ return section.mobile === false
68
+ }
69
+
70
+ /** §2.3 rule 12 — a slot is active when ANY of its destinations is, nested children included. */
71
+ function hasActiveDestination(item: SidebarItem): boolean {
72
+ if (item.active) return true
73
+ return item.children?.some(hasActiveDestination) ?? false
74
+ }
75
+
76
+ /** Rows a group tree will render. Nesting costs a row per node — children are indented rows. */
77
+ function countRows(items: readonly SidebarItem[]): number {
78
+ return items.reduce((total, item) => total + 1 + countRows(item.children ?? []), 0)
79
+ }
80
+
81
+ /** Every destination key in a tree — the reachability set a slot contributes. */
82
+ function collectKeys(items: readonly SidebarItem[], out: Set<string>): void {
83
+ for (const item of items) {
84
+ out.add(item.key)
85
+ collectKeys(item.children ?? [], out)
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Depth-first flatten, parent before children — the order every candidate list and the zero-config
91
+ * fallback read. Nesting is NOT lost: this is the lookup/selection view, while the overflow and
92
+ * group rendering below keep the tree (today's projection flattens it away, which is why a nested
93
+ * destination reads as a sibling of its own parent on mobile).
94
+ */
95
+ function flattenItems(items: readonly SidebarItem[], out: SidebarItem[]): void {
96
+ for (const item of items) {
97
+ out.push(item)
98
+ flattenItems(item.children ?? [], out)
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Drops every destination `keep` rejects, HOISTING a rejected parent's surviving children into its
104
+ * place rather than discarding the subtree — placement is normalized per destination (rule 1), so a
105
+ * visible child of a hidden parent is still a visible destination and must stay reachable.
106
+ */
107
+ function pruneTree(
108
+ items: readonly SidebarItem[],
109
+ keep: (item: SidebarItem) => boolean,
110
+ ): SidebarItem[] {
111
+ const out: SidebarItem[] = []
112
+ for (const item of items) {
113
+ const children = pruneTree(item.children ?? [], keep)
114
+ if (!keep(item)) {
115
+ out.push(...children)
116
+ continue
117
+ }
118
+ const next: SidebarItem = { ...item }
119
+ if (children.length > 0) next.children = children
120
+ else delete next.children
121
+ out.push(next)
122
+ }
123
+ return out
124
+ }
125
+
126
+ /** A slot candidate before the `maxTabs` slice decides which ones survive. */
127
+ type Candidate = {
128
+ /** What the candidate is CALLED — a destination key, or a section's label. Only rule 13's warn
129
+ * reads it, and that warn exists to name the slots that lost the slice. */
130
+ key: string
131
+ /** Destination keys this slot makes reachable — everything else falls to overflow (rule 7). */
132
+ covered: Set<string>
133
+ /** Built after the slice, since a dropped candidate never becomes a slot. */
134
+ toSlot: () => MobileNavSlot | undefined
135
+ }
136
+
137
+ /** A destination becomes a `link` slot: one tap, one navigation, nothing to dismiss. */
138
+ function itemCandidate(item: SidebarItem): Candidate {
139
+ return {
140
+ key: item.key,
141
+ covered: new Set([item.key]),
142
+ toSlot: () => ({
143
+ kind: 'link',
144
+ key: item.key,
145
+ label: item.label,
146
+ short: item.short ?? item.label,
147
+ icon: item.icon,
148
+ active: hasActiveDestination(item),
149
+ item,
150
+ }),
151
+ }
152
+ }
153
+
154
+ /**
155
+ * A SECTION becomes one slot (`mobile: { tab: true }`), holding its destinations. Cardinality picks
156
+ * the surface (§2.2): an empty section is not a slot at all, and a section of ONE destination IS
157
+ * that destination — collapsing to a `link` rather than raising a full surface for a single row,
158
+ * which is exactly what today's projection gets wrong.
159
+ */
160
+ function sectionCandidate(section: SidebarSection, menuMax: number): Candidate {
161
+ const mobile =
162
+ section.mobile === false || section.mobile === undefined ? undefined : section.mobile
163
+ const items = pruneTree(section.items, (item) => placementOf(item, false) !== 'hidden')
164
+ const covered = new Set<string>()
165
+ collectKeys(items, covered)
166
+ const label = mobile?.label ?? section.label
167
+ const rows = countRows(items)
168
+ const only = items[0]
169
+
170
+ return {
171
+ key: section.label,
172
+ covered,
173
+ toSlot: () => {
174
+ if (rows === 0) return undefined
175
+ if (rows === 1 && only !== undefined && !only.disabled) {
176
+ return {
177
+ kind: 'link',
178
+ key: only.key,
179
+ label,
180
+ short: label,
181
+ // The section's own identity wins when it declares one — the consumer configured the
182
+ // SLOT — and falls back to the single destination's icon when it does not.
183
+ icon: mobile?.icon ?? section.icon ?? only.icon,
184
+ active: hasActiveDestination(only),
185
+ item: only,
186
+ }
187
+ }
188
+ return {
189
+ kind: rows <= menuMax ? 'menu' : 'sheet',
190
+ key: section.label,
191
+ label,
192
+ short: label,
193
+ icon: mobile?.icon ?? section.icon,
194
+ active: items.some(hasActiveDestination),
195
+ // One section, so the rows need no group heading — the tab itself names them.
196
+ groups: [{ key: section.label, items }],
197
+ isMore: false,
198
+ }
199
+ },
200
+ }
201
+ }
202
+
203
+ /**
204
+ * §2.3 rules 3-6 — the candidate list, in bar order.
205
+ *
206
+ * `config.tabs` REPLACES the declarative pass entirely (rule 5): it is the one named escape hatch
207
+ * for "bar order != sidebar order", so it wins over every `item.mobile`. Otherwise sections are
208
+ * walked in declaration order, which is what interleaves section slots with item slots correctly.
209
+ */
210
+ function buildCandidates(
211
+ sections: readonly SidebarSection[],
212
+ config: MobileNavConfig | undefined,
213
+ maxTabs: number,
214
+ menuMax: number,
215
+ ): Candidate[] {
216
+ const flat: SidebarItem[] = []
217
+ for (const section of sections) {
218
+ if (isSectionHidden(section)) continue
219
+ flattenItems(section.items, flat)
220
+ }
221
+
222
+ /** Rule 11 — `mobile: 'tab'` on a disabled destination is a configuration mistake, not a slot. */
223
+ const tabbable = (item: SidebarItem): boolean => {
224
+ if (!item.disabled) return true
225
+ if (DEV) {
226
+ console.warn(
227
+ `[basalt] MobileNav: "${item.key}" asks for a bar slot but is disabled — a disabled destination never becomes a tab. It still renders, disabled, in the More surface.`,
228
+ )
229
+ }
230
+ return false
231
+ }
232
+
233
+ if (config?.tabs !== undefined) {
234
+ const candidates: Candidate[] = []
235
+ for (const id of config.tabs) {
236
+ const item = flat.find((candidate) => candidate.key === id)
237
+ if (item !== undefined) {
238
+ if (tabbable(item)) candidates.push(itemCandidate(item))
239
+ continue
240
+ }
241
+ // A section is named by its `label` — `SidebarSection` carries no key of its own.
242
+ const section = sections.find((s) => s.label === id && !isSectionHidden(s))
243
+ if (section !== undefined) {
244
+ candidates.push(sectionCandidate(section, menuMax))
245
+ continue
246
+ }
247
+ if (DEV) {
248
+ console.warn(
249
+ `[basalt] MobileNav: mobileNav.tabs names "${id}", which is neither a destination key nor a visible section label — ignoring it.`,
250
+ )
251
+ }
252
+ }
253
+ return candidates
254
+ }
255
+
256
+ const candidates: Candidate[] = []
257
+ for (const section of sections) {
258
+ if (isSectionHidden(section)) continue
259
+ // Rule 3: a section slot consumes its destinations, so they never also compete as item slots.
260
+ if (section.mobile !== undefined && section.mobile !== false && section.mobile.tab === true) {
261
+ candidates.push(sectionCandidate(section, menuMax))
262
+ continue
263
+ }
264
+ const items: SidebarItem[] = []
265
+ flattenItems(section.items, items)
266
+ for (const item of items) {
267
+ if (placementOf(item, false) !== 'tab') continue
268
+ if (tabbable(item)) candidates.push(itemCandidate(item))
269
+ }
270
+ }
271
+ if (candidates.length > 0) return candidates
272
+
273
+ // Rule 6 — nobody configured anything. The bar still works: the first few top-level destinations
274
+ // become slots, so a consumer gets a usable nav for free and tunes it only if it is wrong.
275
+ const fallback: Candidate[] = []
276
+ for (const section of sections) {
277
+ if (isSectionHidden(section)) continue
278
+ for (const item of section.items) {
279
+ if (fallback.length >= maxTabs - 1) return fallback
280
+ if (item.disabled) continue
281
+ if (placementOf(item, false) === 'hidden') continue
282
+ fallback.push(itemCandidate(item))
283
+ }
284
+ }
285
+ return fallback
286
+ }
287
+
288
+ /** Rule 7 — the leftovers, grouped by source section, tree intact. */
289
+ function buildOverflow(
290
+ sections: readonly SidebarSection[],
291
+ covered: Set<string>,
292
+ ): MobileNavGroup[] {
293
+ const groups: MobileNavGroup[] = []
294
+ for (const section of sections) {
295
+ const hidden = isSectionHidden(section)
296
+ if (hidden) continue
297
+ const items = pruneTree(
298
+ section.items,
299
+ (item) => placementOf(item, hidden) !== 'hidden' && !covered.has(item.key),
300
+ )
301
+ if (items.length === 0) continue
302
+ groups.push({ key: section.label, label: section.label, items })
303
+ }
304
+ return groups
305
+ }
306
+
307
+ /**
308
+ * Projects `sections` onto the bar. Implements §2.3's thirteen rules in order, and is the ONLY
309
+ * place slot selection happens — `MobileNav` receives a finished model and paints it.
310
+ */
311
+ export function projectMobileNav(
312
+ sections: SidebarSection[],
313
+ opts?: ProjectMobileNavOptions,
314
+ ): MobileNavModel {
315
+ const config = opts?.config
316
+ const maxTabs = Math.max(1, config?.maxTabs ?? MOBILE_MAX_TABS_DEFAULT)
317
+ const menuMax = Math.max(1, config?.menuMax ?? MOBILE_MENU_MAX_DEFAULT)
318
+ const extraMoreRows = opts?.extraMoreRows ?? 0
319
+
320
+ const candidates = buildCandidates(sections, config, maxTabs, menuMax)
321
+
322
+ // Rules 8-9 are mutually recursive on paper — `needsMore` reads the overflow, the slice writes
323
+ // it. The fixpoint is one line: a candidate list longer than the bar ALWAYS needs More, because
324
+ // the slice is what pushes the remainder into overflow.
325
+ const coveredByAll = new Set<string>()
326
+ for (const candidate of candidates) for (const key of candidate.covered) coveredByAll.add(key)
327
+ const overflowIfNoneDropped = buildOverflow(sections, coveredByAll)
328
+ const needsMore =
329
+ countRows(overflowIfNoneDropped.flatMap((group) => group.items)) > 0 ||
330
+ extraMoreRows > 0 ||
331
+ candidates.length > maxTabs
332
+
333
+ const limit = needsMore ? maxTabs - 1 : maxTabs
334
+ const kept = candidates.slice(0, limit)
335
+ const dropped = candidates.slice(limit)
336
+ if (DEV && dropped.length > 0) {
337
+ // Rule 13 wants the NAMES, not a tally: "2 candidates dropped" tells a consumer their bar is
338
+ // wrong, while the keys tell them which `mobile: 'tab'` to move or which section to reorder.
339
+ console.warn(
340
+ `[basalt] MobileNav: ${candidates.length} slot candidates for ${limit} available slot(s) — the last ${dropped.length} moved into the More surface: ${dropped.map((candidate) => `"${candidate.key}"`).join(', ')}. Raise mobileNav.maxTabs or drop a mobile: 'tab'.`,
341
+ )
342
+ }
343
+
344
+ const covered = new Set<string>()
345
+ for (const candidate of kept) for (const key of candidate.covered) covered.add(key)
346
+
347
+ const slots: MobileNavSlot[] = []
348
+ for (const candidate of kept) {
349
+ const slot = candidate.toSlot()
350
+ if (slot !== undefined) slots.push(slot)
351
+ }
352
+
353
+ if (!needsMore) return { slots }
354
+
355
+ // Rule 10 — the More slot, inferred over everything the bar could not reach.
356
+ const groups = buildOverflow(sections, covered)
357
+ const overflowItems = groups.flatMap((group) => group.items)
358
+ const rows = countRows(overflowItems) + extraMoreRows
359
+ if (rows === 0) return { slots }
360
+
361
+ const label = config?.moreLabel ?? 'More'
362
+ const only = overflowItems[0]
363
+ if (rows === 1 && extraMoreRows === 0 && only !== undefined && !only.disabled) {
364
+ // §2.2 again: a More surface holding one destination IS that destination.
365
+ slots.push({
366
+ kind: 'link',
367
+ key: only.key,
368
+ label: only.label,
369
+ short: only.short ?? only.label,
370
+ icon: only.icon,
371
+ active: hasActiveDestination(only),
372
+ item: only,
373
+ })
374
+ return { slots }
375
+ }
376
+
377
+ slots.push({
378
+ kind: rows <= menuMax ? 'menu' : 'sheet',
379
+ key: MOBILE_MORE_KEY,
380
+ label,
381
+ short: label,
382
+ // No icon: the More glyph is chrome the renderer owns, and this module holds no JSX.
383
+ icon: undefined,
384
+ active: overflowItems.some(hasActiveDestination),
385
+ groups,
386
+ isMore: true,
387
+ })
388
+ return { slots }
389
+ }