@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.
@@ -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 };