npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@jdcpuwiz/homelab-ui

v0.11.1

Published

Shared dark-theme UI primitives for the JdCpuWiz homelab — Sidebar, Button, Modal, PageHeader, TagChips, plus the canonical Tailwind preset, CSS vars, and the sanctioned three-face typography (Poppins / Orbitron / JetBrains Mono) on one flat #f0f1f4 text

Readme

@jdcpuwiz/homelab-ui

Shared dark-theme UI primitives for the JdCpuWiz homelab. Doghouse is the canonical reference; this package is the extracted version every other project consumes.

What you get

  • ComponentsSidebar, SidebarNavItem, Button, Modal, ConfirmDialog, PageHeader, EmptyState, Spinner, TagChips
  • Tailwind preset — full token namespace (bg-sidebar, bg-card, text-brand, bg-status-success, rounded-widget, w-sidebar, …)
  • Global CSS variables--hl-brand, --hl-sidebar, --hl-card, etc. Override at :root to re-skin a project.
  • Typography — the sanctioned three-face stack (Poppins / Orbitron / JetBrains Mono) on one flat #f0f1f4 text color. The package owns the --hl-font-* wiring; each app self-hosts its faces (see "Typography" + starter/ below)

Why this exists

Every homelab project was re-deriving the same primitives, sidebar shell, and color palette, and every project drifted. This package is the single source of truth so updates land everywhere.

