@meetreeve/ui 0.5.2 → 0.6.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
@@ -80,8 +80,20 @@ declare function BrandGlyph({ brand, size, subtle, className }: BrandGlyphProps)
80
80
  /** Rule 2 — mark cap height ÷ wordmark cap height. */
81
81
  declare const MARK_CAP_RATIO = 1.08;
82
82
  /**
83
- * Rule 3, applied: 8px gap ≈ 0.79× the wordmark's ~10.1px cap height —
84
- * inside the 0.5–0.8 band. Named so nobody "fixes" it per-surface.
83
+ * Rule 7 (DEV-5340) — family construction weights, pixel-matched to the
84
+ * asset family (reeve-mark.svg, MindFortress "M." v2, reeve-services
85
+ * svg_templates): the mark is Black (900), the wordmark ExtraBold (800).
86
+ * The live-DOM lockup previously ran both at semibold (600) — Matt's
87
+ * measured "too thin" on cadasense. Numeric constants are the spec; the
88
+ * *_CLASS twins are the Tailwind application. The generated-asset pipeline
89
+ * mirrors the numeric values — see DEV-5140. Change them together.
90
+ */
91
+ declare const MARK_WEIGHT = 900;
92
+ declare const WORDMARK_WEIGHT = 800;
93
+ /**
94
+ * Rule 3, applied: 8px gap ≈ 0.70× the wordmark's ~11.5px cap height (at
95
+ * text-base) — inside the 0.5–0.8 band. Named so nobody "fixes" it
96
+ * per-surface.
85
97
  */
86
98
  declare const LOCKUP_GAP = "gap-2";
87
99
  /**
@@ -311,6 +323,22 @@ interface AppSidebarProps {
311
323
  /** Accessible label for the mobile hamburger button. */
312
324
  menuLabel?: string;
313
325
  ladderOptions?: UseLadderRungOptions;
326
+ /**
327
+ * Link renderer for every nav row (desktop + mobile drawer NavLink rows,
328
+ * and Flyout's folded-group child rows). Defaults to a bare `"a"` so the
329
+ * package stays framework-agnostic; consumers pass their router's link
330
+ * (e.g. `next/link`) for client-side navigation. Mirrors ResponsiveHeader's
331
+ * `linkComponent` prop (DEV-4429) exactly.
332
+ */
333
+ linkComponent?: ElementType;
334
+ /**
335
+ * Active-route predicate driving row highlighting + `aria-current`
336
+ * everywhere the package would otherwise use its own naive prefix match
337
+ * (`activePath === href || activePath.startsWith(href + "/")`). Defaults
338
+ * to that same behavior — override to add exclusion semantics (e.g.
339
+ * `/studio` should NOT light up on `/studio/photo`).
340
+ */
341
+ isActive?: (href: string, activePath: string) => boolean;
314
342
  /** Merged onto the desktop `<aside>` itself — there is no extra wrapping
315
343
  * element (see the render note below on why that matters for height). */
316
344
  className?: string;
@@ -325,7 +353,7 @@ interface AppSidebarProps {
325
353
  * dependency, matching ResponsiveHeader's existing disclosure pattern in
326
354
  * this same package.
327
355
  */
328
- declare function AppSidebar({ ladder, modules, groupOrder, activePath, brand, topItem, menuLabel, ladderOptions, className, }: AppSidebarProps): react.JSX.Element;
356
+ declare function AppSidebar({ ladder, modules, groupOrder, activePath, brand, topItem, menuLabel, ladderOptions, linkComponent, isActive, className, }: AppSidebarProps): react.JSX.Element;
329
357
 
330
358
  /**
331
359
  * Minimal shape AppSidebar's fallback derivation needs from a module registry
@@ -397,6 +425,15 @@ interface FlyoutProps {
397
425
  * its own `active` flag. */
398
426
  active: boolean;
399
427
  children: FlyoutChild[];
428
+ /**
429
+ * Link renderer for the panel's child rows. Defaults to a bare `"a"` so
430
+ * the package stays framework-agnostic; consumers pass their router's
431
+ * link (e.g. `next/link`) for client-side navigation. Flyout is
432
+ * independently exported (see src/index.ts), so it carries its own copy
433
+ * of this prop rather than requiring AppSidebar as an intermediary —
434
+ * mirrors ResponsiveHeader's `linkComponent` prop (DEV-4429) exactly.
435
+ */
436
+ linkComponent?: ElementType;
400
437
  className?: string;
401
438
  }
402
439
  /**
@@ -434,7 +471,7 @@ interface FlyoutProps {
434
471
  * than working around it. jsdom never computes real scrollable-overflow
435
472
  * geometry, so no unit test caught this — only an actual browser render did.
436
473
  */
437
- declare function Flyout({ label, icon, active, children, className }: FlyoutProps): react.JSX.Element;
474
+ declare function Flyout({ label, icon, active, children, linkComponent, className }: FlyoutProps): react.JSX.Element;
438
475
 
