From faf0987cf896221aa428f051e8b27f0b6fa63311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 4 Oct 2026 21:39:13 -0400 Subject: [PATCH] =?UTF-8?q?Site=20=C2=B7=20Showcase:=20apps=20built=20with?= =?UTF-8?q?=20Ely,=20from=20the=20public=20Ely-GPUI-Showcases=20repo=20at?= =?UTF-8?q?=20a=20pinned=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build fetches showcase.lock's commit and runs the catalog's own validator. /showcase/ lists featured apps first, then the rest by newest or stars, with categories and search; /showcase// shows previews, links and the README. The header drops its sound switch below 414px so the fourth link fits. --- AGENTS.md | 5 +- README.md | 3 +- TASKS.md | 1 + frontend/.gitignore | 2 + frontend/e2e/showcase.spec.ts | 83 +++++++++++++++++++ frontend/package.json | 7 +- frontend/scripts/showcase.mjs | 71 ++++++++++++++++ frontend/showcase.lock | 1 + frontend/src/Shell.tsx | 3 + frontend/src/apps.test.ts | 58 +++++++++++++ frontend/src/apps.ts | 61 ++++++++++++++ frontend/src/main.tsx | 6 +- frontend/src/pages/Showcase.tsx | 125 +++++++++++++++++++++++++++++ frontend/src/pages/ShowcaseApp.tsx | 68 ++++++++++++++++ frontend/src/styles.css | 55 +++++++++++-- tasks/reviews.md | 17 ++++ tasks/showcase.md | 14 ++++ 17 files changed, 567 insertions(+), 13 deletions(-) create mode 100644 frontend/e2e/showcase.spec.ts create mode 100644 frontend/scripts/showcase.mjs create mode 100644 frontend/showcase.lock create mode 100644 frontend/src/apps.test.ts create mode 100644 frontend/src/apps.ts create mode 100644 frontend/src/pages/Showcase.tsx create mode 100644 frontend/src/pages/ShowcaseApp.tsx create mode 100644 tasks/showcase.md 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=<site>/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=<site>/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=<checkout>` 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://<site>/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() { <Link to="/docs/" className="link" activeProps={{ "aria-current": "page" }}> Docs </Link> + <Link to="/showcase/" className="link" activeProps={{ "aria-current": "page" }}> + Showcase + </Link> </nav> <div className="end"> <button className="tool sound" aria-label={sound ? "Sound off" : "Sound on"} aria-pressed={sound} onClick={() => setSound(!sound)}> diff --git a/frontend/src/apps.test.ts b/frontend/src/apps.test.ts new file mode 100644 index 0000000..219a7c5 --- /dev/null +++ b/frontend/src/apps.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { counts, filtered, sorted, split, type App, type Catalog } from "./apps.ts"; + +const app = (id: string, publishedAt: string, more: Partial<App> = {}): App => ({ + id, + name: id, + author: "Ada", + description: `${id} does things.`, + category: "dev", + platforms: ["Linux"], + website: null, + source: `https://github.com/ada/${id}`, + publishedAt, + previews: ["preview0.png"], + html: null, + ...more, +}); + +const old = app("old", "2026-01-01T00:00:00Z", { stars: 900 }); +const mid = app("mid", "2026-05-01T00:00:00+08:00", { category: "work", author: "Grace" }); +const fresh = app("fresh", "2026-09-01T00:00:00Z", { stars: 5 }); +const twin = app("twin", "2026-09-01T00:00:00Z", { stars: 50 }); +const catalog: Catalog = { source: "test", categories: { dev: "Developer Tools", work: "Productivity & Media", system: "System & Desktop" }, featured: ["mid", "old"], apps: [old, mid, fresh, twin] }; + +test("newest comes first, and stars break a tie", () => { + assert.deepEqual(sorted(catalog.apps, "newest").map((a) => a.id), ["twin", "fresh", "mid", "old"]); +}); + +test("most stars comes first, and an app without stars comes last", () => { + assert.deepEqual(sorted(catalog.apps, "stars").map((a) => a.id), ["old", "twin", "fresh", "mid"]); +}); + +test("featured apps keep the catalog's order, and the rest leave them out", () => { + const { featured, rest } = split(catalog); + assert.deepEqual(featured.map((a) => a.id), ["mid", "old"]); + assert.deepEqual(rest.map((a) => a.id), ["fresh", "twin"]); +}); + +test("a featured id with no app fails", () => { + assert.throws(() => split({ ...catalog, featured: ["ghost"] }), /featured ghost is no app/); +}); + +test("search reads the name, the author and the description, in any case", () => { + assert.deepEqual(filtered(catalog.apps, "FRESH", null).map((a) => a.id), ["fresh"]); + assert.deepEqual(filtered(catalog.apps, "grace", null).map((a) => a.id), ["mid"]); + assert.deepEqual(filtered(catalog.apps, "twin does", null).map((a) => a.id), ["twin"]); + assert.deepEqual(filtered(catalog.apps, " ", null).length, 4); +}); + +test("a category narrows the search", () => { + assert.deepEqual(filtered(catalog.apps, "", "work").map((a) => a.id), ["mid"]); + assert.deepEqual(filtered(catalog.apps, "fresh", "work"), []); +}); + +test("every category counts its apps, empty ones too", () => { + assert.deepEqual(counts(catalog), [["dev", "Developer Tools", 3], ["work", "Productivity & Media", 1], ["system", "System & Desktop", 0]]); +}); diff --git a/frontend/src/apps.ts b/frontend/src/apps.ts new file mode 100644 index 0000000..b0320f2 --- /dev/null +++ b/frontend/src/apps.ts @@ -0,0 +1,61 @@ +/** An app from the showcase catalog, as `scripts/showcase.mjs` writes it. */ +export interface App { + id: string; + name: string; + author: string; + description: string; + category: string; + platforms: string[]; + website: string | null; + source: string | null; + publishedAt: string; + previews: string[]; + building?: boolean; + stars?: number; + starsUpdatedAt?: string; + /** The README, rendered; null without one. */ + html: string | null; +} + +export interface Catalog { + source: string; + categories: Record<string, string>; + featured: string[]; + apps: App[]; +} + +export type Sort = "newest" | "stars"; + +export const SUBMIT = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Showcases#submit-an-app"; + +/** Newest first, then most stars; or most stars, then newest. */ +export function sorted(apps: App[], sort: Sort): App[] { + const time = (a: App) => Date.parse(a.publishedAt); + const stars = (a: App) => a.stars ?? -1; + const [first, second] = sort === "newest" ? [time, stars] : [stars, time]; + return [...apps].sort((a, b) => first(b) - first(a) || second(b) - second(a) || a.name.localeCompare(b.name)); +} + +/** The featured apps in their order, and the rest. */ +export function split(catalog: Catalog): { featured: App[]; rest: App[] } { + const byId = new Map(catalog.apps.map((a) => [a.id, a])); + const featured = catalog.featured.map((id) => { + const app = byId.get(id); + if (!app) throw new Error(`showcase: featured ${id} is no app`); + return app; + }); + return { featured, rest: catalog.apps.filter((a) => !catalog.featured.includes(a.id)) }; +} + +/** Apps in a category, or all with none, whose name, author or description holds the query. */ +export function filtered(apps: App[], query: string, category: string | null): App[] { + const q = query.trim().toLowerCase(); + return apps.filter((a) => (!category || a.category === category) && (!q || [a.name, a.author, a.description].some((s) => s.toLowerCase().includes(q)))); +} + +/** How many apps each category holds. */ +export function counts(catalog: Catalog): [string, string, number][] { + return Object.entries(catalog.categories).map(([key, label]) => [key, label, catalog.apps.filter((a) => a.category === key).length]); +} + +export const cover = (app: App) => `/showcase/${app.id}/${app.previews[0]}`; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index c430b59..1a95832 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,8 @@ import { Chapters } from "./pages/Chapters"; import { Docs } from "./pages/Docs"; import { Home } from "./pages/Home"; import { NotFound } from "./pages/NotFound"; +import { Showcase } from "./pages/Showcase"; +import { ShowcaseApp } from "./pages/ShowcaseApp"; import { Story } from "./pages/Story"; import "./styles.css"; @@ -27,9 +29,11 @@ const chapter = createRoute({ const story = createRoute({ getParentRoute: () => root, path: "/components/$page/$story", component: Story }); const docs = createRoute({ getParentRoute: () => root, path: "/docs", component: Docs }); const doc = createRoute({ getParentRoute: () => root, path: "/docs/$page", component: Docs }); +const showcase = createRoute({ getParentRoute: () => root, path: "/showcase", component: Showcase }); +const showcaseApp = createRoute({ getParentRoute: () => root, path: "/showcase/$app", component: ShowcaseApp }); const router = createRouter({ - routeTree: root.addChildren([home, index, chapter, story, docs, doc]), + routeTree: root.addChildren([home, index, chapter, story, docs, doc, showcase, showcaseApp]), trailingSlash: "always", scrollRestoration: true, }); diff --git a/frontend/src/pages/Showcase.tsx b/frontend/src/pages/Showcase.tsx new file mode 100644 index 0000000..ff1bf53 --- /dev/null +++ b/frontend/src/pages/Showcase.tsx @@ -0,0 +1,125 @@ +import { Link } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { counts, cover, filtered, sorted, split, SUBMIT, type App, type Catalog, type Sort } from "../apps"; +import data from "../showcase.json"; + +export const catalog = data as Catalog; + +/** The day an app joined, as its manifest wrote it. */ +export const joined = (app: App) => + new Date(`${app.publishedAt.slice(0, 10)}T00:00:00Z`).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", timeZone: "UTC" }); + +export const starCount = (n: number) => `${new Intl.NumberFormat("en-US", { notation: "compact" }).format(n)} ${n === 1 ? "star" : "stars"}`; + +function Card({ app }: { app: App }) { + return ( + <li className="app"> + <Link to="/showcase/$app/" params={{ app: app.id }}> + <span className="app-shot"> + <img src={cover(app)} alt="" loading="lazy" /> + </span> + <span className="app-name"> + {app.name} + {app.building && <span className="app-tag">Building</span>} + </span> + <span className="app-by">{app.author}</span> + <span className="app-text">{app.description}</span> + <span className="app-meta"> + <span>{app.platforms.join(" · ")}</span> + {app.stars !== undefined && <span>{starCount(app.stars)}</span>} + <span>{joined(app)}</span> + </span> + </Link> + </li> + ); +} + +function Grid({ apps, label }: { apps: App[]; label: string }) { + return ( + <ul className="apps" aria-label={label}> + {apps.map((a) => ( + <Card key={a.id} app={a} /> + ))} + </ul> + ); +} + +export function Submit() { + return ( + <section className="submit"> + <h2>Built something with Ely?</h2> + <p>Add it by pull request to the showcase repository.</p> + <a className="button" href={SUBMIT} rel="noopener"> + Submit your app + </a> + </section> + ); +} + +export function Showcase() { + const [query, setQuery] = useState(""); + const [category, setCategory] = useState<string | null>(null); + const [sort, setSort] = useState<Sort>("newest"); + useEffect(() => { + document.title = "Showcase · Ely"; + }, []); + const { featured, rest } = split(catalog); + const searching = query.trim() !== "" || category !== null; + const found = sorted(filtered(catalog.apps, query, category), sort); + const empty = catalog.apps.length === 0; + + return ( + <> + <section className="showcase"> + <h1>Showcase.</h1> + <p className="lede">Apps built with Ely.</p> + {empty ? ( + <p className="none">No apps yet. Yours could be the first.</p> + ) : ( + <> + <input className="find" type="search" placeholder={`Search ${catalog.apps.length} apps`} aria-label="Search apps" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === "Escape" && setQuery("")} /> + <div className="filters"> + <div className="chips" role="group" aria-label="Category"> + <button className="chip" aria-pressed={category === null} onClick={() => setCategory(null)}> + All <span>{catalog.apps.length}</span> + </button> + {counts(catalog).map(([key, label, n]) => ( + <button key={key} className="chip" aria-pressed={category === key} onClick={() => setCategory(key)}> + {label} <span>{n}</span> + </button> + ))} + </div> + <div className="chips" role="group" aria-label="Sort"> + <button className="chip" aria-pressed={sort === "newest"} onClick={() => setSort("newest")}> + Newest + </button> + <button className="chip" aria-pressed={sort === "stars"} onClick={() => setSort("stars")}> + Most stars + </button> + </div> + </div> + {searching ? ( + found.length ? <Grid apps={found} label="Matching apps" /> : <p className="none">Nothing matches.</p> + ) : ( + <> + {featured.length > 0 && ( + <> + <h2 className="apps-title">Featured</h2> + <Grid apps={featured} label="Featured apps" /> + </> + )} + {rest.length > 0 && ( + <> + <h2 className="apps-title">{featured.length ? "More apps" : "All apps"}</h2> + <Grid apps={sorted(rest, sort)} label={featured.length ? "More apps" : "All apps"} /> + </> + )} + </> + )} + </> + )} + </section> + <Submit /> + </> + ); +} diff --git a/frontend/src/pages/ShowcaseApp.tsx b/frontend/src/pages/ShowcaseApp.tsx new file mode 100644 index 0000000..eb7c64d --- /dev/null +++ b/frontend/src/pages/ShowcaseApp.tsx @@ -0,0 +1,68 @@ +import { Link, useParams } from "@tanstack/react-router"; +import { useEffect } from "react"; +import { catalog, joined, starCount, Submit } from "./Showcase"; +import { NotFound } from "./NotFound"; + +export function ShowcaseApp() { + const { app: id } = useParams({ strict: false }); + const app = catalog.apps.find((a) => a.id === id); + useEffect(() => { + if (app) document.title = `${app.name} · Showcase · Ely`; + }, [app]); + if (!app) return <NotFound what={`No app named ${id} in the showcase.`} />; + + return ( + <> + <article className="app-page"> + <Link className="crumb" to="/showcase/"> + ← Showcase + </Link> + <h1> + {app.name} + {app.building && <span className="app-tag">Building</span>} + </h1> + <p className="app-by">{app.author}</p> + <p className="lede">{app.description}</p> + <div className="app-links"> + {app.website && ( + <a className="button" href={app.website} rel="noopener"> + Website + </a> + )} + {app.source && ( + <a className={app.website ? "text" : "button"} href={app.source} rel="noopener"> + Source + </a> + )} + </div> + <dl className="app-facts"> + <div> + <dt>Category</dt> + <dd>{catalog.categories[app.category]}</dd> + </div> + <div> + <dt>Platforms</dt> + <dd>{app.platforms.join(", ")}</dd> + </div> + {app.stars !== undefined && ( + <div> + <dt>GitHub</dt> + <dd>{starCount(app.stars)}</dd> + </div> + )} + <div> + <dt>Added</dt> + <dd>{joined(app)}</dd> + </div> + </dl> + <div className="app-previews"> + {app.previews.map((name, i) => ( + <img key={name} src={`/showcase/${app.id}/${name}`} alt={`${app.name}, screenshot ${i + 1} of ${app.previews.length}`} loading={i ? "lazy" : "eager"} /> + ))} + </div> + {app.html && <div className="prose app-readme" dangerouslySetInnerHTML={{ __html: app.html }} />} + </article> + <Submit /> + </> + ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e40fdd3..29b0a4f 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -197,6 +197,41 @@ img.shot { display: block; max-width: 100%; height: auto; } .code .copy { position: absolute; top: 10px; right: 12px; padding: 4px 8px; font-size: 12px; color: var(--muted); transition: color 160ms ease-out; } .code .copy:hover { color: var(--fg); } +.showcase, .app-page { padding: 160px 64px 64px; } +.showcase h1 { font-size: clamp(48px, 7vw, 112px); line-height: 0.95; font-weight: 800; letter-spacing: -0.045em; } +.lede { margin-top: 20px; font-size: 20px; line-height: 30px; max-width: 48ch; } +.showcase .none { margin-top: 56px; } +.filters { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px 32px; margin: -40px 0 56px; } +.chips { display: flex; flex-wrap: wrap; gap: 8px; } +.chip { height: 32px; padding: 0 14px; border: 1px solid var(--line); border-radius: 999px; font-size: 13px; color: var(--muted); transition: color 160ms ease-out, border-color 160ms ease-out; } +.chip span { color: var(--faint); font-variant-numeric: tabular-nums; } +.chip:hover, .chip[aria-pressed="true"] { color: var(--fg); border-color: var(--fg); } +.apps-title { margin: 0 0 24px; font-size: 14px; font-weight: 500; letter-spacing: 0; color: var(--muted); } +.apps { list-style: none; margin: 0 0 72px; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: 48px 32px; } +.app a { display: grid; gap: 6px; border-radius: 12px; } +.app-shot { display: block; aspect-ratio: 16 / 10; margin-bottom: 14px; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; background: var(--code); } +.app-shot img { display: block; width: 100%; height: 100%; object-fit: contain; transition: transform 320ms ease-out; } +.app a:hover .app-shot img { transform: scale(1.02); } +.app-name { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 20px; line-height: 28px; font-weight: 700; letter-spacing: -0.02em; overflow-wrap: anywhere; } +.app-tag { padding: 0 8px; border: 1px solid var(--line); border-radius: 999px; font: 400 12px/20px var(--mono); letter-spacing: 0; color: var(--muted); } +.app-by { font-size: 14px; color: var(--muted); overflow-wrap: anywhere; } +.app-page .lede, .app-facts dd { overflow-wrap: anywhere; } +.app-text { font-size: 15px; line-height: 24px; color: color-mix(in srgb, var(--fg) 82%, var(--bg)); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; } +.app-meta { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 4px; font: 400 12px/18px var(--mono); color: var(--faint); } +.submit { display: grid; justify-items: start; gap: 20px; margin: 0 64px; padding: 96px 0 128px; border-top: 1px solid var(--line); } +.submit h2 { font-size: clamp(32px, 4vw, 56px); line-height: 1; font-weight: 800; letter-spacing: -0.04em; } +.app-page { max-width: 1080px; } +.app-page h1 { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 8px 0 12px; font-size: clamp(40px, 5vw, 80px); line-height: 1; font-weight: 800; letter-spacing: -0.04em; overflow-wrap: anywhere; } +.app-page h1 .app-tag { font-size: 13px; } +.app-links { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; margin-top: 32px; } +.app-facts { display: flex; flex-wrap: wrap; gap: 24px 48px; margin: 48px 0; } +.app-facts dt { font: 400 12px/18px var(--mono); color: var(--faint); } +.app-facts dd { margin: 4px 0 0; font-size: 15px; } +.app-previews { display: grid; gap: 32px; } +.app-previews img, .app-readme img { display: block; max-width: 100%; height: auto; border: 1px solid var(--line); border-radius: 12px; } +.app-readme { max-width: 720px; margin-top: 32px; } +.app-readme th, .app-readme td { overflow-wrap: anywhere; } + .missing { min-height: 70vh; display: grid; align-content: center; justify-items: start; gap: 24px; padding: 160px 64px; } .missing h1 { font-size: 64px; letter-spacing: -0.04em; } @@ -224,8 +259,12 @@ img.shot { display: block; max-width: 100%; height: auto; } @media (max-width: 767px) { .bar { padding: 0 16px; gap: 12px; } + .bar nav { gap: 16px; } .bar .end { gap: 0; } - .hero, .chapters, .missing { padding-left: 20px; padding-right: 20px; } + .hero, .chapters, .missing, .showcase, .app-page { padding-left: 20px; padding-right: 20px; } + .showcase, .app-page { padding-top: 112px; } + .submit { margin: 0 20px; padding: 72px 0 96px; } + .filters { margin-top: -40px; } .reader, .docs { padding: 0 20px; } .chapters li { grid-template-columns: 40px minmax(0, 1fr); } .chapters .titles { grid-column: 2; margin-top: 8px; } @@ -233,7 +272,7 @@ img.shot { display: block; max-width: 100%; height: auto; } .chapters .name { font-size: 22px; } .hero-iso { width: 100%; height: 55%; } .hero > :not(.iso) { max-width: none; } - .hero h1, .chapters h1 { font-size: 13vw; } + .hero h1, .chapters h1, .showcase h1 { font-size: 13vw; } .feature h2, .city h2, .close h2 { font-size: 10vw; } .feature, .city { padding: 112px 20px 48px; } .feature-iso { min-height: 220px; } @@ -243,13 +282,17 @@ img.shot { display: block; max-width: 100%; height: auto; } .close { padding: 112px 20px; } } -@media (max-width: 359px) { - .bar { padding: 0 12px; gap: 10px; } - .bar nav { gap: 14px; } - .bar .tool { width: 32px; } +@media (max-width: 413px) { .bar .sound { display: none; } } +@media (max-width: 359px) { + .bar { padding: 0 12px; gap: 8px; } + .bar nav { gap: 10px; } + .bar .link { font-size: 13px; } + .bar .tool { width: 30px; } +} + @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } } diff --git a/tasks/reviews.md b/tasks/reviews.md index db65148..2707e9e 100644 --- a/tasks/reviews.md +++ b/tasks/reviews.md @@ -360,3 +360,20 @@ | T50f | 2 | PASS | | | T50g | 1 | FAIL | the platforms row said the gallery builds on all six; macOS arm64 checks it with clippy | | T50g | 2 | PASS | | +| S1 | 1 | PASS | codex gpt-6.1-sol high | +| S2 | 1 | FAIL | entity-encoded hosts; inline code and encoded addresses in READMEs; counting off Twitter's; zoneless times; a category list; PNG by signature alone; junctions; HTML media declined: Markdown pictures only | +| S2 | 2 | FAIL | zone offsets unchecked; undecodable PNGs; prose punctuation broke allowed addresses; install steps in words | +| S2 | 3 | FAIL | trimming ate an allowed address's own `_`; fixed after the cap (prefix plus punctuation), tested, not reviewed again | +| S3 | 1 | FAIL | pushes off main ran no checks; GitHub sources with a slash or a query got no stars | +| S3 | 2 | FAIL | a test's pattern lost its escapes and the suite failed to load | +| S3 | 3 | PASS | | +| S4 | 1 | PASS | run 37250376319 green | +| S5 | 1 | FAIL | dev and build replaced a local preview with the pin | +| S5 | 2 | PASS | | +| S6 | 1 | FAIL | the header clipped at 320px once Geist loaded; a long author overflowed | +| S6 | 2 | PASS | | +| S7 | 1 | FAIL | a README table overflowed at 320px | +| S7 | 2 | PASS | | +| S8 | 1 | FAIL | the card click matched names by substring; the unknown id could be a real one | +| S8 | 2 | PASS | | +| S9 | 1 | PASS | | diff --git a/tasks/showcase.md b/tasks/showcase.md new file mode 100644 index 0000000..429622d --- /dev/null +++ b/tasks/showcase.md @@ -0,0 +1,14 @@ +# Showcase round, 2026-10-04 + +An apps page like gpui-kit.com/apps, fed by a public repo, `ZacharyZhang-NY/Ely-GPUI-Showcases`. +Choices, the user's: the catalog starts empty; the site fetches the repo at a pinned commit when it builds; categories and search, stars from CI, a detail page from the app's README, a featured order. + +- [x] S1 Plan: these lines, the T51 lines in `TASKS.md` +- [x] S2 Repo · Catalog: `apps/<id>/manifest.json`, `featured.json`, README, CONTRIBUTING; `scripts/validate.mjs` with tests +- [x] S3 Repo · CI: validate on every push and pull request; stars refreshed on main and weekly, `scripts/stars.mjs` with tests +- [x] S4 Repo · Publish: the public repo on GitHub, pushed, CI green +- [x] S5 Site · Data: `frontend/showcase.lock` pins a commit; `scripts/showcase.mjs` fetches it, runs the repo's validator, renders READMEs, writes the catalog; dev, build and CI run it +- [x] S6 Site · List: `/showcase/`, a Showcase link in the header, featured first, the rest newest then stars, categories with counts, search, the empty state, Submit +- [x] S7 Site · Detail: `/showcase/$app/`, previews, links, platforms, stars, the README +- [x] S8 Site · Tests: unit tests for order and filters; Playwright for the page, the empty state and the link +- [x] S9 Docs: AGENTS.md; acceptance, item by item