Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e76e27f06d | ||
|
|
1a39457940 | ||
|
|
6c82b79b01 | ||
|
|
909b9a7671 | ||
|
|
8ea9c256b4 | ||
|
|
107e5b0955 |
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Outlet, Link } from 'react-router-dom'
|
||||
import { useSession } from '@/store/session'
|
||||
import { Stepper } from './Stepper'
|
||||
import { CockpitRail } from './CockpitRail'
|
||||
|
||||
function ThemeToggle() {
|
||||
const theme = useSession((s) => s.theme)
|
||||
const setTheme = useSession((s) => s.setTheme)
|
||||
const dark = theme === 'dark'
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(dark ? 'light' : 'dark')}
|
||||
aria-label={dark ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||
className="rounded-[20px] border border-line px-3 py-1.5 font-mono text-[10px] tracking-[0.08em] text-ink-2 transition-colors hover:border-blue hover:text-blue-ink"
|
||||
>
|
||||
{dark ? '☀ LIGHT' : '☾ DARK'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The cockpit shell — a persistent layout wrapping every workshop phase route.
|
||||
* Header + stepper + a two-pane grid (editorial content via <Outlet/> on the
|
||||
* left, the live instrument rail on the right). The rail stays mounted across
|
||||
* phase navigation, so its live feed never resets.
|
||||
*/
|
||||
export function CockpitLayout() {
|
||||
const team = useSession((s) => s.team)
|
||||
const nodeName = team.name ? team.name.toLowerCase().replace(/\s+/g, '-') : 'crimson-node'
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="flex items-center border-b border-line px-10 py-5 print:hidden">
|
||||
<Link to="/" className="font-mono text-xs font-semibold tracking-[0.14em]">
|
||||
<span className="text-ink">APESS </span>
|
||||
<span className="text-blue">2026</span>
|
||||
<span className="text-[var(--muted)]"> · WORKSHOP</span>
|
||||
</Link>
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
<span className="font-mono text-[11px] text-[var(--muted)]">
|
||||
{nodeName}
|
||||
{team.name && <span> · {team.name}</span>}
|
||||
</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="print:hidden">
|
||||
<Stepper />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto grid w-full max-w-[1400px] items-start gap-14 px-10 pb-[90px] pt-14 lg:grid-cols-[minmax(0,1fr)_464px] print:block print:p-0">
|
||||
<main className="min-h-[560px] w-full max-w-[660px] print:max-w-none">
|
||||
<Outlet />
|
||||
</main>
|
||||
<div className="print:hidden">
|
||||
<CockpitRail />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useSession } from '@/store/session'
|
||||
import { useNodeFeed } from '@/lib/useNodeFeed'
|
||||
import { useTelemetry } from '@/lib/useTelemetry'
|
||||
import { useMatrixMirror } from '@/lib/useMatrixMirror'
|
||||
import { WaveformCanvas } from './WaveformCanvas'
|
||||
import type { NodeActivityKind } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ADD_LAYERS } from '@/lib/addLayers'
|
||||
|
||||
/**
|
||||
* The persistent instrument rail (right pane of the cockpit). A dark "device
|
||||
* screen" — intentionally dark in both themes. Six sections: node heartbeat,
|
||||
* agent activity log, and ADD progress are wired to real state; the LED matrix,
|
||||
* I2C bus, and live acceleration come from the telemetry seam (`useTelemetry`),
|
||||
* which is a **simulated** source today (marked SIM) until the ADXL355 stream
|
||||
* lands. See the plan's WS3.
|
||||
*/
|
||||
|
||||
const LOG_COLOR: Record<NodeActivityKind, string> = {
|
||||
thinking: 'text-rail-dim2',
|
||||
tool: 'text-rail-text2',
|
||||
flash: 'text-rail-blue',
|
||||
error: 'text-rail-spike',
|
||||
response: 'text-rail-green',
|
||||
fallback: 'text-[#d9a441]',
|
||||
}
|
||||
|
||||
// which layers are "next" on each route
|
||||
const NEXT_BY_PATH: Record<string, string[]> = {
|
||||
'/workshop/module1': ['L1'],
|
||||
'/workshop/module2': ['L2', 'L3'],
|
||||
'/workshop/add': ['L4', 'L5'],
|
||||
}
|
||||
|
||||
function RailSection({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="font-mono text-[9.5px] tracking-[0.18em] text-rail-dim">{label}</div>
|
||||
<div className="mt-2">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CockpitRail() {
|
||||
const teamId = useSession((s) => s.teamId)
|
||||
const team = useSession((s) => s.team)
|
||||
const connected = useSession((s) => s.device.connected)
|
||||
const add = useSession((s) => s.add)
|
||||
const submitted = useSession((s) => s.submission.code != null)
|
||||
const { pathname } = useLocation()
|
||||
|
||||
const feed = useNodeFeed(teamId, connected)
|
||||
const tel = useTelemetry(connected)
|
||||
const online = connected && feed.online
|
||||
// Pixel-perfect mirror of the physical matrix (real board frame). Falls back
|
||||
// to the sim frame until the first real frame arrives.
|
||||
const mirror = useMatrixMirror(teamId, online)
|
||||
const matrixDots = mirror ?? tel.matrix
|
||||
const matrixLive = mirror != null
|
||||
const nodeName = team.name ? team.name.toLowerCase().replace(/\s+/g, '-') : 'crimson-node'
|
||||
|
||||
const nextSet = new Set(NEXT_BY_PATH[pathname] ?? [])
|
||||
const layerState = (key: string): 'idle' | 'next' | 'done' => {
|
||||
if (submitted || add[key as keyof typeof add]?.trim()) return 'done'
|
||||
return nextSet.has(key) ? 'next' : 'idle'
|
||||
}
|
||||
|
||||
const log = feed.activity.slice(0, 6)
|
||||
|
||||
return (
|
||||
<aside className="sticky top-6 rounded-[18px] bg-rail-bg p-[22px] text-rail-text shadow-[0_20px_50px_-24px_rgba(0,0,0,0.5)]">
|
||||
{/* 1 · node header / heartbeat */}
|
||||
<div className="flex items-center gap-[11px]">
|
||||
<span
|
||||
className={cn(
|
||||
'h-2.5 w-2.5 rounded-full',
|
||||
online ? 'bg-rail-green shadow-[0_0_10px_#3fd28a] animate-pulse' : 'bg-rail-dim2',
|
||||
)}
|
||||
/>
|
||||
<span className="font-mono text-sm font-semibold tracking-[0.02em] text-rail-text3">{nodeName}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-[9px] tracking-[0.14em] rounded border px-[7px] py-0.5',
|
||||
online ? 'text-rail-green border-[#2c6b4f]' : 'text-rail-dim2 border-rail-line',
|
||||
)}
|
||||
>
|
||||
{online ? 'LIVE' : 'OFFLINE'}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[10px] text-rail-dim">arduino uno q</span>
|
||||
</div>
|
||||
|
||||
{/* 2 · LED matrix 13×8 — real pixel mirror of the physical matrix */}
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-mono text-[9.5px] tracking-[0.18em] text-rail-dim">LED MATRIX · 13×8</div>
|
||||
{matrixLive && (
|
||||
<span className="font-mono text-[8.5px] tracking-[0.12em] text-rail-green">● MIRROR</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex justify-center rounded-[10px] border border-rail-line bg-rail-inset px-[13px] py-3">
|
||||
<div className="grid gap-1" style={{ gridTemplateColumns: 'repeat(13, 1fr)' }}>
|
||||
{Array.from({ length: 104 }).map((_, i) => {
|
||||
const lit = tel.live && matrixDots[i]
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="h-[9px] w-[9px] rounded-[2px] transition-[background] duration-75"
|
||||
style={{
|
||||
background: lit ? 'oklch(0.7 0.2 34)' : 'oklch(0.28 0.01 260)',
|
||||
boxShadow: lit ? '0 0 5px oklch(0.7 0.2 34)' : 'none',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3 · I2C bus (telemetry) */}
|
||||
<RailSection label="I2C BUS · 100 kHz">
|
||||
<div className="rounded-[10px] border border-rail-line2 bg-rail-panel px-1 py-1.5 font-mono text-xs">
|
||||
{tel.i2c.map((d, i) => (
|
||||
<div
|
||||
key={d.addr}
|
||||
className={cn('flex items-center gap-2.5 px-3 py-2', i === 0 && 'border-b border-rail-line')}
|
||||
>
|
||||
<span className="text-rail-blue">{d.addr}</span>
|
||||
<span className="text-rail-text2">{d.name}</span>
|
||||
<span className={cn('ml-auto', d.synced ? 'text-rail-green' : 'text-rail-dim3')}>
|
||||
{d.synced ? '● synced' : '○ idle'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-[7px] font-mono text-[10px] text-rail-dim2">
|
||||
{tel.live ? `SYNC/INT aligned · drift ${tel.driftMs.toFixed(1)} ms` : 'run i2c_scan to enumerate the bus'}
|
||||
</div>
|
||||
</RailSection>
|
||||
|
||||
{/* 4 · live acceleration (telemetry) */}
|
||||
<RailSection label="LIVE ACCELERATION · g">
|
||||
<div className="-mt-[18px] mb-2 flex items-center justify-end gap-2">
|
||||
{tel.live && tel.simulated && (
|
||||
<span className="rounded-[4px] border border-[#d9a441]/40 px-[5px] py-[1px] font-mono text-[8.5px] tracking-[0.12em] text-[#d9a441]">
|
||||
SIM
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-[9.5px] tracking-[0.1em]',
|
||||
!tel.live ? 'text-rail-dim' : tel.event === 'impact' ? 'text-rail-spike' : 'text-rail-green',
|
||||
)}
|
||||
>
|
||||
{!tel.live ? 'AWAITING STREAM' : tel.event === 'impact' ? 'IMPACT SPIKE' : 'NOMINAL'}
|
||||
</span>
|
||||
</div>
|
||||
{tel.live ? (
|
||||
<WaveformCanvas wave={tel.wave} impact={tel.event === 'impact'} />
|
||||
) : (
|
||||
<div className="flex h-[78px] items-center justify-center rounded-[10px] border border-rail-line bg-rail-inset font-mono text-[10px] text-rail-dim3">
|
||||
adxl355_stream — awaiting board
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 font-mono text-[11.5px]">
|
||||
{([['ACC 1', tel.acc1], ['ACC 2', tel.acc2]] as const).map(([n, a]) => (
|
||||
<div key={n} className="rounded-lg border border-rail-line2 bg-rail-panel px-[11px] py-[9px]">
|
||||
<div className="text-[9.5px] tracking-[0.12em] text-rail-dim">{n}</div>
|
||||
{(['x', 'y', 'z'] as const).map((axis) => (
|
||||
<div key={axis} className="text-rail-text2">
|
||||
{axis}{' '}
|
||||
<span className="text-rail-text3 tabular-nums">
|
||||
{tel.live ? a[axis].toFixed(3) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</RailSection>
|
||||
|
||||
{/* 5 · agent activity log (real) */}
|
||||
<RailSection label="AGENT ACTIVITY">
|
||||
<div className="h-[132px] overflow-hidden rounded-[10px] border border-rail-line bg-rail-inset px-[13px] py-[11px] font-mono text-[11px] leading-[1.75]">
|
||||
{log.length === 0 ? (
|
||||
<div className="text-rail-dim2">idle — prompt your agent to see it work</div>
|
||||
) : (
|
||||
log.map((e, i) => (
|
||||
<div key={i} className={cn('truncate', LOG_COLOR[e.kind])}>
|
||||
{e.label}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</RailSection>
|
||||
|
||||
{/* 6 · ADD progress (real, local) */}
|
||||
<RailSection label="AGENT DESIGN DOC">
|
||||
<div className="flex flex-col gap-px font-mono text-[11px]">
|
||||
{ADD_LAYERS.map(({ key, n, title }) => {
|
||||
const st = layerState(key)
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2.5 px-0.5 py-[7px]">
|
||||
<span className={cn(st === 'idle' ? 'text-[#4a5060]' : 'text-rail-blue')}>L{n}</span>
|
||||
<span className={cn(st === 'idle' ? 'text-rail-dim2' : 'text-rail-text2')}>{title}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto',
|
||||
st === 'done' ? 'text-rail-green' : st === 'next' ? 'text-[#d9a441]' : 'text-rail-dim3',
|
||||
)}
|
||||
>
|
||||
{st === 'done' ? 'done' : st === 'next' ? 'next' : '—'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</RailSection>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Mono eyebrow chip, e.g. "PHASE 1 OF 5 · ~10 MIN". */
|
||||
export function Eyebrow({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="inline-block rounded-[5px] bg-[var(--eyebrow-bg)] px-2.5 py-[5px] font-mono text-[11px] tracking-[0.14em] text-ink-3">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Editorial panel header: eyebrow + big Newsreader H1 + intro paragraph. */
|
||||
export function PanelHeading({
|
||||
eyebrow,
|
||||
title,
|
||||
intro,
|
||||
size = 52,
|
||||
}: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
intro?: string
|
||||
size?: number
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Eyebrow>{eyebrow}</Eyebrow>
|
||||
<h1
|
||||
className="mt-[22px] font-semibold leading-[1.03] tracking-[-0.02em] text-ink"
|
||||
style={{ fontSize: size }}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{intro && <p className="mt-[18px] max-w-[580px] text-[18px] leading-[1.55] text-ink-2">{intro}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Section wrapper for a phase panel (consistent top spacing + scroll reset). */
|
||||
export function Panel({ children }: { children: React.ReactNode }) {
|
||||
return <section className="space-y-0">{children}</section>
|
||||
}
|
||||
|
||||
/** The primary "Proceed →" button (Newsreader, blue, gated). */
|
||||
export function ProceedButton({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'rounded-[10px] bg-blue px-[26px] py-3.5 text-[17px] font-medium text-white transition-[opacity,background] duration-150 hover:bg-blue-ink',
|
||||
disabled ? 'cursor-default opacity-45' : 'cursor-pointer opacity-100',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** A standard editorial card. */
|
||||
export function PanelCard({
|
||||
children,
|
||||
className,
|
||||
emphasized,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
emphasized?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[14px] p-[22px_24px]',
|
||||
emphasized
|
||||
? 'border-[1.5px] border-[var(--blue-soft-border)] bg-[linear-gradient(180deg,var(--blue-pick-a),var(--surface))]'
|
||||
: 'border border-line bg-surface',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Mono uppercase field label. */
|
||||
export function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--muted)]">{children}</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useSession, type PhaseKey } from '@/store/session'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Step {
|
||||
key: PhaseKey
|
||||
to: string
|
||||
eyebrow: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const STEPS: Step[] = [
|
||||
{ key: 'reg', to: '/workshop', eyebrow: 'PHASE 1', label: 'Team registration' },
|
||||
{ key: 'setup', to: '/workshop/setup', eyebrow: 'PHASE 2', label: 'Meet your agent' },
|
||||
{ key: 'm1', to: '/workshop/module1', eyebrow: 'PHASE 3', label: 'Module 1' },
|
||||
{ key: 'm2', to: '/workshop/module2', eyebrow: 'PHASE 4', label: 'Module 2' },
|
||||
{ key: 'add', to: '/workshop/add', eyebrow: 'PHASE 5', label: 'Module 3' },
|
||||
]
|
||||
|
||||
/** The five-phase stepper nav (replaces PhaseStrip). Forward-gated: you can only
|
||||
* jump to a step at or before the current one (advance via the Proceed buttons). */
|
||||
export function Stepper() {
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const phases = useSession((s) => s.phases)
|
||||
|
||||
const activeIndex = Math.max(
|
||||
0,
|
||||
STEPS.findIndex((s) => s.to === pathname),
|
||||
)
|
||||
|
||||
return (
|
||||
<nav className="grid grid-cols-5 gap-2.5 border-b border-line px-10 py-4" data-testid="stepper">
|
||||
{STEPS.map((s, i) => {
|
||||
const state = i < activeIndex ? 'done' : i === activeIndex ? 'active' : 'pending'
|
||||
const reachable = i <= activeIndex || phases[s.key]
|
||||
return (
|
||||
<button
|
||||
key={s.key}
|
||||
type="button"
|
||||
data-state={state}
|
||||
disabled={!reachable}
|
||||
onClick={() => reachable && navigate(s.to)}
|
||||
className={cn(
|
||||
'rounded-lg px-3.5 py-[11px] text-left transition-colors',
|
||||
reachable ? 'cursor-pointer' : 'cursor-default',
|
||||
state === 'done' && 'border border-[var(--green-border-2)] bg-[var(--green-bg)]',
|
||||
state === 'active' && 'border-[1.5px] border-blue bg-[var(--blue-soft-bg)]',
|
||||
state === 'pending' && 'border border-line bg-surface',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'font-mono text-[10px] tracking-[0.16em]',
|
||||
state === 'done' && 'text-green',
|
||||
state === 'active' && 'text-blue-eyebrow',
|
||||
state === 'pending' && 'text-faint',
|
||||
)}
|
||||
>
|
||||
{s.eyebrow}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-[15px]',
|
||||
state === 'done' && 'text-green font-medium',
|
||||
state === 'active' && 'text-blue-ink font-semibold',
|
||||
state === 'pending' && 'text-[var(--muted-2)] font-medium',
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* The live-acceleration waveform. Reads the magnitude history from a ref and
|
||||
* redraws via requestAnimationFrame — independent of React renders, so it stays
|
||||
* smooth. Line turns to the spike colour during an impact event.
|
||||
*/
|
||||
export function WaveformCanvas({
|
||||
wave,
|
||||
impact,
|
||||
}: {
|
||||
wave: React.MutableRefObject<number[]>
|
||||
impact: boolean
|
||||
}) {
|
||||
const ref = useRef<HTMLCanvasElement>(null)
|
||||
const impactRef = useRef(impact)
|
||||
impactRef.current = impact
|
||||
|
||||
useEffect(() => {
|
||||
const cv = ref.current
|
||||
if (!cv) return
|
||||
const ctx = cv.getContext('2d')
|
||||
if (!ctx) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const resize = () => {
|
||||
cv.width = cv.clientWidth * dpr
|
||||
cv.height = cv.clientHeight * dpr
|
||||
}
|
||||
resize()
|
||||
window.addEventListener('resize', resize)
|
||||
|
||||
let raf = 0
|
||||
const draw = () => {
|
||||
const w = cv.width
|
||||
const h = cv.height
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
// center baseline
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.06)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, h / 2)
|
||||
ctx.lineTo(w, h / 2)
|
||||
ctx.stroke()
|
||||
// waveform
|
||||
const buf = wave.current
|
||||
const n = buf.length
|
||||
ctx.strokeStyle = impactRef.current ? '#ff8a5c' : '#7fa8ff'
|
||||
ctx.lineWidth = 1.5 * dpr
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = (i / (n - 1)) * w
|
||||
const y = h / 2 - buf[i] * (h * 0.42)
|
||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.stroke()
|
||||
raf = requestAnimationFrame(draw)
|
||||
}
|
||||
raf = requestAnimationFrame(draw)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
window.removeEventListener('resize', resize)
|
||||
}
|
||||
}, [wave])
|
||||
|
||||
return <canvas ref={ref} className="block h-[78px] w-full rounded-[10px] border border-rail-line bg-rail-inset" />
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SwitchProps {
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
id?: string
|
||||
'aria-label'?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A small controlled toggle matching the cockpit design: a pill track that turns
|
||||
* green when on, with a sliding knob. No Radix dependency — plain button.
|
||||
*/
|
||||
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
({ checked, onCheckedChange, disabled, className, ...aria }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
className={cn(
|
||||
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--blue)] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
checked ? 'bg-[var(--green)]' : 'bg-[var(--line-2)]',
|
||||
className,
|
||||
)}
|
||||
{...aria}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block h-[18px] w-[18px] transform rounded-full bg-white shadow transition-transform duration-200',
|
||||
checked ? 'translate-x-[23px]' : 'translate-x-[3px]',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
),
|
||||
)
|
||||
Switch.displayName = 'Switch'
|
||||
|
||||
export { Switch }
|
||||
+67
-1
@@ -24,6 +24,37 @@
|
||||
--input: 214 32% 91%;
|
||||
--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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSession } from '@/store/session'
|
||||
|
||||
/**
|
||||
* Reflect the persisted `theme` onto <html>: toggles the Tailwind `.dark` class
|
||||
* (darkMode:'class') AND sets `data-theme` (the design's convention). Runs on
|
||||
* mount and whenever the store theme changes. Call once, high in the tree.
|
||||
*/
|
||||
export function useApplyTheme() {
|
||||
const theme = useSession((s) => s.theme)
|
||||
useEffect(() => {
|
||||
const el = document.documentElement
|
||||
el.classList.toggle('dark', theme === 'dark')
|
||||
el.setAttribute('data-theme', theme)
|
||||
}, [theme])
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { getNodeMatrix } from './api'
|
||||
|
||||
/**
|
||||
* Poll the board's real LED-matrix framebuffer (~8 fps) for a pixel-perfect
|
||||
* mirror. Returns the 104 on/off dots, or null until a frame is read. Skips a
|
||||
* poll while the previous one is in flight so a slow board can't stack requests.
|
||||
*/
|
||||
export function useMatrixMirror(teamId: string, enabled: boolean, fps = 8): boolean[] | null {
|
||||
const [dots, setDots] = useState<boolean[] | null>(null)
|
||||
const inflight = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setDots(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const tick = async () => {
|
||||
if (inflight.current) return
|
||||
inflight.current = true
|
||||
try {
|
||||
const frame = await getNodeMatrix(teamId)
|
||||
if (!cancelled && frame) setDots(frame)
|
||||
} finally {
|
||||
inflight.current = false
|
||||
}
|
||||
}
|
||||
tick()
|
||||
const id = window.setInterval(tick, Math.round(1000 / fps))
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [teamId, enabled, fps])
|
||||
|
||||
return dots
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Live board telemetry for the cockpit rail — the ADXL355 stream, LED-matrix
|
||||
* mirror, and I2C bus state.
|
||||
*
|
||||
* SOURCE SEAM: today this is a **simulated** source (no ADXL355 is wired, and the
|
||||
* node doesn't yet push accelerometer frames). It faithfully reproduces the
|
||||
* designer's prototype behaviour so the finished rail can be seen and reviewed.
|
||||
* When the real telemetry lands (MCU ADXL355 driver → node `/ws/telemetry` →
|
||||
* `node:accel/matrix/i2c` events), swap `simulate()` for the real subscription
|
||||
* behind this same hook — nothing downstream changes.
|
||||
*/
|
||||
|
||||
export interface XYZ {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
}
|
||||
export interface I2cDevice {
|
||||
addr: string
|
||||
name: string
|
||||
synced: boolean
|
||||
}
|
||||
export interface Telemetry {
|
||||
/** True while a source is producing frames. `simulated` marks the sim source. */
|
||||
live: boolean
|
||||
simulated: boolean
|
||||
acc1: XYZ
|
||||
acc2: XYZ
|
||||
event: 'nominal' | 'impact'
|
||||
driftMs: number
|
||||
/** 104 booleans (13×8 row-major) mirroring the LED matrix. */
|
||||
matrix: boolean[]
|
||||
/** Rolling magnitude history for the waveform canvas (kept in a ref, no re-render). */
|
||||
wave: React.MutableRefObject<number[]>
|
||||
i2c: I2cDevice[]
|
||||
}
|
||||
|
||||
const WAVE_LEN = 160
|
||||
const rand = () => Math.random()
|
||||
const zero: XYZ = { x: 0, y: 0, z: 1 }
|
||||
|
||||
/** Scrolling sine across the 13×8 grid → 104 on/off dots. */
|
||||
function matrixFrame(phase: number): boolean[] {
|
||||
const on = new Array(104).fill(false)
|
||||
for (let c = 0; c < 13; c++) {
|
||||
const yf = 3.5 + 2.6 * Math.sin(c * 0.5 + phase)
|
||||
const y = Math.round(yf)
|
||||
for (let r = 0; r < 8; r++) {
|
||||
if (Math.abs(r - y) < 1.1) on[r * 13 + c] = true
|
||||
}
|
||||
}
|
||||
return on
|
||||
}
|
||||
|
||||
export function useTelemetry(enabled: boolean): Telemetry {
|
||||
const wave = useRef<number[]>(new Array(WAVE_LEN).fill(0))
|
||||
const [acc1, setAcc1] = useState<XYZ>(zero)
|
||||
const [acc2, setAcc2] = useState<XYZ>(zero)
|
||||
const [event, setEvent] = useState<'nominal' | 'impact'>('nominal')
|
||||
const [driftMs, setDriftMs] = useState(0.4)
|
||||
const [matrix, setMatrix] = useState<boolean[]>(() => matrixFrame(0))
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
let phase = 0
|
||||
let impactUntil = 0
|
||||
const jog = (base: number, amp: number) => base + (rand() - 0.5) * amp
|
||||
|
||||
const id = window.setInterval(() => {
|
||||
const now = performance.now()
|
||||
if (now > impactUntil && rand() < 0.045) impactUntil = now + 1400
|
||||
const impact = now < impactUntil
|
||||
|
||||
// per-sensor x/y/z (g). z rests at 1g (gravity); impact shakes all axes.
|
||||
const a = impact ? 1.1 : 0.03
|
||||
const z = impact ? 0.9 : 0.03
|
||||
setAcc1({ x: jog(0, a), y: jog(0, a), z: jog(1, z) })
|
||||
setAcc2({ x: jog(0, a * 0.9), y: jog(0, a * 0.9), z: jog(1, z) })
|
||||
|
||||
// magnitude for the waveform
|
||||
const m = impact ? 0.55 + rand() * 0.85 : 0.04 + rand() * 0.06
|
||||
const buf = wave.current
|
||||
buf.push(m)
|
||||
if (buf.length > WAVE_LEN) buf.shift()
|
||||
|
||||
setEvent(impact ? 'impact' : 'nominal')
|
||||
setDriftMs(0.3 + rand() * 0.5)
|
||||
phase += 0.34
|
||||
setMatrix(matrixFrame(phase))
|
||||
}, 90)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [enabled])
|
||||
|
||||
return {
|
||||
live: enabled,
|
||||
simulated: true,
|
||||
acc1,
|
||||
acc2,
|
||||
event,
|
||||
driftMs,
|
||||
matrix,
|
||||
wave,
|
||||
i2c: [
|
||||
{ addr: '0x1D', name: 'adxl355 · acc 1', synced: enabled },
|
||||
{ addr: '0x53', name: 'adxl355 · acc 2', synced: enabled },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,9 @@ describe('AddBuilder', () => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
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
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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’s
|
||||
own devices, and keeps working when the cloud drops by falling back to an on-board model.
|
||||
Open it to explore, then name the domain it’s for.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 & 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 & 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 & 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’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’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’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’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’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’s next</div>
|
||||
<p className="mt-2 text-[14px] leading-[1.5] text-ink-3">
|
||||
Try all three prompts above. Once your agent has run each one successfully, we’ll
|
||||
capture your domain’s skills and policies here.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PanelCard>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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))',
|
||||
|
||||
Reference in New Issue
Block a user