laif-ds 1.0.0 → 1.0.2

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.
package/dist/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ All notable technical changes to the laif-ds core will be documented in this fil
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [laif-ds@1.0.2]
9
+
10
+ ### 🐛 Fixed
11
+
12
+ - **AppBottomNav**: When the current route matches none of the bar's destinations, the Menu cell no longer takes the active pill — the bar now renders entirely neutral, the same as `showMenu={false}`, since Menu is a disclosure and not a page
13
+
14
+ ## [laif-ds@1.0.1]
15
+
16
+ ### 🚀 Added
17
+
18
+ - **AppSidebar**: New `expandOnHover` prop expands the sidebar as a hover/focus overlay (page content doesn't shift) when collapsed to `collapsible="icon"`; a Pin control in the header promotes that to a permanent expansion, controllable via `pinned` / `defaultPinned` / `onPinnedChange`
19
+ - **AppSidebar / AppBottomNav**: `NavItem`, `NavSubItem`, `NavGroup` and `BottomNavItem` gained `badge` (`number | string | true`) and `badgeLabel` props for a count, short text or dot indicator; `AppBottomNav` also gained `menuBadge` for a dot on the Menu cell. The badge degrades per surface — a pill when the sidebar is expanded, a dot with the value in the tooltip when collapsed to icons, a superscript on `AppBottomNav`'s glyph (text degrades to a dot there) — and never aggregates child counts into a parent. `SidebarMenuBadge` is now deprecated in favor of `NavItem.badge`
20
+
21
+ ### 🔧 Changed
22
+
23
+ - **AppBottomNav**: Cell height increased from 55px to 67px — icons are now `size="sm"` (20px, up from 16px) and vertical padding is `py-2.5` (up from `py-1.5`), bringing the cell further past the 44px touch-target floor
24
+ - **AppBottomNav**: `menuIconName` renamed to `menuIcon`, now accepting `IconName | (() => React.ReactNode)` so the Menu cell can render a custom glyph (e.g. a brand mark) in addition to a lucide icon name
25
+
26
+ ### 🐛 Fixed
27
+
28
+ - **AppSidebar**: On mobile, `navigationFooter` now anchors to the bottom of the panel instead of trailing immediately after the last `navigation` group, matching its desktop placement whenever the navigation list is short enough to leave room
29
+ - **AppSidebar**: The mobile panel's header is no longer offset ~16px from center under the floating close button — centered `headerContent` (e.g. a logo) is now centered correctly
30
+ - **AppSidebar**: Collapsible submenus now animate open/closed instead of snapping instantly (skipped under `prefers-reduced-motion`), and collapsed rows are removed from the tab order and accessibility tree instead of staying mounted
31
+ - **Sidebar**: The panel's edge border (default, non-floating/inset variant) now resolves to the intended `border-d-sidebar-border` token instead of Tailwind v4's `currentColor` default, which rendered near-white on dark themes and near-black on light ones
32
+
8
33
  ## [laif-ds@1.0.0]
9
34
 
10
35
  ### 🚀 Added
@@ -1,8 +1,8 @@
1
1
  "use client";
2
- import { getDefaultExportFromCjs as r } from "./_commonjsHelpers.js";
3
- import { __require as e } from "../node_modules/style-to-js/cjs/index.js";
4
- var t = e();
5
- const a = /* @__PURE__ */ r(t);
2
+ import { getDefaultExportFromCjs as e } from "./_commonjsHelpers.js";
3
+ import { __require as t } from "../node_modules/eventemitter3/index2.js";
4
+ var r = t();
5
+ const m = /* @__PURE__ */ e(r);
6
6
  export {
7
- a as default
7
+ m as default
8
8
  };
@@ -1,8 +1,8 @@
1
1
  "use client";
2
- import { getDefaultExportFromCjs as e } from "./_commonjsHelpers.js";
3
- import { __require as r } from "../node_modules/extend/index.js";
4
- var t = r();
5
- const x = /* @__PURE__ */ e(t);
2
+ import { getDefaultExportFromCjs as r } from "./_commonjsHelpers.js";
3
+ import { __require as e } from "../node_modules/style-to-js/cjs/index.js";
4
+ var t = e();
5
+ const a = /* @__PURE__ */ r(t);
6
6
  export {
7
- x as default
7
+ a as default
8
8
  };
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
  import { getDefaultExportFromCjs as e } from "./_commonjsHelpers.js";
3
- import { __require as t } from "../node_modules/eventemitter3/index2.js";
4
- var r = t();
5
- const m = /* @__PURE__ */ e(r);
3
+ import { __require as r } from "../node_modules/extend/index.js";
4
+ var t = r();
5
+ const x = /* @__PURE__ */ e(t);
6
6
  export {
7
- m as default
7
+ x as default
8
8
  };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "generatedAt": "2026-09-02T16:31:01.025Z",
3
+ "generatedAt": "2026-09-18T09:11:12.858Z",
4
4
  "package": {
5
5
  "name": "laif-ds",
6
- "version": "1.0.0"
6
+ "version": "1.0.2"
7
7
  },
8
8
  "summary": {
9
- "scannedFiles": 402,
9
+ "scannedFiles": 404,
10
10
  "deprecatedComponentsCount": 2,
11
11
  "deprecatedComponentsUsedCount": 0,
12
12
  "componentsWithoutExamplesCount": 5,
@@ -351,7 +351,7 @@
351
351
  "repeatedClassNames": [
352
352
  {
353
353
  "className": "flex items-center gap-2",
354
- "occurrences": 35,
354
+ "occurrences": 36,
355
355
  "files": [
356
356
  "src/components/stories/app-checkbox.stories.tsx",
357
357
  "src/components/stories/app-dialog.stories.tsx",
@@ -367,6 +367,7 @@
367
367
  "src/components/stories/truncated-cell.stories.tsx",
368
368
  "src/components/ui/app-form.tsx",
369
369
  "src/components/ui/app-kanban.tsx",
370
+ "src/components/ui/app-sidebar.tsx",
370
371
  "src/components/ui/file-previewer.tsx",
371
372
  "src/components/ui/weekly-calendar/calendar-header.tsx"
372
373
  ]
@@ -391,11 +392,12 @@
391
392
  },
392
393
  {
393
394
  "className": "text-d-muted-foreground text-sm",
394
- "occurrences": 26,
395
+ "occurrences": 27,
395
396
  "files": [
396
397
  "src/components/stories/app-card.stories.tsx",
397
398
  "src/components/stories/app-dialog.stories.tsx",
398
399
  "src/components/stories/app-kanban.stories.tsx",
400
+ "src/components/stories/app-sidebar.stories.tsx",
399
401
  "src/components/stories/changelog.stories.tsx",
400
402
  "src/components/stories/date-picker.stories.tsx",
401
403
  "src/components/stories/input.stories.tsx",
@@ -451,14 +453,6 @@
451
453
  "src/components/stories/app-form.stories.tsx"
452
454
  ]
453
455
  },
454
- {
455
- "className": "flex h-full overflow-hidden",
456
- "occurrences": 14,
457
- "files": [
458
- "src/components/stories/app-sidebar.stories.tsx",
459
- "src/components/stories/sidebar.stories.tsx"
460
- ]
461
- },
462
456
  {
463
457
  "className": "text-muted-foreground mt-2 text-sm",
464
458
  "occurrences": 13,
@@ -467,11 +461,11 @@
467
461
  ]
468
462
  },
469
463
  {
470
- "className": "text-d-muted-foreground max-w-prose text-sm",
471
- "occurrences": 10,
464
+ "className": "flex h-full overflow-hidden",
465
+ "occurrences": 12,
472
466
  "files": [
473
- "src/components/stories/app-bottom-nav.stories.tsx",
474
- "src/components/stories/app-sidebar.stories.tsx"
467
+ "src/components/stories/app-sidebar.stories.tsx",
468
+ "src/components/stories/sidebar.stories.tsx"
475
469
  ]
476
470
  },
477
471
  {
@@ -482,6 +476,14 @@
482
476
  "src/components/stories/app-radio-group.stories.tsx"
483
477
  ]
484
478
  },
479
+ {
480
+ "className": "text-d-muted-foreground max-w-prose text-sm",
481
+ "occurrences": 9,
482
+ "files": [
483
+ "src/components/stories/app-bottom-nav.stories.tsx",
484
+ "src/components/stories/app-sidebar.stories.tsx"
485
+ ]
486
+ },
485
487
  {
486
488
  "className": "border-d-border mt-2 rounded-md border p-4",
487
489
  "occurrences": 9,
@@ -523,13 +525,6 @@
523
525
  "src/components/stories/async-select.stories.tsx"
524
526
  ]
525
527
  },
