@meetreeve/ui 0.6.3 → 0.7.1

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.d.mts CHANGED
@@ -42,7 +42,14 @@ interface Brand {
42
42
  declare function faviconUrl(domain: string | null | undefined): string | null;
43
43
  interface BrandGlyphProps {
44
44
  brand: Brand;
45
- size?: "sm" | "lg";
45
+ /**
46
+ * "sm"/"lg" are lockup sizes (a wordmark sits alongside the glyph
47
+ * elsewhere in the same surface — see MARK_CAP_RATIO). "xl" is for a
48
+ * bare, standalone glyph slot with no adjacent wordmark at all (e.g. a
49
+ * collapsed 56px icon rail, DEV-5331) — governed by GLYPH_CANVAS_FILL
50
+ * instead, same rule a favicon/avatar tile follows.
51
+ */
52
+ size?: "sm" | "lg" | "xl";
46
53
  /** Slightly lighter tint behind image sources (e.g. dropdown rows). */
47
54
  subtle?: boolean;
48
55
  className?: string;
@@ -341,6 +348,20 @@ interface AppSidebarProps {
341
348
  * `/studio` should NOT light up on `/studio/photo`).
342
349
  */
343
350
  isActive?: (href: string, activePath: string) => boolean;
351
+ /**
352
+ * Whether the desktop rail can collapse to a 56px icon rail (DEV-5331,
353
+ * porting AppNavRail's click-to-pin model into the package). Default
354
+ * `true`. `false` hides the toggle entirely — the rail then just stays at
355
+ * whatever `defaultCollapsed` says, with no way for the user to change it.
356
+ * Mobile is never affected either way (collapse is desktop-only).
357
+ */
358
+ collapsible?: boolean;
359
+ /** Initial collapsed state before any persisted value is read. Default `false`. */
360
+ defaultCollapsed?: boolean;
361
+ /** Fires with the new collapsed value whenever the user toggles the rail. */
362
+ onCollapsedChange?: (collapsed: boolean) => void;
363
+ /** localStorage key for the persisted collapsed preference. Default `"reeve-sidebar-collapsed"`. */
364
+ storageKey?: string;
344
365
  /** Merged onto the desktop `<aside>` itself — there is no extra wrapping
345
366
  * element (see the render note below on why that matters for height). */
346
367
  className?: string;
@@ -355,7 +376,7 @@ interface AppSidebarProps {
355
376
  * dependency, matching ResponsiveHeader's existing disclosure pattern in
356
377
  * this same package.
357
378
  */
358
- declare function AppSidebar({ ladder, modules, groupOrder, activePath, brand, topItem, menuLabel, ladderOptions, linkComponent, isActive, className, }: AppSidebarProps): react.JSX.Element;
379
+ declare function AppSidebar({ ladder, modules, groupOrder, activePath, brand, topItem, menuLabel, ladderOptions, linkComponent, isActive, collapsible, defaultCollapsed, onCollapsedChange, storageKey, className, }: AppSidebarProps): react.JSX.Element;
359
380
 
360
381
  /**
361
382
  * Minimal shape AppSidebar's fallback derivation needs from a module registry
@@ -436,6 +457,29 @@ interface FlyoutProps {
436
457
  * mirrors ResponsiveHeader's `linkComponent` prop (DEV-4429) exactly.
437
458
  */
438
459
  linkComponent?: ElementType;
460
+ /**
461
+ * Icon-only trigger for AppSidebar's collapsed (56px) rail (DEV-5331): the
462
+ * label collapses to a `sr-only` span (kept in the a11y tree) plus a native
463
+ * `title` tooltip — the package has no tooltip primitive and adding one
464
+ * (a Radix dep) is explicitly out of scope. The panel's own child rows are
465
+ * unaffected — they always show full labels regardless of this flag.
466
+ */
467
+ collapsed?: boolean;
468
+ /**
469
+ * Sibling-coordination hooks (DEV-5331 fix — Matt's live review found two
470
+ * folded-group flyouts open + overlapping at once, since each Flyout had
471
+ * zero awareness of its siblings). `onOpenChange` reports every open/
472
+ * closed transition of THIS instance's own hover/click intent — reported
473
+ * even while `forceClosed` is currently suppressing it, so a parent
474
+ * coordinator can learn "this one wants to open" and hand it the single
475
+ * active slot. `forceClosed` is set by that coordinator to immediately
476
+ * hide THIS instance (and reset its internal hover/click state) the
477
+ * instant a different sibling becomes the active one. Both optional and
478
+ * independent of each other — omitted, Flyout is fully standalone (its
479
+ * own state, unaware of any siblings), exactly as before.
480
+ */
481
+ onOpenChange?: (open: boolean) => void;
482
+ forceClosed?: boolean;
439
483
  className?: string;
440
484
  }
441
485
  /**
@@ -473,7 +517,7 @@ interface FlyoutProps {
473
517
  * than working around it. jsdom never computes real scrollable-overflow
474
518
  * geometry, so no unit test caught this — only an actual browser render did.
475
519
  */
476
- declare function Flyout({ label, icon, active, children, linkComponent, className }: FlyoutProps): react.JSX.Element;
520
+ declare function Flyout({ label, icon, active, children, linkComponent, collapsed, onOpenChange, forceClosed, className, }: FlyoutProps): react.JSX.Element;
477
521
 
478
522
  /**
479
523
  * ContextSwitcher (DEV-4408) — promoted out of reeve-frontend's
package/dist/index.d.ts CHANGED
@@ -42,7 +42,14 @@ interface Brand {
42
42
  declare function faviconUrl(domain: string | null | undefined): string | null;
43
43
  interface BrandGlyphProps {
44
44
  brand: Brand;
45
- size?: "sm" | "lg";
45
+ /**
46
+ * "sm"/"lg" are lockup sizes (a wordmark sits alongside the glyph
47
+ * elsewhere in the same surface — see MARK_CAP_RATIO). "xl" is for a
48
+ * bare, standalone glyph slot with no adjacent wordmark at all (e.g. a
49
+ * collapsed 56px icon rail, DEV-5331) — governed by GLYPH_CANVAS_FILL
50
+ * instead, same rule a favicon/avatar tile follows.
51
+ */
52
+ size?: "sm" | "lg" | "xl";
46
53
  /** Slightly lighter tint behind image sources (e.g. dropdown rows). */
47
54
  subtle?: boolean;
48
55
  className?: string;
@@ -341,6 +348,20 @@ interface AppSidebarProps {
341
348
  * `/studio` should NOT light up on `/studio/photo`).
342
349
  */
343
350
  isActive?: (href: string, activePath: string) => boolean;
351
+ /**
352
+ * Whether the desktop rail can collapse to a 56px icon rail (DEV-5331,
353
+ * porting AppNavRail's click-to-pin model into the package). Default
354
+ * `true`. `false` hides the toggle entirely — the rail then just stays at
355
+ * whatever `defaultCollapsed` says, with no way for the user to change it.
356
+ * Mobile is never affected either way (collapse is desktop-only).
357
+ */
358
+ collapsible?: boolean;
359
+ /** Initial collapsed state before any persisted value is read. Default `false`. */
360
+ defaultCollapsed?: boolean;
361
+ /** Fires with the new collapsed value whenever the user toggles the rail. */
362
+ onCollapsedChange?: (collapsed: boolean) => void;
363
+ /** localStorage key for the persisted collapsed preference. Default `"reeve-sidebar-collapsed"`. */
364
+ storageKey?: string;
344
365
  /** Merged onto the desktop `<aside>` itself — there is no extra wrapping
345
366
  * element (see the render note below on why that matters for height). */
346
367
  className?: string;
@@ -355,7 +376,7 @@ interface AppSidebarProps {
355
376
  * dependency, matching ResponsiveHeader's existing disclosure pattern in
356
377
  * this same package.
357
378
  */
358
- declare function AppSidebar({ ladder, modules, groupOrder, activePath, brand, topItem, menuLabel, ladderOptions, linkComponent, isActive, className, }: AppSidebarProps): react.JSX.Element;
379
+ declare function AppSidebar({ ladder, modules, groupOrder, activePath, brand, topItem, menuLabel, ladderOptions, linkComponent, isActive, collapsible, defaultCollapsed, onCollapsedChange, storageKey, className, }: AppSidebarProps): react.JSX.Element;
359
380
 
360
381
  /**
361
382
  * Minimal shape AppSidebar's fallback derivation needs from a module registry
@@ -436,6 +457,29 @@ interface FlyoutProps {
436
457
  * mirrors ResponsiveHeader's `linkComponent` prop (DEV-4429) exactly.
437
458
  */
438
459
  linkComponent?: ElementType;
460
+ /**
461
+ * Icon-only trigger for AppSidebar's collapsed (56px) rail (DEV-5331): the
462
+ * label collapses to a `sr-only` span (kept in the a11y tree) plus a native
463
+ * `title` tooltip — the package has no tooltip primitive and adding one
464
+ * (a Radix dep) is explicitly out of scope. The panel's own child rows are
465
+ * unaffected — they always show full labels regardless of this flag.
466
+ */
467
+ collapsed?: boolean;
468
+ /**
469
+ * Sibling-coordination hooks (DEV-5331 fix — Matt's live review found two
470
+ * folded-group flyouts open + overlapping at once, since each Flyout had
471
+ * zero awareness of its siblings). `onOpenChange` reports every open/
472
+ * closed transition of THIS instance's own hover/click intent — reported
473
+ * even while `forceClosed` is currently suppressing it, so a parent
474
+ * coordinator can learn "this one wants to open" and hand it the single
475
+ * active slot. `forceClosed` is set by that coordinator to immediately
476
+ * hide THIS instance (and reset its internal hover/click state) the
477
+ * instant a different sibling becomes the active one. Both optional and
478
+ * independent of each other — omitted, Flyout is fully standalone (its
479
+ * own state, unaware of any siblings), exactly as before.
480
+ */
481
+ onOpenChange?: (open: boolean) => void;
482
+ forceClosed?: boolean;
439
483
  className?: string;
440
484
  }
441
485
  /**
@@ -473,7 +517,7 @@ interface FlyoutProps {
473
517
  * than working around it. jsdom never computes real scrollable-overflow
474
518
  * geometry, so no unit test caught this — only an actual browser render did.
475
519
  */
476
- declare function Flyout({ label, icon, active, children, linkComponent, className }: FlyoutProps): react.JSX.Element;
520
+ declare function Flyout({ label, icon, active, children, linkComponent, collapsed, onOpenChange, forceClosed, className, }: FlyoutProps): react.JSX.Element;
477
521
 
478
522
  /**
479
523
  * ContextSwitcher (DEV-4408) — promoted out of reeve-frontend's
package/dist/index.js CHANGED
@@ -90,6 +90,7 @@ var WORDMARK_WEIGHT = 800;
90
90
  var WORDMARK_WEIGHT_CLASS = "font-extrabold";
91
91
  var WORDMARK_TEXT_LG = "text-base";
92
92
  var MONOGRAM_TEXT_LG = "text-[17px]";
93
+ var MONOGRAM_TEXT_XL = "text-[26px]";
93
94
  var LOCKUP_GAP = "gap-2";
94
95
  var LOCKUP_ROW = "flex items-baseline";
95
96
  var GLYPH_CANVAS_FILL = 0.65;
@@ -129,9 +130,9 @@ function faviconUrl(domain) {
129
130
  return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=128`;
130
131
  }
131
132
  function BrandGlyph({ brand, size = "sm", subtle = false, className }) {
132
- const box = size === "lg" ? "h-6 w-6" : "h-5 w-5";
133
- const img = size === "lg" ? "h-4 w-4" : "h-3.5 w-3.5";
134
- const dim = size === "lg" ? 16 : 14;
133
+ const box = size === "xl" ? "h-10 w-10" : size === "lg" ? "h-6 w-6" : "h-5 w-5";
134
+ const img = size === "xl" ? "h-7 w-7" : size === "lg" ? "h-4 w-4" : "h-3.5 w-3.5";
135
+ const dim = size === "xl" ? 28 : size === "lg" ? 16 : 14;
135
136
  const candidates = [];
136
137
  if (brand.favicon) candidates.push(brand.favicon);
137
138
  if (brand.logo) candidates.push(brand.logo);
@@ -184,8 +185,10 @@ function BrandGlyph({ brand, size = "sm", subtle = false, className }) {
184
185
  // DEV-5139 rule 2: at "lg" the letter runs 17px = the text-base
185
186
  // wordmark (16px) × MARK_CAP_RATIO (1.08) — a lone round glyph at
186
187
  // exact parity optically reads small. "sm" has no wordmark partner
187
- // in its surfaces; SIZE unchanged pending a lockup audit there.
188
- size === "lg" ? MONOGRAM_TEXT_LG : "text-[11px]",
188
+ // in its surfaces; unchanged pending a lockup audit there. "xl"
189
+ // (DEV-5331) is a standalone glyph with no wordmark at all — rule 5
190
+ // (GLYPH_CANVAS_FILL) governs it instead of rule 2.
191
+ size === "xl" ? MONOGRAM_TEXT_XL : size === "lg" ? MONOGRAM_TEXT_LG : "text-[11px]",
189
192
  box,
190
193
  className
191
194
  ),
@@ -752,11 +755,22 @@ function useLadderRung(ladder, containerRef, options = {}) {
752
755
  var import_react7 = require("react");
753
756
  var import_jsx_runtime3 = require("react/jsx-runtime");
754
757
  var PANEL_GAP = 4;
755
- function Flyout({ label, icon, active, children, linkComponent, className }) {
758
+ function Flyout({
759
+ label,
760
+ icon,
761
+ active,
762
+ children,
763
+ linkComponent,
764
+ collapsed = false,
765
+ onOpenChange,
766
+ forceClosed = false,
767
+ className
768
+ }) {
756
769
  const [hoverOpen, setHoverOpen] = (0, import_react7.useState)(false);
757
770
  const [clickOpen, setClickOpen] = (0, import_react7.useState)(false);
758
771
  const [panelPosition, setPanelPosition] = (0, import_react7.useState)(null);
759
- const open = hoverOpen || clickOpen;
772
+ const wantsOpen = hoverOpen || clickOpen;
773
+ const open = wantsOpen && !forceClosed;
760
774
  const panelId = (0, import_react7.useId)();
761
775
  const triggerRef = (0, import_react7.useRef)(null);
762
776
  const panelRef = (0, import_react7.useRef)(null);
@@ -768,6 +782,17 @@ function Flyout({ label, icon, active, children, linkComponent, className }) {
768
782
  const rect = triggerRef.current?.getBoundingClientRect();
769
783
  if (rect) setPanelPosition({ top: rect.top, left: rect.right + PANEL_GAP });
770
784
  }, []);
785
+ const onOpenChangeRef = (0, import_react7.useRef)(onOpenChange);
786
+ onOpenChangeRef.current = onOpenChange;
787
+ (0, import_react7.useEffect)(() => {
788
+ onOpenChangeRef.current?.(wantsOpen);
789
+ }, [wantsOpen]);
790
+ (0, import_react7.useEffect)(() => {
791
+ if (forceClosed) {
792
+ setHoverOpen(false);
793
+ setClickOpen(false);
794
+ }
795
+ }, [forceClosed]);
771
796
  (0, import_react7.useLayoutEffect)(() => {
772
797
  if (open) reposition();
773
798
  }, [open, reposition]);
@@ -808,9 +833,11 @@ function Flyout({ label, icon, active, children, linkComponent, className }) {
808
833
  "data-testid": "ladder-flyout-trigger",
809
834
  "aria-expanded": open,
810
835
  "aria-controls": panelId,
836
+ title: collapsed ? label : void 0,
811
837
  onClick: () => setClickOpen((v) => !v),
812
838
  className: cn(
813
839
  "group flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-sm transition-colors",
840
+ collapsed && "justify-center px-0",
814
841
  active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
815
842
  ),
816
843
  children: [
@@ -822,7 +849,7 @@ function Flyout({ label, icon, active, children, linkComponent, className }) {
822
849
  "aria-hidden": "true"
823
850
  }
824
851
  ),
825
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "truncate", children: label })
852
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: cn("truncate", collapsed && "sr-only"), children: label })
826
853
  ]
827
854
  }
828
855
  ),
@@ -873,6 +900,9 @@ function Flyout({ label, icon, active, children, linkComponent, className }) {
873
900
 
874
901
  // src/app-sidebar.tsx
875
902
  var import_jsx_runtime4 = require("react/jsx-runtime");
903
+ var DEFAULT_COLLAPSE_STORAGE_KEY = "reeve-sidebar-collapsed";
904
+ var COLLAPSED_WIDTH_CLASS = "w-14";
905
+ var EXPANDED_WIDTH_CLASS = "w-64";
876
906
  function isHrefActive(href, activePath) {
877
907
  return activePath === href || activePath.startsWith(`${href}/`);
878
908
  }
@@ -882,7 +912,8 @@ function NavLink({
882
912
  icon,
883
913
  label,
884
914
  testId,
885
- linkComponent
915
+ linkComponent,
916
+ collapsed = false
886
917
  }) {
887
918
  const Icon = resolveIcon(icon);
888
919
  const LinkEl = linkComponent ?? "a";
@@ -892,8 +923,10 @@ function NavLink({
892
923
  href,
893
924
  "data-testid": testId,
894
925
  "aria-current": active ? "page" : void 0,
926
+ title: collapsed ? label : void 0,
895
927
  className: cn(
896
928
  "group flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm transition-colors",
929
+ collapsed && "justify-center px-0",
897
930
  active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
898
931
  ),
899
932
  children: [
@@ -905,7 +938,7 @@ function NavLink({
905
938
  "aria-hidden": "true"
906
939
  }
907
940
  ),
908
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "truncate", children: label })
941
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: cn("truncate", collapsed && "sr-only"), children: label })
909
942
  ]
910
943
  }
911
944
  );
@@ -921,16 +954,54 @@ function AppSidebar({
921
954
  ladderOptions,
922
955
  linkComponent,
923
956
  isActive,
957
+ collapsible = true,
958
+ defaultCollapsed = false,
959
+ onCollapsedChange,
960
+ storageKey = DEFAULT_COLLAPSE_STORAGE_KEY,
924
961
  className
925
962
  }) {
926
963
  const isActiveFn = isActive ?? isHrefActive;
927
964
  const isDesktop = useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
965
+ const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
928
966
  const [mobileOpen, setMobileOpen] = (0, import_react8.useState)(false);
929
967
  const containerRef = (0, import_react8.useRef)(null);
930
968
  const drawerId = (0, import_react8.useId)();
931
969
  const mobileTriggerRef = (0, import_react8.useRef)(null);
932
970
  const mobileDrawerRef = (0, import_react8.useRef)(null);
933
971
  const mobileCloseRef = (0, import_react8.useRef)(null);
972
+ const [collapsed, setCollapsed] = (0, import_react8.useState)(defaultCollapsed);
973
+ const [collapseHydrated, setCollapseHydrated] = (0, import_react8.useState)(false);
974
+ (0, import_react8.useEffect)(() => {
975
+ if (!collapsible) return;
976
+ try {
977
+ const stored = localStorage.getItem(storageKey);
978
+ if (stored === "true") setCollapsed(true);
979
+ else if (stored === "false") setCollapsed(false);
980
+ } catch {
981
+ }
982
+ setCollapseHydrated(true);
983
+ }, []);
984
+ (0, import_react8.useEffect)(() => {
985
+ if (!collapsible || !collapseHydrated) return;
986
+ try {
987
+ localStorage.setItem(storageKey, String(collapsed));
988
+ } catch {
989
+ }
990
+ }, [collapsed, collapseHydrated, collapsible, storageKey]);
991
+ function handleToggleCollapsed() {
992
+ setCollapsed((prev) => {
993
+ const next = !prev;
994
+ onCollapsedChange?.(next);
995
+ return next;
996
+ });
997
+ }
998
+ const [openFlyoutId, setOpenFlyoutId] = (0, import_react8.useState)(null);
999
+ const handleFlyoutOpenChange = (0, import_react8.useCallback)((id, isOpen) => {
1000
+ setOpenFlyoutId((current) => {
1001
+ if (isOpen) return id;
1002
+ return current === id ? null : current;
1003
+ });
1004
+ }, []);
934
1005
  const moduleMap = (0, import_react8.useMemo)(() => new Map(modules.map((m) => [m.moduleId, m])), [modules]);
935
1006
  const registryForLadder = (0, import_react8.useMemo)(
936
1007
  () => modules.map((m) => ({ moduleId: m.moduleId, group: m.group })),
@@ -952,7 +1023,7 @@ function AppSidebar({
952
1023
  triggerRef: mobileTriggerRef,
953
1024
  initialFocusRef: mobileCloseRef
954
1025
  });
955
- function renderAppItem(item, testIdPrefix) {
1026
+ function renderAppItem(item, testIdPrefix, collapsedRow = false) {
956
1027
  const def = moduleMap.get(item.moduleId);
957
1028
  if (!def) return null;
958
1029
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
@@ -963,12 +1034,13 @@ function AppSidebar({
963
1034
  icon: def.icon,
964
1035
  label: def.label,
965
1036
  testId: `${testIdPrefix}-${item.moduleId}`,
966
- linkComponent
1037
+ linkComponent,
1038
+ collapsed: collapsedRow
967
1039
  },
968
1040
  item.moduleId
969
1041
  );
970
1042
  }
971
- function renderGroupItem(item) {
1043
+ function renderGroupItem(item, collapsedRow = false) {
972
1044
  const children = item.children.map((moduleId) => {
973
1045
  const def = moduleMap.get(moduleId);
974
1046
  const href = def?.href ?? "#";
@@ -987,26 +1059,47 @@ function AppSidebar({
987
1059
  icon: item.icon,
988
1060
  active,
989
1061
  children,
990
- linkComponent
1062
+ linkComponent,
1063
+ collapsed: collapsedRow,
1064
+ forceClosed: openFlyoutId !== null && openFlyoutId !== item.id,
1065
+ onOpenChange: (isOpen) => handleFlyoutOpenChange(item.id, isOpen)
991
1066
  },
992
1067
  item.id
993
1068
  );
994
1069
  }
995
- const brandBlock = brand && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
996
- "a",
997
- {
998
- href: brand.href,
999
- "data-testid": "app-sidebar-brand",
1000
- className: "flex items-center gap-2.5 border-b border-border px-5 py-4",
1001
- children: [
1002
- /* @__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() }) }),
1003
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "truncate text-sm font-semibold tracking-tight", children: brand.label })
1004
- ]
1070
+ function brandBlockFor(collapsedMode) {
1071
+ if (!brand) return null;
1072
+ if (collapsedMode) {
1073
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
1074
+ "a",
1075
+ {
1076
+ href: brand.href,
1077
+ "data-testid": "app-sidebar-brand",
1078
+ title: brand.label,
1079
+ className: "flex items-center justify-center border-b border-border px-1 py-4",
1080
+ children: [
1081
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "sr-only", children: brand.label }),
1082
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(BrandGlyph, { brand: { name: brand.label }, size: "xl" })
1083
+ ]
1084
+ }
1085
+ );
1005
1086
  }
1006
- );
1007
- function topItemBlock(testId) {
1087
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
1088
+ "a",
1089
+ {
1090
+ href: brand.href,
1091
+ "data-testid": "app-sidebar-brand",
1092
+ className: "flex items-center gap-2.5 border-b border-border px-5 py-4",
1093
+ children: [
1094
+ /* @__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() }) }),
1095
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "truncate text-sm font-semibold tracking-tight", children: brand.label })
1096
+ ]
1097
+ }
1098
+ );
1099
+ }
1100
+ function topItemBlock(testId, collapsedMode = false) {
1008
1101
  if (!topItem) return null;
1009
- return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "px-3 pt-4", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1102
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: cn("px-3 pt-4", collapsedMode && "flex justify-center px-0"), children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1010
1103
  NavLink,
1011
1104
  {
1012
1105
  href: topItem.href,
@@ -1014,7 +1107,8 @@ function AppSidebar({
1014
1107
  icon: topItem.icon,
1015
1108
  label: topItem.label,
1016
1109
  testId,
1017
- linkComponent
1110
+ linkComponent,
1111
+ collapsed: collapsedMode
1018
1112
  }
1019
1113
  ) });
1020
1114
  }
@@ -1068,7 +1162,7 @@ function AppSidebar({
1068
1162
  children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_lucide_react2.X, { className: "h-5 w-5", "aria-hidden": "true" })
1069
1163
  }
1070
1164
  ) }),
1071
- brandBlock,
1165
+ brandBlockFor(false),
1072
1166
  topItemBlock("app-sidebar-mobile-top-item"),
1073
1167
  /* @__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(
1074
1168
  (item) => item.type === "app" ? renderAppItem(item, "app-sidebar-mobile-navlink") : null
@@ -1081,19 +1175,47 @@ function AppSidebar({
1081
1175
  "aside",
1082
1176
  {
1083
1177
  "data-testid": "app-sidebar-desktop",
1084
- className: cn("hidden w-64 shrink-0 flex-col border-r border-border bg-sidebar md:flex", className),
1178
+ className: cn(
1179
+ "hidden shrink-0 flex-col border-r border-border bg-sidebar md:flex",
1180
+ collapsed ? COLLAPSED_WIDTH_CLASS : EXPANDED_WIDTH_CLASS,
1181
+ // DEV-5331: a plain CSS width transition is enough — no new deps
1182
+ // for a spring/JS animation. Respect prefers-reduced-motion.
1183
+ !prefersReducedMotion && "transition-[width] duration-200 ease-out",
1184
+ className
1185
+ ),
1085
1186
  children: [
1086
- brandBlock,
1087
- topItemBlock("app-sidebar-top-item"),
1187
+ brandBlockFor(collapsed),
1188
+ topItemBlock("app-sidebar-top-item", collapsed),
1088
1189
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1089
1190
  "nav",
1090
1191
  {
1091
1192
  ref: containerRef,
1092
1193
  "data-testid": "app-sidebar-nav",
1093
1194
  className: "flex-1 overflow-y-auto px-3 py-4",
1094
- 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)) })
1195
+ children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "flex flex-col gap-0.5", children: rung.items.map(
1196
+ (item) => item.type === "app" ? renderAppItem(item, "app-sidebar-navlink", collapsed) : renderGroupItem(item, collapsed)
1197
+ ) })
1095
1198
  }
1096
- )
1199
+ ),
1200
+ collapsible && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "border-t border-border px-2 py-2", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
1201
+ "button",
1202
+ {
1203
+ type: "button",
1204
+ "data-testid": "app-sidebar-collapse-toggle",
1205
+ "aria-pressed": collapsed,
1206
+ "aria-label": collapsed ? "Expand sidebar" : "Collapse sidebar",
1207
+ title: collapsed ? "Expand sidebar" : "Collapse sidebar",
1208
+ onClick: handleToggleCollapsed,
1209
+ className: cn(
1210
+ "flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground",
1211
+ collapsed && "justify-center px-0"
1212
+ ),
1213
+ children: [
1214
+ collapsed ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_lucide_react2.PanelLeftOpen, { className: "size-4 shrink-0", strokeWidth: 1.5, "aria-hidden": "true" }) : /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_lucide_react2.PanelLeftClose, { className: "size-4 shrink-0", strokeWidth: 1.5, "aria-hidden": "true" }),
1215
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: cn("truncate", collapsed && "sr-only"), children: collapsed ? "Expand sidebar" : "Collapse sidebar" })
1216
+ ]
1217
+ }
1218
+ ) })
1097
1219
  ]
1098
1220
  }
1099
1221
  )
package/dist/index.mjs CHANGED
@@ -36,6 +36,7 @@ var WORDMARK_WEIGHT = 800;
36
36
  var WORDMARK_WEIGHT_CLASS = "font-extrabold";
37
37
  var WORDMARK_TEXT_LG = "text-base";
38
38
  var MONOGRAM_TEXT_LG = "text-[17px]";
39
+ var MONOGRAM_TEXT_XL = "text-[26px]";
39
40
  var LOCKUP_GAP = "gap-2";
40
41
  var LOCKUP_ROW = "flex items-baseline";
41
42
  var GLYPH_CANVAS_FILL = 0.65;
@@ -75,9 +76,9 @@ function faviconUrl(domain) {
75
76
  return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=128`;
76
77
  }
77
78
  function BrandGlyph({ brand, size = "sm", subtle = false, className }) {
78
- const box = size === "lg" ? "h-6 w-6" : "h-5 w-5";
79
- const img = size === "lg" ? "h-4 w-4" : "h-3.5 w-3.5";
80
- const dim = size === "lg" ? 16 : 14;
79
+ const box = size === "xl" ? "h-10 w-10" : size === "lg" ? "h-6 w-6" : "h-5 w-5";
80
+ const img = size === "xl" ? "h-7 w-7" : size === "lg" ? "h-4 w-4" : "h-3.5 w-3.5";
81
+ const dim = size === "xl" ? 28 : size === "lg" ? 16 : 14;
81
82
  const candidates = [];
82
83
  if (brand.favicon) candidates.push(brand.favicon);
83
84
  if (brand.logo) candidates.push(brand.logo);
@@ -130,8 +131,10 @@ function BrandGlyph({ brand, size = "sm", subtle = false, className }) {
130
131
  // DEV-5139 rule 2: at "lg" the letter runs 17px = the text-base
131
132
  // wordmark (16px) × MARK_CAP_RATIO (1.08) — a lone round glyph at
132
133
  // exact parity optically reads small. "sm" has no wordmark partner
133
- // in its surfaces; SIZE unchanged pending a lockup audit there.
134
- size === "lg" ? MONOGRAM_TEXT_LG : "text-[11px]",
134
+ // in its surfaces; unchanged pending a lockup audit there. "xl"
135
+ // (DEV-5331) is a standalone glyph with no wordmark at all — rule 5
136
+ // (GLYPH_CANVAS_FILL) governs it instead of rule 2.
137
+ size === "xl" ? MONOGRAM_TEXT_XL : size === "lg" ? MONOGRAM_TEXT_LG : "text-[11px]",
135
138
  box,
136
139
  className
137
140
  ),
@@ -415,8 +418,8 @@ function useResponsivePlaceholder(desktop, mobile) {
415
418
  }
416
419
 
417
420
  // src/app-sidebar.tsx
418
- import { useEffect as useEffect5, useId as useId3, useMemo, useRef as useRef4, useState as useState6 } from "react";
419
- import { Menu as Menu2, X as X2 } from "lucide-react";
421
+ import { useCallback as useCallback3, useEffect as useEffect5, useId as useId3, useMemo, useRef as useRef4, useState as useState6 } from "react";
422
+ import { Menu as Menu2, PanelLeftClose, PanelLeftOpen, X as X2 } from "lucide-react";
420
423
 
421
424
  // src/lib/resolve-icon.ts
422
425
  import * as LucideIcons from "lucide-react";
@@ -698,11 +701,22 @@ function useLadderRung(ladder, containerRef, options = {}) {
698
701
  import { useCallback as useCallback2, useEffect as useEffect4, useId as useId2, useLayoutEffect as useLayoutEffect2, useRef as useRef3, useState as useState5 } from "react";
699
702
  import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
700
703
  var PANEL_GAP = 4;
701
- function Flyout({ label, icon, active, children, linkComponent, className }) {
704
+ function Flyout({
705
+ label,
706
+ icon,
707
+ active,
708
+ children,
709
+ linkComponent,
710
+ collapsed = false,
711
+ onOpenChange,
712
+ forceClosed = false,
713
+ className
714
+ }) {
702
715
  const [hoverOpen, setHoverOpen] = useState5(false);
703
716
  const [clickOpen, setClickOpen] = useState5(false);
704
717
  const [panelPosition, setPanelPosition] = useState5(null);
705
- const open = hoverOpen || clickOpen;
718
+ const wantsOpen = hoverOpen || clickOpen;
719
+ const open = wantsOpen && !forceClosed;
706
720
  const panelId = useId2();
707
721
  const triggerRef = useRef3(null);
708
722
  const panelRef = useRef3(null);
@@ -714,6 +728,17 @@ function Flyout({ label, icon, active, children, linkComponent, className }) {
714
728
  const rect = triggerRef.current?.getBoundingClientRect();
715
729
  if (rect) setPanelPosition({ top: rect.top, left: rect.right + PANEL_GAP });
716
730
  }, []);
731
+ const onOpenChangeRef = useRef3(onOpenChange);
732
+ onOpenChangeRef.current = onOpenChange;
733
+ useEffect4(() => {
734
+ onOpenChangeRef.current?.(wantsOpen);
735
+ }, [wantsOpen]);
736
+ useEffect4(() => {
737
+ if (forceClosed) {
738
+ setHoverOpen(false);
739
+ setClickOpen(false);
740
+ }
741
+ }, [forceClosed]);
717
742
  useLayoutEffect2(() => {
718
743
  if (open) reposition();
719
744
  }, [open, reposition]);
@@ -754,9 +779,11 @@ function Flyout({ label, icon, active, children, linkComponent, className }) {
754
779
  "data-testid": "ladder-flyout-trigger",
755
780
  "aria-expanded": open,
756
781
  "aria-controls": panelId,
782
+ title: collapsed ? label : void 0,
757
783
  onClick: () => setClickOpen((v) => !v),
758
784
  className: cn(
759
785
  "group flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-sm transition-colors",
786
+ collapsed && "justify-center px-0",
760
787
  active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
761
788
  ),
762
789
  children: [
@@ -768,7 +795,7 @@ function Flyout({ label, icon, active, children, linkComponent, className }) {
768
795
  "aria-hidden": "true"
769
796
  }
770
797
  ),
771
- /* @__PURE__ */ jsx3("span", { className: "truncate", children: label })
798
+ /* @__PURE__ */ jsx3("span", { className: cn("truncate", collapsed && "sr-only"), children: label })
772
799
  ]
773
800
  }
