diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md new file mode 100644 index 00000000000..54b5661cb1f --- /dev/null +++ b/apps/desktop/DESIGN.md @@ -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? diff --git a/apps/desktop/src/components/brand-mark.tsx b/apps/desktop/src/components/brand-mark.tsx index a202a6fe1ea..72edfe22bf6 100644 --- a/apps/desktop/src/components/brand-mark.tsx +++ b/apps/desktop/src/components/brand-mark.tsx @@ -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 ( diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 3565add8fe3..3987eec905d 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -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} ドキュメント` }, diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 09ce699ea09..aa58b00da9e 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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} 文件` }, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 3472352b908..46caac06445 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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} 文档` },