From 9f4d645d06c5b060ab927407174535e57346b4ee Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 20 Jul 2026 11:23:15 +0200 Subject: [PATCH] docs: plan a pixel-art desktop mascot ("Cluck") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/index.md | 1 + docs/mascot/README.md | 335 +++++++++++++++++++++++++ plans/2026-07-20-desktop-mascot.md | 376 +++++++++++++++++++++++++++++ plans/index.md | 1 + 4 files changed, 713 insertions(+) create mode 100644 docs/mascot/README.md create mode 100644 plans/2026-07-20-desktop-mascot.md diff --git a/docs/index.md b/docs/index.md index da3a7da..0eb82d5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,6 +8,7 @@ procedures, and the system model. | ---- | -------- | | [adr/](adr/README.md) | Architecture Decision Records (numbered, append-only) | | [mbse/](mbse/README.md) | Model-Based Systems Engineering views of the platform | +| [mascot/](mascot/README.md) | MBSE subsystem model for the desktop mascot (planned) | | [operations/](operations/README.md) | Operator runbooks (deploy, rollback, recovery) | For agent orientation see [AGENTS.md](../AGENTS.md); for the operating model diff --git a/docs/mascot/README.md b/docs/mascot/README.md new file mode 100644 index 0000000..3f65301 --- /dev/null +++ b/docs/mascot/README.md @@ -0,0 +1,335 @@ +# Oikos — Desktop Mascot Subsystem Model + +> Companion to [the platform Model](../mbse/README.md) and +> [the Framework](../mbse/framework.md). This document is a **subsystem +> Model** in Holt's sense — it conforms to the same Framework (Ontology + +> Viewpoints, Markdown + Mermaid Notation) rather than restating it, scoped +> to a single not-yet-built subsystem of the `web` component: the desktop +> mascot ("Cluck"), a pixel-art chicken that lives on the desktop shell. +> Where the platform-wide Views in [../mbse/README.md](../mbse/README.md) +> and the component View for `web/src` in +> [../mbse/components.md](../mbse/components.md#5-web-control-room) speak +> at the level of "the SPA," this document goes one layer deeper into one +> feature of it — the same relationship [components.md](../mbse/components.md) +> has to [README.md](../mbse/README.md), applied recursively. + +**Status of this Model:** the subsystem it describes does not exist in +code yet. Every View below is marked **Planned**, not **Verified** — +compare to [../mbse/README.md](../mbse/README.md)'s confidence grading, +which this document borrows. The corresponding implementation plan is +[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md). + +## Views in this model + +| # | View | Concern it addresses | +|---|---|---| +| [1](#1-mission--system-context) | Mission & System Context | Why a mascot, and what is it never allowed to do? | +| [2](#2-requirements) | Requirements | What must it do, traced from the original request? | +| [3](#3-structural-view) | Structural View | What modules make it up, and which are the extension points? | +| [4](#4-behavioral-view) | Behavioral View | How does it move, live, and react, moment to moment? | +| [5](#5-interfaces-view) | Interfaces View | What does it read from the rest of the system, and how does it persist itself? | +| [6](#6-extension-guide) | Extension Guide | How does a future engineer add an animation, behavior, menu action, or reaction? | +| [7](#7-verification-view) | Verification View | How will we know it works, once built? | + +## 1. Mission & System Context + +**Stakeholders:** the operator (delight, ambient awareness of system +state without opening a window); future engineers extending the mascot's +behaviors/reactions/menu. + +**Mission:** give the desktop shell a persistent, living presence that +makes background system activity legible at a glance — a chat streaming, +a knowledge-graph write, a critical signal — without requiring a window to +be open, while doubling as a lightweight tamagotchi for its own sake +(delight is a legitimate requirement here, not a side effect). + +**Boundary — what the mascot is, and is not:** + +- It is a **purely client-side, read-only observer**. It subscribes to + existing `web` stores (chat, activity, events, dashboard summary) the + same way any other UI component does. +- It **never calls a mutating API endpoint** and is not a new actuation + path — it has no relationship to the `run` gate, `Execution`, or + `Approval` entities described in [the platform Ontology](../mbse/ontology.md). + Its only "mutation" is its own tamagotchi state, stored client-side. +- It is scoped entirely inside the `web` component + ([../mbse/components.md §5](../mbse/components.md#5-web-control-room)); + it introduces no new backend surface, no new MCP tool, no new REST route. + +```mermaid +flowchart TB + subgraph SURFACE["Desktop shell surface (Desktop.svelte)"] + ICONS["Icon layer\nz-0"] + LAUNCH["Task launcher\nz-10"] + WIN["WindowLayer\nz-40"] + MASCOT["MascotLayer\nz-45\n(this subsystem)"] + MENU["Desktop context menu\nz-50"] + end + + MASCOT -->|subscribes, read-only| EVENTS["stores/events.ts\nliveEvents (SSE)"] + MASCOT -->|subscribes, read-only| CHAT["stores/chat.ts\nstreaming"] + MASCOT -->|subscribes, read-only| ACTIVITY["stores/activity.ts\nactivityLog"] + MASCOT -->|subscribes, read-only| CONTEXT["stores/context.ts\nsummary"] + MASCOT -->|reads/writes| LS["localStorage\noikos-mascot"] + + style MASCOT fill:#fff3e0,stroke:#e65100 +``` + +## 2. Requirements + +Traced from the original feature request. All **Planned**. + +| ID | Statement | Source | Status | +|---|---|---|---| +| MASC-1 | The mascot SHALL render as pixel-art, drawn from code (string pixel-grids + palette), not binary sprite assets | User request | Planned | +| MASC-2 | The mascot SHALL roam the desktop surface autonomously, walking along the ground (surface bottom, above the taskbar) under gravity | User request + design decision | Planned | +| MASC-3 | The mascot SHALL be draggable with the mouse; releasing it mid-air SHALL trigger a flutter-fall back to the ground | User request + design decision | Planned | +| MASC-4 | Right-clicking the mascot SHALL open a round (Sims-style) interaction menu supporting nested submenus | User request | Planned | +| MASC-5 | The mascot SHALL have a tamagotchi lifecycle: egg → chick → adult, with a user-assignable name | User request | Planned | +| MASC-6 | The mascot's stage, name, and stats SHALL persist across reloads | User request (implied by "tamagotchi") | Planned | +| MASC-7 | The mascot SHALL have idle states (autonomous behavior when untouched) and interactive states (drag, click, menu) | User request | Planned | +| MASC-8 | The mascot SHALL react visibly to real application activity: chat streaming, knowledge-graph writes, critical signals | User request ("aware of its environment... feels alive and connected") | Planned | +| MASC-9 | Animations, behaviors, menu actions, and reactions SHALL each be defined in a single data-driven registry, so a new one can be added without touching the engine code | User request ("easily expansible") | Planned | +| MASC-10 (NFR) | The mascot's game loop SHALL run at ~30fps via `setTimeout`, not `requestAnimationFrame`, matching the repo's existing [`GraphBackground.svelte`](../../web/src/lib/components/GraphBackground.svelte) convention (rAF suspends in some hidden-tab embeddings) | Codebase convention | Planned | +| MASC-11 (NFR) | The mascot SHALL never write to the API; all mutation is local (localStorage) | Design decision, this document §1 | Planned | +| MASC-12 (NFR) | Persistence writes SHALL be debounced (~300ms), never per animation frame | Codebase convention ([`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) | Planned | + +## 3. Structural View + +**Stakeholders:** an engineer implementing or extending the mascot. +**Why this View earns its place:** MASC-9 (extensibility) is only real if +the module boundaries actually separate data (registries) from engine +code; this View is the check that they do. + +```mermaid +classDiagram + class types_ts { + <> + PixelGrid + AnimName + MascotStage + BehaviorId + Stimulus + RadialAction + } + class palette_ts { + <> + PALETTE: char to CSS color + } + class sprites_ts { + <> + SPRITES: Stage to AnimName to AnimDef + resolveAnim(stage, name) + } + class render_ts { + <> + drawFrame(ctx, grid, palette, flip) + } + class state_svelte_ts { + <> + MascotModel state + grantXp() feed() pet() setName() + tickLifecycle() advanceStageIfReady() + persist (debounced, oikos-mascot) + } + class behavior_ts { + <> + BEHAVIORS: BehaviorId to BehaviorDef + stepMascot(rt, model, now, dt) + } + class stimuli_ts { + <> + REACTIONS: id to ReactionDef + attachStimuli(emit) + } + class actions_ts { + <> + MASCOT_ACTIONS: RadialAction tree + registerMascotAction() + } + class Mascot_svelte { + <> + canvas render loop 30fps + pointer drag/click/contextmenu + } + class MascotLayer_svelte { + <> + z-45 absolute overlay + hosts Mascot + RadialMenu + bubble + } + class RadialMenu_svelte { + <> + z-60 fixed, nested rings + } + class NameDialog_svelte { + <> + } + + sprites_ts --> palette_ts : indexes + sprites_ts --> types_ts : uses + Mascot_svelte --> render_ts : draws frames + Mascot_svelte --> sprites_ts : resolves anim + Mascot_svelte --> behavior_ts : steps FSM + Mascot_svelte --> state_svelte_ts : reads/mutates model + MascotLayer_svelte --> Mascot_svelte : hosts + MascotLayer_svelte --> RadialMenu_svelte : hosts, on contextmenu + MascotLayer_svelte --> stimuli_ts : attaches on mount + MascotLayer_svelte --> NameDialog_svelte : hosts, on hatch/rename + RadialMenu_svelte --> actions_ts : renders tree + stimuli_ts --> behavior_ts : forceBehavior(react) +``` + +**The four extension registries** (MASC-9's concrete answer — see also +[§6 Extension Guide](#6-extension-guide)): `SPRITES` (animations), +`BEHAVIORS` (autonomous states), `MASCOT_ACTIONS` (radial menu tree), +`REACTIONS` (environment stimuli). Each is plain data; the engine +(`behavior.ts`'s `stepMascot`, `Mascot.svelte`'s loop, `RadialMenu.svelte`'s +renderer) is generic over whatever the registry currently contains. + +**Mount point:** two lines in +[`Desktop.svelte`](../../web/src/lib/components/desktop-shell/Desktop.svelte) — +`` rendered inside the surface `
` (the `relative +min-h-0 flex-1 overflow-hidden` element), after ``, so its +`absolute inset-0` shares the surface's coordinate space and its ground +line is exactly the surface's bottom edge (= the taskbar's top edge). + +## 4. Behavioral View + +**Stakeholders:** an engineer reasoning about "what does the mascot do +right now, and why." **Why this View earns its place:** a mascot with an +implicit, ad-hoc state machine is unmaintainable the moment a second +behavior or reaction is added; this View is the state machine made +explicit before any of it is coded. + +### 4.1 Behavior FSM (moment-to-moment autonomy) + +```mermaid +stateDiagram-v2 + [*] --> egg + egg --> chick : hatchProgress reaches 1\n(advanceStageIfReady) + + state chick_and_adult_behaviors { + [*] --> idle + idle --> wander : weighted random pick\non behaviorUntil expiry + wander --> idle + idle --> peck : weighted random pick + peck --> idle + idle --> sleep : weighted random pick + sleep --> idle + wander --> falling : y below ground\n(off a dragged edge, etc.) + idle --> dragged : pointerdown + move\npast 5px threshold + wander --> dragged : pointerdown + move + sleep --> dragged : pointerdown + move\n(interrupts sleep) + dragged --> falling : pointerup, released mid-air + falling --> land : y reaches ground + land --> idle + [*] --> react : stimulus dispatched\n(priority/cooldown gated) + react --> idle : durationMs elapsed,\nreturns to prior-or-idle + } + + chick --> adult : xp reaches ADULT_XP\n(advanceStageIfReady) +``` + +`dragged` always wins over any autonomous behavior; `sleep` is broken only +by a reaction whose `ReactionDef.interruptsSleep` is true (§4.3) or by a +drag. Weighted-random idle selection (`weight` field in `BehaviorDef`) +picks the next autonomous behavior only when the current one's `next()` +returns null past `behaviorUntil` — see +[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md) +for the concrete weights. + +### 4.2 Tamagotchi lifecycle (long-lived state) + +```mermaid +stateDiagram-v2 + [*] --> egg : first load,\ndefaultModel() + egg --> chick : active time >= HATCH_MS (3min)\n+ NameDialog shown + chick --> adult : xp >= ADULT_XP (200) + adult --> [*] +``` + +This is a separate state machine from §4.1: §4.1 governs frame-to-frame +motion/animation, §4.2 governs the tamagotchi's slow-moving `MascotModel` +(persisted, ticked ~1x/sec via `tickLifecycle`, not every frame). + +### 4.3 Example sequence — an environment stimulus becomes a visible reaction + +```mermaid +sequenceDiagram + participant SSE as stores/events.ts (SSE) + participant Stim as stimuli.ts attachStimuli + participant FSM as behavior.ts + participant Mascot as Mascot.svelte (canvas) + + SSE->>Stim: liveEvents updates,\nnew head event severity=critical + Stim->>Stim: check REACTIONS['alarmed']\ncooldown + priority + Stim->>FSM: forceBehavior(rt, 'react', {anim: 'react-alarm', durationMs}) + FSM->>FSM: interrupts current behavior\n(even sleep, interruptsSleep=true) + FSM->>Mascot: rt.behavior = 'react', rt.anim = 'react-alarm' + Mascot->>Mascot: next 30fps tick draws\nreact-alarm frame + Note over FSM: after durationMs,\nnext() returns to idle +``` + +## 5. Interfaces View + +**Stakeholders:** an engineer wiring a new store into the mascot's +awareness, or auditing what it depends on. + +| Interface | Direction | Shape | Notes | +|---|---|---|---| +| [`stores/events.ts`](../../web/src/lib/stores/events.ts) `liveEvents` | consumed | `Writable`, newest-first, ref-counted via `subscribeEvents()` | `OikosEvent.type` families: `approval.*`, `signal.*`, `execution.*`, `health.changed`; `severity: 'info'\|'warning'\|'critical'` | +| [`stores/chat.ts`](../../web/src/lib/stores/chat.ts) `streaming` | consumed | `Writable` | false→true edge triggers the `thinking` reaction, held while true | +| [`stores/activity.ts`](../../web/src/lib/stores/activity.ts) `activityLog` | consumed | derived `Readable`, **recomputed wholesale** on every emission — not append-only | new entries with `type === 'knowledge'` detected by diffing entry `id`s between emissions, not by treating it as a stream | +| [`stores/context.ts`](../../web/src/lib/stores/context.ts) `summary` | consumed | `Writable` | ambient state (open signal counts via `openSignalCount(summary)`) | +| `localStorage['oikos-mascot']` | owned | `MascotModel` JSON, `{ version: 1, stage, name, hatchProgress, happiness, xp, hatchedAt, lastPos: {x}, lastSeen }` | debounced write (~300ms, mirrors [`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) + `beforeunload` flush; `version` field reserved for a future `migrate()`; multi-tab is last-writer-wins (accepted, documented, not solved) | +| [`Desktop.svelte`](../../web/src/lib/components/desktop-shell/Desktop.svelte) mount | owned | ``, 2-line insertion | see §3 | + +No interface in this table is a write path to the Oikos API — consistent +with §1's boundary statement (MASC-11). + +## 6. Extension Guide + +**Stakeholders:** a future engineer adding one new animation, behavior, +menu action, or reaction — this is the Viewpoint 4's "why" made concrete +as a recipe rather than prose (mirrors [../mbse/framework.md §7](../mbse/framework.md)'s +Process Set treatment). + +| To add a... | Touch only | Nothing else changes because | +|---|---|---| +| **Animation** | Add the name to the `AnimName` union in `types.ts`; add frames to `SPRITES[stage]` in `sprites.ts` | `resolveAnim()` and the renderer are generic over the registry | +| **Behavior** | Add the id to `BehaviorId`; add one `BehaviorDef` entry to `BEHAVIORS` in `behavior.ts` | `stepMascot()` and the weighted-random idle selector consume `BEHAVIORS` generically | +| **Radial menu action** | Add a `RadialAction` node to `MASCOT_ACTIONS` in `actions.ts` (or call `registerMascotAction()`), optionally nested under `children` | `RadialMenu.svelte` renders whatever tree it's given, including nesting depth | +| **Environment reaction** | Add a `ReactionDef` to `REACTIONS` in `stimuli.ts`; wire one `store subscription -> predicate -> emit(reaction)` block inside `attachStimuli()` | priority/cooldown/interrupt dispatch logic in `attachStimuli()` is generic over `REACTIONS` | + +## 7. Verification View + +**Stakeholders:** whoever implements this subsystem and needs to know +when it's actually done, not just compiled. + +Manual browser checklist (no automated test harness planned for v1 — see +[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md) +for the same list in implementation-order context): + +- Egg renders grounded at the surface bottom, wiggles occasionally, survives + a reload at the same x (confirm `oikos-mascot` is debounced — no writes + fire from mere walking, only from discrete transitions). +- Dragging the egg up and releasing triggers a flutter-fall with no + tunneling below the taskbar; dragging past the surface edges clamps. +- Forcing hatch (debug menu action) transitions to chick, opens the name + dialog, and the name persists across reload. +- Chick wanders and flips sprite at surface edges, pecks, sleeps + autonomously; a plain click (no drag) triggers a pet/hop reaction. +- Right-clicking the chicken opens the radial menu centered on it, without + triggering the desktop's own right-click menu; a nested submenu (Feed) + opens correctly; Escape pops one level then closes; an outside click + closes it; the menu stays fully visible when the chicken is near a + screen edge or corner. +- With one or more windows open (including a maximized one), the chicken + visibly walks above them without breaking window drag/resize/close. +- Starting a chat and observing it stream triggers the `thinking` reaction + for the duration; a simulated knowledge-graph write triggers `eureka` + once per cooldown window; a simulated critical signal triggers `alarmed` + even while the chicken is asleep. +- Resizing the browser viewport re-grounds and re-clamps the chicken. +- Both the Terracotta and Carbon themes keep the pixel-art palette legible. +- `npm run build` passes with no new errors. diff --git a/plans/2026-07-20-desktop-mascot.md b/plans/2026-07-20-desktop-mascot.md new file mode 100644 index 0000000..9e022c5 --- /dev/null +++ b/plans/2026-07-20-desktop-mascot.md @@ -0,0 +1,376 @@ +# 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](../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 `` + 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` + (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`. + - `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`, `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; '.' = transparent +sprites.ts SPRITES: Record>> + 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 `` inside the surface `
`, after ``, 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>>` 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 +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`) + +```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`) + +```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` stack. +- Dismissal mirrors the desktop menu's existing pattern: + ``, 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 +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. diff --git a/plans/index.md b/plans/index.md index c96dac5..eeecea6 100644 --- a/plans/index.md +++ b/plans/index.md @@ -18,6 +18,7 @@ went sideways, open an investigation. | 2026-07-14 | [Activity timeline](2026-07-14-activity-timeline.md) | In Progress | | 2026-07-17 | [Codebase review, lint audit, and documentation maintenance](2026-07-17-codebase-review-and-cleanup.md) | Report delivered — doc/tooling fixes applied; code refactors pending | | 2026-07-18 | [Session review: three recent sessions](2026-07-18-session-review-three-sessions.md) | Implemented in v0.7.12 — P0.1/P0.2/P1.3/P1.4/P1.5/P1.6/P1.8/P2.10; P1.7 and P2.9 deferred (retry cap covers) | +| 2026-07-20 | [Desktop mascot ("Cluck")](2026-07-20-desktop-mascot.md) | Planned — not started | ## Done