@gpuix/react@0.5.0
-
GPUIX apps now run in the browser. The same React tree renders through GPUI's browser platform on WebGPU, with a WebGL2 fallback.
RetainedTree,GpuixView, styles, and text painting are shared with desktop, so events, selects, comboboxes, inputs, motion, and GPUI scroll gestures all work through a Wasm-to-JavaScript callback bridge. napi-rs stays the desktop bridge; wasm-bindgen startsgpui_webin the page.bun run web # build the Wasm if it is missing, then serve with HMR bun run web:wasm # only cargo + wasm-bindgen
bun run webserves through Bun's frontend dev server, so an edit to a component module is a React Fast Refresh update instead of a page reload.useStatesurvives, the GPUI canvas is never re-created, and the ~19 MB Wasm module is never re-fetched.Browser apps always expose the automation API on
globalThis, so Playwright or Playwriter can drive them by evaluating in the page:await globalThis.gpuix.getByTestId('send').click() await globalThis.gpuix.getByTestId('composer').fill('hello') await globalThis.gpuix.clock.fastForward(200)
Two rules for a browser entry, both learned the hard way: never call
import.meta.hot.accept("./your-app", ...)in the entry file, because Bun runs the dependency-accept callback even when the module already self-accepted for Fast Refresh and the remount wipes every hook; and keep the@gpuix/nativeimport out of any Refresh boundary, because the Wasm half is a singleton andWebGpuixRenderer::initfails withGPUIX web is already running.Browser-specific fixes that landed with it: the debug frame overlay works on Wasm (
render(<App />, { debugFrameOverlay: 'full' })), macOS browsers get Option+Left / Option+Right word navigation in inputs, diagonal resize cursors point the right way, and GPUI Web's IME bridge is fully hidden so hostinputCSS can no longer unhide a stray text field at the top of the page.On iOS, touch pans emit scroll wheel events instead of mouse drags, so a swipe scrolls instead of selecting text. A tap does not start a selection, a long press followed by a drag still does, and a text input requests the software keyboard inside its tap handler so the keyboard opens on the composer and closes elsewhere.
-
New
highlightprop — paint a background wash behind matched or explicitly given text ranges. This is what you need for Ctrl+F, agent citations, or LSP diagnostic tints. Put it on any element and it applies to that subtree, so the root searches the window and a container searches only that container.<div highlight={{ query: 'fox' }}> <text>the quick brown fox</text> </div>
It reaches
<text>,<code>,<markdown>and<diff>with no extra props, because every string GPUIX paints goes through the same funnel.useTextSearchowns the cursor and the count, so a find bar needs no effects:import { useTextSearch } from '@gpuix/react' const search = useTextSearch({ query }) <text>{search.total === 0 ? 'No results' : `${search.active + 1}/${search.total}`}</text> <div onClick={search.previous}><text>↑</text></div> <div onClick={search.next}><text>↓</text></div> <div {...search.props} style={{ flex: 1 }}> <Transcript /> </div>
field meaning querysubstring to match, case-insensitive by default caseSensitiveexact case only wholeWordneither neighbour may be alphanumeric or _rangesexplicit [start, end)UTF-16 pairscolor/activeColorany CSS colour; defaults come from the theme activeIndexwhich match gets activeColor, for a find cursormatchIndexOffsetmatches before this subtree; only for virtualized content radiuscorner radius of the wash, default 2 Matches are non-overlapping and leftmost-first, and never cross a line, exactly like browser find. They do cross the several host nodes React creates for one interpolated line:
<text>Hello {name}!</text>is three host text nodes andHello Tommystill matches.activeIndexcounts matches in paint order, so it means the same thing whether a match sits in a<text>or inside a<code>block.A
<virtual-list>never builds off-screen rows, so the app supplies both numbers with the newfindRangesexport, which runs the same algorithm as the native matcher on a string you give it:import { findRanges, useTextSearch } from '@gpuix/react' const perRow = useMemo( () => rows.map((row) => findRanges({ text: row.text, query }).length), [rows, query], ) const search = useTextSearch({ query, matches: { total: perRow.reduce((n, count) => n + count, 0), indexOffset: perRow.slice(0, windowStart).reduce((n, count) => n + count, 0), }, })
A highlight is a quad, so
getPaintedText()cannot see it.renderer.getPaintedHighlights()reports the matched range in UTF-16 units plus the boxes it drew, one per visual row.Nothing resolves and nothing paints unless an element declares a
highlight. A root-scoped query over a 1000-turn chat costs about 2ms per keystroke, and moving the find cursor only re-colours matches it already found. -
<virtual-list>can mount a window of rows instead of all of them. The children form retains every child, so the first mount of a long transcript used to pay for every row. PassitemCountwithestimatedItemHeightandwindowStart, then render only that slice; native keeps the full logical length for the scrollbar.const WINDOW = 40 function Transcript({ turns }: { turns: Turn[] }) { const [start, setStart] = useState(0) const end = Math.min(turns.length, start + WINDOW) return ( <virtual-list itemCount={turns.length} windowStart={start} estimatedItemHeight={220} onVisibleRange={(event) => setStart(Math.max(0, Math.floor(event.startIndex ?? 0) - WINDOW / 4)) } > {turns.slice(start, end).map((turn) => ( <ChatTurn key={turn.id} turn={turn} /> ))} </virtual-list> ) }
onVisibleRangereportsstartIndexandendIndexafter a scroll. TypeScript now requiresestimatedItemHeightnext toitemCount, and native ignoresitemCountwithout it, because a row React has not mounted would otherwise measure as height 0 and collapse the scrollbar on a jump.There is deliberately no
VirtualListwrapper component. The window is application state. A generic wrapper cannot know when to widen its own window, so it silently dropped rows wheneveritemCountgrew without a scroll, which is exactly what a filter does. -
A prepended row is visible again. A list is anchored on a row, not a pixel offset, so inserting rows above the viewport used to slide the viewport down by the height of the new rows. A todo list or a feed that prepends the newest item never showed it.
scrolled down pinned to the top ┌──────────────────┐ ┌──────────────────┐ │ new row (above) │ ◄── inserted │ new row │ ◄── inserted, visible ├──────────────────┤ ├──────────────────┤ │ ░░ viewport ░░░░ │ stays put │ ░░ viewport ░░░░ │ follows the insert │ ░░░░░░░░░░░░░░░░ │ │ ░░░░░░░░░░░░░░░░ │ └──────────────────┘ └──────────────────┘A browser anchors the same way and suppresses it at
scrollTop: 0. A top-aligned list that is scrolled to the very top now stays at the top across a mutation. Scrolled anywhere else, the rows under the pointer still do not move. A history pane that loads older pages while the user reads should keep usingalignment="bottom". -
Automation can drag, hover, wheel, and hold modifiers. The protocol already carried the events, but nothing exposed them, so a drag or a pan could not be driven from a test.
await app.getByTestId('clip-7').dragBy(120, 0, { steps: 6 }) await app.getByTestId('clip-7-trim-end').dragTo(app.getByTestId('clip-8')) await app.getByTestId('canvas').wheel(0, 120, { modifiers: 'cmd' }) await app.getByTestId('row-3').hover() await app.mouse.drag({ x: 240, y: 500 }, { x: 700, y: 620 }) await app.mouse.wheel({ x: 700, y: 600 }, -140, 0) await app.mouse.down({ x: 100, y: 100 }, { button: 2 })
Call What it does locator.hover()Moves the pointer to the center, so hover styles and tooltips fire locator.wheel(dx, dy)One wheel event over the center locator.dragBy(dx, dy)/locator.dragTo(target)Press, travel, release locator.center()The center of the last painted bounds app.mouse.move / down / up / click / wheel / dragRaw pointer input in window coordinates A drag sends interpolated moves, not one jump, because snapping, live previews, and per-move commits only appear when the pointer travels. Every mouse call takes
modifiersin the same hyphenated syntax aspress('cmd-a'), so cmd-wheel zoom, shift-click range selection, and alt-drag duplication are testable.launch()can now scroll a live app,textContent()concatenates descendants like DOMtextContent, andclick({ button })really sends that button. Mouse input, locator bounds, and clock controls also work against a live app on Windows, Linux, and FreeBSD. -
<input>and<textarea>are reachable from the locator API.await app.getByTestId('composer').click() await app.getByTestId('composer').fill('hello gpuix')
bounds()andclick()threwElement has no painted bounds, because a custom element paints itself and the editor never attached the automation bounds tracker; the only workaround was a hard-coded pixel coordinate. In the browser,fill()andpress()threwGPUI browser input is unavailable: the client looked forinput[data-gpui-input], and zed-industries/zed#63201 replaced that element with a<textarea>. It now matches the attribute alone, exported asIME_MIRROR_SELECTOR.<img>,<svg>,<anchored>,<diff>and<markdown>do register painted bounds now too, so atestIdon<markdown>no longer returns null, andTestRenderer.findByTestId()resolves it from the retained tree. -
macOS apps have a menu bar, so
⌘Q,⌘H,⌥⌘H,⌘Mand⌘Wwork. GPUI never callsNSApplication.setMainMenu:, soNSApp.mainMenustayed nil, macOS painted nothing next to the Apple menu, and there was no way to quit a GPUIX app from the keyboard.Apple <executable> Window ├ Services ├ (AppKit window tiling) ├ Hide <appName> ⌘H ├ Minimize ⌘M ├ Hide Others ⌥⌘H ├ Zoom ├ Show All ├ Close Window ⌘W └ Quit <appName> ⌘Q └ (open windows)New
appNamewindow option for the name insideHide XandQuit X. It defaults totitle.render(<App />, { title: 'Todo', appName: 'Todo' })
appNamedoes not set the title of the application menu: macOS takes that from the executable, sobun app.tsxshowsbun. Only a real.appbundle changes it. There is no Edit menu on purpose, because AppKit consumes a menu key equivalent before the window sees it and⌘Cwould be taken away from text selection and from<input>. -
Pointer capture, like HTML.
onMouseMoveandonMouseUpcontinue after the pointer leaves the element that receivedonMouseDown, matchingsetPointerCapture. A clip, resizer, or slider keeps receiving events without a full-window overlay.<div onMouseDown={(e) => startDrag(e)} onMouseMove={(e) => moveDrag(e)} onMouseUp={() => endDrag()} />
Capture is armed only when the same node listens for down and move. A node with only
onMouseDown/onMouseUpdoes not capture, and a release outside still cancels the click, as in the DOM. -
The wheel reaches an ancestor scroller from under an absolutely positioned child, like a browser. Absolute and fixed boxes used BlockMouse, which ended the hit test, so a timeline clip or a graph node swallowed a pan gesture. Every filled or positioned
divnow uses BlockMouseExceptScroll: clicks and hovers stop, the wheel passes through.<div style={{ position: 'relative' }} onScroll={pan}> {/* the wheel over this clip now pans the surface behind it */} <div style={{ position: 'absolute', left: 240, width: 120, backgroundColor: '#38455C' }} /> </div>
Set
pointerEvents: "auto"on the rare element that must swallow the wheel too, such as a modal backdrop.<anchored>still occludes by default. An absolutely positioned box still takes clicks with no background, exactly like an empty positioneddivin a browser, so a wrapper that only carries a scroll offset should setpointerEvents: "none". -
<code>is a bare surface. It paints glyphs only: no fill, no border, no radius, no padding, no language header.styleis the surface, exactly like a<div>, so the card look belongs to your app instead of to the element.<code code={source} language="typescript" showLineNumbers style={{ padding: 12, borderRadius: 10, borderWidth: 1, borderColor: '#ffffff1f', backgroundColor: '#ffffff09', }} />
fontFamily,fontSize,fontWeight,lineHeightandcolorinstylenow beat the theme, and one resolver feeds the div text style, everyTextRun, and the fixed row height.style.lineHeightused to be dropped and clip tall glyphs; it re-sizes the rows instead.Migration:
showHeaderis gone. Render your own header in a wrapper. Fivetheme.metricsfields only ever styled that card and moved to themdCode*group, where they still tune the<markdown>fenced block:Before After codePaddingX/codePaddingYmdCodePaddingX/mdCodePaddingYcodeRadiusmdCodeRadiuscodeHeaderPaddingYmdCodeHeaderPaddingYcodeHeaderTextSizemdCodeHeaderTextSize<markdown>keeps its card: a document renderer owns its layout, a primitive does not. -
Syntax highlighting moved from Tree-sitter to Syntect, with Oniguruma on native. There is no Tree-sitter runtime and no per-language C grammar in the binary. Language detection is unchanged (fence tag, then path, then shebang), and token classes stay
HighlightKindvalues rather than baked-in colours, so a theme change recolours existing spans without a reparse.Syntect compiles every TextMate regex of a grammar the first time that grammar is used, on the frame thread, inside a paint. The engine decides how expensive that is:
grammar fancy-regex, first use Oniguruma, first use TypeScript ~133ms ~12ms Markdown ~39ms ~1.7ms Rust ~17ms ~1.6ms The chat example mount for 1000 turns goes from about 240ms to about 130ms, and the worst scroll frame from about 17ms to about 6ms. The browser Wasm build keeps the pure-Rust fancy-regex engine, because Oniguruma is a C library. Token colours can shift a little versus Tree-sitter, because Syntect scopes are not the old capture names.
-
A large mount is 4x faster and the retained tree is 5x smaller.
applyBatchused to build aserde_json::Valuetree, deep-clone every style payload out of it, and parse the clone a second time, so each style was allocated three times. The batch now deserializes straight from its JSON bytes into typed ops, and styles are shared by content: a 10,000-turn chat sends 59,320setStyleops carrying 90 distinct styles, and every element gets the sameArc.Measured on a 10,000-turn chat, 221,764 ops:
before after parse and apply 127.1 ms 30.1 ms heap churn 900.5 MB 104.0 MB allocations 1,476,196 186,090 retained tree 224.5 MB 42.6 MB bytes per element 3116 B 592 B A 5,000-row chat also stops rebuilding virtual-list focus maps on every GPUI frame, so sidebar motion and caret blink no longer pay that cost per tick, and custom-element props are only re-parsed when a retained value actually changes.
getAutomationTree()stops serializing style, events, and custom props, which took a 5k-row tree from about 110ms to about 22ms, sogetByTestId().click()is no longer dominated by encoding unused style maps. -
The install is about 8x smaller.
@gpuix/nativepacked every platform binary into the main tarball through a*.nodeglob, on top of the six per-platform packages thatoptionalDependenciesalready 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 544KOnly the loader, the types, the browser entry, and the Wasm build ship in the main package now. Nothing changes at runtime.
-
Fixed Windows x64 native binding failing to load with
ERR_DLOPEN_FAILED. The published.nodestatically importedTaskDialogIndirectfrom comctl32 v6 andu_strlenfromicuuc.dll. Node and Bun do not activate comctl32 v6, so Windows resolved the old comctl32 andLoadLibraryfailed before any JS ran.bun -e "require('@gpuix/native'); console.log('OK')" -
Every CSS
cursorkeyword GPUI can paint is supported, not justpointeranddefault. Resize and drag cursors are what tell a user that an edge can be trimmed or a clip can be grabbed; until nowcol-resizewas silently dropped.<div style={{ cursor: 'grab', active: { cursor: 'grabbing' } }} /> <div style={{ cursor: 'col-resize' }} />
Group Keywords Pointing default,auto,pointer,context-menu,not-allowed,no-dropText text,vertical-text,crosshairDragging grab,grabbing,move,all-scroll,alias,copyResizing col-resize,row-resize,ew-resize,ns-resize,nwse-resize,nesw-resize,n-resize,e-resize,s-resize,w-resize,ne-resize,nw-resize,se-resize,sw-resizecursoris a typed union, so an editor completes the list. An unlisted keyword is ignored, like any other invalid style value. -
Colour strings accept the full csscolorparser grammar across styles, themes, pseudo-states, selection colours, SVG tint, borders, and shadows: modern RGB/HSL/HWB, HSV, LAB/LCH, OKLab/OKLCH, named colours,
transparent, alpha,none, and limited relative-colour forms. TypeScript types are unchanged. -
More style properties reach the GPU. Per-side border widths and one structured
boxShadowwith offset, blur, spread, and colour are new. Per-corner radii,flexBasis, andalignContentwere already declared in the public style type but never applied; they work now. -
New
onAuxClickfor the non-primary mouse buttons.onClicknever fired for a right or middle click, so theisRightClickfield it documents could never betrueand a context menu had no event to hang on.onClickstays primary-only, like the DOM.<div onClick={() => select(item)} onAuxClick={(event) => { if (event.isRightClick) openContextMenu(event.x, event.y) }} />
onMouseDownandonMouseUpstill see every button throughevent.button:0left,1middle,2right. -
Window geometry hooks are pull-based.
useWindowSize()seeded state with a hardcoded800x600and read the renderer once from an effect, so a first read before the platform window had a size kept800x600forever, and a resize was never observed at all. It samples every 100 ms now and only rerenders when the numbers change.New
getWindowInsets()anduseWindowInsets()report system and software-keyboard geometry, so a composer can stay above the iOS keyboard instead of hiding behind it:const { keyboardTop, keyboardVisible, ime } = useWindowInsets() return ( <div style={{ paddingBottom: ime.bottom }}> {keyboardVisible ? `Keyboard starts at ${keyboardTop}px` : 'Keyboard closed'} </div> )
Field Meaning imeEdges covered by the software keyboard safeAreaEdges covered by notches, status bars, home indicators effectivePer-edge max of the two, the region content should avoid keyboardTopY coordinate where the keyboard starts keyboardVisibleime.bottom > 0visibleHeightWindow height minus the effective top and bottom Both hooks take the same option, because Safari fires
visualViewportevents in bursts while the keyboard animates and iOS reports stale values on some of them:useWindowInsets() // 100ms, the default useWindowInsets({ intervalMs: 250 }) // slower useWindowInsets({ intervalMs: false }) // read once, never poll
-
One diagonal gesture scrolls both axes, and
position: "fixed"lays out.overflow: "scroll"moved only one axis per wheel event, because GPUI zeroes the smaller of the two deltas by default.<div style={{ width: 260, height: 220, overflow: 'scroll' }}> {/* one diagonal swipe now pans on X and Y together */} </div>
A flex column stretches its children to the cross axis, so rows in a two-axis container still need to state a width, or there is nothing to pan on X.
position: "fixed"blocked hits likeabsolutebut stayed in normal flow, so a box drifted when its siblings changed; it now lays out likeabsolute. -
Text selection starts from the empty space before the glyphs. A press in parent padding, a code gutter, or the empty start of a line clamps to the nearest text on that row, instead of requiring the mouse-down to land inside the tight text box.
[padding] hello world ^ press here, drag right → "hello world"A press above or below every line still does not start a selection, so a composer or titlebar cannot claim the nearest paragraph, and
userSelect: "none"now also blocks the start.Copying across interpolated text is fixed too.
<text>Hello {name}!</text>is three painted runs of one line, and selecting across them used to copy them joined with newlines. Runs now carry the parent host element they belong to, so the same selection yieldsHello Tommy!, while<code>,<diff>and<markdown>keep one line per line. -
Fixed
keyon every GPUIX element. A list built with.map()failed to typecheck, so any real app broke on the firsttscrun:error TS2322: Type '{ key: string; ... }' is not assignable to type 'Props'. Property 'key' does not exist on type 'Props'.keylives onPropsnow, next toref. It cannot live onJSX.IntrinsicAttributes, because TypeScript 5 ignores that member for intrinsic elements. Every element prop type extendsProps, so<div>,<text>,<img>,<svg>,<canvas>,<input>,<textarea>,<anchored>,<code>,<diff>,<markdown>and<virtual-list>acceptkeyagain, and so domotion.div, Select, Combobox and Tooltip.@gpuix/react/jsx-dev-runtimetypes also match the runtime file now: they re-exportedjsxandjsxsfromreact/jsx-dev-runtime, which exports onlyjsxDEV. -
Abandoned concurrent renders stay out of the native mutation queue. React may throw away a Suspense render. GPUIX waits until commit before it creates native elements, so fallback text paints and abandoned text does not. Unchanged click handlers also stay registered across rerenders, because the whole handler map is no longer cleared before every update.
-
Each React root owns its event handler map. Two
createTestRoot()trees can both start at id1without overwriting each other's handlers, and a remount on the same native renderer keeps allocating new ids, so a late event from the old tree cannot hit a new handler that reused id1.Migration:
resetIdCounter()is gone, andhandleGpuixEventneeds the renderer that produced the event:handleGpuixEvent(event, renderer)
-
Native
<markdown>wraps in flex columns. A markdown node in a flex row kept its max-content width, so a long paragraph or list item blew past the parent. The root and each text block shrink withmin-width: 0now, and a fenced block inside<markdown>matches<code>: long lines scroll on X and leave the vertical wheel on the parent.<div style={{ display: 'flex', flexDirection: 'row', width: 280 }}> <div style={{ width: 40, flexShrink: 0 }} /> <markdown source="- a long sentence that must wrap in the remaining column" style={{ flexGrow: 1 }} /> </div>
-
The test renderer runs on Windows through GPUI's DirectX renderer:
TestGpuixRenderer,createTestRoot(), native input simulation, and PNG screenshot capture. A live window can callcaptureScreenshot()there too. Linux stays unavailable until GPUI ships its pending wgpu headless renderer.The test app also releases its custom elements before the GPUI app goes away.
<input>keeps a GPUI entity handle, and GPUI's leak detector panics if one outlives the app, which killed the whole vitest worker on Windows after every test in the file had already passed.createTestRoot({ width, height })does not size the window on Windows yet: it opens at the display size. Tracked in #21.createTestRoot()can also size the offscreen window, which was always 1280x800. That is wide enough to keep a centeredmaxWidthcolumn at its cap, so any layout that only changes below a breakpoint was invisible to the suite.const narrow = createTestRoot({ width: 640, height: 480 }) createTestRoot({ width: 640 }) // 640 x 800 createTestRoot({ width: 0 }) // throws: must be a positive, finite number
-
New
getDebugFrameOverlayStats()so tests and apps can read the same draw times the on-screen overlay shows.renderer.resetDebugFrameOverlayStats() // ... scroll or click ... const stats = renderer.getDebugFrameOverlayStats() // stats.currentMs, stats.p90Ms, stats.p99Ms, stats.maxMs, stats.frames, stats.samples
p90Msis the overlay 10% line andp99Msis the 1% line: the slow tail, not the fast frames.On macOS,
THROTTLE=utilityrestarts a run undertaskpolicy -c utility, which pins work to E-cores as an M1/M2 Air CPU proxy.backgroundandmaintenanceare slower. GPU and RAM stay on the host machine, so this is not Chrome 6x, and it should not be set in CI.THROTTLE=utility bun run test chat.perf.test.tsx THROTTLE=utility bun --hot chat.tsx -
A Quickstart and a todo starter app. The README described the architecture and the mutation protocol before it ever said how to install the packages, and never mentioned
jsxImportSource, which is required: without it TypeScript falls back to DOM types and<virtual-list>,<markdown>,<code>andstyle.hoverall fail.bun add @gpuix/react react bun add -d @types/react typescript
{ "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 devDesktop app with hot remount bun run buildStandalone binary in dist/todobun run web:devBrowser build served with isolation headers bun run screenshotDrives the app through the automation client bun run testVitest against the GPU test renderer bun run typechecktsc --noEmitIt shows
<virtual-list>, a native<input>,motion.div, tinted<svg>icons, nativehoverandactive, andtestIdautomation hooks. Copy the folder, change@gpuix/reactfromworkspace:^to a version range, and runbun install. -
A video-editor timeline example, to answer whether GPUIX can carry a real editing surface. It drags clips between tracks, trims both edges with snapping, scrubs a playhead, marquee-selects, zooms under the pointer, and pans on both axes with a frozen ruler and a frozen track column.
cd examples && bun --hot timeline.tsx
Two patterns in it are worth copying. React owns the scroll offset: a native
overflow: "scroll"grid cannot drive a frozen header, because GPUI moves the grid on the wheel frame and theonScrollcallback arrives a frame later, so the two tear apart during a fast pan. A drag needs no overlay: each clip and trim handle listens foronMouseDown,onMouseMoveandonMouseUp, which arms pointer capture, so a release past the window edge still ends the gesture, while an overlay mounted on the press cannot arm anything.A pannable surface also has to cull. On 3,259 clips across 26 tracks, one wheel frame costs 7.7ms culled and 92ms with
memoalone. -
GitHub releases include a standalone chat example executable for each platform. No Node, Bun, or Rust install is required.
chmod +x example-chat-aarch64-apple-darwin ./example-chat-aarch64-apple-darwin
macOS may block the unsigned binary the first time: right-click the file, choose Open, and confirm. On Windows, download
example-chat-x86_64-pc-windows-msvc.exeand double-click it. -
@gpuix/nativeand@gpuix/reactare published as Apache-2.0. Both packages declarelicense: Apache-2.0and ship the license text in the npm tarball. GPUI itself is Apache-2.0, so this matches the native dependency. -
Smaller fixes:
destroyElementno longer leaves a dangling child id on the parent or skips invalidating the parent chain, so a cache keyed on the subtree revision cannot serve text that left the tree; automation calls afterclose()are rejected and shutdown is idempotent across the in-process and SSE backends.
Closes #1, #2 (Windows native binding failed to load) and #20 (mouse move and up were dropped after a mouse-down rerender).
Known gap: createTestRoot({ width, height }) does not size the window on Windows yet, tracked in #21.