Agent OS · Motion & Interaction Brief · v2 final

Best-in-class ambient.
Immature transactional.
Close the gap.

WorkerWorld already has a world-class atmosphere — one shared rAF, a synth audio engine welded to motion, a living particle field. What it lacks is the tactile layer: press physics, exit choreography, focus rings, honest reveals. This brief is the spec to raise the transactional and correctness layers to the level the ambient layer already set — no new ambience required.

Honesty
Never animate fabricated data as if it were live. Count-ups fire on a real daemon-event delta — never on seed mount.
60 fps
Transform + opacity only, one shared rAF (systems/onFrame.ts). Never animate blur, width, or deep var cascades.
Reduced motion
A real numeric gate wired into the clock — not a footnote. Currently 0 hits in the tree. P0.
How to read this
Exists already in the tree — keep it
Extend present but incomplete
New greenfield build
Strong ship-grade today
Gap thin / half-wired
Missing absent — a hole

Every claim is verified against the live tree; file:line citations are load-bearing.

01

Where the UI is today

Best-in-class ambient, immature transactional. The work raises the tactile + correctness layers to the ambient layer's level — not more atmosphere.

Keep — the strong substrate

  • Exists  Single shared rAF — lazy start, document.hidden pause, per-callback try/catch. onFrame.ts
  • Exists  Ambient canvas: sprites, sonar, liquid cursor, 70-particle field, mesh orbs 26–44s, vaultBreathe
  • Exists  Synth audio welded to motion — sfxPing sfxChord sfxDeny, ~5ms ramps. sound.ts
  • Exists  Dock spring — hover translateY(-5px), active scale(.93), real overshoot cubic-bezier(.2,.9,.3,1.4)

The gap — the target

  • Missing  No window exit animation. Close = display:none / unmount. Window.tsx:71
  • Gap  List stagger thin — tickerUp exists but only Mcp applies a real capped delay
  • Missing  :focus-visible = 0 hits repo-wide. a11y P0
  • Missing  prefers-reduced-motion = 0 hits. Shipping-blocker
  • Gap  Active outline:none anti-pattern yuri.tsx:233 — overrides any global ring unless removed first
  • Gap  Buttons use scale(.96) with no transition — they snap
02

The motion system to adopt

Desktop lives at the fast end of every band. Numbers in JetBrains Mono — the OS's own number type.

Duration bands

Micro100–150 ms
Standard180–250 ms
Hero280–420 ms
Celebration≤ 600 ms

Exit = enter × 0.7–0.8. Celebration is rare, gated to real milestones.

Easing & springs

Signature ease-outcubic-bezier(.16,1,.3,1)
Enter(0,0,.2,1)
Exit(.4,0,1,1)
Press spring-back(.34,1.56,.64,1)
premium springstiff 300 · damp 30 · m 1
snappy · gentle400/30/.8 · 120/20/1
Contract — the exit path (was ungrounded)

The "exit × 0.75" rule is un-actionable until an exit path exists — and windows stay mounted (display:none) so pty/graph state survives (Window.tsx:110–112). Add a winOut keyframeopacity 1→0, scale(1)→.98 translateY(6px), ≈225ms, easing (.4,0,1,1) — driven by an AnimatePresence-style deferred flag: set closing=true → play → on animationend set display:none. State preserved, never unmounted. Same pattern for every dismissable surface.

Where a true JS spring is required

Only these three moments need velocity-preserving interruption (a second input mid-flight retargets from current velocity). This is what justifies the spring lib — everything else stays cheaper CSS / WAAPI.

Dock press → window open

2nd click mid-open retargets from live velocity

Toast stack reflow

new toast mid-settle retargets the stack, doesn't restart

Vault node settle

2nd node mid-fly retargets camera without a stop

Choreography: stagger 50ms/item, hard cap 6–8 — a naive stagger over long lists is a multi-second freeze. Menus scale from trigger origin. Perf floor: transform + opacity only; never width/height/padding/margin or blur > 20px.

03

Micro-interaction spec, by element

The tactile layer. Every control gets a target feel and an exact number.
a11y P0 — the global focus ring (both steps, in order)

