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)
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
- Flat, not boxed. No card-in-card, no divider borders inside a panel. Group with whitespace and a single hairline, never nested rounded boxes.
- 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; don't fork. - Tokens, not literals. Reference CSS vars (
--ui-*,--shadow-nous,--theme-*), never raw hex / 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.
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-3atxs). Don't re-set icon size. - 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. 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, 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. Don't hardcodepx-6/px-8on pages. - Master/detail overlays:
OverlaySplitLayout+OverlaySidebar/OverlayMain. Cron, profiles, etc. ride this — don't rebuild a titlebar shell. - 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). Never ship 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— don't hand-roll 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; don't reintroduce decorative star/sparkle icons.
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. 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 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) — don't hardcode 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?