vu1n commited on
Commit
3c124f3
·
0 Parent(s):

Puck — desktop fairy familiar (HF Build Small)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +19 -0
  2. .env.example +21 -0
  3. .gitignore +29 -0
  4. BACKLOG.md +238 -0
  5. Dockerfile +45 -0
  6. README.md +58 -0
  7. frontend/.gitignore +24 -0
  8. frontend/README.md +73 -0
  9. frontend/biome.json +45 -0
  10. frontend/bun.lock +371 -0
  11. frontend/index.html +13 -0
  12. frontend/package.json +32 -0
  13. frontend/public/favicon.svg +1 -0
  14. frontend/public/icons.svg +24 -0
  15. frontend/scripts/export-deck.ts +13 -0
  16. frontend/src/engine/chat.ts +38 -0
  17. frontend/src/engine/engine.test.ts +338 -0
  18. frontend/src/engine/events.ts +260 -0
  19. frontend/src/engine/index.ts +22 -0
  20. frontend/src/engine/learning.ts +213 -0
  21. frontend/src/engine/nightBloom.ts +93 -0
  22. frontend/src/engine/reactions.test.ts +77 -0
  23. frontend/src/engine/reactions.ts +146 -0
  24. frontend/src/engine/scoring.ts +30 -0
  25. frontend/src/engine/speech.ts +56 -0
  26. frontend/src/engine/state.ts +76 -0
  27. frontend/src/engine/traces.test.ts +63 -0
  28. frontend/src/engine/traces.ts +54 -0
  29. frontend/src/engine/types.ts +174 -0
  30. frontend/src/engine/util.ts +18 -0
  31. frontend/src/engine/wire.test.ts +86 -0
  32. frontend/src/engine/wire.ts +96 -0
  33. frontend/src/lib/brain.ts +49 -0
  34. frontend/src/lib/daemon.ts +47 -0
  35. frontend/src/lib/debuglog.ts +39 -0
  36. frontend/src/lib/memories.ts +63 -0
  37. frontend/src/lib/molt.ts +24 -0
  38. frontend/src/lib/notify.ts +23 -0
  39. frontend/src/lib/settings.ts +70 -0
  40. frontend/src/lib/storage.ts +39 -0
  41. frontend/src/lib/tauri.ts +154 -0
  42. frontend/src/lib/traces.ts +31 -0
  43. frontend/src/lib/useDrag.ts +39 -0
  44. frontend/src/lib/useIdle.ts +29 -0
  45. frontend/src/lib/vision.ts +137 -0
  46. frontend/src/lib/voice.ts +421 -0
  47. frontend/src/main.tsx +22 -0
  48. frontend/src/modes/sim/SimApp.tsx +1021 -0
  49. frontend/src/styles/base.css +601 -0
  50. frontend/src/styles/ui.css +2175 -0
