hermes-agent/apps/desktop/DESIGN.md
brooklyn! d165933c56
docs(desktop): add DESIGN.md design-system guide + close two consistency gaps (#40823)
Codify the desktop overlay/design conventions in apps/desktop/DESIGN.md:
surfaces & elevation (shadow-nous + --stroke-nous), stroke/color tokens, the
single Button (variants/sizes, no per-call overrides), shared form controls
(controlVariants / SearchField / SegmentedControl / Switch), flat layout
(PAGE_INSET_X, OverlaySplitLayout, ListRow, no card-in-card), feedback states
(Loader / ErrorState / LogView / EmptyState), BrandMark, motion, i18n, and the
nanostore state model. Ends with a pre-merge checklist.

Two fixes so the doc isn't aspirational:
- brand-mark: rounded-md + overflow-hidden (doc says "softly rounded")
- i18n ja/zh/zh-hant: mirror en's "Begin" + drop trailing period on
  connectedProvider (doc says update all locales together)
2026-06-06 22:13:17 +00:00

7.8 KiB

Desktop Design System

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.

Principles

  1. Flat, not boxed. No card-in-card, no divider borders inside a panel. Group with whitespace and a single hairline, never nested rounded boxes.
  2. Borderless + shadow for elevation. Overlays float on shadow-nous + a --stroke-nous hairline, not hard borders.
  3. One primitive per concern. One Button, one set of control variants, one SearchField, one Loader, one ErrorState. Migrate onto them; don't fork.
  4. Tokens, not literals. Reference CSS vars (--ui-*, --shadow-nous, --theme-*), never raw hex / ad-hoc rgba in components.
  5. Style lives in the primitive. Variants and sizes own padding, radius, color, chrome. Call sites pass a variant/size, not className overrides that re-specify those.

Surfaces & elevation

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.

Stroke & color tokens

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).

Buttons — one component

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:

  • Text buttons are square (no radius) and sized by padding + line-height (no fixed heights). Only icon buttons carry the shared 4px radius.
  • SVGs inherit size-3.5 (size-3 at xs). Don't re-set icon size.
  • Polymorph with asChild when the button must render as a link/Slot.

Form controls

  • 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.

Layout

  • Gutters: 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.
  • Master/detail overlays: OverlaySplitLayout + OverlaySidebar / OverlayMain. Cron, profiles, etc. ride this — don't rebuild a titlebar shell.
  • Rows: ListRow (settings primitives.tsx) for label/description/action rows. Flat, flush-left; no per-row indentation that fights flush headers.
  • No dividers between rows unless the list genuinely needs them; prefer spacing. When you do need one, it's a single --ui-stroke-tertiary hairline.

Feedback & empty/error/loading states

  • Loading: Loader (src/components/ui/loader.tsx) — animated math/ascii curves (lemniscate-bloom for long ops). Never ship the literal text "Loading…".
  • Errors: 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.
  • Logs: LogView — no bg, hairline border, tight padding, small mono. Every place we surface raw logs uses it.
  • Empty: EmptyState / EmptyPanel — don't hand-roll centered empties.

Iconography & brand

  • 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.

Motion

  • Quick, functional transitions (~100ms on controls). Respect prefers-reduced-motion for anything beyond a fade.
  • Choreographed exits (e.g. onboarding's "matrix" fade-down) stagger per-element then settle the surface — the outer container's fade is delayed so it doesn't swallow the inner animation. Don't let a global fade race the detail.

i18n

  • Every user-facing string goes through useI18n() (src/i18n/context.tsx). No literals in JSX.
  • Update all locales togetheren, 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.

State (TypeScript)

Mirrors the repo TS style (see root AGENTS.md):

  • Shared/cross-component state → small nanostores, not prop-drilling. Each feature owns its atoms; shared atoms live in src/store.
  • Rendering components subscribe with useStore; non-render actions read with $atom.get().
  • Colocated action modules over god hooks. A hook owns one narrow job.
  • Keep persistence beside the atom that owns it. Route roots stay thin.
  • Prefer interface for public props; extend React primitives (React.ComponentProps<'button'>, Omit<…>).

Affordances

  • cursor-pointer at the primitive level (Button, dropdown/select) — don't hardcode it per call site.
  • Global focus-ring reset; titlebar actions have no active-background state.
  • Esc closes every dismissable overlay/dialog (install/onboarding excluded); close is an x-icon, not the word "Close".

Before you add something — checklist

  • Reuse a primitive (Button, SearchField, SegmentedControl, ListRow, Loader, ErrorState, LogView) instead of forking one?
  • Tokens (--ui-*, shadow-nous, --stroke-nous) — zero raw colors / one-off shadows?
  • No className overriding a primitive's padding / size / radius / chrome?
  • Overlay uses shadow-nous + border-(--stroke-nous), no hard border?
  • Flat — no card-in-card, no gratuitous row dividers?
  • All four locales updated for any new/changed string?
  • cursor-pointer, focus ring, and Esc-to-close behave?