@jdcpuwiz/homelab-ui 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -94,9 +94,23 @@ Two colors carry the whole UI: flat **`#f0f1f4`** for everything you read
94
94
  dimmer grey) and **`#ff9900`** for numeric values. **Page titles are NOT a
95
95
  special color** — the old page-title purple `#aa89b7` is removed; an h1 is
96
96
  `text-xl font-semibold` in the flat foreground. The `text-white/60·/40·/30`
97
- opacity ramp is dead (`text-ink-*` tokens now all resolve to the one flat
98
- foreground). Version stamps stay at `text-white/20`; status pills keep their
99
- solid semantic bg + white text.
97
+ opacity ramp is dead **as a hierarchy device** (`text-ink-*` tokens now all
98
+ resolve to the one flat foreground). Status pills keep their solid semantic
99
+ bg + white text.
100
+
101
+ What survives the ramp is **chrome you aren't reading**, and since 0.9.0 that
102
+ is two tokens rather than five literals:
103
+
104
+ - **`text-foreground-muted`** — deliberately secondary, usually interactive:
105
+ an inactive nav row, a clickable breadcrumb, a back link, a close X, a
106
+ tertiary button, a "Loading…" line. Hovering one lifts it to the full
107
+ foreground.
108
+ - **`text-foreground-faint`** — chrome you do NOT read: breadcrumb chevrons,
109
+ an empty-state icon, the dashed placeholder art, the footer version stamp.
110
+ Never put a sentence in this.
111
+
112
+ Do not add a third step — that is the ramp coming back. Reading text is still
113
+ one flat shade.
100
114
 
101
115
  ### This supersedes both prior standards
102
116
 
@@ -104,9 +118,11 @@ Poppins is what the fleet originally shipped on (wiz3d-prints, the first
104
118
  project) before it drifted to Geist; #57 brings everyone back to it. It replaces
105
119
  the old "Geist via `next/font`" mandate AND the system-ui-only ruling (#345)
106
120
  that briefly replaced it. **Orbitron is sanctioned AND applied** (reversing
107
- #345, which had killed it). Until an app is migrated it may still render Geist or
108
- system-ui — that's known rollout state (tracked in BP #57), not drift to "fix"
109
- ad hoc.
121
+ #345, which had killed it). **BP #57 shipped fleet-wide (all 6 phases done,
122
+ P6 verify closed it out)** — every project is migrated onto the three-face
123
+ standard + the flat `#f0f1f4` foreground. If an app still renders Geist or
124
+ system-ui after this, that's a real regression, not known rollout state — go
125
+ fix it, don't shrug it off.
110
126
 
111
127
  > **Sanity check after deploy:** open DevTools and run
112
128
  > `getComputedStyle(document.body).fontFamily`. It should name **Poppins**. If it
@@ -219,13 +235,13 @@ Done. The app boots with the right fonts, sidebar, and theme.
219
235
  | Component | Purpose |
220
236
  |-------------------|---------|
221
237
  | `Sidebar` | Doghouse-canonical shell. Slots: `widgets`, `nav`, `middle`, `lower`, `admin`, `footer`. w-60 fixed md+, off-canvas drawer on mobile (built-in hamburger + close X). |
222
- | `SidebarNavItem` | Active = `bg-brand` + white text; inactive = `text-white/45` + `bg-white/5` hover. Supports `href` (auto `<a>`) or `render` (next/link wrapper). |
238
+ | `SidebarNavItem` | Active = `bg-brand` + `text-brand-fg`; inactive = `text-foreground-muted` lifting to full foreground over a `bg-overlay-hover` wash. Supports `href` (auto `<a>`) or `render` (next/link wrapper). |
223
239
  | `Button` | `variant` × `size`. Variants: `primary` (brand orange), `secondary` (card), `tertiary` (ghost), `danger` (red). Sizes: `sm`, `md`. |
224
240
  | `Modal` | Overlay + click-outside + Esc + role=dialog + aria-* wiring + footer slot. `density: "form" \| "media"`. |
225
241
  | `ConfirmDialog` | Modal + Button×2, Enter triggers confirm. `tone: "danger" \| "primary"`. |
226
242
  | `PageHeader` | Title + description + back link + actions slot + optional header image (drop a PNG in `/public/` to brand). Supports breadcrumbs. |
227
243
  | `EmptyState` | `panel` (warm card body) or `inline` (text-only) variant. |
228
- | `Spinner` | `<Loader2 />` + label, sized for the dashboard's tertiary-text grey. |
244
+ | `Spinner` | `<Loader2 />` + label, on the standard `--hl-foreground-muted`. |
229
245
  | `TagChips` | Solid-color pills with auto white/black text contrast on yellow. Read-only `<span>` or interactive `<button>`. |
230
246
  | `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. |
231
247
 
@@ -256,8 +272,8 @@ projects **unless a page explicitly calls out otherwise**:
256
272
  3. **`#7d838d` is the input-placeholder token ONLY.** All reading text —
257
273
  labels, captions, secondary lines, table meta — is flat `#f0f1f4`
258
274
  (BP #57; hierarchy from size/weight, never a dimmer grey). Version
259
- stamps are `text-white/20`. Grey body text reads as broken on the
260
- dark theme.
275
+ stamps are `text-foreground-faint`. Grey body text reads as broken on
276
+ the dark theme.
261
277
  4. **Wide content scrolls in its own container** (`overflow-x-auto` on
262
278
  tables/code blocks) — the page body never scrolls horizontally.
263
279
 
@@ -281,6 +297,10 @@ The Tailwind preset exposes the canonical homelab palette:
281
297
  | `bg-status-success` (`#15803d`), `bg-status-info` (`#1d4ed8`), `bg-status-warning` (`#eab308`, **black text**), `bg-status-danger` (`#b91c1c`), `bg-status-special` (`#6d28d9`), `bg-status-neutral` (`#6b7280`), `bg-status-empty` (`#4b5563`), `bg-status-primary` (= brand, **black text**) | Status pills — solid bg + white text per the global rule (yellow + primary take black). |
282
298
  | `border-border` `border-input` `border-input-hover` | Borders |
283
299
  | `bg-overlay` (`rgba(0,0,0,0.9)`) `bg-overlay-chip` (`rgba(0,0,0,0.6)`) | Modal backdrops / hover chips over images |
300
+ | `bg-overlay-hover` (`rgba(255,255,255,0.05)`) | The wash under a hovered nav row / ghost control. Themeable — override `--hl-hover-overlay` with your own tint. |
301
+ | `text-foreground` (flat `#f0f1f4`) | The ONE reading color. Everything you actually read. |
302
+ | `text-foreground-muted` (`rgba(255,255,255,0.45)`) | Deliberately secondary chrome, usually interactive — inactive nav rows, clickable breadcrumbs, back links, close buttons, tertiary buttons, "Loading…". Hover lifts to `text-foreground`. |
303
+ | `text-foreground-faint` (`rgba(255,255,255,0.30)`) | Chrome you do NOT read — breadcrumb chevrons, empty-state icons, placeholder art, footer version stamps. Never a sentence. |
284
304
  | `text-title` (= flat `#f0f1f4`) | h1 title color — **not a special hue** (BP #57 removed the page-title purple); resolves to the flat foreground |
285
305
  | `text-ink-primary` `text-ink-secondary` `text-ink-tertiary` | Reading text — all resolve to the one flat `#f0f1f4` (the old opacity ramp is dead; kept as back-compat aliases). `text-ink-disabled` stays a genuine dim for disabled affordances. |
286
306
  | `rounded-widget` (xl), `rounded-row` (lg), `rounded-chip`, `rounded-panel` (2xl) | Radius aliases |
@@ -298,9 +318,43 @@ The CSS variables are namespaced (`--hl-*`). Redefine them at `:root` in your ow
298
318
  --hl-brand: #00aaff; /* swap brand orange for blue */
299
319
  --hl-sidebar: #0a1929;
300
320
  --hl-card: #112844;
321
+
322
+ /* Don't stop at the surfaces — the dimmed chrome has tokens too (0.9.0),
323
+ and left alone it stays cold white-alpha over your palette. */
324
+ --hl-foreground: #dce8f5;
325
+ --hl-foreground-muted: rgba(220, 232, 245, 0.55);
326
+ --hl-foreground-faint: rgba(220, 232, 245, 0.30);
327
+ --hl-hover-overlay: rgba(220, 232, 245, 0.08);
301
328
  }
302
329
  ```
303
330
 
331
+ **This only actually worked from 0.8.0 on.** Up to 0.7.1 the components
332
+ themselves wrote the fleet palette as literal hex — `bg-[#ff9900]`,
333
+ `bg-[#111111]`, `bg-[#1e1e1e]`, `bg-[#252525]`, `bg-[#2a2620]`,
334
+ `text-[#f0f1f4]` — so an app that redefined those vars still rendered
335
+ orange-and-charcoal, with nothing to error on. (zoo-genes hit it first and
336
+ shipped a scoped CSS override to claw its own green/warm theme back.) They
337
+ now read `bg-[var(--hl-brand)]` and friends.
338
+
339
+ **0.9.0 finished the job** for the dimmed half. Every `text-white/45 · /40 ·
340
+ /30 · /20 · /60` and `hover:bg-white/5` in the package became
341
+ `--hl-foreground-muted`, `--hl-foreground-faint` or `--hl-hover-overlay`, so
342
+ package chrome now follows a warm or light theme instead of staying cold grey
343
+ against it. If you're re-skinning, pin **>= 0.9.0** and set those three vars
344
+ alongside your surfaces.
345
+
346
+ One thing did NOT become themeable, on purpose: **status colors**
347
+ (`bg-status-*`, and the Button `danger` variant — including its white label,
348
+ which is text on a status chip, not reading text) are a fixed fleet semantic.
349
+ Green means OK in every app.
350
+
351
+ ### Why components use `bg-[var(--hl-card)]`, not `bg-card`
352
+
353
+ Both resolve to the same value, but the arbitrary-value form keeps working
354
+ for a consumer who scans `dist/**` without applying the preset — which the
355
+ package promises in "Without Next.js". Inside this package, prefer the
356
+ `var()` form. In your own app code, `bg-card` is fine.
357
+
304
358
  ## Without Next.js
305
359
 
306
360
  Every component works standalone. The `--hl-*` tokens are plain CSS in
@@ -327,6 +381,13 @@ You're missing the `node_modules/@jdcpuwiz/homelab-ui/dist/**` entry in your `ta
327
381
 
328
382
  Same as above — Tailwind isn't scanning the package's dist files.
329
383
 
384
+ ### I overrode `--hl-brand` but the nav pill / buttons are still orange
385
+
386
+ You're on 0.7.1 or older, where the components hardcoded the hex. Bump to
387
+ **0.8.0+**. Confirm with
388
+ `getComputedStyle(document.querySelector('[aria-pressed=true]')).backgroundColor`
389
+ — it should report your color, not `rgb(255, 153, 0)`.
390
+
330
391
  ### `bg-brand` is undefined
331
392
 
332
393
  You skipped the Tailwind preset. Add `presets: [require("@jdcpuwiz/homelab-ui/tailwind-preset")]` to your `tailwind.config`. Alternatively, use the raw CSS variables: `style={{ backgroundColor: "var(--hl-brand)" }}`.
package/dist/index.cjs CHANGED
@@ -51,11 +51,11 @@ function cn(...inputs) {
51
51
 
52
52
  // src/components/Button.tsx
53
53
  var import_jsx_runtime = require("react/jsx-runtime");
54
- var BASE = "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]";
54
+ var BASE = "inline-flex items-center justify-center gap-1.5 rounded-[0.5rem] font-semibold transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--hl-brand-glow)]";
55
55
  var VARIANT = {
56
- primary: "bg-[#ff9900] text-[#0a0a0a] hover:bg-[#cc6a1a]",
57
- secondary: "bg-[#1e1e1e] text-white hover:bg-[#252525]",
58
- tertiary: "text-white/60 hover:bg-[#252525] hover:text-white",
56
+ primary: "bg-[var(--hl-brand)] text-[var(--hl-brand-fg)] hover:bg-[var(--hl-brand-hover)]",
57
+ secondary: "bg-[var(--hl-card)] text-[var(--hl-foreground)] hover:bg-[var(--hl-card-hover)]",
58
+ tertiary: "text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)]",
59
59
  danger: "bg-[#b91c1c] text-white hover:brightness-110"
60
60
  };
61
61
  var SIZE = {
@@ -122,7 +122,7 @@ function Modal({
122
122
  "aria-modal": "true",
123
123
  "aria-labelledby": title ? titleIdRef.current : void 0,
124
124
  className: cn(
125
- "w-full rounded-[1rem] border-2 border-[#1e1e1e] bg-[#111111] p-5",
125
+ "w-full rounded-[1rem] border-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] p-5",
126
126
  maxWidth
127
127
  ),
128
128
  onClick: (e) => e.stopPropagation(),
@@ -132,7 +132,7 @@ function Modal({
132
132
  "h2",
133
133
  {
134
134
  id: titleIdRef.current,
135
- className: "text-sm font-semibold uppercase tracking-widest text-white",
135
+ className: "text-sm font-semibold uppercase tracking-widest text-[var(--hl-foreground)]",
136
136
  children: title
137
137
  }
138
138
  ),
@@ -141,7 +141,7 @@ function Modal({
141
141
  {
142
142
  type: "button",
143
143
  onClick: onClose,
144
- className: "ml-auto rounded-[0.25rem] p-1 text-white/40 transition hover:bg-[#252525] hover:text-white active:scale-90",
144
+ className: "ml-auto rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] active:scale-90",
145
145
  "aria-label": "Close",
146
146
  children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react.X, { className: "h-4 w-4" })
147
147
  }
@@ -194,7 +194,7 @@ function ConfirmDialog({
194
194
  }
195
195
  )
196
196
  ] }),
197
- children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "whitespace-pre-line text-sm text-[#f0f1f4]", children: message })
197
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "whitespace-pre-line text-sm text-[var(--hl-foreground)]", children: message })
198
198
  }
199
199
  );