1 · Remove suppression first. Audit and delete every outline:none — start at the verified yuri.tsx:233 — or the ring gets overridden per-element. 2 · Then add a global :focus-visibleoutline:2px solid var(--ac1) offset:3px + 0 0 0 4px rgba(139,156,249,.15) on every interactive element. :focus-visible, not :focus — so mouse clicks don't ring.

↑ this page dogfoods the spec: press scale(.97) spring-back + real focus ring

Buttons Extend

Hover brightness(1.1) ~180ms · press scale(.97) 90ms · release spring (.34,1.56,.64,1). Add the missing transition:transform .12s to the ~4 snap-back buttons.

Pointer capture — the real fix Extend

Chat (Chat.tsx:305) & Flow Run (Flow.tsx:285–286) stick. Fix with setPointerCapture + onPointerUp/Cancel + onLostPointerCapture — survives drag-off, touch, lost focus. onPointerLeave is a band-aid.

Toggles Gap

Least-tactile control today. Knob spring visualDuration .2 · bounce .1 + press scale(.92) + two-note cue (up = on, down = off).

List rows Extend

Fade + translateY(6px) 220ms · delay:calc(var(--i)*50ms) capped 6–8. Reorder via FLIP. Give hover-snapping skill/schedule cards a transition.

Approval cards Extend

Symmetrize Approve / Deny — Deny has no press state today. Resolution = collapse + checkmark morph ~300ms. No confetti.

Inputs Gap

Recessed thick material · focus = accent border + 0 0 0 3px rgba(139,156,249,.15) · invalid = red border + one 6px shake, ~200ms, single cycle.

04

Signature moments, per app

The memorable animated instant each surface owns. Tagged so nothing already built gets rebuilt.
MissionNew

Detail panel grows from the clicked card (popover-from-origin). Count-up with tabular-nums, ~1s ease-out — real daemon numbers only.

ChatExtend

Honest token stream, coalesced to ≥16ms frame boundaries (never strobe). wwCaret blink 530ms. Queued-gap shimmer 1200ms — never a %.

FlowExtend

Edges stream dashes only while processing (dashMove exists). Amber wwGate halts the DAG for sign-off — wire-up, not greenfield. Node moods = CSS/SVG, not Rive.

VaultExtend

Draw-in bloom. ~405 communities / 6.6k nodes → stagger by community, delay = min(i×4ms, 800ms), edge draw ≤400ms. Focus spring retargets mid-fly.

Cmd+KNew

100×/day → near-zero motion. Scale from center-top .98→1, 80ms. Non-focus-stealing; Esc returns focus to the prior element.

ToastsNew

Real bridge messages, not Sonner boilerplate. Burst-coalesce a 250ms window into one counted toast. Color by agent. A failed post = error toast, never a neutral card.

Per-agent toast identity

Color-code by the real agent.color from the store — the toast tells you who spoke before you read it.

Claude #D97757 Codex #8B9CF9 Grok #A78BFA Hermes #7DD3FC
05

Ambient & depth

Keep the dark glass alive without noise. Glass is a behavior, not a texture.

Lit-glass tell

Blur 10–20px (never animate blur) · edge 1px rgba(255,255,255,.10) · inset 0 1px 0 rgba(255,255,255,.12) · 0 8px 32px rgba(0,0,0,.36) · white text only. Carry the shell's 4-layer shadow into flat in-app cards. Material scale: thin = on-top, regular = structure, thick = recessed inputs. Never lighter-on-lighter.

Living background — 3 layers

Saturated mesh orbs drifting 26–44s (acDrift exists) · depth fog + pointer parallax on the constellation · grain feTurbulence ~.045 pre-rendered to a tiling PNG — animated SVG turbulence is an Electron paint trap.

Per-app spatial identity — specify the wiring or cut it

