apps/desktop/DESIGN.md
Conventions for the Electron desktop app (apps/desktop). Read this before
adding a component, overlay, or style. The rule of thumb: one source per
concern, tokens over literals, flat over boxed. If you reach for a raw color,
a one-off shadow, a bespoke button, or a hardcoded px-* on a control — stop,
there's already a primitive for it.
This file owns the visual and interaction contract. Read
AGENTS.md for architecture, state, resolver, transport, and
testing rules.
This doc contains two kinds of content, maintained differently:
Button variants, primitive names) are the
design system's current API. They are maintained with the code: if you
change a primitive, token, or variant, update its entry here in the same
change — a stale name in this file is a bug, exactly like a stale type.When a rule and the code disagree, fix whichever is wrong rather than forking a one-off at the call site.
shadow-nous + a --stroke-nous hairline, not thick framed boxes. In-panel
structure may use token hairlines sparingly.Button, one set of control variants,
one SearchField, one Loader, one ErrorState. Migrate onto them; don't
fork.--ui-*, --shadow-nous,
--theme-*), never raw hex / ad-hoc rgba in components.variant/size, not className overrides
that re-specify those.OverlayView cards and return to the previous
route on close. Model/session pickers and dialogs layer above the current
surface; they are not navigation stacks.Navigation must preserve context. A background session finishing, a tool result arriving, or a project refresh may update badges and cached data; it must not replace the foreground transcript or steal focus.
Floating panels (base Dialog, route overlays, boot/install/update surfaces,
model-picker, onboarding, prompt overlays, notifications) use:
shadow-nous /* downward-weighted, layered contact→ambient falloff */
border-(--stroke-nous) /* currentColor hairline, theme-adaptive */
Both are CSS vars in src/styles.css — tune in one place, everything inherits.
Don't add per-overlay shadow-[…] or border-(--ui-stroke-secondary)
one-offs; if elevation needs to change, change the token.
Menus and popovers use their own shared shadow-md +
--ui-stroke-secondary primitive treatment. Drag affordances may use tokenized
dashed targets and local blur. These are semantic surface classes, not licenses
for call-site shadow or border inventions.
| Token | Use |
|---|---|
--ui-stroke-primary…quaternary | hairlines, in descending strength |
--ui-stroke-tertiary | the default in-panel divider / list hairline — and every bordered surface in the transcript |
--stroke-nous | the overlay hairline (pairs with shadow-nous) |
--ui-text-primary / -secondary / -tertiary | text hierarchy |
--ui-bg-quaternary | soft control fill (secondary button) |
--ui-widget-surface-background | fill for inline chat widgets (WIDGET_SHELL_CLASS) |
--chrome-action-hover | hover fill for quiet controls |
--theme-primary, --ui-accent | brand/accent |
Never hardcode border-gray-*, bg-white, text-black, etc. The white tile in
BrandMark is the one sanctioned literal (the mark needs a fixed backdrop).
src/components/ui/button.tsx is the single source. Pick a variant + size;
do not pass h-*, px-*, py-*, or icon-size overrides.
Variants: default (primary), destructive, secondary (soft fill —
the default non-primary look), outline (transparent + 1px inset ring, no
fill/shadow), ghost, link, text (boxless quiet inline — "Cancel",
"Clear"), textStrong (bold underlined inline affordance — "Change",
"Open logs").
Sizes: default, xs, sm, lg, inline (flush, zero box — for buttons
that sit inside a heading/sentence; replaces h-auto px-0 py-0), micro
(status-stack/table-footers), and the icon family icon / icon-xs /
icon-sm / icon-lg / icon-titlebar.
Tooltips only when hover teaches something new. <Tip> is for discovery,
not a tax on every icon. Ask: does hover reveal something the user cannot
already see or infer? If not, skip the tip; keep an aria-label for a11y.
Tip unlabeled chrome when the job (or a keybind / truncated path / host /
other detail) is not already on screen — toolbar / titlebar / statusbar icons,
TipKeybindLabel shortcuts, ownership chips, unlabeled icon grids.
Do not tip:
ActionsMenu / DropdownMenuTrigger) — the
affordance is "open menu"; verbs live in the menu. Never tip
"Actions for ${row title}" / "Project actions" / "Actions".aria-label only).Never use native HTML title= on buttons — unstyled, ~500ms OS delay, clashes
with the themed Tip. src/components/ui/__tests__/no-native-title.test.ts
fails on any <button> / <Button> that still carries title=.
Tooltip timing. A hover is not a click — the cursor crosses triggers on
the way somewhere else. Tip waits 200ms before the first open so a sweep
does not flash a trail. After a tip has opened the page is warm: the next
trigger within 300ms opens instantly. The cooldown starts on close, so a
hover a second later waits again. Close is immediate. OverflowTip stays
on its own longer delay (list titles must not trail while scanning).
Slash descriptions. Keep autocomplete rows single-line and ellipsized, but reveal the complete catalog description in the shared themed tooltip when hovering anywhere on a slash row. Size that tooltip to the window with collision padding and word wrapping; it must not intercept row selection. Catalog and completion producers preserve the full author-supplied description.
Model search. Model filters and their highlighted labels treat hyphens, dots, underscores and spaces equivalently. Preserve original label spelling inside marks. The shared highlighter remains literal for other surfaces such as the command palette; model callers explicitly opt in. Model identifier search does not use dictionary spellcheck.
Keybind hints in tooltips. On a tipped button bound to a rebindable hotkey,
use <TipKeybindLabel actionId="..." /> — it reads the i18n label and the
current combo from $bindings. Pass text={...} only when the label is
context-dependent (e.g. "Show" / "Hide"). Never hardcode combos; always use
useKeybindHint or TipKeybindLabel.
Notes:
size-3.5 (size-3 at xs). Don't re-set icon size.asChild when the button must render as a link/Slot.src/components/ui/badge.tsx. Variants: default (tinted primary), muted,
warn, destructive, outline, solid (primary fill — icon-corner counts).
Sizes: default, xs, overlay (titlebar glyph counts).
controlVariants (src/components/ui/control.ts) is the shared shape for
Input / Textarea / SelectTrigger. New text-entry controls compose it.SearchField — borderless, underline-on-focus, auto-width. The only
search input. Don't build boxed search bars; don't wrap it in a bordered tile.
Empty lists hide their search field.SegmentedControl — the choice control for small mutually-exclusive sets
(color mode, tool-call display, usage period). Replaces radio piles and
pill rows.Switch (size="xs") — bare, with aria-label. No bordered text wrapper.PAGE_INSET_X (src/app/layout-constants.ts) for page side
padding; PAGE_INSET_NEG_X to bleed a child to the edge. Don't hardcode
px-6/px-8 on pages.OverlaySplitLayout + OverlaySidebar /
OverlayMain. Cron, profiles, etc. ride this — don't rebuild a titlebar
shell.ListRow (settings primitives.tsx) for label/description/action
rows. Flat, flush-left; no per-row indentation that fights flush headers.--ui-stroke-tertiary hairline.Loader (src/components/ui/loader.tsx) — animated math/ascii
curves (lemniscate-bloom for long ops). Never ship the literal text
"Loading…".ErrorState + the canonical ErrorIcon (no bg chip). One look
for the React boundary, in-dialog errors, and the boot-failure banner. Pass
nodes for title/description so Radix DialogTitle/Description can flow
through for a11y.LogView — no bg, hairline border, tight padding, small mono.
Every place we surface raw logs uses it.EmptyState for plain page bodies; PanelEmpty for overlay
master/detail empties with an icon and action. Don't hand-roll a third
centered empty.ConfirmDialog is the only way we ask "are you sure". It
opens focused on Confirm, so Enter confirms and Esc cancels, and it owns
the pending → done → close beat and the inline error — a call site passes an
async onConfirm and nothing else. A third way out (e.g. "Remove from
sidebar" beside "Delete worktree") goes in the one secondaryAction slot.
Never window.confirm: it's an unstyled blocking Chromium modal. A handler
that wants the answer inline instead of a mounted dialog calls confirm()
from src/store/confirm.ts, which renders this same primitive through the
single ConfirmHost at the shell — the way notify() backs notifications.@assistant-ui/react. Extend the
existing components under src/components/assistant-ui and
src/app/chat/composer; do not fork a second markdown, message, tool-call, or
approval renderer for one feature.WIDGET_SHELL_CLASS
(src/components/chat/widget-shell.ts): shared radius, the
--ui-widget-surface-background fill, no border. Its actions sit outside
the panel, below it. Don't give one widget its own radius or fill.--ui-stroke-tertiary. Not border-border — that's the app-wide
default and reads too hot against the thread.AppShell overlay ownership. Persistent terminal/content layers,
route overlays, dialogs, and boot surfaces must not compete through ad-hoc
z-index literals. Pick a rung of the ladder in styles.css instead —
--z-modal-backdrop / --z-modal / --z-modal-popover, --z-over-modal
(toasts, tooltips, command surfaces) and --z-over-modal-content,
--z-switcher-backdrop / --z-switcher, then the boot chain
--z-connecting → --z-onboarding → --z-setup → --z-crash. Plain
z-10/z-20 are still right for stacking within one component.iconSize scale from src/lib/icons.ts; do not import icon packages directly
in feature code.Codicon is the compact editor/tool/status vocabulary. Use
src/components/ui/codicon.tsx, including codiconIcon() where a
Tabler-shaped component is required.BrandMark (src/components/brand-mark.tsx) is the brand glyph — the
nous-girl mark on a white tile, softly rounded, identical in light/dark.
It replaced scattered Sparkles glyphs in updates / onboarding / about. Use it
for hero/brand moments; don't reintroduce decorative star/sparkle icons.prefers-reduced-motion for anything beyond a fade.transition-all on a hot interaction.
Name the properties, avoid backdrop-filter repaints during movement, and
remove animation before masking a performance problem.The app should feel instant under real load — long transcripts, several panes, live streams. Design toward that:
Prove speed with realistic content. A fast empty-state demo says nothing about a long transcript or a busy terminal.
useI18n() (src/i18n/context.tsx).
No literals in JSX.en, ja, zh, zh-hant. A string change
in en.ts that skips the others is a regression (drifted punctuation,
stale labels). Keep trailing-punctuation and tone consistent across all four.The detailed state contract lives in the scoped
AGENTS.md. Visual code follows these essentials:
src/store.useStore; non-render actions read with
$atom.get().interface for public props; extend React primitives
(React.ComponentProps<'button'>, Omit<…>).cursor-pointer at the primitive level (Button, dropdown/select) — don't
hardcode it per call site.Esc closes every dismissable overlay/dialog (install/onboarding excluded);
close is an x-icon, not the word "Close".Button, SearchField, SegmentedControl,
ListRow, Loader, ErrorState, LogView, ConfirmDialog) instead of
forking one?--ui-*, shadow-nous, --stroke-nous) — zero raw colors /
one-off shadows?className overriding a primitive's padding / size / radius / chrome?<Tip> + aria-label)?title= on buttons?useKeybindHint / TipKeybindLabel?shadow-nous + border-(--stroke-nous), no hard border?transition-all?Esc behavior are correct?cursor-pointer, focus ring, and Esc-to-close behave?