Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 64 additions & 10 deletions docs/ui/theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ editor frame — is written in the semantic tokens of `src/styles/theme.css`. Tw
|------|-----------------------------------------------------|
| 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`
Expand All @@ -67,6 +69,34 @@ literals the components carried before the layer existed, so **moving a componen
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-<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.

### Surfaces that cannot read CSS

Monaco, Recharts and the `@xyflow` ER diagram paint their own canvas from a JS palette, so they
Expand Down Expand Up @@ -308,35 +338,59 @@ shadcn/ui buttons use theme variables automatically:

### 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.

```css
:root {
--warning: #f59e0b;
--warning-foreground: #ffffff;
--studio-hue-lime: #3f6212;
}

.dark {
--warning: #d97706;
--warning-foreground: #ffffff;
--studio-hue-lime: #9ae600;
}
```

### Step 2: Add Theme Mapping

```css
@theme inline {
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--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

```jsx
<div className="bg-warning text-warning-foreground">
Warning message
</div>
<span className="text-hue-lime">…</span>
```

### 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

### Colors Not Applying
Expand Down Expand Up @@ -413,4 +467,4 @@ shadcn/ui buttons use theme variables automatically:

---

*Last updated: June 2026*
*Last updated: September 2026*
2 changes: 1 addition & 1 deletion src/app/admin/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default function AdminError({ error, reset }: { error: Error & { digest?:
<div className="flex gap-3 justify-center">
<button
onClick={reset}
className="px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
className="px-5 py-2.5 bg-brand-solid hover:bg-brand-solid-active text-white rounded-lg text-sm font-medium transition-colors"
>
Try Again
</button>
Expand Down
2 changes: 1 addition & 1 deletion src/app/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default function ErrorPage({ error, reset }: { error: Error & { digest?:
<div className="flex gap-3 justify-center">
<button
onClick={reset}
className="px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
className="px-5 py-2.5 bg-brand-solid hover:bg-brand-solid-active text-white rounded-lg text-sm font-medium transition-colors"
>
Try Again
</button>
Expand Down
16 changes: 8 additions & 8 deletions src/app/login/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ function LoginFormInner({ authProvider }: { authProvider: string }) {
/>

{/* Ambient glow orbs — blue accent family */}
<div className="absolute top-1/4 -left-20 w-80 h-80 bg-blue-500/[0.07] rounded-full blur-3xl" />
<div className="absolute bottom-1/3 right-10 w-64 h-64 bg-cyan-500/[0.05] rounded-full blur-3xl" />
<div className="absolute top-1/4 -left-20 w-80 h-80 bg-brand-tint/[0.07] rounded-full blur-3xl" />
<div className="absolute bottom-1/3 right-10 w-64 h-64 bg-hue-cyan-tint/[0.05] rounded-full blur-3xl" />

{/* Right edge separator */}
<div className="absolute right-0 top-0 bottom-0 w-px bg-fill-strong" />
Expand All @@ -109,9 +109,9 @@ function LoginFormInner({ authProvider }: { authProvider: string }) {
className="flex items-center gap-3 group w-fit"
>
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-fill-strong border border-hairline-strong group-hover:bg-fill-strong group-hover:border-hairline-strong transition-all duration-200">
<LibreDBLogo className="h-9 w-9 text-blue-400" />
<LibreDBLogo className="h-9 w-9 text-brand" />
</div>
<span className="text-xl font-semibold text-white tracking-tight group-hover:text-blue-400 transition-colors duration-200">
<span className="text-xl font-semibold text-white tracking-tight group-hover:text-brand transition-colors duration-200">
LibreDB Studio
</span>
</a>
Expand Down Expand Up @@ -186,13 +186,13 @@ function LoginFormInner({ authProvider }: { authProvider: string }) {
className="flex flex-col items-center gap-4 lg:hidden group"
>
<div className="relative">
<div className="absolute -inset-2 rounded-full bg-blue-500/20 blur-lg" />
<div className="relative flex h-20 w-20 items-center justify-center rounded-2xl bg-raised border border-hairline-strong shadow-lg shadow-blue-500/10 group-hover:border-blue-500/20 transition-all duration-200">
<LibreDBLogo className="h-12 w-12 text-blue-400" />
<div className="absolute -inset-2 rounded-full bg-brand-tint/20 blur-lg" />
<div className="relative flex h-20 w-20 items-center justify-center rounded-2xl bg-raised border border-hairline-strong shadow-lg shadow-blue-500/10 group-hover:border-brand-tint/20 transition-all duration-200">
<LibreDBLogo className="h-12 w-12 text-brand" />
</div>
</div>
<div className="text-center space-y-1">
<h2 className="text-2xl font-bold tracking-tight group-hover:text-blue-400 transition-colors duration-200">
<h2 className="text-2xl font-bold tracking-tight group-hover:text-brand transition-colors duration-200">
LibreDB Studio
</h2>
<p className="text-sm text-muted-foreground">Open-source SQL IDE for cloud-native teams</p>
Expand Down
2 changes: 1 addition & 1 deletion src/app/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default function NotFound() {
<div className="flex gap-3 justify-center">
<Link
href="/"
className="px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
className="px-5 py-2.5 bg-brand-solid hover:bg-brand-solid-active text-white rounded-lg text-sm font-medium transition-colors"
>
Go to Studio
</Link>
Expand Down
4 changes: 2 additions & 2 deletions src/components/CodeGenerator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ export function CodeGenerator({ isOpen, onClose, tableName, tableSchema, databas
<div className="bg-overlay border border-hairline-strong rounded-xl shadow-2xl w-full max-w-xl mx-4 overflow-hidden">
<div className="flex items-center justify-between px-5 py-3 border-b border-hairline">
<div className="flex items-center gap-2">
<Code strokeWidth={1.5} className="w-3.5 h-3.5 text-purple-400" />
<Code strokeWidth={1.5} className="w-3.5 h-3.5 text-hue-purple" />
<span className="text-xs font-medium text-fg">Code Generator</span>
<span className="text-xs text-fg-muted font-mono">{tableName}</span>
{databaseType && <span className="text-xs text-fg-subtle font-mono uppercase">{databaseType}</span>}
Expand Down Expand Up @@ -296,7 +296,7 @@ export function CodeGenerator({ isOpen, onClose, tableName, tableSchema, databas
}}
className={cn(
"w-full text-left px-3 py-1.5 text-xs hover:bg-fill transition-colors",
language === lang.id ? "text-purple-400" : "text-fg-tertiary",
language === lang.id ? "text-hue-purple" : "text-fg-tertiary",
)}
>
{lang.label}
Expand Down
18 changes: 9 additions & 9 deletions src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export function CommandPalette({
{/* Quick Actions */}
<CommandGroup heading="Actions">
<CommandItem onSelect={() => runAction(onExecuteQuery)}>
<Play strokeWidth={1.5} className="w-3.5 h-3.5 text-blue-400" />
<Play strokeWidth={1.5} className="w-3.5 h-3.5 text-hue-blue" />
<span>Run Query</span>
<CommandShortcut>Ctrl+Enter</CommandShortcut>
</CommandItem>
Expand All @@ -132,30 +132,30 @@ export function CommandPalette({
carries the editor's statement in with it (review of #331 T3).
*/
<CommandItem onSelect={() => runAction(onAskAgent)}>
<Bot strokeWidth={1.5} className="w-3.5 h-3.5 text-blue-400" />
<Bot strokeWidth={1.5} className="w-3.5 h-3.5 text-hue-blue" />
<span>Ask the agent about this query</span>
</CommandItem>
)}
<CommandItem onSelect={() => runAction(onAddConnection)}>
<Plus strokeWidth={1.5} className="w-3.5 h-3.5 text-emerald-400" />
<Plus strokeWidth={1.5} className="w-3.5 h-3.5 text-hue-emerald" />
<span>New Connection</span>
</CommandItem>
<CommandItem onSelect={() => runAction(onNavigateHealth)}>
<Activity strokeWidth={1.5} className="w-3.5 h-3.5 text-emerald-400" />
<Activity strokeWidth={1.5} className="w-3.5 h-3.5 text-hue-emerald" />
<span>Health Dashboard</span>
</CommandItem>
<CommandItem onSelect={() => runAction(onNavigateMonitoring)}>
<Gauge strokeWidth={1.5} className="w-3.5 h-3.5 text-purple-400" />
<Gauge strokeWidth={1.5} className="w-3.5 h-3.5 text-hue-purple" />
<span>Monitoring</span>
</CommandItem>
{activeConnection && (
<CommandItem onSelect={() => runAction(onShowDiagram)}>
<Layers strokeWidth={1.5} className="w-3.5 h-3.5 text-cyan-400" />
<Layers strokeWidth={1.5} className="w-3.5 h-3.5 text-hue-cyan" />
<span>Schema Diagram (ERD)</span>
</CommandItem>
)}
<CommandItem onSelect={() => runAction(onLogout)}>
<LogOut strokeWidth={1.5} className="w-3.5 h-3.5 text-red-400" />
<LogOut strokeWidth={1.5} className="w-3.5 h-3.5 text-hue-red" />
<span>Logout</span>
</CommandItem>
</CommandGroup>
Expand All @@ -170,7 +170,7 @@ export function CommandPalette({
<Icon className="w-3.5 h-3.5" />
<span>{conn.name}</span>
{activeConnection?.id === conn.id && (
<span className="ml-auto text-xs text-emerald-500 font-medium">Active</span>
<span className="ml-auto text-xs text-success font-medium">Active</span>
)}
</CommandItem>
);
Expand Down Expand Up @@ -199,7 +199,7 @@ export function CommandPalette({
<CommandGroup heading="Saved Queries">
{savedQueries.map((sq: SavedQuery) => (
<CommandItem key={sq.id} onSelect={() => runAction(() => onLoadSavedQuery(sq.query))}>
<Bookmark strokeWidth={1.5} className="w-3.5 h-3.5 text-purple-400" />
<Bookmark strokeWidth={1.5} className="w-3.5 h-3.5 text-hue-purple" />
<span>{sq.name}</span>
<span className="ml-auto text-xs text-fg-subtle truncate max-w-[150px]">
{sq.query.substring(0, 40)}...
Expand Down
Loading
Loading