526
- {
527
- "className": "flex-1 space-y-3 overflow-auto p-4",
528
- "occurrences": 8,
529
- "files": [
530
- "src/components/stories/app-sidebar.stories.tsx"
531
- ]
532
- },
533
528
  {
534
529
  "className": "!text-d-foreground opacity-50",
535
530
  "occurrences": 8,
@@ -562,6 +557,13 @@
562
557
  "src/components/stories/app-select.stories.tsx"
563
558
  ]
564
559
  },
560
+ {
561
+ "className": "flex-1 space-y-3 overflow-auto p-4",
562
+ "occurrences": 7,
563
+ "files": [
564
+ "src/components/stories/app-sidebar.stories.tsx"
565
+ ]
566
+ },
565
567
  {
566
568
  "className": "border-d-border border px-3 py-1 text-center text-sm font-light",
567
569
  "occurrences": 7,
@@ -23,20 +23,26 @@ scrolls instead of the page container the bar scrolls off-screen, and any
23
23
  horizontal padding on an ancestor breaks the full bleed — that one is a layout
24
24
  bug, not only a cosmetic one.
25
25
 
26
- Total height is **55px** (measured) plus the safe-area reserve, which the
27
- component adds itself (`pb-[env(safe-area-inset-bottom)]`, with no `max()`
28
- floor) budget that column in the page layout. It breaks down as 1px of
29
- `border-t` + 4px of top inset + a 46px pill (34px of content a 16px icon, a
30
- 2px gap and a 16px line plus 12px of vertical padding) + 4px of bottom inset.
26
+ Total height is **67px** 12px over the 55px that was measured when the cell
27
+ had `py-1.5` and a 16px glyph, which is exactly what those two changes add —
28
+ plus the safe-area reserve, which the component adds itself
29
+ (`pb-[env(safe-area-inset-bottom)]`, with no `max()` floor). Budget that column
30
+ in the page layout. It breaks down as 1px of `border-t` + 4px of top inset + a
31
+ 58px pill (38px of content — a 20px icon, a 2px gap and a 16px line — plus 20px
32
+ of vertical padding) + 4px of bottom inset.
31
33
  The reserve is **painted bar surface**: under a home indicator you see bar
32
34
  colour, not page.
33
35
 
34
- The 46px cell clears the 44px touch floor (`min-h-11`) by **2px**, where the
35
- 20px-icon version cleared it by 6. Measured, `min-h-11` is not engaged — the
36
- cell renders at 46px, so the height still comes from `py-1.5` but the margin
37
- is thin: trim the padding, the icon or the label's line-height any further and
38
- `min-h-11` takes the height over, at which point the written padding stops
39
- describing what is rendered. **Measure the cell, not the padding.**
36
+ The vertical padding is `py-2.5`, taken from `designTokens.sizes.touch` the
37
+ only part of that token a six-cell row can afford (`px-4` would leave 29px for
38
+ the label, `text-sm` would widen it further), and the same value the mobile
39
+ sidebar rows get from the token entire. The 58px cell clears the 44px touch
40
+ floor (`min-h-11`) by **14px**, where `py-1.5` over a 16px glyph cleared it by
41
+ 2. `min-h-11` is not engaged the height comes from the padding and the
42
+ content — and the margin is now wide enough that it stays that way: at the old
43
+ values any further trim to the padding, the icon or the label's line-height
44
+ would have handed the height to `min-h-11`, at which point the written padding
45
+ stops describing what is rendered. **Measure the cell, not the padding.**
40
46
 
41
47
  It must be rendered inside a `SidebarProvider` (it reads the sidebar context to
42
48
  open the mobile panel); outside one, `useSidebar()` throws.
@@ -51,7 +57,8 @@ open the mobile panel); outside one, `useSidebar()` throws.
51
57
  | `showMenu` | `boolean` | `true` | `false` drops the Menu cell and spends its slot on a sixth destination. Only for an app that opens the mobile panel elsewhere — see below. |
