Implements plans/2026-07-20-desktop-mascot.md. New code under web/src/lib/mascot/ (types/sprites/render/state/behavior/actions/ stimuli + Mascot/MascotLayer/RadialMenu/NameDialog components) plus CC0 sprite sheets at web/public/mascot/ (chicken + Onocentaur egg pack + reaction bubbles). MascotLayer is inserted into Desktop.svelte after WindowLayer; <2-line integration. Tamagotchi: egg -> chick -> adult lifecycle persisted to localStorage['oikos-mascot'] (debounced 300ms). Egg hatches on first naming (no timed incubation per implementation deviation). Chick/adult wander, peck, sleep, blink autonomously via a weighted-random FSM; the chicken walks above windows (ground line = highest window top edge beneath its x, recomputed each tick from wmState; rides the ground when the window beneath is dragged). Interaction: draggable with flutter-fall physics on release mid-air; plain click = pet (heart bubble + happy anim); right-click opens a rounded-button radial menu (Interact/Care/Identity/Debug nested groups) mirroring the desktop's own right-click menu styling; auto-flips above/ left near screen edges. Awareness: stimulus bus subscribes to chat.ts streaming, activity.ts activityLog (knowledge-entry diff), events.ts liveEvents (critical/ signal -> alarmed, execution -> happy), with priority+cooldown gating. Egg-stage reactions are suppressed. Reaction bubbles are anti-aliased. Sprite loop runs at ~60fps via setTimeout (not rAF) per GraphBackground convention, dt clamped to 100ms; position via transform: translate3d + will-change: transform for compositor-friendly motion. Z-index ordering: WindowLayer z-40 < MascotLayer z-[45] < desktop context menu z-50 < RadialMenu/NameDialog z-[60]. Docs: plan + docs/mascot/README.md (MBSE subsystem model) updated to Implemented with a deviations note covering hatch-on-naming, PNG-sheet art, button-column radial menu, 60fps loop, egg-reaction suppression, and window-walking ground model. VERSION bumped 0.7.13 -> 0.8.0.
22 KiB
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_MSis gone,tickLifecycleno longer advanceshatchProgress, and the egg no longer plays a progressiveegg-crackanimation — it sits onegg-idleuntil named.hatchProgressis retained as a binary 0/1 flag soadvanceStageIfReady()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.tswas removed;render.tsslices 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. ThesetTimeout-not-rAFconvention is preserved;dtis still clamped to 100ms. Position is applied viatransform: translate3d
will-change: transform(compositor layer) instead of CSSleft/topto 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 menuz-[60](must beat the desktop's own right-click menu, which isz-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 layerz-0,TaskLauncherwrapperz-10,WindowLayerz-40— every wrapper ispointer-events-nonewith interactive children re-enablingpointer-events-auto. The desktop's own right-click menu isfixed z-50, dismissed via<svelte:window onclick={closeMenu}>+ Escape. Bare-surface clicks are gated withe.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 (notwindow), position blended via$derivedbetween rest and drag-in-progress values. - Game loop convention:
GraphBackground.sveltedrives its canvas withsetTimeout(() => draw(performance.now()), 33)(~30fps), notrequestAnimationFrame— the code comment there explains some embedding contexts reportdocument.hidden=trueand suspend rAF, which would freeze the animation;setTimeoutkeeps ticking. Follow this for the mascot loop, and clampdtto 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'spersist(wm, { key: 'oikos-windows', debounce: 300, autoRestore: true })— mirror the 300ms debounce foroikos-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.tsmodule with module-level$stateplus exported getter/mutator functions —web/src/lib/stores/theme.svelte.tsis 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 subscriptionsubscribeEvents().OikosEvent.typefamilies:approval.*,signal.*,execution.*,health.changed;severity: 'info' | 'warning' | 'critical'.web/src/lib/stores/chat.ts—streaming: Writable<boolean>.web/src/lib/stores/activity.ts—activityLogis a derived store recomputed wholesale frommessages/planSteps/currentTaskon every emission, not an append-only log — detecting a "new" entry (e.g.type === 'knowledge') requires diffing entryids 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, norequestAnimationFrame, 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'sidleand 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 = falseset once. Horizontal facing flip viactx.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 ifloop.
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 isfalling:vy += GRAVITY * dt, capped at a slow flutter terminal velocity, animfall-flutterwith occasionalflap; on reaching ground, snapy, briefland, thenidle. - Wander: constant
vx = facing * ~40px/s, flipfacingat the surface margins. - Idle selection: when
now > behaviorUntiland the current behavior'snext()returns null, roll a weighted random pick overBEHAVIORSentries that declareweight— starting weights: idle 3, wander 4, peck 2, sleep 1. - Non-self-selecting behaviors (
dragged,falling,react) have noweightand are entered only viaforceBehavior()— pointer code calls it fordragged, gravity logic forfalling, the stimulus bus forreact. - Egg stage:
behaviorlocked to'egg'(rendersegg-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,dtclamped 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()parseslocalStorage['oikos-mascot'], checksversion, falls back todefaultModel()on mismatch/corruption.migrate(raw): MascotModelis a stub switch onversionfor 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 abeforeunloadflush so a quick reload doesn't lose a rename.lastPos.xis 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 setshatchProgress=1andsetStage('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 thestorageevent 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.svelteasfixed, positioned at the chicken's screen center,z-[60](must beat the desktop context menu'sz-50, comfortably aboveWindowLayer'sz-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 withtransform 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
childrenswaps 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 clicksstopPropagation(). Clamp the ring's screen position so it never renders off-viewport (relevant near screen edges/corners). - Opened from
Mascot.svelte'soncontextmenu:e.preventDefault(); e.stopPropagation();then tellMascotLayerto open at the sprite's center (the surface's ownonSurfaceContextMenualready gates oncurrentTarget === 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) |
attachStimulicallssubscribeEvents()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 andpriority >= currentReactPriority(or current behavior isn'tdragged), then callsforceBehavior(rt, 'react', { anim, durationMs }).draggedalways wins over any reaction;sleepis broken only wheninterruptsSleepis true.
Implementation order (sized for one PR each)
types.ts,palette.ts,sprites.ts(egg + chick idle/walk only),render.ts— pure data/functions, no UI yet.state.svelte.tsmodel + debounced persistence — verify by hand via devtools console before wiring any UI.behavior.tsFSM (egg/idle/wander/falling/dragged) +Mascot.svelte+MascotLayer.svelte, insert intoDesktop.svelte. First visible milestone — an egg sits on the ground and can be dragged.- Hatch flow:
tickLifecyclewired into the loop, egg→chick transition +NameDialog.svelte, remaining animations,peck/sleepbehaviors. actions.ts+RadialMenu.svelte(nested rings, open animation, dismissal).stimuli.ts+ the four reaction animations + the wiring table above.- Adult stage sprites + XP threshold; polish (speech/name bubble, a
squash frame on
land). - A short "how to add an animation / behavior / action / reaction" doc
comment at the top of
sprites.ts,behavior.ts,actions.ts, andstimuli.tsrespectively (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
xafter a reload (oikos-mascotin 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
thinkinganimation for the duration; simulate (or trigger for real) a knowledge-graph write and confirmeurekafires once and respects its cooldown on a second write; simulate a critical signal and confirmalarmedfires 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 buildpasses 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'sz-50; the mascot layer itself isz-45(aboveWindowLayer'sz-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).
activityLogis 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 largedtspikes after tab throttling/backgrounding resumes — clampdtbefore feeding it into physics or lifecycle ticking.