big boy webui new new new new new 100 files changed 80 years

This commit is contained in:
legop3
2026-08-18 22:01:25 -04:00
parent 0f0f82e5f6
commit 737760ff56
79 changed files with 2714 additions and 388 deletions
@@ -101,7 +101,7 @@ function GameChoice({ game, disabled, onVote }) {
function StatGrid({ stats, theme }) {
if (!Array.isArray(stats) || !stats.length) return null;
return (
<div className="grid gap-0.5 sm:grid-cols-3">
<div className="grid gap-0.5 @sm:grid-cols-3">
{stats.slice(0, 6).map((stat) => (
<div key={stat.label} className="surface px-1 py-0.75 text-center">
<p className="truncate text-[0.68rem] text-neutral-400">{stat.label}</p>
@@ -215,7 +215,7 @@ function Leaderboard({ players, ownPlayer }) {
const hasOwnPoints = ownPlayer && Number.isFinite(ownPlayer.totalPoints);
return (
<div className="space-y-1">
<div className="grid gap-0.5 sm:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div className="grid gap-0.5 @sm:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div className="surface grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 px-1 py-0.75">
<div className="min-w-0">
<p className="text-xs font-semibold text-neutral-300">Your points</p>
@@ -286,7 +286,7 @@ function BarcodeGamesPanelContent() {
return (
<CardFrame title="Barcode games" bodyClassName="space-y-1 p-0.5 text-sm">
<div className="grid gap-0.5 sm:grid-cols-2">
<div className="grid gap-0.5 @sm:grid-cols-2">
{games.map((game) => (
<GameChoice
key={game.id}
@@ -298,7 +298,9 @@ function BarcodeGamesPanelContent() {
</div>
<section className="space-y-1 border-t border-neutral-700 pt-1">
<div className="grid items-stretch gap-0.5 lg:grid-cols-[minmax(0,1fr)_9rem_10rem]">
{/* The game summary becomes a three-part dashboard only when the card can
fit its fixed timer and participant blocks without crushing the title. */}
<div className="grid items-stretch gap-0.5 @[28rem]:grid-cols-[minmax(0,1fr)_9rem_10rem]">
<div className="surface min-w-0 border-l-4 border-neutral-700 px-1 py-0.75" style={activeTheme.titleBoxStyle}>
<p className="break-words text-xl font-bold leading-tight text-neutral-50" style={activeTheme.textStyle}>
{display.title || activeGame?.title || 'No game active'}
@@ -311,7 +313,7 @@ function BarcodeGamesPanelContent() {
) : null}
<DisplaySections sections={display.sections} />
</div>
<div className="surface px-1 py-0.75 text-left lg:text-right">
<div className="surface px-1 py-0.75 text-left @[28rem]:text-right">
<p className="text-xs font-semibold text-neutral-400">{display.timer?.label || 'Time'}</p>
<p className="font-mono text-xl font-semibold leading-tight text-neutral-50" style={activeTheme.textStyle}>
{timerText || '--'}
@@ -327,7 +329,7 @@ function BarcodeGamesPanelContent() {
<Leaderboard players={state.leaderboard} ownPlayer={state.ownPlayer} />
<div className="grid gap-0.5 border-t border-neutral-700 pt-1 sm:grid-cols-2">
<div className="grid gap-0.5 border-t border-neutral-700 pt-1 @sm:grid-cols-2">
<div className="surface px-1 py-0.75">
<CounterList title="Most scanned objects" entries={state.counters?.objects} />
</div>
+5 -1
View File
@@ -77,7 +77,11 @@ export default function CardFrame({
return (
<section
className={cx(
'panel-section border border-neutral-500/60 bg-neutral-900/100',
// Cards are reused in full-width pages, mobile stacks, and narrow desktop
// sidebars. Making the card itself a query container lets its contents
// respond to the space they actually receive instead of the viewport,
// which may be wide while a sidebar card is only a few hundred pixels.
'panel-section @container border border-neutral-500/60 bg-neutral-900/100',
clipOverflow ? 'overflow-hidden' : 'overflow-visible',
fillHeight && 'flex h-full min-h-0 flex-col',
className,
+7 -2
View File
@@ -1,11 +1,16 @@
.chat-composer { container-type: inline-size; display: flex; flex-wrap: wrap; align-items: stretch; gap: 0.125rem; min-width: 0; overflow: hidden; }
.chat-composer-nickname { flex: 0 0 5rem; min-width: 0; order: 1; }
/* Narrow sidebars need two deliberate control rows. Giving the nickname
control a full flex row prevents the nickname, message, and send controls
from technically fitting by crushing all three into unreadable widths. */
.chat-composer-nickname { flex: 1 1 100%; min-width: 0; order: 1; }
.chat-composer-input { flex: 1 1 0%; min-width: 0; order: 2; }
.chat-composer-send { flex: 0 0 auto; align-self: stretch; order: 3; }
.chat-composer-tts { display: flex; flex: 1 1 100%; align-items: center; gap: 0.125rem; min-width: 0; overflow: hidden; order: 4; }
@container (min-width: 44rem) {
.chat-composer { flex-wrap: nowrap; }
.chat-composer-nickname { flex-basis: 7rem; order: 1; }
/* Wide panels restore the original fixed nickname slot so the message input
receives the remaining row width instead of competing with that form. */
.chat-composer-nickname { flex: 0 0 7rem; order: 1; }
.chat-composer-input { order: 2; }
.chat-composer-tts { flex: 0 1 auto; order: 3; }
.chat-composer-send { order: 4; }
@@ -11,6 +11,7 @@ function isBoolean(value) {
export default function GPIOToggleControl({
label,
icon: Icon,
on,
disabled,
onToggle,
@@ -125,6 +126,9 @@ export default function GPIOToggleControl({
className={buttonClasses}
>
<span className="flex items-center gap-0.5">
{/* Callers opt into an icon so shared desktop GPIO controls retain their
established density while narrow mobile controls gain faster recognition. */}
{Icon ? <Icon className="shrink-0 text-base" aria-hidden="true" /> : null}
<span className="text-sm font-semibold">{label}</span>
{keyLabel ? (
<span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
@@ -80,6 +80,13 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
if (!textContainer || !text) return;
const available = textContainer.clientWidth;
if (!available) return;
/* A narrow desktop sidebar benefits from a stable readable size and
natural wrapping. Mobile deliberately keeps its existing timed,
single-line behavior, while wider desktop banners still use fitting. */
if (!isMobile && available < 384) {
setFontSize(16);
return;
}
const needed = text.scrollWidth;
if (!needed) return;
const scale = Math.min(1, available / needed);
@@ -96,12 +103,13 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
window.cancelAnimationFrame(rafId);
observer.disconnect();
};
}, [goalText]);
}, [goalText, isMobile]);
const containerClass = useMemo(
() =>
[
'panel-section flex w-full items-center justify-center border border-neutral-500/60 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
!isMobile ? '@container' : '',
isMobile ? 'rounded-none' : 'rounded',
'px-1 py-1 text-center font-semibold tracking-tight',
className,
@@ -133,12 +141,31 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
style={{ ...(frameStyle || {}), fontSize: `${fontSize}px`, lineHeight: 1.1 }}
{...dismissProps}
>
<span className="flex w-full items-stretch gap-0.5 whitespace-nowrap rounded-md">
<span className="flex flex-col justify-center border-r border-slate-700/60 px-0.5 text-[0.55em] font-semibold leading-tight text-slate-400">
<span
className={
isMobile
? 'flex w-full items-stretch gap-0.5 whitespace-nowrap rounded-md'
: 'flex w-full flex-col items-stretch gap-0.5 whitespace-normal rounded-md @sm:flex-row @sm:whitespace-nowrap'
}
>
<span
className={
isMobile
? 'flex flex-col justify-center border-r border-slate-700/60 px-0.5 text-[0.55em] font-semibold leading-tight text-slate-400'
: 'flex flex-row items-center justify-center gap-1 border-b border-slate-700/60 px-0.5 py-0.5 text-[0.65em] font-semibold leading-tight text-slate-400 @sm:flex-col @sm:gap-0 @sm:border-b-0 @sm:border-r @sm:py-0 @sm:text-[0.55em]'
}
>
<span>Global</span>
<span>Objective</span>
</span>
<span ref={textContainerRef} className="flex-1 overflow-hidden text-slate-100">
<span
ref={textContainerRef}
className={
isMobile
? 'flex-1 overflow-hidden text-slate-100'
: 'flex-1 whitespace-normal break-words text-slate-100 @sm:overflow-hidden @sm:whitespace-nowrap'
}
>
<span ref={textRef} className="block">
{goalText}
</span>
+19 -11
View File
@@ -29,7 +29,7 @@ function renderSegments(segments, keymap) {
});
}
function renderLine(line, keymap, idx) {
function renderLine(line, keymap) {
if (typeof line === 'string') return line;
if (Array.isArray(line?.segments)) return renderSegments(line.segments, keymap);
if (line && typeof line === 'object') {
@@ -65,7 +65,7 @@ function Hero({ hero, keymap }) {
const key = Array.isArray(line?.segments) ? `hero-${idx}` : `hero-${idx}`;
return (
<li key={key} className="surface-muted px-0.5 py-0.25">
{renderLine(line, keymap, idx)}
{renderLine(line, keymap)}
</li>
);
})}
@@ -84,7 +84,7 @@ function ListBlock({ block, keymap }) {
const key = `item-${idx}`;
return (
<li key={key} className="surface-muted flex flex-wrap items-center gap-0.5 px-0.5 py-0.25">
{renderLine(item, keymap, idx)}
{renderLine(item, keymap)}
</li>
);
})}
@@ -117,8 +117,10 @@ function KeyboardGroup({ group, keymap }) {
<p className="px-0.5 py-0.25 text-[0.75rem] font-semibold text-slate-200">{group.title}</p>
<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>
<div key={item.action} className="surface-muted flex flex-wrap items-center justify-between gap-0.5 px-0.5 py-0.25 text-[0.8rem]">
{/* A binding label may wrap in a slim sidebar, while the key remains
a compact complete token instead of forcing horizontal overflow. */}
<span className="min-w-0 flex-1 text-slate-200">{item.label}</span>
<KeyPill actionId={item.action} keymap={keymap} />
</div>
))}
@@ -131,11 +133,16 @@ function KeyboardBlock({ block, keymap }) {
if (!block) return null;
return (
<div className="space-y-0.5">
<div className="flex items-center justify-between text-xs text-slate-200">
{/* Heading and footnote share a row when possible and wrap independently
when the Help card is mounted in a narrow desktop column. */}
<div className="flex flex-wrap items-center justify-between gap-0.5 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>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-0.5">
{/* Two keyboard groups fit comfortably once the Help surface reaches 32rem.
Using the real content threshold restores the established old-page layout
without forcing two crushed columns inside the narrow new-drive sidebar. */}
<div className="grid grid-cols-1 gap-0.5 @[32rem]:grid-cols-2">
{block.groups?.map((group) => (
<KeyboardGroup key={group.id} group={group} keymap={keymap} />
))}
@@ -185,10 +192,11 @@ export function HelpContentView({ layout, keymap }) {
return (
<div className="space-y-0.5">
<Hero hero={content.hero} keymap={bindings} />
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(360px, 1fr))' }}
>
{/* The previous fixed 360px minimum was wider than the new-drive sidebar
and forced the entire Help card to overflow. This intrinsic minimum
uses one full-width column when 22.5rem cannot physically fit, while
preserving the multi-column presentation in genuinely wide panels. */}
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(22.5rem,100%),1fr))] gap-0.5">
<div className="space-y-0.5">
{mainBlocks.map((block, idx) => (
<BlockRenderer key={block.title || idx} block={block} keymap={bindings} />
@@ -134,8 +134,9 @@ function LampTile({ entity, connected, controlsLocked, onToggle, onSetColor, onS
className={cx(
// The parent grid owns row and column sizing so the room-controls panel
// never sprawls wider than three lamps per row. The tile only keeps a
// small minimum width so names and swatches remain readable.
'flex min-w-[9.5rem] flex-col gap-0.5 rounded border px-0.5 py-0.5 transition-colors',
// zero minimum lets a pair of tiles share a narrow desktop card. The
// internal title truncation and wrapping swatches preserve readability.
'flex min-w-0 flex-col gap-0.5 rounded border px-0.5 py-0.5 transition-colors',
disableToggle ? 'cursor-not-allowed opacity-70' : 'cursor-pointer hover:border-white/60',
tileTone,
)}
@@ -246,7 +247,10 @@ function HomeAssistantControlsContent() {
: 'Lights are locked on. Room controls are disabled.'}
</p>
) : null}
<div className="grid grid-cols-1 gap-0.5 sm:grid-cols-2 lg:grid-cols-3">
{/* Each lamp declares only the smallest width at which its title, status,
and swatches remain useful. Auto-fit then derives the column and row
count directly from the card width without layout-specific breakpoints. */}
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(9rem,100%),1fr))] gap-0.5">
{entities.map((entity) => (
<LampTile
key={entity.id}
+11 -2
View File
@@ -17,6 +17,7 @@ function clampFreq(value) {
export default function HornControl({
disabled,
icon: Icon,
onStart,
onStop,
keyLabel,
@@ -56,10 +57,14 @@ export default function HornControl({
'mobile-touch-control group relative flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-1 py-1.5 text-xs font-semibold select-none no-touch-select';
const active = 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50';
const inactive = 'border-cyan-300/70 bg-cyan-900 text-cyan-50 hover:bg-cyan-800';
return [base, isActive ? active : inactive, 'disabled:opacity-50', className]
// This is a role-based held control rather than a native button, so CSS's
// :disabled pseudo-class can never match it. Reflect disabled state directly
// while leaving the nested settings button available.
const disabledClass = disabled ? 'cursor-not-allowed opacity-50' : '';
return [base, isActive ? active : inactive, disabledClass, className]
.filter(Boolean)
.join(' ');
}, [className, isActive]);
}, [className, disabled, isActive]);
const formattedWaveform = waveform === 'sine' ? 'sine' : 'saw';
@@ -170,6 +175,7 @@ export default function HornControl({
role="button"
tabIndex={0}
aria-pressed={isActive}
aria-disabled={disabled}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
@@ -201,6 +207,9 @@ export default function HornControl({
/>
<div className="relative z-10 flex items-center justify-between gap-0.5">
<span className="flex items-center gap-0.5 text-sm font-semibold">
{/* Keep icon ownership with the caller so adding mobile affordance does
not silently change every existing desktop horn presentation. */}
{Icon ? <Icon className="shrink-0 text-base" aria-hidden="true" /> : null}
<span>Horn</span>
{keyLabel ? (
<span className="rounded bg-black/40 px-1 py-0.5 text-[0.6rem] font-semibold text-white">
@@ -1,7 +1,7 @@
// Hud Chat Input
// Purpose: Defines the Hud Chat Input module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { memo, useMemo, useState } from 'react';
import { memo, useMemo, useRef, useState } from 'react';
import './styles.css';
import '../../MobileControls/mobileControls.css';
import { useChatActions } from '../../../context/ChatContext.jsx';
@@ -25,7 +25,7 @@ function detectSafari() {
!/crios|fxios|edgios|opr|chrome|android/i.test(userAgent);
}
function HudChatInput({ compact = false }) {
function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpenChange = null }) {
const role = useSessionSelector((state) => state.session?.role || null);
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
@@ -45,6 +45,7 @@ function HudChatInput({ compact = false }) {
});
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const formRef = useRef(null);
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
const canChat = role !== 'spectator';
const hideHudChat = role === 'spectator';
@@ -96,22 +97,37 @@ function HudChatInput({ compact = false }) {
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.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 newDrivePresentation = variant === 'newdrive';
const containerClass = newDrivePresentation
? `absolute left-1/2 top-1/2 z-[70] flex w-[min(36rem,82%)] -translate-x-1/2 -translate-y-1/2 items-stretch gap-1 rounded-md border border-neutral-700 bg-neutral-900 p-1 transition-opacity duration-100 ${open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`
: compact
? '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 isSafari = useMemo(() => detectSafari(), []);
/*
Safari zooms focused inputs below 16px. Keep that workaround Safari-only so
other mobile browsers keep the deliberately tiny HUD typography.
*/
const inputTextClass = isSafari ? 'mobile-text-entry' : compact ? 'text-[0.55rem]' : 'text-[0.7rem]';
const inputClass = `min-w-0 flex-1 bg-transparent ${inputTextClass} text-slate-100 placeholder:text-slate-400 focus:outline-none`;
const inputTextClass = newDrivePresentation
? 'text-base'
: isSafari
? 'mobile-text-entry'
: compact
? 'text-[0.55rem]'
: 'text-[0.7rem]';
// The current HUD composer is a physically larger instance of the normal
// chat-panel entry field, not a separately styled translucent overlay.
const inputClass = newDrivePresentation
? `field-input h-12 min-w-0 flex-1 px-2 ${inputTextClass} text-slate-100 placeholder:text-slate-400 focus:outline-none`
: `min-w-0 flex-1 bg-transparent ${inputTextClass} text-slate-100 placeholder:text-slate-400 focus:outline-none`;
// The submit button is still a touch target even though the adjacent input must
// remain editable, so it gets press suppression without inheriting input text
// selection behavior.
const buttonClass = compact
? 'mobile-touch-control rounded bg-cyan-500/80 px-0.35 py-0.2 text-[0.55rem] font-semibold text-black disabled:opacity-50'
: 'mobile-touch-control rounded bg-cyan-500/80 px-0.5 py-0.25 text-[0.7rem] font-semibold text-black disabled:opacity-50';
const buttonClass = newDrivePresentation
? 'button-dark min-h-12 shrink-0 px-4 text-base font-semibold disabled:opacity-50'
: compact
? 'mobile-touch-control rounded bg-cyan-500/80 px-0.35 py-0.2 text-[0.55rem] font-semibold text-black disabled:opacity-50'
: 'mobile-touch-control rounded bg-cyan-500/80 px-0.5 py-0.25 text-[0.7rem] font-semibold text-black disabled:opacity-50';
async function handleSend(event) {
event.preventDefault();
@@ -125,6 +141,9 @@ function HudChatInput({ compact = false }) {
resetHistoryNavigation();
blurChat();
setTypingActive(false);
// The new-drive composer is intentionally transient. Sending returns the
// HUD to its small corner icon while legacy HUD inputs remain unchanged.
if (newDrivePresentation) onOpenChange?.(false);
} catch (err) {
alert(err.message);
} finally {
@@ -135,7 +154,7 @@ function HudChatInput({ compact = false }) {
if (hideHudChat) return null;
return (
<form onSubmit={handleSend} className={containerClass}>
<form ref={formRef} onSubmit={handleSend} className={containerClass}>
<input
className={inputClass}
value={draft}
@@ -150,12 +169,32 @@ function HudChatInput({ compact = false }) {
onFocus={(event) => {
onInputFocus(event);
setTypingActive(Boolean(draft.trim()));
// The chat key focuses the always-mounted HUD input through ChatContext.
// Opening from this focus event avoids a second keyboard listener and
// preserves HUD-over-sidebar focus priority.
if (newDrivePresentation) onOpenChange?.(true);
}}
onBlur={(event) => {
onInputBlur(event);
setTypingActive(false);
if (newDrivePresentation) {
// Defer the containment check until focus has reached a submit or
// close button; moving within the composer must not dismiss it.
window.requestAnimationFrame(() => {
if (!formRef.current?.contains(document.activeElement)) {
onOpenChange?.(false);
}
});
}
}}
onKeyDown={(event) => {
if (newDrivePresentation && event.key === 'Escape') {
event.preventDefault();
onOpenChange?.(false);
blurChat();
setTypingActive(false);
return;
}
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const recalledDraft = navigateHistory(event.key === 'ArrowUp' ? 'previous' : 'next', draft);
if (recalledDraft !== null) {
@@ -172,12 +211,27 @@ function HudChatInput({ compact = false }) {
}
}}
ref={(el) => registerInputRef(el, { target: 'hud' })}
placeholder={canChat ? 'Chat (tts)' : 'Spectator'}
tabIndex={newDrivePresentation && !open ? -1 : undefined}
placeholder={canChat ? (newDrivePresentation ? 'Type a message…' : 'Chat (tts)') : 'Spectator'}
disabled={!canChat}
/>
<button type="submit" disabled={!canChat || sending} className={buttonClass}>
Speak
{sending ? '...' : newDrivePresentation ? 'Send' : 'Speak'}
</button>
{newDrivePresentation ? (
<button
type="button"
aria-label="Close chat"
onClick={() => {
onOpenChange?.(false);
blurChat();
setTypingActive(false);
}}
className="flex min-h-12 w-10 shrink-0 items-center justify-center rounded border border-neutral-600 bg-neutral-800 text-xl leading-none text-slate-200 hover:bg-neutral-700 hover:text-white"
>
×
</button>
) : null}
</form>
);
}
@@ -0,0 +1,70 @@
// Bottom Sensor HUD Geometry
// Purpose: Defines one physical front-rover curve shared by bumpers, IR sensors, and cliffs.
// Scope: Contains static display geometry only; telemetry components decide which shapes are visible.
export const SENSOR_HUD_VIEW_BOX = '0 0 1000 140';
export const LEFT_BUMPER_PATH = 'M 8 128 Q 248 60 488 60';
export const RIGHT_BUMPER_PATH = 'M 512 60 Q 752 60 992 128';
/* Horizontal center tangents keep the mirrored halves circular instead of forming a pointed crown. */
const LEFT_CURVE = Object.freeze({ x0: 8, x1: 488, y0: 128, controlY: 60, y1: 60 });
const RIGHT_CURVE = Object.freeze({ x0: 512, x1: 992, y0: 60, controlY: 60, y1: 128 });
function curveForX(x) {
return x < 500 ? LEFT_CURVE : RIGHT_CURVE;
}
export function bumperCurvePoint(x) {
const curve = curveForX(x);
const t = Math.max(0, Math.min(1, (x - curve.x0) / (curve.x1 - curve.x0)));
const oneMinusT = 1 - t;
const y =
oneMinusT * oneMinusT * curve.y0 +
2 * oneMinusT * t * curve.controlY +
t * t * curve.y1;
const dyDt =
2 * oneMinusT * (curve.controlY - curve.y0) +
2 * t * (curve.y1 - curve.controlY);
const slope = dyDt / (curve.x1 - curve.x0);
return { x, y, slope };
}
function segmentPath(startX, endX, verticalOffset) {
const start = bumperCurvePoint(startX);
const end = bumperCurvePoint(endX);
const middle = bumperCurvePoint((startX + endX) / 2);
/* Solve the quadratic control height so the segment passes through the shared curve midpoint. */
const controlY = 2 * (middle.y + verticalOffset) - (start.y + end.y) / 2 - verticalOffset;
return `M ${startX} ${start.y + verticalOffset} Q ${(startX + endX) / 2} ${controlY} ${endX} ${end.y + verticalOffset}`;
}
const IR_SENSOR_X = [75, 225, 400, 600, 775, 925];
const IR_SENSOR_WIDTHS = [78, 70, 64, 64, 70, 78];
const IR_OFFSET_ABOVE_BUMPER = 10;
export const IR_SENSOR_GEOMETRY = IR_SENSOR_X.map((x, index) => {
const point = bumperCurvePoint(x);
/* The upward normal makes outer sensors fan sideways and center sensors project upward. */
const normalLength = Math.hypot(point.slope, 1);
return Object.freeze({
key: ['left', 'front-left', 'center-left', 'center-right', 'front-right', 'right'][index],
tipX: x,
tipY: point.y - IR_OFFSET_ABOVE_BUMPER,
directionX: point.slope / normalLength,
directionY: -1 / normalLength,
width: IR_SENSOR_WIDTHS[index],
});
});
const CLIFF_OFFSET_BELOW_BUMPER = 21;
const CLIFF_RANGES = [
['left', 185, 315],
['frontLeft', 345, 485],
['frontRight', 515, 655],
['right', 685, 815],
];
export const CLIFF_ARCS = CLIFF_RANGES.map(([key, startX, endX]) => Object.freeze({
key,
path: segmentPath(startX, endX, CLIFF_OFFSET_BELOW_BUMPER),
}));
@@ -0,0 +1,24 @@
// New Generation Bottom Sensor HUD
// Purpose: Gives all bottom sensor visuals one SVG coordinate space and one physical curve.
// Scope: Owns shared placement only; child components retain independent telemetry subscriptions.
import BumperIndicators from '../BumperIndicators/index.jsx';
import IrProximityHud from '../IrProximityHud/index.jsx';
import CliffIndicators from '../CliffIndicators/index.jsx';
import { SENSOR_HUD_VIEW_BOX } from './geometry.js';
import './styles.css';
export default function BottomSensorHud({ roverId }) {
return (
<svg
className="newgen-bottom-sensor-hud"
viewBox={SENSOR_HUD_VIEW_BOX}
preserveAspectRatio="none"
role="img"
aria-label="Rover front sensors"
>
<IrProximityHud roverId={roverId} />
<BumperIndicators roverId={roverId} />
<CliffIndicators roverId={roverId} />
</svg>
);
}
@@ -0,0 +1,11 @@
.newgen-bottom-sensor-hud {
pointer-events: none;
position: absolute;
z-index: 20;
right: 0;
bottom: -2.5rem;
left: 0;
width: 100%;
height: clamp(7rem, 18vh, 11rem);
overflow: visible;
}
@@ -0,0 +1,56 @@
// New Generation Bumper Indicators
// Purpose: Projects the rover's two physical bumper halves across the bottom of the video.
// Scope: Visualizes server telemetry only; it does not infer collisions or change control behavior.
import { useId } from 'react';
import {
shallowObjectEqual,
useVisualTelemetrySelector,
} from '../../../../context/TelemetryContext.jsx';
import { LEFT_BUMPER_PATH, RIGHT_BUMPER_PATH } from '../BottomSensorHud/geometry.js';
import './styles.css';
const EMPTY_BUMPERS = Object.freeze({ bumpLeft: false, bumpRight: false });
function selectBumpers(frame) {
const contact = frame?.sensors?.bumpsAndWheelDrops;
if (!contact) return EMPTY_BUMPERS;
return {
bumpLeft: Boolean(contact.bumpLeft),
bumpRight: Boolean(contact.bumpRight),
};
}
function ActiveBumperArc({ pathId, path, label }) {
return (
<g className="newgen-bumper-arc">
{/*
The broad rounded stroke uses the same visual idea as TopDownMap's
ArcSegment, while this wider quadratic path is shaped for the camera
viewport instead of the circular top-down rover geometry.
*/}
<path id={pathId} d={path} className="newgen-bumper-path" />
<text className="newgen-bumper-label" dy="0.35em">
<textPath href={`#${pathId}`} startOffset="50%" textAnchor="middle">
{label}
</textPath>
</text>
</g>
);
}
export default function BumperIndicators({ roverId }) {
const telemetry = useVisualTelemetrySelector(roverId, selectBumpers, shallowObjectEqual);
const instanceId = useId().replaceAll(':', '');
if (!telemetry.bumpLeft && !telemetry.bumpRight) return null;
return (
<g className="newgen-bumper-visual" aria-label="Active rover bumper sensors">
{telemetry.bumpLeft ? (
<ActiveBumperArc pathId={`${instanceId}-left-bumper`} path={LEFT_BUMPER_PATH} label="BUMPER" />
) : null}
{telemetry.bumpRight ? (
<ActiveBumperArc pathId={`${instanceId}-right-bumper`} path={RIGHT_BUMPER_PATH} label="BUMPER" />
) : null}
</g>
);
}
@@ -0,0 +1,18 @@
.newgen-bumper-path {
fill: none;
stroke: rgb(239 68 68 / 0.48);
stroke-width: 26;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.newgen-bumper-label {
fill: white;
font-family: system-ui, sans-serif;
font-size: 20px;
font-weight: 700;
letter-spacing: 1px;
paint-order: stroke;
stroke: rgb(0 0 0 / 0.65);
stroke-width: 1.5px;
}
@@ -0,0 +1,61 @@
// New Generation Cliff Indicators
// Purpose: Projects the four underside cliff sensors just below the front bumper curve.
// Scope: Uses the rover's cliff booleans directly without interpreting raw cliff signal values.
import { useId } from 'react';
import {
shallowObjectEqual,
useVisualTelemetrySelector,
} from '../../../../context/TelemetryContext.jsx';
import { CLIFF_ARCS } from '../BottomSensorHud/geometry.js';
import './styles.css';
const EMPTY_CLIFFS = Object.freeze({
left: false,
frontLeft: false,
frontRight: false,
right: false,
});
function selectCliffs(frame) {
const sensors = frame?.sensors;
if (!sensors) return EMPTY_CLIFFS;
return {
left: Boolean(sensors.cliffLeft),
frontLeft: Boolean(sensors.cliffFrontLeft),
frontRight: Boolean(sensors.cliffFrontRight),
right: Boolean(sensors.cliffRight),
};
}
function ActiveCliffArc({ pathId, path }) {
return (
<g>
<path id={pathId} d={path} className="newgen-cliff-path" />
<text className="newgen-cliff-label" dy="0.35em">
<textPath href={`#${pathId}`} startOffset="50%" textAnchor="middle">
CLIFF
</textPath>
</text>
</g>
);
}
export default function CliffIndicators({ roverId }) {
const cliffs = useVisualTelemetrySelector(roverId, selectCliffs, shallowObjectEqual);
const instanceId = useId().replaceAll(':', '');
if (!Object.values(cliffs).some(Boolean)) return null;
return (
<g className="newgen-cliff-visual" aria-label="Active rover cliff sensors">
{CLIFF_ARCS.map((sensor) => (
cliffs[sensor.key] ? (
<ActiveCliffArc
key={sensor.key}
pathId={`${instanceId}-${sensor.key}-cliff`}
path={sensor.path}
/>
) : null
))}
</g>
);
}
@@ -0,0 +1,18 @@
.newgen-cliff-path {
fill: none;
stroke: rgb(245 158 11 / 0.68);
stroke-width: 22;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.newgen-cliff-label {
fill: white;
font-family: system-ui, sans-serif;
font-size: 15px;
font-weight: 700;
letter-spacing: 1px;
paint-order: stroke;
stroke: rgb(0 0 0 / 0.65);
stroke-width: 1.5px;
}
@@ -0,0 +1,90 @@
// Bottom-left Corner Pod
// Purpose: Presents only the assigned rover's available physical light, laser, and horn controls.
import { useRef } from 'react';
import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
import HornSettingsExpansion from './HornSettingsExpansion.jsx';
import CornerPodToggle from './CornerPodToggle.jsx';
import usePodVisibility from './usePodVisibility.js';
function RoundControl({ label, icon, keyLabel, active, tone, disabled = false, onClick, onPointerDown, onPointerUp, large = false, className = '' }) {
const ControlIcon = icon;
const toneClass = tone === 'horn'
? active ? 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50' : 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
: active ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50';
return (
<button
type="button"
aria-label={label}
disabled={disabled}
onClick={onClick}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className={`flex shrink-0 select-none flex-col items-center justify-center gap-1 rounded-full border-2 ${toneClass} ${large ? 'h-20 w-20' : 'h-16 w-16'} disabled:cursor-not-allowed disabled:opacity-40 ${className}`}
>
<ControlIcon className={large ? 'text-xl' : 'text-base'} aria-hidden="true" />
<KeyPill label={keyLabel} />
</button>
);
}
export default function BottomLeftPod({ roverId }) {
const [open, setOpen] = usePodVisibility('peripherals', true);
const [hornSettingsOpen, setHornSettingsOpen] = usePodVisibility('hornSettings', false);
const headlight = useControlSelector((control) => control.pipeline?.headlight);
const laser = useControlSelector((control) => control.pipeline?.laser);
const hornDevice = useControlSelector((control) => control.pipeline?.horn);
const headlightOn = useControlSelector((control) => Boolean(control.pipeline?.headlightState?.headlightOn));
const laserOn = useControlSelector((control) => Boolean(control.pipeline?.laserState?.laserOn));
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
const keymap = useControlSelector((control) => control.state.keymap);
const { setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
const canControl = useCanControlRover(roverId);
const hornPointerRef = useRef(null);
const available = Boolean(headlight || laser || hornDevice);
if (!available) return null;
const startHornPointer = (event) => {
if (!canControl) return;
if (hornPointerRef.current != null) return;
event.preventDefault();
hornPointerRef.current = event.pointerId;
event.currentTarget.setPointerCapture?.(event.pointerId);
startHorn();
};
const stopHornPointer = (event) => {
if (hornPointerRef.current !== event.pointerId) return;
hornPointerRef.current = null;
stopHorn();
};
return (
<>
{open ? (
<div className="pointer-events-auto absolute bottom-0 left-0 z-20 h-40 w-40 rounded-tr-[4.25rem] bg-black/60">
{/*
The horn's center is the origin of an invisible 76px-radius arc. Headlight and laser
sit at -75 and -15 degrees on either side of that arc's diagonal midpoint. Their
sixty-degree separation leaves a visible gap between the small circles while keeping
both controls equally distant from the horn. These explicit positions are the
rendered result of that geometry, not unrelated visual nudges.
*/}
{/* Physical rover actions become visibly and behaviorally unavailable
while another queued driver owns the turn. Pod/settings controls
remain interactive because they do not mutate rover hardware. */}
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={formatKeyLabel(keymap?.hornHonk?.[0])} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={formatKeyLabel(keymap?.headlightToggle?.[0])} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={formatKeyLabel(keymap?.laserToggle?.[0])} active={laserOn} disabled={!canControl} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
<CornerPodToggle corner="bottom-left" expanded label="Hide rover controls" onClick={() => setOpen(false)} />
</div>
) : (
<CornerPodToggle corner="bottom-left" expanded={false} label="Show rover controls" onClick={() => setOpen(true)} />
)}
{hornDevice ? <HornSettingsExpansion open={hornSettingsOpen} podOpen={open} onOpenChange={setHornSettingsOpen} /> : null}
</>
);
}
@@ -0,0 +1,101 @@
// Bottom-right Corner Pod
// Purpose: Provides a compact circular camera-tilt control using the existing servo command path.
import { useCallback } from 'react';
import { FaVideo } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
import ChatExpansion from './ChatExpansion.jsx';
import CornerPodToggle from './CornerPodToggle.jsx';
import usePodVisibility from './usePodVisibility.js';
const ARC_CENTER = 72;
const ARC_RADIUS = 60;
const ARC_START_DEGREES = 105;
const ARC_SWEEP_DEGREES = 240;
function pointOnArc(fraction) {
// The unused third is centered on the physical bottom-right screen corner. Starting at
// 105 degrees and sweeping clockwise to 345 degrees leaves that exact corner-facing gap.
const degrees = ARC_START_DEGREES + fraction * ARC_SWEEP_DEGREES;
const radians = (degrees * Math.PI) / 180;
return {
x: ARC_CENTER + Math.cos(radians) * ARC_RADIUS,
y: ARC_CENTER + Math.sin(radians) * ARC_RADIUS,
};
}
export default function BottomRightPod({ roverId }) {
const layout = useDriverLayout();
const [open, setOpen] = usePodVisibility('camera', true);
const camera = useControlSelector((control) => control.state.camera);
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
const keymap = useControlSelector((control) => control.state.keymap);
const { setServoAngle } = useControlActions();
const canControl = useCanControlRover(roverId);
const config = camera?.config;
const enabled = Boolean(roverId && camera?.enabled && config);
const min = Number(config?.minAngle);
const max = Number(config?.maxAngle);
const value = Number.isFinite(camera?.angle) ? camera.angle : Number(config?.homeAngle) || 0;
const fraction = max > min ? Math.max(0, Math.min(1, (value - min) / (max - min))) : 0.5;
const knob = pointOnArc(fraction);
// Keep this derived state in one place because it also tells the independent chat
// expansion whether it must offset itself above a visible camera-control pod.
const showCameraControls = layout === 'desktop';
const cameraPodOpen = showCameraControls && enabled && open;
const updateFromPointer = useCallback((event) => {
if (!canControl || !enabled || dockAssistActive || !(max > min)) return;
const bounds = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / bounds.width) * 144;
const y = ((event.clientY - bounds.top) / bounds.height) * 144;
const pointerDegrees = ((Math.atan2(y - ARC_CENTER, x - ARC_CENTER) * 180) / Math.PI + 360) % 360;
let arcDegrees = (pointerDegrees - ARC_START_DEGREES + 360) % 360;
if (arcDegrees > ARC_SWEEP_DEGREES) {
/*
Pointer input inside the open corner-facing third is outside the slider. Snap it to
whichever visible endpoint is closer so the gap remains visually and behaviorally open.
*/
const distanceFromEnd = arcDegrees - ARC_SWEEP_DEGREES;
const distanceFromStart = 360 - arcDegrees;
arcDegrees = distanceFromStart < distanceFromEnd ? 0 : ARC_SWEEP_DEGREES;
}
const nextFraction = arcDegrees / ARC_SWEEP_DEGREES;
setServoAngle(min + nextFraction * (max - min));
}, [canControl, dockAssistActive, enabled, max, min, setServoAngle]);
return (
<>
{cameraPodOpen ? (
<div className="pointer-events-auto absolute bottom-0 right-0 z-20 flex h-[8.5rem] w-[8.5rem] items-center justify-center rounded-tl-[4.25rem] bg-black/60">
{/* The pod shell and its visibility toggle stay usable while waiting, but
every camera mutation is blocked and visibly muted until control returns. */}
<svg viewBox="0 0 144 144" aria-disabled={!canControl} className={`h-[8.5rem] w-[8.5rem] touch-none ${canControl ? '' : 'pointer-events-none opacity-40'}`} onPointerDown={updateFromPointer} onPointerMove={(event) => { if (event.buttons) updateFromPointer(event); }}>
<path d="M 56.47 129.96 A 60 60 0 1 1 129.96 56.47" pathLength="1" fill="none" stroke="#064e3b" strokeWidth="12" strokeLinecap="round" />
<path d="M 56.47 129.96 A 60 60 0 1 1 129.96 56.47" pathLength="1" fill="none" stroke="#34d399" strokeWidth="12" strokeLinecap="round" strokeDasharray={`${fraction} 1`} />
<circle cx={knob.x} cy={knob.y} r="7" fill="#ecfdf5" stroke="#059669" strokeWidth="3" />
</svg>
<button type="button" aria-label="Reset camera tilt" disabled={!canControl} onClick={() => setServoAngle(0)} className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-0.5 rounded bg-black/55 px-1.5 py-1 font-bold text-white disabled:cursor-not-allowed disabled:opacity-40">
{/* The icon/value stack mirrors the battery pod and clarifies that this
circular gauge changes the live rover camera's tilt angle. */}
<FaVideo className="text-base" aria-hidden="true" />
<span className="text-sm leading-none">{value.toFixed(1)}°</span>
</button>
{/* These positions continue around the same circle just beyond the two slider endpoints.
Together they occupy the open third facing the corner without enlarging the pod. */}
<div className="absolute left-[61%] top-[90%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraDown?.[0])} /></div>
<div className="absolute left-[90%] top-[61%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraUp?.[0])} /></div>
<CornerPodToggle corner="bottom-right" expanded label="Hide camera tilt" onClick={() => setOpen(false)} />
</div>
) : showCameraControls && enabled ? (
<CornerPodToggle corner="bottom-right" expanded={false} label="Show camera tilt" onClick={() => setOpen(true)} />
) : null}
{/* Chat is an independent expansion. It remains usable even when this rover
has no camera-servo configuration or the camera pod is collapsed. */}
<ChatExpansion podOpen={cameraPodOpen} />
</>
);
}
@@ -0,0 +1,59 @@
// New-drive Chat Expansion
// Purpose: Keeps chat collapsed to a corner icon while reusing the established HUD composer behavior.
import { useCallback, useState } from 'react';
import { FaComment } from 'react-icons/fa';
import { useChatActions } from '../../../../context/ChatContext.jsx';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import { useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import HudChatInput from '../../HudChatInput/index.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
export default function ChatExpansion({ podOpen }) {
const role = useSessionSelector((state) => state.session?.role || null);
const chatKeyLabel = useControlSelector((control) => formatKeyLabel(control.state.keymap?.chatFocus?.[0]));
const { blurChat, focusChat } = useChatActions();
const [open, setOpen] = useState(false);
const setChatOpen = useCallback((nextOpen) => {
const next = Boolean(nextOpen);
setOpen(next);
if (!next) blurChat();
}, [blurChat]);
const toggleChat = useCallback(() => {
if (open) {
setChatOpen(false);
return;
}
setOpen(true);
/* HudChatInput remains mounted while visually collapsed, so the existing
ChatContext HUD ref is ready immediately. Waiting one animation frame lets
the opening presentation commit before the browser paints the focus ring. */
window.requestAnimationFrame(focusChat);
}, [focusChat, open, setChatOpen]);
// Spectators use the sidebar transcript but cannot send through the legacy HUD
// composer, so hiding this expansion avoids presenting an inert control.
if (role === 'spectator') return null;
return (
<>
<button
type="button"
aria-label={open ? 'Close chat' : 'Open chat'}
aria-pressed={open}
onClick={toggleChat}
className={`pointer-events-auto absolute z-20 flex h-8 items-center justify-center gap-1 bg-black/60 px-1.5 text-sm text-white transition hover:bg-black/80 ${podOpen ? 'bottom-[8.5rem] right-0 rounded-tl-lg' : 'bottom-0 right-10 rounded-t-lg'}`}
>
<FaComment aria-hidden="true" />
{/* The pill reflects the live keymap so remapping chat focus updates this
compact HUD hint without duplicating or hardcoding the default key. */}
{chatKeyLabel ? <KeyPill label={chatKeyLabel} /> : null}
</button>
<HudChatInput variant="newdrive" open={open} onOpenChange={setChatOpen} />
</>
);
}
@@ -0,0 +1,49 @@
// Corner Pod Toggle
// Purpose: Reserves each exact video corner for a pod's always-reachable collapse control.
import { FaArrowUp } from 'react-icons/fa';
const CORNERS = {
'top-left': {
button: 'left-0 top-0 [clip-path:polygon(0_0,100%_0,0_100%)]',
icon: 'left-1 top-1',
collapseRotation: '-rotate-45',
expandRotation: 'rotate-[135deg]',
},
'top-right': {
button: 'right-0 top-0 [clip-path:polygon(0_0,100%_0,100%_100%)]',
icon: 'right-1 top-1',
collapseRotation: 'rotate-45',
expandRotation: '-rotate-[135deg]',
},
'bottom-left': {
button: 'bottom-0 left-0 [clip-path:polygon(0_0,0_100%,100%_100%)]',
icon: 'bottom-1 left-1',
collapseRotation: '-rotate-[135deg]',
expandRotation: 'rotate-45',
},
'bottom-right': {
button: 'bottom-0 right-0 [clip-path:polygon(100%_0,0_100%,100%_100%)]',
icon: 'bottom-1 right-1',
collapseRotation: 'rotate-[135deg]',
expandRotation: '-rotate-45',
},
};
export default function CornerPodToggle({ corner, expanded, label, onClick }) {
const placement = CORNERS[corner] || CORNERS['top-left'];
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className={`pointer-events-auto absolute z-40 h-10 w-10 bg-black/90 text-white/90 hover:bg-black hover:text-white ${placement.button}`}
>
{/* One arrow icon is rotated toward the physical corner for collapse and directly away
from it for expansion. The triangular hit target itself never moves or disappears. */}
<FaArrowUp
className={`absolute text-[0.65rem] ${placement.icon} ${expanded ? placement.collapseRotation : placement.expandRotation}`}
aria-hidden="true"
/>
</button>
);
}
@@ -0,0 +1,22 @@
// Expansion Toggle
// Purpose: Provides a slim edge strip that remains part of an expansion in both visibility states.
import { FaChevronDown, FaChevronLeft, FaChevronRight, FaChevronUp } from 'react-icons/fa';
const ICONS = { down: FaChevronDown, left: FaChevronLeft, right: FaChevronRight, up: FaChevronUp };
export default function ExpansionToggle({ direction, label, onClick, className = '' }) {
const Icon = ICONS[direction] || FaChevronLeft;
const horizontalEdge = direction === 'up' || direction === 'down';
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className={`flex shrink-0 items-center justify-center bg-black/60 text-[0.5rem] text-white/75 hover:bg-black hover:text-white ${horizontalEdge ? 'h-3 w-8' : 'h-8 w-3'} ${className}`}
>
{/* The black strip is intentionally retained around the chevron. A collapsed expansion is
therefore still a thin piece of that expansion, never a loose button over a pod. */}
<Icon aria-hidden="true" />
</button>
);
}
@@ -0,0 +1,78 @@
// Horn Settings Expansion
// Purpose: Keeps the persisted horn sound controls available independently of the peripheral pod.
import { useCallback } from 'react';
import { HORN_MAX_FREQUENCY } from '../../../../controls/constants.js';
import { useSettingsNamespace } from '../../../../settings/index.js';
import { HORN_SETTINGS_DEFAULTS } from '../../../../settings/namespaces.js';
import ExpansionToggle from './ExpansionToggle.jsx';
function clampFrequency(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) return 0;
return Math.min(HORN_MAX_FREQUENCY, Math.round(numeric));
}
export default function HornSettingsExpansion({ open, podOpen, onOpenChange }) {
const { value, save } = useSettingsNamespace('horn', HORN_SETTINGS_DEFAULTS);
const waveform = value?.waveform === 'sine' ? 'sine' : 'saw';
const frequencies = [...(Array.isArray(value?.freqs) ? value.freqs : HORN_SETTINGS_DEFAULTS.freqs), 0, 0, 0, 0]
.slice(0, 4)
.map(clampFrequency);
const updateFrequency = useCallback((index, nextValue) => {
save((current) => {
// Build from the persisted values on every edit so rapid changes cannot overwrite a
// neighboring frequency input with a stale render-time copy.
const next = [...(Array.isArray(current?.freqs) ? current.freqs : HORN_SETTINGS_DEFAULTS.freqs), 0, 0, 0, 0]
.slice(0, 4)
.map(clampFrequency);
next[index] = clampFrequency(nextValue);
return { ...(current || {}), freqs: next };
});
}, [save]);
if (!open) {
return (
<div className={`pointer-events-auto absolute left-10 z-20 flex h-3 w-8 bg-black/60 ${podOpen ? 'bottom-40' : 'bottom-0'}`}>
<ExpansionToggle direction="up" label="Show horn settings" onClick={() => onOpenChange(true)} />
</div>
);
}
return (
<div className={`pointer-events-auto absolute left-0 z-20 w-52 rounded-tr-xl bg-black/60 p-2 text-xs text-white ${podOpen ? 'bottom-40' : 'bottom-0'}`}>
<div className="mb-2 flex items-center justify-between gap-2">
<span className="font-semibold text-cyan-100">Horn settings</span>
{/* This arrow belongs to the expansion. Closing the peripheral pod never changes
hornSettings visibility; it only moves this panel into the vacated corner. */}
<ExpansionToggle direction="down" label="Hide horn settings" onClick={() => onOpenChange(false)} />
</div>
<label className="flex items-center justify-between gap-2 text-slate-300">
<span>Wave</span>
<select
value={waveform}
onChange={(event) => save((current) => ({ ...(current || {}), waveform: event.target.value === 'sine' ? 'sine' : 'saw' }))}
className="rounded bg-slate-900 px-2 py-1 text-white ring-1 ring-slate-600"
>
<option value="saw">Saw</option>
<option value="sine">Sine</option>
</select>
</label>
<div className="mt-2 grid grid-cols-2 gap-1.5">
{frequencies.map((frequency, index) => (
<label key={index} className="flex items-center gap-1 text-slate-400">
<span>{index + 1}</span>
<input
type="number"
min="0"
max={HORN_MAX_FREQUENCY}
value={frequency}
onChange={(event) => updateFrequency(index, event.target.value)}
className="min-w-0 flex-1 rounded bg-slate-900 px-1.5 py-1 text-right font-mono text-white ring-1 ring-slate-600"
/>
</label>
))}
</div>
</div>
);
}
@@ -0,0 +1,174 @@
// Top-left Corner Pod
// Purpose: Shows the user's current-turn or queue-wait countdown and the rover identity expansion.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useControlSelector } from '../../../../controls/index.js';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import { useSharedClock } from '../../../../hooks/useSharedClock.js';
import RoverLabel from '../../../RoverLabel/index.jsx';
import CornerPodToggle from './CornerPodToggle.jsx';
import ExpansionToggle from './ExpansionToggle.jsx';
import usePodVisibility from './usePodVisibility.js';
export default function TopLeftPod({ roverId }) {
const [timerOpen, setTimerOpen] = usePodVisibility('turnTimer', true);
const [nameOpen, setNameOpen] = usePodVisibility('roverName', true);
const mode = useSessionSelector((state) => state.session?.mode || null);
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const turnInfo = useSessionSelector((state) => state.session?.turnQueues?.[roverId] || null);
const activeDriverId = useSessionSelector((state) => state.session?.activeDrivers?.[roverId] || null);
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
const deadline = turnInfo?.deadline || null;
const idleDeadline = turnInfo?.idleDeadline || null;
const queue = turnInfo?.queue || [];
// Direct ownership is published independently from the detailed queue and is
// therefore the reliable initial-load/reconnect source for the current turn.
const currentDriverId = activeDriverId || turnInfo?.current || null;
const currentIndex = currentDriverId ? queue.indexOf(currentDriverId) : -1;
const userIndex = socketId ? queue.indexOf(socketId) : -1;
const turnActive = mode === 'turns' && queue.length > 1 && currentIndex >= 0 && userIndex >= 0;
const hasTurnDeadline = Boolean(turnActive && deadline);
const now = useSharedClock(1000, hasTurnDeadline);
const currentTurnSeconds = hasTurnDeadline ? Math.max(0, Math.ceil((deadline - now) / 1000)) : null;
const idleSkipSeconds = idleDeadline ? Math.max(0, Math.ceil((idleDeadline - now) / 1000)) : null;
const turnsAhead = turnActive ? (userIndex - currentIndex + queue.length) % queue.length : null;
const seconds = currentTurnSeconds == null || turnsAhead == null
? null
: currentTurnSeconds + Math.max(0, turnsAhead - 1) * 60;
const isCurrentTurn = turnsAhead === 0;
const isWaitingForTurn = turnActive && !isCurrentTurn;
const [showTurnCue, setShowTurnCue] = useState(false);
const [turnCueStartedAt, setTurnCueStartedAt] = useState(null);
const previousCurrentRef = useRef(null);
useEffect(() => {
const wasCurrent = previousCurrentRef.current;
previousCurrentRef.current = isCurrentTurn;
let timer = 0;
if (turnActive && isCurrentTurn && wasCurrent !== true) {
const startedAt = Date.now();
/*
A direct-load current turn and a live ownership handoff both need the same cue. Defer
the visual state update by one task to remain compatible with the repo's React Compiler
rules while preserving the actual handoff timestamp for the minimum display period.
*/
timer = setTimeout(() => {
setTurnCueStartedAt(startedAt);
setShowTurnCue(true);
}, 0);
} else if ((!turnActive || !isCurrentTurn) && showTurnCue) {
timer = setTimeout(() => {
setShowTurnCue(false);
setTurnCueStartedAt(null);
}, 0);
}
return () => {
if (timer) clearTimeout(timer);
};
}, [isCurrentTurn, showTurnCue, turnActive]);
useEffect(() => {
if (!showTurnCue || !turnCueStartedAt || lastControlIntentAt <= turnCueStartedAt) return undefined;
/*
The first real control intent proves the user has noticed and started driving. Keep the
cue for at least two seconds anyway, then return to the compact timer. With no input the
cue remains large so the live server idle-skip deadline cannot be overlooked.
*/
const remainingMinimumMs = Math.max(0, 2000 - (Date.now() - turnCueStartedAt));
const timer = setTimeout(() => setShowTurnCue(false), remainingMinimumMs);
return () => clearTimeout(timer);
}, [lastControlIntentAt, showTurnCue, turnCueStartedAt]);
const gaugePercent = useMemo(() => {
if (seconds == null) return 0;
/*
The server's rover turns are sixty seconds long. Waiting users need one complete
turn added for each driver between the current driver and themselves. A complete
queue rotation is a stable scale across handoffs, so the ring drains continuously
instead of jumping back to full when the current driver changes.
*/
const rotationSeconds = Math.max(60, queue.length * 60);
return Math.max(0, Math.min(1, seconds / rotationSeconds));
}, [queue.length, seconds]);
const visibleGaugePercent = showTurnCue && idleSkipSeconds != null
// The server's initial inactivity grace is seven seconds. Mirroring that known lifecycle
// makes the enlarged ring itself reinforce the prominent skip countdown in the center.
? Math.max(0, Math.min(1, idleSkipSeconds / 7))
: gaugePercent;
const showTimer = Boolean(turnActive && timerOpen);
const showLargeTimer = isWaitingForTurn || showTurnCue;
const timerLabel = seconds == null
// Ownership should remain visible while the detailed deadline is in flight.
? isCurrentTurn ? 'Your turn' : 'Waiting'
: seconds >= 60
? `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
: `${seconds}s`;
return (
<div className={`pointer-events-auto absolute left-0 top-0 flex items-start ${showLargeTimer ? 'z-[100]' : 'z-20'}`}>
{showTimer ? (
<div
className={`relative flex items-center justify-center transition-[width,height,border-radius,background-color] duration-500 ease-out motion-reduce:transition-none ${
showLargeTimer
// The waiting/handoff state replaces the old full-screen turn cue.
// It must be opaque and above every other in-video HUD surface so
// sensor graphics, chat, and docking controls cannot muddy the text.
? 'h-[25.5rem] w-[25.5rem] rounded-br-[12.75rem] bg-black'
: 'h-[8.5rem] w-[8.5rem] rounded-br-[4.25rem] bg-black/60'
}`}
>
{/* The SVG fills the shell. Its circle geometry supplies the same slim visible inset
used by the other pods instead of stacking SVG padding on top of shell padding. */}
<svg className="h-full w-full -rotate-90" viewBox="0 0 100 100" aria-hidden="true">
<circle cx="50" cy="50" r="41" fill="none" stroke="#334155" strokeWidth="10" />
<circle cx="50" cy="50" r="41" fill="none" stroke={showTurnCue ? '#fbbf24' : '#38bdf8'} strokeWidth="10" strokeLinecap="round" pathLength="1" strokeDasharray={`${visibleGaugePercent} 1`} />
</svg>
<span className={`absolute flex flex-col items-center text-center text-white ${showLargeTimer ? 'max-w-[55%]' : 'max-w-[70%]'}`}>
{/* The handoff text replaces the retired desktop full-screen TurnsOverlay.
Typography grows with the pod, while the corner toggle deliberately remains
fixed-size so it never becomes a giant obstruction over the video. */}
{showLargeTimer ? (
<>
<span className={`mb-2 text-[1.75rem] font-bold leading-tight transition-colors duration-300 ${showTurnCue ? 'text-amber-200' : 'text-sky-100'}`}>
{showTurnCue ? 'Its your turn!' : 'Someone else is driving'}
</span>
<strong className="text-[3.375rem] leading-none">
{showTurnCue && idleSkipSeconds != null ? `${idleSkipSeconds}s` : timerLabel}
</strong>
<span className={`mt-2 text-[1rem] font-semibold leading-tight ${showTurnCue ? 'text-amber-200' : 'text-sky-200'}`}>
{showTurnCue
? idleSkipSeconds != null
? 'Start driving or your turn will be skipped'
: 'Youre driving'
: 'until your turn'}
</span>
</>
) : (
<>
<strong className="text-lg leading-none">{timerLabel}</strong>
{seconds != null ? <span className="mt-1 text-[0.6rem] font-semibold text-sky-200">left</span> : null}
</>
)}
</span>
<CornerPodToggle corner="top-left" expanded label="Hide turn timer" onClick={() => setTimerOpen(false)} />
</div>
) : turnActive ? (
<CornerPodToggle corner="top-left" expanded={false} label="Show turn timer" onClick={() => setTimerOpen(true)} />
) : null}
{/* The rover name is an independent edge expansion. Its visibility control lives in
the expansion itself, and its position naturally moves into the corner whenever
the conditional timer pod is absent or manually collapsed. */}
{nameOpen ? (
<div className={`flex h-11 items-center gap-2 bg-black/60 px-2 ${showTimer ? '' : 'rounded-br-xl'}`}>
<RoverLabel roverId={roverId} fallback={roverId} className="px-2 py-1 text-base" />
<ExpansionToggle direction="up" label="Hide rover name" onClick={() => setNameOpen(false)} />
</div>
) : (
<div className={`flex h-3 w-8 bg-black/60 ${showTimer ? '' : 'ml-10'}`}>
<ExpansionToggle direction="down" label="Show rover name" onClick={() => setNameOpen(true)} />
</div>
)}
</div>
);
}
@@ -0,0 +1,192 @@
// Top-right Corner Pod
// Purpose: Combines the battery/current gauge with a compact attached advanced-power expansion.
import { createElement, useMemo } from 'react';
import { FaArrowDown, FaArrowUp, FaBatteryHalf, FaBolt, FaExclamationTriangle, FaMemory, FaThermometerHalf, FaWifi } from 'react-icons/fa';
import { useControlSelector } from '../../../../controls/index.js';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
import { hostStatsEqual, selectHostStats, selectSpectatorTelemetry, spectatorTelemetryEqual } from '../../../../context/telemetryViews.js';
import CornerPodToggle from './CornerPodToggle.jsx';
import ExpansionToggle from './ExpansionToggle.jsx';
import usePodVisibility from './usePodVisibility.js';
function finite(value) {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function clampPercent(value) {
const number = finite(value);
return number == null ? 0 : Math.max(0, Math.min(100, number));
}
function MetricRow({ icon, label, value, percent, iconClass, fillClass }) {
return (
<div className="min-w-0" title={label}>
<div className="flex items-center gap-1">
{createElement(icon, { className: `shrink-0 text-[0.65rem] ${iconClass}`, 'aria-hidden': true })}
<span className="min-w-0 flex-1 truncate text-[0.62rem] leading-none text-slate-200">{label}</span>
<strong className="shrink-0 text-[0.62rem] leading-none text-white">{value}</strong>
</div>
{/* Every meter uses an explicit real-world display range defined by its caller. The bar
therefore adds information instead of merely decorating the latest numeric value.
Keeping it on its own line gives both the title and meter the full panel width. */}
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-slate-700">
<div className={`h-full rounded-full ${fillClass}`} style={{ width: `${clampPercent(percent)}%` }} />
</div>
</div>
);
}
function WifiTile({ signal }) {
const bars = signal == null ? 0 : signal >= -55 ? 4 : signal >= -65 ? 3 : signal >= -75 ? 2 : 1;
const tone = signal == null ? 'bg-slate-600' : signal < -80 ? 'bg-red-400' : signal < -70 ? 'bg-amber-400' : 'bg-emerald-400';
return (
<div className="min-w-0" title="Wi-Fi signal strength">
<div className="flex items-center gap-1">
<FaWifi className={`text-[0.65rem] ${signal == null ? 'text-slate-400' : 'text-emerald-300'}`} aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-[0.62rem] leading-none text-slate-200">Wi-Fi signal</span>
<strong className="shrink-0 text-[0.62rem] leading-none text-white">{signal == null ? '--' : `${Math.round(signal)} dBm`}</strong>
</div>
<div className="mt-1 flex h-2 items-end gap-0.5" aria-hidden="true">
{[1, 2, 3, 4].map((bar) => (
<span key={bar} className={`flex-1 rounded-sm ${bar <= bars ? tone : 'bg-slate-700'}`} style={{ height: `${25 * bar}%` }} />
))}
</div>
</div>
);
}
function SpeedTile({ icon, label, value, colorClass }) {
return (
<div className="flex min-w-0 items-center gap-1" title={label}>
{createElement(icon, { className: colorClass, 'aria-hidden': true })}
<span className="min-w-0 flex-1 truncate text-[0.62rem] leading-none text-slate-200">{label}</span>
<strong className="shrink-0 text-[0.62rem] leading-none text-white">{value}</strong>
</div>
);
}
export default function TopRightPod({ roverId }) {
const [batteryOpen, setBatteryOpen] = usePodVisibility('battery', true);
const [powerOpen, setPowerOpen] = usePodVisibility('advancedPower', false);
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
const batteryState = useSessionSelector((state) => {
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
return rover?.batteryState || null;
});
const electrical = useTelemetrySelector(roverId, selectSpectatorTelemetry, spectatorTelemetryEqual);
const host = useTelemetrySelector(powerOpen ? roverId : null, selectHostStats, hostStatsEqual);
const percent = Math.max(0, Math.min(100, finite(batteryState?.percentDisplay) ?? 0));
const current = finite(electrical?.currentMa) ?? 0;
const currentPercent = Math.max(0, Math.min(1, Math.abs(current) / 2500));
const urgentBattery = Boolean(batteryState?.urgentActive);
const lowBattery = Boolean(batteryState?.warnActive || urgentBattery);
// Warning and urgent are separate server-owned thresholds. Amber gives the first threshold
// a clear but calm identity; red is reserved for the genuinely time-sensitive state.
const batteryTone = urgentBattery ? '#ef4444' : lowBattery ? '#f59e0b' : '#22c55e';
const currentTone = current < 0 ? '#f59e0b' : '#22c55e';
const circumference = 2 * Math.PI * 42;
const currentCircumference = 2 * Math.PI * 32;
const batteryDash = useMemo(() => `${(percent / 100) * circumference} ${circumference}`, [circumference, percent]);
const wifi = host?.wifi || {};
const signal = finite(wifi.signalDbm);
const voltage = finite(electrical?.voltageMv);
const batteryCharge = finite(electrical?.batteryChargeMah);
const batteryCapacity = finite(electrical?.batteryCapacityMah);
const cpuTemp = finite(host?.cpuTempC);
const memoryUsed = finite(host?.memoryUsedPct);
const voltagePercent = voltage == null ? 0 : ((voltage - 12000) / 5000) * 100;
const batteryMahPercent = batteryCharge != null && batteryCapacity > 0 ? (batteryCharge / batteryCapacity) * 100 : 0;
const cpuTempPercent = cpuTemp == null ? 0 : ((cpuTemp - 30) / 55) * 100;
const cpuTempTone = cpuTemp >= 80 ? 'bg-red-400' : cpuTemp >= 70 ? 'bg-amber-400' : 'bg-emerald-400';
const memoryTone = memoryUsed >= 90 ? 'bg-red-400' : memoryUsed >= 75 ? 'bg-amber-400' : 'bg-violet-400';
const download = finite(wifi.downloadMbps);
const upload = finite(wifi.uploadMbps);
const docked = Boolean(electrical?.homeBase);
const chargingLabel = String(electrical?.chargingStateLabel || '').toLowerCase();
const charging = docked && chargingLabel !== '' && chargingLabel !== 'not charging';
const autoDocking = !docked && !dockAssistActive && String(electrical?.oiModeLabel || '').toLowerCase() === 'passive';
// The warning describes the next useful fact instead of blindly telling every user to dock.
// This matters during assist, autonomous docking, and charging, where the old overlay's generic
// instruction was either redundant or actively misleading.
let warningMessage = urgentBattery ? 'Battery critical · Dock now' : 'Battery low · Dock soon';
if (charging) {
warningMessage = urgentBattery ? 'Battery critical · Charging' : 'Battery low · Charging';
} else if (docked) {
warningMessage = urgentBattery ? 'Battery critical · On dock' : 'Battery low · On dock';
} else if (dockAssistActive) {
warningMessage = urgentBattery ? 'Battery critical · Continue docking' : 'Battery low · Continue docking';
} else if (autoDocking) {
warningMessage = urgentBattery ? 'Battery critical · Returning to dock' : 'Battery low · Returning to dock';
}
return (
<div className="pointer-events-auto absolute right-0 top-0 z-20 flex flex-col items-end">
{batteryOpen ? (
<div className="relative flex h-[8.5rem] w-[8.5rem] items-center justify-center rounded-bl-[4.25rem] bg-black/60">
{/* Let the gauge geometry define the visible inset so this pod does not carry an
extra layer of shell padding that the camera pod does not have. */}
<svg className="h-[8.5rem] w-[8.5rem] -rotate-90" viewBox="0 0 100 100" aria-hidden="true">
<circle cx="50" cy="50" r="42" fill="none" stroke="#334155" strokeWidth="9" />
<circle cx="50" cy="50" r="42" fill="none" stroke={batteryTone} strokeWidth="9" strokeLinecap="round" strokeDasharray={batteryDash} />
<circle cx="50" cy="50" r="32" fill="none" stroke="#334155" strokeWidth="5" />
<circle cx="50" cy="50" r="32" fill="none" stroke={currentTone} strokeWidth="5" strokeLinecap="round" strokeDasharray={`${currentPercent * currentCircumference} ${currentCircumference}`} />
</svg>
{/* Keeping the icon and value in one centered stack makes the gauge's
meaning obvious without changing either circular telemetry ring. */}
<span className="absolute flex flex-col items-center justify-center gap-0.5 text-white">
{lowBattery ? (
<FaExclamationTriangle className={urgentBattery ? 'text-lg text-red-300' : 'text-lg text-amber-300'} aria-hidden="true" />
) : (
<FaBatteryHalf className="text-lg" aria-hidden="true" />
)}
<strong className="text-xl leading-none">{percent}%</strong>
</span>
<CornerPodToggle corner="top-right" expanded label="Hide battery pod" onClick={() => setBatteryOpen(false)} />
</div>
) : (
<CornerPodToggle corner="top-right" expanded={false} label="Show battery pod" onClick={() => setBatteryOpen(true)} />
)}
{/* This is status, not another docking control. Keeping it attached to the battery pod
preserves one canonical Dock action while still making the reason for urgency obvious. */}
{lowBattery ? (
<div
className={`absolute top-12 flex items-center gap-1.5 whitespace-nowrap rounded-l px-2.5 py-1.5 text-xs font-bold text-white transition-[right,background-color] ${
batteryOpen ? 'right-[8.5rem]' : 'right-0'
} ${
urgentBattery ? 'bg-red-950' : 'bg-amber-950'
}`}
role="status"
>
<FaExclamationTriangle className={urgentBattery ? 'text-red-300' : 'text-amber-300'} aria-hidden="true" />
<span>{warningMessage}</span>
</div>
) : null}
{/* Advanced power is an independently persisted right-edge expansion. Its own arrow is
retained when closed, and the whole panel moves into the corner if the pod closes. */}
{powerOpen ? (
<div className={`absolute right-0 w-56 rounded-bl-xl bg-black/60 p-1.5 pl-4 text-white ${batteryOpen ? 'top-[8.5rem]' : 'top-0'}`}>
<ExpansionToggle direction="right" label="Hide power and computer" onClick={() => setPowerOpen(false)} className="absolute left-0 top-1/2 -translate-y-1/2" />
<div className="space-y-1.5">
<MetricRow icon={FaBolt} label="Roomba voltage" value={voltage == null ? '--' : `${(voltage / 1000).toFixed(1)} V`} percent={voltagePercent} iconClass="text-sky-300" fillClass="bg-sky-400" />
<MetricRow icon={FaBolt} label="Roomba current" value={`${current > 0 ? '+' : ''}${Math.round(current)} mA`} percent={currentPercent * 100} iconClass={current < 0 ? 'text-amber-300' : 'text-emerald-300'} fillClass={current < 0 ? 'bg-amber-400' : 'bg-emerald-400'} />
<MetricRow icon={FaBatteryHalf} label="Battery charge" value={batteryCharge == null ? '--' : `${Math.round(batteryCharge)} mAh`} percent={batteryMahPercent} iconClass="text-emerald-300" fillClass="bg-emerald-400" />
<MetricRow icon={FaThermometerHalf} label="Computer temperature" value={cpuTemp == null ? '--' : `${cpuTemp.toFixed(1)} C`} percent={cpuTempPercent} iconClass={cpuTemp >= 80 ? 'text-red-300' : cpuTemp >= 70 ? 'text-amber-300' : 'text-emerald-300'} fillClass={cpuTempTone} />
<MetricRow icon={FaMemory} label="Memory usage" value={memoryUsed == null ? '--' : `${Math.round(memoryUsed)}%`} percent={memoryUsed} iconClass={memoryUsed >= 90 ? 'text-red-300' : memoryUsed >= 75 ? 'text-amber-300' : 'text-violet-300'} fillClass={memoryTone} />
<WifiTile signal={signal} />
<SpeedTile icon={FaArrowDown} label="Download speed" value={download == null ? '--' : `${download.toFixed(1)} Mb/s`} colorClass="text-sky-300" />
<SpeedTile icon={FaArrowUp} label="Upload speed" value={upload == null ? '--' : `${upload.toFixed(1)} Mb/s`} colorClass="text-violet-300" />
</div>
</div>
) : (
<div className={`absolute right-0 flex h-8 w-3 bg-black/60 ${batteryOpen ? 'top-[8.5rem]' : 'top-10'}`}>
<ExpansionToggle direction="left" label="Show power and computer" onClick={() => setPowerOpen(true)} />
</div>
)}
</div>
);
}
@@ -0,0 +1,25 @@
// New Drive Corner Pods
// Purpose: Composes the four independently owned corner controls around the shared video stage.
import TopLeftPod from './TopLeftPod.jsx';
import TopRightPod from './TopRightPod.jsx';
import BottomLeftPod from './BottomLeftPod.jsx';
import BottomRightPod from './BottomRightPod.jsx';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
export default function CornerPods({ roverId }) {
const layout = useDriverLayout();
const showPhysicalControlPods = layout === 'desktop';
return (
<>
<TopLeftPod roverId={roverId} />
<TopRightPod roverId={roverId} />
{/* The mobile layouts already provide large touch controls around the video.
Omitting this pod avoids presenting duplicate horn, light, and laser actions. */}
{showPhysicalControlPods ? <BottomLeftPod roverId={roverId} /> : null}
{/* BottomRightPod also owns the independent chat expansion, so it remains mounted
on mobile and determines its own camera-control visibility from layout context. */}
<BottomRightPod roverId={roverId} />
</>
);
}
@@ -0,0 +1,14 @@
// Corner Pod Visibility
// Purpose: Persists each independent pod or expansion without coupling unrelated corner controls.
import { useCallback } from 'react';
import { useSettingsNamespace } from '../../../../settings/index.js';
export default function usePodVisibility(key, defaultOpen = true) {
const { value, save } = useSettingsNamespace('newdrivePods', {});
const open = value?.[key] == null ? defaultOpen : value[key] !== false;
const setOpen = useCallback(
(nextOpen) => save((current) => ({ ...(current || {}), [key]: Boolean(nextOpen) })),
[key, save],
);
return [open, setOpen];
}
@@ -0,0 +1,392 @@
// New Generation Docking HUD
// Purpose: Provides a single low-friction transition between docked, driving, and manual docking.
// Scope: Owns presentation and the existing manual-assist lifecycle for the current driver HUD;
// the archived desktop layout retains its previous DriveDockAction behavior.
import { useCallback, useEffect, useRef, useState } from 'react';
import { FaChargingStation, FaChevronDown } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/telemetryViews.js';
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js';
import { useSettingsNamespace } from '../../../../settings/index.js';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
import ExpansionToggle from '../CornerPods/ExpansionToggle.jsx';
import usePodVisibility from '../CornerPods/usePodVisibility.js';
function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndock }) {
const waitingForTurn = controlsDisabled && !pending;
return (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center p-6">
<button
type="button"
disabled={pending || controlsDisabled}
onClick={onUndock}
className={`pointer-events-auto flex w-[min(32rem,80%)] flex-col items-center gap-2 px-8 py-7 text-center text-white shadow-2xl ring-2 transition focus-visible:outline-none focus-visible:ring-4 ${
waitingForTurn
? 'cursor-not-allowed bg-slate-950/95 ring-slate-400/70'
: 'bg-emerald-950/90 ring-emerald-300/80 hover:bg-emerald-900/95 focus-visible:ring-emerald-200 disabled:cursor-wait disabled:opacity-75'
}`}
>
<strong className="text-3xl leading-tight">{pending ? 'Undocking…' : 'Your rover is docked'}</strong>
{pending ? (
null
) : waitingForTurn ? (
/* A disabled action must explain the ownership constraint instead of
continuing to advertise a click and keybind that cannot succeed. */
<span className="text-lg font-semibold leading-snug text-slate-300">
Wait for your turn to undock.
</span>
) : (
<span className="text-lg font-semibold leading-snug text-emerald-50">
Click here
{driveKeyLabel ? (
<>
{' '}or press <KeyPill label={driveKeyLabel} />
</>
) : null}
{' '}to undock and drive the rover
</span>
)}
{error ? <span className="text-sm font-semibold text-red-200">{error}</span> : null}
</button>
</div>
);
}
function AutoDockingAction({ driveKeyLabel, pending, controlsDisabled, error, onResumeDriving }) {
const waitingForTurn = controlsDisabled && !pending;
return (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center p-6">
<button
type="button"
disabled={pending || controlsDisabled}
onClick={onResumeDriving}
className={`pointer-events-auto flex w-[min(30rem,80%)] flex-col items-center gap-2 px-7 py-6 text-center text-white shadow-2xl ring-2 transition focus-visible:outline-none focus-visible:ring-4 ${
waitingForTurn
? 'cursor-not-allowed bg-slate-950/95 ring-slate-400/70'
: 'bg-amber-950/90 ring-amber-300/80 hover:bg-amber-900/95 focus-visible:ring-amber-200 disabled:cursor-wait disabled:opacity-75'
}`}
>
<strong className="text-3xl leading-tight">
{pending ? 'Starting driving…' : 'Rover is docking itself'}
</strong>
{pending ? null : waitingForTurn ? (
/* Automatic docking is still important context when another user owns
the turn, but the recovery action must not imply that it is available. */
<span className="text-lg font-semibold leading-snug text-slate-300">
Wait for your turn to resume driving.
</span>
) : (
<span className="text-lg font-semibold leading-snug text-amber-50">
Click here
{driveKeyLabel ? (
<>
{' '}or press <KeyPill label={driveKeyLabel} />
</>
) : null}
{' '}to stop automatic docking and resume driving
</span>
)}
{error ? <span className="text-sm font-semibold text-red-200">{error}</span> : null}
</button>
</div>
);
}
function DockAssistAction({ active, pending, controlsDisabled, error, dockKeyLabel, onDock, onCancel, cornerOffsetClass, open, onOpenChange, batterySeverity }) {
const batteryUrgent = batterySeverity === 'urgent';
const batteryLow = batterySeverity === 'low';
if (!open) {
return (
<button
type="button"
aria-label="Show rover docking control"
title="Dock rover"
onClick={() => onOpenChange(true)}
className={`pointer-events-auto absolute top-0 z-20 flex h-6 w-10 items-center justify-center gap-1 rounded-bl text-[0.6rem] transition ${
batteryUrgent
? 'bg-red-950 text-red-100 hover:bg-red-900'
: batteryLow
? 'bg-amber-950 text-amber-100 hover:bg-amber-900'
: 'bg-indigo-950/75 text-indigo-100 hover:bg-indigo-900'
} ${cornerOffsetClass === 'right-0' ? 'right-10' : cornerOffsetClass}`}
>
{/* The collapsed tab retains feature identity instead of becoming an
anonymous expansion arrow whose purpose must be remembered. */}
<FaChargingStation aria-hidden="true" />
<FaChevronDown aria-hidden="true" />
</button>
);
}
if (active) {
return (
<div className="pointer-events-none absolute inset-0 z-[60] flex items-center justify-center">
{/* Once assist is active, the camera image is the user's task context. Centering this
one-line instruction connects it to that view instead of leaving guidance beside the
corner button that already completed its action. */}
<div className="pointer-events-auto flex items-center gap-2 rounded bg-cyan-950/60 p-1.5 text-cyan-50 shadow-xl ring-2 ring-cyan-200/90">
<strong className="whitespace-nowrap text-sm">Dock assist is active, drive forward onto the dock.</strong>
<button
type="button"
onClick={onCancel}
disabled={controlsDisabled}
className="bg-slate-800 px-2 py-1 text-xs font-bold text-white transition hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-200 disabled:cursor-not-allowed disabled:opacity-40"
>
Cancel
</button>
{error ? <span className="text-xs font-semibold text-red-200">{error}</span> : null}
</div>
</div>
);
}
return (
<div className={`pointer-events-auto absolute top-0 z-20 flex items-stretch ${cornerOffsetClass}`}>
<ExpansionToggle direction="up" label="Hide dock controls" onClick={() => onOpenChange(false)} />
<button
type="button"
aria-label="Start rover docking assist"
disabled={pending || controlsDisabled}
onClick={onDock}
className={`flex items-center gap-1.5 rounded-bl-xl px-4 py-2 text-base font-bold shadow-xl ring-1 transition focus-visible:outline-none focus-visible:ring-2 disabled:cursor-wait disabled:opacity-75 ${
batteryUrgent
? 'bg-red-950 text-red-50 ring-red-300/80 hover:bg-red-900 focus-visible:ring-red-200'
: batteryLow
? 'bg-amber-950 text-amber-50 ring-amber-300/80 hover:bg-amber-900 focus-visible:ring-amber-200'
: 'bg-indigo-950/60 text-indigo-50 ring-indigo-300/70 hover:bg-indigo-900 focus-visible:ring-indigo-200'
}`}
>
<FaChargingStation className="shrink-0" aria-hidden="true" />
<span>{pending ? 'Starting…' : batteryUrgent ? 'Dock now' : batteryLow ? 'Dock soon' : 'Dock rover'}</span>
{dockKeyLabel && !pending ? <KeyPill label={dockKeyLabel} /> : null}
</button>
{/* The expansion toggle stays on the far side of this panel so the battery pod's
triangular control remains unobstructed when both occupy the top-right area. */}
{error ? <div className="mt-2 max-w-64 bg-red-950/90 px-3 py-2 text-sm font-semibold text-red-100">{error}</div> : null}
</div>
);
}
function UndockTransitionGhost({ onFinish }) {
const ghostRef = useRef(null);
useEffect(() => {
const element = ghostRef.current;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!element || reducedMotion || typeof element.animate !== 'function') {
onFinish();
return undefined;
}
/*
This intentionally animates a disposable visual copy instead of trying to morph the
centered action into the differently structured corner control. Moving left/top while
scaling and fading is inexpensive, communicates where Dock moved, and needs no viewport
measurements or persistent layout state.
*/
const animation = element.animate(
[
{
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%) scale(1)',
opacity: 1,
},
{
left: 'calc(100% - 10rem)',
top: '0',
transform: 'translate(0, 0) scale(0.2)',
opacity: 0,
},
],
{
duration: 850,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'forwards',
},
);
animation.onfinish = onFinish;
return () => {
animation.onfinish = null;
animation.cancel();
};
}, [onFinish]);
return (
<div
ref={ghostRef}
className="pointer-events-none absolute z-40 flex w-[min(32rem,80%)] origin-top-left flex-col items-center gap-2 bg-emerald-950/90 px-8 py-7 text-center text-white shadow-2xl ring-2 ring-emerald-300/80"
style={{ left: '50%', top: '50%', transform: 'translate(-50%, -50%) scale(1)' }}
aria-hidden="true"
>
<strong className="text-3xl leading-tight">Rover docked</strong>
<span className="text-lg font-semibold text-emerald-50">Dock control moved here</span>
</div>
);
}
export default function DockingHud({ roverId }) {
const layout = useDriverLayout();
const actions = useControlActions();
const keymap = useControlSelector((control) => control.state.keymap);
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
// This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the
// success sounds, camera positioning, speed cap, and automatic exit after charging begins.
const dockAssist = useManualDockAssist({ manageLifecycle: true });
const canControl = useCanControlRover(roverId);
const batteryState = useSessionSelector((state) => {
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
return rover?.batteryState || null;
});
// DockingHud already owns the canonical desktop docking action, so battery urgency only
// changes that action's emphasis and wording instead of introducing a competing button.
const batterySeverity = batteryState?.urgentActive ? 'urgent' : batteryState?.warnActive ? 'low' : null;
const { value: podSettings } = useSettingsNamespace('newdrivePods', {});
const [dockExpansionOpen, setDockExpansionOpen] = usePodVisibility('dockAssist', true);
// The action name is presentation state as well as busy state. Keeping the
// reason prevents a passive, already-undocked rover from ever saying "Undocking".
const [pendingAction, setPendingAction] = useState(null);
const [error, setError] = useState('');
const [showUndockTransition, setShowUndockTransition] = useState(false);
const docked = Boolean(dockTelemetry?.homeBase);
const oiMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase();
// The established UI contract treats exactly passive + undocked as the Roomba's
// autonomous docking attempt. Unknown telemetry must not fabricate that state.
const autoDocking = !docked && !dockAssist.active && oiMode === 'passive';
const pending = pendingAction !== null;
/* Mobile already presents its own touch-oriented driving controls. The docked
action therefore keeps its plain-language instruction without advertising a
keyboard shortcut that is irrelevant on that layout. */
const driveKeyLabel = layout === 'desktop'
? formatKeyLabel(keymap?.driveMacro?.[0])
: '';
const dockKeyLabel = layout === 'desktop'
? formatKeyLabel(keymap?.dockMacro?.[0])
: '';
const batteryPodOpen = podSettings?.battery !== false;
// The camera arc is the shared circular-pod reference size. Keep the dock expansion flush
// against the battery shell after enlarging that gauge to the same 8.5-rem footprint.
const cornerOffsetClass = batteryPodOpen ? 'right-[8.5rem]' : 'right-0';
const previousDockedRef = useRef(docked);
const finishUndockTransition = useCallback(() => setShowUndockTransition(false), []);
useEffect(() => {
const wasDocked = previousDockedRef.current;
// Only a real live transition plays the cue. An already-undocked rover must not animate
// merely because the user opened or refreshed the page.
if (wasDocked && !docked) {
setShowUndockTransition(true);
} else if (docked) {
setShowUndockTransition(false);
}
previousDockedRef.current = docked;
}, [docked]);
const startDriving = async (action) => {
if (!roverId || pending || !canControl) return;
setPendingAction(action);
setError('');
try {
// The established drive sequence is the canonical undock path: it restores the camera,
// enters full Open Interface mode, and performs the short physical back-away from the dock.
dockAssist.exitAssist();
actions.setMode('drive');
await actions.runMacro('drive-sequence');
} catch (caughtError) {
setError(caughtError?.message || 'Unable to start driving. Please try again.');
} finally {
setPendingAction(null);
}
};
const startUndocking = () => {
startDriving('undocking');
};
const resumeDriving = () => {
startDriving('resuming');
};
const startDocking = () => {
if (!roverId || pending || !canControl) return;
setError('');
try {
// Enter on the first click. The explanation appears as the resulting active state instead
// of forcing the user through a modal and a second confirmation action.
dockAssist.enterAssist();
} catch (caughtError) {
setError(caughtError?.message || 'Unable to start dock assist. Please try again.');
}
};
return (
<>
{/* This single layer both dims and blocks the ordinary rover HUD. A passive
undocked rover is still moving autonomously, so it gets a lighter but equally
blocking shield. Explicit pending state keeps the correct shield mounted until
the complete drive sequence finishes even as telemetry changes underneath it. */}
<div
className={`absolute inset-0 z-[25] transition-all duration-300 ${
docked || pendingAction === 'undocking'
? 'pointer-events-auto bg-black/75 opacity-100'
: autoDocking || pendingAction === 'resuming'
? 'pointer-events-auto bg-black/55 opacity-100'
: 'pointer-events-none opacity-0'
}`}
aria-hidden="true"
/>
{docked || pendingAction === 'undocking' ? (
<DockedAction
driveKeyLabel={driveKeyLabel}
pending={pendingAction === 'undocking'}
controlsDisabled={!canControl}
error={error}
onUndock={startUndocking}
/>
) : autoDocking || pendingAction === 'resuming' ? (
<AutoDockingAction
driveKeyLabel={driveKeyLabel}
pending={pendingAction === 'resuming'}
controlsDisabled={!canControl}
error={error}
onResumeDriving={resumeDriving}
/>
) : (
<>
{dockAssist.active ? (
/* Dock assist needs the unobscured camera image. A thin cyan frame communicates the
temporary mode without adding another instruction surface over the video. */
<div className="pointer-events-none absolute inset-0 z-50 ring-8 ring-inset ring-cyan-200/90" aria-hidden="true" />
) : null}
{/* Desktop keeps the compact corner entry point. Mobile starts assist from
its dedicated control column, but once active it still mounts this component
so the centered camera instruction and cancel action remain available. */}
{layout === 'desktop' || dockAssist.active ? (
<DockAssistAction
active={dockAssist.active}
pending={pending}
controlsDisabled={!canControl}
error={error}
dockKeyLabel={dockKeyLabel}
onDock={startDocking}
onCancel={dockAssist.exitAssist}
cornerOffsetClass={cornerOffsetClass}
open={dockExpansionOpen}
onOpenChange={setDockExpansionOpen}
batterySeverity={batterySeverity}
/>
) : null}
{showUndockTransition ? <UndockTransitionGhost onFinish={finishUndockTransition} /> : null}
</>
)}
</>
);
}
@@ -0,0 +1,109 @@
// New Generation IR Proximity HUD
// Purpose: Projects the six light-bump sensors as cones above their physical bumper positions.
// Scope: Mirrors the top-down map's signal geometry without retaining history or smoothing telemetry.
import { memo } from 'react';
import { useVisualTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
import {
lightBumpTelemetryEqual,
selectLightBumpTelemetry,
} from '../../../../context/telemetryViews.js';
import { IR_SENSOR_GEOMETRY } from '../BottomSensorHud/geometry.js';
import './styles.css';
const IR_NOISE_THRESHOLD = 40;
const IR_FULL_STRENGTH = 1200;
const FAR_CONE_LENGTH = 105;
const NEAR_CONE_LENGTH = 12;
const STRIPE_COUNT = 7;
const FADE_IN_RANGE = 0.1;
const MAX_CONE_OPACITY = 1;
const RED_POINT = 0.75;
function clamp01(value) {
return Math.max(0, Math.min(1, value));
}
function buildStripePath(sensor, length) {
const perpendicularX = -sensor.directionY;
const perpendicularY = sensor.directionX;
/*
Keep all seven stripes in one multi-subpath so the browser still manages
only one SVG path per sensor. This function runs only for the memoized
sensor whose raw value changed, preserving the exact tuned width and length
geometry without reconciling 42 separate elements.
*/
return Array.from({ length: STRIPE_COUNT }, (_, index) => {
const fraction = (index + 1) / STRIPE_COUNT;
const stripeDistance = length * fraction;
const centerX = sensor.tipX + sensor.directionX * stripeDistance;
const centerY = sensor.tipY + sensor.directionY * stripeDistance;
const halfWidth = (sensor.width * fraction) / 2;
const leftX = centerX + perpendicularX * halfWidth;
const leftY = centerY + perpendicularY * halfWidth;
const rightX = centerX - perpendicularX * halfWidth;
const rightY = centerY - perpendicularY * halfWidth;
const curveDepth = Math.max(1.5, length * 0.045 * fraction);
const controlX = centerX + sensor.directionX * curveDepth;
const controlY = centerY + sensor.directionY * curveDepth;
return `M ${leftX} ${leftY} Q ${controlX} ${controlY} ${rightX} ${rightY}`;
}).join(' ');
}
function buildPresentation(value) {
const numericValue = Number(value) || 0;
const active = numericValue > IR_NOISE_THRESHOLD;
const normalized = clamp01(
(numericValue - IR_NOISE_THRESHOLD) / (IR_FULL_STRENGTH - IR_NOISE_THRESHOLD),
);
/*
Match the top-down map's display curve. This is an instantaneous spatial
mapping, not temporal smoothing: the current raw value alone determines the
cone length for this frame.
*/
const eased = Math.pow(normalized, 0.35);
const length = FAR_CONE_LENGTH - (FAR_CONE_LENGTH - NEAR_CONE_LENGTH) * eased;
const colorStrength = clamp01(normalized / RED_POINT);
return {
length,
/* Color reaches red independently of distance, making its warning point directly tunable. */
color: `hsl(${120 * (1 - colorStrength)} 90% 50%)`,
/*
Opacity is driven by the raw sensor range, not merely by a CSS transition.
The first fifth of the usable range fades the cone from invisible to its
normal opacity before distance and color become the dominant cues.
*/
opacity: active ? clamp01(normalized / FADE_IN_RANGE) * MAX_CONE_OPACITY : 0,
};
}
const IrSensorCone = memo(function IrSensorCone({ sensor, value }) {
const presentation = buildPresentation(value);
const stripePath = buildStripePath(sensor, presentation.length);
return (
<path
d={stripePath}
className="newgen-ir-proximity-cone"
stroke={presentation.color}
style={{ opacity: presentation.opacity }}
/>
);
});
export default function IrProximityHud({ roverId }) {
const values = useVisualTelemetrySelector(
roverId,
selectLightBumpTelemetry,
lightBumpTelemetryEqual,
);
return (
<g className="newgen-ir-proximity" aria-label="Front infrared proximity sensors">
{IR_SENSOR_GEOMETRY.map((sensor, index) => (
<IrSensorCone key={sensor.key} sensor={sensor} value={values[index]} />
))}
</g>
);
}
@@ -0,0 +1,11 @@
.newgen-ir-proximity-cone {
fill: none;
stroke-width: 6;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
transition: opacity 150ms linear;
}
@media (prefers-reduced-motion: reduce) {
.newgen-ir-proximity-cone { transition: none; }
}
@@ -0,0 +1,43 @@
// New Generation Wheel Drop Indicators
// Purpose: Mirrors the top-down map's red wheel overlay at the left and right video edges.
// Scope: Displays the two physical wheel-drop booleans without generic HUD chrome or control behavior.
import {
shallowObjectEqual,
useVisualTelemetrySelector,
} from '../../../../context/TelemetryContext.jsx';
import './styles.css';
const EMPTY_WHEEL_DROPS = Object.freeze({ left: false, right: false });
function selectWheelDrops(frame) {
const contact = frame?.sensors?.bumpsAndWheelDrops;
if (!contact) return EMPTY_WHEEL_DROPS;
return {
left: Boolean(contact.wheelDropLeft),
right: Boolean(contact.wheelDropRight),
};
}
function WheelDropOverlay({ side }) {
return (
<div
className={`newgen-wheel-drop newgen-wheel-drop--${side}`}
role="alert"
aria-label={`${side} wheel off ground`}
>
{/* Mirrored rotation follows the left/right text orientation used by TopDownMap's wheel glyphs. */}
<span>WHEEL OFF GROUND</span>
</div>
);
}
export default function WheelDropIndicators({ roverId }) {
const drops = useVisualTelemetrySelector(roverId, selectWheelDrops, shallowObjectEqual);
return (
<>
{drops.left ? <WheelDropOverlay side="left" /> : null}
{drops.right ? <WheelDropOverlay side="right" /> : null}
</>
);
}
@@ -0,0 +1,30 @@
.newgen-wheel-drop {
pointer-events: none;
position: absolute;
z-index: 20;
top: 50%;
display: flex;
width: 4rem;
height: 15rem;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
background: rgb(239 68 68 / 0.36);
color: white;
transform: translateY(-50%);
}
.newgen-wheel-drop--left { left: 1rem; }
.newgen-wheel-drop--right { right: 1rem; }
.newgen-wheel-drop span {
white-space: nowrap;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1;
text-shadow: 0 1px 2px rgb(0 0 0 / 0.65);
}
.newgen-wheel-drop--left span { transform: rotate(-90deg); }
.newgen-wheel-drop--right span { transform: rotate(90deg); }
+3 -1
View File
@@ -121,7 +121,9 @@ function LiftCardContent() {
</div>
<section className="surface-muted px-0.5 py-0.5">
<div className="grid grid-cols-1 md:grid-cols-3 gap-0.5">
{/* Stack status and actions in narrow cards, then restore the compact
three-column control row when this card grows wide enough. */}
<div className="grid grid-cols-1 gap-0.5 @md:grid-cols-3">
<div className="rounded-md bg-slate-800 px-1 py-0.75 text-center">
<div className="text-xs text-slate-300">Position</div>
<div className="text-base font-semibold text-slate-100">{positionLabel(position)}</div>
@@ -1,10 +1,14 @@
// Aux Column
// Purpose: Assembles the mobile auxiliary controls column, which is the left column by default.
// Scope: Owns mobile aux/camera/headlight/laser/horn wiring while reusing desktop variation components where intended.
import { useCallback, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
import './mobileControls.css';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import useCanControlRover from '../../hooks/useCanControlRover.js';
import HornControl from '../HornControl/index.jsx';
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
import { AUX_ZERO } from './constants.js';
@@ -27,7 +31,17 @@ function AuxColumnContent() {
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
const { setServoAngle, setHeadlight, setLaser, setAuxMotors, startHorn, stopHorn } = useControlActions();
const dockAssist = useManualDockAssist();
const disabled = !roverId;
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
const canControl = useCanControlRover(roverId);
// Turn ownership is the common mutation boundary for every control in this
// column. Dock and OI state are applied separately only where the hardware
// command itself depends on the Roomba being able to drive.
const controlsDisabled = !roverId || !canControl;
const docked = Boolean(dockTelemetry?.homeBase);
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
const vacuumDisabled = controlsDisabled
|| docked
|| (!drivingMode && !dockAssist.active);
const activeAuxButtonRef = useRef(null);
const cameraConfig = camera?.config;
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
@@ -43,7 +57,7 @@ function AuxColumnContent() {
: typeof cameraConfig?.homeAngle === 'number'
? cameraConfig.homeAngle
: (cameraMin + cameraMax) / 2;
const cameraDisabled = Boolean(disabled || dockAssist.cameraLocked);
const cameraDisabled = Boolean(controlsDisabled || dockAssist.cameraLocked);
/*
The mobile tilt track shares the same precision flag as desktop tilt. This
keeps the servo fine-step behavior tied to the selected movement mode rather
@@ -55,73 +69,80 @@ function AuxColumnContent() {
const handleHeadlightToggle = useCallback(
(nextOn) => {
if (!headlightAvailable) return;
if (!headlightAvailable || controlsDisabled) return;
setHeadlight(nextOn);
},
[headlightAvailable, setHeadlight],
[controlsDisabled, headlightAvailable, setHeadlight],
);
const handleLaserToggle = useCallback(
(nextOn) => {
if (!laserAvailable) return;
if (!laserAvailable || controlsDisabled) return;
setLaser(nextOn);
},
[laserAvailable, setLaser],
[controlsDisabled, laserAvailable, setLaser],
);
const handleHornStart = useCallback(() => {
if (controlsDisabled) return false;
return startHorn();
}, [startHorn]);
}, [controlsDisabled, startHorn]);
const handleAuxPress = useCallback(
(id, values) => {
if (disabled) return;
if (vacuumDisabled) return;
activeAuxButtonRef.current = id;
setAuxMotors(values);
},
[disabled, setAuxMotors],
[setAuxMotors, vacuumDisabled],
);
const handleAuxRelease = useCallback(
(id) => {
if (disabled) return;
if (activeAuxButtonRef.current === id) {
activeAuxButtonRef.current = null;
// Neutral commands must remain available after ownership or OI state is
// lost; otherwise disabling a held control could preserve its last output.
setAuxMotors(AUX_ZERO);
}
},
[disabled, setAuxMotors],
[setAuxMotors],
);
useEffect(() => {
if (!vacuumDisabled || activeAuxButtonRef.current === null) return;
// Pointer cancellation is browser-dependent when a held button becomes
// disabled. Explicitly neutralize the Roomba motors at the state boundary.
activeAuxButtonRef.current = null;
setAuxMotors(AUX_ZERO);
}, [setAuxMotors, vacuumDisabled]);
return (
<div className="mobile-touch-control grid h-full min-h-0 w-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)] gap-0.5 text-slate-100">
<VacuumControls
disabled={disabled}
disabled={vacuumDisabled}
onPress={handleAuxPress}
onRelease={handleAuxRelease}
/>
<div className="mobile-touch-control flex min-h-0 items-stretch gap-0.5">
{cameraEnabled ? (
// Match the desktop camera tilt card's emerald styling so the vertical
// mobile control reads as the same feature in a phone-sized layout.
<div className="mobile-touch-control flex-1 min-h-0 rounded-xl border-2 border-emerald-300/70 bg-emerald-900 px-1 py-1 text-emerald-50">
<VerticalCameraTilt
value={cameraValue}
min={cameraMin}
max={cameraMax}
step={cameraTiltStep}
disabled={cameraDisabled}
onChange={setServoAngle}
/>
</div>
<VerticalCameraTilt
value={cameraValue}
min={cameraMin}
max={cameraMax}
step={cameraTiltStep}
disabled={cameraDisabled}
onChange={setServoAngle}
/>
) : null}
{(headlightAvailable || laserAvailable) ? (
<div className="mobile-touch-control flex min-h-0 flex-1 flex-col gap-0.5">
{headlightAvailable ? (
<GPIOToggleControl
label="Headlight"
icon={FaLightbulb}
on={headlightState?.headlightOn}
disabled={disabled}
disabled={controlsDisabled}
onToggle={handleHeadlightToggle}
heightClass="h-full"
/>
@@ -129,8 +150,9 @@ function AuxColumnContent() {
{laserAvailable ? (
<GPIOToggleControl
label="Laser"
icon={FaCrosshairs}
on={laserState?.laserOn}
disabled={disabled || roomLightsLockedOn}
disabled={controlsDisabled || roomLightsLockedOn}
onToggle={handleLaserToggle}
heightClass="h-full"
/>
@@ -141,7 +163,8 @@ function AuxColumnContent() {
<div className="mobile-touch-control min-h-0">
{hornAvailable ? (
<HornControl
disabled={disabled || hornBlocked}
icon={FaBullhorn}
disabled={controlsDisabled || hornBlocked}
onStart={handleHornStart}
onStop={stopHorn}
active={horn?.active}
@@ -181,7 +181,10 @@ export default function ControlPadPanel({ compact = false, disabled = false }) {
}, [disabled, setCameraPrecisionMode, stopDrivePad]);
return (
<div className={`mobile-touch-control flex flex-1 min-h-0 flex-col overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900 text-slate-100 shadow-md ${compact ? 'h-full' : ''}`}>
<div
aria-disabled={disabled}
className={`mobile-touch-control flex flex-1 min-h-0 flex-col overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900 text-slate-100 shadow-md transition-opacity ${compact ? 'h-full' : ''} ${disabled ? 'opacity-50' : 'opacity-100'}`}
>
<div className={`mobile-touch-control grid grid-cols-3 gap-0.5 border-b border-slate-700 bg-slate-950 ${compact ? 'p-0.25' : 'p-0.5'}`}>
{DRIVE_PAD_SPEED_MODES.map((mode) => {
const active = speedMode === mode.id;
@@ -3,6 +3,7 @@
// Scope: Owns pointer tracking, fixed overlay positioning, and active 3x3 drive-zone feedback.
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { FaArrowsAlt } from 'react-icons/fa';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
const PAD_MARGIN = 12;
@@ -244,7 +245,10 @@ export default function FloatingJoystick({
) : null}
{!compact ? (
<div className="pointer-events-none flex flex-col items-center gap-0.5 px-2 pt-5 text-center">
<span className="text-sm font-semibold text-slate-100">drive pad</span>
<span className="flex items-center gap-1 text-sm font-semibold text-slate-100">
<FaArrowsAlt aria-hidden="true" />
<span>drive pad</span>
</span>
<span className="text-xs leading-tight text-slate-300">hold and drag</span>
</div>
) : (
@@ -3,7 +3,7 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
export default function MobileAuxButton({ id, label, values, color, disabled, onPress, onRelease }) {
export default function MobileAuxButton({ id, label, icon: Icon, values, color, disabled, onPress, onRelease }) {
return (
<button
type="button"
@@ -23,9 +23,12 @@ export default function MobileAuxButton({ id, label, values, color, disabled, on
onContextMenu={(event) => event.preventDefault()}
// The mobile-touch-control class is applied directly to this button because
// long-press callouts and text selection are triggered at the pressed node.
className={`mobile-touch-control flex h-full w-full items-center justify-center rounded-xl border-2 px-1 py-0.75 text-center text-sm font-semibold text-white transition select-none no-touch-select ${color} hover:brightness-110 active:brightness-125 active:scale-[0.99] disabled:opacity-30`}
className={`mobile-touch-control flex h-full w-full items-center justify-center gap-1 rounded-xl border-2 px-1 py-0.75 text-center text-sm font-semibold text-white transition select-none no-touch-select ${color} hover:brightness-110 active:brightness-125 active:scale-[0.99] disabled:opacity-30`}
>
{label}
{/* The icon is optional because this low-level held-action control is also
useful for labels that do not have a clear visual symbol. */}
{Icon ? <Icon className="shrink-0 text-base" aria-hidden="true" /> : null}
<span>{label}</span>
</button>
);
}
@@ -1,36 +1,85 @@
// Movement Column
// Purpose: Assembles the mobile movement column, which is the right column by default.
// Scope: Integrates drive/dock actions with the mobile control pad without hiding that dependency inside the pad.
// Scope: Owns movement availability while the in-video DockingHud owns every dock/undock action.
import { FaChargingStation } from 'react-icons/fa';
import { useControlSelector } from '../../controls/index.js';
import DriveDockAction from '../DriveDockAction/index.jsx';
import { useDriveDockState } from '../DriveDockAction/driveDockState.js';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import useCanControlRover from '../../hooks/useCanControlRover.js';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
import ControlPadPanel from './ControlPadPanel.jsx';
function MovementColumnContent({ layout }) {
const roverId = useControlSelector((control) => control.state.roverId);
const driveDockState = useDriveDockState(roverId);
const dockedNotDriving = driveDockState.docked && !driveDockState.driving;
const expandAction = dockedNotDriving || driveDockState.dockingInProgress;
const disabled = !roverId;
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
const dockAssist = useManualDockAssist();
const canControl = useCanControlRover(roverId);
const batteryState = useSessionSelector((state) => {
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
return rover?.batteryState || null;
});
const batteryUrgent = Boolean(batteryState?.urgentActive);
const batteryLow = Boolean(batteryState?.warnActive || batteryUrgent);
const docked = Boolean(dockTelemetry?.homeBase);
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
/*
DriveDockAction owns whether the rover is ready to accept movement, while
ControlPadPanel owns only movement intent. Keeping the integration here makes
the column layout explicit and prevents the pad component from knowing about
docking state.
DockingHud is now the only place where a user starts driving or enters docking
assist. This column only decides whether touch movement is safe. Requiring both
an undocked rover and full OI mode keeps the pad inert throughout the undock
macro, including the interval where dock contact and OI mode change separately.
Manual assist is the deliberate exception to the normal OI-mode requirement:
its entire purpose is to let the user drive onto the dock under the assist speed
cap. Actual dock contact still disables movement immediately.
*/
const fillClass = dockedNotDriving ? 'max-h-screen self-start' : '';
const containerClass = `mobile-touch-control flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`;
const movementDisabled = !roverId
|| !canControl
|| docked
|| (!drivingMode && !dockAssist.active);
// Both controls share the same hardware-availability boundary. Dock assist
// changes their actions, not who is allowed to command the rover.
const dockActionDisabled = movementDisabled;
const handleDockAction = () => {
if (dockActionDisabled) return;
triggerTouchHaptic('button');
// Mobile deliberately enters assist on the first tap. The centered video HUD
// provides the resulting instruction, so a confirmation modal would only add
// friction between intent and the camera-guided docking task.
if (dockAssist.active) {
dockAssist.exitAssist();
} else {
dockAssist.enterAssist();
}
};
return (
<div className={containerClass} data-mobile-layout={layout}>
<DriveDockAction
layout="mobile"
expand={expandAction}
driveDockState={driveDockState}
compactHeightClass="min-h-[5rem]"
/>
{!expandAction ? <ControlPadPanel disabled={disabled} /> : null}
<div className="mobile-touch-control flex h-full flex-col gap-0.5 text-slate-100" data-mobile-layout={layout}>
<button
type="button"
disabled={dockActionDisabled}
onClick={handleDockAction}
className={`mobile-touch-control flex min-h-[4.5rem] shrink-0 items-center justify-center gap-1.5 rounded-xl border-2 px-2 text-base font-semibold shadow-md transition disabled:cursor-not-allowed disabled:opacity-50 ${
dockAssist.active
? 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
: batteryUrgent
? 'border-red-300/80 bg-red-950 text-red-50'
: batteryLow
? 'border-amber-300/80 bg-amber-950 text-amber-50'
: 'border-indigo-300/70 bg-indigo-900 text-indigo-50'
}`}
>
<FaChargingStation className="text-lg" aria-hidden="true" />
{/* The mobile column remains the only mobile entry point into dock assist. Battery
severity makes that existing action more obvious without adding another control. */}
<span>{dockAssist.active ? 'Exit dock assist' : batteryUrgent ? 'Dock now' : batteryLow ? 'Dock and charge soon' : 'Dock and charge'}</span>
</button>
{/* Keeping the pad mounted prevents the mobile columns from changing size while
the centered video HUD explains and performs dock-related transitions. */}
<ControlPadPanel disabled={movementDisabled} />
</div>
);
}
@@ -1,6 +1,7 @@
// Vacuum Controls
// Purpose: Renders the mobile-only vacuum forward/backward auxiliary motor controls.
// Scope: Owns only the two vacuum buttons; the parent column owns rover state and aux motor commands.
import { FaRedo, FaUndo } from 'react-icons/fa';
import MobileAuxButton from './MobileAuxButton.jsx';
import { AUX_ALL_BACKWARD, AUX_ALL_FORWARD } from './constants.js';
@@ -14,6 +15,7 @@ export default function VacuumControls({
<MobileAuxButton
id="aux-vac-forward"
label="Vacuum Forward"
icon={FaRedo}
values={AUX_ALL_FORWARD}
color="bg-fuchsia-600"
disabled={disabled}
@@ -23,6 +25,7 @@ export default function VacuumControls({
<MobileAuxButton
id="aux-vac-backward"
label="Vacuum Backward"
icon={FaUndo}
values={AUX_ALL_BACKWARD}
color="bg-fuchsia-800"
disabled={disabled}
@@ -2,6 +2,7 @@
// Purpose: Provides the mobile-only camera tilt slider that supports simultaneous two-finger mobile driving.
// Scope: Owns pointer tracking and visual slider state for the compact vertical mobile camera control.
import { useCallback, useMemo, useRef } from 'react';
import { FaVideo } from 'react-icons/fa';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
const HAPTIC_MIN_ANGLE_DELTA_DEGREES = 2;
@@ -32,7 +33,7 @@ export default function VerticalCameraTilt({
if (max === min) return 50;
return ((clampCameraAngle(value, min, max) - min) / (max - min)) * 100;
}, [max, min, value]);
const disabledClass = disabled ? 'opacity-50' : '';
const disabledClass = disabled ? 'cursor-not-allowed opacity-50' : '';
const valueFromPointer = useCallback(
(event) => {
@@ -106,9 +107,16 @@ export default function VerticalCameraTilt({
}, []);
return (
<div className="mobile-touch-control flex h-full items-center justify-center gap-0.5">
<span className="mobile-touch-control text-sm font-semibold text-emerald-50 [writing-mode:vertical-rl] rotate-180">
Camera tilt
<div
className={`mobile-touch-control flex h-full min-h-0 flex-1 items-center justify-center gap-0.5 rounded-xl border-2 border-emerald-300/70 bg-emerald-900 px-1 py-1 text-emerald-50 transition-opacity ${disabledClass}`}
>
{/* This component owns its entire visible card so interaction state, border,
background, label, and slider always dim as one coherent control. */}
<span className="mobile-touch-control flex items-center gap-1 text-sm font-semibold text-emerald-50 [writing-mode:vertical-rl] rotate-180">
{/* Rotate the icon with the established vertical label so both read as one
control identity without consuming additional horizontal space. */}
<FaVideo className="shrink-0" aria-hidden="true" />
<span>Camera tilt</span>
</span>
<div
ref={trackRef}
@@ -134,7 +142,7 @@ export default function VerticalCameraTilt({
camera gesture.
*/
style={{ touchAction: 'none' }}
className={`mobile-touch-control mobile-drag-control relative h-full w-6 rounded-full border border-emerald-100/80 bg-emerald-950 shadow-inner focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-200 ${disabledClass}`.trim()}
className="mobile-touch-control mobile-drag-control relative h-full w-6 rounded-full border border-emerald-100/80 bg-emerald-950 shadow-inner focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-200"
>
<div
className="pointer-events-none absolute inset-x-1 bottom-1 rounded-full bg-emerald-400"
+3 -1
View File
@@ -135,7 +135,9 @@ function NeatoCardContent() {
<p className="text-xs text-slate-400">Control the autonomous Neato robovac. Be nice to him.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0.5">
{/* Neato's control and status groups depend on card width, not on whether
the overall desktop viewport happens to cross Tailwind's lg breakpoint. */}
<div className="grid grid-cols-1 gap-0.5 @[28rem]:grid-cols-2">
<div className="surface-muted grid gap-0.5">
<p className="text-xs text-slate-300 text-center">Controls</p>
<button
@@ -0,0 +1,75 @@
// New Drive Video
// Purpose: Mirrors DriverVideo's ownership by composing media and rover HUD overlays in one stage.
// Scope: Owns the current driver video surface; page columns remain layout concerns.
import RoverMediaPlayer from '../RoverMediaPlayer/index.jsx';
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/index.jsx';
import CornerPods from '../HudOverlays/newgen/CornerPods/index.jsx';
import DockingHud from '../HudOverlays/newgen/DockingHud/index.jsx';
import WheelDropIndicators from '../HudOverlays/newgen/WheelDropIndicators/index.jsx';
import BottomSensorHud from '../HudOverlays/newgen/BottomSensorHud/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useControlSelector } from '../../controls/index.js';
import { useDriverVideoModePolicy } from '../../hooks/useDriverVideoModePolicy.js';
import { useDriverLayout } from '../../layouts/driver/DriverLayoutContext.jsx';
// Mobile keeps the same HUD composition and coordinate system as desktop, but its
// physically smaller video stage needs the complete interface reduced as one unit.
// Keeping this as one scale value prevents individual pods and sensor layers from
// drifting apart as their responsive behavior evolves.
const MOBILE_HUD_SCALE = 0.68;
export default function NewDriveVideo() {
const layout = useDriverLayout();
const mobileHud = layout !== 'desktop';
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const videoMode = useDriverVideoModePolicy(roverId);
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
if (!roverId) {
return (
<div className={`flex min-h-0 w-full items-center justify-center overflow-hidden text-[0.8rem] text-neutral-300 ${mobileHud ? 'aspect-[4/3] shrink-0' : 'h-screen'}`}>
No rover assigned
</div>
);
}
return (
<section
className={`flex min-h-0 w-full items-center overflow-hidden ${mobileHud ? 'shrink-0' : 'h-screen'}`}
aria-label="Rover video and HUD"
>
{/* Media and overlays must share this exact 4:3 containing block. When narrow columns
reduce the video width, the stage height shrinks with it and no HUD element can remain
positioned in the letterboxed space above or below the actual picture. */}
<div className="relative aspect-[4/3] w-full overflow-hidden bg-black [&_img]:object-contain [&_video]:object-contain">
<RoverMediaPlayer roverId={roverId} videoMode={videoMode} />
<div
className="pointer-events-none absolute left-0 top-0 z-30 h-full w-full origin-top-left"
style={mobileHud ? {
/* The inverse logical dimensions exactly cancel the visual scale. An item
anchored at right: 0 or bottom: 0 therefore still lands on the real video
edge instead of floating inward after the layer is reduced. */
width: `${100 / MOBILE_HUD_SCALE}%`,
height: `${100 / MOBILE_HUD_SCALE}%`,
transform: `scale(${MOBILE_HUD_SCALE})`,
} : undefined}
>
{/* Mobile already owns its physical rover controls outside the video. Keep the
informational HUD and chat, but do not duplicate those controls in corner pods. */}
<CornerPods roverId={roverId} />
<DockingHud roverId={roverId} />
<WheelDropIndicators roverId={roverId} />
<BottomSensorHud roverId={roverId} />
{/* Overcurrent remains a stage-wide safety condition. Battery warnings are intentionally
owned by the top-right battery pod so they stay connected to their source and can
account for whether the rover is driving, docking, or already charging. */}
<OvercurrentOverlay roverId={roverId} compact={mobileHud} />
<RoverDescriptionOverlay roverId={roverId} mobileHud={mobileHud} controlIntentAt={lastControlIntentAt} />
</div>
</div>
</section>
);
}
+17 -4
View File
@@ -190,7 +190,12 @@ function OdometerSummary({ odometer, rover, report, now }) {
<span className="font-semibold text-slate-200">{roverLabel(rover)}</span>
<span className="text-xs text-slate-500">{formatAge(odometer?.updatedAt, now)}</span>
</div>
<div className="grid grid-cols-2 gap-0.5 md:grid-cols-5">
{/* A single metric row keeps both its label and value visible in the narrow
desktop sidebar. Wider cards progressively regain dashboard density. */}
{/* Five compact odometer metrics need far less than Tailwind's 42rem 2xl
container. Returning at 28rem restores the normal row in old and mobile
layouts while preserving the two-column fallback in genuinely slim cards. */}
<div className="grid grid-cols-1 gap-0.5 @xs:grid-cols-2 @[28rem]:grid-cols-5">
<Metric label="Total distance" value={formatDistance(odometer?.totalMm)} />
<Metric label="Session distance" value={formatDistance(odometer?.sessionMm)} />
<Metric label="Current speed" value={formatSpeed(odometer?.wheelSpeedsMmPerSecond?.center)} />
@@ -220,14 +225,22 @@ function RoverOdometerList({ rows, primaryRoverId, reportByRoverId }) {
return (
<div
key={rover?.id}
className={`flex items-center justify-between gap-1 rounded px-1 py-0.5 ${
className={`grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-1 gap-y-0.5 rounded px-1 py-0.5 ${
active ? 'bg-slate-700/70 text-slate-50' : 'text-slate-200'
}`}
>
<span className="min-w-0 truncate">{roverLabel(rover)}</span>
<span className="shrink-0 text-right text-slate-100">{formatDistance(odometer?.totalMm)}</span>
<span className="shrink-0 text-right text-slate-300">{formatHealth(report?.batteryHealth?.capacityRetentionPercent)}</span>
<span className="shrink-0 text-right text-slate-400">{formatEfficiency(report?.overallWhPerKm)}</span>
{/* Secondary health metrics share their own line so they never push
the rover identity or primary distance out of a slim card. */}
<span className="col-span-2 flex flex-wrap justify-between gap-x-1 text-[0.72rem]">
<span className="text-slate-300">
Battery {formatHealth(report?.batteryHealth?.capacityRetentionPercent)}
</span>
<span className="text-slate-400">
Efficiency {formatEfficiency(report?.overallWhPerKm)}
</span>
</span>
</div>
);
})}
@@ -65,7 +65,9 @@ export default function RawUserPilePanel({
>
{!hideNicknameForm && (
<div className="space-y-0.5">
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
{/* Keep identity and social controls stacked until this card itself is
wide enough; the viewport says nothing about a sidebar's width. */}
<div className="grid gap-0.5 @sm:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
<div className="min-w-0">
<div className="flex w-full items-center px-0 py-0">
<NicknameForm compact={compact} />
@@ -246,7 +246,10 @@ export default function ReplaySourcesPanel({
</div>
) : null}
<CardFrame title="Replay Sources" fillHeight={fillHeight} bodyClassName="space-y-0.5 text-sm">
<div className={`grid gap-0.5 md:grid-cols-2 ${listWrapClass}`}>
{/* Both source lists are intentionally compact enough to share the
current desktop sidebar. They only collapse when the card becomes
narrower than the supported new-drive desktop column. */}
<div className={`grid grid-cols-2 gap-0.5 @max-[15rem]:grid-cols-1 ${listWrapClass}`}>
<GroupList title="Rovers" items={grouped.rovers} selectedSet={selectedSet} onToggle={toggleKey} />
<GroupList title="Room Cams" items={grouped.rooms} selectedSet={selectedSet} onToggle={toggleKey} />
</div>
@@ -729,7 +729,9 @@ export default function RoverMediaPlayer({
</div>
) : null}
<audio ref={audioRef} autoPlay hidden />
<div className="pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]">
{/* Connection diagnostics remain readable without competing with rover controls,
safety warnings, or the primary HUD layered above the media player. */}
<div className="pointer-events-none absolute left-1 top-1 z-0 font-medium text-slate-100 opacity-50 text-[0.65rem]">
<div className="flex flex-col gap-0.5 leading-none">
<span>Status: {renderedStatus}</span>
{renderedAudioStatus ? <span>Audio: {renderedAudioStatus}</span> : null}
+49 -40
View File
@@ -54,13 +54,13 @@ function normalizeVideoFilter(value) {
: VIDEO_SETTINGS_DEFAULTS.colorFilter;
}
function SettingRow({ children, className = '' }) {
function SettingRow({ children, inline = false, className = '' }) {
// Page settings are changed one row at a time, so each row gets a subtle container and a
// max width. This keeps the label and control together instead of stretching them across
// the full settings pane.
return (
<label
className={`mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded bg-neutral-800/80 px-1.5 py-1 text-sm text-white max-[420px]:grid-cols-1 ${className}`}
className={`mx-auto flex w-full max-w-lg items-center gap-1.5 rounded bg-neutral-800/80 px-1.5 py-1 text-sm text-white ${inline ? 'flex-row' : 'flex-col items-stretch @lg:flex-row @lg:items-center'} ${className}`}
>
{children}
</label>
@@ -96,7 +96,7 @@ function RangeSetting({ label, value, disabled = false, onChange }) {
// the row while the percentage value stays beside the label for quick feedback.
return (
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1 text-sm text-white">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5">
<div className="flex items-center justify-between gap-1.5">
<span className="min-w-0 font-semibold text-white">{label}</span>
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
{Math.round(value * 100)}%
@@ -262,13 +262,19 @@ export default function SettingsPanel() {
}));
};
// Settings owns grids outside its individual CardFrames. This boundary makes
// those grids react to the panel's allocated width in any desktop column.
return (
<Tabs defaultTab="keybindings">
<div className="@container">
<Tabs defaultTab="keybindings">
<TabList>
<Tab id="keybindings">Keybindings</Tab>
<Tab id="controller">Controller</Tab>
<Tab id="page">Page settings</Tab>
<Tab id="admin">Admin</Tab>
{/* Four long labels cannot remain readable in one narrow sidebar row.
Each tab claims half the row until this Settings container is wide,
where the minimum is removed and the original single row returns. */}
<Tab id="keybindings" className="min-w-[calc(50%_-_0.125rem)] @[28rem]:min-w-0">Keybindings</Tab>
<Tab id="controller" className="min-w-[calc(50%_-_0.125rem)] @[28rem]:min-w-0">Controller</Tab>
<Tab id="page" className="min-w-[calc(50%_-_0.125rem)] @[28rem]:min-w-0">Page settings</Tab>
<Tab id="admin" className="min-w-[calc(50%_-_0.125rem)] @[28rem]:min-w-0">Admin</Tab>
</TabList>
<TabPanels>
<TabPanel id="keybindings">
@@ -282,30 +288,23 @@ export default function SettingsPanel() {
</div>
</TabPanel>
<TabPanel id="page">
{/* Page settings use a responsive card grid so unrelated settings do not form one long,
stretched column. Audio spans both columns because volume sliders need extra width
for comfortable pointer control. */}
<div className="grid gap-1.5 lg:grid-cols-2">
{/* The normal two-column settings page returns at the width where two
useful cards actually fit. The former 48rem threshold was unreachable
in the old driver pane and incorrectly made its fallback permanent. */}
<div className="flex flex-col gap-1.5 @[28rem]:grid @[28rem]:grid-cols-2">
<CardFrame
title="Background theme"
className="lg:col-span-2"
className="col-span-full"
bodyClassName="space-y-1.5 p-1 text-sm"
>
{/* Back/Next provide fast visual browsing, while the dropdown remains the direct
route to a known theme in a growing catalog. Neither control saves implicitly. */}
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1.5">
<button
type="button"
className="button-dark min-w-16 text-sm"
onClick={() => movePageThemePreview(-1)}
>
Back
</button>
{/* The selector owns a full row at narrow widths. Navigation is a
separate flex row so neither button can squeeze the select text. */}
<div className="flex flex-col gap-1.5">
<select
aria-label="Preview background theme"
value={previewPageThemeKey}
onChange={handlePageThemeSelect}
className="field-input min-w-0 px-1 py-0.5 text-sm"
className="field-input w-full min-w-0 px-1 py-0.5 text-sm"
>
{PAGE_THEME_OPTIONS.map((theme) => (
<option key={theme.key} value={theme.key}>
@@ -313,20 +312,29 @@ export default function SettingsPanel() {
</option>
))}
</select>
<button
type="button"
className="button-dark min-w-16 text-sm"
onClick={() => movePageThemePreview(1)}
>
Next
</button>
<div className="flex gap-1.5">
<button
type="button"
className="button-dark min-w-0 flex-1 text-sm"
onClick={() => movePageThemePreview(-1)}
>
Back
</button>
<button
type="button"
className="button-dark min-w-0 flex-1 text-sm"
onClick={() => movePageThemePreview(1)}
>
Next
</button>
</div>
</div>
{/* This miniature layout contains both a full-height vertical seam and horizontal
seams between stacked cards. It exercises the exact gap directions the artwork
must serve on the driver and PTZ pages. */}
<div
className={`page-theme-preview grid h-32 grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)_minmax(0,0.85fr)] grid-rows-2 gap-0.5 overflow-hidden rounded border border-neutral-500/70 p-0.5 ${getPageThemeClass(previewPageThemeKey)}`}
className={`page-theme-preview flex h-48 flex-col gap-0.5 overflow-hidden rounded border border-neutral-500/70 p-0.5 @[28rem]:grid @[28rem]:h-32 @[28rem]:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)_minmax(0,0.85fr)] @[28rem]:grid-rows-2 ${getPageThemeClass(previewPageThemeKey)}`}
>
<ThemePreviewCard title="Video" className="row-span-2" />
<ThemePreviewCard title="Controls" />
@@ -334,7 +342,7 @@ export default function SettingsPanel() {
<ThemePreviewCard title="Chat" className="col-span-2" />
</div>
<div className="flex flex-wrap items-center justify-between gap-1.5">
<div className="flex flex-col items-stretch gap-1.5 @lg:flex-row @lg:items-center @lg:justify-between">
<p className="min-w-0 text-xs text-white">
Previewing <span className="font-semibold">{previewPageTheme.label}</span>
{hasUnsavedPageTheme ? (
@@ -345,7 +353,7 @@ export default function SettingsPanel() {
</p>
<button
type="button"
className="button-dark min-w-24 text-sm disabled:cursor-default disabled:opacity-45"
className="button-dark w-full text-sm disabled:cursor-default disabled:opacity-45 @lg:w-auto @lg:min-w-24"
disabled={!hasUnsavedPageTheme}
onClick={handlePageThemeSave}
>
@@ -354,7 +362,7 @@ export default function SettingsPanel() {
</div>
</CardFrame>
<CardFrame title="HUD" bodyClassName="space-y-1 p-1 text-sm">
<SettingRow className="grid-cols-[auto_minmax(0,1fr)] max-[420px]:grid-cols-[auto_minmax(0,1fr)]">
<SettingRow inline>
<input
type="checkbox"
className="h-3.5 w-3.5 accent-emerald-500"
@@ -394,7 +402,7 @@ export default function SettingsPanel() {
</SettingHelp>
</CardFrame>
<CardFrame title="Mobile controls" bodyClassName="space-y-1 p-1 text-sm">
<SettingRow className="grid-cols-[auto_minmax(0,1fr)] max-[420px]:grid-cols-[auto_minmax(0,1fr)]">
<SettingRow inline>
<input
type="checkbox"
className="h-3.5 w-3.5 accent-emerald-500"
@@ -405,7 +413,7 @@ export default function SettingsPanel() {
</SettingRow>
</CardFrame>
<CardFrame title="Macros" bodyClassName="space-y-1 p-1 text-sm">
<SettingRow className="grid-cols-[auto_minmax(0,1fr)] max-[420px]:grid-cols-[auto_minmax(0,1fr)]">
<SettingRow inline>
<input
type="checkbox"
className="h-3.5 w-3.5 accent-emerald-500"
@@ -415,7 +423,7 @@ export default function SettingsPanel() {
<span className="font-semibold text-white">Enable backward bump in drive macro</span>
</SettingRow>
</CardFrame>
<CardFrame title="Audio" className="lg:col-span-2" bodyClassName="space-y-1 p-1 text-sm">
<CardFrame title="Audio" className="col-span-full" bodyClassName="space-y-1 p-1 text-sm">
{/* Audio sliders are stacked inside the wider card so the value chip, label, and
slider remain easy to compare while preserving enough drag distance. */}
<RangeSetting
@@ -470,7 +478,7 @@ export default function SettingsPanel() {
<SettingHelp>Switching reconnects your session.</SettingHelp>
</CardFrame>
<CardFrame title="Inter-instance" bodyClassName="space-y-1 p-1 text-sm">
<SettingRow className="grid-cols-[auto_minmax(0,1fr)] max-[420px]:grid-cols-[auto_minmax(0,1fr)]">
<SettingRow inline>
<input
type="checkbox"
className="h-3.5 w-3.5 accent-emerald-500"
@@ -526,6 +534,7 @@ export default function SettingsPanel() {
</div>
</TabPanel>
</TabPanels>
</Tabs>
</Tabs>
</div>
);
}
+3 -1
View File
@@ -114,7 +114,9 @@ export function TabList({ children, className = '' }) {
className
)}
>
<div className={`flex ${themeGapClass}`}>{children}</div>
{/* Wrapping is driven by the selector's actual contents and allocated width,
so the same tabs remain compact in wide panels and readable in sidebars. */}
<div className={`flex flex-wrap ${themeGapClass}`}>{children}</div>
</div>
);
}
+3 -1
View File
@@ -172,7 +172,9 @@ export default function UserListPanel({
>
{!hideNicknameForm && (
<div className="space-y-0.5">
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
{/* The shared panel can be mounted in a narrow column on a wide desktop,
so this split must follow the card width rather than the viewport. */}
<div className="grid gap-0.5 @sm:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
<div className="min-w-0">
<div className="flex w-full items-center px-0 py-0">
<NicknameForm compact={compact} />
+9 -7
View File
@@ -49,11 +49,13 @@ export default function VipPanel() {
return next;
};
// VipPanel owns its outer grid rather than placing it inside one CardFrame,
// so it establishes its own query boundary for the same width-aware behavior.
return (
<section className="space-y-2 text-base">
<div className="grid gap-2 grid-cols-1 lg:grid-cols-[minmax(0,1.25fr)_minmax(0,0.9fr)]">
<section className="@container space-y-2 text-base">
<div className="grid grid-cols-1 gap-2 @[32rem]:grid-cols-[minmax(0,1.25fr)_minmax(0,0.9fr)]">
{!isVerified ? (
<div className="lg:col-span-2">
<div className="@[32rem]:col-span-2">
<VipVerificationCard
pendingRequestId={pendingRequestId}
currentStoredKey={currentStoredKey}
@@ -66,7 +68,7 @@ export default function VipPanel() {
</div>
) : null}
<div className="lg:col-span-2">
<div className="@[32rem]:col-span-2">
{isVerified ? (
<div className="space-y-2">
<VipMidiBeeperCard />
@@ -89,7 +91,7 @@ export default function VipPanel() {
)}
</div>
<div className="lg:col-span-2">
<div className="@[32rem]:col-span-2">
<VipPrivateRoverAccessCard
requestableRovers={requestablePrivateRovers}
pendingRequests={pendingPrivateRoverRequests}
@@ -99,7 +101,7 @@ export default function VipPanel() {
/>
</div>
<div className="lg:col-span-2">
<div className="@[32rem]:col-span-2">
<VipIdentityCard
currentStoredKey={currentStoredKey}
applyIdentityKey={applyIdentityKey}
@@ -108,7 +110,7 @@ export default function VipPanel() {
/>
</div>
<div className="lg:col-span-2">
<div className="@[32rem]:col-span-2">
<VipProfileImageCard
isVerified={isVerified}
onMessage={setMessage}
@@ -83,10 +83,10 @@ export default function VolumeSettingsCard() {
if (!serverState) return null;
return (
<CardFrame title="Personal volume adjustment" className="lg:col-span-2" bodyClassName="space-y-1 p-1 text-sm">
<CardFrame title="Personal volume adjustment" className="@[28rem]:col-span-2" bodyClassName="space-y-1 p-1 text-sm">
{ADJUSTMENT_FIELDS.map(({ key, label }) => (
<label key={key} className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1 text-white">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5">
<div className="flex items-center justify-between gap-1.5">
<span className="min-w-0 font-semibold text-white">{label}</span>
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
{formatPercent(draft[key])}
@@ -539,7 +539,8 @@ export default function VipAudioUploadCard({
</div>
</section>
<div className="grid gap-0.5 grid-cols-1 lg:grid-cols-2">
{/* PTT controls split only when the Audio Controls card itself is wide. */}
<div className="grid grid-cols-1 gap-0.5 @[28rem]:grid-cols-2">
<section className="surface h-full">
<div className="grid h-full gap-0.5 grid-rows-[auto_auto_1fr]">
<p className="text-sm text-slate-200 text-center">PTT Mode</p>
@@ -578,7 +579,9 @@ export default function VipAudioUploadCard({
</section>
</div>
<div className="grid gap-0.5 grid-cols-1 lg:grid-cols-2">
{/* Upload and live-microphone controls stack in narrow sidebars instead
of inheriting the desktop viewport's two-column decision. */}
<div className="grid grid-cols-1 gap-0.5 @[28rem]:grid-cols-2">
<section className="surface h-full">
<div className="grid h-full gap-0.5 grid-rows-[auto_auto_1fr_auto]">
<p className="text-sm text-slate-200 text-center">Audio Upload</p>
@@ -642,7 +645,10 @@ export default function VipAudioUploadCard({
<section className="surface">
<div className="space-y-0.5">
<p className="text-sm text-slate-200 text-center">Audio Status</p>
<div className="grid gap-0.5 grid-cols-1 sm:grid-cols-2 lg:grid-cols-6">
{/* A compact sidebar gets two readable status columns. At 28rem the six
short status values fit again, restoring the card's normal strip in
old desktop and sufficiently wide mobile surfaces. */}
<div className="grid grid-cols-1 gap-0.5 @xs:grid-cols-2 @[28rem]:grid-cols-6">
<StatusIndicator label="Forward pipe" active={pipelineConnected} detail={pipelineConnected ? 'connected' : 'offline'} />
<StatusIndicator label="Upload playback" active={uploadPlaying} detail={uploadPlaying ? 'playing' : 'idle'} />
<StatusIndicator label="Mic relay" active={micRelayActive} detail={micRelayActive ? 'active' : 'idle'} />
@@ -410,7 +410,9 @@ export default function VipMidiBeeperCard() {
return (
<CardFrame title="Midi Beeper">
<div className="grid gap-1">
<div className="grid gap-0.5 grid-cols-1 lg:grid-cols-2">
{/* The file and live-input workspaces follow this card's width. A wide
viewport must not force them beside each other inside a sidebar. */}
<div className="grid grid-cols-1 gap-0.5 @[28rem]:grid-cols-2">
<section className="surface h-full">
<div className="grid h-full gap-0.5 content-start">
<p className="text-sm text-slate-200 text-center">File playback</p>
@@ -495,7 +497,7 @@ export default function VipMidiBeeperCard() {
<div className="mx-auto grid w-full max-w-sm gap-0.5 text-xs text-slate-300 text-center">
<span>Playback shape</span>
<div className="grid grid-cols-2 gap-0.5 sm:grid-cols-4">
<div className="grid grid-cols-2 gap-0.5 @sm:grid-cols-4">
<label className="grid gap-0.5">
<span>Max note</span>
<input
@@ -647,7 +649,9 @@ export default function VipMidiBeeperCard() {
<section className="surface">
<div className="space-y-0.5">
<p className="text-sm text-slate-200 text-center">Beeper status</p>
<div className="grid gap-0.5 grid-cols-1 sm:grid-cols-2 lg:grid-cols-6">
{/* Status stays legible as a short vertical list in slim cards, then
returns to the normal six-value strip as soon as 28rem is available. */}
<div className="grid grid-cols-1 gap-0.5 @xs:grid-cols-2 @[28rem]:grid-cols-6">
<StatusRow label="Rover" value={ownRoverId || 'none'} active={Boolean(ownRoverId)} />
<StatusRow label="File state" value={playbackState} active={playbackState === 'playing'} />
<StatusRow label="Parts" value={selectedPartIds.length || 'none'} active={selectedPartIds.length > 0} />