52
58
  | `menuLabel` | `string` | `"Menu"` | Label of the menu cell. |
53
59
  | `ariaLabel` | `string` | `"Main navigation"` | Accessible name of the `<nav>` landmark. |
54
- | `menuIconName` | `IconName` | `"Menu"` | Icon of the menu cell. |
60
+ | `menuIcon` | `IconName \| (() => React.ReactNode)` | `"Menu"` | The menu cell's glyph: a lucide name, or a function returning the node to draw for shapes lucide lacks. Nullish back from the function falls back to the default. |
61
+ | `menuBadge` | `boolean` | `false` | A dot on the Menu cell — **never** a count. OR-ed with the badged items dropped past the cell budget. |
55
62
  | `linkComponent` | `React.ComponentType<React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }>` | `undefined` | Router link component (e.g. Next.js `Link`); plain `<a>` otherwise. |
56
63
  | `linkProps` | `Record<string, unknown>` | `{}` | Extra props forwarded to `linkComponent`. |
57
64
  | `className` | `string` | `undefined` | Merged onto the dock wrapper. |
@@ -65,13 +72,15 @@ Inherits the remaining props from `Omit<React.ComponentProps<"div">, "children">
65
72
  | ----------- | -------------- | ------------ | ---------------------------------------------------------------------------------------------- |
66
73
  | `title` | `string` | **required** | Visible label. **Must equal the sidebar `NavItem.title`** of the same destination (see below). |
67
74
  | `url` | `string` | **required** | Ignored when `subItems` is non-empty; pass `"#"` there by convention. |
68
- | `iconName` | `IconName` | **required** | No fallback — at 16px the glyph no longer leads; what makes it required is the row, where a cell without one breaks the rhythm and reads as a failed image. |
75
+ | `iconName` | `IconName` | **required** | No fallback — at 20px the glyph is what the eye lands on first, and a cell without one breaks the rhythm of the row and reads as a failed image. |
69
76
  | `isActive` | `boolean` | `undefined` | Leaf level only — the subtree state is derived from `subItems`. |
70
77
  | `subItems` | `NavSubItem[]` | `undefined` | Presence makes the cell open the mobile menu at this level instead of navigating. |
71
78
  | `ariaLabel` | `string` | `undefined` | Only when the spoken name must differ from the label; it must still contain the visible text. |
79
+ | `badge` | `number \| string \| true` | `undefined` | Count, short text or dot — the same field as `AppSidebar`'s `NavItem.badge`. This surface is `compact`: numbers render as a superscript on the glyph, **text degrades to a dot**. |
80
+ | `badgeLabel`| `string` | `undefined` | The spoken form of `badge`, announced by an `sr-only` span inside the cell. Effectively required for `badge: true`. |
72
81
 
73
82
  `NavSubItem` is the same type `AppSidebar` uses:
74
- `{ title: string; url: string; isActive?: boolean; iconName?: IconName }`.
83
+ `{ title: string; url: string; isActive?: boolean; iconName?: IconName; badge?: number | string | true; badgeLabel?: string }`.
75
84
 
76
85
  ---
77
86
 
@@ -90,7 +99,7 @@ Inherits the remaining props from `Omit<React.ComponentProps<"div">, "children">
90
99
  a flex item's automatic minimum size is its min-content width. `min-w-0`
