MusePi GUI Design Specification
| English | 中文 |
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 indocs/i18n.md. - Typed keys:
TranslationKey = keyof typeof zhCN(zh-CN barrel mergedas const) — both key and params oft()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 explicitas TranslationKeyassertion — 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 andemit()s an immediate re-render (fully new locales supported); plugin-owned keys go throughtLoose(key, params)(coretaccepts onlyTranslationKey). 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 thancontext · {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 constscenarios (PREFERENCE_LABEL, KIND_TRANSITION, SCALAR_ARGS, TIP_KEYS, SOUND_USAGE_KEYS) useas const satisfies Record<…, TranslationKey>or explicitPartial<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-shellis flex ROW — every full-width pane (SettingsView etc.) must carryflex:1; min-width:0, otherwise it collapses to content width. - Settings panel: full-window workspace replacement, left nav + right content (
gui-settings-contentfixed-height scroll container, centered column viawidth: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-centerflex: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, thumbtext-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):
useScrollShadowhook + CSSmask-imagelinear-gradient fade-out at edges — transparent→solid 20–24px soft band, mounted only when content overflows and scrolls away from the edge (driven bydata-top-scroll/data-bottom-scrollattributes). Covered:.gui-transcript(chat) /.gui-sessions-list(session list) /.gui-settings-nav-scroll|content(settings) /.gui-model-list/.gui-notes-editor/.pet-bubbles. Generic carrierFadeScroll(components/FadeScroll.tsx+.gui-fade-scrollrule, optionalonClickpassthrough) 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’sTurn Nlabel 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-in120ms): 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--dim0.35 + saturate .6 /.traj-turn-group--dim0.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/durationtok/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; undergui-motion-offhover 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-densityis a unitless factor (e.g.1/0.85); CSS usescalc(32px * var(--gui-density, 1)). - Radii:
--radius-lgladder etc.; cards uniformlyborder: 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-vibrancyIPC + CSS--gui-glass-overlayopacity; 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-gridis 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:contentsis 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+ optionalonClassapplied 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.schemaRPC, 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 viasettings.set, rolled back on failure. Conditional gating CONDITIONS aligned with TUI settings-defs (11 entries;hasImageProtocolalways 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 viasettings.get(the daemon does not backfill schema defaults for unsetsettings.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 themusepi-gui-default-thinkinglocalStorage mirror; WelcomeComposer preselection moved to a boot snapshot: prefermodelRoles.default’s:levelsuffix (off→thinking disabled), else fall back to the configureddefaultThinkingLevel(auto allowed), medium when unconfigured; boot also strips the suffix for model preselection; ③ schema rows withoptions:"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 readsupdate-manifest.json.notes(OTA channel); the “What’s new” dialog readsCHANGELOG.musepi.md— both must be filled at release. Implementation details ingui-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-accentsession 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 timerformatPauseElapsedminutes:seconds, anchored viadata-paused-atso pausedAt isn’t reset by remounts); the header pause button sendsdaemon.pause/pauseRelease. Session-level pause = a banner inside ChatView + live hold timer (also anchored to pausedAt); the header session pause button sendssession.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 reportsworkingfalse (list/tree paused badges share the same source). Resume = re-fetchpauseStatus+ the subscription-streampause-state/global-pause-stateenvelopes driving updates. - Input boxes: Composer/WelcomeComposer share
autosize(data-focused aware); the focus-mode morph must useuseLayoutEffect(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
ContextMenucomponent (.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 thecolorattribute +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,
ContextMenuandPopfirst land with an opacity-0 no-animation class, then on the next frame (double rAF hop) receive the--entered/--pendingclass startinggui-menu-in—useFloatingMenualready 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--enteredsuffix) — 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 + playedgui-fade-instraight 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; internalvisible/closingstate; exit playsgui-menu-out130ms + backdrop fade); the selection toolbar gained proper enter/exit (gui-select-pop-in/out, keyframes must bake in the inlinetranslateX(-50%)— transform animations override static transforms); base rules unified toopacity:0+--entered{opacity:1;animation:…}sogui-motion-offdegrades 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}→ onlygui-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--enteredclass matches no selector → forever opacity:0 invisible. Verified via CDPgetBoundingClientRect: backdropposition: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”. Cardflex column+ gridflex:1fills height; left-column content bottom-aligned (dotsmargin-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 usejustify-content:centerto vertically center content in the fixed box). - Single-layer ownership of floating card faces (2026-08-15, ColorPicker double-paint lesson):
useFloatingMenu’sclassNamelands 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+ weight600+ 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; thefontSizeprop can override (settings preview passes 96). The mark positions attop: 17%keeping the text’s lower edge (≈235px) above the brand-line top (≈242px). IntersectionObserver pauses offscreen. - Customization & preview (Settings → General):
musepi-gui-dotmatrixtoggle +musepi-gui-dotmatrix-textcustom text (default MusePi, ≤24 chars, welcome page and preview linked live, eventmusepi-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-markinset:0 so it dodges this).
Companion (Agent Companion, BitFun parity, 2026-08-06)
- Presets: 10 built-in Petdex presets (
BUILTIN_PETDEXinsrc/lib/pet.ts, sprites inpublic/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__checkneeds explicit width/height — the Icon component has no default size; omitting it renders 219px). - Companion selection: the
.gui-pet-triggerrow shows the current companion thumbnail + name + chevron (flips when expanded); preview thumbnails usezoom: 0.55. Delete buttons appear only on imported cards, revealed on hover,stopPropagationprevents selection. - Render shape (PetSprite.tsx + gui.css):
.gui-petdex-spritemust beimage-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-hardcodedBUILTIN_PETDEX.contentH; imported packs measure at import viameasurePetdex(); legacy packs are backfilled automatically bymigratePetdexContent()(at usePet mount). Companion size slider (Settings → Companion,musepi-gui-pet-scale60–150%, default 100) multiplies on top of normalization; the desktop pet window receives scale via the main-window bridgepet-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 anchoredbottom: 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 likebottom: 174pxinside 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;stackMorphtransitions 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 atopacity:0without animating, waiting for the bubble window to resize to panel dimensions (resizeevent, 120ms fallback), then plays thepet-panel--inentrance — 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’ bakedtranslateX(-50%)(absolute-centering leftover) shifted the panel half a width left under flow layout, causing the same “right-half-first”. Panel i18n (locale pushed viapet-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 sideen-US/compile-level parity, see the §i18n contract anddocs/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, persistedmusepi-gui-sfx:<event>, invalid values fall back toDEFAULT_SFX). - Call sites use
sfxFor(event)(app/Composer/WelcomeComposer/ApprovalCard/session-store); directsfx(name)remains for one-offs/previews; the master switchmusepi-gui-soundgates 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.userActivationbrowser policy gate — CDP synthetic input produces no real activation, so playback can’t be automated (config read/persist is testable; playback needs real clicks).
- Per-event configuration (opencode per-category sounds parity): 10 events (
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(thegui-motion-offpreference). - 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
morphiconsexclusively (morphicons/react’sMorphIcon, or in pure-DOM contextsmorphicons/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/setAccentPreferenceexecute the switch delayed (340ms) viawithColorTransition—emit()/emitAccent()must live insidewithColorTransition(fn)’sfn(after preference/accent have been updated), not after the call: emitting synchronously outside broadcasts the old value, souseSyncExternalStoresubscribers (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
- Durations/offsets/blur amounts must read tokens; bare values forbidden (bare values = uncapturable by theme/motion packs).
- Enter animations go two-phase (
useTwoPhaseEnter, oropacity:0+ next-frame--entered) to prevent Chromium skipping backdrop sampling. - Respect
gui-motion-off(disabled state appears directly, no animation). - Reuse the
gui-menu-in/out/gui-fade-in/outkeyframes or build same-parameter ones (namedgui-<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
documentin 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-dialogis 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 nullcrash 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/Heightonce on the mount frame for exact repositioning (measuredRefguards 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 shipdeepseek-v4-flash): favorites (musepi-gui-fav-models), the DEFAULT pin (modelRoles.default), selection state, and role-row assignment are all keyed byprovider/id(legacy bare-id entries match compatibly and are cleaned up on toggle);session.setModelcarriesproviderso 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@containerqueries +--gui-motion-chip180ms transition;gui-motion-offswitches 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--spring150ms transition, the highlight shape hugging the capsule (first segment rounds left, last rounds right, a lone segment rounds fully) — consistent with standalone.gui-model-btnhover; the capsule isflex-shrink: 0so 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 perprovider(Mono monochrome variants,size 14;model-brand-icon.tsxinlines a 24-entry provider→icon map + modelId-substring fallback); unknown providers fall back to oc-iconsai-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(daemongetSupportedEfforts; empty for models without thinking support) — never a fixed seven levels; every role-model change routes throughapplyRoleModels(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.addmodels 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-surfacelayout width is fixed at BASE_W (1092), fitted to the window viatransform: scale(containerWidth/1092); the effect depends onactiveId(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-glowis pure-CSS three-layer RGB-offset radial gradients +mix-blend-mode: screen+ hover fade-in + hidden undergui-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_TERMSkeyword hits, bilingual); matching rows in the content area get.gui-settings-match(accent 13% fill + 24% outline) highlighted imperatively + the first matchscrollIntoViews (one scroll per new query/section switch; continued typing doesn’t re-scroll, avoiding jitter);aria-hidden/inertcollapsed 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 (thegui-settings-fieldtwo-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 agroupfield (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 whitelistWidgetRegistry(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 undergui-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(enumCONTEXT_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 —htmlwas the overlooked layer, carrying opaquevar(--bg)and blocking the DWM Acrylic (Windows) / vibrancy (macOS) from showing through the translucent scrims.[data-platform="win32"] .gui-maindisables the pagebackdrop-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 readsupdate-manifest.json.notes, “What’s new” readsCHANGELOG.musepi.md. - Bilingual docs convention (
docs/i18n/README.md): every in-scopedocs/**markdown pairsfoo.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 bybun run verify-translation-pairing(--writerecords 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-inentrance; collapse to a slim pill (persistedmusepi-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 undergui-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--bgfamily). Palette uses theme colors only (fg + bg surfaces), zero accents/gradients — visually kin to WelcomeComposer’sDotMatrixMark(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:dirto repackage (dev-mode Dock icons go throughapp.dock.setIcon(build/icon-dock.png); packaged builds use the bundle icns — swapping only the pngs without repackaging leaves the packaged Dock icon stale).