mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
settings UI reworkings
This commit is contained in:
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
@@ -11,8 +11,8 @@
|
||||
<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-CIuRMYki.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dd2Mm-LQ.css">
|
||||
<script type="module" crossorigin src="/assets/index-jio2k7HC.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-d695mNgs.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -20,6 +20,85 @@ import {
|
||||
buildDescriptorFromCapture,
|
||||
} from './helpers.js';
|
||||
|
||||
function SettingsGroupLabel({ children }) {
|
||||
// Section labels stay visually modest so this panel matches the rest of the app, while white
|
||||
// text keeps them readable without uppercase or oversized type.
|
||||
return <p className="mx-auto w-full max-w-lg text-sm font-semibold text-white">{children}</p>;
|
||||
}
|
||||
|
||||
function MappingRow({
|
||||
action,
|
||||
source,
|
||||
isCapturing,
|
||||
onClear,
|
||||
onCapture,
|
||||
onInvert,
|
||||
}) {
|
||||
// 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="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">{formatSource(source)}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1 max-[520px]:justify-start">
|
||||
{/* Axis-pair controls expose independent inversion because stick X/Y directions often
|
||||
differ between browser mappings. Keeping those buttons beside the source avoids
|
||||
making users hunt across a wide row while testing a binding. */}
|
||||
{action.kind === 'axisPair' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!source}
|
||||
onClick={() => onInvert(action, 'invertX')}
|
||||
className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
Invert X
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!source}
|
||||
onClick={() => onInvert(action, 'invertY')}
|
||||
className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
Invert Y
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* Single-axis mappings only have one inversion flag, so they render the smaller control
|
||||
set and keep button clutter down for trigger-like bindings. */}
|
||||
{action.kind === 'axis' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!source}
|
||||
onClick={() => onInvert(action)}
|
||||
className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
Invert
|
||||
</button>
|
||||
)}
|
||||
{/* Clear and Capture are always present because they are the primary row actions. They
|
||||
wrap with the inversion controls on narrow panes instead of shrinking text. */}
|
||||
<button type="button" onClick={() => onClear(action)} className="button-dark px-1 py-0.5 text-xs">
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCapture(action)}
|
||||
className={`${
|
||||
isCapturing
|
||||
? 'rounded-md bg-emerald-500 px-1 py-0.5 text-emerald-950 hover:bg-emerald-400'
|
||||
: 'button-dark px-1 py-0.5'
|
||||
} text-xs font-medium`}
|
||||
>
|
||||
{isCapturing ? 'Waiting...' : 'Capture'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GamepadMappingSettings() {
|
||||
const hubState = useGamepadHubState();
|
||||
const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace(
|
||||
@@ -226,22 +305,24 @@ export default function GamepadMappingSettings() {
|
||||
<CardFrame
|
||||
title="Controller"
|
||||
meta={activePad ? 'Move sticks or press buttons to bind' : 'Connect a controller to configure.'}
|
||||
bodyClassName="space-y-0.5 text-sm"
|
||||
bodyClassName="space-y-2 p-1 text-sm"
|
||||
>
|
||||
{captureAction && (
|
||||
<p className="mt-0 text-[0.7rem] text-emerald-400">Capturing {captureAction.label}…</p>
|
||||
<p className="mx-auto w-full max-w-lg rounded bg-emerald-950/50 px-1.5 py-1 text-sm text-white">
|
||||
Capturing {captureAction.label}...
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[0.7rem] text-slate-500">Connected controller</p>
|
||||
<div className="space-y-1">
|
||||
<SettingsGroupLabel>Connected controller</SettingsGroupLabel>
|
||||
{hubState.pads.length === 0 ? (
|
||||
<p className="text-[0.7rem] text-slate-400">No controller detected.</p>
|
||||
<p className="mx-auto w-full max-w-lg text-sm text-white">No controller detected.</p>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-0.5 text-xs">
|
||||
<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">
|
||||
<select
|
||||
value={activeSignature ?? ''}
|
||||
onChange={(event) => setActiveSignature(event.target.value)}
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.75rem] text-slate-100"
|
||||
className="min-w-0 rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
|
||||
>
|
||||
{hubState.pads.map((pad) => (
|
||||
<option key={pad.signature} value={pad.signature}>
|
||||
@@ -249,14 +330,18 @@ export default function GamepadMappingSettings() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-[0.7rem] text-slate-400">{activePad?.mapping ?? 'unknown'}</span>
|
||||
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
|
||||
{activePad?.mapping ?? 'unknown'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[0.7rem] text-slate-500">Calibration</p>
|
||||
<div className="space-y-0.5">
|
||||
<div className="space-y-1">
|
||||
<SettingsGroupLabel>Calibration</SettingsGroupLabel>
|
||||
{/* 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">
|
||||
<SliderField
|
||||
label="Drive deadzone"
|
||||
description="Ignore small drive stick drift"
|
||||
@@ -293,19 +378,21 @@ export default function GamepadMappingSettings() {
|
||||
value={activeProfile.calibration?.auxSideScale ?? 0.55}
|
||||
onChange={(value) => updateCalibration({ auxSideScale: value })}
|
||||
/>
|
||||
<label className="block p-0.5">
|
||||
<div className="flex items-center justify-between text-xs text-slate-300">
|
||||
<span className="font-semibold text-slate-100">Camera mode</span>
|
||||
<label className="mx-auto block w-full max-w-lg rounded 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">
|
||||
<span className="min-w-0 font-semibold text-white">Camera mode</span>
|
||||
<select
|
||||
value={activeProfile.calibration?.cameraMode ?? 'absolute'}
|
||||
onChange={(event) => updateCalibration({ cameraMode: event.target.value })}
|
||||
className="rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-[0.75rem] text-slate-100"
|
||||
className="rounded 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>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-[0.65rem] text-slate-500">Absolute maps stick to angle; velocity moves over time.</p>
|
||||
<p className="mt-0.5 text-xs leading-snug text-white">Absolute maps stick to angle; velocity moves over time.</p>
|
||||
</label>
|
||||
<SliderField
|
||||
label="Camera sensitivity"
|
||||
@@ -319,67 +406,26 @@ export default function GamepadMappingSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<div className="space-y-2">
|
||||
{Object.entries(grouped).map(([section, actions]) => (
|
||||
<div key={section} className="space-y-0.5">
|
||||
<p className="text-[0.7rem] text-slate-500">{section}</p>
|
||||
<div className="space-y-0.5">
|
||||
<div key={section} className="space-y-1">
|
||||
<SettingsGroupLabel>{section}</SettingsGroupLabel>
|
||||
<div className="grid gap-1">
|
||||
{actions.map((action) => {
|
||||
const binding = activeProfile.bindings?.[action.id];
|
||||
const source = binding?.sources?.[0] ?? null;
|
||||
// The binding data is unchanged; MappingRow only changes presentation so the
|
||||
// existing capture, clear, and invert handlers continue to own behavior.
|
||||
return (
|
||||
<div key={action.id} className="flex items-center justify-between text-xs">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-100">{action.label}</p>
|
||||
<p className="text-[0.65rem] text-slate-400">{formatSource(source)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{action.kind === 'axisPair' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!source}
|
||||
onClick={() => handleInvert(action, 'invertX')}
|
||||
className="button-dark text-[0.7rem] font-medium disabled:opacity-50"
|
||||
>
|
||||
Invert X
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!source}
|
||||
onClick={() => handleInvert(action, 'invertY')}
|
||||
className="button-dark text-[0.7rem] font-medium disabled:opacity-50"
|
||||
>
|
||||
Invert Y
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{action.kind === 'axis' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!source}
|
||||
onClick={() => handleInvert(action)}
|
||||
className="button-dark text-[0.7rem] font-medium disabled:opacity-50"
|
||||
>
|
||||
Invert
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => handleClear(action)} className="button-dark text-[0.7rem]">
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCaptureAction(action)}
|
||||
className={`${
|
||||
captureAction?.id === action.id
|
||||
? 'px-0.5 py-0.5 bg-emerald-500 text-emerald-950 hover:bg-emerald-400'
|
||||
: 'button-dark'
|
||||
} text-[0.7rem] font-medium`}
|
||||
>
|
||||
{captureAction?.id === action.id ? 'Waiting…' : 'Capture'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<MappingRow
|
||||
key={action.id}
|
||||
action={action}
|
||||
source={source}
|
||||
isCapturing={captureAction?.id === action.id}
|
||||
onClear={handleClear}
|
||||
onCapture={setCaptureAction}
|
||||
onInvert={handleInvert}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -387,43 +433,43 @@ export default function GamepadMappingSettings() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[0.7rem] text-slate-500">Diagnostics</p>
|
||||
<div className="space-y-1">
|
||||
<SettingsGroupLabel>Diagnostics</SettingsGroupLabel>
|
||||
{!activePad ? (
|
||||
<p className="text-[0.7rem] text-slate-400">No controller detected.</p>
|
||||
<p className="mx-auto w-full max-w-lg text-sm text-white">No controller detected.</p>
|
||||
) : (
|
||||
<div className="space-y-0.5 text-[0.7rem] text-slate-300">
|
||||
<p className="text-slate-400">Raw axes</p>
|
||||
<div className="grid grid-cols-2 gap-0.5">
|
||||
<div className="mx-auto w-full max-w-lg space-y-1 rounded bg-neutral-900/70 px-1.5 py-1 text-xs text-white">
|
||||
<p className="text-white">Raw axes</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{activePad.axes.map((value, index) => (
|
||||
<span key={`axis-${index}`} className="font-mono text-slate-300">
|
||||
<span key={`axis-${index}`} className="font-mono text-white">
|
||||
A{index}: {NUMBER_FORMAT.format(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-slate-400">Raw buttons</p>
|
||||
<div className="grid grid-cols-2 gap-0.5">
|
||||
<p className="text-white">Raw buttons</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{activePad.buttons.map((btn, index) => (
|
||||
<span key={`btn-${index}`} className="font-mono text-slate-300">
|
||||
<span key={`btn-${index}`} className="font-mono text-white">
|
||||
B{index}: {NUMBER_FORMAT.format(btn.value)} {btn.pressed ? '●' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{diagnostics?.outputs && (
|
||||
<>
|
||||
<p className="text-slate-400">Mapped outputs</p>
|
||||
<div className="grid grid-cols-2 gap-0.5">
|
||||
<span className="font-mono text-slate-300">
|
||||
<p className="text-white">Mapped outputs</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<span className="font-mono text-white">
|
||||
Drive: {NUMBER_FORMAT.format(diagnostics.outputs.driveVector.x)},{' '}
|
||||
{NUMBER_FORMAT.format(diagnostics.outputs.driveVector.y)}
|
||||
</span>
|
||||
<span className="font-mono text-slate-300">
|
||||
<span className="font-mono text-white">
|
||||
Camera: {NUMBER_FORMAT.format(diagnostics.outputs.cameraAxis)}
|
||||
</span>
|
||||
<span className="font-mono text-slate-300">
|
||||
<span className="font-mono text-white">
|
||||
Main: {NUMBER_FORMAT.format(diagnostics.outputs.auxAxis.main)}
|
||||
</span>
|
||||
<span className="font-mono text-slate-300">
|
||||
<span className="font-mono text-white">
|
||||
Side: {NUMBER_FORMAT.format(diagnostics.outputs.auxAxis.side)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,16 @@ import { NUMBER_FORMAT } from './constants.js';
|
||||
|
||||
export default function SliderField({ label, description, min, max, step, value, onChange }) {
|
||||
return (
|
||||
<label className="block p-0.5">
|
||||
<div className="flex items-center justify-between text-xs text-slate-300">
|
||||
<span className="font-semibold text-slate-100">{label}</span>
|
||||
<span className="font-mono text-slate-400">{NUMBER_FORMAT.format(value)}</span>
|
||||
<label className="mx-auto block w-full max-w-lg rounded 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">
|
||||
{NUMBER_FORMAT.format(value)}
|
||||
</span>
|
||||
</div>
|
||||
{description && <p className="text-[0.65rem] text-slate-500">{description}</p>}
|
||||
{description && <p className="mt-0.5 text-xs leading-snug text-white">{description}</p>}
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
@@ -18,7 +22,7 @@ export default function SliderField({ label, description, min, max, step, value,
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className="mt-0 w-full accent-emerald-400"
|
||||
className="mt-1 w-full accent-emerald-400"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -118,22 +118,31 @@ function useKeyCapture(onCapture) {
|
||||
return { active, startCapture, cancel, captureFromEvent };
|
||||
}
|
||||
|
||||
function SettingsGroupLabel({ children }) {
|
||||
// Group labels stay at the app's normal small-panel scale, but use white text so they are
|
||||
// readable without resorting to oversized type or all-caps styling.
|
||||
return <p className="mx-auto w-full max-w-lg text-sm font-semibold text-white">{children}</p>;
|
||||
}
|
||||
|
||||
function SpeedField({ label, description, value, onChange, min = 0, max = 500, step = 5 }) {
|
||||
return (
|
||||
<label className="block p-0.5">
|
||||
<div className="flex items-center justify-between text-xs text-slate-300">
|
||||
<span className="font-semibold text-slate-100">{label}</span>
|
||||
<label className="mx-auto block w-full max-w-lg rounded 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. */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_4.75rem] items-start gap-1.5 text-sm text-white">
|
||||
<span className="min-w-0 font-semibold text-white">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="w-16 rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.75rem] font-mono text-slate-100"
|
||||
className="w-full rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-right text-xs font-mono text-white"
|
||||
/>
|
||||
</div>
|
||||
{description && <p className="text-[0.65rem] text-slate-500">{description}</p>}
|
||||
<div className="mt-0 flex items-center gap-0.5">
|
||||
{description && <p className="mt-0.5 text-xs leading-snug text-white">{description}</p>}
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
@@ -141,14 +150,46 @@ function SpeedField({ label, description, value, onChange, min = 0, max = 500, s
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-2 flex-1 accent-emerald-400"
|
||||
className="h-2 min-w-0 flex-1 accent-emerald-400"
|
||||
/>
|
||||
<span className="w-12 text-right text-[0.75rem] font-mono text-slate-400">{value}</span>
|
||||
<span className="w-10 text-right text-xs font-mono text-white">{value}</span>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function BindingRow({ action, value, isActive, onCaptureStart, onCancel, onCaptureKeyDown }) {
|
||||
// Binding rows are intentionally constrained and boxed: the action name, current key, and
|
||||
// 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="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>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1 max-[420px]:justify-start">
|
||||
{/* The cancel button only appears during capture so the normal row stays compact, while
|
||||
the active row still gives users an obvious way out if they clicked Change by mistake. */}
|
||||
{isActive && (
|
||||
<button type="button" onClick={onCancel} className="button-danger px-1 py-0.5 text-xs">
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCaptureStart}
|
||||
onKeyDown={isActive ? onCaptureKeyDown : undefined}
|
||||
autoFocus={isActive}
|
||||
className={`${isActive ? 'rounded-md bg-emerald-500 px-1 py-0.5 text-emerald-950 hover:bg-emerald-400' : 'button-dark px-1 py-0.5'} text-xs font-medium transition-colors`}
|
||||
>
|
||||
{isActive ? 'Press a key...' : 'Change'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KeymapSettings() {
|
||||
const {
|
||||
state: { keymap },
|
||||
@@ -218,15 +259,17 @@ export default function KeymapSettings() {
|
||||
title="Keyboard layout"
|
||||
meta="Per-browser · click to change a binding"
|
||||
actions={
|
||||
<button type="button" onClick={() => resetKeyBindings()} className="button-dark text-xs">
|
||||
<button type="button" onClick={() => resetKeyBindings()} className="button-dark px-1 py-0.5 text-xs">
|
||||
Reset defaults
|
||||
</button>
|
||||
}
|
||||
bodyClassName="space-y-0.5 text-sm"
|
||||
bodyClassName="space-y-2 p-1 text-sm"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[0.7rem] text-slate-500">Keyboard speeds</p>
|
||||
<div className="space-y-0.5">
|
||||
<div className="space-y-1">
|
||||
<SettingsGroupLabel>Keyboard speeds</SettingsGroupLabel>
|
||||
{/* Speed controls remain stacked instead of becoming two columns because each slider
|
||||
needs enough width for fine adjustment and a nearby number input. */}
|
||||
<div className="grid gap-1">
|
||||
<SpeedField
|
||||
label="Base speed"
|
||||
description="Normal driving speed"
|
||||
@@ -247,8 +290,8 @@ export default function KeymapSettings() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[0.7rem] text-slate-500">Camera tilt</p>
|
||||
<div className="space-y-1">
|
||||
<SettingsGroupLabel>Camera tilt</SettingsGroupLabel>
|
||||
<SpeedField
|
||||
label="Tilt speed"
|
||||
description="Higher = faster when holding tilt keys"
|
||||
@@ -259,37 +302,26 @@ export default function KeymapSettings() {
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="space-y-2">
|
||||
{Object.entries(grouped).map(([group, actions]) => (
|
||||
<div key={group} className="space-y-0.5">
|
||||
<p className="text-[0.75rem] text-slate-400">{group}</p>
|
||||
<div className="space-y-0.5">
|
||||
<div key={group} className="space-y-1">
|
||||
<SettingsGroupLabel>{group}</SettingsGroupLabel>
|
||||
<div className="grid gap-1">
|
||||
{actions.map((action) => {
|
||||
const value = currentKey(action.id);
|
||||
const isActive = active === action.id;
|
||||
// Each action keeps the same capture behavior as before; only the row chrome
|
||||
// changes so the current binding and Change button stay visually connected.
|
||||
return (
|
||||
<div key={action.id} className="flex items-center justify-between text-xs">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-100">{action.label}</p>
|
||||
<p className="text-[0.65rem] text-slate-400">{formatKeyLabel(value)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{isActive && (
|
||||
<button type="button" onClick={() => cancel()} className="button-danger text-[0.7rem]">
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startCapture(action.id)}
|
||||
onKeyDown={isActive ? (event) => captureFromEvent(action.id, event) : undefined}
|
||||
autoFocus={isActive}
|
||||
className={`${isActive ? 'px-0.5 py-0.5 bg-emerald-500 text-emerald-950 hover:bg-emerald-400' : 'button-dark'} text-[0.7rem] font-medium transition-colors`}
|
||||
>
|
||||
{isActive ? 'Press a key…' : 'Change'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<BindingRow
|
||||
key={action.id}
|
||||
action={action}
|
||||
value={value}
|
||||
isActive={isActive}
|
||||
onCancel={() => cancel()}
|
||||
onCaptureStart={() => startCapture(action.id)}
|
||||
onCaptureKeyDown={(event) => captureFromEvent(action.id, event)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,50 @@ function normalizeVideoFilter(value) {
|
||||
: VIDEO_SETTINGS_DEFAULTS.colorFilter;
|
||||
}
|
||||
|
||||
function SettingRow({ children, className = '' }) {
|
||||
// Page settings are changed one row at a time, so each row gets a subtle container and a
|
||||
// max width. This keeps the label and control together instead of stretching them across
|
||||
// the full settings pane.
|
||||
return (
|
||||
<label
|
||||
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 text-white max-[420px]:grid-cols-1 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingHelp({ children }) {
|
||||
// Helper text is still secondary, but it no longer uses the very small microcopy scale that
|
||||
// made the settings panel difficult to read from a normal driving distance.
|
||||
return <p className="mx-auto w-full max-w-lg text-xs leading-snug text-white">{children}</p>;
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center 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">
|
||||
{Math.round(value * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="mt-1 w-full accent-emerald-500 disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const {
|
||||
state: { keymap, roverId },
|
||||
@@ -157,29 +201,32 @@ export default function SettingsPanel() {
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="page">
|
||||
<div className="space-y-0.5">
|
||||
<CardFrame title="HUD" bodyClassName="space-y-0.5 text-sm">
|
||||
<label className="flex items-center gap-0.5 text-slate-200">
|
||||
{/* Page settings use a responsive card grid so unrelated settings do not form one long,
|
||||
stretched column. Audio spans both columns because volume sliders need extra width
|
||||
for comfortable pointer control. */}
|
||||
<div className="grid gap-1.5 lg:grid-cols-2">
|
||||
<CardFrame title="HUD" bodyClassName="space-y-1 p-1 text-sm">
|
||||
<SettingRow className="grid-cols-[auto_minmax(0,1fr)] max-[420px]:grid-cols-[auto_minmax(0,1fr)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-emerald-500"
|
||||
className="h-3.5 w-3.5 accent-emerald-500"
|
||||
checked={hudMapDesktop}
|
||||
onChange={(e) => setHudMapDesktop(e.target.checked)}
|
||||
/>
|
||||
<span>Show top-down map in HUD (desktop)</span>
|
||||
</label>
|
||||
<p className="text-xs text-slate-500">Mobile HUD keeps the map on by default.</p>
|
||||
<span className="font-semibold text-white">Show top-down map in HUD (desktop)</span>
|
||||
</SettingRow>
|
||||
<SettingHelp>Mobile HUD keeps the map on by default.</SettingHelp>
|
||||
</CardFrame>
|
||||
<CardFrame title="Video" bodyClassName="space-y-0.5 text-sm">
|
||||
<CardFrame title="Video" bodyClassName="space-y-1 p-1 text-sm">
|
||||
{/* The dropdown mirrors the other Page settings controls and writes through the
|
||||
existing cookie-backed settings provider, so the choice survives reloads without
|
||||
adding rover-side state. */}
|
||||
<label className="flex items-center justify-between gap-0.5 text-slate-200">
|
||||
<span>Rover video filter</span>
|
||||
<SettingRow>
|
||||
<span className="font-semibold text-white">Rover video filter</span>
|
||||
<select
|
||||
value={videoColorFilter}
|
||||
onChange={handleVideoFilterChange}
|
||||
className="field-input text-sm"
|
||||
className="field-input min-w-28 px-1 py-0.5 text-sm"
|
||||
>
|
||||
{VIDEO_FILTER_OPTIONS.map((option) => (
|
||||
<option key={option.key} value={option.key}>
|
||||
@@ -187,127 +234,89 @@ export default function SettingsPanel() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-xs text-slate-500">
|
||||
</SettingRow>
|
||||
<SettingHelp>
|
||||
{/* Use the live keymap value here instead of hardcoding the default key because
|
||||
this shortcut is user-configurable in the Keybindings tab. */}
|
||||
<span className="inline-flex flex-wrap items-center gap-0.5">
|
||||
<span className="inline-flex flex-wrap items-center gap-1">
|
||||
<span>Use</span>
|
||||
{videoFilterCycleKeyLabel ? <KeyPill label={videoFilterCycleKeyLabel} /> : null}
|
||||
<span>to cycle filters. Applies only to rover camera pixels; HUD overlays stay full color.</span>
|
||||
</span>
|
||||
</p>
|
||||
</SettingHelp>
|
||||
</CardFrame>
|
||||
<CardFrame title="Mobile controls" bodyClassName="space-y-0.5 text-sm">
|
||||
<label className="flex items-center gap-0.5 text-slate-200">
|
||||
<CardFrame title="Mobile controls" bodyClassName="space-y-1 p-1 text-sm">
|
||||
<SettingRow className="grid-cols-[auto_minmax(0,1fr)] max-[420px]:grid-cols-[auto_minmax(0,1fr)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-emerald-500"
|
||||
className="h-3.5 w-3.5 accent-emerald-500"
|
||||
checked={swapMobileControlColumns}
|
||||
onChange={handleSwapMobileControlColumns}
|
||||
/>
|
||||
<span>Swap control columns (put joystick on the left)</span>
|
||||
</label>
|
||||
<span className="font-semibold text-white">Swap control columns (put joystick on the left)</span>
|
||||
</SettingRow>
|
||||
</CardFrame>
|
||||
<CardFrame title="Macros" bodyClassName="space-y-0.5 text-sm">
|
||||
<label className="flex items-center gap-0.5 text-slate-200">
|
||||
<CardFrame title="Macros" bodyClassName="space-y-1 p-1 text-sm">
|
||||
<SettingRow className="grid-cols-[auto_minmax(0,1fr)] max-[420px]:grid-cols-[auto_minmax(0,1fr)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-emerald-500"
|
||||
className="h-3.5 w-3.5 accent-emerald-500"
|
||||
checked={driveMacroBackoffEnabled}
|
||||
onChange={handleDriveMacroBackoffEnabled}
|
||||
/>
|
||||
<span>Enable backward bump in drive macro</span>
|
||||
</label>
|
||||
<span className="font-semibold text-white">Enable backward bump in drive macro</span>
|
||||
</SettingRow>
|
||||
</CardFrame>
|
||||
<CardFrame title="Audio" bodyClassName="space-y-0.5 text-sm">
|
||||
<label className="grid gap-0.5 text-slate-200">
|
||||
<div className="flex items-center justify-between gap-0.5">
|
||||
<span>Master volume</span>
|
||||
<span className="text-xs text-slate-400">{Math.round(masterVolume * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
<CardFrame title="Audio" className="lg:col-span-2" bodyClassName="space-y-1 p-1 text-sm">
|
||||
{/* Audio sliders are stacked inside the wider card so the value chip, label, and
|
||||
slider remain easy to compare while preserving enough drag distance. */}
|
||||
<RangeSetting
|
||||
label="Master volume"
|
||||
value={masterVolume}
|
||||
onChange={handleAudioRange('masterVolume')}
|
||||
className="w-full accent-emerald-500"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-0.5 text-slate-200">
|
||||
<div className="flex items-center justify-between gap-0.5">
|
||||
<span>Alert/page sounds</span>
|
||||
<span className="text-xs text-slate-400">{Math.round(alertVolume * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
<RangeSetting
|
||||
label="Alert/page sounds"
|
||||
value={alertVolume}
|
||||
onChange={handleAudioRange('alertVolume')}
|
||||
className="w-full accent-emerald-500"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-0.5 text-slate-200">
|
||||
<div className="flex items-center justify-between gap-0.5">
|
||||
<span>Rover audio</span>
|
||||
<span className="text-xs text-slate-400">{Math.round(roverVolume * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
<RangeSetting
|
||||
label="Rover audio"
|
||||
value={roverVolume}
|
||||
onChange={handleAudioRange('roverVolume')}
|
||||
className="w-full accent-emerald-500"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-0.5 text-slate-200">
|
||||
<span>Main brush ducking</span>
|
||||
<SettingRow>
|
||||
<span className="font-semibold text-white">Main brush ducking</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-emerald-500"
|
||||
className="h-3.5 w-3.5 accent-emerald-500"
|
||||
checked={mainBrushDuckEnabled}
|
||||
onChange={handleMainBrushDuckEnabled}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-0.5 text-slate-200">
|
||||
<div className="flex items-center justify-between gap-0.5">
|
||||
<span>Main brush duck amount</span>
|
||||
<span className="text-xs text-slate-400">{Math.round(mainBrushDuckAmount * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
</SettingRow>
|
||||
<RangeSetting
|
||||
label="Main brush duck amount"
|
||||
value={mainBrushDuckAmount}
|
||||
onChange={handleAudioRange('mainBrushDuckAmount')}
|
||||
className="w-full accent-emerald-500"
|
||||
disabled={!mainBrushDuckEnabled}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-slate-500">
|
||||
<SettingHelp>
|
||||
Lowers rover audio only while the main brush is running.
|
||||
</p>
|
||||
</SettingHelp>
|
||||
</CardFrame>
|
||||
<CardFrame title="Connection" bodyClassName="space-y-0.5 text-sm">
|
||||
<label className="flex items-center justify-between gap-0.5 text-slate-200">
|
||||
<span>Transport</span>
|
||||
<CardFrame title="Connection" bodyClassName="space-y-1 p-1 text-sm">
|
||||
<SettingRow>
|
||||
<span className="font-semibold text-white">Transport</span>
|
||||
<select
|
||||
value={connectionTransport}
|
||||
onChange={handleTransportChange}
|
||||
className="field-input text-sm"
|
||||
className="field-input min-w-28 px-1 py-0.5 text-sm"
|
||||
>
|
||||
<option value="websocket">WebSocket</option>
|
||||
<option value="polling">Polling</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-xs text-slate-500">Switching reconnects your session.</p>
|
||||
</SettingRow>
|
||||
<SettingHelp>Switching reconnects your session.</SettingHelp>
|
||||
</CardFrame>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
Reference in New Issue
Block a user