diff --git a/docs/superpowers/plans/2026-09-17-screenshot-rig.md b/docs/superpowers/plans/2026-09-17-screenshot-rig.md new file mode 100644 index 00000000..aa868322 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-screenshot-rig.md @@ -0,0 +1,511 @@ +# Screenshot Rig Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Regenerate the three site figures (`history-dark`, `commit-dark`, +`welcome-dark`) as sharp, current 2x masters from a repeatable `pnpm shoot` +command, with no human clicking a window. + +**Architecture:** The app sets `titleBarStyle: "Overlay"`, so it draws its whole +titlebar in HTML — only the drop shadow and three traffic lights are native. So +render the real `src/` components in headless Chrome at device scale factor 2 +with every `@tauri-apps/*` import aliased to a shim, then composite the native +pixels with sharp. A *scene* is a set of `localStorage` entries plus a +`cmd -> fixture` map; the mock throws on an unregistered command, which is what +turns fixture-building into a worklist rather than guesswork. + +**Tech Stack:** Vite 7 (`resolve.alias`), React 19, headless Chrome +(`--force-device-scale-factor=2`), sharp (already on disk via astro's optional +dependency), TypeScript. + +**Spec:** `docs/superpowers/specs/2026-09-17-screenshot-rig-design.md` + +## Global Constraints + +- Masters land in `site/screenshots/.png` at exactly **3200x2224**, window + **2924x1950**, shadow margins **138** left/right, **94** top, **180** bottom. + These are 2x the measured geometry of the shipped master; `screenshots.mjs` + and `Screenshot.astro` both hardcode the 1600/1112 aspect and must not change. +- Figure names stay `history-dark`, `commit-dark`, `welcome-dark`. +- Dark theme, accent pinned (#455 added palette shuffling). +- Text size and Spacing presets pinned explicitly (#459 drives `--row-scale` / + `--row-step`). +- The clock is frozen per scene — relative ages must not drift with the calendar. +- Fixtures import their types from `src/lib/types.ts`. A backend shape change + must break `tsc`, not the picture. +- No new runtime dependency for the site: sharp is resolved from + `node_modules/.pnpm` the way `screenshots.mjs` already does it. +- Nothing in this plan runs in CI. +- `pnpm` and `cargo` need `~/Library/pnpm` / `~/.cargo/bin` on PATH; in a + worktree-isolated session call the binaries by absolute path + (`~/Library/pnpm/pnpm`) rather than exporting PATH. + +--- + +### Task 1: Shims and Vite config — boot the real app in a browser + +**Files:** +- Create: `site/scripts/shoot/vite.config.ts` +- Create: `site/scripts/shoot/shim/{core,event,window,webviewWindow,dpi,log,dialog,os}.ts` +- Create: `site/scripts/shoot/entry.tsx` +- Create: `site/scripts/shoot/index.html` +- Create: `site/scripts/shoot/tsconfig.json` + +**Interfaces:** +- Produces: `registerScene(scene: Scene): void` and + `type Scene = { name: string; storage: Record; now: string; + handlers: Record) => unknown> }` from + `shim/core.ts`. Tasks 3 and 4 author `Scene` objects. +- Produces: `invoke(cmd, args): Promise` from `shim/core.ts`, which throws + `Error("[shoot] no fixture for \"\"")` on a miss. + +- [ ] **Step 1: Write `shim/core.ts` with the throwing registry** + +Mirror `src/test/invokeMock.ts`'s shape, but driven by a scene rather than +per-test registration: + +```ts +export type Handler = (args: Record) => unknown; +export type Scene = { + name: string; + storage: Record; + now: string; // ISO; frozen clock + handlers: Record; +}; + +let scene: Scene | null = null; +export function registerScene(s: Scene): void { scene = s; } +export function currentScene(): Scene { + if (!scene) throw new Error("[shoot] no scene registered"); + return scene; +} + +export async function invoke(cmd: string, args?: Record): Promise { + const h = currentScene().handlers[cmd]; + if (!h) throw new Error(`[shoot] no fixture for "${cmd}"`); + return (await h(args ?? {})) as T; +} +``` + +- [ ] **Step 2: Write the remaining shims as inert stubs** + +A still figure never listens, logs or opens a dialog. Each shim exports only +what `src/` imports — check with +`grep -rh "from \"@tauri-apps/api/window\"" src/ | sed 's/.*import //'`. + +```ts +// shim/event.ts +export async function listen(): Promise<() => void> { return () => {}; } +export async function emit(): Promise {} +export const TauriEvent = { WINDOW_RESIZED: "tauri://resize" } as const; +``` + +```ts +// shim/log.ts — the app calls these on real paths; swallow them +export async function attachConsole(): Promise<() => void> { return () => {}; } +export async function info(): Promise {} +export async function warn(): Promise {} +export async function error(): Promise {} +export async function debug(): Promise {} +export async function trace(): Promise {} +``` + +`shim/window.ts` and `shim/webviewWindow.ts` need a `getCurrentWindow()` / +`getCurrentWebviewWindow()` returning an object whose methods resolve: `show`, +`hide`, `setTitle`, `label` (`"main"`), `onThemeChanged`, `listen`, `theme` +(`async () => "dark"`), `isVisible` (`async () => true`). `shim/os.ts` exports +`platform: () => "macos"`. `shim/dpi.ts` exports `LogicalSize`/`PhysicalSize` +classes. `shim/dialog.ts` exports `save`/`open`/`message`/`confirm` resolving +`null`/`false`. + +- [ ] **Step 3: Write `vite.config.ts` aliasing every Tauri entry** + +```ts +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const shim = (f: string) => path.resolve(import.meta.dirname, "shim", f); + +export default defineConfig({ + root: import.meta.dirname, + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(root, "src"), + "@tauri-apps/api/core": shim("core.ts"), + "@tauri-apps/api/event": shim("event.ts"), + "@tauri-apps/api/window": shim("window.ts"), + "@tauri-apps/api/webviewWindow": shim("webviewWindow.ts"), + "@tauri-apps/api/dpi": shim("dpi.ts"), + "@tauri-apps/plugin-log": shim("log.ts"), + "@tauri-apps/plugin-dialog": shim("dialog.ts"), + "@tauri-apps/plugin-os": shim("os.ts"), + }, + }, + worker: { format: "es" }, + server: { port: 1430, strictPort: true }, +}); +``` + +`worker: { format: "es" }` is copied from the root config deliberately — the +syntax tokenizer is a module worker and the build fails without it. + +- [ ] **Step 4: Write `entry.tsx` — seed storage, freeze the clock, mount** + +Storage must be written **before** any store module is imported, because the +Zustand stores read `localStorage` at module scope. A dynamic `import()` after +seeding is what guarantees the order. + +```tsx +import { registerScene, type Scene } from "./shim/core"; +import { scenes } from "./scenes"; + +const name = new URLSearchParams(location.search).get("scene") ?? "welcome"; +const scene: Scene | undefined = scenes[name]; +if (!scene) throw new Error(`[shoot] unknown scene "${name}"`); + +registerScene(scene); +localStorage.clear(); +for (const [k, v] of Object.entries(scene.storage)) localStorage.setItem(k, v); + +// Freeze the clock so relative ages ("1mo ago") never drift with the calendar. +const FIXED = new Date(scene.now).getTime(); +const RealDate = Date; +class FrozenDate extends RealDate { + constructor(...args: ConstructorParameters) { + if (args.length === 0) super(FIXED); + else super(...args); + } + static now() { return FIXED; } +} +globalThis.Date = FrozenDate as DateConstructor; + +const [{ default: React }, { default: ReactDOM }, { default: App }] = + await Promise.all([import("react"), import("react-dom/client"), import("@/App")]); +await import("@/index.css"); + +ReactDOM.createRoot(document.getElementById("root")!).render(); +// The driver waits on this flag rather than a fixed timeout. +queueMicrotask(() => { (window as never as { __shotReady?: boolean }).__shotReady = true; }); +``` + +Note `` is mounted WITHOUT `React.StrictMode` and without +`RevealOnFirstPaint` / `PGErrorBoundary`: StrictMode double-invokes effects, +which doubles fixture calls for no benefit, and the reveal is a no-op outside +Tauri. + +- [ ] **Step 5: Add a minimal `welcome` scene so there is something to boot** + +`welcome-dark` is the no-repository state, so it needs almost no fixtures. In +`scenes/welcome.ts`, `storage` is `{}` (nothing open) and `handlers` starts +empty. This is the smoke test for the whole shim layer. + +- [ ] **Step 6: Run the dev server and iterate on the throw list** + +Run: `~/Library/pnpm/pnpm exec vite --config site/scripts/shoot/vite.config.ts` +then open `http://localhost:1430/?scene=welcome`. + +Expected on first run: a `[shoot] no fixture for "…"` throw, or a missing-export +error from a shim. Each one names exactly what to add. Add it, reload, repeat +until the Welcome card renders. Record every command the welcome screen asked +for in a comment at the top of `scenes/welcome.ts` — later scenes inherit them. + +- [ ] **Step 7: Commit** + +``` +feat(site): a headless shim layer that boots the app in a browser +``` + +--- + +### Task 2: The driver and compositor — geometry before content + +**Files:** +- Create: `site/scripts/shoot/shoot.mjs` +- Create: `site/scripts/shoot/composite.mjs` +- Create: `site/scripts/shoot/chrome.sh` +- Modify: `site/package.json` (add the `shoot` script) + +**Interfaces:** +- Consumes: the dev server from Task 1 at `?scene=`. +- Produces: `composite(bodyPng: Buffer, outPath: string): Promise`, writing + a 3200x2224 PNG. + +- [ ] **Step 1: Write `chrome.sh`, a one-line wrapper** + +A quoted Chrome path is refused in a worktree-isolated session as "a command +whose name is computed at runtime", and symlinking the binary breaks it — Chrome +resolves `Google Chrome Framework` relative to the symlink and dies in `dlopen`. +A wrapper that `exec`s the real path is the only thing that works: + +```sh +#!/bin/sh +exec "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" "$@" +``` + +`chmod +x` it. + +- [ ] **Step 2: Write `composite.mjs` against the measured geometry** + +```js +export const GEOM = { + canvas: { w: 3200, h: 2224 }, + window: { w: 2924, h: 1950 }, + margin: { left: 138, top: 94 }, // right 138, bottom 180 + radius: 20, // 10pt at 2x + lights: { cy: 138, r: 12, cx: [152, 192, 232], + fill: ["#ff5f57", "#febc2e", "#28c840"] }, +}; +``` + +Round the body's corners with an SVG mask, draw a blurred black rounded rect +beneath it as the shadow, then composite the three circles. Return early with a +clear error if the body PNG is not exactly `GEOM.window`. + +- [ ] **Step 3: Verify geometry against the shipped master BEFORE any content** + +This is the step that proves the composite, and it must come first. Feed +`composite()` a solid magenta 2924x1950 rectangle and write +`/tmp/geomcheck.png`. Then run the same alpha-bounding-box measurement used to +derive the numbers: + +Run a node script that loads `/tmp/geomcheck.png`, finds the bbox where +`alpha > 250`, and prints it. +Expected: `x 138..3061 y 94..2043`, i.e. window `2924 x 1950`, margins +`left 138 right 138 top 94 bottom 180` — exactly 2x the shipped master's +`69/69/47/90`. + +If it disagrees, fix `composite.mjs` now; every figure inherits this. + +- [ ] **Step 4: Write `shoot.mjs` to drive Chrome** + +Start the vite server as a child process, wait for the port, then per scene run +Chrome headless. Chrome **writes the png and then does not exit** — so treat a +timeout as success if the file exists, and `pkill -f ` +afterwards. Give each shot its own `--user-data-dir` or concurrent shots collide. + +```js +const args = [ + "--headless=new", "--disable-gpu", "--hide-scrollbars", + `--force-device-scale-factor=2`, + `--window-size=${GEOM.window.w / 2},${GEOM.window.h / 2}`, + `--screenshot=${bodyPath}`, + "--virtual-time-budget=8000", + `--user-data-dir=${profileDir}`, + `http://localhost:1430/?scene=${name}`, +]; +``` + +`--window-size` is in CSS px and the scale factor doubles it, so 1462x975 CSS +yields the 2924x1950 body. + +- [ ] **Step 5: Wire `pnpm shoot` and run it for `welcome`** + +Add to `site/package.json`: `"shoot": "node scripts/shoot/shoot.mjs"`. + +Run: `~/Library/pnpm/pnpm shoot welcome` +Expected: `site/screenshots/welcome-dark.png` at 3200x2224. Read the PNG and +confirm the Welcome card is centred, the theme is dark, and the traffic lights +sit in the titlebar rather than over content. + +- [ ] **Step 6: Commit** + +``` +feat(site): drive headless Chrome and composite the macOS window chrome +``` + +--- + +### Task 3: The showcase fixtures and the history scene + +**Files:** +- Create: `site/scripts/shoot/fixtures/showcase.ts` +- Create: `site/scripts/shoot/scenes/history.ts` +- Modify: `site/scripts/shoot/scenes/index.ts` + +**Interfaces:** +- Consumes: `Scene` from `shim/core.ts`. +- Produces: `SHOWCASE_COMMITS`, `SHOWCASE_STATUS`, `SHOWCASE_DIFF`, + `SHOWCASE_HEAD`, `SHOWCASE_BRANCHES` — each typed with the matching import + from `@/lib/types`. + +- [ ] **Step 1: Transcribe the existing hero's content as fixtures** + +The shipped `history-dark.png` is the content brief — it is a composition that +was already approved, and reproducing it keeps the figure comparable. Its seven +visible refs: `HEAD->main`, `fix/div-by-zero-message`, `feat/repl`, +`origin/feat/repl`, `origin/main`, `topic/docs-site`, `topic/bench`. Authors: +Jonas Aasberg, Kofi Mensah, Ana Ruiz, Yuki Tanaka. Twelve rows are visible; +provide ~30 so the list scrolls naturally. + +Type every array against `src/lib/types.ts` — `Commit[]`, `FileStatus[]`, +`HeadInfo`. Do not invent field names; read the type first. + +- [ ] **Step 2: Set the frozen clock so the age column reads "1mo ago"** + +The repository's commits are pinned to 2026-06-26. Set `now` in the scene to +**`"2026-07-28T10:30:00+02:00"`**, roughly one month later, which reproduces the +"1mo ago" / "2mo ago" column the approved composition shows. + +- [ ] **Step 3: Seed the history scene's storage** + +```ts +storage: { + "pg-screen": JSON.stringify("history"), + "pg-open-repos": JSON.stringify([{ path: "/Users/jonas/pgit-showcase", name: "pgit-showcase" }]), + "pg-history-diff-layout": JSON.stringify("side-by-side"), + "pg-settings-v2": JSON.stringify({ themeMode: "dark", textSize: "default", spacing: "default" }), +} +``` + +Read the real shapes from `useTabsStore.ts` and `useSettingsStore.ts` before +writing these — a wrong shape is silently ignored and the scene renders the +default state, which looks like a fixture bug but is a storage bug. + +- [ ] **Step 4: Run and follow the throw list to completion** + +Run: `~/Library/pnpm/pnpm shoot history` +Each `[shoot] no fixture for ""` names the next fixture. Expect roughly: +`open_repo`, `head_info`, `get_status`, `log`/`list_commits`, `commit_detail`, +`diff_commit`, `list_branches`, `ahead_behind`, `blob_ceiling`. + +- [ ] **Step 5: Read the rendered PNG and check it against the current UI** + +Expected, and each is a drift this plan exists to fix: +- icons are lucide (#425), not the hand-drawn set +- the activity bar runs to the window's bottom edge (#426) +- a tag pill carries a tag icon, not a branch's (#443) +- the commit subject column is not collapsed (#444) + +- [ ] **Step 6: Commit** + +``` +feat(site): the showcase fixtures and the history figure +``` + +--- + +### Task 4: The commit scene + +**Files:** +- Create: `site/scripts/shoot/scenes/commit.ts` +- Modify: `site/scripts/shoot/scenes/index.ts` + +**Interfaces:** +- Consumes: the `SHOWCASE_*` fixtures from Task 3 and `Scene` from `shim/core.ts`. + +- [ ] **Step 1: Read the shipped `commit-dark.png` and list what it shows** + +Run `Read site/screenshots/commit-dark.png`. It is the commit/working-tree +screen; note which panes, which staged/unstaged split and what message text the +approved composition used. + +- [ ] **Step 2: Seed storage for the commit screen** + +`"pg-screen": JSON.stringify("commit")` plus `pg-commit-view-mode`, reusing the +same repo entry and settings as Task 3. Reuse `SHOWCASE_STATUS` so the two +figures agree about the repository — the hero's status bar says "4 changed" and +a commit figure showing a different count reads as two different products. + +- [ ] **Step 3: Run and complete the throw list** + +Run: `~/Library/pnpm/pnpm shoot commit` +Expect additionally: `diff_worktree`/`diff_index`, `list_staged`, and the +commit-composer commands (`commit_template`, `commit_cleanup_mode`). + +- [ ] **Step 4: Read the PNG and confirm the composition matches the original** + +- [ ] **Step 5: Commit** + +``` +feat(site): the commit figure +``` + +--- + +### Task 5: Encode, re-alt, retire the manual path + +**Files:** +- Modify: `site/src/pages/index.astro` (hero alt text) +- Modify: `site/src/pages/features.astro` (two alt texts + captions) +- Delete: `site/scripts/capture.mjs` +- Modify: `site/package.json` (drop the `capture` script) +- Modify: `docs/dev/` (whichever file documents the site figures) + +- [ ] **Step 1: Encode all three and confirm the 2x variant appears** + +Run: `~/Library/pnpm/pnpm screenshots` in `site/` +Expected: six files in `site/public/screenshots/` — `.webp` AND +`@2x.webp` for all three — and **no** "is 1x — so NO 2x variant" warning. +That warning's absence is the machine-checkable form of "crisp". + +- [ ] **Step 2: Rewrite the alt text against the new renders** + +The alt text is unusually load-bearing here: these are the only images on the +site carrying product information, and `Screenshot.astro` documents that +"screenshot of platypusgit" is not acceptable. The current hero alt lists a +specific ref set and panel layout — re-read the new PNG and correct anything +that changed. + +- [ ] **Step 3: Delete `capture.mjs` and its package script** + +The spec's reasoning: leaving a broken manual path beside a working automated +one invites someone to use it. Its aspect gate and resize target disagree by +construction, so it cannot produce a master. + +- [ ] **Step 4: Document the rig where the figures are documented** + +Find the doc that currently points at `pnpm capture` +(`grep -rn "pnpm capture" docs/ site/ CLAUDE.md`) and replace it with `pnpm +shoot`, naming the scene files as the place a figure's content is decided. + +- [ ] **Step 5: Verify the whole gate** + +Run, in order: +- `~/Library/pnpm/pnpm exec tsc -p site/scripts/shoot/tsconfig.json --noEmit` +- `~/Library/pnpm/pnpm test` (repo root — `docs` project reads the tree) +- `~/Library/pnpm/pnpm vite build` in `site/` + +Expected: all pass. The `docs` vitest project asserts tree invariants and may +notice a removed script. + +- [ ] **Step 6: Commit and open the PR** + +``` +feat(site): regenerate the figures from a headless rig +``` + +--- + +## Self-Review + +**Spec coverage.** Every section of the spec maps to a task: the shim layer and +the one-function mock surface to Task 1; geometry, the compositor and the DPR-2 +render to Task 2; fixtures, type-pinning, the frozen clock and pinned +presets to Tasks 1/3; the three figures to Tasks 2-4; encoding, alt text, +retiring `capture.mjs` and the docs pointer to Task 5. The "what runs when" +decision (on-demand, committed masters, no CI) is realised by Task 2 Step 5 +adding only a local `pnpm shoot` script and by no task touching `.github/`. + +**Placeholder scan.** No TBD/TODO. The two places that read like +open questions are deliberate and are the method the spec argues for, not gaps: +the throw-list loops (Task 1 Step 6, Task 3 Step 4, Task 4 Step 3) are a +worklist the mock generates, and each names the expected commands so the +executor knows when it is done. Task 3 Step 1 and Task 4 Step 1 direct the +executor to read the shipped PNG because the approved composition is the brief. + +**Type consistency.** `Scene`, `registerScene`, `currentScene`, `invoke` and +`GEOM` are defined once (Tasks 1-2) and referenced with the same names in Tasks +3-4. `SHOWCASE_*` is defined in Task 3 and consumed by name in Task 4. Scene +names (`welcome`, `history`, `commit`) match the `?scene=` parameter and the +`pnpm shoot ` argument throughout; figure names (`welcome-dark`, +`history-dark`, `commit-dark`) stay distinct from scene names and are only used +for output paths. + +**One risk the executor must not paper over.** If a scene renders the default +state instead of the seeded one, the cause is almost always a `localStorage` +shape that the store ignored, not a missing fixture. Read the store's parser +before adding fixtures to chase it. diff --git a/docs/superpowers/specs/2026-09-17-screenshot-rig-design.md b/docs/superpowers/specs/2026-09-17-screenshot-rig-design.md new file mode 100644 index 00000000..3823b83f --- /dev/null +++ b/docs/superpowers/specs/2026-09-17-screenshot-rig-design.md @@ -0,0 +1,199 @@ +# A screenshot rig for the site figures — spec + +No issue: requested directly — "we need crisp screenshots for the site. the +current ones are blurry and old." + +## What is on the site today, and why both halves of that sentence are true + +Three figures carry every pixel of product information the marketing site +shows: `history-dark` is the hero on `index.astro`, and `commit-dark` plus +`welcome-dark` sit on `features.astro`. They are real captures of the app over +a purpose-built demo repository (`pgit-showcase`, fictional authors), taken on +**2026-08-18**. + +**Blurry** is a resolution fact, not a taste. The masters in `site/screenshots/` +are 1600x1112 — a 1x capture. `Screenshot.astro` lays the figures out at 1040 +CSS px and already ships a `srcset` offering a `@2x` variant, and +`scripts/screenshots.mjs` already knows how to encode one. Both refuse to +produce it, correctly, because the master does not contain the pixels: the +2x variant is emitted only when `master.width >= RENDER_W * 2` (2080). So every +Retina visitor is handed the 1040px variant and paints it into 2080 device +pixels, and a screenshot of a UI is almost entirely text — the one content that +does not survive resampling. No WebP quality setting reaches this; the detail +was never in the file. + +**Old** is the larger problem, and it is invisible in a diff. 112 commits have +touched `src/` since those captures. The decisive one is `a73f5ef feat(design): +replace the hand-drawn icon set with lucide-react (#425)` — *every* icon in all +three figures belongs to a set the app no longer ships. Alongside it, and all +visible in the hero: `babc0f7` runs the activity bar to the window's bottom +edge, `0530187` gives a tag pill a tag icon rather than a branch's, `a666295` +stops a narrow log pane from eating the subject column, `75bd3c4` adds the +global Text size and Spacing presets that move every row's geometry. The demo +repository's commits are pinned to 2026-06-26, so the "1mo ago" column in the +hero would read "2mo ago" today. + +So the site is advertising an August build with a retired icon set, softly. + +## Why the existing capture path cannot fix it + +`scripts/capture.mjs` was written on 2026-08-27 — *after* those masters — to +solve exactly this, by demanding a 2x capture and rejecting a 1x one. It has +never produced a master, and on inspection it cannot in its current form. + +It sizes the app **window** to `WINDOW_PT` = 1600x1112 points, then validates +the **resulting PNG** against `RATIO = 1600/1112` (1.4388). Those are different +rectangles. `screencapture -o` returns the window plus its drop shadow over a +transparent margin, so the PNG is always larger than the window. Measured from +the shipped master: a 1462x975 window sits in a 1600x1112 canvas, with margins +of 69px left and right, 47 above and 90 below. Apply margins of that order to a +1600x1112 window and the PNG lands near 1738x1249 — a ratio of 1.39, which +trips the script's own aspect check and exits 1. The aspect gate and the resize +target disagree by construction. + +Beyond that bug, the path is manual by design and says so: *"What you have to do +by hand, because it is a design act and not a crop."* It needs a human on a +Retina display to put the UI in the right state and click the window. +`screencapture -w` blocks on that click, and `osascript`/System Events is +permission-refused in an assistant session, so `--resize` cannot run either. +Every refresh of the figures costs a human sitting down with the app, which is +precisely why they went thirty days and 112 commits without one. + +## The decision: render the real frontend headlessly, composite the chrome + +The app sets `"titleBarStyle": "Overlay"` and `"hiddenTitle": true`. That is the +fact the whole design rests on: **the app draws its entire titlebar itself, in +HTML and CSS.** macOS contributes exactly three traffic-light circles and a drop +shadow. Everything else in those three figures — the repository name, the branch +chip, Refresh/Fetch/Pull/Push, the tab strip, the activity bar, the commit +table, the diff pane and its minimap, the status bar — is web content that a +browser can render. + +So: render `src/` in headless Chrome at device scale factor 2, and composite the +drop shadow and the three traffic lights afterwards. + +### Why not the two alternatives + +**The real binary over WebDriver on macOS** would be ideal and does not exist: +`tauri-driver` supports Linux (WebKitWebDriver) and Windows (msedgedriver), not +macOS. There is no headless route to the real WKWebView. + +**The real binary in the Docker e2e stack** is reachable — the suite already +drives it with a real Rust backend over real temporary repositories, which would +mean zero mock drift and real `git` data. It is rejected on fidelity: it renders +in WebKitGTK on Linux with Linux font stacks, and the site frames these figures +in macOS window chrome. Pasting a macOS titlebar onto a Linux rendering produces +a picture of a product that does not exist. + +Headless Chrome on macOS keeps the system font stack and the real stylesheet. +Its text rasterisation is not bit-identical to WKWebView's, which is an accepted +cost, agreed explicitly: the difference is subpixel, and the figures are viewed +at 1040 CSS px. + +### The mock surface is one function, not 167 + +`src/lib/tauri.ts` exports 167 wrappers across 1983 lines, and the convention +that nothing may call `invoke` directly is load-bearing here: all 167 funnel +through one private `invoke(cmd, args)` at line 110. Mocking the backend is +therefore mocking a `cmd -> fixture` map, not a wrapper-by-wrapper reimplementation. + +The vitest suite already does this. `src/test/invokeMock.ts` is a 41-line +registry — `mockInvoke(cmd, handler)` — that **throws** on an unregistered +command. That throw is the design's best feature: it converts fixture-building +from guesswork into a worklist. Run a scene, read which command it asked for, +add it, repeat, until the screen paints. + +The rig does not import the vitest mocks (they are wired by `vi.mock` in +`setup.ts`, which only exists under vitest). It reuses their *shape* through +Vite `resolve.alias`, which is the build-time equivalent. + +Beyond `@tauri-apps/api/core`, `src/` imports `api/window` (11 sites), +`plugin-log` (10), `api/event` (8), `plugin-dialog` (7), `api/webviewWindow` +(4), `plugin-os` and `api/dpi` (1 each). All are aliased to shims; most are +inert stubs, since a still figure neither listens for an event nor opens a +dialog. + +### Layout + +``` +site/scripts/shoot/ + vite.config.ts aliases every @tauri-apps/* entry to a shim + shim/core.ts invoke() over the cmd -> fixture map; throws on a miss + shim/{event,window,webviewWindow,dpi,log,dialog,os}.ts + fixtures/showcase.ts the pgit-showcase data, typed against src/lib/types.ts + scenes/{history,commit,welcome}.ts per-figure state, route and clock + entry.tsx mounts the real app with a scene applied + shoot.mjs Chrome at DPR 2 -> sharp composite -> screenshots/*.png +``` + +`fixtures/showcase.ts` importing the real types from `src/lib/types.ts` is what +contains the drift risk that the hand-built `AppShowcase.astro` replica died of: +a backend shape change breaks `tsc`, loudly, instead of quietly producing a +wrong picture. The e2e typecheck gate pattern (`tsc -p /tsconfig.json +--noEmit`) applies here too. + +### Geometry, and why it is exact + +The rig reproduces the established composition rather than inventing one. +Measured from `history-dark.png`: window 1462x975 in a 1600x1112 canvas. +Doubled, the rig renders a **2924x1950** viewport and composites into a +**3200x2224** canvas, with shadow margins of 138 left and right, 94 above and +180 below. That is 2x `RENDER_W` with room to spare, so `screenshots.mjs` emits +the `@2x` variant, and it holds the 1600/1112 aspect ratio that both +`screenshots.mjs` and `Screenshot.astro` hardcode — so neither file changes, and +the reserved layout box on the page does not move. + +The composite is two operations in sharp: a rounded-corner mask with a blurred +black shadow beneath, and three circles at the traffic-light positions. Their +coordinates come from the existing master, so the result sits where the eye +already expects it. + +### Determinism + +Three things are pinned, because a figure that changes when nothing changed is a +figure nobody trusts: + +- **The clock.** Relative ages ("1mo ago") are computed against a frozen `Date` + in the scene, not the calendar. This is also what fixes the demo repository's + drift toward "2mo ago" without touching the repository. +- **Text size and Spacing.** #459's presets drive `--row-scale` and `--row-step`; + scenes set them explicitly rather than inheriting whatever `localStorage` + holds. +- **The theme.** Dark, with the accent pinned — #455 added palette shuffling. + +### What runs when + +`pnpm shoot` regenerates the masters on demand, locally. Masters and encoded +WebP stay committed, exactly as `pnpm og` and `pnpm screenshots` already work, +so `astro build` keeps needing no image pipeline and CI installs no Chrome. No +CI drift check: font and Chrome-version differences across runners are a flake +surface, and the composition of a marketing figure stays a deliberate act. + +The rig replaces `scripts/capture.mjs`, which is removed — leaving a broken +manual path beside a working automated one invites someone to use it. +`screenshots.mjs` and `Screenshot.astro` are untouched. + +## Scope + +Three figures, re-shot at the same names and the same composition: +`history-dark`, `commit-dark`, `welcome-dark`. Replacing them in place keeps +every call site and layout decision valid. Their **alt text changes** where the +UI did — it is unusually load-bearing on this site (the only images carrying +product information) and currently describes a retired icon set and a stale +branch list. + +Out of scope: new figures for surfaces shipped since August (settings, theme +editor, branch folders), light-theme variants, and any change to how the site +lays figures out. + +## How it is verified + +- `pnpm exec tsc -p site/scripts/shoot/tsconfig.json --noEmit` — fixtures still + match `src/lib/types.ts`. +- `pnpm shoot` produces three 3200x2224 masters; `pnpm screenshots` then emits + both variants per figure with no 1x warning, which is the machine-checkable + form of "crisp". +- The rendered figures are read back and compared against the current app + surface for the specific drift this spec names: lucide icons, the activity bar + meeting the bottom edge, the tag pill's icon. +- `pnpm test` and `pnpm vite build` in `site/`. diff --git a/site/README.md b/site/README.md index 4fd288f2..4d59b0d7 100644 --- a/site/README.md +++ b/site/README.md @@ -12,7 +12,7 @@ pnpm dev # http://localhost:4321 pnpm build # output -> dist/ pnpm preview # serve the build locally pnpm og # regenerate public/og.png from scripts/og-image.html -pnpm capture # capture a 2x app-window master into screenshots/ (macOS) +pnpm shoot # re-render screenshots/*.png from the real app (macOS) pnpm screenshots # re-encode public/screenshots/*.webp from screenshots/*.png pnpm installers # copy ../scripts/install-pgit.* into public/ (dev + build do this) ``` @@ -104,26 +104,45 @@ detail was never in the file. The masters have to carry 2x the rendered width. the light theme reads as a photograph of an app; it gets a faint accent halo behind it so it has something to sit on. -### Replacing a capture +### Re-rendering the figures ```bash -pnpm capture --resize # size the running app window to 1600x1112 pt -pnpm capture history-dark # then click the window; verifies it came out 2x +pnpm shoot # all three, into screenshots/*.png +pnpm shoot history # just one +pnpm shoot history --report # what fixtures that scene is still missing pnpm screenshots # encode both variants ``` -`pnpm capture` is macOS-only (only `screencapture -o` returns a window with its -shadow on transparency) and **rejects a capture that is not 2x** — an external -1x monitor silently produces a 1x master that looks fine in Preview and blurry -on the site, which is the whole failure this guards. `--resize` drives the -window through System Events and needs Accessibility permission for your -terminal, once; size the window by hand otherwise. - -What the script cannot do for you: put the UI in the state the figure is meant -to show, in dark theme, with nothing personal on screen. Keep the existing -figure names when replacing one — the `alt` text in `index.astro` / -`features.astro` describes what is in that specific window, and a different -window makes it wrong. +`pnpm shoot` renders the **real** app — the components in `../src`, the real +stylesheet — in headless Chrome at device scale factor 2, then composites the +drop shadow and the three traffic lights. That is the whole native half of a +figure: the app sets `titleBarStyle: "Overlay"`, so it draws its own titlebar +and macOS contributes nothing else. + +There is no human in the loop, which is the point. The old `pnpm capture` needed +someone on a Retina display to size a window and click it, and the figures +consequently went thirty days and 112 `src/` commits out of date — every icon in +them belonged to a set the app had stopped shipping. + +Each figure is a **scene** in `scripts/shoot/scenes/`: the `localStorage` it +starts from, a frozen clock, and a `cmd -> fixture` map answering the backend. +That map is the only place a figure's content is decided. The fake `invoke` +**throws** on a command no scene answers, so `--report` renders the list of what +is missing rather than leaving you to guess; a scene is done when it reports +`MISSING (0)`. + +Fixtures are typed against `../src/lib/types.ts` on purpose. When the backend +changes shape, `pnpm exec tsc -p scripts/shoot/tsconfig.json --noEmit` fails +instead of the rig quietly rendering a picture of a product that no longer +exists — which is how the hand-built `AppShowcase` replica died. + +Keep the existing figure names when changing one: the `alt` text in +`index.astro` / `features.astro` describes what is in that specific window, and +a different window makes it wrong. + +Not run in CI. The masters and the encoded WebP are committed, so `astro build` +needs no image pipeline and the deploy installs no Chrome — the same arrangement +`pnpm og` already uses. ## Search-engine setup (manual, one-time) diff --git a/site/package.json b/site/package.json index 2ae3d282..8d65c655 100644 --- a/site/package.json +++ b/site/package.json @@ -10,7 +10,7 @@ "installers": "node scripts/copy-installers.mjs", "og": "node scripts/og-image.mjs", "screenshots": "node scripts/screenshots.mjs", - "capture": "node scripts/capture.mjs" + "shoot": "node scripts/shoot/shoot.mjs" }, "dependencies": { "@astrojs/sitemap": "^3.7.4", diff --git a/site/public/screenshots/commit-dark.webp b/site/public/screenshots/commit-dark.webp index 189fe266..8d55d2c9 100644 Binary files a/site/public/screenshots/commit-dark.webp and b/site/public/screenshots/commit-dark.webp differ diff --git a/site/public/screenshots/commit-dark@2x.webp b/site/public/screenshots/commit-dark@2x.webp new file mode 100644 index 00000000..c036ffbc Binary files /dev/null and b/site/public/screenshots/commit-dark@2x.webp differ diff --git a/site/public/screenshots/history-dark.webp b/site/public/screenshots/history-dark.webp index f2dc300b..141f2307 100644 Binary files a/site/public/screenshots/history-dark.webp and b/site/public/screenshots/history-dark.webp differ diff --git a/site/public/screenshots/history-dark@2x.webp b/site/public/screenshots/history-dark@2x.webp new file mode 100644 index 00000000..f05f59c9 Binary files /dev/null and b/site/public/screenshots/history-dark@2x.webp differ diff --git a/site/public/screenshots/welcome-dark.webp b/site/public/screenshots/welcome-dark.webp index c6f15ff6..9dcfd60c 100644 Binary files a/site/public/screenshots/welcome-dark.webp and b/site/public/screenshots/welcome-dark.webp differ diff --git a/site/public/screenshots/welcome-dark@2x.webp b/site/public/screenshots/welcome-dark@2x.webp new file mode 100644 index 00000000..8cb84dfe Binary files /dev/null and b/site/public/screenshots/welcome-dark@2x.webp differ diff --git a/site/screenshots/commit-dark.png b/site/screenshots/commit-dark.png index 4c8b4b30..7c7f2ce8 100644 Binary files a/site/screenshots/commit-dark.png and b/site/screenshots/commit-dark.png differ diff --git a/site/screenshots/history-dark.png b/site/screenshots/history-dark.png index c69578e9..4e909f95 100644 Binary files a/site/screenshots/history-dark.png and b/site/screenshots/history-dark.png differ diff --git a/site/screenshots/welcome-dark.png b/site/screenshots/welcome-dark.png index 8601cfb0..d7fc1c9a 100644 Binary files a/site/screenshots/welcome-dark.png and b/site/screenshots/welcome-dark.png differ diff --git a/site/scripts/capture.mjs b/site/scripts/capture.mjs deleted file mode 100644 index 6c7cc7e2..00000000 --- a/site/scripts/capture.mjs +++ /dev/null @@ -1,179 +0,0 @@ -// Captures a platypusgit window into screenshots/.png at 2x, ready for -// `pnpm screenshots`. macOS only — the shipped figures carry macOS window -// chrome, and this is the only platform whose screencapture(1) hands back the -// window with its drop shadow over a TRANSPARENT margin. -// -// pnpm capture history-dark # then click the app window -// -// Why this script exists rather than a line in the README. The old README said -// "capture at 1600x1112", which is a 1x capture of a ~1460pt window — and the -// site lays those figures out at 1040 CSS px, so every Retina visitor got a -// 1.3x upscale of 1x text. Nothing downstream can recover from that: the pixels -// were never in the file. The size of a capture is not a detail to leave to -// whoever is holding the mouse, so it is checked here. -// -// What you have to do by hand, because it is a design act and not a crop: -// - Run the app on a Retina display (backing scale 2). An external 1x monitor -// silently produces a 1x capture; this script rejects it. -// - Size the window so it is REND_PT points wide. `pnpm capture --resize` -// does it for you via System Events. -// - Put the UI in the state the figure is meant to show, dark theme, and make -// sure nothing personal is on screen — these end up on the landing page. -import { execFileSync } from 'node:child_process'; -import { existsSync, statSync, mkdirSync, readdirSync } from 'node:fs'; -import { dirname, resolve, join } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -const here = dirname(fileURLToPath(import.meta.url)); -const out = resolve(here, '..', 'screenshots'); - -// The window width, in POINTS, that the masters are captured at. At backing -// scale 2 that is a 3200px master, which is 2x the 1040 CSS px the figures are -// laid out at — so a Retina visitor paints it 1:1. -const REND_PT = 1600; -const RATIO = 1600 / 1112; -const WINDOW_PT = { width: REND_PT, height: Math.round(REND_PT / RATIO) }; - -if (process.platform !== 'darwin') { - console.error( - 'macOS only: the shipped figures carry macOS window chrome, and only\n' + - 'screencapture(1) returns a window with its shadow on transparency.\n' + - 'Capture on a Mac, or hand-produce a 3200x2224 PNG in site/screenshots/.', - ); - process.exit(1); -} - -const argv = process.argv.slice(2); -const resizeOnly = argv.includes('--resize'); -const name = argv.find((a) => !a.startsWith('-')); - -if (!resizeOnly && !name) { - console.error( - 'Usage: pnpm capture e.g. pnpm capture history-dark\n' + - ' pnpm capture --resize size the app window and exit\n\n' + - 'Existing figures (replacing one keeps its alt text valid):\n' + - (existsSync(out) - ? readdirSync(out) - .filter((f) => f.endsWith('.png')) - .map((f) => ` ${f.replace(/\.png$/, '')}`) - .join('\n') - : ' (none)'), - ); - process.exit(1); -} - -// Resize the app window to exactly WINDOW_PT via System Events. Needs -// Accessibility permission for the terminal, once; the error says so. -function resizeWindow() { - const script = ` - tell application "System Events" - if not (exists process "PlatypusGit") then error "PlatypusGit is not running" - tell process "PlatypusGit" - set frontmost to true - set position of window 1 to {60, 60} - set size of window 1 to {${WINDOW_PT.width}, ${WINDOW_PT.height}} - return (size of window 1) as string - end tell - end tell`; - try { - const got = execFileSync('osascript', ['-e', script], { encoding: 'utf8' }).trim(); - console.log(`window sized to ${got.replace(/,\s*/, 'x')} pt (wanted ${WINDOW_PT.width}x${WINDOW_PT.height})`); - } catch (e) { - const msg = String(e.stderr || e.message); - if (/not allowed assistive|osascript is not allowed/i.test(msg)) { - console.error( - 'System Events was refused. Grant your terminal Accessibility access:\n' + - ' System Settings -> Privacy & Security -> Accessibility\n' + - `Or size the window to ${WINDOW_PT.width}x${WINDOW_PT.height} pt by hand and run without --resize.`, - ); - } else if (/not running/.test(msg)) { - console.error('PlatypusGit is not running. Launch it first (pgit . / pnpm tauri dev).'); - } else { - console.error(msg.trim()); - } - process.exit(1); - } -} - -if (resizeOnly) { - resizeWindow(); - process.exit(0); -} - -mkdirSync(out, { recursive: true }); -const to = join(out, `${name}.png`); -const replacing = existsSync(to); - -console.log( - `${replacing ? 'Replacing' : 'Creating'} ${name}.png\n\n` + - ` 1. Put the app in the state this figure should show (dark theme).\n` + - ` 2. Window must be ${WINDOW_PT.width}x${WINDOW_PT.height} pt on a RETINA display.\n` + - ` Run \`pnpm capture --resize\` first if you have not.\n` + - ` 3. Click the app window when the crosshair appears.\n`, -); - -// -o drops the window's shadow from the ALPHA but keeps the transparent margin -// around it, which is what Screenshot.astro relies on (it draws no frame). -// -w window mode, -a exclude other windows, -r no display-profile conversion. -try { - execFileSync('screencapture', ['-w', '-o', '-a', '-r', to], { stdio: 'inherit' }); -} catch { - console.error('screencapture failed or was cancelled.'); - process.exit(1); -} - -if (!existsSync(to) || statSync(to).size === 0) { - console.error('Nothing captured (cancelled?). Nothing written.'); - process.exit(1); -} - -// Verify we actually got 2x. A capture from a 1x external display looks fine -// in Preview and is useless on the site, so this is the whole point. -async function loadSharp() { - try { - return (await import('sharp')).default; - } catch {} - const pnpmDir = resolve(here, '..', 'node_modules', '.pnpm'); - if (existsSync(pnpmDir)) { - const dir = readdirSync(pnpmDir).find((d) => d.startsWith('sharp@')); - if (dir) { - const entry = join(pnpmDir, dir, 'node_modules', 'sharp', 'lib', 'index.js'); - if (existsSync(entry)) return (await import(pathToFileURL(entry).href)).default; - } - } - return null; -} - -const sharp = await loadSharp(); -if (!sharp) { - console.log(`Wrote ${to} (no sharp — dimensions unverified). Run \`pnpm screenshots\`.`); - process.exit(0); -} - -const { width, height } = await sharp(to).metadata(); -const want = REND_PT * 2; -console.log(`\nWrote ${to} — ${width}x${height}`); - -if (width < want) { - const scale = (width / REND_PT).toFixed(2); - console.error( - `\nTOO SMALL: ${width}px wide, need >= ${want}px.\n` + - `That is a ~${scale}x capture. Either the window was not ${REND_PT}pt wide,\n` + - `or the display is not Retina (an external 1x monitor does this). The file\n` + - `is kept so you can look at it, but re-encoding it will NOT make the site\n` + - `sharp — recapture on the built-in display.`, - ); - process.exit(1); -} - -const ratio = (width / height).toFixed(4); -if (ratio !== RATIO.toFixed(4)) { - console.warn( - `\nAspect ratio ${ratio} is not ${RATIO.toFixed(4)} — the window was not\n` + - `${WINDOW_PT.width}x${WINDOW_PT.height} pt. \`pnpm screenshots\` will refuse this until it\n` + - `matches the other masters. Re-run with \`pnpm capture --resize\` first.`, - ); - process.exit(1); -} - -console.log(`Good — a true ${(width / REND_PT).toFixed(0)}x capture. Now: pnpm screenshots`); diff --git a/site/scripts/screenshots.mjs b/site/scripts/screenshots.mjs index 2fa0b8c9..e2c5f586 100644 --- a/site/scripts/screenshots.mjs +++ b/site/scripts/screenshots.mjs @@ -19,7 +19,7 @@ // This is why MASTERS MUST BE 2x CAPTURES (3200x2224). A 1x master cannot be // made sharp here; upscaling it to 2080 would only add bytes. When a master is // too small the 2x variant is SKIPPED and a warning is printed, rather than -// shipping an upscale that pretends to be detail — see `pnpm capture`. +// shipping an upscale that pretends to be detail — see `pnpm shoot`. // // Why WebP at q85 and not the PNG: 1741 KB -> 345 KB for the three at the old // single 1600px variant, with no visible difference at 1:1 on the smallest text @@ -28,6 +28,7 @@ // captures are a window over a TRANSPARENT margin with a baked drop shadow, and // that shadow is what lets one dark asset sit on the light theme. import { readdirSync, existsSync, statSync, unlinkSync } from 'node:fs'; +import { loadSharp } from './sharp.mjs'; import { dirname, resolve, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -47,32 +48,6 @@ const RENDER_W = 1040; const RATIO_W = 1600; const RATIO_H = 1112; -// sharp is a dev-machine tool here, not a dependency. It is already on disk -// after `pnpm install`, because astro declares it as an OPTIONAL dependency -// for its own image service — but pnpm's isolated node_modules does not hoist -// it, so a bare `import 'sharp'` cannot see it from this script. Look in both -// places rather than making the site depend on it: adding it to package.json -// would put a native binary in the deploy install for images that are already -// encoded. -async function loadSharp() { - try { - return (await import('sharp')).default; - } catch {} - const pnpmDir = resolve(here, '..', 'node_modules', '.pnpm'); - if (existsSync(pnpmDir)) { - const dir = readdirSync(pnpmDir).find((d) => d.startsWith('sharp@')); - if (dir) { - const entry = join(pnpmDir, dir, 'node_modules', 'sharp', 'lib', 'index.js'); - if (existsSync(entry)) return (await import(pathToFileURL(entry).href)).default; - } - } - console.error( - 'No sharp found. Run `pnpm install` in site/ (astro brings sharp in as an\n' + - 'optional dependency), or install it yourself: `pnpm add -D sharp`.', - ); - process.exit(1); -} - const sharp = await loadSharp(); const files = readdirSync(src) .filter((f) => f.endsWith('.png')) @@ -115,7 +90,7 @@ for (const file of files) { if (existsSync(stale)) unlinkSync(stale); console.warn( ` ! ${file} is ${master.width}px wide — under ${RENDER_W * 2}px, so NO 2x variant.\n` + - ` Retina displays will upscale and the text will look soft. Recapture at 2x: pnpm capture`, + ` Retina displays will upscale and the text will look soft. Re-shoot at 2x: pnpm shoot`, ); } @@ -142,6 +117,6 @@ if (lowRes > 0) { console.log( `\n${lowRes} of ${files.length} master(s) are 1x. Those figures CANNOT be made\n` + `sharp by re-encoding — the detail is not in the file. Recapture at 2x\n` + - `(${RATIO_W * 2}x${RATIO_H * 2}) with \`pnpm capture\`, then re-run this.`, + `(${RATIO_W * 2}x${RATIO_H * 2}) with \`pnpm shoot\`, then re-run this.`, ); } diff --git a/site/scripts/sharp.mjs b/site/scripts/sharp.mjs new file mode 100644 index 00000000..367ef933 --- /dev/null +++ b/site/scripts/sharp.mjs @@ -0,0 +1,59 @@ +// Finding sharp, which is not a dependency of this site. +// +// astro declares sharp as an OPTIONAL dependency for its own image service, so +// it is already on disk after `pnpm install` — but pnpm's isolated node_modules +// does not hoist it, so a bare `import "sharp"` cannot see it from a script. +// Adding it to package.json would put a native binary in the deploy install for +// images that are already encoded and committed, so instead: look for it. +// +// One module rather than a copy per script, because the copies drifted. Both +// looked only at `lib/index.js`, and **sharp 0.35 moved its entry to +// `dist/index.mjs`** — so `pnpm screenshots` failed with "No sharp found" on +// any machine that resolved 0.35, which reads like a missing install rather +// than a moved file. +import { readdirSync, existsSync } from 'node:fs'; +import { resolve, join, dirname } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); + +// Newest first, and both entry spellings. +const ENTRIES = [ + ['dist', 'index.mjs'], // sharp >= 0.35 + ['lib', 'index.js'], // sharp <= 0.34 +]; + +export async function loadSharp() { + try { + return (await import('sharp')).default; + } catch {} + + // site/scripts -> site, and the repo root: a worktree may have installed + // either, and the rig runs from both. + const roots = [ + resolve(here, '..', 'node_modules', '.pnpm'), + resolve(here, '..', '..', 'node_modules', '.pnpm'), + ]; + + for (const pnpmDir of roots) { + if (!existsSync(pnpmDir)) continue; + // A pnpm directory name carries its peer suffix + // ("sharp@0.35.4_@types+node@26.5.1"), so match the prefix, and prefer the + // highest version when several are installed. + const dirs = readdirSync(pnpmDir) + .filter((d) => d.startsWith('sharp@')) + .sort() + .reverse(); + for (const dir of dirs) { + for (const rel of ENTRIES) { + const entry = join(pnpmDir, dir, 'node_modules', 'sharp', ...rel); + if (existsSync(entry)) return (await import(pathToFileURL(entry).href)).default; + } + } + } + + throw new Error( + 'No sharp found. Run `pnpm install` in site/ (astro brings sharp in as an\n' + + 'optional dependency), or install it yourself: `pnpm add -D sharp`.', + ); +} diff --git a/site/scripts/shoot/chrome.sh b/site/scripts/shoot/chrome.sh new file mode 100755 index 00000000..47e1e318 --- /dev/null +++ b/site/scripts/shoot/chrome.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# A quoted path is refused in a worktree-isolated assistant session ("a command +# whose name is computed at runtime"), and symlinking the binary breaks it -- +# Chrome resolves `Google Chrome Framework` relative to the symlink and dies in +# dlopen. A wrapper that execs the real path is what works. +exec "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" "$@" diff --git a/site/scripts/shoot/composite.mjs b/site/scripts/shoot/composite.mjs new file mode 100644 index 00000000..4ea2f3d1 --- /dev/null +++ b/site/scripts/shoot/composite.mjs @@ -0,0 +1,108 @@ +// Puts the native macOS pixels back on a browser render. +// +// The app sets titleBarStyle "Overlay" + hiddenTitle, so it draws its ENTIRE +// titlebar in HTML. macOS contributes exactly two things: a drop shadow and +// three traffic lights. Chrome renders everything else, so this file is the +// whole native half of a figure. +// +// The geometry is not invented. It is 2x the shipped 2026-08-18 master, +// measured by alpha bounding box: a 1462x975 window in a 1600x1112 canvas, +// margins 69 left/right, 47 top, 90 bottom. Reproducing it exactly is what lets +// these figures drop into the site without touching Screenshot.astro, which +// hardcodes the 1600/1112 aspect to reserve the layout box before the bytes +// arrive. +import { loadSharp } from '../sharp.mjs'; + +export { loadSharp }; + +export const GEOM = { + canvas: { w: 3200, h: 2224 }, + window: { w: 2924, h: 1950 }, + margin: { left: 138, top: 94, right: 138, bottom: 180 }, + // macOS window corner radius is 10pt; at 2x that is 20px. + radius: 20, + // Traffic lights, measured off the master and doubled. macOS spaces them 20pt + // apart at 6pt radius, inset 20pt from the left and 20pt down. + lights: { + cy: 40, + r: 12, + cx: [40, 80, 120], + fill: ['#ff5f57', '#febc2e', '#28c840'], + }, +}; + +/** + * @param {Buffer} bodyPng the browser render, exactly GEOM.window + * @param {string} outPath where the 3200x2224 master goes + */ +export async function composite(bodyPng, outPath) { + const sharp = await loadSharp(); + + const meta = await sharp(bodyPng).metadata(); + if (meta.width !== GEOM.window.w || meta.height !== GEOM.window.h) { + throw new Error( + `body is ${meta.width}x${meta.height}, need exactly ` + + `${GEOM.window.w}x${GEOM.window.h}. Chrome's --window-size is in CSS px ` + + `and --force-device-scale-factor doubles it, so pass ` + + `${GEOM.window.w / 2},${GEOM.window.h / 2}.`, + ); + } + + const { w: W, h: H } = GEOM.canvas; + const { w: winW, h: winH } = GEOM.window; + const { left, top } = GEOM.margin; + const R = GEOM.radius; + + // Round the body's corners: macOS clips the webview to the window shape, and + // a square corner under a rounded shadow is the tell that a figure was + // assembled rather than captured. + const cornerMask = Buffer.from( + `` + + ``, + ); + const roundedBody = await sharp(bodyPng) + .composite([{ input: cornerMask, blend: 'dest-in' }]) + .png() + .toBuffer(); + + // The drop shadow. macOS draws a soft, downward-biased shadow; the master's + // asymmetric margins (94 top vs 180 bottom) are that bias. Offsetting the + // shadow rect down by half the difference reproduces it. + const shadowDy = (GEOM.margin.bottom - GEOM.margin.top) / 2; + const shadowSvg = Buffer.from( + `` + + `` + + `` + + ``, + ); + + // The three traffic lights, positioned relative to the window. + const { cy, r, cx, fill } = GEOM.lights; + const lightsSvg = Buffer.from( + `` + + cx + .map( + (x, i) => + ``, + ) + .join('') + + ``, + ); + + await sharp({ + create: { + width: W, + height: H, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }) + .composite([ + { input: shadowSvg, top: 0, left: 0 }, + { input: roundedBody, top, left }, + { input: lightsSvg, top: 0, left: 0 }, + ]) + .png() + .toFile(outPath); +} diff --git a/site/scripts/shoot/entry.tsx b/site/scripts/shoot/entry.tsx new file mode 100644 index 00000000..382aa6ae --- /dev/null +++ b/site/scripts/shoot/entry.tsx @@ -0,0 +1,104 @@ +// The rig's browser entry. Picks a scene from `?scene=`, seeds its world, and +// mounts the REAL app — no replica, which is the whole point: a hand-built +// stand-in drifts from the app on every UI change with nothing to catch it. +import { registerScene, misses, hits, type Scene } from "./shim/core"; +import { scenes } from "./scenes"; + +const name = new URLSearchParams(location.search).get("scene") ?? "welcome"; +const scene: Scene | undefined = scenes[name]; +if (!scene) { + throw new Error( + `[shoot] unknown scene "${name}". Known: ${Object.keys(scenes).join(", ")}`, + ); +} + +registerScene(scene); + +// Storage must be written BEFORE any store module is imported: the Zustand +// stores read localStorage at module scope, so an import that lands first sees +// an empty world and the figure renders the default state. The dynamic +// import() below is what guarantees the order. +localStorage.clear(); +for (const [k, v] of Object.entries(scene.storage)) localStorage.setItem(k, v); + +// Freeze the clock. Relative ages ("1mo ago") are computed against this, so a +// figure shot today and one shot next month are identical. +const FIXED = new Date(scene.now).getTime(); +const RealDate = Date; +// `ConstructorParameters` collapses to the one-argument overload, +// so a typed rest parameter cannot express "no arguments OR any of the real +// overloads". `unknown[]` plus one cast is the honest way to say it. +class FrozenDate extends RealDate { + constructor(...args: unknown[]) { + if (args.length === 0) super(FIXED); + else super(...(args as [number])); + } + static now(): number { + return FIXED; + } +} +globalThis.Date = FrozenDate as unknown as DateConstructor; + +const [React, ReactDOM, App] = await Promise.all([ + import("react"), + import("react-dom/client"), + import("@/App"), + import("@/index.css"), +]); + +// Deliberately NOT wrapped in React.StrictMode, and without RevealOnFirstPaint +// or PGErrorBoundary: StrictMode double-invokes effects (doubling fixture calls +// for no benefit), and the reveal is a no-op outside a real Tauri window. +ReactDOM.default + .createRoot(document.getElementById("root") as HTMLElement) + .render(React.default.createElement(App.default)); + +// Drive the app into the state this figure shows. Runs after the first render, +// and a failure is LOUD: a scene that silently did not navigate would shoot the +// History screen under the commit figure's name, which is exactly the kind of +// wrong that survives review. +if (scene.afterMount) { + void scene.afterMount().catch((err) => { + console.error("[shoot] afterMount failed", err); + const el = document.createElement("pre"); + el.style.cssText = + "position:fixed;inset:0;z-index:99999;margin:0;padding:24px;" + + "background:#3b0d0d;color:#fff;font:16px/1.5 ui-monospace,Menlo,monospace"; + el.textContent = `[shoot] scene "${scene.name}" afterMount failed:\n\n${String(err)}`; + document.body.appendChild(el); + }); +} + +// The fixture worklist, on screen. +// +// `?report=1` renders every command this scene was asked for and did not have, +// over the top of whatever did render. One run then names every fixture the +// screen wants, instead of surfacing them one reload at a time. The shoot +// driver never passes it, so a real figure is never contaminated. +// +// It is also the ONLY channel out of the page that works here: --dump-dom fires +// at the load event, before a module's top-level await resolves. +if (new URLSearchParams(location.search).get("report") === "1") { + setTimeout(() => { + const el = document.createElement("pre"); + el.style.cssText = [ + "position:fixed", + "inset:0", + "z-index:99999", + "margin:0", + "padding:24px", + "overflow:auto", + "background:#0d1013", + "color:#e6e6e6", + "font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace", + "white-space:pre-wrap", + ].join(";"); + el.textContent = + `scene: ${scene.name} -> ${scene.figure}\n\n` + + `MISSING (${misses.length}):\n` + + (misses.length ? misses.map((c) => ` ${c}`).join("\n") : " (none)") + + `\n\nANSWERED (${hits.length}):\n` + + (hits.length ? hits.map((c) => ` ${c}`).join("\n") : " (none)"); + document.body.appendChild(el); + }, 3000); +} diff --git a/site/scripts/shoot/fixtures/showcase.ts b/site/scripts/shoot/fixtures/showcase.ts new file mode 100644 index 00000000..c7609162 --- /dev/null +++ b/site/scripts/shoot/fixtures/showcase.ts @@ -0,0 +1,645 @@ +// The demo repository the site figures are shot against. +// +// Content is transcribed from the shipped 2026-08-18 hero, which is an approved +// composition: the same seven refs, the same four fictional authors, the same +// selected commit and diff. Reproducing it keeps the new figure directly +// comparable to the one it replaces, so a reviewer is judging the UI change and +// nothing else. +// +// EVERYTHING here is typed against src/lib/types.ts on purpose. That is what +// contains the drift risk the hand-built AppShowcase replica died of: when a +// backend shape changes, this file stops compiling instead of quietly rendering +// a figure of a product that no longer exists. +// +// No real person appears. The authors are invented, and the repository is a toy +// expression evaluator that exists only to make a good screenshot. +import type { + BranchInfo, + CommitInfo, + CommitTemplate, + DiffLine, + FileDiff, + FileContent, + FileStatus, + GitIdentity, + HeadInfo, + LogPage, + RefInfo, + RemoteInfo, + RepoHandle, +} from "@/lib/types"; + +export const SHOWCASE_PATH = "/Users/jonas/pgit-showcase"; +export const SHOWCASE_ID = "showcase"; + +export const HANDLE: RepoHandle = { + id: SHOWCASE_ID, + path: SHOWCASE_PATH, + head: "main", +}; + +// 2026-06-26 10:30 +0200, the showcase repository's own HEAD date. Scenes freeze +// the clock a month later, which is what makes the age column read "1mo ago". +const T0 = Math.floor(new Date("2026-06-26T10:30:00+02:00").getTime() / 1000); +const DAY = 86400; + +const ref = (name: string, kind: RefInfo["kind"]): RefInfo => ({ name, kind }); + +/** + * Expand a short oid into a full 40-hex one, deterministically. + * + * Zero-padding is not good enough: the commit-detail pane prints the FULL oid, + * and `335d8fe0000000000000000000000000000000` in the hero announces that the + * figure is fabricated. A tiny seeded PRNG gives hex that reads like a real sha + * and is identical on every run, which the frozen clock's whole point requires. + */ +function fullOid(shortOid: string): string { + let h = 0; + for (const c of shortOid) h = (h * 31 + c.charCodeAt(0)) >>> 0; + let out = shortOid; + while (out.length < 40) { + // xorshift32 — deterministic, and plenty for making plausible hex. + h ^= h << 13; + h >>>= 0; + h ^= h >> 17; + h ^= h << 5; + h >>>= 0; + out += h.toString(16).padStart(8, "0"); + } + return out.slice(0, 40); +} + +/** Short oids of a commit's parents; resolved to full oids after the list is + * built. Omitted means "the next commit in the list", i.e. a linear chain. */ +type Opts = { body?: string; refs?: RefInfo[]; parents?: string[] }; + +function commit( + shortOid: string, + summary: string, + author: string, + email: string, + ageDays: number, + opts: Opts = {}, +): CommitInfo & { _parents?: string[] } { + return { + oid: fullOid(shortOid), + shortOid, + summary, + body: opts.body ?? null, + author, + email, + timestamp: T0 - ageDays * DAY, + parents: [], + refs: opts.refs ?? [], + _parents: opts.parents, + }; +} + +const JONAS = ["Jonas Aasberg", "jonas@example.com"] as const; +const KOFI = ["Kofi Mensah", "kofi@example.com"] as const; +const ANA = ["Ana Ruiz", "ana@example.com"] as const; +const YUKI = ["Yuki Tanaka", "yuki@example.com"] as const; + +/** + * The visible history. The first twelve are what the approved composition + * shows; the rest exist so the list scrolls like a real repository rather than + * ending in dead space. + */ +export const COMMITS: (CommitInfo & { _parents?: string[] })[] = [ + commit("335d8fe", "refactor(evaluator): dispatch through the engine table", ...JONAS, 0, { + refs: [ref("main", "Branch")], + body: + "The evaluator had its own four-case switch, which meant every operator\n" + + "added to the engine also had to be added here or it parsed and then\n" + + "failed at evaluation.\n\n" + + "One dispatcher now, and the engine's own test covers both.", + }), + commit("e3677fd", "docs: start a changelog", ...JONAS, 1, { + parents: ["9dc9843"], + }), + // Two side branches off the merge, which is what gives the graph its two + // short lanes beside main in the approved composition. + commit("e0d61e5", "fix(engine): one divisor check across div, mod and idiv", ...KOFI, 2, { + refs: [ref("fix/div-by-zero-message", "Branch")], + parents: ["9dc9843"], + }), + commit("628b622", "feat(repl): line-at-a-time REPL, work in progress", ...ANA, 3, { + refs: [ref("feat/repl", "Branch"), ref("origin/feat/repl", "Remote")], + parents: ["9dc9843"], + }), + commit("9dc9843", "merge: docs site and parse benchmark", ...JONAS, 4, { + refs: [ref("origin/main", "Remote")], + parents: ["913d3bf", "63f5166"], + }), + // Both topic branches fork from the same commit, so the merge above closes a + // real diamond rather than a line with a label on it. + commit("913d3bf", "docs(site): plan the docs site layout", ...ANA, 5, { + refs: [ref("topic/docs-site", "Branch")], + parents: ["16377ac"], + }), + commit("63f5166", "test(bench): benchmark the parser's worst case", ...ANA, 6, { + refs: [ref("topic/bench", "Branch")], + parents: ["16377ac"], + }), + commit("16377ac", "fix(engine): document that div raises on a zero divisor", ...JONAS, 7), + commit("1a9aa90", "feat(engine): add atan2d for the degrees-first callers", ...KOFI, 9), + commit("166e07c", "docs: write down the grammar", ...YUKI, 34), + commit("037f1da", "feat(scope): add a scope chain with shadowing", ...KOFI, 36), + commit("e279622", "test(parser): pin precedence and the parenthesis override", ...ANA, 38), + // Below the fold — present so the list does not end mid-window. + commit("b41c0aa", "feat(parser): parenthesised sub-expressions", ...YUKI, 41), + commit("7c2e8d5", "fix(lexer): accept a leading dot in a float literal", ...KOFI, 44), + commit("d90a17b", "test(engine): cover every unary operator", ...ANA, 46), + commit("2f6b4e1", "feat(engine): unary minus and logical not", ...JONAS, 49), + commit("aa3f902", "docs: a README worth reading", ...YUKI, 52), + commit("5e81c34", "refactor(lexer): one token enum, not three", ...KOFI, 55), + commit("c17d5b8", "feat(parser): precedence climbing", ...JONAS, 58), + commit("90ab2e7", "test(lexer): pin the number grammar", ...ANA, 61), + commit("41f7c60", "feat(lexer): numbers, identifiers and operators", ...KOFI, 64), + commit("8b5e2a9", "chore: set up the test harness", ...YUKI, 67), + commit("6d4c1f3", "chore: initial commit", ...JONAS, 70), +]; + +// Resolve each commit's parents. A commit that named none inherits the next one +// in the list — the list is newest-first, so that is an ordinary linear chain. +// The last commit keeps none, which is what makes it the root. +// +// This runs once, at module load, rather than being written out by hand on every +// entry: a parent list spelled in full is a parent list that goes stale the +// first time a commit is inserted, and a wrong one is not a crash — it is a +// subtly wrong graph in a marketing figure, and `(root) → ` in the diff +// header where a real parent belongs. +for (let i = 0; i < COMMITS.length; i++) { + const c = COMMITS[i] as CommitInfo & { _parents?: string[] }; + const named = c._parents; + c.parents = named + ? named.map(fullOid) + : i + 1 < COMMITS.length + ? [COMMITS[i + 1].oid] + : []; + delete c._parents; +} + +export const LOG_PAGE: LogPage = { commits: COMMITS, nextCursor: null }; + +/** + * HEAD. Derived from the top commit rather than written out, so the two cannot + * disagree — and they must not: the `HEAD→main` pill on the first row is drawn + * by matching this oid against the log, so a stale literal here silently costs + * the hero its most recognisable label. + */ +export const HEAD: HeadInfo = { + branch: "main", + headOid: COMMITS[0].oid, +}; + +/** + * The working tree: one staged file and three unstaged, which is the `4 changed` + * both figures' status bars report and the STAGED 1 / CHANGES 3 split the + * approved commit figure shows. + * + * Note this is deliberately a different file set from the history figure's diff + * pane, and that is not an inconsistency: the commit screen shows the WORKING + * TREE, while the history detail shows what one past commit changed. + */ +export const STATUS: FileStatus[] = [ + { + // Staged: index differs from HEAD, worktree matches the index. + path: "src/parser.ts", + embedded: false, + worktree: { kind: "Unmodified" }, + index: { kind: "Modified" }, + additions: 6, + deletions: 0, + stagedAdditions: 6, + stagedDeletions: 0, + }, + { + path: "NOTES.md", + embedded: false, + worktree: { kind: "Untracked" }, + index: { kind: "Unmodified" }, + additions: 6, + deletions: 0, + }, + { + path: "src/engine.ts", + embedded: false, + worktree: { kind: "Modified" }, + index: { kind: "Unmodified" }, + additions: 5, + deletions: 5, + }, + { + path: "tests/lexer.test.ts", + embedded: false, + worktree: { kind: "Modified" }, + index: { kind: "Unmodified" }, + additions: 7, + deletions: 6, + }, +]; + +/** + * The branch list. `main` carries `ahead: 2` because that is what puts the + * `main ↑2` chip in the titlebar and `↑2 ↓0` in the status bar — both visible + * in the approved composition, and both read from here rather than from the log. + */ +export const BRANCHES: BranchInfo[] = [ + { + name: "main", + isHead: true, + isRemote: false, + upstream: "origin/main", + ahead: 2, + behind: 0, + tip: COMMITS[0].oid, + tipTime: COMMITS[0].timestamp, + isDefault: true, + }, + { + name: "fix/div-by-zero-message", + isHead: false, + isRemote: false, + upstream: null, + ahead: 0, + behind: 0, + tip: COMMITS[2].oid, + tipTime: COMMITS[2].timestamp, + isDefault: false, + }, + { + name: "feat/repl", + isHead: false, + isRemote: false, + upstream: "origin/feat/repl", + ahead: 0, + behind: 0, + tip: COMMITS[3].oid, + tipTime: COMMITS[3].timestamp, + isDefault: false, + }, + { + name: "topic/docs-site", + isHead: false, + isRemote: false, + upstream: null, + ahead: 0, + behind: 0, + tip: COMMITS[5].oid, + tipTime: COMMITS[5].timestamp, + isDefault: false, + }, + { + name: "topic/bench", + isHead: false, + isRemote: false, + upstream: null, + ahead: 0, + behind: 0, + tip: COMMITS[6].oid, + tipTime: COMMITS[6].timestamp, + isDefault: false, + }, + { + name: "origin/main", + isHead: false, + isRemote: true, + upstream: null, + ahead: 0, + behind: 0, + tip: COMMITS[4].oid, + tipTime: COMMITS[4].timestamp, + isDefault: true, + }, + { + name: "origin/feat/repl", + isHead: false, + isRemote: true, + upstream: null, + ahead: 0, + behind: 0, + tip: COMMITS[3].oid, + tipTime: COMMITS[3].timestamp, + isDefault: false, + }, +]; + +export const REMOTES: RemoteInfo[] = [ + { name: "origin", url: "https://example.com/pgit-showcase.git" }, +]; + +// ─── The selected commit's diff ────────────────────────────────────────────── + +const ctx = (content: string, oldLineno: number, newLineno: number): DiffLine => ({ + kind: { kind: "Context" }, + oldLineno, + newLineno, + content, +}); +const add = (content: string, newLineno: number): DiffLine => ({ + kind: { kind: "Addition" }, + oldLineno: null, + newLineno, + content, +}); +const del = (content: string, oldLineno: number): DiffLine => ({ + kind: { kind: "Deletion" }, + oldLineno, + newLineno: null, + content, +}); + +/** + * `src/evaluator.ts` as the selected commit changed it: +2 −7, which is the + * `+2 −7` the approved composition shows on the file row. The visible window of + * this hunk — the `./engine.js` import, the `Scope` type, the doc comment about + * an unknown identifier, and the `switch (node.kind)` — is transcribed from the + * shipped hero so the figure reads the same. + * + * The deletions are the four-case switch the commit message says was removed, + * which is what makes the diff illustrate its own subject. + */ +export const EVALUATOR_DIFF: FileDiff = { + path: "src/evaluator.ts", + oldPath: null, + binary: false, + additions: 2, + deletions: 7, + hunks: [ + { + header: "@@ -1,26 +1,21 @@", + oldStart: 1, + oldLines: 26, + newStart: 1, + newLines: 21, + lines: [ + ctx('import type { Node } from "./parser.js";', 1, 1), + add('import { apply } from "./engine.js";', 2), + ctx("", 2, 3), + ctx("export type Scope = Readonly>;", 3, 4), + ctx("", 4, 5), + ctx("/**", 5, 6), + ctx(" * Walks the tree.", 6, 7), + ctx(" *", 7, 8), + ctx( + " * An unknown identifier throws rather than yielding NaN: a typo in a", + 8, + 9, + ), + ctx( + " * name is the most common mistake in an expression language, and NaN", + 9, + 10, + ), + ctx(" * all the way to the top before anyone notices.", 10, 11), + ctx(" */", 11, 12), + ctx( + "export function evaluate(node: Node, scope: Scope = {}): number {", + 12, + 13, + ), + ctx(" switch (node.kind) {", 13, 14), + ctx(' case "num":', 14, 15), + ctx(" return node.value;", 15, 16), + ctx(' case "ident": {', 16, 17), + ctx(" const v = scope[node.name];", 17, 18), + ctx(" if (v === undefined) throw new Error(`unknown: ${node.name}`);", 18, 19), + ctx(" return v;", 19, 20), + ctx(" }", 20, 21), + del(' case "add":', 21), + del(" return evaluate(node.left, scope) + evaluate(node.right, scope);", 22), + del(' case "sub":', 23), + del(" return evaluate(node.left, scope) - evaluate(node.right, scope);", 24), + del(' case "mul":', 25), + del(" return evaluate(node.left, scope) * evaluate(node.right, scope);", 26), + del(" }", 27), + add( + " default:", + 22, + ), + ], + }, + ], +}; + +/** + * `src/engine.ts` as the WORKING TREE has it — the diff the commit figure shows + * in its centre pane, at +5 −5 to match its row in STATUS. + * + * The change illustrates itself: the operator table gains explicit descriptions, + * which is a believable thing to be part-way through when a screenshot is taken. + */ +export const ENGINE_WORKTREE_DIFF: FileDiff = { + path: "src/engine.ts", + oldPath: null, + binary: false, + additions: 5, + deletions: 5, + hunks: [ + { + header: "@@ -2,6 +2,8 @@", + oldStart: 2, + oldLines: 6, + newStart: 2, + newLines: 8, + lines: [ + ctx(" * The operator engine: one descriptor per operator, and a dispatcher", 2, 2), + ctx(" * that reads them.", 3, 3), + ctx(" *", 4, 4), + add(" * UNCOMMITTED: descriptions being made explicit about overflow and", 5), + add(" * domain errors.", 6), + ctx(" * The table is the source of truth. Adding an operator means adding a", 5, 7), + ctx(" * row here and a branch in `apply`; the parser reads `PRECEDENCE` off", 6, 8), + ctx(" * this table rather than hard-coding its own copy.", 7, 9), + ], + }, + { + header: "@@ -23,7 +25,7 @@ export const OPERATORS: readonly OpDescriptor[] = [", + oldStart: 23, + oldLines: 7, + newStart: 25, + newLines: 7, + lines: [ + ctx(' name: "add",', 23, 25), + ctx(" arity: 2,", 24, 26), + ctx(" precedence: 1,", 25, 27), + del(' description: "sum of both operands",', 26), + add( + ' description: "sum of both operands; overflows to Infinity",', + 28, + ), + ctx(" },", 27, 29), + ctx(" {", 28, 30), + ctx(' name: "sub",', 29, 31), + ], + }, + { + header: "@@ -35,7 +37,7 @@ export const OPERATORS: readonly OpDescriptor[] = [", + oldStart: 35, + oldLines: 7, + newStart: 37, + newLines: 7, + lines: [ + ctx(' name: "mul",', 35, 37), + ctx(" arity: 2,", 36, 38), + ctx(" precedence: 3,", 37, 39), + del(' description: "product of both operands",', 38), + add(' description: "product of both operands; the usual rounding",', 40), + ctx(" },", 39, 41), + ctx(" {", 40, 42), + ctx(' name: "div",', 41, 43), + ], + }, + { + header: "@@ -47,9 +49,7 @@ export const OPERATORS: readonly OpDescriptor[] = [", + oldStart: 47, + oldLines: 9, + newStart: 49, + newLines: 7, + lines: [ + ctx(" arity: 2,", 47, 49), + ctx(" precedence: 2,", 48, 50), + del(" // TODO: say what happens on a zero divisor. The engine raises,", 49), + del(" // but nobody reading this table would guess that.", 50), + del(' description: "quotient",', 51), + add(' description: "quotient; raises on a zero divisor",', 51), + ctx(" },", 52, 52), + ctx("];", 53, 53), + ], + }, + ], +}; + +/** + * `src/engine.ts`, whole, on each side of the worktree change. + * + * Needed because the shipped default for `diffContextMode` is `wholeFile`: the + * split view asks for both copies of the file and lays the hunks over them, so + * a scene that answered only the hunks would render a diff with nothing around + * it. Built from the diff above so the two cannot disagree. + */ +function fileAt(side: "old" | "new"): string { + const out: string[] = []; + for (const h of ENGINE_WORKTREE_DIFF.hunks) { + for (const l of h.lines) { + const k = l.kind.kind; + if (k === "Context") out.push(l.content); + else if (k === "Addition" && side === "new") out.push(l.content); + else if (k === "Deletion" && side === "old") out.push(l.content); + } + } + return out.join("\n"); +} + +const ENGINE_NEW: FileContent = { + path: "src/engine.ts", + binary: false, + text: fileAt("new"), + fromHead: false, + size: fileAt("new").length, +}; + +const ENGINE_OLD: FileContent = { + path: "src/engine.ts", + binary: false, + text: fileAt("old"), + fromHead: true, + size: fileAt("old").length, +}; + +/** + * The committer identity the commit panel names. A configured global identity + * on purpose: `NoSignature` is a FORM, and a figure showing the app asking for + * a name and email would advertise a setup step rather than the product. + */ +export const IDENTITY: GitIdentity = { + name: { value: "Jonas Aasberg", scope: "global" }, + email: { value: "jonas@example.com", scope: "global" }, + globalConfigPath: "/Users/jonas/.gitconfig", + localConfigPath: "/Users/jonas/pgit-showcase/.git/config", +}; + +/** No `commit.template` — the composer stays `git commit -m`, and the message + * box in the figure shows its placeholder rather than someone's boilerplate. */ +export const COMMIT_TEMPLATE: CommitTemplate = { + path: null, + body: null, + unreadable: false, + commentPrefix: "#", + cleanup: "default", +}; + +/** + * Handlers for everything the showcase repository answers. + * + * A function rather than a constant so each scene gets its own copy and cannot + * mutate another's. + * + * The empty/inert answers below are deliberate, not lazy: a marketing figure + * should show a repository in an ORDINARY state. A stash count, a rebase in + * progress or a shallow-clone warning would each put a banner or a badge in the + * figure that has nothing to do with what the figure is showing. + */ +export function showcaseHandlers(): Record) => unknown> { + return { + open_repo: () => HANDLE, + close_repo: () => undefined, + trust_repo_path: () => undefined, + head_info: () => HEAD, + get_status: () => STATUS, + get_log_page: () => LOG_PAGE, + watch_repo: () => undefined, + register_window_repos: () => undefined, + + list_branches: () => BRANCHES, + list_remotes: () => REMOTES, + list_tags: () => [], + list_stashes: () => [], + list_submodules: () => [], + list_worktrees: () => [], + + // The selected commit's detail pane. + diff_commit: () => [EVALUATOR_DIFF], + // The commit screen: the selected row's worktree diff, both copies of the + // file for whole-file context, the identity and the (absent) template. + // Path-aware, not a constant: answering every path with one file's diff is + // how a figure ends up captioned `NOTES.md` over the engine's operator + // table. An unknown path gets an empty diff rather than a lie. + get_diff: (args) => + args.path === ENGINE_WORKTREE_DIFF.path + ? ENGINE_WORKTREE_DIFF + : { ...ENGINE_WORKTREE_DIFF, path: String(args.path ?? ""), hunks: [], additions: 0, deletions: 0 }, + read_file_content: () => ENGINE_NEW, + read_file_content_at_index: () => ENGINE_OLD, + get_identity: () => IDENTITY, + get_commit_template: () => COMMIT_TEMPLATE, + // Unsigned, and no notes: both would add a badge to the figure that says + // nothing about what the figure is for. + verify_commit: () => ({ state: "None", signer: null, key: null }), + commit_notes: () => [], + + repo_state: () => "Clean", + shallow_info: () => ({ shallow: false, boundaryCount: 0, singleBranch: false }), + rebase_status: () => ({ + inProgress: false, + nextIndex: 0, + total: 0, + pauseReason: null, + lastCompleted: null, + }), + bisect_status: () => ({ + inProgress: false, + startRef: null, + badTerm: "bad", + goodTerm: "good", + currentOid: null, + remaining: null, + steps: null, + firstBadOid: null, + goodCount: 0, + badCount: 0, + skippedCount: 0, + }), + }; +} diff --git a/site/scripts/shoot/index.html b/site/scripts/shoot/index.html new file mode 100644 index 00000000..b8e307d4 --- /dev/null +++ b/site/scripts/shoot/index.html @@ -0,0 +1,34 @@ + + + + + + + platypusgit — figure + + + + +
+ + + diff --git a/site/scripts/shoot/scenes/commit.ts b/site/scripts/shoot/scenes/commit.ts new file mode 100644 index 00000000..b921e267 --- /dev/null +++ b/site/scripts/shoot/scenes/commit.ts @@ -0,0 +1,40 @@ +// commit-dark — the working tree, staged split, diff and commit composer. +// +// Same repository as the history figure; what differs is the screen and the +// diff view mode. The app always launches on History, so this one navigates the +// way a user does — clicking the activity bar, then the file row — rather than +// being seeded into place. See Scene.afterMount. +import type { Scene } from "../shim/core"; +import { clickWhenPresent, clickByText } from "../shim/core"; +import { BOOT_HANDLERS, settingsStorage } from "./shared"; +import { SHOWCASE_PATH, showcaseHandlers } from "../fixtures/showcase"; + +/** The row the figure shows the diff of. Must be a path in STATUS. */ +const SELECTED = "src/engine.ts"; + +export const commit: Scene = { + name: "commit", + figure: "commit-dark", + now: "2026-07-28T10:30:00+02:00", + storage: { + // Note there is no diff-view-mode setting here: the commit panel's + // Unified/Split toggle is LOCAL React state defaulting to unified + // (CommitPanel.tsx), not the `diffViewMode` setting — that one drives the + // standalone DiffViewer. So this figure clicks it, below. + "pg-settings-v2": settingsStorage(), + "pg-open-repos": JSON.stringify({ + paths: [SHOWCASE_PATH], + active: SHOWCASE_PATH, + }), + }, + handlers: { ...BOOT_HANDLERS, ...showcaseHandlers() }, + afterMount: async () => { + await clickWhenPresent('[data-activity="commit"]'); + // Select the file whose diff this figure is about. Without this the list + // selects its first row (NOTES.md) and the figure shows one file's name + // over another file's diff. + await clickWhenPresent(`[data-path="${SELECTED}"]`); + // Two columns, as the approved composition shows. + await clickByText("button", "Split"); + }, +}; diff --git a/site/scripts/shoot/scenes/history.ts b/site/scripts/shoot/scenes/history.ts new file mode 100644 index 00000000..4c465e8a --- /dev/null +++ b/site/scripts/shoot/scenes/history.ts @@ -0,0 +1,29 @@ +// history-dark — the hero on the site's landing page. +// +// Content is the shipped 2026-08-18 figure's, reproduced: that composition was +// already approved, and keeping it makes the new figure directly comparable. +// What changes is the UI drawing it, which is the point. +// +// Launch always lands on History (AppShell: the old pg-screen restore is gone), +// so this scene only has to say which repository is open. +import type { Scene } from "../shim/core"; +import { BOOT_HANDLERS, settingsStorage } from "./shared"; +import { SHOWCASE_PATH, showcaseHandlers } from "../fixtures/showcase"; + +export const history: Scene = { + name: "history", + figure: "history-dark", + // The showcase repository's commits are pinned to 2026-06-26. A month later + // is what makes the age column read "1mo ago" / "2mo ago", as in the + // approved composition. + now: "2026-07-28T10:30:00+02:00", + storage: { + "pg-settings-v2": settingsStorage(), + // `{ paths, active }` — the shape tabs.ts::loadOpenRepos reads. + "pg-open-repos": JSON.stringify({ + paths: [SHOWCASE_PATH], + active: SHOWCASE_PATH, + }), + }, + handlers: { ...BOOT_HANDLERS, ...showcaseHandlers() }, +}; diff --git a/site/scripts/shoot/scenes/index.ts b/site/scripts/shoot/scenes/index.ts new file mode 100644 index 00000000..ba2ac850 --- /dev/null +++ b/site/scripts/shoot/scenes/index.ts @@ -0,0 +1,12 @@ +// The scene registry. `?scene=` in the browser and `pnpm shoot ` on +// the command line both index into this. +import type { Scene } from "../shim/core"; +import { welcome } from "./welcome"; +import { history } from "./history"; +import { commit } from "./commit"; + +export const scenes: Record = { + welcome, + history, + commit, +}; diff --git a/site/scripts/shoot/scenes/shared.ts b/site/scripts/shoot/scenes/shared.ts new file mode 100644 index 00000000..148ed8c5 --- /dev/null +++ b/site/scripts/shoot/scenes/shared.ts @@ -0,0 +1,67 @@ +// What every figure has in common: the settings they are all pinned to, and the +// handlers the app calls on any start regardless of what is open. +import type { Scene } from "../shim/core"; + +/** + * Settings every figure renders under, written to `pg-settings-v2`. + * + * Pinned rather than left to defaults because a figure that changes when + * nothing changed is a figure nobody trusts — and because two of these are + * genuinely load-bearing: + * + * - `uiZoom: 1.2` matches the shipped 2026-08-18 captures (measured: the + * Welcome card is 634 CSS px there, 526 at 100%). It is a legibility + * decision, not an accident: Screenshot.astro renders a 1462px window into a + * 1040px column, and 13px body text does not survive that 71% downscale + * well. The shim turns this into real browser zoom, through the app's own + * applyZoom path. + * - `uiSpacing` / `uiTextScale` are #459's presets, which drive `--row-scale` + * and `--row-step`. Unpinned, a preset change would silently re-flow every + * row in every figure. + * + * The values other than uiZoom are the app's own defaults, restated so that a + * future default change does not quietly restyle the marketing figures. + */ +export const FIGURE_SETTINGS = { + activeThemeId: "dark-cool", + uiSpacing: "cozy", + uiTextScale: "default", + uiZoom: 1.2, + dateFormat: "relative", + diffViewMode: "inline", +} as const; + +/** + * `pg-settings-v2` holds a PLAIN object — not a zustand persist envelope, so + * this is written straight in with no `{ state, version }` wrapper. + * + * Takes overrides because the figures genuinely disagree about one setting: + * the commit figure shows the SPLIT diff and the history figure the inline one, + * which is what each screen's pane width is worth showing. + */ +export function settingsStorage( + overrides: Partial> = {}, +): string { + return JSON.stringify({ ...FIGURE_SETTINGS, ...overrides }); +} + +/** + * Commands the app issues on any start, whatever is open. Each was added + * because a `--report` run named it; nothing here is speculative. + */ +export const BOOT_HANDLERS: Scene["handlers"] = { + // No `pgit .` argument brought this window up — these figures are the app + // opened on its own. `null` is the "nothing to act on" answer. + take_launch_intent: () => null, + // The filesystem watcher has nothing to watch in a rendered figure. + watch_stop: () => undefined, + // macOS reports `Notify`: update.rs::capability falls through to it for every + // target that is not Windows or Linux, because macOS updates come from the + // Homebrew cask (latest.json carries no darwin entry). Saying "self-update" + // here would put a control in the figure that the shipped macOS app does not + // have. + get_update_capability: () => "notify", + // No update available. Keeps the update chip out of the titlebar — a figure + // advertising the app should not also advertise that the build in it is old. + check_for_update: () => null, +}; diff --git a/site/scripts/shoot/scenes/welcome.ts b/site/scripts/shoot/scenes/welcome.ts new file mode 100644 index 00000000..ef3112d4 --- /dev/null +++ b/site/scripts/shoot/scenes/welcome.ts @@ -0,0 +1,18 @@ +// welcome-dark — the app with no repository open. +// +// The cheapest scene, and the smoke test for the whole shim layer: storage +// holds no repository, so whatever the app asks for here it asks for on EVERY +// start. That is why those handlers live in shared.ts rather than here. +import type { Scene } from "../shim/core"; +import { BOOT_HANDLERS, settingsStorage } from "./shared"; + +export const welcome: Scene = { + name: "welcome", + figure: "welcome-dark", + now: "2026-07-28T10:30:00+02:00", + storage: { + "pg-settings-v2": settingsStorage(), + // No `pg-open-repos`: nothing open IS the figure. + }, + handlers: { ...BOOT_HANDLERS }, +}; diff --git a/site/scripts/shoot/shim/core.ts b/site/scripts/shoot/shim/core.ts new file mode 100644 index 00000000..293d8da4 --- /dev/null +++ b/site/scripts/shoot/shim/core.ts @@ -0,0 +1,274 @@ +// The whole Tauri surface the app imports, faked for a browser render. +// +// Every `@tauri-apps/*` entry `src/` imports is aliased to THIS ONE FILE by +// vite.config.ts. That works because the eight modules' export names do not +// collide (measured — see the alias list), and it keeps the fake in one +// readable place instead of eight files that each stub two functions. +// +// The live surface, per module, as of this commit: +// api/core invoke +// api/event emit, listen, UnlistenFn (type) +// api/window getCurrentWindow +// api/webviewWindow WebviewWindow +// api/webview getCurrentWebview +// api/dpi PhysicalPosition, PhysicalSize +// plugin-log attachConsole, debug, error, warn +// plugin-dialog open, save +// plugin-os platform +// Add to this file when that list grows; the build will tell you, loudly. + +// ---------------------------------------------------------------- the scene + +export type Handler = (args: Record) => unknown; + +/** One figure's world: what the app finds in storage, what the clock says, + * and how the backend answers. */ +export type Scene = { + /** Matches the `?scene=` query param and the `pnpm shoot ` argument. */ + name: string; + /** The figure's output basename, e.g. "history-dark". */ + figure: string; + /** Seeded into localStorage BEFORE any store module is imported. */ + storage: Record; + /** Frozen clock, ISO. Relative ages must not drift with the calendar. */ + now: string; + /** cmd -> fixture. A miss throws, which is the fixture worklist. */ + handlers: Record; + /** + * Put the mounted app into the state this figure shows — click through to a + * screen, select a row, open a pane. + * + * Needed because not everything the app can show is reachable from storage: + * the current screen is React state in AppShell (launch deliberately always + * lands on History, and the old `pg-screen` restore is gone), so a figure of + * any other screen has to navigate the way a user does. Driving the real UI + * also keeps this honest — a scene cannot show a state the app cannot reach. + */ + afterMount?: () => Promise; +}; + +/** Wait for a selector and click it. The retry is not paranoia: the scene runs + * as soon as React has rendered once, and a pane further down the tree may + * still be resolving its own data. */ +export async function clickWhenPresent(selector: string, timeoutMs = 8000): Promise { + const until = Date.now() + timeoutMs; + for (;;) { + const el = document.querySelector(selector); + if (el) { + el.click(); + return; + } + if (Date.now() > until) { + throw new Error(`[shoot] never found "${selector}" to click`); + } + await new Promise((r) => setTimeout(r, 100)); + } +} + +/** + * Click the element matching `selector` whose trimmed text is exactly `text`. + * + * For controls the design system builds without a stable hook — `PGButtonGroup` + * gives its buttons only `aria-pressed`, so Unified/Split can be reached by + * label or not at all. Exact match, not substring: "Split" must not also match + * a "Split view" somewhere else on screen. + */ +export async function clickByText( + selector: string, + text: string, + timeoutMs = 8000, +): Promise { + const until = Date.now() + timeoutMs; + for (;;) { + const el = [...document.querySelectorAll(selector)].find( + (e) => e.textContent?.trim() === text, + ); + if (el) { + el.click(); + return; + } + if (Date.now() > until) { + throw new Error(`[shoot] never found a "${selector}" reading "${text}" to click`); + } + await new Promise((r) => setTimeout(r, 100)); + } +} + +let scene: Scene | null = null; + +export function registerScene(s: Scene): void { + scene = s; +} + +export function currentScene(): Scene { + if (!scene) throw new Error("[shoot] no scene registered"); + return scene; +} + +/** Commands this render asked for and did not get, in first-asked order. + * + * A miss still throws — the app's own error paths stay honest — but it is + * recorded first, so ONE run reports every fixture the screen wants instead of + * making you rediscover them one reload at a time. The overlay in entry.tsx + * renders this. */ +export const misses: string[] = []; + +/** Every command that WAS answered, for pruning a scene back down once it + * renders: a handler nobody calls is a fixture that can drift unnoticed. */ +export const hits: string[] = []; + +// ---------------------------------------------------------------- api/core + +/** The single chokepoint. `src/lib/tauri.ts`'s 167 wrappers all funnel through + * the real one, so answering this answers the whole backend. + * + * A miss THROWS on purpose: it names the next fixture to write, which is what + * makes building a scene a worklist rather than guesswork. */ +export async function invoke( + cmd: string, + args?: Record, +): Promise { + const h = currentScene().handlers[cmd]; + if (!h) { + if (!misses.includes(cmd)) misses.push(cmd); + throw new Error( + `[shoot] no fixture for "${cmd}" in scene "${currentScene().name}". ` + + `Add it to that scene's handlers.`, + ); + } + if (!hits.includes(cmd)) hits.push(cmd); + return (await h(args ?? {})) as T; +} + +// The alias catches more than `src/`: the Tauri PLUGINS import from +// `@tauri-apps/api/core` too, and they resolve through it as well. Measured +// across the installed plugin bundles, they want exactly these two beyond +// `invoke` — `plugin-updater` imports `{ Resource, Channel, invoke }`. Neither +// does anything in a still figure; they exist so the bundler can link. + +export class Channel { + id = 0; + onmessage: ((msg: T) => void) | null = null; + toJSON(): string { + return `__CHANNEL__:${this.id}`; + } +} + +export class Resource { + constructor(public rid: number = 0) {} + async close(): Promise {} +} + +// --------------------------------------------------------------- api/event + +export type UnlistenFn = () => void; +export async function listen(): Promise { + return () => {}; +} +export async function emit(): Promise {} + +// -------------------------------------------- api/window, webviewWindow, webview + +// Shaped after the fakes in src/test/setup.ts, which is the shape the app is +// already known to tolerate. `theme` resolves "dark" rather than the test +// mock's null: these figures are dark by contract, and systemAppearance.ts +// reads exactly this. +const win = { + label: "main", + theme: async () => "dark" as const, + onThemeChanged: async () => () => {}, + onResized: async () => () => {}, + outerPosition: async () => ({ x: 0, y: 0 }), + outerSize: async () => ({ width: 1462, height: 975 }), + isMaximized: async () => false, + setTitle: async () => {}, + show: async () => {}, + hide: async () => {}, + close: async () => {}, + minimize: async () => {}, + toggleMaximize: async () => {}, + setFocus: async () => {}, +}; + +export function getCurrentWindow() { + return win; +} + +export function getCurrentWebview() { + return { + // applyZoom (useSettingsStore) scales the whole UI through the WEBVIEW's + // zoom rather than a CSS transform, so text reflows and stays sharp. CSS + // `zoom` on the root element is the browser-side equivalent: it shrinks the + // layout viewport by the factor and scales the result, which is exactly what + // setZoom does in the real window. + // + // This is not cosmetic. The shipped 2026-08-18 figures were captured at + // 120% (measured: the Welcome card is 634 CSS px there against 526 at + // 100%), and the site renders a 1462px window into a 1040px column — a 71% + // downscale that 13px body text does not survive well. Honouring the + // scene's uiZoom is what keeps the new figures as legible as the old. + setZoom: async (factor: number) => { + document.documentElement.style.zoom = String(factor); + }, + }; +} + +export class WebviewWindow { + static async getByLabel(): Promise { + return null; + } + label: string; + constructor(label: string) { + this.label = label; + } + async once(): Promise { + return () => {}; + } + async setPosition(): Promise {} + async setSize(): Promise {} + async setFocus(): Promise {} + async close(): Promise {} +} + +// ----------------------------------------------------------------- api/dpi + +export class PhysicalSize { + constructor( + public width: number, + public height: number, + ) {} +} +export class PhysicalPosition { + constructor( + public x: number, + public y: number, + ) {} +} + +// ------------------------------------------------------------- plugin-log + +export async function attachConsole(): Promise { + return () => {}; +} +export async function debug(): Promise {} +export async function warn(): Promise {} +export async function error(): Promise {} +export async function info(): Promise {} +export async function trace(): Promise {} + +// ---------------------------------------------------------- plugin-dialog + +// A still figure never opens a picker. `null` is the "user cancelled" answer +// every call site already handles. +export async function open(): Promise { + return null; +} +export async function save(): Promise { + return null; +} + +// -------------------------------------------------------------- plugin-os + +export function platform(): string { + return "macos"; +} diff --git a/site/scripts/shoot/shoot.mjs b/site/scripts/shoot/shoot.mjs new file mode 100644 index 00000000..8e2d7450 --- /dev/null +++ b/site/scripts/shoot/shoot.mjs @@ -0,0 +1,186 @@ +// Regenerates the site figures from the real app, headlessly. +// +// pnpm shoot every scene +// pnpm shoot history one scene +// pnpm shoot history --report what fixtures that scene still wants +// +// It starts the rig's vite server, renders each scene in headless Chrome at +// device scale factor 2, composites the macOS window chrome, and writes +// site/screenshots/
.png. Then run `pnpm screenshots` to encode. +// +// Why this exists rather than `pnpm capture`: that path needed a human on a +// Retina display to size a window and click it, which is why the figures went +// thirty days and 112 src/ commits out of date. See +// docs/superpowers/specs/2026-09-17-screenshot-rig-design.md. +import { spawn, execFileSync } from 'node:child_process'; +import { readFileSync, existsSync, rmSync, mkdirSync, statSync } from 'node:fs'; +import { resolve, join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import net from 'node:net'; +import os from 'node:os'; +import { composite, GEOM } from './composite.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const siteDir = resolve(here, '..', '..'); +const repoRoot = resolve(siteDir, '..'); +const outDir = join(siteDir, 'screenshots'); +// OUTSIDE the vite root (which is `here`). Chrome's user-data-dir writes +// thousands of files; inside the served tree that fires vite's watcher, the +// page reloads in a loop and the screenshot never settles — it looks exactly +// like "Chrome produced nothing". +const tmpDir = join(os.tmpdir(), 'platypusgit-shoot'); +const PORT = 1430; + +const argv = process.argv.slice(2); +const report = argv.includes('--report'); +const wanted = argv.filter((a) => !a.startsWith('-')); + +// The scene list lives in TypeScript (it is typed against src/lib/types.ts), so +// read the names off the registry source rather than duplicating them here. +const registry = readFileSync(join(here, 'scenes', 'index.ts'), 'utf8'); +const allScenes = [...registry.matchAll(/^\s{2}(\w+),$/gm)].map((m) => m[1]); +const scenes = wanted.length ? wanted : allScenes; + +for (const s of scenes) { + if (!allScenes.includes(s)) { + console.error(`Unknown scene "${s}". Known: ${allScenes.join(', ')}`); + process.exit(1); + } +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// "localhost", not "127.0.0.1": vite binds to localhost, which resolves to the +// IPv6 loopback on macOS, so an IPv4-only probe reports a live server as down +// and the driver starts a second one that then fails on strictPort. +function portOpen(port) { + return new Promise((done) => { + const sock = net.connect(port, 'localhost'); + sock.once('connect', () => (sock.destroy(), done(true))); + sock.once('error', () => done(false)); + sock.setTimeout(500, () => (sock.destroy(), done(false))); + }); +} + +async function waitForPort(port, timeoutMs = 60000) { + const until = Date.now() + timeoutMs; + while (Date.now() < until) { + if (await portOpen(port)) return true; + await sleep(300); + } + return false; +} + +// Chrome WRITES THE PNG AND THEN DOES NOT EXIT in this environment. So: launch +// detached, poll for the file, then kill it. Treating the non-exit as failure +// throws away a screenshot that is already on disk. +async function shootOne(scene, bodyPath) { + const profile = join(tmpDir, `profile-${scene}`); + rmSync(profile, { recursive: true, force: true }); + rmSync(bodyPath, { force: true }); + + const url = + `http://localhost:${PORT}/?scene=${encodeURIComponent(scene)}` + + (report ? '&report=1' : ''); + + const child = spawn( + join(here, 'chrome.sh'), + [ + '--headless=new', + '--disable-gpu', + '--hide-scrollbars', + '--force-device-scale-factor=2', + // CSS px; the scale factor doubles it into GEOM.window. + `--window-size=${GEOM.window.w / 2},${GEOM.window.h / 2}`, + `--screenshot=${bodyPath}`, + // Lets fonts, the syntax worker and Shiki's grammar imports settle. + '--virtual-time-budget=12000', + // Each shot gets its OWN profile: re-using one across concurrent shots + // collides and silently produces nothing. + `--user-data-dir=${profile}`, + url, + ], + { stdio: 'ignore', detached: true }, + ); + + const until = Date.now() + 90000; + let ok = false; + while (Date.now() < until) { + // Wait for the size to stop changing, not merely for the file to appear: + // Chrome creates it before it has finished writing. + if (existsSync(bodyPath)) { + const a = statSync(bodyPath).size; + await sleep(400); + if (a > 0 && existsSync(bodyPath) && statSync(bodyPath).size === a) { + ok = true; + break; + } + } + await sleep(400); + } + + try { + process.kill(-child.pid, 'SIGKILL'); + } catch {} + try { + execFileSync('pkill', ['-f', profile], { stdio: 'ignore' }); + } catch {} + rmSync(profile, { recursive: true, force: true }); + + if (!ok) throw new Error(`Chrome produced no screenshot for scene "${scene}"`); +} + +mkdirSync(outDir, { recursive: true }); +mkdirSync(tmpDir, { recursive: true }); + +let server = null; +if (await portOpen(PORT)) { + console.log(`Using the vite server already on :${PORT}`); +} else { + console.log(`Starting the rig's vite server on :${PORT}…`); + server = spawn( + join(repoRoot, 'node_modules', '.bin', 'vite'), + ['--config', join(here, 'vite.config.ts')], + { cwd: repoRoot, stdio: 'ignore', detached: true }, + ); + if (!(await waitForPort(PORT))) { + try { + process.kill(-server.pid, 'SIGKILL'); + } catch {} + console.error(`vite never came up on :${PORT}`); + process.exit(1); + } +} + +try { + for (const scene of scenes) { + // The figure's output name is declared in the scene, next to its content. + const src = readFileSync(join(here, 'scenes', `${scene}.ts`), 'utf8'); + const figure = src.match(/figure:\s*"([^"]+)"/)?.[1]; + if (!figure) throw new Error(`scene "${scene}" declares no figure name`); + + const bodyPath = join(tmpDir, `${scene}-body.png`); + process.stdout.write(`${scene} → `); + await shootOne(scene, bodyPath); + + if (report) { + // The overlay is already in the render; no chrome needed to read it. + console.log(`fixture report at ${bodyPath}`); + continue; + } + + const out = join(outDir, `${figure}.png`); + await composite(readFileSync(bodyPath), out); + console.log(`${figure}.png ${GEOM.canvas.w}x${GEOM.canvas.h}`); + } +} finally { + if (server) { + try { + process.kill(-server.pid, 'SIGKILL'); + } catch {} + } +} + +if (!report) { + console.log('\nNow encode them: pnpm screenshots'); +} diff --git a/site/scripts/shoot/tsconfig.json b/site/scripts/shoot/tsconfig.json new file mode 100644 index 00000000..abc1ba67 --- /dev/null +++ b/site/scripts/shoot/tsconfig.json @@ -0,0 +1,26 @@ +{ + // Typechecks the rig against the REAL app types. This is what contains the + // drift risk the hand-built AppShowcase replica died of: when a backend shape + // changes, the fixtures stop compiling instead of quietly rendering a figure + // that shows a product which no longer exists. + // + // Run it with: pnpm exec tsc -p site/scripts/shoot/tsconfig.json --noEmit + // (the repo root tsconfig does not cover site/, the same way it excludes e2e/) + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "types": ["vite/client"], + "paths": { + "@/*": ["../../../src/*"] + } + }, + "include": ["**/*.ts", "**/*.tsx"] +} diff --git a/site/scripts/shoot/vite.config.ts b/site/scripts/shoot/vite.config.ts new file mode 100644 index 00000000..14b1f754 --- /dev/null +++ b/site/scripts/shoot/vite.config.ts @@ -0,0 +1,42 @@ +// Serves the real app (src/) to a browser with the Tauri surface faked, so +// headless Chrome can render a figure for the site. See +// docs/superpowers/specs/2026-09-17-screenshot-rig-design.md. +// +// This is a SEPARATE config from the repo root's on purpose: the root one is +// what `tauri dev` and the production bundle use, and aliasing @tauri-apps +// there would ship the fake. +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import path from "node:path"; + +const here = import.meta.dirname; +// site/scripts/shoot -> repo root +const root = path.resolve(here, "..", "..", ".."); +const shim = path.resolve(here, "shim", "core.ts"); + +export default defineConfig({ + root: here, + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(root, "src"), + // All eight point at one file — its export names do not collide, and the + // header of shim/core.ts lists which module contributes what. + "@tauri-apps/api/core": shim, + "@tauri-apps/api/event": shim, + "@tauri-apps/api/window": shim, + "@tauri-apps/api/webviewWindow": shim, + "@tauri-apps/api/webview": shim, + "@tauri-apps/api/dpi": shim, + "@tauri-apps/plugin-log": shim, + "@tauri-apps/plugin-dialog": shim, + "@tauri-apps/plugin-os": shim, + }, + }, + // Copied from the root config deliberately: the syntax tokenizer runs in a + // module worker and Shiki code-splits its grammars, which the bundler refuses + // under the default "iife" worker format. + worker: { format: "es" }, + server: { port: 1430, strictPort: true }, +}); diff --git a/site/src/components/Screenshot.astro b/site/src/components/Screenshot.astro index 87e2ad6a..74f9ba8c 100644 --- a/site/src/components/Screenshot.astro +++ b/site/src/components/Screenshot.astro @@ -10,7 +10,7 @@ // ratio from the srcset below. Without the 2x, every Retina visitor sees a // 1.3x upscale of 1x text, which is what made these look blurry. import { existsSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; interface Props { /** Basename under public/screenshots/, no extension. */ @@ -48,8 +48,19 @@ const RENDER_W = 1040; // master gets no @2x rather than an upscale that costs bytes and adds nothing. // Resolved from disk so there is no manifest to fall out of sync; this runs at // build time in Node, never in the browser. -const publicDir = fileURLToPath(new URL('../../public/screenshots/', import.meta.url)); -const has2x = existsSync(`${publicDir}${name}@2x.webp`); +// Resolved from the PROJECT ROOT (process.cwd()), not from import.meta.url. +// +// This component is compiled, and at build time `import.meta.url` points at the +// built chunk -- `new URL('../../public/...', import.meta.url)` resolved to +// `dist/public/screenshots/`, which does not exist, so `has2x` was ALWAYS false +// and no figure ever got a srcset. The bug was invisible because it shipped +// alongside a capture path that never produced a 2x master either: there was +// nothing for the probe to find, so nothing looked wrong. +// +// `astro dev` and `astro build` both run with cwd = site/, which is stable in a +// way the chunk's location is not. +const publicDir = resolve(process.cwd(), 'public', 'screenshots'); +const has2x = existsSync(resolve(publicDir, `${name}@2x.webp`)); const src1x = `${base}/screenshots/${name}.webp`; const srcset = has2x diff --git a/site/src/pages/features.astro b/site/src/pages/features.astro index ebe405ad..b8070442 100644 --- a/site/src/pages/features.astro +++ b/site/src/pages/features.astro @@ -13,7 +13,7 @@ const shotAfterGroup: Record