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}
}
- change(zoomAt(view.current.camera, 1.2, { x: view.current.size.width / 2, y: view.current.size.height / 2 }, view.current.size), true)}>+
- change(zoomAt(view.current.camera, 1 / 1.2, { x: view.current.size.width / 2, y: view.current.size.height / 2 }, view.current.size), true)}>−
+ zoomBy(1.2)}>+
+ zoomBy(1 / 1.2)}>−
⌂
locate(props.selected)}>⌖
- Scenic routes, not dependency links · Static scenery
+ Observatory chart Editorial regions · not live data
+
+ 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 source Mission Control
{progress.durableCountKnown ? `${counts.saved} saved Exploration stars` : "Saved Exploration star count unknown"} · {counts.session} session Exploration stars — not saved { void reconcileProgress(); }}>Reload local progress setResetConfirm(true)}>Clear exploration progress {resetConfirm &&
Clear only this browser’s exploration flags and stars? Atlas positions, public source caches and server budgets are preserved.
{ void clearExploration().then(() => {setResetConfirm(false);setResetError("");}).catch(() => setResetError("Progress could not be cleared. Existing data was retained.")); }}>Confirm clear exploration progress setResetConfirm(false)}>Keep progress }{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) => x.repoKey === focusedKey) && i === 0 ? 0 : -1} onFocus={() => setFocusedKey(p.repoKey)} onClick={() => choose([p.repoKey], "list")}>{p.fullName} {statusText(statuses.get(p.repoKey))} Editorial region · {p.category} )}
+ {rows.map((p, i) => x.repoKey === focusedKey) && i === 0 ? 0 : -1} onFocus={() => setFocusedKey(p.repoKey)} onClick={() => choose([p.repoKey], "list")}>{p.fullName} {statusText(statuses.get(p.repoKey))} Editorial region · {p.category} )}
{catalog !== null && rows.length === 0 && No matching landmarks. Unlisted public repositories may already have cached worlds.
}
{filtered.length > 50 && setPage(p => p - 1)}>Previous {page + 1} / {Math.ceil(filtered.length / 50)} = filtered.length} onClick={() => setPage(p => p + 1)}>Next
}
@@ -188,7 +189,7 @@ export function AtlasPage() {
> : <>{current?.state === "miss" ? "No cached capture was found. No collection was started." : current?.state === "unavailable" ? "The cached source cannot currently be verified. Storage failure is not proof of a missing or private repository." : "Select a landmark to check existing cached facts."}
{lastReceived && Last received: {lastReceived.fullName} at {lastReceived.identity.collectedAt}; current state unknown.
{lastReceived.identity.headSha}{lastReceived.tree} tree · {lastReceived.nodes} districts / {lastReceived.edges} connections. These prior facts do not authorize entry while the source is unavailable.
}GitHub repository >}
- > : <>An observatory for code Click a landmark on the map or in the repository list to enter its world. Unchecked landmarks are outlines, not confirmed captured worlds.
Arrow keys pan, +/− zoom, 0 returns home and F locates a selection. Pinch or drag on touch screens.
Exploration starts with source evidence. Selecting or entering a world does not earn an Exploration star.
>}
+ > : <>Field guide
An observatory for code Click a landmark on the map or in the repository list to enter its world. Unchecked landmarks are outlines, not confirmed captured worlds.
Arrow keys pan, +/− zoom, 0 returns home and F locates a selection. Pinch or drag on touch screens.
Exploration starts with source evidence. Selecting or entering a world does not earn an Exploration star.
>}
{selected && chooser.length===0 && {facts && enterToken && save(enterToken)}>Enter cached world} loadFacts(selected)}>Reload cached facts
}
Public cached source · Read only · Original scenery · Local exploration is separate from GitHub stars and contributions
diff --git a/src/atlas/atlas.css b/src/atlas/atlas.css
index 25b2244..d711367 100644
--- a/src/atlas/atlas.css
+++ b/src/atlas/atlas.css
@@ -1,71 +1,123 @@
-.atlas-shell { height:100dvh; min-height:600px; display:flex; flex-direction:column; color:var(--geist-fg); background:var(--geist-bg); font-size:14px; line-height:1.6; }
-.atlas-header { display:flex; flex-wrap:wrap; gap:16px; align-items:center; min-height:64px; padding:10px 24px; background:var(--geist-surface); border-bottom:1px solid var(--geist-border); }
+.atlas-shell { --atlas-space:#070b16; --atlas-ink:#e8eef2; --atlas-dim:#b7c3cf; --atlas-teal:#7fe7dc; --atlas-panel:rgba(9,14,24,.84); --atlas-line:rgba(170,190,210,.28); height:100dvh; min-height:600px; display:flex; flex-direction:column; color:var(--geist-fg); background:var(--geist-bg); font-size:14px; line-height:1.6; }
+.atlas-header { display:flex; flex-wrap:wrap; gap:8px 18px; align-items:center; min-height:64px; padding:10px 24px; background:var(--geist-surface); border-bottom:1px solid var(--geist-border); }
.atlas-header .brand { min-height:44px; }
-.atlas-header h1 { font:500 24px/1.3 var(--heading); }
-.atlas-header > a:last-child { margin-left:auto; color:var(--geist-interactive); text-decoration:underline; text-underline-offset:3px; min-height:44px; display:flex; align-items:center; }
-.atlas-intro { display:flex; flex-wrap:wrap; justify-content:space-between; gap:4px 24px; padding:10px 24px; }
+.atlas-header h1 { font:500 22px/1.3 var(--heading); letter-spacing:-.01em; padding-left:18px; border-left:1px solid var(--geist-border); }
+.atlas-tag { font:500 12px var(--mono); letter-spacing:.06em; text-transform:uppercase; color:var(--geist-muted); }
+.atlas-home-link { margin-left:auto; display:inline-flex; align-items:center; gap:10px; min-height:44px; padding:0 16px; border:1px solid var(--geist-fg); border-radius:4px; font-size:13px; color:var(--geist-fg); transition:background .15s, color .15s; }
+.atlas-home-link:hover { background:var(--geist-fg); color:var(--geist-surface); }
+.atlas-progress-controls { display:flex; flex-wrap:wrap; align-items:center; gap:8px 10px; padding:8px 24px; background:var(--geist-surface); border-bottom:1px solid var(--geist-border); font-size:13px; color:var(--geist-secondary); }
+.atlas-progress-controls > span:first-child { display:inline-flex; align-items:center; gap:8px; margin-right:auto; font-family:var(--mono); font-size:12px; color:var(--geist-fg); }
+.atlas-progress-controls > span:first-child::before { content:"✧"; display:inline-grid; place-items:center; width:22px; height:22px; border-radius:50%; background:#1a1408; color:#ffd27a; font-size:12px; }
+.atlas-progress-controls button { min-height:36px; padding:4px 12px; border:1px solid var(--geist-border); border-radius:999px; background:var(--geist-surface); font-size:12px; color:var(--geist-fg); }
+.atlas-progress-controls button:hover { background:var(--geist-hover); border-color:var(--geist-control-border); }
+.atlas-progress-controls [role=alertdialog] { flex-basis:100%; display:flex; flex-wrap:wrap; align-items:center; gap:8px; padding:10px 12px; border:1px solid var(--geist-warning-fg); border-radius:8px; background:var(--geist-warning-bg); color:var(--geist-warning-fg); }
+.atlas-progress-controls [role=alertdialog] p { flex-basis:100%; color:var(--geist-warning-fg); }
+.atlas-progress-controls > p { flex-basis:100%; color:var(--geist-secondary); font-size:12px; }
+.atlas-progress-controls > p[role=alert] { color:var(--geist-danger-fg); background:var(--geist-danger-bg); padding:6px 8px; }
+.atlas-intro { display:flex; flex-wrap:wrap; justify-content:space-between; align-items:baseline; gap:4px 24px; padding:10px 24px 0; }
.atlas-intro p { color:var(--geist-secondary); max-width:750px; }
-.atlas-intro p+p { font-size:12px; }
-.atlas-warning { padding:8px 24px; color:var(--geist-warning-fg); background:var(--geist-warning-bg); overflow-wrap:anywhere; }
-.atlas-body { display:grid; grid-template-columns:260px minmax(0,1fr) 320px; flex:1; min-height:0; }
-.atlas-list { min-width:0; min-height:0; display:flex; flex-direction:column; background:var(--geist-surface); padding:16px; border-right:1px solid var(--geist-border); gap:10px; }
-.atlas-list > label { font-size:12px; color:var(--geist-secondary); }
-.atlas-shell input { width:100%; min-width:0; border:1px solid var(--geist-control-border); border-radius:4px; padding:10px; background:var(--geist-surface); color:var(--geist-fg); font-size:14px; }
+.atlas-intro p+p { font:12px/1.5 var(--mono); color:var(--geist-muted); }
+.atlas-warning { margin:8px 24px 0; padding:8px 12px; border-radius:6px; color:var(--geist-warning-fg); background:var(--geist-warning-bg); overflow-wrap:anywhere; }
+.atlas-body { display:grid; grid-template-columns:272px minmax(0,1fr) 328px; gap:12px; padding:12px 16px 12px; flex:1; min-height:0; }
+.atlas-list,.atlas-card { border:1px solid var(--geist-border); border-radius:12px; background:var(--geist-surface); box-shadow:0 1px 2px rgba(0,0,0,.04); }
+.atlas-list { min-width:0; min-height:0; display:flex; flex-direction:column; padding:14px; gap:10px; }
+.atlas-list > label,.atlas-inspect label { font:500 12px var(--mono); letter-spacing:.04em; text-transform:uppercase; color:var(--geist-muted); }
+.atlas-shell input { width:100%; min-width:0; border:1px solid var(--geist-control-border); border-radius:6px; padding:10px 12px; background:var(--geist-surface); color:var(--geist-fg); font-size:14px; transition:border-color .15s, box-shadow .15s; }
+.atlas-shell input:focus { border-color:var(--geist-fg); box-shadow:0 0 0 3px rgba(0,104,214,.12); }
.atlas-shell input::placeholder { color:var(--geist-muted); opacity:1; }
-.atlas-list-rows { flex:1; overflow:auto; min-height:80px; scrollbar-width:thin; }
-.atlas-list-rows > button { display:block; text-align:left; width:100%; min-height:70px; padding:12px 10px; margin-bottom:8px; border:1px solid var(--geist-control-border); border-radius:4px; }
-.atlas-list-rows > button:hover { background:var(--geist-hover); }
-.atlas-list-rows > button[aria-pressed=true] { background:var(--geist-selected); box-shadow:inset 3px 0 var(--geist-fg); }
+.atlas-list-rows { flex:1; overflow:auto; min-height:80px; scrollbar-width:thin; margin:0 -4px; padding:2px 4px; }
+.atlas-list-rows > button { display:grid; grid-template-columns:28px minmax(0,1fr); column-gap:10px; align-items:start; text-align:left; width:100%; min-height:70px; padding:11px 10px; margin-bottom:6px; border:1px solid var(--geist-border); border-radius:8px; background:var(--geist-surface); transition:border-color .15s, background .15s, transform .15s, box-shadow .15s; }
+.atlas-list-rows > button > :not(.atlas-swatch) { grid-column:2; }
+.atlas-list-rows > button:hover { background:var(--geist-hover); border-color:var(--geist-control-border); transform:translateX(2px); }
+.atlas-list-rows > button[aria-pressed=true] { background:#f2fbfa; border-color:#0f766e; box-shadow:inset 3px 0 #0f766e; }
.atlas-list-rows strong { display:block; font-size:13px; overflow-wrap:anywhere; }
.atlas-list-rows span,.atlas-list-rows small { display:block; color:var(--geist-secondary); font-size:12px; line-height:1.5; overflow-wrap:anywhere; }
-.atlas-list-rows small { margin-top:4px; color:var(--geist-muted); }
+.atlas-list-rows small { margin-top:3px; color:var(--geist-muted); }
+.atlas-swatch { grid-row:1/4; width:24px; height:24px; margin-top:2px; border-radius:50%; --p:#9a9ca1; --q:#24262c; background:radial-gradient(circle at 32% 30%, #fff8 0 8%, transparent 9%), radial-gradient(circle at 35% 35%, var(--p), var(--q) 78%); box-shadow:0 0 0 2px var(--geist-surface), 0 0 0 3px color-mix(in srgb, var(--p) 55%, transparent); }
+.atlas-swatch[data-style=factorio] { --p:#c9a95e; --q:#2a2216; }
+.atlas-swatch[data-style=red-alert] { --p:#7b9d4f; --q:#17231a; }
+.atlas-swatch[data-style=red-alert-2] { --p:#3aa0c8; --q:#06132a; }
+.atlas-swatch[data-style=civilization] { --p:#5fa0c8; --q:#0d1730; }
+.atlas-swatch:not([data-state=cached]) { background:radial-gradient(circle at 35% 35%, color-mix(in srgb, var(--p) 22%, transparent), transparent 75%); border:1.5px dashed var(--geist-control-border); box-shadow:none; }
.atlas-inspect { border-top:1px solid var(--geist-border); padding-top:12px; display:flex; flex-direction:column; gap:8px; }
-.atlas-inspect label,.atlas-inspect small { font-size:12px; color:var(--geist-secondary); }
+.atlas-inspect small { font-size:12px; color:var(--geist-secondary); }
.atlas-inspect input { font-size:12px; }
-.atlas-inspect button,.atlas-pagination button,.atlas-tabs button,.atlas-card > button { min-height:44px; padding:8px 12px; border:1px solid var(--geist-control-border); border-radius:4px; background:var(--geist-surface); }
-.atlas-inspect button:hover,.atlas-card > button:hover { background:var(--geist-hover); }
+.atlas-inspect button,.atlas-pagination button,.atlas-tabs button,.atlas-card > button { min-height:44px; padding:8px 12px; border:1px solid var(--geist-control-border); border-radius:6px; background:var(--geist-surface); }
+.atlas-inspect button:hover,.atlas-pagination button:hover,.atlas-card-content > button:hover { background:var(--geist-hover); }
.atlas-inspect [role=alert] { color:var(--geist-danger-fg); background:var(--geist-danger-bg); padding:8px; overflow-wrap:anywhere; }
.atlas-pagination { display:flex; gap:8px; align-items:center; justify-content:space-between; font-size:12px; }
-.atlas-map { min-width:0; min-height:180px; position:relative; overflow:hidden; background:var(--geist-map-bg); }
-.atlas-map > canvas { display:block; position:absolute; inset:0; width:100%; height:100%; touch-action:none; outline-offset:-4px; }
+.atlas-map { min-width:0; min-height:180px; position:relative; overflow:hidden; background:var(--atlas-space); border-radius:14px; box-shadow:0 18px 40px -18px rgba(5,10,25,.55), 0 0 0 1px rgba(10,16,30,.9); isolation:isolate; }
+.atlas-map > canvas { display:block; position:absolute; inset:0; width:100%; height:100%; touch-action:none; outline-offset:-4px; cursor:grab; }
+.atlas-map > canvas:active { cursor:grabbing; }
.atlas-map > canvas:focus-visible { outline:2px solid var(--geist-map-focus); }
-.atlas-map-unavailable { color:var(--geist-map-fg); background:var(--geist-map-surface); position:absolute; top:16px; left:16px; right:80px; padding:12px; }
-.atlas-camera { position:absolute; right:12px; top:12px; display:flex; flex-direction:column; gap:8px; }
-.atlas-camera button { width:44px; height:44px; border:1px solid var(--geist-map-border); color:var(--geist-map-fg); background:var(--geist-map-surface); border-radius:4px; font-size:22px; }
-.atlas-camera button:hover { background:var(--geist-primary-hover); }
+.atlas-map::after { content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none; box-shadow:inset 0 0 0 1px rgba(160,190,220,.12), inset 0 40px 60px -40px rgba(127,231,220,.12); }
+.atlas-map-unavailable { color:var(--atlas-ink); background:var(--atlas-panel); position:absolute; top:16px; left:16px; right:80px; padding:12px; border-radius:8px; }
+.atlas-hud { position:absolute; top:14px; left:16px; display:flex; flex-direction:column; gap:2px; pointer-events:none; font:500 12px/1.5 var(--mono); letter-spacing:.06em; text-transform:uppercase; color:var(--atlas-dim); }
+.atlas-hud span:first-child { display:flex; align-items:center; gap:8px; color:var(--atlas-ink); }
+.atlas-hud span:first-child::before { content:""; width:8px; height:8px; border-radius:50%; background:var(--atlas-teal); box-shadow:0 0 0 3px rgba(127,231,220,.18), 0 0 10px var(--atlas-teal); }
+.atlas-map[data-motion=ambient] .atlas-hud span:first-child::before,.atlas-map[data-motion=camera] .atlas-hud span:first-child::before { animation:atlas-beacon 2.4s ease-in-out infinite; }
+@keyframes atlas-beacon { 50% { box-shadow:0 0 0 6px rgba(127,231,220,0), 0 0 14px var(--atlas-teal); } }
+.atlas-camera { position:absolute; right:12px; top:12px; display:flex; flex-direction:column; gap:0; border:1px solid var(--atlas-line); border-radius:10px; background:var(--atlas-panel); backdrop-filter:blur(6px); overflow:hidden; }
+.atlas-camera button { width:44px; height:44px; color:var(--atlas-ink); background:transparent; font-size:20px; transition:background .15s, color .15s; }
+.atlas-camera button + button { border-top:1px solid var(--atlas-line); }
+.atlas-camera button:hover { background:rgba(127,231,220,.16); color:#fff; }
.atlas-camera button:focus-visible { outline:2px solid var(--geist-map-focus); outline-offset:-3px; }
-.atlas-camera button:disabled { background:var(--geist-map-surface); color:var(--geist-map-muted); opacity:1; }
-.atlas-map-note { position:absolute; bottom:10px; left:12px; right:12px; width:max-content; max-width:calc(100% - 24px); color:var(--geist-map-fg); background:var(--geist-map-surface); font-size:12px; line-height:1.5; padding:4px 8px; pointer-events:none; }
-.atlas-tooltip { position:absolute; max-width:280px; padding:8px 12px; font-size:13px; line-height:1.5; color:var(--geist-map-fg); background:var(--geist-map-surface); border:1px solid var(--geist-map-border); border-radius:4px; overflow-wrap:anywhere; pointer-events:none; }
-.atlas-card { display:flex; flex-direction:column; min-width:0; min-height:0; overflow:hidden; background:var(--geist-surface); padding:20px; border-left:1px solid var(--geist-border); }
+.atlas-camera button:disabled { background:transparent; color:#8d99a6; opacity:1; }
+.atlas-legend { position:absolute; right:12px; bottom:10px; margin:0; padding:8px 12px; list-style:none; display:flex; flex-direction:column; gap:4px; border:1px solid var(--atlas-line); border-radius:10px; background:var(--atlas-panel); backdrop-filter:blur(6px); font:12px/1.4 var(--mono); color:var(--atlas-ink); pointer-events:none; }
+.atlas-legend li { display:flex; align-items:center; gap:8px; }
+.atlas-legend i { display:inline-grid; place-items:center; width:14px; height:14px; border-radius:50%; font:600 12px/1 var(--mono); font-style:normal; flex-shrink:0; }
+.atlas-legend-planet { background:radial-gradient(circle at 35% 35%, #c3e6f0, #1d6d92 70%); box-shadow:0 0 6px rgba(127,231,220,.8); }
+.atlas-legend-ghost { border:1.5px dashed #d6e0e8; color:var(--atlas-ink); font-size:12px; }
+.atlas-legend-lantern { color:#ffd27a; }
+.atlas-map-note { position:absolute; bottom:10px; left:12px; width:max-content; max-width:calc(100% - 300px); color:var(--atlas-ink); background:var(--atlas-panel); border:1px solid var(--atlas-line); border-radius:999px; font:12px/1.5 var(--mono); padding:4px 12px; pointer-events:none; backdrop-filter:blur(6px); }
+.atlas-tooltip { position:absolute; max-width:280px; padding:8px 12px; font-size:13px; line-height:1.45; color:var(--atlas-ink); background:rgba(9,14,24,.94); border:1px solid rgba(127,231,220,.45); border-radius:8px; overflow-wrap:anywhere; pointer-events:none; box-shadow:0 10px 30px -10px rgba(0,0,0,.6); animation:atlas-pop .14s ease-out; }
+.atlas-tooltip strong { display:block; font:600 13px var(--mono); }
+.atlas-tooltip span { display:block; font-size:12px; color:var(--atlas-dim); }
+@keyframes atlas-pop { from { opacity:0; transform:translateY(4px) scale(.98); } }
+.atlas-card { display:flex; flex-direction:column; min-width:0; min-height:0; overflow:hidden; padding:18px 20px; }
.atlas-card-content { min-height:0; flex:1; overflow:auto; scrollbar-width:thin; }
+.atlas-card-content > button { display:block; width:100%; text-align:left; margin-bottom:6px; min-height:44px; padding:8px 12px; border:1px solid var(--geist-border); border-radius:6px; font:13px var(--mono); overflow-wrap:anywhere; }
.atlas-actions { display:flex; gap:8px; flex-shrink:0; padding-top:12px; border-top:1px solid var(--geist-border); }
.atlas-actions > * { flex:1; }
-.atlas-actions button { min-height:44px; padding:8px; border:1px solid var(--geist-control-border); border-radius:4px; background:var(--geist-surface); }
-.atlas-card h2 { font:500 22px/1.3 var(--heading); overflow-wrap:anywhere; margin:8px 0 16px; }
+.atlas-actions button { min-height:44px; padding:8px; border:1px solid var(--geist-control-border); border-radius:6px; background:var(--geist-surface); }
+.atlas-actions button:hover:not(:disabled) { background:var(--geist-hover); }
+.atlas-card h2 { font:500 22px/1.25 var(--heading); overflow-wrap:anywhere; margin:6px 0 14px; }
.atlas-card h2 > span { display:block; }
+.atlas-card h2 > span:first-child:not(:only-child) { font-size:15px; color:var(--geist-secondary); }
.atlas-card p { color:var(--geist-secondary); margin-bottom:14px; overflow-wrap:anywhere; }
-.atlas-card-top { display:flex; align-items:center; justify-content:space-between; gap:8px; font:12px/1.5 var(--mono); color:var(--geist-secondary); }
-.atlas-card-top button { min-width:44px; min-height:44px; border:1px solid var(--geist-control-border); font-size:24px; border-radius:4px; }
-.atlas-card .atlas-status { color:var(--geist-info-fg); background:var(--geist-info-bg); padding:8px; font-size:12px; }
-.atlas-card .atlas-status[data-state="miss"],.atlas-card .atlas-status[data-state="unchecked"] { color:var(--geist-unknown-fg); background:var(--geist-unknown-bg); }
+.atlas-card-top { display:flex; align-items:center; justify-content:space-between; gap:8px; font:500 12px/1.5 var(--mono); letter-spacing:.06em; color:var(--geist-muted); }
+.atlas-card-top button { min-width:44px; min-height:44px; border:1px solid var(--geist-border); font-size:22px; border-radius:6px; }
+.atlas-card-top button:hover { background:var(--geist-hover); }
+.atlas-guide-eyebrow { font:500 12px/1.5 var(--mono); letter-spacing:.06em; text-transform:uppercase; color:var(--geist-muted); margin-bottom:6px; }
+.atlas-guide-keys { display:flex; flex-wrap:wrap; gap:6px; margin:-4px 0 14px; padding:0; list-style:none; }
+.atlas-guide-keys li { display:inline-flex; align-items:center; gap:6px; font-size:12px; color:var(--geist-secondary); }
+.atlas-guide-keys kbd { min-width:24px; padding:1px 6px; border:1px solid var(--geist-control-border); border-bottom-width:2px; border-radius:4px; background:var(--geist-surface); font:500 12px/1.5 var(--mono); color:var(--geist-fg); text-align:center; }
+.atlas-card .atlas-status { display:flex; align-items:center; gap:8px; color:var(--geist-info-fg); background:var(--geist-info-bg); padding:7px 10px; border-radius:6px; font:500 12px var(--mono); }
+.atlas-card .atlas-status::before { content:""; width:8px; height:8px; border-radius:50%; background:currentColor; flex-shrink:0; }
+.atlas-card .atlas-status[data-state="miss"],.atlas-card .atlas-status[data-state="unchecked"],.atlas-card .atlas-status[data-state="loading"] { color:var(--geist-unknown-fg); background:var(--geist-unknown-bg); }
+.atlas-card .atlas-status[data-state="miss"]::before,.atlas-card .atlas-status[data-state="unchecked"]::before,.atlas-card .atlas-status[data-state="loading"]::before { background:transparent; border:1.5px dashed currentColor; }
.atlas-card .atlas-status[data-state="unavailable"] { color:var(--geist-danger-fg); background:var(--geist-danger-bg); }
.atlas-card .atlas-status[data-state="cached"][data-freshness="stale"] { color:var(--geist-warning-fg); background:var(--geist-warning-bg); }
.atlas-last { font-size:12px; color:var(--geist-secondary); overflow-wrap:anywhere; }
-.atlas-card dl { border-block:1px solid var(--geist-border); padding-block:12px; margin-bottom:16px; font-size:12px; }
-.atlas-card dt { color:var(--geist-muted); margin-top:8px; }
-.atlas-card dd { margin:0 0 12px; overflow-wrap:anywhere; color:var(--geist-fg); }
+.atlas-card dl { border-block:1px solid var(--geist-border); padding-block:6px; margin-bottom:16px; font-size:12px; }
+.atlas-card dt { color:var(--geist-muted); margin-top:8px; font:500 12px var(--mono); letter-spacing:.02em; }
+.atlas-card dd { margin:0 0 8px; overflow-wrap:anywhere; color:var(--geist-fg); }
+.atlas-card dd code { font:12px var(--mono); }
.atlas-card a:not(.atlas-enter) { display:inline-flex; align-items:center; min-height:44px; }
.atlas-card a { color:var(--geist-interactive); text-decoration:underline; text-underline-offset:3px; overflow-wrap:anywhere; }
-.atlas-card .atlas-enter { display:flex; justify-content:center; align-items:center; gap:8px; color:var(--geist-primary-fg); background:var(--geist-primary-bg); min-height:44px; padding:12px; border-radius:4px; text-decoration:none; margin-top:0; }
-.atlas-card .atlas-enter:hover { background:var(--geist-primary-hover); }
+.atlas-card .atlas-enter { display:flex; justify-content:center; align-items:center; gap:8px; color:var(--geist-primary-fg); background:var(--geist-primary-bg); min-height:44px; padding:12px; border-radius:6px; text-decoration:none; margin-top:0; font-weight:500; transition:background .15s, box-shadow .2s; }
+.atlas-card .atlas-enter::after { content:"→"; transition:transform .15s; }
+.atlas-card .atlas-enter:hover { background:var(--geist-primary-hover); box-shadow:0 6px 18px -8px rgba(15,118,110,.6); }
+.atlas-card .atlas-enter:hover::after { transform:translateX(3px); }
.atlas-card .atlas-observation { font-size:12px; margin-top:16px; }
+.atlas-card .atlas-lantern { padding:8px 10px; border-radius:6px; background:#fdf7e7; color:#6b4a06; font-size:13px; }
.atlas-card > button { overflow-wrap:anywhere; max-width:100%; }
.atlas-card .atlas-retry { display:block; width:auto; margin-top:0; }
-.atlas-footer { padding:8px 24px; color:var(--geist-secondary); border-top:1px solid var(--geist-border); font-size:12px; }
+.atlas-footer { padding:8px 24px; color:var(--geist-secondary); border-top:1px solid var(--geist-border); background:var(--geist-surface); font:12px/1.5 var(--mono); }
.atlas-tabs { display:none; }
.explorer-atlas-back { color:var(--geist-interactive); font-size:13px; text-decoration:underline; text-underline-offset:3px; min-height:44px; display:flex; align-items:center; }
.explorer-visit-status { font-size:12px; color:var(--geist-secondary); padding:8px 24px; background:var(--geist-bg); border-bottom:1px solid var(--geist-border); }
-@media(max-width:1180px) and (min-width:1024px) { .atlas-body { grid-template-columns:220px minmax(0,1fr) 280px; } .atlas-list { padding:12px; } .atlas-card { padding:16px; } }
-@media(max-width:1023px) { .atlas-body { grid-template-columns:220px minmax(0,1fr); grid-template-rows:minmax(180px,1fr) auto; } .atlas-list { grid-row:1/3; } .atlas-card { grid-column:2; max-height:35dvh; border-top:1px solid var(--geist-border); } .atlas-header { padding-inline:16px; } .atlas-intro { padding-inline:16px; } .atlas-header h1 { font-size:20px; } }
-@media(max-width:600px) { .atlas-shell { min-height:680px; } .atlas-header { gap:8px 16px; } .atlas-header .brand strong { font-size:18px; } .atlas-header h1 { font-size:20px; } .atlas-header > a:last-child { margin-left:0; font-size:13px; } .atlas-intro { padding:8px 12px; } .atlas-intro p { font-size:13px; } .atlas-intro p+p { font-size:12px; } .atlas-tabs { display:flex; gap:8px; padding:8px 12px; } .atlas-tabs button { flex:1; } .atlas-tabs button[aria-pressed=true] { box-shadow:inset 0 -3px var(--geist-fg); background:var(--geist-selected); } .atlas-body { display:flex; flex-direction:column; min-height:360px; } .atlas-map { flex:1; min-height:180px; } .atlas-list { flex:1; min-height:180px; border:0; max-height:55dvh; } .atlas-list-rows { min-height:100px; } .atlas-pane-map .atlas-list { display:none; } .atlas-pane-list .atlas-map { display:none; } .atlas-card { flex:0 1 auto; max-height:35dvh; padding:14px 16px; border-left:0; } .atlas-card h2 { font-size:20px; } .atlas-card > p { font-size:14px; } .atlas-shell input,.atlas-inspect input { font-size:16px; } .atlas-footer { padding-inline:12px; } .explorer-visit-status { padding-inline:12px; } }
+@media(prefers-reduced-motion:reduce) { .atlas-shell *,.atlas-shell *::before,.atlas-shell *::after { animation:none !important; transition:none !important; } }
+@media(max-width:1180px) and (min-width:1024px) { .atlas-body { grid-template-columns:232px minmax(0,1fr) 288px; } .atlas-list { padding:12px; } .atlas-card { padding:16px; } .atlas-tag { display:none; } }
+@media(max-width:1023px) { .atlas-body { grid-template-columns:232px minmax(0,1fr); grid-template-rows:minmax(180px,1fr) auto; } .atlas-list { grid-row:1/3; } .atlas-card { grid-column:2; max-height:35dvh; } .atlas-header { padding-inline:16px; } .atlas-progress-controls,.atlas-intro { padding-inline:16px; } .atlas-header h1 { font-size:20px; } .atlas-tag { display:none; } .atlas-legend { display:none; } .atlas-map-note { max-width:calc(100% - 24px); } }
+@media(max-width:600px) { .atlas-shell { height:auto; min-height:100dvh; } .atlas-header { gap:8px 12px; padding:8px 12px; min-height:56px; } .atlas-header .brand strong { font-size:18px; } .atlas-header h1 { font-size:18px; padding-left:12px; } .atlas-home-link { margin-left:0; min-height:44px; padding:0 12px; font-size:13px; } .atlas-progress-controls { padding:8px 12px; } .atlas-progress-controls > span:first-child { flex-basis:100%; } .atlas-intro { padding:8px 12px 0; } .atlas-intro p { font-size:13px; } .atlas-intro p+p { font-size:12px; } .atlas-tabs { display:flex; gap:4px; margin:8px 12px 0; padding:4px; border:1px solid var(--geist-border); border-radius:10px; background:var(--geist-surface); } .atlas-tabs button { flex:1; border:0; border-radius:6px; } .atlas-tabs button[aria-pressed=true] { background:var(--geist-primary-bg); color:var(--geist-primary-fg); } .atlas-body { display:flex; flex-direction:column; gap:10px; padding:8px 12px 12px; min-height:0; flex:none; } .atlas-map { flex:none; height:min(62dvh,560px); min-height:340px; border-radius:12px; } .atlas-list { flex:none; height:min(62dvh,560px); min-height:340px; } .atlas-list-rows { min-height:100px; } .atlas-pane-map .atlas-list { display:none; } .atlas-pane-list .atlas-map { display:none; } .atlas-card { flex:none; max-height:none; padding:14px 16px; } .atlas-card-content { overflow:visible; } .atlas-card h2 { font-size:20px; } .atlas-card > p { font-size:14px; } .atlas-shell input,.atlas-inspect input { font-size:16px; } .atlas-hud span + span { display:none; } .atlas-map-note { right:12px; width:auto; max-width:none; border-radius:8px; } .atlas-footer { padding-inline:12px; } .explorer-visit-status { padding-inline:12px; } }
diff --git a/src/atlas/camera.ts b/src/atlas/camera.ts
index a6a8bf7..04c0cef 100644
--- a/src/atlas/camera.ts
+++ b/src/atlas/camera.ts
@@ -36,4 +36,17 @@ export function zoomAt(c: Camera, factor: number, point: Point, s: Size) {
return constrain({ ...bounded, centerX: bounded.centerX + before.x - after.x, centerY: bounded.centerY + before.y - after.y }, s);
}
export function resizeCamera(c: Camera, old: Size, next: Size, home: Bounds) { return constrain({ ...c, zoom: fit(home, next).zoom * c.zoom / fit(home, old).zoom }, next); }
+/** One exponential easing step from `from` toward `to` over `dt` ms (zoom eases
+ * in log space). With an `anchor`, the anchored world point stays under the same
+ * screen point throughout, so pointer zoom does not drift. Returns null once the
+ * remaining motion is below a quarter pixel: the caller then snaps to `to`. */
+export function easeCamera(from: Camera, to: Camera, dt: number, s: Size, anchor?: { screen: Point; world: Point } | null, tau = 110): Camera | null {
+ const k = 1 - Math.exp(-Math.max(0, Math.min(dt, 100)) / tau);
+ const zoom = Math.exp(Math.log(from.zoom) + (Math.log(to.zoom) - Math.log(from.zoom)) * k);
+ let next: Camera = { centerX: from.centerX + (to.centerX - from.centerX) * k, centerY: from.centerY + (to.centerY - from.centerY) * k, zoom };
+ if (anchor && Math.abs(Math.log(to.zoom / from.zoom)) > 1e-6)
+ next = constrain({ zoom, centerX: anchor.world.x - (anchor.screen.x - s.width / 2) / zoom, centerY: anchor.world.y - (anchor.screen.y - s.height / 2) / zoom }, s);
+ const pixels = Math.hypot(next.centerX - to.centerX, next.centerY - to.centerY) * to.zoom, zoomGap = Math.abs(Math.log(next.zoom / to.zoom));
+ return pixels < .25 && zoomGap < 1e-3 ? null : next;
+}
export const wheelFactor = (delta: number, mode: number, height: number) => Math.exp(-clamp(delta * (mode === 1 ? 16 : mode === 2 ? height : 1), -240, 240) * .0015);
diff --git a/src/atlas/paint.ts b/src/atlas/paint.ts
index 03d64be..6e310f6 100644
--- a/src/atlas/paint.ts
+++ b/src/atlas/paint.ts
@@ -1,6 +1,6 @@
-import { MAP_CONTRAST as C } from "../world/paint";
-import { hash, visibleGroups, type CacheStatus, type Landmark } from "./model";
-import type { Camera, Size } from "./camera";
+import { hash, visibleGroups, type CacheStatus, type Landmark, type ProjectedLandmark } from "./model";
+import { project, type Camera, type Size } from "./camera";
+import { planetLook, rgba, routePoint, STAR_TILE, type Scenery } from "./scenery";
export interface Rect {
x: number;
y: number;
@@ -13,178 +13,293 @@ export interface HitTarget {
x: number;
y: number;
}
+export interface AtlasFrame {
+ points: Landmark[];
+ camera: Camera;
+ size: Size;
+ statuses: Map;
+ selected: string | null;
+ hover: string | null;
+ /** 0..1 eased hover emphasis for `hover`. */
+ hoverMix: number;
+ lit: ReadonlySet;
+ /** Seconds on the decorative clock. Frozen under reduced motion. */
+ time: number;
+ animated: boolean;
+ /** Device pixels per CSS pixel of the backing store (already capped). */
+ ratio: number;
+ scenery: Scenery;
+}
export const contains = (r: Rect, x: number, y: number) => x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height;
const overlaps = (a: Rect, b: Rect) => a.x < b.x + b.width + 8 && a.x + a.width + 8 > b.x && a.y < b.y + b.height + 8 && a.y + a.height + 8 > b.y;
-const polygon = (g: CanvasRenderingContext2D, points: number[][], fill: string, stroke?: string) => { g.beginPath(); points.forEach(([x, y], i) => i ? g.lineTo(x, y) : g.moveTo(x, y)); g.closePath(); g.fillStyle = fill; g.fill(); if (stroke) {
- g.strokeStyle = stroke;
- g.lineWidth = 1.5;
- g.stroke();
-} };
-function sculpture(g: CanvasRenderingContext2D, x: number, y: number, size: number, variant: number) {
- g.save();
- g.translate(x, y);
- g.scale(size / 64, size / 64);
- g.fillStyle = C.surface;
- g.beginPath();
- g.ellipse(0, 14, 40, 16, 0, 0, Math.PI * 2);
- g.fill();
- polygon(g, [[-35, 3], [0, -8], [35, 3], [35, 14], [0, 25], [-35, 14]], C.border, C.edge);
- polygon(g, [[-35, 3], [0, -8], [35, 3], [0, 14]], C.muted, C.edge);
- if (variant === 0) {
- polygon(g, [[-23, 0], [0, -9], [23, 0], [23, -13], [0, -22], [-23, -13]], C.surface, C.edge);
+const MONO = '"IBM Plex Mono",ui-monospace,SFMono-Regular,Consolas,monospace';
+const SELECT = "#52a8ff", VERIFIED = "#7fe7dc", GOLD = "#ffd27a", INK = "#eef3f4";
+const TAU = Math.PI * 2;
+/** On-screen planet diameter in CSS pixels; grows gently with zoom. */
+export const planetSize = (zoom: number) => Math.max(30, Math.min(76, 52 * (zoom / .3) ** .35));
+const mod = (n: number, m: number) => ((n % m) + m) % m;
+
+/** Paint one Atlas frame. Original procedural scenery only: no textures, tiles,
+ * physics or cross-repository graph. Routes are scenic, not dependency links.
+ * The first full-canvas `fillRect` is the frame boundary (tests count it), so
+ * other full-canvas overlays use `rect()` + `fill()`. Labels use `fillText`. */
+export function paintAtlas(g: CanvasRenderingContext2D, f: AtlasFrame): HitTarget[] {
+ const { points, camera, size, statuses, selected, hover, lit, time: t, animated, ratio, scenery } = f;
+ const W = size.width, H = size.height;
+ // 1. Deep space.
+ const sky = g.createLinearGradient(0, 0, W * .35, H);
+ sky.addColorStop(0, "#070b16"); sky.addColorStop(.55, "#0a1020"); sky.addColorStop(1, "#05070d");
+ g.fillStyle = sky;
+ g.fillRect(0, 0, W, H);
+ // 2. Parallax star field: two cached tiles drifting slower than the map, plus twinkles.
+ const stars = scenery.stars(ratio);
+ const layer = (pattern: CanvasPattern | null, depth: number, alpha: number) => {
+ if (!pattern) return;
+ const tile = STAR_TILE * ratio, ox = mod(-(camera.centerX * camera.zoom * depth) * ratio - (animated ? t * 1.2 * depth * ratio : 0), tile), oy = mod(-(camera.centerY * camera.zoom * depth) * ratio, tile);
+ g.save();
+ g.setTransform(1, 0, 0, 1, ox - tile, oy - tile);
+ g.globalAlpha = alpha;
+ g.fillStyle = pattern;
g.beginPath();
- g.ellipse(0, -17, 17, 14, 0, Math.PI, 0);
- g.lineTo(17, -13);
- g.lineTo(-17, -13);
- g.closePath();
- g.fillStyle = C.muted;
+ g.rect(0, 0, W * ratio + tile * 2, H * ratio + tile * 2);
g.fill();
- g.strokeStyle = C.edge;
- g.stroke();
+ g.restore();
+ };
+ layer(stars.far, .06, .9);
+ layer(stars.near, .16, 1);
+ g.save();
+ g.globalCompositeOperation = "lighter";
+ const tw = 1024, txo = -(camera.centerX * camera.zoom * .26), tyo = -(camera.centerY * camera.zoom * .26);
+ for (const s of stars.twinkles) for (let x = mod(s.x + txo, tw); x < W + 8; x += tw) for (let y = mod(s.y + tyo, tw); y < H + 8; y += tw) {
+ const pulse = animated ? .5 + .5 * Math.sin(t * s.speed + s.phase) : .7, len = s.size * (1.6 + pulse * 2.2);
+ g.globalAlpha = .18 + pulse * .5;
+ g.fillStyle = s.warm ? "#ffe2b8" : "#dbe8ff";
+ g.fillRect(x - len, y - .5, len * 2, 1);
+ g.fillRect(x - .5, y - len, 1, len * 2);
+ g.beginPath(); g.arc(x, y, s.size * .7, 0, TAU); g.fill();
}
- else if (variant === 1) {
- for (const [xx, hh] of [[-18, 34], [15, 45]]) {
- polygon(g, [[xx - 7, 6], [xx + 7, 1], [xx + 7, -hh], [xx - 7, -hh + 5]], C.surface, C.edge);
- polygon(g, [[xx - 10, -hh + 4], [xx, -hh - 2], [xx + 10, -hh - 6], [xx + 2, -hh]], C.muted, C.edge);
- }
- g.strokeStyle = C.edge;
- g.lineWidth = 5;
- g.beginPath();
- g.arc(-1, -10, 17, Math.PI, 0);
- g.stroke();
+ g.restore();
+ // 3. World layer: nebula regions, chart ticks, scenic routes.
+ const z = camera.zoom, ox = W / 2 - camera.centerX * z, oy = H / 2 - camera.centerY * z;
+ const nebula = scenery.nebula(points);
+ g.save();
+ g.translate(ox, oy);
+ g.scale(z, z);
+ g.imageSmoothingEnabled = true;
+ g.globalAlpha = .95;
+ g.drawImage(nebula, 0, 0, nebula.width * 8, nebula.height * 8);
+ g.globalAlpha = 1;
+ const left = -ox / z, top = -oy / z, right = left + W / z, bottom = top + H / z, step = z < .12 ? 1024 : 512;
+ g.fillStyle = "rgba(160,190,220,.16)";
+ const tick = 4 / z, thin = 1 / z;
+ for (let x = Math.floor(left / step) * step; x <= right; x += step)
+ for (let y = Math.floor(top / step) * step; y <= bottom; y += step) { g.fillRect(x - tick, y - thin / 2, tick * 2, thin); g.fillRect(x - thin / 2, y - tick, thin, tick * 2); }
+ const routes = scenery.routes(points);
+ g.lineCap = "round";
+ for (const r of routes) {
+ if (Math.max(r.ax, r.bx, r.cx) < left - 200 || Math.min(r.ax, r.bx, r.cx) > right + 200 || Math.max(r.ay, r.by, r.cy) < top - 200 || Math.min(r.ay, r.by, r.cy) > bottom + 200) continue;
+ g.beginPath(); g.moveTo(r.ax, r.ay); g.quadraticCurveTo(r.cx, r.cy, r.bx, r.by);
+ g.strokeStyle = "rgba(127,231,220,.07)"; g.lineWidth = 7 / z; g.stroke();
+ g.setLineDash([1.5 / z, 9 / z]);
+ g.lineDashOffset = animated ? -t * 16 / z : 0;
+ g.strokeStyle = "rgba(196,240,234,.5)"; g.lineWidth = 1.6 / z; g.stroke();
+ g.setLineDash([]);
}
- else
- for (let i = 0; i < 3; i++) {
- g.beginPath();
- g.ellipse(0, -i * 9, 28 - i * 6, 11 - i * 2, 0, 0, Math.PI * 2);
- g.fillStyle = i % 2 ? C.surface : C.muted;
- g.fill();
- g.strokeStyle = C.edge;
- g.stroke();
- }
g.restore();
-}
-/** Original vector scenery. No textures, tile cache, physics or cross-repo graph. */
-export function paintAtlas(g: CanvasRenderingContext2D, points: Landmark[], camera: Camera, size: Size, statuses: Map, selected: string | null, hover: string | null, lit: ReadonlySet = new Set()) {
- g.fillStyle = C.bg;
- g.fillRect(0, 0, size.width, size.height);
+ // Travelling route lights (decorative motion only).
g.save();
- g.translate(size.width / 2 - camera.centerX * camera.zoom, size.height / 2 - camera.centerY * camera.zoom);
- g.scale(camera.zoom, camera.zoom);
- for (let i = 0; i < 14; i++) {
- const x = 170 + (i % 5) * 1720, y = 340 + Math.floor(i / 5) * 1830, skew = (i % 3) * 140;
- polygon(g, [[x, y], [x + 1100, y - 180 - skew], [x + 1510, y + 340], [x + 1050, y + 640], [x - 120, y + 470]], C.surface);
- g.beginPath();
- g.moveTo(x - 120, y + 470);
- g.bezierCurveTo(x + 350, y + 680, x + 1100, y + 570, x + 1510, y + 340);
- g.strokeStyle = C.border;
- g.globalAlpha = .35;
- g.lineWidth = 2 / camera.zoom;
- g.stroke();
- g.globalAlpha = 1;
+ g.globalCompositeOperation = "lighter";
+ const spark = scenery.glow(VERIFIED);
+ for (const r of routes) {
+ const u = mod((animated ? t * 90 / r.length : 0) + (r.seed % 997) / 997, 1), p = project(routePoint(r, u), camera, size);
+ if (p.x < -20 || p.y < -20 || p.x > W + 20 || p.y > H + 20) continue;
+ const s = 14;
+ g.globalAlpha = .75 * Math.sin(u * Math.PI);
+ g.drawImage(spark, p.x - s / 2, p.y - s / 2, s, s);
}
- g.beginPath();
- g.moveTo(550, 1200);
- g.bezierCurveTo(2000, 2200, 2350, 400, 4100, 1200);
- g.bezierCurveTo(6000, 2300, 3700, 3700, 6700, 4900);
- g.setLineDash([9 / camera.zoom, 12 / camera.zoom]);
- g.strokeStyle = C.border;
- g.lineWidth = 1.5 / camera.zoom;
- g.stroke();
- g.setLineDash([]);
g.restore();
- const groups = visibleGroups(points, camera, size, selected), targets: HitTarget[] = [], labels: {
- group: typeof groups[number];
- rect: Rect;
- text: string;
- }[] = [], accepted: Rect[] = [];
- const maxLabels = size.width < 600 ? 10 : 24;
+ // Occasional shooting star, screen space, ambient only.
+ if (animated) {
+ const cycle = 9, k = mod(t, cycle) / 1.1, n = Math.floor(t / cycle);
+ if (k < 1) {
+ const sx = (hash("meteor" + n) % 1000) / 1000 * W, sy = (hash("meteor-y" + n) % 1000) / 1000 * H * .5, dx = 260, dy = 110;
+ const hx = sx + dx * k, hy = sy + dy * k, trail = g.createLinearGradient(hx - dx * .35, hy - dy * .35, hx, hy);
+ trail.addColorStop(0, "rgba(220,235,255,0)"); trail.addColorStop(1, `rgba(220,235,255,${.7 * Math.sin(k * Math.PI)})`);
+ g.strokeStyle = trail; g.lineWidth = 1.4;
+ g.beginPath(); g.moveTo(hx - dx * .35, hy - dy * .35); g.lineTo(hx, hy); g.stroke();
+ }
+ }
+ // Vignette keeps attention on the chart center.
+ const vignette = g.createRadialGradient(W / 2, H / 2, Math.min(W, H) * .35, W / 2, H / 2, Math.hypot(W, H) * .62);
+ vignette.addColorStop(0, "rgba(3,5,10,0)"); vignette.addColorStop(1, "rgba(3,5,10,.62)");
+ g.fillStyle = vignette; g.beginPath(); g.rect(0, 0, W, H); g.fill();
+ // 4. Landmarks.
+ const groups = visibleGroups(points, camera, size, selected), targets: HitTarget[] = [];
+ const D = planetSize(z), bucket = Math.max(16, Math.round(D * ratio * 1.18 / 8) * 8);
+ const isSelected = (gr: ProjectedLandmark) => gr.keys.length === 1 && gr.keys[0] === selected;
+ const isHover = (gr: ProjectedLandmark) => !!hover && gr.keys.length === 1 && gr.keys[0] === hover;
+ // Label layout first so editorial region names can avoid landmark labels.
+ const labels: { group: ProjectedLandmark; rect: Rect; text: string; below: boolean }[] = [], accepted: Rect[] = [];
+ const maxLabels = W < 600 ? 10 : 24;
const byPriority = [...groups].sort((a, b) => Number(b.keys.includes(selected ?? "")) - Number(a.keys.includes(selected ?? "")) || Number(b.keys.includes(hover ?? "")) - Number(a.keys.includes(hover ?? "")));
- for (const group of groups) {
- const active = group.keys.length === 1 && group.keys[0] === selected, cached = group.landmark && statuses.get(group.landmark.repoKey)?.state === "cached", extent = camera.zoom < .12 ? 36 : camera.zoom > .6 ? 72 : 54;
- const { x, y } = group;
- if (group.keys.length > 1) {
- g.fillStyle = C.surface;
- g.strokeStyle = C.border;
- g.lineWidth = 2;
- g.beginPath();
- g.roundRect(x - 27, y - 26, 54, 52, 8);
- g.fill();
- g.stroke();
- g.fillStyle = C.text;
- g.font = '600 14px ui-monospace,monospace';
- g.textAlign = "center";
- g.textBaseline = "middle";
- g.fillText(String(group.keys.length), x, y);
+ const discs = groups.map(group => ({ group, x: group.x, y: group.y, r: group.keys.length > 1 ? 26 : D * .42 }));
+ for (const group of byPriority) {
+ if (labels.length >= maxLabels && !group.keys.includes(selected ?? "")) continue;
+ const active = group.keys.includes(selected ?? ""), full = group.landmark?.fullName ?? `${group.keys.length} catalog landmarks`;
+ let text = full;
+ g.font = `${active ? 600 : 500} ${active ? 13 : 12}px ${MONO}`;
+ const maxWidth = Math.min(active ? 260 : 180, W - 40);
+ while (text.length > 1 && g.measureText(text + "…").width > maxWidth) text = text.slice(0, -1);
+ if (text !== full) text += "…";
+ const width = g.measureText(text).width + 30, height = 26, r = (group.keys.length > 1 ? 30 : D / 2) + 10;
+ const candidates = [{ x: group.x - width / 2, y: group.y + r, width, height }, { x: group.x - width / 2, y: group.y - r - height, width, height }, { x: group.x + r + 4, y: group.y - height / 2, width, height }, { x: group.x - width - r - 4, y: group.y - height / 2, width, height },
+ { x: group.x - 14, y: group.y + r - 4, width, height }, { x: group.x - width + 14, y: group.y + r - 4, width, height }, { x: group.x - 14, y: group.y - r - height + 4, width, height }, { x: group.x - width + 14, y: group.y - r - height + 4, width, height }];
+ const fits = (c: Rect) => c.x >= 8 && c.x + c.width <= W - 8 && c.y >= 8 && c.y + c.height <= H - 64 && !accepted.some(a => overlaps(c, a)) && !(c.x + c.width > W - 64 && c.y < 230) && !(c.x < 300 && c.y < 62);
+ // Prefer a spot that leaves every other planet uncovered; only the selected or hovered
+ // landmark may fall back to label-only spacing, so crowded labels yield instead of hiding planets.
+ const clear = (c: Rect) => !discs.some(d => d.group !== group && c.x < d.x + d.r && c.x + c.width > d.x - d.r && c.y < d.y + d.r && c.y + c.height > d.y - d.r);
+ let index = candidates.findIndex(c => fits(c) && clear(c));
+ if (index < 0 && (active || group.keys.includes(hover ?? ""))) index = candidates.findIndex(fits);
+ if (index >= 0) { accepted.push(candidates[index]); labels.push({ group, rect: candidates[index], text, below: index === 0 || index === 4 || index === 5 }); }
+ }
+ // Editorial region names: faint chart lettering, never over a landmark label.
+ if (z > .1) {
+ g.save();
+ g.font = `600 11px ${MONO}`;
+ g.textAlign = "center"; g.textBaseline = "middle";
+ (g as CanvasRenderingContext2D & { letterSpacing?: string }).letterSpacing = "2px";
+ for (const region of scenery.regions(points)) {
+ const p = project({ x: region.x, y: region.y }, camera, size), text = region.name.toUpperCase(), width = g.measureText(text).width + 8;
+ const rect = { x: p.x - width / 2, y: p.y - D / 2 - 64, width, height: 18 };
+ if (rect.x < 8 || rect.x + rect.width > W - 64 || rect.y < 8 || rect.y > H - 70 || accepted.some(a => overlaps(rect, a))) continue;
+ accepted.push(rect);
+ g.fillStyle = rgba(region.color, .95);
+ g.globalAlpha = .9;
+ g.fillText(text, p.x, rect.y + 9);
}
- else if (cached)
- sculpture(g, x, y, extent, hash(group.keys[0]) % 3);
- else {
- g.strokeStyle = C.border;
- g.lineWidth = 2;
- g.setLineDash([5, 4]);
- g.beginPath();
- g.ellipse(x, y, extent * .5, extent * .32, 0, 0, Math.PI * 2);
- g.stroke();
- g.setLineDash([]);
- g.fillStyle = C.surface;
- g.fillRect(x - 10, y - 14, 20, 25);
- g.fillStyle = C.text;
- g.font = '500 18px ui-monospace,monospace';
- g.textBaseline = "middle";
- g.textAlign = "center";
- g.fillText("?", x, y + 5);
+ g.restore();
+ }
+ for (const group of groups) {
+ const { x, y } = group, key = group.keys[0], single = group.keys.length === 1, active = isSelected(group), hovered = isHover(group);
+ const scale = hovered ? 1 + .16 * f.hoverMix : 1, d = D * scale, status = single ? statuses.get(key) : undefined, cached = status?.state === "cached";
+ if (!single) {
+ paintCluster(g, scenery, x, y, group.keys.length, t, animated);
+ } else if (cached) {
+ const look = planetLook(group.landmark?.style ?? "moonshot"), sprite = scenery.planet(group.landmark?.style ?? "moonshot", hash(key) % 4, bucket), s = d * 2.2;
+ g.save(); g.globalCompositeOperation = "lighter"; g.globalAlpha = .55 + (animated ? .12 * Math.sin(t * 1.3 + hash(key) % 7) : 0);
+ const halo = scenery.glow(look.glow); g.drawImage(halo, x - d * 1.2, y - d * 1.2, d * 2.4, d * 2.4); g.restore();
+ g.drawImage(sprite, x - s / 2, y - s / 2, s, s);
+ // A small moon in orbit marks a verified capture.
+ const a = (animated ? t * .6 : 0) + (hash(key) % 628) / 100, mx = x + Math.cos(a) * d * .82, my = y + Math.sin(a) * d * .3;
+ if (Math.sin(a) > -.2 || !look.ring) { g.fillStyle = look.light; g.beginPath(); g.arc(mx, my, Math.max(1.5, d * .05), 0, TAU); g.fill(); }
+ } else {
+ paintGhost(g, x, y, d, status?.state ?? "unchecked", group.landmark?.style ?? "moonshot", t, animated);
}
- if (active || group.keys.includes(hover ?? "")) {
- g.beginPath();
- g.ellipse(x, y + 8, extent * .67, extent * .42, 0, 0, Math.PI * 2);
- g.strokeStyle = C.surface;
- g.lineWidth = 6;
- g.stroke();
- g.strokeStyle = active ? C.focus : C.text;
- g.lineWidth = 2;
- g.stroke();
+ if (active) paintSelection(g, x, y, single ? d : 60, t, animated);
+ else if (hovered || (!single && group.keys.includes(hover ?? ""))) {
+ g.beginPath(); g.arc(x, y, (single ? d : 60) * .72, 0, TAU);
+ g.strokeStyle = `rgba(255,255,255,${.3 + .4 * f.hoverMix})`; g.lineWidth = 1.5; g.stroke();
}
- if (group.keys.some(key => lit.has(key))) {
- g.strokeStyle = C.focus; g.fillStyle = C.surface; g.lineWidth = 2;
- g.beginPath(); g.arc(x + extent * .42, y - extent * .5, 12, 0, Math.PI * 2); g.fill(); g.stroke();
- g.fillStyle = C.text; g.font = '600 16px ui-monospace,monospace'; g.textAlign = "center"; g.textBaseline = "middle"; g.fillText("✧", x + extent * .42, y - extent * .5);
+ if (group.keys.some(k => lit.has(k))) {
+ const lx = x + d * .48, ly = y - d * .5;
+ g.save(); g.globalCompositeOperation = "lighter"; g.drawImage(scenery.glow(GOLD), lx - 16, ly - 16, 32, 32); g.restore();
+ g.fillStyle = "#1a1408"; g.strokeStyle = GOLD; g.lineWidth = 1.5;
+ g.beginPath(); g.arc(lx, ly, 10, 0, TAU); g.fill(); g.stroke();
+ g.fillStyle = GOLD; g.font = `600 13px ${MONO}`; g.textAlign = "center"; g.textBaseline = "middle"; g.fillText("✧", lx, ly + 1);
}
- targets.push({ keys: group.keys, x, y, rects: [{ x: x - Math.max(22, extent * .65), y: y - Math.max(22, extent * .8), width: Math.max(44, extent * 1.3), height: Math.max(44, extent * 1.15) }] });
+ const hit = Math.max(44, (single ? D : 60) * 1.3);
+ targets.push({ keys: group.keys, x, y, rects: [{ x: x - hit / 2, y: y - hit / 2, width: hit, height: hit }] });
}
- for (const group of byPriority) {
- if (labels.length >= maxLabels && !group.keys.includes(selected ?? ""))
- continue;
- let text = group.landmark?.fullName ?? `${group.keys.length} catalog landmarks`;
- g.font = `${group.keys.includes(selected ?? "") ? 600 : 500} ${group.keys.includes(selected ?? "") ? 14 : 13}px ui-monospace,SFMono-Regular,Consolas,monospace`;
- const maxWidth = Math.min(group.keys.includes(selected ?? "") ? 260 : 180, size.width - 24);
- while (text.length > 1 && g.measureText(text + "…").width > maxWidth)
- text = text.slice(0, -1);
- if (text !== (group.landmark?.fullName ?? `${group.keys.length} catalog landmarks`))
- text += "…";
- const width = g.measureText(text).width + 16, height = 30;
- const candidates = [{ x: group.x - width / 2, y: group.y + 42, width, height }, { x: group.x - width / 2, y: group.y - 76, width, height }, { x: group.x + 48, y: group.y - 16, width, height }, { x: group.x - width - 48, y: group.y - 16, width, height }];
- const rect = candidates.find(r => r.x >= 8 && r.x + r.width <= size.width - 8 && r.y >= 8 && r.y + r.height <= size.height - 64 && !accepted.some(a => overlaps(r, a)) && !(r.x + r.width > size.width - 64 && r.y < 230));
- if (rect) {
- accepted.push(rect);
- labels.push({ group, rect, text });
- targets.find(t => t.keys === group.keys)?.rects.push(rect);
+ // 5. Label plates.
+ for (const { group, rect, text, below } of labels) {
+ const active = group.keys.includes(selected ?? ""), hovered = group.keys.includes(hover ?? ""), single = group.keys.length === 1;
+ const cached = single && statuses.get(group.keys[0])?.state === "cached";
+ targets.find(tg => tg.keys === group.keys)?.rects.push(rect);
+ if (below && single) {
+ g.strokeStyle = active ? "rgba(82,168,255,.7)" : "rgba(200,220,230,.28)"; g.lineWidth = 1;
+ g.beginPath(); g.moveTo(group.x, group.y + D / 2 + 2); g.lineTo(group.x, rect.y); g.stroke();
}
- }
- for (const { group, rect, text } of labels) {
- const active = group.keys.includes(selected ?? "");
- g.font = `${active ? 600 : 500} ${active ? 14 : 13}px ui-monospace,SFMono-Regular,Consolas,monospace`;
- g.fillStyle = active ? C.labelSelectedPlate : C.labelPlate;
- g.strokeStyle = active ? C.labelSelectedBorder : C.border;
- g.lineWidth = active ? 2 : 1;
- g.beginPath();
- g.roundRect(rect.x, rect.y, rect.width, rect.height, 3);
+ g.font = `${active ? 600 : 500} ${active ? 13 : 12}px ${MONO}`;
+ g.beginPath(); g.roundRect(rect.x, rect.y, rect.width, rect.height, 6);
+ g.fillStyle = active ? "#ffffff" : hovered ? "rgba(22,32,48,.95)" : "rgba(9,14,24,.86)";
g.fill();
- g.stroke();
- g.fillStyle = active ? C.labelSelectedText : C.labelText;
- g.textAlign = "center";
- g.textBaseline = "middle";
- g.fillText(text, rect.x + rect.width / 2, rect.y + 15);
+ if (!cached && !active) g.setLineDash([3, 3]);
+ g.strokeStyle = active ? "#0068d6" : cached ? "rgba(127,231,220,.75)" : hovered ? "rgba(230,236,240,.8)" : "rgba(170,182,196,.6)";
+ g.lineWidth = active ? 2 : 1; g.stroke(); g.setLineDash([]);
+ // Status pip: filled = verified capture, hollow = not confirmed.
+ const px = rect.x + 11, py = rect.y + rect.height / 2;
+ g.beginPath(); g.arc(px, py, 3.2, 0, TAU);
+ if (cached) { g.fillStyle = active ? "#0f766e" : VERIFIED; g.fill(); }
+ else if (!single) { g.fillStyle = active ? "#171717" : "#c9d3dc"; g.fill(); }
+ else { g.strokeStyle = active ? "#525252" : "#aab6c2"; g.lineWidth = 1.2; g.stroke(); }
+ g.fillStyle = active ? "#171717" : INK;
+ g.textAlign = "center"; g.textBaseline = "middle";
+ g.fillText(text, rect.x + 9 + rect.width / 2, rect.y + rect.height / 2 + .5);
}
return targets;
}
+
+/** Unconfirmed landmark: a dashed, translucent outline world with a "?" glyph. */
+function paintGhost(g: CanvasRenderingContext2D, x: number, y: number, d: number, state: string, style: string, t: number, animated: boolean) {
+ const look = planetLook(style), r = d / 2;
+ g.save();
+ const fill = g.createRadialGradient(x - r * .3, y - r * .3, 0, x, y, r);
+ fill.addColorStop(0, rgba(look.glow, .14)); fill.addColorStop(1, rgba(look.glow, .03));
+ g.fillStyle = fill; g.beginPath(); g.arc(x, y, r, 0, TAU); g.fill();
+ g.setLineDash([3, 4]);
+ g.lineDashOffset = animated ? -t * 4 : 0;
+ g.strokeStyle = state === "unavailable" ? "rgba(255,190,110,.8)" : "rgba(214,224,232,.7)"; g.lineWidth = 1.5;
+ g.beginPath(); g.arc(x, y, r, 0, TAU); g.stroke();
+ g.setLineDash([2, 6]);
+ g.strokeStyle = "rgba(214,224,232,.22)"; g.lineWidth = 1;
+ g.beginPath(); g.ellipse(x, y, r * 1.55, r * .45, -.35, 0, TAU); g.stroke();
+ g.setLineDash([]);
+ if (state === "loading") {
+ const a = animated ? t * 5 : 0;
+ g.strokeStyle = VERIFIED; g.lineWidth = 2;
+ g.beginPath(); g.arc(x, y, r + 4, a, a + Math.PI * .6); g.stroke();
+ }
+ g.fillStyle = state === "unavailable" ? "#ffcf8a" : INK;
+ g.font = `500 ${Math.round(Math.max(14, d * .42))}px ${MONO}`;
+ g.textAlign = "center"; g.textBaseline = "middle";
+ g.fillText(state === "miss" ? "×" : state === "unavailable" ? "!" : "?", x, y + 1);
+ g.restore();
+}
+
+function paintCluster(g: CanvasRenderingContext2D, scenery: Scenery, x: number, y: number, count: number, t: number, animated: boolean) {
+ g.save();
+ g.globalCompositeOperation = "lighter";
+ g.globalAlpha = .7;
+ g.drawImage(scenery.glow("#8aa8ff"), x - 46, y - 46, 92, 92);
+ g.globalAlpha = 1;
+ const dots = Math.min(9, count), spin = animated ? t * .35 : 0;
+ g.fillStyle = "#dbe6ff";
+ for (let i = 0; i < dots; i++) { const a = spin + i / dots * TAU; g.beginPath(); g.arc(x + Math.cos(a) * 30, y + Math.sin(a) * 12, 1.8, 0, TAU); g.fill(); }
+ g.restore();
+ g.beginPath(); g.arc(x, y, 19, 0, TAU);
+ g.fillStyle = "rgba(10,16,30,.92)"; g.fill();
+ g.strokeStyle = "rgba(190,210,255,.75)"; g.lineWidth = 1.5; g.stroke();
+ g.fillStyle = INK; g.font = `600 13px ${MONO}`; g.textAlign = "center"; g.textBaseline = "middle";
+ g.fillText(String(count), x, y + 1);
+}
+
+/** Targeting reticle plus a soft pulse; a single still ring without motion. */
+function paintSelection(g: CanvasRenderingContext2D, x: number, y: number, d: number, t: number, animated: boolean) {
+ g.save();
+ if (animated) for (let i = 0; i < 2; i++) {
+ const k = mod(t / 1.8 + i * .5, 1);
+ g.globalAlpha = (1 - k) * .55;
+ g.strokeStyle = SELECT; g.lineWidth = 2 * (1 - k) + .5;
+ g.beginPath(); g.arc(x, y, d * (.6 + k * .75), 0, TAU); g.stroke();
+ }
+ g.globalAlpha = 1;
+ g.strokeStyle = SELECT; g.lineWidth = 2;
+ g.beginPath(); g.arc(x, y, d * .68, 0, TAU); g.stroke();
+ const rr = d * .92, rot = animated ? t * .5 : Math.PI / 4, arm = Math.max(6, d * .2);
+ g.lineWidth = 2; g.strokeStyle = "#cfe6ff";
+ for (let i = 0; i < 4; i++) {
+ const a = rot + i * Math.PI / 2;
+ g.beginPath(); g.arc(x, y, rr, a - arm / rr, a + arm / rr); g.stroke();
+ }
+ g.restore();
+}
diff --git a/src/atlas/scenery.ts b/src/atlas/scenery.ts
new file mode 100644
index 0000000..327bb2b
--- /dev/null
+++ b/src/atlas/scenery.ts
@@ -0,0 +1,261 @@
+import type { StyleId } from "../contracts";
+import { SCENE } from "./camera";
+import { hash, type Landmark } from "./model";
+
+/** Original procedural Atlas scenery. Everything here is illustration: no
+ * repository facts, dependency graph or live data is encoded in a pixel. Layers
+ * are rendered once into small offscreen canvases so an ambient frame is a few
+ * image blits plus vector strokes. */
+
+export interface PlanetLook {
+ base: string; light: string; dark: string; feature: string; accent: string; glow: string; ring: string | null;
+}
+/** Planet looks hint at each landmark's suggested art style (palettes after the hero diorama). */
+export const PLANETS: Record = {
+ moonshot: { base: "#9a9ca1", light: "#eef2ef", dark: "#24262c", feature: "#6f7178", accent: "#7fe7dc", glow: "#8ff3ea", ring: null },
+ factorio: { base: "#9a8452", light: "#e4c98c", dark: "#2a2216", feature: "#6b5a36", accent: "#f2b13b", glow: "#ffcf6e", ring: "#c9a95e" },
+ "red-alert": { base: "#5e7c3e", light: "#b6cf86", dark: "#17231a", feature: "#2f5e6e", accent: "#ff5a43", glow: "#ffd27a", ring: null },
+ "red-alert-2": { base: "#1d6d92", light: "#c3e6f0", dark: "#06132a", feature: "#6ea86a", accent: "#39d0ff", glow: "#6fe3ff", ring: "#39d0ff" },
+ civilization: { base: "#3c83b2", light: "#f4f0d8", dark: "#0d1730", feature: "#8fae5a", accent: "#e0b04a", glow: "#ffe1a0", ring: null },
+};
+export const planetLook = (style: string): PlanetLook => PLANETS[style as StyleId] ?? PLANETS.moonshot;
+
+/** Nebula tints for editorial regions (category names only; not a data claim). */
+const NEBULA = ["#6a4be0", "#1fa3a8", "#c2448a", "#3667e0", "#d0803a", "#3aa866", "#9a4fd6", "#2b8fd6"];
+export const regionColor = (category: string) => NEBULA[hash(category.toLowerCase()) % NEBULA.length];
+
+const rng = (seed: number) => () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
+const canvas = (w: number, h: number) => { const c = document.createElement("canvas"); c.width = Math.max(1, Math.ceil(w)); c.height = Math.max(1, Math.ceil(h)); return c; };
+const rgba = (hex: string, a: number) => { const n = parseInt(hex.slice(1), 16); return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`; };
+export { rgba };
+
+export interface Route { ax: number; ay: number; cx: number; cy: number; bx: number; by: number; length: number; seed: number }
+export interface Region { name: string; color: string; x: number; y: number; count: number }
+export interface Twinkle { x: number; y: number; size: number; speed: number; phase: number; warm: boolean }
+
+export const STAR_TILE = 512;
+
+/** Greedy nearest-neighbour scenic chain. Purely decorative: it never reads
+ * dependencies, stars or response order beyond the stable layout positions. */
+export function scenicRoutes(points: readonly Landmark[]): Route[] {
+ const pool = points.slice(0, 120), routes: Route[] = [];
+ if (pool.length < 2) return routes;
+ const left = new Set(pool.map((_, i) => i));
+ let at = pool.reduce((best, p, i) => (p.x + p.y < pool[best].x + pool[best].y ? i : best), 0);
+ left.delete(at);
+ while (left.size) {
+ let next = -1, distance = Infinity;
+ for (const i of left) { const d = Math.hypot(pool[i].x - pool[at].x, pool[i].y - pool[at].y); if (d < distance) { distance = d; next = i; } }
+ left.delete(next);
+ const a = pool[at], b = pool[next];
+ if (distance < 1900) {
+ const seed = hash(a.repoKey + ">" + b.repoKey), bend = ((seed % 1000) / 1000 - .5) * .5, mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
+ const cx = mx - (b.y - a.y) * bend, cy = my + (b.x - a.x) * bend;
+ routes.push({ ax: a.x, ay: a.y, cx, cy, bx: b.x, by: b.y, length: distance * (1 + Math.abs(bend) * .6), seed });
+ }
+ at = next;
+ }
+ return routes;
+}
+export const routePoint = (r: Route, u: number) => { const v = 1 - u; return { x: v * v * r.ax + 2 * v * u * r.cx + u * u * r.bx, y: v * v * r.ay + 2 * v * u * r.cy + u * u * r.by }; };
+
+/** Editorial regions with a compact footprint get a faint name in the sky. */
+export function editorialRegions(points: readonly Landmark[]): Region[] {
+ const groups = new Map();
+ for (const p of points.slice(0, 2000)) { const list = groups.get(p.category) ?? []; list.push(p); groups.set(p.category, list); }
+ const regions: Region[] = [];
+ for (const [name, list] of groups) {
+ const x = list.reduce((n, p) => n + p.x, 0) / list.length, y = list.reduce((n, p) => n + p.y, 0) / list.length;
+ if (list.length > 40 || list.some(p => Math.hypot(p.x - x, p.y - y) > 1500)) continue;
+ regions.push({ name, color: regionColor(name), x, y: Math.min(...list.map(p => p.y)), count: list.length });
+ }
+ return regions;
+}
+
+export interface Scenery {
+ stars(ratio: number): { far: CanvasPattern | null; near: CanvasPattern | null; twinkles: Twinkle[] };
+ nebula(points: readonly Landmark[]): HTMLCanvasElement;
+ routes(points: readonly Landmark[]): Route[];
+ regions(points: readonly Landmark[]): Region[];
+ planet(style: string, variant: number, px: number): HTMLCanvasElement;
+ glow(color: string): HTMLCanvasElement;
+ dispose(): void;
+}
+
+export function createScenery(g: CanvasRenderingContext2D): Scenery {
+ let starRatio = 0, starLayers: ReturnType | null = null;
+ let nebulaFor: readonly Landmark[] | null = null, nebulaCanvas: HTMLCanvasElement | null = null;
+ let routesFor: readonly Landmark[] | null = null, routeList: Route[] = [];
+ let regionsFor: readonly Landmark[] | null = null, regionList: Region[] = [];
+ const sprites = new Map(), glows = new Map();
+ return {
+ stars(ratio) {
+ if (starLayers && starRatio === ratio) return starLayers;
+ starRatio = ratio;
+ const tile = (count: number, seed: number, bright: number) => {
+ const c = canvas(STAR_TILE * ratio, STAR_TILE * ratio), x = c.getContext("2d"), r = rng(seed);
+ if (!x) return null;
+ for (let i = 0; i < count; i++) {
+ const size = (.35 + r() ** 3 * bright) * ratio, a = .18 + r() * .62, tint = r();
+ x.fillStyle = tint > .9 ? `rgba(255,214,170,${a})` : tint > .75 ? `rgba(170,205,255,${a})` : `rgba(235,240,255,${a})`;
+ x.beginPath(); x.arc(r() * c.width, r() * c.height, size, 0, Math.PI * 2); x.fill();
+ }
+ return g.createPattern(c, "repeat");
+ };
+ const r = rng(77), twinkles: Twinkle[] = Array.from({ length: 22 }, () => ({ x: r() * 1024, y: r() * 1024, size: .8 + r() * 1.2, speed: .6 + r() * 1.6, phase: r() * Math.PI * 2, warm: r() > .7 }));
+ starLayers = { far: tile(340, 11, 1.1), near: tile(70, 29, 1.9), twinkles };
+ return starLayers;
+ },
+ nebula(points) {
+ if (nebulaCanvas && nebulaFor === points) return nebulaCanvas;
+ nebulaFor = points;
+ const k = 1 / 8, c = nebulaCanvas ?? canvas(SCENE.right * k, SCENE.bottom * k), x = c.getContext("2d");
+ nebulaCanvas = c;
+ if (!x) return c;
+ x.setTransform(1, 0, 0, 1, 0, 0);
+ x.clearRect(0, 0, c.width, c.height);
+ x.globalCompositeOperation = "lighter";
+ const r = rng(4242);
+ // Deep background dust lanes: decorative, independent of the catalog.
+ for (let i = 0; i < 9; i++) {
+ const cx = r() * c.width, cy = r() * c.height, rad = 120 + r() * 180, grad = x.createRadialGradient(cx, cy, 0, cx, cy, rad);
+ grad.addColorStop(0, rgba(i % 2 ? "#2a3a7a" : "#3b2a6a", .16)); grad.addColorStop(1, "rgba(0,0,0,0)");
+ x.fillStyle = grad; x.beginPath(); x.ellipse(cx, cy, rad, rad * (.45 + r() * .4), r() * Math.PI, 0, Math.PI * 2); x.fill();
+ }
+ for (const p of points.slice(0, 600)) {
+ const h = hash(p.repoKey), color = regionColor(p.category), px = p.x * k, py = p.y * k;
+ for (let j = 0; j < 3; j++) {
+ const ox = ((h >> (j * 5)) % 60 - 30) * (j + 1) * .9, oy = ((h >> (j * 7 + 3)) % 50 - 25) * (j + 1) * .7, rad = (64 + (h >> (j * 3)) % 48) * (1 - j * .15);
+ const grad = x.createRadialGradient(px + ox, py + oy, 0, px + ox, py + oy, rad);
+ grad.addColorStop(0, rgba(color, j ? .16 : .3)); grad.addColorStop(.55, rgba(color, j ? .07 : .12)); grad.addColorStop(1, rgba(color, 0));
+ x.fillStyle = grad;
+ x.beginPath(); x.ellipse(px + ox, py + oy, rad * 1.35, rad * .8, (h % 314) / 100, 0, Math.PI * 2); x.fill();
+ }
+ }
+ x.globalCompositeOperation = "source-over";
+ // Fine dust specks inside the clouds.
+ for (const p of points.slice(0, 300)) {
+ const local = rng(hash(p.repoKey + ":dust"));
+ x.fillStyle = rgba("#e6ecff", .35);
+ for (let i = 0; i < 14; i++) { const a = local() * Math.PI * 2, d = local() ** .6 * 70; x.fillRect(p.x * k + Math.cos(a) * d, p.y * k + Math.sin(a) * d * .7, .6, .6); }
+ }
+ return c;
+ },
+ routes(points) { if (routesFor !== points) { routesFor = points; routeList = scenicRoutes(points); } return routeList; },
+ regions(points) { if (regionsFor !== points) { regionsFor = points; regionList = editorialRegions(points); } return regionList; },
+ planet(style, variant, px) {
+ const key = `${style}:${variant}:${px}`;
+ let c = sprites.get(key);
+ if (!c) {
+ if (sprites.size > 96) sprites.clear();
+ c = paintPlanet(style, variant, px);
+ sprites.set(key, c);
+ }
+ return c;
+ },
+ glow(color) {
+ let c = glows.get(color);
+ if (!c) {
+ c = canvas(64, 64);
+ const x = c.getContext("2d");
+ if (x) { const grad = x.createRadialGradient(32, 32, 0, 32, 32, 32); grad.addColorStop(0, rgba(color, .9)); grad.addColorStop(.25, rgba(color, .35)); grad.addColorStop(1, rgba(color, 0)); x.fillStyle = grad; x.beginPath(); x.arc(32, 32, 32, 0, Math.PI * 2); x.fill(); }
+ glows.set(color, c);
+ }
+ return c;
+ },
+ dispose() { sprites.clear(); glows.clear(); nebulaCanvas = null; starLayers = null; },
+ };
+}
+
+/** A verified landmark: a small lit world with a settlement on its horizon.
+ * The sprite is 2.2× the planet diameter so halo and ring fit. */
+function paintPlanet(style: string, variant: number, px: number) {
+ const look = planetLook(style), size = px * 2.2, c = canvas(size, size), x = c.getContext("2d");
+ if (!x) return c;
+ const r = px / 2, cx = size / 2, cy = size / 2, rand = rng(hash(style) + variant * 977);
+ // Atmosphere halo.
+ const halo = x.createRadialGradient(cx, cy, r * .8, cx, cy, r * 1.75);
+ halo.addColorStop(0, rgba(look.glow, .42)); halo.addColorStop(.35, rgba(look.glow, .12)); halo.addColorStop(1, rgba(look.glow, 0));
+ x.fillStyle = halo; x.beginPath(); x.arc(cx, cy, r * 1.75, 0, Math.PI * 2); x.fill();
+ const tilt = -.38 + variant * .05;
+ const ring = (front: boolean) => {
+ if (!look.ring) return;
+ x.save(); x.translate(cx, cy); x.rotate(tilt);
+ x.beginPath(); x.ellipse(0, 0, r * 1.62, r * .42, 0, front ? 0 : Math.PI, front ? Math.PI : Math.PI * 2);
+ x.strokeStyle = rgba(look.ring, front ? .85 : .45); x.lineWidth = Math.max(1, r * .14); x.stroke();
+ x.beginPath(); x.ellipse(0, 0, r * 1.38, r * .34, 0, front ? 0 : Math.PI, front ? Math.PI : Math.PI * 2);
+ x.strokeStyle = rgba(look.ring, front ? .5 : .25); x.lineWidth = Math.max(.6, r * .05); x.stroke();
+ x.restore();
+ };
+ ring(false);
+ // Sphere body.
+ x.save();
+ x.beginPath(); x.arc(cx, cy, r, 0, Math.PI * 2); x.clip();
+ x.fillStyle = look.base; x.fillRect(cx - r, cy - r, px, px);
+ if (style === "factorio") {
+ for (let i = 0; i < 7; i++) { x.fillStyle = rgba(i % 2 ? look.feature : look.light, .18 + rand() * .2); x.fillRect(cx - r, cy - r + (i / 7) * px + rand() * 3, px, px / 9 + rand() * px / 14); }
+ x.fillStyle = rgba(look.accent, .55); x.beginPath(); x.ellipse(cx + r * .25, cy + r * .2, r * .22, r * .11, 0, 0, Math.PI * 2); x.fill();
+ } else if (style === "moonshot") {
+ for (let i = 0; i < 9; i++) { const a = rand() * Math.PI * 2, d = rand() * r * .8, s = r * (.08 + rand() * .2), ex = cx + Math.cos(a) * d, ey = cy + Math.sin(a) * d; x.fillStyle = rgba(look.feature, .75); x.beginPath(); x.arc(ex, ey, s, 0, Math.PI * 2); x.fill(); x.strokeStyle = rgba(look.light, .35); x.lineWidth = Math.max(.6, s * .25); x.beginPath(); x.arc(ex - s * .15, ey - s * .15, s, Math.PI * .9, Math.PI * 1.8); x.stroke(); }
+ } else {
+ // Continents (red-alert: seas on olive land; RA2/civ: land on ocean).
+ for (let i = 0; i < 6; i++) {
+ const a = rand() * Math.PI * 2, d = rand() * r * .75, ex = cx + Math.cos(a) * d, ey = cy + Math.sin(a) * d;
+ x.fillStyle = rgba(look.feature, .92); x.beginPath();
+ for (let k = 0; k < 7; k++) { const t = (k / 7) * Math.PI * 2, rr = r * (.14 + rand() * .2); k ? x.lineTo(ex + Math.cos(t) * rr * 1.3, ey + Math.sin(t) * rr) : x.moveTo(ex + Math.cos(t) * rr * 1.3, ey + Math.sin(t) * rr); }
+ x.closePath(); x.fill();
+ }
+ if (style === "civilization") {
+ x.fillStyle = rgba("#cdb65e", .75);
+ for (let i = 0; i < 4; i++) { x.beginPath(); x.arc(cx + (rand() - .5) * r * 1.2, cy + (rand() - .5) * r * 1.2, r * (.06 + rand() * .1), 0, Math.PI * 2); x.fill(); }
+ x.strokeStyle = rgba("#ffffff", .5); x.lineWidth = Math.max(.6, r * .07);
+ for (let i = 0; i < 3; i++) { x.beginPath(); const yy = cy - r * .6 + i * r * .55 + rand() * r * .2; x.moveTo(cx - r, yy); x.bezierCurveTo(cx - r * .3, yy - r * .2, cx + r * .2, yy + r * .2, cx + r, yy - r * .05); x.stroke(); }
+ }
+ }
+ // Day/night terminator and rim light.
+ const shade = x.createRadialGradient(cx - r * .45, cy - r * .5, r * .1, cx - r * .1, cy - r * .1, r * 1.45);
+ shade.addColorStop(0, rgba("#ffffff", .32)); shade.addColorStop(.45, "rgba(0,0,0,0)"); shade.addColorStop(.8, rgba(look.dark, .55)); shade.addColorStop(1, rgba(look.dark, .92));
+ x.fillStyle = shade; x.fillRect(cx - r, cy - r, px, px);
+ // Settlement lights on the night side.
+ x.fillStyle = look.accent;
+ for (let i = 0; i < 8; i++) { const a = Math.PI * (.05 + rand() * .55), d = r * (.45 + rand() * .45); x.globalAlpha = .55 + rand() * .45; x.fillRect(cx + Math.cos(a) * d, cy + Math.sin(a) * d, Math.max(1, r * .06), Math.max(1, r * .06)); }
+ x.globalAlpha = 1;
+ x.restore();
+ x.strokeStyle = rgba(look.glow, .75); x.lineWidth = Math.max(1, r * .06);
+ x.beginPath(); x.arc(cx, cy, r - x.lineWidth / 2, Math.PI * 1.02, Math.PI * 1.72); x.stroke();
+ settlement(x, style, cx, cy, r, look);
+ ring(true);
+ return c;
+}
+
+/** Tiny original silhouettes on the top horizon hint at the world's perspective. */
+function settlement(x: CanvasRenderingContext2D, style: string, cx: number, cy: number, r: number, look: PlanetLook) {
+ if (r < 14) return;
+ x.save();
+ x.translate(cx, cy); x.rotate(-.42); x.translate(0, -r + r * .04);
+ const u = r / 16;
+ x.fillStyle = look.light; x.strokeStyle = look.light; x.lineWidth = Math.max(1, u * .7);
+ const rect = (px: number, w: number, h: number) => x.fillRect(px * u, -h * u, w * u, h * u + u);
+ if (style === "moonshot") {
+ x.beginPath(); x.arc(-3 * u, 0, 3.4 * u, Math.PI, 0); x.fill();
+ x.beginPath(); x.arc(3.5 * u, 0, 2.2 * u, Math.PI, 0); x.fill();
+ rect(6.5, 1.2, 7); x.fillStyle = look.accent; x.fillRect(6.4 * u, -8 * u, 1.4 * u, 1.4 * u);
+ } else if (style === "factorio") {
+ rect(-6, 6, 3.5); rect(-4.5, 1.4, 7.5); rect(-1.6, 1.4, 6); rect(1.5, 4.5, 2.5);
+ x.fillStyle = rgba("#d8cfbf", .55); x.beginPath(); x.arc(-3.8 * u, -9.5 * u, 1.8 * u, 0, Math.PI * 2); x.arc(-2.5 * u, -11.5 * u, 1.3 * u, 0, Math.PI * 2); x.fill();
+ } else if (style === "red-alert") {
+ rect(-5, 5, 3); rect(1, 1, 6);
+ x.beginPath(); x.ellipse(1.5 * u, -7 * u, 3 * u, 1.4 * u, -.4, 0, Math.PI * 2); x.stroke();
+ x.fillStyle = look.accent; x.fillRect(-1 * u, -4.5 * u, 1.3 * u, 1.3 * u);
+ } else if (style === "red-alert-2") {
+ rect(-5.5, 3.6, 4); rect(-.5, 1.2, 8.5);
+ x.beginPath(); x.arc(.1 * u, -9.6 * u, 1.8 * u, 0, Math.PI * 2); x.fill();
+ x.fillStyle = look.accent; x.beginPath(); x.arc(.1 * u, -9.6 * u, .9 * u, 0, Math.PI * 2); x.fill();
+ rect(3, 3, 3);
+ } else {
+ x.beginPath(); x.moveTo(-6 * u, 0); x.lineTo(-6 * u, -3 * u); x.lineTo(-4 * u, -5 * u); x.lineTo(-2 * u, -3 * u); x.lineTo(-2 * u, 0); x.fill();
+ x.beginPath(); x.moveTo(0, u); x.lineTo(0, -5 * u); x.lineTo(2.5 * u, -8 * u); x.lineTo(5 * u, -5 * u); x.lineTo(5 * u, u); x.fill();
+ x.fillStyle = look.accent; x.fillRect(1.9 * u, -4 * u, 1.2 * u, 1.6 * u);
+ }
+ x.restore();
+}
diff --git a/src/components/ExplorerPage.tsx b/src/components/ExplorerPage.tsx
index 7240949..856b51f 100644
--- a/src/components/ExplorerPage.tsx
+++ b/src/components/ExplorerPage.tsx
@@ -45,6 +45,8 @@ import { WorldCanvas } from "../world/WorldCanvas";
import { STYLES } from "../client/demo";
import { clock, enrichWorld, getBudget, getCachedWorld, getWorld, refreshWorld, shortNumber } from "../client/api";
import { Brand, External } from "./shared";
+import { Fact, ProvenanceDisclosure, confidenceCounts, statusCounts, type ProvenanceTone } from "./Provenance";
+import "./world-chrome.css";
/** Keep source text intact while offering natural identifier wrapping points. */
function identifierBreaks(value: string) {
return value.split(/(?<=[/@_.-])/u).map((part, i) => {part} );
@@ -67,6 +69,38 @@ function ciSummary(w: WorldSnapshot) {
tone: passed === w.workflows.length ? "healthy" : "neutral",
};
}
+const PT_DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "America/Los_Angeles" });
+const PT_FULL = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "short", timeZone: "America/Los_Angeles" });
+/** Calm summary + full provenance record for the world topline. */
+function WorldProvenance({ world, stale }: { world: WorldSnapshot; stale: boolean }) {
+ const kind = world.source === "demo" ? "demo" : stale ? "stale" : world.health.github === "error" ? "error" : world.health.github === "partial" ? "partial" : "connected";
+ const status = { demo: "ILLUSTRATION", stale: "STALE CACHE · CURRENT STATE UNKNOWN", error: "UPLINK DEGRADED", partial: "PARTIAL SNAPSHOT", connected: "GITHUB CONNECTED" }[kind];
+ const lead = { demo: "Illustration", stale: "Stale snapshot", error: "Uplink degraded", partial: "Partial snapshot", connected: "Snapshot" }[kind];
+ const short = { demo: "Illustration", stale: "Stale", error: "Degraded", partial: "Partial", connected: "Snapshot" }[kind];
+ const tone: ProvenanceTone = { demo: "demo", stale: "stale", error: "error", partial: "partial", connected: "fresh" }[kind] as ProvenanceTone;
+ const collected = new Date(world.collectedAt);
+ const valid = !Number.isNaN(collected.getTime());
+ return (
+ {lead} {short} {valid && · {PT_DAY.format(collected)}, {clock(world.collectedAt)} }>}
+ >
+ {status}
+
+ {valid ? {PT_FULL.format(collected)} PT : "capture time unknown"}
+ {`${world.repo.defaultBranch} · ${world.headSha.slice(0, 7)}`}
+ {world.nodes.length} · {confidenceCounts(world.nodes)}
+ {world.edges.length} · {confidenceCounts(world.edges)}
+ {statusCounts(world.nodes)}
+ {world.coverage.tree === "full" ? "available" : world.coverage.tree === "truncated" ? "partially sampled" : "unavailable"}
+
+ {"Source-driven world · motion illustrates connections"}
+ {"Architecture inference labeled · not runtime telemetry"}
+
+ );
+}
export function ExplorerPage({
owner,
repo,
@@ -141,6 +175,31 @@ export function ExplorerPage({
setAtlasToken(validatedReturnToken(window.location.search,readAtlasSession().value));
return subscribeExploration(()=>setProgressRevision(x=>x+1));
},[cachedOnly]);
+ // The style switcher scrolls horizontally on narrow screens: keep the active
+ // style in view and fade only the edges that hide more options.
+ const dock = useRef(null);
+ const [dockEdge, setDockEdge] = useState<"none" | "start" | "end" | "both">("none");
+ const updateDockEdges = useCallback(() => {
+ const el = dock.current;
+ if (!el) return;
+ const max = el.scrollWidth - el.clientWidth;
+ const start = el.scrollLeft > 2, end = el.scrollLeft < max - 2;
+ setDockEdge(start && end ? "both" : start ? "start" : end ? "end" : "none");
+ }, []);
+ const hasWorld = !!world;
+ useEffect(() => {
+ const el = dock.current;
+ if (!el) return;
+ const active = el.querySelector("button.active");
+ if (active && el.scrollWidth > el.clientWidth + 1) {
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ el.scrollTo({ left: active.offsetLeft - (el.clientWidth - active.offsetWidth) / 2, behavior: reduce ? "auto" : "smooth" });
+ }
+ updateDockEdges();
+ const observer = new ResizeObserver(updateDockEdges);
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [style, hasWorld, updateDockEdges]);
const changeStyle = (s: StyleId) => {
setStyle(s);
const url = new URL(window.location.href);
@@ -249,19 +308,7 @@ export function ExplorerPage({
) : (
<>
-
-
- {world.source === "demo"
- ? "ILLUSTRATION"
- : stale
- ? "STALE CACHE · CURRENT STATE UNKNOWN"
- : world.health.github === "error"
- ? "UPLINK DEGRADED"
- : world.health.github === "partial"
- ? "PARTIAL SNAPSHOT"
- : "GITHUB CONNECTED"}
- {clock(world.collectedAt)}
-
+
{[
{ id: "world", icon: Map, en: "World" },
@@ -350,7 +397,7 @@ export function ExplorerPage({
<>
{ const identity = worldIdentity(world); if (cachedOnly && identity) noteExploration(identity,"visited"); }} />
{showReplay && cachedOnly && }
-
+
{styleInfo.name.toUpperCase()} / REPOSITORY WORLD
{world.repo.name}
@@ -358,10 +405,10 @@ export function ExplorerPage({
{world.repo.description?.slice(0, 150)}
-
+
setShowLayers(!showLayers)}>
- {"Map legend"}
+ {"Map legend"}
{showLayers && (
@@ -384,10 +431,6 @@ export function ExplorerPage({
)}
-
-
- {"SOURCE-DRIVEN WORLD · MOTION ILLUSTRATES CONNECTIONS"}
-
>
) : tab === "activity" ? (
@@ -541,7 +584,7 @@ export function ExplorerPage({
)}
)}
-
+
{STYLES.map((s) => (
{"Powered by Meta Muse Spark 1.3"}
- {"ARCHITECTURE INFERENCE LABELED · NOT RUNTIME TELEMETRY"}
>
)}
diff --git a/src/components/LandingPage.tsx b/src/components/LandingPage.tsx
index 9554101..1cecbf3 100644
--- a/src/components/LandingPage.tsx
+++ b/src/components/LandingPage.tsx
@@ -13,6 +13,7 @@ import {
LoaderCircle,
ShieldCheck,
Layers3,
+ Play,
} from "lucide-react";
import type { FeaturedProject, StyleId } from "../contracts";
import { STYLES } from "../client/demo";
@@ -184,6 +185,7 @@ export function LandingPage() {
))}
+
Take the tour
Explore Atlas
diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx
index d33ae5c..af52454 100644
--- a/src/components/ProjectCard.tsx
+++ b/src/components/ProjectCard.tsx
@@ -1,9 +1,9 @@
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, useState } from "react";
import { ArrowRight, ArrowUpRight, Github, Star } from "lucide-react";
import { appPath } from "../base-path";
import type { FeaturedProject } from "../contracts";
import { shortNumber } from "../client/api";
-import { RepositoryPreview, type PreviewTilt } from "./RepositoryPreview";
+import { PreviewProvenanceChip, RepositoryPreview, type PreviewProvenance, type PreviewTilt } from "./RepositoryPreview";
const reduced = () => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
@@ -17,6 +17,7 @@ export function ProjectCard({ project: p, enabled, index, onBuild }: { project:
const tilt = useRef({ x: 0, y: 0 });
const frame = useRef(0);
const target = useRef({ x: 0, y: 0, rect: null as DOMRect | null });
+ const [provenance, setProvenance] = useState(null);
const href = p.ready ? appPath(`/world/${p.fullName}?style=${p.suggestedStyle}`) : undefined;
useEffect(() => {
@@ -67,7 +68,7 @@ export function ProjectCard({ project: p, enabled, index, onBuild }: { project:
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onBuild?.(p.fullName); } }}>
-
+
{p.category}
@@ -99,6 +100,8 @@ export function ProjectCard({ project: p, enabled, index, onBuild }: { project:
{"Source"}
+ {/* Outside the card link so the disclosure is its own control, never a navigation. */}
+ {provenance && }
);
diff --git a/src/components/Provenance.tsx b/src/components/Provenance.tsx
new file mode 100644
index 0000000..af7cfc6
--- /dev/null
+++ b/src/components/Provenance.tsx
@@ -0,0 +1,96 @@
+import { useEffect, useId, useRef, useState, type ReactNode } from "react";
+import { Info } from "lucide-react";
+import "./provenance.css";
+
+export type ProvenanceTone = "fresh" | "stale" | "partial" | "error" | "demo" | "unknown";
+
+/**
+ * A calm one-line summary chip that discloses the full provenance record.
+ * Opens on click/Enter/Space (pinned) and, for mouse pointers, on hover.
+ * Escape or focus leaving the widget closes it. Closed details stay in the DOM
+ * (hidden) so the honesty record is never removed, only de-emphasised.
+ */
+export function ProvenanceDisclosure({
+ summary, title, tone, children, className = "", align = "start", direction = "down",
+}: {
+ summary: ReactNode; title: string; tone: ProvenanceTone; children: ReactNode;
+ className?: string; align?: "start" | "end"; direction?: "up" | "down";
+}) {
+ const id = useId();
+ const root = useRef(null);
+ const trigger = useRef(null);
+ const [pinned, setPinned] = useState(false);
+ const [hovered, setHovered] = useState(false);
+ const open = pinned || hovered;
+ useEffect(() => {
+ if (!pinned) return;
+ const outside = (e: PointerEvent) => { if (!root.current?.contains(e.target as Node)) setPinned(false); };
+ document.addEventListener("pointerdown", outside);
+ return () => document.removeEventListener("pointerdown", outside);
+ }, [pinned]);
+ return (
+ { if (e.pointerType === "mouse") setHovered(true); }}
+ onPointerLeave={(e) => { if (e.pointerType === "mouse") setHovered(false); }}
+ onBlur={(e) => { if (!root.current?.contains(e.relatedTarget as Node | null)) setPinned(false); }}
+ onKeyDown={(e) => {
+ if (e.key !== "Escape" || !open) return;
+ e.stopPropagation();
+ setPinned(false); setHovered(false);
+ trigger.current?.focus();
+ }}
+ >
+
{
+ e.stopPropagation();
+ if (pinned) { setPinned(false); setHovered(false); } else setPinned(true);
+ }}
+ >
+
+ {summary}
+
+ {` — ${title}`}
+
+
+ {children}
+
+
+ );
+}
+
+/** A labelled fact row inside a provenance panel. */
+export function Fact({ term, children }: { term: string; children: ReactNode }) {
+ return
{term} {children} ;
+}
+
+const UTC_DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
+/** "Nov 4" for a capture timestamp (UTC, matching the full stamp in the panel). */
+export function shortDay(iso: string | null) {
+ if (!iso) return null;
+ const date = new Date(iso);
+ return Number.isNaN(date.getTime()) ? null : UTC_DAY.format(date);
+}
+export function utcStamp(iso: string) {
+ return `${new Date(iso).toISOString().replace("T", " ").slice(0, 16)} UTC`;
+}
+/** "1 observed · 29 inferred" with zero groups kept explicit. */
+export function confidenceCounts(items: { confidence: "observed" | "inferred" }[]) {
+ const observed = items.filter((item) => item.confidence === "observed").length;
+ return `${observed} observed / ${items.length - observed} inferred`;
+}
+/** Snapshot status lamps, e.g. "1 active · 29 unknown"; unknown is always listed. */
+export function statusCounts(nodes: { status: "healthy" | "active" | "attention" | "unknown" }[]) {
+ const count = (status: string) => nodes.filter((node) => node.status === status).length;
+ return [...(["healthy", "active", "attention"] as const).filter((s) => count(s)).map((s) => `${count(s)} ${s}`), `${count("unknown")} unknown`].join(" · ");
+}
diff --git a/src/components/RepositoryPreview.tsx b/src/components/RepositoryPreview.tsx
index e08c79a..3707f99 100644
--- a/src/components/RepositoryPreview.tsx
+++ b/src/components/RepositoryPreview.tsx
@@ -5,6 +5,7 @@ import { animatePreview } from "../gallery/animation";
import { loadRepositoryPreview } from "../gallery/preview-loader";
import { layoutWorld, mapBounds, type PlacedNode } from "../world/layout";
import { PALETTES, paintTerrain, paintConnections, paintBuilding } from "../world/paint";
+import { ProvenanceDisclosure, Fact, confidenceCounts, shortDay, statusCounts, utcStamp } from "./Provenance";
import "./repository-preview.css";
/** Move decorative survey signals along the actual source graph. The signal
@@ -172,7 +173,7 @@ function MiniWorldCanvas({ data, style, tilt }: { data: RepositoryPreviewData; s
return ;
}
-export function RepositoryPreview({ fullName, style, enabled = true, tilt }: { fullName: string; style: StyleId; enabled?: boolean; tilt?: PreviewTilt }) {
+export function RepositoryPreview({ fullName, style, enabled = true, tilt, onProvenance }: { fullName: string; style: StyleId; enabled?: boolean; tilt?: PreviewTilt; onProvenance?: (info: PreviewProvenance | null) => void }) {
const box = useRef(null);
const [near, setNear] = useState(false);
const [visible, setVisible] = useState(false);
@@ -211,18 +212,46 @@ export function RepositoryPreview({ fullName, style, enabled = true, tilt }: { f
: state === "cached" ? ("Cached snapshot")
: state === "unavailable" ? ("Structure unavailable")
: ("Unknown · no cached map");
+ const report = useRef(onProvenance);
+ report.current = onProvenance;
+ useEffect(() => { report.current?.(mapped && state ? { data: data!, state, label } : null); }, [mapped, data, state, label]);
return (
{mapped && visible &&
}
{!mapped &&
{!data && near && !failed && }{label} {"No automatic collection or model calls"}
}
- {mapped &&
- {label} · {data!.tree === "truncated" ? ("partial tree") : data!.github !== "ok" ? ("partial upstream evidence") : ("GitHub facts")}
- {data!.nodes.length}/{data!.totalNodes} {"districts"} · {data!.edges.length}/{data!.totalEdges} {"links"} · {`${data!.nodes.filter((n) => n.confidence === "observed").length} observed / ${data!.nodes.filter((n) => n.confidence === "inferred").length} inferred`}
- {data!.headSha?.slice(0, 7) || ("head unknown")} · {data!.collectedAt ? {new Date(data!.collectedAt).toISOString().replace("T", " ").slice(0, 16)} UTC : ("capture time unknown")}
- {"Motion illustrates links · not live activity"}
-
}
);
}
+
+/** What a mapped card preview knows about its own capture. */
+export interface PreviewProvenance { data: RepositoryPreviewData; state: RepositoryPreviewData["state"]; label: string }
+
+/** Compact "Stale snapshot · Nov 4 · 30 districts" chip that discloses the full capture record. */
+export function PreviewProvenanceChip({ info }: { info: PreviewProvenance }) {
+ const { data, state, label } = info;
+ const day = shortDay(data.collectedAt);
+ const prefix = state === "stale" ? "Stale snapshot" : state === "cached" ? "Snapshot" : "Unknown snapshot";
+ const tone = state === "stale" ? "stale" : state === "cached" ? "fresh" : "unknown";
+ return (
+ {prefix}{day ? ` · ${day}` : ""}· {data.totalNodes} {data.totalNodes === 1 ? "district" : "districts"}>}
+ >
+ {label}
+
+ {data.tree === "truncated" ? "partial tree" : data.github !== "ok" ? "partial upstream evidence" : "GitHub facts"}
+ {data.nodes.length}/{data.totalNodes} {"districts"} · {data.edges.length}/{data.totalEdges} {"links"}
+ {confidenceCounts(data.nodes)}
+ {statusCounts(data.nodes)}
+ {data.headSha?.slice(0, 7) || "head unknown"}
+ {data.collectedAt ? {utcStamp(data.collectedAt)} : "capture time unknown"}
+
+ {"Motion illustrates links · not live activity"}
+
+ );
+}
diff --git a/src/components/landing-motion.css b/src/components/landing-motion.css
index c0faf18..6f3ccbf 100644
--- a/src/components/landing-motion.css
+++ b/src/components/landing-motion.css
@@ -120,12 +120,11 @@
.project-card-shell.is-warping .repository-preview-canvas { transform: scale(1.6); transition: transform .34s cubic-bezier(.5, 0, .9, .4); }
@keyframes card-warp { from { opacity: 0; transform: scale(.6); } to { opacity: 1; transform: scale(1.5); } }
-/* Map stays fully visible; caption sits below it rather than over half of it. */
+/* The map is the hero; capture provenance lives in a compact disclosure chip over it. */
.landing .project-art { height: auto; min-height: 0; }
.landing .project-art:has(.repository-preview) .repository-preview { position: relative; inset: auto; display: flex; flex-direction: column; }
-.repository-preview-stage { position: relative; height: 196px; overflow: hidden; }
+.repository-preview-stage { position: relative; height: var(--card-art-h, 232px); overflow: hidden; }
.repository-preview-stage .repository-preview-canvas { transition: transform .5s ease; }
-.landing .project-art:has(.repository-preview) .repository-preview-caption { position: relative; inset: auto; border-top: 1px solid var(--geist-map-border); }
.landing .project-art:not(:has(.repository-preview)) { height: 220px; min-height: 220px; }
.preview-loader { display: inline-flex; gap: 5px; }
.preview-loader i { width: 7px; height: 7px; border-radius: 1px; background: currentColor; animation: preview-load 1s ease-in-out infinite; }
diff --git a/src/components/provenance.css b/src/components/provenance.css
new file mode 100644
index 0000000..b4e13be
--- /dev/null
+++ b/src/components/provenance.css
@@ -0,0 +1,38 @@
+/* Calm provenance chip + disclosure panel (cards and world page). */
+.provenance { position: relative; display: inline-flex; min-width: 0; max-width: 100%; z-index: 7; }
+.provenance-trigger {
+ display: inline-flex; align-items: center; gap: 7px; min-width: 0; max-width: 100%; min-height: 28px;
+ padding: 4px 9px 4px 8px; border-radius: 999px; border: 1px solid #ffffff2e;
+ background: #0f1115c7; color: #f4f4f5; font: 500 12px/1.2 var(--mono); letter-spacing: .01em;
+ backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); cursor: pointer;
+ transition: background .2s, border-color .2s;
+}
+.provenance-trigger:hover, .provenance[data-open="true"] .provenance-trigger { background: #0f1115ee; border-color: #ffffff66; }
+.provenance-trigger:focus-visible { outline: 2px solid var(--geist-map-focus); outline-offset: 2px; }
+.provenance-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.provenance-summary .sep { opacity: .45; margin: 0 5px; }
+.provenance-icon { opacity: .6; }
+.provenance-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 0 3px #4ade8026; }
+.provenance[data-tone="stale"] .provenance-dot, .provenance[data-tone="partial"] .provenance-dot { background: #fbbf24; box-shadow: 0 0 0 3px #fbbf2426; }
+.provenance[data-tone="error"] .provenance-dot { background: #f87171; box-shadow: 0 0 0 3px #f8717126; }
+.provenance[data-tone="demo"] .provenance-dot, .provenance[data-tone="unknown"] .provenance-dot { background: #a1a1aa; box-shadow: 0 0 0 3px #a1a1aa26; }
+
+.provenance-panel {
+ position: absolute; top: calc(100% + 8px); left: 0; width: max-content; min-width: 240px; max-width: min(340px, calc(100vw - 32px));
+ padding: 12px 14px; border-radius: 10px; border: 1px solid #ffffff24; background: #0f1115f5; color: #e4e4e7;
+ box-shadow: 0 18px 40px -12px #000a; font: 12px/1.5 var(--mono); text-align: left; overflow-wrap: anywhere; cursor: default;
+}
+.provenance[data-align="end"] .provenance-panel { left: auto; right: 0; }
+.provenance[data-direction="up"] .provenance-panel { top: auto; bottom: calc(100% + 8px); }
+.provenance-panel[hidden] { display: none; }
+.provenance-status { display: block; margin: 0 0 8px; color: #fafafa; font-weight: 600; }
+.provenance[data-tone="stale"] .provenance-status, .provenance[data-tone="partial"] .provenance-status { color: #fcd34d; }
+.provenance[data-tone="error"] .provenance-status { color: #fca5a5; }
+.provenance-panel dl { display: grid; gap: 4px; margin: 0; }
+.provenance-fact { display: grid; grid-template-columns: 82px minmax(0, 1fr); gap: 10px; }
+.provenance-fact dt { color: #a1a1aa; }
+.provenance-fact dd { margin: 0; color: #f4f4f5; }
+.provenance-fact a { color: #93c5fd; text-decoration: underline; text-underline-offset: 2px; display: inline-flex; align-items: center; gap: 3px; }
+.provenance-note { margin: 9px 0 0; padding-top: 8px; border-top: 1px solid #ffffff1f; color: #a1a1aa; font-size: 11.5px; }
+.provenance-note + .provenance-note { margin-top: 2px; padding-top: 0; border-top: 0; }
+@media (prefers-reduced-motion: reduce) { .provenance-trigger { transition: none; } }
diff --git a/src/components/repository-preview.css b/src/components/repository-preview.css
index 0e46206..5efa964 100644
--- a/src/components/repository-preview.css
+++ b/src/components/repository-preview.css
@@ -6,16 +6,20 @@
.repository-preview-canvas { width: 100%; height: 100%; display: block; pointer-events: none; }
.repository-preview-empty { background: var(--geist-map-surface); position: absolute; inset: 32px 14px 16px; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 9px; text-align: center; font: 13px/1.5 var(--mono); color: var(--geist-map-fg); }
.repository-preview-empty small { color: var(--geist-map-muted); font-size: 12px; opacity: 1; }
-.repository-preview-caption { position: absolute; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; gap: 4px; padding: 12px 12px 10px; background: var(--geist-map-surface); font: 12px/1.45 var(--mono); color: var(--geist-map-fg); pointer-events: none; overflow-wrap: anywhere; }
.project-art:has(.repository-preview) .project-category { top: 8px; left: 10px; padding: 4px 6px; background: var(--geist-map-surface); color: var(--geist-map-fg); border-radius: 2px; }
.project-art:has(.repository-preview) .project-arrow { top: 44px; bottom: auto; color: var(--geist-map-fg); border-color: var(--geist-map-fg); background: var(--geist-map-surface); }
-.project-card-shell { position: relative; min-width: 0; }
+.project-card-shell { position: relative; min-width: 0; --card-art-h: 232px; }
+/* Bottom-left of the art, outside the card link; the panel opens upward over the map. */
+.card-provenance { position: absolute; left: 10px; right: 10px; top: calc(var(--card-art-h) - 38px); pointer-events: none; }
+.card-provenance > * { pointer-events: auto; }
+.card-provenance .provenance-panel { left: 0; right: 0; width: auto; min-width: 0; max-width: none; padding: 10px 12px; }
+.card-provenance .provenance-panel dl { gap: 2px; }
+.repository-preview-stage::after { content: ""; position: absolute; inset: auto 0 0 0; height: 56px; background: linear-gradient(to top, #0000002e, #0000); pointer-events: none; }
.project-card-shell > .project-card { display: block; height: 100%; }
.project-source-link { position: absolute; top: 9px; right: 10px; display: inline-flex; align-items: center; gap: 4px; padding: 4px 6px; border: 1px solid var(--geist-map-border); border-radius: 2px; background: var(--geist-map-surface); color: var(--geist-map-fg); font: 13px/1.45 var(--mono); }
.project-source-link:focus-visible { outline-color: var(--geist-map-focus); }
.project-source-link:hover { background: var(--geist-primary-hover); text-decoration: underline; }
-.repository-preview-motion-note { color: var(--geist-map-muted); font-size: 12px; letter-spacing: .1px; }
.site-header nav > a.header-repo-link { text-decoration: underline; text-underline-offset: 3px; display: inline-flex; align-items: center; gap: 6px; color: var(--geist-interactive); font-weight: 600; }
.site-header nav > a.header-repo-link:hover { text-decoration: underline; }
@media (max-width: 900px) {
diff --git a/src/components/world-chrome.css b/src/components/world-chrome.css
new file mode 100644
index 0000000..e99417c
--- /dev/null
+++ b/src/components/world-chrome.css
@@ -0,0 +1,92 @@
+/* World page chrome: calm provenance chip, compact mobile layout, scrollable style switcher.
+ * Prefixed with .explorer so it wins over the base stylesheet regardless of load order. */
+
+/* ---- Provenance chip on the light topline ---- */
+.explorer .world-provenance .provenance-trigger {
+ background: var(--geist-surface); color: var(--geist-secondary); border-color: var(--geist-border);
+ backdrop-filter: none; -webkit-backdrop-filter: none; min-height: 30px;
+}
+.explorer .world-provenance .provenance-trigger:hover,
+.explorer .world-provenance[data-open="true"] .provenance-trigger { background: var(--geist-hover); border-color: var(--geist-control-border); color: var(--geist-fg); }
+.explorer .world-provenance-lead { color: var(--geist-fg); }
+.explorer .world-provenance-short { display: none; color: var(--geist-fg); }
+.explorer .world-provenance .provenance-dot { box-shadow: none; }
+.explorer .world-provenance[data-tone="stale"] .provenance-dot,
+.explorer .world-provenance[data-tone="partial"] .provenance-dot { background: #d97706; }
+.explorer .world-provenance[data-tone="fresh"] .provenance-dot { background: #16a34a; }
+.explorer .world-provenance[data-tone="error"] .provenance-dot { background: #dc2626; }
+.explorer .world-provenance .provenance-panel { z-index: 30; }
+.explorer .world-topline { min-height: 52px; }
+
+/* ---- Map chrome: the caption strip is gone, so everything settles lower ---- */
+.explorer .style-dock { bottom: 16px; }
+.explorer .map-hint { bottom: 76px; opacity: .85; }
+.explorer .camera-controls, .explorer .map-coordinates { bottom: 116px; }
+.explorer .map-tooltip { bottom: 70px; }
+.explorer .stage-heading { padding: 12px 14px; border-radius: 6px; background: #171717e0; }
+
+/* ---- Style switcher: horizontally scrollable with snap and edge fades ---- */
+.explorer .style-dock {
+ overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x proximity; scrollbar-width: none;
+ -webkit-overflow-scrolling: touch;
+}
+.explorer .style-dock::-webkit-scrollbar { display: none; }
+.explorer .style-dock button { scroll-snap-align: center; }
+.explorer .style-dock[data-edge="end"] { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 36px), #0000); mask-image: linear-gradient(to right, #000 calc(100% - 36px), #0000); }
+.explorer .style-dock[data-edge="start"] { -webkit-mask-image: linear-gradient(to left, #000 calc(100% - 36px), #0000); mask-image: linear-gradient(to left, #000 calc(100% - 36px), #0000); }
+.explorer .style-dock[data-edge="both"] { -webkit-mask-image: linear-gradient(to right, #0000, #000 36px, #000 calc(100% - 36px), #0000); mask-image: linear-gradient(to right, #0000, #000 36px, #000 calc(100% - 36px), #0000); }
+
+@media (max-width: 900px) {
+ /* One row: tabs and stamp keep their size, the chip yields (time moves into the panel). */
+ .explorer .world-topline { padding-block: 6px; flex-wrap: nowrap; min-height: 52px; }
+ .explorer .world-topline > .world-provenance { flex: 0 1 auto; min-width: 0; }
+ .explorer .world-topline > .world-tabs { flex: none; flex-wrap: nowrap; height: auto; }
+ .explorer .world-topline > .world-source-stamp { flex: none; flex-wrap: nowrap; }
+ .explorer .world-provenance-time { display: none; }
+ .explorer .stage-tools > button { gap: 8px; }
+}
+
+@media (max-width: 600px) {
+ /* Header + topline: two short rows instead of a tall, half-empty block. */
+ .explorer .explorer-header { min-height: 52px; padding-block: 8px; gap: 10px; }
+ .explorer .world-topline { min-height: 0; padding: 4px 12px; gap: 6px 10px; flex-wrap: nowrap; justify-content: flex-start; }
+ .explorer .world-topline > .world-provenance { order: 2; flex: none; margin-left: auto; }
+ .explorer .world-topline > .world-tabs { order: 1; flex: 0 1 auto; gap: 12px; flex-wrap: nowrap; min-width: 0; height: auto; }
+ .explorer .world-topline > .world-source-stamp { order: 3; flex: none; gap: 0; }
+ .explorer .world-source-stamp > svg, .explorer .world-source-stamp { font-size: 0; }
+ .explorer .world-source-stamp > svg { display: none; }
+ .explorer .world-source-stamp code { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); margin: 0; padding: 0; }
+ .explorer .world-source-stamp button { padding: 8px 4px; }
+ .explorer .world-tabs button { min-height: 40px; padding: 0 1px; }
+ .explorer .world-provenance .provenance-trigger { padding: 3px 8px 3px 7px; min-height: 28px; gap: 6px; }
+ .explorer .world-provenance-lead, .explorer .world-provenance-when { display: none; }
+ .explorer .world-provenance-short { display: inline; }
+ /* Panel drops below the whole topline at stage width instead of hanging off the small chip. */
+ .explorer .world-topline { position: relative; z-index: 20; }
+ .explorer .world-topline > .world-provenance { position: static; }
+ .explorer .world-provenance .provenance-panel { top: calc(100% + 4px); left: 12px; right: 12px; width: auto; min-width: 0; max-width: none; }
+
+ /* Heading: name first; the eyebrow and description step back. */
+ .explorer .stage-heading { top: 12px; left: 12px; max-width: calc(100% - 80px); padding: 8px 12px; }
+ .explorer .stage-eyebrow { display: none; }
+ .explorer .stage-heading h1 { font-size: 22px; margin: 0; }
+ .explorer .stage-heading h1 > span { font-size: 14px; margin-left: 8px; }
+ .explorer .stage-heading p { margin-top: 2px; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; max-width: none; }
+ .explorer .stage-tools { top: 12px; right: 12px; }
+ .explorer .stage-tools > button { padding: 0; width: 40px; height: 40px; justify-content: center; }
+ .explorer .stage-tool-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
+
+ /* Touch: drag/pinch is discoverable; free the map instead of a desktop hint. */
+ .explorer .map-hint { display: none; }
+ .explorer .camera-controls { bottom: 68px; right: 12px; }
+ .explorer .map-tooltip { bottom: 68px; }
+
+ /* Style switcher spans the stage and scrolls; the active style is kept in view. */
+ .explorer .style-dock { left: 12px; right: 12px; bottom: 10px; transform: none; max-width: none; width: auto; padding: 3px; gap: 2px; }
+ .explorer .style-dock button { padding: 8px 10px; min-height: 40px; }
+}
+
+@media (max-width: 400px) {
+ .explorer .world-tabs button > svg { display: none; }
+ .explorer .world-topline > .world-tabs { gap: 10px; }
+}
diff --git a/src/og/card.ts b/src/og/card.ts
new file mode 100644
index 0000000..e24df69
--- /dev/null
+++ b/src/og/card.ts
@@ -0,0 +1,269 @@
+import type { StyleId, WorldEdge, WorldNode } from "../contracts";
+import { illustration, STYLES } from "../client/demo";
+import { layoutWorld, mapBounds } from "../world/layout";
+import { PALETTES, paintBuilding, paintConnections, paintTerrain } from "../world/paint";
+import type { Pen } from "../world/primitives";
+import { drawableText, asciiShare, strokeFont } from "./font";
+import { STYLE_TITLES } from "./meta";
+import { encodePng } from "./png";
+import { SoftCanvas } from "./raster";
+
+export const OG_WIDTH = 1200;
+export const OG_HEIGHT = 630;
+/** Districts drawn per card; the rest are summarised by the stats line. */
+export const OG_MAX_NODES = 30;
+export const OG_MAX_EDGES = 96;
+export const TAGLINE = "Every codebase. A world of its own.";
+
+export interface CardWorld {
+ fullName: string;
+ nodes: WorldNode[];
+ edges: WorldEdge[];
+ totalNodes: number;
+ totalEdges: number;
+ stars: number | null;
+ language: string | null;
+ description: string | null;
+}
+export interface CardOptions {
+ /** Absolute monotonic deadline (performance.now()). */
+ deadline?: number;
+ /** Display-only host/path line, e.g. "chaorders.com/github-factory". */
+ site?: string | null;
+}
+
+const BRAND_INK = "#f5f3ea";
+const pen = (c: SoftCanvas) => c as unknown as Pen;
+function canvas(width: number, height: number, deadline: number) {
+ const c = new SoftCanvas(width, height, deadline);
+ c.text = strokeFont;
+ return c;
+}
+
+/** Draws a world into the target rectangle using the live canvas's shared art. */
+function paintWorld(target: SoftCanvas, world: Pick, style: StyleId, view: { x: number; y: number; w: number; h: number; zoom: number }, deadline: number) {
+ const nodes = world.nodes.slice(0, OG_MAX_NODES);
+ const ids = new Set(nodes.map((n) => n.id));
+ const edges = world.edges.filter((e) => ids.has(e.source) && ids.has(e.target)).slice(0, OG_MAX_EDGES);
+ const placed = layoutWorld(nodes, style);
+ const b = mapBounds(placed);
+ // Red Alert keeps its deliberately coarse raster, like the live world.
+ const res = style === "red-alert" ? 0.62 : 1;
+ const scene = res === 1 ? target : canvas(Math.ceil(target.width * res), Math.ceil(target.height * res), deadline);
+ // Fit the district bounds into the requested focus rectangle; terrain fills the rest.
+ const k = Math.min(1.6, Math.min(view.w / b.width, view.h / b.height) * view.zoom);
+ const ox = view.x + view.w / 2 - (b.x + b.width / 2) * k, oy = view.y + view.h / 2 - (b.y + b.height / 2) * k;
+ const g = pen(scene);
+ g.setTransform(res, 0, 0, res, 0, 0);
+ g.fillStyle = PALETTES[style].ground;
+ g.fillRect(0, 0, target.width, target.height);
+ g.setTransform(res * k, 0, 0, res * k, res * ox, res * oy);
+ paintTerrain(g, style, placed, world.fullName);
+ paintConnections(g, placed, edges, style, 0, false);
+ for (const pn of placed.toSorted((a, c) => a.y - c.y)) paintBuilding(g, pn, style, false, false, 1);
+ g.resetTransform();
+ if (scene !== target) target.blitNearest(scene);
+}
+
+function scrims(g: Pen, style: StyleId, strength = 1) {
+ const left = g.createLinearGradient(0, 0, OG_WIDTH * 0.72, 0);
+ left.addColorStop(0, `rgba(8,10,12,${0.94 * strength})`);
+ left.addColorStop(0.45, `rgba(8,10,12,${0.78 * strength})`);
+ left.addColorStop(1, "rgba(8,10,12,0)");
+ g.fillStyle = left;
+ g.fillRect(0, 0, OG_WIDTH, OG_HEIGHT);
+ const bottom = g.createLinearGradient(0, OG_HEIGHT * 0.55, 0, OG_HEIGHT);
+ bottom.addColorStop(0, "rgba(8,10,12,0)");
+ bottom.addColorStop(1, `rgba(8,10,12,${0.72 * strength})`);
+ g.fillStyle = bottom;
+ g.fillRect(0, 0, OG_WIDTH, OG_HEIGHT);
+ const vignette = g.createRadialGradient(OG_WIDTH * 0.62, OG_HEIGHT * 0.5, OG_HEIGHT * 0.3, OG_WIDTH * 0.62, OG_HEIGHT * 0.5, OG_WIDTH * 0.75);
+ vignette.addColorStop(0, "rgba(0,0,0,0)");
+ vignette.addColorStop(1, style === "civilization" ? "rgba(48,40,22,0.35)" : "rgba(4,8,12,0.5)");
+ g.fillStyle = vignette;
+ g.fillRect(0, 0, OG_WIDTH, OG_HEIGHT);
+}
+
+/** Original crescent mark plus wordmark. The crescent is one polygon, so it
+ * reads correctly on light and dark ground alike. */
+function crescent(g: Pen, cx: number, cy: number, r: number) {
+ const ix = cx + r * 0.42, iy = cy - r * 0.34, ir = r * 0.84;
+ const outer: number[][] = [], inner: number[][] = [];
+ for (let i = 0; i <= 96; i++) {
+ const a = (i / 96) * Math.PI * 2, dx = Math.cos(a), dy = Math.sin(a);
+ // Ray from the outer centre leaves the inner disc at t; keep [t, r].
+ const ox = cx - ix, oy = cy - iy, b = ox * dx + oy * dy, c = ox * ox + oy * oy - ir * ir;
+ const t = -b + Math.sqrt(Math.max(0, b * b - c));
+ if (t >= r) continue;
+ outer.push([cx + dx * r, cy + dy * r]);
+ inner.push([cx + dx * t, cy + dy * t]);
+ }
+ g.beginPath();
+ [...outer, ...inner.reverse()].forEach(([x, y], i) => (i ? g.lineTo(x, y) : g.moveTo(x, y)));
+ g.closePath();
+ g.fill();
+}
+function brand(g: Pen, x: number, y: number, accent: string, size = 1) {
+ g.save();
+ g.fillStyle = accent;
+ crescent(g, x + 15 * size, y, 15 * size);
+ g.fillStyle = BRAND_INK;
+ g.font = `700 ${Math.round(21 * size)}px spacing=1.6`;
+ g.textBaseline = "middle";
+ g.fillText("MOON FACTORY", x + 40 * size, y);
+ g.restore();
+}
+
+function fitFont(g: Pen, text: string, weight: number, max: number, min: number, width: number) {
+ for (let size = max; size > min; size -= 2) {
+ g.font = `${weight} ${size}px`;
+ if (g.measureText(text).width <= width) return size;
+ }
+ g.font = `${weight} ${min}px`;
+ return min;
+}
+function ellipsize(g: Pen, text: string, width: number) {
+ if (g.measureText(text).width <= width) return text;
+ let t = text;
+ while (t.length > 1 && g.measureText(t + "…").width > width) t = t.slice(0, -1);
+ return t.trimEnd() + "…";
+}
+const compact = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k` : String(n)).replace(".0", "");
+
+function chip(g: Pen, x: number, y: number, text: string, accent: string) {
+ g.font = "600 21px";
+ const w = g.measureText(text).width + 30;
+ g.fillStyle = "rgba(10,12,14,0.72)";
+ g.beginPath();
+ g.roundRect(x, y, w, 42, 21);
+ g.fill();
+ g.strokeStyle = `${accent}99`;
+ g.lineWidth = 1.5;
+ g.stroke();
+ g.fillStyle = BRAND_INK;
+ g.textBaseline = "middle";
+ g.textAlign = "left";
+ g.fillText(text, x + 15, y + 21);
+ return w;
+}
+
+function footer(g: Pen, site: string | null | undefined, accent: string) {
+ g.fillStyle = accent;
+ g.fillRect(0, OG_HEIGHT - 6, OG_WIDTH, 6);
+ if (!site) return;
+ g.font = "500 19px spacing=0.6";
+ g.textBaseline = "alphabetic";
+ g.textAlign = "right";
+ g.fillStyle = "rgba(245,243,234,0.82)";
+ g.fillText(drawableText(site, 60), OG_WIDTH - 52, OG_HEIGHT - 34);
+ g.textAlign = "left";
+}
+
+function finish(c: SoftCanvas) {
+ return encodePng(c.width, c.height, c.toRgb([11, 13, 16, 1]));
+}
+
+export function renderWorldCard(world: CardWorld, style: StyleId, options: CardOptions = {}): Uint8Array {
+ const deadline = options.deadline ?? performance.now() + 2500;
+ const c = canvas(OG_WIDTH, OG_HEIGHT, deadline), g = pen(c);
+ const p = PALETTES[style];
+ paintWorld(c, world, style, { x: 520, y: 40, w: 660, h: 560, zoom: 1.08 }, deadline);
+ scrims(g, style);
+ brand(g, 64, 74, p.trim);
+ // Eyebrow: perspective name in the style's accent.
+ g.font = "700 20px spacing=2.4";
+ g.fillStyle = p.trim;
+ g.textBaseline = "alphabetic";
+ g.fillText(`${STYLE_TITLES[style].toUpperCase()} WORLD`, 66, 214);
+ const [owner, repo] = world.fullName.split("/");
+ const safeOwner = drawableText(owner ?? "", 60), safeRepo = drawableText(repo ?? "", 100);
+ g.font = "500 34px";
+ g.fillStyle = "rgba(245,243,234,0.72)";
+ g.fillText(ellipsize(g, `${safeOwner} /`, 640), 64, 268);
+ const size = fitFont(g, safeRepo, 700, 92, 46, 660);
+ g.fillStyle = BRAND_INK;
+ g.fillText(ellipsize(g, safeRepo, 660), 62, 268 + size * 1.02);
+ let y = 268 + size * 1.02 + 30;
+ const description = world.description && asciiShare(world.description) > 0.85 ? drawableText(world.description, 160) : "";
+ if (description) {
+ g.font = "450 22px";
+ g.fillStyle = "rgba(245,243,234,0.78)";
+ g.fillText(ellipsize(g, description, 600), 64, y + 24);
+ y += 44;
+ }
+ let x = 64;
+ const chips = [
+ world.stars !== null ? `★ ${compact(world.stars)}` : null,
+ `${compact(world.totalNodes)} district${world.totalNodes === 1 ? "" : "s"}`,
+ `${compact(world.totalEdges)} link${world.totalEdges === 1 ? "" : "s"}`,
+ world.language ? drawableText(world.language, 18) : null,
+ ].filter((v): v is string => Boolean(v));
+ for (const text of chips) x += chip(g, x, Math.min(y + 18, OG_HEIGHT - 120), text, p.trim) + 12;
+ footer(g, options.site, p.trim);
+ g.font = "500 19px";
+ g.fillStyle = "rgba(245,243,234,0.62)";
+ g.fillText(TAGLINE, 64, OG_HEIGHT - 34);
+ return finish(c);
+}
+
+/** Generic card: the illustrative world seen through all five perspectives. */
+export function renderHomeCard(options: CardOptions = {}): Uint8Array {
+ const deadline = options.deadline ?? performance.now() + 8000;
+ const c = canvas(OG_WIDTH, OG_HEIGHT, deadline), g = pen(c);
+ const demo = illustration();
+ const world = { fullName: "moon-factory/illustration", nodes: demo.nodes, edges: demo.edges };
+ const order: StyleId[] = ["moonshot", "factorio", "red-alert", "red-alert-2", "civilization"];
+ // Five slanted bands on the right, each a different perspective of one world.
+ const x0 = 470, band = (OG_WIDTH - x0) / order.length + 26, slant = 70;
+ order.forEach((style, i) => {
+ const slice = canvas(OG_WIDTH, OG_HEIGHT, deadline);
+ paintWorld(slice, world, style, { x: 520, y: 20, w: 680, h: 610, zoom: 1.15 }, deadline);
+ const left = x0 + i * (band - 26) - (i ? 0 : 400), right = left + band + (i ? 0 : 400);
+ // Copy a parallelogram from the slice into the card.
+ for (let y = 0; y < OG_HEIGHT; y++) {
+ const shift = (1 - y / OG_HEIGHT) * slant;
+ const a = Math.max(0, Math.floor(left + shift)), b = Math.min(OG_WIDTH, Math.floor(right + shift));
+ c.data.set(slice.data.subarray((y * OG_WIDTH + a) * 4, (y * OG_WIDTH + b) * 4), (y * OG_WIDTH + a) * 4);
+ }
+ if (i) {
+ g.strokeStyle = "rgba(11,13,16,0.9)";
+ g.lineWidth = 6;
+ g.beginPath();
+ g.moveTo(left + slant, 0);
+ g.lineTo(left, OG_HEIGHT);
+ g.stroke();
+ }
+ });
+ const left = g.createLinearGradient(0, 0, 900, 0);
+ left.addColorStop(0, "rgba(9,11,14,0.97)");
+ left.addColorStop(0.5, "rgba(9,11,14,0.88)");
+ left.addColorStop(1, "rgba(9,11,14,0)");
+ g.fillStyle = left;
+ g.fillRect(0, 0, OG_WIDTH, OG_HEIGHT);
+ brand(g, 64, 78, "#efb17c");
+ g.fillStyle = BRAND_INK;
+ g.font = "700 76px";
+ g.textBaseline = "alphabetic";
+ g.fillText("Every codebase.", 60, 268);
+ g.fillStyle = "#efb17c";
+ g.fillText("A world of its own.", 60, 360);
+ g.font = "450 25px";
+ g.fillStyle = "rgba(245,243,234,0.8)";
+ g.fillText("Turn any public GitHub repository into", 64, 432);
+ g.fillText("a living world. Five perspectives, one source.", 64, 468);
+ let x = 64;
+ for (const s of order) {
+ const info = STYLES.find((v) => v.id === s)!;
+ g.fillStyle = PALETTES[s].trim;
+ g.beginPath();
+ g.arc(x + 7, 531, 7, 0, Math.PI * 2);
+ g.fill();
+ x += 26;
+ }
+ g.font = "600 19px spacing=1.2";
+ g.fillStyle = "rgba(245,243,234,0.7)";
+ g.textBaseline = "middle";
+ g.fillText("5 ORIGINAL ART STYLES", x + 8, 531);
+ footer(g, options.site, "#efb17c");
+ return finish(c);
+}
diff --git a/src/og/font.ts b/src/og/font.ts
new file mode 100644
index 0000000..cf0c5d5
--- /dev/null
+++ b/src/og/font.ts
@@ -0,0 +1,202 @@
+import type { SoftCanvas, TextEngine } from "./raster";
+
+/** Original monoline geometric stroke font for server-rendered previews.
+ * Units: cap height 10, x-height 7, baseline y = 0, y grows downward.
+ * Commands: M x y | L x y | A cx cy rx ry fromDeg toDeg (toDeg < fromDeg = anticlockwise). */
+const G: Record = {
+ " ": [3.4, ""],
+ A: [7.4, "M0 0 L3.7 -10 L7.4 0 M1.3 -3.4 L6.1 -3.4"],
+ B: [6.6, "M0 0 L0 -10 L3.5 -10 A3.5 -7.6 2.4 2.4 -90 90 L0 -5.2 M3.5 -5.2 L3.9 -5.2 A3.9 -2.6 2.6 2.6 -90 90 L0 0"],
+ C: [7.6, "A4.6 -5 4.4 5 -40 -320"],
+ D: [7.2, "M0 0 L0 -10 L2.4 -10 A2.4 -5 4.8 5 -90 90 L0 0"],
+ E: [6, "M6 -10 L0 -10 L0 0 L6 0 M0 -5.1 L5 -5.1"],
+ F: [5.8, "M5.8 -10 L0 -10 L0 0 M0 -5.1 L4.9 -5.1"],
+ G: [8, "M5.2 -4.6 L8.8 -4.6 L8.8 -2.6 M8.8 -4.6 A4.4 -5 4.4 5 0 -320"],
+ H: [7, "M0 0 L0 -10 M7 0 L7 -10 M0 -5.1 L7 -5.1"],
+ I: [0, "M0 0 L0 -10"],
+ J: [5, "M5 -10 L5 -2.6 A2.5 -2.6 2.5 2.6 0 180"],
+ K: [6.6, "M0 0 L0 -10 M6.4 -10 L0 -3.8 M2.3 -6 L6.6 0"],
+ L: [5.6, "M0 -10 L0 0 L5.6 0"],
+ M: [8.6, "M0 0 L0 -10 L4.3 -2.6 L8.6 -10 L8.6 0"],
+ N: [7.2, "M0 0 L0 -10 L7.2 0 L7.2 -10"],
+ O: [9.2, "A4.6 -5 4.6 5 0 360"],
+ P: [6.4, "M0 0 L0 -10 L3.6 -10 A3.6 -7.3 2.7 2.7 -90 90 L0 -4.6"],
+ Q: [9.2, "A4.6 -5 4.6 5 0 360 M5.8 -2.6 L9 0.6"],
+ R: [6.6, "M0 0 L0 -10 L3.6 -10 A3.6 -7.3 2.7 2.7 -90 90 L0 -4.6 M3.4 -4.6 L6.6 0"],
+ S: [6.6, "A3.3 -7.5 3 2.5 -25 -270 A3.3 -2.5 3.3 2.5 -90 155"],
+ T: [7, "M0 -10 L7 -10 M3.5 -10 L3.5 0"],
+ U: [6.8, "M0 -10 L0 -3.4 A3.4 -3.4 3.4 3.4 180 0 L6.8 -10"],
+ V: [7.4, "M0 -10 L3.7 0 L7.4 -10"],
+ W: [10, "M0 -10 L2.5 0 L5 -7.8 L7.5 0 L10 -10"],
+ X: [7, "M0 -10 L7 0 M7 -10 L0 0"],
+ Y: [7, "M0 -10 L3.5 -4.8 L7 -10 M3.5 -4.8 L3.5 0"],
+ Z: [6.6, "M0 -10 L6.6 -10 L0 0 L6.6 0"],
+ a: [6.6, "M6.6 -3.5 A3.3 -3.5 3.3 3.5 0 360 M6.6 -7 L6.6 0"],
+ b: [6.6, "M0 -10.4 L0 0 M6.6 -3.5 A3.3 -3.5 3.3 3.5 0 360"],
+ c: [6, "A3.4 -3.5 3.3 3.5 -40 -320"],
+ d: [6.6, "M6.6 -10.4 L6.6 0 M6.6 -3.5 A3.3 -3.5 3.3 3.5 0 360"],
+ e: [6.6, "M0.1 -3.6 L6.6 -3.6 A3.3 -3.5 3.3 3.5 0 -315"],
+ f: [4.6, "M5.2 -9.6 A3.4 -8.4 1.9 1.9 -40 -180 L1.5 0 M0 -6.8 L4.4 -6.8"],
+ g: [6.6, "M6.6 -3.5 A3.3 -3.5 3.3 3.5 0 360 M6.6 -7 L6.6 0.4 A3.3 0.4 3.3 2.8 0 150"],
+ h: [6.4, "M0 -10.4 L0 0 M0 -3.8 A3.2 -3.8 3.2 3.2 180 360 L6.4 0"],
+ i: [0, "M0 -7 L0 0 M0 -9.7 L0 -9.65"],
+ j: [2, "M2 -7 L2 1.2 A0.2 1.2 1.8 1.9 0 160 M2 -9.7 L2 -9.65"],
+ k: [5.8, "M0 -10.4 L0 0 M5.6 -7 L0 -2.6 M2 -4.2 L5.8 0"],
+ l: [0, "M0 -10.4 L0 0"],
+ m: [10, "M0 -7 L0 0 M0 -4.2 A2.5 -4.2 2.5 2.8 180 360 L5 0 M5 -4.2 A7.5 -4.2 2.5 2.8 180 360 L10 0"],
+ n: [6.4, "M0 -7 L0 0 M0 -3.8 A3.2 -3.8 3.2 3.2 180 360 L6.4 0"],
+ o: [7, "A3.5 -3.5 3.5 3.5 0 360"],
+ p: [6.6, "M0 -7 L0 3.2 M6.6 -3.5 A3.3 -3.5 3.3 3.5 0 360"],
+ q: [6.6, "M6.6 -7 L6.6 3.2 M6.6 -3.5 A3.3 -3.5 3.3 3.5 0 360"],
+ r: [4.4, "M0 -7 L0 0 M0 -3.6 A3.4 -3.6 3.4 3.4 180 285"],
+ s: [5.6, "A2.8 -5.25 2.6 1.75 -25 -270 A2.8 -1.75 2.8 1.75 -90 155"],
+ t: [4.6, "M1.6 -9.2 L1.6 -1.8 A3.4 -1.8 1.8 1.8 180 90 L4.6 0 M0 -7 L4.4 -7"],
+ u: [6.4, "M0 -7 L0 -3.2 A3.2 -3.2 3.2 3.2 180 0 M6.4 -7 L6.4 0"],
+ v: [6.2, "M0 -7 L3.1 0 L6.2 -7"],
+ w: [9, "M0 -7 L2.25 0 L4.5 -5.4 L6.75 0 L9 -7"],
+ x: [5.8, "M0 -7 L5.8 0 M5.8 -7 L0 0"],
+ y: [6.2, "M0 -7 L3.1 0 M6.2 -7 L2.3 2.4 L1.5 3.2 L0.3 3.2"],
+ z: [5.8, "M0 -7 L5.8 -7 L0 0 L5.8 0"],
+ "0": [6.6, "A3.3 -5 3.3 5 0 360"],
+ "1": [3.4, "M0 -7.8 L2.8 -10 L2.8 0"],
+ "2": [6.2, "A3.1 -7.1 3 2.9 -165 35 L0 0 L6.4 0"],
+ "3": [6.2, "A3.1 -7.6 2.8 2.4 -160 90 A3.1 -2.6 3.2 2.6 -90 155"],
+ "4": [6.8, "M5.2 0 L5.2 -10 L0 -3 L6.8 -3"],
+ "5": [6.2, "M5.8 -10 L1.1 -10 L0.6 -5.6 A3.1 -3.1 3.1 3.1 -125 150"],
+ "6": [6.4, "A3.2 -3.2 3.2 3.2 0 360 M0.3 -4.4 L4.6 -10"],
+ "7": [6.2, "M0 -10 L6.2 -10 L2 0"],
+ "8": [6.4, "M5.7 -7.5 A3.2 -7.5 2.5 2.5 0 360 M6.4 -2.55 A3.2 -2.55 3.2 2.55 0 360"],
+ "9": [6.4, "A3.2 -6.8 3.2 3.2 0 360 M6.1 -5.6 L1.8 0"],
+ ".": [0, "M0 0 L0 -0.05"],
+ ",": [0.6, "M0.6 -0.3 L-0.2 2"],
+ ":": [0, "M0 0 L0 -0.05 M0 -6 L0 -6.05"],
+ ";": [0.6, "M0.6 -0.3 L-0.2 2 M0.6 -6 L0.6 -6.05"],
+ "-": [3.8, "M0 -3.9 L3.8 -3.9"],
+ _: [6, "M0 1.6 L6 1.6"],
+ "/": [5, "M0 1 L5 -10.6"],
+ "\\": [5, "M0 -10.6 L5 1"],
+ "'": [0, "M0 -10 L0 -7.4"],
+ '"': [2.4, "M0 -10 L0 -7.4 M2.4 -10 L2.4 -7.4"],
+ "`": [1.6, "M0 -10.4 L1.6 -8.6"],
+ "!": [0, "M0 -10 L0 -3.2 M0 0 L0 -0.05"],
+ "?": [5.6, "A2.8 -7.4 2.8 2.6 -170 70 L2.8 -3 M2.8 0 L2.8 -0.05"],
+ "(": [2.6, "A3.4 -3.8 3.4 7.2 -110 -250"],
+ ")": [2.6, "A-0.8 -3.8 3.4 7.2 -70 70"],
+ "[": [2.6, "M2.6 -10.6 L0 -10.6 L0 2.2 L2.6 2.2"],
+ "]": [2.6, "M0 -10.6 L2.6 -10.6 L2.6 2.2 L0 2.2"],
+ "{": [3.2, "M3.2 -10.6 L1.8 -10.6 L1.6 -4.6 L0 -4.2 L1.6 -3.8 L1.8 2.2 L3.2 2.2"],
+ "}": [3.2, "M0 -10.6 L1.4 -10.6 L1.6 -4.6 L3.2 -4.2 L1.6 -3.8 L1.4 2.2 L0 2.2"],
+ "<": [5.4, "M5.4 -8.2 L0 -4.2 L5.4 -0.2"],
+ ">": [5.4, "M0 -8.2 L5.4 -4.2 L0 -0.2"],
+ "=": [5.6, "M0 -5.6 L5.6 -5.6 M0 -2.6 L5.6 -2.6"],
+ "+": [5.8, "M0 -4.1 L5.8 -4.1 M2.9 -7 L2.9 -1.2"],
+ "*": [5, "M2.5 -10 L2.5 -5 M0.2 -8.8 L4.8 -6.2 M4.8 -8.8 L0.2 -6.2"],
+ "#": [7, "M2.4 -10 L1.4 0 M5.6 -10 L4.6 0 M0.4 -6.6 L7 -6.6 M0 -3.2 L6.6 -3.2"],
+ "%": [8, "A1.9 -7.8 1.9 2.1 0 360 M8 -2.1 A6.1 -2.1 1.9 2.1 0 360 M7.2 -10 L0.8 0"],
+ "&": [7.4, "M7.4 0 L1.6 -6.6 A3.3 -8.1 1.9 1.9 -150 30 L0.8 -3.6 A3.3 -2.5 2.7 2.5 -150 30 L7 -4"],
+ "@": [10, "M6.6 -3.4 A5 -4.4 1.8 2.1 0 360 M6.6 -6.4 L6.6 -2.8 A8 -2.8 1.4 1.4 180 90 A5 -4.4 5 5.4 20 340"],
+ $: [6.2, "A3.1 -6.9 2.8 2 -25 -270 A3.1 -2.9 3.1 2 -90 155 M3.1 -10.6 L3.1 0.6"],
+ "^": [5, "M0 -6.6 L2.5 -10 L5 -6.6"],
+ "~": [6, "M0 -3.6 L1.5 -4.8 L4.5 -3.2 L6 -4.4"],
+ "|": [0, "M0 -10.6 L0 2.2"],
+ "·": [0, "M0 -4 L0 -4.05"],
+ "…": [8, "M0 0 L0 -0.05 M4 0 L4 -0.05 M8 0 L8 -0.05"],
+ "★": [9, "M4.5 -10.4 L5.7 -6.9 L9.2 -6.9 L6.4 -4.7 L7.5 -1 L4.5 -3.2 L1.5 -1 L2.6 -4.7 L-0.2 -6.9 L3.3 -6.9 L4.5 -10.4"],
+ "•": [0, "M0 -4 L0 -4.05"],
+};
+type Cmd = ["M" | "L", number, number] | ["A", number, number, number, number, number, number];
+const parsed = new Map();
+function glyph(ch: string) {
+ const hit = parsed.get(ch);
+ if (hit) return hit;
+ const def = G[ch];
+ if (!def) return null;
+ const tokens = def[1].split(/\s+/).filter(Boolean);
+ const cmds: Cmd[] = [];
+ for (let i = 0; i < tokens.length; ) {
+ const t = tokens[i];
+ if (t[0] === "A") {
+ const v = [t.slice(1), ...tokens.slice(i + 1, i + 6)].map(Number);
+ cmds.push(["A", v[0], v[1], v[2], v[3], v[4], v[5]]);
+ i += 6;
+ } else {
+ cmds.push([t[0] as "M" | "L", Number(t.slice(1)), Number(tokens[i + 1])]);
+ i += 2;
+ }
+ }
+ const out = { w: def[0], cmds };
+ parsed.set(ch, out);
+ return out;
+}
+
+/** Normalises arbitrary text to drawable glyphs; untrusted input never reaches a font engine. */
+export function drawableText(input: string, max = 120): string {
+ const cleaned = input
+ .normalize("NFKD")
+ .replace(/[̀-ͯ]/g, "")
+ .replace(/[‘’]/g, "'")
+ .replace(/[“”]/g, '"')
+ .replace(/[–—]/g, "-")
+ .replace(/\s+/g, " ")
+ .trim();
+ let out = "";
+ for (const ch of cleaned) {
+ if (out.length >= max) break;
+ out += G[ch] ? ch : "?";
+ }
+ return out.replace(/\?{2,}/g, "?");
+}
+export function asciiShare(input: string) {
+ const chars = [...input.replace(/\s/g, "")];
+ return chars.length ? chars.filter((c) => G[c] && c !== "?").length / chars.length : 1;
+}
+
+export function parseFont(font: string) {
+ const size = Number(font.match(/(\d+(?:\.\d+)?)px/)?.[1] ?? 10);
+ const w = font.match(/\b([1-9]00)\b/)?.[1];
+ const weight = w ? Number(w) : /\bbold\b/.test(font) ? 700 : 400;
+ const spacing = Number(font.match(/\bspacing=(-?\d+(?:\.\d+)?)/)?.[1] ?? 0);
+ return { size: Math.max(1, Math.min(400, size)), weight, spacing };
+}
+const strokeUnits = (weight: number) => 0.9 + ((Math.max(100, Math.min(900, weight)) - 100) / 800) * 1.6;
+
+function layout(text: string, font: string) {
+ const { size, weight, spacing } = parseFont(font);
+ const unit = (size * 0.7) / 10, stroke = strokeUnits(weight);
+ const gap = 1.1 + stroke * 0.7 + spacing;
+ let x = 0;
+ const placed: { g: { w: number; cmds: Cmd[] }; x: number }[] = [];
+ for (const ch of text) {
+ const g = glyph(ch) ?? glyph("?")!;
+ placed.push({ g, x: x + stroke / 2 });
+ x += g.w + gap + (ch === " " ? 0 : stroke);
+ }
+ return { placed, width: Math.max(0, x - gap) * unit, unit, stroke };
+}
+
+export const strokeFont: TextEngine = {
+ measure: (text, font) => layout(drawableText(text, 400), font).width,
+ draw(g: SoftCanvas, text: string, x: number, y: number, font: string, align: string, baseline: string, outline: boolean) {
+ const { placed, width, unit, stroke } = layout(drawableText(text, 400), font);
+ const ox = align === "center" ? x - width / 2 : align === "right" || align === "end" ? x - width : x;
+ const oy = baseline === "middle" ? y + 5 * unit : baseline === "top" || baseline === "hanging" ? y + 10.6 * unit : baseline === "bottom" ? y - 3 * unit : y;
+ if (!outline) g.strokeStyle = g.fillStyle;
+ g.lineWidth = outline ? g.lineWidth : stroke * unit;
+ g.lineCap = "round";
+ g.lineJoin = "round";
+ g.setLineDash([]);
+ for (const { g: gl, x: gx } of placed) {
+ g.beginPath();
+ const px = (v: number) => ox + (gx + v) * unit, py = (v: number) => oy + v * unit;
+ for (const c of gl.cmds) {
+ if (c[0] === "M") g.moveTo(px(c[1]), py(c[2]));
+ else if (c[0] === "L") g.lineTo(px(c[1]), py(c[2]));
+ else {
+ const [, cx, cy, rx = 0, ry = 0, from = 0, to = 0] = c;
+ g.ellipse(px(cx), py(cy), rx * unit, ry * unit, 0, (from * Math.PI) / 180, (to * Math.PI) / 180, to < from);
+ }
+ }
+ g.stroke();
+ }
+ },
+};
diff --git a/src/og/meta.ts b/src/og/meta.ts
new file mode 100644
index 0000000..8daeb56
--- /dev/null
+++ b/src/og/meta.ts
@@ -0,0 +1,85 @@
+import { STYLE_IDS, type StyleId } from "../contracts";
+import { appPath } from "../base-path";
+
+/** Client-safe social metadata. Every value is derived from validated route
+ * params and fixed copy; no repository-supplied text reaches a meta tag. */
+export const SITE_NAME = "Moon Factory";
+export const HOME_TITLE = "Moon Factory — Your codebase, a living world";
+export const HOME_DESCRIPTION = "Every codebase. A world of its own. Turn a public GitHub repository into a living, explorable world. Five perspectives. One source of truth.";
+export const THEME_COLOR = "#0b0d10";
+export const OG_IMAGE_ALT = "A procedurally drawn Moon Factory world diorama.";
+
+/** Social copy names for each perspective. */
+export const STYLE_TITLES: Record = {
+ moonshot: "Moonshot",
+ factorio: "Factorio",
+ "red-alert": "Red Alert",
+ "red-alert-2": "Red Alert 2",
+ civilization: "Civilization",
+};
+export const isStyleId = (value: unknown): value is StyleId => typeof value === "string" && (STYLE_IDS as readonly string[]).includes(value);
+
+/** Same owner/repository rules as server/github.ts parseRepo (verified by tests). */
+export function validRepoParams(owner: unknown, repo: unknown): { owner: string; repo: string } | null {
+ if (typeof owner !== "string" || typeof repo !== "string") return null;
+ const name = repo.replace(/\.git$/, "");
+ if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(owner) || !/^[a-zA-Z0-9_.-]{1,100}$/.test(name) || name === "." || name === "..") return null;
+ return { owner, repo: name };
+}
+
+/** Accepts only a bare http(s) origin; anything else is rejected. */
+export function normalizeOrigin(value: unknown): string | null {
+ if (typeof value !== "string" || !value) return null;
+ try {
+ const url = new URL(value);
+ if ((url.protocol !== "https:" && url.protocol !== "http:") || url.origin !== value.replace(/\/$/, "") || url.username || url.password) return null;
+ return url.origin;
+ } catch {
+ return null;
+ }
+}
+export const absoluteUrl = (origin: string, path: string) => `${origin}${appPath(path)}`;
+
+type Meta = { name?: string; property?: string; content: string } | { title: string };
+function social(origin: string, title: string, description: string, path: string, image: string, alt: string): Meta[] {
+ const url = absoluteUrl(origin, path), imageUrl = absoluteUrl(origin, image);
+ return [
+ { title },
+ { name: "description", content: description },
+ { property: "og:site_name", content: SITE_NAME },
+ { property: "og:type", content: "website" },
+ { property: "og:locale", content: "en_US" },
+ { property: "og:title", content: title },
+ { property: "og:description", content: description },
+ { property: "og:url", content: url },
+ { property: "og:image", content: imageUrl },
+ { property: "og:image:type", content: "image/png" },
+ { property: "og:image:width", content: "1200" },
+ { property: "og:image:height", content: "630" },
+ { property: "og:image:alt", content: alt },
+ { name: "twitter:card", content: "summary_large_image" },
+ { name: "twitter:title", content: title },
+ { name: "twitter:description", content: description },
+ { name: "twitter:image", content: imageUrl },
+ { name: "twitter:image:alt", content: alt },
+ { name: "theme-color", content: THEME_COLOR },
+ ];
+}
+
+export const HOME_IMAGE_PATH = "og/home.png";
+export const homeMeta = (origin: string) => social(origin, HOME_TITLE, HOME_DESCRIPTION, "", HOME_IMAGE_PATH, OG_IMAGE_ALT);
+
+export function worldImagePath(owner: string, repo: string, style: StyleId) {
+ return `og/world/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}?style=${style}`;
+}
+/** Invalid params fall back to the generic homepage card, never echoed text. */
+export function worldMeta(origin: string, owner: unknown, repo: unknown, styleParam: unknown) {
+ const valid = validRepoParams(owner, repo);
+ if (!valid) return homeMeta(origin);
+ const style: StyleId = isStyleId(styleParam) ? styleParam : "moonshot";
+ const full = `${valid.owner}/${valid.repo}`, perspective = STYLE_TITLES[style];
+ const title = `${full} as a ${perspective} world · ${SITE_NAME}`;
+ const description = `Explore the public GitHub repository ${full} as a living ${perspective} world: districts, connections and recent activity, every one linked back to source.`;
+ const page = `world/${encodeURIComponent(valid.owner)}/${encodeURIComponent(valid.repo)}${isStyleId(styleParam) ? `?style=${style}` : ""}`;
+ return social(origin, title, description, page, worldImagePath(valid.owner, valid.repo, style), `${full} drawn as a procedural ${perspective} world.`);
+}
diff --git a/src/og/origin.ts b/src/og/origin.ts
new file mode 100644
index 0000000..a872856
--- /dev/null
+++ b/src/og/origin.ts
@@ -0,0 +1,8 @@
+import { createIsomorphicFn } from "@tanstack/react-start";
+import { serverPublicOrigin } from "../server/public-origin";
+
+/** Server: configured MOONSHOT_PUBLIC_ORIGIN (never the Host header).
+ * Client: the page's own origin, used only after hydration. */
+export const publicOrigin = createIsomorphicFn()
+ .server(() => serverPublicOrigin())
+ .client(() => window.location.origin);
diff --git a/src/og/png.ts b/src/og/png.ts
new file mode 100644
index 0000000..a2d4752
--- /dev/null
+++ b/src/og/png.ts
@@ -0,0 +1,68 @@
+import { deflateSync } from "node:zlib";
+
+const CRC_TABLE = (() => {
+ const t = new Uint32Array(256);
+ for (let n = 0; n < 256; n++) {
+ let c = n;
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ t[n] = c >>> 0;
+ }
+ return t;
+})();
+export function crc32(bytes: Uint8Array, crc = 0xffffffff) {
+ for (let i = 0; i < bytes.length; i++) crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
+ return (crc ^ 0xffffffff) >>> 0;
+}
+function chunk(type: string, data: Uint8Array) {
+ const out = new Uint8Array(12 + data.length), view = new DataView(out.buffer);
+ view.setUint32(0, data.length);
+ for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
+ out.set(data, 8);
+ view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
+ return out;
+}
+const paeth = (a: number, b: number, c: number) => {
+ const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
+};
+
+/** Encodes 8-bit RGB pixels as a PNG (colour type 2), choosing a filter per row. */
+export function encodePng(width: number, height: number, rgb: Uint8Array): Uint8Array {
+ if (rgb.length !== width * height * 3) throw new Error("PNG pixel buffer has the wrong size");
+ const stride = width * 3, raw = new Uint8Array((stride + 1) * height), trial = new Uint8Array(stride);
+ for (let y = 0; y < height; y++) {
+ const row = rgb.subarray(y * stride, (y + 1) * stride), prev = y ? rgb.subarray((y - 1) * stride, y * stride) : null;
+ let best = 0, bestScore = Infinity;
+ for (let f = 0; f < 5; f++) {
+ if (!prev && (f === 2 || f === 4)) continue;
+ let score = 0;
+ for (let i = 0; i < stride; i++) {
+ const a = i >= 3 ? row[i - 3] : 0, b = prev ? prev[i] : 0, c = prev && i >= 3 ? prev[i - 3] : 0;
+ const v = (row[i] - (f === 0 ? 0 : f === 1 ? a : f === 2 ? b : f === 3 ? (a + b) >> 1 : paeth(a, b, c))) & 0xff;
+ score += v < 128 ? v : 256 - v;
+ }
+ if (score < bestScore) { bestScore = score; best = f; }
+ }
+ const o = y * (stride + 1);
+ raw[o] = best;
+ for (let i = 0; i < stride; i++) {
+ const a = i >= 3 ? row[i - 3] : 0, b = prev ? prev[i] : 0, c = prev && i >= 3 ? prev[i - 3] : 0;
+ trial[i] = (row[i] - (best === 0 ? 0 : best === 1 ? a : best === 2 ? b : best === 3 ? (a + b) >> 1 : paeth(a, b, c))) & 0xff;
+ }
+ raw.set(trial, o + 1);
+ }
+ const ihdr = new Uint8Array(13), view = new DataView(ihdr.buffer);
+ view.setUint32(0, width);
+ view.setUint32(4, height);
+ ihdr.set([8, 2, 0, 0, 0], 8);
+ const parts = [
+ new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
+ chunk("IHDR", ihdr),
+ chunk("IDAT", new Uint8Array(deflateSync(raw, { level: 9 }))),
+ chunk("IEND", new Uint8Array(0)),
+ ];
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
+ let o = 0;
+ for (const p of parts) { out.set(p, o); o += p.length; }
+ return out;
+}
diff --git a/src/og/raster.ts b/src/og/raster.ts
new file mode 100644
index 0000000..9cc374d
--- /dev/null
+++ b/src/og/raster.ts
@@ -0,0 +1,567 @@
+/** A small, original, dependency-free software subset of CanvasRenderingContext2D.
+ * It exists so the server can draw the shared procedural world art (src/world)
+ * into a PNG for social previews without a native canvas. Unsupported features
+ * are ignored rather than emulated; every fill/stroke checks a CPU deadline. */
+
+export class RenderBudgetError extends Error {
+ constructor() {
+ super("Preview render exceeded its CPU budget");
+ }
+}
+
+type Rgba = readonly [number, number, number, number]; // straight 0..255 rgb, alpha 0..1
+const TRANSPARENT: Rgba = [0, 0, 0, 0];
+const colorCache = new Map();
+const NAMED: Record = {
+ black: [0, 0, 0, 1], white: [255, 255, 255, 1], transparent: TRANSPARENT, red: [255, 0, 0, 1],
+ green: [0, 128, 0, 1], blue: [0, 0, 255, 1], gray: [128, 128, 128, 1], grey: [128, 128, 128, 1],
+};
+
+function hsl(h: number, s: number, l: number): [number, number, number] {
+ h = ((h % 360) + 360) % 360 / 360;
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q;
+ const f = (t: number) => {
+ t = (t + 1) % 1;
+ return t < 1 / 6 ? p + (q - p) * 6 * t : t < 1 / 2 ? q : t < 2 / 3 ? p + (q - p) * (2 / 3 - t) * 6 : p;
+ };
+ return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255];
+}
+
+/** Parses the CSS color forms used by the art; null means "invalid, keep previous". */
+export function parseColor(input: string): Rgba | null {
+ const key = input.trim().toLowerCase();
+ const hit = colorCache.get(key);
+ if (hit !== undefined) return hit;
+ let out: Rgba | null = null;
+ if (key in NAMED) out = NAMED[key];
+ else if (/^#[0-9a-f]+$/.test(key) && [4, 5, 7, 9].includes(key.length)) {
+ const short = key.length < 6;
+ const digits = short ? key.slice(1).split("").map((c) => c + c) : key.slice(1).match(/../g)!;
+ const v = digits.map((d) => parseInt(d, 16));
+ out = [v[0], v[1], v[2], v.length > 3 ? v[3] / 255 : 1];
+ } else {
+ const m = key.match(/^(rgba?|hsla?)\(([^)]*)\)$/);
+ if (m) {
+ const parts = m[2].split(/[\s,/]+/).filter(Boolean);
+ const num = (s: string, scale: number) => (s.endsWith("%") ? (parseFloat(s) / 100) * scale : parseFloat(s));
+ if (parts.length >= 3) {
+ const a = parts.length > 3 ? Math.max(0, Math.min(1, num(parts[3], 1))) : 1;
+ if (m[1].startsWith("rgb")) out = [num(parts[0], 255), num(parts[1], 255), num(parts[2], 255), a];
+ else {
+ const [r, g, b] = hsl(parseFloat(parts[0]), num(parts[1], 1), num(parts[2], 1));
+ out = [r, g, b, a];
+ }
+ if (out.some((v) => !Number.isFinite(v))) out = null;
+ }
+ }
+ }
+ if (colorCache.size > 4096) colorCache.clear();
+ colorCache.set(key, out);
+ return out;
+}
+
+type Stop = { offset: number; color: Rgba };
+/** Premultiplied 256-entry lookup, matching canvas premultiplied interpolation. */
+function gradientLut(stops: Stop[]) {
+ const lut = new Float32Array(256 * 4);
+ const sorted = stops.toSorted((a, b) => a.offset - b.offset);
+ for (let i = 0; i < 256; i++) {
+ const t = i / 255;
+ let k = 0;
+ while (k < sorted.length - 1 && sorted[k + 1].offset < t) k++;
+ const a = sorted[k], b = sorted[Math.min(sorted.length - 1, k + 1)];
+ const span = b.offset - a.offset;
+ const u = t <= a.offset ? 0 : t >= b.offset ? 1 : span > 0 ? (t - a.offset) / span : 1;
+ const pa = a.color[3], pb = b.color[3];
+ lut[i * 4] = a.color[0] * pa * (1 - u) + b.color[0] * pb * u;
+ lut[i * 4 + 1] = a.color[1] * pa * (1 - u) + b.color[1] * pb * u;
+ lut[i * 4 + 2] = a.color[2] * pa * (1 - u) + b.color[2] * pb * u;
+ lut[i * 4 + 3] = pa * (1 - u) + pb * u;
+ }
+ return lut;
+}
+
+export class SoftGradient {
+ stops: Stop[] = [];
+ constructor(
+ readonly type: "linear" | "radial",
+ readonly coords: number[],
+ ) {}
+ addColorStop(offset: number, color: string) {
+ const c = parseColor(color);
+ if (!c || !Number.isFinite(offset)) return;
+ this.stops.push({ offset: Math.max(0, Math.min(1, offset)), color: c });
+ }
+}
+
+type Matrix = [number, number, number, number, number, number];
+type Point = [number, number];
+interface SubPath { points: Point[]; closed: boolean }
+interface State {
+ m: Matrix; fill: Rgba | SoftGradient; stroke: Rgba | SoftGradient; alpha: number; lineWidth: number;
+ dash: number[]; dashOffset: number; cap: string; join: string; font: string; align: string; baseline: string;
+}
+
+/** Glyph renderer injected by the font module so text uses the same rasterizer. */
+export interface TextEngine {
+ measure(text: string, font: string): number;
+ draw(g: SoftCanvas, text: string, x: number, y: number, font: string, align: string, baseline: string, stroke: boolean): void;
+}
+
+const signedArea = (p: Point[]) => {
+ let s = 0;
+ for (let i = 0, j = p.length - 1; i < p.length; j = i++) s += (p[j][0] - p[i][0]) * (p[j][1] + p[i][1]);
+ return s;
+};
+const oriented = (p: Point[]) => (signedArea(p) < 0 ? p.toReversed() : p);
+function disc(cx: number, cy: number, r: number): Point[] {
+ const n = Math.max(8, Math.min(48, Math.ceil(r * 2.4)));
+ return oriented(Array.from({ length: n }, (_, i) => [cx + Math.cos((i / n) * Math.PI * 2) * r, cy + Math.sin((i / n) * Math.PI * 2) * r] as Point));
+}
+
+export class SoftCanvas {
+ readonly data: Float32Array; // straight-alpha RGBA, rgb 0..255, a 0..1
+ private state: State;
+ private stack: State[] = [];
+ private path: SubPath[] = [];
+ private cover: Float32Array;
+ private ops = 0;
+ text: TextEngine | null = null;
+ imageSmoothingEnabled = true;
+ globalCompositeOperation = "source-over";
+ shadowBlur = 0;
+ shadowColor = "transparent";
+ constructor(
+ readonly width: number,
+ readonly height: number,
+ private readonly deadline = Number.POSITIVE_INFINITY,
+ private readonly clock: () => number = () => performance.now(),
+ ) {
+ this.data = new Float32Array(width * height * 4);
+ this.cover = new Float32Array(width + 2);
+ this.state = {
+ m: [1, 0, 0, 1, 0, 0], fill: [0, 0, 0, 1], stroke: [0, 0, 0, 1], alpha: 1, lineWidth: 1,
+ dash: [], dashOffset: 0, cap: "butt", join: "miter", font: "10px sans-serif", align: "start", baseline: "alphabetic",
+ };
+ }
+ /** Coarse CPU guard; the clock is read every few operations. */
+ private tick() {
+ if (++this.ops % 64 === 0 && this.clock() > this.deadline) throw new RenderBudgetError();
+ }
+
+ // --- state -------------------------------------------------------------
+ save() { this.stack.push({ ...this.state, m: [...this.state.m] as Matrix, dash: [...this.state.dash] }); }
+ restore() { const s = this.stack.pop(); if (s) this.state = s; }
+ get fillStyle(): string | SoftGradient { return this.state.fill instanceof SoftGradient ? this.state.fill : `rgba(${this.state.fill.join(",")})`; }
+ set fillStyle(v: string | SoftGradient) { const c = typeof v === "string" ? parseColor(v) : v instanceof SoftGradient ? v : null; if (c) this.state.fill = c; }
+ get strokeStyle(): string | SoftGradient { return this.state.stroke instanceof SoftGradient ? this.state.stroke : `rgba(${this.state.stroke.join(",")})`; }
+ set strokeStyle(v: string | SoftGradient) { const c = typeof v === "string" ? parseColor(v) : v instanceof SoftGradient ? v : null; if (c) this.state.stroke = c; }
+ get globalAlpha() { return this.state.alpha; }
+ set globalAlpha(v: number) { if (Number.isFinite(v) && v >= 0 && v <= 1) this.state.alpha = v; }
+ get lineWidth() { return this.state.lineWidth; }
+ set lineWidth(v: number) { if (Number.isFinite(v) && v > 0) this.state.lineWidth = v; }
+ get lineCap() { return this.state.cap; }
+ set lineCap(v: string) { this.state.cap = v; }
+ get lineJoin() { return this.state.join; }
+ set lineJoin(v: string) { this.state.join = v; }
+ get lineDashOffset() { return this.state.dashOffset; }
+ set lineDashOffset(v: number) { if (Number.isFinite(v)) this.state.dashOffset = v; }
+ get font() { return this.state.font; }
+ set font(v: string) { this.state.font = v; }
+ get textAlign() { return this.state.align; }
+ set textAlign(v: string) { this.state.align = v; }
+ get textBaseline() { return this.state.baseline; }
+ set textBaseline(v: string) { this.state.baseline = v; }
+ setLineDash(d: number[]) { if (d.every((v) => Number.isFinite(v) && v >= 0)) this.state.dash = d.length % 2 ? [...d, ...d] : [...d]; }
+ getLineDash() { return [...this.state.dash]; }
+
+ // --- transforms --------------------------------------------------------
+ setTransform(a: number, b = 0, c = 0, d = 1, e = 0, f = 0) { this.state.m = [a, b, c, d, e, f]; }
+ resetTransform() { this.state.m = [1, 0, 0, 1, 0, 0]; }
+ transform(a: number, b: number, c: number, d: number, e: number, f: number) {
+ const [A, B, C, D, E, F] = this.state.m;
+ this.state.m = [A * a + C * b, B * a + D * b, A * c + C * d, B * c + D * d, A * e + C * f + E, B * e + D * f + F];
+ }
+ translate(x: number, y: number) { this.transform(1, 0, 0, 1, x, y); }
+ scale(x: number, y: number) { this.transform(x, 0, 0, y, 0, 0); }
+ rotate(a: number) { const c = Math.cos(a), s = Math.sin(a); this.transform(c, s, -s, c, 0, 0); }
+ getTransform() {
+ const [a, b, c, d, e, f] = this.state.m;
+ return { a, b, c, d, e, f, transformPoint: (p: { x: number; y: number }) => ({ x: a * p.x + c * p.y + e, y: b * p.x + d * p.y + f }) };
+ }
+ private tp(x: number, y: number): Point {
+ const [a, b, c, d, e, f] = this.state.m;
+ return [a * x + c * y + e, b * x + d * y + f];
+ }
+ private get scaleFactor() { const [a, b, c, d] = this.state.m; return Math.sqrt(Math.abs(a * d - b * c)) || 1; }
+
+ // --- paths ---------------------------------------------------------------
+ beginPath() { this.path = []; }
+ private current(): SubPath | undefined { return this.path[this.path.length - 1]; }
+ moveTo(x: number, y: number) { if (Number.isFinite(x) && Number.isFinite(y)) this.path.push({ points: [this.tp(x, y)], closed: false }); }
+ private lineToDevice(p: Point) {
+ const sp = this.current();
+ if (!sp || sp.closed) this.path.push({ points: [p], closed: false });
+ else sp.points.push(p);
+ }
+ lineTo(x: number, y: number) { if (Number.isFinite(x) && Number.isFinite(y)) this.lineToDevice(this.tp(x, y)); }
+ closePath() {
+ const sp = this.current();
+ if (!sp) return;
+ sp.closed = true;
+ this.path.push({ points: [sp.points[0]], closed: false });
+ }
+ private lastUser(): Point | null {
+ const sp = this.current();
+ if (!sp) return null;
+ const [a, b, c, d, e, f] = this.state.m, det = a * d - b * c;
+ if (!det) return null;
+ const [X, Y] = sp.points[sp.points.length - 1];
+ return [(d * (X - e) - c * (Y - f)) / det, (-b * (X - e) + a * (Y - f)) / det];
+ }
+ private segments(len: number) { return Math.max(2, Math.min(64, Math.ceil((len * this.scaleFactor) / 3))); }
+ bezierCurveTo(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number) {
+ const p0 = this.lastUser() ?? [c1x, c1y];
+ if (!this.current()) this.moveTo(p0[0], p0[1]);
+ const n = this.segments(Math.hypot(c1x - p0[0], c1y - p0[1]) + Math.hypot(c2x - c1x, c2y - c1y) + Math.hypot(x - c2x, y - c2y));
+ for (let i = 1; i <= n; i++) {
+ const t = i / n, u = 1 - t;
+ this.lineTo(u * u * u * p0[0] + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * x, u * u * u * p0[1] + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * y);
+ }
+ }
+ quadraticCurveTo(cx: number, cy: number, x: number, y: number) {
+ const p0 = this.lastUser() ?? [cx, cy];
+ if (!this.current()) this.moveTo(p0[0], p0[1]);
+ const n = this.segments(Math.hypot(cx - p0[0], cy - p0[1]) + Math.hypot(x - cx, y - cy));
+ for (let i = 1; i <= n; i++) {
+ const t = i / n, u = 1 - t;
+ this.lineTo(u * u * p0[0] + 2 * u * t * cx + t * t * x, u * u * p0[1] + 2 * u * t * cy + t * t * y);
+ }
+ }
+ ellipse(x: number, y: number, rx: number, ry: number, rotation: number, start: number, end: number, ccw = false) {
+ if (![x, y, rx, ry, rotation, start, end].every(Number.isFinite) || rx < 0 || ry < 0) return;
+ let sweep = end - start;
+ const full = Math.PI * 2;
+ if (!ccw && sweep >= full) sweep = full;
+ else if (ccw && -sweep >= full) sweep = -full;
+ else if (!ccw) sweep = ((sweep % full) + full) % full;
+ else sweep = -(((-sweep % full) + full) % full);
+ const n = Math.max(4, Math.min(160, Math.ceil((Math.abs(sweep) / full) * Math.max(12, Math.max(rx, ry) * this.scaleFactor * 0.9))));
+ const cr = Math.cos(rotation), sr = Math.sin(rotation);
+ for (let i = 0; i <= n; i++) {
+ const a = start + (sweep * i) / n, ex = Math.cos(a) * rx, ey = Math.sin(a) * ry;
+ const p = this.tp(x + ex * cr - ey * sr, y + ex * sr + ey * cr);
+ this.lineToDevice(p);
+ }
+ }
+ arc(x: number, y: number, r: number, start: number, end: number, ccw = false) { this.ellipse(x, y, r, r, 0, start, end, ccw); }
+ rect(x: number, y: number, w: number, h: number) { this.moveTo(x, y); this.lineTo(x + w, y); this.lineTo(x + w, y + h); this.lineTo(x, y + h); this.closePath(); }
+ roundRect(x: number, y: number, w: number, h: number, radii: number | number[] = 0) {
+ const r0 = Array.isArray(radii) ? radii[0] ?? 0 : radii;
+ const r = Math.max(0, Math.min(Math.abs(w) / 2, Math.abs(h) / 2, typeof r0 === "number" ? r0 : 0));
+ if (!r) return this.rect(x, y, w, h);
+ this.moveTo(x + r, y);
+ this.lineTo(x + w - r, y);
+ this.ellipse(x + w - r, y + r, r, r, 0, -Math.PI / 2, 0);
+ this.lineTo(x + w, y + h - r);
+ this.ellipse(x + w - r, y + h - r, r, r, 0, 0, Math.PI / 2);
+ this.lineTo(x + r, y + h);
+ this.ellipse(x + r, y + h - r, r, r, 0, Math.PI / 2, Math.PI);
+ this.lineTo(x, y + r);
+ this.ellipse(x + r, y + r, r, r, 0, Math.PI, Math.PI * 1.5);
+ this.closePath();
+ }
+ clip() {}
+
+ // --- painting ------------------------------------------------------------
+ fill(rule: "nonzero" | "evenodd" = "nonzero") {
+ const polys = this.path.filter((s) => s.points.length > 2).map((s) => s.points);
+ this.raster(polys, this.state.fill, rule === "evenodd");
+ }
+ stroke() {
+ const polys: Point[][] = [];
+ const s = this.scaleFactor;
+ const width = this.state.lineWidth * s;
+ // Hairlines keep a minimum footprint and fade instead of disappearing.
+ const w = Math.max(0.6, width), fade = Math.min(1, width / 0.6);
+ for (const sp of this.path) {
+ const pts = sp.closed ? [...sp.points, sp.points[0]] : sp.points;
+ const pieces = this.state.dash.length && this.state.dash.some((d) => d > 0) ? dashed(pts, this.state.dash.map((d) => d * s), this.state.dashOffset * s) : [pts];
+ for (const piece of pieces) strokePolyline(piece, w / 2, this.state.cap, this.state.join, sp.closed && pieces.length === 1, polys);
+ }
+ this.raster(polys, this.state.stroke, false, fade);
+ }
+ fillRect(x: number, y: number, w: number, h: number) {
+ if (![x, y, w, h].every(Number.isFinite)) return;
+ const [a, b, c, d, e, f] = this.state.m;
+ if (b === 0 && c === 0) {
+ let x0 = a * x + e, x1 = a * (x + w) + e, y0 = d * y + f, y1 = d * (y + h) + f;
+ if (x0 > x1) [x0, x1] = [x1, x0];
+ if (y0 > y1) [y0, y1] = [y1, y0];
+ return this.rectFast(x0, y0, x1, y1, this.state.fill);
+ }
+ const saved = this.path;
+ this.path = [];
+ this.rect(x, y, w, h);
+ this.fill();
+ this.path = saved;
+ }
+ strokeRect(x: number, y: number, w: number, h: number) {
+ const saved = this.path;
+ this.path = [];
+ this.rect(x, y, w, h);
+ this.stroke();
+ this.path = saved;
+ }
+ clearRect(x: number, y: number, w: number, h: number) {
+ const [a, , , d, e, f] = this.state.m;
+ const x0 = Math.max(0, Math.floor(a * x + e)), x1 = Math.min(this.width, Math.ceil(a * (x + w) + e));
+ const y0 = Math.max(0, Math.floor(d * y + f)), y1 = Math.min(this.height, Math.ceil(d * (y + h) + f));
+ for (let yy = y0; yy < y1; yy++) this.data.fill(0, (yy * this.width + x0) * 4, (yy * this.width + x1) * 4);
+ }
+ createLinearGradient(x0: number, y0: number, x1: number, y1: number) { return new SoftGradient("linear", [x0, y0, x1, y1]); }
+ createRadialGradient(x0: number, y0: number, r0: number, x1: number, y1: number, r1: number) { return new SoftGradient("radial", [x0, y0, r0, x1, y1, r1]); }
+ measureText(text: string) { return { width: this.text ? this.text.measure(String(text), this.state.font) : String(text).length * 6 }; }
+ fillText(text: string, x: number, y: number) { const p = this.text; if (p) { this.save(); p.draw(this, String(text), x, y, this.state.font, this.state.align, this.state.baseline, false); this.restore(); } }
+ strokeText(text: string, x: number, y: number) { const p = this.text; if (p) { this.save(); p.draw(this, String(text), x, y, this.state.font, this.state.align, this.state.baseline, true); this.restore(); } }
+ drawImage() {}
+
+ /** Copies another canvas with nearest-neighbour scaling (retro low-resolution look). */
+ blitNearest(src: SoftCanvas) {
+ for (let y = 0; y < this.height; y++) {
+ const sy = Math.min(src.height - 1, Math.floor((y * src.height) / this.height));
+ for (let x = 0; x < this.width; x++) {
+ const sx = Math.min(src.width - 1, Math.floor((x * src.width) / this.width));
+ const si = (sy * src.width + sx) * 4, di = (y * this.width + x) * 4;
+ this.data[di] = src.data[si]; this.data[di + 1] = src.data[si + 1]; this.data[di + 2] = src.data[si + 2]; this.data[di + 3] = src.data[si + 3];
+ }
+ }
+ }
+
+ // --- rasterization -------------------------------------------------------
+ private paintAt(paint: Rgba | SoftGradient, cache: { lut?: Float32Array; m?: Matrix }) {
+ if (!(paint instanceof SoftGradient)) return null;
+ if (!cache.lut) { cache.lut = gradientLut(paint.stops.length ? paint.stops : [{ offset: 0, color: TRANSPARENT }]); cache.m = [...this.state.m] as Matrix; }
+ const lut = cache.lut, m = cache.m!;
+ const map = (x: number, y: number): Point => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
+ const s = Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2])) || 1;
+ if (paint.type === "linear") {
+ const [ax, ay] = map(paint.coords[0], paint.coords[1]), [bx, by] = map(paint.coords[2], paint.coords[3]);
+ const dx = bx - ax, dy = by - ay, len2 = dx * dx + dy * dy;
+ return (px: number, py: number, out: Float32Array) => {
+ const t = len2 ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0;
+ const i = Math.max(0, Math.min(255, Math.round(t * 255))) * 4;
+ out[0] = lut[i]; out[1] = lut[i + 1]; out[2] = lut[i + 2]; out[3] = lut[i + 3];
+ };
+ }
+ const [x0, y0] = map(paint.coords[0], paint.coords[1]), r0 = paint.coords[2] * s;
+ const [x1, y1] = map(paint.coords[3], paint.coords[4]), r1 = paint.coords[5] * s;
+ const cdx = x1 - x0, cdy = y1 - y0, dr = r1 - r0, a = cdx * cdx + cdy * cdy - dr * dr;
+ return (px: number, py: number, out: Float32Array) => {
+ const pdx = px - x0, pdy = py - y0;
+ const b = pdx * cdx + pdy * cdy + r0 * dr, c = pdx * pdx + pdy * pdy - r0 * r0;
+ let t: number;
+ if (Math.abs(a) < 1e-9) t = b ? c / (2 * b) : 0;
+ else {
+ const disc = b * b - a * c;
+ if (disc < 0) { out[0] = out[1] = out[2] = out[3] = 0; return; }
+ const sq = Math.sqrt(disc), t1 = (b + sq) / a, t2 = (b - sq) / a;
+ t = r0 + Math.max(t1, t2) * dr >= 0 ? Math.max(t1, t2) : Math.min(t1, t2);
+ if (r0 + t * dr < 0) { out[0] = out[1] = out[2] = out[3] = 0; return; }
+ }
+ const i = Math.max(0, Math.min(255, Math.round(t * 255))) * 4;
+ out[0] = lut[i]; out[1] = lut[i + 1]; out[2] = lut[i + 2]; out[3] = lut[i + 3];
+ };
+ }
+ private blend(i: number, r: number, g: number, b: number, a: number) {
+ // r,g,b premultiplied by a; destination straight alpha.
+ if (a <= 0) return;
+ const d = this.data, da = d[i + 3], keep = 1 - a, outA = a + da * keep;
+ if (outA <= 0) return;
+ d[i] = (r + d[i] * da * keep) / outA;
+ d[i + 1] = (g + d[i + 1] * da * keep) / outA;
+ d[i + 2] = (b + d[i + 2] * da * keep) / outA;
+ d[i + 3] = outA;
+ }
+ private rectFast(x0: number, y0: number, x1: number, y1: number, paint: Rgba | SoftGradient) {
+ this.tick();
+ x0 = Math.max(0, x0); y0 = Math.max(0, y0); x1 = Math.min(this.width, x1); y1 = Math.min(this.height, y1);
+ if (x1 <= x0 || y1 <= y0) return;
+ const sample = this.paintAt(paint, {}), px = new Float32Array(4), alpha = this.state.alpha;
+ const solid = paint instanceof SoftGradient ? null : paint;
+ for (let y = Math.floor(y0); y < Math.ceil(y1); y++) {
+ const cy = Math.min(y + 1, y1) - Math.max(y, y0);
+ for (let x = Math.floor(x0); x < Math.ceil(x1); x++) {
+ const cov = cy * (Math.min(x + 1, x1) - Math.max(x, x0)) * alpha;
+ if (cov <= 0) continue;
+ if (solid) { const a = solid[3] * cov; this.blend((y * this.width + x) * 4, solid[0] * a, solid[1] * a, solid[2] * a, a); }
+ else { sample!(x + 0.5, y + 0.5, px); this.blend((y * this.width + x) * 4, px[0] * cov, px[1] * cov, px[2] * cov, px[3] * cov); }
+ }
+ }
+ }
+ /** Scanline coverage rasterizer: 4 vertical sub-samples, exact horizontal span ends. */
+ private raster(polys: Point[][], paint: Rgba | SoftGradient, evenOdd: boolean, fade = 1) {
+ this.tick();
+ const edges: number[] = []; // x0,y0,x1,y1,dir
+ let minY = Infinity, maxY = -Infinity, minX = Infinity, maxX = -Infinity;
+ for (const poly of polys) {
+ for (let i = 0; i < poly.length; i++) {
+ const [ax, ay] = poly[i], [bx, by] = poly[(i + 1) % poly.length];
+ if (!Number.isFinite(ax + ay + bx + by)) return;
+ minX = Math.min(minX, ax); maxX = Math.max(maxX, ax);
+ if (ay === by) continue;
+ if (ay < by) edges.push(ax, ay, bx, by, 1); else edges.push(bx, by, ax, ay, -1);
+ minY = Math.min(minY, ay, by); maxY = Math.max(maxY, ay, by);
+ }
+ }
+ if (!edges.length) return;
+ const y0 = Math.max(0, Math.floor(minY)), y1 = Math.min(this.height, Math.ceil(maxY));
+ const xLo = Math.max(0, Math.floor(minX)), xHi = Math.min(this.width, Math.ceil(maxX));
+ if (y0 >= y1 || xLo >= xHi) return;
+ const count = edges.length / 5;
+ const order = Array.from({ length: count }, (_, i) => i).sort((p, q) => edges[p * 5 + 1] - edges[q * 5 + 1]);
+ const sample = this.paintAt(paint, {}), px = new Float32Array(4);
+ const solid = paint instanceof SoftGradient ? null : paint;
+ const alpha = this.state.alpha * fade, cover = this.cover, SS = 4;
+ let next = 0;
+ let active: number[] = [];
+ const xs: number[] = [], ws: number[] = [];
+ for (let y = y0; y < y1; y++) {
+ if ((y & 15) === 0) this.tick();
+ let spanLo = Infinity, spanHi = -Infinity;
+ for (let s = 0; s < SS; s++) {
+ const sy = y + (s + 0.5) / SS;
+ while (next < count && edges[order[next] * 5 + 1] <= sy) active.push(order[next++]);
+ active = active.filter((e) => edges[e * 5 + 3] > sy);
+ xs.length = 0; ws.length = 0;
+ for (const e of active) {
+ const o = e * 5, ey0 = edges[o + 1];
+ if (ey0 > sy) continue;
+ const t = (sy - ey0) / (edges[o + 3] - ey0);
+ xs.push(edges[o] + (edges[o + 2] - edges[o]) * t);
+ ws.push(edges[o + 4]);
+ }
+ if (xs.length < 2) continue;
+ const idx = xs.map((_, i) => i).sort((p, q) => xs[p] - xs[q]);
+ let wind = 0;
+ for (let k = 0; k < idx.length - 1; k++) {
+ wind += evenOdd ? 1 : ws[idx[k]];
+ const inside = evenOdd ? wind % 2 !== 0 : wind !== 0;
+ if (!inside) continue;
+ const a = Math.max(xLo, xs[idx[k]]), b = Math.min(xHi, xs[idx[k + 1]]);
+ if (b <= a) continue;
+ const ia = Math.floor(a), ib = Math.floor(b);
+ spanLo = Math.min(spanLo, ia); spanHi = Math.max(spanHi, ib);
+ if (ia === ib) cover[ia] += (b - a) / SS;
+ else {
+ cover[ia] += (ia + 1 - a) / SS;
+ for (let x = ia + 1; x < ib; x++) cover[x] += 1 / SS;
+ if (ib < this.width) cover[ib] += (b - ib) / SS;
+ }
+ }
+ }
+ if (spanLo > spanHi) continue;
+ const hi = Math.min(this.width - 1, spanHi);
+ for (let x = spanLo; x <= hi; x++) {
+ const c = Math.min(1, cover[x]) * alpha;
+ cover[x] = 0;
+ if (c <= 0.002) continue;
+ const i = (y * this.width + x) * 4;
+ if (solid) { const a = solid[3] * c; this.blend(i, solid[0] * a, solid[1] * a, solid[2] * a, a); }
+ else { sample!(x + 0.5, y + 0.5, px); this.blend(i, px[0] * c, px[1] * c, px[2] * c, px[3] * c); }
+ }
+ if (spanHi + 1 < cover.length) cover[spanHi + 1] = 0;
+ }
+ }
+
+ /** Opaque 8-bit RGB over a background colour. */
+ toRgb(background: Rgba = [0, 0, 0, 1]): Uint8Array {
+ const out = new Uint8Array(this.width * this.height * 3);
+ for (let i = 0, j = 0; i < this.data.length; i += 4, j += 3) {
+ const a = this.data[i + 3];
+ out[j] = Math.round(this.data[i] * a + background[0] * (1 - a));
+ out[j + 1] = Math.round(this.data[i + 1] * a + background[1] * (1 - a));
+ out[j + 2] = Math.round(this.data[i + 2] * a + background[2] * (1 - a));
+ }
+ return out;
+ }
+}
+
+function dashed(pts: Point[], dash: number[], offset: number): Point[][] {
+ const total = dash.reduce((a, b) => a + b, 0);
+ if (total <= 0.5) return [pts];
+ const out: Point[][] = [];
+ let k = 0, left = dash[0], on = true;
+ let o = ((offset % total) + total) % total;
+ while (o > 0) { if (o >= left) { o -= left; k = (k + 1) % dash.length; left = dash[k]; on = !on; } else { left -= o; o = 0; } }
+ let cur: Point[] | null = on ? [pts[0]] : null;
+ let pieces = 0;
+ for (let i = 1; i < pts.length; i++) {
+ let [ax, ay] = pts[i - 1];
+ const [bx, by] = pts[i];
+ let seg = Math.hypot(bx - ax, by - ay);
+ while (seg > 0) {
+ if (++pieces > 20000) return out;
+ const step = Math.min(left, seg), t = step / seg;
+ const nx = ax + (bx - ax) * t, ny = ay + (by - ay) * t;
+ if (cur) cur.push([nx, ny]);
+ ax = nx; ay = ny; seg -= step; left -= step;
+ if (left <= 1e-9) {
+ if (cur && cur.length > 1) out.push(cur);
+ k = (k + 1) % dash.length; left = dash[k]; on = !on;
+ cur = on ? [[ax, ay]] : null;
+ }
+ }
+ }
+ if (cur && cur.length > 1) out.push(cur);
+ return out;
+}
+
+/** Converts one polyline into consistently oriented polygons whose nonzero union is the stroke. */
+function strokePolyline(raw: Point[], hw: number, cap: string, join: string, closed: boolean, out: Point[][]) {
+ const pts: Point[] = [];
+ for (const p of raw) { const q = pts[pts.length - 1]; if (!q || Math.hypot(p[0] - q[0], p[1] - q[1]) > 1e-3) pts.push(p); }
+ if (pts.length < 2) {
+ if (pts.length === 1 && cap === "round") out.push(disc(pts[0][0], pts[0][1], hw));
+ return;
+ }
+ const n = pts.length;
+ for (let i = 0; i < n - 1; i++) {
+ let [ax, ay] = pts[i], [bx, by] = pts[i + 1];
+ const len = Math.hypot(bx - ax, by - ay), ux = (bx - ax) / len, uy = (by - ay) / len;
+ if (cap === "square" && !closed) {
+ if (i === 0) { ax -= ux * hw; ay -= uy * hw; }
+ if (i === n - 2) { bx += ux * hw; by += uy * hw; }
+ }
+ const nx = -uy * hw, ny = ux * hw;
+ out.push(oriented([[ax + nx, ay + ny], [bx + nx, by + ny], [bx - nx, by - ny], [ax - nx, ay - ny]]));
+ }
+ const joints = closed ? n : n - 1;
+ for (let i = 1; i < joints; i++) {
+ const p = pts[i % n], a = pts[i - 1], b = pts[(i + 1) % n];
+ if (!a || !b) continue;
+ const l1 = Math.hypot(p[0] - a[0], p[1] - a[1]), l2 = Math.hypot(b[0] - p[0], b[1] - p[1]);
+ if (!l1 || !l2) continue;
+ const u1: Point = [(p[0] - a[0]) / l1, (p[1] - a[1]) / l1], u2: Point = [(b[0] - p[0]) / l2, (b[1] - p[1]) / l2];
+ const cross = u1[0] * u2[1] - u1[1] * u2[0];
+ if (Math.abs(cross) < 1e-4 && u1[0] * u2[0] + u1[1] * u2[1] > 0) continue;
+ if (join === "round" && hw > 0.9) { out.push(disc(p[0], p[1], hw)); continue; }
+ // Bevel on the outer side; miters extend it when the angle is gentle.
+ const side = cross > 0 ? -1 : 1;
+ const o1: Point = [p[0] - u1[1] * hw * side, p[1] + u1[0] * hw * side], o2: Point = [p[0] - u2[1] * hw * side, p[1] + u2[0] * hw * side];
+ const tri: Point[] = [p, o1, o2];
+ if (join === "miter") {
+ const mx = o1[0] + o2[0] - 2 * p[0], my = o1[1] + o2[1] - 2 * p[1], ml = Math.hypot(mx, my);
+ const cosHalf = Math.sqrt(Math.max(0, (1 + u1[0] * u2[0] + u1[1] * u2[1]) / 2));
+ if (ml > 1e-6 && cosHalf > 0.1) {
+ const d = hw / cosHalf;
+ tri.splice(2, 0, [p[0] + (mx / ml) * d, p[1] + (my / ml) * d]);
+ }
+ }
+ out.push(oriented(tri));
+ }
+ if (cap === "round" && !closed) {
+ out.push(disc(pts[0][0], pts[0][1], hw));
+ out.push(disc(pts[n - 1][0], pts[n - 1][1], hw));
+ }
+}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 3bb10b0..a1c84fd 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -7,15 +7,18 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AtlasRouteImport } from './routes/atlas'
+import { Route as TourRouteImport } from './routes/tour'
import { Route as ApiBudgetRouteImport } from './routes/api.budget'
import { Route as ApiGalleryRouteImport } from './routes/api.gallery'
import { Route as ApiHealthRouteImport } from './routes/api.health'
import { Route as ApiWorldsRouteImport } from './routes/api.worlds'
+import { Route as OgHomeDotpngRouteImport } from './routes/og.home[.]png'
import { Route as WorldOwnerRepoRouteImport } from './routes/world.$owner.$repo'
import { Route as ApiBlueprintOwnerRepoRouteImport } from './routes/api.blueprint.$owner.$repo'
import { Route as ApiGalleryPreviewOwnerRepoRouteImport } from './routes/api.gallery-preview.$owner.$repo'
import { Route as ApiHistoryOwnerRepoRouteImport } from './routes/api.history.$owner.$repo'
import { Route as ApiWorldOwnerRepoRouteImport } from './routes/api.world.$owner.$repo'
+import { Route as OgWorldOwnerRepoRouteImport } from './routes/og.world.$owner.$repo'
const IndexRoute = IndexRouteImport.update({
id: '/',
@@ -27,6 +30,11 @@ const AtlasRoute = AtlasRouteImport.update({
path: '/atlas',
getParentRoute: () => rootRouteImport,
} as any)
+const TourRoute = TourRouteImport.update({
+ id: '/tour',
+ path: '/tour',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApiBudgetRoute = ApiBudgetRouteImport.update({
id: '/api/budget',
path: '/api/budget',
@@ -47,6 +55,11 @@ const ApiWorldsRoute = ApiWorldsRouteImport.update({
path: '/api/worlds',
getParentRoute: () => rootRouteImport,
} as any)
+const OgHomeDotpngRoute = OgHomeDotpngRouteImport.update({
+ id: '/og/home.png',
+ path: '/og/home.png',
+ getParentRoute: () => rootRouteImport,
+} as any)
const WorldOwnerRepoRoute = WorldOwnerRepoRouteImport.update({
id: '/world/$owner/$repo',
path: '/world/$owner/$repo',
@@ -73,101 +86,127 @@ const ApiWorldOwnerRepoRoute = ApiWorldOwnerRepoRouteImport.update({
path: '/api/world/$owner/$repo',
getParentRoute: () => rootRouteImport,
} as any)
+const OgWorldOwnerRepoRoute = OgWorldOwnerRepoRouteImport.update({
+ id: '/og/world/$owner/$repo',
+ path: '/og/world/$owner/$repo',
+ getParentRoute: () => rootRouteImport,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/atlas': typeof AtlasRoute
+ '/tour': typeof TourRoute
'/api/budget': typeof ApiBudgetRoute
'/api/gallery': typeof ApiGalleryRoute
'/api/health': typeof ApiHealthRoute
'/api/worlds': typeof ApiWorldsRoute
+ '/og/home.png': typeof OgHomeDotpngRoute
'/world/$owner/$repo': typeof WorldOwnerRepoRoute
'/api/blueprint/$owner/$repo': typeof ApiBlueprintOwnerRepoRoute
'/api/gallery-preview/$owner/$repo': typeof ApiGalleryPreviewOwnerRepoRoute
'/api/history/$owner/$repo': typeof ApiHistoryOwnerRepoRoute
'/api/world/$owner/$repo': typeof ApiWorldOwnerRepoRoute
+ '/og/world/$owner/$repo': typeof OgWorldOwnerRepoRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/atlas': typeof AtlasRoute
+ '/tour': typeof TourRoute
'/api/budget': typeof ApiBudgetRoute
'/api/gallery': typeof ApiGalleryRoute
'/api/health': typeof ApiHealthRoute
'/api/worlds': typeof ApiWorldsRoute
+ '/og/home.png': typeof OgHomeDotpngRoute
'/world/$owner/$repo': typeof WorldOwnerRepoRoute
'/api/blueprint/$owner/$repo': typeof ApiBlueprintOwnerRepoRoute
'/api/gallery-preview/$owner/$repo': typeof ApiGalleryPreviewOwnerRepoRoute
'/api/history/$owner/$repo': typeof ApiHistoryOwnerRepoRoute
'/api/world/$owner/$repo': typeof ApiWorldOwnerRepoRoute
+ '/og/world/$owner/$repo': typeof OgWorldOwnerRepoRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/atlas': typeof AtlasRoute
+ '/tour': typeof TourRoute
'/api/budget': typeof ApiBudgetRoute
'/api/gallery': typeof ApiGalleryRoute
'/api/health': typeof ApiHealthRoute
'/api/worlds': typeof ApiWorldsRoute
+ '/og/home.png': typeof OgHomeDotpngRoute
'/world/$owner/$repo': typeof WorldOwnerRepoRoute
'/api/blueprint/$owner/$repo': typeof ApiBlueprintOwnerRepoRoute
'/api/gallery-preview/$owner/$repo': typeof ApiGalleryPreviewOwnerRepoRoute
'/api/history/$owner/$repo': typeof ApiHistoryOwnerRepoRoute
'/api/world/$owner/$repo': typeof ApiWorldOwnerRepoRoute
+ '/og/world/$owner/$repo': typeof OgWorldOwnerRepoRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/atlas'
+ | '/tour'
| '/api/budget'
| '/api/gallery'
| '/api/health'
| '/api/worlds'
+ | '/og/home.png'
| '/world/$owner/$repo'
| '/api/blueprint/$owner/$repo'
| '/api/gallery-preview/$owner/$repo'
| '/api/history/$owner/$repo'
| '/api/world/$owner/$repo'
+ | '/og/world/$owner/$repo'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/atlas'
+ | '/tour'
| '/api/budget'
| '/api/gallery'
| '/api/health'
| '/api/worlds'
+ | '/og/home.png'
| '/world/$owner/$repo'
| '/api/blueprint/$owner/$repo'
| '/api/gallery-preview/$owner/$repo'
| '/api/history/$owner/$repo'
| '/api/world/$owner/$repo'
+ | '/og/world/$owner/$repo'
id:
| '__root__'
| '/'
| '/atlas'
+ | '/tour'
| '/api/budget'
| '/api/gallery'
| '/api/health'
| '/api/worlds'
+ | '/og/home.png'
| '/world/$owner/$repo'
| '/api/blueprint/$owner/$repo'
| '/api/gallery-preview/$owner/$repo'
| '/api/history/$owner/$repo'
| '/api/world/$owner/$repo'
+ | '/og/world/$owner/$repo'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AtlasRoute: typeof AtlasRoute
+ TourRoute: typeof TourRoute
ApiBudgetRoute: typeof ApiBudgetRoute
ApiGalleryRoute: typeof ApiGalleryRoute
ApiHealthRoute: typeof ApiHealthRoute
ApiWorldsRoute: typeof ApiWorldsRoute
+ OgHomeDotpngRoute: typeof OgHomeDotpngRoute
WorldOwnerRepoRoute: typeof WorldOwnerRepoRoute
ApiBlueprintOwnerRepoRoute: typeof ApiBlueprintOwnerRepoRoute
ApiGalleryPreviewOwnerRepoRoute: typeof ApiGalleryPreviewOwnerRepoRoute
ApiHistoryOwnerRepoRoute: typeof ApiHistoryOwnerRepoRoute
ApiWorldOwnerRepoRoute: typeof ApiWorldOwnerRepoRoute
+ OgWorldOwnerRepoRoute: typeof OgWorldOwnerRepoRoute
}
declare module '@tanstack/react-router' {
@@ -186,6 +225,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AtlasRouteImport
parentRoute: typeof rootRouteImport
}
+ '/tour': {
+ id: '/tour'
+ path: '/tour'
+ fullPath: '/tour'
+ preLoaderRoute: typeof TourRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/api/budget': {
id: '/api/budget'
path: '/api/budget'
@@ -214,6 +260,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiWorldsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/og/home.png': {
+ id: '/og/home.png'
+ path: '/og/home.png'
+ fullPath: '/og/home.png'
+ preLoaderRoute: typeof OgHomeDotpngRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/world/$owner/$repo': {
id: '/world/$owner/$repo'
path: '/world/$owner/$repo'
@@ -249,21 +302,31 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiWorldOwnerRepoRouteImport
parentRoute: typeof rootRouteImport
}
+ '/og/world/$owner/$repo': {
+ id: '/og/world/$owner/$repo'
+ path: '/og/world/$owner/$repo'
+ fullPath: '/og/world/$owner/$repo'
+ preLoaderRoute: typeof OgWorldOwnerRepoRouteImport
+ parentRoute: typeof rootRouteImport
+ }
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AtlasRoute: AtlasRoute,
+ TourRoute: TourRoute,
ApiBudgetRoute: ApiBudgetRoute,
ApiGalleryRoute: ApiGalleryRoute,
ApiHealthRoute: ApiHealthRoute,
ApiWorldsRoute: ApiWorldsRoute,
+ OgHomeDotpngRoute: OgHomeDotpngRoute,
WorldOwnerRepoRoute: WorldOwnerRepoRoute,
ApiBlueprintOwnerRepoRoute: ApiBlueprintOwnerRepoRoute,
ApiGalleryPreviewOwnerRepoRoute: ApiGalleryPreviewOwnerRepoRoute,
ApiHistoryOwnerRepoRoute: ApiHistoryOwnerRepoRoute,
ApiWorldOwnerRepoRoute: ApiWorldOwnerRepoRoute,
+ OgWorldOwnerRepoRoute: OgWorldOwnerRepoRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index 6b234fe..eb89265 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -2,16 +2,15 @@ import { appPath } from "../base-path";
import { createRootRoute, HeadContent, Scripts, Link } from "@tanstack/react-router";
import type { ReactNode } from "react";
import appCss from "../styles.css?url";
+import { homeMeta } from "../og/meta";
+import { publicOrigin } from "../og/origin";
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
- { title: "Moon Factory — Your codebase, a living world" },
- {
- name: "description",
- content: "Turn a public GitHub repository into a living, explorable world. Five perspectives. One source of truth.",
- },
+ // Default share card; routes override by name/property.
+ ...homeMeta(publicOrigin()),
],
links: [
{ rel: "stylesheet", href: appCss },
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index b9e9a79..5a2e0e4 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -1,6 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
import { LandingPage } from "../components/LandingPage";
-export const Route = createFileRoute("/")({ component: Home });
+import { homeMeta } from "../og/meta";
+import { publicOrigin } from "../og/origin";
+export const Route = createFileRoute("/")({ head: () => ({ meta: homeMeta(publicOrigin()) }), component: Home });
function Home() {
return ;
}
diff --git a/src/routes/og.home[.]png.ts b/src/routes/og.home[.]png.ts
new file mode 100644
index 0000000..f04742b
--- /dev/null
+++ b/src/routes/og.home[.]png.ts
@@ -0,0 +1,5 @@
+import { createFileRoute } from "@tanstack/react-router";
+const serve = async ({ request }: { request: Request }) => (await import("../server/og-routes")).serveHomeImage(request);
+export const Route = createFileRoute("/og/home.png")({
+ server: { handlers: { GET: serve, HEAD: serve } },
+});
diff --git a/src/routes/og.world.$owner.$repo.ts b/src/routes/og.world.$owner.$repo.ts
new file mode 100644
index 0000000..1255672
--- /dev/null
+++ b/src/routes/og.world.$owner.$repo.ts
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+const serve = async ({ request, params }: { request: Request; params: { owner: string; repo: string } }) =>
+ (await import("../server/og-routes")).serveWorldImage(request, params.owner, params.repo);
+export const Route = createFileRoute("/og/world/$owner/$repo")({
+ server: { handlers: { GET: serve, HEAD: serve } },
+});
diff --git a/src/routes/tour.tsx b/src/routes/tour.tsx
new file mode 100644
index 0000000..d532ec7
--- /dev/null
+++ b/src/routes/tour.tsx
@@ -0,0 +1,7 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { TourPage } from "../tour/TourPage";
+export const Route = createFileRoute("/tour")({ validateSearch: (search: Record) => search, component: Tour });
+function Tour() {
+ const search = Route.useSearch();
+ return ;
+}
diff --git a/src/routes/world.$owner.$repo.tsx b/src/routes/world.$owner.$repo.tsx
index a160d5f..f61f20f 100644
--- a/src/routes/world.$owner.$repo.tsx
+++ b/src/routes/world.$owner.$repo.tsx
@@ -1,6 +1,12 @@
import { createFileRoute } from "@tanstack/react-router";
import { ExplorerPage } from "../components/ExplorerPage";
-export const Route = createFileRoute("/world/$owner/$repo")({ validateSearch: (search: Record) => search, component: WorldPage });
+import { worldMeta } from "../og/meta";
+import { publicOrigin } from "../og/origin";
+export const Route = createFileRoute("/world/$owner/$repo")({
+ validateSearch: (search: Record) => search,
+ head: ({ params, match }) => ({ meta: worldMeta(publicOrigin(), params.owner, params.repo, (match.search as Record).style) }),
+ component: WorldPage,
+});
function WorldPage() {
const { owner, repo } = Route.useParams();
const search = Route.useSearch();
diff --git a/src/server/og-image.ts b/src/server/og-image.ts
new file mode 100644
index 0000000..4ef20cc
--- /dev/null
+++ b/src/server/og-image.ts
@@ -0,0 +1,133 @@
+import { createHash } from "node:crypto";
+import type { StyleId, WorldEdge, WorldNode, WorldSnapshot } from "../contracts";
+import { renderHomeCard, renderWorldCard, type CardOptions, type CardWorld } from "../og/card";
+import { isStyleId, normalizeOrigin } from "../og/meta";
+import { projectRepositoryPreview } from "./gallery-preview";
+import { parseRepo } from "./github";
+
+/** A world card is immutable for one capture (repo + style + head), so CDNs may keep it. */
+export const OG_CACHE_WORLD = "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800";
+/** The generic card stands in for a miss only briefly, so a later capture appears. */
+export const OG_CACHE_FALLBACK = "public, max-age=300, s-maxage=600";
+export const OG_CACHE_HOME = "public, max-age=86400, s-maxage=604800, stale-while-revalidate=604800";
+
+export type OgImage = { body: Uint8Array; etag: string; cacheControl: string; kind: "world" | "home" };
+type Renderers = {
+ world: (world: CardWorld, style: StyleId, options: CardOptions) => Uint8Array;
+ home: (options: CardOptions) => Uint8Array;
+};
+export interface OgImageOptions {
+ /** Read-only cache lookup. The service never collects, analyses or writes. */
+ getCached: (input: string) => Promise;
+ site?: string | null;
+ clock?: () => number;
+ maxEntries?: number;
+ maxBytes?: number;
+ rendersPerMinute?: number;
+ renderMs?: number;
+ render?: Renderers;
+}
+
+const etagOf = (body: Uint8Array) => `"og-${createHash("sha256").update(body).digest("hex").slice(0, 32)}"`;
+
+/** Cache-only social cards with a bounded LRU, a global render rate and a CPU deadline per render. */
+export function createOgImageService(options: OgImageOptions) {
+ const clock = options.clock ?? (() => performance.now());
+ const render = options.render ?? { world: renderWorldCard, home: renderHomeCard };
+ const maxEntries = options.maxEntries ?? 48, maxBytes = options.maxBytes ?? 24 * 1024 * 1024;
+ const rendersPerMinute = options.rendersPerMinute ?? 20, renderMs = options.renderMs ?? 1500;
+ const site = options.site ?? null;
+ const lru = new Map();
+ let bytes = 0, homeImage: OgImage | undefined, homeFallback: OgImage | undefined;
+ const renders: number[] = [];
+
+ const home = (): OgImage => {
+ if (!homeImage) {
+ const body = render.home({ site, deadline: clock() + 8000 });
+ homeImage = { body, etag: etagOf(body), cacheControl: OG_CACHE_HOME, kind: "home" };
+ homeFallback = { ...homeImage, cacheControl: OG_CACHE_FALLBACK };
+ }
+ return homeImage;
+ };
+ const fallback = () => (home(), homeFallback!);
+ const admitRender = () => {
+ const now = clock();
+ while (renders.length && renders[0] <= now - 60_000) renders.shift();
+ if (renders.length >= rendersPerMinute) return false;
+ renders.push(now);
+ return true;
+ };
+ const remember = (key: string, image: OgImage) => {
+ lru.set(key, image);
+ bytes += image.body.length;
+ for (const [oldest, value] of lru) {
+ if (lru.size <= maxEntries && bytes <= maxBytes) break;
+ lru.delete(oldest);
+ bytes -= value.body.length;
+ }
+ };
+
+ /** Invalid owner/repo rejects (GitHubError 400); every other problem degrades to the generic card. */
+ const world = async (owner: string, repo: string, styleParam: unknown): Promise => {
+ const input = `${owner}/${repo}`;
+ parseRepo(input);
+ const style: StyleId = isStyleId(styleParam) ? styleParam : "moonshot";
+ let snapshot: WorldSnapshot | null;
+ let preview: ReturnType;
+ try {
+ snapshot = await options.getCached(input);
+ preview = projectRepositoryPreview(input, snapshot);
+ } catch {
+ return fallback();
+ }
+ if (!snapshot || preview.nodes.length === 0) return fallback();
+ const key = `${preview.fullName.toLowerCase()}|${style}|${preview.headSha ?? snapshot.collectedAt}`;
+ const hit = lru.get(key);
+ if (hit) {
+ lru.delete(key);
+ lru.set(key, hit);
+ return hit;
+ }
+ if (!admitRender()) return fallback();
+ const card: CardWorld = {
+ fullName: preview.fullName,
+ nodes: preview.nodes as unknown as WorldNode[],
+ edges: preview.edges as unknown as WorldEdge[],
+ totalNodes: preview.totalNodes,
+ totalEdges: preview.totalEdges,
+ stars: Number.isFinite(snapshot.repo.stars) ? snapshot.repo.stars : null,
+ language: typeof snapshot.repo.language === "string" ? snapshot.repo.language : null,
+ description: typeof snapshot.repo.description === "string" ? snapshot.repo.description : null,
+ };
+ let body: Uint8Array;
+ try {
+ body = render.world(card, style, { site, deadline: clock() + renderMs });
+ } catch {
+ return fallback();
+ }
+ const image: OgImage = { body, etag: etagOf(body), cacheControl: OG_CACHE_WORLD, kind: "world" };
+ remember(key, image);
+ return image;
+ };
+ return { home, world, cacheSize: () => lru.size };
+}
+
+/** Display-only site line, e.g. "chaorders.com/github-factory". */
+export function siteLabel(origin: string | null, basePath: string) {
+ const valid = normalizeOrigin(origin);
+ return valid ? `${new URL(valid).host}${basePath.replace(/\/$/, "")}` : null;
+}
+
+export function imageResponse(request: Request, image: OgImage) {
+ const headers = {
+ "content-type": "image/png",
+ "cache-control": image.cacheControl,
+ etag: image.etag,
+ "x-content-type-options": "nosniff",
+ "content-security-policy": "default-src 'none'",
+ "x-og-image": image.kind,
+ };
+ const match = request.headers.get("if-none-match");
+ if (match && match.split(",").some((tag) => tag.trim().replace(/^W\//, "") === image.etag)) return new Response(null, { status: 304, headers });
+ return new Response(request.method === "HEAD" ? null : (image.body as Uint8Array), { status: 200, headers: { ...headers, "content-length": String(image.body.length) } });
+}
diff --git a/src/server/og-routes.ts b/src/server/og-routes.ts
new file mode 100644
index 0000000..48ae83d
--- /dev/null
+++ b/src/server/og-routes.ts
@@ -0,0 +1,30 @@
+import { appPath } from "../base-path";
+import { accessConfig, clientIdentity } from "./access";
+import { apiError } from "./http";
+import { createOgImageService, imageResponse, siteLabel } from "./og-image";
+import { hasConfiguredOrigin, serverPublicOrigin } from "./public-origin";
+import { projectEnv } from "./config";
+import { getCachedWorld } from "./service";
+
+let service: ReturnType | undefined;
+const og = () => (service ??= createOgImageService({
+ getCached: getCachedWorld,
+ site: hasConfiguredOrigin(projectEnv()) ? siteLabel(serverPublicOrigin(), appPath("")) : null,
+}));
+
+export async function serveHomeImage(request: Request) {
+ try {
+ clientIdentity(request, accessConfig());
+ return imageResponse(request, og().home());
+ } catch (error) {
+ return apiError(error);
+ }
+}
+export async function serveWorldImage(request: Request, owner: string, repo: string) {
+ try {
+ clientIdentity(request, accessConfig());
+ return imageResponse(request, await og().world(owner, repo, new URL(request.url).searchParams.get("style")));
+ } catch (error) {
+ return apiError(error);
+ }
+}
diff --git a/src/server/public-origin.ts b/src/server/public-origin.ts
new file mode 100644
index 0000000..ee2da91
--- /dev/null
+++ b/src/server/public-origin.ts
@@ -0,0 +1,19 @@
+import { normalizeOrigin } from "../og/meta";
+import { projectEnv } from "./config";
+
+const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
+
+/** Absolute public origin for share links. Only server configuration counts:
+ * MOONSHOT_PUBLIC_ORIGIN when it is https (or http on loopback), otherwise the
+ * local default. Host and forwarded headers are never consulted. */
+export function resolvePublicOrigin(env: Record) {
+ const configured = normalizeOrigin(env.MOONSHOT_PUBLIC_ORIGIN);
+ if (configured && (configured.startsWith("https://") || LOOPBACK.has(new URL(configured).hostname))) return configured;
+ const port = Number(env.PORT);
+ return `http://localhost:${Number.isInteger(port) && port > 0 && port < 65536 ? port : 3000}`;
+}
+/** True only when the operator configured a real public origin. */
+export const hasConfiguredOrigin = (env: Record) => resolvePublicOrigin(env) === normalizeOrigin(env.MOONSHOT_PUBLIC_ORIGIN);
+
+let cached: string | undefined;
+export const serverPublicOrigin = () => (cached ??= resolvePublicOrigin(projectEnv()));
diff --git a/src/tour/TourPage.tsx b/src/tour/TourPage.tsx
new file mode 100644
index 0000000..f32b0de
--- /dev/null
+++ b/src/tour/TourPage.tsx
@@ -0,0 +1,299 @@
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { ArrowRight, ArrowUpRight, Pause, Play, RotateCcw, ShieldCheck, SkipForward, X } from "lucide-react";
+import { appPath } from "../base-path";
+import { getCachedWorld, getGallery } from "../client/api";
+import { WorldCanvas, type CameraTarget } from "../world/WorldCanvas";
+import { Brand } from "../components/shared";
+import {
+ beatIndexAt,
+ collectStops,
+ createTourClock,
+ nextBoundary,
+ planTour,
+ shotAt,
+ STYLE_NAMES,
+ tourCandidates,
+ tourDuration,
+ type TourStop,
+} from "./schedule";
+import "./tour.css";
+
+const FADE_MS = 900;
+const GLIDE_MS = 520;
+const noop = () => {};
+
+function useMedia(query: string) {
+ const [matches, setMatches] = useState(false);
+ useEffect(() => {
+ const media = window.matchMedia(query);
+ const update = () => setMatches(media.matches);
+ update();
+ media.addEventListener("change", update);
+ return () => media.removeEventListener("change", update);
+ }, [query]);
+ return matches;
+}
+/** Where a framed district lands: right of the caption on wide screens, above it on phones. */
+const WIDE_ANCHOR = [0.6, 0.42] as const,
+ NARROW_ANCHOR = [0.5, 0.33] as const;
+
+/** Hands-free cinematic tour across cached gallery worlds. Cache-only GET reads; no collection,
+ * model calls or writes. Timing uses timeouts at beat/keyframe boundaries only, never RAF; the
+ * world canvas owns its own (visibility-gated) frame loop. */
+export function TourPage({ record = false }: { record?: boolean }) {
+ const reduced = useMedia("(prefers-reduced-motion: reduce)"),
+ narrow = useMedia("(max-width: 640px)");
+ const [stops, setStops] = useState(null);
+ const [elapsed, setElapsed] = useState(0);
+ const [userPaused, setUserPaused] = useState(false);
+ const [hidden, setHidden] = useState(false);
+ const [ended, setEnded] = useState(false);
+ const [seek, setSeek] = useState(0);
+ const clock = useRef(createTourClock(() => performance.now()));
+ const stage = useRef(null),
+ fade = useRef(null);
+
+ useEffect(() => {
+ let active = true;
+ getGallery()
+ .then((g) => collectStops(tourCandidates(g.projects), getCachedWorld))
+ .catch(() => [] as TourStop[])
+ .then((found) => { if (active) setStops(found); });
+ return () => { active = false; };
+ }, []);
+
+ const beats = useMemo(() => planTour(stops ?? [], { reducedMotion: reduced }), [stops, reduced]);
+ const total = tourDuration(beats);
+ const index = beatIndexAt(beats, elapsed),
+ beat = beats[index],
+ shot = shotAt(beat, elapsed - beat.start),
+ stop = stops?.[beat.stop];
+ const playing = !!stops?.length && !userPaused && !hidden && !ended;
+
+ useEffect(() => {
+ const visibility = () => setHidden(document.hidden);
+ visibility();
+ document.addEventListener("visibilitychange", visibility);
+ return () => document.removeEventListener("visibilitychange", visibility);
+ }, []);
+
+ // Wake only when the visible beat or keyframe changes; nothing runs per frame.
+ useEffect(() => {
+ const c = clock.current;
+ if (!playing) { c.pause(); return; }
+ c.play();
+ let timer = 0;
+ const tick = () => {
+ let now = c.elapsed();
+ if (now >= total) {
+ if (!record) { c.pause(); c.seek(total - 1); setElapsed(total - 1); setEnded(true); return; }
+ c.seek(0); now = 0;
+ }
+ setElapsed(now);
+ const next = nextBoundary(beats, now) ?? total;
+ timer = window.setTimeout(tick, Math.max(16, next - now));
+ };
+ tick();
+ return () => { window.clearTimeout(timer); c.pause(); };
+ }, [playing, beats, total, record, seek]);
+
+ const go = (i: number) => {
+ const target = Math.max(0, Math.min(beats.length - 1, i));
+ clock.current.seek(beats[target].start);
+ setElapsed(beats[target].start);
+ setEnded(false);
+ setSeek((n) => n + 1);
+ };
+ const exit = () => window.location.assign(appPath("/"));
+ const toggle = () => {
+ if (!ended) return setUserPaused((p) => !p);
+ go(0);
+ setUserPaused(false);
+ };
+ const controls = useRef({ go, exit, toggle, index, last: beats.length - 1 });
+ controls.current = { go, exit, toggle, index, last: beats.length - 1 };
+ useEffect(() => {
+ const key = (e: KeyboardEvent) => {
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
+ const c = controls.current,
+ onControl = e.target instanceof HTMLElement && !!e.target.closest("button, a");
+ if (e.key === "Escape") { e.preventDefault(); c.exit(); }
+ else if (e.key === " " && !onControl) { e.preventDefault(); c.toggle(); }
+ else if (e.key === "ArrowRight") { e.preventDefault(); c.go(c.index + 1); }
+ else if (e.key === "ArrowLeft") { e.preventDefault(); c.go(c.index - 1); }
+ };
+ window.addEventListener("keydown", key);
+ return () => window.removeEventListener("keydown", key);
+ }, []);
+
+ // The stage is a film, not a control surface.
+ useEffect(() => { stage.current?.setAttribute("inert", ""); }, []);
+
+ // Cross-fade: freeze the last painted frame over the stage, then let CSS fade it out.
+ const scene = stop ? `${beat.stop}:${beat.style}` : "";
+ const previousScene = useRef(scene);
+ useLayoutEffect(() => {
+ const before = previousScene.current;
+ previousScene.current = scene;
+ const overlay = fade.current,
+ source = stage.current?.querySelector('canvas[data-testid="world-canvas"]');
+ if (!overlay || !source || !before || before === scene || reduced || !source.width) return;
+ overlay.width = source.width;
+ overlay.height = source.height;
+ overlay.getContext("2d")?.drawImage(source, 0, 0);
+ overlay.classList.remove("is-fading");
+ void overlay.offsetWidth;
+ overlay.classList.add("is-fading");
+ }, [scene, reduced]);
+
+ // Portrait screens fit the world by width, so districts need a closer shot to read.
+ const camera = useMemo(
+ () => ({ nodeId: shot.nodeId, zoom: shot.zoom * (narrow ? 1.7 : 1), glide: GLIDE_MS, anchor: narrow ? NARROW_ANCHOR : WIDE_ANCHOR }),
+ [shot.nodeId, shot.zoom, narrow],
+ );
+ const state = !stops ? "loading" : !stops.length ? "empty" : ended ? "ended" : userPaused || hidden ? "paused" : "playing";
+ const caption = beat.caption;
+ const isEnd = beat.kind === "end";
+
+ return (
+
+
+ {stop && (
+
+ )}
+
+
+
+
+
+
+
+ {stop && !isEnd && (
+
+
+ {STYLE_NAMES[beat.style]}
+
+ )}
+
+
+ {ended ? : userPaused ? : }
+ {ended ? "Replay" : userPaused ? "Play" : "Pause"}
+
+
go(beats.length - 1)} disabled={!stops?.length || isEnd} aria-label="Skip tour">
+
+ Skip
+
+
+
+
+
+
+
+ {!stops && (
+
+
+ Loading cached worlds…
+
+ )}
+
+ {stops && !isEnd && (
+
+ {caption.eyebrow}
+
+ {beat.kind === "repo" && caption.title.includes("/") ? (
+ <>
+ {caption.title.split("/")[0]}/
+
+ {caption.title.split("/").slice(1).join("/")}
+ >
+ ) : (
+ caption.title
+ )}
+
+ {caption.line}
+ {(caption.meta || caption.confidence) && (
+
+ {caption.confidence && (
+
+
+ {caption.confidence === "observed" ? "SOURCE OBSERVED" : "STRUCTURE INFERRED"}
+
+ )}
+ {caption.meta && {caption.meta} }
+
+ )}
+
+ )}
+
+ {stops && isEnd && (
+
+ {stops.length ? caption.eyebrow : "NO CACHED WORLDS YET"}
+ {caption.title}
+ {stops.length ? caption.line : "No gallery world is cached right now, so the tour has nothing to show. Map a public repository to start one."}
+
+ {stops.length > 0 && WORLDS IN THIS TOUR
}
+ {stops.length > 0 && (
+
+ )}
+
+ )}
+
+
+
+ {beats.map((b, i) => (
+
+ {i === index && (
+
+ )}
+
+ ))}
+
+
+ {stop && Cached snapshot of {stop.world.repo.fullName} · }Inferred architecture from public GitHub metadata, not live telemetry
+ · Space pause · ← → beats · Esc exit
+
+
+
+ );
+}
diff --git a/src/tour/schedule.ts b/src/tour/schedule.ts
new file mode 100644
index 0000000..5b93c05
--- /dev/null
+++ b/src/tour/schedule.ts
@@ -0,0 +1,285 @@
+/** Pure guided-tour planning and timing. No DOM, network or timers: the page drives it. */
+import { STYLE_IDS, type FeaturedProject, type NodeKind, type StyleId, type WorldNode, type WorldSnapshot } from "../contracts";
+
+export const STYLE_NAMES: Record = {
+ moonshot: "Moon Factory",
+ factorio: "Factorio",
+ "red-alert": "Red Alert",
+ "red-alert-2": "Red Alert 2",
+ civilization: "Civilization",
+};
+export interface TourStop {
+ world: WorldSnapshot;
+ /** Gallery editorial style; the stop opens in it. */
+ style: StyleId;
+}
+/** A camera keyframe, `at` ms after its beat starts. */
+export interface Shot {
+ at: number;
+ nodeId: string | null;
+ zoom: number;
+}
+export interface Caption {
+ eyebrow: string;
+ title: string;
+ line: string;
+ /** Short factual row under the line. */
+ meta?: string;
+ /** District evidence state, when the caption names a district. */
+ confidence?: WorldNode["confidence"];
+}
+export interface Beat {
+ id: string;
+ kind: "intro" | "repo" | "perspective" | "end";
+ /** Index into the stops; the end card keeps showing the last world behind it. */
+ stop: number;
+ style: StyleId;
+ start: number;
+ duration: number;
+ shots: Shot[];
+ caption: Caption;
+}
+export const INTRO_MS = 3600,
+ BEAT_MS = 3200,
+ END_MS = 5000,
+ MAX_STOPS = 4;
+/** Showcase order for the tour; anything else ready in the gallery follows. */
+const PREFERRED = ["openclaw/openclaw", "react/react", "pytorch/pytorch", "pingdotgg/t3code", "microsoft/vscode", "kubernetes/kubernetes"];
+const KIND_RANK: NodeKind[] = ["app", "service", "data", "package", "infra", "tool", "test", "docs", "external"];
+const KIND_NAMES: Record = {
+ app: "App",
+ service: "Service",
+ package: "Package",
+ data: "Data",
+ infra: "Infrastructure",
+ docs: "Docs",
+ test: "Test",
+ tool: "Tooling",
+ external: "External",
+};
+
+/** Ready gallery projects only (a real cached snapshot exists), showcase order first. */
+export function tourCandidates(projects: readonly FeaturedProject[]): FeaturedProject[] {
+ const rank = (p: FeaturedProject) => {
+ const i = PREFERRED.indexOf(p.fullName.toLowerCase());
+ return i < 0 ? PREFERRED.length : i;
+ };
+ return projects
+ .map((project, index) => ({ project, index }))
+ .filter(({ project }) => project.ready && /^[^/\s]+\/[^/\s]+$/.test(project.fullName))
+ .sort((a, b) => rank(a.project) - rank(b.project) || a.index - b.index)
+ .map(({ project }) => project);
+}
+
+/** Loads cached worlds in candidate order with bounded concurrency. Misses, errors and
+ * empty worlds are skipped; stops keep candidate order. Never asks for more than needed. */
+export async function collectStops(
+ candidates: readonly FeaturedProject[],
+ load: (owner: string, repo: string) => Promise,
+ want = MAX_STOPS,
+): Promise {
+ const found: (TourStop | null)[] = [];
+ let next = 0,
+ loaded = 0,
+ inFlight = 0;
+ const worker = async () => {
+ // In-flight reads count toward the target, so a miss is the only reason to read further.
+ while (next < candidates.length && loaded + inFlight < want) {
+ const index = next++,
+ project = candidates[index],
+ [owner, repo] = project.fullName.split("/");
+ inFlight++;
+ try {
+ const world = await load(owner, repo);
+ found[index] = world?.nodes?.length ? { world, style: project.suggestedStyle } : null;
+ } catch {
+ found[index] = null;
+ } finally {
+ inFlight--;
+ if (found[index]) loaded++;
+ }
+ }
+ };
+ await Promise.all(Array.from({ length: Math.min(want, candidates.length) }, worker));
+ return found.filter((stop): stop is TourStop => !!stop).slice(0, want);
+}
+
+/** Notable districts: guide-named districts first, then larger core kinds. */
+export function notableDistricts(world: WorldSnapshot, count = 6): WorldNode[] {
+ const byId = new Map(world.nodes.map((n) => [n.id, n]));
+ const named = (world.blueprint?.districts ?? []).map((d) => byId.get(d.nodeId)).filter((n): n is WorldNode => !!n);
+ const rest = world.nodes
+ .filter((n) => n.kind !== "external")
+ .toSorted((a, b) => KIND_RANK.indexOf(a.kind) - KIND_RANK.indexOf(b.kind) || b.size - a.size || a.id.localeCompare(b.id));
+ const picked: WorldNode[] = [];
+ for (const node of [...named, ...rest]) if (!picked.includes(node) && picked.length < count) picked.push(node);
+ return picked.length ? picked : world.nodes.slice(0, count);
+}
+
+const districtCaption = (node: WorldNode, eyebrow: string): Caption => ({
+ eyebrow,
+ title: node.label,
+ line: `${KIND_NAMES[node.kind]} district · ${node.path || "/"}`,
+ confidence: node.confidence,
+});
+
+/** The tour: an intro fly-in, all five perspectives on the first world ("same code, different
+ * worlds"), two perspectives on each further world, then an end card. Reduced motion keeps the
+ * same beats and timing but only the final keyframe of each, so the camera never flies. */
+export function planTour(stops: readonly TourStop[], options: { reducedMotion?: boolean } = {}): Beat[] {
+ const beats: Omit[] = [];
+ if (stops.length) {
+ const first = stops[0],
+ d0 = notableDistricts(first.world),
+ at = (i: number) => d0[i % d0.length].id;
+ beats.push({
+ id: "intro",
+ kind: "intro",
+ stop: 0,
+ style: STYLE_IDS[0],
+ duration: INTRO_MS,
+ shots: [
+ { at: 0, nodeId: null, zoom: 0.62 },
+ { at: 250, nodeId: null, zoom: 1.04 },
+ ],
+ caption: {
+ eyebrow: "MOON FACTORY · GUIDED TOUR",
+ title: "Every codebase. A world of its own.",
+ line: `${stops.length} public ${stops.length === 1 ? "repository" : "repositories"}, rendered from cached snapshots. Architecture is inferred from the source tree, not live telemetry.`,
+ },
+ });
+ STYLE_IDS.forEach((style, i) => {
+ const district = d0[i % d0.length];
+ beats.push({
+ id: `0:${style}`,
+ kind: i ? "perspective" : "repo",
+ stop: 0,
+ style,
+ duration: BEAT_MS,
+ shots: i
+ ? [
+ { at: 0, nodeId: at(i - 1), zoom: 1.35 },
+ { at: 300, nodeId: district.id, zoom: 1.75 },
+ ]
+ : [
+ { at: 0, nodeId: null, zoom: 1.04 },
+ { at: 200, nodeId: district.id, zoom: 1.6 },
+ ],
+ caption: i
+ ? districtCaption(district, `SAME CODE · DIFFERENT WORLDS · ${i + 1}/5 · ${STYLE_NAMES[style].toUpperCase()}`)
+ : repoCaption(first.world, 1, stops.length, style),
+ });
+ });
+ stops.slice(1).forEach((stop, offset) => {
+ const n = offset + 2,
+ districts = notableDistricts(stop.world),
+ from = STYLE_IDS.indexOf(stop.style),
+ second = STYLE_IDS[(from + 2 + offset) % STYLE_IDS.length],
+ a = districts[0],
+ b = districts[1 % districts.length];
+ beats.push({
+ id: `${n - 1}:${stop.style}`,
+ kind: "repo",
+ stop: n - 1,
+ style: stop.style,
+ duration: BEAT_MS,
+ shots: [
+ { at: 0, nodeId: null, zoom: 0.72 },
+ { at: 250, nodeId: a.id, zoom: 1.5 },
+ ],
+ caption: repoCaption(stop.world, n, stops.length, stop.style),
+ });
+ beats.push({
+ id: `${n - 1}:${second}`,
+ kind: "perspective",
+ stop: n - 1,
+ style: second,
+ duration: BEAT_MS,
+ shots: [
+ { at: 0, nodeId: a.id, zoom: 1.3 },
+ { at: 300, nodeId: b.id, zoom: 1.75 },
+ ],
+ caption: districtCaption(b, `SAME CODE · DIFFERENT WORLDS · ${STYLE_NAMES[second].toUpperCase()}`),
+ });
+ });
+ }
+ const last = beats.at(-1);
+ beats.push({
+ id: "end",
+ kind: "end",
+ stop: last?.stop ?? 0,
+ style: last?.style ?? STYLE_IDS[0],
+ duration: END_MS,
+ shots: [{ at: 0, nodeId: null, zoom: 0.8 }],
+ caption: {
+ eyebrow: "YOUR REPOSITORY NEXT",
+ title: "Build your world.",
+ line: "Paste any public GitHub repository. Five perspectives, one source of truth.",
+ },
+ });
+ let start = 0;
+ return beats.map((beat) => {
+ const shots = options.reducedMotion ? [{ ...beat.shots.at(-1)!, at: 0 }] : beat.shots;
+ const planned = { ...beat, shots, start };
+ start += beat.duration;
+ return planned;
+ });
+}
+function repoCaption(world: WorldSnapshot, n: number, total: number, style: StyleId): Caption {
+ return {
+ eyebrow: `WORLD ${n} OF ${total} · ${STYLE_NAMES[style].toUpperCase()}`,
+ title: world.repo.fullName,
+ // Original GitHub description text, unmodified.
+ line: world.repo.description || "Architecture inferred from the public source tree.",
+ meta: `${world.nodes.length} districts · ${world.edges.length} connections · inferred from the public source tree`,
+ };
+}
+
+export const tourDuration = (beats: readonly Beat[]) => (beats.length ? beats.at(-1)!.start + beats.at(-1)!.duration : 0);
+/** Beat index showing at elapsed time `t` (clamped to the tour). */
+export function beatIndexAt(beats: readonly Beat[], t: number): number {
+ let index = 0;
+ for (let i = 0; i < beats.length; i++) if (beats[i].start <= t) index = i;
+ return index;
+}
+/** Keyframe active `offset` ms into a beat. */
+export function shotAt(beat: Beat, offset: number): Shot {
+ let shot = beat.shots[0];
+ for (const s of beat.shots) if (s.at <= offset) shot = s;
+ return shot;
+}
+/** Next elapsed time at which the visible beat or keyframe changes; the tour end included. */
+export function nextBoundary(beats: readonly Beat[], t: number): number | null {
+ let next: number | null = null;
+ for (const beat of beats)
+ for (const time of [beat.start, ...beat.shots.map((s) => beat.start + s.at), beat.start + beat.duration])
+ if (time > t && (next === null || time < next)) next = time;
+ return next;
+}
+/** Overall tour position, 0..1. */
+export const tourProgress = (beats: readonly Beat[], t: number) => {
+ const total = tourDuration(beats);
+ return total ? Math.max(0, Math.min(1, t / total)) : 0;
+};
+
+/** Pausable, seekable elapsed-time clock over an injected monotonic `now`. */
+export function createTourClock(now: () => number) {
+ let base = 0,
+ since: number | null = null;
+ return {
+ elapsed: () => base + (since === null ? 0 : now() - since),
+ playing: () => since !== null,
+ play() {
+ if (since === null) since = now();
+ },
+ pause() {
+ if (since !== null) base += now() - since;
+ since = null;
+ },
+ seek(t: number) {
+ base = Math.max(0, t);
+ if (since !== null) since = now();
+ },
+ };
+}
+export type TourClock = ReturnType;
diff --git a/src/tour/tour.css b/src/tour/tour.css
new file mode 100644
index 0000000..82e8aa3
--- /dev/null
+++ b/src/tour/tour.css
@@ -0,0 +1,369 @@
+/* Guided tour: a full-bleed film over one WorldCanvas. CSS animations only; no script RAF. */
+.tour {
+ --tour-accent: #8fd3ff;
+ --tour-fg: #f7f7f5;
+ --tour-muted: #c4c4c0;
+ --tour-glass: #0b0e12b8;
+ --tour-line: #ffffff2e;
+ position: fixed;
+ inset: 0;
+ overflow: hidden;
+ background: #07090c;
+ color: var(--tour-fg);
+ font-family: "DM Sans", ui-sans-serif, system-ui, sans-serif;
+}
+.tour-style-factorio { --tour-accent: #f2a53a; }
+.tour-style-red-alert { --tour-accent: #ff5a47; }
+.tour-style-red-alert-2 { --tour-accent: #5fd4ff; }
+.tour-style-civilization { --tour-accent: #f0cd76; }
+
+.tour-stage {
+ position: absolute;
+ inset: 0;
+}
+.tour-stage .world-canvas-wrap {
+ position: absolute;
+ inset: 0;
+ min-height: 0;
+}
+.tour-stage .map-coordinates,
+.tour-stage .camera-controls,
+.tour-stage .map-hint,
+.tour-stage .map-tooltip {
+ display: none;
+}
+.tour-stage::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: 2; /* above the cross-fade frame, so the vignette never flashes off at a cut */
+ pointer-events: none;
+ background:
+ radial-gradient(ellipse 75% 70% at 55% 45%, #0000 55%, #000a 100%),
+ linear-gradient(90deg, #000c 0%, #0006 26%, #0000 52%);
+}
+.tour-fade {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ pointer-events: none;
+ z-index: 1;
+}
+.tour-fade.is-fading {
+ animation: tour-fade var(--tour-fade, 900ms) cubic-bezier(.45, 0, .2, 1) forwards;
+}
+@keyframes tour-fade {
+ from { opacity: 1; filter: blur(0); }
+ to { opacity: 0; filter: blur(6px); }
+}
+
+.tour-letterbox {
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: min(13vh, 120px);
+ pointer-events: none;
+ z-index: 2;
+}
+.tour-letterbox.top { top: 0; background: linear-gradient(#000e, #0000); }
+.tour-letterbox.bottom { bottom: 0; height: min(30vh, 260px); background: linear-gradient(#0000, #000d); }
+
+.tour-top {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 5;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 22px 32px;
+}
+.tour-top .brand { color: var(--tour-fg); }
+.tour-top .brand strong { font-size: 22px; }
+.tour-style-chip {
+ margin: 0 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ padding: 6px 12px;
+ border: 1px solid var(--tour-line);
+ border-radius: 999px;
+ background: var(--tour-glass);
+ font: 500 12px var(--mono);
+ letter-spacing: .14em;
+ text-transform: uppercase;
+ backdrop-filter: blur(8px);
+}
+.tour-style-chip i {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--tour-accent);
+ box-shadow: 0 0 10px var(--tour-accent);
+}
+.tour-controls {
+ display: flex;
+ gap: 8px;
+ margin-left: auto;
+}
+.tour-style-chip + .tour-controls { margin-left: 0; }
+.tour-controls button {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ min-height: 38px;
+ padding: 0 14px;
+ border: 1px solid var(--tour-line);
+ border-radius: 999px;
+ background: var(--tour-glass);
+ color: var(--tour-fg);
+ font: 500 13px var(--mono);
+ backdrop-filter: blur(8px);
+}
+.tour-controls button:hover:not(:disabled) { background: #ffffff1f; }
+.tour-controls button:disabled { opacity: .45; background: var(--tour-glass); color: var(--tour-fg); }
+.tour-controls button:focus-visible,
+.tour-end a:focus-visible {
+ outline: 2px solid var(--tour-accent);
+ outline-offset: 2px;
+}
+.tour-controls button:last-child { padding: 0 11px; }
+
+.tour-loading {
+ position: absolute;
+ inset: 0;
+ z-index: 4;
+ display: grid;
+ place-content: center;
+ grid-auto-flow: column;
+ align-items: center;
+ gap: 12px;
+ font: 13px var(--mono);
+ letter-spacing: .1em;
+ color: var(--tour-muted);
+}
+.tour-loading .signal-dot { color: var(--tour-accent); animation: tour-pulse 1.2s ease-in-out infinite alternate; }
+@keyframes tour-pulse { to { opacity: .25; } }
+
+.tour-caption {
+ position: absolute;
+ left: 48px;
+ bottom: 104px;
+ z-index: 4;
+ max-width: min(640px, calc(100% - 96px));
+ text-shadow: 0 2px 18px #000c;
+}
+.tour-eyebrow {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font: 500 12px var(--mono);
+ letter-spacing: .16em;
+ color: var(--tour-accent);
+}
+.tour-eyebrow::before {
+ content: "";
+ width: 28px;
+ height: 1px;
+ background: currentColor;
+}
+.tour-caption h1 {
+ margin: 14px 0 10px;
+ font: 500 clamp(30px, 4.2vw, 58px)/1.04 var(--heading);
+ letter-spacing: -.02em;
+ overflow-wrap: anywhere;
+}
+.tour-owner {
+ font-weight: 400;
+ color: #ffffffa6;
+}
+.tour-caption p {
+ margin: 0;
+ max-width: 540px;
+ font-size: 17px;
+ line-height: 1.5;
+ color: var(--tour-muted);
+}
+.tour-meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 14px;
+ margin-top: 16px;
+ font: 12px var(--mono);
+ letter-spacing: .04em;
+ color: var(--tour-muted);
+}
+.tour .evidence-badge {
+ background: #0c2a1ccc;
+ border-color: #3ddc8f66;
+ color: #7ff0b6;
+ border-radius: 3px;
+}
+.tour .evidence-badge.inferred {
+ background: #2e2408cc;
+ border-color: #f5c35a66;
+ color: #ffd27a;
+}
+.tour-motion .tour-caption > * {
+ animation: tour-rise 760ms cubic-bezier(.2, .7, .1, 1) both;
+}
+.tour-motion .tour-caption > :nth-child(2) { animation-delay: 90ms; }
+.tour-motion .tour-caption > :nth-child(3) { animation-delay: 180ms; }
+.tour-motion .tour-caption > :nth-child(4) { animation-delay: 260ms; }
+.tour-motion .tour-style-chip { animation: tour-rise 600ms cubic-bezier(.2, .7, .1, 1) both; }
+@keyframes tour-rise {
+ from { opacity: 0; transform: translateY(16px); filter: blur(4px); }
+ to { opacity: 1; transform: none; filter: none; }
+}
+
+.tour-end {
+ position: absolute;
+ inset: 0;
+ z-index: 4;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 24px 16px;
+ text-align: center;
+ background: radial-gradient(ellipse 65% 60% at 50% 50%, #07090cf0 0%, #07090cd0 55%, #07090c99 100%);
+ backdrop-filter: blur(5px) saturate(.8);
+}
+.tour-end .tour-eyebrow::after {
+ content: "";
+ width: 28px;
+ height: 1px;
+ background: currentColor;
+}
+.tour-end h1 {
+ margin: 18px 0 12px;
+ font: 500 clamp(40px, 7vw, 92px)/1 var(--heading);
+ letter-spacing: -.03em;
+}
+.tour-end p {
+ margin: 0;
+ max-width: 520px;
+ font-size: 17px;
+ color: var(--tour-muted);
+}
+.tour-cta {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 12px;
+ margin-top: 30px;
+}
+.tour-cta a {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 48px;
+ padding: 0 22px;
+ border-radius: 999px;
+ font: 500 15px var(--heading);
+}
+.tour-primary { background: var(--tour-fg); color: #0a0a0a; }
+.tour-primary:hover { background: #fff; }
+.tour-secondary { border: 1px solid var(--tour-line); background: var(--tour-glass); color: var(--tour-fg); }
+.tour-secondary:hover { background: #ffffff1f; }
+.tour-credits-label {
+ margin-top: 40px;
+ font: 11px var(--mono);
+ letter-spacing: .16em;
+ color: #ffffff80;
+}
+.tour-credits {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 6px 18px;
+ margin: 10px 0 0;
+ padding: 0;
+ list-style: none;
+ font: 12px var(--mono);
+ letter-spacing: .04em;
+ color: var(--tour-muted);
+}
+.tour-credits a:hover { color: var(--tour-fg); text-decoration: underline; }
+.tour-motion .tour-end > * { animation: tour-rise 900ms cubic-bezier(.2, .7, .1, 1) both; }
+.tour-motion .tour-end > :nth-child(2) { animation-delay: 120ms; }
+.tour-motion .tour-end > :nth-child(3) { animation-delay: 220ms; }
+.tour-motion .tour-end > :nth-child(4) { animation-delay: 340ms; }
+.tour-motion .tour-end > :nth-child(5),
+.tour-motion .tour-end > :nth-child(6) { animation-delay: 460ms; }
+
+.tour-bottom {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 5;
+ padding: 0 32px 18px;
+}
+.tour-progress {
+ display: flex;
+ gap: 4px;
+ height: 3px;
+}
+.tour-progress span {
+ position: relative;
+ flex-basis: 0;
+ overflow: hidden;
+ border-radius: 2px;
+ background: #ffffff2b;
+}
+.tour-progress span.done { background: #ffffffd9; }
+.tour-progress span i {
+ position: absolute;
+ inset: 0;
+ transform-origin: left;
+ background: var(--tour-accent);
+ animation-name: tour-fill;
+ animation-timing-function: linear;
+ animation-fill-mode: both;
+}
+@keyframes tour-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
+.tour[data-state="ended"] .tour-progress span i,
+.tour-slideshow .tour-progress span i { animation: none; }
+.tour-disclaimer {
+ margin: 10px 0 0;
+ font: 11px var(--mono);
+ letter-spacing: .05em;
+ color: #ffffffa3;
+}
+
+/* Clean capture: no cursor, no controls; the film loops. */
+.tour-record,
+.tour-record * { cursor: none !important; }
+.tour-record .tour-controls,
+.tour-record .tour-keys { display: none; }
+
+@media (max-width: 640px) {
+ .tour-top { padding: 14px 16px; gap: 8px; }
+ .tour-top .brand strong { font-size: 18px; }
+ .tour-top .brand-mark svg { width: 20px; height: 20px; }
+ .tour-style-chip { display: none; }
+ .tour-controls { gap: 6px; }
+ .tour-controls button { min-height: 36px; padding: 0 10px; }
+ .tour-controls button span { display: none; }
+ .tour-caption { left: 16px; right: 16px; bottom: 76px; max-width: none; }
+ .tour-caption h1 { font-size: 30px; margin: 10px 0 8px; }
+ .tour-caption p { font-size: 14px; }
+ .tour-meta { margin-top: 12px; }
+ .tour-stage::after { background: radial-gradient(ellipse 90% 70% at 50% 40%, #0000 50%, #000a 100%); }
+ .tour-letterbox.bottom { height: 46vh; }
+ .tour-bottom { padding: 0 16px 14px; }
+ .tour-keys,
+ .tour-snapshot { display: none; }
+ .tour-end p { font-size: 15px; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .tour *,
+ .tour *::before,
+ .tour *::after { animation: none !important; transition: none !important; }
+ .tour-fade { display: none; }
+}
diff --git a/src/world/WorldCanvas.tsx b/src/world/WorldCanvas.tsx
index acca3f7..abea660 100644
--- a/src/world/WorldCanvas.tsx
+++ b/src/world/WorldCanvas.tsx
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Minus, Plus, Maximize2 } from "lucide-react";
import type { StyleId, WorldEdge, WorldSnapshot } from "../contracts";
-import { layoutWorld, mapBounds, pickNode, type PlacedNode } from "./layout";
+import { layoutWorld, mapBounds, pickNode, type LabelRect, type PlacedNode } from "./layout";
import { paintTerrain, paintConnections, paintBuilding, paintLabels, PALETTES } from "./paint";
import { setSunlight, setWind } from "./primitives";
import { sceneryAnchors, type SceneryAnchors } from "./terrain";
@@ -29,6 +29,18 @@ export interface WorldCanvasProps {
className?: string;
onRendered?: () => void;
paused?: boolean;
+ /** Optional programmatic camera (e.g. a guided tour); omitted = classic fit-to-world behaviour. */
+ camera?: CameraTarget | null;
+}
+/** Frames one district (`nodeId`) or the whole world (`null`) at `zoom`× the fitted scale, placed
+ * at `anchor` (viewport fractions, default centre). The view snaps when the layout changes or
+ * motion is reduced/paused and otherwise eases with `glide` (ms time constant, default 120).
+ * Reset returns to this target. */
+export interface CameraTarget {
+ nodeId: string | null;
+ zoom?: number;
+ glide?: number;
+ anchor?: readonly [number, number];
}
type Camera = { x: number; y: number; k: number };
const MIN_ZOOM = 0.18,
@@ -36,7 +48,7 @@ const MIN_ZOOM = 0.18,
/** Frame budgets with ~2ms slack so a 60Hz display is not halved by RAF jitter. */
const FAST_FRAME = 1000 / 60 - 2,
SLOW_FRAME = 1000 / 30 - 2;
-export function WorldCanvas({ world, style, selectedId, onSelect, className = "", onRendered, paused = false }: WorldCanvasProps) {
+export function WorldCanvas({ world, style, selectedId, onSelect, className = "", onRendered, paused = false, camera = null }: WorldCanvasProps) {
const canvas = useRef(null),
box = useRef(null),
/** Rendered camera (eases toward `goal` when motion is allowed; equals it otherwise). */
@@ -48,9 +60,9 @@ export function WorldCanvas({ world, style, selectedId, onSelect, className = ""
lastInput = useRef(0),
moving = useRef(false);
const drag = useRef<{ id: number; x: number; y: number; cx: number; cy: number; moved: boolean; lx: number; ly: number; lt: number; vx: number; vy: number } | null>(null);
- const data = useRef({ world, style, selectedId, onSelect, onRendered, paused });
+ const data = useRef({ world, style, selectedId, onSelect, onRendered, paused, camera });
const renderedIdentity = useRef("");
- data.current = { world, style, selectedId, onSelect, onRendered, paused };
+ data.current = { world, style, selectedId, onSelect, onRendered, paused, camera };
const layoutIdentity = useRef("");
const invalidate = useRef(() => {}), reconcileMotion = useRef(() => {});
const placed = useRef([]),
@@ -70,9 +82,23 @@ export function WorldCanvas({ world, style, selectedId, onSelect, className = ""
k = Math.min(w / b.width, h / b.height) * 0.88;
return { k, x: w / 2 - (b.x + b.width / 2) * k, y: h / 2 - (b.y + b.height / 2) * k };
};
+ /** The fitted view, or the programmatic camera target when one is supplied. */
+ const home = (): Camera => {
+ const f = fit(),
+ target = data.current.camera;
+ if (!target) return f;
+ const { w, h } = size.current,
+ k = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, f.k * (target.zoom ?? 1))),
+ node = target.nodeId ? placed.current.find((p) => p.node.id === target.nodeId) : undefined,
+ b = mapBounds(placed.current),
+ cx = node ? node.x : b.x + b.width / 2,
+ cy = node ? node.y - node.height * 0.45 : b.y + b.height / 2,
+ [ax, ay] = target.anchor ?? [0.5, 0.5];
+ return { k, x: w * ax - cx * k, y: h * ay - cy * k };
+ };
const reset = (snap = false) => {
inertia.current = { x: 0, y: 0 };
- aim(fit(), snap);
+ aim(home(), snap);
};
useEffect(() => {
const identity = `${world.repo.fullName}:${style}:${world.nodes
@@ -104,6 +130,10 @@ export function WorldCanvas({ world, style, selectedId, onSelect, className = ""
if (x < 100 || x > w - 100 || y < 125 || y > h - 85) aim({ ...c, x: w / 2 - node.x * c.k, y: h / 2 + 30 - node.y * c.k }, true);
invalidate.current();
}, [selectedId]);
+ useEffect(() => {
+ // Declared after the layout/selection effects so a programmatic target wins the same commit.
+ if (camera) reset();
+ }, [camera?.nodeId, camera?.zoom, camera?.anchor?.[0], camera?.anchor?.[1]]);
useEffect(() => {
const cv = canvas.current,
el = box.current;
@@ -118,6 +148,18 @@ export function WorldCanvas({ world, style, selectedId, onSelect, className = ""
let drift = 0, cost = 0, slow = false, frames = 0;
const retro = document.createElement("canvas"), retroContext = retro.getContext("2d")!;
const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
+ // HTML chrome over the canvas (marked data-canvas-occluder, plus our own
+ // controls). Measured on resize only, never inside a frame.
+ let blocked: LabelRect[] = [];
+ const occluders = () => [
+ ...(el.parentElement?.querySelectorAll(":scope > [data-canvas-occluder]") ?? []),
+ ...el.querySelectorAll(".camera-controls, .map-hint, .map-coordinates"),
+ ];
+ const measureOccluders = () => {
+ const r = el.getBoundingClientRect();
+ blocked = occluders().map((o) => o.getBoundingClientRect()).filter((b) => b.width > 0 && b.height > 0)
+ .map((b) => ({ id: "", x: b.left - r.left, y: b.top - r.top, width: b.width, height: b.height }));
+ };
const animated = () => !motion.matches && !data.current.paused;
let previousMode = animated();
moving.current = previousMode;
@@ -142,6 +184,7 @@ export function WorldCanvas({ world, style, selectedId, onSelect, className = ""
const resize = () => {
const r = el.getBoundingClientRect(), old = size.current;
if (r.width < 1 || r.height < 1) { stop(); return; }
+ measureOccluders();
if (sized && r.width === old.w && r.height === old.h) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
size.current = { w: r.width, h: r.height };
@@ -159,7 +202,7 @@ export function WorldCanvas({ world, style, selectedId, onSelect, className = ""
v.x *= decay; v.y *= decay;
if (Math.hypot(v.x, v.y) < 0.01) inertia.current = { x: 0, y: 0 };
}
- const a = 1 - Math.exp(-dt / 120);
+ const a = 1 - Math.exp(-dt / (data.current.camera?.glide ?? 120));
c.x += (target.x - c.x) * a; c.y += (target.y - c.y) * a; c.k *= Math.pow(target.k / c.k, a);
if (Math.abs(target.x - c.x) < 0.05 && Math.abs(target.y - c.y) < 0.05 && Math.abs(target.k / c.k - 1) < 1e-4) cam.current = { ...target };
// Gentle idle drift fades in after a few seconds without input, and out on any input.
@@ -269,7 +312,7 @@ export function WorldCanvas({ world, style, selectedId, onSelect, className = ""
vignette.addColorStop(1, s === "civilization" ? "#514b3026" : "#07101666");
g.fillStyle = vignette;
g.fillRect(0, 0, width, height);
- paintLabels(g, placed.current, s, sel, hover.current, c);
+ paintLabels(g, placed.current, s, sel, hover.current, c, { width, height, blocked });
const identity = JSON.stringify([w.repo.fullName, w.headSha, w.collectedAt, w.source]);
if (identity !== renderedIdentity.current) { renderedIdentity.current = identity; data.current.onRendered?.(); }
// Adaptive quality: hold 60fps when the frame is cheap, fall back to 30fps when it is not.
@@ -280,6 +323,8 @@ export function WorldCanvas({ world, style, selectedId, onSelect, className = ""
request();
};
const observer = new ResizeObserver(resize); observer.observe(el); resize();
+ const chrome = new ResizeObserver(() => { measureOccluders(); dirty = true; request(); });
+ for (const o of occluders()) chrome.observe(o);
const intersection = new IntersectionObserver(entries => { visible = entries[0]?.isIntersecting ?? false; if (visible) { dirty = true; request(); } else stop(); });
intersection.observe(el);
const visibility = () => { if (document.hidden) stop(); else { dirty = true; request(); } };
@@ -298,7 +343,7 @@ export function WorldCanvas({ world, style, selectedId, onSelect, className = ""
cv.addEventListener("wheel", wheel, { passive: false });
return () => {
stopped = true; stop();
- observer.disconnect(); intersection.disconnect();
+ observer.disconnect(); intersection.disconnect(); chrome.disconnect();
document.removeEventListener("visibilitychange", visibility); motion.removeEventListener("change", media);
invalidate.current = () => {}; reconcileMotion.current = () => {};
cv.removeEventListener("wheel", wheel);
diff --git a/src/world/layout.ts b/src/world/layout.ts
index fac89fa..b98bc39 100644
--- a/src/world/layout.ts
+++ b/src/world/layout.ts
@@ -92,24 +92,43 @@ export interface LabelRect {
width: number;
height: number;
}
-/** Keep the selected/hovered label first; zooming progressively reveals the rest. */
-export function declutterLabels(labels: LabelRect[], priority: (string | null)[]): LabelRect[] {
+/** Where labels may go: the visible canvas minus HTML chrome drawn over it. */
+export interface LabelView {
+ width: number;
+ height: number;
+ /** Screen rectangles covered by overlays (heading, controls, style switcher). */
+ blocked?: LabelRect[];
+ /** Share of the canvas area that label plates may cover (priority culling). */
+ coverage?: number;
+ /** Minimum clear space between plates. */
+ gap?: number;
+}
+const overlaps = (a: LabelRect, b: LabelRect, gap: number) =>
+ a.x < b.x + b.width + gap && a.x + a.width + gap > b.x && a.y < b.y + b.height + gap && a.y + a.height + gap > b.y;
+/** Keep the selected/hovered label first; zooming progressively reveals the rest.
+ * With a view, labels must also sit fully on screen, avoid overlays and fit an
+ * area budget, so small screens show fewer, more important names. Priority
+ * labels (selected/hovered) are never culled by the budget or overlays. */
+export function declutterLabels(labels: LabelRect[], priority: (string | null)[], view?: LabelView): LabelRect[] {
const rank = (id: string) => {
const i = priority.indexOf(id);
return i < 0 ? priority.length : i;
};
+ const gap = view?.gap ?? 4;
+ const budget = view ? view.width * view.height * (view.coverage ?? 1) : Infinity;
+ let used = 0;
const accepted: LabelRect[] = [];
for (const label of labels.toSorted((a, b) => rank(a.id) - rank(b.id))) {
- if (
- !accepted.some(
- (r) =>
- label.x < r.x + r.width + 4 &&
- label.x + label.width + 4 > r.x &&
- label.y < r.y + r.height + 4 &&
- label.y + label.height + 4 > r.y,
- )
- )
- accepted.push(label);
+ const important = rank(label.id) < priority.length;
+ if (view && !important) {
+ const margin = 4;
+ if (label.x < margin || label.y < margin || label.x + label.width > view.width - margin || label.y + label.height > view.height - margin) continue;
+ if (view.blocked?.some((r) => overlaps(label, r, 2))) continue;
+ if (used + label.width * label.height > budget) continue;
+ }
+ if (accepted.some((r) => overlaps(label, r, gap))) continue;
+ accepted.push(label);
+ used += label.width * label.height;
}
return accepted;
}
diff --git a/src/world/paint.ts b/src/world/paint.ts
index 080c883..9c1da60 100644
--- a/src/world/paint.ts
+++ b/src/world/paint.ts
@@ -1,5 +1,5 @@
import type { StyleId, WorldEdge } from "../contracts";
-import { declutterLabels, type PlacedNode } from "./layout";
+import { declutterLabels, type LabelRect, type PlacedNode } from "./layout";
import { drawArchitecture } from "./art";
import { ellipse, line, type Pen } from "./primitives";
import { terrain } from "./terrain";
@@ -164,23 +164,24 @@ export function paintBuilding(g: Pen, pn: PlacedNode, style: StyleId, selected:
ellipse(g, -51, 17, 3, 2.5, STATUS[pn.node.status]);
g.restore();
}
-/** Labels are painted last in screen space, so roofs and vignette never obscure them. */
-function labelMetrics(g: Pen, pn: PlacedNode, selected: boolean, _scale: number) {
- const maxWidth = selected ? 260 : 180;
- g.font = `${selected ? 600 : 500} 13px "IBM Plex Mono",ui-monospace,SFMono-Regular,Consolas,monospace`;
+/** Labels are painted last in screen space, so roofs and vignette never obscure them.
+ * Compact plates (narrow canvases) use a smaller font and a shorter plate. */
+function labelMetrics(g: Pen, pn: PlacedNode, selected: boolean, compact: boolean) {
+ const maxWidth = selected ? (compact ? 200 : 260) : compact ? 128 : 180;
+ g.font = `${selected ? 600 : 500} ${compact ? 11 : 13}px "IBM Plex Mono",ui-monospace,SFMono-Regular,Consolas,monospace`;
let label = pn.node.label;
if (g.measureText(label).width > maxWidth) {
while (label.length > 1 && g.measureText(label + "…").width > maxWidth) label = label.slice(0, -1);
label += "…";
}
- return { label, width: g.measureText(label).width + 16 };
+ return { label, width: g.measureText(label).width + (compact ? 12 : 16), height: compact ? 22 : 28 };
}
-export function paintLabel(g: Pen, pn: PlacedNode, style: StyleId, selected: boolean, x: number, y: number, scale: number) {
+export function paintLabel(g: Pen, pn: PlacedNode, style: StyleId, selected: boolean, x: number, y: number, compact = false) {
g.save();
- const { label, width } = labelMetrics(g, pn, selected, scale);
+ const { label, width, height } = labelMetrics(g, pn, selected, compact);
g.fillStyle = selected ? MAP_CONTRAST.labelSelectedPlate : MAP_CONTRAST.labelPlate;
g.beginPath();
- g.roundRect(x - width / 2, y, width, 28, 3);
+ g.roundRect(x - width / 2, y, width, height, 3);
g.fill();
g.strokeStyle = selected ? MAP_CONTRAST.labelSelectedBorder : MAP_CONTRAST.border;
g.lineWidth = selected ? 2 : 1;
@@ -188,9 +189,11 @@ export function paintLabel(g: Pen, pn: PlacedNode, style: StyleId, selected: boo
g.fillStyle = selected ? MAP_CONTRAST.labelSelectedText : MAP_CONTRAST.labelText;
g.textAlign = "center";
g.textBaseline = "middle";
- g.fillText(label, x, y + 14);
+ g.fillText(label, x, y + height / 2);
g.restore();
}
+/** Narrow canvases switch to compact plates and a tighter label budget. */
+const COMPACT_WIDTH = 640;
export function paintLabels(
g: Pen,
nodes: PlacedNode[],
@@ -202,12 +205,18 @@ export function paintLabels(
y: number;
k: number;
},
+ view?: { width: number; height: number; blocked?: LabelRect[] },
) {
const { x, y, k } = camera;
- const labels = nodes.map((p) => {
- const { width } = labelMetrics(g, p, p.node.id === selectedId, k);
- return { id: p.node.id, x: p.x * k + x - width / 2, y: (p.y + 34) * k + y, width, height: 28 };
+ const compact = !!view && view.width < COMPACT_WIDTH;
+ const metrics = new Map(nodes.map((p) => [p.node.id, labelMetrics(g, p, p.node.id === selectedId, compact)]));
+ // Larger districts claim label space first; ties keep the source order.
+ const labels = nodes.toSorted((a, b) => b.node.size - a.node.size).map((p) => {
+ const { width, height } = metrics.get(p.node.id)!;
+ return { id: p.node.id, x: p.x * k + x - width / 2, y: (p.y + 34) * k + y, width, height };
});
- const visible = new Set(declutterLabels(labels, [selectedId, hoverId]).map((r) => r.id));
- for (const p of nodes) if (visible.has(p.node.id)) paintLabel(g, p, style, p.node.id === selectedId, p.x * k + x, (p.y + 34) * k + y, k);
+ const visible = new Set(declutterLabels(labels, [selectedId, hoverId], view && {
+ ...view, coverage: compact ? 0.09 : 0.14, gap: compact ? 8 : 6,
+ }).map((r) => r.id));
+ for (const p of nodes) if (visible.has(p.node.id)) paintLabel(g, p, style, p.node.id === selectedId, p.x * k + x, (p.y + 34) * k + y, compact);
}
diff --git a/test/og.test.ts b/test/og.test.ts
new file mode 100644
index 0000000..8a62aec
--- /dev/null
+++ b/test/og.test.ts
@@ -0,0 +1,230 @@
+import { expect, spyOn, test } from "bun:test";
+import { inflateSync } from "node:zlib";
+import { createElement, Fragment } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { STYLE_IDS, type WorldSnapshot } from "../src/contracts";
+import { renderHomeCard, renderWorldCard, TAGLINE, type CardWorld } from "../src/og/card";
+import { asciiShare, drawableText } from "../src/og/font";
+import { homeMeta, normalizeOrigin, validRepoParams, worldMeta } from "../src/og/meta";
+import { crc32, encodePng } from "../src/og/png";
+import { RenderBudgetError } from "../src/og/raster";
+import { parseRepo } from "../src/server/github";
+import { createOgImageService, imageResponse, OG_CACHE_FALLBACK, OG_CACHE_HOME, OG_CACHE_WORLD, siteLabel } from "../src/server/og-image";
+import { resolvePublicOrigin } from "../src/server/public-origin";
+import { syntheticWorld } from "./fixtures/browser-world";
+
+const ORIGIN = "https://share.example";
+type Tag = { title?: string; name?: string; property?: string; content?: string };
+const content = (meta: Tag[], key: string) => meta.find((m) => m.name === key || m.property === key)?.content;
+
+function chunks(png: Uint8Array) {
+ const view = new DataView(png.buffer, png.byteOffset, png.byteLength), out: { type: string; data: Uint8Array; crcOk: boolean }[] = [];
+ for (let o = 8; o < png.length;) {
+ const length = view.getUint32(o), type = String.fromCharCode(...png.subarray(o + 4, o + 8));
+ const data = png.subarray(o + 8, o + 8 + length);
+ out.push({ type, data, crcOk: crc32(png.subarray(o + 4, o + 8 + length)) === view.getUint32(o + 8 + length) });
+ o += 12 + length;
+ }
+ return out;
+}
+function expectPng(png: Uint8Array, width: number, height: number) {
+ expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ const list = chunks(png);
+ expect(list.map((c) => c.type)).toEqual(["IHDR", "IDAT", "IEND"]);
+ expect(list.every((c) => c.crcOk)).toBe(true);
+ const ihdr = new DataView(list[0].data.buffer, list[0].data.byteOffset, 13);
+ expect([ihdr.getUint32(0), ihdr.getUint32(4), list[0].data[8], list[0].data[9]]).toEqual([width, height, 8, 2]);
+ expect(inflateSync(list[1].data).length).toBe((width * 3 + 1) * height);
+}
+/** SYNTHETIC verified-source snapshot: test-only flag, never live GitHub evidence. */
+function verifiedWorld(headSha = "1234567890abcdef1234567890abcdef12345678"): WorldSnapshot {
+ const world = syntheticWorld();
+ world.source = "github";
+ world.headSha = headSha;
+ world.refreshAfter = Date.now() + 60_000;
+ return world;
+}
+function card(): CardWorld {
+ const world = syntheticWorld();
+ return { fullName: world.repo.fullName, nodes: world.nodes, edges: world.edges, totalNodes: world.nodes.length, totalEdges: world.edges.length, stars: 42, language: "TypeScript", description: world.repo.description };
+}
+
+test("encodePng emits a valid RGB PNG with correct CRCs and scanlines", () => {
+ expect(crc32(new TextEncoder().encode("IEND"))).toBe(0xae426082);
+ const rgb = new Uint8Array(5 * 3 * 3).map((_, i) => (i * 37) & 0xff);
+ expectPng(encodePng(5, 3, rgb), 5, 3);
+ expect(() => encodePng(5, 3, new Uint8Array(4))).toThrow();
+});
+
+test("SYNTHETIC world and homepage cards are 1200x630 PNGs in every style", () => {
+ for (const style of STYLE_IDS) expectPng(renderWorldCard(card(), style), 1200, 630);
+ expectPng(renderHomeCard({ site: "share.example/github-factory" }), 1200, 630);
+ expect(TAGLINE).toBe("Every codebase. A world of its own.");
+});
+
+test("rendering honours its CPU deadline", () => {
+ expect(() => renderWorldCard(card(), "factorio", { deadline: performance.now() - 1 })).toThrow(RenderBudgetError);
+});
+
+test("image text is reduced to the drawable ASCII glyph set", () => {
+ expect(drawableText("Café “quoted”", 100)).toBe('Cafe "quoted"');
+ for (const ch of drawableText("Café “quoted” — 日本 \u0000 🚀", 100)) expect(ch.charCodeAt(0) < 0x7f || "·…★•".includes(ch)).toBe(true);
+ expect(drawableText("x".repeat(500), 40).length).toBeLessThanOrEqual(40);
+ expect(asciiShare("日本語のリポジトリ")).toBeLessThan(0.5);
+});
+
+test("home meta uses absolute origin + base path for page and image", () => {
+ const meta = homeMeta(ORIGIN) as Tag[];
+ expect(content(meta, "og:url")).toBe(`${ORIGIN}/github-factory/`);
+ expect(content(meta, "og:image")).toBe(`${ORIGIN}/github-factory/og/home.png`);
+ expect(content(meta, "twitter:image")).toBe(`${ORIGIN}/github-factory/og/home.png`);
+ expect(content(meta, "twitter:card")).toBe("summary_large_image");
+ expect(content(meta, "theme-color")).toMatch(/^#[0-9a-f]{6}$/);
+ expect(meta.find((m) => m.title)?.title).toBe("Moon Factory — Your codebase, a living world");
+ expect(content(meta, "og:description")).toContain("Every codebase. A world of its own.");
+});
+
+test("world meta is per repository and style; invalid params fall back to generic meta", () => {
+ const meta = worldMeta(ORIGIN, "openclaw", "openclaw", "factorio") as Tag[];
+ expect(meta.find((m) => m.title)?.title).toBe("openclaw/openclaw as a Factorio world · Moon Factory");
+ expect(content(meta, "og:url")).toBe(`${ORIGIN}/github-factory/world/openclaw/openclaw?style=factorio`);
+ expect(content(meta, "og:image")).toBe(`${ORIGIN}/github-factory/og/world/openclaw/openclaw?style=factorio`);
+ const unstyled = worldMeta(ORIGIN, "vercel", "next.js", "', "x"], ["ok", "..",], ["-bad", "x"], ["ok", "a/b"], ["ok", "x".repeat(101)], [undefined, "x"]])
+ expect(worldMeta(ORIGIN, owner, repo, "factorio")).toEqual(homeMeta(ORIGIN));
+});
+
+test("rendered meta escapes every attribute value", () => {
+ const hostile = '">&';
+ const meta = [...(worldMeta(ORIGIN, hostile, hostile, hostile) as Tag[]), { name: "x", content: hostile }];
+ const html = renderToStaticMarkup(createElement(Fragment, null, ...meta.filter((m) => !m.title).map((m, i) => createElement("meta", { key: i, ...m }))));
+ expect(html).not.toContain("