@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/README.md CHANGED
@@ -102,6 +102,18 @@ import { ResponsiveHeader } from "@meetreeve/ui";
102
102
 
103
103
  Search is the primary slot — always inline, floored at a minimum width from `@meetreeve/ui/tokens`. Under width pressure everything ELSE collapses first: the wordmark drops to glyph-only, secondary controls fold into a hamburger disclosure below `md`.
104
104
 
105
+ ### CSS container-query collapse (DEV-4260)
106
+
107
+ The wordmark↔glyph and inline-actions↔hamburger split is pure CSS — a Tailwind v4 `@container` on the header plus container-query variants (`@min-[768px]:...`) — not JS/`matchMedia`. Both the desktop and mobile structures are always in the DOM; CSS alone decides which is visible at the header's *current container width* (not the viewport — the header can be narrower than the viewport, e.g. inside a sidebar). This means: no desktop first-paint FOUC, and a no-JS viewer gets the correct layout for their actual width instead of always falling back to the mobile/hamburger markup. JS is used only for the hamburger's open/close state and one `ResizeObserver` that closes an open mobile menu when the header's own container regrows past the split.
108
+
109
+ **Your Tailwind build must scan this package's dist for these classes to work** — same requirement as `AppSidebar`'s `md:flex` (DEV-4351). Tailwind v4 ignores `.gitignore`'d paths (`node_modules`) by default, so add:
110
+
111
+ ```css
112
+ @source "../../node_modules/@meetreeve/ui/dist";
113
+ ```
114
+
115
+ to your `globals.css` (adjust the relative path to your app). See `reeve-tenant-frontend`'s `src/app/globals.css` for the existing precedent. Without this, `ResponsiveHeader`'s container-variant classes (and `AppSidebar`'s) never make it into your compiled CSS, and the header renders as if `display: none` applied everywhere.
116
+
105
117
  ## BrandGlyph
106
118
 
107
119
  ```tsx
@@ -112,6 +124,40 @@ import { BrandGlyph } from "@meetreeve/ui";
112
124
 
113
125
  Resolution order: favicon → logo → initials monogram.
114
126
 
127
+ ## ContextSwitcher (DEV-4408)
128
+
129
+ One reusable, data-driven, org-grouped dropdown for switching the active
130
+ "context" — a brand, a tenant, or whatever `refId`-addressable entity a host
131
+ app maps onto `ContextSwitcherItem[]`. Fully headless: no data fetching, no
132
+ app-specific types. Host apps write a thin wrapper that pulls their own data
133
+ source (e.g. `useBrand()`) and maps it to items — see reeve-frontend's
134
+ `BrandContextSwitcher` for the pattern.
135
+
136
+ ```tsx
137
+ import { ContextSwitcher, type ContextSwitcherItem } from "@meetreeve/ui";
138
+
139
+ const items: ContextSwitcherItem[] = brands.map((b) => ({
140
+ refId: b.id,
141
+ name: b.name,
142
+ favicon: faviconUrl(b.slug), // pre-resolve any host-specific favicon rules yourself
143
+ logo: b.logo_url,
144
+ orgId: b.org_id,
145
+ orgName: b.org_name,
146
+ }));
147
+
148
+ <ContextSwitcher
149
+ items={items}
150
+ activeRefId={activeBrand?.id ?? null}
151
+ onSelect={(item) => switchBrand(item.refId)}
152
+ placeholder="Select brand"
153
+ />;
154
+ ```
155
+
156
+ Org headers render only when items span more than one org — the common
157
+ single-org case stays a flat list. `collapsed` swaps the trigger for an
158
+ icon-only button (56px nav rail); `footer` adds a slot below the list (e.g.
159
+ an "Add brand" CTA).
160
+
115
161
  ## Tokens
116
162
 
117
163
  ```ts
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as react from 'react';
2
- import { ReactNode, RefObject } from 'react';
2
+ import { ReactNode, ElementType, RefObject } from 'react';
3
3
  import { z } from 'zod';
4
4
 
5
5
  /**
@@ -10,9 +10,11 @@ import { z } from 'zod';
10
10
  * order:
11
11
  * 1. `brand.favicon` — the site's own square icon,
12
12
  * 2. `brand.logo` rendered `object-contain` (no squish),
13
- * 3. a high-contrast letter monogram in a deterministic brand colour.
13
+ * 3. a single-letter monogram in the brand colour + an accent dot
14
+ * (DEV-4539 — modeled on Reeve's own "R." canon; no boxed container).
14
15
  * Each image load error advances to the next candidate.
15
16
  */
17
+ declare const REEVE_ACCENT = "#FF6B2B";
16
18
  interface Brand {
17
19
  /** Display name — drives the wordmark and the monogram fallback. */
18
20
  name: string;
@@ -20,8 +22,17 @@ interface Brand {
20
22
  favicon?: string | null;
21
23
  /** Wider brand logo URL (second cascade step, rendered `object-contain`). */
22
24
  logo?: string | null;
23
- /** Override the deterministic monogram background colour. */
25
+ /** Override the deterministic monogram letter colour. */
24
26
  color?: string | null;
27
+ /**
28
+ * Accent-dot colour for the single-letter monogram fallback (DEV-4539) —
29
+ * the "family mark". Pass the brand DNA's own strong accent colour when
30
+ * one is known; falls back to Reeve's own accent dot (REEVE_ACCENT)
31
+ * otherwise, the same "part of the Reeve ecosystem" signal the backend
32
+ * logo generator resolves. `null`/omitted both mean "use the fallback" —
33
+ * this field becomes a brand-manifest-owned value once C2 lands.
34
+ */
35
+ accentColor?: string | null;
25
36
  }
26
37
  /**
27
38
  * Favicon URL for a brand's canonical domain. Returns null unless `domain`
@@ -38,6 +49,49 @@ interface BrandGlyphProps {
38
49
  }
39
50
  declare function BrandGlyph({ brand, size, subtle, className }: BrandGlyphProps): react.JSX.Element;
40
51
 
52
+ /**
53
+ * Brand lockup metric rules (DEV-5139) — the single source of truth for how a
54
+ * lettermark relates to its wordmark. Grounded in standard identity-system
55
+ * practice: the measurement unit for a lockup is the WORDMARK'S CAP HEIGHT,
56
+ * proportions are locked as ratios, and the pieces share one baseline.
57
+ *
58
+ * Rules v1:
59
+ * 1. Baseline lock — mark, accent dot, and wordmark sit on ONE shared
60
+ * baseline. Flex `items-baseline` in the lockup; never `items-center`
61
+ * for text marks (center-aligning boxes ≠ aligning baselines — measured
62
+ * 3px baseline float on cadasense, DEV-5139).
63
+ * 2. Cap-height parity + optical overshoot — the mark's cap height is
64
+ * MARK_CAP_RATIO × the wordmark's cap height. A lone round glyph
65
+ * (C/G/O/Q/S) optically undershoots flat caps, so exact parity reads
66
+ * small; 1.08 restores perceived equality (measured 0.93 before).
67
+ * 3. Lockup gap — mark→wordmark gap is 0.5–0.8× the wordmark cap height.
68
+ * 4. Clearspace — ≥1 cap height of empty space around the whole lockup
69
+ * (the header's own padding satisfies this; consumers embedding the
70
+ * lockup elsewhere own the rule).
71
+ * 5. Standalone glyph fill — on a square canvas (favicon, avatar), the
72
+ * letter's cap height is ~60–70% of the canvas edge; ghost-tile below
73
+ * 20px (DEV-4539).
74
+ * 6. Ratios are locked — scale the lockup uniformly; never resize one
75
+ * element of it. These constants are that lock.
76
+ *
77
+ * The generated-asset pipeline (reeve-services brand_identity
78
+ * svg_templates) mirrors these values — see DEV-5140. Change them together.
79
+ */
80
+ /** Rule 2 — mark cap height ÷ wordmark cap height. */
81
+ declare const MARK_CAP_RATIO = 1.08;
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.
85
+ */
86
+ declare const LOCKUP_GAP = "gap-2";
87
+ /**
88
+ * Rule 1, applied: the lockup is one locked unit — baseline-aligned inside,
89
+ * centered as a whole by whatever wraps it (e.g. a touch target).
90
+ */
91
+ declare const LOCKUP_ROW = "flex items-baseline";
92
+ /** Rule 5 — standalone glyph cap height as a fraction of the canvas edge. */
93
+ declare const GLYPH_CANVAS_FILL = 0.65;
94
+
41
95
  interface HeaderNavItem {
42
96
  label: string;
43
97
  href?: string;
@@ -53,6 +107,19 @@ interface ResponsiveHeaderProps {
53
107
  search: ReactNode;
54
108
  /** Secondary nav links — inline on desktop, in the menu on mobile. */
55
109
  nav?: HeaderNavItem[];
110
+ /**
111
+ * Optional destination for the brand lockup. When set, the glyph + wordmark
112
+ * become a single clickable link to this href (restores the "click the logo
113
+ * to go home" affordance). When omitted, the lockup is a plain, non-link div.
114
+ */
115
+ brandHref?: string;
116
+ /**
117
+ * Link renderer for the brand lockup and `nav` href items. Defaults to a bare
118
+ * `"a"` so the package stays framework-agnostic; consumers pass their router's
119
+ * link (e.g. `next/link`) for client-side navigation. Action-only nav items
120
+ * (no href) stay `<button>` regardless.
121
+ */
122
+ linkComponent?: ElementType;
56
123
  /** Auth control (avatar / sign-in) — secondary. */
57
124
  auth?: ReactNode;
58
125
  /** Theme toggle — secondary. */
@@ -70,9 +137,53 @@ interface ResponsiveHeaderProps {
70
137
  * brand wordmark drops to glyph-only and the secondary controls fold into a
71
138
  * hamburger disclosure. The search is never touched.
72
139
  *
73
- * A `"use client"` leaf: it's interactive (matchMedia + disclosure state).
140
+ * CSS container-query collapse (DEV-4260): the wordmark<->glyph and
141
+ * inline-actions<->hamburger split is pure CSS (`@container` on the header +
142
+ * Tailwind v4 container-query variants), NOT JS/matchMedia. Both the desktop
143
+ * and mobile structures render in EVERY environment — SSR, first paint, and
144
+ * with JS fully disabled — so there's no desktop first-paint FOUC and no
145
+ * "no-JS users get hamburger-only" fallback (DEV-4248 follow-up).
146
+ *
147
+ * The split is pinned to `BREAKPOINTS.md` (768px) via an ARBITRARY container
148
+ * variant (`@min-[768px]:...`) rather than Tailwind's *named* `@md` container
149
+ * variant: Tailwind v4's default container scale (`@md` = 448px) is a
150
+ * different scale than the viewport breakpoint scale and does NOT match
151
+ * `BREAKPOINTS.md`. Because Tailwind's build-time scanner needs a literal
152
+ * class string (it greps compiled output, it doesn't evaluate JS), "768" is a
153
+ * hardcoded literal in the classNames below, not interpolated from
154
+ * `BREAKPOINTS.md` — `responsive-header.test.tsx`'s className-contract tests
155
+ * assert against `BREAKPOINTS.md` directly so drift between the token and the
156
+ * literal fails loudly.
157
+ *
158
+ * Consumers must add `@meetreeve/ui/dist` to their Tailwind `@source` scan
159
+ * (see this package's README) or these container-variant classes never make
160
+ * it into the consumer's compiled CSS — same requirement as AppSidebar's
161
+ * `md:flex` (DEV-4351).
162
+ *
163
+ * JS is used ONLY for two things: the hamburger disclosure's open/close
164
+ * state, and ONE ResizeObserver on the header itself (deliberately NOT a
165
+ * viewport matchMedia — the point of DEV-4260/DEV-4248 is that the collapse
166
+ * reacts to the header's own *container* width, e.g. a narrow sidebar on a
167
+ * wide viewport) that tears down an open mobile menu when the header's
168
+ * container regrows past the CSS split.
74
169
  */
75
- declare function ResponsiveHeader({ brand, search, nav, auth, themeToggle, menuLabel, className, }: ResponsiveHeaderProps): react.JSX.Element;
170
+ declare function ResponsiveHeader({ brand, search, nav, brandHref, linkComponent, auth, themeToggle, menuLabel, className, }: ResponsiveHeaderProps): react.JSX.Element;
171
+
172
+ /**
173
+ * Pick a viewport-appropriate placeholder string: the `desktop` copy at/above
174
+ * `md`, the shorter `mobile` copy below it (falling back to `desktop` when no
175
+ * mobile string is supplied). Fixes the "long search prompt clips in the
176
+ * 200px-floored mobile slot" class of bug without the consumer wiring up its
177
+ * own media query.
178
+ *
179
+ * Built on the package's guarded `useMediaQuery`, which returns `false` when
180
+ * `matchMedia` is unavailable — so SSR and the first client paint yield the
181
+ * MOBILE string. That mobile-first default is intentional: the short string
182
+ * never clips, and the value reconciles up to the desktop string on mount once
183
+ * the viewport is measured `>= md` (consistent with the DEV-4260 FOUC note; no
184
+ * hydration mismatch, since server and first client render both read `false`).
185
+ */
186
+ declare function useResponsivePlaceholder(desktop: string, mobile?: string): string;
76
187
 
77
188
  /**
78
189
  * SidebarLadder — the per-tenant JSON artifact (DEV-4351) that pre-calculates
@@ -325,4 +436,69 @@ interface FlyoutProps {
325
436
  */
326
437
  declare function Flyout({ label, icon, active, children, className }: FlyoutProps): react.JSX.Element;
327
438
 
328
- export { type AppRungItem, AppSidebar, type AppSidebarBrand, type AppSidebarModuleDef, type AppSidebarProps, type AppSidebarTopItem, type Brand, BrandGlyph, type BrandGlyphProps, type FallbackModuleDef, Flyout, type FlyoutChild, type FlyoutProps, type GroupRungItem, type HeaderNavItem, ResponsiveHeader, type ResponsiveHeaderProps, type RungItem, type SidebarLadder, type SidebarLadderParsed, type SidebarRung, type UseLadderRungOptions, deriveFallbackLadder, faviconUrl, reconcileLadder, resolveLadder, useLadderRung, zSidebarLadder };
439
+ /**
440
+ * ContextSwitcher (DEV-4408) — promoted out of reeve-frontend's
441
+ * src/components/context/context-switcher.tsx (DEV-2513), which was already
442
+ * fully headless (props in, no app-specific data fetching). One reusable,
443
+ * data-driven, org-grouped dropdown for switching the active "context" —
444
+ * whatever refId-addressable entity a host app's own thin wrapper maps onto
445
+ * `ContextSwitcherItem[]` (reeve-frontend's brand, reeve-tenant-frontend's
446
+ * tenant, or — once the spawned-company substrate lands — a company
447
+ * cockpit). Orgs are pure visual groupers — headers render ONLY for
448
+ * multi-org accounts; the common single-org case stays a flat list.
449
+ *
450
+ * The menu renders through Radix DropdownMenu so it portals out of a
451
+ * narrow/collapsible rail and stays on-screen — Radix handles collision
452
+ * flip/shift. `collapsed` swaps the trigger for an icon-only button that
453
+ * fits a 56px collapsed rail.
454
+ */
455
+ interface ContextSwitcherItem {
456
+ /** Stable identity for this context — a brand id, tenant id, etc. */
457
+ refId: string;
458
+ name: string;
459
+ /**
460
+ * Pre-resolved favicon URL (BrandGlyph's favicon-first cascade). Any
461
+ * host-specific favicon rules (e.g. reeve-frontend's synthetic-domain
462
+ * guard around `faviconUrl`) must be applied by the host BEFORE mapping
463
+ * into this item — this package stays agnostic of any one host's domain
464
+ * conventions.
465
+ */
466
+ favicon?: string | null;
467
+ /** Wider brand/tenant logo URL (BrandGlyph's second cascade step). */
468
+ logo?: string | null;
469
+ /** Override BrandGlyph's deterministic monogram letter colour. */
470
+ color?: string | null;
471
+ /** Override BrandGlyph's monogram accent-dot colour (DEV-4539). */
472
+ accentColor?: string | null;
473
+ orgId?: string | null;
474
+ orgName?: string | null;
475
+ }
476
+ interface ContextOrgGroup {
477
+ orgId: string | null;
478
+ orgName: string | null;
479
+ items: ContextSwitcherItem[];
480
+ }
481
+ /** Group contexts by owning org. Items sort by name; groups by org name. */
482
+ declare function groupByOrg(items: ContextSwitcherItem[]): ContextOrgGroup[];
483
+ interface ContextSwitcherProps {
484
+ items: ContextSwitcherItem[];
485
+ /** refId of the active context. */
486
+ activeRefId: string | null;
487
+ onSelect: (item: ContextSwitcherItem) => void;
488
+ /** Optional dropdown footer slot, e.g. an "Add brand" CTA. */
489
+ footer?: React.ReactNode;
490
+ /** Trigger label when nothing is active. */
491
+ placeholder?: string;
492
+ /** Icon-only trigger — for a collapsed (56px) nav rail. */
493
+ collapsed?: boolean;
494
+ /** Dropdown placement (Radix). Defaults to bottom/start; Radix auto-flips. */
495
+ side?: "top" | "right" | "bottom" | "left";
496
+ align?: "start" | "center" | "end";
497
+ /** Extra classes merged onto the portaled DropdownMenuContent — e.g. a
498
+ * higher z-index so the dropdown clears a high-z overlay (a mobile
499
+ * drawer above the default z-50 content). */
500
+ contentClassName?: string;
501
+ }
502
+ declare function ContextSwitcher({ items, activeRefId, onSelect, footer, placeholder, collapsed, side, align, contentClassName, }: ContextSwitcherProps): react.JSX.Element;
503
+
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 };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as react from 'react';
2
- import { ReactNode, RefObject } from 'react';
2
+ import { ReactNode, ElementType, RefObject } from 'react';
3
3
  import { z } from 'zod';
4
4
 
5
5
  /**
@@ -10,9 +10,11 @@ import { z } from 'zod';
10
10
  * order:
11
11
  * 1. `brand.favicon` — the site's own square icon,
12
12
  * 2. `brand.logo` rendered `object-contain` (no squish),
13
- * 3. a high-contrast letter monogram in a deterministic brand colour.
13
+ * 3. a single-letter monogram in the brand colour + an accent dot
14
+ * (DEV-4539 — modeled on Reeve's own "R." canon; no boxed container).
14
15
  * Each image load error advances to the next candidate.
15
16
  */
17
+ declare const REEVE_ACCENT = "#FF6B2B";
16
18
  interface Brand {
17
19
  /** Display name — drives the wordmark and the monogram fallback. */
18
20
  name: string;
@@ -20,8 +22,17 @@ interface Brand {
20
22
  favicon?: string | null;
21
23
  /** Wider brand logo URL (second cascade step, rendered `object-contain`). */
22
24
  logo?: string | null;
23
- /** Override the deterministic monogram background colour. */
25
+ /** Override the deterministic monogram letter colour. */
24
26
  color?: string | null;
27
+ /**
28
+ * Accent-dot colour for the single-letter monogram fallback (DEV-4539) —
29
+ * the "family mark". Pass the brand DNA's own strong accent colour when
30
+ * one is known; falls back to Reeve's own accent dot (REEVE_ACCENT)
31
+ * otherwise, the same "part of the Reeve ecosystem" signal the backend
32
+ * logo generator resolves. `null`/omitted both mean "use the fallback" —
33
+ * this field becomes a brand-manifest-owned value once C2 lands.
34
+ */
35
+ accentColor?: string | null;
25
36
  }
26
37
  /**
27
38
  * Favicon URL for a brand's canonical domain. Returns null unless `domain`
@@ -38,6 +49,49 @@ interface BrandGlyphProps {
38
49
  }
39
50
  declare function BrandGlyph({ brand, size, subtle, className }: BrandGlyphProps): react.JSX.Element;
40
51
 
52
+ /**
53
+ * Brand lockup metric rules (DEV-5139) — the single source of truth for how a
54
+ * lettermark relates to its wordmark. Grounded in standard identity-system
55
+ * practice: the measurement unit for a lockup is the WORDMARK'S CAP HEIGHT,
56
+ * proportions are locked as ratios, and the pieces share one baseline.
57
+ *
58
+ * Rules v1:
59
+ * 1. Baseline lock — mark, accent dot, and wordmark sit on ONE shared
60
+ * baseline. Flex `items-baseline` in the lockup; never `items-center`
61
+ * for text marks (center-aligning boxes ≠ aligning baselines — measured
62
+ * 3px baseline float on cadasense, DEV-5139).
63
+ * 2. Cap-height parity + optical overshoot — the mark's cap height is
64
+ * MARK_CAP_RATIO × the wordmark's cap height. A lone round glyph
65
+ * (C/G/O/Q/S) optically undershoots flat caps, so exact parity reads
66
+ * small; 1.08 restores perceived equality (measured 0.93 before).
67
+ * 3. Lockup gap — mark→wordmark gap is 0.5–0.8× the wordmark cap height.
68
+ * 4. Clearspace — ≥1 cap height of empty space around the whole lockup
69
+ * (the header's own padding satisfies this; consumers embedding the
70
+ * lockup elsewhere own the rule).
71
+ * 5. Standalone glyph fill — on a square canvas (favicon, avatar), the
72
+ * letter's cap height is ~60–70% of the canvas edge; ghost-tile below
73
+ * 20px (DEV-4539).
74
+ * 6. Ratios are locked — scale the lockup uniformly; never resize one
75
+ * element of it. These constants are that lock.
76
+ *
77
+ * The generated-asset pipeline (reeve-services brand_identity
78
+ * svg_templates) mirrors these values — see DEV-5140. Change them together.
79
+ */
80
+ /** Rule 2 — mark cap height ÷ wordmark cap height. */
81
+ declare const MARK_CAP_RATIO = 1.08;
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.
85
+ */
86
+ declare const LOCKUP_GAP = "gap-2";
87
+ /**
88
+ * Rule 1, applied: the lockup is one locked unit — baseline-aligned inside,
89
+ * centered as a whole by whatever wraps it (e.g. a touch target).
90
+ */
91
+ declare const LOCKUP_ROW = "flex items-baseline";
92
+ /** Rule 5 — standalone glyph cap height as a fraction of the canvas edge. */
93
+ declare const GLYPH_CANVAS_FILL = 0.65;
94
+
41
95
  interface HeaderNavItem {
42
96
  label: string;
43
97
  href?: string;
@@ -53,6 +107,19 @@ interface ResponsiveHeaderProps {
53
107
  search: ReactNode;
54
108
  /** Secondary nav links — inline on desktop, in the menu on mobile. */
55
109
  nav?: HeaderNavItem[];
110
+ /**
111
+ * Optional destination for the brand lockup. When set, the glyph + wordmark
112
+ * become a single clickable link to this href (restores the "click the logo
113
+ * to go home" affordance). When omitted, the lockup is a plain, non-link div.
114
+ */
115
+ brandHref?: string;
116
+ /**
117
+ * Link renderer for the brand lockup and `nav` href items. Defaults to a bare
118
+ * `"a"` so the package stays framework-agnostic; consumers pass their router's
119
+ * link (e.g. `next/link`) for client-side navigation. Action-only nav items
120
+ * (no href) stay `<button>` regardless.
121
+ */
122
+ linkComponent?: ElementType;
56
123
  /** Auth control (avatar / sign-in) — secondary. */
57
124
  auth?: ReactNode;
58
125
  /** Theme toggle — secondary. */
@@ -70,9 +137,53 @@ interface ResponsiveHeaderProps {
70
137
  * brand wordmark drops to glyph-only and the secondary controls fold into a
71
138
  * hamburger disclosure. The search is never touched.
72
139
  *
73
- * A `"use client"` leaf: it's interactive (matchMedia + disclosure state).
140
+ * CSS container-query collapse (DEV-4260): the wordmark<->glyph and
141
+ * inline-actions<->hamburger split is pure CSS (`@container` on the header +
142
+ * Tailwind v4 container-query variants), NOT JS/matchMedia. Both the desktop
143
+ * and mobile structures render in EVERY environment — SSR, first paint, and
144
+ * with JS fully disabled — so there's no desktop first-paint FOUC and no
145
+ * "no-JS users get hamburger-only" fallback (DEV-4248 follow-up).
146
+ *
147
+ * The split is pinned to `BREAKPOINTS.md` (768px) via an ARBITRARY container
148
+ * variant (`@min-[768px]:...`) rather than Tailwind's *named* `@md` container
149
+ * variant: Tailwind v4's default container scale (`@md` = 448px) is a
150
+ * different scale than the viewport breakpoint scale and does NOT match
151
+ * `BREAKPOINTS.md`. Because Tailwind's build-time scanner needs a literal
152
+ * class string (it greps compiled output, it doesn't evaluate JS), "768" is a
153
+ * hardcoded literal in the classNames below, not interpolated from
154
+ * `BREAKPOINTS.md` — `responsive-header.test.tsx`'s className-contract tests
155
+ * assert against `BREAKPOINTS.md` directly so drift between the token and the
156
+ * literal fails loudly.
157
+ *
158
+ * Consumers must add `@meetreeve/ui/dist` to their Tailwind `@source` scan
159
+ * (see this package's README) or these container-variant classes never make
160
+ * it into the consumer's compiled CSS — same requirement as AppSidebar's
161
+ * `md:flex` (DEV-4351).
162
+ *
163
+ * JS is used ONLY for two things: the hamburger disclosure's open/close
164
+ * state, and ONE ResizeObserver on the header itself (deliberately NOT a
165
+ * viewport matchMedia — the point of DEV-4260/DEV-4248 is that the collapse
166
+ * reacts to the header's own *container* width, e.g. a narrow sidebar on a
167
+ * wide viewport) that tears down an open mobile menu when the header's
168
+ * container regrows past the CSS split.
74
169
  */
75
- declare function ResponsiveHeader({ brand, search, nav, auth, themeToggle, menuLabel, className, }: ResponsiveHeaderProps): react.JSX.Element;
170
+ declare function ResponsiveHeader({ brand, search, nav, brandHref, linkComponent, auth, themeToggle, menuLabel, className, }: ResponsiveHeaderProps): react.JSX.Element;
171
+
172
+ /**
173
+ * Pick a viewport-appropriate placeholder string: the `desktop` copy at/above
174
+ * `md`, the shorter `mobile` copy below it (falling back to `desktop` when no
175
+ * mobile string is supplied). Fixes the "long search prompt clips in the
176
+ * 200px-floored mobile slot" class of bug without the consumer wiring up its
177
+ * own media query.
178
+ *
179
+ * Built on the package's guarded `useMediaQuery`, which returns `false` when
180
+ * `matchMedia` is unavailable — so SSR and the first client paint yield the
181
+ * MOBILE string. That mobile-first default is intentional: the short string
182
+ * never clips, and the value reconciles up to the desktop string on mount once
183
+ * the viewport is measured `>= md` (consistent with the DEV-4260 FOUC note; no
184
+ * hydration mismatch, since server and first client render both read `false`).
185
+ */
186
+ declare function useResponsivePlaceholder(desktop: string, mobile?: string): string;
76
187
 
77
188
  /**
78
189
  * SidebarLadder — the per-tenant JSON artifact (DEV-4351) that pre-calculates
@@ -325,4 +436,69 @@ interface FlyoutProps {
325
436
  */
326
437
  declare function Flyout({ label, icon, active, children, className }: FlyoutProps): react.JSX.Element;
327
438
 
328
- export { type AppRungItem, AppSidebar, type AppSidebarBrand, type AppSidebarModuleDef, type AppSidebarProps, type AppSidebarTopItem, type Brand, BrandGlyph, type BrandGlyphProps, type FallbackModuleDef, Flyout, type FlyoutChild, type FlyoutProps, type GroupRungItem, type HeaderNavItem, ResponsiveHeader, type ResponsiveHeaderProps, type RungItem, type SidebarLadder, type SidebarLadderParsed, type SidebarRung, type UseLadderRungOptions, deriveFallbackLadder, faviconUrl, reconcileLadder, resolveLadder, useLadderRung, zSidebarLadder };
439
+ /**
440
+ * ContextSwitcher (DEV-4408) — promoted out of reeve-frontend's
441
+ * src/components/context/context-switcher.tsx (DEV-2513), which was already
442
+ * fully headless (props in, no app-specific data fetching). One reusable,
443
+ * data-driven, org-grouped dropdown for switching the active "context" —
444
+ * whatever refId-addressable entity a host app's own thin wrapper maps onto
445
+ * `ContextSwitcherItem[]` (reeve-frontend's brand, reeve-tenant-frontend's
446
+ * tenant, or — once the spawned-company substrate lands — a company
447
+ * cockpit). Orgs are pure visual groupers — headers render ONLY for
448
+ * multi-org accounts; the common single-org case stays a flat list.
449
+ *
450
+ * The menu renders through Radix DropdownMenu so it portals out of a
451
+ * narrow/collapsible rail and stays on-screen — Radix handles collision
452
+ * flip/shift. `collapsed` swaps the trigger for an icon-only button that
453
+ * fits a 56px collapsed rail.
454
+ */
455
+ interface ContextSwitcherItem {
456
+ /** Stable identity for this context — a brand id, tenant id, etc. */
457
+ refId: string;
458
+ name: string;
459
+ /**
460
+ * Pre-resolved favicon URL (BrandGlyph's favicon-first cascade). Any
461
+ * host-specific favicon rules (e.g. reeve-frontend's synthetic-domain
462
+ * guard around `faviconUrl`) must be applied by the host BEFORE mapping
463
+ * into this item — this package stays agnostic of any one host's domain
464
+ * conventions.
465
+ */
466
+ favicon?: string | null;
467
+ /** Wider brand/tenant logo URL (BrandGlyph's second cascade step). */
468
+ logo?: string | null;
469
+ /** Override BrandGlyph's deterministic monogram letter colour. */
470
+ color?: string | null;
471
+ /** Override BrandGlyph's monogram accent-dot colour (DEV-4539). */
472
+ accentColor?: string | null;
473
+ orgId?: string | null;
474
+ orgName?: string | null;
475
+ }
476
+ interface ContextOrgGroup {
477
+ orgId: string | null;
478
+ orgName: string | null;
479
+ items: ContextSwitcherItem[];
480
+ }
481
+ /** Group contexts by owning org. Items sort by name; groups by org name. */
482
+ declare function groupByOrg(items: ContextSwitcherItem[]): ContextOrgGroup[];
483
+ interface ContextSwitcherProps {
484
+ items: ContextSwitcherItem[];
485
+ /** refId of the active context. */
486
+ activeRefId: string | null;
487
+ onSelect: (item: ContextSwitcherItem) => void;
488
+ /** Optional dropdown footer slot, e.g. an "Add brand" CTA. */
489
+ footer?: React.ReactNode;
490
+ /** Trigger label when nothing is active. */
491
+ placeholder?: string;
492
+ /** Icon-only trigger — for a collapsed (56px) nav rail. */
493
+ collapsed?: boolean;
494
+ /** Dropdown placement (Radix). Defaults to bottom/start; Radix auto-flips. */
495
+ side?: "top" | "right" | "bottom" | "left";
496
+ align?: "start" | "center" | "end";
497
+ /** Extra classes merged onto the portaled DropdownMenuContent — e.g. a
498
+ * higher z-index so the dropdown clears a high-z overlay (a mobile
499
+ * drawer above the default z-50 content). */
500
+ contentClassName?: string;
501
+ }
502
+ declare function ContextSwitcher({ items, activeRefId, onSelect, footer, placeholder, collapsed, side, align, contentClassName, }: ContextSwitcherProps): react.JSX.Element;
503
+
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 };