/display page woooo

This commit is contained in:
legop3
2026-06-08 20:51:51 -04:00
parent 031e23103f
commit 7d41e0ae11
14 changed files with 504 additions and 134 deletions
@@ -0,0 +1,28 @@
// Display Chat Feed
// Purpose: Shows the existing chat panel scaled for distance viewing.
// Scope: Uses ChatPanel minimal mode so the display route reuses normal chat behavior without input chrome.
import ChatPanel from '../../../components/ChatPanel/index.jsx';
export default function DisplayChatFeed() {
const displayChatScale = 2.35;
return (
<section className="h-full min-h-0 overflow-hidden border-t border-slate-800 bg-black p-[0.45vw]">
<div
className="origin-top-left"
style={{
// ChatPanel is shared with the normal UI and intentionally compact.
// Scaling the whole panel keeps message rendering, scrolling, typing
// rows, and future chat behavior in one place while making the display
// readable. Width and height compensation reserve the unscaled layout
// space that becomes exactly one full chat band after transform.
width: `calc(100% / ${displayChatScale})`,
height: `calc(100% / ${displayChatScale})`,
transform: `scale(${displayChatScale})`,
}}
>
<ChatPanel minimal fillHeight hideSpectatorNotice />
</div>
</section>
);
}
@@ -0,0 +1,105 @@
// Display Rover Cell
// Purpose: Shows one rover's room-readable driver and battery status.
// Scope: Reuses shared rover/battery presentation primitives while avoiding controls, queues, and video.
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import BatteryBar from '../../../components/BatteryBar/index.jsx';
import RoverLabel from '../../../components/RoverLabel/index.jsx';
import AutoFitText from '../../../mini/MiniSummaryApp/components/AutoFitText.jsx';
import {
buildRoverStateText,
findDriverForRover,
formatBatteryText,
getDisplayBatteryVisual,
} from '../utils.js';
function classNames(...values) {
return values.filter(Boolean).join(' ');
}
export default function DisplayRoverCell({ rover, session }) {
const frame = useTelemetryFrame(rover?.id);
const visual = getDisplayBatteryVisual({ rover, frame });
const driver = findDriverForRover({ roverId: rover?.id, session });
const stateText = buildRoverStateText(rover, visual);
const batteryText = formatBatteryText(visual);
const active = Boolean(driver);
const urgent = Boolean(visual?.urgentActive);
const warn = Boolean(visual?.warnActive);
const locked = Boolean(rover?.locked);
return (
<article
className={classNames(
'relative min-h-0 overflow-hidden border border-slate-800 bg-black',
locked ? 'border-red-400 ring-4 ring-red-500/80' : '',
!locked && active ? 'ring-2 ring-sky-400/80' : '',
!locked && urgent ? 'ring-4 ring-red-500/90' : !locked && warn ? 'ring-2 ring-amber-300/80' : '',
)}
>
<BatteryBar visual={visual} variant="background" orientation="vertical" />
<div className="relative z-10 grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] gap-[0.55vh] p-[0.85vw] text-center">
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-[0.8vw]">
<div className="min-w-0">
<RoverLabel
rover={rover}
fallback={rover?.id}
as={AutoFitText}
className="leading-none"
maxSize={76}
minSize={20}
/>
</div>
{locked ? (
// Keep the lock warning adjacent to the rover identity so the cell
// remains readable while still making the locked state impossible
// to miss at a glance.
<div className="max-w-[36vw] border-4 border-red-100 bg-red-700 px-[0.9vw] py-[0.45vh] text-center text-[clamp(1.6rem,4.2vh,4.4rem)] font-black leading-none text-white">
<div>LOCKED</div>
{rover?.lockReason ? (
<div className="mt-[0.3vh] text-[clamp(1rem,2.4vh,2.5rem)] leading-none text-red-50">
{rover.lockReason}
</div>
) : null}
</div>
) : null}
</div>
<div className="grid min-h-0 min-w-0 grid-cols-[minmax(0,1.45fr)_minmax(0,0.85fr)] items-center gap-[1vw]">
<div className="min-w-0">
<AutoFitText
className={classNames(
'font-black leading-none',
active ? 'text-white' : 'text-slate-400',
)}
maxSize={128}
minSize={28}
>
{driver?.label || 'Idle'}
</AutoFitText>
</div>
<div className="min-w-0">
<AutoFitText
className={classNames(
'font-black leading-none',
urgent ? 'text-red-100' : warn ? 'text-amber-100' : 'text-slate-100',
)}
maxSize={100}
minSize={24}
>
{batteryText}
</AutoFitText>
</div>
</div>
{stateText ? (
<div className="min-w-0 text-[clamp(0.95rem,1.8vh,1.8rem)] font-black leading-none text-amber-100">
{stateText}
</div>
) : (
// This empty line keeps cells with and without exceptional states the
// same height. The grid should not jump just because one rover becomes
// locked or low battery while people are reading the board.
<div aria-hidden="true" />
)}
</div>
</article>
);
}
@@ -0,0 +1,23 @@
// Display Rover Grid
// Purpose: Fills the central 16:10 display band with rover driver/battery cells.
// Scope: Keeps rover status presentation dense and label-free for room readability.
import DisplayRoverCell from './DisplayRoverCell.jsx';
import { gridClassForRoverCount } from '../utils.js';
export default function DisplayRoverGrid({ roster = [], session }) {
if (!roster.length) {
return (
<section className="flex h-full min-h-0 items-center justify-center border-b border-slate-800 bg-black text-[clamp(2.5rem,7vh,7rem)] font-black text-slate-500">
No rovers
</section>
);
}
return (
<section className={`grid h-full min-h-0 ${gridClassForRoverCount(roster.length)} auto-rows-fr gap-0.5 bg-slate-950 p-0.5`}>
{roster.map((rover) => (
<DisplayRoverCell key={rover.id} rover={rover} session={session} />
))}
</section>
);
}
@@ -0,0 +1,91 @@
// Online People Strip
// Purpose: Renders a one-line, label-free list of online people for the room display.
// Scope: Owns overflow detection and marquee-style motion without changing shared user-list components.
import { useEffect, useMemo, useRef, useState } from 'react';
import { formatUserName } from '../utils.js';
function classNames(...values) {
return values.filter(Boolean).join(' ');
}
export default function OnlinePeopleStrip({ users = [] }) {
const viewportRef = useRef(null);
const trackRef = useRef(null);
const contentRef = useRef(null);
const [overflowing, setOverflowing] = useState(false);
const names = useMemo(
() =>
users
// The display page itself enters spectator mode, and other passive
// spectators are usually not relevant to people physically in the room.
// Filtering them keeps the top strip focused on active participants.
.filter((user) => user?.role !== 'spectator')
.map(formatUserName)
.filter(Boolean)
.sort((a, b) => a.localeCompare(b)),
[users],
);
useEffect(() => {
const viewport = viewportRef.current;
const content = contentRef.current;
if (!viewport || !content) return undefined;
const updateOverflow = () => {
// The strip only moves when it needs to. A still row is easier to read
// when the current online set already fits on the 16:10 display.
setOverflowing(content.scrollWidth > viewport.clientWidth + 4);
};
updateOverflow();
const resizeObserver = new ResizeObserver(updateOverflow);
resizeObserver.observe(viewport);
resizeObserver.observe(content);
return () => resizeObserver.disconnect();
}, [names]);
useEffect(() => {
const track = trackRef.current;
if (!track || !overflowing) return undefined;
// This animation is local to the display route, so it is created directly
// on the strip element instead of adding route-specific keyframes to the
// global stylesheet. The duplicated name row means -50% lands exactly at
// the start of the second copy, producing a continuous readable loop.
const animation = track.animate(
[{ transform: 'translateX(0)' }, { transform: 'translateX(-50%)' }],
{ duration: 42000, iterations: Infinity, easing: 'linear' },
);
return () => animation.cancel();
}, [overflowing, names]);
const renderedNames = names.length ? names : ['No one online'];
const itemNodes = renderedNames.map((name, index) => (
<span key={`${name}-${index}`} className="shrink-0 px-[1.6vw] font-black tracking-normal text-slate-100">
{name}
</span>
));
return (
<div ref={viewportRef} className="relative h-full min-w-0 overflow-hidden border-b border-slate-800/80 bg-black">
<div
ref={trackRef}
className={classNames(
'flex h-full w-max items-center whitespace-nowrap text-[clamp(2rem,4.2vh,4.6rem)] leading-none',
)}
>
<div ref={contentRef} className="flex h-full items-center">
{itemNodes}
</div>
{overflowing ? (
// The duplicate row makes the loop continuous. It is hidden from assistive
// tech because it is purely mechanical animation, not extra information.
<div className="flex h-full items-center" aria-hidden="true">
{itemNodes}
</div>
) : null}
</div>
</div>
);
}