adjusting stylings

This commit is contained in:
legop3
2026-09-08 23:52:25 -04:00
parent d3fd3946e6
commit 6ca7cc0cf0
14 changed files with 198 additions and 109 deletions
+3 -3
View File
@@ -1131,13 +1131,13 @@ Generic peripheral controls are rover controls, so they follow the new driver's
The standardized replacements do not create any new UI. `cameraServo`, `headlight`, and `laser` continue to use their current camera-tilt, headlight, and laser HUD controls. Only entries in the generic `controls` arrays appear in a new surface named `Accessories`.
On desktop, `Accessories` is a vertical button centered on the left wall of the video. It opens a height-limited, vertically scrollable panel toward the right. The panel uses the same large control cards as mobile and is independent of the bottom-left horn, headlight, and laser pod.
On desktop, `Accessories` is a vertical button centered on the left wall of the video. It uses the existing translucent black HUD treatment and opens a height-limited, vertically scrollable panel toward the right. The panel uses the same compact control renderer as mobile and is independent of the bottom-left horn, headlight, and laser pod.
On mobile, a vertical `Accessories` button sits directly to the right of the vacuum-forward and vacuum-backward buttons. Activating it replaces the complete `AuxColumn` contents with the ordered, vertically scrollable accessory list. A much smaller vertical `Aux` tab returns to the normal vacuum, camera, light, laser, and horn controls without reserving a large empty rail beside the accessory list.
On mobile, a vertical `Accessories` button sits directly to the right of the vacuum-forward and vacuum-backward buttons. Activating it replaces the complete `AuxColumn` contents with the ordered, vertically scrollable accessory list. A small `Aux` button shares the first compact device heading and returns to the normal vacuum, camera, light, laser, and horn controls without creating a separate rail or overlay border.
Desktop and mobile reuse one placement-independent `RoverAccessoryControls` renderer inside their different containers. Device-specific React components are not created for individual peripherals. The renderer sends actions through `ControlSystemProvider`, `ControlContext`, and the existing command pipeline so assignment gating, input cancellation, and command behavior remain consistent with other rover HUD controls. Both parents and the renderer disappear completely when the assigned rover has no generic controls; no launcher, empty shell, or reserved space remains.
Control values are local UI values in the first implementation. Slider and toggle changes update the displayed value immediately and are then sent to the server. Restarting `roverd` recreates controls from the new hello rather than persisting peripheral values in `roverSettings`.
Control values are local UI values in the first implementation. Slider and toggle changes update the displayed value immediately and are then sent to the server. Generic sliders use the same custom pointer-capture approach as mobile camera tilt rather than a browser-native range control, which keeps touch behavior and appearance consistent while driving. Restarting `roverd` recreates controls from the new hello rather than persisting peripheral values in `roverSettings`.
## Permissions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -12,8 +12,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-C6QLPM0P.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BF6Z3hOA.css">
<script type="module" crossorigin src="/assets/index-DVBMljJU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CDWVlrvF.css">
</head>
<body>
<div id="root"></div>
@@ -20,10 +20,11 @@ export default function AccessoriesExpansion({ roverId }) {
label="Accessories"
ariaLabel={open ? 'Hide accessory controls' : 'Show accessory controls'}
onClick={() => setOpen(!open)}
className={`pointer-events-auto !h-28 rounded-l-none ${open ? 'rounded-r-none' : ''}`}
hud
className="pointer-events-auto !h-28"
/>
{open ? (
<div className="pointer-events-auto h-[70%] min-h-48 max-h-[32rem] w-72 overflow-hidden rounded-r-xl border-2 border-l-0 border-cyan-300/70 bg-slate-950/95 shadow-2xl">
<div className="pointer-events-auto h-[70%] min-h-48 max-h-[32rem] w-64 overflow-hidden rounded-r-xl bg-black/60 p-0.5">
{/* This is exactly the renderer mounted by AuxColumn. The desktop
wrapper changes available dimensions, never control behavior. */}
<RoverAccessoryControls roverId={roverId} className="h-full" />
@@ -199,17 +199,19 @@ function RoverAuxColumn({ roverId, layout, className }) {
return (
<div className={`mobile-touch-control flex flex-col gap-0.5 ${className}`.trim()} data-mobile-layout={layout}>
{showAccessories && hasAccessories ? (
<div className="mobile-touch-control relative h-full min-h-0 overflow-hidden rounded-xl border-2 border-cyan-300/70 bg-slate-950/95">
{/* The compact return tab overlays only the first heading corner. It
does not reserve an otherwise empty rail down the full column. */}
<AccessoriesToggle
label="Aux"
ariaLabel="Return to auxiliary controls"
compact
onClick={() => setShowAccessories(false)}
className="absolute right-1 top-1 z-10"
<div className="mobile-touch-control h-full min-h-0 overflow-hidden">
<RoverAccessoryControls
roverId={roverId}
className="h-full"
headerAction={(
<AccessoriesToggle
label="Aux"
ariaLabel="Return to auxiliary controls"
compact
onClick={() => setShowAccessories(false)}
/>
)}
/>
<RoverAccessoryControls roverId={roverId} className="h-full pr-1" />
</div>
) : (
<AuxColumnContent
@@ -9,8 +9,14 @@ export default function AccessoriesToggle({
ariaLabel,
onClick,
compact = false,
hud = false,
className = '',
}) {
const sizeClass = compact ? 'h-6 w-10' : 'h-full w-8';
const toneClass = hud
? 'rounded-none border-0 bg-black/60 text-white/75 shadow-none hover:bg-black hover:text-white'
: 'rounded-xl border-2 border-cyan-300/70 bg-cyan-900 text-cyan-50 shadow-md hover:brightness-110 active:brightness-125';
return (
<button
type="button"
@@ -19,11 +25,11 @@ export default function AccessoriesToggle({
triggerTouchHaptic('button');
onClick();
}}
className={`mobile-touch-control flex shrink-0 items-center justify-center rounded-xl border-2 border-cyan-300/70 bg-cyan-900 text-sm font-semibold text-cyan-50 shadow-md transition hover:brightness-110 active:scale-[0.98] active:brightness-125 ${compact ? 'h-14 w-7' : 'h-full w-8'} ${className}`.trim()}
className={`mobile-touch-control flex shrink-0 items-center justify-center text-sm font-semibold transition active:scale-[0.98] ${sizeClass} ${toneClass} ${className}`.trim()}
>
{/* Vertical writing keeps the launcher readable in the narrow wall space
without rotating the glyph itself away from its natural orientation. */}
<span className="flex items-center gap-1 [writing-mode:vertical-rl] rotate-180">
{/* Full launchers use vertical writing in the narrow wall space. The
compact Aux return stays horizontal so it consumes only one heading. */}
<span className={compact ? 'flex items-center' : 'flex items-center gap-1 [writing-mode:vertical-rl] rotate-180'}>
{!compact ? <FaPuzzlePiece className="shrink-0 text-sm" aria-hidden="true" /> : null}
<span>{label}</span>
</span>
@@ -1,7 +1,7 @@
// Accessory Control Field
// Purpose: Maps one firmware-advertised generic control to a large rover-control surface.
// Scope: Owns browser-local values and input semantics; transport and device-specific behavior stay outside this file.
import { useCallback, useEffect, useRef, useState } from 'react';
// Purpose: Maps one firmware-advertised generic control to a compact rover-control surface.
// Scope: Owns browser-local input semantics; transport and device-specific behavior stay outside this file.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
function integerBound(value, fallback) {
@@ -20,7 +20,7 @@ function trimUnicode(value, maximumLength) {
return Array.from(String(value ?? '')).slice(0, maximumLength).join('');
}
const CARD_CLASS = 'mobile-touch-control rounded-xl border-2 px-2.5 py-2 text-slate-50 shadow-md';
const CARD_CLASS = 'mobile-touch-control rounded-xl border-2 px-2 py-1 text-slate-50';
const DISABLED_CLASS = 'disabled:cursor-not-allowed disabled:opacity-40';
function SliderControl({ peripheralId, control, disabled, send, value: storedValue }) {
@@ -29,33 +29,114 @@ function SliderControl({ peripheralId, control, disabled, send, value: storedVal
const value = Number.isInteger(storedValue)
? clampInteger(storedValue, minimum, maximum)
: minimum;
const trackRef = useRef(null);
const pointerIdRef = useRef(null);
const lastHapticValueRef = useRef(value);
const updateValue = (event) => {
const next = clampInteger(event.target.value, minimum, maximum);
const valuePercent = useMemo(() => {
if (maximum === minimum) return 50;
return ((value - minimum) / (maximum - minimum)) * 100;
}, [maximum, minimum, value]);
const sendValue = useCallback((nextValue) => {
const next = clampInteger(nextValue, minimum, maximum);
const hapticStep = Math.max(1, Math.round((maximum - minimum) / 20));
if (Math.abs(next - lastHapticValueRef.current) >= hapticStep) {
triggerTouchHaptic('camera');
lastHapticValueRef.current = next;
}
send(peripheralId, control.id, next);
}, [control.id, maximum, minimum, peripheralId, send]);
const valueFromPointer = useCallback((event) => {
const track = trackRef.current;
if (!track) return value;
const bounds = track.getBoundingClientRect();
const rawPercent = (event.clientX - bounds.left) / Math.max(1, bounds.width);
return minimum + Math.max(0, Math.min(1, rawPercent)) * (maximum - minimum);
}, [maximum, minimum, value]);
const updateFromPointer = useCallback((event) => {
sendValue(valueFromPointer(event));
}, [sendValue, valueFromPointer]);
const handlePointerDown = useCallback((event) => {
if (disabled || pointerIdRef.current !== null) return;
// This follows VerticalCameraTilt's custom pointer-capture path so a range
// drag remains reliable while another finger is operating the drive pad.
event.preventDefault();
pointerIdRef.current = event.pointerId;
lastHapticValueRef.current = value;
trackRef.current?.setPointerCapture?.(event.pointerId);
updateFromPointer(event);
}, [disabled, updateFromPointer, value]);
const handlePointerMove = useCallback((event) => {
if (pointerIdRef.current !== event.pointerId) return;
event.preventDefault();
updateFromPointer(event);
}, [updateFromPointer]);
const handlePointerEnd = useCallback((event) => {
if (pointerIdRef.current !== event.pointerId) return;
event.preventDefault();
pointerIdRef.current = null;
trackRef.current?.releasePointerCapture?.(event.pointerId);
}, []);
const handleKeyDown = (event) => {
if (disabled) return;
let next = null;
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') next = value - 1;
if (event.key === 'ArrowRight' || event.key === 'ArrowUp') next = value + 1;
if (event.key === 'Home') next = minimum;
if (event.key === 'End') next = maximum;
if (next == null) return;
event.preventDefault();
sendValue(next);
};
return (
<label className={`${CARD_CLASS} block border-emerald-300/70 bg-emerald-900`}>
<span className="flex items-center justify-between gap-2 text-sm font-semibold">
<span>{control.name}</span>
<span className="font-mono text-emerald-100">{value}</span>
</span>
<input
type="range"
min={minimum}
max={maximum}
step="1"
value={value}
disabled={disabled}
onChange={updateValue}
className={`mobile-touch-control mt-2 h-8 w-full cursor-pointer accent-emerald-300 ${DISABLED_CLASS}`}
/>
<span className="flex justify-between text-xs text-emerald-100/80" aria-hidden="true">
<span>{minimum}</span>
<span>{maximum}</span>
</span>
</label>
<div className={`${CARD_CLASS} border-emerald-300/70 bg-emerald-900 ${disabled ? 'cursor-not-allowed opacity-40' : ''}`}>
<div className="flex items-center justify-between gap-1 text-sm font-semibold">
<span className="min-w-0 truncate">{control.name}</span>
<span className="shrink-0 font-mono text-emerald-100">{value}</span>
</div>
<div
ref={trackRef}
role="slider"
aria-label={control.name}
aria-valuemin={minimum}
aria-valuemax={maximum}
aria-valuenow={value}
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onLostPointerCapture={(event) => {
if (pointerIdRef.current === event.pointerId) pointerIdRef.current = null;
}}
onKeyDown={handleKeyDown}
onContextMenu={(event) => event.preventDefault()}
style={{ touchAction: 'none' }}
className="mobile-touch-control mobile-drag-control relative mt-1 h-7 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"
>
{/* An inset track gives the thumb room to remain entirely inside the
card at both endpoints without browser-specific range styling. */}
<div className="pointer-events-none absolute inset-1">
<div
className="absolute inset-y-0 left-0 rounded-full bg-emerald-400"
style={{ width: `${valuePercent}%` }}
/>
</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"
style={{ left: `clamp(0.4375rem, ${valuePercent}%, calc(100% - 0.4375rem))` }}
/>
</div>
</div>
);
}
@@ -64,8 +145,7 @@ function ToggleControl({ peripheralId, control, disabled, send, value }) {
const toggle = () => {
if (disabled) return;
const next = !enabled;
send(peripheralId, control.id, next);
send(peripheralId, control.id, !enabled);
triggerTouchHaptic('button');
};
@@ -75,10 +155,10 @@ function ToggleControl({ peripheralId, control, disabled, send, value }) {
aria-pressed={enabled}
disabled={disabled}
onClick={toggle}
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-[4.5rem] w-full items-center justify-between gap-2 font-semibold transition active:scale-[0.99] ${enabled ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50'}`}
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-12 w-full items-center justify-between gap-1 font-semibold transition active:scale-[0.99] ${enabled ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50'}`}
>
<span>{control.name}</span>
<span className="text-sm">{enabled ? 'On' : 'Off'}</span>
<span className="min-w-0 truncate">{control.name}</span>
<span className="shrink-0 text-xs">{enabled ? 'On' : 'Off'}</span>
</button>
);
}
@@ -92,8 +172,8 @@ function MomentaryControl({ peripheralId, control, disabled, send, value }) {
if (!pressedRef.current) return;
pressedRef.current = false;
pointerIdRef.current = null;
// Always pair a successful press with false. In particular, this cleanup
// path runs when a touch is cancelled or the Accessories view is replaced.
// Always pair a successful press with false, including cancellation,
// permission loss, and replacement of the Accessories view.
send(peripheralId, control.id, false);
}, [control.id, peripheralId, send]);
@@ -105,8 +185,6 @@ function MomentaryControl({ peripheralId, control, disabled, send, value }) {
useEffect(() => release, [release]);
useEffect(() => {
// Browsers do not guarantee pointer-up after a held button becomes
// disabled. Proactively emit the neutral edge at the permission boundary.
if (disabled) release();
}, [disabled, release]);
@@ -143,7 +221,7 @@ function MomentaryControl({ peripheralId, control, disabled, send, value }) {
}
}}
onContextMenu={(event) => event.preventDefault()}
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-[4.5rem] w-full items-center justify-center text-center font-semibold transition active:scale-[0.99] ${pressed ? 'border-fuchsia-200 bg-fuchsia-600 text-white' : 'border-fuchsia-300/70 bg-fuchsia-900 text-fuchsia-50'}`}
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-12 w-full items-center justify-center text-center font-semibold transition active:scale-[0.99] ${pressed ? 'border-fuchsia-200 bg-fuchsia-600 text-white' : 'border-fuchsia-300/70 bg-fuchsia-900 text-fuchsia-50'}`}
>
{control.name}
</button>
@@ -169,11 +247,8 @@ function NumberControl({ peripheralId, control, disabled, send, value: storedVal
};
return (
<label className={`${CARD_CLASS} block border-indigo-300/70 bg-indigo-900`}>
<span className="flex items-center justify-between gap-2 text-sm font-semibold">
<span>{control.name}</span>
<span className="text-xs text-indigo-100/80">{minimum}{maximum}</span>
</span>
<label className={`${CARD_CLASS} flex min-h-12 items-center gap-1 border-indigo-300/70 bg-indigo-900`}>
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{control.name}</span>
<input
type="number"
inputMode="numeric"
@@ -182,6 +257,7 @@ function NumberControl({ peripheralId, control, disabled, send, value: storedVal
step="1"
value={value}
disabled={disabled}
aria-label={`${control.name}, ${minimum} to ${maximum}`}
onChange={(event) => setValue(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
@@ -191,7 +267,7 @@ function NumberControl({ peripheralId, control, disabled, send, value: storedVal
event.currentTarget.blur();
}
}}
className={`mobile-touch-control mt-2 min-h-11 w-full rounded-lg border border-indigo-200/70 bg-indigo-950 px-3 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-none focus-visible:ring-2 focus-visible:ring-indigo-200 ${DISABLED_CLASS}`}
/>
</label>
);
@@ -215,15 +291,13 @@ function TextControl({ peripheralId, control, disabled, send, value: storedValue
};
return (
<label className={`${CARD_CLASS} block border-sky-300/70 bg-sky-900`}>
<span className="flex items-center justify-between gap-2 text-sm font-semibold">
<span>{control.name}</span>
<span className="text-xs text-sky-100/80">{Array.from(value).length}/{maximumLength}</span>
</span>
<label className={`${CARD_CLASS} flex min-h-12 items-center gap-1 border-sky-300/70 bg-sky-900`}>
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{control.name}</span>
<input
type="text"
value={value}
disabled={disabled}
aria-label={`${control.name}, maximum ${maximumLength} characters`}
onChange={(event) => setValue(trimUnicode(event.target.value, maximumLength))}
onBlur={commit}
onKeyDown={(event) => {
@@ -233,7 +307,7 @@ function TextControl({ peripheralId, control, disabled, send, value: storedValue
event.currentTarget.blur();
}
}}
className={`mobile-touch-control mt-2 min-h-11 w-full rounded-lg border border-sky-200/70 bg-sky-950 px-3 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-none focus-visible:ring-2 focus-visible:ring-sky-200 ${DISABLED_CLASS}`}
/>
</label>
);
@@ -9,7 +9,7 @@ import useRoverAccessories from './useRoverAccessories.js';
const EMPTY_ACCESSORY_VALUES = Object.freeze({});
export default function RoverAccessoryControls({ roverId, className = '' }) {
export default function RoverAccessoryControls({ roverId, headerAction = null, className = '' }) {
const { peripherals } = useRoverAccessories(roverId);
const canControl = useCanControlRover(roverId);
const { setPeripheralControl } = useControlActions();
@@ -25,30 +25,36 @@ export default function RoverAccessoryControls({ roverId, className = '' }) {
return (
<div
className={`mobile-touch-control min-h-0 overflow-y-auto overscroll-contain p-1.5 text-slate-100 ${className}`.trim()}
className={`mobile-touch-control min-h-0 overflow-y-auto overscroll-contain text-slate-100 ${className}`.trim()}
aria-label="Rover accessories"
>
{peripherals.map((peripheral) => (
<section key={peripheral.id} className="mb-2 last:mb-0">
{/* The firmware's array order is authoritative. Mapping directly over
it keeps physical authoring order intact across every UI host. */}
<h3 className="mb-1.5 border-b border-cyan-300/40 px-1 pr-8 pb-1 text-sm font-semibold text-cyan-100">
{peripheral.name}
</h3>
<div className="flex flex-col gap-1.5">
{peripheral.controls.map((control) => (
<AccessoryControlField
key={control.id}
peripheralId={peripheral.id}
control={control}
value={values[peripheral.id]?.[control.id]}
disabled={!roverId || !canControl}
send={send}
/>
))}
</div>
</section>
))}
{peripherals.map((peripheral, peripheralIndex) => {
const showHeading = peripherals.length > 1 || (peripheralIndex === 0 && headerAction);
return (
<section key={peripheral.id} className="mb-0.5 last:mb-0">
{/* The firmware's array order is authoritative. Mapping directly over
it keeps physical authoring order intact across every UI host. */}
{showHeading ? (
<div className="mb-0.5 flex min-h-7 items-center gap-1 bg-black/60 px-1 text-xs font-semibold text-cyan-100">
<h3 className="min-w-0 flex-1 truncate">{peripheral.name}</h3>
{peripheralIndex === 0 ? headerAction : null}
</div>
) : null}
<div className="flex flex-col gap-0.5">
{peripheral.controls.map((control) => (
<AccessoryControlField
key={control.id}
peripheralId={peripheral.id}
control={control}
value={values[peripheral.id]?.[control.id]}
disabled={!roverId || !canControl}
send={send}
/>
))}
</div>
</section>
);
})}
</div>
);
}