mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
Capture durable Desktop engineering principles from recent sessions — state by authority, workspace-switch shapes, resolver ladders, optimistic UI — and point root AGENTS.md at the scoped guide with current filenames.
292 lines
14 KiB
Markdown
292 lines
14 KiB
Markdown
# 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.
|
|
|
|
This file owns the visual and interaction contract. Read
|
|
[`AGENTS.md`](./AGENTS.md) for architecture, state, resolver, transport, and
|
|
testing rules.
|
|
|
|
This doc contains two kinds of content, maintained differently:
|
|
|
|
- **Principles** (flatness, intent, feedback, motion, cancellation) are durable.
|
|
They hold as components come and go.
|
|
- **Named contracts** (tokens, `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.
|
|
|
|
## 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 elevation for floating panels.** Overlays float on
|
|
`shadow-nous` + a `--stroke-nous` hairline, not thick framed boxes. In-panel
|
|
structure may use token hairlines sparingly.
|
|
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.
|
|
6. **Intent before automation.** Surface useful actions and previews, but do not
|
|
open panes, move focus, or navigate because a tool happened to produce
|
|
something.
|
|
7. **Immediate feedback.** Direct manipulation updates the view first. Network
|
|
or disk persistence reconciles afterward and rolls back visibly on failure.
|
|
|
|
## Information architecture
|
|
|
|
- **Chat is the home surface.** The transcript and composer stay primary; tools,
|
|
previews, files, review, and terminal complement the conversation.
|
|
- **Pages are durable destinations.** Chat, Skills, Messaging, and Artifacts
|
|
remain in shell chrome. Do not hide a distinct product noun inside an
|
|
unrelated page.
|
|
- **Route overlays are short tasks.** Settings, Command Center, Cron, Profiles,
|
|
Agents, and Starmap render as `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.
|
|
- **Panes are working context.** Preview, files, review, and terminal remain
|
|
attached to the current task. Their state survives temporary hiding and chat
|
|
switches where the underlying tool is meant to persist.
|
|
- **One action, one home.** A command may have keyboard, palette, and visible
|
|
affordances, but they invoke the same action and state. Do not fork behavior
|
|
per entry point.
|
|
- **Projects own workspace cwd.** Use Sidebar → Projects for local folders and
|
|
worktrees; do not reintroduce a per-session/right-sidebar folder-picker flow.
|
|
|
|
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.
|
|
|
|
## Surfaces & elevation
|
|
|
|
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.
|
|
|
|
## 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`), `micro`
|
|
(status-stack/table-footers), 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` for plain page bodies; `PanelEmpty` for overlay
|
|
master/detail empties with an icon and action. Don't hand-roll a third
|
|
centered empty.
|
|
|
|
## Chat, tools & boot surfaces
|
|
|
|
- The transcript and composer are built on `@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.
|
|
- A tool result may expose an inline action that opens a preview. It must not
|
|
open the rail automatically.
|
|
- Install, onboarding, connecting, boot failure, and reauthentication are
|
|
distinct states with shared visual primitives. Preserve their recovery
|
|
semantics when unifying appearance.
|
|
- Respect `AppShell` overlay ownership. Persistent terminal/content layers,
|
|
route overlays, dialogs, and boot surfaces must not compete through ad-hoc
|
|
z-index literals.
|
|
|
|
## Iconography & brand
|
|
|
|
- **Tabler** is the default component/chrome set. Import its curated aliases and
|
|
`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.
|
|
- Pick the vocabulary by semantic context and reuse the existing icon for an
|
|
action. Do not introduce a third icon set or mix styles within one control
|
|
group.
|
|
- **`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.
|
|
- Motion follows state; it never delays state. Selection, drag targets, cancel,
|
|
and pressed feedback paint in the current frame.
|
|
- Do not animate layout geometry with `transition-all` on a hot interaction.
|
|
Name the properties, avoid backdrop-filter repaints during movement, and
|
|
remove animation before masking a performance problem.
|
|
|
|
## Direct manipulation & performance
|
|
|
|
The app should feel instant under real load — long transcripts, several panes,
|
|
live streams. Design toward that:
|
|
|
|
- Direct manipulation paints first; persistence reconciles after and rolls back
|
|
visibly on failure.
|
|
- Keep interaction feedback cheap: hot-path state stays local or narrowly
|
|
derived, not wired into heavy trees; pointer work coalesces per frame.
|
|
- One drop region has one visual owner, and drop targets speak one affordance
|
|
language across files, sessions, tabs, and panes. Overlapping targets resolve
|
|
to the active one instead of stacking overlays.
|
|
- Forgiving geometry beats pixel-perfect triggers; edge actions live near their
|
|
edge, not clustered in the center.
|
|
- Expensive stateful surfaces stay mounted when hidden. Visibility is not
|
|
lifecycle.
|
|
|
|
Prove speed with realistic content. A fast empty-state demo says nothing about a
|
|
long transcript or a busy terminal.
|
|
|
|
## Keyboard & cancellation
|
|
|
|
- Keyboard ownership follows focus. The focused surface wins its keys; shell
|
|
shortcuts must not steal a terminal's or editor's bindings.
|
|
- Register global shortcuts through the shared layer, not ad-hoc listeners.
|
|
- One cancel gesture does one thing: cancel the active interaction, or close the
|
|
topmost dismissable surface — never both, never the control underneath.
|
|
- Cancellation is synchronous in the UI even if cleanup is async: overlays,
|
|
cursors, and pending gesture state clear at once.
|
|
- Flows that deliberately cannot be dismissed (install/onboarding, destructive
|
|
confirmation) must make that explicit.
|
|
|
|
## i18n
|
|
|
|
- Every user-facing string goes through `useI18n()` (`src/i18n/context.tsx`).
|
|
No literals in JSX.
|
|
- **Update all locales together** — `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.
|
|
|
|
## State (TypeScript)
|
|
|
|
The detailed state contract lives in the scoped
|
|
[`AGENTS.md`](./AGENTS.md). Visual code follows these essentials:
|
|
|
|
- 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()`.
|
|
- Subscribe to derived coarse facts instead of high-frequency source atoms when
|
|
the component does not render the full value.
|
|
- 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?
|
|
- [ ] No automatic navigation, focus steal, or pane opening from background
|
|
events?
|
|
- [ ] Direct manipulation paints immediately and rolls back cleanly on failure?
|
|
- [ ] Hot interactions avoid broad subscriptions, layout thrash, and
|
|
`transition-all`?
|
|
- [ ] Keyboard ownership and single-action `Esc` behavior are correct?
|
|
- [ ] All four locales updated for any new/changed string?
|
|
- [ ] `cursor-pointer`, focus ring, and `Esc`-to-close behave?
|
|
- [ ] Touched a primitive, token, or variant? Its named-contract entry in this
|
|
file is updated in the same change.
|