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:
co-authored by
Claude Opus 4.8
parent
965af7e5c0
commit
7c141dcd89
+8
-4
@@ -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" />} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 & 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 & 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 & 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user