laif-ds 0.2.92 → 1.0.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.
package/dist/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ 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.0]
9
+
10
+ ### 🚀 Added
11
+
12
+ - **AppSidebar**: Below 768px, the sidebar now renders a Settings-style drill-down menu instead of the desktop tree in a full-screen sheet — grouped cards with dividers, 44px rows with a leading icon and a chevron only where there are children, and items with `subItems` open a level-1 screen (with a `‹ Menu` back control) instead of expanding inline. This replaces the previous mobile behavior unconditionally; there is no prop to opt out, and `collapsible` has no effect below 768px
13
+ - **AppSidebar**: Added `navigationDisplay` prop (`"list" | "tab"`, default `"list"`) — in `"tab"` mode the `navigation` groups render as a tab bar at the top of the sidebar and show one group at a time, instead of stacking every group under its own label. Only the selected tab shows its label, animating open to fit it exactly; every other tab is icon-only. `navigationFooter` is never tabbed and always stays visible at the bottom. A lone `navigation` group still renders without a label (in both displays and on mobile), and with a single group the tab bar is dropped entirely. Collapsed to the icon rail, the bar becomes a single group selector — the active group's icon with a chevron — that opens a `menuitemradio` menu. `NavGroup` gained an optional `iconName`, read only in tab mode
14
+ - **AppBottomNav**: Added a new mobile bottom navigation bar (visible below 768px, `md:hidden`), meant to be mounted as the last child of `SidebarInset`, in the normal flow and never `fixed`. The bar is full-bleed and attached to the bottom edge — `bg-d-card` with a top border only, rounded top corners, `overflow-hidden`, no shadow — for a total height of 55px plus the safe-area inset, painted as bar surface with no minimum floor. It renders up to 5 destinations (`BottomNavItem[]`, already filtered, sorted and route-matched by the app) with 16px icons over a 12px label — deliberately below both native tab-bar baselines, label-forward — plus a Menu cell that opens `AppSidebar`'s mobile panel (`showMenu={false}` drops it and spends the freed slot on a 6th destination instead). A cell with its own `subItems` opens the panel already drilled into that level instead of navigating, via the sidebar context's new `mobileNavTarget`. The active cell is marked with a rounded pill using the sidebar's own active-row colors (`bg-d-sidebar-accent` / `text-d-sidebar-accent-foreground` / `font-bold`, no border) instead of a solid brand fill — the same low-contrast tradeoff the desktop sidebar's active row has always carried on that surface (~1.1–1.2:1, under the 3:1 WCAG 1.4.11 floor for a non-text indicator), carried here by weight rather than tint; label and icon stay at 10:1+ regardless. Hover is limited to fine-pointer, hover-capable devices so a tap can't leave a stuck hover tint next to the active cell
15
+ - **design-tokens**: Added `designTokens.sizes.touch` (44px), `designTokens.motion` (shared easing/duration for the sidebar's tab animation), and `designTokens.navItem` (selected-row styling built on foreground weight and a leading bar for the sidebar's own rows, plus a `basePill`/`selectedPill` variant carrying the rounded-pill treatment for `AppBottomNav`'s active cell)
16
+
17
+ ### 🔧 Changed
18
+
19
+ - **Sidebar**: On mobile, the full-screen `Sheet` branch now takes precedence over `collapsible="none"` — previously, pinning `collapsible="none"` left the trigger toggling `openMobile` with no sheet listening to it, making navigation unreachable below 768px. The mobile sheet now also takes focus on open (instead of landing on whatever `headerContent` renders first, which could raise the keyboard over the menu) and restores focus to the trigger on close
20
+ - **Sheet**: `side="full"` no longer zooms under `prefers-reduced-motion` — the fade stays
21
+
22
+ ### 🐛 Fixed
23
+
24
+ - **AppSidebar**: Mobile row labels now wrap instead of clamping to two lines, so long labels remain fully readable at 200% text zoom (WCAG 1.4.4)
25
+ - **AppSidebar**: Mobile group titles no longer render at a 3.94:1 contrast ratio (muted text on a muted panel)
26
+ - **AppSidebar**: In `navigationDisplay="tab"` mode, focus now survives collapsing and re-expanding the sidebar while a tab is focused, and the selected tab is tracked via React state instead of Radix's `data-state` — `TooltipTrigger asChild` was silently overwriting that attribute on the trigger, which had been killing every active-state rule keyed off it
27
+
8
28
  ## [laif-ds@0.2.92]
9
29
 
10
30
  ### 🐛 Fixed
@@ -1,8 +1,8 @@
1
1
  "use client";
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);
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
- m as default
7
+ a as default
8
8
  };
