feat(web): two-pane "cockpit" redesign with a live board rail
Restructure the workshop flow into the designer's cockpit: a persistent shell (header + 5-step stepper + sticky instrument rail) wrapping the phase routes via a React-Router layout route, so the rail stays mounted across navigation. - Design system: IBM Plex Mono + Newsreader; the full cockpit token set (light + dark) in index.css; a working light/dark theme toggle (store `theme` + useApplyTheme); a `switch` ui primitive. - Shell: CockpitLayout, Stepper (forward-gated), PanelChrome helpers. Every phase page restyled to the editorial panels + the WORKSHOP-FLOW fixes (channels-after-bind, domain framing + L1 prefill, L2/L3 prefill at 3/3, in-place submission finale). Store gains `tried` + `channels.saidHi` (v4). - Live rail (CockpitRail): real node heartbeat + agent activity log + ADD progress; sim telemetry (useTelemetry) for the waveform/accel/I2C behind a seam, marked SIM. - LED-matrix PIXEL MIRROR (real): the rail shows exactly what the physical matrix displays — API GET /nodes/:team/matrix reads the board's framebuffer off the :9999 relay (readMatrixFrame + the `matrixget` relay command); useMatrixMirror polls it and unpacks the 104 bits. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6c82b79b01
commit
1a39457940
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user