diff --git a/e2e/atlas.spec.ts b/e2e/atlas.spec.ts index 88af662..ce9c11b 100644 --- a/e2e/atlas.spec.ts +++ b/e2e/atlas.spec.ts @@ -78,7 +78,9 @@ test("SYNTHETIC unchecked, real cache miss and 503 stay separate, with no implic await expect(page.locator(".atlas-card")).not.toContainText("Not collected"); expect(api.requests.every((r: any) => r.method === "GET")).toBe(true); }); -test("SYNTHETIC actual painted map label selects the same repository as its visible list and stays idle", async ({ page, api }) => { +const motion = (page: any) => page.locator(".atlas-map").getAttribute("data-motion"); +const frames = (cv: any) => cv.evaluate((c: any) => c.__frames ?? 0); +test("SYNTHETIC actual painted map label selects the same repository as its visible list; ambient motion is capped and reduced motion stays idle", async ({ page, api }) => { await page.emulateMedia({ reducedMotion: "no-preference" }); await fixture(page, api); await page.goto(atlasPath); @@ -87,6 +89,8 @@ test("SYNTHETIC actual painted map label selects the same repository as its visi await page.getByRole("button", { name: "Close selected landmark" }).click(); const mapTab = page.getByRole("button", { name: "Map", exact: true }); if (await mapTab.isVisible()) await mapTab.click(); const cv = page.locator(".atlas-map > canvas"); + // Labels only move while the camera eases; read them once it has settled. + await expect.poll(() => motion(page)).toBe("ambient"); await expect.poll(() => cv.evaluate((c: any, key: string) => c.__labels?.some((p: any) => p.text === key), keys[0])).toBe(true); const label = await cv.evaluate((c: any, key: string) => c.__labels.find((p: any) => p.text === key), keys[0]), box = (await cv.boundingBox())!; await page.mouse.click(box.x + label.x, box.y + label.y); @@ -94,10 +98,20 @@ test("SYNTHETIC actual painted map label selects the same repository as its visi await page.getByRole("link", { name: "Back to Atlas", exact: true }).click(); await expect(row(page, keys[0])).toHaveAttribute("aria-pressed", "true"); await expect(page.locator(".atlas-card")).toContainText("a".repeat(40)); + // Ambient twinkle/route motion runs at about 30fps, never the display rate. + await expect.poll(() => motion(page)).toBe("ambient"); + const start = await frames(cv); + await page.waitForTimeout(1000); + const ambient = await frames(cv) - start; + expect(ambient).toBeGreaterThan(5); + expect(ambient).toBeLessThanOrEqual(36); + // Switching to reduced motion stops the loop: static frames on demand only. + await page.emulateMedia({ reducedMotion: "reduce" }); + await expect.poll(() => motion(page)).toBe("still"); await page.waitForTimeout(150); - const before = await cv.evaluate((c: any) => c.__frames); - await page.waitForTimeout(200); - expect(await cv.evaluate((c: any) => c.__frames)).toBe(before); + const before = await frames(cv); + await page.waitForTimeout(250); + expect(await frames(cv)).toBe(before); const dimensions = await cv.evaluate((c: any) => ({ width: c.width, height: c.height, css: c.getBoundingClientRect().width })); expect(dimensions.width / dimensions.css).toBeLessThanOrEqual(1.501); expect(dimensions.width * dimensions.height).toBeLessThanOrEqual(4000000); @@ -135,6 +149,34 @@ test("SYNTHETIC hidden Atlas map stops drawing, resumes one state-correct frame expect(api.requests.length).toBe(requests); await page.getByRole("button", { name: "Map", exact: true }).click(); await expect.poll(() => cv.evaluate((c: any) => c.__frames)).toBeGreaterThan(frames); + await expect.poll(() => motion(page)).toBe("still"); +}); +test("SYNTHETIC ambient Atlas motion pauses for the hidden list pane and hidden tab, then resumes", async ({ page, api }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.setViewportSize({ width: 390, height: 844 }); + await fixture(page, api); + await page.goto(atlasPath); + const cv = page.locator(".atlas-map > canvas"); + await expect.poll(() => motion(page)).toBe("ambient"); + await expect.poll(() => frames(cv)).toBeGreaterThan(2); + const requests = api.requests.length; + await showList(page); + await expect.poll(() => motion(page)).toBe("paused"); + let paused = await frames(cv); + await page.waitForTimeout(400); + expect(await frames(cv)).toBe(paused); + await page.getByRole("button", { name: "Map", exact: true }).click(); + await expect.poll(() => motion(page)).toBe("ambient"); + await expect.poll(() => frames(cv)).toBeGreaterThan(paused + 2); + // SYNTHETIC tab-hidden signal: Playwright cannot background a tab, so fake document.hidden. + await page.evaluate(() => { Object.defineProperty(document, "hidden", { configurable: true, get: () => true }); document.dispatchEvent(new Event("visibilitychange")); }); + await expect.poll(() => motion(page)).toBe("paused"); + paused = await frames(cv); + await page.waitForTimeout(400); + expect(await frames(cv)).toBe(paused); + await page.evaluate(() => { Object.defineProperty(document, "hidden", { configurable: true, get: () => false }); document.dispatchEvent(new Event("visibilitychange")); }); + await expect.poll(() => frames(cv)).toBeGreaterThan(paused + 2); + expect(api.requests.length).toBe(requests); }); test("SYNTHETIC real two-touch pinch and cancellation never become repository entry or collection", async ({ page, api, context }) => { await page.setViewportSize({ width: 390, height: 1000 }); diff --git a/e2e/gallery-preview.spec.ts b/e2e/gallery-preview.spec.ts index 6dc22c6..27b2a10 100644 --- a/e2e/gallery-preview.spec.ts +++ b/e2e/gallery-preview.spec.ts @@ -50,11 +50,28 @@ test("SYNTHETIC cache world shares the real renderer, preserves source stamp, pa await page.goto(`${basePath}/`); const card = page.locator(".project-card").filter({ has: page.getByRole("heading", { name: "SYNTHETIC Atlas", exact: true }) }); await card.scrollIntoViewIfNeeded(); - await expect(card).toContainText("Stale cache"); - await expect(card).toContainText("1234567"); - await expect(card).toContainText("2 observed / 1 inferred"); + // Calm summary on the art; the full capture record sits one keyboard press away. + const shell = page.locator(".project-card-shell").filter({ has: card }); + const chip = shell.locator(".card-provenance .provenance-trigger"); + await expect(chip).toContainText("Stale snapshot"); + await expect(chip).toContainText("districts"); + await expect(chip).toHaveAttribute("aria-expanded", "false"); + const record = shell.getByRole("group", { name: "Capture details for synthetic-moonshot/browser-fixture", includeHidden: true }); + await expect(record).toBeHidden(); + await expect(record).toContainText("Stale cache"); + await chip.focus(); + await page.keyboard.press("Enter"); + await expect(chip).toHaveAttribute("aria-expanded", "true"); + await expect(record).toBeVisible(); + await expect(record).toContainText("Stale cache · current state unknown"); + await expect(record).toContainText("1234567"); + await expect(record).toContainText("2 observed / 1 inferred"); + await expect(record).toContainText("2026-11-04 16:00 UTC"); + await expect(record).toContainText("Motion illustrates links · not live activity"); + await page.keyboard.press("Escape"); + await expect(record).toBeHidden(); + await expect(chip).toBeFocused(); await expect(page.getByRole("link", { name: "GitHub source: synthetic-moonshot/browser-fixture", exact: true })).toHaveAttribute("href", "https://github.com/synthetic-moonshot/browser-fixture"); - await expect(card).toContainText("2026-11-04 16:00 UTC"); const canvas = card.getByTestId("repository-preview-canvas"); await expect(canvas).toBeVisible(); await card.screenshot({ path: "artifacts/gallery-preview-synthetic.png" }); @@ -96,7 +113,9 @@ test("SYNTHETIC all five visible cached styles animate without interaction and f await page.locator(".worlds-section").scrollIntoViewIfNeeded(); const canvases = page.getByTestId("repository-preview-canvas"); await expect(canvases).toHaveCount(5); - await expect(page.getByText("Motion illustrates links · not live activity", { exact: true })).toHaveCount(5); + // Every card keeps the "not live activity" note in its provenance record (disclosed on demand). + await expect(page.locator(".card-provenance .provenance-panel").getByText("Motion illustrates links · not live activity", { exact: true })).toHaveCount(5); + await expect(page.locator(".card-provenance .provenance-trigger")).toHaveCount(5); const signatures = () => canvases.evaluateAll((elements) => elements.map((element) => { const canvas = element as HTMLCanvasElement; const pixels = canvas.getContext("2d")!.getImageData(0, 0, canvas.width, canvas.height).data; diff --git a/e2e/share-preview.spec.ts b/e2e/share-preview.spec.ts new file mode 100644 index 0000000..6e742ba --- /dev/null +++ b/e2e/share-preview.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from "@playwright/test"; + +// Server-rendered share metadata as a crawler sees it (no JavaScript), plus the +// image endpoints. The offline runtime has no cached GitHub world, so world +// cards must fall back to the generic card without any outbound request. +const meta = (html: string, key: string) => + html.match(new RegExp(` { + const base = new URL(baseURL!); + const html = await (await request.get("world/synthetic-moonshot/browser-fixture?style=factorio")).text(); + expect(html).toContain("synthetic-moonshot/browser-fixture as a Factorio world · Moon Factory"); + expect(meta(html, "og:image")).toMatch(new RegExp(`^https?://[^/]+${base.pathname}og/world/synthetic-moonshot/browser-fixture\\?style=factorio$`)); + expect(meta(html, "og:url")).toMatch(new RegExp(`^https?://[^/]+${base.pathname}world/synthetic-moonshot/browser-fixture\\?style=factorio$`)); + expect(meta(html, "twitter:card")).toBe("summary_large_image"); + expect(html.match(/property="og:image"/g)).toHaveLength(1); + const home = await (await request.get("")).text(); + expect(meta(home, "og:image")).toMatch(new RegExp(`${base.pathname}og/home\\.png$`)); + expect(meta(home, "theme-color")).toBeTruthy(); +}); + +test("SYNTHETIC share images are PNGs; a cache miss serves the generic card briefly", async ({ request }) => { + const home = await request.get("og/home.png"); + expect(home.status()).toBe(200); + expect(home.headers()["content-type"]).toBe("image/png"); + const body = await home.body(); + expect([...body.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + expect([body.readUInt32BE(16), body.readUInt32BE(20)]).toEqual([1200, 630]); + const miss = await request.get("og/world/synthetic-moonshot/browser-fixture?style=civilization"); + expect(miss.status()).toBe(200); + expect(miss.headers()["x-og-image"]).toBe("home"); + expect(miss.headers()["cache-control"]).toBe("public, max-age=300, s-maxage=600"); + expect((await request.get("og/world/..%2F/x")).status()).toBe(400); +}); diff --git a/e2e/tour.spec.ts b/e2e/tour.spec.ts new file mode 100644 index 0000000..c8ae4bf --- /dev/null +++ b/e2e/tour.spec.ts @@ -0,0 +1,268 @@ +import type { Page, Route } from "@playwright/test"; +import type { FeaturedProject, NodeKind, WorldSnapshot } from "../src/contracts"; +import { basePath, test, expect, type ApiReply } from "./fixtures"; + +const SHOTS = process.env.MOONSHOT_TOUR_SHOTS; +const tourPath = `${basePath}/tour`; +const STYLES = ["moonshot", "factorio", "red-alert", "red-alert-2", "civilization"]; +type Api = { world: WorldSnapshot; respond: (method: string, path: string, handler: (route: Route) => ApiReply | Promise) => void }; + +/** SYNTHETIC city: invented districts and links, never live GitHub evidence. */ +function city(base: WorldSnapshot, owner: string, name: string, size = 14): WorldSnapshot { + const kinds: NodeKind[] = ["app", "service", "package", "data", "infra", "docs", "test", "tool", "service", "package"]; + const words = ["web", "gateway", "core", "storage", "deploy", "docs", "checks", "cli", "worker", "sdk", "search", "auth", "billing", "ui"]; + const fullName = `${owner}/${name}`, + url = `https://github.com/${fullName}`; + const nodes = Array.from({ length: size }, (_, i) => ({ + ...base.nodes[0], + id: `n${i}`, + label: words[i % words.length], + kind: kinds[i % kinds.length], + path: `${["apps", "services", "packages", "data", "infra", "docs", "test", "tools"][i % 8]}/${words[i % words.length]}`, + size: 40 - i, + status: (["active", "unknown", "healthy", "attention"] as const)[i % 4], + confidence: i % 3 === 2 ? ("inferred" as const) : ("observed" as const), + })); + const edges = nodes.slice(1).map((n, i) => ({ + id: `e${i}`, source: nodes[Math.floor(i / 2)].id, target: n.id, label: "SYNTHETIC relationship", kind: "dependency" as const, confidence: "inferred" as const, + })); + return { + ...base, + source: "github", + repo: { ...base.repo, owner, name, fullName, url, description: `SYNTHETIC ${name} fixture — invented architecture for UI tests.` }, + nodes, + edges, + commits: Array.from({ length: 10 }, (_, i) => ({ ...base.commits[0], id: `c${i}` })), + blueprint: { ...base.blueprint, districts: [{ nodeId: "n1", title: "SYNTHETIC", purpose: "SYNTHETIC" }] }, + }; +} +const row = (fullName: string, ready: boolean, suggestedStyle: FeaturedProject["suggestedStyle"]): FeaturedProject => ({ + fullName, name: `SYNTHETIC ${fullName}`, description: "SYNTHETIC gallery row.", category: "SYNTHETIC", language: "TypeScript", stars: 1, ready, collectedAt: ready ? "2026-11-04T16:00:00Z" : null, suggestedStyle, +}); + +/** Gallery: two cached worlds, one ready-but-missing cache, one not ready (must never be read). */ +function tourApi(api: Api, options: { allMissing?: boolean } = {}) { + const worlds = new Map([ + ["synthetic-tour/harbor", city(api.world, "synthetic-tour", "harbor")], + ["synthetic-tour/observatory", city(api.world, "synthetic-tour", "observatory", 11)], + ]); + const gallery = [ + row("synthetic-tour/harbor", true, "civilization"), + row("synthetic-tour/missing", true, "factorio"), + row("synthetic-tour/observatory", true, "red-alert"), + row("synthetic-tour/not-ready", false, "moonshot"), + ]; + const queries: string[] = []; + api.respond("GET", "/api/gallery", () => ({ body: { projects: gallery } })); + for (const p of gallery) { + api.respond("GET", `/api/gallery-preview/${p.fullName}`, () => ({ body: { state: "unknown", reason: "cache-miss", fullName: p.fullName, headSha: null, collectedAt: null, refreshAfter: null, github: "unknown", tree: "unavailable", nodes: [], edges: [], totalNodes: 0, totalEdges: 0, limited: false } })); + if (!p.ready) continue; + api.respond("GET", `/api/world/${p.fullName}`, (route) => { + queries.push(`${p.fullName}${new URL(route.request().url()).search}`); + const world = options.allMissing ? undefined : worlds.get(p.fullName); + return world ? { body: world } : { status: 404, body: { error: "Not collected. No collection was requested.", code: "CACHE_MISS" } }; + }); + } + return { queries }; +} +async function countFrames(page: Page) { + await page.addInitScript(() => { + const native = window.requestAnimationFrame.bind(window); + const w = window as unknown as { __raf: { requested: number; ran: number; pending: number } }; + w.__raf = { requested: 0, ran: 0, pending: 0 }; + const cancel = window.cancelAnimationFrame.bind(window), live = new Set(); + window.requestAnimationFrame = (cb) => { + w.__raf.requested++; + const id = native((t) => { live.delete(id); w.__raf.ran++; cb(t); }); + live.add(id); + return id; + }; + window.cancelAnimationFrame = (id) => { live.delete(id); cancel(id); }; + setInterval(() => { w.__raf.pending = live.size; }, 20); + }); +} +const frames = (page: Page) => page.evaluate(() => ({ ...(window as unknown as { __raf: { requested: number; ran: number } }).__raf })); +async function expectNoFrames(page: Page, duration = 800) { + await page.waitForTimeout(150); + const before = await frames(page); + await page.waitForTimeout(duration); + const after = await frames(page); + expect(after.requested, "no requestAnimationFrame work may be scheduled").toBe(before.requested); +} +const tour = (page: Page) => page.getByTestId("tour"); + +test("SYNTHETIC tour starts from the homepage, advances beats, switches styles, skips misses and only GETs the cache", async ({ page, api }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + const { queries } = tourApi(api); + await page.goto(`${basePath}/`); + await page.getByRole("link", { name: "Take the tour" }).click(); + await expect(page).toHaveURL(new RegExp(`${tourPath}$`)); + await expect(tour(page)).toHaveAttribute("data-state", "playing"); + await expect(tour(page)).toHaveAttribute("data-mode", "motion"); + await expect(tour(page)).toHaveAttribute("data-beat", "0"); + await expect(page.locator(".tour-caption h1")).toHaveText("Every codebase. A world of its own."); + await expect(page.getByRole("progressbar", { name: "Tour progress" })).toHaveAttribute("aria-valuetext", /Beat 1 of 9/); + // Hands-free: the intro advances on its own. + await expect(tour(page)).toHaveAttribute("data-beat", "1", { timeout: 7000 }); + await expect(page.locator(".tour-caption h1")).toHaveText("synthetic-tour/harbor"); + await expect(page.locator(".tour-caption p")).toHaveText("SYNTHETIC harbor fixture — invented architecture for UI tests."); + // Same code, different worlds: five perspectives on the first world. + const seen: string[] = []; + for (let i = 0; i < 5; i++) { + const style = (await tour(page).getAttribute("data-style"))!; + seen.push(style); + await expect(page.locator(`.tour-stage .world-canvas-wrap.style-${style}`)).toBeVisible(); + await expect(tour(page)).toHaveAttribute("data-repo", "synthetic-tour/harbor"); + await page.keyboard.press("ArrowRight"); + await expect(tour(page)).toHaveAttribute("data-beat", String(i + 2)); + } + expect(seen).toEqual(STYLES); + await expect(page.locator(".tour-fade")).toHaveClass(/is-fading/); + // The missing cache is skipped without breaking the tour; the next world is the third row. + await expect(tour(page)).toHaveAttribute("data-repo", "synthetic-tour/observatory"); + await expect(tour(page)).toHaveAttribute("data-style", "red-alert"); + await page.keyboard.press("ArrowRight"); + await expect(tour(page)).toHaveAttribute("data-style", "civilization"); + await expect(page.locator(".tour-caption .evidence-badge")).toHaveText(/SOURCE OBSERVED|STRUCTURE INFERRED/); + await page.keyboard.press("ArrowLeft"); + await expect(tour(page)).toHaveAttribute("data-beat", "6"); + await page.keyboard.press("ArrowRight"); + await page.keyboard.press("ArrowRight"); + await expect(tour(page)).toHaveAttribute("data-beat-kind", "end"); + await expect(page.getByRole("link", { name: "Build your world" })).toHaveAttribute("href", `${basePath}/#launch`); + await expect(page.getByRole("link", { name: "Explore Atlas" })).toHaveAttribute("href", `${basePath}/atlas`); + expect(queries.sort()).toEqual(["synthetic-tour/harbor?mode=cached", "synthetic-tour/missing?mode=cached", "synthetic-tour/observatory?mode=cached"]); + expect(api.requests.every((r) => r.method === "GET"), "the tour must never POST").toBe(true); + expect(api.requests.some((r) => r.path.includes("blueprint") || r.path.includes("budget") || r.path.includes("not-ready"))).toBe(false); + // Esc exits to the homepage. + await page.keyboard.press("Escape"); + await expect(page).toHaveURL(new RegExp(`${basePath}/$`)); +}); + +test("SYNTHETIC Space pauses, Skip jumps to the end card, Replay restarts, close exits", async ({ page, api }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + tourApi(api); + await page.goto(tourPath); + await expect(tour(page)).toHaveAttribute("data-state", "playing"); + await page.keyboard.press("Space"); + await expect(tour(page)).toHaveAttribute("data-state", "paused"); + await expect(page.getByRole("button", { name: "Play tour" })).toBeVisible(); + await page.waitForTimeout(4200); + await expect(tour(page)).toHaveAttribute("data-beat", "0"); + await page.keyboard.press("Space"); + await expect(tour(page)).toHaveAttribute("data-state", "playing"); + await page.getByRole("button", { name: "Skip tour" }).click(); + await expect(tour(page)).toHaveAttribute("data-beat-kind", "end"); + await expect(page.getByRole("heading", { name: "Build your world." })).toBeVisible(); + await expect(page.getByRole("list", { name: "Worlds in this tour" }).getByRole("link")).toHaveText(["synthetic-tour/harbor", "synthetic-tour/observatory"]); + await expect(tour(page)).toHaveAttribute("data-state", "ended", { timeout: 7000 }); + await page.getByRole("button", { name: "Replay tour" }).click(); + await expect(tour(page)).toHaveAttribute("data-beat", "0"); + await expect(tour(page)).toHaveAttribute("data-state", "playing"); + await page.getByRole("button", { name: "Close tour" }).click(); + await expect(page).toHaveURL(new RegExp(`${basePath}/$`)); + expect(api.requests.every((r) => r.method === "GET")).toBe(true); +}); + +test("SYNTHETIC reduced motion plays a stepped slideshow with no camera flight or idle frames", async ({ page, api }) => { + await countFrames(page); + tourApi(api); + await page.goto(tourPath); + await expect(tour(page)).toHaveAttribute("data-mode", "slideshow"); + await expect(tour(page)).toHaveAttribute("data-state", "playing"); + await expect.poll(async () => (await frames(page)).ran).toBeGreaterThan(0); + await expect(page.locator(".tour-fade")).toBeHidden(); + await expectNoFrames(page); + for (const beat of ["1", "2", "3"]) { + await page.keyboard.press("ArrowRight"); + await expect(tour(page)).toHaveAttribute("data-beat", beat); + // A slide is one still repaint, then nothing: the camera snaps instead of flying. + await expectNoFrames(page, 500); + } + await expect(page.locator(".tour-stage .world-canvas-wrap.style-red-alert")).toBeVisible(); + // Still hands-free: the slideshow advances by itself. + await expect(tour(page)).toHaveAttribute("data-beat", "4", { timeout: 6000 }); +}); + +test("SYNTHETIC hidden page runs zero requestAnimationFrame work and resumes when visible", async ({ page, api }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await countFrames(page); + tourApi(api); + await page.goto(tourPath); + await expect(tour(page)).toHaveAttribute("data-state", "playing"); + const start = await frames(page); + await page.waitForTimeout(600); + expect((await frames(page)).ran - start.ran, "the visible tour animates").toBeGreaterThan(10); + await page.evaluate(() => { + Object.defineProperty(document, "hidden", { configurable: true, get: () => true }); + document.dispatchEvent(new Event("visibilitychange")); + }); + await expect(tour(page)).toHaveAttribute("data-state", "paused"); + await expectNoFrames(page, 1500); + const beat = await tour(page).getAttribute("data-beat"); + await page.waitForTimeout(3000); + await expect(tour(page)).toHaveAttribute("data-beat", beat!); + await page.evaluate(() => { + Object.defineProperty(document, "hidden", { configurable: true, get: () => false }); + document.dispatchEvent(new Event("visibilitychange")); + }); + await expect(tour(page)).toHaveAttribute("data-state", "playing"); + const resumed = await frames(page); + await expect.poll(async () => (await frames(page)).ran).toBeGreaterThan(resumed.ran + 5); +}); + +test("SYNTHETIC ?record=1 hides cursor and controls and loops", async ({ page, api }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + tourApi(api); + await page.goto(`${tourPath}?record=1`); + await expect(tour(page)).toHaveAttribute("data-record", "1"); + await expect(tour(page)).toHaveAttribute("data-state", "playing"); + await expect(page.getByRole("group", { name: "Tour controls" })).toBeHidden(); + expect(await tour(page).evaluate((el) => getComputedStyle(el).cursor)).toBe("none"); + for (let i = 0; i < 8; i++) await page.keyboard.press("ArrowRight"); + await expect(tour(page)).toHaveAttribute("data-beat-kind", "end"); + // Instead of stopping on the end card, the recording loops back to the intro. + await expect(tour(page)).toHaveAttribute("data-beat", "0", { timeout: 8000 }); + await expect(tour(page)).toHaveAttribute("data-state", "playing"); +}); + +test("SYNTHETIC tour with no cached worlds shows the end card instead of breaking", async ({ page, api }) => { + tourApi(api, { allMissing: true }); + await page.goto(tourPath); + await expect(tour(page)).toHaveAttribute("data-state", "empty"); + await expect(page.getByText("NO CACHED WORLDS YET")).toBeVisible(); + await expect(page.getByRole("link", { name: "Build your world" })).toBeVisible(); + await expect(page.getByTestId("world-canvas")).toHaveCount(0); + expect(api.requests.every((r) => r.method === "GET")).toBe(true); +}); + +test("SYNTHETIC tour screenshots (only with MOONSHOT_TOUR_SHOTS)", async ({ page, api }) => { + test.skip(!SHOTS, "Set MOONSHOT_TOUR_SHOTS= to capture review screenshots."); + test.setTimeout(150_000); + await page.emulateMedia({ reducedMotion: "no-preference" }); + tourApi(api); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(tourPath); + await expect(tour(page)).toHaveAttribute("data-state", "playing"); + // Real-time pass: two frames per beat (just after the cut, then settled). + for (let beat = 0; beat < 9; beat++) { + await expect(tour(page)).toHaveAttribute("data-beat", String(beat), { timeout: 8000 }); + await page.waitForTimeout(450); + await page.screenshot({ path: `${SHOTS}/desktop-${String(beat).padStart(2, "0")}a.png` }); + await page.waitForTimeout(1900); + await page.screenshot({ path: `${SHOTS}/desktop-${String(beat).padStart(2, "0")}b.png` }); + } + await page.waitForTimeout(1600); + await page.screenshot({ path: `${SHOTS}/desktop-end.png` }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(tourPath); + await expect(tour(page)).toHaveAttribute("data-beat", "1", { timeout: 8000 }); + await page.waitForTimeout(2000); + await page.screenshot({ path: `${SHOTS}/mobile-390-beat1.png` }); + await page.keyboard.press("ArrowRight"); + await page.waitForTimeout(2200); + await page.screenshot({ path: `${SHOTS}/mobile-390-beat2.png` }); + await page.getByRole("button", { name: "Skip tour" }).click(); + await page.waitForTimeout(1500); + await page.screenshot({ path: `${SHOTS}/mobile-390-end.png` }); +}); diff --git a/e2e/world-chrome.spec.ts b/e2e/world-chrome.spec.ts new file mode 100644 index 0000000..596bb45 --- /dev/null +++ b/e2e/world-chrome.spec.ts @@ -0,0 +1,77 @@ +import { test, expect, openWorld } from "./fixtures"; + +test("SYNTHETIC world provenance is a calm chip whose full record opens by keyboard", async ({ page, api }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await openWorld(page); + const chip = page.locator(".world-provenance .provenance-trigger"); + // The fixture is an illustration (source "demo"); the summary must still say so. + await expect(chip).toContainText("Illustration"); + await expect(chip).toHaveAttribute("aria-expanded", "false"); + const record = page.getByRole("group", { name: "Snapshot provenance", includeHidden: true }); + await expect(record).toBeHidden(); + await chip.focus(); + await page.keyboard.press("Enter"); + await expect(record).toBeVisible(); + await expect(record).toContainText("ILLUSTRATION"); + await expect(record).toContainText(/\d+ observed \/ \d+ inferred/); + await expect(record).toContainText(/\d+ unknown/); + await expect(record).toContainText("not runtime telemetry"); + await expect(record).toContainText("motion illustrates connections"); + await expect(record.getByRole("link", { name: new RegExp(api.world.headSha.slice(0, 7)) })).toHaveAttribute( + "href", + `${api.world.repo.url}/commit/${api.world.headSha}`, + ); + await page.keyboard.press("Escape"); + await expect(record).toBeHidden(); + await expect(chip).toBeFocused(); + expect(api.requests.filter((r) => r.method === "POST")).toEqual([]); +}); + +test("SYNTHETIC stale world keeps the stale state reachable inside the disclosure", async ({ page, api }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + api.world.source = "github"; // SYNTHETIC only: invented facts, never live evidence. + api.world.refreshAfter = Date.parse("2026-11-04T17:00:00Z"); // already elapsed -> stale + await openWorld(page); + const chip = page.locator(".world-provenance .provenance-trigger"); + await expect(chip).toContainText("Stale snapshot"); + await chip.click(); + const record = page.getByRole("group", { name: "Snapshot provenance" }); + await expect(record).toBeVisible(); + await expect(record.locator(".provenance-status")).toHaveText("STALE CACHE · CURRENT STATE UNKNOWN"); + // Outside pointerdown closes: empty topline space between the chip and the tabs. + const chipBox = (await chip.boundingBox())!; + await page.mouse.click(chipBox.x + chipBox.width + 40, chipBox.y + chipBox.height / 2); + await expect(record).toBeHidden(); + // Mobile keeps the same record behind the shorter "Stale" chip. + await page.setViewportSize({ width: 390, height: 844 }); + await expect(chip).toContainText("Stale"); + await chip.click(); + await expect(record).toBeInViewport(); + await expect(record).toContainText("STALE CACHE · CURRENT STATE UNKNOWN"); +}); + +for (const width of [320, 390, 768]) { + test(`SYNTHETIC world page fits ${width}px: no page overflow, one-row topline, every style reachable`, async ({ page }) => { + await page.setViewportSize({ width, height: 844 }); + await openWorld(page); + expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0); + const topline = await page.locator(".world-topline").boundingBox(); + expect(topline!.height).toBeLessThanOrEqual(60); + for (const selector of [".world-tabs", ".world-provenance", ".world-source-stamp"]) { + const box = (await page.locator(`.world-topline > ${selector}`).boundingBox())!; + expect(box.y + box.height).toBeLessThanOrEqual(topline!.y + topline!.height + 1); + expect(box.x + box.width).toBeLessThanOrEqual(width); + } + await expect(page.getByRole("button", { name: "Refresh GitHub data" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Map legend", exact: true })).toBeVisible(); + const dock = page.getByRole("group", { name: "World perspective" }); + const dockBox = (await dock.boundingBox())!; + expect(dockBox.x).toBeGreaterThanOrEqual(0); + expect(dockBox.x + dockBox.width).toBeLessThanOrEqual(width); + const civilization = dock.getByRole("button").filter({ hasText: /Civilization$/ }); + await civilization.click(); + await expect(civilization).toHaveClass(/active/); + await expect(civilization).toBeInViewport({ ratio: 1 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0); + }); +} diff --git a/src/atlas/AtlasCanvas.tsx b/src/atlas/AtlasCanvas.tsx index 42a4108..b5f72db 100644 --- a/src/atlas/AtlasCanvas.tsx +++ b/src/atlas/AtlasCanvas.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; -import { constrain, fit, pan, resizeCamera, wheelFactor, zoomAt, type Camera, type Point, type Size } from "./camera"; -import { homeBounds, type CacheStatus, type Landmark } from "./model"; +import { constrain, easeCamera, fit, pan, resizeCamera, unproject, wheelFactor, zoomAt, type Camera, type Point, type Size } from "./camera"; +import { homeBounds, statusText, type CacheStatus, type Landmark } from "./model"; import { contains, paintAtlas, type HitTarget } from "./paint"; +import { createScenery } from "./scenery"; interface Props { landmarks: Landmark[]; statuses: Map; @@ -20,15 +21,20 @@ interface Props { onClear: () => void; onView: (camera: Camera, homeZoom: number, commit: boolean) => void; } +type Change = { animate?: boolean; anchor?: { screen: Point; world: Point } | null }; +/** Ambient scenery is capped near 30fps; camera easing runs at display rate. */ +const AMBIENT_MS = 1000 / 30 - 4, STILL_TIME = 4.2; export function AtlasCanvas(props: Props) { const canvas = useRef(null), wrap = useRef(null), data = useRef(props); data.current = props; const view = useRef<{ camera: Camera; + target: Camera; + anchor: Change["anchor"]; size: Size; initialized: boolean; - }>({ camera: { centerX: 1400, centerY: 1000, zoom: .2 }, size: { width: 1, height: 1 }, initialized: false }); - const targets = useRef([]), invalidate = useRef(() => { }), commitView = useRef<(c: Camera, commit?: boolean) => void>(() => { }), cancelGesture = useRef<(silent?: boolean) => void>(() => { }); + }>({ camera: { centerX: 1400, centerY: 1000, zoom: .2 }, target: { centerX: 1400, centerY: 1000, zoom: .2 }, anchor: null, size: { width: 1, height: 1 }, initialized: false }); + const targets = useRef([]), invalidate = useRef(() => { }), commitView = useRef<(c: Camera, commit?: boolean, how?: Change) => void>(() => { }), cancelGesture = useRef<(silent?: boolean) => void>(() => { }); const pointers = useRef(new Map()), gesture = useRef<{ mode: "drag" | "pinch"; camera: Camera; @@ -40,55 +46,97 @@ export function AtlasCanvas(props: Props) { } | null>(null), suppressTap = useRef(false); const [unavailable, setUnavailable] = useState(false), [tooltip, setTooltip] = useState<{ text: string; + detail: string; x: number; y: number; key: string; } | null>(null); - const hoverTimer = useRef>(), hoverKey = useRef(null); + const hoverTimer = useRef>(), hoverKey = useRef(null), hoverMix = useRef(0); + const setHover = (key: string | null) => { if (hoverKey.current === key) return; hoverKey.current = key; hoverMix.current = 0; if (canvas.current) canvas.current.style.cursor = key ? "pointer" : ""; invalidate.current(); }; const clearHover = () => { if (hoverTimer.current) - clearTimeout(hoverTimer.current); hoverTimer.current = undefined; hoverKey.current = null; setTooltip(null); invalidate.current(); }; - const change = (c: Camera, commit = false) => commitView.current(c, commit); - const home = () => change(fit(homeBounds(data.current.landmarks), view.current.size), true); + clearTimeout(hoverTimer.current); hoverTimer.current = undefined; setHover(null); setTooltip(null); }; + const change = (c: Camera, commit = false, how: Change = {}) => commitView.current(c, commit, how); + const center = () => ({ x: view.current.size.width / 2, y: view.current.size.height / 2 }); + const zoomBy = (factor: number, at: Point = center()) => { const v = view.current; change(zoomAt(v.target, factor, at, v.size), true, { animate: true, anchor: { screen: at, world: unproject(at, v.target, v.size) } }); }; + const home = () => change(fit(homeBounds(data.current.landmarks), view.current.size), true, { animate: true }); const locate = (key: string | null) => { const p = data.current.landmarks.find(x => x.repoKey === key); if (p) - change({ ...view.current.camera, centerX: p.x, centerY: p.y }, true); }; + change({ ...view.current.target, centerX: p.x, centerY: p.y }, true, { animate: true }); }; useEffect(() => { const cv = canvas.current, el = wrap.current; if (!cv || !el) return; - let raf = 0, visible = true, disposed = false, lastPaint = -Infinity; + let raf = 0, timer: ReturnType | 0 = 0, visible = true, disposed = false, stepAt = 0, motionState = ""; const context = cv.getContext("2d"); if (!context) { setUnavailable(true); return; } + const scenery = createScenery(context), started = performance.now(), reduced = window.matchMedia("(prefers-reduced-motion: reduce)"); + let motion = !reduced.matches; const eligible = () => !disposed && visible && !document.hidden && el.clientWidth > 0 && el.clientHeight > 0; - const request = () => { if (!raf && eligible()) + const request = () => { if (timer) { + clearTimeout(timer); + timer = 0; + } if (!raf && eligible()) raf = requestAnimationFrame(draw); }; + const mark = (state: string) => { if (state !== motionState) { + motionState = state; + el.dataset.motion = state; + } }; const draw = (now: number) => { raf = 0; if (!eligible()) return; - if (now - lastPaint < 1000 / 30) { - request(); - return; + const v = view.current, dt = Math.max(0, now - (stepAt || now)), size = v.size, ratio = cv.width / size.width; + stepAt = now; + let moving = false; + if (v.camera !== v.target) { + const next = motion ? easeCamera(v.camera, v.target, dt, size, v.anchor) : null; + v.camera = next ?? v.target; + if (next) + moving = true; + else + v.anchor = null; } - lastPaint = now; - const { size, camera } = view.current, ratio = cv.width / size.width; + const easingHover = motion && !!hoverKey.current && hoverMix.current < 1; + hoverMix.current = motion ? Math.min(1, hoverMix.current + dt / 140) : 1; context.setTransform(ratio, 0, 0, ratio, 0, 0); - targets.current = paintAtlas(context, data.current.landmarks, camera, size, data.current.statuses, data.current.selected, hoverKey.current, data.current.lit); + targets.current = paintAtlas(context, { points: data.current.landmarks, camera: v.camera, size, statuses: data.current.statuses, selected: data.current.selected, hover: hoverKey.current, hoverMix: hoverMix.current, lit: data.current.lit ?? new Set(), time: motion ? (now - started) / 1000 : STILL_TIME, animated: motion, ratio, scenery }); + mark(moving ? "camera" : motion ? "ambient" : "still"); + if (moving || easingHover) + request(); + else if (motion) + timer = setTimeout(() => { timer = 0; request(); }, AMBIENT_MS); }; invalidate.current = request; - commitView.current = (camera, commit = false) => { view.current.camera = constrain(camera, view.current.size); data.current.onView(view.current.camera, fit(homeBounds(data.current.landmarks), view.current.size).zoom, commit); request(); }; + commitView.current = (camera, commit = false, how = {}) => { + const v = view.current, wasMoving = v.camera !== v.target; + v.target = constrain(camera, v.size); + if (how.animate && motion) { + v.anchor = how.anchor ?? null; + if (!wasMoving) + stepAt = performance.now(); + } + else { + v.camera = v.target; + v.anchor = null; + } + data.current.onView(v.target, fit(homeBounds(data.current.landmarks), v.size).zoom, commit); + request(); + }; const resize = () => { const rect = el.getBoundingClientRect(), size = { width: rect.width, height: rect.height }; if (size.width < 1 || size.height < 1) return; const old = view.current, home = homeBounds(data.current.landmarks), homeCamera = fit(home, size), saved = data.current.initialView; - const camera = old.initialized ? resizeCamera(old.camera, old.size, size, home) : saved ? constrain({ centerX: saved.centerX, centerY: saved.centerY, zoom: homeCamera.zoom * saved.zoomRatio }, size) : constrain(homeCamera, size); + const camera = old.initialized ? resizeCamera(old.target, old.size, size, home) : saved ? constrain({ centerX: saved.centerX, centerY: saved.centerY, zoom: homeCamera.zoom * saved.zoomRatio }, size) : constrain(homeCamera, size); const pixels = size.width < 600 ? 1500000 : 4000000, ratio = Math.min(1.5, window.devicePixelRatio || 1, Math.sqrt(pixels / (size.width * size.height))); cv.width = Math.max(1, Math.floor(size.width * ratio)); cv.height = Math.max(1, Math.floor(size.height * ratio)); - view.current = { camera, size, initialized: true }; + // First arrival without a saved view eases in from a wider shot (motion only). + const intro = !old.initialized && !saved && motion ? constrain({ ...camera, zoom: camera.zoom * .78 }, size) : camera; + view.current = { camera: intro, target: camera, anchor: null, size, initialized: true }; + stepAt = performance.now(); data.current.onView(camera, homeCamera.zoom, false); request(); }; @@ -97,7 +145,11 @@ export function AtlasCanvas(props: Props) { resize(); const stop = () => { if (raf) cancelAnimationFrame(raf); + if (timer) clearTimeout(timer); raf = 0; + timer = 0; + stepAt = 0; + mark("paused"); cancelGesture.current(disposed); }; const intersection = new IntersectionObserver(entries => { visible = entries[0]?.isIntersecting ?? false; if (visible) @@ -110,13 +162,15 @@ export function AtlasCanvas(props: Props) { else request(); }; document.addEventListener("visibilitychange", visibility); + const onMotion = () => { motion = !reduced.matches; request(); }; + reduced.addEventListener("change", onMotion); const blur = () => cancelGesture.current(); window.addEventListener("blur", blur); const wheel = (e: WheelEvent) => { if (e.ctrlKey || e.metaKey) - return; e.preventDefault(); clearHover(); const r = cv.getBoundingClientRect(); change(zoomAt(view.current.camera, wheelFactor(e.deltaY, e.deltaMode, r.height), { x: e.clientX - r.left, y: e.clientY - r.top }, view.current.size), true); }; + return; e.preventDefault(); clearHover(); const r = cv.getBoundingClientRect(); zoomBy(wheelFactor(e.deltaY, e.deltaMode, r.height), { x: e.clientX - r.left, y: e.clientY - r.top }); }; cv.addEventListener("wheel", wheel, { passive: false }); - return () => { disposed = true; stop(); observer.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", visibility); window.removeEventListener("blur", blur); cv.removeEventListener("wheel", wheel); if (hoverTimer.current) - clearTimeout(hoverTimer.current); cv.width = 0; cv.height = 0; invalidate.current = () => { }; commitView.current = () => { }; }; + return () => { disposed = true; stop(); observer.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", visibility); reduced.removeEventListener("change", onMotion); window.removeEventListener("blur", blur); cv.removeEventListener("wheel", wheel); if (hoverTimer.current) + clearTimeout(hoverTimer.current); scenery.dispose(); cv.width = 0; cv.height = 0; invalidate.current = () => { }; commitView.current = () => { }; }; }, []); useEffect(() => { invalidate.current(); }, [props.landmarks, props.statuses, props.selected, props.lit]); useEffect(() => { if (props.locateRequest) @@ -128,6 +182,10 @@ export function AtlasCanvas(props: Props) { return; clearHover(); const p = point(e); + if (view.current.camera !== view.current.target) { + view.current.target = view.current.camera; + view.current.anchor = null; + } pointers.current.set(e.pointerId, p); e.currentTarget.setPointerCapture(e.pointerId); const all = [...pointers.current.values()]; @@ -141,13 +199,16 @@ export function AtlasCanvas(props: Props) { const move = (e: ReactPointerEvent) => { const p = point(e), g = gesture.current; if (!pointers.current.has(e.pointerId) || !g) { - if (e.pointerType === "mouse" && !props.selected) { + if (e.pointerType === "mouse") { if (hoverTimer.current) clearTimeout(hoverTimer.current); const hit = hits(p); if (hit.length === 1 && hit[0].keys.length === 1) { const key = hit[0].keys[0], landmark = props.landmarks.find(x => x.repoKey === key); - hoverTimer.current = setTimeout(() => { hoverKey.current = key; setTooltip({ key, text: landmark?.fullName ?? key, x: Math.max(8, Math.min(p.x + 12, view.current.size.width - 288)), y: Math.max(8, Math.min(p.y - 48, view.current.size.height - 60)) }); invalidate.current(); }, 120); + setHover(key); + if (props.selected) + return; + hoverTimer.current = setTimeout(() => { setTooltip({ key, text: landmark?.fullName ?? key, detail: `${landmark ? landmark.category + " · " : ""}${statusText(props.statuses.get(key))}`, x: Math.max(8, Math.min(p.x + 16, view.current.size.width - 288)), y: Math.max(8, Math.min(p.y - 64, view.current.size.height - 76)) }); }, 120); } else clearHover(); @@ -195,6 +256,7 @@ export function AtlasCanvas(props: Props) { for (const id of ids) if (cv?.hasPointerCapture(id)) cv.releasePointerCapture(id); if (hoverTimer.current) clearTimeout(hoverTimer.current); hoverKey.current = null; + if (cv) cv.style.cursor = ""; if (!silent) setTooltip(null); }; return
@@ -203,18 +265,18 @@ export function AtlasCanvas(props: Props) { clearHover(); }} onKeyDown={e => { if (e.ctrlKey || e.metaKey || e.altKey) return; - const c = view.current.camera, s = view.current.size, step = e.shiftKey ? 128 : 64; + const s = view.current.size, step = e.shiftKey ? 128 : 64; const deltas: Record = { ArrowLeft: [step, 0], ArrowRight: [-step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] }; if (deltas[e.key]) { e.preventDefault(); - change(pan(c, ...deltas[e.key], s), true); + change(pan(view.current.target, ...deltas[e.key], s), true, { animate: true }); } else if (["+", "=", "-"].includes(e.key)) { e.preventDefault(); - change(zoomAt(c, e.key === "-" ? 1 / 1.2 : 1.2, { x: s.width / 2, y: s.height / 2 }, s), true); + zoomBy(e.key === "-" ? 1 / 1.2 : 1.2); } else if (e.key === "0") { e.preventDefault(); @@ -231,13 +293,19 @@ export function AtlasCanvas(props: Props) { props.onClear(); } }}/>} - {tooltip && !props.selected &&
{tooltip.text}
} + {tooltip && !props.selected &&
{tooltip.text}{tooltip.detail}
}
- - + +
-

