diff --git a/fleet/web/app/_wall/PlayDrawer.tsx b/fleet/web/app/_wall/PlayDrawer.tsx index 20a625a92..ebaee9714 100644 --- a/fleet/web/app/_wall/PlayDrawer.tsx +++ b/fleet/web/app/_wall/PlayDrawer.tsx @@ -1,12 +1,15 @@ "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 { useEffect, useRef } from "react"; import { wordArgs, type WallView } from "../../core/index.ts"; import { TurnstileWidget } from "./Turnstile.tsx"; import type { WallClient } from "./wall-client.ts"; @@ -28,36 +31,49 @@ 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, word, setWord }: { client: WallClient; view: WallView; onClose: () => void; panel: DrawerPanel; isolationInAbout: boolean; word: string; setWord: (word: string) => void }) { 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 1ac34264c..3e92f0bea 100644 --- a/fleet/web/app/_wall/Wall.tsx +++ b/fleet/web/app/_wall/Wall.tsx @@ -38,7 +38,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"; @@ -177,6 +177,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 ? ( @@ -807,7 +847,7 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin {view.rotateHint}

{view.play ? ( - ) : null} @@ -836,11 +876,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} @@ -854,10 +891,11 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin {view.ownButton.disabled ? {view.ownButton.note} : null} ) : null} - -
@@ -882,7 +920,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 e2ce17de2..429fe1e09 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 80f6b5137..c329a6b23 100644 --- a/fleet/web/e2e/wall.spec.ts +++ b/fleet/web/e2e/wall.spec.ts @@ -573,6 +573,89 @@ 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("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; @@ -606,6 +689,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 }); @@ -999,6 +1086,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(); @@ -1435,7 +1549,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();