439
476
  /**
440
477
  * ContextSwitcher (DEV-4408) — promoted out of reeve-frontend's
@@ -501,4 +538,4 @@ interface ContextSwitcherProps {
501
538
  }
502
539
  declare function ContextSwitcher({ items, activeRefId, onSelect, footer, placeholder, collapsed, side, align, contentClassName, }: ContextSwitcherProps): react.JSX.Element;
503
540
 
504
- export { type AppRungItem, AppSidebar, type AppSidebarBrand, type AppSidebarModuleDef, type AppSidebarProps, type AppSidebarTopItem, type Brand, BrandGlyph, type BrandGlyphProps, type ContextOrgGroup, ContextSwitcher, type ContextSwitcherItem, type ContextSwitcherProps, type FallbackModuleDef, Flyout, type FlyoutChild, type FlyoutProps, GLYPH_CANVAS_FILL, type GroupRungItem, type HeaderNavItem, LOCKUP_GAP, LOCKUP_ROW, MARK_CAP_RATIO, REEVE_ACCENT, ResponsiveHeader, type ResponsiveHeaderProps, type RungItem, type SidebarLadder, type SidebarLadderParsed, type SidebarRung, type UseLadderRungOptions, deriveFallbackLadder, faviconUrl, groupByOrg, reconcileLadder, resolveLadder, useLadderRung, useResponsivePlaceholder, zSidebarLadder };
541
+ export { type AppRungItem, AppSidebar, type AppSidebarBrand, type AppSidebarModuleDef, type AppSidebarProps, type AppSidebarTopItem, type Brand, BrandGlyph, type BrandGlyphProps, type ContextOrgGroup, ContextSwitcher, type ContextSwitcherItem, type ContextSwitcherProps, type FallbackModuleDef, Flyout, type FlyoutChild, type FlyoutProps, GLYPH_CANVAS_FILL, type GroupRungItem, type HeaderNavItem, LOCKUP_GAP, LOCKUP_ROW, MARK_CAP_RATIO, MARK_WEIGHT, REEVE_ACCENT, ResponsiveHeader, type ResponsiveHeaderProps, type RungItem, type SidebarLadder, type SidebarLadderParsed, type SidebarRung, type UseLadderRungOptions, WORDMARK_WEIGHT, deriveFallbackLadder, faviconUrl, groupByOrg, reconcileLadder, resolveLadder, useLadderRung, useResponsivePlaceholder, zSidebarLadder };
package/dist/index.d.ts CHANGED
@@ -80,8 +80,20 @@ declare function BrandGlyph({ brand, size, subtle, className }: BrandGlyphProps)
80
80
  /** Rule 2 — mark cap height ÷ wordmark cap height. */
81
81
  declare const MARK_CAP_RATIO = 1.08;
82
82
  /**
83
- * Rule 3, applied: 8px gap ≈ 0.79× the wordmark's ~10.1px cap height —
84
- * inside the 0.5–0.8 band. Named so nobody "fixes" it per-surface.
83
+ * Rule 7 (DEV-5340) — family construction weights, pixel-matched to the
84
+ * asset family (reeve-mark.svg, MindFortress "M." v2, reeve-services
85
+ * svg_templates): the mark is Black (900), the wordmark ExtraBold (800).
86
+ * The live-DOM lockup previously ran both at semibold (600) — Matt's
87
+ * measured "too thin" on cadasense. Numeric constants are the spec; the
88
+ * *_CLASS twins are the Tailwind application. The generated-asset pipeline
89
+ * mirrors the numeric values — see DEV-5140. Change them together.
90
+ */
91
+ declare const MARK_WEIGHT = 900;
92
+ declare const WORDMARK_WEIGHT = 800;
93
+ /**
94
+ * Rule 3, applied: 8px gap ≈ 0.70× the wordmark's ~11.5px cap height (at
95
+ * text-base) — inside the 0.5–0.8 band. Named so nobody "fixes" it
96
+ * per-surface.
85
97
  */
86
98
  declare const LOCKUP_GAP = "gap-2";
87
99
  /**
@@ -311,6 +323,22 @@ interface AppSidebarProps {
311
323
  /** Accessible label for the mobile hamburger button. */
312
324
  menuLabel?: string;
313
325
  ladderOptions?: UseLadderRungOptions;
