Move ten completed plans from plans/ to plans/done/ and update the index:
- 2026-07-18 session-review-three-sessions, 2026-07-20 desktop-mascot,
2026-07-20 session-review-ten-sessions, 2026-07-21 chat-full-polish,
2026-07-29 health-check-reality-and-knowledge-graph,
2026-07-30 session-review-plan-drift, and the four 2026-08-03 chat plans
(changes-review, reliability-and-ux-audit, cyberspace-style-adoption,
working-visibility).
- Refresh two stale statuses: cyberspace-style-adoption ("Draft" -> shipped as
full replacement in v0.16.0/757ef2f) and health-check-reality ("ready for
implementation" -> shipped across the v0.14.x-0.16.x check commits).
- .gitignore: ignore local tooling artifacts (.playwright-mcp/, config-screen.png).
No code change. index.md Active/Done tables now match the filesystem (no orphans).
VERSION: 0.17.0 -> 0.17.1
432 lines
22 KiB
Markdown
432 lines
22 KiB
Markdown
# 2026-07-20 — Desktop mascot ("Cluck")
|
||
|
||
**Status:** Implemented
|
||
|
||
> **Deviations from the original plan, applied 2026-07-20 during
|
||
> implementation:**
|
||
> - **Hatching is no longer timed.** The egg → chick transition fires
|
||
> once, on first naming (the name dialog opens on first mount of a
|
||
> fresh egg; submitting it calls `forceHatch()`). `HATCH_MS` is gone,
|
||
> `tickLifecycle` no longer advances `hatchProgress`, and the egg no
|
||
> longer plays a progressive `egg-crack` animation — it sits on
|
||
> `egg-idle` until named. `hatchProgress` is retained as a binary
|
||
> 0/1 flag so `advanceStageIfReady()` and the debug "Force hatch"
|
||
> action still work.
|
||
> - **Sprite art is PNG-sheet-based, not code-drawn pixel grids.** The
|
||
> chicken comes from a CC0 16x16 sprite-sheet pack at
|
||
> `web/public/mascot/`; the egg comes from the Onocentaur egg pack
|
||
> (also CC0). `palette.ts` was removed; `render.ts` slices 16x16
|
||
> frames from sheets instead of painting string grids. Chick and
|
||
> adult share sheets (distinguished only by render scale) until
|
||
> distinct adult art is added.
|
||
> - **The radial menu is a rounded-button column, not a circular
|
||
> ring.** The plan's polar-layout ring was found to hide labels; the
|
||
> menu now mirrors the desktop's own right-click menu styling
|
||
> (full-text buttons, nested via a "Back" breadcrumb).
|
||
> - **The sprite loop runs at ~60fps** (16ms `setTimeout`), not 30fps.
|
||
> Drag and fall motion at 30fps looked choppy on 60Hz+ displays. The
|
||
> `setTimeout`-not-`rAF` convention is preserved; `dt` is still
|
||
> clamped to 100ms. Position is applied via `transform: translate3d`
|
||
> + `will-change: transform` (compositor layer) instead of CSS
|
||
> `left`/`top` to avoid per-frame layout reflow.
|
||
> - **Egg-stage reactions are suppressed.** The stimulus bus still
|
||
> subscribes to chat/activity/events while the egg is on screen, but
|
||
> MascotLayer's emit callback drops any reaction when
|
||
> `model.stage === 'egg'` — the egg isn't "alive" yet, so playing
|
||
> alarm/eureka animations behind the naming dialog would be jarring.
|
||
> - **The mascot walks on top of windows.** The ground line is
|
||
> recomputed each tick from `wmState`: it's the top edge of the
|
||
> highest non-minimized window whose horizontal span covers the
|
||
> mascot's x, or the surface bottom when no window is beneath. When
|
||
> the mascot strolls over a window, the ground rises to that
|
||
> window's top edge; when it walks off the side, the ground drops
|
||
> and it flutter-falls to the next surface beneath (another window,
|
||
> or the desktop). This generalizes the original "walks along the
|
||
> desktop surface's bottom edge" decision to a multi-surface model.
|
||
|
||
## Why
|
||
|
||
The web control room is an OS-style desktop shell (icons, floating
|
||
windows, taskbar) but has no ambient, always-visible signal of what the
|
||
system is doing — you have to open a window to see a chat streaming, a
|
||
knowledge-graph write, or a critical signal land. The user asked for a
|
||
pixel-art chicken mascot that roams the desktop, is draggable and
|
||
interactable (Sims-style radial right-click menu with nested actions), and
|
||
is itself a tamagotchi (egg → chick → adult, nameable, persistent) that
|
||
visibly reacts to real app activity. This plan is scaffolding: every piece
|
||
(sprites, autonomous behaviors, menu actions, environment reactions) is a
|
||
data-driven registry so each can be extended independently later without
|
||
touching the engine code.
|
||
|
||
The MBSE subsystem model for this feature (Mission, Requirements,
|
||
Structural/Behavioral/Interfaces views, Verification) lives at
|
||
[docs/mascot/README.md](../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.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<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.ts` — `streaming: Writable<boolean>`.
|
||
- `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<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.:
|
||
|
||
```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<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 (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<BehaviorId, BehaviorDef>
|
||
export function stepMascot(rt: MascotRuntime, model: MascotModel, now: number, dt: number): void
|
||
export function forceBehavior(rt: MascotRuntime, id: BehaviorId, opts?: { anim?: AnimName; durationMs?: number }): void
|
||
```
|
||
|
||
- **Ground/gravity**: `GROUND_Y = bounds.h`. When above ground and not
|
||
dragged, behavior is `falling`: `vy += GRAVITY * dt`, capped at a slow
|
||
flutter terminal velocity, anim `fall-flutter` with occasional `flap`;
|
||
on reaching ground, snap `y`, brief `land`, then `idle`.
|
||
- **Wander**: constant `vx = facing * ~40px/s`, flip `facing` at the
|
||
surface margins.
|
||
- **Idle selection**: when `now > behaviorUntil` and the current
|
||
behavior's `next()` returns null, roll a weighted random pick over
|
||
`BEHAVIORS` entries that declare `weight` — starting weights: idle 3,
|
||
wander 4, peck 2, sleep 1.
|
||
- **Non-self-selecting behaviors** (`dragged`, `falling`, `react`) have no
|
||
`weight` and are entered only via `forceBehavior()` — pointer code calls
|
||
it for `dragged`, gravity logic for `falling`, the stimulus bus for
|
||
`react`.
|
||
- **Egg stage**: `behavior` locked to `'egg'` (renders `egg-idle`,
|
||
wiggles gently via a render-time transform); dragging is still
|
||
allowed (the egg can be picked up and moved). The egg → chick
|
||
transition fires once, on first naming — see the deviation note at
|
||
the top of this plan.
|
||
- Loop lives in `Mascot.svelte`: `setTimeout(() => tick(performance.now()),
|
||
33)` inside an `$effect`, cleared on teardown, `dt` clamped to 100ms.
|
||
|
||
## Tamagotchi model (`state.svelte.ts`)
|
||
|
||
```ts
|
||
export type MascotStage = 'egg' | 'chick' | 'adult'
|
||
export interface MascotModel {
|
||
version: 1
|
||
stage: MascotStage
|
||
name: string | null
|
||
hatchProgress: number // binary 0/1: 0 until first naming, 1 after (egg stage only)
|
||
happiness: number // 0..100, slow decay, boosted by pet/feed
|
||
xp: number // chick -> adult growth hook
|
||
hatchedAt: number | null
|
||
lastPos: { x: number } | null
|
||
lastSeen: number // for capping passive decay
|
||
}
|
||
export const ADULT_XP = 200
|
||
export function grantXp(n: number): void
|
||
export function feed(): void
|
||
export function pet(): void
|
||
export function setName(name: string): void
|
||
export function tickLifecycle(dt: number): void // called ~1x/sec, not per frame
|
||
export function advanceStageIfReady(): void
|
||
export function forceHatch(): void // called by the name-dialog submit handler on first naming
|
||
```
|
||
|
||
- `load()` parses `localStorage['oikos-mascot']`, checks `version`, falls
|
||
back to `defaultModel()` on mismatch/corruption. `migrate(raw):
|
||
MascotModel` is a stub switch on `version` for future schema changes —
|
||
v1 has no migrations to perform, the stub just documents where they go.
|
||
- Every mutator calls a shared `schedulePersist()` — a 300ms trailing
|
||
debounce, plus a `beforeunload` flush so a quick reload doesn't lose a
|
||
rename. `lastPos.x` is written only on behavior transitions and
|
||
drag-end, never per frame.
|
||
- The egg → chick transition is **not** timed: a fresh egg (stage=egg,
|
||
name=null) opens the name dialog on mount; submitting it calls
|
||
`forceHatch()` which sets `hatchProgress=1` and `setStage('chick')`.
|
||
Returning users with a named mascot skip the dialog. See the deviation
|
||
note at the top of this plan.
|
||
- Multi-tab races (two tabs both writing `oikos-mascot`) are
|
||
last-writer-wins — accepted for this scaffolding, not solved; a future
|
||
pass could listen to the `storage` event if it becomes a real problem.
|
||
|
||
## Radial menu (`actions.ts`, `RadialMenu.svelte`)
|
||
|
||
```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<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`)
|
||
|
||
```ts
|
||
export interface ReactionDef {
|
||
id: string
|
||
anim: AnimName
|
||
priority: number
|
||
cooldownMs: number
|
||
durationMs: number
|
||
interruptsSleep?: boolean
|
||
effect?: () => void // e.g. grantXp(5) on eureka
|
||
}
|
||
export const REACTIONS: Record<string, ReactionDef>
|
||
export function attachStimuli(emit: (r: ReactionDef) => void): () => void // ref-counted, owns subscribeEvents()
|
||
```
|
||
|
||
Initial wiring:
|
||
|
||
| Source | Trigger | Reaction |
|
||
|---|---|---|
|
||
| `chat.ts` `streaming` | `false → true` edge, held while `true` | `thinking` (`react-think`, priority 1) |
|
||
| `activity.ts` `activityLog` | new entry with `type === 'knowledge'`, detected by diffing entry ids against the last-seen set (see note above — the store is recomputed wholesale) | `eureka` (`react-eureka`, priority 2, cooldown 10s, `effect: grantXp(5)`) |
|
||
| `events.ts` `liveEvents` | new head event (`id > lastSeen`) with `severity === 'critical'` or `type` starting `signal.` | `alarmed` (`react-alarm`, priority 3, cooldown 15s, `interruptsSleep: true`) |
|
||
| `events.ts` `liveEvents` | new head event, `type` starting `execution.`, success-ish | `happy` (`react-happy`, priority 1, cooldown 20s) |
|
||
|
||
- `attachStimuli` calls `subscribeEvents()` itself and folds its
|
||
unsubscribe into the returned teardown, so the mascot keeps the SSE
|
||
stream open (ref-counted alongside any page that also subscribes) only
|
||
while mounted.
|
||
- **Egg-stage reactions are suppressed.** MascotLayer's stimulus
|
||
callback drops any reaction when `model.stage === 'egg'` — the egg
|
||
isn't "alive" yet (no name, no hatched chick to react), so stimulus
|
||
events are silently ignored until the egg hatches. This keeps the egg
|
||
calm during the naming dialog rather than playing alarm animations
|
||
behind it.
|
||
- On first emission of `liveEvents`, just record the head event id — do
|
||
not replay history as reactions on mount.
|
||
- Dispatch: `emit(reaction)` checks the cooldown map and
|
||
`priority >= currentReactPriority` (or current behavior isn't
|
||
`dragged`), then calls `forceBehavior(rt, 'react', { anim,
|
||
durationMs })`. `dragged` always wins over any reaction; `sleep` is
|
||
broken only when `interruptsSleep` is true.
|
||
|
||
## Implementation order (sized for one PR each)
|
||
|
||
1. `types.ts`, `palette.ts`, `sprites.ts` (egg + chick idle/walk only),
|
||
`render.ts` — pure data/functions, no UI yet.
|
||
2. `state.svelte.ts` model + debounced persistence — verify by hand via
|
||
devtools console before wiring any UI.
|
||
3. `behavior.ts` FSM (egg/idle/wander/falling/dragged) + `Mascot.svelte` +
|
||
`MascotLayer.svelte`, insert into `Desktop.svelte`. **First visible
|
||
milestone** — an egg sits on the ground and can be dragged.
|
||
4. Hatch flow: `tickLifecycle` wired into the loop, egg→chick transition +
|
||
`NameDialog.svelte`, remaining animations, `peck`/`sleep` behaviors.
|
||
5. `actions.ts` + `RadialMenu.svelte` (nested rings, open animation,
|
||
dismissal).
|
||
6. `stimuli.ts` + the four reaction animations + the wiring table above.
|
||
7. Adult stage sprites + XP threshold; polish (speech/name bubble, a
|
||
squash frame on `land`).
|
||
8. A short "how to add an animation / behavior / action / reaction" doc
|
||
comment at the top of `sprites.ts`, `behavior.ts`, `actions.ts`, and
|
||
`stimuli.ts` respectively (this plan's registry tables above become
|
||
those comments, condensed).
|
||
|
||
## Verification checklist
|
||
|
||
Run `npm run dev` in `web/`, then in the browser:
|
||
|
||
- Egg renders on the ground at the surface bottom, wiggles occasionally,
|
||
and is at the same `x` after a reload (`oikos-mascot` in localStorage —
|
||
confirm it is *not* being written on every frame while merely idling or
|
||
walking, only on discrete transitions).
|
||
- Dragging the egg up and releasing triggers a flutter-fall back down with
|
||
no tunneling below the taskbar; dragging past the surface's left/right
|
||
edges clamps rather than escaping the viewport.
|
||
- The debug "force hatch" action transitions egg → chick, opens the name
|
||
dialog, and the chosen name persists across a reload.
|
||
- The chick wanders and flips its sprite at the surface edges, pecks, and
|
||
sleeps on its own; a plain click (below the 5px drag threshold) triggers
|
||
a pet/hop reaction and grants a little xp.
|
||
- Right-clicking the chicken opens the radial menu centered on it — and
|
||
right-clicking bare desktop elsewhere still opens the *original* desktop
|
||
menu, unaffected. The nested Feed submenu opens; Escape pops one level
|
||
then closes on the next press; clicking outside the menu closes it;
|
||
the menu stays fully on-screen when the chicken is near a corner.
|
||
- With a maximized window open, the chicken visibly walks above it; window
|
||
drag/resize/close still work normally when the chicken merely passes
|
||
under the cursor (not when it's directly over a button being clicked —
|
||
known, accepted overlap per the "renders above windows" decision).
|
||
- Sending a chat message and watching it stream triggers the `thinking`
|
||
animation for the duration; simulate (or trigger for real) a
|
||
knowledge-graph write and confirm `eureka` fires once and respects its
|
||
cooldown on a second write; simulate a critical signal and confirm
|
||
`alarmed` fires even while the chicken is asleep.
|
||
- Resizing the browser viewport re-grounds the chicken and keeps it
|
||
within the new bounds.
|
||
- Both the Terracotta and Carbon themes keep the pixel palette legible.
|
||
- `npm run build` passes with no new errors or warnings beyond the
|
||
pre-existing baseline.
|
||
|
||
## Risks
|
||
|
||
- Z-index ordering is easy to get subtly wrong: radial menu must be
|
||
`z-[60]` to beat the desktop context menu's `z-50`; the mascot layer
|
||
itself is `z-45` (above `WindowLayer`'s `z-40`, below both menus).
|
||
- The mascot rendering above windows means it can occlude/steal clicks on
|
||
window chrome directly beneath it — accepted per the "above windows"
|
||
decision; mitigate by keeping the pointer hitbox tight to the canvas
|
||
element only (no oversized invisible padding).
|
||
- `activityLog` is a derived store recomputed wholesale on every
|
||
emission, not an append-only log — any "new entry" detection must diff
|
||
entry ids between emissions, never assume the store only ever grows by
|
||
appending.
|
||
- `setTimeout`-driven loops can receive large `dt` spikes after tab
|
||
throttling/backgrounding resumes — clamp `dt` before feeding it into
|
||
physics or lifecycle ticking.
|