Compare commits

...
8 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 4.8 fc5f66021f feat(reg): correct Phase 1 for pre-deployed devices + matrix-code binding
Teams already have their boards, so drop the kit pickup + sticker claim. New flow:
run the setup script on your board -> it self-registers and scrolls a code on its
LED matrix -> enter that code to bind the node to your team.

- api: BoardRegistry.claimByCode() binds the unique board whose claimCode matches,
  no kit needed; /claim accepts code-first ({teamId, code[, members]}) and keeps the
  legacy kit path. Snapshot/response use the resolved board's kitId.
- client: ClaimInput.kit optional, carries members.
- BoardClaim: setup-script command + copy, code from the matrix, 'Bind board'.
- TeamRegistration: remove KitSelector + QR-kit preselect; card is now 'Your board'.
- Tests updated + code-first coverage (registry + HTTP).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 06:18:09 -07:00
Omar SobhandClaude Opus 4.8 1b31f6db1b feat(reg): inline MemberFields (+ add-row) replacing the chip input
Phase 1 members now entered as inline text rows with a '+ Add member' affordance and
per-row remove, per the pre-deployed-devices onboarding rework. Parent store still
receives only trimmed, non-empty names. Caps at 5. MemberChips retained (unused) for
now. Tests updated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 06:08:28 -07:00
Omar SobhandClaude Opus 4.8 1642fbce39 docs(demo): all three demo secrets now vaulted in Infisical
ANTHROPIC_OAUTH_TOKEN + APESS_NODE_TOKEN added to cloud-providers/prod alongside
ELEVENLABS_API_KEY. Runbook secrets section is now a single Infisical pull for all
three (APESS_NODE_TOKEN vault key -> NODE_TOKEN env). Verified end-to-end.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 05:12:34 -07:00
Omar SobhandClaude Opus 4.8 eb8c41b14b docs(demo): add DEMO-RUNBOOK.md; ElevenLabs key sourced from Infisical
Full demo-day runbook — secrets, pre-flight (recover.sh + voice proxy), the three
acts (web/Telegram/voice), mid-demo recovery, and gotchas. ELEVENLABS_API_KEY now
lives in Infisical on icarus (project cloud-providers, env prod, alongside the other
provider keys); runbook pulls it via the macbook-admin machine identity. Cloud token
and node token stay manual env exports for now.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 05:06:07 -07:00
Omar SobhandClaude Opus 4.8 413230255e feat(voice): ElevenLabs TTS in serve.py (server-side, key env-only)
serve.py gains /tts (ElevenLabs synthesis) + /config (advertises tts mode). Client
plays the returned MP3 when ElevenLabs is on, else falls back to browser speechSynthesis;
barge-in stops in-flight audio. Default voice Sarah (free-tier usable); key read from
ELEVENLABS_API_KEY env, never committed. README documents the free-tier library-voice
402 gotcha. Verified: 200 audio/mpeg, 38KB MP3.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 04:58:54 -07:00
Omar SobhandClaude Opus 4.8 3fef8c4d23 feat(uno-q): one-command demo-node recovery after USB drop
recover.sh: re-tunnels, relaunches the supervisor with the cloud token in its
environment (env-only, read from ANTHROPIC_OAUTH_TOKEN — never on disk), starts the
matrix bridge app only if down, preserves a running llama to skip cold reload, and
verifies end-to-end (llama/daemon/bridge health + demo agent on claude-sonnet-5 +
matrix_pattern fires). Turns a mid-demo disconnect into a ~30s fix. Tokens are read
from the env, so the script carries no secrets.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 04:51:43 -07:00
Omar SobhandClaude Opus 4.8 b09068b60c feat(voice): same-origin proxy client reusing the reliable /webhook path
The /ws/chat path builds a fresh Agent that omits the dynamically-registered
peripheral tools (matrix_pattern), so the model improvises with shell/read and the
matrix never changes. Switch the voice client to serve.py, which serves the page and
proxies POST /webhook same-origin to the node gateway (bearer token server-side).
Browser does STT+TTS (Web Speech API, no keys); the agent runs on cloud sonnet and
fires matrix_pattern reliably. Validated end-to-end server-side (checker in 6.2s).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 04:43:24 -07:00
Omar SobhandClaude Opus 4.8 1c762e51ba feat(voice): browser voice client for talking to the on-board agent
Single-file client: browser-side STT + TTS (Web Speech API, no keys), drives the
lean demo agent over /ws/chat (?agent=demo, no auth on the workshop board) to change
the LED-matrix animation by voice. Push-to-talk, live transcript, spoken reply, tool
activity display. README covers the localhost-serve secure-context requirement and the
ElevenLabs/voice-duplex upgrade path.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 21:54:10 -07:00
15 changed files with 762 additions and 74 deletions
+11 -6
View File
@@ -173,10 +173,15 @@ export function createApp(opts: AppOptions): Express {
app.post('/claim', async (req, res) => { app.post('/claim', async (req, res) => {
if (!boards || !nodes) return res.status(503).json({ error: 'claim unavailable' }) if (!boards || !nodes) return res.status(503).json({ error: 'claim unavailable' })
const b = req.body ?? {} const b = req.body ?? {}
if (typeof b.kit !== 'string' || typeof b.teamId !== 'string' || typeof b.code !== 'string') { if (typeof b.teamId !== 'string' || typeof b.code !== 'string') {
return res.status(400).json({ error: 'kit, teamId and code are required' }) return res.status(400).json({ error: 'teamId and code are required' })
} }
const result = boards.claim(b.kit, b.code, b.teamId, Date.parse(now())) // Code-first (board scrolls its code on the matrix, no kit picked) is the
// default; a supplied `kit` keeps the legacy sticker-claim path working.
const result =
typeof b.kit === 'string' && b.kit
? boards.claim(b.kit, b.code, b.teamId, Date.parse(now()))
: boards.claimByCode(b.code, b.teamId, Date.parse(now()))
if (!result.ok) { if (!result.ok) {
if (result.reason === 'unknown') { if (result.reason === 'unknown') {
return res.status(404).json({ error: 'no board found for that kit — is it powered on?' }) return res.status(404).json({ error: 'no board found for that kit — is it powered on?' })
@@ -184,7 +189,7 @@ export function createApp(opts: AppOptions): Express {
if (result.reason === 'rate_limited') { if (result.reason === 'rate_limited') {
return res.status(429).json({ error: 'too many attempts — wait a minute and try again' }) return res.status(429).json({ error: 'too many attempts — wait a minute and try again' })
} }
return res.status(401).json({ error: 'wrong claim code' }) return res.status(401).json({ error: "wrong code — check what your board is showing on its matrix" })
} }
const teamId = result.board.claimedBy as string // canonical (== b.teamId on first claim) const teamId = result.board.claimedBy as string // canonical (== b.teamId on first claim)
await nodes.register({ teamId, url: result.board.url, token: result.board.token }) await nodes.register({ teamId, url: result.board.url, token: result.board.token })
@@ -196,7 +201,7 @@ export function createApp(opts: AppOptions): Express {
const team: TeamSnapshot = { const team: TeamSnapshot = {
id: teamId, id: teamId,
name: pickName ?? '', name: pickName ?? '',
kit: b.kit, kit: result.board.kitId,
members: pickMembers ?? [], members: pickMembers ?? [],
domain: typeof prev?.domain === 'string' ? prev.domain : '', domain: typeof prev?.domain === 'string' ? prev.domain : '',
phases: { ...emptyPhases, ...(prev?.phases ?? {}) }, phases: { ...emptyPhases, ...(prev?.phases ?? {}) },
@@ -208,7 +213,7 @@ export function createApp(opts: AppOptions): Express {
broadcast({ type: 'team:update', team }) broadcast({ type: 'team:update', team })
broadcastUnclaimed() // the claimed kit left the pool broadcastUnclaimed() // the claimed kit left the pool
const online = nodes.list().find((n) => n.teamId === teamId)?.online ?? false const online = nodes.list().find((n) => n.teamId === teamId)?.online ?? false
res.status(201).json({ teamId, kit: b.kit, url: result.board.url, online, resumed: result.resumed, team }) res.status(201).json({ teamId, kit: result.board.kitId, url: result.board.url, online, resumed: result.resumed, team })
}) })
// Instructor action: release a kit back to the unclaimed pool and unbind its // Instructor action: release a kit back to the unclaimed pool and unbind its
+14
View File
@@ -112,6 +112,20 @@ describe('board self-register + claim', () => {
expect(pool.body).toEqual({ kits: [] }) expect(pool.body).toEqual({ kits: [] })
}) })
it('code-first: binds by the matrix code with no kit supplied', async () => {
const res = await request(app)
.post('/claim')
.send({ teamId: 'team-07', teamName: 'team_resonance', members: ['A. Rossi'], code: '418302' })
.expect(201)
expect(res.body).toMatchObject({ teamId: 'team-07', kit: 'KIT-07', online: true, resumed: false })
expect(res.body.team).toMatchObject({ id: 'team-07', name: 'team_resonance', members: ['A. Rossi'], deviceConnected: true })
expect(JSON.stringify(res.body)).not.toContain('zc_secret_token')
})
it('code-first: a wrong code is rejected', async () => {
await request(app).post('/claim').send({ teamId: 'team-07', code: '000000' }).expect(401)
})
it('exposes public per-team liveness after a claim', async () => { it('exposes public per-team liveness after a claim', async () => {
await request(app).get('/nodes/team-07/status').expect(404) // not yet claimed await request(app).get('/nodes/team-07/status').expect(404) // not yet claimed
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201) await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
+19
View File
@@ -51,6 +51,25 @@ describe('board registry', () => {
expect(again.ok && again.board.claimedBy).toBe('team-07') // canonical, not team-99 expect(again.ok && again.board.claimedBy).toBe('team-07') // canonical, not team-99
}) })
it('claimByCode binds the matching board with no kit, and rejects a wrong code', () => {
const r = createBoardRegistry()
r.announce(board())
expect(r.claimByCode('000000', 'team-07', 0)).toEqual({ ok: false, reason: 'bad_code' })
const out = r.claimByCode('418302', 'team-07', 0)
expect(out).toMatchObject({ ok: true, resumed: false })
expect(out.ok && out.board.kitId).toBe('KIT-07')
expect(r.isClaimed('KIT-07')).toBe(true)
})
it('claimByCode resumes an already-claimed board to its canonical team', () => {
const r = createBoardRegistry()
r.announce(board())
r.claimByCode('418302', 'team-07', 0)
const again = r.claimByCode('418302', 'team-99', 1)
expect(again).toMatchObject({ ok: true, resumed: true })
expect(again.ok && again.board.claimedBy).toBe('team-07')
})
it('a rebooted claimed board stays claimed, out of the pool, with refreshed url/token (auto-heal)', () => { it('a rebooted claimed board stays claimed, out of the pool, with refreshed url/token (auto-heal)', () => {
const r = createBoardRegistry() const r = createBoardRegistry()
r.announce(board()) r.announce(board())
+18
View File
@@ -40,6 +40,12 @@ export interface BoardRegistry {
* lost its browser can get back onto its own board. Rate-limited per kit. * lost its browser can get back onto its own board. Rate-limited per kit.
*/ */
claim(kitId: string, code: string, teamId: string, nowMs: number): ClaimOutcome claim(kitId: string, code: string, teamId: string, nowMs: number): ClaimOutcome
/**
* Claim (or resume) a board by its code ALONE — the attendee proves possession
* by reading the code the board scrolls on its own matrix, with no kit to pick.
* Finds the unique board whose `claimCode` matches; otherwise `bad_code`.
*/
claimByCode(code: string, teamId: string, nowMs: number): ClaimOutcome
/** Release a kit back to unclaimed; returns the freed teamId (or null). */ /** Release a kit back to unclaimed; returns the freed teamId (or null). */
release(kitId: string): string | null release(kitId: string): string | null
get(kitId: string): Board | undefined get(kitId: string): Board | undefined
@@ -90,6 +96,18 @@ export function createBoardRegistry(seed: Board[] = []): BoardRegistry {
} }
return { ok: true, board, resumed: true } // already claimed → resume to the canonical team return { ok: true, board, resumed: true } // already claimed → resume to the canonical team
}, },
claimByCode(code, teamId, nowMs) {
// Unique per-board codes → the code identifies the board. No match = bad code.
const board = [...boards.values()].find((b) => matches(code, b.claimCode))
if (!board) return { ok: false, reason: 'bad_code' }
if (recentFails(board.kitId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
fails.delete(board.kitId)
if (board.claimedBy === null) {
board.claimedBy = teamId
return { ok: true, board, resumed: false }
}
return { ok: true, board, resumed: true }
},
release(kitId) { release(kitId) {
const board = boards.get(kitId) const board = boards.get(kitId)
if (!board || board.claimedBy === null) return null if (!board || board.claimedBy === null) return null
+92
View File
@@ -0,0 +1,92 @@
# APESS Demo Runbook — Multi-Channel Agent Controls the LED Matrix
One on-board agent on the Arduino Uno Q changes the physical **13×8 LED-matrix
animation** on command from **web chat, Telegram, and voice** — same agent, same
`matrix_pattern` tool, all on cloud sonnet via the reliable `/webhook` path.
## Secrets
All three env-only secrets live in **Infisical** on the `icarus` instance (project
`cloud-providers`, env `prod`). Pull them into the environment in one block:
```bash
DOM=http://icarus.lan:8443 # or https://icarus.taila4f562.ts.net
CP=0788e188-b746-4ea0-a4b2-e0c2d0aec1b6 # cloud-providers project id
export INFISICAL_TOKEN=$(infisical login --method=universal-auth \
--client-id=$(cat ~/.infisical/macbook-admin-id) \
--client-secret=$(cat ~/.infisical/macbook-admin-secret) \
--domain=$DOM --silent --plain)
get(){ infisical secrets get "$1" --projectId=$CP --env=prod --domain=$DOM --plain; }
export ANTHROPIC_OAUTH_TOKEN=$(get ANTHROPIC_OAUTH_TOKEN) # Claude Max setup-token (cloud brain)
export NODE_TOKEN=$(get APESS_NODE_TOKEN) # Uno Q gateway bearer token
export ELEVENLABS_API_KEY=$(get ELEVENLABS_API_KEY) # ElevenLabs TTS voice
```
**None of these may be written to disk or committed** — env-only. Infisical is the vault;
`recover.sh` and `serve.py` read them from the environment. (Vault key `APESS_NODE_TOKEN`
maps to the `NODE_TOKEN` env var the scripts expect.)
## Pre-flight (~5 min before, board plugged into USB)
```bash
cd ~/projects/apress
# (secrets exported per above)
./deploy/uno-q/recover.sh # brings up node + verifies sonnet + matrix (all-green)
# voice proxy — leave running in its own terminal:
NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=$NODE_TOKEN ELEVENLABS_API_KEY=$ELEVENLABS_API_KEY \
python3 deploy/voice-client/serve.py 8090
```
`recover.sh` re-tunnels (`adb forward :8080`), relaunches the daemon with the cloud
token in its environment, starts the matrix bridge app, and confirms
`agent=demo` is on `claude-sonnet-5` and `matrix_pattern` fires.
**On the workshop LAN:** point `NODE_URL` at the board's LAN IP (`http://192.168.x.x:8080`)
instead of the adb-forwarded `127.0.0.1:8080`, so the browser voice client reaches the
board over the network.
## The three acts
1. **Web chat** — prompt *"show the rain animation"* → matrix changes + live activity feed.
2. **Telegram** — **t.me/Apess2026Bot** → *"change it to a beating heart"* → the *same
physical matrix* changes, driven from a phone. (One-time pairing: `/bind <code>` — the
code prints in the daemon log at startup; grep `bind code`.)
3. **Voice** — **http://localhost:8090** in Chrome → hold-to-talk *"make it wave"* → matrix
changes and the reply is **spoken back in the ElevenLabs voice** (Sarah).
Patterns the agent understands: `off, rain, heart, wave, sparkle, checker, solid, blink`.
## If the board disconnects mid-demo
The recurring USB drop kills the daemon/llama/bridge and loses the env-only cloud token.
Re-plug, then:
```bash
./deploy/uno-q/recover.sh # ~30s, re-injects the token, verifies end-to-end
```
The voice proxy auto-recovers via the re-armed tunnel (no restart needed). If it was
stopped, relaunch the `serve.py` line above.
## Gotchas / facts
- **Cloud token is env-only** — a disconnect loses it; recovery *must* re-export it (the
script uses `$ANTHROPIC_OAUTH_TOKEN` from your shell).
- **Use `/webhook`, not `/ws/chat`** — the WS path builds a fresh agent that omits the
peripheral `matrix_pattern` tool; the voice client's `serve.py` proxies `/webhook` for
this reason.
- **First turn after a fresh daemon** is a touch slower (cold); the pre-flight
`recover.sh` call warms it.
- **ElevenLabs free tier** can only use the premade voices attached to the account (not
"library" voices → 402). Default `EXAVITQu4vr4xnSDxMaL` (Sarah) works.
- **Board serial** `65301572`. **Bridge app**: `~/ArduinoApps/uno-q-bridge`
(`arduino-app-cli`, needs `TMPDIR=/tmp`).
## Where things live
- `deploy/uno-q/recover.sh` — one-command node recovery.
- `deploy/voice-client/` — browser voice client + `serve.py` proxy (STT/TTS + ElevenLabs).
- zeroclaw fork (`fix/uno-q-flash-timeouts`) — resident matrix responder + `matrix_pattern`
tool + Telegram channel.
</content>
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# recover.sh — one-command recovery for the APESS Uno Q demo node after a USB drop.
#
# On a disconnect the daemon/llama/bridge die and the cloud token (env-only) is lost.
# This re-tunnels, relaunches the supervisor WITH the token in its environment,
# restarts the matrix bridge app, and verifies the whole chain end-to-end.
#
# Secrets are read from the environment — NEVER hardcoded here. Export first:
# export ANTHROPIC_OAUTH_TOKEN=sk-ant-oat01-... # required: cloud brain
# export NODE_TOKEN=zc_... # optional: end-to-end verify
# ./recover.sh
#
# Env knobs: SERIAL (default 65301572), the two tokens above.
set -u
SERIAL="${SERIAL:-65301572}"
A(){ adb -s "$SERIAL" "$@"; }
S(){ adb -s "$SERIAL" shell "$@"; }
ok(){ printf ' \033[32m✓\033[0m %s\n' "$*"; }
bad(){ printf ' \033[31m✗\033[0m %s\n' "$*"; }
step(){ printf '\n\033[1m%s\033[0m\n' "$*"; }
step "0· Preconditions"
if ! adb devices | grep -q "^${SERIAL}[[:space:]]*device"; then
bad "board $SERIAL not attached — re-plug USB, then re-run"; exit 1
fi
ok "board $SERIAL attached"
[ -n "${ANTHROPIC_OAUTH_TOKEN:-}" ] || { bad "ANTHROPIC_OAUTH_TOKEN not set — cloud brain will fail. export it and re-run"; exit 1; }
ok "cloud token present in env"
step "1· Tunnel"
A forward tcp:8080 tcp:8080 >/dev/null && ok "adb forward :8080 → laptop localhost:8080"
step "2· Stop stale supervisor + daemons (preserve llama)"
S 'for p in $(ps -C zeroclaw-supervisor -o pid= 2>/dev/null); do kill -9 $p 2>/dev/null; done
for p in $(ps -C zeroclaw -o pid= 2>/dev/null); do kill -9 $p 2>/dev/null; done
rm -f /home/arduino/.zc-supervisor.lock; sleep 2
echo " daemons left: $(ps -C zeroclaw -o pid= 2>/dev/null | wc -l)"'
step "3· Relaunch supervisor WITH token env (env-only, never on disk)"
S "export ANTHROPIC_OAUTH_TOKEN='$ANTHROPIC_OAUTH_TOKEN'; \
export ZEROCLAW_providers__models__anthropic__max__api_key='$ANTHROPIC_OAUTH_TOKEN'; \
setsid nohup /home/arduino/zeroclaw-supervisor.sh >/dev/null 2>&1 </dev/null & sleep 2; echo done" >/dev/null
S 'pgrep -f "[z]eroclaw-supervisor" >/dev/null' && ok "supervisor relaunched" || bad "supervisor did NOT start"
step "4· Matrix bridge app (start only if down)"
if [ "$(S 'printf "ping\n" | timeout 4 nc 127.0.0.1 9999 2>/dev/null')" = "pong" ]; then
ok "bridge already running"
else
S 'cd ~/ArduinoApps/uno-q-bridge && TMPDIR=/tmp arduino-app-cli app start ~/ArduinoApps/uno-q-bridge 2>&1 | tail -1'
fi
step "5· Wait for services"
for i in $(seq 1 30); do
L=$(S 'curl -sf -m3 http://127.0.0.1:8083/health >/dev/null 2>&1 && echo 1 || echo 0')
D=$(S 'curl -sf -m3 http://127.0.0.1:8080/health >/dev/null 2>&1 && echo 1 || echo 0')
printf '\r [%02d] llama=%s daemon=%s ' "$i" "$L" "$D"
[ "$D" = 1 ] && break; sleep 6
done; echo
[ "$L" = 1 ] && ok "llama :8083 healthy" || bad "llama :8083 DOWN (cold load can take 3–5 min; re-check)"
[ "$D" = 1 ] && ok "daemon :8080 healthy" || { bad "daemon :8080 DOWN"; exit 1; }
step "6· Bridge (matrix responder)"
P=$(S 'printf "ping\n" | timeout 4 nc 127.0.0.1 9999 2>/dev/null')
[ "$P" = "pong" ] && ok "bridge :9999 responds (ping→pong)" || bad "bridge :9999 not responding — re-run step 4"
step "7· End-to-end: demo agent = cloud sonnet + matrix fires"
if [ -n "${NODE_TOKEN:-}" ]; then
S 'printf "matrix 0\n" | timeout 5 nc 127.0.0.1 9999 >/dev/null 2>&1'
R=$(curl -s -m 30 -X POST "http://127.0.0.1:8080/webhook?agent=demo" \
-H "Authorization: Bearer $NODE_TOKEN" -H 'Content-Type: application/json' \
-d '{"message":"Show the rain animation on the LED matrix"}')
echo "$R" | grep -q "claude-sonnet-5" && ok "agent=demo on claude-sonnet-5" || bad "agent NOT on sonnet — token may not have loaded: $R"
M=$(S "docker logs --since 40s uno-q-bridge-main-1 2>&1 | grep -c \"parts=\['matrix', '1'\]\"")
[ "${M:-0}" -ge 1 ] && ok "matrix_pattern fired (rain)" || bad "matrix did not change"
else
echo " (NODE_TOKEN unset — skipping authenticated end-to-end check)"
fi
step "Recovery complete."
echo " Voice proxy (laptop): if it was running it auto-recovers via the re-armed tunnel."
echo " If not running: NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=\$NODE_TOKEN python3 deploy/voice-client/serve.py 8090"
+53
View File
@@ -0,0 +1,53 @@
# APESS Voice → Node client
Talk to the on-board agent and change the LED-matrix animation **by voice** — same
agent, same `matrix_pattern` tool as web chat and Telegram.
- **STT + TTS run in the browser** (Web Speech API) — no ElevenLabs, no keys.
- **`serve.py` serves the page AND proxies `/webhook`** to the node on the same origin,
so the browser needs no CORS and no bearer token, and we reuse the reliable
`/webhook` path (the `/ws/chat` path drops the peripheral matrix tool, so we avoid it).
## Run it
The node is reached over USB via `adb forward`, or by its LAN IP on the day.
```bash
# 1. expose the board's gateway locally (USB path)
adb forward tcp:8080 tcp:8080
# 2. serve the client + proxy (tokens stay server-side, never in the browser)
cd deploy/voice-client
NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=<gateway-bearer-token> \
ELEVENLABS_API_KEY=<sk_...> python3 serve.py 8090
# on the day, point NODE_URL at the board's LAN IP instead:
# NODE_URL=http://192.168.x.x:8080 NODE_TOKEN=... ELEVENLABS_API_KEY=... python3 serve.py 8090
# 3. open http://localhost:8090 in Chrome
```
Then: the dot goes green (proxy reachable), **hold** the circle, say
*"show the wave animation"*, release. The node runs the agent → `matrix_pattern` →
the matrix changes, and the reply is spoken back.
`localhost` is a secure context so Chrome grants mic access; the proxy hop is
server-side so there is no CORS. One process, one origin, no keys. Chrome required
(Web Speech API).
## TTS: ElevenLabs vs browser
`serve.py` synthesizes replies with **ElevenLabs** when `ELEVENLABS_API_KEY` is set
(server-side `/tts` endpoint → the client plays the returned MP3); otherwise the client
falls back to the browser's built-in `speechSynthesis` voice. The client learns which
mode is active from `GET /config`, so no client change is needed either way.
- Default voice: **Sarah** (`EXAVITQu4vr4xnSDxMaL`). Override with `ELEVENLABS_VOICE_ID`.
- **Free-tier gotcha:** free ElevenLabs accounts can only use the ~21 *premade voices
attached to the account* — not "library" voices (e.g. Rachel `21m00…`), which return
HTTP 402 `paid_plan_required`. List usable voices:
`curl -s https://api.elevenlabs.io/v1/voices -H "xi-api-key: $ELEVENLABS_API_KEY"`.
- Model: `eleven_turbo_v2_5` (low latency) — override with `ELEVENLABS_MODEL`.
(The gateway also has a native voice-duplex path, but it shares the `/ws/chat`
peripheral-tool gap noted above and needs a fork fix first.)
</content>
+173
View File
@@ -0,0 +1,173 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>APESS · Voice → Node</title>
<style>
:root { --bg:#0d0d0f; --fg:#eae6df; --dim:#8a857c; --accent:#e8543f; --line:#26242a; }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg);
font-family:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
min-height:100vh; display:flex; flex-direction:column; align-items:center; }
header { width:100%; border-bottom:1px solid var(--line); padding:14px 18px;
text-transform:uppercase; letter-spacing:.18em; font-size:12px; color:var(--dim);
display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; }
.cfg { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.cfg input { background:#141317; color:var(--fg); border:1px solid var(--line);
padding:6px 8px; font:inherit; font-size:12px; border-radius:4px; }
.cfg input#ip { width:150px; }
.cfg input#agent { width:80px; }
button { font:inherit; cursor:pointer; }
.conn { padding:6px 10px; border:1px solid var(--line); border-radius:4px;
background:transparent; color:var(--fg); font-size:12px; text-transform:uppercase; letter-spacing:.1em; }
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:#5a5750; margin-right:6px; vertical-align:middle; }
.dot.on { background:#4caf72; } .dot.err { background:var(--accent); }
main { flex:1; width:100%; max-width:720px; padding:24px 18px; display:flex; flex-direction:column; gap:18px; }
.mic { align-self:center; width:150px; height:150px; border-radius:50%; border:2px solid var(--line);
background:#141317; color:var(--fg); font-size:13px; text-transform:uppercase; letter-spacing:.12em;
display:flex; align-items:center; justify-content:center; transition:all .15s; user-select:none; }
.mic:hover { border-color:var(--dim); }
.mic.live { border-color:var(--accent); background:#2a1512; box-shadow:0 0 0 6px rgba(232,84,63,.12); }
.mic:disabled { opacity:.4; cursor:not-allowed; }
.hint { text-align:center; color:var(--dim); font-size:12px; margin-top:-8px; }
.log { display:flex; flex-direction:column; gap:12px; }
.row { border:1px solid var(--line); border-radius:6px; padding:12px 14px; }
.row .who { font-size:10px; text-transform:uppercase; letter-spacing:.16em; color:var(--dim); margin-bottom:6px; }
.row.you { border-color:#2f3a44; } .row.node { border-color:#3a2f2c; }
.row.tool { border-style:dashed; color:var(--dim); font-size:12px; }
.txt { font-size:15px; line-height:1.5; white-space:pre-wrap; }
.interim { color:var(--dim); font-style:italic; }
</style>
</head>
<body>
<header>
<span>APESS · VOICE → NODE</span>
<div class="cfg">
<input id="ip" placeholder="board-ip:8080" />
<input id="agent" value="demo" />
<button class="conn" id="connect"><span class="dot" id="dot"></span><span id="connlabel">Connect</span></button>
</div>
</header>
<main>
<button class="mic" id="mic" disabled>Hold&nbsp;to&nbsp;talk</button>
<div class="hint" id="hint">Connect to your node, then hold the circle and speak.</div>
<div class="log" id="log"></div>
</main>
<script>
(() => {
const $ = id => document.getElementById(id);
const ipEl=$('ip'), agentEl=$('agent'), micEl=$('mic'), logEl=$('log'),
dot=$('dot'), connLabel=$('connlabel'), hint=$('hint'), connectBtn=$('connect');
// The node address is configured on serve.py; the browser only picks the agent.
ipEl.style.display = 'none';
agentEl.value = localStorage.getItem('apess_agent') || 'demo';
let connected=false;
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) { hint.textContent = 'This browser has no Web Speech API — use Chrome.'; }
function setConn(state){ // 'on' | 'err' | ''
dot.className = 'dot' + (state ? ' '+state : '');
connected = state==='on';
connLabel.textContent = connected ? 'Ready' : (state==='err'?'Retry':'Connect');
micEl.disabled = !connected || !SR;
if (connected) hint.textContent = 'Hold the circle, speak, release. The node acts and talks back.';
}
function addRow(cls, who, text){
const r=document.createElement('div'); r.className='row '+cls;
r.innerHTML=`<div class="who">${who}</div><div class="txt"></div>`;
r.querySelector('.txt').textContent=text; logEl.appendChild(r);
r.scrollIntoView({behavior:'smooth',block:'end'}); return r;
}
// Transport: POST to a SAME-ORIGIN /webhook that serve.py proxies to the node's
// gateway (reliable path — the WS path drops peripheral tools). No CORS, no auth
// in the browser (serve.py holds the bearer token).
function connect(){
const agent = agentEl.value.trim() || 'demo';
localStorage.setItem('apess_agent', agent);
setConn('');
fetch('/ping').then(r => setConn(r.ok?'on':'err')).catch(()=>setConn('err'));
}
let ttsMode = 'browser', curAudio = null;
fetch('/config').then(r=>r.json()).then(c=>{ ttsMode = c.tts||'browser'; }).catch(()=>{});
async function speak(text){
if (!text) return;
// stop anything currently playing (barge-in)
try { window.speechSynthesis && window.speechSynthesis.cancel(); } catch(e){}
if (curAudio) { try{ curAudio.pause(); }catch(e){} curAudio=null; }
if (ttsMode === 'elevenlabs') {
try {
const r = await fetch('/tts', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({text})});
if (r.ok) {
const url = URL.createObjectURL(await r.blob());
curAudio = new Audio(url); curAudio.onended=()=>URL.revokeObjectURL(url);
await curAudio.play(); return;
}
} catch(e){ /* fall through to browser TTS */ }
}
if (window.speechSynthesis) {
const u = new SpeechSynthesisUtterance(text);
u.rate = 1.02; u.pitch = 1.0; window.speechSynthesis.speak(u);
}
}
async function send(text){
if (!text) return;
addRow('you','You',text);
const agent = agentEl.value.trim() || 'demo';
const pending = addRow('node','Node','…');
try {
const r = await fetch('/webhook?agent='+encodeURIComponent(agent), {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({message:text})
});
const j = await r.json();
const reply = (j.response || j.error || '(no response)').trim();
pending.querySelector('.txt').textContent = reply;
speak(reply);
} catch(e) {
pending.querySelector('.txt').textContent = 'error: '+e.message;
}
}
// --- push-to-talk: hold the circle, speak, release ---
let rec=null, listening=false, finalText='';
function startListen(){
if (!SR || !connected || listening) return;
finalText=''; listening=true; micEl.classList.add('live'); micEl.textContent='Listening…';
rec = new SR(); rec.lang='en-US'; rec.interimResults=true; rec.continuous=false;
rec.onresult = e => {
let interim='';
for (let i=e.resultIndex;i<e.results.length;i++){
const t=e.results[i][0].transcript;
if (e.results[i].isFinal) finalText+=t; else interim+=t;
}
hint.innerHTML = '<span class="interim">'+(finalText+interim||'…')+'</span>';
};
rec.onerror = () => {};
rec.onend = () => { listening=false; micEl.classList.remove('live'); micEl.textContent='Hold to talk';
const t=finalText.trim(); hint.textContent='Hold the circle and speak.'; if (t) send(t); };
try { rec.start(); } catch(e){ listening=false; }
}
function stopListen(){ if (rec && listening) { try{ rec.stop(); }catch(e){} } }
micEl.addEventListener('mousedown', startListen);
micEl.addEventListener('mouseup', stopListen);
micEl.addEventListener('mouseleave', stopListen);
micEl.addEventListener('touchstart', e=>{e.preventDefault();startListen();},{passive:false});
micEl.addEventListener('touchend', e=>{e.preventDefault();stopListen();},{passive:false});
connectBtn.addEventListener('click', connect);
setConn('');
connect(); // auto-check the proxy on load
})();
</script>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""APESS voice client host + gateway proxy.
Serves index.html AND proxies POST /webhook to the node's ZeroClaw gateway on the
same origin — so the browser needs no CORS and no bearer token, and we reuse the
reliable /webhook path (the /ws/chat path drops the peripheral matrix tool).
Usage:
NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=zc_xxx python3 serve.py [port]
- NODE_URL node gateway base (default http://127.0.0.1:8080; via `adb forward
tcp:8080 tcp:8080` over USB, or the board's LAN IP:8080 on the day).
- NODE_TOKEN gateway bearer token (kept server-side, never sent to the browser).
- port local port to serve on (default 8090). Open http://localhost:<port>.
localhost is a secure context, so the browser grants mic access; the proxy hop is
server-side, so there is no CORS. One process, one origin.
"""
import os, sys, json, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__))
NODE_URL = os.environ.get("NODE_URL", "http://127.0.0.1:8080").rstrip("/")
NODE_TOKEN = os.environ.get("NODE_TOKEN", "")
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8090
# Optional ElevenLabs TTS (server-side; key never reaches the browser). If unset,
# the client falls back to the browser's built-in speechSynthesis voice.
ELEVEN_KEY = os.environ.get("ELEVENLABS_API_KEY", "")
ELEVEN_VOICE = os.environ.get("ELEVENLABS_VOICE_ID", "EXAVITQu4vr4xnSDxMaL") # Sarah (free-tier usable)
ELEVEN_MODEL = os.environ.get("ELEVENLABS_MODEL", "eleven_turbo_v2_5")
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass # quiet
def _send(self, code, body, ctype="application/json"):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/ping":
return self._send(200, b'{"ok":true}')
if self.path == "/config":
mode = "elevenlabs" if ELEVEN_KEY else "browser"
return self._send(200, json.dumps({"tts": mode}).encode())
path = "/index.html" if self.path in ("/", "") else self.path.split("?")[0]
fp = os.path.normpath(os.path.join(HERE, path.lstrip("/")))
if not fp.startswith(HERE) or not os.path.isfile(fp):
return self._send(404, b"not found", "text/plain")
ctype = "text/html" if fp.endswith(".html") else "text/plain"
with open(fp, "rb") as f:
self._send(200, f.read(), ctype)
def do_POST(self):
if self.path == "/tts":
return self._tts()
if not self.path.startswith("/webhook"):
return self._send(404, b'{"error":"not found"}')
n = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(n)
q = self.path[len("/webhook"):] # keep ?agent=...
req = urllib.request.Request(
f"{NODE_URL}/webhook{q}", data=body, method="POST",
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {NODE_TOKEN}"})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
self._send(resp.status, resp.read())
except urllib.error.HTTPError as e:
self._send(e.code, e.read() or b'{"error":"upstream"}')
except Exception as e:
self._send(502, json.dumps({"error": str(e)}).encode())
def _tts(self):
if not ELEVEN_KEY:
return self._send(503, b'{"error":"tts disabled"}')
n = int(self.headers.get("Content-Length", 0))
try:
text = json.loads(self.rfile.read(n)).get("text", "").strip()
except Exception:
text = ""
if not text:
return self._send(400, b'{"error":"no text"}')
payload = json.dumps({
"text": text, "model_id": ELEVEN_MODEL,
"voice_settings": {"stability": 0.5, "similarity_boost": 0.75},
}).encode()
req = urllib.request.Request(
f"https://api.elevenlabs.io/v1/text-to-speech/{ELEVEN_VOICE}",
data=payload, method="POST",
headers={"xi-api-key": ELEVEN_KEY, "Content-Type": "application/json",
"Accept": "audio/mpeg"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
self._send(200, resp.read(), "audio/mpeg")
except urllib.error.HTTPError as e:
self._send(e.code, e.read() or b'{"error":"tts upstream"}')
except Exception as e:
self._send(502, json.dumps({"error": str(e)}).encode())
if __name__ == "__main__":
if not NODE_TOKEN:
print("WARN: NODE_TOKEN is empty — gateway calls will 401.", file=sys.stderr)
print(f"APESS voice client → {NODE_URL}")
print(f"TTS: {'ElevenLabs ('+ELEVEN_VOICE+')' if ELEVEN_KEY else 'browser (speechSynthesis)'}")
print(f"open http://localhost:{PORT}")
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
+27 -13
View File
@@ -5,28 +5,31 @@ import { claimBoard, ClaimError, type ClaimResult } from '@/lib/api'
export interface BoardClaimProps { export interface BoardClaimProps {
teamId: string teamId: string
kit: string
teamName: string teamName: string
members: string[]
connected: boolean connected: boolean
port: string | null port: string | null
onClaimed: (result: ClaimResult) => void onClaimed: (result: ClaimResult) => void
/** Pre-fill the code (from the kit QR's ?code= param). */ /** Pre-fill the code (e.g. from a ?code= param). */
initialCode?: string initialCode?: string
} }
/** The three physical bring-up steps an attendee performs before claiming. */ /** The self-service bring-up steps — the board is already set up from the week. */
const STEPS = [ const STEPS = [
'Plug your Uno Q into power over USB-C — the 13×8 matrix lights up.', 'On your board, open a terminal and run the workshop setup script (below).',
'Wait ~30 s for it to boot and join the workshop network.', 'It checks your node is ready, registers it, and scrolls a code across the LED matrix.',
'Enter the 6-digit claim code printed on your kit sticker.', 'Type the code your board is showing to bind it to your team.',
] ]
const SETUP_CMD = 'curl -fsSL https://apess.redclaw.dev/setup.sh | bash'
/** /**
* The board bring-up wizard: walks the attendee through powering on their Uno Q * The board bring-up wizard for pre-deployed devices: the attendee runs the
* and claims it to their team by proving the kit's claim code. On success the * setup script on their own Uno Q, which self-registers the node and shows a
* board is bound server-side (its bearer token never touches the browser). * code on its matrix; entering that code binds the board to the team. The
* bearer token never touches the browser.
*/ */
export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed, initialCode }: BoardClaimProps) { export function BoardClaim({ teamId, teamName, members, connected, port, onClaimed, initialCode }: BoardClaimProps) {
const [code, setCode] = useState(initialCode ?? '') const [code, setCode] = useState(initialCode ?? '')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -49,7 +52,7 @@ export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed,
setBusy(true) setBusy(true)
setError(null) setError(null)
try { try {
const result = await claimBoard({ teamId, kit, teamName, code: trimmed }) const result = await claimBoard({ teamId, teamName, members, code: trimmed })
onClaimed(result) onClaimed(result)
} catch (e) { } catch (e) {
setError(e instanceof ClaimError ? e.message : 'Could not reach the workshop — check your connection.') setError(e instanceof ClaimError ? e.message : 'Could not reach the workshop — check your connection.')
@@ -68,18 +71,29 @@ export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed,
</li> </li>
))} ))}
</ol> </ol>
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
<code className="font-mono text-[11px] text-foreground/90 select-all flex-1 truncate">{SETUP_CMD}</code>
<button
type="button"
aria-label="Copy setup command"
onClick={() => navigator.clipboard?.writeText(SETUP_CMD)}
className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground hover:text-foreground shrink-0"
>
Copy
</button>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
aria-label="Claim code" aria-label="Claim code"
inputMode="numeric" inputMode="numeric"
placeholder="418302" placeholder="code on your matrix"
value={code} value={code}
onChange={(e) => setCode(e.target.value)} onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && claim()} onKeyDown={(e) => e.key === 'Enter' && claim()}
className="font-mono" className="font-mono"
/> />
<Button onClick={claim} disabled={busy || !code.trim()}> <Button onClick={claim} disabled={busy || !code.trim()}>
{busy ? 'Claiming…' : 'Claim board'} {busy ? 'Binding…' : 'Bind board'}
</Button> </Button>
</div> </div>
{error && ( {error && (
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { MemberFields } from './MemberFields'
function Wrapper({ initial = [] as string[] }) {
const [members, setMembers] = useState(initial)
return (
<>
<MemberFields members={members} onChange={setMembers} />
<output data-testid="committed">{members.join(',')}</output>
</>
)
}
describe('MemberFields', () => {
it('renders one empty row by default', () => {
render(<Wrapper />)
expect(screen.getByLabelText('Member 1')).toBeInTheDocument()
})
it('commits trimmed, non-empty names to the parent', async () => {
const user = userEvent.setup()
render(<Wrapper />)
await user.type(screen.getByLabelText('Member 1'), ' A. Rossi ')
expect(screen.getByTestId('committed')).toHaveTextContent('A. Rossi')
})
it('adds a field below when "+" is clicked', async () => {
const user = userEvent.setup()
render(<Wrapper />)
await user.click(screen.getByRole('button', { name: /add member/i }))
expect(screen.getByLabelText('Member 2')).toBeInTheDocument()
})
it('seeds a row per existing member and can remove one', async () => {
const user = userEvent.setup()
render(<Wrapper initial={['A. Rossi', 'K. Tanaka']} />)
expect(screen.getByLabelText('Member 1')).toHaveValue('A. Rossi')
await user.click(screen.getByRole('button', { name: /remove member 1/i }))
expect(screen.getByTestId('committed')).toHaveTextContent('K. Tanaka')
expect(screen.getByTestId('committed')).not.toHaveTextContent('A. Rossi')
})
it('caps at 5 members', () => {
render(<Wrapper initial={['A', 'B', 'C', 'D', 'E']} />)
expect(screen.getByRole('button', { name: /add member/i })).toBeDisabled()
})
})
+86
View File
@@ -0,0 +1,86 @@
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
const MAX_MEMBERS = 5
export interface MemberFieldsProps {
members: string[]
onChange: (next: string[]) => void
}
/**
* Inline member entry: one text field per member with a "+" to append another
* row below, and a "×" to remove a row. The parent store only ever sees the
* trimmed, non-empty names; empty rows are a local editing affordance.
*/
export function MemberFields({ members, onChange }: MemberFieldsProps) {
// Seed local rows from the parent (always at least one row to type into).
const [rows, setRows] = useState<string[]>(members.length ? members : [''])
const commit = (next: string[]) => {
setRows(next)
onChange(next.map((r) => r.trim()).filter(Boolean))
}
const setRow = (i: number, value: string) => {
const next = rows.slice()
next[i] = value
commit(next)
}
const addRow = () => {
if (rows.length >= MAX_MEMBERS) return
setRows([...rows, '']) // don't commit — empty row adds nothing to the store
}
const removeRow = (i: number) => {
const next = rows.length > 1 ? rows.filter((_, idx) => idx !== i) : ['']
commit(next)
}
const full = rows.length >= MAX_MEMBERS
const filled = rows.filter((r) => r.trim()).length
return (
<div className="space-y-2" data-testid="member-fields">
{rows.map((row, i) => (
<div key={i} className="flex gap-2 items-center">
<span className="font-mono text-[10px] text-muted-foreground w-4 shrink-0 text-right">
{i + 1}
</span>
<Input
aria-label={`Member ${i + 1}`}
placeholder="e.g. A. Rossi"
value={row}
onChange={(e) => setRow(i, e.target.value)}
className="font-mono text-sm"
/>
<button
type="button"
aria-label={`Remove member ${i + 1}`}
onClick={() => removeRow(i)}
className="text-muted-foreground hover:text-destructive transition leading-none px-1.5 text-lg shrink-0"
>
×
</button>
</div>
))}
<div className="flex items-center justify-between pl-6">
<Button
type="button"
variant="ghost"
size="sm"
onClick={addRow}
disabled={full}
className="font-mono text-[11px] tracking-wider uppercase h-7 px-2"
>
+ Add member
</Button>
<span className="font-mono text-[10px] text-muted-foreground tracking-wider uppercase">
{filled} / {MAX_MEMBERS}
</span>
</div>
</div>
)
}
+3 -1
View File
@@ -85,8 +85,10 @@ export async function getLeaderboard(code: string): Promise<LeaderboardRow[]> {
// --- board onboarding (participant) --------------------------------------- // --- board onboarding (participant) ---------------------------------------
export interface ClaimInput { export interface ClaimInput {
teamId: string teamId: string
kit: string /** Optional legacy sticker path; omit for code-first (board shows its code). */
kit?: string
teamName?: string teamName?: string
members?: string[]
code: string code: string
} }
export interface ClaimResult { export interface ClaimResult {
+18 -33
View File
@@ -36,13 +36,6 @@ describe('TeamRegistration', () => {
expect(useSession.getState().team.name).toBe('team_resonance') expect(useSession.getState().team.name).toBe('team_resonance')
}) })
it('persists the selected kit to the session store on click', async () => {
const user = userEvent.setup()
renderPage()
await user.click(screen.getByRole('button', { name: 'KIT-04' }))
expect(useSession.getState().team.kit).toBe('KIT-04')
})
it('gates the Proceed button until name + member + board are ready', async () => { it('gates the Proceed button until name + member + board are ready', async () => {
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
@@ -52,8 +45,8 @@ describe('TeamRegistration', () => {
await user.type(screen.getByLabelText(/team name/i), 'team_x') await user.type(screen.getByLabelText(/team name/i), 'team_x')
expect(proceed).toBeDisabled() expect(proceed).toBeDisabled()
const memberInput = screen.getByLabelText(/team member/i) const memberInput = screen.getByLabelText('Member 1')
await user.type(memberInput, 'A. Rossi{Enter}') await user.type(memberInput, 'A. Rossi')
expect(proceed).toBeDisabled() expect(proceed).toBeDisabled()
// a claimed board satisfies the device requirement // a claimed board satisfies the device requirement
@@ -61,35 +54,37 @@ describe('TeamRegistration', () => {
expect(proceed).toBeEnabled() expect(proceed).toBeEnabled()
}) })
it('claims a board with the kit code and marks it connected', async () => { it('binds a board by the matrix code and marks it connected', async () => {
const fetchMock = vi.fn().mockResolvedValue({ const fetchMock = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: async () => ({ teamId: 't', kit: 'KIT-01', online: true }), json: async () => ({ teamId: 't', kit: 'crimson-otter', online: true }),
}) })
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
await user.type(screen.getByLabelText(/claim code/i), '418302') await user.type(screen.getByLabelText(/claim code/i), '4821')
await user.click(screen.getByRole('button', { name: /claim board/i })) await user.click(screen.getByRole('button', { name: /bind board/i }))
expect(await screen.findByTestId('board-connected')).toBeInTheDocument() expect(await screen.findByTestId('board-connected')).toBeInTheDocument()
expect(useSession.getState().device.connected).toBe(true) expect(useSession.getState().device.connected).toBe(true)
// the claim went out with the typed code + selected kit // code-first: the claim carries the code, no kit
const [, init] = fetchMock.mock.calls[0] const [, init] = fetchMock.mock.calls[0]
expect(JSON.parse(init.body)).toMatchObject({ kit: 'KIT-01', code: '418302' }) const body = JSON.parse(init.body)
expect(body).toMatchObject({ code: '4821' })
expect(body.kit).toBeUndefined()
}) })
it('surfaces a wrong-code error from the server', async () => { it('surfaces a wrong-code error from the server', async () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: 'wrong claim code' }) }), vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: 'wrong code — check your matrix' }) }),
) )
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
await user.type(screen.getByLabelText(/claim code/i), '000000') await user.type(screen.getByLabelText(/claim code/i), '0000')
await user.click(screen.getByRole('button', { name: /claim board/i })) await user.click(screen.getByRole('button', { name: /bind board/i }))
expect(await screen.findByRole('alert')).toHaveTextContent(/wrong claim code/i) expect(await screen.findByRole('alert')).toHaveTextContent(/wrong code/i)
expect(useSession.getState().device.connected).toBe(false) expect(useSession.getState().device.connected).toBe(false)
}) })
@@ -97,28 +92,18 @@ describe('TeamRegistration', () => {
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
await user.type(screen.getByLabelText(/team name/i), 'team_x') await user.type(screen.getByLabelText(/team name/i), 'team_x')
await user.type(screen.getByLabelText(/team member/i), 'A. Rossi{Enter}') await user.type(screen.getByLabelText('Member 1'), 'A. Rossi')
act(() => useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0 })) act(() => useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0 }))
await user.click(screen.getByRole('button', { name: /proceed/i })) await user.click(screen.getByRole('button', { name: /proceed/i }))
expect(useSession.getState().phases.reg).toBe(true) expect(useSession.getState().phases.reg).toBe(true)
}) })
it('pre-selects kit from the ?kit= URL param (QR sticker flow)', () => { it('pre-fills the claim code from the ?code= URL param', () => {
render( render(
<MemoryRouter initialEntries={['/workshop?kit=KIT-12']}> <MemoryRouter initialEntries={['/workshop?code=4821']}>
<TeamRegistration /> <TeamRegistration />
</MemoryRouter>, </MemoryRouter>,
) )
expect(useSession.getState().team.kit).toBe('KIT-12') expect(screen.getByLabelText(/claim code/i)).toHaveValue('4821')
expect(screen.getByRole('button', { name: 'KIT-12' })).toHaveAttribute('aria-pressed', 'true')
})
it('pre-fills the claim code from the ?code= URL param (full QR flow)', () => {
render(
<MemoryRouter initialEntries={['/workshop?kit=KIT-07&code=418302']}>
<TeamRegistration />
</MemoryRouter>,
)
expect(screen.getByLabelText(/claim code/i)).toHaveValue('418302')
}) })
}) })
+8 -21
View File
@@ -1,11 +1,9 @@
import { useEffect } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { MemberChips } from '@/components/MemberChips' import { MemberFields } from '@/components/MemberFields'
import { KitSelector } from '@/components/KitSelector'
import { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import { BoardClaim } from '@/components/BoardClaim' import { BoardClaim } from '@/components/BoardClaim'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
@@ -21,11 +19,6 @@ export function TeamRegistration() {
const resumeTeam = useSession((s) => s.resumeTeam) const resumeTeam = useSession((s) => s.resumeTeam)
const completePhase = useSession((s) => s.completePhase) const completePhase = useSession((s) => s.completePhase)
useEffect(() => {
const k = params.get('kit')
if (k && /^KIT-\d{2}$/.test(k)) setTeam({ kit: k })
}, [params, setTeam])
const ready = team.name.trim().length > 0 && team.members.length > 0 && device.connected const ready = team.name.trim().length > 0 && team.members.length > 0 && device.connected
const onProceed = () => { const onProceed = () => {
@@ -55,8 +48,8 @@ export function TeamRegistration() {
</Badge> </Badge>
<h1 className="text-3xl font-bold tracking-tight">Team registration</h1> <h1 className="text-3xl font-bold tracking-tight">Team registration</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl"> <p className="text-sm text-muted-foreground mt-2 max-w-xl">
Name your team, add 3–5 members, pick up your kit, and claim your board. Name your team, add 3–5 members, then bind the board you already set up this
The QR sticker on your kit pre-selects the kit number for you. week — run the setup script and enter the code it scrolls on its LED matrix.
</p> </p>
</div> </div>
</div> </div>
@@ -83,7 +76,7 @@ export function TeamRegistration() {
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground"> <div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Members Members
</div> </div>
<MemberChips <MemberFields
members={team.members} members={team.members}
onChange={(members) => setTeam({ members })} onChange={(members) => setTeam({ members })}
/> />
@@ -93,26 +86,20 @@ export function TeamRegistration() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Kit & device</CardTitle> <CardTitle className="text-base">Your board</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground"> <div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Select kit Bind your node
</div>
<KitSelector value={team.kit} onChange={(kit) => setTeam({ kit })} />
</div>
<div className="space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Board
</div> </div>
<BoardClaim <BoardClaim
teamId={teamId} teamId={teamId}
kit={team.kit}
teamName={team.name} teamName={team.name}
members={team.members}
connected={device.connected} connected={device.connected}
port={device.port} port={device.port}
initialCode={/^\d{6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined} initialCode={/^\d{4,6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
onClaimed={(r) => { onClaimed={(r) => {
// Resume (a lost-browser re-claim): adopt the board's canonical // Resume (a lost-browser re-claim): adopt the board's canonical
// team + restore its progress instead of keeping this fresh id. // team + restore its progress instead of keeping this fresh id.