@@ -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 r } from "../node_modules/extend/index.js";
4
+ var t = r();
5
+ const x = /* @__PURE__ */ e(t);
6
6
  export {
7
- a as default
7
+ x 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 r } from "../node_modules/extend/index.js";
4
- var t = r();
5
- const x = /* @__PURE__ */ e(t);
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
- x as default
7
+ m as default
8
8
  };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "generatedAt": "2026-08-20T15:52:41.132Z",
3
+ "generatedAt": "2026-09-02T16:31:01.025Z",
4
4
  "package": {
5
5
  "name": "laif-ds",
6
- "version": "0.2.92"
6
+ "version": "1.0.0"
7
7
  },
8
8
  "summary": {
9
- "scannedFiles": 398,
9
+ "scannedFiles": 402,
10
10
  "deprecatedComponentsCount": 2,
11
11
  "deprecatedComponentsUsedCount": 0,
12
12
  "componentsWithoutExamplesCount": 5,
@@ -14,8 +14,8 @@
14
14
  "repeatedClassPatternsCount": 25,
15
15
  "lowAiReadinessComponentsCount": 41,
16
16
  "componentsWithoutAccessibilityCoverageCount": 45,
17
- "componentsWithWeakStateCoverageCount": 72,
18
- "averageAiReadinessScore": 55.54
17
+ "componentsWithWeakStateCoverageCount": 71,
18
+ "averageAiReadinessScore": 56.17
19
19
  },
20
20
  "deprecatedUsage": [],
21
21
  "documentationGaps": {
@@ -111,7 +111,6 @@
111
111
  "AlertDialog",
112
112
  "AppDialog",
113
113
  "AppMultipleSelectDropdown",
114
- "AppSidebar",
115
114
  "AspectRatio",
116
115
  "AudioVisualizer",
117
116
  "Avatar",
@@ -452,6 +451,14 @@
452
451
  "src/components/stories/app-form.stories.tsx"
453
452
  ]
454
453
  },
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
+ },
455
462
  {
456
463
  "className": "text-muted-foreground mt-2 text-sm",
457
464
  "occurrences": 13,
@@ -459,6 +466,14 @@
459
466
  "src/components/stories/tabs.stories.tsx"
460
467
  ]
461
468
  },
469
+ {
470
+ "className": "text-d-muted-foreground max-w-prose text-sm",
471
+ "occurrences": 10,
472
+ "files": [
473
+ "src/components/stories/app-bottom-nav.stories.tsx",
474
+ "src/components/stories/app-sidebar.stories.tsx"
475
+ ]
476
+ },
462
477
  {
463
478
  "className": "flex w-[550px] flex-col gap-6",
464
479
  "occurrences": 10,
@@ -508,6 +523,13 @@
508
523
  "src/components/stories/async-select.stories.tsx"
509
524
  ]
510
525
  },
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
+ },
511
533
  {
512
534
  "className": "!text-d-foreground opacity-50",
513
535
  "occurrences": 8,
@@ -569,46 +591,23 @@
569
591
  ]
570
592
  },
