Skip to content

Theming Guide

This document provides a comprehensive guide for understanding and customizing the theming system in dbportal.

dbportal uses a modern theming architecture built on:

  • Tailwind CSS v4 - CSS-first configuration with @theme directive
  • shadcn/ui - Accessible component library with CSS variable theming
  • CSS Custom Properties - Light and dark variable sets, in two layers: the shadcn variables in globals.css and studio’s own semantic tokens in src/styles/theme.css

dbportal is dark-first with a runtime light theme: next-themes writes the dark class, the toggle in the header flips it, and the choice persists under the libredb-theme storage key. Dark is the default and the server-rendered assumption.

globals.css src/styles/theme.css (shipped as dist/styles.css)
│ │
├── :root (shadcn light) ├── :root (studio light tokens)
├── .dark (shadcn dark) ├── .dark (studio dark tokens)
│ │
└── @theme inline └── @theme inline
│ │
└── bg-background, … └── bg-surface, text-fg-muted,
border-hairline, …
src/
├── app/
│ └── globals.css # shadcn variables + app-level global rules; imports theme.css
├── styles/
│ └── theme.css # studio's semantic tokens — the only place a surface colour is written
├── components/
│ ├── theme-provider.tsx # next-themes provider (class attribute, storageKey libredb-theme)
│ └── theme-toggle.tsx # two-state dark ↔ light control
└── hooks/
└── use-effective-theme.ts # the theme in force, for canvases that cannot read CSS

The shadcn variables cover the primitives; studio’s own chrome — panels, rails, grids, the editor frame — is written in the semantic tokens of src/styles/theme.css. Two ramps:

Ramp Tokens (recessed → elevated / brightest → faintest)
Surface canvas · sunken · surface · raised · overlay (plus panel, the translucent card ground)
Text fg-bright · fg · fg-secondary · fg-tertiary · fg-muted · fg-subtle · fg-faint
Accent, state brand · warning · success · danger, each with -bright, -tint, -solid, -solid-hover (plus brand-solid-active, the one filled ground that hovers darker)
Accent, identity hue-<name>, one per hue the app uses, some with -alt (a second step) and -tint (its wash); hue-teal and hue-purple also carry -solid / -solid-hover for the two filled controls painted in a panel’s own hue

Alongside them: hairline / hairline-strong for structural rules, edge / edge-hover for the border of a control the user is meant to see, and fill-subtle / fill / fill-strong for hover, selected and inset grounds. They are consumed as ordinary utilities — bg-surface, text-fg-muted, border-hairline.

In dark, elevation means lighter; in light it means whiter, and the text ramp inverts around fg-muted (zinc-500), the one value that reads on both grounds. The dark values reproduce the literals the components carried before the layer existed, so moving a component onto a token must be a no-op in dark — any visible dark-mode change is a bug unless it is deliberate and called out.

The two accent families answer different questions, and picking the wrong one is the mistake that costs something later. Note the state family is brand, not accent: shadcn already owns --accent — its neutral hover ground, #f5f5f5 — and globals.css maps --color-accent to it after importing this layer, so a studio token of that name loses the cascade silently and paints near-white text on a white page.

  • State — the colour tracks a changing condition. A run failed, a row is selected, a statement is risky. Four roles: brand, warning, success, danger. -bright is the emphasised step and inverts exactly like fg-bright — brighter than its base in dark, darker in light, both meaning further from the ground.
  • Identity — the colour is a fixed label for a thing. Which engine, which bottom panel, added versus removed, primary key versus foreign key, number versus boolean. hue-<name>, and hue-<name>-alt where two identities share a hue: there are more engines than there are hues, and db-ui-config’s own test asserts every engine colour differs.

Folding an identity into a state role repaints seventeen engines in four colours. Folding a state into an identity hue means the next person to change what “error” looks like has to find every red in the codebase. The identity set is selected per mode, the way lib/charts/palette.ts selects rather than flipping a ramp — the two modes run out of room in different places, so a ramp flip produces collisions in one of them.

-tint is the wash a role is painted over (bg-brand-tint/15) and is the same value in both palettes on purpose: a wash is alpha over whatever is behind it, so it already adapts. -solid is a filled control’s ground, mode-independent for the same reason — a button’s label sits on the button, not on the page.

