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
No files matched your search
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
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`.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
web-dist/
|
||||
tmp/
|
||||
screenshots/
|
||||
@@ -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
|
||||
```
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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}`)
|
||||
@@ -4,7 +4,8 @@
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"examples"
|
||||
"examples",
|
||||
"example-app"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bun run build:native && bun run build:react",
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
"index.d.ts",
|
||||
"browser.mjs",
|
||||
"wasm",
|
||||
"*.node",
|
||||
"LICENSE"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||