mobile controls fixes hopefully

This commit is contained in:
legop3
2026-06-10 14:30:41 -04:00
parent bcbd5d03c7
commit 426fd3e7c4
13 changed files with 128 additions and 51 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -5,14 +5,15 @@
<link rel="icon" type="image/png" href="/bitmap.png" />
<link rel="apple-touch-icon" href="/bitmap.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Mobile driving uses dense press controls, so the viewport opts out of browser zoom gestures that can steal touches from the controls. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#020617" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-ChzD0G0n.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B564BVSz.css">
<script type="module" crossorigin src="/assets/index-CaMnasMh.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bk7Q5HRD.css">
</head>
<body>
<div id="root"></div>
+2 -1
View File
@@ -5,7 +5,8 @@
<link rel="icon" type="image/png" href="/bitmap.png" />
<link rel="apple-touch-icon" href="/bitmap.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Mobile driving uses dense press controls, so the viewport opts out of browser zoom gestures that can steal touches from the controls. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#020617" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
@@ -194,8 +194,11 @@ export default function DriveDockAction({
setConfirmOpen(true);
};
// The drive/dock card appears inside the mobile controls and can be held or
// tapped repeatedly; attach mobile touch suppression directly to the card so
// Safari does not select labels or open callouts during those interactions.
const baseCardClasses =
'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-none 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';
+6 -2
View File
@@ -48,8 +48,10 @@ export default function HornControl({
const isActive = Boolean(active);
const clampedHeat = Math.max(0, Math.min(1, Number(heat) || 0));
const buttonClasses = useMemo(() => {
// The horn can be held for continuous input, which makes it especially prone
// to long-press selection/callout behavior on mobile browsers.
const base =
'group relative flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-1 py-1.5 text-xs font-semibold select-none no-touch-select';
'mobile-touch-control group relative flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-1 py-1.5 text-xs font-semibold select-none no-touch-select';
const active = 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50';
const inactive = 'border-cyan-300/70 bg-cyan-900 text-cyan-50 hover:bg-cyan-800';
return [base, isActive ? active : inactive, 'disabled:opacity-50', className]
@@ -147,7 +149,9 @@ export default function HornControl({
event.preventDefault();
setShowSettings((prev) => !prev);
}}
className="rounded bg-black/40 px-1 py-0.5 text-[0.6rem] font-semibold text-white/90 hover:text-white"
// 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"
>
{showSettings ? 'Hide' : 'Settings'}
</button>
@@ -56,12 +56,18 @@ function HudChatInput({ compact = false }) {
const containerClass = compact
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.4 py-0.2'
: 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25';
// Safari zooms focused inputs below 16px. The dedicated mobile-text-entry
// utility preserves normal text editing while keeping focus from changing the
// page zoom when the compact chat field is used over the camera feed.
const inputClass = compact
? 'min-w-0 flex-1 bg-transparent text-[0.55rem] text-slate-100 placeholder:text-slate-400 focus:outline-none'
: 'min-w-0 flex-1 bg-transparent text-[0.7rem] text-slate-100 placeholder:text-slate-400 focus:outline-none';
? 'mobile-text-entry min-w-0 flex-1 bg-transparent text-slate-100 placeholder:text-slate-400 focus:outline-none'
: 'mobile-text-entry min-w-0 flex-1 bg-transparent text-slate-100 placeholder:text-slate-400 focus:outline-none';
// The submit button is still a touch target even though the adjacent input must
// remain editable, so it gets press suppression without inheriting input text
// selection behavior.
const buttonClass = compact
? 'rounded bg-cyan-500/80 px-0.35 py-0.2 text-[0.55rem] font-semibold text-black disabled:opacity-50'
: '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 bg-cyan-500/80 px-0.35 py-0.2 text-[0.55rem] font-semibold text-black disabled:opacity-50'
: 'mobile-touch-control rounded bg-cyan-500/80 px-0.5 py-0.25 text-[0.7rem] font-semibold text-black disabled:opacity-50';
async function handleSend(event) {
event.preventDefault();
@@ -109,7 +115,7 @@ function HudChatInput({ compact = false }) {
}
}}
ref={(el) => registerInputRef(el, { target: 'hud' })}
placeholder={canChat ? 'Chat (TTS)' : 'Spectator'}
placeholder={canChat ? 'Chat (tts)' : 'Spectator'}
disabled={!canChat}
/>
<button type="submit" disabled={!canChat || sending} className={buttonClass}>
@@ -119,7 +119,7 @@ function FloatingPadOverlay({ center, size, activeCellId }) {
);
}
export default function FloatingJoystick({ disabled, onCellChange, onStop }) {
export default function FloatingJoystick({ activeInputLabel = 'stop', disabled, onCellChange, onStop }) {
const containerRef = useRef(null);
const pointerIdRef = useRef(null);
const activePadRef = useRef(null);
@@ -209,7 +209,9 @@ export default function FloatingJoystick({ disabled, onCellChange, onStop }) {
<div
ref={containerRef}
role="presentation"
className="relative flex h-full min-h-[10rem] w-full select-none items-center justify-center overflow-hidden text-slate-100"
className="mobile-touch-control mobile-drag-control relative flex h-full min-h-[10rem] w-full select-none items-center justify-center overflow-hidden text-slate-100"
// 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' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
@@ -220,12 +222,17 @@ export default function FloatingJoystick({ disabled, onCellChange, onStop }) {
}}
onContextMenu={(event) => event.preventDefault()}
>
<div className="pointer-events-none flex flex-col items-center gap-0.5 text-center">
<span className="text-sm font-semibold text-slate-100">drive pad</span>
<span className="px-2 text-xs leading-tight text-slate-300">
hold and drag for keyboard-style driving
<div className="pointer-events-none absolute inset-x-0 top-0 border-b border-slate-700 bg-slate-950 px-1.5 py-0.5 text-center">
{/* The readout lives inside the pointer target instead of above it, so the
visual indicator does not consume any non-drivable space on small phones. */}
<span className="font-mono text-xs font-semibold text-cyan-200">
{activeInputLabel}
</span>
</div>
<div className="pointer-events-none flex flex-col items-center gap-0.5 px-2 pt-5 text-center">
<span className="text-sm font-semibold text-slate-100">drive pad</span>
<span className="text-xs leading-tight text-slate-300">hold and drag</span>
</div>
</div>
{activePad ? (
<FloatingPadOverlay
@@ -14,7 +14,9 @@ export default function MobileAuxButton({ id, label, values, color, disabled, on
onPointerLeave={() => onRelease(id)}
onPointerCancel={() => onRelease(id)}
onContextMenu={(event) => event.preventDefault()}
className={`flex h-full w-full items-center justify-center rounded-xl border-2 px-1 py-0.75 text-center text-sm font-semibold text-white transition select-none no-touch-select ${color} hover:brightness-110 active:brightness-125 active:scale-[0.99] disabled:opacity-30`}
// The mobile-touch-control class is applied directly to this button because
// long-press callouts and text selection are triggered at the pressed node.
className={`mobile-touch-control flex h-full w-full items-center justify-center rounded-xl border-2 px-1 py-0.75 text-center text-sm font-semibold text-white transition select-none no-touch-select ${color} hover:brightness-110 active:brightness-125 active:scale-[0.99] disabled:opacity-30`}
>
{label}
</button>
@@ -48,6 +48,7 @@ function MobileJoystickPanel({ layout }) {
const expandAction = dockedNotDriving || driveDockState.dockingInProgress;
const disabled = !roverId;
const [speedMode, setSpeedMode] = useState('normal');
const [activeInputLabel, setActiveInputLabel] = useState('stop');
const speedModeRef = useRef('normal');
const activeCellRef = useRef(null);
const repeatTimerRef = useRef(null);
@@ -112,6 +113,7 @@ function MobileJoystickPanel({ layout }) {
(lastEvent = 'stop') => {
clearRepeatTimer();
activeCellRef.current = null;
setActiveInputLabel('stop');
sendDriveCell({ id: 'stop', actions: [] }, lastEvent, 'normal');
},
[clearRepeatTimer, sendDriveCell],
@@ -129,6 +131,7 @@ function MobileJoystickPanel({ layout }) {
(cell) => {
if (disabled) return;
activeCellRef.current = cell;
setActiveInputLabel(cell?.label || 'stop');
sendDriveCell(cell, 'move');
startRepeatTimer();
},
@@ -155,8 +158,11 @@ function MobileJoystickPanel({ layout }) {
stopDrivePad('disabled');
}, [disabled, stopDrivePad]);
// The mobile control column itself also blocks selection because Safari can
// otherwise start selecting text from a child label before the child's pointer
// handler gets enough movement to claim the gesture.
const fillClass = dockedNotDriving ? 'max-h-screen self-start' : '';
const containerClass = `flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`;
const containerClass = `mobile-touch-control flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`;
return (
<div className={containerClass} data-mobile-layout={layout}>
@@ -167,8 +173,10 @@ function MobileJoystickPanel({ layout }) {
compactHeightClass="min-h-[5rem]"
/>
{!expandAction ? (
<div className="flex flex-1 min-h-0 flex-col overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900/70 text-slate-100 shadow-md">
<div className="grid grid-cols-3 gap-0.5 border-b border-slate-700 bg-slate-950/80 p-0.5">
// Keep the drive launcher card fully opaque so camera video or page
// backgrounds never show through the target the driver is trying to hold.
<div className="mobile-touch-control flex flex-1 min-h-0 flex-col overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900 text-slate-100 shadow-md">
<div className="mobile-touch-control grid grid-cols-3 gap-0.5 border-b border-slate-700 bg-slate-950 p-0.5">
{DRIVE_PAD_SPEED_MODES.map((mode) => {
const active = speedMode === mode.id;
const speedValue =
@@ -181,7 +189,7 @@ function MobileJoystickPanel({ layout }) {
<button
key={mode.id}
type="button"
className={`min-h-9 rounded-md px-1 text-xs font-semibold ${
className={`mobile-touch-control min-h-9 rounded-md px-1 text-xs font-semibold ${
active
? 'bg-cyan-300 text-slate-950'
: 'bg-slate-800 text-slate-200'
@@ -195,8 +203,9 @@ function MobileJoystickPanel({ layout }) {
);
})}
</div>
<div className="min-h-0 flex-1">
<div className="mobile-touch-control min-h-0 flex-1">
<FloatingJoystick
activeInputLabel={activeInputLabel}
disabled={disabled}
onCellChange={handleCellChange}
onStop={() => stopDrivePad('stop')}
@@ -262,8 +271,8 @@ function MobileActionsColumnContent() {
);
return (
<div className="grid h-full min-h-0 w-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)] gap-0.5 text-slate-100">
<div className="grid min-h-0 grid-rows-2 gap-0.5">
<div className="mobile-touch-control grid h-full min-h-0 w-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)] gap-0.5 text-slate-100">
<div className="mobile-touch-control grid min-h-0 grid-rows-2 gap-0.5">
<MobileAuxButton
id="aux-vac-forward"
label="Vacuum Forward"
@@ -283,9 +292,11 @@ function MobileActionsColumnContent() {
onRelease={handleAuxRelease}
/>
</div>
<div className="flex min-h-0 items-stretch gap-0.5">
<div className="mobile-touch-control flex min-h-0 items-stretch gap-0.5">
{cameraEnabled ? (
<div className="flex-1 min-h-0 rounded bg-zinc-950 p-0.25">
// Match the desktop camera tilt card's emerald styling so the vertical
// mobile control reads as the same feature in a phone-sized layout.
<div className="mobile-touch-control flex-1 min-h-0 rounded-xl border-2 border-emerald-300/70 bg-emerald-900 px-1 py-1 text-emerald-50">
<CameraTiltControl
value={cameraValue}
min={cameraMin}
@@ -294,13 +305,13 @@ function MobileActionsColumnContent() {
disabled={cameraDisabled}
onChange={setServoAngle}
orientation="vertical"
label="Camera Tilt"
labelClass="text-sm font-semibold text-white [writing-mode:vertical-rl] rotate-180"
labelRowClass="text-[0.7rem] text-slate-300"
valueClass="font-mono text-slate-200"
label="Camera tilt"
labelClass="mobile-touch-control text-sm font-semibold text-emerald-50 [writing-mode:vertical-rl] rotate-180"
labelRowClass="mobile-touch-control text-[0.7rem] text-emerald-100"
valueClass="font-mono text-slate-100"
className="h-full gap-0"
sliderClass="h-full w-7"
accentClass="accent-cyan-400"
sliderClass="mobile-touch-control mobile-drag-control h-full w-7"
accentClass="accent-emerald-400"
showEndpoints={false}
showValue={false}
/>
@@ -315,7 +326,7 @@ function MobileActionsColumnContent() {
/>
) : null}
</div>
<div className="min-h-0">
<div className="mobile-touch-control min-h-0">
{hornAvailable ? (
<HornControl
disabled={disabled || hornBlocked}
@@ -336,7 +347,7 @@ function MobileActionsColumnContent() {
export function MobileActionsColumn({ layout, className = '' }) {
return (
<div className={`flex flex-col gap-0.5 ${className}`.trim()} data-mobile-layout={layout}>
<div className={`mobile-touch-control flex flex-col gap-0.5 ${className}`.trim()} data-mobile-layout={layout}>
<MobileActionsColumnContent />
</div>
);
@@ -344,7 +355,7 @@ export function MobileActionsColumn({ layout, className = '' }) {
export function MobileDriveColumn({ layout, className = '' }) {
return (
<div className={`flex flex-col gap-0.5 ${className}`.trim()} data-mobile-layout={layout}>
<div className={`mobile-touch-control flex flex-col gap-0.5 ${className}`.trim()} data-mobile-layout={layout}>
<MobileJoystickPanel layout={layout === 'landscape' ? 'landscape' : 'portrait'} />
</div>
);
@@ -359,8 +370,10 @@ export default function MobilePortraitControls({ swapColumns = false }) {
? <MobileActionsColumn layout="portrait" className={columnHeight} />
: <MobileDriveColumn layout="portrait" className={columnHeight} />;
return (
<section className="panel">
<div className="grid grid-cols-2 gap-0.5 items-stretch">
// Portrait controls need layout grouping but no painted panel behind them;
// each child control owns its own visible surface.
<section className="mobile-touch-control text-white">
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 items-stretch">
{firstColumn}
{secondColumn}
</div>
@@ -40,8 +40,11 @@ export default function NightVisionControl({
};
const buttonClasses = useMemo(() => {
// Night vision is used as a direct mobile press target, so selection and
// Safari callout suppression live on the button itself rather than only on
// the surrounding mobile column.
const base =
'group flex w-full flex-col items-center justify-center gap-0.35 rounded-xl border-2 px-1 py-0.75 text-center select-none no-touch-select';
'mobile-touch-control group flex w-full flex-col items-center justify-center gap-0.35 rounded-xl border-2 px-1 py-0.75 text-center select-none no-touch-select';
const active = 'border-emerald-300/70 bg-emerald-800 text-emerald-50 hover:bg-emerald-700';
const inactive = 'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800';
return [base, displayOn ? active : inactive, 'disabled:opacity-50', heightClass, className]
+37
View File
@@ -225,4 +225,41 @@ body {
-ms-user-select: none;
-webkit-touch-callout: none;
}
.mobile-touch-control {
/*
Mobile rover controls are press surfaces, not document text. These flags
deliberately stack the browser-specific knobs because iOS Safari can still
show callouts, selection handles, tap highlights, or delayed gesture behavior
when only the standard property is present.
*/
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
overscroll-behavior: contain;
}
.mobile-drag-control {
/*
Drag controls need the stricter touch-action value so the browser does not
reinterpret a held thumb as page pan, pinch zoom, double-tap zoom, or text
selection while the control is actively tracking pointer movement.
*/
touch-action: none;
}
.mobile-text-entry {
/*
iOS Safari zooms focused form fields whose computed text size is below
16px. Keep HUD chat inputs at that threshold instead of fighting focus with
JavaScript, because the input still needs normal editing and caret behavior.
*/
font-size: 16px;
-webkit-text-size-adjust: 100%;
touch-action: manipulation;
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
export const INPUT_SETTINGS_DEFAULTS = {
keyboard: {
baseSpeed: 250,
turboSpeed: 400,
turboSpeed: 500,
precisionSpeed: 125,
tiltSpeed: 90,
tiltIntervalMs: 110,