571
593
  {
572
- "className": "text-lg font-semibold",
573
- "occurrences": 6,
574
- "files": [
575
- "src/components/stories/app-kanban.stories.tsx",
576
- "src/components/stories/changelog.stories.tsx",
577
- "src/components/stories/playground.stories.tsx"
578
- ]
579
- },
580
- {
581
- "className": "flex h-full overflow-hidden",
594
+ "className": "text-sm font-semibold",
582
595
  "occurrences": 6,
583
596
  "files": [
584
- "src/components/stories/app-sidebar.stories.tsx",
585
- "src/components/stories/sidebar.stories.tsx"
586
- ]
587
- },
588
- {
589
- "className": "mb-2 text-sm font-medium",
590
- "occurrences": 6,
591
- "files": [
592
- "src/components/stories/chat-message.stories.tsx",
597
+ "src/components/stories/app-bottom-nav.stories.tsx",
598
+ "src/components/stories/collapsible.stories.tsx",
593
599
  "src/components/stories/data-table-utils-example.stories.tsx",
594
- "src/components/stories/dialog.stories.tsx"
595
- ]
596
- },
597
- {
598
- "className": "border-d-border border px-1 capitalize",
599
- "occurrences": 6,
600
- "files": [
601
- "src/components/stories/data-table-docs.stories.tsx",
602
- "src/components/stories/data-table-no-utils-example.stories.tsx",
603
- "src/components/stories/data-table-utils-example.stories.tsx"
600
+ "src/components/stories/truncated-cell.stories.tsx",
601
+ "src/components/ui/weekly-calendar/appointment-card.tsx"
604
602
  ]
605
603
  },
606
604
  {
607
- "className": "list-disc space-y-1 pl-5 text-sm",
605
+ "className": "text-lg font-semibold",
608
606
  "occurrences": 6,
609
607
  "files": [
610
- "src/components/stories/data-table-no-utils-example.stories.tsx",
611
- "src/components/stories/data-table-utils-example.stories.tsx"
608
+ "src/components/stories/app-kanban.stories.tsx",
609
+ "src/components/stories/changelog.stories.tsx",
610
+ "src/components/stories/playground.stories.tsx"
612
611
  ]
613
612
  }
614
613
  ]
