feat: participant flow screens setup/module1/module2/add — TDD

All four workshop phases are now real screens, replacing WorkshopStub:
- EnvSetup (/workshop/setup): device check + serial self-test (no stats
  pollution) + provider/model selection; gated Proceed
- Module1 (/workshop/module1): live IMU feed via useSerial, actor map,
  ADD Layer 1 capture; gated on observed frames + L1 goal
- Module2 (/workshop/module2): harness tuner + live TOML preview, test
  triggers that classify against the tuned threshold, ADD L2/L3
- AddBuilder (/workshop/add): ADD L4/L5, assembled AddDocument, print-to-PDF
  export, deterministic submission code; completes the run
- New components: ActorMap, HarnessTuner, TriggerButtons, AddDocument

23 new tests; suite 86/86 green, typecheck + lint clean, build OK.
Only /admin and /judge remain stubbed (need the backend).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-16 19:13:07 -07:00
co-authored by Claude Opus 4.8
parent 965af7e5c0
commit 7c141dcd89
14 changed files with 968 additions and 4 deletions
+8 -4
View File
@@ -2,6 +2,10 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { Landing } from '@/pages/Landing' import { Landing } from '@/pages/Landing'
import { TeamRegistration } from '@/pages/TeamRegistration' import { TeamRegistration } from '@/pages/TeamRegistration'
import { Lecture } from '@/pages/Lecture' import { Lecture } from '@/pages/Lecture'
import { EnvSetup } from '@/pages/EnvSetup'
import { Module1 } from '@/pages/Module1'
import { Module2 } from '@/pages/Module2'
import { AddBuilder } from '@/pages/AddBuilder'
import { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import type { PhaseKey } from '@/store/session' import type { PhaseKey } from '@/store/session'
@@ -25,10 +29,10 @@ export default function App() {
<Routes> <Routes>
<Route path="/" element={<Landing />} /> <Route path="/" element={<Landing />} />
<Route path="/workshop" element={<TeamRegistration />} /> <Route path="/workshop" element={<TeamRegistration />} />
<Route path="/workshop/setup" element={<WorkshopStub title="Environment setup" phase="setup" />} /> <Route path="/workshop/setup" element={<EnvSetup />} />
<Route path="/workshop/module1" element={<WorkshopStub title="Module 1 · Sense → Reason" phase="m1" />} /> <Route path="/workshop/module1" element={<Module1 />} />
<Route path="/workshop/module2" element={<WorkshopStub title="Module 2 · Harness engineering" phase="m2" />} /> <Route path="/workshop/module2" element={<Module2 />} />
<Route path="/workshop/add" element={<WorkshopStub title="ADD builder & submit" phase="add" />} /> <Route path="/workshop/add" element={<AddBuilder />} />
<Route path="/lecture" element={<Lecture />} /> <Route path="/lecture" element={<Lecture />} />
<Route path="/admin" element={<WorkshopStub title="Instructor dashboard" phase="reg" />} /> <Route path="/admin" element={<WorkshopStub title="Instructor dashboard" phase="reg" />} />
<Route path="/judge" element={<WorkshopStub title="Judge review" phase="reg" />} /> <Route path="/judge" element={<WorkshopStub title="Judge review" phase="reg" />} />
+28
View File
@@ -0,0 +1,28 @@
const NODES = [
{ label: 'IMU + mic', sub: 'sense' },
{ label: 'ZeroClaw', sub: 'reason · on-device' },
{ label: 'Cloud LLM', sub: 'escalate' },
{ label: 'Actuator', sub: 'act' },
]
/** Static sense → reason → act diagram for the perception loop. */
export function ActorMap() {
return (
<div
data-testid="actor-map"
className="flex flex-wrap items-stretch gap-2 border border-border rounded-md bg-card p-4"
>
{NODES.map((n, i) => (
<div key={n.label} className="flex items-center gap-2 flex-1 min-w-[120px]">
<div className="flex-1 rounded-md border border-border bg-background px-3 py-2 text-center">
<div className="text-sm font-semibold">{n.label}</div>
<div className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground mt-0.5">
{n.sub}
</div>
</div>
{i < NODES.length - 1 && <span className="text-primary font-bold shrink-0"></span>}
</div>
))}
</div>
)
}
+73
View File
@@ -0,0 +1,73 @@
import { type ReactNode } from 'react'
import { useSession } from '@/store/session'
import { harnessToToml } from '@/lib/harness'
function LayerBlock({ n, title, body }: { n: number; title: string; body: ReactNode }) {
return (
<div className="space-y-1 break-inside-avoid">
<div className="font-mono text-[10px] uppercase tracking-widest text-primary">
Layer {n} · {title}
</div>
<div className="text-sm leading-relaxed whitespace-pre-wrap">{body || <em className="text-muted-foreground"></em>}</div>
</div>
)
}
/**
* Print-styled render of the full Agent Design Document, assembled from the
* session store. Export = the browser's print-to-PDF over this element.
*/
export function AddDocument() {
const team = useSession((s) => s.team)
const add = useSession((s) => s.add)
const harness = useSession((s) => s.harness)
const stats = useSession((s) => s.stats)
const l1 = (add.L1 as Record<string, string> | null) ?? {}
return (
<article
id="add-document"
data-testid="add-document"
className="bg-card border border-border rounded-md p-6 space-y-5 print:border-0 print:p-0"
>
<header className="space-y-1 border-b border-border pb-4">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
APESS 2026 · Agent Design Document
</div>
<h3 className="text-xl font-bold tracking-tight">{team.name || 'Unnamed team'}</h3>
<div className="font-mono text-[11px] text-muted-foreground">
{team.kit} · {team.members.join(', ') || 'no members'}
</div>
</header>
<LayerBlock
n={1}
title="Perception & goal"
body={
Object.keys(l1).length
? Object.entries(l1)
.map(([k, v]) => `${k}: ${v}`)
.join('\n')
: ''
}
/>
<LayerBlock n={2} title="Reasoning policy" body={add.L2} />
<LayerBlock n={3} title="Action contract" body={add.L3} />
<LayerBlock n={4} title="Failure modes" body={add.L4} />
<LayerBlock n={5} title="AI-native redesign" body={add.L5} />
<div className="grid sm:grid-cols-2 gap-4 border-t border-border pt-4 break-inside-avoid">
<div className="space-y-1">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Harness</div>
<pre className="font-mono text-[11px] whitespace-pre">{harnessToToml(harness)}</pre>
</div>
<div className="space-y-1">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Session</div>
<div className="font-mono text-[11px]">
{stats.calls} frames · {stats.nominal} nominal · {stats.anomalous} anomalous · {stats.critical} critical
</div>
</div>
</div>
</article>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { Input } from '@/components/ui/input'
import { useSession } from '@/store/session'
const FIELDS: { key: 'thresholdG' | 'thresholdDb' | 'callsPerMinute'; label: string; step: number }[] = [
{ key: 'thresholdG', label: 'Threshold · g', step: 0.05 },
{ key: 'thresholdDb', label: 'Threshold · dB', step: 1 },
{ key: 'callsPerMinute', label: 'Calls / min', step: 1 },
]
/** Numeric tuner bound to the session harness thresholds. */
export function HarnessTuner() {
const harness = useSession((s) => s.harness)
const setHarness = useSession((s) => s.setHarness)
return (
<div className="grid sm:grid-cols-3 gap-4" data-testid="harness-tuner">
{FIELDS.map((f) => (
<div key={f.key} className="space-y-2">
<label htmlFor={`harness-${f.key}`} className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
{f.label}
</label>
<Input
id={`harness-${f.key}`}
type="number"
step={f.step}
value={harness[f.key]}
onChange={(e) => {
const n = Number(e.target.value)
if (Number.isFinite(n)) setHarness({ [f.key]: n })
}}
className="font-mono"
/>
</div>
))}
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TriggerButtons } from './TriggerButtons'
describe('TriggerButtons', () => {
it('fires an impact frame with high g and dB', async () => {
const user = userEvent.setup()
const onFire = vi.fn()
render(<TriggerButtons onFire={onFire} />)
await user.click(screen.getByRole('button', { name: /impact/i }))
expect(onFire).toHaveBeenCalledTimes(1)
const frame = onFire.mock.calls[0][0]
expect(frame.ax).toBeGreaterThan(0.8)
expect(frame.db).toBeGreaterThan(65)
})
it('fires a calm frame below thresholds', async () => {
const user = userEvent.setup()
const onFire = vi.fn()
render(<TriggerButtons onFire={onFire} />)
await user.click(screen.getByRole('button', { name: /calm/i }))
const frame = onFire.mock.calls[0][0]
expect(frame.ax).toBeLessThan(0.8)
expect(frame.db).toBeLessThan(65)
})
})
+31
View File
@@ -0,0 +1,31 @@
import { Button } from '@/components/ui/button'
import type { ImuFrame } from '@/lib/serial'
const TRIGGERS: { kind: string; label: string; frame: Omit<ImuFrame, 't'> }[] = [
{ kind: 'calm', label: 'Calm', frame: { ax: 0.05, ay: 0.02, az: 0.05, db: 38 } },
{ kind: 'shake', label: 'Shake', frame: { ax: 1.0, ay: 0.3, az: 0.2, db: 52 } },
{ kind: 'impact', label: 'Impact', frame: { ax: 1.6, ay: 0.8, az: 0.5, db: 82 } },
]
export interface TriggerButtonsProps {
onFire: (frame: ImuFrame) => void
}
/** Inject synthetic frames to exercise the just-tuned classifier. */
export function TriggerButtons({ onFire }: TriggerButtonsProps) {
return (
<div className="flex gap-2" data-testid="trigger-buttons">
{TRIGGERS.map((t) => (
<Button
key={t.kind}
variant="outline"
size="sm"
data-trigger={t.kind}
onClick={() => onFire({ ...t.frame, t: 0 })}
>
{t.label}
</Button>
))}
</div>
)
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { AddBuilder } from './AddBuilder'
import { useSession } from '@/store/session'
function renderPage() {
return render(
<MemoryRouter>
<AddBuilder />
</MemoryRouter>,
)
}
function seedFullAdd() {
const s = useSession.getState()
s.setTeam({ name: 'team_resonance', members: ['A'], kit: 'KIT-03' })
s.setAddLayer('L1', { goal: 'stay safe' })
s.setAddLayer('L2', 'reason')
s.setAddLayer('L3', 'act')
}
describe('AddBuilder', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
})
it('renders the phase strip set to add and the heading', () => {
renderPage()
expect(screen.getByRole('heading', { name: /ADD builder/i })).toBeInTheDocument()
expect(
screen.getByTestId('phase-strip').querySelector('[data-phase="add"]'),
).toHaveAttribute('data-state', 'active')
})
it('persists Layer 4 and 5 to the store', async () => {
const user = userEvent.setup()
renderPage()
await user.type(screen.getByLabelText(/layer 4/i), 'sensor drift unhandled')
await user.type(screen.getByLabelText(/layer 5/i), 'move judgement to edge')
expect(useSession.getState().add.L4).toBe('sensor drift unhandled')
expect(useSession.getState().add.L5).toBe('move judgement to edge')
})
it('renders the assembled document with all five layers', () => {
seedFullAdd()
useSession.getState().setAddLayer('L4', 'fail')
useSession.getState().setAddLayer('L5', 'redesign')
renderPage()
const doc = screen.getByTestId('add-document')
expect(doc).toHaveTextContent('stay safe')
expect(doc).toHaveTextContent('reason')
expect(doc).toHaveTextContent('act')
expect(doc).toHaveTextContent('fail')
expect(doc).toHaveTextContent('redesign')
})
it('exports via window.print', async () => {
const user = userEvent.setup()
const print = vi.fn()
window.print = print
renderPage()
await user.click(screen.getByRole('button', { name: /export pdf/i }))
expect(print).toHaveBeenCalled()
})
it('gates Submit until all five layers have content', async () => {
const user = userEvent.setup()
seedFullAdd()
renderPage()
const submit = screen.getByRole('button', { name: /submit add/i })
expect(submit).toBeDisabled()
await user.type(screen.getByLabelText(/layer 4/i), 'failure')
expect(submit).toBeDisabled()
await user.type(screen.getByLabelText(/layer 5/i), 'redesign')
expect(submit).toBeEnabled()
})
it('records a submission code and completes the phase on submit', async () => {
const user = userEvent.setup()
seedFullAdd()
useSession.getState().setAddLayer('L4', 'failure')
useSession.getState().setAddLayer('L5', 'redesign')
renderPage()
await user.click(screen.getByRole('button', { name: /submit add/i }))
const { submission, phases } = useSession.getState()
expect(submission.code).toMatch(/^KIT-03-/)
expect(submission.submittedAt).not.toBeNull()
expect(phases.add).toBe(true)
expect(screen.getByText(submission.code as string)).toBeInTheDocument()
})
})
+108
View File
@@ -0,0 +1,108 @@
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { PhaseStrip } from '@/components/PhaseStrip'
import { AddLayerForm } from '@/components/AddLayerForm'
import { AddDocument } from '@/components/AddDocument'
import { useSession } from '@/store/session'
import { makeSubmissionCode } from '@/lib/submission'
export function AddBuilder() {
const team = useSession((s) => s.team)
const add = useSession((s) => s.add)
const submission = useSession((s) => s.submission)
const setSubmission = useSession((s) => s.setSubmission)
const completePhase = useSession((s) => s.completePhase)
const l1 = add.L1 as { goal?: string } | null
const complete =
!!l1?.goal?.trim() &&
add.L2.trim().length > 0 &&
add.L3.trim().length > 0 &&
add.L4.trim().length > 0 &&
add.L5.trim().length > 0
const onExport = () => window.print()
const onSubmit = () => {
const code = makeSubmissionCode(team, add)
setSubmission({ code, submittedAt: new Date().toISOString() })
completePhase('add')
// best-effort push to the collective is wired in the sync layer (Part B)
}
const submitted = !!submission.code
return (
<main className="min-h-screen bg-background">
<header className="px-8 py-5 border-b border-border flex items-center justify-between print:hidden">
<div className="font-mono text-xs tracking-widest uppercase">
APESS <span className="text-primary font-bold">2026</span>
<span className="text-muted-foreground"> · Workshop</span>
</div>
<Link to="/workshop/module2" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
Module 2
</Link>
</header>
<div className="print:hidden">
<PhaseStrip active="add" />
</div>
<section className="px-8 py-10 max-w-5xl mx-auto space-y-6">
<div className="print:hidden">
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
Phase 5 of 5 · ~90 min · deadline 19:00
</Badge>
<h1 className="text-3xl font-bold tracking-tight">ADD builder &amp; submit</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
Finish Layers 4 and 5, review the assembled Agent Design Document, export a PDF, and submit
before the deadline.
</p>
</div>
<div className="grid lg:grid-cols-2 gap-6 print:hidden">
<AddLayerForm
layer="L4"
title="ADD · Layer 4 — Failure modes"
description="Name a way the agent fails, and the degradation you designed for it."
placeholder="Sensor drift reads as calm → cross-check acoustic; stale frame → hold last critical, never assume nominal."
/>
<AddLayerForm
layer="L5"
title="ADD · Layer 5 — AI-native redesign"
description="Redraw the edge/cloud boundary on purpose, justified by your failure modes."
placeholder="Keep the safety verdict on-device for outage survival; batch a cloud summary hourly."
/>
</div>
<Card className="print:border-0 print:shadow-none">
<CardHeader className="print:hidden flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Assembled document</CardTitle>
<Button variant="outline" size="sm" onClick={onExport}>Export PDF</Button>
</CardHeader>
<CardContent className="print:p-0">
<AddDocument />
</CardContent>
</Card>
<div className="flex items-center justify-between gap-4 pt-2 print:hidden">
{submitted ? (
<div className="border border-teal/40 bg-teal/5 rounded-md px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Submitted</div>
<div className="font-mono text-sm font-bold text-teal">{submission.code}</div>
</div>
) : (
<span className="text-xs text-muted-foreground">
{complete ? 'All five layers complete — ready to submit.' : 'Complete all five layers to submit.'}
</span>
)}
<Button size="lg" disabled={!complete || submitted} onClick={onSubmit}>
{submitted ? 'Submitted ✓' : 'Submit ADD'}
</Button>
</div>
</section>
</main>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { EnvSetup } from './EnvSetup'
import { useSession } from '@/store/session'
function renderPage() {
return render(
<MemoryRouter>
<EnvSetup />
</MemoryRouter>,
)
}
describe('EnvSetup', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
})
it('renders the phase strip set to setup and the heading', () => {
renderPage()
expect(screen.getByTestId('phase-strip')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /environment setup/i })).toBeInTheDocument()
expect(
screen.getByTestId('phase-strip').querySelector('[data-phase="setup"]'),
).toHaveAttribute('data-state', 'active')
})
it('warns and disables Proceed when the device is not connected', () => {
renderPage()
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
})
it('writes the chosen provider to the harness', async () => {
const user = userEvent.setup()
renderPage()
await user.click(screen.getByRole('button', { name: /groq/i }))
expect(useSession.getState().harness.provider).toBe('groq')
})
describe('self-test', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('passes once frames flow, enabling Proceed without polluting stats', async () => {
useSession.getState().setDevice({ connected: true, port: 'mock-serial://uno-r4-wifi', uptimeS: 0 })
renderPage()
const proceed = screen.getByRole('button', { name: /proceed/i })
expect(proceed).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: /run self-test/i }))
await act(async () => {
await vi.advanceTimersByTimeAsync(2000)
})
expect(screen.getByText(/self-test ok/i)).toBeInTheDocument()
expect(proceed).toBeEnabled()
// self-test frames must NOT be counted as workshop events
expect(useSession.getState().stats.calls).toBe(0)
})
})
it('keeps Proceed gated when connected but self-test has not run', () => {
useSession.getState().setDevice({ connected: true, port: 'x', uptimeS: 0 })
renderPage()
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
})
})
+136
View File
@@ -0,0 +1,136 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { PhaseStrip } from '@/components/PhaseStrip'
import { HarnessProviderSelect } from '@/components/HarnessProviderSelect'
import { useSession } from '@/store/session'
import { requestPort } from '@/lib/serial'
import { cn } from '@/lib/utils'
type SelfTest = 'idle' | 'running' | 'ok'
const SELFTEST_FRAMES = 3
export function EnvSetup() {
const navigate = useNavigate()
const device = useSession((s) => s.device)
const harness = useSession((s) => s.harness)
const completePhase = useSession((s) => s.completePhase)
const [selfTest, setSelfTest] = useState<SelfTest>('idle')
const runSelfTest = async () => {
setSelfTest('running')
const conn = await requestPort()
let count = 0
const unsub = conn.onFrame(() => {
count += 1
if (count >= SELFTEST_FRAMES) {
unsub()
void conn.close()
setSelfTest('ok')
}
})
}
const ready = device.connected && selfTest === 'ok' && !!harness.provider && !!harness.model
const onProceed = () => {
completePhase('setup')
navigate('/workshop/module1')
}
return (
<main className="min-h-screen bg-background">
<header className="px-8 py-5 border-b border-border flex items-center justify-between">
<div className="font-mono text-xs tracking-widest uppercase">
APESS <span className="text-primary font-bold">2026</span>
<span className="text-muted-foreground"> · Workshop</span>
</div>
<Link to="/workshop" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
Team registration
</Link>
</header>
<PhaseStrip active="setup" />
<section className="px-8 py-10 max-w-5xl mx-auto space-y-6">
<div>
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
Phase 2 of 5 · ~15 min
</Badge>
<h1 className="text-3xl font-bold tracking-tight">Environment setup</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
Confirm the board is alive, prove the sense path with a self-test, and choose the reasoning
provider your agent will escalate to. Then you are clear for Module 1.
</p>
</div>
<div className="grid lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Board &amp; sense path</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<div className="space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Device</div>
{device.connected ? (
<div className="border border-teal/40 bg-teal/5 rounded-md px-4 py-3">
<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">Connected</span>
</div>
<div className="font-mono text-[10px] text-muted-foreground mt-1">{device.port}</div>
</div>
) : (
<div className="border border-amber/40 bg-amber/5 rounded-md px-4 py-3 text-sm">
No device. Go back to{' '}
<Link to="/workshop" className="underline">team registration</Link> and connect the board.
</div>
)}
</div>
<div className="space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Self-test</div>
<Button
variant="outline"
className="w-full justify-start"
disabled={!device.connected || selfTest === 'running'}
onClick={runSelfTest}
>
<span
className={cn(
'w-2 h-2 rounded-full mr-3',
selfTest === 'ok' ? 'bg-teal' : selfTest === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground',
)}
/>
{selfTest === 'ok' ? 'Self-test OK' : selfTest === 'running' ? 'Reading frames…' : 'Run self-test'}
</Button>
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
Reads a few live frames to confirm the sense path before Module 1. Does not count toward your
session stats.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Reasoning harness</CardTitle>
</CardHeader>
<CardContent>
<HarnessProviderSelect />
</CardContent>
</Card>
</div>
<div className="flex justify-end pt-4">
<Button size="lg" disabled={!ready} onClick={onProceed}>
Proceed to Module 1
</Button>
</div>
</section>
</main>
)
}
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { Module1 } from './Module1'
import { useSession } from '@/store/session'
function renderPage() {
return render(
<MemoryRouter>
<Module1 />
</MemoryRouter>,
)
}
describe('Module1', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
})
it('renders the phase strip set to m1 and the heading', () => {
renderPage()
expect(screen.getByRole('heading', { name: /sense.*reason/i })).toBeInTheDocument()
expect(
screen.getByTestId('phase-strip').querySelector('[data-phase="m1"]'),
).toHaveAttribute('data-state', 'active')
})
it('renders the actor map and a live feed', () => {
renderPage()
expect(screen.getByTestId('actor-map')).toBeInTheDocument()
expect(screen.getByTestId('live-feed')).toBeInTheDocument()
})
it('gates Proceed until frames are observed and L1 is filled', async () => {
const user = userEvent.setup()
renderPage()
const proceed = screen.getByRole('button', { name: /proceed/i })
expect(proceed).toBeDisabled()
await user.type(screen.getByLabelText(/goal/i), 'keep the structure safe')
expect(proceed).toBeDisabled() // still no frames
})
describe('with the feed running', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('streams frames into stats and enables Proceed when L1 is set', async () => {
useSession.getState().setAddLayer('L1', { goal: 'keep safe' })
renderPage()
fireEvent.click(screen.getByRole('button', { name: /start feed/i }))
await act(async () => {
await vi.advanceTimersByTimeAsync(2000)
})
expect(useSession.getState().stats.calls).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: /proceed/i })).toBeEnabled()
})
})
it('persists the Layer-1 goal to the store', async () => {
const user = userEvent.setup()
renderPage()
await user.type(screen.getByLabelText(/goal/i), 'detect resonance')
expect((useSession.getState().add.L1 as { goal: string }).goal).toBe('detect resonance')
})
})
+98
View File
@@ -0,0 +1,98 @@
import { Link, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { PhaseStrip } from '@/components/PhaseStrip'
import { LiveFeed } from '@/components/LiveFeed'
import { StatsTally } from '@/components/StatsTally'
import { ActorMap } from '@/components/ActorMap'
import { AddLayerForm } from '@/components/AddLayerForm'
import { useSerial } from '@/lib/useSerial'
import { useSession } from '@/store/session'
export function Module1() {
const navigate = useNavigate()
const serial = useSerial()
const stats = useSession((s) => s.stats)
const l1 = useSession((s) => s.add.L1) as { goal?: string } | null
const completePhase = useSession((s) => s.completePhase)
const ready = stats.calls > 0 && !!l1?.goal?.trim()
const onProceed = () => {
completePhase('m1')
navigate('/workshop/module2')
}
return (
<main className="min-h-screen bg-background">
<header className="px-8 py-5 border-b border-border flex items-center justify-between">
<div className="font-mono text-xs tracking-widest uppercase">
APESS <span className="text-primary font-bold">2026</span>
<span className="text-muted-foreground"> · Workshop</span>
</div>
<Link to="/workshop/setup" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
Environment setup
</Link>
</header>
<PhaseStrip active="m1" />
<section className="px-8 py-10 max-w-5xl mx-auto space-y-6">
<div>
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
Phase 3 of 5 · ~75 min
</Badge>
<h1 className="text-3xl font-bold tracking-tight">Module 1 · Sense Reason</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
Watch the perception loop run live, then draft Layer 1 of your Agent Design Document what the
agent senses, the actors it must track, and the goal it pursues.
</p>
</div>
<div className="grid lg:grid-cols-2 gap-6">
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Live IMU feed</CardTitle>
{!serial.connected ? (
<Button size="sm" onClick={() => void serial.connect()}>Start feed</Button>
) : (
<Button size="sm" variant="outline" onClick={() => void serial.disconnect()}>Stop</Button>
)}
</CardHeader>
<CardContent className="space-y-4">
<LiveFeed last={serial.last} frames={serial.frames} connected={serial.connected} mocked={serial.mocked} />
<StatsTally />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Actor map</CardTitle>
</CardHeader>
<CardContent>
<ActorMap />
</CardContent>
</Card>
</div>
<AddLayerForm
layer="L1"
title="ADD · Layer 1 — Perception &amp; goal"
description="Who and what does the agent perceive, and what is it trying to achieve?"
fields={[
{ key: 'goal', label: 'Goal', placeholder: 'keep the structure within safe resonance' },
{ key: 'perception', label: 'Perception inputs', placeholder: 'tri-axis IMU, acoustic level' },
{ key: 'actors', label: 'Actors / entities', placeholder: 'the structure, the operator, the agent' },
]}
/>
<div className="flex justify-end pt-2">
<Button size="lg" disabled={!ready} onClick={onProceed}>
Proceed to Module 2
</Button>
</div>
</section>
</main>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { Module2 } from './Module2'
import { useSession } from '@/store/session'
function renderPage() {
return render(
<MemoryRouter>
<Module2 />
</MemoryRouter>,
)
}
describe('Module2', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
})
it('renders the phase strip set to m2 and the heading', () => {
renderPage()
expect(screen.getByRole('heading', { name: /harness engineering/i })).toBeInTheDocument()
expect(
screen.getByTestId('phase-strip').querySelector('[data-phase="m2"]'),
).toHaveAttribute('data-state', 'active')
})
it('tunes a threshold and reflects it in the TOML preview', async () => {
const user = userEvent.setup()
renderPage()
const input = screen.getByLabelText(/threshold · g/i)
await user.clear(input)
await user.type(input, '1.2')
expect(useSession.getState().harness.thresholdG).toBe(1.2)
expect(screen.getByTestId('harness-toml')).toHaveTextContent('threshold_g = 1.2')
})
it('fires a trigger that classifies against the tuned threshold', async () => {
const user = userEvent.setup()
renderPage()
// tune low so the impact frame is unambiguously critical
const g = screen.getByLabelText(/threshold · g/i)
await user.clear(g)
await user.type(g, '0.5')
await user.click(screen.getByRole('button', { name: /impact/i }))
expect(useSession.getState().stats.critical).toBe(1)
})
it('gates Proceed until a trigger fired and L2 + L3 are filled', async () => {
const user = userEvent.setup()
renderPage()
const proceed = screen.getByRole('button', { name: /proceed/i })
expect(proceed).toBeDisabled()
await user.type(screen.getByLabelText(/layer 2/i), 'escalate on critical')
await user.type(screen.getByLabelText(/layer 3/i), 'drive damper on critical')
expect(proceed).toBeDisabled() // no trigger fired yet
await user.click(screen.getByRole('button', { name: /shake/i }))
expect(proceed).toBeEnabled()
})
it('marks m2 complete on Proceed', async () => {
const user = userEvent.setup()
renderPage()
await user.type(screen.getByLabelText(/layer 2/i), 'L2 text')
await user.type(screen.getByLabelText(/layer 3/i), 'L3 text')
await user.click(screen.getByRole('button', { name: /impact/i }))
await user.click(screen.getByRole('button', { name: /proceed/i }))
expect(useSession.getState().phases.m2).toBe(true)
})
})
+110
View File
@@ -0,0 +1,110 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { PhaseStrip } from '@/components/PhaseStrip'
import { HarnessTuner } from '@/components/HarnessTuner'
import { HarnessTomlPreview } from '@/components/HarnessTomlPreview'
import { TriggerButtons } from '@/components/TriggerButtons'
import { LiveFeed } from '@/components/LiveFeed'
import { StatsTally } from '@/components/StatsTally'
import { AddLayerForm } from '@/components/AddLayerForm'
import { useSerial } from '@/lib/useSerial'
import { useSession } from '@/store/session'
export function Module2() {
const navigate = useNavigate()
const serial = useSerial()
const l2 = useSession((s) => s.add.L2)
const l3 = useSession((s) => s.add.L3)
const completePhase = useSession((s) => s.completePhase)
// gate on a trigger fired on *this* screen, so prior-module frames don't count
const [fired, setFired] = useState(false)
const onFire = (frame: Parameters<typeof serial.inject>[0]) => {
serial.inject(frame)
setFired(true)
}
const ready = fired && l2.trim().length > 0 && l3.trim().length > 0
const onProceed = () => {
completePhase('m2')
navigate('/workshop/add')
}
return (
<main className="min-h-screen bg-background">
<header className="px-8 py-5 border-b border-border flex items-center justify-between">
<div className="font-mono text-xs tracking-widest uppercase">
APESS <span className="text-primary font-bold">2026</span>
<span className="text-muted-foreground"> · Workshop</span>
</div>
<Link to="/workshop/module1" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
Module 1
</Link>
</header>
<PhaseStrip active="m2" />
<section className="px-8 py-10 max-w-5xl mx-auto space-y-6">
<div>
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
Phase 4 of 5 · ~90 min
</Badge>
<h1 className="text-3xl font-bold tracking-tight">Module 2 · Harness engineering</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
Tune the harness, fire test triggers, and watch a frame cross your threshold. Then capture
Layers 2 and 3 how the agent reasons and how it acts.
</p>
</div>
<div className="grid lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Tune the harness</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<HarnessTuner />
<HarnessTomlPreview />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Fire test triggers</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<TriggerButtons onFire={onFire} />
<LiveFeed last={serial.last} frames={serial.frames} connected={serial.connected || fired} mocked={serial.mocked} />
<StatsTally />
</CardContent>
</Card>
</div>
<div className="grid lg:grid-cols-2 gap-6">
<AddLayerForm
layer="L2"
title="ADD · Layer 2 — Reasoning policy"
description="How does the agent decide a verdict from a frame?"
placeholder="Classify each frame; escalate ambiguous cases to the cloud within the call budget."
/>
<AddLayerForm
layer="L3"
title="ADD · Layer 3 — Action contract"
description="What does the agent do for each verdict?"
placeholder="nominal → log; anomalous → alert; critical → drive damper + alert operator."
/>
</div>
<div className="flex justify-end pt-2">
<Button size="lg" disabled={!ready} onClick={onProceed}>
Proceed to ADD builder
</Button>
</div>
</section>
</main>
)
}