200
200
  }
@@ -210,19 +210,19 @@ function EmptyState({
210
210
  className
211
211
  }) {
212
212
  if (variant === "inline") {
213
- return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: cn("px-3 py-2 text-xs text-[#f0f1f4]", className), children: message });
213
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: cn("px-3 py-2 text-xs text-[var(--hl-foreground)]", className), children: message });
214
214
  }
215
215
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
216
216
  "div",
217
217
  {
218
218
  className: cn(
219
- "flex flex-col items-center justify-center gap-3 rounded-[1rem] bg-[#2a2620] p-12 text-center",
219
+ "flex flex-col items-center justify-center gap-3 rounded-[1rem] bg-[var(--hl-card-warm)] p-12 text-center",
220
220
  className
221
221
  ),
222
222
  children: [
223
- icon && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "text-white/30", children: icon }),
224
- title && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("h3", { className: "text-sm font-semibold text-white", children: title }),
225
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "text-sm text-[#f0f1f4]", children: message }),
223
+ icon && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "text-[var(--hl-foreground-faint)]", children: icon }),
224
+ title && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("h3", { className: "text-sm font-semibold text-[var(--hl-foreground)]", children: title }),
225
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "text-sm text-[var(--hl-foreground)]", children: message }),
226
226
  action
227
227
  ]
228
228
  }
@@ -279,12 +279,12 @@ function PageHeader({
279
279
  /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "min-w-0", children: [
280
280
  back && (back.render ? back.render(
281
281
  back.href,
282
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { className: "inline-flex items-center gap-1 text-xs text-white/40 transition hover:text-white", children: backInner })
282
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { className: "inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]", children: backInner })
283
283
  ) : /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
284
284
  "a",
285
285
  {
286
286
  href: back.href,
287
- className: "inline-flex items-center gap-1 text-xs text-white/40 transition hover:text-white",
287
+ className: "inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]",
288
288
  children: backInner
289
289
  }
290
290
  )),
@@ -306,7 +306,7 @@ function PageHeader({
306
306
  children: title
307
307
  }
308
308
  ),
309
- description && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("p", { className: "mt-1 text-xs text-[#f0f1f4]", children: description })
309
+ description && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("p", { className: "mt-1 text-xs text-[var(--hl-foreground)]", children: description })
310
310
  ] })
311
311
  ] }),
312
312
  actions && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { className: "flex items-center gap-2", children: actions })
