# 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 `<svelte:window onclick={closeMenu}>` + 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<OikosEvent[]>`
    (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<boolean>`.
  - `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<DashboardSummary
    | null>`, `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<char, cssColor>; '.' = transparent
sprites.ts            SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>> + 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 `<MascotLayer />` inside the surface `<div>`, after `<WindowLayer
/>`, 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<MascotStage, Partial<Record<AnimName, AnimDef>>>` 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<BehaviorId, BehaviorDef>
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<RadialAction[][]>` stack.
- Dismissal mirrors the desktop menu's existing pattern:
  `<svelte:window onclick={close}>`, 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<string, ReactionDef>
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.
