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>
19 KiB
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 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'(periodicegg-wiggle,egg-crackashatchProgressnears 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,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 // 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()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. - 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.- 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.