Files
hermes-agent/apps/desktop/DESIGN.md
teknium1 6cb7a64e59 revert: Skills and Plugins card catalog browser, third landing (reverts #119381)
Reverts all seven commits of #119381 (59c54c78..476ff4dd), which
re-landed the native card-based Skills/Plugins catalog browser after it
was reverted twice (#113037 reverting #112669, #115787 reverting
#113128). Capabilities -> Skills and Plugins return to the list/detail
tabs with the embedded catalog picker they had before #119381.

Removed with it:
- Desktop catalog/ module (browser, cards, detail dialog, filters,
  reels/masonry, install switch) and skill deep-link install
  (hermes://skill/install); skills/embedded-hub-picker.tsx comes back.
- apps/shared catalog-browse.ts and catalog-install.ts.
- The `catalog` i18n namespace in every locale (+ regenerated
  locales/_keys.desktop.json); the two i18n pack tests and the keys
  test now exercise connectorsPage.searchPlaceholder instead.
- Website /skills "Open in Hermes Desktop" skill links and the
  extract-skills install identifiers; docs return to the pre-#119381 text.
- Later fixes that only patched the reverted catalog code go with it:
  5832a6eb93 + 7aba2cbbe9 (catalog download deadline),
  9e63cdc4b8 (catalog lookalike rows), c756986db2 (catalog counts),
  the catalog half of c13232e90c / f265273347.

Kept on purpose (later work that depends on code #119381 introduced):
- DialogContent `chrome` / `overlayClassName` / nested-dialog layering
  (the image lightbox uses them).
- vitest.setup.ts / src/test/jsdom.ts stubs (other suites rely on them).
- Kanban toolset wiring on the Plugins Desktop switch (cd30fdadf6,
  7ca2f5665f) and its tests, the `hermes_not_connected` server tone,
  the SearchField flex-1 fix, skill provenance helpers. SkillDetail shows
  a provenance pill only for agent/hub skills, since the pre-#119381
  labels have no `external` entry.
2026-10-02 10:29:28 -07:00

577 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
Profile icons and condensed profile rows offer **Open in new window** and
**Set as default** in their existing context menus. Opening a profile creates a
full peer window without switching the source window. The desktop default
applies at startup and to generic new chats; explicit profile/project actions
and profile-specific windows keep their own destinations. Changing the default
does not move existing sessions or replace the active conversation.
Ordinary **New Window** (`⌘⇧N` / `Ctrl+Shift+N`) inherits its opener's device and
profile only at startup, not as a window-specific default. Later device/profile
selections remain authoritative for new chats unless a desktop default is set.
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. Every floating list —
`DropdownMenu`, `Select`, and Popover + cmdk pickers
(`<PopoverContent variant="menu">` + `<Command variant="menu">`) — paints
through `src/components/ui/menu.ts`, so a list reads the same wherever it
opens. Typed fields with suggestions use `ComboboxInput`, never
`<input list>` + `<datalist>` (Chromium paints that as its own OS popup).
Drag affordances may use tokenized
dashed targets and local blur. These are semantic surface classes, not licenses
for call-site shadow or border inventions.
**Queued cards:** `CardStack` (`src/components/ui/card-stack.tsx`) consumes a live,
keyed list, retaining the current item when more arrive. Inline and floating
approvals and both toast placements share its gesture handling and geometry.
The Cursor-reference treatment uses one 96%-scale silhouette 7px above the
front, 220ms promotion, and 180ms upward clearance; no rotation or lateral throw
on button/keyboard decisions. Consumers supply the existing surface tokens and
own the exact-request response. Gestures never grant approval. Departing cards
are immediately inert; toasts can expand to the full live list. One persistent
transcript-level host owns approvals, independent of tool rows and assistant
message boundaries. Prepared approvals can precede tool.start: execution must
not relocate or remount the stack. While approvals remain, a real activity line
above the cards changes from awaiting approval to current-turn command status;
represented execution rows appear only when explicitly expanded. Empty text
continuations must not introduce paragraph gaps. Keep inline approvals beside
the conversation and let genuine content scroll normally; do not inject padding
or write scroll offsets to pin the decision. Preview this order with delayed
start and completion events, not pre-created tool rows. Final approval removal
retires both the painted card and its measured layout footprint; restoring tool
rows must not insert their full height before the outgoing stack can settle.
No completion callback may clear the measurement of a newly arrived card.
Reduced motion settles immediately without retaining empty clearance.
## Window background behavior
Settings → Appearance → Window layout offers **Minimize to tray**, off by default and
local to this desktop installation. When enabled, minimizing ordinary windows
hides them without stopping their work. Close, Alt+F4, and Cmd+Q keep their
normal behavior. The tray's **Show Hermes** restores hidden windows;
**Quit Hermes** keeps the ordinary active-work confirmation and teardown.
On macOS the tray lives in the menu bar; the Dock icon hides only when no normal
window remains visible and returns on restore. If the tray is unavailable,
ordinary minimize/close behavior is retained rather than hiding an unreachable app.
## Window glass
Glass defaults to **29% Tint, Sidebar only** in both light and dark appearances.
Fade defaults to zero so the content column and text stay opaque. Native frost
keeps its platform/appearance defaults. Explicitly saved settings take precedence;
changing defaults must not overwrite a user's existing choices. The shared
`apps/shared/src/translucency.ts` resolver owns these defaults for both the
renderer and Electron's first window paint.
## Stroke & color tokens
| 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 two tiles in
`BrandMark` (white in light mode, `#0d1117` in dark) are the sanctioned literals
(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`, `floating` (a control loose from any surface — opaque
popover fill + `shadow-md`, hover lifts the glyph only), `link`, `text`
(boxless quiet inline — "Cancel", "Clear"), `textStrong` (bold underlined
inline affordance — "Change", "Open logs").
`grip` is the quiet, fill-free drawer handle; pair it with size `grip` for a
48×16 hit area around a small horizontal ridge.
**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:
- Menu triggers (kebabs / ⋯ / `ActionsMenu` / `DropdownMenuTrigger`) — the
affordance is "open menu"; verbs live in the menu. Never tip
`"Actions for ${row title}"` / `"Project actions"` / `"Actions"`.
- Close / dismiss X buttons — the glyph is the label (`aria-label` only).
- Controls whose visible label already says what the tip would ("click to…",
paraphrases of the same words, timer labels restating "Running").
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. Once triggered, entrance has no animation.
Exit fades over 100ms and moves 0.125rem toward the anchor; reduced motion
disables the exit animation. `OverflowTip` stays on its own longer delay
(list titles must not trail while scanning). Bubbles use a 0.25rem radius.
**Tooltip placement.** Choose intent through `placement`: `control` above (default), `toolbar` below, `row` to the right, and `left-rail` / `right-rail` inward. Explicit `side` and `align` override the preference. Radix flips and shifts for collisions, keeps the arrow attached, and hides detached triggers. Controls and toolbars use their owning pane as a boundary; row descriptions and rails may extend into the window. Use `boundary="viewport"` for an intentional escape. Short labels size to content; descriptions wrap within 24rem and the available space, in one rounded bubble.
**Slash descriptions.** Keep autocomplete rows single-line and ellipsized, but reveal the complete catalog description to the right of the hovered row. Use the shared bounded tooltip, 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:
- 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.
## Badges — one component
`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).
## Profile rail
The profile rail keeps its create/import actions outside the scrolling squares.
Clipped horizontal edges reuse `edgeMask(edges, 'x')` from `fade-scroll.tsx`;
the default axis remains vertical for `FadeScroll`. Fitting content is unmasked,
and profile drag gestures temporarily remove the mask so the dragged square stays legible.
## Context-sensitive dialogs
Sudo password dialogs keep the backdrop unblurred (`DialogContent`'s
`blurBackdrop={false}`) and show the complete, selectable command before the
password field. Long commands wrap and scroll; missing backend context is
explicit, never inferred from another tool row. Other dialogs retain the shared
blurred backdrop.
## 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.
- **`FanMenu`** (`src/components/ui/fan-menu.tsx`) — one hub control that
fans sibling toggles out on hover: `direction` `vertical` | `horizontal`
(split around the hub) | `arc`. Discs are `Button` `floating` off /
`default` on; tips face outside the fan according to its geometry. Use it where a row of rarely
touched toggles is costing input width (the composer's voice controls).
## 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.
- **Settings subpages:** `OverlayNav` keeps navigation and disclosure separate:
labels navigate; the shared `DisclosureCaret` button opens a branch without
changing the page. Active paths reveal automatically, inactive paths stay
folded unless manually opened. General comes first wherever present; parent
labels and parent URLs open the first ordered subpage, never an overview or
the last visited child. Explicit child links retain their destination;
every parent and child uses the same Settings breadcrumb, without a duplicate
icon-and-title heading. Page-level `SectionHeading page` retains actions and
counts under breadcrumb-owned chrome; embedded callers keep their headings.
Narrow windows keep every destination available in the shared navigation dropdown.
Search and saved field links resolve to the owning child before highlighting.
- **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.
## Panel titlebars
Top-edge panels extend into the native titlebar band. Their tab strips remain
inside their own zones so tab drops, focus, and split boundaries use the same
geometry. Panels without room beside the measured window controls place their
tabs on a full-width row below the controls. Minimized row groups use vertical
restore rails, including groups with multiple tabs. Sidebar buttons and shortcuts
restore minimized or fully hidden side groups without changing the selected tab.
Lower panels keep local headers. Empty header space moves the window;
tabs and actions remain no-drag, with native-control space reserved from the
existing traffic-light and Window Controls Overlay measurements.
The left cluster shows sidebar, settings, layout editor, and HUD controls. Flip
and the right-sidebar toggle sit on the right; haptics remain in settings.
In Simple interface mode only sidebar, settings and the layout editor render,
and the reserved cluster width shrinks with them (`TITLEBAR_FIXED_TOOLS` is the
one table both the buttons and the width reservation read).
Holding Cmd (Ctrl off macOS) reveals small slot numbers over the target strip's
status dots after 400ms, without changing tab widths. Hints follow the same
binding and hovered/focused-zone resolver as the number shortcuts.
Tab close buttons fade the label with a content mask, not a painted gradient.
The tab reads its surface token directly so glass tint is painted only once.
Sticky user messages clip covered scrolling content, including the gap above
them. Their wrappers stay unpainted; only the rounded user bubble owns a fill.
Clipping follows the pinned prompt and its live height without changing layout,
so glass and message-bubble transparency do not reveal scrolling text.
## 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.
- **Confirmation:** `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.
## Chat typography
Appearance → Typography keeps **UI Scale** as whole-window zoom (90% by
default). **Chat Text Size** is a separate desktop-local multiplier (110% by
default) on conversation text and the composer editor, including floating and
inline-edit composers.
It does not resize the sidebar, settings, toolbars, media, or pane geometry.
Conversation size and line-height tokens are derived inside the transcript/editor
from their root base tokens; do not multiply the global tokens or nest CSS zoom.
## 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.
- **Inline widgets** — a tool result that renders as a panel the user reads or
acts on (clarify, artifact card) wears `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.
- Bordered surfaces in the transcript (tables, fences, callouts, attachments)
use `--ui-stroke-tertiary`. Not `border-border` — that's the app-wide
default and reads too hot against the thread.
- Interactive directive chips in the composer expose their action on hover.
The action stays visible for a 500ms grace period while the pointer crosses
from the chip to the floating pill; leaving both dismisses it.
- A tool result may expose an inline action that opens a preview. It must not
open the rail automatically.
- Tool rows reserve destructive red for explicit failures. Missing read paths and
ambiguous exit-1 results use neutral notices, with details still available.
Errors described inside returned data are not tool failures. Expanded failures
show the actual explanation; supporting output keeps its normal text color.
- Nested transcript scrollers keep their height caps and hand vertical scrolling
back to the thread at either edge (`overscroll-behavior-y: auto`), even when
their content fits. Only the outer thread contains vertical overscroll;
horizontal code/output boundaries may remain contained. Thinking previews
follow new tokens only while near the bottom, preserving the user's reading
position until they scroll back down.
- Composer status groups start collapsed except todos. Progress updates and queue
pause/resume preserve the user's disclosure choice. Error banners meet the
stack's top edge without a blank padding strip. File and preview links remain
visible at the bottom of the stack, below the queue and all status groups.
A centered ridge on the composer's top edge hides/reveals the entire stack,
including the git row, with a short downward/upward drawer slide. Its choice
persists per conversation and owner, not globally. Hidden sections stay
mounted but inert so their disclosure choices survive; reduced motion is instant.
- Popping out a composer makes it the window's only visible composer. It keeps
its viewport placement while hover or keyboard focus selects a chat pane;
moving back into the editor retains that recipient. Drafts, attachments and
queues stay session-owned. Docking restores the individual pane composers.
In either placement, moving into a chat pane gives its editor typing focus
immediately and preserves its caret. Layout-only hover events and delayed
focus callbacks cannot replace that choice, and a live transcript selection
is never cleared by focus-follow. Movement within the same pane
must not flush React; deliberate Tab navigation and clicked controls still work.
An inline message edit is a typing target of its own: opening one keeps focus
in the edit editor, and neither its mount-time focus nor mouse movement while
it is open hands the caret back to the pane composer.
Active dictation or voice conversation pins the recipient until capture ends,
keeping the microphone's stop controls and shortcut attached to its owner.
- Status-stack rows use `StatusRow` with a leading `dismiss` action, a state
icon and optional trailing actions. `StatusDismissButton` owns the Codicon
close button for previews, background tasks and queued prompts; do not swap
it for a trash icon or a CSS glyph. Icons and controls align to the first text
line, including messages with attachment metadata.
- `status-stack.css` owns the shared columns and `0.25rem` nesting step. Rows
own their padding and full-width hover fill. `StatusControlRow` uses the same
columns for goal/loop/heartbeat details; `StatusPendingIcon` supplies the
dashed marker for tasks and criteria. The first row keeps its normal padding;
the stack adds no extra top inset.
- Keep the rounded status card stationary, with the bounded scroll viewport
inside it. The outer scroll boundary uses `overscroll-behavior-y: contain`;
nested rosters and transcripts use `auto` so wheel input can hand off at an
edge without trapping it or scrolling the chat behind the stack.
- Install, onboarding, connecting, boot failure, and reauthentication are
distinct states with shared visual primitives. Preserve their recovery
semantics when unifying appearance.
- Guide startup has a dedicated landing screen with the shared long-operation
Loader and a localized startup label. It remains active until the guide's
saved conversation is loaded. No greeting, composer, Skip setup, or statusbar
appears early. Reduced motion uses a static BrandMark and the same label.
On readiness, reveal the real conversation and fade controls in over 100ms.
Never fabricate progress, delay readiness for motion, or replace a resumed
conversation with a new greeting.
- Respect `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.
## 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 fixed tile, theme-aware: black girl on white in light
mode, white girl on the `#0d1117` dark tile in dark mode (keyed off
`renderedMode` so bright surfaces in dark skins stay on the light mark).
The marks are generated by `scripts/generate_icons.py` from the girl SVGs
+ squircle backgrounds (`assets/nous-girl-*.svg` × `assets/backgrounds/`).
It replaced scattered Sparkles glyphs in updates / onboarding / about. Use it
for hero/brand moments; don't reintroduce decorative star/sparkle icons.
## Motion
- Visible windows keep animating when another app takes focus. Hidden/minimized
windows and inactive panes may pause; background polling stays focus-gated.
- Animated integer counts reuse `AnimatedInt` in `src/components/ui/diff-count.tsx`.
Its spring updates the DOM directly without per-frame React renders.
- 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.
- Focusing the Sessions sidebar preserves the last active chat's visual emphasis.
Dimming still distinguishes session panes; sidebar navigation must not desaturate
the chat or transfer its active highlight to a hidden primary tab.
- Focused and hovered chat panes both retain full color and opacity. Only panes
that are neither focused nor hovered recede, with 20% desaturation.
- 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** — every catalog registered in
`src/i18n/catalog.ts`. 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 of them. `fr`, `de`, and `es` are complete
`Translations` objects, so a key missing there fails the type check; the
`defineLocale()` overlays fall back to English instead.
- **Sparse locales** (`ar`, `ru`) override the English base through
`defineLocale()`. Large catalogs are split by topic: the Arabic source lives
in `src/i18n/ar_<topic>.ts`, recomposed by `src/i18n/ar.ts`.
## 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`, `ConfirmDialog`) 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?
- [ ] Tips only where hover teaches something new (no kebab / menu-trigger
tips; unlabeled chrome that needs discovery gets `<Tip>` + `aria-label`)?
- [ ] No native `title=` on buttons?
- [ ] Keybind hints on tipped buttons use `useKeybindHint` / `TipKeybindLabel`?
- [ ] 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 registered 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.