@@ -323,7 +323,7 @@ function PlaceholderHeader({
323
323
  "div",
324
324
  {
325
325
  title: `Drop ${file} in /public to brand this header`,
326
- 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",
326
+ className: "flex h-28 w-28 shrink-0 flex-col items-center justify-center gap-1 rounded-[1rem] border-2 border-dashed border-[var(--hl-border)] bg-[var(--hl-card-warm)] text-[var(--hl-foreground-faint)] md:h-32 md:w-32",
327
327
  children: [
328
328
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_lucide_react2.ImageOff, { className: "h-6 w-6" }),
329
329
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { className: "px-2 text-center text-[10px] font-semibold uppercase tracking-widest", children: title })
@@ -356,7 +356,7 @@ function Breadcrumb({
356
356
  idx > 0 && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
357
357
  import_lucide_react2.ChevronRight,
358
358
  {
359
- className: "h-4 w-4 shrink-0 text-white/30",
359
+ className: "h-4 w-4 shrink-0 text-[var(--hl-foreground-faint)]",
360
360
  "aria-hidden": "true"
361
361
  }
362
362
  ),
@@ -365,14 +365,14 @@ function Breadcrumb({
365
365
  {
366
366
  type: "button",
367
367
  onClick: crumb.onClick,
368
- className: "rounded px-1 text-white/45 transition hover:text-white",
368
+ className: "rounded px-1 text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]",
369
369
  children: crumb.label
370
370
  }
371
371
  ) : /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
372
372
  "span",
373
373
  {
374
374
  className: cn(
375
- isLast ? "text-white" : "text-white/45",
375
+ isLast ? "text-[var(--hl-foreground)]" : "text-[var(--hl-foreground-muted)]",
376
376
  isLast && "px-1"
377
377
  ),
378
378
  "aria-current": isLast ? "page" : void 0,
@@ -406,7 +406,7 @@ function Sidebar({
406
406
  mobileOpen: controlledOpen,
407
407
  onMobileOpenChange,
408
408
  className,
409
- middleClassName = "mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[#1e1e1e] p-2",
409
+ middleClassName = "mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2",
410
410
  showMobileTopBar = true
411
411
  }) {
412
412
  const [internalOpen, setInternalOpen] = (0, import_react5.useState)(false);
@@ -426,19 +426,19 @@ function Sidebar({
426
426
  };
427
427
  }, [open]);
428
428
  return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(import_jsx_runtime6.Fragment, { children: [
429
- showMobileTopBar && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "fixed inset-x-0 top-0 z-30 flex items-center gap-2 border-b-2 border-[#1e1e1e] bg-[#111111] px-3 py-2 md:hidden", children: [
429
+ showMobileTopBar && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "fixed inset-x-0 top-0 z-30 flex items-center gap-2 border-b-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] px-3 py-2 md:hidden", children: [
430
430
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
431
431
  "button",
432
432
  {
433
433
  type: "button",
434
434
  onClick: () => setOpen(true),
435
- className: "rounded-[0.25rem] p-1.5 text-white transition hover:bg-[#252525] active:scale-90",
435
+ className: "rounded-[0.25rem] p-1.5 text-[var(--hl-foreground)] transition hover:bg-[var(--hl-card-hover)] active:scale-90",
436
436
  "aria-label": "Open menu",
437
437
  children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_lucide_react3.Menu, { className: "h-5 w-5" })
438
438
  }
439
439
  ),
440
440
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("img", { src: logoSrc, alt: "", width: 24, height: 24 }),
441
- /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("p", { className: "text-sm font-semibold text-white", children: logoAlt })
441
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("p", { className: "text-sm font-semibold text-[var(--hl-foreground)]", children: logoAlt })
442
442
  ] }),
443
443
  open && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
444
444
  "div",
@@ -452,12 +452,12 @@ function Sidebar({
452
452
  "aside",
453
453
  {
454
454
  className: cn(
455
- "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",
455
+ "fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,15rem)] shrink-0 flex-col border-r-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] transition-transform md:static md:translate-x-0 md:h-screen",
456
456
  open ? "translate-x-0" : "-translate-x-full md:translate-x-0",
457
457
  className
458
458
  ),
459
459
  children: [
460
- /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "relative flex flex-col items-center border-b-2 border-[#1e1e1e] px-4 pt-6 pb-5", children: [
460
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "relative flex flex-col items-center border-b-2 border-[var(--hl-border)] px-4 pt-6 pb-5", children: [
461
461
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
462
462
  "img",
463
463
  {
@@ -471,19 +471,19 @@ function Sidebar({
471
471
  {
472
472
  type: "button",
473
473
  onClick: () => setOpen(false),
474
- className: "absolute right-3 top-3 rounded-[0.25rem] p-1 text-white/40 transition hover:bg-[#252525] hover:text-white md:hidden",
474
+ className: "absolute right-3 top-3 rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] md:hidden",
475
475
  "aria-label": "Close menu",
476
476
  children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_lucide_react3.X, { className: "h-4 w-4" })
477
477
  }
478
478
  )
479
479
  ] }),
480
480
  widgets && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "mx-3 mt-3", children: widgets }),
481
- nav && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("nav", { className: "mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[#1e1e1e] p-2", children: nav }),
481
+ nav && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("nav", { className: "mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2", children: nav }),
482
482
  middle && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: middleClassName, children: middle }),
483
- lower && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("nav", { className: "mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[#1e1e1e] p-2", children: lower }),
483
+ lower && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("nav", { className: "mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2", children: lower }),
484
484
  !middle && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "flex-1" }),
485
485
  admin && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { children: admin }),
486
- footer && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "border-t-2 border-[#1e1e1e] px-4 py-3 text-center text-xs text-white/20", children: footer })
486
+ footer && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "border-t-2 border-[var(--hl-border)] px-4 py-3 text-center text-xs text-[var(--hl-foreground-faint)]", children: footer })
487
487
  ]
488
488
  }
489
489
  )
@@ -495,7 +495,7 @@ var import_jsx_runtime7 = require("react/jsx-runtime");
495
495
  var ITEM_CLASS_BASE = "flex items-center gap-2 rounded-[0.5rem] px-3 py-2.5 text-base font-medium transition-all";
496
496
  var itemClass = (active) => cn(
497
497
  ITEM_CLASS_BASE,
498
- active ? "bg-[#ff9900] text-white" : "text-white/45 hover:bg-white/5 hover:text-white"
498
+ active ? "bg-[var(--hl-brand)] text-[var(--hl-brand-fg)]" : "text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-hover-overlay)] hover:text-[var(--hl-foreground)]"
499
499
  );
500
500
  function SidebarNavItem({
501
501
  label,
@@ -534,7 +534,7 @@ function Spinner({ label = "Loading\u2026", className }) {
534
534
  "p",
535
535
  {
536
536
  className: cn(
537
- "flex items-center gap-2 px-3 py-2 text-xs text-white/40",
537
+ "flex items-center gap-2 px-3 py-2 text-xs text-[var(--hl-foreground-muted)]",
538
538
  className
539
539
  ),
540
540
  children: [
@@ -548,10 +548,10 @@ function Spinner({ label = "Loading\u2026", className }) {
548
548
  // src/components/StatTile.tsx
549
549
  var import_jsx_runtime9 = require("react/jsx-runtime");
550
550
  function StatTile({ label, value, sub, className }) {
551
- return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: cn("min-w-0 rounded-lg bg-[#1e1e1e] px-4 py-3", className), children: [
552
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: "truncate text-[10px] font-semibold uppercase tracking-widest text-[#f0f1f4]", children: label }),
553
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: "mt-1 truncate text-xl font-semibold tabular-nums text-[#f0f1f4]", children: value }),
554
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: "truncate text-[11px] tabular-nums text-[#f0f1f4]", children: sub ?? "\xA0" })
551
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: cn("min-w-0 rounded-lg bg-[var(--hl-card)] px-4 py-3", className), children: [
552
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: "truncate text-[10px] font-semibold uppercase tracking-widest text-[var(--hl-foreground)]", children: label }),
553
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: "mt-1 truncate text-xl font-semibold tabular-nums text-[var(--hl-foreground)]", children: value }),
554
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: "truncate text-[11px] tabular-nums text-[var(--hl-foreground)]", children: sub ?? "\xA0" })
555
555
  ] });
556
556
  }
