feat(web): two-pane "cockpit" redesign with a live board rail
Restructure the workshop flow into the designer's cockpit: a persistent shell (header + 5-step stepper + sticky instrument rail) wrapping the phase routes via a React-Router layout route, so the rail stays mounted across navigation. - Design system: IBM Plex Mono + Newsreader; the full cockpit token set (light + dark) in index.css; a working light/dark theme toggle (store `theme` + useApplyTheme); a `switch` ui primitive. - Shell: CockpitLayout, Stepper (forward-gated), PanelChrome helpers. Every phase page restyled to the editorial panels + the WORKSHOP-FLOW fixes (channels-after-bind, domain framing + L1 prefill, L2/L3 prefill at 3/3, in-place submission finale). Store gains `tried` + `channels.saidHi` (v4). - Live rail (CockpitRail): real node heartbeat + agent activity log + ADD progress; sim telemetry (useTelemetry) for the waveform/accel/I2C behind a seam, marked SIM. - LED-matrix PIXEL MIRROR (real): the rail shows exactly what the physical matrix displays — API GET /nodes/:team/matrix reads the board's framebuffer off the :9999 relay (readMatrixFrame + the `matrixget` relay command); useMatrixMirror polls it and unpacks the 104 bits. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6c82b79b01
commit
1a39457940
@@ -4,6 +4,7 @@ import type { Store } from './db'
|
|||||||
import { requireCode, requireAnyCode, matches } from './auth'
|
import { requireCode, requireAnyCode, matches } from './auth'
|
||||||
import type { TeamSnapshot, SubmissionDTO, WsEvent } from './types'
|
import type { TeamSnapshot, SubmissionDTO, WsEvent } from './types'
|
||||||
import type { NodeBridge } from './nodes'
|
import type { NodeBridge } from './nodes'
|
||||||
|
import { readMatrixFrame } from './nodes'
|
||||||
import type { BoardRegistry } from './claim'
|
import type { BoardRegistry } from './claim'
|
||||||
|
|
||||||
export interface AppOptions {
|
export interface AppOptions {
|
||||||
@@ -337,6 +338,22 @@ export function createApp(opts: AppOptions): Express {
|
|||||||
res.json({ teamId, url: view.url, online: view.online })
|
res.json({ teamId, url: view.url, online: view.online })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Live LED-matrix mirror: the board's current framebuffer (32 hex chars) so the
|
||||||
|
// dashboard rail can show exactly what the physical 13×8 matrix is displaying.
|
||||||
|
app.get('/nodes/:teamId/matrix', async (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' })
|
||||||
|
try {
|
||||||
|
const hex = await readMatrixFrame(view)
|
||||||
|
if (!/^[0-9a-fA-F]{32}$/.test(hex)) return res.status(502).json({ error: 'bad matrix frame' })
|
||||||
|
res.json({ teamId, hex })
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ error: 'matrix read failed' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 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) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import net from 'node:net'
|
||||||
import type { WsEvent, NodeActivityKind } from './types'
|
import type { WsEvent, NodeActivityKind } from './types'
|
||||||
|
|
||||||
/** A team's ZeroClaw node: gateway URL + its server-side bearer token. */
|
/** A team's ZeroClaw node: gateway URL + its server-side bearer token. */
|
||||||
@@ -7,6 +8,36 @@ export interface NodeRef {
|
|||||||
token: string
|
token: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the board's CURRENT LED-matrix framebuffer for a pixel-perfect mirror.
|
||||||
|
* The matrix responder exposes it over the :9999 line-protocol relay (published
|
||||||
|
* on the board, same host as the gateway) via the `matrixget` command, which
|
||||||
|
* returns 32 hex chars (4×uint32, MSB-first; first 104 bits = the real pixels).
|
||||||
|
*/
|
||||||
|
export function readMatrixFrame(node: Pick<NodeRef, 'url'>, timeoutMs = 1500): Promise<string> {
|
||||||
|
const host = new URL(node.url).hostname
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const sock = net.createConnection({ host, port: 9999 })
|
||||||
|
let buf = ''
|
||||||
|
let settled = false
|
||||||
|
const finish = (err?: Error, val?: string) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
sock.destroy()
|
||||||
|
err ? reject(err) : resolve(val as string)
|
||||||
|
}
|
||||||
|
sock.setTimeout(timeoutMs)
|
||||||
|
sock.on('connect', () => sock.write('matrixget\n'))
|
||||||
|
sock.on('data', (d) => {
|
||||||
|
buf += d.toString()
|
||||||
|
const nl = buf.indexOf('\n')
|
||||||
|
if (nl >= 0) finish(undefined, buf.slice(0, nl).trim())
|
||||||
|
})
|
||||||
|
sock.on('timeout', () => finish(new Error('matrix relay timeout')))
|
||||||
|
sock.on('error', (e) => finish(e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export interface NodeRegistry {
|
export interface NodeRegistry {
|
||||||
register(ref: NodeRef): void
|
register(ref: NodeRef): void
|
||||||
get(teamId: string): NodeRef | undefined
|
get(teamId: string): NodeRef | undefined
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ def handle(conn):
|
|||||||
elif cmd == "count" and len(parts) >= 2:
|
elif cmd == "count" and len(parts) >= 2:
|
||||||
Bridge.call("matrix_count", int(parts[1]))
|
Bridge.call("matrix_count", int(parts[1]))
|
||||||
conn.sendall(b"ok\n")
|
conn.sendall(b"ok\n")
|
||||||
|
elif cmd == "matrixget":
|
||||||
|
r = Bridge.call("matrix_get")
|
||||||
|
conn.sendall(f"{r}\n".encode())
|
||||||
elif cmd == "i2c":
|
elif cmd == "i2c":
|
||||||
r = Bridge.call("i2c_scan")
|
r = Bridge.call("i2c_scan")
|
||||||
conn.sendall(f"{r}\n".encode())
|
conn.sendall(f"{r}\n".encode())
|
||||||
|
|||||||
+4
-2
@@ -6,16 +6,18 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="APESS 2026 Workshop — on-device agentic systems for structural intelligence. FabLab Torino, July 27." />
|
<meta name="description" content="APESS 2026 Workshop — on-device agentic systems for structural intelligence. FabLab Torino, July 27." />
|
||||||
<title>APESS 2026 · Workshop</title>
|
<title>APESS 2026 · Workshop</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
as="style"
|
as="style"
|
||||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=Newsreader:wght@400;600;700&display=swap"
|
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&display=swap"
|
||||||
onload="this.onload=null;this.rel='stylesheet'"
|
onload="this.onload=null;this.rel='stylesheet'"
|
||||||
/>
|
/>
|
||||||
<noscript>
|
<noscript>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=Newsreader:wght@400;600;700&display=swap"
|
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&display=swap"
|
||||||
/>
|
/>
|
||||||
</noscript>
|
</noscript>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+11
-5
@@ -8,19 +8,25 @@ import { Module2 } from '@/pages/Module2'
|
|||||||
import { AddBuilder } from '@/pages/AddBuilder'
|
import { AddBuilder } from '@/pages/AddBuilder'
|
||||||
import { Admin } from '@/pages/Admin'
|
import { Admin } from '@/pages/Admin'
|
||||||
import { Judge } from '@/pages/Judge'
|
import { Judge } from '@/pages/Judge'
|
||||||
|
import { CockpitLayout } from '@/components/cockpit/CockpitLayout'
|
||||||
import { useCollectiveSync } from '@/lib/useCollectiveSync'
|
import { useCollectiveSync } from '@/lib/useCollectiveSync'
|
||||||
|
import { useApplyTheme } from '@/lib/useApplyTheme'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
useCollectiveSync()
|
useCollectiveSync()
|
||||||
|
useApplyTheme()
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Landing />} />
|
<Route path="/" element={<Landing />} />
|
||||||
<Route path="/workshop" element={<TeamRegistration />} />
|
{/* The workshop flow runs inside the persistent cockpit shell. */}
|
||||||
<Route path="/workshop/setup" element={<EnvSetup />} />
|
<Route element={<CockpitLayout />}>
|
||||||
<Route path="/workshop/module1" element={<Module1 />} />
|
<Route path="/workshop" element={<TeamRegistration />} />
|
||||||
<Route path="/workshop/module2" element={<Module2 />} />
|
<Route path="/workshop/setup" element={<EnvSetup />} />
|
||||||
<Route path="/workshop/add" element={<AddBuilder />} />
|
<Route path="/workshop/module1" element={<Module1 />} />
|
||||||
|
<Route path="/workshop/module2" element={<Module2 />} />
|
||||||
|
<Route path="/workshop/add" element={<AddBuilder />} />
|
||||||
|
</Route>
|
||||||
<Route path="/lecture" element={<Lecture />} />
|
<Route path="/lecture" element={<Lecture />} />
|
||||||
<Route path="/admin" element={<Admin />} />
|
<Route path="/admin" element={<Admin />} />
|
||||||
<Route path="/judge" element={<Judge />} />
|
<Route path="/judge" element={<Judge />} />
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Outlet, Link } from 'react-router-dom'
|
||||||
|
import { useSession } from '@/store/session'
|
||||||
|
import { Stepper } from './Stepper'
|
||||||
|
import { CockpitRail } from './CockpitRail'
|
||||||
|
|
||||||
|
function ThemeToggle() {
|
||||||
|
const theme = useSession((s) => s.theme)
|
||||||
|
const setTheme = useSession((s) => s.setTheme)
|
||||||
|
const dark = theme === 'dark'
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme(dark ? 'light' : 'dark')}
|
||||||
|
aria-label={dark ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||||
|
className="rounded-[20px] border border-line px-3 py-1.5 font-mono text-[10px] tracking-[0.08em] text-ink-2 transition-colors hover:border-blue hover:text-blue-ink"
|
||||||
|
>
|
||||||
|
{dark ? '☀ LIGHT' : '☾ DARK'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cockpit shell — a persistent layout wrapping every workshop phase route.
|
||||||
|
* Header + stepper + a two-pane grid (editorial content via <Outlet/> on the
|
||||||
|
* left, the live instrument rail on the right). The rail stays mounted across
|
||||||
|
* phase navigation, so its live feed never resets.
|
||||||
|
*/
|
||||||
|
export function CockpitLayout() {
|
||||||
|
const team = useSession((s) => s.team)
|
||||||
|
const nodeName = team.name ? team.name.toLowerCase().replace(/\s+/g, '-') : 'crimson-node'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<header className="flex items-center border-b border-line px-10 py-5 print:hidden">
|
||||||
|
<Link to="/" className="font-mono text-xs font-semibold tracking-[0.14em]">
|
||||||
|
<span className="text-ink">APESS </span>
|
||||||
|
<span className="text-blue">2026</span>
|
||||||
|
<span className="text-[var(--muted)]"> · WORKSHOP</span>
|
||||||
|
</Link>
|
||||||
|
<div className="ml-auto flex items-center gap-4">
|
||||||
|
<span className="font-mono text-[11px] text-[var(--muted)]">
|
||||||
|
{nodeName}
|
||||||
|
{team.name && <span> · {team.name}</span>}
|
||||||
|
</span>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="print:hidden">
|
||||||
|
<Stepper />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-auto grid w-full max-w-[1400px] items-start gap-14 px-10 pb-[90px] pt-14 lg:grid-cols-[minmax(0,1fr)_464px] print:block print:p-0">
|
||||||
|
<main className="min-h-[560px] w-full max-w-[660px] print:max-w-none">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<div className="print:hidden">
|
||||||
|
<CockpitRail />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
import { useSession } from '@/store/session'
|
||||||
|
import { useNodeFeed } from '@/lib/useNodeFeed'
|
||||||
|
import { useTelemetry } from '@/lib/useTelemetry'
|
||||||
|
import { useMatrixMirror } from '@/lib/useMatrixMirror'
|
||||||
|
import { WaveformCanvas } from './WaveformCanvas'
|
||||||
|
import type { NodeActivityKind } from '@/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ADD_LAYERS } from '@/lib/addLayers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The persistent instrument rail (right pane of the cockpit). A dark "device
|
||||||
|
* screen" — intentionally dark in both themes. Six sections: node heartbeat,
|
||||||
|
* agent activity log, and ADD progress are wired to real state; the LED matrix,
|
||||||
|
* I2C bus, and live acceleration come from the telemetry seam (`useTelemetry`),
|
||||||
|
* which is a **simulated** source today (marked SIM) until the ADXL355 stream
|
||||||
|
* lands. See the plan's WS3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LOG_COLOR: Record<NodeActivityKind, string> = {
|
||||||
|
thinking: 'text-rail-dim2',
|
||||||
|
tool: 'text-rail-text2',
|
||||||
|
flash: 'text-rail-blue',
|
||||||
|
error: 'text-rail-spike',
|
||||||
|
response: 'text-rail-green',
|
||||||
|
fallback: 'text-[#d9a441]',
|
||||||
|
}
|
||||||
|
|
||||||
|
// which layers are "next" on each route
|
||||||
|
const NEXT_BY_PATH: Record<string, string[]> = {
|
||||||
|
'/workshop/module1': ['L1'],
|
||||||
|
'/workshop/module2': ['L2', 'L3'],
|
||||||
|
'/workshop/add': ['L4', 'L5'],
|
||||||
|
}
|
||||||
|
|
||||||
|
function RailSection({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="font-mono text-[9.5px] tracking-[0.18em] text-rail-dim">{label}</div>
|
||||||
|
<div className="mt-2">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CockpitRail() {
|
||||||
|
const teamId = useSession((s) => s.teamId)
|
||||||
|
const team = useSession((s) => s.team)
|
||||||
|
const connected = useSession((s) => s.device.connected)
|
||||||
|
const add = useSession((s) => s.add)
|
||||||
|
const submitted = useSession((s) => s.submission.code != null)
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
|
||||||
|
const feed = useNodeFeed(teamId, connected)
|
||||||
|
const tel = useTelemetry(connected)
|
||||||
|
const online = connected && feed.online
|
||||||
|
// Pixel-perfect mirror of the physical matrix (real board frame). Falls back
|
||||||
|
// to the sim frame until the first real frame arrives.
|
||||||
|
const mirror = useMatrixMirror(teamId, online)
|
||||||
|
const matrixDots = mirror ?? tel.matrix
|
||||||
|
const matrixLive = mirror != null
|
||||||
|
const nodeName = team.name ? team.name.toLowerCase().replace(/\s+/g, '-') : 'crimson-node'
|
||||||
|
|
||||||
|
const nextSet = new Set(NEXT_BY_PATH[pathname] ?? [])
|
||||||
|
const layerState = (key: string): 'idle' | 'next' | 'done' => {
|
||||||
|
if (submitted || add[key as keyof typeof add]?.trim()) return 'done'
|
||||||
|
return nextSet.has(key) ? 'next' : 'idle'
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = feed.activity.slice(0, 6)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sticky top-6 rounded-[18px] bg-rail-bg p-[22px] text-rail-text shadow-[0_20px_50px_-24px_rgba(0,0,0,0.5)]">
|
||||||
|
{/* 1 · node header / heartbeat */}
|
||||||
|
<div className="flex items-center gap-[11px]">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'h-2.5 w-2.5 rounded-full',
|
||||||
|
online ? 'bg-rail-green shadow-[0_0_10px_#3fd28a] animate-pulse' : 'bg-rail-dim2',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-sm font-semibold tracking-[0.02em] text-rail-text3">{nodeName}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-mono text-[9px] tracking-[0.14em] rounded border px-[7px] py-0.5',
|
||||||
|
online ? 'text-rail-green border-[#2c6b4f]' : 'text-rail-dim2 border-rail-line',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{online ? 'LIVE' : 'OFFLINE'}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto font-mono text-[10px] text-rail-dim">arduino uno q</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2 · LED matrix 13×8 — real pixel mirror of the physical matrix */}
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-mono text-[9.5px] tracking-[0.18em] text-rail-dim">LED MATRIX · 13×8</div>
|
||||||
|
{matrixLive && (
|
||||||
|
<span className="font-mono text-[8.5px] tracking-[0.12em] text-rail-green">● MIRROR</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex justify-center rounded-[10px] border border-rail-line bg-rail-inset px-[13px] py-3">
|
||||||
|
<div className="grid gap-1" style={{ gridTemplateColumns: 'repeat(13, 1fr)' }}>
|
||||||
|
{Array.from({ length: 104 }).map((_, i) => {
|
||||||
|
const lit = tel.live && matrixDots[i]
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="h-[9px] w-[9px] rounded-[2px] transition-[background] duration-75"
|
||||||
|
style={{
|
||||||
|
background: lit ? 'oklch(0.7 0.2 34)' : 'oklch(0.28 0.01 260)',
|
||||||
|
boxShadow: lit ? '0 0 5px oklch(0.7 0.2 34)' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3 · I2C bus (telemetry) */}
|
||||||
|
<RailSection label="I2C BUS · 100 kHz">
|
||||||
|
<div className="rounded-[10px] border border-rail-line2 bg-rail-panel px-1 py-1.5 font-mono text-xs">
|
||||||
|
{tel.i2c.map((d, i) => (
|
||||||
|
<div
|
||||||
|
key={d.addr}
|
||||||
|
className={cn('flex items-center gap-2.5 px-3 py-2', i === 0 && 'border-b border-rail-line')}
|
||||||
|
>
|
||||||
|
<span className="text-rail-blue">{d.addr}</span>
|
||||||
|
<span className="text-rail-text2">{d.name}</span>
|
||||||
|
<span className={cn('ml-auto', d.synced ? 'text-rail-green' : 'text-rail-dim3')}>
|
||||||
|
{d.synced ? '● synced' : '○ idle'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-[7px] font-mono text-[10px] text-rail-dim2">
|
||||||
|
{tel.live ? `SYNC/INT aligned · drift ${tel.driftMs.toFixed(1)} ms` : 'run i2c_scan to enumerate the bus'}
|
||||||
|
</div>
|
||||||
|
</RailSection>
|
||||||
|
|
||||||
|
{/* 4 · live acceleration (telemetry) */}
|
||||||
|
<RailSection label="LIVE ACCELERATION · g">
|
||||||
|
<div className="-mt-[18px] mb-2 flex items-center justify-end gap-2">
|
||||||
|
{tel.live && tel.simulated && (
|
||||||
|
<span className="rounded-[4px] border border-[#d9a441]/40 px-[5px] py-[1px] font-mono text-[8.5px] tracking-[0.12em] text-[#d9a441]">
|
||||||
|
SIM
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-mono text-[9.5px] tracking-[0.1em]',
|
||||||
|
!tel.live ? 'text-rail-dim' : tel.event === 'impact' ? 'text-rail-spike' : 'text-rail-green',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!tel.live ? 'AWAITING STREAM' : tel.event === 'impact' ? 'IMPACT SPIKE' : 'NOMINAL'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{tel.live ? (
|
||||||
|
<WaveformCanvas wave={tel.wave} impact={tel.event === 'impact'} />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[78px] items-center justify-center rounded-[10px] border border-rail-line bg-rail-inset font-mono text-[10px] text-rail-dim3">
|
||||||
|
adxl355_stream — awaiting board
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2 font-mono text-[11.5px]">
|
||||||
|
{([['ACC 1', tel.acc1], ['ACC 2', tel.acc2]] as const).map(([n, a]) => (
|
||||||
|
<div key={n} className="rounded-lg border border-rail-line2 bg-rail-panel px-[11px] py-[9px]">
|
||||||
|
<div className="text-[9.5px] tracking-[0.12em] text-rail-dim">{n}</div>
|
||||||
|
{(['x', 'y', 'z'] as const).map((axis) => (
|
||||||
|
<div key={axis} className="text-rail-text2">
|
||||||
|
{axis}{' '}
|
||||||
|
<span className="text-rail-text3 tabular-nums">
|
||||||
|
{tel.live ? a[axis].toFixed(3) : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</RailSection>
|
||||||
|
|
||||||
|
{/* 5 · agent activity log (real) */}
|
||||||
|
<RailSection label="AGENT ACTIVITY">
|
||||||
|
<div className="h-[132px] overflow-hidden rounded-[10px] border border-rail-line bg-rail-inset px-[13px] py-[11px] font-mono text-[11px] leading-[1.75]">
|
||||||
|
{log.length === 0 ? (
|
||||||
|
<div className="text-rail-dim2">idle — prompt your agent to see it work</div>
|
||||||
|
) : (
|
||||||
|
log.map((e, i) => (
|
||||||
|
<div key={i} className={cn('truncate', LOG_COLOR[e.kind])}>
|
||||||
|
{e.label}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</RailSection>
|
||||||
|
|
||||||
|
{/* 6 · ADD progress (real, local) */}
|
||||||
|
<RailSection label="AGENT DESIGN DOC">
|
||||||
|
<div className="flex flex-col gap-px font-mono text-[11px]">
|
||||||
|
{ADD_LAYERS.map(({ key, n, title }) => {
|
||||||
|
const st = layerState(key)
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center gap-2.5 px-0.5 py-[7px]">
|
||||||
|
<span className={cn(st === 'idle' ? 'text-[#4a5060]' : 'text-rail-blue')}>L{n}</span>
|
||||||
|
<span className={cn(st === 'idle' ? 'text-rail-dim2' : 'text-rail-text2')}>{title}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'ml-auto',
|
||||||
|
st === 'done' ? 'text-rail-green' : st === 'next' ? 'text-[#d9a441]' : 'text-rail-dim3',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{st === 'done' ? 'done' : st === 'next' ? 'next' : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</RailSection>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/** Mono eyebrow chip, e.g. "PHASE 1 OF 5 · ~10 MIN". */
|
||||||
|
export function Eyebrow({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="inline-block rounded-[5px] bg-[var(--eyebrow-bg)] px-2.5 py-[5px] font-mono text-[11px] tracking-[0.14em] text-ink-3">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editorial panel header: eyebrow + big Newsreader H1 + intro paragraph. */
|
||||||
|
export function PanelHeading({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
intro,
|
||||||
|
size = 52,
|
||||||
|
}: {
|
||||||
|
eyebrow: string
|
||||||
|
title: string
|
||||||
|
intro?: string
|
||||||
|
size?: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Eyebrow>{eyebrow}</Eyebrow>
|
||||||
|
<h1
|
||||||
|
className="mt-[22px] font-semibold leading-[1.03] tracking-[-0.02em] text-ink"
|
||||||
|
style={{ fontSize: size }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{intro && <p className="mt-[18px] max-w-[580px] text-[18px] leading-[1.55] text-ink-2">{intro}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Section wrapper for a phase panel (consistent top spacing + scroll reset). */
|
||||||
|
export function Panel({ children }: { children: React.ReactNode }) {
|
||||||
|
return <section className="space-y-0">{children}</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The primary "Proceed →" button (Newsreader, blue, gated). */
|
||||||
|
export function ProceedButton({
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
disabled?: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'rounded-[10px] bg-blue px-[26px] py-3.5 text-[17px] font-medium text-white transition-[opacity,background] duration-150 hover:bg-blue-ink',
|
||||||
|
disabled ? 'cursor-default opacity-45' : 'cursor-pointer opacity-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A standard editorial card. */
|
||||||
|
export function PanelCard({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
emphasized,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
emphasized?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-[14px] p-[22px_24px]',
|
||||||
|
emphasized
|
||||||
|
? 'border-[1.5px] border-[var(--blue-soft-border)] bg-[linear-gradient(180deg,var(--blue-pick-a),var(--surface))]'
|
||||||
|
: 'border border-line bg-surface',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mono uppercase field label. */
|
||||||
|
export function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--muted)]">{children}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { useSession, type PhaseKey } from '@/store/session'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
key: PhaseKey
|
||||||
|
to: string
|
||||||
|
eyebrow: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEPS: Step[] = [
|
||||||
|
{ key: 'reg', to: '/workshop', eyebrow: 'PHASE 1', label: 'Team registration' },
|
||||||
|
{ key: 'setup', to: '/workshop/setup', eyebrow: 'PHASE 2', label: 'Meet your agent' },
|
||||||
|
{ key: 'm1', to: '/workshop/module1', eyebrow: 'PHASE 3', label: 'Module 1' },
|
||||||
|
{ key: 'm2', to: '/workshop/module2', eyebrow: 'PHASE 4', label: 'Module 2' },
|
||||||
|
{ key: 'add', to: '/workshop/add', eyebrow: 'PHASE 5', label: 'Module 3' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** The five-phase stepper nav (replaces PhaseStrip). Forward-gated: you can only
|
||||||
|
* jump to a step at or before the current one (advance via the Proceed buttons). */
|
||||||
|
export function Stepper() {
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const phases = useSession((s) => s.phases)
|
||||||
|
|
||||||
|
const activeIndex = Math.max(
|
||||||
|
0,
|
||||||
|
STEPS.findIndex((s) => s.to === pathname),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="grid grid-cols-5 gap-2.5 border-b border-line px-10 py-4" data-testid="stepper">
|
||||||
|
{STEPS.map((s, i) => {
|
||||||
|
const state = i < activeIndex ? 'done' : i === activeIndex ? 'active' : 'pending'
|
||||||
|
const reachable = i <= activeIndex || phases[s.key]
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
type="button"
|
||||||
|
data-state={state}
|
||||||
|
disabled={!reachable}
|
||||||
|
onClick={() => reachable && navigate(s.to)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg px-3.5 py-[11px] text-left transition-colors',
|
||||||
|
reachable ? 'cursor-pointer' : 'cursor-default',
|
||||||
|
state === 'done' && 'border border-[var(--green-border-2)] bg-[var(--green-bg)]',
|
||||||
|
state === 'active' && 'border-[1.5px] border-blue bg-[var(--blue-soft-bg)]',
|
||||||
|
state === 'pending' && 'border border-line bg-surface',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'font-mono text-[10px] tracking-[0.16em]',
|
||||||
|
state === 'done' && 'text-green',
|
||||||
|
state === 'active' && 'text-blue-eyebrow',
|
||||||
|
state === 'pending' && 'text-faint',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.eyebrow}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-[15px]',
|
||||||
|
state === 'done' && 'text-green font-medium',
|
||||||
|
state === 'active' && 'text-blue-ink font-semibold',
|
||||||
|
state === 'pending' && 'text-[var(--muted-2)] font-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live-acceleration waveform. Reads the magnitude history from a ref and
|
||||||
|
* redraws via requestAnimationFrame — independent of React renders, so it stays
|
||||||
|
* smooth. Line turns to the spike colour during an impact event.
|
||||||
|
*/
|
||||||
|
export function WaveformCanvas({
|
||||||
|
wave,
|
||||||
|
impact,
|
||||||
|
}: {
|
||||||
|
wave: React.MutableRefObject<number[]>
|
||||||
|
impact: boolean
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLCanvasElement>(null)
|
||||||
|
const impactRef = useRef(impact)
|
||||||
|
impactRef.current = impact
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cv = ref.current
|
||||||
|
if (!cv) return
|
||||||
|
const ctx = cv.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
const resize = () => {
|
||||||
|
cv.width = cv.clientWidth * dpr
|
||||||
|
cv.height = cv.clientHeight * dpr
|
||||||
|
}
|
||||||
|
resize()
|
||||||
|
window.addEventListener('resize', resize)
|
||||||
|
|
||||||
|
let raf = 0
|
||||||
|
const draw = () => {
|
||||||
|
const w = cv.width
|
||||||
|
const h = cv.height
|
||||||
|
ctx.clearRect(0, 0, w, h)
|
||||||
|
// center baseline
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.06)'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(0, h / 2)
|
||||||
|
ctx.lineTo(w, h / 2)
|
||||||
|
ctx.stroke()
|
||||||
|
// waveform
|
||||||
|
const buf = wave.current
|
||||||
|
const n = buf.length
|
||||||
|
ctx.strokeStyle = impactRef.current ? '#ff8a5c' : '#7fa8ff'
|
||||||
|
ctx.lineWidth = 1.5 * dpr
|
||||||
|
ctx.beginPath()
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const x = (i / (n - 1)) * w
|
||||||
|
const y = h / 2 - buf[i] * (h * 0.42)
|
||||||
|
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
|
||||||
|
}
|
||||||
|
ctx.stroke()
|
||||||
|
raf = requestAnimationFrame(draw)
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(draw)
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
window.removeEventListener('resize', resize)
|
||||||
|
}
|
||||||
|
}, [wave])
|
||||||
|
|
||||||
|
return <canvas ref={ref} className="block h-[78px] w-full rounded-[10px] border border-rail-line bg-rail-inset" />
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface SwitchProps {
|
||||||
|
checked: boolean
|
||||||
|
onCheckedChange: (checked: boolean) => void
|
||||||
|
id?: string
|
||||||
|
'aria-label'?: string
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small controlled toggle matching the cockpit design: a pill track that turns
|
||||||
|
* green when on, with a sliding knob. No Radix dependency — plain button.
|
||||||
|
*/
|
||||||
|
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||||
|
({ checked, onCheckedChange, disabled, className, ...aria }, ref) => (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onCheckedChange(!checked)}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--blue)] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
checked ? 'bg-[var(--green)]' : 'bg-[var(--line-2)]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...aria}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block h-[18px] w-[18px] transform rounded-full bg-white shadow transition-transform duration-200',
|
||||||
|
checked ? 'translate-x-[23px]' : 'translate-x-[3px]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Switch.displayName = 'Switch'
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
+67
-1
@@ -24,6 +24,37 @@
|
|||||||
--input: 214 32% 91%;
|
--input: 214 32% 91%;
|
||||||
--ring: 211 100% 50%;
|
--ring: 211 100% 50%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
|
/* ── Cockpit design tokens (light) ── */
|
||||||
|
--bg: #fbfaf8;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-soft: #faf9f7;
|
||||||
|
--eyebrow-bg: #f0eee9;
|
||||||
|
--ink: #1a1c22;
|
||||||
|
--ink-2: #4a4f59;
|
||||||
|
--ink-3: #5b606b;
|
||||||
|
--muted: #8b8f98;
|
||||||
|
--muted-2: #9a988f;
|
||||||
|
--faint: #b6b4ae;
|
||||||
|
--line: #e7e5e0;
|
||||||
|
--line-2: #dcdad3;
|
||||||
|
--doc-line: #ecebe6;
|
||||||
|
--doc-bg: #fdfdfc;
|
||||||
|
--blue: #2f6bff;
|
||||||
|
--blue-ink: #1f4fd6;
|
||||||
|
--blue-eyebrow: #6f93e6;
|
||||||
|
--blue-soft-bg: #f2f6ff;
|
||||||
|
--blue-soft-border: #cfdcff;
|
||||||
|
--blue-grad-a: #f6f9ff;
|
||||||
|
--blue-grad-b: #eef4ff;
|
||||||
|
--blue-pick-a: #fbfcff;
|
||||||
|
--green: #2f9e6f;
|
||||||
|
--green-bg: #f4fbf7;
|
||||||
|
--green-border: #a9dcc4;
|
||||||
|
--green-border-2: #cfe6da;
|
||||||
|
--amber: #c9922f;
|
||||||
|
--dot-idle: #c9ccd2;
|
||||||
|
--mono-sub: #6b7382;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -46,6 +77,37 @@
|
|||||||
--border: 217 32% 17%;
|
--border: 217 32% 17%;
|
||||||
--input: 217 32% 17%;
|
--input: 217 32% 17%;
|
||||||
--ring: 211 100% 60%;
|
--ring: 211 100% 60%;
|
||||||
|
|
||||||
|
/* ── Cockpit design tokens (dark) ── */
|
||||||
|
--bg: #0f1116;
|
||||||
|
--surface: #191c22;
|
||||||
|
--surface-soft: #14171d;
|
||||||
|
--eyebrow-bg: #21252c;
|
||||||
|
--ink: #eceef2;
|
||||||
|
--ink-2: #b7bcc6;
|
||||||
|
--ink-3: #a2a8b3;
|
||||||
|
--muted: #838a95;
|
||||||
|
--muted-2: #838a95;
|
||||||
|
--faint: #565c67;
|
||||||
|
--line: #272b33;
|
||||||
|
--line-2: #343a44;
|
||||||
|
--doc-line: #272b33;
|
||||||
|
--doc-bg: #12151b;
|
||||||
|
--blue: #5183ff;
|
||||||
|
--blue-ink: #a7c0ff;
|
||||||
|
--blue-eyebrow: #7ea2ff;
|
||||||
|
--blue-soft-bg: #16223c;
|
||||||
|
--blue-soft-border: #2b3e69;
|
||||||
|
--blue-grad-a: #141d31;
|
||||||
|
--blue-grad-b: #101828;
|
||||||
|
--blue-pick-a: #12151b;
|
||||||
|
--green: #3fd28a;
|
||||||
|
--green-bg: #10231b;
|
||||||
|
--green-border: #2c6b4f;
|
||||||
|
--green-border-2: #2c6b4f;
|
||||||
|
--amber: #d9a441;
|
||||||
|
--dot-idle: #3a3f49;
|
||||||
|
--mono-sub: #8a919c;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +116,11 @@
|
|||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground antialiased;
|
@apply antialiased;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: 'Newsreader', ui-serif, serif;
|
||||||
font-feature-settings: 'rlig' 1, 'calt' 1;
|
font-feature-settings: 'rlig' 1, 'calt' 1;
|
||||||
|
transition: background-color 0.25s ease, color 0.25s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,28 @@ export async function getNodeStatus(teamId: string): Promise<{ teamId: string; u
|
|||||||
return (await res.json()) as { teamId: string; url?: string; online: boolean }
|
return (await res.json()) as { teamId: string; url?: string; online: boolean }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The board's live LED-matrix frame → 104 booleans (13×8 row-major), a pixel
|
||||||
|
* mirror of the physical matrix. `hex` = 4×uint32 packed MSB-first (see the MCU
|
||||||
|
* `matrix_get`). Returns null if the board can't be read.
|
||||||
|
*/
|
||||||
|
export async function getNodeMatrix(teamId: string): Promise<boolean[] | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/matrix`)
|
||||||
|
if (!res.ok) return null
|
||||||
|
const { hex } = (await res.json()) as { hex: string }
|
||||||
|
if (!/^[0-9a-fA-F]{32}$/.test(hex)) return null
|
||||||
|
const words = [0, 8, 16, 24].map((o) => parseInt(hex.slice(o, o + 8), 16) >>> 0)
|
||||||
|
const dots: boolean[] = []
|
||||||
|
for (let i = 0; i < 104; i++) {
|
||||||
|
dots.push(((words[i >> 5] >>> (31 - (i & 31))) & 1) === 1)
|
||||||
|
}
|
||||||
|
return dots
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Instructor-only: kit ids of boards that have self-registered but are unclaimed. */
|
/** Instructor-only: kit ids of boards that have self-registered but are unclaimed. */
|
||||||
export async function getUnclaimed(code: string): Promise<string[]> {
|
export async function getUnclaimed(code: string): Promise<string[]> {
|
||||||
const res = await fetch(`${API_BASE}/nodes/unclaimed`, { headers: authHeaders(code) })
|
const res = await fetch(`${API_BASE}/nodes/unclaimed`, { headers: authHeaders(code) })
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reflect the persisted `theme` onto <html>: toggles the Tailwind `.dark` class
|
||||||
|
* (darkMode:'class') AND sets `data-theme` (the design's convention). Runs on
|
||||||
|
* mount and whenever the store theme changes. Call once, high in the tree.
|
||||||
|
*/
|
||||||
|
export function useApplyTheme() {
|
||||||
|
const theme = useSession((s) => s.theme)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = document.documentElement
|
||||||
|
el.classList.toggle('dark', theme === 'dark')
|
||||||
|
el.setAttribute('data-theme', theme)
|
||||||
|
}, [theme])
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { getNodeMatrix } from './api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll the board's real LED-matrix framebuffer (~8 fps) for a pixel-perfect
|
||||||
|
* mirror. Returns the 104 on/off dots, or null until a frame is read. Skips a
|
||||||
|
* poll while the previous one is in flight so a slow board can't stack requests.
|
||||||
|
*/
|
||||||
|
export function useMatrixMirror(teamId: string, enabled: boolean, fps = 8): boolean[] | null {
|
||||||
|
const [dots, setDots] = useState<boolean[] | null>(null)
|
||||||
|
const inflight = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setDots(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
const tick = async () => {
|
||||||
|
if (inflight.current) return
|
||||||
|
inflight.current = true
|
||||||
|
try {
|
||||||
|
const frame = await getNodeMatrix(teamId)
|
||||||
|
if (!cancelled && frame) setDots(frame)
|
||||||
|
} finally {
|
||||||
|
inflight.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
const id = window.setInterval(tick, Math.round(1000 / fps))
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.clearInterval(id)
|
||||||
|
}
|
||||||
|
}, [teamId, enabled, fps])
|
||||||
|
|
||||||
|
return dots
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live board telemetry for the cockpit rail — the ADXL355 stream, LED-matrix
|
||||||
|
* mirror, and I2C bus state.
|
||||||
|
*
|
||||||
|
* SOURCE SEAM: today this is a **simulated** source (no ADXL355 is wired, and the
|
||||||
|
* node doesn't yet push accelerometer frames). It faithfully reproduces the
|
||||||
|
* designer's prototype behaviour so the finished rail can be seen and reviewed.
|
||||||
|
* When the real telemetry lands (MCU ADXL355 driver → node `/ws/telemetry` →
|
||||||
|
* `node:accel/matrix/i2c` events), swap `simulate()` for the real subscription
|
||||||
|
* behind this same hook — nothing downstream changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface XYZ {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
z: number
|
||||||
|
}
|
||||||
|
export interface I2cDevice {
|
||||||
|
addr: string
|
||||||
|
name: string
|
||||||
|
synced: boolean
|
||||||
|
}
|
||||||
|
export interface Telemetry {
|
||||||
|
/** True while a source is producing frames. `simulated` marks the sim source. */
|
||||||
|
live: boolean
|
||||||
|
simulated: boolean
|
||||||
|
acc1: XYZ
|
||||||
|
acc2: XYZ
|
||||||
|
event: 'nominal' | 'impact'
|
||||||
|
driftMs: number
|
||||||
|
/** 104 booleans (13×8 row-major) mirroring the LED matrix. */
|
||||||
|
matrix: boolean[]
|
||||||
|
/** Rolling magnitude history for the waveform canvas (kept in a ref, no re-render). */
|
||||||
|
wave: React.MutableRefObject<number[]>
|
||||||
|
i2c: I2cDevice[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const WAVE_LEN = 160
|
||||||
|
const rand = () => Math.random()
|
||||||
|
const zero: XYZ = { x: 0, y: 0, z: 1 }
|
||||||
|
|
||||||
|
/** Scrolling sine across the 13×8 grid → 104 on/off dots. */
|
||||||
|
function matrixFrame(phase: number): boolean[] {
|
||||||
|
const on = new Array(104).fill(false)
|
||||||
|
for (let c = 0; c < 13; c++) {
|
||||||
|
const yf = 3.5 + 2.6 * Math.sin(c * 0.5 + phase)
|
||||||
|
const y = Math.round(yf)
|
||||||
|
for (let r = 0; r < 8; r++) {
|
||||||
|
if (Math.abs(r - y) < 1.1) on[r * 13 + c] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return on
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTelemetry(enabled: boolean): Telemetry {
|
||||||
|
const wave = useRef<number[]>(new Array(WAVE_LEN).fill(0))
|
||||||
|
const [acc1, setAcc1] = useState<XYZ>(zero)
|
||||||
|
const [acc2, setAcc2] = useState<XYZ>(zero)
|
||||||
|
const [event, setEvent] = useState<'nominal' | 'impact'>('nominal')
|
||||||
|
const [driftMs, setDriftMs] = useState(0.4)
|
||||||
|
const [matrix, setMatrix] = useState<boolean[]>(() => matrixFrame(0))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
let phase = 0
|
||||||
|
let impactUntil = 0
|
||||||
|
const jog = (base: number, amp: number) => base + (rand() - 0.5) * amp
|
||||||
|
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
const now = performance.now()
|
||||||
|
if (now > impactUntil && rand() < 0.045) impactUntil = now + 1400
|
||||||
|
const impact = now < impactUntil
|
||||||
|
|
||||||
|
// per-sensor x/y/z (g). z rests at 1g (gravity); impact shakes all axes.
|
||||||
|
const a = impact ? 1.1 : 0.03
|
||||||
|
const z = impact ? 0.9 : 0.03
|
||||||
|
setAcc1({ x: jog(0, a), y: jog(0, a), z: jog(1, z) })
|
||||||
|
setAcc2({ x: jog(0, a * 0.9), y: jog(0, a * 0.9), z: jog(1, z) })
|
||||||
|
|
||||||
|
// magnitude for the waveform
|
||||||
|
const m = impact ? 0.55 + rand() * 0.85 : 0.04 + rand() * 0.06
|
||||||
|
const buf = wave.current
|
||||||
|
buf.push(m)
|
||||||
|
if (buf.length > WAVE_LEN) buf.shift()
|
||||||
|
|
||||||
|
setEvent(impact ? 'impact' : 'nominal')
|
||||||
|
setDriftMs(0.3 + rand() * 0.5)
|
||||||
|
phase += 0.34
|
||||||
|
setMatrix(matrixFrame(phase))
|
||||||
|
}, 90)
|
||||||
|
|
||||||
|
return () => window.clearInterval(id)
|
||||||
|
}, [enabled])
|
||||||
|
|
||||||
|
return {
|
||||||
|
live: enabled,
|
||||||
|
simulated: true,
|
||||||
|
acc1,
|
||||||
|
acc2,
|
||||||
|
event,
|
||||||
|
driftMs,
|
||||||
|
matrix,
|
||||||
|
wave,
|
||||||
|
i2c: [
|
||||||
|
{ addr: '0x1D', name: 'adxl355 · acc 1', synced: enabled },
|
||||||
|
{ addr: '0x53', name: 'adxl355 · acc 2', synced: enabled },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,12 +27,9 @@ describe('AddBuilder', () => {
|
|||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the phase strip set to add and the heading', () => {
|
it('renders the heading', () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
expect(screen.getByRole('heading', { name: /harness.*loops/i })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { name: /harness.*loops/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 () => {
|
it('persists Layer 4 and 5 to the store', async () => {
|
||||||
@@ -92,6 +89,7 @@ describe('AddBuilder', () => {
|
|||||||
expect(submission.code).toMatch(/^KIT-03-/)
|
expect(submission.code).toMatch(/^KIT-03-/)
|
||||||
expect(submission.submittedAt).not.toBeNull()
|
expect(submission.submittedAt).not.toBeNull()
|
||||||
expect(phases.add).toBe(true)
|
expect(phases.add).toBe(true)
|
||||||
expect(screen.getByText(submission.code as string)).toBeInTheDocument()
|
// The finale confirms submission (the raw code is no longer shown inline).
|
||||||
|
expect(screen.getByRole('heading', { name: /add submitted/i })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+105
-103
@@ -1,124 +1,126 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { 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 { AddLayerForm } from '@/components/AddLayerForm'
|
import { AddLayerForm } from '@/components/AddLayerForm'
|
||||||
import { AddDocument } from '@/components/AddDocument'
|
import { AddDocument } from '@/components/AddDocument'
|
||||||
import { OpenYourNode } from '@/components/OpenYourNode'
|
import { PanelHeading, ProceedButton } from '@/components/cockpit/PanelChrome'
|
||||||
|
import { ADD_LAYERS } from '@/lib/addLayers'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
import { makeSubmissionCode } from '@/lib/submission'
|
import { makeSubmissionCode } from '@/lib/submission'
|
||||||
|
|
||||||
export function AddBuilder() {
|
export function AddBuilder() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const team = useSession((s) => s.team)
|
const team = useSession((s) => s.team)
|
||||||
const add = useSession((s) => s.add)
|
const add = useSession((s) => s.add)
|
||||||
const submission = useSession((s) => s.submission)
|
const submission = useSession((s) => s.submission)
|
||||||
const setSubmission = useSession((s) => s.setSubmission)
|
const setSubmission = useSession((s) => s.setSubmission)
|
||||||
const completePhase = useSession((s) => s.completePhase)
|
const completePhase = useSession((s) => s.completePhase)
|
||||||
|
|
||||||
const complete =
|
const complete = ADD_LAYERS.every(({ key }) => add[key].trim().length > 0)
|
||||||
add.L1.trim().length > 0 &&
|
|
||||||
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
|
const submitted = !!submission.code
|
||||||
|
|
||||||
return (
|
const onExport = () => window.print()
|
||||||
<main className="min-h-screen bg-background">
|
const onSubmit = () => {
|
||||||
<header className="px-8 py-5 border-b border-border flex items-center justify-between print:hidden">
|
setSubmission({ code: makeSubmissionCode(team, add), submittedAt: new Date().toISOString() })
|
||||||
<div className="font-mono text-xs tracking-widest uppercase">
|
completePhase('add')
|
||||||
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">
|
// ── Submission finale (design panel6) ──
|
||||||
<PhaseStrip active="add" />
|
if (submitted) {
|
||||||
</div>
|
return (
|
||||||
|
<section className="print:hidden">
|
||||||
<section className="px-8 py-10 max-w-5xl mx-auto space-y-6">
|
<div className="mt-5 rounded-[18px] border border-[var(--green-border-2)] bg-[linear-gradient(180deg,var(--green-bg),var(--surface))] px-12 py-14 text-center">
|
||||||
<div className="print:hidden">
|
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-[var(--green)] text-[32px] text-white">
|
||||||
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
|
✓
|
||||||
Phase 5 of 5 · ~90 min · deadline 19:00
|
</div>
|
||||||
</Badge>
|
<h1 className="mt-6 text-[44px] font-semibold tracking-[-0.02em]">ADD submitted</h1>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Module 3 · Harness, Loops & submit</h1>
|
<p className="mx-auto mt-3.5 max-w-[460px] text-[18px] leading-[1.5] text-ink-2">
|
||||||
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
|
Team <strong className="font-semibold text-ink">{team.name || 'Matrix'}</strong> ·{' '}
|
||||||
Finish Layers 4 and 5 — how your node reasons and how it runs over time — review the assembled
|
{(team.name || 'crimson-node').toLowerCase().replace(/\s+/g, '-')} — your Agent Design Document
|
||||||
Agent Design Document, export a PDF, and submit before the deadline.
|
is in for judging. Your node keeps running the design you just shipped.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3">
|
<div className="mt-7 inline-flex flex-wrap justify-center gap-2.5 font-mono text-[11px] tracking-[0.1em] text-[var(--green)]">
|
||||||
<OpenYourNode variant="inline" />
|
{ADD_LAYERS.map(({ n, title }) => (
|
||||||
|
<span key={n} className="rounded-md border border-[var(--green-border)] px-[11px] py-1.5">
|
||||||
|
L{n} {title.split(' ')[0].toUpperCase()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/workshop')}
|
||||||
|
className="rounded-[9px] border border-line-2 bg-surface px-[22px] py-2.5 text-[15px] text-ink transition-colors hover:border-blue hover:text-blue-ink"
|
||||||
|
>
|
||||||
|
← Back to start
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid lg:grid-cols-2 gap-6 print:hidden">
|
|
||||||
<AddLayerForm
|
|
||||||
layer="L4"
|
|
||||||
title="ADD · Layer 4 — Harness (where each decision runs)"
|
|
||||||
description="Which decisions run on the board, which escalate to the cloud — and what still works with no network at all. Describe the degradation path, not just the happy path."
|
|
||||||
placeholder={
|
|
||||||
'Routine checks: on-board model, no network needed.\n' +
|
|
||||||
'Ambiguous or high-consequence calls: escalate to the cloud model.\n\n' +
|
|
||||||
'Degradation path:\n' +
|
|
||||||
'• cloud slow or rate-limited → fall back on-board, note reduced confidence\n' +
|
|
||||||
'• no network at all → keep sensing, logging and safing locally; queue anything that needs escalation\n' +
|
|
||||||
'• on-board model unavailable → stop actuating, alert, keep recording'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<AddLayerForm
|
|
||||||
layer="L5"
|
|
||||||
title="ADD · Layer 5 — Loops (cadence, and what happens when a cycle fails)"
|
|
||||||
description="How often it checks, what it reports by exception — and how the loop behaves when a cycle fails: stale readings, missed ticks, partial data."
|
|
||||||
placeholder={
|
|
||||||
'Heartbeat every 30 s; sample the sensor each minute; report only on exception; daily summary.\n\n' +
|
|
||||||
'When a cycle fails:\n' +
|
|
||||||
'• missed tick → skip, do not back-fill invented data\n' +
|
|
||||||
'• partial data → report what is missing, not an average of what is left\n' +
|
|
||||||
'• N consecutive failures → escalate to a human and stop acting on the readings'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</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>
|
</section>
|
||||||
</main>
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="print:hidden">
|
||||||
|
<PanelHeading
|
||||||
|
eyebrow="PHASE 5 OF 5 · ~90 MIN · DEADLINE 19:00"
|
||||||
|
title="Module 3 · Harness, Loops & submit"
|
||||||
|
intro="Finish Layers 4 and 5 — how your node reasons and how it runs over time — review the assembled Agent Design Document, export a PDF, and submit before the deadline."
|
||||||
|
size={44}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-4 md:grid-cols-2 print:hidden">
|
||||||
|
<AddLayerForm
|
||||||
|
layer="L4"
|
||||||
|
title="ADD · Layer 4 — Harness"
|
||||||
|
description="Which decisions run on the board, which escalate — and the degradation path, not just the happy path."
|
||||||
|
placeholder={
|
||||||
|
'Routine checks: on-board model, no network needed.\n' +
|
||||||
|
'Ambiguous or high-consequence calls: escalate to the cloud model.\n\n' +
|
||||||
|
'Degradation path:\n' +
|
||||||
|
'• cloud slow → fall back on-board, note reduced confidence\n' +
|
||||||
|
'• no network → keep sensing, logging and safing locally\n' +
|
||||||
|
'• on-board model down → stop actuating, alert, keep recording'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AddLayerForm
|
||||||
|
layer="L5"
|
||||||
|
title="ADD · Layer 5 — Loops"
|
||||||
|
description="How often it checks, what it reports by exception — and how the loop behaves when a cycle fails."
|
||||||
|
placeholder={
|
||||||
|
'Heartbeat every 30 s; sample each minute; report only on exception.\n\n' +
|
||||||
|
'When a cycle fails:\n' +
|
||||||
|
'• missed tick → skip, do not back-fill invented data\n' +
|
||||||
|
'• partial data → report what is missing\n' +
|
||||||
|
'• N failures → escalate to a human and stop acting'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 rounded-[14px] border border-line bg-surface p-[26px_28px]">
|
||||||
|
<div className="flex items-center justify-between print:hidden">
|
||||||
|
<div className="text-[19px] font-semibold">Assembled document</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onExport}
|
||||||
|
className="rounded-lg border border-line-2 bg-surface px-4 py-2 text-[14px] text-ink transition-colors hover:border-blue hover:text-blue-ink"
|
||||||
|
>
|
||||||
|
Export PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-[18px] print:mt-0">
|
||||||
|
<AddDocument />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex items-center justify-between gap-4 print:hidden">
|
||||||
|
<span className={complete ? 'text-[15px] text-[var(--green)]' : 'text-[15px] text-[var(--muted-2)]'}>
|
||||||
|
{complete ? 'All five layers complete — ready to submit.' : 'Complete all five layers to submit.'}
|
||||||
|
</span>
|
||||||
|
<ProceedButton disabled={!complete} onClick={onSubmit}>
|
||||||
|
Submit ADD
|
||||||
|
</ProceedButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,13 +23,9 @@ describe('EnvSetup — Meet your agent', () => {
|
|||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the phase strip set to setup and the heading', () => {
|
it('renders the heading', () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
expect(screen.getByTestId('phase-strip')).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('heading', { name: /meet your agent/i })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { name: /meet your agent/i })).toBeInTheDocument()
|
||||||
expect(
|
|
||||||
screen.getByTestId('phase-strip').querySelector('[data-phase="setup"]'),
|
|
||||||
).toHaveAttribute('data-state', 'active')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prompts to claim a board first when not connected, and gates Proceed', () => {
|
it('prompts to claim a board first when not connected, and gates Proceed', () => {
|
||||||
|
|||||||
+37
-55
@@ -1,79 +1,61 @@
|
|||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { 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 { OpenYourNode } from '@/components/OpenYourNode'
|
import { OpenYourNode } from '@/components/OpenYourNode'
|
||||||
import { DomainPicker } from '@/components/DomainPicker'
|
import { DomainPicker } from '@/components/DomainPicker'
|
||||||
|
import { PanelHeading, PanelCard, ProceedButton } from '@/components/cockpit/PanelChrome'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
export function EnvSetup() {
|
export function EnvSetup() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const device = useSession((s) => s.device)
|
const device = useSession((s) => s.device)
|
||||||
const domain = useSession((s) => s.domain)
|
const domain = useSession((s) => s.domain)
|
||||||
|
const add = useSession((s) => s.add)
|
||||||
|
const setAddLayer = useSession((s) => s.setAddLayer)
|
||||||
const completePhase = useSession((s) => s.completePhase)
|
const completePhase = useSession((s) => s.completePhase)
|
||||||
|
|
||||||
const ready = device.connected && domain.trim().length > 0
|
const ready = device.connected && domain.trim().length > 0
|
||||||
|
|
||||||
const onProceed = () => {
|
const onProceed = () => {
|
||||||
|
// Carry the domain into Layer 1 as a starting draft (only if untouched).
|
||||||
|
if (!add.L1.trim() && domain.trim()) {
|
||||||
|
setAddLayer('L1', `Domain: ${domain.trim()}. Events: an impact spike, a sustained sway, a stale sensor.`)
|
||||||
|
}
|
||||||
completePhase('setup')
|
completePhase('setup')
|
||||||
navigate('/workshop/module1')
|
navigate('/workshop/module1')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-background">
|
<section>
|
||||||
<header className="px-8 py-5 border-b border-border flex items-center justify-between">
|
<PanelHeading
|
||||||
<div className="font-mono text-xs tracking-widest uppercase">
|
eyebrow="PHASE 2 OF 5 · ~15 MIN"
|
||||||
APESS <span className="text-primary font-bold">2026</span>
|
title="Meet your agent"
|
||||||
<span className="text-muted-foreground"> · Workshop</span>
|
intro="Your board runs the APESS agent — a Claude-powered agent on the edge. It reasons about your domain, drives the board's own devices, and keeps working when the cloud drops. Open it to explore, then name the domain it's for."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Open your agent */}
|
||||||
|
<PanelCard className="mt-9">
|
||||||
|
<div className="text-[17px] font-semibold">Open your agent to explore</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<OpenYourNode variant="hero" />
|
||||||
</div>
|
</div>
|
||||||
<Link to="/workshop" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
|
</PanelCard>
|
||||||
← Team registration
|
|
||||||
</Link>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<PhaseStrip active="setup" />
|
{/* Pick your domain — emphasized: this seeds all five layers */}
|
||||||
|
<PanelCard emphasized className="mt-[22px]">
|
||||||
<section className="px-8 py-10 max-w-3xl mx-auto space-y-6">
|
<div className="text-[17px] font-semibold">Pick your domain</div>
|
||||||
<div>
|
<p className="mt-1.5 text-[14px] leading-[1.5] text-ink-3">
|
||||||
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
|
This one line seeds all five layers of your Agent Design Document — the domain your agent
|
||||||
Phase 2 of 5 · ~15 min
|
serves and the events it must notice.
|
||||||
</Badge>
|
</p>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Meet your agent</h1>
|
<div className="mt-4">
|
||||||
<p className="text-sm text-muted-foreground mt-2 max-w-2xl leading-relaxed">
|
<DomainPicker />
|
||||||
Your board now runs the <span className="font-medium text-foreground">APESS agent</span> — a
|
|
||||||
Claude-powered agent living on the edge. It reasons about your domain, drives the board’s
|
|
||||||
own devices, and keeps working when the cloud drops by falling back to an on-board model.
|
|
||||||
Open it to explore, then name the domain it’s for.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</PanelCard>
|
||||||
|
|
||||||
{/* 1 — Open your agent (hero) */}
|
<div className="mt-9 flex justify-end">
|
||||||
<Card>
|
<ProceedButton disabled={!ready} onClick={onProceed}>
|
||||||
<CardHeader>
|
Proceed to Module 1 →
|
||||||
<CardTitle className="text-base">Open your agent to explore</CardTitle>
|
</ProceedButton>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
</section>
|
||||||
<OpenYourNode variant="hero" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 2 — Pick your domain */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Pick your domain</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<DomainPicker />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="flex justify-end pt-4">
|
|
||||||
<Button size="lg" disabled={!ready} onClick={onProceed}>
|
|
||||||
Proceed to Module 1 →
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,12 +30,9 @@ describe('Module1', () => {
|
|||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the phase strip set to m1 and the heading', () => {
|
it('renders the heading', () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
expect(screen.getByRole('heading', { name: /domain.*events/i })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { name: /domain.*events/i })).toBeInTheDocument()
|
||||||
expect(
|
|
||||||
screen.getByTestId('phase-strip').querySelector('[data-phase="m1"]'),
|
|
||||||
).toHaveAttribute('data-state', 'active')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the domain carried over from the earlier screen (read-only)', () => {
|
it('shows the domain carried over from the earlier screen (read-only)', () => {
|
||||||
|
|||||||
+31
-48
@@ -1,8 +1,6 @@
|
|||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { PhaseStrip } from '@/components/PhaseStrip'
|
|
||||||
import { AddLayerForm } from '@/components/AddLayerForm'
|
import { AddLayerForm } from '@/components/AddLayerForm'
|
||||||
|
import { PanelHeading, ProceedButton } from '@/components/cockpit/PanelChrome'
|
||||||
import { useNodeFeed } from '@/lib/useNodeFeed'
|
import { useNodeFeed } from '@/lib/useNodeFeed'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
@@ -14,8 +12,6 @@ export function Module1() {
|
|||||||
const domain = useSession((s) => s.domain)
|
const domain = useSession((s) => s.domain)
|
||||||
const completePhase = useSession((s) => s.completePhase)
|
const completePhase = useSession((s) => s.completePhase)
|
||||||
|
|
||||||
// The board's own loop runs on-device, so an online board (or any activity
|
|
||||||
// from it) is the proof that the sense→reason loop is live.
|
|
||||||
const sensed = feed.online || feed.activity.length > 0
|
const sensed = feed.online || feed.activity.length > 0
|
||||||
const ready = sensed && l1.trim().length > 0
|
const ready = sensed && l1.trim().length > 0
|
||||||
|
|
||||||
@@ -25,55 +21,42 @@ export function Module1() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-background">
|
<section>
|
||||||
<header className="px-8 py-5 border-b border-border flex items-center justify-between">
|
<PanelHeading
|
||||||
<div className="font-mono text-xs tracking-widest uppercase">
|
eyebrow="PHASE 3 OF 5 · ~75 MIN"
|
||||||
APESS <span className="text-primary font-bold">2026</span>
|
title="Module 1 · Domain & events"
|
||||||
<span className="text-muted-foreground"> · Workshop</span>
|
intro="Define the domain your agent is for and the events it must sense and act on — carried from what you named earlier. This is Layer 1 of your Agent Design Document."
|
||||||
</div>
|
size={46}
|
||||||
<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" />
|
<div
|
||||||
|
className="mt-9 rounded-xl border border-line bg-surface-soft px-5 py-4"
|
||||||
<section className="px-8 py-10 max-w-5xl mx-auto space-y-6">
|
data-testid="domain-carried"
|
||||||
<div>
|
>
|
||||||
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
|
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--muted)]">Your domain</div>
|
||||||
Phase 3 of 5 · ~75 min
|
{domain.trim() ? (
|
||||||
</Badge>
|
<div className="mt-1.5 text-[19px] font-semibold tracking-[-0.01em]">{domain}</div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Module 1 · Domain & events</h1>
|
) : (
|
||||||
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
|
<div className="mt-1.5 text-[14px] text-ink-3">
|
||||||
Define the domain your agent is for and the events it must sense and act on. Carried over
|
Not set yet — name it on <span className="font-medium">Meet your agent</span>.
|
||||||
from the domain you named earlier — now draft Layer 1 of your Agent Design Document.
|
</div>
|
||||||
</p>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border border-border bg-card px-5 py-4 space-y-1.5" data-testid="domain-carried">
|
|
||||||
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Your domain</div>
|
|
||||||
{domain.trim() ? (
|
|
||||||
<div className="text-lg font-semibold tracking-tight">{domain}</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Not set yet — name it on <span className="font-medium">Meet your agent</span>.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
<AddLayerForm
|
<AddLayerForm
|
||||||
layer="L1"
|
layer="L1"
|
||||||
title="ADD · Layer 1 — Domain & events"
|
title="ADD · Layer 1 — Domain & events"
|
||||||
description="What domain does your node operate in, and what events must it notice?"
|
description="What domain does your node operate in, and what events must it notice?"
|
||||||
placeholder="Domain: structural resonance monitoring. Events: an impact spike, a sustained sway, a stale sensor."
|
placeholder="Domain: structural resonance monitoring. Events: an impact spike, a sustained sway, a stale sensor."
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end pt-2">
|
<div className="mt-6 flex justify-end">
|
||||||
<Button size="lg" disabled={!ready} onClick={onProceed}>
|
<ProceedButton disabled={!ready} onClick={onProceed}>
|
||||||
Proceed to Module 2 →
|
Proceed to Module 2 →
|
||||||
</Button>
|
</ProceedButton>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,12 +37,9 @@ describe('Module2', () => {
|
|||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the phase strip set to m2 and the heading', () => {
|
it('renders the heading', () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
expect(screen.getByRole('heading', { name: /skills.*policies/i })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { name: /skills.*policies/i })).toBeInTheDocument()
|
||||||
expect(
|
|
||||||
screen.getByTestId('phase-strip').querySelector('[data-phase="m2"]'),
|
|
||||||
).toHaveAttribute('data-state', 'active')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('is a chat with the three canned prompts — no live feed / build & flash', () => {
|
it('is a chat with the three canned prompts — no live feed / build & flash', () => {
|
||||||
|
|||||||
+66
-84
@@ -1,109 +1,91 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { 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 { AgentChat } from '@/components/AgentChat'
|
import { AgentChat } from '@/components/AgentChat'
|
||||||
import { AddLayerForm } from '@/components/AddLayerForm'
|
import { AddLayerForm } from '@/components/AddLayerForm'
|
||||||
|
import { PanelHeading, PanelCard, ProceedButton } from '@/components/cockpit/PanelChrome'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
|
const L2_PREFILL =
|
||||||
|
'i2c_scan to enumerate both ADXL355Z; matrix_text and matrix_count for on-board readouts; adxl355_stream for live vibration.'
|
||||||
|
const L3_PREFILL =
|
||||||
|
'Report only on exception; escalate ambiguous or high-consequence calls to the cloud model; never suppress a stale-sensor alarm.'
|
||||||
|
|
||||||
export function Module2() {
|
export function Module2() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const l2 = useSession((s) => s.add.L2)
|
const l2 = useSession((s) => s.add.L2)
|
||||||
const l3 = useSession((s) => s.add.L3)
|
const l3 = useSession((s) => s.add.L3)
|
||||||
|
const tried = useSession((s) => s.tried)
|
||||||
|
const setTried = useSession((s) => s.setTried)
|
||||||
|
const setAddLayer = useSession((s) => s.setAddLayer)
|
||||||
const completePhase = useSession((s) => s.completePhase)
|
const completePhase = useSession((s) => s.completePhase)
|
||||||
const [tried, setTried] = useState(0)
|
|
||||||
|
|
||||||
const allTried = tried >= 3
|
const allTried = tried >= 3
|
||||||
const ready = allTried && l2.trim().length > 0 && l3.trim().length > 0
|
const ready = allTried && l2.trim().length > 0 && l3.trim().length > 0
|
||||||
|
|
||||||
|
// On reaching 3/3, seed Layers 2 & 3 with a starting draft (only if untouched).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allTried) return
|
||||||
|
if (!useSession.getState().add.L2.trim()) setAddLayer('L2', L2_PREFILL)
|
||||||
|
if (!useSession.getState().add.L3.trim()) setAddLayer('L3', L3_PREFILL)
|
||||||
|
}, [allTried, setAddLayer])
|
||||||
|
|
||||||
const onProceed = () => {
|
const onProceed = () => {
|
||||||
completePhase('m2')
|
completePhase('m2')
|
||||||
navigate('/workshop/add')
|
navigate('/workshop/add')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-background">
|
<section>
|
||||||
<header className="px-8 py-5 border-b border-border flex items-center justify-between">
|
<PanelHeading
|
||||||
<div className="font-mono text-xs tracking-widest uppercase">
|
eyebrow="PHASE 4 OF 5 · ~90 MIN"
|
||||||
APESS <span className="text-primary font-bold">2026</span>
|
title="Module 2 · Skills & policies"
|
||||||
<span className="text-muted-foreground"> · Workshop</span>
|
intro="Talk to your agent and watch it run real tools on your board. Each prompt makes it use a built-in skill — then capture your domain's skills and the policy that governs them."
|
||||||
</div>
|
size={46}
|
||||||
<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-3xl 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 · Skills & policies</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
|
|
||||||
Your agent already ships with expert skills — hardware, the MCU bridge, the LED matrix,
|
|
||||||
flashing, and more. Try them from the chat below: each prompt makes the agent use its
|
|
||||||
skills and tools on your real board.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="mt-9">
|
||||||
<AgentChat onProgress={(done) => setTried(done)} />
|
<AgentChat onProgress={(done) => setTried(done)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* What's next — revealed once all three prompts have run successfully. */}
|
{allTried ? (
|
||||||
{allTried ? (
|
<div className="mt-8 space-y-5" data-testid="whats-next">
|
||||||
<div className="space-y-6" data-testid="whats-next">
|
<div>
|
||||||
<div className="pt-2">
|
<h2 className="text-[22px] font-semibold tracking-[-0.01em]">What’s next</h2>
|
||||||
<h2 className="text-lg font-semibold tracking-tight">What’s next</h2>
|
<p className="mt-1 max-w-[560px] text-[15px] text-ink-2">
|
||||||
<p className="text-sm text-muted-foreground mt-1 max-w-xl">
|
You just watched the agent enumerate the bus and drive the matrix with its built-in
|
||||||
You just watched the agent enumerate a bus and drive the matrix using its built-in
|
skills. Now capture <span className="font-medium text-ink">your domain’s</span> skills and
|
||||||
skills. Now capture <span className="font-medium text-foreground">your domain’s</span>{' '}
|
the policy that governs them — Layers 2 and 3, drafted below.
|
||||||
skills and the policy that governs them — Layers 2 and 3 of your Agent Design Document.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AddLayerForm
|
|
||||||
layer="L2"
|
|
||||||
title="ADD · Layer 2 — Skills"
|
|
||||||
description="What skills can the agent invoke to act on its domain?"
|
|
||||||
placeholder="Flash a sketch to the MCU; scroll a message; drive the damper; sample the IMU."
|
|
||||||
/>
|
|
||||||
<AddLayerForm
|
|
||||||
layer="L3"
|
|
||||||
title="ADD · Layer 3 — Policies & failure"
|
|
||||||
description="The actuation gate — and what happens when things break. For each failure you can name, which way does it fail? A fail-safe must never quietly report 'normal'."
|
|
||||||
placeholder={
|
|
||||||
'Autonomous: log + alert. Needs approval: drive the actuator. E-stop: operator halts actuation at any time.\n\n' +
|
|
||||||
'Failure states → response:\n' +
|
|
||||||
'• sensor disconnected / stuck value / drifting → mark UNKNOWN, never "nominal"\n' +
|
|
||||||
'• reading older than 60 s → treat as no reading\n' +
|
|
||||||
'• cloud unreachable → decide on-board, flag reduced confidence\n' +
|
|
||||||
'• agent unsure → escalate to a human, do not actuate'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end pt-2">
|
|
||||||
<Button size="lg" disabled={!ready} onClick={onProceed}>
|
|
||||||
Proceed to ADD builder →
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<Card>
|
<AddLayerForm
|
||||||
<CardHeader>
|
layer="L2"
|
||||||
<CardTitle className="text-base text-muted-foreground">What’s next</CardTitle>
|
title="ADD · Layer 2 — Skills"
|
||||||
</CardHeader>
|
description="What skills can the agent invoke to act on its domain?"
|
||||||
<CardContent>
|
placeholder="Flash a sketch to the MCU; scroll a message; drive the damper; sample the IMU."
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
/>
|
||||||
Try all three prompts above. Once your agent has run each one successfully, we’ll
|
<AddLayerForm
|
||||||
capture your domain’s skills and policies here.
|
layer="L3"
|
||||||
</p>
|
title="ADD · Layer 3 — Policies & failure"
|
||||||
</CardContent>
|
description="The actuation gate — and what happens when things break. A fail-safe must never quietly report 'normal'."
|
||||||
</Card>
|
placeholder="Report only on exception; escalate ambiguous calls to the cloud; never suppress a stale-sensor alarm."
|
||||||
)}
|
/>
|
||||||
</section>
|
|
||||||
</main>
|
<div className="flex justify-end">
|
||||||
|
<ProceedButton disabled={!ready} onClick={onProceed}>
|
||||||
|
Proceed to Module 3 →
|
||||||
|
</ProceedButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PanelCard className="mt-6">
|
||||||
|
<div className="text-[15px] font-semibold text-ink-2">What’s next</div>
|
||||||
|
<p className="mt-2 text-[14px] leading-[1.5] text-ink-3">
|
||||||
|
Try all three prompts above. Once your agent has run each one successfully, we’ll
|
||||||
|
capture your domain’s skills and policies here.
|
||||||
|
</p>
|
||||||
|
</PanelCard>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,8 @@ describe('TeamRegistration', () => {
|
|||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the phase strip and the team form heading', () => {
|
it('renders the team form heading', () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
expect(screen.getByTestId('phase-strip')).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('heading', { name: /team registration/i })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { name: /team registration/i })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+73
-113
@@ -1,14 +1,11 @@
|
|||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { MemberFields } from '@/components/MemberFields'
|
import { MemberFields } from '@/components/MemberFields'
|
||||||
import { PhaseStrip } from '@/components/PhaseStrip'
|
|
||||||
import { BoardClaim } from '@/components/BoardClaim'
|
import { BoardClaim } from '@/components/BoardClaim'
|
||||||
import { SayHiCard } from '@/components/SayHiCard'
|
import { SayHiCard } from '@/components/SayHiCard'
|
||||||
import { TelegramSetup } from '@/components/TelegramSetup'
|
import { TelegramSetup } from '@/components/TelegramSetup'
|
||||||
import { VoiceSetup } from '@/components/VoiceSetup'
|
import { VoiceSetup } from '@/components/VoiceSetup'
|
||||||
|
import { PanelHeading, PanelCard, ProceedButton, FieldLabel } from '@/components/cockpit/PanelChrome'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
export function TeamRegistration() {
|
export function TeamRegistration() {
|
||||||
@@ -31,122 +28,85 @@ export function TeamRegistration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-background">
|
<section>
|
||||||
<header className="px-8 py-5 border-b border-border flex items-center justify-between">
|
<PanelHeading
|
||||||
<div className="font-mono text-xs tracking-widest uppercase">
|
eyebrow="PHASE 1 OF 5 · ~10 MIN"
|
||||||
APESS <span className="text-primary font-bold">2026</span>
|
title="Team registration"
|
||||||
<span className="text-muted-foreground"> · Workshop</span>
|
intro="Name your team, add 3–5 members, then bind the board you set up this week — run the app and enter the code it scrolls across its LED matrix."
|
||||||
</div>
|
/>
|
||||||
<Link to="/" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
|
|
||||||
← Back to landing
|
|
||||||
</Link>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<PhaseStrip active="reg" />
|
<div className="mt-9 grid gap-5 md:grid-cols-2">
|
||||||
|
<PanelCard>
|
||||||
|
<div className="text-[17px] font-semibold">Team</div>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<FieldLabel>Team name</FieldLabel>
|
||||||
|
<Input
|
||||||
|
aria-label="Team name"
|
||||||
|
placeholder="team_resonance"
|
||||||
|
value={team.name}
|
||||||
|
onChange={(e) => setTeam({ name: e.target.value })}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 space-y-2">
|
||||||
|
<FieldLabel>Members</FieldLabel>
|
||||||
|
<MemberFields members={team.members} onChange={(members) => setTeam({ members })} />
|
||||||
|
</div>
|
||||||
|
</PanelCard>
|
||||||
|
|
||||||
<section className="px-8 py-10 max-w-5xl mx-auto space-y-6">
|
<PanelCard>
|
||||||
<div className="flex items-end justify-between flex-wrap gap-3">
|
<div className="text-[17px] font-semibold">Your board</div>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<FieldLabel>Bind your node</FieldLabel>
|
||||||
|
<BoardClaim
|
||||||
|
teamId={teamId}
|
||||||
|
teamName={team.name}
|
||||||
|
members={team.members}
|
||||||
|
connected={device.connected}
|
||||||
|
port={device.port}
|
||||||
|
initialCode={/^\d{4,6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
|
||||||
|
onDisconnect={disconnect}
|
||||||
|
onClaimed={(r) => {
|
||||||
|
if (r.resumed && r.team) {
|
||||||
|
resumeTeam({
|
||||||
|
id: r.team.id,
|
||||||
|
name: r.team.name,
|
||||||
|
kit: r.team.kit,
|
||||||
|
members: r.team.members,
|
||||||
|
phases: r.team.phases,
|
||||||
|
stats: r.team.stats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setDevice({ connected: true, port: `board · ${r.kit}`, uptimeS: 0, nodeUrl: r.url ?? null })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PanelCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The connection-sequencing fix: channels appear only after the board is
|
||||||
|
bound, so the first-connection moment isn't a pile-up. */}
|
||||||
|
{device.connected && (
|
||||||
|
<div className="mt-8 space-y-5" data-testid="post-connect">
|
||||||
<div>
|
<div>
|
||||||
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
|
<h2 className="text-[22px] font-semibold tracking-[-0.01em]">Your agent</h2>
|
||||||
Phase 1 of 5 · ~10 min
|
<p className="mt-1 text-[15px] text-ink-2">
|
||||||
</Badge>
|
Your board is bound — say hi to the agent on it, then set up how you reach it.
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Team registration</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
|
|
||||||
Name your team, add 3–5 members, then bind the board you already set up this
|
|
||||||
week — run the setup script and enter the code it scrolls on its LED matrix.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<SayHiCard />
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
<div className="grid lg:grid-cols-2 gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Team</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="team-name" className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
|
||||||
Team name
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="team-name"
|
|
||||||
placeholder="team_resonance"
|
|
||||||
value={team.name}
|
|
||||||
onChange={(e) => setTeam({ name: e.target.value })}
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
|
||||||
Members
|
|
||||||
</div>
|
|
||||||
<MemberFields
|
|
||||||
members={team.members}
|
|
||||||
onChange={(members) => setTeam({ members })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Your board</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
|
||||||
Bind your node
|
|
||||||
</div>
|
|
||||||
<BoardClaim
|
|
||||||
teamId={teamId}
|
|
||||||
teamName={team.name}
|
|
||||||
members={team.members}
|
|
||||||
connected={device.connected}
|
|
||||||
port={device.port}
|
|
||||||
initialCode={/^\d{4,6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
|
|
||||||
onDisconnect={disconnect}
|
|
||||||
onClaimed={(r) => {
|
|
||||||
// Resume (a lost-browser re-claim): adopt the board's canonical
|
|
||||||
// team + restore its progress instead of keeping this fresh id.
|
|
||||||
if (r.resumed && r.team) {
|
|
||||||
resumeTeam({
|
|
||||||
id: r.team.id,
|
|
||||||
name: r.team.name,
|
|
||||||
kit: r.team.kit,
|
|
||||||
members: r.team.members,
|
|
||||||
phases: r.team.phases,
|
|
||||||
stats: r.team.stats,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
setDevice({ connected: true, port: `board · ${r.kit}`, uptimeS: 0, nodeUrl: r.url ?? null })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Once the board is bound, the agent-facing setup unfolds below. */}
|
|
||||||
{device.connected && (
|
|
||||||
<div className="space-y-6" data-testid="post-connect">
|
|
||||||
<div className="pt-2">
|
|
||||||
<h2 className="text-lg font-semibold tracking-tight">Your agent</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Your board is bound — now meet the agent on it and set up how you reach it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SayHiCard />
|
|
||||||
<TelegramSetup />
|
<TelegramSetup />
|
||||||
<VoiceSetup />
|
<VoiceSetup />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end pt-4">
|
|
||||||
<Button size="lg" disabled={!ready} onClick={onProceed}>
|
|
||||||
Meet your agent →
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
)}
|
||||||
</main>
|
|
||||||
|
<div className="mt-9 flex justify-end">
|
||||||
|
<ProceedButton disabled={!ready} onClick={onProceed}>
|
||||||
|
Meet your agent →
|
||||||
|
</ProceedButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-2
@@ -54,8 +54,12 @@ export interface Channels {
|
|||||||
telegram: string | null
|
telegram: string | null
|
||||||
/** Whether the team enabled browser voice on the node. */
|
/** Whether the team enabled browser voice on the node. */
|
||||||
voice: boolean
|
voice: boolean
|
||||||
|
/** Whether the team has completed the "say hi" handshake with their agent. */
|
||||||
|
saidHi: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
/** Stable per-browser identity, generated once and persisted. */
|
/** Stable per-browser identity, generated once and persisted. */
|
||||||
function genTeamId(): string {
|
function genTeamId(): string {
|
||||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
||||||
@@ -74,7 +78,13 @@ export interface SessionState {
|
|||||||
submission: Submission
|
submission: Submission
|
||||||
/** Extra channels + voice, set up during onboarding (client-side prefs). */
|
/** Extra channels + voice, set up during onboarding (client-side prefs). */
|
||||||
channels: Channels
|
channels: Channels
|
||||||
|
/** UI theme, toggled from the cockpit header; persisted. */
|
||||||
|
theme: Theme
|
||||||
|
/** How many of Module 2's canned prompts have been run successfully (0–3). */
|
||||||
|
tried: number
|
||||||
setTeam: (patch: Partial<Team>) => void
|
setTeam: (patch: Partial<Team>) => void
|
||||||
|
setTheme: (theme: Theme) => void
|
||||||
|
setTried: (tried: number) => void
|
||||||
setDevice: (patch: Partial<Device>) => void
|
setDevice: (patch: Partial<Device>) => void
|
||||||
setDomain: (d: string) => void
|
setDomain: (d: string) => void
|
||||||
setChannels: (patch: Partial<Channels>) => void
|
setChannels: (patch: Partial<Channels>) => void
|
||||||
@@ -106,7 +116,9 @@ const initial = {
|
|||||||
stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 },
|
stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 },
|
||||||
add: { L1: '', L2: '', L3: '', L4: '', L5: '' } as AddLayers,
|
add: { L1: '', L2: '', L3: '', L4: '', L5: '' } as AddLayers,
|
||||||
submission: { code: null, submittedAt: null } as Submission,
|
submission: { code: null, submittedAt: null } as Submission,
|
||||||
channels: { telegram: null, voice: false } as Channels,
|
channels: { telegram: null, voice: false, saidHi: false } as Channels,
|
||||||
|
theme: 'light' as Theme,
|
||||||
|
tried: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSession = create<SessionState>()(
|
export const useSession = create<SessionState>()(
|
||||||
@@ -114,6 +126,8 @@ export const useSession = create<SessionState>()(
|
|||||||
(set) => ({
|
(set) => ({
|
||||||
...initial,
|
...initial,
|
||||||
setTeam: (patch) => set((s) => ({ team: { ...s.team, ...patch } })),
|
setTeam: (patch) => set((s) => ({ team: { ...s.team, ...patch } })),
|
||||||
|
setTheme: (theme) => set({ theme }),
|
||||||
|
setTried: (tried) => set({ tried }),
|
||||||
setDevice: (patch) => set((s) => ({ device: { ...s.device, ...patch } })),
|
setDevice: (patch) => set((s) => ({ device: { ...s.device, ...patch } })),
|
||||||
setDomain: (domain) => set({ domain }),
|
setDomain: (domain) => set({ domain }),
|
||||||
setChannels: (patch) => set((s) => ({ channels: { ...s.channels, ...patch } })),
|
setChannels: (patch) => set((s) => ({ channels: { ...s.channels, ...patch } })),
|
||||||
@@ -146,7 +160,7 @@ export const useSession = create<SessionState>()(
|
|||||||
// until they explicitly hit Disconnect (or Reset). sessionStorage was
|
// until they explicitly hit Disconnect (or Reset). sessionStorage was
|
||||||
// tab-volatile and dropped the connection on a hard reload.
|
// tab-volatile and dropped the connection on a hard reload.
|
||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
version: 3,
|
version: 4,
|
||||||
// v1 held a different shape (add.L1 was an object, no `domain`) — too stale
|
// v1 held a different shape (add.L1 was an object, no `domain`) — too stale
|
||||||
// to salvage, so reset. From v2 on we merge over `initial` so newly-added
|
// to salvage, so reset. From v2 on we merge over `initial` so newly-added
|
||||||
// fields (e.g. `channels`) are always present without wiping progress.
|
// fields (e.g. `channels`) are always present without wiping progress.
|
||||||
|
|||||||
+16
-1
@@ -6,9 +6,24 @@ export default {
|
|||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Newsreader', 'ui-serif', 'serif'],
|
sans: ['Newsreader', 'ui-serif', 'serif'],
|
||||||
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
|
mono: ['"IBM Plex Mono"', 'ui-monospace', 'monospace'],
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
|
// Cockpit design tokens (hex CSS vars, theme-swapped in index.css).
|
||||||
|
ink: { DEFAULT: 'var(--ink)', 2: 'var(--ink-2)', 3: 'var(--ink-3)' },
|
||||||
|
blue: { DEFAULT: 'var(--blue)', ink: 'var(--blue-ink)', eyebrow: 'var(--blue-eyebrow)' },
|
||||||
|
line: { DEFAULT: 'var(--line)', 2: 'var(--line-2)' },
|
||||||
|
surface: { DEFAULT: 'var(--surface)', soft: 'var(--surface-soft)' },
|
||||||
|
faint: 'var(--faint)',
|
||||||
|
green: { DEFAULT: 'var(--green)' },
|
||||||
|
// Fixed dark instrument-rail palette (same in both themes).
|
||||||
|
rail: {
|
||||||
|
bg: '#14161b', inset: '#0c0d11', panel: '#1b1e24',
|
||||||
|
line: '#23262e', line2: '#262a33',
|
||||||
|
text: '#e6e8ec', text2: '#c7cad1', text3: '#f2f3f5',
|
||||||
|
dim: '#7b8290', dim2: '#6b7280', dim3: '#565c68',
|
||||||
|
blue: '#7fa8ff', green: '#3fd28a', spike: '#ff8a5c',
|
||||||
|
},
|
||||||
border: 'hsl(var(--border))',
|
border: 'hsl(var(--border))',
|
||||||
input: 'hsl(var(--input))',
|
input: 'hsl(var(--input))',
|
||||||
ring: 'hsl(var(--ring))',
|
ring: 'hsl(var(--ring))',
|
||||||
|
|||||||
Reference in New Issue
Block a user