many small improvements

This commit is contained in:
legop3
2026-08-19 19:08:06 -04:00
parent a5ec1dcb2d
commit fab0673d9e
15 changed files with 252 additions and 178 deletions
@@ -3,7 +3,7 @@
import { createElement, useMemo } from 'react';
import { FaArrowDown, FaArrowUp, FaBatteryHalf, FaBolt, FaExclamationTriangle, FaMemory, FaThermometerHalf, FaWifi } from 'react-icons/fa';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
import { useVisualTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
import { hostStatsEqual, selectHostStats, selectSpectatorTelemetry, spectatorTelemetryEqual } from '../../../../context/telemetryViews.js';
import CornerPodToggle from './CornerPodToggle.jsx';
import ExpansionPanel from './ExpansionPanel.jsx';
@@ -73,8 +73,13 @@ export default function TopRightPod({ roverId }) {
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
return rover?.batteryState || null;
});
const electrical = useTelemetrySelector(roverId, selectSpectatorTelemetry, spectatorTelemetryEqual);
const host = useTelemetrySelector(powerOpen ? roverId : null, selectHostStats, hostStatsEqual);
// These values drive gauges and informational bars only; no control decision
// depends on their render cadence. The visual subscription remains immediate
// on desktop while honoring MobileLayoutFrame's shared telemetry throttle on
// phones, preventing analog current/voltage noise from repainting this pod at
// the rover sensor-stream rate.
const electrical = useVisualTelemetrySelector(roverId, selectSpectatorTelemetry, spectatorTelemetryEqual);
const host = useVisualTelemetrySelector(powerOpen ? roverId : null, selectHostStats, hostStatsEqual);
const percent = Math.max(0, Math.min(100, finite(batteryState?.percentDisplay) ?? 0));
const current = finite(electrical?.currentMa) ?? 0;
const currentPercent = Math.max(0, Math.min(1, Math.abs(current) / 2500));
@@ -31,6 +31,8 @@ export function useManualDockAssist(options = {}) {
const justDocked = docked && !wasDockedRef.current;
const justUndocked = !docked && wasDockedRef.current;
if (active && justDocked) {
// Stop on the first home-base contact so continued input cannot push the rover against the dock.
actions.stopAllMotion();
actions.sendSong([{ note: 84, duration: 6 }], { slot: 1 });
} else if (active && justUndocked) {
actions.sendSong([{ note: 72, duration: 6 }], { slot: 1 });
@@ -23,11 +23,12 @@ export default function MobileLandscapeLayout() {
<MobileLayoutFrame>
<div className={`flex flex-col ${themeGapClass}`}>
{/* The center column deliberately contains only the driver video. */}
<section className={`grid grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] ${themeGapClass}`}>
<section className={`mobile-landscape-driver-grid grid ${themeGapClass}`}>
{firstColumn}
<div className="min-w-0 self-start">
{/* The video stage adapts through DriverLayoutContext while this layout keeps
ownership of the existing landscape touch-control columns. */}
{/* The grid owns the 4:3 height constraint so its center track always
matches the video. Any width the video cannot use is reassigned to
the control columns instead of becoming empty gutters around it. */}
<NewDriveVideo />
</div>
{secondColumn}
@@ -1,4 +1,27 @@
@media (max-width: 1023px) {
.driver-mobile-layout {
/* Sixty viewport-width units preserves the landscape layout's original
0.7 / 2.1 / 0.7 proportions. The height-derived limit prevents that
center track from producing a 4:3 video taller than the small viewport. */
--mobile-landscape-video-width: min(60vw, 133.333svh);
}
.driver-mobile-layout:has(> .panel-section) {
/* GlobalObjectiveBanner is a direct panel-section child when visible. Only
then reserve its compact mobile height; a dismissed or absent banner no
longer leaves permanent space beneath and beside the video. */
--mobile-landscape-video-width: min(60vw, calc(133.333svh - 3.333rem));
}
.driver-mobile-layout .mobile-landscape-driver-grid {
/* The side controls consume all width not used by the exact video track.
This removes the empty center-column gutters created by max-width alone. */
grid-template-columns:
minmax(0, 1fr)
minmax(0, var(--mobile-landscape-video-width))
minmax(0, 1fr);
}
html:has(.driver-mobile-layout) {
/* These three driver-only positions deliberately use strict page snapping. */
scroll-snap-type: y mandatory;
+1 -1
View File
@@ -1,4 +1,4 @@
// Settings Constants
// Purpose: Defines keys/defaults used by the persisted settings subsystem. Scope: Centralizes stable identifiers and fallback values for settings storage.
export const SETTINGS_COOKIE = 'roverSettings';
export const SETTINGS_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
export const SETTINGS_STORAGE_KEY = 'roverSettings';
+59 -16
View File
@@ -1,38 +1,81 @@
// Settings Persistence
// Purpose: Implements local persistence read/write behavior for settings namespaces. Scope: Encapsulates storage IO, parsing guards, and migration-safe defaults.
import { SETTINGS_COOKIE, SETTINGS_MAX_AGE } from './constants.js';
import { SETTINGS_COOKIE, SETTINGS_STORAGE_KEY } from './constants.js';
function parseCookieValue(raw) {
function parseSettings(raw, source) {
try {
const decoded = decodeURIComponent(raw ?? '');
return JSON.parse(decoded);
return JSON.parse(raw);
} catch (error) {
console.warn('Failed to parse settings cookie', error); // eslint-disable-line no-console
console.warn(`Failed to parse settings from ${source}`, error);
return null;
}
}
export function loadSettings() {
if (typeof document === 'undefined') return {};
function loadCookieSettings() {
const cookiePrefix = `${SETTINGS_COOKIE}=`;
const entry = document.cookie
.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith(cookiePrefix));
if (!entry) return {};
if (!entry) return null;
const raw = entry.substring(cookiePrefix.length);
return parseCookieValue(raw) ?? {};
try {
return parseSettings(decodeURIComponent(raw ?? ''), 'cookie');
} catch (error) {
// URI decoding can fail before JSON parsing when an old cookie is truncated or corrupt.
// Treat that cookie as unavailable so it cannot prevent the Web UI from starting cleanly.
console.warn('Failed to decode settings cookie', error);
return null;
}
}
function removeSettingsCookie() {
// The cookie used path=/, so deletion must use the same path. Removal happens only after
// localStorage has accepted and verified the migrated value, preserving the recoverable copy
// when storage is disabled by a browser policy.
document.cookie = `${SETTINGS_COOKIE}=; path=/; max-age=0; samesite=strict`;
}
export function loadSettings() {
if (typeof window === 'undefined' || typeof document === 'undefined') return {};
try {
const stored = window.localStorage.getItem(SETTINGS_STORAGE_KEY);
if (stored !== null) {
return parseSettings(stored, 'localStorage') ?? {};
}
} catch (error) {
// Some privacy modes expose localStorage but throw when it is accessed. Continue to the
// legacy cookie so existing users retain usable settings in those restricted environments.
console.warn('Failed to read settings from localStorage', error);
return loadCookieSettings() ?? {};
}
const cookieSettings = loadCookieSettings();
if (cookieSettings === null) return {};
// This is a one-time migration for browsers that already have roverSettings. Reuse the normal
// verified writer so the legacy cookie is removed only when the complete value is safely stored.
if (saveSettings(cookieSettings)) {
removeSettingsCookie();
}
return cookieSettings;
}
export function saveSettings(settings) {
if (typeof document === 'undefined') return false;
if (typeof window === 'undefined') return false;
try {
const serialized = encodeURIComponent(JSON.stringify(settings ?? {}));
const cookie = `${SETTINGS_COOKIE}=${serialized}; path=/; max-age=${SETTINGS_MAX_AGE}; samesite=strict`;
document.cookie = cookie;
return true;
const serialized = JSON.stringify(settings ?? {});
window.localStorage.setItem(SETTINGS_STORAGE_KEY, serialized);
// setItem can be intercepted or fail unusually in embedded/privacy-constrained browsers.
// Reading the exact value back keeps the provider from claiming success unless persistence
// really contains the complete settings payload that was just written.
return window.localStorage.getItem(SETTINGS_STORAGE_KEY) === serialized;
} catch (error) {
console.warn('Failed to write settings cookie', error); // eslint-disable-line no-console
console.warn('Failed to write settings to localStorage', error);
return false;
}
}
}