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.
shadow-nous + a
--stroke-nous hairline, not hard borders.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.Every overlay / dialog / toast (boot-failure, install, notifications,
model-picker, onboarding, prompt-overlays, updates, base Dialog) uses:
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.
| 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), and the icon
family icon / icon-xs / icon-sm / icon-lg / icon-titlebar.
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 / EmptyPanel — don't hand-roll centered empties.Codicon is the icon set. No mixing icon libraries inline.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.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.Mirrors the repo TS style (see root AGENTS.md):
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?shadow-nous + border-(--stroke-nous), no hard border?cursor-pointer, focus ring, and Esc-to-close behave?