Spaces:
Running
Running
Commit ·
3c124f3
0
Parent(s):
Puck — desktop fairy familiar (HF Build Small)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +19 -0
- .env.example +21 -0
- .gitignore +29 -0
- BACKLOG.md +238 -0
- Dockerfile +45 -0
- README.md +58 -0
- frontend/.gitignore +24 -0
- frontend/README.md +73 -0
- frontend/biome.json +45 -0
- frontend/bun.lock +371 -0
- frontend/index.html +13 -0
- frontend/package.json +32 -0
- frontend/public/favicon.svg +1 -0
- frontend/public/icons.svg +24 -0
- frontend/scripts/export-deck.ts +13 -0
- frontend/src/engine/chat.ts +38 -0
- frontend/src/engine/engine.test.ts +338 -0
- frontend/src/engine/events.ts +260 -0
- frontend/src/engine/index.ts +22 -0
- frontend/src/engine/learning.ts +213 -0
- frontend/src/engine/nightBloom.ts +93 -0
- frontend/src/engine/reactions.test.ts +77 -0
- frontend/src/engine/reactions.ts +146 -0
- frontend/src/engine/scoring.ts +30 -0
- frontend/src/engine/speech.ts +56 -0
- frontend/src/engine/state.ts +76 -0
- frontend/src/engine/traces.test.ts +63 -0
- frontend/src/engine/traces.ts +54 -0
- frontend/src/engine/types.ts +174 -0
- frontend/src/engine/util.ts +18 -0
- frontend/src/engine/wire.test.ts +86 -0
- frontend/src/engine/wire.ts +96 -0
- frontend/src/lib/brain.ts +49 -0
- frontend/src/lib/daemon.ts +47 -0
- frontend/src/lib/debuglog.ts +39 -0
- frontend/src/lib/memories.ts +63 -0
- frontend/src/lib/molt.ts +24 -0
- frontend/src/lib/notify.ts +23 -0
- frontend/src/lib/settings.ts +70 -0
- frontend/src/lib/storage.ts +39 -0
- frontend/src/lib/tauri.ts +154 -0
- frontend/src/lib/traces.ts +31 -0
- frontend/src/lib/useDrag.ts +39 -0
- frontend/src/lib/useIdle.ts +29 -0
- frontend/src/lib/vision.ts +137 -0
- frontend/src/lib/voice.ts +421 -0
- frontend/src/main.tsx +22 -0
- frontend/src/modes/sim/SimApp.tsx +1021 -0
- frontend/src/styles/base.css +601 -0
- 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 |
+
}
|