@@ -0,0 +1,426 @@
1
+ # AppBottomNav
2
+
3
+ ## Overview
4
+
5
+ Mobile bottom navigation bar: up to 5 primary destinations plus a **Menu** cell
6
+ that opens `AppSidebar`'s mobile panel — or up to 6 destinations with
7
+ `showMenu={false}`, which is the same six cells either way. Visible below 768px
8
+ only (`md:hidden`), and rendered **in the normal flow — never `fixed`**.
9
+
10
+ The bar is **full-bleed and attached to the bottom edge**: the surface is
11
+ `bg-d-card` with `border-t border-d-border`, `rounded-t-lg` on the top two
12
+ corners only and `overflow-hidden`, which is the same language `DrawerContent`
13
+ uses for its `bottom` direction. Below 768px `SidebarInset` is already
14
+ full-viewport-width, so the full bleed needs no positioning of its own.
15
+
16
+ **Mount it as the last child of `SidebarInset`**, outside any page-level padding
17
+ wrapper, and make the page content beside it the scroll container
18
+ (`flex-1 min-h-0 overflow-auto`). `SidebarInset` is a flex column, so with the
19
+ dock's `shrink-0` the bar stays put while the page scrolls — the sticky feel
20
+ without `fixed`, and without every consuming app having to add bottom padding to
21
+ every scroll container. **Two things an app can get wrong**: if the document
22
+ scrolls instead of the page container the bar scrolls off-screen, and any
23
+ horizontal padding on an ancestor breaks the full bleed — that one is a layout
24
+ bug, not only a cosmetic one.
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.
31
+ The reserve is **painted bar surface**: under a home indicator you see bar
32
+ colour, not page.
33
+
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.**
40
+
41
+ It must be rendered inside a `SidebarProvider` (it reads the sidebar context to
42
+ open the mobile panel); outside one, `useSidebar()` throws.
43
+
44
+ ---
45
+
46
+ ## Props
47
+
48
+ | Prop | Type | Default | Description |
49
+ | --------------- | --------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------- |
50
+ | `items` | `BottomNavItem[]` | **required** | Already filtered, sorted and route-matched by the app. Max 5 used, or 6 with `showMenu={false}`. |
51
+ | `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
+ | `menuLabel` | `string` | `"Menu"` | Label of the menu cell. |
53
+ | `ariaLabel` | `string` | `"Main navigation"` | Accessible name of the `<nav>` landmark. |
54
+ | `menuIconName` | `IconName` | `"Menu"` | Icon of the menu cell. |
55
+ | `linkComponent` | `React.ComponentType<React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }>` | `undefined` | Router link component (e.g. Next.js `Link`); plain `<a>` otherwise. |
56
+ | `linkProps` | `Record<string, unknown>` | `{}` | Extra props forwarded to `linkComponent`. |
57
+ | `className` | `string` | `undefined` | Merged onto the dock wrapper. |
58
+
59
+ Inherits the remaining props from `Omit<React.ComponentProps<"div">, "children">`
60
+ (the bar renders its own cells; it takes no children).
61
+
62
+ ### `BottomNavItem`
63
+
64
+ | Field | Type | Default | Description |
65
+ | ----------- | -------------- | ------------ | ---------------------------------------------------------------------------------------------- |
66
+ | `title` | `string` | **required** | Visible label. **Must equal the sidebar `NavItem.title`** of the same destination (see below). |
67
+ | `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. |
69
+ | `isActive` | `boolean` | `undefined` | Leaf level only — the subtree state is derived from `subItems`. |
70
+ | `subItems` | `NavSubItem[]` | `undefined` | Presence makes the cell open the mobile menu at this level instead of navigating. |
71
+ | `ariaLabel` | `string` | `undefined` | Only when the spoken name must differ from the label; it must still contain the visible text. |
72
+
73
+ `NavSubItem` is the same type `AppSidebar` uses:
74
+ `{ title: string; url: string; isActive?: boolean; iconName?: IconName }`.
75
+
76
+ ---
77
+
78
+ ## Behavior
79
+
80
+ ### Layout
81
+
82
+ - **6 cells maximum**: the first 5 `items` plus the Menu cell, or the first 6
83
+ `items` with `showMenu={false}`. The budget is on **cells and does not move
84
+ with `showMenu`** — hiding the Menu spends the slot it frees on a destination
85
+ rather than fattening the other cells, which is the only reason to hide it.
86
+ Neither number is configurable.
87
+ - Cells are always **`flex-1 min-w-0`** — equal width, evenly distributed, at
88
+ every count and whatever a cell holds. Both halves matter: `flex-1` is
89
+ `1 1 0%`, which stays even only while no cell's content forces it wider, since
90
+ a flex item's automatic minimum size is its min-content width. `min-w-0`
91
+ removes that escape hatch, so the label truncates instead of the row going
92
+ 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
94
+ narrowest cell of 52px — but by arithmetic rather than by construction.)
95
+ - **Six is a maximum, not a count.** Nothing pads the row: with fewer items the
96
+ cells simply get fatter, as every native tab bar does, and Menu stays pinned
97
+ to the thumb-reachable bottom-right corner. Minus the 4px inset on each side,
98
+ at 375px that is **61.2px** at 6 cells, **73.4px** at 5, **91.8px** at 4 and
99
+ **122.3px** at 3 (at 320px, 6 cells are 52px) — all above the 44px touch floor
100
+ (`min-h-11`, which lets a cell grow but never clip). The `FewerItems` story is
101
+ three items plus Menu.
102
+ - **The sixth cell is paid for out of the label, and the bill is not small.**
103
+ Take off `px-1` and the label box is **53px at 375px**, against the 65.4px it
104
+ had at five cells. Measured in the `WithoutMenu` story at 375px, that is
105
+ enough to ellipsize labels that used to fit: `Dashboard` and `Documenti`
106
+ render at 63px and now truncate, `Impostazioni` at 74px truncates hard,
107
+ while `Clienti`, `Report` and `Storico` fit. **At six cells, plan for ~8
108
+ characters at 375px** — one-word labels, and short ones. `WithoutMenu` at
109
+ 375px is the tightest row the component can render; check a real label set
110
+ there before shipping six.
111
+ - The **dock carries the surface**, not the inner `<nav>`: full-bleed, attached
112
+ to the bottom edge, `border-t` + `rounded-t-lg` (8px, the value
113
+ `DrawerContent` uses for its bottom direction), and `overflow-hidden`, which
114
+ guarantees the corners of the surface stay the dock's own whatever a cell
115
+ paints. The `<nav>` is pure layout, with a **uniform 4px inset** (`p-1`): that
116
+ is what keeps the active pill clear of the 8px top curve — a full-bleed cell
117
+ with a radius of its own comes out shaved on the first and last slot — and it
118
+ is uniform because a pill nearer one edge than the other reads as a
119
+ misalignment. **No gap** between cells: only one is ever filled, so the
120
+ neighbours are transparent and a gap would only narrow the label.
121
+ - **No shadow, deliberately.** The bar is in-flow, so nothing ever passes behind
122
+ it, and Tailwind's shadows project downwards — from the bottom edge that is
123
+ off-screen. In dark a black-alpha shadow is invisible anyway, so the
124
+ separation would rest on the border in one theme and on two mechanisms in the
125
+ other. The border is the single carrier.
126
+ - **No visual hint on cells that open the menu** — no chevron, no caret. At
127
+ ~61px a chevron costs two characters of an already-truncating label, and a
128
+ hint on some cells only breaks the even rhythm that makes a tab bar scannable.
129
+ The feedback is the reveal itself.
130
+
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
146
+ refused, because adding a step to `sizeMap` is a scale decision for ~93
147
+ components taken to serve one bar.
148
+
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.
152
+
153
+ ### Active state
154
+
155
+ Exactly one cell is ever marked, with `data-active="true"` and a **rounded
156
+ pill**: `bg-d-sidebar-accent text-d-sidebar-accent-foreground font-bold` on a
157
+ `rounded-md` (6px) surface, inset 4px from every edge of the dock
158
+ (`designTokens.navItem.selectedPill`, with `navItem.basePill` carrying the
159
+ radius on every cell). **No border on any side.**
160
+
161
+ This is the desktop sidebar row's own active state, ported verbatim — the pair
162
+ `sidebarMenuButtonVariants` paints under `data-[active=true]`. One vocabulary
163
+ for "you are here" across the two navigations was judged worth more than either
164
+ surface being optimal on its own.
165
+
166
+ #### Contrast: very low, and inherited on purpose
167
+
168
+ With no border, the fill is the only carrier of the state, which puts it under
169
+ WCAG 1.4.11 (≥ 3:1 for a non-text indicator) against the bar surface. **The
170
+ sidebar's accent is nowhere near that floor in either theme**:
171
+ `--d-sidebar-accent` is `--colors--surface--tertiary` at 40% alpha, so it
172
+ composites to a few levels off whatever it sits on — `#efefef` on the light bar,
173
+ `#2f2f2f` on the dark one.
174
+
175
+ | against the bar (`d-card`) | light (`#fafafa`) | dark (`#1f1f1f`) |
176
+ | ------------------------------------------- | ----------------- | ---------------- |
177
+ | `bg-d-sidebar-accent` (in use, composited) | **1.10:1** ✗ | **1.23:1** ✗ |
178
+ | `bg-d-accent` (solid tertiary) | 1.26:1 ✗ | 1.77:1 ✗ |
179
+ | `bg-d-primary` (previous) | 1.98:1 ✗ | 7.99:1 ✓ |
180
+ | `bg-d-primary/40` | 1.30:1 ✗ | 2.39:1 ✗ |
181
+ | `bg-d-primary/10` | 1.07:1 ✗ | 1.20:1 ✗ |
182
+ | `bg-d-foreground` (conforming, dropped) | 13.01:1 ✓ | 12.49:1 ✓ |
183
+
184
+ | on the fill | light | dark |
185
+ | --------------------------------------------- | --------- | --------- |
186
+ | `text-d-sidebar-accent-foreground` (label, icon) | 11.9:1 ✓ | 10.1:1 ✓ |
187
+
188
+ Two things follow. **The label is never at risk**:
189
+ `--d-sidebar-accent-foreground` is `--colors--text--body-primary`, the very
190
+ colour the inactive cells already use, so text and icon read 11.9:1 and 10.1:1
191
+ on the composited fill — what falls under the floor is the _separation of the
192
+ active surface from the bar_, not legibility. And **this is not a regression
193
+ introduced by the bottom bar**: the same fill on the sidebar's own surface
194
+ (`#f5f5f5`) measures 1.08:1, so the desktop sidebar has always marked the
195
+ current row by **weight**, with the tint as a hint. Here the weight does the
196
+ same job.
197
+
198
+ Which is why the pill is `font-bold` and not `font-semibold`: with a fill this
199
+ faint, the weight _is_ the state. It costs label width on a cell that already
200
+ truncates at 375px — if that has to be bought back, `font-semibold` is the place
201
+ to spend it, not the fill.
202
+
203
+ If the 3:1 floor ever has to be met, the table's last row is the way back
204
+ (`bg-d-foreground` + `text-d-background`) — a near-black pill, dropped once
205
+ already because it reads as a selected chip rather than as the current tab.
206
+ `bg-d-primary` + `text-d-primary-foreground` is the other history: under the
207
+ floor in light at 1.98:1, but separating by hue, which 1.4.11 does not measure.
208
+ A tint of primary is not an option, as the table shows.
209
+
210
+ The sidebar's `navItem.selected` can still afford a 10% tint because a 2px rule
211
+ carries the state there; here there is no rule to fall back on.
212
+
213
+ `rounded-md` and not `radius.sm`: `sm` is 4px in the Tailwind v4 bundle and 2px
214
+ in the v3 one, and this would be the only place the two disagree. Not
215
+ `rounded-d-md` either — that maps onto `--d-radius` and becomes 18px under the
216
+ claymorphism theme, while the dock around it is a fixed `rounded-t-lg`.
217
+
218
+ - **Leaf item active** (`isActive`) → the cell is a link with
219
+ `aria-current="page"`.
220
+ - **Parent whose child is current** (any `subItems[].isActive`) → the cell is a
221
+ `<button>`, marked, **without** `aria-current`: a button is not a page, it is a
222
+ 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.
231
+ - Two active items is an app bug: the first in order wins and one warning is
232
+ logged.
233
+
234
+ Inactive cells use `text-d-foreground` with `font-medium` (not
235
+ `text-d-muted-foreground`, which measures 4.11:1 on the card in the light theme
236
+ — below the 4.5:1 floor at 12px; on `d-background` it would be worse, 3.94:1,
237
+ so the full-bleed surface does not change the conclusion). The subordination
238
+ comes from the weight and from the absence of the pill.
239
+
240
+ **Interaction states.** Inactive cells hover and press to
241
+ `bg-d-sidebar-accent` + `text-d-sidebar-accent-foreground`, and the marked cell
242
+ has **no hover or press variant at all** — again the sidebar's behaviour rather
243
+ than an omission: `sidebarMenuButtonVariants` gives the active row the accent
244
+ and then repeats that same accent under `hover:` and `active:`, so the current
245
+ row is inert to the pointer. The consequence is that the three states share one
246
+ colour and only the bold label separates the current cell from a hovered one.
247
+ Survivable in a vertical list, where the pointer is on the row you are looking
248
+ at; not in a six-cell row, where a hover tint left behind by a tap sits _beside_
249
+ the active cell and reads as a second one. **So hover is gated behind
250
+ `[@media(hover:hover) and (pointer:fine)]`** — the sidebar's own fix for exactly
251
+ this, applied there on its tab triggers. Hover is for a real mouse (below 768px
252
+ also covers a narrow desktop window); the finger gets the press state, which is
253
+ immediate, cannot stick, and previews the destination becoming current. There is
254
+ no `interaction.active` squash: its scale is for buttons, and a tab-bar cell is a
255
+ surface you move between, not an object you push — an anisotropic scale on a
256
+ 12px truncating label distorts the one thing the cell exists to let you read.
257
+
258
+ ### Opening the menu
259
+
260
+ Both the Menu cell and any cell with `subItems` open `AppSidebar`'s mobile Sheet
261
+ (`setOpenMobile(true)`, never `toggleSidebar()`).
262
+
263
+ - The **Menu** cell always opens at level 0. Its `aria-expanded` follows
264
+ `openMobile`. There is no visual treatment of the open state: the sheet is
265
+ full-screen, so the bar is entirely covered while it is open.
266
+ - A cell with `subItems` opens the panel **already drilled into that level**. It
267
+ publishes `item.title` as the sidebar context's `mobileNavTarget` and
268
+ `AppSidebarMobile` resolves it, at mount, against the first `NavItem` across
269
+ `navigation` then `navigationFooter` whose `title` matches and which has
270
+ sub-items. **This is why `BottomNavItem.title` must equal the sidebar
271
+ `NavItem.title`** — both lists come from the same app-side source. No match
272
+ opens level 0 and logs a warning; it is never an error surface.
273
+ - The target only ever describes the next open: it is cleared when the panel
274
+ closes, so a stale target can never re-drill.
275
+
276
+ #### Opting out: `showMenu={false}`
277
+
278
+ **"Under 768px there is always a way to open the navigation" is the invariant
279
+ the bar guarantees on its own, and `showMenu={false}` is an app taking that
280
+ guarantee over — not switching it off.** With no Menu cell, the only thing left
281
+ in the bar that reaches the mobile panel is a cell that happens to carry
282
+ `subItems`; the full navigation, and every destination that did not make the six
283
+ slots, is otherwise unreachable.
284
+
285
+ So the opt-out is for an app that already provides that way somewhere else — a
286
+ `SidebarTrigger` in its own header is the case it exists for (see the
287
+ `WithoutMenu` story, which renders one). **It is not warned about**, because the
288
+ component cannot see the app's header and a warning it can neither confirm nor
289
+ suppress is noise. Turning it off is a claim; the app has to honour it.
290
+
291
+ ### Accessibility
292
+
293
+ - `<nav aria-label={ariaLabel}>` — a named landmark, because this is the primary
294
+ way around the app.
295
+ - Cells that open the menu are `<button type="button">` with
296
+ `aria-expanded`, and deliberately **no** `aria-controls` (the drill panel's id
297
+ is Radix-generated and unreachable from the bar) and **no** `aria-haspopup`
298
+ (`"menu"` would promise arrow-key menu semantics the drill-down does not
299
+ implement).
300
+ - No `title` attributes: they do nothing on touch and some screen readers read
301
+ them after the name.
302
+ - Focus ring is `designTokens.focusRing` + `focus-visible:ring-inset` — the
303
+ cells touch each other, so a 3px outset ring would spill over the neighbouring
304
+ cell and read as if two cells were focused.
305
+ - Above 768px the dock is `display: none`, which also removes every cell from
306
+ the tab order. There is no JS breakpoint gate: `useIsMobile` returns `false` on
307
+ the first render, so a JS gate would omit the bar on SSR and first paint and
308
+ pop it in on hydration.
309
+
310
+ ### Motion
311
+
312
+ No entrance animation — the bar is chrome, present from first paint. The only
313
+ 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`.
317
+
318
+ ### Warnings (`console.warn`, deduplicated per message)
319
+
320
+ - more `items` than slots, naming the dropped titles;
321
+ - `items: []` together with `showMenu={false}`, where the bar renders `null`;
322
+ - two or more active items;
323
+ - a duplicate `url` + `title` pair (duplicate React keys make React reuse DOM
324
+ and state across cells);
325
+ - an unresolved drill target (logged by `AppSidebarMobile`).
326
+
327
+ ### Edge cases
328
+
329
+ - **`items: []`** renders the bar with the Menu cell only, spanning the full
330
+ width: "under 768px there is always a way to open the navigation" is the
331
+ design system's invariant, and `null` would strand a user whose permissions
332
+ resolve to zero primary destinations.
333
+ - **`items: []` with `showMenu={false}`** is the one configuration that renders
334
+ **`null`**, with a warning: there is nothing left to put in the bar, and the
335
+ alternative is a 9px strip of card and a border. It is warned about where the
336
+ opt-out itself is not, because the two halves of it are set in different
337
+ places — permission filtering and a layout prop — so an app arrives here by
338
+ accident rather than on purpose.
339
+ - **More items than slots**: the first 5 in order are kept (6 with
340
+ `showMenu={false}`), the rest dropped with a warning naming them. Ordering is
341
+ the app's job, so first-N is the only non-arbitrary cut.
342
+ - **An item without an icon** is a compile error, not a runtime fallback.
343
+
344
+ ---
345
+
346
+ ## Examples
347
+
348
+ ### App shell
349
+
350
+ ```tsx
351
+ import {
352
+ AppBottomNav,
353
+ AppSidebar,
354
+ SidebarInset,
355
+ SidebarProvider,
356
+ type BottomNavItem,
357
+ } from "laif-ds";
358
+ import Link from "next/link";
359
+
360
+ // Permission filtering, ordering and route matching all stay in the app.
361
+ const bottomNavItems: BottomNavItem[] = [
362
+ { title: "Dashboard", url: "/dashboard", iconName: "Home", isActive: true },
363
+ { title: "Clienti", url: "/clients", iconName: "Users" },
364
+ {
365
+ // Same title as the sidebar item, so the menu opens inside this level.
366
+ title: "Documenti",
367
+ url: "#",
368
+ iconName: "FileText",
369
+ subItems: [
370
+ { title: "Recenti", url: "/documents/recent" },
371
+ { title: "Condivisi", url: "/documents/shared" },
372
+ ],
373
+ },
374
+ { title: "Report", url: "/reports", iconName: "ChartLine" },
375
+ ];
376
+
377
+ export function Layout({ children }: { children: React.ReactNode }) {
378
+ return (
379
+ <SidebarProvider>
380
+ <AppSidebar navigation={navigation} linkComponent={Link} />
381
+ <SidebarInset>
382
+ {/* The page is the scroll container, not the document. */}
383
+ <div className="min-h-0 flex-1 overflow-auto p-4">{children}</div>
384
+ <AppBottomNav items={bottomNavItems} linkComponent={Link} />
385
+ </SidebarInset>
386
+ </SidebarProvider>
387
+ );
388
+ }
389
+ ```
390
+
391
+ ### Localised copy
392
+
393
+ ```tsx
394
+ <AppBottomNav
395
+ items={bottomNavItems}
396
+ menuLabel={t("nav.menu")}
397
+ ariaLabel={t("nav.mainNavigation")}
398
+ />
399
+ ```
400
+
401
+ ### Without the Menu cell
402
+
403
+ The header trigger is not optional here — it is what keeps the mobile panel
404
+ reachable once the Menu cell is gone.
405
+
406
+ ```tsx
407
+ <SidebarInset>
408
+ <header className="flex h-14 shrink-0 items-center gap-2 border-b px-2">
409
+ <SidebarTrigger />
410
+ </header>
411
+ <div className="min-h-0 flex-1 overflow-auto p-4">{children}</div>
412
+ {/* Six destinations: the slot the Menu cell would have taken. */}
413
+ <AppBottomNav items={sixItems} showMenu={false} linkComponent={Link} />
414
+ </SidebarInset>
415
+ ```
416
+
417
+ ---
418
+
419
+ ## Notes
420
+
421
+ - **Routing**: pass `linkComponent` (e.g. Next.js `Link`) to avoid full page
422
+ reloads; the design system never imports a router itself.
423
+ - **Icons**: `iconName` goes through the `Icon` component (lucide names).
424
+ - **Pairing**: the bar is a sibling of `AppSidebar`, not a replacement — the
425
+ full navigation is still reachable through the Menu cell, or through whatever
426
+ opens the mobile panel in its place under `showMenu={false}`.