@meetreeve/ui 0.2.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/README.md +134 -0
- package/dist/index.d.mts +328 -0
- package/dist/index.d.ts +328 -0
- package/dist/index.js +955 -0
- package/dist/index.mjs +910 -0
- package/dist/tokens/index.d.mts +31 -0
- package/dist/tokens/index.d.ts +31 -0
- package/dist/tokens/index.js +47 -0
- package/dist/tokens/index.mjs +21 -0
- package/dist/tokens.css +25 -0
- package/package.json +75 -0
package/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# @meetreeve/ui
|
|
2
|
+
|
|
3
|
+
Shared brand-adaptive UI primitives for MindFortress frontends: design tokens, a favicon→logo→monogram `BrandGlyph`, a `ResponsiveHeader` that keeps the search bar usable on mobile, and `AppSidebar` — a viewport-driven auto-collapse navigation ladder (DEV-4351).
|
|
4
|
+
|
|
5
|
+
`"use client"` components ship in one bundle (`.`); server-safe design tokens ship separately (`./tokens`, `./tokens.css`) so consumers that don't need the interactive pieces aren't forced into a client boundary.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @meetreeve/ui
|
|
11
|
+
# peers: react >=18, react-dom >=18, next >=14, lucide-react >=0.400.0, zod >=3.24.0
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## AppSidebar (DEV-4351)
|
|
15
|
+
|
|
16
|
+
The sidebar for apps with more modules than a short viewport can show at once. It pre-calculates every collapse step (a **ladder**) rather than computing a layout on the fly: as the container shrinks, whole module groups fold into a single hover/click "flyout" row — nothing is ever hidden, just one level deeper.
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
import { AppSidebar, type AppSidebarModuleDef } from "@meetreeve/ui";
|
|
20
|
+
import { usePathname } from "next/navigation";
|
|
21
|
+
import ladderConfig from "@/config/sidebar-ladder.json";
|
|
22
|
+
import { MODULE_REGISTRY, GROUP_ORDER } from "@/app/_lib/modules";
|
|
23
|
+
|
|
24
|
+
const modules: AppSidebarModuleDef[] = MODULE_REGISTRY.map((m) => ({
|
|
25
|
+
moduleId: m.name,
|
|
26
|
+
label: m.label,
|
|
27
|
+
icon: m.icon.displayName ?? m.icon.name, // lucide-react export name, e.g. "Mail"
|
|
28
|
+
group: m.group,
|
|
29
|
+
href: `/modules/${m.name}`,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
export function Sidebar() {
|
|
33
|
+
const activePath = usePathname();
|
|
34
|
+
return (
|
|
35
|
+
<AppSidebar
|
|
36
|
+
ladder={ladderConfig} // omit/undefined when no per-tenant config exists yet
|
|
37
|
+
modules={modules}
|
|
38
|
+
groupOrder={GROUP_ORDER}
|
|
39
|
+
activePath={activePath}
|
|
40
|
+
brand={{ label: "reeve-tenant", href: "/dashboard" }}
|
|
41
|
+
topItem={{ href: "/dashboard", label: "Overview", icon: "LayoutDashboard" }}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### The ladder data model
|
|
48
|
+
|
|
49
|
+
A `SidebarLadder` is a per-tenant JSON artifact (`src/config/sidebar-ladder.json` by convention) — an ordered list of **rungs**, most-expanded first. Each rung declares every row as either a bare `app` (one module) or a folded `group` (a parent row whose `children` open in a flyout). `rows` strictly decreases rung-over-rung; rung 0 is always the fully-expanded layout (no folded groups).
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
interface SidebarLadder {
|
|
53
|
+
version: number;
|
|
54
|
+
rungs: SidebarRung[]; // ordered, most-expanded first
|
|
55
|
+
}
|
|
56
|
+
interface SidebarRung {
|
|
57
|
+
rows: number; // === items.length
|
|
58
|
+
items: RungItem[]; // top-to-bottom render order
|
|
59
|
+
}
|
|
60
|
+
type RungItem =
|
|
61
|
+
| { type: "app"; moduleId: string }
|
|
62
|
+
| { type: "group"; id: string; label: string; icon: string; children: string[] };
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`icon` is a **lucide-react named export string** (e.g. `"Mail"`, `"Layers"`) — the ladder is plain JSON, so it can't carry a component reference; `AppSidebar` resolves the name to a component at render time (falling back to `Layers` if unresolved).
|
|
66
|
+
|
|
67
|
+
`zSidebarLadder` (exported from `@meetreeve/ui`) enforces every invariant at load: every module appears exactly once per rung (as its own row, or inside exactly one group — never both, never twice); the module set is identical across every rung (folding can't invent or lose a module); group `children` is non-empty; rung 0 has no folded groups.
|
|
68
|
+
|
|
69
|
+
### No config? No agent output? It still works.
|
|
70
|
+
|
|
71
|
+
If `ladder` is omitted, `null`, or fails `zSidebarLadder` validation, `AppSidebar` derives a deterministic ladder from `modules` + `groupOrder` (`deriveFallbackLadder`) — folding the **last** group in `groupOrder` first, walking backward. A bad agent-authored config can never brick a tenant's navigation.
|
|
72
|
+
|
|
73
|
+
A valid-but-stale ladder (modules added/removed since it was authored) is reconciled against the live `modules` list (`reconcileLadder`, exported alongside): an unknown `moduleId` is dropped; a `moduleId` the ladder doesn't reference anywhere is appended as a new top-level row on every rung.
|
|
74
|
+
|
|
75
|
+
### Rung selection (`useLadderRung`)
|
|
76
|
+
|
|
77
|
+
`AppSidebar` measures its nav container with `ResizeObserver` and snaps to the largest rung whose `rows` fits `floor(containerHeight / rowHeight)`. Stepping DOWN to a more-folded rung is immediate; stepping back UP requires an extra row of slack (`hysteresisRows`, default 1) so a resize sitting right at a boundary doesn't flap. The hook (and `AppSidebar`'s `ladderOptions` prop) exposes both `rowHeight` and `hysteresisRows` for tuning.
|
|
78
|
+
|
|
79
|
+
Server renders rung 0; the real rung is selected client-side in `useLayoutEffect`, before paint — no visible snap flash on load.
|
|
80
|
+
|
|
81
|
+
### Mobile
|
|
82
|
+
|
|
83
|
+
The ladder itself is desktop-only. Below the `md` breakpoint, `AppSidebar` renders a self-contained hamburger trigger + overlay drawer (no `shadcn`/`Sheet` dependency) showing rung 0 — the full, flat module list — scrollable.
|
|
84
|
+
|
|
85
|
+
### Folded groups (`Flyout`)
|
|
86
|
+
|
|
87
|
+
A folded group's row opens a horizontal flyout on hover (desktop) or click (keyboard/touch parity): `aria-expanded` + menu roles, focus moves to the first child on open, Esc closes and returns focus to the trigger. If the active route's module is inside a folded group, the group's own row shows the active state and the flyout highlights the specific child. No open/close transition under `prefers-reduced-motion`.
|
|
88
|
+
|
|
89
|
+
## ResponsiveHeader
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
import { ResponsiveHeader } from "@meetreeve/ui";
|
|
93
|
+
|
|
94
|
+
<ResponsiveHeader
|
|
95
|
+
brand={{ name: "Cadasense", favicon: "/favicon.ico", logo: null }}
|
|
96
|
+
search={<SearchInput />}
|
|
97
|
+
nav={[{ label: "Docs", href: "/docs" }]}
|
|
98
|
+
auth={<AuthMenu />}
|
|
99
|
+
themeToggle={<ThemeToggle />}
|
|
100
|
+
/>;
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Search is the primary slot — always inline, floored at a minimum width from `@meetreeve/ui/tokens`. Under width pressure everything ELSE collapses first: the wordmark drops to glyph-only, secondary controls fold into a hamburger disclosure below `md`.
|
|
104
|
+
|
|
105
|
+
## BrandGlyph
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
import { BrandGlyph } from "@meetreeve/ui";
|
|
109
|
+
|
|
110
|
+
<BrandGlyph brand={{ name: "Cadasense", favicon: "/favicon.ico", logo: null }} size="lg" />;
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Resolution order: favicon → logo → initials monogram.
|
|
114
|
+
|
|
115
|
+
## Tokens
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { MIN_SIZES, BREAKPOINTS } from "@meetreeve/ui/tokens";
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```css
|
|
122
|
+
@import "@meetreeve/ui/tokens.css"; /* Tailwind v4 @theme custom properties */
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
npm install # this package uses npm, NOT the monorepo's pnpm — see repo root CLAUDE.md
|
|
129
|
+
npm run typecheck
|
|
130
|
+
npm run test
|
|
131
|
+
npm run build
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
No lint script by design — the quality gate is `typecheck` + `test` + `build`.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode, RefObject } from 'react';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Brand glyph with a favicon-first cascade (ported/generalised from
|
|
7
|
+
* DEV-4164's `context/brand-glyph`).
|
|
8
|
+
*
|
|
9
|
+
* A tiny square favicon reads far better than a wide wordmark, so we try, in
|
|
10
|
+
* order:
|
|
11
|
+
* 1. `brand.favicon` — the site's own square icon,
|
|
12
|
+
* 2. `brand.logo` rendered `object-contain` (no squish),
|
|
13
|
+
* 3. a high-contrast letter monogram in a deterministic brand colour.
|
|
14
|
+
* Each image load error advances to the next candidate.
|
|
15
|
+
*/
|
|
16
|
+
interface Brand {
|
|
17
|
+
/** Display name — drives the wordmark and the monogram fallback. */
|
|
18
|
+
name: string;
|
|
19
|
+
/** Square favicon URL (first cascade step). Use `faviconUrl(domain)` to derive one. */
|
|
20
|
+
favicon?: string | null;
|
|
21
|
+
/** Wider brand logo URL (second cascade step, rendered `object-contain`). */
|
|
22
|
+
logo?: string | null;
|
|
23
|
+
/** Override the deterministic monogram background colour. */
|
|
24
|
+
color?: string | null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Favicon URL for a brand's canonical domain. Returns null unless `domain`
|
|
28
|
+
* actually looks like a hostname, so non-domain values (e.g. a plain company
|
|
29
|
+
* name) skip straight to the logo/monogram steps.
|
|
30
|
+
*/
|
|
31
|
+
declare function faviconUrl(domain: string | null | undefined): string | null;
|
|
32
|
+
interface BrandGlyphProps {
|
|
33
|
+
brand: Brand;
|
|
34
|
+
size?: "sm" | "lg";
|
|
35
|
+
/** Slightly lighter tint behind image sources (e.g. dropdown rows). */
|
|
36
|
+
subtle?: boolean;
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
declare function BrandGlyph({ brand, size, subtle, className }: BrandGlyphProps): react.JSX.Element;
|
|
40
|
+
|
|
41
|
+
interface HeaderNavItem {
|
|
42
|
+
label: string;
|
|
43
|
+
href?: string;
|
|
44
|
+
onSelect?: () => void;
|
|
45
|
+
}
|
|
46
|
+
interface ResponsiveHeaderProps {
|
|
47
|
+
/** Brand for the glyph + wordmark lockup. */
|
|
48
|
+
brand: Brand;
|
|
49
|
+
/**
|
|
50
|
+
* The PRIMARY slot. Always rendered inline with a guaranteed min-width — it
|
|
51
|
+
* never collapses to an icon. Give it a full search field.
|
|
52
|
+
*/
|
|
53
|
+
search: ReactNode;
|
|
54
|
+
/** Secondary nav links — inline on desktop, in the menu on mobile. */
|
|
55
|
+
nav?: HeaderNavItem[];
|
|
56
|
+
/** Auth control (avatar / sign-in) — secondary. */
|
|
57
|
+
auth?: ReactNode;
|
|
58
|
+
/** Theme toggle — secondary. */
|
|
59
|
+
themeToggle?: ReactNode;
|
|
60
|
+
/** Accessible label for the mobile menu button. */
|
|
61
|
+
menuLabel?: string;
|
|
62
|
+
className?: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Brand-adaptive header that fixes the "mobile header crushes the search bar"
|
|
66
|
+
* class of bug.
|
|
67
|
+
*
|
|
68
|
+
* - Search is the primary slot: always inline, floored at `MIN_SIZES.searchInput`.
|
|
69
|
+
* - Under width pressure the OTHER controls collapse first — below `md` the
|
|
70
|
+
* brand wordmark drops to glyph-only and the secondary controls fold into a
|
|
71
|
+
* hamburger disclosure. The search is never touched.
|
|
72
|
+
*
|
|
73
|
+
* A `"use client"` leaf: it's interactive (matchMedia + disclosure state).
|
|
74
|
+
*/
|
|
75
|
+
declare function ResponsiveHeader({ brand, search, nav, auth, themeToggle, menuLabel, className, }: ResponsiveHeaderProps): react.JSX.Element;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* SidebarLadder — the per-tenant JSON artifact (DEV-4351) that pre-calculates
|
|
79
|
+
* every viewport-driven collapse step for AppSidebar. Agent-authored at spawn
|
|
80
|
+
* (DEV-4352) or derived deterministically by `deriveFallbackLadder` (see
|
|
81
|
+
* `./fallback`) when no config exists. The client never invents a layout —
|
|
82
|
+
* `useLadderRung` only SELECTS a rung from this data.
|
|
83
|
+
*/
|
|
84
|
+
interface AppRungItem {
|
|
85
|
+
type: "app";
|
|
86
|
+
moduleId: string;
|
|
87
|
+
}
|
|
88
|
+
interface GroupRungItem {
|
|
89
|
+
type: "group";
|
|
90
|
+
/** Stable id for the folded group (also its React key). */
|
|
91
|
+
id: string;
|
|
92
|
+
label: string;
|
|
93
|
+
/** lucide-react named export, e.g. "Layers" — resolved to a component at render time. */
|
|
94
|
+
icon: string;
|
|
95
|
+
/** moduleIds folded into this group. Non-empty (zod-enforced). */
|
|
96
|
+
children: string[];
|
|
97
|
+
}
|
|
98
|
+
type RungItem = AppRungItem | GroupRungItem;
|
|
99
|
+
interface SidebarRung {
|
|
100
|
+
/** Row count for this rung. Always equals `items.length` (zod-enforced) —
|
|
101
|
+
* each item, whether a bare app row or a folded group, is exactly one row. */
|
|
102
|
+
rows: number;
|
|
103
|
+
/** Top-to-bottom render order. */
|
|
104
|
+
items: RungItem[];
|
|
105
|
+
}
|
|
106
|
+
interface SidebarLadder {
|
|
107
|
+
version: number;
|
|
108
|
+
/** Ordered rungs, most-expanded first. Rung 0 is always the fully-expanded
|
|
109
|
+
* layout (no folded groups); `rows` strictly decreases thereafter. */
|
|
110
|
+
rungs: SidebarRung[];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* SidebarLadder zod schema — enforces every invariant from the DEV-4351 spec:
|
|
114
|
+
* - rung 0 is fully expanded (no folded groups — every item is an app row).
|
|
115
|
+
* - `rows` strictly decreases rung-over-rung.
|
|
116
|
+
* - a rung's declared `rows` matches its actual item count.
|
|
117
|
+
* - group `children` is non-empty (covered at the item level above).
|
|
118
|
+
* - every module appears EXACTLY ONCE per rung (as a row, or inside exactly
|
|
119
|
+
* one group's children — never both, never twice).
|
|
120
|
+
* - the set of modules is identical across every rung — folding can never
|
|
121
|
+
* invent or lose a module.
|
|
122
|
+
*/
|
|
123
|
+
declare const zSidebarLadder: z.ZodObject<{
|
|
124
|
+
version: z.ZodNumber;
|
|
125
|
+
rungs: z.ZodArray<z.ZodObject<{
|
|
126
|
+
rows: z.ZodNumber;
|
|
127
|
+
items: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
128
|
+
type: z.ZodLiteral<"app">;
|
|
129
|
+
moduleId: z.ZodString;
|
|
130
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
131
|
+
type: z.ZodLiteral<"group">;
|
|
132
|
+
id: z.ZodString;
|
|
133
|
+
label: z.ZodString;
|
|
134
|
+
icon: z.ZodString;
|
|
135
|
+
children: z.ZodArray<z.ZodString>;
|
|
136
|
+
}, z.core.$strip>], "type">>;
|
|
137
|
+
}, z.core.$strip>>;
|
|
138
|
+
}, z.core.$strip>;
|
|
139
|
+
type SidebarLadderParsed = z.infer<typeof zSidebarLadder>;
|
|
140
|
+
|
|
141
|
+
interface UseLadderRungOptions {
|
|
142
|
+
/** Approximate rendered height of one nav row, px. */
|
|
143
|
+
rowHeight?: number;
|
|
144
|
+
/** Extra rows of slack required before stepping back UP to a less-folded
|
|
145
|
+
* rung, so a resize sitting right at a boundary doesn't flap. */
|
|
146
|
+
hysteresisRows?: number;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Measures a nav container via ResizeObserver and selects the largest ladder
|
|
150
|
+
* rung (rungs are ordered most-expanded first, `rows` strictly decreasing)
|
|
151
|
+
* whose row requirement fits `rowsAvailable = floor(height / rowHeight)`.
|
|
152
|
+
*
|
|
153
|
+
* SSR-safe: the initial state is always rung 0 (fully expanded) — the
|
|
154
|
+
* measurement only runs client-side inside `useLayoutEffect`, which fires
|
|
155
|
+
* before the browser paints, so there's no visible rung-snap flash on first
|
|
156
|
+
* load (server markup and the first client paint agree).
|
|
157
|
+
*
|
|
158
|
+
* Hysteresis: stepping DOWN to a more-folded rung happens the instant it no
|
|
159
|
+
* longer fits. Stepping back UP to a less-folded rung requires an EXTRA
|
|
160
|
+
* `hysteresisRows` of slack beyond that rung's own row requirement, so a
|
|
161
|
+
* resize hovering right at a boundary doesn't flip back and forth.
|
|
162
|
+
*/
|
|
163
|
+
declare function useLadderRung(ladder: SidebarLadder, containerRef: RefObject<HTMLElement | null>, options?: UseLadderRungOptions): SidebarRung;
|
|
164
|
+
|
|
165
|
+
interface AppSidebarModuleDef {
|
|
166
|
+
moduleId: string;
|
|
167
|
+
label: string;
|
|
168
|
+
/** lucide-react named export, e.g. "Mail". */
|
|
169
|
+
icon: string;
|
|
170
|
+
group: string;
|
|
171
|
+
href: string;
|
|
172
|
+
}
|
|
173
|
+
interface AppSidebarBrand {
|
|
174
|
+
label: string;
|
|
175
|
+
href: string;
|
|
176
|
+
}
|
|
177
|
+
interface AppSidebarTopItem {
|
|
178
|
+
href: string;
|
|
179
|
+
label: string;
|
|
180
|
+
/** lucide-react named export. */
|
|
181
|
+
icon: string;
|
|
182
|
+
}
|
|
183
|
+
interface AppSidebarProps {
|
|
184
|
+
/** Raw, parsed (but not yet zod-validated) sidebar-ladder.json — or
|
|
185
|
+
* `undefined`/`null` when no per-tenant config exists. A ladder that fails
|
|
186
|
+
* validation degrades to the deterministic fallback, same as no config. */
|
|
187
|
+
ladder?: unknown;
|
|
188
|
+
/** The full module catalog — resolves each row's label/icon/href and
|
|
189
|
+
* drives the fallback derivation when no ladder config is supplied. */
|
|
190
|
+
modules: AppSidebarModuleDef[];
|
|
191
|
+
/** Group fold order — the LAST group folds first (fallback derivation +
|
|
192
|
+
* drift reconciliation both use this). */
|
|
193
|
+
groupOrder: string[];
|
|
194
|
+
/** Current pathname — drives the active-route highlight. */
|
|
195
|
+
activePath: string;
|
|
196
|
+
/** Brand block rendered above the ladder. */
|
|
197
|
+
brand?: AppSidebarBrand;
|
|
198
|
+
/** A fixed nav row above the ladder, unaffected by rung folding (e.g. an "Overview" link). */
|
|
199
|
+
topItem?: AppSidebarTopItem;
|
|
200
|
+
/** Accessible label for the mobile hamburger button. */
|
|
201
|
+
menuLabel?: string;
|
|
202
|
+
ladderOptions?: UseLadderRungOptions;
|
|
203
|
+
/** Merged onto the desktop `<aside>` itself — there is no extra wrapping
|
|
204
|
+
* element (see the render note below on why that matters for height). */
|
|
205
|
+
className?: string;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* AppSidebar (DEV-4351) — the viewport-driven auto-collapse ladder sidebar.
|
|
209
|
+
*
|
|
210
|
+
* Desktop: measures its nav container and snaps to the largest ladder rung
|
|
211
|
+
* that fits (see `useLadderRung`); folded groups render as a `Flyout`.
|
|
212
|
+
* Mobile: the ladder is desktop-only — a self-contained hamburger + overlay
|
|
213
|
+
* drawer renders rung 0 (fully expanded), scrollable. No shadcn/Sheet
|
|
214
|
+
* dependency, matching ResponsiveHeader's existing disclosure pattern in
|
|
215
|
+
* this same package.
|
|
216
|
+
*/
|
|
217
|
+
declare function AppSidebar({ ladder, modules, groupOrder, activePath, brand, topItem, menuLabel, ladderOptions, className, }: AppSidebarProps): react.JSX.Element;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Minimal shape AppSidebar's fallback derivation needs from a module registry
|
|
221
|
+
* entry — deliberately decoupled from any one app's full module-def shape
|
|
222
|
+
* (label/icon/description live in the consumer's own registry and are
|
|
223
|
+
* resolved at render time by AppSidebar, not baked into the ladder here).
|
|
224
|
+
*/
|
|
225
|
+
interface FallbackModuleDef {
|
|
226
|
+
moduleId: string;
|
|
227
|
+
group: string;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Deterministic ladder derivation for when `sidebar-ladder.json` is absent or
|
|
231
|
+
* fails zod validation — a bad agent output can never brick a tenant's nav.
|
|
232
|
+
*
|
|
233
|
+
* Rung 0 is the fully-expanded layout (one row per module, registry order
|
|
234
|
+
* preserved). Each subsequent rung folds one more group into a single parent
|
|
235
|
+
* row, walking `groupOrder` BACK TO FRONT — the LAST group folds first (e.g.
|
|
236
|
+
* for Engage/Data/Intelligence/Commerce/Operations, Operations folds first,
|
|
237
|
+
* then Commerce, then Intelligence, then Data, then Engage last).
|
|
238
|
+
*
|
|
239
|
+
* A group with fewer than 2 members is folded into the accumulated set
|
|
240
|
+
* without emitting its own rung — collapsing a 1-member group nets zero row
|
|
241
|
+
* reduction (one app row becomes one group row), which would violate the
|
|
242
|
+
* ladder's strictly-decreasing-rows invariant if emitted as its own step. It
|
|
243
|
+
* still gets folded — just bundled into the rung of whichever fold next
|
|
244
|
+
* produces a real row-count decrease.
|
|
245
|
+
*/
|
|
246
|
+
declare function deriveFallbackLadder(registry: FallbackModuleDef[], groupOrder: string[]): SidebarLadder;
|
|
247
|
+
/**
|
|
248
|
+
* Reconciles a (possibly stale) ladder against the live registry — mirrors
|
|
249
|
+
* `use-sidebar-config.ts`'s `reconcileConfig()` (unknown ids dropped, missing
|
|
250
|
+
* ids appended). Ladder/registry drift can never brick or hide a module:
|
|
251
|
+
* - a moduleId the ladder references but the registry no longer has is
|
|
252
|
+
* dropped from every rung (and from any group's children).
|
|
253
|
+
* - a moduleId the registry has but the ladder doesn't reference anywhere in
|
|
254
|
+
* rung 0 is appended as a new top-level row on EVERY rung.
|
|
255
|
+
*/
|
|
256
|
+
declare function reconcileLadder(ladder: SidebarLadder, registry: FallbackModuleDef[]): SidebarLadder;
|
|
257
|
+
/**
|
|
258
|
+
* The single entry point AppSidebar uses to resolve the ladder it will
|
|
259
|
+
* render: validate the raw (parsed-JSON) ladder if one was supplied,
|
|
260
|
+
* reconcile it against the live registry, and fall back to a fresh
|
|
261
|
+
* deterministic derivation on absence OR validation failure.
|
|
262
|
+
*
|
|
263
|
+
* The reconciled result is re-validated against `zSidebarLadder` before it's
|
|
264
|
+
* returned (deep-review finding, DEV-4351): `reconcileLadder`'s per-rung
|
|
265
|
+
* filtering can — on a specific drift shape (dropping enough of a folded
|
|
266
|
+
* group's members that a rung's row count converges with its neighbor's) —
|
|
267
|
+
* produce a ladder that no longer satisfies the strictly-decreasing-rows
|
|
268
|
+
* invariant, which would silently orphan a rung in `useLadderRung`'s
|
|
269
|
+
* first-fit scan. A bad reconciliation degrades to the same deterministic
|
|
270
|
+
* fallback as a bad or missing config — it never ships invalid output.
|
|
271
|
+
*/
|
|
272
|
+
declare function resolveLadder(raw: unknown, registry: FallbackModuleDef[], groupOrder: string[]): SidebarLadder;
|
|
273
|
+
|
|
274
|
+
interface FlyoutChild {
|
|
275
|
+
moduleId: string;
|
|
276
|
+
label: string;
|
|
277
|
+
href: string;
|
|
278
|
+
active: boolean;
|
|
279
|
+
}
|
|
280
|
+
interface FlyoutProps {
|
|
281
|
+
label: string;
|
|
282
|
+
/** lucide-react named export, e.g. "Layers". */
|
|
283
|
+
icon: string;
|
|
284
|
+
/** True when the active route's module is folded inside this group — the
|
|
285
|
+
* PARENT row shows the active state; the child itself is highlighted via
|
|
286
|
+
* its own `active` flag. */
|
|
287
|
+
active: boolean;
|
|
288
|
+
children: FlyoutChild[];
|
|
289
|
+
className?: string;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* The "offshoot" for a folded sidebar group (DEV-4351): hover opens a
|
|
293
|
+
* horizontal flyout listing the group's children on desktop; click toggles it
|
|
294
|
+
* for keyboard/touch parity. `aria-expanded`, focus moves into the panel on
|
|
295
|
+
* a CLICK/keyboard open (never on a mere hover — CR CLI finding), Esc closes
|
|
296
|
+
* and returns focus to the trigger — via the shared `useDismissableDisclosure`
|
|
297
|
+
* (mirrors ResponsiveHeader's original mobile-disclosure a11y pattern in this
|
|
298
|
+
* same package).
|
|
299
|
+
*
|
|
300
|
+
* Plain links, not `menu`/`menuitem` roles (CR CLI finding): those ARIA roles
|
|
301
|
+
* promise a roving-tabindex, arrow-key/Home/End keyboard contract this
|
|
302
|
+
* disclosure doesn't implement — misusing them would announce a keyboard
|
|
303
|
+
* model to assistive tech that isn't actually there. Native `<a>` Tab
|
|
304
|
+
* navigation is fully accessible on its own without that promise.
|
|
305
|
+
*
|
|
306
|
+
* Hover and click are independent open sources (CR CLI finding): they used
|
|
307
|
+
* to share one boolean, so a mouse user hovering (which opened it) and then
|
|
308
|
+
* clicking the row — the normal way to interact with it — immediately
|
|
309
|
+
* toggled it closed again. `hoverOpen`/`clickOpen` are now tracked
|
|
310
|
+
* separately; the panel shows while EITHER is true, and only a click/
|
|
311
|
+
* keyboard-driven open moves focus into it.
|
|
312
|
+
*
|
|
313
|
+
* The panel is positioned `position: fixed` at the trigger's actual screen
|
|
314
|
+
* coordinates, not CSS-anchored (`absolute; left: 100%`) to its nearest
|
|
315
|
+
* positioned ancestor (outcome-eval finding, live browser check): that
|
|
316
|
+
* ancestor sits inside AppSidebar's `<nav>`, which has `overflow-y: auto` —
|
|
317
|
+
* a CSS quirk auto-promotes the OTHER axis to `overflow-x: auto` too, so an
|
|
318
|
+
* `absolute`-positioned panel extending past the narrow sidebar's edge
|
|
319
|
+
* inflated the nav's own scrollWidth, and the browser auto-scrolled the nav
|
|
320
|
+
* horizontally to reveal it — hiding every OTHER nav item off-screen.
|
|
321
|
+
* `position: fixed` doesn't contribute to any scrolling ancestor's
|
|
322
|
+
* scrollable overflow at all, so it escapes the problem entirely rather
|
|
323
|
+
* than working around it. jsdom never computes real scrollable-overflow
|
|
324
|
+
* geometry, so no unit test caught this — only an actual browser render did.
|
|
325
|
+
*/
|
|
326
|
+
declare function Flyout({ label, icon, active, children, className }: FlyoutProps): react.JSX.Element;
|
|
327
|
+
|
|
328
|
+
export { type AppRungItem, AppSidebar, type AppSidebarBrand, type AppSidebarModuleDef, type AppSidebarProps, type AppSidebarTopItem, type Brand, BrandGlyph, type BrandGlyphProps, type FallbackModuleDef, Flyout, type FlyoutChild, type FlyoutProps, type GroupRungItem, type HeaderNavItem, ResponsiveHeader, type ResponsiveHeaderProps, type RungItem, type SidebarLadder, type SidebarLadderParsed, type SidebarRung, type UseLadderRungOptions, deriveFallbackLadder, faviconUrl, reconcileLadder, resolveLadder, useLadderRung, zSidebarLadder };
|