@meetreeve/ui 0.2.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -33,19 +33,26 @@ var src_exports = {};
33
33
  __export(src_exports, {
34
34
  AppSidebar: () => AppSidebar,
35
35
  BrandGlyph: () => BrandGlyph,
36
+ ContextSwitcher: () => ContextSwitcher,
36
37
  Flyout: () => Flyout,
38
+ GLYPH_CANVAS_FILL: () => GLYPH_CANVAS_FILL,
39
+ LOCKUP_GAP: () => LOCKUP_GAP,
40
+ LOCKUP_ROW: () => LOCKUP_ROW,
41
+ MARK_CAP_RATIO: () => MARK_CAP_RATIO,
42
+ REEVE_ACCENT: () => REEVE_ACCENT,
37
43
  ResponsiveHeader: () => ResponsiveHeader,
38
44
  deriveFallbackLadder: () => deriveFallbackLadder,
39
45
  faviconUrl: () => faviconUrl,
46
+ groupByOrg: () => groupByOrg,
40
47
  reconcileLadder: () => reconcileLadder,
41
48
  resolveLadder: () => resolveLadder,
42
49
  useLadderRung: () => useLadderRung,
50
+ useResponsivePlaceholder: () => useResponsivePlaceholder,
43
51
  zSidebarLadder: () => zSidebarLadder
44
52
  });
45
53
  module.exports = __toCommonJS(src_exports);
46
54
 
47
55
  // src/brand-glyph.tsx
48
- var import_image = __toESM(require("next/image"));
49
56
  var import_react = require("react");
50
57
 
51
58
  // src/lib/cn.ts
@@ -68,12 +75,18 @@ function cn(...inputs) {
68
75
  }
69
76
 
70
77
  // 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";
78
+ function firstInitial(name) {
79
+ const trimmed = (name ?? "").trim();
80
+ return trimmed ? trimmed[0].toUpperCase() : "?";
75
81
  }
76
82
 
83
+ // src/lib/lockup.ts
84
+ var MARK_CAP_RATIO = 1.08;
85
+ var MONOGRAM_TEXT_LG = "text-[15px]";
86
+ var LOCKUP_GAP = "gap-2";
87
+ var LOCKUP_ROW = "flex items-baseline";
88
+ var GLYPH_CANVAS_FILL = 0.65;
89
+
77
90
  // src/lib/monogram.ts
78
91
  var MONOGRAM_COLORS = [
79
92
  "#7c3aed",
@@ -101,6 +114,7 @@ function monogramColor(seed) {
101
114
 
102
115
  // src/brand-glyph.tsx
103
116
  var import_jsx_runtime = require("react/jsx-runtime");
117
+ var REEVE_ACCENT = "#FF6B2B";
104
118
  function faviconUrl(domain) {
105
119
  if (!domain) return null;
106
120
  const host = domain.trim().toLowerCase();
@@ -121,21 +135,26 @@ function BrandGlyph({ brand, size = "sm", subtle = false, className }) {
121
135
  "span",
122
136
  {
123
137
  className: cn(
124
- "flex shrink-0 items-center justify-center overflow-hidden rounded",
138
+ // `self-center` opts image glyphs out of the lockup's baseline
139
+ // alignment (DEV-5139): an image box has no text baseline, so
140
+ // flexbox would seat its BOTTOM edge on the wordmark baseline and
141
+ // shove the icon upward. Centering preserves the pre-DEV-5139 look.
142
+ "flex shrink-0 items-center justify-center self-center overflow-hidden rounded",
125
143
  // Tint needs a dark-mode counterpart or it vanishes on dark backgrounds.
126
144
  subtle ? "bg-black/5 dark:bg-white/10" : "bg-black/10 dark:bg-white/15",
127
145
  box,
128
146
  className
129
147
  ),
130
148
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
131
- import_image.default,
149
+ "img",
132
150
  {
133
151
  src,
134
152
  alt: "",
135
153
  width: dim,
136
154
  height: dim,
155
+ loading: "lazy",
156
+ decoding: "async",
137
157
  "data-testid": "brand-image",
138
- unoptimized: true,
139
158
  onError: () => setFailed((prev) => new Set(prev).add(src)),
140
159
  className: cn(img, "rounded-sm object-contain")
141
160
  }
@@ -143,19 +162,28 @@ function BrandGlyph({ brand, size = "sm", subtle = false, className }) {
143
162
  }
144
163
  );
145
164
  }
146
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
165
+ const letter = firstInitial(brand.name);
166
+ const hasRealLetter = letter !== "?";
167
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
147
168
  "span",
148
169
  {
149
170
  "aria-hidden": true,
150
171
  "data-testid": "brand-monogram",
151
172
  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]",
173
+ "flex shrink-0 select-none items-baseline justify-center font-semibold uppercase",
174
+ // DEV-5139 rule 2: at "lg" the letter runs 15px = the text-sm
175
+ // wordmark (14px) × MARK_CAP_RATIO (1.08) — a lone round glyph at
176
+ // exact parity optically reads small. "sm" has no wordmark partner
177
+ // in its surfaces; unchanged pending a lockup audit there.
178
+ size === "lg" ? MONOGRAM_TEXT_LG : "text-[11px]",
154
179
  box,
155
180
  className
156
181
  ),
157
- style: { backgroundColor: brand.color || monogramColor(brand.name || "?") },
158
- children: initials(brand.name)
182
+ style: { color: brand.color || monogramColor(brand.name || "?") },
183
+ children: [
184
+ letter,
185
+ hasRealLetter && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "data-testid": "brand-monogram-dot", style: { color: brand.accentColor || REEVE_ACCENT }, children: "." })
186
+ ]
159
187
  }
