@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/dist/index.js ADDED
@@ -0,0 +1,955 @@
1
+ "use client";
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __export = (target, all) => {
10
+ for (var name in all)
11
+ __defProp(target, name, { get: all[name], enumerable: true });
12
+ };
13
+ var __copyProps = (to, from, except, desc) => {
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (let key of __getOwnPropNames(from))
16
+ if (!__hasOwnProp.call(to, key) && key !== except)
17
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
18
+ }
19
+ return to;
20
+ };
21
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
22
+ // If the importer is in node compatibility mode or this is not an ESM
23
+ // file that has been converted to a CommonJS file using a Babel-
24
+ // compatible transform (i.e. "__esModule" has not been set), then set
25
+ // "default" to the CommonJS "module.exports" for node compatibility.
26
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
27
+ mod
28
+ ));
29
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
30
+
31
+ // src/index.ts
32
+ var src_exports = {};
33
+ __export(src_exports, {
34
+ AppSidebar: () => AppSidebar,
35
+ BrandGlyph: () => BrandGlyph,
36
+ Flyout: () => Flyout,
37
+ ResponsiveHeader: () => ResponsiveHeader,
38
+ deriveFallbackLadder: () => deriveFallbackLadder,
39
+ faviconUrl: () => faviconUrl,
40
+ reconcileLadder: () => reconcileLadder,
41
+ resolveLadder: () => resolveLadder,
42
+ useLadderRung: () => useLadderRung,
43
+ zSidebarLadder: () => zSidebarLadder
44
+ });
45
+ module.exports = __toCommonJS(src_exports);
46
+
47
+ // src/brand-glyph.tsx
48
+ var import_image = __toESM(require("next/image"));
49
+ var import_react = require("react");
50
+
51
+ // src/lib/cn.ts
52
+ function cn(...inputs) {
53
+ const out = [];
54
+ for (const input of inputs) {
55
+ if (!input) continue;
56
+ if (typeof input === "string" || typeof input === "number") {
57
+ out.push(String(input));
58
+ } else if (Array.isArray(input)) {
59
+ const nested = cn(...input);
60
+ if (nested) out.push(nested);
61
+ } else if (typeof input === "object") {
62
+ for (const [key, value] of Object.entries(input)) {
63
+ if (value) out.push(key);
64
+ }
65
+ }
66
+ }
67
+ return out.join(" ");
68
+ }
69
+
70
+ // src/lib/initials.ts
71
+ function initials(name) {
72
+ if (!name) return "\xB7\xB7";
73
+ const parts = name.trim().split(/\s+/).slice(0, 2);
74
+ return parts.map((p) => p[0]?.toUpperCase() ?? "").join("") || "\xB7\xB7";
75
+ }
76
+
77
+ // src/lib/monogram.ts
78
+ var MONOGRAM_COLORS = [
79
+ "#7c3aed",
80
+ // violet
81
+ "#2563eb",
82
+ // blue
83
+ "#0e7490",
84
+ // cyan
85
+ "#047857",
86
+ // emerald
87
+ "#c2410c",
88
+ // orange
89
+ "#dc2626",
90
+ // red
91
+ "#db2777",
92
+ // pink
93
+ "#4f46e5"
94
+ // indigo
95
+ ];
96
+ function monogramColor(seed) {
97
+ let h = 0;
98
+ for (let i = 0; i < seed.length; i++) h = h * 31 + seed.charCodeAt(i) | 0;
99
+ return MONOGRAM_COLORS[Math.abs(h) % MONOGRAM_COLORS.length];
100
+ }
101
+
102
+ // src/brand-glyph.tsx
103
+ var import_jsx_runtime = require("react/jsx-runtime");
104
+ function faviconUrl(domain) {
105
+ if (!domain) return null;
106
+ const host = domain.trim().toLowerCase();
107
+ if (!host.includes(".") || /\s/.test(host)) return null;
108
+ return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=128`;
109
+ }
110
+ function BrandGlyph({ brand, size = "sm", subtle = false, className }) {
111
+ const box = size === "lg" ? "h-6 w-6" : "h-5 w-5";
112
+ const img = size === "lg" ? "h-4 w-4" : "h-3.5 w-3.5";
113
+ const dim = size === "lg" ? 16 : 14;
114
+ const candidates = [];
115
+ if (brand.favicon) candidates.push(brand.favicon);
116
+ if (brand.logo) candidates.push(brand.logo);
117
+ const [failed, setFailed] = (0, import_react.useState)(() => /* @__PURE__ */ new Set());
118
+ const src = candidates.find((u) => !failed.has(u)) ?? null;
119
+ if (src) {
120
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
121
+ "span",
122
+ {
123
+ className: cn(
124
+ "flex shrink-0 items-center justify-center overflow-hidden rounded",
125
+ // Tint needs a dark-mode counterpart or it vanishes on dark backgrounds.
126
+ subtle ? "bg-black/5 dark:bg-white/10" : "bg-black/10 dark:bg-white/15",
127
+ box,
128
+ className
129
+ ),
130
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
131
+ import_image.default,
132
+ {
133
+ src,
134
+ alt: "",
135
+ width: dim,
136
+ height: dim,
137
+ "data-testid": "brand-image",
138
+ unoptimized: true,
139
+ onError: () => setFailed((prev) => new Set(prev).add(src)),
140
+ className: cn(img, "rounded-sm object-contain")
141
+ }
142
+ )
143
+ }
144
+ );
145
+ }
146
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
147
+ "span",
148
+ {
149
+ "aria-hidden": true,
150
+ "data-testid": "brand-monogram",
151
+ className: cn(
152
+ "flex shrink-0 select-none items-center justify-center rounded font-semibold uppercase text-white",
153
+ size === "lg" ? "text-[11px]" : "text-[9px]",
154
+ box,
155
+ className
156
+ ),
157
+ style: { backgroundColor: brand.color || monogramColor(brand.name || "?") },
158
+ children: initials(brand.name)
159
+ }
160
+ );
161
+ }
162
+
163
+ // src/responsive-header.tsx
164
+ var import_react3 = require("react");
165
+ var import_lucide_react = require("lucide-react");
166
+
167
+ // src/lib/use-media-query.ts
168
+ var import_react2 = require("react");
169
+ function useMediaQuery(query) {
170
+ const subscribe = (0, import_react2.useCallback)(
171
+ (onChange) => {
172
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
173
+ return () => {
174
+ };
175
+ }
176
+ const mql = window.matchMedia(query);
177
+ mql.addEventListener("change", onChange);
178
+ return () => mql.removeEventListener("change", onChange);
179
+ },
180
+ [query]
181
+ );
182
+ const getSnapshot = () => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(query).matches : false;
183
+ return (0, import_react2.useSyncExternalStore)(subscribe, getSnapshot, () => false);
184
+ }
185
+
186
+ // src/tokens.ts
187
+ var MIN_SIZES = {
188
+ /** WCAG 2.5.5 minimum interactive target, px. Applies to BOTH width and height. */
189
+ touchTarget: 44,
190
+ /** Floor width for the always-inline search field on mobile, px. */
191
+ searchInput: 200,
192
+ /** Generic minimum width/height for a secondary control (button, select), px. */
193
+ control: 44,
194
+ /** Max width of the brand wordmark before it truncates, px. */
195
+ wordmarkMaxWidth: 180
196
+ };
197
+ var BREAKPOINTS = {
198
+ /** Tailwind `sm`. */
199
+ sm: 640,
200
+ /** Tailwind `md` — the header switches to its full desktop layout at/above this. */
201
+ md: 768
202
+ };
203
+
204
+ // src/responsive-header.tsx
205
+ var import_jsx_runtime2 = require("react/jsx-runtime");
206
+ function MenuIcon({ open }) {
207
+ return open ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react.X, { className: "h-5 w-5", "aria-hidden": "true" }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react.Menu, { className: "h-5 w-5", "aria-hidden": "true" });
208
+ }
209
+ function SecondaryControls({
210
+ nav,
211
+ auth,
212
+ themeToggle
213
+ }) {
214
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
215
+ nav?.map(
216
+ (item) => (
217
+ // A link navigates; an action-only item (no href) is a button — never an
218
+ // <a href="#"> that scroll-jumps and pushes a history entry.
219
+ item.href ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
220
+ "a",
221
+ {
222
+ href: item.href,
223
+ onClick: item.onSelect,
224
+ className: "text-sm font-medium text-foreground/80 hover:text-foreground",
225
+ children: item.label
226
+ },
227
+ item.label
228
+ ) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
229
+ "button",
230
+ {
231
+ type: "button",
232
+ onClick: item.onSelect,
233
+ className: "text-left text-sm font-medium text-foreground/80 hover:text-foreground",
234
+ children: item.label
235
+ },
236
+ item.label
237
+ )
238
+ )
239
+ ),
240
+ themeToggle,
241
+ auth
242
+ ] });
243
+ }
244
+ function ResponsiveHeader({
245
+ brand,
246
+ search,
247
+ nav,
248
+ auth,
249
+ themeToggle,
250
+ menuLabel = "Menu",
251
+ className
252
+ }) {
253
+ const isDesktop = useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
254
+ const [open, setOpen] = (0, import_react3.useState)(false);
255
+ const panelId = (0, import_react3.useId)();
256
+ const buttonRef = (0, import_react3.useRef)(null);
257
+ const panelRef = (0, import_react3.useRef)(null);
258
+ (0, import_react3.useEffect)(() => {
259
+ if (isDesktop) setOpen(false);
260
+ }, [isDesktop]);
261
+ (0, import_react3.useEffect)(() => {
262
+ if (!open) return;
263
+ const onKey = (e) => {
264
+ if (e.key === "Escape") {
265
+ setOpen(false);
266
+ buttonRef.current?.focus();
267
+ }
268
+ };
269
+ const onPointerDown = (e) => {
270
+ const target = e.target;
271
+ if (!panelRef.current?.contains(target) && !buttonRef.current?.contains(target)) {
272
+ setOpen(false);
273
+ }
274
+ };
275
+ document.addEventListener("keydown", onKey);
276
+ document.addEventListener("pointerdown", onPointerDown);
277
+ return () => {
278
+ document.removeEventListener("keydown", onKey);
279
+ document.removeEventListener("pointerdown", onPointerDown);
280
+ };
281
+ }, [open]);
282
+ const hasSecondary = Boolean(nav && nav.length > 0 || auth || themeToggle);
283
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
284
+ "header",
285
+ {
286
+ "data-testid": "responsive-header",
287
+ className: cn(
288
+ "flex w-full items-center gap-3 border-b border-border bg-background px-4 py-2",
289
+ className
290
+ ),
291
+ children: [
292
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { "data-testid": "header-brand", className: "flex shrink-0 items-center gap-2", children: [
293
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(BrandGlyph, { brand, size: "lg" }),
294
+ isDesktop && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
295
+ "span",
296
+ {
297
+ "data-testid": "header-wordmark",
298
+ className: "truncate text-sm font-semibold text-foreground",
299
+ style: { maxWidth: MIN_SIZES.wordmarkMaxWidth },
300
+ children: brand.name
301
+ }
302
+ )
303
+ ] }),
304
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
305
+ "div",
306
+ {
307
+ "data-testid": "header-search",
308
+ className: "flex flex-1 items-center",
309
+ style: { minWidth: MIN_SIZES.searchInput },
310
+ children: search
311
+ }
312
+ ),
313
+ hasSecondary && (isDesktop ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("nav", { "data-testid": "header-actions", className: "flex shrink-0 items-center gap-3", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(SecondaryControls, { nav, auth, themeToggle }) }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "relative shrink-0", children: [
314
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
315
+ "button",
316
+ {
317
+ ref: buttonRef,
318
+ type: "button",
319
+ "data-testid": "header-menu-button",
320
+ "aria-label": menuLabel,
321
+ "aria-expanded": open,
322
+ "aria-controls": panelId,
323
+ onClick: () => setOpen((v) => !v),
324
+ className: "flex items-center justify-center rounded-md border border-border text-foreground",
325
+ style: { minWidth: MIN_SIZES.touchTarget, minHeight: MIN_SIZES.touchTarget },
326
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(MenuIcon, { open })
327
+ }
328
+ ),
329
+ open && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
330
+ "div",
331
+ {
332
+ ref: panelRef,
333
+ id: panelId,
334
+ "data-testid": "header-menu-panel",
335
+ className: "absolute right-0 top-full z-50 mt-2 flex min-w-[12rem] flex-col gap-3 rounded-md border border-border bg-background p-3 shadow-lg",
336
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(SecondaryControls, { nav, auth, themeToggle })
337
+ }
338
+ )
339
+ ] }))
340
+ ]
341
+ }
342
+ );
343
+ }
344
+
345
+ // src/app-sidebar.tsx
346
+ var import_react7 = require("react");
347
+ var import_lucide_react2 = require("lucide-react");
348
+
349
+ // src/lib/resolve-icon.ts
350
+ var LucideIcons = __toESM(require("lucide-react"));
351
+ var FALLBACK_ICON_NAME = "Layers";
352
+ var REACT_FORWARD_REF = /* @__PURE__ */ Symbol.for("react.forward_ref");
353
+ var NON_ICON_EXPORTS = /* @__PURE__ */ new Set(["Icon"]);
354
+ function isIconComponent(icons, name) {
355
+ if (NON_ICON_EXPORTS.has(name)) return false;
356
+ if (!Object.prototype.hasOwnProperty.call(icons, name)) return false;
357
+ const value = icons[name];
358
+ return typeof value === "object" && value !== null && value.$$typeof === REACT_FORWARD_REF;
359
+ }
360
+ function resolveIcon(name) {
361
+ const icons = LucideIcons;
362
+ const candidate = isIconComponent(icons, name) ? icons[name] : void 0;
363
+ return candidate ?? icons[FALLBACK_ICON_NAME];
364
+ }
365
+
366
+ // src/lib/use-dismissable-disclosure.ts
367
+ var import_react4 = require("react");
368
+ function useDismissableDisclosure({
369
+ open,
370
+ onClose,
371
+ panelRef,
372
+ triggerRef,
373
+ initialFocusRef
374
+ }) {
375
+ (0, import_react4.useEffect)(() => {
376
+ if (open) initialFocusRef?.current?.focus();
377
+ }, [open]);
378
+ (0, import_react4.useEffect)(() => {
379
+ if (!open) return;
380
+ const onKey = (e) => {
381
+ if (e.key === "Escape") {
382
+ onClose();
383
+ triggerRef.current?.focus();
384
+ }
385
+ };
386
+ const onPointerDown = (e) => {
387
+ const target = e.target;
388
+ if (!panelRef.current?.contains(target) && !triggerRef.current?.contains(target)) {
389
+ onClose();
390
+ }
391
+ };
392
+ document.addEventListener("keydown", onKey);
393
+ document.addEventListener("pointerdown", onPointerDown);
394
+ return () => {
395
+ document.removeEventListener("keydown", onKey);
396
+ document.removeEventListener("pointerdown", onPointerDown);
397
+ };
398
+ }, [open, onClose, panelRef, triggerRef]);
399
+ }
400
+
401
+ // src/ladder/types.ts
402
+ var import_zod = require("zod");
403
+ var zAppRungItem = import_zod.z.object({
404
+ type: import_zod.z.literal("app"),
405
+ moduleId: import_zod.z.string().min(1)
406
+ });
407
+ var zGroupRungItem = import_zod.z.object({
408
+ type: import_zod.z.literal("group"),
409
+ id: import_zod.z.string().min(1),
410
+ label: import_zod.z.string().min(1),
411
+ icon: import_zod.z.string().min(1),
412
+ children: import_zod.z.array(import_zod.z.string().min(1)).min(1)
413
+ });
414
+ var zRungItem = import_zod.z.discriminatedUnion("type", [zAppRungItem, zGroupRungItem]);
415
+ var zSidebarRungShape = import_zod.z.object({
416
+ rows: import_zod.z.number().int().positive(),
417
+ items: import_zod.z.array(zRungItem).min(1)
418
+ });
419
+ function moduleIdsOf(rung) {
420
+ const ids = [];
421
+ for (const item of rung.items) {
422
+ if (item.type === "app") {
423
+ ids.push(item.moduleId);
424
+ } else {
425
+ ids.push(...item.children);
426
+ }
427
+ }
428
+ return ids;
429
+ }
430
+ var zSidebarLadder = import_zod.z.object({
431
+ version: import_zod.z.number().int().nonnegative(),
432
+ rungs: import_zod.z.array(zSidebarRungShape).min(1)
433
+ }).superRefine((ladder, ctx) => {
434
+ const { rungs } = ladder;
435
+ if (rungs.length === 0) return;
436
+ const rung0HasGroup = rungs[0].items.some((item) => item.type === "group");
437
+ if (rung0HasGroup) {
438
+ ctx.addIssue({
439
+ code: import_zod.z.ZodIssueCode.custom,
440
+ message: "rung 0 must be fully expanded \u2014 no folded groups allowed",
441
+ path: ["rungs", 0, "items"]
442
+ });
443
+ }
444
+ let baselineModuleSet = null;
445
+ rungs.forEach((rung, i) => {
446
+ if (rung.rows !== rung.items.length) {
447
+ ctx.addIssue({
448
+ code: import_zod.z.ZodIssueCode.custom,
449
+ message: `rung ${i}: rows (${rung.rows}) must equal its item count (${rung.items.length})`,
450
+ path: ["rungs", i, "rows"]
451
+ });
452
+ }
453
+ if (i > 0 && rung.rows >= rungs[i - 1].rows) {
454
+ ctx.addIssue({
455
+ code: import_zod.z.ZodIssueCode.custom,
456
+ message: `rung ${i}: rows (${rung.rows}) must be strictly less than rung ${i - 1}'s (${rungs[i - 1].rows})`,
457
+ path: ["rungs", i, "rows"]
458
+ });
459
+ }
460
+ const ids = moduleIdsOf(rung);
461
+ const seen = /* @__PURE__ */ new Set();
462
+ for (const id of ids) {
463
+ if (seen.has(id)) {
464
+ ctx.addIssue({
465
+ code: import_zod.z.ZodIssueCode.custom,
466
+ message: `rung ${i}: module "${id}" appears more than once (as two rows, or as a row AND inside a group)`,
467
+ path: ["rungs", i]
468
+ });
469
+ }
470
+ seen.add(id);
471
+ }
472
+ const groupIds = /* @__PURE__ */ new Set();
473
+ for (const item of rung.items) {
474
+ if (item.type !== "group") continue;
475
+ if (groupIds.has(item.id)) {
476
+ ctx.addIssue({
477
+ code: import_zod.z.ZodIssueCode.custom,
478
+ message: `rung ${i}: group id "${item.id}" appears more than once`,
479
+ path: ["rungs", i]
480
+ });
481
+ }
482
+ groupIds.add(item.id);
483
+ }
484
+ if (baselineModuleSet === null) {
485
+ baselineModuleSet = seen;
486
+ } else {
487
+ const missing = [...baselineModuleSet].filter((id) => !seen.has(id));
488
+ const extra = [...seen].filter((id) => !baselineModuleSet.has(id));
489
+ if (missing.length > 0 || extra.length > 0) {
490
+ ctx.addIssue({
491
+ code: import_zod.z.ZodIssueCode.custom,
492
+ message: `rung ${i}: module set differs from rung 0 (missing: ${missing.join(", ") || "none"}; extra: ${extra.join(", ") || "none"})`,
493
+ path: ["rungs", i]
494
+ });
495
+ }
496
+ }
497
+ });
498
+ });
499
+
500
+ // src/ladder/fallback.ts
501
+ var FALLBACK_GROUP_ICON = "Layers";
502
+ function deriveFallbackLadder(registry, groupOrder) {
503
+ const rung0Items = registry.map((m) => ({
504
+ type: "app",
505
+ moduleId: m.moduleId
506
+ }));
507
+ const rungs = [{ rows: rung0Items.length, items: rung0Items }];
508
+ const effectiveGroupOrder = [.../* @__PURE__ */ new Set([...groupOrder, ...registry.map((m) => m.group)])];
509
+ const foldOrder = [...effectiveGroupOrder].reverse();
510
+ const folded = /* @__PURE__ */ new Set();
511
+ let previousRows = rungs[0].rows;
512
+ for (const group of foldOrder) {
513
+ const membersOfGroup = registry.filter((m) => m.group === group).map((m) => m.moduleId);
514
+ if (membersOfGroup.length === 0) continue;
515
+ folded.add(group);
516
+ const items = [];
517
+ for (const g of effectiveGroupOrder) {
518
+ const membersOfG = registry.filter((m) => m.group === g).map((m) => m.moduleId);
519
+ if (membersOfG.length === 0) continue;
520
+ if (folded.has(g)) {
521
+ items.push({ type: "group", id: g, label: g, icon: FALLBACK_GROUP_ICON, children: membersOfG });
522
+ } else {
523
+ for (const moduleId of membersOfG) {
524
+ items.push({ type: "app", moduleId });
525
+ }
526
+ }
527
+ }
528
+ if (items.length < previousRows) {
529
+ rungs.push({ rows: items.length, items });
530
+ previousRows = items.length;
531
+ }
532
+ }
533
+ return { version: 1, rungs };
534
+ }
535
+ function reconcileLadder(ladder, registry) {
536
+ const validIds = new Set(registry.map((m) => m.moduleId));
537
+ function dropUnknown(items) {
538
+ const out = [];
539
+ for (const item of items) {
540
+ if (item.type === "app") {
541
+ if (validIds.has(item.moduleId)) out.push(item);
542
+ } else {
543
+ const children = item.children.filter((id) => validIds.has(id));
544
+ if (children.length > 0) out.push({ ...item, children });
545
+ }
546
+ }
547
+ return out;
548
+ }
549
+ const cleanedRungs = ladder.rungs.map((rung) => {
550
+ const items = dropUnknown(rung.items);
551
+ return { rows: items.length, items };
552
+ });
553
+ const rung0Ids = new Set(
554
+ (cleanedRungs[0]?.items ?? []).flatMap((i) => i.type === "app" ? [i.moduleId] : i.children)
555
+ );
556
+ const missing = registry.filter((m) => !rung0Ids.has(m.moduleId));
557
+ if (missing.length === 0) {
558
+ return { ...ladder, rungs: cleanedRungs };
559
+ }
560
+ const appended = missing.map((m) => ({ type: "app", moduleId: m.moduleId }));
561
+ const withAppended = cleanedRungs.map((rung) => ({
562
+ rows: rung.items.length + appended.length,
563
+ items: [...rung.items, ...appended]
564
+ }));
565
+ return { ...ladder, rungs: withAppended };
566
+ }
567
+ function resolveLadder(raw, registry, groupOrder) {
568
+ if (raw != null) {
569
+ const parsed = zSidebarLadder.safeParse(raw);
570
+ if (parsed.success) {
571
+ const reconciled = reconcileLadder(parsed.data, registry);
572
+ if (zSidebarLadder.safeParse(reconciled).success) {
573
+ return reconciled;
574
+ }
575
+ }
576
+ }
577
+ return deriveFallbackLadder(registry, groupOrder);
578
+ }
579
+
580
+ // src/ladder/use-ladder-rung.ts
581
+ var import_react5 = require("react");
582
+ var DEFAULT_ROW_HEIGHT = 36;
583
+ var DEFAULT_HYSTERESIS_ROWS = 1;
584
+ function useLadderRung(ladder, containerRef, options = {}) {
585
+ const rowHeight = options.rowHeight ?? DEFAULT_ROW_HEIGHT;
586
+ const hysteresisRows = options.hysteresisRows ?? DEFAULT_HYSTERESIS_ROWS;
587
+ const [rungIndex, setRungIndex] = (0, import_react5.useState)(0);
588
+ const rungIndexRef = (0, import_react5.useRef)(0);
589
+ const ladderSignature = `${ladder.version}:${ladder.rungs.map((r) => r.rows).join(",")}`;
590
+ (0, import_react5.useLayoutEffect)(() => {
591
+ rungIndexRef.current = 0;
592
+ setRungIndex(0);
593
+ const node = containerRef.current;
594
+ if (!node || typeof ResizeObserver === "undefined") return;
595
+ const select = (height) => {
596
+ const rowsAvailable = Math.floor(height / rowHeight);
597
+ const currentRows = ladder.rungs[rungIndexRef.current].rows;
598
+ let bestIndex = ladder.rungs.length - 1;
599
+ for (let i = 0; i < ladder.rungs.length; i++) {
600
+ if (ladder.rungs[i].rows <= rowsAvailable) {
601
+ bestIndex = i;
602
+ break;
603
+ }
604
+ }
605
+ const bestRows = ladder.rungs[bestIndex].rows;
606
+ const isSteppingUp = bestRows > currentRows;
607
+ if (isSteppingUp && rowsAvailable < bestRows + hysteresisRows) {
608
+ return;
609
+ }
610
+ if (bestIndex !== rungIndexRef.current) {
611
+ rungIndexRef.current = bestIndex;
612
+ setRungIndex(bestIndex);
613
+ }
614
+ };
615
+ const ro = new ResizeObserver((entries) => {
616
+ const entry = entries[0];
617
+ if (entry) select(entry.contentRect.height);
618
+ });
619
+ ro.observe(node);
620
+ return () => ro.disconnect();
621
+ }, [ladderSignature, containerRef, rowHeight, hysteresisRows]);
622
+ return ladder.rungs[Math.min(rungIndex, ladder.rungs.length - 1)];
623
+ }
624
+
625
+ // src/ladder/flyout.tsx
626
+ var import_react6 = require("react");
627
+ var import_jsx_runtime3 = require("react/jsx-runtime");
628
+ var PANEL_GAP = 4;
629
+ function Flyout({ label, icon, active, children, className }) {
630
+ const [hoverOpen, setHoverOpen] = (0, import_react6.useState)(false);
631
+ const [clickOpen, setClickOpen] = (0, import_react6.useState)(false);
632
+ const [panelPosition, setPanelPosition] = (0, import_react6.useState)(null);
633
+ const open = hoverOpen || clickOpen;
634
+ const panelId = (0, import_react6.useId)();
635
+ const triggerRef = (0, import_react6.useRef)(null);
636
+ const panelRef = (0, import_react6.useRef)(null);
637
+ const firstItemRef = (0, import_react6.useRef)(null);
638
+ const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
639
+ const Icon = resolveIcon(icon);
640
+ const reposition = (0, import_react6.useCallback)(() => {
641
+ const rect = triggerRef.current?.getBoundingClientRect();
642
+ if (rect) setPanelPosition({ top: rect.top, left: rect.right + PANEL_GAP });
643
+ }, []);
644
+ (0, import_react6.useLayoutEffect)(() => {
645
+ if (open) reposition();
646
+ }, [open, reposition]);
647
+ (0, import_react6.useEffect)(() => {
648
+ if (!open) return;
649
+ window.addEventListener("resize", reposition);
650
+ window.addEventListener("scroll", reposition, true);
651
+ return () => {
652
+ window.removeEventListener("resize", reposition);
653
+ window.removeEventListener("scroll", reposition, true);
654
+ };
655
+ }, [open, reposition]);
656
+ useDismissableDisclosure({
657
+ open,
658
+ onClose: () => {
659
+ setHoverOpen(false);
660
+ setClickOpen(false);
661
+ },
662
+ panelRef,
663
+ triggerRef
664
+ });
665
+ (0, import_react6.useEffect)(() => {
666
+ if (clickOpen && panelPosition) firstItemRef.current?.focus();
667
+ }, [clickOpen, panelPosition]);
668
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
669
+ "div",
670
+ {
671
+ "data-testid": "ladder-flyout",
672
+ className: cn("relative", className),
673
+ onMouseEnter: () => setHoverOpen(true),
674
+ onMouseLeave: () => setHoverOpen(false),
675
+ children: [
676
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
677
+ "button",
678
+ {
679
+ ref: triggerRef,
680
+ type: "button",
681
+ "data-testid": "ladder-flyout-trigger",
682
+ "aria-expanded": open,
683
+ "aria-controls": panelId,
684
+ onClick: () => setClickOpen((v) => !v),
685
+ className: cn(
686
+ "group flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-sm transition-colors",
687
+ active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
688
+ ),
689
+ children: [
690
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
691
+ Icon,
692
+ {
693
+ className: cn("size-4 shrink-0", active ? "text-primary" : "text-muted-foreground"),
694
+ strokeWidth: 1.5,
695
+ "aria-hidden": "true"
696
+ }
697
+ ),
698
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "truncate", children: label })
699
+ ]
700
+ }
701
+ ),
702
+ open && panelPosition && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
703
+ "div",
704
+ {
705
+ ref: panelRef,
706
+ id: panelId,
707
+ "data-testid": "ladder-flyout-panel",
708
+ style: { position: "fixed", top: panelPosition.top, left: panelPosition.left },
709
+ className: cn(
710
+ "z-50 flex min-w-[12rem] flex-col gap-0.5 rounded-md border border-border bg-background p-2 shadow-lg",
711
+ !prefersReducedMotion && "transition-all duration-150 ease-out"
712
+ ),
713
+ children: children.map((child, i) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
714
+ "a",
715
+ {
716
+ ref: i === 0 ? firstItemRef : void 0,
717
+ href: child.href,
718
+ "aria-current": child.active ? "page" : void 0,
719
+ className: cn(
720
+ "truncate rounded-md px-3 py-1.5 text-sm transition-colors",
721
+ child.active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
722
+ ),
723
+ children: child.label
724
+ },
725
+ child.moduleId
726
+ ))
727
+ }
728
+ )
729
+ ]
730
+ }
731
+ );
732
+ }
733
+
734
+ // src/app-sidebar.tsx
735
+ var import_jsx_runtime4 = require("react/jsx-runtime");
736
+ function isHrefActive(activePath, href) {
737
+ return activePath === href || activePath.startsWith(`${href}/`);
738
+ }
739
+ function NavLink({
740
+ href,
741
+ active,
742
+ icon,
743
+ label,
744
+ testId
745
+ }) {
746
+ const Icon = resolveIcon(icon);
747
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
748
+ "a",
749
+ {
750
+ href,
751
+ "data-testid": testId,
752
+ "aria-current": active ? "page" : void 0,
753
+ className: cn(
754
+ "group flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm transition-colors",
755
+ active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
756
+ ),
757
+ children: [
758
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
759
+ Icon,
760
+ {
761
+ className: cn("size-4 shrink-0", active ? "text-primary" : "text-muted-foreground"),
762
+ strokeWidth: 1.5,
763
+ "aria-hidden": "true"
764
+ }
765
+ ),
766
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "truncate", children: label })
767
+ ]
768
+ }
769
+ );
770
+ }
771
+ function AppSidebar({
772
+ ladder,
773
+ modules,
774
+ groupOrder,
775
+ activePath,
776
+ brand,
777
+ topItem,
778
+ menuLabel = "Menu",
779
+ ladderOptions,
780
+ className
781
+ }) {
782
+ const isDesktop = useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
783
+ const [mobileOpen, setMobileOpen] = (0, import_react7.useState)(false);
784
+ const containerRef = (0, import_react7.useRef)(null);
785
+ const drawerId = (0, import_react7.useId)();
786
+ const mobileTriggerRef = (0, import_react7.useRef)(null);
787
+ const mobileDrawerRef = (0, import_react7.useRef)(null);
788
+ const mobileCloseRef = (0, import_react7.useRef)(null);
789
+ const moduleMap = (0, import_react7.useMemo)(() => new Map(modules.map((m) => [m.moduleId, m])), [modules]);
790
+ const registryForLadder = (0, import_react7.useMemo)(
791
+ () => modules.map((m) => ({ moduleId: m.moduleId, group: m.group })),
792
+ [modules]
793
+ );
794
+ const resolvedLadder = (0, import_react7.useMemo)(
795
+ () => resolveLadder(ladder, registryForLadder, groupOrder),
796
+ [ladder, registryForLadder, groupOrder]
797
+ );
798
+ const rung = useLadderRung(resolvedLadder, containerRef, ladderOptions);
799
+ const rung0 = resolvedLadder.rungs[0];
800
+ (0, import_react7.useEffect)(() => {
801
+ if (isDesktop) setMobileOpen(false);
802
+ }, [isDesktop]);
803
+ useDismissableDisclosure({
804
+ open: mobileOpen,
805
+ onClose: () => setMobileOpen(false),
806
+ panelRef: mobileDrawerRef,
807
+ triggerRef: mobileTriggerRef,
808
+ initialFocusRef: mobileCloseRef
809
+ });
810
+ function renderAppItem(item, testIdPrefix) {
811
+ const def = moduleMap.get(item.moduleId);
812
+ if (!def) return null;
813
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
814
+ NavLink,
815
+ {
816
+ href: def.href,
817
+ active: isHrefActive(activePath, def.href),
818
+ icon: def.icon,
819
+ label: def.label,
820
+ testId: `${testIdPrefix}-${item.moduleId}`
821
+ },
822
+ item.moduleId
823
+ );
824
+ }
825
+ function renderGroupItem(item) {
826
+ const children = item.children.map((moduleId) => {
827
+ const def = moduleMap.get(moduleId);
828
+ const href = def?.href ?? "#";
829
+ return {
830
+ moduleId,
831
+ label: def?.label ?? moduleId,
832
+ href,
833
+ active: isHrefActive(activePath, href)
834
+ };
835
+ });
836
+ const active = children.some((c) => c.active);
837
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Flyout, { label: item.label, icon: item.icon, active, children }, item.id);
838
+ }
839
+ const brandBlock = brand && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
840
+ "a",
841
+ {
842
+ href: brand.href,
843
+ "data-testid": "app-sidebar-brand",
844
+ className: "flex items-center gap-2.5 border-b border-border px-5 py-4",
845
+ children: [
846
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "grid size-7 place-items-center rounded-md bg-primary text-primary-foreground", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "font-mono text-sm font-bold", children: (brand.label[0] ?? "r").toUpperCase() }) }),
847
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "truncate text-sm font-semibold tracking-tight", children: brand.label })
848
+ ]
849
+ }
850
+ );
851
+ function topItemBlock(testId) {
852
+ if (!topItem) return null;
853
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "px-3 pt-4", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
854
+ NavLink,
855
+ {
856
+ href: topItem.href,
857
+ active: isHrefActive(activePath, topItem.href),
858
+ icon: topItem.icon,
859
+ label: topItem.label,
860
+ testId
861
+ }
862
+ ) });
863
+ }
864
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_jsx_runtime4.Fragment, { children: [
865
+ !isDesktop && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
866
+ "button",
867
+ {
868
+ ref: mobileTriggerRef,
869
+ type: "button",
870
+ "data-testid": "app-sidebar-mobile-trigger",
871
+ "aria-label": menuLabel,
872
+ "aria-expanded": mobileOpen,
873
+ "aria-controls": drawerId,
874
+ onClick: () => setMobileOpen((v) => !v),
875
+ className: "flex items-center justify-center rounded-md border border-border p-2 text-foreground",
876
+ children: mobileOpen ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_lucide_react2.X, { className: "h-5 w-5", "aria-hidden": "true" }) : /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_lucide_react2.Menu, { className: "h-5 w-5", "aria-hidden": "true" })
877
+ }
878
+ ),
879
+ !isDesktop && mobileOpen && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "fixed inset-0 z-50", children: [
880
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
881
+ "div",
882
+ {
883
+ "data-testid": "app-sidebar-mobile-scrim",
884
+ className: "absolute inset-0 bg-black/50",
885
+ "aria-hidden": "true",
886
+ onClick: () => setMobileOpen(false)
887
+ }
888
+ ),
889
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
890
+ "div",
891
+ {
892
+ ref: mobileDrawerRef,
893
+ id: drawerId,
894
+ role: "dialog",
895
+ "aria-modal": "true",
896
+ "aria-label": "Navigation",
897
+ "data-testid": "app-sidebar-mobile-drawer",
898
+ className: "absolute inset-y-0 left-0 flex w-72 flex-col overflow-y-auto border-r border-border bg-sidebar",
899
+ children: [
900
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "sticky top-0 z-10 flex justify-end bg-sidebar p-2", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
901
+ "button",
902
+ {
903
+ ref: mobileCloseRef,
904
+ type: "button",
905
+ "data-testid": "app-sidebar-mobile-close",
906
+ "aria-label": `Close ${menuLabel.toLowerCase()}`,
907
+ onClick: () => setMobileOpen(false),
908
+ className: "flex items-center justify-center rounded-md p-2 text-foreground hover:bg-foreground/5",
909
+ children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_lucide_react2.X, { className: "h-5 w-5", "aria-hidden": "true" })
910
+ }
911
+ ) }),
912
+ brandBlock,
913
+ topItemBlock("app-sidebar-mobile-top-item"),
914
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("nav", { className: "flex-1 px-3 py-4", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "flex flex-col gap-0.5", children: rung0.items.map(
915
+ (item) => item.type === "app" ? renderAppItem(item, "app-sidebar-mobile-navlink") : null
916
+ ) }) })
917
+ ]
918
+ }
919
+ )
920
+ ] }),
921
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
922
+ "aside",
923
+ {
924
+ "data-testid": "app-sidebar-desktop",
925
+ className: cn("hidden w-64 shrink-0 flex-col border-r border-border bg-sidebar md:flex", className),
926
+ children: [
927
+ brandBlock,
928
+ topItemBlock("app-sidebar-top-item"),
929
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
930
+ "nav",
931
+ {
932
+ ref: containerRef,
933
+ "data-testid": "app-sidebar-nav",
934
+ className: "flex-1 overflow-y-auto px-3 py-4",
935
+ children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "flex flex-col gap-0.5", children: rung.items.map((item) => item.type === "app" ? renderAppItem(item, "app-sidebar-navlink") : renderGroupItem(item)) })
936
+ }
937
+ )
938
+ ]
939
+ }
940
+ )
941
+ ] });
942
+ }
943
+ // Annotate the CommonJS export names for ESM import in node:
944
+ 0 && (module.exports = {
945
+ AppSidebar,
946
+ BrandGlyph,
947
+ Flyout,
948
+ ResponsiveHeader,
949
+ deriveFallbackLadder,
950
+ faviconUrl,
951
+ reconcileLadder,
952
+ resolveLadder,
953
+ useLadderRung,
954
+ zSidebarLadder
955
+ });