moving a LOT of stuff around in web ui

This commit is contained in:
legop3
2026-08-02 23:06:30 -04:00
parent 3ebd1c7f9c
commit 15a60a58e6
45 changed files with 814 additions and 870 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -12,8 +12,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-CqhbbOso.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-5vzB2lhE.css">
<script type="module" crossorigin src="/assets/index-KnOn2V1J.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7PpZTwSc.css">
</head>
<body>
<div id="root"></div>
-443
View File
@@ -1,443 +0,0 @@
// Main Application Shell
// Purpose: Composes the primary rover control interface and page-level layout. Scope: Orchestrates high-level panels, overlays, and feature modules for the default route.
import { useCallback, useEffect, useMemo, useState } from 'react';
import TelemetryPanel from './components/TelemetryPanel/index.jsx';
import PiHostStatsCard from './components/PiHostStatsCard/index.jsx';
import ReplaySourcesPanel from './components/ReplaySourcesPanel/index.jsx';
import AlertFeed from './components/AlertFeed/index.jsx';
import AuxColumn from './components/MobileControls/AuxColumn.jsx';
import MovementColumn from './components/MobileControls/MovementColumn.jsx';
import {
ControlSystemProvider,
KeyboardInputManager,
GamepadInputManager,
useControlSelector,
} from './controls/index.js';
import RoomCameraPanel from './components/RoomCameraPanel/index.jsx';
import KinectPanel from './components/KinectPanel/index.jsx';
import BalanceBoardPanel from './components/BalanceBoardPanel/index.jsx';
import DriverVideo from './components/DriverVideo/index.jsx';
import RightPaneTabs from './components/RightPaneTabs/index.jsx';
import ModeGateOverlay from './components/ModeGateOverlay/index.jsx';
import HomeAssistantControls from './components/HomeAssistantControls/index.jsx';
import TurnAlertListener from './components/TurnAlertListener/index.jsx';
import RawUserPilePanel from './components/RawUserPilePanel/index.jsx';
import OverseerPreferencePanel from './components/OverseerPreferencePanel/index.jsx';
import SocialButtonsGrid from './components/SocialButtonsGrid/index.jsx';
import ChatPanel from './components/ChatPanel/index.jsx';
import FullscreenPrompt from './components/FullscreenPrompt/index.jsx';
import FloatingFullscreenButton from './components/FloatingFullscreenButton/index.jsx';
import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js';
import { useSettingsNamespace } from './settings/index.js';
import HelpOverlay from './components/HelpOverlay/index.jsx';
import QuickstartOverlay from './components/QuickstartOverlay/index.jsx';
import HelpPanel from './components/HelpPanel/index.jsx';
import SettingsPanel from './components/SettingsPanel/index.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs/index.jsx';
import useDefaultNickname from './hooks/useDefaultNickname.js';
import useUserIdentitySync from './hooks/useUserIdentitySync.js';
import useIncomingInterInstanceTransfer from './hooks/useIncomingInterInstanceTransfer.js';
import GlobalObjectiveBanner from './components/GlobalObjectiveBanner/index.jsx';
import RoverQueuesPanel from './components/RoverQueuesPanel/index.jsx';
import PtzQueueCard from './components/PtzCamera/index.jsx';
import VipPanel from './components/VipPanel/index.jsx';
import { useSessionSelector } from './context/SessionContext.jsx';
import { useTelemetryVisualPolicy } from './context/TelemetryContext.jsx';
import ButtonBoxPanel from './components/ButtonBoxPanel/index.jsx';
import BarcodeGamesPanel from './components/BarcodeGamesPanel/index.jsx';
import OdometerPanel from './components/OdometerPanel/index.jsx';
import FleetReportsCard from './components/FleetReportsCard/index.jsx';
import LiftCard from './components/LiftCard/index.jsx';
import NeatoCard from './components/NeatoCard/index.jsx';
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
import DuplicateIdentityOverlay from './components/DuplicateIdentityOverlay/index.jsx';
import DriverAdCard from './components/DriverAdCard/index.jsx';
import {
DEFAULT_PAGE_THEME_KEY,
getPageThemeClass,
themeGapClass,
themeStackClass,
} from './themes/index.js';
import useLayoutMode from './hooks/useLayoutMode.js';
function DesktopLayout({ layout, onOpenHelpOverlay }) {
return (
<div className={`flex h-full ${themeGapClass} overflow-hidden`}>
<div className={`flex min-w-0 flex-[1.22] flex-col ${themeGapClass} overflow-y-auto pr-0`}>
<DriverVideo />
<PiHostStatsCard />
{/*
The ad owns its empty-state gate and uses auto top margin to consume
any spare column height. Keeping it as the final desktop-only child
pins it to the bottom without introducing layout measurement code or
exposing it on mobile and alternate application routes.
*/}
<DriverAdCard className="mt-auto" />
{/* <TelemetryPanel /> */}
</div>
<div className={`flex min-w-0 flex-1 flex-col ${themeGapClass} overflow-y-auto`}>
<GlobalObjectiveBanner layout={layout} />
<RightPaneTabs layout={layout} onOpenHelpOverlay={onOpenHelpOverlay} />
{/* <SessionSnapshot /> */}
</div>
</div>
);
}
function MobileFeatureTabs({
layout,
onOpenHelpOverlay,
roomPanelId,
}) {
const [activeTab, setActiveTab] = useState('chat');
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const ownRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
const ownAudioForward = useSessionSelector((state) => {
const roverId = String(state.session?.assignment?.roverId || '').trim();
return roverId ? state.session?.audioForward?.[roverId] || null : null;
});
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
const vipDotClass = isVerified ? 'bg-emerald-400' : 'bg-amber-400';
const openMicEnabled = Boolean(vipAudio?.openMicEnabled);
const pttMode = vipAudio?.pttMode === 'clip' ? 'clip' : 'live';
const vipMicActive = Boolean(
ownRoverId &&
isVerified &&
(pttMode === 'clip' ? pttActive : (openMicEnabled || pttActive)),
);
const vipClipPlaying = Boolean(
ownRoverId &&
isVerified &&
pttMode === 'clip' &&
ownAudioForward?.source === 'upload' &&
ownAudioForward?.state === 'playing',
);
const showOverseerPreferencePanel = useSessionSelector((state) => {
const vote = state.session?.overseerVote;
// Match the panel's server-owned voting gate so the mobile chat row can
// collapse to a single-column layout when voting is unavailable, including
// disabled service and direct-address mode.
return Boolean(vote?.votingEnabled);
});
const handleTabChange = useCallback(
(tab) => {
setActiveTab(tab);
},
[],
);
return (
<section className="text-base">
<Tabs defaultTab="chat" currentTab={activeTab} onTabChange={handleTabChange}>
<TabList>
<Tab id="chat">Chat</Tab>
<Tab id="activities">Activities</Tab>
<Tab id="vip" highlight={vipClipPlaying ? 'green' : vipMicActive ? 'pink' : 'none'}>
<span className="inline-flex items-center gap-0.5">
<span>VIP</span>
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${vipDotClass}`}
aria-hidden="true"
title={isVerified ? 'Verified' : 'Not verified'}
/>
</span>
</Tab>
<Tab id="roomcontrols">Room Controls</Tab>
<Tab id="help">Help</Tab>
<Tab id="settings">Settings</Tab>
</TabList>
<TabPanels>
<TabPanel id="chat">
<div className={themeStackClass}>
<ChatPanel nicknameLayout="stacked" />
<div className={themeStackClass}>
<div className={`grid ${themeGapClass} md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]`}>
<SocialButtonsGrid />
</div>
<div
className={`grid ${themeGapClass} ${
showOverseerPreferencePanel
? 'grid-cols-[minmax(0,1fr)_minmax(0,1fr)]'
: 'grid-cols-[minmax(0,1fr)]'
}`}
>
{/*
The overseer vote panel is server-vote-gated. When voting
is unavailable, the raw user pile should reclaim the row
instead of sitting in a half-empty two-column layout.
*/}
{showOverseerPreferencePanel ? <OverseerPreferencePanel /> : null}
<RawUserPilePanel hideNicknameForm />
</div>
</div>
</div>
</TabPanel>
{/* activities tab */}
<TabPanel id="activities">
<div className={`flex flex-col ${themeGapClass}`}>
<NeatoCard />
<LiftCard />
<BalanceBoardPanel />
<BarcodeGamesPanel />
<OdometerPanel />
<ButtonBoxPanel />
<KinectPanel />
{/* Fleet reports owns its optional feature gate and intentionally
remains last so Activities keeps lightweight controls first. */}
<FleetReportsCard />
</div>
</TabPanel>
<TabPanel id="vip" keepMounted>
<VipPanel isActive={activeTab === 'vip'} layout={layout} />
</TabPanel>
<TabPanel id="roomcontrols">
<div className={themeStackClass}>
{/* {showTelemetry ? <TelemetryPanel /> : null} */}
<HomeAssistantControls />
<RoomCameraPanel panelId={roomPanelId} />
</div>
</TabPanel>
<TabPanel id="help">
<HelpPanel layout={layout} onOpenOverlay={onOpenHelpOverlay} />
</TabPanel>
<TabPanel id="settings">
<div className={themeStackClass}>
<SettingsPanel />
</div>
</TabPanel>
</TabPanels>
</Tabs>
</section>
);
}
function MobilePortraitLayout({ onOpenHelpOverlay, swapMobileControlColumns = false }) {
const columnHeight = 'h-[min(60svh,24rem)]';
const firstColumn = swapMobileControlColumns
? <MovementColumn layout="portrait" className={columnHeight} />
: <AuxColumn layout="portrait" className={columnHeight} />;
const secondColumn = swapMobileControlColumns
? <AuxColumn layout="portrait" className={columnHeight} />
: <MovementColumn layout="portrait" className={columnHeight} />;
return (
<div className={`flex flex-col ${themeGapClass}`}>
<DriverVideo layoutFormat="mobile-portrait" />
{/* Portrait owns the two-column placement because the same reusable mobile
columns sit in different grid contexts in portrait and landscape. */}
<section className="mobile-touch-control text-white">
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 items-stretch">
{firstColumn}
{secondColumn}
</div>
</section>
<div className={`grid ${themeGapClass} grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]`}>
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
<div className="space-y-0.5">
<RoverQueuesPanel />
<PtzQueueCard layout="mobile-portrait" />
</div>
</div>
{/* <ControlSummary /> */}
<MobileFeatureTabs
layout="mobile-portrait"
onOpenHelpOverlay={onOpenHelpOverlay}
roomPanelId="mobile-portrait-room"
/>
</div>
);
}
function MobileLandscapeLayout({ onOpenHelpOverlay, swapMobileControlColumns = false }) {
const columnClass = 'self-start h-[min(100svh,32rem)]';
const firstColumn = swapMobileControlColumns
? <MovementColumn layout="landscape" className={columnClass} />
: <AuxColumn layout="landscape" className={columnClass} />;
const secondColumn = swapMobileControlColumns
? <AuxColumn layout="landscape" className={columnClass} />
: <MovementColumn layout="landscape" className={columnClass} />;
return (
<div className={`flex flex-col ${themeGapClass}`}>
<section className={`grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] ${themeGapClass}`}>
{firstColumn}
<div>
<DriverVideo layoutFormat="mobile-landscape" />
<div className={`grid ${themeGapClass} grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]`}>
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
<div className="space-y-0.5">
<RoverQueuesPanel />
<PtzQueueCard layout="mobile-landscape" />
</div>
</div>
{/* <TelemetryPanel /> */}
</div>
{secondColumn}
</section>
<div className={`flex flex-col ${themeGapClass} pb-0`}>
<MobileFeatureTabs
layout="mobile-landscape"
onOpenHelpOverlay={onOpenHelpOverlay}
roomPanelId="mobile-landscape-room"
/>
</div>
</div>
);
}
function App() {
const layout = useLayoutMode();
const isDesktop = layout === 'desktop';
const fullscreen = useFullscreenPrompt(layout);
const { value: pageSettings } = useSettingsNamespace('page', {
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
});
// Resolve the cookie value through the shared catalog before painting the page. This prevents
// an obsolete or hand-edited key from stripping the background class from every exposed seam.
const pageBackgroundClass = getPageThemeClass(pageSettings?.backgroundTheme);
return (
<div className={`${pageBackgroundClass} text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'ios-safe-screen min-h-screen'}`}>
<AppWithProviders layout={layout} isDesktop={isDesktop} fullscreen={fullscreen} />
</div>
);
}
function AppWithProviders({ layout, isDesktop, fullscreen }) {
useDefaultNickname();
useIncomingInterInstanceTransfer();
useUserIdentitySync({ identitySurface: 'driver' });
useTelemetryVisualPolicy({ mobile: !isDesktop });
const {
visible: fullscreenVisible,
mode: fullscreenMode,
isIOS: fullscreenIsIOS,
nativeSupported: fullscreenNativeSupported,
enterFullscreen,
dismiss,
showPrompt,
} = fullscreen;
const {
value: helpSettings,
save: saveHelpSettings,
} = useSettingsNamespace('help', { showOnLoad: true });
const {
value: quickstartSettings,
status: quickstartStatus,
save: saveQuickstartSettings,
} = useSettingsNamespace('quickstart', { showOnLoad: true });
const { value: pageSettings } = useSettingsNamespace('page', {
swapMobileControlColumns: false,
});
const swapMobileControlColumns = Boolean(pageSettings?.swapMobileControlColumns);
const fullscreenButtonSide = swapMobileControlColumns ? 'left' : 'right';
const showFloatingFullscreenButton = !isDesktop && (fullscreenIsIOS || fullscreenNativeSupported);
const [helpVisible, setHelpVisible] = useState(false);
const [quickstartVisible, setQuickstartVisible] = useState(false);
useEffect(() => {
if (quickstartStatus === 'ready') {
setQuickstartVisible(quickstartSettings?.showOnLoad !== false);
}
}, [quickstartStatus, quickstartSettings?.showOnLoad]);
const openHelp = useCallback(() => {
setHelpVisible(true);
}, []);
const closeHelp = useCallback(() => {
setHelpVisible(false);
}, []);
const closeQuickstart = useCallback(() => {
setQuickstartVisible(false);
}, []);
const handleFloatingFullscreen = useCallback(async () => {
if (fullscreenIsIOS) {
showPrompt();
return;
}
const entered = await enterFullscreen();
if (!entered) {
showPrompt();
}
}, [enterFullscreen, fullscreenIsIOS, showPrompt]);
const setQuickstartShowOnLoad = useCallback(
(enabled) => {
const next = Boolean(enabled);
saveQuickstartSettings((current) => ({ ...(current ?? {}), showOnLoad: next }));
if (!next) {
setQuickstartVisible(false);
}
},
[saveQuickstartSettings],
);
const openHelpFromQuickstart = useCallback(() => {
setQuickstartVisible(false);
setHelpVisible(true);
}, []);
const handleFullscreenPromptEnter = useCallback(async () => {
const entered = await enterFullscreen();
return entered;
}, [enterFullscreen]);
const handleFullscreenPromptDismiss = useCallback(() => {
dismiss();
}, [dismiss]);
const renderedLayout = useMemo(
() =>
isDesktop
? <DesktopLayout layout={layout} onOpenHelpOverlay={openHelp} />
: layout === 'mobile-landscape'
? <MobileLandscapeLayout onOpenHelpOverlay={openHelp} swapMobileControlColumns={swapMobileControlColumns} />
: <MobilePortraitLayout onOpenHelpOverlay={openHelp} swapMobileControlColumns={swapMobileControlColumns} />,
[isDesktop, layout, openHelp, swapMobileControlColumns],
);
return (
<ControlSystemProvider>
<KeyboardInputManager />
<GamepadInputManager />
<main className={`flex w-full flex-col ${themeGapClass} text-base ${isDesktop ? 'h-full overflow-hidden' : ''}`}>
{!isDesktop ? <GlobalObjectiveBanner layout={layout} /> : null}
{renderedLayout}
</main>
<AlertFeed />
<DuplicateIdentityOverlay />
<RewardRunOverlay />
<TurnAlertListener />
<ModeGateOverlay />
<SocketConnectionPill />
<HelpOverlay
visible={helpVisible}
layout={layout}
onClose={closeHelp}
showOnLoad={helpSettings?.showOnLoad !== false}
onToggleShowOnLoad={(enabled) => saveHelpSettings((current) => ({ ...(current ?? {}), showOnLoad: Boolean(enabled) }))}
/>
<FullscreenPrompt
visible={fullscreenVisible}
mode={fullscreenMode}
onEnterFullscreen={handleFullscreenPromptEnter}
onDismiss={handleFullscreenPromptDismiss}
/>
<QuickstartOverlay
visible={quickstartVisible}
layout={layout}
showOnLoad={quickstartSettings?.showOnLoad !== false}
onToggleShowOnLoad={setQuickstartShowOnLoad}
onOpenHelp={openHelpFromQuickstart}
onClose={closeQuickstart}
/>
{showFloatingFullscreenButton ? (
<FloatingFullscreenButton
side={fullscreenButtonSide}
onClick={handleFloatingFullscreen}
/>
) : null}
</ControlSystemProvider>
);
}
export default App;
@@ -2,6 +2,7 @@
// Purpose: Defines the Battery Bar 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 React from 'react';
import './styles.css';
import { WARN_DISPLAY_PERCENT } from '../../lib/battery.js';
const WARN_FLASH_MS = 1600;
@@ -0,0 +1,4 @@
@keyframes batteryTickWarn { 0%, 49% { background-color: rgba(255,255,255,.95); } 50%, 100% { background-color: rgba(239,68,68,.95); } }
.battery-tick-warn { animation: batteryTickWarn .4s steps(2,end) infinite; }
@keyframes batteryUrgentFlash { 0%, 49% { opacity: 1; } 50%, 100% { opacity: .25; } }
.battery-urgent-flash { animation: batteryUrgentFlash .4s steps(2,end) infinite; }
+1
View File
@@ -3,6 +3,7 @@
// Scope: Keeps timeline updates isolated from controlled form inputs so incoming chat activity does not
// force the composer DOM to re-commit while a user is simply watching or driving.
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import './styles.css';
import { useChatActions, useChatTimeline } from '../../context/ChatContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
+12
View File
@@ -0,0 +1,12 @@
.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; }
.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; }
.chat-composer-input { order: 2; }
.chat-composer-tts { flex: 0 1 auto; order: 3; }
.chat-composer-send { order: 4; }
}
@@ -2,6 +2,7 @@
// Purpose: Defines the Drive Dock Action 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 { useState } from 'react';
import '../MobileControls/mobileControls.css';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
@@ -2,6 +2,7 @@
// Purpose: Renders a direct press target for rover GPIO-backed toggles such as the headlight and laser.
// Scope: Owns optimistic button state and touch/click de-duplication while callers provide device labels and actions.
import { useEffect, useMemo, useRef, useState } from 'react';
import '../MobileControls/mobileControls.css';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
function isBoolean(value) {
@@ -2,6 +2,7 @@
// Purpose: Defines the Horn Control 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import '../MobileControls/mobileControls.css';
import { useSettingsNamespace } from '../../settings/index.js';
import { HORN_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import { HORN_MAX_FREQUENCY } from '../../controls/constants.js';
@@ -2,6 +2,8 @@
// 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 './styles.css';
import '../../MobileControls/mobileControls.css';
import { useChatActions } from '../../../context/ChatContext.jsx';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../../settings/index.js';
@@ -0,0 +1 @@
.mobile-text-entry { font-size: 16px; -webkit-text-size-adjust: 100%; touch-action: manipulation; }
@@ -2,6 +2,7 @@
// Purpose: Renders remote rover servers discovered through the inter-instance directory.
// Scope: Owns external server metadata presentation while reusing RoverQueuesPanel for rover/queue rows.
import { useMemo } from 'react';
import './styles.css';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import CardFrame from '../CardFrame/index.jsx';
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
@@ -0,0 +1,7 @@
.inter-instance-overlay-frame { max-width: calc(100vw - .5rem); }
.inter-instance-overlay-body { max-height: 82vh; }
@media (min-width: 1024px) {
.inter-instance-overlay-scale { zoom: 1.5; }
.inter-instance-overlay-frame { max-width: calc((100vw - .5rem) / 1.5); }
.inter-instance-overlay-body { max-height: 54.6667vh; }
}
@@ -2,6 +2,7 @@
// 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 './mobileControls.css';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import HornControl from '../HornControl/index.jsx';
@@ -2,6 +2,7 @@
// Purpose: Provides the mobile movement control pad and speed mode selector.
// Scope: Converts touch pad cells into keyboard-style drive vectors; movement column owns drive/dock placement.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import './mobileControls.css';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { normalizeKeymapEntries } from '../../controls/keymapUtils.js';
import {
@@ -0,0 +1,8 @@
.no-touch-select { user-select: none; -webkit-user-select: none; -ms-user-select: none; -webkit-touch-callout: none; }
.mobile-touch-control {
/* Suppress browser gestures that compete with deliberate control presses. */
user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none;
-webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;
touch-action: manipulation; overscroll-behavior: contain;
}
.mobile-drag-control { touch-action: none; }
@@ -2,6 +2,7 @@
// Purpose: Defines the Mode Gate Overlay 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 { useMemo } from 'react';
import '../InterInstancePanel/styles.css';
import AuthPanel from '../AuthPanel/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSharedClock } from '../../hooks/useSharedClock.js';
+10
View File
@@ -14,6 +14,16 @@ function useTabsContext() {
return context;
}
// eslint-disable-next-line react-refresh/only-export-components
export function useTabIsActive(id) {
/*
Tab content that manages a mounted media lifecycle needs to know whether it
is selected without having the parent shell relay activeTab as a prop. This
reads the same Tabs context that already controls TabPanel visibility.
*/
return useTabsContext().activeTab === id;
}
const TAB_VARIANTS = {
primary: {
base: 'flex-1 px-0.5 py-0.5 text-sm font-medium focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-slate-500 rounded-md border border-slate-800',
@@ -2,6 +2,7 @@
// Purpose: Provides the verified-user entry point and fullscreen controller for the single Reolink PTZ camera.
// Scope: Owns PTZ UI state only; server-side PTZ ownership, rover handoff, and command authorization remain authoritative.
import { useCallback, useEffect, useMemo, useState } from 'react';
import '../MobileControls/mobileControls.css';
import { createPortal } from 'react-dom';
import ChatPanel from '../ChatPanel/index.jsx';
import CardFrame from '../CardFrame/index.jsx';
+128
View File
@@ -0,0 +1,128 @@
// Main Application Shell
// Purpose: Composes the primary rover control interface and page-level layout. Scope: Orchestrates high-level panels, overlays, and feature modules for the default route.
import { useCallback, useEffect, useState } from 'react';
import AlertFeed from '../components/AlertFeed/index.jsx';
import {
ControlSystemProvider,
KeyboardInputManager,
GamepadInputManager,
} from '../controls/index.js';
import ModeGateOverlay from '../components/ModeGateOverlay/index.jsx';
import TurnAlertListener from '../components/TurnAlertListener/index.jsx';
import { useSettingsNamespace } from '../settings/index.js';
import HelpOverlay from '../components/HelpOverlay/index.jsx';
import QuickstartOverlay from '../components/QuickstartOverlay/index.jsx';
import useDefaultNickname from '../hooks/useDefaultNickname.js';
import useUserIdentitySync from '../hooks/useUserIdentitySync.js';
import useIncomingInterInstanceTransfer from '../hooks/useIncomingInterInstanceTransfer.js';
import RewardRunOverlay from '../components/RewardRunOverlay/index.jsx';
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
import DuplicateIdentityOverlay from '../components/DuplicateIdentityOverlay/index.jsx';
import {
DEFAULT_PAGE_THEME_KEY,
getPageThemeClass,
themeGapClass,
} from '../themes/index.js';
import useLayoutMode from '../hooks/useLayoutMode.js';
import { DriverLayoutProvider } from '../layouts/driver/DriverLayoutContext.jsx';
import DriverLayoutRoot from '../layouts/driver/DriverLayoutRoot.jsx';
/* Driver-page compositions live under layouts/driver. App retains only global providers, overlays, and route-level state. */
function DriverPageRoot() {
const layout = useLayoutMode();
const { value: pageSettings } = useSettingsNamespace('page', {
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
});
// Resolve the cookie value through the shared catalog before painting the page. This prevents
// an obsolete or hand-edited key from stripping the background class from every exposed seam.
const pageBackgroundClass = getPageThemeClass(pageSettings?.backgroundTheme);
return (
<div className={`${pageBackgroundClass} text-slate-100`}>
<DriverPageContent layout={layout} />
</div>
);
}
function DriverPageContent({ layout }) {
useDefaultNickname();
useIncomingInterInstanceTransfer();
useUserIdentitySync({ identitySurface: 'driver' });
const {
value: helpSettings,
save: saveHelpSettings,
} = useSettingsNamespace('help', { showOnLoad: true });
const {
value: quickstartSettings,
status: quickstartStatus,
save: saveQuickstartSettings,
} = useSettingsNamespace('quickstart', { showOnLoad: true });
const [helpVisible, setHelpVisible] = useState(false);
const [quickstartVisible, setQuickstartVisible] = useState(false);
useEffect(() => {
if (quickstartStatus === 'ready') {
setQuickstartVisible(quickstartSettings?.showOnLoad !== false);
}
}, [quickstartStatus, quickstartSettings?.showOnLoad]);
const openHelp = useCallback(() => {
setHelpVisible(true);
}, []);
const closeHelp = useCallback(() => {
setHelpVisible(false);
}, []);
const closeQuickstart = useCallback(() => {
setQuickstartVisible(false);
}, []);
const setQuickstartShowOnLoad = useCallback(
(enabled) => {
const next = Boolean(enabled);
saveQuickstartSettings((current) => ({ ...(current ?? {}), showOnLoad: next }));
if (!next) {
setQuickstartVisible(false);
}
},
[saveQuickstartSettings],
);
const openHelpFromQuickstart = useCallback(() => {
setQuickstartVisible(false);
setHelpVisible(true);
}, []);
return (
<ControlSystemProvider>
<KeyboardInputManager />
<GamepadInputManager />
<main className={`relative flex w-full flex-col ${themeGapClass} text-base`}>
<DriverLayoutProvider layout={layout} openHelp={openHelp}>
<DriverLayoutRoot />
</DriverLayoutProvider>
</main>
<AlertFeed />
<DuplicateIdentityOverlay />
<RewardRunOverlay />
<TurnAlertListener />
<ModeGateOverlay />
<SocketConnectionPill />
<HelpOverlay
visible={helpVisible}
layout={layout}
onClose={closeHelp}
showOnLoad={helpSettings?.showOnLoad !== false}
onToggleShowOnLoad={(enabled) => saveHelpSettings((current) => ({ ...(current ?? {}), showOnLoad: Boolean(enabled) }))}
/>
<QuickstartOverlay
visible={quickstartVisible}
layout={layout}
showOnLoad={quickstartSettings?.showOnLoad !== false}
onToggleShowOnLoad={setQuickstartShowOnLoad}
onOpenHelp={openHelpFromQuickstart}
onClose={closeQuickstart}
/>
</ControlSystemProvider>
);
}
export default DriverPageRoot;
-205
View File
@@ -25,15 +25,6 @@ body {
scrollbar-color: rgba(148, 163, 184, 0.55) transparent;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
*::-webkit-scrollbar {
width: 2px;
height: 2px;
@@ -59,40 +50,6 @@ body {
}
@layer components {
.inter-instance-overlay-frame {
/* The unscaled frame always stays inside the viewport on phones and on
desktop browsers that do not apply the larger presentation below. */
max-width: calc(100vw - 0.5rem);
}
.inter-instance-overlay-body {
max-height: 82vh;
}
@media (min-width: 1024px) {
.inter-instance-overlay-scale {
/*
`zoom` enlarges the complete interface—including typography, controls,
spacing, and hit targets—while participating in layout. A transform
would only enlarge the paint result and could overlap or clip sibling
content because the browser would still reserve the original size.
*/
zoom: 1.5;
}
.inter-instance-overlay-frame {
/* Reserve the inverse width before the 1.5x zoom so the final rendered
frame still fits inside the physical desktop viewport. */
max-width: calc((100vw - 0.5rem) / 1.5);
}
.inter-instance-overlay-body {
/* 54.6667vh becomes approximately 82vh after the 1.5x desktop zoom. */
max-height: 54.6667vh;
}
}
.panel {
@apply bg-black text-white p-0 rounded-md;
}
@@ -125,166 +82,4 @@ body {
@apply px-0.5 py-0.5 text-sm font-medium text-white transition-colors bg-rose-600 hover:bg-rose-500 rounded-md;
}
.chat-composer {
/* The chat bar lives in several panel widths, so container queries are more
accurate than viewport media queries. The default narrow layout preserves
a usable chat row first, then moves all TTS controls to a second row. */
container-type: inline-size;
display: flex;
flex-wrap: wrap;
align-items: stretch;
gap: 0.125rem;
min-width: 0;
overflow: hidden;
}
.chat-composer-nickname {
/* Keep nickname deliberately compact so the message input and Send button
remain usable on mobile while still allowing longer typed nicknames. */
flex: 0 0 5rem;
min-width: 0;
order: 1;
}
.chat-composer-input {
/* This input is the pressure valve for the first row. It can shrink to the
remaining space, but the TTS controls no longer steal that row on mobile. */
flex: 1 1 0%;
min-width: 0;
order: 2;
}
.chat-composer-send {
/* Send must stay with the message input in the narrow two-row layout. */
flex: 0 0 auto;
align-self: stretch;
order: 3;
}
.chat-composer-tts {
/* The full-width basis is what creates the second row when the composer is
narrow; the controls themselves still remain one compact TTS strip. */
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 {
/* Once there is enough room for a meaningful message field, all controls
return to the single-row desktop layout. */
flex-wrap: nowrap;
}
.chat-composer-nickname {
flex-basis: 7rem;
order: 1;
}
.chat-composer-input {
order: 2;
}
.chat-composer-tts {
flex: 0 1 auto;
order: 3;
}
.chat-composer-send {
order: 4;
}
}
}
@layer utilities {
@keyframes batteryTickWarn {
0%,
49% {
background-color: rgba(255, 255, 255, 0.95);
}
50%,
100% {
background-color: rgba(239, 68, 68, 0.95);
}
}
.battery-tick-warn {
animation: batteryTickWarn 0.4s steps(2, end) infinite;
}
@keyframes batteryUrgentFlash {
0%,
49% {
opacity: 1;
}
50%,
100% {
opacity: 0.25;
}
}
.battery-urgent-flash {
animation: batteryUrgentFlash 0.4s steps(2, end) infinite;
}
.no-touch-select {
user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
-webkit-touch-callout: none;
}
.mobile-touch-control {
/*
Mobile rover controls are press surfaces, not document text. These flags
deliberately stack the browser-specific knobs because iOS Safari can still
show callouts, selection handles, tap highlights, or delayed gesture behavior
when only the standard property is present.
*/
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
overscroll-behavior: contain;
}
.mobile-drag-control {
/*
Drag controls need the stricter touch-action value so the browser does not
reinterpret a held thumb as page pan, pinch zoom, double-tap zoom, or text
selection while the control is actively tracking pointer movement.
*/
touch-action: none;
}
.mobile-text-entry {
/*
iOS Safari zooms focused form fields whose computed text size is below
16px. Keep HUD chat inputs at that threshold instead of fighting focus with
JavaScript, because the input still needs normal editing and caret behavior.
*/
font-size: 16px;
-webkit-text-size-adjust: 100%;
touch-action: manipulation;
}
.ios-safe-screen {
/*
viewport-fit=cover stops iOS from reserving automatic bars on both ends of
a homescreen app. We then add back only the unsafe notch edges so controls
do not sit under camera hardware, while the opposite edge stays flush.
*/
/* padding-top: env(safe-area-inset-top); */
/* padding-left: env(safe-area-inset-left); */
/* padding-right: env(safe-area-inset-right); */
/* padding-bottom: 0; */
}
}
@@ -0,0 +1,28 @@
// Desktop Driver Layout
// Purpose: Owns the concrete two-pane desktop driver-page composition.
// Scope: Places existing cards without abstracting or changing their presentation.
import DriverVideo from '../../../components/DriverVideo/index.jsx';
import PiHostStatsCard from '../../../components/PiHostStatsCard/index.jsx';
import DriverAdCard from '../../../components/DriverAdCard/index.jsx';
import GlobalObjectiveBanner from '../../../components/GlobalObjectiveBanner/index.jsx';
import { themeGapClass } from '../../../themes/index.js';
import DesktopRightPaneTabs from '../DesktopRightPaneTabs/index.jsx';
import { useTelemetryVisualPolicy } from '../../../context/TelemetryContext.jsx';
export default function DesktopLayout() {
useTelemetryVisualPolicy({ mobile: false });
return (
<div className={`flex h-screen ${themeGapClass} overflow-hidden`}>
<div className={`flex min-w-0 flex-[1.22] flex-col ${themeGapClass} overflow-y-auto pr-0`}>
<DriverVideo />
<PiHostStatsCard />
{/* The self-gating ad remains pinned to the bottom of the left pane. */}
<DriverAdCard className="mt-auto" />
</div>
<div className={`flex min-w-0 flex-1 flex-col ${themeGapClass} overflow-y-auto`}>
<GlobalObjectiveBanner layout="desktop" />
<DesktopRightPaneTabs />
</div>
</div>
);
}
@@ -1,41 +1,34 @@
// Right Pane Tabs
// Purpose: Defines the Right Pane Tabs 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 RoomCameraPanel from '../RoomCameraPanel/index.jsx';
import KinectPanel from '../KinectPanel/index.jsx';
import BalanceBoardPanel from '../BalanceBoardPanel/index.jsx';
import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
import SettingsPanel from '../SettingsPanel/index.jsx';
import HelpPanel from '../HelpPanel/index.jsx';
import ChatPanel from '../ChatPanel/index.jsx';
import { LinkButtonsPanel } from '../UserListPanel/index.jsx';
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
import PtzQueueCard from '../PtzCamera/index.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../Tabs/index.jsx';
import TopDownMap from '../TopDownMap/index.jsx';
import DriveDockAction from '../DriveDockAction/index.jsx';
import { useDriveDockState } from '../DriveDockAction/driveDockState.js';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
import RawUserPilePanel from '../RawUserPilePanel/index.jsx';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
import HornControl from '../HornControl/index.jsx';
import CameraTiltControl from '../CameraTiltControl/index.jsx';
import VipPanel from '../VipPanel/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
import BarcodeGamesPanel from '../BarcodeGamesPanel/index.jsx';
import OdometerPanel from '../OdometerPanel/index.jsx';
import FleetReportsCard from '../FleetReportsCard/index.jsx';
import LiftCard from '../LiftCard/index.jsx';
import NeatoCard from '../NeatoCard/index.jsx';
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
import CardFrame from '../CardFrame/index.jsx';
import RoomCameraPanel from '../../../components/RoomCameraPanel/index.jsx';
import HomeAssistantControls from '../../../components/HomeAssistantControls/index.jsx';
import ChatPanel from '../../../components/ChatPanel/index.jsx';
import { LinkButtonsPanel } from '../../../components/UserListPanel/index.jsx';
import ReplaySourcesPanel from '../../../components/ReplaySourcesPanel/index.jsx';
import PtzQueueCard from '../../../components/PtzCamera/index.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../../../components/Tabs/index.jsx';
import TopDownMap from '../../../components/TopDownMap/index.jsx';
import DriveDockAction from '../../../components/DriveDockAction/index.jsx';
import { useDriveDockState } from '../../../components/DriveDockAction/driveDockState.js';
import { useControlActions, useControlSelector } from '../../../controls/index.js';
import RoverQueuesPanel from '../../../components/RoverQueuesPanel/index.jsx';
import RawUserPilePanel from '../../../components/RawUserPilePanel/index.jsx';
import { formatKeyLabel } from '../../../controls/keymapUtils.js';
import GPIOToggleControl from '../../../components/GPIOToggleControl/index.jsx';
import HornControl from '../../../components/HornControl/index.jsx';
import CameraTiltControl from '../../../components/CameraTiltControl/index.jsx';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../../settings/index.js';
import OverseerPreferencePanel from '../../../components/OverseerPreferencePanel/index.jsx';
import CardFrame from '../../../components/CardFrame/index.jsx';
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import { themeGapClass, themeStackClass } from '../../themes/index.js';
import { useManualDockAssist } from '../../../features/manualDockAssist/useManualDockAssist.js';
import { themeGapClass, themeStackClass } from '../../../themes/index.js';
import ActivitiesTab from '../tabs/shared/ActivitiesTab/index.jsx';
import VipTab from '../tabs/shared/VipTab/index.jsx';
import HelpTab from '../tabs/shared/HelpTab/index.jsx';
import SettingsTab from '../tabs/shared/SettingsTab/index.jsx';
const CHAT_DOCK_INITIAL_HEIGHT = 224;
const CHAT_DOCK_MIN_HEIGHT = 144;
@@ -217,7 +210,7 @@ function QueueReplayLinksRow() {
);
}
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
export default function RightPaneTabs() {
const [activeTab, setActiveTab] = useState('telemetry');
const chatDockRef = useRef(null);
const [chatDockHeight, setChatDockHeight] = useState(CHAT_DOCK_INITIAL_HEIGHT);
@@ -420,36 +413,10 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
</div>
</TabPanel>
{/* activities tab */}
<TabPanel id="activities">
<div className={`flex flex-col ${themeGapClass}`}>
<NeatoCard />
<LiftCard />
<BalanceBoardPanel />
<BarcodeGamesPanel />
<OdometerPanel />
<ButtonBoxPanel />
<KinectPanel />
{/* The compact report is a terminal summary for Activities; the
component itself disappears when the server feature is off. */}
<FleetReportsCard />
</div>
</TabPanel>
{/* VIP tab */}
<TabPanel id="vip" keepMounted>
<VipPanel isActive={activeTab === 'vip'} layout={layout} />
</TabPanel>
{/* help tab */}
<TabPanel id="help">
<HelpPanel layout={layout} onOpenOverlay={onOpenHelpOverlay} />
</TabPanel>
{/* settings tab */}
<TabPanel id="settings">
<SettingsPanel />
</TabPanel>
<ActivitiesTab />
<VipTab />
<HelpTab />
<SettingsTab />
</TabPanels>
</Tabs>
</section>
@@ -0,0 +1,38 @@
// Driver Layout Context
// Purpose: Exposes the existing global Help-overlay action to deeply placed tab content.
// Scope: Avoids threading a layout callback through otherwise concrete composition files.
import { createContext, useContext } from 'react';
const DriverLayoutContext = createContext(null);
export function DriverLayoutProvider({ layout, openHelp, children }) {
/*
Layout mode is classified once at the driver-route boundary. Concrete
layouts and tabs read it here instead of each installing a resize listener
or receiving chains of layout/configuration props.
*/
return <DriverLayoutContext.Provider value={{ layout, openHelp }}>{children}</DriverLayoutContext.Provider>;
}
/*
This hook intentionally lives beside its tiny route-local provider so the
driver layout contract remains discoverable in one file. It is not a React
component export, so the Fast Refresh rule needs this narrow exception.
*/
// eslint-disable-next-line react-refresh/only-export-components
export function useOpenDriverHelp() {
const context = useContext(DriverLayoutContext);
if (!context) {
throw new Error('useOpenDriverHelp must be used within DriverLayoutProvider.');
}
return context.openHelp;
}
// eslint-disable-next-line react-refresh/only-export-components
export function useDriverLayout() {
const context = useContext(DriverLayoutContext);
if (!context) {
throw new Error('useDriverLayout must be used within DriverLayoutProvider.');
}
return context.layout;
}
@@ -0,0 +1,17 @@
// Driver Layout Root
// Purpose: Selects and frames the concrete desktop or mobile driver composition.
// Scope: Owns layout-only mobile framing so App.jsx does not place page cards.
import DesktopLayout from './DesktopLayout/index.jsx';
import MobilePortraitLayout from './MobilePortraitLayout/index.jsx';
import MobileLandscapeLayout from './MobileLandscapeLayout/index.jsx';
import { useDriverLayout } from './DriverLayoutContext.jsx';
export default function DriverLayoutRoot() {
const layout = useDriverLayout();
if (layout === 'desktop') {
return <DesktopLayout />;
}
return layout === 'mobile-landscape' ? <MobileLandscapeLayout /> : <MobilePortraitLayout />;
}
@@ -0,0 +1,37 @@
// Mobile Landscape Driver Layout
// Purpose: Owns the concrete landscape video, controls, and secondary-card placement.
import AuxColumn from '../../../components/MobileControls/AuxColumn.jsx';
import MovementColumn from '../../../components/MobileControls/MovementColumn.jsx';
import DriverVideo from '../../../components/DriverVideo/index.jsx';
import { useSettingsNamespace } from '../../../settings/index.js';
import { themeGapClass } from '../../../themes/index.js';
import MobileLayoutFrame from '../MobileLayoutFrame/index.jsx';
import MobileSecondaryContent from '../MobileSecondaryContent/index.jsx';
export default function MobileLandscapeLayout() {
const { value: pageSettings } = useSettingsNamespace('page', { swapMobileControlColumns: false });
const swap = Boolean(pageSettings?.swapMobileControlColumns);
const columnClass = 'self-start h-[min(100svh,32rem)]';
const firstColumn = swap
? <MovementColumn layout="landscape" className={columnClass} />
: <AuxColumn layout="landscape" className={columnClass} />;
const secondColumn = swap
? <AuxColumn layout="landscape" className={columnClass} />
: <MovementColumn layout="landscape" className={columnClass} />;
return (
<MobileLayoutFrame>
<div className={`flex flex-col ${themeGapClass}`}>
{/* The center column deliberately contains only the driver video. */}
<section className={`grid grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] ${themeGapClass}`}>
{firstColumn}
<div className="min-w-0 self-start">
<DriverVideo layoutFormat="mobile-landscape" />
</div>
{secondColumn}
</section>
<MobileSecondaryContent />
</div>
</MobileLayoutFrame>
);
}
@@ -0,0 +1,47 @@
// Mobile Driver Layout Frame
// Purpose: Owns behavior and framing shared exclusively by portrait and landscape driver layouts.
// Scope: Keeps fullscreen, mobile telemetry policy, objective banner, and snap framing out of the shared driver root.
import { useCallback } from 'react';
import FloatingFullscreenButton from '../../../components/FloatingFullscreenButton/index.jsx';
import FullscreenPrompt from '../../../components/FullscreenPrompt/index.jsx';
import GlobalObjectiveBanner from '../../../components/GlobalObjectiveBanner/index.jsx';
import { useTelemetryVisualPolicy } from '../../../context/TelemetryContext.jsx';
import { useFullscreenPrompt } from '../../../hooks/useFullscreenPrompt.js';
import { useSettingsNamespace } from '../../../settings/index.js';
import { themeGapClass } from '../../../themes/index.js';
import { useDriverLayout } from '../DriverLayoutContext.jsx';
import './styles.css';
export default function MobileLayoutFrame({ children }) {
const layout = useDriverLayout();
useTelemetryVisualPolicy({ mobile: true });
const fullscreen = useFullscreenPrompt(layout);
const { value: pageSettings } = useSettingsNamespace('page', { swapMobileControlColumns: false });
const buttonSide = pageSettings?.swapMobileControlColumns ? 'left' : 'right';
const showButton = fullscreen.isIOS || fullscreen.nativeSupported;
const handleFloatingFullscreen = useCallback(async () => {
if (fullscreen.isIOS) {
fullscreen.showPrompt();
return;
}
const entered = await fullscreen.enterFullscreen();
if (!entered) fullscreen.showPrompt();
}, [fullscreen]);
return (
<div className={`driver-mobile-layout min-h-screen flex flex-col ${themeGapClass}`}>
{/* The absolute marker creates the literal page-top snap without taking flex space. */}
<div className="mobile-top-snap" aria-hidden="true" />
<GlobalObjectiveBanner layout={layout} />
{children}
<FullscreenPrompt
visible={fullscreen.visible}
mode={fullscreen.mode}
onEnterFullscreen={fullscreen.enterFullscreen}
onDismiss={fullscreen.dismiss}
/>
{showButton ? <FloatingFullscreenButton side={buttonSide} onClick={handleFloatingFullscreen} /> : null}
</div>
);
}
@@ -0,0 +1,23 @@
@media (max-width: 1023px) {
html:has(.driver-mobile-layout) {
/* These three driver-only positions deliberately use strict page snapping. */
scroll-snap-type: y mandatory;
}
.driver-mobile-layout .mobile-top-snap {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 1px;
pointer-events: none;
scroll-snap-align: start;
scroll-snap-stop: always;
}
.driver-mobile-layout .mobile-content-snap,
.driver-mobile-layout .mobile-tabs-snap {
scroll-snap-align: start;
scroll-snap-stop: always;
}
}
@@ -0,0 +1,37 @@
// Mobile Portrait Driver Layout
// Purpose: Owns the concrete portrait video, controls, and secondary-card placement.
import AuxColumn from '../../../components/MobileControls/AuxColumn.jsx';
import MovementColumn from '../../../components/MobileControls/MovementColumn.jsx';
import DriverVideo from '../../../components/DriverVideo/index.jsx';
import { useSettingsNamespace } from '../../../settings/index.js';
import { themeGapClass } from '../../../themes/index.js';
import MobileLayoutFrame from '../MobileLayoutFrame/index.jsx';
import MobileSecondaryContent from '../MobileSecondaryContent/index.jsx';
export default function MobilePortraitLayout() {
const { value: pageSettings } = useSettingsNamespace('page', { swapMobileControlColumns: false });
const swap = Boolean(pageSettings?.swapMobileControlColumns);
const columnHeight = 'h-[min(60svh,24rem)]';
const firstColumn = swap
? <MovementColumn layout="portrait" className={columnHeight} />
: <AuxColumn layout="portrait" className={columnHeight} />;
const secondColumn = swap
? <AuxColumn layout="portrait" className={columnHeight} />
: <MovementColumn layout="portrait" className={columnHeight} />;
return (
<MobileLayoutFrame>
<div className={`flex flex-col ${themeGapClass}`}>
<DriverVideo layoutFormat="mobile-portrait" />
{/* Preserve the existing two-column portrait control surface exactly. */}
<section className="mobile-touch-control text-white">
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 items-stretch">
{firstColumn}
{secondColumn}
</div>
</section>
<MobileSecondaryContent />
</div>
</MobileLayoutFrame>
);
}
@@ -0,0 +1,31 @@
// Mobile Driver Secondary Content
// Purpose: Owns the identical replay, queue, tabs, and terminal-ad composition used by both mobile layouts.
import DriverAdCard from '../../../components/DriverAdCard/index.jsx';
import PtzQueueCard from '../../../components/PtzCamera/index.jsx';
import ReplaySourcesPanel from '../../../components/ReplaySourcesPanel/index.jsx';
import RoverQueuesPanel from '../../../components/RoverQueuesPanel/index.jsx';
import { themeGapClass } from '../../../themes/index.js';
import { useDriverLayout } from '../DriverLayoutContext.jsx';
import MobileTabs from '../MobileTabs/index.jsx';
export default function MobileSecondaryContent() {
const layout = useDriverLayout();
const portrait = layout === 'mobile-portrait';
const replayPanelId = portrait
? 'replay-sources-mobile-portrait'
: 'replay-sources-mobile-landscape';
return (
<div className={`mobile-content-snap flex flex-col ${themeGapClass} ${portrait ? '' : 'pb-0'}`}>
<div className={`grid ${themeGapClass} grid-cols-2`}>
<div className="space-y-0.5">
<ReplaySourcesPanel panelId={replayPanelId} />
<PtzQueueCard layout={layout} />
</div>
<RoverQueuesPanel />
</div>
<MobileTabs />
<DriverAdCard />
</div>
);
}
@@ -0,0 +1,54 @@
// Mobile Driver Tabs
// Purpose: Owns the shared mobile tab selector, state, and per-tab modules.
import { useCallback, useState } from 'react';
import Tabs, { Tab, TabList, TabPanels } from '../../../components/Tabs/index.jsx';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useControlSelector } from '../../../controls/index.js';
import { useSettingsNamespace } from '../../../settings/index.js';
import MobileChatTab from '../tabs/mobile/ChatTab/index.jsx';
import RoomControlsTab from '../tabs/mobile/RoomControlsTab/index.jsx';
import ActivitiesTab from '../tabs/shared/ActivitiesTab/index.jsx';
import VipTab from '../tabs/shared/VipTab/index.jsx';
import HelpTab from '../tabs/shared/HelpTab/index.jsx';
import SettingsTab from '../tabs/shared/SettingsTab/index.jsx';
export default function MobileTabs() {
const [activeTab, setActiveTab] = useState('chat');
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const ownRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
const ownAudioForward = useSessionSelector((state) => ownRoverId ? state.session?.audioForward?.[ownRoverId] || null : null);
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
const pttMode = vipAudio?.pttMode === 'clip' ? 'clip' : 'live';
const vipMicActive = Boolean(ownRoverId && isVerified && (pttMode === 'clip' ? pttActive : (vipAudio?.openMicEnabled || pttActive)));
const vipClipPlaying = Boolean(ownRoverId && isVerified && pttMode === 'clip' && ownAudioForward?.source === 'upload' && ownAudioForward?.state === 'playing');
const handleTabChange = useCallback((tab) => setActiveTab(tab), []);
return (
<section className="mobile-tabs-snap text-base">
<Tabs defaultTab="chat" currentTab={activeTab} onTabChange={handleTabChange}>
<TabList>
<Tab id="chat">Chat</Tab>
<Tab id="activities">Activities</Tab>
<Tab id="vip" highlight={vipClipPlaying ? 'green' : vipMicActive ? 'pink' : 'none'}>
<span className="inline-flex items-center gap-0.5">
<span>VIP</span>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${isVerified ? 'bg-emerald-400' : 'bg-amber-400'}`} aria-hidden="true" title={isVerified ? 'Verified' : 'Not verified'} />
</span>
</Tab>
<Tab id="roomcontrols">Room Controls</Tab>
<Tab id="help">Help</Tab>
<Tab id="settings">Settings</Tab>
</TabList>
<TabPanels>
<MobileChatTab />
<ActivitiesTab />
<VipTab />
<RoomControlsTab />
<HelpTab />
<SettingsTab />
</TabPanels>
</Tabs>
</section>
);
}
@@ -0,0 +1,34 @@
// Mobile Chat Tab
// Purpose: Owns the mobile chat and compact supporting-card composition.
import ChatPanel from '../../../../../components/ChatPanel/index.jsx';
import SocialButtonsGrid from '../../../../../components/SocialButtonsGrid/index.jsx';
import OverseerPreferencePanel from '../../../../../components/OverseerPreferencePanel/index.jsx';
import RawUserPilePanel from '../../../../../components/RawUserPilePanel/index.jsx';
import { TabPanel } from '../../../../../components/Tabs/index.jsx';
import { useSessionSelector } from '../../../../../context/SessionContext.jsx';
import { isFeatureEnabled } from '../../../../../lib/features.js';
import { themeGapClass, themeStackClass } from '../../../../../themes/index.js';
export default function MobileChatTab() {
const showOverseerPreferencePanel = useSessionSelector((state) => Boolean(state.session?.overseerVote?.votingEnabled));
const showSocialButtons = useSessionSelector((state) => {
/* Match the card's own gate so its absence cannot reserve an empty column. */
const socials = Array.isArray(state.session?.socials) ? state.session.socials : [];
return isFeatureEnabled(state, 'socials') && socials.length > 0;
});
return (
<TabPanel id="chat">
<div className={themeStackClass}>
<ChatPanel nicknameLayout="stacked" />
<div className={`grid items-start ${themeGapClass} ${showSocialButtons ? 'grid-cols-[minmax(0,1fr)_minmax(0,1fr)]' : 'grid-cols-[minmax(0,1fr)]'}`}>
{showSocialButtons ? <SocialButtonsGrid /> : null}
<div className={`flex min-w-0 flex-col ${themeGapClass} ${showSocialButtons ? '' : 'max-w-sm'}`}>
{showOverseerPreferencePanel ? <OverseerPreferencePanel /> : null}
<RawUserPilePanel hideNicknameForm compact />
</div>
</div>
</div>
</TabPanel>
);
}
@@ -0,0 +1,20 @@
// Mobile Room Controls Tab
// Purpose: Owns the concrete mobile room-controls card order.
import { TabPanel } from '../../../../../components/Tabs/index.jsx';
import HomeAssistantControls from '../../../../../components/HomeAssistantControls/index.jsx';
import RoomCameraPanel from '../../../../../components/RoomCameraPanel/index.jsx';
import { themeStackClass } from '../../../../../themes/index.js';
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
export default function RoomControlsTab() {
const layout = useDriverLayout();
const panelId = layout === 'mobile-landscape' ? 'mobile-landscape-room' : 'mobile-portrait-room';
return (
<TabPanel id="roomcontrols">
<div className={themeStackClass}>
<HomeAssistantControls />
<RoomCameraPanel panelId={panelId} />
</div>
</TabPanel>
);
}
@@ -0,0 +1,30 @@
// Driver Activities Tab
// Purpose: Owns the shared desktop/mobile ordering of activity cards.
import { TabPanel } from '../../../../../components/Tabs/index.jsx';
import NeatoCard from '../../../../../components/NeatoCard/index.jsx';
import LiftCard from '../../../../../components/LiftCard/index.jsx';
import BalanceBoardPanel from '../../../../../components/BalanceBoardPanel/index.jsx';
import BarcodeGamesPanel from '../../../../../components/BarcodeGamesPanel/index.jsx';
import OdometerPanel from '../../../../../components/OdometerPanel/index.jsx';
import ButtonBoxPanel from '../../../../../components/ButtonBoxPanel/index.jsx';
import KinectPanel from '../../../../../components/KinectPanel/index.jsx';
import FleetReportsCard from '../../../../../components/FleetReportsCard/index.jsx';
import { themeGapClass } from '../../../../../themes/index.js';
export default function ActivitiesTab() {
return (
<TabPanel id="activities">
<div className={`flex flex-col ${themeGapClass}`}>
<NeatoCard />
<LiftCard />
<BalanceBoardPanel />
<BarcodeGamesPanel />
<OdometerPanel />
<ButtonBoxPanel />
<KinectPanel />
{/* Fleet reports retains its existing terminal position and self-gate. */}
<FleetReportsCard />
</div>
</TabPanel>
);
}
@@ -0,0 +1,16 @@
// Driver Help Tab
// Purpose: Owns the shared driver help panel placement.
import { TabPanel } from '../../../../../components/Tabs/index.jsx';
import HelpPanel from '../../../../../components/HelpPanel/index.jsx';
import { useOpenDriverHelp } from '../../../DriverLayoutContext.jsx';
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
export default function HelpTab() {
const openHelp = useOpenDriverHelp();
const layout = useDriverLayout();
return (
<TabPanel id="help">
<HelpPanel layout={layout} onOpenOverlay={openHelp} />
</TabPanel>
);
}
@@ -0,0 +1,16 @@
// Driver Settings Tab
// Purpose: Owns the shared driver settings panel placement.
import { TabPanel } from '../../../../../components/Tabs/index.jsx';
import SettingsPanel from '../../../../../components/SettingsPanel/index.jsx';
import { themeStackClass } from '../../../../../themes/index.js';
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
export default function SettingsTab() {
const layout = useDriverLayout();
const wrapped = layout !== 'desktop';
return (
<TabPanel id="settings">
{wrapped ? <div className={themeStackClass}><SettingsPanel /></div> : <SettingsPanel />}
</TabPanel>
);
}
@@ -0,0 +1,15 @@
// Driver VIP Tab
// Purpose: Owns the shared keep-mounted VIP panel lifecycle.
import { TabPanel, useTabIsActive } from '../../../../../components/Tabs/index.jsx';
import VipPanel from '../../../../../components/VipPanel/index.jsx';
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
export default function VipTab() {
const layout = useDriverLayout();
const isActive = useTabIsActive('vip');
return (
<TabPanel id="vip" keepMounted>
<VipPanel isActive={isActive} layout={layout} />
</TabPanel>
);
}
+2 -2
View File
@@ -7,7 +7,7 @@ import './index.css'
// Theme artwork is a separate style concern from global component utilities. Loading its dedicated
// entrypoint here keeps every route consistent without returning theme definitions to index.css.
import './themes/styles/index.css'
import App from './App.jsx'
import DriverPageRoot from './driver/DriverPageRoot.jsx'
import { SocketProvider } from './context/SocketContext.jsx'
import { SessionProvider } from './context/SessionContext.jsx'
import { TelemetryProvider } from './context/TelemetryContext.jsx'
@@ -38,7 +38,7 @@ createRoot(document.getElementById('root')).render(
<BrowserRouter>
<AnalyticsReporter />
<Routes>
<Route path="/" element={<App />} />
<Route path="/" element={<DriverPageRoot />} />
<Route path="/spectate" element={<SpectatorApp />} />
<Route path="/mini" element={<MiniSummaryApp />} />
<Route path="/display" element={<ServerDisplayApp />} />