mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
big boy webui new new new new new 100 files changed 80 years
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
New Drive corner-pod HUD sketch, second design pass
|
||||
Purpose: Shows true edge-mounted pods, physically attached expansions, and circular controls.
|
||||
Scope: Static design communication only; production geometry remains an implementation decision.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900" role="img" aria-labelledby="title description">
|
||||
<title id="title">Edge-mounted New Drive HUD pods</title>
|
||||
<desc id="description">Four pods flow directly into the corners of a four by three rover video. Each has one inward rounded corner, and expansions attach directly along video edges.</desc>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="video" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#26343d" />
|
||||
<stop offset="0.55" stop-color="#111827" />
|
||||
<stop offset="1" stop-color="#1f2937" />
|
||||
</linearGradient>
|
||||
<linearGradient id="battery" x1="0" y1="1" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#22c55e" />
|
||||
<stop offset="0.72" stop-color="#84cc16" />
|
||||
<stop offset="1" stop-color="#eab308" />
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feDropShadow dx="0" dy="5" stdDeviation="8" flood-color="#000000" flood-opacity="0.5" />
|
||||
</filter>
|
||||
<style>
|
||||
.pod { fill: #080a0f; fill-opacity: 0.86; stroke: #d1d5db; stroke-opacity: 0.26; stroke-width: 2; }
|
||||
.expansion { fill: #080a0f; fill-opacity: 0.82; stroke: #d1d5db; stroke-opacity: 0.2; stroke-width: 2; }
|
||||
.label { fill: #f8fafc; font-family: Inter, system-ui, sans-serif; font-weight: 700; }
|
||||
.small { fill: #cbd5e1; font-family: Inter, system-ui, sans-serif; font-size: 17px; }
|
||||
.tiny { fill: #94a3b8; font-family: Inter, system-ui, sans-serif; font-size: 14px; }
|
||||
.arrow-button { fill: #1f2937; stroke: #e5e7eb; stroke-opacity: 0.55; stroke-width: 1.5; }
|
||||
.arrow { fill: none; stroke: #f8fafc; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.track { fill: none; stroke: #334155; stroke-linecap: round; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- The entire canvas is the shared 4:3 video/HUD coordinate space. -->
|
||||
<rect width="1200" height="900" fill="url(#video)" />
|
||||
<path d="M0 610 C235 510 390 590 600 515 C820 438 1000 520 1200 430 L1200 900 L0 900 Z" fill="#0b1516" opacity="0.76" />
|
||||
<path d="M0 655 C240 555 425 635 630 560 C840 483 1020 560 1200 475" fill="none" stroke="#334155" stroke-width="5" opacity="0.42" />
|
||||
<text x="600" y="450" text-anchor="middle" class="small" opacity="0.28">Rover video</text>
|
||||
|
||||
<!-- Top-left pod flows into the top and left edges; only its inward bottom-right corner rounds. -->
|
||||
<g filter="url(#shadow)">
|
||||
<path class="pod" d="M0 0 H190 V124 Q190 190 124 190 H0 Z" />
|
||||
<circle class="track" cx="91" cy="91" r="55" stroke-width="14" />
|
||||
<circle cx="91" cy="91" r="55" fill="none" stroke="#38bdf8" stroke-width="14" stroke-linecap="round" stroke-dasharray="255 346" transform="rotate(-90 91 91)" />
|
||||
<text x="91" y="84" text-anchor="middle" class="tiny">Turn</text>
|
||||
<text x="91" y="116" text-anchor="middle" class="label" font-size="30">0:42</text>
|
||||
<circle class="arrow-button" cx="22" cy="22" r="16" />
|
||||
<path class="arrow" d="M29 29 L16 16 M16 16 L25 16 M16 16 L16 25" />
|
||||
|
||||
<!-- This expansion begins exactly where the pod ends and continues directly into the top edge. -->
|
||||
<path class="expansion" d="M190 0 H486 V50 Q486 78 458 78 H190 Z" />
|
||||
<rect x="210" y="20" width="200" height="38" rx="8" fill="#7c3aed" opacity="0.72" />
|
||||
<text x="310" y="45" text-anchor="middle" class="label" font-size="18">Rover name</text>
|
||||
<circle class="arrow-button" cx="458" cy="39" r="14" />
|
||||
<path class="arrow" d="M458 46 L458 32 M458 32 L452 38 M458 32 L464 38" />
|
||||
</g>
|
||||
|
||||
<!-- Top-right pod and both expansions form one continuous edge-mounted cluster. -->
|
||||
<g filter="url(#shadow)">
|
||||
<path class="pod" d="M1010 0 H1200 V190 H1076 Q1010 190 1010 124 Z" />
|
||||
<circle cx="1105" cy="91" r="59" fill="none" stroke="#334155" stroke-width="13" />
|
||||
<circle cx="1105" cy="91" r="59" fill="none" stroke="url(#battery)" stroke-width="13" stroke-linecap="round" stroke-dasharray="300 371" transform="rotate(-90 1105 91)" />
|
||||
<circle cx="1105" cy="91" r="42" fill="none" stroke="#334155" stroke-width="7" />
|
||||
<circle cx="1105" cy="91" r="42" fill="none" stroke="#f59e0b" stroke-width="7" stroke-linecap="round" stroke-dasharray="112 264" transform="rotate(-90 1105 91)" />
|
||||
<text x="1105" y="101" text-anchor="middle" class="label" font-size="30">81%</text>
|
||||
<circle class="arrow-button" cx="1178" cy="22" r="16" />
|
||||
<path class="arrow" d="M1171 29 L1184 16 M1184 16 L1175 16 M1184 16 L1184 25" />
|
||||
|
||||
<!-- Left expansion is attached to the pod at x=1010 and touches the top video edge. -->
|
||||
<path class="expansion" d="M690 0 H1010 V78 H718 Q690 78 690 50 Z" />
|
||||
<text x="718" y="31" class="label" font-size="18">Dock assist</text>
|
||||
<text x="718" y="57" class="small">Dock rover</text>
|
||||
<rect x="912" y="24" width="38" height="30" rx="6" fill="#312e81" stroke="#a5b4fc" />
|
||||
<text x="931" y="45" text-anchor="middle" class="label" font-size="14">G</text>
|
||||
<circle class="arrow-button" cx="980" cy="39" r="14" />
|
||||
<path class="arrow" d="M980 46 L980 32 M980 32 L974 38 M980 32 L986 38" />
|
||||
|
||||
<!-- Lower expansion shares the pod's bottom edge and flows directly into the right edge. -->
|
||||
<path class="expansion" d="M930 190 H1200 V420 H996 Q930 420 930 354 Z" />
|
||||
<text x="958" y="225" class="label" font-size="18">Advanced power</text>
|
||||
<text x="958" y="255" class="small">Voltage</text>
|
||||
<rect x="958" y="266" width="214" height="8" rx="2" fill="#334155" />
|
||||
<rect x="958" y="266" width="160" height="8" rx="2" fill="#38bdf8" />
|
||||
<text x="958" y="306" class="small">Current</text>
|
||||
<rect x="958" y="317" width="214" height="8" rx="2" fill="#334155" />
|
||||
<rect x="958" y="317" width="90" height="8" rx="2" fill="#f59e0b" />
|
||||
<text x="958" y="359" class="tiny">Computer 54 C</text>
|
||||
<text x="958" y="383" class="tiny">Wi-Fi -58 dBm</text>
|
||||
<circle class="arrow-button" cx="1174" cy="216" r="14" />
|
||||
<path class="arrow" d="M1167 216 L1181 216 M1181 216 L1175 210 M1181 216 L1175 222" />
|
||||
</g>
|
||||
|
||||
<!-- Bottom-left pod flows into the left and bottom edges with a compact triangular control group. -->
|
||||
<g filter="url(#shadow)">
|
||||
<path class="pod" d="M0 680 H220 Q300 680 300 760 V900 H0 Z" />
|
||||
<circle cx="68" cy="758" r="38" fill="#172554" stroke="#60a5fa" stroke-width="2" />
|
||||
<text x="68" y="754" text-anchor="middle" class="label" font-size="24">☀</text>
|
||||
<text x="68" y="779" text-anchor="middle" class="tiny">E</text>
|
||||
<circle cx="102" cy="850" r="38" fill="#3b2f0b" stroke="#facc15" stroke-width="2" />
|
||||
<text x="102" y="846" text-anchor="middle" class="label" font-size="24">⌁</text>
|
||||
<text x="102" y="871" text-anchor="middle" class="tiny">R</text>
|
||||
<circle cx="208" cy="798" r="57" fill="#3f1d2e" stroke="#fb7185" stroke-width="3" />
|
||||
<text x="208" y="794" text-anchor="middle" class="label" font-size="25">Horn</text>
|
||||
<text x="208" y="823" text-anchor="middle" class="tiny">H</text>
|
||||
<circle class="arrow-button" cx="252" cy="754" r="14" />
|
||||
<path class="arrow" d="M246 754 L258 754 M258 754 L253 749 M258 754 L253 759" />
|
||||
<circle class="arrow-button" cx="22" cy="878" r="16" />
|
||||
<path class="arrow" d="M29 871 L16 884 M16 884 L25 884 M16 884 L16 875" />
|
||||
</g>
|
||||
|
||||
<!-- Bottom-right pod contains a circular tilt slider rather than a horizontal or pill track. -->
|
||||
<g filter="url(#shadow)">
|
||||
<path class="pod" d="M840 900 V760 Q840 680 920 680 H1200 V900 Z" />
|
||||
<text x="1168" y="714" text-anchor="end" class="label" font-size="18">Camera tilt</text>
|
||||
<circle class="track" cx="1030" cy="800" r="76" stroke-width="13" />
|
||||
<circle cx="1030" cy="800" r="76" fill="none" stroke="#38bdf8" stroke-width="13" stroke-linecap="round" stroke-dasharray="285 478" transform="rotate(140 1030 800)" />
|
||||
<circle cx="976" cy="746" r="13" fill="#e0f2fe" stroke="#0284c7" stroke-width="4" />
|
||||
<text x="1030" y="808" text-anchor="middle" class="label" font-size="25">-12.5°</text>
|
||||
<text x="1030" y="832" text-anchor="middle" class="tiny">Click for zero</text>
|
||||
<circle cx="948" cy="838" r="22" fill="#1e3a8a" stroke="#93c5fd" />
|
||||
<text x="948" y="844" text-anchor="middle" class="label" font-size="14">J</text>
|
||||
<circle cx="1112" cy="838" r="22" fill="#1e3a8a" stroke="#93c5fd" />
|
||||
<text x="1112" y="844" text-anchor="middle" class="label" font-size="14">U</text>
|
||||
<circle class="arrow-button" cx="1178" cy="878" r="16" />
|
||||
<path class="arrow" d="M1171 871 L1184 884 M1184 884 L1175 884 M1184 884 L1184 875" />
|
||||
</g>
|
||||
|
||||
<!-- Immediate sensor overlays remain separate and are shown only as faint context here. -->
|
||||
<path d="M360 900 Q600 808 840 900" fill="none" stroke="#ef4444" stroke-width="13" stroke-linecap="round" opacity="0.25" />
|
||||
<path d="M410 886 Q600 820 790 886" fill="none" stroke="#22c55e" stroke-width="5" stroke-dasharray="12 10" opacity="0.4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
+48
-1
@@ -25,4 +25,51 @@
|
||||
- idk
|
||||
- right
|
||||
- chat, users, rovers list, and replay sources
|
||||
- everything involving the rover is a video HUD, everything external is in the sidebars
|
||||
- everything involving the rover is a video HUD, everything external is in the sidebars
|
||||
|
||||
|
||||
|
||||
|
||||
## section 2
|
||||
|
||||
There will be corner mounted (one pod in each corner of the video), rounded pods in the HUD, which will contain gauges and controls for the rover
|
||||
|
||||
These pods will be collapsible, with a corner mounted arrow. the arrow points towards the corner when the pod is out, and points out of the corner when the pod is hidden.
|
||||
|
||||
There can also be "pod expansions" that will be in the corner of the pod and the side of the video. These are also collapsible, but they collapse into the side of the video that they are touching, instead of collapsing into the corner, with the same style arrow button as the pods.
|
||||
|
||||
For example, a pod in the top left is open. This pod has an expansion to it's right that is also open. I can collapse the pod into the corner, the expansion stays, it gets moved into the top left corner where the pod was.
|
||||
|
||||
- corner pods:
|
||||
- top left
|
||||
- pod
|
||||
- turns timer
|
||||
- round gauge circle that ticks down with time
|
||||
- inside it, is the turn countdown
|
||||
- this pod goes away when theres nothing to count
|
||||
- right of pod expansion
|
||||
- rover name with colored background
|
||||
- expanded by default
|
||||
- top right
|
||||
- pod
|
||||
- round rover battery bar gauge, based off how battery bar looks
|
||||
- concentric to this bar is an unlabeled current gauge, styled after the current bar that the top down map contains
|
||||
- inside the circle, is the battery percentage.
|
||||
- left of pod expansion
|
||||
- dock assist button and keybind
|
||||
- expanded by default
|
||||
- below pod expansion
|
||||
- combined advanced power view for the roomba with other info from rover host stats
|
||||
- bottom left
|
||||
- pod
|
||||
- has circular buttons for laser, horn, and headlight
|
||||
- each button is a related icon and the keybind label for the feature
|
||||
- arranged nicely
|
||||
- horn button is larger, and contains an arrow to open the horn settings menu
|
||||
- this pod disappears when none of these things are enabled
|
||||
- if one of the button's features is not enabled, that button should go away
|
||||
- bottom right
|
||||
- pod
|
||||
- rounded camera tilt slider, with keybind label on each end for up / down
|
||||
- in the area inside the slider, show the tilt degrees
|
||||
- clicking the degrees label should set camera tilt to 0
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
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
@@ -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-F5hHfgX6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CaoGM6_n.css">
|
||||
<script type="module" crossorigin src="/assets/index-2kYHs9Qn.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CX--Qo38.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -6,10 +6,12 @@ const { renderIndexHtml, renderOgImage, renderWebManifest } = require('../embedS
|
||||
|
||||
/*
|
||||
Every client-side BrowserRouter entry point must also be an explicit HTTP
|
||||
entry point. Including /ptz here lets direct loads and browser refreshes
|
||||
receive the same rendered index document as navigation from the driver page.
|
||||
entry point. Keeping this list aligned with webui/src/main.jsx lets direct
|
||||
loads and browser refreshes receive the same rendered index document as
|
||||
in-app navigation. The retired desktop composition is intentionally exposed
|
||||
at /old; the removed /newdrive route is intentionally absent.
|
||||
*/
|
||||
app.get(['/', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports'], async (req, res) => {
|
||||
app.get(['/', '/old', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports'], async (req, res) => {
|
||||
try {
|
||||
const html = await renderIndexHtml(req);
|
||||
res.type('html').send(html);
|
||||
|
||||
Generated
+11
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
@@ -1421,6 +1422,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/container-queries": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz",
|
||||
"integrity": "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@thumbmarkjs/thumbmarkjs": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@thumbmarkjs/thumbmarkjs/-/thumbmarkjs-1.10.0.tgz",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -19,6 +19,7 @@ const EMPTY_SPECTATOR_TELEMETRY = Object.freeze({
|
||||
voltageMv: null,
|
||||
currentMa: null,
|
||||
batteryChargeMah: null,
|
||||
batteryCapacityMah: null,
|
||||
oiModeLabel: 'Unknown',
|
||||
chargingStateLabel: '',
|
||||
homeBase: false,
|
||||
@@ -136,6 +137,7 @@ export function selectSpectatorTelemetry(frame) {
|
||||
voltageMv: bucketNumber(sensors.voltageMv, 25),
|
||||
currentMa: bucketNumber(sensors.currentMa, 25),
|
||||
batteryChargeMah: sensors.batteryChargeMah ?? null,
|
||||
batteryCapacityMah: sensors.batteryCapacityMah ?? null,
|
||||
oiModeLabel: sensors.oiMode?.label || 'Unknown',
|
||||
chargingStateLabel: sensors.chargingState?.label || '',
|
||||
homeBase: Boolean(sensors.chargingSources?.homeBase),
|
||||
|
||||
@@ -29,7 +29,7 @@ import DriverLayoutRoot from '../layouts/driver/DriverLayoutRoot.jsx';
|
||||
import UndockedPageExitGuard from '../components/UndockedPageExitGuard/index.jsx';
|
||||
|
||||
/* Driver-page compositions live under layouts/driver. App retains only global providers, overlays, and route-level state. */
|
||||
function DriverPageRoot() {
|
||||
function DriverPageRoot({ oldDesktop = false }) {
|
||||
const layout = useLayoutMode();
|
||||
const { value: pageSettings } = useSettingsNamespace('page', {
|
||||
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
|
||||
@@ -40,12 +40,12 @@ function DriverPageRoot() {
|
||||
|
||||
return (
|
||||
<div className={`${pageBackgroundClass} text-slate-100`}>
|
||||
<DriverPageContent layout={layout} />
|
||||
<DriverPageContent layout={layout} oldDesktop={oldDesktop} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DriverPageContent({ layout }) {
|
||||
function DriverPageContent({ layout, oldDesktop }) {
|
||||
useDefaultNickname();
|
||||
useIncomingInterInstanceTransfer();
|
||||
useUserIdentitySync({ identitySurface: 'driver' });
|
||||
@@ -103,7 +103,9 @@ function DriverPageContent({ layout }) {
|
||||
<GamepadInputManager />
|
||||
<main className={`relative flex w-full flex-col ${themeGapClass} text-base`}>
|
||||
<DriverLayoutProvider layout={layout} openHelp={openHelp}>
|
||||
<DriverLayoutRoot />
|
||||
{/* Route selection affects only which desktop composition is mounted;
|
||||
all providers, overlays, inputs, and mobile layouts remain shared. */}
|
||||
<DriverLayoutRoot oldDesktop={oldDesktop} />
|
||||
</DriverLayoutProvider>
|
||||
</main>
|
||||
<AlertFeed />
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Rover Control Availability
|
||||
// Purpose: Mirrors the server turnService.canDrive rule for immediate, consistent UI disabling.
|
||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||
|
||||
export function selectCanControlRover(state, roverId) {
|
||||
const session = state.session;
|
||||
if (!roverId || session?.role === 'spectator') return false;
|
||||
if (session?.mode !== 'turns') return true;
|
||||
|
||||
const turnInfo = session?.turnQueues?.[roverId] || null;
|
||||
const queue = Array.isArray(turnInfo?.queue) ? turnInfo.queue : [];
|
||||
|
||||
/* The server permits control when a turns queue is absent or contains at most
|
||||
one driver. Only a genuinely shared queue requires the active socket id.
|
||||
Keeping this exact ordering prevents initial session loading from disabling
|
||||
an otherwise uncontested rover. The server remains the final authority. */
|
||||
if (queue.length <= 1) return true;
|
||||
/* activeDrivers is the server's direct ownership map and can arrive before
|
||||
the richer queue snapshot during an initial load or reconnect. The queue
|
||||
field remains a compatibility fallback for intermediate payloads. */
|
||||
const activeDriverId = session?.activeDrivers?.[roverId] || turnInfo?.current || null;
|
||||
return Boolean(session?.socketId && activeDriverId === session.socketId);
|
||||
}
|
||||
|
||||
export default function useCanControlRover(roverId) {
|
||||
return useSessionSelector((state) => selectCanControlRover(state, roverId));
|
||||
}
|
||||
+12
-28
@@ -14,39 +14,23 @@ body {
|
||||
@apply bg-black text-slate-100;
|
||||
}
|
||||
|
||||
/*
|
||||
Native scrollbar gutters consume meaningful width in the application's many
|
||||
nested sidebars and logs. Hide only the browser chrome globally; overflow,
|
||||
wheel, trackpad, touch, keyboard, and programmatic scrolling remain unchanged.
|
||||
*/
|
||||
html,
|
||||
body {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.55) transparent;
|
||||
}
|
||||
|
||||
body,
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.55) transparent;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar,
|
||||
*::-webkit-scrollbar {
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(148, 163, 184, 0.55);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
@supports (scrollbar-width: thin) {
|
||||
@media (pointer: coarse) {
|
||||
html,
|
||||
body,
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
}
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
||||
@@ -1,28 +1,112 @@
|
||||
// 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';
|
||||
// Purpose: Places existing site columns around the full-height rover video and integrated HUD.
|
||||
// Scope: Owns the default desktop composition only; video and HUD behavior remain component concerns.
|
||||
import NewDriveVideo from '../../../components/NewDriveVideo/index.jsx';
|
||||
import ChatPanel from '../../../components/ChatPanel/index.jsx';
|
||||
import RawUserPilePanel from '../../../components/RawUserPilePanel/index.jsx';
|
||||
import RoverQueuesPanel from '../../../components/RoverQueuesPanel/index.jsx';
|
||||
import ReplaySourcesPanel from '../../../components/ReplaySourcesPanel/index.jsx';
|
||||
import HomeAssistantControls from '../../../components/HomeAssistantControls/index.jsx';
|
||||
import RoomCameraPanel from '../../../components/RoomCameraPanel/index.jsx';
|
||||
import GlobalObjectiveBanner from '../../../components/GlobalObjectiveBanner/index.jsx';
|
||||
import DriverAdCard from '../../../components/DriverAdCard/index.jsx';
|
||||
import OverseerPreferencePanel from '../../../components/OverseerPreferencePanel/index.jsx';
|
||||
import PtzQueueCard from '../../../components/PtzCamera/index.jsx';
|
||||
import { LinkButtonsPanel } from '../../../components/UserListPanel/index.jsx';
|
||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../../../components/Tabs/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';
|
||||
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 });
|
||||
function LeftColumnTabs() {
|
||||
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 />
|
||||
<Tabs defaultTab="room">
|
||||
<TabList className="sticky top-0 z-30 shrink-0 bg-neutral-950">
|
||||
<Tab id="room">Room</Tab>
|
||||
<Tab id="activities">Activities</Tab>
|
||||
<Tab id="vip">Vip</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel id="room">
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
{/* The objective belongs below the selector so the tab bar remains the first visible,
|
||||
sticky control at the top of the independently scrolling left column. */}
|
||||
<GlobalObjectiveBanner layout="desktop" />
|
||||
<HomeAssistantControls />
|
||||
<RoomCameraPanel defaultOrientation="vertical" panelId="newdrive-left-sidebar" />
|
||||
</div>
|
||||
</TabPanel>
|
||||
{/* These shared tabs retain their existing feature gates and mounted-media behavior. */}
|
||||
<ActivitiesTab />
|
||||
<VipTab />
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityColumn() {
|
||||
return (
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
{/* A fixed initial chat height keeps the composer usable in a narrow column. The sidebar
|
||||
itself scrolls, so every rover/site panel below remains reachable on a 720p display. */}
|
||||
<div className="h-80 min-h-56">
|
||||
<ChatPanel fillHeight />
|
||||
</div>
|
||||
<OverseerPreferencePanel />
|
||||
<ReplaySourcesPanel panelId="newdrive-replay-sources" />
|
||||
<RoverQueuesPanel title="Rovers" />
|
||||
<RawUserPilePanel compact hideNicknameForm />
|
||||
<LinkButtonsPanel fillHeight={false} />
|
||||
<PtzQueueCard layout="desktop" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RightColumnTabs() {
|
||||
return (
|
||||
<Tabs defaultTab="community">
|
||||
<TabList className="sticky top-0 z-30 shrink-0 bg-neutral-950">
|
||||
<Tab id="community">Chat/Rovers</Tab>
|
||||
<Tab id="help">Help</Tab>
|
||||
<Tab id="settings">Settings</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel id="community">
|
||||
<CommunityColumn />
|
||||
</TabPanel>
|
||||
<HelpTab />
|
||||
<SettingsTab />
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DesktopLayout() {
|
||||
return (
|
||||
/* Equal 17rem minimums keep reused site cards usable on a 1280px desktop.
|
||||
The center track deliberately retains a zero minimum so the 4:3 video,
|
||||
rather than either sidebar, gives up width when the viewport is tight. */
|
||||
<div
|
||||
className={`grid h-screen min-w-0 w-full grid-cols-[minmax(17rem,1fr)_minmax(0,133.333vh)_minmax(17rem,1fr)] overflow-hidden ${themeGapClass}`}
|
||||
>
|
||||
<aside
|
||||
className={`flex min-h-0 min-w-0 flex-col overflow-x-hidden overflow-y-auto ${themeGapClass}`}
|
||||
aria-label="Room, activities, and site information"
|
||||
>
|
||||
<LeftColumnTabs />
|
||||
<DriverAdCard className="mt-auto" />
|
||||
</aside>
|
||||
|
||||
<NewDriveVideo />
|
||||
|
||||
<aside
|
||||
className={`flex min-h-0 min-w-0 flex-col overflow-x-hidden overflow-y-auto ${themeGapClass}`}
|
||||
aria-label="Chat, rovers, help, and settings"
|
||||
>
|
||||
<RightColumnTabs />
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
// 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 OldDesktopLayout from './OldDesktopLayout/index.jsx';
|
||||
import MobilePortraitLayout from './MobilePortraitLayout/index.jsx';
|
||||
import MobileLandscapeLayout from './MobileLandscapeLayout/index.jsx';
|
||||
import { useDriverLayout } from './DriverLayoutContext.jsx';
|
||||
|
||||
export default function DriverLayoutRoot() {
|
||||
export default function DriverLayoutRoot({ oldDesktop = false }) {
|
||||
const layout = useDriverLayout();
|
||||
|
||||
if (layout === 'desktop') {
|
||||
return <DesktopLayout />;
|
||||
// /old changes only the desktop composition. Mobile keeps one canonical
|
||||
// portrait/landscape implementation regardless of which desktop URL was opened.
|
||||
return oldDesktop ? <OldDesktopLayout /> : <DesktopLayout />;
|
||||
}
|
||||
|
||||
return layout === 'mobile-landscape' ? <MobileLandscapeLayout /> : <MobilePortraitLayout />;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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 NewDriveVideo from '../../../components/NewDriveVideo/index.jsx';
|
||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||
import { themeGapClass } from '../../../themes/index.js';
|
||||
import MobileLayoutFrame from '../MobileLayoutFrame/index.jsx';
|
||||
@@ -26,7 +26,9 @@ export default function MobileLandscapeLayout() {
|
||||
<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" />
|
||||
{/* The video stage adapts through DriverLayoutContext while this layout keeps
|
||||
ownership of the existing landscape touch-control columns. */}
|
||||
<NewDriveVideo />
|
||||
</div>
|
||||
{secondColumn}
|
||||
</section>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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 NewDriveVideo from '../../../components/NewDriveVideo/index.jsx';
|
||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||
import { themeGapClass } from '../../../themes/index.js';
|
||||
import MobileLayoutFrame from '../MobileLayoutFrame/index.jsx';
|
||||
@@ -22,7 +22,9 @@ export default function MobilePortraitLayout() {
|
||||
return (
|
||||
<MobileLayoutFrame>
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
<DriverVideo layoutFormat="mobile-portrait" />
|
||||
{/* NewDriveVideo reads the shared layout context and removes only its redundant
|
||||
physical-control pods; the established portrait controls below remain unchanged. */}
|
||||
<NewDriveVideo />
|
||||
{/* 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">
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Old Desktop Driver Layout
|
||||
// Purpose: Preserves the retired two-pane desktop composition for the explicit /old route.
|
||||
// Scope: This is a layout archive only; shared controls, telemetry, and route-level providers stay current.
|
||||
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 OldDesktopLayout() {
|
||||
// The archived layout keeps the same desktop telemetry cadence it used before
|
||||
// replacement, while mobile policy remains owned by the mobile layout frame.
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,9 @@ function getIdentitySurface() {
|
||||
to mount. The main driver and dedicated PTZ controller are the two active
|
||||
control surfaces; every other route is passive identity-wise.
|
||||
*/
|
||||
return window.location.pathname === '/' || window.location.pathname === '/ptz'
|
||||
return window.location.pathname === '/'
|
||||
|| window.location.pathname === '/old'
|
||||
|| window.location.pathname === '/ptz'
|
||||
? 'driver'
|
||||
: 'passive';
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ createRoot(document.getElementById('root')).render(
|
||||
<AnalyticsReporter />
|
||||
<Routes>
|
||||
<Route path="/" element={<DriverPageRoot />} />
|
||||
{/* The retired desktop composition remains available for direct comparison
|
||||
without duplicating the driver shell or changing canonical mobile layouts. */}
|
||||
<Route path="/old" element={<DriverPageRoot oldDesktop />} />
|
||||
<Route path="/spectate" element={<SpectatorApp />} />
|
||||
<Route path="/mini" element={<MiniSummaryApp />} />
|
||||
<Route path="/display" element={<ServerDisplayApp />} />
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
// Purpose: Defines persisted theme identities and their presentation order.
|
||||
// Scope: Shared by the settings picker and every route that resolves a saved page theme.
|
||||
|
||||
export const DEFAULT_PAGE_THEME_KEY = 'progress-pride';
|
||||
// Use the quiet, artwork-free theme whenever a browser has not saved a preference yet. Keeping
|
||||
// this fallback as a catalog key means every page and the settings preview resolve the same default.
|
||||
export const DEFAULT_PAGE_THEME_KEY = 'black';
|
||||
|
||||
export const PAGE_THEME_OPTIONS = [
|
||||
// Stable keys are stored in the browser settings cookie. Keeping presentation labels separate
|
||||
// lets the UI wording improve later without invalidating anybody's saved preference.
|
||||
{ key: 'progress-pride', label: 'Progress pride', className: 'page-theme-progress-pride' },
|
||||
// Keep the high-contrast caution-stripe option near the default so it is reachable with one
|
||||
// Next click as well as directly through the dropdown.
|
||||
// Keep the high-contrast caution-stripe option near the start of the catalog so it remains easy
|
||||
// to reach while browsing sequentially as well as directly through the dropdown.
|
||||
{ key: 'hazard-stripes', label: 'Hazard stripes', className: 'page-theme-hazard-stripes' },
|
||||
{ key: 'pride-mix', label: 'Pride mix', className: 'page-theme-pride-mix' },
|
||||
{ key: 'rainbow', label: 'Rainbow', className: 'page-theme-rainbow' },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import containerQueries from '@tailwindcss/container-queries';
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
@@ -7,5 +9,9 @@ export default {
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
// Shared cards use their allocated width rather than the browser viewport to
|
||||
// decide when compact layouts are necessary. Tailwind 3 does not emit the
|
||||
// @container utility or @... variants unless its official plugin is enabled;
|
||||
// without this, every card silently remains in its unprefixed narrow layout.
|
||||
plugins: [containerQueries],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user