# MusePi GUI Design Specification

English | [中文](gui-design.zh-CN.md)

> Status: **living document** (established 2026-08-06) — defines the **design style and interaction standards** for `packages/gui` / `packages/desktop-web` (what it looks like, how it moves, how it is organized). Kept in sync with the implementation; implementation files are authoritative.
>
> Implementation contracts, daemon RPC shapes, pitfall records, and verification methods live in **`docs/gui-implementation.md`** (split out of this file on 2026-08-06). Early wireframe/architecture drafts (gui-prototype / gui-architecture / gui-migration) have been deleted — the implementation shipped long ago; this document and gui-implementation are authoritative.
>
> Editing convention: change the implementation → update this file in sync; when this document disagrees with code, code wins and this document gets updated.

## i18n contract (desktop-web/src/i18n)

- **Named parameters**: `t("… {count} …", { count: n })` — no `{0}` positional parameters (openchamber/opencode/bitfun/kimi-code all use named parameters, which read well translated; positional parameters were musepi's old approach, fully migrated 2026-08-06).
- **Word lists split by domain** (2026-08-16): `zh-CN/` + `en-US/`, 12 domain modules each (shell/composer/sessions/context/collab/transcript/settings/agents/tools/pet/guest), merged by barrel into a flat map — edit copy by finding the corresponding domain file; duplicate keys across domains throw at barrel module load (replacing silent spread overrides). The TUI word list (`coding-agent/src/i18n/zh-CN/`, 13 domains) uses the same split and guard but keeps `{0}` positional parameters. Architecture overview in `docs/i18n.md`.
- **Typed keys**: `TranslationKey = keyof typeof zhCN` (zh-CN barrel merged `as const`) — both key and params of `t()` checked at compile time: misspelled key, wrong placeholder name, missing param → tsgo error. Dynamic keys (schema-driven labels, runtime error strings, `tag.${…}` concatenation) take an explicit `as TranslationKey` assertion — at runtime they still fall back to the raw key via `?? key`.
- **Placeholder types**: `ParamsOf<K>` uses template literal types to extract `{name}` from zh-CN values and map them to `{ [name]: string | number }` — parameter names strongly bound to translation templates.
- **Compile-level en parity** (2026-08-16): every en domain file is `as const satisfies Record<ZhKey, string>` — en missing/extra key is a compile error (negative check TS2353); new zh keys must add en in sync.
- **Plugin seam** (2026-08-16): `registerTranslations(locale, map)` registers/overrides copy at runtime and `emit()`s an immediate re-render (fully new locales supported); plugin-owned keys go through `tLoose(key, params)` (core `t` accepts only `TranslationKey`). Neither persists.
- **en passthrough**: keys are the English source text; replacement applies to the final string (dict hit or key fallback alike) — English UI shows `context · 42%` rather than `context · {pct}`.
- **Tests**: packages/desktop-web/src/i18n/i18n.test.ts (key-set parity, cross-domain duplicate guard, registration override/isolation) + test/i18n.test.ts (lookup/replacement/fallback/English passthrough/no-positional-residue assertions); tests calling setLocale must restore the initial locale in afterAll (bun test runs files sequentially in one process; leakage pollutes other tests asserting English copy).
- **Typed side effects**: typing forces every UI string to have a zh translation — migration backfilled previously passthrough keys (open sidebar/connected/unknown/jump to bottom, etc.); `as const` scenarios (PREFERENCE_LABEL, KIND_TRANSITION, SCALAR_ARGS, TIP_KEYS, SOUND_USAGE_KEYS) use `as const satisfies Record<…, TranslationKey>` or explicit `Partial<Record<…, TranslationKey>>` so dynamic indexing keeps literal types.
- **Call during render**: `t()` is called at render time only (module-load calls capture the old locale — already constrained by comments).

## 0. Tree/Trajectory glossary (normalized 2026-08-21, eliminating naming misalignment)

| Term | Refers to | Component/carrier | Notes |
|---|---|---|---|
| **Session list** | **Sessions** aggregated in the left sidebar by group/project/date/scheduled task (multi-select/pin/status marking) | `SessionTree.tsx` (file name kept from the old name; its job is a list) | Don't call it "session tree"; docs/comments say "session list" |
| **Session/message tree** | **In-session entry tree**: entry id/parentId hierarchy, fork branches, leaf navigation (the semantics of TUI `/tree`) | TUI `tree-selector.ts` + session-level `session-manager.ts`; GUI-side carrier `lib/message-tree.ts` (`buildMessageTree`) | Two data layers distinct from the "session list": the list governs "which session to pick", the message tree governs "how a session branches" |
| **Trajectory** | The current session's **event timeline**: turn grouping + Overview timeline + inspector + jumping | Right ContextPanel "Trajectory" tab (`TrajectoryView` + `TimelineOverview`) | Time-projection axis; same source as the message tree (same batch of entries) but a different projection dimension |
| **/trace (planned)** | Fusion view of message tree × trajectory in the TUI: time/cost/token columns overlaid on the tree structure | New TUI command (reuses the tree-selector data source) | Plan in `docs/tui-trace-plan.md`; `/tree` stays a pure structural projection |

**Naming iron rule**: in code/docs/UI copy, "session tree" may only mean the message tree (/tree semantics); trees in the session sense are always "session list"; timelines are always "trajectory".

## 1. Layout system

- **Three-column shell** (openchamber-consensus layout): left SessionSidebar (sessions/groups/projects) + middle ChatView (message stream + rounded composer) + right ContextPanel (always visible). `gui-shell` is flex ROW — every full-width pane (SettingsView etc.) must carry `flex:1; min-width:0`, otherwise it collapses to content width.
- **Settings panel**: full-window workspace replacement, left nav + right content (`gui-settings-content` fixed-height scroll container, centered column via `width:100% + max-width:840 + margin-inline:auto`).
- **Extensions control center** (settings "Extensions" tab): the section fills the settings viewport (`gui-skills-section` = `height:100%` flex column, `gui-ext-center` `flex:1; min-height:0`) — the left list (`gui-ext-list-scroll`) and right detail (`gui-ext-detail`) scroll **independently** inside their own rounded containers while the settings page as a whole does not scroll (TUI /extensions panel parity); both columns share the thin scrollbar recipe (8px, thumb `text-faint 30%`, hover 50%, same formula as xterm); instruction content is not height-capped (pre has no max-height), scrolling wholesale with the detail area to avoid nested scrolling.
- **Content-edge feathering (ScrollShadow, audited 2026-08-21)**: `useScrollShadow` hook + CSS `mask-image` linear-gradient fade-out at edges — transparent→solid 20–24px soft band, mounted only when content overflows and scrolls away from the edge (driven by `data-top-scroll`/`data-bottom-scroll` attributes). Covered: `.gui-transcript` (chat) / `.gui-sessions-list` (session list) / `.gui-settings-nav-scroll|content` (settings) / `.gui-model-list` / `.gui-notes-editor` / `.pet-bubbles`. **Generic carrier `FadeScroll`** (`components/FadeScroll.tsx` + `.gui-fade-scroll` rule, optional `onClick` passthrough) serves generalized scroll containers without a dedicated class — **all 11 sites wired**: right-panel tab content bodies / trajectory list / extensions tab / GitLog / Diff / PR panels / connect wizard / import list / onboarding selection list / model-settings list / mode-definition dialog.
- **Empty state**: WelcomeComposer large input box (brand/greeting/hints + composer); typing creates a session; focus mode (⌘⇧E) lets the input fill the screen.
- **Message stream**: reuses desktop-web `tr-*` classes; user messages right-aligned rounded bubbles, assistant messages full-width without bubbles; 40px gutter holds avatars.

### Trajectory timeline & inspector (DSH Trajectory Overview parity, 2026-08-21)

The top of TrajectoryView in the right ContextPanel's "Trajectory" tab = a **fixed Overview time bar** (`TimelineOverview.tsx`, does not scroll with the list): a 44px rounded band with sunken inset + 1px border; background = the time domain ([earliest start, latest end] across all turns), mono start/end clocks at both ends; one segment per turn (`traj-ov-segment`, accent 26% → hover 42%; spanning the full round duration when the agent_end-frozen round duration hits, otherwise ending at that turn's last event), and one dot per tsMs-bearing event within a turn (`traj-ov-dot`, colored by kind; user dots slightly lower/staggered).

