Compare commits

..
6 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 4.8 e76e27f06d chore(uno-q): auto-recover the board on USB re-plug
Replace the obsolete host-daemon recover.sh with the current App-Lab-node flow:
re-forward the adb tunnels (:8080 gateway, :9999 matrix relay), wait for the
daemon (start the app if needed), re-register the node with the local API, and
sync a fixed claim code onto the matrix so the board + API agree. Adds a
`--watch` mode and a launchd agent (com.redclaw.apess-board-recover.plist) that
runs it on every reconnect — so a re-plug heals itself, no manual step.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 07:38:21 -07:00
Omar SobhandClaude Opus 4.8 1a39457940 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]>
2026-07-23 07:38:14 -07:00
Omar SobhandClaude Opus 4.8 6c82b79b01 docs(uno-q): add WORKSHOP-FLOW.md; refresh ONBOARDING to current state
WORKSHOP-FLOW.md is the participant-journey / design-review doc: local-stack
launch, the module→ADD-layer map, each screen's job (does/sees/system), and the
open design questions. Mirrors the shared flow artifact.

ONBOARDING refresh: matrix tools now include matrix_count; Telegram ships
disabled (no more tokenless probe spam); drop the removed "Refine" step; cross-
link the two docs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 20:05:24 -07:00
Omar SobhandClaude Opus 4.8 909b9a7671 feat(uno-q): wire the native matrix_count path (relay + allowlist + skill)
Adds the `count N` command to the host matrix-relay, allowlists + auto-approves
matrix_count for the default agent, and steers the led-matrix skill to use
matrix_count for "count to N once a second" instead of flashing a sketch (which
fails in the App Lab container). Pairs with the zeroclaw sketch/tool changes.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 19:04:34 -07:00
Omar SobhandClaude Opus 4.8 8ea9c256b4 fix(nodes): surface the tool result as the agent's reply, not just "finished"
The Module 2 chat showed lifecycle labels ("Agent started / Running i2c_scan /
Agent finished") but never the actual answer. mapNodeEvent dropped the
`tool_call_result` log line, and the observability `agent_end` event is slow and
content-free (the native tool path leaves the final text empty). The real reply
lives in tool_call_result.attributes.output — e.g. i2c_scan →
"No I2C devices responded on the bus." Map it to a `response` activity (or
`error` on a failed tool), so the chat shows the outcome. Falls back to a
"<tool> ✓" marker when a tool returns no output (e.g. a matrix write).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 18:37:17 -07:00
Omar SobhandClaude Opus 4.8 107e5b0955 fix(onboard): ship telegram disabled so a tokenless channel can't spam probes
A tokenless [channels.telegram.default] with enabled=true starts the Telegram
channel anyway; its getUpdates startup probe fails every 5s ("Startup probe:
API error : ; retrying in 5s") and that WARN leaks into the Module 2 chat via
the node activity feed. Ship enabled=false; the setup wizard flips it on with a
real bot_token when a team opts in (api/src/nodes.ts configureTelegram).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 18:25:23 -07:00
36 changed files with 1593 additions and 515 deletions
+17
View File
@@ -4,6 +4,7 @@ import type { Store } from './db'
import { requireCode, requireAnyCode, matches } from './auth'
import type { TeamSnapshot, SubmissionDTO, WsEvent } from './types'
import type { NodeBridge } from './nodes'
import { readMatrixFrame } from './nodes'
import type { BoardRegistry } from './claim'
export interface AppOptions {
@@ -337,6 +338,22 @@ export function createApp(opts: AppOptions): Express {
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
// (the /ws hub is admin/judge only). Public, keyed by teamId.
app.get('/nodes/:teamId/events', (req, res) => {
+27
View File
@@ -83,6 +83,33 @@ describe('mapNodeEvent — ZeroClaw /api/events → WsEvent', () => {
expect(ev).toMatchObject({ type: 'node:activity', kind: 'response' })
})
it('surfaces a tool_call_result output as the response text (the real answer)', () => {
const ev = mapNodeEvent('t1', {
message: 'tool_call_result',
attributes: { tool: 'i2c_scan', output: 'No I2C devices responded on the bus.', error_reason: null },
event: { action: 'complete', category: 'tool', outcome: 'success' },
})
expect(ev).toMatchObject({ type: 'node:activity', kind: 'response', label: 'No I2C devices responded on the bus.' })
})
it('falls back to a tool ✓ marker when a tool result carries no output', () => {
const ev = mapNodeEvent('t1', {
message: 'tool_call_result',
attributes: { tool: 'matrix_text', output: '' },
event: { outcome: 'success' },
})
expect(ev).toMatchObject({ kind: 'response', label: 'matrix_text ✓' })
})
it('maps a failed tool result to an error activity', () => {
const ev = mapNodeEvent('t1', {
message: 'tool_call_result',
attributes: { tool: 'i2c_scan', output: 'bridge unreachable', error_reason: 'timeout' },
event: { outcome: 'failure' },
})
expect(ev).toMatchObject({ kind: 'error', label: 'bridge unreachable' })
})
it('ignores noisy/internal events (llm_request, plain notes, non-objects)', () => {
expect(mapNodeEvent('t1', { type: 'llm_request' })).toBeNull()
expect(mapNodeEvent('t1', { message: 'No sandbox backend available, using application-layer security' })).toBeNull()
+45
View File
@@ -1,3 +1,4 @@
import net from 'node:net'
import type { WsEvent, NodeActivityKind } from './types'
/** A team's ZeroClaw node: gateway URL + its server-side bearer token. */
@@ -7,6 +8,36 @@ export interface NodeRef {
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 {
register(ref: NodeRef): void
get(teamId: string): NodeRef | undefined
@@ -79,6 +110,20 @@ export function mapNodeEvent(teamId: string, raw: unknown): WsEvent | null {
// 2) Structured log lines carry a `message`.
const message = str(e.message)
if (message) {
// A tool's actual RESULT — the real reply. The observability `agent_end`
// event is slow and content-free (the native tool path leaves the final
// text empty), so this `tool_call_result` line is where the answer lives:
// e.g. i2c_scan → "No I2C devices responded on the bus." Surface it as the
// agent's response so the chat shows the outcome, not just "Agent finished".
if (message === 'tool_call_result') {
const attrs = e.attributes && typeof e.attributes === 'object' ? (e.attributes as Record<string, unknown>) : {}
const ev = e.event && typeof e.event === 'object' ? (e.event as Record<string, unknown>) : {}
const output = str(attrs.output).trim()
const tool = str(attrs.tool) || 'tool'
const failed = str(ev.outcome).toLowerCase() === 'failure' || str(attrs.error_reason).length > 0
if (failed) return activity('error', output || `${tool} failed`)
return activity('response', output || `${tool} ✓`)
}
if (/compiled and flashed/i.test(message)) {
const addr = message.match(/0x[0-9A-Fa-f]+/)?.[0]
return activity('flash', addr ? `Flashed to ${addr}` : 'Flashed to the MCU')
+15 -8
View File
@@ -1,5 +1,9 @@
# APESS onboarding — fully containerized
> **The setup runbook.** For the participant *journey* (each screen's job, the
> module→ADD-layer map, open design questions), see
> [`WORKSHOP-FLOW.md`](./WORKSHOP-FLOW.md).
A team needs two things running: the **APESS stack on their laptop** and the
**ZeroClaw node on their Uno Q**. Both are containers. Nothing installs to a host.
@@ -8,10 +12,11 @@ LAPTOP: docker compose up → apess-api + apess-web (deploy/lan)
BOARD : App Lab → Run → ONE container = daemon + relay + responder
```
Everything a team does — say-hi, the module chat, Refine, Telegram, the LED
matrix, the I2C scan — runs through this. The board never needs the Zephyr flash
toolchain or Linux `/dev/i2c`: the matrix is driven by a resident responder and
I2C is scanned on the MCU (Wire), both over the RouterBridge relay.
Everything a team does — say-hi, the module chat, Telegram, the LED matrix (text,
patterns, and the 0..N counter), the I2C scan — runs through this. The board never
needs the Zephyr flash toolchain or Linux `/dev/i2c`: the matrix is driven by a
resident responder and I2C is scanned on the MCU (Wire), both over the RouterBridge
relay.
## 1. Instructor — build + host the app (once)
@@ -22,10 +27,12 @@ export ANTHROPIC_OAUTH_TOKEN=sk-ant-oat01-… # baked into the bundle
./deploy/uno-q/package-onboard-app.sh # → dist/apess-onboard/ + dist/apess-onboard.zip
```
The bundle contains: the ZeroClaw binary (matrix_text + i2c_scan), the
single-`default`-agent config (matrix + i2c_scan allowlisted, Telegram-ready),
the skills, the responder sketch, and the baked token. It ships **without** a
`.secret_key` (each board mints its own on first Run) and **without** any team's
The bundle contains: the ZeroClaw binary (`matrix_text`, `matrix_pattern`,
`matrix_count`, `i2c_scan`), the single-`default`-agent config (those tools
allowlisted), the skills, the responder sketch, and the baked token. Telegram
ships **disabled** with an empty token (a tokenless channel would spam startup
probes) — the Phase-1 wizard flips it on when a team opts in. It ships **without**
a `.secret_key` (each board mints its own on first Run) and **without** any team's
Telegram token. The `dist/` output is gitignored (it holds the token).
The zip is a standard App Lab export archive (top dir = app name) — verified to
+137
View File
@@ -0,0 +1,137 @@
# APESS 2026 — Workshop flow
**From a cold laptop to a shipped agent design.** The end-to-end path a team walks
today — every screen, the job it does, what the person *does* versus what they
*see*, and the spots we think the flow can get better.
> Shared for design review. Companion to [`ONBOARDING.md`](./ONBOARDING.md) (the
> operational runbook) — this doc is the *journey*, that one is the *setup*.
`Arduino Uno Q · 4GB` · `ZeroClaw edge agent` · `FabLab Torino` · `27 Jul 2026`
---
## Before the room fills — launch the local stack
The workshop runs **on the team's own laptop**, right next to the board — a
cloud API can't reach devices behind the room's NAT. One command brings up the
web app and the API together.
```sh
# bring up web + API on the team laptop (same WiFi as the board)
cd deploy/lan
cp .env.example .env # set ADMIN_CODE, JUDGE_CODE, FLEET_SECRET
docker compose --env-file .env -f docker-compose.yml up -d --build
# → web on :80 · API proxied same-origin at /api · board app served at /download/
# ✓ everyone (team · board · judge) opens http://<laptop-ip>/
```
- **Local-first** — autonomous and offline-capable; nothing depends on the cloud during the session.
- **Shared secret** — `FLEET_SECRET` must match the value baked into the board app, or self-registration is rejected.
- **The board app** — the same laptop serves `/download/apess-onboard.zip`, the one-click App Lab app the team imports next.
---
## The through-line — one document, built five layers deep
Every screen after setup adds a layer to the team's **Agent Design Document
(ADD)**. The live board proves each capability as they design it — the modules
aren't lessons, they're the ADD taking shape.
| Layer | What it captures | Where |
|-------|------------------|-------|
| **Layer 1** | Domain & events | Module 1 |
| **Layer 2** | Skills | Module 2 |
| **Layer 3** | Policies & failure | Module 2 |
| **Layer 4** | Harness | Module 3 |
| **Layer 5** | Loops | Module 3 |
---
## The path, screen by screen
### ◦ Landing — `/`
Sets the frame: a Claude agent on the edge, on real hardware. One door in.
- **Does** — clicks **Start the workshop**.
- **Sees** — the pitch, the presenter, the single call to action — nothing else competing.
### ① Team registration — `/workshop` · Phase 1
Claim a name, and claim a board — the moment the physical device becomes *this
team's* agent.
- **Does** — enters team name + members → downloads the board app → App Lab **Import → Run** → types the code the matrix scrolls → **Bind board**.
- **Sees** — the board light up and scroll its claim code; on bind, three cards: *Say hi*, *Set up Telegram*, *Enable voice*.
- **System** — node self-registers to the laptop; the bearer token moves pool→bridge and never touches the browser.
- ⚠ **Watch** — first-connection is the busiest moment in the flow: binding plus three optional channel cards all land at once. Worth sequencing.
### ② Meet your agent — `/workshop/setup` · Phase 2
Introduce the agent, then name the **domain** it will serve — the seed the whole
ADD grows from.
- **Does** — opens the agent dashboard to explore, then **picks a domain** (e.g. "structural stress").
- **Sees** — the live ZeroClaw dashboard on the board; a single domain input that gates progress.
- ⚠ **Watch** — the domain drives every later layer but is introduced almost in passing. Does it deserve more weight this early?
### ③ Module 1 — Domain & events — `/workshop/module1` · ADD Layer 1
Turn the chosen domain into the world the agent lives in and the events it
reacts to.
- **Sees** — their domain carried over, read-only.
- **Does** — drafts Layer 1 of the Agent Design Document.
### ④ Module 2 — Skills & policies — `/workshop/module2` · ADD Layers 2–3
The hands-on core: talk to the agent, watch it use real tools on the board, then
codify what it can do and what governs it.
| Prompt | Tool | What it proves |
|--------|------|----------------|
| List the I2C devices on the bus | `i2c_scan` | reads real hardware |
| Count to 100, once a second, on the matrix | `matrix_count` | a timed loop on the MCU |
| Scroll GO CLAWS on the matrix | `matrix_text` | instant runtime display |
- **Sees** — each tool's *actual result* stream back into the chat — no flashing, all in-container.
- **Does** — runs all three → **What's next** unlocks Layers 2 & 3 (Skills, Policies & failure).
- ⚠ **Watch** — Module 2 alone carries two ADD layers plus the only live-hardware moment — heavier than 1 and 3. Prompts must be phrased as commands, since that's what reliably drives tools.
### ⑤ Module 3 — Harness, loops & submit — `/workshop/add` · ADD Layers 4–5
Finish the design: where each decision runs, how it repeats, and what happens
when a cycle fails — then ship it.
- **Does** — drafts Layer 4 (Harness) & Layer 5 (Loops), reviews the assembled document, hits **Submit ADD**.
- **Sees** — all five layers in one place; a confirmed submission.
- **System** — the ADD lands in the API, ready for judging at `/judge`.
---
## Behind the scenes
- **Fleet & scoring** — `/admin` shows every team, their phase and board health; `/judge` scores the submitted ADDs across the cohort.
- **Persistence** — team, board binding and progress live in the browser; a refresh or nav-away resumes where they left off, until an explicit Disconnect.
- **Reachability** — board and laptop share WiFi; the board self-registers over mDNS. If the room WiFi isolates clients, USB tethering is the fallback path.
- **One agent, no flashing** — a single agent with all skills. Matrix and I2C run on a resident MCU responder over a socket — instant, in-container, nothing to re-flash mid-workshop.
---
## For the designer — where the flow could get better
The honest open questions — where the current path works but feels uneven. This
is what we'd love a fresh eye on.
1. **The first-connection pile-up** — binding the board and three optional channel setups (say-hi, Telegram, voice) all appear at the same instant. What's the right sequence — celebrate the connection first, then offer channels?
2. **Uneven module weight** — layers map 1 → 2·3 → 4·5 across the three modules, so Module 2 does double duty *and* owns the only live-hardware moment. Rebalance the pacing, or split Module 2?
3. **The domain's quiet debut** — the domain seeds all five layers yet is chosen in one small field during "Meet your agent." Does it need a stronger framing moment?
4. **Feedback for a slow agent** — a cloud round-trip can take seconds; a tool result streams back as plain lines. What does "the agent is thinking / working" look like so waiting never reads as broken?
5. **When the board drops** — USB unplugs and WiFi isolation are real. The recovery path exists but is invisible to the team — how should a disconnect surface, and guide them back?
---
*Current-state workflow — APESS 2026 · RedClaw · Uno Q + ZeroClaw.*
*A rendered version of this doc is available as a shareable web page (ask the presenter for the link).*
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
launchd agent: auto-run the board recovery watcher.
It keeps `recover.sh --watch` alive, which sits on `adb wait-for-device` and
re-forwards tunnels + re-registers the node with the local API every time the
Uno Q reconnects — so a USB re-plug heals itself with no manual step.
Install (per-user):
cp deploy/uno-q/com.redclaw.apess-board-recover.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.redclaw.apess-board-recover.plist
Stop / uninstall:
launchctl unload ~/Library/LaunchAgents/com.redclaw.apess-board-recover.plist
Logs: ~/Library/Logs/apess-board-recover.log
Edit the paths below if your checkout lives elsewhere.
-->
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.redclaw.apess-board-recover</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/quantum/projects/apress/deploy/uno-q/recover.sh</string>
<string>--watch</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<!-- launchd's PATH is minimal; add Homebrew + platform-tools for adb/python3/curl. -->
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<!-- Override any of recover.sh's knobs here if needed, e.g.: -->
<!-- <key>CLAIM_CODE</key><string>7777</string> -->
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>/Users/quantum/Library/Logs/apess-board-recover.log</string>
<key>StandardErrorPath</key>
<string>/Users/quantum/Library/Logs/apess-board-recover.log</string>
</dict>
</plist>
+2 -2
View File
@@ -82,8 +82,8 @@ prompt_injection_mode = "compact"
# without a human approver (the webhook path is non-interactive).
[risk_profiles.default]
level = "supervised"
allowed_tools = ["matrix_pattern", "matrix_text", "i2c_scan", "uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect", "read_skill", "file_read", "content_search"]
auto_approve = ["matrix_pattern", "matrix_text", "i2c_scan", "uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect", "read_skill", "file_read", "content_search"]
allowed_tools = ["matrix_pattern", "matrix_text", "matrix_count", "i2c_scan", "uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect", "read_skill", "file_read", "content_search"]
auto_approve = ["matrix_pattern", "matrix_text", "matrix_count", "i2c_scan", "uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect", "read_skill", "file_read", "content_search"]
[runtime_profiles.unoq]
agentic = true
+6
View File
@@ -34,6 +34,12 @@ def handle(conn):
elif cmd == "text" and len(parts) >= 2:
Bridge.call("matrix_text", " ".join(parts[1:]))
conn.sendall(b"ok\n")
elif cmd == "count" and len(parts) >= 2:
Bridge.call("matrix_count", int(parts[1]))
conn.sendall(b"ok\n")
elif cmd == "matrixget":
r = Bridge.call("matrix_get")
conn.sendall(f"{r}\n".encode())
elif cmd == "i2c":
r = Bridge.call("i2c_scan")
conn.sendall(f"{r}\n".encode())
+7 -3
View File
@@ -203,8 +203,8 @@ prompt_injection_mode = "compact"
[risk_profiles.default]
level = "supervised"
allowed_tools = ["matrix_pattern", "matrix_text", "i2c_scan", "uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect", "read_skill", "file_read", "content_search"]
auto_approve = ["matrix_pattern", "matrix_text", "i2c_scan", "uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect", "read_skill", "file_read", "content_search", "memory_recall", "web_search_tool", "web_fetch", "calculator", "glob_search", "image_info", "weather", "tool_search", "browser", "browser_open"]
allowed_tools = ["matrix_pattern", "matrix_text", "matrix_count", "i2c_scan", "uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect", "read_skill", "file_read", "content_search"]
auto_approve = ["matrix_pattern", "matrix_text", "matrix_count", "i2c_scan", "uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect", "read_skill", "file_read", "content_search", "memory_recall", "web_search_tool", "web_fetch", "calculator", "glob_search", "image_info", "weather", "tool_search", "browser", "browser_open"]
allowed_commands = ["git", "npm", "cargo", "ls", "cat", "grep", "find", "echo", "pwd", "wc", "head", "tail", "date", "df", "du", "uname", "uptime", "hostname", "python", "python3", "pip", "node", "free"]
allowed_roots = []
always_ask = []
@@ -383,7 +383,11 @@ transcription_provider = ""
tts_provider = ""
[channels.telegram.default]
enabled = true
# Ships DISABLED: a tokenless channel would fail its getUpdates startup probe
# every 5s ("Startup probe: API error"), and that noise leaks into the module
# chat. The Telegram setup wizard flips this to enabled + a real bot_token when
# a team opts in (see api/src/nodes.ts configureTelegram).
enabled = false
bot_token = ""
api_base_url = "https://api.telegram.org"
approval_timeout_secs = 120
+92 -69
View File
@@ -1,81 +1,104 @@
#!/usr/bin/env bash
# recover.sh — one-command recovery for the APESS Uno Q demo node after a USB drop.
# recover.sh — restore the Uno Q board's LOCAL DEV binding after a USB re-plug.
#
# On a disconnect the daemon/llama/bridge die and the cloud token (env-only) is lost.
# This re-tunnels, relaunches the supervisor WITH the token in its environment,
# restarts the matrix bridge app, and verifies the whole chain end-to-end.
# On re-plug the Uno Q's Linux reboots and the laptop's adb tunnels vanish, so
# the local API (and the LED-matrix mirror) lose the board; the board also boots
# showing a fresh random claim code the API never received. This makes it all
# consistent again in one pass:
# 1. re-forward the adb tunnels (:8080 gateway, :9999 matrix relay)
# 2. wait for the node daemon (start the App Lab app if it isn't up)
# 3. re-register the node with the local API (restores its in-memory binding)
# 4. scroll a fixed claim code on the matrix so the board + API agree
#
# Secrets are read from the environment — NEVER hardcoded here. Export first:
# export ANTHROPIC_OAUTH_TOKEN=sk-ant-oat01-... # required: cloud brain
# export NODE_TOKEN=zc_... # optional: end-to-end verify
# ./recover.sh
# Usage:
# ./recover.sh # one recovery pass, then exit
# ./recover.sh --watch # run forever: recover on every (re)connect
#
# Env knobs: SERIAL (default 65301572), the two tokens above.
set -u
# The App Lab node (apess-onboard) carries its own baked cloud token, so unlike
# the old host-daemon flow this needs NO secrets in the environment. Config via
# env (defaults suit the current dev board):
# SERIAL FLEET_SECRET KIT_ID CLAIM_CODE API_URL NODE_URL APP
set -uo pipefail
SERIAL="${SERIAL:-65301572}"
A(){ adb -s "$SERIAL" "$@"; }
S(){ adb -s "$SERIAL" shell "$@"; }
API="${API_URL:-http://127.0.0.1:3000}"
FLEET_SECRET="${FLEET_SECRET:-apess2026}"
KIT_ID="${KIT_ID:-crimson-node}"
CLAIM_CODE="${CLAIM_CODE:-7777}"
NODE_URL="${NODE_URL:-http://127.0.0.1:8080}"
APP="${APP:-/home/arduino/ArduinoApps/apess-onboard}"
PORTS=(8080 9999)
# Resolve adb even under launchd's minimal PATH.
ADB="$(command -v adb || true)"
for c in /opt/homebrew/bin/adb /usr/local/bin/adb "$HOME/Library/Android/sdk/platform-tools/adb"; do
[ -n "$ADB" ] && break
[ -x "$c" ] && ADB="$c"
done
[ -n "$ADB" ] || { echo "recover: adb not found in PATH"; exit 127; }
log() { printf '\033[36m[recover]\033[0m %s\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
bad(){ printf ' \033[31m✗\033[0m %s\n' "$*"; }
step(){ printf '\n\033[1m%s\033[0m\n' "$*"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
step "0· Preconditions"
if ! adb devices | grep -q "^${SERIAL}[[:space:]]*device"; then
bad "board $SERIAL not attached — re-plug USB, then re-run"; exit 1
adb_present() { "$ADB" devices | grep -q "^${SERIAL}[[:space:]].*device$"; }
matrix_code() {
python3 - "$CLAIM_CODE" <<'PY' 2>/dev/null
import socket, sys
s = socket.create_connection(('127.0.0.1', 9999), timeout=3)
s.sendall(f"text {sys.argv[1]}\n".encode()); s.recv(16); s.close()
PY
}
recover_once() {
adb_present || { warn "board $SERIAL not connected"; return 1; }
# 1 · re-forward tunnels (they vanish on re-plug)
for p in "${PORTS[@]}"; do
"$ADB" -s "$SERIAL" forward --list 2>/dev/null | grep -q "tcp:$p" \
|| "$ADB" -s "$SERIAL" forward "tcp:$p" "tcp:$p" >/dev/null
done
ok "tunnels forwarded (${PORTS[*]})"
# 2 · wait for the node daemon (auto-starts on boot; start it if not)
local n=0
until curl -s -m2 "$NODE_URL/health" -o /dev/null 2>/dev/null; do
n=$((n + 1))
if [ "$n" -eq 20 ]; then
warn "daemon not up after ~40s — starting the app"
"$ADB" -s "$SERIAL" shell "arduino-app-cli app start $APP" >/dev/null 2>&1 || true
fi
ok "board $SERIAL attached"
[ -n "${ANTHROPIC_OAUTH_TOKEN:-}" ] || { bad "ANTHROPIC_OAUTH_TOKEN not set — cloud brain will fail. export it and re-run"; exit 1; }
ok "cloud token present in env"
if [ "$n" -gt 90 ]; then warn "daemon never came up ($NODE_URL/health)"; return 1; fi
sleep 2
done
ok "node daemon healthy"
step "1· Tunnel"
A forward tcp:8080 tcp:8080 >/dev/null && ok "adb forward :8080 → laptop localhost:8080"
# 3 · re-register with the local API (restores the in-memory node binding)
local r
r="$(curl -s -m5 -X POST "$API/nodes/self-register" \
-H "x-fleet-secret: $FLEET_SECRET" -H 'content-type: application/json' \
-d "{\"kitId\":\"$KIT_ID\",\"claimCode\":\"$CLAIM_CODE\",\"url\":\"$NODE_URL\",\"token\":\"open-lan\"}" 2>/dev/null)"
if echo "$r" | grep -q '"url"'; then ok "re-registered with API (code $CLAIM_CODE)"
else warn "API self-register failed — is the API up at $API? ($r)"; return 1; fi
step "2· Stop stale supervisor + daemons (preserve llama)"
S 'for p in $(ps -C zeroclaw-supervisor -o pid= 2>/dev/null); do kill -9 $p 2>/dev/null; done
for p in $(ps -C zeroclaw -o pid= 2>/dev/null); do kill -9 $p 2>/dev/null; done
rm -f /home/arduino/.zc-supervisor.lock; sleep 2
echo " daemons left: $(ps -C zeroclaw -o pid= 2>/dev/null | wc -l)"'
# 4 · sync the claim code onto the matrix so board + API agree
matrix_code && ok "matrix showing $CLAIM_CODE" || warn "could not set matrix code (relay :9999)"
log "recovered — bind in the UI with code $CLAIM_CODE"
}
step "3· Relaunch supervisor WITH token env (env-only, never on disk)"
S "export ANTHROPIC_OAUTH_TOKEN='$ANTHROPIC_OAUTH_TOKEN'; \
export ZEROCLAW_providers__models__anthropic__max__api_key='$ANTHROPIC_OAUTH_TOKEN'; \
setsid nohup /home/arduino/zeroclaw-supervisor.sh >/dev/null 2>&1 </dev/null & sleep 2; echo done" >/dev/null
S 'pgrep -f "[z]eroclaw-supervisor" >/dev/null' && ok "supervisor relaunched" || bad "supervisor did NOT start"
watch_loop() {
log "watching board $SERIAL — recover on every (re)connect (Ctrl-C to stop)"
while true; do
"$ADB" -s "$SERIAL" wait-for-device
sleep 3 # let Linux + the App Lab app finish booting
recover_once || warn "recovery pass incomplete; will retry on next reconnect"
while adb_present; do sleep 2; done
log "board disconnected — waiting for re-plug"
done
}
step "4· Matrix bridge app (start only if down)"
if [ "$(S 'printf "ping\n" | timeout 4 nc 127.0.0.1 9999 2>/dev/null')" = "pong" ]; then
ok "bridge already running"
else
S 'cd ~/ArduinoApps/uno-q-bridge && TMPDIR=/tmp arduino-app-cli app start ~/ArduinoApps/uno-q-bridge 2>&1 | tail -1'
fi
step "5· Wait for services"
for i in $(seq 1 30); do
L=$(S 'curl -sf -m3 http://127.0.0.1:8083/health >/dev/null 2>&1 && echo 1 || echo 0')
D=$(S 'curl -sf -m3 http://127.0.0.1:8080/health >/dev/null 2>&1 && echo 1 || echo 0')
printf '\r [%02d] llama=%s daemon=%s ' "$i" "$L" "$D"
[ "$D" = 1 ] && break; sleep 6
done; echo
[ "$L" = 1 ] && ok "llama :8083 healthy" || bad "llama :8083 DOWN (cold load can take 3–5 min; re-check)"
[ "$D" = 1 ] && ok "daemon :8080 healthy" || { bad "daemon :8080 DOWN"; exit 1; }
step "6· Bridge (matrix responder)"
P=$(S 'printf "ping\n" | timeout 4 nc 127.0.0.1 9999 2>/dev/null')
[ "$P" = "pong" ] && ok "bridge :9999 responds (ping→pong)" || bad "bridge :9999 not responding — re-run step 4"
step "7· End-to-end: demo agent = cloud sonnet + matrix fires"
if [ -n "${NODE_TOKEN:-}" ]; then
S 'printf "matrix 0\n" | timeout 5 nc 127.0.0.1 9999 >/dev/null 2>&1'
R=$(curl -s -m 30 -X POST "http://127.0.0.1:8080/webhook?agent=demo" \
-H "Authorization: Bearer $NODE_TOKEN" -H 'Content-Type: application/json' \
-d '{"message":"Show the rain animation on the LED matrix"}')
echo "$R" | grep -q "claude-sonnet-5" && ok "agent=demo on claude-sonnet-5" || bad "agent NOT on sonnet — token may not have loaded: $R"
M=$(S "docker logs --since 40s uno-q-bridge-main-1 2>&1 | grep -c \"parts=\['matrix', '1'\]\"")
[ "${M:-0}" -ge 1 ] && ok "matrix_pattern fired (rain)" || bad "matrix did not change"
else
echo " (NODE_TOKEN unset — skipping authenticated end-to-end check)"
fi
step "Recovery complete."
echo " Voice proxy (laptop): if it was running it auto-recovers via the re-armed tunnel."
echo " If not running: NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=\$NODE_TOKEN python3 deploy/voice-client/serve.py 8090"
case "${1:-}" in
--watch | -w) watch_loop ;;
*) recover_once ;;
esac
+7 -1
View File
@@ -18,8 +18,14 @@ compile, no flash:
/ print `<text>`" request (e.g. `text="GO CLAWS"`).
- **`matrix_pattern`** — switch to a preset animation: `off, rain, heart, wave,
sparkle, checker, solid, blink`.
- **`matrix_count`** — count `0..N` on the matrix, **one number per second**. Use
for ANY "count to N / count up / print the numbers 0..N once a second" request
(e.g. `n=100`). The MCU runs the timed loop itself, so this is a single instant
call — do **NOT** write and flash a counting sketch. Flashing a timed loop is the
wrong tool: it takes ~90s, it fails inside the App Lab container, and it
overwrites the resident responder.
**Always reach for these tools first** for text or a preset animation. They take
**Always reach for these tools first** for text, a preset animation, or a count. They take
effect in under a second. Do **NOT** write and flash a sketch for these — flashing
takes ~90s **and overwrites the resident responder, breaking `matrix_text` /
`matrix_pattern` until it's re-flashed.** Only write + flash a sketch (below) for a
+4 -2
View File
@@ -6,16 +6,18 @@
<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." />
<title>APESS 2026 · Workshop</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="preload"
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'"
/>
<noscript>
<link
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>
</head>
+6
View File
@@ -8,19 +8,25 @@ import { Module2 } from '@/pages/Module2'
import { AddBuilder } from '@/pages/AddBuilder'
import { Admin } from '@/pages/Admin'
import { Judge } from '@/pages/Judge'
import { CockpitLayout } from '@/components/cockpit/CockpitLayout'
import { useCollectiveSync } from '@/lib/useCollectiveSync'
import { useApplyTheme } from '@/lib/useApplyTheme'
export default function App() {
useCollectiveSync()
useApplyTheme()
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Landing />} />
{/* The workshop flow runs inside the persistent cockpit shell. */}
<Route element={<CockpitLayout />}>
<Route path="/workshop" element={<TeamRegistration />} />
<Route path="/workshop/setup" element={<EnvSetup />} />
<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="/admin" element={<Admin />} />
<Route path="/judge" element={<Judge />} />
+63
View File
@@ -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>
)
}
+221
View File
@@ -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>
)
}
+98
View File
@@ -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>
)
}
+77
View File
@@ -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>
)
}
+66
View File
@@ -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" />
}
+44
View File
@@ -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
View File
@@ -24,6 +24,37 @@
--input: 214 32% 91%;
--ring: 211 100% 50%;
--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 {
@@ -46,6 +77,37 @@
--border: 217 32% 17%;
--input: 217 32% 17%;
--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;
}
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;
transition: background-color 0.25s ease, color 0.25s ease;
}
}
+22
View File
@@ -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 }
}
/**
* 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. */
export async function getUnclaimed(code: string): Promise<string[]> {
const res = await fetch(`${API_BASE}/nodes/unclaimed`, { headers: authHeaders(code) })
+16
View File
@@ -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])
}
+38
View File
@@ -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
}
+111
View File
@@ -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 },
],
}
}
+3 -5
View File
@@ -27,12 +27,9 @@ describe('AddBuilder', () => {
sessionStorage.clear()
})
it('renders the phase strip set to add and the heading', () => {
it('renders the heading', () => {
renderPage()
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 () => {
@@ -92,6 +89,7 @@ describe('AddBuilder', () => {
expect(submission.code).toMatch(/^KIT-03-/)
expect(submission.submittedAt).not.toBeNull()
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()
})
})
+82 -80
View File
@@ -1,124 +1,126 @@
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { PhaseStrip } from '@/components/PhaseStrip'
import { useNavigate } from 'react-router-dom'
import { AddLayerForm } from '@/components/AddLayerForm'
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 { makeSubmissionCode } from '@/lib/submission'
export function AddBuilder() {
const navigate = useNavigate()
const team = useSession((s) => s.team)
const add = useSession((s) => s.add)
const submission = useSession((s) => s.submission)
const setSubmission = useSession((s) => s.setSubmission)
const completePhase = useSession((s) => s.completePhase)
const complete =
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 complete = ADD_LAYERS.every(({ key }) => add[key].trim().length > 0)
const submitted = !!submission.code
const onExport = () => window.print()
const onSubmit = () => {
setSubmission({ code: makeSubmissionCode(team, add), submittedAt: new Date().toISOString() })
completePhase('add')
}
// ── Submission finale (design panel6) ──
if (submitted) {
return (
<main className="min-h-screen bg-background">
<header className="px-8 py-5 border-b border-border flex items-center justify-between print:hidden">
<div className="font-mono text-xs tracking-widest uppercase">
APESS <span className="text-primary font-bold">2026</span>
<span className="text-muted-foreground"> · Workshop</span>
<section className="print:hidden">
<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="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-[var(--green)] text-[32px] text-white">
✓
</div>
<Link to="/workshop/module2" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
← Module 2
</Link>
</header>
<div className="print:hidden">
<PhaseStrip active="add" />
</div>
<section className="px-8 py-10 max-w-5xl mx-auto space-y-6">
<div className="print:hidden">
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
Phase 5 of 5 · ~90 min · deadline 19:00
</Badge>
<h1 className="text-3xl font-bold tracking-tight">Module 3 · Harness, Loops &amp; submit</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
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.
<h1 className="mt-6 text-[44px] font-semibold tracking-[-0.02em]">ADD submitted</h1>
<p className="mx-auto mt-3.5 max-w-[460px] text-[18px] leading-[1.5] text-ink-2">
Team <strong className="font-semibold text-ink">{team.name || 'Matrix'}</strong> ·{' '}
{(team.name || 'crimson-node').toLowerCase().replace(/\s+/g, '-')} — your Agent Design Document
is in for judging. Your node keeps running the design you just shipped.
</p>
<div className="mt-3">
<OpenYourNode variant="inline" />
<div className="mt-7 inline-flex flex-wrap justify-center gap-2.5 font-mono text-[11px] tracking-[0.1em] text-[var(--green)]">
{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>
</section>
)
}
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="grid lg:grid-cols-2 gap-6 print:hidden">
<div className="mt-8 grid gap-4 md:grid-cols-2 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."
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 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'
'• 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 (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."
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 the sensor each minute; report only on exception; daily summary.\n\n' +
'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, not an average of what is left\n' +
'• N consecutive failures → escalate to a human and stop acting on the readings'
'• partial data → report what is missing\n' +
'• N failures → escalate to a human and stop acting'
}
/>
</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 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>
) : (
<span className="text-xs text-muted-foreground">
<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>
)}
<Button size="lg" disabled={!complete || submitted} onClick={onSubmit}>
{submitted ? 'Submitted ✓' : 'Submit ADD'}
</Button>
<ProceedButton disabled={!complete} onClick={onSubmit}>
Submit ADD
</ProceedButton>
</div>
</section>
</main>
)
}
+1 -5
View File
@@ -23,13 +23,9 @@ describe('EnvSetup — Meet your agent', () => {
sessionStorage.clear()
})
it('renders the phase strip set to setup and the heading', () => {
it('renders the heading', () => {
renderPage()
expect(screen.getByTestId('phase-strip')).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', () => {
+33 -51
View File
@@ -1,79 +1,61 @@
import { Link, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { PhaseStrip } from '@/components/PhaseStrip'
import { useNavigate } from 'react-router-dom'
import { OpenYourNode } from '@/components/OpenYourNode'
import { DomainPicker } from '@/components/DomainPicker'
import { PanelHeading, PanelCard, ProceedButton } from '@/components/cockpit/PanelChrome'
import { useSession } from '@/store/session'
export function EnvSetup() {
const navigate = useNavigate()
const device = useSession((s) => s.device)
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 ready = device.connected && domain.trim().length > 0
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')
navigate('/workshop/module1')
}
return (
<main className="min-h-screen bg-background">
<header className="px-8 py-5 border-b border-border flex items-center justify-between">
<div className="font-mono text-xs tracking-widest uppercase">
APESS <span className="text-primary font-bold">2026</span>
<span className="text-muted-foreground"> · Workshop</span>
</div>
<Link to="/workshop" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
← Team registration
</Link>
</header>
<section>
<PanelHeading
eyebrow="PHASE 2 OF 5 · ~15 MIN"
title="Meet your agent"
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."
/>
<PhaseStrip active="setup" />
<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 2 of 5 · ~15 min
</Badge>
<h1 className="text-3xl font-bold tracking-tight">Meet your agent</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-2xl leading-relaxed">
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&rsquo;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&rsquo;s for.
</p>
</div>
{/* 1 — Open your agent (hero) */}
<Card>
<CardHeader>
<CardTitle className="text-base">Open your agent to explore</CardTitle>
</CardHeader>
<CardContent>
{/* 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" />
</CardContent>
</Card>
</div>
</PanelCard>
{/* 2 — Pick your domain */}
<Card>
<CardHeader>
<CardTitle className="text-base">Pick your domain</CardTitle>
</CardHeader>
<CardContent>
{/* Pick your domain — emphasized: this seeds all five layers */}
<PanelCard emphasized className="mt-[22px]">
<div className="text-[17px] font-semibold">Pick your domain</div>
<p className="mt-1.5 text-[14px] leading-[1.5] text-ink-3">
This one line seeds all five layers of your Agent Design Document — the domain your agent
serves and the events it must notice.
</p>
<div className="mt-4">
<DomainPicker />
</CardContent>
</Card>
</div>
</PanelCard>
<div className="flex justify-end pt-4">
<Button size="lg" disabled={!ready} onClick={onProceed}>
<div className="mt-9 flex justify-end">
<ProceedButton disabled={!ready} onClick={onProceed}>
Proceed to Module 1 →
</Button>
</ProceedButton>
</div>
</section>
</main>
)
}
+1 -4
View File
@@ -30,12 +30,9 @@ describe('Module1', () => {
sessionStorage.clear()
})
it('renders the phase strip set to m1 and the heading', () => {
it('renders the heading', () => {
renderPage()
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)', () => {
+22 -39
View File
@@ -1,8 +1,6 @@
import { Link, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { PhaseStrip } from '@/components/PhaseStrip'
import { useNavigate } from 'react-router-dom'
import { AddLayerForm } from '@/components/AddLayerForm'
import { PanelHeading, ProceedButton } from '@/components/cockpit/PanelChrome'
import { useNodeFeed } from '@/lib/useNodeFeed'
import { useSession } from '@/store/session'
@@ -14,8 +12,6 @@ export function Module1() {
const domain = useSession((s) => s.domain)
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 ready = sensed && l1.trim().length > 0
@@ -25,55 +21,42 @@ export function Module1() {
}
return (
<main className="min-h-screen bg-background">
<header className="px-8 py-5 border-b border-border flex items-center justify-between">
<div className="font-mono text-xs tracking-widest uppercase">
APESS <span className="text-primary font-bold">2026</span>
<span className="text-muted-foreground"> · Workshop</span>
</div>
<Link to="/workshop/setup" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
← Environment setup
</Link>
</header>
<section>
<PanelHeading
eyebrow="PHASE 3 OF 5 · ~75 MIN"
title="Module 1 · Domain & events"
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."
size={46}
/>
<PhaseStrip active="m1" />
<section className="px-8 py-10 max-w-5xl mx-auto space-y-6">
<div>
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
Phase 3 of 5 · ~75 min
</Badge>
<h1 className="text-3xl font-bold tracking-tight">Module 1 · Domain &amp; events</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
Define the domain your agent is for and the events it must sense and act on. Carried over
from the domain you named earlier — now draft Layer 1 of your Agent Design Document.
</p>
</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>
<div
className="mt-9 rounded-xl border border-line bg-surface-soft px-5 py-4"
data-testid="domain-carried"
>
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--muted)]">Your domain</div>
{domain.trim() ? (
<div className="text-lg font-semibold tracking-tight">{domain}</div>
<div className="mt-1.5 text-[19px] font-semibold tracking-[-0.01em]">{domain}</div>
) : (
<div className="text-sm text-muted-foreground">
<div className="mt-1.5 text-[14px] text-ink-3">
Not set yet — name it on <span className="font-medium">Meet your agent</span>.
</div>
)}
</div>
<div className="mt-5">
<AddLayerForm
layer="L1"
title="ADD · Layer 1 — Domain &amp; events"
title="ADD · Layer 1 — Domain & events"
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."
/>
</div>
<div className="flex justify-end pt-2">
<Button size="lg" disabled={!ready} onClick={onProceed}>
<div className="mt-6 flex justify-end">
<ProceedButton disabled={!ready} onClick={onProceed}>
Proceed to Module 2 →
</Button>
</ProceedButton>
</div>
</section>
</main>
)
}
+1 -4
View File
@@ -37,12 +37,9 @@ describe('Module2', () => {
sessionStorage.clear()
})
it('renders the phase strip set to m2 and the heading', () => {
it('renders the heading', () => {
renderPage()
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', () => {
+44 -62
View File
@@ -1,66 +1,60 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { PhaseStrip } from '@/components/PhaseStrip'
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { AgentChat } from '@/components/AgentChat'
import { AddLayerForm } from '@/components/AddLayerForm'
import { PanelHeading, PanelCard, ProceedButton } from '@/components/cockpit/PanelChrome'
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() {
const navigate = useNavigate()
const l2 = useSession((s) => s.add.L2)
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 [tried, setTried] = useState(0)
const allTried = tried >= 3
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 = () => {
completePhase('m2')
navigate('/workshop/add')
}
return (
<main className="min-h-screen bg-background">
<header className="px-8 py-5 border-b border-border flex items-center justify-between">
<div className="font-mono text-xs tracking-widest uppercase">
APESS <span className="text-primary font-bold">2026</span>
<span className="text-muted-foreground"> · Workshop</span>
</div>
<Link to="/workshop/module1" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
← Module 1
</Link>
</header>
<PhaseStrip active="m2" />
<section className="px-8 py-10 max-w-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 &amp; 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>
<section>
<PanelHeading
eyebrow="PHASE 4 OF 5 · ~90 MIN"
title="Module 2 · Skills & policies"
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."
size={46}
/>
<div className="mt-9">
<AgentChat onProgress={(done) => setTried(done)} />
</div>
{/* What's next — revealed once all three prompts have run successfully. */}
{allTried ? (
<div className="space-y-6" data-testid="whats-next">
<div className="pt-2">
<h2 className="text-lg font-semibold tracking-tight">What&rsquo;s next</h2>
<p className="text-sm text-muted-foreground mt-1 max-w-xl">
You just watched the agent enumerate a bus and drive the matrix using its built-in
skills. Now capture <span className="font-medium text-foreground">your domain&rsquo;s</span>{' '}
skills and the policy that governs them — Layers 2 and 3 of your Agent Design Document.
<div className="mt-8 space-y-5" data-testid="whats-next">
<div>
<h2 className="text-[22px] font-semibold tracking-[-0.01em]">What&rsquo;s next</h2>
<p className="mt-1 max-w-[560px] text-[15px] text-ink-2">
You just watched the agent enumerate the bus and drive the matrix with its built-in
skills. Now capture <span className="font-medium text-ink">your domain&rsquo;s</span> skills and
the policy that governs them — Layers 2 and 3, drafted below.
</p>
</div>
@@ -73,37 +67,25 @@ export function Module2() {
<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'
}
description="The actuation gate — and what happens when things break. A fail-safe must never quietly report 'normal'."
placeholder="Report only on exception; escalate ambiguous calls to the cloud; never suppress a stale-sensor alarm."
/>
<div className="flex justify-end pt-2">
<Button size="lg" disabled={!ready} onClick={onProceed}>
Proceed to ADD builder →
</Button>
<div className="flex justify-end">
<ProceedButton disabled={!ready} onClick={onProceed}>
Proceed to Module 3 →
</ProceedButton>
</div>
</div>
) : (
<Card>
<CardHeader>
<CardTitle className="text-base text-muted-foreground">What&rsquo;s next</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground leading-relaxed">
<PanelCard className="mt-6">
<div className="text-[15px] font-semibold text-ink-2">What&rsquo;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&rsquo;ll
capture your domain&rsquo;s skills and policies here.
</p>
</CardContent>
</Card>
</PanelCard>
)}
</section>
</main>
)
}
+1 -2
View File
@@ -22,9 +22,8 @@ describe('TeamRegistration', () => {
vi.unstubAllGlobals()
})
it('renders the phase strip and the team form heading', () => {
it('renders the team form heading', () => {
renderPage()
expect(screen.getByTestId('phase-strip')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /team registration/i })).toBeInTheDocument()
})
+35 -75
View File
@@ -1,14 +1,11 @@
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { MemberFields } from '@/components/MemberFields'
import { PhaseStrip } from '@/components/PhaseStrip'
import { BoardClaim } from '@/components/BoardClaim'
import { SayHiCard } from '@/components/SayHiCard'
import { TelegramSetup } from '@/components/TelegramSetup'
import { VoiceSetup } from '@/components/VoiceSetup'
import { PanelHeading, PanelCard, ProceedButton, FieldLabel } from '@/components/cockpit/PanelChrome'
import { useSession } from '@/store/session'
export function TeamRegistration() {
@@ -31,72 +28,36 @@ export function TeamRegistration() {
}
return (
<main className="min-h-screen bg-background">
<header className="px-8 py-5 border-b border-border flex items-center justify-between">
<div className="font-mono text-xs tracking-widest uppercase">
APESS <span className="text-primary font-bold">2026</span>
<span className="text-muted-foreground"> · Workshop</span>
</div>
<Link to="/" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
← Back to landing
</Link>
</header>
<section>
<PanelHeading
eyebrow="PHASE 1 OF 5 · ~10 MIN"
title="Team registration"
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."
/>
<PhaseStrip active="reg" />
<section className="px-8 py-10 max-w-5xl mx-auto space-y-6">
<div className="flex items-end justify-between flex-wrap gap-3">
<div>
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
Phase 1 of 5 · ~10 min
</Badge>
<h1 className="text-3xl font-bold tracking-tight">Team registration</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
Name your team, add 3–5 members, then bind the board you already set up this
week — run the setup script and enter the code it scrolls on its LED matrix.
</p>
</div>
</div>
<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>
<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
id="team-name"
aria-label="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 className="mt-5 space-y-2">
<FieldLabel>Members</FieldLabel>
<MemberFields members={team.members} onChange={(members) => setTeam({ members })} />
</div>
<MemberFields
members={team.members}
onChange={(members) => setTeam({ members })}
/>
</div>
</CardContent>
</Card>
</PanelCard>
<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>
<PanelCard>
<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}
@@ -106,8 +67,6 @@ export function TeamRegistration() {
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,
@@ -122,31 +81,32 @@ export function TeamRegistration() {
}}
/>
</div>
</CardContent>
</Card>
</PanelCard>
</div>
{/* Once the board is bound, the agent-facing setup unfolds below. */}
{/* 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="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.
<div className="mt-8 space-y-5" data-testid="post-connect">
<div>
<h2 className="text-[22px] font-semibold tracking-[-0.01em]">Your agent</h2>
<p className="mt-1 text-[15px] text-ink-2">
Your board is bound — say hi to the agent on it, then set up how you reach it.
</p>
</div>
<SayHiCard />
<div className="grid gap-5 md:grid-cols-2">
<TelegramSetup />
<VoiceSetup />
</div>
</div>
)}
<div className="flex justify-end pt-4">
<Button size="lg" disabled={!ready} onClick={onProceed}>
<div className="mt-9 flex justify-end">
<ProceedButton disabled={!ready} onClick={onProceed}>
Meet your agent →
</Button>
</ProceedButton>
</div>
</section>
</main>
)
}
+16 -2
View File
@@ -54,8 +54,12 @@ export interface Channels {
telegram: string | null
/** Whether the team enabled browser voice on the node. */
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. */
function genTeamId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
@@ -74,7 +78,13 @@ export interface SessionState {
submission: Submission
/** Extra channels + voice, set up during onboarding (client-side prefs). */
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
setTheme: (theme: Theme) => void
setTried: (tried: number) => void
setDevice: (patch: Partial<Device>) => void
setDomain: (d: string) => void
setChannels: (patch: Partial<Channels>) => void
@@ -106,7 +116,9 @@ const initial = {
stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 },
add: { L1: '', L2: '', L3: '', L4: '', L5: '' } as AddLayers,
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>()(
@@ -114,6 +126,8 @@ export const useSession = create<SessionState>()(
(set) => ({
...initial,
setTeam: (patch) => set((s) => ({ team: { ...s.team, ...patch } })),
setTheme: (theme) => set({ theme }),
setTried: (tried) => set({ tried }),
setDevice: (patch) => set((s) => ({ device: { ...s.device, ...patch } })),
setDomain: (domain) => set({ domain }),
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
// tab-volatile and dropped the connection on a hard reload.
storage: createJSONStorage(() => localStorage),
version: 3,
version: 4,
// 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
// fields (e.g. `channels`) are always present without wiping progress.
+16 -1
View File
@@ -6,9 +6,24 @@ export default {
extend: {
fontFamily: {
sans: ['Newsreader', 'ui-serif', 'serif'],
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
mono: ['"IBM Plex Mono"', 'ui-monospace', 'monospace'],
},
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))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',