326
+ /**
327
+ * Link renderer for every nav row (desktop + mobile drawer NavLink rows,
328
+ * and Flyout's folded-group child rows). Defaults to a bare `"a"` so the
329
+ * package stays framework-agnostic; consumers pass their router's link
330
+ * (e.g. `next/link`) for client-side navigation. Mirrors ResponsiveHeader's
331
+ * `linkComponent` prop (DEV-4429) exactly.
332
+ */
333
+ linkComponent?: ElementType;
334
+ /**
335
+ * Active-route predicate driving row highlighting + `aria-current`
336
+ * everywhere the package would otherwise use its own naive prefix match
337
+ * (`activePath === href || activePath.startsWith(href + "/")`). Defaults
338
+ * to that same behavior — override to add exclusion semantics (e.g.
339
+ * `/studio` should NOT light up on `/studio/photo`).
340
+ */
341
+ isActive?: (href: string, activePath: string) => boolean;
314
342
  /** Merged onto the desktop `<aside>` itself — there is no extra wrapping
315
343
  * element (see the render note below on why that matters for height). */
316
344
  className?: string;
@@ -325,7 +353,7 @@ interface AppSidebarProps {
325
353
  * dependency, matching ResponsiveHeader's existing disclosure pattern in
326
354
  * this same package.
327
355
  */
328
- declare function AppSidebar({ ladder, modules, groupOrder, activePath, brand, topItem, menuLabel, ladderOptions, className, }: AppSidebarProps): react.JSX.Element;
356
+ declare function AppSidebar({ ladder, modules, groupOrder, activePath, brand, topItem, menuLabel, ladderOptions, linkComponent, isActive, className, }: AppSidebarProps): react.JSX.Element;
329
357
 
330
358
  /**
331
359
  * Minimal shape AppSidebar's fallback derivation needs from a module registry
@@ -397,6 +425,15 @@ interface FlyoutProps {
397
425
  * its own `active` flag. */
398
426
  active: boolean;
399
427
  children: FlyoutChild[];
428
+ /**
429
+ * Link renderer for the panel's child rows. Defaults to a bare `"a"` so
430
+ * the package stays framework-agnostic; consumers pass their router's
431
+ * link (e.g. `next/link`) for client-side navigation. Flyout is
432
+ * independently exported (see src/index.ts), so it carries its own copy
433
+ * of this prop rather than requiring AppSidebar as an intermediary —
434
+ * mirrors ResponsiveHeader's `linkComponent` prop (DEV-4429) exactly.
435
+ */
436
+ linkComponent?: ElementType;
400
437
  className?: string;
401
438
  }
402
439
  /**
@@ -434,7 +471,7 @@ interface FlyoutProps {
434
471
  * than working around it. jsdom never computes real scrollable-overflow
435
472
  * geometry, so no unit test caught this — only an actual browser render did.
436
473
  */
437
- declare function Flyout({ label, icon, active, children, className }: FlyoutProps): react.JSX.Element;
474
+ declare function Flyout({ label, icon, active, children, linkComponent, className }: FlyoutProps): react.JSX.Element;
438
475
 