91
100
  removes that escape hatch, so the label truncates instead of the row going
92
101
  uneven. (Without it the row would still be even at these widths — the
93
- min-content floor is 24px, a `shrink-0` 16px icon plus `px-1`, against a
102
+ min-content floor is 28px, a `shrink-0` 20px icon plus `px-1`, against a
94
103
  narrowest cell of 52px — but by arithmetic rather than by construction.)
95
104
  - **Six is a maximum, not a count.** Nothing pads the row: with fewer items the
96
105
  cells simply get fatter, as every native tab bar does, and Menu stays pinned
@@ -128,34 +137,36 @@ Inherits the remaining props from `Omit<React.ComponentProps<"div">, "children">
128
137
  hint on some cells only breaks the even rhythm that makes a tab bar scannable.
129
138
  The feedback is the reveal itself.
130
139
 
131
- ### Icon size: a deliberate divergence from the native baselines
132
-
133
- Cell icons are `Icon size="xs"` — **16px** — over a 12px (`text-xs`) label. That
134
- is under both native baselines on purpose: iOS HIG draws a tab glyph at ~25pt
135
- over a 10pt label, Material 3 at 24dp over a 12sp one. At the 20px this bar used
136
- before it was already below both; 16px puts it at two thirds of MD3. **This is a
137
- decision, not an oversight — do not "correct" it upwards.** The bar is
138
- label-forward.
139
-
140
- The measurement behind the call: 16px against 20px is −20% linear and −36% in
141
- area, enough that the glyph stops out-reading its label and settles level with
142
- it. The word is what identifies the destination; the icon is what keeps the row
143
- scannable and what makes a cell look intact — which is why `iconName` is still
144
- required with no fallback. The size is the `xs` rung of the shared `Icon` scale
145
- and not a per-call `className` override: an 18px rung was considered and
140
+ ### Icon size
141
+
142
+ Cell icons are `Icon size="sm"` — **20px** — over a 12px (`text-xs`) label, one
143
+ rung up from the `xs` (16px) the bar carried before. Still under both native
144
+ baselines: iOS HIG draws a tab glyph at ~25pt over a 10pt label, Material 3 at
145
+ 24dp over a 12sp one, so 20px sits below MD3 rather than above it.
146
+
147
+ The measurement behind the step: 16px against 20px is −20% linear and −36% in
148
+ area. At 16px the glyph settled *level* with its label — a fine proportion to
149
+ read, a poor one to aim at from across the screen. At 20px the icon leads and
150
+ the label confirms, which is the order a tab bar is scanned in, and it is why
151
+ `iconName` is required with no fallback.
152
+
153
+ The size is a rung of the shared `Icon` scale, not a per-call `className`
154
+ override, and never an intermediate value: an 18px rung was considered and
146
155
  refused, because adding a step to `sizeMap` is a scale decision for ~93
147
- components taken to serve one bar.
156
+ components taken to serve one bar. **The next size up is `md` (24px)** — MD3
157
+ parity, and 4px more bar.
148
158
 
149
- The padding was **not** compensated when the icon shrank: `py-1.5` stayed, so
150
- the bar came down from 59px to 55px. A lower bar was the point of the smaller
151
- glyph, not a side effect of it.
159
+ Height history, so the trade is legible: the bar was 59px with a 20px glyph and
160
+ `py-1.5`, came down to 55px when the glyph shrank to 16px with the padding
161
+ uncompensated, and is 67px now `py-2.5` from `designTokens.sizes.touch` (+8px,
162
+ spent on the touch target) and the glyph back to 20px (+4px, spent on reach).
152
163
 
153
164
  ### Active state
154
165
 
155
166
  Exactly one cell is ever marked, with `data-active="true"` and a **rounded
156
167
  pill**: `bg-d-sidebar-accent text-d-sidebar-accent-foreground font-bold` on a
157
168
  `rounded-md` (6px) surface, inset 4px from every edge of the dock
158
- (`designTokens.navItem.selectedPill`, with `navItem.basePill` carrying the
169
+ (`navTokens.item.selectedPill`, with `navTokens.item.basePill` carrying the
159
170
  radius on every cell). **No border on any side.**
160
171
 
161
172
  This is the desktop sidebar row's own active state, ported verbatim — the pair
@@ -207,7 +218,7 @@ already because it reads as a selected chip rather than as the current tab.
207
218
  floor in light at 1.98:1, but separating by hue, which 1.4.11 does not measure.
208
219
  A tint of primary is not an option, as the table shows.
209
220
 
210
- The sidebar's `navItem.selected` can still afford a 10% tint because a 2px rule
221
+ The sidebar's `navTokens.item.selected` can still afford a 10% tint because a 2px rule
211
222
  carries the state there; here there is no rule to fall back on.
212
223
 
213
224
  `rounded-md` and not `radius.sm`: `sm` is 4px in the Tailwind v4 bundle and 2px
@@ -220,14 +231,13 @@ claymorphism theme, while the dock around it is a fixed `rounded-t-lg`.
220
231
  - **Parent whose child is current** (any `subItems[].isActive`) → the cell is a
221
232
  `<button>`, marked, **without** `aria-current`: a button is not a page, it is a
222
233
  disclosure. So the app only has to mark the leaf.
223
- - **Nothing in the bar owns the route** → the **Menu** cell takes the pill, with
224
- no `aria-current` and no `aria-expanded="true"`. Five neutral cells read as
225
- "the component failed to work out where I am"; the truthful statement is "the
226
- page you are on is reachable through here", and it is redundant for a screen
227
- reader, so nothing is added to the accessibility tree. With
228
- `showMenu={false}` there is nothing to hand the pill to and the bar does
229
- render entirely neutral — the honest state when every cell is a destination
230
- and none of them is the current one.
234
+ - **Nothing in the bar owns the route** → **no cell is marked**, Menu included.
235
+ The bar renders entirely neutral, exactly as it always has with
236
+ `showMenu={false}`. The Menu cell is a disclosure, not a destination: the pill
237
+ means "this is the page you are on", and handing it to a button that opens a
238
+ panel says that about something which is not a page. A route outside the bar's
239
+ five destinations is a real state, and the honest way to render it is no pill
240
+ at all.
231
241
  - Two active items is an app bug: the first in order wins and one warning is
232
242
  logged.
233
243
 
@@ -255,6 +265,51 @@ no `interaction.active` squash: its scale is for buttons, and a tab-bar cell is
255
265
  surface you move between, not an object you push — an anisotropic scale on a
256
266
  12px truncating label distorts the one thing the cell exists to let you read.
257
267
 
268
+ ### Badges
269
+
270
+ `BottomNavItem.badge` is the same field as `AppSidebar`'s `NavItem.badge` — the
271
+ three forms (count, short text, dot), the `99+` overflow, the `0`-renders-nothing
272
+ rule and the warnings are all documented once, in `AppSidebar.md`. What is
273
+ specific to this surface:
274
+
275
+ - The capacity is **`compact`**: a **number** renders as a 14px superscript on
276
+ the glyph (`99+` included), and a **short text degrades to a dot**. Four
277
+ characters of superscript in a 52px cell either do not read or run into the
278
+ neighbouring cell, so text is not shrunk, it is dropped to the smallest form
279
+ that still says "there is something here".
280
+ - The badge sits on the **glyph**, not beside the label, which is what keeps the
281
+ ~8-character label budget documented above exactly as it was. Measured at
282
+ 320px, the tightest row the bar renders: the label box is 44px in the badged
283
+ story and 44px in `Default` — the badge costs the label nothing.
284
+ - It **does not bleed**. Six cells at 320px give a 44px content box around a
285
+ 20px glyph, so there are 12px of slack per side; the badge reaches 6px past
286
+ the glyph plus 2px of ring, and it sits ~11px below the top edge of the dock,
287
+ so it meets neither the `rounded-t-lg` corners nor the dock's
288
+ `overflow-hidden`.
289
+ - **No interaction with the active pill.** The pill marks the position, the
290
+ badge marks the quantity; inverting the badge on the current cell would tie
291
+ two orthogonal channels together. `ring-2 ring-d-card` is safe over the pill
292
+ unconditionally — pill and card measure 1.10:1 light / 1.23:1 dark against
293
+ each other, so one ring colour works in both states.
294
+ - A cell with `subItems` that has no badge of its own shows a **dot** when one
295
+ of its descendants is badged. Counts never aggregate.
296
+ - **The Menu cell takes `menuBadge`, a dot and never a count**: the Menu opens
297
+ the whole navigation, a superset of `items`, so any number the bar computed
298
+ would be a lie. It is OR-ed with the items **dropped past the cell budget**,
299
+ so a drop cannot hide state as well as a destination — the drop warning still
300
+ fires, naming them. The Menu dot carries no announcement: there is no label to
301
+ give it, and what it stands for is announced on the rows of the panel it
302
+ opens.
303
+ - **Live counts vs static ones** follow the contract written once in
304
+ `AppSidebar.md` ("Live counts vs static ones, and who announces them"): the
305
+ design system assumes neither and renders no live region; when a count changes
306
+ as the direct result of an action on the current screen, the app announces it
307
+ in its own status region next to that action.
308
+
309
+ The `WithBadges` and `WithBadgesSmall` stories are the same five labels as
310
+ `Default`, badged, at 390px and 375px; `MenuBadgeFromDroppedItem` is the drop
311
+ case.
312
+
258
313
  ### Opening the menu
259
314
 
260
315
  Both the Menu cell and any cell with `subItems` open `AppSidebar`'s mobile Sheet
@@ -299,6 +354,13 @@ suppress is noise. Turning it off is a claim; the app has to honour it.
299
354
  implement).
300
355
  - No `title` attributes: they do nothing on touch and some screen readers read
301
356
  them after the name.
357
+ - A badge is `aria-hidden="true"` and its `badgeLabel` rides an in-flow
358
+ `sr-only` span **inside the cell**, so the name composes as "Clienti, 12
359
+ clienti nuovi" and never "Clienti 12". `aria-current` is untouched, on the
360
+ destination cells as on the parent cells (which carry `aria-expanded` and no
361
+ `aria-current`, as above). An aggregate dot — a parent's, or the Menu cell's —
362
+ is visual only: it has no text of its own, and what it stands for is announced
363
+ on the row that owns it.
302
364
  - Focus ring is `designTokens.focusRing` + `focus-visible:ring-inset` — the
303
365
  cells touch each other, so a 3px outset ring would spill over the neighbouring
304
366
  cell and read as if two cells were focused.
@@ -311,18 +373,24 @@ suppress is noise. Turning it off is a claim; the app has to honour it.
311
373
 
312
374
  No entrance animation — the bar is chrome, present from first paint. The only
313
375
  transition is the colour change already carried by `designTokens.focusRing`, so
314
- there is nothing for `prefers-reduced-motion` to suppress. The absence of a
315
- shadow is equally deliberate (see Layout). The sheet's own 300ms open is owned
316
- by `SheetContent`.
376
+ there is nothing for `prefers-reduced-motion` to suppress. **Badges add none**:
377
+ no enter/exit transition, no pulse, no animated counter a badge appearing is
378
+ not a reason to open that chapter. The absence of a shadow is equally deliberate
379
+ (see Layout). The sheet's own 300ms open is owned by `SheetContent`.
317
380
 
318
381
  ### Warnings (`console.warn`, deduplicated per message)
319
382
 
320
- - more `items` than slots, naming the dropped titles;
383
+ - more `items` than slots, naming the dropped titles — and if one of the dropped
384
+ items was badged, the Menu cell takes a dot so the state is not dropped with
385
+ it;
321
386
  - `items: []` together with `showMenu={false}`, where the bar renders `null`;
322
387
  - two or more active items;
323
388
  - a duplicate `url` + `title` pair (duplicate React keys make React reuse DOM
324
389
  and state across cells);
325
- - an unresolved drill target (logged by `AppSidebarMobile`).
390
+ - an unresolved drill target (logged by `AppSidebarMobile`);
391
+ - an invalid `badge` (negative, non-integer, empty string) or one over 8
392
+ characters, and a `badge: true` with no `badgeLabel` — all logged by the shared
393
+ normaliser, naming the row.
326
394
 
327
395
  ### Edge cases
328
396
 
@@ -398,6 +466,69 @@ export function Layout({ children }: { children: React.ReactNode }) {
398
466
  />
399
467
  ```
