@jdcpuwiz/homelab-ui 0.4.0 → 0.5.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 +32 -0
- package/dist/index.cjs +30 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +53 -3
- package/dist/index.d.ts +53 -3
- package/dist/index.js +28 -5
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -140,6 +140,38 @@ Done. The app boots with the right fonts, sidebar, and theme.
|
|
|
140
140
|
| `EmptyState` | `panel` (warm card body) or `inline` (text-only) variant. |
|
|
141
141
|
| `Spinner` | `<Loader2 />` + label, sized for the dashboard's tertiary-text grey. |
|
|
142
142
|
| `TagChips` | Solid-color pills with auto white/black text contrast on yellow. Read-only `<span>` or interactive `<button>`. |
|
|
143
|
+
| `StatTile` / `StatTileRow` | Live-stat tiles with **stable geometry** (see Layout standard below): truncate-never-wrap, always-reserved sub line, tabular numerals; the row is a full-width `auto-fit` grid that stretches with the browser. |
|
|
144
|
+
|
|
145
|
+
## Layout standard (applies to EVERY homelab UI)
|
|
146
|
+
|
|
147
|
+
Locked in by Wiz 2026-07-13 (born on zoo-docs' dashboard). Default for all
|
|
148
|
+
projects **unless a page explicitly calls out otherwise**:
|
|
149
|
+
|
|
150
|
+
1. **Content pages are FULL WIDTH.** No `max-w-*` caps on the page
|
|
151
|
+
container — content grows and shrinks with the browser. (A page may
|
|
152
|
+
opt out deliberately, e.g. a long-form reading view, but that's the
|
|
153
|
+
exception and should be commented as such.)
|
|
154
|
+
2. **Live data must never move the page.** Anything that re-renders on a
|
|
155
|
+
poll (status cards, counters, countdowns) keeps constant HEIGHT:
|
|
156
|
+
- text truncates (`truncate`), never wraps
|
|
157
|
+
- optional lines are always reserved (render ` `, not nothing)
|
|
158
|
+
- ticking numbers use `tabular-nums`
|
|
159
|
+
Use `StatTile` + `StatTileRow` and this comes free:
|
|
160
|
+
|
|
161
|
+
```tsx
|
|
162
|
+
<StatTileRow>
|
|
163
|
+
<StatTile label="Analyzer" value="idle" sub="last finished 2m ago" />
|
|
164
|
+
<StatTile label="Total runs" value={128} />
|
|
165
|
+
<StatTile label="Next sweep" value="12:41" sub="every 15 min" />
|
|
166
|
+
</StatTileRow>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
3. **`#7d838d` is the input-placeholder token ONLY.** All reading text —
|
|
170
|
+
labels, captions, secondary lines, table meta — is flat `#e2e4e9`.
|
|
171
|
+
Version stamps are `text-white/20`. Grey body text reads as broken on
|
|
172
|
+
the dark theme.
|
|
173
|
+
4. **Wide content scrolls in its own container** (`overflow-x-auto` on
|
|
174
|
+
tables/code blocks) — the page body never scrolls horizontally.
|
|
143
175
|
|
|
144
176
|
### Helpers
|
|
145
177
|
|
package/dist/index.cjs
CHANGED
|
@@ -30,6 +30,8 @@ __export(src_exports, {
|
|
|
30
30
|
Sidebar: () => Sidebar,
|
|
31
31
|
SidebarNavItem: () => SidebarNavItem,
|
|
32
32
|
Spinner: () => Spinner,
|
|
33
|
+
StatTile: () => StatTile,
|
|
34
|
+
StatTileRow: () => StatTileRow,
|
|
33
35
|
TagChips: () => TagChips,
|
|
34
36
|
cn: () => cn,
|
|
35
37
|
tagTextColor: () => tagTextColor
|
|
@@ -393,6 +395,7 @@ var import_jsx_runtime6 = require("react/jsx-runtime");
|
|
|
393
395
|
function Sidebar({
|
|
394
396
|
logoSrc,
|
|
395
397
|
logoAlt,
|
|
398
|
+
logoClassName = "h-36 w-36 rounded-[1rem] object-contain",
|
|
396
399
|
widgets,
|
|
397
400
|
nav,
|
|
398
401
|
middle,
|
|
@@ -459,7 +462,7 @@ function Sidebar({
|
|
|
459
462
|
{
|
|
460
463
|
src: logoSrc,
|
|
461
464
|
alt: logoAlt,
|
|
462
|
-
className:
|
|
465
|
+
className: logoClassName
|
|
463
466
|
}
|
|
464
467
|
),
|
|
465
468
|
showMobileTopBar && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
@@ -541,6 +544,26 @@ function Spinner({ label = "Loading\u2026", className }) {
|
|
|
541
544
|
);
|
|
542
545
|
}
|
|
543
546
|
|
|
547
|
+
// src/components/StatTile.tsx
|
|
548
|
+
var import_jsx_runtime9 = require("react/jsx-runtime");
|
|
549
|
+
function StatTile({ label, value, sub, className }) {
|
|
550
|
+
return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: cn("min-w-0 rounded-lg bg-[#1e1e1e] px-4 py-3", className), children: [
|
|
551
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: "truncate text-[10px] font-semibold uppercase tracking-widest text-[#e2e4e9]", children: label }),
|
|
552
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: "mt-1 truncate text-xl font-semibold tabular-nums text-white", children: value }),
|
|
553
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: "truncate text-[11px] tabular-nums text-[#e2e4e9]", children: sub ?? "\xA0" })
|
|
554
|
+
] });
|
|
555
|
+
}
|
|
556
|
+
function StatTileRow({ children, minTileWidth = "9rem", className }) {
|
|
557
|
+
return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
|
|
558
|
+
"div",
|
|
559
|
+
{
|
|
560
|
+
className: cn("grid gap-3", className),
|
|
561
|
+
style: { gridTemplateColumns: `repeat(auto-fit, minmax(${minTileWidth}, 1fr))` },
|
|
562
|
+
children
|
|
563
|
+
}
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
544
567
|
// src/lib/colors.ts
|
|
545
568
|
function tagTextColor(hex) {
|
|
546
569
|
const normalized = hex.trim().replace(/^#/, "");
|
|
@@ -555,17 +578,17 @@ function tagTextColor(hex) {
|
|
|
555
578
|
}
|
|
556
579
|
|
|
557
580
|
// src/components/TagChips.tsx
|
|
558
|
-
var
|
|
581
|
+
var import_jsx_runtime10 = require("react/jsx-runtime");
|
|
559
582
|
function TagChips({ tags, size = "xs", onClick }) {
|
|
560
583
|
if (tags.length === 0) return null;
|
|
561
584
|
const sizeClasses = size === "xs" ? "px-1.5 py-0.5 text-[10px]" : "px-2 py-0.5 text-xs";
|
|
562
|
-
return /* @__PURE__ */ (0,
|
|
585
|
+
return /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("div", { className: "flex flex-wrap gap-1", children: tags.map((t) => {
|
|
563
586
|
const style = {
|
|
564
587
|
backgroundColor: t.color,
|
|
565
588
|
color: tagTextColor(t.color)
|
|
566
589
|
};
|
|
567
590
|
const className = `inline-block rounded-[0.25rem] font-semibold ${sizeClasses}`;
|
|
568
|
-
return onClick ? /* @__PURE__ */ (0,
|
|
591
|
+
return onClick ? /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
|
|
569
592
|
"button",
|
|
570
593
|
{
|
|
571
594
|
type: "button",
|
|
@@ -575,7 +598,7 @@ function TagChips({ tags, size = "xs", onClick }) {
|
|
|
575
598
|
children: t.name
|
|
576
599
|
},
|
|
577
600
|
t.id
|
|
578
|
-
) : /* @__PURE__ */ (0,
|
|
601
|
+
) : /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("span", { className, style, children: t.name }, t.id);
|
|
579
602
|
}) });
|
|
580
603
|
}
|
|
581
604
|
|
|
@@ -596,6 +619,8 @@ var FONT_CSS_VARIABLES = {
|
|
|
596
619
|
Sidebar,
|
|
597
620
|
SidebarNavItem,
|
|
598
621
|
Spinner,
|
|
622
|
+
StatTile,
|
|
623
|
+
StatTileRow,
|
|
599
624
|
TagChips,
|
|
600
625
|
cn,
|
|
601
626
|
tagTextColor
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/components/Button.tsx","../src/lib/utils.ts","../src/components/ConfirmDialog.tsx","../src/components/Modal.tsx","../src/components/EmptyState.tsx","../src/components/PageHeader.tsx","../src/components/Sidebar.tsx","../src/components/SidebarNavItem.tsx","../src/components/Spinner.tsx","../src/lib/colors.ts","../src/components/TagChips.tsx","../src/lib/fonts.ts"],"sourcesContent":["// Components\nexport { Button, type ButtonProps } from \"./components/Button\";\nexport {\n ConfirmDialog,\n type ConfirmDialogProps,\n} from \"./components/ConfirmDialog\";\nexport { EmptyState, type EmptyStateProps } from \"./components/EmptyState\";\nexport { Modal, type ModalProps } from \"./components/Modal\";\nexport {\n PageHeader,\n type PageHeaderProps,\n type Crumb,\n} from \"./components/PageHeader\";\nexport { Sidebar, type SidebarProps } from \"./components/Sidebar\";\nexport {\n SidebarNavItem,\n type SidebarNavItemProps,\n} from \"./components/SidebarNavItem\";\nexport { Spinner, type SpinnerProps } from \"./components/Spinner\";\nexport {\n TagChips,\n type TagChipsProps,\n type TagRef,\n} from \"./components/TagChips\";\n\n// Helpers\nexport { cn } from \"./lib/utils\";\nexport { tagTextColor } from \"./lib/colors\";\nexport { FONT_CSS_VARIABLES } from \"./lib/fonts\";\n","\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { cn } from \"../lib/utils\";\n\ntype Variant = \"primary\" | \"secondary\" | \"tertiary\" | \"danger\";\ntype Size = \"sm\" | \"md\";\n\nexport type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {\n variant?: Variant;\n size?: Size;\n};\n\nconst BASE =\n \"inline-flex items-center justify-center gap-1.5 rounded-[0.5rem] font-semibold transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#ffb84d]\";\n\nconst VARIANT: Record<Variant, string> = {\n primary: \"bg-[#ff9900] text-[#0a0a0a] hover:bg-[#cc6a1a]\",\n secondary: \"bg-[#1e1e1e] text-white hover:bg-[#252525]\",\n tertiary: \"text-white/60 hover:bg-[#252525] hover:text-white\",\n danger: \"bg-[#b91c1c] text-white hover:brightness-110\",\n};\n\nconst SIZE: Record<Size, string> = {\n sm: \"px-3 py-1 text-xs\",\n md: \"px-4 py-1.5 text-sm\",\n};\n\n/**\n * Homelab Button. Use the variant + size props; never pass raw color\n * classes through className. className is for layout extras only\n * (e.g. `w-full`).\n */\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n function Button(\n { variant = \"secondary\", size = \"md\", className, ...rest },\n ref,\n ) {\n return (\n <button\n ref={ref}\n {...rest}\n className={cn(BASE, VARIANT[variant], SIZE[size], className)}\n />\n );\n },\n);\n","import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Conditional Tailwind class joiner. Merges variants and resolves\n * conflicts (e.g. `cn(\"p-2\", \"p-4\")` → `\"p-4\"`).\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","\"use client\";\n\nimport { useEffect } from \"react\";\nimport { Modal } from \"./Modal\";\nimport { Button } from \"./Button\";\n\nexport type ConfirmDialogProps = {\n open: boolean;\n title: string;\n message: string;\n confirmLabel?: string;\n cancelLabel?: string;\n tone?: \"danger\" | \"primary\";\n onConfirm: () => void;\n onClose: () => void;\n};\n\n/**\n * Confirm/cancel dialog built on Modal + Button. Enter triggers confirm,\n * Esc cancels (via Modal's built-in handling).\n */\nexport function ConfirmDialog({\n open,\n title,\n message,\n confirmLabel = \"Confirm\",\n cancelLabel = \"Cancel\",\n tone = \"danger\",\n onConfirm,\n onClose,\n}: ConfirmDialogProps) {\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Enter\") onConfirm();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onConfirm]);\n\n return (\n <Modal\n open={open}\n onClose={onClose}\n title={title}\n footer={\n <>\n <Button variant=\"tertiary\" size=\"md\" onClick={onClose}>\n {cancelLabel}\n </Button>\n <Button\n variant={tone === \"danger\" ? \"danger\" : \"primary\"}\n size=\"md\"\n onClick={onConfirm}\n >\n {confirmLabel}\n </Button>\n </>\n }\n >\n <p className=\"whitespace-pre-line text-sm text-white/70\">{message}</p>\n </Modal>\n );\n}\n","\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\ntype Density = \"form\" | \"media\";\n\nexport type ModalProps = {\n open: boolean;\n onClose: () => void;\n title?: string;\n /** Show the close X in the top right. Default: true when title is set. */\n showClose?: boolean;\n /**\n * Overlay weight:\n * `form` — dialogs / pickers / confirms (`bg-black/70`)\n * `media` — lightbox / asset preview (`bg-black/90`)\n * Defaults to `form`.\n */\n density?: Density;\n /** Max width — default `max-w-md`. */\n maxWidth?: string;\n /** Footer slot — render buttons here. Always right-aligned. */\n footer?: React.ReactNode;\n children: React.ReactNode;\n};\n\n/**\n * Single modal frame. Every dialog routes through this:\n * - overlay + click-outside dismiss\n * - Esc-to-close\n * - role=dialog + aria-modal + aria-labelledby wiring\n * - DESIGN.md header treatment (uppercase tracking-widest)\n * - shared right-aligned footer row\n */\nexport function Modal({\n open,\n onClose,\n title,\n showClose,\n density = \"form\",\n maxWidth = \"max-w-md\",\n footer,\n children,\n}: ModalProps) {\n const titleIdRef = useRef(\n `hl-modal-${Math.random().toString(36).slice(2, 8)}`,\n );\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") onClose();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onClose]);\n\n if (!open) return null;\n\n const showCloseBtn = showClose ?? Boolean(title);\n const overlayClass = density === \"media\" ? \"bg-black/90\" : \"bg-black/70\";\n\n return (\n <div\n className={cn(\n \"fixed inset-0 z-50 flex items-center justify-center p-4\",\n overlayClass,\n )}\n onClick={onClose}\n role=\"presentation\"\n >\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={title ? titleIdRef.current : undefined}\n className={cn(\n \"w-full rounded-[1rem] border-2 border-[#1e1e1e] bg-[#111111] p-5\",\n maxWidth,\n )}\n onClick={(e) => e.stopPropagation()}\n >\n {(title || showCloseBtn) && (\n <div className=\"mb-4 flex items-start justify-between gap-3\">\n {title && (\n <h2\n id={titleIdRef.current}\n className=\"text-sm font-semibold uppercase tracking-widest text-white\"\n >\n {title}\n </h2>\n )}\n {showCloseBtn && (\n <button\n type=\"button\"\n onClick={onClose}\n className=\"ml-auto rounded-[0.25rem] p-1 text-white/40 transition hover:bg-[#252525] hover:text-white active:scale-90\"\n aria-label=\"Close\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n )}\n {children}\n {footer && (\n <div className=\"mt-5 flex flex-wrap items-center justify-end gap-2\">\n {footer}\n </div>\n )}\n </div>\n </div>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type EmptyStateProps = {\n icon?: React.ReactNode;\n title?: string;\n message: string;\n action?: React.ReactNode;\n /**\n * `panel` — large empty state with warm card background (page bodies).\n * `inline` — compact text-only line for sidebar tree, popovers, etc.\n */\n variant?: \"panel\" | \"inline\";\n className?: string;\n};\n\nexport function EmptyState({\n icon,\n title,\n message,\n action,\n variant = \"panel\",\n className,\n}: EmptyStateProps) {\n if (variant === \"inline\") {\n return (\n <p className={cn(\"px-3 py-2 text-xs text-white/40\", className)}>\n {message}\n </p>\n );\n }\n return (\n <div\n className={cn(\n \"flex flex-col items-center justify-center gap-3 rounded-[1rem] bg-[#2a2620] p-12 text-center\",\n className,\n )}\n >\n {icon && <div className=\"text-white/30\">{icon}</div>}\n {title && (\n <h3 className=\"text-sm font-semibold text-white\">{title}</h3>\n )}\n <p className=\"text-sm text-white/60\">{message}</p>\n {action}\n </div>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { ArrowLeft, ChevronRight, ImageOff } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type Crumb = {\n label: string;\n onClick?: () => void;\n};\n\nexport type PageHeaderProps = {\n title: string;\n description?: string;\n /** Back link rendered above the title. */\n back?: { href: string; label: string; render?: (href: string, children: React.ReactNode) => React.ReactNode };\n /** Right-aligned actions slot. */\n actions?: React.ReactNode;\n /**\n * Header image URL (e.g. `/logos.png`). When the URL resolves, the\n * image is the visual title. When it 404s, a styled placeholder at\n * the same dimensions takes its place — the layout stays stable so\n * creating a new themed page \"just works\" the moment you drop a\n * matching PNG in /public/.\n *\n * When `imageSrc` is undefined, no image block renders and the\n * 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 (white + bold); earlier crumbs are clickable.\n */\n breadcrumb?: Crumb[];\n className?: string;\n};\n\n/**\n * Page-header block. The image block reserves space whenever `imageSrc`\n * is set — drop the PNG in /public/ and the layout just fills in.\n *\n * Text title rules:\n * - imageSrc set + image present → text becomes sr-only\n * - imageSrc set + image 404 → placeholder block + text becomes sr-only\n * - imageSrc unset → text title is visible\n * - breadcrumb provided → breadcrumb replaces text title regardless of image\n *\n * The back link uses a plain <a> by default. In Next.js, pass\n * `back.render` to use next/link:\n *\n * import Link from \"next/link\";\n * <PageHeader back={{ href: \"/foo\", label: \"Back\", render: (href, c) => <Link href={href}>{c}</Link> }} />\n */\nexport function PageHeader({\n title,\n description,\n back,\n actions,\n imageSrc,\n imageAlt,\n breadcrumb,\n className,\n}: PageHeaderProps) {\n const [imageErrored, setImageErrored] = useState(false);\n useEffect(() => {\n setImageErrored(false);\n }, [imageSrc]);\n\n const hasImageSlot = !!imageSrc;\n const imageRenders = hasImageSlot && !imageErrored;\n const hasBreadcrumb = Array.isArray(breadcrumb) && breadcrumb.length > 0;\n const textIsSrOnly = hasImageSlot;\n\n const backInner = back ? (\n <>\n <ArrowLeft className=\"h-3 w-3\" />\n {back.label}\n </>\n ) : null;\n\n return (\n <header\n className={cn(\n \"flex flex-wrap items-end justify-between gap-3\",\n className,\n )}\n >\n <div className=\"flex min-w-0 items-end gap-4\">\n {hasImageSlot &&\n (imageRenders ? (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={imageSrc}\n alt={imageAlt ?? title}\n onError={() => setImageErrored(true)}\n className=\"h-28 w-28 shrink-0 rounded-[1rem] object-contain md:h-32 md:w-32\"\n />\n ) : (\n <PlaceholderHeader title={title} imageSrc={imageSrc!} />\n ))}\n <div className=\"min-w-0\">\n {back &&\n (back.render ? (\n back.render(\n back.href,\n <span className=\"inline-flex items-center gap-1 text-xs text-white/40 transition hover:text-white\">\n {backInner}\n </span>,\n )\n ) : (\n <a\n href={back.href}\n className=\"inline-flex items-center gap-1 text-xs text-white/40 transition hover:text-white\"\n >\n {backInner}\n </a>\n ))}\n {hasBreadcrumb ? (\n <Breadcrumb\n crumbs={breadcrumb!}\n srTitle={title}\n className={back ? \"mt-1\" : \"\"}\n />\n ) : (\n <h1\n className={cn(\n \"text-xl font-semibold text-[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-white/40\">{description}</p>\n )}\n </div>\n </div>\n {actions && <div className=\"flex items-center gap-2\">{actions}</div>}\n </header>\n );\n}\n\nfunction PlaceholderHeader({\n title,\n imageSrc,\n}: {\n title: string;\n imageSrc: string;\n}) {\n const file = imageSrc.replace(/^\\//, \"\");\n return (\n <div\n title={`Drop ${file} in /public to brand this header`}\n className=\"flex h-28 w-28 shrink-0 flex-col items-center justify-center gap-1 rounded-[1rem] border-2 border-dashed border-[#1e1e1e] bg-[#2a2620] text-white/30 md:h-32 md:w-32\"\n >\n <ImageOff className=\"h-6 w-6\" />\n <span className=\"px-2 text-center text-[10px] font-semibold uppercase tracking-widest\">\n {title}\n </span>\n </div>\n );\n}\n\nfunction Breadcrumb({\n crumbs,\n srTitle,\n className,\n}: {\n crumbs: Crumb[];\n srTitle: string;\n className?: string;\n}) {\n return (\n <nav\n aria-label=\"Breadcrumb\"\n className={cn(\n \"flex flex-wrap items-center gap-1.5 text-xl font-semibold\",\n className,\n )}\n >\n <h1 className=\"sr-only\">{srTitle}</h1>\n {crumbs.map((crumb, idx) => {\n const isLast = idx === crumbs.length - 1;\n return (\n <span\n key={`${crumb.label}-${idx}`}\n className=\"flex items-center gap-1.5\"\n >\n {idx > 0 && (\n <ChevronRight\n className=\"h-4 w-4 shrink-0 text-white/30\"\n aria-hidden=\"true\"\n />\n )}\n {crumb.onClick && !isLast ? (\n <button\n type=\"button\"\n onClick={crumb.onClick}\n className=\"rounded px-1 text-white/45 transition hover:text-white\"\n >\n {crumb.label}\n </button>\n ) : (\n <span\n className={cn(\n isLast ? \"text-white\" : \"text-white/45\",\n isLast && \"px-1\",\n )}\n aria-current={isLast ? \"page\" : undefined}\n >\n {crumb.label}\n </span>\n )}\n </span>\n );\n })}\n </nav>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Menu, X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarProps = {\n /**\n * Logo image src — typically `/logo.png` or `/<project>-logo-256.png`.\n * Rendered at w-36 h-36 inside a centered block with px-4 pt-6 pb-5\n * + border-b-2 (doghouse-canonical spec).\n */\n logoSrc: string;\n /** Alt text for the logo + label shown in the mobile top bar. */\n logoAlt: string;\n /**\n * Slots — every section is optional. Render them in this order:\n * 1. Logo block (always)\n * 2. widgets — clock / weather / homepage rotator / etc\n * 3. nav — primary navigation\n * 4. middle — long-running content (folder tree, etc) — flex-1\n * 5. lower — secondary nav (manage-tags, settings link, etc)\n * 6. (spacer)\n * 7. admin — admin section\n * 8. footer — version stamp, etc\n */\n widgets?: React.ReactNode;\n nav?: React.ReactNode;\n middle?: React.ReactNode;\n lower?: React.ReactNode;\n admin?: React.ReactNode;\n footer?: React.ReactNode;\n /**\n * Mobile-drawer behavior. Controlled — the consumer owns the open\n * state so they can wire it from anywhere (top-bar hamburger,\n * keyboard shortcut, route change, etc.).\n *\n * When omitted, the Sidebar manages its own drawer state using the\n * built-in mobile top bar (hamburger renders top-left on screens < md).\n */\n mobileOpen?: boolean;\n onMobileOpenChange?: (open: boolean) => void;\n /** Extra classes on the outer <aside>. */\n className?: string;\n /**\n * Override the className used for the `middle` slot's wrapper.\n *\n * The default — `\"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[#1e1e1e] p-2\"` —\n * is correct for asset-den's \"single panel with a scrollable folder\n * tree inside.\" Override when the middle slot's content is itself a\n * stack of cards (e.g. doghouse's per-service status widgets) — those\n * already have `bg-[#1e1e1e]`, so wrapping them in another same-color\n * panel makes them disappear.\n *\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n middleClassName?: string;\n /**\n * When true, render a built-in mobile top bar (logo + hamburger) on\n * screens < md. Defaults to true. Disable when the consumer renders\n * its own top chrome.\n */\n showMobileTopBar?: boolean;\n};\n\n/**\n * Canonical homelab sidebar shell. Doghouse is the reference impl;\n * this is the extracted version every other project consumes.\n *\n * Dimensions are non-negotiable:\n * - md+ width: 15rem default (override per-project via `:root { --hl-sidebar-width: 18rem; }`)\n * - md+ position: fixed-flex in a `display:flex` parent\n * - mobile: off-canvas drawer at full sidebar width\n * - logo block: w-36 h-36 centered, px-4 pt-6 pb-5, border-b-2\n *\n * The component renders raw hex values (#111111, #1e1e1e) so it works\n * even when the consumer skips the Tailwind preset. Apply the preset\n * + globals.css for token-based overrides.\n */\nexport function Sidebar({\n logoSrc,\n logoAlt,\n widgets,\n nav,\n middle,\n lower,\n admin,\n footer,\n mobileOpen: controlledOpen,\n onMobileOpenChange,\n className,\n middleClassName = \"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[#1e1e1e] p-2\",\n showMobileTopBar = true,\n}: SidebarProps) {\n const [internalOpen, setInternalOpen] = useState(false);\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen : internalOpen;\n\n const setOpen = (next: boolean) => {\n if (!isControlled) setInternalOpen(next);\n onMobileOpenChange?.(next);\n };\n\n // Lock body scroll while the mobile drawer is open.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!open) return;\n const prev = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n return () => {\n document.body.style.overflow = prev;\n };\n }, [open]);\n\n return (\n <>\n {showMobileTopBar && (\n <div className=\"fixed inset-x-0 top-0 z-30 flex items-center gap-2 border-b-2 border-[#1e1e1e] bg-[#111111] px-3 py-2 md:hidden\">\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n className=\"rounded-[0.25rem] p-1.5 text-white transition hover:bg-[#252525] active:scale-90\"\n aria-label=\"Open menu\"\n >\n <Menu className=\"h-5 w-5\" />\n </button>\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={logoSrc} alt=\"\" width={24} height={24} />\n <p className=\"text-sm font-semibold text-white\">{logoAlt}</p>\n </div>\n )}\n\n {open && (\n <div\n className=\"fixed inset-0 z-40 bg-black/70 md:hidden\"\n onClick={() => setOpen(false)}\n role=\"presentation\"\n />\n )}\n\n <aside\n className={cn(\n \"fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,15rem)] shrink-0 flex-col border-r-2 border-[#1e1e1e] bg-[#111111] transition-transform md:static md:translate-x-0 md:h-screen\",\n open ? \"translate-x-0\" : \"-translate-x-full md:translate-x-0\",\n className,\n )}\n >\n <div className=\"relative flex flex-col items-center border-b-2 border-[#1e1e1e] px-4 pt-6 pb-5\">\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n src={logoSrc}\n alt={logoAlt}\n className=\"h-36 w-36 rounded-[1rem] object-contain\"\n />\n {showMobileTopBar && (\n <button\n type=\"button\"\n onClick={() => setOpen(false)}\n className=\"absolute right-3 top-3 rounded-[0.25rem] p-1 text-white/40 transition hover:bg-[#252525] hover:text-white md:hidden\"\n aria-label=\"Close menu\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n\n {widgets && <div className=\"mx-3 mt-3\">{widgets}</div>}\n\n {nav && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[#1e1e1e] p-2\">\n {nav}\n </nav>\n )}\n\n {middle && <div className={middleClassName}>{middle}</div>}\n\n {lower && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[#1e1e1e] p-2\">\n {lower}\n </nav>\n )}\n\n {!middle && <div className=\"flex-1\" />}\n\n {admin && <div>{admin}</div>}\n\n {footer && (\n <div className=\"border-t-2 border-[#1e1e1e] px-4 py-3 text-center text-xs text-white/20\">\n {footer}\n </div>\n )}\n </aside>\n </>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarNavItemProps = {\n label: string;\n icon?: React.ReactNode;\n active?: boolean;\n onClick?: () => void;\n /**\n * When set, renders as <a href>. Otherwise renders as <button>. In\n * Next.js, prefer passing `render` so you can wrap in next/link\n * without losing classes:\n *\n * <SidebarNavItem\n * label=\"Home\"\n * href=\"/\"\n * render={(props, children) => <Link {...props}>{children}</Link>}\n * />\n */\n href?: string;\n render?: (\n props: { href: string; className: string },\n children: React.ReactNode,\n ) => React.ReactNode;\n};\n\nconst ITEM_CLASS_BASE =\n \"flex items-center gap-2 rounded-[0.5rem] px-3 py-2.5 text-base font-medium transition-all\";\n\nconst itemClass = (active: boolean) =>\n cn(\n ITEM_CLASS_BASE,\n active\n ? \"bg-[#ff9900] text-white\"\n : \"text-white/45 hover:bg-white/5 hover:text-white\",\n );\n\n/**\n * Single nav row for the Sidebar's `nav` / `lower` slots. Matches the\n * doghouse-canonical style: active = orange bg + white text, inactive =\n * white/45 text + white/5 hover bg.\n */\nexport function SidebarNavItem({\n label,\n icon,\n active = false,\n onClick,\n href,\n render,\n}: SidebarNavItemProps) {\n const className = itemClass(active);\n const inner = (\n <>\n {icon}\n <span>{label}</span>\n </>\n );\n\n if (href && render) return <>{render({ href, className }, inner)}</>;\n if (href) {\n return (\n <a href={href} className={className}>\n {inner}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-pressed={active}\n className={`${className} w-full`}\n >\n {inner}\n </button>\n );\n}\n","\"use client\";\n\nimport { Loader2 } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SpinnerProps = {\n label?: string;\n className?: string;\n};\n\n/**\n * Consistent inline loading affordance — used wherever the app would\n * otherwise say \"Loading…\" in plain text. Compact spinner + label, color\n * is the same tertiary-text grey across the app.\n */\nexport function Spinner({ label = \"Loading…\", className }: SpinnerProps) {\n return (\n <p\n className={cn(\n \"flex items-center gap-2 px-3 py-2 text-xs text-white/40\",\n className,\n )}\n >\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n {label}\n </p>\n );\n}\n","/**\n * Pick black or white text for a solid color chip.\n *\n * The homelab status palette forces white text on every status color\n * EXCEPT yellow (warning: #eab308), which uses black for contrast. This\n * helper enforces that rule wherever a user-picked color drives a chip\n * background (TagChips, status badges, etc).\n *\n * The yellow rule generalizes — anything in the yellow band (high\n * luminance + warm hue) gets black ink.\n */\nexport function tagTextColor(hex: string): \"#ffffff\" | \"#000000\" {\n const normalized = hex.trim().replace(/^#/, \"\");\n if (normalized.length !== 3 && normalized.length !== 6) return \"#ffffff\";\n const expand =\n normalized.length === 3\n ? normalized\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : normalized;\n const r = parseInt(expand.slice(0, 2), 16);\n const g = parseInt(expand.slice(2, 4), 16);\n const b = parseInt(expand.slice(4, 6), 16);\n if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return \"#ffffff\";\n // Perceived luminance per ITU BT.601.\n const luma = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n return luma > 0.65 ? \"#000000\" : \"#ffffff\";\n}\n","\"use client\";\n\nimport { tagTextColor } from \"../lib/colors\";\n\nexport type TagRef = {\n id: string | number;\n name: string;\n color: string;\n};\n\nexport type TagChipsProps = {\n tags: TagRef[];\n size?: \"xs\" | \"sm\";\n onClick?: (tag: TagRef) => void;\n};\n\n/**\n * Render a row of solid-color tag chips. Yellow auto-flips text to\n * black per the homelab status-palette contrast rule (see tagTextColor).\n *\n * Renders `<button>` when interactive (`onClick` set), `<span>` when\n * read-only — keeps screen-reader semantics honest.\n */\nexport function TagChips({ tags, size = \"xs\", onClick }: TagChipsProps) {\n if (tags.length === 0) return null;\n const sizeClasses =\n size === \"xs\" ? \"px-1.5 py-0.5 text-[10px]\" : \"px-2 py-0.5 text-xs\";\n\n return (\n <div className=\"flex flex-wrap gap-1\">\n {tags.map((t) => {\n const style = {\n backgroundColor: t.color,\n color: tagTextColor(t.color),\n };\n const className = `inline-block rounded-[0.25rem] font-semibold ${sizeClasses}`;\n return onClick ? (\n <button\n key={t.id}\n type=\"button\"\n onClick={() => onClick(t)}\n className={`${className} transition active:scale-95`}\n style={style}\n >\n {t.name}\n </button>\n ) : (\n <span key={t.id} className={className} style={style}>\n {t.name}\n </span>\n );\n })}\n </div>\n );\n}\n","/**\n * Font CSS variable names used by `@jdcpuwiz/homelab-ui`'s Tailwind preset.\n *\n * Why constants and not re-exported loaders: Next.js's `next/font/google`\n * is a build-time directive scanned out of your *app* source by the SWC\n * plugin. The loader call MUST literally appear as `const X = Font(...)`\n * in your `app/layout.tsx`; pre-bundling it through a library turns\n * `const` into `var` and Next refuses to compile it. So the canonical\n * setup is \"paste this snippet into layout.tsx\" (see README) — and these\n * constants document the expected variable names so you wire `--font-*`\n * correctly without guessing.\n *\n * Snippet to paste into `app/layout.tsx`:\n *\n * import { Geist, Geist_Mono, Orbitron } from \"next/font/google\";\n *\n * const geistSans = Geist({ variable: \"--font-geist-sans\", subsets: [\"latin\"] });\n * const geistMono = Geist_Mono({ variable: \"--font-geist-mono\", subsets: [\"latin\"] });\n * const orbitron = Orbitron({ variable: \"--font-orbitron\", subsets: [\"latin\"] });\n *\n * <body className={`${geistSans.variable} ${geistMono.variable} ${orbitron.variable} antialiased`}>\n *\n * Sanity check after deploy:\n * getComputedStyle(document.body).fontFamily.startsWith('\"Geist\"')\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--font-geist-sans\",\n mono: \"--font-geist-mono\",\n display: \"--font-orbitron\",\n} as const;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACEA,mBAA2B;;;ACF3B,kBAAsC;AACtC,4BAAwB;AAMjB,SAAS,MAAM,QAA8B;AAClD,aAAO,mCAAQ,kBAAK,MAAM,CAAC;AAC7B;;;AD8BM;AA1BN,IAAM,OACJ;AAEF,IAAM,UAAmC;AAAA,EACvC,SAAS;AAAA,EACT,WAAW;AAAA,EACX,UAAU;AAAA,EACV,QAAQ;AACV;AAEA,IAAM,OAA6B;AAAA,EACjC,IAAI;AAAA,EACJ,IAAI;AACN;AAOO,IAAM,aAAS;AAAA,EACpB,SAASA,QACP,EAAE,UAAU,aAAa,OAAO,MAAM,WAAW,GAAG,KAAK,GACzD,KACA;AACA,WACE;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACC,GAAG;AAAA,QACJ,WAAW,GAAG,MAAM,QAAQ,OAAO,GAAG,KAAK,IAAI,GAAG,SAAS;AAAA;AAAA,IAC7D;AAAA,EAEJ;AACF;;;AE5CA,IAAAC,gBAA0B;;;ACA1B,IAAAC,gBAAkC;AAClC,0BAAkB;AAiFR,IAAAC,sBAAA;AAhDH,SAAS,MAAM;AAAA,EACpB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,WAAW;AAAA,EACX;AAAA,EACA;AACF,GAAe;AACb,QAAM,iBAAa;AAAA,IACjB,YAAY,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,GAAG,CAAC,CAAC;AAAA,EACpD;AAEA,+BAAU,MAAM;AACd,QAAI,CAAC,KAAM;AACX,UAAM,QAAQ,CAAC,MAAqB;AAClC,UAAI,EAAE,QAAQ,SAAU,SAAQ;AAAA,IAClC;AACA,WAAO,iBAAiB,WAAW,KAAK;AACxC,WAAO,MAAM,OAAO,oBAAoB,WAAW,KAAK;AAAA,EAC1D,GAAG,CAAC,MAAM,OAAO,CAAC;AAElB,MAAI,CAAC,KAAM,QAAO;AAElB,QAAM,eAAe,aAAa,QAAQ,KAAK;AAC/C,QAAM,eAAe,YAAY,UAAU,gBAAgB;AAE3D,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MACA,SAAS;AAAA,MACT,MAAK;AAAA,MAEL;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,cAAW;AAAA,UACX,mBAAiB,QAAQ,WAAW,UAAU;AAAA,UAC9C,WAAW;AAAA,YACT;AAAA,YACA;AAAA,UACF;AAAA,UACA,SAAS,CAAC,MAAM,EAAE,gBAAgB;AAAA,UAEhC;AAAA,sBAAS,iBACT,8CAAC,SAAI,WAAU,+CACZ;AAAA,uBACC;AAAA,gBAAC;AAAA;AAAA,kBACC,IAAI,WAAW;AAAA,kBACf,WAAU;AAAA,kBAET;AAAA;AAAA,cACH;AAAA,cAED,gBACC;AAAA,gBAAC;AAAA;AAAA,kBACC,MAAK;AAAA,kBACL,SAAS;AAAA,kBACT,WAAU;AAAA,kBACV,cAAW;AAAA,kBAEX,uDAAC,yBAAE,WAAU,WAAU;AAAA;AAAA,cACzB;AAAA,eAEJ;AAAA,YAED;AAAA,YACA,UACC,6CAAC,SAAI,WAAU,sDACZ,kBACH;AAAA;AAAA;AAAA,MAEJ;AAAA;AAAA,EACF;AAEJ;;;ADpEQ,IAAAC,sBAAA;AAzBD,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA,eAAe;AAAA,EACf,cAAc;AAAA,EACd,OAAO;AAAA,EACP;AAAA,EACA;AACF,GAAuB;AACrB,+BAAU,MAAM;AACd,QAAI,CAAC,KAAM;AACX,UAAM,QAAQ,CAAC,MAAqB;AAClC,UAAI,EAAE,QAAQ,QAAS,WAAU;AAAA,IACnC;AACA,WAAO,iBAAiB,WAAW,KAAK;AACxC,WAAO,MAAM,OAAO,oBAAoB,WAAW,KAAK;AAAA,EAC1D,GAAG,CAAC,MAAM,SAAS,CAAC;AAEpB,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA;AAAA,MACA;AAAA,MACA,QACE,8EACE;AAAA,qDAAC,UAAO,SAAQ,YAAW,MAAK,MAAK,SAAS,SAC3C,uBACH;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,SAAS,SAAS,WAAW,WAAW;AAAA,YACxC,MAAK;AAAA,YACL,SAAS;AAAA,YAER;AAAA;AAAA,QACH;AAAA,SACF;AAAA,MAGF,uDAAC,OAAE,WAAU,6CAA6C,mBAAQ;AAAA;AAAA,EACpE;AAEJ;;;AEpCM,IAAAC,sBAAA;AAVC,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV;AACF,GAAoB;AAClB,MAAI,YAAY,UAAU;AACxB,WACE,6CAAC,OAAE,WAAW,GAAG,mCAAmC,SAAS,GAC1D,mBACH;AAAA,EAEJ;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MAEC;AAAA,gBAAQ,6CAAC,SAAI,WAAU,iBAAiB,gBAAK;AAAA,QAC7C,SACC,6CAAC,QAAG,WAAU,oCAAoC,iBAAM;AAAA,QAE1D,6CAAC,OAAE,WAAU,yBAAyB,mBAAQ;AAAA,QAC7C;AAAA;AAAA;AAAA,EACH;AAEJ;;;AC7CA,IAAAC,gBAAoC;AACpC,IAAAC,uBAAkD;AAwE9C,IAAAC,sBAAA;AArBG,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAoB;AAClB,QAAM,CAAC,cAAc,eAAe,QAAI,wBAAS,KAAK;AACtD,+BAAU,MAAM;AACd,oBAAgB,KAAK;AAAA,EACvB,GAAG,CAAC,QAAQ,CAAC;AAEb,QAAM,eAAe,CAAC,CAAC;AACvB,QAAM,eAAe,gBAAgB,CAAC;AACtC,QAAM,gBAAgB,MAAM,QAAQ,UAAU,KAAK,WAAW,SAAS;AACvE,QAAM,eAAe;AAErB,QAAM,YAAY,OAChB,8EACE;AAAA,iDAAC,kCAAU,WAAU,WAAU;AAAA,IAC9B,KAAK;AAAA,KACR,IACE;AAEJ,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MAEA;AAAA,sDAAC,SAAI,WAAU,gCACZ;AAAA,2BACE;AAAA;AAAA,YAEC;AAAA,cAAC;AAAA;AAAA,gBACC,KAAK;AAAA,gBACL,KAAK,YAAY;AAAA,gBACjB,SAAS,MAAM,gBAAgB,IAAI;AAAA,gBACnC,WAAU;AAAA;AAAA,YACZ;AAAA,cAEA,6CAAC,qBAAkB,OAAc,UAAqB;AAAA,UAE1D,8CAAC,SAAI,WAAU,WACZ;AAAA,qBACE,KAAK,SACJ,KAAK;AAAA,cACH,KAAK;AAAA,cACL,6CAAC,UAAK,WAAU,oFACb,qBACH;AAAA,YACF,IAEA;AAAA,cAAC;AAAA;AAAA,gBACC,MAAM,KAAK;AAAA,gBACX,WAAU;AAAA,gBAET;AAAA;AAAA,YACH;AAAA,YAEH,gBACC;AAAA,cAAC;AAAA;AAAA,gBACC,QAAQ;AAAA,gBACR,SAAS;AAAA,gBACT,WAAW,OAAO,SAAS;AAAA;AAAA,YAC7B,IAEA;AAAA,cAAC;AAAA;AAAA,gBACC,WAAW;AAAA,kBACT;AAAA,kBACA,QAAQ;AAAA,kBACR,gBAAgB;AAAA,gBAClB;AAAA,gBAEC;AAAA;AAAA,YACH;AAAA,YAED,eACC,6CAAC,OAAE,WAAU,8BAA8B,uBAAY;AAAA,aAE3D;AAAA,WACF;AAAA,QACC,WAAW,6CAAC,SAAI,WAAU,2BAA2B,mBAAQ;AAAA;AAAA;AAAA,EAChE;AAEJ;AAEA,SAAS,kBAAkB;AAAA,EACzB;AAAA,EACA;AACF,GAGG;AACD,QAAM,OAAO,SAAS,QAAQ,OAAO,EAAE;AACvC,SACE;AAAA,IAAC;AAAA;AAAA,MACC,OAAO,QAAQ,IAAI;AAAA,MACnB,WAAU;AAAA,MAEV;AAAA,qDAAC,iCAAS,WAAU,WAAU;AAAA,QAC9B,6CAAC,UAAK,WAAU,wEACb,iBACH;AAAA;AAAA;AAAA,EACF;AAEJ;AAEA,SAAS,WAAW;AAAA,EAClB;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,cAAW;AAAA,MACX,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MAEA;AAAA,qDAAC,QAAG,WAAU,WAAW,mBAAQ;AAAA,QAChC,OAAO,IAAI,CAAC,OAAO,QAAQ;AAC1B,gBAAM,SAAS,QAAQ,OAAO,SAAS;AACvC,iBACE;AAAA,YAAC;AAAA;AAAA,cAEC,WAAU;AAAA,cAET;AAAA,sBAAM,KACL;AAAA,kBAAC;AAAA;AAAA,oBACC,WAAU;AAAA,oBACV,eAAY;AAAA;AAAA,gBACd;AAAA,gBAED,MAAM,WAAW,CAAC,SACjB;AAAA,kBAAC;AAAA;AAAA,oBACC,MAAK;AAAA,oBACL,SAAS,MAAM;AAAA,oBACf,WAAU;AAAA,oBAET,gBAAM;AAAA;AAAA,gBACT,IAEA;AAAA,kBAAC;AAAA;AAAA,oBACC,WAAW;AAAA,sBACT,SAAS,eAAe;AAAA,sBACxB,UAAU;AAAA,oBACZ;AAAA,oBACA,gBAAc,SAAS,SAAS;AAAA,oBAE/B,gBAAM;AAAA;AAAA,gBACT;AAAA;AAAA;AAAA,YA1BG,GAAG,MAAM,KAAK,IAAI,GAAG;AAAA,UA4B5B;AAAA,QAEJ,CAAC;AAAA;AAAA;AAAA,EACH;AAEJ;;;AC3NA,IAAAC,gBAAoC;AACpC,IAAAC,uBAAwB;AAiHpB,IAAAC,sBAAA;AApCG,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB,mBAAmB;AACrB,GAAiB;AACf,QAAM,CAAC,cAAc,eAAe,QAAI,wBAAS,KAAK;AACtD,QAAM,eAAe,mBAAmB;AACxC,QAAM,OAAO,eAAe,iBAAiB;AAE7C,QAAM,UAAU,CAAC,SAAkB;AACjC,QAAI,CAAC,aAAc,iBAAgB,IAAI;AACvC,yBAAqB,IAAI;AAAA,EAC3B;AAGA,+BAAU,MAAM;AACd,QAAI,OAAO,aAAa,YAAa;AACrC,QAAI,CAAC,KAAM;AACX,UAAM,OAAO,SAAS,KAAK,MAAM;AACjC,aAAS,KAAK,MAAM,WAAW;AAC/B,WAAO,MAAM;AACX,eAAS,KAAK,MAAM,WAAW;AAAA,IACjC;AAAA,EACF,GAAG,CAAC,IAAI,CAAC;AAET,SACE,8EACG;AAAA,wBACC,8CAAC,SAAI,WAAU,mHACb;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,SAAS,MAAM,QAAQ,IAAI;AAAA,UAC3B,WAAU;AAAA,UACV,cAAW;AAAA,UAEX,uDAAC,6BAAK,WAAU,WAAU;AAAA;AAAA,MAC5B;AAAA,MAEA,6CAAC,SAAI,KAAK,SAAS,KAAI,IAAG,OAAO,IAAI,QAAQ,IAAI;AAAA,MACjD,6CAAC,OAAE,WAAU,oCAAoC,mBAAQ;AAAA,OAC3D;AAAA,IAGD,QACC;AAAA,MAAC;AAAA;AAAA,QACC,WAAU;AAAA,QACV,SAAS,MAAM,QAAQ,KAAK;AAAA,QAC5B,MAAK;AAAA;AAAA,IACP;AAAA,IAGF;AAAA,MAAC;AAAA;AAAA,QACC,WAAW;AAAA,UACT;AAAA,UACA,OAAO,kBAAkB;AAAA,UACzB;AAAA,QACF;AAAA,QAEA;AAAA,wDAAC,SAAI,WAAU,kFAEb;AAAA;AAAA,cAAC;AAAA;AAAA,gBACC,KAAK;AAAA,gBACL,KAAK;AAAA,gBACL,WAAU;AAAA;AAAA,YACZ;AAAA,YACC,oBACC;AAAA,cAAC;AAAA;AAAA,gBACC,MAAK;AAAA,gBACL,SAAS,MAAM,QAAQ,KAAK;AAAA,gBAC5B,WAAU;AAAA,gBACV,cAAW;AAAA,gBAEX,uDAAC,0BAAE,WAAU,WAAU;AAAA;AAAA,YACzB;AAAA,aAEJ;AAAA,UAEC,WAAW,6CAAC,SAAI,WAAU,aAAa,mBAAQ;AAAA,UAE/C,OACC,6CAAC,SAAI,WAAU,4DACZ,eACH;AAAA,UAGD,UAAU,6CAAC,SAAI,WAAW,iBAAkB,kBAAO;AAAA,UAEnD,SACC,6CAAC,SAAI,WAAU,4DACZ,iBACH;AAAA,UAGD,CAAC,UAAU,6CAAC,SAAI,WAAU,UAAS;AAAA,UAEnC,SAAS,6CAAC,SAAK,iBAAM;AAAA,UAErB,UACC,6CAAC,SAAI,WAAU,2EACZ,kBACH;AAAA;AAAA;AAAA,IAEJ;AAAA,KACF;AAEJ;;;AC9II,IAAAC,sBAAA;AA1BJ,IAAM,kBACJ;AAEF,IAAM,YAAY,CAAC,WACjB;AAAA,EACE;AAAA,EACA,SACI,4BACA;AACN;AAOK,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AAAA,EACA;AAAA,EACA;AACF,GAAwB;AACtB,QAAM,YAAY,UAAU,MAAM;AAClC,QAAM,QACJ,8EACG;AAAA;AAAA,IACD,6CAAC,UAAM,iBAAM;AAAA,KACf;AAGF,MAAI,QAAQ,OAAQ,QAAO,6EAAG,iBAAO,EAAE,MAAM,UAAU,GAAG,KAAK,GAAE;AACjE,MAAI,MAAM;AACR,WACE,6CAAC,OAAE,MAAY,WACZ,iBACH;AAAA,EAEJ;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL;AAAA,MACA,gBAAc;AAAA,MACd,WAAW,GAAG,SAAS;AAAA,MAEtB;AAAA;AAAA,EACH;AAEJ;;;AC3EA,IAAAC,uBAAwB;AAepB,IAAAC,sBAAA;AAFG,SAAS,QAAQ,EAAE,QAAQ,iBAAY,UAAU,GAAiB;AACvE,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MAEA;AAAA,qDAAC,gCAAQ,WAAU,wBAAuB;AAAA,QACzC;AAAA;AAAA;AAAA,EACH;AAEJ;;;AChBO,SAAS,aAAa,KAAoC;AAC/D,QAAM,aAAa,IAAI,KAAK,EAAE,QAAQ,MAAM,EAAE;AAC9C,MAAI,WAAW,WAAW,KAAK,WAAW,WAAW,EAAG,QAAO;AAC/D,QAAM,SACJ,WAAW,WAAW,IAClB,WACG,MAAM,EAAE,EACR,IAAI,CAAC,MAAM,IAAI,CAAC,EAChB,KAAK,EAAE,IACV;AACN,QAAM,IAAI,SAAS,OAAO,MAAM,GAAG,CAAC,GAAG,EAAE;AACzC,QAAM,IAAI,SAAS,OAAO,MAAM,GAAG,CAAC,GAAG,EAAE;AACzC,QAAM,IAAI,SAAS,OAAO,MAAM,GAAG,CAAC,GAAG,EAAE;AACzC,MAAI,OAAO,MAAM,CAAC,KAAK,OAAO,MAAM,CAAC,KAAK,OAAO,MAAM,CAAC,EAAG,QAAO;AAElE,QAAM,QAAQ,QAAQ,IAAI,QAAQ,IAAI,QAAQ,KAAK;AACnD,SAAO,OAAO,OAAO,YAAY;AACnC;;;ACSU,IAAAC,sBAAA;AAdH,SAAS,SAAS,EAAE,MAAM,OAAO,MAAM,QAAQ,GAAkB;AACtE,MAAI,KAAK,WAAW,EAAG,QAAO;AAC9B,QAAM,cACJ,SAAS,OAAO,8BAA8B;AAEhD,SACE,6CAAC,SAAI,WAAU,wBACZ,eAAK,IAAI,CAAC,MAAM;AACf,UAAM,QAAQ;AAAA,MACZ,iBAAiB,EAAE;AAAA,MACnB,OAAO,aAAa,EAAE,KAAK;AAAA,IAC7B;AACA,UAAM,YAAY,gDAAgD,WAAW;AAC7E,WAAO,UACL;AAAA,MAAC;AAAA;AAAA,QAEC,MAAK;AAAA,QACL,SAAS,MAAM,QAAQ,CAAC;AAAA,QACxB,WAAW,GAAG,SAAS;AAAA,QACvB;AAAA,QAEC,YAAE;AAAA;AAAA,MANE,EAAE;AAAA,IAOT,IAEA,6CAAC,UAAgB,WAAsB,OACpC,YAAE,QADM,EAAE,EAEb;AAAA,EAEJ,CAAC,GACH;AAEJ;;;AC7BO,IAAM,qBAAqB;AAAA,EAChC,MAAM;AAAA,EACN,MAAM;AAAA,EACN,SAAS;AACX;","names":["Button","import_react","import_react","import_jsx_runtime","import_jsx_runtime","import_jsx_runtime","import_react","import_lucide_react","import_jsx_runtime","import_react","import_lucide_react","import_jsx_runtime","import_jsx_runtime","import_lucide_react","import_jsx_runtime","import_jsx_runtime"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/components/Button.tsx","../src/lib/utils.ts","../src/components/ConfirmDialog.tsx","../src/components/Modal.tsx","../src/components/EmptyState.tsx","../src/components/PageHeader.tsx","../src/components/Sidebar.tsx","../src/components/SidebarNavItem.tsx","../src/components/Spinner.tsx","../src/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 } from \"./lib/fonts\";\n","\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { cn } from \"../lib/utils\";\n\ntype Variant = \"primary\" | \"secondary\" | \"tertiary\" | \"danger\";\ntype Size = \"sm\" | \"md\";\n\nexport type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {\n variant?: Variant;\n size?: Size;\n};\n\nconst BASE =\n \"inline-flex items-center justify-center gap-1.5 rounded-[0.5rem] font-semibold transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#ffb84d]\";\n\nconst VARIANT: Record<Variant, string> = {\n primary: \"bg-[#ff9900] text-[#0a0a0a] hover:bg-[#cc6a1a]\",\n secondary: \"bg-[#1e1e1e] text-white hover:bg-[#252525]\",\n tertiary: \"text-white/60 hover:bg-[#252525] hover:text-white\",\n danger: \"bg-[#b91c1c] text-white hover:brightness-110\",\n};\n\nconst SIZE: Record<Size, string> = {\n sm: \"px-3 py-1 text-xs\",\n md: \"px-4 py-1.5 text-sm\",\n};\n\n/**\n * Homelab Button. Use the variant + size props; never pass raw color\n * classes through className. className is for layout extras only\n * (e.g. `w-full`).\n */\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n function Button(\n { variant = \"secondary\", size = \"md\", className, ...rest },\n ref,\n ) {\n return (\n <button\n ref={ref}\n {...rest}\n className={cn(BASE, VARIANT[variant], SIZE[size], className)}\n />\n );\n },\n);\n","import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Conditional Tailwind class joiner. Merges variants and resolves\n * conflicts (e.g. `cn(\"p-2\", \"p-4\")` → `\"p-4\"`).\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","\"use client\";\n\nimport { useEffect } from \"react\";\nimport { Modal } from \"./Modal\";\nimport { Button } from \"./Button\";\n\nexport type ConfirmDialogProps = {\n open: boolean;\n title: string;\n message: string;\n confirmLabel?: string;\n cancelLabel?: string;\n tone?: \"danger\" | \"primary\";\n onConfirm: () => void;\n onClose: () => void;\n};\n\n/**\n * Confirm/cancel dialog built on Modal + Button. Enter triggers confirm,\n * Esc cancels (via Modal's built-in handling).\n */\nexport function ConfirmDialog({\n open,\n title,\n message,\n confirmLabel = \"Confirm\",\n cancelLabel = \"Cancel\",\n tone = \"danger\",\n onConfirm,\n onClose,\n}: ConfirmDialogProps) {\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Enter\") onConfirm();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onConfirm]);\n\n return (\n <Modal\n open={open}\n onClose={onClose}\n title={title}\n footer={\n <>\n <Button variant=\"tertiary\" size=\"md\" onClick={onClose}>\n {cancelLabel}\n </Button>\n <Button\n variant={tone === \"danger\" ? \"danger\" : \"primary\"}\n size=\"md\"\n onClick={onConfirm}\n >\n {confirmLabel}\n </Button>\n </>\n }\n >\n <p className=\"whitespace-pre-line text-sm text-white/70\">{message}</p>\n </Modal>\n );\n}\n","\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\ntype Density = \"form\" | \"media\";\n\nexport type ModalProps = {\n open: boolean;\n onClose: () => void;\n title?: string;\n /** Show the close X in the top right. Default: true when title is set. */\n showClose?: boolean;\n /**\n * Overlay weight:\n * `form` — dialogs / pickers / confirms (`bg-black/70`)\n * `media` — lightbox / asset preview (`bg-black/90`)\n * Defaults to `form`.\n */\n density?: Density;\n /** Max width — default `max-w-md`. */\n maxWidth?: string;\n /** Footer slot — render buttons here. Always right-aligned. */\n footer?: React.ReactNode;\n children: React.ReactNode;\n};\n\n/**\n * Single modal frame. Every dialog routes through this:\n * - overlay + click-outside dismiss\n * - Esc-to-close\n * - role=dialog + aria-modal + aria-labelledby wiring\n * - DESIGN.md header treatment (uppercase tracking-widest)\n * - shared right-aligned footer row\n */\nexport function Modal({\n open,\n onClose,\n title,\n showClose,\n density = \"form\",\n maxWidth = \"max-w-md\",\n footer,\n children,\n}: ModalProps) {\n const titleIdRef = useRef(\n `hl-modal-${Math.random().toString(36).slice(2, 8)}`,\n );\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") onClose();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onClose]);\n\n if (!open) return null;\n\n const showCloseBtn = showClose ?? Boolean(title);\n const overlayClass = density === \"media\" ? \"bg-black/90\" : \"bg-black/70\";\n\n return (\n <div\n className={cn(\n \"fixed inset-0 z-50 flex items-center justify-center p-4\",\n overlayClass,\n )}\n onClick={onClose}\n role=\"presentation\"\n >\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={title ? titleIdRef.current : undefined}\n className={cn(\n \"w-full rounded-[1rem] border-2 border-[#1e1e1e] bg-[#111111] p-5\",\n maxWidth,\n )}\n onClick={(e) => e.stopPropagation()}\n >\n {(title || showCloseBtn) && (\n <div className=\"mb-4 flex items-start justify-between gap-3\">\n {title && (\n <h2\n id={titleIdRef.current}\n className=\"text-sm font-semibold uppercase tracking-widest text-white\"\n >\n {title}\n </h2>\n )}\n {showCloseBtn && (\n <button\n type=\"button\"\n onClick={onClose}\n className=\"ml-auto rounded-[0.25rem] p-1 text-white/40 transition hover:bg-[#252525] hover:text-white active:scale-90\"\n aria-label=\"Close\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n )}\n {children}\n {footer && (\n <div className=\"mt-5 flex flex-wrap items-center justify-end gap-2\">\n {footer}\n </div>\n )}\n </div>\n </div>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type EmptyStateProps = {\n icon?: React.ReactNode;\n title?: string;\n message: string;\n action?: React.ReactNode;\n /**\n * `panel` — large empty state with warm card background (page bodies).\n * `inline` — compact text-only line for sidebar tree, popovers, etc.\n */\n variant?: \"panel\" | \"inline\";\n className?: string;\n};\n\nexport function EmptyState({\n icon,\n title,\n message,\n action,\n variant = \"panel\",\n className,\n}: EmptyStateProps) {\n if (variant === \"inline\") {\n return (\n <p className={cn(\"px-3 py-2 text-xs text-white/40\", className)}>\n {message}\n </p>\n );\n }\n return (\n <div\n className={cn(\n \"flex flex-col items-center justify-center gap-3 rounded-[1rem] bg-[#2a2620] p-12 text-center\",\n className,\n )}\n >\n {icon && <div className=\"text-white/30\">{icon}</div>}\n {title && (\n <h3 className=\"text-sm font-semibold text-white\">{title}</h3>\n )}\n <p className=\"text-sm text-white/60\">{message}</p>\n {action}\n </div>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { ArrowLeft, ChevronRight, ImageOff } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type Crumb = {\n label: string;\n onClick?: () => void;\n};\n\nexport type PageHeaderProps = {\n title: string;\n description?: string;\n /** Back link rendered above the title. */\n back?: { href: string; label: string; render?: (href: string, children: React.ReactNode) => React.ReactNode };\n /** Right-aligned actions slot. */\n actions?: React.ReactNode;\n /**\n * Header image URL (e.g. `/logos.png`). When the URL resolves, the\n * image is the visual title. When it 404s, a styled placeholder at\n * the same dimensions takes its place — the layout stays stable so\n * creating a new themed page \"just works\" the moment you drop a\n * matching PNG in /public/.\n *\n * When `imageSrc` is undefined, no image block renders and the\n * 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 (white + bold); earlier crumbs are clickable.\n */\n breadcrumb?: Crumb[];\n className?: string;\n};\n\n/**\n * Page-header block. The image block reserves space whenever `imageSrc`\n * is set — drop the PNG in /public/ and the layout just fills in.\n *\n * Text title rules:\n * - imageSrc set + image present → text becomes sr-only\n * - imageSrc set + image 404 → placeholder block + text becomes sr-only\n * - imageSrc unset → text title is visible\n * - breadcrumb provided → breadcrumb replaces text title regardless of image\n *\n * The back link uses a plain <a> by default. In Next.js, pass\n * `back.render` to use next/link:\n *\n * import Link from \"next/link\";\n * <PageHeader back={{ href: \"/foo\", label: \"Back\", render: (href, c) => <Link href={href}>{c}</Link> }} />\n */\nexport function PageHeader({\n title,\n description,\n back,\n actions,\n imageSrc,\n imageAlt,\n breadcrumb,\n className,\n}: PageHeaderProps) {\n const [imageErrored, setImageErrored] = useState(false);\n useEffect(() => {\n setImageErrored(false);\n }, [imageSrc]);\n\n const hasImageSlot = !!imageSrc;\n const imageRenders = hasImageSlot && !imageErrored;\n const hasBreadcrumb = Array.isArray(breadcrumb) && breadcrumb.length > 0;\n const textIsSrOnly = hasImageSlot;\n\n const backInner = back ? (\n <>\n <ArrowLeft className=\"h-3 w-3\" />\n {back.label}\n </>\n ) : null;\n\n return (\n <header\n className={cn(\n \"flex flex-wrap items-end justify-between gap-3\",\n className,\n )}\n >\n <div className=\"flex min-w-0 items-end gap-4\">\n {hasImageSlot &&\n (imageRenders ? (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={imageSrc}\n alt={imageAlt ?? title}\n onError={() => setImageErrored(true)}\n className=\"h-28 w-28 shrink-0 rounded-[1rem] object-contain md:h-32 md:w-32\"\n />\n ) : (\n <PlaceholderHeader title={title} imageSrc={imageSrc!} />\n ))}\n <div className=\"min-w-0\">\n {back &&\n (back.render ? (\n back.render(\n back.href,\n <span className=\"inline-flex items-center gap-1 text-xs text-white/40 transition hover:text-white\">\n {backInner}\n </span>,\n )\n ) : (\n <a\n href={back.href}\n className=\"inline-flex items-center gap-1 text-xs text-white/40 transition hover:text-white\"\n >\n {backInner}\n </a>\n ))}\n {hasBreadcrumb ? (\n <Breadcrumb\n crumbs={breadcrumb!}\n srTitle={title}\n className={back ? \"mt-1\" : \"\"}\n />\n ) : (\n <h1\n className={cn(\n \"text-xl font-semibold text-[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-white/40\">{description}</p>\n )}\n </div>\n </div>\n {actions && <div className=\"flex items-center gap-2\">{actions}</div>}\n </header>\n );\n}\n\nfunction PlaceholderHeader({\n title,\n imageSrc,\n}: {\n title: string;\n imageSrc: string;\n}) {\n const file = imageSrc.replace(/^\\//, \"\");\n return (\n <div\n title={`Drop ${file} in /public to brand this header`}\n className=\"flex h-28 w-28 shrink-0 flex-col items-center justify-center gap-1 rounded-[1rem] border-2 border-dashed border-[#1e1e1e] bg-[#2a2620] text-white/30 md:h-32 md:w-32\"\n >\n <ImageOff className=\"h-6 w-6\" />\n <span className=\"px-2 text-center text-[10px] font-semibold uppercase tracking-widest\">\n {title}\n </span>\n </div>\n );\n}\n\nfunction Breadcrumb({\n crumbs,\n srTitle,\n className,\n}: {\n crumbs: Crumb[];\n srTitle: string;\n className?: string;\n}) {\n return (\n <nav\n aria-label=\"Breadcrumb\"\n className={cn(\n \"flex flex-wrap items-center gap-1.5 text-xl font-semibold\",\n className,\n )}\n >\n <h1 className=\"sr-only\">{srTitle}</h1>\n {crumbs.map((crumb, idx) => {\n const isLast = idx === crumbs.length - 1;\n return (\n <span\n key={`${crumb.label}-${idx}`}\n className=\"flex items-center gap-1.5\"\n >\n {idx > 0 && (\n <ChevronRight\n className=\"h-4 w-4 shrink-0 text-white/30\"\n aria-hidden=\"true\"\n />\n )}\n {crumb.onClick && !isLast ? (\n <button\n type=\"button\"\n onClick={crumb.onClick}\n className=\"rounded px-1 text-white/45 transition hover:text-white\"\n >\n {crumb.label}\n </button>\n ) : (\n <span\n className={cn(\n isLast ? \"text-white\" : \"text-white/45\",\n isLast && \"px-1\",\n )}\n aria-current={isLast ? \"page\" : undefined}\n >\n {crumb.label}\n </span>\n )}\n </span>\n );\n })}\n </nav>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Menu, X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarProps = {\n /**\n * Logo image src — typically `/logo.png` or `/<project>-logo-256.png`.\n * Rendered at w-36 h-36 inside a centered block with px-4 pt-6 pb-5\n * + border-b-2 (doghouse-canonical spec). 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-[#1e1e1e] p-2\"` —\n * is correct for asset-den's \"single panel with a scrollable folder\n * tree inside.\" Override when the middle slot's content is itself a\n * stack of cards (e.g. doghouse's per-service status widgets) — those\n * already have `bg-[#1e1e1e]`, so wrapping them in another same-color\n * panel makes them disappear.\n *\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n middleClassName?: string;\n /**\n * When true, render a built-in mobile top bar (logo + hamburger) on\n * screens < md. Defaults to true. Disable when the consumer renders\n * its own top chrome.\n */\n showMobileTopBar?: boolean;\n};\n\n/**\n * Canonical homelab sidebar shell. Doghouse is the reference impl;\n * this is the extracted version every other project consumes.\n *\n * Dimensions are non-negotiable:\n * - md+ width: 15rem default (override per-project via `:root { --hl-sidebar-width: 18rem; }`)\n * - md+ position: fixed-flex in a `display:flex` parent\n * - mobile: off-canvas drawer at full sidebar width\n * - logo block: w-36 h-36 centered, px-4 pt-6 pb-5, border-b-2\n *\n * The component renders raw hex values (#111111, #1e1e1e) so it works\n * even when the consumer skips the Tailwind preset. Apply the preset\n * + globals.css for token-based overrides.\n */\nexport function Sidebar({\n logoSrc,\n logoAlt,\n 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-[#1e1e1e] p-2\",\n showMobileTopBar = true,\n}: SidebarProps) {\n const [internalOpen, setInternalOpen] = useState(false);\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen : internalOpen;\n\n const setOpen = (next: boolean) => {\n if (!isControlled) setInternalOpen(next);\n onMobileOpenChange?.(next);\n };\n\n // Lock body scroll while the mobile drawer is open.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!open) return;\n const prev = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n return () => {\n document.body.style.overflow = prev;\n };\n }, [open]);\n\n return (\n <>\n {showMobileTopBar && (\n <div className=\"fixed inset-x-0 top-0 z-30 flex items-center gap-2 border-b-2 border-[#1e1e1e] bg-[#111111] px-3 py-2 md:hidden\">\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n className=\"rounded-[0.25rem] p-1.5 text-white transition hover:bg-[#252525] active:scale-90\"\n aria-label=\"Open menu\"\n >\n <Menu className=\"h-5 w-5\" />\n </button>\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={logoSrc} alt=\"\" width={24} height={24} />\n <p className=\"text-sm font-semibold text-white\">{logoAlt}</p>\n </div>\n )}\n\n {open && (\n <div\n className=\"fixed inset-0 z-40 bg-black/70 md:hidden\"\n onClick={() => setOpen(false)}\n role=\"presentation\"\n />\n )}\n\n <aside\n className={cn(\n \"fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,15rem)] shrink-0 flex-col border-r-2 border-[#1e1e1e] bg-[#111111] transition-transform md:static md:translate-x-0 md:h-screen\",\n open ? \"translate-x-0\" : \"-translate-x-full md:translate-x-0\",\n className,\n )}\n >\n <div className=\"relative flex flex-col items-center border-b-2 border-[#1e1e1e] px-4 pt-6 pb-5\">\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n src={logoSrc}\n alt={logoAlt}\n className={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-white/40 transition hover:bg-[#252525] hover:text-white md:hidden\"\n aria-label=\"Close menu\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n\n {widgets && <div className=\"mx-3 mt-3\">{widgets}</div>}\n\n {nav && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[#1e1e1e] p-2\">\n {nav}\n </nav>\n )}\n\n {middle && <div className={middleClassName}>{middle}</div>}\n\n {lower && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[#1e1e1e] p-2\">\n {lower}\n </nav>\n )}\n\n {!middle && <div className=\"flex-1\" />}\n\n {admin && <div>{admin}</div>}\n\n {footer && (\n <div className=\"border-t-2 border-[#1e1e1e] px-4 py-3 text-center text-xs text-white/20\">\n {footer}\n </div>\n )}\n </aside>\n </>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarNavItemProps = {\n label: string;\n icon?: React.ReactNode;\n active?: boolean;\n onClick?: () => void;\n /**\n * When set, renders as <a href>. Otherwise renders as <button>. In\n * Next.js, prefer passing `render` so you can wrap in next/link\n * without losing classes:\n *\n * <SidebarNavItem\n * label=\"Home\"\n * href=\"/\"\n * render={(props, children) => <Link {...props}>{children}</Link>}\n * />\n */\n href?: string;\n render?: (\n props: { href: string; className: string },\n children: React.ReactNode,\n ) => React.ReactNode;\n};\n\nconst ITEM_CLASS_BASE =\n \"flex items-center gap-2 rounded-[0.5rem] px-3 py-2.5 text-base font-medium transition-all\";\n\nconst itemClass = (active: boolean) =>\n cn(\n ITEM_CLASS_BASE,\n active\n ? \"bg-[#ff9900] text-white\"\n : \"text-white/45 hover:bg-white/5 hover:text-white\",\n );\n\n/**\n * Single nav row for the Sidebar's `nav` / `lower` slots. Matches the\n * doghouse-canonical style: active = orange bg + white text, inactive =\n * white/45 text + white/5 hover bg.\n */\nexport function SidebarNavItem({\n label,\n icon,\n active = false,\n onClick,\n href,\n render,\n}: SidebarNavItemProps) {\n const className = itemClass(active);\n const inner = (\n <>\n {icon}\n <span>{label}</span>\n </>\n );\n\n if (href && render) return <>{render({ href, className }, inner)}</>;\n if (href) {\n return (\n <a href={href} className={className}>\n {inner}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-pressed={active}\n className={`${className} w-full`}\n >\n {inner}\n </button>\n );\n}\n","\"use client\";\n\nimport { Loader2 } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SpinnerProps = {\n label?: string;\n className?: string;\n};\n\n/**\n * Consistent inline loading affordance — used wherever the app would\n * otherwise say \"Loading…\" in plain text. Compact spinner + label, color\n * is the same tertiary-text grey across the app.\n */\nexport function Spinner({ label = \"Loading…\", className }: SpinnerProps) {\n return (\n <p\n className={cn(\n \"flex items-center gap-2 px-3 py-2 text-xs text-white/40\",\n className,\n )}\n >\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n {label}\n </p>\n );\n}\n","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 * - tabular numerals → ticking numbers don't wiggle the layout\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-[#1e1e1e] px-4 py-3\", className)}>\n <p className=\"truncate text-[10px] font-semibold uppercase tracking-widest text-[#e2e4e9]\">\n {label}\n </p>\n <p className=\"mt-1 truncate text-xl font-semibold tabular-nums text-white\">{value}</p>\n <p className=\"truncate text-[11px] tabular-nums text-[#e2e4e9]\">{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 CSS variable names used by `@jdcpuwiz/homelab-ui`'s Tailwind preset.\n *\n * Why constants and not re-exported loaders: Next.js's `next/font/google`\n * is a build-time directive scanned out of your *app* source by the SWC\n * plugin. The loader call MUST literally appear as `const X = Font(...)`\n * in your `app/layout.tsx`; pre-bundling it through a library turns\n * `const` into `var` and Next refuses to compile it. So the canonical\n * setup is \"paste this snippet into layout.tsx\" (see README) — and these\n * constants document the expected variable names so you wire `--font-*`\n * correctly without guessing.\n *\n * Snippet to paste into `app/layout.tsx`:\n *\n * import { Geist, Geist_Mono, Orbitron } from \"next/font/google\";\n *\n * const geistSans = Geist({ variable: \"--font-geist-sans\", subsets: [\"latin\"] });\n * const geistMono = Geist_Mono({ variable: \"--font-geist-mono\", subsets: [\"latin\"] });\n * const orbitron = Orbitron({ variable: \"--font-orbitron\", subsets: [\"latin\"] });\n *\n * <body className={`${geistSans.variable} ${geistMono.variable} ${orbitron.variable} antialiased`}>\n *\n * Sanity check after deploy:\n * getComputedStyle(document.body).fontFamily.startsWith('\"Geist\"')\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--font-geist-sans\",\n mono: \"--font-geist-mono\",\n display: \"--font-orbitron\",\n} as const;\n"],"mappings":"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","names":["Button","import_react","import_react","import_jsx_runtime","import_jsx_runtime","import_jsx_runtime","import_react","import_lucide_react","import_jsx_runtime","import_react","import_lucide_react","import_jsx_runtime","import_jsx_runtime","import_lucide_react","import_jsx_runtime","import_jsx_runtime","import_jsx_runtime"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -133,11 +133,21 @@ type SidebarProps = {
|
|
|
133
133
|
/**
|
|
134
134
|
* Logo image src — typically `/logo.png` or `/<project>-logo-256.png`.
|
|
135
135
|
* Rendered at w-36 h-36 inside a centered block with px-4 pt-6 pb-5
|
|
136
|
-
* + border-b-2 (doghouse-canonical spec).
|
|
136
|
+
* + border-b-2 (doghouse-canonical spec). Override sizing/shape via
|
|
137
|
+
* `logoClassName`.
|
|
137
138
|
*/
|
|
138
139
|
logoSrc: string;
|
|
139
140
|
/** Alt text for the logo + label shown in the mobile top bar. */
|
|
140
141
|
logoAlt: string;
|
|
142
|
+
/**
|
|
143
|
+
* Override the className applied to the logo `<img>`. Default is
|
|
144
|
+
* `"h-36 w-36 rounded-[1rem] object-contain"` (doghouse-canonical
|
|
145
|
+
* square logo). Pass `"h-auto w-full rounded-md object-contain"` for
|
|
146
|
+
* full-width banner-style logos, or any other Tailwind classes.
|
|
147
|
+
* Pass the whole class string when overriding — your value replaces
|
|
148
|
+
* the default (no merge).
|
|
149
|
+
*/
|
|
150
|
+
logoClassName?: string;
|
|
141
151
|
/**
|
|
142
152
|
* Slots — every section is optional. Render them in this order:
|
|
143
153
|
* 1. Logo block (always)
|
|
@@ -202,7 +212,7 @@ type SidebarProps = {
|
|
|
202
212
|
* even when the consumer skips the Tailwind preset. Apply the preset
|
|
203
213
|
* + globals.css for token-based overrides.
|
|
204
214
|
*/
|
|
205
|
-
declare function Sidebar({ logoSrc, logoAlt, widgets, nav, middle, lower, admin, footer, mobileOpen: controlledOpen, onMobileOpenChange, className, middleClassName, showMobileTopBar, }: SidebarProps): react.JSX.Element;
|
|
215
|
+
declare function Sidebar({ logoSrc, logoAlt, logoClassName, widgets, nav, middle, lower, admin, footer, mobileOpen: controlledOpen, onMobileOpenChange, className, middleClassName, showMobileTopBar, }: SidebarProps): react.JSX.Element;
|
|
206
216
|
|
|
207
217
|
type SidebarNavItemProps = {
|
|
208
218
|
label: string;
|
|
@@ -244,6 +254,46 @@ type SpinnerProps = {
|
|
|
244
254
|
*/
|
|
245
255
|
declare function Spinner({ label, className }: SpinnerProps): react.JSX.Element;
|
|
246
256
|
|
|
257
|
+
type StatTileProps = {
|
|
258
|
+
/** Uppercase micro-label above the value. */
|
|
259
|
+
label: string;
|
|
260
|
+
/** The stat itself — keep it short; it truncates rather than wraps. */
|
|
261
|
+
value: React.ReactNode;
|
|
262
|
+
/** Optional caption under the value. The line is ALWAYS reserved so the
|
|
263
|
+
* tile height never changes when a sub appears/disappears. */
|
|
264
|
+
sub?: React.ReactNode;
|
|
265
|
+
className?: string;
|
|
266
|
+
};
|
|
267
|
+
/**
|
|
268
|
+
* Live-stat tile with STABLE GEOMETRY — the fleet standard for status
|
|
269
|
+
* cards (born on zoo-docs' dashboard, 2026-07-13):
|
|
270
|
+
*
|
|
271
|
+
* - text truncates, never wraps → tile height is constant
|
|
272
|
+
* - the sub line renders even when empty → no height jump when data
|
|
273
|
+
* (queue counts, countdowns) comes and goes between polls
|
|
274
|
+
* - tabular numerals → ticking numbers don't wiggle the layout
|
|
275
|
+
*
|
|
276
|
+
* Anything elastic in HEIGHT shoves the whole page around on every poll;
|
|
277
|
+
* width elasticity belongs to the row (see StatTileRow).
|
|
278
|
+
*/
|
|
279
|
+
declare function StatTile({ label, value, sub, className }: StatTileProps): react.JSX.Element;
|
|
280
|
+
type StatTileRowProps = {
|
|
281
|
+
children: React.ReactNode;
|
|
282
|
+
/** Minimum tile width before the grid drops a column. Default "9rem". */
|
|
283
|
+
minTileWidth?: string;
|
|
284
|
+
className?: string;
|
|
285
|
+
};
|
|
286
|
+
/**
|
|
287
|
+
* Full-width, equal-column container for StatTiles. `auto-fit` +
|
|
288
|
+
* `minmax(minTileWidth, 1fr)` means the row stretches edge-to-edge with
|
|
289
|
+
* the browser and sheds whole columns on narrow screens — tiles never
|
|
290
|
+
* overflow off-screen and never wrap their text.
|
|
291
|
+
*
|
|
292
|
+
* Pair with the layout standard: content pages are FULL WIDTH (no
|
|
293
|
+
* max-w-* caps) unless a page explicitly opts out.
|
|
294
|
+
*/
|
|
295
|
+
declare function StatTileRow({ children, minTileWidth, className }: StatTileRowProps): react.JSX.Element;
|
|
296
|
+
|
|
247
297
|
type TagRef = {
|
|
248
298
|
id: string | number;
|
|
249
299
|
name: string;
|
|
@@ -313,4 +363,4 @@ declare const FONT_CSS_VARIABLES: {
|
|
|
313
363
|
readonly display: "--font-orbitron";
|
|
314
364
|
};
|
|
315
365
|
|
|
316
|
-
export { Button, type ButtonProps, ConfirmDialog, type ConfirmDialogProps, type Crumb, EmptyState, type EmptyStateProps, FONT_CSS_VARIABLES, Modal, type ModalProps, PageHeader, type PageHeaderProps, Sidebar, SidebarNavItem, type SidebarNavItemProps, type SidebarProps, Spinner, type SpinnerProps, TagChips, type TagChipsProps, type TagRef, cn, tagTextColor };
|
|
366
|
+
export { Button, type ButtonProps, ConfirmDialog, type ConfirmDialogProps, type Crumb, EmptyState, type EmptyStateProps, FONT_CSS_VARIABLES, Modal, type ModalProps, PageHeader, type PageHeaderProps, Sidebar, SidebarNavItem, type SidebarNavItemProps, type SidebarProps, Spinner, type SpinnerProps, StatTile, type StatTileProps, StatTileRow, type StatTileRowProps, TagChips, type TagChipsProps, type TagRef, cn, tagTextColor };
|
package/dist/index.d.ts
CHANGED
|
@@ -133,11 +133,21 @@ type SidebarProps = {
|
|
|
133
133
|
/**
|
|
134
134
|
* Logo image src — typically `/logo.png` or `/<project>-logo-256.png`.
|
|
135
135
|
* Rendered at w-36 h-36 inside a centered block with px-4 pt-6 pb-5
|
|
136
|
-
* + border-b-2 (doghouse-canonical spec).
|
|
136
|
+
* + border-b-2 (doghouse-canonical spec). Override sizing/shape via
|
|
137
|
+
* `logoClassName`.
|
|
137
138
|
*/
|
|
138
139
|
logoSrc: string;
|
|
139
140
|
/** Alt text for the logo + label shown in the mobile top bar. */
|
|
140
141
|
logoAlt: string;
|
|
142
|
+
/**
|
|
143
|
+
* Override the className applied to the logo `<img>`. Default is
|
|
144
|
+
* `"h-36 w-36 rounded-[1rem] object-contain"` (doghouse-canonical
|
|
145
|
+
* square logo). Pass `"h-auto w-full rounded-md object-contain"` for
|
|
146
|
+
* full-width banner-style logos, or any other Tailwind classes.
|
|
147
|
+
* Pass the whole class string when overriding — your value replaces
|
|
148
|
+
* the default (no merge).
|
|
149
|
+
*/
|
|
150
|
+
logoClassName?: string;
|
|
141
151
|
/**
|
|
142
152
|
* Slots — every section is optional. Render them in this order:
|
|
143
153
|
* 1. Logo block (always)
|
|
@@ -202,7 +212,7 @@ type SidebarProps = {
|
|
|
202
212
|
* even when the consumer skips the Tailwind preset. Apply the preset
|
|
203
213
|
* + globals.css for token-based overrides.
|
|
204
214
|
*/
|
|
205
|
-
declare function Sidebar({ logoSrc, logoAlt, widgets, nav, middle, lower, admin, footer, mobileOpen: controlledOpen, onMobileOpenChange, className, middleClassName, showMobileTopBar, }: SidebarProps): react.JSX.Element;
|
|
215
|
+
declare function Sidebar({ logoSrc, logoAlt, logoClassName, widgets, nav, middle, lower, admin, footer, mobileOpen: controlledOpen, onMobileOpenChange, className, middleClassName, showMobileTopBar, }: SidebarProps): react.JSX.Element;
|
|
206
216
|
|
|
207
217
|
type SidebarNavItemProps = {
|
|
208
218
|
label: string;
|
|
@@ -244,6 +254,46 @@ type SpinnerProps = {
|
|
|
244
254
|
*/
|
|
245
255
|
declare function Spinner({ label, className }: SpinnerProps): react.JSX.Element;
|
|
246
256
|
|
|
257
|
+
type StatTileProps = {
|
|
258
|
+
/** Uppercase micro-label above the value. */
|
|
259
|
+
label: string;
|
|
260
|
+
/** The stat itself — keep it short; it truncates rather than wraps. */
|
|
261
|
+
value: React.ReactNode;
|
|
262
|
+
/** Optional caption under the value. The line is ALWAYS reserved so the
|
|
263
|
+
* tile height never changes when a sub appears/disappears. */
|
|
264
|
+
sub?: React.ReactNode;
|
|
265
|
+
className?: string;
|
|
266
|
+
};
|
|
267
|
+
/**
|
|
268
|
+
* Live-stat tile with STABLE GEOMETRY — the fleet standard for status
|
|
269
|
+
* cards (born on zoo-docs' dashboard, 2026-07-13):
|
|
270
|
+
*
|
|
271
|
+
* - text truncates, never wraps → tile height is constant
|
|
272
|
+
* - the sub line renders even when empty → no height jump when data
|
|
273
|
+
* (queue counts, countdowns) comes and goes between polls
|
|
274
|
+
* - tabular numerals → ticking numbers don't wiggle the layout
|
|
275
|
+
*
|
|
276
|
+
* Anything elastic in HEIGHT shoves the whole page around on every poll;
|
|
277
|
+
* width elasticity belongs to the row (see StatTileRow).
|
|
278
|
+
*/
|
|
279
|
+
declare function StatTile({ label, value, sub, className }: StatTileProps): react.JSX.Element;
|
|
280
|
+
type StatTileRowProps = {
|
|
281
|
+
children: React.ReactNode;
|
|
282
|
+
/** Minimum tile width before the grid drops a column. Default "9rem". */
|
|
283
|
+
minTileWidth?: string;
|
|
284
|
+
className?: string;
|
|
285
|
+
};
|
|
286
|
+
/**
|
|
287
|
+
* Full-width, equal-column container for StatTiles. `auto-fit` +
|
|
288
|
+
* `minmax(minTileWidth, 1fr)` means the row stretches edge-to-edge with
|
|
289
|
+
* the browser and sheds whole columns on narrow screens — tiles never
|
|
290
|
+
* overflow off-screen and never wrap their text.
|
|
291
|
+
*
|
|
292
|
+
* Pair with the layout standard: content pages are FULL WIDTH (no
|
|
293
|
+
* max-w-* caps) unless a page explicitly opts out.
|
|
294
|
+
*/
|
|
295
|
+
declare function StatTileRow({ children, minTileWidth, className }: StatTileRowProps): react.JSX.Element;
|
|
296
|
+
|
|
247
297
|
type TagRef = {
|
|
248
298
|
id: string | number;
|
|
249
299
|
name: string;
|
|
@@ -313,4 +363,4 @@ declare const FONT_CSS_VARIABLES: {
|
|
|
313
363
|
readonly display: "--font-orbitron";
|
|
314
364
|
};
|
|
315
365
|
|
|
316
|
-
export { Button, type ButtonProps, ConfirmDialog, type ConfirmDialogProps, type Crumb, EmptyState, type EmptyStateProps, FONT_CSS_VARIABLES, Modal, type ModalProps, PageHeader, type PageHeaderProps, Sidebar, SidebarNavItem, type SidebarNavItemProps, type SidebarProps, Spinner, type SpinnerProps, TagChips, type TagChipsProps, type TagRef, cn, tagTextColor };
|
|
366
|
+
export { Button, type ButtonProps, ConfirmDialog, type ConfirmDialogProps, type Crumb, EmptyState, type EmptyStateProps, FONT_CSS_VARIABLES, Modal, type ModalProps, PageHeader, type PageHeaderProps, Sidebar, SidebarNavItem, type SidebarNavItemProps, type SidebarProps, Spinner, type SpinnerProps, StatTile, type StatTileProps, StatTileRow, type StatTileRowProps, TagChips, type TagChipsProps, type TagRef, cn, tagTextColor };
|
package/dist/index.js
CHANGED
|
@@ -357,6 +357,7 @@ import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-run
|
|
|
357
357
|
function Sidebar({
|
|
358
358
|
logoSrc,
|
|
359
359
|
logoAlt,
|
|
360
|
+
logoClassName = "h-36 w-36 rounded-[1rem] object-contain",
|
|
360
361
|
widgets,
|
|
361
362
|
nav,
|
|
362
363
|
middle,
|
|
@@ -423,7 +424,7 @@ function Sidebar({
|
|
|
423
424
|
{
|
|
424
425
|
src: logoSrc,
|
|
425
426
|
alt: logoAlt,
|
|
426
|
-
className:
|
|
427
|
+
className: logoClassName
|
|
427
428
|
}
|
|
428
429
|
),
|
|
429
430
|
showMobileTopBar && /* @__PURE__ */ jsx6(
|
|
@@ -505,6 +506,26 @@ function Spinner({ label = "Loading\u2026", className }) {
|
|
|
505
506
|
);
|
|
506
507
|
}
|
|
507
508
|
|
|
509
|
+
// src/components/StatTile.tsx
|
|
510
|
+
import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
|
|
511
|
+
function StatTile({ label, value, sub, className }) {
|
|
512
|
+
return /* @__PURE__ */ jsxs8("div", { className: cn("min-w-0 rounded-lg bg-[#1e1e1e] px-4 py-3", className), children: [
|
|
513
|
+
/* @__PURE__ */ jsx9("p", { className: "truncate text-[10px] font-semibold uppercase tracking-widest text-[#e2e4e9]", children: label }),
|
|
514
|
+
/* @__PURE__ */ jsx9("p", { className: "mt-1 truncate text-xl font-semibold tabular-nums text-white", children: value }),
|
|
515
|
+
/* @__PURE__ */ jsx9("p", { className: "truncate text-[11px] tabular-nums text-[#e2e4e9]", children: sub ?? "\xA0" })
|
|
516
|
+
] });
|
|
517
|
+
}
|
|
518
|
+
function StatTileRow({ children, minTileWidth = "9rem", className }) {
|
|
519
|
+
return /* @__PURE__ */ jsx9(
|
|
520
|
+
"div",
|
|
521
|
+
{
|
|
522
|
+
className: cn("grid gap-3", className),
|
|
523
|
+
style: { gridTemplateColumns: `repeat(auto-fit, minmax(${minTileWidth}, 1fr))` },
|
|
524
|
+
children
|
|
525
|
+
}
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
508
529
|
// src/lib/colors.ts
|
|
509
530
|
function tagTextColor(hex) {
|
|
510
531
|
const normalized = hex.trim().replace(/^#/, "");
|
|
@@ -519,17 +540,17 @@ function tagTextColor(hex) {
|
|
|
519
540
|
}
|
|
520
541
|
|
|
521
542
|
// src/components/TagChips.tsx
|
|
522
|
-
import { jsx as
|
|
543
|
+
import { jsx as jsx10 } from "react/jsx-runtime";
|
|
523
544
|
function TagChips({ tags, size = "xs", onClick }) {
|
|
524
545
|
if (tags.length === 0) return null;
|
|
525
546
|
const sizeClasses = size === "xs" ? "px-1.5 py-0.5 text-[10px]" : "px-2 py-0.5 text-xs";
|
|
526
|
-
return /* @__PURE__ */
|
|
547
|
+
return /* @__PURE__ */ jsx10("div", { className: "flex flex-wrap gap-1", children: tags.map((t) => {
|
|
527
548
|
const style = {
|
|
528
549
|
backgroundColor: t.color,
|
|
529
550
|
color: tagTextColor(t.color)
|
|
530
551
|
};
|
|
531
552
|
const className = `inline-block rounded-[0.25rem] font-semibold ${sizeClasses}`;
|
|
532
|
-
return onClick ? /* @__PURE__ */
|
|
553
|
+
return onClick ? /* @__PURE__ */ jsx10(
|
|
533
554
|
"button",
|
|
534
555
|
{
|
|
535
556
|
type: "button",
|
|
@@ -539,7 +560,7 @@ function TagChips({ tags, size = "xs", onClick }) {
|
|
|
539
560
|
children: t.name
|
|
540
561
|
},
|
|
541
562
|
t.id
|
|
542
|
-
) : /* @__PURE__ */
|
|
563
|
+
) : /* @__PURE__ */ jsx10("span", { className, style, children: t.name }, t.id);
|
|
543
564
|
}) });
|
|
544
565
|
}
|
|
545
566
|
|
|
@@ -559,6 +580,8 @@ export {
|
|
|
559
580
|
Sidebar,
|
|
560
581
|
SidebarNavItem,
|
|
561
582
|
Spinner,
|
|
583
|
+
StatTile,
|
|
584
|
+
StatTileRow,
|
|
562
585
|
TagChips,
|
|
563
586
|
cn,
|
|
564
587
|
tagTextColor
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/components/Button.tsx","../src/lib/utils.ts","../src/components/ConfirmDialog.tsx","../src/components/Modal.tsx","../src/components/EmptyState.tsx","../src/components/PageHeader.tsx","../src/components/Sidebar.tsx","../src/components/SidebarNavItem.tsx","../src/components/Spinner.tsx","../src/lib/colors.ts","../src/components/TagChips.tsx","../src/lib/fonts.ts"],"sourcesContent":["\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { cn } from \"../lib/utils\";\n\ntype Variant = \"primary\" | \"secondary\" | \"tertiary\" | \"danger\";\ntype Size = \"sm\" | \"md\";\n\nexport type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {\n variant?: Variant;\n size?: Size;\n};\n\nconst BASE =\n \"inline-flex items-center justify-center gap-1.5 rounded-[0.5rem] font-semibold transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#ffb84d]\";\n\nconst VARIANT: Record<Variant, string> = {\n primary: \"bg-[#ff9900] text-[#0a0a0a] hover:bg-[#cc6a1a]\",\n secondary: \"bg-[#1e1e1e] text-white hover:bg-[#252525]\",\n tertiary: \"text-white/60 hover:bg-[#252525] hover:text-white\",\n danger: \"bg-[#b91c1c] text-white hover:brightness-110\",\n};\n\nconst SIZE: Record<Size, string> = {\n sm: \"px-3 py-1 text-xs\",\n md: \"px-4 py-1.5 text-sm\",\n};\n\n/**\n * Homelab Button. Use the variant + size props; never pass raw color\n * classes through className. className is for layout extras only\n * (e.g. `w-full`).\n */\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n function Button(\n { variant = \"secondary\", size = \"md\", className, ...rest },\n ref,\n ) {\n return (\n <button\n ref={ref}\n {...rest}\n className={cn(BASE, VARIANT[variant], SIZE[size], className)}\n />\n );\n },\n);\n","import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Conditional Tailwind class joiner. Merges variants and resolves\n * conflicts (e.g. `cn(\"p-2\", \"p-4\")` → `\"p-4\"`).\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","\"use client\";\n\nimport { useEffect } from \"react\";\nimport { Modal } from \"./Modal\";\nimport { Button } from \"./Button\";\n\nexport type ConfirmDialogProps = {\n open: boolean;\n title: string;\n message: string;\n confirmLabel?: string;\n cancelLabel?: string;\n tone?: \"danger\" | \"primary\";\n onConfirm: () => void;\n onClose: () => void;\n};\n\n/**\n * Confirm/cancel dialog built on Modal + Button. Enter triggers confirm,\n * Esc cancels (via Modal's built-in handling).\n */\nexport function ConfirmDialog({\n open,\n title,\n message,\n confirmLabel = \"Confirm\",\n cancelLabel = \"Cancel\",\n tone = \"danger\",\n onConfirm,\n onClose,\n}: ConfirmDialogProps) {\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Enter\") onConfirm();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onConfirm]);\n\n return (\n <Modal\n open={open}\n onClose={onClose}\n title={title}\n footer={\n <>\n <Button variant=\"tertiary\" size=\"md\" onClick={onClose}>\n {cancelLabel}\n </Button>\n <Button\n variant={tone === \"danger\" ? \"danger\" : \"primary\"}\n size=\"md\"\n onClick={onConfirm}\n >\n {confirmLabel}\n </Button>\n </>\n }\n >\n <p className=\"whitespace-pre-line text-sm text-white/70\">{message}</p>\n </Modal>\n );\n}\n","\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\ntype Density = \"form\" | \"media\";\n\nexport type ModalProps = {\n open: boolean;\n onClose: () => void;\n title?: string;\n /** Show the close X in the top right. Default: true when title is set. */\n showClose?: boolean;\n /**\n * Overlay weight:\n * `form` — dialogs / pickers / confirms (`bg-black/70`)\n * `media` — lightbox / asset preview (`bg-black/90`)\n * Defaults to `form`.\n */\n density?: Density;\n /** Max width — default `max-w-md`. */\n maxWidth?: string;\n /** Footer slot — render buttons here. Always right-aligned. */\n footer?: React.ReactNode;\n children: React.ReactNode;\n};\n\n/**\n * Single modal frame. Every dialog routes through this:\n * - overlay + click-outside dismiss\n * - Esc-to-close\n * - role=dialog + aria-modal + aria-labelledby wiring\n * - DESIGN.md header treatment (uppercase tracking-widest)\n * - shared right-aligned footer row\n */\nexport function Modal({\n open,\n onClose,\n title,\n showClose,\n density = \"form\",\n maxWidth = \"max-w-md\",\n footer,\n children,\n}: ModalProps) {\n const titleIdRef = useRef(\n `hl-modal-${Math.random().toString(36).slice(2, 8)}`,\n );\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") onClose();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onClose]);\n\n if (!open) return null;\n\n const showCloseBtn = showClose ?? Boolean(title);\n const overlayClass = density === \"media\" ? \"bg-black/90\" : \"bg-black/70\";\n\n return (\n <div\n className={cn(\n \"fixed inset-0 z-50 flex items-center justify-center p-4\",\n overlayClass,\n )}\n onClick={onClose}\n role=\"presentation\"\n >\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={title ? titleIdRef.current : undefined}\n className={cn(\n \"w-full rounded-[1rem] border-2 border-[#1e1e1e] bg-[#111111] p-5\",\n maxWidth,\n )}\n onClick={(e) => e.stopPropagation()}\n >\n {(title || showCloseBtn) && (\n <div className=\"mb-4 flex items-start justify-between gap-3\">\n {title && (\n <h2\n id={titleIdRef.current}\n className=\"text-sm font-semibold uppercase tracking-widest text-white\"\n >\n {title}\n </h2>\n )}\n {showCloseBtn && (\n <button\n type=\"button\"\n onClick={onClose}\n className=\"ml-auto rounded-[0.25rem] p-1 text-white/40 transition hover:bg-[#252525] hover:text-white active:scale-90\"\n aria-label=\"Close\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n )}\n {children}\n {footer && (\n <div className=\"mt-5 flex flex-wrap items-center justify-end gap-2\">\n {footer}\n </div>\n )}\n </div>\n </div>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type EmptyStateProps = {\n icon?: React.ReactNode;\n title?: string;\n message: string;\n action?: React.ReactNode;\n /**\n * `panel` — large empty state with warm card background (page bodies).\n * `inline` — compact text-only line for sidebar tree, popovers, etc.\n */\n variant?: \"panel\" | \"inline\";\n className?: string;\n};\n\nexport function EmptyState({\n icon,\n title,\n message,\n action,\n variant = \"panel\",\n className,\n}: EmptyStateProps) {\n if (variant === \"inline\") {\n return (\n <p className={cn(\"px-3 py-2 text-xs text-white/40\", className)}>\n {message}\n </p>\n );\n }\n return (\n <div\n className={cn(\n \"flex flex-col items-center justify-center gap-3 rounded-[1rem] bg-[#2a2620] p-12 text-center\",\n className,\n )}\n >\n {icon && <div className=\"text-white/30\">{icon}</div>}\n {title && (\n <h3 className=\"text-sm font-semibold text-white\">{title}</h3>\n )}\n <p className=\"text-sm text-white/60\">{message}</p>\n {action}\n </div>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { ArrowLeft, ChevronRight, ImageOff } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type Crumb = {\n label: string;\n onClick?: () => void;\n};\n\nexport type PageHeaderProps = {\n title: string;\n description?: string;\n /** Back link rendered above the title. */\n back?: { href: string; label: string; render?: (href: string, children: React.ReactNode) => React.ReactNode };\n /** Right-aligned actions slot. */\n actions?: React.ReactNode;\n /**\n * Header image URL (e.g. `/logos.png`). When the URL resolves, the\n * image is the visual title. When it 404s, a styled placeholder at\n * the same dimensions takes its place — the layout stays stable so\n * creating a new themed page \"just works\" the moment you drop a\n * matching PNG in /public/.\n *\n * When `imageSrc` is undefined, no image block renders and the\n * 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 (white + bold); earlier crumbs are clickable.\n */\n breadcrumb?: Crumb[];\n className?: string;\n};\n\n/**\n * Page-header block. The image block reserves space whenever `imageSrc`\n * is set — drop the PNG in /public/ and the layout just fills in.\n *\n * Text title rules:\n * - imageSrc set + image present → text becomes sr-only\n * - imageSrc set + image 404 → placeholder block + text becomes sr-only\n * - imageSrc unset → text title is visible\n * - breadcrumb provided → breadcrumb replaces text title regardless of image\n *\n * The back link uses a plain <a> by default. In Next.js, pass\n * `back.render` to use next/link:\n *\n * import Link from \"next/link\";\n * <PageHeader back={{ href: \"/foo\", label: \"Back\", render: (href, c) => <Link href={href}>{c}</Link> }} />\n */\nexport function PageHeader({\n title,\n description,\n back,\n actions,\n imageSrc,\n imageAlt,\n breadcrumb,\n className,\n}: PageHeaderProps) {\n const [imageErrored, setImageErrored] = useState(false);\n useEffect(() => {\n setImageErrored(false);\n }, [imageSrc]);\n\n const hasImageSlot = !!imageSrc;\n const imageRenders = hasImageSlot && !imageErrored;\n const hasBreadcrumb = Array.isArray(breadcrumb) && breadcrumb.length > 0;\n const textIsSrOnly = hasImageSlot;\n\n const backInner = back ? (\n <>\n <ArrowLeft className=\"h-3 w-3\" />\n {back.label}\n </>\n ) : null;\n\n return (\n <header\n className={cn(\n \"flex flex-wrap items-end justify-between gap-3\",\n className,\n )}\n >\n <div className=\"flex min-w-0 items-end gap-4\">\n {hasImageSlot &&\n (imageRenders ? (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={imageSrc}\n alt={imageAlt ?? title}\n onError={() => setImageErrored(true)}\n className=\"h-28 w-28 shrink-0 rounded-[1rem] object-contain md:h-32 md:w-32\"\n />\n ) : (\n <PlaceholderHeader title={title} imageSrc={imageSrc!} />\n ))}\n <div className=\"min-w-0\">\n {back &&\n (back.render ? (\n back.render(\n back.href,\n <span className=\"inline-flex items-center gap-1 text-xs text-white/40 transition hover:text-white\">\n {backInner}\n </span>,\n )\n ) : (\n <a\n href={back.href}\n className=\"inline-flex items-center gap-1 text-xs text-white/40 transition hover:text-white\"\n >\n {backInner}\n </a>\n ))}\n {hasBreadcrumb ? (\n <Breadcrumb\n crumbs={breadcrumb!}\n srTitle={title}\n className={back ? \"mt-1\" : \"\"}\n />\n ) : (\n <h1\n className={cn(\n \"text-xl font-semibold text-[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-white/40\">{description}</p>\n )}\n </div>\n </div>\n {actions && <div className=\"flex items-center gap-2\">{actions}</div>}\n </header>\n );\n}\n\nfunction PlaceholderHeader({\n title,\n imageSrc,\n}: {\n title: string;\n imageSrc: string;\n}) {\n const file = imageSrc.replace(/^\\//, \"\");\n return (\n <div\n title={`Drop ${file} in /public to brand this header`}\n className=\"flex h-28 w-28 shrink-0 flex-col items-center justify-center gap-1 rounded-[1rem] border-2 border-dashed border-[#1e1e1e] bg-[#2a2620] text-white/30 md:h-32 md:w-32\"\n >\n <ImageOff className=\"h-6 w-6\" />\n <span className=\"px-2 text-center text-[10px] font-semibold uppercase tracking-widest\">\n {title}\n </span>\n </div>\n );\n}\n\nfunction Breadcrumb({\n crumbs,\n srTitle,\n className,\n}: {\n crumbs: Crumb[];\n srTitle: string;\n className?: string;\n}) {\n return (\n <nav\n aria-label=\"Breadcrumb\"\n className={cn(\n \"flex flex-wrap items-center gap-1.5 text-xl font-semibold\",\n className,\n )}\n >\n <h1 className=\"sr-only\">{srTitle}</h1>\n {crumbs.map((crumb, idx) => {\n const isLast = idx === crumbs.length - 1;\n return (\n <span\n key={`${crumb.label}-${idx}`}\n className=\"flex items-center gap-1.5\"\n >\n {idx > 0 && (\n <ChevronRight\n className=\"h-4 w-4 shrink-0 text-white/30\"\n aria-hidden=\"true\"\n />\n )}\n {crumb.onClick && !isLast ? (\n <button\n type=\"button\"\n onClick={crumb.onClick}\n className=\"rounded px-1 text-white/45 transition hover:text-white\"\n >\n {crumb.label}\n </button>\n ) : (\n <span\n className={cn(\n isLast ? \"text-white\" : \"text-white/45\",\n isLast && \"px-1\",\n )}\n aria-current={isLast ? \"page\" : undefined}\n >\n {crumb.label}\n </span>\n )}\n </span>\n );\n })}\n </nav>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Menu, X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarProps = {\n /**\n * Logo image src — typically `/logo.png` or `/<project>-logo-256.png`.\n * Rendered at w-36 h-36 inside a centered block with px-4 pt-6 pb-5\n * + border-b-2 (doghouse-canonical spec).\n */\n logoSrc: string;\n /** Alt text for the logo + label shown in the mobile top bar. */\n logoAlt: string;\n /**\n * Slots — every section is optional. Render them in this order:\n * 1. Logo block (always)\n * 2. widgets — clock / weather / homepage rotator / etc\n * 3. nav — primary navigation\n * 4. middle — long-running content (folder tree, etc) — flex-1\n * 5. lower — secondary nav (manage-tags, settings link, etc)\n * 6. (spacer)\n * 7. admin — admin section\n * 8. footer — version stamp, etc\n */\n widgets?: React.ReactNode;\n nav?: React.ReactNode;\n middle?: React.ReactNode;\n lower?: React.ReactNode;\n admin?: React.ReactNode;\n footer?: React.ReactNode;\n /**\n * Mobile-drawer behavior. Controlled — the consumer owns the open\n * state so they can wire it from anywhere (top-bar hamburger,\n * keyboard shortcut, route change, etc.).\n *\n * When omitted, the Sidebar manages its own drawer state using the\n * built-in mobile top bar (hamburger renders top-left on screens < md).\n */\n mobileOpen?: boolean;\n onMobileOpenChange?: (open: boolean) => void;\n /** Extra classes on the outer <aside>. */\n className?: string;\n /**\n * Override the className used for the `middle` slot's wrapper.\n *\n * The default — `\"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[#1e1e1e] p-2\"` —\n * is correct for asset-den's \"single panel with a scrollable folder\n * tree inside.\" Override when the middle slot's content is itself a\n * stack of cards (e.g. doghouse's per-service status widgets) — those\n * already have `bg-[#1e1e1e]`, so wrapping them in another same-color\n * panel makes them disappear.\n *\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n middleClassName?: string;\n /**\n * When true, render a built-in mobile top bar (logo + hamburger) on\n * screens < md. Defaults to true. Disable when the consumer renders\n * its own top chrome.\n */\n showMobileTopBar?: boolean;\n};\n\n/**\n * Canonical homelab sidebar shell. Doghouse is the reference impl;\n * this is the extracted version every other project consumes.\n *\n * Dimensions are non-negotiable:\n * - md+ width: 15rem default (override per-project via `:root { --hl-sidebar-width: 18rem; }`)\n * - md+ position: fixed-flex in a `display:flex` parent\n * - mobile: off-canvas drawer at full sidebar width\n * - logo block: w-36 h-36 centered, px-4 pt-6 pb-5, border-b-2\n *\n * The component renders raw hex values (#111111, #1e1e1e) so it works\n * even when the consumer skips the Tailwind preset. Apply the preset\n * + globals.css for token-based overrides.\n */\nexport function Sidebar({\n logoSrc,\n logoAlt,\n widgets,\n nav,\n middle,\n lower,\n admin,\n footer,\n mobileOpen: controlledOpen,\n onMobileOpenChange,\n className,\n middleClassName = \"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[#1e1e1e] p-2\",\n showMobileTopBar = true,\n}: SidebarProps) {\n const [internalOpen, setInternalOpen] = useState(false);\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen : internalOpen;\n\n const setOpen = (next: boolean) => {\n if (!isControlled) setInternalOpen(next);\n onMobileOpenChange?.(next);\n };\n\n // Lock body scroll while the mobile drawer is open.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!open) return;\n const prev = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n return () => {\n document.body.style.overflow = prev;\n };\n }, [open]);\n\n return (\n <>\n {showMobileTopBar && (\n <div className=\"fixed inset-x-0 top-0 z-30 flex items-center gap-2 border-b-2 border-[#1e1e1e] bg-[#111111] px-3 py-2 md:hidden\">\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n className=\"rounded-[0.25rem] p-1.5 text-white transition hover:bg-[#252525] active:scale-90\"\n aria-label=\"Open menu\"\n >\n <Menu className=\"h-5 w-5\" />\n </button>\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={logoSrc} alt=\"\" width={24} height={24} />\n <p className=\"text-sm font-semibold text-white\">{logoAlt}</p>\n </div>\n )}\n\n {open && (\n <div\n className=\"fixed inset-0 z-40 bg-black/70 md:hidden\"\n onClick={() => setOpen(false)}\n role=\"presentation\"\n />\n )}\n\n <aside\n className={cn(\n \"fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,15rem)] shrink-0 flex-col border-r-2 border-[#1e1e1e] bg-[#111111] transition-transform md:static md:translate-x-0 md:h-screen\",\n open ? \"translate-x-0\" : \"-translate-x-full md:translate-x-0\",\n className,\n )}\n >\n <div className=\"relative flex flex-col items-center border-b-2 border-[#1e1e1e] px-4 pt-6 pb-5\">\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n src={logoSrc}\n alt={logoAlt}\n className=\"h-36 w-36 rounded-[1rem] object-contain\"\n />\n {showMobileTopBar && (\n <button\n type=\"button\"\n onClick={() => setOpen(false)}\n className=\"absolute right-3 top-3 rounded-[0.25rem] p-1 text-white/40 transition hover:bg-[#252525] hover:text-white md:hidden\"\n aria-label=\"Close menu\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n\n {widgets && <div className=\"mx-3 mt-3\">{widgets}</div>}\n\n {nav && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[#1e1e1e] p-2\">\n {nav}\n </nav>\n )}\n\n {middle && <div className={middleClassName}>{middle}</div>}\n\n {lower && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[#1e1e1e] p-2\">\n {lower}\n </nav>\n )}\n\n {!middle && <div className=\"flex-1\" />}\n\n {admin && <div>{admin}</div>}\n\n {footer && (\n <div className=\"border-t-2 border-[#1e1e1e] px-4 py-3 text-center text-xs text-white/20\">\n {footer}\n </div>\n )}\n </aside>\n </>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarNavItemProps = {\n label: string;\n icon?: React.ReactNode;\n active?: boolean;\n onClick?: () => void;\n /**\n * When set, renders as <a href>. Otherwise renders as <button>. In\n * Next.js, prefer passing `render` so you can wrap in next/link\n * without losing classes:\n *\n * <SidebarNavItem\n * label=\"Home\"\n * href=\"/\"\n * render={(props, children) => <Link {...props}>{children}</Link>}\n * />\n */\n href?: string;\n render?: (\n props: { href: string; className: string },\n children: React.ReactNode,\n ) => React.ReactNode;\n};\n\nconst ITEM_CLASS_BASE =\n \"flex items-center gap-2 rounded-[0.5rem] px-3 py-2.5 text-base font-medium transition-all\";\n\nconst itemClass = (active: boolean) =>\n cn(\n ITEM_CLASS_BASE,\n active\n ? \"bg-[#ff9900] text-white\"\n : \"text-white/45 hover:bg-white/5 hover:text-white\",\n );\n\n/**\n * Single nav row for the Sidebar's `nav` / `lower` slots. Matches the\n * doghouse-canonical style: active = orange bg + white text, inactive =\n * white/45 text + white/5 hover bg.\n */\nexport function SidebarNavItem({\n label,\n icon,\n active = false,\n onClick,\n href,\n render,\n}: SidebarNavItemProps) {\n const className = itemClass(active);\n const inner = (\n <>\n {icon}\n <span>{label}</span>\n </>\n );\n\n if (href && render) return <>{render({ href, className }, inner)}</>;\n if (href) {\n return (\n <a href={href} className={className}>\n {inner}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-pressed={active}\n className={`${className} w-full`}\n >\n {inner}\n </button>\n );\n}\n","\"use client\";\n\nimport { Loader2 } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SpinnerProps = {\n label?: string;\n className?: string;\n};\n\n/**\n * Consistent inline loading affordance — used wherever the app would\n * otherwise say \"Loading…\" in plain text. Compact spinner + label, color\n * is the same tertiary-text grey across the app.\n */\nexport function Spinner({ label = \"Loading…\", className }: SpinnerProps) {\n return (\n <p\n className={cn(\n \"flex items-center gap-2 px-3 py-2 text-xs text-white/40\",\n className,\n )}\n >\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n {label}\n </p>\n );\n}\n","/**\n * Pick black or white text for a solid color chip.\n *\n * The homelab status palette forces white text on every status color\n * EXCEPT yellow (warning: #eab308), which uses black for contrast. This\n * helper enforces that rule wherever a user-picked color drives a chip\n * background (TagChips, status badges, etc).\n *\n * The yellow rule generalizes — anything in the yellow band (high\n * luminance + warm hue) gets black ink.\n */\nexport function tagTextColor(hex: string): \"#ffffff\" | \"#000000\" {\n const normalized = hex.trim().replace(/^#/, \"\");\n if (normalized.length !== 3 && normalized.length !== 6) return \"#ffffff\";\n const expand =\n normalized.length === 3\n ? normalized\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : normalized;\n const r = parseInt(expand.slice(0, 2), 16);\n const g = parseInt(expand.slice(2, 4), 16);\n const b = parseInt(expand.slice(4, 6), 16);\n if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return \"#ffffff\";\n // Perceived luminance per ITU BT.601.\n const luma = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n return luma > 0.65 ? \"#000000\" : \"#ffffff\";\n}\n","\"use client\";\n\nimport { tagTextColor } from \"../lib/colors\";\n\nexport type TagRef = {\n id: string | number;\n name: string;\n color: string;\n};\n\nexport type TagChipsProps = {\n tags: TagRef[];\n size?: \"xs\" | \"sm\";\n onClick?: (tag: TagRef) => void;\n};\n\n/**\n * Render a row of solid-color tag chips. Yellow auto-flips text to\n * black per the homelab status-palette contrast rule (see tagTextColor).\n *\n * Renders `<button>` when interactive (`onClick` set), `<span>` when\n * read-only — keeps screen-reader semantics honest.\n */\nexport function TagChips({ tags, size = \"xs\", onClick }: TagChipsProps) {\n if (tags.length === 0) return null;\n const sizeClasses =\n size === \"xs\" ? \"px-1.5 py-0.5 text-[10px]\" : \"px-2 py-0.5 text-xs\";\n\n return (\n <div className=\"flex flex-wrap gap-1\">\n {tags.map((t) => {\n const style = {\n backgroundColor: t.color,\n color: tagTextColor(t.color),\n };\n const className = `inline-block rounded-[0.25rem] font-semibold ${sizeClasses}`;\n return onClick ? (\n <button\n key={t.id}\n type=\"button\"\n onClick={() => onClick(t)}\n className={`${className} transition active:scale-95`}\n style={style}\n >\n {t.name}\n </button>\n ) : (\n <span key={t.id} className={className} style={style}>\n {t.name}\n </span>\n );\n })}\n </div>\n );\n}\n","/**\n * Font CSS variable names used by `@jdcpuwiz/homelab-ui`'s Tailwind preset.\n *\n * Why constants and not re-exported loaders: Next.js's `next/font/google`\n * is a build-time directive scanned out of your *app* source by the SWC\n * plugin. The loader call MUST literally appear as `const X = Font(...)`\n * in your `app/layout.tsx`; pre-bundling it through a library turns\n * `const` into `var` and Next refuses to compile it. So the canonical\n * setup is \"paste this snippet into layout.tsx\" (see README) — and these\n * constants document the expected variable names so you wire `--font-*`\n * correctly without guessing.\n *\n * Snippet to paste into `app/layout.tsx`:\n *\n * import { Geist, Geist_Mono, Orbitron } from \"next/font/google\";\n *\n * const geistSans = Geist({ variable: \"--font-geist-sans\", subsets: [\"latin\"] });\n * const geistMono = Geist_Mono({ variable: \"--font-geist-mono\", subsets: [\"latin\"] });\n * const orbitron = Orbitron({ variable: \"--font-orbitron\", subsets: [\"latin\"] });\n *\n * <body className={`${geistSans.variable} ${geistMono.variable} ${orbitron.variable} antialiased`}>\n *\n * Sanity check after deploy:\n * getComputedStyle(document.body).fontFamily.startsWith('\"Geist\"')\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--font-geist-sans\",\n mono: \"--font-geist-mono\",\n display: \"--font-orbitron\",\n} as const;\n"],"mappings":"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","names":["Button","useEffect","jsx","jsx","jsxs","useEffect","jsx","jsxs","useEffect","Fragment","jsx","jsxs","useEffect","useEffect","useState","X","Fragment","jsx","jsxs","useState","useEffect","X","Fragment","jsx","jsxs","jsx","jsxs","jsx"]}
|
|
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-[#ffb84d]\";\n\nconst VARIANT: Record<Variant, string> = {\n primary: \"bg-[#ff9900] text-[#0a0a0a] hover:bg-[#cc6a1a]\",\n secondary: \"bg-[#1e1e1e] text-white hover:bg-[#252525]\",\n tertiary: \"text-white/60 hover:bg-[#252525] hover:text-white\",\n danger: \"bg-[#b91c1c] text-white hover:brightness-110\",\n};\n\nconst SIZE: Record<Size, string> = {\n sm: \"px-3 py-1 text-xs\",\n md: \"px-4 py-1.5 text-sm\",\n};\n\n/**\n * Homelab Button. Use the variant + size props; never pass raw color\n * classes through className. className is for layout extras only\n * (e.g. `w-full`).\n */\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n function Button(\n { variant = \"secondary\", size = \"md\", className, ...rest },\n ref,\n ) {\n return (\n <button\n ref={ref}\n {...rest}\n className={cn(BASE, VARIANT[variant], SIZE[size], className)}\n />\n );\n },\n);\n","import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Conditional Tailwind class joiner. Merges variants and resolves\n * conflicts (e.g. `cn(\"p-2\", \"p-4\")` → `\"p-4\"`).\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","\"use client\";\n\nimport { useEffect } from \"react\";\nimport { Modal } from \"./Modal\";\nimport { Button } from \"./Button\";\n\nexport type ConfirmDialogProps = {\n open: boolean;\n title: string;\n message: string;\n confirmLabel?: string;\n cancelLabel?: string;\n tone?: \"danger\" | \"primary\";\n onConfirm: () => void;\n onClose: () => void;\n};\n\n/**\n * Confirm/cancel dialog built on Modal + Button. Enter triggers confirm,\n * Esc cancels (via Modal's built-in handling).\n */\nexport function ConfirmDialog({\n open,\n title,\n message,\n confirmLabel = \"Confirm\",\n cancelLabel = \"Cancel\",\n tone = \"danger\",\n onConfirm,\n onClose,\n}: ConfirmDialogProps) {\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Enter\") onConfirm();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onConfirm]);\n\n return (\n <Modal\n open={open}\n onClose={onClose}\n title={title}\n footer={\n <>\n <Button variant=\"tertiary\" size=\"md\" onClick={onClose}>\n {cancelLabel}\n </Button>\n <Button\n variant={tone === \"danger\" ? \"danger\" : \"primary\"}\n size=\"md\"\n onClick={onConfirm}\n >\n {confirmLabel}\n </Button>\n </>\n }\n >\n <p className=\"whitespace-pre-line text-sm text-white/70\">{message}</p>\n </Modal>\n );\n}\n","\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\ntype Density = \"form\" | \"media\";\n\nexport type ModalProps = {\n open: boolean;\n onClose: () => void;\n title?: string;\n /** Show the close X in the top right. Default: true when title is set. */\n showClose?: boolean;\n /**\n * Overlay weight:\n * `form` — dialogs / pickers / confirms (`bg-black/70`)\n * `media` — lightbox / asset preview (`bg-black/90`)\n * Defaults to `form`.\n */\n density?: Density;\n /** Max width — default `max-w-md`. */\n maxWidth?: string;\n /** Footer slot — render buttons here. Always right-aligned. */\n footer?: React.ReactNode;\n children: React.ReactNode;\n};\n\n/**\n * Single modal frame. Every dialog routes through this:\n * - overlay + click-outside dismiss\n * - Esc-to-close\n * - role=dialog + aria-modal + aria-labelledby wiring\n * - DESIGN.md header treatment (uppercase tracking-widest)\n * - shared right-aligned footer row\n */\nexport function Modal({\n open,\n onClose,\n title,\n showClose,\n density = \"form\",\n maxWidth = \"max-w-md\",\n footer,\n children,\n}: ModalProps) {\n const titleIdRef = useRef(\n `hl-modal-${Math.random().toString(36).slice(2, 8)}`,\n );\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") onClose();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onClose]);\n\n if (!open) return null;\n\n const showCloseBtn = showClose ?? Boolean(title);\n const overlayClass = density === \"media\" ? \"bg-black/90\" : \"bg-black/70\";\n\n return (\n <div\n className={cn(\n \"fixed inset-0 z-50 flex items-center justify-center p-4\",\n overlayClass,\n )}\n onClick={onClose}\n role=\"presentation\"\n >\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={title ? titleIdRef.current : undefined}\n className={cn(\n \"w-full rounded-[1rem] border-2 border-[#1e1e1e] bg-[#111111] p-5\",\n maxWidth,\n )}\n onClick={(e) => e.stopPropagation()}\n >\n {(title || showCloseBtn) && (\n <div className=\"mb-4 flex items-start justify-between gap-3\">\n {title && (\n <h2\n id={titleIdRef.current}\n className=\"text-sm font-semibold uppercase tracking-widest text-white\"\n >\n {title}\n </h2>\n )}\n {showCloseBtn && (\n <button\n type=\"button\"\n onClick={onClose}\n className=\"ml-auto rounded-[0.25rem] p-1 text-white/40 transition hover:bg-[#252525] hover:text-white active:scale-90\"\n aria-label=\"Close\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n )}\n {children}\n {footer && (\n <div className=\"mt-5 flex flex-wrap items-center justify-end gap-2\">\n {footer}\n </div>\n )}\n </div>\n </div>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type EmptyStateProps = {\n icon?: React.ReactNode;\n title?: string;\n message: string;\n action?: React.ReactNode;\n /**\n * `panel` — large empty state with warm card background (page bodies).\n * `inline` — compact text-only line for sidebar tree, popovers, etc.\n */\n variant?: \"panel\" | \"inline\";\n className?: string;\n};\n\nexport function EmptyState({\n icon,\n title,\n message,\n action,\n variant = \"panel\",\n className,\n}: EmptyStateProps) {\n if (variant === \"inline\") {\n return (\n <p className={cn(\"px-3 py-2 text-xs text-white/40\", className)}>\n {message}\n </p>\n );\n }\n return (\n <div\n className={cn(\n \"flex flex-col items-center justify-center gap-3 rounded-[1rem] bg-[#2a2620] p-12 text-center\",\n className,\n )}\n >\n {icon && <div className=\"text-white/30\">{icon}</div>}\n {title && (\n <h3 className=\"text-sm font-semibold text-white\">{title}</h3>\n )}\n <p className=\"text-sm text-white/60\">{message}</p>\n {action}\n </div>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { ArrowLeft, ChevronRight, ImageOff } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type Crumb = {\n label: string;\n onClick?: () => void;\n};\n\nexport type PageHeaderProps = {\n title: string;\n description?: string;\n /** Back link rendered above the title. */\n back?: { href: string; label: string; render?: (href: string, children: React.ReactNode) => React.ReactNode };\n /** Right-aligned actions slot. */\n actions?: React.ReactNode;\n /**\n * Header image URL (e.g. `/logos.png`). When the URL resolves, the\n * image is the visual title. When it 404s, a styled placeholder at\n * the same dimensions takes its place — the layout stays stable so\n * creating a new themed page \"just works\" the moment you drop a\n * matching PNG in /public/.\n *\n * When `imageSrc` is undefined, no image block renders and the\n * 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 (white + bold); earlier crumbs are clickable.\n */\n breadcrumb?: Crumb[];\n className?: string;\n};\n\n/**\n * Page-header block. The image block reserves space whenever `imageSrc`\n * is set — drop the PNG in /public/ and the layout just fills in.\n *\n * Text title rules:\n * - imageSrc set + image present → text becomes sr-only\n * - imageSrc set + image 404 → placeholder block + text becomes sr-only\n * - imageSrc unset → text title is visible\n * - breadcrumb provided → breadcrumb replaces text title regardless of image\n *\n * The back link uses a plain <a> by default. In Next.js, pass\n * `back.render` to use next/link:\n *\n * import Link from \"next/link\";\n * <PageHeader back={{ href: \"/foo\", label: \"Back\", render: (href, c) => <Link href={href}>{c}</Link> }} />\n */\nexport function PageHeader({\n title,\n description,\n back,\n actions,\n imageSrc,\n imageAlt,\n breadcrumb,\n className,\n}: PageHeaderProps) {\n const [imageErrored, setImageErrored] = useState(false);\n useEffect(() => {\n setImageErrored(false);\n }, [imageSrc]);\n\n const hasImageSlot = !!imageSrc;\n const imageRenders = hasImageSlot && !imageErrored;\n const hasBreadcrumb = Array.isArray(breadcrumb) && breadcrumb.length > 0;\n const textIsSrOnly = hasImageSlot;\n\n const backInner = back ? (\n <>\n <ArrowLeft className=\"h-3 w-3\" />\n {back.label}\n </>\n ) : null;\n\n return (\n <header\n className={cn(\n \"flex flex-wrap items-end justify-between gap-3\",\n className,\n )}\n >\n <div className=\"flex min-w-0 items-end gap-4\">\n {hasImageSlot &&\n (imageRenders ? (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={imageSrc}\n alt={imageAlt ?? title}\n onError={() => setImageErrored(true)}\n className=\"h-28 w-28 shrink-0 rounded-[1rem] object-contain md:h-32 md:w-32\"\n />\n ) : (\n <PlaceholderHeader title={title} imageSrc={imageSrc!} />\n ))}\n <div className=\"min-w-0\">\n {back &&\n (back.render ? (\n back.render(\n back.href,\n <span className=\"inline-flex items-center gap-1 text-xs text-white/40 transition hover:text-white\">\n {backInner}\n </span>,\n )\n ) : (\n <a\n href={back.href}\n className=\"inline-flex items-center gap-1 text-xs text-white/40 transition hover:text-white\"\n >\n {backInner}\n </a>\n ))}\n {hasBreadcrumb ? (\n <Breadcrumb\n crumbs={breadcrumb!}\n srTitle={title}\n className={back ? \"mt-1\" : \"\"}\n />\n ) : (\n <h1\n className={cn(\n \"text-xl font-semibold text-[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-white/40\">{description}</p>\n )}\n </div>\n </div>\n {actions && <div className=\"flex items-center gap-2\">{actions}</div>}\n </header>\n );\n}\n\nfunction PlaceholderHeader({\n title,\n imageSrc,\n}: {\n title: string;\n imageSrc: string;\n}) {\n const file = imageSrc.replace(/^\\//, \"\");\n return (\n <div\n title={`Drop ${file} in /public to brand this header`}\n className=\"flex h-28 w-28 shrink-0 flex-col items-center justify-center gap-1 rounded-[1rem] border-2 border-dashed border-[#1e1e1e] bg-[#2a2620] text-white/30 md:h-32 md:w-32\"\n >\n <ImageOff className=\"h-6 w-6\" />\n <span className=\"px-2 text-center text-[10px] font-semibold uppercase tracking-widest\">\n {title}\n </span>\n </div>\n );\n}\n\nfunction Breadcrumb({\n crumbs,\n srTitle,\n className,\n}: {\n crumbs: Crumb[];\n srTitle: string;\n className?: string;\n}) {\n return (\n <nav\n aria-label=\"Breadcrumb\"\n className={cn(\n \"flex flex-wrap items-center gap-1.5 text-xl font-semibold\",\n className,\n )}\n >\n <h1 className=\"sr-only\">{srTitle}</h1>\n {crumbs.map((crumb, idx) => {\n const isLast = idx === crumbs.length - 1;\n return (\n <span\n key={`${crumb.label}-${idx}`}\n className=\"flex items-center gap-1.5\"\n >\n {idx > 0 && (\n <ChevronRight\n className=\"h-4 w-4 shrink-0 text-white/30\"\n aria-hidden=\"true\"\n />\n )}\n {crumb.onClick && !isLast ? (\n <button\n type=\"button\"\n onClick={crumb.onClick}\n className=\"rounded px-1 text-white/45 transition hover:text-white\"\n >\n {crumb.label}\n </button>\n ) : (\n <span\n className={cn(\n isLast ? \"text-white\" : \"text-white/45\",\n isLast && \"px-1\",\n )}\n aria-current={isLast ? \"page\" : undefined}\n >\n {crumb.label}\n </span>\n )}\n </span>\n );\n })}\n </nav>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Menu, X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarProps = {\n /**\n * Logo image src — typically `/logo.png` or `/<project>-logo-256.png`.\n * Rendered at w-36 h-36 inside a centered block with px-4 pt-6 pb-5\n * + border-b-2 (doghouse-canonical spec). 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-[#1e1e1e] p-2\"` —\n * is correct for asset-den's \"single panel with a scrollable folder\n * tree inside.\" Override when the middle slot's content is itself a\n * stack of cards (e.g. doghouse's per-service status widgets) — those\n * already have `bg-[#1e1e1e]`, so wrapping them in another same-color\n * panel makes them disappear.\n *\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n middleClassName?: string;\n /**\n * When true, render a built-in mobile top bar (logo + hamburger) on\n * screens < md. Defaults to true. Disable when the consumer renders\n * its own top chrome.\n */\n showMobileTopBar?: boolean;\n};\n\n/**\n * Canonical homelab sidebar shell. Doghouse is the reference impl;\n * this is the extracted version every other project consumes.\n *\n * Dimensions are non-negotiable:\n * - md+ width: 15rem default (override per-project via `:root { --hl-sidebar-width: 18rem; }`)\n * - md+ position: fixed-flex in a `display:flex` parent\n * - mobile: off-canvas drawer at full sidebar width\n * - logo block: w-36 h-36 centered, px-4 pt-6 pb-5, border-b-2\n *\n * The component renders raw hex values (#111111, #1e1e1e) so it works\n * even when the consumer skips the Tailwind preset. Apply the preset\n * + globals.css for token-based overrides.\n */\nexport function Sidebar({\n logoSrc,\n logoAlt,\n 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-[#1e1e1e] p-2\",\n showMobileTopBar = true,\n}: SidebarProps) {\n const [internalOpen, setInternalOpen] = useState(false);\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen : internalOpen;\n\n const setOpen = (next: boolean) => {\n if (!isControlled) setInternalOpen(next);\n onMobileOpenChange?.(next);\n };\n\n // Lock body scroll while the mobile drawer is open.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!open) return;\n const prev = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n return () => {\n document.body.style.overflow = prev;\n };\n }, [open]);\n\n return (\n <>\n {showMobileTopBar && (\n <div className=\"fixed inset-x-0 top-0 z-30 flex items-center gap-2 border-b-2 border-[#1e1e1e] bg-[#111111] px-3 py-2 md:hidden\">\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n className=\"rounded-[0.25rem] p-1.5 text-white transition hover:bg-[#252525] active:scale-90\"\n aria-label=\"Open menu\"\n >\n <Menu className=\"h-5 w-5\" />\n </button>\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={logoSrc} alt=\"\" width={24} height={24} />\n <p className=\"text-sm font-semibold text-white\">{logoAlt}</p>\n </div>\n )}\n\n {open && (\n <div\n className=\"fixed inset-0 z-40 bg-black/70 md:hidden\"\n onClick={() => setOpen(false)}\n role=\"presentation\"\n />\n )}\n\n <aside\n className={cn(\n \"fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,15rem)] shrink-0 flex-col border-r-2 border-[#1e1e1e] bg-[#111111] transition-transform md:static md:translate-x-0 md:h-screen\",\n open ? \"translate-x-0\" : \"-translate-x-full md:translate-x-0\",\n className,\n )}\n >\n <div className=\"relative flex flex-col items-center border-b-2 border-[#1e1e1e] px-4 pt-6 pb-5\">\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n src={logoSrc}\n alt={logoAlt}\n className={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-white/40 transition hover:bg-[#252525] hover:text-white md:hidden\"\n aria-label=\"Close menu\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n\n {widgets && <div className=\"mx-3 mt-3\">{widgets}</div>}\n\n {nav && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[#1e1e1e] p-2\">\n {nav}\n </nav>\n )}\n\n {middle && <div className={middleClassName}>{middle}</div>}\n\n {lower && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[#1e1e1e] p-2\">\n {lower}\n </nav>\n )}\n\n {!middle && <div className=\"flex-1\" />}\n\n {admin && <div>{admin}</div>}\n\n {footer && (\n <div className=\"border-t-2 border-[#1e1e1e] px-4 py-3 text-center text-xs text-white/20\">\n {footer}\n </div>\n )}\n </aside>\n </>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarNavItemProps = {\n label: string;\n icon?: React.ReactNode;\n active?: boolean;\n onClick?: () => void;\n /**\n * When set, renders as <a href>. Otherwise renders as <button>. In\n * Next.js, prefer passing `render` so you can wrap in next/link\n * without losing classes:\n *\n * <SidebarNavItem\n * label=\"Home\"\n * href=\"/\"\n * render={(props, children) => <Link {...props}>{children}</Link>}\n * />\n */\n href?: string;\n render?: (\n props: { href: string; className: string },\n children: React.ReactNode,\n ) => React.ReactNode;\n};\n\nconst ITEM_CLASS_BASE =\n \"flex items-center gap-2 rounded-[0.5rem] px-3 py-2.5 text-base font-medium transition-all\";\n\nconst itemClass = (active: boolean) =>\n cn(\n ITEM_CLASS_BASE,\n active\n ? \"bg-[#ff9900] text-white\"\n : \"text-white/45 hover:bg-white/5 hover:text-white\",\n );\n\n/**\n * Single nav row for the Sidebar's `nav` / `lower` slots. Matches the\n * doghouse-canonical style: active = orange bg + white text, inactive =\n * white/45 text + white/5 hover bg.\n */\nexport function SidebarNavItem({\n label,\n icon,\n active = false,\n onClick,\n href,\n render,\n}: SidebarNavItemProps) {\n const className = itemClass(active);\n const inner = (\n <>\n {icon}\n <span>{label}</span>\n </>\n );\n\n if (href && render) return <>{render({ href, className }, inner)}</>;\n if (href) {\n return (\n <a href={href} className={className}>\n {inner}\n </a>\n );\n }\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-pressed={active}\n className={`${className} w-full`}\n >\n {inner}\n </button>\n );\n}\n","\"use client\";\n\nimport { Loader2 } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SpinnerProps = {\n label?: string;\n className?: string;\n};\n\n/**\n * Consistent inline loading affordance — used wherever the app would\n * otherwise say \"Loading…\" in plain text. Compact spinner + label, color\n * is the same tertiary-text grey across the app.\n */\nexport function Spinner({ label = \"Loading…\", className }: SpinnerProps) {\n return (\n <p\n className={cn(\n \"flex items-center gap-2 px-3 py-2 text-xs text-white/40\",\n className,\n )}\n >\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n {label}\n </p>\n );\n}\n","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 * - tabular numerals → ticking numbers don't wiggle the layout\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-[#1e1e1e] px-4 py-3\", className)}>\n <p className=\"truncate text-[10px] font-semibold uppercase tracking-widest text-[#e2e4e9]\">\n {label}\n </p>\n <p className=\"mt-1 truncate text-xl font-semibold tabular-nums text-white\">{value}</p>\n <p className=\"truncate text-[11px] tabular-nums text-[#e2e4e9]\">{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 CSS variable names used by `@jdcpuwiz/homelab-ui`'s Tailwind preset.\n *\n * Why constants and not re-exported loaders: Next.js's `next/font/google`\n * is a build-time directive scanned out of your *app* source by the SWC\n * plugin. The loader call MUST literally appear as `const X = Font(...)`\n * in your `app/layout.tsx`; pre-bundling it through a library turns\n * `const` into `var` and Next refuses to compile it. So the canonical\n * setup is \"paste this snippet into layout.tsx\" (see README) — and these\n * constants document the expected variable names so you wire `--font-*`\n * correctly without guessing.\n *\n * Snippet to paste into `app/layout.tsx`:\n *\n * import { Geist, Geist_Mono, Orbitron } from \"next/font/google\";\n *\n * const geistSans = Geist({ variable: \"--font-geist-sans\", subsets: [\"latin\"] });\n * const geistMono = Geist_Mono({ variable: \"--font-geist-mono\", subsets: [\"latin\"] });\n * const orbitron = Orbitron({ variable: \"--font-orbitron\", subsets: [\"latin\"] });\n *\n * <body className={`${geistSans.variable} ${geistMono.variable} ${orbitron.variable} antialiased`}>\n *\n * Sanity check after deploy:\n * getComputedStyle(document.body).fontFamily.startsWith('\"Geist\"')\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--font-geist-sans\",\n mono: \"--font-geist-mono\",\n display: \"--font-orbitron\",\n} as const;\n"],"mappings":"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","names":["Button","useEffect","jsx","jsx","jsxs","useEffect","jsx","jsxs","useEffect","Fragment","jsx","jsxs","useEffect","useEffect","useState","X","Fragment","jsx","jsxs","useState","useEffect","X","Fragment","jsx","jsxs","jsx","jsxs","jsx","jsxs","jsx"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jdcpuwiz/homelab-ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Shared dark-theme UI primitives for the JdCpuWiz homelab
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Shared dark-theme UI primitives for the JdCpuWiz homelab \u2014 Sidebar, Button, Modal, PageHeader, TagChips, plus the canonical Tailwind preset, CSS vars, and next/font wiring.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Wiz <shad@deckerzoo.com>",
|
|
7
7
|
"repository": {
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"ladle": "ladle serve",
|
|
46
46
|
"ladle:build": "ladle build",
|
|
47
47
|
"typecheck": "tsc --noEmit",
|
|
48
|
-
"prepublishOnly": "npm run build"
|
|
48
|
+
"prepublishOnly": "npm run build",
|
|
49
|
+
"prepare": "npm run build"
|
|
49
50
|
},
|
|
50
51
|
"peerDependencies": {
|
|
51
52
|
"next": ">=15",
|
|
@@ -83,4 +84,4 @@
|
|
|
83
84
|
"react",
|
|
84
85
|
"design-system"
|
|
85
86
|
]
|
|
86
|
-
}
|
|
87
|
+
}
|