Files
gpuix/scripts/screenshots.ts
T
Tommy D. Rossi 7d45d3681a Make GPUIX installable: quickstart, todo example app, and a key that typechecks
Installing the published packages and writing an app was not possible by
following the README. Three things blocked it.

**1. `key` failed on every GPUIX element**

    error TS2322: Type '{ key: string; ... }' is not assignable to type 'Props'.
      Property 'key' does not exist on type 'Props'.

Any list built with `.map()` broke on the first `tsc` run. `key` was expected
to arrive through `JSX.IntrinsicAttributes`, but TypeScript 5 ignores that
member for intrinsic elements. React's DOM types work only because
`DetailedHTMLProps` already carries `key`.

`key` now sits on `Props`, next to `ref`, and on `VirtualListShared`, the one
element prop type that does not extend `Props`. Verified with a probe over all
12 intrinsic elements plus `motion.div`, `VirtualList`, Select, Combobox and
Tooltip.

`jsx-dev-runtime.d.ts` also re-exported `jsx` and `jsxs` from
`react/jsx-dev-runtime`, which exports only `jsxDEV`. The declarations now
match `jsx-dev-runtime.js`.

Nothing caught this because Bun never typechecks and the build tsconfig
excludes `src/__tests__`. CI now runs `bun run typecheck` in `example-app`,
whose `app.tsx` maps a keyed list inside a `<virtual-list>`.

**2. A hello world installed 254 MB**

`@gpuix/native` packed all six platform binaries into the main tarball through
a `*.node` glob, on top of the six per-platform packages that
`optionalDependencies` already resolves.

    node_modules                       254M
    |- @gpuix/native                   185M  <- all six, never loaded
    |- @gpuix/native-darwin-arm64       23M  <- the one that loads
    |- @gpuix/react                    544K

The glob is gone. `index.js` still resolves the platform package at runtime.

**3. The README never said how to install**

It opened with the architecture and the mutation protocol. There was no
`bun add` anywhere, and no mention of `jsxImportSource`, which is required:
without it TypeScript uses DOM types and `<virtual-list>`, `<markdown>`,
`<code>` and `style.hover` all fail.

A **Quickstart** now sits between the hero and Examples: install, tsconfig,
entry file, `bun --hot`, `bun build --compile`. Nothing was removed. The
Building section says up front that it is for working on GPUIX itself.

**example-app/**

A todo app to copy, in one file, using the Waku palette from `chat.tsx`.

| Script | What it does |
|---|---|
| `bun run dev` | `bun --hot app.tsx` |
| `bun run build` | standalone binary in `dist/todo` |
| `bun run web:build` | browser renderer, needs nightly Rust |
| `bun run web:dev` | browser bundle served with COOP/COEP |
| `bun run screenshot` | drives the app through the automation client |
| `bun run typecheck` | `tsc --noEmit` |

It shows `<virtual-list>`, a native `<input>`, `motion.div`, tinted `<svg>`,
native `hover` and `active`, and `testId` hooks. `scripts/screenshots.ts`
regenerates `docs/images/todo-app.png` from it.

Verified on all four paths: `bun --hot`, the 77 MB binary, the browser build at
`localhost:4188`, and a clean `typecheck`.

Two things worth remembering, both now in comments:

- the browser build embeds only IBM Plex Sans and Lilex, so `Helvetica` falls
  back to a monospace and the family has to differ per target
- `maxWidth` on a `<virtual-list>` row does nothing; the list lays each row out
  at list width, so the column cap goes one level in

`bun.lock` carries a few entries from unrelated in-flight work in `examples/`.
They are inert and prune on the next install.

Session: ses_fc1c928b0ffeeHIfdRpxtE0QaV
2026-08-26 15:55:10 +02:00

73 lines
3.0 KiB
TypeScript

/**
* Regenerate the screenshots the README links to.
*
* Test output lands in gitignored `screenshots/` folders and churns on every
* run. Only the curated set below is committed, so the README never shows a
* stale frame and the repo never carries a binary diff per test run.
*
* bun scripts/screenshots.ts
*/
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const OUT = path.join(ROOT, 'docs', 'images')
/** `[source screenshot, committed name]`, relative to the repo root. */
const CURATED: [string, string][] = [
['example-app/screenshots/todo.png', 'todo-app.png'],
['examples/screenshots/chat-top.png', 'chat-app.png'],
['packages/react/screenshots/showcase.png', 'showcase.png'],
['packages/react/screenshots/markdown-document.png', 'markdown.png'],
['packages/react/screenshots/code-typescript.png', 'code.png'],
['packages/react/screenshots/diff-multi-file.png', 'diff.png'],
['packages/react/screenshots/selection-after.png', 'selection.png'],
['packages/react/screenshots/metrics-roomy.png', 'metrics.png'],
]
function run(command: string, args: string[], cwd: string) {
console.log(`[screenshots] ${command} ${args.join(' ')}`)
const result = spawnSync(command, args, { cwd, stdio: 'inherit' })
if (result.status !== 0) {
console.error(`[screenshots] failed: ${command} ${args.join(' ')}`)
process.exit(1)
}
}
console.log('[screenshots] rendering visual tests')
run('bun', ['run', 'test', 'showcase'], path.join(ROOT, 'packages', 'react'))
run('bun', ['run', 'test', 'markdown'], path.join(ROOT, 'packages', 'react'))
run('bun', ['run', 'test', 'code'], path.join(ROOT, 'packages', 'react'))
run('bun', ['run', 'test', 'diff-native'], path.join(ROOT, 'packages', 'react'))
run('bun', ['run', 'test', 'chat'], path.join(ROOT, 'examples'))
// The todo example has no test suite. It captures itself through the
// automation client, with the motion clock paused.
run('bun', ['run', 'screenshot'], path.join(ROOT, 'example-app'))
fs.mkdirSync(OUT, { recursive: true })
for (const [from, name] of CURATED) {
const source = path.join(ROOT, from)
if (!fs.existsSync(source)) {
console.error(`[screenshots] missing ${from} — did its test run?`)
process.exit(1)
}
const target = path.join(OUT, name)
// A 256-colour palette is lossless in practice for flat dark UI (no
// gradients to band) and cuts the committed size by about two thirds.
const shrunk = spawnSync(
'magick',
[source, '-strip', '-colors', '256', '-define', 'png:compression-level=9', target],
{ stdio: 'inherit' }
)
if (shrunk.status !== 0) {
console.warn('[screenshots] magick unavailable, copying uncompressed')
fs.copyFileSync(source, target)
}
const kb = Math.round(fs.statSync(target).size / 1024)
console.log(`[screenshots] ${name} (${kb} KB)`)
}
console.log(`[screenshots] wrote ${CURATED.length} files to docs/images`)