mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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)
This commit is contained in:
parent
f033b7dbfb
commit
d165933c56
5 changed files with 178 additions and 8 deletions
167
apps/desktop/DESIGN.md
Normal file
167
apps/desktop/DESIGN.md
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
# 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 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)
|
||||
|
||||
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?
|
||||
|
|
@ -3,11 +3,14 @@ import { cn } from '@/lib/utils'
|
|||
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
|
||||
|
||||
// Brand badge: nous-girl mark on a white tile, identical in light/dark.
|
||||
// Fills the tile (no padding/radius); size via className (default size-14).
|
||||
// Fills the tile (softly rounded); size via className (default size-14).
|
||||
export function BrandMark({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex size-14 shrink-0 items-center justify-center bg-white', className)}
|
||||
className={cn(
|
||||
'inline-flex size-14 shrink-0 items-center justify-center overflow-hidden rounded-md bg-white',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<img alt="" className="size-full object-contain" src={assetPath('nous-girl.jpg')} />
|
||||
|
|
|
|||
|
|
@ -1430,7 +1430,7 @@ export const ja = defineLocale({
|
|||
},
|
||||
startingSignIn: provider => `${provider} のサインインを開始中...`,
|
||||
verifyingCode: provider => `${provider} でコードを確認中...`,
|
||||
connectedProvider: provider => `${provider} が接続されました。`,
|
||||
connectedProvider: provider => `${provider} が接続されました`,
|
||||
connectedPicking: provider => `${provider} が接続されました。デフォルトモデルを選択中...`,
|
||||
signInFailed: 'サインインに失敗しました。再試行してください。',
|
||||
pickDifferentProvider: '別のプロバイダーを選択',
|
||||
|
|
@ -1456,7 +1456,7 @@ export const ja = defineLocale({
|
|||
free: '無料',
|
||||
price: (input, output) => `${input} 入力 / ${output} 出力 per Mtok`,
|
||||
change: '変更',
|
||||
startChatting: 'チャットを始める',
|
||||
startChatting: '始める',
|
||||
docs: provider => `${provider} ドキュメント`
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1391,7 +1391,7 @@ export const zhHant = defineLocale({
|
|||
},
|
||||
startingSignIn: provider => `正在為 ${provider} 啟動登入...`,
|
||||
verifyingCode: provider => `正在透過 ${provider} 驗證您的代碼...`,
|
||||
connectedProvider: provider => `${provider} 已連線。`,
|
||||
connectedProvider: provider => `${provider} 已連線`,
|
||||
connectedPicking: provider => `${provider} 已連線。正在選擇預設模型...`,
|
||||
signInFailed: '登入失敗,請重試。',
|
||||
pickDifferentProvider: '選擇其他提供方',
|
||||
|
|
@ -1417,7 +1417,7 @@ export const zhHant = defineLocale({
|
|||
free: '免費',
|
||||
price: (input, output) => `${input} 輸入 / ${output} 輸出 每 Mtok`,
|
||||
change: '變更',
|
||||
startChatting: '開始聊天',
|
||||
startChatting: '開始',
|
||||
docs: provider => `${provider} 文件`
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1470,7 +1470,7 @@ export const zh: Translations = {
|
|||
},
|
||||
startingSignIn: provider => `正在为 ${provider} 启动登录...`,
|
||||
verifyingCode: provider => `正在通过 ${provider} 验证你的代码...`,
|
||||
connectedProvider: provider => `${provider} 已连接。`,
|
||||
connectedProvider: provider => `${provider} 已连接`,
|
||||
connectedPicking: provider => `${provider} 已连接。正在选择默认模型...`,
|
||||
signInFailed: '登录失败,请重试。',
|
||||
pickDifferentProvider: '选择其他提供方',
|
||||
|
|
@ -1494,7 +1494,7 @@ export const zh: Translations = {
|
|||
free: '免费',
|
||||
price: (input, output) => `${input} 输入 / ${output} 输出每 Mtok`,
|
||||
change: '更改',
|
||||
startChatting: '开始对话',
|
||||
startChatting: '开始',
|
||||
docs: provider => `${provider} 文档`
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue