diff --git a/AGENTS.md b/AGENTS.md
index 0919254..b681fa8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -17,7 +17,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles.
- Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow.
- Web: on wasm32, `web-time` 1.1 (MIT OR Apache-2.0) gives the `Instant` gpui's executor returns, std's own natively; jiff takes `js`, rust-embed `debug-embed`, similar `wasm32_web_time`. The gallery's web entry takes `gpui_web`, `wasm-bindgen`, `js-sys` and `web-sys` as wasm-only dev-dependencies, and a Hebrew cut of Noto Sans Hebrew 2.003 (OFL, `examples/gallery/fonts`): gpui_web asks the browser for emoji and CJK alone. The `wasm-bindgen` CLI at Cargo.lock's version and binaryen's `wasm-opt` finish the bundle.
-- Gallery: `examples/gallery`. Website: `frontend/` (Vite 8.3, pnpm 11, TypeScript, React 19.3, TanStack Router 1.170 in code-based routes, Lenis 1.3), in serro.ai's language: heavy titles over thin faint text, hairlines, one accent, full-screen sections, isometric line miniatures on a canvas. Pages name a story by its title's first part (`src/data.ts`'s `name`); scroll bars stay hidden; layouts step at 1024 and 768. Each story runs live from the gallery built for wasm32 through gpui_web, in a same-origin iframe, or, where a browser cannot run it, as a native capture. The Docs tab is Markdown in `frontend/src/docs/`, read in its `ORDER` and rendered by `marked` 18 (MIT) inside `vite.config.ts`'s `docs` plugin, so a bad guide fails `pnpm build` and the browser gets HTML alone; a fenced block tagged `example=NAME` shows `examples/docs/NAME.rs`, an example `scripts/check.sh` compiles as `docs_NAME`, so every full Rust sample on the site builds. Below 360px the header drops the sound switch. Geist and Geist Mono (OFL, `@fontsource-variable`), Reicon icons (MIT, based on Solar Icons, CC BY 4.0, credited in the footer), simple-icons' GitHub mark (CC0). It deploys to Cloudflare Workers static assets through `npx wrangler` (4.143).
+- Gallery: `examples/gallery`. Website: `frontend/` (Vite 8.3, pnpm 11, TypeScript, React 19.3, TanStack Router 1.170 in code-based routes, Lenis 1.3), in serro.ai's language: heavy titles over thin faint text, hairlines, one accent, full-screen sections, isometric line miniatures on a canvas. Pages name a story by its title's first part (`src/data.ts`'s `name`); scroll bars stay hidden; layouts step at 1024 and 768. Each story runs live from the gallery built for wasm32 through gpui_web, in a same-origin iframe, or, where a browser cannot run it, as a native capture. The Docs tab is Markdown in `frontend/src/docs/`, read in its `ORDER` and rendered by `marked` 18 (MIT) inside `vite.config.ts`'s `docs` plugin, so a bad guide fails `pnpm build` and the browser gets HTML alone; a fenced block tagged `example=NAME` shows `examples/docs/NAME.rs`, an example `scripts/check.sh` compiles as `docs_NAME`, so every full Rust sample on the site builds. The Showcase tab lists apps built with Ely from the public repo `ZacharyZhang-NY/Ely-GPUI-Showcases`: manifests, PNG previews and Markdown READMEs, checked by its `scripts/catalog.mjs` (`marked`, `pngjs`, `twitter-text`), its stars refreshed by its CI. Below 414px the header drops the sound switch. Geist and Geist Mono (OFL, `@fontsource-variable`), Reicon icons (MIT, based on Solar Icons, CC BY 4.0, credited in the footer), simple-icons' GitHub mark (CC0). It deploys to Cloudflare Workers static assets through `npx wrangler` (4.143).
## Commands
@@ -26,7 +26,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- `cargo run --example gallery -- --narrow 280 --capture
` lays each page, header and body, at that width in a padded card and shoots it without its script, whose aims are measured at full width. Demos that set their own width run past the card.
- `RUSTC_BOOTSTRAP=1 cargo check --lib --target wasm32-unknown-unknown` checks the web build; gpui_web's `wasm_thread` needs the bootstrap, as Zed's web examples do.
- `ELY_GALLERY_ASSETS= scripts/web.sh ` builds the gallery for the browser, profile `web`, and fails past Cloudflare's 25 MiB. It opens at `?page=&story=&theme=light|dark`; the host page calls `gallery.setTheme(mode)` or posts `{ ely: "theme", theme }`, before the wasm runs or after, and the page posts `{ ely: "ready" }` to its parent once it runs.
-- In `frontend/`: `ELY_GALLERY_ASSETS=/gallery/assets/ pnpm gallery` builds the gallery into `public/gallery`, `pnpm dev` serves the site, `pnpm build` checks types and writes `dist`, `pnpm deploy` runs `wrangler deploy`, `pnpm test` runs the iso engine's tests, and `pnpm local` builds the gallery for localhost, builds the site and serves both on http://localhost:4173/ (wasm-bindgen and wasm-opt on PATH, as `scripts/web.sh` says). `node scripts/og.mjs`, with `pnpm preview` up, renders `public/og.png` from the hero. The site reads `examples/gallery/stories.json` for its chapters, stories and search.
+- In `frontend/`: `ELY_GALLERY_ASSETS=/gallery/assets/ pnpm gallery` builds the gallery into `public/gallery`, `pnpm dev` serves the site, `pnpm build` checks types and writes `dist`, `pnpm deploy` runs `wrangler deploy`, `pnpm showcase` writes the showcase catalog into `src/showcase.json` and `public/showcase` (both ignored): it downloads the commit `showcase.lock` pins, installs that commit's packages and runs its own validator, so a bad catalog fails the build; `dev` and `build` run it first, and `ELY_SHOWCASE=` reads a local catalog instead. `pnpm test` runs the iso engine's and the showcase list's tests, and `pnpm local` builds the gallery for localhost, builds the site and serves both on http://localhost:4173/ (wasm-bindgen and wasm-opt on PATH, as `scripts/web.sh` says). `node scripts/og.mjs`, with `pnpm preview` up, renders `public/og.png` from the hero. The site reads `examples/gallery/stories.json` for its chapters, stories and search.
- `python3 scripts/stories.py` writes `examples/gallery/stories.json`, every page's sections in drawing order with their components, for the site: the names in a title, and in its `SHOWN` table what a story draws beyond them. It fails on a section opened inside another and on a ticked entry with a home of its own that no story names. `scripts/check.sh` refuses a stale one.
- `cargo test --lib --features test-support`, `cargo clippy --all-targets --features test-support -- -D warnings`, `cargo fmt --check`. `scripts/check.sh` runs them with the house rules and lints the gallery for wasm32.
- `rm -rf target/debug/incremental` and `cargo clean -p ely-gpui-component` after each task item keep the disk lean. Cargo keeps every old build of the crate and its tests under a new hash; by T25 they had filled the disk.
@@ -255,4 +255,5 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- 2026-09-28: SystemNotification is `prove`: gpui's `show_system_notification` posts only from an app bundle; unbundled, as `cargo run` starts the gallery, it logs and drops the call, and the web posts nothing.
- 2026-09-29: the ported debug gallery draws its heaviest page about 1.6 times slower than on gpui 0.2.2 (tooling: 9 fps, worst frame 117 ms, against 14 fps and 72 ms), with gpui's accessibility tree on or off (`Application::new_inaccessible` measured the same). Ely's code did not change there; the frame cost is gpui's.
- 2026-09-29: the web gallery runs on one thread, since a static site sends no cross-origin isolation, and draws through WebGL2 where WebGPU is missing. Its pictures are served beside the wasm, not embedded: the wasm stays under Cloudflare's 25 MiB, and a story fetches only its own. `wasm-opt -Oz` takes the wasm from 23.0 to 20.0 MB; gzip and brotli stay near 7.9 and 5.5 MB.
+- 2026-10-04: the Showcase follows gpui-kit.com/apps: apps live in their own public repo and arrive by pull request. The site pins a commit of it in `frontend/showcase.lock` and moves only when someone bumps the pin, so stars refresh on the site with each bump. READMEs take Markdown and PNG pictures alone, no HTML or video, so the site needs no sanitizer. The catalog started empty.
- 2026-10-04: chapter 44, Rendering, holds `RenderSurface` alone. gpui at 1a28cff paints `RenderImage`s everywhere, `CVPixelBuffer`s (NV12) on macOS's Metal renderer alone, and shares no device or shader hook, so shader, camera and GPU views are the host's, feeding the surface, and zero-copy or Metal, OpenGL, Vulkan and Direct3D surfaces are blocked (`tasks/ch44.md`). CI tests the crate and builds the gallery on Linux, Windows and macOS, arm64 and x86_64.
diff --git a/README.md b/README.md
index c66d34d..a99da56 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,8 @@ In a browser, `?page=buttons&story=icon-button&theme=dark` draws one section alo
cd frontend
pnpm install
ELY_GALLERY_ASSETS=https:///gallery/assets/ pnpm gallery # the wasm gallery, into public/gallery
-pnpm dev # or: pnpm build && pnpm preview
+pnpm dev # or: pnpm build && pnpm preview; both fetch the showcase at showcase.lock's commit
+ELY_SHOWCASE=../../Ely-GPUI-Showcases pnpm dev # the showcase from a local checkout
node scripts/og.mjs # with pnpm preview up: public/og.png from the hero
pnpm deploy # Cloudflare, through wrangler
```
diff --git a/TASKS.md b/TASKS.md
index 7f44794..87953e5 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -185,6 +185,7 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T50e Rendering · Platforms: CI builds and tests the crate on Linux, macOS and Windows, arm64 and x86_64
- [x] T50f Rendering · Docs: a guide with an example `scripts/check.sh` compiles; AGENTS.md
- [x] T50g Rendering · Acceptance: the checklist, item by item
+- [x] T51 Showcase · an apps page fed by a public repo — `tasks/showcase.md`
## T00 detail
diff --git a/frontend/.gitignore b/frontend/.gitignore
index c50a3f5..9f1bc08 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -4,3 +4,5 @@ public/gallery
.wrangler
test-results
playwright-report
+public/showcase
+src/showcase.json
diff --git a/frontend/e2e/showcase.spec.ts b/frontend/e2e/showcase.spec.ts
new file mode 100644
index 0000000..5776b78
--- /dev/null
+++ b/frontend/e2e/showcase.spec.ts
@@ -0,0 +1,83 @@
+import { expect, test, type Page } from "@playwright/test";
+import { SUBMIT, type Catalog } from "../src/apps";
+import data from "../src/showcase.json" with { type: "json" };
+
+// The catalog the site was built with: the pinned commit, or ELY_SHOWCASE's checkout.
+const catalog = data as Catalog;
+const empty = catalog.apps.length === 0;
+
+function watch(page: Page): string[] {
+ const errors: string[] = [];
+ page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
+ page.on("pageerror", (e) => errors.push(e.message));
+ return errors;
+}
+
+test("the header's Showcase link opens the page, with a way to submit", async ({ page }) => {
+ const errors = watch(page);
+ await page.goto("/components/");
+ await page.getByRole("navigation", { name: "Site" }).getByRole("link", { name: "Showcase" }).click();
+ await expect(page).toHaveURL("/showcase/");
+ await expect(page.getByRole("heading", { level: 1 })).toHaveText("Showcase.");
+ await expect(page).toHaveTitle("Showcase · Ely");
+ await expect(page.getByRole("link", { name: "Submit your app" })).toHaveAttribute("href", SUBMIT);
+ expect(errors).toEqual([]);
+});
+
+test("an empty catalog says so", async ({ page }) => {
+ test.skip(!empty, "the catalog has apps");
+ await page.goto("/showcase/");
+ await expect(page.locator(".none")).toHaveText("No apps yet. Yours could be the first.");
+ await expect(page.getByRole("searchbox")).toHaveCount(0);
+});
+
+test("featured apps come first, then the rest, and categories count them", async ({ page }) => {
+ test.skip(empty, "the catalog is empty");
+ await page.goto("/showcase/");
+ const all = page.getByRole("group", { name: "Category" }).getByRole("button");
+ await expect(all.first()).toHaveText(`All ${catalog.apps.length}`);
+ await expect(all).toHaveCount(Object.keys(catalog.categories).length + 1);
+ if (catalog.featured.length) {
+ const first = catalog.apps.find((a) => a.id === catalog.featured[0])!;
+ await expect(page.getByRole("list", { name: "Featured apps" }).locator(".app-name").first()).toContainText(first.name);
+ }
+});
+
+test("search narrows the apps, and a miss says so", async ({ page }) => {
+ test.skip(empty, "the catalog is empty");
+ const [app] = catalog.apps;
+ await page.goto("/showcase/");
+ const find = page.getByRole("searchbox", { name: "Search apps" });
+ await find.fill(app.name);
+ await expect(page.getByRole("list", { name: "Matching apps" }).locator(".app-name").first()).toContainText(app.name);
+ await find.fill("zzzz-no-such-app");
+ await expect(page.locator(".none")).toHaveText("Nothing matches.");
+ await find.press("Escape");
+ await expect(find).toHaveValue("");
+});
+
+test("a card opens its app, with its previews and links", async ({ page }) => {
+ test.skip(empty, "the catalog is empty");
+ const errors = watch(page);
+ const [app] = catalog.apps;
+ await page.goto("/showcase/");
+ await page.locator(`.app a[href="/showcase/${app.id}/"]`).first().click();
+ await expect(page).toHaveURL(`/showcase/${app.id}/`);
+ await expect(page.getByRole("heading", { level: 1 })).toContainText(app.name);
+ const shots = page.locator(".app-previews img");
+ await expect(shots).toHaveCount(app.previews.length);
+ await expect.poll(() => shots.first().evaluate((img: HTMLImageElement) => img.naturalWidth)).toBeGreaterThan(0);
+ const links = page.locator(".app-links");
+ if (app.source) await expect(links.getByRole("link", { name: "Source" })).toHaveAttribute("href", app.source);
+ if (app.website) await expect(links.getByRole("link", { name: "Website" })).toHaveAttribute("href", app.website);
+ await expect(page.locator(".app-readme")).toHaveCount(app.html ? 1 : 0);
+ expect(errors).toEqual([]);
+});
+
+test("an unknown app says so", async ({ page }) => {
+ const errors = watch(page);
+ // Ids are kebab-case, so no catalog holds this one.
+ await page.goto("/showcase/no_such_app/");
+ await expect(page.getByRole("heading", { level: 1 })).toHaveText("Not found");
+ expect(errors.join()).toContain("No app named no_such_app");
+});
diff --git a/frontend/package.json b/frontend/package.json
index c8c7c02..2e33fd1 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -4,13 +4,14 @@
"type": "module",
"scripts": {
"gallery": "../scripts/web.sh public/gallery",
- "dev": "vite",
- "build": "tsc --noEmit && vite build",
+ "showcase": "node scripts/showcase.mjs",
+ "dev": "pnpm showcase && vite",
+ "build": "pnpm showcase && tsc --noEmit && vite build",
"preview": "vite preview",
"local": "ELY_GALLERY_ASSETS=http://localhost:4173/gallery/assets/ pnpm gallery && pnpm build && vite preview --port 4173 --strictPort",
"deploy": "wrangler deploy",
"e2e": "playwright test",
- "test": "node --test src/iso/engine.test.ts"
+ "test": "node --test src/iso/engine.test.ts src/apps.test.ts"
},
"devDependencies": {
"@playwright/test": "1.63.0",
diff --git a/frontend/scripts/showcase.mjs b/frontend/scripts/showcase.mjs
new file mode 100644
index 0000000..9bacf47
--- /dev/null
+++ b/frontend/scripts/showcase.mjs
@@ -0,0 +1,71 @@
+// Writes the showcase catalog for the site: the pinned commit, or the checkout ELY_SHOWCASE names.
+import { execFileSync } from "node:child_process";
+import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { Marked } from "marked";
+
+const REPO = "ZacharyZhang-NY/Ely-GPUI-Showcases";
+const site = fileURLToPath(new URL("..", import.meta.url));
+const pub = join(site, "public", "showcase");
+const out = join(site, "src", "showcase.json");
+const stamp = join(pub, ".commit");
+
+/** The catalog at `commit`, unpacked with its validator's packages. */
+async function fetchCatalog(commit) {
+ const work = mkdtempSync(join(tmpdir(), "ely-showcase-"));
+ const url = `https://codeload.github.com/${REPO}/tar.gz/${commit}`;
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`showcase: ${url} answered ${res.status}`);
+ writeFileSync(join(work, "catalog.tar.gz"), Buffer.from(await res.arrayBuffer()));
+ execFileSync("tar", ["-xzf", "catalog.tar.gz"], { cwd: work });
+ const root = join(work, `${REPO.split("/")[1]}-${commit}`);
+ const pnpm = process.env.npm_execpath;
+ if (!pnpm) throw new Error("showcase: run through pnpm, which installs the catalog's validator");
+ execFileSync(process.execPath, [pnpm, "install", "--frozen-lockfile", "--ignore-scripts", "--silent"], { cwd: root, stdio: "inherit" });
+ return { root, done: () => rmSync(work, { recursive: true, force: true }) };
+}
+
+/** Checks the catalog at `root` with its own validator and writes the site's copy. */
+async function write(root, source) {
+ const { read } = await import(pathToFileURL(join(root, "scripts", "catalog.mjs")).href);
+ const catalog = read(root);
+ rmSync(pub, { recursive: true, force: true });
+ mkdirSync(pub, { recursive: true });
+ const apps = catalog.apps.map(({ readme, ...app }) => {
+ const to = join(pub, app.id);
+ mkdirSync(to, { recursive: true });
+ const pictures = new Set(app.previews);
+ const marked = new Marked({
+ gfm: true,
+ walkTokens(token) {
+ if (token.type === "heading") token.depth = Math.min(6, token.depth + 1);
+ if (token.type !== "image") return;
+ pictures.add(token.href);
+ token.href = `/showcase/${app.id}/${token.href}`;
+ },
+ });
+ const html = readme === null ? null : marked.parse(readme, { async: false });
+ for (const name of pictures) copyFileSync(join(root, "apps", app.id, name), join(to, name));
+ return { ...app, html };
+ });
+ writeFileSync(out, `${JSON.stringify({ source, categories: catalog.categories, featured: catalog.featured, apps }, null, 2)}\n`);
+ writeFileSync(stamp, source);
+ console.log(`showcase: ${apps.length} apps, ${catalog.featured.length} featured, from ${source}`);
+}
+
+const local = process.env.ELY_SHOWCASE;
+if (local) {
+ await write(resolve(local), "local");
+} else {
+ const commit = readFileSync(join(site, "showcase.lock"), "utf8").trim();
+ if (!/^[0-9a-f]{40}$/.test(commit)) throw new Error(`showcase: showcase.lock holds no commit: ${commit}`);
+ if (existsSync(out) && existsSync(stamp) && readFileSync(stamp, "utf8") === commit) {
+ console.log(`showcase: ${commit.slice(0, 7)} already written`);
+ } else {
+ const { root, done } = await fetchCatalog(commit);
+ await write(root, commit);
+ done();
+ }
+}
diff --git a/frontend/showcase.lock b/frontend/showcase.lock
new file mode 100644
index 0000000..891de66
--- /dev/null
+++ b/frontend/showcase.lock
@@ -0,0 +1 @@
+0f8f5b9cc392d4741c239b1c1c0f6071dfc6faa5
diff --git a/frontend/src/Shell.tsx b/frontend/src/Shell.tsx
index 3756973..0f9e82b 100644
--- a/frontend/src/Shell.tsx
+++ b/frontend/src/Shell.tsx
@@ -23,6 +23,9 @@ function Header() {
Docs
+
+ Showcase
+