` plus a
+ * `` / `
` / right-action header block —
+ * each drifting on radius (`rounded-lg` vs `rounded-xl`), border presence, and
+ * stray `shadow-xs`. Panel resolves the surface from `card-shell` (the same
+ * class `` uses) and the title from `designPrimitive.type.panelTitle`, so
+ * every titled panel is identical and retheming happens in one place.
+ *
+ * For an untitled surface use ``. For a header row *inside* an existing
+ * card use ``. For a full-bleed page hero use ``.
+ */
+interface PanelProps {
+ /** Panel heading. Omit for a body-only surface (prefer then). */
+ title?: ReactNode
+ /** Secondary line under the title. */
+ subtitle?: ReactNode
+ /** Small icon rendered inline before the title. */
+ icon?: LucideIcon
+ /** Right-aligned header slot — buttons, selects, badges. */
+ action?: ReactNode
+ /** Extra classes on the panel surface (padding override, margins, etc.). */
+ className?: string
+ /** Extra classes on the header row. */
+ headerClassName?: string
+ /** Semantic element. Defaults to . */
+ as?: 'section' | 'div'
+ id?: string
+ children?: ReactNode
+}
+
+export function Panel({
+ title,
+ subtitle,
+ icon: Icon,
+ action,
+ className,
+ headerClassName,
+ as: Tag = 'section',
+ id,
+ children,
+}: PanelProps) {
+ const hasHeader = title != null || action != null
+ return (
+
+ {hasHeader && (
+
+
+ {Icon &&
}
+ {title != null && (
+
+
{title}
+ {subtitle != null &&
{subtitle}
}
+
+ )}
+
+ {action != null &&
{action}
}
+
+ )}
+ {children}
+
+ )
+}
diff --git a/src/lib/admin-ui.ts b/src/lib/admin-ui.ts
index 512a6a7d6..49e5a7787 100644
--- a/src/lib/admin-ui.ts
+++ b/src/lib/admin-ui.ts
@@ -1,5 +1,6 @@
import { UI_STATUS } from '@/config/ui/status'
import { designPrimitive } from '@/lib/design-system'
+import { NAV_STATE } from '@/lib/design/nav'
/**
* Admin UI Design Tokens — SSOT
@@ -181,8 +182,8 @@ export const adminInteractive = {
rowHoverFaint: 'hover:bg-surface-raised dark:hover:bg-surface-base/2',
/** Checkbox-selected or active leave-period row */
rowSelected: 'bg-action-muted/40',
- /** Sidebar nav item — current route */
- navActive: 'bg-action/10 text-action ring-1 ring-action/20',
+ /** Sidebar nav item — current route. Value lives in the nav-state SSOT. */
+ navActive: NAV_STATE.sidebar.active,
/** Command palette / typeahead keyboard highlight */
pickerActive: 'bg-action-muted text-action',
/** Unread notification row tint */
diff --git a/src/lib/design-system.ts b/src/lib/design-system.ts
index dfb3e7531..6a9f0fb46 100644
--- a/src/lib/design-system.ts
+++ b/src/lib/design-system.ts
@@ -331,7 +331,10 @@ export const designPrimitive = {
// x.ai style: tight tracking, high contrast, scale discipline
type: {
pageTitle: 'text-2xl font-bold tracking-tight text-text-primary',
+ // Header row *inside* a card (AdminSectionHeader). Compact.
sectionTitle:'text-sm font-semibold tracking-tight text-text-primary',
+ // Title of a standalone titled surface (). One step up from sectionTitle.
+ panelTitle: 'text-base font-semibold tracking-tight text-text-primary',
subTitle: 'text-sm font-medium text-text-secondary',
body: 'text-sm text-text-secondary',
meta: 'text-xs text-text-tertiary',
diff --git a/src/lib/design/nav.ts b/src/lib/design/nav.ts
new file mode 100644
index 000000000..990b40e67
--- /dev/null
+++ b/src/lib/design/nav.ts
@@ -0,0 +1,54 @@
+/**
+ * Navigation-state SSOT.
+ *
+ * The ONE definition of how a navigation item looks in each of its shapes and
+ * states. Every nav surface — top header, vertical sidebar, mobile bottom tab
+ * bar, bottom-sheet grid — resolves its active/inactive classes from here, so
+ * "the current page" has exactly one visual meaning across the whole product.
+ *
+ * Neutral module (NOT admin-scoped): dashboard, public, and admin chrome all
+ * consume it. `src/lib/admin-ui.ts` re-exports `NAV_STATE.sidebar.active` as
+ * `adminInteractive.navActive` so the value is defined once, here.
+ *
+ * Before this file, the "active nav item" was encoded five different ways
+ * (`ring-action/20` vs `ring-action/30` vs inline `bg-action-muted text-action`
+ * vs `border-action …` vs bare `text-action`). Add a shape here rather than
+ * hand-rolling a sixth.
+ */
+import { cn } from '@/lib/utils'
+
+export type NavShape = 'sidebar' | 'bottomTab' | 'pill'
+
+/**
+ * base — layout/shape classes shared by both states
+ * active — current route
+ * inactive — resting + hover
+ */
+export const NAV_STATE: Record = {
+ /** Vertical sidebar link (admin shell, detail sidebars). */
+ sidebar: {
+ base: 'flex items-center gap-2.5 rounded-lg px-2 py-3 lg:py-1.5 transition-colors',
+ active: 'bg-action/10 text-action ring-1 ring-action/20',
+ inactive:
+ 'text-text-tertiary hover:bg-surface-raised hover:text-text-primary dark:hover:bg-surface-base/4',
+ },
+ /** Mobile bottom-tab item (icon stacked over label). 56px thumb target. */
+ bottomTab: {
+ base: 'flex min-h-[56px] flex-1 flex-col items-center justify-center gap-0.5 py-2 text-xs transition-colors',
+ active: 'text-action',
+ inactive: 'text-text-tertiary hover:text-text-secondary',
+ },
+ /** Bordered chip/pill (bottom-sheet grid, secondary nav clusters). */
+ pill: {
+ base: 'flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors',
+ active: 'border-action bg-action-muted text-action',
+ inactive:
+ 'border-subtle text-text-secondary hover:border-strong hover:text-text-primary',
+ },
+}
+
+/** Resolve the full class string for a nav item of the given shape + state. */
+export function navLinkClass(shape: NavShape, active: boolean, className?: string): string {
+ const s = NAV_STATE[shape]
+ return cn(s.base, active ? s.active : s.inactive, className)
+}