feat(web): two-pane "cockpit" redesign with a live board rail

Restructure the workshop flow into the designer's cockpit: a persistent shell
(header + 5-step stepper + sticky instrument rail) wrapping the phase routes via
a React-Router layout route, so the rail stays mounted across navigation.

- Design system: IBM Plex Mono + Newsreader; the full cockpit token set (light
  + dark) in index.css; a working light/dark theme toggle (store `theme` +
  useApplyTheme); a `switch` ui primitive.
- Shell: CockpitLayout, Stepper (forward-gated), PanelChrome helpers. Every
  phase page restyled to the editorial panels + the WORKSHOP-FLOW fixes
  (channels-after-bind, domain framing + L1 prefill, L2/L3 prefill at 3/3,
  in-place submission finale). Store gains `tried` + `channels.saidHi` (v4).
- Live rail (CockpitRail): real node heartbeat + agent activity log + ADD
  progress; sim telemetry (useTelemetry) for the waveform/accel/I2C behind a
  seam, marked SIM.
- LED-matrix PIXEL MIRROR (real): the rail shows exactly what the physical
  matrix displays — API GET /nodes/:team/matrix reads the board's framebuffer
  off the :9999 relay (readMatrixFrame + the `matrixget` relay command);
  useMatrixMirror polls it and unpacks the 104 bits.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-23 07:38:14 -07:00
