new themeses!!

This commit is contained in:
legop3
2026-07-17 21:01:48 -04:00
parent f9461433af
commit 4e6e9e3021
23 changed files with 636 additions and 65 deletions
+19 -4
View File
@@ -23,6 +23,8 @@ import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
import { useSharedClock } from '../../hooks/useSharedClock.js';
import { isFeatureEnabled } from '../../lib/features.js';
import { trackAnalyticsEvent } from '../../analytics/index.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { DEFAULT_PAGE_THEME_KEY, getPageThemeClass } from '../../themes/index.js';
const PTZ_DEFAULT_COLOR = '#38bdf8';
@@ -562,7 +564,10 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
<main className="min-h-0 shrink-0 overflow-hidden bg-black" style={{ aspectRatio: '16 / 9' }}>
<PtzMediaPane ptz={ptz} open framed />
</main>
<aside className="flex min-h-0 min-w-56 flex-1 flex-col gap-0.5 overflow-y-auto bg-neutral-950 text-sm">
{/* Keep the sidebar itself transparent. Its child cards still own their dark surfaces,
while the shared PTZ page theme can show through the same compact gaps as the driver
layout instead of being covered by one solid sidebar rectangle. */}
<aside className="flex min-h-0 min-w-56 flex-1 flex-col gap-0.5 overflow-y-auto text-sm">
<PtzQueueSummary ptz={ptz} />
{ptz?.isOperator ? (
<PtzLightingControls ptz={ptz} />
@@ -717,9 +722,16 @@ export function PtzControllerPage({ layout = 'desktop' }) {
const routeExitReleaseTimerRef = useRef(null);
const participantRef = useRef(false);
const closingThroughButtonRef = useRef(false);
const { value: pageSettings } = useSettingsNamespace('page', {
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
});
const isMobile = layout !== 'desktop';
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
// PTZ is a separate route but shares the browser's page settings. Applying the catalog class to
// its body surface exposes the theme only through layout padding and card gaps; camera pixels,
// controls, and card interiors retain their purpose-built dark backgrounds.
const pageBackgroundClass = getPageThemeClass(pageSettings?.backgroundTheme);
useEffect(() => {
// Route-exit cleanup runs after the last render, so retain the latest
@@ -825,7 +837,7 @@ export function PtzControllerPage({ layout = 'desktop' }) {
if (!featureEnabled) {
return (
<main className="flex min-h-[100dvh] items-center justify-center bg-black p-2 text-slate-100">
<main className={`flex min-h-[100dvh] items-center justify-center p-2 text-slate-100 ${pageBackgroundClass}`}>
<CardFrame title="PTZ camera" bodyClassName="space-y-1 p-2 text-sm">
<p>The PTZ camera is not available.</p>
<button type="button" className="button-dark w-full" onClick={() => navigate('/')}>Return to driver page</button>
@@ -835,7 +847,10 @@ export function PtzControllerPage({ layout = 'desktop' }) {
}
return (
<main className="h-[100dvh] w-full overflow-hidden bg-black text-slate-100">
<main className={`h-[100dvh] w-full overflow-hidden text-slate-100 ${pageBackgroundClass}`}>
{/* The fullscreen CardFrame remains the structural shell. Painting its otherwise
transparent body is what lets every desktop and mobile PTZ composition share one
continuous pattern without threading theme props into each individual child panel. */}
<CardFrame
title={isMobile ? '' : ptz?.name || 'PTZ Camera'}
actions={isMobile ? null : (
@@ -847,7 +862,7 @@ export function PtzControllerPage({ layout = 'desktop' }) {
fillHeight
clipOverflow={false}
className="h-[100dvh] w-[100vw] rounded-none border-0 !bg-black"
bodyClassName="relative min-h-0 flex-1"
bodyClassName={`relative min-h-0 flex-1 ${pageBackgroundClass}`}
>
{isMobile ? (
layout === 'mobile-landscape' ? (
+1 -1
View File
@@ -34,7 +34,7 @@ import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
import CardFrame from '../CardFrame/index.jsx';
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import { themeGapClass, themeStackClass } from '../../themeFlags.js';
import { themeGapClass, themeStackClass } from '../../themes/index.js';
import { trackAnalyticsEvent } from '../../analytics/index.js';
const CHAT_DOCK_INITIAL_HEIGHT = 224;
+132 -1
View File
@@ -1,7 +1,7 @@
// Settings Panel
// Purpose: Defines the Settings Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import AuthPanel from '../AuthPanel/index.jsx';
import AdminPanel from '../AdminPanel/index.jsx';
@@ -19,6 +19,13 @@ import { useSocket } from '../../context/SocketContext.jsx';
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../analytics/index.js';
import {
DEFAULT_PAGE_THEME_KEY,
PAGE_THEME_OPTIONS,
getPageTheme,
getPageThemeClass,
normalizePageThemeKey,
} from '../../themes/index.js';
const manualTabs = [
{ key: 'start', label: 'Start OI' },
@@ -66,6 +73,24 @@ function SettingHelp({ children }) {
return <p className="mx-auto w-full max-w-lg text-xs leading-snug text-white">{children}</p>;
}
function ThemePreviewCard({ title, className = '' }) {
// Use the production CardFrame rather than a lookalike rectangle. This makes the demonstration
// honest about borders, opaque card surfaces, and the exact amount of theme visible in a gap.
return (
<CardFrame
title={title}
className={className}
bodyClassName="flex min-h-0 flex-1 flex-col justify-center gap-1 p-1"
fillHeight
>
{/* Neutral placeholder lines suggest real panel content without making the preview look like
an interactive control surface or tying it to any one driver/PTZ layout. */}
<div className="h-1.5 w-4/5 rounded-full bg-neutral-600/80" />
<div className="h-1.5 w-3/5 rounded-full bg-neutral-700/90" />
</CardFrame>
);
}
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.
@@ -116,6 +141,7 @@ export default function SettingsPanel() {
swapMobileControlColumns: false,
driveMacroBackoffEnabled: true,
interInstanceTransferSettings: true,
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
});
const { value: audioSettings, save: saveAudioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const { value: videoSettings, save: saveVideoSettings } = useSettingsNamespace('video', VIDEO_SETTINGS_DEFAULTS);
@@ -126,6 +152,11 @@ export default function SettingsPanel() {
? pageSettings.driveMacroBackoffEnabled
: true;
const interInstanceTransferSettings = pageSettings?.interInstanceTransferSettings !== false;
const savedPageThemeKey = normalizePageThemeKey(pageSettings?.backgroundTheme);
const [previewPageThemeKey, setPreviewPageThemeKey] = useState(savedPageThemeKey);
const previewPageTheme = getPageTheme(previewPageThemeKey);
const savedPageTheme = getPageTheme(savedPageThemeKey);
const hasUnsavedPageTheme = previewPageThemeKey !== savedPageThemeKey;
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
const roverVolume = Number.isFinite(audioSettings?.roverVolume) ? audioSettings.roverVolume : AUDIO_SETTINGS_DEFAULTS.roverVolume;
@@ -141,6 +172,13 @@ export default function SettingsPanel() {
const videoColorFilter = normalizeVideoFilter(videoSettings?.colorFilter);
const videoFilterCycleKeyLabel = formatKeyLabel(keymap?.videoFilterCycle?.[0]);
useEffect(() => {
// Settings load after the provider mounts and can also be replaced by an incoming inter-instance
// transfer. Resynchronize only when the persisted key changes; browsing the local preview does
// not touch pageSettings, so Previous/Next choices are not accidentally reset.
setPreviewPageThemeKey(savedPageThemeKey);
}, [savedPageThemeKey]);
const sensorButtons = useMemo(
() => [
{ key: 'start', label: 'Enable stream', enable: true },
@@ -202,6 +240,32 @@ export default function SettingsPanel() {
trackAnalyticsEvent('settings_change', { setting: 'interInstanceTransferSettings', value: checked });
};
const movePageThemePreview = (direction) => {
const currentIndex = PAGE_THEME_OPTIONS.findIndex((theme) => theme.key === previewPageThemeKey);
const safeIndex = currentIndex >= 0 ? currentIndex : 0;
// Theme browsing wraps in both directions so the Back and Next buttons remain useful at the
// ends of the catalog instead of forcing the user to reverse through every previous option.
const nextIndex = (safeIndex + direction + PAGE_THEME_OPTIONS.length) % PAGE_THEME_OPTIONS.length;
setPreviewPageThemeKey(PAGE_THEME_OPTIONS[nextIndex].key);
};
const handlePageThemeSelect = (event) => {
setPreviewPageThemeKey(normalizePageThemeKey(event.target.value));
};
const handlePageThemeSave = () => {
// Browsing is intentionally local. Persist only this explicit choice so opening Page settings
// and experimenting with patterns cannot unexpectedly alter the driver or PTZ page background.
savePageSettings((current) => ({
...(current ?? {}),
backgroundTheme: previewPageThemeKey,
}));
trackAnalyticsEvent('settings_change', {
setting: 'backgroundTheme',
value: previewPageThemeKey,
});
};
const handleVideoFilterChange = (event) => {
const nextFilter = normalizeVideoFilter(event.target.value);
@@ -238,6 +302,73 @@ export default function SettingsPanel() {
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="Background theme"
className="lg:col-span-2"
bodyClassName="space-y-1.5 p-1 text-sm"
>
{/* Back/Next provide fast visual browsing, while the dropdown remains the direct
route to a known theme in a growing catalog. Neither control saves implicitly. */}
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1.5">
<button
type="button"
className="button-dark min-w-16 text-sm"
onClick={() => movePageThemePreview(-1)}
>
Back
</button>
<select
aria-label="Preview background theme"
value={previewPageThemeKey}
onChange={handlePageThemeSelect}
className="field-input min-w-0 px-1 py-0.5 text-sm"
>
{PAGE_THEME_OPTIONS.map((theme) => (
<option key={theme.key} value={theme.key}>
{theme.label}
</option>
))}
</select>
<button
type="button"
className="button-dark min-w-16 text-sm"
onClick={() => movePageThemePreview(1)}
>
Next
</button>
</div>
{/* This miniature layout contains both a full-height vertical seam and horizontal
seams between stacked cards. It exercises the exact gap directions the artwork
must serve on the driver and PTZ pages. */}
<div
className={`page-theme-preview grid h-32 grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)_minmax(0,0.85fr)] grid-rows-2 gap-0.5 overflow-hidden rounded border border-neutral-500/70 p-0.5 ${getPageThemeClass(previewPageThemeKey)}`}
>
<ThemePreviewCard title="Video" className="row-span-2" />
<ThemePreviewCard title="Controls" />
<ThemePreviewCard title="Queue" />
<ThemePreviewCard title="Chat" className="col-span-2" />
</div>
<div className="flex flex-wrap items-center justify-between gap-1.5">
<p className="min-w-0 text-xs text-white">
Previewing <span className="font-semibold">{previewPageTheme.label}</span>
{hasUnsavedPageTheme ? (
<span className="text-slate-300">; saved theme is {savedPageTheme.label}.</span>
) : (
<span className="text-slate-300">; this is your saved theme.</span>
)}
</p>
<button
type="button"
className="button-dark min-w-24 text-sm disabled:cursor-default disabled:opacity-45"
disabled={!hasUnsavedPageTheme}
onClick={handlePageThemeSave}
>
Save theme
</button>
</div>
</CardFrame>
<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
+1 -1
View File
@@ -2,7 +2,7 @@
// Purpose: Defines the Tabs module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { createContext, useCallback, useContext, useMemo, useState, useEffect } from 'react';
import { themeGapClass, themeStackClass } from '../../themeFlags.js';
import { themeGapClass, themeStackClass } from '../../themes/index.js';
const TabsContext = createContext(null);