new help system!!

This commit is contained in:
legop3
2025-12-04 23:50:44 -05:00
parent edeac5e6ff
commit 9f0bb8bd24
14 changed files with 572 additions and 74 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
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-ba8YMKfH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7IHa35aL.css">
<script type="module" crossorigin src="/assets/index-B-bJbbPY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-4BBPmLYK.css">
</head>
<body>
<div id="root"></div>
+59 -12
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import TelemetryPanel from './components/TelemetryPanel.jsx';
import DrivePanel from './components/DrivePanel.jsx';
import AlertFeed from './components/AlertFeed.jsx';
@@ -22,6 +22,8 @@ import UserListPanel from './components/UserListPanel.jsx';
import ChatPanel from './components/ChatPanel.jsx';
import FullscreenPrompt from './components/FullscreenPrompt.jsx';
import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js';
import { useSettingsNamespace } from './settings/index.js';
import HelpOverlay from './components/HelpOverlay.jsx';
function useLayoutMode() {
const [mode, setMode] = useState(() => {
@@ -53,7 +55,7 @@ function useLayoutMode() {
return mode;
}
function DesktopLayout({ layout }) {
function DesktopLayout({ layout, onOpenHelpOverlay }) {
return (
<div className="flex h-full gap-0.5 overflow-hidden">
<div className="flex min-w-0 flex-[1.8] flex-col gap-0.5 overflow-y-auto pr-0.5">
@@ -69,7 +71,7 @@ function DesktopLayout({ layout }) {
<LogPanel />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto">
<RightPaneTabs layout={layout} />
<RightPaneTabs layout={layout} onOpenHelpOverlay={onOpenHelpOverlay} />
{/* <SessionSnapshot /> */}
</div>
</div>
@@ -128,17 +130,57 @@ function MobileLandscapeLayout() {
function App() {
const layout = useLayoutMode();
const isDesktop = layout === 'desktop';
const { visible: fullscreenVisible, mode: fullscreenMode, enterFullscreen, dismiss } = useFullscreenPrompt(layout);
const renderedLayout =
isDesktop
? <DesktopLayout layout={layout} />
: layout === 'mobile-landscape'
? <MobileLandscapeLayout />
: <MobilePortraitLayout />;
const fullscreen = useFullscreenPrompt(layout);
return (
<div className={`bg-black text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'min-h-screen'}`}>
<SettingsProvider>
<AppWithProviders layout={layout} isDesktop={isDesktop} fullscreen={fullscreen} />
</SettingsProvider>
</div>
);
}
function AppWithProviders({ layout, isDesktop, fullscreen }) {
const {
visible: fullscreenVisible,
mode: fullscreenMode,
enterFullscreen,
dismiss,
} = fullscreen;
const {
value: helpSettings,
save: saveHelpSettings,
} = useSettingsNamespace('help', { showOnLoad: true });
const [helpVisible, setHelpVisible] = useState(false);
useEffect(() => {
if (helpSettings?.showOnLoad !== false) {
setHelpVisible(true);
}
}, [helpSettings?.showOnLoad]);
const openHelp = useCallback(() => setHelpVisible(true), []);
const closeHelp = useCallback(() => setHelpVisible(false), []);
const setShowOnLoad = useCallback(
(enabled) => {
saveHelpSettings((current) => ({ ...(current ?? {}), showOnLoad: Boolean(enabled) }));
},
[saveHelpSettings],
);
const renderedLayout = useMemo(
() =>
isDesktop
? <DesktopLayout layout={layout} onOpenHelpOverlay={openHelp} />
: layout === 'mobile-landscape'
? <MobileLandscapeLayout />
: <MobilePortraitLayout />,
[isDesktop, layout, openHelp],
);
return (
<ControlSystemProvider>
<KeyboardInputManager />
<GamepadInputManager />
@@ -148,6 +190,13 @@ function App() {
<AlertFeed />
<TurnAlertListener />
<ModeGateOverlay />
<HelpOverlay
visible={helpVisible}
layout={layout}
onClose={closeHelp}
showOnLoad={helpSettings?.showOnLoad !== false}
onToggleShowOnLoad={setShowOnLoad}
/>
<FullscreenPrompt
visible={fullscreenVisible}
mode={fullscreenMode}
@@ -155,8 +204,6 @@ function App() {
onDismiss={dismiss}
/>
</ControlSystemProvider>
</SettingsProvider>
</div>
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ export default function DiscordInviteButton({text = "Join our Discord!"}) {
href={discordInvite}
target="_blank"
rel="noopener noreferrer"npm
className="inline-flex items-center px-3 py-2 text-white rainbow-animate-bg transition justify-center"
className="inline-flex items-center w-full h-full text-white rainbow-animate-bg transition justify-center"
// animated rainbow backgound
// className="inline-flex items-center px-3 py-2 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white rounded hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 transition"
>
+204
View File
@@ -0,0 +1,204 @@
import { useMemo } from 'react';
import { formatKeyLabel } from '../controls/keymapUtils.js';
import { getHelpContent } from '../help/content.js';
function KeyPill({ actionId, keymap }) {
const value = keymap?.[actionId]?.[0] ?? '';
return (
<span className="rounded border border-slate-600 bg-slate-900/40 px-1 text-[0.7rem] text-slate-200">
{formatKeyLabel(value)}
</span>
);
}
function renderSegments(segments, keymap) {
return segments.map((segment, idx) => {
if (typeof segment === 'string') {
return <span key={`text-${idx}`}>{segment}</span>;
}
if (segment?.action) {
return <KeyPill key={`pill-${segment.action}-${idx}`} actionId={segment.action} keymap={keymap} />;
}
if (segment?.text) {
return <span key={`span-${idx}`}>{segment.text}</span>;
}
return null;
});
}
function renderLine(line, keymap, idx) {
if (typeof line === 'string') return line;
if (Array.isArray(line?.segments)) return renderSegments(line.segments, keymap);
if (line && typeof line === 'object') {
// Fallback: render any stray object as text if possible
if (typeof line.text === 'string') return line.text;
if (Array.isArray(line)) return renderSegments(line, keymap);
}
return String(line ?? '');
}
function Hero({ hero, keymap }) {
if (!hero) return null;
return (
<div className="surface space-y-0.25 px-0.5 py-0.5">
<div className="flex flex-wrap items-center justify-between gap-0.5">
<div>
<p className="text-sm font-semibold text-white">{hero.title}</p>
{hero.subtitle && <p className="text-xs text-slate-300">{hero.subtitle}</p>}
</div>
{hero.chips && (
<div className="flex flex-wrap gap-0.25 text-[0.7rem] text-slate-200">
{hero.chips.map((chip) => (
<span key={chip} className="rounded border border-slate-700 px-1 py-[2px]">
{chip}
</span>
))}
</div>
)}
</div>
{hero.bullets && (
<ul className="space-y-0.25 text-[0.85rem] text-slate-200">
{hero.bullets.map((line, idx) => {
const key = Array.isArray(line?.segments) ? `hero-${idx}` : `hero-${idx}`;
return (
<li key={key} className="surface-muted px-0.5 py-0.25">
{renderLine(line, keymap, idx)}
</li>
);
})}
</ul>
)}
</div>
);
}
function ListBlock({ block, keymap }) {
return (
<div className="space-y-0.25">
<p className="text-xs font-semibold text-slate-200">{block.title}</p>
<ul className="space-y-0.25 text-[0.8rem] text-slate-300">
{block.items.map((item, idx) => {
const key = `item-${idx}`;
return (
<li key={key} className="surface-muted flex flex-wrap items-center gap-0.25 px-0.5 py-0.25">
{renderLine(item, keymap, idx)}
</li>
);
})}
</ul>
</div>
);
}
function CalloutBlock({ block }) {
const toneClass = block.tone === 'info' ? 'border-cyan-500/40' : 'border-slate-700';
return (
<div className={`surface space-y-0.25 border ${toneClass} px-0.5 py-0.5`}>
<p className="text-xs font-semibold text-slate-100">{block.title}</p>
{block.body && (
<ul className="space-y-0.25 text-[0.8rem] text-slate-300">
{block.body.map((line, idx) => (
<li key={`callout-${idx}`} className="surface-muted px-0.5 py-0.25">
{renderLine(line, {})}
</li>
))}
</ul>
)}
</div>
);
}
function KeyboardGroup({ group, keymap }) {
return (
<div className="space-y-0.25 surface">
<p className="px-0.5 py-0.25 text-[0.75rem] font-semibold text-slate-200">{group.title}</p>
<div className="space-y-0.25 px-0.5 pb-0.25">
{group.items.map((item) => (
<div key={item.action} className="surface-muted flex items-center justify-between gap-0.5 px-0.5 py-0.25 text-[0.8rem]">
<span className="text-slate-200">{item.label}</span>
<KeyPill actionId={item.action} keymap={keymap} />
</div>
))}
</div>
</div>
);
}
function KeyboardBlock({ block, keymap }) {
if (!block) return null;
return (
<div className="space-y-0.25">
<div className="flex items-center justify-between text-xs text-slate-200">
<span className="font-semibold">{block.title}</span>
{block.footnote && <span className="text-[0.7rem] text-slate-400">{block.footnote}</span>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-0.5">
{block.groups?.map((group) => (
<KeyboardGroup key={group.id} group={group} keymap={keymap} />
))}
</div>
</div>
);
}
function GamepadBlock({ block }) {
if (!block) return null;
return (
<div className="space-y-0.25">
<p className="text-xs font-semibold text-slate-200">{block.title}</p>
<ul className="space-y-0.25 text-[0.8rem] text-slate-300">
{block.items?.map((line, idx) => (
<li key={`gamepad-${idx}`} className="surface-muted px-0.5 py-0.25">
{renderLine(line, {})}
</li>
))}
</ul>
</div>
);
}
function BlockRenderer({ block, keymap }) {
if (!block) return null;
switch (block.type) {
case 'list':
return <ListBlock block={block} keymap={keymap} />;
case 'callout':
return <CalloutBlock block={block} />;
case 'keyboard':
return <KeyboardBlock block={block} keymap={keymap} />;
case 'gamepad':
return <GamepadBlock block={block} />;
default:
return null;
}
}
export function HelpContentView({ layout, keymap }) {
const content = useMemo(() => getHelpContent(layout), [layout]);
const bindings = keymap || {};
const mainBlocks = content.main || [];
const asideBlocks = content.aside || [];
return (
<div className="space-y-0.5">
<Hero hero={content.hero} keymap={bindings} />
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(360px, 1fr))' }}
>
<div className="space-y-0.5">
{mainBlocks.map((block, idx) => (
<BlockRenderer key={block.title || idx} block={block} keymap={bindings} />
))}
</div>
<div className="space-y-0.5">
{asideBlocks.map((block, idx) => (
<BlockRenderer key={block.title || idx} block={block} keymap={bindings} />
))}
</div>
</div>
</div>
);
}
export default HelpContentView;
+44
View File
@@ -0,0 +1,44 @@
import { useControlSystem } from '../controls/index.js';
import HelpContentView from './HelpContentView.jsx';
export default function HelpOverlay({ visible, layout, onClose, showOnLoad, onToggleShowOnLoad }) {
if (!visible) return null;
const { state } = useControlSystem();
const handleCheckbox = (event) => {
const keepShowing = !event.target.checked;
onToggleShowOnLoad?.(keepShowing);
};
return (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/80 px-0.5 py-0.5">
<div className="pointer-events-auto surface w-full max-w-4xl max-h-[90vh] overflow-hidden shadow-2xl">
<div className="flex items-center justify-between border-b border-slate-700 px-0.5 py-0.25 text-sm text-slate-200">
<span className="font-semibold">Help & controls</span>
<div className="flex items-center gap-0.5 text-[0.8rem] text-slate-300">
<label className="flex items-center gap-0.25">
<input
type="checkbox"
checked={!showOnLoad}
onChange={handleCheckbox}
className="accent-cyan-500"
/>
<span>Don&apos;t show again</span>
</label>
<button
type="button"
onClick={onClose}
className="button-dark px-2 py-0.25 text-[0.8rem]"
>
Close
</button>
</div>
</div>
<div className="max-h-[85vh] overflow-y-auto p-0.5">
<HelpContentView layout={layout} keymap={state?.keymap || {}} />
</div>
</div>
</div>
);
}
+18 -28
View File
@@ -1,32 +1,22 @@
export default function HelpPanel({ layout }) {
const presets = {
desktop: [
'Use WASD + Shift to drive. Video must stay focused.',
'Macros on the left prep the rover before moving.',
'Switch tabs on the right for telemetry, room, and advanced tools.',
],
'mobile-portrait': [
'Keep joystick centered when not driving.',
'Auxiliary buttons sit beside the joystick use them carefully.',
'Scroll to access drive/telemetry if screen space is limited.',
],
'mobile-landscape': [
'Joystick and video are side by side for game-style control.',
'Rotate back to portrait if you need admin controls quickly.',
'Tabs are desktop-only; mobile shows sections stacked.',
],
};
const tips = presets[layout] || presets.desktop;
import { useControlSystem } from '../controls/index.js';
import HelpContentView from './HelpContentView.jsx';
export default function HelpPanel({ layout, onOpenOverlay }) {
const { state } = useControlSystem();
return (
<section className="panel-section space-y-0.5 text-base">
<p className="text-sm text-slate-400">Help</p>
<ul className="space-y-0.5 text-sm">
{tips.map((tip) => (
<li key={tip} className="surface text-slate-200">
{tip}
</li>
))}
</ul>
<section className="panel-section space-y-0.5 text-sm">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Help</span>
<button
type="button"
onClick={onOpenOverlay}
className="button-dark px-1 py-0.25 text-[0.75rem]"
>
Open full help
</button>
</div>
<HelpContentView layout={layout} keymap={state?.keymap || {}} />
</section>
);
}
+2 -2
View File
@@ -7,7 +7,7 @@ import SettingsPanel from './SettingsPanel.jsx';
import HelpPanel from './HelpPanel.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
export default function RightPaneTabs({ layout }) {
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
return (
<section className="panel text-base">
<Tabs defaultTab="telemetry">
@@ -36,7 +36,7 @@ export default function RightPaneTabs({ layout }) {
<SettingsPanel />
</TabPanel>
<TabPanel id="help">
<HelpPanel layout={layout} />
<HelpPanel layout={layout} onOpenOverlay={onOpenHelpOverlay} />
</TabPanel>
</TabPanels>
</Tabs>
+8 -1
View File
@@ -3,6 +3,7 @@ import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
import { useSocket } from '../context/SocketContext.jsx';
import NicknameForm from './NicknameForm.jsx';
import DiscordInviteButton from './DiscordInviteButton.jsx';
function roleColors(role) {
switch (role) {
@@ -128,8 +129,14 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full min-h-0 flex-col overflow-hidden' : ''} ${className}`}
>
{!hideNicknameForm && (
<div className="space-y-0.5">
<div className="space-y-0.5 flex">
<div className='w-1/2'>
<NicknameForm />
</div>
<div className='w-1/2'>
<DiscordInviteButton />
</div>
{!canSetNickname && <p className="text-xs text-slate-500">Spectators cannot set nicknames.</p>}
</div>
)}
+206
View File
@@ -0,0 +1,206 @@
export const HELP_LAYOUTS = ['desktop', 'mobile-portrait', 'mobile-landscape'];
// Block-based help content; each layout defines a hero plus main/aside blocks.
// Text supports inline key pills via segments: strings or { action: 'driveMacro' }.
export const HELP_CONTENT = {
desktop: {
main: [
{
type: 'list',
title: 'Chat and nicknames',
items: [
'Set a nickname in the user list panel, on the bottom left of the page below the rover video.',
{ segments: ['Toggle chat focus with ', { action: 'chatFocus' }, '. Press ', { action: 'chatFocus'}, ' again to send.'] },
]
},
{
type: 'list',
title: 'Driving the rover',
items: [
{ segments: ['Press the "Start Driving" button onscreen, or press ' , { action: 'driveMacro' }, ' on your keyboard to put the rover into driving mode.'] },
'Refer to the controls for the controls for the rover.'
],
},
{
type: 'list',
title: 'Docking the rover',
items: [
'Line up the rover to the dock, about a foot away, then:',
{ segments: ['Press the "Dock and Charge" button onscreen, or press ', { action: 'dockMacro' }, ' on your keyboard to start docking.'] },
'Wait for the rover to confirm it is docked and charging before leaving it unattended.',
],
},
],
aside: [
{
type: 'keyboard',
title: 'Keyboard controls',
footnote: 'Per-browser; adjust in Settings → Keybindings.',
groups: [
{
id: 'movement',
title: 'Movement',
items: [
{ action: 'driveForward', label: 'Forward' },
{ action: 'driveBackward', label: 'Backward' },
{ action: 'driveLeft', label: 'Turn left' },
{ action: 'driveRight', label: 'Turn right' },
{ action: 'boostModifier', label: 'Boost speed' },
{ action: 'slowModifier', label: 'Precision speed' },
],
},
{
id: 'macros',
title: 'Rover modes & chat',
items: [
{ action: 'driveMacro', label: 'Drive macro' },
{ action: 'dockMacro', label: 'Dock macro' },
{ action: 'chatFocus', label: 'Chat focus' },
],
},
{
id: 'camera',
title: 'Camera',
items: [
{ action: 'cameraUp', label: 'Tilt up' },
{ action: 'cameraDown', label: 'Tilt down' },
{ action: 'nightVisionToggle', label: 'Toggle night vision' },
],
},
{
id: 'motors',
title: 'Rover Aux Motors',
items: [
{ action: 'auxMainForward', label: 'Main brush forward' },
{ action: 'auxMainReverse', label: 'Main brush reverse' },
{ action: 'auxSideForward', label: 'Side brush forward' },
{ action: 'auxSideReverse', label: 'Side brush reverse' },
{ action: 'auxVacuumFast', label: 'Vacuum max' },
{ action: 'auxVacuumSlow', label: 'Vacuum low' },
{ action: 'auxAllForward', label: 'All motors forward' },
],
},
],
},
{
type: 'gamepad',
title: 'Gamepad / joystick',
items: [
'Gamepad controls are not mapped by default, this is because of how terribly inconsistent gamepad implementations are across browsers and devices.',
'You can map gamepad controls in Settings → Controller.',
'Use at your own risk, it may not be perfect depending on your setup.'
],
},
],
},
'mobile-portrait': {
main: [
{
type: 'list',
title: 'Chat and nicknames',
items: [
'Set a nickname in the user list panel below.',
'Tap in the chat box to send messages in the chat.'
]
},
{
type: 'list',
title: 'Driving the rover',
items: [
{ segments: ['Press the "Start Driving" button onscreen to put the rover into driving mode.'] },
'Look below the rover video. Use the joystick on the right to move the rover, and hold the buttons on the left to run the aux motors.'
],
},
{
type: 'list',
title: 'Docking the rover',
items: [
'Line up the rover to the dock, about a foot away, then:',
{ segments: ['Press the "Dock and Charge" button onscreen to start docking.'] },
'Wait for the rover to confirm it is docked and charging before leaving it unattended.',
],
}
],
},
'mobile-landscape': {
main: [
{
type: 'list',
title: 'Chat and nicknames',
items: [
'Scroll down to see more of the page.',
'Set a nickname in the user list panel below.',
'Tap in the chat box to send messages in the chat.'
]
},
{
type: 'list',
title: 'Driving the rover',
items: [
{ segments: ['Press the "Start Driving" button, or the "Drive" button to put the rover into driving mode.'] },
'Use the joystick to the right of the video feed to move the rover, and hold the buttons on the left to run the aux motors.'
],
},
{
type: 'list',
title: 'Docking the rover',
items: [
'Line up the rover to the dock, about a foot away, then:',
{ segments: ['Press the "Dock and Charge" or the "Dock" button onscreen to start docking.'] },
'Wait for the rover to confirm it is docked and charging before leaving it unattended.',
],
}
],
// aside: [
// {
// type: 'keyboard',
// title: 'Keyboard (if attached)',
// footnote: 'Per-browser; adjust in Settings → Controls.',
// groups: [
// {
// id: 'movement',
// title: 'Movement',
// items: [
// { action: 'driveForward', label: 'Forward' },
// { action: 'driveBackward', label: 'Backward' },
// { action: 'driveLeft', label: 'Turn left' },
// { action: 'driveRight', label: 'Turn right' },
// { action: 'boostModifier', label: 'Boost' },
// { action: 'slowModifier', label: 'Precision' },
// ],
// },
// {
// id: 'camera',
// title: 'Camera',
// items: [
// { action: 'cameraUp', label: 'Tilt up' },
// { action: 'cameraDown', label: 'Tilt down' },
// ],
// },
// {
// id: 'macros',
// title: 'Macros & chat',
// items: [
// { action: 'driveMacro', label: 'Drive macro' },
// { action: 'dockMacro', label: 'Dock macro' },
// { action: 'nightVisionToggle', label: 'Toggle night vision' },
// { action: 'chatFocus', label: 'Chat focus' },
// ],
// },
// ],
// },
// {
// type: 'gamepad',
// title: 'Gamepad / joystick',
// items: [
// 'Left stick drives; right stick/D-pad: camera when mapped.',
// 'Buttons can trigger macros or aux motors; adjust deadzones if drifting.',
// ],
// },
// ],
},
};
export function getHelpContent(layout) {
return HELP_CONTENT[layout] || HELP_CONTENT.desktop;
}
+1 -1
View File
@@ -71,7 +71,7 @@ body {
270deg,
#ff0000,
#ff7f00,
#ffff00,
#ffff00b3,
#00ff00,
#0000ff,
#4b0082,