Files
oikos/plans/done/2026-07-20-desktop-mascot.md
dtoro 195d45a0e9
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
docs(plans): reconcile plan statuses; archive 10 done plans
Move ten completed plans from plans/ to plans/done/ and update the index:
- 2026-07-18 session-review-three-sessions, 2026-07-20 desktop-mascot,
  2026-07-20 session-review-ten-sessions, 2026-07-21 chat-full-polish,
  2026-07-29 health-check-reality-and-knowledge-graph,
  2026-07-30 session-review-plan-drift, and the four 2026-08-03 chat plans
  (changes-review, reliability-and-ux-audit, cyberspace-style-adoption,
  working-visibility).
- Refresh two stale statuses: cyberspace-style-adoption ("Draft" -> shipped as
  full replacement in v0.16.0/757ef2f) and health-check-reality ("ready for
  implementation" -> shipped across the v0.14.x-0.16.x check commits).
- .gitignore: ignore local tooling artifacts (.playwright-mcp/, config-screen.png).

No code change. index.md Active/Done tables now match the filesystem (no orphans).

VERSION: 0.17.0 -> 0.17.1
2026-08-03 22:52:25 +02:00

22 KiB
Raw Blame History

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 — 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.sveltepointerdown + 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.tsliveEvents: 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.tsstreaming: Writable<boolean>.
    • web/src/lib/stores/activity.tsactivityLog 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 ids against the previous emission, not just reacting to the store firing.
    • web/src/lib/stores/context.tssummary: 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.:

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 (24 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)

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)

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)

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 transforms 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)

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.