docs(claude): add nested CLAUDE.md files

Init-deep pass: contextual guidance auto-loaded when Claude reads
files in those directories. Closest-CLAUDE.md-wins precedence.

  dashboard/   backend wiring (BOX-3 WS, voice-bridge REST, hints SSE)
  packages/    workspace scope, deps direction, NodeNext ESM rule
    +scenario-engine/  pure TS interpreter, no React/DOM
    +shared/           types contract, single js-yaml runtime dep
    +ui/               headless+tokens, promotion-bar policy
  hardware/    KiCad/PIO satellites, boundary with submodule master
This commit is contained in:
L'électron rare
2026-05-24 00:32:40 +02:00
parent db7149b897
commit ae9390c19c
6 changed files with 245 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
# Dashboard
Live game-master view : connection setup → expert/simple dashboards → real-time puzzle timeline + NPC / voice / hints panels. Sister app to `apps/atelier/`. Same React 19 + Vite 6 + zustand 5 stack, no Blockly / no R3F.
## Backend wiring
The dashboard talks to **three independent backends**, each through one hook + a typed contract in `@zacus/shared`. None of them touch each other ; the dashboard is the aggregator.
| Backend | Transport | Default URL | Hook | Endpoints used |
|---------|-----------|-------------|------|----------------|
| BOX-3 ESP32 master | **WebSocket** | `ws://zacus-box3.local:81` | `gameStore.connect()` | bidirectional event stream + command frames |
| voice-bridge (MacStudio) | **REST polling** | `http://100.116.92.12:8200` | `useVoiceBridge` (2 s) + `useVoiceUsage` (5 s) | `GET /health/ready`, `GET /tts/cache/stats`, `GET /usage/stats`, `POST /usage/reset` (X-Admin-Key) |
| hints engine | **SSE preferred, REST polling fallback** | `http://localhost:8311` | `useHintsEngine` | `GET /hints/sessions`, `GET /hints/events` (SSE), `DELETE /hints/sessions/{id}` |
All URLs are overridable via `VITE_*` env vars (`VITE_VOICE_BRIDGE_URL`, `VITE_HINTS_BASE_URL`, etc.) — the constants live in `packages/shared/src/constants.ts` and are the source of truth.
**Mock for offline dev** : `pnpm mock` boots `mock/ws-server.ts` on `:81` and replays a deterministic BOX-3 event sequence. Use it for any UI work that does not require a real ESP32 — the voice-bridge and hints backends do not have mocks ; point at the live MacStudio or run the Python services locally.
## Layout
```
src/
main.tsx # ReactDOM root
App.tsx # Routes ConnectionSetup ↔ ExpertDashboard / SimpleDashboard
components/
ConnectionSetup.tsx # Host / port form, persists in localStorage
ExpertDashboard.tsx # Full grid: Timeline + ControlPanel + all panels
SimpleDashboard.tsx # Stripped GM view for non-tech operators
Timeline.tsx # Puzzle progression + event log
ControlPanel.tsx # Manual triggers (skip, reset, force-hint)
PuzzleCard.tsx # Per-puzzle status tile
NpcPanel.tsx # Live NPC mood + last utterance
VoiceActivityPanel.tsx # Mic state, last STT transcript
VoiceUsagePanel.tsx # Cost-audit panel — uses useVoiceUsage hook
HintsAdaptivePanel.tsx # Hints engine state + adaptive level
hooks/
useVoiceBridge.ts # REST polling of voice-bridge :8200 health + cache
useVoiceUsage.ts # REST polling of voice-bridge /usage/* (cost audit)
useHintsEngine.ts # SSE+polling against the hints engine (auto-fallback)
store/
gameStore.ts # Zustand : BOX-3 WebSocket + mirrored game state
mock/
ws-server.ts # `pnpm mock` — fake BOX-3 WS on :81 for offline dev
```
## Patterns
- **One store** (`gameStore`) — owns the BOX-3 WebSocket, mirrors phase / puzzles / NPC / events. Auto-reconnects via `BOX3_WS_RECONNECT_MS`. The store keeps the last 200 events (`events.slice(-200)`). Don't split per-panel ; the dashboard is a coherent view of one game session.
- **Hooks own their endpoints**. Components consume hook output, never `fetch` directly. The three hooks are independent — none import each other.
- **`useHintsEngine` defaults to SSE** (`auto` mode) and demotes to polling after a 2 s connect timeout or 3 consecutive `onerror` callbacks. A 30 s heartbeat watchdog reconnects wedged streams. In `jsdom` / Node tests (no `EventSource`), it transparently falls back to polling.
- **Test files colocate** : `*.test.tsx` next to the component / hook. Vitest is configured in `vite.config.ts`.
- **Mock server is canonical for dev** : run `pnpm mock` instead of pointing at a real ESP32 — replays a fixed sequence of `game_start`, `profile_detected`, `puzzle_solved`, `hint_given`, `npc_spoke` events plus `timer_update` every 2 s.
- **Dashboard runs on `:5174`** (atelier uses `:5173`). Both can run together during integration work.
## Backend gotchas
- **voice-bridge `:8200`** lives on the MacStudio (`100.116.92.12`). Reachable from any machine on the Tailnet ; from outside, tunnel via `electron-server`. `/health/ready` returns `503` during F5 warmup with a coherent body — the hook treats that as "still loading", not an error.
- **`POST /usage/reset`** requires `X-Admin-Key` whenever the bridge env sets `VOICE_BRIDGE_ADMIN_KEY`. Pass it through `VITE_VOICE_BRIDGE_ADMIN_KEY` or `useVoiceUsage({ adminKey })`.
- **Hints engine is `:8311`** (per `HINTS_DEFAULT_BASE_URL`). The docstring at `tools/hints/server.py:48` recommends `--port 8300`**don't follow it**, that port is taken by `whisper.cpp` on the MacStudio. Run with `--port 8311`. The service is typically launched on the GM laptop alongside the dashboard, not on the MacStudio.
## Anti-patterns
- Reaching into `apps/atelier/` — these apps share `packages/shared` and `packages/ui` only, never each other's internals.
- Fetching from components — go through a hook so mock / real switching stays in one place.
- Storing per-component UI state in `gameStore` — keep it `useState`. The store is for game state, not panel toggles.
- Opening a WebSocket to voice-bridge `:8200` from here — the WS at `/voice/ws` is reserved for the firmware (per `useVoiceBridge` docstring). Use the REST polls.
- Hardcoding hosts / ports — read from `packages/shared/src/constants.ts` (`BOX3_MDNS_HOST`, `VOICE_BRIDGE_DEFAULT_BASE_URL`, `HINTS_DEFAULT_BASE_URL`) or the matching `VITE_*` env.
- Treating SSE payloads as data in `useHintsEngine` — they are *refresh signals*. Authoritative state lives behind `GET /hints/sessions`.
+41
View File
@@ -0,0 +1,41 @@
# Frontend Packages
Shared workspace packages consumed by `apps/atelier` and `apps/dashboard` via `workspace:*`. Three packages, each a distinct concern.
```
packages/
scenario-engine/ # Pure TS scenario interpreter (no React) — engine + profiler + scorer
shared/ # Types, constants, yaml-parser — zero runtime deps beyond yaml
ui/ # React component library + Tailwind tokens (tokens.css)
```
## Scope rules
- **`scenario-engine/`** — no React, no DOM. Pure functions over the Runtime 3 IR. Atelier instantiates it for live simulation ; dashboard does not import it. Tests live in `__tests__/`.
- **`shared/`** — `types.ts` is the source of truth for IR / event / WS message shapes. `yaml-parser.ts` is the only place that parses scenario YAML on the web side (the canonical compiler is `tools/scenario/compile_runtime3.py`). If a type is used by both apps, it lives here.
- **`ui/`** — design-system components only (buttons, panels, layout primitives). App-specific composite components stay in the app. `tokens.css` defines the CSS variables ; `tailwind.config.ts` reads them.
## Build
Each package builds independently :
```bash
pnpm --filter @zacus/scenario-engine build
pnpm --filter @zacus/shared build
pnpm --filter @zacus/ui build
```
TS project references wire it together (`tsconfig.json` in each package + root `tsconfig.json`).
## Patterns
- **Re-export from `index.ts`** — apps import `@zacus/shared`, never `@zacus/shared/src/types`.
- **Bump together** : a breaking change in `shared` requires touching both apps in the same PR. Keep the public API small to make this cheap.
- **No app code in packages** — if it references atelier / dashboard concepts, it stays in the app.
## Anti-patterns
- Adding React to `scenario-engine` — it must run in a Node test harness without DOM.
- Putting Tailwind classes in `shared``shared` has no view layer.
- Duplicating YAML parsing logic in an app — extend `yaml-parser.ts` instead.
- Cross-importing between packages beyond `shared → (none)`, `ui → shared`, `scenario-engine → shared`. No cycles.
@@ -0,0 +1,33 @@
# scenario-engine
TS implementation of the Runtime 3 IR — interpreter + group profiler + scorer. **Pure logic, no React, no DOM**, runs in Node test harness.
Public API (`src/index.ts`) :
```ts
ZacusScenarioEngine // engine.ts — IR executor
detectGroupProfile, profileConfidence // profiler.ts — player group classification
computeScore, assembleCode // scorer.ts — end-of-game scoring
ScenarioEngine // type re-export from @zacus/shared
```
## Files
- `src/engine.ts` — the executor. Holds run state, advances pivots, emits events.
- `src/profiler.ts` — analyses player behaviour to pick a group profile (drives adaptive hints + NPC tone).
- `src/scorer.ts` — final score + the assembled-code reveal.
- `__tests__/engine.test.ts` — Vitest, runs without DOM.
## Rules
- **Contract = `specs/ZACUS_RUNTIME_3_SPEC.md`** (repo root). The Python compiler `tools/scenario/compile_runtime3.py` produces the IR ; this package consumes it. Both must stay in lockstep — if you add an opcode here, add it to the spec and the Python compiler in the same PR.
- **Imports end in `.js`** (NodeNext ESM resolution) even though source is `.ts`. The `index.ts` already follows this.
- **Only dep is `@zacus/shared`** for types. Adding another runtime dep needs justification — this package is meant to be embedded in test harnesses and possibly server contexts.
- **No `Date.now()`, no `Math.random()`** in `engine.ts` without injection — tests must be deterministic. Pass a clock / RNG through the engine constructor.
## Anti-patterns
- Importing React / DOM / browser APIs.
- Forking the IR shape locally instead of extending `@zacus/shared/types`.
- Calling out to network / fs from the engine — it's a pure interpreter.
- Letting `profiler` and `scorer` reach into engine internals — they consume the same IR + event log the host gives them.
+25
View File
@@ -0,0 +1,25 @@
# shared
Source of truth for cross-app types, constants, and scenario YAML parsing. **No view layer, no React**. Runtime deps : `js-yaml` only.
```
src/
types.ts # IR shapes, event shapes, WS message shapes, ScenarioEngine interface
constants.ts # Shared magic numbers / enums
yaml-parser.ts # YAML → typed scenario object
index.ts # `export * from` each of the above
```
## Rules
- **`types.ts` is the contract**. Both apps and `scenario-engine` import from here. A type change is a workspace-wide change : bump together, check both apps compile.
- **`yaml-parser.ts` is the only web-side YAML reader**. The canonical compiler is the Python one in `tools/scenario/compile_runtime3.py` — this parser exists so the atelier can preview / live-diff without a Python round-trip. If they diverge, the Python compiler wins ; mirror the change here.
- **Constants stay tiny** — if a value is used in one place, inline it. Promote to `constants.ts` on the second use.
- **`.js` import suffix** in TS sources (NodeNext ESM). The existing `index.ts` follows this.
## Anti-patterns
- Adding a runtime dep beyond `js-yaml` — the value of `shared` is that it's cheap to embed everywhere.
- Re-exporting types under a renamed alias — keep one canonical name.
- App-specific types leaking in (e.g. dashboard panel props) — those stay in the app.
- Parsing YAML in an app component — go through `yaml-parser` so format changes have one place to land.
+33
View File
@@ -0,0 +1,33 @@
# ui
Shared React component library + design tokens. Current surface : `Button`, `Card`, `Badge`, `Progress`. Built on Radix primitives (`@radix-ui/react-{dialog,progress,toggle,tooltip}`), styled via Tailwind + `tokens.css`.
```
src/
components/ # Button, Card, Badge, Progress (one file per component)
tokens.css # CSS variables — exported as @zacus/ui/styles.css
index.ts # Re-exports component + props type per component
tailwind.config.ts
```
Consumer imports :
```ts
import { Button, type ButtonProps } from '@zacus/ui';
import '@zacus/ui/styles.css'; // tokens.css
```
## Rules
- **Headless + tokens** : Radix gives behaviour, `tokens.css` gives visual identity. Don't hardcode colors in components — reference CSS variables.
- **Promotion bar is high** : a component lives in the app until ≥2 apps need it. Premature `ui` entries become churn.
- **Props named `{Name}Props` and exported** alongside the component — atelier and dashboard rely on these to type their wrappers.
- **Tailwind is allowed here** (unlike `apps/atelier` which is inline-styles / `main.css`). The `tailwind.config.ts` lives next to the source.
- **No app state, no zustand, no fetch** — components are stateless or use local `useState` only.
## Anti-patterns
- Adding a feature-specific component (e.g. `<NpcMoodBadge/>`) — that belongs in the app that owns the concept.
- Importing from `@zacus/scenario-engine``ui` doesn't know about scenarios.
- Bundling icons / fonts — leave asset choice to the consumer.
- Breaking the `ButtonProps`-style export pattern — downstream code does `extends ButtonProps`.
+45
View File
@@ -0,0 +1,45 @@
# Hardware
Physical artefacts of the escape room : firmware sources, PCB projects, enclosures, BOM, wiring diagrams, puzzle assembly. The ESP32-S3 master firmware lives in the top-level `ESP32_ZACUS/` submodule — **this folder is the surrounding hardware**, not the master firmware.
```
hardware/
firmware/ # Arduino / PlatformIO sketches for puzzle satellites + UI boards
projects/ # KiCad projects — plip-telephone, slic-phone, slic-phone-esp32
puzzles/ # Per-puzzle BOM, GPIO mapping, assembly checklists, suitcase layout
bom/ # Aggregated bill of materials (bom.md)
wiring/ # Wiring diagrams
enclosure/ # 3D-printed / laser-cut enclosure files
ui_freenove_allinone/ # Reference Freenove board UI assets
```
## Where to look
| Task | Location |
|------|----------|
| Order parts | `bom/bom.md` + per-puzzle BOMs in `puzzles/BOM_V3_complete.csv` |
| Wire a puzzle station | `puzzles/gpio_mapping.md` + `wiring/` |
| Build a satellite firmware | `firmware/` (PlatformIO — `platformio.ini`) |
| Edit a KiCad schematic / PCB | `projects/<board>/` |
| Lay out the suitcase | `puzzles/suitcase_layout.md` |
| Print an enclosure | `enclosure/` |
## Patterns
- **PlatformIO is the firmware build system here**, not Arduino IDE. Each board has an env in `firmware/platformio.ini`. Override locally with `platformio_override.ini` (gitignored on field machines).
- **KiCad projects use the standard kicad skill** — see `/Users/electron/CLAUDE.md` for the kicad-pro MCP server. Don't hand-edit `.kicad_pcb` for routing.
- **BOM stays in sync with KiCad symbols** — use the `bom` skill / `lcsc` / `digikey` skills, not manual CSV edits.
- **Per-puzzle docs are the source of truth for assembly** — when GPIO assignment changes, update `puzzles/gpio_mapping.md` *and* the firmware constants in the same commit.
## Boundary with `ESP32_ZACUS/`
- `ESP32_ZACUS/` (submodule) = the **master** ESP32-S3 (NPC engine, voice pipeline, vision, media manager). Owns the game loop.
- `hardware/firmware/` = **satellite** boards (puzzle stations, secondary UIs, audio kits). They speak to the master over the protocol in `firmware/protocol/`.
- `PLIP_FIRMWARE/` (top-level) = the retro telephone annex. Distinct from `hardware/projects/plip-telephone` (which is the KiCad project for the same artefact).
## Anti-patterns
- Editing `ESP32_ZACUS/` files from here — it's a submodule with its own repo and CLAUDE.md.
- Duplicating BOM rows between `bom/bom.md` and `puzzles/BOM_V3_complete.csv` without marking one as derived.
- Changing GPIO assignments without grepping firmware *and* `puzzles/gpio_mapping.md`.
- Committing `.pio/`, `build/`, or local-override `platformio_override.ini`.