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
This commit is contained in:
Tommy D. Rossi committed 2026-08-26 15:55:10 +02:00
1 parent 8210a38aa0
commit 7d45d3681a
33 files changed
+1084 -7

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
---
'@gpuix/react': patch
---
Add a **Quickstart** to the README and a **todo example app** to start from.
The README described the architecture and the mutation protocol before it ever
said how to install the packages. It also never mentioned `jsxImportSource`,
which is required: without it TypeScript falls back to DOM types and
`<virtual-list>`, `<markdown>`, `<code>` and `style.hover` all fail.
```bash
bun add @gpuix/react react
bun add -d @types/react typescript
```
```json
{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "@gpuix/react" } }
```
`example-app/` is a complete todo app in one file, with scripts already wired:
| Script | What it does |
|---|---|
| `bun run dev` | Desktop app with hot remount |
| `bun run build` | Standalone binary in `dist/todo` |
| `bun run web:dev` | Browser build served with isolation headers |
| `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>`
icons, native `hover` and `active`, and `testId` automation hooks. Copy the
folder, change `@gpuix/react` from `workspace:^` to a version range, and run
`bun install`.
+33
View File
@@ -0,0 +1,33 @@
---
'@gpuix/react': patch
---
Fix `key` on every GPUIX element. A list built with `.map()` failed to
typecheck before this change, so any real app broke on the first `tsc` run:
```
error TS2322: Type '{ key: string; ... }' is not assignable to type 'Props'.
Property 'key' does not exist on type 'Props'.
```
`key` now lives on `Props` and on `<virtual-list>`, next to `ref`. It cannot
live on `JSX.IntrinsicAttributes`, because TypeScript 5 ignores that member for
intrinsic elements. React's DOM types work only because `DetailedHTMLProps`
already carries `key`.
```tsx
{todos.map((todo) => (
<div key={todo.id}>
<text style={{ color: '#e2e2e2' }}>{todo.title}</text>
</div>
))}
```
Every element prop type extends `Props`, so `<div>`, `<text>`, `<img>`, `<svg>`,
`<canvas>`, `<input>`, `<textarea>`, `<anchored>`, `<code>`, `<diff>`,
`<markdown>` and `<virtual-list>` all accept `key` again. So do `motion.div`,
`VirtualList`, Select, Combobox and Tooltip.
Also fix `@gpuix/react/jsx-dev-runtime` types. They re-exported `jsx` and `jsxs`
from `react/jsx-dev-runtime`, which exports only `jsxDEV`. The declarations now
match the runtime file.
+20
View File
@@ -0,0 +1,20 @@
---
'@gpuix/native': patch
---
Shrink the install by about **8x**. `@gpuix/native` packed every platform binary
into the main tarball through a `*.node` glob, on top of the six per-platform
packages that `optionalDependencies` already resolves.
A hello-world install paid for all of it:
```
node_modules 254M
├── @gpuix/native 185M ◄── all six binaries, unused
├── @gpuix/native-darwin-arm64 23M ◄── the one that loads
└── @gpuix/react 544K
```
The glob is gone. Only the loader, the types, the browser entry, and the Wasm
build ship in the main package. Nothing changes at runtime: `index.js` still
resolves the platform package for the host.
+6
View File
@@ -181,6 +181,12 @@ jobs:
run: bun run test
working-directory: examples
# Bun never typechecks. This step is what catches a broken public type,
# such as `key` disappearing from every element.
- name: Typecheck the example app
run: bun run typecheck
working-directory: example-app
test-windows:
name: test-windows-load
needs: build
+104 -2
View File
@@ -4,7 +4,7 @@ React bindings for [GPUI](https://github.com/zed-industries/zed/tree/main/crates
Build native GPU-accelerated desktop apps with React and TypeScript. Your components render directly to the GPU via Metal, DirectX, or Vulkan. No Electron, no web views.
![A Waku-style app built with GPUIX](docs/images/chat-app.png)
![A Waku-style app built with GPUIX](./docs/images/chat-app.png)
Everything above is GPUIX: the sidebar, the scrolling list, the composer,
and native `<markdown>`. Start it with **`bun --hot`** so a save remounts React
@@ -14,17 +14,115 @@ on the same window:
cd examples && bun --hot chat.tsx
```
## Quickstart
Install two packages. `@gpuix/react` pulls the native renderer for your
platform, so there is nothing to build and no Rust toolchain to install.
```bash
bun add @gpuix/react react
bun add -d @types/react typescript
```
### 1. Point TypeScript at the GPUIX JSX types
**`jsxImportSource` is required.** Without it TypeScript uses DOM types, so
`<virtual-list>`, `<markdown>`, `<code>` and `style.hover` all fail to
typecheck.
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "@gpuix/react",
"strict": true,
"noImplicitAny": false,
"skipLibCheck": true,
"noEmit": true
}
}
```
### 2. Write the entry file
End the file with `render()`. That call creates the window, mounts React, and
starts the frame loop.
```tsx
import { useState } from 'react'
import { render } from '@gpuix/react'
function App() {
const [count, setCount] = useState(0)
return (
<div style={{ padding: 24, backgroundColor: '#1a1a1a', height: '100%' }}>
<div
onClick={() => setCount((c) => c + 1)}
style={{
padding: 12,
borderRadius: 8,
cursor: 'pointer',
backgroundColor: '#232323',
hover: { backgroundColor: '#2c2c2c' },
}}
>
<text style={{ color: '#e2e2e2' }}>Count: {count}</text>
</div>
</div>
)
}
render(<App />, { title: 'My App', width: 800, height: 600 })
```
> [!IMPORTANT]
> **Give every `<text>` a `color`.** GPUI does not inherit `color` from a
> parent, so text with no color paints **black** and disappears on a dark
> surface.
### 3. Run it
```bash
bun --hot app.tsx
```
Use `bun --hot`, not plain `bun`. A save then remounts React on the same
window instead of opening a second one.
### 4. Ship a binary
```bash
bun build --compile app.tsx --outfile dist/app
./dist/app
```
The binary carries the renderer, so it runs with no Bun and no Node install.
### Start from the example app
[`example-app/`](https://github.com/remorses/gpuix/tree/main/example-app) is a complete todo app in one file, with `dev`,
`build`, `web:dev` and `typecheck` scripts already wired. Copy the folder,
change `@gpuix/react` from `workspace:^` to a version range, and run
`bun install`.
![The GPUIX todo example app](./docs/images/todo-app.png)
## Examples
| Example | Run | What it shows |
|---|---|---|
| **todo** | `bun run dev` in [`example-app/`](https://github.com/remorses/gpuix/tree/main/example-app) | The starting point: one file, a `<virtual-list>`, a native `<input>`, and an animated sidebar |
| **chat** | `bun --hot chat.tsx` | A Waku-style app: transparent titlebar, animated sidebar, message list, composer, `<markdown>` |
| **native-text** | `bun --hot native-text.tsx` | The three native text components with a tab switcher |
| **counter** | `bun --hot counter.tsx` | The smallest possible app: state, events, hover |
| **diff** | `bun --hot diff.tsx` | A diff viewer composed from `<div>` and `<text>` in JS, for comparison |
| **web** | `bun run web` from the repository root | The ChatGPT example rendered in a browser canvas with WebGPU |
All of them live in [`examples/`](./examples) and use hardcoded data.
The todo app lives in [`example-app/`](https://github.com/remorses/gpuix/tree/main/example-app) and is meant to be copied.
The rest live in [`examples/`](https://github.com/remorses/gpuix/tree/main/examples). All of them use hardcoded data.
Or download a standalone **chat** build from the [GitHub release](https://github.com/remorses/gpuix/releases). Files are named `example-chat-<target>`. No Bun or Rust install is required.
@@ -176,6 +274,10 @@ Event handlers are stored in a JS-side registry keyed by `(elementId, eventType)
## Building
This section is for **working on GPUIX itself**. To build an app with it, see
[Quickstart](#quickstart) instead. Installing the packages needs no Rust
toolchain and no submodule.
### Prerequisites
1. Rust toolchain
+61 -2
View File
@@ -13,6 +13,19 @@
"typescript": "^5.3.0",
},
},
"example-app": {
"name": "@gpuix/example-app",
"version": "0.1.0",
"dependencies": {
"@gpuix/react": "workspace:^",
"react": "^19.2.4",
},
"devDependencies": {
"@types/bun": "^1.2.0",
"@types/react": "^19.2.0",
"typescript": "^5.3.0",
},
},
"examples": {
"name": "@gpuix/examples",
"version": "0.1.0",
@@ -27,20 +40,22 @@
"devDependencies": {
"@types/mdast": "^4.0.4",
"@types/react": "^19.2.0",
"cbor-x": "^1.6.6",
"msgpackr": "^2.0.6",
"tsx": "^4.19.0",
"vitest": "^4.0.18",
},
},
"packages/native": {
"name": "@gpuix/native",
"version": "0.3.0",
"version": "0.4.0",
"devDependencies": {
"@napi-rs/cli": "^3.1.3",
},
},
"packages/react": {
"name": "@gpuix/react",
"version": "0.3.0",
"version": "0.4.0",
"dependencies": {
"@gpuix/native": "workspace:^",
"eventsource-parser": "^4.1.0",
@@ -73,6 +88,18 @@
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -131,6 +158,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
"@gpuix/example-app": ["@gpuix/example-app@workspace:example-app"],
"@gpuix/examples": ["@gpuix/examples@workspace:examples"],
"@gpuix/native": ["@gpuix/native@workspace:packages/native"],
@@ -175,6 +204,18 @@
"@kmamal/sdl": ["@kmamal/sdl@0.11.13", "", { "dependencies": { "tar": "^7.4.3" } }, "sha512-9WmxYNtCggi7Ovq1cU7m/s5WXD/+eKQxDMnL3bU8B5vr5GlaLg4xLykDCpcbWKkJJ2i6llTrdL7LiqikwDFz4w=="],
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="],
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="],
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="],
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
"@napi-rs/cli": ["@napi-rs/cli@3.5.1", "", { "dependencies": { "@inquirer/prompts": "^8.0.0", "@napi-rs/cross-toolchain": "^1.0.3", "@napi-rs/wasm-tools": "^1.0.1", "@octokit/rest": "^22.0.1", "clipanion": "^4.0.0-rc.4", "colorette": "^2.0.20", "emnapi": "^1.7.1", "es-toolkit": "^1.41.0", "js-yaml": "^4.1.0", "obug": "^2.0.0", "semver": "^7.7.3", "typanion": "^3.14.0" }, "peerDependencies": { "@emnapi/runtime": "^1.7.1" }, "optionalPeers": ["@emnapi/runtime"], "bin": { "napi": "dist/cli.js", "napi-raw": "cli.mjs" } }, "sha512-XBfLQRDcB3qhu6bazdMJsecWW55kR85l5/k0af9BIBELXQSsCFU0fzug7PX8eQp6vVdm7W/U3z6uP5WmITB2Gw=="],
"@napi-rs/cross-toolchain": ["@napi-rs/cross-toolchain@1.0.3", "", { "dependencies": { "@napi-rs/lzma": "^1.4.5", "@napi-rs/tar": "^1.1.0", "debug": "^4.4.1" }, "peerDependencies": { "@napi-rs/cross-toolchain-arm64-target-aarch64": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-armv7": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-ppc64le": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-s390x": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-x86_64": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-aarch64": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-armv7": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-ppc64le": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-s390x": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-x86_64": "^1.0.3" }, "optionalPeers": ["@napi-rs/cross-toolchain-arm64-target-aarch64", "@napi-rs/cross-toolchain-arm64-target-armv7", "@napi-rs/cross-toolchain-arm64-target-ppc64le", "@napi-rs/cross-toolchain-arm64-target-s390x", "@napi-rs/cross-toolchain-arm64-target-x86_64", "@napi-rs/cross-toolchain-x64-target-aarch64", "@napi-rs/cross-toolchain-x64-target-armv7", "@napi-rs/cross-toolchain-x64-target-ppc64le", "@napi-rs/cross-toolchain-x64-target-s390x", "@napi-rs/cross-toolchain-x64-target-x86_64"] }, "sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg=="],
@@ -377,6 +418,8 @@
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
@@ -437,6 +480,12 @@
"boolbase": ["boolbase@2.0.0", "", {}, "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA=="],
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
"cbor-x": ["cbor-x@1.6.6", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-8QiD9PGOxyQHo7s2pzwTBH6lTjqekxPdl9Aq6fXvZgCuCJHOht1puDEA/fTr6mciB76c+M+Gi0qT2i1a4pm4Wg=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
@@ -477,6 +526,8 @@
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="],
@@ -687,10 +738,16 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msgpackr": ["msgpackr@2.0.6", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-plGul/tqjt9vqWFR9zyqyLZls6gb5KTLvnsRO2B9+TZ8tNiXiVI/CR8LiDWlAX4IUeVkQ9gsmzKA6voC2QOciw=="],
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
"mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
"nth-check": ["nth-check@3.0.1", "", { "dependencies": { "boolbase": "^2.0.0" } }, "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
@@ -843,6 +900,8 @@
"htmlparser2/domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"msgpackr-extract/node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"@gpuix/react/react-reconciler/scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="],
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+4
View File
@@ -0,0 +1,4 @@
dist/
web-dist/
tmp/
screenshots/
+89
View File
@@ -0,0 +1,89 @@
# GPUIX todo app
A small todo app that renders on the GPU. Copy this folder to start a project.
```bash
bun install
bun run dev
```
`dev` runs `bun --hot app.tsx`. A save remounts React on the same window.
## Scripts
| Script | What it does |
|---|---|
| `bun run dev` | Start the desktop app with hot remount |
| `bun run build` | Compile a standalone binary into `dist/todo` |
| `bun run typecheck` | Run `tsc --noEmit` |
| `bun run web:build` | Build the browser renderer. Needs nightly Rust |
| `bun run web:dev` | Bundle for the browser and serve on `:4173` |
| `bun run screenshot` | Drive the app with the automation client and write a PNG |
## Files
```
app.tsx the whole app: tokens, icons, data, components, screen
index.html the browser page. GPUI creates the canvas itself
web.ts bundles app.tsx and serves it with isolation headers
screenshot.ts drives the app with the automation client and writes a PNG
assets/icons lucide SVGs, imported as text and tinted with style.color
assets.d.ts tells TypeScript that a `.svg` import is a string
```
## What it shows
- **`<virtual-list>`** for the task list, so only visible rows are built
- **native `<input>`** in the composer, with a caret, IME, and clipboard
- **`motion.div`** for the sidebar, animated in Rust with no React frames
- **`<svg>`** icons tinted through `style.color`
- **`hover` and `active`** styles applied natively, with no JavaScript round trip
- **`testId`** props, so the automation client can drive the app
## Copy it out of this repo
This folder uses `"@gpuix/react": "workspace:^"`. Change that to a version range
and it installs from npm:
```json
{
"dependencies": {
"@gpuix/react": "^0.4.0",
"react": "^19.2.4"
}
}
```
Nothing else in the folder depends on the repository.
## Two rules that are easy to miss
**Set `jsxImportSource`.** Without it TypeScript uses DOM types and every GPUIX
element fails:
```json
{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "@gpuix/react" } }
```
**Set a `color` on text.** GPUI does not inherit `color`, so a `<text>` with no
color paints black and disappears on a dark surface.
## Fonts on the web
The browser build ships **IBM Plex Sans** and **Lilex** only. Any other family
falls back to Lilex, which is a monospace, so `app.tsx` picks the family per
target:
```tsx
const FONT = typeof window === 'undefined' ? 'Helvetica' : 'IBM Plex Sans'
```
## Cross-origin isolation
The Wasm renderer uses shared memory. `web.ts` sends the two headers a
production host also needs:
```http
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```
+534
View File
@@ -0,0 +1,534 @@
/**
* A todo app rendered directly on the GPU by GPUIX.
*
* Desktop: bun run dev
* Browser: bun run web:build && bun run web:dev
* Binary: bun run build && ./dist/todo
*
* The whole app is this one file. Read it top to bottom: tokens, icons,
* data, small components, then the screen.
*/
import { useMemo, useRef, useState } from 'react'
import { motion, render } from '@gpuix/react'
import iconCheck from './assets/icons/check.svg' with { type: 'text' }
import iconCircleCheck from './assets/icons/circle-check.svg' with { type: 'text' }
import iconInbox from './assets/icons/inbox.svg' with { type: 'text' }
import iconPanelLeft from './assets/icons/panel-left.svg' with { type: 'text' }
import iconPlus from './assets/icons/plus.svg' with { type: 'text' }
import iconSearch from './assets/icons/search.svg' with { type: 'text' }
import iconSettings from './assets/icons/settings.svg' with { type: 'text' }
import iconSparkle from './assets/icons/sparkle.svg' with { type: 'text' }
import iconStar from './assets/icons/star.svg' with { type: 'text' }
import iconSun from './assets/icons/sun.svg' with { type: 'text' }
import iconTrash from './assets/icons/trash.svg' with { type: 'text' }
// ── Design tokens ──────────────────────────────────────────────────────────
// GPUI does not inherit `color`, so every <text> sets one. These names keep
// that honest.
const C = {
canvas: '#1A1A1A',
sidebar: '#181818',
raised: '#232323',
overlay: '#E6EAF20D',
overlayStrong: '#E6EAF217',
border: '#E6EAF212',
sidebarBorder: '#292929',
text: '#E2E2E2',
secondary: '#A3A3A3',
tertiary: '#7D7D7D',
ghost: '#575757',
accent: '#E2795B',
onAccent: '#17181C',
}
// The browser build ships only IBM Plex Sans and Lilex. Any other family falls
// back to Lilex, which is a monospace, so the web name must differ.
const FONT = typeof window === 'undefined' ? 'Helvetica' : 'IBM Plex Sans'
const SIDEBAR_WIDTH = 236
const CONTENT_MAX_WIDTH = 640
// macOS draws the traffic lights over the app, so the sidebar starts below them.
const TITLEBAR_CLEARANCE =
typeof process !== 'undefined' && process.platform === 'darwin' ? 86 : 20
const ICONS = {
check: iconCheck,
circleCheck: iconCircleCheck,
inbox: iconInbox,
panelLeft: iconPanelLeft,
plus: iconPlus,
search: iconSearch,
settings: iconSettings,
sparkle: iconSparkle,
star: iconStar,
sun: iconSun,
trash: iconTrash,
} as const
type IconName = keyof typeof ICONS
function Icon({ name, size = 15, color }: { name: IconName; size?: number; color: string }) {
return (
<svg source={ICONS[name]} style={{ width: size, height: size, flexShrink: 0, color }} />
)
}
// ── Data ───────────────────────────────────────────────────────────────────
type Todo = {
id: string
title: string
done: boolean
starred: boolean
today: boolean
}
type ViewId = 'inbox' | 'today' | 'starred' | 'done'
const VIEWS: { id: ViewId; label: string; icon: IconName }[] = [
{ id: 'inbox', label: 'Inbox', icon: 'inbox' },
{ id: 'today', label: 'Today', icon: 'sun' },
{ id: 'starred', label: 'Starred', icon: 'star' },
{ id: 'done', label: 'Done', icon: 'circleCheck' },
]
const INITIAL: Todo[] = [
{ id: 't1', title: 'Read the GPUIX quickstart', done: true, starred: false, today: true },
{ id: 't2', title: 'Draw the first window', done: true, starred: false, today: true },
{ id: 't3', title: 'Wire a native <input> to React state', done: false, starred: true, today: true },
{ id: 't4', title: 'Put a long list behind <virtual-list>', done: false, starred: false, today: true },
{ id: 't5', title: 'Animate the sidebar with motion.div', done: false, starred: true, today: false },
{ id: 't6', title: 'Tint an icon through style.color', done: false, starred: false, today: false },
{ id: 't7', title: 'Compile a standalone binary', done: false, starred: false, today: false },
{ id: 't8', title: 'Ship it', done: false, starred: false, today: false },
]
function matches(todo: Todo, view: ViewId): boolean {
if (view === 'done') return todo.done
if (view === 'starred') return todo.starred && !todo.done
if (view === 'today') return todo.today && !todo.done
return !todo.done
}
// ── Pieces ─────────────────────────────────────────────────────────────────
function IconButton({
icon,
onClick,
testId,
color = C.tertiary,
}: {
icon: IconName
onClick?: () => void
testId?: string
color?: string
}) {
return (
<div
testId={testId}
onClick={onClick}
style={{
width: 28,
height: 28,
flexShrink: 0,
borderRadius: 7,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
hover: { backgroundColor: C.overlay },
active: { backgroundColor: C.overlayStrong },
}}
>
<Icon name={icon} color={color} />
</div>
)
}
function SidebarRow({
icon,
label,
count,
active,
onClick,
}: {
icon: IconName
label: string
count?: number
active?: boolean
onClick?: () => void
}) {
return (
<div
testId={`view-${label.toLowerCase()}`}
onClick={onClick}
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 10,
height: 32,
paddingLeft: 8,
paddingRight: 8,
borderRadius: 7,
cursor: 'pointer',
backgroundColor: active ? C.overlayStrong : undefined,
hover: active ? undefined : { backgroundColor: C.overlay },
}}
>
<Icon name={icon} size={14} color={active ? C.text : C.secondary} />
<text style={{ flexGrow: 1, fontSize: 13, fontFamily: FONT, color: active ? C.text : C.secondary }}>
{label}
</text>
{count ? (
<text style={{ fontSize: 12, fontFamily: FONT, color: C.ghost }}>{String(count)}</text>
) : null}
</div>
)
}
function Checkbox({ done, onToggle }: { done: boolean; onToggle: () => void }) {
return (
<div
onClick={onToggle}
style={{
width: 19,
height: 19,
flexShrink: 0,
borderRadius: 10,
borderWidth: 1.5,
borderColor: done ? C.accent : C.ghost,
backgroundColor: done ? C.accent : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
hover: { borderColor: done ? C.accent : C.secondary },
}}
>
{done ? <Icon name="check" size={11} color={C.onAccent} /> : null}
</div>
)
}
function TodoRow({
todo,
onToggle,
onStar,
onDelete,
}: {
todo: Todo
onToggle: () => void
onStar: () => void
onDelete: () => void
}) {
const [hovered, setHovered] = useState(false)
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 12,
minHeight: 44,
// A `<virtual-list>` row is laid out at the list width, so the column
// is capped here, one level in, and not on the row wrapper.
maxWidth: CONTENT_MAX_WIDTH,
paddingLeft: 10,
paddingRight: 6,
borderRadius: 9,
hover: { backgroundColor: C.overlay },
}}
>
<Checkbox done={todo.done} onToggle={onToggle} />
<text
style={{
flexGrow: 1,
fontSize: 14,
fontFamily: FONT,
lineHeight: 20,
color: todo.done ? C.ghost : C.text,
}}
>
{todo.title}
</text>
{todo.starred ? <Icon name="star" size={13} color={C.accent} /> : null}
{hovered ? (
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 2 }}>
<IconButton icon="star" onClick={onStar} color={todo.starred ? C.accent : C.ghost} />
<IconButton icon="trash" onClick={onDelete} color={C.ghost} />
</div>
) : null}
</div>
)
}
function Composer({ onAdd }: { onAdd: (title: string) => void }) {
const [draft, setDraft] = useState('')
const submit = () => {
const title = draft.trim()
if (!title) return
onAdd(title)
setDraft('')
}
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 10,
height: 46,
paddingLeft: 12,
paddingRight: 8,
borderRadius: 12,
borderWidth: 1,
borderColor: C.border,
backgroundColor: C.raised,
}}
>
<Icon name="plus" size={15} color={C.tertiary} />
<input
testId="composer"
value={draft}
placeholder="Add a task"
autoFocus
onChange={(event) => setDraft(event.value ?? '')}
onSubmit={submit}
theme={{ caret: C.accent }}
style={{ flexGrow: 1, fontSize: 14, fontFamily: FONT, color: C.text }}
/>
<div
testId="add"
onClick={submit}
style={{
height: 30,
paddingLeft: 14,
paddingRight: 14,
borderRadius: 8,
display: 'flex',
alignItems: 'center',
opacity: draft.trim() ? 1 : 0.35,
backgroundColor: C.accent,
cursor: 'pointer',
hover: { backgroundColor: '#EC8767' },
}}
>
<text style={{ fontSize: 13, fontFamily: FONT, color: C.onAccent }}>Add</text>
</div>
</div>
)
}
function EmptyState({ view }: { view: ViewId }) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 10,
paddingTop: 90,
}}
>
<Icon name="sparkle" size={22} color={C.ghost} />
<text style={{ fontSize: 14, fontFamily: FONT, color: C.tertiary }}>
{view === 'done' ? 'Nothing finished yet' : 'All clear'}
</text>
</div>
)
}
// ── Screen ─────────────────────────────────────────────────────────────────
export function TodoApp() {
const [todos, setTodos] = useState(INITIAL)
const [view, setView] = useState<ViewId>('today')
const [collapsed, setCollapsed] = useState(false)
const nextId = useRef(INITIAL.length)
const counts = useMemo(() => {
const of = (id: ViewId) => todos.filter((todo) => matches(todo, id)).length
return { inbox: of('inbox'), today: of('today'), starred: of('starred'), done: of('done') }
}, [todos])
const visible = useMemo(() => todos.filter((todo) => matches(todo, view)), [todos, view])
const activeView = VIEWS.find((entry) => entry.id === view)!
const update = (id: string, patch: Partial<Todo>) =>
setTodos((current) =>
current.map((todo) => (todo.id === id ? { ...todo, ...patch } : todo)),
)
const add = (title: string) => {
nextId.current += 1
setTodos((current) => [
{ id: `t${nextId.current}`, title, done: false, starred: false, today: view !== 'inbox' },
...current,
])
}
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
width: '100%',
height: '100%',
backgroundColor: C.canvas,
}}
>
{/* Animate an outer clipping box, not the sidebar itself, so its text
never reflows during the transition. */}
<motion.div
initial={false}
animate={{ width: collapsed ? 0 : SIDEBAR_WIDTH }}
transition={{ duration: 0.2, ease: 'easeOut' }}
style={{
display: 'flex',
flexDirection: 'row',
height: '100%',
flexShrink: 0,
overflow: 'hidden',
}}
>
<div
style={{
width: SIDEBAR_WIDTH,
height: '100%',
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
gap: 2,
paddingTop: TITLEBAR_CLEARANCE,
paddingLeft: 10,
paddingRight: 10,
paddingBottom: 10,
backgroundColor: C.sidebar,
borderRightWidth: 1,
borderColor: C.sidebarBorder,
}}
>
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 9,
height: 30,
paddingLeft: 8,
paddingBottom: 6,
}}
>
<Icon name="sparkle" size={14} color={C.accent} />
<text style={{ fontSize: 13, fontFamily: FONT, color: C.text }}>Tasks</text>
</div>
{VIEWS.map((entry) => (
<SidebarRow
key={entry.id}
icon={entry.icon}
label={entry.label}
count={counts[entry.id]}
active={entry.id === view}
onClick={() => setView(entry.id)}
/>
))}
<div style={{ flexGrow: 1 }} />
<SidebarRow icon="settings" label="Settings" />
</div>
</motion.div>
<div
style={{
flexGrow: 1,
minWidth: 0,
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 10,
height: 52,
paddingLeft: collapsed ? TITLEBAR_CLEARANCE : 14,
paddingRight: 14,
flexShrink: 0,
}}
>
<IconButton
icon="panelLeft"
testId="sidebar-toggle"
onClick={() => setCollapsed((open) => !open)}
/>
<text style={{ fontSize: 15, fontFamily: FONT, color: C.text }}>{activeView.label}</text>
<text style={{ fontSize: 13, fontFamily: FONT, color: C.ghost }}>
{String(visible.length)}
</text>
<div style={{ flexGrow: 1 }} />
<IconButton icon="search" />
</div>
{/* <virtual-list> is the only scroller on this screen. Nested scrolling
is not supported, so nothing inside a row may scroll. */}
<virtual-list
estimatedItemHeight={48}
style={{
flexGrow: 1,
minHeight: 0,
paddingLeft: 14,
paddingRight: 14,
}}
>
{visible.length === 0
? [<EmptyState key="empty" view={view} />]
: visible.map((todo) => (
<div key={todo.id}>
<TodoRow
todo={todo}
onToggle={() => update(todo.id, { done: !todo.done })}
onStar={() => update(todo.id, { starred: !todo.starred })}
onDelete={() =>
setTodos((current) => current.filter((entry) => entry.id !== todo.id))
}
/>
</div>
))}
</virtual-list>
<div
style={{
flexShrink: 0,
paddingLeft: 14,
paddingRight: 14,
paddingTop: 8,
paddingBottom: 14,
maxWidth: CONTENT_MAX_WIDTH + 28,
}}
>
<Composer onAdd={add} />
</div>
</div>
</div>
)
}
render(<TodoApp />, {
title: 'Todo',
width: 940,
height: 660,
titlebarTransparent: true,
windowBackground: 'blurred',
trafficLightX: 16,
trafficLightY: 17,
})
+6
View File
@@ -0,0 +1,6 @@
// `import icon from './x.svg' with { type: 'text' }` gives the file contents as
// a string. Bun's bundler embeds it, so the icon ships inside the binary.
declare module "*.svg" {
const source: string
export default source
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>

After

Width:  |  Height:  |  Size: 184 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>

After

Width:  |  Height:  |  Size: 214 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>

After

Width:  |  Height:  |  Size: 311 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="3"/><path d="M9 3v18"/></svg>

After

Width:  |  Height:  |  Size: 225 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>

After

Width:  |  Height:  |  Size: 197 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 56"><path fill="#000" d="M23.957 41.77a18.02 18.02 0 0 0 10.477-3.376l11.109 11.11a2.66 2.66 0 0 0 1.898.773c1.524 0 2.625-1.172 2.625-2.672c0-.703-.234-1.359-.75-1.874L38.277 34.668c2.32-3.047 3.703-6.82 3.703-10.922c0-9.914-8.109-18.023-18.023-18.023c-9.937 0-18.023 8.109-18.023 18.023S14.02 41.77 23.957 41.77m0-3.891c-7.758 0-14.133-6.398-14.133-14.133S16.2 9.613 23.957 9.613c7.734 0 14.133 6.399 14.133 14.133c0 7.735-6.399 14.133-14.133 14.133"/></svg>

After

Width:  |  Height:  |  Size: 517 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.51a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>

After

Width:  |  Height:  |  Size: 766 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="m4.206 7.5 15.588 9"/><path d="m19.794 7.5-15.588 9"/></svg>

After

Width:  |  Height:  |  Size: 240 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/></svg>

After

Width:  |  Height:  |  Size: 533 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>

After

Width:  |  Height:  |  Size: 398 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>

After

Width:  |  Height:  |  Size: 318 B

+31
View File
@@ -0,0 +1,31 @@
<!-- Browser page for the GPUIX todo app. GPUI creates the canvas itself. -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1" />
<title>GPUIX Todo</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; }
canvas { display: block; width: 100%; height: 100%; touch-action: none; }
/* The app draws every glyph itself, so the page must not behave like a
document: a long press would select the body and pop the iOS callout. */
html, body, canvas {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
/* GPUI's hidden IME textarea still needs a real selection for
composition, backspace context, and clipboard. */
[data-gpui-input] {
-webkit-user-select: text !important;
user-select: text !important;
}
</style>
</head>
<body>
<script type="module" src="/app.js"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@gpuix/example-app",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot app.tsx",
"build": "bun build --compile app.tsx --outfile dist/todo",
"web:build": "bun run --cwd ../packages/native build:web",
"web:dev": "bun web.ts",
"screenshot": "bun screenshot.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@gpuix/react": "workspace:^",
"react": "^19.2.4"
},
"devDependencies": {
"@types/bun": "^1.2.0",
"@types/react": "^19.2.0",
"typescript": "^5.3.0"
}
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Drive the app like Playwright and write a PNG.
*
* `launch()` starts `app.tsx` as a child process and speaks the automation
* protocol over its stdin pipe. `clock.pause()` freezes native motion, so the
* sidebar frame is the same on every run.
*
* bun run screenshot writes screenshots/todo.png
* bun run screenshot out.png writes that path instead
*/
import { mkdirSync } from 'node:fs'
import path from 'node:path'
import { launch } from '@gpuix/react/automation'
// The renderer writes the file itself and does not create the folder.
const out = process.argv[2] ?? 'screenshots/todo.png'
mkdirSync(path.dirname(out), { recursive: true })
const app = await launch({ command: 'bun', args: ['app.tsx'] })
await app.getByTestId('composer').waitFor({ timeoutMs: 60_000 })
await app.clock.pause()
await app.screenshot({ path: out })
await app.close()
console.log(`[screenshot] wrote ${out}`)
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "@gpuix/react",
"types": ["bun"],
"strict": true,
"noImplicitAny": false,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["*.ts", "*.tsx", "*.d.ts"]
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Bundle app.tsx for the browser and serve it.
*
* The Wasm renderer uses shared memory, so every response needs the two
* cross-origin isolation headers. A production host must send the same ones.
*
* `@gpuix/native` resolves to its `browser` entry during a browser build. That
* entry imports the Wasm file, so Bun copies it next to the bundle. Nothing
* here has to know where the package lives.
*/
import { existsSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.dirname(fileURLToPath(import.meta.url))
const OUT = path.join(ROOT, 'web-dist')
const bundle = await Bun.build({
entrypoints: [path.join(ROOT, 'app.tsx')],
outdir: OUT,
target: 'browser',
format: 'esm',
naming: 'app.js',
throw: false,
})
if (!bundle.success) {
for (const message of bundle.logs) console.error(message)
console.error('\nweb: if the Wasm renderer is missing, run: bun run web:build')
process.exit(1)
}
const ISOLATION = {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
}
function contentType(pathname: string): string {
if (pathname.endsWith('.wasm')) return 'application/wasm'
if (pathname.endsWith('.js') || pathname.endsWith('.mjs')) return 'text/javascript'
return 'text/html'
}
const server = Bun.serve({
port: Number(process.env.PORT || 4173),
fetch(request) {
const { pathname } = new URL(request.url)
if (pathname === '/favicon.ico') {
return new Response(null, { status: 204, headers: ISOLATION })
}
const file =
pathname === '/' ? path.join(ROOT, 'index.html') : path.join(OUT, pathname.slice(1))
if (!file.startsWith(ROOT) || !existsSync(file)) {
return new Response('Not found', { status: 404 })
}
return new Response(Bun.file(file), {
headers: { ...ISOLATION, 'Content-Type': contentType(pathname) },
})
},
})
console.log(`web: http://localhost:${server.port}`)
+2 -1
View File
@@ -4,7 +4,8 @@
"license": "Apache-2.0",
"workspaces": [
"packages/*",
"examples"
"examples",
"example-app"
],
"scripts": {
"build": "bun run build:native && bun run build:react",
-1
View File
@@ -42,7 +42,6 @@
"index.d.ts",
"browser.mjs",
"wasm",
"*.node",
"LICENSE"
]
}
+5 -1
View File
@@ -1,5 +1,9 @@
/// GPUIX JSX dev-runtime types — mirrors jsx-runtime.d.ts for development builds.
///
/// React 19's `react/jsx-dev-runtime` exports only `jsxDEV`, so the aliases here
/// must match jsx-dev-runtime.js instead of re-exporting `jsx` and `jsxs`.
import type * as React from "react"
import type {
AnchoredProps,
CodeProps,
@@ -13,7 +17,7 @@ import type {
VirtualListProps,
} from "./dist/types/host"
export { jsx, jsxs, Fragment } from "react/jsx-dev-runtime"
export { jsxDEV, jsxDEV as jsx, jsxDEV as jsxs, Fragment } from "react/jsx-dev-runtime"
export namespace JSX {
type ElementType = React.JSX.ElementType
+4
View File
@@ -1,6 +1,10 @@
/// GPUIX JSX runtime types — maps intrinsic elements to GPUIX Props
/// instead of DOM types. Activated via "jsxImportSource": "@gpuix/react".
///
/// `key` is declared on `Props`, not on `IntrinsicAttributes` below.
/// TypeScript 5 ignores `IntrinsicAttributes` for intrinsic elements.
import type * as React from "react"
import type {
AnchoredProps,
CodeProps,
+7
View File
@@ -257,6 +257,11 @@ export interface GpuixTheme {
// Element IDs are auto-generated numeric IDs (not user-settable).
// Use React refs to get an element's ID: ref.current.id
export interface Props {
// `key` must live here, not in `JSX.IntrinsicAttributes`. TypeScript 5 ignores
// that member for intrinsic elements, and React's DOM types work only because
// `DetailedHTMLProps` already carries `key`. Without this field every
// `<div key={...} />` inside a `.map()` fails to typecheck.
key?: React.Key | null
style?: StyleDesc
children?: React.ReactNode
ref?: React.Ref<PublicInstance>
@@ -320,6 +325,8 @@ export interface TextareaProps extends InputProps {
}
type VirtualListShared = {
// See the note on `Props.key`.
key?: React.Key | null
style?: StyleDesc
children?: React.ReactNode
ref?: React.Ref<PublicInstance>
+4
View File
@@ -18,6 +18,7 @@ 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'],
@@ -42,6 +43,9 @@ 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) {