rover based colors!

This commit is contained in:
legop3
2026-05-21 20:49:01 -04:00
parent d72bc0db03
commit 7e7b07083a
27 changed files with 268 additions and 169 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="Roomba Rover" />
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-D13hsMAR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dva9v7lV.css">
<script type="module" crossorigin src="/assets/index-tQBHQF7J.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C-j0HVkU.css">
</head>
<body>
<div id="root"></div>
@@ -127,7 +127,7 @@ export default function ButtonBoxPanel() {
}, [effectiveAlertVolume, socket]);
return (
<CardFrame title="Button Box" accent="#eab308" bodyClassName="space-y-0.5 text-base">
<CardFrame title="Button Box" bodyClassName="space-y-0.5 text-base">
<div className="grid grid-cols-4 gap-0.5">
{buttons.map((button) => {
const id = Number(button.id);
+22 -8
View File
@@ -1,7 +1,11 @@
import { useSessionSelector } from '../../context/SessionContext.jsx';
// Utilities
function cx(...values) {
return values.filter(Boolean).join(' ');
}
// Color helpers
function hexToRgb(hex) {
const raw = String(hex || '').trim();
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
@@ -24,21 +28,29 @@ function rgba(rgb, alpha) {
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
// Component
export default function CardFrame({
title = '',
meta = null,
actions = null,
accent = null,
hideHeader = false,
className = '',
headerClassName = '',
bodyClassName = '',
fillHeight = false,
clipOverflow = true,
children,
}) {
const showHeader = !hideHeader && (title || meta != null || actions);
const accentRgb = hexToRgb(accent);
const cardStyle = accentRgb ? { borderColor: rgba(accentRgb, 0.65) } : undefined;
const ownRoverColor = useSessionSelector((state) => {
const roverId = String(state.session?.assignment?.roverId || '').trim();
if (!roverId) return null;
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
const rover = roster.find((entry) => String(entry?.id) === roverId);
return rover?.color || null;
});
const accentRgb = hexToRgb(ownRoverColor);
const cardStyle = accentRgb ? { borderColor: rgba(accentRgb, 0.35) } : undefined;
const headerStyle = accentRgb
? {
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
@@ -48,28 +60,30 @@ export default function CardFrame({
return (
<section
className={cx(
'panel-section overflow-hidden border border-neutral-600/55 bg-neutral-900/95 shadow-[0_1px_0_rgba(255,255,255,0.04)_inset,0_10px_24px_rgba(0,0,0,0.3)]',
'panel-section border border-neutral-500/60 bg-neutral-900/95 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
clipOverflow ? 'overflow-hidden' : 'overflow-visible',
fillHeight && 'flex h-full min-h-0 flex-col',
className,
)}
style={cardStyle}
>
{showHeader ? (
// Header row
<header
className={cx(
'flex items-center justify-between gap-0.5 border-b border-neutral-600/45 bg-gradient-to-r from-neutral-900 via-neutral-800 to-neutral-700 px-0.5 py-0.5',
'flex items-center justify-between gap-0.5 border-b border-neutral-500/50 bg-gradient-to-r from-neutral-800 via-neutral-700 to-neutral-600 px-0.5 py-0.5',
headerClassName,
)}
style={headerStyle}
>
<div className="flex min-w-0 items-center gap-0.5">
{title ? <p className="m-0 text-[0.78rem] font-semibold leading-none text-neutral-100">{title}</p> : null}
{meta != null ? <span className="text-[0.68rem] font-medium leading-none text-neutral-300">{meta}</span> : null}
{title ? <p className="m-0 text-[0.78rem] font-semibold leading-none text-neutral-50">{title}</p> : null}
{meta != null ? <span className="text-[0.68rem] font-medium leading-none text-neutral-200">{meta}</span> : null}
</div>
{actions ? <div className="flex flex-wrap items-center justify-end gap-0.5">{actions}</div> : null}
</header>
) : null}
<div className={cx('', fillHeight && 'flex flex-1 min-h-0 flex-col', bodyClassName)}>{children}</div>
<div className={cx(fillHeight && 'flex flex-1 min-h-0 flex-col', bodyClassName)}>{children}</div>
</section>
);
}
+1 -1
View File
@@ -109,7 +109,7 @@ export default function ChatPanel({
return (
<CardFrame
title={title}
accent="#22c55e"
hideHeader={!title}
fillHeight={fillHeight}
bodyClassName="space-y-0.5 text-base"
@@ -8,11 +8,40 @@ const MOBILE_DISMISS_MS = 10000;
const MAX_FONT_PX = 28;
const MIN_FONT_PX = 14;
function hexToRgb(hex) {
const raw = String(hex || '').trim();
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(normalized)) return null;
const expanded =
normalized.length === 3
? normalized
.split('')
.map((ch) => ch + ch)
.join('')
: normalized;
return {
r: Number.parseInt(expanded.slice(0, 2), 16),
g: Number.parseInt(expanded.slice(2, 4), 16),
b: Number.parseInt(expanded.slice(4, 6), 16),
};
}
function rgba(rgb, alpha) {
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
export default function GlobalObjectiveBanner({ layout = 'desktop', className = '', dismissable = true }) {
const goalText = useSessionSelector((state) => {
const text = state.session?.globalObjective?.text;
return text ? String(text).trim() : '';
});
const ownRoverColor = useSessionSelector((state) => {
const roverId = String(state.session?.assignment?.roverId || '').trim();
if (!roverId) return null;
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
const rover = roster.find((entry) => String(entry?.id) === roverId);
return rover?.color || null;
});
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape' || layout === 'mobile';
const [visible, setVisible] = useState(false);
const [fontSize, setFontSize] = useState(MAX_FONT_PX);
@@ -72,7 +101,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
const containerClass = useMemo(
() =>
[
'panel-section flex w-full items-center justify-center',
'panel-section flex w-full items-center justify-center border border-neutral-500/60 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
isMobile ? 'rounded-none' : 'rounded',
'px-1 py-1 text-center font-semibold tracking-tight',
className,
@@ -81,6 +110,13 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
.join(' '),
[className, isMobile],
);
const accentRgb = hexToRgb(ownRoverColor);
const frameStyle = accentRgb
? {
borderColor: rgba(accentRgb, 0.35),
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
}
: undefined;
if (!goalText || !visible) return null;
@@ -94,7 +130,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
<div
ref={containerRef}
className={containerClass}
style={{ fontSize: `${fontSize}px`, lineHeight: 1.1 }}
style={{ ...(frameStyle || {}), fontSize: `${fontSize}px`, lineHeight: 1.1 }}
{...dismissProps}
>
<span className="flex w-full items-stretch gap-0.5 whitespace-nowrap rounded-md">
@@ -256,7 +256,7 @@ export default function HomeAssistantControls() {
if (!ha?.enabled) {
return (
<CardFrame title="Room Controls" accent="#14b8a6" bodyClassName="space-y-0.5 text-sm text-slate-400">
<CardFrame title="Room Controls" bodyClassName="space-y-0.5 text-sm text-slate-400">
<p className="text-slate-500">Not configured on the server.</p>
</CardFrame>
);
@@ -264,7 +264,7 @@ export default function HomeAssistantControls() {
if (entities.length === 0) {
return (
<CardFrame title="Room Controls" accent="#14b8a6" bodyClassName="space-y-0.5 text-sm text-slate-400">
<CardFrame title="Room Controls" bodyClassName="space-y-0.5 text-sm text-slate-400">
<p className="text-slate-500">No lights or switches configured.</p>
</CardFrame>
);
@@ -290,7 +290,7 @@ export default function HomeAssistantControls() {
);
return (
<CardFrame title="Room Controls" accent="#14b8a6" actions={actions} bodyClassName="space-y-0.5 text-base">
<CardFrame title="Room Controls" actions={actions} bodyClassName="space-y-0.5 text-base">
{controlsLocked ? (
<p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
{lockState === 'off'
+1 -1
View File
@@ -9,7 +9,7 @@ export default function LogPanel() {
const logs = useSessionSelector((state) => state.logs);
const rendered = useMemo(() => logs.slice().reverse(), [logs]);
return (
<CardFrame title="Server logs" accent="#f97316" bodyClassName="space-y-0.5 text-base">
<CardFrame title="Server logs" bodyClassName="space-y-0.5 text-base">
<div className="surface h-64 overflow-y-auto font-mono text-xs">
{logs.length === 0 ? (
<p>No logs yet.</p>
@@ -22,7 +22,7 @@ export default function OverseerPreferencePanel() {
};
return (
<CardFrame title="Overseer vote" accent="#10b981" bodyClassName="space-y-0.5 text-center text-xs text-slate-200">
<CardFrame title="Overseer vote" bodyClassName="space-y-0.5 text-center text-xs text-slate-200">
<label
className={`flex items-center justify-center gap-1.5 rounded px-1 py-0.5 ${
enabled ? 'bg-emerald-600/80 text-emerald-50' : 'bg-slate-600/70 text-slate-100'
@@ -60,7 +60,7 @@ export default function RawUserPilePanel({
return (
<CardFrame
title={!hideHeader ? 'Users' : ''}
accent="#94a3b8"
hideHeader={hideHeader}
fillHeight={fillHeight}
className={className}
@@ -150,7 +150,7 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
const listWrapClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : '';
return (
<CardFrame title="Replay Sources" accent="#06b6d4" fillHeight={fillHeight} bodyClassName="space-y-0.5 text-sm">
<CardFrame title="Replay Sources" fillHeight={fillHeight} bodyClassName="space-y-0.5 text-sm">
<div className={`grid gap-0.5 md:grid-cols-2 ${listWrapClass}`}>
<GroupList title="Rovers" items={grouped.rovers} selected={selected} onToggle={toggleKey} />
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
+1 -1
View File
@@ -36,7 +36,7 @@ function TopDownMapPanel() {
const sensors = frame?.sensors || {};
return (
<CardFrame hideHeader accent="#0067f7">
<CardFrame hideHeader>
<div className="aspect-square w-full">
<TopDownMap sensors={sensors} />
</div>
@@ -88,7 +88,7 @@ export default function RoomCameraPanel({
return (
<CardFrame
title="Room cameras"
accent="#38bdf8"
actions={actions}
hideHeader={hideHeader}
bodyClassName="space-y-0.5 text-base"
@@ -97,7 +97,7 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null };
return (
<CardFrame title={title} accent="#f59e0b" bodyClassName="space-y-0.5 text-sm">
<CardFrame title={title} bodyClassName="space-y-0.5 text-sm">
{rosterItems.length === 0 ? (
<p className="text-sm text-slate-500">No rovers registered.</p>
) : (
+49 -1
View File
@@ -2,6 +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 { useSessionSelector } from '../../context/SessionContext.jsx';
const TabsContext = createContext(null);
@@ -33,6 +34,28 @@ function classNames(...parts) {
return parts.filter(Boolean).join(' ');
}
function hexToRgb(hex) {
const raw = String(hex || '').trim();
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(normalized)) return null;
const expanded =
normalized.length === 3
? normalized
.split('')
.map((ch) => ch + ch)
.join('')
: normalized;
return {
r: Number.parseInt(expanded.slice(0, 2), 16),
g: Number.parseInt(expanded.slice(2, 4), 16),
b: Number.parseInt(expanded.slice(4, 6), 16),
};
}
function rgba(rgb, alpha) {
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
export default function Tabs({ children, defaultTab, currentTab, onTabChange, variant = DEFAULT_VARIANT }) {
const [internalTab, setInternalTab] = useState(defaultTab ?? null);
const [tabOrder, setTabOrder] = useState([]);
@@ -95,7 +118,32 @@ export default function Tabs({ children, defaultTab, currentTab, onTabChange, va
}
export function TabList({ children, className = '' }) {
return <div className={classNames('flex gap-0.5', className)}>{children}</div>;
const ownRoverColor = useSessionSelector((state) => {
const roverId = String(state.session?.assignment?.roverId || '').trim();
if (!roverId) return null;
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
const rover = roster.find((entry) => String(entry?.id) === roverId);
return rover?.color || null;
});
const accentRgb = hexToRgb(ownRoverColor);
const frameStyle = accentRgb ? { borderColor: rgba(accentRgb, 0.35) } : undefined;
const headerStyle = accentRgb
? {
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
}
: undefined;
return (
<div
className={classNames(
'panel-section overflow-hidden border border-neutral-500/60 bg-neutral-900/95 p-0.5 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
className
)}
style={frameStyle}
>
<div className="flex gap-0.5" style={headerStyle}>{children}</div>
</div>
);
}
export function Tab({ id, children, className = '', disabled = false, highlight = 'none' }) {
@@ -5,6 +5,7 @@ import { useMemo } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
import { useDockIr } from '../../hooks/useDockIr.js';
import CardFrame from '../CardFrame/index.jsx';
function formatMetric(value, fallback = '--') {
if (value == null || value === '') return fallback;
@@ -39,7 +40,7 @@ export default function TelemetryPanel() {
}, [activeDriverId, roverId, selfSocketId, users]);
return (
<section className="panel-section space-y-0.5 text-base text-slate-100">
<CardFrame hideHeader clipOverflow={false} bodyClassName="space-y-0.5 text-base text-slate-100">
{/* <div className="text-sm text-slate-400">
<span>{connected ? 'online' : 'offline'}</span>
<span> · role {session?.role || 'unknown'}</span>
@@ -60,7 +61,7 @@ export default function TelemetryPanel() {
{rawSnippet && (
<pre className="surface whitespace-pre-wrap break-words text-xs text-lime-300">{rawSnippet}</pre>
)}
</section>
</CardFrame>
);
}
+1 -1
View File
@@ -20,7 +20,7 @@ export function NicknameEntryPanel({ compact = false }) {
export function LinkButtonsPanel() {
return (
<CardFrame title="Socials" accent="#3030f5" fillHeight bodyClassName="flex flex-1 min-h-0 flex-col gap-0.5 text-base">
<CardFrame title="Socials" fillHeight bodyClassName="flex flex-1 min-h-0 flex-col gap-0.5 text-base">
<SocialButtonsGrid className="flex-1 min-h-0" />
</CardFrame>
);
@@ -530,7 +530,7 @@ export default function VipAudioUploadCard({
);
return (
<CardFrame title="Audio Controls" accent="#a78bfa">
<CardFrame title="Audio Controls">
<div className="grid gap-1">
<section className="surface">
<div className="flex items-center justify-center gap-0.5 py-0.25 text-xs text-slate-300">
+1 -1
View File
@@ -41,7 +41,7 @@ export default function VipIdentityCard({ currentStoredKey, applyIdentityKey, on
const wrapClass = fullWidth ? 'w-full' : flowWrapClass;
return (
<CardFrame title="Identity key" accent="#60a5fa" className={wrapClass}>
<CardFrame title="Identity key" className={wrapClass}>
<div className={innerFlowClass}>
<p className="text-xs text-slate-500">Current: {maskKey(currentStoredKey) || 'not set yet'}</p>
<input
+1 -1
View File
@@ -70,7 +70,7 @@ export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
return (
<CardFrame
title="Lift Controls"
accent="#06b6d4"
className={`relative ${wrapClass}`}
bodyClassName="text-sm text-slate-200"
actions={
+1 -1
View File
@@ -101,7 +101,7 @@ export default function VipNeatoCard({
return (
<CardFrame
title="Neato Controls"
accent="#22c55e"
className={wrapClass}
bodyClassName="text-sm text-slate-200"
actions={
@@ -43,7 +43,7 @@ export default function VipPrivateRoverAccessCard({
};
return (
<CardFrame title="Private rover access requests" accent="#f43f5e" className={wrapClass} bodyClassName="text-sm text-slate-300">
<CardFrame title="Private rover access requests" className={wrapClass} bodyClassName="text-sm text-slate-300">
<div className={innerFlowClass}>
{requestableRovers.length === 0 ? (
<p className="text-xs text-slate-500">No closed private rovers are available to request right now.</p>
@@ -55,7 +55,7 @@ export default function VipProfileImageCard({ isVerified = false, fullWidth = fa
};
return (
<CardFrame title="Chat profile image URL" accent="#f59e0b" className={wrapClass} bodyClassName="text-sm text-slate-300">
<CardFrame title="Chat profile image URL" className={wrapClass} bodyClassName="text-sm text-slate-300">
<form className={innerFlowClass} onSubmit={handleSave}>
<p className="text-xs text-slate-500">
Verified users can set a custom avatar for chat and Discord bridge messages.
@@ -62,7 +62,7 @@ export default function VipVerificationCard({
if (pendingRequestId) {
return (
<CardFrame title="Verification" accent="#eab308" className={wrapClass} bodyClassName="text-sm text-slate-300">
<CardFrame title="Verification" className={wrapClass} bodyClassName="text-sm text-slate-300">
<div className={innerFlowClass}>Verification request pending: {pendingRequestId}</div>
</CardFrame>
);
@@ -70,7 +70,7 @@ export default function VipVerificationCard({
if (requestFlowStep === 0) {
return (
<CardFrame title="Verification" accent="#eab308" className={wrapClass}>
<CardFrame title="Verification" className={wrapClass}>
<div className={innerFlowClass}>
<button type="button" className="button-dark text-sm" onClick={beginRequestFlow} disabled={working}>
Request Verification
@@ -81,7 +81,7 @@ export default function VipVerificationCard({
}
return (
<CardFrame title="Request verification" accent="#eab308" className={wrapClass}>
<CardFrame title="Request verification" className={wrapClass}>
<form onSubmit={handleRequestSubmit}>
<div className={innerFlowClass}>
<p className="text-xs text-slate-500">