@jdcpuwiz/homelab-ui 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -164,7 +164,7 @@ The Tailwind preset exposes the canonical homelab palette:
164
164
  | `text-title` (`#aa89b7`) | h1 purple (doghouse convention) |
165
165
  | `text-ink-primary` `text-ink-secondary` `text-ink-tertiary` `text-ink-disabled` | When `text-white/60` semantics aren't enough |
166
166
  | `rounded-widget` (xl), `rounded-row` (lg), `rounded-chip`, `rounded-panel` (2xl) | Radius aliases |
167
- | `w-sidebar` (15rem), `w-logo` `h-logo` (9rem) | Layout sizes |
167
+ | `w-sidebar` (default 15rem), `w-logo` `h-logo` (9rem) | Layout sizes (sidebar width is `var(--hl-sidebar-width)` — override at `:root`) |
168
168
  | `font-sans` `font-mono` `font-display` | Geist / Geist Mono / Orbitron |
169
169
  | `text-2xs` (10px / 14px) | Sidebar footers, grid captions |
170
170
 
package/dist/index.cjs CHANGED
@@ -402,6 +402,7 @@ function Sidebar({
402
402
  mobileOpen: controlledOpen,
403
403
  onMobileOpenChange,
404
404
  className,
405
+ middleClassName = "mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[#1e1e1e] p-2",
405
406
  showMobileTopBar = true
406
407
  }) {
407
408
  const [internalOpen, setInternalOpen] = (0, import_react5.useState)(false);
@@ -447,7 +448,7 @@ function Sidebar({
447
448
  "aside",
448
449
  {
449
450
  className: cn(
450
- "fixed inset-y-0 left-0 z-50 flex w-60 shrink-0 flex-col border-r-2 border-[#1e1e1e] bg-[#111111] transition-transform md:static md:translate-x-0 md:h-screen",
451
+ "fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,15rem)] shrink-0 flex-col border-r-2 border-[#1e1e1e] bg-[#111111] transition-transform md:static md:translate-x-0 md:h-screen",
451
452
  open ? "translate-x-0" : "-translate-x-full md:translate-x-0",
452
453
  className
453
454
  ),
@@ -474,7 +475,7 @@ function Sidebar({
474
475
  ] }),
475
476
  widgets && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "mx-3 mt-3", children: widgets }),
476
477
  nav && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("nav", { className: "mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[#1e1e1e] p-2", children: nav }),
477
- middle && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[#1e1e1e] p-2", children: middle }),
478
+ middle && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: middleClassName, children: middle }),
478
479
  lower && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("nav", { className: "mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[#1e1e1e] p-2", children: lower }),
479
480
  !middle && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "flex-1" }),
480
481
  admin && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { children: admin }),
