# Playable Showcase — Dev Log ## Project Overview Internal tool for uploading, previewing, and organizing HTML playable ads. Single-file HTML playables are uploaded, stored on disk, and served via Express. React SPA (Vite) for the frontend. JSON file-based DB (no SQL). **Stack:** Node.js + Express · React + Vite · React Router DOM · Multer · Puppeteer --- ## Architecture ``` PlayableShowcase/ ├── server/ │ ├── index.js — Express server (port 3000), serves /public and /uploads │ ├── db.js — JSON file-based database (ads + apps) │ ├── routes/ │ │ ├── ads.js — GET/PATCH /api/ads/:id, POST /api/ads/:id/screenshot │ │ ├── upload.js — POST /api/upload (multer, creates ad record) │ │ └── apps.js — CRUD /api/apps, GET /api/apps/lookup │ └── utils/ │ └── screenshot.js — Puppeteer headless screenshot utility ├── client/ │ ├── vite.config.js — outDir: '../public' (matches Express static path) │ └── src/ │ ├── App.jsx — Routes: / (Catalog), /apps, /viewer/:id │ ├── api.js — All fetch calls to Express API │ ├── components/ │ │ ├── AppLayout.jsx/css — Shared sidebar shell + mobile hamburger │ │ ├── AdCard.jsx/css — Catalog grid card (thumbnail + app icon) │ │ ├── AppModal.jsx — Create/edit application modal (shared) │ │ ├── MetadataModal.jsx — Edit playable metadata in Viewer │ │ └── UploadPreviewModal.jsx/css — Upload flow modal │ └── pages/ │ ├── Catalog.jsx/css — Main grid + sidebar with search/filter │ ├── Viewer.jsx/css — Playable preview with device frame │ └── Apps.jsx/css — Application management page ├── uploads/ — Uploaded HTML files + thumbnail PNGs └── public/ — Vite build output (served by Express) ``` --- ## Features Implemented ### Catalog (`/`) - Grid of AdCards with thumbnails, app icon, title, app name - Sidebar: brand, upload button (hidden file input → UploadPreviewModal), app filter, search - Search filters by: title, developer, app name, tags - Apps sorted alphabetically in filter list ### Viewer (`/viewer/:id`) - Device frame (phone/tablet/desktop) with scale-to-fit - Rotate frame button - Manual screenshot button (server-side Puppeteer) - Auto-screenshot on iframe load if no thumbnail exists - Sidebar: playable info, app icon + store links, repo@branch, tags, frame controls, edit button - MetadataModal for editing: title, app, tags, developer, repo, branch, notes, replace HTML file ### Apps (`/apps`) - List all applications with icon, name, store links - Create/edit/delete apps - App Store / Play Store URL auto-fill (lookupApp API) ### Upload Flow - Upload HTML → UploadPreviewModal opens - Left: live iframe preview of the playable - Right: metadata form (title, app, tags, developer, repo, branch, notes) - "Take Screenshot" button (required before Save) — calls Puppeteer server-side - "New Application" inline button opens AppModal without leaving the flow - Screenshot taken via server-side Puppeteer (not client-side canvas capture) ### Mobile - Sidebar collapses to hamburger menu at ≤680px - Overlay closes sidebar when tapped - Upload preview frame uses aspect-ratio: 9/19.5 --- ## Key Technical Decisions ### Screenshot: Server-side Puppeteer (not client-side html2canvas) Client-side approaches (html2canvas, canvas.toDataURL) were tried and failed for various reasons: - html2canvas can't access iframe CSS from parent window context → HTML overlays invisible - WebGL `preserveDrawingBuffer: false` → canvas.toDataURL() always returns blank - Composite-canvas fallback only captures WebGL, misses HTML UI layers **Decision:** All screenshots go through `POST /api/ads/:id/screenshot` which runs Puppeteer. Puppeteer captures the GPU-composited output, getting WebGL + HTML overlays correctly. ### Vite Build Output `vite.config.js` sets `build: { outDir: '../public', emptyOutDir: true }`. Express serves static files from `./public`. Without this, Vite outputs to `client/dist` and Express throws `ENOENT: no such file or directory, stat '.../public/index.html'`. ### AdCard Thumbnail Display `.ad-card__thumb` uses `aspect-ratio: 9 / 19.5` to match the 390×844 Puppeteer output. `object-fit: cover` fills the container cleanly since both are the same ratio. ### AppModal Shared Component AppModal was extracted from Apps.jsx into `components/AppModal.jsx` so it can be used by both Apps.jsx (edit apps) and UploadPreviewModal.jsx (create app inline during upload). Imports styles from `'../pages/Apps.css'` (note the relative path from components/). ### Sidebar Layout Pattern All pages use `AppLayout` + consistent CSS class names: - `.sb-brand` — top brand name - `.sb-nav` — nav button area - `.sb-btn` — sidebar button - `.sb-divider` — horizontal rule - `.sb-section-label` — uppercase section label - `.viewer__sb-info` — padded content block inside a section --- ## Outstanding Issues ### 1. White Screenshots — Unity WebGL (UNRESOLVED) **Symptom:** Clicking "Take Screenshot" on Unity WebGL playables returns a white/blank image. **Root cause analysis:** Unity WebGL uses `preserveDrawingBuffer: false` by default. This means after the GPU compositor reads the WebGL framebuffer to display the frame, the buffer is cleared. `canvas.toDataURL()` called between frames returns blank. **Fixes attempted:** 1. `html2canvas` with frozen canvas data — failed (doesn't capture HTML overlays) 2. Canvas composite fallback — failed (same issue) 3. Puppeteer with `networkidle2` + 1500ms wait — failed (Unity initializes after network idle) 4. Puppeteer with `waitForFunction` polling `canvas.toDataURL()` — failed (still blank due to preserveDrawingBuffer) 5. **`evaluateOnNewDocument` monkey-patch to force `preserveDrawingBuffer: true`** — partial - Monkey-patches `HTMLCanvasElement.prototype.getContext` before any page scripts run - Forces `{ preserveDrawingBuffer: true }` on all webgl/webgl2 contexts - Changed `waitUntil: 'networkidle2'` → `'load'` because Unity's fetch requests keep the network permanently busy, causing networkidle2 timeout on large files - Test script confirmed this works in isolation (saw "PL Island House" 3D screenshot) - **But user still reports white screenshots in the actual app** **Suspected remaining cause:** The server process was NOT restarted after screenshot.js was changed. Node.js caches `require()`d modules — the old code runs until the server restarts. Or: there are additional Unity playables with different initialization patterns. **What to try next:** - Restart the server (`Ctrl+C` then `node index.js` or `npm start` in `/server`) - If still white: add console logging to screenshot.js to see what `waitForFunction` detects - Consider: `page.waitForFunction` might not see `window.unityInstance` if Unity sets it as a local variable (not on window) — check the actual Unity HTML template used - Nuclear option: after canvas polling resolves, take 3 screenshots 500ms apart and keep the largest (most content = most bytes) **Current screenshot.js logic (server/utils/screenshot.js):** ```js // 1. Inject monkey-patch BEFORE page scripts run await page.evaluateOnNewDocument(() => { const orig = HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext = function(type, attrs) { if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') { attrs = Object.assign({}, attrs || {}, { preserveDrawingBuffer: true }); } return orig.call(this, type, attrs); }; }); // 2. Navigate (load, not networkidle2) await page.goto(url, { waitUntil: 'load', timeout: 60000 }); // 3. Poll canvas for non-blank pixels (up to 20s) await page.waitForFunction(() => { const canvases = Array.from(document.querySelectorAll('canvas')) .filter(c => c.width > 0 && c.height > 0); return canvases.some(c => { try { const d = c.toDataURL('image/png'); return (d.split(',')[1] || '').length > 2000; } catch { return false; } }); }, { timeout: 20000, polling: 500 }); // 4. Settle 800ms, then screenshot await new Promise(r => setTimeout(r, 800)); const buffer = await page.screenshot({ type: 'png' }); ``` --- ### 2. Simulated Device Too Small on Mobile (UNRESOLVED) **Symptom:** On mobile viewports, the device frame in Viewer is very small — doesn't fill the available space. **Root cause:** `.viewer__stage` is a flex child of `.app-layout__main` which is `display: flex; flex-direction: column`. The stage needs `flex: 1; min-height: 0` to grow and fill the column. `min-height: 0` is required because flex children default to `min-height: auto`, preventing them from shrinking below their content size. **Fix was applied** (Viewer.css sets `flex: 1; min-height: 0` on `.viewer__stage`) but mobile may still show the device too small because: - On mobile, the hamburger bar takes 56px at the top (`padding-top: 56px` on `.app-layout__main`) - The scale calculation in Viewer.jsx uses `stageDims` from a ResizeObserver - If ResizeObserver fires before layout settles, `stageDims` could be stale - The scale formula: `Math.min(1, (sw - 32) / fw, (sh - 32) / fh)` where fw/fh are frame dimensions **What to try next:** - Verify `.viewer__stage` actually has `flex: 1; min-height: 0` in the built CSS (run `npm run build` in `/client` and check `/public/assets/index-*.css`) - On mobile, check if `stageDims.height` reflects the full available height minus hamburger - The `DeviceFrame` component's `getFrameSize(frame, rotated)` returns fixed pixel dimensions — if the stage is e.g. 400px tall on mobile but the frame is 844px, scale = 400/844 ≈ 0.47 which might feel too small. Consider increasing margin or adjusting min-scale. --- ## Playable Fields (DB Schema) Each ad record: ```json { "id": "uuid", "filename": "uuid.html", "title": "string", "appId": "uuid (ref to apps)", "tags": ["string"], "developer": "string", "repo": "string", "branch": "string", "notes": "string", "thumbnail": "uuid-thumb.png", "uploaded_at": 1234567890, "updated_at": 1234567890 } ``` Each app record: ```json { "id": "uuid", "name": "string", "ios_url": "string", "android_url": "string", "icon_url": "string" } ``` --- ## Dev Commands ```bash # Server (from /server) node index.js # or if package.json has start script: npm start # Client dev server (from /client) npm run dev # Client production build (from /client) npm run build # Output goes to /public (Express static root) ```