From 834ab6794cca911e3855968470a0329f2ab48f4f Mon Sep 17 00:00:00 2001 From: ejc3 Date: Tue, 27 Oct 2026 07:42:14 +0000 Subject: [PATCH 1/2] fleet web: each header button opens its own panel; add a GitHub link The (i), the menu and "Direct the scene" all opened the same drawer and only scrolled it to a different section, so the menu always opened scrolled down past the controls. They now open one panel each in the same drawer shell: - Direct the scene: the controls only (nudge, word, light, streams, stats). - Menu: About only (title, sentence, counter, recent actions). - (i): isolation only (title, sentences, repository link). Opening one replaces another, each opens at scrollTop 0 with focus on its heading (or the first control for the controls), Escape and the close button work the same, and closing returns focus to the button that opened it. The play button's aria-expanded is true only while the controls are open. The header gains a GitHub icon link (inline SVG) next to the (i) and the menu, on desktop and phone. Its href is view.isolation.link.href, the same value the isolation panel links to. On a phone the header has the GitHub link and the menu; the (i) is left out and About also carries the isolation section, because three icons plus the title and LIVE badge overflow a 360 px screen. e2e: a test that the three buttons open distinct panels at scrollTop 0 (red on the previous code: About content appeared in the controls drawer), a test for the GitHub link and its href, a phone check for the header and the About panel, and the scrub count assertion now reads About instead of the isolation panel. --- fleet/web/app/_wall/PlayDrawer.tsx | 96 ++++++++++++++++++------------ fleet/web/app/_wall/Wall.tsx | 72 ++++++++++++++++------ fleet/web/app/wall.css | 2 + fleet/web/e2e/wall.spec.ts | 93 +++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 55 deletions(-) diff --git a/fleet/web/app/_wall/PlayDrawer.tsx b/fleet/web/app/_wall/PlayDrawer.tsx index 20a625a92..2e1776aaf 100644 --- a/fleet/web/app/_wall/PlayDrawer.tsx +++ b/fleet/web/app/_wall/PlayDrawer.tsx @@ -1,10 +1,13 @@ "use client"; -// The drawer: directing the scene (choosing the camera's next stop, writing a word, dropping a -// light), how the streams arrive, the stats overlay, and what the page is (the -// counters, recent actions, how a square is isolated). Every -// label comes from the view model; every action goes through WallClient.act, -// which holds it until the Turnstile check passes when the coordinator asks for one. +// The drawer shell and its three panels. "play" directs the scene (choosing the camera's next +// stop, writing a word, dropping a light, how the streams arrive, the stats overlay). "about" +// is what the page is (title, sentence, counters, recent actions). "isolation" is how a square +// is isolated. Each header button opens its own panel in the same shell, so they share the +// close button, Escape and focus handling, and each opens at its top. On a phone the header has +// no room for a third icon, so About carries the isolation section too (`isolationInAbout`). Every label comes from +// the view model; every action goes through WallClient.act, which holds it until the Turnstile +// check passes when the coordinator asks for one. import { useEffect, useRef, useState } from "react"; import { wordArgs, type WallView } from "../../core/index.ts"; @@ -28,35 +31,48 @@ export function VerifyBox({ client, view }: { client: WallClient; view: WallView ); } -export function PlayDrawer({ client, view, onClose, section }: { client: WallClient; view: WallView; onClose: () => void; section: string | null }) { +export type DrawerPanel = "play" | "about" | "isolation"; + +export function PlayDrawer({ client, view, onClose, panel, isolationInAbout }: { client: WallClient; view: WallView; onClose: () => void; panel: DrawerPanel; isolationInAbout: boolean }) { const p = view.play!; - const [word, setWord] = useState(""); - const panel = useRef(null); - const iso = view.isolation; + const shell = useRef(null); + const title = panel === "about" ? view.title : panel === "isolation" ? view.isolation.title : p.title; useEffect(() => { - const target = section ? panel.current?.querySelector(`#${section}-title`) : null; - if (target) { - target.scrollIntoView({ block: "start" }); - target.focus(); - } else { - panel.current?.querySelector("button, input")?.focus(); - } + // Every panel opens at its top: focus without scrolling, then pin the scroll to 0. + const target = panel === "play" ? shell.current?.querySelector("button, input") : shell.current?.querySelector(`#${panel}-title`); + target?.focus({ preventScroll: true }); + if (shell.current) shell.current.scrollTop = 0; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [onClose, section]); + }, [onClose, panel]); return ( - + + ); +} + +function IsolationSection({ view, heading = false }: { view: WallView; heading?: boolean }) { + const iso = view.isolation; + return ( +
+ {heading ?

{iso.title}

: null} + {iso.sentences.map((t) => ( +

{t}

+ ))} + + {iso.link.label} + +
); } diff --git a/fleet/web/app/_wall/Wall.tsx b/fleet/web/app/_wall/Wall.tsx index 3febf44fe..3d765dd9a 100644 --- a/fleet/web/app/_wall/Wall.tsx +++ b/fleet/web/app/_wall/Wall.tsx @@ -39,7 +39,7 @@ import { canDecodeH264, WallClient } from "./wall-client.ts"; import { FocusPanel } from "./FocusPanel.tsx"; import { OwnGpu } from "./OwnGpu.tsx"; import { HalfClockView, Overlays, WakeCard } from "./Overlays.tsx"; -import { PlayDrawer, VerifyBox } from "./PlayDrawer.tsx"; +import { PlayDrawer, VerifyBox, type DrawerPanel } from "./PlayDrawer.tsx"; import { Reel } from "./Reel.tsx"; import type { DrawSettings } from "./worker-msgs.ts"; @@ -178,6 +178,25 @@ function MenuIcon() { ); } +const GITHUB_LABEL = "fcvm on GitHub"; + +function GithubIcon() { + return ( + + ); +} + +/** The repository link; the address is the one the isolation panel links to. */ +function GithubLink({ href, label }: { href: string; label: string }) { + return ( + + + + ); +} + function RestartIcon() { return (
{brand} - +
+ + +
{stage} {view.race && view.race.clocks.length > 0 ? ( @@ -810,7 +848,7 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin {view.rotateHint}

{view.play ? ( - ) : null} @@ -839,11 +877,8 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin ref={playButtonRef} type="button" className="btn" - aria-expanded={view.playButton.open} - onClick={() => { - setDrawerSection(null); - client.ui({ drawer: !view.playButton.open, lightMode: false }); - }} + aria-expanded={playOpen} + onClick={(e) => openDrawer("play", e.currentTarget)} data-testid="play" > {view.playButton.label} @@ -857,10 +892,11 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin {view.ownButton.disabled ? {view.ownButton.note} : null} ) : null} - -
@@ -885,7 +921,7 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin ) : null} {keyGrid} - {state.ui.drawer && view.play && !covered ? : null} + {state.ui.drawer && view.play && !covered ? : null} {needsVerifyPrompt ? (
diff --git a/fleet/web/app/wall.css b/fleet/web/app/wall.css index 5aef23b5b..3532ecae3 100644 --- a/fleet/web/app/wall.css +++ b/fleet/web/app/wall.css @@ -69,6 +69,8 @@ h1, h2, h3, p { margin: 0; } .own-btn { display: inline-flex; flex-direction: column; align-items: flex-end; gap: 2px; max-width: 260px; } .own-btn .note { font-size: 14px; color: var(--text); text-align: right; text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8); } .icon-btn { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; border: 1px solid var(--control); background: rgba(11, 13, 16, 0.55); color: var(--text); cursor: pointer; } +a.icon-btn { text-decoration: none; } +.header-icons { display: flex; align-items: center; gap: 8px; } .icon-btn:hover { border-color: #9aa5b3; } .icon-btn[aria-pressed="true"] { background: #1d2a36; border-color: var(--fcvm); } .hud-top .btn { background: rgba(11, 13, 16, 0.55); } diff --git a/fleet/web/e2e/wall.spec.ts b/fleet/web/e2e/wall.spec.ts index d198dcbac..3779ffbc6 100644 --- a/fleet/web/e2e/wall.spec.ts +++ b/fleet/web/e2e/wall.spec.ts @@ -539,6 +539,76 @@ test("viewers choose the directed camera's next stop", async ({ page }) => { await admin("/mock/schedule", { hold_in_ms: 600_000 }); }); +test("the header links to the fcvm repository, with the address the isolation panel uses", async ({ page }) => { + await openWall(page, 1); + const gh = page.getByTestId("github-link"); + await expect(gh).toHaveCount(1); + await expect(gh).toHaveAttribute("href", "https://github.com/ejc3/fcvm"); + await expect(gh).toHaveAttribute("target", "_blank"); + await expect(gh).toHaveAttribute("rel", "noopener noreferrer"); + await expect(gh).toHaveAttribute("aria-label", "fcvm on GitHub"); + await expect(gh).toHaveAttribute("title", "fcvm on GitHub"); + + await page.getByTestId("isolation-link").click(); + await expect(page.getByTestId("isolation-repo")).toHaveAttribute("href", (await gh.getAttribute("href")) ?? "missing"); +}); + +test("the three header buttons open distinct panels, each at its top", async ({ page }) => { + await openWall(page, 1); + const drawer = page.getByTestId("drawer"); + const scrollTop = () => drawer.evaluate((el) => el.scrollTop); + // What only the controls have, what only About has, what only the isolation panel has. + const controls = [page.getByLabel("Write a word into the world"), page.getByTestId("streams-switch"), page.getByTestId("stats-switch")]; + const about = [page.getByTestId("about"), page.getByTestId("sentence"), page.getByTestId("counter")]; + const isolation = [page.getByTestId("isolation"), page.getByTestId("isolation-repo")]; + const only = async (panel: "play" | "about" | "isolation") => { + await expect(drawer).toHaveCount(1); + for (const [name, els] of [["play", controls], ["about", about], ["isolation", isolation]] as const) { + for (const el of els) await expect(el, `${name} content in the ${panel} panel`).toHaveCount(name === panel ? 1 : 0); + } + await expect(drawer).toHaveAttribute("data-panel", panel); + expect(await scrollTop()).toBe(0); + }; + + // Direct the scene: the controls, without About or the isolation text. + await page.getByTestId("play").click(); + await only("play"); + // Scroll the controls down; the other panels still open at their top. + await drawer.evaluate((el) => { + el.scrollTop = 300; + }); + + // The menu: About only. Opened from the keyboard because the open drawer covers the header. + await page.getByTestId("menu").focus(); + await page.keyboard.press("Enter"); + await only("about"); + await expect(page.locator("#about-title")).toBeFocused(); + await expect(page.getByTestId("counter")).toContainText("GPU VMs restored:"); + + // The (i): the isolation text only. Opening it replaced About. + await page.getByTestId("isolation-link").focus(); + await page.keyboard.press("Enter"); + await only("isolation"); + await expect(page.locator("#isolation-title")).toBeFocused(); + await expect(page.getByTestId("isolation-repo")).toHaveAttribute("href", "https://github.com/ejc3/fcvm"); + + // Escape closes the panel, and focus goes back to the button that opened it. + await page.keyboard.press("Escape"); + await expect(drawer).toHaveCount(0); + await expect(page.getByTestId("isolation-link")).toBeFocused(); + + // Each button opens its panel on its own, from closed, at the top. + await page.getByTestId("menu").click(); + await only("about"); + await page.keyboard.press("Escape"); + await page.getByTestId("isolation-link").click(); + await only("isolation"); + await page.getByRole("button", { name: "Close" }).click(); + await expect(drawer).toHaveCount(0); + await page.getByTestId("play").click(); + await only("play"); +}); + test("the count of GPU VMs restored ticks as VMs restore; the isolation panel says how a square is isolated", async ({ page }) => { await openWall(page, 15); const counters = async () => ((await mockState()).state as unknown as { counters: { restores: number; scrubs: number } }).counters; @@ -572,6 +642,10 @@ test("the count of GPU VMs restored ticks as VMs restore; the isolation panel sa try { await page.evaluate(() => window.__fleet!.client.send("relaunch", { tile: "fcvm:1" })); await expect(panel.locator("p").last()).toHaveText("GPU memory is cleared before a GPU moves to the next virtual machine.", { timeout: 10_000 }); + // The count is About's, not the isolation panel's. + await expect(page.getByTestId("counter-scrubs")).toHaveCount(0); + await page.getByTestId("menu").focus(); + await page.keyboard.press("Enter"); await expect(page.getByTestId("counter-scrubs")).toHaveText("GPU memory scrubs: 1"); } finally { await admin("/mock/schedule", { gpu_scrub: false }); @@ -1358,7 +1432,26 @@ test("phone: the image full width at the top, the strip under it, one button in const dock = await page.getByTestId("restart-all").boundingBox(); expect(dock!.y + dock!.height).toBeLessThanOrEqual(vh); expect(dock!.y).toBeGreaterThan(vh / 2); + // The header holds the GitHub link and the menu in one row with the title; no (i): About carries + // the isolation section on a phone. + const header = await page.locator(".phone header").boundingBox(); + for (const id of ["github-link", "menu"]) { + const box = await page.getByTestId(id).boundingBox(); + expect(box!.x + box!.width, id).toBeLessThanOrEqual(vw); + expect(box!.y + box!.height, id).toBeLessThanOrEqual(header!.y + header!.height); + } + await expect(page.getByTestId("isolation-link")).toHaveCount(0); + await expect(page.getByTestId("github-link")).toHaveAttribute("href", "https://github.com/ejc3/fcvm"); + await page.getByTestId("menu").click(); + await expect(page.getByTestId("drawer")).toHaveAttribute("data-panel", "about"); + await expect(page.getByTestId("counter")).toHaveCount(1); + await expect(page.getByTestId("isolation-repo")).toHaveAttribute("href", "https://github.com/ejc3/fcvm"); + await expect(page.getByTestId("word-status")).toHaveCount(0); + expect(await page.getByTestId("drawer").evaluate((el) => el.scrollTop)).toBe(0); + await page.getByRole("button", { name: "Close" }).click(); await page.getByTestId("phone-play").click(); + await expect(page.getByTestId("drawer")).toHaveAttribute("data-panel", "play"); + await expect(page.getByTestId("about")).toHaveCount(0); await expect(page.getByTestId("drawer")).toBeVisible(); await page.getByTestId("text-style-landmark").click(); await expect(page.getByRole("radio", { name: "Landmark" })).toBeChecked(); From 02e36a523d485ecdb953d2de57ffde8000428d7c Mon Sep 17 00:00:00 2001 From: ejc3 Date: Tue, 27 Oct 2026 13:48:37 +0000 Subject: [PATCH 2/2] fleet web: keep the Turnstile check and the word draft across drawer panels Review findings on the panel split: - The Turnstile check lived in the controls section only, so with About or the isolation panel open, an action queued from the page (Restart all) showed no check, and the floating prompt is hidden while any drawer is open. VerifyBox now sits in the drawer shell, above whichever panel shows. - The word being typed was local state of the controls section, which the panel switch unmounts, so it was lost on switching to About and back. The draft is now state in Wall and passed down. The drawer no longer takes a per-panel React key, so the check widget stays mounted across a switch. e2e: one test for each, both red on the previous commit. --- fleet/web/app/_wall/PlayDrawer.tsx | 12 ++++----- fleet/web/app/_wall/Wall.tsx | 4 ++- fleet/web/e2e/wall.spec.ts | 40 ++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/fleet/web/app/_wall/PlayDrawer.tsx b/fleet/web/app/_wall/PlayDrawer.tsx index 2e1776aaf..ebaee9714 100644 --- a/fleet/web/app/_wall/PlayDrawer.tsx +++ b/fleet/web/app/_wall/PlayDrawer.tsx @@ -9,7 +9,7 @@ // the view model; every action goes through WallClient.act, which holds it until the Turnstile // check passes when the coordinator asks for one. -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import { wordArgs, type WallView } from "../../core/index.ts"; import { TurnstileWidget } from "./Turnstile.tsx"; import type { WallClient } from "./wall-client.ts"; @@ -33,7 +33,7 @@ export function VerifyBox({ client, view }: { client: WallClient; view: WallView export type DrawerPanel = "play" | "about" | "isolation"; -export function PlayDrawer({ client, view, onClose, panel, isolationInAbout }: { client: WallClient; view: WallView; onClose: () => void; panel: DrawerPanel; isolationInAbout: boolean }) { +export function PlayDrawer({ client, view, onClose, panel, isolationInAbout, word, setWord }: { client: WallClient; view: WallView; onClose: () => void; panel: DrawerPanel; isolationInAbout: boolean; word: string; setWord: (word: string) => void }) { const p = view.play!; const shell = useRef(null); const title = panel === "about" ? view.title : panel === "isolation" ? view.isolation.title : p.title; @@ -60,20 +60,20 @@ export function PlayDrawer({ client, view, onClose, panel, isolationInAbout }: { {p.close} + {/* In the shell, so a queued action's check is there whichever panel is open. */} + {panel === "about" ? : null} {panel === "about" && isolationInAbout ? : null} {panel === "isolation" ? : null} - {panel === "play" ? : null} + {panel === "play" ? : null} ); } -function PlaySections({ client, view }: { client: WallClient; view: WallView }) { +function PlaySections({ client, view, word, setWord }: { client: WallClient; view: WallView; word: string; setWord: (word: string) => void }) { const p = view.play!; - const [word, setWord] = useState(""); return ( <> - {p.nudge ? (

{p.nudge.title}

diff --git a/fleet/web/app/_wall/Wall.tsx b/fleet/web/app/_wall/Wall.tsx index 3d765dd9a..c01d1d8be 100644 --- a/fleet/web/app/_wall/Wall.tsx +++ b/fleet/web/app/_wall/Wall.tsx @@ -234,6 +234,8 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin const [labelSize, setLabelSize] = useState<{ w: number; h: number } | undefined>(undefined); /** Which panel the drawer shows: the controls, About, or how a square is isolated. */ const [drawerPanel, setDrawerPanel] = useState("play"); + /** The word being typed; it outlives panel switches. */ + const [wordDraft, setWordDraft] = useState(""); /** The button that opened the drawer; closing it returns focus there. */ const drawerOpener = useRef(null); const phone = useMedia("(max-width: 700px)"); @@ -921,7 +923,7 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin ) : null} {keyGrid} - {state.ui.drawer && view.play && !covered ? : null} + {state.ui.drawer && view.play && !covered ? : null} {needsVerifyPrompt ? (
diff --git a/fleet/web/e2e/wall.spec.ts b/fleet/web/e2e/wall.spec.ts index 3779ffbc6..3e717549f 100644 --- a/fleet/web/e2e/wall.spec.ts +++ b/fleet/web/e2e/wall.spec.ts @@ -609,6 +609,19 @@ test("the three header buttons open distinct panels, each at its top", async ({ await only("play"); }); +test("a word being typed survives switching to another panel and back", async ({ page }) => { + await openWall(page, 1); + await page.getByTestId("play").click(); + await page.getByLabel("Write a word into the world").fill("half a thought"); + await page.getByTestId("menu").focus(); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("drawer")).toHaveAttribute("data-panel", "about"); + await page.getByTestId("play").focus(); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("drawer")).toHaveAttribute("data-panel", "play"); + await expect(page.getByLabel("Write a word into the world")).toHaveValue("half a thought"); +}); + test("the count of GPU VMs restored ticks as VMs restore; the isolation panel says how a square is isolated", async ({ page }) => { await openWall(page, 15); const counters = async () => ((await mockState()).state as unknown as { counters: { restores: number; scrubs: number } }).counters; @@ -1039,6 +1052,33 @@ test("Turnstile: the first action waits for the check, then goes through", async } }); +test("Turnstile: the check shows in the About panel too, for an action queued while it is open", async ({ page }) => { + await admin("/mock/turnstile", { sitekey: "example-sitekey" }); + try { + await page.addInitScript(() => { + window.turnstile = { + render(el: HTMLElement, o: { callback: (t: string) => void }) { + const b = document.createElement("button"); + b.textContent = "Verify (test)"; + b.onclick = () => o.callback("mock-pass"); + el.appendChild(b); + return "w"; + }, + remove() {}, + }; + }); + await openWall(page, 15); + await page.getByTestId("menu").click(); + await expect(page.getByTestId("drawer")).toHaveAttribute("data-panel", "about"); + await page.getByTestId("restart-all").click(); + await expect(page.getByTestId("drawer").getByTestId("verify")).toBeVisible(); + await page.getByRole("button", { name: "Verify (test)" }).click(); + await expect(page.getByTestId("verify")).toHaveCount(0); + } finally { + await admin("/mock/turnstile", { sitekey: null }); + } +}); + test("your own GPU: a wall tile's GPU is lent, streams full screen with its own camera for a minute, then snaps back into the wall", async ({ page, browser }) => { await openWall(page, 15); const other = await (await browser.newContext()).newPage();