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
16 changes: 8 additions & 8 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { Suspense, useEffect, useLayoutEffect, useState, type ReactNode } from "react";
import { Link, Redirect, Route, Switch, useLocation, useSearch } from "wouter";
import { Signpost } from "lucide-react";
import { LoginPage } from "./auth/Login";
import { SignupPage, VerifyEmailPage } from "./auth/Signup";
import { ForgotPasswordPage, ResetPasswordPage } from "./auth/ForgotPassword";
import { InvitePage, OnboardPage } from "./auth/InviteOnboard";
import { PrivacyPage, TermsPage } from "./legal/LegalPages";
import { useApp } from "./state";
import { forgetReturn, keepSearch, readAddress, readReturn, rememberReturn } from "./nav";
import { AuthLayout } from "./auth/AuthLayout";
import { PendingShell } from "./shell/PendingShell";
import { ConnectionRecovery } from "./shell/ConnectionRecovery";
import { StreetScene } from "./shell/StreetScene";
import { lazyComponent } from "./shell/lazyComponent";
import { usePageTitle } from "./shell/pageTitle";

Expand Down Expand Up @@ -128,13 +129,12 @@ function SignInFirst({ requested }: { requested: string | null }) {

function NotFoundPage() {
return (
<AuthLayout>
<div className="commune-auth-panel">
<h2>This page isn’t here.</h2>
<p className="commune-auth-lede">The link may be mistyped, or the page may have moved.</p>
<Link href={keepSearch("/")} className="commune-auth-btn">Go to Home</Link>
</div>
</AuthLayout>
<StreetScene label="Street runner, a game to play on a missing page">
<Signpost className="c-street-icon" size={24} strokeWidth={1.5} aria-hidden="true" />
<h1>This page isn’t here.</h1>
<p>The link may be mistyped, or the page may have moved.</p>
<div className="c-street-actions"><Link href={keepSearch("/")} className="a-btn">Go to Home</Link></div>
</StreetScene>
);
}

Expand Down
34 changes: 20 additions & 14 deletions apps/web/src/shell/ConnectionRecovery.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { WifiOff } from "lucide-react";
import "./connection-recovery.css";
import { StreetScene } from "./StreetScene";

export function ConnectionRecovery({ retry, signOut }: { retry: () => Promise<void>; signOut: () => Promise<void> }) {
const [offline, setOffline] = useState(() => !navigator.onLine);
const [busy, setBusy] = useState(false);
const [attempted, setAttempted] = useState(false);
// The connection came back during a run: say so, and let the run finish.
const [backOnline, setBackOnline] = useState(false);
const playing = useRef(false);
const inFlight = useRef(false);
const retryRef = useRef(retry);
retryRef.current = retry;
Expand All @@ -15,6 +18,7 @@ export function ConnectionRecovery({ retry, signOut }: { retry: () => Promise<vo
inFlight.current = true;
setBusy(true);
setAttempted(false);
setBackOnline(false);
try { await retryRef.current(); }
finally {
inFlight.current = false;
Expand All @@ -24,8 +28,11 @@ export function ConnectionRecovery({ retry, signOut }: { retry: () => Promise<vo
}

useEffect(() => {
const online = () => { setOffline(false); void reconnect(); };
const offline = () => setOffline(true);
const online = () => {
setOffline(false);
if (playing.current) setBackOnline(true); else void reconnect();
};
const offline = () => { setOffline(true); setBackOnline(false); };
window.addEventListener("online", online);
window.addEventListener("offline", offline);
return () => {
Expand All @@ -34,18 +41,17 @@ export function ConnectionRecovery({ retry, signOut }: { retry: () => Promise<vo
};
}, []);

return <main className="a-body c-connection-recovery">
<span className="a-wordmark">commune.</span>
<section className="c-connection-card" aria-labelledby="connection-title" aria-busy={busy}>
<WifiOff className="c-connection-icon" size={24} strokeWidth={1.5} aria-hidden="true" />
<h1 id="connection-title">{offline ? "You’re offline" : "We couldn’t connect"}</h1>
<p>{offline ? "Check your internet connection. We’ll try again when you’re back online." : "We couldn’t check your account just now. Try again in a moment."}</p>
<div className="c-connection-actions">
<button className="a-btn" type="button" disabled={busy || offline} onClick={() => void reconnect()}>{busy ? "Reconnecting…" : "Try again"}</button>
return <StreetScene label="Street runner, a game to play while Commune reconnects" onPlayingChange={(now) => { playing.current = now; }}>
<div aria-busy={busy}>
<WifiOff className="c-street-icon" size={24} strokeWidth={1.5} aria-hidden="true" />
<h1>{backOnline ? "You’re back online" : offline ? "You’re offline" : "We couldn’t connect"}</h1>
<p>{backOnline ? "Commune is ready. Open it when you’ve finished your run." : offline ? "Check your internet connection. We’ll try again when you’re back online." : "We couldn’t check your account just now. Try again in a moment."}</p>
<div className="c-street-actions">
<button className="a-btn" type="button" disabled={busy || offline} onClick={() => void reconnect()}>{busy ? "Reconnecting…" : backOnline ? "Open Commune" : "Try again"}</button>
{/* Signing out here also works offline: this device forgets the account straight away. */}
<button className="a-btn ghost" type="button" disabled={busy} onClick={() => void signOut()}>Sign out</button>
</div>
<div className="c-connection-feedback" role="status">{attempted && !busy ? "Still unable to connect. Please try again shortly." : ""}</div>
</section>
</main>;
<div className="c-street-feedback" role="status">{attempted && !busy ? "Still unable to connect. Please try again shortly." : ""}</div>
</div>
</StreetScene>;
}
195 changes: 195 additions & 0 deletions apps/web/src/shell/StreetScene.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { advance, BEST_KEY, CAUSES, drawStreet, jump, layoutFor, metres, newRun, readPalette, release, STREET_UNITS, UNIT, type Hole, type Layout, type Pose } from "./streetRunner";
import "./street-scene.css";

const readBest = () => { try { return Number(localStorage.getItem(BEST_KEY)) || 0; } catch { return 0; } };
const saveBest = (best: number) => { try { localStorage.setItem(BEST_KEY, String(best)); } catch { /* private window */ } };
const JUMP_KEYS = [" ", "ArrowUp", "w", "W"];
// Room between the message and the roofs for the hint.
const HINT_ROOM = 64;

/**
* A street that runs edge to edge across the top of a recovery screen, with
* its message (children) in the sky. Tapping the street, Space or the up arrow
* plays Street runner; it needs no network and plays no sound. A run pauses
* whenever the page is hidden or loses focus.
*/
export function StreetScene({ label, children, onPlayingChange }: { label: string; children: ReactNode; onPlayingChange?: (playing: boolean) => void }) {
const sceneRef = useRef<HTMLElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const messageRef = useRef<HTMLDivElement>(null);
const hintRef = useRef<HTMLParagraphElement>(null);
const run = useRef(newRun());
const pose = useRef<Pose>("ready");
const layout = useRef<Layout>(layoutFor(720, 480));
const hole = useRef<Hole | null>(null);
const frame = useRef(0);
const last = useRef(0);
const endedAt = useRef(0);
const [shown, setShown] = useState<Pose>("ready");
const [distance, setDistance] = useState(0);
const [best, setBest] = useState(readBest);
const [hint, setHint] = useState("Press Space or tap the street to run");
const [announcement, setAnnouncement] = useState("");
const playingChange = useRef(onPlayingChange);
playingChange.current = onPlayingChange;

const draw = useCallback(() => {
const scene = sceneRef.current, ctx = canvasRef.current?.getContext("2d");
if (!scene || !ctx) return;
drawStreet(ctx, layout.current, run.current, pose.current, readPalette(scene), hole.current);
}, []);

const setPose = useCallback((next: Pose) => {
const wasPlaying = pose.current === "running" || pose.current === "paused";
pose.current = next;
setShown(next);
const playing = next === "running" || next === "paused";
if (playing !== wasPlaying) playingChange.current?.(playing);
}, []);

// Size the canvas to the scene, keeping clear sky under the message.
const fit = useCallback(() => {
const scene = sceneRef.current, canvas = canvasRef.current, message = messageRef.current;
if (!scene || !canvas || !message) return;
const s = scene.getBoundingClientRect(), m = message.getBoundingClientRect();
const need = Math.ceil(m.bottom - s.top + HINT_ROOM + STREET_UNITS * UNIT);
if (scene.style.minHeight !== `${need}px`) scene.style.minHeight = `${need}px`;
const next = layoutFor(scene.clientWidth, scene.clientHeight);
layout.current = next;
hole.current = { x: Math.floor((m.left - s.left - 8) / UNIT), y: Math.floor((m.top - s.top - 8) / UNIT), w: Math.ceil((m.width + 16) / UNIT), h: Math.ceil((m.height + 16) / UNIT) };
canvas.width = next.width * UNIT;
canvas.height = next.height * UNIT;
canvas.style.width = `${next.width * UNIT}px`;
canvas.style.height = `${next.height * UNIT}px`;
const hintEl = hintRef.current;
if (hintEl) hintEl.style.top = `${Math.max(0, (next.row - 46) * UNIT - hintEl.offsetHeight - 10)}px`;
draw();
}, [draw]);

const step = useCallback((time: number) => {
if (pose.current !== "running") return;
const dt = Math.min((time - last.current) / 1000, 0.05);
last.current = time;
const hit = advance(run.current, layout.current, dt);
const m = metres(run.current.distance);
setDistance(m);
if (hit) {
endedAt.current = performance.now();
setPose("over");
const record = m > readBest();
if (record) { saveBest(m); setBest(m); }
const said = `${CAUSES[hit]} at ${m} m.${record && m > 0 ? " A new best." : ""}`;
setHint(`${said} Space or tap to run again.`);
setAnnouncement(said);
draw();
return;
}
draw();
frame.current = requestAnimationFrame(step);
}, [draw, setPose]);

const start = useCallback(() => {
cancelAnimationFrame(frame.current);
run.current = newRun();
last.current = performance.now();
setDistance(0);
setHint("");
setAnnouncement("Running. Press Space to jump.");
setPose("running");
frame.current = requestAnimationFrame(step);
}, [setPose, step]);

const pause = useCallback(() => {
if (pose.current !== "running") return;
cancelAnimationFrame(frame.current);
setPose("paused");
setHint("Paused. Space or tap to carry on.");
setAnnouncement("Paused.");
draw();
}, [draw, setPose]);

const resume = useCallback(() => {
if (pose.current !== "paused") return;
last.current = performance.now();
setHint("");
setPose("running");
frame.current = requestAnimationFrame(step);
}, [setPose, step]);

const press = useCallback(() => {
const now = pose.current;
if (now === "ready" || (now === "over" && performance.now() - endedAt.current > 450)) start();
else if (now === "paused") resume();
else if (now === "running") jump(run.current);
}, [resume, start]);

useLayoutEffect(() => {
fit();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => fit());
if (sceneRef.current) observer.observe(sceneRef.current);
if (messageRef.current) observer.observe(messageRef.current);
return () => observer.disconnect();
}, [fit]);

// The hint moves with the roofs when its text changes length.
useLayoutEffect(() => { fit(); }, [hint, fit]);

useEffect(() => {
const onKey = (event: KeyboardEvent) => {
const onControl = event.target instanceof Element && event.target.closest("button, a, input, textarea, select");
if (JUMP_KEYS.includes(event.key) && !onControl) {
event.preventDefault();
if (!event.repeat) press();
} else if (["Escape", "p", "P"].includes(event.key)) {
if (pose.current === "running") pause(); else if (pose.current === "paused") resume();
}
};
const onKeyUp = (event: KeyboardEvent) => { if (JUMP_KEYS.includes(event.key)) release(run.current); };
const onHidden = () => { if (document.hidden) pause(); };
document.addEventListener("keydown", onKey);
document.addEventListener("keyup", onKeyUp);
document.addEventListener("visibilitychange", onHidden);
window.addEventListener("blur", pause);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("keyup", onKeyUp);
document.removeEventListener("visibilitychange", onHidden);
window.removeEventListener("blur", pause);
cancelAnimationFrame(frame.current);
};
}, [press, pause, resume]);

const playing = shown === "running" || shown === "paused";
return <main className="a-body c-street">
<section
ref={sceneRef}
className="c-street-scene"
aria-label={label}
onPointerDown={(event) => {
if ((event.target as Element).closest("button, a, .c-street-message")) return;
event.preventDefault();
press();
}}
onPointerUp={() => release(run.current)}
>
<canvas ref={canvasRef} className="c-street-canvas" aria-hidden="true" />
<div className="c-street-top">
<span className="a-wordmark">commune.</span>
<div className="c-street-score">
{shown !== "ready" && <span><b>{distance}</b> m</span>}
<span>Best {best} m</span>
{playing && <button type="button" className="c-street-pause" onClick={() => shown === "paused" ? resume() : pause()}>{shown === "paused" ? "Resume" : "Pause"}</button>}
</div>
</div>
<div ref={messageRef} className="c-street-message">{children}</div>
<p ref={hintRef} className="c-street-hint" hidden={!hint}>{hint}</p>
</section>
<div className="c-street-help">
<p>Space, ↑ or a tap to jump. Let go early for a small hop. Stay low when a pigeon swoops.</p>
<p>No sound. Playing uses no data and doesn’t touch your account.</p>
</div>
<p className="sr-only" role="status">{announcement}</p>
</main>;
}
97 changes: 97 additions & 0 deletions apps/web/src/shell/street-scene.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/* The connection and missing-page screens: a street scene edge to edge
across the top, its message in the sky (see StreetScene.tsx). In the
screens layer, like the other recovery screens. */
@layer screens {
.c-street {
min-height: 100dvh;
background: var(--paper);
color: var(--ink);
/* The scene's colours, read by the canvas. */
--street-sky: #c6e3f2;
--street-cloud: #f5fbff;
--street-sun: #e4c477;
--street-house: #faf5e9;
--street-house-alt: #efe3cf;
--street-roof: #687c6e;
--street-window: #c8d9d8;
--street-frame: var(--street-house);
--street-door: var(--sage);
--street-wood: #bb9768;
--street-leaf: #6d9877;
--street-pave: #ddd6c8;
--street-kerb: #b8b0a0;
--street-joint: #cfc7b7;
--street-bin: #4a5560;
--street-bin-lid: #323a44;
--street-puddle: #9fc1d3;
--street-skin: var(--street-wood);
--street-hair: var(--navy);
--street-top: var(--sage);
--street-legs: var(--navy);
--street-bird: var(--ink-soft);
--street-brick: #c68055;
--street-lamp: #5a6660;
--street-light: #f4e3a1;
--street-box: #4f7a63;
--street-glass: var(--sage-soft);
/* Text on the sky: the secondary grey is darkened to stay readable there. */
--street-ink-soft: #3f4c5e;
--street-chip: rgba(250, 245, 233, 0.92);
}
.c-street-scene {
position: relative;
overflow: hidden;
height: clamp(440px, 58dvh, 700px);
background: var(--street-sky);
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
user-select: none;
-webkit-user-select: none;
}
.c-street-canvas { position: absolute; left: 0; bottom: 0; display: block; image-rendering: pixelated; }
.c-street-top {
position: absolute; inset: 0 0 auto 0;
display: flex; justify-content: space-between; align-items: center; gap: 16px;
padding: 22px clamp(16px, 5vw, 56px);
}
.c-street-top .a-wordmark { color: var(--navy); font-size: 30px; }
.c-street-score { display: flex; align-items: center; gap: 14px; font-size: 13px; color: var(--street-ink-soft); font-variant-numeric: tabular-nums; }
.c-street-score b { color: var(--ink); font-weight: 550; }
.c-street-pause {
height: 32px; padding: 0 10px; border-radius: var(--radius-control);
border: 1px solid var(--border-strong); background: var(--street-chip); color: var(--ink);
font: inherit; font-size: 12.5px; font-weight: 550; cursor: pointer;
}
.c-street-message {
position: absolute;
left: clamp(16px, 5vw, 56px);
top: clamp(80px, 13dvh, 128px);
width: min(420px, calc(100% - 32px));
cursor: auto;
user-select: text;
-webkit-user-select: text;
}
.c-street-icon { color: var(--street-ink-soft); display: block; margin-bottom: 14px; }
.c-street-message h1 { font-size: clamp(22px, 2.4vw, 28px); line-height: 1.2; font-weight: 550; letter-spacing: -.5px; margin: 0 0 10px; color: var(--ink); text-wrap: balance; }
.c-street-message p { font-size: 14px; line-height: 1.6; color: var(--street-ink-soft); margin: 0 0 20px; max-width: 38ch; }
.c-street-message .a-btn { border-radius: var(--radius-control); min-height: 40px; text-decoration: none; }
.c-street-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.c-street-feedback { min-height: 22px; padding-top: 10px; font-size: 12px; line-height: 1.5; color: var(--street-ink-soft); }
.c-street-hint {
position: absolute; top: 0; left: 50%; transform: translateX(-50%);
width: max-content; max-width: calc(100% - 32px); margin: 0;
padding: 7px 12px; border-radius: var(--radius-control);
background: var(--street-chip); color: var(--ink);
font-size: 13px; line-height: 1.4; text-align: center; pointer-events: none;
}
.c-street-help { max-width: 640px; margin: 0 auto; padding: 28px 16px; display: grid; gap: 6px; text-align: center; font-size: 13px; color: var(--ink-soft); }
.c-street-help p { margin: 0; }
@media (max-width: 600px) {
.c-street-scene { height: clamp(480px, 64dvh, 640px); }
.c-street-top { padding-top: 18px; }
.c-street-top .a-wordmark { font-size: 26px; }
.c-street-message { top: 66px; }
.c-street-icon { display: none; }
}
}
Loading
Loading