DuckHaven — UI Design¶
Companion to
ARCHITECTURE.md. Defines the visual language, information architecture, and key screens for the DuckHaven control plane SPA (duckhaven-apiis the contract; this document covers the React frontend described in §6 (theweb/code map) and §8 of the architecture).Status: Implemented — describes the shipped DuckHaven SPA.
Audience: the engineer(s) building the frontend.
1. Design DNA¶
Three forces shape every decision:
- DuckDB-first. DuckHaven is for people who already love DuckDB. The
visual identity borrows directly from
duckdb.org/design/manual/: lemon yellow as the brand anchor, the "railroad diagram" motif (curved connectors, junction points, rounded rectangles, 2–3px strokes) as a structural cue, monospace numerals everywhere data appears. - Open-source first. The aesthetic target is a polished developer tool (think Linear, Supabase Studio, Sourcegraph), not an enterprise SaaS console. No marketing gradients, no decorative hero illustrations inside the app. Density and keyboard-first interaction over breathing room.
- Homelab-scale. Two to ten users on Tailscale, not 10,000 on the open internet. The chrome is small. There is one primary surface — the worksheet — and everything else is one click away from it.
We're not trying to look like Databricks or Snowflake. Those products serve very different audiences (enterprise admins, BI analysts) and lean on heavy chrome — large left rails, multi-pane Genie/Copilot panels, dense ribbon toolbars. We borrow their proven patterns (catalog tree on the left, tabbed worksheets, role/warehouse context selector in the worksheet header, results below the editor) but keep the surface area roughly half the size.
What we take from each reference¶
| From Databricks SQL Editor | From Snowflake Snowsight | From DuckDB itself |
|---|---|---|
| Tabbed worksheets, command palette (Cmd/Ctrl-K) | Per-worksheet context bar (warehouse + role → here: agent + workspace) | Lemon yellow brand, railroad/junction motif |
| Catalog browser permanently docked left | Worksheet explorer as a flat searchable list | Monospace identifiers, dark code blocks with syntax tokens |
| Multiple statement results stacked under the editor | Tab-state preservation across sessions | Sparse, technical, no-nonsense voice |
| Real-time progress feedback per statement | "Open in new tab" everywhere | Light mode primary; dark mode honored, not centered |
What we explicitly reject from them¶
- No notebook/cell model (architecture D4). A worksheet is one buffer.
- No AI assistant panel in MVP. The right side of the worksheet is for results, not chat.
- No marketplace, no governance dashboard. Those are enterprise distractions. Lineage is the exception we did take: it earns its place as a tab on the table you are already looking at, not as a destination of its own.
- No expandable mega-nav. Five top-level destinations max.
2. Visual Language¶
2.1 Color¶
The palette is anchored to DuckDB's official brand colors and extended with neutrals for UI chrome. Light mode is the default surface; dark mode is a fully-supported peer (D4 of this doc, §9 of architecture's threat model treats dark mode as a parity requirement, not a nice-to-have, because data engineers run terminals all day).
Brand (from duckdb.org/design/manual/)¶
| Token | Light hex | Dark hex | Use |
|---|---|---|---|
--brand-yellow |
#FFF100 |
#FFF100 |
Logo, primary CTA accent, "run" state highlight |
--brand-orange |
#FF6900 |
#FF8A33 |
Warnings, in-flight query progress, agent attention |
--brand-slate-blue |
#7D66FF |
#9C8AFF |
Selection, focus rings, links |
--brand-maya-blue |
#2EAFFF |
#5BC0FF |
Informational badges, schema icons |
The yellow is loud. Use it on ≤2% of pixels at any time — the Run button, the active worksheet tab indicator, the logo. Never as a background field, never as body text.
Neutrals (Tailwind slate derived)¶
| Token | Light | Dark | Use |
|---|---|---|---|
--bg-canvas |
#FFFFFF |
#0B0F19 |
App background |
--bg-surface |
#F8FAFC (slate-50) |
#111827 (gray-900) |
Cards, panels |
--bg-elevated |
#FFFFFF |
#1F2937 (gray-800) |
Modals, dropdowns, command palette |
--bg-code |
#0B0F19 |
#0B0F19 |
SQL editor background (same in both modes — Monaco) |
--border-subtle |
#E2E8F0 (slate-200) |
#1F2937 (gray-800) |
Pane dividers |
--border-strong |
#CBD5E1 (slate-300) |
#374151 (gray-700) |
Input borders, hovered dividers |
--text-primary |
#0F172A (slate-900) |
#F1F5F9 (slate-100) |
Body, headings |
--text-secondary |
#475569 (slate-600) |
#94A3B8 (slate-400) |
Metadata, captions |
--text-tertiary |
#94A3B8 (slate-400) |
#64748B (slate-500) |
Placeholders, disabled |
Semantic¶
| Token | Light | Dark | Use |
|---|---|---|---|
--status-queued |
#94A3B8 (slate-400) |
#64748B |
Query in queue |
--status-running |
#FF6900 (orange) |
#FF8A33 |
Query executing |
--status-success |
#16A34A (green-600) |
#22C55E |
Done, agent healthy |
--status-failed |
#DC2626 (red-600) |
#EF4444 |
Errors, agent down |
--status-cancelled |
#475569 (slate-600) |
#94A3B8 |
User-cancelled query |
All foreground/background pairs validated for WCAG AA (4.5:1 body, 3:1 large). Yellow text is forbidden on white; yellow only ever appears on dark surfaces or as a fill behind dark text.
2.2 Typography¶
Three families, all open-source and self-hosted (no Google Fonts CDN calls — this is a Tailscale-only app, no public CDN dependencies):
| Role | Family | Weights | Notes |
|---|---|---|---|
| UI / body | Inter | 400, 500, 600, 700 | All product chrome, navigation, table labels |
| Display / brand | Inter (tight tracking) | 700, 800 | Workspace names, page titles. We do not use a separate display face — Inter Tight at 700 carries it. |
| Code / data | JetBrains Mono | 400, 500, 700 | Monaco editor, table cell values, identifiers, query IDs, durations |
Why not Space Mono everywhere (the design-system tool suggested it):
Space Mono's brutalist personality fights with the railroad-diagram
softness DuckDB carries. JetBrains Mono has tabular numerals out of the
box, ligatures developers expect (=>, !=, >=), and Cyrillic/Greek
coverage if anyone ever uses non-ASCII column names.
Scale (8-point rhythm)¶
| Token | px | Use |
|---|---|---|
text-2xs |
11 | Audit timestamps, kbd hints |
text-xs |
12 | Table headers, status pills, breadcrumbs |
text-sm |
13 | Default body, button text, menu items |
text-base |
14 | Worksheet metadata, form labels |
text-md |
16 | Section headers within panels |
text-lg |
18 | Page titles |
text-xl |
24 | Workspace switcher header, empty-state titles |
text-2xl |
32 | Login screen, onboarding only |
Body line-height 1.5. Code line-height 1.6. Tabular figures
(font-variant-numeric: tabular-nums) on every numeric column — rows,
durations, byte counts.
2.3 Spacing & Sizing¶
4-pt grid. Density is closer to Linear than to Snowflake.
| Token | px |
|---|---|
space-0.5 |
2 |
space-1 |
4 |
space-2 |
8 |
space-3 |
12 |
space-4 |
16 |
space-6 |
24 |
space-8 |
32 |
space-12 |
48 |
Standard surface paddings: card body space-4, modal body space-6,
panel header space-3 space-4. Section gap inside a panel: space-6.
Component sizes (touch-target floor 32×32 on desktop — DuckHaven has no mobile/tablet target):
| Height | Padding-x | |
|---|---|---|
| Button (default) | 32 | 12 |
| Button (compact, inline in toolbars) | 28 | 8 |
| Input | 32 | 10 |
| Select / agent picker | 32 | 10 |
| Tab | 32 | 12 |
| Top bar | 48 | 16 |
| Status bar (worksheet bottom) | 28 | 12 |
2.4 Radius, Borders, Elevation¶
| Token | Value | Use |
|---|---|---|
radius-sm |
4 px | Inputs, buttons, badges |
radius-md |
6 px | Cards, panels, dropdowns |
radius-lg |
10 px | Modals, command palette |
radius-full |
9999 px | Avatars, status dots, pills |
Borders are 1px (--border-subtle) by default. Elevation in light mode
uses shadow + border together — drop shadows alone don't read well at low
contrast.
| Elevation | Shadow (light) | Shadow (dark) |
|---|---|---|
e0 panel |
none | none |
e1 card |
0 1px 2px rgb(15 23 42 / 0.04) |
0 1px 0 rgb(0 0 0 / 0.4) |
e2 dropdown |
0 4px 12px rgb(15 23 42 / 0.08) |
0 4px 12px rgb(0 0 0 / 0.6) |
e3 modal |
0 12px 32px rgb(15 23 42 / 0.12) |
0 16px 40px rgb(0 0 0 / 0.7) |
Dark mode prefers borders over shadows for hierarchy (shadows mostly
disappear on #0B0F19).
Scrollbars¶
Scrollbars are thin, rounded pills over a transparent track — quiet at rest,
darkening on hover. The treatment is defined once, at the root: the standard
scrollbar-color / scrollbar-width: thin properties on * in
web/src/index.css, with a ::-webkit-scrollbar fallback for legacy
WebKit/Blink that lack scrollbar-color (feature-detected, so nothing
version-locks). Both paths consume the theme tokens --scrollbar-thumb
(slate-500, ≥ 3:1 against every app surface in both themes) and
--scrollbar-thumb-hover. The assistant chat's Radix ScrollArea thumb uses
the same tokens (bg-scrollbar-thumb, hover:bg-scrollbar-thumb-hover), so
the native and Radix variants stay in sync by construction.
Interaction states are cosmetic. Hover/active strengthening applies in the
legacy-WebKit fallback and the Radix thumb; the standard scrollbar-color
path has no hover variant, so evergreen browsers keep their own momentary
state. On macOS the overlay scrollbars appear only while scrolling; the
treatment applies whenever a scrollbar is visible. One intentional contrast
edge: light-mode hover (#475569) over --bg-code blocks reads ~ 2.5:1 —
momentary and decorative, where resting #64748b keeps 4.4:1.
2.5 Iconography¶
- Lucide as the single icon set (matches the rounded-rectangle, 2 px stroke aesthetic DuckDB uses).
- 16 px in toolbars and tree rows; 20 px in primary nav; 14 px inline with body text.
- Never emoji, anywhere — including empty states and toasts.
- Two custom SVGs we maintain in-repo:
- DuckHaven mark — a duck silhouette nested inside a rounded junction-point shape (a nod to DuckDB's railroad-diagram motif and to DuckHaven being a "haven" — a port of call for a duck).
- Storage backend glyphs — object storage (database), S3 (object cube), ADLS (cloud), each 20 px, single-color, currentColor.
2.6 Motion¶
Conservative. We're a developer tool, not a marketing page.
| Pattern | Duration | Easing |
|---|---|---|
| Dropdown / popover open | 120 ms | ease-out |
| Modal open / overlay fade | 180 ms | ease-out |
| Tab content swap | 100 ms | ease-out |
| Query status pill color change | 160 ms | ease-in-out |
| Skeleton shimmer | 1400 ms loop | linear |
| Worksheet split-pane drag | 0 ms (direct) | — |
prefers-reduced-motion zeros out everything except status-pill color
transitions (those convey meaning, not decoration).
The only "delight" animation is the Run button's brand-yellow pulse when a query has just been dispatched — one 240 ms easing pulse, then the button settles into its disabled-while-running state. No looping.
3. Information Architecture¶
Five top-level destinations, surfaced as a thin left rail (icons + labels on hover/expand). The rail is collapsed to icons by default; expanded state is remembered per user.
┌──┬──────────────────────────────────────────────────────────────────┐
│🦆│ TopBar: workspace switcher │ ⌘K search │ user menu │
├──┴──────────────────────────────────────────────────────────────────┤
│ 📝 │ │
│ 📚 │ MAIN CONTENT AREA │
│ 💾 │ (route-specific) │
│ 🛰 │ │
│ 📊 │ │
│ ⚙ │ │
└────┴─────────────────────────────────────────────────────────────────┘
| Rail icon | Label | Destination |
|---|---|---|
| 📝 (file-text) | Worksheets | Tabbed SQL editor — default landing |
| 📚 (book-open) | Catalog | Workspace → schema → table browser, read-only details view |
| 💾 (database) | Saved queries | Library of named queries, owned + shared |
| 📊 (clock-history) | History | Workspace query log; admins get an All-workspaces audit toggle |
| ⚙ (cog) | Admin | Storage backends, agents, users, maintenance (visible only to admins) |
User menu (top-right): name, "Theme: light/dark/system", "Sign out".
The yellow duck mark in the top-left does not navigate — clicking it opens the workspace switcher (Snowflake-style), the most common destination-change action.
4. Key Screens¶
The five screens that have to be right at M1. Each is shown as an ASCII wireframe with the layout intent, then a short notes block. Margins, exact widths, and component states are settled at implementation time against the shadcn/ui primitives.
4.1 Worksheet (the entire product, basically)¶
┌─────────────────────────────────────────────────────────────────────────────┐
│ 🦆 acme-analytics ▾ │ ⌘K Search… │ ◐ light Marton ▾ │
├──┬──────────────────────────────────────────────────────────────────────────┤
│📝│ Tabs: ▸ events.sql ● │ ▸ funnel-draft │ ▸ + [⋮] │
│📚│ ┌─ Catalog ────────────┐ ┌─ SQL ──────────────────────────── ⚙ ─┐ │
│💾│ │ 🔍 search tables… │ │ Agent: ▾ agent-a (DuckDB 1.5.2 • 6GB) │ │
│🛰│ │ ▾ acme-analytics │ │ Memory: 6 GB ▾ Timeout: 10 min ▾ │ │
│📊│ │ ▾ raw │ │ │ │
│⚙ │ │ ▸ events 42M│ │ 1 SELECT │ │
│ │ │ ▸ users 1.1M│ │ 2 date_trunc('day', event_time) d, │ │
│ │ │ ▸ analytics │ │ 3 count(*) n │ │
│ │ │ ▸ acme-shared (ro) │ │ 4 FROM raw.events │ │
│ │ │ │ │ 5 WHERE event_time >= '2026-05-01' │ │
│ │ │ │ │ 6 GROUP BY 1 ORDER BY 1; │ │
│ │ │ │ │ │ │
│ │ │ │ │ [▶ Run ⌘↵] [Cancel] [Save…] │ │
│ │ │ │ └────────────────────────────────────────┘ │
│ │ │ │ ┌─ Results ─────────────────── ● done 1.4s ────┐ │
│ │ │ │ │ Statement 1 · 30 rows · 6.2 KB │ │
│ │ │ │ │ ┌──────────┬─────┐ │ │
│ │ │ │ │ │ d │ n │ │ │
│ │ │ │ │ │ 2026-05-01│ 9123│ │ │
│ │ │ │ │ │ 2026-05-02│10488│ │ │
│ │ │ │ │ │ … │ │
│ │ │ │ │ └──────────┴─────┘ [Copy] [Download CSV] │ │
│ │ └───────────────────────┘ └──────────────────────────────────────────────┘ │
│ │ Status bar: ● agent-a healthy │ workspace: acme-analytics (S3) │ 1.4s │
└──┴──────────────────────────────────────────────────────────────────────────┘
Notes:
- Three vertical panes, draggable dividers: catalog (240–360 px), editor + results (the rest, with horizontal divider between them).
- Tab strip holds open worksheets. Dot = unsaved. ⋮ menu per tab: Rename, Duplicate, Close, Close others.
- Agent selector lives in the editor's settings strip (gear icon
opens a popover for memory/timeout overrides). The selector itself
shows agent name + DuckDB version + per-query memory ceiling. Health
dot to the left. If the agent is missing the extension required for
this workspace's backend, the option is dimmed with a "missing
azureextension" tooltip — the user can still pick it but Run will fail-fast with the same message inline. - Run button: the only place yellow appears in this screen. ⌘↵ shortcut shown inside the button at 11 px.
- Results are tabs under the editor when a statement produces multiple result sets (rare in DuckDB but possible with multi-statement scripts). One result tab per statement. Pagination is cursor-based and fetched on-demand.
- Status bar is the persistent reminder of which compute against which storage the user is using. This is the answer to "wait, where am I running this?" — a question Databricks/Snowflake users ask multiple times a day.
Engine selector (close-up)¶
┌─ Engine ─────────────────────────────────┐
│ Select an agent to run on │
│ │
│ ● agent-a DuckDB 1.5.2 6 GB │
│ home-server · S3 ✓ · ADLS ✓ · local ✓ │
│ ● agent-b DuckDB 1.5.2 12 GB │
│ beefy-vm · S3 ✓ · ADLS ✗ · local ✓ │
│ ○ agent-c DuckDB 1.4.3 6 GB │
│ legacy · S3 ✓ · ADLS ✓ · local ✓ │
│ │
│ ⓘ This workspace is on ADLS — agent-b │
│ cannot execute writes here. │
└──────────────────────────────────────────┘
The capability advertisement is rendered as compact backend tags
under each agent — ✓ available, ✗ missing extension. The
incompatible row is selectable (for SELECT-against-other-backend cases
the architecture allows) but a warning banner appears in the editor if
the SQL writes.
Query lifecycle pill¶
A single pill in the result header walks through ● queued (slate) →
● running 0:04 (orange, counter ticking) → ● done 1.4s (green) /
● failed (red) / ● cancelled (slate). One affordance, four colors,
matches the --status-* tokens above.
4.2 Catalog browser (full-screen detail)¶
The left-pane tree is a quick navigator; the full Catalog destination is the detail view a user opens when they want to see a table's schema, sample rows, or metadata.
┌─────────────────────────────────────────────────────────────────────────────┐
│ Catalog / acme-analytics / raw / events [≡] [✎] │
├─────────────────────────────────────────────────────────────────────────────┤
│ Backend: S3 (s3://acme-data/duckhaven/acme-analytics/) │
│ Format: Iceberg · Catalog Commits ON · 42.1M rows · 312 MB · 14 commits │
│ Owner: Marton (you) · Last write: 2026-05-15 14:03 by jess (agent-b) │
│ │
│ ┌─ Schema ────────────────────────────┐ ┌─ Sample (LIMIT 50) ────────────┐ │
│ │ # │ Column │ Type │NULL? │ │ event_id │ user_id │ event… │ │
│ │ 1 │ event_id │ UUID │ N │ │ 9c2… │ 4f1… │ click │ │
│ │ 2 │ user_id │ UUID │ Y │ │ 9c3… │ — │ view │ │
│ │ 3 │ event_type │ VARCHAR │ N │ │ … │
│ │ 4 │ event_time │ TIMESTAMP │ N │ │ │
│ │ 5 │ properties │ JSON │ Y │ │ │
│ └──────────────────────────────────────┘ └──────────────────────────────────┘
│ │
│ [Query this table] [Show recent queries] [Show table history] │
└─────────────────────────────────────────────────────────────────────────────┘
Notes:
- Breadcrumb is clickable all the way up. ⌘-click to open in a new tab.
- No edit-schema form — the pencil icon top-right opens "Rename / Drop".
Column changes are offered as generated
ALTER TABLESQL dropped into a worksheet (worksheetSql.ts) rather than applied from a dialog, so what the engine supports is visible before it runs. - Sample rows uses the worksheet's last-selected agent; if none, the first healthy agent compatible with this workspace's backend.
4.3 Workspace switcher (modal, ⌘K from anywhere)¶
Opens on yellow-duck click or ⌘. / Ctrl+.. Same surface as the global
command palette but pre-filtered to workspaces. Linear-style:
┌─ Switch workspace ───────────────────────────────┐
│ 🔍 Type to filter… │
│ │
│ ▸ acme-analytics S3 owner ↵ │
│ ▸ acme-research ADLS writer │
│ ▸ public object reader │
│ ▸ home-lab object owner │
│ ───────────────── │
│ + Create workspace… │
└──────────────────────────────────────────────────┘
The role chip uses neutral colors only (no yellow/orange/green) — it's metadata, not a status. The backend kind is the second column because "where am I writing?" is a more common question than "what's my role?".
4.4 Admin — Agents¶
The agent registry is the screen operators will spend the most time on. It's a list with one drawer-style detail panel.
┌─ Admin / Agents ────────────────────────────────────── [Generate bootstrap] ┐
│ │
│ Status Name DuckDB Host Extensions Mem Last ping │
│ ●green agent-a 1.5.2 homeserver-01 iceberg,httpfs,az 6G 2s ago │
│ ●green agent-b 1.5.2 beefy-vm iceberg,httpfs 12G 1s ago │
│ ●red agent-c 1.4.3 — iceberg,httpfs,az 6G 4m ago │
│ ●amber agent-d 1.5.2 sandbox iceberg 6G 12s ago │
│ │
│ Drawer (agent-b selected) ────────────────────────────────────────────────── │
│ Capabilities · 12 GB cap · 4 cores · Tailscale 100.74.x.x │
│ Workspaces served (last 24h): acme-analytics (124 q), public (8 q) │
│ Recent errors: 0 │
│ [Revoke credential] [View audit for this agent] │
└──────────────────────────────────────────────────────────────────────────────┘
Notes:
- Bootstrap token modal shows the one-time token with a copy-to-
clipboard button and a clear "this is the only time we'll show it"
warning. The token field is
JetBrains Mono, 14 px, no obscuring — at ≤10 users on Tailscale the threat model (architecture §9) doesn't warrant hiding it. - Amber status = connected but missing an extension that some workspace it could serve needs. Hovering the dot lists which.
4.5 Admin — Storage backends¶
A flat table; backends are the second-most-edited admin object.
┌─ Admin / Storage backends ──────────────────────────── [+ Register backend] ┐
│ Kind Name Root URI UC cred In use│
│ S3 acme-prod s3://acme-data/duckhaven/ ✓ valid 3 ws │
│ ADLS research abfss://research@acme/duckhaven/ ✓ valid 1 ws │
│ Object home-lab home-lab/ — 1 ws │
│ Object box — 2 ws │
└──────────────────────────────────────────────────────────────────────────────┘
Notes:
- "In use" is non-zero → delete is disabled with a tooltip listing the workspaces.
- "Register backend" is a 3-step wizard: kind → URI → UC credential
binding + health check. The health check is loud — it returns the
short-lived credential vending result and one test
LISTagainst the root, with the agent that ran the check named in the result.
5. Component Inventory¶
The components below have first-class shadcn/ui implementations and small DuckHaven-specific wrappers. Built on Radix primitives.
| Component | shadcn primitive | DuckHaven wrapper notes |
|---|---|---|
| Button | button |
Adds intent="run" (yellow) + loading-spinner-while-async |
| Tabs (worksheet) | tabs |
Closable, dirty-dot, drag-to-reorder |
| Tree (catalog) | custom (Radix collapsible + virtualized) |
Lazy-load children per click; row count badges |
| Command palette | command |
Static groups: Workspaces, Navigate; dynamic (debounced, ≥2 chars): Recent, Schemas, Tables, Saved queries |
| Combobox (agent picker) | command + popover |
Per-row capability tags |
| Data table (results) | TanStack Table + custom virtualization | Tabular nums; cell click = "copy"; right-click → "Filter by", "Group by" inserts SQL |
| Toast | sonner |
Stack max 3, 4 s default, errors require dismiss |
| Modal | dialog |
size="md|lg"; default focus on first input |
| Drawer (admin detail) | sheet |
Right-side, 480 px, persistent until dismissed |
| Status pill | custom | Four states, see §4.1 |
| Skeleton | skeleton |
One shimmer animation, gated by prefers-reduced-motion |
| Avatar | avatar |
Initials only (no Gravatar — Tailscale-only, no public CDN) |
Monaco configuration¶
- Theme: a custom DuckHaven Dark (used regardless of UI theme — code is always dark, mirroring DuckDB's terminal aesthetic).
- Token colors map to DuckDB's syntax-highlight conventions: keywords =
--brand-yellow, strings =--brand-orange, functions =#7DD3FC, identifiers = slate-200, comments = slate-500. - Auto-format on save uses
sql-formatterwithlanguage: "duckdb". - A 12 px gutter shows query line/col positions from agent errors (S1 pass criterion bullet 3 of the architecture).
6. Interaction States (the "boring but critical" section)¶
These are the states the eight prior sections take for granted. Listing them explicitly so the implementer doesn't have to guess.
6.1 Loading¶
- Initial app load: full-screen yellow duck mark, 1.2× scaled, with a 2 px ring that fills in 600 ms. Replaces nothing — appears before React hydration. Reduced-motion → static logo + "Loading…" label.
- Worksheet tab opening: skeleton-shimmer the editor pane only; catalog stays interactive.
- Query running: editor freezes inputs that mutate SQL (typing is still allowed, but Run is disabled and Cancel takes its place). Result pane shows skeleton table rows.
- Result pagination: a thin yellow progress bar at the top of the result table, no full skeleton.
6.2 Empty¶
Every list and grid has a real empty state. Never a blank pane.
| Surface | Empty copy | Action |
|---|---|---|
| No worksheets yet | "Open a worksheet to start querying." | [New worksheet] |
| No tables in schema | "No tables in raw yet." |
[Create table…] if writer+ |
| No agents connected | "No agents are connected. Generate a bootstrap token to register one." | [Generate bootstrap] |
| No audit rows | "No queries yet." | — (read-only) |
| No saved queries | "Save a worksheet to keep it here." | [Save current worksheet] |
| No workspaces | "You're not a member of any workspace yet. Ask an admin to add you." | — |
Empty-state typography: text-md slate-600 title + text-sm
slate-500 secondary line. No illustrations. The action button is the
only color. Spacing: empty state vertically centered in pane, 320 px max
content width.
6.3 Error¶
Errors are shown where they happen, never as global toasts unless the user couldn't have caused them (network/loss-of-connection).
- SQL error: inline below the editor, with the agent's reported line and column highlighting in the gutter. Copyable. "Open in worksheet" if surfaced from a saved query.
- Agent disconnected mid-query: result pane shows "Agent agent-b disconnected" + suggested action ("Run on a different agent" with a pre-filled engine picker).
- Permission denied: modal with the exact workspace + role mismatch ("You're a reader on acme-research; this action needs writer.").
- Validation (form): inline below the field, red text, 12 px,
aria-live=polite, focus moves to the first invalid field on submit.
Never use red for non-error semantic content (e.g. revoke buttons are neutral with a confirmation dialog — they're not errors, they're destructive actions).
6.4 Focus and keyboard¶
- Every interactive element has a 2 px
--brand-slate-bluefocus ring with a 2 px offset. The ring never collides with adjacent elements. - Global shortcuts:
⌘K/Ctrl+K: command palette⌘./Ctrl+.: workspace switcher⌘↵/Ctrl+↵: Run query⌘S/Ctrl+S: Save worksheet (no autosave — the editor isn't a notebook; we don't want surprise persistence of half-typed SQL)⌘W/Ctrl+W: Close current tab (with unsaved-changes guard)Esc: dismiss modal / cancel running query (with confirm if >1 s)- Tab order matches visual order. Tree rows use ↑↓ for navigation, → / ←
for expand/collapse,
Enterfor "open in active worksheet",⌘Enterfor "open in new tab".
7. Light Mode, Dark Mode¶
Both modes are first-class. Theme switching is per-user, persisted
server-side (so it follows the user across devices on the same
Tailnet), with a system option that follows the OS.
Rules of thumb when implementing both:
- Test in dark first when implementing data-dense panels. Light is the easier mode visually; dark is where contrast bugs hide.
- Borders, not shadows, for hierarchy in dark mode.
- Yellow stays #FFF100 in both modes — its luminance puts it within
AA contrast on both
#0B0F19and#FFFFFF, but only for non-text use. Yellow is never body text. - Code blocks (Monaco) use the same dark theme in both modes. This is deliberate — it tracks DuckDB's own docs convention and reduces retina flash for users running dark terminals next to a light-themed app.
Dark-mode swatch (the one swap that's easy to get wrong)¶
In light mode, text-secondary is slate-600 on slate-50 (legible
metadata). In dark mode, the equivalent is slate-400 on gray-900,
not slate-600 on gray-900 (which fails AA at 13 px). Codify this in
the theme tokens — never inline color literals.
8. Accessibility Contract¶
- WCAG 2.1 AA is the floor. AAA where it costs us nothing.
- All interactive elements: keyboard reachable,
aria-labelif icon- only,aria-pressed/aria-expanded/aria-selectedfor stateful ones,aria-busywhile loading. - Status pills convey status by color and label (never color alone). "● running 0:04" not just "●".
- Toasts use
aria-live="polite"for info/success,role="alert"for errors. prefers-reduced-motionhonored everywhere except the four-state status pill (motion conveys meaning, no good alternative).- Monaco's built-in screen-reader mode is exposed via the editor's
context menu and
Alt+F1. - High-contrast mode (Windows) inherits from CSS system colors — we
don't override
forced-colors: active.
A standing rule: a feature is not done until both keyboard-only and screen-reader walk-throughs of the primary path pass. M2 exit criterion adds this.
9. Frontend Tech Mapping¶
To stay aligned with ARCHITECTURE.md §8:
| Concern | Choice |
|---|---|
| Framework | React 19 + TypeScript + Vite |
| Routing | TanStack Router (typed routes, search-param state) |
| Data | TanStack Query — query + mutation cache; no Redux |
| UI primitives | shadcn/ui (Radix under the hood) + Tailwind v4 |
| Editor | Monaco, lazy-loaded on first worksheet open |
| Icons | Lucide-react |
| Forms | React Hook Form + Zod (admin wizards) |
| Toasts | Sonner |
| Tables | TanStack Table + custom virtual scroller |
| Theme tokens | CSS custom properties + a Tailwind preset that consumes them |
| Fonts | Self-hosted Inter + JetBrains Mono via @fontsource |
| Tests | Vitest + Testing Library + Playwright (smoke + a11y) |
The design tokens live in src/styles/tokens.css as CSS variables; the
Tailwind preset only references those variables. This is what keeps the
DuckDB brand colors in one editable place when the upstream brand
evolves.
10. Open UI Questions (resolve during M1)¶
- Q-UI-1. Workspace switcher: surface storage backend kind as an icon (current sketch) or as a colored capsule? Default: icon.
- Q-UI-2. Show per-statement results stacked (Databricks-style) or tabbed (Snowflake-style) when a script has multiple statements? Default: tabbed. Stacking gets noisy past two statements.
- Q-UI-3. Should the agent picker remember per-worksheet or per- workspace? Default: per-worksheet (matches "user picks engine per query" — D15 of architecture).
- Q-UI-4. Auto-suggest from catalog inside Monaco — pull schemas
eagerly on workspace open or lazily on
.keystroke? Default: lazy, with a 250 ms pre-fetch on schema-tree hover. - Q-UI-5. Result-grid cell click — copy-to-clipboard (current) or open a detail popover (better for long JSON cells)? Default: copy for ≤120 chars, popover otherwise.
- Q-UI-6. Theme default for a brand-new install — light, dark, or system? Default: system, with the first-run modal offering an explicit choice.
11. Out of Scope for the Design RFC¶
The same way ARCHITECTURE.md §2 declares non-goals, this doc declares
design non-goals so they don't sneak in during M1:
- No AI/Copilot/Assistant panel.
- No data-quality dashboards. (Lineage shipped after M1, as a table-detail tab — see Lineage.)
- No marketing pages —
/redirects to/worksheetsafter login. - No notification center. Toasts only.
- No customizable themes beyond light/dark. Brand stays brand.
- No public-internet-facing pages (no signup, no marketing footer).
- No mobile breakpoint. Floor is 1280 × 800. A mobile UI is a v2 decision driven by the on-call use case, not a launch requirement.
End of design document.