diff --git a/apps/cafe/app.tsx b/apps/cafe/app.tsx index 0b935dba..adc04710 100644 --- a/apps/cafe/app.tsx +++ b/apps/cafe/app.tsx @@ -13,7 +13,7 @@ // phase guards, no input blocking needed for this demo's script. import { createMemo, createSignal, For, Show, onMount } from "solid-js"; -import { Text, View, type NodeMirror } from "@pocketjs/framework/components"; +import { Focusable, Text, View, type NodeMirror } from "@pocketjs/framework/components"; import { onButtonPress } from "@pocketjs/framework/lifecycle"; import { BTN, pushFocusScope } from "@pocketjs/framework/input"; import { runEffect } from "@pocketjs/framework/effects"; @@ -57,7 +57,7 @@ export default function Cafe() { setQty((q) => q.map((n, j) => (j === i ? n + 1 : n))); }; - onButtonPress(BTN.START, () => { + const placeOrder = () => { if (phase() !== "menu" || total() === 0) return; setPhase("placing"); const order = items() @@ -73,7 +73,8 @@ export default function Cafe() { setPhase("menu"); }); }); - }); + }; + onButtonPress(BTN.START, placeOrder); return ( @@ -116,9 +117,15 @@ export default function Cafe() { - ↕ BROWSE · ○ ADD · START = PLACE ORDER - + /* Tapping the hint places the order too — the touch twin of + START (menu rows already add on tap via activation). The + active: wash is the only visual, so untouched frames stay + byte-identical. */ + + + ↕ BROWSE · ○ ADD · START = PLACE ORDER + + } > diff --git a/apps/cafe/psp/Psp.toml b/apps/cafe/psp/Psp.toml new file mode 100644 index 00000000..c10d32ce --- /dev/null +++ b/apps/cafe/psp/Psp.toml @@ -0,0 +1,7 @@ +# XMB metadata for the PocketJS Cafe EBOOT. tools/psp.ts copies this to +# hosts/psp/Psp.toml when building this demo (any framework); cargo-psp packs +# it into PARAM.SFO / ICON0 / PIC1. +# Regenerate the art with: bun tools/gen-demo-covers.ts cafe +title = "PocketJS Cafe" +xmb_icon_png = "icon0.png" +xmb_background_png = "pic1.png" diff --git a/apps/cafe/psp/icon0.png b/apps/cafe/psp/icon0.png new file mode 100644 index 00000000..1ba90dd6 Binary files /dev/null and b/apps/cafe/psp/icon0.png differ diff --git a/apps/cafe/psp/pic1.png b/apps/cafe/psp/pic1.png new file mode 100644 index 00000000..f18dd5d7 Binary files /dev/null and b/apps/cafe/psp/pic1.png differ diff --git a/apps/cafe/vita/sce_sys/livearea/contents/bg.png b/apps/cafe/vita/sce_sys/livearea/contents/bg.png new file mode 100644 index 00000000..9631f10f Binary files /dev/null and b/apps/cafe/vita/sce_sys/livearea/contents/bg.png differ diff --git a/apps/cafe/vita/sce_sys/livearea/contents/startup.png b/apps/cafe/vita/sce_sys/livearea/contents/startup.png new file mode 100644 index 00000000..5ded7ad1 Binary files /dev/null and b/apps/cafe/vita/sce_sys/livearea/contents/startup.png differ diff --git a/apps/cards/vita/sce_sys/livearea/contents/bg.png b/apps/cards/vita/sce_sys/livearea/contents/bg.png new file mode 100644 index 00000000..715a58a0 Binary files /dev/null and b/apps/cards/vita/sce_sys/livearea/contents/bg.png differ diff --git a/apps/cards/vita/sce_sys/livearea/contents/startup.png b/apps/cards/vita/sce_sys/livearea/contents/startup.png new file mode 100644 index 00000000..e8608ab1 Binary files /dev/null and b/apps/cards/vita/sce_sys/livearea/contents/startup.png differ diff --git a/apps/chrome/psp/Psp.toml b/apps/chrome/psp/Psp.toml new file mode 100644 index 00000000..7d0cd723 --- /dev/null +++ b/apps/chrome/psp/Psp.toml @@ -0,0 +1,7 @@ +# XMB metadata for the PocketJS Chrome EBOOT. tools/psp.ts copies this to +# hosts/psp/Psp.toml when building this demo (any framework); cargo-psp packs +# it into PARAM.SFO / ICON0 / PIC1. +# Regenerate the art with: bun tools/gen-demo-covers.ts chrome +title = "PocketJS Chrome" +xmb_icon_png = "icon0.png" +xmb_background_png = "pic1.png" diff --git a/apps/chrome/psp/icon0.png b/apps/chrome/psp/icon0.png new file mode 100644 index 00000000..f3ff0e7b Binary files /dev/null and b/apps/chrome/psp/icon0.png differ diff --git a/apps/chrome/psp/pic1.png b/apps/chrome/psp/pic1.png new file mode 100644 index 00000000..3aef4287 Binary files /dev/null and b/apps/chrome/psp/pic1.png differ diff --git a/apps/chrome/vita/sce_sys/livearea/contents/bg.png b/apps/chrome/vita/sce_sys/livearea/contents/bg.png new file mode 100644 index 00000000..288c097b Binary files /dev/null and b/apps/chrome/vita/sce_sys/livearea/contents/bg.png differ diff --git a/apps/chrome/vita/sce_sys/livearea/contents/startup.png b/apps/chrome/vita/sce_sys/livearea/contents/startup.png new file mode 100644 index 00000000..1af40e2e Binary files /dev/null and b/apps/chrome/vita/sce_sys/livearea/contents/startup.png differ diff --git a/apps/cursor/psp/Psp.toml b/apps/cursor/psp/Psp.toml new file mode 100644 index 00000000..a020784d --- /dev/null +++ b/apps/cursor/psp/Psp.toml @@ -0,0 +1,7 @@ +# XMB metadata for the PocketJS Cursor EBOOT. tools/psp.ts copies this to +# hosts/psp/Psp.toml when building this demo (any framework); cargo-psp packs +# it into PARAM.SFO / ICON0 / PIC1. +# Regenerate the art with: bun tools/gen-demo-covers.ts cursor +title = "PocketJS Cursor" +xmb_icon_png = "icon0.png" +xmb_background_png = "pic1.png" diff --git a/apps/cursor/psp/icon0.png b/apps/cursor/psp/icon0.png new file mode 100644 index 00000000..16ea2087 Binary files /dev/null and b/apps/cursor/psp/icon0.png differ diff --git a/apps/cursor/psp/pic1.png b/apps/cursor/psp/pic1.png new file mode 100644 index 00000000..c02632ba Binary files /dev/null and b/apps/cursor/psp/pic1.png differ diff --git a/apps/cursor/vita/sce_sys/livearea/contents/bg.png b/apps/cursor/vita/sce_sys/livearea/contents/bg.png new file mode 100644 index 00000000..3906cc21 Binary files /dev/null and b/apps/cursor/vita/sce_sys/livearea/contents/bg.png differ diff --git a/apps/cursor/vita/sce_sys/livearea/contents/startup.png b/apps/cursor/vita/sce_sys/livearea/contents/startup.png new file mode 100644 index 00000000..353589af Binary files /dev/null and b/apps/cursor/vita/sce_sys/livearea/contents/startup.png differ diff --git a/apps/gallery/vita/sce_sys/livearea/contents/bg.png b/apps/gallery/vita/sce_sys/livearea/contents/bg.png new file mode 100644 index 00000000..76c21bd3 Binary files /dev/null and b/apps/gallery/vita/sce_sys/livearea/contents/bg.png differ diff --git a/apps/gallery/vita/sce_sys/livearea/contents/startup.png b/apps/gallery/vita/sce_sys/livearea/contents/startup.png new file mode 100644 index 00000000..a3eea34d Binary files /dev/null and b/apps/gallery/vita/sce_sys/livearea/contents/startup.png differ diff --git a/apps/hero/psp/pic1.png b/apps/hero/psp/pic1.png index 0b3533a4..e125caaf 100644 Binary files a/apps/hero/psp/pic1.png and b/apps/hero/psp/pic1.png differ diff --git a/apps/hero/vita/sce_sys/livearea/contents/bg.png b/apps/hero/vita/sce_sys/livearea/contents/bg.png new file mode 100644 index 00000000..414221f8 Binary files /dev/null and b/apps/hero/vita/sce_sys/livearea/contents/bg.png differ diff --git a/apps/hero/vita/sce_sys/livearea/contents/startup.png b/apps/hero/vita/sce_sys/livearea/contents/startup.png new file mode 100644 index 00000000..f1575037 Binary files /dev/null and b/apps/hero/vita/sce_sys/livearea/contents/startup.png differ diff --git a/apps/im/vita/sce_sys/livearea/contents/bg.png b/apps/im/vita/sce_sys/livearea/contents/bg.png new file mode 100644 index 00000000..30eb211d Binary files /dev/null and b/apps/im/vita/sce_sys/livearea/contents/bg.png differ diff --git a/apps/im/vita/sce_sys/livearea/contents/startup.png b/apps/im/vita/sce_sys/livearea/contents/startup.png new file mode 100644 index 00000000..62e0b674 Binary files /dev/null and b/apps/im/vita/sce_sys/livearea/contents/startup.png differ diff --git a/apps/library/vita/sce_sys/livearea/contents/bg.png b/apps/library/vita/sce_sys/livearea/contents/bg.png new file mode 100644 index 00000000..505220de Binary files /dev/null and b/apps/library/vita/sce_sys/livearea/contents/bg.png differ diff --git a/apps/library/vita/sce_sys/livearea/contents/startup.png b/apps/library/vita/sce_sys/livearea/contents/startup.png new file mode 100644 index 00000000..587e7e20 Binary files /dev/null and b/apps/library/vita/sce_sys/livearea/contents/startup.png differ diff --git a/apps/motions/app.tsx b/apps/motions/app.tsx index a39ca24e..aee7de4d 100644 --- a/apps/motions/app.tsx +++ b/apps/motions/app.tsx @@ -16,6 +16,7 @@ import { Show, createSignal } from "solid-js"; import type { JSX } from "solid-js"; import { Image, Text, View } from "@pocketjs/framework/components"; import { onButtonPress } from "@pocketjs/framework/lifecycle"; +import { createGesture } from "@pocketjs/framework/gesture"; import { BTN } from "@pocketjs/framework/input"; // --------------------------------------------------------------------------- @@ -654,8 +655,19 @@ const PAGES: PageDef[] = [ export default function Motions() { const [index, setIndex] = createSignal(0); const page = () => PAGES[index()]; - onButtonPress(BTN.RIGHT | BTN.RTRIGGER, () => setIndex((i) => (i + 1) % PAGES.length)); - onButtonPress(BTN.LEFT | BTN.LTRIGGER, () => setIndex((i) => (i + PAGES.length - 1) % PAGES.length)); + const next = () => setIndex((i) => (i + 1) % PAGES.length); + const prev = () => setIndex((i) => (i + PAGES.length - 1) % PAGES.length); + onButtonPress(BTN.RIGHT | BTN.RTRIGGER, next); + onButtonPress(BTN.LEFT | BTN.LTRIGGER, prev); + // Touch: the paging idiom is a horizontal swipe anywhere on the deck — + // swipe left = forward, matching the L/R buttons (docs/TOUCH.md §3). + createGesture({ + axis: "x", + onPanEnd: (c) => { + if (c.dx <= -40 || c.vx <= -300) next(); + else if (c.dx >= 40 || c.vx >= 300) prev(); + }, + }); return ( diff --git a/apps/motions/psp/Psp.toml b/apps/motions/psp/Psp.toml new file mode 100644 index 00000000..8c1dced8 --- /dev/null +++ b/apps/motions/psp/Psp.toml @@ -0,0 +1,7 @@ +# XMB metadata for the PocketJS Motions EBOOT. tools/psp.ts copies this to +# hosts/psp/Psp.toml when building this demo (any framework); cargo-psp packs +# it into PARAM.SFO / ICON0 / PIC1. +# Regenerate the art with: bun tools/gen-demo-covers.ts motions +title = "PocketJS Motions" +xmb_icon_png = "icon0.png" +xmb_background_png = "pic1.png" diff --git a/apps/motions/psp/icon0.png b/apps/motions/psp/icon0.png new file mode 100644 index 00000000..9c2360b7 Binary files /dev/null and b/apps/motions/psp/icon0.png differ diff --git a/apps/motions/psp/pic1.png b/apps/motions/psp/pic1.png new file mode 100644 index 00000000..962e31d7 Binary files /dev/null and b/apps/motions/psp/pic1.png differ diff --git a/apps/motions/vita/sce_sys/livearea/contents/bg.png b/apps/motions/vita/sce_sys/livearea/contents/bg.png new file mode 100644 index 00000000..4f711593 Binary files /dev/null and b/apps/motions/vita/sce_sys/livearea/contents/bg.png differ diff --git a/apps/motions/vita/sce_sys/livearea/contents/startup.png b/apps/motions/vita/sce_sys/livearea/contents/startup.png new file mode 100644 index 00000000..33253e36 Binary files /dev/null and b/apps/motions/vita/sce_sys/livearea/contents/startup.png differ diff --git a/apps/music/vita/sce_sys/livearea/contents/bg.png b/apps/music/vita/sce_sys/livearea/contents/bg.png new file mode 100644 index 00000000..68ab0aff Binary files /dev/null and b/apps/music/vita/sce_sys/livearea/contents/bg.png differ diff --git a/apps/music/vita/sce_sys/livearea/contents/startup.png b/apps/music/vita/sce_sys/livearea/contents/startup.png new file mode 100644 index 00000000..bb4f0e47 Binary files /dev/null and b/apps/music/vita/sce_sys/livearea/contents/startup.png differ diff --git a/apps/notifications/vita/sce_sys/livearea/contents/bg.png b/apps/notifications/vita/sce_sys/livearea/contents/bg.png new file mode 100644 index 00000000..df59533f Binary files /dev/null and b/apps/notifications/vita/sce_sys/livearea/contents/bg.png differ diff --git a/apps/notifications/vita/sce_sys/livearea/contents/startup.png b/apps/notifications/vita/sce_sys/livearea/contents/startup.png new file mode 100644 index 00000000..9192460e Binary files /dev/null and b/apps/notifications/vita/sce_sys/livearea/contents/startup.png differ diff --git a/apps/settings/vita/sce_sys/livearea/contents/bg.png b/apps/settings/vita/sce_sys/livearea/contents/bg.png new file mode 100644 index 00000000..474becdc Binary files /dev/null and b/apps/settings/vita/sce_sys/livearea/contents/bg.png differ diff --git a/apps/settings/vita/sce_sys/livearea/contents/startup.png b/apps/settings/vita/sce_sys/livearea/contents/startup.png new file mode 100644 index 00000000..567d2094 Binary files /dev/null and b/apps/settings/vita/sce_sys/livearea/contents/startup.png differ diff --git a/apps/stats/app.tsx b/apps/stats/app.tsx index 2518612d..02077810 100644 --- a/apps/stats/app.tsx +++ b/apps/stats/app.tsx @@ -11,6 +11,7 @@ import { createMemo, createSignal, onMount, Show } from "solid-js"; import { Text, View, type NodeMirror } from "@pocketjs/framework/components"; import { animate } from "@pocketjs/framework/animation"; import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle"; +import { createGesture } from "@pocketjs/framework/gesture"; import { BTN } from "@pocketjs/framework/input"; const COUNT_FRAMES = 75; @@ -161,11 +162,20 @@ export default function Stats() { const [tab, setTab] = createSignal(0); const [systemsFrame, setSystemsFrame] = createSignal(0); - onButtonPress(BTN.RIGHT, () => { + const toSystems = () => { setTab(1); setSystemsFrame(0); - }); + }; + onButtonPress(BTN.RIGHT, toSystems); onButtonPress(BTN.LEFT, () => setTab(0)); + // Touch: swipe between the two boards, mirroring LEFT/RIGHT. + createGesture({ + axis: "x", + onPanEnd: (c) => { + if (c.dx <= -40 || c.vx <= -300) toSystems(); + else if (c.dx >= 40 || c.vx >= 300) setTab(0); + }, + }); onFrame(() => { if (frameN() < COUNT_FRAMES) setFrameN(frameN() + 1); if (tab() === 1 && systemsFrame() < SYSTEMS_MAX_FRAMES) { diff --git a/apps/stats/vita/sce_sys/livearea/contents/bg.png b/apps/stats/vita/sce_sys/livearea/contents/bg.png new file mode 100644 index 00000000..1b9fca91 Binary files /dev/null and b/apps/stats/vita/sce_sys/livearea/contents/bg.png differ diff --git a/apps/stats/vita/sce_sys/livearea/contents/startup.png b/apps/stats/vita/sce_sys/livearea/contents/startup.png new file mode 100644 index 00000000..c7cb569e Binary files /dev/null and b/apps/stats/vita/sce_sys/livearea/contents/startup.png differ diff --git a/apps/zoomlab/psp/Psp.toml b/apps/zoomlab/psp/Psp.toml new file mode 100644 index 00000000..cf009e09 --- /dev/null +++ b/apps/zoomlab/psp/Psp.toml @@ -0,0 +1,7 @@ +# XMB metadata for the PocketJS ZoomLab EBOOT. tools/psp.ts copies this to +# hosts/psp/Psp.toml when building this demo (any framework); cargo-psp packs +# it into PARAM.SFO / ICON0 / PIC1. +# Regenerate the art with: bun tools/gen-demo-covers.ts zoomlab +title = "PocketJS ZoomLab" +xmb_icon_png = "icon0.png" +xmb_background_png = "pic1.png" diff --git a/apps/zoomlab/psp/icon0.png b/apps/zoomlab/psp/icon0.png new file mode 100644 index 00000000..642549aa Binary files /dev/null and b/apps/zoomlab/psp/icon0.png differ diff --git a/apps/zoomlab/psp/pic1.png b/apps/zoomlab/psp/pic1.png new file mode 100644 index 00000000..8face831 Binary files /dev/null and b/apps/zoomlab/psp/pic1.png differ diff --git a/apps/zoomlab/vita/sce_sys/livearea/contents/bg.png b/apps/zoomlab/vita/sce_sys/livearea/contents/bg.png new file mode 100644 index 00000000..82c07cc7 Binary files /dev/null and b/apps/zoomlab/vita/sce_sys/livearea/contents/bg.png differ diff --git a/apps/zoomlab/vita/sce_sys/livearea/contents/startup.png b/apps/zoomlab/vita/sce_sys/livearea/contents/startup.png new file mode 100644 index 00000000..e6f8ce1b Binary files /dev/null and b/apps/zoomlab/vita/sce_sys/livearea/contents/startup.png differ diff --git a/tests/golden-specs.ts b/tests/golden-specs.ts index 49b05316..34619bc2 100644 --- a/tests/golden-specs.ts +++ b/tests/golden-specs.ts @@ -238,6 +238,30 @@ export const GOLDEN_SPECS: GoldenSpec[] = [ return []; }, }, + { + // The motions pager swipe with a REAL THUMB's profile: the landing + // wobbles vertically before the horizontal sweep builds — the exact + // arc that exposed the single-shot axis verdict on hardware (a pure + // scripted line passed everywhere while devices never paged). f40 = + // page 1; f100 = page 2 reached through the deferred-dominance claim. + name: "motions-swipe", + app: "motions-main", + frames: 110, + capture: [40, 100], + input: () => 0, + touch: (f) => { + if (f >= 60 && f < 68) { + const i = f - 60; + // Frame 1 after the down moves 8px DOWN and only 2 across — the + // wrong axis crosses slop first, exactly like a landing thumb. + // The horizontal sweep takes over from frame 2. + const x = i <= 1 ? 360 - i * 2 : 358 - (i - 1) * 34; + const y = i <= 1 ? 140 + i * 8 : 148 + i; + return [{ id: 0, x, y }]; + } + return []; + }, + }, ]; export function encodeThresholdInput(spec: GoldenSpec): string { diff --git a/tests/sim.test.ts b/tests/sim.test.ts index a7a7fe08..25334404 100644 --- a/tests/sim.test.ts +++ b/tests/sim.test.ts @@ -93,6 +93,21 @@ describe("the virtual clock", () => { }); }); +describe("touch activation reaches the demo fleet", () => { + test("hero: taps on the button increment the count", async () => { + // The button is a bare Focusable with onPress — no touch code in the + // app. Each tap rides its hit fact into the default activation + // recognizer (docs/TOUCH.md §0) exactly like CIRCLE does. Four taps + // cross the count>3 gate, mounting an unambiguous beacon line. + const taps = [0.5, 0.7, 0.9, 1.1].flatMap((at, i) => [ + { at, touch: [{ id: i + 1, x: 70, y: 234 }] }, + { at: at + 0.1, touch: [] }, + ]); + const t = await runScenario({ app: "hero-main", hz: 60, seconds: 2, script: taps }); + expect(treeHasText(t.tree, "Reactive on real hardware.")).toBe(true); + }); +}); + describe("the journey actually happened", () => { test("order placed, cart reset, world settled", () => { // The trace's tree probe (a DevTools getTree after the final frame) is diff --git a/tools/gen-demo-covers.ts b/tools/gen-demo-covers.ts index 87aac48d..b7bb5df2 100644 --- a/tools/gen-demo-covers.ts +++ b/tools/gen-demo-covers.ts @@ -11,14 +11,29 @@ // bun tools/gen-demo-covers.ts (all demos) // bun tools/gen-demo-covers.ts hero music (a subset) -import { createCanvas, GlobalFonts, type SKRSContext2D } from "@napi-rs/canvas"; +import { createCanvas, GlobalFonts, type Canvas, type SKRSContext2D } from "@napi-rs/canvas"; import { mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { runScenario } from "../hosts/sim/sim.ts"; import { BTN } from "../contracts/spec/spec.ts"; const ROOT = new URL("../", import.meta.url).pathname; GlobalFonts.registerFromPath(ROOT + "assets/fonts/Inter-Bold.ttf", "Inter"); +// Vita LiveArea art must be indexed PNG-8 (tools/vita-package.ts validates +// color type 3, non-interlaced, <=420 KiB) — same ImageMagick dependency and +// conversion the default sce_sys assets use (tools/generate-vita-livearea.ts). +const magick = Bun.which("magick"); +let png8Seq = 0; +async function writePng8(canvas: Canvas, out: string): Promise { + if (!magick) throw new Error("gen-demo-covers: ImageMagick `magick` is required for Vita PNG8 art"); + const tmp = join(tmpdir(), `pocketjs-cover-${process.pid}-${png8Seq++}.png`); + await Bun.write(tmp, canvas.toBuffer("image/png")); + const child = Bun.spawn([magick, tmp, "-strip", `PNG8:${out}`], { stderr: "inherit" }); + if ((await child.exited) !== 0) throw new Error(`magick PNG8 conversion failed for ${out}`); +} + interface DemoCover { dir: string; bundle: string; @@ -29,6 +44,9 @@ interface DemoCover { /** Seconds of sim time before the PIC1 screenshot. */ seconds: number; script?: { at: number; press: number }[]; + /** false = leave the demo's bespoke PSP art alone; emit Vita LiveArea only + * (apps/im keeps its hand-made psp/gen-cover.ts output). */ + pspArt?: boolean; mark: (g: SKRSContext2D) => void; } @@ -211,6 +229,127 @@ const DEMOS: DemoCover[] = [ g.fill(); }, }, + { + dir: "cafe", + bundle: "cafe-main", + title: "PocketJS Cafe", + word: ["POCKET", "CAFE"], + accent: "#f59e0b", + seconds: 2.0, + script: [{ at: 1.2, press: BTN.CIRCLE }], + mark: (g) => { + roundRect(g, 16, 30, 30, 26, 6); + g.fillStyle = "#f59e0b"; + g.fill(); + g.beginPath(); + g.arc(50, 40, 8, -Math.PI / 2, Math.PI / 2); + g.lineWidth = 5; + g.strokeStyle = "#b45309"; + g.stroke(); + for (const x of [24, 34]) { + roundRect(g, x, 16, 4, 9, 2); + g.fillStyle = "#fcd34d"; + g.fill(); + } + }, + }, + { + dir: "chrome", + bundle: "chrome-main", + title: "PocketJS Chrome", + word: ["POCKET", "CHROME"], + accent: "#a78bfa", + seconds: 1.5, + mark: (g) => { + for (const [i, hue] of ["#7c3aed", "#a78bfa", "#ddd6fe"].entries()) { + g.beginPath(); + g.moveTo(14 + i * 6, 18); + g.bezierCurveTo(30 + i * 6, 30, 22 + i * 6, 46, 38 + i * 6, 60); + g.lineWidth = 6; + g.strokeStyle = hue; + g.stroke(); + } + }, + }, + { + dir: "cursor", + bundle: "cursor-main", + title: "PocketJS Cursor", + word: ["POCKET", "CURSOR"], + accent: "#f472b6", + seconds: 1.5, + mark: (g) => { + g.beginPath(); + g.moveTo(20, 16); + g.lineTo(20, 56); + g.lineTo(30, 46); + g.lineTo(38, 60); + g.lineTo(44, 56); + g.lineTo(36, 43); + g.lineTo(50, 41); + g.closePath(); + g.fillStyle = "#f472b6"; + g.fill(); + }, + }, + { + dir: "motions", + bundle: "motions-main", + title: "PocketJS Motions", + word: ["POCKET", "MOTIONS"], + accent: "#fb7185", + seconds: 1.5, + mark: (g) => { + for (const [i, r] of [14, 10, 6].entries()) { + g.beginPath(); + g.arc(24 + i * 14, 38 + (i % 2 ? 10 : -6), r, 0, Math.PI * 2); + g.fillStyle = i % 2 ? "#fb7185" : "#fda4af"; + g.fill(); + } + }, + }, + { + dir: "zoomlab", + bundle: "zoomlab-main", + title: "PocketJS ZoomLab", + word: ["POCKET", "ZOOM"], + accent: "#38bdf8", + seconds: 1.5, + mark: (g) => { + g.beginPath(); + g.arc(30, 34, 14, 0, Math.PI * 2); + g.lineWidth = 6; + g.strokeStyle = "#38bdf8"; + g.stroke(); + g.beginPath(); + g.moveTo(41, 45); + g.lineTo(52, 58); + g.lineWidth = 7; + g.strokeStyle = "#0ea5e9"; + g.stroke(); + }, + }, + { + dir: "im", + bundle: "im-main", + title: "Pocket Talk", + word: ["POCKET", "TALK"], + accent: "#4ade80", + seconds: 1.5, + pspArt: false, + mark: (g) => { + roundRect(g, 14, 20, 40, 28, 9); + g.fillStyle = "#4ade80"; + g.fill(); + g.beginPath(); + g.moveTo(24, 48); + g.lineTo(24, 58); + g.lineTo(34, 48); + g.closePath(); + g.fillStyle = "#4ade80"; + g.fill(); + }, + }, ]; const only = new Set(Bun.argv.slice(2)); @@ -218,53 +357,62 @@ const selected = only.size === 0 ? DEMOS : DEMOS.filter((d) => only.has(d.dir)); if (selected.length === 0) throw new Error(`no demos match: ${[...only].join(", ")}`); for (const demo of selected) { - const out = `${ROOT}apps/${demo.dir}/psp/`; - mkdirSync(out, { recursive: true }); - - // ICON0 — 144×80 family tile: mark left, two-line wordmark right, accent rule. - { - const c = createCanvas(144, 80); - const g = c.getContext("2d"); - roundRect(g, 0.5, 0.5, 143, 79, 10); - g.fillStyle = "#0a1118"; - g.fill(); - g.strokeStyle = "#22333f"; - g.lineWidth = 1; - g.stroke(); - demo.mark(g); - g.fillStyle = "#e8f0f2"; - g.font = "bold 15px Inter"; - g.fillText(demo.word[0], 66, 36); - g.fillText(demo.word[1], 66, 55); - g.fillStyle = demo.accent; - g.fillRect(67, 61, 26, 2); - await Bun.write(out + "icon0.png", c.toBuffer("image/png")); - } - - // PIC1 — a real frame of the demo via the sim pump the goldens use. + // ONE deterministic sim frame per demo (the same pump the golden suites + // use) feeds every rendition: the PSP PIC1 and the Vita LiveArea pair. + const trace = await runScenario({ + app: demo.bundle, + hz: 60, + seconds: demo.seconds, + script: demo.script, + }); + const frame = createCanvas(480, 272); { - const trace = await runScenario({ - app: demo.bundle, - hz: 60, - seconds: demo.seconds, - script: demo.script, - }); - const c = createCanvas(480, 272); - const g = c.getContext("2d"); + const g = frame.getContext("2d"); const img = g.createImageData(480, 272); img.data.set(trace.finalFrame); g.putImageData(img, 0, 0); - g.fillStyle = "rgba(0,0,0,0.30)"; - g.fillRect(0, 0, 480, 272); - const grad = g.createLinearGradient(0, 0, 480, 0); - grad.addColorStop(0, "rgba(0,0,0,0.45)"); - grad.addColorStop(0.55, "rgba(0,0,0,0)"); - g.fillStyle = grad; - g.fillRect(0, 0, 480, 272); - await Bun.write(out + "pic1.png", c.toBuffer("image/png")); } - const toml = `# XMB metadata for the ${demo.title} EBOOT. tools/psp.ts copies this to + if (demo.pspArt !== false) { + const out = `${ROOT}apps/${demo.dir}/psp/`; + mkdirSync(out, { recursive: true }); + + // ICON0 — 144×80 family tile: mark left, two-line wordmark right, accent rule. + { + const c = createCanvas(144, 80); + const g = c.getContext("2d"); + roundRect(g, 0.5, 0.5, 143, 79, 10); + g.fillStyle = "#0a1118"; + g.fill(); + g.strokeStyle = "#22333f"; + g.lineWidth = 1; + g.stroke(); + demo.mark(g); + g.fillStyle = "#e8f0f2"; + g.font = "bold 15px Inter"; + g.fillText(demo.word[0], 66, 36); + g.fillText(demo.word[1], 66, 55); + g.fillStyle = demo.accent; + g.fillRect(67, 61, 26, 2); + await Bun.write(out + "icon0.png", c.toBuffer("image/png")); + } + + // PIC1 — the frame, dimmed left-heavy so the XMB column stays legible. + { + const c = createCanvas(480, 272); + const g = c.getContext("2d"); + g.drawImage(frame, 0, 0); + g.fillStyle = "rgba(0,0,0,0.30)"; + g.fillRect(0, 0, 480, 272); + const grad = g.createLinearGradient(0, 0, 480, 0); + grad.addColorStop(0, "rgba(0,0,0,0.45)"); + grad.addColorStop(0.55, "rgba(0,0,0,0)"); + g.fillStyle = grad; + g.fillRect(0, 0, 480, 272); + await Bun.write(out + "pic1.png", c.toBuffer("image/png")); + } + + const toml = `# XMB metadata for the ${demo.title} EBOOT. tools/psp.ts copies this to # hosts/psp/Psp.toml when building this demo (any framework); cargo-psp packs # it into PARAM.SFO / ICON0 / PIC1. # Regenerate the art with: bun tools/gen-demo-covers.ts ${demo.dir} @@ -272,6 +420,41 @@ title = "${demo.title}" xmb_icon_png = "icon0.png" xmb_background_png = "pic1.png" `; - await Bun.write(out + "Psp.toml", toml); - console.log(`covers: apps/${demo.dir}/psp/ (icon0 144x80, pic1 480x272, "${demo.title}")`); + await Bun.write(out + "Psp.toml", toml); + console.log(`covers: apps/${demo.dir}/psp/ (icon0 144x80, pic1 480x272, "${demo.title}")`); + } + + // Vita LiveArea — the app ITSELF is the preview, not a brand logo + // (tools/vita.ts overlays apps//vita/ onto the default sce_sys; the + // square icon0 bubble stays the framework default). + { + const vitaOut = `${ROOT}apps/${demo.dir}/vita/sce_sys/livearea/contents/`; + mkdirSync(vitaOut, { recursive: true }); + + // bg.png 840×500: the frame at ×1.75 between two 12px family bands, + // gently dimmed so the LiveArea gate text stays legible. + { + const c = createCanvas(840, 500); + const g = c.getContext("2d"); + g.fillStyle = "#0a1118"; + g.fillRect(0, 0, 840, 500); + g.imageSmoothingEnabled = true; + g.drawImage(frame, 0, 12, 840, 476); + g.fillStyle = "rgba(0,0,0,0.18)"; + g.fillRect(0, 12, 840, 476); + g.fillStyle = demo.accent; + g.fillRect(0, 494, 840, 6); + await writePng8(c, vitaOut + "bg.png"); + } + + // startup.png 280×158: the same frame, full-bleed. + { + const c = createCanvas(280, 158); + const g = c.getContext("2d"); + g.imageSmoothingEnabled = true; + g.drawImage(frame, 0, 0, 280, 158); + await writePng8(c, vitaOut + "startup.png"); + } + console.log(`covers: apps/${demo.dir}/vita/ (bg 840x500, startup 280x158)`); + } } diff --git a/tools/psp-all.ts b/tools/psp-all.ts index bf344364..e7c3b0a5 100644 --- a/tools/psp-all.ts +++ b/tools/psp-all.ts @@ -31,7 +31,7 @@ // is 100% a pure function of its text. import { $ } from "bun"; -import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { deflateSync } from "node:zlib"; @@ -58,6 +58,19 @@ const MAIN_SUFFIX = "-main.tsx"; // Demo discovery + metadata // --------------------------------------------------------------------------- +/** Not memory-stick material: framework labs and per-framework variants of + * hero, the launcher (its real EBOOT embeds a registry via + * tools/launcher.ts — a bare build would collide with that install), and + * apps whose primary target is another host class entirely. */ +const SKIP = new Set([ + "launcher", + "note", + "ipod-nano", + "vue-sfc-lab", + "hero-vue-sfc", + "hero-vue-vapor", +]); + function listDemos(): string[] { const names = new Set(); for (const f of readdirSync(demosDir)) { @@ -66,6 +79,7 @@ function listDemos(): string[] { else if (f.endsWith(MAIN_SUFFIX)) names.add(f.slice(0, -MAIN_SUFFIX.length)); } return [...names] + .filter((n) => !SKIP.has(n)) .sort(); } @@ -609,7 +623,15 @@ if (import.meta.main) { const folder = folderName(name); const destDir = join(pspGameRoot, folder); mkdirSync(destDir, { recursive: true }); - await repackEboot(name, title, eboot, destDir); + if (existsSync(join(demosDir, name, "psp", "Psp.toml"))) { + // The demo carries a committed cover fragment (tools/gen-demo-covers.ts + // -> apps//psp/): cargo-psp already packed its ICON0/PIC1/title, + // so ship the EBOOT untouched instead of repacking procedural art + // over the authored covers. + copyFileSync(eboot, join(destDir, "EBOOT.PBP")); + } else { + await repackEboot(name, title, eboot, destDir); + } built.push({ name, title, folder }); } diff --git a/tools/vita-all.ts b/tools/vita-all.ts new file mode 100644 index 00000000..8604c8a8 --- /dev/null +++ b/tools/vita-all.ts @@ -0,0 +1,89 @@ +// tools/vita-all.ts — package EVERY PocketJS demo into installable Vita VPKs, +// each with its own LiveArea art (apps//vita/ from +// tools/gen-demo-covers.ts: the app's real frame as the full-screen preview, +// the framework's square icon0 bubble) and a unique TITLE_ID derived from the +// demo's own application id — side-by-side installs never collide. +// +// bun tools/vita-all.ts [--debug] # default: --release +// +// Output: dist/vita-all/PocketJS-.vpk. Copy them to ux0:vpk/ and +// install each with VitaShell. This script only writes under dist/ — it +// never touches a mounted device itself. +// +// The demo set mirrors tools/psp-all.ts: the same SKIP list (framework labs +// and per-framework hero variants, the launcher with its registry-embedding +// pipeline, apps whose primary target is another host class). A demo whose +// manifest does not admit the vita target is skipped with a note, not a +// failure — admission is the manifest's call, not this script's. + +import { $ } from "bun"; +import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = fileURLToPath(new URL("../", import.meta.url)); +const demosDir = join(rootDir, "apps"); +const outRoot = join(rootDir, "dist/vita-all"); + +const args = Bun.argv.slice(2); +const release = !args.includes("--debug"); + +const SKIP = new Set([ + "launcher", + "note", + "ipod-nano", + "vue-sfc-lab", + "hero-vue-sfc", + "hero-vue-vapor", +]); + +function listDemos(): string[] { + const names: string[] = []; + for (const f of readdirSync(demosDir)) { + const path = join(demosDir, f); + if (statSync(path).isDirectory() && existsSync(join(path, "main.tsx")) && !SKIP.has(f)) { + names.push(f); + } + } + return names.sort(); +} + +const demos = listDemos(); +if (demos.length === 0) { + console.error(`no demos found under ${demosDir}`); + process.exit(1); +} + +rmSync(outRoot, { recursive: true, force: true }); +mkdirSync(outRoot, { recursive: true }); + +const built: string[] = []; +const skipped: { name: string; reason: string }[] = []; + +for (const [index, name] of demos.entries()) { + console.log(`[${index + 1}/${demos.length}] building ${name}`); + try { + await $`bun tools/vita.ts ${name} ${release ? "--release" : ""}`.cwd(rootDir); + } catch (e) { + skipped.push({ name, reason: e instanceof Error ? e.message.slice(0, 120) : String(e) }); + console.log(` skipped (${name}: build/admission refused)`); + continue; + } + // tools/vita.ts packages to dist/vita/.vpk; take the newest one. + const vitaDir = join(rootDir, "dist/vita"); + const vpks = readdirSync(vitaDir) + .filter((f) => f.endsWith(".vpk")) + .map((f) => ({ f, t: statSync(join(vitaDir, f)).mtimeMs })) + .sort((a, b) => b.t - a.t); + if (vpks.length === 0) { + skipped.push({ name, reason: "no VPK produced" }); + continue; + } + const dest = join(outRoot, `PocketJS-${name}.vpk`); + copyFileSync(join(vitaDir, vpks[0].f), dest); + built.push(name); +} + +console.log(`\nBuilt ${built.length} Vita VPK(s): ${outRoot}`); +for (const s of skipped) console.log(`skipped ${s.name}: ${s.reason}`); +console.log("Copy the .vpk files to ux0:vpk/ and install each with VitaShell."); diff --git a/tools/vita.ts b/tools/vita.ts index ea23d723..0df827c6 100644 --- a/tools/vita.ts +++ b/tools/vita.ts @@ -308,11 +308,17 @@ if (!existsSync(eboot)) throw new Error(`PocketJS vita: eboot not found at ${eboot}`); await $`${vitasdk}/bin/vita-mksfoex -d ATTRIBUTE2=12 -s TITLE_ID=${titleId} ${packageTitle} ${sfo}`; +// A demo carrying apps//vita/ (tools/gen-demo-covers.ts) overlays its +// own LiveArea art onto the framework defaults — the app itself becomes the +// full-screen preview instead of the brand logo. The square icon0 bubble +// stays the default unless the fragment overrides it too. +const appAssets = `${pspUiDir}apps/${appArg}/vita`; await packageVitaVpk({ tool: `${vitasdk}/bin/vita-pack-vpk`, sfo, eboot, output: vpk, + applicationAssets: appArg && existsSync(appAssets) ? appAssets : undefined, }); const packagedDirectory = packageOutputDir ?? resolvePath(outputDir, "vita");