This commit is contained in:
legop3
2026-09-12 16:16:59 -04:00
parent 99d2a7689f
commit fb31ff52bd
28 changed files with 545 additions and 88 deletions
@@ -0,0 +1,82 @@
// Auto Fit Text
// Purpose: Sizes one unwrapped label to the largest font that fits its container.
// Scope: Supports the existing width-only labels and full-box overlays from one shared implementation.
import { useLayoutEffect, useRef, useState } from 'react';
export default function AutoFitText({
children,
className = '',
containerClassName = '',
maxSize = 1000,
minSize = 14,
fitHeight = false,
style = undefined,
}) {
const containerRef = useRef(null);
const textRef = useRef(null);
const [fontSize, setFontSize] = useState(maxSize);
useLayoutEffect(() => {
const container = containerRef.current;
const textEl = textRef.current;
if (!container || !textEl) return undefined;
let animationFrame = null;
const fit = () => {
const width = container.clientWidth;
const height = container.clientHeight;
if (!width || (fitHeight && !height)) {
scheduleFit();
return;
}
// Binary search avoids stepping through hundreds of possible font sizes.
// Overlay labels test both axes; ordinary rover labels preserve their
// previous width-only behavior so this shared move changes no layout.
let low = minSize;
let high = maxSize;
let best = minSize;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
textEl.style.fontSize = `${middle}px`;
const fitsWidth = textEl.scrollWidth <= width;
const fitsHeight = !fitHeight || textEl.scrollHeight <= height;
if (fitsWidth && fitsHeight) {
best = middle;
low = middle + 1;
} else {
high = middle - 1;
}
}
setFontSize(best);
};
const scheduleFit = () => {
if (animationFrame) cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(fit);
};
scheduleFit();
const observer = new ResizeObserver(scheduleFit);
observer.observe(container);
return () => {
if (animationFrame) cancelAnimationFrame(animationFrame);
observer.disconnect();
};
}, [children, fitHeight, maxSize, minSize]);
return (
<div
ref={containerRef}
className={`${fitHeight ? 'flex h-full items-center justify-center' : ''} w-full min-w-0 ${containerClassName}`}
>
<div
ref={textRef}
className={`whitespace-nowrap ${className}`}
style={{ fontSize: `${fontSize}px`, lineHeight: fitHeight ? 1 : 1.1, ...(style || {}) }}
>
{children}
</div>
</div>
);
}
@@ -3,6 +3,7 @@
// Scope: Owns row chrome, queue chips, timer labels, and row/button event plumbing;
// callers still own target-specific permission checks and request actions.
import RoverLabel from '../RoverLabel/index.jsx';
import RoverHelpOverlay from '../RoverHelpOverlay/index.jsx';
function classNames(...values) {
return values.filter(Boolean).join(' ');
@@ -105,7 +106,7 @@ export default function QueueTargetRow({
return (
<li
className={classNames(
'surface flex flex-wrap items-start justify-between gap-0.5',
'surface relative flex flex-wrap items-start justify-between gap-0.5 overflow-hidden',
canClick && 'cursor-pointer',
locked
? 'bg-red-900/40'
@@ -191,6 +192,9 @@ export default function QueueTargetRow({
{buttonLabel}
</button>
) : null}
{/* Queue rows use the exact overlay component as the large video and
display surfaces; its measured font automatically adapts to this box. */}
<RoverHelpOverlay active={Boolean(target?.needsHelp)} />
</li>
);
}
@@ -0,0 +1,26 @@
// Rover Help Overlay
// Purpose: Gives every rover surface one unmistakable, responsive HELP treatment.
// Scope: Renders only the shared visual; server roster state decides when it is active.
import AutoFitText from '../AutoFitText/index.jsx';
import './styles.css';
export default function RoverHelpOverlay({ active = false }) {
if (!active) return null;
return (
<div
className="rover-help-overlay pointer-events-none absolute inset-0 z-50 overflow-hidden border-[clamp(0.2rem,1.2cqw,1rem)] border-white bg-red-600 p-[clamp(0.2rem,2cqw,1.5rem)] text-white"
role="status"
aria-label="Rover needs help"
>
<AutoFitText
fitHeight
minSize={8}
maxSize={1400}
className="font-black tracking-tight text-white drop-shadow-[0_0_0.08em_rgba(0,0,0,1)]"
>
HELP
</AutoFitText>
</div>
);
}
@@ -0,0 +1,12 @@
/* A one-second stepped cycle is urgent and room-readable without becoming a
rapid strobe. Opacity applies to the entire shared treatment, so its border,
background, and auto-fit word flash together on every rover surface. */
@keyframes rover-help-flash {
0%, 49% { opacity: 0.96; }
50%, 100% { opacity: 0.12; }
}
.rover-help-overlay {
container-type: size;
animation: rover-help-flash 1s steps(1, end) infinite;
}
@@ -4,6 +4,7 @@ import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/inde
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
import VerticalBatteryOverlay from '../HudOverlays/VerticalBatteryOverlay/index.jsx';
import RoverHelpOverlay from '../RoverHelpOverlay/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
export default function SpectateVideo({
@@ -11,6 +12,7 @@ export default function SpectateVideo({
label,
fitParent = false,
layoutFormat = 'desktop',
needsHelp = false,
}) {
const isExternalSpectatorSnapshotOnly = useSessionSelector((state) =>
state.session?.role === 'spectator' &&
@@ -46,6 +48,7 @@ export default function SpectateVideo({
<OvercurrentOverlay roverId={roverId} compact={false} />
<LowBatteryOverlay roverId={roverId} compact={false} />
<VerticalBatteryOverlay show roverId={roverId} mobileHud={false} />
<RoverHelpOverlay active={needsHelp} />
</div>
</div>
);