Theming Guide
This document provides a comprehensive guide for understanding and customizing the theming system in dbportal.
Overview
Section titled “Overview”dbportal uses a modern theming architecture built on:
- Tailwind CSS v4 - CSS-first configuration with
@themedirective - 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.cssand studio’s own semantic tokens insrc/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.
Architecture
Section titled “Architecture”Theme Configuration Flow
Section titled “Theme Configuration Flow”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, …File Structure
Section titled “File Structure”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 CSSThe studio token layer
Section titled “The studio token layer”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.
State or identity
Section titled “State or identity”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.-brightis the emphasised step and inverts exactly likefg-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>, andhue-<name>-altwhere two identities share a hue: there are more engines than there are hues, anddb-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.
Surfaces that cannot read CSS
Section titled “Surfaces that cannot read CSS”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.
The tokens file
Section titled “The tokens file”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.)
CSS Variables
Section titled “CSS Variables”Core Variables
Section titled “Core Variables”| 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 |
Chart Colors
Section titled “Chart Colors”| 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 |
Dark Mode
Section titled “Dark Mode”Current Configuration
Section titled “Current Configuration”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 */}Switching Themes
Section titled “Switching Themes”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 v4 Integration
Section titled “Tailwind v4 Integration”The @theme inline Directive
Section titled “The @theme inline Directive”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>IDE Warnings
Section titled “IDE Warnings”Your IDE may show warnings like Unknown at rule @theme. This is expected because:
- Tailwind v4’s
@themedirective 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"}Best Practices
Section titled “Best Practices”DO Use Theme Variables
Section titled “DO Use Theme Variables”// 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">DON’T Use Hardcoded Colors
Section titled “DON’T Use Hardcoded Colors”// 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">Opacity Modifiers
Section titled “Opacity Modifiers”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 */}Customizing the Theme
Section titled “Customizing the Theme”Step 1: Modify CSS Variables
Section titled “Step 1: Modify CSS Variables”Edit src/app/globals.css:
.dark { /* Change the primary color */ --primary: #3b82f6; /* blue-500 */ --primary-foreground: #ffffff;
/* Change the accent color */ --accent: #1e3a5f;}Step 2: Verify Mappings
Section titled “Step 2: Verify Mappings”Ensure @theme inline maps your variables:
@theme inline { --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-accent: var(--accent);}Step 3: Test in Both Themes
Section titled “Step 3: Test in Both Themes”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.
Component-Specific Theming
Section titled “Component-Specific Theming”Buttons
Section titled “Buttons”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 */}Dropdowns & Popovers
Section titled “Dropdowns & Popovers”<DropdownMenuContent> {/* bg-popover text-popover-foreground */}Inputs
Section titled “Inputs”<Input> {/* bg-background border-input */}Adding New Colors
Section titled “Adding New Colors”Step 1: Define Variables
Section titled “Step 1: Define Variables”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;}Step 2: Add Theme Mapping
Section titled “Step 2: Add Theme Mapping”@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.
Step 3: Use in Components
Section titled “Step 3: Use in Components”<span className="text-hue-lime">…</span>Step 4: Let the suite check it
Section titled “Step 4: Let the suite check it”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.
Troubleshooting
Section titled “Troubleshooting”Colors Not Applying
Section titled “Colors Not Applying”- Check that the variable is defined in both
:rootand.dark - Verify the
@theme inlinemapping exists - Ensure you’re using the correct class name (
bg-cardnotbg-[--card])
A Theme Does Not Take Effect
Section titled “A Theme Does Not Take Effect”- Check the
darkclass on<html>—next-themestoggles it there; if it never changes, the<ThemeProvider>is missing or a stored value is being written verbatim (see Switching Themes) - Ensure the variable is defined in both the
:rootand.darkselectors — a token that exists in one palette only silently resolves to nothing in the other - Confirm
@theme inlinemaps the variable to a--color-*utility.inlineis required: a plain@themeresolves the value at build time and freezes whichever palette was in scope - Confirm
globals.cssstill importssrc/styles/theme.css
Build Errors
Section titled “Build Errors”- Run
bun run buildto check for CSS syntax errors - Verify all variables are properly closed
- Check for typos in variable names
Resources
Section titled “Resources”Official Documentation
Section titled “Official Documentation”Theme Generators
Section titled “Theme Generators”- tweakcn - Interactive shadcn/ui theme editor
- shadcn Theme Generator - Official theme generator
Color References
Section titled “Color References”- Tailwind Zinc Palette
- OKLCH Color Space - Modern color space for themes
Color Palette Reference
Section titled “Color Palette Reference”Light Mode (Default)
Section titled “Light Mode (Default)”| 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 |
Dark Mode
Section titled “Dark Mode”| 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