big ui sweep. combined queue and roster.

This commit is contained in:
legop3
2026-01-20 14:25:48 -05:00
parent 850ac3ec15
commit b9a007bd48
39 changed files with 568 additions and 234 deletions
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
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-B5QFIE1I.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BjSf29Wv.css">
<script type="module" crossorigin src="/assets/index-D58yshkA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cbwue-lt.css">
</head>
<body>
<div id="root"></div>
+7 -7
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import TelemetryPanel from './components/TelemetryPanel.jsx';
import ControlSummary, { RoverRosterPanel } from './components/ControlSummary.jsx';
import ReplaySourcesPanel from './components/ReplaySourcesPanel.jsx';
import AlertFeed from './components/AlertFeed.jsx';
import MobileControls, {
@@ -16,7 +15,7 @@ import RightPaneTabs from './components/RightPaneTabs.jsx';
import ModeGateOverlay from './components/ModeGateOverlay.jsx';
import HomeAssistantControls from './components/HomeAssistantControls.jsx';
import TurnAlertListener from './components/TurnAlertListener.jsx';
import UserListPanel from './components/UserListPanel.jsx';
import RawUserPilePanel from './components/RawUserPilePanel.jsx';
import ChatPanel from './components/ChatPanel.jsx';
import FullscreenPrompt from './components/FullscreenPrompt.jsx';
import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js';
@@ -27,6 +26,7 @@ import SettingsPanel from './components/SettingsPanel.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs.jsx';
import useDefaultNickname from './hooks/useDefaultNickname.js';
import CommunityGoalBanner from './components/CommunityGoalBanner.jsx';
import RoverQueuesPanel from './components/RoverQueuesPanel.jsx';
function useLayoutMode() {
const [mode, setMode] = useState(() => {
@@ -61,7 +61,7 @@ function useLayoutMode() {
function DesktopLayout({ layout, onOpenHelpOverlay }) {
return (
<div className="flex h-full gap-0.5 overflow-hidden">
<div className="flex min-w-0 flex-[1.22] flex-col gap-0.5 overflow-y-auto pr-0.5">
<div className="flex min-w-0 flex-[1.22] flex-col gap-0.5 overflow-y-auto pr-0">
<DriverVideoPanel />
<TelemetryPanel />
<LogPanel />
@@ -94,7 +94,7 @@ function MobileFeatureTabs({
<TabPanel id="chat">
<div className="space-y-0.5">
<ChatPanel />
<UserListPanel />
<RawUserPilePanel />
</div>
</TabPanel>
<TabPanel id="roomcontrols">
@@ -126,7 +126,7 @@ function MobilePortraitLayout({ onOpenHelpOverlay }) {
<MobileControls />
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
<RoverRosterPanel />
<RoverQueuesPanel />
</div>
{/* <ControlSummary /> */}
<MobileFeatureTabs
@@ -147,13 +147,13 @@ function MobileLandscapeLayout({ onOpenHelpOverlay }) {
<DriverVideoPanel layoutFormat="mobile-landscape" />
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
<RoverRosterPanel />
<RoverQueuesPanel />
</div>
{/* <TelemetryPanel /> */}
</div>
<MobileLandscapeControlColumn />
</section>
<div className="flex flex-col gap-0.5 pb-0.5">
<div className="flex flex-col gap-0.5 pb-0">
<MobileFeatureTabs
layout="mobile-landscape"
onOpenHelpOverlay={onOpenHelpOverlay}
+3 -3
View File
@@ -167,7 +167,7 @@ function ReplaySnapshotHealth({ health }) {
</span>
</div>
</div>
<div className="space-y-0.25 text-xs text-slate-300">
<div className="space-y-0.5 text-xs text-slate-300">
{replay.sources.map((source) => (
<div key={`${source.type}:${source.id}`} className="flex items-center justify-between">
<span>{source.label}</span>
@@ -177,7 +177,7 @@ function ReplaySnapshotHealth({ health }) {
</div>
))}
</div>
<div className="space-y-0.25 text-xs text-slate-300">
<div className="space-y-0.5 text-xs text-slate-300">
{snapshots.rovers.map((entry) => (
<div key={`rover:${entry.id}`} className="flex items-center justify-between">
<span>{entry.name}</span>
@@ -187,7 +187,7 @@ function ReplaySnapshotHealth({ health }) {
</div>
))}
</div>
<div className="space-y-0.25 text-xs text-slate-300">
<div className="space-y-0.5 text-xs text-slate-300">
{snapshots.rooms.map((entry) => (
<div key={`room:${entry.id}`} className="flex items-center justify-between">
<span>{entry.name}</span>
+1 -1
View File
@@ -99,7 +99,7 @@ export default function CameraServoPanel() {
onTouchEnd={commitSlider}
onPointerUp={commitSlider}
/>
<div className="mt-0.5 flex justify-between text-xs text-slate-400">
<div className="mt-0 flex justify-between text-xs text-slate-400">
<span>{formatDegrees(min)}</span>
<span>{formatDegrees(max)}</span>
</div>
+1 -1
View File
@@ -66,7 +66,7 @@ export default function ChatMessageRow({ message }) {
return (
<div
className={`surface-muted relative flex flex-wrap items-start gap-1 text-sm ${
className={`surface-muted relative flex flex-wrap items-start gap-0.5 text-sm ${
isAdmin
? 'border border-amber-400/30'
: message.fromDiscord
+4 -4
View File
@@ -85,7 +85,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
return (
<section className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full flex-col overflow-hidden' : ''}`}>
<div className={`surface overflow-y-auto space-y-0.25 ${listClass}`} ref={listRef}>
<div className={`surface overflow-y-auto space-y-0.5 px-0 ${listClass}`} ref={listRef}>
{sorted.length === 0 ? (
<p className="text-sm text-slate-500">No messages yet.</p>
) : (
@@ -93,7 +93,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
)}
</div>
{!hideInput && (
<form className="flex flex-wrap items-start gap-0.5" onSubmit={handleSend}>
<form className="flex flex-wrap items-stretch gap-0.5" onSubmit={handleSend}>
<input
className="field-input flex-1 min-w-[10rem]"
value={draft}
@@ -112,7 +112,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
/>
{ttsSupported && (
<div className="flex flex-wrap items-center gap-0.5 basis-full sm:basis-auto">
<label className="flex items-center gap-0.25 text-xs text-slate-300">
<label className="flex items-center gap-0.5 text-xs text-slate-300">
<input
type="checkbox"
checked={speak}
@@ -171,7 +171,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
<button
type="submit"
disabled={!canChat || sending}
className="button-dark disabled:opacity-50 self-start"
className="button-dark h-full disabled:opacity-50 self-stretch"
>
{sending ? '...' : 'Send'}
</button>
+5 -5
View File
@@ -56,13 +56,13 @@ export default function ControlSummary() {
return (
<section className="panel-section">
<div className="grid items-stretch gap-1 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] md:min-h-[18rem]">
<div className="grid items-stretch gap-0.5 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] md:min-h-[18rem]">
<div className="flex h-full w-full items-stretch justify-center">
<div className="aspect-square h-full w-full">
<TopDownMap sensors={sensors} />
</div>
</div>
<div className="grid h-full grid-rows-[1fr_1fr_auto] gap-0.75">
<div className="grid h-full grid-rows-[1fr_1fr_auto] gap-0.5">
<div className="row-span-2">
<DriveDockAction layout="desktop" expand driveDockState={driveDockState} />
</div>
@@ -134,12 +134,12 @@ export function InlineCameraTilt({ keymap }) {
onTouchEnd={commitSlider}
onPointerUp={commitSlider}
/>
<div className="mt-0.25 flex items-center justify-between text-[0.7rem] text-slate-400">
<span className="flex items-center gap-0.25">
<div className="mt-0 flex items-center justify-between text-[0.7rem] text-slate-400">
<span className="flex items-center gap-0.5">
{downLabel ? <KeyPill label={downLabel} /> : null}
{formatDegrees(min)}
</span>
<span className="flex items-center gap-0.25">
<span className="flex items-center gap-0.5">
{formatDegrees(max)}
{upLabel ? <KeyPill label={upLabel} /> : null}
</span>
+3 -3
View File
@@ -12,12 +12,12 @@ export default function DiscordInviteButton({ text = "Join our Discord!", classN
href={discordInvite}
target="_blank"
rel="noopener noreferrer"
className={`inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white rainbow-animate-bg transition justify-center ${className}`}
className={`inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white rainbow-animate-bg transition justify-center gap-1 ${className}`}
// animated rainbow backgound
// className="inline-flex items-center px-3 py-2 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white rounded hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 transition"
>
<FaDiscord className="mr-2" />
<FaDiscord className="mr-0" />
{text}
</a>
);
}
}
+5 -5
View File
@@ -58,7 +58,7 @@ function DockModal({ instructions, onConfirm, onCancel, pending }) {
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 p-1">
<div className="surface w-full max-w-md space-y-0.5 border border-indigo-700 bg-indigo-950/90 p-1 text-slate-100 shadow-2xl">
<div className="space-y-0.25">
<div className="space-y-0.5">
<p className="text-lg font-semibold text-indigo-50">Dock Rover</p>
<p className="text-sm text-slate-200">{instructions.summary}</p>
<StepList steps={instructions.steps} tone="indigo" />
@@ -186,7 +186,7 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, dr
disabled={driveDisabled}
className={`${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${emeraldCta}`}
>
<div className="space-y-0.25 w-full">
<div className="space-y-0.5 w-full">
<div className="flex flex-wrap items-center justify-center gap-0.5">
<span className="text-base font-semibold text-emerald-50">Start Driving</span>
{!isMobile && driveKeyLabel ? <KeyPill label={driveKeyLabel} /> : null}
@@ -217,7 +217,7 @@ export default function DriveDockAction({ layout = 'desktop', expand = false, dr
onClick={handleReturnToDrive}
className={`${baseCardClasses} ${filledHeight} ${ctaTextAndLayout} ${ctaSize} ${amberCta}`}
>
<div className="space-y-0.25 w-full">
<div className="space-y-0.5 w-full">
<div className="flex flex-wrap items-center justify-center gap-0.5">
<span className="text-base font-semibold text-amber-50">Docking in Progress</span>
{!isMobile ? <ActionPill label="Click to return to driving mode" tone="amber" /> : null}
@@ -299,10 +299,10 @@ function StepList({ steps, tone = 'emerald' }) {
{steps.map((step, idx) => (
<div key={step} className="text-[0.85rem] leading-snug break-words">
<div className="flex items-start">
<span className={`mr-0.35 align-top text-[0.75rem] font-semibold ${numberColor}`}>{idx + 1}.</span>
<span className={`mr-0 align-top text-[0.75rem] font-semibold ${numberColor}`}>{idx + 1}.</span>
<span className="align-top">{step}</span>
</div>
{idx < steps.length - 1 ? <div className="mt-0.35 h-px bg-white/10" /> : null}
{idx < steps.length - 1 ? <div className="mt-0 h-px bg-white/10" /> : null}
</div>
))}
</div>
+2 -2
View File
@@ -75,7 +75,7 @@ function ActionCard({ title, description, statuses, tone, onClick, disabled, foo
<p className="text-base font-semibold">{title}</p>
<p className="text-sm text-white/90">{description}</p>
{/* center statuses in button */}
<div className="mt-0.5 flex flex-wrap gap-0.5 items-center w-full justify-center">
<div className="mt-0 flex flex-wrap gap-0.5 items-center w-full justify-center">
{statuses.map((status) => (
<span
key={status.label}
@@ -87,7 +87,7 @@ function ActionCard({ title, description, statuses, tone, onClick, disabled, foo
</span>
))}
</div>
{footnote && <p className="mt-0.5 text-xs text-emerald-50/80">{footnote}</p>}
{footnote && <p className="mt-0 text-xs text-emerald-50/80">{footnote}</p>}
</button>
);
}
+1 -1
View File
@@ -134,7 +134,7 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-[4/3]">
<p>You are not assigned to a rover.</p>
{/* colored button to visit the spectator page */}
<p className="mt-2">
<p className="mt-0">
<a href="/spectate" className="text-blue-400 underline hover:text-blue-500">
Click here to visit the spectator page.
</a>
+2 -2
View File
@@ -5,7 +5,7 @@ export default function FullscreenPrompt({ visible, mode, onEnterFullscreen, onD
return (
<div className="fixed inset-0 z-40 flex items-end justify-center p-2 pointer-events-none sm:items-center">
<div className="pointer-events-auto w-full max-w-sm rounded-lg border border-cyan-500/40 bg-zinc-950/95 shadow-xl">
<div className="space-y-2 p-4 text-sm text-slate-100">
<div className="space-y-0.5 p-4 text-sm text-slate-100">
<h2 className="text-base font-semibold text-white">Better in fullscreen</h2>
{isIOSMode ? (
<p className="text-slate-300">
@@ -18,7 +18,7 @@ export default function FullscreenPrompt({ visible, mode, onEnterFullscreen, onD
via the system back or home gesture.
</p>
)}
<div className="flex justify-end gap-2 pt-1 text-sm">
<div className="flex justify-end gap-0.5 pt-1 text-sm">
<button type="button" className="rounded border border-slate-600 px-3 py-1 text-slate-200" onClick={onDismiss}>
{isIOSMode ? 'Got it' : 'Not now'}
</button>
@@ -55,7 +55,7 @@ function SliderField({ label, description, min, max, step, value, onChange }) {
step={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
className="mt-0.5 w-full accent-emerald-400"
className="mt-0 w-full accent-emerald-400"
/>
</label>
);
@@ -187,7 +187,7 @@ export default function GamepadMappingSettings() {
: 'Connect a controller to configure.'}
</p>
{capture && (
<p className="mt-0.5 text-[0.7rem] text-emerald-400">Capturing {capture.label}</p>
<p className="mt-0 text-[0.7rem] text-emerald-400">Capturing {capture.label}</p>
)}
</div>
<button type="button" onClick={() => reset()} className="button-dark text-xs">
+13 -13
View File
@@ -40,14 +40,14 @@ function renderLine(line, keymap, idx) {
function Hero({ hero, keymap }) {
if (!hero) return null;
return (
<div className="surface space-y-0.25 px-0.5 py-0.5">
<div className="surface space-y-0.5 px-0.5 py-0.5">
<div className="flex flex-wrap items-center justify-between gap-0.5">
<div>
<p className="text-sm font-semibold text-white">{hero.title}</p>
{hero.subtitle && <p className="text-xs text-slate-300">{hero.subtitle}</p>}
</div>
{hero.chips && (
<div className="flex flex-wrap gap-0.25 text-[0.7rem] text-slate-200">
<div className="flex flex-wrap gap-0.5 text-[0.7rem] text-slate-200">
{hero.chips.map((chip) => (
<span key={chip} className="rounded border border-slate-700 px-1 py-[2px]">
{chip}
@@ -57,7 +57,7 @@ function Hero({ hero, keymap }) {
)}
</div>
{hero.bullets && (
<ul className="space-y-0.25 text-[0.85rem] text-slate-200">
<ul className="space-y-0.5 text-[0.85rem] text-slate-200">
{hero.bullets.map((line, idx) => {
const key = Array.isArray(line?.segments) ? `hero-${idx}` : `hero-${idx}`;
return (
@@ -74,13 +74,13 @@ function Hero({ hero, keymap }) {
function ListBlock({ block, keymap }) {
return (
<div className="space-y-0.25">
<div className="space-y-0.5">
<p className="text-xs font-semibold text-slate-200">{block.title}</p>
<ul className="space-y-0.25 text-[0.8rem] text-slate-300">
<ul className="space-y-0.5 text-[0.8rem] text-slate-300">
{block.items.map((item, idx) => {
const key = `item-${idx}`;
return (
<li key={key} className="surface-muted flex flex-wrap items-center gap-0.25 px-0.5 py-0.25">
<li key={key} className="surface-muted flex flex-wrap items-center gap-0.5 px-0.5 py-0.25">
{renderLine(item, keymap, idx)}
</li>
);
@@ -93,10 +93,10 @@ function ListBlock({ block, keymap }) {
function CalloutBlock({ block }) {
const toneClass = block.tone === 'info' ? 'border-cyan-500/40' : 'border-slate-700';
return (
<div className={`surface space-y-0.25 border ${toneClass} px-0.5 py-0.5`}>
<div className={`surface space-y-0.5 border ${toneClass} px-0.5 py-0.5`}>
<p className="text-xs font-semibold text-slate-100">{block.title}</p>
{block.body && (
<ul className="space-y-0.25 text-[0.8rem] text-slate-300">
<ul className="space-y-0.5 text-[0.8rem] text-slate-300">
{block.body.map((line, idx) => (
<li key={`callout-${idx}`} className="surface-muted px-0.5 py-0.25">
{renderLine(line, {})}
@@ -110,9 +110,9 @@ function CalloutBlock({ block }) {
function KeyboardGroup({ group, keymap }) {
return (
<div className="space-y-0.25 surface">
<div className="space-y-0.5 surface">
<p className="px-0.5 py-0.25 text-[0.75rem] font-semibold text-slate-200">{group.title}</p>
<div className="space-y-0.25 px-0.5 pb-0.25">
<div className="space-y-0.5 px-0.5 pb-0.25">
{group.items.map((item) => (
<div key={item.action} className="surface-muted flex items-center justify-between gap-0.5 px-0.5 py-0.25 text-[0.8rem]">
<span className="text-slate-200">{item.label}</span>
@@ -127,7 +127,7 @@ function KeyboardGroup({ group, keymap }) {
function KeyboardBlock({ block, keymap }) {
if (!block) return null;
return (
<div className="space-y-0.25">
<div className="space-y-0.5">
<div className="flex items-center justify-between text-xs text-slate-200">
<span className="font-semibold">{block.title}</span>
{block.footnote && <span className="text-[0.7rem] text-slate-400">{block.footnote}</span>}
@@ -144,9 +144,9 @@ function KeyboardBlock({ block, keymap }) {
function GamepadBlock({ block }) {
if (!block) return null;
return (
<div className="space-y-0.25">
<div className="space-y-0.5">
<p className="text-xs font-semibold text-slate-200">{block.title}</p>
<ul className="space-y-0.25 text-[0.8rem] text-slate-300">
<ul className="space-y-0.5 text-[0.8rem] text-slate-300">
{block.items?.map((line, idx) => (
<li key={`gamepad-${idx}`} className="surface-muted px-0.5 py-0.25">
{renderLine(line, {})}
+1 -1
View File
@@ -17,7 +17,7 @@ export default function HelpOverlay({ visible, layout, onClose, showOnLoad, onTo
<div className="flex items-center justify-between border-b border-slate-700 px-0.5 py-0.25 text-sm text-slate-200">
<span className="font-semibold">Help & controls</span>
<div className="flex items-center gap-0.5 text-[0.8rem] text-slate-300">
<label className="flex items-center gap-0.25">
<label className="flex items-center gap-0.5">
<input
type="checkbox"
checked={!showOnLoad}
@@ -34,14 +34,14 @@ function EntityRow({ entity, connected, onToggle }) {
type="button"
onClick={() => onToggle(entity.id)}
disabled={disableToggle}
className={`flex min-w-[10rem] flex-1 items-center justify-between gap-1 rounded px-1 py-0.5 text-left transition-colors ${toneStyles} disabled:opacity-60 disabled:hover:bg-inherit`}
className={`flex min-w-[10rem] flex-1 items-center justify-between gap-0.5 rounded px-1 py-0.5 text-left transition-colors ${toneStyles} disabled:opacity-60 disabled:hover:bg-inherit`}
>
<div className="min-w-0">
<div className="flex items-center gap-1 text-sm">
<div className="flex items-center gap-0.5 text-sm">
<span className="truncate font-semibold text-white">{entity.name || entity.id}</span>
{/* <StatusBadge label={entity.type === 'light' ? 'Light' : 'Switch'} /> */}
</div>
<div className="flex items-center gap-1 text-xs text-slate-400">
<div className="flex items-center gap-0.5 text-xs text-slate-400">
<StatusBadge label={statusLabel} tone={statusTone} />
{!connected && <span className="text-amber-200"> · Offline</span>}
</div>
@@ -84,10 +84,10 @@ export default function HomeAssistantControls() {
return (
<section className="panel-section space-y-0.5 text-base">
<header className="flex items-center justify-between gap-0.5 text-sm text-slate-400">
<div className="flex items-center gap-1">
<div className="flex items-center gap-0.5">
<p>Light Controls</p>
<span className="text-xs text-slate-500">{entities.length}</span>
<div className="flex items-center gap-1 text-xs text-slate-300 background-black">
<div className="flex items-center gap-0.5 text-xs text-slate-300 background-black">
<span className="flex items-center gap-0.5">
<span>On</span>
{onKeyLabel ? <KeyPill label={onKeyLabel} /> : null}
+1 -1
View File
@@ -115,7 +115,7 @@ function SpeedField({ label, description, value, onChange, min = 0, max = 500, s
/>
</div>
{description && <p className="text-[0.65rem] text-slate-500">{description}</p>}
<div className="mt-0.5 flex items-center gap-0.5">
<div className="mt-0 flex items-center gap-0.5">
<input
type="range"
min={min}
+2 -2
View File
@@ -12,9 +12,9 @@ export default function KoFiButton({ text = "Support me on Ko-fi!", className =
href={kofiLink}
target="_blank"
rel="noopener noreferrer"
className={`inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white kofi-animate-bg transition justify-center ${className}`}
className={`inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white kofi-animate-bg transition justify-center gap-1 ${className}`}
>
<FaCoffee className="mr-1" />
<FaCoffee className="mr-0" />
{text}
</a>
);
+1 -1
View File
@@ -229,7 +229,7 @@ function MobileJoystickPanel({ layout }) {
step={0.5}
value={cameraValue}
onChange={handleCameraSlider}
className="mt-0.5 w-full accent-cyan-400"
className="mt-0 w-full accent-cyan-400"
/>
</div>
)}
+2 -2
View File
@@ -31,7 +31,7 @@ export default function NicknameForm({ compact = false }) {
}
return (
<form className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-0.5" onSubmit={handleSave}>
<form className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-stretch gap-0.5" onSubmit={handleSave}>
<input
className="field-input flex-1 min-w-0"
value={nicknameInput}
@@ -43,7 +43,7 @@ export default function NicknameForm({ compact = false }) {
<button
type="submit"
disabled={!canSetNickname || saving}
className="button-dark shrink-0 whitespace-nowrap disabled:opacity-50"
className="button-dark h-full shrink-0 whitespace-nowrap px-0.5 py-0 disabled:opacity-50"
>
{saving ? 'Saving…' : compact ? 'Set' : 'Save'}
</button>
+135
View File
@@ -0,0 +1,135 @@
import { useEffect, useMemo, useRef } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
import { useSocket } from '../context/SocketContext.jsx';
import NicknameForm from './NicknameForm.jsx';
import DiscordInviteButton from './DiscordInviteButton.jsx';
import KoFiButton from './KoFiButton.jsx';
function roleColors(role) {
switch (role) {
case 'admin':
case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300';
case 'spectator':
return 'text-slate-400';
default:
return 'text-sky-300';
}
}
function formatLabel(user, selfId) {
if (!user) return '';
const base = user.nickname || user.socketId?.slice(0, 6) || 'unknown';
if (user.socketId && user.socketId === selfId) {
return `${base} (you)`;
}
return base;
}
export default function RawUserPilePanel({
hideNicknameForm = false,
hideHeader = false,
className = '',
fillHeight = false,
compact = false,
}) {
const { session, setNickname } = useSession();
const { value } = useSettingsNamespace('profile', { nickname: '' });
const lastSyncedSocketRef = useRef(null);
const socket = useSocket();
const canSetNickname = session?.role !== 'spectator';
const users = session?.users ?? [];
const selfId = session?.socketId || null;
useEffect(() => {
if (!canSetNickname) return;
if (!session?.socketId) return;
const nicknameInput = value.nickname || '';
if (!nicknameInput) return;
if (session.socketId === lastSyncedSocketRef.current) return;
const currentId = session.socketId;
setNickname(nicknameInput).then(() => {
lastSyncedSocketRef.current = currentId;
}).catch(() => {});
}, [canSetNickname, session?.socketId, setNickname, value.nickname]);
useEffect(() => {
if (!socket) return undefined;
const handleConnect = () => {
if (!canSetNickname) return;
const nick = (value.nickname || '').trim();
if (!nick) return;
setNickname(nick).then(() => {
lastSyncedSocketRef.current = session?.socketId || null;
}).catch(() => {});
};
socket.on('connect', handleConnect);
return () => socket.off('connect', handleConnect);
}, [canSetNickname, setNickname, socket, value.nickname, session?.socketId]);
const sorted = useMemo(
() =>
[...users].sort((a, b) => {
if (a.socketId === selfId) return -1;
if (b.socketId === selfId) return 1;
return (a.nickname || '').localeCompare(b.nickname || '');
}),
[selfId, users],
);
const baseListClass = fillHeight
? 'flex-1 min-h-0 overflow-y-auto'
: compact
? 'h-28 overflow-y-auto'
: 'h-48 overflow-y-auto';
return (
<section
className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full min-h-0 flex-col overflow-hidden' : ''} ${className}`}
>
{!hideNicknameForm && (
<div className="space-y-0.5">
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
<div className="min-w-0">
<div className="surface flex w-full items-center px-0 py-0">
<NicknameForm compact={compact} />
</div>
</div>
<div className="grid gap-0.5 sm:grid-cols-2 md:grid-cols-1">
<DiscordInviteButton />
<KoFiButton />
</div>
</div>
{!canSetNickname && <p className="text-xs text-slate-500">Spectators cannot set nicknames.</p>}
</div>
)}
<div className={`space-y-0.5 ${fillHeight ? 'flex flex-1 min-h-0 flex-col' : ''}`}>
{!hideHeader && (
<div className={`flex items-center justify-between text-sm text-slate-400 ${compact ? 'text-xs' : ''}`}>
<span>Users</span>
<span className="text-xs text-slate-500">{sorted.length}</span>
</div>
)}
<div
className={`surface flex flex-wrap content-start items-start gap-0.5 px-0 pb-0 ${baseListClass} ${compact ? 'text-[0.8rem]' : ''}`}
>
{sorted.length === 0 ? (
<p className="text-sm text-slate-500">Waiting for users</p>
) : (
sorted.map((user) => (
<span
key={user.socketId}
className={`rounded bg-slate-800/80 px-1 py-0.25 text-[0.7rem] ${roleColors(user.role)}`}
>
{formatLabel(user, selfId)}
</span>
))
)}
</div>
</div>
</section>
);
}
+3 -3
View File
@@ -113,7 +113,7 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
<GroupList title="Rovers" items={grouped.rovers} selected={selected} onToggle={toggleKey} />
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
</div>
<div className="space-y-0.25">
<div className="space-y-0.5">
<button
type="button"
className="button-dark w-full text-xs disabled:opacity-40"
@@ -132,9 +132,9 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
function GroupList({ title, items, selected, onToggle }) {
if (!items.length) return null;
return (
<div className="space-y-0.25">
<div className="space-y-0.5">
<div className="panel-muted text-xs">{title}</div>
<div className="space-y-0.25">
<div className="space-y-0.5">
{items.map((item) => (
<label key={item.key} className="surface flex items-center gap-0.5 text-xs">
<input
+8 -6
View File
@@ -1,16 +1,18 @@
import { InlineCameraTilt, RoverRosterPanel } from './ControlSummary.jsx';
import { InlineCameraTilt } from './ControlSummary.jsx';
import RoomCameraPanel from './RoomCameraPanel.jsx';
import HomeAssistantControls from './HomeAssistantControls.jsx';
import SettingsPanel from './SettingsPanel.jsx';
import HelpPanel from './HelpPanel.jsx';
import ChatPanel from './ChatPanel.jsx';
import UserListPanel, { LinkButtonsPanel, NicknameEntryPanel } from './UserListPanel.jsx';
import { LinkButtonsPanel, NicknameEntryPanel } from './UserListPanel.jsx';
import ReplaySourcesPanel from './ReplaySourcesPanel.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
import TopDownMap from './TopDownMap.jsx';
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
import { useControlSystem } from '../controls/index.js';
import RoverQueuesPanel from './RoverQueuesPanel.jsx';
import RawUserPilePanel from './RawUserPilePanel.jsx';
function TopDownMapPanel() {
const {
@@ -60,15 +62,15 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
<DriveDockPanel />
</div>
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)_minmax(0,0.75fr)]">
<RoverRosterPanel />
<RoverQueuesPanel />
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
<LinkButtonsPanel />
</div>
<div className="grid items-stretch gap-0.5 grid-cols-[minmax(0,1.3fr)_minmax(0,0.7fr)] h-[14rem]">
<div className="grid items-stretch gap-0.5 grid-cols-[minmax(0,1.3fr)_minmax(0,0.22fr)] h-[14rem]">
<ChatPanel fillHeight />
<div className="grid min-h-0 gap-0.5 grid-rows-[auto_minmax(0,1fr)]">
<div className="grid min-h-0 gap-0.5 grid-rows-[minmax(0,1fr)_auto]">
<RawUserPilePanel compact hideNicknameForm fillHeight />
<NicknameEntryPanel compact />
<UserListPanel compact hideNicknameForm fillHeight />
</div>
</div>
<HomeAssistantControls />
+1 -1
View File
@@ -25,7 +25,7 @@ export default function RoomCameraFeed({ feed, label }) {
<div className="pointer-events-none absolute left-0 top-0 bg-black/70 px-0.5 py-0.5 text-xs font-semibold text-white">
{label}
</div>
<div className="pointer-events-none absolute bottom-0 left-0 m-0.5 flex items-center gap-1 rounded bg-black/70 px-0.5 py-0.25 text-[0.7rem] text-slate-100">
<div className="pointer-events-none absolute bottom-0 left-0 m-0 flex items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25 text-[0.7rem] text-slate-100">
<span className={`h-2 w-2 rounded-full ${blink ? 'bg-emerald-400' : 'bg-slate-500'}`} />
<span>{statusText}</span>
</div>
+2 -2
View File
@@ -68,11 +68,11 @@ export default function RoomCameraPanel({
<section className="panel-section space-y-0.5 text-base">
{!hideHeader && (
<header className="flex flex-wrap items-center justify-between gap-0.5 text-sm text-slate-400">
<div className="flex items-center gap-1">
<div className="flex items-center gap-0.5">
<p>Room cameras</p>
<span className="text-xs text-slate-500">{cameras.length}</span>
</div>
<div className="flex flex-wrap items-center gap-1 text-xs">
<div className="flex flex-wrap items-center gap-0.5 text-xs">
{showLayoutToggle && (
<div className="flex items-center gap-0.5">
<span className="text-slate-500">Layout:</span>
+197
View File
@@ -0,0 +1,197 @@
import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
function classNames(...values) {
return values.filter(Boolean).join(' ');
}
function formatBattery(rover) {
const percent = rover?.batteryState?.percentDisplay;
if (percent == null) return '--';
return `${percent}%`;
}
function batteryClass(rover) {
if (!rover?.batteryState) return 'text-slate-400';
if (rover.batteryState.urgentActive) return 'text-red-400';
if (rover.batteryState.warnActive) return 'text-amber-300';
return 'text-emerald-300';
}
function roleColors(role) {
switch (role) {
case 'admin':
case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300';
case 'spectator':
return 'text-slate-400';
default:
return 'text-sky-300';
}
}
function formatLabel(user, selfId) {
if (!user) return '';
const base = user.nickname || user.socketId?.slice(0, 6) || 'unknown';
if (user.socketId && user.socketId === selfId) {
return `${base} (you)`;
}
return base;
}
export default function RoverQueuesPanel({ title = 'Rovers' }) {
const { session, requestControl } = useSession();
const [pending, setPending] = useState({});
const [now, setNow] = useState(() => Date.now());
const canRequest = useMemo(() => session?.role && session.role !== 'spectator', [session?.role]);
const roster = session?.roster ?? [];
const turnQueues = session?.turnQueues ?? {};
const users = session?.users ?? [];
const selfId = session?.socketId || null;
const hasDeadlines = useMemo(
() => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline),
[turnQueues],
);
useEffect(() => {
if (!hasDeadlines) return undefined;
const timer = setInterval(() => {
setNow(Date.now());
}, 1000);
return () => clearInterval(timer);
}, [hasDeadlines]);
const rosterItems = useMemo(() => {
const known = new Set(roster.map((rover) => String(rover.id)));
const extra = Object.keys(turnQueues || {})
.filter((id) => !known.has(String(id)))
.map((id) => ({ id, name: id, locked: false, batteryState: null }));
return [...roster, ...extra];
}, [roster, turnQueues]);
async function handleRequest(targetRoverId) {
if (!targetRoverId) return;
setPending((prev) => ({ ...prev, [targetRoverId]: true }));
try {
await requestControl(targetRoverId);
} catch (err) {
alert(err.message);
} finally {
setPending((prev) => ({ ...prev, [targetRoverId]: false }));
}
}
const lookupUser = (socketId) =>
users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null };
return (
<div className="space-y-0.5 text-sm">
{title && <p className="text-sm text-slate-400">{title}</p>}
{rosterItems.length === 0 ? (
<p className="text-sm text-slate-500">No rovers registered.</p>
) : (
<ul className="space-y-0.5 text-sm">
{rosterItems.map((rover) => {
const roverId = String(rover.id);
const info = turnQueues?.[roverId] || null;
const queue = info?.queue || [];
const deadline = info?.idleDeadline || info?.deadline || null;
const remainingSeconds =
deadline && deadline > now ? Math.ceil((deadline - now) / 1000) : deadline ? 0 : null;
const currentId = info?.current || null;
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
const nextId =
queue.length > 1
? currentIdx >= 0
? queue[(currentIdx + 1) % queue.length]
: queue[0]
: null;
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
const showTimer = remainingSeconds != null && (isSelfCurrent || isSelfNext);
const locked = Boolean(rover.locked);
const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked';
const buttonLabel = locked ? lockLabel : pending[roverId] ? '...' : 'request';
const canClickRow = canRequest && !locked && !pending[roverId];
return (
<li
key={rover.id}
className={classNames(
'surface flex flex-wrap items-start justify-between gap-0.5',
canClickRow && 'cursor-pointer',
locked && 'bg-red-900/40',
)}
onClick={() => {
if (!canClickRow) return;
handleRequest(rover.id);
}}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center justify-between gap-0.5">
<div className="flex items-center gap-0.5">
<p className="text-slate-200">{rover.name}</p>
{showTimer ? (
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-200">
{isSelfCurrent ? `${remainingSeconds}s left` : `Your turn in ${remainingSeconds}s`}
</span>
) : null}
</div>
<span className={classNames('text-[0.75rem] font-semibold', batteryClass(rover))}>
{formatBattery(rover)}
</span>
</div>
{queue.length === 0 ? (
<p className="text-[0.7rem] text-slate-500">No queue.</p>
) : (
<div className="flex flex-wrap items-center gap-0.5">
{queue.map((socketId, idx) => {
const user = lookupUser(socketId);
const isCurrent = socketId === currentId;
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
const highlightClass = isCurrent
? 'bg-sky-600 text-white ring-2 ring-amber-300'
: isNext
? 'bg-emerald-700/60 text-emerald-100 ring-1 ring-emerald-300/70'
: 'bg-slate-800 text-slate-200';
return (
<span
key={`${roverId}-${socketId}-${idx}`}
className={`flex items-center gap-0.5 rounded px-1 text-[0.7rem] ${highlightClass}`}
>
<span className={`${roleColors(user.role)} font-semibold`}>
{formatLabel(user, selfId)}
</span>
{isCurrent && <span className="text-[0.65rem] text-slate-200">now</span>}
{isNext && <span className="text-[0.65rem] text-emerald-100">next</span>}
</span>
);
})}
</div>
)}
</div>
{canRequest ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleRequest(rover.id);
}}
disabled={pending[roverId] || locked}
className={classNames(
'button-dark disabled:opacity-40',
locked && 'bg-red-600/70 text-white hover:bg-red-600',
)}
>
{buttonLabel}
</button>
) : null}
</li>
);
})}
</ul>
)}
</div>
);
}
+1 -1
View File
@@ -38,7 +38,7 @@ export default function RoverRoster({
>
<div>
<p className="text-slate-200">{rover.name}</p>
<p className="text-xs text-slate-500 flex flex-wrap items-center gap-1">
<p className="text-xs text-slate-500 flex flex-wrap items-center gap-0.5">
<span>{rover.locked ? 'locked' : 'free'}</span>
{rover.lockReason && <span className="rounded bg-black/30 px-1">{rover.lockReason}</span>}
<span className={classNames('font-semibold', batteryClass(rover))}>
+1 -1
View File
@@ -118,7 +118,7 @@ export function Tab({ id, children, className = '', disabled = false }) {
}
export function TabPanels({ children, className = '' }) {
return <div className={classNames('mt-0.5 space-y-0.5', className)}>{children}</div>;
return <div className={classNames('mt-0 space-y-0.5', className)}>{children}</div>;
}
export function TabPanel({ id, children, keepMounted = false }) {
+5 -5
View File
@@ -173,7 +173,7 @@ function DetailCard({ title, children }) {
function ValueRow({ label, value }) {
return (
<div className="flex items-center justify-between gap-1">
<div className="flex items-center justify-between gap-0.5">
<span className="text-slate-300">{label}</span>
<span className="text-slate-100">{value}</span>
</div>
@@ -212,16 +212,16 @@ function DockMiniStatus({ sensors }) {
</span>
);
return (
<div className="flex items-center justify-between gap-1 text-xs text-slate-200">
<div className="flex items-center gap-1">
<div className="flex items-center justify-between gap-0.5 text-xs text-slate-200">
<div className="flex items-center gap-0.5">
<span className="text-slate-400">L</span>
{badge(left)}
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-0.5">
<span className="text-slate-400">O</span>
{badge(omni)}
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-0.5">
<span className="text-slate-400">R</span>
{badge(right)}
</div>
+10 -10
View File
@@ -9,7 +9,7 @@ import KoFiButton from './KoFiButton.jsx';
export function NicknameEntryPanel({ compact = false }) {
return (
<section className="panel-section flex h-full min-h-0 flex-col gap-0.5 text-base">
<div className="surface flex w-full items-center">
<div className="surface flex w-full items-center px-0 py-0">
<NicknameForm compact={compact} />
</div>
</section>
@@ -155,7 +155,7 @@ export default function UserListPanel({
return (
<div
key={user.socketId}
className={`surface-muted flex items-center gap-1 ${compact ? 'py-0.25 text-[0.8rem]' : 'text-sm'}`}
className={`surface-muted flex items-center gap-0.5 ${compact ? 'py-0.25 text-[0.8rem]' : 'text-sm'}`}
>
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
{user.roverId ? (
@@ -181,7 +181,7 @@ export default function UserListPanel({
<div className="space-y-0.5">
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
<div className="min-w-0">
<div className="surface flex w-full items-center">
<div className="surface flex w-full items-center px-0 py-0">
<NicknameForm compact={compact} />
</div>
</div>
@@ -230,7 +230,7 @@ export default function UserListPanel({
</div>
)}
{showQueuesSection ? (
<div className={`surface space-y-0.25 ${turnsListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
<div className={`surface space-y-0.5 px-0 pb-0 ${turnsListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
{Object.keys(turnQueues || {}).length === 0 ? (
<p className="text-sm text-slate-500">No turn queues yet.</p>
) : (
@@ -247,8 +247,8 @@ export default function UserListPanel({
: queue[0]
: null;
return (
<div key={roverId} className={`surface-muted flex flex-col gap-0.25 ${compact ? 'text-[0.8rem] py-0.25' : 'text-sm'}`}>
<div className="flex items-center gap-1">
<div key={roverId} className={`surface-muted flex flex-col gap-0.5 ${compact ? 'text-[0.8rem] py-0.25' : 'text-sm'}`}>
<div className="flex items-center gap-0.5">
<p className="font-semibold text-slate-200">{rosterName(roverId)}</p>
{remaining != null && (
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">
@@ -259,7 +259,7 @@ export default function UserListPanel({
{queue.length === 0 ? (
<p className="text-[0.75rem] text-slate-500">No drivers queued.</p>
) : (
<div className="flex flex-wrap items-center gap-1">
<div className="flex flex-wrap items-center gap-0.5">
{queue.map((socketId, idx) => {
const user = lookupUser(socketId);
const isCurrent = socketId === currentId;
@@ -297,18 +297,18 @@ export default function UserListPanel({
) : null}
{showUsersSection ? (
<div className={`surface space-y-0.25 ${usersListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
<div className={`surface space-y-0.5 px-0 pb-0 ${usersListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
{renderUserList()}
</div>
) : null}
{showUsersSecondary ? (
<div className={`space-y-0.25 ${fillHeight ? 'flex min-h-0 flex-1 flex-col' : ''}`}>
<div className={`space-y-0.5 ${fillHeight ? 'flex min-h-0 flex-1 flex-col' : ''}`}>
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Users</span>
<span className="text-[0.7rem] text-slate-500">{sorted.length}</span>
</div>
<div className={`surface space-y-0.25 ${usersListClass}`}>{renderUserList()}</div>
<div className={`surface space-y-0.5 px-0 pb-0 ${usersListClass}`}>{renderUserList()}</div>
</div>
) : null}
</div>
+12 -12
View File
@@ -433,7 +433,7 @@ export default function VideoTile({
}`}
>
<div className="text-center">{qualityNotice}</div>
<div className="pointer-events-auto mt-1">
<div className="pointer-events-auto mt-0">
<DiscordInviteButton text={'Join our Discord server while you wait!'} />
</div>
</div>
@@ -441,7 +441,7 @@ export default function VideoTile({
) : null}
</div>
{!showVerticalBattery && (
<div className="space-y-0.25">
<div className="space-y-0.5">
<LightBumpBars sensors={sensors} />
<BatteryBar visual={batteryVisual} />
</div>
@@ -484,7 +484,7 @@ function BatteryBarVertical({ visual }) {
style={{ height: `${visual.percentDisplay}%` }}
/>
</div>
<span className="mt-0.5 text-[0.65rem] font-semibold text-slate-100">{percentText}</span>
<span className="mt-0 text-[0.65rem] font-semibold text-slate-100">{percentText}</span>
</div>
);
}
@@ -510,7 +510,7 @@ function LightBumpBars({ sensors }) {
const barHeight = 12;
return (
<div className="flex w-full items-center justify-center gap-1">
<div className="flex w-full items-center justify-center gap-0.5">
{values.map((v, idx) => {
const t = eased(v);
const dir = idx < segments / 2 ? -1 : 1; // left bars fill left, right bars fill right
@@ -600,15 +600,15 @@ function HudOverlay({
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
<div className="flex flex-col gap-[1px] leading-none">
<div className="flex flex-col gap-0.5 leading-none">
<span>Status: {status}</span>
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
</div>
</div>
<div
className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.35 bg-black/70 text-slate-100 ${statusTextClass} ${statusPadClass}`}
className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.5 bg-black/70 text-slate-100 ${statusTextClass} ${statusPadClass}`}
>
<div className="space-y-0.1 leading-tight">
<div className="space-y-0.5 leading-tight">
<span className={`${isMobile ? 'text-[0.45rem]' : 'text-[0.6rem]'} uppercase tracking-wide text-slate-400`}>
Telemetry
</span>
@@ -639,7 +639,7 @@ function HudOverlay({
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
<div
className={`flex items-center gap-1 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
className={`flex items-center gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
>
<span className="font-semibold text-white">{label || 'Unnamed Rover'}</span>
{driverLabel ? <span className="text-slate-300"> {driverLabel}</span> : null}
@@ -652,7 +652,7 @@ function HudOverlay({
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
<div className="flex flex-col gap-[1px] leading-none">
<div className="flex flex-col gap-0.5 leading-none">
<span>Status: {status}</span>
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
</div>
@@ -700,7 +700,7 @@ function TurnCueOverlay({
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
return (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/55">
<div className={`flex flex-col items-center gap-1 rounded border border-amber-300/80 bg-black/70 ${padClass}`}>
<div className={`flex flex-col items-center gap-0.5 rounded border border-amber-300/80 bg-black/70 ${padClass}`}>
<div className={`font-semibold text-amber-200 ${titleClass}`}>IT IS YOUR TURN!</div>
<div className={`text-amber-200/80 ${subClass}`}>Start driving!</div>
{showCountdown ? (
@@ -732,7 +732,7 @@ function OvercurrentOverlay({ motors, compact = false }) {
>
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
<div>OVERCURRENT</div>
<div className={`mt-0.5 font-medium text-white ${subTextClass}`}>{labels.join(', ')}</div>
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{labels.join(', ')}</div>
</div>
</div>
);
@@ -796,7 +796,7 @@ function HudChatInput({ compact = false }) {
return { speak: true, engine, voice };
}, [ttsSettings?.engine, ttsSettings?.pitch, ttsSettings?.voice, ttsSupported]);
const containerClass = compact
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.25 rounded bg-black/70 px-0.4 py-0.2'
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.4 py-0.2'
: 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25';
const inputClass = compact
? 'min-w-0 flex-1 bg-transparent text-[0.55rem] text-slate-100 placeholder:text-slate-400 focus:outline-none'
@@ -46,7 +46,7 @@ export default function DriveModeToggle({ size = 'default' }) {
{currentLabel}
</span>
</div>
<div className="mt-0.5 grid grid-cols-2 gap-0.5 text-xs">
<div className="mt-0 grid grid-cols-2 gap-0.5 text-xs">
<button
type="button"
onClick={handleDrive}
+2 -2
View File
@@ -44,11 +44,11 @@ body {
@layer components {
.panel {
@apply bg-black text-white p-0.5;
@apply bg-black text-white p-0;
}
.panel-section {
@apply bg-neutral-900 text-white p-0.5;
@apply bg-neutral-900 text-white p-0;
}
.panel-muted {
+1 -1
View File
@@ -101,7 +101,7 @@ function MiniSummaryContent() {
}
return (
<div className="relative h-screen w-screen overflow-hidden bg-black p-0.5 text-slate-100 flex flex-col gap-0.5">
<div className="relative h-screen w-screen overflow-hidden bg-black p-0 text-slate-100 flex flex-col gap-0.5">
<ChatOverlay />
<section className="panel relative flex min-h-0 flex-1 overflow-hidden">
{!spectatorReady ? (
+6 -6
View File
@@ -6,13 +6,13 @@ import { useVideoRequests } from '../hooks/useVideoRequests.js';
import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js';
import VideoTile from '../components/VideoTile.jsx';
import RoomCameraPanel from '../components/RoomCameraPanel.jsx';
import UserListPanel from '../components/UserListPanel.jsx';
import ChatPanel from '../components/ChatPanel.jsx';
import LogPanel from '../components/LogPanel.jsx';
import RoverRoster from '../components/RoverRoster.jsx';
import AlertFeed from '../components/AlertFeed.jsx';
import useDefaultNickname from '../hooks/useDefaultNickname.js';
import CommunityGoalBanner from '../components/CommunityGoalBanner.jsx';
import RoverQueuesPanel from '../components/RoverQueuesPanel.jsx';
import RawUserPilePanel from '../components/RawUserPilePanel.jsx';
function formatDriverLabel({ roverId, session }) {
const activeDriverId = session?.activeDrivers?.[roverId] || null;
@@ -28,7 +28,7 @@ function formatDriverLabel({ roverId, session }) {
function RoverSpectatorCard({ rover, frame, snapshotFeed, audioInfo, session }) {
const driverLabel = formatDriverLabel({ roverId: rover.id, session });
return (
<article className="min-h-[16rem] rounded bg-zinc-900 p-0.5 sm:min-h-[18rem]">
<article className="min-h-[16rem] rounded bg-zinc-900 p-0 sm:min-h-[18rem]">
<div className="min-h-0 overflow-hidden rounded bg-black/20">
<VideoTile
sessionInfo={null}
@@ -124,7 +124,7 @@ function SpectatorContent() {
return (
<div className="min-h-screen bg-black text-slate-100 md:h-screen md:overflow-hidden">
<main className="grid min-h-screen grid-cols-1 gap-0.5 p-0.5 md:h-full md:min-h-0 md:grid-cols-[minmax(0,1fr)_18rem] lg:grid-cols-[minmax(0,1fr)_20rem]">
<main className="grid min-h-screen grid-cols-1 gap-0.5 p-0 md:h-full md:min-h-0 md:grid-cols-[minmax(0,1fr)_18rem] lg:grid-cols-[minmax(0,1fr)_20rem]">
<section className="flex min-h-0 min-w-0 flex-col gap-0.5 md:overflow-y-auto">
<RoverRow
roster={roster}
@@ -138,10 +138,10 @@ function SpectatorContent() {
<section className="flex min-h-0 min-w-0 flex-col gap-0.5 md:h-full">
<CommunityGoalBanner layout="desktop" />
<div className="panel">
<RoverRoster roster={roster} title="Rovers" emptyText="No rovers registered." />
<RoverQueuesPanel title="Rovers" />
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
<UserListPanel hideNicknameForm hideHeader fillHeight className="h-full" showBothTurnsAndUsers />
<RawUserPilePanel hideNicknameForm hideHeader fillHeight className="h-full" />
</div>
<div className="min-h-0 min-w-0 flex-[1.1] overflow-hidden">
<ChatPanel hideInput hideSpectatorNotice fillHeight />