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
@@ -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>
);
}