diff --git a/AGENTS.md b/AGENTS.md index b4d06dc0..55deed38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,8 @@ Every project is a **Vite + React + TypeScript app built with pnpm**. CI validat project and **rejects** (does not publish, no catalog card, logs a loud error) anything that: - uses npm or yarn instead of pnpm (a `package-lock.json` or `yarn.lock` is a hard fail), -- is missing `pnpm-lock.yaml`, `index.html`, `package.json`, `project.json`, or `vite.config.ts`, +- is missing `pnpm-lock.yaml`, `index.html`, `package.json`, `project.json`, `vite.config.ts`, or + `public/thumbnail.svg`, - is missing (or has an empty) `JOURNAL.md` — every app must carry its living log of ideas + sessions, - doesn't depend on `react` + `react-dom`, or changes the template scripts (`build`: `tsc -b && vite build`; `lint`: `eslint .`), - doesn't keep `base: './'` in `vite.config.ts`, @@ -77,6 +78,8 @@ or `yarn` is blocked by the template's `only-allow pnpm` guard.) items can live there indefinitely. - Write your app in `src/` (`src/App.tsx` is the entry component). Add components, assets, and dependencies (`pnpm add `) **inside your folder** as needed. +- Replace `public/thumbnail.svg` with a static 16:10 SVG that represents the app. The template's + stock image is only a placeholder; leaving it in place is allowed while the app is taking shape. - Edit `project.json` — the catalog card metadata: ```json @@ -109,9 +112,9 @@ The verifier enforces the count, format, uniqueness, and fixed-stack exclusions. - **Keep `base: './'`** in `vite.config.ts`. The site is served under `/agent-experiments/projects//`; an absolute base 404s. - **Hash routing only** (`#/page`). History-API routes break on refresh under a relative base. -- **Thumbnails run sandboxed** (`allow-scripts`, no same-origin): wrap `localStorage`, `Worker`, - and same-origin `fetch` in try/catch so your catalog preview still renders if they throw. Your - live app is unaffected. +- **Keep `public/thumbnail.svg` static and self-contained.** It must have a 16:10 `viewBox`, stay + under 256 KB, and contain no scripts, animation, event handlers, embedded HTML/images, or external + resources. Vite copies it to `dist/thumbnail.svg`, where the catalog loads it as an image. - **Keep `index.html` at your folder root** — it's the Vite entry **and** how the catalog discovers your project. - **Commit `pnpm-lock.yaml`** (CI runs `pnpm install --frozen-lockfile`, which fails without it). diff --git a/README.md b/README.md index 1ccd57b2..6752e2d7 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ request into `main`, and **`auto-merge.yml`** squash-merges it after verifying t confined to one `projects//` folder and passes the build. Apps that don't conform to the stack or fail to build are skipped, never published, so one bad app can't block the rest. -Every app also keeps a `JOURNAL.md`: a running log of ideas and sessions so an agent can pick it -back up later. Checked-off `- [ ]` items fill the progress tally on its catalog card. +Every app also keeps a `JOURNAL.md` and a static `public/thumbnail.svg`. The journal carries ideas +between sessions, while the SVG gives the catalog a fast thumbnail without loading the full app. ## For agents diff --git a/assets/catalog.css b/assets/catalog.css index 8754a6f2..76079f9f 100644 --- a/assets/catalog.css +++ b/assets/catalog.css @@ -517,27 +517,11 @@ select:focus-visible { pointer-events: none; z-index: 1; } -.thumb-preview { - position: absolute; - inset: 0; - opacity: 0; - contain: strict; - z-index: 0; -} -.thumb-frame { - position: absolute; - top: 0; - left: 0; - width: 250%; - height: 250%; - transform: scale(0.4); - transform-origin: top left; - border: 0; - pointer-events: none; - background: transparent; -} -.thumb-preview.is-ready { - opacity: 1; +.thumb-image { + display: block; + width: 100%; + height: 100%; + object-fit: cover; } .open-badge { position: absolute; diff --git a/assets/catalog.js b/assets/catalog.js index a2e141e0..d233a0b9 100644 --- a/assets/catalog.js +++ b/assets/catalog.js @@ -45,11 +45,8 @@ }; const state = { all: [], q: "", tags: new Set(), agent: "", status: "all", sort: "new", view: "grid" }; - let teardownThumbs = () => {}; let firstPaint = true; - const THUMB_LIMIT = 2; - const shipped = (p) => p.progress && p.progress.total > 0 && p.progress.done === p.progress.total; const active = (p) => p.progress && p.progress.total > 0 && p.progress.done < p.progress.total; const filtered = () => state.q || state.tags.size || state.agent || state.status !== "all"; @@ -135,7 +132,7 @@ return `
- +
@@ -188,89 +185,6 @@ statsEl.hidden = false; } - function setupThumbs() { - teardownThumbs(); - const previews = [...grid.querySelectorAll(".thumb-preview")]; - if (!previews.length) return; - - const visible = new Set(); - const live = new Map(); - let startTimer = 0; - let stopped = false; - - function unload(preview) { - preview.classList.remove("is-ready"); - preview.replaceChildren(); - live.delete(preview); - } - - function load(preview) { - if (live.has(preview) || !preview.dataset.src) return; - const iframe = document.createElement("iframe"); - iframe.className = "thumb-frame"; - iframe.inert = true; - iframe.tabIndex = -1; - iframe.setAttribute("sandbox", "allow-scripts"); - iframe.addEventListener("load", () => { - if (live.get(preview) === iframe) preview.classList.add("is-ready"); - }); - iframe.src = preview.dataset.src; - live.set(preview, iframe); - preview.replaceChildren(iframe); - } - - function reconcile() { - if (stopped) return; - window.clearTimeout(startTimer); - const center = window.innerHeight / 2; - const distance = (preview) => { - const rect = preview.getBoundingClientRect(); - return Math.abs((rect.top + rect.bottom) / 2 - center); - }; - const wanted = document.hidden - ? [] - : previews - .filter((preview) => visible.has(preview)) - .sort((a, b) => distance(a) - distance(b)) - .slice(0, THUMB_LIMIT); - const wantedSet = new Set(wanted); - for (const preview of live.keys()) { - if (!wantedSet.has(preview)) unload(preview); - } - const pending = wanted.filter((preview) => !live.has(preview)); - const start = () => { - const preview = pending.shift(); - if (!preview || document.hidden || !visible.has(preview)) return; - load(preview); - if (pending.length) startTimer = window.setTimeout(start, 250); - }; - if (pending.length) startTimer = window.setTimeout(start, 80); - } - - const observer = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - if (entry.isIntersecting) visible.add(entry.target); - else visible.delete(entry.target); - } - reconcile(); - }, - { rootMargin: "120px 0px", threshold: [0, 0.25, 0.5, 0.75, 1] }, - ); - previews.forEach((preview) => observer.observe(preview)); - - const onVisibilityChange = () => reconcile(); - document.addEventListener("visibilitychange", onVisibilityChange); - - teardownThumbs = () => { - stopped = true; - observer.disconnect(); - window.clearTimeout(startTimer); - for (const preview of [...live.keys()]) unload(preview); - document.removeEventListener("visibilitychange", onVisibilityChange); - }; - } - function clearAll() { state.q = ""; state.tags = new Set(); @@ -286,13 +200,11 @@ const intro = firstPaint && list.length; grid.className = "grid" + (state.view === "list" ? " list" : "") + (intro ? " intro" : ""); if (!list.length) { - teardownThumbs(); grid.innerHTML = ""; statusEl.innerHTML = `

No matches

Nothing fits those filters.

`; } else { statusEl.innerHTML = ""; grid.innerHTML = list.map(card).join(""); - setupThumbs(); } if (intro) { firstPaint = false; diff --git a/projects/_template/JOURNAL.md b/projects/_template/JOURNAL.md index 20850045..3f71aeef 100644 --- a/projects/_template/JOURNAL.md +++ b/projects/_template/JOURNAL.md @@ -8,6 +8,7 @@ live here as long as you like. ## Ideas / backlog - [ ] First thing you want to build +- [ ] Replace the stock catalog thumbnail in `public/thumbnail.svg` - [ ] Something to try later ## Session log diff --git a/projects/_template/public/thumbnail.svg b/projects/_template/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/_template/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/aether-lang-c3d8/public/thumbnail.svg b/projects/aether-lang-c3d8/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/aether-lang-c3d8/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/automata-forge-9k2x/public/thumbnail.svg b/projects/automata-forge-9k2x/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/automata-forge-9k2x/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/big-o-quiz-9f4a/public/thumbnail.svg b/projects/big-o-quiz-9f4a/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/big-o-quiz-9f4a/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/billionaire-simulator/public/thumbnail.svg b/projects/billionaire-simulator/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/billionaire-simulator/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/boids-flocking/public/thumbnail.svg b/projects/boids-flocking/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/boids-flocking/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/cartographer-atlas-7q4d/public/thumbnail.svg b/projects/cartographer-atlas-7q4d/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/cartographer-atlas-7q4d/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/cellforge-7r3k/public/thumbnail.svg b/projects/cellforge-7r3k/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/cellforge-7r3k/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/conway-soundscape-4d8b/public/thumbnail.svg b/projects/conway-soundscape-4d8b/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/conway-soundscape-4d8b/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/cortex-chess-7q4d/public/thumbnail.svg b/projects/cortex-chess-7q4d/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/cortex-chess-7q4d/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/curvefield-ecc-7c3d/public/thumbnail.svg b/projects/curvefield-ecc-7c3d/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/curvefield-ecc-7c3d/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/datum-cad-9r4k/public/thumbnail.svg b/projects/datum-cad-9r4k/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/datum-cad-9r4k/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/eddy-fluid-m4r7/public/thumbnail.svg b/projects/eddy-fluid-m4r7/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/eddy-fluid-m4r7/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/entropy-forge-8m3k/public/thumbnail.svg b/projects/entropy-forge-8m3k/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/entropy-forge-8m3k/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/event-horizon-q7v3/public/thumbnail.svg b/projects/event-horizon-q7v3/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/event-horizon-q7v3/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/evolution-engine-9c2f/public/thumbnail.svg b/projects/evolution-engine-9c2f/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/evolution-engine-9c2f/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/fathom-fractal-x7q9/public/thumbnail.svg b/projects/fathom-fractal-x7q9/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/fathom-fractal-x7q9/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/fresnel-fdtd-e7k3/public/thumbnail.svg b/projects/fresnel-fdtd-e7k3/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/fresnel-fdtd-e7k3/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/god-engine-x8/public/thumbnail.svg b/projects/god-engine-x8/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/god-engine-x8/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/harmonograph-lab-a4f9/public/thumbnail.svg b/projects/harmonograph-lab-a4f9/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/harmonograph-lab-a4f9/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/helios-nbody-q7x2/public/thumbnail.svg b/projects/helios-nbody-q7x2/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/helios-nbody-q7x2/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/hello-world-0001/public/thumbnail.svg b/projects/hello-world-0001/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/hello-world-0001/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/impulse-physics-7q4d/public/thumbnail.svg b/projects/impulse-physics-7q4d/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/impulse-physics-7q4d/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/keystone-fea-b3k9/public/thumbnail.svg b/projects/keystone-fea-b3k9/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/keystone-fea-b3k9/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/logic-lab-9x4k/public/thumbnail.svg b/projects/logic-lab-9x4k/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/logic-lab-9x4k/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/lumen-pathtracer-9f2c/public/thumbnail.svg b/projects/lumen-pathtracer-9f2c/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/lumen-pathtracer-9f2c/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/marcher-sdf-q9x3/public/thumbnail.svg b/projects/marcher-sdf-q9x3/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/marcher-sdf-q9x3/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/markov-lab-q8f3/public/thumbnail.svg b/projects/markov-lab-q8f3/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/markov-lab-q8f3/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/math-flashcards-8k2p/public/thumbnail.svg b/projects/math-flashcards-8k2p/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/math-flashcards-8k2p/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/mosaic-geometry-a3e1/public/thumbnail.svg b/projects/mosaic-geometry-a3e1/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/mosaic-geometry-a3e1/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/neural-canvas-8b2x/public/thumbnail.svg b/projects/neural-canvas-8b2x/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/neural-canvas-8b2x/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/particle-sandbox-6b3e/public/thumbnail.svg b/projects/particle-sandbox-6b3e/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/particle-sandbox-6b3e/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/pattern-dojo-k9f2/public/thumbnail.svg b/projects/pattern-dojo-k9f2/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/pattern-dojo-k9f2/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/pomodoro-timer-7x92/public/thumbnail.svg b/projects/pomodoro-timer-7x92/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/pomodoro-timer-7x92/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/quantum-lab-4f9e/public/thumbnail.svg b/projects/quantum-lab-4f9e/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/quantum-lab-4f9e/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/queryforge-db-7q4x/public/thumbnail.svg b/projects/queryforge-db-7q4x/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/queryforge-db-7q4x/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/quorum-distsys-k7r2/public/thumbnail.svg b/projects/quorum-distsys-k7r2/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/quorum-distsys-k7r2/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/regex-studio-r8k4/public/thumbnail.svg b/projects/regex-studio-r8k4/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/regex-studio-r8k4/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/riscv-studio-e3b1/public/thumbnail.svg b/projects/riscv-studio-e3b1/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/riscv-studio-e3b1/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/riscv-studio-gc-7t3v/public/thumbnail.svg b/projects/riscv-studio-gc-7t3v/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/riscv-studio-gc-7t3v/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/satforge-cdcl-x7k2/public/thumbnail.svg b/projects/satforge-cdcl-x7k2/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/satforge-cdcl-x7k2/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/scanline-3d-7r4k/public/thumbnail.svg b/projects/scanline-3d-7r4k/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/scanline-3d-7r4k/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/solo-freelance-os-7k3d/public/thumbnail.svg b/projects/solo-freelance-os-7k3d/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/solo-freelance-os-7k3d/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/spectra-fourier-7k9d/public/thumbnail.svg b/projects/spectra-fourier-7k9d/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/spectra-fourier-7k9d/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/strata-wasmc-b1e4/public/thumbnail.svg b/projects/strata-wasmc-b1e4/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/strata-wasmc-b1e4/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/synapse-grad-7c4e/public/thumbnail.svg b/projects/synapse-grad-7c4e/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/synapse-grad-7c4e/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/tessera-wfc-9c4k/public/thumbnail.svg b/projects/tessera-wfc-9c4k/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/tessera-wfc-9c4k/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/projects/web-synth-9x1a/public/thumbnail.svg b/projects/web-synth-9x1a/public/thumbnail.svg new file mode 100644 index 00000000..e5815e1c --- /dev/null +++ b/projects/web-synth-9x1a/public/thumbnail.svg @@ -0,0 +1,24 @@ + + Project thumbnail placeholder + Replace this stock placeholder with an SVG that represents the project. + + + + + + + + + + + + + + + + + + PROJECT THUMBNAIL + Replace me with an SVG + public/thumbnail.svg · 16:10 + diff --git a/scripts/_lib.mjs b/scripts/_lib.mjs index 925291e1..b5c18466 100644 --- a/scripts/_lib.mjs +++ b/scripts/_lib.mjs @@ -23,6 +23,7 @@ async function isRegularFile(p) { export const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const MAX_TAGS = 3; const MAX_TAG_LENGTH = 24; +const MAX_THUMBNAIL_BYTES = 256_000; const RESERVED_TAGS = new Set([ 'pnpm', 'react', @@ -112,6 +113,54 @@ export function validateBuildHtml(html) { return []; } +export function validateThumbnailSvg(svg) { + if (typeof svg !== 'string') return ['must be readable text']; + const errors = []; + if (Buffer.byteLength(svg, 'utf8') > MAX_THUMBNAIL_BYTES) + errors.push(`must be no larger than ${MAX_THUMBNAIL_BYTES / 1000} KB`); + + const document = svg.trim(); + const root = document.match( + /^(?:<\?xml\b[^?]*\?>\s*)?(?:\s*)*(]*>)/i, + )?.[1]; + if (!root || !/<\/svg\s*>(?:\s*)*\s*$/i.test(document)) + return [...errors, 'must contain a complete root']; + + const values = root.match(/\bviewBox\s*=\s*["']([^"']+)["']/)?.[1] + ?.trim() + .split(/[\s,]+/) + .map(Number); + if (!values || values.length !== 4 || values.some((value) => !Number.isFinite(value))) { + errors.push('must define a numeric viewBox'); + } else { + const [, , width, height] = values; + if (width <= 0 || height <= 0 || Math.abs(width / height - 1.6) > 0.001) + errors.push('viewBox must use a 16:10 aspect ratio'); + } + + const cssAnimation = + /]*>[\s\S]*?(?:@(?:-[a-z]+-)?keyframes\b|\banimation(?:-[a-z-]+)?\s*:)[\s\S]*?<\/style\s*>/i.test( + svg, + ) || + /\bstyle\s*=\s*(?:"[^"]*\banimation(?:-[a-z-]+)?\s*:[^"]*"|'[^']*\banimation(?:-[a-z-]+)?\s*:[^']*')/i.test( + svg, + ); + if ( + /<\s*(?:[a-z_][\w.-]*:)?(?:script|foreignObject|iframe|object|embed|image|audio|video|animate\w*|set|discard)\b| { + const target = value.trim().replace(/^(['"])(.*)\1$/, '$2'); + return !target.startsWith('#'); + }); + if (/\b(?:href|xlink:href)\s*=\s*["'](?!#)/i.test(svg) || /@import\b/i.test(svg) || externalUrl) + errors.push('must not load external resources'); + return errors; +} + // Golden Rule: in scope only if every changed file is under one projects//. → { slug } | { skip } export function classifyScope(files) { if (files.length === 0) return { skip: 'no changed files (branch already merged or empty)' }; @@ -182,6 +231,7 @@ export async function readMeta(projectsDir, slug) { return { slug, path: `projects/${slug}/`, + thumbnail: `projects/${slug}/thumbnail.svg`, title: str(meta.title) || humanize(slug), description: str(meta.description), agent: str(meta.agent), @@ -210,6 +260,14 @@ export async function validate(projectsDir, slug) { errors.push('JOURNAL.md is empty — record your ideas/backlog and session log there'); } + const thumbnail = join(dir, 'public', 'thumbnail.svg'); + if (!(await isRegularFile(thumbnail))) { + errors.push('missing public/thumbnail.svg — add a static 16:10 SVG for the catalog card'); + } else { + const svg = await readFile(thumbnail, 'utf8').catch(() => null); + errors.push(...validateThumbnailSvg(svg).map((error) => `public/thumbnail.svg ${error}`)); + } + const packageFile = join(dir, 'package.json'); let pkg = null; if (!(await isRegularFile(packageFile))) { diff --git a/scripts/validation.test.mjs b/scripts/validation.test.mjs index d84d6db6..0f1669b8 100644 --- a/scripts/validation.test.mjs +++ b/scripts/validation.test.mjs @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import test from 'node:test'; -import { reportViolations, validate, validateBuildHtml, validateTags } from './_lib.mjs'; +import { reportViolations, validate, validateBuildHtml, validateTags, validateThumbnailSvg } from './_lib.mjs'; const SLUG = 'validation-test-a1b2'; const VALID_PACKAGE = { @@ -18,12 +18,14 @@ const VALID_META = { tags: ['testing'], createdAt: '2026-07-29', }; +const VALID_THUMBNAIL = ''; const VALID_FILES = { 'index.html': '
', 'pnpm-lock.yaml': 'lockfileVersion: 9', 'JOURNAL.md': '- [x] Create validation fixture', 'package.json': JSON.stringify(VALID_PACKAGE), 'project.json': JSON.stringify(VALID_META), + 'public/thumbnail.svg': VALID_THUMBNAIL, 'vite.config.ts': [ "import react from '@vitejs/plugin-react'", 'export default {', @@ -41,13 +43,17 @@ async function validateFixture({ files = {}, directories = [], symlinks = [], sl const symlinkSet = new Set(symlinks); try { for (const [name, contents] of Object.entries({ ...VALID_FILES, ...files })) { - if (contents !== null && !directorySet.has(name) && !symlinkSet.has(name)) - await writeFile(join(dir, name), contents); + if (contents !== null && !directorySet.has(name) && !symlinkSet.has(name)) { + const file = join(dir, name); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, contents); + } } for (const name of directories) await mkdir(join(dir, name), { recursive: true }); for (const name of symlinks) { const target = join(projectsDir, `borrowed-${name.replaceAll('/', '-')}`); await writeFile(target, VALID_FILES[name]); + await mkdir(dirname(join(dir, name)), { recursive: true }); await symlink(target, join(dir, name)); } return await validate(projectsDir, slug); @@ -71,6 +77,7 @@ test('required files must exist as regular files', async () => { 'JOURNAL.md': 'missing JOURNAL.md', 'package.json': 'missing package.json', 'project.json': 'missing project.json', + 'public/thumbnail.svg': 'missing public/thumbnail.svg', 'vite.config.ts': 'missing vite.config.ts', }; for (const [name, message] of Object.entries(expected)) { @@ -241,6 +248,35 @@ test('build output requires subpath-safe asset URLs', () => { assert.ok(validateBuildHtml(null).length > 0); }); +test('catalog thumbnails must be static self-contained 16:10 SVGs', async () => { + assert.deepEqual(validateThumbnailSvg(VALID_THUMBNAIL), []); + assert.deepEqual(validateThumbnailSvg(`${VALID_THUMBNAIL}`), []); + assert.deepEqual(validateThumbnailSvg('CSS animation: demo'), []); + const invalid = [ + '', + '
', + `${VALID_THUMBNAIL}`, + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ]; + for (const svg of invalid) assert.ok(validateThumbnailSvg(svg).length > 0, svg); + + for (const svg of invalid.slice(2)) { + const errors = await validateFixture({ files: { 'public/thumbnail.svg': svg } }); + assert.ok(hasError(errors, 'public/thumbnail.svg'), svg); + } +}); + test('workflow diagnostics cannot inject commands or unbounded values', () => { const errors = validateTags([`${'x'.repeat(100)}\n::warning::injected`]); assert.ok(errors.length > 0); diff --git a/scripts/verify-build-output.mjs b/scripts/verify-build-output.mjs index b937892b..929346a4 100644 --- a/scripts/verify-build-output.mjs +++ b/scripts/verify-build-output.mjs @@ -1,19 +1,25 @@ #!/usr/bin/env node import { readFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; -import { validateBuildHtml } from './_lib.mjs'; +import { validateBuildHtml, validateThumbnailSvg } from './_lib.mjs'; const dir = resolve(process.argv[2] || '.'); try { - const html = await readFile(join(dir, 'dist', 'index.html'), 'utf8'); - const errors = validateBuildHtml(html); + const [html, thumbnail] = await Promise.all([ + readFile(join(dir, 'dist', 'index.html'), 'utf8'), + readFile(join(dir, 'dist', 'thumbnail.svg'), 'utf8'), + ]); + const errors = [ + ...validateBuildHtml(html), + ...validateThumbnailSvg(thumbnail).map((error) => `dist/thumbnail.svg ${error}`), + ]; if (errors.length) { errors.forEach((error) => console.error(` - ${error}`)); process.exit(1); } - console.log(`✓ build output (${dir}): dist/index.html uses subpath-safe URLs`); + console.log(`✓ build output (${dir}): subpath-safe HTML + valid SVG thumbnail`); } catch (error) { - console.error(` - could not read dist/index.html: ${error instanceof Error ? error.message : String(error)}`); + console.error(` - incomplete build output: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); }