Z Engine GUI — UI Guide (Svelte 5)
Status: canonical · Frontend is Svelte 5 + Bits UI + Vite, hosted by Tauri 2. Read this before adding or changing any file under crates/z-engine-gui/ui.
This guide exists so humans and LLMs produce the same kind of code: small files, one responsibility, reusable primitives, no ad-hoc overlays.
1. Why this stack
The v0.1 React UI grew a 1 000-line event god-module, hand-rolled popovers / dialogs / menus, and useSyncExternalStore boilerplate in every screen. That is the clutter this rewrite removes.
| Choice | Why |
|---|---|
| Svelte 5 (Vite, no SvelteKit) | Single-window Tauri SPA. Kit routing/SSR adds nothing and fights the webview. |
| Runes + thin store bind | Components read reactive values. Domain/runtime stays plain TypeScript so vitest does not need a browser. |
| Bits UI (headless) | Accessible dialogs, menus, context menus, popovers, disclosures and tooltips. We own the look. |
| No Tailwind / no shadcn-svelte | The look is already defined by our own tokens in styles/tokens.css. A second design system would fight it. |
| Hugeicons (core paths) | Same icon set as before; rendered by our Icon primitive, not a React wrapper. |
Do not add a component library that ships its own theme.
2. Dependency direction
.svelte screens → $lib/ui primitives (Bits UI wrappers)
→ $lib/runtime (sessions / projects / inbox / catalogs / events)
→ $lib/domain (pure helpers + tests)
→ $lib/commands/*.ts (ONLY Tauri invoke)- Screens never call
invoke()or@tauri-apps/api/core. - Engine events are listened to only in
lib/runtime/listen.ts(the updater's progress inlib/updateStore.ts); window and webview APIs only inWindowControls,AppShellandlib/runtime/pathDrop.ts(OS drops). - Bits UI is imported only inside
lib/ui/*. Feature components use our wrappers (Dialog,Menu,Popover, …). Three.js (three) is imported only insidelib/pet3d/, whichcomponents/pet/petView.tsloads lazily. - The IPC contract in
lib/commands/*.tsis the seam to the Tauri shell; engine events arrive asEventEnvelopes on theengineEventchannel and their types are generated intolib/protocol/(never edit those files).
3. App map
3.1 Screen structure
App.svelte boots, shows the splash, then Onboarding (no projects and no chats yet) or AppShell; the palette, Settings and the one ConfirmDialog mount above either.
AppShell (chrome/)
├── AppSidebar glass card: SidebarNav · ProjectGroup › ChatRow · Other chats · SidebarFooter
├── stage MainStage (home | chat | inbox) under the transparent title zone, TopBar:
│ sidebar controls · TitleStatus: WaitingBubble, Island (IslandCapsule with IslandPet, IslandCard),
│ ContextBubble (ContextCard) · ChangesButton · side panel toggle
├── SidePanel glass card (sidepanel/): SidePanelTabs · DiffPanel | PlanView | WorkPanel | PromptInspector
└── PetLayer the roaming pet (pet/), above the app and below every popoverui.view asks for home, chat or inbox; stageFor() (lib/domain/stage.ts, via currentStage()) decides, so an empty new chat is still the project home. One composer serves both: the hero on the home, docked in a chat; it glides between the two, a new stage fades up (appear) and the hidden sidebar card slides out by translate. The side panel docks beside the stage or, expanded, covers it (surfaces). Double-clicking an empty part of the title zone (title bar, sidebar, panel or page head) maximizes or restores the window. Full-window pages (4.7) cover the window; dialogs and popovers float above.
3.2 File map
crates/z-engine-gui/ui/src/
├── main.ts # mount + platform and window-material classes; imports styles/* only
├── App.svelte # composition root (wiring only)
├── styles/ # every stylesheet (4.5); splash.css is linked from index.html
├── lib/
│ ├── protocol/ # GENERATED by ts-rs: Event, Command, Message, config/…
│ ├── commands/ # typed invoke wrappers per domain — IPC boundary
│ │ └── engine.ts workspace.ts app.ts settings.ts pet.ts
│ ├── runtime/ # the one event subscription and live state
│ │ ├── listen.ts # initEvents (Tauri listen("engineEvent"), once)
│ │ └── sessions projects inbox chatChanges sessionList catalogs pet (.svelte.ts), actions, toasts, …
│ ├── domain/ # pure functions + vitest (stage, island, liveStatus, sidePanel, plans, inbox, receipt, …)
│ │ ├── sessionView/ # EventEnvelope reducer, snapshot, streaming, tools, turns
│ │ ├── timeline/ # transcript turns, blocks, tool-run groups, the long-chat window
│ │ ├── tools/ # per-tool presentation helpers
│ │ ├── settings/ # forms, scopes, provenance, credentials, settings search
│ │ └── pet/ # pose, emotions, motion, looks, growth, perches, behavior; face shapes and layout, 3D rig, portrait
│ ├── pet3d/ # the pet in 3D: shared WebGL renderer, frame loop (petFrames), palette, scene; the only three import
│ ├── stores/ # composer, settings, ui chrome and side panel, panel nudges, island, live, pet, stage, shortcuts, confirm, onboarding, app actions
│ ├── ui/ # Bits UI kit + Icon + Button + small primitives (4.3); motion, springs, presence, perch, whenVisible (4.5)
│ └── svelte/ # bindStore() — store → rune
└── components/
├── chat/ chat/tools/ # transcript, turn work summary and actions, code blocks, composer, approvals; one card per tool family, ToolGroup
├── planning/ # question cards, the Plan tab and "Plan ready" row, todo checklist, what waits in the composer
├── agents/ # the Agents tab: apply cards, agent and job rows, usage, transcripts
├── settings/ # settings page, grouped nav, scope menu, tabs
├── sidepanel/ # the side panel card and its tab band
├── overlays/ # Changes tab (DiffPanel), prompt inspector (Context tab), palette, worktree dialog, shell drawer
├── chrome/ # AppShell, MainStage, TopBar, the island (capsule, card) and satellites, FullPage, splash
├── pet/ # the pet: 3D/flat switch, SVG body, face, props, motion, island slot and portrait, roaming layer, card, look picker, helper sprite
├── sidebar/ # AppSidebar, SidebarNav, ProjectGroup, ChatRow, SidebarFooter
├── home/ # project home: header, starters, Continue / Changes / setup cards
├── inbox/ # Activity inbox: needs you, finished, notices
└── onboarding/ # first-run stepsFile budget (same as the repo): target ≤300 lines, hard cap 400. Split by responsibility, never by percentage.
4. Patterns
4.1 Component shape
<script lang="ts">
import { Button } from "$lib/ui";
import { sessions } from "$lib/runtime";
type Props = { pending?: boolean };
let { pending = false }: Props = $props();
const active = $derived(sessions.active);
function onSend() {
/* call a function from lib/runtime or lib/commands — no invoke() */
}
</script>
<section class="composer">
<Button variant="accent" disabled={pending} onclick={onSend}>Send</Button>
</section>$props()/$state/$derived/$effectonly. No Svelte 4export let.- Props are a typed object. Events that bubble use callback props (
onClose,onApprove), notcreateEventDispatcher. - One visual thing per file. If a file names a card, it renders that card.
4.2 Stores
Runtime state is rune classes in lib/runtime/*.svelte.ts (sessions, sessionList, catalogs, projects, inbox, chatChanges, pet) whose logic delegates to pure lib/domain helpers, so tests stay node-vitest. The remaining { subscribe, getSnapshot } stores (toasts, workspaces, updates, the shell) are bound in screens:
const toasts = bindStore(toastStore); // toasts.currentApp chrome state is UI-only runes in lib/stores/ (ui.svelte.ts: stage, overlays and the side panel, ui.panel with openPanel(tab, target?, scope?); panelNudges.svelte.ts: what opens or badges a panel tab; island.svelte.ts: whether the island card and the context card are open; live.svelte.ts: createLive(), the status, pet pose and clock shared by the title bar and the pet; pet.svelte.ts: the pet's card, perch and reactions; confirm, onboarding; chords in shortcuts.ts). Never put Tauri listeners or invoke inside a store except listen.ts, lib/commands/*.ts, and the workspace/update stores that already wrap a single command; stores that fetch (projects, chatChanges, pet) call the wrappers.
4.3 Bits UI — use the kit, not the package
<!-- YES -->
<Dialog.Root bind:open>
<DialogPanel title="New chat in a worktree">…</DialogPanel>
</Dialog.Root>
<!-- NO — do not import bits-ui from a feature component -->
<script>
import { Dialog } from "bits-ui";
</script>Wrappers apply our tokens and the kit's classes in styles/kit.css (.modal, .menu, .disclosure-*, .badge, .ring, .empty-state). When Bits UI’s API moves, only lib/ui/ changes.
| Need | Kit |
|---|---|
| Any button | Button (variant ghost, secondary, primary, accent, danger, icon, icon-mini; size s, m, l); buttonClass() in button.ts styles Bits triggers the same way |
| Modal (worktree, provider connect) | Dialog with DialogPanel |
| Confirm destructive | ConfirmDialog, asked through confirmStore.ask() (lib/stores/confirm.svelte.ts) |
Overflow menu, "Always allow…", scope menu, + menu | Menu |
| Right-click menu | ContextMenu |
| Detail that waits until asked for (tool runs, receipts, advanced options) | Disclosure |
| A count (a zero draws nothing) | Badge |
| A status word or tag (tools, agents, jobs, cards) | Pill (tone from the status tones, dot, live) |
| Progress or context usage | ProgressRing |
| An empty place and the next step | EmptyState (optional art snippet instead of the icon, such as the pet's Inbox spot) |
| A search box | SearchField (Settings search, the inspector outline) |
| The selected row's fill, sliding between rows | SelectionCapsule (sidebar nav and chats, Settings nav, side panel tabs) |
| A shortcut shown in text | Kbd |
| Anchored panel: mode and model chips, context card, pet card | Popover |
| Exclusive choices side by side (diff scope and layout, filters) | SegmentedChoice (compact in toolbars) |
| Icon button labels and their shortcuts | Tooltip (text, optional shortcut) |
Select exists but is unused (the palette and the / and @ lists are their own listboxes). The palette itself is a kit Dialog (DialogPanel with label and contentClass="palette"). Do not invent another position: fixed overlay with a backdrop div unless Bits has no primitive for it (the boot splash is the one exception).
4.4 Surfaces
Which component and pure function own each surface (transcript, receipt, approvals, composer, the one home of every fact, the side panel and overlays, the pet) is in GUI surfaces. Read it before adding a banner, pill, panel tab or pet behavior.
4.5 CSS and visual hierarchy
- Every stylesheet lives in
styles/andmain.tsimports only those (splash.cssis linked fromindex.htmlso the splash paints first):tokens.css(every token),base.css,motion.css,materials.css(.sheet,.glass,.glass-strong, edge blurs), the kit (kit.css,kit-parts.css,buttons.css,fields.css,segmented.css,panels.css) and one or more files per area (sidepanel.css,island*.css,pet-*.css, …), each under 400 lines. - Canonical tokens only (
--l1-bg…--l3-*,--sheet,--raised,--label-2,--separator,--r-m,--elev-2, …); the legacy aliases (--surface,--text-2, …) are gone. - Color means status, through the
--tone-*tokens: green live work (--tone-working), amber needs you (--tone-attention), red failed (--tone-danger-*), blue your own shell (--tone-shell). The one--accent(selection, focus, the primary action), code (syntax, diff lines) and the context layer hues are the only other colors. - Four layers, back to front (
tokens.css), one glass recipe (materials.css) at two strengths: L0 the window (native material or solid--l0-bg); L1 near-opaque content (.sheet: the stage.canvas-pane, diffs, plans, the Settings pane, transcript text); L2 floating glass (.glass: sidebar, side panel, composer, island); L3 stronger glass (.glass-strong: popovers, menus, tooltips, dialogs). - Keep the hierarchy low-clutter: window material → content sheet → content column → quiet nested metadata. Prefer spacing and type weight before adding another border, badge, or filled panel. Keep transcript and composer aligned with
--measure-chat,--gutter-chat,--column-chat. - Motion: anything that travels, grows or opens rides a spring from
lib/ui/springs.ts(snappy for controls, smooth for sheets and panels, bouncy for the pet and droplets, gentle for long travel): in CSS as an--ease-spring-*linear()easing with its--dur-spring-*(motion.css, printed byspringCssVars(), guarded bysprings.test.ts), inelement.animate()asspringEasingCss(), in Svelte as theappear(fade up),sheet(full page) andsplitOff(title-bar droplets) transitions oflib/ui/motion.ts.Springwith theSPRINGpresets follows input (the pet's eyes);presence()keeps an overlay mounted through its exit. - Only
transform(translate,scale,rotate) andopacityanimate. Reduce Motion:motion.tsonly fades, the pet's moves jump, and CSS animations and transitions last 1 ms, once (motion.css). - Entrance animations that animate
opacityfillbackwards, neverbothorforwards: an element whose finished opacity animation is still filling becomes a backdrop root, and anybackdrop-filterglass inside it stops blurring (seen in Chromium and WebView2). - Looping decorative animations run on HTML elements (the compositor animates them), never on SVG groups, which restyle and repaint every frame: the pet breathes on its
.pet-breathwrapper (pet-moves.css), and the 3D pet draws only throughpetFrames(lib/pet3d/scheduler.ts) while something plays, so an idle window does no per-frame work. - Read layout in a frame callback, never in the middle of an update (
perches.measure()inlib/ui/perch.svelte.ts), and let work needed only on screen wait forwhenVisible()(lib/ui/whenVisible.ts: turn placeholders, code colors). - Scoped
<style>in a component is allowed for one-off layout that will never be reused. Shared look belongs in that area's stylesheet. - No inline style objects except chart/canvas geometry.
4.6 Icons
<script>
import { Icon, Plus } from "$lib/ui/icons";
</script>
<Icon icon={Plus} size={16} />Add a new icon in lib/ui/icons.ts only. Do not import @hugeicons/core-free-icons from a screen.
4.7 Full-window pages (Settings)
Settings is the one full-window page (chrome/FullPage.svelte, panels.css); the prompt inspector is the side panel's Context tab.
- Frame: a
position: fixed; inset: 0frame on--canvas, mounted by its parent; it opens and closes as a sheet (sheet: it settles from 98 % with a fade and drops back a touch as it leaves). - Title bar:
.full-page-barwith.full-page-bar-sidegroups anddata-tauri-drag-region(traffic-light clearance fromhtml.plat-mac): Back (title="Back (Esc)"), the title andleadleft;actionsand<WindowControlsMaybe />right. - Body: padded by
--shell-gaplike.app-body; the page brings its floating cards (Settings: a glass nav card beside one--sheetcontent pane, its content centred on--measure-chat). Esc callsonClose()unless something inside used it; noEscbadge.
5. How to add things
| Adding… | Do this |
|---|---|
| A screen control | New components/<area>/<Name>.svelte. Use kit primitives. Keep ≤300 lines. |
| A Bits wrapper | New file in lib/ui/. Re-export from lib/ui/index.ts. |
| An IPC command | Rust src-tauri/src/commands/<domain>.rs + generate_handler! + wrapper in lib/commands/<domain>.ts. Then call the wrapper. |
| An engine event | Variant in z-engine-protocol (regenerate TS), then reduce it in lib/domain/sessionView/. Never listen in a component. |
| A pure helper | lib/domain/<name>.ts + sibling *.test.ts. |
| A Settings page | SettingsTab in lib/stores/ui.svelte.ts, SETTINGS_TABS in SettingsNav.svelte, a SETTINGS_SECTIONS group and SETTING_ENTRIES rows, a branch in SettingsPage.svelte. |
| A CSS token | --name in styles/tokens.css. Use it; do not hard-code hex in components. |
| A stylesheet | styles/<area>.css, imported in main.ts. |
6. Testing
- Domain + runtime: vitest, node environment, next to the file (
sessionView/reduce.test.ts,runtime/sessions.test.ts). - Do not mount Svelte in unit tests unless the logic cannot be extracted.
npm test,npm run checkandnpm run lintmust stay green.npm run buildis the Tauri frontend compile.- Performance: in a dev build,
__zengine.longChat(1000)in the webview console opens a local-only chat of 1,000 finished turns to profile (lib/runtime/devChat.ts; it never reaches the engine).
7. What not to do
- Do not reintroduce React,
useSyncExternalStore, or.tsx. - Do not add SvelteKit, Tailwind, shadcn-svelte, or Melt UI.
- Do not grow
App.sveltepast wiring (stores → screens). Put handlers inlib/stores/app-actions.tsor the relevant domain module. - Do not call
listen("engineEvent")a second time.initEvents()is one-shot by design. - Do not copy-paste a dialog/menu/select. Extend the kit.
- Do not add a stylesheet outside
styles/, or give a fact a second home. - Do not put prompt text, Rust types, or provider IDs in the UI. The frontend is presentation-only.
8. Keyboard map (must keep)
App-wide chords: shortcutFor() in lib/stores/shortcuts.ts (plain ⌘ or Ctrl only). Composer keys: composerIntent() in lib/domain/composerKeys.ts.
| Shortcut | Action |
|---|---|
| ⌘/Ctrl+K | Command palette |
| ⌘/Ctrl+N | New chat |
| ⌘/Ctrl+B | Show or hide the sidebar |
| ⌘/Ctrl+D | The side panel's Changes tab (again: close the panel) |
| ⌘/Ctrl+, | Settings |
| Enter | Send (queues while the agent works) |
| Shift+Enter | Newline |
| ⌘/Ctrl+Enter | Interrupt the turn and send |
| Shift+Tab | Next permission mode |
| ↑ / ↓ | Composer history, from the first or last line |
| Esc | In the composer: cancel the turn, else clear the draft. Elsewhere: close the island card first; then the side panel steps back (out of an agent's transcript, out of the expanded view, closed); on Settings, Back |
| ← / → | Previous / next side panel tab (tab band focused); resize the panel (edge handle focused) |
| [ / ] or k / j | Previous / next file in the Changes tab |
| y / s / p / n | Answer the focused approval card |
9. Visual language (do not restyle casually)
- Dark only, no gradient fills. The window material (dark vibrancy on macOS, dark Mica on Windows 11) shows through the title zone and the gaps between cards;
html.native-glassis set only when the shell reports it applied (app_info.nativeGlass), elsehtml.solid-surfaces(Windows 10, Linux) paints the window solid while the glass inside still blurs. Reduce Transparency makes every layer solid. - Four layers (4.5): content on the L1 sheet, floating cards on L2 glass, popovers on L3; L1 and L2 radius 16, L3 radius 14. The island and its two satellites are the only live element in the title bar.
- SF Pro at 13px (
--text-m), labels--label/-2/-3/-4; one blue--accentfor selection, focus and the primary action; every other color means status. - Radius 6 / 8 / 12 / 16 / 20 and full (
--r-xs…--r-full). Hairline separators at 7 % (--separator) and 12 % (--separator-strong) white. - Overlay title bar; macOS traffic lights stay system-drawn inside the sidebar card's head; Windows draws its own caption buttons (
WindowControls).
Changing the palette is a design change, not a drive-by cleanup.