Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 60 additions & 38 deletions fleet/web/app/_wall/PlayDrawer.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<HTMLElement | null>(null);
const iso = view.isolation;
const shell = useRef<HTMLElement | null>(null);
const title = panel === "about" ? view.title : panel === "isolation" ? view.isolation.title : p.title;

useEffect(() => {
const target = section ? panel.current?.querySelector<HTMLElement>(`#${section}-title`) : null;
if (target) {
target.scrollIntoView({ block: "start" });
target.focus();
} else {
panel.current?.querySelector<HTMLElement>("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<HTMLElement>("button, input") : shell.current?.querySelector<HTMLElement>(`#${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 (
<aside ref={panel} className="drawer" role="dialog" aria-modal="false" aria-labelledby="play-title" data-testid="drawer">
<aside ref={shell} className="drawer" role="dialog" aria-modal="false" aria-labelledby={`${panel}-title`} data-testid="drawer" data-panel={panel}>
<header>
<h2 id="play-title">{p.title}</h2>
<h2 id={`${panel}-title`} tabIndex={panel === "play" ? undefined : -1}>
{title}
</h2>
<button type="button" className="btn" onClick={onClose}>
{p.close}
</button>
</header>
{/* In the shell, so a queued action's check is there whichever panel is open. */}
<VerifyBox client={client} view={view} />
{panel === "about" ? <AboutSection view={view} /> : null}
{panel === "about" && isolationInAbout ? <IsolationSection view={view} heading /> : null}
{panel === "isolation" ? <IsolationSection view={view} /> : null}
{panel === "play" ? <PlaySections client={client} view={view} word={word} setWord={setWord} /> : null}
</aside>
);
}

function PlaySections({ client, view, word, setWord }: { client: WallClient; view: WallView; word: string; setWord: (word: string) => void }) {
const p = view.play!;
return (
<>
{p.nudge ? (
<section aria-labelledby="nudge-title">
<h3 id="nudge-title">{p.nudge.title}</h3>
Expand Down Expand Up @@ -175,11 +191,14 @@ export function PlayDrawer({ client, view, onClose, section }: { client: WallCli
{p.stats.family}
</p>
</section>
<section className="about" aria-labelledby="about-title" data-testid="about">
<h3 id="about-title" tabIndex={-1}>
{view.title}
</h3>
<p data-testid="sentence">{view.sentence}</p>
</>
);
}

function AboutSection({ view }: { view: WallView }) {
return (
<section className="about" data-testid="about">
<p data-testid="sentence">{view.sentence}</p>
{view.counter ? (
<p className="counter" data-testid="counter">
{view.counter.label}:{" "}
Expand Down Expand Up @@ -209,18 +228,21 @@ export function PlayDrawer({ client, view, onClose, section }: { client: WallCli
))}
</ol>
) : null}
</section>
<section className="isolation" aria-labelledby="isolation-title" data-testid="isolation">
<h3 id="isolation-title" tabIndex={-1}>
{iso.title}
</h3>
{iso.sentences.map((t) => (
<p key={t}>{t}</p>
))}
<a href={iso.link.href} target="_blank" rel="noopener noreferrer" data-testid="isolation-repo">
{iso.link.label}
</a>
</section>
</aside>
</section>
);
}

function IsolationSection({ view, heading = false }: { view: WallView; heading?: boolean }) {
const iso = view.isolation;
return (
<section className="isolation" data-testid="isolation">
{heading ? <h3>{iso.title}</h3> : null}
{iso.sentences.map((t) => (
<p key={t}>{t}</p>
))}
<a href={iso.link.href} target="_blank" rel="noopener noreferrer" data-testid="isolation-repo">
{iso.link.label}
</a>
</section>
);
}
74 changes: 56 additions & 18 deletions fleet/web/app/_wall/Wall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -177,6 +177,25 @@ function MenuIcon() {
);
}

const GITHUB_LABEL = "fcvm on GitHub";

function GithubIcon() {
return (
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.6 7.6 0 0 1 2-.27c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z" />
</svg>
);
}

/** The repository link; the address is the one the isolation panel links to. */
function GithubLink({ href, label }: { href: string; label: string }) {
return (
<a className="icon-btn" href={href} target="_blank" rel="noopener noreferrer" aria-label={label} title={label} data-testid="github-link">
<GithubIcon />
</a>
);
}

function RestartIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
Expand Down Expand Up @@ -212,8 +231,12 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin
const [areas, setAreas] = useState<Areas>({ mosaic: null, own: null });
const [avoid, setAvoid] = useState<Box[]>([]);
const [labelSize, setLabelSize] = useState<{ w: number; h: number } | undefined>(undefined);
/** The drawer section to show when it opens (the isolation link), or null for its top. */
const [drawerSection, setDrawerSection] = useState<string | null>(null);
/** Which panel the drawer shows: the controls, About, or how a square is isolated. */
const [drawerPanel, setDrawerPanel] = useState<DrawerPanel>("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<HTMLElement | null>(null);
const phone = useMedia("(max-width: 700px)");
const reducedMotion = useMedia("(prefers-reduced-motion: reduce)");
const canvasRef = useRef<HTMLCanvasElement | null>(null);
Expand Down Expand Up @@ -597,7 +620,8 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin
);
const closeDrawer = useCallback(() => {
client.ui({ drawer: false });
requestAnimationFrame(() => playButtonRef.current?.focus());
const opener = drawerOpener.current;
requestAnimationFrame(() => (opener?.isConnected ? opener : playButtonRef.current)?.focus());
}, [client]);
const getOwnGpu = (from: HTMLElement) => {
returnFocus.current = from;
Expand Down Expand Up @@ -727,10 +751,23 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin
</div>
) : null;

const openDrawer = (section: string | null) => {
setDrawerSection(section);
// A header button opens its own panel; pressing the one whose panel is open closes it, and
// pressing another swaps the panel in place.
const drawerOn = state.ui.drawer;
const openDrawer = (panel: DrawerPanel, from: HTMLElement) => {
if (drawerOn && drawerPanel === panel) {
closeDrawer();
return;
}
drawerOpener.current = from;
setDrawerPanel(panel);
client.ui({ drawer: true, lightMode: false });
};
const playOpen = drawerOn && drawerPanel === "play";
// However the drawer closed, the next plain open is the controls.
useEffect(() => {
if (!drawerOn) setDrawerPanel("play");
}, [drawerOn]);

const needsVerifyPrompt = view.play?.verify.needed && state.turnstile.queued.length > 0 && !state.ui.drawer;

Expand Down Expand Up @@ -784,9 +821,12 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin
<div className="phone" data-version={view.version} style={covered ? { visibility: "hidden" } : undefined}>
<header>
{brand}
<button type="button" className="icon-btn" aria-label="About this page" data-testid="menu" onClick={() => openDrawer("about")}>
<MenuIcon />
</button>
<div className="header-icons">
<GithubLink href={view.isolation.link.href} label={GITHUB_LABEL} />
<button type="button" className="icon-btn" aria-label="About this page" title="About this page" aria-expanded={drawerOn && drawerPanel === "about"} data-testid="menu" onClick={(e) => openDrawer("about", e.currentTarget)}>
<MenuIcon />
</button>
</div>
</header>
{stage}
{view.race && view.race.clocks.length > 0 ? (
Expand All @@ -807,7 +847,7 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin
{view.rotateHint}
</p>
{view.play ? (
<button type="button" className="link play-link" data-testid="phone-play" aria-expanded={view.playButton.open} onClick={() => openDrawer(null)}>
<button type="button" className="link play-link" data-testid="phone-play" aria-expanded={playOpen} onClick={(e) => openDrawer("play", e.currentTarget)}>
{view.playButton.phoneLabel}
</button>
) : null}
Expand Down Expand Up @@ -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}
Expand All @@ -854,10 +891,11 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin
{view.ownButton.disabled ? <span className="note" data-testid="own-note">{view.ownButton.note}</span> : null}
</span>
) : null}
<button type="button" className="icon-btn" aria-label={view.isolation.open} title={view.isolation.open} onClick={() => openDrawer("isolation")} data-testid="isolation-link">
<button type="button" className="icon-btn" aria-label={view.isolation.open} title={view.isolation.open} aria-expanded={drawerOn && drawerPanel === "isolation"} onClick={(e) => openDrawer("isolation", e.currentTarget)} data-testid="isolation-link">
<InfoIcon />
</button>
<button type="button" className="icon-btn" aria-label="About this page" title="About this page" onClick={() => openDrawer("about")} data-testid="menu">
<GithubLink href={view.isolation.link.href} label={GITHUB_LABEL} />
<button type="button" className="icon-btn" aria-label="About this page" title="About this page" aria-expanded={drawerOn && drawerPanel === "about"} onClick={(e) => openDrawer("about", e.currentTarget)} data-testid="menu">
<MenuIcon />
</button>
</div>
Expand All @@ -882,7 +920,7 @@ export function Wall({ coordUrl, mediaUrl }: { coordUrl: string; mediaUrl: strin
) : null}
<Overlays view={view} phone={phone} />
{keyGrid}
{state.ui.drawer && view.play && !covered ? <PlayDrawer client={client} view={view} onClose={closeDrawer} section={drawerSection} /> : null}
{state.ui.drawer && view.play && !covered ? <PlayDrawer client={client} view={view} onClose={closeDrawer} panel={drawerPanel} isolationInAbout={phone} word={wordDraft} setWord={setWordDraft} /> : null}
{needsVerifyPrompt ? (
<div className="verify-pop" role="dialog" aria-label={view.play!.verify.title}>
<VerifyBox client={client} view={view} />
Expand Down
2 changes: 2 additions & 0 deletions fleet/web/app/wall.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
Loading
Loading