557
557
  function StatTileRow({ children, minTileWidth = "9rem", className }) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/components/Button.tsx","../src/lib/utils.ts","../src/components/ConfirmDialog.tsx","../src/components/Modal.tsx","../src/components/EmptyState.tsx","../src/components/PageHeader.tsx","../src/components/Sidebar.tsx","../src/components/SidebarNavItem.tsx","../src/components/Spinner.tsx","../src/components/StatTile.tsx","../src/lib/colors.ts","../src/components/TagChips.tsx","../src/lib/fonts.ts"],"sourcesContent":["// Components\nexport { Button, type ButtonProps } from \"./components/Button\";\nexport {\n ConfirmDialog,\n type ConfirmDialogProps,\n} from \"./components/ConfirmDialog\";\nexport { EmptyState, type EmptyStateProps } from \"./components/EmptyState\";\nexport { Modal, type ModalProps } from \"./components/Modal\";\nexport {\n PageHeader,\n type PageHeaderProps,\n type Crumb,\n} from \"./components/PageHeader\";\nexport { Sidebar, type SidebarProps } from \"./components/Sidebar\";\nexport {\n SidebarNavItem,\n type SidebarNavItemProps,\n} from \"./components/SidebarNavItem\";\nexport { Spinner, type SpinnerProps } from \"./components/Spinner\";\nexport {\n StatTile,\n StatTileRow,\n type StatTileProps,\n type StatTileRowProps,\n} from \"./components/StatTile\";\nexport {\n TagChips,\n type TagChipsProps,\n type TagRef,\n} from \"./components/TagChips\";\n\n// Helpers\nexport { cn } from \"./lib/utils\";\nexport { tagTextColor } from \"./lib/colors\";\nexport { FONT_CSS_VARIABLES, FONT_APP_VARIABLES } from \"./lib/fonts\";\n","\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { cn } from \"../lib/utils\";\n\ntype Variant = \"primary\" | \"secondary\" | \"tertiary\" | \"danger\";\ntype Size = \"sm\" | \"md\";\n\nexport type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {\n variant?: Variant;\n size?: Size;\n};\n\nconst BASE =\n \"inline-flex items-center justify-center gap-1.5 rounded-[0.5rem] font-semibold transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#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-[#f0f1f4]\">{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-[#f0f1f4]\", 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-[#f0f1f4]\">{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-[#f0f1f4]\">{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-[#f0f1f4]\">\n {label}\n </p>\n <p className=\"mt-1 truncate text-xl font-semibold tabular-nums text-[#f0f1f4]\">{value}</p>\n <p className=\"truncate text-[11px] tabular-nums text-[#f0f1f4]\">{sub ?? \" \"}</p>\n </div>\n );\n}\n\nexport type StatTileRowProps = {\n children: React.ReactNode;\n /** Minimum tile width before the grid drops a column. Default \"9rem\". */\n minTileWidth?: string;\n className?: string;\n};\n\n/**\n * Full-width, equal-column container for StatTiles. `auto-fit` +\n * `minmax(minTileWidth, 1fr)` means the row stretches edge-to-edge with\n * the browser and sheds whole columns on narrow screens — tiles never\n * overflow off-screen and never wrap their text.\n *\n * Pair with the layout standard: content pages are FULL WIDTH (no\n * max-w-* caps) unless a page explicitly opts out.\n */\nexport function StatTileRow({ children, minTileWidth = \"9rem\", className }: StatTileRowProps) {\n return (\n <div\n className={cn(\"grid gap-3\", className)}\n style={{ gridTemplateColumns: `repeat(auto-fit, minmax(${minTileWidth}, 1fr))` }}\n >\n {children}\n </div>\n );\n}\n","/**\n * Pick black or white text for a solid color chip.\n *\n * The homelab status palette forces white text on every status color\n * EXCEPT yellow (warning: #eab308), which uses black for contrast. This\n * helper enforces that rule wherever a user-picked color drives a chip\n * background (TagChips, status badges, etc).\n *\n * The yellow rule generalizes — anything in the yellow band (high\n * luminance + warm hue) gets black ink.\n */\nexport function tagTextColor(hex: string): \"#ffffff\" | \"#000000\" {\n const normalized = hex.trim().replace(/^#/, \"\");\n if (normalized.length !== 3 && normalized.length !== 6) return \"#ffffff\";\n const expand =\n normalized.length === 3\n ? normalized\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : normalized;\n const r = parseInt(expand.slice(0, 2), 16);\n const g = parseInt(expand.slice(2, 4), 16);\n const b = parseInt(expand.slice(4, 6), 16);\n if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return \"#ffffff\";\n // Perceived luminance per ITU BT.601.\n const luma = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n return luma > 0.65 ? \"#000000\" : \"#ffffff\";\n}\n","\"use client\";\n\nimport { tagTextColor } from \"../lib/colors\";\n\nexport type TagRef = {\n id: string | number;\n name: string;\n color: string;\n};\n\nexport type TagChipsProps = {\n tags: TagRef[];\n size?: \"xs\" | \"sm\";\n onClick?: (tag: TagRef) => void;\n};\n\n/**\n * Render a row of solid-color tag chips. Yellow auto-flips text to\n * black per the homelab status-palette contrast rule (see tagTextColor).\n *\n * Renders `<button>` when interactive (`onClick` set), `<span>` when\n * read-only — keeps screen-reader semantics honest.\n */\nexport function TagChips({ tags, size = \"xs\", onClick }: TagChipsProps) {\n if (tags.length === 0) return null;\n const sizeClasses =\n size === \"xs\" ? \"px-1.5 py-0.5 text-[10px]\" : \"px-2 py-0.5 text-xs\";\n\n return (\n <div className=\"flex flex-wrap gap-1\">\n {tags.map((t) => {\n const style = {\n backgroundColor: t.color,\n color: tagTextColor(t.color),\n };\n const className = `inline-block rounded-[0.25rem] font-semibold ${sizeClasses}`;\n return onClick ? (\n <button\n key={t.id}\n type=\"button\"\n onClick={() => onClick(t)}\n className={`${className} transition active:scale-95`}\n style={style}\n >\n {t.name}\n </button>\n ) : (\n <span key={t.id} className={className} style={style}>\n {t.name}\n </span>\n );\n })}\n </div>\n );\n}\n","/**\n * Font wiring for `@jdcpuwiz/homelab-ui`.\n *\n * ## The standard: three self-hosted faces, split by JOB (BuildPlan #57)\n *\n * Wiz ruling 2026-07-24. This SUPERSEDES both the old \"Geist via next/font\"\n * mandate AND the system-ui-only ruling (#345) that briefly replaced it.\n *\n * SANS Poppins — all body / UI / headings / labels\n * DISPLAY Orbitron — BIG numbers only (stat heroes, clock, ≥ ~1.5rem)\n * MONO JetBrains Mono — small values, table figures, version stamps,\n * code (< ~1.5rem)\n *\n * The size line is the hard boundary: Orbitron is a display face and must\n * NEVER be applied to a dense small cell — if a number is small, it's\n * JetBrains Mono.\n *\n * ## Loading is PER-APP — the package can't do it for you\n *\n * A `next/font` loader cannot be re-exported from this package: Next's SWC\n * plugin scans the app's OWN source for literal `const Foo = Font({...})`\n * calls, and bundling rewrites `const`→`var` so Next refuses. So each app\n * self-hosts its faces and binds them to the CSS vars this package reads.\n * The package only owns the `--hl-font-*` wiring in `globals.css`.\n *\n * The convention (see the `starter/` folder for copy-paste layouts):\n *\n * - Next apps: `next/font/google`, each loader given the matching\n * `variable` from `FONT_APP_VARIABLES` below, Poppins weights 300–800,\n * `display: \"swap\"`. Add the three `.variable` classes to `<body>`.\n * - Vite apps: `@fontsource/poppins` etc. — the plain family name resolves,\n * no CSS var needed (the package's `--hl-font-*` already falls back to the\n * literal family name).\n *\n * `globals.css` declares `--hl-font-sans` / `--hl-font-display` /\n * `--hl-font-mono` referencing the app variables below first, then the literal\n * face name, then the platform fallback — so text is readable even mid-load\n * and an un-migrated app degrades to the system stack, never to nothing.\n *\n * Sanity check after deploy — this should name Poppins:\n * getComputedStyle(document.body).fontFamily // → \"…Poppins…\"\n */\n\n/**\n * The package's own token names — the `--hl-font-*` vars the Tailwind preset's\n * `font-sans` / `font-mono` / `font-display` classes resolve to. Consumers\n * rarely touch these directly.\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--hl-font-sans\",\n mono: \"--hl-font-mono\",\n display: \"--hl-font-display\",\n} as const;\n\n/**\n * The app-side CSS variable each next/font loader must expose (its `variable:`\n * option). `globals.css` reads these, so an app that follows this naming gets\n * the three faces wired with zero extra config. Match these EXACTLY.\n *\n * const poppins = Poppins({ ..., variable: FONT_APP_VARIABLES.sans });\n */\nexport const FONT_APP_VARIABLES = {\n sans: \"--font-poppins\",\n display: \"--font-orbitron\",\n mono: \"--font-jetbrains-mono\",\n} as const;\n"],"mappings":"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","names":["Button","import_react","import_react","import_jsx_runtime","import_jsx_runtime","import_jsx_runtime","import_react","import_lucide_react","import_jsx_runtime","import_react","import_lucide_react","import_jsx_runtime","import_jsx_runtime","import_lucide_react","import_jsx_runtime","import_jsx_runtime","import_jsx_runtime"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/components/Button.tsx","../src/lib/utils.ts","../src/components/ConfirmDialog.tsx","../src/components/Modal.tsx","../src/components/EmptyState.tsx","../src/components/PageHeader.tsx","../src/components/Sidebar.tsx","../src/components/SidebarNavItem.tsx","../src/components/Spinner.tsx","../src/components/StatTile.tsx","../src/lib/colors.ts","../src/components/TagChips.tsx","../src/lib/fonts.ts"],"sourcesContent":["// Components\nexport { Button, type ButtonProps } from \"./components/Button\";\nexport {\n ConfirmDialog,\n type ConfirmDialogProps,\n} from \"./components/ConfirmDialog\";\nexport { EmptyState, type EmptyStateProps } from \"./components/EmptyState\";\nexport { Modal, type ModalProps } from \"./components/Modal\";\nexport {\n PageHeader,\n type PageHeaderProps,\n type Crumb,\n} from \"./components/PageHeader\";\nexport { Sidebar, type SidebarProps } from \"./components/Sidebar\";\nexport {\n SidebarNavItem,\n type SidebarNavItemProps,\n} from \"./components/SidebarNavItem\";\nexport { Spinner, type SpinnerProps } from \"./components/Spinner\";\nexport {\n StatTile,\n StatTileRow,\n type StatTileProps,\n type StatTileRowProps,\n} from \"./components/StatTile\";\nexport {\n TagChips,\n type TagChipsProps,\n type TagRef,\n} from \"./components/TagChips\";\n\n// Helpers\nexport { cn } from \"./lib/utils\";\nexport { tagTextColor } from \"./lib/colors\";\nexport { FONT_CSS_VARIABLES, FONT_APP_VARIABLES } from \"./lib/fonts\";\n","\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { cn } from \"../lib/utils\";\n\ntype Variant = \"primary\" | \"secondary\" | \"tertiary\" | \"danger\";\ntype Size = \"sm\" | \"md\";\n\nexport type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {\n variant?: Variant;\n size?: Size;\n};\n\nconst BASE =\n \"inline-flex items-center justify-center gap-1.5 rounded-[0.5rem] font-semibold transition active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--hl-brand-glow)]\";\n\n// `danger`'s BACKGROUND stays a literal — status colors are a fixed fleet\n// semantic (the preset hardcodes them too), not a per-project brand token.\n// Its label stays literal `text-white` for the same reason: that is text\n// ON a status chip, which lib/colors.ts already rules is always white,\n// NOT reading text on an app surface. Everything else here is a token.\nconst VARIANT: Record<Variant, string> = {\n primary:\n \"bg-[var(--hl-brand)] text-[var(--hl-brand-fg)] hover:bg-[var(--hl-brand-hover)]\",\n secondary:\n \"bg-[var(--hl-card)] text-[var(--hl-foreground)] hover:bg-[var(--hl-card-hover)]\",\n tertiary:\n \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)]\",\n danger: \"bg-[#b91c1c] text-white hover:brightness-110\",\n};\n\nconst SIZE: Record<Size, string> = {\n sm: \"px-3 py-1 text-xs\",\n md: \"px-4 py-1.5 text-sm\",\n};\n\n/**\n * Homelab Button. Use the variant + size props; never pass raw color\n * classes through className. className is for layout extras only\n * (e.g. `w-full`).\n */\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n function Button(\n { variant = \"secondary\", size = \"md\", className, ...rest },\n ref,\n ) {\n return (\n <button\n ref={ref}\n {...rest}\n className={cn(BASE, VARIANT[variant], SIZE[size], className)}\n />\n );\n },\n);\n","import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Conditional Tailwind class joiner. Merges variants and resolves\n * conflicts (e.g. `cn(\"p-2\", \"p-4\")` → `\"p-4\"`).\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","\"use client\";\n\nimport { useEffect } from \"react\";\nimport { Modal } from \"./Modal\";\nimport { Button } from \"./Button\";\n\nexport type ConfirmDialogProps = {\n open: boolean;\n title: string;\n message: string;\n confirmLabel?: string;\n cancelLabel?: string;\n tone?: \"danger\" | \"primary\";\n onConfirm: () => void;\n onClose: () => void;\n};\n\n/**\n * Confirm/cancel dialog built on Modal + Button. Enter triggers confirm,\n * Esc cancels (via Modal's built-in handling).\n */\nexport function ConfirmDialog({\n open,\n title,\n message,\n confirmLabel = \"Confirm\",\n cancelLabel = \"Cancel\",\n tone = \"danger\",\n onConfirm,\n onClose,\n}: ConfirmDialogProps) {\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Enter\") onConfirm();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onConfirm]);\n\n return (\n <Modal\n open={open}\n onClose={onClose}\n title={title}\n footer={\n <>\n <Button variant=\"tertiary\" size=\"md\" onClick={onClose}>\n {cancelLabel}\n </Button>\n <Button\n variant={tone === \"danger\" ? \"danger\" : \"primary\"}\n size=\"md\"\n onClick={onConfirm}\n >\n {confirmLabel}\n </Button>\n </>\n }\n >\n <p className=\"whitespace-pre-line text-sm text-[var(--hl-foreground)]\">{message}</p>\n </Modal>\n );\n}\n","\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\ntype Density = \"form\" | \"media\";\n\nexport type ModalProps = {\n open: boolean;\n onClose: () => void;\n title?: string;\n /** Show the close X in the top right. Default: true when title is set. */\n showClose?: boolean;\n /**\n * Overlay weight:\n * `form` — dialogs / pickers / confirms (`bg-black/70`)\n * `media` — lightbox / asset preview (`bg-black/90`)\n * Defaults to `form`.\n */\n density?: Density;\n /** Max width — default `max-w-md`. */\n maxWidth?: string;\n /** Footer slot — render buttons here. Always right-aligned. */\n footer?: React.ReactNode;\n children: React.ReactNode;\n};\n\n/**\n * Single modal frame. Every dialog routes through this:\n * - overlay + click-outside dismiss\n * - Esc-to-close\n * - role=dialog + aria-modal + aria-labelledby wiring\n * - DESIGN.md header treatment (uppercase tracking-widest)\n * - shared right-aligned footer row\n */\nexport function Modal({\n open,\n onClose,\n title,\n showClose,\n density = \"form\",\n maxWidth = \"max-w-md\",\n footer,\n children,\n}: ModalProps) {\n const titleIdRef = useRef(\n `hl-modal-${Math.random().toString(36).slice(2, 8)}`,\n );\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") onClose();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onClose]);\n\n if (!open) return null;\n\n const showCloseBtn = showClose ?? Boolean(title);\n const overlayClass = density === \"media\" ? \"bg-black/90\" : \"bg-black/70\";\n\n return (\n <div\n className={cn(\n \"fixed inset-0 z-50 flex items-center justify-center p-4\",\n overlayClass,\n )}\n onClick={onClose}\n role=\"presentation\"\n >\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={title ? titleIdRef.current : undefined}\n className={cn(\n \"w-full rounded-[1rem] border-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] p-5\",\n maxWidth,\n )}\n onClick={(e) => e.stopPropagation()}\n >\n {(title || showCloseBtn) && (\n <div className=\"mb-4 flex items-start justify-between gap-3\">\n {title && (\n <h2\n id={titleIdRef.current}\n className=\"text-sm font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\"\n >\n {title}\n </h2>\n )}\n {showCloseBtn && (\n <button\n type=\"button\"\n onClick={onClose}\n className=\"ml-auto rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] active:scale-90\"\n aria-label=\"Close\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n )}\n {children}\n {footer && (\n <div className=\"mt-5 flex flex-wrap items-center justify-end gap-2\">\n {footer}\n </div>\n )}\n </div>\n </div>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type EmptyStateProps = {\n icon?: React.ReactNode;\n title?: string;\n message: string;\n action?: React.ReactNode;\n /**\n * `panel` — large empty state with warm card background (page bodies).\n * `inline` — compact text-only line for sidebar tree, popovers, etc.\n */\n variant?: \"panel\" | \"inline\";\n className?: string;\n};\n\nexport function EmptyState({\n icon,\n title,\n message,\n action,\n variant = \"panel\",\n className,\n}: EmptyStateProps) {\n if (variant === \"inline\") {\n return (\n <p className={cn(\"px-3 py-2 text-xs text-[var(--hl-foreground)]\", className)}>\n {message}\n </p>\n );\n }\n return (\n <div\n className={cn(\n \"flex flex-col items-center justify-center gap-3 rounded-[1rem] bg-[var(--hl-card-warm)] p-12 text-center\",\n className,\n )}\n >\n {icon && (\n <div className=\"text-[var(--hl-foreground-faint)]\">{icon}</div>\n )}\n {title && (\n <h3 className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {title}\n </h3>\n )}\n <p className=\"text-sm text-[var(--hl-foreground)]\">{message}</p>\n {action}\n </div>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { ArrowLeft, ChevronRight, ImageOff } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type Crumb = {\n label: string;\n onClick?: () => void;\n};\n\nexport type PageHeaderProps = {\n title: string;\n description?: string;\n /** Back link rendered above the title. */\n back?: { href: string; label: string; render?: (href: string, children: React.ReactNode) => React.ReactNode };\n /** Right-aligned actions slot. */\n actions?: React.ReactNode;\n /**\n * Header image URL (e.g. `/logos.png`). When the URL resolves, the\n * image is the visual title. When it 404s, a styled placeholder at\n * the same dimensions takes its place — the layout stays stable so\n * creating a new themed page \"just works\" the moment you drop a\n * matching PNG in /public/.\n *\n * When `imageSrc` is undefined, no image block renders and the\n * text title is the visual title.\n */\n imageSrc?: string;\n imageAlt?: string;\n /**\n * Breadcrumb trail. When provided, replaces the text title. Last\n * crumb is current (full foreground + bold); earlier crumbs are muted\n * and clickable.\n */\n breadcrumb?: Crumb[];\n className?: string;\n};\n\n/**\n * Page-header block. The image block reserves space whenever `imageSrc`\n * is set — drop the PNG in /public/ and the layout just fills in.\n *\n * Text title rules:\n * - imageSrc set + image present → text becomes sr-only\n * - imageSrc set + image 404 → placeholder block + text becomes sr-only\n * - imageSrc unset → text title is visible\n * - breadcrumb provided → breadcrumb replaces text title regardless of image\n *\n * The back link uses a plain <a> by default. In Next.js, pass\n * `back.render` to use next/link:\n *\n * import Link from \"next/link\";\n * <PageHeader back={{ href: \"/foo\", label: \"Back\", render: (href, c) => <Link href={href}>{c}</Link> }} />\n */\nexport function PageHeader({\n title,\n description,\n back,\n actions,\n imageSrc,\n imageAlt,\n breadcrumb,\n className,\n}: PageHeaderProps) {\n const [imageErrored, setImageErrored] = useState(false);\n useEffect(() => {\n setImageErrored(false);\n }, [imageSrc]);\n\n const hasImageSlot = !!imageSrc;\n const imageRenders = hasImageSlot && !imageErrored;\n const hasBreadcrumb = Array.isArray(breadcrumb) && breadcrumb.length > 0;\n const textIsSrOnly = hasImageSlot;\n\n const backInner = back ? (\n <>\n <ArrowLeft className=\"h-3 w-3\" />\n {back.label}\n </>\n ) : null;\n\n return (\n <header\n className={cn(\n \"flex flex-wrap items-end justify-between gap-3\",\n className,\n )}\n >\n <div className=\"flex min-w-0 items-end gap-4\">\n {hasImageSlot &&\n (imageRenders ? (\n // eslint-disable-next-line @next/next/no-img-element\n <img\n src={imageSrc}\n alt={imageAlt ?? title}\n onError={() => setImageErrored(true)}\n className=\"h-28 w-28 shrink-0 rounded-[1rem] object-contain md:h-32 md:w-32\"\n />\n ) : (\n <PlaceholderHeader title={title} imageSrc={imageSrc!} />\n ))}\n <div className=\"min-w-0\">\n {back &&\n (back.render ? (\n back.render(\n back.href,\n <span className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\">\n {backInner}\n </span>,\n )\n ) : (\n <a\n href={back.href}\n className=\"inline-flex items-center gap-1 text-xs text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {backInner}\n </a>\n ))}\n {hasBreadcrumb ? (\n <Breadcrumb\n crumbs={breadcrumb!}\n srTitle={title}\n className={back ? \"mt-1\" : \"\"}\n />\n ) : (\n <h1\n className={cn(\n \"text-xl font-semibold text-[var(--hl-title)]\",\n back && \"mt-1\",\n textIsSrOnly && \"sr-only\",\n )}\n >\n {title}\n </h1>\n )}\n {description && (\n <p className=\"mt-1 text-xs text-[var(--hl-foreground)]\">{description}</p>\n )}\n </div>\n </div>\n {actions && <div className=\"flex items-center gap-2\">{actions}</div>}\n </header>\n );\n}\n\nfunction PlaceholderHeader({\n title,\n imageSrc,\n}: {\n title: string;\n imageSrc: string;\n}) {\n const file = imageSrc.replace(/^\\//, \"\");\n return (\n <div\n title={`Drop ${file} in /public to brand this header`}\n className=\"flex h-28 w-28 shrink-0 flex-col items-center justify-center gap-1 rounded-[1rem] border-2 border-dashed border-[var(--hl-border)] bg-[var(--hl-card-warm)] text-[var(--hl-foreground-faint)] md:h-32 md:w-32\"\n >\n <ImageOff className=\"h-6 w-6\" />\n <span className=\"px-2 text-center text-[10px] font-semibold uppercase tracking-widest\">\n {title}\n </span>\n </div>\n );\n}\n\nfunction Breadcrumb({\n crumbs,\n srTitle,\n className,\n}: {\n crumbs: Crumb[];\n srTitle: string;\n className?: string;\n}) {\n return (\n <nav\n aria-label=\"Breadcrumb\"\n className={cn(\n \"flex flex-wrap items-center gap-1.5 text-xl font-semibold\",\n className,\n )}\n >\n <h1 className=\"sr-only\">{srTitle}</h1>\n {crumbs.map((crumb, idx) => {\n const isLast = idx === crumbs.length - 1;\n return (\n <span\n key={`${crumb.label}-${idx}`}\n className=\"flex items-center gap-1.5\"\n >\n {idx > 0 && (\n <ChevronRight\n className=\"h-4 w-4 shrink-0 text-[var(--hl-foreground-faint)]\"\n aria-hidden=\"true\"\n />\n )}\n {crumb.onClick && !isLast ? (\n <button\n type=\"button\"\n onClick={crumb.onClick}\n className=\"rounded px-1 text-[var(--hl-foreground-muted)] transition hover:text-[var(--hl-foreground)]\"\n >\n {crumb.label}\n </button>\n ) : (\n <span\n className={cn(\n isLast\n ? \"text-[var(--hl-foreground)]\"\n : \"text-[var(--hl-foreground-muted)]\",\n isLast && \"px-1\",\n )}\n aria-current={isLast ? \"page\" : undefined}\n >\n {crumb.label}\n </span>\n )}\n </span>\n );\n })}\n </nav>\n );\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { Menu, X } from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarProps = {\n /**\n * Logo image src — typically `/logo.png` or `/<project>-logo-256.png`.\n * Rendered at w-36 h-36 inside a centered block with px-4 pt-6 pb-5\n * + border-b-2 (doghouse-canonical spec). Override sizing/shape via\n * `logoClassName`.\n */\n logoSrc: string;\n /** Alt text for the logo + label shown in the mobile top bar. */\n logoAlt: string;\n /**\n * Override the className applied to the logo `<img>`. Default is\n * `\"h-36 w-36 rounded-[1rem] object-contain\"` (doghouse-canonical\n * square logo). Pass `\"h-auto w-full rounded-md object-contain\"` for\n * full-width banner-style logos, or any other Tailwind classes.\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n logoClassName?: string;\n /**\n * Slots — every section is optional. Render them in this order:\n * 1. Logo block (always)\n * 2. widgets — clock / weather / homepage rotator / etc\n * 3. nav — primary navigation\n * 4. middle — long-running content (folder tree, etc) — flex-1\n * 5. lower — secondary nav (manage-tags, settings link, etc)\n * 6. (spacer)\n * 7. admin — admin section\n * 8. footer — version stamp, etc\n */\n widgets?: React.ReactNode;\n nav?: React.ReactNode;\n middle?: React.ReactNode;\n lower?: React.ReactNode;\n admin?: React.ReactNode;\n footer?: React.ReactNode;\n /**\n * Mobile-drawer behavior. Controlled — the consumer owns the open\n * state so they can wire it from anywhere (top-bar hamburger,\n * keyboard shortcut, route change, etc.).\n *\n * When omitted, the Sidebar manages its own drawer state using the\n * built-in mobile top bar (hamburger renders top-left on screens < md).\n */\n mobileOpen?: boolean;\n onMobileOpenChange?: (open: boolean) => void;\n /** Extra classes on the outer <aside>. */\n className?: string;\n /**\n * Override the className used for the `middle` slot's wrapper.\n *\n * The default — `\"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\"` —\n * is correct for asset-den's \"single panel with a scrollable folder\n * tree inside.\" Override when the middle slot's content is itself a\n * stack of cards (e.g. doghouse's per-service status widgets) — those\n * already sit on the card token, so wrapping them in another same-color\n * panel makes them disappear.\n *\n * Pass the whole class string when overriding — your value replaces\n * the default (no merge).\n */\n middleClassName?: string;\n /**\n * When true, render a built-in mobile top bar (logo + hamburger) on\n * screens < md. Defaults to true. Disable when the consumer renders\n * its own top chrome.\n */\n showMobileTopBar?: boolean;\n};\n\n/**\n * Canonical homelab sidebar shell. Doghouse is the reference impl;\n * this is the extracted version every other project consumes.\n *\n * Dimensions are non-negotiable:\n * - md+ width: 15rem default (override per-project via `:root { --hl-sidebar-width: 18rem; }`)\n * - md+ position: fixed-flex in a `display:flex` parent\n * - mobile: off-canvas drawer at full sidebar width\n * - logo block: w-36 h-36 centered, px-4 pt-6 pb-5, border-b-2\n *\n * Surfaces are `bg-[var(--hl-sidebar)]` / `bg-[var(--hl-card)]` /\n * `border-[var(--hl-border)]` — arbitrary-value classes that read the\n * tokens directly, so they still work when the consumer skips the\n * Tailwind preset AND re-skin correctly when the consumer overrides the\n * vars. (They were raw hex until 0.8.0, which silently pinned every\n * consumer to the fleet palette.) Import globals.css for the defaults.\n */\nexport function Sidebar({\n logoSrc,\n logoAlt,\n logoClassName = \"h-36 w-36 rounded-[1rem] object-contain\",\n widgets,\n nav,\n middle,\n lower,\n admin,\n footer,\n mobileOpen: controlledOpen,\n onMobileOpenChange,\n className,\n middleClassName = \"mx-3 mt-3 flex-1 overflow-y-auto rounded-[0.75rem] bg-[var(--hl-card)] p-2\",\n showMobileTopBar = true,\n}: SidebarProps) {\n const [internalOpen, setInternalOpen] = useState(false);\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen : internalOpen;\n\n const setOpen = (next: boolean) => {\n if (!isControlled) setInternalOpen(next);\n onMobileOpenChange?.(next);\n };\n\n // Lock body scroll while the mobile drawer is open.\n useEffect(() => {\n if (typeof document === \"undefined\") return;\n if (!open) return;\n const prev = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n return () => {\n document.body.style.overflow = prev;\n };\n }, [open]);\n\n return (\n <>\n {showMobileTopBar && (\n <div className=\"fixed inset-x-0 top-0 z-30 flex items-center gap-2 border-b-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] px-3 py-2 md:hidden\">\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n className=\"rounded-[0.25rem] p-1.5 text-[var(--hl-foreground)] transition hover:bg-[var(--hl-card-hover)] active:scale-90\"\n aria-label=\"Open menu\"\n >\n <Menu className=\"h-5 w-5\" />\n </button>\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={logoSrc} alt=\"\" width={24} height={24} />\n <p className=\"text-sm font-semibold text-[var(--hl-foreground)]\">\n {logoAlt}\n </p>\n </div>\n )}\n\n {open && (\n <div\n className=\"fixed inset-0 z-40 bg-black/70 md:hidden\"\n onClick={() => setOpen(false)}\n role=\"presentation\"\n />\n )}\n\n <aside\n className={cn(\n \"fixed inset-y-0 left-0 z-50 flex w-[var(--hl-sidebar-width,15rem)] shrink-0 flex-col border-r-2 border-[var(--hl-border)] bg-[var(--hl-sidebar)] transition-transform md:static md:translate-x-0 md:h-screen\",\n open ? \"translate-x-0\" : \"-translate-x-full md:translate-x-0\",\n className,\n )}\n >\n <div className=\"relative flex flex-col items-center border-b-2 border-[var(--hl-border)] px-4 pt-6 pb-5\">\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n src={logoSrc}\n alt={logoAlt}\n className={logoClassName}\n />\n {showMobileTopBar && (\n <button\n type=\"button\"\n onClick={() => setOpen(false)}\n className=\"absolute right-3 top-3 rounded-[0.25rem] p-1 text-[var(--hl-foreground-muted)] transition hover:bg-[var(--hl-card-hover)] hover:text-[var(--hl-foreground)] md:hidden\"\n aria-label=\"Close menu\"\n >\n <X className=\"h-4 w-4\" />\n </button>\n )}\n </div>\n\n {widgets && <div className=\"mx-3 mt-3\">{widgets}</div>}\n\n {nav && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {nav}\n </nav>\n )}\n\n {middle && <div className={middleClassName}>{middle}</div>}\n\n {lower && (\n <nav className=\"mx-3 mt-3 space-y-0.5 rounded-[0.75rem] bg-[var(--hl-card)] p-2\">\n {lower}\n </nav>\n )}\n\n {!middle && <div className=\"flex-1\" />}\n\n {admin && <div>{admin}</div>}\n\n {footer && (\n <div className=\"border-t-2 border-[var(--hl-border)] px-4 py-3 text-center text-xs text-[var(--hl-foreground-faint)]\">\n {footer}\n </div>\n )}\n </aside>\n </>\n );\n}\n","\"use client\";\n\nimport { cn } from \"../lib/utils\";\n\nexport type SidebarNavItemProps = {\n label: string;\n icon?: React.ReactNode;\n active?: boolean;\n onClick?: () => void;\n /**\n * When set, renders as <a href>. Otherwise renders as <button>. In\n * Next.js, prefer passing `render` so you can wrap in next/link\n * without losing classes:\n *\n * <SidebarNavItem\n * label=\"Home\"\n * href=\"/\"\n * render={(props, children) => <Link {...props}>{children}</Link>}\n * />\n */\n 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-[var(--hl-brand)] text-[var(--hl-brand-fg)]\"\n : \"text-[var(--hl-foreground-muted)] hover:bg-[var(--hl-hover-overlay)] hover:text-[var(--hl-foreground)]\",\n );\n\n/**\n * Single nav row for the Sidebar's `nav` / `lower` slots. Matches the\n * doghouse-canonical style: active = brand bg + brand-fg text, inactive =\n * muted text lifting to full foreground on hover, over a hover wash.\n *\n * Every colour here is a token. The active pill reads `--hl-brand` /\n * `--hl-brand-fg` (it was `bg-[#ff9900] text-white` until 0.8.0); the\n * inactive row reads `--hl-foreground-muted` / `--hl-hover-overlay` /\n * `--hl-foreground` (it was `text-white/45 hover:bg-white/5\n * hover:text-white` until 0.9.0). Both were themeability bugs of the same\n * shape — see globals.css.\n */\nexport function SidebarNavItem({\n label,\n icon,\n active = false,\n onClick,\n href,\n render,\n}: SidebarNavItemProps) {\n const className = itemClass(active);\n const 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, on the\n * same `--hl-foreground-muted` as every other secondary line.\n */\nexport function Spinner({ label = \"Loading…\", className }: SpinnerProps) {\n return (\n <p\n className={cn(\n \"flex items-center gap-2 px-3 py-2 text-xs text-[var(--hl-foreground-muted)]\",\n className,\n )}\n >\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n {label}\n </p>\n );\n}\n","import { cn } from \"../lib/utils\";\n\nexport type StatTileProps = {\n /** Uppercase micro-label above the value. */\n label: string;\n /** The stat itself — keep it short; it truncates rather than wraps. */\n value: React.ReactNode;\n /** Optional caption under the value. The line is ALWAYS reserved so the\n * tile height never changes when a sub appears/disappears. */\n sub?: React.ReactNode;\n className?: string;\n};\n\n/**\n * Live-stat tile with STABLE GEOMETRY — the fleet standard for status\n * cards (born on zoo-docs' dashboard, 2026-07-13):\n *\n * - text truncates, never wraps → tile height is constant\n * - the sub line renders even when empty → no height jump when data\n * (queue counts, countdowns) comes and goes between polls\n * - 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-[var(--hl-card)] px-4 py-3\", className)}>\n <p className=\"truncate text-[10px] font-semibold uppercase tracking-widest text-[var(--hl-foreground)]\">\n {label}\n </p>\n <p className=\"mt-1 truncate text-xl font-semibold tabular-nums text-[var(--hl-foreground)]\">{value}</p>\n <p className=\"truncate text-[11px] tabular-nums text-[var(--hl-foreground)]\">{sub ?? \" \"}</p>\n </div>\n );\n}\n\nexport type StatTileRowProps = {\n children: React.ReactNode;\n /** Minimum tile width before the grid drops a column. Default \"9rem\". */\n minTileWidth?: string;\n className?: string;\n};\n\n/**\n * Full-width, equal-column container for StatTiles. `auto-fit` +\n * `minmax(minTileWidth, 1fr)` means the row stretches edge-to-edge with\n * the browser and sheds whole columns on narrow screens — tiles never\n * overflow off-screen and never wrap their text.\n *\n * Pair with the layout standard: content pages are FULL WIDTH (no\n * max-w-* caps) unless a page explicitly opts out.\n */\nexport function StatTileRow({ children, minTileWidth = \"9rem\", className }: StatTileRowProps) {\n return (\n <div\n className={cn(\"grid gap-3\", className)}\n style={{ gridTemplateColumns: `repeat(auto-fit, minmax(${minTileWidth}, 1fr))` }}\n >\n {children}\n </div>\n );\n}\n","/**\n * Pick black or white text for a solid color chip.\n *\n * The homelab status palette forces white text on every status color\n * EXCEPT yellow (warning: #eab308), which uses black for contrast. This\n * helper enforces that rule wherever a user-picked color drives a chip\n * background (TagChips, status badges, etc).\n *\n * The yellow rule generalizes — anything in the yellow band (high\n * luminance + warm hue) gets black ink.\n */\nexport function tagTextColor(hex: string): \"#ffffff\" | \"#000000\" {\n const normalized = hex.trim().replace(/^#/, \"\");\n if (normalized.length !== 3 && normalized.length !== 6) return \"#ffffff\";\n const expand =\n normalized.length === 3\n ? normalized\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : normalized;\n const r = parseInt(expand.slice(0, 2), 16);\n const g = parseInt(expand.slice(2, 4), 16);\n const b = parseInt(expand.slice(4, 6), 16);\n if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return \"#ffffff\";\n // Perceived luminance per ITU BT.601.\n const luma = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n return luma > 0.65 ? \"#000000\" : \"#ffffff\";\n}\n","\"use client\";\n\nimport { tagTextColor } from \"../lib/colors\";\n\nexport type TagRef = {\n id: string | number;\n name: string;\n color: string;\n};\n\nexport type TagChipsProps = {\n tags: TagRef[];\n size?: \"xs\" | \"sm\";\n onClick?: (tag: TagRef) => void;\n};\n\n/**\n * Render a row of solid-color tag chips. Yellow auto-flips text to\n * black per the homelab status-palette contrast rule (see tagTextColor).\n *\n * Renders `<button>` when interactive (`onClick` set), `<span>` when\n * read-only — keeps screen-reader semantics honest.\n */\nexport function TagChips({ tags, size = \"xs\", onClick }: TagChipsProps) {\n if (tags.length === 0) return null;\n const sizeClasses =\n size === \"xs\" ? \"px-1.5 py-0.5 text-[10px]\" : \"px-2 py-0.5 text-xs\";\n\n return (\n <div className=\"flex flex-wrap gap-1\">\n {tags.map((t) => {\n const style = {\n backgroundColor: t.color,\n color: tagTextColor(t.color),\n };\n const className = `inline-block rounded-[0.25rem] font-semibold ${sizeClasses}`;\n return onClick ? (\n <button\n key={t.id}\n type=\"button\"\n onClick={() => onClick(t)}\n className={`${className} transition active:scale-95`}\n style={style}\n >\n {t.name}\n </button>\n ) : (\n <span key={t.id} className={className} style={style}>\n {t.name}\n </span>\n );\n })}\n </div>\n );\n}\n","/**\n * Font wiring for `@jdcpuwiz/homelab-ui`.\n *\n * ## The standard: three self-hosted faces, split by JOB (BuildPlan #57)\n *\n * Wiz ruling 2026-07-24. This SUPERSEDES both the old \"Geist via next/font\"\n * mandate AND the system-ui-only ruling (#345) that briefly replaced it.\n *\n * SANS Poppins — all body / UI / headings / labels\n * DISPLAY Orbitron — BIG numbers only (stat heroes, clock, ≥ ~1.5rem)\n * MONO JetBrains Mono — small values, table figures, version stamps,\n * code (< ~1.5rem)\n *\n * The size line is the hard boundary: Orbitron is a display face and must\n * NEVER be applied to a dense small cell — if a number is small, it's\n * JetBrains Mono.\n *\n * ## Loading is PER-APP — the package can't do it for you\n *\n * A `next/font` loader cannot be re-exported from this package: Next's SWC\n * plugin scans the app's OWN source for literal `const Foo = Font({...})`\n * calls, and bundling rewrites `const`→`var` so Next refuses. So each app\n * self-hosts its faces and binds them to the CSS vars this package reads.\n * The package only owns the `--hl-font-*` wiring in `globals.css`.\n *\n * The convention (see the `starter/` folder for copy-paste layouts):\n *\n * - Next apps: `next/font/google`, each loader given the matching\n * `variable` from `FONT_APP_VARIABLES` below, Poppins weights 300–800,\n * `display: \"swap\"`. Add the three `.variable` classes to `<body>`.\n * - Vite apps: `@fontsource/poppins` etc. — the plain family name resolves,\n * no CSS var needed (the package's `--hl-font-*` already falls back to the\n * literal family name).\n *\n * `globals.css` declares `--hl-font-sans` / `--hl-font-display` /\n * `--hl-font-mono` referencing the app variables below first, then the literal\n * face name, then the platform fallback — so text is readable even mid-load\n * and an un-migrated app degrades to the system stack, never to nothing.\n *\n * Sanity check after deploy — this should name Poppins:\n * getComputedStyle(document.body).fontFamily // → \"…Poppins…\"\n */\n\n/**\n * The package's own token names — the `--hl-font-*` vars the Tailwind preset's\n * `font-sans` / `font-mono` / `font-display` classes resolve to. Consumers\n * rarely touch these directly.\n */\nexport const FONT_CSS_VARIABLES = {\n sans: \"--hl-font-sans\",\n mono: \"--hl-font-mono\",\n display: \"--hl-font-display\",\n} as const;\n\n/**\n * The app-side CSS variable each next/font loader must expose (its `variable:`\n * option). `globals.css` reads these, so an app that follows this naming gets\n * the three faces wired with zero extra config. Match these EXACTLY.\n *\n * const poppins = Poppins({ ..., variable: FONT_APP_VARIABLES.sans });\n */\nexport const FONT_APP_VARIABLES = {\n sans: \"--font-poppins\",\n display: \"--font-orbitron\",\n mono: \"--font-jetbrains-mono\",\n} as const;\n"],"mappings":"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","names":["Button","import_react","import_react","import_jsx_runtime","import_jsx_runtime","import_jsx_runtime","import_react","import_lucide_react","import_jsx_runtime","import_react","import_lucide_react","import_jsx_runtime","import_jsx_runtime","import_lucide_react","import_jsx_runtime","import_jsx_runtime","import_jsx_runtime"]}