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.
Every claim is verified against the live tree; file:line citations are load-bearing.
Where the UI is today
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
The motion system to adopt
Duration bands
Exit = enter × 0.7–0.8. Celebration is rare, gated to real milestones.
Easing & springs
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 keyframe — opacity 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.
Micro-interaction spec, by element
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-visible → outline: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.
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.
Signature moments, per app
Detail panel grows from the clicked card (popover-from-origin). Count-up with tabular-nums, ~1s ease-out — real daemon numbers only.
Honest token stream, coalesced to ≥16ms frame boundaries (never strobe). wwCaret blink 530ms. Queued-gap shimmer 1200ms — never a %.
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.
Draw-in bloom. ~405 communities / 6.6k nodes → stagger by community, delay = min(i×4ms, 800ms), edge draw ≤400ms. Focus spring retargets mid-fly.
100×/day → near-zero motion. Scale from center-top .98→1, 80ms. Non-focus-stealing; Esc returns focus to the prior element.
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.
Ambient & depth
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.
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.
Sound × motion
Mapping — extend sound.ts
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.
Asset strategy
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.
Guardrails & build order
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.
Kill the a11y holes
Delete every outline:none (start yuri.tsx:233) + add the global :focus-visible ring.
Fix the tactile snaps & sticks
transition:transform .12s on the ~4 snap-back buttons + convert Chat/Flow to pointer-capture.
Gate motion & add the exit path
Reduced-motion gate in onFrame + the winOut deferred-unmount exit.
Tokenize the system
Durations, easings, springs, skipAnimation as shared tokens — one source of truth.
Build the signature moments
Per-app moments (§04) — once the substrate is trustworthy.
§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.