Scenic routes, not dependency links · Static scenery

+ +
    +
  • Verified cached capture
  • +
  • ?Unchecked · outline only
  • +
  • ✧Lantern lit
  • +
+

Scenic routes, not dependency links · Illustrative motion, not live data

; } diff --git a/src/atlas/AtlasPage.tsx b/src/atlas/AtlasPage.tsx index 4646166..9275b24 100644 --- a/src/atlas/AtlasPage.tsx +++ b/src/atlas/AtlasPage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useNavigate } from "@tanstack/react-router"; +import { ArrowUpRight } from "lucide-react"; import type { FeaturedProject } from "../contracts"; import { getCachedWorld, getGallery } from "../client/api"; import { Brand, External } from "../components/shared"; @@ -158,7 +159,7 @@ export function AtlasPage() { save(); } }, [facts, selected, landmarks, save]); return
-

Repository Atlas

Mission Control
+

Repository Atlas

An observatory of open sourceMission Control
{progress.durableCountKnown ? `${counts.saved} saved Exploration stars` : "Saved Exploration star count unknown"} · {counts.session} session Exploration stars — not saved{resetConfirm &&

Clear only this browser’s exploration flags and stars? Atlas positions, public source caches and server budgets are preserved.

}{resetError &&

{resetError}

}{progress.warning &&

{progress.warning}

}

Click a landmark to enter its cached world, or inspect a repository first. This atlas shows its catalog and your cached survey entries.

{catalog === null ? "Loading catalog" : `${landmarks.length} listed landmarks · ${[...statuses.values()].filter(s => s.state === "cached").length} captures checked this visit`} · No full GitHub map or complete history is claimed.

{catalogError &&

{catalogError}

} @@ -169,7 +170,7 @@ export function AtlasPage() {
{ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return; const buttons = Array.from(e.currentTarget.querySelectorAll("button")), index = buttons.indexOf(document.activeElement as HTMLButtonElement); if (index < 0) return; e.preventDefault(); const next = e.key === "Home" ? 0 : e.key === "End" ? buttons.length - 1 : Math.max(0, Math.min(buttons.length - 1, index + (e.key === "ArrowDown" ? 1 : -1))); buttons[next]?.focus(); }}> - {rows.map((p, i) => )} + {rows.map((p, i) =>