mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
big boy webui new new new new new 100 files changed 80 years
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? 'It’s 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'
|
||||
: 'You’re 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); }
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user