@jdcpuwiz/homelab-ui 0.10.6 → 0.11.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
@@ -244,11 +244,80 @@ a screen reader (or a colorblind user) gets for "you are here". Two traps:
244
244
  destructure silently drops `aria-current` (and any field added later) even
245
245
  though `active` and the styling still look correct.
246
246
 
247
+ ## The mobile top bar reserves its own height (0.10.6, BUG #339)
248
+
249
+ Under `md` the Sidebar renders a `fixed inset-x-0 top-0` bar (logo +
250
+ hamburger). Until 0.10.6 nothing reserved its height, so every consumer's
251
+ `<main>` started at y=0 and each app's page `<h1>` sat half behind it at phone
252
+ widths — fleet-wide.
253
+
254
+ It cannot reserve space with a spacer element: the Sidebar's siblings land
255
+ inside the consumer's `display:flex` ROW (`<Sidebar/><main/>`), where a block
256
+ becomes a zero-width flex column beside `<main>` and pushes nothing down. So:
257
+
258
+ - `globals.css` defines **`--hl-mobile-topbar-height`** (3.25rem) and pads the
259
+ body by it under 768px. The bar sets `min-h-[var(--hl-mobile-topbar-height)]`
260
+ from the SAME token, so bar and reservation cannot drift.
261
+ - The rule is gated on **`html.hl-has-mobile-topbar`**, which the Sidebar adds
262
+ only while it is actually rendering the bar. Pass `showMobileTopBar={false}`
263
+ and you get no bar and no padding — nothing changes for an app that draws its
264
+ own top chrome.
265
+ - **If your shell clamps to the viewport** (`h-screen overflow-hidden`), also
266
+ subtract the bar under `md` or the body gains a bar's worth of scroll and the
267
+ title can be dragged back under it:
268
+ `className="flex h-[calc(100dvh-var(--hl-mobile-topbar-height,0px))] overflow-hidden md:h-screen"`.
269
+ Document-flow shells need nothing. Keep the `0px` fallback — it is what makes
270
+ the class correct in an app that has not loaded `globals.css`.
271
+
272
+ Re-skin the bar's height by overriding the token at `:root`, like any other.
273
+
274
+ ### Consumers below 0.10.x, and the two left there on purpose (C641)
275
+
276
+ A caret range on a **0.x** package can never resolve a new minor — npm treats
277
+ every 0.x minor as breaking — so a fix here reaches nobody until each app's
278
+ range is edited by hand. After the 0.10.6 sweep three apps were still stranded:
279
+
280
+ - **routes/frontend** was on `^0.4.1` and was the only one still showing the
281
+ overlap. Now on `^0.10.6`.
282
+ - **timesheet/packages/frontend** and **wiz3dtools/packages/frontend** are on
283
+ `^0.7.1` and are **deliberately left there** (Wiz's call, 2026-08-29). Both
284
+ pass `showMobileTopBar={false}` and draw their own top chrome, so the 0.10.6
285
+ reservation is gated off for them — they gain *nothing* from the upgrade
286
+ while crossing 0.8.0 (literal hex → `--hl-*` tokens) and 0.9.0 (the chrome
287
+ token routing), which is all of the visual risk. Revisit only when one of
288
+ them actually needs something from a later version.
289
+
290
+ **If you do move one of them, the token routing is the trap, not the fonts.**
291
+ From 0.8.0 the Sidebar draws every colour from `var(--hl-*)`, so an app that
292
+ does not import this package's `globals.css` renders it with no background,
293
+ border or foreground at all. Importing that stylesheet is not always the answer
294
+ either: it is an **unlayered** rule set that forces a dark `html`/`body` and a
295
+ 14px root font-size, which silently kills a light theme and rescales every
296
+ `rem`. Routes' way out was to declare the `--hl-*` tokens in its own
297
+ stylesheet, using the same values 0.4.1 had hardcoded — plus a copy of the
298
+ `html.hl-has-mobile-topbar` padding rule — so the chrome is unchanged and the
299
+ overlap is still fixed.
300
+
301
+ ### Sidebar width is 240px, not 15rem (0.11.0, B358)
302
+
303
+ Wiz ratified **240px** as the fleet sidebar-width standard on 2026-09-02.
304
+ `--hl-sidebar-width` used to default to `15rem`, which only equalled 240px at
305
+ a 16px root — this package's own `globals.css` sets a 14px root, so its own
306
+ Ladle preview (and any consumer that inherited 14px) actually rendered a
307
+ 210px sidebar. The default is now a **fixed pixel value, `240px`**, so it can
308
+ never drift with an app's root font-size again. Per-project overrides
309
+ (`:root { --hl-sidebar-width: 18rem; }`) still work exactly as before.
310
+
311
+ **Same caret-range trap as above: consumers pinned `^0.10.x` will NOT pick up
312
+ 0.11.0 on their own.** A `^0.10.x` range only resolves 0.10.x releases; moving
313
+ to 240px reaches nobody until each app's `package.json` range is bumped by
314
+ hand and reinstalled. The fleet-wide consumer sweep is tracked as C685.
315
+
247
316
  ## Components
248
317
 
249
318
  | Component | Purpose |
250
319
  |-------------------|---------|
251
- | `Sidebar` | Doghouse-canonical shell. Slots: `widgets`, `nav`, `middle`, `lower`, `admin`, `footer`. w-60 fixed md+, off-canvas drawer on mobile (built-in hamburger + close X). |
320
+ | `Sidebar` | Doghouse-canonical shell. Slots: `widgets`, `nav`, `middle`, `lower`, `admin`, `footer`. 240px fixed md+, off-canvas drawer on mobile (built-in hamburger + close X). The mobile top bar reserves its own height — see below. |
252
321
  | `SidebarNavItem` | Active = `bg-brand` + `text-brand-fg` + `aria-current="page"`; inactive = `text-foreground-muted` lifting to full foreground over a `bg-overlay-hover` wash. Supports `href` (auto `<a>`) or `render` (next/link wrapper — spread its props, don't destructure). |
253
322
  | `Button` | `variant` × `size`. Variants: `primary` (brand orange), `secondary` (card), `tertiary` (ghost), `danger` (red). Sizes: `sm`, `md`. |
254
323
  | `Modal` | Overlay + click-outside + Esc + role=dialog + aria-* wiring + footer slot. `density: "form" \| "media"`. |
@@ -321,7 +390,7 @@ The Tailwind preset exposes the canonical homelab palette:
321
390
  | `text-title` (= flat `#f0f1f4`) | h1 title color — **not a special hue** (BP #57 removed the page-title purple); resolves to the flat foreground |
322
391
  | `text-ink-primary` `text-ink-secondary` `text-ink-tertiary` | Reading text — all resolve to the one flat `#f0f1f4` (the old opacity ramp is dead; kept as back-compat aliases). `text-ink-disabled` stays a genuine dim for disabled affordances. |
323
392
  | `rounded-widget` (xl), `rounded-row` (lg), `rounded-chip`, `rounded-panel` (2xl) | Radius aliases |
324
- | `w-sidebar` (default 15rem), `w-logo` `h-logo` (9rem) | Layout sizes (sidebar width is `var(--hl-sidebar-width)` — override at `:root`) |
393
+ | `w-sidebar` (default 240px), `w-logo` `h-logo` (9rem) | Layout sizes (sidebar width is `var(--hl-sidebar-width)`, a fixed-px value independent of root font-size — override at `:root`) |
325
394
  | `font-sans` `font-mono` `font-display` | The three sanctioned faces (BP #57) — Poppins / JetBrains Mono / Orbitron. Each app self-hosts the faces and binds them to `--hl-font-*` (see Typography + `starter/`). |
326
395
  | `text-2xs` (10px / 14px) | Sidebar footers, grid captions |
327
396
 
@@ -418,6 +487,27 @@ npm run typecheck # tsc --noEmit
418
487
  npm run ladle # reference renders at http://localhost:61000
419
488
  ```
420
489
 
490
+ ### The ladle script pins `NODE_ENV=development` on purpose
491
+
492
+ Don't drop that prefix, and don't start the preview with a bare
493
+ `npx ladle serve` from a shell that exports `NODE_ENV=production`.
494
+
495
+ React 19 ships a *production* copy of `react/jsx-dev-runtime` whose only
496
+ job is to export `jsxDEV` as `undefined` (`exports.jsxDEV = void 0`).
497
+ Vite honours an ambient `NODE_ENV` when it pre-bundles dependencies, so a
498
+ production value makes it bake that stub into `node_modules/.vite/deps`
499
+ — while the SWC transform keeps emitting dev-runtime calls. Every story
500
+ then dies on `TypeError: _jsxDEV is not a function` and nothing mounts,
501
+ even though the server starts cleanly and serves the shell. The failure
502
+ looks like a broken story, not a broken environment (BUG #342).
503
+
504
+ If you ever see that error, delete `node_modules/.vite` and confirm the
505
+ regenerated `deps/react_jsx-dev-runtime.js` inlines
506
+ `react-jsx-dev-runtime.development.js`, not `.production.js`.
507
+
508
+ `npm run ladle:build` is unaffected — a production build wants the
509
+ production runtime and never calls `jsxDEV`.
510
+
421
511
  ## License
422
512
 
423
513
  MIT
package/dist/index.cjs CHANGED
@@ -459,7 +459,7 @@ function Sidebar({
459
459
  "aside",
460
460
  {
461
461
  className: cn(
462
- "fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,15rem)] shrink-0 flex-col border-r-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] transition-transform md:static md:translate-x-0 md:h-screen",
462
+ "fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,240px)] shrink-0 flex-col border-r-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] transition-transform md:static md:translate-x-0 md:h-screen",
463
463
  open ? "translate-x-0" : "-translate-x-full md:translate-x-0",
464
464
  className
465
465
  ),
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/components/Button.tsx","../src/lib/utils.ts","../src/components/ConfirmDialog.tsx","../src/components/Modal.tsx","../src/components/EmptyState.tsx","../src/components/PageHeader.tsx","../src/components/Sidebar.tsx","../src/components/SidebarNavItem.tsx","../src/components/Spinner.tsx","../src/components/StatTile.tsx","../src/lib/colors.ts","../src/components/TagChips.tsx","../src/lib/fonts.ts"],"sourcesContent":["// Components\nexport { Button, type ButtonProps } from \"./components/Button\";\nexport {\n ConfirmDialog,\n type ConfirmDialogProps,\n} from \"./components/ConfirmDialog\";\nexport { EmptyState, type EmptyStateProps } from \"./components/EmptyState\";\nexport { Modal, type ModalProps } from \"./components/Modal\";\nexport {\n PageHeader,\n type PageHeaderProps,\n type Crumb,\n} from \"./components/PageHeader\";\nexport { Sidebar, type SidebarProps } from \"./components/Sidebar\";\nexport {\n SidebarNavItem,\n type SidebarNavItemProps,\n} from \"./components/SidebarNavItem\";\nexport { Spinner, type SpinnerProps } from \"./components/Spinner\";\nexport {\n StatTile,\n StatTileRow,\n type StatTileProps,\n type StatTileRowProps,\n} from \"./components/StatTile\";\nexport {\n TagChips,\n type TagChipsProps,\n type TagRef,\n} from \"./components/TagChips\";\n\n// Helpers\nexport { cn } from \"./lib/utils\";\nexport { tagTextColor } from \"./lib/colors\";\nexport { FONT_CSS_VARIABLES, FONT_APP_VARIABLES } from \"./lib/fonts\";\n","\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { cn } from \"../lib/utils\";\n\ntype Variant = \"primary\" | \"secondary\" | \"tertiary\" | \"danger\";\ntype Size = \"sm\" | \"md\";\n\nexport type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {\n variant?: Variant;\n size?: Size;\n};\n\nconst BASE =\n \"inline-flex items-center justify-center gap-1.5 rounded-[0.5rem] font-semibold transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--hl-brand-glow)]\";\n\n// `danger`'s BACKGROUND stays a literal — status colors are a fixed fleet\n// semantic (the preset hardcodes them too), not a per-project brand token.\n// Its label stays literal `text-white` for the same reason: that is text\n// ON a status chip, which lib/colors.ts already rules is always white,\n// NOT reading text on an app surface. Everything else here is a token.\nconst VARIANT: Record<Variant, string> = {\n primary:\n \"bg-[var(--hl-brand)] text-[var(--hl-brand-fg)] hover:bg-[var(--hl-brand-hover)]\",\n secondary:\n \"bg-[var(--hl-card)] text-[var(--hl-foreground)] hover:bg-[var(--hl-card-hover)]\",\n tertiary:\n \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)]\",\n danger: \"bg-[#b91c1c] text-white hover:brightness-110\",\n};\n\nconst SIZE: Record<Size, string> = {\n sm: \"px-3 py-1 text-xs\",\n md: \"px-4 py-1.5 text-sm\",\n};\n\n/**\n * Homelab Button. Use the variant + size props; never pass raw color\n * classes through className. className is for layout extras only\n * (e.g. `w-full`).\n */\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n function Button(\n { variant = \"secondary\", size = \"md\", className, ...rest },\n ref,\n ) {\n return (\n <button\n ref={ref}\n {...rest}\n className={cn(BASE, VARIANT[variant], SIZE[size], className)}\n />\n );\n },\n);\n","import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Conditional Tailwind class joiner. Merges variants and resolves\n * conflicts (e.g. `cn(\"p-2\", \"p-4\")` → `\"p-4\"`).\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","\"use client\";\n\nimport { useEffect } from \"react\";\nimport { Modal } from \"./Modal\";\nimport { Button } from \"./Button\";\n\nexport type ConfirmDialogProps = {\n open: boolean;\n title: string;\n message: string;\n confirmLabel?: string;\n cancelLabel?: string;\n tone?: \"danger\" | \"primary\";\n onConfirm: () => void;\n onClose: () => void;\n};\n\n/**\n * Confirm/cancel dialog built on Modal + Button. Enter triggers confirm,\n * Esc cancels (via Modal's built-in handling).\n */\nexport function ConfirmDialog({\n open,\n title,\n message,\n confirmLabel = \"Confirm\",\n cancelLabel = \"Cancel\",\n tone = \"danger\",\n onConfirm,\n onClose,\n}: ConfirmDialogProps) {\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Enter\") onConfirm();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onConfirm]);\n\n return (\n <Modal\n open={open}\n onClose={onClose}\n title={title}\n footer={\n <>\n <Button variant=\"tertiary\" size=\"md\" onClick={onClose}>\n {cancelLabel}\n </Button>\n <Button\n variant={tone === \"danger\" ? \"danger\" : \"primary\"}\n size=\"md\"\n onClick={onConfirm}\n >\n {confirmLabel}\n </Button>\n </>\n }\n >\n <p className=\"whitespace-pre-line text-sm text-[var(--hl-foreground)]\">{message}</p>\n </Modal>\n );\n}\n","\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\ntype Density = \"form\" | \"media\";\n\nexport type ModalProps = {\n open: boolean;\n onClose: () => void;\n title?: string;\n /** Show the close X in the top right. Default: true when title is set. */\n showClose?: boolean;\n /**\n * Overlay weight:\n * `form` — dialogs / pickers / confirms (`bg-black/70`)\n * `media` — lightbox / asset preview (`bg-black/90`)\n * Defaults to `form`.\n */\n density?: Density;\n /** Max width — default `max-w-md`. */\n maxWidth?: string;\n /** Footer slot — render buttons here. Always right-aligned. */\n footer?: React.ReactNode;\n children: React.ReactNode;\n};\n\n/**\n * Single modal frame. Every dialog routes through this:\n * - overlay + click-outside dismiss\n * - Esc-to-close\n * - role=dialog + aria-modal + aria-labelledby wiring\n * - DESIGN.md header treatment (uppercase tracking-widest)\n * - shared right-aligned footer row\n */\nexport function Modal({\n open,\n onClose,\n title,\n showClose,\n density = \"form\",\n maxWidth = \"max-w-md\",\n footer,\n children,\n}: ModalProps) {\n const titleIdRef = useRef(\n `hl-modal-${Math.random().toString(36).slice(2, 8)}`,\n );\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") onClose();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onClose]);\n\n if (!open) return null;\n\n const showCloseBtn = showClose ?? Boolean(title);\n const overlayClass = density === \"media\" ? \"bg-black/90\" : \"bg-black/70\";\n\n return (\n <div\n className={cn(\n \"fixed inset-0 z-50 flex items-center justify-center p-4\",\n overlayClass,\n )}\n onClick={onClose}\n role=\"presentation\"\n >\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={title ? titleIdRef.current : undefined}\n className={cn(\n \"w-full rounded-[1rem] border-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] p-5\",\n maxWidth,\n )}\n onClick={(e) => e.stopPropagation()}\n >\n {(title || showCloseBtn) && (\n <div className=\"mb-4 flex items-start justify-between gap-3\">\n {title && (\n <h2\n id={titleIdRef.current}\n className=\"text-sm font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\"\n >\n {title}\n </h2>\n )}\n {showCloseBtn && (\n <button\n type=\"button\"\n onClick={onClose}\n className=\"ml-auto rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] active:scale-90\"\n aria-label=\"Close\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n )}\n {children}\n {footer && (\n <div className=\"mt-5 flex flex-wrap items-center justify-end gap-2\">\n {footer}\n </div>\n )}\n </div>\n </div>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type EmptyStateProps = {\n icon?: React.ReactNode;\n title?: string;\n message: string;\n action?: React.ReactNode;\n /**\n * `panel` — large empty state with warm card background (page bodies).\n * `inline` — compact text-only line for sidebar tree, popovers, etc.\n */\n variant?: \"panel\" | \"inline\";\n className?: string;\n};\n\nexport function EmptyState({\n icon,\n title,\n message,\n action,\n variant = \"panel\",\n className,\n}: EmptyStateProps) {\n if (variant === \"inline\") {\n return (\n <p className={cn(\"px-3 py-2 text-xs text-[var(--hl-foreground)]\", className)}>\n {message}\n </p>\n );\n }\n return (\n <div\n className={cn(\n \"flex flex-col items-center justify-center gap-3 rounded-[1rem] bg-[var(--hl-card-warm)] p-12 text-center\",\n className,\n )}\n >\n {icon && (\n <div className=\"text-[var(--hl-foreground-faint)]\">{icon}</div>\n )}\n {title && (\n <h3 className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {title}\n </h3>\n )}\n <p className=\"text-sm text-[var(--hl-foreground)]\">{message}</p>\n {action}\n </div>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { ArrowLeft, ChevronRight, ImageOff } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type Crumb = {\n label: string;\n onClick?: () => void;\n};\n\nexport type PageHeaderProps = {\n title: string;\n description?: string;\n /** Back link rendered above the title. */\n back?: { href: string; label: string; render?: (href: string, children: React.ReactNode) => React.ReactNode };\n /** Right-aligned actions slot. */\n actions?: React.ReactNode;\n /**\n * Header image URL (e.g. `/logos.png`). When the URL resolves, the\n * image is the visual title. When it 404s, a styled placeholder at\n * the same dimensions takes its place — the layout stays stable so\n * creating a new themed page \"just works\" the moment you drop a\n * matching PNG in /public/.\n *\n * When `imageSrc` is undefined, no image block renders and the\n * text title is the visual title.\n */\n imageSrc?: string;\n imageAlt?: string;\n /**\n * Breadcrumb trail. When provided, replaces the text title. Last\n * crumb is current (full foreground + bold); earlier crumbs are muted\n * and clickable.\n */\n breadcrumb?: Crumb[];\n className?: string;\n};\n\n/**\n * Page-header block. The image block reserves space whenever `imageSrc`\n * is set — drop the PNG in /public/ and the layout just fills in.\n *\n * Text title rules:\n * - imageSrc set + image present → text becomes sr-only\n * - imageSrc set + image 404 → placeholder block + text becomes sr-only\n * - imageSrc unset → text title is visible\n * - breadcrumb provided → breadcrumb replaces text title regardless of image\n *\n * The back link uses a plain <a> by default. In Next.js, pass\n * `back.render` to use next/link:\n *\n * import Link from \"next/link\";\n * <PageHeader back={{ href: \"/foo\", label: \"Back\", render: (href, c) => <Link href={href}>{c}</Link> }} />\n */\nexport function PageHeader({\n title,\n description,\n back,\n actions,\n imageSrc,\n imageAlt,\n breadcrumb,\n className,\n}: PageHeaderProps) {\n const [imageErrored, setImageErrored] = useState(false);\n useEffect(() => {\n setImageErrored(false);\n }, [imageSrc]);\n\n const hasImageSlot = !!imageSrc;\n const imageRenders = hasImageSlot && !imageErrored;\n const hasBreadcrumb = Array.isArray(breadcrumb) && breadcrumb.length > 0;\n const textIsSrOnly = hasImageSlot;\n\n const backInner = back ? (\n <>\n <ArrowLeft className=\"h-3 w-3\" />\n {back.label}\n </>\n ) : null;\n\n return (\n <header\n className={cn(\n \"flex flex-wrap items-end justify-between gap-3\",\n className,\n )}\n >\n <div className=\"flex min-w-0 items-end gap-4\">\n {hasImageSlot &&\n (imageRenders ? (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={imageSrc}\n alt={imageAlt ?? title}\n onError={() => setImageErrored(true)}\n className=\"h-28 w-28 shrink-0 rounded-[1rem] object-contain md:h-32 md:w-32\"\n />\n ) : (\n <PlaceholderHeader title={title} imageSrc={imageSrc!} />\n ))}\n <div className=\"min-w-0\">\n {back &&\n (back.render ? (\n back.render(\n back.href,\n <span className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\">\n {backInner}\n </span>,\n )\n ) : (\n <a\n href={back.href}\n className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {backInner}\n </a>\n ))}\n {hasBreadcrumb ? (\n <Breadcrumb\n crumbs={breadcrumb!}\n srTitle={title}\n className={back ? \"mt-1\" : \"\"}\n />\n ) : (\n <h1\n className={cn(\n \"text-xl font-semibold text-[var(--hl-title)]\",\n back && \"mt-1\",\n textIsSrOnly && \"sr-only\",\n )}\n >\n {title}\n </h1>\n )}\n {description && (\n <p className=\"mt-1 text-xs text-[var(--hl-foreground)]\">{description}</p>\n )}\n </div>\n </div>\n {actions && <div className=\"flex items-center gap-2\">{actions}</div>}\n </header>\n );\n}\n\nfunction PlaceholderHeader({\n title,\n imageSrc,\n}: {\n title: string;\n imageSrc: string;\n}) {\n const file = imageSrc.replace(/^\\//, \"\");\n return (\n <div\n title={`Drop ${file} in /public to brand this header`}\n className=\"flex h-28 w-28 shrink-0 flex-col items-center justify-center gap-1 rounded-[1rem] border-2 border-dashed border-[var(--hl-border)] bg-[var(--hl-card-warm)] text-[var(--hl-foreground-faint)] md:h-32 md:w-32\"\n >\n <ImageOff className=\"h-6 w-6\" />\n <span className=\"px-2 text-center text-[10px] font-semibold uppercase tracking-widest\">\n {title}\n </span>\n </div>\n );\n}\n\nfunction Breadcrumb({\n crumbs,\n srTitle,\n className,\n}: {\n crumbs: Crumb[];\n srTitle: string;\n className?: string;\n}) {\n return (\n <nav\n aria-label=\"Breadcrumb\"\n className={cn(\n \"flex flex-wrap items-center gap-1.5 text-xl font-semibold\",\n className,\n )}\n >\n <h1 className=\"sr-only\">{srTitle}</h1>\n {crumbs.map((crumb, idx) => {\n const isLast = idx === crumbs.length - 1;\n return (\n <span\n key={`${crumb.label}-${idx}`}\n className=\"flex items-center gap-1.5\"\n >\n {idx > 0 && (\n <ChevronRight\n className=\"h-4 w-4 shrink-0 text-[var(--hl-foreground-faint)]\"\n aria-hidden=\"true\"\n />\n )}\n {crumb.onClick && !isLast ? (\n <button\n type=\"button\"\n onClick={crumb.onClick}\n className=\"rounded px-1 text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {crumb.label}\n </button>\n ) : (\n <span\n className={cn(\n isLast\n ? \"text-[var(--hl-foreground)]\"\n : \"text-[var(--hl-foreground-muted)]\",\n isLast && \"px-1\",\n )}\n aria-current={isLast ? \"page\" : undefined}\n >\n {crumb.label}\n </span>\n )}\n </span>\n );\n })}\n </nav>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Menu, X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarProps = {\n /**\n * Logo image src — typically `/logo.png` or `/<project>-logo-256.png`.\n * Rendered at w-36 h-36 inside a centered block with px-4 pt-6 pb-5\n * + border-b-2 (doghouse-canonical spec). Override sizing/shape via\n * `logoClassName`.\n */\n logoSrc: string;\n /** Alt text for the logo + label shown in the mobile top bar. */\n logoAlt: string;\n /**\n * Override the className applied to the logo `<img>`. Default is\n * `\"h-36 w-36 rounded-[1rem] object-contain\"` (doghouse-canonical\n * square logo). Pass `\"h-auto w-full rounded-md object-contain\"` for\n * full-width banner-style logos, or any other Tailwind classes.\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n logoClassName?: string;\n /**\n * Slots — every section is optional. Render them in this order:\n * 1. Logo block (always)\n * 2. widgets — clock / weather / homepage rotator / etc\n * 3. nav — primary navigation\n * 4. middle — long-running content (folder tree, etc) — flex-1\n * 5. lower — secondary nav (manage-tags, settings link, etc)\n * 6. (spacer)\n * 7. admin — admin section\n * 8. footer — version stamp, etc\n */\n widgets?: React.ReactNode;\n nav?: React.ReactNode;\n middle?: React.ReactNode;\n lower?: React.ReactNode;\n admin?: React.ReactNode;\n footer?: React.ReactNode;\n /**\n * Mobile-drawer behavior. Controlled — the consumer owns the open\n * state so they can wire it from anywhere (top-bar hamburger,\n * keyboard shortcut, route change, etc.).\n *\n * When omitted, the Sidebar manages its own drawer state using the\n * built-in mobile top bar (hamburger renders top-left on screens < md).\n */\n mobileOpen?: boolean;\n onMobileOpenChange?: (open: boolean) => void;\n /** Extra classes on the outer <aside>. */\n className?: string;\n /**\n * Override the className used for the `middle` slot's wrapper.\n *\n * The default — `\"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\"` —\n * is correct for asset-den's \"single panel with a scrollable folder\n * tree inside.\" Override when the middle slot's content is itself a\n * stack of cards (e.g. doghouse's per-service status widgets) — those\n * already sit on the card token, so wrapping them in another same-color\n * panel makes them disappear.\n *\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n middleClassName?: string;\n /**\n * When true, render a built-in mobile top bar (logo + hamburger) on\n * screens < md. Defaults to true. Disable when the consumer renders\n * its own top chrome.\n */\n showMobileTopBar?: boolean;\n};\n\n/**\n * Canonical homelab sidebar shell. Doghouse is the reference impl;\n * this is the extracted version every other project consumes.\n *\n * Dimensions are non-negotiable:\n * - md+ width: 15rem default (override per-project via `:root { --hl-sidebar-width: 18rem; }`)\n * - md+ position: fixed-flex in a `display:flex` parent\n * - mobile: off-canvas drawer at full sidebar width\n * - logo block: w-36 h-36 centered, px-4 pt-6 pb-5, border-b-2\n *\n * Surfaces are `bg-[var(--hl-sidebar)]` / `bg-[var(--hl-card)]` /\n * `border-[var(--hl-border)]` — arbitrary-value classes that read the\n * tokens directly, so they still work when the consumer skips the\n * Tailwind preset AND re-skin correctly when the consumer overrides the\n * vars. (They were raw hex until 0.8.0, which silently pinned every\n * consumer to the fleet palette.) Import globals.css for the defaults.\n */\nexport function Sidebar({\n logoSrc,\n logoAlt,\n logoClassName = \"h-36 w-36 rounded-[1rem] object-contain\",\n widgets,\n nav,\n middle,\n lower,\n admin,\n footer,\n mobileOpen: controlledOpen,\n onMobileOpenChange,\n className,\n middleClassName = \"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\",\n showMobileTopBar = true,\n}: SidebarProps) {\n const [internalOpen, setInternalOpen] = useState(false);\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen : internalOpen;\n\n const setOpen = (next: boolean) => {\n if (!isControlled) setInternalOpen(next);\n onMobileOpenChange?.(next);\n };\n\n // Reserve the fixed mobile top bar's height in flow (BUG #339).\n //\n // The bar is `fixed`, so it sits OVER the consumer's content and every\n // app's page <h1> was half-hidden behind it on a phone. It cannot be a\n // spacer element rendered here: this component's siblings land inside the\n // consumer's `display:flex` row (`<Sidebar/><main/>`), where a block would\n // become a zero-width flex COLUMN beside <main> and push nothing down.\n //\n // So the reservation is a `body { padding-top }` rule in globals.css, gated\n // on this class — set only while a bar is actually rendered. Five fleet apps\n // pass `showMobileTopBar={false}` and draw their own top chrome; an ungated\n // rule would give each of them 44px of dead space at the top of every phone\n // view. The bar's own min-height and that padding read the SAME\n // `--hl-mobile-topbar-height` token, so the two can never drift.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!showMobileTopBar) return;\n const root = document.documentElement;\n root.classList.add(\"hl-has-mobile-topbar\");\n return () => root.classList.remove(\"hl-has-mobile-topbar\");\n }, [showMobileTopBar]);\n\n // Lock body scroll while the mobile drawer is open.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!open) return;\n const prev = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n return () => {\n document.body.style.overflow = prev;\n };\n }, [open]);\n\n return (\n <>\n {showMobileTopBar && (\n <div className=\"hl-mobile-topbar fixed inset-x-0 top-0 z-30 flex min-h-[var(--hl-mobile-topbar-height,3.25rem)] items-center gap-2 border-b-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] px-3 py-2 md:hidden\">\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n className=\"rounded-[0.25rem] p-1.5 text-[var(--hl-foreground)] transition hover:bg-[var(--hl-card-hover)] active:scale-90\"\n aria-label=\"Open menu\"\n >\n <Menu className=\"h-5 w-5\" />\n </button>\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={logoSrc} alt=\"\" width={24} height={24} />\n <p className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {logoAlt}\n </p>\n </div>\n )}\n\n {open && (\n <div\n className=\"fixed inset-0 z-40 bg-black/70 md:hidden\"\n onClick={() => setOpen(false)}\n role=\"presentation\"\n />\n )}\n\n <aside\n className={cn(\n \"fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,15rem)] shrink-0 flex-col border-r-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] transition-transform md:static md:translate-x-0 md:h-screen\",\n open ? \"translate-x-0\" : \"-translate-x-full md:translate-x-0\",\n className,\n )}\n >\n <div className=\"relative flex flex-col items-center border-b-2 border-[var(--hl-border)] px-4 pt-6 pb-5\">\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n src={logoSrc}\n alt={logoAlt}\n className={logoClassName}\n />\n {showMobileTopBar && (\n <button\n type=\"button\"\n onClick={() => setOpen(false)}\n className=\"absolute right-3 top-3 rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] md:hidden\"\n aria-label=\"Close menu\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n\n {widgets && <div className=\"mx-3 mt-3\">{widgets}</div>}\n\n {nav && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {nav}\n </nav>\n )}\n\n {middle && <div className={middleClassName}>{middle}</div>}\n\n {lower && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {lower}\n </nav>\n )}\n\n {!middle && <div className=\"flex-1\" />}\n\n {admin && <div>{admin}</div>}\n\n {footer && (\n <div className=\"border-t-2 border-[var(--hl-border)] px-4 py-3 text-center text-xs text-[var(--hl-foreground-faint)]\">\n {footer}\n </div>\n )}\n </aside>\n </>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarNavItemProps = {\n label: string;\n icon?: React.ReactNode;\n active?: boolean;\n onClick?: () => void;\n /**\n * When set, renders as <a href>. Otherwise renders as <button>. In\n * Next.js, prefer passing `render` so you can wrap in next/link\n * without losing classes:\n *\n * <SidebarNavItem\n * label=\"Home\"\n * href=\"/\"\n * render={(props, children) => <Link {...props}>{children}</Link>}\n * />\n *\n * Spread `{...props}` rather than destructuring individual fields\n * (`{ href, className }`) — the props object also carries `aria-current`\n * for the active item, and a narrow destructure silently drops it (and\n * any future field) even though the type still says it's there.\n */\n href?: string;\n render?: (\n props: { href: string; className: string; \"aria-current\"?: \"page\" },\n children: React.ReactNode,\n ) => React.ReactNode;\n};\n\nconst ITEM_CLASS_BASE =\n \"flex items-center gap-2 rounded-[0.5rem] px-3 py-2.5 text-base font-medium transition-all\";\n\nconst itemClass = (active: boolean) =>\n cn(\n ITEM_CLASS_BASE,\n active\n ? \"bg-[var(--hl-brand)] text-[var(--hl-brand-fg)]\"\n : \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-hover-overlay)] hover:text-[var(--hl-foreground)]\",\n );\n\n/**\n * Single nav row for the Sidebar's `nav` / `lower` slots. Matches the\n * doghouse-canonical style: active = brand bg + brand-fg text, inactive =\n * muted text lifting to full foreground on hover, over a hover wash.\n *\n * Every colour here is a token. The active pill reads `--hl-brand` /\n * `--hl-brand-fg` (it was `bg-[#ff9900] text-white` until 0.8.0); the\n * inactive row reads `--hl-foreground-muted` / `--hl-hover-overlay` /\n * `--hl-foreground` (it was `text-white/45 hover:bg-white/5\n * hover:text-white` until 0.9.0). Both were themeability bugs of the same\n * shape — see globals.css.\n */\nexport function SidebarNavItem({\n label,\n icon,\n active = false,\n onClick,\n href,\n render,\n}: SidebarNavItemProps) {\n const className = itemClass(active);\n const ariaCurrent = active ? (\"page\" as const) : undefined;\n const inner = (\n <>\n {icon}\n <span>{label}</span>\n </>\n );\n\n if (href && render)\n return (\n <>{render({ href, className, \"aria-current\": ariaCurrent }, inner)}</>\n );\n if (href) {\n return (\n <a href={href} aria-current={ariaCurrent} className={className}>\n {inner}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-current={ariaCurrent}\n aria-pressed={active}\n className={`${className} w-full`}\n >\n {inner}\n </button>\n );\n}\n","\"use client\";\n\nimport { Loader2 } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SpinnerProps = {\n label?: string;\n className?: string;\n};\n\n/**\n * Consistent inline loading affordance — used wherever the app would\n * otherwise say \"Loading…\" in plain text. Compact spinner + label, on the\n * same `--hl-foreground-muted` as every other secondary line.\n */\nexport function Spinner({ label = \"Loading…\", className }: SpinnerProps) {\n return (\n <p\n className={cn(\n \"flex items-center gap-2 px-3 py-2 text-xs text-[var(--hl-foreground-muted)]\",\n className,\n )}\n >\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n {label}\n </p>\n );\n}\n","import { cn } from \"../lib/utils\";\n\nexport type StatTileProps = {\n /** Uppercase micro-label above the value. */\n label: string;\n /** The stat itself — keep it short; it truncates rather than wraps. */\n value: React.ReactNode;\n /** Optional caption under the value. The line is ALWAYS reserved so the\n * tile height never changes when a sub appears/disappears. */\n sub?: React.ReactNode;\n className?: string;\n};\n\n/**\n * Live-stat tile with STABLE GEOMETRY — the fleet standard for status\n * cards (born on zoo-docs' dashboard, 2026-07-13):\n *\n * - text truncates, never wraps → tile height is constant\n * - the sub line renders even when empty → no height jump when data\n * (queue counts, countdowns) comes and goes between polls\n * - value/sub render in font-mono (metrically monospaced) so ticking\n * numbers don't wiggle the width — tabular-nums alone doesn't do this,\n * since the fleet's Poppins/Orbitron subsets don't ship the tnum\n * OpenType feature (B264)\n *\n * Anything elastic in HEIGHT shoves the whole page around on every poll;\n * width elasticity belongs to the row (see StatTileRow).\n */\nexport function StatTile({ label, value, sub, className }: StatTileProps) {\n return (\n <div className={cn(\"min-w-0 rounded-lg bg-[var(--hl-card)] px-4 py-3\", className)}>\n <p className=\"truncate text-[10px] font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\">\n {label}\n </p>\n <p className=\"mt-1 truncate font-mono text-xl font-semibold tabular-nums text-[var(--hl-foreground)]\">{value}</p>\n <p className=\"truncate font-mono text-[11px] tabular-nums text-[var(--hl-foreground)]\">{sub ?? \" \"}</p>\n </div>\n );\n}\n\nexport type StatTileRowProps = {\n children: React.ReactNode;\n /** Minimum tile width before the grid drops a column. Default \"9rem\". */\n minTileWidth?: string;\n className?: string;\n};\n\n/**\n * Full-width, equal-column container for StatTiles. `auto-fit` +\n * `minmax(minTileWidth, 1fr)` means the row stretches edge-to-edge with\n * the browser and sheds whole columns on narrow screens — tiles never\n * overflow off-screen and never wrap their text.\n *\n * Pair with the layout standard: content pages are FULL WIDTH (no\n * max-w-* caps) unless a page explicitly opts out.\n */\nexport function StatTileRow({ children, minTileWidth = \"9rem\", className }: StatTileRowProps) {\n return (\n <div\n className={cn(\"grid gap-3\", className)}\n style={{ gridTemplateColumns: `repeat(auto-fit, minmax(${minTileWidth}, 1fr))` }}\n >\n {children}\n </div>\n );\n}\n","/**\n * Pick black or white text for a solid color chip.\n *\n * The homelab status palette forces white text on every status color\n * EXCEPT yellow (warning: #eab308), which uses black for contrast. This\n * helper enforces that rule wherever a user-picked color drives a chip\n * background (TagChips, status badges, etc).\n *\n * The yellow rule generalizes — anything in the yellow band (high\n * luminance + warm hue) gets black ink.\n */\nexport function tagTextColor(hex: string): \"#ffffff\" | \"#000000\" {\n const normalized = hex.trim().replace(/^#/, \"\");\n if (normalized.length !== 3 && normalized.length !== 6) return \"#ffffff\";\n const expand =\n normalized.length === 3\n ? normalized\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : normalized;\n const r = parseInt(expand.slice(0, 2), 16);\n const g = parseInt(expand.slice(2, 4), 16);\n const b = parseInt(expand.slice(4, 6), 16);\n if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return \"#ffffff\";\n // Perceived luminance per ITU BT.601.\n const luma = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n return luma > 0.65 ? \"#000000\" : \"#ffffff\";\n}\n","\"use client\";\n\nimport { tagTextColor } from \"../lib/colors\";\n\nexport type TagRef = {\n id: string | number;\n name: string;\n color: string;\n};\n\nexport type TagChipsProps = {\n tags: TagRef[];\n size?: \"xs\" | \"sm\";\n onClick?: (tag: TagRef) => void;\n};\n\n/**\n * Render a row of solid-color tag chips. Yellow auto-flips text to\n * black per the homelab status-palette contrast rule (see tagTextColor).\n *\n * Renders `<button>` when interactive (`onClick` set), `<span>` when\n * read-only — keeps screen-reader semantics honest.\n */\nexport function TagChips({ tags, size = \"xs\", onClick }: TagChipsProps) {\n if (tags.length === 0) return null;\n const sizeClasses =\n size === \"xs\" ? \"px-1.5 py-0.5 text-[10px]\" : \"px-2 py-0.5 text-xs\";\n\n return (\n <div className=\"flex flex-wrap gap-1\">\n {tags.map((t) => {\n const style = {\n backgroundColor: t.color,\n color: tagTextColor(t.color),\n };\n const className = `inline-block rounded-[0.25rem] font-semibold ${sizeClasses}`;\n return onClick ? (\n <button\n key={t.id}\n type=\"button\"\n onClick={() => onClick(t)}\n className={`${className} transition active:scale-95`}\n style={style}\n >\n {t.name}\n </button>\n ) : (\n <span key={t.id} className={className} style={style}>\n {t.name}\n </span>\n );\n })}\n </div>\n );\n}\n","/**\n * Font wiring for `@jdcpuwiz/homelab-ui`.\n *\n * ## The standard: three self-hosted faces, split by JOB (BuildPlan #57)\n *\n * Wiz ruling 2026-07-24. This SUPERSEDES both the old \"Geist via next/font\"\n * mandate AND the system-ui-only ruling (#345) that briefly replaced it.\n *\n * SANS Poppins — all body / UI / headings / labels\n * DISPLAY Orbitron — BIG numbers only (stat heroes, clock, ≥ ~1.5rem)\n * MONO JetBrains Mono — small values, table figures, version stamps,\n * code (< ~1.5rem)\n *\n * The size line is the hard boundary: Orbitron is a display face and must\n * NEVER be applied to a dense small cell — if a number is small, it's\n * JetBrains Mono.\n *\n * ## The package NOW OWNS THE FACES (C525) — one import, no per-app config\n *\n * import \"@jdcpuwiz/homelab-ui/fonts.css\"\n *\n * That registers all three faces from woff2 shipped in this package, so\n * `next build` never reaches fonts.gstatic.com.\n *\n * This block previously said loading had to be per-app. That was true only of\n * `next/font`: its SWC plugin scans the app's OWN source for literal\n * `const Foo = Font({...})` calls and bundling rewrites `const`→`var`, so a\n * re-exported LOADER genuinely cannot work. The constraint never applied to\n * plain `@font-face`, which is what fonts.css uses.\n *\n * - Next apps: import `fonts.css`. Nothing else — no loaders, no\n * `.variable` classes, no `--font-*` variables.\n * - Vite apps: import `fonts.css` too, or `@fontsource/poppins` etc.\n *\n * ⚠ `FONT_APP_VARIABLES` below is LEGACY. Apps still running `next/font` set\n * those vars and `globals.css` still reads them first, so both mechanisms\n * work side by side during the rollout. Do NOT reference `--font-poppins`\n * and friends in new code: once an app moves to fonts.css they are unset, and\n * `var(--x)` with no fallback ARGUMENT is invalid at computed-value time —\n * the whole declaration is discarded and the element inherits its parent's\n * face rather than reaching your authored fallback. Use `--hl-font-sans` /\n * `--hl-font-display` / `--hl-font-mono`.\n *\n * ## `next/font/google` is a BUILD-TIME NETWORK DEPENDENCY (2026-08-15)\n *\n * The Next path above makes `next build` reach fonts.gstatic.com, which means\n * every consuming app's DEPLOY depends on it. zoovault's did, and lost: its\n * image floated `next` to a release whose next/font/google requests began\n * 404ing, and the deploy died on a commit that built clean locally. The\n * root-cause fix was pinning `next` from a committed lockfile inside the image\n * (`npm ci`, never a bare `npm install`) — do that first, everywhere. Beyond\n * that, self-hosted faces remove the network from the build entirely;\n * zoovault now runs `next/font/local` over woff2 files sourced from\n * @fontsource and committed in-repo.\n *\n * Note the fallback chain in `globals.css` — `var(--font-poppins, \"Poppins\")`.\n * The package asks for the CSS var and then for the LITERAL FAMILY NAME, so\n * anything that registers a \"Poppins\" @font-face satisfies it. next/font is a\n * convention here, not a requirement, and the Vite path above is proof.\n *\n * `globals.css` declares `--hl-font-sans` / `--hl-font-display` /\n * `--hl-font-mono` referencing the app variables below first, then the literal\n * face name, then the platform fallback — so text is readable even mid-load\n * and an un-migrated app degrades to the system stack, never to nothing.\n *\n * Sanity check after deploy — this should name Poppins:\n * getComputedStyle(document.body).fontFamily // → \"…Poppins…\"\n */\n\n/**\n * The package's own token names — the `--hl-font-*` vars the Tailwind preset's\n * `font-sans` / `font-mono` / `font-display` classes resolve to. Consumers\n * rarely touch these directly.\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--hl-font-sans\",\n mono: \"--hl-font-mono\",\n display: \"--hl-font-display\",\n} as const;\n\n/**\n * The app-side CSS variable each next/font loader must expose (its `variable:`\n * option). `globals.css` reads these, so an app that follows this naming gets\n * the three faces wired with zero extra config. Match these EXACTLY.\n *\n * const poppins = Poppins({ ..., variable: FONT_APP_VARIABLES.sans });\n */\nexport const FONT_APP_VARIABLES = {\n sans: \"--font-poppins\",\n display: \"--font-orbitron\",\n mono: \"--font-jetbrains-mono\",\n} as const;\n"],"mappings":"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","names":["Button","import_react","import_react","import_jsx_runtime","import_jsx_runtime","import_jsx_runtime","import_react","import_lucide_react","import_jsx_runtime","import_react","import_lucide_react","import_jsx_runtime","import_jsx_runtime","import_lucide_react","import_jsx_runtime","import_jsx_runtime","import_jsx_runtime"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/components/Button.tsx","../src/lib/utils.ts","../src/components/ConfirmDialog.tsx","../src/components/Modal.tsx","../src/components/EmptyState.tsx","../src/components/PageHeader.tsx","../src/components/Sidebar.tsx","../src/components/SidebarNavItem.tsx","../src/components/Spinner.tsx","../src/components/StatTile.tsx","../src/lib/colors.ts","../src/components/TagChips.tsx","../src/lib/fonts.ts"],"sourcesContent":["// Components\nexport { Button, type ButtonProps } from \"./components/Button\";\nexport {\n ConfirmDialog,\n type ConfirmDialogProps,\n} from \"./components/ConfirmDialog\";\nexport { EmptyState, type EmptyStateProps } from \"./components/EmptyState\";\nexport { Modal, type ModalProps } from \"./components/Modal\";\nexport {\n PageHeader,\n type PageHeaderProps,\n type Crumb,\n} from \"./components/PageHeader\";\nexport { Sidebar, type SidebarProps } from \"./components/Sidebar\";\nexport {\n SidebarNavItem,\n type SidebarNavItemProps,\n} from \"./components/SidebarNavItem\";\nexport { Spinner, type SpinnerProps } from \"./components/Spinner\";\nexport {\n StatTile,\n StatTileRow,\n type StatTileProps,\n type StatTileRowProps,\n} from \"./components/StatTile\";\nexport {\n TagChips,\n type TagChipsProps,\n type TagRef,\n} from \"./components/TagChips\";\n\n// Helpers\nexport { cn } from \"./lib/utils\";\nexport { tagTextColor } from \"./lib/colors\";\nexport { FONT_CSS_VARIABLES, FONT_APP_VARIABLES } from \"./lib/fonts\";\n","\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { cn } from \"../lib/utils\";\n\ntype Variant = \"primary\" | \"secondary\" | \"tertiary\" | \"danger\";\ntype Size = \"sm\" | \"md\";\n\nexport type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {\n variant?: Variant;\n size?: Size;\n};\n\nconst BASE =\n \"inline-flex items-center justify-center gap-1.5 rounded-[0.5rem] font-semibold transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--hl-brand-glow)]\";\n\n// `danger`'s BACKGROUND stays a literal — status colors are a fixed fleet\n// semantic (the preset hardcodes them too), not a per-project brand token.\n// Its label stays literal `text-white` for the same reason: that is text\n// ON a status chip, which lib/colors.ts already rules is always white,\n// NOT reading text on an app surface. Everything else here is a token.\nconst VARIANT: Record<Variant, string> = {\n primary:\n \"bg-[var(--hl-brand)] text-[var(--hl-brand-fg)] hover:bg-[var(--hl-brand-hover)]\",\n secondary:\n \"bg-[var(--hl-card)] text-[var(--hl-foreground)] hover:bg-[var(--hl-card-hover)]\",\n tertiary:\n \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)]\",\n danger: \"bg-[#b91c1c] text-white hover:brightness-110\",\n};\n\nconst SIZE: Record<Size, string> = {\n sm: \"px-3 py-1 text-xs\",\n md: \"px-4 py-1.5 text-sm\",\n};\n\n/**\n * Homelab Button. Use the variant + size props; never pass raw color\n * classes through className. className is for layout extras only\n * (e.g. `w-full`).\n */\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n function Button(\n { variant = \"secondary\", size = \"md\", className, ...rest },\n ref,\n ) {\n return (\n <button\n ref={ref}\n {...rest}\n className={cn(BASE, VARIANT[variant], SIZE[size], className)}\n />\n );\n },\n);\n","import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Conditional Tailwind class joiner. Merges variants and resolves\n * conflicts (e.g. `cn(\"p-2\", \"p-4\")` → `\"p-4\"`).\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","\"use client\";\n\nimport { useEffect } from \"react\";\nimport { Modal } from \"./Modal\";\nimport { Button } from \"./Button\";\n\nexport type ConfirmDialogProps = {\n open: boolean;\n title: string;\n message: string;\n confirmLabel?: string;\n cancelLabel?: string;\n tone?: \"danger\" | \"primary\";\n onConfirm: () => void;\n onClose: () => void;\n};\n\n/**\n * Confirm/cancel dialog built on Modal + Button. Enter triggers confirm,\n * Esc cancels (via Modal's built-in handling).\n */\nexport function ConfirmDialog({\n open,\n title,\n message,\n confirmLabel = \"Confirm\",\n cancelLabel = \"Cancel\",\n tone = \"danger\",\n onConfirm,\n onClose,\n}: ConfirmDialogProps) {\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Enter\") onConfirm();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onConfirm]);\n\n return (\n <Modal\n open={open}\n onClose={onClose}\n title={title}\n footer={\n <>\n <Button variant=\"tertiary\" size=\"md\" onClick={onClose}>\n {cancelLabel}\n </Button>\n <Button\n variant={tone === \"danger\" ? \"danger\" : \"primary\"}\n size=\"md\"\n onClick={onConfirm}\n >\n {confirmLabel}\n </Button>\n </>\n }\n >\n <p className=\"whitespace-pre-line text-sm text-[var(--hl-foreground)]\">{message}</p>\n </Modal>\n );\n}\n","\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\ntype Density = \"form\" | \"media\";\n\nexport type ModalProps = {\n open: boolean;\n onClose: () => void;\n title?: string;\n /** Show the close X in the top right. Default: true when title is set. */\n showClose?: boolean;\n /**\n * Overlay weight:\n * `form` — dialogs / pickers / confirms (`bg-black/70`)\n * `media` — lightbox / asset preview (`bg-black/90`)\n * Defaults to `form`.\n */\n density?: Density;\n /** Max width — default `max-w-md`. */\n maxWidth?: string;\n /** Footer slot — render buttons here. Always right-aligned. */\n footer?: React.ReactNode;\n children: React.ReactNode;\n};\n\n/**\n * Single modal frame. Every dialog routes through this:\n * - overlay + click-outside dismiss\n * - Esc-to-close\n * - role=dialog + aria-modal + aria-labelledby wiring\n * - DESIGN.md header treatment (uppercase tracking-widest)\n * - shared right-aligned footer row\n */\nexport function Modal({\n open,\n onClose,\n title,\n showClose,\n density = \"form\",\n maxWidth = \"max-w-md\",\n footer,\n children,\n}: ModalProps) {\n const titleIdRef = useRef(\n `hl-modal-${Math.random().toString(36).slice(2, 8)}`,\n );\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") onClose();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onClose]);\n\n if (!open) return null;\n\n const showCloseBtn = showClose ?? Boolean(title);\n const overlayClass = density === \"media\" ? \"bg-black/90\" : \"bg-black/70\";\n\n return (\n <div\n className={cn(\n \"fixed inset-0 z-50 flex items-center justify-center p-4\",\n overlayClass,\n )}\n onClick={onClose}\n role=\"presentation\"\n >\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={title ? titleIdRef.current : undefined}\n className={cn(\n \"w-full rounded-[1rem] border-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] p-5\",\n maxWidth,\n )}\n onClick={(e) => e.stopPropagation()}\n >\n {(title || showCloseBtn) && (\n <div className=\"mb-4 flex items-start justify-between gap-3\">\n {title && (\n <h2\n id={titleIdRef.current}\n className=\"text-sm font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\"\n >\n {title}\n </h2>\n )}\n {showCloseBtn && (\n <button\n type=\"button\"\n onClick={onClose}\n className=\"ml-auto rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] active:scale-90\"\n aria-label=\"Close\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n )}\n {children}\n {footer && (\n <div className=\"mt-5 flex flex-wrap items-center justify-end gap-2\">\n {footer}\n </div>\n )}\n </div>\n </div>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type EmptyStateProps = {\n icon?: React.ReactNode;\n title?: string;\n message: string;\n action?: React.ReactNode;\n /**\n * `panel` — large empty state with warm card background (page bodies).\n * `inline` — compact text-only line for sidebar tree, popovers, etc.\n */\n variant?: \"panel\" | \"inline\";\n className?: string;\n};\n\nexport function EmptyState({\n icon,\n title,\n message,\n action,\n variant = \"panel\",\n className,\n}: EmptyStateProps) {\n if (variant === \"inline\") {\n return (\n <p className={cn(\"px-3 py-2 text-xs text-[var(--hl-foreground)]\", className)}>\n {message}\n </p>\n );\n }\n return (\n <div\n className={cn(\n \"flex flex-col items-center justify-center gap-3 rounded-[1rem] bg-[var(--hl-card-warm)] p-12 text-center\",\n className,\n )}\n >\n {icon && (\n <div className=\"text-[var(--hl-foreground-faint)]\">{icon}</div>\n )}\n {title && (\n <h3 className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {title}\n </h3>\n )}\n <p className=\"text-sm text-[var(--hl-foreground)]\">{message}</p>\n {action}\n </div>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { ArrowLeft, ChevronRight, ImageOff } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type Crumb = {\n label: string;\n onClick?: () => void;\n};\n\nexport type PageHeaderProps = {\n title: string;\n description?: string;\n /** Back link rendered above the title. */\n back?: { href: string; label: string; render?: (href: string, children: React.ReactNode) => React.ReactNode };\n /** Right-aligned actions slot. */\n actions?: React.ReactNode;\n /**\n * Header image URL (e.g. `/logos.png`). When the URL resolves, the\n * image is the visual title. When it 404s, a styled placeholder at\n * the same dimensions takes its place — the layout stays stable so\n * creating a new themed page \"just works\" the moment you drop a\n * matching PNG in /public/.\n *\n * When `imageSrc` is undefined, no image block renders and the\n * text title is the visual title.\n */\n imageSrc?: string;\n imageAlt?: string;\n /**\n * Breadcrumb trail. When provided, replaces the text title. Last\n * crumb is current (full foreground + bold); earlier crumbs are muted\n * and clickable.\n */\n breadcrumb?: Crumb[];\n className?: string;\n};\n\n/**\n * Page-header block. The image block reserves space whenever `imageSrc`\n * is set — drop the PNG in /public/ and the layout just fills in.\n *\n * Text title rules:\n * - imageSrc set + image present → text becomes sr-only\n * - imageSrc set + image 404 → placeholder block + text becomes sr-only\n * - imageSrc unset → text title is visible\n * - breadcrumb provided → breadcrumb replaces text title regardless of image\n *\n * The back link uses a plain <a> by default. In Next.js, pass\n * `back.render` to use next/link:\n *\n * import Link from \"next/link\";\n * <PageHeader back={{ href: \"/foo\", label: \"Back\", render: (href, c) => <Link href={href}>{c}</Link> }} />\n */\nexport function PageHeader({\n title,\n description,\n back,\n actions,\n imageSrc,\n imageAlt,\n breadcrumb,\n className,\n}: PageHeaderProps) {\n const [imageErrored, setImageErrored] = useState(false);\n useEffect(() => {\n setImageErrored(false);\n }, [imageSrc]);\n\n const hasImageSlot = !!imageSrc;\n const imageRenders = hasImageSlot && !imageErrored;\n const hasBreadcrumb = Array.isArray(breadcrumb) && breadcrumb.length > 0;\n const textIsSrOnly = hasImageSlot;\n\n const backInner = back ? (\n <>\n <ArrowLeft className=\"h-3 w-3\" />\n {back.label}\n </>\n ) : null;\n\n return (\n <header\n className={cn(\n \"flex flex-wrap items-end justify-between gap-3\",\n className,\n )}\n >\n <div className=\"flex min-w-0 items-end gap-4\">\n {hasImageSlot &&\n (imageRenders ? (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={imageSrc}\n alt={imageAlt ?? title}\n onError={() => setImageErrored(true)}\n className=\"h-28 w-28 shrink-0 rounded-[1rem] object-contain md:h-32 md:w-32\"\n />\n ) : (\n <PlaceholderHeader title={title} imageSrc={imageSrc!} />\n ))}\n <div className=\"min-w-0\">\n {back &&\n (back.render ? (\n back.render(\n back.href,\n <span className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\">\n {backInner}\n </span>,\n )\n ) : (\n <a\n href={back.href}\n className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {backInner}\n </a>\n ))}\n {hasBreadcrumb ? (\n <Breadcrumb\n crumbs={breadcrumb!}\n srTitle={title}\n className={back ? \"mt-1\" : \"\"}\n />\n ) : (\n <h1\n className={cn(\n \"text-xl font-semibold text-[var(--hl-title)]\",\n back && \"mt-1\",\n textIsSrOnly && \"sr-only\",\n )}\n >\n {title}\n </h1>\n )}\n {description && (\n <p className=\"mt-1 text-xs text-[var(--hl-foreground)]\">{description}</p>\n )}\n </div>\n </div>\n {actions && <div className=\"flex items-center gap-2\">{actions}</div>}\n </header>\n );\n}\n\nfunction PlaceholderHeader({\n title,\n imageSrc,\n}: {\n title: string;\n imageSrc: string;\n}) {\n const file = imageSrc.replace(/^\\//, \"\");\n return (\n <div\n title={`Drop ${file} in /public to brand this header`}\n className=\"flex h-28 w-28 shrink-0 flex-col items-center justify-center gap-1 rounded-[1rem] border-2 border-dashed border-[var(--hl-border)] bg-[var(--hl-card-warm)] text-[var(--hl-foreground-faint)] md:h-32 md:w-32\"\n >\n <ImageOff className=\"h-6 w-6\" />\n <span className=\"px-2 text-center text-[10px] font-semibold uppercase tracking-widest\">\n {title}\n </span>\n </div>\n );\n}\n\nfunction Breadcrumb({\n crumbs,\n srTitle,\n className,\n}: {\n crumbs: Crumb[];\n srTitle: string;\n className?: string;\n}) {\n return (\n <nav\n aria-label=\"Breadcrumb\"\n className={cn(\n \"flex flex-wrap items-center gap-1.5 text-xl font-semibold\",\n className,\n )}\n >\n <h1 className=\"sr-only\">{srTitle}</h1>\n {crumbs.map((crumb, idx) => {\n const isLast = idx === crumbs.length - 1;\n return (\n <span\n key={`${crumb.label}-${idx}`}\n className=\"flex items-center gap-1.5\"\n >\n {idx > 0 && (\n <ChevronRight\n className=\"h-4 w-4 shrink-0 text-[var(--hl-foreground-faint)]\"\n aria-hidden=\"true\"\n />\n )}\n {crumb.onClick && !isLast ? (\n <button\n type=\"button\"\n onClick={crumb.onClick}\n className=\"rounded px-1 text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {crumb.label}\n </button>\n ) : (\n <span\n className={cn(\n isLast\n ? \"text-[var(--hl-foreground)]\"\n : \"text-[var(--hl-foreground-muted)]\",\n isLast && \"px-1\",\n )}\n aria-current={isLast ? \"page\" : undefined}\n >\n {crumb.label}\n </span>\n )}\n </span>\n );\n })}\n </nav>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Menu, X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarProps = {\n /**\n * Logo image src — typically `/logo.png` or `/<project>-logo-256.png`.\n * Rendered at w-36 h-36 inside a centered block with px-4 pt-6 pb-5\n * + border-b-2 (doghouse-canonical spec). Override sizing/shape via\n * `logoClassName`.\n */\n logoSrc: string;\n /** Alt text for the logo + label shown in the mobile top bar. */\n logoAlt: string;\n /**\n * Override the className applied to the logo `<img>`. Default is\n * `\"h-36 w-36 rounded-[1rem] object-contain\"` (doghouse-canonical\n * square logo). Pass `\"h-auto w-full rounded-md object-contain\"` for\n * full-width banner-style logos, or any other Tailwind classes.\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n logoClassName?: string;\n /**\n * Slots — every section is optional. Render them in this order:\n * 1. Logo block (always)\n * 2. widgets — clock / weather / homepage rotator / etc\n * 3. nav — primary navigation\n * 4. middle — long-running content (folder tree, etc) — flex-1\n * 5. lower — secondary nav (manage-tags, settings link, etc)\n * 6. (spacer)\n * 7. admin — admin section\n * 8. footer — version stamp, etc\n */\n widgets?: React.ReactNode;\n nav?: React.ReactNode;\n middle?: React.ReactNode;\n lower?: React.ReactNode;\n admin?: React.ReactNode;\n footer?: React.ReactNode;\n /**\n * Mobile-drawer behavior. Controlled — the consumer owns the open\n * state so they can wire it from anywhere (top-bar hamburger,\n * keyboard shortcut, route change, etc.).\n *\n * When omitted, the Sidebar manages its own drawer state using the\n * built-in mobile top bar (hamburger renders top-left on screens < md).\n */\n mobileOpen?: boolean;\n onMobileOpenChange?: (open: boolean) => void;\n /** Extra classes on the outer <aside>. */\n className?: string;\n /**\n * Override the className used for the `middle` slot's wrapper.\n *\n * The default — `\"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\"` —\n * is correct for asset-den's \"single panel with a scrollable folder\n * tree inside.\" Override when the middle slot's content is itself a\n * stack of cards (e.g. doghouse's per-service status widgets) — those\n * already sit on the card token, so wrapping them in another same-color\n * panel makes them disappear.\n *\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n middleClassName?: string;\n /**\n * When true, render a built-in mobile top bar (logo + hamburger) on\n * screens < md. Defaults to true. Disable when the consumer renders\n * its own top chrome.\n */\n showMobileTopBar?: boolean;\n};\n\n/**\n * Canonical homelab sidebar shell. Doghouse is the reference impl;\n * this is the extracted version every other project consumes.\n *\n * Dimensions are non-negotiable:\n * - md+ width: 240px default, fixed pixels — independent of root font-size\n * (B358) (override per-project via `:root { --hl-sidebar-width: 18rem; }`)\n * - md+ position: fixed-flex in a `display:flex` parent\n * - mobile: off-canvas drawer at full sidebar width\n * - logo block: w-36 h-36 centered, px-4 pt-6 pb-5, border-b-2\n *\n * Surfaces are `bg-[var(--hl-sidebar)]` / `bg-[var(--hl-card)]` /\n * `border-[var(--hl-border)]` — arbitrary-value classes that read the\n * tokens directly, so they still work when the consumer skips the\n * Tailwind preset AND re-skin correctly when the consumer overrides the\n * vars. (They were raw hex until 0.8.0, which silently pinned every\n * consumer to the fleet palette.) Import globals.css for the defaults.\n */\nexport function Sidebar({\n logoSrc,\n logoAlt,\n logoClassName = \"h-36 w-36 rounded-[1rem] object-contain\",\n widgets,\n nav,\n middle,\n lower,\n admin,\n footer,\n mobileOpen: controlledOpen,\n onMobileOpenChange,\n className,\n middleClassName = \"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\",\n showMobileTopBar = true,\n}: SidebarProps) {\n const [internalOpen, setInternalOpen] = useState(false);\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen : internalOpen;\n\n const setOpen = (next: boolean) => {\n if (!isControlled) setInternalOpen(next);\n onMobileOpenChange?.(next);\n };\n\n // Reserve the fixed mobile top bar's height in flow (BUG #339).\n //\n // The bar is `fixed`, so it sits OVER the consumer's content and every\n // app's page <h1> was half-hidden behind it on a phone. It cannot be a\n // spacer element rendered here: this component's siblings land inside the\n // consumer's `display:flex` row (`<Sidebar/><main/>`), where a block would\n // become a zero-width flex COLUMN beside <main> and push nothing down.\n //\n // So the reservation is a `body { padding-top }` rule in globals.css, gated\n // on this class — set only while a bar is actually rendered. Five fleet apps\n // pass `showMobileTopBar={false}` and draw their own top chrome; an ungated\n // rule would give each of them 44px of dead space at the top of every phone\n // view. The bar's own min-height and that padding read the SAME\n // `--hl-mobile-topbar-height` token, so the two can never drift.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!showMobileTopBar) return;\n const root = document.documentElement;\n root.classList.add(\"hl-has-mobile-topbar\");\n return () => root.classList.remove(\"hl-has-mobile-topbar\");\n }, [showMobileTopBar]);\n\n // Lock body scroll while the mobile drawer is open.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!open) return;\n const prev = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n return () => {\n document.body.style.overflow = prev;\n };\n }, [open]);\n\n return (\n <>\n {showMobileTopBar && (\n <div className=\"hl-mobile-topbar fixed inset-x-0 top-0 z-30 flex min-h-[var(--hl-mobile-topbar-height,3.25rem)] items-center gap-2 border-b-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] px-3 py-2 md:hidden\">\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n className=\"rounded-[0.25rem] p-1.5 text-[var(--hl-foreground)] transition hover:bg-[var(--hl-card-hover)] active:scale-90\"\n aria-label=\"Open menu\"\n >\n <Menu className=\"h-5 w-5\" />\n </button>\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={logoSrc} alt=\"\" width={24} height={24} />\n <p className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {logoAlt}\n </p>\n </div>\n )}\n\n {open && (\n <div\n className=\"fixed inset-0 z-40 bg-black/70 md:hidden\"\n onClick={() => setOpen(false)}\n role=\"presentation\"\n />\n )}\n\n <aside\n className={cn(\n \"fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,240px)] shrink-0 flex-col border-r-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] transition-transform md:static md:translate-x-0 md:h-screen\",\n open ? \"translate-x-0\" : \"-translate-x-full md:translate-x-0\",\n className,\n )}\n >\n <div className=\"relative flex flex-col items-center border-b-2 border-[var(--hl-border)] px-4 pt-6 pb-5\">\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n src={logoSrc}\n alt={logoAlt}\n className={logoClassName}\n />\n {showMobileTopBar && (\n <button\n type=\"button\"\n onClick={() => setOpen(false)}\n className=\"absolute right-3 top-3 rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] md:hidden\"\n aria-label=\"Close menu\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n\n {widgets && <div className=\"mx-3 mt-3\">{widgets}</div>}\n\n {nav && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {nav}\n </nav>\n )}\n\n {middle && <div className={middleClassName}>{middle}</div>}\n\n {lower && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {lower}\n </nav>\n )}\n\n {!middle && <div className=\"flex-1\" />}\n\n {admin && <div>{admin}</div>}\n\n {footer && (\n <div className=\"border-t-2 border-[var(--hl-border)] px-4 py-3 text-center text-xs text-[var(--hl-foreground-faint)]\">\n {footer}\n </div>\n )}\n </aside>\n </>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarNavItemProps = {\n label: string;\n icon?: React.ReactNode;\n active?: boolean;\n onClick?: () => void;\n /**\n * When set, renders as <a href>. Otherwise renders as <button>. In\n * Next.js, prefer passing `render` so you can wrap in next/link\n * without losing classes:\n *\n * <SidebarNavItem\n * label=\"Home\"\n * href=\"/\"\n * render={(props, children) => <Link {...props}>{children}</Link>}\n * />\n *\n * Spread `{...props}` rather than destructuring individual fields\n * (`{ href, className }`) — the props object also carries `aria-current`\n * for the active item, and a narrow destructure silently drops it (and\n * any future field) even though the type still says it's there.\n */\n href?: string;\n render?: (\n props: { href: string; className: string; \"aria-current\"?: \"page\" },\n children: React.ReactNode,\n ) => React.ReactNode;\n};\n\nconst ITEM_CLASS_BASE =\n \"flex items-center gap-2 rounded-[0.5rem] px-3 py-2.5 text-base font-medium transition-all\";\n\nconst itemClass = (active: boolean) =>\n cn(\n ITEM_CLASS_BASE,\n active\n ? \"bg-[var(--hl-brand)] text-[var(--hl-brand-fg)]\"\n : \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-hover-overlay)] hover:text-[var(--hl-foreground)]\",\n );\n\n/**\n * Single nav row for the Sidebar's `nav` / `lower` slots. Matches the\n * doghouse-canonical style: active = brand bg + brand-fg text, inactive =\n * muted text lifting to full foreground on hover, over a hover wash.\n *\n * Every colour here is a token. The active pill reads `--hl-brand` /\n * `--hl-brand-fg` (it was `bg-[#ff9900] text-white` until 0.8.0); the\n * inactive row reads `--hl-foreground-muted` / `--hl-hover-overlay` /\n * `--hl-foreground` (it was `text-white/45 hover:bg-white/5\n * hover:text-white` until 0.9.0). Both were themeability bugs of the same\n * shape — see globals.css.\n */\nexport function SidebarNavItem({\n label,\n icon,\n active = false,\n onClick,\n href,\n render,\n}: SidebarNavItemProps) {\n const className = itemClass(active);\n const ariaCurrent = active ? (\"page\" as const) : undefined;\n const inner = (\n <>\n {icon}\n <span>{label}</span>\n </>\n );\n\n if (href && render)\n return (\n <>{render({ href, className, \"aria-current\": ariaCurrent }, inner)}</>\n );\n if (href) {\n return (\n <a href={href} aria-current={ariaCurrent} className={className}>\n {inner}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-current={ariaCurrent}\n aria-pressed={active}\n className={`${className} w-full`}\n >\n {inner}\n </button>\n );\n}\n","\"use client\";\n\nimport { Loader2 } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SpinnerProps = {\n label?: string;\n className?: string;\n};\n\n/**\n * Consistent inline loading affordance — used wherever the app would\n * otherwise say \"Loading…\" in plain text. Compact spinner + label, on the\n * same `--hl-foreground-muted` as every other secondary line.\n */\nexport function Spinner({ label = \"Loading…\", className }: SpinnerProps) {\n return (\n <p\n className={cn(\n \"flex items-center gap-2 px-3 py-2 text-xs text-[var(--hl-foreground-muted)]\",\n className,\n )}\n >\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n {label}\n </p>\n );\n}\n","import { cn } from \"../lib/utils\";\n\nexport type StatTileProps = {\n /** Uppercase micro-label above the value. */\n label: string;\n /** The stat itself — keep it short; it truncates rather than wraps. */\n value: React.ReactNode;\n /** Optional caption under the value. The line is ALWAYS reserved so the\n * tile height never changes when a sub appears/disappears. */\n sub?: React.ReactNode;\n className?: string;\n};\n\n/**\n * Live-stat tile with STABLE GEOMETRY — the fleet standard for status\n * cards (born on zoo-docs' dashboard, 2026-07-13):\n *\n * - text truncates, never wraps → tile height is constant\n * - the sub line renders even when empty → no height jump when data\n * (queue counts, countdowns) comes and goes between polls\n * - value/sub render in font-mono (metrically monospaced) so ticking\n * numbers don't wiggle the width — tabular-nums alone doesn't do this,\n * since the fleet's Poppins/Orbitron subsets don't ship the tnum\n * OpenType feature (B264)\n *\n * Anything elastic in HEIGHT shoves the whole page around on every poll;\n * width elasticity belongs to the row (see StatTileRow).\n */\nexport function StatTile({ label, value, sub, className }: StatTileProps) {\n return (\n <div className={cn(\"min-w-0 rounded-lg bg-[var(--hl-card)] px-4 py-3\", className)}>\n <p className=\"truncate text-[10px] font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\">\n {label}\n </p>\n <p className=\"mt-1 truncate font-mono text-xl font-semibold tabular-nums text-[var(--hl-foreground)]\">{value}</p>\n <p className=\"truncate font-mono text-[11px] tabular-nums text-[var(--hl-foreground)]\">{sub ?? \" \"}</p>\n </div>\n );\n}\n\nexport type StatTileRowProps = {\n children: React.ReactNode;\n /** Minimum tile width before the grid drops a column. Default \"9rem\". */\n minTileWidth?: string;\n className?: string;\n};\n\n/**\n * Full-width, equal-column container for StatTiles. `auto-fit` +\n * `minmax(minTileWidth, 1fr)` means the row stretches edge-to-edge with\n * the browser and sheds whole columns on narrow screens — tiles never\n * overflow off-screen and never wrap their text.\n *\n * Pair with the layout standard: content pages are FULL WIDTH (no\n * max-w-* caps) unless a page explicitly opts out.\n */\nexport function StatTileRow({ children, minTileWidth = \"9rem\", className }: StatTileRowProps) {\n return (\n <div\n className={cn(\"grid gap-3\", className)}\n style={{ gridTemplateColumns: `repeat(auto-fit, minmax(${minTileWidth}, 1fr))` }}\n >\n {children}\n </div>\n );\n}\n","/**\n * Pick black or white text for a solid color chip.\n *\n * The homelab status palette forces white text on every status color\n * EXCEPT yellow (warning: #eab308), which uses black for contrast. This\n * helper enforces that rule wherever a user-picked color drives a chip\n * background (TagChips, status badges, etc).\n *\n * The yellow rule generalizes — anything in the yellow band (high\n * luminance + warm hue) gets black ink.\n */\nexport function tagTextColor(hex: string): \"#ffffff\" | \"#000000\" {\n const normalized = hex.trim().replace(/^#/, \"\");\n if (normalized.length !== 3 && normalized.length !== 6) return \"#ffffff\";\n const expand =\n normalized.length === 3\n ? normalized\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : normalized;\n const r = parseInt(expand.slice(0, 2), 16);\n const g = parseInt(expand.slice(2, 4), 16);\n const b = parseInt(expand.slice(4, 6), 16);\n if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return \"#ffffff\";\n // Perceived luminance per ITU BT.601.\n const luma = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n return luma > 0.65 ? \"#000000\" : \"#ffffff\";\n}\n","\"use client\";\n\nimport { tagTextColor } from \"../lib/colors\";\n\nexport type TagRef = {\n id: string | number;\n name: string;\n color: string;\n};\n\nexport type TagChipsProps = {\n tags: TagRef[];\n size?: \"xs\" | \"sm\";\n onClick?: (tag: TagRef) => void;\n};\n\n/**\n * Render a row of solid-color tag chips. Yellow auto-flips text to\n * black per the homelab status-palette contrast rule (see tagTextColor).\n *\n * Renders `<button>` when interactive (`onClick` set), `<span>` when\n * read-only — keeps screen-reader semantics honest.\n */\nexport function TagChips({ tags, size = \"xs\", onClick }: TagChipsProps) {\n if (tags.length === 0) return null;\n const sizeClasses =\n size === \"xs\" ? \"px-1.5 py-0.5 text-[10px]\" : \"px-2 py-0.5 text-xs\";\n\n return (\n <div className=\"flex flex-wrap gap-1\">\n {tags.map((t) => {\n const style = {\n backgroundColor: t.color,\n color: tagTextColor(t.color),\n };\n const className = `inline-block rounded-[0.25rem] font-semibold ${sizeClasses}`;\n return onClick ? (\n <button\n key={t.id}\n type=\"button\"\n onClick={() => onClick(t)}\n className={`${className} transition active:scale-95`}\n style={style}\n >\n {t.name}\n </button>\n ) : (\n <span key={t.id} className={className} style={style}>\n {t.name}\n </span>\n );\n })}\n </div>\n );\n}\n","/**\n * Font wiring for `@jdcpuwiz/homelab-ui`.\n *\n * ## The standard: three self-hosted faces, split by JOB (BuildPlan #57)\n *\n * Wiz ruling 2026-07-24. This SUPERSEDES both the old \"Geist via next/font\"\n * mandate AND the system-ui-only ruling (#345) that briefly replaced it.\n *\n * SANS Poppins — all body / UI / headings / labels\n * DISPLAY Orbitron — BIG numbers only (stat heroes, clock, ≥ ~1.5rem)\n * MONO JetBrains Mono — small values, table figures, version stamps,\n * code (< ~1.5rem)\n *\n * The size line is the hard boundary: Orbitron is a display face and must\n * NEVER be applied to a dense small cell — if a number is small, it's\n * JetBrains Mono.\n *\n * ## The package NOW OWNS THE FACES (C525) — one import, no per-app config\n *\n * import \"@jdcpuwiz/homelab-ui/fonts.css\"\n *\n * That registers all three faces from woff2 shipped in this package, so\n * `next build` never reaches fonts.gstatic.com.\n *\n * This block previously said loading had to be per-app. That was true only of\n * `next/font`: its SWC plugin scans the app's OWN source for literal\n * `const Foo = Font({...})` calls and bundling rewrites `const`→`var`, so a\n * re-exported LOADER genuinely cannot work. The constraint never applied to\n * plain `@font-face`, which is what fonts.css uses.\n *\n * - Next apps: import `fonts.css`. Nothing else — no loaders, no\n * `.variable` classes, no `--font-*` variables.\n * - Vite apps: import `fonts.css` too, or `@fontsource/poppins` etc.\n *\n * ⚠ `FONT_APP_VARIABLES` below is LEGACY. Apps still running `next/font` set\n * those vars and `globals.css` still reads them first, so both mechanisms\n * work side by side during the rollout. Do NOT reference `--font-poppins`\n * and friends in new code: once an app moves to fonts.css they are unset, and\n * `var(--x)` with no fallback ARGUMENT is invalid at computed-value time —\n * the whole declaration is discarded and the element inherits its parent's\n * face rather than reaching your authored fallback. Use `--hl-font-sans` /\n * `--hl-font-display` / `--hl-font-mono`.\n *\n * ## `next/font/google` is a BUILD-TIME NETWORK DEPENDENCY (2026-08-15)\n *\n * The Next path above makes `next build` reach fonts.gstatic.com, which means\n * every consuming app's DEPLOY depends on it. zoovault's did, and lost: its\n * image floated `next` to a release whose next/font/google requests began\n * 404ing, and the deploy died on a commit that built clean locally. The\n * root-cause fix was pinning `next` from a committed lockfile inside the image\n * (`npm ci`, never a bare `npm install`) — do that first, everywhere. Beyond\n * that, self-hosted faces remove the network from the build entirely;\n * zoovault now runs `next/font/local` over woff2 files sourced from\n * @fontsource and committed in-repo.\n *\n * Note the fallback chain in `globals.css` — `var(--font-poppins, \"Poppins\")`.\n * The package asks for the CSS var and then for the LITERAL FAMILY NAME, so\n * anything that registers a \"Poppins\" @font-face satisfies it. next/font is a\n * convention here, not a requirement, and the Vite path above is proof.\n *\n * `globals.css` declares `--hl-font-sans` / `--hl-font-display` /\n * `--hl-font-mono` referencing the app variables below first, then the literal\n * face name, then the platform fallback — so text is readable even mid-load\n * and an un-migrated app degrades to the system stack, never to nothing.\n *\n * Sanity check after deploy — this should name Poppins:\n * getComputedStyle(document.body).fontFamily // → \"…Poppins…\"\n */\n\n/**\n * The package's own token names — the `--hl-font-*` vars the Tailwind preset's\n * `font-sans` / `font-mono` / `font-display` classes resolve to. Consumers\n * rarely touch these directly.\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--hl-font-sans\",\n mono: \"--hl-font-mono\",\n display: \"--hl-font-display\",\n} as const;\n\n/**\n * The app-side CSS variable each next/font loader must expose (its `variable:`\n * option). `globals.css` reads these, so an app that follows this naming gets\n * the three faces wired with zero extra config. Match these EXACTLY.\n *\n * const poppins = Poppins({ ..., variable: FONT_APP_VARIABLES.sans });\n */\nexport const FONT_APP_VARIABLES = {\n sans: \"--font-poppins\",\n display: \"--font-orbitron\",\n mono: \"--font-jetbrains-mono\",\n} as const;\n"],"mappings":"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","names":["Button","import_react","import_react","import_jsx_runtime","import_jsx_runtime","import_jsx_runtime","import_react","import_lucide_react","import_jsx_runtime","import_react","import_lucide_react","import_jsx_runtime","import_jsx_runtime","import_lucide_react","import_jsx_runtime","import_jsx_runtime","import_jsx_runtime"]}
package/dist/index.d.cts CHANGED
@@ -204,7 +204,8 @@ type SidebarProps = {
204
204
  * this is the extracted version every other project consumes.
205
205
  *
206
206
  * Dimensions are non-negotiable:
207
- * - md+ width: 15rem default (override per-project via `:root { --hl-sidebar-width: 18rem; }`)
207
+ * - md+ width: 240px default, fixed pixels — independent of root font-size
208
+ * (B358) (override per-project via `:root { --hl-sidebar-width: 18rem; }`)
208
209
  * - md+ position: fixed-flex in a `display:flex` parent
209
210
  * - mobile: off-canvas drawer at full sidebar width
210
211
  * - logo block: w-36 h-36 centered, px-4 pt-6 pb-5, border-b-2
package/dist/index.d.ts CHANGED
@@ -204,7 +204,8 @@ type SidebarProps = {
204
204
  * this is the extracted version every other project consumes.
205
205
  *
206
206
  * Dimensions are non-negotiable:
207
- * - md+ width: 15rem default (override per-project via `:root { --hl-sidebar-width: 18rem; }`)
207
+ * - md+ width: 240px default, fixed pixels — independent of root font-size
208
+ * (B358) (override per-project via `:root { --hl-sidebar-width: 18rem; }`)
208
209
  * - md+ position: fixed-flex in a `display:flex` parent
209
210
  * - mobile: off-canvas drawer at full sidebar width
210
211
  * - logo block: w-36 h-36 centered, px-4 pt-6 pb-5, border-b-2
package/dist/index.js CHANGED
@@ -420,7 +420,7 @@ function Sidebar({
420
420
  "aside",
421
421
  {
422
422
  className: cn(
423
- "fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,15rem)] shrink-0 flex-col border-r-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] transition-transform md:static md:translate-x-0 md:h-screen",
423
+ "fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,240px)] shrink-0 flex-col border-r-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] transition-transform md:static md:translate-x-0 md:h-screen",
424
424
  open ? "translate-x-0" : "-translate-x-full md:translate-x-0",
425
425
  className
426
426
  ),
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/components/Button.tsx","../src/lib/utils.ts","../src/components/ConfirmDialog.tsx","../src/components/Modal.tsx","../src/components/EmptyState.tsx","../src/components/PageHeader.tsx","../src/components/Sidebar.tsx","../src/components/SidebarNavItem.tsx","../src/components/Spinner.tsx","../src/components/StatTile.tsx","../src/lib/colors.ts","../src/components/TagChips.tsx","../src/lib/fonts.ts"],"sourcesContent":["\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { cn } from \"../lib/utils\";\n\ntype Variant = \"primary\" | \"secondary\" | \"tertiary\" | \"danger\";\ntype Size = \"sm\" | \"md\";\n\nexport type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {\n variant?: Variant;\n size?: Size;\n};\n\nconst BASE =\n \"inline-flex items-center justify-center gap-1.5 rounded-[0.5rem] font-semibold transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--hl-brand-glow)]\";\n\n// `danger`'s BACKGROUND stays a literal — status colors are a fixed fleet\n// semantic (the preset hardcodes them too), not a per-project brand token.\n// Its label stays literal `text-white` for the same reason: that is text\n// ON a status chip, which lib/colors.ts already rules is always white,\n// NOT reading text on an app surface. Everything else here is a token.\nconst VARIANT: Record<Variant, string> = {\n primary:\n \"bg-[var(--hl-brand)] text-[var(--hl-brand-fg)] hover:bg-[var(--hl-brand-hover)]\",\n secondary:\n \"bg-[var(--hl-card)] text-[var(--hl-foreground)] hover:bg-[var(--hl-card-hover)]\",\n tertiary:\n \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)]\",\n danger: \"bg-[#b91c1c] text-white hover:brightness-110\",\n};\n\nconst SIZE: Record<Size, string> = {\n sm: \"px-3 py-1 text-xs\",\n md: \"px-4 py-1.5 text-sm\",\n};\n\n/**\n * Homelab Button. Use the variant + size props; never pass raw color\n * classes through className. className is for layout extras only\n * (e.g. `w-full`).\n */\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n function Button(\n { variant = \"secondary\", size = \"md\", className, ...rest },\n ref,\n ) {\n return (\n <button\n ref={ref}\n {...rest}\n className={cn(BASE, VARIANT[variant], SIZE[size], className)}\n />\n );\n },\n);\n","import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Conditional Tailwind class joiner. Merges variants and resolves\n * conflicts (e.g. `cn(\"p-2\", \"p-4\")` → `\"p-4\"`).\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","\"use client\";\n\nimport { useEffect } from \"react\";\nimport { Modal } from \"./Modal\";\nimport { Button } from \"./Button\";\n\nexport type ConfirmDialogProps = {\n open: boolean;\n title: string;\n message: string;\n confirmLabel?: string;\n cancelLabel?: string;\n tone?: \"danger\" | \"primary\";\n onConfirm: () => void;\n onClose: () => void;\n};\n\n/**\n * Confirm/cancel dialog built on Modal + Button. Enter triggers confirm,\n * Esc cancels (via Modal's built-in handling).\n */\nexport function ConfirmDialog({\n open,\n title,\n message,\n confirmLabel = \"Confirm\",\n cancelLabel = \"Cancel\",\n tone = \"danger\",\n onConfirm,\n onClose,\n}: ConfirmDialogProps) {\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Enter\") onConfirm();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onConfirm]);\n\n return (\n <Modal\n open={open}\n onClose={onClose}\n title={title}\n footer={\n <>\n <Button variant=\"tertiary\" size=\"md\" onClick={onClose}>\n {cancelLabel}\n </Button>\n <Button\n variant={tone === \"danger\" ? \"danger\" : \"primary\"}\n size=\"md\"\n onClick={onConfirm}\n >\n {confirmLabel}\n </Button>\n </>\n }\n >\n <p className=\"whitespace-pre-line text-sm text-[var(--hl-foreground)]\">{message}</p>\n </Modal>\n );\n}\n","\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\ntype Density = \"form\" | \"media\";\n\nexport type ModalProps = {\n open: boolean;\n onClose: () => void;\n title?: string;\n /** Show the close X in the top right. Default: true when title is set. */\n showClose?: boolean;\n /**\n * Overlay weight:\n * `form` — dialogs / pickers / confirms (`bg-black/70`)\n * `media` — lightbox / asset preview (`bg-black/90`)\n * Defaults to `form`.\n */\n density?: Density;\n /** Max width — default `max-w-md`. */\n maxWidth?: string;\n /** Footer slot — render buttons here. Always right-aligned. */\n footer?: React.ReactNode;\n children: React.ReactNode;\n};\n\n/**\n * Single modal frame. Every dialog routes through this:\n * - overlay + click-outside dismiss\n * - Esc-to-close\n * - role=dialog + aria-modal + aria-labelledby wiring\n * - DESIGN.md header treatment (uppercase tracking-widest)\n * - shared right-aligned footer row\n */\nexport function Modal({\n open,\n onClose,\n title,\n showClose,\n density = \"form\",\n maxWidth = \"max-w-md\",\n footer,\n children,\n}: ModalProps) {\n const titleIdRef = useRef(\n `hl-modal-${Math.random().toString(36).slice(2, 8)}`,\n );\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") onClose();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onClose]);\n\n if (!open) return null;\n\n const showCloseBtn = showClose ?? Boolean(title);\n const overlayClass = density === \"media\" ? \"bg-black/90\" : \"bg-black/70\";\n\n return (\n <div\n className={cn(\n \"fixed inset-0 z-50 flex items-center justify-center p-4\",\n overlayClass,\n )}\n onClick={onClose}\n role=\"presentation\"\n >\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={title ? titleIdRef.current : undefined}\n className={cn(\n \"w-full rounded-[1rem] border-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] p-5\",\n maxWidth,\n )}\n onClick={(e) => e.stopPropagation()}\n >\n {(title || showCloseBtn) && (\n <div className=\"mb-4 flex items-start justify-between gap-3\">\n {title && (\n <h2\n id={titleIdRef.current}\n className=\"text-sm font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\"\n >\n {title}\n </h2>\n )}\n {showCloseBtn && (\n <button\n type=\"button\"\n onClick={onClose}\n className=\"ml-auto rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] active:scale-90\"\n aria-label=\"Close\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n )}\n {children}\n {footer && (\n <div className=\"mt-5 flex flex-wrap items-center justify-end gap-2\">\n {footer}\n </div>\n )}\n </div>\n </div>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type EmptyStateProps = {\n icon?: React.ReactNode;\n title?: string;\n message: string;\n action?: React.ReactNode;\n /**\n * `panel` — large empty state with warm card background (page bodies).\n * `inline` — compact text-only line for sidebar tree, popovers, etc.\n */\n variant?: \"panel\" | \"inline\";\n className?: string;\n};\n\nexport function EmptyState({\n icon,\n title,\n message,\n action,\n variant = \"panel\",\n className,\n}: EmptyStateProps) {\n if (variant === \"inline\") {\n return (\n <p className={cn(\"px-3 py-2 text-xs text-[var(--hl-foreground)]\", className)}>\n {message}\n </p>\n );\n }\n return (\n <div\n className={cn(\n \"flex flex-col items-center justify-center gap-3 rounded-[1rem] bg-[var(--hl-card-warm)] p-12 text-center\",\n className,\n )}\n >\n {icon && (\n <div className=\"text-[var(--hl-foreground-faint)]\">{icon}</div>\n )}\n {title && (\n <h3 className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {title}\n </h3>\n )}\n <p className=\"text-sm text-[var(--hl-foreground)]\">{message}</p>\n {action}\n </div>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { ArrowLeft, ChevronRight, ImageOff } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type Crumb = {\n label: string;\n onClick?: () => void;\n};\n\nexport type PageHeaderProps = {\n title: string;\n description?: string;\n /** Back link rendered above the title. */\n back?: { href: string; label: string; render?: (href: string, children: React.ReactNode) => React.ReactNode };\n /** Right-aligned actions slot. */\n actions?: React.ReactNode;\n /**\n * Header image URL (e.g. `/logos.png`). When the URL resolves, the\n * image is the visual title. When it 404s, a styled placeholder at\n * the same dimensions takes its place — the layout stays stable so\n * creating a new themed page \"just works\" the moment you drop a\n * matching PNG in /public/.\n *\n * When `imageSrc` is undefined, no image block renders and the\n * text title is the visual title.\n */\n imageSrc?: string;\n imageAlt?: string;\n /**\n * Breadcrumb trail. When provided, replaces the text title. Last\n * crumb is current (full foreground + bold); earlier crumbs are muted\n * and clickable.\n */\n breadcrumb?: Crumb[];\n className?: string;\n};\n\n/**\n * Page-header block. The image block reserves space whenever `imageSrc`\n * is set — drop the PNG in /public/ and the layout just fills in.\n *\n * Text title rules:\n * - imageSrc set + image present → text becomes sr-only\n * - imageSrc set + image 404 → placeholder block + text becomes sr-only\n * - imageSrc unset → text title is visible\n * - breadcrumb provided → breadcrumb replaces text title regardless of image\n *\n * The back link uses a plain <a> by default. In Next.js, pass\n * `back.render` to use next/link:\n *\n * import Link from \"next/link\";\n * <PageHeader back={{ href: \"/foo\", label: \"Back\", render: (href, c) => <Link href={href}>{c}</Link> }} />\n */\nexport function PageHeader({\n title,\n description,\n back,\n actions,\n imageSrc,\n imageAlt,\n breadcrumb,\n className,\n}: PageHeaderProps) {\n const [imageErrored, setImageErrored] = useState(false);\n useEffect(() => {\n setImageErrored(false);\n }, [imageSrc]);\n\n const hasImageSlot = !!imageSrc;\n const imageRenders = hasImageSlot && !imageErrored;\n const hasBreadcrumb = Array.isArray(breadcrumb) && breadcrumb.length > 0;\n const textIsSrOnly = hasImageSlot;\n\n const backInner = back ? (\n <>\n <ArrowLeft className=\"h-3 w-3\" />\n {back.label}\n </>\n ) : null;\n\n return (\n <header\n className={cn(\n \"flex flex-wrap items-end justify-between gap-3\",\n className,\n )}\n >\n <div className=\"flex min-w-0 items-end gap-4\">\n {hasImageSlot &&\n (imageRenders ? (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={imageSrc}\n alt={imageAlt ?? title}\n onError={() => setImageErrored(true)}\n className=\"h-28 w-28 shrink-0 rounded-[1rem] object-contain md:h-32 md:w-32\"\n />\n ) : (\n <PlaceholderHeader title={title} imageSrc={imageSrc!} />\n ))}\n <div className=\"min-w-0\">\n {back &&\n (back.render ? (\n back.render(\n back.href,\n <span className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\">\n {backInner}\n </span>,\n )\n ) : (\n <a\n href={back.href}\n className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {backInner}\n </a>\n ))}\n {hasBreadcrumb ? (\n <Breadcrumb\n crumbs={breadcrumb!}\n srTitle={title}\n className={back ? \"mt-1\" : \"\"}\n />\n ) : (\n <h1\n className={cn(\n \"text-xl font-semibold text-[var(--hl-title)]\",\n back && \"mt-1\",\n textIsSrOnly && \"sr-only\",\n )}\n >\n {title}\n </h1>\n )}\n {description && (\n <p className=\"mt-1 text-xs text-[var(--hl-foreground)]\">{description}</p>\n )}\n </div>\n </div>\n {actions && <div className=\"flex items-center gap-2\">{actions}</div>}\n </header>\n );\n}\n\nfunction PlaceholderHeader({\n title,\n imageSrc,\n}: {\n title: string;\n imageSrc: string;\n}) {\n const file = imageSrc.replace(/^\\//, \"\");\n return (\n <div\n title={`Drop ${file} in /public to brand this header`}\n className=\"flex h-28 w-28 shrink-0 flex-col items-center justify-center gap-1 rounded-[1rem] border-2 border-dashed border-[var(--hl-border)] bg-[var(--hl-card-warm)] text-[var(--hl-foreground-faint)] md:h-32 md:w-32\"\n >\n <ImageOff className=\"h-6 w-6\" />\n <span className=\"px-2 text-center text-[10px] font-semibold uppercase tracking-widest\">\n {title}\n </span>\n </div>\n );\n}\n\nfunction Breadcrumb({\n crumbs,\n srTitle,\n className,\n}: {\n crumbs: Crumb[];\n srTitle: string;\n className?: string;\n}) {\n return (\n <nav\n aria-label=\"Breadcrumb\"\n className={cn(\n \"flex flex-wrap items-center gap-1.5 text-xl font-semibold\",\n className,\n )}\n >\n <h1 className=\"sr-only\">{srTitle}</h1>\n {crumbs.map((crumb, idx) => {\n const isLast = idx === crumbs.length - 1;\n return (\n <span\n key={`${crumb.label}-${idx}`}\n className=\"flex items-center gap-1.5\"\n >\n {idx > 0 && (\n <ChevronRight\n className=\"h-4 w-4 shrink-0 text-[var(--hl-foreground-faint)]\"\n aria-hidden=\"true\"\n />\n )}\n {crumb.onClick && !isLast ? (\n <button\n type=\"button\"\n onClick={crumb.onClick}\n className=\"rounded px-1 text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {crumb.label}\n </button>\n ) : (\n <span\n className={cn(\n isLast\n ? \"text-[var(--hl-foreground)]\"\n : \"text-[var(--hl-foreground-muted)]\",\n isLast && \"px-1\",\n )}\n aria-current={isLast ? \"page\" : undefined}\n >\n {crumb.label}\n </span>\n )}\n </span>\n );\n })}\n </nav>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Menu, X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarProps = {\n /**\n * Logo image src — typically `/logo.png` or `/<project>-logo-256.png`.\n * Rendered at w-36 h-36 inside a centered block with px-4 pt-6 pb-5\n * + border-b-2 (doghouse-canonical spec). Override sizing/shape via\n * `logoClassName`.\n */\n logoSrc: string;\n /** Alt text for the logo + label shown in the mobile top bar. */\n logoAlt: string;\n /**\n * Override the className applied to the logo `<img>`. Default is\n * `\"h-36 w-36 rounded-[1rem] object-contain\"` (doghouse-canonical\n * square logo). Pass `\"h-auto w-full rounded-md object-contain\"` for\n * full-width banner-style logos, or any other Tailwind classes.\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n logoClassName?: string;\n /**\n * Slots — every section is optional. Render them in this order:\n * 1. Logo block (always)\n * 2. widgets — clock / weather / homepage rotator / etc\n * 3. nav — primary navigation\n * 4. middle — long-running content (folder tree, etc) — flex-1\n * 5. lower — secondary nav (manage-tags, settings link, etc)\n * 6. (spacer)\n * 7. admin — admin section\n * 8. footer — version stamp, etc\n */\n widgets?: React.ReactNode;\n nav?: React.ReactNode;\n middle?: React.ReactNode;\n lower?: React.ReactNode;\n admin?: React.ReactNode;\n footer?: React.ReactNode;\n /**\n * Mobile-drawer behavior. Controlled — the consumer owns the open\n * state so they can wire it from anywhere (top-bar hamburger,\n * keyboard shortcut, route change, etc.).\n *\n * When omitted, the Sidebar manages its own drawer state using the\n * built-in mobile top bar (hamburger renders top-left on screens < md).\n */\n mobileOpen?: boolean;\n onMobileOpenChange?: (open: boolean) => void;\n /** Extra classes on the outer <aside>. */\n className?: string;\n /**\n * Override the className used for the `middle` slot's wrapper.\n *\n * The default — `\"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\"` —\n * is correct for asset-den's \"single panel with a scrollable folder\n * tree inside.\" Override when the middle slot's content is itself a\n * stack of cards (e.g. doghouse's per-service status widgets) — those\n * already sit on the card token, so wrapping them in another same-color\n * panel makes them disappear.\n *\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n middleClassName?: string;\n /**\n * When true, render a built-in mobile top bar (logo + hamburger) on\n * screens < md. Defaults to true. Disable when the consumer renders\n * its own top chrome.\n */\n showMobileTopBar?: boolean;\n};\n\n/**\n * Canonical homelab sidebar shell. Doghouse is the reference impl;\n * this is the extracted version every other project consumes.\n *\n * Dimensions are non-negotiable:\n * - md+ width: 15rem default (override per-project via `:root { --hl-sidebar-width: 18rem; }`)\n * - md+ position: fixed-flex in a `display:flex` parent\n * - mobile: off-canvas drawer at full sidebar width\n * - logo block: w-36 h-36 centered, px-4 pt-6 pb-5, border-b-2\n *\n * Surfaces are `bg-[var(--hl-sidebar)]` / `bg-[var(--hl-card)]` /\n * `border-[var(--hl-border)]` — arbitrary-value classes that read the\n * tokens directly, so they still work when the consumer skips the\n * Tailwind preset AND re-skin correctly when the consumer overrides the\n * vars. (They were raw hex until 0.8.0, which silently pinned every\n * consumer to the fleet palette.) Import globals.css for the defaults.\n */\nexport function Sidebar({\n logoSrc,\n logoAlt,\n logoClassName = \"h-36 w-36 rounded-[1rem] object-contain\",\n widgets,\n nav,\n middle,\n lower,\n admin,\n footer,\n mobileOpen: controlledOpen,\n onMobileOpenChange,\n className,\n middleClassName = \"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\",\n showMobileTopBar = true,\n}: SidebarProps) {\n const [internalOpen, setInternalOpen] = useState(false);\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen : internalOpen;\n\n const setOpen = (next: boolean) => {\n if (!isControlled) setInternalOpen(next);\n onMobileOpenChange?.(next);\n };\n\n // Reserve the fixed mobile top bar's height in flow (BUG #339).\n //\n // The bar is `fixed`, so it sits OVER the consumer's content and every\n // app's page <h1> was half-hidden behind it on a phone. It cannot be a\n // spacer element rendered here: this component's siblings land inside the\n // consumer's `display:flex` row (`<Sidebar/><main/>`), where a block would\n // become a zero-width flex COLUMN beside <main> and push nothing down.\n //\n // So the reservation is a `body { padding-top }` rule in globals.css, gated\n // on this class — set only while a bar is actually rendered. Five fleet apps\n // pass `showMobileTopBar={false}` and draw their own top chrome; an ungated\n // rule would give each of them 44px of dead space at the top of every phone\n // view. The bar's own min-height and that padding read the SAME\n // `--hl-mobile-topbar-height` token, so the two can never drift.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!showMobileTopBar) return;\n const root = document.documentElement;\n root.classList.add(\"hl-has-mobile-topbar\");\n return () => root.classList.remove(\"hl-has-mobile-topbar\");\n }, [showMobileTopBar]);\n\n // Lock body scroll while the mobile drawer is open.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!open) return;\n const prev = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n return () => {\n document.body.style.overflow = prev;\n };\n }, [open]);\n\n return (\n <>\n {showMobileTopBar && (\n <div className=\"hl-mobile-topbar fixed inset-x-0 top-0 z-30 flex min-h-[var(--hl-mobile-topbar-height,3.25rem)] items-center gap-2 border-b-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] px-3 py-2 md:hidden\">\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n className=\"rounded-[0.25rem] p-1.5 text-[var(--hl-foreground)] transition hover:bg-[var(--hl-card-hover)] active:scale-90\"\n aria-label=\"Open menu\"\n >\n <Menu className=\"h-5 w-5\" />\n </button>\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={logoSrc} alt=\"\" width={24} height={24} />\n <p className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {logoAlt}\n </p>\n </div>\n )}\n\n {open && (\n <div\n className=\"fixed inset-0 z-40 bg-black/70 md:hidden\"\n onClick={() => setOpen(false)}\n role=\"presentation\"\n />\n )}\n\n <aside\n className={cn(\n \"fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,15rem)] shrink-0 flex-col border-r-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] transition-transform md:static md:translate-x-0 md:h-screen\",\n open ? \"translate-x-0\" : \"-translate-x-full md:translate-x-0\",\n className,\n )}\n >\n <div className=\"relative flex flex-col items-center border-b-2 border-[var(--hl-border)] px-4 pt-6 pb-5\">\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n src={logoSrc}\n alt={logoAlt}\n className={logoClassName}\n />\n {showMobileTopBar && (\n <button\n type=\"button\"\n onClick={() => setOpen(false)}\n className=\"absolute right-3 top-3 rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] md:hidden\"\n aria-label=\"Close menu\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n\n {widgets && <div className=\"mx-3 mt-3\">{widgets}</div>}\n\n {nav && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {nav}\n </nav>\n )}\n\n {middle && <div className={middleClassName}>{middle}</div>}\n\n {lower && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {lower}\n </nav>\n )}\n\n {!middle && <div className=\"flex-1\" />}\n\n {admin && <div>{admin}</div>}\n\n {footer && (\n <div className=\"border-t-2 border-[var(--hl-border)] px-4 py-3 text-center text-xs text-[var(--hl-foreground-faint)]\">\n {footer}\n </div>\n )}\n </aside>\n </>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarNavItemProps = {\n label: string;\n icon?: React.ReactNode;\n active?: boolean;\n onClick?: () => void;\n /**\n * When set, renders as <a href>. Otherwise renders as <button>. In\n * Next.js, prefer passing `render` so you can wrap in next/link\n * without losing classes:\n *\n * <SidebarNavItem\n * label=\"Home\"\n * href=\"/\"\n * render={(props, children) => <Link {...props}>{children}</Link>}\n * />\n *\n * Spread `{...props}` rather than destructuring individual fields\n * (`{ href, className }`) — the props object also carries `aria-current`\n * for the active item, and a narrow destructure silently drops it (and\n * any future field) even though the type still says it's there.\n */\n href?: string;\n render?: (\n props: { href: string; className: string; \"aria-current\"?: \"page\" },\n children: React.ReactNode,\n ) => React.ReactNode;\n};\n\nconst ITEM_CLASS_BASE =\n \"flex items-center gap-2 rounded-[0.5rem] px-3 py-2.5 text-base font-medium transition-all\";\n\nconst itemClass = (active: boolean) =>\n cn(\n ITEM_CLASS_BASE,\n active\n ? \"bg-[var(--hl-brand)] text-[var(--hl-brand-fg)]\"\n : \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-hover-overlay)] hover:text-[var(--hl-foreground)]\",\n );\n\n/**\n * Single nav row for the Sidebar's `nav` / `lower` slots. Matches the\n * doghouse-canonical style: active = brand bg + brand-fg text, inactive =\n * muted text lifting to full foreground on hover, over a hover wash.\n *\n * Every colour here is a token. The active pill reads `--hl-brand` /\n * `--hl-brand-fg` (it was `bg-[#ff9900] text-white` until 0.8.0); the\n * inactive row reads `--hl-foreground-muted` / `--hl-hover-overlay` /\n * `--hl-foreground` (it was `text-white/45 hover:bg-white/5\n * hover:text-white` until 0.9.0). Both were themeability bugs of the same\n * shape — see globals.css.\n */\nexport function SidebarNavItem({\n label,\n icon,\n active = false,\n onClick,\n href,\n render,\n}: SidebarNavItemProps) {\n const className = itemClass(active);\n const ariaCurrent = active ? (\"page\" as const) : undefined;\n const inner = (\n <>\n {icon}\n <span>{label}</span>\n </>\n );\n\n if (href && render)\n return (\n <>{render({ href, className, \"aria-current\": ariaCurrent }, inner)}</>\n );\n if (href) {\n return (\n <a href={href} aria-current={ariaCurrent} className={className}>\n {inner}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-current={ariaCurrent}\n aria-pressed={active}\n className={`${className} w-full`}\n >\n {inner}\n </button>\n );\n}\n","\"use client\";\n\nimport { Loader2 } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SpinnerProps = {\n label?: string;\n className?: string;\n};\n\n/**\n * Consistent inline loading affordance — used wherever the app would\n * otherwise say \"Loading…\" in plain text. Compact spinner + label, on the\n * same `--hl-foreground-muted` as every other secondary line.\n */\nexport function Spinner({ label = \"Loading…\", className }: SpinnerProps) {\n return (\n <p\n className={cn(\n \"flex items-center gap-2 px-3 py-2 text-xs text-[var(--hl-foreground-muted)]\",\n className,\n )}\n >\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n {label}\n </p>\n );\n}\n","import { cn } from \"../lib/utils\";\n\nexport type StatTileProps = {\n /** Uppercase micro-label above the value. */\n label: string;\n /** The stat itself — keep it short; it truncates rather than wraps. */\n value: React.ReactNode;\n /** Optional caption under the value. The line is ALWAYS reserved so the\n * tile height never changes when a sub appears/disappears. */\n sub?: React.ReactNode;\n className?: string;\n};\n\n/**\n * Live-stat tile with STABLE GEOMETRY — the fleet standard for status\n * cards (born on zoo-docs' dashboard, 2026-07-13):\n *\n * - text truncates, never wraps → tile height is constant\n * - the sub line renders even when empty → no height jump when data\n * (queue counts, countdowns) comes and goes between polls\n * - value/sub render in font-mono (metrically monospaced) so ticking\n * numbers don't wiggle the width — tabular-nums alone doesn't do this,\n * since the fleet's Poppins/Orbitron subsets don't ship the tnum\n * OpenType feature (B264)\n *\n * Anything elastic in HEIGHT shoves the whole page around on every poll;\n * width elasticity belongs to the row (see StatTileRow).\n */\nexport function StatTile({ label, value, sub, className }: StatTileProps) {\n return (\n <div className={cn(\"min-w-0 rounded-lg bg-[var(--hl-card)] px-4 py-3\", className)}>\n <p className=\"truncate text-[10px] font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\">\n {label}\n </p>\n <p className=\"mt-1 truncate font-mono text-xl font-semibold tabular-nums text-[var(--hl-foreground)]\">{value}</p>\n <p className=\"truncate font-mono text-[11px] tabular-nums text-[var(--hl-foreground)]\">{sub ?? \" \"}</p>\n </div>\n );\n}\n\nexport type StatTileRowProps = {\n children: React.ReactNode;\n /** Minimum tile width before the grid drops a column. Default \"9rem\". */\n minTileWidth?: string;\n className?: string;\n};\n\n/**\n * Full-width, equal-column container for StatTiles. `auto-fit` +\n * `minmax(minTileWidth, 1fr)` means the row stretches edge-to-edge with\n * the browser and sheds whole columns on narrow screens — tiles never\n * overflow off-screen and never wrap their text.\n *\n * Pair with the layout standard: content pages are FULL WIDTH (no\n * max-w-* caps) unless a page explicitly opts out.\n */\nexport function StatTileRow({ children, minTileWidth = \"9rem\", className }: StatTileRowProps) {\n return (\n <div\n className={cn(\"grid gap-3\", className)}\n style={{ gridTemplateColumns: `repeat(auto-fit, minmax(${minTileWidth}, 1fr))` }}\n >\n {children}\n </div>\n );\n}\n","/**\n * Pick black or white text for a solid color chip.\n *\n * The homelab status palette forces white text on every status color\n * EXCEPT yellow (warning: #eab308), which uses black for contrast. This\n * helper enforces that rule wherever a user-picked color drives a chip\n * background (TagChips, status badges, etc).\n *\n * The yellow rule generalizes — anything in the yellow band (high\n * luminance + warm hue) gets black ink.\n */\nexport function tagTextColor(hex: string): \"#ffffff\" | \"#000000\" {\n const normalized = hex.trim().replace(/^#/, \"\");\n if (normalized.length !== 3 && normalized.length !== 6) return \"#ffffff\";\n const expand =\n normalized.length === 3\n ? normalized\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : normalized;\n const r = parseInt(expand.slice(0, 2), 16);\n const g = parseInt(expand.slice(2, 4), 16);\n const b = parseInt(expand.slice(4, 6), 16);\n if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return \"#ffffff\";\n // Perceived luminance per ITU BT.601.\n const luma = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n return luma > 0.65 ? \"#000000\" : \"#ffffff\";\n}\n","\"use client\";\n\nimport { tagTextColor } from \"../lib/colors\";\n\nexport type TagRef = {\n id: string | number;\n name: string;\n color: string;\n};\n\nexport type TagChipsProps = {\n tags: TagRef[];\n size?: \"xs\" | \"sm\";\n onClick?: (tag: TagRef) => void;\n};\n\n/**\n * Render a row of solid-color tag chips. Yellow auto-flips text to\n * black per the homelab status-palette contrast rule (see tagTextColor).\n *\n * Renders `<button>` when interactive (`onClick` set), `<span>` when\n * read-only — keeps screen-reader semantics honest.\n */\nexport function TagChips({ tags, size = \"xs\", onClick }: TagChipsProps) {\n if (tags.length === 0) return null;\n const sizeClasses =\n size === \"xs\" ? \"px-1.5 py-0.5 text-[10px]\" : \"px-2 py-0.5 text-xs\";\n\n return (\n <div className=\"flex flex-wrap gap-1\">\n {tags.map((t) => {\n const style = {\n backgroundColor: t.color,\n color: tagTextColor(t.color),\n };\n const className = `inline-block rounded-[0.25rem] font-semibold ${sizeClasses}`;\n return onClick ? (\n <button\n key={t.id}\n type=\"button\"\n onClick={() => onClick(t)}\n className={`${className} transition active:scale-95`}\n style={style}\n >\n {t.name}\n </button>\n ) : (\n <span key={t.id} className={className} style={style}>\n {t.name}\n </span>\n );\n })}\n </div>\n );\n}\n","/**\n * Font wiring for `@jdcpuwiz/homelab-ui`.\n *\n * ## The standard: three self-hosted faces, split by JOB (BuildPlan #57)\n *\n * Wiz ruling 2026-07-24. This SUPERSEDES both the old \"Geist via next/font\"\n * mandate AND the system-ui-only ruling (#345) that briefly replaced it.\n *\n * SANS Poppins — all body / UI / headings / labels\n * DISPLAY Orbitron — BIG numbers only (stat heroes, clock, ≥ ~1.5rem)\n * MONO JetBrains Mono — small values, table figures, version stamps,\n * code (< ~1.5rem)\n *\n * The size line is the hard boundary: Orbitron is a display face and must\n * NEVER be applied to a dense small cell — if a number is small, it's\n * JetBrains Mono.\n *\n * ## The package NOW OWNS THE FACES (C525) — one import, no per-app config\n *\n * import \"@jdcpuwiz/homelab-ui/fonts.css\"\n *\n * That registers all three faces from woff2 shipped in this package, so\n * `next build` never reaches fonts.gstatic.com.\n *\n * This block previously said loading had to be per-app. That was true only of\n * `next/font`: its SWC plugin scans the app's OWN source for literal\n * `const Foo = Font({...})` calls and bundling rewrites `const`→`var`, so a\n * re-exported LOADER genuinely cannot work. The constraint never applied to\n * plain `@font-face`, which is what fonts.css uses.\n *\n * - Next apps: import `fonts.css`. Nothing else — no loaders, no\n * `.variable` classes, no `--font-*` variables.\n * - Vite apps: import `fonts.css` too, or `@fontsource/poppins` etc.\n *\n * ⚠ `FONT_APP_VARIABLES` below is LEGACY. Apps still running `next/font` set\n * those vars and `globals.css` still reads them first, so both mechanisms\n * work side by side during the rollout. Do NOT reference `--font-poppins`\n * and friends in new code: once an app moves to fonts.css they are unset, and\n * `var(--x)` with no fallback ARGUMENT is invalid at computed-value time —\n * the whole declaration is discarded and the element inherits its parent's\n * face rather than reaching your authored fallback. Use `--hl-font-sans` /\n * `--hl-font-display` / `--hl-font-mono`.\n *\n * ## `next/font/google` is a BUILD-TIME NETWORK DEPENDENCY (2026-08-15)\n *\n * The Next path above makes `next build` reach fonts.gstatic.com, which means\n * every consuming app's DEPLOY depends on it. zoovault's did, and lost: its\n * image floated `next` to a release whose next/font/google requests began\n * 404ing, and the deploy died on a commit that built clean locally. The\n * root-cause fix was pinning `next` from a committed lockfile inside the image\n * (`npm ci`, never a bare `npm install`) — do that first, everywhere. Beyond\n * that, self-hosted faces remove the network from the build entirely;\n * zoovault now runs `next/font/local` over woff2 files sourced from\n * @fontsource and committed in-repo.\n *\n * Note the fallback chain in `globals.css` — `var(--font-poppins, \"Poppins\")`.\n * The package asks for the CSS var and then for the LITERAL FAMILY NAME, so\n * anything that registers a \"Poppins\" @font-face satisfies it. next/font is a\n * convention here, not a requirement, and the Vite path above is proof.\n *\n * `globals.css` declares `--hl-font-sans` / `--hl-font-display` /\n * `--hl-font-mono` referencing the app variables below first, then the literal\n * face name, then the platform fallback — so text is readable even mid-load\n * and an un-migrated app degrades to the system stack, never to nothing.\n *\n * Sanity check after deploy — this should name Poppins:\n * getComputedStyle(document.body).fontFamily // → \"…Poppins…\"\n */\n\n/**\n * The package's own token names — the `--hl-font-*` vars the Tailwind preset's\n * `font-sans` / `font-mono` / `font-display` classes resolve to. Consumers\n * rarely touch these directly.\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--hl-font-sans\",\n mono: \"--hl-font-mono\",\n display: \"--hl-font-display\",\n} as const;\n\n/**\n * The app-side CSS variable each next/font loader must expose (its `variable:`\n * option). `globals.css` reads these, so an app that follows this naming gets\n * the three faces wired with zero extra config. Match these EXACTLY.\n *\n * const poppins = Poppins({ ..., variable: FONT_APP_VARIABLES.sans });\n */\nexport const FONT_APP_VARIABLES = {\n sans: \"--font-poppins\",\n display: \"--font-orbitron\",\n mono: \"--font-jetbrains-mono\",\n} as const;\n"],"mappings":"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","names":["Button","useEffect","jsx","jsx","jsxs","useEffect","jsx","jsxs","useEffect","Fragment","jsx","jsxs","useEffect","useEffect","useState","X","Fragment","jsx","jsxs","useState","useEffect","X","Fragment","jsx","jsxs","jsx","jsxs","jsx","jsxs","jsx"]}
1
+ {"version":3,"sources":["../src/components/Button.tsx","../src/lib/utils.ts","../src/components/ConfirmDialog.tsx","../src/components/Modal.tsx","../src/components/EmptyState.tsx","../src/components/PageHeader.tsx","../src/components/Sidebar.tsx","../src/components/SidebarNavItem.tsx","../src/components/Spinner.tsx","../src/components/StatTile.tsx","../src/lib/colors.ts","../src/components/TagChips.tsx","../src/lib/fonts.ts"],"sourcesContent":["\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { cn } from \"../lib/utils\";\n\ntype Variant = \"primary\" | \"secondary\" | \"tertiary\" | \"danger\";\ntype Size = \"sm\" | \"md\";\n\nexport type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {\n variant?: Variant;\n size?: Size;\n};\n\nconst BASE =\n \"inline-flex items-center justify-center gap-1.5 rounded-[0.5rem] font-semibold transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--hl-brand-glow)]\";\n\n// `danger`'s BACKGROUND stays a literal — status colors are a fixed fleet\n// semantic (the preset hardcodes them too), not a per-project brand token.\n// Its label stays literal `text-white` for the same reason: that is text\n// ON a status chip, which lib/colors.ts already rules is always white,\n// NOT reading text on an app surface. Everything else here is a token.\nconst VARIANT: Record<Variant, string> = {\n primary:\n \"bg-[var(--hl-brand)] text-[var(--hl-brand-fg)] hover:bg-[var(--hl-brand-hover)]\",\n secondary:\n \"bg-[var(--hl-card)] text-[var(--hl-foreground)] hover:bg-[var(--hl-card-hover)]\",\n tertiary:\n \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)]\",\n danger: \"bg-[#b91c1c] text-white hover:brightness-110\",\n};\n\nconst SIZE: Record<Size, string> = {\n sm: \"px-3 py-1 text-xs\",\n md: \"px-4 py-1.5 text-sm\",\n};\n\n/**\n * Homelab Button. Use the variant + size props; never pass raw color\n * classes through className. className is for layout extras only\n * (e.g. `w-full`).\n */\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n function Button(\n { variant = \"secondary\", size = \"md\", className, ...rest },\n ref,\n ) {\n return (\n <button\n ref={ref}\n {...rest}\n className={cn(BASE, VARIANT[variant], SIZE[size], className)}\n />\n );\n },\n);\n","import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Conditional Tailwind class joiner. Merges variants and resolves\n * conflicts (e.g. `cn(\"p-2\", \"p-4\")` → `\"p-4\"`).\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","\"use client\";\n\nimport { useEffect } from \"react\";\nimport { Modal } from \"./Modal\";\nimport { Button } from \"./Button\";\n\nexport type ConfirmDialogProps = {\n open: boolean;\n title: string;\n message: string;\n confirmLabel?: string;\n cancelLabel?: string;\n tone?: \"danger\" | \"primary\";\n onConfirm: () => void;\n onClose: () => void;\n};\n\n/**\n * Confirm/cancel dialog built on Modal + Button. Enter triggers confirm,\n * Esc cancels (via Modal's built-in handling).\n */\nexport function ConfirmDialog({\n open,\n title,\n message,\n confirmLabel = \"Confirm\",\n cancelLabel = \"Cancel\",\n tone = \"danger\",\n onConfirm,\n onClose,\n}: ConfirmDialogProps) {\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Enter\") onConfirm();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onConfirm]);\n\n return (\n <Modal\n open={open}\n onClose={onClose}\n title={title}\n footer={\n <>\n <Button variant=\"tertiary\" size=\"md\" onClick={onClose}>\n {cancelLabel}\n </Button>\n <Button\n variant={tone === \"danger\" ? \"danger\" : \"primary\"}\n size=\"md\"\n onClick={onConfirm}\n >\n {confirmLabel}\n </Button>\n </>\n }\n >\n <p className=\"whitespace-pre-line text-sm text-[var(--hl-foreground)]\">{message}</p>\n </Modal>\n );\n}\n","\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\ntype Density = \"form\" | \"media\";\n\nexport type ModalProps = {\n open: boolean;\n onClose: () => void;\n title?: string;\n /** Show the close X in the top right. Default: true when title is set. */\n showClose?: boolean;\n /**\n * Overlay weight:\n * `form` — dialogs / pickers / confirms (`bg-black/70`)\n * `media` — lightbox / asset preview (`bg-black/90`)\n * Defaults to `form`.\n */\n density?: Density;\n /** Max width — default `max-w-md`. */\n maxWidth?: string;\n /** Footer slot — render buttons here. Always right-aligned. */\n footer?: React.ReactNode;\n children: React.ReactNode;\n};\n\n/**\n * Single modal frame. Every dialog routes through this:\n * - overlay + click-outside dismiss\n * - Esc-to-close\n * - role=dialog + aria-modal + aria-labelledby wiring\n * - DESIGN.md header treatment (uppercase tracking-widest)\n * - shared right-aligned footer row\n */\nexport function Modal({\n open,\n onClose,\n title,\n showClose,\n density = \"form\",\n maxWidth = \"max-w-md\",\n footer,\n children,\n}: ModalProps) {\n const titleIdRef = useRef(\n `hl-modal-${Math.random().toString(36).slice(2, 8)}`,\n );\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") onClose();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onClose]);\n\n if (!open) return null;\n\n const showCloseBtn = showClose ?? Boolean(title);\n const overlayClass = density === \"media\" ? \"bg-black/90\" : \"bg-black/70\";\n\n return (\n <div\n className={cn(\n \"fixed inset-0 z-50 flex items-center justify-center p-4\",\n overlayClass,\n )}\n onClick={onClose}\n role=\"presentation\"\n >\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={title ? titleIdRef.current : undefined}\n className={cn(\n \"w-full rounded-[1rem] border-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] p-5\",\n maxWidth,\n )}\n onClick={(e) => e.stopPropagation()}\n >\n {(title || showCloseBtn) && (\n <div className=\"mb-4 flex items-start justify-between gap-3\">\n {title && (\n <h2\n id={titleIdRef.current}\n className=\"text-sm font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\"\n >\n {title}\n </h2>\n )}\n {showCloseBtn && (\n <button\n type=\"button\"\n onClick={onClose}\n className=\"ml-auto rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] active:scale-90\"\n aria-label=\"Close\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n )}\n {children}\n {footer && (\n <div className=\"mt-5 flex flex-wrap items-center justify-end gap-2\">\n {footer}\n </div>\n )}\n </div>\n </div>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type EmptyStateProps = {\n icon?: React.ReactNode;\n title?: string;\n message: string;\n action?: React.ReactNode;\n /**\n * `panel` — large empty state with warm card background (page bodies).\n * `inline` — compact text-only line for sidebar tree, popovers, etc.\n */\n variant?: \"panel\" | \"inline\";\n className?: string;\n};\n\nexport function EmptyState({\n icon,\n title,\n message,\n action,\n variant = \"panel\",\n className,\n}: EmptyStateProps) {\n if (variant === \"inline\") {\n return (\n <p className={cn(\"px-3 py-2 text-xs text-[var(--hl-foreground)]\", className)}>\n {message}\n </p>\n );\n }\n return (\n <div\n className={cn(\n \"flex flex-col items-center justify-center gap-3 rounded-[1rem] bg-[var(--hl-card-warm)] p-12 text-center\",\n className,\n )}\n >\n {icon && (\n <div className=\"text-[var(--hl-foreground-faint)]\">{icon}</div>\n )}\n {title && (\n <h3 className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {title}\n </h3>\n )}\n <p className=\"text-sm text-[var(--hl-foreground)]\">{message}</p>\n {action}\n </div>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { ArrowLeft, ChevronRight, ImageOff } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type Crumb = {\n label: string;\n onClick?: () => void;\n};\n\nexport type PageHeaderProps = {\n title: string;\n description?: string;\n /** Back link rendered above the title. */\n back?: { href: string; label: string; render?: (href: string, children: React.ReactNode) => React.ReactNode };\n /** Right-aligned actions slot. */\n actions?: React.ReactNode;\n /**\n * Header image URL (e.g. `/logos.png`). When the URL resolves, the\n * image is the visual title. When it 404s, a styled placeholder at\n * the same dimensions takes its place — the layout stays stable so\n * creating a new themed page \"just works\" the moment you drop a\n * matching PNG in /public/.\n *\n * When `imageSrc` is undefined, no image block renders and the\n * text title is the visual title.\n */\n imageSrc?: string;\n imageAlt?: string;\n /**\n * Breadcrumb trail. When provided, replaces the text title. Last\n * crumb is current (full foreground + bold); earlier crumbs are muted\n * and clickable.\n */\n breadcrumb?: Crumb[];\n className?: string;\n};\n\n/**\n * Page-header block. The image block reserves space whenever `imageSrc`\n * is set — drop the PNG in /public/ and the layout just fills in.\n *\n * Text title rules:\n * - imageSrc set + image present → text becomes sr-only\n * - imageSrc set + image 404 → placeholder block + text becomes sr-only\n * - imageSrc unset → text title is visible\n * - breadcrumb provided → breadcrumb replaces text title regardless of image\n *\n * The back link uses a plain <a> by default. In Next.js, pass\n * `back.render` to use next/link:\n *\n * import Link from \"next/link\";\n * <PageHeader back={{ href: \"/foo\", label: \"Back\", render: (href, c) => <Link href={href}>{c}</Link> }} />\n */\nexport function PageHeader({\n title,\n description,\n back,\n actions,\n imageSrc,\n imageAlt,\n breadcrumb,\n className,\n}: PageHeaderProps) {\n const [imageErrored, setImageErrored] = useState(false);\n useEffect(() => {\n setImageErrored(false);\n }, [imageSrc]);\n\n const hasImageSlot = !!imageSrc;\n const imageRenders = hasImageSlot && !imageErrored;\n const hasBreadcrumb = Array.isArray(breadcrumb) && breadcrumb.length > 0;\n const textIsSrOnly = hasImageSlot;\n\n const backInner = back ? (\n <>\n <ArrowLeft className=\"h-3 w-3\" />\n {back.label}\n </>\n ) : null;\n\n return (\n <header\n className={cn(\n \"flex flex-wrap items-end justify-between gap-3\",\n className,\n )}\n >\n <div className=\"flex min-w-0 items-end gap-4\">\n {hasImageSlot &&\n (imageRenders ? (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={imageSrc}\n alt={imageAlt ?? title}\n onError={() => setImageErrored(true)}\n className=\"h-28 w-28 shrink-0 rounded-[1rem] object-contain md:h-32 md:w-32\"\n />\n ) : (\n <PlaceholderHeader title={title} imageSrc={imageSrc!} />\n ))}\n <div className=\"min-w-0\">\n {back &&\n (back.render ? (\n back.render(\n back.href,\n <span className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\">\n {backInner}\n </span>,\n )\n ) : (\n <a\n href={back.href}\n className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {backInner}\n </a>\n ))}\n {hasBreadcrumb ? (\n <Breadcrumb\n crumbs={breadcrumb!}\n srTitle={title}\n className={back ? \"mt-1\" : \"\"}\n />\n ) : (\n <h1\n className={cn(\n \"text-xl font-semibold text-[var(--hl-title)]\",\n back && \"mt-1\",\n textIsSrOnly && \"sr-only\",\n )}\n >\n {title}\n </h1>\n )}\n {description && (\n <p className=\"mt-1 text-xs text-[var(--hl-foreground)]\">{description}</p>\n )}\n </div>\n </div>\n {actions && <div className=\"flex items-center gap-2\">{actions}</div>}\n </header>\n );\n}\n\nfunction PlaceholderHeader({\n title,\n imageSrc,\n}: {\n title: string;\n imageSrc: string;\n}) {\n const file = imageSrc.replace(/^\\//, \"\");\n return (\n <div\n title={`Drop ${file} in /public to brand this header`}\n className=\"flex h-28 w-28 shrink-0 flex-col items-center justify-center gap-1 rounded-[1rem] border-2 border-dashed border-[var(--hl-border)] bg-[var(--hl-card-warm)] text-[var(--hl-foreground-faint)] md:h-32 md:w-32\"\n >\n <ImageOff className=\"h-6 w-6\" />\n <span className=\"px-2 text-center text-[10px] font-semibold uppercase tracking-widest\">\n {title}\n </span>\n </div>\n );\n}\n\nfunction Breadcrumb({\n crumbs,\n srTitle,\n className,\n}: {\n crumbs: Crumb[];\n srTitle: string;\n className?: string;\n}) {\n return (\n <nav\n aria-label=\"Breadcrumb\"\n className={cn(\n \"flex flex-wrap items-center gap-1.5 text-xl font-semibold\",\n className,\n )}\n >\n <h1 className=\"sr-only\">{srTitle}</h1>\n {crumbs.map((crumb, idx) => {\n const isLast = idx === crumbs.length - 1;\n return (\n <span\n key={`${crumb.label}-${idx}`}\n className=\"flex items-center gap-1.5\"\n >\n {idx > 0 && (\n <ChevronRight\n className=\"h-4 w-4 shrink-0 text-[var(--hl-foreground-faint)]\"\n aria-hidden=\"true\"\n />\n )}\n {crumb.onClick && !isLast ? (\n <button\n type=\"button\"\n onClick={crumb.onClick}\n className=\"rounded px-1 text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {crumb.label}\n </button>\n ) : (\n <span\n className={cn(\n isLast\n ? \"text-[var(--hl-foreground)]\"\n : \"text-[var(--hl-foreground-muted)]\",\n isLast && \"px-1\",\n )}\n aria-current={isLast ? \"page\" : undefined}\n >\n {crumb.label}\n </span>\n )}\n </span>\n );\n })}\n </nav>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Menu, X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarProps = {\n /**\n * Logo image src — typically `/logo.png` or `/<project>-logo-256.png`.\n * Rendered at w-36 h-36 inside a centered block with px-4 pt-6 pb-5\n * + border-b-2 (doghouse-canonical spec). Override sizing/shape via\n * `logoClassName`.\n */\n logoSrc: string;\n /** Alt text for the logo + label shown in the mobile top bar. */\n logoAlt: string;\n /**\n * Override the className applied to the logo `<img>`. Default is\n * `\"h-36 w-36 rounded-[1rem] object-contain\"` (doghouse-canonical\n * square logo). Pass `\"h-auto w-full rounded-md object-contain\"` for\n * full-width banner-style logos, or any other Tailwind classes.\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n logoClassName?: string;\n /**\n * Slots — every section is optional. Render them in this order:\n * 1. Logo block (always)\n * 2. widgets — clock / weather / homepage rotator / etc\n * 3. nav — primary navigation\n * 4. middle — long-running content (folder tree, etc) — flex-1\n * 5. lower — secondary nav (manage-tags, settings link, etc)\n * 6. (spacer)\n * 7. admin — admin section\n * 8. footer — version stamp, etc\n */\n widgets?: React.ReactNode;\n nav?: React.ReactNode;\n middle?: React.ReactNode;\n lower?: React.ReactNode;\n admin?: React.ReactNode;\n footer?: React.ReactNode;\n /**\n * Mobile-drawer behavior. Controlled — the consumer owns the open\n * state so they can wire it from anywhere (top-bar hamburger,\n * keyboard shortcut, route change, etc.).\n *\n * When omitted, the Sidebar manages its own drawer state using the\n * built-in mobile top bar (hamburger renders top-left on screens < md).\n */\n mobileOpen?: boolean;\n onMobileOpenChange?: (open: boolean) => void;\n /** Extra classes on the outer <aside>. */\n className?: string;\n /**\n * Override the className used for the `middle` slot's wrapper.\n *\n * The default — `\"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\"` —\n * is correct for asset-den's \"single panel with a scrollable folder\n * tree inside.\" Override when the middle slot's content is itself a\n * stack of cards (e.g. doghouse's per-service status widgets) — those\n * already sit on the card token, so wrapping them in another same-color\n * panel makes them disappear.\n *\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n middleClassName?: string;\n /**\n * When true, render a built-in mobile top bar (logo + hamburger) on\n * screens < md. Defaults to true. Disable when the consumer renders\n * its own top chrome.\n */\n showMobileTopBar?: boolean;\n};\n\n/**\n * Canonical homelab sidebar shell. Doghouse is the reference impl;\n * this is the extracted version every other project consumes.\n *\n * Dimensions are non-negotiable:\n * - md+ width: 240px default, fixed pixels — independent of root font-size\n * (B358) (override per-project via `:root { --hl-sidebar-width: 18rem; }`)\n * - md+ position: fixed-flex in a `display:flex` parent\n * - mobile: off-canvas drawer at full sidebar width\n * - logo block: w-36 h-36 centered, px-4 pt-6 pb-5, border-b-2\n *\n * Surfaces are `bg-[var(--hl-sidebar)]` / `bg-[var(--hl-card)]` /\n * `border-[var(--hl-border)]` — arbitrary-value classes that read the\n * tokens directly, so they still work when the consumer skips the\n * Tailwind preset AND re-skin correctly when the consumer overrides the\n * vars. (They were raw hex until 0.8.0, which silently pinned every\n * consumer to the fleet palette.) Import globals.css for the defaults.\n */\nexport function Sidebar({\n logoSrc,\n logoAlt,\n logoClassName = \"h-36 w-36 rounded-[1rem] object-contain\",\n widgets,\n nav,\n middle,\n lower,\n admin,\n footer,\n mobileOpen: controlledOpen,\n onMobileOpenChange,\n className,\n middleClassName = \"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\",\n showMobileTopBar = true,\n}: SidebarProps) {\n const [internalOpen, setInternalOpen] = useState(false);\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen : internalOpen;\n\n const setOpen = (next: boolean) => {\n if (!isControlled) setInternalOpen(next);\n onMobileOpenChange?.(next);\n };\n\n // Reserve the fixed mobile top bar's height in flow (BUG #339).\n //\n // The bar is `fixed`, so it sits OVER the consumer's content and every\n // app's page <h1> was half-hidden behind it on a phone. It cannot be a\n // spacer element rendered here: this component's siblings land inside the\n // consumer's `display:flex` row (`<Sidebar/><main/>`), where a block would\n // become a zero-width flex COLUMN beside <main> and push nothing down.\n //\n // So the reservation is a `body { padding-top }` rule in globals.css, gated\n // on this class — set only while a bar is actually rendered. Five fleet apps\n // pass `showMobileTopBar={false}` and draw their own top chrome; an ungated\n // rule would give each of them 44px of dead space at the top of every phone\n // view. The bar's own min-height and that padding read the SAME\n // `--hl-mobile-topbar-height` token, so the two can never drift.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!showMobileTopBar) return;\n const root = document.documentElement;\n root.classList.add(\"hl-has-mobile-topbar\");\n return () => root.classList.remove(\"hl-has-mobile-topbar\");\n }, [showMobileTopBar]);\n\n // Lock body scroll while the mobile drawer is open.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!open) return;\n const prev = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n return () => {\n document.body.style.overflow = prev;\n };\n }, [open]);\n\n return (\n <>\n {showMobileTopBar && (\n <div className=\"hl-mobile-topbar fixed inset-x-0 top-0 z-30 flex min-h-[var(--hl-mobile-topbar-height,3.25rem)] items-center gap-2 border-b-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] px-3 py-2 md:hidden\">\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n className=\"rounded-[0.25rem] p-1.5 text-[var(--hl-foreground)] transition hover:bg-[var(--hl-card-hover)] active:scale-90\"\n aria-label=\"Open menu\"\n >\n <Menu className=\"h-5 w-5\" />\n </button>\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={logoSrc} alt=\"\" width={24} height={24} />\n <p className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {logoAlt}\n </p>\n </div>\n )}\n\n {open && (\n <div\n className=\"fixed inset-0 z-40 bg-black/70 md:hidden\"\n onClick={() => setOpen(false)}\n role=\"presentation\"\n />\n )}\n\n <aside\n className={cn(\n \"fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,240px)] shrink-0 flex-col border-r-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] transition-transform md:static md:translate-x-0 md:h-screen\",\n open ? \"translate-x-0\" : \"-translate-x-full md:translate-x-0\",\n className,\n )}\n >\n <div className=\"relative flex flex-col items-center border-b-2 border-[var(--hl-border)] px-4 pt-6 pb-5\">\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n src={logoSrc}\n alt={logoAlt}\n className={logoClassName}\n />\n {showMobileTopBar && (\n <button\n type=\"button\"\n onClick={() => setOpen(false)}\n className=\"absolute right-3 top-3 rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] md:hidden\"\n aria-label=\"Close menu\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n\n {widgets && <div className=\"mx-3 mt-3\">{widgets}</div>}\n\n {nav && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {nav}\n </nav>\n )}\n\n {middle && <div className={middleClassName}>{middle}</div>}\n\n {lower && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {lower}\n </nav>\n )}\n\n {!middle && <div className=\"flex-1\" />}\n\n {admin && <div>{admin}</div>}\n\n {footer && (\n <div className=\"border-t-2 border-[var(--hl-border)] px-4 py-3 text-center text-xs text-[var(--hl-foreground-faint)]\">\n {footer}\n </div>\n )}\n </aside>\n </>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarNavItemProps = {\n label: string;\n icon?: React.ReactNode;\n active?: boolean;\n onClick?: () => void;\n /**\n * When set, renders as <a href>. Otherwise renders as <button>. In\n * Next.js, prefer passing `render` so you can wrap in next/link\n * without losing classes:\n *\n * <SidebarNavItem\n * label=\"Home\"\n * href=\"/\"\n * render={(props, children) => <Link {...props}>{children}</Link>}\n * />\n *\n * Spread `{...props}` rather than destructuring individual fields\n * (`{ href, className }`) — the props object also carries `aria-current`\n * for the active item, and a narrow destructure silently drops it (and\n * any future field) even though the type still says it's there.\n */\n href?: string;\n render?: (\n props: { href: string; className: string; \"aria-current\"?: \"page\" },\n children: React.ReactNode,\n ) => React.ReactNode;\n};\n\nconst ITEM_CLASS_BASE =\n \"flex items-center gap-2 rounded-[0.5rem] px-3 py-2.5 text-base font-medium transition-all\";\n\nconst itemClass = (active: boolean) =>\n cn(\n ITEM_CLASS_BASE,\n active\n ? \"bg-[var(--hl-brand)] text-[var(--hl-brand-fg)]\"\n : \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-hover-overlay)] hover:text-[var(--hl-foreground)]\",\n );\n\n/**\n * Single nav row for the Sidebar's `nav` / `lower` slots. Matches the\n * doghouse-canonical style: active = brand bg + brand-fg text, inactive =\n * muted text lifting to full foreground on hover, over a hover wash.\n *\n * Every colour here is a token. The active pill reads `--hl-brand` /\n * `--hl-brand-fg` (it was `bg-[#ff9900] text-white` until 0.8.0); the\n * inactive row reads `--hl-foreground-muted` / `--hl-hover-overlay` /\n * `--hl-foreground` (it was `text-white/45 hover:bg-white/5\n * hover:text-white` until 0.9.0). Both were themeability bugs of the same\n * shape — see globals.css.\n */\nexport function SidebarNavItem({\n label,\n icon,\n active = false,\n onClick,\n href,\n render,\n}: SidebarNavItemProps) {\n const className = itemClass(active);\n const ariaCurrent = active ? (\"page\" as const) : undefined;\n const inner = (\n <>\n {icon}\n <span>{label}</span>\n </>\n );\n\n if (href && render)\n return (\n <>{render({ href, className, \"aria-current\": ariaCurrent }, inner)}</>\n );\n if (href) {\n return (\n <a href={href} aria-current={ariaCurrent} className={className}>\n {inner}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-current={ariaCurrent}\n aria-pressed={active}\n className={`${className} w-full`}\n >\n {inner}\n </button>\n );\n}\n","\"use client\";\n\nimport { Loader2 } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SpinnerProps = {\n label?: string;\n className?: string;\n};\n\n/**\n * Consistent inline loading affordance — used wherever the app would\n * otherwise say \"Loading…\" in plain text. Compact spinner + label, on the\n * same `--hl-foreground-muted` as every other secondary line.\n */\nexport function Spinner({ label = \"Loading…\", className }: SpinnerProps) {\n return (\n <p\n className={cn(\n \"flex items-center gap-2 px-3 py-2 text-xs text-[var(--hl-foreground-muted)]\",\n className,\n )}\n >\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n {label}\n </p>\n );\n}\n","import { cn } from \"../lib/utils\";\n\nexport type StatTileProps = {\n /** Uppercase micro-label above the value. */\n label: string;\n /** The stat itself — keep it short; it truncates rather than wraps. */\n value: React.ReactNode;\n /** Optional caption under the value. The line is ALWAYS reserved so the\n * tile height never changes when a sub appears/disappears. */\n sub?: React.ReactNode;\n className?: string;\n};\n\n/**\n * Live-stat tile with STABLE GEOMETRY — the fleet standard for status\n * cards (born on zoo-docs' dashboard, 2026-07-13):\n *\n * - text truncates, never wraps → tile height is constant\n * - the sub line renders even when empty → no height jump when data\n * (queue counts, countdowns) comes and goes between polls\n * - value/sub render in font-mono (metrically monospaced) so ticking\n * numbers don't wiggle the width — tabular-nums alone doesn't do this,\n * since the fleet's Poppins/Orbitron subsets don't ship the tnum\n * OpenType feature (B264)\n *\n * Anything elastic in HEIGHT shoves the whole page around on every poll;\n * width elasticity belongs to the row (see StatTileRow).\n */\nexport function StatTile({ label, value, sub, className }: StatTileProps) {\n return (\n <div className={cn(\"min-w-0 rounded-lg bg-[var(--hl-card)] px-4 py-3\", className)}>\n <p className=\"truncate text-[10px] font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\">\n {label}\n </p>\n <p className=\"mt-1 truncate font-mono text-xl font-semibold tabular-nums text-[var(--hl-foreground)]\">{value}</p>\n <p className=\"truncate font-mono text-[11px] tabular-nums text-[var(--hl-foreground)]\">{sub ?? \" \"}</p>\n </div>\n );\n}\n\nexport type StatTileRowProps = {\n children: React.ReactNode;\n /** Minimum tile width before the grid drops a column. Default \"9rem\". */\n minTileWidth?: string;\n className?: string;\n};\n\n/**\n * Full-width, equal-column container for StatTiles. `auto-fit` +\n * `minmax(minTileWidth, 1fr)` means the row stretches edge-to-edge with\n * the browser and sheds whole columns on narrow screens — tiles never\n * overflow off-screen and never wrap their text.\n *\n * Pair with the layout standard: content pages are FULL WIDTH (no\n * max-w-* caps) unless a page explicitly opts out.\n */\nexport function StatTileRow({ children, minTileWidth = \"9rem\", className }: StatTileRowProps) {\n return (\n <div\n className={cn(\"grid gap-3\", className)}\n style={{ gridTemplateColumns: `repeat(auto-fit, minmax(${minTileWidth}, 1fr))` }}\n >\n {children}\n </div>\n );\n}\n","/**\n * Pick black or white text for a solid color chip.\n *\n * The homelab status palette forces white text on every status color\n * EXCEPT yellow (warning: #eab308), which uses black for contrast. This\n * helper enforces that rule wherever a user-picked color drives a chip\n * background (TagChips, status badges, etc).\n *\n * The yellow rule generalizes — anything in the yellow band (high\n * luminance + warm hue) gets black ink.\n */\nexport function tagTextColor(hex: string): \"#ffffff\" | \"#000000\" {\n const normalized = hex.trim().replace(/^#/, \"\");\n if (normalized.length !== 3 && normalized.length !== 6) return \"#ffffff\";\n const expand =\n normalized.length === 3\n ? normalized\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : normalized;\n const r = parseInt(expand.slice(0, 2), 16);\n const g = parseInt(expand.slice(2, 4), 16);\n const b = parseInt(expand.slice(4, 6), 16);\n if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return \"#ffffff\";\n // Perceived luminance per ITU BT.601.\n const luma = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n return luma > 0.65 ? \"#000000\" : \"#ffffff\";\n}\n","\"use client\";\n\nimport { tagTextColor } from \"../lib/colors\";\n\nexport type TagRef = {\n id: string | number;\n name: string;\n color: string;\n};\n\nexport type TagChipsProps = {\n tags: TagRef[];\n size?: \"xs\" | \"sm\";\n onClick?: (tag: TagRef) => void;\n};\n\n/**\n * Render a row of solid-color tag chips. Yellow auto-flips text to\n * black per the homelab status-palette contrast rule (see tagTextColor).\n *\n * Renders `<button>` when interactive (`onClick` set), `<span>` when\n * read-only — keeps screen-reader semantics honest.\n */\nexport function TagChips({ tags, size = \"xs\", onClick }: TagChipsProps) {\n if (tags.length === 0) return null;\n const sizeClasses =\n size === \"xs\" ? \"px-1.5 py-0.5 text-[10px]\" : \"px-2 py-0.5 text-xs\";\n\n return (\n <div className=\"flex flex-wrap gap-1\">\n {tags.map((t) => {\n const style = {\n backgroundColor: t.color,\n color: tagTextColor(t.color),\n };\n const className = `inline-block rounded-[0.25rem] font-semibold ${sizeClasses}`;\n return onClick ? (\n <button\n key={t.id}\n type=\"button\"\n onClick={() => onClick(t)}\n className={`${className} transition active:scale-95`}\n style={style}\n >\n {t.name}\n </button>\n ) : (\n <span key={t.id} className={className} style={style}>\n {t.name}\n </span>\n );\n })}\n </div>\n );\n}\n","/**\n * Font wiring for `@jdcpuwiz/homelab-ui`.\n *\n * ## The standard: three self-hosted faces, split by JOB (BuildPlan #57)\n *\n * Wiz ruling 2026-07-24. This SUPERSEDES both the old \"Geist via next/font\"\n * mandate AND the system-ui-only ruling (#345) that briefly replaced it.\n *\n * SANS Poppins — all body / UI / headings / labels\n * DISPLAY Orbitron — BIG numbers only (stat heroes, clock, ≥ ~1.5rem)\n * MONO JetBrains Mono — small values, table figures, version stamps,\n * code (< ~1.5rem)\n *\n * The size line is the hard boundary: Orbitron is a display face and must\n * NEVER be applied to a dense small cell — if a number is small, it's\n * JetBrains Mono.\n *\n * ## The package NOW OWNS THE FACES (C525) — one import, no per-app config\n *\n * import \"@jdcpuwiz/homelab-ui/fonts.css\"\n *\n * That registers all three faces from woff2 shipped in this package, so\n * `next build` never reaches fonts.gstatic.com.\n *\n * This block previously said loading had to be per-app. That was true only of\n * `next/font`: its SWC plugin scans the app's OWN source for literal\n * `const Foo = Font({...})` calls and bundling rewrites `const`→`var`, so a\n * re-exported LOADER genuinely cannot work. The constraint never applied to\n * plain `@font-face`, which is what fonts.css uses.\n *\n * - Next apps: import `fonts.css`. Nothing else — no loaders, no\n * `.variable` classes, no `--font-*` variables.\n * - Vite apps: import `fonts.css` too, or `@fontsource/poppins` etc.\n *\n * ⚠ `FONT_APP_VARIABLES` below is LEGACY. Apps still running `next/font` set\n * those vars and `globals.css` still reads them first, so both mechanisms\n * work side by side during the rollout. Do NOT reference `--font-poppins`\n * and friends in new code: once an app moves to fonts.css they are unset, and\n * `var(--x)` with no fallback ARGUMENT is invalid at computed-value time —\n * the whole declaration is discarded and the element inherits its parent's\n * face rather than reaching your authored fallback. Use `--hl-font-sans` /\n * `--hl-font-display` / `--hl-font-mono`.\n *\n * ## `next/font/google` is a BUILD-TIME NETWORK DEPENDENCY (2026-08-15)\n *\n * The Next path above makes `next build` reach fonts.gstatic.com, which means\n * every consuming app's DEPLOY depends on it. zoovault's did, and lost: its\n * image floated `next` to a release whose next/font/google requests began\n * 404ing, and the deploy died on a commit that built clean locally. The\n * root-cause fix was pinning `next` from a committed lockfile inside the image\n * (`npm ci`, never a bare `npm install`) — do that first, everywhere. Beyond\n * that, self-hosted faces remove the network from the build entirely;\n * zoovault now runs `next/font/local` over woff2 files sourced from\n * @fontsource and committed in-repo.\n *\n * Note the fallback chain in `globals.css` — `var(--font-poppins, \"Poppins\")`.\n * The package asks for the CSS var and then for the LITERAL FAMILY NAME, so\n * anything that registers a \"Poppins\" @font-face satisfies it. next/font is a\n * convention here, not a requirement, and the Vite path above is proof.\n *\n * `globals.css` declares `--hl-font-sans` / `--hl-font-display` /\n * `--hl-font-mono` referencing the app variables below first, then the literal\n * face name, then the platform fallback — so text is readable even mid-load\n * and an un-migrated app degrades to the system stack, never to nothing.\n *\n * Sanity check after deploy — this should name Poppins:\n * getComputedStyle(document.body).fontFamily // → \"…Poppins…\"\n */\n\n/**\n * The package's own token names — the `--hl-font-*` vars the Tailwind preset's\n * `font-sans` / `font-mono` / `font-display` classes resolve to. Consumers\n * rarely touch these directly.\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--hl-font-sans\",\n mono: \"--hl-font-mono\",\n display: \"--hl-font-display\",\n} as const;\n\n/**\n * The app-side CSS variable each next/font loader must expose (its `variable:`\n * option). `globals.css` reads these, so an app that follows this naming gets\n * the three faces wired with zero extra config. Match these EXACTLY.\n *\n * const poppins = Poppins({ ..., variable: FONT_APP_VARIABLES.sans });\n */\nexport const FONT_APP_VARIABLES = {\n sans: \"--font-poppins\",\n display: \"--font-orbitron\",\n mono: \"--font-jetbrains-mono\",\n} as const;\n"],"mappings":"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","names":["Button","useEffect","jsx","jsx","jsxs","useEffect","jsx","jsxs","useEffect","Fragment","jsx","jsxs","useEffect","useEffect","useState","X","Fragment","jsx","jsxs","useState","useEffect","X","Fragment","jsx","jsxs","jsx","jsxs","jsx","jsxs","jsx"]}
package/globals.css CHANGED
@@ -97,9 +97,12 @@
97
97
  foreground. */
