diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 76707a8..4cea05e 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -40,6 +40,8 @@ jobs: - name: Build site run: npm run build + env: + PIXELFORGE_BUILD_SHA: ${{ github.sha }} - name: Upload Pages artifact uses: actions/upload-pages-artifact@v5 @@ -80,3 +82,5 @@ jobs: - name: Post-deploy health check run: node scripts/check-deploy-health.mjs "${{ needs.deploy.outputs.page_url }}" + env: + PIXELFORGE_EXPECTED_BUILD: ${{ github.sha }} diff --git a/README.md b/README.md index cfb8016..e42b686 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Browser visual smoke (builds, serves the preview, and drives headless Chrome acr npm run smoke:visual ``` -CI on `main` runs lint, tests, build, the bundle budget, and the browser visual smoke. Deployment to GitHub Pages is handled by `.github/workflows/deploy-pages.yml` after CI succeeds on `main`, with a manual dispatch fallback; a post-deploy health check then verifies the published page and its hashed script/stylesheet assets (`npm run check:deploy` runs the same check locally). +CI on `main` runs lint, tests, build, the bundle budget, and the browser visual smoke. Deployment to GitHub Pages is handled by `.github/workflows/deploy-pages.yml` after CI succeeds on `main`, with a manual dispatch fallback; a post-deploy health check then verifies the published page carries the build stamp for the deployed commit (``, injected at build time) and that its hashed script/stylesheet assets load (`npm run check:deploy` runs the same check locally; pass `--expect-build ` to enforce the stamp). ## Security notes diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index a6a5fe7..c46893d 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -201,13 +201,14 @@ PixelForge can generate images from a text prompt directly into a new layer. You 2. Click **Settings** (opens the AI Settings modal). 3. Paste your **Anthropic API key** (used to refine the prompt). 4. Paste your **Replicate API key** (used to render the image). -5. Click **Save**. Keys are stored in this browser's local storage only. +5. Paste the URL of a **CORS proxy** you run yourself that forwards requests to `api.replicate.com`. Replicate's API does not send CORS headers, so the browser cannot call it directly; without a proxy every generation fails at the request step. +6. Click **Save**. Keys are stored in this tab's session storage only and are cleared when the tab closes. Get keys from: - **Anthropic:** https://console.anthropic.com/ → API Keys - **Replicate:** https://replicate.com/account/api-tokens -Your keys never leave your browser, never enter `.pforge` save files, and never enter autosaved drafts. +Your keys never enter `.pforge` save files or autosaved drafts. The Anthropic key goes only to `api.anthropic.com`. The Replicate key transits the CORS proxy you configured, so only use a proxy you control. ### Generating @@ -343,7 +344,10 @@ Check that the active layer is **visible** (eye icon not crossed out) and has ** The Brush works only on raster layers. Add one via **+ Raster** in the Layers section, or let PixelForge auto-switch by clicking the highlighted layer. **"AI Generate says 'Set your API keys'."** -Open **✨ Generate → Settings** and paste both keys. Keys are stored in your browser's local storage. +Open **✨ Generate → Settings** and paste both keys. Keys are stored in this tab's session storage, so a new tab or a restarted browser needs them again. + +**"AI generation says it could not reach Replicate."** +Replicate blocks direct browser calls. Set the CORS proxy URL in AI Settings to a proxy you run that forwards to `api.replicate.com`. **"AI generation failed."** - Check your key validity on the provider dashboard diff --git a/package.json b/package.json index c2bd376..325bec0 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "budget": "node scripts/check-bundle-size.mjs", "smoke:visual": "node scripts/run-visual-smoke.mjs", "check:deploy": "node scripts/check-deploy-health.mjs", + "ops:state": "node tools/ops/build-ops-state.mjs", + "ops:validate": "node tools/ops/validate-ops-state.mjs", "ci": "node scripts/verify-runtime.mjs && npm run lint && npm run test && npm run build && npm run budget" }, "dependencies": { diff --git a/scripts/check-deploy-health.mjs b/scripts/check-deploy-health.mjs index 65af170..9d66c03 100644 --- a/scripts/check-deploy-health.mjs +++ b/scripts/check-deploy-health.mjs @@ -2,23 +2,40 @@ // // Fetches the deployed page, confirms the app shell markup is present, then // fetches the hashed module script and stylesheet the page references so a -// broken asset upload cannot pass. Retries to ride out Pages propagation. +// broken asset upload cannot pass. When an expected build stamp is supplied +// (PIXELFORGE_EXPECTED_BUILD or --expect-build), the page must carry that +// stamp in its tag, so a stale or partial +// deployment cannot pass just because some earlier build is still served. +// Retries to ride out Pages propagation. // -// Usage: node scripts/check-deploy-health.mjs [baseUrl] +// Usage: node scripts/check-deploy-health.mjs [baseUrl] [--expect-build ] // baseUrl defaults to PIXELFORGE_DEPLOY_URL or the live GitHub Pages origin. import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; -const baseUrl = normalizeBase( - process.argv[2] || process.env.PIXELFORGE_DEPLOY_URL || "https://davehomeassist.github.io/PixelForge/", -); -const maxAttempts = Number(process.env.PIXELFORGE_HEALTH_ATTEMPTS || 10); -const retryDelayMs = Number(process.env.PIXELFORGE_HEALTH_RETRY_MS || 6000); +export const BUILD_META_NAME = "pixelforge-build"; -function normalizeBase(url) { +export function normalizeBase(url) { return url.endsWith("/") ? url : `${url}/`; } +export function parseArgs(argv) { + const positional = []; + let expectedBuild = null; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--expect-build") { + expectedBuild = argv[index + 1] || null; + index += 1; + } else { + positional.push(arg); + } + } + return { baseUrl: positional[0] || null, expectedBuild }; +} + async function fetchOk(url, accept) { const response = await fetch(url, { redirect: "follow", headers: { accept } }); if (!response.ok) { @@ -31,7 +48,7 @@ async function fetchOk(url, accept) { return { body, contentType: response.headers.get("content-type") || "" }; } -function extractSameOriginAssets(html) { +export function extractSameOriginAssets(html, baseUrl) { const assets = []; const patterns = [ { kind: "script", regex: /]*type="module"[^>]*src="([^"]+)"/g }, @@ -48,7 +65,13 @@ function extractSameOriginAssets(html) { return assets; } -async function checkOnce() { +export function extractBuildStamp(html) { + const match = html.match(/ asset.kind === "script"); const hasStylesheet = assets.some(asset => asset.kind === "stylesheet"); if (!hasScript || !hasStylesheet) { @@ -81,10 +117,18 @@ async function checkOnce() { } async function main() { + const args = parseArgs(process.argv.slice(2)); + const baseUrl = normalizeBase( + args.baseUrl || process.env.PIXELFORGE_DEPLOY_URL || "https://davehomeassist.github.io/PixelForge/", + ); + const expectedBuild = (args.expectedBuild || process.env.PIXELFORGE_EXPECTED_BUILD || "").trim() || null; + const maxAttempts = Number(process.env.PIXELFORGE_HEALTH_ATTEMPTS || 10); + const retryDelayMs = Number(process.env.PIXELFORGE_HEALTH_RETRY_MS || 6000); + let lastError; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { - await checkOnce(); + await checkOnce({ baseUrl, expectedBuild }); console.log(`[health] Deployment healthy at ${baseUrl}`); return; } catch (error) { @@ -98,7 +142,10 @@ async function main() { throw lastError; } -main().catch(error => { - console.error("[health] Failed:", error.message); - process.exitCode = 1; -}); +const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) { + main().catch(error => { + console.error("[health] Failed:", error.message); + process.exitCode = 1; + }); +} diff --git a/src/AppPages.css b/src/AppPages.css index 4d27ed6..7d11f32 100644 --- a/src/AppPages.css +++ b/src/AppPages.css @@ -213,6 +213,7 @@ .pf-quick-card strong, .pf-template-card strong, .pf-recent-file strong, .pf-preset-card strong { font-size: 14px; } .pf-quick-card small, .pf-template-card small, .pf-template-card em, .pf-recent-file small { color: var(--page-muted); font-size: 12px; font-style: normal; } .pf-recent-list { display: grid; gap: 8px; } +.pf-recent-empty { margin: 0; padding: 14px 16px; border: 1px dashed var(--page-line-strong); border-radius: 10px; color: var(--page-muted); font-size: 13px; } .pf-recent-file { display: grid; grid-template-columns: auto minmax(0,1fr) auto auto; align-items: center; gap: 12px; padding: 11px 12px; text-align: left; } .pf-recent-file > span:nth-child(3), .pf-recent-file > span:nth-child(4) { display: inline-flex; align-items: center; gap: 6px; color: var(--page-muted); font-size: 12px; } .pf-file-thumb { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 7px; background: rgba(93,159,216,0.16); color: var(--page-blue); } diff --git a/src/PixelForge.jsx b/src/PixelForge.jsx index e434bdb..f4130e9 100644 --- a/src/PixelForge.jsx +++ b/src/PixelForge.jsx @@ -42,6 +42,7 @@ import { cloneShape, mergePrefs, getToolRequirement, isToolCompatibleWithLayer, normalizePanelTab, } from "./utils.js"; import { renderEditor } from "./render.js"; +import { consumeLaunchIntent } from "./launchIntent.js"; import { commitFloat } from "./marquee.js"; import { cropToRect, trimTransparent, rotateCanvas, flipCanvas } from "./canvasOps.js"; import { hitShape } from "./shapes.js"; @@ -715,8 +716,18 @@ export default function PixelForge() { /* ─── Init ─── */ useEffect(() => { - resetDocument(); - }, [resetDocument]); + const intent = consumeLaunchIntent(); + if (!intent) { + resetDocument(); + return; + } + resetDocument(intent.width, intent.height, intent.background || DEFAULT_BG); + if (!intent.backgroundSupported) { + requestAnimationFrame(() => { + flash(`Background "${intent.requestedBackground}" is not supported yet. Opened with a white background.`, "info", 3200); + }); + } + }, [flash, resetDocument]); /* ─── Render ─── */ const renderFrame = useEffectEvent(() => { diff --git a/src/__tests__/deployHealth.test.js b/src/__tests__/deployHealth.test.js new file mode 100644 index 0000000..8762d68 --- /dev/null +++ b/src/__tests__/deployHealth.test.js @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createServer } from "node:http"; +import { + checkOnce, + extractBuildStamp, + extractSameOriginAssets, + parseArgs, +} from "../../scripts/check-deploy-health.mjs"; + +const PAGE = (stamp) => ` + +PixelForge + + + +
`; + +function serve(routes) { + const server = createServer((req, res) => { + const route = routes[req.url]; + if (!route) { + res.statusCode = 404; + res.end("missing"); + return; + } + res.setHeader("content-type", route.type); + res.end(route.body); + }); + return new Promise(resolve => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ server, baseUrl: `http://127.0.0.1:${port}/PixelForge/` }); + }); + }); +} + +describe("check-deploy-health parsing", () => { + it("parses positional base url and expected build flag", () => { + expect(parseArgs(["https://example.test/PixelForge/", "--expect-build", "abc123"])).toEqual({ + baseUrl: "https://example.test/PixelForge/", + expectedBuild: "abc123", + }); + expect(parseArgs([])).toEqual({ baseUrl: null, expectedBuild: null }); + }); + + it("extracts the build stamp and only same-origin assets", () => { + const html = PAGE("deadbeef"); + expect(extractBuildStamp(html)).toBe("deadbeef"); + expect(extractBuildStamp("")).toBeNull(); + const assets = extractSameOriginAssets(html, "https://example.test/PixelForge/"); + expect(assets.map(asset => asset.kind)).toEqual(["script", "stylesheet"]); + expect(assets.every(asset => asset.url.startsWith("https://example.test/"))).toBe(true); + }); +}); + +describe("check-deploy-health against a served page", () => { + let active = null; + afterEach(async () => { + if (active) await new Promise(resolve => active.close(resolve)); + active = null; + }); + + async function serveBuild(stamp) { + const served = await serve({ + "/PixelForge/": { type: "text/html; charset=utf-8", body: PAGE(stamp) }, + "/PixelForge/assets/index-abc.js": { type: "text/javascript", body: "console.log(1)" }, + "/PixelForge/assets/index-abc.css": { type: "text/css", body: "body{}" }, + }); + active = served.server; + return served.baseUrl; + } + + it("passes when the served build matches the expected stamp", async () => { + const baseUrl = await serveBuild("abc123"); + await expect(checkOnce({ baseUrl, expectedBuild: "abc123" })).resolves.toBeUndefined(); + }); + + it("fails when the served build is stale", async () => { + const baseUrl = await serveBuild("older"); + await expect(checkOnce({ baseUrl, expectedBuild: "abc123" })).rejects.toThrow(/is build older, expected abc123/); + }); + + it("fails when the page carries no stamp but one is expected", async () => { + const served = await serve({ + "/PixelForge/": { type: "text/html", body: PAGE("x").replace(/]*>/, "") }, + }); + active = served.server; + await expect(checkOnce({ baseUrl: served.baseUrl, expectedBuild: "abc123" })).rejects.toThrow(/carries no pixelforge-build stamp/); + }); + + it("fails when a referenced asset is missing", async () => { + const served = await serve({ + "/PixelForge/": { type: "text/html", body: PAGE("abc123") }, + "/PixelForge/assets/index-abc.css": { type: "text/css", body: "body{}" }, + }); + active = served.server; + await expect(checkOnce({ baseUrl: served.baseUrl, expectedBuild: null })).rejects.toThrow(/index-abc\.js returned 404/); + }); +}); diff --git a/src/__tests__/launchIntent.test.jsx b/src/__tests__/launchIntent.test.jsx new file mode 100644 index 0000000..8077ddd --- /dev/null +++ b/src/__tests__/launchIntent.test.jsx @@ -0,0 +1,101 @@ +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import PixelForge from "../PixelForge.jsx"; +import { renderEditor } from "../render.js"; +import { + LAUNCH_INTENT_KEY, + consumeLaunchIntent, + normalizeLaunchIntent, + writeLaunchIntent, +} from "../launchIntent.js"; +import { DEFAULT_BG, DEFAULT_H, DEFAULT_W } from "../constants.js"; + +vi.mock("../render.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renderEditor: vi.fn(), + }; +}); + +class ResizeObserverMock { + observe() {} + disconnect() {} +} + +function setViewportSize(width = 1000, height = 800) { + Object.defineProperty(HTMLElement.prototype, "clientWidth", { configurable: true, value: width }); + Object.defineProperty(HTMLElement.prototype, "clientHeight", { configurable: true, value: height }); +} + +function installAnimationFrame() { + if (globalThis.requestAnimationFrame) return; + globalThis.requestAnimationFrame = callback => window.setTimeout(() => callback(performance.now()), 0); + globalThis.cancelAnimationFrame = id => window.clearTimeout(id); + window.requestAnimationFrame = globalThis.requestAnimationFrame; + window.cancelAnimationFrame = globalThis.cancelAnimationFrame; +} + +describe("launchIntent contract", () => { + beforeEach(() => { + window.sessionStorage.clear(); + }); + + it("round-trips a launcher draft and consumes it exactly once", () => { + expect(writeLaunchIntent({ preset: "hd", width: 1920, height: 1080, background: "white" })).toBe(true); + expect(window.sessionStorage.getItem(LAUNCH_INTENT_KEY)).not.toBeNull(); + const intent = consumeLaunchIntent(); + expect(intent).toMatchObject({ preset: "hd", width: 1920, height: 1080, background: DEFAULT_BG, backgroundSupported: true }); + expect(window.sessionStorage.getItem(LAUNCH_INTENT_KEY)).toBeNull(); + expect(consumeLaunchIntent()).toBeNull(); + }); + + it("clamps dimensions to the document model and reports unsupported backgrounds", () => { + const intent = normalizeLaunchIntent({ width: "20000", height: "12", background: "dark" }); + expect(intent.width).toBe(8192); + expect(intent.height).toBe(64); + expect(intent.background).toBeNull(); + expect(intent.requestedBackground).toBe("dark"); + expect(intent.backgroundSupported).toBe(false); + }); + + it("falls back to defaults for garbage input", () => { + expect(normalizeLaunchIntent(null)).toBeNull(); + expect(normalizeLaunchIntent({ width: "abc", height: -4 })).toMatchObject({ width: DEFAULT_W, height: DEFAULT_H, backgroundSupported: true }); + window.sessionStorage.setItem(LAUNCH_INTENT_KEY, "{not json"); + expect(consumeLaunchIntent()).toBeNull(); + expect(window.sessionStorage.getItem(LAUNCH_INTENT_KEY)).toBeNull(); + }); +}); + +describe("editor honors launch intent", () => { + beforeEach(() => { + window.sessionStorage.clear(); + setViewportSize(); + installAnimationFrame(); + globalThis.ResizeObserver = ResizeObserverMock; + window.ResizeObserver = ResizeObserverMock; + renderEditor.mockClear(); + }); + + afterEach(() => { + cleanup(); + window.sessionStorage.clear(); + }); + + it("opens the document size chosen in the launcher", async () => { + writeLaunchIntent({ preset: "story", width: 1080, height: 1920, background: "white" }); + await act(async () => { + render(); + }); + await waitFor(() => expect(screen.getAllByText("1080 × 1920").length).toBeGreaterThan(0)); + expect(window.sessionStorage.getItem(LAUNCH_INTENT_KEY)).toBeNull(); + }); + + it("opens the default document when no intent is present", async () => { + await act(async () => { + render(); + }); + await waitFor(() => expect(screen.getAllByText(`${DEFAULT_W} × ${DEFAULT_H}`).length).toBeGreaterThan(0)); + }); +}); diff --git a/src/__tests__/opsState.test.js b/src/__tests__/opsState.test.js new file mode 100644 index 0000000..f427643 --- /dev/null +++ b/src/__tests__/opsState.test.js @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { + DEFAULT_SCHEMA_PATH, + DEFAULT_STATE_PATH, + loadSchema, + validateOpsState, + validateOpsStateFile, +} from "../../tools/ops/validate-ops-state.mjs"; + +describe("ops-state contract", () => { + it("committed ops-state.json conforms to ops-state.schema.json", () => { + expect(validateOpsStateFile(DEFAULT_STATE_PATH, DEFAULT_SCHEMA_PATH)).toEqual([]); + }); + + it("committed ops-state.json reports the generator that owns it", () => { + const state = JSON.parse(readFileSync(DEFAULT_STATE_PATH, "utf8")); + expect(state.metadata.generator).toBe("tools/ops/build-ops-state.mjs"); + expect(state.metadata.schemaVersion).toBe(2); + }); + + it("rejects documents that break the contract", () => { + const schema = loadSchema(); + const valid = JSON.parse(readFileSync(DEFAULT_STATE_PATH, "utf8")); + + const badStatus = { ...valid, status: "green" }; + expect(validateOpsState(badStatus, schema)).toContainEqual(expect.stringContaining("$.status")); + + const missingMetadata = { ...valid }; + delete missingMetadata.metadata; + expect(validateOpsState(missingMetadata, schema)).toContainEqual(expect.stringContaining('required property "metadata"')); + + const extraField = { ...valid, notes: "hand edited" }; + expect(validateOpsState(extraField, schema)).toContainEqual(expect.stringContaining('unexpected property "notes"')); + + const badKpi = { ...valid, kpis: [{ label: "", value: 1, status: "ok" }] }; + expect(validateOpsState(badKpi, schema)).toContainEqual(expect.stringContaining("$.kpis[0].label")); + + const badDate = { ...valid, updatedAt: "yesterday" }; + expect(validateOpsState(badDate, schema)).toContainEqual(expect.stringContaining("date-time")); + }); +}); diff --git a/src/launchIntent.js b/src/launchIntent.js new file mode 100644 index 0000000..326c5d6 --- /dev/null +++ b/src/launchIntent.js @@ -0,0 +1,68 @@ +import { DEFAULT_W, DEFAULT_H, DEFAULT_BG } from "./constants.js"; +import { clamp } from "./utils.js"; + +// Launch intent is the handoff contract between the launcher pages (home, +// templates) and the editor. The launcher writes it, the editor consumes it +// exactly once on mount and opens a document that honors it. sessionStorage +// keeps the intent scoped to the tab that created it. +export const LAUNCH_INTENT_KEY = "PixelForge.launchDraft.v1"; +export const MIN_LAUNCH_DIM = 64; +export const MAX_LAUNCH_DIM = 8192; + +// Backgrounds the document model can honor today. createDefaultDocument fills +// the background layer with one solid color, so only solid fills map. Any +// other launcher choice is reported back to the caller as unsupported instead +// of being silently replaced. +export const LAUNCH_BACKGROUNDS = { + white: DEFAULT_BG, +}; + +function toLaunchDim(value, fallback) { + const n = Math.round(Number(value)); + if (!Number.isFinite(n) || n <= 0) return fallback; + return clamp(n, MIN_LAUNCH_DIM, MAX_LAUNCH_DIM); +} + +export function normalizeLaunchIntent(raw) { + if (!raw || typeof raw !== "object") return null; + const requestedBackground = typeof raw.background === "string" ? raw.background.trim().toLowerCase() : ""; + const background = LAUNCH_BACKGROUNDS[requestedBackground] || null; + return { + preset: typeof raw.preset === "string" ? raw.preset : null, + width: toLaunchDim(raw.width, DEFAULT_W), + height: toLaunchDim(raw.height, DEFAULT_H), + background, + requestedBackground: requestedBackground || null, + backgroundSupported: !requestedBackground || !!background, + }; +} + +export function writeLaunchIntent(draft) { + try { + window.sessionStorage.setItem(LAUNCH_INTENT_KEY, JSON.stringify(draft)); + return true; + } catch { + // Storage may be disabled; the editor still opens with defaults. + return false; + } +} + +function takeStoredIntent() { + try { + const raw = window.sessionStorage.getItem(LAUNCH_INTENT_KEY); + if (raw != null) window.sessionStorage.removeItem(LAUNCH_INTENT_KEY); + return raw; + } catch { + return null; + } +} + +export function consumeLaunchIntent() { + const raw = takeStoredIntent(); + if (raw == null) return null; + try { + return normalizeLaunchIntent(JSON.parse(raw)); + } catch { + return null; + } +} diff --git a/src/pages/LauncherPage.jsx b/src/pages/LauncherPage.jsx index c11d969..852f476 100644 --- a/src/pages/LauncherPage.jsx +++ b/src/pages/LauncherPage.jsx @@ -4,6 +4,7 @@ import { Palette, Play, Plus, Search, Upload, } from "lucide-react"; import { ROUTES, routeHref } from "../routes.js"; +import { writeLaunchIntent } from "../launchIntent.js"; const QUICK = [ { id: "sq", name: "Square", size: "2048 x 2048", ratio: "1:1" }, @@ -21,13 +22,6 @@ const TEMPLATES = [ { id: "emote", name: "Emote Pack", dim: "112 x 112", cat: "Stream", tag: "emotes", tone: "pink" }, ]; -const RECENT_FILES = [ - { id: "forest", name: "forest-parallax.pforge", dim: "1920 x 1080", edited: "12 min ago", size: "4.2 MB" }, - { id: "sprite", name: "hero-sprite-v3.pforge", dim: "128 x 128", edited: "2 hours ago", size: "890 KB" }, - { id: "menu", name: "menu-mockup.png", dim: "1440 x 1024", edited: "Yesterday", size: "2.1 MB" }, - { id: "tiles", name: "tileset-dungeon.pforge", dim: "512 x 512", edited: "Yesterday", size: "3.6 MB" }, -]; - const PRESETS = [ { id: "hd", name: "HD 1080p", w: 1920, h: 1080 }, { id: "ig", name: "Instagram Post", w: 1080, h: 1080 }, @@ -101,16 +95,16 @@ function NewProjectModal({ draft, setDraft, close, create }) { ); } -export default function LauncherPage({ navigate, initialView = "home" }) { +// PixelForge has no recent files store yet. The list stays empty until a real +// source of truth exists; callers can pass one in, but nothing fabricates rows. +const NO_RECENT_FILES = []; + +export default function LauncherPage({ navigate, initialView = "home", recentFiles = NO_RECENT_FILES }) { const [modalOpen, setModalOpen] = useState(false); const [draft, setDraft] = useState({ preset: "hd", width: 1920, height: 1080, background: "white" }); const isTemplates = initialView === "templates"; const createProject = () => { - try { - window.sessionStorage.setItem("PixelForge.launchDraft.v1", JSON.stringify(draft)); - } catch { - // Non-critical; the editor remains available even if storage is disabled. - } + writeLaunchIntent(draft); navigate(ROUTES.editor); }; @@ -199,14 +193,16 @@ export default function LauncherPage({ navigate, initialView = "home" }) {
- {RECENT_FILES.map(file => ( + {recentFiles.length ? recentFiles.map(file => ( - ))} + )) : ( +

No recent files yet. PixelForge does not track opened projects; use Open Editor and load a .pforge file from the File menu.

+ )}
@@ -218,7 +214,11 @@ export default function LauncherPage({ navigate, initialView = "home" }) {
{TEMPLATES.map(template => ( -