160
188
  );
161
189
  }
@@ -164,23 +192,21 @@ function BrandGlyph({ brand, size = "sm", subtle = false, className }) {
164
192
  var import_react3 = require("react");
165
193
  var import_lucide_react = require("lucide-react");
166
194
 
167
- // src/lib/use-media-query.ts
195
+ // src/lib/use-container-width-at-least.ts
168
196
  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);
197
+ function useContainerWidthAtLeast(ref, px) {
198
+ const [atLeast, setAtLeast] = (0, import_react2.useState)(false);
199
+ (0, import_react2.useEffect)(() => {
200
+ const el = ref.current;
201
+ if (!el || typeof ResizeObserver === "undefined") return;
202
+ const ro = new ResizeObserver(([entry]) => {
203
+ if (!entry) return;
204
+ setAtLeast(entry.contentRect.width >= px);
205
+ });
206
+ ro.observe(el);
207
+ return () => ro.disconnect();
208
+ }, [ref, px]);
209
+ return atLeast;
184
210
  }
185
211
 
186
212
  // src/tokens.ts
@@ -200,6 +226,10 @@ var BREAKPOINTS = {
200
226
  /** Tailwind `md` — the header switches to its full desktop layout at/above this. */
201
227
  md: 768
202
228
  };
229
+ var TOUCH_TARGET_STYLE = {
230
+ minWidth: MIN_SIZES.touchTarget,
231
+ minHeight: MIN_SIZES.touchTarget
232
+ };
203
233
 
204
234
  // src/responsive-header.tsx
205
235
  var import_jsx_runtime2 = require("react/jsx-runtime");
