@meetreeve/ui
v0.14.0
Published
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.
Readme
@meetreeve/ui
Shared brand-adaptive UI primitives for MindFortress frontends: design tokens, a favicon→logo→monogram BrandGlyph, a ResponsiveHeader that keeps the search bar usable on mobile, and AppSidebar — a viewport-driven auto-collapse navigation ladder (DEV-4351).
"use client" components ship in one bundle (.); server-safe design tokens ship separately (./tokens, ./tokens.css) so consumers that don't need the interactive pieces aren't forced into a client boundary.
Install
pnpm add @meetreeve/ui
# peers: react >=18, react-dom >=18, next >=14, lucide-react >=0.400.0, zod >=3.24.0AppSidebar (DEV-4351)
The sidebar for apps with more modules than a short viewport can show at once. It pre-calculates every collapse step (a ladder) rather than computing a layout on the fly: as the container shrinks, whole module groups fold into a single hover/click "flyout" row — nothing is ever hidden, just one level deeper.
import { AppSidebar, type AppSidebarModuleDef } from "@meetreeve/ui";
import { usePathname } from "next/navigation";
import ladderConfig from "@/config/sidebar-ladder.json";
import { MODULE_REGISTRY, GROUP_ORDER } from "@/app/_lib/modules";
const modules: AppSidebarModuleDef[] = MODULE_REGISTRY.map((m) => ({
moduleId: m.name,
label: m.label,
icon: m.icon.displayName ?? m.icon.name, // lucide-react export name, e.g. "Mail"
group: m.group,
href: `/modules/${m.name}`,
}));
export function Sidebar() {
const activePath = usePathname();
return (
<AppSidebar
ladder={ladderConfig} // omit/undefined when no per-tenant config exists yet
modules={modules}
groupOrder={GROUP_ORDER}
activePath={activePath}
brand={{ label: "reeve-tenant", href: "/dashboard" }}
topItem={{ href: "/dashboard", label: "Overview", icon: "LayoutDashboard" }}
/>
);
}The ladder data model
A SidebarLadder is a per-tenant JSON artifact (src/config/sidebar-ladder.json by convention) — an ordered list of rungs, most-expanded first. Each rung declares every row as either a bare app (one module) or a folded group (a parent row whose children open in a flyout). rows strictly decreases rung-over-rung; rung 0 is always the fully-expanded layout (no folded groups).
interface SidebarLadder {
version: number;
rungs: SidebarRung[]; // ordered, most-expanded first
}
interface SidebarRung {
rows: number; // === items.length
items: RungItem[]; // top-to-bottom render order
}
type RungItem =
| { type: "app"; moduleId: string }
| { type: "group"; id: string; label: string; icon: string; children: string[] };icon is a lucide-react named export string (e.g. "Mail", "Layers") — the ladder is plain JSON, so it can't carry a component reference; AppSidebar resolves the name to a component at render time (falling back to Layers if unresolved).
zSidebarLadder (exported from @meetreeve/ui) enforces every invariant at load: every module appears exactly once per rung (as its own row, or inside exactly one group — never both, never twice); the module set is identical across every rung (folding can't invent or lose a module); group children is non-empty; rung 0 has no folded groups.
No config? No agent output? It still works.
If ladder is omitted, null, or fails zSidebarLadder validation, AppSidebar derives a deterministic ladder from modules + groupOrder (deriveFallbackLadder) — folding the last group in groupOrder first, walking backward. A bad agent-authored config can never brick a tenant's navigation.
A valid-but-stale ladder (modules added/removed since it was authored) is reconciled against the live modules list (reconcileLadder, exported alongside): an unknown moduleId is dropped; a moduleId the ladder doesn't reference anywhere is appended as a new top-level row on every rung.
Rung selection (useLadderRung)
AppSidebar measures its nav container with ResizeObserver and snaps to the largest rung whose rows fits floor(containerHeight / rowHeight). Stepping DOWN to a more-folded rung is immediate; stepping back UP requires an extra row of slack (hysteresisRows, default 1) so a resize sitting right at a boundary doesn't flap. The hook (and AppSidebar's ladderOptions prop) exposes both rowHeight and hysteresisRows for tuning.
Server renders rung 0; the real rung is selected client-side in useLayoutEffect, before paint — no visible snap flash on load.
Mobile
The ladder itself is desktop-only. Below the md breakpoint, AppSidebar renders a self-contained hamburger trigger + overlay drawer (no shadcn/Sheet dependency) showing rung 0 — the full, flat module list — scrollable.
Folded groups (Flyout)
A folded group's row opens a horizontal flyout on hover (desktop) or click (keyboard/touch parity): aria-expanded + menu roles, focus moves to the first child on open, Esc closes and returns focus to the trigger. If the active route's module is inside a folded group, the group's own row shows the active state and the flyout highlights the specific child. No open/close transition under prefers-reduced-motion.
ResponsiveHeader
import { ResponsiveHeader } from "@meetreeve/ui";
<ResponsiveHeader
brand={{ name: "Cadasense", favicon: "/favicon.ico", logo: null }}
search={<SearchInput />}
nav={[{ label: "Docs", href: "/docs" }]}
auth={<AuthMenu />}
themeToggle={<ThemeToggle />}
/>;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.
CSS container-query collapse (DEV-4260)
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.
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:
@source "../../node_modules/@meetreeve/ui/dist";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.
BrandGlyph
import { BrandGlyph } from "@meetreeve/ui";
<BrandGlyph brand={{ name: "Cadasense", favicon: "/favicon.ico", logo: null }} size="lg" />;Resolution order: favicon → logo → initials monogram.
ContextSwitcher (DEV-4408)
One reusable, data-driven, org-grouped dropdown for switching the active
"context" — a brand, a tenant, or whatever refId-addressable entity a host
app maps onto ContextSwitcherItem[]. Fully headless: no data fetching, no
app-specific types. Host apps write a thin wrapper that pulls their own data
source (e.g. useBrand()) and maps it to items — see reeve-frontend's
BrandContextSwitcher for the pattern.
import { ContextSwitcher, type ContextSwitcherItem } from "@meetreeve/ui";
const items: ContextSwitcherItem[] = brands.map((b) => ({
refId: b.id,
name: b.name,
favicon: faviconUrl(b.slug), // pre-resolve any host-specific favicon rules yourself
logo: b.logo_url,
orgId: b.org_id,
orgName: b.org_name,
}));
<ContextSwitcher
items={items}
activeRefId={activeBrand?.id ?? null}
onSelect={(item) => switchBrand(item.refId)}
placeholder="Select brand"
/>;Org headers render only when items span more than one org — the common
single-org case stays a flat list. collapsed swaps the trigger for an
icon-only button (56px nav rail); footer adds a slot below the list (e.g.
an "Add brand" CTA).
InfoTip (DEV-11387)
A "?" circle that reveals a short explanation, so a page can keep caveats and definitions one hover away instead of as paragraphs of body copy.
import { InfoTip } from "@meetreeve/ui";
<h2>Human review required</h2>
<InfoTip label="About human review">
These networks are withheld from buying groups until a person checks the evidence.
</InfoTip>;Built on Base UI's Popover, not a tooltip, because a tooltip never opens on
touch: hover opens it for mouse users, click/tap and Enter/Space open it
everywhere, and Escape or an outside click closes it. side/align set the
preferred placement and the bubble flips near a viewport edge.
The trigger keeps the 44px MIN_SIZES.touchTarget floor while its glyph is
16px. The extra height is cancelled so it sits inline in a heading without
changing the line height, but the full 44px of width stays in the layout — the
hit area never overlaps the text beside it, so expect the glyph to look
generously spaced rather than tight against its heading.
The bubble is portalled to <body> and styles from the host's theme tokens, so
the host must define --color-popover, --color-popover-foreground,
--color-border and --color-ring, and Tailwind consumers must include this
package's dist as a @source (see ResponsiveHeader above).
Tokens
import { MIN_SIZES, BREAKPOINTS } from "@meetreeve/ui/tokens";@import "@meetreeve/ui/tokens.css"; /* Tailwind v4 @theme custom properties */Development
npm install # this package uses npm, NOT the monorepo's pnpm — see repo root CLAUDE.md
npm run typecheck
npm run test
npm run buildNo lint script by design — the quality gate is typecheck + test + build.