The effect: orbs shift toward the active app's agent color. The gap: agent.color and --ac1 exist, but there is no app→agent→--ac1 binding, and apps aren't 1:1 with agents. Contract: explicit appId→agentColor map (agentless apps → default #8B9CF9); transition --ac1 on app change over 600ms cubic-bezier(.4,0,.2,1). If the map can't be defined confidently — cut it. It's optional polish, not correctness.

06

Sound × motion

Spring is the haptic substitute. Fire sound at the physics event — the spring bottom, the checkmark pop — not the input timestamp.

Mapping — extend sound.ts

clickcrunch + 5ms ramps + ±5¢ jitter
toggleup / down interval
approve · run-donesfxChord + shimmer tail
errorshort darker down (sfxDeny)
ambientmotion only — never sound

Silence is a design choice

Scroll, hover, drag stay silent. Always 5ms ramps (kills the WebAudio click artifact). Always defeatable via live toggles.sounds. Match envelope to curve — sharp snap → sharp cue, soft settle → shimmer tail; intensity scales with consequence.

Reduced-motion is also a signal to soften / mute non-essential cues — not just visuals.

07

Asset strategy

An escalation ladder — take the cheapest rung that clears the bar. Procedural (shaders + synth) is the zero-asset gold standard and matches the existing aesthetic.
1CSS transform / opacity — the default for ~everything tactile
2Inline SVG — icons, strokes, morphs, edge draw
3JS spring lib — only the 3 interruption moments (§02)
4Canvas 2D — ≤ 3–5k elements (the constellation)
5Rive / Lottie — deferred escalation, behind a gate (see below)
6WebGL / GLSL shader — Vault field, hero-grade ambient
7Pre-rendered video — never-reacts-to-input only
Rive is a deferred escalation — NOT the plan

Rive / Lottie are not in package.json and appear nowhere in src. Default Flow node moods, the Yuri orb, and per-card avatars to CSS/SVG state machines. Rive only if it: (a) proves ≥10× size win for that moment, (b) passes offline .wasm bundling, (c) doesn't blow the bundle budget. Offline rules (hard): vendor runtimes via Vite (no CDN <script>), bundle .riv/.lottie as hashed assets, and verify no unpkg/cdn string survives the build.

08

Guardrails & build order

Three contracts bind every recommendation. Then the sequence that closes every P0 in under a day.
Honesty — the seed-handoff trap (the exact one in this codebase)

store/seeds.ts ships fully-populated fake numbers (visits 6240, usd 38.4) that render before any daemon event. Count-ups, filling bars, and rising sparklines all assert "this is live" — so they are daemon-event-only. Contract: a count-up fires only on a daemon-event delta transition, never on mount / seed render. Seed values carry the SIM / treatment until the first real event replaces them.

Performance

60fps or it doesn't ship. One rAF. CSS / WAAPI for discrete transitions. Transform + opacity only. Pre-render static blur / grain. overflow:clip over glass. Never animate blur.

Reduced motion — numeric contract

1 · one matchMedia read wired into onFrame — ambient callbacks don't register (gate at the clock). 2 · discrete transitions collapse to opacity 1ms / a skipAnimation flag. 3 · never delete feedback — the state change still lands, just without travel.

Build order — cheapest, highest-correctness first

Steps 1–3 are < 1 day combined and close every P0 a11y / correctness / honesty hole. Ambient, sound, and asset work layer in after, without blocking.

1

Kill the a11y holes

Delete every outline:none (start yuri.tsx:233) + add the global :focus-visible ring.

2

Fix the tactile snaps & sticks

transition:transform .12s on the ~4 snap-back buttons + convert Chat/Flow to pointer-capture.

3

Gate motion & add the exit path

Reduced-motion gate in onFrame + the winOut deferred-unmount exit.

4

Tokenize the system

Durations, easings, springs, skipAnimation as shared tokens — one source of truth.

5

Build the signature moments

Per-app moments (§04) — once the substrate is trustworthy.

Apps left thin — an explicit decision, not an oversight

§04 covers 6 of 14. The rest inherit the token defaults with no bespoke moment, with three noted for a later pass: Memory (brain writes/consolidation are a natural morph), Studio (a next-P0 elsewhere), and Terminal (live xterm pty — motion must not fight the terminal's own cursor/scroll; no ambient overlay inside the pty). Mcp already runs the one real capped tickerUp — keep it as the reference implementation for list rows.