@@ -209,15 +239,17 @@ function MenuIcon({ open }) {
209
239
  function SecondaryControls({
210
240
  nav,
211
241
  auth,
212
- themeToggle
242
+ themeToggle,
243
+ linkComponent
213
244
  }) {
245
+ const LinkEl = linkComponent ?? "a";
214
246
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
215
247
  nav?.map(
216
248
  (item) => (
217
249
  // A link navigates; an action-only item (no href) is a button — never an
218
250
  // <a href="#"> that scroll-jumps and pushes a history entry.
219
251
  item.href ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
220
- "a",
252
+ LinkEl,
221
253
  {
222
254
  href: item.href,
223
255
  onClick: item.onSelect,
@@ -225,15 +257,22 @@ function SecondaryControls({
225
257
  children: item.label
226
258
  },
227
259
  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
260
+ ) : (
261
+ // A real <button> secondary control — MIN_SIZES.control is the
262
+ // token's exact stated purpose ("Generic minimum width/height for
263
+ // a secondary control (button, select)"), previously defined but
264
+ // unused anywhere in this package (DEV-4249).
265
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
266
+ "button",
267
+ {
268
+ type: "button",
269
+ onClick: item.onSelect,
270
+ style: { minWidth: MIN_SIZES.control, minHeight: MIN_SIZES.control },
271
+ className: "flex items-center justify-start text-left text-sm font-medium text-foreground/80 hover:text-foreground",
272
+ children: item.label
273
+ },
274
+ item.label
275
+ )
237
276
  )
238
277
  )
239
278
  ),
@@ -245,19 +284,23 @@ function ResponsiveHeader({
245
284
  brand,
246
285
  search,
247
286
  nav,
287
+ brandHref,
288
+ linkComponent,
248
289
  auth,
249
290
  themeToggle,
250
291
  menuLabel = "Menu",
251
292
  className
252
293
  }) {
253
- const isDesktop = useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
294
+ const LinkEl = linkComponent ?? "a";
254
295
  const [open, setOpen] = (0, import_react3.useState)(false);
255
296
  const panelId = (0, import_react3.useId)();
297
+ const headerRef = (0, import_react3.useRef)(null);
256
298
  const buttonRef = (0, import_react3.useRef)(null);
257
299
  const panelRef = (0, import_react3.useRef)(null);
300
+ const containerAtOrAboveMd = useContainerWidthAtLeast(headerRef, BREAKPOINTS.md);
258
301
  (0, import_react3.useEffect)(() => {
259
- if (isDesktop) setOpen(false);
260
- }, [isDesktop]);
302
+ if (containerAtOrAboveMd) setOpen(false);
303
+ }, [containerAtOrAboveMd]);
261
304
  (0, import_react3.useEffect)(() => {
262
305
  if (!open) return;
263
306
  const onKey = (e) => {
@@ -280,27 +323,41 @@ function ResponsiveHeader({
280
323
  };
281
324
  }, [open]);
282
325
  const hasSecondary = Boolean(nav && nav.length > 0 || auth || themeToggle);
326
+ const brandLockup = /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { "data-testid": "header-lockup", className: cn(LOCKUP_ROW, LOCKUP_GAP), children: [
327
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(BrandGlyph, { brand, size: "lg" }),
328
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
329
+ "span",
330
+ {
331
+ "data-testid": "header-wordmark",
332
+ className: "hidden truncate text-sm font-semibold text-foreground @min-[768px]:block",
333
+ style: { maxWidth: MIN_SIZES.wordmarkMaxWidth },
334
+ children: brand.name
335
+ }
336
+ )
337
+ ] });
283
338
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
284
339
  "header",
285
340
  {
341
+ ref: headerRef,
286
342
  "data-testid": "responsive-header",
287
343
  className: cn(
288
- "flex w-full items-center gap-3 border-b border-border bg-background px-4 py-2",
344
+ // `@container` makes this header itself the query context, so the
345
+ // variants below react to ITS width (e.g. a sidebar), not the viewport.
346
+ "@container flex w-full items-center gap-3 border-b border-border bg-background px-4 py-2",
289
347
  className
290
348
  ),
291
349
  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
- ] }),
350
+ brandHref ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
351
+ LinkEl,
352
+ {
353
+ href: brandHref,
354
+ "aria-label": brand.name || "Home",
355
+ "data-testid": "header-brand",
356
+ style: TOUCH_TARGET_STYLE,
357
+ className: "flex shrink-0 items-center",
358
+ children: brandLockup
359
+ }
360
+ ) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { "data-testid": "header-brand", className: "flex shrink-0 items-center", children: brandLockup }),
304
361
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
305
362
  "div",
306
363
  {
@@ -310,40 +367,91 @@ function ResponsiveHeader({
310
367
  children: search
311
368
  }
312
369
  ),
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: [
370
+ hasSecondary && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
314
371
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
315
- "button",
372
+ "nav",
316
373
  {
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 })
374
+ "data-testid": "header-actions",
375
+ className: "hidden shrink-0 items-center gap-3 @min-[768px]:flex",
376
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
377
+ SecondaryControls,
378
+ {
379
+ nav,
380
+ auth,
381
+ themeToggle,
382
+ linkComponent
383
+ }
384
+ )
327
385
  }
328
386
  ),
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
- ] }))
387
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "relative shrink-0 @min-[768px]:hidden", children: [
388
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
389
+ "button",
390
+ {
391
+ ref: buttonRef,
392
+ type: "button",
393
+ "data-testid": "header-menu-button",
394
+ "aria-label": menuLabel,
395
+ "aria-expanded": open,
396
+ "aria-controls": panelId,
397
+ onClick: () => setOpen((v) => !v),
398
+ className: "flex items-center justify-center rounded-md border border-border text-foreground",
399
+ style: TOUCH_TARGET_STYLE,
400
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(MenuIcon, { open })
401
+ }
402
+ ),
403
+ open && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
404
+ "div",
405
+ {
406
+ ref: panelRef,
407
+ id: panelId,
408
+ "data-testid": "header-menu-panel",
409
+ 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",
410
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
411
+ SecondaryControls,
412
+ {
413
+ nav,
414
+ auth,
415
+ themeToggle,
416
+ linkComponent
417
+ }
418
+ )
419
+ }
420
+ )
421
+ ] })
422
+ ] })
340
423
  ]
