# 2026-07-20 — Desktop mascot ("Cluck") **Status:** Implemented > **Deviations from the original plan, applied 2026-07-20 during > implementation:** > - **Hatching is no longer timed.** The egg → chick transition fires > once, on first naming (the name dialog opens on first mount of a > fresh egg; submitting it calls `forceHatch()`). `HATCH_MS` is gone, > `tickLifecycle` no longer advances `hatchProgress`, and the egg no > longer plays a progressive `egg-crack` animation — it sits on > `egg-idle` until named. `hatchProgress` is retained as a binary > 0/1 flag so `advanceStageIfReady()` and the debug "Force hatch" > action still work. > - **Sprite art is PNG-sheet-based, not code-drawn pixel grids.** The > chicken comes from a CC0 16x16 sprite-sheet pack at > `web/public/mascot/`; the egg comes from the Onocentaur egg pack > (also CC0). `palette.ts` was removed; `render.ts` slices 16x16 > frames from sheets instead of painting string grids. Chick and > adult share sheets (distinguished only by render scale) until > distinct adult art is added. > - **The radial menu is a rounded-button column, not a circular > ring.** The plan's polar-layout ring was found to hide labels; the > menu now mirrors the desktop's own right-click menu styling > (full-text buttons, nested via a "Back" breadcrumb). > - **The sprite loop runs at ~60fps** (16ms `setTimeout`), not 30fps. > Drag and fall motion at 30fps looked choppy on 60Hz+ displays. The > `setTimeout`-not-`rAF` convention is preserved; `dt` is still > clamped to 100ms. Position is applied via `transform: translate3d` > + `will-change: transform` (compositor layer) instead of CSS > `left`/`top` to avoid per-frame layout reflow. > - **Egg-stage reactions are suppressed.** The stimulus bus still > subscribes to chat/activity/events while the egg is on screen, but > MascotLayer's emit callback drops any reaction when > `model.stage === 'egg'` — the egg isn't "alive" yet, so playing > alarm/eureka animations behind the naming dialog would be jarring. > - **The mascot walks on top of windows.** The ground line is > recomputed each tick from `wmState`: it's the top edge of the > highest non-minimized window whose horizontal span covers the > mascot's x, or the surface bottom when no window is beneath. When > the mascot strolls over a window, the ground rises to that > window's top edge; when it walks off the side, the ground drops > and it flutter-falls to the next surface beneath (another window, > or the desktop). This generalizes the original "walks along the > desktop surface's bottom edge" decision to a multi-surface model. ## 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'` (renders `egg-idle`, wiggles gently via a render-time transform); dragging is still allowed (the egg can be picked up and moved). The egg → chick transition fires once, on first naming — see the deviation note at the top of this plan. - 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 // binary 0/1: 0 until first naming, 1 after (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 capping passive decay } 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 export function forceHatch(): void // called by the name-dialog submit handler on first naming ``` - `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. - The egg → chick transition is **not** timed: a fresh egg (stage=egg, name=null) opens the name dialog on mount; submitting it calls `forceHatch()` which sets `hatchProgress=1` and `setStage('chick')`. Returning users with a named mascot skip the dialog. See the deviation note at the top of this plan. - 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. - **Egg-stage reactions are suppressed.** MascotLayer's stimulus callback drops any reaction when `model.stage === 'egg'` — the egg isn't "alive" yet (no name, no hatched chick to react), so stimulus events are silently ignored until the egg hatches. This keeps the egg calm during the naming dialog rather than playing alarm animations behind it. - 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.