98
98
  --hl-title: var(--hl-foreground);
99
99
 
100
- /* Sidebar shell width — md+ flex column. Override per project, e.g.
101
- `:root { --hl-sidebar-width: 18rem; }` in your own globals.css. */
102
- --hl-sidebar-width: 15rem;
100
+ /* Sidebar shell width — md+ flex column. 240px is the Wiz-ratified fleet
101
+ standard (B358, 2026-09-02); PIXELS on purpose — an rem value floats
102
+ with an app's root font-size (that's exactly how this drifted to 210
103
+ the first time), so this must not scale with it. Override per project,
104
+ e.g. `:root { --hl-sidebar-width: 18rem; }` in your own globals.css. */
105
+ --hl-sidebar-width: 240px;
103
106
 
104
107
  /* Typography — THREE self-hosted faces, split by JOB (Wiz ruling
105
108
  2026-07-24, BuildPlan #57). This SUPERSEDES both the old "Geist via
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jdcpuwiz/homelab-ui",
3
- "version": "0.10.6",
3
+ "version": "0.11.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 the sanctioned three-face typography (Poppins / Orbitron / JetBrains Mono) on one flat #f0f1f4 text color.",
5
5
  "license": "MIT",
6
6
  "author": "Wiz <shad@deckerzoo.com>",
@@ -46,7 +46,7 @@
46
46
  "scripts": {
47
47
  "build": "tsup",
48
48
  "dev": "tsup --watch",
49
- "ladle": "ladle serve",
49
+ "ladle": "NODE_ENV=development ladle serve",
50
50
  "ladle:build": "ladle build",
51
51
  "typecheck": "tsc --noEmit",
52
52
  "prepublishOnly": "npm run build && npm run check:publish",
@@ -127,7 +127,7 @@ module.exports = {
127
127
  panel: "1rem",
128
128
  },
129
129
  spacing: {
130
- sidebar: "var(--hl-sidebar-width, 15rem)",
130
+ sidebar: "var(--hl-sidebar-width, 240px)",
131
131
  logo: "9rem",
132
132
  },
133
133
  // `colors.border` above only carries DEFAULT. Nesting `input`/`inputHover`