341
424
  }
342
425
  );
343
426
  }
344
427
 
428
+ // src/lib/use-media-query.ts
429
+ var import_react4 = require("react");
430
+ function useMediaQuery(query) {
431
+ const subscribe = (0, import_react4.useCallback)(
432
+ (onChange) => {
433
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
434
+ return () => {
435
+ };
436
+ }
437
+ const mql = window.matchMedia(query);
438
+ mql.addEventListener("change", onChange);
439
+ return () => mql.removeEventListener("change", onChange);
440
+ },
441
+ [query]
442
+ );
443
+ const getSnapshot = () => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(query).matches : false;
444
+ return (0, import_react4.useSyncExternalStore)(subscribe, getSnapshot, () => false);
445
+ }
446
+
447
+ // src/use-responsive-placeholder.ts
448
+ function useResponsivePlaceholder(desktop, mobile) {
449
+ const isDesktop = useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
450
+ return isDesktop ? desktop : mobile ?? desktop;
451
+ }
452
+
345
453
  // src/app-sidebar.tsx
346
- var import_react7 = require("react");
454
+ var import_react8 = require("react");
347
455
  var import_lucide_react2 = require("lucide-react");
348
456
 
349
457
  // src/lib/resolve-icon.ts
@@ -364,7 +472,7 @@ function resolveIcon(name) {
364
472
  }
365
473
 
366
474
  // src/lib/use-dismissable-disclosure.ts
