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
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
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-D9i-ZujG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D5gzYj1F.css">
<script type="module" crossorigin src="/assets/index-BUo1IlRS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BITzjVQo.css">
</head>
<body>
<div id="root"></div>
@@ -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;
}
}
}