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) => {
if (!boards || !nodes) return res.status(503).json({ error: 'claim unavailable' })
const b = req.body ?? {}
if (typeof b.kit !== 'string' || typeof b.teamId !== 'string' || typeof b.code !== 'string') {
return res.status(400).json({ error: 'kit, teamId and code are required' })
if (typeof b.teamId !== 'string' || typeof b.code !== 'string') {
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.reason === 'unknown') {
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') {
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)
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 = {
id: teamId,
name: pickName ?? '',
kit: b.kit,
kit: result.board.kitId,
members: pickMembers ?? [],
domain: typeof prev?.domain === 'string' ? prev.domain : '',
phases: { ...emptyPhases, ...(prev?.phases ?? {}) },
@@ -208,7 +213,7 @@ export function createApp(opts: AppOptions): Express {
broadcast({ type: 'team:update', team })
broadcastUnclaimed() // the claimed kit left the pool
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
+14
View File
@@ -112,6 +112,20 @@ describe('board self-register + claim', () => {
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 () => {
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)
+19
View File
@@ -51,6 +51,25 @@ describe('board registry', () => {
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)', () => {
const r = createBoardRegistry()
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.
*/
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(kitId: string): string | null
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
},
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) {
const board = boards.get(kitId)
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 {
teamId: string
kit: string
teamName: string
members: string[]
connected: boolean
port: string | null
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
}
/** 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 = [
'Plug your Uno Q into power over USB-C — the 13×8 matrix lights up.',
'Wait ~30 s for it to boot and join the workshop network.',
'Enter the 6-digit claim code printed on your kit sticker.',
'On your board, open a terminal and run the workshop setup script (below).',
'It checks your node is ready, registers it, and scrolls a code across the LED matrix.',
'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
* and claims it to their team by proving the kit's claim code. On success the
* board is bound server-side (its bearer token never touches the browser).
* The board bring-up wizard for pre-deployed devices: the attendee runs the
* setup script on their own Uno Q, which self-registers the node and shows a
* 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 [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -49,7 +52,7 @@ export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed,
setBusy(true)
setError(null)
try {
const result = await claimBoard({ teamId, kit, teamName, code: trimmed })
const result = await claimBoard({ teamId, teamName, members, code: trimmed })
onClaimed(result)
} catch (e) {
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>
))}
</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">
<Input
aria-label="Claim code"
inputMode="numeric"
placeholder="418302"
placeholder="code on your matrix"
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && claim()}
className="font-mono"
/>
<Button onClick={claim} disabled={busy || !code.trim()}>
{busy ? 'Claiming…' : 'Claim board'}
{busy ? 'Binding…' : 'Bind board'}
</Button>
</div>
{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) ---------------------------------------
export interface ClaimInput {
teamId: string
kit: string
/** Optional legacy sticker path; omit for code-first (board shows its code). */
kit?: string
teamName?: string
members?: string[]
code: string
}
export interface ClaimResult {
+18 -33
View File
@@ -36,13 +36,6 @@ describe('TeamRegistration', () => {
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 () => {
const user = userEvent.setup()
renderPage()
@@ -52,8 +45,8 @@ describe('TeamRegistration', () => {
await user.type(screen.getByLabelText(/team name/i), 'team_x')
expect(proceed).toBeDisabled()
const memberInput = screen.getByLabelText(/team member/i)
await user.type(memberInput, 'A. Rossi{Enter}')
const memberInput = screen.getByLabelText('Member 1')
await user.type(memberInput, 'A. Rossi')
expect(proceed).toBeDisabled()
// a claimed board satisfies the device requirement
@@ -61,35 +54,37 @@ describe('TeamRegistration', () => {
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({
ok: true,
json: async () => ({ teamId: 't', kit: 'KIT-01', online: true }),
json: async () => ({ teamId: 't', kit: 'crimson-otter', online: true }),
})
vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup()
renderPage()
await user.type(screen.getByLabelText(/claim code/i), '418302')
await user.click(screen.getByRole('button', { name: /claim board/i }))
await user.type(screen.getByLabelText(/claim code/i), '4821')
await user.click(screen.getByRole('button', { name: /bind board/i }))
expect(await screen.findByTestId('board-connected')).toBeInTheDocument()
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]
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 () => {
vi.stubGlobal(
'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()
renderPage()
await user.type(screen.getByLabelText(/claim code/i), '000000')
await user.click(screen.getByRole('button', { name: /claim board/i }))
expect(await screen.findByRole('alert')).toHaveTextContent(/wrong claim code/i)
await user.type(screen.getByLabelText(/claim code/i), '0000')
await user.click(screen.getByRole('button', { name: /bind board/i }))
expect(await screen.findByRole('alert')).toHaveTextContent(/wrong code/i)
expect(useSession.getState().device.connected).toBe(false)
})
@@ -97,28 +92,18 @@ describe('TeamRegistration', () => {
const user = userEvent.setup()
renderPage()
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 }))
await user.click(screen.getByRole('button', { name: /proceed/i }))
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(
<MemoryRouter initialEntries={['/workshop?kit=KIT-12']}>
<MemoryRouter initialEntries={['/workshop?code=4821']}>
<TeamRegistration />
</MemoryRouter>,
)
expect(useSession.getState().team.kit).toBe('KIT-12')
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')
expect(screen.getByLabelText(/claim code/i)).toHaveValue('4821')
})
})
+8 -21
View File
@@ -1,11 +1,9 @@
import { useEffect } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { MemberChips } from '@/components/MemberChips'
import { KitSelector } from '@/components/KitSelector'
import { MemberFields } from '@/components/MemberFields'
import { PhaseStrip } from '@/components/PhaseStrip'
import { BoardClaim } from '@/components/BoardClaim'
import { useSession } from '@/store/session'
@@ -21,11 +19,6 @@ export function TeamRegistration() {
const resumeTeam = useSession((s) => s.resumeTeam)
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 onProceed = () => {
@@ -55,8 +48,8 @@ export function TeamRegistration() {
</Badge>
<h1 className="text-3xl font-bold tracking-tight">Team registration</h1>
<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.
The QR sticker on your kit pre-selects the kit number for you.
Name your team, add 3–5 members, then bind the board you already set up this
week — run the setup script and enter the code it scrolls on its LED matrix.
</p>
</div>
</div>
@@ -83,7 +76,7 @@ export function TeamRegistration() {
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Members
</div>
<MemberChips
<MemberFields
members={team.members}
onChange={(members) => setTeam({ members })}
/>
@@ -93,26 +86,20 @@ export function TeamRegistration() {
<Card>
<CardHeader>
<CardTitle className="text-base">Kit & device</CardTitle>
<CardTitle className="text-base">Your board</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Select kit
</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
Bind your node
</div>
<BoardClaim
teamId={teamId}
kit={team.kit}
teamName={team.name}
members={team.members}
connected={device.connected}
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) => {
// Resume (a lost-browser re-claim): adopt the board's canonical
// team + restore its progress instead of keeping this fresh id.