367
- var import_react4 = require("react");
475
+ var import_react5 = require("react");
368
476
  function useDismissableDisclosure({
369
477
  open,
370
478
  onClose,
@@ -372,10 +480,10 @@ function useDismissableDisclosure({
372
480
  triggerRef,
373
481
  initialFocusRef
374
482
  }) {
375
- (0, import_react4.useEffect)(() => {
483
+ (0, import_react5.useEffect)(() => {
376
484
  if (open) initialFocusRef?.current?.focus();
377
485
  }, [open]);
378
- (0, import_react4.useEffect)(() => {
486
+ (0, import_react5.useEffect)(() => {
379
487
  if (!open) return;
380
488
  const onKey = (e) => {
381
489
  if (e.key === "Escape") {
@@ -578,16 +686,16 @@ function resolveLadder(raw, registry, groupOrder) {
578
686
  }
579
687
 
580
688
  // src/ladder/use-ladder-rung.ts
581
- var import_react5 = require("react");
689
+ var import_react6 = require("react");
582
690
  var DEFAULT_ROW_HEIGHT = 36;
583
691
  var DEFAULT_HYSTERESIS_ROWS = 1;
584
692
  function useLadderRung(ladder, containerRef, options = {}) {
585
693
  const rowHeight = options.rowHeight ?? DEFAULT_ROW_HEIGHT;
586
694
  const hysteresisRows = options.hysteresisRows ?? DEFAULT_HYSTERESIS_ROWS;
587
- const [rungIndex, setRungIndex] = (0, import_react5.useState)(0);
588
- const rungIndexRef = (0, import_react5.useRef)(0);
695
+ const [rungIndex, setRungIndex] = (0, import_react6.useState)(0);
696
+ const rungIndexRef = (0, import_react6.useRef)(0);
589
697
  const ladderSignature = `${ladder.version}:${ladder.rungs.map((r) => r.rows).join(",")}`;
590
- (0, import_react5.useLayoutEffect)(() => {
698
+ (0, import_react6.useLayoutEffect)(() => {
591
699
  rungIndexRef.current = 0;
592
700
  setRungIndex(0);
593
701
  const node = containerRef.current;
@@ -623,28 +731,28 @@ function useLadderRung(ladder, containerRef, options = {}) {
623
731
  }
624
732
 
625
733
  // src/ladder/flyout.tsx
626
- var import_react6 = require("react");
734
+ var import_react7 = require("react");
627
735
  var import_jsx_runtime3 = require("react/jsx-runtime");
628
736
  var PANEL_GAP = 4;
629
737
  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);
738
+ const [hoverOpen, setHoverOpen] = (0, import_react7.useState)(false);
739
+ const [clickOpen, setClickOpen] = (0, import_react7.useState)(false);
740
+ const [panelPosition, setPanelPosition] = (0, import_react7.useState)(null);
633
741
  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);
742
+ const panelId = (0, import_react7.useId)();
743
+ const triggerRef = (0, import_react7.useRef)(null);
744
+ const panelRef = (0, import_react7.useRef)(null);
745
+ const firstItemRef = (0, import_react7.useRef)(null);
638
746
  const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
639
747
  const Icon = resolveIcon(icon);
640
- const reposition = (0, import_react6.useCallback)(() => {
748
+ const reposition = (0, import_react7.useCallback)(() => {
641
749
  const rect = triggerRef.current?.getBoundingClientRect();
642
750
  if (rect) setPanelPosition({ top: rect.top, left: rect.right + PANEL_GAP });
643
751
  }, []);
644
- (0, import_react6.useLayoutEffect)(() => {
752
+ (0, import_react7.useLayoutEffect)(() => {
645
753
  if (open) reposition();
646
754
  }, [open, reposition]);
647
- (0, import_react6.useEffect)(() => {
755
+ (0, import_react7.useEffect)(() => {
648
756
  if (!open) return;
649
757
  window.addEventListener("resize", reposition);
650
758
  window.addEventListener("scroll", reposition, true);
@@ -662,7 +770,7 @@ function Flyout({ label, icon, active, children, className }) {
662
770
  panelRef,
663
771
  triggerRef
664
772
  });
665
- (0, import_react6.useEffect)(() => {
773
+ (0, import_react7.useEffect)(() => {
666
774
  if (clickOpen && panelPosition) firstItemRef.current?.focus();
667
775
  }, [clickOpen, panelPosition]);
668
776
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
@@ -716,8 +824,9 @@ function Flyout({ label, icon, active, children, className }) {
716
824
  ref: i === 0 ? firstItemRef : void 0,
717
825
  href: child.href,
718
826
  "aria-current": child.active ? "page" : void 0,
827
+ style: TOUCH_TARGET_STYLE,
719
828
  className: cn(
720
- "truncate rounded-md px-3 py-1.5 text-sm transition-colors",
829
+ "flex items-center truncate rounded-md px-3 py-1.5 text-sm transition-colors",
721
830
  child.active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
722
831
  ),
723
832
  children: child.label
@@ -780,24 +889,24 @@ function AppSidebar({
780
889
  className
781
890
  }) {
782
891
  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)(
892
+ const [mobileOpen, setMobileOpen] = (0, import_react8.useState)(false);
893
+ const containerRef = (0, import_react8.useRef)(null);
894
+ const drawerId = (0, import_react8.useId)();
895
+ const mobileTriggerRef = (0, import_react8.useRef)(null);
896
+ const mobileDrawerRef = (0, import_react8.useRef)(null);
897
+ const mobileCloseRef = (0, import_react8.useRef)(null);
898
+ const moduleMap = (0, import_react8.useMemo)(() => new Map(modules.map((m) => [m.moduleId, m])), [modules]);
899
+ const registryForLadder = (0, import_react8.useMemo)(
791
900
  () => modules.map((m) => ({ moduleId: m.moduleId, group: m.group })),
792
901
  [modules]
793
902
  );
794
- const resolvedLadder = (0, import_react7.useMemo)(
903
+ const resolvedLadder = (0, import_react8.useMemo)(
795
904
  () => resolveLadder(ladder, registryForLadder, groupOrder),
796
905
  [ladder, registryForLadder, groupOrder]
797
906
  );
798
907
  const rung = useLadderRung(resolvedLadder, containerRef, ladderOptions);
799
908
  const rung0 = resolvedLadder.rungs[0];
800
- (0, import_react7.useEffect)(() => {
909
+ (0, import_react8.useEffect)(() => {
801
910
  if (isDesktop) setMobileOpen(false);
802
911
  }, [isDesktop]);
803
912
  useDismissableDisclosure({
@@ -872,6 +981,7 @@ function AppSidebar({
872
981
  "aria-expanded": mobileOpen,
873
982
  "aria-controls": drawerId,
874
983
  onClick: () => setMobileOpen((v) => !v),
984
+ style: TOUCH_TARGET_STYLE,
875
985
  className: "flex items-center justify-center rounded-md border border-border p-2 text-foreground",
876
986
  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
987
  }
@@ -905,6 +1015,7 @@ function AppSidebar({
905
1015
  "data-testid": "app-sidebar-mobile-close",
906
1016
  "aria-label": `Close ${menuLabel.toLowerCase()}`,
907
1017
  onClick: () => setMobileOpen(false),
1018
+ style: TOUCH_TARGET_STYLE,
908
1019
  className: "flex items-center justify-center rounded-md p-2 text-foreground hover:bg-foreground/5",
909
1020
  children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_lucide_react2.X, { className: "h-5 w-5", "aria-hidden": "true" })
910
1021
  }
@@ -940,16 +1051,198 @@ function AppSidebar({
940
1051
  )
941
1052
  ] });
942
1053
  }
1054
+
1055
+ // src/context-switcher.tsx
1056
+ var import_lucide_react3 = require("lucide-react");
1057
+
1058
+ // src/lib/group-by-org.ts
1059
+ function groupItemsByOrg(rows, get) {
1060
+ const byOrg = /* @__PURE__ */ new Map();
1061
+ for (const row of rows) {
1062
+ const key = get.orgId(row) ?? "";
1063
+ let group = byOrg.get(key);
1064
+ if (!group) {
1065
+ group = {
1066
+ orgId: get.orgId(row) ?? null,
1067
+ orgName: get.orgName(row) ?? null,
1068
+ items: []
1069
+ };
1070
+ byOrg.set(key, group);
1071
+ }
1072
+ group.items.push(row);
1073
+ }
1074
+ const groups = [...byOrg.values()];
1075
+ for (const g of groups) {
1076
+ g.items.sort((a, b) => get.name(a).localeCompare(get.name(b)));
1077
+ }
1078
+ groups.sort((a, b) => (a.orgName ?? "").localeCompare(b.orgName ?? ""));
1079
+ return groups;
1080
+ }
1081
+
1082
+ // src/lib/dropdown-menu.tsx
1083
+ var DropdownMenuPrimitive = __toESM(require("@radix-ui/react-dropdown-menu"));
1084
+ var import_jsx_runtime5 = require("react/jsx-runtime");
1085
+ function DropdownMenu(props) {
1086
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(DropdownMenuPrimitive.Root, { "data-slot": "dropdown-menu", ...props });
1087
+ }
1088
+ function DropdownMenuTrigger(props) {
1089
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(DropdownMenuPrimitive.Trigger, { "data-slot": "dropdown-menu-trigger", ...props });
1090
+ }
1091
+ function DropdownMenuContent({
1092
+ className,
1093
+ sideOffset = 4,
1094
+ ...props
1095
+ }) {
1096
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(DropdownMenuPrimitive.Portal, { children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
1097
+ DropdownMenuPrimitive.Content,
1098
+ {
1099
+ "data-slot": "dropdown-menu-content",
1100
+ sideOffset,
1101
+ className: cn(
1102
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
1103
+ className
1104
+ ),
1105
+ ...props
1106
+ }
1107
+ ) });
1108
+ }
1109
+ function DropdownMenuItem({
1110
+ className,
1111
+ ...props
1112
+ }) {
1113
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
1114
+ DropdownMenuPrimitive.Item,
1115
+ {
1116
+ "data-slot": "dropdown-menu-item",
1117
+ className: cn(
1118
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
1119
+ className
1120
+ ),
1121
+ ...props
1122
+ }
1123
+ );
1124
+ }
1125
+ function DropdownMenuLabel({
1126
+ className,
1127
+ ...props
1128
+ }) {
1129
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
1130
+ DropdownMenuPrimitive.Label,
1131
+ {
1132
+ "data-slot": "dropdown-menu-label",
1133
+ className: cn("px-2 py-1.5 text-sm font-medium", className),
1134
+ ...props
1135
+ }
1136
+ );
1137
+ }
1138
+
1139
+ // src/context-switcher.tsx
1140
+ var import_jsx_runtime6 = require("react/jsx-runtime");
1141
+ function groupByOrg(items) {
1142
+ return groupItemsByOrg(items, {
1143
+ orgId: (it) => it.orgId,
1144
+ orgName: (it) => it.orgName,
1145
+ name: (it) => it.name
1146
+ });
1147
+ }
1148
+ function toBrand(item) {
1149
+ return {
1150
+ name: item.name,
1151
+ favicon: item.favicon,
1152
+ logo: item.logo,
1153
+ color: item.color,
1154
+ accentColor: item.accentColor
1155
+ };
1156
+ }
1157
+ function ContextSwitcher({
1158
+ items,
1159
+ activeRefId,
1160
+ onSelect,
1161
+ footer,
1162
+ placeholder = "Select",
1163
+ collapsed = false,
1164
+ side = "bottom",
1165
+ align = "start",
1166
+ contentClassName
1167
+ }) {
1168
+ const active = items.find((it) => it.refId === activeRefId) ?? null;
1169
+ const groups = groupByOrg(items);
1170
+ const showOrgHeaders = groups.length > 1;
1171
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(DropdownMenu, { children: [
1172
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(DropdownMenuTrigger, { asChild: true, children: collapsed ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
1173
+ "button",
1174
+ {
1175
+ type: "button",
1176
+ title: active?.name || placeholder,
1177
+ "aria-label": active?.name || placeholder,
1178
+ className: "flex h-10 w-10 items-center justify-center rounded-lg text-zinc-700 transition-colors hover:bg-zinc-900/5 dark:text-zinc-300 dark:hover:bg-white/5",
1179
+ children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(BrandGlyph, { brand: toBrand(active ?? { name: "" }), size: "lg" })
1180
+ }
1181
+ ) : /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
1182
+ "button",
1183
+ {
1184
+ type: "button",
1185
+ className: "group flex items-center gap-2 rounded-md border border-border bg-card/60 px-2.5 py-1.5 text-sm font-medium transition-colors hover:bg-muted",
1186
+ children: [
1187
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(BrandGlyph, { brand: toBrand(active ?? { name: "" }) }),
1188
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "max-w-[140px] truncate text-foreground/90", children: active?.name || placeholder }),
1189
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_lucide_react3.ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" })
1190
+ ]
1191
+ }
1192
+ ) }),
1193
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
1194
+ DropdownMenuContent,
1195
+ {
1196
+ side,
1197
+ align,
1198
+ sideOffset: 6,
1199
+ collisionPadding: 8,
1200
+ className: cn("w-60 overflow-hidden p-0", contentClassName),
1201
+ children: [
1202
+ groups.map((group) => /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { children: [
1203
+ showOrgHeaders && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(DropdownMenuLabel, { className: "px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground", children: group.orgName ?? "Other" }),
1204
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: cn(showOrgHeaders && "ml-5 border-l border-border/60"), children: group.items.map((item) => {
1205
+ const isActive = item.refId === activeRefId;
1206
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
1207
+ DropdownMenuItem,
1208
+ {
1209
+ "data-active": isActive || void 0,
1210
+ onSelect: () => onSelect(item),
1211
+ className: "flex w-full items-center gap-2.5 rounded-none px-3 py-2 text-sm",
1212
+ children: [
1213
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(BrandGlyph, { brand: toBrand(item), subtle: true }),
1214
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "flex-1 truncate text-left text-foreground/90", children: item.name }),
1215
+ isActive && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_lucide_react3.Check, { className: "size-3.5 text-emerald-500" })
1216
+ ]
1217
+ },
1218
+ item.refId
1219
+ );
1220
+ }) })
1221
+ ] }, group.orgId ?? "unattributed")),
1222
+ footer && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "sticky bottom-0 border-t border-border bg-popover", children: footer })
1223
+ ]
1224
+ }
1225
+ )
1226
+ ] });
1227
+ }
943
1228
  // Annotate the CommonJS export names for ESM import in node:
944
1229
  0 && (module.exports = {
945
1230
  AppSidebar,
946
1231
  BrandGlyph,
1232
+ ContextSwitcher,
947
1233
  Flyout,
1234
+ GLYPH_CANVAS_FILL,
1235
+ LOCKUP_GAP,
1236
+ LOCKUP_ROW,
1237
+ MARK_CAP_RATIO,
1238
+ REEVE_ACCENT,
948
1239
  ResponsiveHeader,
949
1240
  deriveFallbackLadder,
950
1241
  faviconUrl,
1242
+ groupByOrg,
951
1243
  reconcileLadder,
952
1244
  resolveLadder,
953
1245
  useLadderRung,
1246
+ useResponsivePlaceholder,
954
1247
  zSidebarLadder
955
1248
  });