Skip to content

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.

ChoiceWhy
Svelte 5 (Vite, no SvelteKit)Single-window Tauri SPA. Kit routing/SSR adds nothing and fights the webview.
Runes + thin store bindComponents 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-svelteThe 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 in lib/updateStore.ts); window and webview APIs only in WindowControls, AppShell and lib/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 inside lib/pet3d/, which components/pet/petView.ts loads lazily.
  • The IPC contract in lib/commands/*.ts is the seam to the Tauri shell; engine events arrive as EventEnvelopes on the engineEvent channel and their types are generated into lib/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 popover

ui.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 steps

File budget (same as the repo): target ≤300 lines, hard cap 400. Split by responsibility, never by percentage.

4. Patterns ​

4.1 Component shape ​

svelte
<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 / $effect only. No Svelte 4 export let.
  • Props are a typed object. Events that bubble use callback props (onClose, onApprove), not createEventDispatcher.
  • 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:

ts
const toasts = bindStore(toastStore); // toasts.current

App 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 ​

svelte
<!-- 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.

NeedKit
Any buttonButton (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 destructiveConfirmDialog, asked through confirmStore.ask() (lib/stores/confirm.svelte.ts)
Overflow menu, "Always allow…", scope menu, + menuMenu
Right-click menuContextMenu
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 usageProgressRing
An empty place and the next stepEmptyState (optional art snippet instead of the icon, such as the pet's Inbox spot)
A search boxSearchField (Settings search, the inspector outline)
The selected row's fill, sliding between rowsSelectionCapsule (sidebar nav and chats, Settings nav, side panel tabs)
A shortcut shown in textKbd
Anchored panel: mode and model chips, context card, pet cardPopover
Exclusive choices side by side (diff scope and layout, filters)SegmentedChoice (compact in toolbars)
Icon button labels and their shortcutsTooltip (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/ and main.ts imports only those (splash.css is linked from index.html so 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 by springCssVars(), guarded by springs.test.ts), in element.animate() as springEasingCss(), in Svelte as the appear (fade up), sheet (full page) and splitOff (title-bar droplets) transitions of lib/ui/motion.ts. Spring with the SPRING presets follows input (the pet's eyes); presence() keeps an overlay mounted through its exit.
  • Only transform (translate, scale, rotate) and opacity animate. Reduce Motion: motion.ts only fades, the pet's moves jump, and CSS animations and transitions last 1 ms, once (motion.css).
  • Entrance animations that animate opacity fill backwards, never both or forwards: an element whose finished opacity animation is still filling becomes a backdrop root, and any backdrop-filter glass 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-breath wrapper (pet-moves.css), and the 3D pet draws only through petFrames (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() in lib/ui/perch.svelte.ts), and let work needed only on screen wait for whenVisible() (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 ​

svelte
<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.

  1. Frame: a position: fixed; inset: 0 frame 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).
  2. Title bar: .full-page-bar with .full-page-bar-side groups and data-tauri-drag-region (traffic-light clearance from html.plat-mac): Back (title="Back (Esc)"), the title and lead left; actions and <WindowControlsMaybe /> right.
  3. Body: padded by --shell-gap like .app-body; the page brings its floating cards (Settings: a glass nav card beside one --sheet content pane, its content centred on --measure-chat). Esc calls onClose() unless something inside used it; no Esc badge.

5. How to add things ​

Adding…Do this
A screen controlNew components/<area>/<Name>.svelte. Use kit primitives. Keep ≤300 lines.
A Bits wrapperNew file in lib/ui/. Re-export from lib/ui/index.ts.
An IPC commandRust src-tauri/src/commands/<domain>.rs + generate_handler! + wrapper in lib/commands/<domain>.ts. Then call the wrapper.
An engine eventVariant in z-engine-protocol (regenerate TS), then reduce it in lib/domain/sessionView/. Never listen in a component.
A pure helperlib/domain/<name>.ts + sibling *.test.ts.
A Settings pageSettingsTab 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 stylesheetstyles/<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 check and npm run lint must stay green. npm run build is 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.svelte past wiring (stores → screens). Put handlers in lib/stores/app-actions.ts or 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.

ShortcutAction
⌘/Ctrl+KCommand palette
⌘/Ctrl+NNew chat
⌘/Ctrl+BShow or hide the sidebar
⌘/Ctrl+DThe side panel's Changes tab (again: close the panel)
⌘/Ctrl+,Settings
EnterSend (queues while the agent works)
Shift+EnterNewline
⌘/Ctrl+EnterInterrupt the turn and send
Shift+TabNext permission mode
↑ / ↓Composer history, from the first or last line
EscIn 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 / jPrevious / next file in the Changes tab
y / s / p / nAnswer 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-glass is set only when the shell reports it applied (app_info.nativeGlass), else html.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 --accent for 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.