big boy webui new new new new new 100 files changed 80 years

This commit is contained in:
legop3
2026-08-18 22:01:25 -04:00
parent 0f0f82e5f6
commit 737760ff56
79 changed files with 2714 additions and 388 deletions
@@ -1,10 +1,14 @@
// Aux Column
// Purpose: Assembles the mobile auxiliary controls column, which is the left column by default.
// Scope: Owns mobile aux/camera/headlight/laser/horn wiring while reusing desktop variation components where intended.
import { useCallback, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
import './mobileControls.css';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import useCanControlRover from '../../hooks/useCanControlRover.js';
import HornControl from '../HornControl/index.jsx';
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
import { AUX_ZERO } from './constants.js';
@@ -27,7 +31,17 @@ function AuxColumnContent() {
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
const { setServoAngle, setHeadlight, setLaser, setAuxMotors, startHorn, stopHorn } = useControlActions();
const dockAssist = useManualDockAssist();
const disabled = !roverId;
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
const canControl = useCanControlRover(roverId);
// Turn ownership is the common mutation boundary for every control in this
// column. Dock and OI state are applied separately only where the hardware
// command itself depends on the Roomba being able to drive.
const controlsDisabled = !roverId || !canControl;
const docked = Boolean(dockTelemetry?.homeBase);
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
const vacuumDisabled = controlsDisabled
|| docked
|| (!drivingMode && !dockAssist.active);
const activeAuxButtonRef = useRef(null);
const cameraConfig = camera?.config;
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
@@ -43,7 +57,7 @@ function AuxColumnContent() {
: typeof cameraConfig?.homeAngle === 'number'
? cameraConfig.homeAngle
: (cameraMin + cameraMax) / 2;
const cameraDisabled = Boolean(disabled || dockAssist.cameraLocked);
const cameraDisabled = Boolean(controlsDisabled || dockAssist.cameraLocked);
/*
The mobile tilt track shares the same precision flag as desktop tilt. This
keeps the servo fine-step behavior tied to the selected movement mode rather
@@ -55,73 +69,80 @@ function AuxColumnContent() {
const handleHeadlightToggle = useCallback(
(nextOn) => {
if (!headlightAvailable) return;
if (!headlightAvailable || controlsDisabled) return;
setHeadlight(nextOn);
},
[headlightAvailable, setHeadlight],
[controlsDisabled, headlightAvailable, setHeadlight],
);
const handleLaserToggle = useCallback(
(nextOn) => {
if (!laserAvailable) return;
if (!laserAvailable || controlsDisabled) return;
setLaser(nextOn);
},
[laserAvailable, setLaser],
[controlsDisabled, laserAvailable, setLaser],
);
const handleHornStart = useCallback(() => {
if (controlsDisabled) return false;
return startHorn();
}, [startHorn]);
}, [controlsDisabled, startHorn]);
const handleAuxPress = useCallback(
(id, values) => {
if (disabled) return;
if (vacuumDisabled) return;
activeAuxButtonRef.current = id;
setAuxMotors(values);
},
[disabled, setAuxMotors],
[setAuxMotors, vacuumDisabled],
);
const handleAuxRelease = useCallback(
(id) => {
if (disabled) return;
if (activeAuxButtonRef.current === id) {
activeAuxButtonRef.current = null;
// Neutral commands must remain available after ownership or OI state is
// lost; otherwise disabling a held control could preserve its last output.
setAuxMotors(AUX_ZERO);
}
},
[disabled, setAuxMotors],
[setAuxMotors],
);
useEffect(() => {
if (!vacuumDisabled || activeAuxButtonRef.current === null) return;
// Pointer cancellation is browser-dependent when a held button becomes
// disabled. Explicitly neutralize the Roomba motors at the state boundary.
activeAuxButtonRef.current = null;
setAuxMotors(AUX_ZERO);
}, [setAuxMotors, vacuumDisabled]);
return (
<div className="mobile-touch-control grid h-full min-h-0 w-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)] gap-0.5 text-slate-100">
<VacuumControls
disabled={disabled}
disabled={vacuumDisabled}
onPress={handleAuxPress}
onRelease={handleAuxRelease}
/>
<div className="mobile-touch-control flex min-h-0 items-stretch gap-0.5">
{cameraEnabled ? (
// Match the desktop camera tilt card's emerald styling so the vertical
// mobile control reads as the same feature in a phone-sized layout.
<div className="mobile-touch-control flex-1 min-h-0 rounded-xl border-2 border-emerald-300/70 bg-emerald-900 px-1 py-1 text-emerald-50">
<VerticalCameraTilt
value={cameraValue}
min={cameraMin}
max={cameraMax}
step={cameraTiltStep}
disabled={cameraDisabled}
onChange={setServoAngle}
/>
</div>
<VerticalCameraTilt
value={cameraValue}
min={cameraMin}
max={cameraMax}
step={cameraTiltStep}
disabled={cameraDisabled}
onChange={setServoAngle}
/>
) : null}
{(headlightAvailable || laserAvailable) ? (
<div className="mobile-touch-control flex min-h-0 flex-1 flex-col gap-0.5">
{headlightAvailable ? (
<GPIOToggleControl
label="Headlight"
icon={FaLightbulb}
on={headlightState?.headlightOn}
disabled={disabled}
disabled={controlsDisabled}
onToggle={handleHeadlightToggle}
heightClass="h-full"
/>
@@ -129,8 +150,9 @@ function AuxColumnContent() {
{laserAvailable ? (
<GPIOToggleControl
label="Laser"
icon={FaCrosshairs}
on={laserState?.laserOn}
disabled={disabled || roomLightsLockedOn}
disabled={controlsDisabled || roomLightsLockedOn}
onToggle={handleLaserToggle}
heightClass="h-full"
/>
@@ -141,7 +163,8 @@ function AuxColumnContent() {
<div className="mobile-touch-control min-h-0">
{hornAvailable ? (
<HornControl
disabled={disabled || hornBlocked}
icon={FaBullhorn}
disabled={controlsDisabled || hornBlocked}
onStart={handleHornStart}
onStop={stopHorn}
active={horn?.active}
@@ -181,7 +181,10 @@ export default function ControlPadPanel({ compact = false, disabled = false }) {
}, [disabled, setCameraPrecisionMode, stopDrivePad]);
return (
<div className={`mobile-touch-control flex flex-1 min-h-0 flex-col overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900 text-slate-100 shadow-md ${compact ? 'h-full' : ''}`}>
<div
aria-disabled={disabled}
className={`mobile-touch-control flex flex-1 min-h-0 flex-col overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900 text-slate-100 shadow-md transition-opacity ${compact ? 'h-full' : ''} ${disabled ? 'opacity-50' : 'opacity-100'}`}
>
<div className={`mobile-touch-control grid grid-cols-3 gap-0.5 border-b border-slate-700 bg-slate-950 ${compact ? 'p-0.25' : 'p-0.5'}`}>
{DRIVE_PAD_SPEED_MODES.map((mode) => {
const active = speedMode === mode.id;
@@ -3,6 +3,7 @@
// Scope: Owns pointer tracking, fixed overlay positioning, and active 3x3 drive-zone feedback.
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { FaArrowsAlt } from 'react-icons/fa';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
const PAD_MARGIN = 12;
@@ -244,7 +245,10 @@ export default function FloatingJoystick({
) : null}
{!compact ? (
<div className="pointer-events-none flex flex-col items-center gap-0.5 px-2 pt-5 text-center">
<span className="text-sm font-semibold text-slate-100">drive pad</span>
<span className="flex items-center gap-1 text-sm font-semibold text-slate-100">
<FaArrowsAlt aria-hidden="true" />
<span>drive pad</span>
</span>
<span className="text-xs leading-tight text-slate-300">hold and drag</span>
</div>
) : (
@@ -3,7 +3,7 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
export default function MobileAuxButton({ id, label, values, color, disabled, onPress, onRelease }) {
export default function MobileAuxButton({ id, label, icon: Icon, values, color, disabled, onPress, onRelease }) {
return (
<button
type="button"
@@ -23,9 +23,12 @@ export default function MobileAuxButton({ id, label, values, color, disabled, on
onContextMenu={(event) => event.preventDefault()}
// The mobile-touch-control class is applied directly to this button because
// long-press callouts and text selection are triggered at the pressed node.
className={`mobile-touch-control flex h-full w-full items-center justify-center rounded-xl border-2 px-1 py-0.75 text-center text-sm font-semibold text-white transition select-none no-touch-select ${color} hover:brightness-110 active:brightness-125 active:scale-[0.99] disabled:opacity-30`}
className={`mobile-touch-control flex h-full w-full items-center justify-center gap-1 rounded-xl border-2 px-1 py-0.75 text-center text-sm font-semibold text-white transition select-none no-touch-select ${color} hover:brightness-110 active:brightness-125 active:scale-[0.99] disabled:opacity-30`}
>
{label}
{/* The icon is optional because this low-level held-action control is also
useful for labels that do not have a clear visual symbol. */}
{Icon ? <Icon className="shrink-0 text-base" aria-hidden="true" /> : null}
<span>{label}</span>
</button>
);
}
@@ -1,36 +1,85 @@
// Movement Column
// Purpose: Assembles the mobile movement column, which is the right column by default.
// Scope: Integrates drive/dock actions with the mobile control pad without hiding that dependency inside the pad.
// Scope: Owns movement availability while the in-video DockingHud owns every dock/undock action.
import { FaChargingStation } from 'react-icons/fa';
import { useControlSelector } from '../../controls/index.js';
import DriveDockAction from '../DriveDockAction/index.jsx';
import { useDriveDockState } from '../DriveDockAction/driveDockState.js';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import useCanControlRover from '../../hooks/useCanControlRover.js';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
import ControlPadPanel from './ControlPadPanel.jsx';
function MovementColumnContent({ layout }) {
const roverId = useControlSelector((control) => control.state.roverId);
const driveDockState = useDriveDockState(roverId);
const dockedNotDriving = driveDockState.docked && !driveDockState.driving;
const expandAction = dockedNotDriving || driveDockState.dockingInProgress;
const disabled = !roverId;
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
const dockAssist = useManualDockAssist();
const canControl = useCanControlRover(roverId);
const batteryState = useSessionSelector((state) => {
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
return rover?.batteryState || null;
});
const batteryUrgent = Boolean(batteryState?.urgentActive);
const batteryLow = Boolean(batteryState?.warnActive || batteryUrgent);
const docked = Boolean(dockTelemetry?.homeBase);
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
/*
DriveDockAction owns whether the rover is ready to accept movement, while
ControlPadPanel owns only movement intent. Keeping the integration here makes
the column layout explicit and prevents the pad component from knowing about
docking state.
DockingHud is now the only place where a user starts driving or enters docking
assist. This column only decides whether touch movement is safe. Requiring both
an undocked rover and full OI mode keeps the pad inert throughout the undock
macro, including the interval where dock contact and OI mode change separately.
Manual assist is the deliberate exception to the normal OI-mode requirement:
its entire purpose is to let the user drive onto the dock under the assist speed
cap. Actual dock contact still disables movement immediately.
*/
const fillClass = dockedNotDriving ? 'max-h-screen self-start' : '';
const containerClass = `mobile-touch-control flex h-full flex-col gap-0.5 text-slate-100 ${fillClass}`;
const movementDisabled = !roverId
|| !canControl
|| docked
|| (!drivingMode && !dockAssist.active);
// Both controls share the same hardware-availability boundary. Dock assist
// changes their actions, not who is allowed to command the rover.
const dockActionDisabled = movementDisabled;
const handleDockAction = () => {
if (dockActionDisabled) return;
triggerTouchHaptic('button');
// Mobile deliberately enters assist on the first tap. The centered video HUD
// provides the resulting instruction, so a confirmation modal would only add
// friction between intent and the camera-guided docking task.
if (dockAssist.active) {
dockAssist.exitAssist();
} else {
dockAssist.enterAssist();
}
};
return (
<div className={containerClass} data-mobile-layout={layout}>
<DriveDockAction
layout="mobile"
expand={expandAction}
driveDockState={driveDockState}
compactHeightClass="min-h-[5rem]"
/>
{!expandAction ? <ControlPadPanel disabled={disabled} /> : null}
<div className="mobile-touch-control flex h-full flex-col gap-0.5 text-slate-100" data-mobile-layout={layout}>
<button
type="button"
disabled={dockActionDisabled}
onClick={handleDockAction}
className={`mobile-touch-control flex min-h-[4.5rem] shrink-0 items-center justify-center gap-1.5 rounded-xl border-2 px-2 text-base font-semibold shadow-md transition disabled:cursor-not-allowed disabled:opacity-50 ${
dockAssist.active
? 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
: batteryUrgent
? 'border-red-300/80 bg-red-950 text-red-50'
: batteryLow
? 'border-amber-300/80 bg-amber-950 text-amber-50'
: 'border-indigo-300/70 bg-indigo-900 text-indigo-50'
}`}
>
<FaChargingStation className="text-lg" aria-hidden="true" />
{/* The mobile column remains the only mobile entry point into dock assist. Battery
severity makes that existing action more obvious without adding another control. */}
<span>{dockAssist.active ? 'Exit dock assist' : batteryUrgent ? 'Dock now' : batteryLow ? 'Dock and charge soon' : 'Dock and charge'}</span>
</button>
{/* Keeping the pad mounted prevents the mobile columns from changing size while
the centered video HUD explains and performs dock-related transitions. */}
<ControlPadPanel disabled={movementDisabled} />
</div>
);
}
@@ -1,6 +1,7 @@
// Vacuum Controls
// Purpose: Renders the mobile-only vacuum forward/backward auxiliary motor controls.
// Scope: Owns only the two vacuum buttons; the parent column owns rover state and aux motor commands.
import { FaRedo, FaUndo } from 'react-icons/fa';
import MobileAuxButton from './MobileAuxButton.jsx';
import { AUX_ALL_BACKWARD, AUX_ALL_FORWARD } from './constants.js';
@@ -14,6 +15,7 @@ export default function VacuumControls({
<MobileAuxButton
id="aux-vac-forward"
label="Vacuum Forward"
icon={FaRedo}
values={AUX_ALL_FORWARD}
color="bg-fuchsia-600"
disabled={disabled}
@@ -23,6 +25,7 @@ export default function VacuumControls({
<MobileAuxButton
id="aux-vac-backward"
label="Vacuum Backward"
icon={FaUndo}
values={AUX_ALL_BACKWARD}
color="bg-fuchsia-800"
disabled={disabled}
@@ -2,6 +2,7 @@
// Purpose: Provides the mobile-only camera tilt slider that supports simultaneous two-finger mobile driving.
// Scope: Owns pointer tracking and visual slider state for the compact vertical mobile camera control.
import { useCallback, useMemo, useRef } from 'react';
import { FaVideo } from 'react-icons/fa';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
const HAPTIC_MIN_ANGLE_DELTA_DEGREES = 2;
@@ -32,7 +33,7 @@ export default function VerticalCameraTilt({
if (max === min) return 50;
return ((clampCameraAngle(value, min, max) - min) / (max - min)) * 100;
}, [max, min, value]);
const disabledClass = disabled ? 'opacity-50' : '';
const disabledClass = disabled ? 'cursor-not-allowed opacity-50' : '';
const valueFromPointer = useCallback(
(event) => {
@@ -106,9 +107,16 @@ export default function VerticalCameraTilt({
}, []);
return (
<div className="mobile-touch-control flex h-full items-center justify-center gap-0.5">
<span className="mobile-touch-control text-sm font-semibold text-emerald-50 [writing-mode:vertical-rl] rotate-180">
Camera tilt
<div
className={`mobile-touch-control flex h-full min-h-0 flex-1 items-center justify-center gap-0.5 rounded-xl border-2 border-emerald-300/70 bg-emerald-900 px-1 py-1 text-emerald-50 transition-opacity ${disabledClass}`}
>
{/* This component owns its entire visible card so interaction state, border,
background, label, and slider always dim as one coherent control. */}
<span className="mobile-touch-control flex items-center gap-1 text-sm font-semibold text-emerald-50 [writing-mode:vertical-rl] rotate-180">
{/* Rotate the icon with the established vertical label so both read as one
control identity without consuming additional horizontal space. */}
<FaVideo className="shrink-0" aria-hidden="true" />
<span>Camera tilt</span>
</span>
<div
ref={trackRef}
@@ -134,7 +142,7 @@ export default function VerticalCameraTilt({
camera gesture.
*/
style={{ touchAction: 'none' }}
className={`mobile-touch-control mobile-drag-control relative h-full w-6 rounded-full border border-emerald-100/80 bg-emerald-950 shadow-inner focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-200 ${disabledClass}`.trim()}
className="mobile-touch-control mobile-drag-control relative h-full w-6 rounded-full border border-emerald-100/80 bg-emerald-950 shadow-inner focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-200"
>
<div
className="pointer-events-none absolute inset-x-1 bottom-1 rounded-full bg-emerald-400"