From 6f48edb1aa9657a386935e41aa34434cd6f382aa Mon Sep 17 00:00:00 2001 From: Anders Hafreager Date: Sat, 15 Aug 2026 21:55:23 +0200 Subject: [PATCH 1/2] feat: fullscreen mode for the simulation viewport A toggle in the viewport's top-right corner expands the 3D view to fill the screen via the Fullscreen API, falling back to a fixed in-app overlay (Esc exits both). The script/progress overlay and the toggle stay visible in fullscreen. Fixes #410 Co-Authored-By: Claude Fable 5 --- src/shell/RunDetail.tsx | 110 +++++++++++++++++++++++++++++++++++++--- src/shell/icons.tsx | 14 +++++ 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/src/shell/RunDetail.tsx b/src/shell/RunDetail.tsx index 88085c00..b74de024 100644 --- a/src/shell/RunDetail.tsx +++ b/src/shell/RunDetail.tsx @@ -15,6 +15,8 @@ import { BackIcon, ChartIcon, ChevronDownIcon, + CompressIcon, + ExpandIcon, FileIcon, PlayIcon, StopIcon, @@ -80,6 +82,63 @@ const RunDetail = ({ runId }: { runId: string }) => { return next; }); + // Fullscreen viewport: native Fullscreen API when available, an in-app + // fixed overlay ("immersive") when it is not (or the request is denied). + const viewportRef = useRef(null); + const [nativeFullscreen, setNativeFullscreen] = useState(false); + const [immersive, setImmersive] = useState(false); + const expanded = nativeFullscreen || immersive; + + useEffect(() => { + const onChange = () => + setNativeFullscreen( + document.fullscreenElement != null && + document.fullscreenElement === viewportRef.current, + ); + document.addEventListener("fullscreenchange", onChange); + return () => document.removeEventListener("fullscreenchange", onChange); + }, []); + + useEffect(() => { + if (!immersive) { + return; + } + // Native fullscreen exits on Esc by itself; the overlay needs a handler. + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setImmersive(false); + } + }; + window.addEventListener("keydown", onKey); + // The overlay resizes the viewport without a window resize; nudge + // listeners (omovi sizes the canvas off resize events). + window.dispatchEvent(new Event("resize")); + return () => { + window.removeEventListener("keydown", onKey); + window.dispatchEvent(new Event("resize")); + }; + }, [immersive]); + + const toggleFullscreen = () => { + const element = viewportRef.current; + if (!element) { + return; + } + if (nativeFullscreen) { + void document.exitFullscreen().catch(() => {}); + return; + } + if (immersive) { + setImmersive(false); + return; + } + if (typeof element.requestFullscreen === "function") { + element.requestFullscreen().catch(() => setImmersive(true)); + } else { + setImmersive(true); + } + }; + // Finished runs: persisted log + frame + output list from storage. useEffect(() => { if (live || !dirName) { @@ -337,14 +396,25 @@ const RunDetail = ({ runId }: { runId: string }) => { {/* Viewport */}
{live ? ( @@ -470,6 +540,32 @@ const RunDetail = ({ runId }: { runId: string }) => { )}
+ {/* Fullscreen toggle */} + {/* Console strip (collapsible: the header bar always stays visible, diff --git a/src/shell/icons.tsx b/src/shell/icons.tsx index 681c7a99..5abb495f 100644 --- a/src/shell/icons.tsx +++ b/src/shell/icons.tsx @@ -251,6 +251,20 @@ export const ChevronRightIcon = ({ size = 16 }: IconProps) => export const BackIcon = ({ size = 14 }: IconProps) => stroke(size, , 2); +export const ExpandIcon = ({ size = 15 }: IconProps) => + stroke( + size, + , + 2, + ); + +export const CompressIcon = ({ size = 15 }: IconProps) => + stroke( + size, + , + 2, + ); + export const DotsIcon = ({ size = 16 }: IconProps) => ( From 2704a7fb2084ea38f5bc937db863b13b07ac5615 Mon Sep 17 00:00:00 2001 From: Anders Hafreager Date: Sat, 15 Aug 2026 22:14:33 +0200 Subject: [PATCH 2/2] fix: keep Stop reachable and exit fullscreen when the run ends Review fixes: native fullscreen's top layer hides everything outside the viewport (run-screen chrome, antd toasts), so a Stop button now renders inside the fullscreened viewport for live runs, and fullscreen/ immersive mode exits automatically when the live run finishes. Co-Authored-By: Claude Fable 5 --- src/shell/RunDetail.tsx | 49 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/shell/RunDetail.tsx b/src/shell/RunDetail.tsx index b74de024..223c852d 100644 --- a/src/shell/RunDetail.tsx +++ b/src/shell/RunDetail.tsx @@ -110,8 +110,9 @@ const RunDetail = ({ runId }: { runId: string }) => { } }; window.addEventListener("keydown", onKey); - // The overlay resizes the viewport without a window resize; nudge - // listeners (omovi sizes the canvas off resize events). + // The overlay resizes the viewport without a window resize; nudge the + // remaining window-resize listeners (the 3D canvas itself polls its + // container size every frame and doesn't need it). window.dispatchEvent(new Event("resize")); return () => { window.removeEventListener("keydown", onKey); @@ -119,6 +120,19 @@ const RunDetail = ({ runId }: { runId: string }) => { }; }, [immersive]); + // Leave fullscreen when the live run ends: native fullscreen's top layer + // hides everything outside the viewport (toasts included), and the + // completion UI lives outside it. + useEffect(() => { + if (live) { + return; + } + setImmersive(false); + if (document.fullscreenElement) { + void document.exitFullscreen().catch(() => {}); + } + }, [live]); + const toggleFullscreen = () => { const element = viewportRef.current; if (!element) { @@ -540,6 +554,37 @@ const RunDetail = ({ runId }: { runId: string }) => { )} + {/* Stop stays reachable while the run screen chrome is hidden. */} + {expanded && live && ( + + )} {/* Fullscreen toggle */}