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 @@ - + diff --git a/assets/images/spinner-01.svg b/assets/images/spinner-01.svg index 8a01408e..86a404b4 100644 --- a/assets/images/spinner-01.svg +++ b/assets/images/spinner-01.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/images/spinner-02.svg b/assets/images/spinner-02.svg index 4a73010f..d8a004c8 100644 --- a/assets/images/spinner-02.svg +++ b/assets/images/spinner-02.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/images/spinner-03.svg b/assets/images/spinner-03.svg index 5bdd2de5..40ac45b2 100644 --- a/assets/images/spinner-03.svg +++ b/assets/images/spinner-03.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/images/spinner-04.svg b/assets/images/spinner-04.svg index 9b21b023..62ef44f7 100644 --- a/assets/images/spinner-04.svg +++ b/assets/images/spinner-04.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/images/spinner-05.svg b/assets/images/spinner-05.svg index 81f1e88e..5e5ad635 100644 --- a/assets/images/spinner-05.svg +++ b/assets/images/spinner-05.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/images/spinner-06.svg b/assets/images/spinner-06.svg index 7028e772..ad7f5c3a 100644 --- a/assets/images/spinner-06.svg +++ b/assets/images/spinner-06.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/images/spinner-07.svg b/assets/images/spinner-07.svg index 483d31a6..45a49633 100644 --- a/assets/images/spinner-07.svg +++ b/assets/images/spinner-07.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/images/spinner-atlas.svg b/assets/images/spinner-atlas.svg index dd3ade41..aacf034c 100644 --- a/assets/images/spinner-atlas.svg +++ b/assets/images/spinner-atlas.svg @@ -2,7 +2,7 @@ baked into the circle coordinates because bake-svg scans shapes flat (no ). Regenerate via the block in tools/gen-demo-covers.ts history or by re-offsetting assets/images/spinner-XX.svg. --> - + diff --git a/site/assets/pocket-stage-web.js b/site/assets/pocket-stage-web.js index 1cdcf701..6e6afedd 100644 --- a/site/assets/pocket-stage-web.js +++ b/site/assets/pocket-stage-web.js @@ -172,7 +172,14 @@ function easeInOut(t) { return t * t * (3 - 2 * t); } -export async function mountPocketStage(root) { +/** + * Mount the authored PSP model around a PocketHost framebuffer. + * + * The homepage owns its launcher host, while the Playground supplies the host + * that already owns the live-compiled app. Keeping both paths here means the + * GLB, screen material, camera, and authored button hit regions stay identical. + */ +export async function mountPocketStage(root, options = {}) { const viewport = root.querySelector("[data-stage-viewport]"); const canvas = root.querySelector("[data-stage-canvas]"); const screenCanvas = root.querySelector("[data-stage-screen]"); @@ -190,7 +197,7 @@ export async function mountPocketStage(root) { }); } catch (error) { root.classList.add("has-error"); - status.textContent = "Interactive 3D is unavailable in this browser."; + status.textContent = options.errorText ?? "Interactive 3D is unavailable in this browser."; console.error("Pocket Stage WebGL startup failed", error); return; } @@ -237,9 +244,12 @@ export async function mountPocketStage(root) { let focused = false; let savedDeskPose = null; let pressed = null; + let lastPressedPart = null; let cancelRelease = null; let wheelSnapTimer = 0; let ready = false; + let screenUploads = 0; + const suppliedHost = options.host ?? null; const renderNow = () => { renderRaf = 0; @@ -258,6 +268,13 @@ export async function mountPocketStage(root) { renderRaf = requestAnimationFrame(renderNow); }; + const refreshScreen = () => { + if (!screenTexture) return; + screenTexture.needsUpdate = true; + screenUploads++; + invalidate(); + }; + const resize = () => { const width = Math.max(1, viewport.clientWidth); const height = Math.max(1, viewport.clientHeight); @@ -370,6 +387,7 @@ export async function mountPocketStage(root) { event.preventDefault(); event.stopImmediatePropagation(); pressed = { bit, pointerId: event.pointerId, tickAtPress: host.tickCount }; + lastPressedPart = part.name; root.dataset.pressedPart = part.name; canvas.setPointerCapture(event.pointerId); host.press(bit, true); @@ -416,7 +434,10 @@ export async function mountPocketStage(root) { inViewport = visible; if (!visible || document.hidden) { releaseButton(); - host?.stop(); + // A supplied host belongs to the Playground compiler lifecycle. The + // shell may pause its own WebGL work, but it must not stop an app that + // has just been reset or started outside this adapter. + if (!suppliedHost) host?.stop(); if (renderRaf) cancelAnimationFrame(renderRaf); renderRaf = 0; if (cameraRaf) cancelAnimationFrame(cameraRaf); @@ -424,7 +445,7 @@ export async function mountPocketStage(root) { controls.enabled = !focused; return; } - host?.wake(); + if (!suppliedHost) host?.wake(); invalidate(); }; @@ -436,29 +457,35 @@ export async function mountPocketStage(root) { document.addEventListener("visibilitychange", () => setVisible(inViewport)); try { - const stageHost = new PocketHost(); + const stageHost = suppliedHost ?? new PocketHost(); host = stageHost; - let textureReady = false; - const hostReady = stageHost.mount(screenCanvas, { - wasmUrl: "/pg/pocketjs.wasm", - keyboardTarget: canvas, - showHud: false, - idleAfterMs: 1200, - onBlit: () => { - if (!textureReady || !screenTexture) return; - screenTexture.needsUpdate = true; - invalidate(); - }, - onError: (error) => { + if (suppliedHost) { + const onSuppliedHostError = stageHost.onError; + stageHost.onError = (error) => { releaseButton(); - root.classList.add("has-error"); - status.textContent = "The Pocket app stopped unexpectedly."; - console.error("Pocket Stage guest failed", error); - }, - }); - - const profileResponse = await fetch(STAGE_ROOT + "psp-profile.json").then(failResponse); + onSuppliedHostError(error); + }; + } + const hostReady = suppliedHost + ? Promise.resolve(stageHost) + : stageHost.mount(screenCanvas, { + wasmUrl: "/pg/pocketjs.wasm", + keyboardTarget: canvas, + showHud: false, + idleAfterMs: 1200, + onBlit: refreshScreen, + onError: (error) => { + releaseButton(); + root.classList.add("has-error"); + status.textContent = "The Pocket app stopped unexpectedly."; + console.error("Pocket Stage guest failed", error); + }, + }); + + const profileUrl = STAGE_ROOT + "psp-profile.json"; + const profileResponse = await fetch(profileUrl).then(failResponse); const profile = await profileResponse.json(); + const modelUrl = STAGE_ROOT + profile.lods.orbit; // The package's view block is the same camera authority the native // pocket-stage runtime reads; the adapter carries no model facts. const view = profile.view ?? {}; @@ -468,51 +495,61 @@ export async function mountPocketStage(root) { controls.target.fromArray(view.desk_target_mm ?? [0, 0, 0]); controls.update(); focusDistanceMm = view.focus_distance_mm ?? focusDistanceMm; - // The stage boots the Pocket Launcher (docs/LAUNCHER.md) — the same - // multi-app deck the PSP EBOOT ships, on the wasm core. Each app - // arrives as a `.pocket` package (contracts/spec/pocket-package.ts, footer-hash - // verified on decode); the wasm host renders the psp variant, exactly - // like the handheld. apps.json is the registry twin next to them. - const bundleCache = new Map(); - const fetchBundle = async (output) => { - if (!bundleCache.has(output)) { - bundleCache.set( - output, - fetch(STAGE_ROOT + "apps/" + output + ".pocket") - .then(failResponse) - .then((r) => r.arrayBuffer()) - .then((buffer) => { - const pkg = decodePocketPackage(new Uint8Array(buffer)); - const variant = findVariant(pkg, "psp"); - if (!variant) throw new Error(output + ".pocket has no psp variant"); - const js = findSection(variant, POCKET_SECTION.js); - const pak = findSection(variant, POCKET_SECTION.pak) ?? new Uint8Array(0); - // The js section carries its QuickJS NUL — strip it for eval- - // by-source; copy the pak out of the shared package buffer. - return { - js: new TextDecoder().decode(js.subarray(0, js.length - 1)), - pak: pak.slice().buffer, - }; - }), - ); - } - return bundleCache.get(output); - }; const loader = new GLTFLoader(); - const [model, registryResponse, launcherBundle] = await Promise.all([ - loader.loadAsync(STAGE_ROOT + profile.lods.orbit), - fetch(STAGE_ROOT + "apps/apps.json").then(failResponse), - fetchBundle("launcher-main"), - hostReady, - ]); - const registry = await registryResponse.json(); - stageHost.enableAppSwitching({ - launcher: "launcher-main", - apps: registry.apps, - fetchBundle, - onSwitch: () => invalidate(), - }); - const { js: appSource, pak } = launcherBundle; + let model; + let registry = null; + let fetchBundle = null; + let launcherBundle = null; + if (suppliedHost) { + [model] = await Promise.all([ + loader.loadAsync(modelUrl), + hostReady, + ]); + } else { + // The homepage stage boots the Pocket Launcher (docs/LAUNCHER.md) — the + // same multi-app deck the PSP EBOOT ships. The Playground skips this + // branch because its supplied host already owns the live-compiled app. + const bundleCache = new Map(); + fetchBundle = async (output) => { + if (!bundleCache.has(output)) { + bundleCache.set( + output, + fetch(STAGE_ROOT + "apps/" + output + ".pocket") + .then(failResponse) + .then((r) => r.arrayBuffer()) + .then((buffer) => { + const pkg = decodePocketPackage(new Uint8Array(buffer)); + const variant = findVariant(pkg, "psp"); + if (!variant) throw new Error(output + ".pocket has no psp variant"); + const js = findSection(variant, POCKET_SECTION.js); + const pak = findSection(variant, POCKET_SECTION.pak) ?? new Uint8Array(0); + // The js section carries its QuickJS NUL — strip it for eval- + // by-source; copy the pak out of the shared package buffer. + return { + js: new TextDecoder().decode(js.subarray(0, js.length - 1)), + pak: pak.slice().buffer, + }; + }), + ); + } + return bundleCache.get(output); + }; + const [loadedModel, registryResponse, loadedLauncher] = await Promise.all([ + loader.loadAsync(modelUrl), + fetch(STAGE_ROOT + "apps/apps.json").then(failResponse), + fetchBundle("launcher-main"), + hostReady, + ]); + model = loadedModel; + registry = await registryResponse.json(); + launcherBundle = loadedLauncher; + stageHost.enableAppSwitching({ + launcher: "launcher-main", + apps: registry.apps, + fetchBundle, + onSwitch: () => invalidate(), + }); + } screenTexture = new THREE.CanvasTexture(screenCanvas); screenTexture.colorSpace = THREE.SRGBColorSpace; @@ -520,7 +557,6 @@ export async function mountPocketStage(root) { screenTexture.generateMipmaps = false; screenTexture.minFilter = THREE.LinearFilter; screenTexture.magFilter = THREE.LinearFilter; - textureReady = true; const canonical = canonicalizeModel(model.scene, profile); bindPackageMaterials(canonical, profile, screenTexture); @@ -528,40 +564,52 @@ export async function mountPocketStage(root) { proxyGroup = buildPickProxies(profile); scene.add(proxyGroup); - stageHost.runIIFE(appSource, pak); - screenTexture.needsUpdate = true; + if (launcherBundle) { + const { js: appSource, pak } = launcherBundle; + stageHost.runIIFE(appSource, pak); + } + refreshScreen(); ready = true; root.dataset.ready = "true"; root.classList.add("is-ready"); - status.textContent = "Pocket Stage ready"; - if (!inViewport || document.hidden) stageHost.stop(); + status.textContent = options.readyText ?? "Pocket Stage ready"; + if ((!inViewport || document.hidden) && !suppliedHost) stageHost.stop(); invalidate(); // Warm the deck's apps once the hero is up: sequential, idle-priority — // a launch then swaps instantly instead of showing a fetch hold. - const prefetch = async () => { - for (const app of registry.apps) { - try { - await fetchBundle(app.output); - } catch { - // offline or trimmed deploy — the launch path will surface it + if (registry && fetchBundle) { + const prefetch = async () => { + for (const app of registry.apps) { + try { + await fetchBundle(app.output); + } catch { + // offline or trimmed deploy — the launch path will surface it + } } - } - }; - ("requestIdleCallback" in window ? requestIdleCallback : setTimeout)(prefetch); + }; + ("requestIdleCallback" in window ? requestIdleCallback : setTimeout)(prefetch); + } // Exposed only as a receipt for the local/CI browser verifier. - globalThis.__pocketStageReceipt = () => ({ + const receiptName = options.receiptName ?? "__pocketStageReceipt"; + globalThis[receiptName] = () => ({ ready, stageFrames: renderCount, guestTicks: stageHost.tickCount, screenFrames: stageHost.blitCount, + screenUploads, + screenCanvasId: screenCanvas.id || null, + profileUrl, + modelUrl, focused, pressedPart: root.dataset.pressedPart || null, + lastPressedPart, }); + return { refreshScreen, releaseInput: releaseButton }; } catch (error) { root.classList.add("has-error"); - status.textContent = "Pocket Stage could not be loaded."; + status.textContent = options.errorText ?? "Pocket Stage could not be loaded."; console.error("Pocket Stage load failed", error); } } diff --git a/site/assets/screen.css b/site/assets/screen.css deleted file mode 100644 index d23d4847..00000000 --- a/site/assets/screen.css +++ /dev/null @@ -1,291 +0,0 @@ -/* ====================================================================== - PocketJS live demo — a PSP-silhouette shell around the 480×272 display. - Piano-black pill body with near-semicircular ends, controls flanking the - screen exactly where the real handheld puts them: d-pad + analog nub on - the left, the △○×□ diamond (PlayStation glyph colors) on the right, L/R - riding the top corners, and a bottom bar of speaker dots · wordmark · - SELECT/START. Shared by the homepage hero and the playground; sizes in - container-query units so it scales to any width. Tapping a control drives - the WebAssembly demo (assets/home.js / playground.js). - ====================================================================== */ - -.screen-emu { - --emu-active: #22d3ee; /* press feedback — the site accent */ - --emu-label: #8b93a7; /* silver silkscreen labels */ - /* ONE fixed radius shared by the body and the shoulders' outer corners. - MUST be a fixed unit (px), NOT cqw: cqw on the body (a container) resolves - against the viewport, but on the shoulders (descendants) against this - container — so a cqw value renders as two different pixel radii. Large, - because a PSP is a lozenge: its ends are close to half-circles. */ - --emu-radius: 84px; - container-type: inline-size; - position: relative; /* L/R shoulders are absolutely pinned to the top corners */ - display: grid; - /* side columns are `auto` (sized by the .emu-pad / .emu-faces clusters, which - are equal width so the screen stays centered) — do NOT use cqw here, it - resolves against the ancestor/viewport, not this container. The shoulders - are NOT a grid row (they float over the top corners), so there's no tall - empty band above the screen. */ - grid-template-columns: auto minmax(0, 1fr) auto; - grid-template-rows: auto auto; - grid-template-areas: - "dpad screen faces" - "sys sys sys"; - align-items: center; - column-gap: 2.6cqw; - row-gap: 0.9cqw; - padding: 2.6cqw 4.4cqw 1.1cqw; /* wide side padding sells the pill ends */ - border-radius: var(--emu-radius); - /* piano black: glossy top light-sweep + faint horizontal sheen + deep body */ - background: - radial-gradient(130% 55% at 50% -6%, rgba(148, 163, 205, 0.2), transparent 60%), - linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, transparent 22%), - repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.008) 0 1px, transparent 1px 3px), - linear-gradient(180deg, #1a1e2a 0%, #10131c 46%, #090b11 100%); - box-shadow: - inset 0 1.5px 0 rgba(255, 255, 255, 0.12), - inset 0 -3cqw 6cqw rgba(0, 2, 8, 0.55), - inset 0 0 0 1px rgba(255, 255, 255, 0.05), - 0 44px 90px -42px rgba(0, 0, 0, 0.95), - 0 0 0 1px rgba(38, 46, 66, 0.9); -} - -/* ---- shoulder bumpers — tabs on the FLAT stretch of the top edge. The pill - body's corner arc spans exactly --emu-radius horizontally, so insetting by - that much guarantees the shoulder sits fully on the silhouette at ANY - radius (pinning them at the corners left them floating outside the curve — - observed on the deployed page). Bottom corners stay square so the tab - welds onto the edge. ------------------------------------------------------ */ -.emu-shoulder { - display: none; /* hidden pending a better shoulder treatment — the seated-tab - look still reads off; L/R stay reachable via keyboard */ - position: absolute; - top: 0cqw; - min-width: 12cqw; - padding: 0.9cqw 2.6cqw; - cursor: pointer; - font-family: var(--font-mono, ui-monospace, monospace); - font-size: 2cqw; - font-weight: 700; - letter-spacing: 0.16em; - color: var(--emu-label); - background: linear-gradient(180deg, #232837, #12151f); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 1.2cqw 1.2cqw 0 0; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12), 0 2px 4px rgba(0, 0, 0, 0.4); - transition: color 0.1s ease, background 0.1s ease, transform 0.06s ease, box-shadow 0.1s ease, border-color 0.1s ease; -} -.emu-shoulder.l { left: var(--emu-radius); } -.emu-shoulder.r { right: var(--emu-radius); } -.emu-shoulder:hover { background: linear-gradient(180deg, #2a3042, #171b26); color: #c6cddc; } -.emu-shoulder.is-down, -.emu-shoulder:active { - color: var(--emu-active); - border-color: rgba(34, 211, 238, 0.5); - box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.5), 0 0 9px rgba(34, 211, 238, 0.3); -} - -/* ---- left column: d-pad above the analog nub, like the real face ---------- */ -.emu-pad { - grid-area: dpad; - justify-self: center; - width: 17cqw; /* match .emu-faces so the two side columns are equal */ - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - gap: 2.2cqw; -} -.emu-dpad { - position: relative; - width: 15cqw; - aspect-ratio: 1; -} - -/* the analog nub — a low round slider disc, concentric rings, decorative */ -.emu-nub { - width: 6.4cqw; - aspect-ratio: 1; - border-radius: 50%; - background: - radial-gradient(circle at 50% 38%, #2b3040 0%, #191d29 58%, #0d1018 100%); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.14), - inset 0 0 0 1.2cqw rgba(0, 0, 0, 0.25), - 0 2px 5px rgba(0, 0, 0, 0.55), - 0 0 0 0.55cqw #05070c, - 0 0 0 0.7cqw rgba(255, 255, 255, 0.05); -} - -/* ---- the recessed widescreen (center) — glass panel look ------------------ */ -.emu-screen { - grid-area: screen; - position: relative; - width: 100%; - aspect-ratio: 480 / 272; - border-radius: 1.2cqw; - overflow: hidden; - background: #04060c; - /* the LCD sits inside a wider glossy glass face: thin inner bezel, a broad - near-black glass ring, then a hairline silver rim */ - box-shadow: - inset 0 0 0 1px rgba(0, 0, 0, 0.9), - inset 0 2px 10px rgba(0, 0, 0, 0.82), - 0 0 0 1.9cqw #05070d, - 0 0 0 2cqw rgba(255, 255, 255, 0.06), - 0 0 30px -4px rgba(56, 189, 248, 0.28); - margin: 0 0.6cqw; -} -.emu-screen canvas { - display: block; - width: 100%; - height: 100%; - image-rendering: pixelated; - outline: none; -} -.emu-loading { - position: absolute; - inset: 0; - display: grid; - place-items: center; - font-family: var(--font-mono, ui-monospace, monospace); - font-size: 3cqw; - color: #5b6b86; -} - -/* ---- shared key base (d-pad arms + face buttons) -------------------------- */ -.emu-key { - position: absolute; - border: 0; - cursor: pointer; - padding: 0; - display: grid; - place-items: center; - color: rgba(226, 232, 240, 0.9); - background: radial-gradient(circle at 50% 34%, #262b3a, #141824 62%, #0c0f17 100%); - border: 1px solid rgba(255, 255, 255, 0.08); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 2px 5px rgba(0, 0, 0, 0.5); - transition: color 0.1s ease, background 0.1s ease, transform 0.06s ease, border-color 0.1s ease, box-shadow 0.1s ease; -} -.emu-key svg { width: 46%; height: 46%; } -.emu-key:hover { border-color: rgba(255, 255, 255, 0.18); } -.emu-key.is-down, -.emu-key:active { - color: var(--emu-active); - border-color: rgba(34, 211, 238, 0.55); - box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.55), 0 0 10px rgba(34, 211, 238, 0.35); - transform: translateY(1px) scale(0.97); -} - -/* d-pad — ONE molded cross (inline SVG, recolored via CSS), silver arrows over - transparent arm hit-areas */ -.emu-cross { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - overflow: visible; - filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.55)); -} -.emu-cross path { - fill: #1c202c; - stroke: rgba(255, 255, 255, 0.09); -} /* CSS overrides the inline fill/stroke attributes */ -.emu-dpad .emu-key { - background: transparent; - border: 0; - box-shadow: none; - color: var(--emu-label); -} -.emu-dpad .emu-key svg { width: 42%; height: 42%; } -.emu-dpad .up { top: 2%; left: 35%; width: 30%; height: 34%; } -.emu-dpad .down { bottom: 2%; left: 35%; width: 30%; height: 34%; } -.emu-dpad .left { left: 2%; top: 35%; height: 30%; width: 34%; } -.emu-dpad .right { right: 2%; top: 35%; height: 30%; width: 34%; } -.emu-dpad .emu-key:hover { background: rgba(148, 163, 184, 0.1); border-radius: 10%; } -.emu-dpad .emu-key.is-down, -.emu-dpad .emu-key:active { - background: rgba(34, 211, 238, 0.2); - color: var(--emu-active); - border-radius: 10%; - box-shadow: none; - transform: none; -} - -/* face buttons diamond (right) — PlayStation glyph colors, muted to match - the body; press feedback stays the site cyan */ -.emu-faces { - grid-area: faces; - justify-self: center; - position: relative; - width: 17cqw; - aspect-ratio: 1; -} -/* smaller buttons pushed out to the corners → wider gaps between the diamond */ -.emu-faces .emu-key { width: 33%; aspect-ratio: 1; border-radius: 50%; } -.emu-faces .tri { top: 0; left: 33.5%; color: #52d3a2; } -.emu-faces .sq { top: 33.5%; left: 0; color: #e6a3c4; } -.emu-faces .ci { top: 33.5%; right: 0; color: #ef8f8f; } -.emu-faces .cross { bottom: 0; left: 33.5%; color: #7fb2f0; } - -/* ---- bottom bar: speaker · wordmark · SELECT/START ------------------------ */ -.emu-sys { - grid-area: sys; - width: 100%; - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - padding: 0 1.2cqw; -} -.emu-speaker { - justify-self: start; - display: flex; - gap: 0.9cqw; -} -.emu-speaker i { - width: 0.8cqw; - aspect-ratio: 1; - border-radius: 50%; - background: #05070c; - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.9), 0 1px 0 rgba(255, 255, 255, 0.05); -} -.emu-brand { - justify-self: center; - font-family: var(--font-mono, ui-monospace, monospace); - font-size: 1.7cqw; - font-weight: 700; - font-style: italic; - letter-spacing: 0.34em; - color: #39415a; - text-shadow: 0 1px 0 rgba(0, 0, 0, 0.6); - user-select: none; -} -.emu-sysbtns { - justify-self: end; - display: flex; - flex-direction: row; - align-items: center; - gap: 1.6cqw; -} -.emu-sys-btn { - cursor: pointer; - font-family: var(--font-mono, ui-monospace, monospace); - font-size: 1.3cqw; - font-weight: 700; - letter-spacing: 0.12em; - color: var(--emu-label); - padding: 0.5cqw 1.9cqw; - border-radius: 999px; - background: linear-gradient(180deg, #20242f, #11141d); - border: 1px solid rgba(255, 255, 255, 0.07); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 2px 4px rgba(0, 0, 0, 0.4); - transition: color 0.1s ease, background 0.1s ease, transform 0.06s ease, box-shadow 0.1s ease, border-color 0.1s ease; -} -.emu-sys-btn:hover { background: linear-gradient(180deg, #272c3a, #151924); color: #c6cddc; } -.emu-sys-btn.is-down, -.emu-sys-btn:active { - color: var(--emu-active); - border-color: rgba(34, 211, 238, 0.5); - box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.5), 0 0 9px rgba(34, 211, 238, 0.3); - transform: translateY(1px); -} diff --git a/site/assets/tailwind.css b/site/assets/tailwind.css index f8049dc3..dc5fd69d 100644 --- a/site/assets/tailwind.css +++ b/site/assets/tailwind.css @@ -445,38 +445,110 @@ radial-gradient(36% 58% at 100% 12%, color-mix(in oklab, var(--color-brand-2) 4%, transparent), transparent 72%), var(--color-ink); } - .pg-device { width: 100%; max-width: 460px; } - .pg-screen { + .pg-stage { + width: 100%; + margin: 0; + outline: none; + } + .pg-stage__viewport { position: relative; - aspect-ratio: 480 / 272; - border-radius: 10px; + width: 100%; + aspect-ratio: 16 / 9; overflow: hidden; + outline: none; + } + .pg-stage__canvas { + display: block; + width: 100%; + height: 100%; + outline: none; + cursor: grab; + touch-action: none; + } + .pg-stage__canvas:active { cursor: grabbing; } + .pg-stage__viewport:focus-visible .pg-stage__canvas { + border-radius: 0.8rem; + box-shadow: inset 0 0 0 2px color-mix(in oklab, var(--color-brand-2) 78%, white); + } + .pg-stage__screen { display: none; } + .pg-stage__status { + position: absolute; + left: 50%; + bottom: 0.7rem; + display: inline-flex; + align-items: center; + gap: 0.5rem; + max-width: calc(100% - 2rem); + padding: 0.45rem 0.75rem; + border: 1px solid rgba(85, 103, 133, 0.56); + border-radius: 999px; + color: var(--color-slate-300); + background: rgba(6, 10, 18, 0.82); + font-family: var(--font-mono); + font-size: 0.68rem; + line-height: 1.25; + text-align: center; + transform: translateX(-50%); + backdrop-filter: blur(8px); + transition: opacity 0.25s ease, transform 0.25s ease; + } + .pg-stage__status-dot { + width: 6px; + height: 6px; + flex: none; + border-radius: 50%; + background: #9de5c7; + box-shadow: 0 0 10px rgba(157, 229, 199, 0.82); + animation: pg-stage-pulse 1.1s ease-in-out infinite alternate; + } + .pg-stage.is-ready .pg-stage__status { + opacity: 0; + transform: translate(-50%, 5px); + pointer-events: none; + } + .pg-stage.has-error .pg-stage__viewport { height: auto; } + .pg-stage.has-error .pg-stage__canvas { display: none; } + .pg-stage.has-error .pg-stage__screen { + display: block; + width: min(100%, 480px); + height: auto; + margin: 0 auto; + border-radius: 0.65rem; background: #000; - box-shadow: 0 0 0 6px #05070d, 0 20px 40px -24px #000; - } - .pg-pad { margin-top: 1rem; display: flex; align-items: center; justify-content: space-between; } - .pg-dpad { position: relative; width: 96px; height: 96px; } - .pg-dpad .pad { position: absolute; } - .pg-dpad .up { top: 0; left: 33px; } - .pg-dpad .down { bottom: 0; left: 33px; } - .pg-dpad .left { left: 0; top: 33px; } - .pg-dpad .right { right: 0; top: 33px; } - .pg-face { position: relative; width: 108px; height: 96px; } - .pg-face .pad { position: absolute; border-radius: 999px; } - .pg-face .tri { top: 0; left: 38px; color: #6ee7b7; } - .pg-face .sq { top: 32px; left: 4px; color: #f9a8d4; } - .pg-face .ci { top: 32px; right: 4px; color: #fca5a5; } - .pg-face .cr { bottom: 0; left: 38px; color: #93c5fd; } - .pad { - width: 30px; height: 30px; display: grid; place-items: center; - border: 1px solid var(--color-line-2); background: var(--color-surface-2); - color: var(--color-slate-300); border-radius: 7px; cursor: pointer; font-size: 0.7rem; - user-select: none; transition: background 0.1s, transform 0.05s; - } - .pad:hover { border-color: var(--color-brand); } - .pad.held, .pad:active { background: var(--color-brand); color: #04121b; transform: scale(0.94); } - .pg-sys { display: flex; gap: 0.75rem; } - .pad.sys { width: auto; padding: 0 0.7rem; height: 24px; font-size: 0.66rem; } + image-rendering: pixelated; + box-shadow: 0 0 0 1px var(--color-line-2), 0 24px 54px -34px #000; + } + .pg-stage.has-error .pg-stage__viewport:focus-visible .pg-stage__screen { + box-shadow: + 0 0 0 2px color-mix(in oklab, var(--color-brand-2) 78%, white), + 0 24px 54px -34px #000; + } + .pg-stage.has-error .pg-stage__status { + color: #fecaca; + border-color: rgba(248, 113, 113, 0.44); + } + .pg-stage.has-error .pg-stage__status-dot { + background: #f87171; + box-shadow: none; + animation: none; + } + .pg-stage__credit { + padding: 0.35rem 0.35rem 0; + color: var(--color-slate-600); + font-size: 0.66rem; + line-height: 1.4; + text-align: right; + } + .pg-stage__credit a { + color: var(--color-slate-400); + text-decoration: underline; + text-underline-offset: 2px; + } + .pg-stage__credit a:hover { color: var(--color-slate-200); } + @keyframes pg-stage-pulse { + from { opacity: 0.35; transform: scale(0.78); } + to { opacity: 1; transform: scale(1); } + } .pg-error { width: 100%; max-width: 620px; margin: 0; padding: 0.75rem 0.9rem; background: color-mix(in oklab, #7f1d1d 30%, var(--color-ink-2)); diff --git a/site/build.ts b/site/build.ts index 6ade6716..ca47a2ac 100644 --- a/site/build.ts +++ b/site/build.ts @@ -574,12 +574,10 @@ async function main() { active: "playground", body: readFileSync(SITE + "playground/page.html", "utf8"), bodyClass: "pg-page", - head: IMPORT_MAP + '\n', + head: IMPORT_MAP, scripts: [''], path: "/playground/", })); - copy(SITE + "assets/screen.css", "assets/screen.css"); - // 6. homepage — bespoke "cinematic" design: its own chrome + home.css + // home.js (the baked demo wall + lazy Pocket Stage). Not wrapped in the shared // header/footer (those stay for docs + playground). diff --git a/site/playground/compiler-entry.ts b/site/playground/compiler-entry.ts index 5bf1325d..ae2775ab 100644 --- a/site/playground/compiler-entry.ts +++ b/site/playground/compiler-entry.ts @@ -297,10 +297,22 @@ const nearestPow2 = (n: number) => { return Math.max(8, Math.min(512, p)); }; +function svgImageBlob(source: string): Blob { + const normalized = /]*\bxmlns\s*=/i.test(source) + ? source + : source.replace(/ { const res = await fetch(assetBase + name).catch(() => null); if (!res || !res.ok) return placeholderImage(); - const blob = await res.blob(); + // SVG markup copied from app directories is also accepted by native builds, + // where an XML namespace is optional. Browser image decoders require it on + // standalone SVG blobs, so normalize the root before creating the image URL. + const blob = name.toLowerCase().endsWith(".svg") + ? svgImageBlob(await res.text()) + : await res.blob(); const url = URL.createObjectURL(blob); try { const img = await new Promise((resolve, reject) => { diff --git a/site/playground/host.d.ts b/site/playground/host.d.ts index 13fa03db..d946f451 100644 --- a/site/playground/host.d.ts +++ b/site/playground/host.d.ts @@ -21,6 +21,7 @@ export class PocketHost { tickCount: number; blitCount: number; press(bit: number, down: boolean): void; + reset(): void; afterNextTick(callback: () => void): () => void; _safeFrame(): boolean; } diff --git a/site/playground/page.html b/site/playground/page.html index 2bd0d0e7..8db91469 100644 --- a/site/playground/page.html +++ b/site/playground/page.html @@ -22,43 +22,37 @@
-
-
- - -
-
- - - - - +
+
+
+ + +
+ + Loading PSP model…
-
-
- -
-
- - - - -
-
- - -
- - -
-
-
+
+ PSP model by Dibad + · CC BY 4.0 +
+
-

+

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 5048c10c..825c4b89 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/). @@ -39,6 +39,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; @@ -52,13 +54,47 @@ async function main() { // --- host ----------------------------------------------------------------- const host = new PocketHost(); if (verifyMode) globalThis.__pgHost = host; - 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(), showHud: !verifyMode, idleAfterMs: verifyMode ? 0 : Infinity, }); + 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. + // The full demo matrix drives the hidden framebuffer deterministically and + // exits each isolated browser as soon as its receipt is complete. Its Stage + // shell has a separate raycast/WebGL verifier, so avoid starting GLB fetches + // that would still be in flight when a short matrix run closes the page. + if (!verifyMode) { + 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; @@ -156,6 +192,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; @@ -223,23 +263,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 = query.get("demo"); const bootFramework = query.get("framework"); diff --git a/site/verify-playground-stage.ts b/site/verify-playground-stage.ts new file mode 100644 index 00000000..3476647e --- /dev/null +++ b/site/verify-playground-stage.ts @@ -0,0 +1,276 @@ +// 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; + }; + const spinnerFrameHash = () => { + const context = framebuffer.getContext("2d"); + const data = context.getImageData(360, 70, 120, 140).data; + let value = 2166136261; + for (let i = 0; i < data.length; i += 4) { + value ^= data[i] | (data[i + 1] << 8) | (data[i + 2] << 16) | (data[i + 3] << 24); + value = Math.imul(value, 16777619); + } + return value >>> 0; + }; + + // The Hero spinner advances every three guest frames. Sampling its authored + // framebuffer region catches both a stalled guest and SVG decode fallback: + // eight failed SVGs previously produced one identical checker hash while the + // Stage upload counters continued to advance. + const spinnerStartTick = receipt()?.guestTicks ?? 0; + const spinnerHashes = new Set(); + const stageUploadHashes = new Set(); + let stageUploadCalls = 0; + const contextPrototypes = new Set( + [window.WebGLRenderingContext, window.WebGL2RenderingContext] + .filter(Boolean) + .map((Context) => Context.prototype), + ); + for (const prototype of contextPrototypes) { + for (const name of ["texImage2D", "texSubImage2D"]) { + const original = prototype[name]; + if (typeof original !== "function") continue; + prototype[name] = function (...args) { + if (args.includes(framebuffer)) { + stageUploadCalls++; + stageUploadHashes.add(spinnerFrameHash()); + } + return original.apply(this, args); + }; + } + } + let spinnerEndTick = spinnerStartTick; + for (let i = 0; i < 64; i++) { + spinnerHashes.add(spinnerFrameHash()); + spinnerEndTick = receipt()?.guestTicks ?? spinnerEndTick; + if ( + spinnerHashes.size >= 8 + && stageUploadHashes.size >= 8 + && spinnerEndTick - spinnerStartTick >= 24 + ) break; + // Vary the interval so the verifier cannot phase-lock with the 3-tick + // animation step and repeatedly skip the same authored frame. + await sleep(37 + (i % 5) * 7); + } + + 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 resourceEntries = performance.getEntriesByType("resource"); + const resources = resourceEntries.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, + spinnerGuestTicks: spinnerEndTick - spinnerStartTick, + spinnerFramebufferHashes: spinnerHashes.size, + spinnerStageUploadCalls: stageUploadCalls, + spinnerStageUploadHashes: stageUploadHashes.size, + wasmLoads: resources.filter((path) => path.endsWith("/pg/pocketjs.wasm")).length, + launcherLoads: resources.filter((path) => path.startsWith("/stage/apps/")), + modelResources: resourceEntries + .filter((entry) => new URL(entry.name).pathname === "/stage/psp_lod3_eco.glb") + .map((entry) => ({ + duration: Math.round(entry.duration), + transferSize: entry.transferSize, + decodedBodySize: entry.decodedBodySize, + })), + 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 modelLoaded = result.modelResources.some( + (resource: { decodedBodySize: number }) => resource.decodedBodySize > 0, +); +const loadedModelUrl = new URL("/stage/psp_lod3_eco.glb", url).href; +const expectedCanceledModelRequest = + `net::ERR_ABORTED: ${loadedModelUrl} (type=Fetch, canceled=true)`; +// CDP can report a canceled Fetch for this URL after ResourceTiming shows the +// complete body. Accept only that exact cancellation when the browser receipt +// also proves the model loaded and the Stage became ready. +const unexpectedNetworkErrors = (report.networkErrors ?? []).filter( + (error: string) => !(modelLoaded && result.stageReady === "true" && error === expectedCanceledModelRequest), +); +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" + && modelLoaded, + 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, + animatedStageTexture: + result.spinnerGuestTicks >= 24 + && result.spinnerFramebufferHashes >= 8 + && result.spinnerStageUploadCalls >= 8 + && result.spinnerStageUploadHashes >= 8, + noBrowserErrors: + report.pageErrors.length === 0 + && report.consoleErrors.length === 0 + && unexpectedNetworkErrors.length === 0, +}; +const failures = Object.entries(checks) + .filter(([, passed]) => !passed) + .map(([name]) => name); + +console.log(JSON.stringify({ ...report, unexpectedNetworkErrors, checks }, null, 2)); +if (failures.length) { + throw new Error(`Playground Stage smoke failed: ${failures.join(", ")}`); +} diff --git a/site/verify-playground-vue-vapor.ts b/site/verify-playground-vue-vapor.ts new file mode 100644 index 00000000..05b94295 --- /dev/null +++ b/site/verify-playground-vue-vapor.ts @@ -0,0 +1,152 @@ +// Real-browser regression for the Playground's split Vue Vapor bundles. +// Serve site/dist first, then run: +// bun site/verify-playground-vue-vapor.ts 'http://127.0.0.1:8140/playground/?demo=hero&framework=vue-vapor' + +const url = process.argv[2] + ?? "http://127.0.0.1:8140/playground/?demo=hero&framework=vue-vapor"; +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 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); + } + + const ops = globalThis.ui; + const textWritesByNode = new Map(); + const insertedNodeIds = new Set(); + const invalidTextWrites = []; + const invalidInserts = []; + for (const name of ["setText", "replaceText"]) { + const original = ops[name]; + ops[name] = function (id, value) { + if (!Number.isInteger(id) || id <= 0) { + invalidTextWrites.push({ name, id, value: String(value) }); + } else { + const writes = textWritesByNode.get(id) ?? new Set(); + writes.add(String(value)); + textWritesByNode.set(id, writes); + } + return original.apply(this, arguments); + }; + } + const insertBefore = ops.insertBefore; + ops.insertBefore = function (parent, child, anchor) { + if ( + !Number.isInteger(parent) || parent <= 0 + || !Number.isInteger(child) || child <= 0 + || !Number.isInteger(anchor) || anchor < 0 + ) { + invalidInserts.push({ parent, child, anchor }); + } else { + insertedNodeIds.add(child); + } + return insertBefore.apply(this, arguments); + }; + + run.click(); + let sawBusy = status.dataset.kind === "busy"; + const rerunDeadline = performance.now() + 12000; + while (performance.now() < rerunDeadline) { + sawBusy ||= status.dataset.kind === "busy"; + if (sawBusy && status.dataset.kind === "ok") break; + await sleep(50); + } + // Vue Vapor schedules part of its mount work in microtasks; leave a guest + // turn after the status flips before collecting the native-tree receipt. + await sleep(250); + + const insertedTextWrites = [...textWritesByNode] + .filter(([id]) => insertedNodeIds.has(id)) + .flatMap(([, writes]) => [...writes]); + const resourceEntries = performance.getEntriesByType("resource"); + const resources = resourceEntries.map((entry) => new URL(entry.name).pathname); + return { + stageReady: stage.dataset.ready, + hasError: stage.classList.contains("has-error"), + sawBusy, + statusKind: status.dataset.kind, + status: status.textContent, + insertedTextWrites, + insertedTextNodes: [...textWritesByNode.keys()].filter((id) => insertedNodeIds.has(id)).length, + invalidTextWrites, + invalidInserts, + wasmLoads: resources.filter((path) => path.endsWith("/pg/pocketjs.wasm")).length, + launcherLoads: resources.filter((path) => path.startsWith("/stage/apps/")), + modelResources: resourceEntries + .filter((entry) => new URL(entry.name).pathname === "/stage/psp_lod3_eco.glb") + .map((entry) => ({ decodedBodySize: entry.decodedBodySize })), + receipt: receipt(), + }; +})()`; + +const child = Bun.spawn( + [process.execPath, verify, url, "1000", probe], + { + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + SHOT: process.env.SHOT ?? "/tmp/pocketjs-playground-vue-vapor.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 hasText = (value: string) => + result.insertedTextWrites.some((text: string) => text.includes(value)); +const modelLoaded = result.modelResources.some( + (resource: { decodedBodySize: number }) => resource.decodedBodySize > 0, +); +const loadedModelUrl = new URL("/stage/psp_lod3_eco.glb", url).href; +const expectedCanceledModelRequest = + `net::ERR_ABORTED: ${loadedModelUrl} (type=Fetch, canceled=true)`; +const unexpectedNetworkErrors = (report.networkErrors ?? []).filter( + (error: string) => !(modelLoaded && result.stageReady === "true" && error === expectedCanceledModelRequest), +); +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" + && modelLoaded, + onePlaygroundRuntime: result.wasmLoads === 1 && result.launcherLoads.length === 0, + nativeTextNodes: + result.sawBusy + && result.invalidTextWrites.length === 0 + && result.invalidInserts.length === 0 + && result.insertedTextNodes > 0 + && hasText("PocketJS") + && hasText("Vue Vapor") + && hasText("JSX at 60 FPS.") + && hasText("Press Circle"), + noBrowserErrors: + report.pageErrors.length === 0 + && report.consoleErrors.length === 0 + && unexpectedNetworkErrors.length === 0, +}; +const failures = Object.entries(checks) + .filter(([, passed]) => !passed) + .map(([name]) => name); + +console.log(JSON.stringify({ ...report, unexpectedNetworkErrors, checks }, null, 2)); +if (failures.length) { + throw new Error(`Playground Vue Vapor smoke failed: ${failures.join(", ")}`); +} diff --git a/site/verify-playground.ts b/site/verify-playground.ts index 7398c66b..a9b41adb 100644 --- a/site/verify-playground.ts +++ b/site/verify-playground.ts @@ -281,9 +281,9 @@ function makeProbe(buttons: string[]) { controlError = controlErrorEl && !controlErrorEl.hidden ? controlErrorEl.textContent : null; controlFrameAlive = typeof host.frameCb === 'function'; - // Fresh component state, then the same frame count through the visible - // gamepad controls. Any final pixel difference is attributable to input, - // including for demos whose normal UI animates continuously. + // Fresh component state, then the same frame count through the shared + // PocketHost input path. The PSP raycast controls call this same method; + // their hit proxies are covered separately by the Stage verifier. const runButton = document.querySelector('#pg-run'); if (!runButton) throw new Error('Playground Run button is missing'); const originalSetText = host.ops.setText; @@ -322,15 +322,15 @@ function makeProbe(buttons: string[]) { await waitForRun(); host.stop(); for (const value of sequence) { - const button = document.querySelector('[data-btn="' + value + '"]'); - if (!button) continue; - button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + const bit = Number(value); + if (!Number.isInteger(bit) || bit <= 0) throw new Error('Invalid input bit ' + value); + host.press(bit, true); host.stop(); for (let i = 0; i < edgeFrames; i++) { host._safeFrame(); await Promise.resolve(); } - button.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true })); + host.press(bit, false); host.stop(); for (let i = 0; i < edgeFrames; i++) { host._safeFrame(); @@ -446,7 +446,7 @@ async function verifyVariant(demo: string, framework: Framework) { errors.push(`${probe.invalidTextWrites} host text write(s) used an invalid native node id`); } if (probe.pressed.length !== INPUTS[demo].length) { - errors.push(`only ${probe.pressed.length}/${INPUTS[demo].length} controls were found`); + errors.push(`only ${probe.pressed.length}/${INPUTS[demo].length} inputs were applied`); } for (const expectedText of EXPECTED_TEXT[demo]) { if (!probe.textWrites.some((value) => value.includes(expectedText))) { diff --git a/site/verify.ts b/site/verify.ts index 72b422b8..ce317254 100644 --- a/site/verify.ts +++ b/site/verify.ts @@ -172,6 +172,7 @@ try { const pageErrors: string[] = []; const consoleErrors: string[] = []; const networkErrors: string[] = []; + const networkRequestUrls = new Map(); ws.addEventListener("message", (event: any) => { const message = JSON.parse(event.data); if (message.sessionId !== sessionId) return; @@ -184,8 +185,15 @@ try { message.params.args.map((arg: any) => arg.value ?? arg.description ?? "").join(" "), ); } + if (message.method === "Network.requestWillBeSent") { + networkRequestUrls.set(message.params.requestId, message.params.request.url); + } if (message.method === "Network.loadingFailed") { - networkErrors.push(`${message.params.errorText}: ${message.params.requestId}`); + const request = networkRequestUrls.get(message.params.requestId) ?? message.params.requestId; + networkErrors.push( + `${message.params.errorText}: ${request}` + + ` (type=${message.params.type ?? "unknown"}, canceled=${message.params.canceled === true})`, + ); } if (message.method === "Network.responseReceived" && message.params.response.status >= 400) { networkErrors.push(`${message.params.response.status}: ${message.params.response.url}`); diff --git a/tests/site-stage.test.ts b/tests/site-stage.test.ts index 556cc9c8..e4444ff5 100644 --- a/tests/site-stage.test.ts +++ b/tests/site-stage.test.ts @@ -96,6 +96,114 @@ 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("playground spinner SVGs declare the browser image namespace", () => { + const spinnerDir = ROOT + "assets/images/"; + const spinnerFiles = readdirSync(spinnerDir) + .filter((file) => /^spinner-(?:0[0-7]|atlas)\.svg$/.test(file)) + .sort(); + expect(spinnerFiles).toEqual([ + "spinner-00.svg", + "spinner-01.svg", + "spinner-02.svg", + "spinner-03.svg", + "spinner-04.svg", + "spinner-05.svg", + "spinner-06.svg", + "spinner-07.svg", + "spinner-atlas.svg", + ]); + + for (const file of spinnerFiles) { + const svg = readFileSync(spinnerDir + file, "utf8"); + expect(svg).toMatch(/]*\bxmlns="http:\/\/www\.w3\.org\/2000\/svg"/); + } + + const compiler = readFileSync(ROOT + "site/playground/compiler-entry.ts", "utf8"); + expect(compiler).toContain("function svgImageBlob(source: string): Blob"); + expect(compiler).toContain("www.w3.org/2000/svg"); + expect(compiler).toContain("? svgImageBlob(await res.text())"); +}); + +test("site build binds Vue Vapor runtime and JSX helper to the Pocket document", () => { + const build = readFileSync(ROOT + "site/build.ts", "utf8"); + expect(build).toContain('document: "globalThis.__pocketDocument"'); + + const runtimeStart = build.indexOf("async function bundleVueVapor"); + const helperStart = build.indexOf("function patchVaporHelperCode"); + const writerStart = build.indexOf("function writeVueVaporHelpers"); + const headersStart = build.indexOf("function writeStaticHeaders"); + expect(runtimeStart).toBeGreaterThan(-1); + expect(helperStart).toBeGreaterThan(runtimeStart); + expect(writerStart).toBeGreaterThan(helperStart); + expect(headersStart).toBeGreaterThan(writerStart); + + const runtimeBuild = build.slice(runtimeStart, helperStart); + expect(runtimeBuild).toContain("...VUE_VAPOR_DOCUMENT_DEFINE"); + expect(runtimeBuild).toContain('if (!code.includes("globalThis.__pocketDocument"))'); + expect(runtimeBuild).toContain("Vue Vapor browser runtime does not target the PocketJS document facade"); + + const helperBuild = build.slice(helperStart, writerStart); + expect(helperBuild).toContain("define: VUE_VAPOR_DOCUMENT_DEFINE"); + + const helperWriter = build.slice(writerStart, headersStart); + expect(helperWriter).toContain("const isVaporHelper = id === vaporHelperId"); + expect(helperWriter).toContain("isVaporHelper ? patchVaporHelperCode(code) : code"); + expect(helperWriter).toContain( + 'if (isVaporHelper && !output.includes("globalThis.__pocketDocument"))', + ); + expect(helperWriter).toContain("Vue Vapor JSX helper does not target the PocketJS document facade"); +}); + 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 +274,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); +});