# Theming Guide This document provides a comprehensive guide for understanding and customizing the theming system in LibreDB Studio. ## Overview LibreDB Studio 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` Studio 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 ### 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 ``` 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 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-`, 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 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-`, and `hue--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. ### 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 `` 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. ### Embedding `globals.css` is not packaged, so an app consuming `@libredb/studio` must import the tokens itself or every `var(--studio-*)` resolves to nothing: ```ts import "@libredb/studio/styles.css"; ``` See [`docs/TOOLCHAIN.md`](../TOOLCHAIN.md) for how that file is staged into `dist/` and what guards it. ## CSS Variables ### 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 | 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 ### Current Configuration LibreDB Studio uses a dark-first design with the following color palette (based on Tailwind Zinc): ```css .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 The layout wraps the app in `next-themes`' provider: ```tsx {children} ``` 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 ### The `@theme inline` Directive Tailwind CSS v4 introduces CSS-first configuration. The `@theme inline` directive maps CSS variables to Tailwind utility classes: ```css @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); /* ... */ } ``` This enables using semantic class names: ```jsx
Content
``` ### IDE Warnings 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`: ```json { "css.lint.unknownAtRules": "ignore" } ``` ## Best Practices ### DO Use Theme Variables ```jsx // Good - uses theme variables