@jdcpuwiz/homelab-ui 0.10.4 → 0.10.6

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.cjs CHANGED
@@ -416,6 +416,13 @@ function Sidebar({
416
416
  if (!isControlled) setInternalOpen(next);
417
417
  onMobileOpenChange?.(next);
418
418
  };
419
+ (0, import_react5.useEffect)(() => {
420
+ if (typeof document === "undefined") return;
421
+ if (!showMobileTopBar) return;
422
+ const root = document.documentElement;
423
+ root.classList.add("hl-has-mobile-topbar");
424
+ return () => root.classList.remove("hl-has-mobile-topbar");
425
+ }, [showMobileTopBar]);
419
426
  (0, import_react5.useEffect)(() => {
420
427
  if (typeof document === "undefined") return;
421
428
  if (!open) return;
@@ -426,7 +433,7 @@ function Sidebar({
426
433
  };
427
434
  }, [open]);
428
435
  return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(import_jsx_runtime6.Fragment, { children: [
429
- showMobileTopBar && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "fixed inset-x-0 top-0 z-30 flex items-center gap-2 border-b-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] px-3 py-2 md:hidden", children: [
436
+ showMobileTopBar && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "hl-mobile-topbar fixed inset-x-0 top-0 z-30 flex min-h-[var(--hl-mobile-topbar-height,3.25rem)] items-center gap-2 border-b-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] px-3 py-2 md:hidden", children: [
430
437
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
431
438
  "button",
432
439
  {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/components/Button.tsx","../src/lib/utils.ts","../src/components/ConfirmDialog.tsx","../src/components/Modal.tsx","../src/components/EmptyState.tsx","../src/components/PageHeader.tsx","../src/components/Sidebar.tsx","../src/components/SidebarNavItem.tsx","../src/components/Spinner.tsx","../src/components/StatTile.tsx","../src/lib/colors.ts","../src/components/TagChips.tsx","../src/lib/fonts.ts"],"sourcesContent":["// Components\nexport { Button, type ButtonProps } from \"./components/Button\";\nexport {\n ConfirmDialog,\n type ConfirmDialogProps,\n} from \"./components/ConfirmDialog\";\nexport { EmptyState, type EmptyStateProps } from \"./components/EmptyState\";\nexport { Modal, type ModalProps } from \"./components/Modal\";\nexport {\n PageHeader,\n type PageHeaderProps,\n type Crumb,\n} from \"./components/PageHeader\";\nexport { Sidebar, type SidebarProps } from \"./components/Sidebar\";\nexport {\n SidebarNavItem,\n type SidebarNavItemProps,\n} from \"./components/SidebarNavItem\";\nexport { Spinner, type SpinnerProps } from \"./components/Spinner\";\nexport {\n StatTile,\n StatTileRow,\n type StatTileProps,\n type StatTileRowProps,\n} from \"./components/StatTile\";\nexport {\n TagChips,\n type TagChipsProps,\n type TagRef,\n} from \"./components/TagChips\";\n\n// Helpers\nexport { cn } from \"./lib/utils\";\nexport { tagTextColor } from \"./lib/colors\";\nexport { FONT_CSS_VARIABLES, FONT_APP_VARIABLES } from \"./lib/fonts\";\n","\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { cn } from \"../lib/utils\";\n\ntype Variant = \"primary\" | \"secondary\" | \"tertiary\" | \"danger\";\ntype Size = \"sm\" | \"md\";\n\nexport type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {\n variant?: Variant;\n size?: Size;\n};\n\nconst BASE =\n \"inline-flex items-center justify-center gap-1.5 rounded-[0.5rem] font-semibold transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--hl-brand-glow)]\";\n\n// `danger`'s BACKGROUND stays a literal — status colors are a fixed fleet\n// semantic (the preset hardcodes them too), not a per-project brand token.\n// Its label stays literal `text-white` for the same reason: that is text\n// ON a status chip, which lib/colors.ts already rules is always white,\n// NOT reading text on an app surface. Everything else here is a token.\nconst VARIANT: Record<Variant, string> = {\n primary:\n \"bg-[var(--hl-brand)] text-[var(--hl-brand-fg)] hover:bg-[var(--hl-brand-hover)]\",\n secondary:\n \"bg-[var(--hl-card)] text-[var(--hl-foreground)] hover:bg-[var(--hl-card-hover)]\",\n tertiary:\n \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)]\",\n danger: \"bg-[#b91c1c] text-white hover:brightness-110\",\n};\n\nconst SIZE: Record<Size, string> = {\n sm: \"px-3 py-1 text-xs\",\n md: \"px-4 py-1.5 text-sm\",\n};\n\n/**\n * Homelab Button. Use the variant + size props; never pass raw color\n * classes through className. className is for layout extras only\n * (e.g. `w-full`).\n */\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n function Button(\n { variant = \"secondary\", size = \"md\", className, ...rest },\n ref,\n ) {\n return (\n <button\n ref={ref}\n {...rest}\n className={cn(BASE, VARIANT[variant], SIZE[size], className)}\n />\n );\n },\n);\n","import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Conditional Tailwind class joiner. Merges variants and resolves\n * conflicts (e.g. `cn(\"p-2\", \"p-4\")` → `\"p-4\"`).\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","\"use client\";\n\nimport { useEffect } from \"react\";\nimport { Modal } from \"./Modal\";\nimport { Button } from \"./Button\";\n\nexport type ConfirmDialogProps = {\n open: boolean;\n title: string;\n message: string;\n confirmLabel?: string;\n cancelLabel?: string;\n tone?: \"danger\" | \"primary\";\n onConfirm: () => void;\n onClose: () => void;\n};\n\n/**\n * Confirm/cancel dialog built on Modal + Button. Enter triggers confirm,\n * Esc cancels (via Modal's built-in handling).\n */\nexport function ConfirmDialog({\n open,\n title,\n message,\n confirmLabel = \"Confirm\",\n cancelLabel = \"Cancel\",\n tone = \"danger\",\n onConfirm,\n onClose,\n}: ConfirmDialogProps) {\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Enter\") onConfirm();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onConfirm]);\n\n return (\n <Modal\n open={open}\n onClose={onClose}\n title={title}\n footer={\n <>\n <Button variant=\"tertiary\" size=\"md\" onClick={onClose}>\n {cancelLabel}\n </Button>\n <Button\n variant={tone === \"danger\" ? \"danger\" : \"primary\"}\n size=\"md\"\n onClick={onConfirm}\n >\n {confirmLabel}\n </Button>\n </>\n }\n >\n <p className=\"whitespace-pre-line text-sm text-[var(--hl-foreground)]\">{message}</p>\n </Modal>\n );\n}\n","\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\ntype Density = \"form\" | \"media\";\n\nexport type ModalProps = {\n open: boolean;\n onClose: () => void;\n title?: string;\n /** Show the close X in the top right. Default: true when title is set. */\n showClose?: boolean;\n /**\n * Overlay weight:\n * `form` — dialogs / pickers / confirms (`bg-black/70`)\n * `media` — lightbox / asset preview (`bg-black/90`)\n * Defaults to `form`.\n */\n density?: Density;\n /** Max width — default `max-w-md`. */\n maxWidth?: string;\n /** Footer slot — render buttons here. Always right-aligned. */\n footer?: React.ReactNode;\n children: React.ReactNode;\n};\n\n/**\n * Single modal frame. Every dialog routes through this:\n * - overlay + click-outside dismiss\n * - Esc-to-close\n * - role=dialog + aria-modal + aria-labelledby wiring\n * - DESIGN.md header treatment (uppercase tracking-widest)\n * - shared right-aligned footer row\n */\nexport function Modal({\n open,\n onClose,\n title,\n showClose,\n density = \"form\",\n maxWidth = \"max-w-md\",\n footer,\n children,\n}: ModalProps) {\n const titleIdRef = useRef(\n `hl-modal-${Math.random().toString(36).slice(2, 8)}`,\n );\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") onClose();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onClose]);\n\n if (!open) return null;\n\n const showCloseBtn = showClose ?? Boolean(title);\n const overlayClass = density === \"media\" ? \"bg-black/90\" : \"bg-black/70\";\n\n return (\n <div\n className={cn(\n \"fixed inset-0 z-50 flex items-center justify-center p-4\",\n overlayClass,\n )}\n onClick={onClose}\n role=\"presentation\"\n >\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={title ? titleIdRef.current : undefined}\n className={cn(\n \"w-full rounded-[1rem] border-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] p-5\",\n maxWidth,\n )}\n onClick={(e) => e.stopPropagation()}\n >\n {(title || showCloseBtn) && (\n <div className=\"mb-4 flex items-start justify-between gap-3\">\n {title && (\n <h2\n id={titleIdRef.current}\n className=\"text-sm font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\"\n >\n {title}\n </h2>\n )}\n {showCloseBtn && (\n <button\n type=\"button\"\n onClick={onClose}\n className=\"ml-auto rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] active:scale-90\"\n aria-label=\"Close\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n )}\n {children}\n {footer && (\n <div className=\"mt-5 flex flex-wrap items-center justify-end gap-2\">\n {footer}\n </div>\n )}\n </div>\n </div>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type EmptyStateProps = {\n icon?: React.ReactNode;\n title?: string;\n message: string;\n action?: React.ReactNode;\n /**\n * `panel` — large empty state with warm card background (page bodies).\n * `inline` — compact text-only line for sidebar tree, popovers, etc.\n */\n variant?: \"panel\" | \"inline\";\n className?: string;\n};\n\nexport function EmptyState({\n icon,\n title,\n message,\n action,\n variant = \"panel\",\n className,\n}: EmptyStateProps) {\n if (variant === \"inline\") {\n return (\n <p className={cn(\"px-3 py-2 text-xs text-[var(--hl-foreground)]\", className)}>\n {message}\n </p>\n );\n }\n return (\n <div\n className={cn(\n \"flex flex-col items-center justify-center gap-3 rounded-[1rem] bg-[var(--hl-card-warm)] p-12 text-center\",\n className,\n )}\n >\n {icon && (\n <div className=\"text-[var(--hl-foreground-faint)]\">{icon}</div>\n )}\n {title && (\n <h3 className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {title}\n </h3>\n )}\n <p className=\"text-sm text-[var(--hl-foreground)]\">{message}</p>\n {action}\n </div>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { ArrowLeft, ChevronRight, ImageOff } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type Crumb = {\n label: string;\n onClick?: () => void;\n};\n\nexport type PageHeaderProps = {\n title: string;\n description?: string;\n /** Back link rendered above the title. */\n back?: { href: string; label: string; render?: (href: string, children: React.ReactNode) => React.ReactNode };\n /** Right-aligned actions slot. */\n actions?: React.ReactNode;\n /**\n * Header image URL (e.g. `/logos.png`). When the URL resolves, the\n * image is the visual title. When it 404s, a styled placeholder at\n * the same dimensions takes its place — the layout stays stable so\n * creating a new themed page \"just works\" the moment you drop a\n * matching PNG in /public/.\n *\n * When `imageSrc` is undefined, no image block renders and the\n * text title is the visual title.\n */\n imageSrc?: string;\n imageAlt?: string;\n /**\n * Breadcrumb trail. When provided, replaces the text title. Last\n * crumb is current (full foreground + bold); earlier crumbs are muted\n * and clickable.\n */\n breadcrumb?: Crumb[];\n className?: string;\n};\n\n/**\n * Page-header block. The image block reserves space whenever `imageSrc`\n * is set — drop the PNG in /public/ and the layout just fills in.\n *\n * Text title rules:\n * - imageSrc set + image present → text becomes sr-only\n * - imageSrc set + image 404 → placeholder block + text becomes sr-only\n * - imageSrc unset → text title is visible\n * - breadcrumb provided → breadcrumb replaces text title regardless of image\n *\n * The back link uses a plain <a> by default. In Next.js, pass\n * `back.render` to use next/link:\n *\n * import Link from \"next/link\";\n * <PageHeader back={{ href: \"/foo\", label: \"Back\", render: (href, c) => <Link href={href}>{c}</Link> }} />\n */\nexport function PageHeader({\n title,\n description,\n back,\n actions,\n imageSrc,\n imageAlt,\n breadcrumb,\n className,\n}: PageHeaderProps) {\n const [imageErrored, setImageErrored] = useState(false);\n useEffect(() => {\n setImageErrored(false);\n }, [imageSrc]);\n\n const hasImageSlot = !!imageSrc;\n const imageRenders = hasImageSlot && !imageErrored;\n const hasBreadcrumb = Array.isArray(breadcrumb) && breadcrumb.length > 0;\n const textIsSrOnly = hasImageSlot;\n\n const backInner = back ? (\n <>\n <ArrowLeft className=\"h-3 w-3\" />\n {back.label}\n </>\n ) : null;\n\n return (\n <header\n className={cn(\n \"flex flex-wrap items-end justify-between gap-3\",\n className,\n )}\n >\n <div className=\"flex min-w-0 items-end gap-4\">\n {hasImageSlot &&\n (imageRenders ? (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={imageSrc}\n alt={imageAlt ?? title}\n onError={() => setImageErrored(true)}\n className=\"h-28 w-28 shrink-0 rounded-[1rem] object-contain md:h-32 md:w-32\"\n />\n ) : (\n <PlaceholderHeader title={title} imageSrc={imageSrc!} />\n ))}\n <div className=\"min-w-0\">\n {back &&\n (back.render ? (\n back.render(\n back.href,\n <span className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\">\n {backInner}\n </span>,\n )\n ) : (\n <a\n href={back.href}\n className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {backInner}\n </a>\n ))}\n {hasBreadcrumb ? (\n <Breadcrumb\n crumbs={breadcrumb!}\n srTitle={title}\n className={back ? \"mt-1\" : \"\"}\n />\n ) : (\n <h1\n className={cn(\n \"text-xl font-semibold text-[var(--hl-title)]\",\n back && \"mt-1\",\n textIsSrOnly && \"sr-only\",\n )}\n >\n {title}\n </h1>\n )}\n {description && (\n <p className=\"mt-1 text-xs text-[var(--hl-foreground)]\">{description}</p>\n )}\n </div>\n </div>\n {actions && <div className=\"flex items-center gap-2\">{actions}</div>}\n </header>\n );\n}\n\nfunction PlaceholderHeader({\n title,\n imageSrc,\n}: {\n title: string;\n imageSrc: string;\n}) {\n const file = imageSrc.replace(/^\\//, \"\");\n return (\n <div\n title={`Drop ${file} in /public to brand this header`}\n className=\"flex h-28 w-28 shrink-0 flex-col items-center justify-center gap-1 rounded-[1rem] border-2 border-dashed border-[var(--hl-border)] bg-[var(--hl-card-warm)] text-[var(--hl-foreground-faint)] md:h-32 md:w-32\"\n >\n <ImageOff className=\"h-6 w-6\" />\n <span className=\"px-2 text-center text-[10px] font-semibold uppercase tracking-widest\">\n {title}\n </span>\n </div>\n );\n}\n\nfunction Breadcrumb({\n crumbs,\n srTitle,\n className,\n}: {\n crumbs: Crumb[];\n srTitle: string;\n className?: string;\n}) {\n return (\n <nav\n aria-label=\"Breadcrumb\"\n className={cn(\n \"flex flex-wrap items-center gap-1.5 text-xl font-semibold\",\n className,\n )}\n >\n <h1 className=\"sr-only\">{srTitle}</h1>\n {crumbs.map((crumb, idx) => {\n const isLast = idx === crumbs.length - 1;\n return (\n <span\n key={`${crumb.label}-${idx}`}\n className=\"flex items-center gap-1.5\"\n >\n {idx > 0 && (\n <ChevronRight\n className=\"h-4 w-4 shrink-0 text-[var(--hl-foreground-faint)]\"\n aria-hidden=\"true\"\n />\n )}\n {crumb.onClick && !isLast ? (\n <button\n type=\"button\"\n onClick={crumb.onClick}\n className=\"rounded px-1 text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {crumb.label}\n </button>\n ) : (\n <span\n className={cn(\n isLast\n ? \"text-[var(--hl-foreground)]\"\n : \"text-[var(--hl-foreground-muted)]\",\n isLast && \"px-1\",\n )}\n aria-current={isLast ? \"page\" : undefined}\n >\n {crumb.label}\n </span>\n )}\n </span>\n );\n })}\n </nav>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Menu, X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarProps = {\n /**\n * Logo image src — typically `/logo.png` or `/<project>-logo-256.png`.\n * Rendered at w-36 h-36 inside a centered block with px-4 pt-6 pb-5\n * + border-b-2 (doghouse-canonical spec). Override sizing/shape via\n * `logoClassName`.\n */\n logoSrc: string;\n /** Alt text for the logo + label shown in the mobile top bar. */\n logoAlt: string;\n /**\n * Override the className applied to the logo `<img>`. Default is\n * `\"h-36 w-36 rounded-[1rem] object-contain\"` (doghouse-canonical\n * square logo). Pass `\"h-auto w-full rounded-md object-contain\"` for\n * full-width banner-style logos, or any other Tailwind classes.\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n logoClassName?: string;\n /**\n * Slots — every section is optional. Render them in this order:\n * 1. Logo block (always)\n * 2. widgets — clock / weather / homepage rotator / etc\n * 3. nav — primary navigation\n * 4. middle — long-running content (folder tree, etc) — flex-1\n * 5. lower — secondary nav (manage-tags, settings link, etc)\n * 6. (spacer)\n * 7. admin — admin section\n * 8. footer — version stamp, etc\n */\n widgets?: React.ReactNode;\n nav?: React.ReactNode;\n middle?: React.ReactNode;\n lower?: React.ReactNode;\n admin?: React.ReactNode;\n footer?: React.ReactNode;\n /**\n * Mobile-drawer behavior. Controlled — the consumer owns the open\n * state so they can wire it from anywhere (top-bar hamburger,\n * keyboard shortcut, route change, etc.).\n *\n * When omitted, the Sidebar manages its own drawer state using the\n * built-in mobile top bar (hamburger renders top-left on screens < md).\n */\n mobileOpen?: boolean;\n onMobileOpenChange?: (open: boolean) => void;\n /** Extra classes on the outer <aside>. */\n className?: string;\n /**\n * Override the className used for the `middle` slot's wrapper.\n *\n * The default — `\"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\"` —\n * is correct for asset-den's \"single panel with a scrollable folder\n * tree inside.\" Override when the middle slot's content is itself a\n * stack of cards (e.g. doghouse's per-service status widgets) — those\n * already sit on the card token, so wrapping them in another same-color\n * panel makes them disappear.\n *\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n middleClassName?: string;\n /**\n * When true, render a built-in mobile top bar (logo + hamburger) on\n * screens < md. Defaults to true. Disable when the consumer renders\n * its own top chrome.\n */\n showMobileTopBar?: boolean;\n};\n\n/**\n * Canonical homelab sidebar shell. Doghouse is the reference impl;\n * this is the extracted version every other project consumes.\n *\n * Dimensions are non-negotiable:\n * - md+ width: 15rem default (override per-project via `:root { --hl-sidebar-width: 18rem; }`)\n * - md+ position: fixed-flex in a `display:flex` parent\n * - mobile: off-canvas drawer at full sidebar width\n * - logo block: w-36 h-36 centered, px-4 pt-6 pb-5, border-b-2\n *\n * Surfaces are `bg-[var(--hl-sidebar)]` / `bg-[var(--hl-card)]` /\n * `border-[var(--hl-border)]` — arbitrary-value classes that read the\n * tokens directly, so they still work when the consumer skips the\n * Tailwind preset AND re-skin correctly when the consumer overrides the\n * vars. (They were raw hex until 0.8.0, which silently pinned every\n * consumer to the fleet palette.) Import globals.css for the defaults.\n */\nexport function Sidebar({\n logoSrc,\n logoAlt,\n logoClassName = \"h-36 w-36 rounded-[1rem] object-contain\",\n widgets,\n nav,\n middle,\n lower,\n admin,\n footer,\n mobileOpen: controlledOpen,\n onMobileOpenChange,\n className,\n middleClassName = \"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\",\n showMobileTopBar = true,\n}: SidebarProps) {\n const [internalOpen, setInternalOpen] = useState(false);\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen : internalOpen;\n\n const setOpen = (next: boolean) => {\n if (!isControlled) setInternalOpen(next);\n onMobileOpenChange?.(next);\n };\n\n // Lock body scroll while the mobile drawer is open.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!open) return;\n const prev = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n return () => {\n document.body.style.overflow = prev;\n };\n }, [open]);\n\n return (\n <>\n {showMobileTopBar && (\n <div className=\"fixed inset-x-0 top-0 z-30 flex items-center gap-2 border-b-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] px-3 py-2 md:hidden\">\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n className=\"rounded-[0.25rem] p-1.5 text-[var(--hl-foreground)] transition hover:bg-[var(--hl-card-hover)] active:scale-90\"\n aria-label=\"Open menu\"\n >\n <Menu className=\"h-5 w-5\" />\n </button>\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={logoSrc} alt=\"\" width={24} height={24} />\n <p className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {logoAlt}\n </p>\n </div>\n )}\n\n {open && (\n <div\n className=\"fixed inset-0 z-40 bg-black/70 md:hidden\"\n onClick={() => setOpen(false)}\n role=\"presentation\"\n />\n )}\n\n <aside\n className={cn(\n \"fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,15rem)] shrink-0 flex-col border-r-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] transition-transform md:static md:translate-x-0 md:h-screen\",\n open ? \"translate-x-0\" : \"-translate-x-full md:translate-x-0\",\n className,\n )}\n >\n <div className=\"relative flex flex-col items-center border-b-2 border-[var(--hl-border)] px-4 pt-6 pb-5\">\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n src={logoSrc}\n alt={logoAlt}\n className={logoClassName}\n />\n {showMobileTopBar && (\n <button\n type=\"button\"\n onClick={() => setOpen(false)}\n className=\"absolute right-3 top-3 rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] md:hidden\"\n aria-label=\"Close menu\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n\n {widgets && <div className=\"mx-3 mt-3\">{widgets}</div>}\n\n {nav && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {nav}\n </nav>\n )}\n\n {middle && <div className={middleClassName}>{middle}</div>}\n\n {lower && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {lower}\n </nav>\n )}\n\n {!middle && <div className=\"flex-1\" />}\n\n {admin && <div>{admin}</div>}\n\n {footer && (\n <div className=\"border-t-2 border-[var(--hl-border)] px-4 py-3 text-center text-xs text-[var(--hl-foreground-faint)]\">\n {footer}\n </div>\n )}\n </aside>\n </>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarNavItemProps = {\n label: string;\n icon?: React.ReactNode;\n active?: boolean;\n onClick?: () => void;\n /**\n * When set, renders as <a href>. Otherwise renders as <button>. In\n * Next.js, prefer passing `render` so you can wrap in next/link\n * without losing classes:\n *\n * <SidebarNavItem\n * label=\"Home\"\n * href=\"/\"\n * render={(props, children) => <Link {...props}>{children}</Link>}\n * />\n *\n * Spread `{...props}` rather than destructuring individual fields\n * (`{ href, className }`) — the props object also carries `aria-current`\n * for the active item, and a narrow destructure silently drops it (and\n * any future field) even though the type still says it's there.\n */\n href?: string;\n render?: (\n props: { href: string; className: string; \"aria-current\"?: \"page\" },\n children: React.ReactNode,\n ) => React.ReactNode;\n};\n\nconst ITEM_CLASS_BASE =\n \"flex items-center gap-2 rounded-[0.5rem] px-3 py-2.5 text-base font-medium transition-all\";\n\nconst itemClass = (active: boolean) =>\n cn(\n ITEM_CLASS_BASE,\n active\n ? \"bg-[var(--hl-brand)] text-[var(--hl-brand-fg)]\"\n : \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-hover-overlay)] hover:text-[var(--hl-foreground)]\",\n );\n\n/**\n * Single nav row for the Sidebar's `nav` / `lower` slots. Matches the\n * doghouse-canonical style: active = brand bg + brand-fg text, inactive =\n * muted text lifting to full foreground on hover, over a hover wash.\n *\n * Every colour here is a token. The active pill reads `--hl-brand` /\n * `--hl-brand-fg` (it was `bg-[#ff9900] text-white` until 0.8.0); the\n * inactive row reads `--hl-foreground-muted` / `--hl-hover-overlay` /\n * `--hl-foreground` (it was `text-white/45 hover:bg-white/5\n * hover:text-white` until 0.9.0). Both were themeability bugs of the same\n * shape — see globals.css.\n */\nexport function SidebarNavItem({\n label,\n icon,\n active = false,\n onClick,\n href,\n render,\n}: SidebarNavItemProps) {\n const className = itemClass(active);\n const ariaCurrent = active ? (\"page\" as const) : undefined;\n const inner = (\n <>\n {icon}\n <span>{label}</span>\n </>\n );\n\n if (href && render)\n return (\n <>{render({ href, className, \"aria-current\": ariaCurrent }, inner)}</>\n );\n if (href) {\n return (\n <a href={href} aria-current={ariaCurrent} className={className}>\n {inner}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-current={ariaCurrent}\n aria-pressed={active}\n className={`${className} w-full`}\n >\n {inner}\n </button>\n );\n}\n","\"use client\";\n\nimport { Loader2 } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SpinnerProps = {\n label?: string;\n className?: string;\n};\n\n/**\n * Consistent inline loading affordance — used wherever the app would\n * otherwise say \"Loading…\" in plain text. Compact spinner + label, on the\n * same `--hl-foreground-muted` as every other secondary line.\n */\nexport function Spinner({ label = \"Loading…\", className }: SpinnerProps) {\n return (\n <p\n className={cn(\n \"flex items-center gap-2 px-3 py-2 text-xs text-[var(--hl-foreground-muted)]\",\n className,\n )}\n >\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n {label}\n </p>\n );\n}\n","import { cn } from \"../lib/utils\";\n\nexport type StatTileProps = {\n /** Uppercase micro-label above the value. */\n label: string;\n /** The stat itself — keep it short; it truncates rather than wraps. */\n value: React.ReactNode;\n /** Optional caption under the value. The line is ALWAYS reserved so the\n * tile height never changes when a sub appears/disappears. */\n sub?: React.ReactNode;\n className?: string;\n};\n\n/**\n * Live-stat tile with STABLE GEOMETRY — the fleet standard for status\n * cards (born on zoo-docs' dashboard, 2026-07-13):\n *\n * - text truncates, never wraps → tile height is constant\n * - the sub line renders even when empty → no height jump when data\n * (queue counts, countdowns) comes and goes between polls\n * - value/sub render in font-mono (metrically monospaced) so ticking\n * numbers don't wiggle the width — tabular-nums alone doesn't do this,\n * since the fleet's Poppins/Orbitron subsets don't ship the tnum\n * OpenType feature (B264)\n *\n * Anything elastic in HEIGHT shoves the whole page around on every poll;\n * width elasticity belongs to the row (see StatTileRow).\n */\nexport function StatTile({ label, value, sub, className }: StatTileProps) {\n return (\n <div className={cn(\"min-w-0 rounded-lg bg-[var(--hl-card)] px-4 py-3\", className)}>\n <p className=\"truncate text-[10px] font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\">\n {label}\n </p>\n <p className=\"mt-1 truncate font-mono text-xl font-semibold tabular-nums text-[var(--hl-foreground)]\">{value}</p>\n <p className=\"truncate font-mono text-[11px] tabular-nums text-[var(--hl-foreground)]\">{sub ?? \" \"}</p>\n </div>\n );\n}\n\nexport type StatTileRowProps = {\n children: React.ReactNode;\n /** Minimum tile width before the grid drops a column. Default \"9rem\". */\n minTileWidth?: string;\n className?: string;\n};\n\n/**\n * Full-width, equal-column container for StatTiles. `auto-fit` +\n * `minmax(minTileWidth, 1fr)` means the row stretches edge-to-edge with\n * the browser and sheds whole columns on narrow screens — tiles never\n * overflow off-screen and never wrap their text.\n *\n * Pair with the layout standard: content pages are FULL WIDTH (no\n * max-w-* caps) unless a page explicitly opts out.\n */\nexport function StatTileRow({ children, minTileWidth = \"9rem\", className }: StatTileRowProps) {\n return (\n <div\n className={cn(\"grid gap-3\", className)}\n style={{ gridTemplateColumns: `repeat(auto-fit, minmax(${minTileWidth}, 1fr))` }}\n >\n {children}\n </div>\n );\n}\n","/**\n * Pick black or white text for a solid color chip.\n *\n * The homelab status palette forces white text on every status color\n * EXCEPT yellow (warning: #eab308), which uses black for contrast. This\n * helper enforces that rule wherever a user-picked color drives a chip\n * background (TagChips, status badges, etc).\n *\n * The yellow rule generalizes — anything in the yellow band (high\n * luminance + warm hue) gets black ink.\n */\nexport function tagTextColor(hex: string): \"#ffffff\" | \"#000000\" {\n const normalized = hex.trim().replace(/^#/, \"\");\n if (normalized.length !== 3 && normalized.length !== 6) return \"#ffffff\";\n const expand =\n normalized.length === 3\n ? normalized\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : normalized;\n const r = parseInt(expand.slice(0, 2), 16);\n const g = parseInt(expand.slice(2, 4), 16);\n const b = parseInt(expand.slice(4, 6), 16);\n if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return \"#ffffff\";\n // Perceived luminance per ITU BT.601.\n const luma = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n return luma > 0.65 ? \"#000000\" : \"#ffffff\";\n}\n","\"use client\";\n\nimport { tagTextColor } from \"../lib/colors\";\n\nexport type TagRef = {\n id: string | number;\n name: string;\n color: string;\n};\n\nexport type TagChipsProps = {\n tags: TagRef[];\n size?: \"xs\" | \"sm\";\n onClick?: (tag: TagRef) => void;\n};\n\n/**\n * Render a row of solid-color tag chips. Yellow auto-flips text to\n * black per the homelab status-palette contrast rule (see tagTextColor).\n *\n * Renders `<button>` when interactive (`onClick` set), `<span>` when\n * read-only — keeps screen-reader semantics honest.\n */\nexport function TagChips({ tags, size = \"xs\", onClick }: TagChipsProps) {\n if (tags.length === 0) return null;\n const sizeClasses =\n size === \"xs\" ? \"px-1.5 py-0.5 text-[10px]\" : \"px-2 py-0.5 text-xs\";\n\n return (\n <div className=\"flex flex-wrap gap-1\">\n {tags.map((t) => {\n const style = {\n backgroundColor: t.color,\n color: tagTextColor(t.color),\n };\n const className = `inline-block rounded-[0.25rem] font-semibold ${sizeClasses}`;\n return onClick ? (\n <button\n key={t.id}\n type=\"button\"\n onClick={() => onClick(t)}\n className={`${className} transition active:scale-95`}\n style={style}\n >\n {t.name}\n </button>\n ) : (\n <span key={t.id} className={className} style={style}>\n {t.name}\n </span>\n );\n })}\n </div>\n );\n}\n","/**\n * Font wiring for `@jdcpuwiz/homelab-ui`.\n *\n * ## The standard: three self-hosted faces, split by JOB (BuildPlan #57)\n *\n * Wiz ruling 2026-07-24. This SUPERSEDES both the old \"Geist via next/font\"\n * mandate AND the system-ui-only ruling (#345) that briefly replaced it.\n *\n * SANS Poppins — all body / UI / headings / labels\n * DISPLAY Orbitron — BIG numbers only (stat heroes, clock, ≥ ~1.5rem)\n * MONO JetBrains Mono — small values, table figures, version stamps,\n * code (< ~1.5rem)\n *\n * The size line is the hard boundary: Orbitron is a display face and must\n * NEVER be applied to a dense small cell — if a number is small, it's\n * JetBrains Mono.\n *\n * ## The package NOW OWNS THE FACES (C525) — one import, no per-app config\n *\n * import \"@jdcpuwiz/homelab-ui/fonts.css\"\n *\n * That registers all three faces from woff2 shipped in this package, so\n * `next build` never reaches fonts.gstatic.com.\n *\n * This block previously said loading had to be per-app. That was true only of\n * `next/font`: its SWC plugin scans the app's OWN source for literal\n * `const Foo = Font({...})` calls and bundling rewrites `const`→`var`, so a\n * re-exported LOADER genuinely cannot work. The constraint never applied to\n * plain `@font-face`, which is what fonts.css uses.\n *\n * - Next apps: import `fonts.css`. Nothing else — no loaders, no\n * `.variable` classes, no `--font-*` variables.\n * - Vite apps: import `fonts.css` too, or `@fontsource/poppins` etc.\n *\n * ⚠ `FONT_APP_VARIABLES` below is LEGACY. Apps still running `next/font` set\n * those vars and `globals.css` still reads them first, so both mechanisms\n * work side by side during the rollout. Do NOT reference `--font-poppins`\n * and friends in new code: once an app moves to fonts.css they are unset, and\n * `var(--x)` with no fallback ARGUMENT is invalid at computed-value time —\n * the whole declaration is discarded and the element inherits its parent's\n * face rather than reaching your authored fallback. Use `--hl-font-sans` /\n * `--hl-font-display` / `--hl-font-mono`.\n *\n * ## `next/font/google` is a BUILD-TIME NETWORK DEPENDENCY (2026-08-15)\n *\n * The Next path above makes `next build` reach fonts.gstatic.com, which means\n * every consuming app's DEPLOY depends on it. zoovault's did, and lost: its\n * image floated `next` to a release whose next/font/google requests began\n * 404ing, and the deploy died on a commit that built clean locally. The\n * root-cause fix was pinning `next` from a committed lockfile inside the image\n * (`npm ci`, never a bare `npm install`) — do that first, everywhere. Beyond\n * that, self-hosted faces remove the network from the build entirely;\n * zoovault now runs `next/font/local` over woff2 files sourced from\n * @fontsource and committed in-repo.\n *\n * Note the fallback chain in `globals.css` — `var(--font-poppins, \"Poppins\")`.\n * The package asks for the CSS var and then for the LITERAL FAMILY NAME, so\n * anything that registers a \"Poppins\" @font-face satisfies it. next/font is a\n * convention here, not a requirement, and the Vite path above is proof.\n *\n * `globals.css` declares `--hl-font-sans` / `--hl-font-display` /\n * `--hl-font-mono` referencing the app variables below first, then the literal\n * face name, then the platform fallback — so text is readable even mid-load\n * and an un-migrated app degrades to the system stack, never to nothing.\n *\n * Sanity check after deploy — this should name Poppins:\n * getComputedStyle(document.body).fontFamily // → \"…Poppins…\"\n */\n\n/**\n * The package's own token names — the `--hl-font-*` vars the Tailwind preset's\n * `font-sans` / `font-mono` / `font-display` classes resolve to. Consumers\n * rarely touch these directly.\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--hl-font-sans\",\n mono: \"--hl-font-mono\",\n display: \"--hl-font-display\",\n} as const;\n\n/**\n * The app-side CSS variable each next/font loader must expose (its `variable:`\n * option). `globals.css` reads these, so an app that follows this naming gets\n * the three faces wired with zero extra config. Match these EXACTLY.\n *\n * const poppins = Poppins({ ..., variable: FONT_APP_VARIABLES.sans });\n */\nexport const FONT_APP_VARIABLES = {\n sans: \"--font-poppins\",\n display: \"--font-orbitron\",\n mono: \"--font-jetbrains-mono\",\n} as const;\n"],"mappings":"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","names":["Button","import_react","import_react","import_jsx_runtime","import_jsx_runtime","import_jsx_runtime","import_react","import_lucide_react","import_jsx_runtime","import_react","import_lucide_react","import_jsx_runtime","import_jsx_runtime","import_lucide_react","import_jsx_runtime","import_jsx_runtime","import_jsx_runtime"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/components/Button.tsx","../src/lib/utils.ts","../src/components/ConfirmDialog.tsx","../src/components/Modal.tsx","../src/components/EmptyState.tsx","../src/components/PageHeader.tsx","../src/components/Sidebar.tsx","../src/components/SidebarNavItem.tsx","../src/components/Spinner.tsx","../src/components/StatTile.tsx","../src/lib/colors.ts","../src/components/TagChips.tsx","../src/lib/fonts.ts"],"sourcesContent":["// Components\nexport { Button, type ButtonProps } from \"./components/Button\";\nexport {\n ConfirmDialog,\n type ConfirmDialogProps,\n} from \"./components/ConfirmDialog\";\nexport { EmptyState, type EmptyStateProps } from \"./components/EmptyState\";\nexport { Modal, type ModalProps } from \"./components/Modal\";\nexport {\n PageHeader,\n type PageHeaderProps,\n type Crumb,\n} from \"./components/PageHeader\";\nexport { Sidebar, type SidebarProps } from \"./components/Sidebar\";\nexport {\n SidebarNavItem,\n type SidebarNavItemProps,\n} from \"./components/SidebarNavItem\";\nexport { Spinner, type SpinnerProps } from \"./components/Spinner\";\nexport {\n StatTile,\n StatTileRow,\n type StatTileProps,\n type StatTileRowProps,\n} from \"./components/StatTile\";\nexport {\n TagChips,\n type TagChipsProps,\n type TagRef,\n} from \"./components/TagChips\";\n\n// Helpers\nexport { cn } from \"./lib/utils\";\nexport { tagTextColor } from \"./lib/colors\";\nexport { FONT_CSS_VARIABLES, FONT_APP_VARIABLES } from \"./lib/fonts\";\n","\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { cn } from \"../lib/utils\";\n\ntype Variant = \"primary\" | \"secondary\" | \"tertiary\" | \"danger\";\ntype Size = \"sm\" | \"md\";\n\nexport type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {\n variant?: Variant;\n size?: Size;\n};\n\nconst BASE =\n \"inline-flex items-center justify-center gap-1.5 rounded-[0.5rem] font-semibold transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--hl-brand-glow)]\";\n\n// `danger`'s BACKGROUND stays a literal — status colors are a fixed fleet\n// semantic (the preset hardcodes them too), not a per-project brand token.\n// Its label stays literal `text-white` for the same reason: that is text\n// ON a status chip, which lib/colors.ts already rules is always white,\n// NOT reading text on an app surface. Everything else here is a token.\nconst VARIANT: Record<Variant, string> = {\n primary:\n \"bg-[var(--hl-brand)] text-[var(--hl-brand-fg)] hover:bg-[var(--hl-brand-hover)]\",\n secondary:\n \"bg-[var(--hl-card)] text-[var(--hl-foreground)] hover:bg-[var(--hl-card-hover)]\",\n tertiary:\n \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)]\",\n danger: \"bg-[#b91c1c] text-white hover:brightness-110\",\n};\n\nconst SIZE: Record<Size, string> = {\n sm: \"px-3 py-1 text-xs\",\n md: \"px-4 py-1.5 text-sm\",\n};\n\n/**\n * Homelab Button. Use the variant + size props; never pass raw color\n * classes through className. className is for layout extras only\n * (e.g. `w-full`).\n */\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n function Button(\n { variant = \"secondary\", size = \"md\", className, ...rest },\n ref,\n ) {\n return (\n <button\n ref={ref}\n {...rest}\n className={cn(BASE, VARIANT[variant], SIZE[size], className)}\n />\n );\n },\n);\n","import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Conditional Tailwind class joiner. Merges variants and resolves\n * conflicts (e.g. `cn(\"p-2\", \"p-4\")` → `\"p-4\"`).\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","\"use client\";\n\nimport { useEffect } from \"react\";\nimport { Modal } from \"./Modal\";\nimport { Button } from \"./Button\";\n\nexport type ConfirmDialogProps = {\n open: boolean;\n title: string;\n message: string;\n confirmLabel?: string;\n cancelLabel?: string;\n tone?: \"danger\" | \"primary\";\n onConfirm: () => void;\n onClose: () => void;\n};\n\n/**\n * Confirm/cancel dialog built on Modal + Button. Enter triggers confirm,\n * Esc cancels (via Modal's built-in handling).\n */\nexport function ConfirmDialog({\n open,\n title,\n message,\n confirmLabel = \"Confirm\",\n cancelLabel = \"Cancel\",\n tone = \"danger\",\n onConfirm,\n onClose,\n}: ConfirmDialogProps) {\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Enter\") onConfirm();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onConfirm]);\n\n return (\n <Modal\n open={open}\n onClose={onClose}\n title={title}\n footer={\n <>\n <Button variant=\"tertiary\" size=\"md\" onClick={onClose}>\n {cancelLabel}\n </Button>\n <Button\n variant={tone === \"danger\" ? \"danger\" : \"primary\"}\n size=\"md\"\n onClick={onConfirm}\n >\n {confirmLabel}\n </Button>\n </>\n }\n >\n <p className=\"whitespace-pre-line text-sm text-[var(--hl-foreground)]\">{message}</p>\n </Modal>\n );\n}\n","\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\ntype Density = \"form\" | \"media\";\n\nexport type ModalProps = {\n open: boolean;\n onClose: () => void;\n title?: string;\n /** Show the close X in the top right. Default: true when title is set. */\n showClose?: boolean;\n /**\n * Overlay weight:\n * `form` — dialogs / pickers / confirms (`bg-black/70`)\n * `media` — lightbox / asset preview (`bg-black/90`)\n * Defaults to `form`.\n */\n density?: Density;\n /** Max width — default `max-w-md`. */\n maxWidth?: string;\n /** Footer slot — render buttons here. Always right-aligned. */\n footer?: React.ReactNode;\n children: React.ReactNode;\n};\n\n/**\n * Single modal frame. Every dialog routes through this:\n * - overlay + click-outside dismiss\n * - Esc-to-close\n * - role=dialog + aria-modal + aria-labelledby wiring\n * - DESIGN.md header treatment (uppercase tracking-widest)\n * - shared right-aligned footer row\n */\nexport function Modal({\n open,\n onClose,\n title,\n showClose,\n density = \"form\",\n maxWidth = \"max-w-md\",\n footer,\n children,\n}: ModalProps) {\n const titleIdRef = useRef(\n `hl-modal-${Math.random().toString(36).slice(2, 8)}`,\n );\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") onClose();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onClose]);\n\n if (!open) return null;\n\n const showCloseBtn = showClose ?? Boolean(title);\n const overlayClass = density === \"media\" ? \"bg-black/90\" : \"bg-black/70\";\n\n return (\n <div\n className={cn(\n \"fixed inset-0 z-50 flex items-center justify-center p-4\",\n overlayClass,\n )}\n onClick={onClose}\n role=\"presentation\"\n >\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={title ? titleIdRef.current : undefined}\n className={cn(\n \"w-full rounded-[1rem] border-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] p-5\",\n maxWidth,\n )}\n onClick={(e) => e.stopPropagation()}\n >\n {(title || showCloseBtn) && (\n <div className=\"mb-4 flex items-start justify-between gap-3\">\n {title && (\n <h2\n id={titleIdRef.current}\n className=\"text-sm font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\"\n >\n {title}\n </h2>\n )}\n {showCloseBtn && (\n <button\n type=\"button\"\n onClick={onClose}\n className=\"ml-auto rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] active:scale-90\"\n aria-label=\"Close\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n )}\n {children}\n {footer && (\n <div className=\"mt-5 flex flex-wrap items-center justify-end gap-2\">\n {footer}\n </div>\n )}\n </div>\n </div>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type EmptyStateProps = {\n icon?: React.ReactNode;\n title?: string;\n message: string;\n action?: React.ReactNode;\n /**\n * `panel` — large empty state with warm card background (page bodies).\n * `inline` — compact text-only line for sidebar tree, popovers, etc.\n */\n variant?: \"panel\" | \"inline\";\n className?: string;\n};\n\nexport function EmptyState({\n icon,\n title,\n message,\n action,\n variant = \"panel\",\n className,\n}: EmptyStateProps) {\n if (variant === \"inline\") {\n return (\n <p className={cn(\"px-3 py-2 text-xs text-[var(--hl-foreground)]\", className)}>\n {message}\n </p>\n );\n }\n return (\n <div\n className={cn(\n \"flex flex-col items-center justify-center gap-3 rounded-[1rem] bg-[var(--hl-card-warm)] p-12 text-center\",\n className,\n )}\n >\n {icon && (\n <div className=\"text-[var(--hl-foreground-faint)]\">{icon}</div>\n )}\n {title && (\n <h3 className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {title}\n </h3>\n )}\n <p className=\"text-sm text-[var(--hl-foreground)]\">{message}</p>\n {action}\n </div>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { ArrowLeft, ChevronRight, ImageOff } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type Crumb = {\n label: string;\n onClick?: () => void;\n};\n\nexport type PageHeaderProps = {\n title: string;\n description?: string;\n /** Back link rendered above the title. */\n back?: { href: string; label: string; render?: (href: string, children: React.ReactNode) => React.ReactNode };\n /** Right-aligned actions slot. */\n actions?: React.ReactNode;\n /**\n * Header image URL (e.g. `/logos.png`). When the URL resolves, the\n * image is the visual title. When it 404s, a styled placeholder at\n * the same dimensions takes its place — the layout stays stable so\n * creating a new themed page \"just works\" the moment you drop a\n * matching PNG in /public/.\n *\n * When `imageSrc` is undefined, no image block renders and the\n * text title is the visual title.\n */\n imageSrc?: string;\n imageAlt?: string;\n /**\n * Breadcrumb trail. When provided, replaces the text title. Last\n * crumb is current (full foreground + bold); earlier crumbs are muted\n * and clickable.\n */\n breadcrumb?: Crumb[];\n className?: string;\n};\n\n/**\n * Page-header block. The image block reserves space whenever `imageSrc`\n * is set — drop the PNG in /public/ and the layout just fills in.\n *\n * Text title rules:\n * - imageSrc set + image present → text becomes sr-only\n * - imageSrc set + image 404 → placeholder block + text becomes sr-only\n * - imageSrc unset → text title is visible\n * - breadcrumb provided → breadcrumb replaces text title regardless of image\n *\n * The back link uses a plain <a> by default. In Next.js, pass\n * `back.render` to use next/link:\n *\n * import Link from \"next/link\";\n * <PageHeader back={{ href: \"/foo\", label: \"Back\", render: (href, c) => <Link href={href}>{c}</Link> }} />\n */\nexport function PageHeader({\n title,\n description,\n back,\n actions,\n imageSrc,\n imageAlt,\n breadcrumb,\n className,\n}: PageHeaderProps) {\n const [imageErrored, setImageErrored] = useState(false);\n useEffect(() => {\n setImageErrored(false);\n }, [imageSrc]);\n\n const hasImageSlot = !!imageSrc;\n const imageRenders = hasImageSlot && !imageErrored;\n const hasBreadcrumb = Array.isArray(breadcrumb) && breadcrumb.length > 0;\n const textIsSrOnly = hasImageSlot;\n\n const backInner = back ? (\n <>\n <ArrowLeft className=\"h-3 w-3\" />\n {back.label}\n </>\n ) : null;\n\n return (\n <header\n className={cn(\n \"flex flex-wrap items-end justify-between gap-3\",\n className,\n )}\n >\n <div className=\"flex min-w-0 items-end gap-4\">\n {hasImageSlot &&\n (imageRenders ? (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={imageSrc}\n alt={imageAlt ?? title}\n onError={() => setImageErrored(true)}\n className=\"h-28 w-28 shrink-0 rounded-[1rem] object-contain md:h-32 md:w-32\"\n />\n ) : (\n <PlaceholderHeader title={title} imageSrc={imageSrc!} />\n ))}\n <div className=\"min-w-0\">\n {back &&\n (back.render ? (\n back.render(\n back.href,\n <span className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\">\n {backInner}\n </span>,\n )\n ) : (\n <a\n href={back.href}\n className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {backInner}\n </a>\n ))}\n {hasBreadcrumb ? (\n <Breadcrumb\n crumbs={breadcrumb!}\n srTitle={title}\n className={back ? \"mt-1\" : \"\"}\n />\n ) : (\n <h1\n className={cn(\n \"text-xl font-semibold text-[var(--hl-title)]\",\n back && \"mt-1\",\n textIsSrOnly && \"sr-only\",\n )}\n >\n {title}\n </h1>\n )}\n {description && (\n <p className=\"mt-1 text-xs text-[var(--hl-foreground)]\">{description}</p>\n )}\n </div>\n </div>\n {actions && <div className=\"flex items-center gap-2\">{actions}</div>}\n </header>\n );\n}\n\nfunction PlaceholderHeader({\n title,\n imageSrc,\n}: {\n title: string;\n imageSrc: string;\n}) {\n const file = imageSrc.replace(/^\\//, \"\");\n return (\n <div\n title={`Drop ${file} in /public to brand this header`}\n className=\"flex h-28 w-28 shrink-0 flex-col items-center justify-center gap-1 rounded-[1rem] border-2 border-dashed border-[var(--hl-border)] bg-[var(--hl-card-warm)] text-[var(--hl-foreground-faint)] md:h-32 md:w-32\"\n >\n <ImageOff className=\"h-6 w-6\" />\n <span className=\"px-2 text-center text-[10px] font-semibold uppercase tracking-widest\">\n {title}\n </span>\n </div>\n );\n}\n\nfunction Breadcrumb({\n crumbs,\n srTitle,\n className,\n}: {\n crumbs: Crumb[];\n srTitle: string;\n className?: string;\n}) {\n return (\n <nav\n aria-label=\"Breadcrumb\"\n className={cn(\n \"flex flex-wrap items-center gap-1.5 text-xl font-semibold\",\n className,\n )}\n >\n <h1 className=\"sr-only\">{srTitle}</h1>\n {crumbs.map((crumb, idx) => {\n const isLast = idx === crumbs.length - 1;\n return (\n <span\n key={`${crumb.label}-${idx}`}\n className=\"flex items-center gap-1.5\"\n >\n {idx > 0 && (\n <ChevronRight\n className=\"h-4 w-4 shrink-0 text-[var(--hl-foreground-faint)]\"\n aria-hidden=\"true\"\n />\n )}\n {crumb.onClick && !isLast ? (\n <button\n type=\"button\"\n onClick={crumb.onClick}\n className=\"rounded px-1 text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {crumb.label}\n </button>\n ) : (\n <span\n className={cn(\n isLast\n ? \"text-[var(--hl-foreground)]\"\n : \"text-[var(--hl-foreground-muted)]\",\n isLast && \"px-1\",\n )}\n aria-current={isLast ? \"page\" : undefined}\n >\n {crumb.label}\n </span>\n )}\n </span>\n );\n })}\n </nav>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Menu, X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarProps = {\n /**\n * Logo image src — typically `/logo.png` or `/<project>-logo-256.png`.\n * Rendered at w-36 h-36 inside a centered block with px-4 pt-6 pb-5\n * + border-b-2 (doghouse-canonical spec). Override sizing/shape via\n * `logoClassName`.\n */\n logoSrc: string;\n /** Alt text for the logo + label shown in the mobile top bar. */\n logoAlt: string;\n /**\n * Override the className applied to the logo `<img>`. Default is\n * `\"h-36 w-36 rounded-[1rem] object-contain\"` (doghouse-canonical\n * square logo). Pass `\"h-auto w-full rounded-md object-contain\"` for\n * full-width banner-style logos, or any other Tailwind classes.\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n logoClassName?: string;\n /**\n * Slots — every section is optional. Render them in this order:\n * 1. Logo block (always)\n * 2. widgets — clock / weather / homepage rotator / etc\n * 3. nav — primary navigation\n * 4. middle — long-running content (folder tree, etc) — flex-1\n * 5. lower — secondary nav (manage-tags, settings link, etc)\n * 6. (spacer)\n * 7. admin — admin section\n * 8. footer — version stamp, etc\n */\n widgets?: React.ReactNode;\n nav?: React.ReactNode;\n middle?: React.ReactNode;\n lower?: React.ReactNode;\n admin?: React.ReactNode;\n footer?: React.ReactNode;\n /**\n * Mobile-drawer behavior. Controlled — the consumer owns the open\n * state so they can wire it from anywhere (top-bar hamburger,\n * keyboard shortcut, route change, etc.).\n *\n * When omitted, the Sidebar manages its own drawer state using the\n * built-in mobile top bar (hamburger renders top-left on screens < md).\n */\n mobileOpen?: boolean;\n onMobileOpenChange?: (open: boolean) => void;\n /** Extra classes on the outer <aside>. */\n className?: string;\n /**\n * Override the className used for the `middle` slot's wrapper.\n *\n * The default — `\"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\"` —\n * is correct for asset-den's \"single panel with a scrollable folder\n * tree inside.\" Override when the middle slot's content is itself a\n * stack of cards (e.g. doghouse's per-service status widgets) — those\n * already sit on the card token, so wrapping them in another same-color\n * panel makes them disappear.\n *\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n middleClassName?: string;\n /**\n * When true, render a built-in mobile top bar (logo + hamburger) on\n * screens < md. Defaults to true. Disable when the consumer renders\n * its own top chrome.\n */\n showMobileTopBar?: boolean;\n};\n\n/**\n * Canonical homelab sidebar shell. Doghouse is the reference impl;\n * this is the extracted version every other project consumes.\n *\n * Dimensions are non-negotiable:\n * - md+ width: 15rem default (override per-project via `:root { --hl-sidebar-width: 18rem; }`)\n * - md+ position: fixed-flex in a `display:flex` parent\n * - mobile: off-canvas drawer at full sidebar width\n * - logo block: w-36 h-36 centered, px-4 pt-6 pb-5, border-b-2\n *\n * Surfaces are `bg-[var(--hl-sidebar)]` / `bg-[var(--hl-card)]` /\n * `border-[var(--hl-border)]` — arbitrary-value classes that read the\n * tokens directly, so they still work when the consumer skips the\n * Tailwind preset AND re-skin correctly when the consumer overrides the\n * vars. (They were raw hex until 0.8.0, which silently pinned every\n * consumer to the fleet palette.) Import globals.css for the defaults.\n */\nexport function Sidebar({\n logoSrc,\n logoAlt,\n logoClassName = \"h-36 w-36 rounded-[1rem] object-contain\",\n widgets,\n nav,\n middle,\n lower,\n admin,\n footer,\n mobileOpen: controlledOpen,\n onMobileOpenChange,\n className,\n middleClassName = \"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\",\n showMobileTopBar = true,\n}: SidebarProps) {\n const [internalOpen, setInternalOpen] = useState(false);\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen : internalOpen;\n\n const setOpen = (next: boolean) => {\n if (!isControlled) setInternalOpen(next);\n onMobileOpenChange?.(next);\n };\n\n // Reserve the fixed mobile top bar's height in flow (BUG #339).\n //\n // The bar is `fixed`, so it sits OVER the consumer's content and every\n // app's page <h1> was half-hidden behind it on a phone. It cannot be a\n // spacer element rendered here: this component's siblings land inside the\n // consumer's `display:flex` row (`<Sidebar/><main/>`), where a block would\n // become a zero-width flex COLUMN beside <main> and push nothing down.\n //\n // So the reservation is a `body { padding-top }` rule in globals.css, gated\n // on this class — set only while a bar is actually rendered. Five fleet apps\n // pass `showMobileTopBar={false}` and draw their own top chrome; an ungated\n // rule would give each of them 44px of dead space at the top of every phone\n // view. The bar's own min-height and that padding read the SAME\n // `--hl-mobile-topbar-height` token, so the two can never drift.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!showMobileTopBar) return;\n const root = document.documentElement;\n root.classList.add(\"hl-has-mobile-topbar\");\n return () => root.classList.remove(\"hl-has-mobile-topbar\");\n }, [showMobileTopBar]);\n\n // Lock body scroll while the mobile drawer is open.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!open) return;\n const prev = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n return () => {\n document.body.style.overflow = prev;\n };\n }, [open]);\n\n return (\n <>\n {showMobileTopBar && (\n <div className=\"hl-mobile-topbar fixed inset-x-0 top-0 z-30 flex min-h-[var(--hl-mobile-topbar-height,3.25rem)] items-center gap-2 border-b-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] px-3 py-2 md:hidden\">\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n className=\"rounded-[0.25rem] p-1.5 text-[var(--hl-foreground)] transition hover:bg-[var(--hl-card-hover)] active:scale-90\"\n aria-label=\"Open menu\"\n >\n <Menu className=\"h-5 w-5\" />\n </button>\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={logoSrc} alt=\"\" width={24} height={24} />\n <p className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {logoAlt}\n </p>\n </div>\n )}\n\n {open && (\n <div\n className=\"fixed inset-0 z-40 bg-black/70 md:hidden\"\n onClick={() => setOpen(false)}\n role=\"presentation\"\n />\n )}\n\n <aside\n className={cn(\n \"fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,15rem)] shrink-0 flex-col border-r-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] transition-transform md:static md:translate-x-0 md:h-screen\",\n open ? \"translate-x-0\" : \"-translate-x-full md:translate-x-0\",\n className,\n )}\n >\n <div className=\"relative flex flex-col items-center border-b-2 border-[var(--hl-border)] px-4 pt-6 pb-5\">\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n src={logoSrc}\n alt={logoAlt}\n className={logoClassName}\n />\n {showMobileTopBar && (\n <button\n type=\"button\"\n onClick={() => setOpen(false)}\n className=\"absolute right-3 top-3 rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] md:hidden\"\n aria-label=\"Close menu\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n\n {widgets && <div className=\"mx-3 mt-3\">{widgets}</div>}\n\n {nav && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {nav}\n </nav>\n )}\n\n {middle && <div className={middleClassName}>{middle}</div>}\n\n {lower && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {lower}\n </nav>\n )}\n\n {!middle && <div className=\"flex-1\" />}\n\n {admin && <div>{admin}</div>}\n\n {footer && (\n <div className=\"border-t-2 border-[var(--hl-border)] px-4 py-3 text-center text-xs text-[var(--hl-foreground-faint)]\">\n {footer}\n </div>\n )}\n </aside>\n </>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarNavItemProps = {\n label: string;\n icon?: React.ReactNode;\n active?: boolean;\n onClick?: () => void;\n /**\n * When set, renders as <a href>. Otherwise renders as <button>. In\n * Next.js, prefer passing `render` so you can wrap in next/link\n * without losing classes:\n *\n * <SidebarNavItem\n * label=\"Home\"\n * href=\"/\"\n * render={(props, children) => <Link {...props}>{children}</Link>}\n * />\n *\n * Spread `{...props}` rather than destructuring individual fields\n * (`{ href, className }`) — the props object also carries `aria-current`\n * for the active item, and a narrow destructure silently drops it (and\n * any future field) even though the type still says it's there.\n */\n href?: string;\n render?: (\n props: { href: string; className: string; \"aria-current\"?: \"page\" },\n children: React.ReactNode,\n ) => React.ReactNode;\n};\n\nconst ITEM_CLASS_BASE =\n \"flex items-center gap-2 rounded-[0.5rem] px-3 py-2.5 text-base font-medium transition-all\";\n\nconst itemClass = (active: boolean) =>\n cn(\n ITEM_CLASS_BASE,\n active\n ? \"bg-[var(--hl-brand)] text-[var(--hl-brand-fg)]\"\n : \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-hover-overlay)] hover:text-[var(--hl-foreground)]\",\n );\n\n/**\n * Single nav row for the Sidebar's `nav` / `lower` slots. Matches the\n * doghouse-canonical style: active = brand bg + brand-fg text, inactive =\n * muted text lifting to full foreground on hover, over a hover wash.\n *\n * Every colour here is a token. The active pill reads `--hl-brand` /\n * `--hl-brand-fg` (it was `bg-[#ff9900] text-white` until 0.8.0); the\n * inactive row reads `--hl-foreground-muted` / `--hl-hover-overlay` /\n * `--hl-foreground` (it was `text-white/45 hover:bg-white/5\n * hover:text-white` until 0.9.0). Both were themeability bugs of the same\n * shape — see globals.css.\n */\nexport function SidebarNavItem({\n label,\n icon,\n active = false,\n onClick,\n href,\n render,\n}: SidebarNavItemProps) {\n const className = itemClass(active);\n const ariaCurrent = active ? (\"page\" as const) : undefined;\n const inner = (\n <>\n {icon}\n <span>{label}</span>\n </>\n );\n\n if (href && render)\n return (\n <>{render({ href, className, \"aria-current\": ariaCurrent }, inner)}</>\n );\n if (href) {\n return (\n <a href={href} aria-current={ariaCurrent} className={className}>\n {inner}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-current={ariaCurrent}\n aria-pressed={active}\n className={`${className} w-full`}\n >\n {inner}\n </button>\n );\n}\n","\"use client\";\n\nimport { Loader2 } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SpinnerProps = {\n label?: string;\n className?: string;\n};\n\n/**\n * Consistent inline loading affordance — used wherever the app would\n * otherwise say \"Loading…\" in plain text. Compact spinner + label, on the\n * same `--hl-foreground-muted` as every other secondary line.\n */\nexport function Spinner({ label = \"Loading…\", className }: SpinnerProps) {\n return (\n <p\n className={cn(\n \"flex items-center gap-2 px-3 py-2 text-xs text-[var(--hl-foreground-muted)]\",\n className,\n )}\n >\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n {label}\n </p>\n );\n}\n","import { cn } from \"../lib/utils\";\n\nexport type StatTileProps = {\n /** Uppercase micro-label above the value. */\n label: string;\n /** The stat itself — keep it short; it truncates rather than wraps. */\n value: React.ReactNode;\n /** Optional caption under the value. The line is ALWAYS reserved so the\n * tile height never changes when a sub appears/disappears. */\n sub?: React.ReactNode;\n className?: string;\n};\n\n/**\n * Live-stat tile with STABLE GEOMETRY — the fleet standard for status\n * cards (born on zoo-docs' dashboard, 2026-07-13):\n *\n * - text truncates, never wraps → tile height is constant\n * - the sub line renders even when empty → no height jump when data\n * (queue counts, countdowns) comes and goes between polls\n * - value/sub render in font-mono (metrically monospaced) so ticking\n * numbers don't wiggle the width — tabular-nums alone doesn't do this,\n * since the fleet's Poppins/Orbitron subsets don't ship the tnum\n * OpenType feature (B264)\n *\n * Anything elastic in HEIGHT shoves the whole page around on every poll;\n * width elasticity belongs to the row (see StatTileRow).\n */\nexport function StatTile({ label, value, sub, className }: StatTileProps) {\n return (\n <div className={cn(\"min-w-0 rounded-lg bg-[var(--hl-card)] px-4 py-3\", className)}>\n <p className=\"truncate text-[10px] font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\">\n {label}\n </p>\n <p className=\"mt-1 truncate font-mono text-xl font-semibold tabular-nums text-[var(--hl-foreground)]\">{value}</p>\n <p className=\"truncate font-mono text-[11px] tabular-nums text-[var(--hl-foreground)]\">{sub ?? \" \"}</p>\n </div>\n );\n}\n\nexport type StatTileRowProps = {\n children: React.ReactNode;\n /** Minimum tile width before the grid drops a column. Default \"9rem\". */\n minTileWidth?: string;\n className?: string;\n};\n\n/**\n * Full-width, equal-column container for StatTiles. `auto-fit` +\n * `minmax(minTileWidth, 1fr)` means the row stretches edge-to-edge with\n * the browser and sheds whole columns on narrow screens — tiles never\n * overflow off-screen and never wrap their text.\n *\n * Pair with the layout standard: content pages are FULL WIDTH (no\n * max-w-* caps) unless a page explicitly opts out.\n */\nexport function StatTileRow({ children, minTileWidth = \"9rem\", className }: StatTileRowProps) {\n return (\n <div\n className={cn(\"grid gap-3\", className)}\n style={{ gridTemplateColumns: `repeat(auto-fit, minmax(${minTileWidth}, 1fr))` }}\n >\n {children}\n </div>\n );\n}\n","/**\n * Pick black or white text for a solid color chip.\n *\n * The homelab status palette forces white text on every status color\n * EXCEPT yellow (warning: #eab308), which uses black for contrast. This\n * helper enforces that rule wherever a user-picked color drives a chip\n * background (TagChips, status badges, etc).\n *\n * The yellow rule generalizes — anything in the yellow band (high\n * luminance + warm hue) gets black ink.\n */\nexport function tagTextColor(hex: string): \"#ffffff\" | \"#000000\" {\n const normalized = hex.trim().replace(/^#/, \"\");\n if (normalized.length !== 3 && normalized.length !== 6) return \"#ffffff\";\n const expand =\n normalized.length === 3\n ? normalized\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : normalized;\n const r = parseInt(expand.slice(0, 2), 16);\n const g = parseInt(expand.slice(2, 4), 16);\n const b = parseInt(expand.slice(4, 6), 16);\n if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return \"#ffffff\";\n // Perceived luminance per ITU BT.601.\n const luma = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n return luma > 0.65 ? \"#000000\" : \"#ffffff\";\n}\n","\"use client\";\n\nimport { tagTextColor } from \"../lib/colors\";\n\nexport type TagRef = {\n id: string | number;\n name: string;\n color: string;\n};\n\nexport type TagChipsProps = {\n tags: TagRef[];\n size?: \"xs\" | \"sm\";\n onClick?: (tag: TagRef) => void;\n};\n\n/**\n * Render a row of solid-color tag chips. Yellow auto-flips text to\n * black per the homelab status-palette contrast rule (see tagTextColor).\n *\n * Renders `<button>` when interactive (`onClick` set), `<span>` when\n * read-only — keeps screen-reader semantics honest.\n */\nexport function TagChips({ tags, size = \"xs\", onClick }: TagChipsProps) {\n if (tags.length === 0) return null;\n const sizeClasses =\n size === \"xs\" ? \"px-1.5 py-0.5 text-[10px]\" : \"px-2 py-0.5 text-xs\";\n\n return (\n <div className=\"flex flex-wrap gap-1\">\n {tags.map((t) => {\n const style = {\n backgroundColor: t.color,\n color: tagTextColor(t.color),\n };\n const className = `inline-block rounded-[0.25rem] font-semibold ${sizeClasses}`;\n return onClick ? (\n <button\n key={t.id}\n type=\"button\"\n onClick={() => onClick(t)}\n className={`${className} transition active:scale-95`}\n style={style}\n >\n {t.name}\n </button>\n ) : (\n <span key={t.id} className={className} style={style}>\n {t.name}\n </span>\n );\n })}\n </div>\n );\n}\n","/**\n * Font wiring for `@jdcpuwiz/homelab-ui`.\n *\n * ## The standard: three self-hosted faces, split by JOB (BuildPlan #57)\n *\n * Wiz ruling 2026-07-24. This SUPERSEDES both the old \"Geist via next/font\"\n * mandate AND the system-ui-only ruling (#345) that briefly replaced it.\n *\n * SANS Poppins — all body / UI / headings / labels\n * DISPLAY Orbitron — BIG numbers only (stat heroes, clock, ≥ ~1.5rem)\n * MONO JetBrains Mono — small values, table figures, version stamps,\n * code (< ~1.5rem)\n *\n * The size line is the hard boundary: Orbitron is a display face and must\n * NEVER be applied to a dense small cell — if a number is small, it's\n * JetBrains Mono.\n *\n * ## The package NOW OWNS THE FACES (C525) — one import, no per-app config\n *\n * import \"@jdcpuwiz/homelab-ui/fonts.css\"\n *\n * That registers all three faces from woff2 shipped in this package, so\n * `next build` never reaches fonts.gstatic.com.\n *\n * This block previously said loading had to be per-app. That was true only of\n * `next/font`: its SWC plugin scans the app's OWN source for literal\n * `const Foo = Font({...})` calls and bundling rewrites `const`→`var`, so a\n * re-exported LOADER genuinely cannot work. The constraint never applied to\n * plain `@font-face`, which is what fonts.css uses.\n *\n * - Next apps: import `fonts.css`. Nothing else — no loaders, no\n * `.variable` classes, no `--font-*` variables.\n * - Vite apps: import `fonts.css` too, or `@fontsource/poppins` etc.\n *\n * ⚠ `FONT_APP_VARIABLES` below is LEGACY. Apps still running `next/font` set\n * those vars and `globals.css` still reads them first, so both mechanisms\n * work side by side during the rollout. Do NOT reference `--font-poppins`\n * and friends in new code: once an app moves to fonts.css they are unset, and\n * `var(--x)` with no fallback ARGUMENT is invalid at computed-value time —\n * the whole declaration is discarded and the element inherits its parent's\n * face rather than reaching your authored fallback. Use `--hl-font-sans` /\n * `--hl-font-display` / `--hl-font-mono`.\n *\n * ## `next/font/google` is a BUILD-TIME NETWORK DEPENDENCY (2026-08-15)\n *\n * The Next path above makes `next build` reach fonts.gstatic.com, which means\n * every consuming app's DEPLOY depends on it. zoovault's did, and lost: its\n * image floated `next` to a release whose next/font/google requests began\n * 404ing, and the deploy died on a commit that built clean locally. The\n * root-cause fix was pinning `next` from a committed lockfile inside the image\n * (`npm ci`, never a bare `npm install`) — do that first, everywhere. Beyond\n * that, self-hosted faces remove the network from the build entirely;\n * zoovault now runs `next/font/local` over woff2 files sourced from\n * @fontsource and committed in-repo.\n *\n * Note the fallback chain in `globals.css` — `var(--font-poppins, \"Poppins\")`.\n * The package asks for the CSS var and then for the LITERAL FAMILY NAME, so\n * anything that registers a \"Poppins\" @font-face satisfies it. next/font is a\n * convention here, not a requirement, and the Vite path above is proof.\n *\n * `globals.css` declares `--hl-font-sans` / `--hl-font-display` /\n * `--hl-font-mono` referencing the app variables below first, then the literal\n * face name, then the platform fallback — so text is readable even mid-load\n * and an un-migrated app degrades to the system stack, never to nothing.\n *\n * Sanity check after deploy — this should name Poppins:\n * getComputedStyle(document.body).fontFamily // → \"…Poppins…\"\n */\n\n/**\n * The package's own token names — the `--hl-font-*` vars the Tailwind preset's\n * `font-sans` / `font-mono` / `font-display` classes resolve to. Consumers\n * rarely touch these directly.\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--hl-font-sans\",\n mono: \"--hl-font-mono\",\n display: \"--hl-font-display\",\n} as const;\n\n/**\n * The app-side CSS variable each next/font loader must expose (its `variable:`\n * option). `globals.css` reads these, so an app that follows this naming gets\n * the three faces wired with zero extra config. Match these EXACTLY.\n *\n * const poppins = Poppins({ ..., variable: FONT_APP_VARIABLES.sans });\n */\nexport const FONT_APP_VARIABLES = {\n sans: \"--font-poppins\",\n display: \"--font-orbitron\",\n mono: \"--font-jetbrains-mono\",\n} as const;\n"],"mappings":"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","names":["Button","import_react","import_react","import_jsx_runtime","import_jsx_runtime","import_jsx_runtime","import_react","import_lucide_react","import_jsx_runtime","import_react","import_lucide_react","import_jsx_runtime","import_jsx_runtime","import_lucide_react","import_jsx_runtime","import_jsx_runtime","import_jsx_runtime"]}
package/dist/index.js CHANGED
@@ -377,6 +377,13 @@ function Sidebar({
377
377
  if (!isControlled) setInternalOpen(next);
378
378
  onMobileOpenChange?.(next);
379
379
  };
380
+ useEffect4(() => {
381
+ if (typeof document === "undefined") return;
382
+ if (!showMobileTopBar) return;
383
+ const root = document.documentElement;
384
+ root.classList.add("hl-has-mobile-topbar");
385
+ return () => root.classList.remove("hl-has-mobile-topbar");
386
+ }, [showMobileTopBar]);
380
387
  useEffect4(() => {
381
388
  if (typeof document === "undefined") return;
382
389
  if (!open) return;
@@ -387,7 +394,7 @@ function Sidebar({
387
394
  };
388
395
  }, [open]);
389
396
  return /* @__PURE__ */ jsxs5(Fragment3, { children: [
390
- showMobileTopBar && /* @__PURE__ */ jsxs5("div", { className: "fixed inset-x-0 top-0 z-30 flex items-center gap-2 border-b-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] px-3 py-2 md:hidden", children: [
397
+ showMobileTopBar && /* @__PURE__ */ jsxs5("div", { className: "hl-mobile-topbar fixed inset-x-0 top-0 z-30 flex min-h-[var(--hl-mobile-topbar-height,3.25rem)] items-center gap-2 border-b-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] px-3 py-2 md:hidden", children: [
391
398
  /* @__PURE__ */ jsx6(
392
399
  "button",
393
400
  {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/components/Button.tsx","../src/lib/utils.ts","../src/components/ConfirmDialog.tsx","../src/components/Modal.tsx","../src/components/EmptyState.tsx","../src/components/PageHeader.tsx","../src/components/Sidebar.tsx","../src/components/SidebarNavItem.tsx","../src/components/Spinner.tsx","../src/components/StatTile.tsx","../src/lib/colors.ts","../src/components/TagChips.tsx","../src/lib/fonts.ts"],"sourcesContent":["\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { cn } from \"../lib/utils\";\n\ntype Variant = \"primary\" | \"secondary\" | \"tertiary\" | \"danger\";\ntype Size = \"sm\" | \"md\";\n\nexport type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {\n variant?: Variant;\n size?: Size;\n};\n\nconst BASE =\n \"inline-flex items-center justify-center gap-1.5 rounded-[0.5rem] font-semibold transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--hl-brand-glow)]\";\n\n// `danger`'s BACKGROUND stays a literal — status colors are a fixed fleet\n// semantic (the preset hardcodes them too), not a per-project brand token.\n// Its label stays literal `text-white` for the same reason: that is text\n// ON a status chip, which lib/colors.ts already rules is always white,\n// NOT reading text on an app surface. Everything else here is a token.\nconst VARIANT: Record<Variant, string> = {\n primary:\n \"bg-[var(--hl-brand)] text-[var(--hl-brand-fg)] hover:bg-[var(--hl-brand-hover)]\",\n secondary:\n \"bg-[var(--hl-card)] text-[var(--hl-foreground)] hover:bg-[var(--hl-card-hover)]\",\n tertiary:\n \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)]\",\n danger: \"bg-[#b91c1c] text-white hover:brightness-110\",\n};\n\nconst SIZE: Record<Size, string> = {\n sm: \"px-3 py-1 text-xs\",\n md: \"px-4 py-1.5 text-sm\",\n};\n\n/**\n * Homelab Button. Use the variant + size props; never pass raw color\n * classes through className. className is for layout extras only\n * (e.g. `w-full`).\n */\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n function Button(\n { variant = \"secondary\", size = \"md\", className, ...rest },\n ref,\n ) {\n return (\n <button\n ref={ref}\n {...rest}\n className={cn(BASE, VARIANT[variant], SIZE[size], className)}\n />\n );\n },\n);\n","import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Conditional Tailwind class joiner. Merges variants and resolves\n * conflicts (e.g. `cn(\"p-2\", \"p-4\")` → `\"p-4\"`).\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","\"use client\";\n\nimport { useEffect } from \"react\";\nimport { Modal } from \"./Modal\";\nimport { Button } from \"./Button\";\n\nexport type ConfirmDialogProps = {\n open: boolean;\n title: string;\n message: string;\n confirmLabel?: string;\n cancelLabel?: string;\n tone?: \"danger\" | \"primary\";\n onConfirm: () => void;\n onClose: () => void;\n};\n\n/**\n * Confirm/cancel dialog built on Modal + Button. Enter triggers confirm,\n * Esc cancels (via Modal's built-in handling).\n */\nexport function ConfirmDialog({\n open,\n title,\n message,\n confirmLabel = \"Confirm\",\n cancelLabel = \"Cancel\",\n tone = \"danger\",\n onConfirm,\n onClose,\n}: ConfirmDialogProps) {\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Enter\") onConfirm();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onConfirm]);\n\n return (\n <Modal\n open={open}\n onClose={onClose}\n title={title}\n footer={\n <>\n <Button variant=\"tertiary\" size=\"md\" onClick={onClose}>\n {cancelLabel}\n </Button>\n <Button\n variant={tone === \"danger\" ? \"danger\" : \"primary\"}\n size=\"md\"\n onClick={onConfirm}\n >\n {confirmLabel}\n </Button>\n </>\n }\n >\n <p className=\"whitespace-pre-line text-sm text-[var(--hl-foreground)]\">{message}</p>\n </Modal>\n );\n}\n","\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\ntype Density = \"form\" | \"media\";\n\nexport type ModalProps = {\n open: boolean;\n onClose: () => void;\n title?: string;\n /** Show the close X in the top right. Default: true when title is set. */\n showClose?: boolean;\n /**\n * Overlay weight:\n * `form` — dialogs / pickers / confirms (`bg-black/70`)\n * `media` — lightbox / asset preview (`bg-black/90`)\n * Defaults to `form`.\n */\n density?: Density;\n /** Max width — default `max-w-md`. */\n maxWidth?: string;\n /** Footer slot — render buttons here. Always right-aligned. */\n footer?: React.ReactNode;\n children: React.ReactNode;\n};\n\n/**\n * Single modal frame. Every dialog routes through this:\n * - overlay + click-outside dismiss\n * - Esc-to-close\n * - role=dialog + aria-modal + aria-labelledby wiring\n * - DESIGN.md header treatment (uppercase tracking-widest)\n * - shared right-aligned footer row\n */\nexport function Modal({\n open,\n onClose,\n title,\n showClose,\n density = \"form\",\n maxWidth = \"max-w-md\",\n footer,\n children,\n}: ModalProps) {\n const titleIdRef = useRef(\n `hl-modal-${Math.random().toString(36).slice(2, 8)}`,\n );\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") onClose();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onClose]);\n\n if (!open) return null;\n\n const showCloseBtn = showClose ?? Boolean(title);\n const overlayClass = density === \"media\" ? \"bg-black/90\" : \"bg-black/70\";\n\n return (\n <div\n className={cn(\n \"fixed inset-0 z-50 flex items-center justify-center p-4\",\n overlayClass,\n )}\n onClick={onClose}\n role=\"presentation\"\n >\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={title ? titleIdRef.current : undefined}\n className={cn(\n \"w-full rounded-[1rem] border-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] p-5\",\n maxWidth,\n )}\n onClick={(e) => e.stopPropagation()}\n >\n {(title || showCloseBtn) && (\n <div className=\"mb-4 flex items-start justify-between gap-3\">\n {title && (\n <h2\n id={titleIdRef.current}\n className=\"text-sm font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\"\n >\n {title}\n </h2>\n )}\n {showCloseBtn && (\n <button\n type=\"button\"\n onClick={onClose}\n className=\"ml-auto rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] active:scale-90\"\n aria-label=\"Close\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n )}\n {children}\n {footer && (\n <div className=\"mt-5 flex flex-wrap items-center justify-end gap-2\">\n {footer}\n </div>\n )}\n </div>\n </div>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type EmptyStateProps = {\n icon?: React.ReactNode;\n title?: string;\n message: string;\n action?: React.ReactNode;\n /**\n * `panel` — large empty state with warm card background (page bodies).\n * `inline` — compact text-only line for sidebar tree, popovers, etc.\n */\n variant?: \"panel\" | \"inline\";\n className?: string;\n};\n\nexport function EmptyState({\n icon,\n title,\n message,\n action,\n variant = \"panel\",\n className,\n}: EmptyStateProps) {\n if (variant === \"inline\") {\n return (\n <p className={cn(\"px-3 py-2 text-xs text-[var(--hl-foreground)]\", className)}>\n {message}\n </p>\n );\n }\n return (\n <div\n className={cn(\n \"flex flex-col items-center justify-center gap-3 rounded-[1rem] bg-[var(--hl-card-warm)] p-12 text-center\",\n className,\n )}\n >\n {icon && (\n <div className=\"text-[var(--hl-foreground-faint)]\">{icon}</div>\n )}\n {title && (\n <h3 className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {title}\n </h3>\n )}\n <p className=\"text-sm text-[var(--hl-foreground)]\">{message}</p>\n {action}\n </div>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { ArrowLeft, ChevronRight, ImageOff } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type Crumb = {\n label: string;\n onClick?: () => void;\n};\n\nexport type PageHeaderProps = {\n title: string;\n description?: string;\n /** Back link rendered above the title. */\n back?: { href: string; label: string; render?: (href: string, children: React.ReactNode) => React.ReactNode };\n /** Right-aligned actions slot. */\n actions?: React.ReactNode;\n /**\n * Header image URL (e.g. `/logos.png`). When the URL resolves, the\n * image is the visual title. When it 404s, a styled placeholder at\n * the same dimensions takes its place — the layout stays stable so\n * creating a new themed page \"just works\" the moment you drop a\n * matching PNG in /public/.\n *\n * When `imageSrc` is undefined, no image block renders and the\n * text title is the visual title.\n */\n imageSrc?: string;\n imageAlt?: string;\n /**\n * Breadcrumb trail. When provided, replaces the text title. Last\n * crumb is current (full foreground + bold); earlier crumbs are muted\n * and clickable.\n */\n breadcrumb?: Crumb[];\n className?: string;\n};\n\n/**\n * Page-header block. The image block reserves space whenever `imageSrc`\n * is set — drop the PNG in /public/ and the layout just fills in.\n *\n * Text title rules:\n * - imageSrc set + image present → text becomes sr-only\n * - imageSrc set + image 404 → placeholder block + text becomes sr-only\n * - imageSrc unset → text title is visible\n * - breadcrumb provided → breadcrumb replaces text title regardless of image\n *\n * The back link uses a plain <a> by default. In Next.js, pass\n * `back.render` to use next/link:\n *\n * import Link from \"next/link\";\n * <PageHeader back={{ href: \"/foo\", label: \"Back\", render: (href, c) => <Link href={href}>{c}</Link> }} />\n */\nexport function PageHeader({\n title,\n description,\n back,\n actions,\n imageSrc,\n imageAlt,\n breadcrumb,\n className,\n}: PageHeaderProps) {\n const [imageErrored, setImageErrored] = useState(false);\n useEffect(() => {\n setImageErrored(false);\n }, [imageSrc]);\n\n const hasImageSlot = !!imageSrc;\n const imageRenders = hasImageSlot && !imageErrored;\n const hasBreadcrumb = Array.isArray(breadcrumb) && breadcrumb.length > 0;\n const textIsSrOnly = hasImageSlot;\n\n const backInner = back ? (\n <>\n <ArrowLeft className=\"h-3 w-3\" />\n {back.label}\n </>\n ) : null;\n\n return (\n <header\n className={cn(\n \"flex flex-wrap items-end justify-between gap-3\",\n className,\n )}\n >\n <div className=\"flex min-w-0 items-end gap-4\">\n {hasImageSlot &&\n (imageRenders ? (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={imageSrc}\n alt={imageAlt ?? title}\n onError={() => setImageErrored(true)}\n className=\"h-28 w-28 shrink-0 rounded-[1rem] object-contain md:h-32 md:w-32\"\n />\n ) : (\n <PlaceholderHeader title={title} imageSrc={imageSrc!} />\n ))}\n <div className=\"min-w-0\">\n {back &&\n (back.render ? (\n back.render(\n back.href,\n <span className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\">\n {backInner}\n </span>,\n )\n ) : (\n <a\n href={back.href}\n className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {backInner}\n </a>\n ))}\n {hasBreadcrumb ? (\n <Breadcrumb\n crumbs={breadcrumb!}\n srTitle={title}\n className={back ? \"mt-1\" : \"\"}\n />\n ) : (\n <h1\n className={cn(\n \"text-xl font-semibold text-[var(--hl-title)]\",\n back && \"mt-1\",\n textIsSrOnly && \"sr-only\",\n )}\n >\n {title}\n </h1>\n )}\n {description && (\n <p className=\"mt-1 text-xs text-[var(--hl-foreground)]\">{description}</p>\n )}\n </div>\n </div>\n {actions && <div className=\"flex items-center gap-2\">{actions}</div>}\n </header>\n );\n}\n\nfunction PlaceholderHeader({\n title,\n imageSrc,\n}: {\n title: string;\n imageSrc: string;\n}) {\n const file = imageSrc.replace(/^\\//, \"\");\n return (\n <div\n title={`Drop ${file} in /public to brand this header`}\n className=\"flex h-28 w-28 shrink-0 flex-col items-center justify-center gap-1 rounded-[1rem] border-2 border-dashed border-[var(--hl-border)] bg-[var(--hl-card-warm)] text-[var(--hl-foreground-faint)] md:h-32 md:w-32\"\n >\n <ImageOff className=\"h-6 w-6\" />\n <span className=\"px-2 text-center text-[10px] font-semibold uppercase tracking-widest\">\n {title}\n </span>\n </div>\n );\n}\n\nfunction Breadcrumb({\n crumbs,\n srTitle,\n className,\n}: {\n crumbs: Crumb[];\n srTitle: string;\n className?: string;\n}) {\n return (\n <nav\n aria-label=\"Breadcrumb\"\n className={cn(\n \"flex flex-wrap items-center gap-1.5 text-xl font-semibold\",\n className,\n )}\n >\n <h1 className=\"sr-only\">{srTitle}</h1>\n {crumbs.map((crumb, idx) => {\n const isLast = idx === crumbs.length - 1;\n return (\n <span\n key={`${crumb.label}-${idx}`}\n className=\"flex items-center gap-1.5\"\n >\n {idx > 0 && (\n <ChevronRight\n className=\"h-4 w-4 shrink-0 text-[var(--hl-foreground-faint)]\"\n aria-hidden=\"true\"\n />\n )}\n {crumb.onClick && !isLast ? (\n <button\n type=\"button\"\n onClick={crumb.onClick}\n className=\"rounded px-1 text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {crumb.label}\n </button>\n ) : (\n <span\n className={cn(\n isLast\n ? \"text-[var(--hl-foreground)]\"\n : \"text-[var(--hl-foreground-muted)]\",\n isLast && \"px-1\",\n )}\n aria-current={isLast ? \"page\" : undefined}\n >\n {crumb.label}\n </span>\n )}\n </span>\n );\n })}\n </nav>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Menu, X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarProps = {\n /**\n * Logo image src — typically `/logo.png` or `/<project>-logo-256.png`.\n * Rendered at w-36 h-36 inside a centered block with px-4 pt-6 pb-5\n * + border-b-2 (doghouse-canonical spec). Override sizing/shape via\n * `logoClassName`.\n */\n logoSrc: string;\n /** Alt text for the logo + label shown in the mobile top bar. */\n logoAlt: string;\n /**\n * Override the className applied to the logo `<img>`. Default is\n * `\"h-36 w-36 rounded-[1rem] object-contain\"` (doghouse-canonical\n * square logo). Pass `\"h-auto w-full rounded-md object-contain\"` for\n * full-width banner-style logos, or any other Tailwind classes.\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n logoClassName?: string;\n /**\n * Slots — every section is optional. Render them in this order:\n * 1. Logo block (always)\n * 2. widgets — clock / weather / homepage rotator / etc\n * 3. nav — primary navigation\n * 4. middle — long-running content (folder tree, etc) — flex-1\n * 5. lower — secondary nav (manage-tags, settings link, etc)\n * 6. (spacer)\n * 7. admin — admin section\n * 8. footer — version stamp, etc\n */\n widgets?: React.ReactNode;\n nav?: React.ReactNode;\n middle?: React.ReactNode;\n lower?: React.ReactNode;\n admin?: React.ReactNode;\n footer?: React.ReactNode;\n /**\n * Mobile-drawer behavior. Controlled — the consumer owns the open\n * state so they can wire it from anywhere (top-bar hamburger,\n * keyboard shortcut, route change, etc.).\n *\n * When omitted, the Sidebar manages its own drawer state using the\n * built-in mobile top bar (hamburger renders top-left on screens < md).\n */\n mobileOpen?: boolean;\n onMobileOpenChange?: (open: boolean) => void;\n /** Extra classes on the outer <aside>. */\n className?: string;\n /**\n * Override the className used for the `middle` slot's wrapper.\n *\n * The default — `\"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\"` —\n * is correct for asset-den's \"single panel with a scrollable folder\n * tree inside.\" Override when the middle slot's content is itself a\n * stack of cards (e.g. doghouse's per-service status widgets) — those\n * already sit on the card token, so wrapping them in another same-color\n * panel makes them disappear.\n *\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n middleClassName?: string;\n /**\n * When true, render a built-in mobile top bar (logo + hamburger) on\n * screens < md. Defaults to true. Disable when the consumer renders\n * its own top chrome.\n */\n showMobileTopBar?: boolean;\n};\n\n/**\n * Canonical homelab sidebar shell. Doghouse is the reference impl;\n * this is the extracted version every other project consumes.\n *\n * Dimensions are non-negotiable:\n * - md+ width: 15rem default (override per-project via `:root { --hl-sidebar-width: 18rem; }`)\n * - md+ position: fixed-flex in a `display:flex` parent\n * - mobile: off-canvas drawer at full sidebar width\n * - logo block: w-36 h-36 centered, px-4 pt-6 pb-5, border-b-2\n *\n * Surfaces are `bg-[var(--hl-sidebar)]` / `bg-[var(--hl-card)]` /\n * `border-[var(--hl-border)]` — arbitrary-value classes that read the\n * tokens directly, so they still work when the consumer skips the\n * Tailwind preset AND re-skin correctly when the consumer overrides the\n * vars. (They were raw hex until 0.8.0, which silently pinned every\n * consumer to the fleet palette.) Import globals.css for the defaults.\n */\nexport function Sidebar({\n logoSrc,\n logoAlt,\n logoClassName = \"h-36 w-36 rounded-[1rem] object-contain\",\n widgets,\n nav,\n middle,\n lower,\n admin,\n footer,\n mobileOpen: controlledOpen,\n onMobileOpenChange,\n className,\n middleClassName = \"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\",\n showMobileTopBar = true,\n}: SidebarProps) {\n const [internalOpen, setInternalOpen] = useState(false);\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen : internalOpen;\n\n const setOpen = (next: boolean) => {\n if (!isControlled) setInternalOpen(next);\n onMobileOpenChange?.(next);\n };\n\n // Lock body scroll while the mobile drawer is open.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!open) return;\n const prev = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n return () => {\n document.body.style.overflow = prev;\n };\n }, [open]);\n\n return (\n <>\n {showMobileTopBar && (\n <div className=\"fixed inset-x-0 top-0 z-30 flex items-center gap-2 border-b-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] px-3 py-2 md:hidden\">\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n className=\"rounded-[0.25rem] p-1.5 text-[var(--hl-foreground)] transition hover:bg-[var(--hl-card-hover)] active:scale-90\"\n aria-label=\"Open menu\"\n >\n <Menu className=\"h-5 w-5\" />\n </button>\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={logoSrc} alt=\"\" width={24} height={24} />\n <p className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {logoAlt}\n </p>\n </div>\n )}\n\n {open && (\n <div\n className=\"fixed inset-0 z-40 bg-black/70 md:hidden\"\n onClick={() => setOpen(false)}\n role=\"presentation\"\n />\n )}\n\n <aside\n className={cn(\n \"fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,15rem)] shrink-0 flex-col border-r-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] transition-transform md:static md:translate-x-0 md:h-screen\",\n open ? \"translate-x-0\" : \"-translate-x-full md:translate-x-0\",\n className,\n )}\n >\n <div className=\"relative flex flex-col items-center border-b-2 border-[var(--hl-border)] px-4 pt-6 pb-5\">\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n src={logoSrc}\n alt={logoAlt}\n className={logoClassName}\n />\n {showMobileTopBar && (\n <button\n type=\"button\"\n onClick={() => setOpen(false)}\n className=\"absolute right-3 top-3 rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] md:hidden\"\n aria-label=\"Close menu\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n\n {widgets && <div className=\"mx-3 mt-3\">{widgets}</div>}\n\n {nav && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {nav}\n </nav>\n )}\n\n {middle && <div className={middleClassName}>{middle}</div>}\n\n {lower && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {lower}\n </nav>\n )}\n\n {!middle && <div className=\"flex-1\" />}\n\n {admin && <div>{admin}</div>}\n\n {footer && (\n <div className=\"border-t-2 border-[var(--hl-border)] px-4 py-3 text-center text-xs text-[var(--hl-foreground-faint)]\">\n {footer}\n </div>\n )}\n </aside>\n </>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarNavItemProps = {\n label: string;\n icon?: React.ReactNode;\n active?: boolean;\n onClick?: () => void;\n /**\n * When set, renders as <a href>. Otherwise renders as <button>. In\n * Next.js, prefer passing `render` so you can wrap in next/link\n * without losing classes:\n *\n * <SidebarNavItem\n * label=\"Home\"\n * href=\"/\"\n * render={(props, children) => <Link {...props}>{children}</Link>}\n * />\n *\n * Spread `{...props}` rather than destructuring individual fields\n * (`{ href, className }`) — the props object also carries `aria-current`\n * for the active item, and a narrow destructure silently drops it (and\n * any future field) even though the type still says it's there.\n */\n href?: string;\n render?: (\n props: { href: string; className: string; \"aria-current\"?: \"page\" },\n children: React.ReactNode,\n ) => React.ReactNode;\n};\n\nconst ITEM_CLASS_BASE =\n \"flex items-center gap-2 rounded-[0.5rem] px-3 py-2.5 text-base font-medium transition-all\";\n\nconst itemClass = (active: boolean) =>\n cn(\n ITEM_CLASS_BASE,\n active\n ? \"bg-[var(--hl-brand)] text-[var(--hl-brand-fg)]\"\n : \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-hover-overlay)] hover:text-[var(--hl-foreground)]\",\n );\n\n/**\n * Single nav row for the Sidebar's `nav` / `lower` slots. Matches the\n * doghouse-canonical style: active = brand bg + brand-fg text, inactive =\n * muted text lifting to full foreground on hover, over a hover wash.\n *\n * Every colour here is a token. The active pill reads `--hl-brand` /\n * `--hl-brand-fg` (it was `bg-[#ff9900] text-white` until 0.8.0); the\n * inactive row reads `--hl-foreground-muted` / `--hl-hover-overlay` /\n * `--hl-foreground` (it was `text-white/45 hover:bg-white/5\n * hover:text-white` until 0.9.0). Both were themeability bugs of the same\n * shape — see globals.css.\n */\nexport function SidebarNavItem({\n label,\n icon,\n active = false,\n onClick,\n href,\n render,\n}: SidebarNavItemProps) {\n const className = itemClass(active);\n const ariaCurrent = active ? (\"page\" as const) : undefined;\n const inner = (\n <>\n {icon}\n <span>{label}</span>\n </>\n );\n\n if (href && render)\n return (\n <>{render({ href, className, \"aria-current\": ariaCurrent }, inner)}</>\n );\n if (href) {\n return (\n <a href={href} aria-current={ariaCurrent} className={className}>\n {inner}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-current={ariaCurrent}\n aria-pressed={active}\n className={`${className} w-full`}\n >\n {inner}\n </button>\n );\n}\n","\"use client\";\n\nimport { Loader2 } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SpinnerProps = {\n label?: string;\n className?: string;\n};\n\n/**\n * Consistent inline loading affordance — used wherever the app would\n * otherwise say \"Loading…\" in plain text. Compact spinner + label, on the\n * same `--hl-foreground-muted` as every other secondary line.\n */\nexport function Spinner({ label = \"Loading…\", className }: SpinnerProps) {\n return (\n <p\n className={cn(\n \"flex items-center gap-2 px-3 py-2 text-xs text-[var(--hl-foreground-muted)]\",\n className,\n )}\n >\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n {label}\n </p>\n );\n}\n","import { cn } from \"../lib/utils\";\n\nexport type StatTileProps = {\n /** Uppercase micro-label above the value. */\n label: string;\n /** The stat itself — keep it short; it truncates rather than wraps. */\n value: React.ReactNode;\n /** Optional caption under the value. The line is ALWAYS reserved so the\n * tile height never changes when a sub appears/disappears. */\n sub?: React.ReactNode;\n className?: string;\n};\n\n/**\n * Live-stat tile with STABLE GEOMETRY — the fleet standard for status\n * cards (born on zoo-docs' dashboard, 2026-07-13):\n *\n * - text truncates, never wraps → tile height is constant\n * - the sub line renders even when empty → no height jump when data\n * (queue counts, countdowns) comes and goes between polls\n * - value/sub render in font-mono (metrically monospaced) so ticking\n * numbers don't wiggle the width — tabular-nums alone doesn't do this,\n * since the fleet's Poppins/Orbitron subsets don't ship the tnum\n * OpenType feature (B264)\n *\n * Anything elastic in HEIGHT shoves the whole page around on every poll;\n * width elasticity belongs to the row (see StatTileRow).\n */\nexport function StatTile({ label, value, sub, className }: StatTileProps) {\n return (\n <div className={cn(\"min-w-0 rounded-lg bg-[var(--hl-card)] px-4 py-3\", className)}>\n <p className=\"truncate text-[10px] font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\">\n {label}\n </p>\n <p className=\"mt-1 truncate font-mono text-xl font-semibold tabular-nums text-[var(--hl-foreground)]\">{value}</p>\n <p className=\"truncate font-mono text-[11px] tabular-nums text-[var(--hl-foreground)]\">{sub ?? \" \"}</p>\n </div>\n );\n}\n\nexport type StatTileRowProps = {\n children: React.ReactNode;\n /** Minimum tile width before the grid drops a column. Default \"9rem\". */\n minTileWidth?: string;\n className?: string;\n};\n\n/**\n * Full-width, equal-column container for StatTiles. `auto-fit` +\n * `minmax(minTileWidth, 1fr)` means the row stretches edge-to-edge with\n * the browser and sheds whole columns on narrow screens — tiles never\n * overflow off-screen and never wrap their text.\n *\n * Pair with the layout standard: content pages are FULL WIDTH (no\n * max-w-* caps) unless a page explicitly opts out.\n */\nexport function StatTileRow({ children, minTileWidth = \"9rem\", className }: StatTileRowProps) {\n return (\n <div\n className={cn(\"grid gap-3\", className)}\n style={{ gridTemplateColumns: `repeat(auto-fit, minmax(${minTileWidth}, 1fr))` }}\n >\n {children}\n </div>\n );\n}\n","/**\n * Pick black or white text for a solid color chip.\n *\n * The homelab status palette forces white text on every status color\n * EXCEPT yellow (warning: #eab308), which uses black for contrast. This\n * helper enforces that rule wherever a user-picked color drives a chip\n * background (TagChips, status badges, etc).\n *\n * The yellow rule generalizes — anything in the yellow band (high\n * luminance + warm hue) gets black ink.\n */\nexport function tagTextColor(hex: string): \"#ffffff\" | \"#000000\" {\n const normalized = hex.trim().replace(/^#/, \"\");\n if (normalized.length !== 3 && normalized.length !== 6) return \"#ffffff\";\n const expand =\n normalized.length === 3\n ? normalized\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : normalized;\n const r = parseInt(expand.slice(0, 2), 16);\n const g = parseInt(expand.slice(2, 4), 16);\n const b = parseInt(expand.slice(4, 6), 16);\n if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return \"#ffffff\";\n // Perceived luminance per ITU BT.601.\n const luma = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n return luma > 0.65 ? \"#000000\" : \"#ffffff\";\n}\n","\"use client\";\n\nimport { tagTextColor } from \"../lib/colors\";\n\nexport type TagRef = {\n id: string | number;\n name: string;\n color: string;\n};\n\nexport type TagChipsProps = {\n tags: TagRef[];\n size?: \"xs\" | \"sm\";\n onClick?: (tag: TagRef) => void;\n};\n\n/**\n * Render a row of solid-color tag chips. Yellow auto-flips text to\n * black per the homelab status-palette contrast rule (see tagTextColor).\n *\n * Renders `<button>` when interactive (`onClick` set), `<span>` when\n * read-only — keeps screen-reader semantics honest.\n */\nexport function TagChips({ tags, size = \"xs\", onClick }: TagChipsProps) {\n if (tags.length === 0) return null;\n const sizeClasses =\n size === \"xs\" ? \"px-1.5 py-0.5 text-[10px]\" : \"px-2 py-0.5 text-xs\";\n\n return (\n <div className=\"flex flex-wrap gap-1\">\n {tags.map((t) => {\n const style = {\n backgroundColor: t.color,\n color: tagTextColor(t.color),\n };\n const className = `inline-block rounded-[0.25rem] font-semibold ${sizeClasses}`;\n return onClick ? (\n <button\n key={t.id}\n type=\"button\"\n onClick={() => onClick(t)}\n className={`${className} transition active:scale-95`}\n style={style}\n >\n {t.name}\n </button>\n ) : (\n <span key={t.id} className={className} style={style}>\n {t.name}\n </span>\n );\n })}\n </div>\n );\n}\n","/**\n * Font wiring for `@jdcpuwiz/homelab-ui`.\n *\n * ## The standard: three self-hosted faces, split by JOB (BuildPlan #57)\n *\n * Wiz ruling 2026-07-24. This SUPERSEDES both the old \"Geist via next/font\"\n * mandate AND the system-ui-only ruling (#345) that briefly replaced it.\n *\n * SANS Poppins — all body / UI / headings / labels\n * DISPLAY Orbitron — BIG numbers only (stat heroes, clock, ≥ ~1.5rem)\n * MONO JetBrains Mono — small values, table figures, version stamps,\n * code (< ~1.5rem)\n *\n * The size line is the hard boundary: Orbitron is a display face and must\n * NEVER be applied to a dense small cell — if a number is small, it's\n * JetBrains Mono.\n *\n * ## The package NOW OWNS THE FACES (C525) — one import, no per-app config\n *\n * import \"@jdcpuwiz/homelab-ui/fonts.css\"\n *\n * That registers all three faces from woff2 shipped in this package, so\n * `next build` never reaches fonts.gstatic.com.\n *\n * This block previously said loading had to be per-app. That was true only of\n * `next/font`: its SWC plugin scans the app's OWN source for literal\n * `const Foo = Font({...})` calls and bundling rewrites `const`→`var`, so a\n * re-exported LOADER genuinely cannot work. The constraint never applied to\n * plain `@font-face`, which is what fonts.css uses.\n *\n * - Next apps: import `fonts.css`. Nothing else — no loaders, no\n * `.variable` classes, no `--font-*` variables.\n * - Vite apps: import `fonts.css` too, or `@fontsource/poppins` etc.\n *\n * ⚠ `FONT_APP_VARIABLES` below is LEGACY. Apps still running `next/font` set\n * those vars and `globals.css` still reads them first, so both mechanisms\n * work side by side during the rollout. Do NOT reference `--font-poppins`\n * and friends in new code: once an app moves to fonts.css they are unset, and\n * `var(--x)` with no fallback ARGUMENT is invalid at computed-value time —\n * the whole declaration is discarded and the element inherits its parent's\n * face rather than reaching your authored fallback. Use `--hl-font-sans` /\n * `--hl-font-display` / `--hl-font-mono`.\n *\n * ## `next/font/google` is a BUILD-TIME NETWORK DEPENDENCY (2026-08-15)\n *\n * The Next path above makes `next build` reach fonts.gstatic.com, which means\n * every consuming app's DEPLOY depends on it. zoovault's did, and lost: its\n * image floated `next` to a release whose next/font/google requests began\n * 404ing, and the deploy died on a commit that built clean locally. The\n * root-cause fix was pinning `next` from a committed lockfile inside the image\n * (`npm ci`, never a bare `npm install`) — do that first, everywhere. Beyond\n * that, self-hosted faces remove the network from the build entirely;\n * zoovault now runs `next/font/local` over woff2 files sourced from\n * @fontsource and committed in-repo.\n *\n * Note the fallback chain in `globals.css` — `var(--font-poppins, \"Poppins\")`.\n * The package asks for the CSS var and then for the LITERAL FAMILY NAME, so\n * anything that registers a \"Poppins\" @font-face satisfies it. next/font is a\n * convention here, not a requirement, and the Vite path above is proof.\n *\n * `globals.css` declares `--hl-font-sans` / `--hl-font-display` /\n * `--hl-font-mono` referencing the app variables below first, then the literal\n * face name, then the platform fallback — so text is readable even mid-load\n * and an un-migrated app degrades to the system stack, never to nothing.\n *\n * Sanity check after deploy — this should name Poppins:\n * getComputedStyle(document.body).fontFamily // → \"…Poppins…\"\n */\n\n/**\n * The package's own token names — the `--hl-font-*` vars the Tailwind preset's\n * `font-sans` / `font-mono` / `font-display` classes resolve to. Consumers\n * rarely touch these directly.\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--hl-font-sans\",\n mono: \"--hl-font-mono\",\n display: \"--hl-font-display\",\n} as const;\n\n/**\n * The app-side CSS variable each next/font loader must expose (its `variable:`\n * option). `globals.css` reads these, so an app that follows this naming gets\n * the three faces wired with zero extra config. Match these EXACTLY.\n *\n * const poppins = Poppins({ ..., variable: FONT_APP_VARIABLES.sans });\n */\nexport const FONT_APP_VARIABLES = {\n sans: \"--font-poppins\",\n display: \"--font-orbitron\",\n mono: \"--font-jetbrains-mono\",\n} as const;\n"],"mappings":"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","names":["Button","useEffect","jsx","jsx","jsxs","useEffect","jsx","jsxs","useEffect","Fragment","jsx","jsxs","useEffect","useEffect","useState","X","Fragment","jsx","jsxs","useState","useEffect","X","Fragment","jsx","jsxs","jsx","jsxs","jsx","jsxs","jsx"]}
1
+ {"version":3,"sources":["../src/components/Button.tsx","../src/lib/utils.ts","../src/components/ConfirmDialog.tsx","../src/components/Modal.tsx","../src/components/EmptyState.tsx","../src/components/PageHeader.tsx","../src/components/Sidebar.tsx","../src/components/SidebarNavItem.tsx","../src/components/Spinner.tsx","../src/components/StatTile.tsx","../src/lib/colors.ts","../src/components/TagChips.tsx","../src/lib/fonts.ts"],"sourcesContent":["\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { cn } from \"../lib/utils\";\n\ntype Variant = \"primary\" | \"secondary\" | \"tertiary\" | \"danger\";\ntype Size = \"sm\" | \"md\";\n\nexport type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {\n variant?: Variant;\n size?: Size;\n};\n\nconst BASE =\n \"inline-flex items-center justify-center gap-1.5 rounded-[0.5rem] font-semibold transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--hl-brand-glow)]\";\n\n// `danger`'s BACKGROUND stays a literal — status colors are a fixed fleet\n// semantic (the preset hardcodes them too), not a per-project brand token.\n// Its label stays literal `text-white` for the same reason: that is text\n// ON a status chip, which lib/colors.ts already rules is always white,\n// NOT reading text on an app surface. Everything else here is a token.\nconst VARIANT: Record<Variant, string> = {\n primary:\n \"bg-[var(--hl-brand)] text-[var(--hl-brand-fg)] hover:bg-[var(--hl-brand-hover)]\",\n secondary:\n \"bg-[var(--hl-card)] text-[var(--hl-foreground)] hover:bg-[var(--hl-card-hover)]\",\n tertiary:\n \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)]\",\n danger: \"bg-[#b91c1c] text-white hover:brightness-110\",\n};\n\nconst SIZE: Record<Size, string> = {\n sm: \"px-3 py-1 text-xs\",\n md: \"px-4 py-1.5 text-sm\",\n};\n\n/**\n * Homelab Button. Use the variant + size props; never pass raw color\n * classes through className. className is for layout extras only\n * (e.g. `w-full`).\n */\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n function Button(\n { variant = \"secondary\", size = \"md\", className, ...rest },\n ref,\n ) {\n return (\n <button\n ref={ref}\n {...rest}\n className={cn(BASE, VARIANT[variant], SIZE[size], className)}\n />\n );\n },\n);\n","import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Conditional Tailwind class joiner. Merges variants and resolves\n * conflicts (e.g. `cn(\"p-2\", \"p-4\")` → `\"p-4\"`).\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","\"use client\";\n\nimport { useEffect } from \"react\";\nimport { Modal } from \"./Modal\";\nimport { Button } from \"./Button\";\n\nexport type ConfirmDialogProps = {\n open: boolean;\n title: string;\n message: string;\n confirmLabel?: string;\n cancelLabel?: string;\n tone?: \"danger\" | \"primary\";\n onConfirm: () => void;\n onClose: () => void;\n};\n\n/**\n * Confirm/cancel dialog built on Modal + Button. Enter triggers confirm,\n * Esc cancels (via Modal's built-in handling).\n */\nexport function ConfirmDialog({\n open,\n title,\n message,\n confirmLabel = \"Confirm\",\n cancelLabel = \"Cancel\",\n tone = \"danger\",\n onConfirm,\n onClose,\n}: ConfirmDialogProps) {\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Enter\") onConfirm();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onConfirm]);\n\n return (\n <Modal\n open={open}\n onClose={onClose}\n title={title}\n footer={\n <>\n <Button variant=\"tertiary\" size=\"md\" onClick={onClose}>\n {cancelLabel}\n </Button>\n <Button\n variant={tone === \"danger\" ? \"danger\" : \"primary\"}\n size=\"md\"\n onClick={onConfirm}\n >\n {confirmLabel}\n </Button>\n </>\n }\n >\n <p className=\"whitespace-pre-line text-sm text-[var(--hl-foreground)]\">{message}</p>\n </Modal>\n );\n}\n","\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\ntype Density = \"form\" | \"media\";\n\nexport type ModalProps = {\n open: boolean;\n onClose: () => void;\n title?: string;\n /** Show the close X in the top right. Default: true when title is set. */\n showClose?: boolean;\n /**\n * Overlay weight:\n * `form` — dialogs / pickers / confirms (`bg-black/70`)\n * `media` — lightbox / asset preview (`bg-black/90`)\n * Defaults to `form`.\n */\n density?: Density;\n /** Max width — default `max-w-md`. */\n maxWidth?: string;\n /** Footer slot — render buttons here. Always right-aligned. */\n footer?: React.ReactNode;\n children: React.ReactNode;\n};\n\n/**\n * Single modal frame. Every dialog routes through this:\n * - overlay + click-outside dismiss\n * - Esc-to-close\n * - role=dialog + aria-modal + aria-labelledby wiring\n * - DESIGN.md header treatment (uppercase tracking-widest)\n * - shared right-aligned footer row\n */\nexport function Modal({\n open,\n onClose,\n title,\n showClose,\n density = \"form\",\n maxWidth = \"max-w-md\",\n footer,\n children,\n}: ModalProps) {\n const titleIdRef = useRef(\n `hl-modal-${Math.random().toString(36).slice(2, 8)}`,\n );\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") onClose();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onClose]);\n\n if (!open) return null;\n\n const showCloseBtn = showClose ?? Boolean(title);\n const overlayClass = density === \"media\" ? \"bg-black/90\" : \"bg-black/70\";\n\n return (\n <div\n className={cn(\n \"fixed inset-0 z-50 flex items-center justify-center p-4\",\n overlayClass,\n )}\n onClick={onClose}\n role=\"presentation\"\n >\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={title ? titleIdRef.current : undefined}\n className={cn(\n \"w-full rounded-[1rem] border-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] p-5\",\n maxWidth,\n )}\n onClick={(e) => e.stopPropagation()}\n >\n {(title || showCloseBtn) && (\n <div className=\"mb-4 flex items-start justify-between gap-3\">\n {title && (\n <h2\n id={titleIdRef.current}\n className=\"text-sm font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\"\n >\n {title}\n </h2>\n )}\n {showCloseBtn && (\n <button\n type=\"button\"\n onClick={onClose}\n className=\"ml-auto rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] active:scale-90\"\n aria-label=\"Close\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n )}\n {children}\n {footer && (\n <div className=\"mt-5 flex flex-wrap items-center justify-end gap-2\">\n {footer}\n </div>\n )}\n </div>\n </div>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type EmptyStateProps = {\n icon?: React.ReactNode;\n title?: string;\n message: string;\n action?: React.ReactNode;\n /**\n * `panel` — large empty state with warm card background (page bodies).\n * `inline` — compact text-only line for sidebar tree, popovers, etc.\n */\n variant?: \"panel\" | \"inline\";\n className?: string;\n};\n\nexport function EmptyState({\n icon,\n title,\n message,\n action,\n variant = \"panel\",\n className,\n}: EmptyStateProps) {\n if (variant === \"inline\") {\n return (\n <p className={cn(\"px-3 py-2 text-xs text-[var(--hl-foreground)]\", className)}>\n {message}\n </p>\n );\n }\n return (\n <div\n className={cn(\n \"flex flex-col items-center justify-center gap-3 rounded-[1rem] bg-[var(--hl-card-warm)] p-12 text-center\",\n className,\n )}\n >\n {icon && (\n <div className=\"text-[var(--hl-foreground-faint)]\">{icon}</div>\n )}\n {title && (\n <h3 className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {title}\n </h3>\n )}\n <p className=\"text-sm text-[var(--hl-foreground)]\">{message}</p>\n {action}\n </div>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { ArrowLeft, ChevronRight, ImageOff } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type Crumb = {\n label: string;\n onClick?: () => void;\n};\n\nexport type PageHeaderProps = {\n title: string;\n description?: string;\n /** Back link rendered above the title. */\n back?: { href: string; label: string; render?: (href: string, children: React.ReactNode) => React.ReactNode };\n /** Right-aligned actions slot. */\n actions?: React.ReactNode;\n /**\n * Header image URL (e.g. `/logos.png`). When the URL resolves, the\n * image is the visual title. When it 404s, a styled placeholder at\n * the same dimensions takes its place — the layout stays stable so\n * creating a new themed page \"just works\" the moment you drop a\n * matching PNG in /public/.\n *\n * When `imageSrc` is undefined, no image block renders and the\n * text title is the visual title.\n */\n imageSrc?: string;\n imageAlt?: string;\n /**\n * Breadcrumb trail. When provided, replaces the text title. Last\n * crumb is current (full foreground + bold); earlier crumbs are muted\n * and clickable.\n */\n breadcrumb?: Crumb[];\n className?: string;\n};\n\n/**\n * Page-header block. The image block reserves space whenever `imageSrc`\n * is set — drop the PNG in /public/ and the layout just fills in.\n *\n * Text title rules:\n * - imageSrc set + image present → text becomes sr-only\n * - imageSrc set + image 404 → placeholder block + text becomes sr-only\n * - imageSrc unset → text title is visible\n * - breadcrumb provided → breadcrumb replaces text title regardless of image\n *\n * The back link uses a plain <a> by default. In Next.js, pass\n * `back.render` to use next/link:\n *\n * import Link from \"next/link\";\n * <PageHeader back={{ href: \"/foo\", label: \"Back\", render: (href, c) => <Link href={href}>{c}</Link> }} />\n */\nexport function PageHeader({\n title,\n description,\n back,\n actions,\n imageSrc,\n imageAlt,\n breadcrumb,\n className,\n}: PageHeaderProps) {\n const [imageErrored, setImageErrored] = useState(false);\n useEffect(() => {\n setImageErrored(false);\n }, [imageSrc]);\n\n const hasImageSlot = !!imageSrc;\n const imageRenders = hasImageSlot && !imageErrored;\n const hasBreadcrumb = Array.isArray(breadcrumb) && breadcrumb.length > 0;\n const textIsSrOnly = hasImageSlot;\n\n const backInner = back ? (\n <>\n <ArrowLeft className=\"h-3 w-3\" />\n {back.label}\n </>\n ) : null;\n\n return (\n <header\n className={cn(\n \"flex flex-wrap items-end justify-between gap-3\",\n className,\n )}\n >\n <div className=\"flex min-w-0 items-end gap-4\">\n {hasImageSlot &&\n (imageRenders ? (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={imageSrc}\n alt={imageAlt ?? title}\n onError={() => setImageErrored(true)}\n className=\"h-28 w-28 shrink-0 rounded-[1rem] object-contain md:h-32 md:w-32\"\n />\n ) : (\n <PlaceholderHeader title={title} imageSrc={imageSrc!} />\n ))}\n <div className=\"min-w-0\">\n {back &&\n (back.render ? (\n back.render(\n back.href,\n <span className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\">\n {backInner}\n </span>,\n )\n ) : (\n <a\n href={back.href}\n className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {backInner}\n </a>\n ))}\n {hasBreadcrumb ? (\n <Breadcrumb\n crumbs={breadcrumb!}\n srTitle={title}\n className={back ? \"mt-1\" : \"\"}\n />\n ) : (\n <h1\n className={cn(\n \"text-xl font-semibold text-[var(--hl-title)]\",\n back && \"mt-1\",\n textIsSrOnly && \"sr-only\",\n )}\n >\n {title}\n </h1>\n )}\n {description && (\n <p className=\"mt-1 text-xs text-[var(--hl-foreground)]\">{description}</p>\n )}\n </div>\n </div>\n {actions && <div className=\"flex items-center gap-2\">{actions}</div>}\n </header>\n );\n}\n\nfunction PlaceholderHeader({\n title,\n imageSrc,\n}: {\n title: string;\n imageSrc: string;\n}) {\n const file = imageSrc.replace(/^\\//, \"\");\n return (\n <div\n title={`Drop ${file} in /public to brand this header`}\n className=\"flex h-28 w-28 shrink-0 flex-col items-center justify-center gap-1 rounded-[1rem] border-2 border-dashed border-[var(--hl-border)] bg-[var(--hl-card-warm)] text-[var(--hl-foreground-faint)] md:h-32 md:w-32\"\n >\n <ImageOff className=\"h-6 w-6\" />\n <span className=\"px-2 text-center text-[10px] font-semibold uppercase tracking-widest\">\n {title}\n </span>\n </div>\n );\n}\n\nfunction Breadcrumb({\n crumbs,\n srTitle,\n className,\n}: {\n crumbs: Crumb[];\n srTitle: string;\n className?: string;\n}) {\n return (\n <nav\n aria-label=\"Breadcrumb\"\n className={cn(\n \"flex flex-wrap items-center gap-1.5 text-xl font-semibold\",\n className,\n )}\n >\n <h1 className=\"sr-only\">{srTitle}</h1>\n {crumbs.map((crumb, idx) => {\n const isLast = idx === crumbs.length - 1;\n return (\n <span\n key={`${crumb.label}-${idx}`}\n className=\"flex items-center gap-1.5\"\n >\n {idx > 0 && (\n <ChevronRight\n className=\"h-4 w-4 shrink-0 text-[var(--hl-foreground-faint)]\"\n aria-hidden=\"true\"\n />\n )}\n {crumb.onClick && !isLast ? (\n <button\n type=\"button\"\n onClick={crumb.onClick}\n className=\"rounded px-1 text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {crumb.label}\n </button>\n ) : (\n <span\n className={cn(\n isLast\n ? \"text-[var(--hl-foreground)]\"\n : \"text-[var(--hl-foreground-muted)]\",\n isLast && \"px-1\",\n )}\n aria-current={isLast ? \"page\" : undefined}\n >\n {crumb.label}\n </span>\n )}\n </span>\n );\n })}\n </nav>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Menu, X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarProps = {\n /**\n * Logo image src — typically `/logo.png` or `/<project>-logo-256.png`.\n * Rendered at w-36 h-36 inside a centered block with px-4 pt-6 pb-5\n * + border-b-2 (doghouse-canonical spec). Override sizing/shape via\n * `logoClassName`.\n */\n logoSrc: string;\n /** Alt text for the logo + label shown in the mobile top bar. */\n logoAlt: string;\n /**\n * Override the className applied to the logo `<img>`. Default is\n * `\"h-36 w-36 rounded-[1rem] object-contain\"` (doghouse-canonical\n * square logo). Pass `\"h-auto w-full rounded-md object-contain\"` for\n * full-width banner-style logos, or any other Tailwind classes.\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n logoClassName?: string;\n /**\n * Slots — every section is optional. Render them in this order:\n * 1. Logo block (always)\n * 2. widgets — clock / weather / homepage rotator / etc\n * 3. nav — primary navigation\n * 4. middle — long-running content (folder tree, etc) — flex-1\n * 5. lower — secondary nav (manage-tags, settings link, etc)\n * 6. (spacer)\n * 7. admin — admin section\n * 8. footer — version stamp, etc\n */\n widgets?: React.ReactNode;\n nav?: React.ReactNode;\n middle?: React.ReactNode;\n lower?: React.ReactNode;\n admin?: React.ReactNode;\n footer?: React.ReactNode;\n /**\n * Mobile-drawer behavior. Controlled — the consumer owns the open\n * state so they can wire it from anywhere (top-bar hamburger,\n * keyboard shortcut, route change, etc.).\n *\n * When omitted, the Sidebar manages its own drawer state using the\n * built-in mobile top bar (hamburger renders top-left on screens < md).\n */\n mobileOpen?: boolean;\n onMobileOpenChange?: (open: boolean) => void;\n /** Extra classes on the outer <aside>. */\n className?: string;\n /**\n * Override the className used for the `middle` slot's wrapper.\n *\n * The default — `\"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\"` —\n * is correct for asset-den's \"single panel with a scrollable folder\n * tree inside.\" Override when the middle slot's content is itself a\n * stack of cards (e.g. doghouse's per-service status widgets) — those\n * already sit on the card token, so wrapping them in another same-color\n * panel makes them disappear.\n *\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n middleClassName?: string;\n /**\n * When true, render a built-in mobile top bar (logo + hamburger) on\n * screens < md. Defaults to true. Disable when the consumer renders\n * its own top chrome.\n */\n showMobileTopBar?: boolean;\n};\n\n/**\n * Canonical homelab sidebar shell. Doghouse is the reference impl;\n * this is the extracted version every other project consumes.\n *\n * Dimensions are non-negotiable:\n * - md+ width: 15rem default (override per-project via `:root { --hl-sidebar-width: 18rem; }`)\n * - md+ position: fixed-flex in a `display:flex` parent\n * - mobile: off-canvas drawer at full sidebar width\n * - logo block: w-36 h-36 centered, px-4 pt-6 pb-5, border-b-2\n *\n * Surfaces are `bg-[var(--hl-sidebar)]` / `bg-[var(--hl-card)]` /\n * `border-[var(--hl-border)]` — arbitrary-value classes that read the\n * tokens directly, so they still work when the consumer skips the\n * Tailwind preset AND re-skin correctly when the consumer overrides the\n * vars. (They were raw hex until 0.8.0, which silently pinned every\n * consumer to the fleet palette.) Import globals.css for the defaults.\n */\nexport function Sidebar({\n logoSrc,\n logoAlt,\n logoClassName = \"h-36 w-36 rounded-[1rem] object-contain\",\n widgets,\n nav,\n middle,\n lower,\n admin,\n footer,\n mobileOpen: controlledOpen,\n onMobileOpenChange,\n className,\n middleClassName = \"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\",\n showMobileTopBar = true,\n}: SidebarProps) {\n const [internalOpen, setInternalOpen] = useState(false);\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen : internalOpen;\n\n const setOpen = (next: boolean) => {\n if (!isControlled) setInternalOpen(next);\n onMobileOpenChange?.(next);\n };\n\n // Reserve the fixed mobile top bar's height in flow (BUG #339).\n //\n // The bar is `fixed`, so it sits OVER the consumer's content and every\n // app's page <h1> was half-hidden behind it on a phone. It cannot be a\n // spacer element rendered here: this component's siblings land inside the\n // consumer's `display:flex` row (`<Sidebar/><main/>`), where a block would\n // become a zero-width flex COLUMN beside <main> and push nothing down.\n //\n // So the reservation is a `body { padding-top }` rule in globals.css, gated\n // on this class — set only while a bar is actually rendered. Five fleet apps\n // pass `showMobileTopBar={false}` and draw their own top chrome; an ungated\n // rule would give each of them 44px of dead space at the top of every phone\n // view. The bar's own min-height and that padding read the SAME\n // `--hl-mobile-topbar-height` token, so the two can never drift.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!showMobileTopBar) return;\n const root = document.documentElement;\n root.classList.add(\"hl-has-mobile-topbar\");\n return () => root.classList.remove(\"hl-has-mobile-topbar\");\n }, [showMobileTopBar]);\n\n // Lock body scroll while the mobile drawer is open.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!open) return;\n const prev = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n return () => {\n document.body.style.overflow = prev;\n };\n }, [open]);\n\n return (\n <>\n {showMobileTopBar && (\n <div className=\"hl-mobile-topbar fixed inset-x-0 top-0 z-30 flex min-h-[var(--hl-mobile-topbar-height,3.25rem)] items-center gap-2 border-b-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] px-3 py-2 md:hidden\">\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n className=\"rounded-[0.25rem] p-1.5 text-[var(--hl-foreground)] transition hover:bg-[var(--hl-card-hover)] active:scale-90\"\n aria-label=\"Open menu\"\n >\n <Menu className=\"h-5 w-5\" />\n </button>\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={logoSrc} alt=\"\" width={24} height={24} />\n <p className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {logoAlt}\n </p>\n </div>\n )}\n\n {open && (\n <div\n className=\"fixed inset-0 z-40 bg-black/70 md:hidden\"\n onClick={() => setOpen(false)}\n role=\"presentation\"\n />\n )}\n\n <aside\n className={cn(\n \"fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,15rem)] shrink-0 flex-col border-r-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] transition-transform md:static md:translate-x-0 md:h-screen\",\n open ? \"translate-x-0\" : \"-translate-x-full md:translate-x-0\",\n className,\n )}\n >\n <div className=\"relative flex flex-col items-center border-b-2 border-[var(--hl-border)] px-4 pt-6 pb-5\">\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n src={logoSrc}\n alt={logoAlt}\n className={logoClassName}\n />\n {showMobileTopBar && (\n <button\n type=\"button\"\n onClick={() => setOpen(false)}\n className=\"absolute right-3 top-3 rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] md:hidden\"\n aria-label=\"Close menu\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n\n {widgets && <div className=\"mx-3 mt-3\">{widgets}</div>}\n\n {nav && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {nav}\n </nav>\n )}\n\n {middle && <div className={middleClassName}>{middle}</div>}\n\n {lower && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {lower}\n </nav>\n )}\n\n {!middle && <div className=\"flex-1\" />}\n\n {admin && <div>{admin}</div>}\n\n {footer && (\n <div className=\"border-t-2 border-[var(--hl-border)] px-4 py-3 text-center text-xs text-[var(--hl-foreground-faint)]\">\n {footer}\n </div>\n )}\n </aside>\n </>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarNavItemProps = {\n label: string;\n icon?: React.ReactNode;\n active?: boolean;\n onClick?: () => void;\n /**\n * When set, renders as <a href>. Otherwise renders as <button>. In\n * Next.js, prefer passing `render` so you can wrap in next/link\n * without losing classes:\n *\n * <SidebarNavItem\n * label=\"Home\"\n * href=\"/\"\n * render={(props, children) => <Link {...props}>{children}</Link>}\n * />\n *\n * Spread `{...props}` rather than destructuring individual fields\n * (`{ href, className }`) — the props object also carries `aria-current`\n * for the active item, and a narrow destructure silently drops it (and\n * any future field) even though the type still says it's there.\n */\n href?: string;\n render?: (\n props: { href: string; className: string; \"aria-current\"?: \"page\" },\n children: React.ReactNode,\n ) => React.ReactNode;\n};\n\nconst ITEM_CLASS_BASE =\n \"flex items-center gap-2 rounded-[0.5rem] px-3 py-2.5 text-base font-medium transition-all\";\n\nconst itemClass = (active: boolean) =>\n cn(\n ITEM_CLASS_BASE,\n active\n ? \"bg-[var(--hl-brand)] text-[var(--hl-brand-fg)]\"\n : \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-hover-overlay)] hover:text-[var(--hl-foreground)]\",\n );\n\n/**\n * Single nav row for the Sidebar's `nav` / `lower` slots. Matches the\n * doghouse-canonical style: active = brand bg + brand-fg text, inactive =\n * muted text lifting to full foreground on hover, over a hover wash.\n *\n * Every colour here is a token. The active pill reads `--hl-brand` /\n * `--hl-brand-fg` (it was `bg-[#ff9900] text-white` until 0.8.0); the\n * inactive row reads `--hl-foreground-muted` / `--hl-hover-overlay` /\n * `--hl-foreground` (it was `text-white/45 hover:bg-white/5\n * hover:text-white` until 0.9.0). Both were themeability bugs of the same\n * shape — see globals.css.\n */\nexport function SidebarNavItem({\n label,\n icon,\n active = false,\n onClick,\n href,\n render,\n}: SidebarNavItemProps) {\n const className = itemClass(active);\n const ariaCurrent = active ? (\"page\" as const) : undefined;\n const inner = (\n <>\n {icon}\n <span>{label}</span>\n </>\n );\n\n if (href && render)\n return (\n <>{render({ href, className, \"aria-current\": ariaCurrent }, inner)}</>\n );\n if (href) {\n return (\n <a href={href} aria-current={ariaCurrent} className={className}>\n {inner}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-current={ariaCurrent}\n aria-pressed={active}\n className={`${className} w-full`}\n >\n {inner}\n </button>\n );\n}\n","\"use client\";\n\nimport { Loader2 } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SpinnerProps = {\n label?: string;\n className?: string;\n};\n\n/**\n * Consistent inline loading affordance — used wherever the app would\n * otherwise say \"Loading…\" in plain text. Compact spinner + label, on the\n * same `--hl-foreground-muted` as every other secondary line.\n */\nexport function Spinner({ label = \"Loading…\", className }: SpinnerProps) {\n return (\n <p\n className={cn(\n \"flex items-center gap-2 px-3 py-2 text-xs text-[var(--hl-foreground-muted)]\",\n className,\n )}\n >\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n {label}\n </p>\n );\n}\n","import { cn } from \"../lib/utils\";\n\nexport type StatTileProps = {\n /** Uppercase micro-label above the value. */\n label: string;\n /** The stat itself — keep it short; it truncates rather than wraps. */\n value: React.ReactNode;\n /** Optional caption under the value. The line is ALWAYS reserved so the\n * tile height never changes when a sub appears/disappears. */\n sub?: React.ReactNode;\n className?: string;\n};\n\n/**\n * Live-stat tile with STABLE GEOMETRY — the fleet standard for status\n * cards (born on zoo-docs' dashboard, 2026-07-13):\n *\n * - text truncates, never wraps → tile height is constant\n * - the sub line renders even when empty → no height jump when data\n * (queue counts, countdowns) comes and goes between polls\n * - value/sub render in font-mono (metrically monospaced) so ticking\n * numbers don't wiggle the width — tabular-nums alone doesn't do this,\n * since the fleet's Poppins/Orbitron subsets don't ship the tnum\n * OpenType feature (B264)\n *\n * Anything elastic in HEIGHT shoves the whole page around on every poll;\n * width elasticity belongs to the row (see StatTileRow).\n */\nexport function StatTile({ label, value, sub, className }: StatTileProps) {\n return (\n <div className={cn(\"min-w-0 rounded-lg bg-[var(--hl-card)] px-4 py-3\", className)}>\n <p className=\"truncate text-[10px] font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\">\n {label}\n </p>\n <p className=\"mt-1 truncate font-mono text-xl font-semibold tabular-nums text-[var(--hl-foreground)]\">{value}</p>\n <p className=\"truncate font-mono text-[11px] tabular-nums text-[var(--hl-foreground)]\">{sub ?? \" \"}</p>\n </div>\n );\n}\n\nexport type StatTileRowProps = {\n children: React.ReactNode;\n /** Minimum tile width before the grid drops a column. Default \"9rem\". */\n minTileWidth?: string;\n className?: string;\n};\n\n/**\n * Full-width, equal-column container for StatTiles. `auto-fit` +\n * `minmax(minTileWidth, 1fr)` means the row stretches edge-to-edge with\n * the browser and sheds whole columns on narrow screens — tiles never\n * overflow off-screen and never wrap their text.\n *\n * Pair with the layout standard: content pages are FULL WIDTH (no\n * max-w-* caps) unless a page explicitly opts out.\n */\nexport function StatTileRow({ children, minTileWidth = \"9rem\", className }: StatTileRowProps) {\n return (\n <div\n className={cn(\"grid gap-3\", className)}\n style={{ gridTemplateColumns: `repeat(auto-fit, minmax(${minTileWidth}, 1fr))` }}\n >\n {children}\n </div>\n );\n}\n","/**\n * Pick black or white text for a solid color chip.\n *\n * The homelab status palette forces white text on every status color\n * EXCEPT yellow (warning: #eab308), which uses black for contrast. This\n * helper enforces that rule wherever a user-picked color drives a chip\n * background (TagChips, status badges, etc).\n *\n * The yellow rule generalizes — anything in the yellow band (high\n * luminance + warm hue) gets black ink.\n */\nexport function tagTextColor(hex: string): \"#ffffff\" | \"#000000\" {\n const normalized = hex.trim().replace(/^#/, \"\");\n if (normalized.length !== 3 && normalized.length !== 6) return \"#ffffff\";\n const expand =\n normalized.length === 3\n ? normalized\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : normalized;\n const r = parseInt(expand.slice(0, 2), 16);\n const g = parseInt(expand.slice(2, 4), 16);\n const b = parseInt(expand.slice(4, 6), 16);\n if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return \"#ffffff\";\n // Perceived luminance per ITU BT.601.\n const luma = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n return luma > 0.65 ? \"#000000\" : \"#ffffff\";\n}\n","\"use client\";\n\nimport { tagTextColor } from \"../lib/colors\";\n\nexport type TagRef = {\n id: string | number;\n name: string;\n color: string;\n};\n\nexport type TagChipsProps = {\n tags: TagRef[];\n size?: \"xs\" | \"sm\";\n onClick?: (tag: TagRef) => void;\n};\n\n/**\n * Render a row of solid-color tag chips. Yellow auto-flips text to\n * black per the homelab status-palette contrast rule (see tagTextColor).\n *\n * Renders `<button>` when interactive (`onClick` set), `<span>` when\n * read-only — keeps screen-reader semantics honest.\n */\nexport function TagChips({ tags, size = \"xs\", onClick }: TagChipsProps) {\n if (tags.length === 0) return null;\n const sizeClasses =\n size === \"xs\" ? \"px-1.5 py-0.5 text-[10px]\" : \"px-2 py-0.5 text-xs\";\n\n return (\n <div className=\"flex flex-wrap gap-1\">\n {tags.map((t) => {\n const style = {\n backgroundColor: t.color,\n color: tagTextColor(t.color),\n };\n const className = `inline-block rounded-[0.25rem] font-semibold ${sizeClasses}`;\n return onClick ? (\n <button\n key={t.id}\n type=\"button\"\n onClick={() => onClick(t)}\n className={`${className} transition active:scale-95`}\n style={style}\n >\n {t.name}\n </button>\n ) : (\n <span key={t.id} className={className} style={style}>\n {t.name}\n </span>\n );\n })}\n </div>\n );\n}\n","/**\n * Font wiring for `@jdcpuwiz/homelab-ui`.\n *\n * ## The standard: three self-hosted faces, split by JOB (BuildPlan #57)\n *\n * Wiz ruling 2026-07-24. This SUPERSEDES both the old \"Geist via next/font\"\n * mandate AND the system-ui-only ruling (#345) that briefly replaced it.\n *\n * SANS Poppins — all body / UI / headings / labels\n * DISPLAY Orbitron — BIG numbers only (stat heroes, clock, ≥ ~1.5rem)\n * MONO JetBrains Mono — small values, table figures, version stamps,\n * code (< ~1.5rem)\n *\n * The size line is the hard boundary: Orbitron is a display face and must\n * NEVER be applied to a dense small cell — if a number is small, it's\n * JetBrains Mono.\n *\n * ## The package NOW OWNS THE FACES (C525) — one import, no per-app config\n *\n * import \"@jdcpuwiz/homelab-ui/fonts.css\"\n *\n * That registers all three faces from woff2 shipped in this package, so\n * `next build` never reaches fonts.gstatic.com.\n *\n * This block previously said loading had to be per-app. That was true only of\n * `next/font`: its SWC plugin scans the app's OWN source for literal\n * `const Foo = Font({...})` calls and bundling rewrites `const`→`var`, so a\n * re-exported LOADER genuinely cannot work. The constraint never applied to\n * plain `@font-face`, which is what fonts.css uses.\n *\n * - Next apps: import `fonts.css`. Nothing else — no loaders, no\n * `.variable` classes, no `--font-*` variables.\n * - Vite apps: import `fonts.css` too, or `@fontsource/poppins` etc.\n *\n * ⚠ `FONT_APP_VARIABLES` below is LEGACY. Apps still running `next/font` set\n * those vars and `globals.css` still reads them first, so both mechanisms\n * work side by side during the rollout. Do NOT reference `--font-poppins`\n * and friends in new code: once an app moves to fonts.css they are unset, and\n * `var(--x)` with no fallback ARGUMENT is invalid at computed-value time —\n * the whole declaration is discarded and the element inherits its parent's\n * face rather than reaching your authored fallback. Use `--hl-font-sans` /\n * `--hl-font-display` / `--hl-font-mono`.\n *\n * ## `next/font/google` is a BUILD-TIME NETWORK DEPENDENCY (2026-08-15)\n *\n * The Next path above makes `next build` reach fonts.gstatic.com, which means\n * every consuming app's DEPLOY depends on it. zoovault's did, and lost: its\n * image floated `next` to a release whose next/font/google requests began\n * 404ing, and the deploy died on a commit that built clean locally. The\n * root-cause fix was pinning `next` from a committed lockfile inside the image\n * (`npm ci`, never a bare `npm install`) — do that first, everywhere. Beyond\n * that, self-hosted faces remove the network from the build entirely;\n * zoovault now runs `next/font/local` over woff2 files sourced from\n * @fontsource and committed in-repo.\n *\n * Note the fallback chain in `globals.css` — `var(--font-poppins, \"Poppins\")`.\n * The package asks for the CSS var and then for the LITERAL FAMILY NAME, so\n * anything that registers a \"Poppins\" @font-face satisfies it. next/font is a\n * convention here, not a requirement, and the Vite path above is proof.\n *\n * `globals.css` declares `--hl-font-sans` / `--hl-font-display` /\n * `--hl-font-mono` referencing the app variables below first, then the literal\n * face name, then the platform fallback — so text is readable even mid-load\n * and an un-migrated app degrades to the system stack, never to nothing.\n *\n * Sanity check after deploy — this should name Poppins:\n * getComputedStyle(document.body).fontFamily // → \"…Poppins…\"\n */\n\n/**\n * The package's own token names — the `--hl-font-*` vars the Tailwind preset's\n * `font-sans` / `font-mono` / `font-display` classes resolve to. Consumers\n * rarely touch these directly.\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--hl-font-sans\",\n mono: \"--hl-font-mono\",\n display: \"--hl-font-display\",\n} as const;\n\n/**\n * The app-side CSS variable each next/font loader must expose (its `variable:`\n * option). `globals.css` reads these, so an app that follows this naming gets\n * the three faces wired with zero extra config. Match these EXACTLY.\n *\n * const poppins = Poppins({ ..., variable: FONT_APP_VARIABLES.sans });\n */\nexport const FONT_APP_VARIABLES = {\n sans: \"--font-poppins\",\n display: \"--font-orbitron\",\n mono: \"--font-jetbrains-mono\",\n} as const;\n"],"mappings":";;;AAEA,SAAS,kBAAkB;;;ACF3B,SAAS,YAA6B;AACtC,SAAS,eAAe;AAMjB,SAAS,MAAM,QAA8B;AAClD,SAAO,QAAQ,KAAK,MAAM,CAAC;AAC7B;;;ADsCM;AAlCN,IAAM,OACJ;AAOF,IAAM,UAAmC;AAAA,EACvC,SACE;AAAA,EACF,WACE;AAAA,EACF,UACE;AAAA,EACF,QAAQ;AACV;AAEA,IAAM,OAA6B;AAAA,EACjC,IAAI;AAAA,EACJ,IAAI;AACN;AAOO,IAAM,SAAS;AAAA,EACpB,SAASA,QACP,EAAE,UAAU,aAAa,OAAO,MAAM,WAAW,GAAG,KAAK,GACzD,KACA;AACA,WACE;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACC,GAAG;AAAA,QACJ,WAAW,GAAG,MAAM,QAAQ,OAAO,GAAG,KAAK,IAAI,GAAG,SAAS;AAAA;AAAA,IAC7D;AAAA,EAEJ;AACF;;;AEpDA,SAAS,aAAAC,kBAAiB;;;ACA1B,SAAS,WAAW,cAAc;AAClC,SAAS,SAAS;AAiFR,SAEI,OAAAC,MAFJ;AAhDH,SAAS,MAAM;AAAA,EACpB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,WAAW;AAAA,EACX;AAAA,EACA;AACF,GAAe;AACb,QAAM,aAAa;AAAA,IACjB,YAAY,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,GAAG,CAAC,CAAC;AAAA,EACpD;AAEA,YAAU,MAAM;AACd,QAAI,CAAC,KAAM;AACX,UAAM,QAAQ,CAAC,MAAqB;AAClC,UAAI,EAAE,QAAQ,SAAU,SAAQ;AAAA,IAClC;AACA,WAAO,iBAAiB,WAAW,KAAK;AACxC,WAAO,MAAM,OAAO,oBAAoB,WAAW,KAAK;AAAA,EAC1D,GAAG,CAAC,MAAM,OAAO,CAAC;AAElB,MAAI,CAAC,KAAM,QAAO;AAElB,QAAM,eAAe,aAAa,QAAQ,KAAK;AAC/C,QAAM,eAAe,YAAY,UAAU,gBAAgB;AAE3D,SACE,gBAAAA;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MACA,SAAS;AAAA,MACT,MAAK;AAAA,MAEL;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,cAAW;AAAA,UACX,mBAAiB,QAAQ,WAAW,UAAU;AAAA,UAC9C,WAAW;AAAA,YACT;AAAA,YACA;AAAA,UACF;AAAA,UACA,SAAS,CAAC,MAAM,EAAE,gBAAgB;AAAA,UAEhC;AAAA,sBAAS,iBACT,qBAAC,SAAI,WAAU,+CACZ;AAAA,uBACC,gBAAAA;AAAA,gBAAC;AAAA;AAAA,kBACC,IAAI,WAAW;AAAA,kBACf,WAAU;AAAA,kBAET;AAAA;AAAA,cACH;AAAA,cAED,gBACC,gBAAAA;AAAA,gBAAC;AAAA;AAAA,kBACC,MAAK;AAAA,kBACL,SAAS;AAAA,kBACT,WAAU;AAAA,kBACV,cAAW;AAAA,kBAEX,0BAAAA,KAAC,KAAE,WAAU,WAAU;AAAA;AAAA,cACzB;AAAA,eAEJ;AAAA,YAED;AAAA,YACA,UACC,gBAAAA,KAAC,SAAI,WAAU,sDACZ,kBACH;AAAA;AAAA;AAAA,MAEJ;AAAA;AAAA,EACF;AAEJ;;;ADpEQ,mBACE,OAAAC,MADF,QAAAC,aAAA;AAzBD,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA,eAAe;AAAA,EACf,cAAc;AAAA,EACd,OAAO;AAAA,EACP;AAAA,EACA;AACF,GAAuB;AACrB,EAAAC,WAAU,MAAM;AACd,QAAI,CAAC,KAAM;AACX,UAAM,QAAQ,CAAC,MAAqB;AAClC,UAAI,EAAE,QAAQ,QAAS,WAAU;AAAA,IACnC;AACA,WAAO,iBAAiB,WAAW,KAAK;AACxC,WAAO,MAAM,OAAO,oBAAoB,WAAW,KAAK;AAAA,EAC1D,GAAG,CAAC,MAAM,SAAS,CAAC;AAEpB,SACE,gBAAAF;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA;AAAA,MACA;AAAA,MACA,QACE,gBAAAC,MAAA,YACE;AAAA,wBAAAD,KAAC,UAAO,SAAQ,YAAW,MAAK,MAAK,SAAS,SAC3C,uBACH;AAAA,QACA,gBAAAA;AAAA,UAAC;AAAA;AAAA,YACC,SAAS,SAAS,WAAW,WAAW;AAAA,YACxC,MAAK;AAAA,YACL,SAAS;AAAA,YAER;AAAA;AAAA,QACH;AAAA,SACF;AAAA,MAGF,0BAAAA,KAAC,OAAE,WAAU,2DAA2D,mBAAQ;AAAA;AAAA,EAClF;AAEJ;;;AEpCM,gBAAAG,MAMF,QAAAC,aANE;AAVC,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV;AACF,GAAoB;AAClB,MAAI,YAAY,UAAU;AACxB,WACE,gBAAAD,KAAC,OAAE,WAAW,GAAG,iDAAiD,SAAS,GACxE,mBACH;AAAA,EAEJ;AACA,SACE,gBAAAC;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MAEC;AAAA,gBACC,gBAAAD,KAAC,SAAI,WAAU,qCAAqC,gBAAK;AAAA,QAE1D,SACC,gBAAAA,KAAC,QAAG,WAAU,qDACX,iBACH;AAAA,QAEF,gBAAAA,KAAC,OAAE,WAAU,uCAAuC,mBAAQ;AAAA,QAC3D;AAAA;AAAA;AAAA,EACH;AAEJ;;;ACjDA,SAAS,aAAAE,YAAW,gBAAgB;AACpC,SAAS,WAAW,cAAc,gBAAgB;AAyE9C,qBAAAC,WACE,OAAAC,MADF,QAAAC,aAAA;AArBG,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAoB;AAClB,QAAM,CAAC,cAAc,eAAe,IAAI,SAAS,KAAK;AACtD,EAAAC,WAAU,MAAM;AACd,oBAAgB,KAAK;AAAA,EACvB,GAAG,CAAC,QAAQ,CAAC;AAEb,QAAM,eAAe,CAAC,CAAC;AACvB,QAAM,eAAe,gBAAgB,CAAC;AACtC,QAAM,gBAAgB,MAAM,QAAQ,UAAU,KAAK,WAAW,SAAS;AACvE,QAAM,eAAe;AAErB,QAAM,YAAY,OAChB,gBAAAD,MAAAF,WAAA,EACE;AAAA,oBAAAC,KAAC,aAAU,WAAU,WAAU;AAAA,IAC9B,KAAK;AAAA,KACR,IACE;AAEJ,SACE,gBAAAC;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MAEA;AAAA,wBAAAA,MAAC,SAAI,WAAU,gCACZ;AAAA,2BACE;AAAA;AAAA,YAEC,gBAAAD;AAAA,cAAC;AAAA;AAAA,gBACC,KAAK;AAAA,gBACL,KAAK,YAAY;AAAA,gBACjB,SAAS,MAAM,gBAAgB,IAAI;AAAA,gBACnC,WAAU;AAAA;AAAA,YACZ;AAAA,cAEA,gBAAAA,KAAC,qBAAkB,OAAc,UAAqB;AAAA,UAE1D,gBAAAC,MAAC,SAAI,WAAU,WACZ;AAAA,qBACE,KAAK,SACJ,KAAK;AAAA,cACH,KAAK;AAAA,cACL,gBAAAD,KAAC,UAAK,WAAU,yHACb,qBACH;AAAA,YACF,IAEA,gBAAAA;AAAA,cAAC;AAAA;AAAA,gBACC,MAAM,KAAK;AAAA,gBACX,WAAU;AAAA,gBAET;AAAA;AAAA,YACH;AAAA,YAEH,gBACC,gBAAAA;AAAA,cAAC;AAAA;AAAA,gBACC,QAAQ;AAAA,gBACR,SAAS;AAAA,gBACT,WAAW,OAAO,SAAS;AAAA;AAAA,YAC7B,IAEA,gBAAAA;AAAA,cAAC;AAAA;AAAA,gBACC,WAAW;AAAA,kBACT;AAAA,kBACA,QAAQ;AAAA,kBACR,gBAAgB;AAAA,gBAClB;AAAA,gBAEC;AAAA;AAAA,YACH;AAAA,YAED,eACC,gBAAAA,KAAC,OAAE,WAAU,4CAA4C,uBAAY;AAAA,aAEzE;AAAA,WACF;AAAA,QACC,WAAW,gBAAAA,KAAC,SAAI,WAAU,2BAA2B,mBAAQ;AAAA;AAAA;AAAA,EAChE;AAEJ;AAEA,SAAS,kBAAkB;AAAA,EACzB;AAAA,EACA;AACF,GAGG;AACD,QAAM,OAAO,SAAS,QAAQ,OAAO,EAAE;AACvC,SACE,gBAAAC;AAAA,IAAC;AAAA;AAAA,MACC,OAAO,QAAQ,IAAI;AAAA,MACnB,WAAU;AAAA,MAEV;AAAA,wBAAAD,KAAC,YAAS,WAAU,WAAU;AAAA,QAC9B,gBAAAA,KAAC,UAAK,WAAU,wEACb,iBACH;AAAA;AAAA;AAAA,EACF;AAEJ;AAEA,SAAS,WAAW;AAAA,EAClB;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,SACE,gBAAAC;AAAA,IAAC;AAAA;AAAA,MACC,cAAW;AAAA,MACX,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MAEA;AAAA,wBAAAD,KAAC,QAAG,WAAU,WAAW,mBAAQ;AAAA,QAChC,OAAO,IAAI,CAAC,OAAO,QAAQ;AAC1B,gBAAM,SAAS,QAAQ,OAAO,SAAS;AACvC,iBACE,gBAAAC;AAAA,YAAC;AAAA;AAAA,cAEC,WAAU;AAAA,cAET;AAAA,sBAAM,KACL,gBAAAD;AAAA,kBAAC;AAAA;AAAA,oBACC,WAAU;AAAA,oBACV,eAAY;AAAA;AAAA,gBACd;AAAA,gBAED,MAAM,WAAW,CAAC,SACjB,gBAAAA;AAAA,kBAAC;AAAA;AAAA,oBACC,MAAK;AAAA,oBACL,SAAS,MAAM;AAAA,oBACf,WAAU;AAAA,oBAET,gBAAM;AAAA;AAAA,gBACT,IAEA,gBAAAA;AAAA,kBAAC;AAAA;AAAA,oBACC,WAAW;AAAA,sBACT,SACI,gCACA;AAAA,sBACJ,UAAU;AAAA,oBACZ;AAAA,oBACA,gBAAc,SAAS,SAAS;AAAA,oBAE/B,gBAAM;AAAA;AAAA,gBACT;AAAA;AAAA;AAAA,YA5BG,GAAG,MAAM,KAAK,IAAI,GAAG;AAAA,UA8B5B;AAAA,QAEJ,CAAC;AAAA;AAAA;AAAA,EACH;AAEJ;;;AC9NA,SAAS,aAAAG,YAAW,YAAAC,iBAAgB;AACpC,SAAS,MAAM,KAAAC,UAAS;AAqJpB,qBAAAC,WASQ,OAAAC,MAPJ,QAAAC,aAFJ;AA3DG,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA,gBAAgB;AAAA,EAChB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB,mBAAmB;AACrB,GAAiB;AACf,QAAM,CAAC,cAAc,eAAe,IAAIC,UAAS,KAAK;AACtD,QAAM,eAAe,mBAAmB;AACxC,QAAM,OAAO,eAAe,iBAAiB;AAE7C,QAAM,UAAU,CAAC,SAAkB;AACjC,QAAI,CAAC,aAAc,iBAAgB,IAAI;AACvC,yBAAqB,IAAI;AAAA,EAC3B;AAgBA,EAAAC,WAAU,MAAM;AACd,QAAI,OAAO,aAAa,YAAa;AACrC,QAAI,CAAC,iBAAkB;AACvB,UAAM,OAAO,SAAS;AACtB,SAAK,UAAU,IAAI,sBAAsB;AACzC,WAAO,MAAM,KAAK,UAAU,OAAO,sBAAsB;AAAA,EAC3D,GAAG,CAAC,gBAAgB,CAAC;AAGrB,EAAAA,WAAU,MAAM;AACd,QAAI,OAAO,aAAa,YAAa;AACrC,QAAI,CAAC,KAAM;AACX,UAAM,OAAO,SAAS,KAAK,MAAM;AACjC,aAAS,KAAK,MAAM,WAAW;AAC/B,WAAO,MAAM;AACX,eAAS,KAAK,MAAM,WAAW;AAAA,IACjC;AAAA,EACF,GAAG,CAAC,IAAI,CAAC;AAET,SACE,gBAAAF,MAAAF,WAAA,EACG;AAAA,wBACC,gBAAAE,MAAC,SAAI,WAAU,sMACb;AAAA,sBAAAD;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,SAAS,MAAM,QAAQ,IAAI;AAAA,UAC3B,WAAU;AAAA,UACV,cAAW;AAAA,UAEX,0BAAAA,KAAC,QAAK,WAAU,WAAU;AAAA;AAAA,MAC5B;AAAA,MAEA,gBAAAA,KAAC,SAAI,KAAK,SAAS,KAAI,IAAG,OAAO,IAAI,QAAQ,IAAI;AAAA,MACjD,gBAAAA,KAAC,OAAE,WAAU,qDACV,mBACH;AAAA,OACF;AAAA,IAGD,QACC,gBAAAA;AAAA,MAAC;AAAA;AAAA,QACC,WAAU;AAAA,QACV,SAAS,MAAM,QAAQ,KAAK;AAAA,QAC5B,MAAK;AAAA;AAAA,IACP;AAAA,IAGF,gBAAAC;AAAA,MAAC;AAAA;AAAA,QACC,WAAW;AAAA,UACT;AAAA,UACA,OAAO,kBAAkB;AAAA,UACzB;AAAA,QACF;AAAA,QAEA;AAAA,0BAAAA,MAAC,SAAI,WAAU,2FAEb;AAAA,4BAAAD;AAAA,cAAC;AAAA;AAAA,gBACC,KAAK;AAAA,gBACL,KAAK;AAAA,gBACL,WAAW;AAAA;AAAA,YACb;AAAA,YACC,oBACC,gBAAAA;AAAA,cAAC;AAAA;AAAA,gBACC,MAAK;AAAA,gBACL,SAAS,MAAM,QAAQ,KAAK;AAAA,gBAC5B,WAAU;AAAA,gBACV,cAAW;AAAA,gBAEX,0BAAAA,KAACI,IAAA,EAAE,WAAU,WAAU;AAAA;AAAA,YACzB;AAAA,aAEJ;AAAA,UAEC,WAAW,gBAAAJ,KAAC,SAAI,WAAU,aAAa,mBAAQ;AAAA,UAE/C,OACC,gBAAAA,KAAC,SAAI,WAAU,mEACZ,eACH;AAAA,UAGD,UAAU,gBAAAA,KAAC,SAAI,WAAW,iBAAkB,kBAAO;AAAA,UAEnD,SACC,gBAAAA,KAAC,SAAI,WAAU,mEACZ,iBACH;AAAA,UAGD,CAAC,UAAU,gBAAAA,KAAC,SAAI,WAAU,UAAS;AAAA,UAEnC,SAAS,gBAAAA,KAAC,SAAK,iBAAM;AAAA,UAErB,UACC,gBAAAA,KAAC,SAAI,WAAU,wGACZ,kBACH;AAAA;AAAA;AAAA,IAEJ;AAAA,KACF;AAEJ;;;ACvKI,qBAAAK,WAEE,OAAAC,MAFF,QAAAC,aAAA;AAlCJ,IAAM,kBACJ;AAEF,IAAM,YAAY,CAAC,WACjB;AAAA,EACE;AAAA,EACA,SACI,mDACA;AACN;AAcK,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AAAA,EACA;AAAA,EACA;AACF,GAAwB;AACtB,QAAM,YAAY,UAAU,MAAM;AAClC,QAAM,cAAc,SAAU,SAAmB;AACjD,QAAM,QACJ,gBAAAA,MAAAF,WAAA,EACG;AAAA;AAAA,IACD,gBAAAC,KAAC,UAAM,iBAAM;AAAA,KACf;AAGF,MAAI,QAAQ;AACV,WACE,gBAAAA,KAAAD,WAAA,EAAG,iBAAO,EAAE,MAAM,WAAW,gBAAgB,YAAY,GAAG,KAAK,GAAE;AAEvE,MAAI,MAAM;AACR,WACE,gBAAAC,KAAC,OAAE,MAAY,gBAAc,aAAa,WACvC,iBACH;AAAA,EAEJ;AACA,SACE,gBAAAA;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL;AAAA,MACA,gBAAc;AAAA,MACd,gBAAc;AAAA,MACd,WAAW,GAAG,SAAS;AAAA,MAEtB;AAAA;AAAA,EACH;AAEJ;;;AC5FA,SAAS,eAAe;AAepB,SAME,OAAAE,MANF,QAAAC,aAAA;AAFG,SAAS,QAAQ,EAAE,QAAQ,iBAAY,UAAU,GAAiB;AACvE,SACE,gBAAAA;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MAEA;AAAA,wBAAAD,KAAC,WAAQ,WAAU,wBAAuB;AAAA,QACzC;AAAA;AAAA;AAAA,EACH;AAEJ;;;ACGI,SACE,OAAAE,MADF,QAAAC,aAAA;AAFG,SAAS,SAAS,EAAE,OAAO,OAAO,KAAK,UAAU,GAAkB;AACxE,SACE,gBAAAA,MAAC,SAAI,WAAW,GAAG,oDAAoD,SAAS,GAC9E;AAAA,oBAAAD,KAAC,OAAE,WAAU,4FACV,iBACH;AAAA,IACA,gBAAAA,KAAC,OAAE,WAAU,0FAA0F,iBAAM;AAAA,IAC7G,gBAAAA,KAAC,OAAE,WAAU,2EAA2E,iBAAO,QAAI;AAAA,KACrG;AAEJ;AAkBO,SAAS,YAAY,EAAE,UAAU,eAAe,QAAQ,UAAU,GAAqB;AAC5F,SACE,gBAAAA;AAAA,IAAC;AAAA;AAAA,MACC,WAAW,GAAG,cAAc,SAAS;AAAA,MACrC,OAAO,EAAE,qBAAqB,2BAA2B,YAAY,UAAU;AAAA,MAE9E;AAAA;AAAA,EACH;AAEJ;;;ACtDO,SAAS,aAAa,KAAoC;AAC/D,QAAM,aAAa,IAAI,KAAK,EAAE,QAAQ,MAAM,EAAE;AAC9C,MAAI,WAAW,WAAW,KAAK,WAAW,WAAW,EAAG,QAAO;AAC/D,QAAM,SACJ,WAAW,WAAW,IAClB,WACG,MAAM,EAAE,EACR,IAAI,CAAC,MAAM,IAAI,CAAC,EAChB,KAAK,EAAE,IACV;AACN,QAAM,IAAI,SAAS,OAAO,MAAM,GAAG,CAAC,GAAG,EAAE;AACzC,QAAM,IAAI,SAAS,OAAO,MAAM,GAAG,CAAC,GAAG,EAAE;AACzC,QAAM,IAAI,SAAS,OAAO,MAAM,GAAG,CAAC,GAAG,EAAE;AACzC,MAAI,OAAO,MAAM,CAAC,KAAK,OAAO,MAAM,CAAC,KAAK,OAAO,MAAM,CAAC,EAAG,QAAO;AAElE,QAAM,QAAQ,QAAQ,IAAI,QAAQ,IAAI,QAAQ,KAAK;AACnD,SAAO,OAAO,OAAO,YAAY;AACnC;;;ACSU,gBAAAE,aAAA;AAdH,SAAS,SAAS,EAAE,MAAM,OAAO,MAAM,QAAQ,GAAkB;AACtE,MAAI,KAAK,WAAW,EAAG,QAAO;AAC9B,QAAM,cACJ,SAAS,OAAO,8BAA8B;AAEhD,SACE,gBAAAA,MAAC,SAAI,WAAU,wBACZ,eAAK,IAAI,CAAC,MAAM;AACf,UAAM,QAAQ;AAAA,MACZ,iBAAiB,EAAE;AAAA,MACnB,OAAO,aAAa,EAAE,KAAK;AAAA,IAC7B;AACA,UAAM,YAAY,gDAAgD,WAAW;AAC7E,WAAO,UACL,gBAAAA;AAAA,MAAC;AAAA;AAAA,QAEC,MAAK;AAAA,QACL,SAAS,MAAM,QAAQ,CAAC;AAAA,QACxB,WAAW,GAAG,SAAS;AAAA,QACvB;AAAA,QAEC,YAAE;AAAA;AAAA,MANE,EAAE;AAAA,IAOT,IAEA,gBAAAA,MAAC,UAAgB,WAAsB,OACpC,YAAE,QADM,EAAE,EAEb;AAAA,EAEJ,CAAC,GACH;AAEJ;;;ACoBO,IAAM,qBAAqB;AAAA,EAChC,MAAM;AAAA,EACN,MAAM;AAAA,EACN,SAAS;AACX;AASO,IAAM,qBAAqB;AAAA,EAChC,MAAM;AAAA,EACN,SAAS;AAAA,EACT,MAAM;AACR;","names":["Button","useEffect","jsx","jsx","jsxs","useEffect","jsx","jsxs","useEffect","Fragment","jsx","jsxs","useEffect","useEffect","useState","X","Fragment","jsx","jsxs","useState","useEffect","X","Fragment","jsx","jsxs","jsx","jsxs","jsx","jsxs","jsx"]}
package/globals.css CHANGED
@@ -154,6 +154,15 @@
154
154
  (#9ca3af) instead of the fleet #7d838d. Override at :root to re-skin,
155
155
  like every other token. */
156
156
  --hl-placeholder: #7d838d;
157
+
158
+ /* Height of the Sidebar's fixed mobile top bar (BUG #339). ONE constant:
159
+ the bar sets `min-h-[var(--hl-mobile-topbar-height,3.25rem)]` and the
160
+ reservation rule at the bottom of this file pads the body by the same
161
+ token, so a future change to the bar cannot re-open the overlap.
162
+ 3.25rem is the bar's natural height (border-b-2 + py-2 + its 5rem-tall
163
+ hamburger row) rounded up at both the 14px root this file sets and a
164
+ 16px root, so the bar never grows past its own reservation. */
165
+ --hl-mobile-topbar-height: 3.25rem;
157
166
  }
158
167
 
159
168
  html {
@@ -184,3 +193,27 @@ body {
184
193
  color: var(--hl-placeholder);
185
194
  opacity: 1; /* Firefox dims placeholders with its own default opacity */
186
195
  }
196
+
197
+ /* Reserve the fixed mobile top bar's height in flow (BUG #339).
198
+ *
199
+ * The Sidebar's phone top bar is `position: fixed` at `top-0`, so it sat OVER
200
+ * the consumer's content: every app's page <h1> was half-hidden behind it at
201
+ * phone widths, fleet-wide. It cannot reserve its own space with a sibling
202
+ * spacer — the Sidebar's siblings land inside the consumer's `display:flex`
203
+ * row (`<Sidebar/><main/>`), where a block becomes a zero-width flex COLUMN
204
+ * beside <main> and pushes nothing down — so the reservation is here.
205
+ *
206
+ * GATED on `html.hl-has-mobile-topbar`, which the Sidebar adds only while it
207
+ * is actually rendering the bar. Five fleet apps pass
208
+ * `showMobileTopBar={false}` and draw their own top chrome; an ungated rule
209
+ * would give each of them a bar's worth of dead space on every phone view.
210
+ * An app that imports this file but renders no Sidebar likewise gets nothing.
211
+ *
212
+ * md (768px) is the same breakpoint the bar's own `md:hidden` uses (767.98px
213
+ * covers a fractional viewport width) — keep the
214
+ * two in step. */
215
+ @media (max-width: 767.98px) {
216
+ html.hl-has-mobile-topbar body {
217
+ padding-top: var(--hl-mobile-topbar-height, 3.25rem);
218
+ }
219
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jdcpuwiz/homelab-ui",
3
- "version": "0.10.4",
3
+ "version": "0.10.6",
4
4
  "description": "Shared dark-theme UI primitives for the JdCpuWiz homelab — Sidebar, Button, Modal, PageHeader, TagChips, plus the canonical Tailwind preset, CSS vars, and the sanctioned three-face typography (Poppins / Orbitron / JetBrains Mono) on one flat #f0f1f4 text color.",
5
5
  "license": "MIT",
6
6
  "author": "Wiz <shad@deckerzoo.com>",