Rewrite 'do not X' / 'never Y' / 'avoid Z' / 'cannot W' patterns across documentation files into positive 'do ABC to achieve XYZ' instructions. Files changed: - AGENTS.md (180 lines): 30+ patterns converted including caching, profiles, known pitfalls, testing, change-detector tests - CONTRIBUTING.md (50 lines): 14+ patterns including memory providers, cross-platform rules, skill authoring, security - README-FreeBSD.md: operator-user instructions - apps/desktop/DESIGN.md (49 lines): 12 design constraint patterns - docs/observability/README.md: 4 observer contract patterns Hard safety invariants preserved: - Secrets never in logs → 'Keep secrets out of logs. Redact from log output' - Tests never write to ~/.hermes → 'Use _isolate_hermes_home fixture' - Prompt cache never broken → 'Past context stays immutable mid-conversation'
8.2 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
- Flat, not boxed. Use flat grouping with whitespace and a single hairline; avoid card-in-card and divider borders inside a panel.
- Borderless + shadow for elevation. Overlays float on
shadow-nous+ a--stroke-noushairline, not hard borders. - One primitive per concern. One
Button, one set of control variants, oneSearchField, oneLoader, oneErrorState. Migrate onto them; keep each concern using one primitive instead of forking. - Tokens, not literals. Reference CSS vars (
--ui-*,--shadow-nous,--theme-*) for all colors and shadows — avoid raw hex and ad-hoc rgba in components. - Style lives in the primitive. Variants and sizes own padding, radius,
color, chrome. Call sites pass a
variant/size, notclassNameoverrides 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.
Keep overlays uniform: use the shared tokens for all elevation; if a change
is needed, update the token rather than adding per-overlay custom shadows
or borders.
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 |
Reference CSS vars (--ui-text-primary, --ui-stroke-tertiary, etc.) for all
text and border colors. 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-3atxs). Let the button component control icon sizing. - Polymorph with
asChildwhen the button must render as a link/Slot.
Form controls
controlVariants(src/components/ui/control.ts) is the shared shape forInput/Textarea/SelectTrigger. New text-entry controls compose it.SearchField— borderless, underline-on-focus, auto-width. The only search input. Use this single search component instead of building boxed search bars or wrapping 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, witharia-label. No bordered text wrapper.
Layout
- Gutters:
PAGE_INSET_X(src/app/layout-constants.ts) for page side padding;PAGE_INSET_NEG_Xto bleed a child to the edge. Use these constants instead of hardcodingpx-6/px-8on pages. - Master/detail overlays:
OverlaySplitLayout+OverlaySidebar/OverlayMain. Cron, profiles, etc. ride this — reuse the existing shell instead of rebuilding a titlebar for each overlay. - Rows:
ListRow(settingsprimitives.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-tertiaryhairline.
Feedback & empty/error/loading states
- Loading:
Loader(src/components/ui/loader.tsx) — animated math/ascii curves (lemniscate-bloomfor long ops). Use this instead of the literal text "Loading…". - Errors:
ErrorState+ the canonicalErrorIcon(no bg chip). One look for the React boundary, in-dialog errors, and the boot-failure banner. Pass nodes for title/description so RadixDialogTitle/Descriptioncan 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— use the shared components for all empty states rather than hand-rolling centered empties.
Iconography & brand
Codiconis the icon set. No mixing icon libraries inline.BrandMark(src/components/brand-mark.tsx) is the brand glyph — thenous-girlmark 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; keep the brand mark consistent by avoiding decorative star/sparkle icons alongside it.
Motion
- Quick, functional transitions (~100ms on controls). Respect
prefers-reduced-motionfor 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. Keep the global fade synchronized with inner animation timing to avoid racing the detail.
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 inen.tsthat 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
interfacefor public props; extend React primitives (React.ComponentProps<'button'>,Omit<…>).
Affordances
cursor-pointerat the primitive level (Button, dropdown/select) — let the primitive own cursor behavior rather than hardcoding it per call site.- Global focus-ring reset; titlebar actions have no active-background state.
Esccloses 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
classNameoverriding 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, andEsc-to-close behave?