439
476
  /**
440
477
  * ContextSwitcher (DEV-4408) — promoted out of reeve-frontend's
@@ -501,4 +538,4 @@ interface ContextSwitcherProps {
501
538
  }
502
539
  declare function ContextSwitcher({ items, activeRefId, onSelect, footer, placeholder, collapsed, side, align, contentClassName, }: ContextSwitcherProps): react.JSX.Element;
503
540
 
504
- export { type AppRungItem, AppSidebar, type AppSidebarBrand, type AppSidebarModuleDef, type AppSidebarProps, type AppSidebarTopItem, type Brand, BrandGlyph, type BrandGlyphProps, type ContextOrgGroup, ContextSwitcher, type ContextSwitcherItem, type ContextSwitcherProps, type FallbackModuleDef, Flyout, type FlyoutChild, type FlyoutProps, GLYPH_CANVAS_FILL, type GroupRungItem, type HeaderNavItem, LOCKUP_GAP, LOCKUP_ROW, MARK_CAP_RATIO, REEVE_ACCENT, ResponsiveHeader, type ResponsiveHeaderProps, type RungItem, type SidebarLadder, type SidebarLadderParsed, type SidebarRung, type UseLadderRungOptions, deriveFallbackLadder, faviconUrl, groupByOrg, reconcileLadder, resolveLadder, useLadderRung, useResponsivePlaceholder, zSidebarLadder };
541
+ export { type AppRungItem, AppSidebar, type AppSidebarBrand, type AppSidebarModuleDef, type AppSidebarProps, type AppSidebarTopItem, type Brand, BrandGlyph, type BrandGlyphProps, type ContextOrgGroup, ContextSwitcher, type ContextSwitcherItem, type ContextSwitcherProps, type FallbackModuleDef, Flyout, type FlyoutChild, type FlyoutProps, GLYPH_CANVAS_FILL, type GroupRungItem, type HeaderNavItem, LOCKUP_GAP, LOCKUP_ROW, MARK_CAP_RATIO, MARK_WEIGHT, REEVE_ACCENT, ResponsiveHeader, type ResponsiveHeaderProps, type RungItem, type SidebarLadder, type SidebarLadderParsed, type SidebarRung, type UseLadderRungOptions, WORDMARK_WEIGHT, deriveFallbackLadder, faviconUrl, groupByOrg, reconcileLadder, resolveLadder, useLadderRung, useResponsivePlaceholder, zSidebarLadder };
package/dist/index.js CHANGED
@@ -39,8 +39,10 @@ __export(src_exports, {
39
39
  LOCKUP_GAP: () => LOCKUP_GAP,
40
40
  LOCKUP_ROW: () => LOCKUP_ROW,
41
41
  MARK_CAP_RATIO: () => MARK_CAP_RATIO,
42
+ MARK_WEIGHT: () => MARK_WEIGHT,
42
43
  REEVE_ACCENT: () => REEVE_ACCENT,
43
44
  ResponsiveHeader: () => ResponsiveHeader,
45
+ WORDMARK_WEIGHT: () => WORDMARK_WEIGHT,
44
46
  deriveFallbackLadder: () => deriveFallbackLadder,
45
47
  faviconUrl: () => faviconUrl,
46
48
  groupByOrg: () => groupByOrg,
@@ -82,7 +84,12 @@ function firstInitial(name) {
82
84
 
83
85
  // src/lib/lockup.ts
84
86
  var MARK_CAP_RATIO = 1.08;
85
- var MONOGRAM_TEXT_LG = "text-[15px]";
87
+ var MARK_WEIGHT = 900;
88
+ var MARK_WEIGHT_CLASS = "font-black";
89
+ var WORDMARK_WEIGHT = 800;
90
+ var WORDMARK_WEIGHT_CLASS = "font-extrabold";
91
+ var WORDMARK_TEXT_LG = "text-base";
92
+ var MONOGRAM_TEXT_LG = "text-[17px]";
86
93
  var LOCKUP_GAP = "gap-2";
87
94
  var LOCKUP_ROW = "flex items-baseline";
88
95
  var GLYPH_CANVAS_FILL = 0.65;
@@ -170,11 +177,14 @@ function BrandGlyph({ brand, size = "sm", subtle = false, className }) {
170
177
  "aria-hidden": true,
171
178
  "data-testid": "brand-monogram",
172
179
  className: cn(
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
180
+ // DEV-5340 rule 7: the mark carries the family weight (Black 900)
181
+ // at every size — weight is family-wide, unlike the size audit.
182
+ "flex shrink-0 select-none items-baseline justify-center uppercase",
183
+ MARK_WEIGHT_CLASS,
184
+ // DEV-5139 rule 2: at "lg" the letter runs 17px = the text-base
185
+ // wordmark (16px) × MARK_CAP_RATIO (1.08) — a lone round glyph at
176
186
  // exact parity optically reads small. "sm" has no wordmark partner
177
- // in its surfaces; unchanged pending a lockup audit there.
187
+ // in its surfaces; SIZE unchanged pending a lockup audit there.
178
188
  size === "lg" ? MONOGRAM_TEXT_LG : "text-[11px]",
179
189
  box,
180
190
  className
@@ -218,7 +228,9 @@ var MIN_SIZES = {
218
228
  /** Generic minimum width/height for a secondary control (button, select), px. */
219
229
  control: 44,
220
230
  /** Max width of the brand wordmark before it truncates, px. */
221
- wordmarkMaxWidth: 180
231
+ // DEV-5340: scaled with the wordmark's text-sm→text-base bump (180 × 16/14
232
+ // ≈ 206) so the truncation point keeps the same character budget.
233
+ wordmarkMaxWidth: 205
222
234
  };
