Files
oikos/plans/2026-07-20-desktop-mascot.md
dtoro 9f4d645d06
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: plan a pixel-art desktop mascot ("Cluck")
Design-only (no code yet): an MBSE subsystem model for a chicken mascot
that roams the desktop shell, is draggable, opens a Sims-style nested
radial menu, and has a tamagotchi lifecycle (egg -> chick -> adult) that
reacts to real app activity (chat streaming, knowledge-graph writes,
signals). Everything (animations, autonomous behaviors, menu actions,
environment reactions) is scoped as a data-driven registry for easy
extension.

- docs/mascot/README.md: subsystem Model conforming to docs/mbse's
  Holt-based Framework — mission/boundary, requirements, structural view
  (module registry map), behavioral view (behavior FSM + lifecycle state
  machines + a stimulus sequence diagram), interfaces view (which web
  stores it observes, read-only), extension guide, verification view.
- plans/2026-07-20-desktop-mascot.md: the concrete file-by-file
  implementation plan for web/src/lib/mascot/ derived from the model,
  with an ordered build sequence and a manual browser verification
  checklist.
- Indexed both in docs/index.md and plans/index.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 11:23:15 +02:00

19 KiB
Raw Blame History

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 — 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' (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)

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)

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.
  • 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.