Surface system#
The UI splits into two surface contexts. Each surface has its own interaction language so the eye can tell at a glance which "layer" of the app it is hovering: navigation vs content. These rules apply in light and dark themes. Light-theme tokens are the ones that usually go wrong (white fill on a pale rail).
The two surfaces#
─────────────────────────────────────────────────────────────────
surface background tone where it lives
─────────────────────────────────────────────────────────────────
deep `--bg-secondary` the page behind the
three columns (`.app`)
─────────────────────────────────────────────────────────────────
panel raised cards: the left and right rails
`--bg-input` (rails), (`--bg-input`, same
`--bg-primary` (centre) material as the input
box) and the centre
column (`--bg-primary`:
chat stream, settings
and every other page)
─────────────────────────────────────────────────────────────────
The window is three floating cards on one page — the shadcn
Sidebar floating shell for both rails (components/ui/sidebar.tsx,
the registry's shell only) and its inset idea for the centre
column at once. Each card has 14px corners (rounded-2xl) and the
per-theme raised shadow (--composer-shadow), no border and no
ring; the rails keep a 7px gutter on every side (p-2), the
centre card sits 7px off the top and bottom edges and takes its
side gaps from the rails' gutters. The rails are white
(--bg-input) so they share a material with the input box and
the user bubble; the centre card stays --bg-primary so those
white raised elements keep their contrast on it.
In a split layout the centre card dissolves and every pane is its
own card of the same material (.center-col[data-split]): each
pane keeps 7px to the rail cards and the window edges, and the
panes sit 8px apart. Empty panes draw the same card themselves.
Collapsed, a rail is --rail-collapsed-w (63px: the 49px icon
rail inside the card plus the two gutters);
components/layout/use-resizable-rail.ts carries the same number.
Everything inside the cards — rows, section headers, the footer —
is unchanged by the shell and keeps the recipes below.
Interaction language per surface#
Mouse interaction never draws an outer focus ring on buttons. Keyboard focus
on buttons uses a small brightness change without an outline or box-shadow.
The top tab strip is the exception: its role="tab" targets use each theme's
own --focus-ring, which is lighter in dark themes and darker in light themes.
Deep surface (sidebars)#
Components on the deep surface are list rows — conversation
items, branch entries, function favourites, and the same row used
in a content-pane rail (MCP drawio / linear / + Add server).
They should NOT behave like buttons:
- no border, no outline, no fill in the idle state
- hover / selected → switch background to a visible grey
(
--bg-hover/--bg-selected), text stays in--text-primaryor--text-secondary - never fill selected rows with
--bg-input. The rail card is already that white; a white row on it disappears - avoid the brand-coloured glyph treatment except for the very
small status / activity indicators (
.indicator-dot)
Rationale: the sidebar is dense and frequently scanned. A field of brand-coloured pills makes it loud and visually competes with the content column. Greying-on-hover keeps the layer calm and still gives the click target enough feedback.
Panel surface (chat content + dialogs)#
Components on the panel surface ARE buttons / pills / cards:
- they sit on a lifted background, so a "ghost outline" pattern reads cleanly
- idle state —
--bg-surfacebackground,--text-primarytext or brand-coloured text for primary actions - hover — fill with the brand colour, swap text to its contrast
pair (
--text-on-accent) - the inverted hover is what makes the chain of "actions" feel like one design family — the user knows that the colour shift is universally the "this is going to do something" affordance
Header tab pills on a manage page (Abilities / Programs /
Plugins / Skills) are the one bright exception: the selected tab
uses --bg-input so it reads like the search box (lighter, not
darker). That fill is only for those pills. Do not copy it
onto sidebar rows or MCP server rows.
One list-row recipe#
Sidebar nav (+ New chat, Agents, Abilities, History, Scheduler)
and content-pane list rows (MCP drawio / linear / + Add server)
share one box. Do not invent a second height, padding, radius,
or selected fill for the rail on the right.
property token / value
─────────────────────────────────────────────────────────────────
height `--ui-list-h` → `--ui-button-h` → 30px
padding 6px 8px
gap 12px
radius `--ui-list-radius` (10px)
idle transparent, 1px transparent border if needed for
box-sizing only
hover `--bg-hover`
selected `--bg-hover` (same grey, never white / `--bg-input`)
+ Add server is the same row as + New chat: a normal list
row, slightly muted text. Not italic, not a different add-style.
Implementation: .ui-list-item in apps/web/app/styles/base.css
is the source. MCP .serverItem must match those metrics (prefer
composing .ui-list-item over a parallel recipe).
Size system — list rows and Buttons#
List rows keep one fixed set. CSS variables in
apps/web/app/styles/base.css:
set height radius css tokens
─────────────────────────────────────────────────────────────────
list 30 px 10 px --ui-list-h · --ui-list-radius
─────────────────────────────────────────────────────────────────
Sidebar rows, MCP tab pills, and MCP server rows all sit on this 30px rhythm. There is no sm / md / lg ladder for list rows: when one slot may pick from several sizes, every author negotiates with the design and the sizes drift apart.
Buttons are the shadcn/ui Button in the radix-luma style.
apps/web/components/ui/button.tsx is copied verbatim from the
official registry (npx shadcn add button, style radix-luma);
the only local changes are its imports and a forwardRef wrapper
for React 18. Every size is a pill (rounded-4xl); the call site
picks one of the official sizes and never edits the classes.
The sizes are rem-based and this app's root font size is 14px
(html { font-size: 14px } in base.css, which ~560 existing
utilities are tuned to), so every shadcn size renders at 7/8 of
its nominal value:
size app height app text use
─────────────────────────────────────────────────────────────────
xs 21 px 10.5 px avoid — text is too small here
sm 28 px 12.25 px chips and compact control rows
default 31.5 px 12.25 px dialog and settings actions
lg 35 px 12.25 px hero / empty-state actions
icon-sm/default/lg square = circle at those heights
─────────────────────────────────────────────────────────────────
Elements that cannot be a <Button> (a span that hosts its own
✕, a Radix trigger rendered as a span) take the same look through
cn(buttonVariants({ variant, size })) on their className — the
cn() merge matters: the raw class list carries both
border-transparent and the variant's border colour.
A header row (search + tab pills + icon buttons) must share one vertical center. A 1–2 px height mismatch between those controls is a bug, not a variant.
Inputs, selects, and borders#
Inputs and dropdowns share one single-layer 1px edge
(border: 1px solid var(--border), fill --bg-input).
- Do not stack a 2px
:focus-visiblehalo on that 1px edge. After a native<select>closes and focus remains, the stacked ring reads as a double frame. - Hover must keep the 1px border. Setting
border-color: transparenton hover makes the box look like it vanished (MCP catalog buttons had this). - Do not invent a second input chrome per page. Settings,
dialogs, plugins, and MCP editors use the same 1px +
--bg-inputtreatment.
Dialogs#
Dialogs fade in and out only (about 300ms). No slide from the top, no snap-close. Motion is opacity, not translate.
Settings rows#
Settings pages (General, Memory, System, and the rest) use one two-column row:
- left: name, left-aligned. Description stays in this column and does not run into the control
- right: the control / value, right-aligned
- left and right are isolated columns. Do not stack the label above the input
- status chips (
LIVE,NEXT START, …) sit to the left of their control, never mixed left/right across rows
Button variant guidance#
The variants are shadcn's, plus one app-owned elevated; colours
come from the shadcn tokens (--primary, --secondary, --muted, --input, --ring,
…), which apps/web/app/globals.css bridges onto each theme's
palette — so every theme restyles Buttons without touching them.
variant idle hover
─────────────────────────────────────────────────────────────────
default bg-primary + primary-foreground bg-primary/80
outline 1px border-border; transparent bg-muted (light),
in dark, bg-background in light bg-input/30 (dark)
secondary bg-secondary secondary + 5% ink
elevated --bg-input, no border, shadow-sm shadow-md, + 5% ink
ghost transparent bg-muted (/50 dark)
destructive bg-destructive/10 + destructive text bg-destructive/20
link primary text underline
─────────────────────────────────────────────────────────────────
All variants press down 1px while active, show a 3px ring/30
halo on keyboard focus, and drop to 50% opacity when disabled.
elevated is the one variant Luma's registry does not have: Luma
ships no shadowed Button (only its Card carries shadow-md), and
the composer's pills want the same borderless raised look as the
input box. It reuses the per-theme composer shadow pair through
the shadow-raised / shadow-raised-hover utilities declared in
app/globals.css @theme, so the pills and the box lift together
in every theme.
Pick per surface:
- Primary action (Run, Save, Test, Apply, Send) →
default. - Secondary action (Cancel, Close, Reset, Browse) →
outlineorsecondary. - Controls in a dense row (the composer's model / effort /
permission triggers, icon toggles, environment chips) →
elevated: no border, lifted off the page by the same shadow as the input box, a step deeper on hover. - Purely incidental actions that should vanish until hovered →
ghost. - Destructive (Delete, Remove, Stop) →
destructive. - Deep surface — sidebar rows → don't use the Button
primitive. Use
.ui-list-item/nav-classes.ts.
Composer controls#
The chat composer is built from the same parts:
- Environment chips above the box (channel, web surface,
project, working folders, DAG HUD) →
elevatedsm; the add-folder chip iselevatedicon-sm. - Bottom row (permission, chat / exec model, effort, plus,
tool toggles, context ring) →
elevated,sm/icon-sm. The composer CSS hands its older base rules back to the Button withrevert-layerrather than drawing its own chip. - Send →
icon-sm:defaultwhen there is something to send,ghostwhen empty,destructivewhile it stops a run. - Input box → the shadcn Luma Card look through the per-theme
--composer-*tokens:--bg-inputsurface with no border,shadow-smat rest andshadow-mdon hover or focus (shadow alpha 0.1 on light themes, 0.25 on dark), no focus ring; 23px corners (a pill at one line).
Web page preview and browser toolbar#
The floating page preview over chat
(components/center-tabs/web-tab-pip.tsx) is a popover card:
bg-popover, ring-1 ring-foreground/10 in place of a border,
shadow-lg, 10px corners on all four sides, and the stage below the
header clips the page at the same bottom radius. The radius is pinned
to the native page view's corner radius
(apps/desktop/main/web-views.js), which the DOM cannot clip: a
larger frame radius would let page pixels show past the bottom
corners, so the two change together or not at all. The 32px header
holds the title (text-sm font-medium text-foreground), the status
(text-xs text-muted-foreground), and ghost icon-xs icon buttons
with 14px icons whose hover tips are HoverTip + TipBody
(hover-tips.md); a resume error is one line of
text-xs text-destructive, no box. The CSS module keeps geometry only
(position, size, drag cursor, resize handles), because unlayered
module rules would beat the utilities.
The browser toolbar's icon buttons (back / forward / reload / home /
bookmark / library / open in browser / menu, and the control bar) are
the same ghost Button through webToolbarButton()
(components/center-tabs/toolbar-button.ts): icon-sm for its 14px
icon rule, held at the toolbar's 26px by .webToolbarBtn, which
carries geometry only, so the 40px row keeps its rhythm.
Don'ts#
- Don't invent a new hover / selected / border treatment per page. One recipe, then reuse it. A new look needs a line in this file first.
- Don't introduce a new pill background colour without listing it
here first. Flavours in budget: deep grey hover, panel, brand
fill, and the header-tab
--bg-inputexception above. - Don't put brand-coloured fills on the deep surface — the contrast against the rail makes a brand pill look like an alert, not a click target.
- Don't use white /
--bg-inputas the selected fill on sidebar or content-pane list rows. Light theme washes out. - Don't give MCP server rows (or any other content-pane list) a different height, padding, or selected fill than sidebar nav.
- Don't add hover motion (translate-y, scale-105) on either surface. Hover swaps the background; the only motion is the Button's own 1px press while active.
- Don't edit the official classes in
components/ui/button.tsx(elevatedis the one app-owned variant) or restate a Button lookalike in CSS. Pick avariant/size; if a legacy rule still overrides a Button, remove it or hand it back withrevert-layer. - Don't stack a 2px focus glow on a 1px input/select edge.
- Don't drop a control's 1px border on hover.
- Don't slide dialogs. Fade only.
- Don't let a settings description overflow into the right-hand control column, or stack label above control.
Implementation status#
- Done:
Button(radix-luma), the composer environment chips, bottom-row controls, send button, input box, the web page preview (frame, header, icon buttons), and the browser toolbar's icon buttons. - Not yet migrated: popover / menu panels (
MENU_PANEL,components/ui/popover.tsx,dropdown-menu.tsx), tooltips, badges, and the hand-styled CSS-module popups — they still use the glass surface tokens (--glass-*). Form inputs and selects keep the 1px-edge rule above until they move to the shadcn input. Sidebar list rows stay on the list set above.