coloooors

This commit is contained in:
legop3
2025-11-18 15:36:56 -05:00
parent 457ad42565
commit fb5b166a37
31 changed files with 292 additions and 327 deletions
+27
View File
@@ -0,0 +1,27 @@
# general idea
- uses the rover's reported battery full, warn, and urgent values
- will apply to all rovers
- uses the rover locking system
- completely server side
- always use the battery warn value as 0% battery and the full value as 100%
## first: the server-side server-wide event bus
- global server event bus
- used for realtime alerts between modules
- includes a way to tell where the event is coming from
## what will the battery manager do?
- watch each rover's battery charge number (reported in sensors)
- if the number reaches warn, fire an event on the event bus
- the UI will show a warning to users, independently based on sensor data
- once the rover is docked and charging, lock it.
- when the battery is fully charged, unlock it.
## while we're at it...
- add a lock reason to the rover locking system
- add the following to the rover roster in the UI state message:
- battery full #
- battery warn #
- battery urgent #
- in the rover roster UI component, add a display for battery percentage and lock reason.
- make the rover's background red when locked
+4 -4
View File
@@ -4,9 +4,6 @@
- video stream currently unloads and reloads based on the tab being active - video stream currently unloads and reloads based on the tab being active
- just remove this feature. It needs to stay running all the time. - just remove this feature. It needs to stay running all the time.
## UI and session info
- UI needs a display for the turns mode
## UI tweaks and fixes ## UI tweaks and fixes
- remove redundant rover name tags everywhere - remove redundant rover name tags everywhere
- just have one in the hud: "You are driving <name>" - just have one in the hud: "You are driving <name>"
@@ -20,4 +17,7 @@
- buttons should be even darker, almost black - buttons should be even darker, almost black
- all buttons should have a hover effect which lightens them a little - all buttons should have a hover effect which lightens them a little
- text should be either white or the same gray - text should be either white or the same gray
-
## mobile UI elements
- they are currently not styled to match the rest of the program
- make them match
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title> <title>webui</title>
<script type="module" crossorigin src="/assets/index-Be0x3WiZ.js"></script> <script type="module" crossorigin src="/assets/index-CbBQ197T.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BTX_m20O.css"> <link rel="stylesheet" crossorigin href="/assets/index-DkvQkTlU.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+12 -12
View File
@@ -49,12 +49,12 @@ function useLayoutMode() {
function DesktopLayout({ layout }) { function DesktopLayout({ layout }) {
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-0.5">
<section className="grid grid-cols-[minmax(0,1.8fr)_minmax(0,1fr)] gap-1"> <section className="grid grid-cols-[minmax(0,1.8fr)_minmax(0,1fr)] gap-0.5">
<DriverVideoPanel /> <DriverVideoPanel />
<RightPaneTabs layout={layout} /> <RightPaneTabs layout={layout} />
</section> </section>
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1"> <section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-0.5">
{/* <AdminPanel /> */} {/* <AdminPanel /> */}
<LogPanel /> <LogPanel />
<SessionSnapshot /> <SessionSnapshot />
@@ -65,7 +65,7 @@ function DesktopLayout({ layout }) {
function MobilePortraitLayout() { function MobilePortraitLayout() {
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-0.5">
<DriverVideoPanel /> <DriverVideoPanel />
<MobileControls /> <MobileControls />
<DrivePanel /> <DrivePanel />
@@ -80,21 +80,21 @@ function MobilePortraitLayout() {
function MobileLandscapeLayout() { function MobileLandscapeLayout() {
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-0.5">
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-1"> <section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-0.5">
<MobileLandscapeAuxColumn /> <MobileLandscapeAuxColumn />
<DriverVideoPanel /> <DriverVideoPanel />
<MobileLandscapeControlColumn /> <MobileLandscapeControlColumn />
</section> </section>
<div className="flex flex-col gap-1 pb-2"> <div className="flex flex-col gap-0.5 pb-0.5">
<RoomCameraPanel /> <RoomCameraPanel />
<DrivePanel /> <DrivePanel />
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1"> <section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-0.5">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-0.5">
<AuthPanel /> <AuthPanel />
<AdminPanel /> <AdminPanel />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-0.5">
<LogPanel /> <LogPanel />
<TelemetryPanel /> <TelemetryPanel />
</div> </div>
@@ -114,12 +114,12 @@ function App() {
: <MobilePortraitLayout />; : <MobilePortraitLayout />;
return ( return (
<div className="min-h-screen bg-black text-slate-50"> <div className="min-h-screen bg-black text-slate-100">
<SettingsProvider> <SettingsProvider>
<ControlSystemProvider> <ControlSystemProvider>
<KeyboardInputManager /> <KeyboardInputManager />
<GamepadInputManager /> <GamepadInputManager />
<main className="flex w-full flex-col gap-1 text-base">{renderedLayout}</main> <main className="flex w-full flex-col gap-0.5 text-base">{renderedLayout}</main>
<AlertFeed /> <AlertFeed />
<ModeGateOverlay /> <ModeGateOverlay />
</ControlSystemProvider> </ControlSystemProvider>
+8 -12
View File
@@ -57,14 +57,10 @@ export default function AdminPanel() {
if (!isAdmin) return null; if (!isAdmin) return null;
return ( return (
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100"> <section className="panel-section space-y-0.5 text-base">
<div className="flex items-center justify-between gap-1 text-sm"> <div className="flex items-center justify-between gap-0.5 text-sm">
<span>Admin controls</span> <span>Admin controls</span>
<select <select value={currentMode} onChange={handleModeChange} className="field-input text-sm">
value={currentMode}
onChange={handleModeChange}
className="rounded-sm bg-black/40 px-1 py-1 text-sm text-slate-100"
>
{MODES.map((mode) => ( {MODES.map((mode) => (
<option key={mode.key} value={mode.key}> <option key={mode.key} value={mode.key}>
{mode.label} {mode.label}
@@ -73,25 +69,25 @@ export default function AdminPanel() {
</select> </select>
</div> </div>
<div className="mt-1 space-y-1 text-sm"> <div className="space-y-0.5 text-sm">
{roster.map((rover) => ( {roster.map((rover) => (
<div key={rover.id} className="flex flex-wrap items-center justify-between gap-1 rounded-sm bg-black/30 px-1 py-1"> <div key={rover.id} className="surface flex flex-wrap items-center justify-between gap-0.5">
<div> <div>
<p className="text-slate-100">{rover.name}</p> <p className="text-slate-100">{rover.name}</p>
<p className="text-xs text-slate-400">{lockMap[rover.id] ? 'locked' : 'unlocked'}</p> <p className="text-xs text-slate-400">{lockMap[rover.id] ? 'locked' : 'unlocked'}</p>
</div> </div>
<div className="flex flex-wrap gap-1 text-xs"> <div className="flex flex-wrap gap-0.5 text-xs">
<button <button
type="button" type="button"
onClick={() => handleLockToggle(rover.id, !lockMap[rover.id])} onClick={() => handleLockToggle(rover.id, !lockMap[rover.id])}
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100" className="button-dark"
> >
{lockMap[rover.id] ? 'Unlock' : 'Lock'} {lockMap[rover.id] ? 'Unlock' : 'Lock'}
</button> </button>
<button <button
type="button" type="button"
onClick={() => handleForceControl(rover.id)} onClick={() => handleForceControl(rover.id)}
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100" className="button-dark"
> >
Force Force
</button> </button>
+3 -3
View File
@@ -30,7 +30,7 @@ export default function AlertFeed() {
if (!visible.length) return null; if (!visible.length) return null;
return ( return (
<div className="pointer-events-none fixed top-1 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-1"> <div className="pointer-events-none fixed top-0.5 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-0.5">
{visible.map((toast) => ( {visible.map((toast) => (
<AlertToast key={toast.key} alert={toast.alert} /> <AlertToast key={toast.key} alert={toast.alert} />
))} ))}
@@ -46,8 +46,8 @@ function AlertToast({ alert }) {
? 'bg-amber-500/30 text-amber-100' ? 'bg-amber-500/30 text-amber-100'
: 'bg-emerald-500/30 text-emerald-100'; : 'bg-emerald-500/30 text-emerald-100';
return ( return (
<div className={`pointer-events-auto rounded-sm px-1 py-1 text-[0.75rem] ${colorClasses}`}> <div className={`pointer-events-auto px-0.5 py-0.5 text-[0.85rem] ${colorClasses}`}>
<p className="text-[0.65rem] text-slate-300">{alert.title || 'Alert'}</p> <p className="text-xs text-slate-200">{alert.title || 'Alert'}</p>
<p className="text-sm text-white">{alert.message}</p> <p className="text-sm text-white">{alert.message}</p>
</div> </div>
); );
+8 -20
View File
@@ -22,43 +22,31 @@ export default function AuthPanel() {
} }
return ( return (
<div className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100"> <div className="panel-section space-y-0.5 text-base">
<p className="text-sm text-slate-400">Admin login</p> <p className="text-sm text-slate-400">Admin login</p>
<form className="mt-1 flex flex-col gap-1" onSubmit={handleLogin}> <form className="flex flex-col gap-0.5" onSubmit={handleLogin}>
<input <input
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100" className="field-input"
placeholder="Username" placeholder="Username"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
/> />
<input <input
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100" className="field-input"
placeholder="Password" placeholder="Password"
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
<button <button type="submit" disabled={loading} className="button-dark w-full disabled:opacity-50">
type="submit"
disabled={loading}
className="rounded-sm bg-slate-200 px-1 py-1 text-sm font-semibold text-black disabled:opacity-50"
>
{loading ? 'Logging in…' : 'Login'} {loading ? 'Logging in…' : 'Login'}
</button> </button>
</form> </form>
<div className="mt-1 flex gap-1 text-sm"> <div className="flex gap-0.5 text-sm">
<button <button type="button" onClick={() => setRole('user')} className="flex-1 button-dark">
type="button"
onClick={() => setRole('user')}
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-slate-200"
>
Driver Driver
</button> </button>
<button <button type="button" onClick={() => setRole('spectator')} className="flex-1 button-dark">
type="button"
onClick={() => setRole('spectator')}
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-slate-200"
>
Spectator Spectator
</button> </button>
</div> </div>
+8 -20
View File
@@ -81,12 +81,12 @@ export default function CameraServoPanel() {
}; };
return ( return (
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100"> <section className="panel-section space-y-0.5 text-base">
<div className="flex items-center justify-between text-sm text-slate-300"> <div className="flex items-center justify-between text-sm text-slate-300">
<span>Camera Tilt</span> <span>Camera Tilt</span>
<span className="font-mono text-sm text-slate-100">{formatDegrees(value)}</span> <span className="font-mono text-sm text-slate-100">{formatDegrees(value)}</span>
</div> </div>
<div className="mt-2"> <div>
<input <input
type="range" type="range"
className="w-full accent-emerald-400" className="w-full accent-emerald-400"
@@ -99,35 +99,23 @@ export default function CameraServoPanel() {
onTouchEnd={commitSlider} onTouchEnd={commitSlider}
onPointerUp={commitSlider} onPointerUp={commitSlider}
/> />
<div className="mt-1 flex justify-between text-xs text-slate-400"> <div className="mt-0.5 flex justify-between text-xs text-slate-400">
<span>{formatDegrees(min)}</span> <span>{formatDegrees(min)}</span>
<span>{formatDegrees(max)}</span> <span>{formatDegrees(max)}</span>
</div> </div>
</div> </div>
<div className="mt-2 flex gap-1 text-sm"> <div className="flex gap-0.5 text-sm">
<button <button type="button" className="flex-1 button-dark" onClick={() => handleNudge(-1)}>
type="button"
className="flex-1 rounded bg-slate-800 px-1 py-0.5 text-slate-100 hover:bg-slate-700"
onClick={() => handleNudge(-1)}
>
Tilt Down Tilt Down
</button> </button>
<button <button type="button" className="flex-1 button-dark" onClick={() => goServoHome()}>
type="button"
className="flex-1 rounded bg-slate-700 px-1 py-0.5 text-slate-100 hover:bg-slate-600"
onClick={() => goServoHome()}
>
Center Center
</button> </button>
<button <button type="button" className="flex-1 button-dark" onClick={() => handleNudge(1)}>
type="button"
className="flex-1 rounded bg-slate-800 px-1 py-0.5 text-slate-100 hover:bg-slate-700"
onClick={() => handleNudge(1)}
>
Tilt Up Tilt Up
</button> </button>
</div> </div>
<p className="mt-1 text-xs text-slate-400"> <p className="text-xs text-slate-400">
Step: {formatDegrees(step)} · Pin GPIO {config?.pin} Step: {formatDegrees(step)} · Pin GPIO {config?.pin}
</p> </p>
</section> </section>
+11 -8
View File
@@ -27,12 +27,12 @@ export default function DrivePanel() {
}; };
return ( return (
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100"> <section className="panel-section space-y-0.5 text-base">
<div className="flex items-center justify-between text-sm text-slate-300"> <div className="flex items-center justify-between text-sm text-slate-300">
<span>Drive control</span> <span>Drive control</span>
<span>{roverId ? `Rover ${roverId}` : 'unassigned'}</span> <span>{roverId ? 'Ready to drive' : 'No rover assigned'}</span>
</div> </div>
<div className="mt-1 space-y-1"> <div className="space-y-0.5">
<ActionCard <ActionCard
title="Start Driving" title="Start Driving"
description="Press to enable driving mode, then start moving. The headlamps should illuminate." description="Press to enable driving mode, then start moving. The headlamps should illuminate."
@@ -58,21 +58,24 @@ export default function DrivePanel() {
} }
function ActionCard({ title, description, statuses, tone, onClick, disabled, footnote }) { function ActionCard({ title, description, statuses, tone, onClick, disabled, footnote }) {
const baseColor = tone === 'indigo' ? 'bg-indigo-600' : 'bg-emerald-600'; const colors =
tone === 'indigo'
? { base: 'bg-indigo-600', hover: 'hover:bg-indigo-500' }
: { base: 'bg-emerald-600', hover: 'hover:bg-emerald-500' };
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className={`w-full rounded-lg ${baseColor} px-1 py-1 text-left text-white disabled:opacity-40`} className={`w-full ${colors.base} ${colors.hover} px-0.5 py-0.5 text-left text-white transition-colors disabled:opacity-40`}
> >
<p className="text-base font-semibold">{title}</p> <p className="text-base font-semibold">{title}</p>
<p className="text-sm text-white/90">{description}</p> <p className="text-sm text-white/90">{description}</p>
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-0.5 flex flex-wrap gap-0.5">
{statuses.map((status) => ( {statuses.map((status) => (
<span <span
key={status.label} key={status.label}
className={`rounded-full px-1 py-0.5 text-xs font-semibold ${ className={`px-0.5 py-0.5 text-xs font-semibold ${
status.active ? 'bg-lime-300 text-emerald-900' : 'bg-emerald-900 text-emerald-100' status.active ? 'bg-lime-300 text-emerald-900' : 'bg-emerald-900 text-emerald-100'
}`} }`}
> >
@@ -80,7 +83,7 @@ function ActionCard({ title, description, statuses, tone, onClick, disabled, foo
</span> </span>
))} ))}
</div> </div>
{footnote && <p className="mt-1 text-xs text-emerald-50/80">{footnote}</p>} {footnote && <p className="mt-0.5 text-xs text-emerald-50/80">{footnote}</p>}
</button> </button>
); );
} }
+5 -11
View File
@@ -15,20 +15,14 @@ export default function DriverVideoPanel() {
: null; : null;
const batteryConfig = batteryRecord?.battery ?? null; const batteryConfig = batteryRecord?.battery ?? null;
const roverLabel = batteryRecord?.name || (roverId ? `Rover ${roverId}` : '');
return ( return (
<section className="rounded-sm bg-[#242a32] p-1"> <section className="panel">
{roverId ? ( {roverId ? (
<div className="w-full"> <VideoTile sessionInfo={info} label={roverLabel} telemetryFrame={frame} batteryConfig={batteryConfig} />
<VideoTile
sessionInfo={info}
label={roverId}
muted={false}
telemetryFrame={frame}
batteryConfig={batteryConfig}
/>
</div>
) : ( ) : (
<p className="text-sm text-slate-400">Assign a rover to view the video feed.</p> <p className="panel-muted">Assign a rover to view the video feed.</p>
)} )}
</section> </section>
); );
+17 -25
View File
@@ -42,7 +42,7 @@ const ACTIONS = [
function SliderField({ label, description, min, max, step, value, onChange }) { function SliderField({ label, description, min, max, step, value, onChange }) {
return ( return (
<label className="block rounded border border-white/5 p-1"> <label className="surface block">
<div className="flex items-center justify-between text-xs text-slate-300"> <div className="flex items-center justify-between text-xs text-slate-300">
<span className="font-semibold text-slate-100">{label}</span> <span className="font-semibold text-slate-100">{label}</span>
<span className="font-mono text-slate-400">{NUMBER_FORMAT.format(value)}</span> <span className="font-mono text-slate-400">{NUMBER_FORMAT.format(value)}</span>
@@ -55,7 +55,7 @@ function SliderField({ label, description, min, max, step, value, onChange }) {
step={step} step={step}
value={value} value={value}
onChange={(event) => onChange(Number(event.target.value))} onChange={(event) => onChange(Number(event.target.value))}
className="mt-1 w-full accent-emerald-400" className="mt-0.5 w-full accent-emerald-400"
/> />
</label> </label>
); );
@@ -162,31 +162,27 @@ export default function GamepadMappingSettings() {
}; };
return ( return (
<section className="rounded-sm bg-[#1d232b] p-1 text-sm text-slate-100"> <section className="panel-section space-y-0.5 text-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Gamepad mapping</p> <p className="text-xs text-slate-400">Gamepad mapping</p>
<p className="text-[0.65rem] text-slate-500"> <p className="text-[0.65rem] text-slate-500">
{gamepadConnected {gamepadConnected
? 'Move sticks/axes with a big sweep or press buttons when capturing.' ? 'Move sticks/axes with a big sweep or press buttons when capturing.'
: 'Connect a controller to configure.'} : 'Connect a controller to configure.'}
</p> </p>
{capture && ( {capture && (
<p className="mt-1 text-[0.7rem] text-emerald-400">Capturing {capture.label}</p> <p className="mt-0.5 text-[0.7rem] text-emerald-400">Capturing {capture.label}</p>
)} )}
</div> </div>
<button <button type="button" onClick={() => reset()} className="button-dark text-xs">
type="button"
onClick={() => reset()}
className="rounded-sm bg-black/40 px-2 py-1 text-xs uppercase tracking-wide text-slate-200 hover:bg-black/60"
>
Clear all Clear all
</button> </button>
</div> </div>
<div className="mt-2 space-y-2"> <div className="space-y-0.5">
<div className="rounded border border-white/5 p-1"> <div className="space-y-0.5 surface">
<p className="text-[0.7rem] uppercase tracking-wide text-slate-500">Sensitivity &amp; feel</p> <p className="text-[0.7rem] text-slate-500">Sensitivity &amp; feel</p>
<div className="mt-1 space-y-1"> <div className="space-y-0.5">
<SliderField <SliderField
label="Drive deadzone" label="Drive deadzone"
description="Ignore small drive stick movement" description="Ignore small drive stick movement"
@@ -226,27 +222,23 @@ export default function GamepadMappingSettings() {
</div> </div>
</div> </div>
{Object.entries(grouped).map(([section, actions]) => ( {Object.entries(grouped).map(([section, actions]) => (
<div key={section} className="rounded border border-white/5 p-1"> <div key={section} className="space-y-0.5 surface">
<p className="text-[0.7rem] uppercase tracking-wide text-slate-500">{section}</p> <p className="text-[0.7rem] text-slate-500">{section}</p>
<div className="mt-1 space-y-1"> <div className="space-y-0.5">
{actions.map((action) => ( {actions.map((action) => (
<div key={action.id} className="flex items-center justify-between rounded bg-black/30 px-1 py-1 text-xs"> <div key={action.id} className="surface-muted flex items-center justify-between text-xs">
<div> <div>
<p className="font-semibold text-slate-100">{action.label}</p> <p className="font-semibold text-slate-100">{action.label}</p>
<p className="text-[0.65rem] text-slate-400">{getValueLabel(action)}</p> <p className="text-[0.65rem] text-slate-400">{getValueLabel(action)}</p>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-0.5">
<button <button type="button" onClick={() => handleClear(action)} className="button-dark text-[0.7rem]">
type="button"
onClick={() => handleClear(action)}
className="rounded bg-slate-800 px-2 py-1 text-[0.65rem] uppercase tracking-wide"
>
Clear Clear
</button> </button>
<button <button
type="button" type="button"
onClick={() => setCapture(action)} onClick={() => setCapture(action)}
className={`rounded px-2 py-1 text-[0.65rem] uppercase tracking-wide ${capture?.id === action.id ? 'bg-emerald-500 text-emerald-950' : 'bg-slate-700 text-slate-100'}`} className={`${capture?.id === action.id ? 'px-0.5 py-0.5 bg-emerald-500 text-emerald-950 hover:bg-emerald-400' : 'button-dark'} text-[0.7rem] font-medium`}
> >
{capture?.id === action.id ? 'Waiting…' : 'Capture'} {capture?.id === action.id ? 'Waiting…' : 'Capture'}
</button> </button>
+3 -3
View File
@@ -18,11 +18,11 @@ export default function HelpPanel({ layout }) {
}; };
const tips = presets[layout] || presets.desktop; const tips = presets[layout] || presets.desktop;
return ( return (
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100"> <section className="panel-section space-y-0.5 text-base">
<p className="text-sm text-slate-400">Help</p> <p className="text-sm text-slate-400">Help</p>
<ul className="mt-1 space-y-1 text-sm"> <ul className="space-y-0.5 text-sm">
{tips.map((tip) => ( {tips.map((tip) => (
<li key={tip} className="rounded-sm bg-black/30 px-1 py-1 text-slate-200"> <li key={tip} className="surface text-slate-200">
{tip} {tip}
</li> </li>
))} ))}
+12 -23
View File
@@ -71,51 +71,40 @@ export default function KeymapSettings() {
); );
return ( return (
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100"> <section className="panel-section space-y-0.5 text-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Keyboard Layout</p> <p className="text-xs text-slate-400">Keyboard layout</p>
<p className="text-[0.7rem] text-slate-500">Per-browser · click to change a binding</p> <p className="text-[0.7rem] text-slate-500">Per-browser · click to change a binding</p>
</div> </div>
<button <button type="button" onClick={() => resetKeyBindings()} className="button-dark text-xs">
type="button" Reset defaults
onClick={() => resetKeyBindings()}
className="rounded-sm bg-black/40 px-2 py-1 text-xs uppercase tracking-wide text-slate-200 hover:bg-black/60"
>
Reset Defaults
</button> </button>
</div> </div>
<div className="mt-2 space-y-2"> <div className="space-y-0.5">
{Object.entries(grouped).map(([group, actions]) => ( {Object.entries(grouped).map(([group, actions]) => (
<div key={group} className="rounded border border-white/5 p-1"> <div key={group} className="space-y-0.5 surface">
<p className="text-[0.7rem] uppercase tracking-wide text-slate-400">{group}</p> <p className="text-[0.75rem] text-slate-400">{group}</p>
<div className="mt-1 space-y-1"> <div className="space-y-0.5">
{actions.map((action) => { {actions.map((action) => {
const value = currentKey(action.id); const value = currentKey(action.id);
const isActive = active === action.id; const isActive = active === action.id;
return ( return (
<div <div key={action.id} className="surface-muted flex items-center justify-between text-xs">
key={action.id}
className="flex items-center justify-between rounded bg-black/30 px-1 py-1 text-xs"
>
<div> <div>
<p className="font-semibold text-slate-100">{action.label}</p> <p className="font-semibold text-slate-100">{action.label}</p>
<p className="text-[0.65rem] text-slate-400">{formatKeyLabel(value)}</p> <p className="text-[0.65rem] text-slate-400">{formatKeyLabel(value)}</p>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-0.5">
{isActive && ( {isActive && (
<button <button type="button" onClick={() => cancel()} className="button-danger text-[0.7rem]">
type="button"
onClick={() => cancel()}
className="rounded bg-red-600 px-2 py-1 text-[0.65rem] uppercase tracking-wide text-red-100"
>
Cancel Cancel
</button> </button>
)} )}
<button <button
type="button" type="button"
onClick={() => startCapture(action.id)} onClick={() => startCapture(action.id)}
className={`rounded px-2 py-1 text-[0.65rem] uppercase tracking-wide ${isActive ? 'bg-emerald-500 text-emerald-950' : 'bg-slate-700 text-slate-100'}`} className={`${isActive ? 'px-0.5 py-0.5 bg-emerald-500 text-emerald-950 hover:bg-emerald-400' : 'button-dark'} text-[0.7rem] font-medium transition-colors`}
> >
{isActive ? 'Press a key…' : 'Change'} {isActive ? 'Press a key…' : 'Change'}
</button> </button>
+3 -3
View File
@@ -3,12 +3,12 @@ import { useSession } from '../context/SessionContext.jsx';
export default function LogPanel() { export default function LogPanel() {
const { logs } = useSession(); const { logs } = useSession();
return ( return (
<div className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100"> <div className="panel-section space-y-0.5 text-base">
<div className="flex items-center justify-between text-sm text-slate-400"> <div className="flex items-center justify-between text-sm text-slate-400">
<span>Server logs</span> <span>Server logs</span>
<span>{logs.length}</span> <span>{logs.length}</span>
</div> </div>
<div className="mt-1 h-64 overflow-y-auto rounded-sm bg-black/40 p-1 font-mono text-xs"> <div className="surface h-64 overflow-y-auto space-y-0.5 font-mono text-xs">
{logs.length === 0 ? ( {logs.length === 0 ? (
<p>No logs yet.</p> <p>No logs yet.</p>
) : ( ) : (
@@ -16,7 +16,7 @@ export default function LogPanel() {
.slice() .slice()
.reverse() .reverse()
.map((entry) => ( .map((entry) => (
<div key={entry.id} className="mb-1"> <div key={entry.id} className="surface-muted">
<span className="text-amber-400">{entry.timestamp}</span>{' '} <span className="text-amber-400">{entry.timestamp}</span>{' '}
<span className="text-lime-400">[{entry.level}]</span>{' '} <span className="text-lime-400">[{entry.level}]</span>{' '}
{entry.label && <span className="text-teal-400">[{entry.label}]</span>}{' '} {entry.label && <span className="text-teal-400">[{entry.label}]</span>}{' '}
+16 -21
View File
@@ -96,7 +96,7 @@ function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) {
<div <div
ref={containerRef} ref={containerRef}
role="presentation" role="presentation"
className={`relative w-full ${heightClass} select-none overflow-hidden rounded-lg border border-slate-800 bg-slate-900/70`} className={`relative w-full ${heightClass} select-none overflow-hidden bg-zinc-950`}
style={{ touchAction: 'none' }} style={{ touchAction: 'none' }}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
@@ -114,11 +114,11 @@ function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) {
{visual.active && ( {visual.active && (
<> <>
<div <div
className="pointer-events-none absolute h-28 w-28 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-cyan-400/60 bg-cyan-400/10" className="pointer-events-none absolute h-28 w-28 -translate-x-1/2 -translate-y-1/2 bg-cyan-400/10 outline outline-2 outline-cyan-400/60 [clip-path:circle(50%)]"
style={{ left: visual.base.x, top: visual.base.y }} style={{ left: visual.base.x, top: visual.base.y }}
/> />
<div <div
className="pointer-events-none absolute h-12 w-12 -translate-x-1/2 -translate-y-1/2 rounded-full bg-cyan-300/80 shadow-lg" className="pointer-events-none absolute h-12 w-12 -translate-x-1/2 -translate-y-1/2 bg-cyan-300/80 shadow-lg [clip-path:circle(50%)]"
style={{ style={{
left: visual.base.x + visual.knob.x, left: visual.base.x + visual.knob.x,
top: visual.base.y + visual.knob.y, top: visual.base.y + visual.knob.y,
@@ -195,7 +195,7 @@ function MobileJoystickPanel({ layout }) {
}; };
return ( return (
<div className="flex flex-col gap-1 text-slate-100"> <div className="flex flex-col gap-0.5 text-slate-100">
<DriveModeToggle size="compact" /> <DriveModeToggle size="compact" />
<FloatingJoystick <FloatingJoystick
disabled={disabled} disabled={disabled}
@@ -204,17 +204,12 @@ function MobileJoystickPanel({ layout }) {
onMove={handleMove} onMove={handleMove}
onStop={handleStop} onStop={handleStop}
/> />
<button <button type="button" onClick={stopAllMotion} disabled={disabled} className="button-danger w-full disabled:opacity-40">
type="button"
onClick={stopAllMotion}
disabled={disabled}
className="rounded bg-red-600 px-2 py-2 text-xs font-semibold uppercase tracking-wide text-red-100 disabled:opacity-40"
>
Panic Stop Panic Stop
</button> </button>
{cameraEnabled && ( {cameraEnabled && (
<div className="rounded-lg bg-slate-900/70 p-2 text-xs"> <div className="bg-zinc-950 p-0.5 text-xs">
<div className="flex items-center justify-between text-[0.7rem] uppercase tracking-wide text-slate-400"> <div className="flex items-center justify-between text-[0.75rem] text-slate-400">
<span>Camera Tilt</span> <span>Camera Tilt</span>
<span className="font-mono text-slate-200">{cameraValue.toFixed(1)}°</span> <span className="font-mono text-slate-200">{cameraValue.toFixed(1)}°</span>
</div> </div>
@@ -225,7 +220,7 @@ function MobileJoystickPanel({ layout }) {
step={0.5} step={0.5}
value={cameraValue} value={cameraValue}
onChange={handleCameraSlider} onChange={handleCameraSlider}
className="mt-1 w-full accent-cyan-400" className="mt-0.5 w-full accent-cyan-400"
/> />
</div> </div>
)} )}
@@ -269,9 +264,9 @@ function AuxMotorPanel({ orientation }) {
const gridCols = orientation === 'landscape' ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'; const gridCols = orientation === 'landscape' ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2';
return ( return (
<div className="flex flex-col gap-1 text-slate-100"> <div className="flex flex-col gap-0.5 text-slate-100">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Aux controls</p> <p className="text-xs text-slate-400">Aux controls</p>
<div className={`grid ${gridCols} gap-1`}> <div className={`grid ${gridCols} gap-0.5`}>
{AUX_BUTTONS.map((button) => ( {AUX_BUTTONS.map((button) => (
<button <button
key={button.id} key={button.id}
@@ -285,7 +280,7 @@ function AuxMotorPanel({ orientation }) {
onPointerLeave={() => handleRelease(button)} onPointerLeave={() => handleRelease(button)}
onPointerCancel={() => handleRelease(button)} onPointerCancel={() => handleRelease(button)}
onContextMenu={(event) => event.preventDefault()} onContextMenu={(event) => event.preventDefault()}
className={`rounded-lg px-2 py-2 text-left text-sm font-semibold uppercase tracking-wide text-white transition ${button.color} disabled:opacity-30`} className={`px-0.5 py-0.5 text-left text-sm font-semibold text-white transition ${button.color} hover:brightness-110 disabled:opacity-30`}
> >
{button.label} {button.label}
</button> </button>
@@ -297,7 +292,7 @@ function AuxMotorPanel({ orientation }) {
export function MobileLandscapeAuxColumn() { export function MobileLandscapeAuxColumn() {
return ( return (
<div className="flex h-full flex-col gap-1"> <div className="flex h-full flex-col gap-0.5">
<AuxMotorPanel orientation="landscape" /> <AuxMotorPanel orientation="landscape" />
</div> </div>
); );
@@ -305,7 +300,7 @@ export function MobileLandscapeAuxColumn() {
export function MobileLandscapeControlColumn() { export function MobileLandscapeControlColumn() {
return ( return (
<div className="flex h-full flex-col gap-1"> <div className="flex h-full flex-col gap-0.5">
<MobileJoystickPanel layout="landscape" /> <MobileJoystickPanel layout="landscape" />
</div> </div>
); );
@@ -313,8 +308,8 @@ export function MobileLandscapeControlColumn() {
export default function MobilePortraitControls() { export default function MobilePortraitControls() {
return ( return (
<section className="w-full"> <section className="panel">
<div className="grid grid-cols-2 gap-1"> <div className="grid grid-cols-2 gap-0.5">
<AuxMotorPanel orientation="portrait" /> <AuxMotorPanel orientation="portrait" />
<MobileJoystickPanel layout="portrait" /> <MobileJoystickPanel layout="portrait" />
</div> </div>
+4 -4
View File
@@ -33,13 +33,13 @@ export default function ModeGateOverlay() {
const details = getModeDetails(mode); const details = getModeDetails(mode);
return ( return (
<div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black/85 px-2 py-2"> <div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black/85 px-0.5 py-0.5">
<div className="w-full max-w-md space-y-2 rounded-lg bg-[#141821] p-2 text-slate-100 shadow-2xl"> <div className="surface w-full max-w-md space-y-0.5 text-slate-100 shadow-2xl">
<div className="space-y-1"> <div className="space-y-0.5">
<p className="text-lg font-semibold">{details.title}</p> <p className="text-lg font-semibold">{details.title}</p>
<p className="text-sm text-slate-300">{details.description}</p> <p className="text-sm text-slate-300">{details.description}</p>
</div> </div>
<div className="rounded-md bg-black/40 p-1"> <div className="surface-muted">
<AuthPanel /> <AuthPanel />
</div> </div>
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
+6 -6
View File
@@ -8,16 +8,16 @@ import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
function RoomControlsPlaceholder() { function RoomControlsPlaceholder() {
return ( return (
<div className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100"> <div className="panel-section space-y-0.5 text-sm">
<p className="text-xs text-slate-400">Room controls</p> <p className="text-slate-400">Room controls</p>
<p className="text-sm text-slate-200">Coming soon: lighting, docks, and environmental toggles.</p> <p className="text-slate-200">Coming soon: lighting, docks, and environmental toggles.</p>
</div> </div>
); );
} }
export default function RightPaneTabs({ layout }) { export default function RightPaneTabs({ layout }) {
return ( return (
<section className="rounded-sm bg-[#1a1d23] p-1 text-base text-slate-100"> <section className="panel text-base">
<Tabs defaultTab="telemetry"> <Tabs defaultTab="telemetry">
<TabList> <TabList>
<Tab id="telemetry">Rover Controls</Tab> <Tab id="telemetry">Rover Controls</Tab>
@@ -27,14 +27,14 @@ export default function RightPaneTabs({ layout }) {
</TabList> </TabList>
<TabPanels> <TabPanels>
<TabPanel id="telemetry"> <TabPanel id="telemetry">
<div className="space-y-1"> <div className="space-y-0.5">
<DrivePanel /> <DrivePanel />
<CameraServoPanel /> <CameraServoPanel />
<TelemetryPanel /> <TelemetryPanel />
</div> </div>
</TabPanel> </TabPanel>
<TabPanel id="room"> <TabPanel id="room">
<div className="space-y-1"> <div className="space-y-0.5">
<RoomCameraPanel /> <RoomCameraPanel />
<RoomControlsPlaceholder /> <RoomControlsPlaceholder />
</div> </div>
+2 -2
View File
@@ -1,8 +1,8 @@
export default function RoomCameraPanel() { export default function RoomCameraPanel() {
return ( return (
<div className="min-h-[8rem] rounded-sm bg-[#242a32] p-1 text-base text-slate-100"> <div className="panel-section min-h-[8rem] space-y-0.5 text-base">
<p className="text-center text-sm text-slate-400">Room camera</p> <p className="text-center text-sm text-slate-400">Room camera</p>
<p className="mt-1 text-center">Feed placeholder. Wire upcoming room cam here.</p> <p className="text-center text-slate-200">Feed placeholder. Wire upcoming room cam here.</p>
</div> </div>
); );
} }
+2 -2
View File
@@ -5,9 +5,9 @@ export default function SessionSnapshot() {
const { session } = useSession(); const { session } = useSession();
const payload = useMemo(() => JSON.stringify(session ?? {}, null, 2), [session]); const payload = useMemo(() => JSON.stringify(session ?? {}, null, 2), [session]);
return ( return (
<div className="rounded-sm bg-[#242a32] p-1 text-xs text-slate-100"> <div className="panel-section space-y-0.5 text-xs">
<p className="text-sm text-slate-400">Session snapshot</p> <p className="text-sm text-slate-400">Session snapshot</p>
<pre className="mt-1 h-64 overflow-y-auto bg-black/40 p-1 text-[0.7rem] text-lime-300">{payload}</pre> <pre className="surface h-64 overflow-y-auto font-mono text-[0.7rem] text-lime-300">{payload}</pre>
</div> </div>
); );
} }
+12 -12
View File
@@ -42,49 +42,49 @@ export default function SettingsPanel() {
</TabList> </TabList>
<TabPanels> <TabPanels>
<TabPanel id="keybindings"> <TabPanel id="keybindings">
<div className="space-y-1"> <div className="space-y-0.5">
<KeymapSettings /> <KeymapSettings />
</div> </div>
</TabPanel> </TabPanel>
<TabPanel id="controller"> <TabPanel id="controller">
<div className="space-y-1"> <div className="space-y-0.5">
<GamepadMappingSettings /> <GamepadMappingSettings />
</div> </div>
</TabPanel> </TabPanel>
<TabPanel id="admin"> <TabPanel id="admin">
<div className="space-y-1"> <div className="space-y-0.5">
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100"> <section className="panel-section space-y-0.5 text-sm">
<p className="text-xs text-slate-400">Manual OI commands</p> <p className="text-slate-400">Manual OI commands</p>
<div className="mt-1 flex flex-wrap gap-1"> <div className="surface flex flex-wrap gap-0.5">
{manualTabs.map((tab) => ( {manualTabs.map((tab) => (
<button <button
key={tab.key} key={tab.key}
type="button" type="button"
onClick={() => sendOiCommand(tab.key)} onClick={() => sendOiCommand(tab.key)}
disabled={!canControl} disabled={!canControl}
className="rounded-sm bg-black/40 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-30" className="button-dark text-xs disabled:opacity-30"
> >
{tab.label} {tab.label}
</button> </button>
))} ))}
</div> </div>
</section> </section>
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100"> <section className="panel-section space-y-0.5 text-sm">
<p className="text-xs text-slate-400">Sensor stream</p> <p className="text-slate-400">Sensor stream</p>
<div className="mt-1 flex gap-1"> <div className="surface flex gap-0.5">
{sensorButtons.map((btn) => ( {sensorButtons.map((btn) => (
<button <button
key={btn.key} key={btn.key}
type="button" type="button"
onClick={() => handleSensorToggle(btn.enable)} onClick={() => handleSensorToggle(btn.enable)}
disabled={!canControl} disabled={!canControl}
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-30" className="flex-1 button-dark text-xs disabled:opacity-30"
> >
{btn.label} {btn.label}
</button> </button>
))} ))}
</div> </div>
{!canControl && <p className="mt-1 text-xs text-slate-500">Assign a rover to toggle streams.</p>} {!canControl && <p className="text-xs text-slate-500">Assign a rover to toggle streams.</p>}
</section> </section>
<AuthPanel /> <AuthPanel />
<AdminPanel /> <AdminPanel />
+5 -5
View File
@@ -12,9 +12,9 @@ function useTabsContext() {
const TAB_VARIANTS = { const TAB_VARIANTS = {
primary: { primary: {
base: 'flex-1 rounded-sm px-1 py-1 text-xs font-semibold transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-slate-500', base: 'flex-1 px-0.5 py-0.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-slate-500',
active: 'bg-slate-100 text-slate-900', active: 'bg-sky-600 text-white',
inactive: 'bg-black/30 text-slate-300 hover:text-slate-100', inactive: 'bg-zinc-800 text-slate-200 hover:bg-sky-500 hover:text-white',
}, },
}; };
@@ -86,7 +86,7 @@ export default function Tabs({ children, defaultTab, currentTab, onTabChange, va
} }
export function TabList({ children, className = '' }) { export function TabList({ children, className = '' }) {
return <div className={classNames('flex gap-1', className)}>{children}</div>; return <div className={classNames('flex gap-0.5', className)}>{children}</div>;
} }
export function Tab({ id, children, className = '', disabled = false }) { export function Tab({ id, children, className = '', disabled = false }) {
@@ -118,7 +118,7 @@ export function Tab({ id, children, className = '', disabled = false }) {
} }
export function TabPanels({ children, className = '' }) { export function TabPanels({ children, className = '' }) {
return <div className={classNames('mt-1 space-y-1', className)}>{children}</div>; return <div className={classNames('mt-0.5 space-y-0.5', className)}>{children}</div>;
} }
export function TabPanel({ id, children, keepMounted = false }) { export function TabPanel({ id, children, keepMounted = false }) {
+14 -14
View File
@@ -47,7 +47,7 @@ export default function TelemetryPanel() {
} }
return ( return (
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100"> <section className="panel-section space-y-0.5 text-base text-slate-100">
<div className="text-sm text-slate-400"> <div className="text-sm text-slate-400">
<span>{connected ? 'online' : 'offline'}</span> <span>{connected ? 'online' : 'offline'}</span>
<span> · role {session?.role || 'unknown'}</span> <span> · role {session?.role || 'unknown'}</span>
@@ -56,12 +56,12 @@ export default function TelemetryPanel() {
<span> · driver {driverLabel}</span> <span> · driver {driverLabel}</span>
</div> </div>
{!roverId ? ( {!roverId ? (
<p className="mt-1 text-[0.75rem] text-slate-400">Assign a rover to view sensors.</p> <p className="text-sm text-slate-500">Assign a rover to view sensors.</p>
) : !frame ? ( ) : !frame ? (
<p className="mt-1 text-[0.75rem] text-slate-400">Waiting for sensor frames</p> <p className="text-sm text-slate-500">Waiting for sensor frames</p>
) : ( ) : (
<> <>
<div className="flex flex-wrap gap-0.5"> <div className="flex flex-wrap gap-0.5 text-sm">
<Metric label="Charge" value={formatMetric(charge != null && capacity != null ? `${charge}/${capacity}` : null)} /> <Metric label="Charge" value={formatMetric(charge != null && capacity != null ? `${charge}/${capacity}` : null)} />
<Metric label="Charging" value={formatMetric(sensors.chargingState?.label)} /> <Metric label="Charging" value={formatMetric(sensors.chargingState?.label)} />
<Metric label="OI mode" value={formatMetric(sensors.oiMode?.label)} /> <Metric label="OI mode" value={formatMetric(sensors.oiMode?.label)} />
@@ -70,7 +70,7 @@ export default function TelemetryPanel() {
<Metric label="Left Encoder" value={formatMetric(sensors.encoderCountsLeft)} /> <Metric label="Left Encoder" value={formatMetric(sensors.encoderCountsLeft)} />
<Metric label="Right Encoder" value={formatMetric(sensors.encoderCountsRight)} /> <Metric label="Right Encoder" value={formatMetric(sensors.encoderCountsRight)} />
</div> </div>
<div className="mt-1 flex gap-0.5"> <div className="flex gap-0.5">
<CliffBar value={sensors.cliffLeftSignal} /> <CliffBar value={sensors.cliffLeftSignal} />
<CliffBar value={sensors.cliffFrontLeftSignal} /> <CliffBar value={sensors.cliffFrontLeftSignal} />
<CliffBar value={sensors.cliffFrontRightSignal} /> <CliffBar value={sensors.cliffFrontRightSignal} />
@@ -80,24 +80,24 @@ export default function TelemetryPanel() {
)} )}
{rawSnippet && ( {rawSnippet && (
// console.log('rawSnippet:', rawSnippet), // console.log('rawSnippet:', rawSnippet),
<pre className="mt-1 text-wrap break-words p-1 text-xs text-lime-300 bg-gray-700"> <pre className="surface whitespace-pre-wrap break-words text-xs text-lime-300">
{rawSnippet} {rawSnippet}
</pre> </pre>
)} )}
<div className="mt-1"> <div className="space-y-0.5">
<p className="text-sm text-slate-400">Rovers</p> <p className="text-sm text-slate-400">Rovers</p>
{roster.length === 0 ? ( {roster.length === 0 ? (
// if there ARE NO rovers // if there ARE NO rovers
<p className="text-sm text-slate-500">No roster data.</p> <p className="text-sm text-slate-500">No roster data.</p>
) : ( ) : (
// if there ARE rovers // if there ARE rovers
<ul className="mt-1 space-y-1 text-sm"> <ul className="space-y-0.5 text-sm">
{roster.map((rover) => ( {roster.map((rover) => (
<li key={rover.id} className="flex items-center justify-between gap-1"> <li key={rover.id} className="surface flex items-center justify-between gap-0.5">
<div> <div>
<p className="text-slate-200">{rover.name}</p> <p className="text-slate-200">{rover.name}</p>
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
@@ -109,7 +109,7 @@ export default function TelemetryPanel() {
type="button" type="button"
onClick={() => handleRequest(rover.id)} onClick={() => handleRequest(rover.id)}
disabled={pending[rover.id]} disabled={pending[rover.id]}
className="rounded-sm bg-black/40 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-40" className="button-dark disabled:opacity-40"
> >
{pending[rover.id] ? '...' : 'request'} {pending[rover.id] ? '...' : 'request'}
</button> </button>
@@ -130,15 +130,15 @@ function CliffBar({ value }) {
const pct = value == null ? 0 : Math.max(0, Math.min(100, (value / 4095) * 100)); const pct = value == null ? 0 : Math.max(0, Math.min(100, (value / 4095) * 100));
const height = `${pct}%`; const height = `${pct}%`;
return ( return (
<div className="w-1/4 h-6 bg-gray-700 rounded-sm overflow-hidden"> <div className="surface-muted h-6 w-1/4">
<div className="w-full bg-amber-400" style={{ height }} /> <div className="w-full bg-amber-500" style={{ height }} />
</div> </div>
); );
} }
function Metric({ label, value }) { function Metric({ label, value }) {
return ( return (
<div className="bg-gray-700 rounded-sm p-0.5 text-sm"> <div className="surface text-sm">
{label}: {value} {label}: {value}
</div> </div>
); );
+32 -67
View File
@@ -34,6 +34,17 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
restartTimer.current = setTimeout(() => setRestartToken(Date.now()), RESTART_DELAY_MS); restartTimer.current = setTimeout(() => setRestartToken(Date.now()), RESTART_DELAY_MS);
}, []); }, []);
const ensurePlayback = useCallback(async () => {
const video = videoRef.current;
if (!video) return;
try {
video.muted = true;
await video.play();
} catch {
// Autoplay might still be blocked; retry logic elsewhere will handle it.
}
}, []);
const attemptUnmute = useCallback( const attemptUnmute = useCallback(
(delay = 0) => { (delay = 0) => {
if (forceMute) return; if (forceMute) return;
@@ -52,6 +63,7 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
try { try {
await ensurePlayback();
video.muted = false; video.muted = false;
await video.play(); await video.play();
setMuted(false); setMuted(false);
@@ -64,7 +76,7 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
unmuteTimer.current = setTimeout(tryPlay, delay); unmuteTimer.current = setTimeout(tryPlay, delay);
}, },
[forceMute], [ensurePlayback, forceMute],
); );
useEffect( useEffect(
@@ -92,6 +104,9 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
if (!active) return; if (!active) return;
setStatus(nextStatus); setStatus(nextStatus);
setDetail(info || null); setDetail(info || null);
if (nextStatus === 'playing') {
ensurePlayback();
}
if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) { if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) {
scheduleRestart(); scheduleRestart();
} }
@@ -116,7 +131,7 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
clearTimeout(resetMuteId); clearTimeout(resetMuteId);
player?.stop(); player?.stop();
}; };
}, [sessionInfo?.url, sessionInfo?.token, restartToken, scheduleRestart]); }, [sessionInfo?.url, sessionInfo?.token, restartToken, scheduleRestart, ensurePlayback]);
useEffect(() => { useEffect(() => {
if (status === 'stopped' && sessionInfo?.url) { if (status === 'stopped' && sessionInfo?.url) {
@@ -133,8 +148,8 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
: status; : status;
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-0.5">
<div className="relative w-full overflow-hidden rounded-sm bg-black aspect-video"> <div className="relative w-full overflow-hidden bg-black aspect-video">
<video <video
ref={videoRef} ref={videoRef}
muted={forceMute || muted} muted={forceMute || muted}
@@ -157,12 +172,11 @@ function BatteryBar({ charge, config, label, status }) {
const urgent = config?.Urgent ?? null; const urgent = config?.Urgent ?? null;
if (charge == null || full == null || warn == null) { if (charge == null || full == null || warn == null) {
return ( return (
<div className="rounded-sm bg-[#1e1e1e] px-1 py-1 text-sm text-slate-200"> <div className="panel-section space-y-0.5 text-sm">
<div className="flex items-center justify-between text-xs text-slate-400"> <div className="flex items-center justify-between text-xs text-slate-400">
{/* <span>{label}</span> */}
<span>{status}</span> <span>{status}</span>
</div> </div>
<p className="mt-1 text-xs text-slate-400">Battery telemetry unavailable</p> <p className="text-xs text-slate-500">Battery telemetry unavailable</p>
</div> </div>
); );
} }
@@ -177,13 +191,12 @@ function BatteryBar({ charge, config, label, status }) {
const warnTriggered = urgent != null && charge <= urgent; const warnTriggered = urgent != null && charge <= urgent;
const barClass = depleted ? 'bg-red-500 animate-pulse' : warnTriggered ? 'bg-amber-400' : 'bg-emerald-500'; const barClass = depleted ? 'bg-red-500 animate-pulse' : warnTriggered ? 'bg-amber-400' : 'bg-emerald-500';
return ( return (
<div className="space-y-2 rounded-sm bg-[#1e1e1e] px-1 py-1 text-sm text-slate-200"> <div className="panel-section space-y-0.5 text-sm">
<div className="flex items-center justify-between text-xs text-slate-400"> <div className="flex items-center justify-between text-xs text-slate-400">
{/* <span>{label}</span> */}
<span>{status}</span> <span>{status}</span>
</div> </div>
<div className="relative h-4 w-full rounded-full bg-slate-800"> <div className="relative h-4 w-full bg-zinc-900">
<div className={`h-full rounded-full transition-[width] ${barClass}`} style={{ width: `${percentDisplay}%` }} /> <div className={`h-full transition-[width] ${barClass}`} style={{ width: `${percentDisplay}%` }} />
<span className="absolute inset-0 flex items-center justify-center text-xs font-semibold text-black/80"> <span className="absolute inset-0 flex items-center justify-center text-xs font-semibold text-black/80">
Battery {percentText} Battery {percentText}
</span> </span>
@@ -204,73 +217,25 @@ function HudOverlay({ frame, label }) {
const pulse = frame?.receivedAt ? now - frame.receivedAt < 200 : false; const pulse = frame?.receivedAt ? now - frame.receivedAt < 200 : false;
// const bumperBadges = [
// { label: 'B-L', active: bumps.bumpLeft },
// { label: 'B-R', active: bumps.bumpRight },
// ];
// const wheelBadges = [
// { label: 'Drop L', active: bumps.wheelDropLeft },
// { label: 'Drop R', active: bumps.wheelDropRight },
// ];
// console.log('session', session);
return ( return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
{/* <div className="flex items-center justify-between gap-1"> <div className="absolute bottom-0.5 left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 px-0.5 py-0.5 text-xs text-slate-100">
<div className="flex items-center gap-1 text-[0.55rem] text-slate-400">
<span className={`h-2 w-2 rounded-full ${pulse ? 'bg-emerald-300 shadow-[0_0_4px_rgba(16,185,129,0.8)]' : 'bg-slate-700'}`} />
<span>sensor</span>
</div>
<div className="flex gap-1">
{bumperBadges.map((badge) => (
<HudBadge key={badge.label} label={badge.label} active={badge.active} />
))}
</div>
</div>
<div className="flex justify-end gap-1">
{wheelBadges.map((badge) => (
<HudBadge key={badge.label} label={badge.label} active={badge.active} />
))}
</div> */}
{/* status that tells you the name of your rover (label) */}
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-2 rounded-full bg-gray-900/40 px-2 py-0.5 text-[0.65rem] font-medium text-slate-200">
<span>You are driving {label || 'Unnamed Rover'}</span> <span>You are driving {label || 'Unnamed Rover'}</span>
<span>|</span> <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span>
<span>{pulse ? 'Sensors Active' : 'No Recent Sensor Data'}</span>
</div> </div>
{/* bump and wheel drops bar */} {/* bump and wheel drops bar */}
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex gap-1 rounded-full bg-gray-900/40 text-[0.55rem] font-medium"> <div className="absolute top-0.5 left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/70 px-0.5 py-0.5 text-[0.6rem] font-medium text-slate-200">
<div className={`px-0.5 rounded-full ${bumps.bumpLeft ? 'bg-red-500 text-white animate-pulse' : 'bg-black/40 text-slate-500'}`}> <div className={`px-0.5 py-0.5 ${bumps.bumpLeft ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Left bump</div>
Left Bump <div className={`px-0.5 py-0.5 ${bumps.wheelDropLeft ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Left wheel drop</div>
</div> <div className={`px-0.5 py-0.5 ${bumps.wheelDropRight ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Right wheel drop</div>
{/* left wheel drop */} <div className={`px-0.5 py-0.5 ${bumps.bumpRight ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Right bump</div>
<div className={`px-0.5 rounded-full ${bumps.wheelDropLeft ? 'bg-red-500 text-white animate-pulse' : 'bg-black/40 text-slate-500'}`}>
Left Wheel Drop
</div>
{/* right wheel drop */}
<div className={`px-0.5 rounded-full ${bumps.wheelDropRight ? 'bg-red-500 text-white animate-pulse' : 'bg-black/40 text-slate-500'}`}>
Right Wheel Drop
</div>
{/* right bump */}
<div className={`px-0.5 rounded-full ${bumps.bumpRight ? 'bg-red-500 text-white animate-pulse' : 'bg-black/40 text-slate-500'}`}>
Right Bump
</div>
</div> </div>
</div> </div>
); );
} }
// function HudBadge({ label, active }) {
// return (
// <span
// className={`rounded-sm px-1 py-0.5 text-[0.55rem] ${active ? 'bg-emerald-500/30 text-emerald-100' : 'bg-black/40 text-slate-500'}`}
// >
// {label}
// </span>
// );
// }
const OVERCURRENT_LABELS = { const OVERCURRENT_LABELS = {
leftWheel: 'Left wheel', leftWheel: 'Left wheel',
rightWheel: 'Right wheel', rightWheel: 'Right wheel',
@@ -285,7 +250,7 @@ function OvercurrentOverlay({ motors }) {
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-900/60"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-900/60">
<div className="text-center text-2xl font-semibold text-white animate-pulse"> <div className="text-center text-2xl font-semibold text-white animate-pulse">
<div>Overcurrent</div> <div>Overcurrent</div>
<div className="mt-1 text-xl font-medium text-white">{labels.join(', ')}</div> <div className="mt-0.5 text-xl font-medium text-white">{labels.join(', ')}</div>
</div> </div>
</div> </div>
); );
@@ -37,25 +37,21 @@ export default function DriveModeToggle({ size = 'default' }) {
} }
}; };
const pillClass = const pillClass = size === 'compact' ? 'text-xs px-0.5 py-0.5' : 'text-sm px-0.5 py-0.5';
size === 'compact'
? 'text-xs px-1 py-0.5'
: 'text-sm px-1.5 py-0.5';
return ( return (
<div className="rounded-sm bg-black/30 p-1 text-slate-100"> <div className="bg-black/30 p-0.5 text-slate-100">
<div className="flex items-center justify-between"> <div className="flex items-center">
<span className={`rounded-full ${driveReady ? 'bg-emerald-700' : 'bg-indigo-700'} ${pillClass}`}> <span className={`${driveReady ? 'bg-emerald-700' : 'bg-indigo-700'} ${pillClass}`}>
{currentLabel} {currentLabel}
</span> </span>
<span className="text-[0.65rem] text-slate-400">Rover {roverId ?? '—'}</span>
</div> </div>
<div className="mt-1 grid grid-cols-2 gap-1 text-xs"> <div className="mt-0.5 grid grid-cols-2 gap-0.5 text-xs">
<button <button
type="button" type="button"
onClick={handleDrive} onClick={handleDrive}
disabled={disabled} disabled={disabled}
className={`rounded-sm bg-emerald-600 px-1 py-1 font-semibold uppercase tracking-wide text-emerald-50 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`} className={`bg-emerald-600 px-0.5 py-0.5 font-semibold text-emerald-50 transition-colors hover:bg-emerald-500 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`}
> >
Drive Drive
</button> </button>
@@ -63,7 +59,7 @@ export default function DriveModeToggle({ size = 'default' }) {
type="button" type="button"
onClick={handleDock} onClick={handleDock}
disabled={disabled} disabled={disabled}
className={`rounded-sm bg-indigo-600 px-1 py-1 font-semibold uppercase tracking-wide text-indigo-50 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`} className={`bg-indigo-600 px-0.5 py-0.5 font-semibold text-indigo-50 transition-colors hover:bg-indigo-500 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`}
> >
Dock Dock
</button> </button>
+36 -3
View File
@@ -5,12 +5,45 @@
:root { :root {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
sans-serif; sans-serif;
color: #0f172a; @apply bg-black text-slate-100;
background-color: #f8fafc;
} }
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
background-color: #0f172a; @apply bg-black text-slate-100;
}
@layer components {
.panel {
@apply bg-zinc-800 text-slate-100 p-0.5;
}
.panel-section {
@apply bg-zinc-950 text-slate-100 p-0.5;
}
.panel-muted {
@apply text-sm text-slate-400;
}
.surface {
@apply bg-zinc-900/80 text-slate-100 border border-zinc-800 px-0.5 py-0.5;
}
.surface-muted {
@apply bg-zinc-950/80 text-slate-200 border border-zinc-900 px-0.5 py-0.5;
}
.field-input {
@apply bg-zinc-900/80 text-slate-100 border border-zinc-800 px-0.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-slate-500;
}
.button-dark {
@apply px-0.5 py-0.5 text-sm font-medium text-white transition-colors bg-sky-600 hover:bg-sky-500;
}
.button-danger {
@apply px-0.5 py-0.5 text-sm font-medium text-white transition-colors bg-rose-600 hover:bg-rose-500;
}
} }
+1
View File
@@ -47,6 +47,7 @@ export class WhepPlayer {
if (!this.video) return; if (!this.video) return;
this.video.playsInline = true; this.video.playsInline = true;
this.video.autoplay = true; this.video.autoplay = true;
this.video.muted = true;
this.video.disableRemotePlayback = true; this.video.disableRemotePlayback = true;
if ('latencyHint' in HTMLMediaElement.prototype) { if ('latencyHint' in HTMLMediaElement.prototype) {
this.video.latencyHint = 'interactive'; this.video.latencyHint = 'interactive';
+20 -22
View File
@@ -7,28 +7,26 @@ import VideoTile from '../components/VideoTile.jsx';
function SpectatorStatus({ connected, ready, rosterCount }) { function SpectatorStatus({ connected, ready, rosterCount }) {
return ( return (
<div className="flex flex-wrap items-center gap-3 text-sm"> <div className="flex flex-wrap items-center gap-0.5 text-sm">
<span className={`rounded-full px-3 py-1 font-semibold ${connected ? 'bg-emerald-500/20 text-emerald-200' : 'bg-red-500/20 text-red-200'}`}> <span className={`px-0.5 py-0.5 font-semibold ${connected ? 'bg-emerald-500/20 text-emerald-200' : 'bg-red-500/20 text-red-200'}`}>
{connected ? 'Connected' : 'Disconnected'} {connected ? 'Connected' : 'Disconnected'}
</span> </span>
<span className={`rounded-full px-3 py-1 ${ready ? 'bg-blue-600/40 text-blue-100' : 'bg-slate-700 text-slate-200'}`}> <span className={`px-0.5 py-0.5 ${ready ? 'bg-blue-600/40 text-blue-100' : 'bg-slate-700 text-slate-200'}`}>
Spectator mode {ready ? 'active' : 'pending…'} Spectator mode {ready ? 'active' : 'pending…'}
</span> </span>
<span className="rounded-full bg-slate-800 px-3 py-1 text-slate-200"> <span className="bg-slate-800 px-0.5 py-0.5 text-slate-200">Rovers: {rosterCount}</span>
Rovers: {rosterCount}
</span>
</div> </div>
); );
} }
function LogStream({ logs }) { function LogStream({ logs }) {
return ( return (
<section className="rounded-3xl border border-slate-800 bg-slate-900/70 p-4"> <section className="panel space-y-0.5 text-sm">
<header className="mb-3 flex items-center justify-between"> <header className="mb-0.5 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Server logs</h2> <h2 className="text-lg font-semibold text-white">Server logs</h2>
<span className="text-xs uppercase tracking-[0.3em] text-slate-500">latest {logs.length}</span> <span className="text-xs text-slate-500">latest {logs.length}</span>
</header> </header>
<div className="h-60 overflow-y-auto rounded-2xl bg-slate-950/40 p-3 text-xs font-mono text-slate-300"> <div className="surface h-60 overflow-y-auto space-y-0.5 text-xs font-mono text-slate-300">
{logs.length === 0 ? ( {logs.length === 0 ? (
<p>No log entries yet.</p> <p>No log entries yet.</p>
) : ( ) : (
@@ -36,7 +34,7 @@ function LogStream({ logs }) {
.slice() .slice()
.reverse() .reverse()
.map((entry) => ( .map((entry) => (
<div key={entry.id} className="mb-2"> <div key={entry.id} className="surface-muted">
<span className="text-slate-500">{entry.timestamp}</span>{' '} <span className="text-slate-500">{entry.timestamp}</span>{' '}
<span className="text-cyan-300">[{entry.level}]</span>{' '} <span className="text-cyan-300">[{entry.level}]</span>{' '}
{entry.label && <span className="text-pink-300">[{entry.label}]</span>}{' '} {entry.label && <span className="text-pink-300">[{entry.label}]</span>}{' '}
@@ -59,22 +57,22 @@ function formatSensors(sensors = {}) {
function RoverSpectatorCard({ rover, frame, videoInfo }) { function RoverSpectatorCard({ rover, frame, videoInfo }) {
const sensorList = formatSensors(frame?.sensors); const sensorList = formatSensors(frame?.sensors);
return ( return (
<article className="space-y-4 rounded-3xl border border-slate-800 bg-slate-900/70 p-5"> <article className="space-y-0.5 bg-zinc-900 p-0.5">
<header className="flex flex-col gap-1"> <header className="flex flex-col gap-0.5">
<h3 className="text-2xl font-semibold text-white">{rover.name}</h3> <h3 className="text-2xl font-semibold text-white">{rover.name}</h3>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
{frame?.receivedAt ? `Updated ${new Date(frame.receivedAt).toLocaleTimeString()}` : 'Waiting for telemetry…'} {frame?.receivedAt ? `Updated ${new Date(frame.receivedAt).toLocaleTimeString()}` : 'Waiting for telemetry…'}
</p> </p>
</header> </header>
<VideoTile sessionInfo={videoInfo} label={rover.name} forceMute /> <VideoTile sessionInfo={videoInfo} label={rover.name} forceMute />
<section className="rounded-2xl border border-slate-800/80 bg-slate-950/50 p-4"> <section className="surface">
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Telemetry</p> <p className="text-xs text-slate-500">Telemetry</p>
{sensorList.length === 0 ? ( {sensorList.length === 0 ? (
<p className="mt-3 text-sm text-slate-400">No sensor data yet.</p> <p className="mt-0.5 text-sm text-slate-400">No sensor data yet.</p>
) : ( ) : (
<dl className="mt-3 grid gap-2 text-sm text-slate-200"> <dl className="mt-0.5 grid gap-0.5 text-sm text-slate-200">
{sensorList.map(({ key, value }) => ( {sensorList.map(({ key, value }) => (
<div key={key} className="flex justify-between gap-4"> <div key={key} className="flex justify-between gap-0.5">
<dt className="text-slate-500">{key}</dt> <dt className="text-slate-500">{key}</dt>
<dd className="text-right font-semibold text-white">{value}</dd> <dd className="text-right font-semibold text-white">{value}</dd>
</div> </div>
@@ -99,14 +97,14 @@ export default function SpectatorApp() {
); );
return ( return (
<div className="min-h-screen bg-slate-950 text-slate-50"> <div className="min-h-screen bg-black text-slate-100">
<main className="mx-auto flex max-w-7xl flex-col gap-6 px-6 py-10"> <main className="mx-auto flex max-w-7xl flex-col gap-0.5 px-0.5 py-0.5">
<header className="space-y-3"> <header className="space-y-0.5">
<h1 className="text-4xl font-semibold text-white">Spectator Console</h1> <h1 className="text-4xl font-semibold text-white">Spectator Console</h1>
<p className="text-slate-400">Live rover telemetry & logs. This view is read-only.</p> <p className="text-slate-400">Live rover telemetry & logs. This view is read-only.</p>
<SpectatorStatus {...status} /> <SpectatorStatus {...status} />
</header> </header>
<section className="grid gap-6 lg:grid-cols-3"> <section className="grid gap-0.5 lg:grid-cols-3">
{roster.length === 0 ? ( {roster.length === 0 ? (
<p className="col-span-full text-slate-400">No rovers registered.</p> <p className="col-span-full text-slate-400">No rovers registered.</p>
) : ( ) : (