.dockerignore ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Keep the build context lean — only the server, schema, and frontend source are needed.
2
+ # (desktop/ holds a 4GB Tauri target; node_modules/.venv are reinstalled in-image.)
3
+ **/node_modules
4
+ **/.venv
5
+ **/target
6
+ **/__pycache__
7
+ *.pyc
8
+ desktop
9
+ recognize
10
+ molt/data
11
+ server/data
12
+ frontend/dist
13
+ .git
14
+ puck-handoff
15
+ *.zip
16
+ *.aiff
17
+ .env
18
+ .env.local
19
+ **/.DS_Store
.env.example ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copy to .env.local (gitignored). The daemon loads .env.local at startup.
2
+ # All optional — sensible defaults bake in. Real exported env vars override the file.
3
+
4
+ # --- Text brain (narration + chat). Default: local Ollama. ---
5
+ # PUCK_BRAIN_URL=http://127.0.0.1:11434/v1
6
+ # PUCK_BRAIN_MODEL=hf.co/liahmartens/Holotron-12B-GGUF:Q8_0
7
+ # PUCK_BRAIN_TIMEOUT=25
8
+
9
+ # --- Vision (screenshot → events). ---
10
+ # Cloud-primary (Modal vLLM) is the default stance: local 12B tiling melts laptops
11
+ # and fails on weak machines. Non-localhost URL → whole-image; localhost → tiled.
12
+ PUCK_VISION_URL=https://<you>--puck-brain-serve.modal.run/v1
13
+ PUCK_VISION_MODEL=Hcompany/Holotron-12B
14
+ # Optional local fallback (e.g. llama-server on :8080). Empty = cloud only.
15
+ PUCK_VISION_FALLBACK_URL=
16
+ PUCK_VISION_FALLBACK_MODEL=
17
+ # A cold vLLM 12B start can take minutes; once warm it's ~5-15s.
18
+ PUCK_VISION_TIMEOUT=120
19
+
20
+ # Modal auth (for `modal deploy server/brain_modal.py`): run `modal token new` — it
21
+ # writes ~/.modal.toml. It is NOT read from any env var; keep tokens out of the repo.
.gitignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # dependencies / build artifacts
2
+ node_modules/
3
+ dist/
4
+ # desktop/src-tauri/target — 4GB Rust build
5
+ target/
6
+ .venv/
7
+ __pycache__/
8
+ *.pyc
9
+
10
+ # secrets / local env — .env has Modal tokens; .env.example stays tracked
11
+ .env
12
+ .env.local
13
+ .claude/settings.local.json
14
+
15
+ # large, regenerable, or transient (not source)
16
+ # 336MB ONNX CLIP model + index.npz — rebuilt by recognizer.build_index()
17
+ recognize/model/
18
+ # compiled Swift OCR binary — built by `swiftc ocr.swift`
19
+ recognize/bin/
20
+ # peek screenshots + log.jsonl — live capture data, often personal
21
+ server/data/
22
+
23
+ # handoff exports (stale duplicates of the tree)
24
+ puck-handoff/
25
+ puck-handoff.zip
26
+
27
+ # misc
28
+ .DS_Store
29
+ *.aiff
BACKLOG.md ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Puck backlog
2
+
3
+ Ideas not yet built. Each entry: what, why, and where it slots into the architecture
4
+ (engine = pure policy/data, ui = presentation, modes/sim = orchestration).
5
+
6
+ ## Gestures — sprite dances/reactions keyed to event type
7
+
8
+ **What:** Puck performs a short gesture matched to *what* he's reporting, not just
9
+ flying + speaking. A build passing → a little hop/spin of pride. Tests failing or a
10
+ permission block → an urgent shake/alarm wiggle. Discord noise he ignored → a dismissive
11
+ shrug. Claude finished → a satisfied stretch. Stale tab → a curious head-tilt. Sleep →
12
+ a yawn. The gesture plays during the flight-to + bubble beat.
13
+
14
+ **Why:** On a busy real desktop the sprite is glanceable; a gesture lets you read the
15
+ *kind* of news pre-attentively, before the words. It's the single highest charm-per-line
16
+ addition left, and it makes the overlay feel like a creature rather than a notifier.
17
+
18
+ **Where it slots in:**
19
+ - `engine/` — a pure `gestureFor(event | decision)` map: `EventDef.id`/`source` →
20
+ gesture name. Data, not logic; pin a couple of mappings with a test. Could also key
21
+ off the existing tier/mischief so high-mischief gestures are more theatrical.
22
+ - `ui/Sprite.tsx` — a `gesture?: GestureName` prop adding a CSS class (`gesture-hop`,
23
+ `gesture-shake`, `gesture-shrug`, …); animations on `.puck-bob`/`.puck-face` only
24
+ (compositor-friendly transforms, like the existing flap/bob). Clear the class on
25
+ animationend so it re-triggers.
26
+ - `modes/sim/SimApp.tsx` — set the gesture alongside the existing `flyTo`/`setBubble`
27
+ in `fireEvent`; clears when the surface resolves.
28
+
29
+ **Notes:** keep gestures to transform/opacity so they stay GPU-composited (the whole
30
+ sprite layer is). Reuse the per-form structure in `SpriteBody` — gestures should read
31
+ on all four forms (mossling/wisp/gremlin/moth), so animate the wrapper, not form-specific
32
+ parts. Pairs naturally with the existing mood tint + alert ring.
33
+
34
+ **Color pulse (same feature, cheapest channel):** transient-tint the alert ring + glow
35
+ to a per-event-type hue while a surface is pending — red/urgent for failures &
36
+ permission blocks, gold/success for completions, grey/dim for ignored noise. The ring
37
+ and `.puck-glow` already read CSS vars (`--accent`, `--glow`), so this is a single
38
+ `--alert-hue` override set from the same `engine` map that picks the gesture — one
39
+ `{ gesture, hue }` lookup feeds both. Even shipping the color pulse *alone* (before
40
+ gesture animation work) is a real readability win. Don't fight the existing mood tint
41
+ (`--puck-body` etc.): pulse the ring/glow, leave the body color to mood, so "what kind
42
+ of news" (hue) and "how Puck feels lately" (mood) stay separate signals.
43
+
44
+ ## Ambient quips — Puck reacts to what you're doing
45
+
46
+ **What:** Occasionally Puck mutters a quip or non-sequitur riffing on your current
47
+ context — the active app, a window title, the shape of what you're typing. Not help,
48
+ not a summary: flavor. "Still in the auth thicket, I see." / "That's a lot of tabs for
49
+ one small human." / a non-sequitur about the dock breathing. Low frequency, skippable,
50
+ never twice about the same thing.
51
+
52
+ **Why:** This is the difference between a notifier that lives in the corner and a
53
+ familiar that *shares the room*. It's the most "alive" feature on the list — and the
54
+ most dangerous, so it ships last and most carefully.
55
+
56
+ **Privacy is the feature, not a caveat** (design doc §17.3–17.4 are the law here):
57
+ - **Local inference ONLY — hard gate.** Context never leaves the machine. If the brain
58
+ is the cloud path (Modal/ZeroGPU), this feature is *disabled*, full stop, not degraded.
59
+ Enforce in code: the quip path checks the resolved brain is localhost and refuses
60
+ otherwise — a loud guard, not a setting.
61
+ - **Opt-in, off by default.** A distinct toggle from notifications; the permission copy
62
+ says plainly what's read and that it stays local.
63
+ - **Ephemeral, never persisted, never a trace, never training data.** Context for a quip
64
+ is read, used for one generation, dropped. It must not touch the memory garden, the
65
+ trace export, or localStorage.
66
+ - **Redact before the model sees it.** Strip obvious secrets (password fields, token-shaped
67
+ strings, anything in a field marked sensitive). Prefer *abstractions* over content —
68
+ "a long terminal command", "a code file", "a messaging app" — over the literal text.
69
+ Quoting back verbatim is the creepy line; stay on the abstract side of it.
70
+ - **Never about people or private content.** App categories and your own activity, yes;
71
+ the contents of a DM, an email body, a name on screen — no.
72
+
73
+ **Where it slots in:**
74
+ - The desktop watcher (future, §9.2: NSWorkspace frontmost app, optional AX/screen) is the
75
+ context source — same daemon `/events` path, a new low-priority `context` event kind, or
76
+ a separate local-only endpoint that never queues.
77
+ - `engine` decides *whether* to quip (rare; respects annoyance budget / presence, reuses
78
+ the interruption-taste machinery so an annoying quip trains Puck quieter).
79
+ - The quip generation uses the local brain with a tight "one playful aside, ≤12 words,
80
+ never quote the user" prompt; surfaces through the existing bubble channel.
81
+
82
+ **Ship order:** after the desktop watcher exists and after a privacy pass. Until then it's
83
+ sim-only flavor at most (riffing on the fake desktop, where there's nothing real to leak).
84
+
85
+ ## Phototaxis — a fairy drawn to stimuli (whimsical wander)
86
+
87
+ **What:** Puck should behave like a moth/fairy — *pulled* toward activity rather than
88
+ drifting at random. Flits toward motion, lingers near what's lively, chases the
89
+ occasional shiny thing, then loses interest.
90
+
91
+ **Why:** The wander is the sprite's resting personality — it's on screen far more than
92
+ any bubble. Uniform-random reads as a screensaver; attraction reads as *alive and
93
+ curious*. Highest charm-per-effort of the ambient ideas.
94
+
95
+ **Buildable now — zero new permissions (do this part first):**
96
+ Replace the uniform-random wander target in `SimApp` with a weighted pull toward salient
97
+ points we already have:
98
+ - the **cursor** (occasional gentle follow / curious approach, then retreat — never
99
+ clingy; respects `presence`),
100
+ - the **last event location** (he lingers where something just happened),
101
+ - in the overlay, the **focused window** rect (future: NSWorkspace frontmost-app
102
+ position via the daemon — he hangs near where you're working, patrols where you're not).
103
+ Keep it a *gradient*, not a leash: weighted-random pick among attractors + noise, so it
104
+ stays unpredictable. Lives in the engine as a pure `pickWanderTarget(attractors, rng)`;
105
+ the loop already exists. Tune against `presence` (low = aloof, high = follows the action).
106
+
107
+ **Sensor-gated — backlog, same privacy rules as ambient quips (local-only, ephemeral):**
108
+ - **Screen color / motion / "flashing lights":** needs ScreenCaptureKit — the transparent
109
+ overlay can't see what's beneath it. A coarse, downsampled brightness/motion map (NOT
110
+ readable content) could let him drift toward an area that just changed (a video started,
111
+ a notification flashed). Local-only, never stored, abstractions not pixels.
112
+ - **Audio reactivity:** mic is a hard no by default; "system audio is playing / its level"
113
+ is lighter but still opt-in + local-only. A bass-thump bob or a turn-toward-the-sound
114
+ would be delightful but ships last, behind the same gate as quips.
115
+
116
+ **Smell test for all of it:** attraction must stay *cute*, never *surveillant*. He reacts
117
+ to the shape of activity (something moved, something's loud), never to its content.
118
+
119
+ ## Take-me-there — Puck knows where the activity is, and ferries you to it
120
+
121
+ **What:** On a real notification, Puck should point you at the *actual* window that needs
122
+ you — fly to it if it's on this Space, beckon "follow me" if it's on another — and
123
+ **clicking him navigates there** (focuses the app, macOS brings its Space forward).
124
+
125
+ **Current behavior (the gap):** wire events arrive with `target: null` (the sim's targets
126
+ were fake windows), so in the overlay `fireEvent` flies Puck to a *random* screen point.
127
+ He has no idea where the source app lives — we never gave him real-window awareness.
128
+ This is the design doc's Phase 4 ("patrol the desktops you abandoned" presumes knowing
129
+ where they are).
130
+
131
+ **Phase 1 — click-to-activate (high value, no Space geometry needed):**
132
+ - Event carries a **locator**: source app bundle id / pid / window title. The Claude hook
133
+ already has `TERM_PROGRAM`, `cwd`, and the calling pid available; `puck-run` knows its
134
+ terminal. Add an optional `locator` to the wire schema (`{bundleId?, pid?, title?}`).
135
+ - Rust command `activate_target(locator)` -> `NSRunningApplication(bundleIdentifier:).activate`
136
+ (or AX focus by pid/title). macOS switches to that app's Space automatically.
137
+ - Frontend: clicking Puck while a located surface is pending calls it. This alone delivers
138
+ "Puck lit up -> click -> you're where the thing is," across Spaces, without knowing which.
139
+
140
+ **Phase 2 — same-Space vs other-Space (the fiddly bit):**
141
+ - Determine if the target window is on the *current* Space: `CGWindowListCopyWindowInfo`
142
+ with `kCGWindowListOptionOnScreenOnly` lists on-screen windows; absent target -> elsewhere.
143
+ Robust Space identity needs the semi-private CGSSpace APIs — fragile, optional.
144
+ - Same Space -> fly to the window's screen rect (window bounds from CGWindowList).
145
+ Other Space -> a "come hither" beckon toward the screen edge (pairs with the gesture
146
+ entry), and the click teleports.
147
+
148
+ **Notes:** locator is metadata, not content — bundle id + window title, never window
149
+ *contents* (anti-creep). Activation is an explicit user click (navigation, not an
150
+ autonomous action), so it stays inside the safety tiers. Depends on: gesture vocabulary
151
+ (beckon) and the future native window watcher (NSWorkspace frontmost / CGWindowList).
152
+
153
+ ## Camouflage — Puck adapts to what he's floating over
154
+
155
+ **What:** When Puck drifts over text or busy content, he reacts to his surroundings like
156
+ a chameleon/glass-wisp — goes translucent, refracts, or (dream version) "mirrors" the
157
+ texture behind him onto his own body. Blends, then pops back when he moves to empty space.
158
+
159
+ **Why:** Sells "he's really *in* your desktop, not pasted on top." A creature that
160
+ responds to its background reads as inhabiting the space. Pairs with the wisp form
161
+ (already glass-like).
162
+
163
+ **Cheap / free now (no sensing):**
164
+ - **Shy fade:** lower sprite opacity while stationary over the busy center of the screen,
165
+ restore when wandering to the margins — pure CSS/opacity on the existing wander state.
166
+ Reads as "blending in" without literally seeing anything.
167
+ - **Refraction (maybe free, needs a WKWebView test):** a `backdrop-filter` glass body on
168
+ the sprite. In the transparent overlay this *might* sample the real desktop behind the
169
+ window (same open question as the speech-bubble blur) — if it does, a refractive/distort
170
+ body gives instant chameleon shimmer with zero screen-capture. Test in the Tauri overlay
171
+ before committing to it.
172
+
173
+ **Dream version — literal mirror (screen-capture gated):**
174
+ - Sample the screen region directly under the sprite (ScreenCaptureKit), downsample, and
175
+ paint it onto his body as living camouflage. Stunning, but it's the same hard gate as
176
+ ambient quips / phototaxis sensors: local-only, ephemeral, opt-in, never stored, never
177
+ leaves the machine. Texture/color only — never treated as readable content.
178
+
179
+ **Where it slots in:** `ui/Sprite.tsx` (a `camouflage` intensity prop on `.puck-body`),
180
+ driven by `modes/sim` from sprite position vs. screen regions. The shy-fade is a
181
+ 20-minute add; refraction is a test-then-maybe; the literal mirror waits for the screen
182
+ watcher + privacy pass.
183
+
184
+ ## Real app icons — known apps, OS-extracted where possible
185
+
186
+ **What:** Replace the glyph characters (✳ ◍ ✉ …) in the sim windows/dock and feed
187
+ source-markers with real app icons — Claude, ChatGPT, Chrome, Gmail, Mail, Discord,
188
+ Terminal, etc. Looks dramatically more legit, especially in the overlay.
189
+
190
+ **Pulling the *actual* OS icon (the good version):**
191
+ - macOS: `NSWorkspace.shared.icon(forFile: "/Applications/Foo.app")` returns the real
192
+ icon for any installed `.app` bundle — a Rust/Tauri command can extract → PNG → hand to
193
+ the webview. So Chrome, Mail, Discord, Slack, the host terminal: real icons, free, always
194
+ current.
195
+ - **The limit you called:** a terminal *binary* (claude, codex) has no bundle and no icon.
196
+ Fall back to the **host terminal's** icon (iTerm/Terminal/Ghostty/Warp — which the Claude
197
+ hook can report via `TERM_PROGRAM`), or a curated Puck-styled glyph.
198
+ - **Web apps with no native app** (ChatGPT, Gmail as a tab): no bundle to extract from —
199
+ these need a small **curated bundled icon set** (a dozen SVGs/PNGs).
200
+
201
+ **So: hybrid.** OS extraction where a bundle exists (Rust command, overlay only), curated
202
+ bundled set for web-apps + terminal-binary fallbacks (works everywhere incl. the Space/sim).
203
+
204
+ **Where it slots in:**
205
+ - `ui/Desktop.tsx` (`WIN_DEFS` icons, dock glyphs) and the feed `source` marker — currently
206
+ single glyph chars; swap for an `<Icon source=…>` that prefers OS-extracted, falls back to
207
+ bundled, falls back to glyph.
208
+ - Overlay-only Rust command `app_icon(bundleId|path) -> png` for the extraction half.
209
+ - Engine `SOURCES`/`EventDef` already carry source identity; add an optional `bundleId` hint.
210
+
211
+ **Cheap first step (no native):** ship the curated bundled icon set + source→icon map; use
212
+ glyph only as last resort. The OS-extraction half is an overlay enhancement on top.
213
+
214
+ ## Local vision — the private, free path for continuous perception
215
+
216
+ **What:** Run Puck's eyes on-device instead of (or alongside) Modal, so real-screen
217
+ perception is private and continuous vision costs ~nothing. Brain seam already supports
218
+ it: point PUCK_VISION_URL at a local OpenAI-compatible server.
219
+
220
+ **Capability is NOT the blocker** (verified 2026-06-07): screen-reading is OCR + light
221
+ "what's notable" reasoning — small VLMs excel at it. Options:
222
+ - **Holotron-12B local** via llama.cpp + mmproj — llama.cpp merged Nemotron-Nano-12B-v2-VL
223
+ (PR #19547). `convert_hf_to_gguf.py --mmproj` → vision projector → `llama-server`. Same
224
+ model as cloud, same capability, ~24GB on the 48GB Mac, free. NB: Ollama can't load the
225
+ mmproj — must use raw llama-server. Keeps the Nemotron Quest tie.
226
+ - **Qwen2.5-VL-7B local** — ~6GB, 95.7 DocVQA, fast; ideal for *continuous ambient* (every
227
+ 45s forever on the M4 Max). Loses the Nemotron tie, plenty for screen-reading.
228
+ - MiniCPM-V 2.6 (~5.5GB), Moondream2 (1.9B, CPU) as even-lighter fallbacks.
229
+
230
+ **Recommendation:** Holotron for the showcase + Quest (cloud now → local llama-server
231
+ later as the private path); Qwen2.5-VL-7B-local as the cheap continuous engine. The
232
+ visionMode "Continuous" tier (built, currently same cloud path) should switch to a local
233
+ PUCK_VISION_URL when this lands.
234
+
235
+ **Where it slots in:** zero engine/frontend change — it's a runtime: spin up
236
+ `llama-server --mmproj` (or vLLM/mlx when fixed) and set PUCK_VISION_URL. Plus the
237
+ real-screen capture (ScreenCaptureKit, overlay) to feed it actual pixels instead of the
238
+ sim snapshot.
Dockerfile ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Puck — Hugging Face Space (Docker SDK).
2
+ # Two stages: build the React UI, then run the gr.Server daemon that serves it AND
3
+ # proxies "peeks" to the cloud (Modal) vision brain. The macOS-only paths (screen OCR,
4
+ # `say` voice, ONNX recognizer) self-disable on Linux; the Space hosts the browser SIM,
5
+ # whose vision runs against Modal. Custom UI over gr.Server = the Off-Brand badge.
6
+
7
+ # ---- stage 1: build the frontend ----
8
+ FROM node:24-slim AS web
9
+ WORKDIR /web
10
+ COPY frontend/package.json frontend/package-lock.json* ./
11
+ RUN npm ci || npm install
12
+ COPY frontend/ ./
13
+ RUN npm run build # → /web/dist
14
+
15
+ # ---- stage 2: python daemon ----
16
+ FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
17
+ # HF Spaces run as uid 1000 — give it a writable HOME for the peek log + uv cache.
18
+ RUN useradd -m -u 1000 user
19
+ ENV HOME=/home/user \
20
+ PATH=/home/user/.local/bin:$PATH \
21
+ UV_LINK_MODE=copy \
22
+ UV_COMPILE_BYTECODE=1 \
23
+ PUCK_HOST=0.0.0.0 \
24
+ PORT=7860 \
25
+ PUCK_VISION_URL=https://vuluan--puck-brain-serve.modal.run/v1 \
26
+ PUCK_VISION_MODEL=Hcompany/Holotron-12B \
27
+ PUCK_VISION_TIMEOUT=120
28
+ WORKDIR /home/user/app
29
+
30
+ # deps first (cached across code edits); the server's lockfile drives the resolve
31
+ COPY --chown=user:user server/pyproject.toml server/uv.lock ./server/
32
+ RUN cd server && uv sync --no-dev
33
+
34
+ # app code + the assets app.py reads at import (schema/) + the built UI
35
+ COPY --chown=user:user server/ ./server/
36
+ COPY --chown=user:user schema/ ./schema/
37
+ COPY --chown=user:user --from=web /web/dist ./frontend/dist
38
+ RUN mkdir -p server/data && chown -R user:user /home/user/app
39
+
40
+ USER user
41
+ EXPOSE 7860
42
+ WORKDIR /home/user/app/server
43
+ # Run the venv's interpreter directly — no `uv` at runtime (it would hit a root-owned
44
+ # build-time cache, and we don't want a re-resolve on boot). The env is already synced.
45
+ CMD ["/home/user/app/server/.venv/bin/python", "app.py"]
README.md ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Puck
3
+ emoji: 🧚
4
+ colorFrom: green
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ short_description: A mischievous desktop fairy that comments on your work
11
+ tags:
12
+ - build-small
13
+ - thousand-token-wood
14
+ - off-brand
15
+ - off-the-grid
16
+ - field-notes
17
+ - best-demo
18
+ - track:wood
19
+ - sponsor:nvidia
20
+ - sponsor:modal
21
+ - achievement:offgrid
22
+ - achievement:welltuned
23
+ - achievement:offbrand
24
+ - achievement:llama
25
+ - achievement:fieldnotes
26
+ ---
27
+
28
+ <!-- TRACK + BADGES: the tag slugs above are my best guess. Before submitting, paste this
29
+ README into the field-guide validator (https://build-small-hackathon-field-guide.hf.space/submit)
30
+ and use the EXACT slugs it expects for the Thousand Token Wood track + each badge. -->
31
+
32
+ # 🧚 Puck — a desktop fairy familiar
33
+
34
+ Puck is a small, mischievous creature that lives on your screen. He **roams**, **peeks** at one little patch of whatever you're doing, and **murmurs** a single in-character line about it — then drifts on. He's not an assistant and not a notifier. He's company: *marginally useful, reliably charming.*
35
+
36
+ > **This Space is the playable demo** (a simulated desktop in your browser). Puck's real home is a transparent always-on-top overlay on your actual Mac desktop — that's the video. Here you can poke him, watch him roam, peek, react, and blend into the desktop.
37
+
38
+ **▶️ Demo video:** _<add link — YouTube or uploaded to this Space>_ · **📣 Social post:** _<add link>_ · **📓 Field notes:** [the build writeup](submission/FIELD_NOTES.md)
39
+
40
+ ## What he does
41
+ - **Roam → peek → quip.** Every so often he flutters somewhere and looks at the small region under him. A vision-language model *sees that patch and speaks in his voice* in one shot — the AI is doing the fun thing, not narrating from a script.
42
+ - **Feels things.** Each peek is classified into an emotion that drives his whole reaction together — a **gesture** (giddy laugh, NANI-confusion, a worried tremble, a wistful droop), an **aura color**, and an **emotional voice** (pitch + pace). Puck's a *learning* creature, so confusion is his honest default — and endearing.
43
+ - **Reads the room.** On a real terminal he uses on-device OCR to tell *which* coding agent you're running (Claude Code vs Codex vs opencode vs pi) and grounds his quip in the actual text on screen.
44
+ - **Camouflages.** Sit still and he cloaks — his skin clears so the desktop reads right through him, with a faint shimmer and two watching eyes. Move, and he snaps back.
45
+ - **Dreams.** At night he **blooms** the day's peeks into a small garden of memories.
46
+
47
+ ## Built Small
48
+ Puck is a constellation of small, laptop-sized models — **nothing over 32B**, and it fits in your pocket:
49
+ - 👁️ **Vision + voice-of-the-fairy:** Nemotron-Nano-**12B**-VL (a single VLM that both *sees* and *speaks* the quip).
50
+ - 🔤 **Tool/site recognition:** on-device **Vision OCR** + an **ONNX CLIP ViT-B/32** (~88M) fingerprinter.
51
+ - 🗣️ **Neural voice:** **Kokoro-82M**, running in-browser (WebGPU) — a fairy voice in 82M params.
52
+
53
+ The whole companion runs locally and offline ("Off the Grid"). *This hosted Space* uses a cloud (Modal) copy of the 12B so it works in your browser — which scales to zero, so **the first peek may take a moment while Puck stretches his wings.** ✨
54
+
55
+ ## Under the hood
56
+ A custom React UI served by a **`gradio.Server`** daemon (Off-Brand) — two habitats from one app: this browser **sim**, and a **Tauri** overlay for the real desktop. Region peeks, an emotion contract, a memory log that blooms into a garden.
57
+
58
+ *HF Build Small — Thousand Token Wood (Creative).*
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/README.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + TypeScript + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
17
+
18
+ ```js
19
+ export default defineConfig([
20
+ globalIgnores(['dist']),
21
+ {
22
+ files: ['**/*.{ts,tsx}'],
23
+ extends: [
24
+ // Other configs...
25
+
26
+ // Remove tseslint.configs.recommended and replace with this
27
+ tseslint.configs.recommendedTypeChecked,
28
+ // Alternatively, use this for stricter rules
29
+ tseslint.configs.strictTypeChecked,
30
+ // Optionally, add this for stylistic rules
31
+ tseslint.configs.stylisticTypeChecked,
32
+
33
+ // Other configs...
34
+ ],
35
+ languageOptions: {
36
+ parserOptions: {
37
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
38
+ tsconfigRootDir: import.meta.dirname,
39
+ },
40
+ // other options...
41
+ },
42
+ },
43
+ ])
44
+ ```
45
+
46
+ You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
47
+
48
+ ```js
49
+ // eslint.config.js
50
+ import reactX from 'eslint-plugin-react-x'
51
+ import reactDom from 'eslint-plugin-react-dom'
52
+
53
+ export default defineConfig([
54
+ globalIgnores(['dist']),
55
+ {
56
+ files: ['**/*.{ts,tsx}'],
57
+ extends: [
58
+ // Other configs...
59
+ // Enable lint rules for React
60
+ reactX.configs['recommended-typescript'],
61
+ // Enable lint rules for React DOM
62
+ reactDom.configs.recommended,
63
+ ],
64
+ languageOptions: {
65
+ parserOptions: {
66
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
67
+ tsconfigRootDir: import.meta.dirname,
68
+ },
69
+ // other options...
70
+ },
71
+ },
72
+ ])
73
+ ```
frontend/biome.json ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
3
+ "vcs": {
4
+ "enabled": true,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": true
7
+ },
8
+ "files": {
9
+ "ignoreUnknown": false
10
+ },
11
+ "formatter": {
12
+ "enabled": true,
13
+ "indentStyle": "space",
14
+ "indentWidth": 2,
15
+ "lineWidth": 110
16
+ },
17
+ "linter": {
18
+ "enabled": true,
19
+ "rules": {
20
+ "recommended": true,
21
+ "a11y": {
22
+ "useKeyWithClickEvents": "off",
23
+ "noStaticElementInteractions": "off",
24
+ "noNoninteractiveElementInteractions": "off",
25
+ "useSemanticElements": "off"
26
+ },
27
+ "style": {
28
+ "noDescendingSpecificity": "off"
29
+ }
30
+ }
31
+ },
32
+ "javascript": {
33
+ "formatter": {
34
+ "quoteStyle": "double"
35
+ }
36
+ },
37
+ "assist": {
38
+ "enabled": true,
39
+ "actions": {
40
+ "source": {
41
+ "organizeImports": "on"
42
+ }
43
+ }
44
+ }
45
+ }
frontend/bun.lock ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "frontend",
7
+ "dependencies": {
8
+ "@tauri-apps/api": "^2.11.0",
9
+ "html-to-image": "^1.11.13",
10
+ "kokoro-js": "^1.2.1",
11
+ "react": "^19.2.6",
12
+ "react-dom": "^19.2.6",
13
+ },
14
+ "devDependencies": {
15
+ "@biomejs/biome": "^2.4.16",
16
+ "@types/node": "^24.12.3",
17
+ "@types/react": "^19.2.14",
18
+ "@types/react-dom": "^19.2.3",
19
+ "@vitejs/plugin-react": "^6.0.1",
20
+ "typescript": "~6.0.2",
21
+ "vite": "^8.0.12",
22
+ "vitest": "^4.1.8",
23
+ },
24
+ },
25
+ },
26
+ "packages": {
27
+ "@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="],
28
+
29
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="],
30
+
31
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="],
32
+
33
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="],
34
+
35
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="],
36
+
37
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="],
38
+
39
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="],
40
+
41
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="],
42
+
43
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="],
44
+
45
+ "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
46
+
47
+ "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
48
+
49
+ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
50
+
51
+ "@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="],
52
+
53
+ "@huggingface/transformers": ["@huggingface/transformers@3.8.1", "", { "dependencies": { "@huggingface/jinja": "^0.5.3", "onnxruntime-node": "1.21.0", "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", "sharp": "^0.34.1" } }, "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA=="],
54
+
55
+ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
56
+
57
+ "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
58
+
59
+ "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
60
+
61
+ "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
62
+
63
+ "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
64
+
65
+ "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
66
+
67
+ "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
68
+
69
+ "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
70
+
71
+ "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
72
+
73
+ "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
74
+
75
+ "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
76
+
77
+ "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
78
+
79
+ "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
80
+
81
+ "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
82
+
83
+ "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
84
+
85
+ "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
86
+
87
+ "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
88
+
89
+ "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
90
+
91
+ "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
92
+
93
+ "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
94
+
95
+ "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
96
+
97
+ "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
98
+
99
+ "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
100
+
101
+ "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
102
+
103
+ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
104
+
105
+ "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
106
+
107
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
108
+
109
+ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
110
+
111
+ "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
112
+
113
+ "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
114
+
115
+ "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
116
+
117
+ "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
118
+
119
+ "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="],
120
+
121
+ "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
122
+
123
+ "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
124
+
125
+ "@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="],
126
+
127
+ "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
128
+
129
+ "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
130
+
131
+ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
132
+
133
+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="],
134
+
135
+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="],
136
+
137
+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="],
138
+
139
+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="],
140
+
141
+ "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="],
142
+
143
+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="],
144
+
145
+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="],
146
+
147
+ "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="],
148
+
149
+ "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="],
150
+
151
+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="],
152
+
153
+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="],
154
+
155
+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="],
156
+
157
+ "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="],
158
+
159
+ "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="],
160
+
161
+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="],
162
+
163
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
164
+
165
+ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
166
+
167
+ "@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="],
168
+
169
+ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
170
+
171
+ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
172
+
173
+ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
174
+
175
+ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
176
+
177
+ "@types/node": ["@types/node@24.13.1", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg=="],
178
+
179
+ "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
180
+
181
+ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
182
+
183
+ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="],
184
+
185
+ "@vitest/expect": ["@vitest/expect@4.1.8", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ=="],
186
+
187
+ "@vitest/mocker": ["@vitest/mocker@4.1.8", "", { "dependencies": { "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw=="],
188
+
189
+ "@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="],
190
+
191
+ "@vitest/runner": ["@vitest/runner@4.1.8", "", { "dependencies": { "@vitest/utils": "4.1.8", "pathe": "^2.0.3" } }, "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg=="],
192
+
193
+ "@vitest/snapshot": ["@vitest/snapshot@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ=="],
194
+
195
+ "@vitest/spy": ["@vitest/spy@4.1.8", "", {}, "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA=="],
196
+
197
+ "@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="],
198
+
199
+ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
200
+
201
+ "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
202
+
203
+ "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
204
+
205
+ "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
206
+
207
+ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
208
+
209
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
210
+
211
+ "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
212
+
213
+ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
214
+
215
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
216
+
217
+ "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
218
+
219
+ "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
220
+
221
+ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
222
+
223
+ "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
224
+
225
+ "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="],
226
+
227
+ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
228
+
229
+ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
230
+
231
+ "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
232
+
233
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
234
+
235
+ "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
236
+
237
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
238
+
239
+ "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
240
+
241
+ "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
242
+
243
+ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
244
+
245
+ "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
246
+
247
+ "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
248
+
249
+ "html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
250
+
251
+ "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
252
+
253
+ "kokoro-js": ["kokoro-js@1.2.1", "", { "dependencies": { "@huggingface/transformers": "^3.5.1", "phonemizer": "^1.2.1" } }, "sha512-oq0HZJWis3t8lERkMJh84WLU86dpYD0EuBPtqYnLlQzyFP1OkyBRDcweAqCfhNOpltyN9j/azp1H6uuC47gShw=="],
254
+
255
+ "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
256
+
257
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
258
+
259
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
260
+
261
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
262
+
263
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
264
+
265
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
266
+
267
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
268
+
269
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
270
+
271
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
272
+
273
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
274
+
275
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
276
+
277
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
278
+
279
+ "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
280
+
281
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
282
+
283
+ "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
284
+
285
+ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
286
+
287
+ "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
288
+
289
+ "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
290
+
291
+ "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
292
+
293
+ "obug": ["obug@2.1.2", "", {}, "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg=="],
294
+
295
+ "onnxruntime-common": ["onnxruntime-common@1.21.0", "", {}, "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ=="],
296
+
297
+ "onnxruntime-node": ["onnxruntime-node@1.21.0", "", { "dependencies": { "global-agent": "^3.0.0", "onnxruntime-common": "1.21.0", "tar": "^7.0.1" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw=="],
298
+
299
+ "onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="],
300
+
301
+ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
302
+
303
+ "phonemizer": ["phonemizer@1.2.1", "", {}, "sha512-v0KJ4mi2T4Q7eJQ0W15Xd4G9k4kICSXE8bpDeJ8jisL4RyJhNWsweKTOi88QXFc4r4LZlz5jVL5lCHhkpdT71A=="],
304
+
305
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
306
+
307
+ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
308
+
309
+ "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
310
+
311
+ "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
312
+
313
+ "protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="],
314
+
315
+ "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
316
+
317
+ "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
318
+
319
+ "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
320
+
321
+ "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="],
322
+
323
+ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
324
+
325
+ "semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="],
326
+
327
+ "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
328
+
329
+ "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
330
+
331
+ "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
332
+
333
+ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
334
+
335
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
336
+
337
+ "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
338
+
339
+ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
340
+
341
+ "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
342
+
343
+ "tar": ["tar@7.5.16", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w=="],
344
+
345
+ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
346
+
347
+ "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
348
+
349
+ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
350
+
351
+ "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
352
+
353
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
354
+
355
+ "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
356
+
357
+ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
358
+
359
+ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
360
+
361
+ "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="],
362
+
363
+ "vitest": ["vitest@4.1.8", "", { "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", "@vitest/pretty-format": "4.1.8", "@vitest/runner": "4.1.8", "@vitest/snapshot": "4.1.8", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.8", "@vitest/browser-preview": "4.1.8", "@vitest/browser-webdriverio": "4.1.8", "@vitest/coverage-istanbul": "4.1.8", "@vitest/coverage-v8": "4.1.8", "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig=="],
364
+
365
+ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
366
+
367
+ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
368
+
369
+ "onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.22.0-dev.20250409-89f8206ba4", "", {}, "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ=="],
370
+ }
371
+ }
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en" data-theme="terrarium">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Puck — desktop familiar</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
frontend/package.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "puck-frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "biome check src",
10
+ "format": "biome format --write src",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
13
+ "export:deck": "bun run scripts/export-deck.ts"
14
+ },
15
+ "dependencies": {
16
+ "@tauri-apps/api": "^2.11.0",
17
+ "html-to-image": "^1.11.13",
18
+ "kokoro-js": "^1.2.1",
19
+ "react": "^19.2.6",
20
+ "react-dom": "^19.2.6"
21
+ },
22
+ "devDependencies": {
23
+ "@biomejs/biome": "^2.4.16",
24
+ "@types/node": "^24.12.3",
25
+ "@types/react": "^19.2.14",
26
+ "@types/react-dom": "^19.2.3",
27
+ "@vitejs/plugin-react": "^6.0.1",
28
+ "typescript": "~6.0.2",
29
+ "vite": "^8.0.12",
30
+ "vitest": "^4.1.8"
31
+ }
32
+ }
frontend/public/favicon.svg ADDED
frontend/public/icons.svg ADDED
frontend/scripts/export-deck.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Export the engine's event deck to JSON for the Python-side molt tooling.
2
+ // The deck's hand-written tier lines are the gold personality dataset —
3
+ // this keeps molt/ reading the same source of truth the sim runs on.
4
+ // Regenerate: bun run export:deck (from frontend/)
5
+
6
+ import { mkdirSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { EVENTS } from "../src/engine/events";
9
+
10
+ const out = join(import.meta.dir, "../../molt/data/deck.json");
11
+ mkdirSync(join(import.meta.dir, "../../molt/data"), { recursive: true });
12
+ writeFileSync(out, `${JSON.stringify(EVENTS, null, 2)}\n`);
13
+ console.log(`wrote ${EVENTS.length} events → ${out}`);
frontend/src/engine/chat.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { FairyState, Rng } from "./types";
2
+ import { pick } from "./util";
3
+
4
+ // Keyword-routed scripted replies — the seam where a real model slots in later.
5
+ export function reply(text: string, fs: FairyState, rng: Rng = Math.random): string {
6
+ const q = text.toLowerCase();
7
+ const dramatic = fs.mischief > 60;
8
+ const pools: Record<string, string[]> = {
9
+ sleep: dramatic
10
+ ? ["Tired already? Fine. Tuck me in and I'll dream us both smarter."]
11
+ : ["Say the word and I'll start Night Bloom. I sleep, I sort, I wake up a little different."],
12
+ memory: [
13
+ "Everything I keep lives in the garden — water it, prune it, pin it. I only plant what's durable.",
14
+ ],
15
+ mischief: ["Crank the Goblin Weather and I get theatrical. Lower it and I behave. Mostly."],
16
+ claude: dramatic
17
+ ? ["The code goblin on Desktop 2? I keep one eye on its scratching at all times."]
18
+ : ["I watch Claude closely. The moment it finishes, you'll hear from me."],
19
+ who: [
20
+ "I'm Puck. I'm not your assistant — I'm the thing that lives in the corners and patrols what you abandon.",
21
+ ],
22
+ help: ["I help in the margins. Unfinished tasks, loud inboxes, builds that finish while you look away."],
23
+ discord: ["The social bog? I filter it hard. Mentions reach you. Swamp noise does not."],
24
+ thanks: ["Mm. I'll remember you said that.", "Plant it in the garden, then. A kindness."],
25
+ };
26
+ for (const k of Object.keys(pools)) if (q.includes(k)) return pick(pools[k], rng);
27
+ return pick(
28
+ [
29
+ "I heard you. I'm mostly watching the desktops you left behind, though.",
30
+ "Noted. I'll fold it into tonight's Night Bloom.",
31
+ dramatic
32
+ ? "A fair point, for a human. I'll consider it while I patrol."
33
+ : "Got it. I'll keep that in mind on the next patrol.",
34
+ "I'm half-listening and fully loyal. Both at once.",
35
+ ],
36
+ rng,
37
+ );
38
+ }
frontend/src/engine/engine.test.ts ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Behavior pins: these values are computed from the prototype's formulas.
2
+ // If a test fails on a contract change, that's a deliberate engine change — not a test to "fix".
3
+ import { describe, expect, it } from "vitest";
4
+ import { EVENTS } from "./events";
5
+ import {
6
+ acceptAutomation,
7
+ applyBias,
8
+ automationOffer,
9
+ confidence,
10
+ declineAutomation,
11
+ defaultProfile,
12
+ dispositionLabel,
13
+ ensureSources,
14
+ handlesEvent,
15
+ hasAutomation,
16
+ irks,
17
+ rateSource,
18
+ statsFor,
19
+ voiceTags,
20
+ } from "./learning";
21
+ import { nightBloom } from "./nightBloom";
22
+ import { applyMode, decide, score } from "./scoring";
23
+ import { learn, speak } from "./speech";
24
+ import { defaultState, defaultTaste, moodFor, rateFairy } from "./state";
25
+ import type { FairyState, FeedItem, Rating } from "./types";
26
+
27
+ const ev = (id: string) => {
28
+ const e = EVENTS.find((x) => x.id === id);
29
+ if (!e) throw new Error(`no event ${id}`);
30
+ return e;
31
+ };
32
+
33
+ const neutral: FairyState = { ...defaultState(), mischief: 50, protectiveness: 50, curiosity: 50 };
34
+
35
+ describe("score", () => {
36
+ it("passes base scores through at neutral personality", () => {
37
+ expect(score(ev("claude_done"), neutral)).toEqual(ev("claude_done").base);
38
+ });
39
+
40
+ it("protectiveness raises annoyance and relevance; mischief lowers annoyance", () => {
41
+ const s = score(ev("discord_noise"), {
42
+ ...neutral,
43
+ protectiveness: 70,
44
+ mischief: 30,
45
+ curiosity: 50,
46
+ });
47
+ // annoyance: 86 + 20*0.25 - (-20)*0.15 = 94
48
+ expect(s.annoyance).toBe(94);
49
+ // relevance: 18 + 20*0.1 = 20
50
+ expect(s.relevance).toBe(20);
51
+ // interest: 14 + 0 + (-20)*0.15 = 11
52
+ expect(s.interest).toBe(11);
53
+ });
54
+
55
+ it("clamps to 0..100", () => {
56
+ const s = score(ev("discord_noise"), { ...neutral, protectiveness: 100, mischief: 0 });
57
+ expect(s.annoyance).toBe(100);
58
+ });
59
+ });
60
+
61
+ describe("decide", () => {
62
+ it("interrupts when urgency and relevance are both >= 80", () => {
63
+ expect(decide({ urgency: 84, relevance: 86, novelty: 55, annoyance: 30, interest: 70 })).toBe(
64
+ "interrupt",
65
+ );
66
+ });
67
+
68
+ it("ignores low-relevance high-annoyance noise", () => {
69
+ expect(decide(score(ev("discord_noise"), neutral))).toBe("ignore");
70
+ });
71
+
72
+ it("notifies on high-worth events (claude_done at neutral: worth 239)", () => {
73
+ expect(decide(score(ev("claude_done"), neutral))).toBe("notify");
74
+ });
75
+
76
+ it("walks the bands: glow > 120, scroll > 70, else ignore", () => {
77
+ const base = { novelty: 0, annoyance: 0 };
78
+ expect(decide({ ...base, urgency: 50, relevance: 50, interest: 50, annoyance: 0 })).toBe("glow"); // worth 150
79
+ expect(decide({ ...base, urgency: 30, relevance: 30, interest: 30, annoyance: 0 })).toBe("scroll"); // worth 90
80
+ expect(decide({ ...base, urgency: 20, relevance: 20, interest: 20, annoyance: 0 })).toBe("ignore"); // worth 60
81
+ });
82
+ });
83
+
84
+ describe("speak", () => {
85
+ it("picks mythic when taste favors theatre and mischief is high (rng pinned)", () => {
86
+ const u = speak(
87
+ ev("claude_done"),
88
+ { ...neutral, mischief: 80 },
89
+ { theatrical: 1, terse: 0, useful: 0, warm: 1 },
90
+ () => 0.99, // caprice equal across tiers; side-quest roll 0.99 > 0.4 → no side quest
91
+ );
92
+ expect(u.tier).toBe("mythic");
93
+ expect(u.text).toBe(ev("claude_done").lines.mythic);
94
+ });
95
+
96
+ it("picks plain when taste favors terse-useful (rng pinned)", () => {
97
+ const u = speak(ev("claude_done"), neutral, { theatrical: 0, terse: 1, useful: 1, warm: 0 }, () => 0);
98
+ expect(u.tier).toBe("plain");
99
+ expect(u.tags).toEqual(["useful", "terse"]);
100
+ });
101
+
102
+ it("appends a side quest at high mischief when the roll hits", () => {
103
+ const u = speak(ev("claude_done"), { ...neutral, mischief: 80 }, defaultTaste(), () => 0.1);
104
+ expect(u.text.length).toBeGreaterThan(ev("claude_done").lines[u.tier].length);
105
+ });
106
+ });
107
+
108
+ describe("learn (taste)", () => {
109
+ it("helpful reinforces used tags and usefulness", () => {
110
+ const t = learn(defaultTaste(), ["theatrical", "warm"], "helpful");
111
+ expect(t.theatrical).toBeCloseTo(0.58);
112
+ expect(t.warm).toBeCloseTo(0.63);
113
+ expect(t.useful).toBeCloseTo(0.58);
114
+ });
115
+
116
+ it("annoying suppresses used tags, extra penalty on theatrical", () => {
117
+ const t = learn(defaultTaste(), ["theatrical", "warm"], "annoying");
118
+ expect(t.theatrical).toBeCloseTo(0.38); // -0.08 then -0.04
119
+ expect(t.warm).toBeCloseTo(0.47);
120
+ });
121
+
122
+ it("cute trades usefulness for theatre", () => {
123
+ const t = learn(defaultTaste(), ["useful", "terse"], "cute");
124
+ expect(t.theatrical).toBeCloseTo(0.58);
125
+ expect(t.useful).toBeCloseTo(0.46);
126
+ });
127
+
128
+ it("clamps to 0..1", () => {
129
+ let t = defaultTaste();
130
+ for (let i = 0; i < 20; i++) t = learn(t, ["theatrical"], "annoying");
131
+ expect(t.theatrical).toBe(0);
132
+ });
133
+ });
134
+
135
+ describe("learning profile", () => {
136
+ it("ratings push per-source bias with the prototype's step sizes", () => {
137
+ let p = defaultProfile();
138
+ p = rateSource(p, "Mail", "helpful");
139
+ expect(p.sources.Mail).toMatchObject({ bias: 9, helpful: 1, samples: 1 });
140
+ p = rateSource(p, "Mail", "annoying");
141
+ expect(p.sources.Mail.bias).toBe(-3);
142
+ p = rateSource(p, "Mail", "cute");
143
+ expect(p.sources.Mail.bias).toBe(-6);
144
+ expect(p.sources.Mail.samples).toBe(3);
145
+ });
146
+
147
+ it("bias clamps at ±50", () => {
148
+ let p = defaultProfile();
149
+ for (let i = 0; i < 10; i++) p = rateSource(p, "Discord", "annoying");
150
+ expect(p.sources.Discord.bias).toBe(-50);
151
+ });
152
+
153
+ it("survives a profile persisted before a source existed (Learning-tab crash regression)", () => {
154
+ // simulate old localStorage: a profile whose sources map predates "Git"
155
+ const old = defaultProfile();
156
+ delete (old.sources as Record<string, unknown>).Git;
157
+ // the unguarded paths that crashed the overlay must now be safe
158
+ expect(statsFor(old, "Git")).toMatchObject({ bias: 0, samples: 0 });
159
+ expect(() => irks(old)).not.toThrow();
160
+ expect(ensureSources(old).sources.Git).toBeDefined();
161
+ // a profile that already has every source is returned unchanged (no needless writes)
162
+ const full = defaultProfile();
163
+ expect(ensureSources(full)).toBe(full);
164
+ });
165
+
166
+ it("applyBias shifts along the ladder, one rung per 18 bias", () => {
167
+ expect(applyBias("glow", 18)).toBe("notify");
168
+ expect(applyBias("glow", -18)).toBe("scroll");
169
+ expect(applyBias("glow", 9)).toBe("notify"); // Math.round(0.5) = 1
170
+ expect(applyBias("ignore", -50)).toBe("ignore");
171
+ });
172
+
173
+ it("applyBias never manufactures an interrupt", () => {
174
+ expect(applyBias("notify", 50)).toBe("notify");
175
+ expect(applyBias("interrupt", 50)).toBe("interrupt");
176
+ });
177
+
178
+ it("offers Discord automation after 2 annoying ratings, once only", () => {
179
+ let p = defaultProfile(); // seeded with Discord.annoying = 1
180
+ expect(automationOffer(p, "Discord")).toBeNull();
181
+ p = rateSource(p, "Discord", "annoying");
182
+ const offer = automationOffer(p, "Discord");
183
+ expect(offer?.id).toBe("mute_channels");
184
+ p = declineAutomation(p, "Discord");
185
+ expect(automationOffer(p, "Discord")).toBeNull(); // declined = don't re-offer
186
+ });
187
+
188
+ it("Build earns automation through helpful ratings instead", () => {
189
+ let p = defaultProfile();
190
+ p = rateSource(p, "Build", "helpful");
191
+ expect(automationOffer(p, "Build")).toBeNull();
192
+ p = rateSource(p, "Build", "helpful");
193
+ expect(automationOffer(p, "Build")?.id).toBe("watch_builds");
194
+ p = acceptAutomation(p, "Build");
195
+ expect(hasAutomation(p, "Build")).toBe(true);
196
+ expect(automationOffer(p, "Build")).toBeNull();
197
+ });
198
+
199
+ it("automations handle only their scoped events — a mention escapes the muted bog", () => {
200
+ let p = defaultProfile();
201
+ p = acceptAutomation(p, "Discord");
202
+ expect(handlesEvent(p, "Discord", "discord_noise")?.id).toBe("mute_channels");
203
+ expect(handlesEvent(p, "Discord", "discord_mention")).toBeNull();
204
+ });
205
+
206
+ it("watching builds silently never eats a failure", () => {
207
+ let p = defaultProfile();
208
+ p = acceptAutomation(p, "Build");
209
+ expect(handlesEvent(p, "Build", "build_done")?.id).toBe("watch_builds");
210
+ expect(handlesEvent(p, "Build", "build_fail")).toBeNull();
211
+ });
212
+
213
+ it("handlesEvent is null before the automation is accepted", () => {
214
+ expect(handlesEvent(defaultProfile(), "Discord", "discord_noise")).toBeNull();
215
+ });
216
+
217
+ it("confidence caps at 1 after 5 samples", () => {
218
+ expect(confidence(0)).toBe(0);
219
+ expect(confidence(3)).toBeCloseTo(0.6);
220
+ expect(confidence(9)).toBe(1);
221
+ });
222
+
223
+ it("dispositionLabel bands", () => {
224
+ expect(dispositionLabel(30).t).toBe("interrupt freely");
225
+ expect(dispositionLabel(10).t).toBe("speak up");
226
+ expect(dispositionLabel(0).t).toBe("log quietly");
227
+ expect(dispositionLabel(-20).t).toBe("rarely surface");
228
+ expect(dispositionLabel(-40).t).toBe("stay silent");
229
+ });
230
+
231
+ it("irks lists annoying sources, most-flagged first", () => {
232
+ let p = defaultProfile();
233
+ p = rateSource(p, "Mail", "annoying");
234
+ p = rateSource(p, "Mail", "annoying");
235
+ const list = irks(p);
236
+ expect(list.map((s) => s.id)).toEqual(["Mail", "Discord"]);
237
+ });
238
+
239
+ it("voiceTags falls back when taste is undecided", () => {
240
+ expect(voiceTags({ theatrical: 0.5, terse: 0.5, useful: 0.5, warm: 0.5 })).toEqual([
241
+ "still figuring you out",
242
+ ]);
243
+ expect(voiceTags({ theatrical: 0.3, terse: 0.7, useful: 0.7, warm: 0.7 })).toEqual([
244
+ "to the point",
245
+ "brief",
246
+ "warm",
247
+ "low drama",
248
+ ]);
249
+ });
250
+ });
251
+
252
+ describe("nightBloom", () => {
253
+ const item = (id: string, rating: Rating | null): FeedItem => ({
254
+ uid: id + rating,
255
+ id,
256
+ source: ev(id).source,
257
+ say: "",
258
+ tier: "plain",
259
+ tags: [],
260
+ decision: "notify",
261
+ time: "12:00",
262
+ rating,
263
+ });
264
+
265
+ it("converts ratings into personality deltas", () => {
266
+ const r = nightBloom(
267
+ [item("claude_done", "helpful"), item("discord_noise", "annoying"), item("build_done", "cute")],
268
+ () => 0,
269
+ );
270
+ expect(r.deltas).toEqual({
271
+ protectiveness: 2 + 1,
272
+ attachment: 1,
273
+ chattiness: -3 - 1,
274
+ mischief: -2 + 2,
275
+ age: 1,
276
+ });
277
+ expect(r.counts).toEqual({ helpful: 1, annoying: 1, cute: 1 });
278
+ expect(r.rules).toHaveLength(3);
279
+ });
280
+
281
+ it("promotes memories from helpful/annoying ratings, deduped by event", () => {
282
+ const r = nightBloom(
283
+ [item("claude_done", "helpful"), item("claude_done", "helpful"), item("build_done", "cute")],
284
+ () => 0,
285
+ );
286
+ expect(r.memories).toEqual([ev("claude_done").memory]);
287
+ });
288
+
289
+ it("a quiet day still drifts: curiosity +2, one tone memory", () => {
290
+ const r = nightBloom([item("claude_done", null)], () => 0);
291
+ expect(r.deltas).toEqual({ curiosity: 2, age: 1 });
292
+ expect(r.memories[0].type).toBe("tone");
293
+ });
294
+
295
+ it("an empty day yields no memories", () => {
296
+ expect(nightBloom([], () => 0).memories).toEqual([]);
297
+ });
298
+ });
299
+
300
+ describe("applyMode", () => {
301
+ it("silent demotes notify/interrupt to scroll, leaves quiet rungs alone", () => {
302
+ expect(applyMode("notify", "silent")).toBe("scroll");
303
+ expect(applyMode("interrupt", "silent")).toBe("scroll");
304
+ expect(applyMode("glow", "silent")).toBe("glow");
305
+ });
306
+
307
+ it("goblin promotes glow to notify; patrol/polite change nothing", () => {
308
+ expect(applyMode("glow", "goblin")).toBe("notify");
309
+ expect(applyMode("notify", "goblin")).toBe("notify");
310
+ expect(applyMode("notify", "patrol")).toBe("notify");
311
+ expect(applyMode("glow", "polite")).toBe("glow");
312
+ });
313
+ });
314
+
315
+ describe("rateFairy", () => {
316
+ it("nudges personality per rating (prototype steps)", () => {
317
+ expect(rateFairy(neutral, "helpful")).toMatchObject({
318
+ protectiveness: neutral.protectiveness + 2,
319
+ attachment: neutral.attachment + 1,
320
+ });
321
+ expect(rateFairy(neutral, "annoying").chattiness).toBe(neutral.chattiness - 3);
322
+ expect(rateFairy(neutral, "cute").attachment).toBe(neutral.attachment + 1);
323
+ });
324
+
325
+ it("clamps at the 0..100 bounds", () => {
326
+ expect(rateFairy({ ...neutral, protectiveness: 99 }, "helpful").protectiveness).toBe(100);
327
+ expect(rateFairy({ ...neutral, chattiness: 1 }, "annoying").chattiness).toBe(0);
328
+ });
329
+ });
330
+
331
+ describe("moodFor", () => {
332
+ it("maps fairy state to mood with prototype thresholds", () => {
333
+ expect(moodFor({ ...neutral, energy: 20 })).toBe("sleepy");
334
+ expect(moodFor({ ...neutral, mischief: 70 })).toBe("mischief");
335
+ expect(moodFor({ ...neutral, protectiveness: 80 })).toBe("proud");
336
+ expect(moodFor(neutral)).toBe("curious");
337
+ });
338
+ });
frontend/src/engine/events.ts ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { EventDef } from "./types";
2
+
3
+ // The event deck Puck patrols. Lines come in three voice tiers;
4
+ // taste + fairy state pick which one he actually says.
5
+ export const EVENTS: readonly EventDef[] = [
6
+ {
7
+ id: "claude_done",
8
+ source: "Claude",
9
+ target: "claude",
10
+ glyph: "✦",
11
+ title: "Task finished — Implement Memory Garden",
12
+ base: { urgency: 76, relevance: 91, novelty: 48, annoyance: 16, interest: 88 },
13
+ effect: "claude_done",
14
+ lines: {
15
+ plain: "Claude finished the Memory Garden task. 7 files changed — worth a review.",
16
+ playful: "The thing on Desktop 2 says it's done. Seven files different. Go look before it gets cocky.",
17
+ mythic:
18
+ "The code goblin has stopped scratching. It claims the garden blooms — I am suspicious but pleased.",
19
+ },
20
+ memory: {
21
+ text: "Vu wants to know the moment Claude finishes a task.",
22
+ type: "preference",
23
+ icon: "🪶",
24
+ },
25
+ },
26
+ {
27
+ id: "claude_permission",
28
+ source: "Claude",
29
+ target: "claude",
30
+ glyph: "✋",
31
+ title: "Permission needed — run database migration",
32
+ // blocked-waiting-on-you is the design doc's killer case: urgent AND relevant → interrupt band
33
+ base: { urgency: 82, relevance: 88, novelty: 45, annoyance: 20, interest: 70 },
34
+ effect: "none",
35
+ lines: {
36
+ plain: "Claude needs a yes or no before it can continue. It's blocked until you answer.",
37
+ playful: "The code goblin is pawing at a locked door. It needs your yes to keep going.",
38
+ mythic: "The goblin halts mid-ritual, hand raised — it awaits your blessing to proceed.",
39
+ },
40
+ memory: {
41
+ text: "A blocked Claude is wasted time — permission requests are interrupt-worthy.",
42
+ type: "boundary",
43
+ icon: "🗝️",
44
+ },
45
+ },
46
+ {
47
+ id: "build_done",
48
+ source: "Build",
49
+ target: "terminal",
50
+ glyph: "▸",
51
+ title: "Build succeeded in 2m 14s",
52
+ base: { urgency: 58, relevance: 72, novelty: 30, annoyance: 22, interest: 60 },
53
+ effect: "build_done",
54
+ lines: {
55
+ plain: "Build passed. 2m 14s, no errors.",
56
+ playful: "The long-running spell finished and didn't explode. Green across the board.",
57
+ mythic: "The forge cooled. Two minutes of smoke, zero curses. The artifact is whole.",
58
+ },
59
+ memory: {
60
+ text: "Builds on Desktop 2 are worth a quiet nod, not a shout.",
61
+ type: "pattern",
62
+ icon: "🌿",
63
+ },
64
+ },
65
+ {
66
+ id: "build_fail",
67
+ source: "Build",
68
+ target: "terminal",
69
+ glyph: "✕",
70
+ title: "Tests failed — 3 failing in auth.test.ts",
71
+ base: { urgency: 84, relevance: 86, novelty: 55, annoyance: 30, interest: 70 },
72
+ effect: "build_fail",
73
+ lines: {
74
+ plain: "Tests failed: 3 failing in auth.test.ts. You'll want this now.",
75
+ playful: "The forge spat sparks — three tests went red in auth. I'd peek before it spreads.",
76
+ mythic: "Something in the auth thicket bit back. Three little failures, fangs out. I bring word early.",
77
+ },
78
+ memory: {
79
+ text: "Vu wants failing tests surfaced fast, even mid-focus.",
80
+ type: "boundary",
81
+ icon: "🍄",
82
+ },
83
+ },
84
+ {
85
+ id: "mail_important",
86
+ source: "Mail",
87
+ target: "mail",
88
+ glyph: "✉",
89
+ title: "New mail — Hugging Face (hackathon)",
90
+ base: { urgency: 64, relevance: 80, novelty: 60, annoyance: 28, interest: 72 },
91
+ effect: "mail_unread",
92
+ lines: {
93
+ plain: "Email from Hugging Face about the hackathon. Looks like it matters.",
94
+ playful: "A letter came from the hackathon people. It smells official, not spammy.",
95
+ mythic: "A rectangle from Hugging Face arrived. It hums with deadline. Worth opening, I think.",
96
+ },
97
+ memory: {
98
+ text: "Mail from the hackathon org is always worth a flag.",
99
+ type: "preference",
100
+ icon: "🗝️",
101
+ },
102
+ },
103
+ {
104
+ id: "mail_finance",
105
+ source: "Mail",
106
+ target: "mail",
107
+ glyph: "✉",
108
+ title: "New mail — Fidelity statement",
109
+ base: { urgency: 30, relevance: 44, novelty: 25, annoyance: 46, interest: 30 },
110
+ effect: "mail_unread",
111
+ lines: {
112
+ plain: "A statement from Fidelity landed. Not urgent — filed it for later.",
113
+ playful: "A rectangle from Fidelity arrived. It smells like finance. No fire, just numbers.",
114
+ mythic: "A grey envelope from the money-keepers. It can wait in the reeds until you're ready.",
115
+ },
116
+ memory: {
117
+ text: "Finance mail is low-urgency unless Vu says otherwise.",
118
+ type: "aversion",
119
+ icon: "🍄",
120
+ },
121
+ },
122
+ {
123
+ id: "discord_noise",
124
+ source: "Discord",
125
+ target: "discord",
126
+ glyph: "◍",
127
+ title: "12 messages in #general",
128
+ base: { urgency: 12, relevance: 18, novelty: 10, annoyance: 86, interest: 14 },
129
+ effect: "discord_noise",
130
+ lines: {
131
+ plain: "Discord #general got noisy. Nothing aimed at you — leaving it.",
132
+ playful: "Discord made swamp noises again. Twelve of them. No emergency in the muck.",
133
+ mythic: "The social bog gurgled twelve times. I waded in, found no treasure, waded out.",
134
+ },
135
+ memory: {
136
+ text: "Vu does not want general Discord chatter surfaced.",
137
+ type: "aversion",
138
+ icon: "🍄",
139
+ },
140
+ },
141
+ {
142
+ id: "discord_mention",
143
+ source: "Discord",
144
+ target: "discord",
145
+ glyph: "◍",
146
+ title: "@mention from Mira in #puck-build",
147
+ base: { urgency: 70, relevance: 78, novelty: 65, annoyance: 32, interest: 66 },
148
+ effect: "discord_mention",
149
+ lines: {
150
+ plain: "Mira mentioned you in #puck-build. That one's actually for you.",
151
+ playful: "Someone named Mira poked you directly in #puck-build. The rest is bog noise, but this isn't.",
152
+ mythic: "A voice in the bog called your true name — Mira, in the build-channel. That, I carry to you.",
153
+ },
154
+ memory: {
155
+ text: "Direct @mentions are worth surfacing; channel noise is not.",
156
+ type: "boundary",
157
+ icon: "🗝️",
158
+ },
159
+ },
160
+ {
161
+ id: "calendar_soon",
162
+ source: "Calendar",
163
+ target: "calendar",
164
+ glyph: "◷",
165
+ title: "Demo sync in 10 minutes",
166
+ base: { urgency: 80, relevance: 82, novelty: 40, annoyance: 24, interest: 58 },
167
+ effect: "calendar_soon",
168
+ lines: {
169
+ plain: "Heads up — demo sync in 10 minutes.",
170
+ playful: "A meeting is creeping up — the demo sync, ten minutes out. Just so you're not ambushed.",
171
+ mythic: "The clock-spirits whisper: a gathering approaches in ten minutes. Ready your face.",
172
+ },
173
+ memory: {
174
+ text: "Vu likes a 10-minute warning before meetings.",
175
+ type: "preference",
176
+ icon: "🌿",
177
+ },
178
+ },
179
+ {
180
+ id: "stale_tab",
181
+ source: "Browser",
182
+ target: null,
183
+ glyph: "❍",
184
+ title: "Tab open 3 days — 'sqlite-vec docs'",
185
+ base: { urgency: 8, relevance: 34, novelty: 20, annoyance: 40, interest: 52 },
186
+ effect: "none",
187
+ lines: {
188
+ plain: "That sqlite-vec docs tab has been open 3 days. Read it or close it?",
189
+ playful: "A tab has been loitering for three days — sqlite-vec docs. It deserves judgment.",
190
+ mythic: "A tab has gone feral after three days in the open. The sqlite-vec scroll. Tame it or free it.",
191
+ },
192
+ memory: {
193
+ text: "Vu collects docs tabs and rarely returns to them.",
194
+ type: "pattern",
195
+ icon: "❍",
196
+ },
197
+ },
198
+ {
199
+ id: "browser_newpage",
200
+ source: "Browser",
201
+ target: null,
202
+ glyph: "❍",
203
+ // childlike-wonder dimension: a fresh page is a doorway, and Puck wants to peek
204
+ title: "New page opened — a long-read about deep-sea creatures",
205
+ base: { urgency: 10, relevance: 38, novelty: 80, annoyance: 18, interest: 82 },
206
+ effect: "none",
207
+ lines: {
208
+ plain: "You opened something about deep-sea creatures. Looks interesting.",
209
+ playful: "Ooh — a new portal just opened, all about deep-sea creatures. Can I look? I want to look.",
210
+ mythic:
211
+ "A doorway bloomed in the glass! Beyond it, glowing things from the deep. I pressed my face to it.",
212
+ },
213
+ memory: {
214
+ text: "Vu opens long-reads; Puck is endlessly curious about them.",
215
+ type: "preference",
216
+ icon: "🫧",
217
+ },
218
+ },
219
+ {
220
+ id: "browser_rabbithole",
221
+ source: "Browser",
222
+ target: null,
223
+ glyph: "❍",
224
+ title: "Eleven tabs deep on one topic",
225
+ base: { urgency: 14, relevance: 42, novelty: 55, annoyance: 30, interest: 74 },
226
+ effect: "none",
227
+ lines: {
228
+ plain: "Eleven tabs on the same thing now. You're down a rabbit hole.",
229
+ playful:
230
+ "Eleven little doorways, all the same shape — you've tumbled down a rabbit hole and I tumbled after you.",
231
+ mythic:
232
+ "Eleven portals, one obsession. We are both lost in the warren now, and I do not wish to be found.",
233
+ },
234
+ memory: {
235
+ text: "Vu rabbit-holes; Puck happily follows the curiosity rather than scolding it.",
236
+ type: "pattern",
237
+ icon: "🫧",
238
+ },
239
+ },
240
+ {
241
+ id: "git_push",
242
+ source: "Git",
243
+ target: "terminal",
244
+ glyph: "⎇",
245
+ // the showcase for repetition-as-personality: celebrated once, shrugged-at on a streak
246
+ title: "Pushed 3 commits to origin/main",
247
+ base: { urgency: 28, relevance: 56, novelty: 34, annoyance: 16, interest: 52 },
248
+ effect: "none",
249
+ lines: {
250
+ plain: "Pushed to origin/main — 3 commits up.",
251
+ playful: "Whoosh — three commits flung at origin. They stuck the landing.",
252
+ mythic: "Three parcels hurled into the great remote void. They arrived. Probably.",
253
+ },
254
+ memory: {
255
+ text: "Vu ships in small frequent pushes — celebrate the first, shrug at the streak.",
256
+ type: "pattern",
257
+ icon: "⎇",
258
+ },
259
+ },
260
+ ];
frontend/src/engine/index.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export { reply } from "./chat";
2
+ export { EVENTS } from "./events";
3
+ export * as Learn from "./learning";
4
+ export { nightBloom, outcomeRating } from "./nightBloom";
5
+ export {
6
+ asPeekEmotion,
7
+ PEEK_EMOTIONS,
8
+ type PeekEmotion,
9
+ type Reaction,
10
+ type ReactionKind,
11
+ reactionForEmotion,
12
+ reactionForEvent,
13
+ reactionForRating,
14
+ summonReaction,
15
+ } from "./reactions";
16
+ export { applyMode, decide, score } from "./scoring";
17
+ export { learn, STYLE_TAGS, speak } from "./speech";
18
+ export { defaultState, defaultTaste, moodFor, rateFairy, seedMemories } from "./state";
19
+ export { buildTraces, type TraceRecord } from "./traces";
20
+ export * from "./types";
21
+ export * from "./util";
22
+ export { isWireEvent, toEventDef, type WireEvent } from "./wire";
frontend/src/engine/learning.ts ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The visible learning profile: per-source interruption bias that ratings
2
+ // push around, learned tone tags, and the automation offers that bridge
3
+ // toward acting on the user's behalf.
4
+
5
+ import type {
6
+ AutomationDef,
7
+ Decision,
8
+ LearnedAutomation,
9
+ Profile,
10
+ Rating,
11
+ SourceId,
12
+ SourceStats,
13
+ Taste,
14
+ } from "./types";
15
+ import { clamp } from "./util";
16
+
17
+ export interface SourceInfo {
18
+ id: SourceId;
19
+ glyph: string;
20
+ label: string;
21
+ }
22
+
23
+ export const SOURCES: readonly SourceInfo[] = [
24
+ { id: "Claude", glyph: "✳", label: "Claude Code" },
25
+ { id: "Build", glyph: "▦", label: "Builds & tests" },
26
+ { id: "Mail", glyph: "✉", label: "Mail" },
27
+ { id: "Discord", glyph: "◍", label: "Discord" },
28
+ { id: "Calendar", glyph: "◷", label: "Calendar" },
29
+ { id: "Browser", glyph: "❍", label: "Stray tabs" },
30
+ { id: "Git", glyph: "⎇", label: "Git" },
31
+ ];
32
+
33
+ // automations Puck can offer once he's confident a source annoys you
34
+ export const AUTOMATIONS: Partial<Record<SourceId, AutomationDef>> = {
35
+ Discord: {
36
+ id: "mute_channels",
37
+ verb: "auto-mute noisy channels",
38
+ offer:
39
+ "You've waved off the social bog a few times now. Want me to mute the loud channels and only ping you on direct @mentions?",
40
+ done: "muted #general · only @mentions reach you",
41
+ handles: ["discord_noise"],
42
+ },
43
+ Mail: {
44
+ id: "file_finance",
45
+ verb: "auto-file finance mail",
46
+ offer:
47
+ "Three statements, three shrugs. Shall I quietly file finance & statement mail from now on, and only flag the ones that look urgent?",
48
+ done: "filed Fidelity statement · finance mail handled",
49
+ handles: ["mail_finance"],
50
+ },
51
+ Browser: {
52
+ id: "close_stale",
53
+ verb: "close stale tabs",
54
+ offer: "These docs tabs go feral and you never return. Want me to close anything untouched for 3+ days?",
55
+ done: "closed 'sqlite-vec docs' · 3 days untouched",
56
+ handles: ["stale_tab"],
57
+ },
58
+ Build: {
59
+ id: "watch_builds",
60
+ verb: "watch builds silently",
61
+ offer:
62
+ "You always glance at builds yourself. Want me to stop announcing the passing ones and only speak up when something breaks?",
63
+ done: "passing builds logged silently · failures still surface",
64
+ handles: ["build_done"],
65
+ },
66
+ };
67
+
68
+ const EMPTY_STATS: SourceStats = { bias: 0, helpful: 0, annoying: 0, cute: 0, samples: 0 };
69
+
70
+ /** A source's stats, or empty defaults if this (possibly older) profile predates
71
+ * the source. Indexing profile.sources directly is unsafe — use this. */
72
+ export function statsFor(profile: Profile, id: SourceId): SourceStats {
73
+ return profile.sources[id] ?? EMPTY_STATS;
74
+ }
75
+
76
+ /** Backfill any SOURCES added after this profile was persisted. Adding a source
77
+ * must never leave old profiles with a missing entry (that crashed the Learning
78
+ * tab render). Returns the same object when nothing's missing. */
79
+ export function ensureSources(profile: Profile): Profile {
80
+ const missing = SOURCES.filter((s) => !profile.sources[s.id]);
81
+ if (!missing.length) return profile;
82
+ const sources = { ...profile.sources };
83
+ for (const s of missing) sources[s.id] = { ...EMPTY_STATS };
84
+ return { ...profile, sources };
85
+ }
86
+
87
+ export function defaultProfile(): Profile {
88
+ const sources = {} as Record<SourceId, SourceStats>;
89
+ for (const s of SOURCES) sources[s.id] = { ...EMPTY_STATS };
90
+ // seed a couple so a fresh profile isn't blank
91
+ sources.Claude.bias = 10;
92
+ sources.Claude.helpful = 1;
93
+ sources.Claude.samples = 1;
94
+ sources.Discord.bias = -8;
95
+ sources.Discord.annoying = 1;
96
+ sources.Discord.samples = 1;
97
+ return { sources, automations: [], offered: {} };
98
+ }
99
+
100
+ export function sourceBias(profile: Profile, source: SourceId): number {
101
+ return profile.sources[source]?.bias ?? 0;
102
+ }
103
+
104
+ /** A rating pushes that source's bias (helpful↑ surface more, annoying↓ back off). */
105
+ export function rateSource(profile: Profile, source: SourceId, rating: Rating): Profile {
106
+ if (!profile.sources[source]) return profile;
107
+ const s = { ...profile.sources[source] };
108
+ s.samples += 1;
109
+ if (rating === "helpful") {
110
+ s.bias = clamp(s.bias + 9, -50, 50);
111
+ s.helpful++;
112
+ }
113
+ if (rating === "annoying") {
114
+ s.bias = clamp(s.bias - 12, -50, 50);
115
+ s.annoying++;
116
+ }
117
+ if (rating === "cute") {
118
+ s.bias = clamp(s.bias - 3, -50, 50);
119
+ s.cute++;
120
+ }
121
+ return { ...profile, sources: { ...profile.sources, [source]: s } };
122
+ }
123
+
124
+ // Learned bias shifts a decision up/down the ladder (never manufactures
125
+ // a full interrupt — those stay reserved for genuine urgency).
126
+ const LADDER: Decision[] = ["ignore", "scroll", "glow", "notify", "interrupt"];
127
+
128
+ export function applyBias(decision: Decision, bias: number): Decision {
129
+ let idx = LADDER.indexOf(decision);
130
+ if (idx < 0) return decision;
131
+ idx = clamp(idx + Math.round(bias / 18), 0, 4);
132
+ if (decision !== "interrupt" && idx === 4) idx = 3;
133
+ return LADDER[idx];
134
+ }
135
+
136
+ export interface Disposition {
137
+ t: string;
138
+ k: "hi" | "mid" | "lo" | "off";
139
+ }
140
+
141
+ export function dispositionLabel(bias: number): Disposition {
142
+ if (bias >= 28) return { t: "interrupt freely", k: "hi" };
143
+ if (bias >= 8) return { t: "speak up", k: "mid" };
144
+ if (bias > -8) return { t: "log quietly", k: "lo" };
145
+ if (bias > -28) return { t: "rarely surface", k: "lo" };
146
+ return { t: "stay silent", k: "off" };
147
+ }
148
+
149
+ /** Confidence grows with samples, caps at 1. */
150
+ export function confidence(samples: number): number {
151
+ return Math.min(1, samples / 5);
152
+ }
153
+
154
+ /** Readable tone tags from the taste vector. */
155
+ export function voiceTags(taste: Taste): string[] {
156
+ const tags: string[] = [];
157
+ if (taste.useful > 0.6) tags.push("to the point");
158
+ if (taste.terse > 0.6) tags.push("brief");
159
+ if (taste.warm > 0.6) tags.push("warm");
160
+ if (taste.theatrical > 0.62) tags.push("theatrical");
161
+ else if (taste.theatrical < 0.4) tags.push("low drama");
162
+ if (!tags.length) tags.push("still figuring you out");
163
+ return tags;
164
+ }
165
+
166
+ /** What irks you — distinct annoying sources, most-flagged first. */
167
+ export function irks(profile: Profile): (SourceInfo & { n: number })[] {
168
+ return SOURCES.map((s) => ({ ...s, n: statsFor(profile, s.id).annoying }))
169
+ .filter((s) => s.n > 0)
170
+ .sort((a, b) => b.n - a.n);
171
+ }
172
+
173
+ /** Should Puck offer an automation for this source right now? */
174
+ export function automationOffer(
175
+ profile: Profile,
176
+ source: SourceId,
177
+ ): (AutomationDef & { source: SourceId }) | null {
178
+ const auto = AUTOMATIONS[source];
179
+ if (!auto) return null;
180
+ if (profile.offered[source]) return null;
181
+ if (profile.automations.find((a) => a.id === auto.id)) return null;
182
+ const s = profile.sources[source];
183
+ // Build earns its automation by being consistently *helpful*; the rest by annoying you
184
+ const ready = source === "Build" ? s.helpful >= 2 : s.annoying >= 2;
185
+ return ready ? { source, ...auto } : null;
186
+ }
187
+
188
+ export function acceptAutomation(profile: Profile, source: SourceId): Profile {
189
+ const auto = AUTOMATIONS[source];
190
+ if (!auto) return profile;
191
+ return {
192
+ ...profile,
193
+ offered: { ...profile.offered, [source]: true },
194
+ automations: [...profile.automations, { id: auto.id, source, verb: auto.verb, done: auto.done }],
195
+ };
196
+ }
197
+
198
+ export function declineAutomation(profile: Profile, source: SourceId): Profile {
199
+ return { ...profile, offered: { ...profile.offered, [source]: true } };
200
+ }
201
+
202
+ export function hasAutomation(profile: Profile, source: SourceId): boolean {
203
+ const auto = AUTOMATIONS[source];
204
+ return !!auto && profile.automations.some((a) => a.id === auto.id);
205
+ }
206
+
207
+ /** The accepted automation that silently handles this event, if any.
208
+ * Scoped per-event, not per-source: muting the bog must not eat a @mention. */
209
+ export function handlesEvent(profile: Profile, source: SourceId, eventId: string): LearnedAutomation | null {
210
+ const auto = AUTOMATIONS[source];
211
+ if (!auto?.handles.includes(eventId)) return null;
212
+ return profile.automations.find((a) => a.id === auto.id) ?? null;
213
+ }
frontend/src/engine/nightBloom.ts ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { EVENTS } from "./events";
2
+ import type { FairyStat, FeedItem, MemorySeed, NightBloomResult, Rating, Rng } from "./types";
3
+ import { pick } from "./util";
4
+
5
+ const DREAMS = [
6
+ "I walked across three desktops. One smelled of code smoke, one of unopened letters, one of abandoned tabs.",
7
+ "I dreamed Desktop 2 was a forest of half-built things, all of them humming to be finished.",
8
+ "In the dream the inbox was a swamp and I was a small loyal frog, choosing which croaks to carry to you.",
9
+ "I dreamed I grew a second pair of wings, just for patrolling the corners you forget.",
10
+ ] as const;
11
+
12
+ const MORNINGS = [
13
+ "I dreamed of Desktop 2. It is where unfinished things go to molt.",
14
+ "Morning. I sorted the moss from the noise. I am a slightly better familiar than yesterday.",
15
+ "I woke a little less chatty and a little more loyal to the things you leave running.",
16
+ "The bog is quieter in my head now. I kept what mattered, composted the rest.",
17
+ ] as const;
18
+
19
+ /** A surfaced comment's learning signal, derived from BEHAVIOR (not buttons):
20
+ * tapping through = wanted it, swatting away = too noisy. Explicit ratings (the
21
+ * sim's bubble/modal) still win when present. This is the heart of "learn while
22
+ * you sleep" — the day's actions become tomorrow's taste. */
23
+ export function outcomeRating(item: FeedItem): Rating | null {
24
+ if (item.rating) return item.rating;
25
+ const o = item.outcome;
26
+ if (!o) return null;
27
+ if (o.type === "clicked_through") return "helpful"; // opened it → worth surfacing
28
+ if (o.type === "swatted" || o.type === "dismissed") return "annoying"; // flung away → too much
29
+ return null; // expired / unresolved — no clear signal, don't punish silence
30
+ }
31
+
32
+ /** Consolidate the day's feed into personality drift, memories, and a dream. */
33
+ export function nightBloom(log: FeedItem[], rng: Rng = Math.random): NightBloomResult {
34
+ const signals = log
35
+ .map((e) => ({ e, r: outcomeRating(e) }))
36
+ .filter((s): s is { e: FeedItem; r: Rating } => s.r != null);
37
+ const counts: Record<Rating, number> = { helpful: 0, annoying: 0, cute: 0 };
38
+ for (const s of signals) counts[s.r] += 1;
39
+
40
+ // personality drift from the day's reactions
41
+ const deltas: NightBloomResult["deltas"] = {};
42
+ const add = (k: FairyStat | "age", v: number) => {
43
+ deltas[k] = (deltas[k] || 0) + v;
44
+ };
45
+ if (counts.helpful) {
46
+ add("protectiveness", counts.helpful * 2);
47
+ add("attachment", counts.helpful);
48
+ }
49
+ if (counts.annoying) {
50
+ add("chattiness", -counts.annoying * 3);
51
+ add("mischief", -counts.annoying * 2);
52
+ add("protectiveness", counts.annoying);
53
+ }
54
+ if (counts.cute) {
55
+ add("mischief", counts.cute * 2);
56
+ add("chattiness", -counts.cute);
57
+ }
58
+ if (!signals.length) add("curiosity", 2);
59
+ add("age", 1); // a small drift each night regardless
60
+
61
+ // memories: promote the strongest signals (a thing tapped-through or swatted)
62
+ const memories: MemorySeed[] = [];
63
+ const seen = new Set<string>();
64
+ for (const { e, r } of signals) {
65
+ const def = EVENTS.find((x) => x.id === e.id);
66
+ if (!def || seen.has(def.id)) continue;
67
+ if (r === "helpful" || r === "annoying") {
68
+ memories.push({ ...def.memory });
69
+ seen.add(def.id);
70
+ }
71
+ }
72
+ if (!memories.length && log.length) {
73
+ memories.push({
74
+ text: "Vu mostly let me patrol in peace today. I'll trust my own taste more.",
75
+ type: "tone",
76
+ icon: "🌿",
77
+ });
78
+ }
79
+
80
+ const rules: string[] = [];
81
+ if (counts.helpful) rules.push("Keep surfacing Claude and build completions promptly.");
82
+ if (counts.annoying) rules.push("Fewer interruptions for low-stakes noise.");
83
+ if (counts.cute) rules.push("A little more theatre is allowed.");
84
+
85
+ return {
86
+ deltas,
87
+ memories,
88
+ rules,
89
+ dream: pick(DREAMS, rng),
90
+ morning: pick(MORNINGS, rng),
91
+ counts,
92
+ };
93
+ }
frontend/src/engine/reactions.test.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { EVENTS } from "./events";
3
+ import {
4
+ asPeekEmotion,
5
+ PEEK_EMOTIONS,
6
+ reactionForEmotion,
7
+ reactionForEvent,
8
+ reactionForRating,
9
+ } from "./reactions";
10
+
11
+ const ev = (id: string) => {
12
+ const e = EVENTS.find((x) => x.id === id);
13
+ if (!e) throw new Error(`no such event: ${id}`);
14
+ return e;
15
+ };
16
+ const stableRng = () => 0.99; // never trips the mischief-dance branch
17
+
18
+ describe("reactionForEvent", () => {
19
+ it("celebrates a fresh win and shouts", () => {
20
+ const r = reactionForEvent(ev("git_push"), { repeats: 0, rng: stableRng });
21
+ expect(r.kind).toBe("celebrate");
22
+ expect(r.shout).toBe("shipped!");
23
+ });
24
+
25
+ it("decays to a shrug when the same event keeps recurring", () => {
26
+ expect(reactionForEvent(ev("git_push"), { repeats: 1, rng: stableRng }).kind).toBe("pop");
27
+ expect(reactionForEvent(ev("git_push"), { repeats: 3, rng: stableRng }).kind).toBe("shrug");
28
+ });
29
+
30
+ it("keeps alarms sharp no matter how often they fire", () => {
31
+ expect(reactionForEvent(ev("build_fail"), { repeats: 0, rng: stableRng }).kind).toBe("nani");
32
+ expect(reactionForEvent(ev("build_fail"), { repeats: 9, rng: stableRng }).kind).toBe("nani");
33
+ });
34
+
35
+ it("high mischief can turn a celebration into a dance", () => {
36
+ const r = reactionForEvent(ev("claude_done"), { repeats: 0, mischief: 90, rng: () => 0 });
37
+ expect(r.kind).toBe("dance");
38
+ });
39
+
40
+ it("falls back to a pop for an unmapped event", () => {
41
+ const fake = { ...ev("git_push"), id: "totally_new" };
42
+ expect(reactionForEvent(fake, { rng: stableRng }).kind).toBe("pop");
43
+ });
44
+ });
45
+
46
+ describe("reactionForRating", () => {
47
+ it("maps ratings to felt reactions", () => {
48
+ expect(reactionForRating("cute").kind).toBe("dance");
49
+ expect(reactionForRating("annoying").kind).toBe("sulk");
50
+ expect(reactionForRating("helpful").kind).toBe("pop");
51
+ });
52
+ });
53
+
54
+ describe("peek emotions", () => {
55
+ const stable = () => 0.99;
56
+
57
+ it("maps each emotion to its gesture with a shout", () => {
58
+ expect(reactionForEmotion("confused").kind).toBe("nani");
59
+ expect(reactionForEmotion("amused").kind).toBe("laugh");
60
+ expect(reactionForEmotion("worried").kind).toBe("fret");
61
+ expect(reactionForEmotion("wistful").kind).toBe("sad");
62
+ expect(reactionForEmotion("curious").kind).toBe("perk");
63
+ expect(reactionForEmotion("delighted", { rng: stable }).kind).toBe("celebrate");
64
+ for (const e of PEEK_EMOTIONS) expect(reactionForEmotion(e).shout).toBeTruthy();
65
+ });
66
+
67
+ it("lets a giddy (high-mischief) Puck escalate delight into a dance", () => {
68
+ expect(reactionForEmotion("delighted", { mischief: 90, rng: () => 0 }).kind).toBe("dance");
69
+ });
70
+
71
+ it("coerces unknown/missing brain strings to curious", () => {
72
+ expect(asPeekEmotion("amused")).toBe("amused");
73
+ expect(asPeekEmotion("nonsense")).toBe("curious");
74
+ expect(asPeekEmotion(undefined)).toBe("curious");
75
+ expect(asPeekEmotion(null)).toBe("curious");
76
+ });
77
+ });
frontend/src/engine/reactions.ts ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Puck's visible reactions — the personality layer that sits on top of judgment.
2
+ // The engine already decides *whether* to surface an event (the Decision ladder)
3
+ // and *what words* to use (speech tiers); this decides *how he physically reacts*:
4
+ // a one-shot body gesture plus an optional shout floating over the sprite.
5
+ // Pure data — SimApp plays the gesture; nothing here touches React or the DOM.
6
+
7
+ import type { EventDef, Rating, Rng } from "./types";
8
+ import { pick } from "./util";
9
+
10
+ export type ReactionKind =
11
+ | "celebrate" // joyful bounce + spin — a win landed
12
+ | "nani" // anime freeze-frame snap-zoom — alarm, "wait, WHAT?"
13
+ | "perk" // lean-in scale-up — curiosity, something worth a look
14
+ | "shrug" // deflated tilt — nonchalance, "not this again"
15
+ | "sulk" // droop + shrink — chastened, an annoyed swat landed
16
+ | "dance" // wiggle spin — delight / high mischief
17
+ | "summon" // big insistent size-up + bounce — "hey, look at this!" (replaces the
18
+ // screen-takeover interrupt modal: demands attention without blocking)
19
+ | "laugh" // giggle bounce — found something funny (a meme, a joke)
20
+ | "sad" // droop + dim — something poignant, lonely, or failing
21
+ | "fret" // recoil + tremble — the human seems angry or frustrated
22
+ | "pop"; // tiny default acknowledgement
23
+
24
+ export interface Reaction {
25
+ kind: ReactionKind;
26
+ /** Floating exclamation over the sprite; omit for a wordless gesture. */
27
+ shout?: string;
28
+ /** One-shot duration (ms). Kept in lockstep with the CSS animation lengths in
29
+ * ui.css (.puck.react-*) so SimApp clears the reaction exactly as it ends. */
30
+ ms: number;
31
+ }
32
+
33
+ const MS: Record<ReactionKind, number> = {
34
+ celebrate: 1100,
35
+ nani: 900,
36
+ perk: 750,
37
+ shrug: 1000,
38
+ sulk: 1050,
39
+ dance: 1500,
40
+ summon: 1600,
41
+ laugh: 1200,
42
+ sad: 1300,
43
+ fret: 1100,
44
+ pop: 480,
45
+ };
46
+
47
+ const make = (kind: ReactionKind, shout?: string): Reaction => ({ kind, shout, ms: MS[kind] });
48
+
49
+ // ---- peek emotions: how Puck FEELS about a patch he peeked at ----------------
50
+ // The vision brain classifies each peek into one of these (it's a learning, not-too-
51
+ // clever sprite — confusion is common and endearing). Each maps to a body gesture +
52
+ // a pool of shouts. This is the companion loop's personality layer (the event map
53
+ // below is the older, dormant notification path).
54
+
55
+ export type PeekEmotion = "curious" | "confused" | "amused" | "delighted" | "worried" | "wistful";
56
+
57
+ const EMOTION_GESTURE: Record<PeekEmotion, ReactionKind> = {
58
+ curious: "perk",
59
+ confused: "nani", // it doesn't understand — the freeze-frame "wait, what IS that?"
60
+ amused: "laugh",
61
+ delighted: "celebrate",
62
+ worried: "fret", // the human seems angry/frustrated, or errors are piling up
63
+ wistful: "sad",
64
+ };
65
+
66
+ const EMOTION_SHOUTS: Record<PeekEmotion, readonly string[]> = {
67
+ curious: ["ooh?", "hm?", "?"],
68
+ confused: ["NANI?!", "eh?!", "wha—", "huh?!"],
69
+ amused: ["pfft", "hehe", "haha!", "heh"],
70
+ delighted: ["ooh!", "yes!", "nice!", "✨"],
71
+ worried: ["eep", "easy…", "uh oh", "yikes"],
72
+ wistful: ["…oh", "hm.", "…"],
73
+ };
74
+
75
+ /** Canonical emotion list (mirrors PEEK_EMOTIONS in brain.py). */
76
+ export const PEEK_EMOTIONS = Object.keys(EMOTION_GESTURE) as PeekEmotion[];
77
+
78
+ /** Coerce an arbitrary brain string to a known emotion (defaults to curious). */
79
+ export function asPeekEmotion(s: string | undefined | null): PeekEmotion {
80
+ return s != null && Object.hasOwn(EMOTION_GESTURE, s) ? (s as PeekEmotion) : "curious";
81
+ }
82
+
83
+ /** Map a peeked-at emotion to the gesture Puck plays, with a fitting shout.
84
+ * A giddy (high-mischief) Puck escalates plain delight into a full dance. */
85
+ export function reactionForEmotion(
86
+ emotion: PeekEmotion,
87
+ opts: { mischief?: number; rng?: Rng } = {},
88
+ ): Reaction {
89
+ const { mischief = 45, rng = Math.random } = opts;
90
+ const shout = pick(EMOTION_SHOUTS[emotion], rng);
91
+ if (emotion === "delighted" && mischief > 70 && rng() < 0.5) return make("dance", shout);
92
+ return make(EMOTION_GESTURE[emotion], shout);
93
+ }
94
+
95
+ /** The attention-grab for important events — Puck's non-blocking answer to the
96
+ * old interrupt modal. Triggered by the orchestrator, not the event→reaction map. */
97
+ export function summonReaction(): Reaction {
98
+ return make("summon", "!");
99
+ }
100
+
101
+ // Base reaction per event id. Anything not listed falls back to a small pop.
102
+ const BY_ID: Record<string, Reaction> = {
103
+ claude_done: make("celebrate", "done!"),
104
+ claude_permission: make("perk", "?"),
105
+ build_done: make("celebrate", "green!"),
106
+ build_fail: make("nani", "NANI?!"),
107
+ mail_important: make("perk"),
108
+ mail_finance: make("shrug"),
109
+ discord_mention: make("perk", "!"),
110
+ discord_noise: make("shrug"),
111
+ calendar_soon: make("nani", "!"),
112
+ stale_tab: make("shrug"),
113
+ browser_newpage: make("perk", "ooh"),
114
+ browser_rabbithole: make("perk", "deeper…"),
115
+ git_push: make("celebrate", "shipped!"),
116
+ };
117
+
118
+ // Decay toward nonchalance when the same thing keeps happening — the first push
119
+ // is "shipped!", the second a quiet pop, the third a tired shrug.
120
+ const REPEAT_SHRUGS = ["again?", "…another one", "not again", "sure, fine"] as const;
121
+
122
+ /** How Puck physically reacts to an event surfacing.
123
+ * Repetition is the personality beat (celebrate → pop → shrug), but *alarms*
124
+ * keep their bite no matter how often they recur — a fire is always a fire. */
125
+ export function reactionForEvent(
126
+ ev: EventDef,
127
+ opts: { repeats?: number; mischief?: number; rng?: Rng } = {},
128
+ ): Reaction {
129
+ const { repeats = 0, mischief = 45, rng = Math.random } = opts;
130
+ const base = BY_ID[ev.id] ?? make("pop");
131
+
132
+ if (base.kind !== "nani") {
133
+ if (repeats >= 2) return make("shrug", pick(REPEAT_SHRUGS, rng));
134
+ if (repeats === 1) return make("pop"); // muted second take
135
+ }
136
+ // a high-mischief Puck sometimes turns a celebration into a little dance
137
+ if (base.kind === "celebrate" && mischief > 70 && rng() < 0.4) return make("dance", base.shout);
138
+ return base;
139
+ }
140
+
141
+ /** Reaction to the user rating one of Puck's comments. */
142
+ export function reactionForRating(rating: Rating): Reaction {
143
+ if (rating === "cute") return make("dance", "♪");
144
+ if (rating === "annoying") return make("sulk", "…sorry");
145
+ return make("pop"); // helpful — a small satisfied pop
146
+ }
frontend/src/engine/scoring.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Decision, EventDef, EventScores, FairyState, PuckMode } from "./types";
2
+ import { clamp } from "./util";
3
+
4
+ /** Personality bends the raw event scores before the decision bands see them. */
5
+ export function score(ev: EventDef, fs: FairyState): EventScores {
6
+ const s = { ...ev.base };
7
+ // protectiveness raises the bar for annoyance; mischief lowers Puck's filter
8
+ s.annoyance = clamp(s.annoyance + (fs.protectiveness - 50) * 0.25 - (fs.mischief - 50) * 0.15);
9
+ s.interest = clamp(s.interest + (fs.curiosity - 50) * 0.2 + (fs.mischief - 50) * 0.15);
10
+ s.relevance = clamp(s.relevance + (fs.protectiveness - 50) * 0.1);
11
+ return s;
12
+ }
13
+
14
+ /** Decision bands → rungs of the interruption ladder. */
15
+ export function decide(s: EventScores): Decision {
16
+ const worth = s.relevance + s.urgency + s.interest - s.annoyance;
17
+ if (s.urgency >= 80 && s.relevance >= 80) return "interrupt";
18
+ if (s.relevance < 35 && s.annoyance > 60) return "ignore";
19
+ if (worth > 180) return "notify";
20
+ if (worth > 120) return "glow";
21
+ if (worth > 70) return "scroll";
22
+ return "ignore";
23
+ }
24
+
25
+ /** Mode overrides applied after scoring + learned bias (mirrors applyBias). */
26
+ export function applyMode(decision: Decision, mode: PuckMode): Decision {
27
+ if (mode === "silent" && (decision === "notify" || decision === "interrupt")) return "scroll";
28
+ if (mode === "goblin" && decision === "glow") return "notify";
29
+ return decision;
30
+ }
frontend/src/engine/speech.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { EventDef, FairyState, Rating, Rng, StyleTag, Taste, Tier, Utterance } from "./types";
2
+ import { clamp01, pick } from "./util";
3
+
4
+ export const STYLE_TAGS: Record<Tier, StyleTag[]> = {
5
+ plain: ["useful", "terse"],
6
+ playful: ["useful", "warm"],
7
+ mythic: ["theatrical", "warm"],
8
+ };
9
+
10
+ const SIDE_QUESTS = [
11
+ "Pay me in a snack and I'll watch it for you.",
12
+ "I left a tiny ✦ on it so I can find it again.",
13
+ "I also rearranged one desktop icon. You'll never know which.",
14
+ ] as const;
15
+
16
+ /** Pick the voice tier the taste vector + fairy state favor, with a little caprice. */
17
+ export function speak(ev: EventDef, fs: FairyState, taste: Taste, rng: Rng = Math.random): Utterance {
18
+ const tiers = Object.keys(ev.lines) as Tier[];
19
+ let best: Tier = tiers[0];
20
+ let bestW = -1e9;
21
+ for (const tier of tiers) {
22
+ const tags = STYLE_TAGS[tier];
23
+ let w = tags.reduce((a, t) => a + (taste[t] ?? 0.5), 0);
24
+ if (tier === "mythic") w += (fs.mischief - 50) / 90; // mischief loves drama
25
+ if (tier === "plain") w += (fs.protectiveness - 50) / 140;
26
+ w += rng() * 0.5; // a little caprice
27
+ if (w > bestW) {
28
+ bestW = w;
29
+ best = tier;
30
+ }
31
+ }
32
+ let text = ev.lines[best];
33
+ // high mischief sometimes tacks on a harmless side-quest
34
+ if (fs.mischief > 70 && rng() < 0.4) text += ` ${pick(SIDE_QUESTS, rng)}`;
35
+ return { text, tier: best, tags: STYLE_TAGS[best] };
36
+ }
37
+
38
+ /** A rating nudges taste toward / away from the tags that were used. */
39
+ export function learn(taste: Taste, tags: StyleTag[], rating: Rating): Taste {
40
+ const t = { ...taste };
41
+ const step = 0.08;
42
+ if (rating === "helpful") {
43
+ for (const g of tags) t[g] = clamp01(t[g] + step);
44
+ t.useful = clamp01(t.useful + step);
45
+ }
46
+ if (rating === "annoying") {
47
+ for (const g of tags) t[g] = clamp01(t[g] - step);
48
+ t.theatrical = clamp01(t.theatrical - step / 2);
49
+ }
50
+ if (rating === "cute") {
51
+ t.theatrical = clamp01(t.theatrical + step);
52
+ t.warm = clamp01(t.warm + step / 2);
53
+ t.useful = clamp01(t.useful - step / 2);
54
+ }
55
+ return t;
56
+ }
frontend/src/engine/state.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { FairyState, Memory, Mood, Rating, Taste } from "./types";
2
+ import { clamp } from "./util";
3
+
4
+ export function defaultState(): FairyState {
5
+ return {
6
+ energy: 78,
7
+ mischief: 45,
8
+ protectiveness: 60,
9
+ chattiness: 52,
10
+ curiosity: 80,
11
+ trust: 74,
12
+ attachment: 66,
13
+ ageDays: 4,
14
+ obsession: "forgotten desktops",
15
+ };
16
+ }
17
+
18
+ export function defaultTaste(): Taste {
19
+ return { theatrical: 0.5, terse: 0.5, useful: 0.5, warm: 0.55 };
20
+ }
21
+
22
+ /** Immediate personality nudge from a rating (Night Bloom does the larger drift). */
23
+ export function rateFairy(fs: FairyState, rating: Rating): FairyState {
24
+ const n = { ...fs };
25
+ if (rating === "helpful") {
26
+ n.protectiveness = clamp(fs.protectiveness + 2);
27
+ n.attachment = clamp(fs.attachment + 1);
28
+ }
29
+ if (rating === "annoying") n.chattiness = clamp(fs.chattiness - 3);
30
+ if (rating === "cute") n.attachment = clamp(fs.attachment + 1);
31
+ return n;
32
+ }
33
+
34
+ export function moodFor(fs: FairyState): Mood {
35
+ if (fs.energy < 25) return "sleepy";
36
+ if (fs.mischief > 68) return "mischief";
37
+ if (fs.protectiveness > 72) return "proud";
38
+ return "curious";
39
+ }
40
+
41
+ export function seedMemories(): Memory[] {
42
+ return [
43
+ {
44
+ id: "m1",
45
+ text: "Puck is being built for the Hugging Face Build Small hackathon.",
46
+ type: "project",
47
+ icon: "🗝️",
48
+ salience: 0.9,
49
+ pinned: true,
50
+ },
51
+ {
52
+ id: "m2",
53
+ text: "Vu likes agents that are useful but pretend not to be.",
54
+ type: "preference",
55
+ icon: "🌿",
56
+ salience: 0.72,
57
+ pinned: false,
58
+ },
59
+ {
60
+ id: "m3",
61
+ text: "Desktop 2 is the workshop — where code smoke gathers.",
62
+ type: "place",
63
+ icon: "🪶",
64
+ salience: 0.66,
65
+ pinned: false,
66
+ },
67
+ {
68
+ id: "m4",
69
+ text: "Vu distrusts bloated, noisy notifications.",
70
+ type: "aversion",
71
+ icon: "🍄",
72
+ salience: 0.58,
73
+ pinned: false,
74
+ },
75
+ ];
76
+ }
frontend/src/engine/traces.test.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { defaultProfile, rateSource } from "./learning";
3
+ import { defaultState, defaultTaste } from "./state";
4
+ import { buildTraces } from "./traces";
5
+ import type { FeedItem } from "./types";
6
+
7
+ const item = (over: Partial<FeedItem>): FeedItem => ({
8
+ uid: "u1",
9
+ id: "claude_done",
10
+ source: "Claude",
11
+ say: "The goblin rests.",
12
+ tier: "playful",
13
+ tags: ["useful", "warm"],
14
+ decision: "notify",
15
+ time: "12:00",
16
+ rating: null,
17
+ ...over,
18
+ });
19
+
20
+ describe("buildTraces", () => {
21
+ const fs = defaultState();
22
+ const taste = defaultTaste();
23
+ const now = new Date("2026-06-06T12:00:00Z");
24
+
25
+ it("snapshots event, fairy dials, taste, and per-source bias", () => {
26
+ let profile = defaultProfile();
27
+ profile = rateSource(profile, "Discord", "annoying"); // seed -8 → -20
28
+ const [t] = buildTraces([item({ source: "Discord", id: "discord_noise" })], fs, taste, profile, now);
29
+ expect(t.v).toBe(1);
30
+ expect(t.ts).toBe("2026-06-06T12:00:00.000Z");
31
+ expect(t.event).toMatchObject({ id: "discord_noise", source: "Discord", decision: "notify" });
32
+ expect(t.sourceBias).toBe(-20);
33
+ expect(t.fairy.mischief).toBe(fs.mischief);
34
+ expect(t.taste).toEqual(taste);
35
+ });
36
+
37
+ it("carries channel and outcome when present, null when absent", () => {
38
+ const outcome = { type: "dismissed" as const, rating: null, latencyMs: 420, visible: true };
39
+ const [a, b] = buildTraces(
40
+ [item({ channel: "toast", outcome }), item({ uid: "u2" })],
41
+ fs,
42
+ taste,
43
+ defaultProfile(),
44
+ now,
45
+ );
46
+ expect(a.event.channel).toBe("toast");
47
+ expect(a.outcome).toEqual(outcome);
48
+ expect(b.event.channel).toBeNull();
49
+ expect(b.outcome).toBeNull();
50
+ });
51
+
52
+ it("includes silent decisions too — ignore/scroll distributions matter for the notify head", () => {
53
+ const [t] = buildTraces(
54
+ [item({ decision: "ignore", channel: "feed" })],
55
+ fs,
56
+ taste,
57
+ defaultProfile(),
58
+ now,
59
+ );
60
+ expect(t.event.decision).toBe("ignore");
61
+ expect(t.outcome).toBeNull();
62
+ });
63
+ });
frontend/src/engine/traces.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Trace records: the day's surfaced decisions + how the user reacted,
2
+ // snapshotted with the fairy/taste/bias state that produced them.
3
+ // This is the molt's training data — exported at Night Bloom, appended
4
+ // to server/data/traces.jsonl by the daemon.
5
+
6
+ import { sourceBias } from "./learning";
7
+ import type { FairyState, FeedItem, Profile, SurfaceOutcome, Taste } from "./types";
8
+
9
+ export interface TraceRecord {
10
+ v: 1;
11
+ ts: string;
12
+ event: Pick<FeedItem, "id" | "source" | "say" | "tier" | "decision"> & {
13
+ channel: FeedItem["channel"] | null;
14
+ };
15
+ /** the dials that shaped this decision, at export time */
16
+ fairy: Pick<FairyState, "mischief" | "protectiveness" | "chattiness" | "curiosity" | "energy">;
17
+ taste: Taste;
18
+ sourceBias: number;
19
+ outcome: SurfaceOutcome | null;
20
+ }
21
+
22
+ /** Snapshot the day's feed as training traces. Pure — caller decides when/where to ship. */
23
+ export function buildTraces(
24
+ feed: FeedItem[],
25
+ fs: FairyState,
26
+ taste: Taste,
27
+ profile: Profile,
28
+ now: Date = new Date(),
29
+ ): TraceRecord[] {
30
+ const ts = now.toISOString();
31
+ const fairy = {
32
+ mischief: fs.mischief,
33
+ protectiveness: fs.protectiveness,
34
+ chattiness: fs.chattiness,
35
+ curiosity: fs.curiosity,
36
+ energy: fs.energy,
37
+ };
38
+ return feed.map((e) => ({
39
+ v: 1 as const,
40
+ ts,
41
+ event: {
42
+ id: e.id,
43
+ source: e.source,
44
+ say: e.say,
45
+ tier: e.tier,
46
+ decision: e.decision,
47
+ channel: e.channel ?? null,
48
+ },
49
+ fairy,
50
+ taste,
51
+ sourceBias: sourceBias(profile, e.source),
52
+ outcome: e.outcome ?? null,
53
+ }));
54
+ }
frontend/src/engine/types.ts ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Puck engine contract. Pure data — no React, no DOM.
2
+
3
+ export type Rating = "helpful" | "annoying" | "cute";
4
+
5
+ /** The interruption ladder, least → most intrusive. */
6
+ export type Decision = "ignore" | "scroll" | "glow" | "notify" | "interrupt";
7
+
8
+ /** Where a decision landed on screen (presentation policy, chosen per shell). */
9
+ export type Channel = "feed" | "glow" | "bubble" | "toast" | "interrupt";
10
+
11
+ /** How the user resolved a surfaced comment. The silent outcomes are labels too:
12
+ * expired = not worth words, fast dismiss = reflex swat, slow dismiss = read-then-closed. */
13
+ export type OutcomeType = "rated" | "dismissed" | "swatted" | "expired" | "clicked_through" | "unresolved";
14
+
15
+ export interface SurfaceOutcome {
16
+ type: OutcomeType;
17
+ rating: Rating | null;
18
+ /** surface → first interaction (or expiry/sleep), ms */
19
+ latencyMs: number;
20
+ /** tab visibility at surface time — an unseen toast is not a rejected toast */
21
+ visible: boolean;
22
+ }
23
+
24
+ /** Voice tiers an event line can be written in. */
25
+ export type Tier = "plain" | "playful" | "mythic";
26
+
27
+ /** Style traits used to steer tier selection via the taste vector. */
28
+ export type StyleTag = "useful" | "terse" | "warm" | "theatrical";
29
+
30
+ export type Mood = "curious" | "mischief" | "sleepy" | "proud" | "grumpy";
31
+
32
+ /** User-selected behavior mode (menu bar). Modifies decisions post-scoring. */
33
+ export type PuckMode = "polite" | "patrol" | "goblin" | "silent";
34
+
35
+ export type SourceId = "Claude" | "Build" | "Mail" | "Discord" | "Calendar" | "Browser" | "Git";
36
+
37
+ /** Simulated-desktop mutation an event causes. */
38
+ export type EffectId =
39
+ | "claude_done"
40
+ | "build_done"
41
+ | "build_fail"
42
+ | "mail_unread"
43
+ | "discord_mention"
44
+ | "discord_noise"
45
+ | "calendar_soon"
46
+ | "none";
47
+
48
+ export type MemoryType = "preference" | "aversion" | "pattern" | "project" | "place" | "tone" | "boundary";
49
+
50
+ export interface FairyState {
51
+ energy: number;
52
+ mischief: number;
53
+ protectiveness: number;
54
+ chattiness: number;
55
+ curiosity: number;
56
+ trust: number;
57
+ attachment: number;
58
+ ageDays: number;
59
+ obsession: string;
60
+ }
61
+
62
+ /** The 0..100 personality dials Night Bloom may drift. */
63
+ export type FairyStat =
64
+ | "energy"
65
+ | "mischief"
66
+ | "protectiveness"
67
+ | "chattiness"
68
+ | "curiosity"
69
+ | "trust"
70
+ | "attachment";
71
+
72
+ /** How much Puck believes you like each style trait, 0..1. */
73
+ export type Taste = Record<StyleTag, number>;
74
+
75
+ export interface EventScores {
76
+ urgency: number;
77
+ relevance: number;
78
+ novelty: number;
79
+ annoyance: number;
80
+ interest: number;
81
+ }
82
+
83
+ export interface MemorySeed {
84
+ text: string;
85
+ type: MemoryType;
86
+ icon: string;
87
+ }
88
+
89
+ export interface Memory extends MemorySeed {
90
+ id: string;
91
+ salience: number;
92
+ pinned: boolean;
93
+ }
94
+
95
+ export interface EventDef {
96
+ id: string;
97
+ source: SourceId;
98
+ /** Window the sprite flies to; null = nowhere in particular. */
99
+ target: string | null;
100
+ glyph: string;
101
+ title: string;
102
+ base: EventScores;
103
+ effect: EffectId;
104
+ lines: Record<Tier, string>;
105
+ memory: MemorySeed;
106
+ }
107
+
108
+ export interface Utterance {
109
+ text: string;
110
+ tier: Tier;
111
+ tags: StyleTag[];
112
+ }
113
+
114
+ export interface FeedItem {
115
+ uid: string;
116
+ id: string;
117
+ source: SourceId;
118
+ say: string;
119
+ tier: Tier;
120
+ tags: StyleTag[];
121
+ decision: Decision | "handled" | "learned";
122
+ time: string;
123
+ rating: Rating | null;
124
+ /** Presentation channel this landed on (absent for pre-tracking items). */
125
+ channel?: Channel;
126
+ /** Engagement resolution — fine-tune label material. Absent until resolved. */
127
+ outcome?: SurfaceOutcome;
128
+ }
129
+
130
+ export interface NightBloomResult {
131
+ /** "age" is ceremonial (always +1, displayed nowhere); stat keys get clamp-applied on wake. */
132
+ deltas: Partial<Record<FairyStat | "age", number>>;
133
+ memories: MemorySeed[];
134
+ rules: string[];
135
+ dream: string;
136
+ morning: string;
137
+ counts: Record<Rating, number>;
138
+ }
139
+
140
+ // ---- learning profile (per-source interruption taste) ----------------------
141
+
142
+ export interface SourceStats {
143
+ /** -50..50; positive = surface more, negative = back off. */
144
+ bias: number;
145
+ helpful: number;
146
+ annoying: number;
147
+ cute: number;
148
+ samples: number;
149
+ }
150
+
151
+ export interface AutomationDef {
152
+ id: string;
153
+ verb: string;
154
+ offer: string;
155
+ done: string;
156
+ /** Event ids this automation handles silently. Anything else from the
157
+ * source still goes through normal scoring — a mention still reaches
158
+ * you after the bog is muted. */
159
+ handles: string[];
160
+ }
161
+
162
+ export interface LearnedAutomation extends Pick<AutomationDef, "id" | "verb" | "done"> {
163
+ source: SourceId;
164
+ }
165
+
166
+ export interface Profile {
167
+ sources: Record<SourceId, SourceStats>;
168
+ automations: LearnedAutomation[];
169
+ /** Sources we already offered an automation for (accepted or declined). */
170
+ offered: Partial<Record<SourceId, boolean>>;
171
+ }
172
+
173
+ /** Injectable randomness so tests are deterministic. */
174
+ export type Rng = () => number;
frontend/src/engine/util.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Rng } from "./types";
2
+
3
+ export const clamp = (n: number, lo = 0, hi = 100): number => Math.max(lo, Math.min(hi, n));
4
+
5
+ export const clamp01 = (n: number): number => Math.max(0, Math.min(1, n));
6
+
7
+ export const pick = <T>(a: readonly T[], rng: Rng = Math.random): T => a[Math.floor(rng() * a.length)];
8
+
9
+ export const hhmm = (d: Date = new Date()): string =>
10
+ d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
11
+
12
+ export function shuffle<T>(a: T[], rng: Rng = Math.random): T[] {
13
+ for (let i = a.length - 1; i > 0; i--) {
14
+ const j = Math.floor(rng() * (i + 1));
15
+ [a[i], a[j]] = [a[j], a[i]];
16
+ }
17
+ return a;
18
+ }
frontend/src/engine/wire.test.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Parity pins between engine/wire.ts and /schema/event.schema.json — the
2
+ // cross-language contract. If these fail, one side of the wire drifted.
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { describe, expect, it } from "vitest";
6
+ import { EVENTS } from "./events";
7
+ import {
8
+ isWireEvent,
9
+ TEMPLATES,
10
+ toEventDef,
11
+ WIRE_OPTIONAL,
12
+ WIRE_REQUIRED,
13
+ WIRE_SOURCES,
14
+ type WireEvent,
15
+ } from "./wire";
16
+
17
+ const schema = JSON.parse(readFileSync(join(__dirname, "../../../schema/event.schema.json"), "utf8"));
18
+
19
+ describe("schema parity", () => {
20
+ it("required fields match the JSON schema", () => {
21
+ expect([...WIRE_REQUIRED].sort()).toEqual([...schema.required].sort());
22
+ });
23
+
24
+ it("all schema properties are known to the TS side", () => {
25
+ expect(Object.keys(schema.properties).sort()).toEqual([...WIRE_REQUIRED, ...WIRE_OPTIONAL].sort());
26
+ });
27
+
28
+ it("source enum matches the JSON schema", () => {
29
+ expect([...WIRE_SOURCES].sort()).toEqual([...schema.properties.source.enum].sort());
30
+ });
31
+ });
32
+
33
+ describe("isWireEvent", () => {
34
+ it("accepts a minimal valid event", () => {
35
+ expect(isWireEvent({ source: "claude", type: "task_completed", title: "Done" })).toBe(true);
36
+ });
37
+
38
+ it("rejects unknown sources, empty fields, and non-objects", () => {
39
+ expect(isWireEvent({ source: "slack", type: "x", title: "y" })).toBe(false);
40
+ expect(isWireEvent({ source: "claude", type: "", title: "y" })).toBe(false);
41
+ expect(isWireEvent({ source: "claude", type: "x", title: "" })).toBe(false);
42
+ expect(isWireEvent(null)).toBe(false);
43
+ expect(isWireEvent("claude")).toBe(false);
44
+ });
45
+ });
46
+
47
+ describe("TEMPLATES integrity", () => {
48
+ it("every template id resolves to a deck event (renames can't silently fall through)", () => {
49
+ const ids = new Set(EVENTS.map((e) => e.id));
50
+ for (const id of Object.values(TEMPLATES)) expect(ids).toContain(id);
51
+ });
52
+ });
53
+
54
+ describe("toEventDef", () => {
55
+ const claude: WireEvent = {
56
+ source: "claude",
57
+ type: "task_completed",
58
+ title: "Implement wire schema",
59
+ summary: "3 files changed",
60
+ };
61
+
62
+ it("borrows the matching template's scoring", () => {
63
+ const def = toEventDef(claude);
64
+ const template = EVENTS.find((e) => e.id === "claude_done");
65
+ expect(def.base).toEqual(template?.base);
66
+ expect(def.source).toBe("Claude");
67
+ expect(def.lines.plain).toBe("Implement wire schema — 3 files changed");
68
+ });
69
+
70
+ it("real events never mutate sim windows and have no flight target", () => {
71
+ const def = toEventDef(claude);
72
+ expect(def.effect).toBe("none");
73
+ expect(def.target).toBeNull();
74
+ });
75
+
76
+ it("unknown types get middling fallback scores (never an interrupt)", () => {
77
+ const def = toEventDef({ source: "build", type: "linted", title: "Lint clean" });
78
+ expect(def.base.urgency).toBeLessThan(80);
79
+ expect(def.lines.plain).toBe("Lint clean");
80
+ });
81
+
82
+ it("build failures map to the urgent template", () => {
83
+ const def = toEventDef({ source: "build", type: "tests_failed", title: "2 failing" });
84
+ expect(def.base).toEqual(EVENTS.find((e) => e.id === "build_fail")?.base);
85
+ });
86
+ });
frontend/src/engine/wire.ts ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Wire events: what external watchers POST to the daemon (see /schema/event.schema.json).
2
+ // This module is the only place wire shapes become engine EventDefs — the daemon
3
+ // stays dumb pipes; mapping wire types onto scoring templates is policy, so it lives here.
4
+
5
+ import { EVENTS } from "./events";
6
+ import type { EventDef, EventScores, SourceId } from "./types";
7
+
8
+ export interface WireEvent {
9
+ id?: string;
10
+ ts?: string;
11
+ source: "claude" | "build" | "mail" | "discord" | "calendar" | "browser";
12
+ type: string;
13
+ title: string;
14
+ summary?: string;
15
+ payload?: Record<string, unknown>;
16
+ }
17
+
18
+ /** Keep in lockstep with event.schema.json — pinned by a parity test. */
19
+ export const WIRE_SOURCES = ["claude", "build", "mail", "discord", "calendar", "browser"] as const;
20
+ export const WIRE_REQUIRED = ["source", "type", "title"] as const;
21
+ export const WIRE_OPTIONAL = ["id", "ts", "summary", "payload"] as const;
22
+
23
+ export function isWireEvent(v: unknown): v is WireEvent {
24
+ if (typeof v !== "object" || v === null) return false;
25
+ const o = v as Record<string, unknown>;
26
+ return (
27
+ typeof o.source === "string" &&
28
+ (WIRE_SOURCES as readonly string[]).includes(o.source) &&
29
+ typeof o.type === "string" &&
30
+ o.type.length > 0 &&
31
+ typeof o.title === "string" &&
32
+ o.title.length > 0
33
+ );
34
+ }
35
+
36
+ const SOURCE_IDS: Record<WireEvent["source"], SourceId> = {
37
+ claude: "Claude",
38
+ build: "Build",
39
+ mail: "Mail",
40
+ discord: "Discord",
41
+ calendar: "Calendar",
42
+ browser: "Browser",
43
+ };
44
+
45
+ // (source, type) → the sim-deck event whose base scores fit. Real events borrow
46
+ // the deck's tuned scoring + memory seeds; only the words change.
47
+ // Exported for the drift test: every value must name a real deck event.
48
+ export const TEMPLATES: Record<string, string> = {
49
+ "claude/task_completed": "claude_done",
50
+ "claude/stop": "claude_done",
51
+ "claude/notification": "claude_done",
52
+ "claude/permission_needed": "claude_permission",
53
+ // a failed Claude turn borrows the failing-build urgency profile
54
+ "claude/failed": "build_fail",
55
+ "build/passed": "build_done",
56
+ "build/failed": "build_fail",
57
+ "build/tests_failed": "build_fail",
58
+ "mail/important": "mail_important",
59
+ "mail/statement": "mail_finance",
60
+ "discord/mention": "discord_mention",
61
+ "discord/noise": "discord_noise",
62
+ "calendar/soon": "calendar_soon",
63
+ "browser/stale_tab": "stale_tab",
64
+ "browser/new_page": "browser_newpage",
65
+ "browser/rabbit_hole": "browser_rabbithole",
66
+ };
67
+
68
+ // unknown (source, type): middling everything — surfaces as a scroll/glow, never an interrupt
69
+ const FALLBACK_SCORES: EventScores = { urgency: 50, relevance: 60, novelty: 55, annoyance: 30, interest: 60 };
70
+
71
+ /** Wrap a wire event in an EventDef the engine can score and voice. */
72
+ export function toEventDef(w: WireEvent): EventDef {
73
+ const template = EVENTS.find((e) => e.id === TEMPLATES[`${w.source}/${w.type}`]);
74
+ const line = w.summary ? `${w.title} — ${w.summary}` : w.title;
75
+ return {
76
+ id: w.id ?? `wire_${w.source}_${w.type}`,
77
+ source: SOURCE_IDS[w.source],
78
+ // real events have no sim window to fly to; the shell decides where Puck goes
79
+ target: null,
80
+ glyph: template?.glyph ?? "✦",
81
+ title: w.title,
82
+ base: template?.base ?? FALLBACK_SCORES,
83
+ effect: "none",
84
+ // real titles speak for themselves; tier flavor comes from the template's framing
85
+ lines: {
86
+ plain: line,
87
+ playful: template ? `Word from the ${w.source} realm: ${line}` : line,
88
+ mythic: template ? `An omen from ${w.source}: ${line}` : line,
89
+ },
90
+ memory: template?.memory ?? {
91
+ text: `Vu gets real ${w.source} events now. Watch how he reacts.`,
92
+ type: "pattern",
93
+ icon: "🪶",
94
+ },
95
+ };
96
+ }
frontend/src/lib/brain.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Client for the daemon's brain proxy. Every call degrades to null —
2
+ // the scripted engine is the floor, the model is the ceiling. Puck is
3
+ // never dead, just less eloquent.
4
+
5
+ import type { FairyState, WireEvent } from "../engine";
6
+ import { moodFor } from "../engine";
7
+
8
+ interface BrainFairyState {
9
+ mood: string;
10
+ mischief: number;
11
+ obsession: string;
12
+ }
13
+
14
+ const forBrain = (fs: FairyState): BrainFairyState => ({
15
+ mood: moodFor(fs),
16
+ mischief: fs.mischief,
17
+ obsession: fs.obsession,
18
+ });
19
+
20
+ async function post(path: string, body: unknown, timeoutMs: number): Promise<string | null> {
21
+ try {
22
+ const res = await fetch(path, {
23
+ method: "POST",
24
+ headers: { "Content-Type": "application/json" },
25
+ body: JSON.stringify(body),
26
+ signal: AbortSignal.timeout(timeoutMs),
27
+ });
28
+ if (!res.ok) return null; // 503 = no brain configured; scripted fallback is the design
29
+ const data: unknown = await res.json();
30
+ const text = (data as { text?: unknown }).text;
31
+ return typeof text === "string" && text.length > 0 ? text : null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** Fairy-voiced line for a real wire event. Null → use the template line. */
38
+ export function brainNarrate(event: WireEvent, fs: FairyState): Promise<string | null> {
39
+ return post("/api/brain/narrate", { event, fairy_state: forBrain(fs) }, 25000);
40
+ }
41
+
42
+ /** In-character chat reply. Null → use the scripted keyword reply. */
43
+ export function brainChat(
44
+ message: string,
45
+ fs: FairyState,
46
+ history: { who: string; text: string }[],
47
+ ): Promise<string | null> {
48
+ return post("/api/brain/chat", { message, fairy_state: forBrain(fs), history }, 25000);
49
+ }
frontend/src/lib/daemon.ts ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Poll the local daemon for real wire events. Offline-tolerant by design:
2
+ // the sim is fully alive without a daemon, so failures are silent and the
3
+ // poll backs off until the daemon answers again.
4
+
5
+ import { isWireEvent, type WireEvent } from "../engine";
6
+
7
+ const POLL_MS = 3000;
8
+ const OFFLINE_BACKOFF_MS = 15000;
9
+
10
+ export function startDaemonPoll(onEvents: (events: WireEvent[]) => void): () => void {
11
+ let timer: ReturnType<typeof setTimeout> | undefined;
12
+ let stopped = false;
13
+
14
+ const tick = async () => {
15
+ if (stopped) return; // unmounted before first tick — never drain for a dead poller
16
+ let delay = POLL_MS;
17
+ try {
18
+ const res = await fetch("/api/events/pending", { signal: AbortSignal.timeout(2000) });
19
+ if (res.ok) {
20
+ const body: unknown = await res.json();
21
+ const list = (body as { events?: unknown[] }).events ?? [];
22
+ const valid = list.filter(isWireEvent);
23
+ // a malformed event on this path means the daemon let contract drift through — be loud
24
+ if (valid.length !== list.length)
25
+ console.error("puck: daemon sent events violating the wire schema", list);
26
+ // drain is destructive — a stopped poller (StrictMode's first mount) must not
27
+ // swallow events meant for the live one. Losing one drain to an unlucky unmount
28
+ // race is acceptable; silently eating every event on dev double-mount is not.
29
+ if (valid.length && !stopped) onEvents(valid);
30
+ } else {
31
+ delay = OFFLINE_BACKOFF_MS;
32
+ }
33
+ } catch {
34
+ delay = OFFLINE_BACKOFF_MS; // daemon not running — normal in Space/demo mode
35
+ }
36
+ if (!stopped) timer = setTimeout(tick, delay);
37
+ };
38
+
39
+ // drain is destructive, so the first fetch must not race React StrictMode's
40
+ // mount→unmount→mount: the doomed first instance is cleaned up synchronously,
41
+ // well inside this delay, so only the surviving poller ever touches the queue.
42
+ timer = setTimeout(tick, 350);
43
+ return () => {
44
+ stopped = true;
45
+ clearTimeout(timer);
46
+ };
47
+ }
frontend/src/lib/debuglog.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Mirror the overlay's console to the daemon (/api/debug/log → /tmp/puck-frontend.log)
2
+ // because the WKWebView inspector is unusable behind a fullscreen always-on-top
3
+ // transparent window. Tauri-only so the browser sim isn't noisy.
4
+
5
+ import { inTauri } from "./tauri";
6
+
7
+ const stringify = (a: unknown): string => {
8
+ if (typeof a === "string") return a;
9
+ try {
10
+ return JSON.stringify(a);
11
+ } catch {
12
+ return String(a);
13
+ }
14
+ };
15
+
16
+ export function installDebugLog(): void {
17
+ if (!inTauri()) return;
18
+ const send = (level: string, args: unknown[]) => {
19
+ void fetch("/api/debug/log", {
20
+ method: "POST",
21
+ headers: { "Content-Type": "application/json" },
22
+ body: JSON.stringify({ level, msg: args.map(stringify).join(" ") }),
23
+ }).catch(() => {});
24
+ };
25
+ for (const level of ["log", "warn", "error"] as const) {
26
+ const orig = console[level].bind(console);
27
+ console[level] = (...args: unknown[]) => {
28
+ orig(...args);
29
+ send(level, args);
30
+ };
31
+ }
32
+ window.addEventListener("error", (e) =>
33
+ send("error", [`onerror: ${e.message} @ ${e.filename}:${e.lineno}`]),
34
+ );
35
+ window.addEventListener("unhandledrejection", (e) =>
36
+ send("error", [`unhandledrejection: ${String(e.reason)}`]),
37
+ );
38
+ console.log("puck: debug-log forwarding on");
39
+ }
frontend/src/lib/memories.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Puck's memory = the tail of his peek observations (what he saw + said). The
2
+ // daemon logs every peek; this reads them back for the Memory tab + chat grounding.
3
+ // At Night Bloom, those observations are distilled into durable garden memories.
4
+
5
+ import type { MemorySeed } from "../engine";
6
+
7
+ export interface Memory {
8
+ ts: string;
9
+ image: string;
10
+ quip: string;
11
+ }
12
+
13
+ /** Night Bloom: ask the daemon to distill the day's peeks into durable garden
14
+ * memories. [] if the brain's down or the day was too thin. Slow (LLM). */
15
+ export async function fetchBloom(fairyState: { mischief: number; mood: string }): Promise<MemorySeed[]> {
16
+ try {
17
+ const res = await fetch("/api/brain/bloom", {
18
+ method: "POST",
19
+ headers: { "Content-Type": "application/json" },
20
+ body: JSON.stringify({ fairy_state: fairyState }),
21
+ signal: AbortSignal.timeout(60000),
22
+ });
23
+ if (!res.ok) return [];
24
+ return ((await res.json()) as { memories?: MemorySeed[] }).memories ?? [];
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ export async function fetchMemories(limit = 30): Promise<Memory[]> {
31
+ try {
32
+ const res = await fetch(`/api/memories?limit=${limit}`, { signal: AbortSignal.timeout(5000) });
33
+ if (!res.ok) return [];
34
+ return ((await res.json()) as { memories?: Memory[] }).memories ?? [];
35
+ } catch {
36
+ return []; // daemon down — no memory to show
37
+ }
38
+ }
39
+
40
+ /** Existing recognizer labels (to suggest reuse when teaching Puck). */
41
+ export async function fetchLabels(): Promise<string[]> {
42
+ try {
43
+ const res = await fetch("/api/recognize/labels", { signal: AbortSignal.timeout(4000) });
44
+ if (!res.ok) return [];
45
+ return ((await res.json()) as { labels?: string[] }).labels ?? [];
46
+ } catch {
47
+ return [];
48
+ }
49
+ }
50
+
51
+ /** Teach Puck what a peek is → enrolls that screenshot as a fingerprint reference. */
52
+ export async function labelPeek(image: string, label: string): Promise<boolean> {
53
+ try {
54
+ const res = await fetch("/api/recognize/label", {
55
+ method: "POST",
56
+ headers: { "Content-Type": "application/json" },
57
+ body: JSON.stringify({ image, label }),
58
+ });
59
+ return res.ok;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
frontend/src/lib/molt.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Molt readiness — how Puck's learning actually accumulates. The daemon counts
2
+ // the rated traces collected so far against the threshold for the next adapter
3
+ // "molt". Night Bloom shows this so sleep reflects real progress, not theatre.
4
+ // Null when the daemon is down (Space/demo mode) — the screen just omits the gauge.
5
+
6
+ export interface MoltStatus {
7
+ /** rated traces accumulated server-side (server/data/traces.jsonl) */
8
+ traces: number;
9
+ /** traces needed before a molt (LoRA retrain from base) is eligible */
10
+ threshold: number;
11
+ /** hand-built SFT examples the molt starts from */
12
+ sftBase: number;
13
+ ready: boolean;
14
+ }
15
+
16
+ export async function fetchMoltStatus(): Promise<MoltStatus | null> {
17
+ try {
18
+ const res = await fetch("/api/molt/status", { signal: AbortSignal.timeout(4000) });
19
+ if (!res.ok) return null;
20
+ return (await res.json()) as MoltStatus;
21
+ } catch {
22
+ return null; // daemon offline — sleep still works, just without the gauge
23
+ }
24
+ }
frontend/src/lib/notify.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // System notifications for when the tab is buried — the bridge to real
2
+ // desktop usage until the Tauri overlay exists. Click = engagement,
3
+ // which the caller records as a clicked_through outcome.
4
+
5
+ export function notifyPermission(): NotificationPermission | "unsupported" {
6
+ return "Notification" in window ? Notification.permission : "unsupported";
7
+ }
8
+
9
+ /** Must be called from a user gesture (settings toggle). */
10
+ export async function requestNotifyPermission(): Promise<boolean> {
11
+ if (!("Notification" in window)) return false;
12
+ return (await Notification.requestPermission()) === "granted";
13
+ }
14
+
15
+ export function osNotify(body: string, onClick: () => void): void {
16
+ if (!("Notification" in window) || Notification.permission !== "granted") return;
17
+ const n = new Notification("Puck", { body, silent: true });
18
+ n.onclick = () => {
19
+ window.focus();
20
+ onClick();
21
+ n.close();
22
+ };
23
+ }
frontend/src/lib/settings.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { BubbleStyle, SpriteForm } from "../ui/Sprite";
2
+ import { DEFAULT_KOKORO_VOICE, type VoiceEngine, type VoiceMode } from "./voice";
3
+
4
+ export type Theme = "terrarium" | "candlelight" | "nocturne";
5
+ export type NotifyStyle = "adaptive" | "bubble" | "toast" | "interrupt";
6
+ // off: never. ondemand: only the "Look around" button. ambient: auto, on a slow
7
+ // timer, paused when idle/hidden so the cloud GPU scales to zero (cost-safe).
8
+ export type VisionMode = "off" | "ondemand" | "ambient";
9
+
10
+ /** Ambient capture interval. Doesn't change cloud cost much (GPU stays warm
11
+ * while active regardless) — idle-off is the real saving — but a slower cadence
12
+ * is gentler and plenty for a screen that changes on human timescales. */
13
+ export const AMBIENT_INTERVAL_MS = 45_000;
14
+
15
+ export interface Settings {
16
+ theme: Theme;
17
+ accent: string;
18
+ form: SpriteForm;
19
+ /** 0..100 — how often Puck wanders and how dense events are. */
20
+ presence: number;
21
+ mischief: number;
22
+ /** Comment typography: fairy serif, plain UI, or handwriting. */
23
+ voice: BubbleStyle;
24
+ voiceSound: VoiceMode;
25
+ /** Which TTS engine: local neural (Kokoro) or the OS voices. */
26
+ voiceEngine: VoiceEngine;
27
+ /** Chosen OS (speechSynthesis) voice name; "" = auto-pick a soft english voice. */
28
+ voiceName: string;
29
+ /** Chosen Kokoro voice id (e.g. "af_heart", "am_puck"). */
30
+ kokoroVoice: string;
31
+ /** Chosen macOS `say` voice name (e.g. "Samantha"); "" = system default. */
32
+ sayVoice: string;
33
+ /** Speak the short reaction shouts ("NANI?!", "shipped!") aloud too. */
34
+ speakShouts: boolean;
35
+ notify: NotifyStyle;
36
+ /** Mirror surfaced comments to OS notifications when the tab is hidden. */
37
+ osNotify: boolean;
38
+ /** How Puck uses his eyes (vision). Default on-demand: nothing runs unattended. */
39
+ visionMode: VisionMode;
40
+ }
41
+
42
+ export const ACCENTS = ["#e7b85c", "#8fcf86", "#e58fa6", "#b89be6", "#6fc6d6"] as const;
43
+
44
+ export const defaultSettings = (): Settings => ({
45
+ theme: "terrarium",
46
+ accent: "#e7b85c",
47
+ form: "mossling",
48
+ presence: 62,
49
+ mischief: 45,
50
+ voice: "voice",
51
+ voiceSound: "full", // audible by default (browser autoplay may need one click first)
52
+ voiceEngine: "say", // Mac-native voice — works in the overlay and the browser demo alike
53
+ voiceName: "",
54
+ kokoroVoice: DEFAULT_KOKORO_VOICE,
55
+ sayVoice: "Samantha",
56
+ speakShouts: true,
57
+ notify: "adaptive",
58
+ osNotify: false,
59
+ visionMode: "ondemand",
60
+ });
61
+
62
+ /** Readable ink color for text on top of the accent. */
63
+ export function inkFor(hex: string): string {
64
+ const h = hex.replace("#", "");
65
+ const n = parseInt(h.length === 3 ? h.replace(/./g, (c) => c + c) : h, 16);
66
+ const r = (n >> 16) & 255;
67
+ const g = (n >> 8) & 255;
68
+ const b = n & 255;
69
+ return r * 299 + g * 587 + b * 114 > 150000 ? "#22180a" : "#fff7e8";
70
+ }
frontend/src/lib/storage.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+
3
+ const PREFIX = "puck:";
4
+
5
+ function load<T>(key: string, fallback: () => T): T {
6
+ try {
7
+ const raw = localStorage.getItem(PREFIX + key);
8
+ if (raw == null) return fallback();
9
+ // merge over fallback so new fields added later get their defaults
10
+ const parsed = JSON.parse(raw);
11
+ const base = fallback();
12
+ return typeof base === "object" && base !== null && !Array.isArray(base)
13
+ ? { ...base, ...(parsed as object) }
14
+ : (parsed as T);
15
+ } catch (e) {
16
+ console.error(`puck: failed to load ${key}, using defaults`, e);
17
+ return fallback();
18
+ }
19
+ }
20
+
21
+ /** useState backed by localStorage. `fallback` runs only when nothing is stored. */
22
+ export function usePersistent<T>(key: string, fallback: () => T) {
23
+ const [value, setValue] = React.useState<T>(() => load(key, fallback));
24
+ React.useEffect(() => {
25
+ try {
26
+ localStorage.setItem(PREFIX + key, JSON.stringify(value));
27
+ } catch (e) {
28
+ console.error(`puck: failed to persist ${key}`, e);
29
+ }
30
+ }, [key, value]);
31
+ return [value, setValue] as const;
32
+ }
33
+
34
+ /** Wipe all of Puck's persisted state (the "forget everything" escape hatch). */
35
+ export function forgetEverything() {
36
+ for (const k of Object.keys(localStorage)) {
37
+ if (k.startsWith(PREFIX)) localStorage.removeItem(k);
38
+ }
39
+ }
frontend/src/lib/tauri.ts ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Bridge to the Tauri overlay shell. The overlay window is click-through by
2
+ // default; Rust polls the global cursor and re-enables events only while it's
3
+ // inside one of these rects. The frontend's job: keep Rust's rect list fresh.
4
+ // Everything no-ops outside Tauri (sim in a browser, the Space).
5
+
6
+ interface HitRect {
7
+ x: number;
8
+ y: number;
9
+ w: number;
10
+ h: number;
11
+ }
12
+
13
+ const HIT_SELECTORS = [
14
+ ".puck",
15
+ ".bubble",
16
+ ".toasts",
17
+ ".comp",
18
+ ".drop",
19
+ ".settings",
20
+ ".interrupt-card",
21
+ ".bloom-card",
22
+ // dev-only: vite's error overlay must be clickable or it's undismissable
23
+ // (anything interactive but unlisted = clicks sail through to apps below)
24
+ "vite-error-overlay",
25
+ ].join(", ");
26
+
27
+ const PAD = 8; // forgiving edges — a near-miss poke should still land
28
+
29
+ export function inTauri(): boolean {
30
+ return "__TAURI_INTERNALS__" in window;
31
+ }
32
+
33
+ // Best-effort event-source → macOS app name. Imperfect (Claude Code lives in a
34
+ // terminal, "browser" could be any of several) but right for the common cases;
35
+ // easy to refine once we can read the real frontmost app per event.
36
+ const SOURCE_APP: Record<string, string> = {
37
+ Claude: "Claude",
38
+ Build: "Terminal",
39
+ Git: "Terminal",
40
+ Mail: "Mail",
41
+ Discord: "Discord",
42
+ Calendar: "Calendar",
43
+ Browser: "Safari",
44
+ };
45
+
46
+ export interface Region {
47
+ x: number;
48
+ y: number;
49
+ w: number;
50
+ h: number;
51
+ }
52
+
53
+ /** What a peek yields: the line Puck says + how the patch made him feel (drives the
54
+ * sprite gesture). `emotion` is a PeekEmotion string; the engine coerces it. */
55
+ export interface PeekResult {
56
+ quip: string;
57
+ emotion: string;
58
+ }
59
+
60
+ /** Companion loop: Puck peeks at a small region (given in LOGICAL/CSS px — we
61
+ * scale to physical here) and Rust returns the daemon's `{quip, emotion}`. Image
62
+ * stays native-side; only the short result crosses the IPC. Null outside Tauri or
63
+ * on failure (Screen Recording not granted, vision down). */
64
+ export async function peekNative(
65
+ region: Region,
66
+ fairyState: { mischief: number; mood: string },
67
+ ): Promise<PeekResult | null> {
68
+ if (!inTauri()) return null;
69
+ const dpr = window.devicePixelRatio || 1;
70
+ try {
71
+ const { invoke } = await import("@tauri-apps/api/core");
72
+ const json = await invoke<string>("peek", {
73
+ x: Math.round(region.x * dpr),
74
+ y: Math.round(region.y * dpr),
75
+ w: Math.round(region.w * dpr),
76
+ h: Math.round(region.h * dpr),
77
+ fairyState,
78
+ });
79
+ const r = JSON.parse(json) as { quip?: string; emotion?: string };
80
+ return r.quip ? { quip: r.quip, emotion: r.emotion ?? "curious" } : null;
81
+ } catch (e) {
82
+ console.error("puck: native peek failed (grant Screen Recording?)", e);
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /** Make the overlay the key window so you can type into it (chat). The borderless
88
+ * always-on-top overlay never becomes key on its own. No-op outside Tauri. */
89
+ export async function focusWindow(): Promise<void> {
90
+ if (!inTauri()) return;
91
+ try {
92
+ const { invoke } = await import("@tauri-apps/api/core");
93
+ await invoke("focus_window");
94
+ } catch (e) {
95
+ console.error("puck: focus_window failed", e);
96
+ }
97
+ }
98
+
99
+ /** Bring the app a notification is about to the front (the "tap to go there"). */
100
+ export async function focusApp(source: string): Promise<void> {
101
+ if (!inTauri()) return;
102
+ const app = SOURCE_APP[source];
103
+ if (!app) return;
104
+ try {
105
+ const { invoke } = await import("@tauri-apps/api/core");
106
+ await invoke("focus_app", { app });
107
+ } catch (e) {
108
+ console.error("puck: focus_app failed", e);
109
+ }
110
+ }
111
+
112
+ /** Native "look around": Rust captures the real screen and posts it to the daemon
113
+ * in the background, returning at once (the ~20s vision inference must not block
114
+ * — perceived events arrive via the normal poll). Resolves true once the capture
115
+ * is dispatched, false on failure (e.g. Screen Recording not granted). The image
116
+ * never crosses the IPC bridge (multi-MB through it crashed WKWebView). */
117
+ export async function lookAroundNative(fairyState: { mischief: number; mood: string }): Promise<boolean> {
118
+ if (!inTauri()) return false;
119
+ try {
120
+ const { invoke } = await import("@tauri-apps/api/core");
121
+ await invoke("look_around", { fairyState });
122
+ return true;
123
+ } catch (e) {
124
+ console.error("puck: native look-around failed (grant Screen Recording?)", e);
125
+ return false;
126
+ }
127
+ }
128
+
129
+ /** Poll interactive element rects and report them to the Rust cursor poller. */
130
+ export function startHitRectReporter(): () => void {
131
+ if (!inTauri()) return () => {};
132
+ let last = "";
133
+ const iv = setInterval(async () => {
134
+ const rects: HitRect[] = [...document.querySelectorAll(HIT_SELECTORS)].map((el) => {
135
+ const r = el.getBoundingClientRect();
136
+ return {
137
+ x: Math.round(r.x - PAD),
138
+ y: Math.round(r.y - PAD),
139
+ w: Math.round(r.width + PAD * 2),
140
+ h: Math.round(r.height + PAD * 2),
141
+ };
142
+ });
143
+ const key = JSON.stringify(rects);
144
+ if (key === last) return; // don't spam IPC when nothing moved
145
+ last = key;
146
+ try {
147
+ const { invoke } = await import("@tauri-apps/api/core");
148
+ await invoke("set_hit_rects", { rects });
149
+ } catch (e) {
150
+ console.error("puck: hit-rect report failed", e);
151
+ }
152
+ }, 150);
153
+ return () => clearInterval(iv);
154
+ }
frontend/src/lib/traces.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Ship trace records to the daemon at Night Bloom. Daemon down is normal
2
+ // (Space/demo mode) — buffer in localStorage and retry at the next sleep,
3
+ // so no day's labels are lost to a napping daemon.
4
+
5
+ import type { TraceRecord } from "../engine";
6
+
7
+ const BUFFER_KEY = "puck:trace-buffer";
8
+
9
+ export async function exportTraces(records: TraceRecord[]): Promise<void> {
10
+ let buffered: TraceRecord[] = [];
11
+ try {
12
+ buffered = JSON.parse(localStorage.getItem(BUFFER_KEY) ?? "[]");
13
+ } catch (e) {
14
+ console.error("puck: corrupt trace buffer, dropping it", e);
15
+ }
16
+ const all = [...buffered, ...records];
17
+ if (!all.length) return;
18
+
19
+ try {
20
+ const res = await fetch("/api/traces", {
21
+ method: "POST",
22
+ headers: { "Content-Type": "application/json" },
23
+ body: JSON.stringify({ records: all }),
24
+ signal: AbortSignal.timeout(5000),
25
+ });
26
+ if (!res.ok) throw new Error(`daemon rejected traces: ${res.status}`);
27
+ localStorage.removeItem(BUFFER_KEY);
28
+ } catch {
29
+ localStorage.setItem(BUFFER_KEY, JSON.stringify(all));
30
+ }
31
+ }
frontend/src/lib/useDrag.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+
3
+ export interface Pos {
4
+ x: number;
5
+ y: number;
6
+ }
7
+
8
+ /** Mouse-drag helper shared by windows and panels. Clamps to the viewport. */
9
+ export function useDrag(
10
+ pos: Pos,
11
+ onPos: (p: Pos) => void,
12
+ opts: { minX?: number; minY?: number; marginR?: number; marginB?: number } = {},
13
+ ) {
14
+ const { minX = 4, minY = 30, marginR = 80, marginB = 60 } = opts;
15
+ // fresh pos for the closure without re-binding listeners mid-drag
16
+ const ref = React.useRef(pos);
17
+ ref.current = pos;
18
+
19
+ return React.useCallback(
20
+ (e: React.MouseEvent) => {
21
+ const sx = e.clientX;
22
+ const sy = e.clientY;
23
+ const { x: ox, y: oy } = ref.current;
24
+ const move = (ev: MouseEvent) => {
25
+ onPos({
26
+ x: Math.max(minX, Math.min(window.innerWidth - marginR, ox + ev.clientX - sx)),
27
+ y: Math.max(minY, Math.min(window.innerHeight - marginB, oy + ev.clientY - sy)),
28
+ });
29
+ };
30
+ const up = () => {
31
+ window.removeEventListener("mousemove", move);
32
+ window.removeEventListener("mouseup", up);
33
+ };
34
+ window.addEventListener("mousemove", move);
35
+ window.addEventListener("mouseup", up);
36
+ },
37
+ [onPos, minX, minY, marginR, marginB],
38
+ );
39
+ }
frontend/src/lib/useIdle.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+
3
+ // Idle detection — the gate that makes cloud vision affordable. When the user
4
+ // is away (no input for `thresholdMs`) or the tab is hidden, ambient vision
5
+ // pauses so the Modal GPU scales to zero and bills nothing.
6
+ export function useIdle(thresholdMs = 150_000): boolean {
7
+ const [idle, setIdle] = React.useState(false);
8
+ const last = React.useRef(Date.now());
9
+
10
+ React.useEffect(() => {
11
+ const bump = () => {
12
+ last.current = Date.now();
13
+ setIdle(false); // wake immediately on activity (React bails if already false)
14
+ };
15
+ const events = ["mousemove", "mousedown", "keydown", "wheel", "touchstart"] as const;
16
+ for (const e of events) window.addEventListener(e, bump, { passive: true });
17
+
18
+ const tick = setInterval(() => {
19
+ setIdle(document.hidden || Date.now() - last.current > thresholdMs);
20
+ }, 5000);
21
+
22
+ return () => {
23
+ for (const e of events) window.removeEventListener(e, bump);
24
+ clearInterval(tick);
25
+ };
26
+ }, [thresholdMs]);
27
+
28
+ return idle;
29
+ }
frontend/src/lib/vision.ts ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Puck's eyes. Capture what's on screen, hand it to the vision brain (Modal),
2
+ // which returns wire events that the daemon queues — so the existing poll picks
3
+ // them up and Puck reacts, no special handling here. Vision is just another
4
+ // event source that happens to come from pixels instead of a hook.
5
+ //
6
+ // Sim/Space: snapshot the rendered desktop scene (html-to-image). The overlay's
7
+ // real-screen path (ScreenCaptureKit via Rust) lands later; same /api/brain/see.
8
+
9
+ import { toPng } from "html-to-image";
10
+ import type { FairyState } from "../engine";
11
+ import { moodFor } from "../engine";
12
+ import { inTauri, lookAroundNative, type PeekResult, peekNative, type Region } from "./tauri";
13
+
14
+ // Puck's own surfaces — he observes the desktop, not himself. Reading his own
15
+ // feed back through vision would be a hall-of-mirrors (and trains on noise).
16
+ const PUCK_UI = [
17
+ "puck", // the sprite
18
+ "comp", // companion (shows his own feed — the worst offender)
19
+ "bubble",
20
+ "toasts",
21
+ "drop", // menu dropdown
22
+ "settings",
23
+ "interrupt-wrap",
24
+ "bloom",
25
+ ];
26
+
27
+ /** Sim: render the fake desktop and crop to the small region Puck is peering at
28
+ * (Puck's own UI filtered out). The overlay crops in Rust instead. */
29
+ async function snapshotRegion(region: Region): Promise<string | null> {
30
+ const root = document.getElementById("root");
31
+ if (!root) return null;
32
+ try {
33
+ const full = await toPng(root, {
34
+ pixelRatio: 1,
35
+ filter: (node) => !(node instanceof HTMLElement) || !PUCK_UI.some((c) => node.classList?.contains?.(c)),
36
+ });
37
+ const im = new Image();
38
+ im.src = full;
39
+ await im.decode();
40
+ const c = document.createElement("canvas");
41
+ c.width = region.w;
42
+ c.height = region.h;
43
+ const ctx = c.getContext("2d");
44
+ if (!ctx) return null;
45
+ ctx.drawImage(im, region.x, region.y, region.w, region.h, 0, 0, region.w, region.h);
46
+ return c.toDataURL("image/jpeg", 0.85);
47
+ } catch (e) {
48
+ console.error("puck: region snapshot failed", e);
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /** Nudge the vision backend awake on load (fire-and-forget). On a hosted Space the
54
+ * cloud 12B scales to zero; pinging now means it's warming before the first peek
55
+ * (~50-95s out). No-op cost locally (a warm /models call). */
56
+ export function warmVision(): void {
57
+ void fetch("/api/brain/warm", { method: "POST" }).catch(() => {});
58
+ }
59
+
60
+ /** The companion loop's eye: peek at the patch under Puck → `{quip, emotion}` (or null).
61
+ * Overlay: Rust captures the region + the daemon voices it. Sim: crop the fake
62
+ * desktop and POST it. No events, no queue — a line for a bubble + a felt reaction. */
63
+ export async function peekScene(fs: FairyState, region: Region): Promise<PeekResult | null> {
64
+ const fairyState = { mischief: fs.mischief, mood: moodFor(fs) };
65
+ if (inTauri()) return peekNative(region, fairyState);
66
+ const image = await snapshotRegion(region);
67
+ if (!image) return null;
68
+ try {
69
+ const res = await fetch("/api/brain/peek", {
70
+ method: "POST",
71
+ headers: { "Content-Type": "application/json" },
72
+ body: JSON.stringify({ image, fairy_state: fairyState }),
73
+ signal: AbortSignal.timeout(70000),
74
+ });
75
+ if (!res.ok) return null;
76
+ const r = (await res.json()) as { quip?: string; emotion?: string };
77
+ return r.quip ? { quip: r.quip, emotion: r.emotion ?? "curious" } : null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /** Snapshot the SIMULATED desktop (sim/Space) to a PNG data URL, with Puck's own
84
+ * UI filtered out. The overlay's real-screen path lives in `perceive` (it's
85
+ * captured + posted entirely in Rust). */
86
+ async function snapshotScene(): Promise<string | null> {
87
+ const root = document.getElementById("root");
88
+ if (!root) return null;
89
+ return toPng(root, {
90
+ pixelRatio: 1, // the VLM needs legible content, not retina detail — keep it small
91
+ filter: (node) => !(node instanceof HTMLElement) || !PUCK_UI.some((c) => node.classList?.contains?.(c)),
92
+ }).catch((e) => {
93
+ console.error("puck: scene snapshot failed", e);
94
+ return null;
95
+ });
96
+ }
97
+
98
+ /** Look at the screen; perceived events are queued daemon-side (the existing poll
99
+ * delivers them). Returns the count it queued for UI feedback, or **null when vision
100
+ * is unavailable** (no endpoint / offline / timeout) — caller treats null distinctly
101
+ * ("my eyes are shut") from 0 ("nothing worth a fuss"). Null is graceful by design;
102
+ * vision is optional and the sim runs without it. */
103
+ export async function perceive(fs: FairyState): Promise<number | null> {
104
+ const fairyState = { mischief: fs.mischief, mood: moodFor(fs) };
105
+
106
+ // Overlay: Rust captures the real screen AND posts it (background) — the image
107
+ // never enters the webview. Blank Puck for ~2 frames so he isn't in the shot;
108
+ // lookAroundNative returns right after the capture (NOT after inference), so the
109
+ // blank is brief. Perceived events arrive via the poll. 0 = dispatched, null = failed.
110
+ if (inTauri()) {
111
+ const html = document.documentElement;
112
+ html.classList.add("capturing");
113
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
114
+ try {
115
+ return (await lookAroundNative(fairyState)) ? 0 : null;
116
+ } finally {
117
+ html.classList.remove("capturing");
118
+ }
119
+ }
120
+
121
+ const image = await snapshotScene();
122
+ if (!image) return null;
123
+ try {
124
+ const res = await fetch("/api/brain/see", {
125
+ method: "POST",
126
+ headers: { "Content-Type": "application/json" },
127
+ body: JSON.stringify({ image, fairy_state: fairyState }),
128
+ signal: AbortSignal.timeout(70000), // cloud VLM + possible cold start
129
+ });
130
+ if (!res.ok) return null; // 503 = no vision endpoint configured; silent like the brain seam
131
+ // daemon returns { observed, queued, events }; we only surface the queued count
132
+ const data: unknown = await res.json();
133
+ return (data as { queued?: number }).queued ?? 0;
134
+ } catch {
135
+ return null; // offline / timeout — vision is optional, the sim runs without it
136
+ }
137
+ }
frontend/src/lib/voice.ts ADDED
@@ -0,0 +1,421 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Puck's voice. Two engines behind one seam:
2
+ // - kokoro: local neural TTS (kokoro-js, Kokoro-82M ONNX) — runs in WebGPU/wasm
3
+ // right here in the page; model is fetched once from the HF CDN and cached by
4
+ // the browser. Genuinely characterful, fully local, works in the Tauri webview
5
+ // and the HF Space alike.
6
+ // - browser: OS SpeechSynthesis — instant, zero-download, the fallback when
7
+ // Kokoro hasn't loaded yet or errors.
8
+ // Gated by the interruption ladder (`speaks`) and shaped by mood + reaction.
9
+ // This module is the ONLY audio producer — callers never touch an engine directly.
10
+
11
+ import type { KokoroTTS } from "kokoro-js";
12
+ import type { Mood, ReactionKind } from "../engine";
13
+
14
+ export type VoiceMode = "off" | "whisper" | "full";
15
+ // browser = OS speechSynthesis · kokoro = local neural · say = macOS `say` via the
16
+ // daemon (the only thing that works in the WKWebView overlay — it plays Mac-side).
17
+ export type VoiceEngine = "browser" | "kokoro" | "say";
18
+ export type VoiceChannel = "interrupt" | "bubble" | "toast" | "reaction";
19
+
20
+ // ---- browser (OS) voices ----------------------------------------------------
21
+ let voices: SpeechSynthesisVoice[] = [];
22
+ let chosen: SpeechSynthesisVoice | null = null;
23
+
24
+ const PREFERRED = [
25
+ "Samantha",
26
+ "Karen",
27
+ "Moira",
28
+ "Tessa",
29
+ "Fiona",
30
+ "Google UK English Female",
31
+ "Google US English",
32
+ ];
33
+
34
+ function refresh() {
35
+ if (!("speechSynthesis" in window)) return;
36
+ voices = window.speechSynthesis.getVoices() || [];
37
+ chosen =
38
+ PREFERRED.map((n) => voices.find((v) => v.name === n)).find(Boolean) ??
39
+ voices.find((v) => /en[-_]/i.test(v.lang) && /female/i.test(v.name)) ??
40
+ voices.find((v) => /en[-_]/i.test(v.lang)) ??
41
+ voices[0] ??
42
+ null;
43
+ }
44
+ if (typeof window !== "undefined" && "speechSynthesis" in window) {
45
+ refresh();
46
+ window.speechSynthesis.onvoiceschanged = refresh;
47
+ }
48
+
49
+ /** OS voices for the Settings picker (english first). */
50
+ export function listVoices(): { name: string; lang: string }[] {
51
+ return [...voices]
52
+ .sort((a, b) => Number(/en/i.test(b.lang)) - Number(/en/i.test(a.lang)))
53
+ .map((v) => ({ name: v.name, lang: v.lang }));
54
+ }
55
+ function voiceByName(name?: string): SpeechSynthesisVoice | null {
56
+ if (!name) return chosen;
57
+ return voices.find((v) => v.name === name) ?? chosen;
58
+ }
59
+
60
+ // ---- kokoro (neural) voices -------------------------------------------------
61
+ // Curated subset of Kokoro's voices that suit a small, bright familiar.
62
+ export const KOKORO_VOICES = [
63
+ { id: "af_heart", label: "Heart · warm" },
64
+ { id: "af_bella", label: "Bella · bright" },
65
+ { id: "af_nicole", label: "Nicole · soft" },
66
+ { id: "af_aoede", label: "Aoede · gentle" },
67
+ { id: "af_sky", label: "Sky · light" },
68
+ { id: "af_jessica", label: "Jessica · airy" },
69
+ { id: "bf_emma", label: "Emma · british" },
70
+ { id: "am_puck", label: "Puck · playful" },
71
+ ] as const;
72
+ export const DEFAULT_KOKORO_VOICE = "af_heart";
73
+
74
+ let kokoro: KokoroTTS | null = null;
75
+ let kokoroPromise: Promise<KokoroTTS> | null = null;
76
+
77
+ export function kokoroReady(): boolean {
78
+ return kokoro !== null;
79
+ }
80
+
81
+ // Only run neural TTS where WebGPU exists. Chromium-on-Mac has it; the Tauri
82
+ // overlay's WKWebView does NOT — and loading transformers.js + the model there
83
+ // crashed WebKit's content process. So gate Kokoro on WebGPU and let those
84
+ // environments use the OS voice instead of a crash.
85
+ export function kokoroSupported(): boolean {
86
+ return typeof navigator !== "undefined" && "gpu" in navigator;
87
+ }
88
+
89
+ /** Lazy-load the model (code-split + fetched once, cached by the browser). */
90
+ export function loadKokoro(
91
+ onProgress?: (p: { progress?: number; status?: string }) => void,
92
+ ): Promise<KokoroTTS> {
93
+ if (kokoro) return Promise.resolve(kokoro);
94
+ if (!kokoroPromise) {
95
+ kokoroPromise = (async () => {
96
+ const { KokoroTTS } = await import("kokoro-js");
97
+ const device = "gpu" in navigator ? "webgpu" : "wasm";
98
+ const tts = await KokoroTTS.from_pretrained("onnx-community/Kokoro-82M-v1.0-ONNX", {
99
+ dtype: device === "webgpu" ? "fp32" : "q8", // fp32 only when the GPU can carry it
100
+ device,
101
+ progress_callback: onProgress as never,
102
+ });
103
+ kokoro = tts;
104
+ return tts;
105
+ })().catch((e) => {
106
+ kokoroPromise = null; // let a later attempt retry
107
+ throw e;
108
+ });
109
+ }
110
+ return kokoroPromise;
111
+ }
112
+
113
+ // the single in-flight neural clip, so cancel/mute can stop it mid-sentence
114
+ let currentAudio: HTMLAudioElement | null = null;
115
+ function stopKokoro() {
116
+ if (currentAudio) {
117
+ currentAudio.pause();
118
+ currentAudio = null;
119
+ }
120
+ }
121
+
122
+ async function speakKokoro(
123
+ text: string,
124
+ {
125
+ voiceName,
126
+ speed,
127
+ onStart,
128
+ onEnd,
129
+ }: { voiceName?: string; speed: number; onStart?: () => void; onEnd?: () => void },
130
+ ): Promise<boolean> {
131
+ try {
132
+ const tts = await loadKokoro();
133
+ const audio = await tts.generate(text, { voice: (voiceName || DEFAULT_KOKORO_VOICE) as never, speed });
134
+ const url = URL.createObjectURL(audio.toBlob());
135
+ stopKokoro();
136
+ const el = new Audio(url);
137
+ currentAudio = el;
138
+ el.onplay = () => onStart?.();
139
+ const done = () => {
140
+ onEnd?.();
141
+ URL.revokeObjectURL(url);
142
+ if (currentAudio === el) currentAudio = null;
143
+ };
144
+ el.onended = done;
145
+ el.onerror = done;
146
+ await el.play();
147
+ return true;
148
+ } catch (e) {
149
+ // model still downloading, webgpu hiccup, autoplay block — fall back to the OS voice
150
+ console.error("puck: kokoro tts failed → browser fallback", e);
151
+ return false;
152
+ }
153
+ }
154
+
155
+ // ---- native macOS voice (daemon `say`) --------------------------------------
156
+ // Plays on the Mac itself, so it works in the WKWebView overlay where browser
157
+ // audio is dead. The endpoint blocks until speech ends (or is superseded), so
158
+ // the fetch resolving == speech finished — good enough to drive the animation.
159
+ let sayAbort: AbortController | null = null;
160
+
161
+ async function sayViaDaemon(
162
+ text: string,
163
+ {
164
+ voice,
165
+ rate,
166
+ pitch,
167
+ onStart,
168
+ onEnd,
169
+ }: { voice?: string; rate: number; pitch?: number; onStart?: () => void; onEnd?: () => void },
170
+ ): Promise<boolean> {
171
+ try {
172
+ sayAbort?.abort();
173
+ sayAbort = new AbortController();
174
+ onStart?.();
175
+ // macOS `say` has no pitch flag, but honors an inline [[pbas N]] (pitch baseline).
176
+ // Map our ~0.6–1.8 pitch onto it so the overlay's native voice carries emotion too
177
+ // (~50 ≈ neutral). The daemon passes text straight to `say`, so the command runs.
178
+ const shaped =
179
+ pitch && pitch !== 1
180
+ ? `[[pbas ${Math.round(Math.max(30, Math.min(92, 50 * pitch)))}]] ${text}`
181
+ : text;
182
+ const res = await fetch("/api/voice/say", {
183
+ method: "POST",
184
+ headers: { "Content-Type": "application/json" },
185
+ // rate as words-per-minute (say's -r); 175 ≈ default cadence
186
+ body: JSON.stringify({ text: shaped, voice: voice || undefined, rate: Math.round(175 * rate) }),
187
+ signal: sayAbort.signal,
188
+ });
189
+ onEnd?.();
190
+ return res.ok; // 501 (non-mac) / offline → caller falls back to browser TTS
191
+ } catch {
192
+ onEnd?.(); // aborted (superseded) or daemon down
193
+ return false;
194
+ }
195
+ }
196
+
197
+ function stopSay() {
198
+ sayAbort?.abort();
199
+ void fetch("/api/voice/stop", { method: "POST" }).catch(() => {});
200
+ }
201
+
202
+ type SayVoice = { id: string; lang: string };
203
+ let sayVoiceCache: SayVoice[] | null = null;
204
+ /** macOS `say` voices for the Settings picker (fetched once from the daemon). */
205
+ export async function sayVoices(): Promise<SayVoice[]> {
206
+ if (sayVoiceCache) return sayVoiceCache;
207
+ let list: SayVoice[] = [];
208
+ try {
209
+ const res = await fetch("/api/voice/voices", { signal: AbortSignal.timeout(4000) });
210
+ if (res.ok) list = ((await res.json()) as { voices?: SayVoice[] }).voices ?? [];
211
+ } catch {
212
+ /* daemon down / non-mac — no native voices to list */
213
+ }
214
+ sayVoiceCache = list;
215
+ return list;
216
+ }
217
+
218
+ // ---- shared shaping ---------------------------------------------------------
219
+ const clean = (t: string) =>
220
+ t
221
+ .replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}←-⇿✦✕▸◷◍❍☾⎇♪]/gu, "")
222
+ .replace(/\s+/g, " ")
223
+ .trim();
224
+
225
+ // A spoken notification is a glance, not a paragraph: the bubble keeps the full
226
+ // (sometimes mythic, two-sentence) line, but speech takes just the first sentence,
227
+ // capped, so `say` doesn't read a whole monologue. Shouts are already short → no-op.
228
+ function concise(t: string, maxWords = 13): string {
229
+ const first = t.split(/(?<=[.!?])\s+/)[0]?.trim() || t.trim();
230
+ const words = first.split(/\s+/);
231
+ return words.length <= maxWords ? first : `${words.slice(0, maxWords).join(" ")}…`;
232
+ }
233
+
234
+ /** Should this channel speak under this mode? */
235
+ export function speaks(mode: VoiceMode, channel: VoiceChannel): boolean {
236
+ if (mode === "off") return false;
237
+ if (mode === "whisper") return channel === "interrupt";
238
+ return true; // full
239
+ }
240
+
241
+ // Mood sets the baseline delivery; a reaction layers a short emotional tilt on top.
242
+ const MOOD_DELIVERY: Record<Mood, { rate: number; pitch: number }> = {
243
+ curious: { rate: 1.0, pitch: 1.05 },
244
+ mischief: { rate: 1.12, pitch: 1.22 },
245
+ sleepy: { rate: 0.84, pitch: 0.9 },
246
+ proud: { rate: 0.98, pitch: 1.0 },
247
+ grumpy: { rate: 0.95, pitch: 0.92 },
248
+ };
249
+ const REACTION_TILT: Partial<Record<ReactionKind, { rate: number; pitch: number }>> = {
250
+ nani: { rate: 0.18, pitch: 0.28 }, // startled, fast and high (confusion)
251
+ celebrate: { rate: 0.06, pitch: 0.16 },
252
+ perk: { rate: 0.02, pitch: 0.12 },
253
+ dance: { rate: 0.1, pitch: 0.2 },
254
+ laugh: { rate: 0.12, pitch: 0.25 }, // bright, giddy (amusement)
255
+ fret: { rate: 0.16, pitch: 0.16 }, // quick, tense (the human's upset)
256
+ sad: { rate: -0.22, pitch: -0.24 }, // slow, low, heavy (wistful)
257
+ shrug: { rate: -0.12, pitch: -0.12 },
258
+ sulk: { rate: -0.16, pitch: -0.16 },
259
+ };
260
+
261
+ export interface SpeakOpts {
262
+ mood?: Mood;
263
+ mischief?: number;
264
+ mode?: VoiceMode;
265
+ channel?: VoiceChannel;
266
+ /** Layer a reaction's emotional tilt onto the mood baseline. */
267
+ reaction?: ReactionKind;
268
+ engine?: VoiceEngine;
269
+ /** Voice id/name for the active engine; falls back to that engine's default. */
270
+ voiceName?: string;
271
+ onStart?: () => void;
272
+ onEnd?: () => void;
273
+ }
274
+
275
+ function browserSpeak(
276
+ text: string,
277
+ o: {
278
+ rate: number;
279
+ pitch: number;
280
+ mode: VoiceMode;
281
+ channel: VoiceChannel;
282
+ voiceName?: string;
283
+ onStart?: () => void;
284
+ onEnd?: () => void;
285
+ },
286
+ ): boolean {
287
+ if (!("speechSynthesis" in window)) {
288
+ o.onEnd?.();
289
+ return false;
290
+ }
291
+ try {
292
+ window.speechSynthesis.cancel();
293
+ const u = new SpeechSynthesisUtterance(clean(text));
294
+ const v = voiceByName(o.voiceName);
295
+ if (v) u.voice = v;
296
+ u.rate = o.rate;
297
+ u.pitch = o.pitch;
298
+ u.volume =
299
+ o.channel === "interrupt" ? (o.mode === "whisper" ? 0.5 : 0.95) : o.channel === "reaction" ? 0.7 : 0.6;
300
+ u.onstart = () => o.onStart?.();
301
+ u.onend = () => o.onEnd?.();
302
+ u.onerror = () => o.onEnd?.();
303
+ window.speechSynthesis.speak(u);
304
+ return true;
305
+ } catch {
306
+ o.onEnd?.();
307
+ return false;
308
+ }
309
+ }
310
+
311
+ export function speakAloud(
312
+ text: string,
313
+ {
314
+ mood = "curious",
315
+ mischief = 45,
316
+ mode = "whisper",
317
+ channel = "bubble",
318
+ reaction,
319
+ engine = "browser",
320
+ voiceName,
321
+ onStart,
322
+ onEnd,
323
+ }: SpeakOpts = {},
324
+ ): boolean {
325
+ if (!speaks(mode, channel)) {
326
+ onEnd?.();
327
+ return false;
328
+ }
329
+ const base = MOOD_DELIVERY[mood];
330
+ const tilt = (reaction && REACTION_TILT[reaction]) || { rate: 0, pitch: 0 };
331
+ const rate = Math.max(0.6, Math.min(1.6, base.rate + tilt.rate));
332
+ const pitch = Math.max(0.6, Math.min(1.8, base.pitch + tilt.pitch + (mischief - 45) / 300));
333
+ // the displayed bubble keeps the full line; speech gets the trimmed form
334
+ const spoken = concise(clean(text));
335
+
336
+ if (engine === "say") {
337
+ // Mac-side native voice (the overlay's only working path). Fall back to
338
+ // browser TTS if the daemon is down / not macOS.
339
+ void sayViaDaemon(spoken, { voice: voiceName, rate, pitch, onStart, onEnd }).then((ok) => {
340
+ if (!ok) browserSpeak(spoken, { rate, pitch, mode, channel, onStart, onEnd });
341
+ });
342
+ return true;
343
+ }
344
+ if (engine === "kokoro" && kokoroSupported()) {
345
+ const speed = Math.max(0.7, Math.min(1.4, rate)); // Kokoro has no pitch knob — voice + speed carry it
346
+ void speakKokoro(spoken, { voiceName, speed, onStart, onEnd }).then((ok) => {
347
+ if (!ok) browserSpeak(spoken, { rate, pitch, mode, channel, onStart, onEnd }); // graceful degrade
348
+ });
349
+ return true;
350
+ }
351
+ // browser engine, or Kokoro unsupported here (e.g. the WKWebView overlay).
352
+ // Only forward voiceName for the real browser engine — a Kokoro id ("af_heart")
353
+ // means nothing to the OS, so let it auto-pick.
354
+ return browserSpeak(spoken, {
355
+ rate,
356
+ pitch,
357
+ mode,
358
+ channel,
359
+ voiceName: engine === "browser" ? voiceName : undefined,
360
+ onStart,
361
+ onEnd,
362
+ });
363
+ }
364
+
365
+ // Browsers block programmatic audio until the user has interacted with the page.
366
+ // Neural speech `await`s model-load + inference, so by the time it calls play()
367
+ // the gesture is long gone → NotAllowedError. Fix: on the FIRST real interaction,
368
+ // play a silent clip — that grants the page sticky activation, so every later
369
+ // (event-driven, gesture-less) utterance is allowed. Idempotent.
370
+ let unlocked = false;
371
+ export function unlockAudio(): void {
372
+ if (unlocked) return;
373
+ unlocked = true;
374
+ try {
375
+ const a = new Audio("data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=");
376
+ a.muted = true;
377
+ void a.play().catch(() => {});
378
+ } catch {
379
+ /* no Audio — neural path will just fall back to speechSynthesis */
380
+ }
381
+ }
382
+
383
+ function browserPreview(text: string, voiceName?: string): void {
384
+ if (!("speechSynthesis" in window)) return;
385
+ window.speechSynthesis.cancel();
386
+ const u = new SpeechSynthesisUtterance(text);
387
+ const v = voiceByName(voiceName);
388
+ if (v) u.voice = v;
389
+ u.pitch = 1.15;
390
+ u.rate = 1.02;
391
+ window.speechSynthesis.speak(u);
392
+ }
393
+
394
+ /** Audition a voice in Settings — bypasses the mode gate (it's an explicit tap). */
395
+ export function previewVoice(engine: VoiceEngine, voiceName?: string): void {
396
+ const text = "Hello — I'm Puck. I'll keep an eye on things.";
397
+ if (engine === "say") {
398
+ void sayViaDaemon(text, { voice: voiceName, rate: 1.0 }).then((ok) => {
399
+ if (!ok) browserPreview(text);
400
+ });
401
+ return;
402
+ }
403
+ if (engine === "kokoro" && kokoroSupported()) {
404
+ // fall back to the OS voice if the model is still downloading or errors
405
+ void speakKokoro(text, { voiceName, speed: 1.05 }).then((ok) => {
406
+ if (!ok) browserPreview(text);
407
+ });
408
+ return;
409
+ }
410
+ browserPreview(text, engine === "browser" ? voiceName : undefined);
411
+ }
412
+
413
+ export function cancelSpeech() {
414
+ stopKokoro();
415
+ stopSay();
416
+ try {
417
+ window.speechSynthesis?.cancel();
418
+ } catch {
419
+ /* no speech engine — nothing to cancel */
420
+ }
421
+ }
frontend/src/main.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { installDebugLog } from "./lib/debuglog";
4
+ import { SimApp } from "./modes/sim/SimApp";
5
+ import "./styles/base.css";
6
+ import "./styles/ui.css";
7
+
8
+ installDebugLog(); // overlay console → daemon log (the WKWebView inspector is unusable)
9
+
10
+ const root = document.getElementById("root");
11
+ if (!root) throw new Error("no #root element");
12
+
13
+ // ?mode=overlay → transparent shell over the real desktop; running inside the
14
+ // Tauri shell implies overlay. Default (browser/Space) → sim.
15
+ const overlay =
16
+ new URLSearchParams(location.search).get("mode") === "overlay" || "__TAURI_INTERNALS__" in window;
17
+
18
+ createRoot(root).render(
19
+ <StrictMode>
20
+ <SimApp overlay={overlay} />
21
+ </StrictMode>,
22
+ );
frontend/src/modes/sim/SimApp.tsx ADDED
@@ -0,0 +1,1021 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Puck — app orchestration. The living loop: events spawn,
2
+ // Puck flies, scores, decides, comments, learns, sleeps.
3
+
4
+ import * as React from "react";
5
+ import {
6
+ applyMode,
7
+ buildTraces,
8
+ clamp,
9
+ type Decision,
10
+ decide,
11
+ defaultState,
12
+ defaultTaste,
13
+ EVENTS,
14
+ type EventDef,
15
+ type FairyStat,
16
+ type FairyState,
17
+ type FeedItem,
18
+ hhmm,
19
+ Learn,
20
+ learn as learnTaste,
21
+ type Memory,
22
+ type MemorySeed,
23
+ moodFor,
24
+ type NightBloomResult,
25
+ nightBloom,
26
+ type OutcomeType,
27
+ outcomeRating,
28
+ type PuckMode,
29
+ asPeekEmotion,
30
+ type Rating,
31
+ type Reaction,
32
+ type ReactionKind,
33
+ rateFairy,
34
+ reactionForEmotion,
35
+ reactionForEvent,
36
+ reactionForRating,
37
+ type SurfaceOutcome,
38
+ score,
39
+ seedMemories,
40
+ shuffle,
41
+ speak,
42
+ summonReaction,
43
+ } from "../../engine";
44
+ import { startDaemonPoll } from "../../lib/daemon";
45
+ import { fetchBloom } from "../../lib/memories";
46
+ import { fetchMoltStatus, type MoltStatus } from "../../lib/molt";
47
+ import { osNotify } from "../../lib/notify";
48
+ import { defaultSettings, inkFor, type Settings } from "../../lib/settings";
49
+ import { usePersistent } from "../../lib/storage";
50
+ import { focusApp, startHitRectReporter } from "../../lib/tauri";
51
+ import { exportTraces } from "../../lib/traces";
52
+ import type { Pos } from "../../lib/useDrag";
53
+ import { useIdle } from "../../lib/useIdle";
54
+ import { peekScene, warmVision } from "../../lib/vision";
55
+ import {
56
+ cancelSpeech,
57
+ kokoroSupported,
58
+ loadKokoro,
59
+ speakAloud,
60
+ unlockAudio,
61
+ type VoiceChannel,
62
+ type VoiceEngine,
63
+ } from "../../lib/voice";
64
+ import { type ChatMsg, Companion, type CompanionTab, type MemoryAction } from "../../ui/Companion";
65
+ import {
66
+ applyEffect,
67
+ DesktopWindows,
68
+ Dock,
69
+ type DockApp,
70
+ initialWinState,
71
+ MenuBar,
72
+ type WinState,
73
+ } from "../../ui/Desktop";
74
+ import {
75
+ Interrupt,
76
+ type InterruptData,
77
+ MenuDropdown,
78
+ NightBloom,
79
+ type OfferData,
80
+ type ToastItem,
81
+ Toasts,
82
+ } from "../../ui/Overlays";
83
+ import { SettingsPanel } from "../../ui/SettingsPanel";
84
+ import { Bubble, type BubbleData, PuckSprite, type SpriteState } from "../../ui/Sprite";
85
+
86
+ const MOOD_LABEL = {
87
+ curious: "curious",
88
+ mischief: "feeling mischievous",
89
+ sleepy: "drowsy",
90
+ proud: "proud",
91
+ grumpy: "grumpy",
92
+ } as const;
93
+
94
+ let UID = 0;
95
+ const uid = () => `u${Date.now().toString(36)}-${++UID}`;
96
+
97
+ // "tap to open" in the sim: source → which sim window to focus (the overlay uses
98
+ // the native focusApp instead). Best-effort; not every source has a window.
99
+ const SOURCE_WINDOW: Record<string, string> = {
100
+ Claude: "claude",
101
+ Build: "terminal",
102
+ Git: "terminal",
103
+ Mail: "mail",
104
+ Discord: "discord",
105
+ Calendar: "calendar",
106
+ };
107
+
108
+ const initialPositions = (): Record<string, Pos> => ({
109
+ claude: { x: 40, y: 60 },
110
+ terminal: { x: 40, y: 392 },
111
+ mail: { x: 486, y: 64 },
112
+ discord: { x: 486, y: 408 },
113
+ calendar: { x: 300, y: 300 },
114
+ });
115
+
116
+ type Channel = "feed" | "glow" | "bubble" | "toast" | "interrupt";
117
+
118
+ // Decision→channel stays in the orchestrator on purpose: the engine ranks importance
119
+ // (the Decision ladder); how that lands on screen is a presentation policy per shell
120
+ // (sim bubble today, native notification in the Tauri overlay later).
121
+
122
+ function channelFor(decision: Decision, notify: Settings["notify"]): Channel {
123
+ if (decision === "ignore" || decision === "scroll") return "feed";
124
+ if (decision === "glow") return "glow";
125
+ // notify or interrupt
126
+ if (notify === "interrupt") return "interrupt";
127
+ if (notify === "toast") return "toast";
128
+ if (notify === "bubble") return "bubble";
129
+ // adaptive: Puck decides
130
+ return decision === "interrupt" ? "interrupt" : "bubble";
131
+ }
132
+
133
+ // One organism, two habitats: the sim renders a fake desktop and spawns deck
134
+ // events; the overlay renders transparent over the REAL desktop and only
135
+ // real daemon events fire. Same engine, same learning, same creature.
136
+ export function SimApp({ overlay = false }: { overlay?: boolean } = {}) {
137
+ // ---- persisted creature state -------------------------------------------
138
+ const [settings, setSettings] = usePersistent<Settings>("settings", defaultSettings);
139
+ const [fs, setFs] = usePersistent<FairyState>("fairy", defaultState);
140
+ const [taste, setTaste] = usePersistent("taste", defaultTaste);
141
+ const [profile, setProfile] = usePersistent("profile", Learn.defaultProfile);
142
+ const [memories, setMemories] = usePersistent<Memory[]>("memories", seedMemories);
143
+ const [feed, setFeed] = usePersistent<FeedItem[]>("feed", () => []);
144
+ // chat tab removed; setChat is still written by wake/offers (dormant, unshown)
145
+ const [, setChat] = usePersistent<ChatMsg[]>("chat", () => []);
146
+
147
+ // ---- ephemeral scene state -----------------------------------------------
148
+ const [positions, setPositions] = React.useState(initialPositions);
149
+ const [focused, setFocused] = React.useState<string | null>(null);
150
+ const [attnId, setAttnId] = React.useState<string | null>(null);
151
+ const [winState, setWinState] = React.useState<WinState>(initialWinState);
152
+
153
+ const [sprite, setSprite] = React.useState<SpriteState>({
154
+ x: 250,
155
+ y: 470,
156
+ flying: false,
157
+ resting: true,
158
+ facing: "right",
159
+ });
160
+ const [speaking, setSpeaking] = React.useState(false);
161
+ const [reaction, setReaction] = React.useState<{ kind: ReactionKind; shout?: string } | null>(null);
162
+ // monotonic — bumped on every reaction so the sprite restarts the animation
163
+ // even when the same gesture fires twice (back-to-back pushes still pop)
164
+ const [reactKey, setReactKey] = React.useState(0);
165
+ const [offer, setOffer] = React.useState<OfferData | null>(null);
166
+ const [bubble, setBubble] = React.useState<BubbleData | null>(null);
167
+ const [toasts, setToasts] = React.useState<ToastItem[]>([]);
168
+ const [interrupt, setInterrupt] = React.useState<InterruptData | null>(null);
169
+ const [sleeping, setSleeping] = React.useState<NightBloomResult | null>(null);
170
+ // real molt progress for the sleep screen (+ tonight's exported count); null = daemon down
171
+ const [molt, setMolt] = React.useState<(MoltStatus & { exported: number }) | null>(null);
172
+
173
+ const [companion, setCompanion] = React.useState(!overlay);
174
+ const [compTab, setCompTab] = React.useState<CompanionTab>("learn");
175
+ const [compPos, setCompPos] = React.useState<Pos>({ x: window.innerWidth - 404, y: 64 });
176
+ const [settingsOpen, setSettingsOpen] = React.useState(false);
177
+ const [settingsPos, setSettingsPos] = React.useState<Pos>({ x: window.innerWidth - 404, y: 400 });
178
+ const [dropdown, setDropdown] = React.useState(false);
179
+ const [muted, setMuted] = React.useState(false);
180
+ // camo: after sitting still a while Puck blends into the desktop (glassy/transparent
181
+ // skin); he snaps back the instant he acts. Pure ambient charm.
182
+ const [camo, setCamo] = React.useState(false);
183
+ const [mode, setMode] = React.useState<PuckMode>("patrol");
184
+ const [pulse, setPulse] = React.useState(0);
185
+ const [badge, setBadge] = React.useState(0);
186
+ const [clock, setClock] = React.useState(hhmm());
187
+
188
+ const mood = moodFor(fs);
189
+ const setSetting = <K extends keyof Settings>(key: K, value: Settings[K]) =>
190
+ setSettings((s) => ({ ...s, [key]: value }));
191
+
192
+ // ---- apply theme / accent / mood to <html> -------------------------------
193
+ React.useEffect(() => {
194
+ document.documentElement.dataset.theme = settings.theme;
195
+ }, [settings.theme]);
196
+ // biome-ignore lint/correctness/useExhaustiveDependencies: overlay never changes at runtime
197
+ React.useEffect(() => {
198
+ document.documentElement.classList.toggle("overlay", overlay);
199
+ // wake the (possibly cold, scale-to-zero) cloud vision before the first peek; in the
200
+ // browser sim, greet with a one-line "waking" intro so a first-time visitor (a judge
201
+ // on the hosted Space) knows the lull is Puck stretching, not a hang.
202
+ warmVision();
203
+ if (!overlay) {
204
+ const s = R.current.sprite;
205
+ setBubble({
206
+ uid: uid(),
207
+ source: "",
208
+ text: "mmf… give me a moment to wake up ✨",
209
+ tier: "playful",
210
+ tags: ["warm"],
211
+ x: s.x,
212
+ y: s.y,
213
+ ratable: false,
214
+ });
215
+ }
216
+ return startHitRectReporter();
217
+ }, []);
218
+ React.useEffect(() => {
219
+ const r = document.documentElement.style;
220
+ r.setProperty("--accent", settings.accent);
221
+ r.setProperty("--accent-ink", inkFor(settings.accent));
222
+ }, [settings.accent]);
223
+ React.useEffect(() => {
224
+ const c = document.documentElement.classList;
225
+ for (const m of ["curious", "mischief", "sleepy", "proud", "grumpy"]) c.remove(`mood-${m}`);
226
+ c.add(`mood-${sleeping ? "sleepy" : mood}`);
227
+ }, [mood, sleeping]);
228
+ // the mischief setting is the same dial as the fairy's mischief stat
229
+ React.useEffect(() => {
230
+ setFs((p) => (p.mischief === settings.mischief ? p : { ...p, mischief: settings.mischief }));
231
+ }, [settings.mischief, setFs]);
232
+ React.useEffect(() => {
233
+ const i = setInterval(() => setClock(hhmm()), 10000);
234
+ return () => clearInterval(i);
235
+ }, []);
236
+ // Heal a profile persisted before a source (e.g. Git) was added — otherwise the
237
+ // Learning tab indexes a missing profile.sources[id] and the render throws.
238
+ // biome-ignore lint/correctness/useExhaustiveDependencies: one-shot migration on mount
239
+ React.useEffect(() => {
240
+ setProfile((p) => Learn.ensureSources(p));
241
+ // one-time: wipe the pre-companion-mode feed (old "CLAUDE … notified" alert
242
+ // entries are confusing cruft now that Puck only roams + quips)
243
+ if (!localStorage.getItem("puck:companion-reset")) {
244
+ localStorage.setItem("puck:companion-reset", "1");
245
+ setFeed([]);
246
+ }
247
+ }, []);
248
+ // On the first real interaction: unlock audio (so later gesture-less speech can
249
+ // play past the autoplay gate) and warm the neural model so the first utterance
250
+ // isn't a cold ~80MB wait. Fires once, then detaches.
251
+ React.useEffect(() => {
252
+ const onFirst = () => {
253
+ unlockAudio();
254
+ const s = R.current.settings;
255
+ // only warm the neural model where WebGPU exists — never in the WKWebView overlay
256
+ if (s.voiceEngine === "kokoro" && s.voiceSound !== "off" && kokoroSupported()) void loadKokoro();
257
+ window.removeEventListener("pointerdown", onFirst);
258
+ window.removeEventListener("keydown", onFirst);
259
+ };
260
+ window.addEventListener("pointerdown", onFirst);
261
+ window.addEventListener("keydown", onFirst);
262
+ return () => {
263
+ window.removeEventListener("pointerdown", onFirst);
264
+ window.removeEventListener("keydown", onFirst);
265
+ };
266
+ }, []);
267
+
268
+ // refs so the interval reads fresh values
269
+ const R = React.useRef({
270
+ fs,
271
+ taste,
272
+ mode,
273
+ settings,
274
+ sleeping,
275
+ bubble,
276
+ interrupt,
277
+ profile,
278
+ companion,
279
+ sprite,
280
+ muted,
281
+ });
282
+ R.current = { fs, taste, mode, settings, sleeping, bubble, interrupt, profile, companion, sprite, muted };
283
+
284
+ // ---- voice ---------------------------------------------------------------
285
+ const sayAloud = (text: string, channel: VoiceChannel, reaction?: ReactionKind) => {
286
+ const cur = R.current;
287
+ if (cur.muted) return; // muted Puck still emotes, just silently
288
+ // the overlay is a WKWebView — only the Mac-native `say` path makes sound there
289
+ const engine: VoiceEngine = overlay ? "say" : cur.settings.voiceEngine;
290
+ const voiceName =
291
+ engine === "kokoro"
292
+ ? cur.settings.kokoroVoice
293
+ : engine === "say"
294
+ ? cur.settings.sayVoice
295
+ : cur.settings.voiceName;
296
+ speakAloud(text, {
297
+ mood: moodFor(cur.fs),
298
+ mischief: cur.fs.mischief,
299
+ mode: cur.settings.voiceSound,
300
+ channel,
301
+ reaction,
302
+ engine,
303
+ voiceName,
304
+ onStart: () => setSpeaking(true),
305
+ onEnd: () => setSpeaking(false),
306
+ });
307
+ };
308
+
309
+ // ---- reactions (the visible personality layer) ---------------------------
310
+ const reactTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
311
+ const react = (rx: Reaction) => {
312
+ uncamo(); // any gesture snaps him out of hiding
313
+ setReactKey((k) => k + 1);
314
+ setReaction({ kind: rx.kind, shout: rx.shout });
315
+ // speak the shout too (a bubble/interrupt sentence ~760ms later cancels and
316
+ // supersedes it — so feed-only glances get the shout as their only audio)
317
+ if (rx.shout && R.current.settings.speakShouts) sayAloud(rx.shout, "reaction", rx.kind);
318
+ clearTimeout(reactTimer.current);
319
+ reactTimer.current = setTimeout(() => setReaction(null), rx.ms);
320
+ };
321
+ // short-term memory of what just happened, so a recurring event reads as
322
+ // "not again" rather than fresh delight. 2-min window; id-keyed.
323
+ const recent = React.useRef<{ id: string; t: number }[]>([]);
324
+ const repeatsOf = (id: string): number => {
325
+ const now = Date.now();
326
+ const live = recent.current.filter((e) => now - e.t < 120_000);
327
+ recent.current = [...live, { id, t: now }].slice(-30);
328
+ return live.filter((e) => e.id === id).length;
329
+ };
330
+
331
+ // ---- sprite flight -------------------------------------------------------
332
+ const flyTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
333
+ const flyTo = (x: number, y: number, after?: () => void) => {
334
+ uncamo(); // he can't fly while blended in — reveal, then move
335
+ setSprite((s) => ({ ...s, x, y, flying: true, resting: false, facing: x < s.x ? "left" : "right" }));
336
+ clearTimeout(flyTimer.current);
337
+ flyTimer.current = setTimeout(() => {
338
+ setSprite((s) => ({ ...s, flying: false, resting: true }));
339
+ after?.();
340
+ }, 760);
341
+ };
342
+ const winCenter = (id: string): Pos => {
343
+ const el = document.getElementById(`win-${id}`);
344
+ if (!el) return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
345
+ const r = el.getBoundingClientRect();
346
+ return { x: r.left + r.width / 2, y: r.top + 18 };
347
+ };
348
+
349
+ // ---- fire one event ------------------------------------------------------
350
+ const queue = React.useRef<EventDef[]>([]);
351
+ const nextEvent = (): EventDef => {
352
+ if (!queue.current.length) queue.current = shuffle(EVENTS.slice());
353
+ const ev = queue.current.pop();
354
+ if (!ev) throw new Error("empty event deck");
355
+ return ev;
356
+ };
357
+
358
+ const fireEvent = (forced?: EventDef) => {
359
+ const cur = R.current;
360
+ if (cur.sleeping) return;
361
+ const ev = forced ?? nextEvent();
362
+
363
+ // an accepted automation covers this exact event? Puck handles it silently.
364
+ // (scoped per-event: a @mention still escapes the muted bog)
365
+ const auto = Learn.handlesEvent(cur.profile, ev.source, ev.id);
366
+ if (auto) {
367
+ setWinState((w) => applyEffect(w, ev.effect));
368
+ const item: FeedItem = {
369
+ uid: uid(),
370
+ id: ev.id,
371
+ source: ev.source,
372
+ say: `Handled it for you — ${auto.done}.`,
373
+ tier: "plain",
374
+ tags: ["useful"],
375
+ decision: "handled",
376
+ time: hhmm(),
377
+ rating: null,
378
+ };
379
+ setFeed((f) => [item, ...f].slice(0, 60));
380
+ if (ev.target) {
381
+ const t = winCenter(ev.target);
382
+ flyTo(t.x, t.y, () => setTimeout(() => setAttnId(null), 1000));
383
+ }
384
+ return;
385
+ }
386
+
387
+ const s = score(ev, cur.fs);
388
+ let decision = decide(s);
389
+ // LEARNED: shift the decision by what Puck knows about this source
390
+ decision = Learn.applyBias(decision, Learn.sourceBias(cur.profile, ev.source));
391
+ decision = applyMode(decision, cur.mode);
392
+
393
+ // mutate the relevant window
394
+ setWinState((w) => applyEffect(w, ev.effect));
395
+ if (ev.target) setAttnId(ev.target);
396
+
397
+ const v = speak(ev, cur.fs, cur.taste);
398
+ // emote about it even when there are no words — Puck reacting to everything
399
+ // on screen is the "marginally useful, always alive" character
400
+ react(reactionForEvent(ev, { repeats: repeatsOf(ev.id), mischief: cur.fs.mischief }));
401
+ // muted = stay watching but go quiet: everything drops to the feed, no popups
402
+ let channel = cur.muted ? "feed" : channelFor(decision, cur.settings.notify);
403
+ // the overlay has no sim windows to anchor a speech bubble to — surface every
404
+ // notification as a corner toast (tap to open / fling to swat), never a bubble
405
+ if (overlay && channel === "bubble") channel = "toast";
406
+ const item: FeedItem = {
407
+ uid: uid(),
408
+ id: ev.id,
409
+ source: ev.source,
410
+ say: v.text,
411
+ tier: v.tier,
412
+ tags: v.tags,
413
+ decision,
414
+ time: hhmm(),
415
+ rating: null,
416
+ channel,
417
+ };
418
+ setFeed((f) => [item, ...f].slice(0, 60));
419
+ // surfaced channels get an engagement clock; how (and whether) the user
420
+ // resolves them becomes a training label at Night Bloom
421
+ if (channel === "bubble" || channel === "toast" || channel === "interrupt") markSurfaced(item.uid);
422
+
423
+ const target = ev.target
424
+ ? winCenter(ev.target)
425
+ : { x: 200 + Math.random() * 400, y: 200 + Math.random() * 200 };
426
+
427
+ if (channel === "feed") {
428
+ // a glance, no words
429
+ if (ev.target) flyTo(target.x, target.y, () => setTimeout(() => setAttnId(null), 1400));
430
+ else setAttnId(null);
431
+ return;
432
+ }
433
+ if (channel === "glow") {
434
+ flyTo(target.x, target.y);
435
+ if (!cur.companion) setBadge((b) => b + 1);
436
+ setTimeout(() => setAttnId(null), 2600);
437
+ return;
438
+ }
439
+ if (channel === "interrupt") {
440
+ // Overlay: no screen takeover. Puck demands attention with a big "summon"
441
+ // dance + a (non-blocking) toast, not a modal — user feedback: a dance beats
442
+ // taking over the screen. The sim keeps the modal (its "take me there" can
443
+ // focus a sim window; the overlay can't focus real apps yet).
444
+ if (overlay) {
445
+ // dance in the open space just LEFT of the toast stack (top:34 right:12
446
+ // w:330), not under it — he was flying into the notifications and hiding
447
+ flyTo(window.innerWidth - 420, 96);
448
+ react(summonReaction());
449
+ setToasts((ts) => [...ts, { uid: item.uid, source: ev.source, say: v.text }]);
450
+ if (!cur.companion) setBadge((b) => b + 1);
451
+ mirrorToOs(item.uid, v.text);
452
+ sayAloud(v.text, "interrupt"); // interrupt channel speaks even in whisper
453
+ setTimeout(() => setAttnId(null), 2600);
454
+ return;
455
+ }
456
+ flyTo(window.innerWidth / 2, window.innerHeight / 2 - 80);
457
+ setInterrupt({ uid: item.uid, say: v.text, target: ev.target });
458
+ mirrorToOs(item.uid, v.text);
459
+ sayAloud(v.text, "interrupt");
460
+ return;
461
+ }
462
+ if (channel === "toast") {
463
+ flyTo(window.innerWidth - 420, 96); // beside the toast stack, not under it
464
+ setToasts((ts) => [...ts, { uid: item.uid, source: ev.source, say: v.text }]);
465
+ if (!cur.companion) setBadge((b) => b + 1);
466
+ mirrorToOs(item.uid, v.text);
467
+ sayAloud(v.text, "toast");
468
+ setTimeout(() => setAttnId(null), 2600);
469
+ return;
470
+ }
471
+ // bubble
472
+ flyTo(target.x, target.y, () => {
473
+ setBubble({
474
+ uid: item.uid,
475
+ source: ev.source,
476
+ text: v.text,
477
+ tier: v.tier,
478
+ tags: v.tags,
479
+ x: target.x,
480
+ y: target.y,
481
+ ratable: true,
482
+ });
483
+ mirrorToOs(item.uid, v.text);
484
+ sayAloud(v.text, "bubble");
485
+ });
486
+ if (!cur.companion) setBadge((b) => b + 1);
487
+ };
488
+
489
+ // ---- engagement outcomes (fine-tune labels) -------------------------------
490
+ const surfaced = React.useRef(new Map<string, { at: number; visible: boolean }>());
491
+ const markSurfaced = (feedUid: string) => {
492
+ surfaced.current.set(feedUid, { at: Date.now(), visible: document.visibilityState === "visible" });
493
+ };
494
+ // tab buried → reach through the OS; a click is real engagement
495
+ const mirrorToOs = (feedUid: string, text: string) => {
496
+ const cur = R.current;
497
+ if (!cur.settings.osNotify || !document.hidden) return;
498
+ osNotify(text, () => {
499
+ resolveOutcome(feedUid, "clicked_through");
500
+ setCompanion(true);
501
+ setBadge(0);
502
+ });
503
+ };
504
+ const resolveOutcome = (feedUid: string, type: OutcomeType, rating: Rating | null = null) => {
505
+ const s = surfaced.current.get(feedUid);
506
+ if (!s) return; // not a tracked surface (or already resolved)
507
+ surfaced.current.delete(feedUid);
508
+ const outcome: SurfaceOutcome = { type, rating, latencyMs: Date.now() - s.at, visible: s.visible };
509
+ setFeed((f) => f.map((x) => (x.uid === feedUid ? { ...x, outcome } : x)));
510
+ };
511
+
512
+ // ---- ratings & learning --------------------------------------------------
513
+ const applyRating = (feedUid: string, rating: Rating, tags?: FeedItem["tags"]) => {
514
+ const item = feed.find((x) => x.uid === feedUid);
515
+ const source = item?.source;
516
+ setTaste((tt) => learnTaste(tt, tags?.length ? tags : ["useful", "warm"], rating));
517
+ setFeed((f) => f.map((x) => (x.uid === feedUid ? { ...x, rating } : x)));
518
+ setFs((p) => rateFairy(p, rating));
519
+ // LEARNED: push this source's interruption bias, then maybe offer to act
520
+ if (source) {
521
+ setProfile((pr) => {
522
+ const next = Learn.rateSource(pr, source, rating);
523
+ const o = Learn.automationOffer(next, source);
524
+ if (o)
525
+ setTimeout(() => setOffer({ uid: uid(), source: o.source, verb: o.verb, offer: o.offer }), 900);
526
+ return next;
527
+ });
528
+ }
529
+ react(reactionForRating(rating));
530
+ setPulse((p) => p + 1);
531
+ setAttnId(null);
532
+ };
533
+
534
+ // accept / decline a learned automation
535
+ const acceptOffer = () => {
536
+ if (!offer) return;
537
+ setProfile((pr) => Learn.acceptAutomation(pr, offer.source));
538
+ setChat((c) => [
539
+ ...c,
540
+ {
541
+ who: "puck",
542
+ text: `Done. I'll handle ${offer.verb} from now on. You won't hear about it unless it matters.`,
543
+ },
544
+ ]);
545
+ setFeed((f) => [
546
+ {
547
+ uid: uid(),
548
+ id: `learned_${offer.source}`,
549
+ source: offer.source,
550
+ say: `Learned a new trick — I'll ${offer.verb} from now on.`,
551
+ tier: "plain" as const,
552
+ tags: ["useful" as const],
553
+ decision: "learned" as const,
554
+ time: hhmm(),
555
+ rating: null,
556
+ },
557
+ ...f,
558
+ ]);
559
+ setOffer(null);
560
+ };
561
+ const declineOffer = () => {
562
+ if (offer) setProfile((pr) => Learn.declineAutomation(pr, offer.source));
563
+ setOffer(null);
564
+ };
565
+
566
+ const rateBubble = (rating: Rating) => {
567
+ if (bubble) {
568
+ resolveOutcome(bubble.uid, "rated", rating);
569
+ applyRating(bubble.uid, rating, bubble.tags);
570
+ }
571
+ setBubble(null);
572
+ };
573
+ const dismissBubble = () => {
574
+ if (bubble) resolveOutcome(bubble.uid, "dismissed");
575
+ setBubble(null);
576
+ setAttnId(null);
577
+ };
578
+ // Toast interactions ARE the learning signal (no rating buttons): a tap opens
579
+ // the app it's about (positive), a fling swats it away (negative). Both just
580
+ // record an outcome — Night Bloom turns the day's behavior into taste/bias.
581
+ const goToast = (tUid: string) => {
582
+ const t = toasts.find((x) => x.uid === tUid);
583
+ resolveOutcome(tUid, "clicked_through");
584
+ if (t) {
585
+ if (overlay) void focusApp(t.source);
586
+ else if (SOURCE_WINDOW[t.source]) setFocused(SOURCE_WINDOW[t.source]);
587
+ }
588
+ setToasts((ts) => ts.filter((x) => x.uid !== tUid));
589
+ setAttnId(null);
590
+ };
591
+ const swatToast = (tUid: string) => {
592
+ resolveOutcome(tUid, "swatted");
593
+ setToasts((ts) => ts.filter((x) => x.uid !== tUid));
594
+ setAttnId(null);
595
+ };
596
+ const rateInterrupt = (rating: Rating) => {
597
+ if (interrupt) {
598
+ // "Take me there" is stronger than a thumbs-up — the interrupt earned a context switch
599
+ resolveOutcome(interrupt.uid, rating === "helpful" ? "clicked_through" : "rated", rating);
600
+ applyRating(interrupt.uid, rating, feed.find((f) => f.uid === interrupt.uid)?.tags);
601
+ if (rating === "helpful" && interrupt.target) setFocused(interrupt.target);
602
+ }
603
+ setInterrupt(null);
604
+ };
605
+
606
+ // ---- the companion loop: roam → peek at the patch under him → quip --------
607
+ // This IS Puck now: an ambient creature that drifts around, peeks at the small
608
+ // region he's hovering over, and murmurs one whimsical line about what he sees.
609
+ // No alerts, no events — calm-tech "Diversion" by default. (The old event/alert
610
+ // path is dormant; see the daemon poll below.)
611
+ const wnCD = React.useRef(5);
612
+ const pkCD = React.useRef(7); // ticks (≈s) until the first peek
613
+ const CAMO_TICKS = 7; // seconds of stillness before he blends in
614
+ const camoCD = React.useRef(CAMO_TICKS);
615
+ const uncamo = () => {
616
+ setCamo(false);
617
+ camoCD.current = CAMO_TICKS; // reset the stillness clock so he doesn't re-blend at once
618
+ };
619
+ const idle = useIdle();
620
+ const idleRef = React.useRef(idle);
621
+ idleRef.current = idle;
622
+ const peeking = React.useRef(false);
623
+
624
+ // the patch he's peering at: a window-ish box offset in his facing direction
625
+ // (so he's never in his own shot), clamped on-screen. Logical/CSS px.
626
+ const peekRegion = (s: SpriteState) => {
627
+ // a window-sized patch (logical px); peekNative scales by devicePixelRatio,
628
+ // so this covers the same screen area on retina. Bigger = more context for a
629
+ // better comment, still one fast inference (no tiling).
630
+ const W = 820;
631
+ const H = 560;
632
+ const x0 = s.facing === "left" ? s.x - 16 - W : s.x + 16;
633
+ return {
634
+ x: Math.round(Math.max(0, Math.min(window.innerWidth - W, x0))),
635
+ y: Math.round(Math.max(0, Math.min(window.innerHeight - H, s.y - H / 2))),
636
+ w: W,
637
+ h: H,
638
+ };
639
+ };
640
+
641
+ const doPeek = async () => {
642
+ if (peeking.current) return;
643
+ peeking.current = true;
644
+ uncamo(); // surface to take a look
645
+ const html = document.documentElement;
646
+ try {
647
+ const region = peekRegion(R.current.sprite);
648
+ // BLANK Puck's own UI (sprite, bubbles, the open companion panel) so the
649
+ // screenshot is the real desktop, not himself reading his own feed. The
650
+ // Rust capture grabs the region first thing; unblank shortly after (well
651
+ // before the ~5s VLM finishes) so it's only a brief flicker, not a freeze.
652
+ html.classList.add("capturing");
653
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
654
+ const resultPromise = peekScene(R.current.fs, region);
655
+ setTimeout(() => html.classList.remove("capturing"), 800); // capture is done by now
656
+ const result = await resultPromise;
657
+ console.log("puck: peek", JSON.stringify(region), "→", result?.emotion, result?.quip);
658
+ if (!result) return;
659
+ const { quip, emotion } = result;
660
+ const s = R.current.sprite;
661
+ // FEEL it first — the VLM-classified emotion drives a one-shot gesture + shout
662
+ // (laugh, NANI-confusion, fret, …); the bubble sentence ~760ms later supersedes
663
+ // the shout's audio, so we hear the shout then the quip.
664
+ const rx = reactionForEmotion(asPeekEmotion(emotion), { mischief: R.current.fs.mischief });
665
+ react(rx);
666
+ // the quip IS the bubble — a self-dismissing murmur, no actions, no rating
667
+ setBubble({
668
+ uid: uid(),
669
+ source: "",
670
+ text: quip,
671
+ tier: "playful",
672
+ tags: ["warm"],
673
+ x: s.x,
674
+ y: s.y,
675
+ ratable: false,
676
+ });
677
+ // the persisted Memory tab (daemon peek log) is the record now — no feed.
678
+ // Speak the quip in the SAME emotional register as the gesture (rate + pitch tilt).
679
+ sayAloud(quip, "bubble", rx.kind);
680
+ } finally {
681
+ html.classList.remove("capturing");
682
+ peeking.current = false;
683
+ }
684
+ };
685
+
686
+ // biome-ignore lint/correctness/useExhaustiveDependencies: interval reads via refs
687
+ React.useEffect(() => {
688
+ const iv = setInterval(() => {
689
+ const cur = R.current;
690
+ if (cur.sleeping) return;
691
+ const presence = cur.settings.presence;
692
+ const restful = !cur.bubble && !cur.sprite.flying;
693
+ // wander
694
+ wnCD.current -= 1;
695
+ if (wnCD.current <= 0) {
696
+ wnCD.current = Math.round(3 + (100 - presence) / 12);
697
+ if (presence > 14 && restful && Math.random() < presence / 90) {
698
+ const x = 120 + Math.random() * (window.innerWidth - 460);
699
+ const y = 110 + Math.random() * (window.innerHeight - 320);
700
+ flyTo(x, y);
701
+ }
702
+ }
703
+ // peek + quip on an ambient cadence, paused when away / busy
704
+ pkCD.current -= 1;
705
+ if (pkCD.current <= 0) {
706
+ pkCD.current = 50 + Math.floor(Math.random() * 45); // ~50-95s, jittered
707
+ if (restful && !idleRef.current && !document.hidden && !peeking.current) void doPeek();
708
+ }
709
+ // camo: blend into the desktop after sitting still a while (uncloak is snappy and
710
+ // handled at the action sites — react/flyTo/doPeek/poke — so here we only fade IN)
711
+ if (restful && !peeking.current && !document.hidden) {
712
+ camoCD.current -= 1;
713
+ if (camoCD.current <= 0) setCamo(true);
714
+ } else {
715
+ camoCD.current = CAMO_TICKS;
716
+ setCamo(false);
717
+ }
718
+ }, 1000);
719
+ return () => clearInterval(iv);
720
+ }, []);
721
+
722
+ // auto-dismiss an unrated bubble after a while (becomes part of the scroll);
723
+ // expiry is itself a label: surfaced, seen or not, never engaged
724
+ // biome-ignore lint/correctness/useExhaustiveDependencies: resolveOutcome is recreated per render but stable in behavior
725
+ React.useEffect(() => {
726
+ if (!bubble) return;
727
+ const tm = setTimeout(() => {
728
+ resolveOutcome(bubble.uid, "expired");
729
+ setBubble(null);
730
+ }, 11000);
731
+ return () => clearTimeout(tm);
732
+ }, [bubble]);
733
+
734
+ // toasts you neither open nor swat expire on their own — that silence is a
735
+ // (mild) signal too, and it stops the corner from stacking up notifications
736
+ // biome-ignore lint/correctness/useExhaustiveDependencies: resolveOutcome stable in behavior
737
+ React.useEffect(() => {
738
+ if (!toasts.length) return;
739
+ const iv = setInterval(() => {
740
+ const now = Date.now();
741
+ const dead = toasts.filter((t) => {
742
+ const s = surfaced.current.get(t.uid);
743
+ return s && now - s.at > 15000;
744
+ });
745
+ if (!dead.length) return;
746
+ for (const t of dead) resolveOutcome(t.uid, "expired");
747
+ setToasts((ts) => ts.filter((t) => !dead.some((d) => d.uid === t.uid)));
748
+ }, 1000);
749
+ return () => clearInterval(iv);
750
+ }, [toasts]);
751
+
752
+ // ---- real events from the local daemon (DORMANT in companion mode) -------
753
+ // For the hackathon Puck is purely a companion that roams + comments — he does
754
+ // NOT alert on hooks (Claude finishing, builds, mentions). We still drain the
755
+ // queue so it doesn't grow, but surface nothing. The narrate→fireEvent path
756
+ // below stays in the code for when "useful alerts" come back later.
757
+ // biome-ignore lint/correctness/useExhaustiveDependencies: poll reads via refs; bind once
758
+ React.useEffect(() => {
759
+ return startDaemonPoll(() => {
760
+ /* companion mode: drain + ignore — no alerting on hook events */
761
+ });
762
+ }, []);
763
+
764
+ // ---- memory garden actions ----------------------------------------------
765
+ const memAction = (kind: MemoryAction, id: string) => {
766
+ setMemories((ms) => {
767
+ if (kind === "prune") return ms.filter((m) => m.id !== id);
768
+ return ms.map((m) =>
769
+ m.id !== id
770
+ ? m
771
+ : kind === "water"
772
+ ? { ...m, salience: Math.min(1, m.salience + 0.12) }
773
+ : { ...m, pinned: !m.pinned, salience: m.pinned ? m.salience : 1 },
774
+ );
775
+ });
776
+ };
777
+
778
+ // ---- sleep / wake --------------------------------------------------------
779
+ const beginSleep = () => {
780
+ cancelSpeech();
781
+ setSpeaking(false);
782
+ setBubble(null);
783
+ setInterrupt(null);
784
+ setToasts([]);
785
+ setDropdown(false);
786
+ setOffer(null);
787
+ setSettingsOpen(false);
788
+ // export the day as training traces before the feed is composted.
789
+ // Anything still on screen resolves as "unresolved" with its dwell time.
790
+ const exportFeed = feed.map((x) => {
791
+ const s = surfaced.current.get(x.uid);
792
+ return s && !x.outcome
793
+ ? {
794
+ ...x,
795
+ outcome: {
796
+ type: "unresolved" as const,
797
+ rating: null,
798
+ latencyMs: Date.now() - s.at,
799
+ visible: s.visible,
800
+ },
801
+ }
802
+ : x;
803
+ });
804
+ surfaced.current.clear();
805
+ const records = buildTraces(exportFeed, fs, taste, profile);
806
+ // export first, THEN read status so the gauge includes tonight's just-appended traces
807
+ void exportTraces(records).then(() =>
808
+ fetchMoltStatus().then((m) => setMolt(m && { ...m, exported: records.length })),
809
+ );
810
+ setSleeping(nightBloom(feed));
811
+ // the day's peeks BLOOM into durable garden memories (LLM distill, ~slow) —
812
+ // they stream into the sleep result, then wake() plants them in the Garden
813
+ void fetchBloom({ mischief: fs.mischief, mood: moodFor(fs) }).then((bloomed) => {
814
+ if (bloomed.length) setSleeping((r) => (r ? { ...r, memories: [...bloomed, ...r.memories] } : r));
815
+ });
816
+ };
817
+ const wake = () => {
818
+ const r = sleeping;
819
+ if (r) {
820
+ setFs((p) => {
821
+ const n: FairyState = { ...p, ageDays: p.ageDays + 1, energy: 88 };
822
+ for (const [k, v] of Object.entries(r.deltas) as [FairyStat | "age", number][]) {
823
+ if (k !== "age") n[k] = clamp(n[k] + v);
824
+ }
825
+ return n;
826
+ });
827
+ if (r.deltas.mischief) setSetting("mischief", clamp(fs.mischief + r.deltas.mischief));
828
+ if (r.memories.length) {
829
+ setMemories((ms) => [
830
+ ...r.memories.map((m: MemorySeed, i: number) => ({
831
+ ...m,
832
+ id: `n${Date.now()}${i}`,
833
+ salience: 0.7,
834
+ pinned: false,
835
+ })),
836
+ ...ms,
837
+ ]);
838
+ }
839
+ setChat((c) => [...c, { who: "puck", text: r.morning }]);
840
+ // consolidate the day's BEHAVIOR into per-source interruption bias overnight
841
+ // (implicit only — items rated explicitly in the sim already shifted bias live)
842
+ setProfile((pr) => {
843
+ let next = pr;
844
+ for (const item of feed) {
845
+ if (item.rating) continue;
846
+ const rt = outcomeRating(item);
847
+ if (rt) next = Learn.rateSource(next, item.source, rt);
848
+ }
849
+ return next;
850
+ });
851
+ }
852
+ setSleeping(null);
853
+ setPulse((p) => p + 1);
854
+ setFeed([]);
855
+ setWinState(initialWinState());
856
+ };
857
+
858
+ // ---- status line for dropdown -------------------------------------------
859
+ const lastNotable = feed.find((f) => f.decision !== "ignore");
860
+ const status = lastNotable ? lastNotable.say : "All quiet. I'm patrolling the desktops you left behind.";
861
+
862
+ const openCompanion = () => {
863
+ uncamo(); // poked — pop into view
864
+ setCompanion(true);
865
+ setBadge(0);
866
+ setDropdown(false);
867
+ };
868
+
869
+ // Mute = Puck keeps watching (events still log to the feed) but goes silent and
870
+ // stops popping up. Muting now also cuts any audio/surface mid-flight.
871
+ const toggleMute = () => {
872
+ setDropdown(false);
873
+ setMuted((m) => {
874
+ if (!m) {
875
+ cancelSpeech();
876
+ setBubble(null);
877
+ setInterrupt(null);
878
+ setToasts([]);
879
+ }
880
+ return !m;
881
+ });
882
+ };
883
+
884
+ // menu "Look around": a manual peek-and-quip (same as the ambient loop, on demand)
885
+ const lookAround = () => {
886
+ setDropdown(false);
887
+ void doPeek();
888
+ };
889
+
890
+ const dockApps: DockApp[] = React.useMemo(
891
+ () => [
892
+ { id: "finder", name: "Finder", glyph: "🗂" },
893
+ {
894
+ id: "claude",
895
+ name: "Claude Code",
896
+ glyph: "✳",
897
+ running: true,
898
+ badge: winState.claude === "done" ? 1 : 0,
899
+ },
900
+ { id: "terminal", name: "Terminal", glyph: "▦", running: true },
901
+ { id: "mail", name: "Mail", glyph: "✉", badge: winState.mail.length },
902
+ { id: "discord", name: "Discord", glyph: "◍", badge: winState.discordMention ? 1 : 0 },
903
+ { id: "calendar", name: "Calendar", glyph: "◷" },
904
+ { id: "puck", name: "Puck", glyph: "❖", running: true, tint: "var(--accent)" },
905
+ ],
906
+ [winState],
907
+ );
908
+ const onDock = (id: string) => {
909
+ if (id === "puck") openCompanion();
910
+ else if (positions[id]) setFocused(id);
911
+ };
912
+
913
+ return (
914
+ <>
915
+ {!overlay && (
916
+ <>
917
+ <div className="wall-spores" />
918
+ <div className="vignette" />
919
+ <MenuBar
920
+ moodLabel={MOOD_LABEL[mood]}
921
+ puckActive={dropdown}
922
+ badge={badge}
923
+ onPuck={() => setDropdown((d) => !d)}
924
+ clock={clock}
925
+ />
926
+ <DesktopWindows
927
+ positions={positions}
928
+ onPos={(id, p) => setPositions((ps) => ({ ...ps, [id]: p }))}
929
+ onFocus={setFocused}
930
+ focused={focused}
931
+ attnId={attnId}
932
+ winState={winState}
933
+ />
934
+ <Dock apps={dockApps} onOpen={onDock} />
935
+ </>
936
+ )}
937
+
938
+ <Companion
939
+ open={companion}
940
+ pos={compPos}
941
+ onPos={setCompPos}
942
+ onClose={() => setCompanion(false)}
943
+ tab={compTab}
944
+ setTab={setCompTab}
945
+ memories={memories}
946
+ onMem={memAction}
947
+ fs={fs}
948
+ moodLabel={MOOD_LABEL[mood]}
949
+ pulseKey={pulse}
950
+ />
951
+
952
+ <PuckSprite
953
+ sprite={sprite}
954
+ form={settings.form}
955
+ speaking={speaking}
956
+ alert={!!bubble || !!interrupt || toasts.length > 0}
957
+ reaction={reaction?.kind ?? null}
958
+ shout={reaction?.shout}
959
+ reactKey={reactKey}
960
+ muted={muted}
961
+ camo={camo}
962
+ onPoke={openCompanion}
963
+ onMenu={() => setDropdown((d) => !d)}
964
+ />
965
+
966
+ {bubble && (
967
+ <Bubble data={bubble} bubbleStyle={settings.voice} onRate={rateBubble} onDismiss={dismissBubble} />
968
+ )}
969
+
970
+ <Toasts
971
+ items={toasts}
972
+ bubbleStyle={settings.voice}
973
+ onGo={goToast}
974
+ onSwat={swatToast}
975
+ offer={offer}
976
+ onAccept={acceptOffer}
977
+ onDecline={declineOffer}
978
+ />
979
+
980
+ <Interrupt data={interrupt} form={settings.form} onRate={rateInterrupt} />
981
+
982
+ <MenuDropdown
983
+ open={dropdown}
984
+ status={status}
985
+ mode={mode}
986
+ setMode={setMode}
987
+ muted={muted}
988
+ onMute={toggleMute}
989
+ onCompanion={openCompanion}
990
+ onPatrol={() => {
991
+ setDropdown(false);
992
+ fireEvent();
993
+ }}
994
+ onLook={lookAround}
995
+ onSleep={() => {
996
+ setDropdown(false);
997
+ beginSleep();
998
+ }}
999
+ onSettings={() => {
1000
+ setDropdown(false);
1001
+ setSettingsOpen(true);
1002
+ }}
1003
+ onClose={() => setDropdown(false)}
1004
+ />
1005
+
1006
+ {sleeping && <NightBloom result={sleeping} molt={molt} onWake={wake} />}
1007
+
1008
+ <SettingsPanel
1009
+ open={settingsOpen}
1010
+ pos={settingsPos}
1011
+ onPos={setSettingsPos}
1012
+ onClose={() => setSettingsOpen(false)}
1013
+ settings={settings}
1014
+ onChange={setSetting}
1015
+ overlay={overlay}
1016
+ onPatrol={() => fireEvent()}
1017
+ onSleep={beginSleep}
1018
+ />
1019
+ </>
1020
+ );
1021
+ }
frontend/src/styles/base.css ADDED
@@ -0,0 +1,601 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Puck — desktop familiar. Theme tokens + macOS chrome + sprite.
3
+ Three themes switch via data-theme on <html>.
4
+ Accent is overridable live via --accent (Tweaks).
5
+ ============================================================ */
6
+
7
+ @import url("https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400;1,6..72,500;1,6..72,600&family=JetBrains+Mono:wght@400;500;600&family=Caveat:wght@500;600&display=swap");
8
+
9
+ :root {
10
+ /* set by Tweaks; theme provides a fallback */
11
+ --accent: #e7b85c;
12
+ --accent-ink: #2a2010;
13
+ --ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
14
+ --voice: "Newsreader", Georgia, serif;
15
+ --mono: "JetBrains Mono", ui-monospace, monospace;
16
+ --hand: "Caveat", cursive;
17
+ --r: 12px;
18
+ }
19
+
20
+ /* ---------- Terrarium Night (default) ---------- */
21
+ :root[data-theme="terrarium"] {
22
+ --wall: radial-gradient(120% 90% at 70% -10%, #16241a 0%, #0c130e 45%, #060a07 100%);
23
+ --wall-spores: rgba(231, 184, 92, 0.05);
24
+ --ink: #f0e7d0;
25
+ --ink-soft: rgba(240, 231, 208, 0.62);
26
+ --ink-faint: rgba(240, 231, 208, 0.34);
27
+ --panel: rgba(20, 30, 23, 0.74);
28
+ --panel-2: rgba(28, 40, 31, 0.82);
29
+ --panel-solid: #131c16;
30
+ --border: rgba(231, 200, 150, 0.16);
31
+ --border-strong: rgba(231, 200, 150, 0.32);
32
+ --menubar: rgba(10, 16, 12, 0.55);
33
+ --win-bg: rgba(18, 26, 21, 0.92);
34
+ --win-head: rgba(30, 42, 33, 0.9);
35
+ --win-border: rgba(231, 200, 150, 0.14);
36
+ --shadow: 0 24px 70px rgba(0, 0, 0, 0.5);
37
+ --glow: rgba(231, 184, 92, 0.16);
38
+ --dot: rgba(231, 200, 150, 0.5);
39
+ color-scheme: dark;
40
+ }
41
+
42
+ /* ---------- Candlelight (cozy light) ---------- */
43
+ :root[data-theme="candlelight"] {
44
+ --wall: radial-gradient(120% 100% at 60% -10%, #f3ead7 0%, #e9dcc2 50%, #ddcba9 100%);
45
+ --wall-spores: rgba(120, 80, 30, 0.045);
46
+ --ink: #33291a;
47
+ --ink-soft: rgba(51, 41, 26, 0.66);
48
+ --ink-faint: rgba(51, 41, 26, 0.4);
49
+ --panel: rgba(255, 250, 240, 0.86);
50
+ --panel-2: rgba(252, 245, 232, 0.95);
51
+ --panel-solid: #fbf5ea;
52
+ --border: rgba(80, 60, 30, 0.16);
53
+ --border-strong: rgba(80, 60, 30, 0.3);
54
+ --menubar: rgba(255, 250, 240, 0.6);
55
+ --win-bg: rgba(255, 252, 246, 0.96);
56
+ --win-head: rgba(245, 237, 223, 0.95);
57
+ --win-border: rgba(80, 60, 30, 0.14);
58
+ --shadow: 0 24px 60px rgba(80, 55, 20, 0.22);
59
+ --glow: rgba(214, 142, 60, 0.2);
60
+ --dot: rgba(120, 90, 40, 0.45);
61
+ color-scheme: light;
62
+ }
63
+
64
+ /* ---------- Glass Nocturne (cool frosted dark) ---------- */
65
+ :root[data-theme="nocturne"] {
66
+ --wall: radial-gradient(120% 90% at 65% -10%, #1a2230 0%, #0e131c 50%, #080b11 100%);
67
+ --wall-spores: rgba(150, 190, 240, 0.05);
68
+ --ink: #e9eef6;
69
+ --ink-soft: rgba(233, 238, 246, 0.62);
70
+ --ink-faint: rgba(233, 238, 246, 0.34);
71
+ --panel: rgba(20, 27, 38, 0.66);
72
+ --panel-2: rgba(28, 37, 51, 0.78);
73
+ --panel-solid: #131a26;
74
+ --border: rgba(180, 205, 240, 0.16);
75
+ --border-strong: rgba(180, 205, 240, 0.32);
76
+ --menubar: rgba(12, 17, 25, 0.5);
77
+ --win-bg: rgba(18, 25, 36, 0.86);
78
+ --win-head: rgba(28, 38, 53, 0.86);
79
+ --win-border: rgba(180, 205, 240, 0.14);
80
+ --shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
81
+ --glow: rgba(120, 170, 240, 0.16);
82
+ --dot: rgba(180, 205, 240, 0.5);
83
+ color-scheme: dark;
84
+ }
85
+
86
+ * {
87
+ box-sizing: border-box;
88
+ }
89
+
90
+ html,
91
+ body {
92
+ margin: 0;
93
+ height: 100%;
94
+ overflow: hidden;
95
+ }
96
+
97
+ body {
98
+ font-family: var(--ui);
99
+ color: var(--ink);
100
+ background: var(--wall);
101
+ -webkit-font-smoothing: antialiased;
102
+ text-rendering: optimizeLegibility;
103
+ user-select: none;
104
+ }
105
+
106
+ #root {
107
+ height: 100vh;
108
+ width: 100vw;
109
+ }
110
+
111
+ /* faint drifting spore texture over the wallpaper */
112
+ .wall-spores {
113
+ position: fixed;
114
+ inset: 0;
115
+ pointer-events: none;
116
+ z-index: 0;
117
+ background-image: radial-gradient(circle, var(--wall-spores) 1px, transparent 1.4px);
118
+ background-size: 46px 46px;
119
+ opacity: 0.8;
120
+ animation: drift 60s linear infinite;
121
+ }
122
+ @keyframes drift {
123
+ to {
124
+ background-position: 460px 230px;
125
+ }
126
+ }
127
+
128
+ .vignette {
129
+ position: fixed;
130
+ inset: 0;
131
+ pointer-events: none;
132
+ z-index: 1;
133
+ box-shadow: inset 0 0 200px rgba(0, 0, 0, 0.35);
134
+ }
135
+ :root[data-theme="candlelight"] .vignette {
136
+ box-shadow: inset 0 0 220px rgba(90, 60, 20, 0.18);
137
+ }
138
+
139
+ /* ============================================================
140
+ macOS menu bar
141
+ ============================================================ */
142
+ .menubar {
143
+ position: fixed;
144
+ top: 0;
145
+ left: 0;
146
+ right: 0;
147
+ height: 26px;
148
+ z-index: 50;
149
+ display: flex;
150
+ align-items: center;
151
+ gap: 18px;
152
+ padding: 0 12px;
153
+ background: var(--menubar);
154
+ backdrop-filter: blur(22px) saturate(150%);
155
+ -webkit-backdrop-filter: blur(22px) saturate(150%);
156
+ border-bottom: 0.5px solid var(--border);
157
+ font-size: 13px;
158
+ }
159
+ .menubar .mb-apple {
160
+ font-size: 14px;
161
+ opacity: 0.85;
162
+ }
163
+ .menubar .mb-app {
164
+ font-weight: 600;
165
+ }
166
+ .menubar .mb-menu {
167
+ opacity: 0.8;
168
+ font-weight: 400;
169
+ }
170
+ .menubar .mb-right {
171
+ margin-left: auto;
172
+ display: flex;
173
+ align-items: center;
174
+ gap: 14px;
175
+ }
176
+ .menubar .mb-ico {
177
+ opacity: 0.78;
178
+ font-size: 12px;
179
+ display: flex;
180
+ align-items: center;
181
+ }
182
+ .menubar .mb-clock {
183
+ font-variant-numeric: tabular-nums;
184
+ font-size: 12.5px;
185
+ opacity: 0.9;
186
+ }
187
+
188
+ /* the Puck menu-bar status item */
189
+ .mb-puck {
190
+ display: flex;
191
+ align-items: center;
192
+ gap: 6px;
193
+ cursor: default;
194
+ padding: 2px 7px;
195
+ border-radius: 7px;
196
+ position: relative;
197
+ transition: background 0.15s;
198
+ }
199
+ .mb-puck:hover {
200
+ background: rgba(255, 255, 255, 0.1);
201
+ }
202
+ .mb-puck.active {
203
+ background: rgba(255, 255, 255, 0.14);
204
+ }
205
+ .mb-puck .mb-puck-dot {
206
+ width: 7px;
207
+ height: 7px;
208
+ border-radius: 50%;
209
+ background: var(--accent);
210
+ box-shadow: 0 0 8px var(--accent);
211
+ }
212
+ .mb-puck .mb-puck-mood {
213
+ font-size: 11.5px;
214
+ opacity: 0.75;
215
+ letter-spacing: 0.01em;
216
+ }
217
+ .mb-puck .badge {
218
+ position: absolute;
219
+ top: -2px;
220
+ right: -2px;
221
+ min-width: 15px;
222
+ height: 15px;
223
+ border-radius: 8px;
224
+ background: #ff5b54;
225
+ color: #fff;
226
+ font-size: 10px;
227
+ font-weight: 700;
228
+ display: flex;
229
+ align-items: center;
230
+ justify-content: center;
231
+ padding: 0 4px;
232
+ box-shadow: 0 0 0 1.5px var(--menubar);
233
+ }
234
+
235
+ /* ============================================================
236
+ App windows on the desktop
237
+ ============================================================ */
238
+ .win {
239
+ position: absolute;
240
+ z-index: 10;
241
+ background: var(--win-bg);
242
+ border: 0.5px solid var(--win-border);
243
+ border-radius: 11px;
244
+ box-shadow: var(--shadow);
245
+ backdrop-filter: blur(30px) saturate(140%);
246
+ -webkit-backdrop-filter: blur(30px) saturate(140%);
247
+ overflow: hidden;
248
+ display: flex;
249
+ flex-direction: column;
250
+ transition:
251
+ box-shadow 0.2s,
252
+ transform 0.2s;
253
+ }
254
+ .win.dimmed {
255
+ filter: saturate(0.7) brightness(0.82);
256
+ }
257
+ .win.attn {
258
+ box-shadow:
259
+ var(--shadow),
260
+ 0 0 0 1.5px var(--accent),
261
+ 0 0 38px var(--glow);
262
+ }
263
+ .win-head {
264
+ height: 36px;
265
+ flex: none;
266
+ display: flex;
267
+ align-items: center;
268
+ gap: 8px;
269
+ padding: 0 12px;
270
+ background: var(--win-head);
271
+ border-bottom: 0.5px solid var(--win-border);
272
+ cursor: grab;
273
+ }
274
+ .win-head:active {
275
+ cursor: grabbing;
276
+ }
277
+ .traffic {
278
+ display: flex;
279
+ gap: 8px;
280
+ }
281
+ .traffic i {
282
+ width: 12px;
283
+ height: 12px;
284
+ border-radius: 50%;
285
+ display: block;
286
+ }
287
+ .traffic .r {
288
+ background: #ff5f57;
289
+ }
290
+ .traffic .y {
291
+ background: #febc2e;
292
+ }
293
+ .traffic .g {
294
+ background: #28c840;
295
+ }
296
+ .win-title {
297
+ font-size: 12.5px;
298
+ font-weight: 600;
299
+ opacity: 0.85;
300
+ display: flex;
301
+ align-items: center;
302
+ gap: 7px;
303
+ }
304
+ .win-title .wt-ico {
305
+ font-size: 13px;
306
+ }
307
+ .win-sub {
308
+ margin-left: auto;
309
+ font-size: 11px;
310
+ opacity: 0.5;
311
+ font-family: var(--mono);
312
+ }
313
+ .win-body {
314
+ flex: 1;
315
+ min-height: 0;
316
+ overflow: hidden;
317
+ padding: 14px 16px;
318
+ }
319
+
320
+ /* — Claude Code window — */
321
+ .cc-line {
322
+ font-family: var(--mono);
323
+ font-size: 12px;
324
+ line-height: 1.7;
325
+ color: var(--ink-soft);
326
+ }
327
+ .cc-line .tok-fn {
328
+ color: var(--accent);
329
+ }
330
+ .cc-line .tok-dim {
331
+ opacity: 0.5;
332
+ }
333
+ .cc-task {
334
+ margin-top: 10px;
335
+ padding: 9px 11px;
336
+ border-radius: 9px;
337
+ background: var(--panel);
338
+ border: 0.5px solid var(--border);
339
+ display: flex;
340
+ align-items: center;
341
+ gap: 9px;
342
+ font-size: 12px;
343
+ }
344
+ .cc-task .spin {
345
+ width: 13px;
346
+ height: 13px;
347
+ border-radius: 50%;
348
+ border: 2px solid var(--border-strong);
349
+ border-top-color: var(--accent);
350
+ animation: spin 0.8s linear infinite;
351
+ }
352
+ .cc-task.done .spin {
353
+ border: none;
354
+ animation: none;
355
+ color: #46c96a;
356
+ font-size: 14px;
357
+ }
358
+ @keyframes spin {
359
+ to {
360
+ transform: rotate(360deg);
361
+ }
362
+ }
363
+ .cc-task .pct {
364
+ margin-left: auto;
365
+ font-family: var(--mono);
366
+ font-size: 11px;
367
+ opacity: 0.6;
368
+ }
369
+
370
+ /* — Terminal / build window — */
371
+ .term {
372
+ font-family: var(--mono);
373
+ font-size: 11.5px;
374
+ line-height: 1.65;
375
+ color: var(--ink-soft);
376
+ }
377
+ .term .ok {
378
+ color: #46c96a;
379
+ }
380
+ .term .err {
381
+ color: #ff6b6b;
382
+ }
383
+ .term .warn {
384
+ color: #f2c14e;
385
+ }
386
+ .term .prompt {
387
+ color: var(--accent);
388
+ }
389
+ .bar {
390
+ height: 6px;
391
+ border-radius: 4px;
392
+ background: var(--border);
393
+ overflow: hidden;
394
+ margin-top: 8px;
395
+ }
396
+ .bar i {
397
+ display: block;
398
+ height: 100%;
399
+ background: var(--accent);
400
+ transition: width 0.4s;
401
+ }
402
+
403
+ /* — Mail / Discord list windows — */
404
+ .list {
405
+ display: flex;
406
+ flex-direction: column;
407
+ gap: 2px;
408
+ margin: -6px -6px 0;
409
+ }
410
+ .list-row {
411
+ display: flex;
412
+ gap: 10px;
413
+ padding: 8px 8px;
414
+ border-radius: 8px;
415
+ align-items: flex-start;
416
+ }
417
+ .list-row.unread {
418
+ background: var(--panel);
419
+ }
420
+ .list-row .avatar {
421
+ width: 28px;
422
+ height: 28px;
423
+ border-radius: 50%;
424
+ flex: none;
425
+ display: flex;
426
+ align-items: center;
427
+ justify-content: center;
428
+ font-size: 12px;
429
+ font-weight: 700;
430
+ color: var(--accent-ink);
431
+ background: var(--accent);
432
+ }
433
+ .list-row .avatar.muted {
434
+ background: var(--border-strong);
435
+ color: var(--ink-soft);
436
+ }
437
+ .list-row .lr-main {
438
+ min-width: 0;
439
+ flex: 1;
440
+ }
441
+ .list-row .lr-top {
442
+ display: flex;
443
+ justify-content: space-between;
444
+ gap: 8px;
445
+ }
446
+ .list-row .lr-from {
447
+ font-size: 12.5px;
448
+ font-weight: 600;
449
+ white-space: nowrap;
450
+ overflow: hidden;
451
+ text-overflow: ellipsis;
452
+ }
453
+ .list-row .lr-time {
454
+ font-size: 10.5px;
455
+ opacity: 0.45;
456
+ white-space: nowrap;
457
+ font-family: var(--mono);
458
+ }
459
+ .list-row .lr-sub {
460
+ font-size: 11.5px;
461
+ opacity: 0.62;
462
+ white-space: nowrap;
463
+ overflow: hidden;
464
+ text-overflow: ellipsis;
465
+ }
466
+ .list-row .lr-dot {
467
+ width: 6px;
468
+ height: 6px;
469
+ border-radius: 50%;
470
+ background: var(--accent);
471
+ margin-top: 6px;
472
+ flex: none;
473
+ }
474
+ .list-row .lr-dot.hidden {
475
+ visibility: hidden;
476
+ }
477
+
478
+ /* — Calendar window — */
479
+ .cal-day {
480
+ font-size: 11px;
481
+ opacity: 0.5;
482
+ text-transform: uppercase;
483
+ letter-spacing: 0.08em;
484
+ }
485
+ .cal-ev {
486
+ margin-top: 7px;
487
+ padding: 8px 10px;
488
+ border-radius: 8px;
489
+ border-left: 3px solid var(--accent);
490
+ background: var(--panel);
491
+ font-size: 12px;
492
+ }
493
+ .cal-ev .ce-t {
494
+ font-weight: 600;
495
+ }
496
+ .cal-ev .ce-time {
497
+ opacity: 0.55;
498
+ font-size: 11px;
499
+ font-family: var(--mono);
500
+ }
501
+
502
+ /* ============================================================
503
+ Dock
504
+ ============================================================ */
505
+ .dock {
506
+ position: fixed;
507
+ left: 50%;
508
+ bottom: 8px;
509
+ transform: translateX(-50%);
510
+ z-index: 45;
511
+ display: flex;
512
+ align-items: flex-end;
513
+ gap: 10px;
514
+ padding: 8px 12px;
515
+ border-radius: 18px;
516
+ background: var(--panel);
517
+ border: 0.5px solid var(--border);
518
+ backdrop-filter: blur(26px) saturate(150%);
519
+ -webkit-backdrop-filter: blur(26px) saturate(150%);
520
+ box-shadow: var(--shadow);
521
+ }
522
+ .dock-ico {
523
+ width: 42px;
524
+ height: 42px;
525
+ border-radius: 11px;
526
+ cursor: default;
527
+ position: relative;
528
+ display: flex;
529
+ align-items: center;
530
+ justify-content: center;
531
+ font-size: 22px;
532
+ background: var(--panel-2);
533
+ border: 0.5px solid var(--border);
534
+ transition: transform 0.18s cubic-bezier(0.3, 0.8, 0.3, 1.4);
535
+ }
536
+ .dock-ico:hover {
537
+ transform: translateY(-9px) scale(1.12);
538
+ }
539
+ .dock-ico.run::after {
540
+ content: "";
541
+ position: absolute;
542
+ bottom: -5px;
543
+ left: 50%;
544
+ transform: translateX(-50%);
545
+ width: 4px;
546
+ height: 4px;
547
+ border-radius: 50%;
548
+ background: var(--ink-soft);
549
+ }
550
+ .dock-ico .dk-badge {
551
+ position: absolute;
552
+ top: -3px;
553
+ right: -3px;
554
+ min-width: 16px;
555
+ height: 16px;
556
+ border-radius: 8px;
557
+ background: #ff5b54;
558
+ color: #fff;
559
+ font-size: 10px;
560
+ font-weight: 700;
561
+ display: flex;
562
+ align-items: center;
563
+ justify-content: center;
564
+ padding: 0 4px;
565
+ }
566
+
567
+ /* ============================================================
568
+ Generic floating panel (companion, dropdown)
569
+ ============================================================ */
570
+ .panel {
571
+ background: var(--panel-2);
572
+ border: 0.5px solid var(--border);
573
+ border-radius: var(--r);
574
+ backdrop-filter: blur(30px) saturate(150%);
575
+ -webkit-backdrop-filter: blur(30px) saturate(150%);
576
+ box-shadow: var(--shadow);
577
+ }
578
+
579
+ .sect-label {
580
+ font-size: 10px;
581
+ font-weight: 700;
582
+ letter-spacing: 0.09em;
583
+ text-transform: uppercase;
584
+ color: var(--ink-faint);
585
+ }
586
+
587
+ /* scrollbars inside panels */
588
+ .scroll {
589
+ overflow-y: auto;
590
+ scrollbar-width: thin;
591
+ scrollbar-color: var(--border-strong) transparent;
592
+ }
593
+ .scroll::-webkit-scrollbar {
594
+ width: 9px;
595
+ }
596
+ .scroll::-webkit-scrollbar-thumb {
597
+ background: var(--border-strong);
598
+ border-radius: 5px;
599
+ border: 3px solid transparent;
600
+ background-clip: content-box;
601
+ }
frontend/src/styles/ui.css ADDED
@@ -0,0 +1,2175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Puck — sprite, companion window, comments, notifications,
3
+ Night Bloom. Loaded after styles.css.
4
+ ============================================================ */
5
+
6
+ /* ============================================================
7
+ The sprite
8
+ ============================================================ */
9
+ .puck {
10
+ position: fixed;
11
+ z-index: 60;
12
+ left: 0;
13
+ top: 0;
14
+ width: 64px;
15
+ height: 64px;
16
+ transform: translate(-100px, -100px);
17
+ transition: transform 1.25s cubic-bezier(0.45, 0.05, 0.25, 1);
18
+ pointer-events: none;
19
+ will-change: transform;
20
+ }
21
+ .puck.snappy {
22
+ transition: transform 0.7s cubic-bezier(0.5, 0, 0.2, 1);
23
+ }
24
+ .puck-hit {
25
+ position: absolute;
26
+ inset: -6px;
27
+ pointer-events: auto;
28
+ cursor: pointer;
29
+ }
30
+
31
+ /* inner wrapper handles bob + facing so the flight transform stays clean */
32
+ .puck-bob {
33
+ width: 100%;
34
+ height: 100%;
35
+ animation: bob 3.1s ease-in-out infinite;
36
+ position: relative;
37
+ }
38
+ @keyframes bob {
39
+ 0%,
40
+ 100% {
41
+ transform: translateY(-3px);
42
+ }
43
+ 50% {
44
+ transform: translateY(3px);
45
+ }
46
+ }
47
+ .puck.flying .puck-bob {
48
+ animation-duration: 0.9s;
49
+ }
50
+ .puck-face {
51
+ width: 100%;
52
+ height: 100%;
53
+ position: relative;
54
+ transition: transform 0.4s;
55
+ }
56
+ .puck.face-left .puck-face {
57
+ transform: scaleX(-1);
58
+ }
59
+
60
+ /* glow halo */
61
+ .puck-glow {
62
+ position: absolute;
63
+ inset: -55%;
64
+ border-radius: 50%;
65
+ background: radial-gradient(circle, var(--puck-glow, var(--glow)) 0%, transparent 62%);
66
+ opacity: 1;
67
+ animation: pulse 3.4s ease-in-out infinite;
68
+ }
69
+ @keyframes pulse {
70
+ 0%,
71
+ 100% {
72
+ transform: scale(0.9);
73
+ opacity: 0.6;
74
+ }
75
+ 50% {
76
+ transform: scale(1.12);
77
+ opacity: 1;
78
+ }
79
+ }
80
+
81
+ /* body */
82
+ .puck-body {
83
+ position: absolute;
84
+ left: 50%;
85
+ top: 50%;
86
+ width: 38px;
87
+ height: 38px;
88
+ transform: translate(-50%, -50%);
89
+ border-radius: 50%;
90
+ background: radial-gradient(circle at 35% 30%, var(--puck-hi, #cdeccb), var(--puck-body, #8fd6a0) 70%);
91
+ box-shadow:
92
+ inset -3px -4px 8px rgba(0, 0, 0, 0.25),
93
+ 0 3px 14px rgba(0, 0, 0, 0.4);
94
+ }
95
+ .puck-eye {
96
+ position: absolute;
97
+ top: 40%;
98
+ width: 6px;
99
+ height: 8px;
100
+ border-radius: 50%;
101
+ background: #15201a;
102
+ }
103
+ .puck-eye.l {
104
+ left: 31%;
105
+ }
106
+ .puck-eye.r {
107
+ right: 31%;
108
+ }
109
+ .puck-eye::after {
110
+ content: "";
111
+ position: absolute;
112
+ top: 1px;
113
+ left: 1px;
114
+ width: 2.5px;
115
+ height: 2.5px;
116
+ border-radius: 50%;
117
+ background: rgba(255, 255, 255, 0.9);
118
+ }
119
+ .puck-cheek {
120
+ position: absolute;
121
+ top: 56%;
122
+ width: 5px;
123
+ height: 3px;
124
+ border-radius: 50%;
125
+ background: rgba(255, 140, 120, 0.45);
126
+ }
127
+ .puck-cheek.l {
128
+ left: 26%;
129
+ }
130
+ .puck-cheek.r {
131
+ right: 26%;
132
+ }
133
+
134
+ /* wings */
135
+ .puck-wing {
136
+ position: absolute;
137
+ top: 42%;
138
+ width: 26px;
139
+ height: 30px;
140
+ background: radial-gradient(
141
+ circle at 50% 30%,
142
+ rgba(255, 255, 255, 0.5),
143
+ var(--puck-wing, rgba(180, 230, 200, 0.32)) 70%,
144
+ transparent
145
+ );
146
+ border: 0.5px solid rgba(255, 255, 255, 0.4);
147
+ }
148
+ .puck-wing.l {
149
+ right: 56%;
150
+ border-radius: 60% 30% 50% 50%;
151
+ transform-origin: right center;
152
+ animation: flapL 0.34s ease-in-out infinite alternate;
153
+ }
154
+ .puck-wing.r {
155
+ left: 56%;
156
+ border-radius: 30% 60% 50% 50%;
157
+ transform-origin: left center;
158
+ animation: flapR 0.34s ease-in-out infinite alternate;
159
+ }
160
+ @keyframes flapL {
161
+ from {
162
+ transform: rotateY(20deg) rotate(8deg);
163
+ }
164
+ to {
165
+ transform: rotateY(60deg) rotate(-12deg);
166
+ }
167
+ }
168
+ @keyframes flapR {
169
+ from {
170
+ transform: rotateY(-20deg) rotate(-8deg);
171
+ }
172
+ to {
173
+ transform: rotateY(-60deg) rotate(12deg);
174
+ }
175
+ }
176
+ .puck.flying .puck-wing.l,
177
+ .puck.flying .puck-wing.r {
178
+ animation-duration: 0.18s;
179
+ }
180
+ .puck.resting .puck-wing.l,
181
+ .puck.resting .puck-wing.r {
182
+ animation-duration: 0.7s;
183
+ }
184
+
185
+ /* sparkle trail dot */
186
+ .puck-trail {
187
+ position: absolute;
188
+ left: 50%;
189
+ top: 50%;
190
+ width: 4px;
191
+ height: 4px;
192
+ border-radius: 50%;
193
+ background: var(--accent);
194
+ opacity: 0;
195
+ }
196
+ .puck.flying .puck-trail {
197
+ animation: trail 0.9s ease-out infinite;
198
+ }
199
+ @keyframes trail {
200
+ 0% {
201
+ opacity: 0.8;
202
+ transform: translate(-50%, -50%) scale(1);
203
+ }
204
+ 100% {
205
+ opacity: 0;
206
+ transform: translate(-50%, 14px) scale(0.2);
207
+ }
208
+ }
209
+
210
+ /* mood tints applied to :root */
211
+ .mood-curious {
212
+ --puck-body: #8fd6a0;
213
+ --puck-hi: #cdeccb;
214
+ --puck-wing: rgba(180, 230, 200, 0.34);
215
+ --puck-glow: rgba(150, 220, 170, 0.22);
216
+ }
217
+ .mood-mischief {
218
+ --puck-body: #c9a3ec;
219
+ --puck-hi: #ead7fa;
220
+ --puck-wing: rgba(210, 180, 240, 0.34);
221
+ --puck-glow: rgba(190, 150, 235, 0.24);
222
+ }
223
+ .mood-sleepy {
224
+ --puck-body: #7f93c4;
225
+ --puck-hi: #c3cdec;
226
+ --puck-wing: rgba(170, 185, 225, 0.3);
227
+ --puck-glow: rgba(150, 170, 225, 0.2);
228
+ }
229
+ .mood-proud {
230
+ --puck-body: #edc46a;
231
+ --puck-hi: #f7e6b3;
232
+ --puck-wing: rgba(240, 210, 150, 0.34);
233
+ --puck-glow: rgba(231, 184, 92, 0.26);
234
+ }
235
+ .mood-grumpy {
236
+ --puck-body: #d98a72;
237
+ --puck-hi: #f0bca8;
238
+ --puck-wing: rgba(225, 170, 150, 0.32);
239
+ --puck-glow: rgba(210, 130, 100, 0.22);
240
+ }
241
+
242
+ /* ============================================================
243
+ Speech bubble (Puck's comments anchored to the sprite)
244
+ ============================================================ */
245
+ .bubble {
246
+ position: fixed;
247
+ z-index: 61;
248
+ max-width: 290px;
249
+ pointer-events: auto;
250
+ padding: 12px 15px 13px;
251
+ border-radius: 15px;
252
+ background: var(--panel-2);
253
+ border: 0.5px solid var(--border-strong);
254
+ backdrop-filter: blur(26px) saturate(150%);
255
+ -webkit-backdrop-filter: blur(26px) saturate(150%);
256
+ box-shadow:
257
+ var(--shadow),
258
+ 0 0 28px var(--glow);
259
+ transform-origin: var(--bub-org, bottom left);
260
+ animation: bubpop 0.4s cubic-bezier(0.2, 1.3, 0.4, 1);
261
+ }
262
+ @keyframes bubpop {
263
+ from {
264
+ opacity: 0;
265
+ transform: scale(0.7) translateY(8px);
266
+ }
267
+ to {
268
+ opacity: 1;
269
+ transform: scale(1) translateY(0);
270
+ }
271
+ }
272
+ .bubble.out {
273
+ animation: bubout 0.3s ease forwards;
274
+ }
275
+ @keyframes bubout {
276
+ to {
277
+ opacity: 0;
278
+ transform: scale(0.85) translateY(6px);
279
+ }
280
+ }
281
+ .bubble .bub-src {
282
+ font-size: 10px;
283
+ font-weight: 700;
284
+ letter-spacing: 0.07em;
285
+ text-transform: uppercase;
286
+ color: var(--accent);
287
+ margin-bottom: 5px;
288
+ display: flex;
289
+ align-items: center;
290
+ gap: 6px;
291
+ }
292
+ .bubble .bub-src .bub-tier {
293
+ color: var(--ink-faint);
294
+ font-weight: 600;
295
+ }
296
+ .bubble .bub-text {
297
+ font-family: var(--voice);
298
+ font-style: italic;
299
+ font-size: 16px;
300
+ line-height: 1.4;
301
+ color: var(--ink);
302
+ }
303
+ .bubble.style-plain .bub-text {
304
+ font-family: var(--ui);
305
+ font-style: normal;
306
+ font-size: 14px;
307
+ }
308
+ .bubble.style-hand .bub-text {
309
+ font-family: var(--hand);
310
+ font-style: normal;
311
+ font-size: 20px;
312
+ }
313
+ .bubble .bub-tail {
314
+ position: absolute;
315
+ width: 14px;
316
+ height: 14px;
317
+ background: inherit;
318
+ border-left: 0.5px solid var(--border-strong);
319
+ border-bottom: 0.5px solid var(--border-strong);
320
+ transform: rotate(45deg);
321
+ }
322
+ .bubble .bub-acts {
323
+ display: flex;
324
+ gap: 6px;
325
+ margin-top: 10px;
326
+ }
327
+ .bub-rate {
328
+ flex: 1;
329
+ appearance: none;
330
+ border: 0.5px solid var(--border);
331
+ background: var(--panel);
332
+ color: var(--ink-soft);
333
+ font-family: var(--ui);
334
+ font-size: 11px;
335
+ padding: 5px 4px;
336
+ border-radius: 8px;
337
+ cursor: pointer;
338
+ transition: all 0.13s;
339
+ display: flex;
340
+ align-items: center;
341
+ justify-content: center;
342
+ gap: 3px;
343
+ }
344
+ .bub-rate:hover {
345
+ background: var(--accent);
346
+ color: var(--accent-ink);
347
+ border-color: transparent;
348
+ transform: translateY(-1px);
349
+ }
350
+
351
+ /* ============================================================
352
+ Companion window — the "note-taking" feed surface
353
+ ============================================================ */
354
+ .comp {
355
+ position: fixed;
356
+ z-index: 40;
357
+ width: 372px;
358
+ display: flex;
359
+ flex-direction: column;
360
+ max-height: calc(100vh - 120px);
361
+ }
362
+ .comp-head {
363
+ display: flex;
364
+ align-items: center;
365
+ gap: 10px;
366
+ padding: 13px 14px 11px;
367
+ cursor: grab;
368
+ }
369
+ .comp-head:active {
370
+ cursor: grabbing;
371
+ }
372
+ .comp-spr {
373
+ width: 34px;
374
+ height: 34px;
375
+ border-radius: 50%;
376
+ flex: none;
377
+ position: relative;
378
+ background: radial-gradient(circle at 35% 30%, var(--puck-hi, #cdeccb), var(--puck-body, #8fd6a0) 70%);
379
+ box-shadow: inset -2px -3px 6px rgba(0, 0, 0, 0.25);
380
+ }
381
+ .comp-spr .e {
382
+ position: absolute;
383
+ top: 13px;
384
+ width: 4px;
385
+ height: 6px;
386
+ border-radius: 50%;
387
+ background: #15201a;
388
+ }
389
+ .comp-spr .e.l {
390
+ left: 11px;
391
+ }
392
+ .comp-spr .e.r {
393
+ right: 11px;
394
+ }
395
+ .comp-id {
396
+ line-height: 1.15;
397
+ }
398
+ .comp-id b {
399
+ font-size: 14px;
400
+ }
401
+ .comp-id span {
402
+ font-size: 11px;
403
+ color: var(--ink-soft);
404
+ display: block;
405
+ font-family: var(--voice);
406
+ font-style: italic;
407
+ }
408
+ .comp-head .comp-x {
409
+ margin-left: auto;
410
+ appearance: none;
411
+ border: 0;
412
+ background: transparent;
413
+ color: var(--ink-faint);
414
+ font-size: 14px;
415
+ cursor: pointer;
416
+ width: 24px;
417
+ height: 24px;
418
+ border-radius: 7px;
419
+ }
420
+ .comp-head .comp-x:hover {
421
+ background: var(--panel);
422
+ color: var(--ink);
423
+ }
424
+
425
+ .comp-tabs {
426
+ display: flex;
427
+ gap: 2px;
428
+ padding: 0 12px;
429
+ border-bottom: 0.5px solid var(--border);
430
+ }
431
+ .comp-tab {
432
+ appearance: none;
433
+ border: 0;
434
+ background: transparent;
435
+ color: var(--ink-soft);
436
+ font-family: var(--ui);
437
+ font-size: 12.5px;
438
+ font-weight: 600;
439
+ padding: 9px 11px;
440
+ cursor: pointer;
441
+ border-bottom: 2px solid transparent;
442
+ margin-bottom: -0.5px;
443
+ display: flex;
444
+ align-items: center;
445
+ gap: 6px;
446
+ }
447
+ .comp-tab:hover {
448
+ color: var(--ink);
449
+ }
450
+ .comp-tab.on {
451
+ color: var(--ink);
452
+ border-bottom-color: var(--accent);
453
+ }
454
+ .comp-tab .tcount {
455
+ font-size: 10px;
456
+ background: var(--panel);
457
+ padding: 1px 6px;
458
+ border-radius: 9px;
459
+ font-weight: 700;
460
+ }
461
+
462
+ .comp-body {
463
+ flex: 1;
464
+ min-height: 0;
465
+ overflow-y: auto;
466
+ padding: 12px 14px;
467
+ }
468
+
469
+ /* feed items */
470
+ .feed-day {
471
+ font-size: 10px;
472
+ font-weight: 700;
473
+ letter-spacing: 0.08em;
474
+ text-transform: uppercase;
475
+ color: var(--ink-faint);
476
+ margin: 4px 0 10px;
477
+ }
478
+ .feed-item {
479
+ display: flex;
480
+ gap: 11px;
481
+ padding-bottom: 16px;
482
+ position: relative;
483
+ }
484
+ .feed-item::before {
485
+ content: "";
486
+ position: absolute;
487
+ left: 6px;
488
+ top: 16px;
489
+ bottom: -2px;
490
+ width: 1.5px;
491
+ background: var(--border);
492
+ }
493
+ .feed-item:last-child::before {
494
+ display: none;
495
+ }
496
+ .feed-dot {
497
+ width: 13px;
498
+ height: 13px;
499
+ border-radius: 50%;
500
+ flex: none;
501
+ margin-top: 2px;
502
+ background: var(--panel);
503
+ border: 2px solid var(--border-strong);
504
+ z-index: 1;
505
+ }
506
+ .feed-dot.notify {
507
+ background: var(--accent);
508
+ border-color: var(--accent);
509
+ box-shadow: 0 0 10px var(--glow);
510
+ }
511
+ .feed-dot.interrupt {
512
+ background: #ff5b54;
513
+ border-color: #ff5b54;
514
+ }
515
+ .feed-dot.ignore {
516
+ background: transparent;
517
+ }
518
+ .feed-main {
519
+ flex: 1;
520
+ min-width: 0;
521
+ }
522
+ .feed-meta {
523
+ display: flex;
524
+ align-items: baseline;
525
+ gap: 8px;
526
+ margin-bottom: 2px;
527
+ }
528
+ .feed-src {
529
+ font-size: 10.5px;
530
+ font-weight: 700;
531
+ letter-spacing: 0.05em;
532
+ text-transform: uppercase;
533
+ color: var(--ink-soft);
534
+ }
535
+ .feed-time {
536
+ font-size: 10px;
537
+ color: var(--ink-faint);
538
+ font-family: var(--mono);
539
+ margin-left: auto;
540
+ }
541
+ .feed-say {
542
+ font-family: var(--voice);
543
+ font-style: italic;
544
+ font-size: 14.5px;
545
+ line-height: 1.42;
546
+ color: var(--ink);
547
+ }
548
+ .feed-say.plain {
549
+ font-family: var(--ui);
550
+ font-style: normal;
551
+ font-size: 13px;
552
+ }
553
+ .feed-decision {
554
+ font-size: 10.5px;
555
+ color: var(--ink-faint);
556
+ margin-top: 4px;
557
+ font-family: var(--mono);
558
+ }
559
+ .feed-rated {
560
+ display: inline-flex;
561
+ align-items: center;
562
+ gap: 4px;
563
+ font-size: 10.5px;
564
+ margin-top: 5px;
565
+ padding: 2px 8px;
566
+ border-radius: 9px;
567
+ background: var(--panel);
568
+ color: var(--ink-soft);
569
+ }
570
+
571
+ /* chat */
572
+ .chat-log {
573
+ display: flex;
574
+ flex-direction: column;
575
+ gap: 11px;
576
+ }
577
+ .chat-msg {
578
+ max-width: 86%;
579
+ padding: 9px 13px;
580
+ border-radius: 14px;
581
+ font-size: 13.5px;
582
+ line-height: 1.4;
583
+ }
584
+ .chat-msg.user {
585
+ align-self: flex-end;
586
+ background: var(--accent);
587
+ color: var(--accent-ink);
588
+ border-bottom-right-radius: 5px;
589
+ }
590
+ .chat-msg.puck {
591
+ align-self: flex-start;
592
+ background: var(--panel);
593
+ border: 0.5px solid var(--border);
594
+ font-family: var(--voice);
595
+ font-style: italic;
596
+ font-size: 14.5px;
597
+ border-bottom-left-radius: 5px;
598
+ }
599
+ .chat-input {
600
+ display: flex;
601
+ gap: 8px;
602
+ padding: 11px 14px;
603
+ border-top: 0.5px solid var(--border);
604
+ }
605
+ .chat-input input {
606
+ flex: 1;
607
+ background: var(--panel);
608
+ border: 0.5px solid var(--border);
609
+ border-radius: 10px;
610
+ padding: 9px 12px;
611
+ color: var(--ink);
612
+ font-family: var(--ui);
613
+ font-size: 13px;
614
+ outline: none;
615
+ }
616
+ .chat-input input:focus {
617
+ border-color: var(--accent);
618
+ }
619
+ .chat-input button {
620
+ appearance: none;
621
+ border: 0;
622
+ background: var(--accent);
623
+ color: var(--accent-ink);
624
+ border-radius: 10px;
625
+ padding: 0 14px;
626
+ font-weight: 600;
627
+ cursor: pointer;
628
+ }
629
+
630
+ /* memory garden */
631
+ .garden {
632
+ display: flex;
633
+ flex-direction: column;
634
+ gap: 9px;
635
+ }
636
+ .mem {
637
+ display: flex;
638
+ gap: 11px;
639
+ padding: 11px 12px;
640
+ border-radius: 11px;
641
+ background: var(--panel);
642
+ border: 0.5px solid var(--border);
643
+ align-items: flex-start;
644
+ transition:
645
+ transform 0.15s,
646
+ box-shadow 0.15s;
647
+ }
648
+ .mem.pinned {
649
+ border-color: var(--accent);
650
+ box-shadow:
651
+ 0 0 0 0.5px var(--accent),
652
+ 0 0 18px var(--glow);
653
+ }
654
+ .mem .mem-ico {
655
+ font-size: 19px;
656
+ flex: none;
657
+ line-height: 1.2;
658
+ }
659
+ .mem .mem-main {
660
+ flex: 1;
661
+ min-width: 0;
662
+ }
663
+ .mem .mem-text {
664
+ font-size: 13px;
665
+ line-height: 1.4;
666
+ }
667
+ .mem .mem-foot {
668
+ display: flex;
669
+ align-items: center;
670
+ gap: 8px;
671
+ margin-top: 7px;
672
+ }
673
+ .mem .mem-type {
674
+ font-size: 9.5px;
675
+ font-weight: 700;
676
+ letter-spacing: 0.06em;
677
+ text-transform: uppercase;
678
+ color: var(--ink-faint);
679
+ }
680
+ .mem .mem-salience {
681
+ flex: 1;
682
+ height: 4px;
683
+ border-radius: 3px;
684
+ background: var(--border);
685
+ overflow: hidden;
686
+ max-width: 80px;
687
+ }
688
+ .mem .mem-salience i {
689
+ display: block;
690
+ height: 100%;
691
+ background: var(--accent);
692
+ transition: width 0.4s;
693
+ }
694
+ .mem .mem-acts {
695
+ display: flex;
696
+ gap: 4px;
697
+ }
698
+ .mem-btn {
699
+ appearance: none;
700
+ width: 24px;
701
+ height: 24px;
702
+ border-radius: 7px;
703
+ border: 0.5px solid var(--border);
704
+ background: transparent;
705
+ cursor: pointer;
706
+ font-size: 12px;
707
+ opacity: 0.6;
708
+ transition: all 0.13s;
709
+ }
710
+ .mem-btn:hover {
711
+ opacity: 1;
712
+ background: var(--panel-2);
713
+ transform: translateY(-1px);
714
+ }
715
+ .mem-btn.on {
716
+ opacity: 1;
717
+ background: var(--accent);
718
+ border-color: transparent;
719
+ }
720
+
721
+ .empty {
722
+ text-align: center;
723
+ color: var(--ink-faint);
724
+ font-family: var(--voice);
725
+ font-style: italic;
726
+ font-size: 14px;
727
+ padding: 30px 16px;
728
+ line-height: 1.5;
729
+ }
730
+
731
+ /* fairy state strip (companion footer) */
732
+ .state-strip {
733
+ padding: 11px 14px;
734
+ border-top: 0.5px solid var(--border);
735
+ display: grid;
736
+ grid-template-columns: 1fr 1fr;
737
+ gap: 9px 16px;
738
+ }
739
+ .stat-top {
740
+ display: flex;
741
+ justify-content: space-between;
742
+ font-size: 10.5px;
743
+ margin-bottom: 4px;
744
+ }
745
+ .stat-top .sl {
746
+ color: var(--ink-soft);
747
+ font-weight: 600;
748
+ }
749
+ .stat-top .sv {
750
+ color: var(--ink-faint);
751
+ font-variant-numeric: tabular-nums;
752
+ }
753
+ .stat-bar {
754
+ height: 5px;
755
+ border-radius: 3px;
756
+ background: var(--border);
757
+ overflow: hidden;
758
+ }
759
+ .stat-bar i {
760
+ display: block;
761
+ height: 100%;
762
+ background: var(--accent);
763
+ transition: width 0.6s cubic-bezier(0.3, 0.8, 0.3, 1);
764
+ border-radius: 3px;
765
+ }
766
+ .stat-bar i.delta-up {
767
+ animation: glowup 1.2s ease;
768
+ }
769
+ @keyframes glowup {
770
+ 0%,
771
+ 100% {
772
+ box-shadow: none;
773
+ }
774
+ 40% {
775
+ box-shadow: 0 0 12px var(--accent);
776
+ }
777
+ }
778
+
779
+ /* ============================================================
780
+ Menu-bar dropdown
781
+ ============================================================ */
782
+ .drop {
783
+ position: fixed;
784
+ top: 30px;
785
+ z-index: 70;
786
+ width: 280px;
787
+ padding: 8px;
788
+ }
789
+ .drop-row {
790
+ display: flex;
791
+ align-items: center;
792
+ gap: 10px;
793
+ padding: 8px 10px;
794
+ border-radius: 8px;
795
+ font-size: 13px;
796
+ cursor: pointer;
797
+ }
798
+ .drop-row:hover {
799
+ background: var(--accent);
800
+ color: var(--accent-ink);
801
+ }
802
+ .drop-row .dr-ico {
803
+ width: 18px;
804
+ text-align: center;
805
+ opacity: 0.8;
806
+ }
807
+ .drop-row .dr-k {
808
+ margin-left: auto;
809
+ font-size: 11px;
810
+ opacity: 0.5;
811
+ font-family: var(--mono);
812
+ }
813
+ .drop-sep {
814
+ height: 0.5px;
815
+ background: var(--border);
816
+ margin: 6px 4px;
817
+ }
818
+ .drop-status {
819
+ padding: 9px 11px 11px;
820
+ }
821
+ .drop-status .ds-line {
822
+ font-family: var(--voice);
823
+ font-style: italic;
824
+ font-size: 14px;
825
+ line-height: 1.4;
826
+ color: var(--ink);
827
+ }
828
+ .drop-modes {
829
+ display: flex;
830
+ gap: 5px;
831
+ margin-top: 10px;
832
+ }
833
+ .mode-pill {
834
+ flex: 1;
835
+ text-align: center;
836
+ font-size: 10.5px;
837
+ font-weight: 600;
838
+ padding: 6px 4px;
839
+ border-radius: 8px;
840
+ background: var(--panel);
841
+ border: 0.5px solid var(--border);
842
+ cursor: pointer;
843
+ color: var(--ink-soft);
844
+ }
845
+ .mode-pill.on {
846
+ background: var(--accent);
847
+ color: var(--accent-ink);
848
+ border-color: transparent;
849
+ }
850
+
851
+ /* ============================================================
852
+ Notifications (toast / interrupt)
853
+ ============================================================ */
854
+ .toasts {
855
+ position: fixed;
856
+ top: 34px;
857
+ right: 12px;
858
+ z-index: 65;
859
+ display: flex;
860
+ flex-direction: column;
861
+ gap: 9px;
862
+ width: 330px;
863
+ }
864
+ .toast {
865
+ padding: 13px 14px;
866
+ border-radius: 14px;
867
+ background: var(--panel-2);
868
+ border: 0.5px solid var(--border);
869
+ backdrop-filter: blur(26px) saturate(150%);
870
+ -webkit-backdrop-filter: blur(26px) saturate(150%);
871
+ box-shadow: var(--shadow);
872
+ animation: toastin 0.45s cubic-bezier(0.2, 1.1, 0.4, 1);
873
+ }
874
+ @keyframes toastin {
875
+ from {
876
+ opacity: 0;
877
+ transform: translateX(40px);
878
+ }
879
+ to {
880
+ opacity: 1;
881
+ transform: translateX(0);
882
+ }
883
+ }
884
+ .toast.out {
885
+ animation: toastout 0.3s ease forwards;
886
+ }
887
+ @keyframes toastout {
888
+ to {
889
+ opacity: 0;
890
+ transform: translateX(40px);
891
+ }
892
+ }
893
+ .toast-top {
894
+ display: flex;
895
+ align-items: center;
896
+ gap: 8px;
897
+ margin-bottom: 6px;
898
+ }
899
+ .toast-top .tt-spr {
900
+ width: 22px;
901
+ height: 22px;
902
+ border-radius: 50%;
903
+ flex: none;
904
+ background: radial-gradient(circle at 35% 30%, var(--puck-hi, #cdeccb), var(--puck-body, #8fd6a0) 70%);
905
+ }
906
+ .toast-top .tt-name {
907
+ font-size: 12px;
908
+ font-weight: 700;
909
+ }
910
+ .toast-top .tt-time {
911
+ margin-left: auto;
912
+ font-size: 10.5px;
913
+ color: var(--ink-faint);
914
+ font-family: var(--mono);
915
+ }
916
+ .toast-top .tt-hint {
917
+ margin-left: auto;
918
+ font-size: 10px;
919
+ color: var(--ink-faint);
920
+ opacity: 0.7;
921
+ white-space: nowrap;
922
+ }
923
+ /* a toast is grab-and-fling: tap opens the app, throw swats it away */
924
+ .toast.throw {
925
+ cursor: grab;
926
+ user-select: none;
927
+ touch-action: none;
928
+ }
929
+ .toast.throw:active {
930
+ cursor: grabbing;
931
+ }
932
+ .toast-say {
933
+ font-family: var(--voice);
934
+ font-style: italic;
935
+ font-size: 14.5px;
936
+ line-height: 1.4;
937
+ }
938
+ .toast-say.plain {
939
+ font-family: var(--ui);
940
+ font-style: normal;
941
+ font-size: 13px;
942
+ }
943
+ .toast-acts {
944
+ display: flex;
945
+ gap: 6px;
946
+ margin-top: 10px;
947
+ }
948
+
949
+ /* full interrupt overlay */
950
+ .interrupt-wrap {
951
+ position: fixed;
952
+ inset: 0;
953
+ z-index: 80;
954
+ display: flex;
955
+ align-items: center;
956
+ justify-content: center;
957
+ background: rgba(0, 0, 0, 0.4);
958
+ backdrop-filter: blur(3px);
959
+ animation: fadein 0.3s;
960
+ }
961
+ @keyframes fadein {
962
+ from {
963
+ opacity: 0;
964
+ }
965
+ to {
966
+ opacity: 1;
967
+ }
968
+ }
969
+ .interrupt-card {
970
+ width: 380px;
971
+ padding: 22px;
972
+ text-align: center;
973
+ animation: bubpop 0.45s cubic-bezier(0.2, 1.3, 0.4, 1);
974
+ }
975
+ .interrupt-card .int-spr {
976
+ width: 56px;
977
+ height: 56px;
978
+ margin: 0 auto 14px;
979
+ position: relative;
980
+ background: radial-gradient(circle at 35% 30%, var(--puck-hi, #cdeccb), var(--puck-body, #8fd6a0) 70%);
981
+ border-radius: 50%;
982
+ box-shadow: 0 0 30px var(--glow);
983
+ }
984
+ .interrupt-card .int-say {
985
+ font-family: var(--voice);
986
+ font-style: italic;
987
+ font-size: 19px;
988
+ line-height: 1.4;
989
+ margin-bottom: 16px;
990
+ }
991
+ .interrupt-card .int-acts {
992
+ display: flex;
993
+ gap: 9px;
994
+ }
995
+
996
+ .btn {
997
+ appearance: none;
998
+ border: 0.5px solid var(--border);
999
+ background: var(--panel);
1000
+ color: var(--ink);
1001
+ font-family: var(--ui);
1002
+ font-size: 12.5px;
1003
+ font-weight: 600;
1004
+ padding: 8px 12px;
1005
+ border-radius: 9px;
1006
+ cursor: pointer;
1007
+ flex: 1;
1008
+ transition: all 0.13s;
1009
+ }
1010
+ .btn:hover {
1011
+ background: var(--panel-2);
1012
+ transform: translateY(-1px);
1013
+ }
1014
+ .btn.primary {
1015
+ background: var(--accent);
1016
+ color: var(--accent-ink);
1017
+ border-color: transparent;
1018
+ }
1019
+ .btn.primary:hover {
1020
+ filter: brightness(1.08);
1021
+ }
1022
+
1023
+ /* ============================================================
1024
+ Night Bloom (sleep / wake)
1025
+ ============================================================ */
1026
+ .bloom {
1027
+ position: fixed;
1028
+ inset: 0;
1029
+ z-index: 90;
1030
+ display: flex;
1031
+ align-items: center;
1032
+ justify-content: center;
1033
+ background: radial-gradient(circle at 50% 40%, #14202c, #060a0f 80%);
1034
+ animation: fadein 0.6s;
1035
+ overflow: hidden;
1036
+ }
1037
+ :root[data-theme="candlelight"] .bloom {
1038
+ background: radial-gradient(circle at 50% 40%, #2a2114, #140d06 80%);
1039
+ }
1040
+ .bloom-stars {
1041
+ position: absolute;
1042
+ inset: 0;
1043
+ background-image: radial-gradient(circle, rgba(255, 255, 255, 0.7) 0.6px, transparent 1px);
1044
+ background-size: 38px 52px;
1045
+ opacity: 0.18;
1046
+ animation: twinkle 5s ease-in-out infinite;
1047
+ }
1048
+ @keyframes twinkle {
1049
+ 0%,
1050
+ 100% {
1051
+ opacity: 0.1;
1052
+ }
1053
+ 50% {
1054
+ opacity: 0.25;
1055
+ }
1056
+ }
1057
+ .bloom-card {
1058
+ width: 460px;
1059
+ max-width: 90vw;
1060
+ text-align: center;
1061
+ position: relative;
1062
+ z-index: 1;
1063
+ color: #f0e7d0;
1064
+ }
1065
+ .bloom-spr {
1066
+ width: 70px;
1067
+ height: 70px;
1068
+ margin: 0 auto 20px;
1069
+ position: relative;
1070
+ border-radius: 50%;
1071
+ background: radial-gradient(circle at 35% 30%, #c3cdec, #7f93c4 70%);
1072
+ box-shadow: 0 0 50px rgba(150, 170, 225, 0.5);
1073
+ animation: bob 3.6s ease-in-out infinite;
1074
+ }
1075
+ .bloom-spr .ze {
1076
+ position: absolute;
1077
+ top: 28px;
1078
+ width: 8px;
1079
+ height: 3px;
1080
+ border-radius: 3px;
1081
+ background: #1a2436;
1082
+ }
1083
+ .bloom-spr .ze.l {
1084
+ left: 20px;
1085
+ }
1086
+ .bloom-spr .ze.r {
1087
+ right: 20px;
1088
+ }
1089
+ .bloom-stage {
1090
+ font-size: 11px;
1091
+ font-weight: 700;
1092
+ letter-spacing: 0.16em;
1093
+ text-transform: uppercase;
1094
+ color: rgba(240, 231, 208, 0.5);
1095
+ margin-bottom: 14px;
1096
+ min-height: 14px;
1097
+ }
1098
+ .bloom-title {
1099
+ font-family: var(--voice);
1100
+ font-style: italic;
1101
+ font-size: 26px;
1102
+ margin-bottom: 8px;
1103
+ }
1104
+ .bloom-text {
1105
+ font-size: 14px;
1106
+ line-height: 1.6;
1107
+ color: rgba(240, 231, 208, 0.78);
1108
+ min-height: 70px;
1109
+ }
1110
+ .bloom-dream {
1111
+ font-family: var(--voice);
1112
+ font-style: italic;
1113
+ font-size: 17px;
1114
+ line-height: 1.6;
1115
+ color: rgba(240, 231, 208, 0.9);
1116
+ padding: 0 10px;
1117
+ }
1118
+ .bloom-deltas {
1119
+ display: flex;
1120
+ flex-direction: column;
1121
+ gap: 8px;
1122
+ margin: 18px 0;
1123
+ text-align: left;
1124
+ }
1125
+ .bloom-delta {
1126
+ display: flex;
1127
+ align-items: center;
1128
+ gap: 10px;
1129
+ font-size: 13px;
1130
+ padding: 9px 13px;
1131
+ border-radius: 10px;
1132
+ background: rgba(255, 255, 255, 0.05);
1133
+ border: 0.5px solid rgba(255, 255, 255, 0.12);
1134
+ animation: deltain 0.5s ease backwards;
1135
+ }
1136
+ @keyframes deltain {
1137
+ from {
1138
+ opacity: 0;
1139
+ transform: translateY(8px);
1140
+ }
1141
+ to {
1142
+ opacity: 1;
1143
+ }
1144
+ }
1145
+ .bloom-delta .bd-dir {
1146
+ font-weight: 700;
1147
+ font-variant-numeric: tabular-nums;
1148
+ }
1149
+ .bloom-delta .bd-dir.up {
1150
+ color: #7fdc9a;
1151
+ }
1152
+ .bloom-delta .bd-dir.down {
1153
+ color: #e89a8a;
1154
+ }
1155
+ .bloom-delta .bd-label {
1156
+ flex: 1;
1157
+ }
1158
+ .bloom-mem {
1159
+ font-family: var(--voice);
1160
+ font-style: italic;
1161
+ }
1162
+ .bloom-prog {
1163
+ display: flex;
1164
+ gap: 5px;
1165
+ justify-content: center;
1166
+ margin-top: 20px;
1167
+ }
1168
+ .bloom-prog i {
1169
+ width: 6px;
1170
+ height: 6px;
1171
+ border-radius: 50%;
1172
+ background: rgba(240, 231, 208, 0.25);
1173
+ transition: all 0.3s;
1174
+ }
1175
+ .bloom-prog i.on {
1176
+ background: rgba(240, 231, 208, 0.85);
1177
+ transform: scale(1.3);
1178
+ }
1179
+ .bloom-btn {
1180
+ margin-top: 24px;
1181
+ appearance: none;
1182
+ border: 0.5px solid rgba(255, 255, 255, 0.25);
1183
+ background: rgba(255, 255, 255, 0.08);
1184
+ color: #f0e7d0;
1185
+ font-family: var(--ui);
1186
+ font-size: 13.5px;
1187
+ font-weight: 600;
1188
+ padding: 11px 26px;
1189
+ border-radius: 11px;
1190
+ cursor: pointer;
1191
+ transition: all 0.15s;
1192
+ }
1193
+ .bloom-btn:hover {
1194
+ background: rgba(255, 255, 255, 0.16);
1195
+ transform: translateY(-1px);
1196
+ }
1197
+
1198
+ /* sleeping-Z floaties */
1199
+ .zfly {
1200
+ position: absolute;
1201
+ font-family: var(--voice);
1202
+ font-style: italic;
1203
+ font-size: 22px;
1204
+ color: rgba(240, 231, 208, 0.5);
1205
+ animation: zfly 3.2s ease-out infinite;
1206
+ }
1207
+ @keyframes zfly {
1208
+ 0% {
1209
+ opacity: 0;
1210
+ transform: translateY(0) scale(0.6);
1211
+ }
1212
+ 30% {
1213
+ opacity: 0.7;
1214
+ }
1215
+ 100% {
1216
+ opacity: 0;
1217
+ transform: translateY(-60px) translateX(20px) scale(1.2);
1218
+ }
1219
+ }
1220
+
1221
+ /* ============================================================
1222
+ Speaking shimmer — sound rings when Puck talks aloud
1223
+ ============================================================ */
1224
+ .puck-voicewave {
1225
+ position: absolute;
1226
+ left: 50%;
1227
+ top: 50%;
1228
+ width: 0;
1229
+ height: 0;
1230
+ pointer-events: none;
1231
+ opacity: 0;
1232
+ }
1233
+ .puck.speaking .puck-voicewave {
1234
+ opacity: 1;
1235
+ }
1236
+ .puck.speaking .puck-voicewave i {
1237
+ position: absolute;
1238
+ left: 0;
1239
+ top: 0;
1240
+ border-radius: 50%;
1241
+ border: 1.5px solid var(--accent);
1242
+ transform: translate(-50%, -50%);
1243
+ animation: voicering 1.5s ease-out infinite;
1244
+ }
1245
+ .puck.speaking .puck-voicewave i:nth-child(2) {
1246
+ animation-delay: 0.5s;
1247
+ }
1248
+ .puck.speaking .puck-voicewave i:nth-child(3) {
1249
+ animation-delay: 1s;
1250
+ }
1251
+ @keyframes voicering {
1252
+ 0% {
1253
+ width: 30px;
1254
+ height: 30px;
1255
+ opacity: 0.7;
1256
+ }
1257
+ 100% {
1258
+ width: 86px;
1259
+ height: 86px;
1260
+ opacity: 0;
1261
+ }
1262
+ }
1263
+ .puck.speaking .puck-glow {
1264
+ animation-duration: 0.7s;
1265
+ }
1266
+
1267
+ /* ============================================================
1268
+ Automation offer toast — the "want me to handle it?" card
1269
+ ============================================================ */
1270
+ .toast.offer {
1271
+ border-color: var(--accent);
1272
+ box-shadow:
1273
+ var(--shadow),
1274
+ 0 0 28px var(--glow);
1275
+ }
1276
+ .toast.offer .tt-name {
1277
+ color: var(--accent);
1278
+ }
1279
+
1280
+ /* feed dots for learned / handled */
1281
+ .feed-dot.handled {
1282
+ background: var(--panel);
1283
+ border-color: var(--accent);
1284
+ position: relative;
1285
+ }
1286
+ .feed-dot.handled::after {
1287
+ content: "✓";
1288
+ position: absolute;
1289
+ inset: 0;
1290
+ display: flex;
1291
+ align-items: center;
1292
+ justify-content: center;
1293
+ font-size: 8px;
1294
+ color: var(--accent);
1295
+ font-weight: 700;
1296
+ }
1297
+ .feed-dot.learned {
1298
+ background: var(--accent);
1299
+ border-color: var(--accent);
1300
+ box-shadow: 0 0 12px var(--glow);
1301
+ }
1302
+
1303
+ /* ============================================================
1304
+ Learning panel
1305
+ ============================================================ */
1306
+ .learn-note {
1307
+ font-family: var(--voice);
1308
+ font-style: italic;
1309
+ font-size: 13.5px;
1310
+ line-height: 1.45;
1311
+ color: var(--ink-soft);
1312
+ padding: 2px 0 4px;
1313
+ }
1314
+ /* Memory tab — Puck's recent observations (peek log) */
1315
+ .musings {
1316
+ display: flex;
1317
+ flex-direction: column;
1318
+ margin-top: 6px;
1319
+ }
1320
+ .musing {
1321
+ display: flex;
1322
+ gap: 9px;
1323
+ padding: 9px 2px;
1324
+ border-bottom: 1px solid var(--line, rgba(255, 255, 255, 0.06));
1325
+ }
1326
+ .musing-dot {
1327
+ width: 6px;
1328
+ height: 6px;
1329
+ border-radius: 50%;
1330
+ background: var(--accent);
1331
+ margin-top: 6px;
1332
+ flex: none;
1333
+ opacity: 0.65;
1334
+ }
1335
+ .musing-main {
1336
+ flex: 1;
1337
+ min-width: 0;
1338
+ }
1339
+ .musing-text {
1340
+ font-family: var(--voice);
1341
+ font-style: italic;
1342
+ font-size: 13.5px;
1343
+ line-height: 1.4;
1344
+ }
1345
+ .musing-time {
1346
+ font-size: 10.5px;
1347
+ color: var(--ink-faint);
1348
+ font-family: var(--mono);
1349
+ margin-top: 3px;
1350
+ }
1351
+ /* a musing is clickable (→ label this peek) */
1352
+ button.musing {
1353
+ width: 100%;
1354
+ text-align: left;
1355
+ font: inherit;
1356
+ color: inherit;
1357
+ background: none;
1358
+ border: none;
1359
+ border-bottom: 1px solid var(--line, rgba(255, 255, 255, 0.06));
1360
+ cursor: pointer;
1361
+ }
1362
+ button.musing:hover {
1363
+ background: rgba(255, 255, 255, 0.04);
1364
+ }
1365
+ /* labeling view: screenshot + reuse-a-label chips + a new label */
1366
+ .label-shot {
1367
+ display: block;
1368
+ width: 100%;
1369
+ border-radius: 10px;
1370
+ border: 1px solid var(--line, rgba(255, 255, 255, 0.12));
1371
+ margin: 8px 0;
1372
+ }
1373
+ .label-quip {
1374
+ font-family: var(--voice);
1375
+ font-style: italic;
1376
+ font-size: 13px;
1377
+ color: var(--ink-soft);
1378
+ margin-bottom: 10px;
1379
+ }
1380
+ .label-pick {
1381
+ display: flex;
1382
+ flex-wrap: wrap;
1383
+ gap: 6px;
1384
+ margin-bottom: 10px;
1385
+ }
1386
+ .label-pick .ltag {
1387
+ cursor: pointer;
1388
+ border: none;
1389
+ }
1390
+ .label-new {
1391
+ display: flex;
1392
+ gap: 6px;
1393
+ }
1394
+ .label-new input {
1395
+ flex: 1;
1396
+ min-width: 0;
1397
+ font: inherit;
1398
+ font-size: 12px;
1399
+ color: var(--ink);
1400
+ background: var(--surface-2, rgba(0, 0, 0, 0.08));
1401
+ border: 1px solid var(--line, rgba(0, 0, 0, 0.12));
1402
+ border-radius: 8px;
1403
+ padding: 6px 8px;
1404
+ }
1405
+ .label-new button {
1406
+ font: inherit;
1407
+ font-size: 12px;
1408
+ cursor: pointer;
1409
+ background: var(--accent);
1410
+ color: var(--accent-ink, #fff7e8);
1411
+ border: none;
1412
+ border-radius: 8px;
1413
+ padding: 6px 12px;
1414
+ }
1415
+ .label-back {
1416
+ font: inherit;
1417
+ font-size: 12px;
1418
+ cursor: pointer;
1419
+ background: none;
1420
+ color: var(--ink-faint);
1421
+ border: none;
1422
+ margin-top: 12px;
1423
+ padding: 4px 0;
1424
+ }
1425
+ .label-saved {
1426
+ font-size: 13px;
1427
+ color: var(--accent);
1428
+ padding: 10px 0;
1429
+ }
1430
+ .learn-empty {
1431
+ font-size: 12.5px;
1432
+ color: var(--ink-faint);
1433
+ font-style: italic;
1434
+ padding: 2px 0;
1435
+ }
1436
+
1437
+ .learn-sources {
1438
+ display: flex;
1439
+ flex-direction: column;
1440
+ gap: 11px;
1441
+ }
1442
+ .lsrc {
1443
+ display: flex;
1444
+ gap: 11px;
1445
+ align-items: flex-start;
1446
+ }
1447
+ .lsrc-glyph {
1448
+ font-size: 16px;
1449
+ width: 22px;
1450
+ text-align: center;
1451
+ flex: none;
1452
+ opacity: 0.85;
1453
+ margin-top: 1px;
1454
+ }
1455
+ .lsrc-main {
1456
+ flex: 1;
1457
+ min-width: 0;
1458
+ }
1459
+ .lsrc-top {
1460
+ display: flex;
1461
+ justify-content: space-between;
1462
+ align-items: baseline;
1463
+ gap: 8px;
1464
+ margin-bottom: 5px;
1465
+ }
1466
+ .lsrc-label {
1467
+ font-size: 12.5px;
1468
+ font-weight: 600;
1469
+ }
1470
+ .lsrc-disp {
1471
+ font-size: 10.5px;
1472
+ font-weight: 700;
1473
+ letter-spacing: 0.02em;
1474
+ padding: 2px 8px;
1475
+ border-radius: 9px;
1476
+ background: var(--panel);
1477
+ white-space: nowrap;
1478
+ }
1479
+ .lsrc-disp.hi {
1480
+ color: var(--accent-ink);
1481
+ background: var(--accent);
1482
+ }
1483
+ .lsrc-disp.mid {
1484
+ color: var(--accent);
1485
+ }
1486
+ .lsrc-disp.lo {
1487
+ color: var(--ink-soft);
1488
+ }
1489
+ .lsrc-disp.off {
1490
+ color: var(--ink-faint);
1491
+ }
1492
+ .lsrc-track {
1493
+ height: 6px;
1494
+ border-radius: 4px;
1495
+ background: var(--border);
1496
+ overflow: hidden;
1497
+ position: relative;
1498
+ }
1499
+ .lsrc-track::before {
1500
+ content: "";
1501
+ position: absolute;
1502
+ left: 50%;
1503
+ top: 0;
1504
+ bottom: 0;
1505
+ width: 1px;
1506
+ background: var(--border-strong);
1507
+ z-index: 1;
1508
+ }
1509
+ .lsrc-fill {
1510
+ display: block;
1511
+ height: 100%;
1512
+ background: var(--accent);
1513
+ transition: width 0.5s cubic-bezier(0.3, 0.8, 0.3, 1);
1514
+ border-radius: 4px;
1515
+ }
1516
+ .lsrc-fill.off,
1517
+ .lsrc-fill.lo {
1518
+ background: var(--ink-faint);
1519
+ }
1520
+ .lsrc-conf {
1521
+ font-size: 10px;
1522
+ color: var(--ink-faint);
1523
+ font-family: var(--mono);
1524
+ margin-top: 4px;
1525
+ }
1526
+
1527
+ .learn-tags {
1528
+ display: flex;
1529
+ flex-wrap: wrap;
1530
+ gap: 7px;
1531
+ }
1532
+ .ltag {
1533
+ font-size: 12px;
1534
+ padding: 5px 11px;
1535
+ border-radius: 20px;
1536
+ background: var(--panel);
1537
+ border: 0.5px solid var(--border);
1538
+ color: var(--ink);
1539
+ white-space: nowrap;
1540
+ }
1541
+ .ltag.irk {
1542
+ border-color: rgba(230, 140, 110, 0.4);
1543
+ color: var(--ink-soft);
1544
+ }
1545
+
1546
+ .learn-autos {
1547
+ display: flex;
1548
+ flex-direction: column;
1549
+ gap: 8px;
1550
+ }
1551
+ .lauto {
1552
+ display: flex;
1553
+ gap: 11px;
1554
+ padding: 11px 12px;
1555
+ border-radius: 11px;
1556
+ align-items: flex-start;
1557
+ background: var(--panel);
1558
+ border: 0.5px solid var(--accent);
1559
+ box-shadow: 0 0 0 0.5px var(--accent);
1560
+ }
1561
+ .lauto-check {
1562
+ width: 20px;
1563
+ height: 20px;
1564
+ border-radius: 50%;
1565
+ background: var(--accent);
1566
+ color: var(--accent-ink);
1567
+ display: flex;
1568
+ align-items: center;
1569
+ justify-content: center;
1570
+ font-size: 11px;
1571
+ font-weight: 800;
1572
+ flex: none;
1573
+ margin-top: 1px;
1574
+ }
1575
+ .lauto-verb {
1576
+ font-size: 13px;
1577
+ font-weight: 600;
1578
+ }
1579
+ .lauto-done {
1580
+ font-size: 11px;
1581
+ color: var(--ink-soft);
1582
+ font-family: var(--mono);
1583
+ margin-top: 3px;
1584
+ }
1585
+
1586
+ /* ============================================================
1587
+ Settings panel (Tweaks reborn as product UI)
1588
+ ============================================================ */
1589
+ .settings {
1590
+ position: fixed;
1591
+ z-index: 75;
1592
+ width: 340px;
1593
+ display: flex;
1594
+ flex-direction: column;
1595
+ max-height: calc(100vh - 120px);
1596
+ }
1597
+ .set-body {
1598
+ display: flex;
1599
+ flex-direction: column;
1600
+ gap: 10px;
1601
+ }
1602
+ .set-body .sect-label {
1603
+ margin-top: 8px;
1604
+ }
1605
+ .set-body .sect-label:first-child {
1606
+ margin-top: 0;
1607
+ }
1608
+ .set-row {
1609
+ display: flex;
1610
+ align-items: center;
1611
+ gap: 10px;
1612
+ }
1613
+ .set-label {
1614
+ font-size: 12px;
1615
+ color: var(--ink-soft);
1616
+ width: 88px;
1617
+ flex: none;
1618
+ }
1619
+ .set-ctl {
1620
+ flex: 1;
1621
+ min-width: 0;
1622
+ }
1623
+ .set-pills {
1624
+ display: flex;
1625
+ gap: 5px;
1626
+ flex-wrap: wrap;
1627
+ }
1628
+ .set-pills .mode-pill {
1629
+ flex: 1 1 auto;
1630
+ appearance: none;
1631
+ font-family: var(--ui);
1632
+ }
1633
+ .set-swatches {
1634
+ display: flex;
1635
+ gap: 7px;
1636
+ }
1637
+ .set-swatch {
1638
+ appearance: none;
1639
+ width: 22px;
1640
+ height: 22px;
1641
+ border-radius: 50%;
1642
+ cursor: pointer;
1643
+ border: 2px solid transparent;
1644
+ transition:
1645
+ transform 0.13s,
1646
+ border-color 0.13s;
1647
+ }
1648
+ .set-swatch:hover {
1649
+ transform: scale(1.12);
1650
+ }
1651
+ .set-swatch.on {
1652
+ border-color: var(--ink);
1653
+ transform: scale(1.12);
1654
+ }
1655
+ .set-slider {
1656
+ display: flex;
1657
+ align-items: center;
1658
+ gap: 9px;
1659
+ }
1660
+ .set-slider input[type="range"] {
1661
+ flex: 1;
1662
+ accent-color: var(--accent);
1663
+ }
1664
+ .set-val {
1665
+ font-size: 11px;
1666
+ font-family: var(--mono);
1667
+ color: var(--ink-faint);
1668
+ width: 24px;
1669
+ text-align: right;
1670
+ }
1671
+ .set-actions {
1672
+ display: flex;
1673
+ gap: 8px;
1674
+ margin-top: 6px;
1675
+ }
1676
+
1677
+ /* menubar puck item is a real <button> now */
1678
+ .mb-puck {
1679
+ appearance: none;
1680
+ border: 0;
1681
+ background: transparent;
1682
+ color: inherit;
1683
+ font: inherit;
1684
+ }
1685
+
1686
+ /* ============================================================
1687
+ Overlay mode — transparent shell over the real desktop
1688
+ ============================================================ */
1689
+ html.overlay,
1690
+ html.overlay body {
1691
+ background: transparent !important;
1692
+ }
1693
+ html.overlay .puck-hit {
1694
+ cursor: pointer;
1695
+ }
1696
+ /* during a real-screen capture, blank Puck so his own sprite/bubbles don't land
1697
+ in the screenshot he's about to read (the overlay window is otherwise clear) */
1698
+ html.capturing #root {
1699
+ opacity: 0;
1700
+ transition: none;
1701
+ }
1702
+ /* Glassier comment surfaces over the real desktop — let it show through, but keep
1703
+ the blur so text stays legible (fully transparent would kill readability). */
1704
+ html.overlay .bubble,
1705
+ html.overlay .toast {
1706
+ background: color-mix(in srgb, var(--panel-2) 60%, transparent);
1707
+ backdrop-filter: blur(32px) saturate(160%);
1708
+ -webkit-backdrop-filter: blur(32px) saturate(160%);
1709
+ }
1710
+
1711
+ /* ============================================================
1712
+ Attention: alert ring when something's waiting + hover-grow.
1713
+ On a busy desktop a quiet bob is missable; the ring isn't.
1714
+ ============================================================ */
1715
+ .puck-alert-ring {
1716
+ position: absolute;
1717
+ left: 50%;
1718
+ top: 50%;
1719
+ width: 38px;
1720
+ height: 38px;
1721
+ margin: -19px 0 0 -19px;
1722
+ border-radius: 50%;
1723
+ border: 2px solid var(--accent);
1724
+ opacity: 0;
1725
+ pointer-events: none;
1726
+ }
1727
+ .puck.alert .puck-alert-ring {
1728
+ animation: alertring 1.4s ease-out infinite;
1729
+ }
1730
+ @keyframes alertring {
1731
+ 0% {
1732
+ opacity: 0.8;
1733
+ transform: scale(0.7);
1734
+ }
1735
+ 70% {
1736
+ opacity: 0;
1737
+ transform: scale(2.1);
1738
+ }
1739
+ 100% {
1740
+ opacity: 0;
1741
+ transform: scale(2.1);
1742
+ }
1743
+ }
1744
+ /* alert also quickens the bob and warms the glow so the whole sprite reads "!" */
1745
+ .puck.alert .puck-bob {
1746
+ animation-duration: 1.5s;
1747
+ }
1748
+ .puck.alert .puck-glow {
1749
+ animation-duration: 1.4s;
1750
+ }
1751
+
1752
+ /* hover-grow on the pokeable creature — tactile "I'm clickable" feedback */
1753
+ .puck-bob {
1754
+ transition: transform 0.18s cubic-bezier(0.3, 0.8, 0.3, 1.4);
1755
+ }
1756
+ .puck-hit:hover ~ .puck-bob,
1757
+ .puck-bob:has(.puck-hit:hover) {
1758
+ transform: scale(1.18);
1759
+ }
1760
+
1761
+ /* settings inline note (e.g. vision cost explainer) */
1762
+ .set-note {
1763
+ font-size: 11px;
1764
+ line-height: 1.4;
1765
+ color: var(--ink-faint);
1766
+ font-style: italic;
1767
+ padding: 2px 0 2px;
1768
+ }
1769
+
1770
+ /* voice picker — native select + audition button */
1771
+ .set-voicepick {
1772
+ display: flex;
1773
+ gap: 6px;
1774
+ align-items: center;
1775
+ }
1776
+ .set-select {
1777
+ flex: 1;
1778
+ min-width: 0;
1779
+ font: inherit;
1780
+ font-size: 12px;
1781
+ color: var(--ink);
1782
+ background: var(--surface-2, rgba(0, 0, 0, 0.08));
1783
+ border: 1px solid var(--line, rgba(0, 0, 0, 0.12));
1784
+ border-radius: 8px;
1785
+ padding: 5px 7px;
1786
+ }
1787
+
1788
+ /* molt gauge on the Night Bloom wake screen — real trace accumulation */
1789
+ .bloom-molt {
1790
+ margin: 14px auto 4px;
1791
+ max-width: 320px;
1792
+ text-align: left;
1793
+ }
1794
+ .bm-row {
1795
+ display: flex;
1796
+ justify-content: space-between;
1797
+ font-size: 12px;
1798
+ letter-spacing: 0.3px;
1799
+ color: rgba(255, 247, 224, 0.82);
1800
+ margin-bottom: 5px;
1801
+ }
1802
+ .bm-bar {
1803
+ height: 6px;
1804
+ border-radius: 99px;
1805
+ background: rgba(255, 255, 255, 0.12);
1806
+ overflow: hidden;
1807
+ }
1808
+ .bm-bar i {
1809
+ display: block;
1810
+ height: 100%;
1811
+ border-radius: 99px;
1812
+ background: linear-gradient(90deg, var(--accent), #fff7e0);
1813
+ transition: width 0.8s ease;
1814
+ }
1815
+ .bm-note {
1816
+ margin-top: 7px;
1817
+ font-size: 11px;
1818
+ line-height: 1.5;
1819
+ color: rgba(255, 247, 224, 0.6);
1820
+ font-style: italic;
1821
+ }
1822
+
1823
+ /* ============================================================
1824
+ Reactions — one-shot personality gestures. The react-* class
1825
+ lands on .puck; .puck-bob is keyed in React so the animation
1826
+ restarts even when the same reaction fires twice in a row.
1827
+ Each keyframe starts and ends at neutral so the resting bob
1828
+ resumes seamlessly when the class is removed. Durations mirror
1829
+ ReactionKind MS in engine/reactions.ts.
1830
+ ============================================================ */
1831
+ .puck[class*="react-"] .puck-bob {
1832
+ animation-iteration-count: 1;
1833
+ }
1834
+ .puck.react-celebrate .puck-bob {
1835
+ animation: react-celebrate 1.1s cubic-bezier(0.3, 0.7, 0.3, 1.3);
1836
+ }
1837
+ @keyframes react-celebrate {
1838
+ 0% {
1839
+ transform: translateY(0) scale(1) rotate(0);
1840
+ }
1841
+ 20% {
1842
+ transform: translateY(-15px) scale(1.18) rotate(-8deg);
1843
+ }
1844
+ 45% {
1845
+ transform: translateY(2px) scale(0.96) rotate(6deg);
1846
+ }
1847
+ 65% {
1848
+ transform: translateY(-9px) scale(1.08) rotate(-4deg);
1849
+ }
1850
+ 100% {
1851
+ transform: translateY(0) scale(1) rotate(0);
1852
+ }
1853
+ }
1854
+ .puck.react-nani .puck-bob {
1855
+ animation: react-nani 0.9s cubic-bezier(0.2, 1.5, 0.3, 1);
1856
+ }
1857
+ @keyframes react-nani {
1858
+ 0% {
1859
+ transform: scale(1);
1860
+ }
1861
+ 14% {
1862
+ transform: scale(1.03) translateX(-2px);
1863
+ } /* the freeze beat */
1864
+ 22% {
1865
+ transform: scale(1.46) translateY(-6px);
1866
+ } /* SNAP zoom */
1867
+ 34% {
1868
+ transform: scale(1.4) translateX(4px);
1869
+ }
1870
+ 46% {
1871
+ transform: scale(1.43) translateX(-4px);
1872
+ }
1873
+ 60% {
1874
+ transform: scale(1.41);
1875
+ }
1876
+ 100% {
1877
+ transform: scale(1);
1878
+ }
1879
+ }
1880
+ .puck.react-perk .puck-bob {
1881
+ animation: react-perk 0.75s ease;
1882
+ }
1883
+ @keyframes react-perk {
1884
+ 0% {
1885
+ transform: scale(1) translateY(0);
1886
+ }
1887
+ 40% {
1888
+ transform: scale(1.16) translateY(-7px);
1889
+ }
1890
+ 100% {
1891
+ transform: scale(1) translateY(0);
1892
+ }
1893
+ }
1894
+ .puck.react-shrug .puck-bob {
1895
+ animation: react-shrug 1s ease-in-out;
1896
+ }
1897
+ @keyframes react-shrug {
1898
+ 0%,
1899
+ 100% {
1900
+ transform: translateY(0) rotate(0) scale(1);
1901
+ }
1902
+ 25% {
1903
+ transform: translateY(3px) rotate(-7deg) scale(0.97);
1904
+ }
1905
+ 60% {
1906
+ transform: translateY(3px) rotate(7deg) scale(0.97);
1907
+ }
1908
+ }
1909
+ .puck.react-sulk .puck-bob {
1910
+ animation: react-sulk 1.05s ease;
1911
+ }
1912
+ @keyframes react-sulk {
1913
+ 0%,
1914
+ 100% {
1915
+ transform: translateY(0) scale(1);
1916
+ opacity: 1;
1917
+ }
1918
+ 35% {
1919
+ transform: translateY(9px) scale(0.85);
1920
+ opacity: 0.65;
1921
+ }
1922
+ }
1923
+ .puck.react-dance .puck-bob {
1924
+ animation: react-dance 1.5s ease-in-out;
1925
+ }
1926
+ @keyframes react-dance {
1927
+ 0%,
1928
+ 100% {
1929
+ transform: translateY(0) rotate(0);
1930
+ }
1931
+ 15% {
1932
+ transform: translateY(-6px) rotate(-14deg);
1933
+ }
1934
+ 35% {
1935
+ transform: translateY(0) rotate(12deg);
1936
+ }
1937
+ 55% {
1938
+ transform: translateY(-6px) rotate(-12deg);
1939
+ }
1940
+ 75% {
1941
+ transform: translateY(0) rotate(10deg);
1942
+ }
1943
+ }
1944
+ .puck.react-summon .puck-bob {
1945
+ animation: react-summon 1.6s cubic-bezier(0.34, 1.56, 0.5, 1);
1946
+ }
1947
+ @keyframes react-summon {
1948
+ 0% {
1949
+ transform: scale(1) translateY(0);
1950
+ }
1951
+ 18% {
1952
+ transform: scale(1.42) translateY(-12px) rotate(-6deg);
1953
+ } /* big size-up */
1954
+ 34% {
1955
+ transform: scale(1.34) translateY(2px) rotate(5deg);
1956
+ }
1957
+ 50% {
1958
+ transform: scale(1.42) translateY(-9px) rotate(-5deg);
1959
+ } /* insistent second hop */
1960
+ 66% {
1961
+ transform: scale(1.34) translateY(2px) rotate(4deg);
1962
+ }
1963
+ 82% {
1964
+ transform: scale(1.38) translateY(-5px) rotate(0deg);
1965
+ }
1966
+ 100% {
1967
+ transform: scale(1) translateY(0) rotate(0);
1968
+ }
1969
+ }
1970
+ .puck.react-pop .puck-bob {
1971
+ animation: react-pop 0.48s cubic-bezier(0.3, 0.8, 0.3, 1.4);
1972
+ }
1973
+ @keyframes react-pop {
1974
+ 0%,
1975
+ 100% {
1976
+ transform: scale(1);
1977
+ }
1978
+ 45% {
1979
+ transform: scale(1.16);
1980
+ }
1981
+ }
1982
+ /* laugh — giddy little bounces with a rocking wobble (found something funny) */
1983
+ .puck.react-laugh .puck-bob {
1984
+ animation: react-laugh 1.2s ease-in-out;
1985
+ }
1986
+ @keyframes react-laugh {
1987
+ 0%,
1988
+ 100% {
1989
+ transform: translateY(0) rotate(0) scale(1);
1990
+ }
1991
+ 15% {
1992
+ transform: translateY(-8px) rotate(-7deg) scale(1.06);
1993
+ }
1994
+ 30% {
1995
+ transform: translateY(1px) rotate(6deg) scale(0.98);
1996
+ }
1997
+ 45% {
1998
+ transform: translateY(-7px) rotate(-6deg) scale(1.05);
1999
+ }
2000
+ 60% {
2001
+ transform: translateY(1px) rotate(5deg) scale(0.98);
2002
+ }
2003
+ 75% {
2004
+ transform: translateY(-5px) rotate(-4deg) scale(1.03);
2005
+ }
2006
+ }
2007
+ /* sad — a slow droop, shrinking and dimming (something poignant or lonely) */
2008
+ .puck.react-sad .puck-bob {
2009
+ animation: react-sad 1.3s ease;
2010
+ }
2011
+ @keyframes react-sad {
2012
+ 0%,
2013
+ 100% {
2014
+ transform: translateY(0) scale(1);
2015
+ filter: none;
2016
+ }
2017
+ 35%,
2018
+ 70% {
2019
+ transform: translateY(7px) scale(0.9) rotate(-3deg);
2020
+ filter: brightness(0.68) saturate(0.6);
2021
+ }
2022
+ }
2023
+ /* fret — a startled recoil then a nervous side-to-side tremble (the human's upset) */
2024
+ .puck.react-fret .puck-bob {
2025
+ animation: react-fret 1.1s ease;
2026
+ }
2027
+ @keyframes react-fret {
2028
+ 0%,
2029
+ 100% {
2030
+ transform: translate(0, 0) scale(1);
2031
+ }
2032
+ 10% {
2033
+ transform: translate(0, -6px) scale(1.08);
2034
+ }
2035
+ 25% {
2036
+ transform: translateX(-4px) scale(1.04);
2037
+ }
2038
+ 37% {
2039
+ transform: translateX(4px) scale(1.04);
2040
+ }
2041
+ 49% {
2042
+ transform: translateX(-3px) scale(1.02);
2043
+ }
2044
+ 61% {
2045
+ transform: translateX(3px) scale(1.02);
2046
+ }
2047
+ 73% {
2048
+ transform: translateX(-2px);
2049
+ }
2050
+ 85% {
2051
+ transform: translateX(2px);
2052
+ }
2053
+ }
2054
+
2055
+ /* Emotion COLOR — the aura flushes a feeling-hue for the gesture's duration, then
2056
+ reverts to the slow mood tint (--puck-glow is overridden on .puck, which beats the
2057
+ mood-* ancestor and cascades to .puck-glow). Layered on top of the body's mood color
2058
+ so curiosity stays "his" color and only the strong feelings recolor him. */
2059
+ .puck.react-nani {
2060
+ --puck-glow: rgba(244, 244, 255, 0.62); /* white surprise flash */
2061
+ }
2062
+ .puck.react-laugh {
2063
+ --puck-glow: rgba(246, 212, 120, 0.5); /* warm giggle gold */
2064
+ }
2065
+ .puck.react-celebrate,
2066
+ .puck.react-dance {
2067
+ --puck-glow: rgba(255, 206, 105, 0.62); /* bright delight */
2068
+ }
2069
+ .puck.react-celebrate .puck-glow,
2070
+ .puck.react-dance .puck-glow {
2071
+ animation-duration: 0.6s; /* giddy fast pulse */
2072
+ }
2073
+ .puck.react-fret {
2074
+ --puck-glow: rgba(240, 92, 74, 0.58); /* alarm red */
2075
+ }
2076
+ .puck.react-fret .puck-glow {
2077
+ animation-duration: 0.45s; /* anxious flicker */
2078
+ }
2079
+ .puck.react-sad {
2080
+ --puck-glow: rgba(108, 148, 230, 0.42); /* cool, lonely blue */
2081
+ }
2082
+ .puck.react-sad .puck-glow {
2083
+ animation-duration: 5s; /* slow, heavy */
2084
+ }
2085
+
2086
+ /* muted — watching but silent: calm the glow, drop a small badge */
2087
+ .puck.muted .puck-glow {
2088
+ opacity: 0.35;
2089
+ animation-duration: 6s;
2090
+ }
2091
+ .puck-mute {
2092
+ position: absolute;
2093
+ right: -2px;
2094
+ top: -2px;
2095
+ font-size: 13px;
2096
+ line-height: 1;
2097
+ filter: grayscale(0.3);
2098
+ pointer-events: none;
2099
+ z-index: 4;
2100
+ }
2101
+
2102
+ /* camo — after sitting still, Puck cloaks into an active-camo patch (the per-rule
2103
+ comments below cover the visual; the gist: what's behind reads through sharp, with a
2104
+ shimmer + rim so he's a cloaked shape, not a hole). Fades in over ~1.2s; dropping the
2105
+ class is an instant "boo!" reveal. Trigger lives in SimApp's idle loop. */
2106
+ .puck.camo .puck-body {
2107
+ /* Active camo, not transparency: the fill goes clear so what's behind reads through
2108
+ SHARP (overlay: the real desktop through the transparent window; sim: the page).
2109
+ NO blur — blur is what made it unreadable. */
2110
+ background: transparent !important;
2111
+ /* the camo TELL — a non-blurring shimmer (brightness/contrast/hue keep text legible,
2112
+ unlike blur) so his shape registers as a cloaked patch, not a hole. The backdrop
2113
+ shimmer only renders in the sim (the overlay's transparent window has nothing to
2114
+ sample); the refractive rim below carries the tell in both habitats. */
2115
+ backdrop-filter: brightness(1.08) contrast(1.05) hue-rotate(10deg);
2116
+ -webkit-backdrop-filter: brightness(1.08) contrast(1.05) hue-rotate(10deg);
2117
+ box-shadow:
2118
+ inset 0 0 0 1px rgba(255, 255, 255, 0.13),
2119
+ 0 0 8px rgba(170, 215, 255, 0.16);
2120
+ transition:
2121
+ background 1.2s ease,
2122
+ backdrop-filter 1.2s ease,
2123
+ box-shadow 1.2s ease;
2124
+ }
2125
+ .puck.camo .puck-wing,
2126
+ .puck.camo .puck-cheek,
2127
+ .puck.camo .puck-trail,
2128
+ .puck.camo .puck-glow {
2129
+ opacity: 0.07;
2130
+ transition: opacity 1.2s ease;
2131
+ }
2132
+ .puck.camo .puck-eye {
2133
+ opacity: 0.34; /* a faint watching glimmer through the cloak */
2134
+ transition: opacity 1.2s ease;
2135
+ }
2136
+
2137
+ /* the shout — a quick chat-flash that floats up and fades over the sprite */
2138
+ .puck-shout {
2139
+ position: absolute;
2140
+ left: 50%;
2141
+ bottom: 100%;
2142
+ white-space: nowrap;
2143
+ font-weight: 800;
2144
+ font-size: 14px;
2145
+ letter-spacing: 0.2px;
2146
+ color: var(--accent-ink, #fff7e8);
2147
+ background: var(--accent);
2148
+ padding: 2px 9px;
2149
+ border-radius: 11px;
2150
+ box-shadow: 0 3px 10px rgba(0, 0, 0, 0.25);
2151
+ pointer-events: none;
2152
+ z-index: 3;
2153
+ animation: shout-pop 1s ease forwards;
2154
+ }
2155
+ @keyframes shout-pop {
2156
+ 0% {
2157
+ opacity: 0;
2158
+ transform: translate(-50%, 6px) scale(0.6);
2159
+ }
2160
+ 18% {
2161
+ opacity: 1;
2162
+ transform: translate(-50%, -6px) scale(1.1);
2163
+ }
2164
+ 35% {
2165
+ transform: translate(-50%, -8px) scale(1);
2166
+ }
2167
+ 80% {
2168
+ opacity: 1;
2169
+ transform: translate(-50%, -12px) scale(1);
2170
+ }
2171
+ 100% {
2172
+ opacity: 0;
2173
+ transform: translate(-50%, -22px) scale(0.95);
2174
+ }
2175
+ }