400
468
 
469
+ ### A Menu cell of the app's own
470
+
471
+ Both halves of the cell are configurable: `menuLabel` for the copy, `menuIcon`
472
+ for the glyph. **`menuIcon` is one prop with two forms**, told apart by
473
+ `typeof`:
474
+
475
+ | form | when | example |
476
+ | ----------------------- | ------------------------------- | ----------------------------- |
477
+ | `IconName` (string) | the shape is in lucide | `menuIcon="LayoutGrid"` |
478
+ | `() => React.ReactNode` | it is not — a brand mark, an SVG | `menuIcon={() => <AcmeMark/>}` |
479
+
480
+ `IconName` is `keyof typeof LucideIcons`, so the string form already covers the
481
+ whole set — the cell is not tied to the hamburger. Reach for that when the app
482
+ already has one in its header, where two hamburgers on one screen read as two
483
+ different menus.
484
+
485
+ ```tsx
486
+ <AppBottomNav items={bottomNavItems} menuLabel="Altro" menuIcon="LayoutGrid" />
487
+ ```
488
+
489
+ **Why a function and not a `ReactNode`.** A string is itself a valid
490
+ `ReactNode`, so `IconName | ReactNode` collapses to `ReactNode` and there is no
491
+ way left to tell a lucide name from literal text. The function form keeps the
492
+ union discriminable by `typeof menuIcon === "string"` — and one prop instead of
493
+ two makes "both passed" unrepresentable rather than something to warn about.
494
+
495
+ **The function is called, not rendered as a component.** Hooks inside it are
496
+ not supported; in exchange an inline arrow is safe, which is how this gets
497
+ written in practice — as an element type it would change identity on every
498
+ render and remount the subtree.
499
+
500
+ #### A glyph lucide does not have
501
+
502
+ ```tsx
503
+ <AppBottomNav
504
+ items={bottomNavItems}
505
+ menuLabel="Acme"
506
+ menuIcon={() => <AcmeMark />}
507
+ />
508
+ ```
509
+
510
+ Returning nullish falls back to the default glyph, so a conditional icon cannot
511
+ leave the cell bare: the row's argument for a mandatory glyph is that a cell
512
+ without one reads as a failed image, and that does not stop applying here.
513
+
514
+ **The geometry stays the design system's; only the drawing is the app's.** What
515
+ the function returns is rendered inside a box of exactly the size
516
+ `Icon size="sm"` occupies — 20px, `shrink-0` — and a direct `<svg>` child is
517
+ stretched to fill it (`[&>svg]:size-full`), which is the case the wrapper exists
518
+ for: a pasted SVG carries its own `width`/`height` and would otherwise land at
519
+ whatever those say. `preserveAspectRatio` defaults to `meet`, so a non-square
520
+ viewBox is letterboxed inside the box rather than distorted. A child that is not
521
+ a direct `<svg>` has to size itself; centring is handled either way. Measured on
522
+ the `CustomMenuIconNode` story, which returns a 48×32 SVG on purpose: it renders
523
+ at 20×20, the same box as the lucide glyph in the first cell.
524
+
525
+ This is the one cell whose glyph the design system does not control, and so the
526
+ one that can break the rhythm of the other five — a drawing with a different
527
+ stroke weight or optical density will read as foreign next to five lucide
528
+ glyphs, and no wrapper can fix that. `BottomNavItem.iconName` stays name-only
529
+ for exactly that reason: the escape hatch is deliberately this cell's alone.
530
+ **Prefer the string form wherever lucide has the shape.**
531
+
401
532
  ### Without the Menu cell
402
533
 
403
534
  The header trigger is not optional here — it is what keeps the mobile panel