It also exists to kill silent token drift — a class of bug where a CSS variable resolves to nothing and the page falls back to something nobody chose, with no error anywhere. Asset Den once shipped 9 phases of "design-expert approved" code sitting on exactly that failure (--font-geist-sans resolved to nothing). The font standard has since churned twice — Geist, then a brief system-ui ruling, and now (BP #57) three self-hosted faces (Poppins / Orbitron / JetBrains Mono); see "Typography". The lesson that survives is the general one — looking fine in review is not evidence a token actually resolved.

Install

npm install @jdcpuwiz/homelab-ui

Peer deps: react >= 18, next >= 15 (optional), tailwindcss ^3.

Typography

Three self-hosted faces, split by JOB (settled: Wiz ruling 2026-07-24, BuildPlan #57 — shipped in 0.7.0). The size line is the hard boundary.

| Role | Face | Where | |---|---|---| | Body / UI / headings / labels | Poppins (--hl-font-sans) | everything you read | | Big display numbers (≥ ~1.5rem) | Orbitron (--hl-font-display) | stat-card heroes, clock, large KPIs | | Small values + code/logs (< 1.5rem) | JetBrains Mono (--hl-font-mono) | table figures, inline values, version stamps, code |

The --hl-font-* vars in @jdcpuwiz/homelab-ui/globals.css:

--hl-font-sans:    var(--font-poppins, "Poppins"), system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--hl-font-display: var(--font-orbitron, "Orbitron"), var(--hl-font-sans);
--hl-font-mono:    var(--font-jetbrains-mono, "JetBrains Mono"), ui-monospace, "SF Mono", Menlo, Consolas, monospace;

The literal face name is var()'s own fallback argument, not just the next comma item — a bare var(--font-poppins), "Poppins", … looks equivalent but isn't: an unresolved var() with no inline fallback invalidates the whole declaration (CSS custom-properties spec), so an app that never sets --font-poppins (any Vite/@fontsource consumer) would silently lose the entire chain and render the browser's default serif instead of falling through to "Poppins". Fixed in 0.7.1 after it surfaced building BuildPlan #57's own reference render.

Each var references the app's next/font variable first, then the literal face name (covers @fontsource / a system-installed face), then the platform fallback — so text is readable even mid-load and an un-migrated app degrades to the system stack, never to nothing. font-sans / font-display / font-mono in the Tailwind preset read exactly these vars.

The ~1.5rem line is a rule, not a suggestion. Orbitron is a display face — a great big stat number, illegible in a dense 11–12px table cell. It must NEVER be applied to a small value. If a number is small and dense, it is JetBrains Mono.

Loading is PER-APP — the package can't do it for you

A next/font loader cannot be re-exported from this package: Next's SWC plugin scans your app's OWN source for literal const Foo = Font({...}) calls, and bundling rewrites constvar so Next refuses. So each app self-hosts its faces and binds them to the vars above; the package only owns the CSS-var wiring.

Copy-paste templates live in starter/:

  • starter/next-layout.tsx — the Next root layout. One import "@jdcpuwiz/homelab-ui/fonts.css" registers all three faces; there are no loaders and no --font-* variables to match any more (C525/C541).
  • starter/vite-fonts.ts — the @fontsource imports for the two Vite apps (wiz3dtools, timesheet).

Self-hosted everywhere — no runtime Google Fonts <link>. The var names are also exported as constants if you'd rather not hardcode them:

import { FONT_CSS_VARIABLES, FONT_APP_VARIABLES } from "@jdcpuwiz/homelab-ui";
// FONT_CSS_VARIABLES → { sans: "--hl-font-sans", mono: "--hl-font-mono", display: "--hl-font-display" }
// FONT_APP_VARIABLES → { sans: "--font-poppins", display: "--font-orbitron", mono: "--font-jetbrains-mono" }

Text color — one flat shade

Two colors carry the whole UI: flat #f0f1f4 for everything you read (labels, body, titles — all one shade; hierarchy from size/weight, never a dimmer grey) and #ff9900 for numeric values. Page titles are NOT a special color — the old page-title purple #aa89b7 is removed; an h1 is text-xl font-semibold in the flat foreground. The text-white/60·/40·/30 opacity ramp is dead as a hierarchy device (text-ink-* tokens now all resolve to the one flat foreground). Status pills keep their solid semantic bg + white text.

What survives the ramp is chrome you aren't reading, and since 0.9.0 that is two tokens rather than five literals:

  • text-foreground-muted — deliberately secondary, usually interactive: an inactive nav row, a clickable breadcrumb, a back link, a close X, a tertiary button, a "Loading…" line. Hovering one lifts it to the full foreground.
  • text-foreground-faint — chrome you do NOT read: breadcrumb chevrons, an empty-state icon, the dashed placeholder art, the footer version stamp. Never put a sentence in this.

Do not add a third step — that is the ramp coming back. Reading text is still one flat shade.

This supersedes both prior standards

Poppins is what the fleet originally shipped on (wiz3d-prints, the first project) before it drifted to Geist; #57 brings everyone back to it. It replaces the old "Geist via next/font" mandate AND the system-ui-only ruling (#345) that briefly replaced it. Orbitron is sanctioned AND applied (reversing #345, which had killed it). BP #57 shipped fleet-wide (all 6 phases done, P6 verify closed it out) — every project is migrated onto the three-face standard + the flat #f0f1f4 foreground. If an app still renders Geist or system-ui after this, that's a real regression, not known rollout state — go fix it, don't shrug it off.

Sanity check after deploy: open DevTools and run getComputedStyle(document.body).fontFamily. It should name Poppins. If it names Geist, system-ui, or nothing, the loader didn't land or the CSS import is missing.

Setup (3 steps — copy-paste)

1. Import the faces + the token stylesheet in app/layout.tsx

fonts.css registers Poppins / Orbitron / JetBrains Mono from woff2 files shipped inside this package; globals.css brings in the --hl-* tokens (including the --hl-font-* wiring). Import the package's stylesheet before your own globals.css so yours can override. This is the full copy-paste — the complete file is in starter/next-layout.tsx.

import type { Metadata } from "next";
import "@jdcpuwiz/homelab-ui/fonts.css";   // the three faces, self-hosted
import "@jdcpuwiz/homelab-ui/globals.css"; // tokens + base rules
import "./globals.css"; // optional, your own project styles

export const metadata: Metadata = { title: "My App" };

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className="dark">
      <body className="antialiased">{children}</body>
    </html>
  );
}

No loaders, no variable: names, no .variable classes — font-sans (Poppins), font-display (Orbitron) and font-mono (JetBrains Mono) resolve from the tokens. Vite apps can import the same fonts.css, or use @fontsource — see starter/vite-fonts.ts.