**Interaction contract** (aligned with DSH Overview drag-focus, restrained pacing):

- **Column-alignment contract (measured 2026-08-21)**: within event rows, tool name/params/result/text all **left-align** with the entry name (same column x, zero left indent inside `.traj-content`); the turn header's `Turn N` label shares width with event tags (`min-width: 62px`) so the **turn summary column aligns exactly with the event content column** (measured equal x) — keep this contract when adding/changing row layout.
- Drag (pointer capture, displacement ≥3px) = select a time interval; interval overlay accent 18% + 1px emphasis edges left/right; active chip (`traj-focus-chip`) shows "Focus hh:mm:ss – hh:mm:ss" + ✕.
- Clicking a turn segment selects that **whole turn**; clicking empty space clears; Esc clears the interval first, then the selection (consistent with the modal keyboard contract).
- Hovering a segment/dot → tooltip (`traj-ov-tip`, `gui-fade-in` 120ms): title + start/end/exact moment + duration; hides with a 140ms leave delay, easing glides between tooltips. **Conversational durations**: 0.8s / 12.3s / 1m 23s / 1h 02m (`durationText`, same wording as the status bar).
- **Focus mode**: while an interval is active, list turns/events dim (`.traj-event--dim` 0.35 + saturate .6 / `.traj-turn-group--dim` 0.45); entries inside the interval stay untouched — no clipping, only denoising; dsh-style "focus" rather than "filter".
- **Selection inspector**: clicking a row selects it (accent 45% outline + 8% fill); the inspector panel (`traj-inspector`) sits below the time bar: header kind tag + title + ✕; grid rows = time (full format) / turn / round duration (shown only on roundDurations hit) / exact moment (mono); **settled assistant records additionally show model request stats** — tokens (`↑{in+cacheWrite} ↓{out} ☍{cacheRead}`, k/M compaction) / request duration / time-to-first-byte / rate (`output/duration` tok/s, same wording as transcript usage rows); Input/Output blocks (`traj-inspector-pre`, max-height 132 with internal scroll, pre-wrap).
- Motion: transitions 120–140ms reading `--gui-ease-out`; under `gui-motion-off` hover fades degrade naturally to instant appearance (opacity-based animations).

**CSS naming**: `traj-ov-*` / `traj-focus-*` / `traj-inspector-*` / `traj-event--selected|--dim` all live in the trajectory section of `gui-workspace.css`. Icons reuse the oc-icons sprite (focus chip uses `target`; **never the nonexistent `focus-3`**).

## 2. Design tokens & theme

- **Three orthogonal axes** (the DOM layer always carries resolved values, no "system"): `data-theme` (resolved light/dark) + `data-accent` (accent preset) + `data-ui-theme` (independent light/dark UI preset).
- **Density**: `--gui-density` is a **unitless factor** (e.g. `1`/`0.85`); CSS uses `calc(32px * var(--gui-density, 1))`.
- **Radii**: `--radius-lg` ladder etc.; cards uniformly `border: 1px solid var(--border)` + `background: var(--color-surface-raised|sunken)`.
- **Fonts**: UI defaults to serif plus bundled Maple Mono NF CN (monospace); variable fonts Inter/JetBrains Mono under `@fontsource-variable/*`. Code block font size follows `--gui-code-size`.
- **Glass**: `gui-vibrancy` IPC + CSS `--gui-glass-overlay` opacity; transparency toggle off = 100% overlay covers all semi-transparent rules.

## 3. Motion standard (core standard, finalized 2026-08-06)

| Scenario | Component | Mechanism |
|---|---|---|
| Conditional block (visibility follows another option) | `<Reveal open>` (`components/Reveal.tsx`) | useCollapse px-height 240ms `cubic-bezier(0.22,1,0.36,1)` + outer 160ms fade-in; closed state `aria-hidden`+`inert`; node stays mounted |
| Stays mounted but changes height (tab switches/list growth) | `<HeightMorph morphKey>` (`components/HeightMorph.tsx`) | Capture old height at render → commit new content → height transition → settle `auto`; same outer 160ms fade-in restarts per key; **during the morph the container clips content with `overflow:hidden`, otherwise new content instantly fills (overflow pins the short box) and you only see box edges moving = "no animation"; restored after settle**; when height is unchanged (`|target-prev|<1`, e.g. the fixed-height scroll container of settings section switches) skip pinning/clipping and keep only the fade — otherwise the scrollbar vanishes 300ms and scrolling gets disabled; **duration adapts to height delta 240→480ms (`delta/6` capped) — the ease-out curve is extremely front-loaded, so even a fixed 240ms large expansion (e.g. the 2200px provider grid) still reads as a quick pop** |

- **HeightMorph iron rules**: children render directly into the ref-carrying outer element, **never wrap an intermediate div** — callers pass their own layout classes (`.gui-provider-grid` is a CSS grid); a wrapper becomes the grid's only child and stacks everything single-column (lesson from 70 cards at 4234px pseudo-smooth). `display:contents` is an alternative fix but the fade won't render. **Morphs must clip** (see table above) — the provider "show all" expansion reveals cards progressively (8→40→56→…→70), not cards popping in at once.
- **Forbidden** to collapse via `grid-template-rows: 0fr↔1fr` (Chromium one-way animation issue documented in useCollapse).
- Settings-section switching is a fixed-height scroll container; HeightMorph contributes only the fade.
- Timing conventions: height/morph 240ms + `cubic-bezier(0.22,1,0.36,1)`; fades 160ms ease; KITT sweep 1.7s same easing alternate.
- Application sites: SettingsView conditional items (theme branch/glass slider/sweep color), model inner tabs, provider-grid "show all", settings section switching, SessionSidebar group/project blocks, CustomGroups.

## 4. Component & settings patterns