223
235
  var BREAKPOINTS = {
224
236
  /** Tailwind `sm`. */
@@ -329,7 +341,13 @@ function ResponsiveHeader({
329
341
  "span",
330
342
  {
331
343
  "data-testid": "header-wordmark",
332
- className: "hidden truncate text-sm font-semibold text-foreground @min-[768px]:block",
344
+ className: cn(
345
+ // DEV-5340: family scale + weight — text-base / ExtraBold (800),
346
+ // via the named lockup constants (never per-surface literals).
347
+ "hidden truncate text-foreground @min-[768px]:block",
348
+ WORDMARK_TEXT_LG,
349
+ WORDMARK_WEIGHT_CLASS
350
+ ),
333
351
  style: { maxWidth: MIN_SIZES.wordmarkMaxWidth },
334
352
  children: brand.name
335
353
  }
@@ -734,7 +752,7 @@ function useLadderRung(ladder, containerRef, options = {}) {
734
752
  var import_react7 = require("react");
735
753
  var import_jsx_runtime3 = require("react/jsx-runtime");
736
754
  var PANEL_GAP = 4;
737
- function Flyout({ label, icon, active, children, className }) {
755
+ function Flyout({ label, icon, active, children, linkComponent, className }) {
738
756
  const [hoverOpen, setHoverOpen] = (0, import_react7.useState)(false);
739
757
  const [clickOpen, setClickOpen] = (0, import_react7.useState)(false);
740
758
  const [panelPosition, setPanelPosition] = (0, import_react7.useState)(null);
@@ -745,6 +763,7 @@ function Flyout({ label, icon, active, children, className }) {
745
763
  const firstItemRef = (0, import_react7.useRef)(null);
746
764
  const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
747
765
  const Icon = resolveIcon(icon);
766
+ const LinkEl = linkComponent ?? "a";
748
767
  const reposition = (0, import_react7.useCallback)(() => {
749
768
  const rect = triggerRef.current?.getBoundingClientRect();
750
769
  if (rect) setPanelPosition({ top: rect.top, left: rect.right + PANEL_GAP });
@@ -818,20 +837,32 @@ function Flyout({ label, icon, active, children, className }) {
818
837
  "z-50 flex min-w-[12rem] flex-col gap-0.5 rounded-md border border-border bg-background p-2 shadow-lg",
819
838
  !prefersReducedMotion && "transition-all duration-150 ease-out"
820
839
  ),
821
- children: children.map((child, i) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
822
- "a",
823
- {
824
- ref: i === 0 ? firstItemRef : void 0,
825
- href: child.href,
826
- "aria-current": child.active ? "page" : void 0,
827
- style: TOUCH_TARGET_STYLE,
828
- className: cn(
829
- "flex items-center truncate rounded-md px-3 py-1.5 text-sm transition-colors",
830
- child.active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
831
- ),
832
- children: child.label
833
- },
834
- child.moduleId
840
+ children: children.map((child, i) => (
841
+ // `ref` on the first row (DEV-4391): the "focus moves into the
842
+ // panel on click open" behavior above needs a real
843
+ // HTMLAnchorElement here. A custom `linkComponent` must forward
844
+ // its ref to the underlying <a> for that to keep working —
845
+ // true for `next/link` (the intended consumer here) and for any
846
+ // React 19 function component (ref-as-prop, no forwardRef
847
+ // needed). A non-forwarding custom component on React <19 just
848
+ // silently loses the focus-on-open behavior (a console ref
849
+ // warning, not a crash) — same tradeoff any Link-injection
850
+ // pattern accepts; not a regression from today's plain `<a>`.
851
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
852
+ LinkEl,
853
+ {
854
+ ref: i === 0 ? firstItemRef : void 0,
855
+ href: child.href,
856
+ "aria-current": child.active ? "page" : void 0,
857
+ style: TOUCH_TARGET_STYLE,
858
+ className: cn(
859
+ "flex items-center truncate rounded-md px-3 py-1.5 text-sm transition-colors",
860
+ child.active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
861
+ ),
862
+ children: child.label
863
+ },
864
+ child.moduleId
865
+ )
835
866
  ))
836
867
  }
837
868
  )
@@ -842,7 +873,7 @@ function Flyout({ label, icon, active, children, className }) {
842
873
 
843
874
  // src/app-sidebar.tsx
844
875
  var import_jsx_runtime4 = require("react/jsx-runtime");
845
- function isHrefActive(activePath, href) {
876
+ function isHrefActive(href, activePath) {
846
877
  return activePath === href || activePath.startsWith(`${href}/`);
847
878
  }
848
879
  function NavLink({
@@ -850,11 +881,13 @@ function NavLink({
850
881
  active,
851
882
  icon,
852
883
  label,
853
- testId
884
+ testId,
885
+ linkComponent
854
886
  }) {
855
887
  const Icon = resolveIcon(icon);
888
+ const LinkEl = linkComponent ?? "a";
856
889
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
857
- "a",
890
+ LinkEl,
858
891
  {
859
892
  href,
860
893
  "data-testid": testId,
@@ -886,8 +919,11 @@ function AppSidebar({
886
919
  topItem,
887
920
  menuLabel = "Menu",
888
921
  ladderOptions,
922
+ linkComponent,
923
+ isActive,
889
924
  className
890
925
  }) {
926
+ const isActiveFn = isActive ?? isHrefActive;
891
927
  const isDesktop = useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
892
928
  const [mobileOpen, setMobileOpen] = (0, import_react8.useState)(false);
893
929
  const containerRef = (0, import_react8.useRef)(null);
@@ -923,10 +959,11 @@ function AppSidebar({
923
959
  NavLink,
924
960
  {
925
961
  href: def.href,
926
- active: isHrefActive(activePath, def.href),
962
+ active: isActiveFn(def.href, activePath),
927
963
  icon: def.icon,
928
964
  label: def.label,
929
- testId: `${testIdPrefix}-${item.moduleId}`
965
+ testId: `${testIdPrefix}-${item.moduleId}`,
966
+ linkComponent
930
967
  },
931
968
  item.moduleId
932
969
  );
@@ -939,11 +976,21 @@ function AppSidebar({
939
976
  moduleId,
940
977
  label: def?.label ?? moduleId,
941
978
  href,
942
- active: isHrefActive(activePath, href)
979
+ active: isActiveFn(href, activePath)
943
980
  };
944
981
  });
945
982
  const active = children.some((c) => c.active);
946
- return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Flyout, { label: item.label, icon: item.icon, active, children }, item.id);
983
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
984
+ Flyout,
985
+ {
986
+ label: item.label,
987
+ icon: item.icon,
988
+ active,
989
+ children,
990
+ linkComponent
991
+ },
992
+ item.id
993
+ );
947
994
  }
948
995
  const brandBlock = brand && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
949
996
  "a",
@@ -963,10 +1010,11 @@ function AppSidebar({
963
1010
  NavLink,
964
1011
  {
965
1012
  href: topItem.href,
966
- active: isHrefActive(activePath, topItem.href),
1013
+ active: isActiveFn(topItem.href, activePath),
967
1014
  icon: topItem.icon,
968
1015
  label: topItem.label,
969
- testId
1016
+ testId,
1017
+ linkComponent
970
1018
  }
971
1019
  ) });
972
1020
  }
@@ -1235,8 +1283,10 @@ function ContextSwitcher({
1235
1283
  LOCKUP_GAP,
1236
1284
  LOCKUP_ROW,
1237
1285
  MARK_CAP_RATIO,
1286
+ MARK_WEIGHT,
1238
1287
  REEVE_ACCENT,
1239
1288
  ResponsiveHeader,
1289
+ WORDMARK_WEIGHT,
1240
1290
  deriveFallbackLadder,
1241
1291
  faviconUrl,
1242
1292
  groupByOrg,
package/dist/index.mjs CHANGED
@@ -30,7 +30,12 @@ function firstInitial(name) {
30
30
 
31
31
  // src/lib/lockup.ts
32
32
  var MARK_CAP_RATIO = 1.08;
33
- var MONOGRAM_TEXT_LG = "text-[15px]";
33
+ var MARK_WEIGHT = 900;
34
+ var MARK_WEIGHT_CLASS = "font-black";
35
+ var WORDMARK_WEIGHT = 800;
36
+ var WORDMARK_WEIGHT_CLASS = "font-extrabold";
37
+ var WORDMARK_TEXT_LG = "text-base";
38
+ var MONOGRAM_TEXT_LG = "text-[17px]";
34
39
  var LOCKUP_GAP = "gap-2";
35
40
  var LOCKUP_ROW = "flex items-baseline";
36
41
  var GLYPH_CANVAS_FILL = 0.65;
@@ -118,11 +123,14 @@ function BrandGlyph({ brand, size = "sm", subtle = false, className }) {
118
123
  "aria-hidden": true,
119
124
  "data-testid": "brand-monogram",
120
125
  className: cn(
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
126
+ // DEV-5340 rule 7: the mark carries the family weight (Black 900)
127
+ // at every size — weight is family-wide, unlike the size audit.
128
+ "flex shrink-0 select-none items-baseline justify-center uppercase",
129
+ MARK_WEIGHT_CLASS,
130
+ // DEV-5139 rule 2: at "lg" the letter runs 17px = the text-base
131
+ // wordmark (16px) × MARK_CAP_RATIO (1.08) — a lone round glyph at
124
132
  // exact parity optically reads small. "sm" has no wordmark partner
125
- // in its surfaces; unchanged pending a lockup audit there.
133
+ // in its surfaces; SIZE unchanged pending a lockup audit there.
126
134
  size === "lg" ? MONOGRAM_TEXT_LG : "text-[11px]",
127
135
  box,
128
136
  className
@@ -166,7 +174,9 @@ var MIN_SIZES = {
166
174
  /** Generic minimum width/height for a secondary control (button, select), px. */
167
175
  control: 44,
168
176
  /** Max width of the brand wordmark before it truncates, px. */
169
- wordmarkMaxWidth: 180
177
+ // DEV-5340: scaled with the wordmark's text-sm→text-base bump (180 × 16/14
178
+ // ≈ 206) so the truncation point keeps the same character budget.
179
+ wordmarkMaxWidth: 205
170
180
  };
171
181
  var BREAKPOINTS = {
172
182
  /** Tailwind `sm`. */
@@ -277,7 +287,13 @@ function ResponsiveHeader({
277
287
  "span",
278
288
  {
279
289
  "data-testid": "header-wordmark",
280
- className: "hidden truncate text-sm font-semibold text-foreground @min-[768px]:block",
290
+ className: cn(
291
+ // DEV-5340: family scale + weight — text-base / ExtraBold (800),
292
+ // via the named lockup constants (never per-surface literals).
293
+ "hidden truncate text-foreground @min-[768px]:block",
294
+ WORDMARK_TEXT_LG,
295
+ WORDMARK_WEIGHT_CLASS
296
+ ),
281
297
  style: { maxWidth: MIN_SIZES.wordmarkMaxWidth },
282
298
  children: brand.name
283
299
  }
@@ -682,7 +698,7 @@ function useLadderRung(ladder, containerRef, options = {}) {
682
698
  import { useCallback as useCallback2, useEffect as useEffect4, useId as useId2, useLayoutEffect as useLayoutEffect2, useRef as useRef3, useState as useState5 } from "react";
683
699
  import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
684
700
  var PANEL_GAP = 4;
685
- function Flyout({ label, icon, active, children, className }) {
701
+ function Flyout({ label, icon, active, children, linkComponent, className }) {
686
702
  const [hoverOpen, setHoverOpen] = useState5(false);
687
703
  const [clickOpen, setClickOpen] = useState5(false);
688
704
  const [panelPosition, setPanelPosition] = useState5(null);
@@ -693,6 +709,7 @@ function Flyout({ label, icon, active, children, className }) {
693
709
  const firstItemRef = useRef3(null);
694
710
  const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
695
711
  const Icon = resolveIcon(icon);
712
+ const LinkEl = linkComponent ?? "a";
696
713
  const reposition = useCallback2(() => {
697
714
  const rect = triggerRef.current?.getBoundingClientRect();
698
715
  if (rect) setPanelPosition({ top: rect.top, left: rect.right + PANEL_GAP });
@@ -766,20 +783,32 @@ function Flyout({ label, icon, active, children, className }) {
766
783
  "z-50 flex min-w-[12rem] flex-col gap-0.5 rounded-md border border-border bg-background p-2 shadow-lg",
767
784
  !prefersReducedMotion && "transition-all duration-150 ease-out"
768
785
  ),
769
- children: children.map((child, i) => /* @__PURE__ */ jsx3(
770
- "a",
771
- {
772
- ref: i === 0 ? firstItemRef : void 0,
773
- href: child.href,
774
- "aria-current": child.active ? "page" : void 0,
775
- style: TOUCH_TARGET_STYLE,
776
- className: cn(
777
- "flex items-center truncate rounded-md px-3 py-1.5 text-sm transition-colors",
778
- child.active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
779
- ),
780
- children: child.label
781
- },
782
- child.moduleId
786
+ children: children.map((child, i) => (
787
+ // `ref` on the first row (DEV-4391): the "focus moves into the
788
+ // panel on click open" behavior above needs a real
789
+ // HTMLAnchorElement here. A custom `linkComponent` must forward
790
+ // its ref to the underlying <a> for that to keep working —
791
+ // true for `next/link` (the intended consumer here) and for any
792
+ // React 19 function component (ref-as-prop, no forwardRef
793
+ // needed). A non-forwarding custom component on React <19 just
794
+ // silently loses the focus-on-open behavior (a console ref
795
+ // warning, not a crash) — same tradeoff any Link-injection
796
+ // pattern accepts; not a regression from today's plain `<a>`.
797
+ /* @__PURE__ */ jsx3(
798
+ LinkEl,
799
+ {
800
+ ref: i === 0 ? firstItemRef : void 0,
801
+ href: child.href,
802
+ "aria-current": child.active ? "page" : void 0,
803
+ style: TOUCH_TARGET_STYLE,
804
+ className: cn(
805
+ "flex items-center truncate rounded-md px-3 py-1.5 text-sm transition-colors",
806
+ child.active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
807
+ ),
808
+ children: child.label
809
+ },
810
+ child.moduleId
811
+ )
783
812
  ))
784
813
  }
785
814
  )
@@ -790,7 +819,7 @@ function Flyout({ label, icon, active, children, className }) {
790
819
 
791
820
  // src/app-sidebar.tsx
792
821
  import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
793
- function isHrefActive(activePath, href) {
822
+ function isHrefActive(href, activePath) {
794
823
  return activePath === href || activePath.startsWith(`${href}/`);
795
824
  }
796
825
  function NavLink({
@@ -798,11 +827,13 @@ function NavLink({
798
827
  active,
799
828
  icon,
800
829
  label,
801
- testId
830
+ testId,
831
+ linkComponent
802
832
  }) {
803
833
  const Icon = resolveIcon(icon);
834
+ const LinkEl = linkComponent ?? "a";
804
835
  return /* @__PURE__ */ jsxs4(
805
- "a",
836
+ LinkEl,
806
837
  {
807
838
  href,
808
839
  "data-testid": testId,
@@ -834,8 +865,11 @@ function AppSidebar({
834
865
  topItem,
835
866
  menuLabel = "Menu",
836
867
  ladderOptions,
868
+ linkComponent,
869
+ isActive,
837
870
  className
838
871
  }) {
872
+ const isActiveFn = isActive ?? isHrefActive;
839
873
  const isDesktop = useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
840
874
  const [mobileOpen, setMobileOpen] = useState6(false);
841
875
  const containerRef = useRef4(null);
@@ -871,10 +905,11 @@ function AppSidebar({
871
905
  NavLink,
872
906
  {
873
907
  href: def.href,
874
- active: isHrefActive(activePath, def.href),
908
+ active: isActiveFn(def.href, activePath),
875
909
  icon: def.icon,
876
910
  label: def.label,
877
- testId: `${testIdPrefix}-${item.moduleId}`
911
+ testId: `${testIdPrefix}-${item.moduleId}`,
912
+ linkComponent
878
913
  },
879
914
  item.moduleId
880
915
  );
@@ -887,11 +922,21 @@ function AppSidebar({
887
922
  moduleId,
888
923
  label: def?.label ?? moduleId,
889
924
  href,
890
- active: isHrefActive(activePath, href)
925
+ active: isActiveFn(href, activePath)
891
926
  };
892
927
  });
893
928
  const active = children.some((c) => c.active);
894
- return /* @__PURE__ */ jsx4(Flyout, { label: item.label, icon: item.icon, active, children }, item.id);
929
+ return /* @__PURE__ */ jsx4(
930
+ Flyout,
931
+ {
932
+ label: item.label,
933
+ icon: item.icon,
934
+ active,
935
+ children,
936
+ linkComponent
937
+ },
938
+ item.id
939
+ );
895
940
  }
896
941
  const brandBlock = brand && /* @__PURE__ */ jsxs4(
897
942
  "a",
@@ -911,10 +956,11 @@ function AppSidebar({
911
956
  NavLink,
912
957
  {
913
958
  href: topItem.href,
914
- active: isHrefActive(activePath, topItem.href),
959
+ active: isActiveFn(topItem.href, activePath),
915
960
  icon: topItem.icon,
916
961
  label: topItem.label,
917
- testId
962
+ testId,
963
+ linkComponent
918
964
  }
919
965
  ) });
920
966
  }
@@ -1182,8 +1228,10 @@ export {
1182
1228
  LOCKUP_GAP,
1183
1229
  LOCKUP_ROW,
1184
1230
  MARK_CAP_RATIO,
1231
+ MARK_WEIGHT,
1185
1232
  REEVE_ACCENT,
1186
1233
  ResponsiveHeader,
1234
+ WORDMARK_WEIGHT,
1187
1235
  deriveFallbackLadder,
1188
1236
  faviconUrl,
1189
1237
  groupByOrg,
@@ -17,7 +17,7 @@ declare const MIN_SIZES: {
17
17
  /** Generic minimum width/height for a secondary control (button, select), px. */
18
18
  readonly control: 44;
19
19
  /** Max width of the brand wordmark before it truncates, px. */
20
- readonly wordmarkMaxWidth: 180;
20
+ readonly wordmarkMaxWidth: 205;
21
21
  };
22
22
  declare const BREAKPOINTS: {
23
23
  /** Tailwind `sm`. */
@@ -17,7 +17,7 @@ declare const MIN_SIZES: {
17
17
  /** Generic minimum width/height for a secondary control (button, select), px. */
18
18
  readonly control: 44;
19
19
  /** Max width of the brand wordmark before it truncates, px. */
20
- readonly wordmarkMaxWidth: 180;
20
+ readonly wordmarkMaxWidth: 205;
21
21
  };
22
22
  declare const BREAKPOINTS: {
23
23
  /** Tailwind `sm`. */
@@ -33,7 +33,9 @@ var MIN_SIZES = {
33
33
  /** Generic minimum width/height for a secondary control (button, select), px. */
34
34
  control: 44,
35
35
  /** Max width of the brand wordmark before it truncates, px. */
36
- wordmarkMaxWidth: 180
36
+ // DEV-5340: scaled with the wordmark's text-sm→text-base bump (180 × 16/14
37
+ // ≈ 206) so the truncation point keeps the same character budget.
38
+ wordmarkMaxWidth: 205
37
39
  };
38
40
  var BREAKPOINTS = {
39
41
  /** Tailwind `sm`. */
@@ -7,7 +7,9 @@ var MIN_SIZES = {
7
7
  /** Generic minimum width/height for a secondary control (button, select), px. */
8
8
  control: 44,
9
9
  /** Max width of the brand wordmark before it truncates, px. */
10
- wordmarkMaxWidth: 180
10
+ // DEV-5340: scaled with the wordmark's text-sm→text-base bump (180 × 16/14
11
+ // ≈ 206) so the truncation point keeps the same character budget.
12
+ wordmarkMaxWidth: 205
11
13
  };
12
14
  var BREAKPOINTS = {
13
15
  /** Tailwind `sm`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meetreeve/ui",
3
- "version": "0.5.2",
3
+ "version": "0.6.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",