Do NOT use next/font/google here (C525). It makes next build fetch from fonts.gstatic.com, putting a Google dependency in every consumer's DEPLOY — zoovault's died that way on 2026-08-15, on a commit that had built clean locally. Self-hosting removes the build-time fetch, not just the runtime <link>. And do not reference --font-poppins / --font-orbitron / --font-jetbrains-mono: those existed only under next/font, and var(--x) with no fallback ARGUMENT is invalid at computed-value time — the whole declaration is discarded and the element inherits its parent's face.

2. Apply the Tailwind preset in tailwind.config.{js,ts}

module.exports = {
  presets: [require("@jdcpuwiz/homelab-ui/tailwind-preset")],
  content: [
    "./app/**/*.{ts,tsx}",
    "./components/**/*.{ts,tsx}",
    // REQUIRED: pull classes from the package's compiled JS
    "./node_modules/@jdcpuwiz/homelab-ui/dist/**/*.{js,mjs,cjs}",
  ],
};

Skipping the node_modules/@jdcpuwiz/... entry means Tailwind won't see the classes the package uses internally → broken styles. This is the single most common setup mistake.

3. Render the sidebar

import { Sidebar, SidebarNavItem } from "@jdcpuwiz/homelab-ui";
import { LayoutDashboard, Server } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";

export default function AppShell({ children }) {
  const pathname = usePathname();
  return (
    <div className="flex h-screen overflow-hidden">
      <Sidebar
        logoSrc="/logo.png"
        logoAlt="My App"
        nav={
          <>
            <SidebarNavItem
              label="Home"
              icon={<LayoutDashboard size={16} />}
              href="/"
              active={pathname === "/"}
              render={(props, c) => <Link {...props}>{c}</Link>}
            />
            <SidebarNavItem
              label="Servers"
              icon={<Server size={16} />}
              href="/servers"
              active={pathname.startsWith("/servers")}
              render={(props, c) => <Link {...props}>{c}</Link>}
            />
          </>
        }
        footer="v0.1.0"
      />
      <main className="flex-1 overflow-y-auto bg-[var(--hl-content)]">
        {children}
      </main>
    </div>
  );
}

Done. The app boots with the right fonts, sidebar, and theme.

Getting active right matters, not just visually. SidebarNavItem emits aria-current="page" when active is true — that's the only non-color signal a screen reader (or a colorblind user) gets for "you are here". Two traps:

  • Match href as a prefix, not an exact string, for any item whose page has nested routes (pathname.startsWith(item.href), with pathname === "/" as a special case for the root item) — pathname === item.href lights up nothing on /servers/42.
  • Spread the render callback's props (<Link {...props}>) rather than destructuring individual fields (({ href, className }) => ...). A narrow destructure silently drops aria-current (and any field added later) even though active and the styling still look correct.