- **Settings rows**: `gui-settings-row` = label+desc left, control right; `PrefToggle` (toggle, `storageKey` + optional `onClass` applied inverted onto documentElement) / `PrefSegmented` (segmented choice) are the two standard controls; prefer reusing them for new settings.
- **TUI settings sync (2026-08-11, merged into existing tabs)**: all **336 config items across the TUI settings panel's 10 tabs merged into desktop settings**, no separate "TUI Settings" page — **merge into a matching tab when one exists, add a tab when not, rename where needed**: Appearance (merged, native theme card + schema 30 items) / Model Settings (merged, role models + schema 44 items) / Tasks & Subagents (renamed from "Subagents", merged tasks 28 items) / new Interaction (41) · Context (27) · Shell (16) · Tools (60) · Providers (36); Files & LSP and Memory stay as independent schema sections. All **schema-driven** (daemon `settings.schema` RPC, same source as TUI). Controls: boolean→toggle / enum→select (enums without options synthesized by the daemon) / string→input (credential masking preserved) / number→input / **array→comma-separated input (committed on blur)** / **record→compact JSON input (invalid JSON errors inline, not committed)**; changes write optimistically via `settings.set`, rolled back on failure. Conditional gating CONDITIONS aligned with TUI settings-defs (11 entries; `hasImageProtocol` always true on desktop). **Full Chinese i18n**: 966 translations ported from coding-agent i18n into desktop-web zh-CN (labels/descriptions/options/groups; proper nouns such as models/providers/voices keep source form, consistent with TUI); uncovered items fall back to English. Behavior spot-check: textVerbosity low ≈150 chars vs high ≈250 chars (verified along the GUI→daemon→session behavior chain). **Duplicate-definition audit fixes (2026-08-11)**: ① single language source — `settings.locale` (config.yml) is the sole source: boot syncs renderer locale via `settings.get` (the daemon does not backfill schema defaults for unset `settings.locale`/`defaultThinkingLevel`, preventing a forced switch away from Chinese UI when unconfigured), the regular language select and the Interaction tab's "Interface Language" row both dual-write RPC + localStorage mirror; NAV_GROUPS became render-time evaluated (the former module-level constant froze the first language); ② thinking level — dropped the `musepi-gui-default-thinking` localStorage mirror; WelcomeComposer preselection moved to a boot snapshot: prefer `modelRoles.default`'s `:level` suffix (off→thinking disabled), else fall back to the configured `defaultThinkingLevel` (auto allowed), medium when unconfigured; boot also strips the suffix for model preselection; ③ schema rows with `options:"runtime"` (theme.light/dark) render as read-only inputs in the GUI (prevents typing invalid theme ids into config.yml), hinting "options provided by the TUI runtime". **Navigation de-overlap**: "Agents" renamed "Running Agents" (live roster, agents.list 2s polling), clearly distinguished from "Tasks & Subagents" (tasks schema config) — their contents never overlapped; the renaming removes confusion. **Full roster removal from settings (2026-08-11)**: both the "Running Agents" settings tab and the roster block embedded in "Tasks & Subagents" were deleted — the live roster lives in the session right-panel AgentsPanel (a real-time HUD driven by the session stream: main/sub rows, status, activity, relative time, progress/lifecycle — richer than agents.list polling); the settings page returns to pure configuration semantics. Swarm GUI inventory (vs kimi-code apps/kimi-web): kimi has an in-transcript inline SwarmTool card (member accordion + phase dots + overview bar + done/total), an AgentDetailPanel (pause reason/streaming output/progress groups), and a ChatDock+TasksPane bottom dock; we currently have the right-panel AgentsPanel HUD + task/yield tool cards rendering agent results, no in-transcript swarm card, no detail panel. **Task tool card upgraded to SwarmTool grade (2026-08-11)**: header done/total chip (aggregate failures shown red), phase overview atop the body (segmented bar done/merge-failed/running/failed/aborted five segments + legend), a leading phase dot per member row (running pulses), a per-member accordion (click the chevron to expand full output/errors/patch; settled members default collapsed, chevron rendered only when details exist); all data comes from the existing TaskToolDetails.results/progress, no new data pipeline. 6 SSR tests + CDP verification (2/2, 3/3 chip, ok/run dots, chevron expands alpha output). Follow-up polish: task-list rows without content (only #N, no description) no longer render empty rows; advanced AgentProgress fields at live/settle time (retryState/extractedToolData/inflightTaskDetails) not yet consumed.

