feat(web): board claim wizard replaces Web-Serial connect (onboarding slice 2)

Attendee-facing half of the "preloaded + self-register + claim" flow.

- BoardClaim wizard: three physical bring-up steps (power → boot →
  enter code) + a claim-code field that calls POST /claim. On success
  the board shows as claimed; ClaimError surfaces the server's message
  (wrong code / not powered on / rate-limited).
- TeamRegistration: swaps the Web-Serial "connect device · 115200 baud"
  step for the wizard, with a "use the simulator instead" escape hatch
  (sim needs no board). serial.ts stays — Module1/2 still use it for the
  simulated sense path.
- EnvSetup: live self-test now polls GET /nodes/:teamId/status (board
  liveness) instead of reading Web-Serial frames; sim path unchanged.
- api client: claimBoard() + ClaimError + getNodeStatus().
- api: GET /nodes/:teamId/status (public per-team liveness).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-07 09:32:47 -07:00
co-authored by Claude Opus 4.8
parent 14e08b3623
commit 0442f7e865
8 changed files with 243 additions and 47 deletions
+10
View File
@@ -200,6 +200,16 @@ export function createApp(opts: AppOptions): Express {
res.status(202).json({ accepted: true }) res.status(202).json({ accepted: true })
}) })
// Public liveness for a team's board — the wizard/self-test polls this after
// a claim. Online reflects the bridge's live /health + SSE view.
app.get('/nodes/:teamId/status', (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const teamId = String(req.params.teamId)
const view = nodes.list().find((n) => n.teamId === teamId)
if (!view) return res.status(404).json({ error: 'no node registered for team' })
res.json({ teamId, online: view.online })
})
// Participant-scoped SSE: a team watches only its own board's activity // Participant-scoped SSE: a team watches only its own board's activity
// (the /ws hub is admin/judge only). Public, keyed by teamId. // (the /ws hub is admin/judge only). Public, keyed by teamId.
app.get('/nodes/:teamId/events', (req, res) => { app.get('/nodes/:teamId/events', (req, res) => {
+7
View File
@@ -97,6 +97,13 @@ describe('board self-register + claim', () => {
expect(events.some((e) => e.type === 'team:update' && e.team.id === 'team-07')).toBe(true) expect(events.some((e) => e.type === 'team:update' && e.team.id === 'team-07')).toBe(true)
}) })
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)
const res = await request(app).get('/nodes/team-07/status').expect(200)
expect(res.body).toEqual({ teamId: 'team-07', online: true })
})
it('is single-use — a second claim of the same kit 404s', async () => { it('is single-use — a second claim of the same kit 404s', async () => {
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)
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-08', code: '418302' }).expect(404) await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-08', code: '418302' }).expect(404)
+90
View File
@@ -0,0 +1,90 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { claimBoard, ClaimError, type ClaimResult } from '@/lib/api'
export interface BoardClaimProps {
teamId: string
kit: string
teamName: string
connected: boolean
port: string | null
onClaimed: (result: ClaimResult) => void
}
/** The three physical bring-up steps an attendee performs before claiming. */
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.',
]
/**
* 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).
*/
export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed }: BoardClaimProps) {
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
if (connected) {
return (
<div className="border border-teal/40 bg-teal/5 rounded-md px-4 py-3" data-testid="board-connected">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-teal animate-pulse" />
<span className="text-sm font-medium">Board claimed</span>
</div>
<div className="font-mono text-[10px] text-muted-foreground mt-1">{port}</div>
</div>
)
}
const claim = async () => {
const trimmed = code.trim()
if (!trimmed || busy) return
setBusy(true)
setError(null)
try {
const result = await claimBoard({ teamId, kit, teamName, code: trimmed })
onClaimed(result)
} catch (e) {
setError(e instanceof ClaimError ? e.message : 'Could not reach the workshop — check your connection.')
} finally {
setBusy(false)
}
}
return (
<div className="space-y-3">
<ol className="space-y-2">
{STEPS.map((s, i) => (
<li key={i} className="flex gap-2.5 text-xs text-muted-foreground leading-relaxed">
<span className="font-mono text-[10px] font-bold text-primary w-4 shrink-0">{i + 1}</span>
<span>{s}</span>
</li>
))}
</ol>
<div className="flex gap-2">
<Input
aria-label="Claim code"
inputMode="numeric"
placeholder="418302"
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'}
</Button>
</div>
{error && (
<p role="alert" className="text-xs text-red-500 leading-relaxed">
{error}
</p>
)}
</div>
)
}
+47
View File
@@ -69,6 +69,53 @@ export async function getLeaderboard(code: string): Promise<LeaderboardRow[]> {
return asJson(await fetch(`${API_BASE}/leaderboard`, { headers: authHeaders(code) }), 'getLeaderboard') return asJson(await fetch(`${API_BASE}/leaderboard`, { headers: authHeaders(code) }), 'getLeaderboard')
} }
// --- board onboarding (participant) ---------------------------------------
export interface ClaimInput {
teamId: string
kit: string
teamName?: string
code: string
}
export interface ClaimResult {
teamId: string
kit: string
online: boolean
}
/** A failed claim, carrying the HTTP status + the server's human message. */
export class ClaimError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.name = 'ClaimError'
this.status = status
}
}
/**
* Claim a powered-on board to this team by proving the kit's claim code. The
* bearer token stays server-side; success binds the board and brings it online.
*/
export async function claimBoard(input: ClaimInput): Promise<ClaimResult> {
const res = await fetch(`${API_BASE}/claim`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
})
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
throw new ClaimError(res.status, body.error ?? `claim ${res.status}`)
}
return (await res.json()) as ClaimResult
}
/** Poll a team's board liveness (used by the setup self-test). */
export async function getNodeStatus(teamId: string): Promise<{ teamId: string; online: boolean }> {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/status`)
if (!res.ok) throw new Error(`getNodeStatus ${res.status}`)
return (await res.json()) as { teamId: string; online: boolean }
}
// --- ZeroClaw node (participant) ------------------------------------------ // --- ZeroClaw node (participant) ------------------------------------------
/** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */ /** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */
export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> { export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> {
+10 -2
View File
@@ -4,6 +4,13 @@ import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import { EnvSetup } from './EnvSetup' import { EnvSetup } from './EnvSetup'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { getNodeStatus } from '@/lib/api'
vi.mock('@/lib/api', async (orig) => ({
...(await orig<typeof import('@/lib/api')>()),
getNodeStatus: vi.fn(),
}))
const mockNodeStatus = vi.mocked(getNodeStatus)
function renderPage() { function renderPage() {
return render( return render(
@@ -44,9 +51,10 @@ describe('EnvSetup', () => {
beforeEach(() => vi.useFakeTimers()) beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers()) afterEach(() => vi.useRealTimers())
it('passes once frames flow, enabling Proceed without polluting stats', async () => { it('passes once the board reports online, enabling Proceed without polluting stats', async () => {
mockNodeStatus.mockResolvedValue({ teamId: 't', online: true })
useSession.getState().setMode('live') useSession.getState().setMode('live')
useSession.getState().setDevice({ connected: true, port: 'mock-serial://uno-q', uptimeS: 0 }) useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0 })
renderPage() renderPage()
const proceed = screen.getByRole('button', { name: /proceed/i }) const proceed = screen.getByRole('button', { name: /proceed/i })
+18 -11
View File
@@ -6,15 +6,18 @@ import { Badge } from '@/components/ui/badge'
import { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import { HarnessProviderSelect } from '@/components/HarnessProviderSelect' import { HarnessProviderSelect } from '@/components/HarnessProviderSelect'
import { useSession, type RunMode } from '@/store/session' import { useSession, type RunMode } from '@/store/session'
import { requestPort } from '@/lib/serial' import { getNodeStatus } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type SelfTest = 'idle' | 'running' | 'ok' type SelfTest = 'idle' | 'running' | 'ok'
const SELFTEST_FRAMES = 3 /** Live self-test: poll the claimed board's liveness this many times. */
const SELFTEST_POLLS = 12
const SELFTEST_POLL_MS = 500
export function EnvSetup() { export function EnvSetup() {
const navigate = useNavigate() const navigate = useNavigate()
const teamId = useSession((s) => s.teamId)
const device = useSession((s) => s.device) const device = useSession((s) => s.device)
const mode = useSession((s) => s.mode) const mode = useSession((s) => s.mode)
const setMode = useSession((s) => s.setMode) const setMode = useSession((s) => s.setMode)
@@ -38,16 +41,20 @@ export function EnvSetup() {
setSelfTest('ok') setSelfTest('ok')
return return
} }
const conn = await requestPort() // Live: confirm the claimed board is reachable and online.
let count = 0 for (let i = 0; i < SELFTEST_POLLS; i++) {
const unsub = conn.onFrame(() => { try {
count += 1 const s = await getNodeStatus(teamId)
if (count >= SELFTEST_FRAMES) { if (s.online) {
unsub() setSelfTest('ok')
void conn.close() return
setSelfTest('ok') }
} catch {
/* board not registered yet / transient — keep polling */
} }
}) await new Promise((r) => setTimeout(r, SELFTEST_POLL_MS))
}
setSelfTest('idle') // couldn't confirm — let them retry
} }
const deviceReady = mode === 'sim' || device.connected const deviceReady = mode === 'sim' || device.connected
+40 -4
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
@@ -18,6 +18,9 @@ describe('TeamRegistration', () => {
useSession.getState().reset() useSession.getState().reset()
sessionStorage.clear() sessionStorage.clear()
}) })
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders the phase strip and the team form heading', () => { it('renders the phase strip and the team form heading', () => {
renderPage() renderPage()
@@ -40,7 +43,7 @@ describe('TeamRegistration', () => {
expect(useSession.getState().team.kit).toBe('KIT-04') expect(useSession.getState().team.kit).toBe('KIT-04')
}) })
it('gates the Proceed button until name + member + device 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()
const proceed = screen.getByRole('button', { name: /proceed/i }) const proceed = screen.getByRole('button', { name: /proceed/i })
@@ -53,16 +56,49 @@ describe('TeamRegistration', () => {
await user.type(memberInput, 'A. Rossi{Enter}') await user.type(memberInput, 'A. Rossi{Enter}')
expect(proceed).toBeDisabled() expect(proceed).toBeDisabled()
await user.click(screen.getByRole('button', { name: /connect device/i })) // the simulator escape hatch satisfies the board requirement without hardware
await user.click(screen.getByRole('button', { name: /use the simulator/i }))
expect(proceed).toBeEnabled() expect(proceed).toBeEnabled()
}) })
it('claims a board with the kit code and marks it connected', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ teamId: 't', kit: 'KIT-01', 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 }))
expect(await screen.findByTestId('board-connected')).toBeInTheDocument()
expect(useSession.getState().device.connected).toBe(true)
// the claim went out with the typed code + selected kit
const [, init] = fetchMock.mock.calls[0]
expect(JSON.parse(init.body)).toMatchObject({ kit: 'KIT-01', code: '418302' })
})
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' }) }),
)
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)
expect(useSession.getState().device.connected).toBe(false)
})
it('marks the reg phase complete when Proceed is clicked', async () => { it('marks the reg phase complete when Proceed is clicked', async () => {
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(/team member/i), 'A. Rossi{Enter}')
await user.click(screen.getByRole('button', { name: /connect device/i })) await user.click(screen.getByRole('button', { name: /use the simulator/i }))
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)
}) })
+21 -30
View File
@@ -7,12 +7,13 @@ import { Badge } from '@/components/ui/badge'
import { MemberChips } from '@/components/MemberChips' import { MemberChips } from '@/components/MemberChips'
import { KitSelector } from '@/components/KitSelector' import { KitSelector } from '@/components/KitSelector'
import { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import { BoardClaim } from '@/components/BoardClaim'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { requestPort, serialSupported } from '@/lib/serial'
export function TeamRegistration() { export function TeamRegistration() {
const navigate = useNavigate() const navigate = useNavigate()
const [params] = useSearchParams() const [params] = useSearchParams()
const teamId = useSession((s) => s.teamId)
const team = useSession((s) => s.team) const team = useSession((s) => s.team)
const device = useSession((s) => s.device) const device = useSession((s) => s.device)
const setTeam = useSession((s) => s.setTeam) const setTeam = useSession((s) => s.setTeam)
@@ -31,16 +32,7 @@ export function TeamRegistration() {
navigate('/workshop/setup') navigate('/workshop/setup')
} }
const onConnect = async () => { const useSimulator = () => setDevice({ connected: true, port: 'simulator · no board', uptimeS: 0 })
// Without Web Serial (tests, unsupported browsers) connect synchronously to
// the mock so the flow stays usable; otherwise prompt for the real board.
if (!serialSupported()) {
setDevice({ connected: true, port: 'mock-serial://uno-q', uptimeS: 0 })
return
}
const conn = await requestPort()
setDevice({ connected: true, port: conn.port, uptimeS: 0 })
}
return ( return (
<main className="min-h-screen bg-background"> <main className="min-h-screen bg-background">
@@ -64,7 +56,7 @@ 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 35 members, pick up your kit, and connect the board over USB. Name your team, add 35 members, pick up your kit, and claim your board.
The QR sticker on your kit pre-selects the kit number for you. The QR sticker on your kit pre-selects the kit number for you.
</p> </p>
</div> </div>
@@ -113,26 +105,25 @@ export function TeamRegistration() {
</div> </div>
<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">
Device Board
</div> </div>
{device.connected ? ( <BoardClaim
<div className="border border-teal/40 bg-teal/5 rounded-md px-4 py-3"> teamId={teamId}
<div className="flex items-center gap-2"> kit={team.kit}
<span className="w-2 h-2 rounded-full bg-teal animate-pulse" /> teamName={team.name}
<span className="text-sm font-medium">Connected</span> connected={device.connected}
</div> port={device.port}
<div className="font-mono text-[10px] text-muted-foreground mt-1">{device.port}</div> onClaimed={(r) => setDevice({ connected: true, port: `board · ${r.kit}`, uptimeS: 0 })}
</div> />
) : ( {!device.connected && (
<Button onClick={onConnect} variant="outline" className="w-full justify-start"> <button
<span className="w-2 h-2 rounded-full bg-muted-foreground mr-3" /> type="button"
Connect device · 115200 baud onClick={useSimulator}
</Button> className="font-mono text-[10px] text-muted-foreground hover:text-foreground underline"
>
No board yet? Use the simulator instead
</button>
)} )}
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
Uses the browser's Web Serial API to talk to the board over USB. Falls back to a
simulated device on browsers without Web Serial support.
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>