@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.mjs CHANGED
@@ -1,7 +1,6 @@
1
1
  "use client";
2
2
 
3
3
  // src/brand-glyph.tsx
4
- import Image from "next/image";
5
4
  import { useState } from "react";
6
5
 
7
6
  // src/lib/cn.ts
@@ -24,12 +23,18 @@ function cn(...inputs) {
24
23
  }
25
24
 
26
25
  // src/lib/initials.ts
27
- function initials(name) {
28
- if (!name) return "\xB7\xB7";
29
- const parts = name.trim().split(/\s+/).slice(0, 2);
30
- return parts.map((p) => p[0]?.toUpperCase() ?? "").join("") || "\xB7\xB7";
26
+ function firstInitial(name) {
27
+ const trimmed = (name ?? "").trim();
28
+ return trimmed ? trimmed[0].toUpperCase() : "?";
31
29
  }
32
30
 
31
+ // src/lib/lockup.ts
32
+ var MARK_CAP_RATIO = 1.08;
33
+ var MONOGRAM_TEXT_LG = "text-[15px]";
34
+ var LOCKUP_GAP = "gap-2";
35
+ var LOCKUP_ROW = "flex items-baseline";
36
+ var GLYPH_CANVAS_FILL = 0.65;
37
+
33
38
  // src/lib/monogram.ts
34
39
  var MONOGRAM_COLORS = [
35
40
  "#7c3aed",
@@ -56,7 +61,8 @@ function monogramColor(seed) {
56
61
  }
57
62
 
58
63
  // src/brand-glyph.tsx
59
- import { jsx } from "react/jsx-runtime";
64
+ import { jsx, jsxs } from "react/jsx-runtime";
65
+ var REEVE_ACCENT = "#FF6B2B";
60
66
  function faviconUrl(domain) {
61
67
  if (!domain) return null;
62
68
  const host = domain.trim().toLowerCase();
@@ -77,21 +83,26 @@ function BrandGlyph({ brand, size = "sm", subtle = false, className }) {
77
83
  "span",
78
84
  {
79
85
  className: cn(
80
- "flex shrink-0 items-center justify-center overflow-hidden rounded",
86
+ // `self-center` opts image glyphs out of the lockup's baseline
87
+ // alignment (DEV-5139): an image box has no text baseline, so
88
+ // flexbox would seat its BOTTOM edge on the wordmark baseline and
89
+ // shove the icon upward. Centering preserves the pre-DEV-5139 look.
90
+ "flex shrink-0 items-center justify-center self-center overflow-hidden rounded",
81
91
  // Tint needs a dark-mode counterpart or it vanishes on dark backgrounds.
82
92
  subtle ? "bg-black/5 dark:bg-white/10" : "bg-black/10 dark:bg-white/15",
83
93
  box,
84
94
  className
85
95
  ),
86
96
  children: /* @__PURE__ */ jsx(
87
- Image,
97
+ "img",
88
98
  {
89
99
  src,
90
100
  alt: "",
91
101
  width: dim,
92
102
  height: dim,
103
+ loading: "lazy",
104
+ decoding: "async",
93
105
  "data-testid": "brand-image",
94
- unoptimized: true,
95
106
  onError: () => setFailed((prev) => new Set(prev).add(src)),
96
107
  className: cn(img, "rounded-sm object-contain")
97
108
  }
@@ -99,44 +110,51 @@ function BrandGlyph({ brand, size = "sm", subtle = false, className }) {
99
110
  }
100
111
  );
101
112
  }
102
- return /* @__PURE__ */ jsx(
113
+ const letter = firstInitial(brand.name);
114
+ const hasRealLetter = letter !== "?";
115
+ return /* @__PURE__ */ jsxs(
103
116
  "span",
104
117
  {
105
118
  "aria-hidden": true,
106
119
  "data-testid": "brand-monogram",
107
120
  className: cn(
108
- "flex shrink-0 select-none items-center justify-center rounded font-semibold uppercase text-white",
109
- size === "lg" ? "text-[11px]" : "text-[9px]",
121
+ "flex shrink-0 select-none items-baseline justify-center font-semibold uppercase",
122
+ // DEV-5139 rule 2: at "lg" the letter runs 15px = the text-sm
123
+ // wordmark (14px) × MARK_CAP_RATIO (1.08) — a lone round glyph at
124
+ // exact parity optically reads small. "sm" has no wordmark partner
125
+ // in its surfaces; unchanged pending a lockup audit there.
126
+ size === "lg" ? MONOGRAM_TEXT_LG : "text-[11px]",
110
127
  box,
111
128
  className
112
129
  ),
113
- style: { backgroundColor: brand.color || monogramColor(brand.name || "?") },
114
- children: initials(brand.name)
130
+ style: { color: brand.color || monogramColor(brand.name || "?") },
131
+ children: [
132
+ letter,
133
+ hasRealLetter && /* @__PURE__ */ jsx("span", { "data-testid": "brand-monogram-dot", style: { color: brand.accentColor || REEVE_ACCENT }, children: "." })
134
+ ]
115
135
  }
116
136
  );
117
137
  }
118
138
 
119
139
  // src/responsive-header.tsx
120
- import { useEffect, useId, useRef, useState as useState2 } from "react";
140
+ import { useEffect as useEffect2, useId, useRef, useState as useState3 } from "react";
121
141
  import { Menu, X } from "lucide-react";
122
142
 
123
- // src/lib/use-media-query.ts
124
- import { useCallback, useSyncExternalStore } from "react";
125
- function useMediaQuery(query) {
126
- const subscribe = useCallback(
127
- (onChange) => {
128
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
129
- return () => {
130
- };
131
- }
132
- const mql = window.matchMedia(query);
133
- mql.addEventListener("change", onChange);
134
- return () => mql.removeEventListener("change", onChange);
135
- },
136
- [query]
137
- );
138
- const getSnapshot = () => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(query).matches : false;
139
- return useSyncExternalStore(subscribe, getSnapshot, () => false);
143
+ // src/lib/use-container-width-at-least.ts
144
+ import { useEffect, useState as useState2 } from "react";
145
+ function useContainerWidthAtLeast(ref, px) {
146
+ const [atLeast, setAtLeast] = useState2(false);
147
+ useEffect(() => {
148
+ const el = ref.current;
149
+ if (!el || typeof ResizeObserver === "undefined") return;
150
+ const ro = new ResizeObserver(([entry]) => {
151
+ if (!entry) return;
152
+ setAtLeast(entry.contentRect.width >= px);
153
+ });
154
+ ro.observe(el);
155
+ return () => ro.disconnect();
156
+ }, [ref, px]);
157
+ return atLeast;
140
158
  }
141
159
 
142
160
  // src/tokens.ts
@@ -156,24 +174,30 @@ var BREAKPOINTS = {
156
174
  /** Tailwind `md` — the header switches to its full desktop layout at/above this. */
157
175
  md: 768
158
176
  };
177
+ var TOUCH_TARGET_STYLE = {
178
+ minWidth: MIN_SIZES.touchTarget,
179
+ minHeight: MIN_SIZES.touchTarget
180
+ };
159
181
 
160
182
  // src/responsive-header.tsx
161
- import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
183
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
162
184
  function MenuIcon({ open }) {
163
185
  return open ? /* @__PURE__ */ jsx2(X, { className: "h-5 w-5", "aria-hidden": "true" }) : /* @__PURE__ */ jsx2(Menu, { className: "h-5 w-5", "aria-hidden": "true" });
164
186
  }
165
187
  function SecondaryControls({
166
188
  nav,
167
189
  auth,
168
- themeToggle
190
+ themeToggle,
191
+ linkComponent
169
192
  }) {
170
- return /* @__PURE__ */ jsxs(Fragment, { children: [
193
+ const LinkEl = linkComponent ?? "a";
194
+ return /* @__PURE__ */ jsxs2(Fragment, { children: [
171
195
  nav?.map(
172
196
  (item) => (
173
197
  // A link navigates; an action-only item (no href) is a button — never an
174
198
  // <a href="#"> that scroll-jumps and pushes a history entry.
175
199
  item.href ? /* @__PURE__ */ jsx2(
176
- "a",
200
+ LinkEl,
177
201
  {
178
202
  href: item.href,
179
203
  onClick: item.onSelect,
@@ -181,15 +205,22 @@ function SecondaryControls({
181
205
  children: item.label
182
206
  },
183
207
  item.label
184
- ) : /* @__PURE__ */ jsx2(
185
- "button",
186
- {
187
- type: "button",
188
- onClick: item.onSelect,
189
- className: "text-left text-sm font-medium text-foreground/80 hover:text-foreground",
190
- children: item.label
191
- },
192
- item.label
208
+ ) : (
209
+ // A real <button> secondary control — MIN_SIZES.control is the
210
+ // token's exact stated purpose ("Generic minimum width/height for
211
+ // a secondary control (button, select)"), previously defined but
212
+ // unused anywhere in this package (DEV-4249).
213
+ /* @__PURE__ */ jsx2(
214
+ "button",
215
+ {
216
+ type: "button",
217
+ onClick: item.onSelect,
218
+ style: { minWidth: MIN_SIZES.control, minHeight: MIN_SIZES.control },
219
+ className: "flex items-center justify-start text-left text-sm font-medium text-foreground/80 hover:text-foreground",
220
+ children: item.label
221
+ },
222
+ item.label
223
+ )
193
224
  )
194
225
  )
195
226
  ),
@@ -201,20 +232,24 @@ function ResponsiveHeader({
201
232
  brand,
202
233
  search,
203
234
  nav,
235
+ brandHref,
236
+ linkComponent,
204
237
  auth,
205
238
  themeToggle,
206
239
  menuLabel = "Menu",
207
240
  className
208
241
  }) {
209
- const isDesktop = useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
210
- const [open, setOpen] = useState2(false);
242
+ const LinkEl = linkComponent ?? "a";
243
+ const [open, setOpen] = useState3(false);
211
244
  const panelId = useId();
245
+ const headerRef = useRef(null);
212
246
  const buttonRef = useRef(null);
213
247
  const panelRef = useRef(null);
214
- useEffect(() => {
215
- if (isDesktop) setOpen(false);
216
- }, [isDesktop]);
217
- useEffect(() => {
248
+ const containerAtOrAboveMd = useContainerWidthAtLeast(headerRef, BREAKPOINTS.md);
249
+ useEffect2(() => {
250
+ if (containerAtOrAboveMd) setOpen(false);
251
+ }, [containerAtOrAboveMd]);
252
+ useEffect2(() => {
218
253
  if (!open) return;
219
254
  const onKey = (e) => {
220
255
  if (e.key === "Escape") {
@@ -236,27 +271,41 @@ function ResponsiveHeader({
236
271
  };
237
272
  }, [open]);
238
273
  const hasSecondary = Boolean(nav && nav.length > 0 || auth || themeToggle);
239
- return /* @__PURE__ */ jsxs(
274
+ const brandLockup = /* @__PURE__ */ jsxs2("span", { "data-testid": "header-lockup", className: cn(LOCKUP_ROW, LOCKUP_GAP), children: [
275
+ /* @__PURE__ */ jsx2(BrandGlyph, { brand, size: "lg" }),
276
+ /* @__PURE__ */ jsx2(
277
+ "span",
278
+ {
279
+ "data-testid": "header-wordmark",
280
+ className: "hidden truncate text-sm font-semibold text-foreground @min-[768px]:block",
281
+ style: { maxWidth: MIN_SIZES.wordmarkMaxWidth },
282
+ children: brand.name
283
+ }
284
+ )
285
+ ] });
286
+ return /* @__PURE__ */ jsxs2(
240
287
  "header",
241
288
  {
289
+ ref: headerRef,
242
290
  "data-testid": "responsive-header",
243
291
  className: cn(
244
- "flex w-full items-center gap-3 border-b border-border bg-background px-4 py-2",
292
+ // `@container` makes this header itself the query context, so the
293
+ // variants below react to ITS width (e.g. a sidebar), not the viewport.
294
+ "@container flex w-full items-center gap-3 border-b border-border bg-background px-4 py-2",
245
295
  className
246
296
  ),
247
297
  children: [
248
- /* @__PURE__ */ jsxs("div", { "data-testid": "header-brand", className: "flex shrink-0 items-center gap-2", children: [
249
- /* @__PURE__ */ jsx2(BrandGlyph, { brand, size: "lg" }),
250
- isDesktop && /* @__PURE__ */ jsx2(
251
- "span",
252
- {
253
- "data-testid": "header-wordmark",
254
- className: "truncate text-sm font-semibold text-foreground",
255
- style: { maxWidth: MIN_SIZES.wordmarkMaxWidth },
256
- children: brand.name
257
- }
258
- )
259
- ] }),
298
+ brandHref ? /* @__PURE__ */ jsx2(
299
+ LinkEl,
300
+ {
301
+ href: brandHref,
302
+ "aria-label": brand.name || "Home",
303
+ "data-testid": "header-brand",
304
+ style: TOUCH_TARGET_STYLE,
305
+ className: "flex shrink-0 items-center",
306
+ children: brandLockup
307
+ }
308
+ ) : /* @__PURE__ */ jsx2("div", { "data-testid": "header-brand", className: "flex shrink-0 items-center", children: brandLockup }),
260
309
  /* @__PURE__ */ jsx2(
261
310
  "div",
262
311
  {
@@ -266,40 +315,91 @@ function ResponsiveHeader({
266
315
  children: search
267
316
  }
268
317
  ),
269
- hasSecondary && (isDesktop ? /* @__PURE__ */ jsx2("nav", { "data-testid": "header-actions", className: "flex shrink-0 items-center gap-3", children: /* @__PURE__ */ jsx2(SecondaryControls, { nav, auth, themeToggle }) }) : /* @__PURE__ */ jsxs("div", { className: "relative shrink-0", children: [
318
+ hasSecondary && /* @__PURE__ */ jsxs2(Fragment, { children: [
270
319
  /* @__PURE__ */ jsx2(
271
- "button",
320
+ "nav",
272
321
  {
273
- ref: buttonRef,
274
- type: "button",
275
- "data-testid": "header-menu-button",
276
- "aria-label": menuLabel,
277
- "aria-expanded": open,
278
- "aria-controls": panelId,
279
- onClick: () => setOpen((v) => !v),
280
- className: "flex items-center justify-center rounded-md border border-border text-foreground",
281
- style: { minWidth: MIN_SIZES.touchTarget, minHeight: MIN_SIZES.touchTarget },
282
- children: /* @__PURE__ */ jsx2(MenuIcon, { open })
322
+ "data-testid": "header-actions",
323
+ className: "hidden shrink-0 items-center gap-3 @min-[768px]:flex",
324
+ children: /* @__PURE__ */ jsx2(
325
+ SecondaryControls,
326
+ {
327
+ nav,
328
+ auth,
329
+ themeToggle,
330
+ linkComponent
331
+ }
332
+ )
283
333
  }
284
334
  ),
285
- open && /* @__PURE__ */ jsx2(
286
- "div",
287
- {
288
- ref: panelRef,
289
- id: panelId,
290
- "data-testid": "header-menu-panel",
291
- 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",
292
- children: /* @__PURE__ */ jsx2(SecondaryControls, { nav, auth, themeToggle })
293
- }
294
- )
295
- ] }))
335
+ /* @__PURE__ */ jsxs2("div", { className: "relative shrink-0 @min-[768px]:hidden", children: [
336
+ /* @__PURE__ */ jsx2(
337
+ "button",
338
+ {
339
+ ref: buttonRef,
340
+ type: "button",
341
+ "data-testid": "header-menu-button",
342
+ "aria-label": menuLabel,
343
+ "aria-expanded": open,
344
+ "aria-controls": panelId,
345
+ onClick: () => setOpen((v) => !v),
346
+ className: "flex items-center justify-center rounded-md border border-border text-foreground",
347
+ style: TOUCH_TARGET_STYLE,
348
+ children: /* @__PURE__ */ jsx2(MenuIcon, { open })
349
+ }
350
+ ),
351
+ open && /* @__PURE__ */ jsx2(
352
+ "div",
353
+ {
354
+ ref: panelRef,
355
+ id: panelId,
356
+ "data-testid": "header-menu-panel",
357
+ 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",
358
+ children: /* @__PURE__ */ jsx2(
359
+ SecondaryControls,
360
+ {
361
+ nav,
362
+ auth,
363
+ themeToggle,
364
+ linkComponent
365
+ }
366
+ )
367
+ }
368
+ )
369
+ ] })
370
+ ] })
296
371
  ]
297
372
  }
298
373
  );
299
374
  }
300
375
 
376
+ // src/lib/use-media-query.ts
377
+ import { useCallback, useSyncExternalStore } from "react";
378
+ function useMediaQuery(query) {
379
+ const subscribe = useCallback(
380
+ (onChange) => {
381
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
382
+ return () => {
383
+ };
384
+ }
385
+ const mql = window.matchMedia(query);
386
+ mql.addEventListener("change", onChange);
387
+ return () => mql.removeEventListener("change", onChange);
388
+ },
389
+ [query]
390
+ );
391
+ const getSnapshot = () => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(query).matches : false;
392
+ return useSyncExternalStore(subscribe, getSnapshot, () => false);
393
+ }
394
+
395
+ // src/use-responsive-placeholder.ts
396
+ function useResponsivePlaceholder(desktop, mobile) {
397
+ const isDesktop = useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
398
+ return isDesktop ? desktop : mobile ?? desktop;
399
+ }
400
+
301
401
  // src/app-sidebar.tsx
302
- import { useEffect as useEffect4, useId as useId3, useMemo, useRef as useRef4, useState as useState5 } from "react";
402
+ import { useEffect as useEffect5, useId as useId3, useMemo, useRef as useRef4, useState as useState6 } from "react";
303
403
  import { Menu as Menu2, X as X2 } from "lucide-react";
304
404
 
305
405
  // src/lib/resolve-icon.ts
@@ -320,7 +420,7 @@ function resolveIcon(name) {
320
420
  }
321
421
 
322
422
  // src/lib/use-dismissable-disclosure.ts
323
- import { useEffect as useEffect2 } from "react";
423
+ import { useEffect as useEffect3 } from "react";
324
424
  function useDismissableDisclosure({
325
425
  open,
326
426
  onClose,
@@ -328,10 +428,10 @@ function useDismissableDisclosure({
328
428
  triggerRef,
329
429
  initialFocusRef
330
430
  }) {
331
- useEffect2(() => {
431
+ useEffect3(() => {
332
432
  if (open) initialFocusRef?.current?.focus();
333
433
  }, [open]);
334
- useEffect2(() => {
434
+ useEffect3(() => {
335
435
  if (!open) return;
336
436
  const onKey = (e) => {
337
437
  if (e.key === "Escape") {
@@ -534,13 +634,13 @@ function resolveLadder(raw, registry, groupOrder) {
534
634
  }
535
635
 
536
636
  // src/ladder/use-ladder-rung.ts
537
- import { useLayoutEffect, useRef as useRef2, useState as useState3 } from "react";
637
+ import { useLayoutEffect, useRef as useRef2, useState as useState4 } from "react";
538
638
  var DEFAULT_ROW_HEIGHT = 36;
539
639
  var DEFAULT_HYSTERESIS_ROWS = 1;
540
640
  function useLadderRung(ladder, containerRef, options = {}) {
541
641
  const rowHeight = options.rowHeight ?? DEFAULT_ROW_HEIGHT;
542
642
  const hysteresisRows = options.hysteresisRows ?? DEFAULT_HYSTERESIS_ROWS;
543
- const [rungIndex, setRungIndex] = useState3(0);
643
+ const [rungIndex, setRungIndex] = useState4(0);
544
644
  const rungIndexRef = useRef2(0);
545
645
  const ladderSignature = `${ladder.version}:${ladder.rungs.map((r) => r.rows).join(",")}`;
546
646
  useLayoutEffect(() => {
@@ -579,13 +679,13 @@ function useLadderRung(ladder, containerRef, options = {}) {
579
679
  }
580
680
 
581
681
  // src/ladder/flyout.tsx
582
- import { useCallback as useCallback2, useEffect as useEffect3, useId as useId2, useLayoutEffect as useLayoutEffect2, useRef as useRef3, useState as useState4 } from "react";
583
- import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
682
+ import { useCallback as useCallback2, useEffect as useEffect4, useId as useId2, useLayoutEffect as useLayoutEffect2, useRef as useRef3, useState as useState5 } from "react";
683
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
584
684
  var PANEL_GAP = 4;
585
685
  function Flyout({ label, icon, active, children, className }) {
586
- const [hoverOpen, setHoverOpen] = useState4(false);
587
- const [clickOpen, setClickOpen] = useState4(false);
588
- const [panelPosition, setPanelPosition] = useState4(null);
686
+ const [hoverOpen, setHoverOpen] = useState5(false);
687
+ const [clickOpen, setClickOpen] = useState5(false);
688
+ const [panelPosition, setPanelPosition] = useState5(null);
589
689
  const open = hoverOpen || clickOpen;
590
690
  const panelId = useId2();
591
691
  const triggerRef = useRef3(null);
@@ -600,7 +700,7 @@ function Flyout({ label, icon, active, children, className }) {
600
700
  useLayoutEffect2(() => {
601
701
  if (open) reposition();
602
702
  }, [open, reposition]);
603
- useEffect3(() => {
703
+ useEffect4(() => {
604
704
  if (!open) return;
605
705
  window.addEventListener("resize", reposition);
606
706
  window.addEventListener("scroll", reposition, true);
@@ -618,10 +718,10 @@ function Flyout({ label, icon, active, children, className }) {
618
718
  panelRef,
619
719
  triggerRef
620
720
  });
621
- useEffect3(() => {
721
+ useEffect4(() => {
622
722
  if (clickOpen && panelPosition) firstItemRef.current?.focus();
623
723
  }, [clickOpen, panelPosition]);
624
- return /* @__PURE__ */ jsxs2(
724
+ return /* @__PURE__ */ jsxs3(
625
725
  "div",
626
726
  {
627
727
  "data-testid": "ladder-flyout",
@@ -629,7 +729,7 @@ function Flyout({ label, icon, active, children, className }) {
629
729
  onMouseEnter: () => setHoverOpen(true),
630
730
  onMouseLeave: () => setHoverOpen(false),
631
731
  children: [
632
- /* @__PURE__ */ jsxs2(
732
+ /* @__PURE__ */ jsxs3(
633
733
  "button",
634
734
  {
635
735
  ref: triggerRef,
@@ -672,8 +772,9 @@ function Flyout({ label, icon, active, children, className }) {
672
772
  ref: i === 0 ? firstItemRef : void 0,
673
773
  href: child.href,
674
774
  "aria-current": child.active ? "page" : void 0,
775
+ style: TOUCH_TARGET_STYLE,
675
776
  className: cn(
676
- "truncate rounded-md px-3 py-1.5 text-sm transition-colors",
777
+ "flex items-center truncate rounded-md px-3 py-1.5 text-sm transition-colors",
677
778
  child.active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
678
779
  ),
679
780
  children: child.label
@@ -688,7 +789,7 @@ function Flyout({ label, icon, active, children, className }) {
688
789
  }
689
790
 
690
791
  // src/app-sidebar.tsx
691
- import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
792
+ import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
692
793
  function isHrefActive(activePath, href) {
693
794
  return activePath === href || activePath.startsWith(`${href}/`);
694
795
  }
@@ -700,7 +801,7 @@ function NavLink({
700
801
  testId
701
802
  }) {
702
803
  const Icon = resolveIcon(icon);
703
- return /* @__PURE__ */ jsxs3(
804
+ return /* @__PURE__ */ jsxs4(
704
805
  "a",
705
806
  {
706
807
  href,
@@ -736,7 +837,7 @@ function AppSidebar({
736
837
  className
737
838
  }) {
738
839
  const isDesktop = useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
739
- const [mobileOpen, setMobileOpen] = useState5(false);
840
+ const [mobileOpen, setMobileOpen] = useState6(false);
740
841
  const containerRef = useRef4(null);
741
842
  const drawerId = useId3();
742
843
  const mobileTriggerRef = useRef4(null);
@@ -753,7 +854,7 @@ function AppSidebar({
753
854
  );
754
855
  const rung = useLadderRung(resolvedLadder, containerRef, ladderOptions);
755
856
  const rung0 = resolvedLadder.rungs[0];
756
- useEffect4(() => {
857
+ useEffect5(() => {
757
858
  if (isDesktop) setMobileOpen(false);
758
859
  }, [isDesktop]);
759
860
  useDismissableDisclosure({
@@ -792,7 +893,7 @@ function AppSidebar({
792
893
  const active = children.some((c) => c.active);
793
894
  return /* @__PURE__ */ jsx4(Flyout, { label: item.label, icon: item.icon, active, children }, item.id);
794
895
  }
795
- const brandBlock = brand && /* @__PURE__ */ jsxs3(
896
+ const brandBlock = brand && /* @__PURE__ */ jsxs4(
796
897
  "a",
797
898
  {
798
899
  href: brand.href,
@@ -817,7 +918,7 @@ function AppSidebar({
817
918
  }
818
919
  ) });
819
920
  }
820
- return /* @__PURE__ */ jsxs3(Fragment2, { children: [
921
+ return /* @__PURE__ */ jsxs4(Fragment2, { children: [
821
922
  !isDesktop && /* @__PURE__ */ jsx4(
822
923
  "button",
823
924
  {
@@ -828,11 +929,12 @@ function AppSidebar({
828
929
  "aria-expanded": mobileOpen,
829
930
  "aria-controls": drawerId,
830
931
  onClick: () => setMobileOpen((v) => !v),
932
+ style: TOUCH_TARGET_STYLE,
831
933
  className: "flex items-center justify-center rounded-md border border-border p-2 text-foreground",
832
934
  children: mobileOpen ? /* @__PURE__ */ jsx4(X2, { className: "h-5 w-5", "aria-hidden": "true" }) : /* @__PURE__ */ jsx4(Menu2, { className: "h-5 w-5", "aria-hidden": "true" })
833
935
  }
834
936
  ),
835
- !isDesktop && mobileOpen && /* @__PURE__ */ jsxs3("div", { className: "fixed inset-0 z-50", children: [
937
+ !isDesktop && mobileOpen && /* @__PURE__ */ jsxs4("div", { className: "fixed inset-0 z-50", children: [
836
938
  /* @__PURE__ */ jsx4(
837
939
  "div",
838
940
  {
@@ -842,7 +944,7 @@ function AppSidebar({
842
944
  onClick: () => setMobileOpen(false)
843
945
  }
844
946
  ),
845
- /* @__PURE__ */ jsxs3(
947
+ /* @__PURE__ */ jsxs4(
846
948
  "div",
847
949
  {
848
950
  ref: mobileDrawerRef,
@@ -861,6 +963,7 @@ function AppSidebar({
861
963
  "data-testid": "app-sidebar-mobile-close",
862
964
  "aria-label": `Close ${menuLabel.toLowerCase()}`,
863
965
  onClick: () => setMobileOpen(false),
966
+ style: TOUCH_TARGET_STYLE,
864
967
  className: "flex items-center justify-center rounded-md p-2 text-foreground hover:bg-foreground/5",
865
968
  children: /* @__PURE__ */ jsx4(X2, { className: "h-5 w-5", "aria-hidden": "true" })
866
969
  }
@@ -874,7 +977,7 @@ function AppSidebar({
874
977
  }
875
978
  )
876
979
  ] }),
877
- /* @__PURE__ */ jsxs3(
980
+ /* @__PURE__ */ jsxs4(
878
981
  "aside",
879
982
  {
880
983
  "data-testid": "app-sidebar-desktop",
@@ -896,15 +999,197 @@ function AppSidebar({
896
999
  )
897
1000
  ] });
898
1001
  }
1002
+
1003
+ // src/context-switcher.tsx
1004
+ import { ChevronDown, Check } from "lucide-react";
1005
+
1006
+ // src/lib/group-by-org.ts
1007
+ function groupItemsByOrg(rows, get) {
1008
+ const byOrg = /* @__PURE__ */ new Map();
1009
+ for (const row of rows) {
1010
+ const key = get.orgId(row) ?? "";
1011
+ let group = byOrg.get(key);
1012
+ if (!group) {
1013
+ group = {
1014
+ orgId: get.orgId(row) ?? null,
1015
+ orgName: get.orgName(row) ?? null,
1016
+ items: []
1017
+ };
1018
+ byOrg.set(key, group);
1019
+ }
1020
+ group.items.push(row);
1021
+ }
1022
+ const groups = [...byOrg.values()];
1023
+ for (const g of groups) {
1024
+ g.items.sort((a, b) => get.name(a).localeCompare(get.name(b)));
1025
+ }
1026
+ groups.sort((a, b) => (a.orgName ?? "").localeCompare(b.orgName ?? ""));
1027
+ return groups;
1028
+ }
1029
+
1030
+ // src/lib/dropdown-menu.tsx
1031
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
1032
+ import { jsx as jsx5 } from "react/jsx-runtime";
1033
+ function DropdownMenu(props) {
1034
+ return /* @__PURE__ */ jsx5(DropdownMenuPrimitive.Root, { "data-slot": "dropdown-menu", ...props });
1035
+ }
1036
+ function DropdownMenuTrigger(props) {
1037
+ return /* @__PURE__ */ jsx5(DropdownMenuPrimitive.Trigger, { "data-slot": "dropdown-menu-trigger", ...props });
1038
+ }
1039
+ function DropdownMenuContent({
1040
+ className,
1041
+ sideOffset = 4,
1042
+ ...props
1043
+ }) {
1044
+ return /* @__PURE__ */ jsx5(DropdownMenuPrimitive.Portal, { children: /* @__PURE__ */ jsx5(
1045
+ DropdownMenuPrimitive.Content,
1046
+ {
1047
+ "data-slot": "dropdown-menu-content",
1048
+ sideOffset,
1049
+ className: cn(
1050
+ "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",
1051
+ className
1052
+ ),
1053
+ ...props
1054
+ }
1055
+ ) });
1056
+ }
1057
+ function DropdownMenuItem({
1058
+ className,
1059
+ ...props
1060
+ }) {
1061
+ return /* @__PURE__ */ jsx5(
1062
+ DropdownMenuPrimitive.Item,
1063
+ {
1064
+ "data-slot": "dropdown-menu-item",
1065
+ className: cn(
1066
+ "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",
1067
+ className
1068
+ ),
1069
+ ...props
1070
+ }
1071
+ );
1072
+ }
1073
+ function DropdownMenuLabel({
1074
+ className,
1075
+ ...props
1076
+ }) {
1077
+ return /* @__PURE__ */ jsx5(
1078
+ DropdownMenuPrimitive.Label,
1079
+ {
1080
+ "data-slot": "dropdown-menu-label",
1081
+ className: cn("px-2 py-1.5 text-sm font-medium", className),
1082
+ ...props
1083
+ }
1084
+ );
1085
+ }
1086
+
1087
+ // src/context-switcher.tsx
1088
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
1089
+ function groupByOrg(items) {
1090
+ return groupItemsByOrg(items, {
1091
+ orgId: (it) => it.orgId,
1092
+ orgName: (it) => it.orgName,
1093
+ name: (it) => it.name
1094
+ });
1095
+ }
1096
+ function toBrand(item) {
1097
+ return {
1098
+ name: item.name,
1099
+ favicon: item.favicon,
1100
+ logo: item.logo,
1101
+ color: item.color,
1102
+ accentColor: item.accentColor
1103
+ };
1104
+ }
1105
+ function ContextSwitcher({
1106
+ items,
1107
+ activeRefId,
1108
+ onSelect,
1109
+ footer,
1110
+ placeholder = "Select",
1111
+ collapsed = false,
1112
+ side = "bottom",
1113
+ align = "start",
1114
+ contentClassName
1115
+ }) {
1116
+ const active = items.find((it) => it.refId === activeRefId) ?? null;
1117
+ const groups = groupByOrg(items);
1118
+ const showOrgHeaders = groups.length > 1;
1119
+ return /* @__PURE__ */ jsxs5(DropdownMenu, { children: [
1120
+ /* @__PURE__ */ jsx6(DropdownMenuTrigger, { asChild: true, children: collapsed ? /* @__PURE__ */ jsx6(
1121
+ "button",
1122
+ {
1123
+ type: "button",
1124
+ title: active?.name || placeholder,
1125
+ "aria-label": active?.name || placeholder,
1126
+ 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",
1127
+ children: /* @__PURE__ */ jsx6(BrandGlyph, { brand: toBrand(active ?? { name: "" }), size: "lg" })
1128
+ }
1129
+ ) : /* @__PURE__ */ jsxs5(
1130
+ "button",
1131
+ {
1132
+ type: "button",
1133
+ 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",
1134
+ children: [
1135
+ /* @__PURE__ */ jsx6(BrandGlyph, { brand: toBrand(active ?? { name: "" }) }),
1136
+ /* @__PURE__ */ jsx6("span", { className: "max-w-[140px] truncate text-foreground/90", children: active?.name || placeholder }),
1137
+ /* @__PURE__ */ jsx6(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" })
1138
+ ]
1139
+ }
1140
+ ) }),
1141
+ /* @__PURE__ */ jsxs5(
1142
+ DropdownMenuContent,
1143
+ {
1144
+ side,
1145
+ align,
1146
+ sideOffset: 6,
1147
+ collisionPadding: 8,
1148
+ className: cn("w-60 overflow-hidden p-0", contentClassName),
1149
+ children: [
1150
+ groups.map((group) => /* @__PURE__ */ jsxs5("div", { children: [
1151
+ showOrgHeaders && /* @__PURE__ */ jsx6(DropdownMenuLabel, { className: "px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground", children: group.orgName ?? "Other" }),
1152
+ /* @__PURE__ */ jsx6("div", { className: cn(showOrgHeaders && "ml-5 border-l border-border/60"), children: group.items.map((item) => {
1153
+ const isActive = item.refId === activeRefId;
1154
+ return /* @__PURE__ */ jsxs5(
1155
+ DropdownMenuItem,
1156
+ {
1157
+ "data-active": isActive || void 0,
1158
+ onSelect: () => onSelect(item),
1159
+ className: "flex w-full items-center gap-2.5 rounded-none px-3 py-2 text-sm",
1160
+ children: [
1161
+ /* @__PURE__ */ jsx6(BrandGlyph, { brand: toBrand(item), subtle: true }),
1162
+ /* @__PURE__ */ jsx6("span", { className: "flex-1 truncate text-left text-foreground/90", children: item.name }),
1163
+ isActive && /* @__PURE__ */ jsx6(Check, { className: "size-3.5 text-emerald-500" })
1164
+ ]
1165
+ },
1166
+ item.refId
1167
+ );
1168
+ }) })
1169
+ ] }, group.orgId ?? "unattributed")),
1170
+ footer && /* @__PURE__ */ jsx6("div", { className: "sticky bottom-0 border-t border-border bg-popover", children: footer })
1171
+ ]
1172
+ }
1173
+ )
1174
+ ] });
1175
+ }
899
1176
  export {
900
1177
  AppSidebar,
901
1178
  BrandGlyph,
1179
+ ContextSwitcher,
902
1180
  Flyout,
1181
+ GLYPH_CANVAS_FILL,
1182
+ LOCKUP_GAP,
1183
+ LOCKUP_ROW,
1184
+ MARK_CAP_RATIO,
1185
+ REEVE_ACCENT,
903
1186
  ResponsiveHeader,
904
1187
  deriveFallbackLadder,
905
1188
  faviconUrl,
1189
+ groupByOrg,
906
1190
  reconcileLadder,
907
1191
  resolveLadder,
908
1192
  useLadderRung,
1193
+ useResponsivePlaceholder,
909
1194
  zSidebarLadder
910
1195
  };