**Desktop subagent operations (2026-08-11, TUI Agent Hub parity)**: daemon adds RPCs `agents.kill` (abort + release tombstone→aborted) / `agents.revive` (ensureLive) / `agents.chat` (ensureLive + prompt steer, isomorphic to the collab host's agent-cmd, beside agents.list in server.ts). In the GUI right ContextPanel, AgentsPanel renders an AgentControls action bar for the selected row (gui/src/components/AgentControls.tsx): running→stop, parked/aborted→revive, chat input (Enter sends), errors in small print. Collab guests go through agent-cmd frames, desktop through RPC — both paths semantically identical. The SDK events.ts agent-progress payload comment corrected to SubagentProgressPayload wrapper (the shape the daemon actually sends). RPC verified live: kill idle→{ok}+ref aborted, kill/revive error paths, chat→ensureLive+steer effective; the running-state kill timing window wasn't captured (the step-3.7-flash subagent finishes too fast), abort path isomorphic to the collab host. **i18n completion**: schema UI strings cover 100% of translatable items — labels/descriptions/option labels/option descriptions fully translated (≈240 newly hand-translated entries, reusing 78 coding-agent zh entries); only proper nouns (models/providers/voices/hardware/API-key names/numbers) stay in English, consistent with TUI; zh-CN.ts fully formatted via biome --write.

- **Settings key names**: always `musepi-gui-*` (e.g. `musepi-gui-chat-usermsg`, `musepi-gui-statusbar-indicator`); class-toggle-like preferences (e.g. `gui-chat-hide-time`) toggle on documentElement, styled in gui.css's preferences section.
- **Settings → Check for updates (2026-08-22)**: inline display of "current version / status (checking · up to date · new version found)" + a manual check button; on discovery, expand below desc an **update-notes summary + an explicit "Go to download" button** (no automatic window.open popping a browser). The startup auto-check (main process, after 12s) pushes its notice as a **bottom-right toast** (`UpdateToast`): version (current → latest) + notes + "Go to download"/"Skip this version" (remembered per version in localStorage, bitfun parity). **Two independent notes channels**: the toast reads `update-manifest.json.notes` (OTA channel); the "What's new" dialog reads `CHANGELOG.musepi.md` — both must be filled at release. Implementation details in `gui-implementation.md §17`.
- **Preview mode**: config items carry live previews (effect preview/chat preview) — reuse **real rendered components** + sample content, driven by option state; never fabricate static fake previews.
- **Status bar**: braille/orb indicator (`--gui-status-accent` session color, ported from TUI djb2 hash) + flowing-light/KITT/plain-text effects; KITT is a gradient bright band within the text (not a separate bar); sweep color selectable between default hue/accent.
- **Pause UI (2026-08-20, same gate as TUI /pause)**: two-level pause, state comes from the daemon (survives reconnect/restart, see gui-implementation.md §1c). **Global pause** = full-screen frosted overlay `GlobalPauseOverlay` (`.gui-global-pause`, active overlay + centered card: pause icon + "Paused" title + live timer `formatPauseElapsed` minutes:seconds, anchored via `data-paused-at` so pausedAt isn't reset by remounts); the header pause button sends `daemon.pause/pauseRelease`. **Session-level pause** = a banner inside ChatView + live hold timer (also anchored to pausedAt); the header session pause button sends `session.pause/pauseRelease`. The two levels don't interfere: session pause freezes only that session (a per-session AgentPauseGate), global pause freezes all sessions (a process-level gate; the agent loop checks global first, then session). A paused session reports `working` false (list/tree paused badges share the same source). Resume = re-fetch `pauseStatus` + the subscription-stream `pause-state`/`global-pause-state` envelopes driving updates.
- **Input boxes**: Composer/WelcomeComposer share `autosize` (data-focused aware); the focus-mode morph must use `useLayoutEffect` (a passive effect paints one full-width frame before snapping back).
- **Context menu (unified standard component)**: all floating context menus (sessions/groups/project blocks/list items) use the `ContextMenu` component (`.gui-context-menu`, frosted glass: bg-overlay + blur(24px) saturate(180%) + layered shadows + 130ms gui-menu-in/out, portal to root) — **never invent another menu style**; items = icon + label + optional hint/divider/danger/disabled/color dot (group color picker uses the `color` attribute + `gui-dot`). Action ownership: sessions=fixed actions; groups=rename/color/delete; project blocks=open Finder/copy path/remove project (removal lives in the context menu, no separate inline delete button). **Inline editing** (e.g. group rename) uses a dedicated compact class (`.gui-group-edit`: 13px/500, line-height 20px, padding 1px 4px, transparent background + accent 55% thin focus border) — the global `.gui-input` (8px padding + border ≈38px tall) makes the inline-editing state bloat and jump; forbidden for inline rename.
- **Floating menus must enter in two phases (critical fix 2026-08-06)**: on mount, `ContextMenu` and `Pop` first land with **an opacity-0 no-animation class**, then on the next frame (double rAF hop) receive the `--entered`/`--pending` class starting `gui-menu-in` — `useFloatingMenu` already worked this way. Reason: **animating at mount (gui-menu-in carries transform scale) makes Chromium skip backdrop sampling on the real screen compositor, so the menu renders as plain translucency (content behind shows straight through, no frost)**; CDP screenshots (offscreen compositing) still show blur and easily mislead verification (we once misjudged "transparent-window blur totally broken" and wrongly covered every float layer with a 95% scrim — since reverted; never again). Implementation: `.gui-context-menu{opacity:0}` + `.gui-context-menu--entered{opacity:1;animation:gui-menu-in}`; Pop reuses `.gui-menu-popup--pending/--entered`; **Pop caller classes (openin/instance/header-title/overlay/creds/view/add-project/proj) must not ship their own animation** (removed; provided uniformly by --entered). Verification requires real screen screenshots (screencapture -l); CDP screenshots don't count.
- **Two-phase unification across all float layers (2026-08-11)**: added shared hook **`useTwoPhaseEnter(active)`** (`lib/use-two-phase-enter.ts`, returns the `--entered` suffix) — Board zoom (`gui-board-focus`)/widget tasks (`gui-task-modal`)/onboarding backdrop (`gui-onboarding-backdrop`)/⌘K command palette (`gui-palette`)/selection toolbar (`gui-select-pop`) previously did conditional-mount + played `gui-fade-in` straight on the mount frame, a §6.5 risk class (pure-opacity mild variant; failure never reproduced but violated the contract); all now wired. Accompanying: the command palette moved to **persistent mounting** (app.tsx no longer conditionally renders it; internal `visible`/`closing` state; exit plays `gui-menu-out` 130ms + backdrop fade); the selection toolbar gained proper enter/exit (`gui-select-pop-in/out`, keyframes must bake in the inline `translateX(-50%)` — transform animations override static transforms); base rules unified to `opacity:0` + `--entered{opacity:1;animation:…}` so `gui-motion-off` degrades naturally to instant appearance (no per-class motion-off listing needed). **Class-name composition trap (2026-08-11, all 5 first drafts hit it)**: the base class is the only source of fixed/centering/blur, so you must **keep the base class + append the complete BEM modifier token** — `gui-foo${entered ? " gui-foo--entered" : ""}`. Two wrong concatenations: ① `gui-foo${entered}` → only `gui-foo--entered`, base class lost → the layer falls into document flow (onboarding card observed at x=280 y=0, trailing the sidebar); ② `gui-foo --entered` (space-concatenated suffix) → the orphan `--entered` class matches no selector → forever opacity:0 invisible. Verified via CDP `getBoundingClientRect`: backdrop `position:fixed inset:0 opacity:1` + card centerDelta [0,0].
- **First-run onboarding floats centered (2026-08-11)**: ZCode two-column card floating centered — `min(1000px, calc(100vw-160px)) × min(620px, calc(100vh-160px))` (≥80px frosted margin per side, 20px radius + 24px shadow). **Absolutely not fullscreen edge-to-edge**: user feedback on the near-fullscreen attempt (40px margins) said right/top touched the app edges and it felt oversized overall; the onboarding layer must keep the surrounding frosted feathered mask clearly visible to feel "floating above the app". Card `flex column` + grid `flex:1` fills height; left-column content bottom-aligned (dots `margin-top:auto`), demo animation vertically centered in the right gradient panel. **Demo windows for the three steps unified at 280×190** (previously three sizes 236×168 / 244×155 / 244×120 made the right panel jump between steps; chat/settings use `justify-content:center` to vertically center content in the fixed box).
- **Single-layer ownership of floating card faces (2026-08-15, ColorPicker double-paint lesson)**: `useFloatingMenu`'s `className` lands on the portal outer element — **menu-type** (proj/todo/queue/creds…) passes className and the outer element acts as the card face, so content must be flat elements without a card face; **panel-type** (quota/context/color-picker) components carry the card-face class on their root and callers **must not pass className**. The same card-face class at two layers = nested double-painted rounded frosted containers (an extra layer of rounded glass behind the content). Test: the card-face class appears exactly once in the DOM.
- **Dot-matrix brand background** (`DotMatrixMark.tsx`, behind WelcomeComposer, kimi-referenced enhanced edition 2026-08-06): text rasterized into a full rectangular dot matrix — faint background dots (fg 8%) + lit text dots (fg) + ~2% colored accent dots (5-color palette) with **slowly flowing HSL hue** (sin offset ±0.12, independent phase per dot) + **feathered edges** (42px smoothstep decay beyond the text bbox, radius/alpha fading with distance, no hard rectangular boundary) + **click ripples** (pointerdown spawns a wavefront expanding 0.55px/ms, radial pulse pushing/enlarging dots within a 26px band, dissipating past 900px) + breathing + mouse halo (attraction magnification/color shift on active). **i18n font adaptation**: CJK/JP/KR auto-switches font stacks (PingFang/Hiragino/Noto…). **Glyph parameters (tuned 2026-08-06)**: `gridGap 7` + `dotRadius 2.0` + weight `600` + 140px short-text ladder (>6 chars 115, >10 chars 85; CJK >4 chars 120, >8 chars 90) — measured ≈**11 columns per character** (~15% denser than the kimi reference's ~9.6 columns at 140@8); at weight 700 the M's diagonals rasterize into solid blocks (reads as a clunky square), 600 keeps the pixel staircase, the classic dot-matrix M; the `fontSize` prop can override (settings preview passes 96). The mark positions at `top: 17%` keeping the text's lower edge (≈235px) above the brand-line top (≈242px). IntersectionObserver pauses offscreen.
- **Customization & preview** (Settings → General): `musepi-gui-dotmatrix` toggle + `musepi-gui-dotmatrix-text` custom text (default MusePi, ≤24 chars, welcome page and preview linked live, event `musepi-dotmatrix-changed`); preview = a small-font instance of the same component (`fontSize={96}`) inside `.gui-dotmatrix-preview` (744×170 rounded card), **must set CSS size on the preview canvas** (`width/height: 100%`) — the component sets pixel buffers but no CSS size; unstyled, the canvas displays at buffer size (300×150×dpr), text 2× enlarged and clipped top-left by the container (the welcome canvas has `.gui-welcome-mark` inset:0 so it dodges this).

### Companion (Agent Companion, BitFun parity, 2026-08-06)

- **Presets**: 10 built-in Petdex presets (`BUILTIN_PETDEX` in `src/lib/pet.ts`, sprites in `public/pets/`, 768×936 = 8×9 grid, 96×104 frames, sourced from BitFun MIT). The settings grid groups by "Imported → Presets"; card = rest-frame thumbnail (`zoom: 0.66`, doesn't clip transform animations) + name + description truncated to 2 lines; the selected card gets an accent border + 14px check (`gui-pet-card__check` needs explicit width/height — the Icon component has no default size; omitting it renders 219px).
- **Companion selection**: the `.gui-pet-trigger` row shows the current companion thumbnail + name + chevron (flips when expanded); preview thumbnails use `zoom: 0.55`. Delete buttons appear only on imported cards, revealed on hover, `stopPropagation` prevents selection.
- **Render shape** (PetSprite.tsx + gui.css): `.gui-petdex-sprite` must be `image-rendering: pixelated`; frame loop + one transform animation per mood (`PETDEX_MOOD_ANIM`: rest 2.4s+breathe, working 1.16s+work bob, hover 1.44s+lift, dragging 0.96s+wiggle), both attached to the same element simultaneously (different properties don't conflict); mood row mapping rest=0/hover=1/dragging=2/error=5/waiting=6/working=7/analyzing=8.
- **Size normalization & scaling** (2026-08-06): all Petdex companions render normalized to **rest-row content height** (`PET_CONTENT_TARGET_H = 100`, k = 100/contentH) — imported packs vary in frame size (Doraemon 192×208 vs built-in 96×104); without normalization everything doubles in size and shadows overflow the canvas. contentH sources: built-ins use the measured-hardcoded `BUILTIN_PETDEX.contentH`; imported packs measure at import via `measurePetdex()`; legacy packs are backfilled automatically by `migratePetdexContent()` (at usePet mount). **Companion size slider** (Settings → Companion, `musepi-gui-pet-scale` 60–150%, default 100) multiplies on top of normalization; the desktop pet window receives scale via the main-window bridge `pet-activity {scale}` (cross-window localStorage unreliable), the in-composer pet reads the pref directly.
- **Window & shadow bounds**: pet window 320×290 (`PET_WINDOW_SIZE`), companion anchored `bottom: 52px` (frame [134,238]; sitting slightly high reads more centered) — **drop-shadows need ample radiation room** (rest 0 6px 16px ≈22px → 30px headroom; hover 0 10px 22px ≈32px + bump ≈2px → 18px headroom), otherwise the shadow hard-clips at the window's bottom edge (visually severed). **Bubbles/panel moved out of the pet window** (dual windows, below) — bubble-stack rules like `bottom: 174px` inside the pet window are single-window-era leftovers, kept only as legacy.
- **Bubble stack (dual windows, updated 2026-08-11)**: max 5 (`MAX_VISIBLE_BUBBLES`), newest on top, typewriter reveal one by one, × close, 8s auto-dismiss; **iOS Notification-Center collapsed shape** — collapsed shows only the newest + an "N more" chip; clicking expands the full list; collapse↔expand is a **width+height dual-axis morph** (320ms overshoot; `stackMorph` transitions width+height together, the window follows frame-by-frame via RO reports); dark rounded bubbles + border + light shadow. Bubbles render in the **bubble window** (`.pet-bubble-window`, content-driven sizing), no longer floating inside the pet window. **The bubble window is a per-pixel transparent window on all platforms** (since 2026-08-22) — the window body has no background color; only the cards themselves carry frosted-glass faces (hand-drawn tint + highlight + hairline); macOS vibrancy was tried but painted the entire window rectangle as a glass rectangle (a background ring peeking outside the rounded corners), abandoned.
- **Interaction panel (dual windows, rewritten 2026-08-11)**: clicking the pet toggles the panel inside the bubble window (the single-window era resized the pet window 320×290→340×540, abandoned). Panel = live task summary (working/idle + current tool + latest message ≤80 chars, 1s throttled push + instant snapshot on open) + approval cards (question bubbles carrying requestId → approve/deny via main-window `tool.approve/deny`) + quick reply (with a session steer/followUp; without, createSession with the first message, same semantics as the welcome page) + session title + ↗ open-main-window button + tabs (messages/recent sessions). The panel is fixed 316px wide, flow + margin centered (not absolute, `transform: none`); **entry gating (width first, 2026-08-11)**: the panel mounts at `opacity:0` without animating, waiting for the bubble window to resize to panel dimensions (`resize` event, 120ms fallback), then plays the `pet-panel--in` entrance — otherwise the 316px panel gets clipped inside the bubble-stack-width (~140px) window, "right half shows first, left half pops in later" (measured root cause); entry uses **flat keyframes with no horizontal movement** (`pet-panel-in-flat`: translateY(10px)+scale(0.98)+blur only) — the legacy keyframes' baked `translateX(-50%)` (absolute-centering leftover) shifted the panel half a width left under flow layout, causing the same "right-half-first". Panel i18n (locale pushed via `pet-activity {locale}`). Bubbles hide while the panel is open.

## 5. i18n & sound effects

- **i18n**: copy keys double as English fallbacks; zh translations split by domain under `desktop-web/src/i18n/zh-CN/<domain>.ts` (en side `en-US/` compile-level parity, see the §i18n contract and `docs/i18n.md`); `t()` called at render points (module-level consts don't follow locale switches). Number/time formatting explicitly passes locale; relying on browser defaults is forbidden.
- **Sound effects (event-driven overhaul 2026-08-07)**: cuelume (Web Audio synthesis, 14 recipes); routed uniformly through `gui/src/lib/sfx.ts`:
  - **Per-event configuration** (opencode per-category sounds parity): 10 events (`SFX_EVENTS`) — send message / first message / message completed (agent_end, sounds only when stopReason is neither aborted nor error) / approval request / approval granted / approval denied / session switched / turn stopped / tool result / error; each event can swap voices (`soundFor`/`setSoundFor`, persisted `musepi-gui-sfx:<event>`, invalid values fall back to `DEFAULT_SFX`).
  - **Call sites use `sfxFor(event)`** (app/Composer/WelcomeComposer/ApprovalCard/session-store); direct `sfx(name)` remains for one-offs/previews; the master switch `musepi-gui-sound` gates everything.
  - **Message-completed hooks agent_end, not turn_end** (corrected 2026-08-07): turn_end fires on every model call (chained sounds on multi-tool tasks) and stacks with the stop sound on abort — agent_end fires once per run.
  - **Settings UI**: notifications & sound tab = one row per event (name + trigger description + default voice) + voice dropdown + ▶ preview + a 14-swatch palette grid (`ALL_SOUNDS`/`WIRED_SOUNDS`/`previewSound`); when wiring a new trigger point, update WIRED_SOUNDS and the Chinese usage copy in sync.
  - **Verification gotcha**: cuelume honors the `navigator.userActivation` browser policy gate — CDP synthetic input produces no real activation, so playback can't be automated (config read/persist is testable; playback needs real clicks).

## 5b. Animation & library choices (evaluated 2026-08-07)

- **Principle: CSS first + hand-rolled hooks**. The entire existing motion system is handwritten CSS/JS (Reveal/HeightMorph/useCollapse, BorderBeam, DotMatrixMark, ThinkingOrbs, the KITT sweep, two-phase float layers, companion frame animation) — desktop GUI motion needs are "refined, restrained UI feedback"; CSS transition/keyframes suffice at zero runtime cost and natively respect `prefers-reduced-motion` (the `gui-motion-off` preference).
- **Third-party in use**: `cuelume` (sound), `lucide-react`/`lucide` (icons), `morphicons` (composer send/stop icon morph), `beautiful-mermaid` (desktop-web Mermaid rendering), `@xterm/xterm` (terminal), `pdfjs-dist` (PDF). **motion (formerly Framer Motion) was a dependency with zero references — removed** (2026-08-07).
- **GSAP evaluation (not adopted)**: GSAP 3 (now entirely free, all plugins included) is the industry standard for imperative timelines/ScrollTrigger/SplitText/MotionPath — but its strengths (marketing-page scroll scenes, per-character text effects, complex multi-step orchestration) aren't on the desktop GUI core path; adopting it would establish a new animation paradigm (timelines/interpolation) running parallel to the existing CSS track. **Kept as a candidate**: re-evaluate if we later do welcome-page brand-text per-character animation (SplitText-class) or complex transition orchestration.
- **Icon transitions = morphicons, hand-drawn crossfades forbidden (lesson 2026-08-14)**: any "icon A → icon B" transition (theme/accent full-screen masks, button state swaps, status cards) uses **`morphicons`** exclusively (`morphicons/react`'s `MorphIcon`, or in pure-DOM contexts `morphicons/element`'s `<morph-icon>` + `set()`/`morphTo(target, "snappy")`) — **shape morphing via Procrustes optimal rotation + polar interpolation + spring physics**. **Forbidden**: stacking two SVGs and faking a morph with opacity/rotate crossfade (misused on the 2026-08-14 theme mask; the user explicitly demanded the morphicons look; Composer/Transcript/onboarding steps are already all morphicons — the mask must match).
- **Store change notifications must emit inside the swap callback (lesson 2026-08-14)**: `setThemePreference`/`setAccentPreference` execute the switch delayed (340ms) via `withColorTransition` — **`emit()`/`emitAccent()` must live inside `withColorTransition(fn)`'s `fn`** (after preference/accent have been updated), not after the call: emitting synchronously outside broadcasts the **old value**, so `useSyncExternalStore` subscribers (settings segmented/palette buttons) read the old preference — button state **lags one click** (you clicked light, the theme switched, the button still says "follow system"; the next click displays the previous choice).
- **React Bits evaluation (source reference, no package installs)**: 140+ open-source animation components (MIT family, github.com/DavidHDev/react-bits). Consistent with our existing "borrow patterns from reference repos" workflow — candidates (copy on demand): `BlurText`/`ShinyText` (welcome brand text), `CountUp` (rolling numbers: status-bar tokens/stats), `SpotlightCard` (settings-card hover glow), `Aurora`/`Particles` (welcome-background alternatives; the existing DotMatrixMark takes priority). BorderBeam we already have self-built (referencing opencode); reactbits' version works for parameter comparison.

## 5c. Reference resources (design ↔ implementation mapping)

| Resource | Used for | Notes |
|---|---|---|
| opencode (`../opencode` dev) | Session tree/header/server instances/settings v2 shape | Its three sound categories (agent/permissions/errors) were the blueprint for per-event sound configuration |
| openchamber (`../openchamber` v1.18.1) | Three-column shell/settings layout/notification templates/remote instances (SSH+port forwarding) | Primary reference for the settings page shape; message partial-selection float/save-as-image/new-session-from-answer (partial selection + save-image landed 2026-08-07; fork modal not done) |
| bitfun (`../bitfun` main) | Companion (Petdex/frame animation/mood)/SSH remote workspaces/approvals | Primary reference for desktop-pet visuals & interaction |
| clawd-on-desk (`/tmp/clawd-on-desk`, rullerzhou-afk, AGPL) | Pet floater layout/permission bubbles/status indication | **Content-driven window design reference** (analyzed 2026-08-11): fixed width + adaptive height (window width constant → no anchor clipping); bubble-stack placement priority below→side (whichever has room, right preferred)→corner; entry slides in from the pet's side (translateX 60→0 spring). The "width first" idea (size stable before animating) landed as panel entry gating |
| kimi-code (`../kimi-code`) | Icon card-in-card 80.5%/dot-matrix brand background/provider grid | Dock visual alignment baseline |
| ZCode | Connect wizard 4 steps (SSH/Docker) | ConnectDialog step skeleton |
| `../ui-references/aicss/` | AI interface CSS recipes (thinking/code-block/comparison-table…) | Message-stream detail comparison |
| `../ui-references/cuelume/` `border-beam/` `thinking-orbs/` | Sound/beams/thinking-orbs references | Inspiration sources for self-built components |
| reactbits.dev (since 2026-08-07) | Animation-component source reference | Landed: CountUp/BlurText/ShinyText/SpotlightCard (all zero-dependency variants); candidate: glyph particle background (needs WebGL, not adopted) |

## 5d. Design gaps & follow-ups (logged 2026-08-07)

| Gap | Current state | Completion design draft | Status |
|---|---|---|---|
| **Plan-approval 3-option GUI support** | The GUI ApprovalCard only approves/denies (tool.approve/deny); the TUI has approve-and-run (new session) / approve-and-compact-context / approve-and-keep-context — those ride the `xd://propose` device flow → `handlePlanApproval` → in-process `session.prompt`, a TUI-only mechanism; the daemon's approval-request payload carries only `{requestId, tool}` with no plan metadata, and the GUI has no matching RPC | ① daemon `approval-request` attaches plan context for plan tools (planFilePath/title/planExists, mirroring the TUI propose dispatch shape); ② add an approve mode parameter (tool.approve extended with `mode: "run"\|"compact"\|"keep"`); ③ GUI ApprovalCard detects `tool === plan` and shows 3 options, defaulting to keep-context; ④ pet approval cards share the same source | Logged, unscheduled |
| **New-session-from-answer modal** | Fork exists (`session.forkAt`, non-destructive branching); openchamber uses a config modal (model/thinking level/agent/instructions/worktree/goal run) | Lightweight modal reusing ModelSelector/ThinkingSelector, defaults = current session | Logged, unscheduled (optional) |
| **Aurora/Particles welcome background** | Not adopted (WebGL/persistent rAF violates CSS-first; DotMatrixMark is already the brand visual) | If users want a "vibe change", offer a CSS gradient-animation alternative or a toggle | Alternative, not doing |

## 5f. Design asset extension points (pluggable, finalized 2026-08-16)

Built-in design assets are organized around **tokens + an override mechanism**; third-party/theme/motion packs extend by overriding tokens, never by forking components.

### Motion parameter table (gui.css `:root`)

| Token | Default | Purpose | How to override |
|---|---|---|---|
| `--spring` / `--spring-snappy` / `--spring-bouncy` | spring(300,30)/(400,34)/(320,16) linear() | All UI morph easings | Inject CSS overriding the `:root` variables |
| `--gui-motion-menu-in/out` | 130ms | Float-layer menu enter/exit | Same |
| `--gui-motion-chip` | 180ms | Chips/small elements | Same |
| `--gui-motion-fade-in/out` | 160ms | Fades | Same |
| `--gui-motion-height` / `-max` | 240ms / 480ms | Height morphs (HeightMorph, delta/6 capped) | Same |
| `--gui-motion-blur` | 280ms | Blur-type effects (BlurText) | Same |
| `--gui-motion-roll` | 240ms | Roll/page-flip type | Same |
| `--gui-motion-slide-y` / `-lg` | 6px / 10px | Translation distances | Same |
| `--gui-motion-blur-amt` / `-lg` | 8px / 24px | Blur amounts | Same |
| `--gui-ease-out` | cubic-bezier(0.22,1,0.36,1) | Height/morph easing alias | Same |

**Override mechanism**: keyframes and transitions always read tokens (`var(--gui-motion-*)`); a motion pack/theme injects a stylesheet (later-loaded wins) overriding the variables for a whole-skin swap — no keyframe edits needed. `gui-motion-off` (prefers-reduced-motion) disables globally.

### Component parameterization (customizable without forking)

- `BlurText` (`stepMs`/className), `ShinyText` (`speed`/`spread`/`shineColor`), `CountUp` (`duration`/`format`), `SlidingNumber` (`padStart`/`decimals`), `TextMorph` (`stepMs`/`durationMs`), `GuiSelect` (options/className), `SpotlightCard` (`spotlightColor`)
- Glass layers: `--gui-glass-alpha` (transparency slider) + derived `--gui-glass-overlay`; `.gui-main`/floating-card blur reads platform classes (`[data-platform="win32"]` disables the underlying blur, see the performance section)

### Intake contract for new motion components

1. Durations/offsets/blur amounts **must read tokens**; bare values forbidden (bare values = uncapturable by theme/motion packs).
2. Enter animations go two-phase (`useTwoPhaseEnter`, or `opacity:0` + next-frame `--entered`) to prevent Chromium skipping backdrop sampling.
3. Respect `gui-motion-off` (disabled state appears directly, no animation).
4. Reuse the `gui-menu-in/out` / `gui-fade-in/out` keyframes or build same-parameter ones (named `gui-<name>-in/out`).

## 5e. Dialogs, keyboard & selectors (finalized 2026-08-14)

### Dialog animation & keyboard priority

- **DialogFrame contract**: hosts render **unconditionally** + driven by `open` (`{x && <DialogFrame/>}` conditional mounting loses the exit animation — the 180ms closing phase); prompt/confirm (`lib/prompt-dialog.tsx`) use the same two-phase enter + closing; `finish()` resolves the promise only after the exit animation completes.
- **Modals hold the keyboard**: while a DialogFrame is open, Esc is listened for on `document` in the **capture phase** → onClose (wins over handlers beneath; the composer stops swallowing Enter-to-send); focus moves into the dialog's first focusable element and restores on close; confirm boxes: Enter = confirm (focus lands on the confirm button); onboarding panels: Enter = next step (a focused input keeps its own Enter), Esc = previous step/close from the first step, panel focuses itself on open; announcement panels: Esc = close.
- **Compact dialogs**: small-content confirmation boxes use `gui-dialog--confirm` (auto size + max-width 380 + 22×24 padding) — the base `.gui-dialog` is a 600×420 settings box; desc + two buttons inside reads broken (board delete / new project / scheduled delete all tripped on this).
- **Hooks iron rule**: all hook declarations must precede **any early return** (hooks after `if (!open) return null` crash with "Rendered more hooks than during the previous render" when open toggles — AnnouncementOverlay regression, observed live).

### Floating positioning standard (finalized 2026-08-25, benchmarked against openchamber v1.20.0)

**Single-entry iron rule**: all popup float layers (menus/dropdowns/context menus/color pickers/attachment menus) must route through `components/Pop.tsx` → `lib/use-floating-menu.tsx` (the only implementation: portal to React root + global exclusivity + `gui-menu-in/out` animation). **Hand-written `position: fixed/absolute` popup floats are forbidden** — openchamber uses @base-ui/react (floating-ui popper engine internally); we hand-write the same semantics without the dependency:

- **Collision semantics (flip + shift)**: vertical = flip upward when it doesn't fit below the anchor (or more room above) (flip); horizontal = shift wholly into the viewport on left/right overflow (clamped to `[8, innerWidth − menuW − 8]`, shift never flips) — right-aligned menus pin their right edge to the anchor's right edge and nudge right to survive tight spaces instead of being truncated by window edges.
- **Two-phase measurement**: at first open the menu isn't mounted yet → estimate position at 260×300 → re-measure `offsetWidth/Height` once on the mount frame for exact repositioning (`measuredRef` guards loops); right-aligned menus therefore still land precisely on the anchor.
- **Vertical flip accounts for height**: `flipUpForBottomOverflow = r.bottom + 6 + menuH > innerHeight − 8` — tall menus hanging under low anchors flip up too; bottom overflow is never allowed.
- **Persistent floating cards (non-popups)**: fixed corner cards like the btw ask-card/badge cards must clamp themselves to the viewport (`maxWidth: calc(100vw − 48px)` + `max-height: min(60vh,520px)` + body scrolling); bare boundary-less fixed is forbidden.
- **Keyboard**: floating cards/menus must wire their Esc promises (e.g. the btw card's "Esc closes" hint ↔ onKeyDown Escape); hints and behavior must not diverge.

### Model selector (provider compound key)

- **Model identity = `provider/id`**, never the bare id — two providers can expose the same bare id (opencode-go / opencode-zen both ship `deepseek-v4-flash`): favorites (`musepi-gui-fav-models`), the DEFAULT pin (`modelRoles.default`), selection state, and role-row assignment are all keyed by `provider/id` (legacy bare-id entries match compatibly and are cleaned up on toggle); `session.setModel` carries `provider` so the daemon resolves precisely (daemon-side provider-scoped lookup added).
- **Composer/welcome** model menu rows = model name + provider badge + favorite star + **DEFAULT pin** (target icon, filled for the current default) — tapping the pin writes `modelRoles.default` (same key as the settings-page DEFAULT role, consistent on both sides); menu min-width 260 / max-width 344.
- **Single-capsule merge (dsh single-trigger parity)**: the two selectors merge into one capsule (`ModelThinkingCapsule`), the left segment showing model brand icon + model name, the right segment a thinking icon (brain) + level text; clicks open independent menus (model search/favorites/pin menu + thinking-level ladder). When the composer frame lacks width, the capsule auto-collapses to icons only (text fades via `@container` queries + `--gui-motion-chip` 180ms transition; `gui-motion-off` switches instantly; thresholds: 480px the thinking text yields first, 380px the model text and divider follow, both measured against `.gui-composer-frame`'s inline size). A thin vertical divider separates segments; each segment highlights on hover via a `--spring` 150ms transition, the highlight shape hugging the capsule (first segment rounds left, last rounds right, a lone segment rounds fully) — consistent with standalone `.gui-model-btn` hover; the capsule is `flex-shrink: 0` so crowded button rows don't squeeze it (collapse is driven solely by frame width).
- **Model brand icons (`@lobehub/icons`, MIT)**: the capsule's left segment and menu rows render brand logos per `provider` (Mono monochrome variants, `size 14`; `model-brand-icon.tsx` inlines a 24-entry provider→icon map + modelId-substring fallback); unknown providers fall back to oc-icons `ai-agent`; deep imports (`@lobehub/icons/es/<Brand>`) guarantee tree-shaking bundles only the brands in use.
- **Role thinking levels are dynamic**: role-row thinking selects render `resolvedRoleModels[role].efforts` (daemon `getSupportedEfforts`; empty for models without thinking support) — never a fixed seven levels; every role-model change routes through `applyRoleModels` (re-fetch resolvedRoleModels after a successful set) so the "auto-select" derived rows and the level list refresh immediately.

### Fetch available models (custom provider form)

- **Config surface shape**: adding a custom provider is a **properly designed dialog** (DialogFrame, `gui-dialog--settings`), opened from "Add custom provider" inside the "Custom Providers" tab — **not a standalone tab** (user feedback: adding a custom provider belongs in a designed dialog; the old add-tab was removed). Once Base URL and API protocol (openai-completions / openai-responses) are set, press "Fetch available models" — **only OpenAI-compatible protocols can be queried**: anthropic-messages / google-generative-ai expose no readable model list; the error guides manual entry (same philosophy as DSH discover-models: a one-time query against the draft during configuration, writes nothing anywhere). **The onboarding (OnboardingOverlay ProviderSetup) custom form offers the same "Fetch available models"** — no hand-typing model ids.
- **Query-is-draft**: RPC params = the form's current values (baseUrl/api/apiKey/provider name); **the apiKey serves this one query only — the daemon never persists it**; without a baseUrl the button is disabled with the hover hint "Fill in Base URL first".
- **Candidates dialog** (DialogFrame, permanently mounted, driven by `candidates !== null`, nested inside the config dialog): checkbox list (id + name, endpoint order) + select-all/deselect-all ("Deselect all" while all selected) + "Add selected"; adopted candidates merge into the form's model list (`adopted`), each row deletable; errors (protocol unsupported/401/404/endpoint has no models) display inline beneath the button, never a dialog.
- **Submit semantics**: the `models.add` models array = adopted list merged with the manually entered single entry (modelId/modelName/compactionModel); validation changed to "provider name, Base URL, and at least one model required" — the single entry and the adopted list must satisfy at least one.
- **Success feedback**: saving closes the dialog and returns to the "Custom Providers" tab, briefly showing "Provider added" at the add button's original spot (dismisses after 2.5s); onboarding shows the added card.

### Board canvas & group glow

- **Canvas adaptation**: `.gui-board-surface` layout width is fixed at BASE_W (1092), fitted to the window via `transform: scale(containerWidth/1092)`; the effect depends on `activeId` (home-view ref null at mount → deps `[]` left the scale stuck at 1 forever, the 1092 layout overflowing clipped — fixed); `overflow-x: hidden` + `overflow-y: auto` (transforms don't change layout; narrow windows would otherwise show horizontal-scrollbar artifacts).
- **ChromaGroup glow** (reactbits ChromaGrid parity, `components/ChromaGroup.tsx`): the container's pointermove writes `--cg-x/--cg-y` (zero re-render); `.gui-chroma-glow` is pure-CSS three-layer RGB-offset radial gradients + `mix-blend-mode: screen` + hover fade-in + hidden under `gui-motion-off` — **one shared glow lights every card in the group** (board canvas + model provider grid); companion presets/desktop-pet market **do not apply** (a whole-background bloom over dense scrolling small-card grids + a fixed inset-0 getting clipped in scroll containers — reverted after user testing).

### Settings search & new project

- **Settings search**: the sidebar search filters at **config-item level** (section label or `SECTION_SEARCH_TERMS` keyword hits, bilingual); matching rows in the content area get `.gui-settings-match` (accent 13% fill + 24% outline) highlighted imperatively + the first match `scrollIntoView`s (one scroll per new query/section switch; continued typing doesn't re-scroll, avoiding jitter); `aria-hidden/inert` collapsed rows are skipped.
- **New blank project** (kimiwork parity): sidebar projects tab "Add project/remote" menu + composer project menu → DialogFrame (name + parent-path native picker) → daemon `fs.mkdir { cwd: parentPath, path: name }` → opens + `musepi-gui-project-added`; the save button enables only when both fields are filled, failures shown inline. Fields = compact label-above-control layout (the `gui-settings-field` two-column grid squeezes inputs down to 76px inside compact dialogs).

## 5g. Recent landed features (2026-08-24 → 2026-08-26)

Design decisions and patterns for work landed after the early sections; implementation contracts and pitfalls in `docs/gui-implementation.md` §18, per-feature specs in the referenced docs.

- **Right-panel redesign Phase 1–2** (`docs/gui-right-panel-redesign.md`): the right ContextPanel moved to a **grouped 44px icon rail** — the surface registry (`surfaces/registry.ts`) gains a `group` field (primary/secondary/tertiary); high-frequency icons pinned, secondary folded into the rail "…" overflow; width clamp widened to **260–1200px** (+ a maximize state); **⌘E** toggles the panel, **⌘⇧E** is focus mode (input fills the screen); closing animates as a **220ms width collapse** (not a proma overlay). The second TabBar row and multi-instance tabs were **architecturally vetoed** ("the rail is the single navigation axis"); Phase 3 surface-level refinement continues.
- **Board / widget canvas** (`docs/board-dashboard.md`, `docs/widget-design-system.md`): `BoardPage` + a whitelist `WidgetRegistry` (18 widgets) — one registry renders the board grid, transcript inline cards, and the pin window, so a widget is written once and reused in three places. Canvas is fixed-layout (BASE_W 1092) scaled to the window (`transform: scale(window/1092)`), `overflow-x:hidden` + `overflow-y:auto`; ChromaGroup glow (`components/ChromaGroup.tsx`) lights the whole group with one shared RGB-offset radial gradient (`mix-blend-mode: screen`, hidden under `gui-motion-off`).
- **Composer & status-line settings** (daemon schema, surfaced in the settings "Interaction"/"Shell" tabs, §4 TUI-settings sync): `composer.shape` (string, default `"box"`) selects the composer style; `statusLine.contextLine` (enum `CONTEXT_LINE_MODE_VALUES`, default `"embedded"`) drives the status-line gauge — `off` (solid accent), `percentage` (used portion in accent, remainder border), `annotated`/`embedded` (percentage + window labels).
- **Frosted glass on win32 (2026-08-26)**: `html:root, html:root body { background: transparent }` is now explicit — `html` was the overlooked layer, carrying opaque `var(--bg)` and blocking the DWM Acrylic (Windows) / vibrancy (macOS) from showing through the translucent scrims. `[data-platform="win32"] .gui-main` disables the page `backdrop-filter` (the blur comes from the window material, GPU-cheaper); `[data-platform="win32"][data-theme="light"]` takes a thin 22–58% scrim (Acrylic is a bright material; the default 58–76% light scrim washed out the frost).
- **OTA update UI**: "Check for updates" (§4) upgraded from "Go to download" (openExternal) to **download → progress → restart** via electron-updater (v0.4.4) — `docs/ota-update-design.md`; the toast now shows percent + "Restart now". Notes channels unchanged: toast reads `update-manifest.json.notes`, "What's new" reads `CHANGELOG.musepi.md`.
- **Bilingual docs convention** (`docs/i18n/README.md`): every in-scope `docs/**` markdown pairs `foo.md` + `foo.zh-CN.md` + `foo.i18n.yaml` (blob-hash consistency record); language switcher after the heading (`English | [中文](foo.zh-CN.md)` / `[English](foo.md) | 中文`); enforced by `bun run verify-translation-pairing` (`--write` records hashes; strict for named pairs); the two languages hold equal authority and structure mirrors across the pair.

## 5h. Absorption round additions (2026-08-29)

- **Floating status cards** (chat top-right, ZCode 悬浮卡 parity): compact frosted launchers (git / agents / todo), 248px wide, `gui-menu-in` entrance; collapse to a slim pill (persisted `musepi-gui-status-cards`); the stack disappears entirely when empty — never decorate an idle session. Click-through opens the owning surface; cards never duplicate surface UI beyond the branch switcher.
- **Reward ticket overlay** (campaign what's-new variant): starfield sky + floating 3D-tilt ticket, layered transforms (tilt / float / entrance on separate elements — one animation owner per `transform`); CountUp on the amount; all motion dies under `gui-motion-off`/`prefers-reduced-motion`. Claim feedback couples to the completion sfx.
- **Maximized right panel** is modal: a scrim (z-840, starts below the 48px header, click restores) backs the z-850 panel — floating fixed layers (float scrollbar, tooltips) must tier BELOW the scrim or they read as panel content.
- **Git graph table** (commits tab): lane-solved SVG rail + ref badges (HEAD=home/accent, local=branch, remote=cloud/muted, tag=amber) + date/author/hash columns; hash click copies with 1.2s feedback. Session-scoped i18n keys live in the settings domain (`subject/date/author/commit column`, `load more`).

## 6. Brand icon (App Icon, redesigned 2026-08-06)

- **Source file**: `packages/gui/build/icon.svg` (1024×1024 canvas, dot-matrix coordinates generated by a Python script — 23×23 grid). Build artifacts: `build/icon.png` (1024×1024) + `build/icon.icns` (iconutil 10-tier iconset).
- **Design language**: **dot-matrix style** — a 23×23 dot grid (24px pitch), faint background dots (fg 9% alpha, `r=4.2`) + lit π shape (warm-white fg `#ece8e9`, `r=7.6`); π = a 3-dot-thick beam (rows 3-5, cols 5-18) + 3-column-wide legs (rows 6-19). Background = theme-dark micro-gradient (`#242128 → #1b191f`, the `--bg` family). **Palette uses theme colors only (fg + bg surfaces), zero accents/gradients** — visually kin to WelcomeComposer's `DotMatrixMark` (the dot-matrix brand background), replacing the old "dark base + pink-purple-cyan gradient π" (gaudy, detached from the theme).
- **Card-in-card layout (measured against kimi, 2026-08-06)**: the icon = a dark card occupying **80.5% of the tile (824/1024, symmetric 100px transparent margins all around)** + superellipse n=5 card corners — exactly matching Kimi desktop (`/Applications/Kimi.app`'s icon.icns measures an alpha bbox x100-923, 80.5%). **Root cause of "our Dock icon is bigger than kimi's"**: we previously went full-bleed 100% while kimi is card-in-card 80.5%; the 92%-inset variant still exceeded 80.5% ("always a bit bigger"). Full-bleed 1024 + system mask is the Apple HIG baseline, but **visual unity with neighboring apps outranks abstract HIG compliance** — kimi genuinely ships card-in-card, and we want to sit side-by-side at equal size.
- **Three synced copies**: `build/icon.png` (packaging source) + `build/icon-dock.png` (dev Dock setIcon) + `src/vendor/logo.png` (splash/embedded, 512 with same params); the packaged icns carries the same 80.5% card margins (without repackaging, sync the bundle icns manually).
- **Change workflow**: adjust matrix params (grid/π shape/dot radii/palette) → render a 1024 PNG via Chrome headless → apply 80.5% card-in-card + superellipse corner cropping → regenerate the iconset + `iconutil -c icns` → replace build/icon.png + icon.icns + icon-dock.png + src/vendor/logo.png (+ the release bundle's icns) → **after manually syncing the bundle icns you must re-sign** (`codesign --force --deep --sign - release/mac-arm64/MusePi.app` — modifying resources after signing voids the signature, CSDN 4.3 pitfall) → `bun run pack:dir` to repackage (dev-mode Dock icons go through `app.dock.setIcon(build/icon-dock.png)`; packaged builds use the bundle icns — **swapping only the pngs without repackaging leaves the packaged Dock icon stale**).
