diff --git a/rulesdocs/refector_rules_and_tracking.md b/rulesdocs/refector_rules_and_tracking.md
index 79329123..67bb2ca6 100644
--- a/rulesdocs/refector_rules_and_tracking.md
+++ b/rulesdocs/refector_rules_and_tracking.md
@@ -9,6 +9,7 @@
- Preserve imports/exports and call signatures unless internal-only and non-observable.
- Validate no runtime behavior changes (manual flow checks + targeted tests when available).
- Make small, reviewable commits per service/component area.
+- Treat `npm run build` output files committed into this repo as intentional deployment artifacts; do not discard them as noise.
## Server backend
- Convert every service into a folder-based structure.
@@ -56,7 +57,7 @@
## WebUI frontend
### BIGGEST OFFENDERS
- [x] mini summary app
-- [ ] spectator app
+- [x] spectator app
- [ ] vip audio upload card
- [ ] admin panel
- [ ] drive dock action
@@ -68,9 +69,11 @@
### COMPLETED COMPONENTS
- mini summary app
+- spectator app
### LARGE CHANGES
- Split `webui/src/mini/MiniSummaryApp.jsx` into folderized modules under `webui/src/mini/MiniSummaryApp/` with a compatibility entrypoint preserved.
+- Split `webui/src/spectate/SpectatorApp.jsx` into folderized modules under `webui/src/spectate/SpectatorApp/` with a compatibility entrypoint preserved.
## Done criteria (per item)
- [ ] Folderized structure created.
diff --git a/webui/src/spectate/SpectatorApp.jsx b/webui/src/spectate/SpectatorApp.jsx
index edb468b9..93404c52 100644
--- a/webui/src/spectate/SpectatorApp.jsx
+++ b/webui/src/spectate/SpectatorApp.jsx
@@ -1,231 +1,3 @@
-import { useEffect, useState } from 'react';
-import { useSession } from '../context/SessionContext.jsx';
-import { useSpectatorMode } from '../hooks/useSpectatorMode.js';
-import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
-import { useVideoRequests } from '../hooks/useVideoRequests.js';
-import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js';
-import VideoTile from '../components/VideoTile.jsx';
-import RoomCameraPanel from '../components/RoomCameraPanel.jsx';
-import ChatPanel from '../components/ChatPanel.jsx';
-import LogPanel from '../components/LogPanel.jsx';
-import AlertFeed from '../components/AlertFeed.jsx';
-import useDefaultNickname from '../hooks/useDefaultNickname.js';
-import CommunityGoalBanner from '../components/CommunityGoalBanner.jsx';
-import RoverQueuesPanel from '../components/RoverQueuesPanel.jsx';
-import RawUserPilePanel from '../components/RawUserPilePanel.jsx';
-import ButtonBoxPanel from '../components/ButtonBoxPanel.jsx';
-import RewardRunOverlay from '../components/RewardRunOverlay.jsx';
+import SpectatorAppRoot from './SpectatorApp/SpectatorAppRoot.jsx';
-function usePortraitLayout() {
- const [isPortrait, setIsPortrait] = useState(() => {
- if (typeof window === 'undefined') return false;
- return window.matchMedia('(max-aspect-ratio: 4/3)').matches;
- });
-
- useEffect(() => {
- if (typeof window === 'undefined') return undefined;
- const media = window.matchMedia('(max-aspect-ratio: 4/3)');
- const handleChange = (event) => setIsPortrait(event.matches);
- if (media.addEventListener) {
- media.addEventListener('change', handleChange);
- } else {
- media.addListener(handleChange);
- }
- return () => {
- if (media.removeEventListener) {
- media.removeEventListener('change', handleChange);
- } else {
- media.removeListener(handleChange);
- }
- };
- }, []);
-
- return isPortrait;
-}
-
-function formatDriverLabel({ roverId, session }) {
- const activeDriverId = session?.activeDrivers?.[roverId] || null;
- const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
- const label = user?.nickname || (activeDriverId ? activeDriverId.slice(0, 6) : 'No driver');
- const mode = session?.mode;
- const turnInfo = session?.turnQueues?.[roverId];
- const driverText = mode === 'turns' && turnInfo?.current ? `${label} (turns)` : label;
-
- return driverText;
-}
-
-function RoverSpectatorCard({ rover, frame, sessionInfo, videoMode, snapshotFeed, audioInfo, session }) {
- const driverLabel = formatDriverLabel({ roverId: rover.id, session });
- return (
-
-
-
-
-
- );
-}
-
-function RoverRow({ roster, frames, videoSources, snapshotFeeds, audioSources, session, canSpectateVideo }) {
- if (roster.length === 0) {
- return
No rovers registered.
;
- }
- return (
-
- {roster.map((rover) => (
-
- ))}
-
- );
-}
-
-function SecondaryRow() {
- return (
-
- );
-}
-
-function LogsRow({ className = '' }) {
- return (
-
-
-
- );
-}
-
-function SpectatorContent() {
- const { session } = useSession();
- const inLockdown = session?.mode === 'lockdown';
- const canSpectateVideo = Boolean(session?.isLocalNetwork);
- useDefaultNickname();
- useSpectatorMode();
- const isPortraitLayout = usePortraitLayout();
- const frames = useTelemetryFrames();
- const roster = session?.roster ?? [];
- const snapshotFeeds = useRoverSnapshots(
- roster.map((rover) => rover.id),
- { enabled: !inLockdown && !canSpectateVideo, version: session?.mode },
- );
- const videoEntries = canSpectateVideo
- ? roster.map((rover) => ({ type: 'rover', id: rover.id, key: rover.id }))
- : [];
- const videoSources = useVideoRequests(videoEntries, { enabled: !inLockdown && canSpectateVideo, version: session?.mode });
- const audioEntries = roster.flatMap((rover) =>
- rover.media?.audioPublishUrl
- ? [{ type: 'rover', id: `${rover.id}-audio`, key: `${rover.id}-audio` }]
- : [],
- );
- const audioSources = useVideoRequests(audioEntries, { enabled: !inLockdown, version: session?.mode });
-
- if (inLockdown) {
- return (
-
-
-
Spectate disabled during lockdown.
-
Please wait until the server leaves lockdown to view streams.
-
-
- );
- }
-
- const mainClass = isPortraitLayout
- ? 'flex min-h-screen flex-col bg-black text-slate-100 md:h-screen md:overflow-hidden'
- : 'grid min-h-screen grid-cols-1 gap-0.5 bg-black text-slate-100 md:h-full md:min-h-0 md:grid-cols-[minmax(0,1fr)_18rem] lg:grid-cols-[minmax(0,1fr)_20rem]';
- const contentClass = isPortraitLayout
- ? 'order-2 flex min-h-0 min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto'
- : 'order-1 flex min-h-0 min-w-0 flex-col gap-0.5 md:overflow-y-auto';
- const sidebarClass = isPortraitLayout
- ? 'order-1 grid w-full min-h-0 items-stretch gap-0.5 border-b border-slate-800/60 bg-slate-950/90 p-0.5 grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.9fr)_minmax(0,0.7fr)]'
- : 'order-2 flex min-h-0 min-w-0 flex-col gap-0.5 border-l border-slate-800/60 bg-slate-950/90 md:h-full md:overflow-y-auto';
- const topBarItemClass = isPortraitLayout ? '' : '';
- const portraitItemHeight = isPortraitLayout ? 'h-64' : '';
-
- return (
-
-
-
- {isPortraitLayout ? (
-
- ) : (
-
-
-
-
- )}
-
- {!isPortraitLayout ? (
-
-
-
- ) : null}
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-export default function SpectatorApp() {
- return ;
-}
+export default SpectatorAppRoot;
diff --git a/webui/src/spectate/SpectatorApp/SpectatorAppRoot.jsx b/webui/src/spectate/SpectatorApp/SpectatorAppRoot.jsx
new file mode 100644
index 00000000..1a1ed8ec
--- /dev/null
+++ b/webui/src/spectate/SpectatorApp/SpectatorAppRoot.jsx
@@ -0,0 +1,6 @@
+// Spectator app compatibility root.
+import SpectatorContent from './SpectatorContent.jsx';
+
+export default function SpectatorAppRoot() {
+ return ;
+}
diff --git a/webui/src/spectate/SpectatorApp/SpectatorContent.jsx b/webui/src/spectate/SpectatorApp/SpectatorContent.jsx
new file mode 100644
index 00000000..59352fb5
--- /dev/null
+++ b/webui/src/spectate/SpectatorApp/SpectatorContent.jsx
@@ -0,0 +1,122 @@
+// Main spectator screen layout and feed wiring.
+import { useSession } from '../../context/SessionContext.jsx';
+import { useTelemetryFrames } from '../../context/TelemetryContext.jsx';
+import { useVideoRequests } from '../../hooks/useVideoRequests.js';
+import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
+import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
+import useDefaultNickname from '../../hooks/useDefaultNickname.js';
+import ChatPanel from '../../components/ChatPanel.jsx';
+import AlertFeed from '../../components/AlertFeed.jsx';
+import CommunityGoalBanner from '../../components/CommunityGoalBanner.jsx';
+import RoverQueuesPanel from '../../components/RoverQueuesPanel.jsx';
+import RawUserPilePanel from '../../components/RawUserPilePanel.jsx';
+import ButtonBoxPanel from '../../components/ButtonBoxPanel.jsx';
+import RewardRunOverlay from '../../components/RewardRunOverlay.jsx';
+import usePortraitLayout from './hooks/usePortraitLayout.js';
+import RoverRow from './components/RoverRow.jsx';
+import SecondaryRow from './components/SecondaryRow.jsx';
+import LogsRow from './components/LogsRow.jsx';
+
+export default function SpectatorContent() {
+ const { session } = useSession();
+ const inLockdown = session?.mode === 'lockdown';
+ const canSpectateVideo = Boolean(session?.isLocalNetwork);
+ useDefaultNickname();
+ useSpectatorMode();
+ const isPortraitLayout = usePortraitLayout();
+ const frames = useTelemetryFrames();
+ const roster = session?.roster ?? [];
+ const snapshotFeeds = useRoverSnapshots(
+ roster.map((rover) => rover.id),
+ { enabled: !inLockdown && !canSpectateVideo, version: session?.mode },
+ );
+ const videoEntries = canSpectateVideo
+ ? roster.map((rover) => ({ type: 'rover', id: rover.id, key: rover.id }))
+ : [];
+ const videoSources = useVideoRequests(videoEntries, {
+ enabled: !inLockdown && canSpectateVideo,
+ version: session?.mode,
+ });
+ const audioEntries = roster.flatMap((rover) =>
+ rover.media?.audioPublishUrl
+ ? [{ type: 'rover', id: `${rover.id}-audio`, key: `${rover.id}-audio` }]
+ : [],
+ );
+ const audioSources = useVideoRequests(audioEntries, { enabled: !inLockdown, version: session?.mode });
+
+ if (inLockdown) {
+ return (
+
+
+
Spectate disabled during lockdown.
+
Please wait until the server leaves lockdown to view streams.
+
+
+ );
+ }
+
+ const mainClass = isPortraitLayout
+ ? 'flex min-h-screen flex-col bg-black text-slate-100 md:h-screen md:overflow-hidden'
+ : 'grid min-h-screen grid-cols-1 gap-0.5 bg-black text-slate-100 md:h-full md:min-h-0 md:grid-cols-[minmax(0,1fr)_18rem] lg:grid-cols-[minmax(0,1fr)_20rem]';
+ const contentClass = isPortraitLayout
+ ? 'order-2 flex min-h-0 min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto'
+ : 'order-1 flex min-h-0 min-w-0 flex-col gap-0.5 md:overflow-y-auto';
+ const sidebarClass = isPortraitLayout
+ ? 'order-1 grid w-full min-h-0 items-stretch gap-0.5 border-b border-slate-800/60 bg-slate-950/90 p-0.5 grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.9fr)_minmax(0,0.7fr)]'
+ : 'order-2 flex min-h-0 min-w-0 flex-col gap-0.5 border-l border-slate-800/60 bg-slate-950/90 md:h-full md:overflow-y-auto';
+ const topBarItemClass = isPortraitLayout ? '' : '';
+ const portraitItemHeight = isPortraitLayout ? 'h-64' : '';
+
+ return (
+
+
+
+ {isPortraitLayout ? (
+
+ ) : (
+
+
+
+
+ )}
+
+ {!isPortraitLayout ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/webui/src/spectate/SpectatorApp/components/LogsRow.jsx b/webui/src/spectate/SpectatorApp/components/LogsRow.jsx
new file mode 100644
index 00000000..c05fd9e9
--- /dev/null
+++ b/webui/src/spectate/SpectatorApp/components/LogsRow.jsx
@@ -0,0 +1,10 @@
+// Log panel wrapper row for spectator layout.
+import LogPanel from '../../../components/LogPanel.jsx';
+
+export default function LogsRow({ className = '' }) {
+ return (
+
+
+
+ );
+}
diff --git a/webui/src/spectate/SpectatorApp/components/RoverRow.jsx b/webui/src/spectate/SpectatorApp/components/RoverRow.jsx
new file mode 100644
index 00000000..7a7efe27
--- /dev/null
+++ b/webui/src/spectate/SpectatorApp/components/RoverRow.jsx
@@ -0,0 +1,26 @@
+// Rover grid row for spectator view.
+import RoverSpectatorCard from './RoverSpectatorCard.jsx';
+
+export default function RoverRow({ roster, frames, videoSources, snapshotFeeds, audioSources, session, canSpectateVideo }) {
+ if (roster.length === 0) {
+ return No rovers registered.
;
+ }
+ return (
+
+ {roster.map((rover) => (
+
+ ))}
+
+ );
+}
diff --git a/webui/src/spectate/SpectatorApp/components/RoverSpectatorCard.jsx b/webui/src/spectate/SpectatorApp/components/RoverSpectatorCard.jsx
new file mode 100644
index 00000000..71656cd1
--- /dev/null
+++ b/webui/src/spectate/SpectatorApp/components/RoverSpectatorCard.jsx
@@ -0,0 +1,27 @@
+// Single rover spectator card containing live/snapshot tile and HUD.
+import VideoTile from '../../../components/VideoTile.jsx';
+import { formatDriverLabel } from '../utils.js';
+
+export default function RoverSpectatorCard({ rover, frame, sessionInfo, videoMode, snapshotFeed, audioInfo, session }) {
+ const driverLabel = formatDriverLabel({ roverId: rover.id, session });
+ return (
+
+
+
+
+
+ );
+}
diff --git a/webui/src/spectate/SpectatorApp/components/SecondaryRow.jsx b/webui/src/spectate/SpectatorApp/components/SecondaryRow.jsx
new file mode 100644
index 00000000..58b20e1e
--- /dev/null
+++ b/webui/src/spectate/SpectatorApp/components/SecondaryRow.jsx
@@ -0,0 +1,17 @@
+// Secondary panel row showing room cameras.
+import RoomCameraPanel from '../../../components/RoomCameraPanel.jsx';
+
+export default function SecondaryRow() {
+ return (
+
+ );
+}
diff --git a/webui/src/spectate/SpectatorApp/hooks/usePortraitLayout.js b/webui/src/spectate/SpectatorApp/hooks/usePortraitLayout.js
new file mode 100644
index 00000000..c43d0115
--- /dev/null
+++ b/webui/src/spectate/SpectatorApp/hooks/usePortraitLayout.js
@@ -0,0 +1,29 @@
+// Detect portrait-oriented viewport layout mode for spectator UI.
+import { useEffect, useState } from 'react';
+
+export default function usePortraitLayout() {
+ const [isPortrait, setIsPortrait] = useState(() => {
+ if (typeof window === 'undefined') return false;
+ return window.matchMedia('(max-aspect-ratio: 4/3)').matches;
+ });
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return undefined;
+ const media = window.matchMedia('(max-aspect-ratio: 4/3)');
+ const handleChange = (event) => setIsPortrait(event.matches);
+ if (media.addEventListener) {
+ media.addEventListener('change', handleChange);
+ } else {
+ media.addListener(handleChange);
+ }
+ return () => {
+ if (media.removeEventListener) {
+ media.removeEventListener('change', handleChange);
+ } else {
+ media.removeListener(handleChange);
+ }
+ };
+ }, []);
+
+ return isPortrait;
+}
diff --git a/webui/src/spectate/SpectatorApp/utils.js b/webui/src/spectate/SpectatorApp/utils.js
new file mode 100644
index 00000000..d1349f12
--- /dev/null
+++ b/webui/src/spectate/SpectatorApp/utils.js
@@ -0,0 +1,11 @@
+// Spectator app display formatting helpers.
+export function formatDriverLabel({ roverId, session }) {
+ const activeDriverId = session?.activeDrivers?.[roverId] || null;
+ const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
+ const label = user?.nickname || (activeDriverId ? activeDriverId.slice(0, 6) : 'No driver');
+ const mode = session?.mode;
+ const turnInfo = session?.turnQueues?.[roverId];
+ const driverText = mode === 'turns' && turnInfo?.current ? `${label} (turns)` : label;
+
+ return driverText;
+}