@@ -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/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 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 } 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-[#ffb84d]\";\n\nconst VARIANT: Record<Variant, string> = {\n primary: \"bg-[#ff9900] text-[#0a0a0a] hover:bg-[#cc6a1a]\",\n secondary: \"bg-[#1e1e1e] text-white hover:bg-[#252525]\",\n tertiary: \"text-white/60 hover:bg-[#252525] hover:text-white\",\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-white/70\">{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-[#1e1e1e] bg-[#111111] 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-white\"\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-white/40 transition hover:bg-[#252525] hover:text-white 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-white/40\", 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-[#2a2620] p-12 text-center\",\n className,\n )}\n >\n {icon && <div className=\"text-white/30\">{icon}</div>}\n {title && (\n <h3 className=\"text-sm font-semibold text-white\">{title}</h3>\n )}\n <p className=\"text-sm text-white/60\">{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 * purple text-title becomes 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 (white + bold); earlier crumbs are 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-white/40 transition hover:text-white\">\n {backInner}\n </span>,\n )\n ) : (\n <a\n href={back.href}\n className=\"inline-flex items-center gap-1 text-xs text-white/40 transition hover:text-white\"\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-[#aa89b7]\",\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-white/40\">{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-[#1e1e1e] bg-[#2a2620] text-white/30 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-white/30\"\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-white/45 transition hover:text-white\"\n >\n {crumb.label}\n </button>\n ) : (\n <span\n className={cn(\n isLast ? \"text-white\" : \"text-white/45\",\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).\n */\n logoSrc: string;\n /** Alt text for the logo + label shown in the mobile top bar. */\n logoAlt: 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 * 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 (`w-60` / `w-sidebar`)\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 * The component renders raw hex values (#111111, #1e1e1e) so it works\n * even when the consumer skips the Tailwind preset. Apply the preset\n * + globals.css for token-based overrides.\n */\nexport function Sidebar({\n logoSrc,\n logoAlt,\n widgets,\n nav,\n middle,\n lower,\n admin,\n footer,\n mobileOpen: controlledOpen,\n onMobileOpenChange,\n className,\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-[#1e1e1e] bg-[#111111] px-3 py-2 md:hidden\">\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n className=\"rounded-[0.25rem] p-1.5 text-white transition hover:bg-[#252525] 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-white\">{logoAlt}</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-60 shrink-0 flex-col border-r-2 border-[#1e1e1e] bg-[#111111] 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-[#1e1e1e] 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=\"h-36 w-36 rounded-[1rem] object-contain\"\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-white/40 transition hover:bg-[#252525] hover:text-white 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-[#1e1e1e] p-2\">\n {nav}\n </nav>\n )}\n\n {middle && (\n <div className=\"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[#1e1e1e] p-2\">\n {middle}\n </div>\n )}\n\n {lower && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[#1e1e1e] 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-[#1e1e1e] px-4 py-3 text-center text-xs text-white/20\">\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 href?: string;\n render?: (\n props: { href: string; className: string },\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-[#ff9900] text-white\"\n : \"text-white/45 hover:bg-white/5 hover:text-white\",\n );\n\n/**\n * Single nav row for the Sidebar's `nav` / `lower` slots. Matches the\n * doghouse-canonical style: active = orange bg + white text, inactive =\n * white/45 text + white/5 hover bg.\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 inner = (\n <>\n {icon}\n <span>{label}</span>\n </>\n );\n\n if (href && render) return <>{render({ href, className }, inner)}</>;\n if (href) {\n return (\n <a href={href} className={className}>\n {inner}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n onClick={onClick}\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, color\n * is the same tertiary-text grey across the app.\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-white/40\",\n className,\n )}\n >\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n {label}\n </p>\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 CSS variable names used by `@jdcpuwiz/homelab-ui`'s Tailwind preset.\n *\n * Why constants and not re-exported loaders: Next.js's `next/font/google`\n * is a build-time directive scanned out of your *app* source by the SWC\n * plugin. The loader call MUST literally appear as `const X = Font(...)`\n * in your `app/layout.tsx`; pre-bundling it through a library turns\n * `const` into `var` and Next refuses to compile it. So the canonical\n * setup is \"paste this snippet into layout.tsx\" (see README) — and these\n * constants document the expected variable names so you wire `--font-*`\n * correctly without guessing.\n *\n * Snippet to paste into `app/layout.tsx`:\n *\n * import { Geist, Geist_Mono, Orbitron } from \"next/font/google\";\n *\n * const geistSans = Geist({ variable: \"--font-geist-sans\", subsets: [\"latin\"] });\n * const geistMono = Geist_Mono({ variable: \"--font-geist-mono\", subsets: [\"latin\"] });\n * const orbitron = Orbitron({ variable: \"--font-orbitron\", subsets: [\"latin\"] });\n *\n * <body className={`${geistSans.variable} ${geistMono.variable} ${orbitron.variable} antialiased`}>\n *\n * Sanity check after deploy:\n * getComputedStyle(document.body).fontFamily.startsWith('\"Geist\"')\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--font-geist-sans\",\n mono: \"--font-geist-mono\",\n display: \"--font-orbitron\",\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"]}
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/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 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 } 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-[#ffb84d]\";\n\nconst VARIANT: Record<Variant, string> = {\n primary: \"bg-[#ff9900] text-[#0a0a0a] hover:bg-[#cc6a1a]\",\n secondary: \"bg-[#1e1e1e] text-white hover:bg-[#252525]\",\n tertiary: \"text-white/60 hover:bg-[#252525] hover:text-white\",\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-white/70\">{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-[#1e1e1e] bg-[#111111] 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-white\"\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-white/40 transition hover:bg-[#252525] hover:text-white 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-white/40\", 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-[#2a2620] p-12 text-center\",\n className,\n )}\n >\n {icon && <div className=\"text-white/30\">{icon}</div>}\n {title && (\n <h3 className=\"text-sm font-semibold text-white\">{title}</h3>\n )}\n <p className=\"text-sm text-white/60\">{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 * purple text-title becomes 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 (white + bold); earlier crumbs are 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-white/40 transition hover:text-white\">\n {backInner}\n </span>,\n )\n ) : (\n <a\n href={back.href}\n className=\"inline-flex items-center gap-1 text-xs text-white/40 transition hover:text-white\"\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-[#aa89b7]\",\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-white/40\">{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-[#1e1e1e] bg-[#2a2620] text-white/30 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-white/30\"\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-white/45 transition hover:text-white\"\n >\n {crumb.label}\n </button>\n ) : (\n <span\n className={cn(\n isLast ? \"text-white\" : \"text-white/45\",\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).\n */\n logoSrc: string;\n /** Alt text for the logo + label shown in the mobile top bar. */\n logoAlt: 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-[#1e1e1e] 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 have `bg-[#1e1e1e]`, 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 * The component renders raw hex values (#111111, #1e1e1e) so it works\n * even when the consumer skips the Tailwind preset. Apply the preset\n * + globals.css for token-based overrides.\n */\nexport function Sidebar({\n logoSrc,\n logoAlt,\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-[#1e1e1e] 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-[#1e1e1e] bg-[#111111] px-3 py-2 md:hidden\">\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n className=\"rounded-[0.25rem] p-1.5 text-white transition hover:bg-[#252525] 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-white\">{logoAlt}</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-[#1e1e1e] bg-[#111111] 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-[#1e1e1e] 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=\"h-36 w-36 rounded-[1rem] object-contain\"\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-white/40 transition hover:bg-[#252525] hover:text-white 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-[#1e1e1e] 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-[#1e1e1e] 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-[#1e1e1e] px-4 py-3 text-center text-xs text-white/20\">\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 href?: string;\n render?: (\n props: { href: string; className: string },\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-[#ff9900] text-white\"\n : \"text-white/45 hover:bg-white/5 hover:text-white\",\n );\n\n/**\n * Single nav row for the Sidebar's `nav` / `lower` slots. Matches the\n * doghouse-canonical style: active = orange bg + white text, inactive =\n * white/45 text + white/5 hover bg.\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 inner = (\n <>\n {icon}\n <span>{label}</span>\n </>\n );\n\n if (href && render) return <>{render({ href, className }, inner)}</>;\n if (href) {\n return (\n <a href={href} className={className}>\n {inner}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n onClick={onClick}\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, color\n * is the same tertiary-text grey across the app.\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-white/40\",\n className,\n )}\n >\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n {label}\n </p>\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 CSS variable names used by `@jdcpuwiz/homelab-ui`'s Tailwind preset.\n *\n * Why constants and not re-exported loaders: Next.js's `next/font/google`\n * is a build-time directive scanned out of your *app* source by the SWC\n * plugin. The loader call MUST literally appear as `const X = Font(...)`\n * in your `app/layout.tsx`; pre-bundling it through a library turns\n * `const` into `var` and Next refuses to compile it. So the canonical\n * setup is \"paste this snippet into layout.tsx\" (see README) — and these\n * constants document the expected variable names so you wire `--font-*`\n * correctly without guessing.\n *\n * Snippet to paste into `app/layout.tsx`:\n *\n * import { Geist, Geist_Mono, Orbitron } from \"next/font/google\";\n *\n * const geistSans = Geist({ variable: \"--font-geist-sans\", subsets: [\"latin\"] });\n * const geistMono = Geist_Mono({ variable: \"--font-geist-mono\", subsets: [\"latin\"] });\n * const orbitron = Orbitron({ variable: \"--font-orbitron\", subsets: [\"latin\"] });\n *\n * <body className={`${geistSans.variable} ${geistMono.variable} ${orbitron.variable} antialiased`}>\n *\n * Sanity check after deploy:\n * getComputedStyle(document.body).fontFamily.startsWith('\"Geist\"')\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--font-geist-sans\",\n mono: \"--font-geist-mono\",\n display: \"--font-orbitron\",\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"]}
package/dist/index.d.cts CHANGED
@@ -167,6 +167,20 @@ type SidebarProps = {
167
167
  onMobileOpenChange?: (open: boolean) => void;
168
168
  /** Extra classes on the outer <aside>. */
169
169
  className?: string;
170
+ /**
171
+ * Override the className used for the `middle` slot's wrapper.
172
+ *
173
+ * The default — `"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[#1e1e1e] p-2"` —
174
+ * is correct for asset-den's "single panel with a scrollable folder
175
+ * tree inside." Override when the middle slot's content is itself a
176
+ * stack of cards (e.g. doghouse's per-service status widgets) — those
177
+ * already have `bg-[#1e1e1e]`, so wrapping them in another same-color
178
+ * panel makes them disappear.
179
+ *
180
+ * Pass the whole class string when overriding — your value replaces
181
+ * the default (no merge).
182
+ */
183
+ middleClassName?: string;
170
184
  /**
171
185
  * When true, render a built-in mobile top bar (logo + hamburger) on
172
186
  * screens < md. Defaults to true. Disable when the consumer renders
@@ -179,7 +193,7 @@ type SidebarProps = {
179
193
  * this is the extracted version every other project consumes.
180
194
  *
181
195
  * Dimensions are non-negotiable:
182
- * - md+ width: 15rem (`w-60` / `w-sidebar`)
196
+ * - md+ width: 15rem default (override per-project via `:root { --hl-sidebar-width: 18rem; }`)
183
197
  * - md+ position: fixed-flex in a `display:flex` parent
184
198
  * - mobile: off-canvas drawer at full sidebar width
185
199
  * - logo block: w-36 h-36 centered, px-4 pt-6 pb-5, border-b-2
@@ -188,7 +202,7 @@ type SidebarProps = {
188
202
  * even when the consumer skips the Tailwind preset. Apply the preset
189
203
  * + globals.css for token-based overrides.
190
204
  */
191
- declare function Sidebar({ logoSrc, logoAlt, widgets, nav, middle, lower, admin, footer, mobileOpen: controlledOpen, onMobileOpenChange, className, showMobileTopBar, }: SidebarProps): react.JSX.Element;
205
+ declare function Sidebar({ logoSrc, logoAlt, widgets, nav, middle, lower, admin, footer, mobileOpen: controlledOpen, onMobileOpenChange, className, middleClassName, showMobileTopBar, }: SidebarProps): react.JSX.Element;
192
206
 
193
207
  type SidebarNavItemProps = {
194
208
  label: string;
package/dist/index.d.ts CHANGED
@@ -167,6 +167,20 @@ type SidebarProps = {
167
167
  onMobileOpenChange?: (open: boolean) => void;
168
168
  /** Extra classes on the outer <aside>. */
169
169
  className?: string;
170
+ /**
171
+ * Override the className used for the `middle` slot's wrapper.
172
+ *
173
+ * The default — `"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[#1e1e1e] p-2"` —
174
+ * is correct for asset-den's "single panel with a scrollable folder
175
+ * tree inside." Override when the middle slot's content is itself a
176
+ * stack of cards (e.g. doghouse's per-service status widgets) — those
177
+ * already have `bg-[#1e1e1e]`, so wrapping them in another same-color
178
+ * panel makes them disappear.
179
+ *
180
+ * Pass the whole class string when overriding — your value replaces
181
+ * the default (no merge).
182
+ */
183
+ middleClassName?: string;
170
184
  /**
171
185
  * When true, render a built-in mobile top bar (logo + hamburger) on
172
186
  * screens < md. Defaults to true. Disable when the consumer renders
@@ -179,7 +193,7 @@ type SidebarProps = {
179
193
  * this is the extracted version every other project consumes.
180
194
  *
181
195
  * Dimensions are non-negotiable:
182
- * - md+ width: 15rem (`w-60` / `w-sidebar`)
196
+ * - md+ width: 15rem default (override per-project via `:root { --hl-sidebar-width: 18rem; }`)
183
197
  * - md+ position: fixed-flex in a `display:flex` parent
184
198
  * - mobile: off-canvas drawer at full sidebar width
185
199
  * - logo block: w-36 h-36 centered, px-4 pt-6 pb-5, border-b-2
@@ -188,7 +202,7 @@ type SidebarProps = {
188
202
  * even when the consumer skips the Tailwind preset. Apply the preset
189
203
  * + globals.css for token-based overrides.
190
204
  */
191
- declare function Sidebar({ logoSrc, logoAlt, widgets, nav, middle, lower, admin, footer, mobileOpen: controlledOpen, onMobileOpenChange, className, showMobileTopBar, }: SidebarProps): react.JSX.Element;
205
+ declare function Sidebar({ logoSrc, logoAlt, widgets, nav, middle, lower, admin, footer, mobileOpen: controlledOpen, onMobileOpenChange, className, middleClassName, showMobileTopBar, }: SidebarProps): react.JSX.Element;
192
206
 
193
207
  type SidebarNavItemProps = {
194
208
  label: string;
package/dist/index.js CHANGED
@@ -366,6 +366,7 @@ function Sidebar({
366
366
  mobileOpen: controlledOpen,
367
367
  onMobileOpenChange,
368
368
  className,
369
+ middleClassName = "mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[#1e1e1e] p-2",
369
370
  showMobileTopBar = true
370
371
  }) {
371
372
  const [internalOpen, setInternalOpen] = useState2(false);
@@ -411,7 +412,7 @@ function Sidebar({
411
412
  "aside",
412
413
  {
413
414
  className: cn(
414
- "fixed inset-y-0 left-0 z-50 flex w-60 shrink-0 flex-col border-r-2 border-[#1e1e1e] bg-[#111111] transition-transform md:static md:translate-x-0 md:h-screen",
415
+ "fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,15rem)] shrink-0 flex-col border-r-2 border-[#1e1e1e] bg-[#111111] transition-transform md:static md:translate-x-0 md:h-screen",
415
416
  open ? "translate-x-0" : "-translate-x-full md:translate-x-0",
416
417
  className
417
418
  ),
@@ -438,7 +439,7 @@ function Sidebar({
438
439
  ] }),
439
440
  widgets && /* @__PURE__ */ jsx6("div", { className: "mx-3 mt-3", children: widgets }),
440
441
  nav && /* @__PURE__ */ jsx6("nav", { className: "mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[#1e1e1e] p-2", children: nav }),
441
- middle && /* @__PURE__ */ jsx6("div", { className: "mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[#1e1e1e] p-2", children: middle }),
442
+ middle && /* @__PURE__ */ jsx6("div", { className: middleClassName, children: middle }),
442
443
  lower && /* @__PURE__ */ jsx6("nav", { className: "mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[#1e1e1e] p-2", children: lower }),
443
444
  !middle && /* @__PURE__ */ jsx6("div", { className: "flex-1" }),
444
445
  admin && /* @__PURE__ */ jsx6("div", { children: admin }),
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/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-[#ffb84d]\";\n\nconst VARIANT: Record<Variant, string> = {\n primary: \"bg-[#ff9900] text-[#0a0a0a] hover:bg-[#cc6a1a]\",\n secondary: \"bg-[#1e1e1e] text-white hover:bg-[#252525]\",\n tertiary: \"text-white/60 hover:bg-[#252525] hover:text-white\",\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-white/70\">{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-[#1e1e1e] bg-[#111111] 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-white\"\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-white/40 transition hover:bg-[#252525] hover:text-white 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-white/40\", 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-[#2a2620] p-12 text-center\",\n className,\n )}\n >\n {icon && <div className=\"text-white/30\">{icon}</div>}\n {title && (\n <h3 className=\"text-sm font-semibold text-white\">{title}</h3>\n )}\n <p className=\"text-sm text-white/60\">{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 * purple text-title becomes 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 (white + bold); earlier crumbs are 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-white/40 transition hover:text-white\">\n {backInner}\n </span>,\n )\n ) : (\n <a\n href={back.href}\n className=\"inline-flex items-center gap-1 text-xs text-white/40 transition hover:text-white\"\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-[#aa89b7]\",\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-white/40\">{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-[#1e1e1e] bg-[#2a2620] text-white/30 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-white/30\"\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-white/45 transition hover:text-white\"\n >\n {crumb.label}\n </button>\n ) : (\n <span\n className={cn(\n isLast ? \"text-white\" : \"text-white/45\",\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).\n */\n logoSrc: string;\n /** Alt text for the logo + label shown in the mobile top bar. */\n logoAlt: 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 * 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 (`w-60` / `w-sidebar`)\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 * The component renders raw hex values (#111111, #1e1e1e) so it works\n * even when the consumer skips the Tailwind preset. Apply the preset\n * + globals.css for token-based overrides.\n */\nexport function Sidebar({\n logoSrc,\n logoAlt,\n widgets,\n nav,\n middle,\n lower,\n admin,\n footer,\n mobileOpen: controlledOpen,\n onMobileOpenChange,\n className,\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-[#1e1e1e] bg-[#111111] px-3 py-2 md:hidden\">\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n className=\"rounded-[0.25rem] p-1.5 text-white transition hover:bg-[#252525] 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-white\">{logoAlt}</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-60 shrink-0 flex-col border-r-2 border-[#1e1e1e] bg-[#111111] 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-[#1e1e1e] 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=\"h-36 w-36 rounded-[1rem] object-contain\"\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-white/40 transition hover:bg-[#252525] hover:text-white 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-[#1e1e1e] p-2\">\n {nav}\n </nav>\n )}\n\n {middle && (\n <div className=\"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[#1e1e1e] p-2\">\n {middle}\n </div>\n )}\n\n {lower && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[#1e1e1e] 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-[#1e1e1e] px-4 py-3 text-center text-xs text-white/20\">\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 href?: string;\n render?: (\n props: { href: string; className: string },\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-[#ff9900] text-white\"\n : \"text-white/45 hover:bg-white/5 hover:text-white\",\n );\n\n/**\n * Single nav row for the Sidebar's `nav` / `lower` slots. Matches the\n * doghouse-canonical style: active = orange bg + white text, inactive =\n * white/45 text + white/5 hover bg.\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 inner = (\n <>\n {icon}\n <span>{label}</span>\n </>\n );\n\n if (href && render) return <>{render({ href, className }, inner)}</>;\n if (href) {\n return (\n <a href={href} className={className}>\n {inner}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n onClick={onClick}\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, color\n * is the same tertiary-text grey across the app.\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-white/40\",\n className,\n )}\n >\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n {label}\n </p>\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 CSS variable names used by `@jdcpuwiz/homelab-ui`'s Tailwind preset.\n *\n * Why constants and not re-exported loaders: Next.js's `next/font/google`\n * is a build-time directive scanned out of your *app* source by the SWC\n * plugin. The loader call MUST literally appear as `const X = Font(...)`\n * in your `app/layout.tsx`; pre-bundling it through a library turns\n * `const` into `var` and Next refuses to compile it. So the canonical\n * setup is \"paste this snippet into layout.tsx\" (see README) — and these\n * constants document the expected variable names so you wire `--font-*`\n * correctly without guessing.\n *\n * Snippet to paste into `app/layout.tsx`:\n *\n * import { Geist, Geist_Mono, Orbitron } from \"next/font/google\";\n *\n * const geistSans = Geist({ variable: \"--font-geist-sans\", subsets: [\"latin\"] });\n * const geistMono = Geist_Mono({ variable: \"--font-geist-mono\", subsets: [\"latin\"] });\n * const orbitron = Orbitron({ variable: \"--font-orbitron\", subsets: [\"latin\"] });\n *\n * <body className={`${geistSans.variable} ${geistMono.variable} ${orbitron.variable} antialiased`}>\n *\n * Sanity check after deploy:\n * getComputedStyle(document.body).fontFamily.startsWith('\"Geist\"')\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--font-geist-sans\",\n mono: \"--font-geist-mono\",\n display: \"--font-orbitron\",\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;;;AD8BM;AA1BN,IAAM,OACJ;AAEF,IAAM,UAAmC;AAAA,EACvC,SAAS;AAAA,EACT,WAAW;AAAA,EACX,UAAU;AAAA,EACV,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;;;AE5CA,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,6CAA6C,mBAAQ;AAAA;AAAA,EACpE;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,mCAAmC,SAAS,GAC1D,mBACH;AAAA,EAEJ;AACA,SACE,gBAAAC;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MAEC;AAAA,gBAAQ,gBAAAD,KAAC,SAAI,WAAU,iBAAiB,gBAAK;AAAA,QAC7C,SACC,gBAAAA,KAAC,QAAG,WAAU,oCAAoC,iBAAM;AAAA,QAE1D,gBAAAA,KAAC,OAAE,WAAU,yBAAyB,mBAAQ;AAAA,QAC7C;AAAA;AAAA;AAAA,EACH;AAEJ;;;AC7CA,SAAS,aAAAE,YAAW,gBAAgB;AACpC,SAAS,WAAW,cAAc,gBAAgB;AAwE9C,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,oFACb,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,8BAA8B,uBAAY;AAAA,aAE3D;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,SAAS,eAAe;AAAA,sBACxB,UAAU;AAAA,oBACZ;AAAA,oBACA,gBAAc,SAAS,SAAS;AAAA,oBAE/B,gBAAM;AAAA;AAAA,gBACT;AAAA;AAAA;AAAA,YA1BG,GAAG,MAAM,KAAK,IAAI,GAAG;AAAA,UA4B5B;AAAA,QAEJ,CAAC;AAAA;AAAA;AAAA,EACH;AAEJ;;;AC3NA,SAAS,aAAAG,YAAW,YAAAC,iBAAgB;AACpC,SAAS,MAAM,KAAAC,UAAS;AAkGpB,qBAAAC,WASQ,OAAAC,MAPJ,QAAAC,aAFJ;AAnCG,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ;AAAA,EACA;AAAA,EACA,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;AAGA,EAAAC,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,mHACb;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,oCAAoC,mBAAQ;AAAA,OAC3D;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,kFAEb;AAAA,4BAAAD;AAAA,cAAC;AAAA;AAAA,gBACC,KAAK;AAAA,gBACL,KAAK;AAAA,gBACL,WAAU;AAAA;AAAA,YACZ;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,4DACZ,eACH;AAAA,UAGD,UACC,gBAAAA,KAAC,SAAI,WAAU,uEACZ,kBACH;AAAA,UAGD,SACC,gBAAAA,KAAC,SAAI,WAAU,4DACZ,iBACH;AAAA,UAGD,CAAC,UAAU,gBAAAA,KAAC,SAAI,WAAU,UAAS;AAAA,UAEnC,SAAS,gBAAAA,KAAC,SAAK,iBAAM;AAAA,UAErB,UACC,gBAAAA,KAAC,SAAI,WAAU,2EACZ,kBACH;AAAA;AAAA;AAAA,IAEJ;AAAA,KACF;AAEJ;;;ACnII,qBAAAK,WAEE,OAAAC,MAFF,QAAAC,aAAA;AA1BJ,IAAM,kBACJ;AAEF,IAAM,YAAY,CAAC,WACjB;AAAA,EACE;AAAA,EACA,SACI,4BACA;AACN;AAOK,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AAAA,EACA;AAAA,EACA;AACF,GAAwB;AACtB,QAAM,YAAY,UAAU,MAAM;AAClC,QAAM,QACJ,gBAAAA,MAAAF,WAAA,EACG;AAAA;AAAA,IACD,gBAAAC,KAAC,UAAM,iBAAM;AAAA,KACf;AAGF,MAAI,QAAQ,OAAQ,QAAO,gBAAAA,KAAAD,WAAA,EAAG,iBAAO,EAAE,MAAM,UAAU,GAAG,KAAK,GAAE;AACjE,MAAI,MAAM;AACR,WACE,gBAAAC,KAAC,OAAE,MAAY,WACZ,iBACH;AAAA,EAEJ;AACA,SACE,gBAAAA;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL;AAAA,MACA,gBAAc;AAAA,MACd,WAAW,GAAG,SAAS;AAAA,MAEtB;AAAA;AAAA,EACH;AAEJ;;;AC3EA,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;;;AChBO,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,YAAA;AAdH,SAAS,SAAS,EAAE,MAAM,OAAO,MAAM,QAAQ,GAAkB;AACtE,MAAI,KAAK,WAAW,EAAG,QAAO;AAC9B,QAAM,cACJ,SAAS,OAAO,8BAA8B;AAEhD,SACE,gBAAAA,KAAC,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,KAAC,UAAgB,WAAsB,OACpC,YAAE,QADM,EAAE,EAEb;AAAA,EAEJ,CAAC,GACH;AAEJ;;;AC7BO,IAAM,qBAAqB;AAAA,EAChC,MAAM;AAAA,EACN,MAAM;AAAA,EACN,SAAS;AACX;","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"]}
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/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-[#ffb84d]\";\n\nconst VARIANT: Record<Variant, string> = {\n primary: \"bg-[#ff9900] text-[#0a0a0a] hover:bg-[#cc6a1a]\",\n secondary: \"bg-[#1e1e1e] text-white hover:bg-[#252525]\",\n tertiary: \"text-white/60 hover:bg-[#252525] hover:text-white\",\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-white/70\">{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-[#1e1e1e] bg-[#111111] 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-white\"\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-white/40 transition hover:bg-[#252525] hover:text-white 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-white/40\", 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-[#2a2620] p-12 text-center\",\n className,\n )}\n >\n {icon && <div className=\"text-white/30\">{icon}</div>}\n {title && (\n <h3 className=\"text-sm font-semibold text-white\">{title}</h3>\n )}\n <p className=\"text-sm text-white/60\">{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 * purple text-title becomes 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 (white + bold); earlier crumbs are 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-white/40 transition hover:text-white\">\n {backInner}\n </span>,\n )\n ) : (\n <a\n href={back.href}\n className=\"inline-flex items-center gap-1 text-xs text-white/40 transition hover:text-white\"\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-[#aa89b7]\",\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-white/40\">{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-[#1e1e1e] bg-[#2a2620] text-white/30 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-white/30\"\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-white/45 transition hover:text-white\"\n >\n {crumb.label}\n </button>\n ) : (\n <span\n className={cn(\n isLast ? \"text-white\" : \"text-white/45\",\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).\n */\n logoSrc: string;\n /** Alt text for the logo + label shown in the mobile top bar. */\n logoAlt: 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-[#1e1e1e] 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 have `bg-[#1e1e1e]`, 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 * The component renders raw hex values (#111111, #1e1e1e) so it works\n * even when the consumer skips the Tailwind preset. Apply the preset\n * + globals.css for token-based overrides.\n */\nexport function Sidebar({\n logoSrc,\n logoAlt,\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-[#1e1e1e] 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-[#1e1e1e] bg-[#111111] px-3 py-2 md:hidden\">\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n className=\"rounded-[0.25rem] p-1.5 text-white transition hover:bg-[#252525] 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-white\">{logoAlt}</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-[#1e1e1e] bg-[#111111] 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-[#1e1e1e] 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=\"h-36 w-36 rounded-[1rem] object-contain\"\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-white/40 transition hover:bg-[#252525] hover:text-white 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-[#1e1e1e] 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-[#1e1e1e] 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-[#1e1e1e] px-4 py-3 text-center text-xs text-white/20\">\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 href?: string;\n render?: (\n props: { href: string; className: string },\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-[#ff9900] text-white\"\n : \"text-white/45 hover:bg-white/5 hover:text-white\",\n );\n\n/**\n * Single nav row for the Sidebar's `nav` / `lower` slots. Matches the\n * doghouse-canonical style: active = orange bg + white text, inactive =\n * white/45 text + white/5 hover bg.\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 inner = (\n <>\n {icon}\n <span>{label}</span>\n </>\n );\n\n if (href && render) return <>{render({ href, className }, inner)}</>;\n if (href) {\n return (\n <a href={href} className={className}>\n {inner}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n onClick={onClick}\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, color\n * is the same tertiary-text grey across the app.\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-white/40\",\n className,\n )}\n >\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n {label}\n </p>\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 CSS variable names used by `@jdcpuwiz/homelab-ui`'s Tailwind preset.\n *\n * Why constants and not re-exported loaders: Next.js's `next/font/google`\n * is a build-time directive scanned out of your *app* source by the SWC\n * plugin. The loader call MUST literally appear as `const X = Font(...)`\n * in your `app/layout.tsx`; pre-bundling it through a library turns\n * `const` into `var` and Next refuses to compile it. So the canonical\n * setup is \"paste this snippet into layout.tsx\" (see README) — and these\n * constants document the expected variable names so you wire `--font-*`\n * correctly without guessing.\n *\n * Snippet to paste into `app/layout.tsx`:\n *\n * import { Geist, Geist_Mono, Orbitron } from \"next/font/google\";\n *\n * const geistSans = Geist({ variable: \"--font-geist-sans\", subsets: [\"latin\"] });\n * const geistMono = Geist_Mono({ variable: \"--font-geist-mono\", subsets: [\"latin\"] });\n * const orbitron = Orbitron({ variable: \"--font-orbitron\", subsets: [\"latin\"] });\n *\n * <body className={`${geistSans.variable} ${geistMono.variable} ${orbitron.variable} antialiased`}>\n *\n * Sanity check after deploy:\n * getComputedStyle(document.body).fontFamily.startsWith('\"Geist\"')\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--font-geist-sans\",\n mono: \"--font-geist-mono\",\n display: \"--font-orbitron\",\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"]}
package/globals.css CHANGED
@@ -38,6 +38,10 @@
38
38
 
39
39
  /* Page-title purple (doghouse convention — h1 only) */
40
40
  --hl-title: #aa89b7;
41
+
42
+ /* Sidebar shell width — md+ flex column. Override per project, e.g.
43
+ `:root { --hl-sidebar-width: 18rem; }` in your own globals.css. */
44
+ --hl-sidebar-width: 15rem;
41
45
  }
42
46
 
43
47
  html {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jdcpuwiz/homelab-ui",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
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 next/font wiring.",
5
5
  "license": "MIT",
6
6
  "author": "Wiz <shad@deckerzoo.com>",
@@ -108,7 +108,7 @@ module.exports = {
108
108
  panel: "1rem",
109
109
  },
110
110
  spacing: {
111
- sidebar: "15rem",
111
+ sidebar: "var(--hl-sidebar-width, 15rem)",
112
112
  logo: "9rem",
113
113
  },
114
114
  ringColor: {