Monaco, Recharts and the @xyflow ER diagram paint their own canvas from a JS palette, so they cannot resolve a token. They read useEffectiveTheme() instead, which observes the dark class on <html> rather than calling useTheme() — that class is where next-themes writes studio’s choice and where an embedding host writes its own, so one source answers both deployments and an embedded studio needs no provider to follow along.

src/styles/theme.css holds the tokens and globals.css imports it; every var(--studio-*) resolves through that one file. (Upstream published it as a package stylesheet for embedding hosts; the package is gone, the file stayed.)

Variable Description Usage
--background Page background color bg-background
--foreground Default text color text-foreground
--card Card/panel background bg-card
--card-foreground Card text color text-card-foreground
--popover Popover/dropdown background bg-popover
--popover-foreground Popover text color text-popover-foreground
--primary Primary action color bg-primary, text-primary
--primary-foreground Text on primary text-primary-foreground
--secondary Secondary action color bg-secondary
--secondary-foreground Text on secondary text-secondary-foreground
--muted Muted/subtle background bg-muted
--muted-foreground Muted text color text-muted-foreground
--accent Accent/hover background bg-accent
--accent-foreground Text on accent text-accent-foreground
--destructive Destructive action color bg-destructive
--destructive-foreground Text on destructive text-destructive-foreground
--border Border color border-border
--input Input border color border-input
--ring Focus ring color ring-ring
--radius Border radius base rounded-lg, rounded-md
Variable Light (:root) Dark (.dark) Usage
--chart-1 #e76e50 #3b82f6 Primary chart color
--chart-2 #2a9d90 #22c55e Secondary chart color
--chart-3 #274754 #f59e0b Tertiary chart color
--chart-4 #e8c468 #a855f7 Quaternary chart color
--chart-5 #f4a462 #ec4899 Quinary chart color

dbportal uses a dark-first design with the following color palette (based on Tailwind Zinc):

.dark {
--background: #09090b; /* zinc-950 */
--foreground: #fafafa; /* zinc-50 */
--card: #0a0a0a; /* near zinc-950 */
--popover: #0a0a0a;
--secondary: #27272a; /* zinc-800 */
--muted: #27272a; /* zinc-800 */
--accent: #27272a; /* zinc-800 */
--border: #27272a; /* zinc-800 */
--muted-foreground: #a1a1aa; /* zinc-400 */
}

The layout wraps the app in next-themes’ provider:

<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} storageKey="libredb-theme">
{children}
</ThemeProvider>

Two states only, dark and light — enableSystem is off, so there is no third “system” entry in the cycle. The storage key is deliberately studio’s own rather than next-themes’ default theme: enableSystem={false} does not sanitize a stored "system", it writes it to the class list verbatim, so a key that a previous system-enabled build could have written is a key that can hand the document a class="system" and no palette at all.

Anything that renders differently per theme must be guarded against hydration mismatch — the server has no document to read, so useEffectiveTheme() answers "dark" there and the toggle renders a neutral label until it has hydrated.

Tailwind CSS v4 introduces CSS-first configuration. The @theme inline directive maps CSS variables to Tailwind utility classes:

@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
/* ... */
}

This enables using semantic class names:

<div className="bg-background text-foreground">
<div className="bg-card border-border">
Content
</div>
</div>

Your IDE may show warnings like Unknown at rule @theme. This is expected because:

  • Tailwind v4’s @theme directive is new
  • CSS validators don’t recognize it yet
  • It works correctly - the build succeeds

To suppress these warnings in VS Code, add to .vscode/settings.json:

{
"css.lint.unknownAtRules": "ignore"
}
// Good - uses theme variables
<div className="bg-background text-foreground border-border">
<span className="text-muted-foreground">
<button className="bg-primary text-primary-foreground hover:bg-accent">
// Bad - hardcoded colors
<div className="bg-[#050505] text-white border-[#262626]">
<span className="text-zinc-500">
<button className="bg-zinc-900 hover:bg-zinc-800">

Use opacity modifiers with theme variables:

<div className="bg-accent/50"> {/* 50% opacity */}
<span className="text-muted-foreground/70"> {/* 70% opacity */}
<div className="border-border/30"> {/* 30% opacity */}

Edit src/app/globals.css:

.dark {
/* Change the primary color */
--primary: #3b82f6; /* blue-500 */
--primary-foreground: #ffffff;
/* Change the accent color */
--accent: #1e3a5f;
}

Ensure @theme inline maps your variables:

@theme inline {
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-accent: var(--accent);
}

Both variable sets are rendered at runtime, so a new colour is only half-added until it has a value in each. Verify with the header toggle, not by reasoning about the values: a token that is legible in dark and 2:1 against a white ground is a token that ships an unreadable light theme.

shadcn/ui buttons use theme variables automatically:

<Button variant="default"> {/* bg-primary */}
<Button variant="secondary"> {/* bg-secondary */}
<Button variant="outline"> {/* border-input */}
<Button variant="ghost"> {/* hover:bg-accent */}
<Button variant="destructive"> {/* bg-destructive */}
<Card> {/* bg-card border-border */}
<CardHeader>
<CardTitle> {/* text-card-foreground */}
<DropdownMenuContent> {/* bg-popover text-popover-foreground */}
<Input> {/* bg-background border-input */}

Both palettes, always. A token declared in one resolves to nothing in the other, which is invalid at computed-value time — the ground falls to transparent, the hairline to currentColor, and only in the theme nobody happened to be looking at.

:root {
--studio-hue-lime: #3f6212;
}
.dark {
--studio-hue-lime: #9ae600;
}
@theme inline {
--color-hue-lime: var(--studio-hue-lime);
}

inline is required: a plain @theme resolves the value at build time and freezes whichever palette was in scope.

<span className="text-hue-lime">…</span>

Do not verify a colour by eye, and do not write the ratio into a comment. tests/unit/theme-accent-contrast.test.ts measures every accent token on every ground it can land on, in both palettes, and tests/unit/theme-token-usage.test.ts fails on a colour literal in src and on a token nothing reaches. The bars a new token has to clear:

  • 4.5:1 on the five studio grounds, on a wash of its own hue at every alpha the code actually paints (scanned out of src, currently up to /25), and on the brand tile — WCAG AA for text, in both palettes.
  • No worse in light than in dark if the token is ever used with a foreground opacity (text-warning/80). An opacity modifier composites before anyone reads it, so a token that clears AA opaque can fall under it faded; the suite measures those separately and pins the ones that do not clear.
  • Separation no tighter than the shipped dark set’s own minimum, if it joins the identity palette. The bar is measured, not chosen, so it moves if dark is ever retuned.
  • A call site. A token nobody uses is a value nobody has checked.

The helper the tests measure with is tests/helpers/contrast.ts. It reads the Tailwind palette out of node_modules rather than transcribing it, so a Tailwind upgrade that restyles blue-400 reports itself as the dark-mode change it is.

  1. Check that the variable is defined in both :root and .dark
  2. Verify the @theme inline mapping exists
  3. Ensure you’re using the correct class name (bg-card not bg-[--card])
  1. Check the dark class on <html> — next-themes toggles it there; if it never changes, the <ThemeProvider> is missing or a stored value is being written verbatim (see Switching Themes)
  2. Ensure the variable is defined in both the :root and .dark selectors — a token that exists in one palette only silently resolves to nothing in the other
  3. Confirm @theme inline maps the variable to a --color-* utility. inline is required: a plain @theme resolves the value at build time and freezes whichever palette was in scope
  4. Confirm globals.css still imports src/styles/theme.css
  1. Run bun run build to check for CSS syntax errors
  2. Verify all variables are properly closed
  3. Check for typos in variable names
Variable Hex Description
background #ffffff White
foreground #0a0a0a Near black
card #ffffff White
primary #171717 Near black
secondary #f5f5f5 Light gray
muted #f5f5f5 Light gray
muted-foreground #737373 Medium gray
accent #f5f5f5 Light gray
border #e5e5e5 Gray
Variable Hex Tailwind Description
background #09090b zinc-950 Near black
foreground #fafafa zinc-50 Near white
card #0a0a0a - Dark
primary #fafafa zinc-50 Near white
secondary #27272a zinc-800 Dark gray
muted #27272a zinc-800 Dark gray
accent #27272a zinc-800 Dark gray
border #27272a zinc-800 Dark gray
muted-foreground #a1a1aa zinc-400 Medium gray

Last updated: September 2026