# 2026-07-20 — Desktop mascot ("Cluck") **Status:** Planned ## Why The web control room is an OS-style desktop shell (icons, floating windows, taskbar) but has no ambient, always-visible signal of what the system is doing — you have to open a window to see a chat streaming, a knowledge-graph write, or a critical signal land. The user asked for a pixel-art chicken mascot that roams the desktop, is draggable and interactable (Sims-style radial right-click menu with nested actions), and is itself a tamagotchi (egg → chick → adult, nameable, persistent) that visibly reacts to real app activity. This plan is scaffolding: every piece (sprites, autonomous behaviors, menu actions, environment reactions) is a data-driven registry so each can be extended independently later without touching the engine code. The MBSE subsystem model for this feature (Mission, Requirements, Structural/Behavioral/Interfaces views, Verification) lives at [docs/mascot/README.md](../docs/mascot/README.md) — read it first for the full rationale and diagrams; this document is the concrete file-by-file implementation plan derived from it. **Design decisions already made with the user:** - Renders **above windows** (desktop-pet style) — mascot layer `z-45`, radial menu `z-[60]` (must beat the desktop's own right-click menu, which is `z-50`). - Art is **code-drawn pixel art** — string pixel-grids + a palette map in TypeScript, rendered to a small canvas, no binary sprite assets. - Movement is **gravity + ground** — walks along the desktop surface's bottom edge (= the taskbar's top edge), flutter-falls when dropped mid-air. ## Verified codebase facts this plan builds on - `web/src/lib/components/desktop-shell/Desktop.svelte` — the surface div (`relative min-h-0 flex-1 overflow-hidden`) hosts layered children: icon layer `z-0`, `TaskLauncher` wrapper `z-10`, `WindowLayer` `z-40` — every wrapper is `pointer-events-none` with interactive children re-enabling `pointer-events-auto`. The desktop's own right-click menu is `fixed z-50`, dismissed via `` + Escape. Bare-surface clicks are gated with `e.currentTarget === e.target`. - Drag pattern to copy: `desktop-shell/DesktopIcon.svelte` — `pointerdown` + `el.setPointerCapture(e.pointerId)`, a 5px movement threshold distinguishes a click from a drag, move/up listeners attached to the element itself (not `window`), position blended via `$derived` between rest and drag-in-progress values. - Game loop convention: `GraphBackground.svelte` drives its canvas with `setTimeout(() => draw(performance.now()), 33)` (~30fps), **not** `requestAnimationFrame` — the code comment there explains some embedding contexts report `document.hidden=true` and suspend rAF, which would freeze the animation; `setTimeout` keeps ticking. Follow this for the mascot loop, and clamp `dt` to 100ms so a throttled/backgrounded tab doesn't produce a physics-breaking huge step on resume. - Persistence convention: hyphenated `oikos-*` localStorage keys (`oikos-desktop-icons`, `oikos-theme`, `oikos-windows`). Window layout uses wmkit's `persist(wm, { key: 'oikos-windows', debounce: 300, autoRestore: true })` — mirror the 300ms debounce for `oikos-mascot`; never write on every animation frame, only on discrete state transitions (behavior change, drag end, stage change, rename). - Runes idiom for cross-component client state: a `.svelte.ts` module with module-level `$state` plus exported getter/mutator functions — `web/src/lib/stores/theme.svelte.ts` is the canonical example (`let current: Theme = $state(initialTheme)`, `getTheme()`, `setTheme()`, `toggleTheme()`). - Awareness sources, all plain Svelte stores already in the codebase: - `web/src/lib/stores/events.ts` — `liveEvents: Writable` (newest-first, capped at 200), fed by a ref-counted SSE subscription `subscribeEvents()`. `OikosEvent.type` families: `approval.*`, `signal.*`, `execution.*`, `health.changed`; `severity: 'info' | 'warning' | 'critical'`. - `web/src/lib/stores/chat.ts` — `streaming: Writable`. - `web/src/lib/stores/activity.ts` — `activityLog` is a **derived** store recomputed wholesale from `messages`/`planSteps`/`currentTask` on every emission, **not an append-only log** — detecting a "new" entry (e.g. `type === 'knowledge'`) requires diffing entry `id`s against the previous emission, not just reacting to the store firing. - `web/src/lib/stores/context.ts` — `summary: Writable`, `openSignalCount(summary)`. - No `@keyframes`, no `requestAnimationFrame`, no sprite/pixel-art code exists anywhere in the repo today — this is greenfield within the established canvas-loop convention above. ## File layout All new, under `web/src/lib/mascot/`: ``` types.ts PixelGrid, AnimName, MascotStage, BehaviorId, Stimulus, RadialAction palette.ts Record; '.' = transparent sprites.ts SPRITES: Record>> + resolveAnim() fallback render.ts drawFrame(ctx, grid, palette, flip) — stateless canvas painter state.svelte.ts Tamagotchi model: module $state + mutators, debounced persist, versioned schema behavior.ts FSM: BEHAVIORS registry + stepMascot(rt, model, now, dt) stimuli.ts Stimulus bus: REACTIONS registry + attachStimuli(emit), ref-counted actions.ts MASCOT_ACTIONS radial tree + registerMascotAction() Mascot.svelte canvas sprite, 30fps loop, pointer drag/click/contextmenu MascotLayer.svelte pointer-events-none absolute inset-0 z-45 overlay; hosts Mascot + RadialMenu + bubble RadialMenu.svelte round nested menu, fixed z-[60] NameDialog.svelte naming prompt (hatch + rename) ``` **Integration — 2 lines in `Desktop.svelte`:** import `MascotLayer` and render `` inside the surface `
`, after ``, so its `absolute inset-0` shares the surface's coordinate space and its ground line lands exactly at the surface's bottom edge (the taskbar's top edge). ## Sprite system (`types.ts`, `palette.ts`, `sprites.ts`, `render.ts`) Frames are human-editable string pixel-grids indexing a palette, e.g.: ```ts export type PixelGrid = string[] // rows of same-length strings, one char per pixel export interface AnimDef { frames: PixelGrid[]; fps: number; loop: boolean } export type AnimName = | 'egg-idle' | 'egg-wiggle' | 'egg-crack' | 'hatch' | 'idle' | 'blink' | 'walk' | 'peck' | 'flap' | 'sleep' | 'dragged' | 'fall-flutter' | 'land' | 'react-eureka' | 'react-alarm' | 'react-think' | 'react-happy' ``` - Grids: egg 12×12, chick 14×14, adult 16×16, all bottom-anchored inside a fixed 20×20 logical canvas so feet land on the ground line consistently across stages. - `SPRITES: Record>>` is the registry; `resolveAnim(stage, name)` falls back to that stage's `idle` and finally a 1-frame placeholder, so a missing animation never crashes the renderer. - Canvas is sized to the logical grid; screen scale is pure CSS (`width: 20*SCALE px; image-rendering: pixelated`), `ctx.imageSmoothingEnabled = false` set once. Horizontal facing flip via `ctx.translate(w,0); ctx.scale(-1,1)` — no mirrored frame data needed. - Initial animation set (2–4 frames each): egg-idle/egg-wiggle/egg-crack/ hatch; idle/blink/walk/peck/flap/sleep; dragged/fall-flutter/land; react-think/react-eureka/react-alarm/react-happy. - Frame index = `floor((now - animStart) / 1000 * fps)`, wrapped if `loop`. ## Behavior engine (`behavior.ts`) ```ts export interface MascotRuntime { x: number; y: number // sprite bottom-center, surface coords vx: number; vy: number facing: 1 | -1 behavior: BehaviorId // 'egg' | 'idle' | 'wander' | 'peck' | 'sleep' | 'dragged' | 'falling' | 'react' behaviorUntil: number anim: AnimName animStart: number reactAnim: AnimName | null bounds: { w: number; h: number } } export interface BehaviorDef { id: BehaviorId anim: (rt: MascotRuntime, model: MascotModel) => AnimName enter?: (rt: MascotRuntime) => void tick: (rt: MascotRuntime, dt: number, now: number) => void next: (rt: MascotRuntime, now: number) => BehaviorId | null weight?: number // idle-selectable when > 0; undefined/0 = not auto-picked minMs: number; maxMs: number } export const BEHAVIORS: Record export function stepMascot(rt: MascotRuntime, model: MascotModel, now: number, dt: number): void export function forceBehavior(rt: MascotRuntime, id: BehaviorId, opts?: { anim?: AnimName; durationMs?: number }): void ``` - **Ground/gravity**: `GROUND_Y = bounds.h`. When above ground and not dragged, behavior is `falling`: `vy += GRAVITY * dt`, capped at a slow flutter terminal velocity, anim `fall-flutter` with occasional `flap`; on reaching ground, snap `y`, brief `land`, then `idle`. - **Wander**: constant `vx = facing * ~40px/s`, flip `facing` at the surface margins. - **Idle selection**: when `now > behaviorUntil` and the current behavior's `next()` returns null, roll a weighted random pick over `BEHAVIORS` entries that declare `weight` — starting weights: idle 3, wander 4, peck 2, sleep 1. - **Non-self-selecting behaviors** (`dragged`, `falling`, `react`) have no `weight` and are entered only via `forceBehavior()` — pointer code calls it for `dragged`, gravity logic for `falling`, the stimulus bus for `react`. - **Egg stage**: `behavior` locked to `'egg'` (periodic `egg-wiggle`, `egg-crack` as `hatchProgress` nears 1); dragging is still allowed (the egg can be picked up and moved). - Loop lives in `Mascot.svelte`: `setTimeout(() => tick(performance.now()), 33)` inside an `$effect`, cleared on teardown, `dt` clamped to 100ms. ## Tamagotchi model (`state.svelte.ts`) ```ts export type MascotStage = 'egg' | 'chick' | 'adult' export interface MascotModel { version: 1 stage: MascotStage name: string | null hatchProgress: number // 0..1, egg stage only happiness: number // 0..100, slow decay, boosted by pet/feed xp: number // chick -> adult growth hook hatchedAt: number | null lastPos: { x: number } | null lastSeen: number // for capped offline egg-incubation progress } export const HATCH_MS = 3 * 60_000 // active time to hatch (demo-friendly) export const ADULT_XP = 200 export function grantXp(n: number): void export function feed(): void export function pet(): void export function setName(name: string): void export function tickLifecycle(dt: number): void // called ~1x/sec, not per frame export function advanceStageIfReady(): void ``` - `load()` parses `localStorage['oikos-mascot']`, checks `version`, falls back to `defaultModel()` on mismatch/corruption. `migrate(raw): MascotModel` is a stub switch on `version` for future schema changes — v1 has no migrations to perform, the stub just documents where they go. - Every mutator calls a shared `schedulePersist()` — a 300ms trailing debounce, plus a `beforeunload` flush so a quick reload doesn't lose a rename. `lastPos.x` is written only on behavior transitions and drag-end, never per frame. - Multi-tab races (two tabs both writing `oikos-mascot`) are last-writer-wins — accepted for this scaffolding, not solved; a future pass could listen to the `storage` event if it becomes a real problem. ## Radial menu (`actions.ts`, `RadialMenu.svelte`) ```ts export interface RadialAction { id: string label: string icon?: Component // lucide, same convention as the desktop menu visible?: (model: MascotModel) => boolean // e.g. Rename only once hatched children?: RadialAction[] action?: (ctx: MascotActionCtx) => void // leaf only } export const MASCOT_ACTIONS: RadialAction[] export function registerMascotAction(a: RadialAction, parentId?: string): void ``` v1 tree: **Interact** [Pet, Feed → [Seeds, Worm]], **Care** [Sleep, Wake], **Identity** [Rename], **Debug** [Force hatch/stage, Reset]. - Rendered by `MascotLayer.svelte` as `fixed`, positioned at the chicken's screen center, **`z-[60]`** (must beat the desktop context menu's `z-50`, comfortably above `WindowLayer`'s `z-40`). - Layout: items on a circle (radius ≈ 70px) via polar `transform`s around the menu's anchor point. Open animation: buttons start scale-0 at center and transition to their polar position with `transform 120ms cubic-bezier(.2,1.4,.4,1)`, staggered ~20ms per item — pure CSS, no keyframes, reads as snappy/springy per the "snappy" requirement. - **Nesting**: selecting a node with `children` swaps the ring's contents to those children plus a center "back" button; track the breadcrumb as a local `$state` stack. - Dismissal mirrors the desktop menu's existing pattern: ``, Escape pops one level then closes on the next press; the menu's own clicks `stopPropagation()`. Clamp the ring's screen position so it never renders off-viewport (relevant near screen edges/corners). - Opened from `Mascot.svelte`'s `oncontextmenu`: `e.preventDefault(); e.stopPropagation();` then tell `MascotLayer` to open at the sprite's center (the surface's own `onSurfaceContextMenu` already gates on `currentTarget === target`, so this is defensive, not strictly required — but keep it for clarity). ## Stimulus / reaction system (`stimuli.ts`) ```ts export interface ReactionDef { id: string anim: AnimName priority: number cooldownMs: number durationMs: number interruptsSleep?: boolean effect?: () => void // e.g. grantXp(5) on eureka } export const REACTIONS: Record export function attachStimuli(emit: (r: ReactionDef) => void): () => void // ref-counted, owns subscribeEvents() ``` Initial wiring: | Source | Trigger | Reaction | |---|---|---| | `chat.ts` `streaming` | `false → true` edge, held while `true` | `thinking` (`react-think`, priority 1) | | `activity.ts` `activityLog` | new entry with `type === 'knowledge'`, detected by diffing entry ids against the last-seen set (see note above — the store is recomputed wholesale) | `eureka` (`react-eureka`, priority 2, cooldown 10s, `effect: grantXp(5)`) | | `events.ts` `liveEvents` | new head event (`id > lastSeen`) with `severity === 'critical'` or `type` starting `signal.` | `alarmed` (`react-alarm`, priority 3, cooldown 15s, `interruptsSleep: true`) | | `events.ts` `liveEvents` | new head event, `type` starting `execution.`, success-ish | `happy` (`react-happy`, priority 1, cooldown 20s) | - `attachStimuli` calls `subscribeEvents()` itself and folds its unsubscribe into the returned teardown, so the mascot keeps the SSE stream open (ref-counted alongside any page that also subscribes) only while mounted. - On first emission of `liveEvents`, just record the head event id — do not replay history as reactions on mount. - Dispatch: `emit(reaction)` checks the cooldown map and `priority >= currentReactPriority` (or current behavior isn't `dragged`), then calls `forceBehavior(rt, 'react', { anim, durationMs })`. `dragged` always wins over any reaction; `sleep` is broken only when `interruptsSleep` is true. ## Implementation order (sized for one PR each) 1. `types.ts`, `palette.ts`, `sprites.ts` (egg + chick idle/walk only), `render.ts` — pure data/functions, no UI yet. 2. `state.svelte.ts` model + debounced persistence — verify by hand via devtools console before wiring any UI. 3. `behavior.ts` FSM (egg/idle/wander/falling/dragged) + `Mascot.svelte` + `MascotLayer.svelte`, insert into `Desktop.svelte`. **First visible milestone** — an egg sits on the ground and can be dragged. 4. Hatch flow: `tickLifecycle` wired into the loop, egg→chick transition + `NameDialog.svelte`, remaining animations, `peck`/`sleep` behaviors. 5. `actions.ts` + `RadialMenu.svelte` (nested rings, open animation, dismissal). 6. `stimuli.ts` + the four reaction animations + the wiring table above. 7. Adult stage sprites + XP threshold; polish (speech/name bubble, a squash frame on `land`). 8. A short "how to add an animation / behavior / action / reaction" doc comment at the top of `sprites.ts`, `behavior.ts`, `actions.ts`, and `stimuli.ts` respectively (this plan's registry tables above become those comments, condensed). ## Verification checklist Run `npm run dev` in `web/`, then in the browser: - Egg renders on the ground at the surface bottom, wiggles occasionally, and is at the same `x` after a reload (`oikos-mascot` in localStorage — confirm it is *not* being written on every frame while merely idling or walking, only on discrete transitions). - Dragging the egg up and releasing triggers a flutter-fall back down with no tunneling below the taskbar; dragging past the surface's left/right edges clamps rather than escaping the viewport. - The debug "force hatch" action transitions egg → chick, opens the name dialog, and the chosen name persists across a reload. - The chick wanders and flips its sprite at the surface edges, pecks, and sleeps on its own; a plain click (below the 5px drag threshold) triggers a pet/hop reaction and grants a little xp. - Right-clicking the chicken opens the radial menu centered on it — and right-clicking bare desktop elsewhere still opens the *original* desktop menu, unaffected. The nested Feed submenu opens; Escape pops one level then closes on the next press; clicking outside the menu closes it; the menu stays fully on-screen when the chicken is near a corner. - With a maximized window open, the chicken visibly walks above it; window drag/resize/close still work normally when the chicken merely passes under the cursor (not when it's directly over a button being clicked — known, accepted overlap per the "renders above windows" decision). - Sending a chat message and watching it stream triggers the `thinking` animation for the duration; simulate (or trigger for real) a knowledge-graph write and confirm `eureka` fires once and respects its cooldown on a second write; simulate a critical signal and confirm `alarmed` fires even while the chicken is asleep. - Resizing the browser viewport re-grounds the chicken and keeps it within the new bounds. - Both the Terracotta and Carbon themes keep the pixel palette legible. - `npm run build` passes with no new errors or warnings beyond the pre-existing baseline. ## Risks - Z-index ordering is easy to get subtly wrong: radial menu must be `z-[60]` to beat the desktop context menu's `z-50`; the mascot layer itself is `z-45` (above `WindowLayer`'s `z-40`, below both menus). - The mascot rendering above windows means it can occlude/steal clicks on window chrome directly beneath it — accepted per the "above windows" decision; mitigate by keeping the pointer hitbox tight to the canvas element only (no oversized invisible padding). - `activityLog` is a derived store recomputed wholesale on every emission, not an append-only log — any "new entry" detection must diff entry ids between emissions, never assume the store only ever grows by appending. - `setTimeout`-driven loops can receive large `dt` spikes after tab throttling/backgrounding resumes — clamp `dt` before feeding it into physics or lifecycle ticking.