mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 18:58:49 -05:00
add link embedding in chat, update tailwind, update icons, tweak column layout. (8000 files changed because tailwind changed stuff in the update)
This commit is contained in:
Generated
+1121
-1256
File diff suppressed because it is too large
Load Diff
+7
-5
@@ -15,12 +15,15 @@
|
||||
"@rjsf/core": "^6.10.0",
|
||||
"@rjsf/validator-ajv8": "^6.10.0",
|
||||
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
|
||||
"linkify-react": "^4.3.3",
|
||||
"linkifyjs": "^4.3.3",
|
||||
"midi-file": "^1.2.4",
|
||||
"papaparse": "^5.5.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-joystick-component": "^6.2.1",
|
||||
"react-player": "^3.4.0",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"three": "^0.184.0",
|
||||
"uplot": "^1.6.32",
|
||||
@@ -28,18 +31,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"baseline-browser-mapping": "^2.11.25",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"socket.io-client": "4.8.3",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"vite": "^7.2.2",
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
|
||||
return (
|
||||
<div ref={editorRef} className="configuration-editor">
|
||||
<CardFrame title="Configuration" meta={`revision ${revision}`} clipOverflow={false} bodyClassName="p-0.5">
|
||||
<div ref={toolbarRef} className="configuration-toolbar sticky top-0 z-20 mb-0.5 space-y-0.5 border border-neutral-500/60 bg-neutral-900/95 p-0.5 backdrop-blur">
|
||||
<div ref={toolbarRef} className="configuration-toolbar sticky top-0 z-20 mb-0.5 space-y-0.5 border border-neutral-500/60 bg-neutral-900/95 p-0.5 backdrop-blur-sm">
|
||||
<p className="text-xs text-slate-400">Saving applies the complete revision immediately and reloads each affected service.</p>
|
||||
{/* All document actions stay together at the start of the toolbar. The
|
||||
editor may use a wide canvas, but width is never used to separate a
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function PasswordConfirmationDialog({ open, busy, error, onCancel
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/75 p-2">
|
||||
<div className="fixed inset-0 z-1000 flex items-center justify-center bg-black/75 p-2">
|
||||
<form className="w-full max-w-sm border border-neutral-500/70 bg-neutral-900 p-1 text-slate-100 shadow-2xl" onSubmit={submit}>
|
||||
<h2 className="text-base font-semibold">Confirm password</h2>
|
||||
<p className="mt-0.5 text-xs text-slate-400">This sensitive action requires recent confirmation of your administrator password.</p>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
* Scope: Keeps route-specific hierarchy and RJSF integration out of the shared global stylesheet.
|
||||
*/
|
||||
|
||||
/* Reuse the shared theme without emitting a second copy of Tailwind. */
|
||||
@reference '../index.css';
|
||||
|
||||
.configuration-tree {
|
||||
/* The large gap is intentionally limited to root siblings. It makes each
|
||||
top-level configuration section read as a separate YAML block while the
|
||||
@@ -91,7 +94,7 @@
|
||||
.configuration-value input:not([type='checkbox']),
|
||||
.configuration-value select,
|
||||
.configuration-value textarea {
|
||||
@apply w-full rounded-md border border-neutral-600 bg-neutral-700 px-0.5 py-0.5 text-white placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-sky-500;
|
||||
@apply w-full rounded-md border border-neutral-600 bg-neutral-700 px-0.5 py-0.5 text-white placeholder:text-slate-400 focus:outline-hidden focus:ring-1 focus:ring-sky-500;
|
||||
}
|
||||
|
||||
.configuration-value input[type='checkbox'] {
|
||||
|
||||
@@ -504,7 +504,7 @@ export default function AdminPanelContent() {
|
||||
value={reasonDraft}
|
||||
onChange={(event) => setReasonDraft(event.target.value)}
|
||||
placeholder="Set an admin mode reason"
|
||||
className="field-input text-sm min-h-[3.5rem]"
|
||||
className="field-input text-sm min-h-14"
|
||||
/>
|
||||
<div className="flex gap-0.5 text-xs">
|
||||
<button type="button" onClick={handleReasonSave} className="button-dark">
|
||||
@@ -558,7 +558,7 @@ export default function AdminPanelContent() {
|
||||
{updateStates[rover.id] ? 'Updating...' : 'Update'}
|
||||
</button>
|
||||
{rover?.private?.enabled ? (
|
||||
<div className="w-full rounded border border-slate-700/70 p-0.5 space-y-0.5 text-[0.7rem]">
|
||||
<div className="w-full rounded-sm border border-slate-700/70 p-0.5 space-y-0.5 text-[0.7rem]">
|
||||
<div className="text-slate-300">Private safety</div>
|
||||
<label className="flex items-center gap-0.5">
|
||||
<input
|
||||
|
||||
@@ -74,28 +74,28 @@ export default function LlmCommentaryPanel({ state, onClearHistory, clearingHist
|
||||
{statPills.map((pill) => (
|
||||
<span
|
||||
key={pill.label}
|
||||
className="rounded border border-slate-600/60 bg-slate-800/70 px-0.5 py-0.25 text-[0.72rem] leading-tight text-slate-200"
|
||||
className="rounded-sm border border-slate-600/60 bg-slate-800/70 px-0.5 py-0.25 text-[0.72rem] leading-tight text-slate-200"
|
||||
>
|
||||
{pill.label}: {pill.value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{errors.message ? (
|
||||
<div className="surface text-xs text-red-300 break-words">Error: {errors.message}</div>
|
||||
<div className="surface text-xs text-red-300 wrap-break-word">Error: {errors.message}</div>
|
||||
) : null}
|
||||
{errors.details ? (
|
||||
<details className="surface text-xs text-red-200">
|
||||
<summary className="cursor-pointer select-none text-red-300">Failure details</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-red-200">
|
||||
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-red-200">
|
||||
{JSON.stringify(errors.details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
{output.generated ? (
|
||||
<div className="surface text-xs text-slate-200 break-words">Generated: {output.generated}</div>
|
||||
<div className="surface text-xs text-slate-200 wrap-break-word">Generated: {output.generated}</div>
|
||||
) : null}
|
||||
{output.posted ? (
|
||||
<div className="surface text-xs text-emerald-200 break-words">Posted: {output.posted}</div>
|
||||
<div className="surface text-xs text-emerald-200 wrap-break-word">Posted: {output.posted}</div>
|
||||
) : null}
|
||||
<div className="grid gap-0.5 md:grid-cols-2">
|
||||
<div className="space-y-0.5">
|
||||
@@ -120,7 +120,7 @@ export default function LlmCommentaryPanel({ state, onClearHistory, clearingHist
|
||||
</div>
|
||||
<details className="surface text-xs text-slate-200">
|
||||
<summary className="cursor-pointer select-none text-slate-300">Full Monitor Payload</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
|
||||
{JSON.stringify(state, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
@@ -154,7 +154,7 @@ export default function LlmCommentaryPanel({ state, onClearHistory, clearingHist
|
||||
{selectedRun ? (
|
||||
<details className="surface text-xs text-slate-200" open>
|
||||
<summary className="cursor-pointer select-none text-slate-300">Run #{selectedRun.runId} details</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
|
||||
{JSON.stringify(selectedRun, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
@@ -58,49 +58,49 @@ export default function OverseerControlPanel({ state, onClearHistory, clearingHi
|
||||
|
||||
<details className="surface text-xs text-slate-200" open>
|
||||
<summary className="cursor-pointer select-none text-slate-300">Latest Context</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
|
||||
{input.stateUpdate || '<none>'}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<details className="surface text-xs text-slate-200" open>
|
||||
<summary className="cursor-pointer select-none text-slate-300">Exact Model Input (messages[])</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
|
||||
{renderModelMessages()}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<details className="surface text-xs text-slate-200">
|
||||
<summary className="cursor-pointer select-none text-slate-300">Exact System Prompt</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
|
||||
{input.systemPrompt || '<none>'}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<details className="surface text-xs text-slate-200">
|
||||
<summary className="cursor-pointer select-none text-slate-300">Exact Transcript Rows</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
|
||||
{JSON.stringify(input.transcript || [], null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<details className="surface text-xs text-slate-200">
|
||||
<summary className="cursor-pointer select-none text-slate-300">Tool Availability</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
|
||||
{JSON.stringify({ available: input.availableTools, blocked: input.blockedTools }, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<details className="surface text-xs text-slate-200" open>
|
||||
<summary className="cursor-pointer select-none text-slate-300">Exact Model Output</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
|
||||
{output.raw || '<none>'}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<details className="surface text-xs text-slate-200" open>
|
||||
<summary className="cursor-pointer select-none text-slate-300">Live Tool Calls</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
|
||||
{JSON.stringify(output.liveToolCalls || [], null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
@@ -115,7 +115,7 @@ export default function OverseerControlPanel({ state, onClearHistory, clearingHi
|
||||
|
||||
<details className="surface text-xs text-slate-200">
|
||||
<summary className="cursor-pointer select-none text-slate-300">Recent Runs</summary>
|
||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
|
||||
{JSON.stringify(state.history || [], null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
@@ -124,7 +124,7 @@ export default function OverseerControlPanel({ state, onClearHistory, clearingHi
|
||||
{showPayload ? 'Hide Full Payload' : 'Show Full Payload'}
|
||||
</button>
|
||||
{showPayload ? (
|
||||
<pre className="surface whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">{JSON.stringify(state, null, 2)}</pre>
|
||||
<pre className="surface whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">{JSON.stringify(state, null, 2)}</pre>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -384,7 +384,7 @@ function AlertToastShell({ toast, onDismiss, onPause, onResume }) {
|
||||
const genericBackgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.5)` : 'rgba(33, 150, 243, 0.18)';
|
||||
const genericBorderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 1)` : 'rgba(33, 150, 243, 0.45)';
|
||||
const shellClass = [
|
||||
'pointer-events-auto w-fit max-w-[80vw] overflow-hidden rounded-md shadow-sm shadow-black/40',
|
||||
'pointer-events-auto w-fit max-w-[80vw] overflow-hidden rounded-md shadow-xs shadow-black/40',
|
||||
generic ? '' : 'border border-white/10 bg-black/75',
|
||||
].join(' ');
|
||||
|
||||
@@ -429,7 +429,7 @@ function AlertToastShell({ toast, onDismiss, onPause, onResume }) {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-4 w-4 items-center justify-center rounded-sm text-xs leading-none text-slate-300 hover:bg-white/10 hover:text-white focus:bg-white/10 focus:text-white focus:outline-none focus:ring-1"
|
||||
className="flex h-4 w-4 items-center justify-center rounded-xs text-xs leading-none text-slate-300 hover:bg-white/10 hover:text-white focus:bg-white/10 focus:text-white focus:outline-hidden focus:ring-1"
|
||||
style={{ '--tw-ring-color': accent }}
|
||||
onClick={() => onDismiss(toast.key)}
|
||||
title="Dismiss alert"
|
||||
@@ -507,7 +507,7 @@ function AlertToast({ alert }) {
|
||||
if (alert.kind === 'buttonbox-active' && alert.payload) {
|
||||
const payload = alert.payload;
|
||||
return (
|
||||
<div className="pointer-events-none w-[12.5rem]">
|
||||
<div className="pointer-events-none w-50">
|
||||
<ButtonBoxTile
|
||||
buttonId={payload.buttonId}
|
||||
count={payload.count}
|
||||
@@ -549,7 +549,7 @@ function AlertToast({ alert }) {
|
||||
continuous surface while this renderer stays responsible for one line of
|
||||
compact title/message text.
|
||||
*/
|
||||
className="pointer-events-auto min-w-0 max-w-[24rem] px-1.5 py-0.5 text-left text-[0.72rem] leading-tight text-slate-100"
|
||||
className="pointer-events-auto min-w-0 max-w-96 px-1.5 py-0.5 text-left text-[0.72rem] leading-tight text-slate-100"
|
||||
>
|
||||
<p className="truncate text-slate-100">
|
||||
<span className="font-semibold text-slate-300">{alert.title || 'Alert'}</span>
|
||||
|
||||
@@ -57,7 +57,7 @@ function centerOfPressure(corners) {
|
||||
|
||||
function CornerReading({ className, label, value }) {
|
||||
return (
|
||||
<div className={`surface absolute min-w-[5.5rem] text-center ${className}`}>
|
||||
<div className={`surface absolute min-w-22 text-center ${className}`}>
|
||||
<div className="text-[0.62rem] text-slate-400">{label}</div>
|
||||
<div className="text-sm font-semibold text-slate-100">{formatWeight(value)}</div>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@ function GameChoice({ game, disabled, onVote }) {
|
||||
type="button"
|
||||
disabled={disabled || game.active}
|
||||
onClick={() => onVote(game.id)}
|
||||
className="surface flex min-h-[3.5rem] flex-col items-start justify-between border-l-4 border-l-neutral-600 px-1 py-0.75 text-left transition-colors hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
className="surface flex min-h-14 flex-col items-start justify-between border-l-4 border-l-neutral-600 px-1 py-0.75 text-left transition-colors hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
style={style}
|
||||
>
|
||||
<span className="flex w-full items-start justify-between gap-1">
|
||||
@@ -154,7 +154,7 @@ function DisplaySections({ sections }) {
|
||||
{section.items.slice(0, 8).map((item, itemIndex) => (
|
||||
<p
|
||||
key={`${item.text}-${itemIndex}`}
|
||||
className={`break-words text-xs leading-snug ${
|
||||
className={`wrap-break-word text-xs leading-snug ${
|
||||
item.status === 'active'
|
||||
? 'font-semibold text-neutral-50'
|
||||
: item.status === 'complete'
|
||||
@@ -302,14 +302,14 @@ function BarcodeGamesPanelContent() {
|
||||
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}>
|
||||
<p className="wrap-break-word text-xl font-bold leading-tight text-neutral-50" style={activeTheme.textStyle}>
|
||||
{display.title || activeGame?.title || 'No game active'}
|
||||
</p>
|
||||
<p className="mt-0.5 break-words text-sm font-semibold leading-tight text-neutral-200">
|
||||
<p className="mt-0.5 wrap-break-word text-sm font-semibold leading-tight text-neutral-200">
|
||||
{display.primary || 'Vote for a game to start'}
|
||||
</p>
|
||||
{display.secondary ? (
|
||||
<p className="mt-0.5 break-words text-xs leading-snug text-neutral-300">{display.secondary}</p>
|
||||
<p className="mt-0.5 wrap-break-word text-xs leading-snug text-neutral-300">{display.secondary}</p>
|
||||
) : null}
|
||||
<DisplaySections sections={display.sections} />
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,7 @@ function BatteryBar({
|
||||
}
|
||||
|
||||
const barShellClass = classNames(
|
||||
'relative overflow-hidden rounded-sm border border-slate-800',
|
||||
'relative overflow-hidden rounded-xs border border-slate-800',
|
||||
isUrgent || isWarn ? baseToneStrong : 'bg-zinc-900/90',
|
||||
isUrgent && 'battery-urgent-flash',
|
||||
isVertical
|
||||
@@ -132,7 +132,7 @@ function BatteryBar({
|
||||
isVertical ? (compact ? 'text-[0.55rem]' : 'text-[0.65rem]') : 'text-xs',
|
||||
);
|
||||
const labelPillClass = classNames(
|
||||
'rounded px-1.5 py-0.5',
|
||||
'rounded-sm px-1.5 py-0.5',
|
||||
isBackground ? 'bg-black/30 text-slate-100' : 'bg-black/50 text-slate-100',
|
||||
);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ function formatDegrees(value) {
|
||||
|
||||
function KeyPill({ label }) {
|
||||
if (!label) return null;
|
||||
return <span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">{label}</span>;
|
||||
return <span className="rounded-sm bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">{label}</span>;
|
||||
}
|
||||
|
||||
export default function CameraTiltControl({
|
||||
@@ -147,7 +147,7 @@ export default function CameraTiltControl({
|
||||
<div className={`flex items-center justify-between ${labelRowClass}`.trim()}>
|
||||
<span className={labelClass}>{label}</span>
|
||||
{showValue ? (
|
||||
<span className={`rounded bg-slate-800 px-1 py-0.5 text-[0.7rem] font-semibold ${valueClass}`.trim()}>
|
||||
<span className={`rounded-sm bg-slate-800 px-1 py-0.5 text-[0.7rem] font-semibold ${valueClass}`.trim()}>
|
||||
{formatDegrees(value)}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function CardFrame({
|
||||
// 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',
|
||||
'panel-section @container border border-neutral-500/60 bg-neutral-900',
|
||||
clipOverflow ? 'overflow-hidden' : 'overflow-visible',
|
||||
fillHeight && 'flex h-full min-h-0 flex-col',
|
||||
className,
|
||||
@@ -99,7 +99,7 @@ export default function CardFrame({
|
||||
// on the shared component gives long cards a consistent title bar
|
||||
// without changing the layout of existing callers.
|
||||
stickyHeader && 'sticky top-0 z-10',
|
||||
// 'flex items-center justify-between gap-0.5 border-b border-neutral-500/50 bg-gradient-to-r from-neutral-800 via-neutral-700 to-neutral-600 px-0.5 py-0.5',
|
||||
// 'flex items-center justify-between gap-0.5 border-b border-neutral-500/50 bg-linear-to-r from-neutral-800 via-neutral-700 to-neutral-600 px-0.5 py-0.5',
|
||||
headerClassName,
|
||||
)}
|
||||
style={headerStyle}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useState } from 'react';
|
||||
import { find } from 'linkifyjs';
|
||||
import ReactPlayer from 'react-player';
|
||||
|
||||
function ChatEmbed({ url }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (failed) return null;
|
||||
|
||||
// Only direct image paths are identifiable without fetching page metadata.
|
||||
const isImage = /\.(avif|gif|jpe?g|png|webp|svg)$/i.test(new URL(url).pathname);
|
||||
if (isImage) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="block max-w-full">
|
||||
<img
|
||||
src={url}
|
||||
alt="Shared image"
|
||||
loading="lazy"
|
||||
className="max-h-60 max-w-full rounded-sm object-contain"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (!ReactPlayer.canPlay(url)) return null;
|
||||
|
||||
return (
|
||||
<div className="aspect-video w-full max-w-sm overflow-hidden rounded-sm">
|
||||
<ReactPlayer
|
||||
src={url}
|
||||
controls
|
||||
playing={false}
|
||||
width="100%"
|
||||
height="100%"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatEmbeds({ text }) {
|
||||
const urls = [...new Set(find(String(text || ''), 'url')
|
||||
.map((link) => link.href)
|
||||
.filter((url) => /^https?:\/\//i.test(url)))];
|
||||
|
||||
return urls.map((url) => <ChatEmbed key={url} url={url} />);
|
||||
}
|
||||
@@ -3,8 +3,16 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useState } from 'react';
|
||||
import { FaDiscord } from 'react-icons/fa';
|
||||
import Linkify from 'linkify-react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import RoverLabel from '../RoverLabel/index.jsx';
|
||||
import ChatEmbeds from './ChatEmbeds.jsx';
|
||||
|
||||
const LINK_OPTIONS = {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
className: 'text-sky-300 underline break-all',
|
||||
};
|
||||
|
||||
function roleColors(role) {
|
||||
switch (role) {
|
||||
@@ -37,7 +45,7 @@ function displayName(message) {
|
||||
// height, while the matching calc() size keeps the avatar square after adding that
|
||||
// top/bottom coverage.
|
||||
const CHAT_ROW_FULL_HEIGHT_AVATAR_CLASS =
|
||||
'my-[-0.125rem] flex h-[calc(1rem+0.25rem)] w-[calc(1rem+0.25rem)] shrink-0 overflow-hidden rounded-none';
|
||||
'-my-0.5 flex h-5 w-5 shrink-0 overflow-hidden rounded-none';
|
||||
|
||||
function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
|
||||
if (!guildIconUrl && !userAvatarUrl) return null;
|
||||
@@ -120,7 +128,7 @@ export function ChatIdentity({ message, toolsToggle = null }) {
|
||||
/>
|
||||
) : null}
|
||||
{isBot ? (
|
||||
<span className="shrink-0 rounded bg-emerald-900/60 px-1 text-[0.65rem] font-semibold uppercase tracking-wide text-white">
|
||||
<span className="shrink-0 rounded-sm bg-emerald-900/60 px-1 text-[0.65rem] font-semibold uppercase tracking-wide text-white">
|
||||
bot
|
||||
</span>
|
||||
) : null}
|
||||
@@ -185,7 +193,7 @@ export default function ChatMessageRow({ message, variant = 'message' }) {
|
||||
hasToolCalls ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded border border-slate-600/70 bg-slate-800/60 px-1 py-[1px] text-[0.65rem] text-slate-200 hover:bg-slate-700/70"
|
||||
className="shrink-0 rounded-sm border border-slate-600/70 bg-slate-800/60 px-1 py-px text-[0.65rem] text-slate-200 hover:bg-slate-700/70"
|
||||
onClick={() => {
|
||||
if (isSpectator) return;
|
||||
setOpen((v) => !v);
|
||||
@@ -198,10 +206,12 @@ export default function ChatMessageRow({ message, variant = 'message' }) {
|
||||
<div className={chatRowClass(message, variant)}>
|
||||
<div className="flex w-full items-start gap-0.5">
|
||||
<span
|
||||
className={`min-w-0 flex-1 break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
|
||||
className={`min-w-0 flex-1 wrap-break-word leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
|
||||
>
|
||||
<ChatIdentity message={message} toolsToggle={toolsToggle} />
|
||||
{!isOpen && (hasText || isTyping) ? ` ${rowText}` : ''}
|
||||
{!isOpen && (hasText || isTyping) ? (
|
||||
<Linkify options={LINK_OPTIONS}>{` ${rowText}`}</Linkify>
|
||||
) : null}
|
||||
</span>
|
||||
{!isTyping ? (
|
||||
<span className="shrink-0 text-[0.65rem] text-slate-400/60">
|
||||
@@ -210,7 +220,7 @@ export default function ChatMessageRow({ message, variant = 'message' }) {
|
||||
) : null}
|
||||
</div>
|
||||
{isOpen && hasToolCalls ? (
|
||||
<div className="w-full rounded border border-slate-700/70 bg-slate-900/70 p-0.5 text-[0.68rem] text-slate-200">
|
||||
<div className="w-full rounded-sm border border-slate-700/70 bg-slate-900/70 p-0.5 text-[0.68rem] text-slate-200">
|
||||
{toolCalls.map((entry, idx) => {
|
||||
const status = String(entry?.status || 'unknown');
|
||||
const statusLabel = status === 'ok' ? 'ok' : status === 'blocked' ? 'blocked' : status === 'error' ? 'error' : 'unknown';
|
||||
@@ -225,10 +235,11 @@ export default function ChatMessageRow({ message, variant = 'message' }) {
|
||||
</div>
|
||||
) : null}
|
||||
{isOpen && hasText ? (
|
||||
<div className={`w-full break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}>
|
||||
{message.text}
|
||||
<div className={`w-full wrap-break-word leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}>
|
||||
<Linkify options={LINK_OPTIONS}>{message.text}</Linkify>
|
||||
</div>
|
||||
) : null}
|
||||
{!isTyping && hasText ? <ChatEmbeds text={message.text} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ function TtsControls({
|
||||
: voice;
|
||||
saveTtsSettings((current) => ({ ...(current || {}), engine: next, voice: nextVoice }));
|
||||
}}
|
||||
className={`${compactSelectClass} w-[5.5rem] shrink`}
|
||||
className={`${compactSelectClass} w-22 shrink`}
|
||||
>
|
||||
<option value="flite">flite</option>
|
||||
<option value="espeak">espeak</option>
|
||||
@@ -173,7 +173,7 @@ function TtsControls({
|
||||
const next = event.target.value;
|
||||
saveTtsSettings((current) => ({ ...(current || {}), voice: next }));
|
||||
}}
|
||||
className={`${compactSelectClass} w-[3.25rem] shrink`}
|
||||
className={`${compactSelectClass} w-13 shrink`}
|
||||
>
|
||||
{(engine === 'chromegtts' ? CHROME_TTS_VOICES : FLITE_VOICES).map((v) => (
|
||||
<option key={v} value={v}>
|
||||
@@ -189,7 +189,7 @@ function TtsControls({
|
||||
const next = Number(event.target.value);
|
||||
saveTtsSettings((current) => ({ ...(current || {}), googlePitch: next }));
|
||||
}}
|
||||
className={`${compactSelectClass} w-[5rem] shrink`}
|
||||
className={`${compactSelectClass} w-20 shrink`}
|
||||
>
|
||||
{GOOGLE_TTS_VALUES.map((value) => (
|
||||
<option key={`pitch-${value}`} value={value}>
|
||||
@@ -203,7 +203,7 @@ function TtsControls({
|
||||
const next = Number(event.target.value);
|
||||
saveTtsSettings((current) => ({ ...(current || {}), googleSpeed: next }));
|
||||
}}
|
||||
className={`${compactSelectClass} w-[5rem] shrink`}
|
||||
className={`${compactSelectClass} w-20 shrink`}
|
||||
>
|
||||
{GOOGLE_TTS_VALUES.map((value) => (
|
||||
<option key={`speed-${value}`} value={value}>
|
||||
@@ -221,7 +221,7 @@ function TtsControls({
|
||||
const next = Number(event.target.value);
|
||||
saveTtsSettings((current) => ({ ...(current || {}), pitch: next }));
|
||||
}}
|
||||
className={`${compactSelectClass} w-[4.5rem] shrink`}
|
||||
className={`${compactSelectClass} w-18 shrink`}
|
||||
>
|
||||
{ESPEAK_PITCHES.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
|
||||
@@ -31,7 +31,7 @@ function StatusRow({ value, tone = 'neutral' }) {
|
||||
|
||||
function KeyPill({ label }) {
|
||||
if (!label) return null;
|
||||
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
|
||||
return <span className="rounded-sm border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
|
||||
}
|
||||
|
||||
function ActionPill({ label, tone }) {
|
||||
@@ -186,7 +186,7 @@ export default function DriveDockAction({
|
||||
// tapped repeatedly; attach mobile touch suppression directly to the card so
|
||||
// Safari does not select labels or open callouts during those interactions.
|
||||
const baseCardClasses =
|
||||
'mobile-touch-control flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-0.75 py-0.75 text-slate-100 shadow-md transition hover:-translate-y-0.5 hover:shadow-xl focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60 select-none no-touch-select';
|
||||
'mobile-touch-control flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-0.75 py-0.75 text-slate-100 shadow-md transition hover:-translate-y-0.5 hover:shadow-xl focus-visible:outline-hidden focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60 select-none no-touch-select';
|
||||
const ctaText = 'text-center';
|
||||
const ctaLayout = 'items-center justify-between';
|
||||
const compactLayout = 'items-center justify-center';
|
||||
@@ -352,9 +352,9 @@ function StepList({ steps, tone = 'emerald' }) {
|
||||
? 'text-amber-200'
|
||||
: 'text-indigo-200';
|
||||
return (
|
||||
<div className={`space-y-0.5 rounded border px-0.5 py-0.35 text-left ${container}`}>
|
||||
<div className={`space-y-0.5 rounded-sm border px-0.5 py-0.35 text-left ${container}`}>
|
||||
{steps.map((step, idx) => (
|
||||
<div key={step} className="text-[0.85rem] leading-snug break-words">
|
||||
<div key={step} className="text-[0.85rem] leading-snug wrap-break-word">
|
||||
<div className="flex items-start">
|
||||
<span className={`mr-0 align-top text-[0.75rem] font-semibold ${numberColor}`}>{idx + 1}.</span>
|
||||
<span className="align-top">{step}</span>
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
|
||||
<CardFrame hideHeader className="shrink-0">
|
||||
{/* The legacy page keeps its existing muted 4:3 frame while sharing
|
||||
the removal-message behavior with the current video surface. */}
|
||||
<div className="panel-muted aspect-[4/3]">
|
||||
<div className="panel-muted aspect-4/3">
|
||||
<EmptyDriverVideoNotice />
|
||||
</div>
|
||||
</CardFrame>
|
||||
@@ -34,7 +34,7 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
|
||||
return (
|
||||
<CardFrame hideHeader className="shrink-0">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="relative w-full overflow-hidden bg-black aspect-[4/3]">
|
||||
<div className="relative w-full overflow-hidden bg-black aspect-4/3">
|
||||
<RoverMediaPlayer roverId={roverId} videoMode={videoMode} />
|
||||
<TurnsOverlay mobileHud={mobileHud} />
|
||||
<RoverDescriptionOverlay
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function DuplicateIdentityOverlay() {
|
||||
: 'This driver session is already active in another tab.';
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto fixed inset-0 z-[2000] flex items-center justify-center bg-black px-0.5 py-0.5 text-slate-100">
|
||||
<div className="pointer-events-auto fixed inset-0 z-2000 flex items-center justify-center bg-black px-0.5 py-0.5 text-slate-100">
|
||||
<section className="surface w-full max-w-md space-y-0.5 text-center shadow-2xl">
|
||||
<div className="space-y-0.5">
|
||||
<h1 className="text-lg font-semibold text-white">Another driver tab is already open</h1>
|
||||
|
||||
@@ -24,13 +24,13 @@ export default function FullscreenPrompt({ visible, mode, onEnterFullscreen, onD
|
||||
<p className="text-xs border-t border-b border-slate-700 p-0.5 text-blue-300 text-center">This will only show once just to let you know. There is a fullscreen button in the bottom right for later use.</p>
|
||||
|
||||
<div className="flex justify-end gap-0.5 pt-1 text-sm">
|
||||
<button type="button" className="rounded border border-slate-600 px-3 py-1 text-slate-200" onClick={onDismiss}>
|
||||
<button type="button" className="rounded-sm border border-slate-600 px-3 py-1 text-slate-200" onClick={onDismiss}>
|
||||
{isIOSMode ? 'Got it' : 'Not now'}
|
||||
</button>
|
||||
{!isIOSMode && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-cyan-500 px-3 py-1 font-semibold text-black hover:bg-cyan-400"
|
||||
className="rounded-sm bg-cyan-500 px-3 py-1 font-semibold text-black hover:bg-cyan-400"
|
||||
onClick={onEnterFullscreen}
|
||||
>
|
||||
Enter fullscreen
|
||||
|
||||
@@ -131,12 +131,12 @@ export default function GPIOToggleControl({
|
||||
{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">
|
||||
<span className="rounded-sm bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
|
||||
{keyLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className={`rounded px-1 py-0.5 text-[0.7rem] font-semibold ${statusClasses}`}>
|
||||
<span className={`rounded-sm px-1 py-0.5 text-[0.7rem] font-semibold ${statusClasses}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -48,13 +48,13 @@ function actionDriveMode(actionId) {
|
||||
|
||||
function CurveField({ label, value, onChange }) {
|
||||
return (
|
||||
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
|
||||
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 text-sm text-white">
|
||||
<span className="font-semibold">{label}</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
className="rounded-sm border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
>
|
||||
<option value="linear">Linear</option>
|
||||
<option value="expo">Fine center control</option>
|
||||
@@ -78,7 +78,7 @@ function MappingRow({
|
||||
// Mapping rows are constrained to a readable width so the source text and buttons remain
|
||||
// visually connected. Buttons wrap on very narrow panes instead of forcing tiny text.
|
||||
return (
|
||||
<div 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 max-[520px]:grid-cols-1">
|
||||
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded-sm bg-neutral-800/80 px-1.5 py-1 text-sm max-[520px]:grid-cols-1">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-semibold leading-snug text-white">{action.label}</p>
|
||||
@@ -471,7 +471,7 @@ export default function GamepadMappingSettings() {
|
||||
bodyClassName="space-y-2 p-1 text-sm"
|
||||
>
|
||||
{captureAction && (
|
||||
<div className="mx-auto flex w-full max-w-lg items-center justify-between gap-1 rounded bg-emerald-950/50 px-1.5 py-1 text-sm text-white">
|
||||
<div className="mx-auto flex w-full max-w-lg items-center justify-between gap-1 rounded-sm bg-emerald-950/50 px-1.5 py-1 text-sm text-white">
|
||||
<span>Release controls, then move or press the input for {captureAction.label}.</span>
|
||||
<button type="button" onClick={() => setCaptureAction(null)} className="button-dark px-1 py-0.5 text-xs">
|
||||
Cancel
|
||||
@@ -482,7 +482,7 @@ export default function GamepadMappingSettings() {
|
||||
<div className="space-y-1">
|
||||
<SettingsGroupLabel>Connected controller</SettingsGroupLabel>
|
||||
{hubState.error ? (
|
||||
<p className="mx-auto w-full max-w-lg rounded border border-red-500/60 bg-red-950/40 px-1.5 py-1 text-sm text-white">
|
||||
<p className="mx-auto w-full max-w-lg rounded-sm border border-red-500/60 bg-red-950/40 px-1.5 py-1 text-sm text-white">
|
||||
Controller access failed: {hubState.error}
|
||||
</p>
|
||||
) : hubState.pads.length === 0 ? (
|
||||
@@ -492,11 +492,11 @@ export default function GamepadMappingSettings() {
|
||||
: 'No controller detected. Connect it, focus this page, then press a button.'}
|
||||
</p>
|
||||
) : (
|
||||
<div 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 max-[420px]:grid-cols-1">
|
||||
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded-sm bg-neutral-800/80 px-1.5 py-1 text-sm max-[420px]:grid-cols-1">
|
||||
<select
|
||||
value={activePad?.instanceKey ?? ''}
|
||||
onChange={(event) => setActiveInstanceKey(event.target.value)}
|
||||
className="min-w-0 rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
className="min-w-0 rounded-sm border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
>
|
||||
{hubState.pads.map((pad) => (
|
||||
<option key={pad.instanceKey} value={pad.instanceKey}>
|
||||
@@ -504,7 +504,7 @@ export default function GamepadMappingSettings() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
|
||||
<span className="rounded-sm bg-neutral-900 px-1 py-0.5 text-xs text-white">
|
||||
{activePad?.mapping ?? 'unknown'}
|
||||
</span>
|
||||
<p className="col-span-full truncate text-xs text-slate-300" title={activePad?.id}>
|
||||
@@ -519,13 +519,13 @@ export default function GamepadMappingSettings() {
|
||||
{/* Calibration controls stay in one stacked column because range inputs become harder to
|
||||
tune when squeezed into multiple narrow columns. */}
|
||||
<div className="grid gap-1">
|
||||
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
|
||||
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 text-sm text-white">
|
||||
<span className="font-semibold">Button prompts</span>
|
||||
<select
|
||||
value={activeProfile.promptStyle ?? 'auto'}
|
||||
onChange={(event) => updateProfile({ promptStyle: event.target.value })}
|
||||
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
className="rounded-sm border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
>
|
||||
<option value="auto">Automatic</option>
|
||||
<option value="xbox">Xbox</option>
|
||||
@@ -536,7 +536,7 @@ export default function GamepadMappingSettings() {
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs leading-snug text-white">Override this only when the browser reports the controller incorrectly.</p>
|
||||
</label>
|
||||
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
|
||||
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
|
||||
<span className="min-w-0 font-semibold text-white">Steering mode</span>
|
||||
<select
|
||||
@@ -545,7 +545,7 @@ export default function GamepadMappingSettings() {
|
||||
updateCalibration({ driveMode: event.target.value });
|
||||
setCaptureAction(null);
|
||||
}}
|
||||
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
className="rounded-sm border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
>
|
||||
<option value="single">Single stick</option>
|
||||
<option value="tank">Tank sticks</option>
|
||||
@@ -641,7 +641,7 @@ export default function GamepadMappingSettings() {
|
||||
onChange={(value) => updateCalibration({ precisionSpeed: value })}
|
||||
/>
|
||||
{driveMode === 'single' && (
|
||||
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
|
||||
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1">
|
||||
{/* Camera mode is styled like the sliders so calibration controls read as one group
|
||||
even though this specific setting is a select instead of a range input. */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
|
||||
@@ -649,7 +649,7 @@ export default function GamepadMappingSettings() {
|
||||
<select
|
||||
value={activeProfile.calibration?.cameraMode ?? 'velocity'}
|
||||
onChange={(event) => updateCalibration({ cameraMode: event.target.value })}
|
||||
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
className="rounded-sm border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
>
|
||||
<option value="absolute">Absolute</option>
|
||||
<option value="velocity">Velocity</option>
|
||||
@@ -714,7 +714,7 @@ export default function GamepadMappingSettings() {
|
||||
{!activePad ? (
|
||||
null
|
||||
) : (
|
||||
<details className="mx-auto w-full max-w-lg rounded bg-neutral-900/70 px-1.5 py-1 text-xs text-white">
|
||||
<details className="mx-auto w-full max-w-lg rounded-sm bg-neutral-900/70 px-1.5 py-1 text-xs text-white">
|
||||
<summary className="cursor-pointer text-sm font-semibold text-white">Advanced diagnostics</summary>
|
||||
<div className="mt-1 space-y-1">
|
||||
<p className="text-white">Raw axes</p>
|
||||
|
||||
@@ -5,12 +5,12 @@ import { NUMBER_FORMAT } from './constants.js';
|
||||
|
||||
export default function SliderField({ label, description, min, max, step, value, onChange }) {
|
||||
return (
|
||||
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
|
||||
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1">
|
||||
{/* The formatted value is kept close to the setting name because calibration usually
|
||||
happens by nudging a slider and watching the number, not by reading the full panel row. */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
|
||||
<span className="min-w-0 font-semibold text-white">{label}</span>
|
||||
<span className="rounded bg-neutral-900 px-1 py-0.5 font-mono text-xs text-white">
|
||||
<span className="rounded-sm bg-neutral-900 px-1 py-0.5 font-mono text-xs text-white">
|
||||
{NUMBER_FORMAT.format(value)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
|
||||
[
|
||||
'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',
|
||||
isMobile ? 'rounded-none' : 'rounded-sm',
|
||||
'px-1 py-1 text-center font-semibold tracking-tight',
|
||||
className,
|
||||
]
|
||||
@@ -163,7 +163,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
|
||||
className={
|
||||
isMobile
|
||||
? 'flex-1 overflow-hidden text-slate-100'
|
||||
: 'flex-1 whitespace-normal break-words text-slate-100 @sm:overflow-hidden @sm:whitespace-nowrap'
|
||||
: 'flex-1 whitespace-normal wrap-break-word text-slate-100 @sm:overflow-hidden @sm:whitespace-nowrap'
|
||||
}
|
||||
>
|
||||
<span ref={textRef} className="block">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getHelpContent } from '../../help/content.js';
|
||||
|
||||
function KeyPill({ actionId }) {
|
||||
return (
|
||||
<span className="rounded border border-slate-600 bg-slate-900/40 px-1 text-[0.7rem] text-slate-200">
|
||||
<span className="rounded-sm border border-slate-600 bg-slate-900/40 px-1 text-[0.7rem] text-slate-200">
|
||||
<ControlHint actionId={actionId} />
|
||||
</span>
|
||||
);
|
||||
@@ -52,7 +52,7 @@ function Hero({ hero, keymap }) {
|
||||
{hero.chips && (
|
||||
<div className="flex flex-wrap gap-0.5 text-[0.7rem] text-slate-200">
|
||||
{hero.chips.map((chip) => (
|
||||
<span key={chip} className="rounded border border-slate-700 px-1 py-[2px]">
|
||||
<span key={chip} className="rounded-sm border border-slate-700 px-1 py-[2px]">
|
||||
{chip}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function NumberControl({ entity, disabled, onChange }) {
|
||||
<input type="number" aria-label={entity.name} min={entity.min ?? undefined} max={entity.max ?? undefined} step={entity.step || 'any'}
|
||||
value={value} disabled={blocked} onChange={(event) => control.edit(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') control.commit(event.currentTarget.value); }}
|
||||
className="w-16 min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />
|
||||
className="w-16 min-w-0 rounded-sm border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />
|
||||
{entity.unit ? <span className="text-[0.65rem] text-slate-400">{entity.unit}</span> : null}
|
||||
</div>
|
||||
{!limitsKnown ? <span className="text-xs text-amber-200">Waiting for number limits</span> : null}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Preserve raw sensor text and units; a sensor's non-on value is never "off".
|
||||
export default function ReadOnlyControl({ entity }) {
|
||||
return <span className="break-words text-xs text-slate-200">
|
||||
return <span className="wrap-break-word text-xs text-slate-200">
|
||||
{entity.available ? (entity.password ? '••••••' : `${entity.state}${entity.unit ? ` ${entity.unit}` : ''}`) : 'Unavailable'}
|
||||
</span>;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default function SelectControl({ entity, disabled, onChange }) {
|
||||
return <>
|
||||
<select aria-label={entity.name} value={entity.state} disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50">
|
||||
className="w-full min-w-0 rounded-sm border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50">
|
||||
{!entity.options.includes(entity.state) ? <option value={entity.state} disabled>{entity.state}</option> : null}
|
||||
{entity.options.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
|
||||
@@ -14,6 +14,6 @@ export default function TextControl({ entity, disabled, onChange }) {
|
||||
onCompositionStart={() => { composing.current = true; control.cancel(); }}
|
||||
onCompositionEnd={(event) => { composing.current = false; control.edit(event.currentTarget.value); }}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter' && !composing.current) control.commit(event.currentTarget.value); }}
|
||||
className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />
|
||||
className="w-full min-w-0 rounded-sm border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function ToggleControl({ entity, disabled, onChange }) {
|
||||
return <>
|
||||
<button type="button" aria-label={`Toggle ${entity.name}`} aria-pressed={on}
|
||||
disabled={disabled} onClick={() => onChange(on ? 'off' : 'on')}
|
||||
className={`w-full rounded border px-1 py-0.5 text-xs font-semibold disabled:opacity-50 ${on ? 'border-emerald-700/70 bg-emerald-900 text-white' : 'border-neutral-700 bg-neutral-950 text-slate-300'}`}>
|
||||
className={`w-full rounded-sm border px-1 py-0.5 text-xs font-semibold disabled:opacity-50 ${on ? 'border-emerald-700/70 bg-emerald-900 text-white' : 'border-neutral-700 bg-neutral-950 text-slate-300'}`}>
|
||||
{on ? 'On' : 'Off'}
|
||||
</button>
|
||||
</>;
|
||||
|
||||
@@ -26,7 +26,7 @@ function ActivityTile({ entity, connected, allowed, admin }) {
|
||||
const color = /^#[0-9a-f]{6}$/i.test(entity.color) ? entity.color : '#3b82f6';
|
||||
const Control = controls[entity.type] || ReadOnlyControl;
|
||||
const disabled = !connected || !entity.available || !allowed || (entity.locked && !admin);
|
||||
return <div className="flex min-w-0 flex-col gap-0.5 rounded border px-0.5 py-0.5"
|
||||
return <div className="flex min-w-0 flex-col gap-0.5 rounded-sm border px-0.5 py-0.5"
|
||||
style={{ borderColor: entity.locked ? '#b45309' : `${color}aa`, backgroundColor: `${color}33` }}>
|
||||
<div className="-mx-0.5 -mt-0.5 flex min-h-5 min-w-0 items-center gap-0.5 rounded-t px-0.5 py-0.5 text-white" style={{ backgroundColor: `${color}66` }}>
|
||||
<Icon className="shrink-0 text-xs" aria-hidden="true" />
|
||||
@@ -52,7 +52,7 @@ export default function HomeAssistantActivitiesPanel() {
|
||||
// Reuse the room-control auto-fit grid and compact tile rhythm, while keeping
|
||||
// all data and commands in the Activities namespace on both device layouts.
|
||||
return <CardFrame title="Activity Controls" bodyClassName="space-y-0.5 text-sm"
|
||||
actions={<span className={`rounded px-1 py-0.5 text-xs font-semibold leading-none ${connected ? 'bg-emerald-900 text-emerald-100' : 'bg-amber-900 text-amber-100'}`}>{connected ? 'Connected' : 'Offline'}</span>}>
|
||||
actions={<span className={`rounded-sm px-1 py-0.5 text-xs font-semibold leading-none ${connected ? 'bg-emerald-900 text-emerald-100' : 'bg-amber-900 text-amber-100'}`}>{connected ? 'Connected' : 'Offline'}</span>}>
|
||||
{!connected ? <p className="px-0.5 text-xs text-amber-200">{socketConnected ? 'Home Assistant is offline.' : 'Server disconnected.'} Values may be out of date.</p> : null}
|
||||
{!allowed ? <p className="px-0.5 text-xs text-slate-400">Controls are read-only with your current access.</p> : null}
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(9rem,100%),1fr))] gap-0.5">
|
||||
|
||||
@@ -24,7 +24,7 @@ function StatusBadge({ label, tone = 'muted' }) {
|
||||
? 'bg-amber-900 text-amber-100'
|
||||
: 'bg-slate-800 text-slate-200';
|
||||
return (
|
||||
<span className={`rounded px-1 py-0.5 text-xs font-semibold leading-none ${styles}`}>
|
||||
<span className={`rounded-sm px-1 py-0.5 text-xs font-semibold leading-none ${styles}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
@@ -82,7 +82,7 @@ function LampSwatch({ entity, swatch, disabled, onSetColor, onSetWhite }) {
|
||||
className={cx(
|
||||
// The tile background already shows the Home Assistant-reported color,
|
||||
// so swatches are plain presets rather than a selected-state indicator.
|
||||
'h-5 w-6 shrink-0 rounded-sm border border-white/80 transition-transform disabled:cursor-not-allowed disabled:opacity-40',
|
||||
'h-5 w-6 shrink-0 rounded-xs border border-white/80 transition-transform disabled:cursor-not-allowed disabled:opacity-40',
|
||||
!disabled && 'hover:scale-110 hover:border-white',
|
||||
)}
|
||||
style={{ backgroundColor: swatch.hex }}
|
||||
@@ -135,7 +135,7 @@ function LampTile({ entity, connected, controlsLocked, onToggle, onSetColor, onS
|
||||
// never sprawls wider than three lamps per row. The tile only keeps a
|
||||
// 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',
|
||||
'flex min-w-0 flex-col gap-0.5 rounded-sm border px-0.5 py-0.5 transition-colors',
|
||||
disableToggle ? 'cursor-not-allowed opacity-70' : 'cursor-pointer hover:border-white/60',
|
||||
tileTone,
|
||||
)}
|
||||
@@ -235,7 +235,7 @@ function HomeAssistantControlsContent() {
|
||||
return (
|
||||
<CardFrame title="Room Lights" actions={actions} bodyClassName="space-y-0.5 text-base">
|
||||
{lightPolicyLocked ? (
|
||||
<p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
|
||||
<p className="rounded-sm border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
|
||||
{adminCanControlLockedLights
|
||||
? lockState === 'off'
|
||||
? 'Lights are locked off. Admin room lights remain available.'
|
||||
@@ -267,5 +267,5 @@ function HomeAssistantControlsContent() {
|
||||
|
||||
function KeyPill({ label }) {
|
||||
if (!label) return null;
|
||||
return <span className="rounded border border-slate-600 px-1 text-[0.65rem] text-slate-300">{label}</span>;
|
||||
return <span className="rounded-sm border border-slate-600 px-1 text-[0.65rem] text-slate-300">{label}</span>;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function HornControl({
|
||||
}}
|
||||
// The settings toggle is a nested press target, so it gets the same touch
|
||||
// suppression as the larger horn surface instead of relying on inheritance.
|
||||
className="mobile-touch-control rounded bg-black/40 px-1 py-0.5 text-[0.6rem] font-semibold text-white/90 hover:text-white"
|
||||
className="mobile-touch-control rounded-sm bg-black/40 px-1 py-0.5 text-[0.6rem] font-semibold text-white/90 hover:text-white"
|
||||
>
|
||||
{showSettings ? 'Hide' : 'Settings'}
|
||||
</button>
|
||||
@@ -212,7 +212,7 @@ export default function HornControl({
|
||||
{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">
|
||||
<span className="rounded-sm bg-black/40 px-1 py-0.5 text-[0.6rem] font-semibold text-white">
|
||||
{keyLabel}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -234,7 +234,7 @@ export default function HornControl({
|
||||
<select
|
||||
value={formattedWaveform}
|
||||
onChange={updateWaveform}
|
||||
className="rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.65rem] text-slate-100"
|
||||
className="rounded-sm border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.65rem] text-slate-100"
|
||||
>
|
||||
<option value="saw">Saw</option>
|
||||
<option value="sine">Sine</option>
|
||||
@@ -252,7 +252,7 @@ export default function HornControl({
|
||||
value={freq}
|
||||
data-horn-freq-index={idx}
|
||||
onChange={(event) => updateFreq(idx, event.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
|
||||
className="w-full rounded-sm border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
@@ -268,7 +268,7 @@ export default function HornControl({
|
||||
<select
|
||||
value={formattedWaveform}
|
||||
onChange={updateWaveform}
|
||||
className="rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.65rem] text-slate-100"
|
||||
className="rounded-sm border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.65rem] text-slate-100"
|
||||
>
|
||||
<option value="saw">Saw</option>
|
||||
<option value="sine">Sine</option>
|
||||
@@ -285,7 +285,7 @@ export default function HornControl({
|
||||
value={freq}
|
||||
data-horn-freq-index={idx}
|
||||
onChange={(event) => updateFreq(idx, event.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
|
||||
className="w-full rounded-sm border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -99,10 +99,10 @@ function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpen
|
||||
]);
|
||||
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'}`
|
||||
? `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';
|
||||
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-36 max-w-[70vw] items-center gap-0.5 rounded-sm bg-black/70 px-0.4 py-0.2'
|
||||
: 'pointer-events-auto absolute bottom-1 right-1 flex w-48 max-w-[70vw] items-center gap-0.5 rounded-sm 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
|
||||
@@ -118,16 +118,16 @@ function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpen
|
||||
// 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`;
|
||||
? `field-input h-12 min-w-0 flex-1 px-2 ${inputTextClass} text-slate-100 placeholder:text-slate-400 focus:outline-hidden`
|
||||
: `min-w-0 flex-1 bg-transparent ${inputTextClass} text-slate-100 placeholder:text-slate-400 focus:outline-hidden`;
|
||||
// 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 = 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';
|
||||
? 'mobile-touch-control rounded-sm bg-cyan-500/80 px-0.35 py-0.2 text-[0.55rem] font-semibold text-black disabled:opacity-50'
|
||||
: 'mobile-touch-control rounded-sm 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();
|
||||
@@ -227,7 +227,7 @@ function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpen
|
||||
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"
|
||||
className="flex min-h-12 w-10 shrink-0 items-center justify-center rounded-sm border border-neutral-600 bg-neutral-800 text-xl leading-none text-slate-200 hover:bg-neutral-700 hover:text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function HudMapOverlay({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute rounded" style={mapStyle}>
|
||||
<div className="pointer-events-none absolute rounded-sm" style={mapStyle}>
|
||||
<TopDownMap roverId={roverId} sensors={sensors} size={240} overlay />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,11 +37,11 @@ export default function SpectatorTelemetryOverlay({ sensors, telemetry = null, m
|
||||
>
|
||||
<div className="space-y-0.5 leading-tight">
|
||||
<div className="flex flex-col gap-0.5 text-[0.75rem] font-semibold uppercase tracking-wide">
|
||||
<span className={`rounded px-1.5 py-0.5 ${dockTone}`}>{docked ? 'Docked' : 'Undocked'}</span>
|
||||
<span className={`rounded px-1.5 py-0.5 ${chargingTone}`}>
|
||||
<span className={`rounded-sm px-1.5 py-0.5 ${dockTone}`}>{docked ? 'Docked' : 'Undocked'}</span>
|
||||
<span className={`rounded-sm px-1.5 py-0.5 ${chargingTone}`}>
|
||||
{charging ? 'Charging' : docked ? 'Not charging' : 'Not charging'}
|
||||
</span>
|
||||
<span className={`rounded px-1.5 py-0.5 ${oiTone}`}>OI: {oiLabel}</span>
|
||||
<span className={`rounded-sm px-1.5 py-0.5 ${oiTone}`}>OI: {oiLabel}</span>
|
||||
</div>
|
||||
{telemetryEntries.map(([labelText, value]) => (
|
||||
<span key={labelText} className="flex items-center justify-between gap-0.5">
|
||||
|
||||
@@ -38,7 +38,7 @@ function LightBumpBars({ roverId = null, sensors }) {
|
||||
const fill = `${t * 100}%`;
|
||||
const color = hueFor(v);
|
||||
return (
|
||||
<div key={idx} className="relative flex-1 min-w-[0]" style={{ height: `${barHeight}px` }}>
|
||||
<div key={idx} className="relative flex-1 min-w-0" style={{ height: `${barHeight}px` }}>
|
||||
<div className="h-full w-full overflow-hidden bg-slate-800" style={{ borderRadius: 0 }}>
|
||||
<div
|
||||
className="h-full"
|
||||
|
||||
@@ -51,10 +51,10 @@ function OvercurrentOverlay({ roverId = null, overcurrentLimiter = null, compact
|
||||
? `${labels.join(', ')} · output ${Math.round(driveCap * 100)}%`
|
||||
: labels.join(', ');
|
||||
const containerClass = bypassed
|
||||
? 'h-[3.5rem] w-[14rem]'
|
||||
? 'h-14 w-56'
|
||||
: compact
|
||||
? 'h-[3.5rem] w-[14rem]'
|
||||
: 'h-[7rem] w-[22rem]';
|
||||
? 'h-14 w-56'
|
||||
: 'h-28 w-88';
|
||||
const titleClass = compact || bypassed ? 'text-base' : 'text-3xl';
|
||||
const detailClass = compact || bypassed ? 'text-xs' : 'text-base';
|
||||
const backgroundClass = bypassed ? 'bg-amber-950/75' : 'bg-red-950/70';
|
||||
|
||||
@@ -224,7 +224,7 @@ function TurnsOverlay({
|
||||
{showTurnCue ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/55">
|
||||
<div
|
||||
className={`flex flex-col items-center gap-0.5 rounded border border-amber-300/80 bg-black/70 ${cuePadClass}`}
|
||||
className={`flex flex-col items-center gap-0.5 rounded-sm border border-amber-300/80 bg-black/70 ${cuePadClass}`}
|
||||
>
|
||||
<div className={`font-semibold text-amber-200 ${titleClass}`}>IT IS YOUR TURN!</div>
|
||||
<div className={`text-amber-200/80 ${subClass}`}>Start driving!</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ function VerticalBatteryOverlay({ show = false, roverId = null, sensors, battery
|
||||
if (!show || !batteryVisual?.available) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute right-1 top-1/2 flex h-[70%] -translate-y-1/2 flex-col items-center justify-center rounded bg-black/60 px-0.5 pb-1 pt-1">
|
||||
<div className="pointer-events-none absolute right-1 top-1/2 flex h-[70%] -translate-y-1/2 flex-col items-center justify-center rounded-sm bg-black/60 px-0.5 pb-1 pt-1">
|
||||
<BatteryBar
|
||||
visual={batteryVisual}
|
||||
orientation="vertical"
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function AccessoriesExpansion({ roverId }) {
|
||||
ariaLabel={open ? 'Hide accessory controls' : 'Show accessory controls'}
|
||||
onClick={() => setOpen(!open)}
|
||||
hud
|
||||
className="!h-28"
|
||||
className="h-28!"
|
||||
/>
|
||||
{open ? (
|
||||
<RoverAccessoryControls
|
||||
|
||||
@@ -69,15 +69,15 @@ export default function BottomRightPod({ roverId }) {
|
||||
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">
|
||||
<div className="pointer-events-auto absolute bottom-0 right-0 z-20 flex h-34 w-34 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); }}>
|
||||
<svg viewBox="0 0 144 144" aria-disabled={!canControl} className={`h-34 w-34 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">
|
||||
<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-sm 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" />
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function ChatExpansion({ podOpen }) {
|
||||
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'}`}
|
||||
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-34 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
|
||||
|
||||
@@ -7,24 +7,24 @@ const CORNERS = {
|
||||
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]',
|
||||
expandRotation: 'rotate-135',
|
||||
},
|
||||
'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]',
|
||||
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]',
|
||||
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]',
|
||||
collapseRotation: 'rotate-135',
|
||||
expandRotation: '-rotate-45',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function HornSettingsExpansion({ open, onOpenChange }) {
|
||||
<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"
|
||||
className="rounded-sm bg-slate-900 px-2 py-1 text-white ring-1 ring-slate-600"
|
||||
>
|
||||
<option value="saw">Saw</option>
|
||||
<option value="sine">Sine</option>
|
||||
@@ -72,7 +72,7 @@ export default function HornSettingsExpansion({ open, onOpenChange }) {
|
||||
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"
|
||||
className="min-w-0 flex-1 rounded-sm bg-slate-900 px-1.5 py-1 text-right font-mono text-white ring-1 ring-slate-600"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function TopLeftPod({ roverId }) {
|
||||
: `${seconds}s`;
|
||||
|
||||
return (
|
||||
<div className={`pointer-events-auto absolute left-0 top-0 flex items-start ${showLargeTimer ? 'z-[100]' : 'z-20'}`}>
|
||||
<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 ${
|
||||
@@ -113,8 +113,8 @@ export default function TopLeftPod({ roverId }) {
|
||||
// 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'
|
||||
? 'h-102 w-102 rounded-br-[12.75rem] bg-black'
|
||||
: 'h-34 w-34 rounded-br-[4.25rem] bg-black/60'
|
||||
}`}
|
||||
>
|
||||
{/* The SVG fills the shell. Its circle geometry supplies the same slim visible inset
|
||||
|
||||
@@ -61,7 +61,7 @@ function WifiTile({ signal, ssid }) {
|
||||
<div className="mt-1 truncate text-[0.68rem] font-semibold leading-none text-white">Wi-Fi: {ssid || '--'}</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}%` }} />
|
||||
<span key={bar} className={`flex-1 rounded-xs ${bar <= bars ? tone : 'bg-slate-700'}`} style={{ height: `${25 * bar}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,10 +128,10 @@ export default function TopRightPod({ roverId }) {
|
||||
<>
|
||||
<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">
|
||||
<div className="relative flex h-34 w-34 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">
|
||||
<svg className="h-34 w-34 -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" />
|
||||
@@ -158,7 +158,7 @@ export default function TopRightPod({ roverId }) {
|
||||
<ExpansionPanel
|
||||
open={powerOpen}
|
||||
onOpenChange={setPowerOpen}
|
||||
anchorClassName={`absolute right-0 ${batteryOpen ? 'top-[8.5rem]' : 'top-10'}`}
|
||||
anchorClassName={`absolute right-0 ${batteryOpen ? 'top-34' : 'top-10'}`}
|
||||
panelAlign="right"
|
||||
panelClassName="w-56 rounded-bl-xl bg-black/60 p-1.5 pt-4 text-white"
|
||||
openDirection="left"
|
||||
@@ -185,7 +185,7 @@ export default function TopRightPod({ roverId }) {
|
||||
animation; severity comes from its size and solid color rather than visual noise. */}
|
||||
{lowBattery && !docked ? (
|
||||
<div
|
||||
className={`pointer-events-none absolute left-1/2 top-[58%] z-[55] flex -translate-x-1/2 -translate-y-1/2 items-center gap-2 whitespace-nowrap px-4 py-2 font-bold text-white shadow-xl ${
|
||||
className={`pointer-events-none absolute left-1/2 top-[58%] z-55 flex -translate-x-1/2 -translate-y-1/2 items-center gap-2 whitespace-nowrap px-4 py-2 font-bold text-white shadow-xl ${
|
||||
urgentBattery ? 'bg-red-950 text-xl' : 'bg-amber-950 text-base'
|
||||
}`}
|
||||
role="alert"
|
||||
|
||||
@@ -38,14 +38,14 @@ function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndoc
|
||||
{/* The docked shield is owned by the dismissible action so hiding the
|
||||
prompt also reveals the video and ordinary HUD instead of leaving an
|
||||
unexplained dark, input-blocking layer behind. */}
|
||||
<div className="pointer-events-auto absolute inset-0 z-[25] bg-black/75" aria-hidden="true" />
|
||||
<div className="pointer-events-auto absolute inset-0 z-25 bg-black/75" aria-hidden="true" />
|
||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center p-6">
|
||||
<div className="relative w-[min(32rem,80%)]">
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending || controlsDisabled}
|
||||
onClick={onUndock}
|
||||
className={`pointer-events-auto flex w-full 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 ${mainToneClass}`}
|
||||
className={`pointer-events-auto flex w-full flex-col items-center gap-2 px-8 py-7 text-center text-white shadow-2xl ring-2 transition focus-visible:outline-hidden focus-visible:ring-4 ${mainToneClass}`}
|
||||
>
|
||||
<strong className="text-3xl leading-tight">{pending ? 'Undocking…' : 'Your rover is docked'}</strong>
|
||||
{pending ? (
|
||||
@@ -73,7 +73,7 @@ function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndoc
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHidden(true)}
|
||||
className={`pointer-events-auto absolute left-1/2 top-full -translate-x-1/2 rounded-b-lg px-6 py-1.5 text-sm font-bold text-white shadow-xl ring-2 transition focus-visible:outline-none focus-visible:ring-4 ${hideToneClass}`}
|
||||
className={`pointer-events-auto absolute left-1/2 top-full -translate-x-1/2 rounded-b-lg px-6 py-1.5 text-sm font-bold text-white shadow-xl ring-2 transition focus-visible:outline-hidden focus-visible:ring-4 ${hideToneClass}`}
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
@@ -93,7 +93,7 @@ function AutoDockingAction({ driveKeyLabel, pending, controlsDisabled, error, on
|
||||
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 ${
|
||||
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-hidden 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'
|
||||
@@ -134,17 +134,17 @@ function DockAssistAction({ active, pending, controlsDisabled, error, dockKeyLab
|
||||
const dockPositionClass = cornerOffsetClass === 'right-0' ? 'right-10' : cornerOffsetClass;
|
||||
if (active) {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-[60] flex items-center justify-center">
|
||||
<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">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-sm 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"
|
||||
className="bg-slate-800 px-2 py-1 text-xs font-bold text-white transition hover:bg-slate-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-cyan-200 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -172,7 +172,7 @@ function DockAssistAction({ active, pending, controlsDisabled, error, dockKeyLab
|
||||
aria-label="Start rover docking assist"
|
||||
disabled={pending || controlsDisabled}
|
||||
onClick={onDock}
|
||||
className={`flex items-center gap-1.5 rounded-bl-xl px-4 pb-2 pt-4 text-base font-bold shadow-xl ring-1 transition focus-visible:outline-none focus-visible:ring-2 disabled:cursor-wait disabled:opacity-75 ${
|
||||
className={`flex items-center gap-1.5 rounded-bl-xl px-4 pb-2 pt-4 text-base font-bold shadow-xl ring-1 transition focus-visible:outline-hidden 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
|
||||
@@ -286,7 +286,7 @@ export default function DockingHud({ roverId }) {
|
||||
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 cornerOffsetClass = batteryPodOpen ? 'right-34' : 'right-0';
|
||||
const previousDockedRef = useRef(docked);
|
||||
const finishUndockTransition = useCallback(() => setShowUndockTransition(false), []);
|
||||
|
||||
@@ -344,7 +344,7 @@ export default function DockingHud({ roverId }) {
|
||||
docked shield lives inside DockedAction because the new Hide control
|
||||
must dismiss the prompt and its dimming as one coherent surface. */}
|
||||
<div
|
||||
className={`absolute inset-0 z-[25] transition-all duration-300 ${
|
||||
className={`absolute inset-0 z-25 transition-all duration-300 ${
|
||||
autoDocking || pendingAction === 'resuming'
|
||||
? 'pointer-events-auto bg-black/55 opacity-100'
|
||||
: 'pointer-events-none opacity-0'
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function InitialSessionOverlay() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black text-slate-200"
|
||||
className="fixed inset-0 z-1000 flex items-center justify-center bg-black text-slate-200"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={connected ? 'Loading session' : 'Connecting'}
|
||||
|
||||
@@ -57,7 +57,7 @@ function InstanceStatus({ remote }) {
|
||||
const availability = getRemoteAvailability(remote);
|
||||
return (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-0.5 text-[0.7rem]">
|
||||
<span className={classNames('rounded px-1.5 py-0.5 text-xs font-semibold', statusClass(availability.tone))}>
|
||||
<span className={classNames('rounded-sm px-1.5 py-0.5 text-xs font-semibold', statusClass(availability.tone))}>
|
||||
{availability.label}
|
||||
</span>
|
||||
</div>
|
||||
@@ -108,7 +108,7 @@ function InstancePanel({ remote, children = null }) {
|
||||
{online && features.length ? (
|
||||
<div className="flex flex-wrap justify-center gap-0.5">
|
||||
{features.map((feature) => (
|
||||
<span key={feature} className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-200">
|
||||
<span key={feature} className="rounded-sm bg-slate-800 px-1 text-[0.7rem] text-slate-200">
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
@@ -185,11 +185,11 @@ export function ExternalInstancesCompact({ onBrowse = null }) {
|
||||
|
||||
export function InterInstancePopup({ onClose }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/80 p-0.5">
|
||||
<div className="fixed inset-0 z-70 flex items-center justify-center bg-black/80 p-0.5">
|
||||
<InterInstanceBrowserFrame
|
||||
onClose={onClose}
|
||||
scaledOverlay
|
||||
className="inter-instance-overlay-frame w-fit [container-type:normal]"
|
||||
className="inter-instance-overlay-frame w-fit @container-normal"
|
||||
bodyClassName="inter-instance-overlay-body overflow-y-auto p-0.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -127,7 +127,7 @@ function SettingsGroupLabel({ children }) {
|
||||
|
||||
function SpeedField({ label, description, value, onChange, min = 0, max = 500, step = 5 }) {
|
||||
return (
|
||||
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
|
||||
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1">
|
||||
{/* The value field sits beside the label because users tune these settings by comparing
|
||||
the name and the numeric value together. Keeping them in one row prevents the number
|
||||
input from drifting across a wide settings panel. */}
|
||||
@@ -139,7 +139,7 @@ function SpeedField({ label, description, value, onChange, min = 0, max = 500, s
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="w-full rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-right text-xs font-mono text-white"
|
||||
className="w-full rounded-sm border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-right text-xs font-mono text-white"
|
||||
/>
|
||||
</div>
|
||||
{description && <p className="mt-0.5 text-xs leading-snug text-white">{description}</p>}
|
||||
@@ -164,7 +164,7 @@ function BindingRow({ action, value, isActive, onCaptureStart, onCancel, onCaptu
|
||||
// change button are one interaction unit, so proximity matters more than stretching to fill
|
||||
// every available pixel in the surrounding sidebar.
|
||||
return (
|
||||
<div 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 max-[420px]:grid-cols-1">
|
||||
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded-sm bg-neutral-800/80 px-1.5 py-1 text-sm max-[420px]:grid-cols-1">
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold leading-snug text-white">{action.label}</p>
|
||||
<p className="mt-0.5 text-xs leading-snug text-white">{formatKeyLabel(value)}</p>
|
||||
|
||||
@@ -111,10 +111,10 @@ function KinectPanelContent() {
|
||||
|
||||
const actions = (
|
||||
<div className="flex flex-wrap items-center justify-end gap-0.5 text-[0.68rem] text-slate-400">
|
||||
<span className={`inline-flex min-w-[3.7rem] justify-center rounded border px-1 py-0.5 text-xs font-semibold ${statusPill.className}`}>
|
||||
<span className={`inline-flex min-w-[3.7rem] justify-center rounded-sm border px-1 py-0.5 text-xs font-semibold ${statusPill.className}`}>
|
||||
{statusPill.label}
|
||||
</span>
|
||||
<div className="inline-flex overflow-hidden rounded border border-slate-700">
|
||||
<div className="inline-flex overflow-hidden rounded-sm border border-slate-700">
|
||||
{['3d', 'image'].map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
@@ -147,7 +147,7 @@ function KinectPanelContent() {
|
||||
|
||||
return (
|
||||
<CardFrame title="Kinect Viewer" actions={actions} bodyClassName="space-y-0.5 p-0.5 text-sm">
|
||||
<div className="aspect-[4/3] w-full overflow-hidden rounded bg-black">
|
||||
<div className="aspect-4/3 w-full overflow-hidden rounded-sm bg-black">
|
||||
{activeView === '3d' && pointCloudFrame?.buffer ? (
|
||||
<PointCloudViewer frame={pointCloudFrame} />
|
||||
) : activeView === '3d' ? (
|
||||
@@ -163,7 +163,7 @@ function KinectPanelContent() {
|
||||
)}
|
||||
</div>
|
||||
{visibleError ? (
|
||||
<p className="m-0 break-words text-[0.7rem] text-red-300">{String(visibleError).toLowerCase()}</p>
|
||||
<p className="m-0 wrap-break-word text-[0.7rem] text-red-300">{String(visibleError).toLowerCase()}</p>
|
||||
) : null}
|
||||
</CardFrame>
|
||||
);
|
||||
|
||||
@@ -95,7 +95,7 @@ function LiftCardContent() {
|
||||
className="relative w-full"
|
||||
bodyClassName="text-sm text-slate-200"
|
||||
actions={
|
||||
<span className={`inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${badgeClass(statusTone)}`}>
|
||||
<span className={`inline-flex w-auto rounded-sm px-1 py-0.25 text-xs font-semibold ${badgeClass(statusTone)}`}>
|
||||
{status}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -87,9 +87,9 @@ function FloatingPadOverlay({ center, size, activeCellId }) {
|
||||
const halfPad = size / 2;
|
||||
|
||||
return createPortal(
|
||||
<div className="pointer-events-none fixed inset-0 z-[1000]" aria-hidden="true">
|
||||
<div className="pointer-events-none fixed inset-0 z-1000" aria-hidden="true">
|
||||
<div
|
||||
className="absolute grid grid-cols-3 grid-rows-3 overflow-hidden rounded-lg border-2 border-cyan-300/80 bg-slate-950/90 shadow-2xl shadow-cyan-950/50 backdrop-blur-sm"
|
||||
className="absolute grid grid-cols-3 grid-rows-3 overflow-hidden rounded-lg border-2 border-cyan-300/80 bg-slate-950/90 shadow-2xl shadow-cyan-950/50 backdrop-blur-xs"
|
||||
style={{
|
||||
height: size,
|
||||
left: center.x - halfPad,
|
||||
@@ -221,7 +221,7 @@ export default function FloatingJoystick({
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="presentation"
|
||||
className={`mobile-touch-control mobile-drag-control relative flex h-full w-full select-none items-center justify-center overflow-hidden text-slate-100 ${compact ? 'min-h-[7rem]' : 'min-h-[10rem]'}`}
|
||||
className={`mobile-touch-control mobile-drag-control relative flex h-full w-full select-none items-center justify-center overflow-hidden text-slate-100 ${compact ? 'min-h-28' : 'min-h-40'}`}
|
||||
// Pointer drags are the whole control model here, so this inline value
|
||||
// reinforces the utility class even if future class churn changes it.
|
||||
style={{ touchAction: 'none' }}
|
||||
|
||||
@@ -62,7 +62,7 @@ function MovementColumnContent({ layout }) {
|
||||
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 ${
|
||||
className={`mobile-touch-control flex min-h-18 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
|
||||
|
||||
@@ -142,14 +142,14 @@ 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"
|
||||
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-hidden 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"
|
||||
style={{ height: `${valuePercent}%` }}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute left-1/2 h-3.5 w-3.5 -translate-x-1/2 rounded-full border border-emerald-950 bg-emerald-200 shadow"
|
||||
className="pointer-events-none absolute left-1/2 h-3.5 w-3.5 -translate-x-1/2 rounded-full border border-emerald-950 bg-emerald-200 shadow-sm"
|
||||
style={{ bottom: `clamp(0.25rem, calc(${valuePercent}% - 0.4375rem), calc(100% - 1.125rem))` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +119,7 @@ export default function ModeGateOverlay() {
|
||||
hideWhenEmpty
|
||||
scaledOverlay
|
||||
singleColumn
|
||||
className="inter-instance-overlay-frame w-[20.5rem] min-w-0 max-w-full"
|
||||
className="inter-instance-overlay-frame w-82 min-w-0 max-w-full"
|
||||
bodyClassName="inter-instance-overlay-body min-w-0 overflow-x-hidden overflow-y-auto p-0.5"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -125,7 +125,7 @@ function NeatoCardContent() {
|
||||
className="w-full"
|
||||
bodyClassName="text-sm text-slate-200"
|
||||
actions={
|
||||
<span className={`inline-flex w-auto rounded px-1 py-0.25 text-xs font-semibold ${metricToneClass(headerTone)}`}>
|
||||
<span className={`inline-flex w-auto rounded-sm px-1 py-0.25 text-xs font-semibold ${metricToneClass(headerTone)}`}>
|
||||
{headerStatus}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function NewDriveVideo() {
|
||||
if (!roverId) {
|
||||
return (
|
||||
<div
|
||||
className={`min-h-0 w-full overflow-hidden bg-black ${mobileHud ? 'aspect-[4/3] shrink-0' : 'h-screen'}`}
|
||||
className={`min-h-0 w-full overflow-hidden bg-black ${mobileHud ? 'aspect-4/3 shrink-0' : 'h-screen'}`}
|
||||
aria-label="No rover assigned"
|
||||
>
|
||||
{/* Keep the unassigned state inside the same solid video-shaped stage
|
||||
@@ -49,7 +49,7 @@ export default function NewDriveVideo() {
|
||||
{/* 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">
|
||||
<div className="relative aspect-4/3 w-full overflow-hidden bg-black [&_img]:object-contain [&_video]:object-contain">
|
||||
<RoverMediaPlayer roverId={roverId} videoMode={videoMode} />
|
||||
|
||||
<div
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function NicknameForm({ compact = false }) {
|
||||
disabled={saving}
|
||||
className="button-dark h-full shrink-0 whitespace-nowrap px-0.5 py-0 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : compact ? 'Set' : 'Save'}
|
||||
{saving ? 'Saving…' : compact ? 'Set Nickname' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ function formatClassification(value) {
|
||||
function ProgressBar({ value, color = 'bg-emerald-500' }) {
|
||||
const width = `${Math.round(Math.max(0, Math.min(1, Number(value) || 0)) * 100)}%`;
|
||||
return (
|
||||
<div className="h-2 w-full overflow-hidden rounded bg-slate-800">
|
||||
<div className="h-2 w-full overflow-hidden rounded-sm bg-slate-800">
|
||||
<div className={`h-full ${color}`} style={{ width }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ function OverseerMemoryPopup({ memory, onClose }) {
|
||||
>
|
||||
{/* The memory is rendered as the server's existing plain-text summary so
|
||||
this popup stays consistent with the overseer/admin debug display. */}
|
||||
<pre className="surface max-h-[70vh] overflow-y-auto whitespace-pre-wrap break-words font-mono text-[0.72rem] text-slate-200">{summary}</pre>
|
||||
<pre className="surface max-h-[70vh] overflow-y-auto whitespace-pre-wrap wrap-break-word font-mono text-[0.72rem] text-slate-200">{summary}</pre>
|
||||
</CardFrame>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -182,7 +182,7 @@ function PtzLightingControls({ ptz, disabled = false }) {
|
||||
onClick={cycleIr}
|
||||
>
|
||||
<span className="text-sm font-semibold">Infrared</span>
|
||||
<span className="rounded bg-cyan-300 px-1 py-0.5 text-[0.7rem] font-semibold text-cyan-950">{irMode}</span>
|
||||
<span className="rounded-sm bg-cyan-300 px-1 py-0.5 text-[0.7rem] font-semibold text-cyan-950">{irMode}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -406,7 +406,7 @@ function PtzPresetPanel({ ptz }) {
|
||||
bodyClassName="flex min-h-0 flex-col gap-1 p-1 text-xs"
|
||||
>
|
||||
{ptz?.presetsError ? (
|
||||
<div className="rounded border border-amber-500/50 bg-amber-950/40 p-1 text-amber-100">
|
||||
<div className="rounded-sm border border-amber-500/50 bg-amber-950/40 p-1 text-amber-100">
|
||||
{ptz.presetsError}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -443,7 +443,7 @@ function PtzPresetPanel({ ptz }) {
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className="rounded border border-slate-700 bg-black/30 p-2 text-center text-slate-400">
|
||||
<div className="rounded-sm border border-slate-700 bg-black/30 p-2 text-center text-slate-400">
|
||||
No presets saved.
|
||||
</div>
|
||||
)}
|
||||
@@ -568,7 +568,7 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
|
||||
<PtzPresetPanel ptz={ptz} />
|
||||
</div>
|
||||
{releasePending ? (
|
||||
<div className="pointer-events-none absolute bottom-1 right-1 rounded bg-black/80 px-2 py-1 text-xs text-slate-200">
|
||||
<div className="pointer-events-none absolute bottom-1 right-1 rounded-sm bg-black/80 px-2 py-1 text-xs text-slate-200">
|
||||
Closing...
|
||||
</div>
|
||||
) : null}
|
||||
@@ -594,7 +594,7 @@ function PtzMobileLandscape({ ptz, onClose, releasePending = false }) {
|
||||
<main className="relative h-[calc(100dvh-0.25rem)] min-h-0 overflow-hidden bg-black">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-1 top-1 z-50 rounded border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow disabled:opacity-50"
|
||||
className="absolute left-1 top-1 z-50 rounded-sm border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow-sm disabled:opacity-50"
|
||||
disabled={releasePending}
|
||||
onClick={onClose}
|
||||
>
|
||||
@@ -645,7 +645,7 @@ function PtzMobilePortrait({ ptz, onClose, releasePending = false }) {
|
||||
<main className="relative aspect-video min-h-0 shrink-0 overflow-hidden bg-black">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-1 top-1 z-50 rounded border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow disabled:opacity-50"
|
||||
className="absolute left-1 top-1 z-50 rounded-sm border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow-sm disabled:opacity-50"
|
||||
disabled={releasePending}
|
||||
onClick={onClose}
|
||||
>
|
||||
@@ -814,7 +814,7 @@ export function PtzControllerPage({ layout = 'desktop' }) {
|
||||
|
||||
if (!featureEnabled) {
|
||||
return (
|
||||
<main className={`flex min-h-[100dvh] items-center justify-center p-2 text-slate-100 ${pageBackgroundClass}`}>
|
||||
<main className={`flex min-h-dvh items-center justify-center p-2 text-slate-100 ${pageBackgroundClass}`}>
|
||||
<CardFrame title="PTZ camera" bodyClassName="space-y-1 p-2 text-sm">
|
||||
<p>The PTZ camera is not available.</p>
|
||||
<button type="button" className="button-dark w-full" onClick={() => navigate('/')}>Return to driver page</button>
|
||||
@@ -824,7 +824,7 @@ export function PtzControllerPage({ layout = 'desktop' }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={`h-[100dvh] w-full overflow-hidden text-slate-100 ${pageBackgroundClass}`}>
|
||||
<main className={`h-dvh w-full overflow-hidden text-slate-100 ${pageBackgroundClass}`}>
|
||||
{/* The fullscreen CardFrame remains the structural shell. Painting its otherwise
|
||||
transparent body is what lets every desktop and mobile PTZ composition share one
|
||||
continuous pattern without threading theme props into each individual child panel. */}
|
||||
@@ -838,7 +838,7 @@ export function PtzControllerPage({ layout = 'desktop' }) {
|
||||
hideHeader={isMobile}
|
||||
fillHeight
|
||||
clipOverflow={false}
|
||||
className="h-[100dvh] w-[100vw] rounded-none border-0 !bg-black"
|
||||
className="h-dvh w-screen rounded-none border-0 bg-black!"
|
||||
bodyClassName={`relative min-h-0 flex-1 ${pageBackgroundClass}`}
|
||||
>
|
||||
{isMobile ? (
|
||||
@@ -952,7 +952,7 @@ export default function PtzQueueCard() {
|
||||
</button>
|
||||
) : null}
|
||||
{!canUse ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-black/75 px-2 text-center text-sm font-semibold text-slate-100">
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-sm bg-black/75 px-2 text-center text-sm font-semibold text-slate-100">
|
||||
Verify your account to use the PTZ camera.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -66,7 +66,7 @@ export function QueueUserChips({
|
||||
return (
|
||||
<span
|
||||
key={`${targetId}-${socketId}-${idx}`}
|
||||
className={`flex items-center gap-0.5 rounded px-1 text-[0.7rem] ${highlightClass}`}
|
||||
className={`flex items-center gap-0.5 rounded-sm px-1 text-[0.7rem] ${highlightClass}`}
|
||||
>
|
||||
<span className={`${roleColors(user.role)} font-semibold`}>
|
||||
{formatQueueUserLabel(user, selfId)}
|
||||
@@ -129,7 +129,7 @@ export default function QueueTargetRow({
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
className="h-8 w-10 shrink-0 rounded border border-slate-700 bg-black object-cover"
|
||||
className="h-8 w-10 shrink-0 rounded-sm border border-slate-700 bg-black object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
@@ -151,7 +151,7 @@ export default function QueueTargetRow({
|
||||
) : null}
|
||||
</p>
|
||||
{timerLabel ? (
|
||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-200">
|
||||
<span className="rounded-sm bg-slate-800 px-1 text-[0.7rem] text-slate-200">
|
||||
{timerLabel}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function RawUserPilePanel({
|
||||
sorted.map((user) => (
|
||||
<span
|
||||
key={user.socketId}
|
||||
className={`rounded px-1 py-0.25 text-[0.7rem] ${roleColors(user.role)}`}
|
||||
className={`rounded-sm px-1 py-0.25 text-[0.7rem] ${roleColors(user.role)}`}
|
||||
>
|
||||
{formatLabel(user, selfId)}
|
||||
</span>
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function ReplayReadyPopup({ replay, onClose, variant = 'modal' })
|
||||
clipOverflow={false}
|
||||
bodyClassName="p-0.5"
|
||||
>
|
||||
<div className="overflow-hidden rounded bg-black">
|
||||
<div className="overflow-hidden rounded-sm bg-black">
|
||||
<video
|
||||
key={videoUrl}
|
||||
src={videoUrl}
|
||||
@@ -126,7 +126,7 @@ export default function ReplayReadyPopup({ replay, onClose, variant = 'modal' })
|
||||
// The floating panel is intentionally compact because it appears for users who
|
||||
// did not ask for the replay. It still loads the video immediately, but its
|
||||
// bounded height prevents the floating card from covering too much of the UI.
|
||||
className="aspect-video max-h-[14rem] w-full bg-black"
|
||||
className="aspect-video max-h-56 w-full bg-black"
|
||||
/>
|
||||
</div>
|
||||
</CardFrame>
|
||||
@@ -143,7 +143,7 @@ export default function ReplayReadyPopup({ replay, onClose, variant = 'modal' })
|
||||
className={isModal ? 'h-full w-full' : ''}
|
||||
bodyClassName={`${isPanel || isModal ? 'flex min-h-0 flex-1 flex-col' : ''} space-y-0.5 p-0.5 text-sm text-slate-200`}
|
||||
>
|
||||
<div className={`${isPanel || isModal ? 'min-h-0 flex-1' : ''} overflow-hidden rounded bg-black`}>
|
||||
<div className={`${isPanel || isModal ? 'min-h-0 flex-1' : ''} overflow-hidden rounded-sm bg-black`}>
|
||||
<video
|
||||
key={videoUrl}
|
||||
src={videoUrl}
|
||||
@@ -156,18 +156,18 @@ export default function ReplayReadyPopup({ replay, onClose, variant = 'modal' })
|
||||
// that video should fill the available viewport instead of behaving like
|
||||
// a centered dialog preview. object-contain preserves the replay frame
|
||||
// without cropping if the browser viewport is not the same aspect ratio.
|
||||
className={`${isPanel ? 'h-full min-h-[10rem]' : isModal ? 'h-full object-contain' : 'aspect-video max-h-[72vh]'} w-full bg-black`}
|
||||
className={`${isPanel ? 'h-full min-h-40' : isModal ? 'h-full object-contain' : 'aspect-video max-h-[72vh]'} w-full bg-black`}
|
||||
/>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
|
||||
if (isPanel) {
|
||||
return <div className="flex h-full min-h-[14rem] flex-col">{card}</div>;
|
||||
return <div className="flex h-full min-h-56 flex-col">{card}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] flex items-stretch justify-stretch bg-black" onClick={onClose} role="presentation">
|
||||
<div className="fixed inset-0 z-110 flex items-stretch justify-stretch bg-black" onClick={onClose} role="presentation">
|
||||
<div
|
||||
className="pointer-events-auto h-full w-full"
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -237,7 +237,7 @@ export default function ReplaySourcesPanel({
|
||||
return (
|
||||
<div className={`relative ${fillHeight ? 'flex h-full min-h-0 flex-col' : ''}`}>
|
||||
{showPanelReplay ? (
|
||||
<div className="absolute bottom-[calc(100%+0.125rem)] left-1/2 z-[70] w-[min(20rem,calc(100vw-1rem))] -translate-x-1/2">
|
||||
<div className="absolute bottom-[calc(100%+0.125rem)] left-1/2 z-70 w-[min(20rem,calc(100vw-1rem))] -translate-x-1/2">
|
||||
<ReplayReadyPopup
|
||||
replay={panelReplay}
|
||||
variant="floating-panel"
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function RewardRunOverlay() {
|
||||
if (!overlay) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 z-[120] flex items-center justify-center bg-black/75 px-1 py-1">
|
||||
<div className="pointer-events-none fixed inset-0 z-120 flex items-center justify-center bg-black/75 px-1 py-1">
|
||||
<div className="pointer-events-auto w-full max-w-2xl">
|
||||
<div className="surface rounded-none border-2 border-cyan-300 bg-amber-100/95 px-1 py-1 text-center text-black shadow-2xl">
|
||||
<div className="flex items-center justify-center gap-1 border-2 border-blue-600 bg-yellow-100 px-0.5 py-1">
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function RoomCameraFeed({ feed, label }) {
|
||||
}, [feed]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden rounded bg-black" style={{ aspectRatio: '4 / 3' }}>
|
||||
<div className="relative w-full overflow-hidden rounded-sm bg-black" style={{ aspectRatio: '4 / 3' }}>
|
||||
{feed?.objectUrl ? (
|
||||
<img src={feed.objectUrl} alt={label} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
@@ -28,7 +28,7 @@ export default function RoomCameraFeed({ feed, label }) {
|
||||
<div className="pointer-events-none absolute left-0 top-0 bg-black/70 px-0.5 py-0.5 text-xs font-semibold text-white">
|
||||
{label}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 m-0 flex items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25 text-[0.7rem] text-slate-100">
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 m-0 flex items-center gap-0.5 rounded-sm bg-black/70 px-0.5 py-0.25 text-[0.7rem] text-slate-100">
|
||||
<span className={`h-2 w-2 rounded-full ${blink ? 'bg-emerald-400' : 'bg-slate-500'}`} />
|
||||
<span>{statusText}</span>
|
||||
</div>
|
||||
|
||||
@@ -160,7 +160,7 @@ function RoomCameraPanelContent({
|
||||
const [orientation, setOrientation] = useState(() =>
|
||||
normalizeOrientation(
|
||||
panelId ? orientationSettings?.[panelId] : defaultOrientation,
|
||||
'horizontal',
|
||||
normalizeOrientation(defaultOrientation, 'horizontal'),
|
||||
),
|
||||
);
|
||||
const storedOrientation = panelId ? orientationSettings?.[panelId] : null;
|
||||
@@ -197,7 +197,7 @@ function RoomCameraPanelContent({
|
||||
const actions = showLayoutToggle ? (
|
||||
<div className="flex items-center gap-0.5 text-[0.68rem] text-slate-400">
|
||||
<span>Layout</span>
|
||||
<div className="inline-flex overflow-hidden rounded border border-slate-700">
|
||||
<div className="inline-flex overflow-hidden rounded-sm border border-slate-700">
|
||||
{ORIENTATIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
|
||||
@@ -125,7 +125,7 @@ function SliderControl({ peripheralId, control, disabled, send, value: storedVal
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
style={{ touchAction: 'none' }}
|
||||
className="mobile-touch-control mobile-drag-control relative mt-0.5 h-6 w-full 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"
|
||||
className="mobile-touch-control mobile-drag-control relative mt-0.5 h-6 w-full rounded-full border border-emerald-100/80 bg-emerald-950 shadow-inner focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-emerald-200"
|
||||
>
|
||||
{/* An inset track gives the thumb room to remain entirely inside the
|
||||
card at both endpoints without browser-specific range styling. */}
|
||||
@@ -136,7 +136,7 @@ function SliderControl({ peripheralId, control, disabled, send, value: storedVal
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-emerald-950 bg-emerald-200 shadow"
|
||||
className="pointer-events-none absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-emerald-950 bg-emerald-200 shadow-sm"
|
||||
style={{ left: `clamp(0.4375rem, ${valuePercent}%, calc(100% - 0.4375rem))` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -271,7 +271,7 @@ function NumberControl({ peripheralId, control, disabled, send, value: storedVal
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className={`mobile-touch-control h-9 w-[45%] min-w-16 rounded-lg border border-indigo-200/70 bg-indigo-950 px-1.5 text-right text-base text-white outline-none focus-visible:ring-2 focus-visible:ring-indigo-200 ${DISABLED_CLASS}`}
|
||||
className={`mobile-touch-control h-9 w-[45%] min-w-16 rounded-lg border border-indigo-200/70 bg-indigo-950 px-1.5 text-right text-base text-white outline-hidden focus-visible:ring-2 focus-visible:ring-indigo-200 ${DISABLED_CLASS}`}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
@@ -311,7 +311,7 @@ function TextControl({ peripheralId, control, disabled, send, value: storedValue
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className={`mobile-touch-control h-9 w-[55%] min-w-20 rounded-lg border border-sky-200/70 bg-sky-950 px-1.5 text-base text-white outline-none focus-visible:ring-2 focus-visible:ring-sky-200 ${DISABLED_CLASS}`}
|
||||
className={`mobile-touch-control h-9 w-[55%] min-w-20 rounded-lg border border-sky-200/70 bg-sky-950 px-1.5 text-base text-white outline-hidden focus-visible:ring-2 focus-visible:ring-sky-200 ${DISABLED_CLASS}`}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function RoverLabel({
|
||||
return (
|
||||
<Component
|
||||
className={classNames(
|
||||
'inline-block rounded border border-transparent px-1 py-[1px] font-semibold text-white',
|
||||
'inline-block rounded-sm border border-transparent px-1 py-px font-semibold text-white',
|
||||
className,
|
||||
)}
|
||||
style={{ ...(style || {}), ...(roverNameChromeStyle(resolvedColor, 0.5) || {}) }}
|
||||
|
||||
@@ -723,7 +723,7 @@ export default function RoverMediaPlayer({
|
||||
)}
|
||||
{showConnectingOverlay ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-black/45">
|
||||
<div className="rounded border border-slate-500/70 bg-black/70 px-3 py-1 text-sm font-semibold text-slate-100">
|
||||
<div className="rounded-sm border border-slate-500/70 bg-black/70 px-3 py-1 text-sm font-semibold text-slate-100">
|
||||
Connecting to video....
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -304,7 +304,7 @@ export default function RoverQueuesPanel({
|
||||
</ul>
|
||||
)}
|
||||
{externalBlocked ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-black/70 px-2 text-center text-sm font-semibold text-slate-100">
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-sm bg-black/70 px-2 text-center text-sm font-semibold text-slate-100">
|
||||
{disabledOverlay}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function RoverRoster({
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 flex flex-wrap items-center gap-0.5">
|
||||
<span>{rover.locked ? 'locked' : 'free'}</span>
|
||||
{rover.lockReason && <span className="rounded bg-black/30 px-1">{rover.lockReason}</span>}
|
||||
{rover.lockReason && <span className="rounded-sm bg-black/30 px-1">{rover.lockReason}</span>}
|
||||
<span className={classNames('font-semibold', batteryClass(rover))}>
|
||||
Battery {formatBattery(rover)}
|
||||
</span>
|
||||
|
||||
@@ -60,7 +60,7 @@ function SettingRow({ children, inline = false, className = '' }) {
|
||||
// the full settings pane.
|
||||
return (
|
||||
<label
|
||||
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}`}
|
||||
className={`mx-auto flex w-full max-w-lg items-center gap-1.5 rounded-sm 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>
|
||||
@@ -95,10 +95,10 @@ function RangeSetting({ label, value, disabled = false, onChange }) {
|
||||
// Range settings need enough horizontal room for accurate pointer input, so the slider spans
|
||||
// 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">
|
||||
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1 text-sm text-white">
|
||||
<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">
|
||||
<span className="rounded-sm bg-neutral-900 px-1 py-0.5 text-xs text-white">
|
||||
{Math.round(value * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -270,10 +270,10 @@ export default function SettingsPanel() {
|
||||
{/* 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>
|
||||
<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">
|
||||
@@ -333,7 +333,7 @@ export default function SettingsPanel() {
|
||||
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 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)}`}
|
||||
className={`page-theme-preview flex h-48 flex-col gap-0.5 overflow-hidden rounded-sm 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" />
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function SocialButton({ id = null, label, url, icon, color, layou
|
||||
{isInline ? (
|
||||
<span className="inline-flex max-w-full items-center justify-center gap-1 text-center leading-tight">
|
||||
<Icon className="shrink-0" style={{ fontSize: '1.1em' }} />
|
||||
<span className="break-words">{text}</span>
|
||||
<span className="wrap-break-word">{text}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex h-full w-full max-w-full flex-col items-center justify-between text-center leading-tight">
|
||||
@@ -60,7 +60,7 @@ export default function SocialButton({ id = null, label, url, icon, color, layou
|
||||
<Icon className="shrink-0" style={{ fontSize: 'clamp(1rem, 2.2vh + 1.2vw, 2rem)' }} />
|
||||
{/* <Icon className="shrink-0" style={{ fontSize: '2vh' }} /> */}
|
||||
</span>
|
||||
<span className="w-full break-words leading-tight">{text}</span>
|
||||
<span className="w-full wrap-break-word leading-tight">{text}</span>
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
|
||||
@@ -95,13 +95,13 @@ export default function SocketConnectionPill() {
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
'fixed bottom-2 left-2 z-[120] transition-opacity duration-500',
|
||||
'fixed bottom-2 left-2 z-120 transition-opacity duration-500',
|
||||
!visible && 'hidden',
|
||||
faded ? 'opacity-0 pointer-events-none' : 'opacity-100',
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className={cls('rounded-sm px-2 py-1 text-xs', toneClass)}>
|
||||
<div className={cls('rounded-xs px-2 py-1 text-xs', toneClass)}>
|
||||
<span className="font-semibold">{statusLabel}</span>
|
||||
{detail ? <span className="ml-1 opacity-85">({detail})</span> : null}
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function SpectateVideo({
|
||||
const videoMode = isExternalSpectatorSnapshotOnly ? 'snapshot' : null;
|
||||
return (
|
||||
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
|
||||
<div className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-[4/3]'}`}>
|
||||
<div className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-4/3'}`}>
|
||||
<RoverMediaPlayer
|
||||
roverId={roverId}
|
||||
label={label}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function useTabIsActive(id) {
|
||||
|
||||
const TAB_VARIANTS = {
|
||||
primary: {
|
||||
base: 'flex-1 px-0.5 py-0.5 text-sm font-medium focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-slate-500 rounded-md border border-slate-800',
|
||||
base: 'flex-1 px-0.5 py-0.5 text-sm font-medium focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-slate-500 rounded-md border border-slate-800',
|
||||
active: 'bg-sky-600 text-white border-white',
|
||||
inactive: 'bg-zinc-900 text-slate-300 hover:bg-sky-500 hover:text-white',
|
||||
},
|
||||
@@ -36,7 +36,7 @@ const TAB_HIGHLIGHTS = {
|
||||
none: '',
|
||||
pink: 'bg-pink-500 text-white border-pink-200 hover:bg-pink-400',
|
||||
green: 'bg-emerald-500 text-white border-emerald-200 hover:bg-emerald-400',
|
||||
pinkblue: 'bg-gradient-to-r from-lime-800 to-teal-800'
|
||||
pinkblue: 'bg-linear-to-r from-lime-800 to-teal-800'
|
||||
};
|
||||
|
||||
const DEFAULT_VARIANT = 'primary';
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function TelemetryPanel() {
|
||||
</>
|
||||
)}
|
||||
{rawSnippet && (
|
||||
<pre className="surface whitespace-pre-wrap break-words text-xs text-lime-300">{rawSnippet}</pre>
|
||||
<pre className="surface whitespace-pre-wrap wrap-break-word text-xs text-lime-300">{rawSnippet}</pre>
|
||||
)}
|
||||
</CardFrame>
|
||||
);
|
||||
@@ -163,7 +163,7 @@ function ValueRow({ label, value }) {
|
||||
}
|
||||
|
||||
function Pill({ active, tone = 'green', label }) {
|
||||
const base = 'rounded px-1.5 py-0.5 text-[0.7rem] font-semibold';
|
||||
const base = 'rounded-sm px-1.5 py-0.5 text-[0.7rem] font-semibold';
|
||||
const colors =
|
||||
tone === 'red'
|
||||
? active
|
||||
|
||||
@@ -152,7 +152,7 @@ export default function UserListPanel({
|
||||
<span className="text-[0.7rem] text-slate-500">no rover</span>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<span className="rounded bg-amber-500/30 px-1 text-[0.7rem] text-amber-200">
|
||||
<span className="rounded-sm bg-amber-500/30 px-1 text-[0.7rem] text-amber-200">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
@@ -188,7 +188,7 @@ export default function UserListPanel({
|
||||
<div className={`space-y-0.5 ${fillHeight ? 'flex flex-1 min-h-0 flex-col' : ''}`}>
|
||||
{!hideHeader && showToggle ? (
|
||||
<div className="flex justify-end">
|
||||
<div className="inline-flex overflow-hidden rounded border border-slate-700 text-[0.7rem]">
|
||||
<div className="inline-flex overflow-hidden rounded-sm border border-slate-700 text-[0.7rem]">
|
||||
<button
|
||||
type="button"
|
||||
className={`px-1 py-0.5 ${turnView === 'queues' ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
|
||||
@@ -230,7 +230,7 @@ export default function UserListPanel({
|
||||
<RoverLabel roverId={roverId} fallback={roverId} />
|
||||
</p>
|
||||
{remaining != null && (
|
||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">
|
||||
<span className="rounded-sm bg-slate-800 px-1 text-[0.7rem]">
|
||||
{remaining}s left
|
||||
</span>
|
||||
)}
|
||||
@@ -254,7 +254,7 @@ export default function UserListPanel({
|
||||
return (
|
||||
<span
|
||||
key={`${roverId}-${socketId}-${idx}`}
|
||||
className={`flex items-center gap-0.5 rounded px-1 ${compact ? 'text-[0.7rem]' : 'text-[0.8rem]'} ${highlightClass}`}
|
||||
className={`flex items-center gap-0.5 rounded-sm px-1 ${compact ? 'text-[0.7rem]' : 'text-[0.8rem]'} ${highlightClass}`}
|
||||
>
|
||||
<span className={`${roleColors(user.role)} font-semibold`}>
|
||||
{formatLabel(user, selfId)}
|
||||
|
||||
@@ -85,10 +85,10 @@ export default function VolumeSettingsCard() {
|
||||
return (
|
||||
<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">
|
||||
<label key={key} className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1 text-white">
|
||||
<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">
|
||||
<span className="rounded-sm bg-neutral-900 px-1 py-0.5 text-xs text-white">
|
||||
{formatPercent(draft[key])}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
export default function KeyPill({ label }) {
|
||||
if (!label) return null;
|
||||
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
|
||||
return <span className="rounded-sm border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function VipPrivateRoverAccessCard({
|
||||
<p className="truncate text-[0.7rem] text-slate-500">{roverId}</p>
|
||||
</div>
|
||||
{pending ? (
|
||||
<span className="rounded bg-amber-700/30 px-1 py-0.5 text-[0.7rem] text-amber-200">
|
||||
<span className="rounded-sm bg-amber-700/30 px-1 py-0.5 text-[0.7rem] text-amber-200">
|
||||
Pending
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -152,12 +152,12 @@ function PtzStatePanel({ ptz, onClose, onRelease, releaseDisabled = false }) {
|
||||
<StatusRow label="Transcoder" value={publisherStatus} tone={publisher.running ? 'text-emerald-300' : 'text-amber-300'} />
|
||||
{publisherProgress ? <StatusRow label="Progress" value={publisherProgress} /> : null}
|
||||
{publisher.lastStderr ? (
|
||||
<div className="surface max-h-24 overflow-y-auto whitespace-pre-wrap break-words font-mono text-[0.68rem] leading-tight text-slate-200">
|
||||
<div className="surface max-h-24 overflow-y-auto whitespace-pre-wrap wrap-break-word font-mono text-[0.68rem] leading-tight text-slate-200">
|
||||
{publisher.lastStderr}
|
||||
</div>
|
||||
) : null}
|
||||
{ptz?.blocked?.message ? (
|
||||
<div className="rounded border border-amber-500/50 bg-amber-950/40 p-1 text-xs text-amber-100">
|
||||
<div className="rounded-sm border border-amber-500/50 bg-amber-950/40 p-1 text-xs text-amber-100">
|
||||
{ptz.blocked.message}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -213,7 +213,7 @@ function PtzLightingControls({ ptz, disabled = false }) {
|
||||
onClick={cycleIr}
|
||||
>
|
||||
<span className="text-sm font-semibold">Infrared</span>
|
||||
<span className="rounded bg-cyan-300 px-1 py-0.5 text-[0.7rem] font-semibold text-cyan-950">{irMode}</span>
|
||||
<span className="rounded-sm bg-cyan-300 px-1 py-0.5 text-[0.7rem] font-semibold text-cyan-950">{irMode}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -383,7 +383,7 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
|
||||
<div className="shrink-0">
|
||||
<PtzControlReference />
|
||||
</div>
|
||||
<div className="min-h-[12rem] flex-1">
|
||||
<div className="min-h-48 flex-1">
|
||||
{/*
|
||||
The global chat key focuses the input registered by ChatPanel through
|
||||
ChatContext. Keeping a real ChatPanel mounted inside the PTZ fullscreen
|
||||
@@ -412,7 +412,7 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
|
||||
<div className="shrink-0">
|
||||
<PtzMobileControlsPanel ptz={ptz} disabled={!isOperator} />
|
||||
</div>
|
||||
<div className="min-h-[10rem] flex-1">
|
||||
<div className="min-h-40 flex-1">
|
||||
{/*
|
||||
Mobile uses the same ChatPanel registration as desktop so the mapped
|
||||
chat key and the on-screen input stay on one shared chat implementation.
|
||||
@@ -444,12 +444,12 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
|
||||
: 'grid-cols-[minmax(0,1fr)_20rem]';
|
||||
|
||||
const controller = (
|
||||
<div className="fixed inset-0 z-[110] h-[100dvh] w-[100vw] overflow-hidden bg-black text-slate-100">
|
||||
<div className="fixed inset-0 z-110 h-dvh w-screen overflow-hidden bg-black text-slate-100">
|
||||
<CardFrame
|
||||
hideHeader
|
||||
fillHeight
|
||||
clipOverflow={false}
|
||||
className="h-[100dvh] w-[100vw] rounded-none border-0 !bg-black"
|
||||
className="h-dvh w-screen rounded-none border-0 bg-black!"
|
||||
bodyClassName={`grid h-full min-h-0 overflow-hidden ${sidebarWidthClass}`}
|
||||
>
|
||||
<main className="relative min-h-0 min-w-0 bg-black">
|
||||
@@ -562,7 +562,7 @@ export default function VipPtzCameraCard({ onMessage, fullWidth = false, layout
|
||||
</div>
|
||||
<PtzQueueList queue={ptz?.queue} operatorLabel={ptz?.operatorLabel} />
|
||||
{ptz?.blocked?.message ? (
|
||||
<p className="w-full rounded border border-amber-500/50 bg-amber-950/40 p-1 text-xs text-amber-100">
|
||||
<p className="w-full rounded-sm border border-amber-500/50 bg-amber-950/40 p-1 text-xs text-amber-100">
|
||||
{ptz.blocked.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -36,7 +36,7 @@ const FILTERS = [
|
||||
|
||||
function StatusPill({ active, children }) {
|
||||
return (
|
||||
<span className={`rounded px-1 py-0.25 text-[0.68rem] font-semibold ${active ? 'bg-emerald-700 text-emerald-50' : 'bg-neutral-800 text-neutral-300'}`}>
|
||||
<span className={`rounded-sm px-1 py-0.25 text-[0.68rem] font-semibold ${active ? 'bg-emerald-700 text-emerald-50' : 'bg-neutral-800 text-neutral-300'}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
@@ -71,7 +71,7 @@ function UserListCard({ users, truncated, selectedUserId, query, filter, loading
|
||||
Showing the first 100 matches. Narrow the search to find older users.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="min-h-[18rem] flex-1 overflow-y-auto">
|
||||
<div className="min-h-72 flex-1 overflow-y-auto">
|
||||
{users.length ? users.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
@@ -197,7 +197,7 @@ function StatusCard({ user, onVerified, onDeterrence, onMuted }) {
|
||||
<span>Deterred</span>
|
||||
</label>
|
||||
<textarea
|
||||
className="field-input min-h-[4rem] w-full text-xs"
|
||||
className="field-input min-h-16 w-full text-xs"
|
||||
value={reason}
|
||||
placeholder="Deterrence reason"
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
@@ -282,7 +282,7 @@ function FeatureStateCard({ user, onSaveFeature, onDeleteFeature }) {
|
||||
<button type="button" className="button-dark text-xs" onClick={remove} disabled={!namespace.trim()}>Delete</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="field-input min-h-[18rem] w-full font-mono text-xs"
|
||||
className="field-input min-h-72 w-full font-mono text-xs"
|
||||
value={text}
|
||||
spellCheck={false}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
@@ -295,7 +295,7 @@ function FeatureStateCard({ user, onSaveFeature, onDeleteFeature }) {
|
||||
function RawRecordCard({ user }) {
|
||||
return (
|
||||
<CardFrame title="Raw record" bodyClassName="p-0.5 text-xs">
|
||||
<pre className="surface max-h-[36rem] overflow-auto whitespace-pre-wrap break-words font-mono text-[0.7rem] text-lime-300">
|
||||
<pre className="surface max-h-144 overflow-auto whitespace-pre-wrap wrap-break-word font-mono text-[0.7rem] text-lime-300">
|
||||
{stringifyJson(user)}
|
||||
</pre>
|
||||
</CardFrame>
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function ServerDisplayContent() {
|
||||
|
||||
return (
|
||||
<div className="display-page flex h-screen w-screen flex-col overflow-hidden bg-black text-slate-100">
|
||||
<div className="flex h-[8vh] min-h-[4rem] shrink-0 overflow-hidden">
|
||||
<div className="flex h-[8vh] min-h-16 shrink-0 overflow-hidden">
|
||||
<OnlinePeopleStrip users={session?.users || []} />
|
||||
<DisplayPtzOperatorBadge />
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function DisplayNoticeOverlay() {
|
||||
if (!notice) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 z-[130] flex items-center justify-center bg-black px-[4vw] py-[4vh]">
|
||||
<div className="pointer-events-none fixed inset-0 z-130 flex items-center justify-center bg-black px-[4vw] py-[4vh]">
|
||||
<div className="max-w-[92vw] border-4 border-cyan-200 bg-slate-950 px-[3vw] py-[2.5vh] text-center">
|
||||
<div className="whitespace-pre-wrap text-[clamp(3.2rem,11vh,11rem)] font-black leading-[0.95] text-white">
|
||||
{notice.text}
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function DisplayRoverCell({ rover, session }) {
|
||||
{driver?.label || 'Idle'}
|
||||
</AutoFitText>
|
||||
</div>
|
||||
<div className="min-w-0 bg-black/50 rounded">
|
||||
<div className="min-w-0 bg-black/50 rounded-sm">
|
||||
<AutoFitText
|
||||
className={classNames(
|
||||
'font-black leading-none',
|
||||
|
||||
+29
-4
@@ -1,6 +1,31 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss' source('./');
|
||||
@source '../index.html';
|
||||
|
||||
@layer base {
|
||||
/* Retain the existing form and border defaults across the v4 Preflight change. */
|
||||
*,
|
||||
::before,
|
||||
::after,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--color-gray-400);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
button,
|
||||
[role='button'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
@@ -55,7 +80,7 @@ body::-webkit-scrollbar,
|
||||
}
|
||||
|
||||
.field-input {
|
||||
@apply bg-neutral-700 border border-neutral-600 text-white placeholder:text-slate-400 px-0.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-sky-500 rounded-md;
|
||||
@apply bg-neutral-700 border border-neutral-600 text-white placeholder:text-slate-400 px-0.5 py-0.5 focus:outline-hidden focus:ring-1 focus:ring-sky-500 rounded-md;
|
||||
}
|
||||
|
||||
.button-dark {
|
||||
|
||||
@@ -31,7 +31,7 @@ function LeftColumnTabs() {
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel id="room">
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{/* 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" />
|
||||
@@ -49,10 +49,10 @@ function LeftColumnTabs() {
|
||||
|
||||
function CommunityColumn() {
|
||||
return (
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{/* 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">
|
||||
<div className="h-96 min-h-56">
|
||||
<ChatPanel fillHeight />
|
||||
</div>
|
||||
<OverseerPreferencePanel />
|
||||
@@ -93,7 +93,7 @@ export default function DesktopLayout() {
|
||||
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}`}
|
||||
className="flex min-h-0 min-w-0 flex-col gap-y-2 overflow-x-hidden overflow-y-auto"
|
||||
aria-label="Room, activities, and site information"
|
||||
>
|
||||
<LeftColumnTabs />
|
||||
@@ -103,7 +103,7 @@ export default function DesktopLayout() {
|
||||
<NewDriveVideo />
|
||||
|
||||
<aside
|
||||
className={`flex min-h-0 min-w-0 flex-col overflow-x-hidden overflow-y-auto ${themeGapClass}`}
|
||||
className="flex min-h-0 min-w-0 flex-col gap-y-2 overflow-x-hidden overflow-y-auto"
|
||||
aria-label="Chat, rovers, help, and settings"
|
||||
>
|
||||
<RightColumnTabs />
|
||||
|
||||
@@ -186,7 +186,7 @@ function QueueReplayLinksRow() {
|
||||
*/
|
||||
return (
|
||||
<div className={`flex items-stretch ${themeGapClass}`}>
|
||||
<div className="relative min-w-0 basis-0 grow-[1]">
|
||||
<div className="relative min-w-0 basis-0 grow">
|
||||
{/*
|
||||
The absolutely positioned queue card is removed from flex cross-size
|
||||
calculation. Replay and the links/PTZ stack therefore define the row
|
||||
|
||||
@@ -11,11 +11,13 @@ import ButtonBoxPanel from '../../../../../components/ButtonBoxPanel/index.jsx';
|
||||
import KinectPanel from '../../../../../components/KinectPanel/index.jsx';
|
||||
import FleetReportsCard from '../../../../../components/FleetReportsCard/index.jsx';
|
||||
import { themeGapClass } from '../../../../../themes/index.js';
|
||||
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
|
||||
|
||||
export default function ActivitiesTab() {
|
||||
const layout = useDriverLayout();
|
||||
return (
|
||||
<TabPanel id="activities">
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
<div className={`flex flex-col ${layout === 'desktop' ? 'gap-y-2' : themeGapClass}`}>
|
||||
<NeatoCard />
|
||||
<LiftCard />
|
||||
<HomeAssistantActivitiesPanel />
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function InfoColumn({
|
||||
<div className="flex w-full min-w-0 flex-1 flex-col items-center gap-4">
|
||||
{showPreview ? (
|
||||
<div className="mt-auto w-full">
|
||||
<div className="w-full aspect-[4/3]">
|
||||
<div className="w-full aspect-4/3">
|
||||
<RoverMediaPlayer
|
||||
sessionInfo={sessionInfo}
|
||||
videoMode={videoMode}
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function FleetTimeSeriesChart({ minutes = [], roverIds = [], metr
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-0.5 text-[0.68rem] text-slate-400">Drag horizontally to zoom. Double-click to restore the full range. Hover for exact values.</p>
|
||||
<div ref={elementRef} className="min-h-[24rem] w-full overflow-hidden text-slate-200" role="img" aria-label={`Fleet time series for ${metric}`} />
|
||||
<div ref={elementRef} className="min-h-96 w-full overflow-hidden text-slate-200" role="img" aria-label={`Fleet time series for ${metric}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -296,14 +296,14 @@ export default function ScannerContent() {
|
||||
/>
|
||||
<section className="flex min-h-screen w-full items-center justify-center">
|
||||
<div className="flex max-w-full flex-col items-center gap-[3vh]">
|
||||
<h1 className="max-w-full break-words text-[17vw] font-black leading-none tracking-normal">
|
||||
<h1 className="max-w-full wrap-break-word text-[17vw] font-black leading-none tracking-normal">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="max-w-full break-words text-[6vw] font-bold leading-tight tracking-normal">
|
||||
<p className="max-w-full wrap-break-word text-[6vw] font-bold leading-tight tracking-normal">
|
||||
{label}
|
||||
</p>
|
||||
{secondary ? (
|
||||
<p className="max-w-full break-words text-[4vw] font-bold leading-tight tracking-normal">
|
||||
<p className="max-w-full wrap-break-word text-[4vw] font-bold leading-tight tracking-normal">
|
||||
{secondary}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -320,7 +320,7 @@ export default function ScannerContent() {
|
||||
{section.items.slice(0, 8).map((item, itemIndex) => (
|
||||
<p
|
||||
key={`${item.text}-${itemIndex}`}
|
||||
className={`max-w-full break-words text-[3.1vw] leading-tight tracking-normal ${
|
||||
className={`max-w-full wrap-break-word text-[3.1vw] leading-tight tracking-normal ${
|
||||
item.status === 'active'
|
||||
? 'font-black'
|
||||
: item.status === 'complete'
|
||||
|
||||
@@ -57,7 +57,7 @@ function PtzSnapshotFallback({ label, source }) {
|
||||
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: true });
|
||||
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
||||
return (
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded bg-black">
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-sm bg-black">
|
||||
{snapshot?.objectUrl ? (
|
||||
<img src={snapshot.objectUrl} alt={label} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
@@ -98,7 +98,7 @@ function PtzLiveOrSnapshot({ label }) {
|
||||
<PtzLiveVideo
|
||||
enabled
|
||||
startMuted
|
||||
className="relative aspect-video w-full overflow-hidden rounded bg-black"
|
||||
className="relative aspect-video w-full overflow-hidden rounded-sm bg-black"
|
||||
fallback={({ source }) => <PtzSnapshotFallback label={label} source={source} />}
|
||||
/>
|
||||
);
|
||||
@@ -119,7 +119,7 @@ export default function PtzSpectatorCard() {
|
||||
const label = ptz?.name || 'PTZ Camera';
|
||||
|
||||
return (
|
||||
<article className="flex min-h-[16rem] flex-col rounded bg-zinc-900 p-0 sm:min-h-[18rem]">
|
||||
<article className="flex min-h-64 flex-col rounded-sm bg-zinc-900 p-0 sm:min-h-72">
|
||||
<PtzLiveOrSnapshot label={label} />
|
||||
<div className="min-h-0 flex-1 space-y-0.5 overflow-hidden p-1 text-xs">
|
||||
<InfoRow label="Operator" value={ptz?.operatorLabel || 'none'} />
|
||||
@@ -130,7 +130,7 @@ export default function PtzSpectatorCard() {
|
||||
<InfoRow label="Transcoder" value={publisherStatus} tone={publisher.running ? 'text-emerald-300' : 'text-amber-300'} />
|
||||
{publisherProgress ? <InfoRow label="Progress" value={publisherProgress} /> : null}
|
||||
{publisher.lastStderr ? (
|
||||
<div className="line-clamp-2 break-words font-mono text-[0.65rem] leading-tight text-slate-400">
|
||||
<div className="line-clamp-2 wrap-break-word font-mono text-[0.65rem] leading-tight text-slate-400">
|
||||
{publisher.lastStderr}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user