Edit the code — the preview recompiles live (Solid, Vue Vapor or Octane + Tailwind + baked fonts → a .pak,
- rendered by the Rust core in WebAssembly). Click the screen, then use the on-screen controls, or the
+ rendered by the Rust core in WebAssembly). Click the PSP, then use its controls, or the
keyboard: arrows = d-pad · Z/Enter = ◯ · X = ✕ · A = ▢ · S = △.
diff --git a/site/playground/playground.js b/site/playground/playground.js
index d3c41859..babf9f58 100644
--- a/site/playground/playground.js
+++ b/site/playground/playground.js
@@ -16,7 +16,7 @@ import { EditorView, basicSetup } from "codemirror";
import { EditorState } from "@codemirror/state";
import { javascript } from "@codemirror/lang-javascript";
import { oneDark } from "@codemirror/theme-one-dark";
-import { PocketHost, BTN } from "./host.js";
+import { PocketHost } from "./host.js";
// Dynamic-import the heavy (3 MB) compiler + the shared runtime lazily, with
// computed specifiers so the bundler leaves them external (served from /pg/).
@@ -37,6 +37,8 @@ async function main() {
const frameworkBtns = [...document.querySelectorAll("[data-framework]")];
const runBtn = $("#pg-run");
const resetBtn = $("#pg-reset");
+ const stageRoot = $("[data-playground-stage]");
+ const stageKeyboardTarget = stageRoot.querySelector("[data-stage-viewport]");
const setStatus = (s, kind = "") => {
statusEl.textContent = s;
@@ -49,11 +51,39 @@ async function main() {
// --- host -----------------------------------------------------------------
const host = new PocketHost();
- await host.mount(canvas, {
+ let stageController = null;
+ const hostReady = host.mount(canvas, {
wasmUrl: PG + "pocketjs.wasm",
+ keyboardTarget: stageKeyboardTarget,
onError: (e) => showError(String(e && e.stack ? e.stack : e)),
onLog: () => {},
+ onBlit: () => stageController?.refreshScreen(),
});
+ stageRoot.addEventListener("pointerdown", () => stageKeyboardTarget.focus(), true);
+ canvas.addEventListener("click", () => stageKeyboardTarget.focus());
+ await hostReady;
+
+ // The same authored GLB, camera, screen material, and raycast controls used
+ // by the landing page wrap this host's live framebuffer. Loading the shell
+ // separately keeps Three.js out of the editor bundle and lets compilation
+ // start even while the model is still arriving.
+ void import("/assets/pocket-stage-web.js")
+ .then(({ mountPocketStage }) => mountPocketStage(stageRoot, {
+ host,
+ readyText: "Playground PSP ready",
+ errorText: "The PSP model could not be loaded. Using the 2D preview.",
+ receiptName: "__playgroundStageReceipt",
+ }))
+ .then((controller) => {
+ stageController = controller;
+ stageController?.refreshScreen();
+ })
+ .catch((error) => {
+ stageRoot.classList.add("has-error");
+ const stageStatus = stageRoot.querySelector("[data-stage-status]");
+ if (stageStatus) stageStatus.textContent = "The PSP model could not be loaded. Using the 2D preview.";
+ console.error("Playground PSP module failed", error);
+ });
// --- editor ---------------------------------------------------------------
let compileTimer = 0;
@@ -151,6 +181,10 @@ async function main() {
globalThis.__pgDispose?.();
} catch {}
globalThis.__pgDispose = null;
+ // A model tap may still be waiting for the old guest's next tick. End it
+ // before reset() clears that callback so neither the host bit nor the
+ // Stage pointer latch can leak into the newly compiled app.
+ stageController?.releaseInput();
host.reset();
globalThis.__pgStyles = result.styleMap;
globalThis.__pgPak = result.pak;
@@ -214,23 +248,6 @@ async function main() {
if (v) setDoc(v.source);
});
- // virtual gamepad
- for (const el of document.querySelectorAll("[data-btn]")) {
- const bit = parseInt(el.dataset.btn, 16);
- const set = (down) => (e) => {
- e.preventDefault();
- el.classList.toggle("is-down", down);
- host.press(bit, down);
- };
- el.addEventListener("mousedown", set(true));
- el.addEventListener("mouseup", set(false));
- el.addEventListener("mouseleave", set(false));
- el.addEventListener("touchstart", set(true), { passive: false });
- el.addEventListener("touchend", set(false));
- el.addEventListener("touchcancel", set(false));
- }
- canvas.addEventListener("click", () => canvas.focus());
-
// boot with the first demo (or a fallback), honoring ?demo=
const boot = new URLSearchParams(location.search).get("demo");
const bootFramework = new URLSearchParams(location.search).get("framework");
diff --git a/site/verify-playground-stage.ts b/site/verify-playground-stage.ts
new file mode 100644
index 00000000..f0e6beb4
--- /dev/null
+++ b/site/verify-playground-stage.ts
@@ -0,0 +1,193 @@
+// Real-browser smoke for the Playground's shared Pocket Stage shell.
+// Serve site/dist first, then run:
+// bun site/verify-playground-stage.ts 'http://127.0.0.1:8140/playground/?demo=hero&framework=solid'
+
+const url = process.argv[2]
+ ?? "http://127.0.0.1:8140/playground/?demo=hero&framework=solid";
+const verify = new URL("./verify.ts", import.meta.url).pathname;
+
+const probe = `(async () => {
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+ const stage = document.querySelector("[data-playground-stage]");
+ const webgl = document.querySelector("canvas[data-stage-canvas]");
+ const framebuffer = document.querySelector("#pg-canvas");
+ const credit = document.querySelector(".pg-stage__credit a");
+ const run = document.querySelector("#pg-run");
+ const status = document.querySelector("#pg-status");
+ const receipt = () => globalThis.__playgroundStageReceipt?.();
+ const readyDeadline = performance.now() + 12000;
+ while (performance.now() < readyDeadline) {
+ if (stage.dataset.ready === "true" && status.dataset.kind === "ok") break;
+ await sleep(100);
+ }
+ // Let the post-load ResizeObserver and the first Stage render settle so the
+ // authored hit proxies and their normalized viewport coordinates agree.
+ await sleep(750);
+ const lowerScreenHash = () => {
+ const context = framebuffer.getContext("2d");
+ const top = Math.floor(framebuffer.height * 0.68);
+ const data = context.getImageData(0, top, framebuffer.width, framebuffer.height - top).data;
+ let value = 2166136261;
+ for (let i = 0; i < data.length; i += 4) {
+ value ^= data[i] | (data[i + 1] << 8) | (data[i + 2] << 16);
+ value = Math.imul(value, 16777619);
+ }
+ return value >>> 0;
+ };
+
+ credit.focus();
+ const enter = new KeyboardEvent("keydown", {
+ key: "Enter",
+ code: "Enter",
+ bubbles: true,
+ cancelable: true,
+ });
+ credit.dispatchEvent(enter);
+ const creditEnterPrevented = enter.defaultPrevented;
+
+ webgl.setPointerCapture = () => {};
+ webgl.releasePointerCapture = () => {};
+ const rect = webgl.getBoundingClientRect();
+ let pointerId = 100;
+ const pointer = (type, point, id) => new PointerEvent(type, {
+ pointerId: id,
+ pointerType: "mouse",
+ isPrimary: true,
+ button: 0,
+ buttons: type === "pointerup" ? 0 : 1,
+ clientX: rect.left + rect.width * point.x,
+ clientY: rect.top + rect.height * point.y,
+ bubbles: true,
+ cancelable: true,
+ });
+ const candidates = [
+ { x: 0.95, y: 0.455 },
+ { x: 0.95, y: 0.49 },
+ { x: 0.925, y: 0.455 },
+ { x: 0.975, y: 0.455 },
+ ];
+ let circlePoint = null;
+ let firstPointerId = null;
+ for (const point of candidates) {
+ const id = pointerId++;
+ webgl.dispatchEvent(pointer("pointerdown", point, id));
+ await sleep(20);
+ if (receipt()?.pressedPart === "btn_circle") {
+ circlePoint = point;
+ firstPointerId = id;
+ break;
+ }
+ webgl.dispatchEvent(pointer("pointerup", point, id));
+ const releaseDeadline = performance.now() + 800;
+ while (performance.now() < releaseDeadline && receipt()?.pressedPart) await sleep(20);
+ }
+ const firstDownPart = receipt()?.pressedPart ?? null;
+
+ // Do not send pointerup: Run must release both the host bit and the Stage
+ // pointer latch before PocketHost.reset() discards afterNextTick callbacks.
+ run.click();
+ let sawBusy = status.dataset.kind === "busy";
+ const deadline = performance.now() + 12000;
+ while (performance.now() < deadline) {
+ sawBusy ||= status.dataset.kind === "busy";
+ if (sawBusy && status.dataset.kind === "ok") break;
+ await sleep(50);
+ }
+ await sleep(100);
+ const afterReset = receipt();
+ const hashBefore = lowerScreenHash();
+
+ const dpadPoint = { x: 0.17, y: 0.44 };
+ const dpadPointerId = pointerId++;
+ webgl.dispatchEvent(pointer("pointerdown", dpadPoint, dpadPointerId));
+ await sleep(30);
+ const navigationPart = receipt()?.pressedPart ?? null;
+ await sleep(50);
+ webgl.dispatchEvent(pointer("pointerup", dpadPoint, dpadPointerId));
+ await sleep(120);
+
+ let secondDownPart = null;
+ let hashAfter = hashBefore;
+ let afterSecond = afterReset;
+ if (circlePoint) {
+ const secondPointerId = pointerId++;
+ webgl.dispatchEvent(pointer("pointerdown", circlePoint, secondPointerId));
+ await sleep(20);
+ secondDownPart = receipt()?.pressedPart ?? null;
+ await sleep(60);
+ webgl.dispatchEvent(pointer("pointerup", circlePoint, secondPointerId));
+ await sleep(180);
+ hashAfter = lowerScreenHash();
+ afterSecond = receipt();
+ }
+
+ const resources = performance.getEntriesByType("resource").map((entry) => new URL(entry.name).pathname);
+ return {
+ stageReady: stage.dataset.ready,
+ hasError: stage.classList.contains("has-error"),
+ creditEnterPrevented,
+ sawBusy,
+ statusKind: status.dataset.kind,
+ status: status.textContent,
+ firstDownPart,
+ firstPointerId,
+ releasedAcrossReset: afterReset?.pressedPart == null,
+ navigationPart,
+ secondDownPart,
+ releasedAfterSecond: afterSecond?.pressedPart == null,
+ lowerFramebufferChanged: hashBefore !== hashAfter,
+ wasmLoads: resources.filter((path) => path.endsWith("/pg/pocketjs.wasm")).length,
+ launcherLoads: resources.filter((path) => path.startsWith("/stage/apps/")),
+ receipt: afterSecond,
+ };
+})()`;
+
+const child = Bun.spawn(
+ [process.execPath, verify, url, "1000", probe],
+ {
+ stdout: "pipe",
+ stderr: "pipe",
+ env: {
+ ...process.env,
+ SHOT: process.env.SHOT ?? "/tmp/pocketjs-playground-stage.png",
+ },
+ },
+);
+const [stdout, stderr, exitCode] = await Promise.all([
+ new Response(child.stdout).text(),
+ new Response(child.stderr).text(),
+ child.exited,
+]);
+if (exitCode !== 0) throw new Error(stderr || stdout || `site verifier exited ${exitCode}`);
+
+const report = JSON.parse(stdout);
+const result = report.probe;
+const checks = {
+ stageReady:
+ result.stageReady === "true"
+ && result.hasError === false
+ && result.statusKind === "ok",
+ sharedPackage:
+ result.receipt?.profileUrl === "/stage/psp-profile.json"
+ && result.receipt?.modelUrl === "/stage/psp_lod3_eco.glb"
+ && result.receipt?.screenCanvasId === "pg-canvas",
+ onePlaygroundRuntime: result.wasmLoads === 1 && result.launcherLoads.length === 0,
+ creditKeepsEnter: result.creditEnterPrevented === false,
+ resetReleasesModelInput:
+ result.sawBusy
+ && result.firstDownPart === "btn_circle"
+ && result.releasedAcrossReset
+ && result.navigationPart === "dpad_right"
+ && result.secondDownPart === "btn_circle"
+ && result.releasedAfterSecond,
+ liveFramebuffer: result.lowerFramebufferChanged && result.receipt?.screenUploads > 1,
+ noBrowserErrors: report.pageErrors.length === 0 && report.consoleErrors.length === 0,
+};
+const failures = Object.entries(checks)
+ .filter(([, passed]) => !passed)
+ .map(([name]) => name);
+
+console.log(JSON.stringify({ ...report, checks }, null, 2));
+if (failures.length) {
+ throw new Error(`Playground Stage smoke failed: ${failures.join(", ")}`);
+}
diff --git a/tests/site-stage.test.ts b/tests/site-stage.test.ts
index 556cc9c8..68f93442 100644
--- a/tests/site-stage.test.ts
+++ b/tests/site-stage.test.ts
@@ -96,6 +96,56 @@ test("homepage declares the live launcher and visible attributions", () => {
}
});
+test("playground wraps its live framebuffer in the homepage PSP model", () => {
+ const home = readFileSync(ROOT + "site/home.html", "utf8");
+ const playground = readFileSync(ROOT + "site/playground/page.html", "utf8");
+ for (const marker of [
+ "data-pocket-stage",
+ "data-stage-viewport",
+ "data-stage-canvas",
+ "data-stage-screen",
+ "data-stage-status",
+ ]) {
+ expect(home).toContain(marker);
+ expect(playground).toContain(marker);
+ }
+ expect(playground).toContain('id="pg-canvas" class="pg-stage__screen" data-stage-screen');
+ expect(playground).toContain("Dibad");
+ expect(playground).toContain("creativecommons.org/licenses/by/4.0");
+ expect(playground).not.toContain("screen-emu");
+ expect(playground).not.toContain("data-btn");
+
+ const homeGlue = readFileSync(ROOT + "site/assets/home.js", "utf8");
+ const playgroundGlue = readFileSync(ROOT + "site/playground/playground.js", "utf8");
+ for (const glue of [homeGlue, playgroundGlue]) {
+ expect(glue).toContain('import("/assets/pocket-stage-web.js")');
+ expect(glue).toContain("mountPocketStage");
+ }
+ expect(playgroundGlue).toContain("host,");
+ expect(playgroundGlue).toContain("stageController?.refreshScreen()");
+ expect(playgroundGlue).toContain("stageController?.releaseInput();\n host.reset();");
+
+ const adapter = readFileSync(ROOT + "site/assets/pocket-stage-web.js", "utf8");
+ expect(adapter).toContain("const stageHost = suppliedHost ?? new PocketHost()");
+ expect(adapter).toContain("if (suppliedHost)");
+ expect(adapter).toContain("const onSuppliedHostError = stageHost.onError");
+ expect(adapter).toContain("releaseButton();\n onSuppliedHostError(error);");
+ expect(adapter).toContain("const modelUrl = STAGE_ROOT + profile.lods.orbit");
+ expect(adapter).toContain("loader.loadAsync(modelUrl)");
+ expect(adapter).toContain("screenCanvasId: screenCanvas.id || null");
+ expect(adapter).toContain("lastPressedPart");
+ expect(adapter).toContain("return { refreshScreen, releaseInput: releaseButton }");
+
+ const build = readFileSync(ROOT + "site/build.ts", "utf8");
+ expect(build).not.toContain("screen.css");
+ expect(existsSync(ROOT + "site/assets/screen.css")).toBe(false);
+
+ const css = readFileSync(ROOT + "site/assets/tailwind.css", "utf8");
+ expect(css).toContain(".pg-stage.has-error .pg-stage__canvas { display: none; }");
+ expect(css).toContain(".pg-stage.has-error .pg-stage__screen");
+ expect(css).toContain(".pg-stage.has-error .pg-stage__viewport:focus-visible .pg-stage__screen");
+});
+
test("homepage and shared pages use one footer description", () => {
const homeTemplate = readFileSync(ROOT + "site/home.html", "utf8");
const siteBuild = readFileSync(ROOT + "site/build.ts", "utf8");
@@ -166,3 +216,22 @@ test("a fast button tap is released only after one guest turn observes it", () =
expect(seen).toEqual([BTN.CIRCLE, 0]);
host.rafId = 0;
});
+
+test("a deferred button release can be canceled before a host reset", () => {
+ const host = new PocketHost();
+ host.wasm = { init() {}, ops: {}, tick() {}, drawHash: () => 0n };
+ host.frameCb = () => {};
+ host.rafId = 1;
+
+ host.press(BTN.CIRCLE, true);
+ const cancelRelease = host.afterNextTick(() => host.press(BTN.CIRCLE, false));
+ const releaseInput = () => {
+ cancelRelease();
+ host.press(BTN.CIRCLE, false);
+ };
+
+ releaseInput();
+ host.rafId = 0;
+ host.reset();
+ expect(host.held & BTN.CIRCLE).toBe(0);
+});
From 56d15ea17ab2738f569e00a2dd1901ffb9a9310f Mon Sep 17 00:00:00 2001
From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com>
Date: Mon, 10 Aug 2026 23:20:33 +0800
Subject: [PATCH 2/2] fix(playground): restore example rendering
---
assets/images/spinner-00.svg | 2 +-
assets/images/spinner-01.svg | 2 +-
assets/images/spinner-02.svg | 2 +-
assets/images/spinner-03.svg | 2 +-
assets/images/spinner-04.svg | 2 +-
assets/images/spinner-05.svg | 2 +-
assets/images/spinner-06.svg | 2 +-
assets/images/spinner-07.svg | 2 +-
assets/images/spinner-atlas.svg | 2 +-
site/build.ts | 31 ++++++-
site/playground/compiler-entry.ts | 14 ++-
site/verify-playground-stage.ts | 59 ++++++++++++
site/verify-playground-vue-vapor.ts | 135 ++++++++++++++++++++++++++++
tests/site-stage.test.ts | 58 ++++++++++++
14 files changed, 301 insertions(+), 14 deletions(-)
create mode 100644 site/verify-playground-vue-vapor.ts
diff --git a/assets/images/spinner-00.svg b/assets/images/spinner-00.svg
index 217c70f4..f11dccba 100644
--- a/assets/images/spinner-00.svg
+++ b/assets/images/spinner-00.svg
@@ -1,4 +1,4 @@
-