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 |
--stroke-nous | the overlay hairline (pairs with shadow-nous) |
--ui-text-primary / -secondary / -tertiary | text hierarchy |
--ui-bg-quaternary | soft control fill (secondary button) |
--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.
Icon-only buttons must have a tooltip. Every button with an icon* size
carries no visible text label, so it must be wrapped in <Tip label={...}>
with a descriptive label (matching the button's aria-label). Never use the
native HTML title= attribute — it's unstyled, delayed (~500ms OS default),
and visually inconsistent with the instant themed Tip. An enforcement test
(src/components/ui/__tests__/no-native-title.test.ts) fails on any <button>
or <Button> that still carries title=.
Keybind hints in tooltips. When a button corresponds to a rebindable
hotkey, use <TipKeybindLabel actionId="..." /> as the Tip label — it
auto-reads both the i18n label and the current keybind combo from the store,
so the hint stays live when the user rebinds. Pass text={...} only when the
tooltip is context-dependent (e.g. "Show" / "Hide" based on state). Never
hardcode combos in components — always read from the $bindings store via
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.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.@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.AppShell overlay ownership. Persistent terminal/content layers,
route overlays, dialogs, and boot surfaces must not compete through ad-hoc
z-index literals.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) 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> with a descriptive label?title= on buttons — use <Tip> instead?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?