co-authored by Claude Opus 4.8
parent 6c82b79b01
commit 1a39457940
28 changed files with 1240 additions and 434 deletions
+221
View File
@@ -0,0 +1,221 @@
import { useLocation } from 'react-router-dom'
import { useSession } from '@/store/session'
import { useNodeFeed } from '@/lib/useNodeFeed'
import { useTelemetry } from '@/lib/useTelemetry'
import { useMatrixMirror } from '@/lib/useMatrixMirror'
import { WaveformCanvas } from './WaveformCanvas'
import type { NodeActivityKind } from '@/types'
import { cn } from '@/lib/utils'
import { ADD_LAYERS } from '@/lib/addLayers'
/**
* The persistent instrument rail (right pane of the cockpit). A dark "device
* screen" — intentionally dark in both themes. Six sections: node heartbeat,
* agent activity log, and ADD progress are wired to real state; the LED matrix,
* I2C bus, and live acceleration come from the telemetry seam (`useTelemetry`),
* which is a **simulated** source today (marked SIM) until the ADXL355 stream
* lands. See the plan's WS3.
*/
const LOG_COLOR: Record<NodeActivityKind, string> = {
thinking: 'text-rail-dim2',
tool: 'text-rail-text2',
flash: 'text-rail-blue',
error: 'text-rail-spike',
response: 'text-rail-green',
fallback: 'text-[#d9a441]',
}
// which layers are "next" on each route
const NEXT_BY_PATH: Record<string, string[]> = {
'/workshop/module1': ['L1'],
'/workshop/module2': ['L2', 'L3'],
'/workshop/add': ['L4', 'L5'],
}
function RailSection({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="mt-5">
<div className="font-mono text-[9.5px] tracking-[0.18em] text-rail-dim">{label}</div>
<div className="mt-2">{children}</div>
</div>
)
}
export function CockpitRail() {
const teamId = useSession((s) => s.teamId)
const team = useSession((s) => s.team)
const connected = useSession((s) => s.device.connected)
const add = useSession((s) => s.add)
const submitted = useSession((s) => s.submission.code != null)
const { pathname } = useLocation()
const feed = useNodeFeed(teamId, connected)
const tel = useTelemetry(connected)
const online = connected && feed.online
// Pixel-perfect mirror of the physical matrix (real board frame). Falls back
// to the sim frame until the first real frame arrives.
const mirror = useMatrixMirror(teamId, online)
const matrixDots = mirror ?? tel.matrix
const matrixLive = mirror != null
const nodeName = team.name ? team.name.toLowerCase().replace(/\s+/g, '-') : 'crimson-node'
const nextSet = new Set(NEXT_BY_PATH[pathname] ?? [])
const layerState = (key: string): 'idle' | 'next' | 'done' => {
if (submitted || add[key as keyof typeof add]?.trim()) return 'done'
return nextSet.has(key) ? 'next' : 'idle'
}
const log = feed.activity.slice(0, 6)
return (
<aside className="sticky top-6 rounded-[18px] bg-rail-bg p-[22px] text-rail-text shadow-[0_20px_50px_-24px_rgba(0,0,0,0.5)]">
{/* 1 · node header / heartbeat */}
<div className="flex items-center gap-[11px]">
<span
className={cn(
'h-2.5 w-2.5 rounded-full',
online ? 'bg-rail-green shadow-[0_0_10px_#3fd28a] animate-pulse' : 'bg-rail-dim2',
)}
/>
<span className="font-mono text-sm font-semibold tracking-[0.02em] text-rail-text3">{nodeName}</span>
<span
className={cn(
'font-mono text-[9px] tracking-[0.14em] rounded border px-[7px] py-0.5',
online ? 'text-rail-green border-[#2c6b4f]' : 'text-rail-dim2 border-rail-line',
)}
>
{online ? 'LIVE' : 'OFFLINE'}
</span>
<span className="ml-auto font-mono text-[10px] text-rail-dim">arduino uno q</span>
</div>
{/* 2 · LED matrix 13×8 — real pixel mirror of the physical matrix */}
<div className="mt-5">
<div className="flex items-center justify-between">
<div className="font-mono text-[9.5px] tracking-[0.18em] text-rail-dim">LED MATRIX · 13×8</div>
{matrixLive && (
<span className="font-mono text-[8.5px] tracking-[0.12em] text-rail-green">● MIRROR</span>
)}
</div>
<div className="mt-2 flex justify-center rounded-[10px] border border-rail-line bg-rail-inset px-[13px] py-3">
<div className="grid gap-1" style={{ gridTemplateColumns: 'repeat(13, 1fr)' }}>
{Array.from({ length: 104 }).map((_, i) => {
const lit = tel.live && matrixDots[i]
return (
<span
key={i}
className="h-[9px] w-[9px] rounded-[2px] transition-[background] duration-75"
style={{
background: lit ? 'oklch(0.7 0.2 34)' : 'oklch(0.28 0.01 260)',
boxShadow: lit ? '0 0 5px oklch(0.7 0.2 34)' : 'none',
}}
/>
)
})}
</div>
</div>
</div>
{/* 3 · I2C bus (telemetry) */}
<RailSection label="I2C BUS · 100 kHz">
<div className="rounded-[10px] border border-rail-line2 bg-rail-panel px-1 py-1.5 font-mono text-xs">
{tel.i2c.map((d, i) => (
<div
key={d.addr}
className={cn('flex items-center gap-2.5 px-3 py-2', i === 0 && 'border-b border-rail-line')}
>
<span className="text-rail-blue">{d.addr}</span>
<span className="text-rail-text2">{d.name}</span>
<span className={cn('ml-auto', d.synced ? 'text-rail-green' : 'text-rail-dim3')}>
{d.synced ? '● synced' : '○ idle'}
</span>
</div>
))}
</div>
<div className="mt-[7px] font-mono text-[10px] text-rail-dim2">
{tel.live ? `SYNC/INT aligned · drift ${tel.driftMs.toFixed(1)} ms` : 'run i2c_scan to enumerate the bus'}
</div>
</RailSection>
{/* 4 · live acceleration (telemetry) */}
<RailSection label="LIVE ACCELERATION · g">
<div className="-mt-[18px] mb-2 flex items-center justify-end gap-2">
{tel.live && tel.simulated && (
<span className="rounded-[4px] border border-[#d9a441]/40 px-[5px] py-[1px] font-mono text-[8.5px] tracking-[0.12em] text-[#d9a441]">
SIM
</span>
)}
<span
className={cn(
'font-mono text-[9.5px] tracking-[0.1em]',
!tel.live ? 'text-rail-dim' : tel.event === 'impact' ? 'text-rail-spike' : 'text-rail-green',
)}
>
{!tel.live ? 'AWAITING STREAM' : tel.event === 'impact' ? 'IMPACT SPIKE' : 'NOMINAL'}
</span>
</div>
{tel.live ? (
<WaveformCanvas wave={tel.wave} impact={tel.event === 'impact'} />
) : (
<div className="flex h-[78px] items-center justify-center rounded-[10px] border border-rail-line bg-rail-inset font-mono text-[10px] text-rail-dim3">
adxl355_stream — awaiting board
</div>
)}
<div className="mt-2 grid grid-cols-2 gap-2 font-mono text-[11.5px]">
{([['ACC 1', tel.acc1], ['ACC 2', tel.acc2]] as const).map(([n, a]) => (
<div key={n} className="rounded-lg border border-rail-line2 bg-rail-panel px-[11px] py-[9px]">
<div className="text-[9.5px] tracking-[0.12em] text-rail-dim">{n}</div>
{(['x', 'y', 'z'] as const).map((axis) => (
<div key={axis} className="text-rail-text2">
{axis}{' '}
<span className="text-rail-text3 tabular-nums">
{tel.live ? a[axis].toFixed(3) : '—'}
</span>
</div>
))}
</div>
))}
</div>
</RailSection>
{/* 5 · agent activity log (real) */}
<RailSection label="AGENT ACTIVITY">
<div className="h-[132px] overflow-hidden rounded-[10px] border border-rail-line bg-rail-inset px-[13px] py-[11px] font-mono text-[11px] leading-[1.75]">
{log.length === 0 ? (
<div className="text-rail-dim2">idle — prompt your agent to see it work</div>
) : (
log.map((e, i) => (
<div key={i} className={cn('truncate', LOG_COLOR[e.kind])}>
{e.label}
</div>
))
)}
</div>
</RailSection>
{/* 6 · ADD progress (real, local) */}
<RailSection label="AGENT DESIGN DOC">
<div className="flex flex-col gap-px font-mono text-[11px]">
{ADD_LAYERS.map(({ key, n, title }) => {
const st = layerState(key)
return (
<div key={key} className="flex items-center gap-2.5 px-0.5 py-[7px]">
<span className={cn(st === 'idle' ? 'text-[#4a5060]' : 'text-rail-blue')}>L{n}</span>
<span className={cn(st === 'idle' ? 'text-rail-dim2' : 'text-rail-text2')}>{title}</span>
<span
className={cn(
'ml-auto',
st === 'done' ? 'text-rail-green' : st === 'next' ? 'text-[#d9a441]' : 'text-rail-dim3',
)}
>
{st === 'done' ? 'done' : st === 'next' ? 'next' : '—'}
</span>
</div>
)
})}
</div>
</RailSection>
</aside>
)
}