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:
legop3
2026-09-20 15:41:45 -04:00
parent 69c5bfe544
commit ff8b0c530e
151 changed files with 5331 additions and 1787 deletions
@@ -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>
+4 -1
View File
@@ -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>
);
+4 -4
View File
@@ -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>
+2 -2
View File
@@ -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}
+2 -2
View File
@@ -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} />);
}
+19 -8
View File
@@ -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>
);
}
+5 -5
View File
@@ -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>
+2 -2
View File
@@ -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>;
}
+6 -6
View File
@@ -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>
+4 -4
View File
@@ -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>
);
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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>
}
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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>
+10 -10
View File
@@ -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>
);
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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>
+8 -8
View File
@@ -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" />
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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}
+2 -2
View File
@@ -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
+4 -4
View File
@@ -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}
+5 -5
View File
@@ -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
View File
@@ -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}
+1 -1
View File
@@ -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}
@@ -5,8 +5,8 @@ import SpectateVideo from '../../../components/SpectateVideo/index.jsx';
export default function RoverSpectatorCard({ rover }) {
return (
<article className="min-h-[16rem] rounded bg-zinc-900 p-0 sm:min-h-[18rem]">
<div className="min-h-0 overflow-hidden rounded bg-black/20">
<article className="min-h-64 rounded-sm bg-zinc-900 p-0 sm:min-h-72">
<div className="min-h-0 overflow-hidden rounded-sm bg-black/20">
<SpectateVideo
roverId={rover.id}
label={rover.name}
+2 -2
View File
@@ -3,8 +3,8 @@
* Scope: Canvas positioning and the explicit no-artwork fallback; individual artwork lives elsewhere.
*/
/* These selectors intentionally remain ordinary CSS. This stylesheet is processed separately from
* index.css, so placing them in a Tailwind layer would require duplicating Tailwind's directives. */
/* Keep theme artwork outside Tailwind's layers so it takes precedence over
* utility backgrounds on the page canvas and settings previews. */
.page-theme {
/* A fixed canvas makes separated card gaps look like windows onto one continuous design.
Individual theme classes below supply only their artwork and fallback color. */