774
801
  ),
@@ -819,6 +846,9 @@ function Flyout({ label, icon, active, children, linkComponent, className }) {
819
846
 
820
847
  // src/app-sidebar.tsx
821
848
  import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
849
+ var DEFAULT_COLLAPSE_STORAGE_KEY = "reeve-sidebar-collapsed";
850
+ var COLLAPSED_WIDTH_CLASS = "w-14";
851
+ var EXPANDED_WIDTH_CLASS = "w-64";
822
852
  function isHrefActive(href, activePath) {
823
853
  return activePath === href || activePath.startsWith(`${href}/`);
824
854
  }
@@ -828,7 +858,8 @@ function NavLink({
828
858
  icon,
829
859
  label,
830
860
  testId,
831
- linkComponent
861
+ linkComponent,
862
+ collapsed = false
832
863
  }) {
833
864
  const Icon = resolveIcon(icon);
834
865
  const LinkEl = linkComponent ?? "a";
@@ -838,8 +869,10 @@ function NavLink({
838
869
  href,
839
870
  "data-testid": testId,
840
871
  "aria-current": active ? "page" : void 0,
872
+ title: collapsed ? label : void 0,
841
873
  className: cn(
842
874
  "group flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm transition-colors",
875
+ collapsed && "justify-center px-0",
843
876
  active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
844
877
  ),
845
878
  children: [
@@ -851,7 +884,7 @@ function NavLink({
851
884
  "aria-hidden": "true"
852
885
  }
853
886
  ),
854
- /* @__PURE__ */ jsx4("span", { className: "truncate", children: label })
887
+ /* @__PURE__ */ jsx4("span", { className: cn("truncate", collapsed && "sr-only"), children: label })
855
888
  ]
856
889
  }
857
890
  );
@@ -867,16 +900,54 @@ function AppSidebar({
867
900
  ladderOptions,
868
901
  linkComponent,
869
902
  isActive,
903
+ collapsible = true,
904
+ defaultCollapsed = false,
905
+ onCollapsedChange,
906
+ storageKey = DEFAULT_COLLAPSE_STORAGE_KEY,
870
907
  className
871
908
  }) {
872
909
  const isActiveFn = isActive ?? isHrefActive;
873
910
  const isDesktop = useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
911
+ const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
874
912
  const [mobileOpen, setMobileOpen] = useState6(false);
875
913
  const containerRef = useRef4(null);
876
914
  const drawerId = useId3();
877
915
  const mobileTriggerRef = useRef4(null);
878
916
  const mobileDrawerRef = useRef4(null);
879
917
  const mobileCloseRef = useRef4(null);
918
+ const [collapsed, setCollapsed] = useState6(defaultCollapsed);
919
+ const [collapseHydrated, setCollapseHydrated] = useState6(false);
920
+ useEffect5(() => {
921
+ if (!collapsible) return;
922
+ try {
923
+ const stored = localStorage.getItem(storageKey);
924
+ if (stored === "true") setCollapsed(true);
925
+ else if (stored === "false") setCollapsed(false);
926
+ } catch {
927
+ }
928
+ setCollapseHydrated(true);
929
+ }, []);
930
+ useEffect5(() => {
931
+ if (!collapsible || !collapseHydrated) return;
932
+ try {
933
+ localStorage.setItem(storageKey, String(collapsed));
934
+ } catch {
935
+ }
936
+ }, [collapsed, collapseHydrated, collapsible, storageKey]);
937
+ function handleToggleCollapsed() {
938
+ setCollapsed((prev) => {
939
+ const next = !prev;
940
+ onCollapsedChange?.(next);
941
+ return next;
942
+ });
943
+ }
944
+ const [openFlyoutId, setOpenFlyoutId] = useState6(null);
945
+ const handleFlyoutOpenChange = useCallback3((id, isOpen) => {
946
+ setOpenFlyoutId((current) => {
947
+ if (isOpen) return id;
948
+ return current === id ? null : current;
949
+ });
950
+ }, []);
880
951
  const moduleMap = useMemo(() => new Map(modules.map((m) => [m.moduleId, m])), [modules]);
881
952
  const registryForLadder = useMemo(
882
953
  () => modules.map((m) => ({ moduleId: m.moduleId, group: m.group })),
@@ -898,7 +969,7 @@ function AppSidebar({
898
969
  triggerRef: mobileTriggerRef,
899
970
  initialFocusRef: mobileCloseRef
900
971
  });
901
- function renderAppItem(item, testIdPrefix) {
972
+ function renderAppItem(item, testIdPrefix, collapsedRow = false) {
902
973
  const def = moduleMap.get(item.moduleId);
903
974
  if (!def) return null;
904
975
  return /* @__PURE__ */ jsx4(
@@ -909,12 +980,13 @@ function AppSidebar({
909
980
  icon: def.icon,
910
981
  label: def.label,
911
982
  testId: `${testIdPrefix}-${item.moduleId}`,
912
- linkComponent
983
+ linkComponent,
984
+ collapsed: collapsedRow
913
985
  },
914
986
  item.moduleId
915
987
  );
916
988
  }
917
- function renderGroupItem(item) {
989
+ function renderGroupItem(item, collapsedRow = false) {
918
990
  const children = item.children.map((moduleId) => {
919
991
  const def = moduleMap.get(moduleId);
920
992
  const href = def?.href ?? "#";
@@ -933,26 +1005,47 @@ function AppSidebar({
933
1005
  icon: item.icon,
934
1006
  active,
935
1007
  children,
936
- linkComponent
1008
+ linkComponent,
1009
+ collapsed: collapsedRow,
1010
+ forceClosed: openFlyoutId !== null && openFlyoutId !== item.id,
1011
+ onOpenChange: (isOpen) => handleFlyoutOpenChange(item.id, isOpen)
937
1012
  },
938
1013
  item.id
939
1014
  );
940
1015
  }
941
- const brandBlock = brand && /* @__PURE__ */ jsxs4(
942
- "a",
943
- {
944
- href: brand.href,
945
- "data-testid": "app-sidebar-brand",
946
- className: "flex items-center gap-2.5 border-b border-border px-5 py-4",
947
- children: [
948
- /* @__PURE__ */ jsx4("span", { className: "grid size-7 place-items-center rounded-md bg-primary text-primary-foreground", children: /* @__PURE__ */ jsx4("span", { className: "font-mono text-sm font-bold", children: (brand.label[0] ?? "r").toUpperCase() }) }),
949
- /* @__PURE__ */ jsx4("span", { className: "truncate text-sm font-semibold tracking-tight", children: brand.label })
950
- ]
1016
+ function brandBlockFor(collapsedMode) {
1017
+ if (!brand) return null;
1018
+ if (collapsedMode) {
1019
+ return /* @__PURE__ */ jsxs4(
1020
+ "a",
1021
+ {
1022
+ href: brand.href,
1023
+ "data-testid": "app-sidebar-brand",
1024
+ title: brand.label,
1025
+ className: "flex items-center justify-center border-b border-border px-1 py-4",
1026
+ children: [
1027
+ /* @__PURE__ */ jsx4("span", { className: "sr-only", children: brand.label }),
1028
+ /* @__PURE__ */ jsx4(BrandGlyph, { brand: { name: brand.label }, size: "xl" })
1029
+ ]
1030
+ }
1031
+ );
951
1032
  }
952
- );
953
- function topItemBlock(testId) {
1033
+ return /* @__PURE__ */ jsxs4(
1034
+ "a",
1035
+ {
1036
+ href: brand.href,
1037
+ "data-testid": "app-sidebar-brand",
1038
+ className: "flex items-center gap-2.5 border-b border-border px-5 py-4",
1039
+ children: [
1040
+ /* @__PURE__ */ jsx4("span", { className: "grid size-7 place-items-center rounded-md bg-primary text-primary-foreground", children: /* @__PURE__ */ jsx4("span", { className: "font-mono text-sm font-bold", children: (brand.label[0] ?? "r").toUpperCase() }) }),
1041
+ /* @__PURE__ */ jsx4("span", { className: "truncate text-sm font-semibold tracking-tight", children: brand.label })
1042
+ ]
1043
+ }
1044
+ );
1045
+ }
1046
+ function topItemBlock(testId, collapsedMode = false) {
954
1047
  if (!topItem) return null;
955
- return /* @__PURE__ */ jsx4("div", { className: "px-3 pt-4", children: /* @__PURE__ */ jsx4(
1048
+ return /* @__PURE__ */ jsx4("div", { className: cn("px-3 pt-4", collapsedMode && "flex justify-center px-0"), children: /* @__PURE__ */ jsx4(
956
1049
  NavLink,
957
1050
  {
958
1051
  href: topItem.href,
@@ -960,7 +1053,8 @@ function AppSidebar({
960
1053
  icon: topItem.icon,
961
1054
  label: topItem.label,
962
1055
  testId,
963
- linkComponent
1056
+ linkComponent,
1057
+ collapsed: collapsedMode
964
1058
  }
965
1059
  ) });
966
1060
  }
@@ -1014,7 +1108,7 @@ function AppSidebar({
1014
1108
  children: /* @__PURE__ */ jsx4(X2, { className: "h-5 w-5", "aria-hidden": "true" })
1015
1109
  }
1016
1110
  ) }),
1017
- brandBlock,
1111
+ brandBlockFor(false),
1018
1112
  topItemBlock("app-sidebar-mobile-top-item"),
1019
1113
  /* @__PURE__ */ jsx4("nav", { className: "flex-1 px-3 py-4", children: /* @__PURE__ */ jsx4("div", { className: "flex flex-col gap-0.5", children: rung0.items.map(
1020
1114
  (item) => item.type === "app" ? renderAppItem(item, "app-sidebar-mobile-navlink") : null
@@ -1027,19 +1121,47 @@ function AppSidebar({
1027
1121
  "aside",
1028
1122
  {
1029
1123
  "data-testid": "app-sidebar-desktop",
1030
- className: cn("hidden w-64 shrink-0 flex-col border-r border-border bg-sidebar md:flex", className),
1124
+ className: cn(
1125
+ "hidden shrink-0 flex-col border-r border-border bg-sidebar md:flex",
1126
+ collapsed ? COLLAPSED_WIDTH_CLASS : EXPANDED_WIDTH_CLASS,
1127
+ // DEV-5331: a plain CSS width transition is enough — no new deps
1128
+ // for a spring/JS animation. Respect prefers-reduced-motion.
1129
+ !prefersReducedMotion && "transition-[width] duration-200 ease-out",
1130
+ className
1131
+ ),
1031
1132
  children: [
1032
- brandBlock,
1033
- topItemBlock("app-sidebar-top-item"),
1133
+ brandBlockFor(collapsed),
1134
+ topItemBlock("app-sidebar-top-item", collapsed),
1034
1135
  /* @__PURE__ */ jsx4(
1035
1136
  "nav",
1036
1137
  {
1037
1138
  ref: containerRef,
1038
1139
  "data-testid": "app-sidebar-nav",
1039
1140
  className: "flex-1 overflow-y-auto px-3 py-4",
1040
- children: /* @__PURE__ */ jsx4("div", { className: "flex flex-col gap-0.5", children: rung.items.map((item) => item.type === "app" ? renderAppItem(item, "app-sidebar-navlink") : renderGroupItem(item)) })
1141
+ children: /* @__PURE__ */ jsx4("div", { className: "flex flex-col gap-0.5", children: rung.items.map(
1142
+ (item) => item.type === "app" ? renderAppItem(item, "app-sidebar-navlink", collapsed) : renderGroupItem(item, collapsed)
1143
+ ) })
1041
1144
  }
1042
- )
1145
+ ),
1146
+ collapsible && /* @__PURE__ */ jsx4("div", { className: "border-t border-border px-2 py-2", children: /* @__PURE__ */ jsxs4(
1147
+ "button",
1148
+ {
1149
+ type: "button",
1150
+ "data-testid": "app-sidebar-collapse-toggle",
1151
+ "aria-pressed": collapsed,
1152
+ "aria-label": collapsed ? "Expand sidebar" : "Collapse sidebar",
1153
+ title: collapsed ? "Expand sidebar" : "Collapse sidebar",
1154
+ onClick: handleToggleCollapsed,
1155
+ className: cn(
1156
+ "flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground",
1157
+ collapsed && "justify-center px-0"
1158
+ ),
1159
+ children: [
1160
+ collapsed ? /* @__PURE__ */ jsx4(PanelLeftOpen, { className: "size-4 shrink-0", strokeWidth: 1.5, "aria-hidden": "true" }) : /* @__PURE__ */ jsx4(PanelLeftClose, { className: "size-4 shrink-0", strokeWidth: 1.5, "aria-hidden": "true" }),
1161
+ /* @__PURE__ */ jsx4("span", { className: cn("truncate", collapsed && "sr-only"), children: collapsed ? "Expand sidebar" : "Collapse sidebar" })
1162
+ ]
1163
+ }
1164
+ ) })
1043
1165
  ]
1044
1166
  }
1045
1167
  )
@@ -51,6 +51,17 @@ declare const WORDMARK_WEIGHT_CLASS = "font-extrabold";
51
51
  */
52
52
  declare const WORDMARK_TEXT_LG = "text-base";
53
53
  declare const MONOGRAM_TEXT_LG = "text-[17px]";
54
+ /**
55
+ * "xl" — a bare, STANDALONE glyph slot with no adjacent wordmark at all
56
+ * (e.g. AppSidebar's collapsed 56px icon rail, DEV-5331 — Matt's live
57
+ * review found the "lg" monogram too small there). No wordmark to pair
58
+ * with means rule 2 (MARK_CAP_RATIO) doesn't apply; rule 5
59
+ * (GLYPH_CANVAS_FILL) does instead — same governing rule as a favicon/
60
+ * avatar tile. Canvas = 40px (`h-10 w-10`); letter cap height = 40 ×
61
+ * GLYPH_CANVAS_FILL (0.65) = 26px, landing in Matt's requested ~24-28px
62
+ * range for a rail comfortably wider than the glyph itself.
63
+ */
64
+ declare const MONOGRAM_TEXT_XL = "text-[26px]";
54
65
  /**
55
66
  * Rule 3, applied: 8px gap ≈ 0.70× the wordmark's ~11.5px cap height (at
56
67
  * text-base) — inside the 0.5–0.8 band. Named so nobody "fixes" it
@@ -65,4 +76,4 @@ declare const LOCKUP_ROW = "flex items-baseline";
65
76
  /** Rule 5 — standalone glyph cap height as a fraction of the canvas edge. */
66
77
  declare const GLYPH_CANVAS_FILL = 0.65;
67
78
 
68
- export { GLYPH_CANVAS_FILL, LOCKUP_GAP, LOCKUP_ROW, MARK_CAP_RATIO, MARK_WEIGHT, MARK_WEIGHT_CLASS, MONOGRAM_TEXT_LG, WORDMARK_TEXT_LG, WORDMARK_WEIGHT, WORDMARK_WEIGHT_CLASS };
79
+ export { GLYPH_CANVAS_FILL, LOCKUP_GAP, LOCKUP_ROW, MARK_CAP_RATIO, MARK_WEIGHT, MARK_WEIGHT_CLASS, MONOGRAM_TEXT_LG, MONOGRAM_TEXT_XL, WORDMARK_TEXT_LG, WORDMARK_WEIGHT, WORDMARK_WEIGHT_CLASS };
@@ -51,6 +51,17 @@ declare const WORDMARK_WEIGHT_CLASS = "font-extrabold";
51
51
  */
52
52
  declare const WORDMARK_TEXT_LG = "text-base";
53
53
  declare const MONOGRAM_TEXT_LG = "text-[17px]";
54
+ /**
55
+ * "xl" — a bare, STANDALONE glyph slot with no adjacent wordmark at all
56
+ * (e.g. AppSidebar's collapsed 56px icon rail, DEV-5331 — Matt's live
57
+ * review found the "lg" monogram too small there). No wordmark to pair
58
+ * with means rule 2 (MARK_CAP_RATIO) doesn't apply; rule 5
59
+ * (GLYPH_CANVAS_FILL) does instead — same governing rule as a favicon/
60
+ * avatar tile. Canvas = 40px (`h-10 w-10`); letter cap height = 40 ×
61
+ * GLYPH_CANVAS_FILL (0.65) = 26px, landing in Matt's requested ~24-28px
62
+ * range for a rail comfortably wider than the glyph itself.
63
+ */
64
+ declare const MONOGRAM_TEXT_XL = "text-[26px]";
54
65
  /**
55
66
  * Rule 3, applied: 8px gap ≈ 0.70× the wordmark's ~11.5px cap height (at
56
67
  * text-base) — inside the 0.5–0.8 band. Named so nobody "fixes" it
@@ -65,4 +76,4 @@ declare const LOCKUP_ROW = "flex items-baseline";
65
76
  /** Rule 5 — standalone glyph cap height as a fraction of the canvas edge. */
66
77
  declare const GLYPH_CANVAS_FILL = 0.65;
67
78
 
68
- export { GLYPH_CANVAS_FILL, LOCKUP_GAP, LOCKUP_ROW, MARK_CAP_RATIO, MARK_WEIGHT, MARK_WEIGHT_CLASS, MONOGRAM_TEXT_LG, WORDMARK_TEXT_LG, WORDMARK_WEIGHT, WORDMARK_WEIGHT_CLASS };
79
+ export { GLYPH_CANVAS_FILL, LOCKUP_GAP, LOCKUP_ROW, MARK_CAP_RATIO, MARK_WEIGHT, MARK_WEIGHT_CLASS, MONOGRAM_TEXT_LG, MONOGRAM_TEXT_XL, WORDMARK_TEXT_LG, WORDMARK_WEIGHT, WORDMARK_WEIGHT_CLASS };
@@ -27,6 +27,7 @@ __export(lockup_exports, {
27
27
  MARK_WEIGHT: () => MARK_WEIGHT,
28
28
  MARK_WEIGHT_CLASS: () => MARK_WEIGHT_CLASS,
29
29
  MONOGRAM_TEXT_LG: () => MONOGRAM_TEXT_LG,
30
+ MONOGRAM_TEXT_XL: () => MONOGRAM_TEXT_XL,
30
31
  WORDMARK_TEXT_LG: () => WORDMARK_TEXT_LG,
31
32
  WORDMARK_WEIGHT: () => WORDMARK_WEIGHT,
32
33
  WORDMARK_WEIGHT_CLASS: () => WORDMARK_WEIGHT_CLASS
@@ -39,6 +40,7 @@ var WORDMARK_WEIGHT = 800;
39
40
  var WORDMARK_WEIGHT_CLASS = "font-extrabold";
40
41
  var WORDMARK_TEXT_LG = "text-base";
41
42
  var MONOGRAM_TEXT_LG = "text-[17px]";
43
+ var MONOGRAM_TEXT_XL = "text-[26px]";
42
44
  var LOCKUP_GAP = "gap-2";
43
45
  var LOCKUP_ROW = "flex items-baseline";
44
46
  var GLYPH_CANVAS_FILL = 0.65;
@@ -51,6 +53,7 @@ var GLYPH_CANVAS_FILL = 0.65;
51
53
  MARK_WEIGHT,
52
54
  MARK_WEIGHT_CLASS,
53
55
  MONOGRAM_TEXT_LG,
56
+ MONOGRAM_TEXT_XL,
54
57
  WORDMARK_TEXT_LG,
55
58
  WORDMARK_WEIGHT,
56
59
  WORDMARK_WEIGHT_CLASS
@@ -6,6 +6,7 @@ var WORDMARK_WEIGHT = 800;
6
6
  var WORDMARK_WEIGHT_CLASS = "font-extrabold";
7
7
  var WORDMARK_TEXT_LG = "text-base";
8
8
  var MONOGRAM_TEXT_LG = "text-[17px]";
9
+ var MONOGRAM_TEXT_XL = "text-[26px]";
9
10
  var LOCKUP_GAP = "gap-2";
10
11
  var LOCKUP_ROW = "flex items-baseline";
11
12
  var GLYPH_CANVAS_FILL = 0.65;
@@ -17,6 +18,7 @@ export {
17
18
  MARK_WEIGHT,
18
19
  MARK_WEIGHT_CLASS,
19
20
  MONOGRAM_TEXT_LG,
21
+ MONOGRAM_TEXT_XL,
20
22
  WORDMARK_TEXT_LG,
21
23
  WORDMARK_WEIGHT,
22
24
  WORDMARK_WEIGHT_CLASS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meetreeve/ui",
3
- "version": "0.6.3",
3
+ "version": "0.7.1",
4
4
  "description": "Shared brand-adaptive UI primitives for MindFortress frontends — min-size tokens, a favicon→logo→monogram BrandGlyph, a responsive header that keeps the search bar usable on mobile, and AppSidebar's viewport-driven auto-collapse ladder.",
5
5
  "license": "UNLICENSED",
6
6
  "main": "./dist/index.js",