The mobile top bar reserves its own height (0.10.6, BUG #339)

Under md the Sidebar renders a fixed inset-x-0 top-0 bar (logo + hamburger). Until 0.10.6 nothing reserved its height, so every consumer's <main> started at y=0 and each app's page <h1> sat half behind it at phone widths — fleet-wide.

It cannot reserve space with a spacer element: the Sidebar's siblings land inside the consumer's display:flex ROW (<Sidebar/><main/>), where a block becomes a zero-width flex column beside <main> and pushes nothing down. So:

  • globals.css defines --hl-mobile-topbar-height (3.25rem) and pads the body by it under 768px. The bar sets min-h-[var(--hl-mobile-topbar-height)] from the SAME token, so bar and reservation cannot drift.
  • The rule is gated on html.hl-has-mobile-topbar, which the Sidebar adds only while it is actually rendering the bar. Pass showMobileTopBar={false} and you get no bar and no padding — nothing changes for an app that draws its own top chrome.
  • If your shell clamps to the viewport (h-screen overflow-hidden), also subtract the bar under md or the body gains a bar's worth of scroll and the title can be dragged back under it: className="flex h-[calc(100dvh-var(--hl-mobile-topbar-height,0px))] overflow-hidden md:h-screen". Document-flow shells need nothing. Keep the 0px fallback — it is what makes the class correct in an app that has not loaded globals.css.

Re-skin the bar's height by overriding the token at :root, like any other.

Consumers below 0.10.x, and the two left there on purpose (C641)

A caret range on a 0.x package can never resolve a new minor — npm treats every 0.x minor as breaking — so a fix here reaches nobody until each app's range is edited by hand. After the 0.10.6 sweep three apps were still stranded:

  • routes/frontend was on ^0.4.1 and was the only one still showing the overlap. Now on ^0.10.6.
  • timesheet/packages/frontend and wiz3dtools/packages/frontend are on ^0.7.1 and are deliberately left there (Wiz's call, 2026-08-29). Both pass showMobileTopBar={false} and draw their own top chrome, so the 0.10.6 reservation is gated off for them — they gain nothing from the upgrade while crossing 0.8.0 (literal hex → --hl-* tokens) and 0.9.0 (the chrome token routing), which is all of the visual risk. Revisit only when one of them actually needs something from a later version.

If you do move one of them, the token routing is the trap, not the fonts. From 0.8.0 the Sidebar draws every colour from var(--hl-*), so an app that does not import this package's globals.css renders it with no background, border or foreground at all. Importing that stylesheet is not always the answer either: it is an unlayered rule set that forces a dark html/body and a 14px root font-size, which silently kills a light theme and rescales every rem. Routes' way out was to declare the --hl-* tokens in its own stylesheet, using the same values 0.4.1 had hardcoded — plus a copy of the html.hl-has-mobile-topbar padding rule — so the chrome is unchanged and the overlap is still fixed.

Sidebar width is 240px, not 15rem (0.11.0, B358)

Wiz ratified 240px as the fleet sidebar-width standard on 2026-09-02. --hl-sidebar-width used to default to 15rem, which only equalled 240px at a 16px root — this package's own globals.css sets a 14px root, so its own Ladle preview (and any consumer that inherited 14px) actually rendered a 210px sidebar. The default is now a fixed pixel value, 240px, so it can never drift with an app's root font-size again. Per-project overrides (:root { --hl-sidebar-width: 18rem; }) still work exactly as before.

Same caret-range trap as above: consumers pinned ^0.10.x will NOT pick up 0.11.0 on their own. A ^0.10.x range only resolves 0.10.x releases; moving to 240px reaches nobody until each app's package.json range is bumped by hand and reinstalled. The fleet-wide consumer sweep is tracked as C685.

Components

| Component | Purpose | |-------------------|---------| | Sidebar | Doghouse-canonical shell. Slots: widgets, nav, middle, lower, admin, footer. 240px fixed md+, off-canvas drawer on mobile (built-in hamburger + close X). The mobile top bar reserves its own height — see below. | | SidebarNavItem | Active = bg-brand + text-brand-fg + aria-current="page"; inactive = text-foreground-muted lifting to full foreground over a bg-overlay-hover wash. Supports href (auto <a>) or render (next/link wrapper — spread its props, don't destructure). | | Button | variant × size. Variants: primary (brand orange), secondary (card), tertiary (ghost), danger (red). Sizes: sm, md. | | Modal | Overlay + click-outside + Esc + role=dialog + aria-* wiring + footer slot. density: "form" \| "media". | | ConfirmDialog | Modal + Button×2, Enter triggers confirm. tone: "danger" \| "primary". | | PageHeader | Title + description + back link + actions slot + optional header image (drop a PNG in /public/ to brand). Supports breadcrumbs. | | EmptyState | panel (warm card body) or inline (text-only) variant. | | Spinner | <Loader2 /> + label, on the standard --hl-foreground-muted. | | TagChips | Solid-color pills with auto white/black text contrast on yellow. Read-only <span> or interactive <button>. | | StatTile / StatTileRow | Live-stat tiles with stable geometry (see Layout standard below): truncate-never-wrap, always-reserved sub line, font-mono values so digit width can't shift; the row is a full-width auto-fit grid that stretches with the browser. |

Layout standard (applies to EVERY homelab UI)

Locked in by Wiz 2026-07-13 (born on zoo-docs' dashboard). Default for all projects unless a page explicitly calls out otherwise:

  1. Content pages are FULL WIDTH. No max-w-* caps on the page container — content grows and shrinks with the browser. (A page may opt out deliberately, e.g. a long-form reading view, but that's the exception and should be commented as such.)

  2. Live data must never move the page. Anything that re-renders on a poll (status cards, counters, countdowns) keeps constant HEIGHT:

    • text truncates (truncate), never wraps
    • optional lines are always reserved (render  , not nothing)
    • ticking numbers render in font-mono (JetBrains Mono) — width stability here needs a metrically-monospaced face; the fleet's Poppins/Orbitron subsets don't carry the tnum OpenType feature, so a bare tabular-nums on those faces computes but does nothing (B264) Use StatTile + StatTileRow and this comes free:
    <StatTileRow>
      <StatTile label="Analyzer" value="idle" sub="last finished 2m ago" />
      <StatTile label="Total runs" value={128} />
      <StatTile label="Next sweep" value="12:41" sub="every 15 min" />
    </StatTileRow>
  3. #7d838d is the input-placeholder token ONLY. All reading text — labels, captions, secondary lines, table meta — is flat #f0f1f4 (BP #57; hierarchy from size/weight, never a dimmer grey). Version stamps are text-foreground-faint. Grey body text reads as broken on the dark theme.

  4. Wide content scrolls in its own container (overflow-x-auto on tables/code blocks) — the page body never scrolls horizontally.

Helpers

import { cn, tagTextColor } from "@jdcpuwiz/homelab-ui";
  • cn(...inputs) — clsx + tailwind-merge.
  • tagTextColor(hex) — returns #ffffff or #000000 depending on luminance. Use on any user-picked chip background so yellow doesn't render unreadable white text.

Tokens

The Tailwind preset exposes the canonical homelab palette:

| Token | Use | |---------------------------------------------------------|-----| | bg-sidebar bg-content bg-card bg-card-hover bg-card-warm | Surfaces (darkest → lightest) | | bg-brand text-brand text-brand-ink bg-brand-hover ring-brand-glow | Brand orange — identity only, never status | | 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). | | border-border border-input border-input-hover | Borders | | bg-overlay (rgba(0,0,0,0.9)) bg-overlay-chip (rgba(0,0,0,0.6)) | Modal backdrops / hover chips over images | | 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. | | text-foreground (flat #f0f1f4) | The ONE reading color. Everything you actually read. | | 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. | | 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. | | text-title (= flat #f0f1f4) | h1 title color — not a special hue (BP #57 removed the page-title purple); resolves to the flat foreground | | var(--hl-danger-fg) (#f87171, no preset class yet — use text-[var(--hl-danger-fg)]) | error/danger TEXT — for inline error lines and validation messages. bg-status-danger (#b91c1c) is a badge BACKGROUND (white text on it); as text on --hl-card it's 2.58:1, failing AA. #f87171 measures 6.03:1 on --hl-card (#1e1e1e) — passes AA. Badges keep the solid #b91c1c background; this is text-only. | | 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. | | rounded-widget (xl), rounded-row (lg), rounded-chip, rounded-panel (2xl) | Radius aliases | | w-sidebar (default 240px), w-logo h-logo (9rem) | Layout sizes (sidebar width is var(--hl-sidebar-width), a fixed-px value independent of root font-size — override at :root) | | font-sans font-mono font-display | The three sanctioned faces (BP #57) — Poppins / JetBrains Mono / Orbitron. Each app self-hosts the faces and binds them to --hl-font-* (see Typography + starter/). | | text-2xs (10px / 14px) | Sidebar footers, grid captions |

Overriding tokens per app

The CSS variables are namespaced (--hl-*). Redefine them at :root in your own CSS — the preset's color tokens reference the vars, so your override wins everywhere automatically.

/* app/globals.css — re-skin your project */
:root {
  --hl-brand: #00aaff;          /* swap brand orange for blue */
  --hl-sidebar: #0a1929;
  --hl-card: #112844;

  /* Don't stop at the surfaces — the dimmed chrome has tokens too (0.9.0),
     and left alone it stays cold white-alpha over your palette. */
  --hl-foreground: #dce8f5;
  --hl-foreground-muted: rgba(220, 232, 245, 0.55);
  --hl-foreground-faint: rgba(220, 232, 245, 0.30);
  --hl-hover-overlay: rgba(220, 232, 245, 0.08);
}

This only actually worked from 0.8.0 on. Up to 0.7.1 the components themselves wrote the fleet palette as literal hex — bg-[#ff9900], bg-[#111111], bg-[#1e1e1e], bg-[#252525], bg-[#2a2620], text-[#f0f1f4] — so an app that redefined those vars still rendered orange-and-charcoal, with nothing to error on. (zoo-genes hit it first and shipped a scoped CSS override to claw its own green/warm theme back.) They now read bg-[var(--hl-brand)] and friends.

0.9.0 finished the job for the dimmed half. Every text-white/45 · /40 · /30 · /20 · /60 and hover:bg-white/5 in the package became --hl-foreground-muted, --hl-foreground-faint or --hl-hover-overlay, so package chrome now follows a warm or light theme instead of staying cold grey against it. If you're re-skinning, pin >= 0.9.0 and set those three vars alongside your surfaces.

One thing did NOT become themeable, on purpose: status colors (bg-status-*, and the Button danger variant — including its white label, which is text on a status chip, not reading text) are a fixed fleet semantic. Green means OK in every app.

Why components use bg-[var(--hl-card)], not bg-card

Both resolve to the same value, but the arbitrary-value form keeps working for a consumer who scans dist/** without applying the preset — which the package promises in "Without Next.js". Inside this package, prefer the var() form. In your own app code, bg-card is fine.

Without Next.js

Every component works standalone. The --hl-* tokens are plain CSS in globals.css — import it (or copy its :root block) and you have the theme. For the faces on a non-Next app, self-host via @fontsource (see starter/vite-fonts.ts); the --hl-font-* vars fall back to the literal family names, so no CSS-variable wiring is needed.

Troubleshooting

What should the body font be?

Run getComputedStyle(document.body).fontFamily in DevTools.

  • Names "Poppins" → working as intended. This is the standard.
  • Names Geist or the system stack (system-ui, -apple-system, …) → this app hasn't been migrated to the three-face standard yet (or a stray Geist loader is still winning). Wire the loaders from starter/next-layout.tsx / starter/vite-fonts.ts.
  • Returns "ui-sans-serif", … with no --hl-* tokens resolving → you haven't imported @jdcpuwiz/homelab-ui/globals.css. See "Setup → 1".

Sidebar looks unstyled

You're missing the node_modules/@jdcpuwiz/homelab-ui/dist/** entry in your tailwind.config content array. Add it.

Modal renders but Tailwind classes don't apply

Same as above — Tailwind isn't scanning the package's dist files.

I overrode --hl-brand but the nav pill / buttons are still orange

You're on 0.7.1 or older, where the components hardcoded the hex. Bump to 0.8.0+. Confirm with getComputedStyle(document.querySelector('[aria-pressed=true]')).backgroundColor — it should report your color, not rgb(255, 153, 0).

bg-brand is undefined

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)" }}.

Local development

npm install
npm run build       # tsup → dist/
npm run typecheck   # tsc --noEmit
npm run ladle       # reference renders at http://localhost:61000

The ladle script pins NODE_ENV=development on purpose

Don't drop that prefix, and don't start the preview with a bare npx ladle serve from a shell that exports NODE_ENV=production.

React 19 ships a production copy of react/jsx-dev-runtime whose only job is to export jsxDEV as undefined (exports.jsxDEV = void 0). Vite honours an ambient NODE_ENV when it pre-bundles dependencies, so a production value makes it bake that stub into node_modules/.vite/deps — while the SWC transform keeps emitting dev-runtime calls. Every story then dies on TypeError: _jsxDEV is not a function and nothing mounts, even though the server starts cleanly and serves the shell. The failure looks like a broken story, not a broken environment (BUG #342).

If you ever see that error, delete node_modules/.vite and confirm the regenerated deps/react_jsx-dev-runtime.js inlines react-jsx-dev-runtime.development.js, not .production.js.

npm run ladle:build is unaffected — a production build wants the production runtime and never calls jsxDEV.

License

MIT