Skip to content

feat: css value styles, linear gradients and motion to auto height - #1

Closed
mateo-m wants to merge 55 commits into
main-basefrom
feat/css-values-and-classname
Closed

mateo-m wants to merge 55 commits into
main-basefrom
feat/css-values-and-classname

Conversation

@mateo-m

@mateo-m mateo-m commented Aug 26, 2026 •

Copy link
Copy Markdown
Owner

What this adds

The commits of feat/css-values-and-classname:

  • One css parser for style values, and one resolve pass per style.
  • width and height accept css lengths.
  • Linear gradients, corner-shape and cursor keywords.
  • Motion animates height to auto, measures the content at the resolved width, and collapses from the height auto reached.
  • render takes root options and exposes the renderer.
  • A demo of css values, classes and motion.
  • Fixes: cmd-c copies the selection, key works on a built-in tag, and compiled example apps get signed.
  • The branch tracks upstream main through a merge commit, so the diff against main stays this work only.
  • The zed submodule points at this fork.

What I ran and saw

  • cargo test in packages/native at this branch: 291 passed, 0 failed.
  • The react suite at this branch: 414 passed, 0 failed. The motion stall the first version of this PR described is gone. It came from a pointer capture that bound to the wrong hitbox and from lineHeight: 20 read as a multiple. Both fixes are in this branch, with tests.
  • A JS lineHeight number is pixels, as upstream reads it. Only a string gets the css multiple reading. A unit test covers each reading.

What I did not verify

  • Windows and Linux. Every run above was on macOS. CI builds the other targets.

Fix `hasNativeTestRenderer` always reporting false for anyone who
installs the package, found by copying `example-app/` out of the
repository and installing `@gpuix/react` from npm:

    Test Files  1 skipped (1)
         Tests  6 skipped (6)

`testing.js` ships as ESM and loaded the addon with a bare
`require("@gpuix/native")`. Node has no `require` in ESM. Inside this
repository vitest inlines the workspace package and provides one, so
every suite here passed; installed from npm the package is externalized
and run by Node, the call threw `require is not defined`, and the catch
reported native as missing. Every guarded suite then skipped silently,
which is worse than failing.

It loads through `createRequire(import.meta.url)` now. Verified by
dropping the built file over the installed 0.5.0 package in that copy:
the 6 tests run, and `bun run screenshot` drives the real window.

Session: ses_fc0b7cd62ffe3aBw0pyYBOBDjf

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The zed submodule URL is switched to SSH in .gitmodules, which is likely to break CI submodule checkout (and new clones) without additional SSH-key setup.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR expands GPUIX’s styling and motion capabilities across the React reconciler and native renderer by introducing CSS-value parsing (including variables and modern color syntaxes), a className resolver seam, and motion support for animating height to/from auto, along with new tests and a demo app to exercise the features end-to-end.

Changes:

  • Add a className resolver option on roots and route class-derived styles through the reconciler’s style send path.
  • Improve native style handling/performance (boxed styles, cached resolutions) and add motion height: auto measurement via isolated layout.
  • Add comprehensive tests + a new demo app showcasing CSS values (colors/lengths/vars/gradients/corners), inheritance, class names, and motion.
File summaries
File Description
packages/react/tsconfig.typecheck.json Adds a dedicated typecheck config to include type-only assertion files.
packages/react/src/testing.ts Extends test renderer helpers (clipboard read, style-resolution counters, pixel sampling, root options).
packages/react/src/reconciler/renderer.ts Extends render() options to include root options (e.g., class resolver).
packages/react/src/reconciler/reconciler.ts Adds RootOptions to createRoot and stores renderer on the returned Root.
packages/react/src/reconciler/host-config.ts Centralizes style computation and adds className + inline-style merging; fixes hide/unhide style behavior.
packages/react/src/reconciler/class-names.ts Implements className token/string caching and inline override semantics.
packages/react/src/index.ts Exports ClassNameResolver and RootOptions types.
packages/react/src/tests/style-types.check.ts Adds type-level assertions for StyleDesc (custom props, field types, state rules).
packages/react/src/tests/style-resolution-cache.test.tsx Adds native tests asserting style-resolution caching behavior across frames/updates/motion.
packages/react/src/tests/sizing.test.tsx Adds screenshot/geometry tests for width/height/min/max accepting CSS lengths, vars, calc, etc.
packages/react/src/tests/selection.test.tsx Adds cmd-c copy test and updates lineHeight cases to length strings.
packages/react/src/tests/render.test.tsx Tests passing a class resolver through render().
packages/react/src/tests/jsx-types.check.tsx Adds JSX type assertions (including key on intrinsic tags).
packages/react/src/tests/input.test.tsx Tests clipboard behavior for input selection vs document selection.
packages/react/src/tests/inheritance.test.tsx Adds screenshot tests pinning inherited text properties behavior.
packages/react/src/tests/css-lengths.test.tsx Adds screenshot tests for CSS lineHeight semantics and calc folding.
packages/react/src/tests/color-functions.test.tsx Updates color parsing expectations (lightningcss-based, drops nonstandard syntaxes).
packages/react/src/tests/class-names.test.tsx Adds screenshot tests verifying className output matches equivalent inline styles.
packages/react/package.json Adds a typecheck script for CI.
packages/react/jsx-runtime.d.ts Fixes nodenext import path and adds key support on intrinsic tags via Tag<P>.
packages/react/jsx-dev-runtime.d.ts Simplifies dev-runtime typings to re-export JSX namespace from jsx-runtime.
packages/native/src/theme.rs Adds dark to Theme, derives PartialEq for Theme/SyntaxPalette, narrows diff-layout hashing semantics/tests.
packages/native/src/text/paint.rs Moves copy handling to keystroke observation and adds optional cursor styling for selectable text.
packages/native/src/text/mod.rs Re-exports the new copy keystroke watcher.
packages/native/src/test_renderer.rs Adds style-resolution counters, clipboard read, pixel sampling, and refactors screenshot capture; updates style parsing call.
packages/native/src/retained_tree.rs Boxes style storage, adds resolved-style/cascade caches, and ensures empty styles don’t create false changes.
packages/native/src/renderer/virtual_list.rs Introduces retained virtual list state/config syncing logic.
packages/native/src/renderer/batch.rs Adds atomic batch parsing/validation and shared apply logic.
packages/native/src/renderer/auto_height.rs Implements measured-layout wrapper for animating height to/from content (“auto”).
packages/native/src/lib.rs Splits event payload types into events and makes style module public.
packages/native/src/events.rs Defines Rust→JS event payload module (renamed/relocated).
packages/native/src/custom_elements/mod.rs Adds cascade to custom render context and centralizes style application via ctx.styled().
packages/native/src/custom_elements/markdown.rs Uses ctx.styled() to apply styles with correct variable scope.
packages/native/src/custom_elements/input.rs Uses ctx.styled() for consistent style/cascade application.
packages/native/src/custom_elements/img.rs Uses ctx.styled() for consistent style/cascade application.
packages/native/src/custom_elements/diff.rs Ensures borderRadius reads through cascade scope; uses ctx.styled().
packages/native/src/custom_elements/code.rs Uses ctx.styled() for consistent style/cascade application.
packages/native/src/custom_elements/anchored.rs Uses ctx.styled() for consistent style/cascade application.
packages/native/src/color.rs Switches native color parsing to the new CSS engine (gpuix-css/lightningcss) and adds context-aware parsing.
packages/native/index.d.ts Updates generated TS declarations for new test renderer APIs.
packages/native/css/src/color.rs Adds GPU-independent CSS color parsing crate using lightningcss with contextual resolution.
packages/native/css/Cargo.toml Declares the new gpuix-css crate and its lightningcss dependency.
packages/native/Cargo.toml Adds gpuix-css and image deps; removes csscolorparser; adds style parse benchmark target.
packages/native/benches/style_parse.rs Adds benchmark measuring StyleDesc JSON parse performance.
examples/tsconfig.json Broadens example TS include patterns.
examples/package.json Adds demo run/compile scripts and updates compile invocation args.
examples/demo/variables.tsx Demo panel for CSS custom properties and var().
examples/demo/ui.tsx Shared demo UI components implemented via className.
examples/demo/perf.tsx Demo performance panel using frame overlay stats + list virtualization example.
examples/demo/lengths.tsx Demo panel for CSS lengths, calc/min/max/clamp, and dropped units.
examples/demo/inheritance.tsx Demo panel for inherited text props, currentColor, and selection inheritance.
examples/demo/gradients.tsx Demo panel for linear gradients.
examples/demo/corners.tsx Demo panel for corner shapes + motion interpolation.
examples/demo/colors.tsx Demo panel for modern CSS color syntaxes including mix/relative/light-dark/currentColor.
examples/demo/classes.ts Example class token resolver (tailwind-like) + counter.
examples/demo/class-names.tsx Demo panel illustrating className caching/override semantics.
examples/demo/app.tsx Demo app shell with sidebar navigation + palette swapping via custom properties.
examples/demo.tsx Entry point wiring demo app with render() + resolveClassName.
examples/demo.test.tsx Integration tests driving the demo app/panels via native test renderer and asserting key behaviors.
examples/compile.ts Generalizes compile script (args parsing, optional icons, macOS codesign) and supports multiple apps.
examples/chat.test.tsx Updates expectations for retained style reporting (e.g., width now reports '100%').
CONTEXT.md Adds shared vocabulary/terminology documentation for the codebase.
AGENTS.md Expands contributor guidance (commit message rules, etc.).
.gitmodules Updates zed submodule URL/branch.
.github/workflows/ci.yml Adds React typecheck step to CI.
.changeset/split-the-renderer-by-subject.md Changeset documenting renderer module split.
.changeset/sizes-read-css-lengths.md Changeset documenting sizing props accepting CSS lengths.
.changeset/resolve-styles-once.md Changeset documenting resolved-style caching + visibility/hide fixes.
.changeset/render-takes-a-class-resolver.md Changeset documenting passing resolver via render() and exposing renderer on Root.
.changeset/read-styles-without-buffering.md Changeset documenting faster StyleDesc parsing and boxed storage.
.changeset/read-colours-with-lightningcss.md Changeset documenting move to lightningcss for color parsing and syntax changes.
.changeset/motion-keeps-the-style-cache.md Changeset documenting keeping style cache stable during motion.
.changeset/linear-gradient-backgrounds.md Changeset documenting linear-gradient background support.
.changeset/key-on-a-built-in-tag.md Changeset documenting intrinsic key typing + nodenext import fix.
.changeset/custom-properties-and-var.md Changeset documenting custom properties, var(), currentColor, and numeric fields as string/number.
.changeset/cursor-keywords.md Changeset documenting cursor keyword support + I-beam over selectable text.
.changeset/css-lengths-and-calc.md Changeset documenting CSS lineHeight semantics + calc support in lengths.
.changeset/corner-shape.md Changeset documenting corner-shape support and motion interpolation semantics.
.changeset/copy-selection-with-cmd-c.md Changeset documenting cmd-c copy behavior change.
.changeset/close-a-height-from-where-it-is.md Changeset documenting smooth collapse behavior for height:auto animations.
.changeset/class-name-prop.md Changeset documenting className prop and resolver seam.
.changeset/bend-toward-content-that-grows.md Changeset documenting motion bending when content changes mid-animation.
.changeset/animate-height-to-auto.md Changeset documenting animating height to/from auto via measurement.
Review details
  • Files reviewed: 98/103 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .gitmodules
Comment thread packages/react/src/reconciler/renderer.ts
Comment thread packages/react/src/testing.ts Outdated
remorses and others added 7 commits August 26, 2026 21:50
Three changes to the release pipeline, all found while cutting 0.5.0.

**One target per OS.** The matrix built six targets; it now builds the
architecture each OS is mostly used on and nothing else:

| OS      | Target                    | Renderer      |
|---------|---------------------------|---------------|
| macOS   | aarch64-apple-darwin      | Metal         |
| Linux   | x86_64-unknown-linux-gnu  | Vulkan / wgpu |
| Windows | x86_64-pc-windows-msvc    | Direct3D      |

Every extra target is a full gpui build, and gpui is most of the wall
clock of this workflow, so a release was paying twice over for platforms
almost nobody installs. Intel macOS, arm64 Linux and arm64 Windows lose
their prebuilt binary. `napi.targets` is trimmed to match; the two lists
must stay identical or the published loader looks for a platform package
CI never built. Both test jobs already pull targets that stay.

**The chat example gets an extension.** A GitHub release asset is served
as raw bytes, so a download lost the executable bit and, on macOS and
Linux, arrived with no extension at all. The build job now packs those
two into `example-chat-<target>.tar.gz`, which keeps the mode and names
itself. Windows keeps its `.exe`. Verified end to end with the real
0.5.0 macOS binary: pack, drop the mode to 644 like a browser download,
`tar -xzf`, and the extracted file is `-rwxr-xr-x` and opens its window.
The download also drops from 83 MB to 33 MB.

**Create the GitHub release before CI reaches the publish job.** The
upload step exits 0 when no release exists for the tag, so a release
created afterwards gets no binaries, and the only way to add them later
is to move ~100 MB through a laptop. There is no API that attaches an
artifact to a release: the assets endpoint wants the raw bytes in the
request body, and `gh release upload` cannot read stdin (cli/cli#5820).
AGENTS.md now spells out the order, and what to do about the tags when
CI fails and you push fixes.

Session: ses_fc073cf53ffem2NBtBXPFaCgyF
My previous commit used `git add -A` and swept in `website/docs.json`
and `website/src/changelog/intro.mdx`, which another session wrote
while I was working. They are not part of the CI change.

This restores both to their state before that commit and leaves the
work on disk, untouched: docs.json is a modified working tree file
again and intro.mdx is untracked again, so whoever wrote them can
commit them with their own message.

Session: ses_fc073cf53ffem2NBtBXPFaCgyF
Replace the stale pending-validation note with the result that CI now runs the full React and example suites through the DirectX-backed Windows test renderer.

This matches the successful Windows validation added for issue remorses#26 and released in 0.5.1.

Session: ses_fc05758b6ffevGYCjaV7pxsfuO
Replace the single docs sidebar with a tabbed navigation that separates the
main documentation from generated release notes. Keep GitHub and npm as
link-only tabs so all top-level destinations use one consistent navigation
model and icon set.

Add introductory content for the changelog tab. It explains that native and
React packages ship together from one commit and points readers to the
prebuilt chat binaries attached to each release. Holocron uses this page as
the stable introduction before the release entries fetched from GitHub.

This commit restores the website-only work that was deliberately left in the
working tree by a906cf9 after it was accidentally included in an unrelated CI
commit.

Session: ses_fc04fcb1dffeooxK1KAopXSTAY
Build the browser chat entry as part of the documentation website's production
build. The web build now emits a minified `chat.js` bundle and its Wasm asset
into the website's ignored public directory, after rebuilding the native Wasm
and React package so deployed output cannot use stale generated code.

Keep the production destination fixed at `website/public/chat-example` rather
than accepting a caller-controlled directory. The build clears that generated
directory before bundling without allowing a malformed command to remove an
arbitrary repository path.

Serve the shared example HTML at `/chat-example/`, redirect the non-trailing
slash form, and set COOP and COEP on the document. Those headers make the page
cross-origin isolated, which the shared WebAssembly memory requires. Static
JavaScript and Wasm stay same-origin under the same route prefix.

Add an accessible loading screen while the browser downloads and initializes
the renderer, then remove it after GPUIX mounts. The same shell remains usable
for local browser development, while the production route swaps its TypeScript
entry for the generated JavaScript asset.

Ignore the generated website bundle and add a minor changeset for the new live
example at https://gpuix.dev/chat-example/.

Session: ses_fc04fcb1dffeooxK1KAopXSTAY
Normalize CRLF when the code prop enters the native element so syntax
highlighting, painting, selection, and clipboard output all use the same
LF-only source.

Add GPU-backed regression coverage for painted rows and cross-line selection.
Preserve the final empty row produced by a trailing newline.

Validation:
- native release build
- 201 Rust library tests
- 342 React renderer tests
- 36 example application tests

Session: ses_fc057a79fffeOPMjeaNqNgpvHd
@mateo-m mateo-m closed this in ac0426c Aug 26, 2026
mateo-m and others added 26 commits August 27, 2026 01:44
`render()` takes `focus` and `show`. `focus: false` opens the window behind
whatever app you were typing in, like `open -g`. `show: false` opens nothing
at all, so the process boots with a live React tree and an empty screen.

    render(<App />, { title: 'Notes', focus: false })

**Two knobs, not one**

`gpui::WindowOptions` already had `focus` and `show`, and `to_gpui_window_options`
simply never set them. But mapping the fields alone does nothing, because
`init()` called `cx.activate(true)` unconditionally on both init paths.

On macOS the window flag only decides whether the window becomes *key inside*
the app; activation is what pulls the whole process forward. Both had to move
together, so `activate` is computed before the closure (the options are moved)
and the call is gated on it.

**Revealing it later**

`activateWindow()` brings the window forward and focuses it. It is the only way
to reveal a `show: false` window. macOS goes through `update_window`, the
threaded platforms through a new `UiCommand::ActivateWindow`. It runs
`cx.activate(true)` alongside `Window::activate_window()`, because the latter
only makes the window key within the app.

**Platform support**

Straight from GPUI's `WindowParams`:

| Platform | `focus: false`                     | `show: false` |
|----------|------------------------------------|---------------|
| macOS    | orders in front without becoming key | honored     |
| Windows  | `SW_SHOWNOACTIVATE`                | honored       |
| Linux    | ignored                            | ignored       |

The macOS Dock icon still appears. `did_finish_launching` hardcodes
`NSApplicationActivationPolicyRegular`, so a menu-bar-agent app would need a
`remorses/zed` fork commit. Searched upstream first per AGENTS.md: no issue and
no PR asks for a configurable policy, and the only code hit is that hardcoded
call. Left for a follow-up.

**Verified**

Five new `window_options_tests` cover the mapping, including that an unset
option still defaults to true and that the existing fields keep mapping.

Focus stealing is not unit-testable, so I drove real windows and read the
frontmost process id and the AppKit window count through `osascript`:

    default                   frontmost=app       windows=1
    focus: false              frontmost=other     windows=1
    show: false               frontmost=other     windows=0
      after activateWindow()  frontmost=app       windows=1

`cargo test --lib` 137 pass, `packages/react` 302 pass, `examples` 35 pass.
`x86_64-unknown-linux-musl` cross-builds, which type-checks the threaded
branch; Windows cannot cross-compile here because `onig_sys` needs Windows C
headers, and CI covers it.

Session: ses_fc17399a8ffeTt72KZetsU15ty
`focus: false` shipped in the previous commit with no guidance on when to
reach for it. The answer is: whenever an agent starts the app. An agent that
launches the window to check its own work takes the keyboard away from
whoever is typing, mid-sentence, once per iteration, and it gains nothing by
doing so.

**README**

The `Background launch` section now leads with that rule, and a new
`Let an agent drive the app` section documents the environment-variable
pattern so a human run is unchanged and an agent run stays out of the way:

    render(<App />, { focus: process.env.GPUIX_BACKGROUND !== '1' })

    bun app.tsx                      # you: window comes to the front
    GPUIX_BACKGROUND=1 bun app.tsx   # agent: opens behind your editor

**AGENTS.md**

The same rule as an instruction, in `Drive the live window`, so an agent
follows it without being asked.

**examples/chat.tsx**

The AGENTS.md snippet launches `chat.tsx` with `GPUIX_BACKGROUND=1`, but
`chat.tsx` read no such variable, so the documented example would have
silently kept stealing focus. Its `render()` now honours it. This is the app
AGENTS.md tells agents to launch, so it is the one that most needs the flag.

**A claim I had to retract**

The first draft said typing works on a background window. It does not.
`fill()` and `press()` throw `keystrokes are not live yet`, because the live
renderer implements no `simulateKeystrokes`. That is a pre-existing `launch()`
gap and is unrelated to focus; it fails on a focused window too. All three
documents now say so and point at `createTestRoot()` for anything that types.

**Verified rather than assumed**

Every claim was run against a real launched window, then the probe deleted:

    click() on an unfocused window        works, count=2 after two clicks
    screenshot() on an unfocused window   works
    both on a show:false window           works, never on screen
    focus not stolen                      Chrome stayed frontmost
    fill() / press() on a live process    throws

The exact AGENTS.md snippet was then run end to end: it launched `chat.tsx`,
clicked `sidebar-collapse`, wrote a screenshot of the fully painted app, and
left Chrome frontmost throughout.

`examples` 36 tests pass.

Session: ses_fc17399a8ffeTt72KZetsU15ty
Export the public host instance type for typed element refs.

Add a bounded virtual-list chat example with cursor pagination in both directions, stable prepends, variable-height Safe MDX rows, route links, loading states, and retained-page limits.

Include focused coverage for initial rendering, route navigation, paging to both ends, stable retained rows, and request termination at history boundaries.

Session: ses_fbcc7a504ffe9Ff4lP0KDJLp3B
An older page landing while the reader waited in the loading row moved the
screen. Three stacked causes, three layers of fix:

1. gpui anchors a list on the item at the viewport top. While the reader
   waits in the void that item IS the void, so the splice replaced the
   screen under it. The old restore (`scrollToItem(listId, index)`) snapped
   the anchor message to the viewport top with offset 0, itself a jump of
   up to EDGE_HEIGHT pixels. No non-negative ListOffset can express "keep
   this row where it is": the rows above it are freshly inserted and
   unmeasured. The gpui fork (submodule commit 8b94defe56) now accepts a
   negative `offset_in_item` in `ListState::scroll_to`; `layout_items`
   resolves it by walking upward with real measured heights, which is what
   makes the restore exact rather than estimate-based.

2. On the live renderer `scrollToItem` applied `scroll_to` immediately,
   but the just-committed prepend only reaches the ListState at the next
   `GpuixView::render`, so `splice_focusable` shifted the freshly restored
   anchor a second time. The test renderer hid this because its wrapper
   flushes first. Virtual-list scrolls are now queued in
   `PENDING_VIRTUAL_LIST_SCROLLS` and applied right after that frame's
   splice, then the queue is cleared (one-shot).

3. Scrolling DOWN jumped for a different reason: a reader resting at the
   bottom sits on gpui's at-end sentinel (`logical_scroll_top` is None,
   reported as itemIndex == item count), where any offset-in-void math is
   meaningless. `getListScrollTop` returns
   `[itemIndex, offsetInItemPx, viewportHeightPx]`; the app converts the
   sentinel with `EDGE_HEIGHT - viewportHeight` so the void's old top edge
   stays put and the new page fills the blank space below the text.

New API on all four surfaces (napi, UiCommand, wasm, test renderer):

- `scrollToItem(listId, index, offsetInItem?)` — px, may be negative
- `getListScrollTop(listId)` — logical anchor plus viewport height

`examples/infinite-chat.tsx` drops the snap-to-top workaround and restores
both directions. The loading row shrank from 560px to 160px.

Tests, each verified to fail against the previous behaviour:

- examples: "keeps the message under the reader at the same pixel when an
  older page lands", "keeps the trailing messages at the same pixel when a
  newer page lands", "lands a newer page inside the void the reader is
  staring at"
- packages/react: scrollToItem offset roundtrip, negative-offset
  normalization ([50,-100] over 40px rows resolves to [47,20]), top clamp

Session: ses_fbc66676dffeMDwZ3jJhimdkw6
`bun scripts/web.ts` now registers one bundled document per example:
`/` keeps the chat and `/infinite` serves the infinite history. The
shared cross-origin isolation headers wrap each document the same way.

The inline page styles move from web.html into a shared web.css that
both documents import, and web-infinite-chat.{html,tsx} are the browser
entry pair for the example. bun.lock picks up string-dedent for the
example's seeded message generator.

Session: ses_fbc66676dffeMDwZ3jJhimdkw6
GPUI keeps hover, active, pointer capture, implicit scroll, the accessibility
node and its own per-element state behind a `GlobalElementId`. An element with
no `.id(..)` has none of that, and the failure is always silent: the prop
type-checks, the value crosses the bridge, and nothing happens.

Three holes, all invisible from JavaScript:

| You write | What happened |
|---|---|
| `<text onClick={..}>` | listener registered, never fired |
| `<code style={{ hover: {..} }}>` | style serialized, then dropped |
| `<img src="cat.gif" />` | frozen on frame 0 |

`<text>` had its own builder. It made a bare `gpui::div()` with no id and wired
none of the shared `Props` surface, so `onClick`, `onMouseEnter`, `onKeyDown`,
`autoFocus`, `tabIndex` and pointer capture were all accepted and ignored.
`build_div` already did everything `<text>` needs, so it is renamed
`build_host_container` and both types go through it. `build_text` is deleted.

`StyleDesc` carries `hover` and `active` for every element type, but only
`build_div` read them. New `apply_interactive_styles` applies the base styles
plus both refinements, and every stateful GPUI root uses it now. The one
exception is `<virtual-list>`, whose `style` type drops them: gpui's `List` has
no interactive identity to hold a pressed or hovered state.

`wire_standard_events` moves out of `code.rs` into `custom_elements/mod.rs` and
grows into `custom_surface`, which does styles, bounds and events in one call
for `<code>`, `<markdown>`, `<diff>` and the image fallbacks.

`<img>` and `<svg>` get host-derived ids, so gpui's `ImgState` (the animated
frame index and the delayed loading placeholder) survives a frame.

## Bounds without a wrapper

`<img>`, `<svg>` and `<anchored>` accepted `testId`, appeared in the automation
tree, and then threw `Element has no painted bounds` on `click()`.

`bounds_tracker` needs a positioned parent and a canvas child, which a leaf
cannot have, and wrapping the leaf in a div would move the layout box: the
wrapper becomes the flex item and the image loses intrinsic sizing and corner
clipping. They use gpui's new `on_painted` instead, through
`automation::track_own_bounds`. `<anchored>` uses it too, because only gpui
knows where a deferred overlay landed after snapping, so the recorded box is
the overlay and not the trigger.

## Element ids are integers

`<div>` and `<text>` use `ElementId::Integer(host_id)` rather than a formatted
`"__gpuix_<id>"`. Host ids are already unique per renderer, and the
`SharedString` allocation ran on every node on every frame. Measured on the
3,259-clip timeline pan with culling off: the formatted name cost 14% over the
old text builder, the integer costs 4%. Custom elements keep
`ElementId::Name`, a different enum variant, so the namespaces cannot collide.

## gpui submodule

Bumped to `d8f267829d` on `remorses/zed:gpuix`, which carries the two fixes this
depends on:

- `should_insert_hitbox` now includes `active_style` and `group_active_style`.
  An `active` style with no click listener, hover style or cursor never got a
  hitbox, so the press was never recorded and the refinement never applied. It
  looked like it worked as soon as you added an unrelated `onClick`.
- New `InteractiveElement::on_painted`, an own-bounds listener on
  `Interactivity`. It reports from paint, not prepaint, because `List::prepaint`
  speculatively prepaints a row range and can roll the window back and prepaint
  a different one.

Session: ses_fc10c354effe0X3SbzUKVPca44
## Every removed string leaked

```tsx
<div>{visible ? 'hello' : null}</div>
```

Toggle `visible` off and the text left the screen, but the native element stayed
in the retained tree, detached and unreachable, for the life of the process. A
long chat or list grew without limit.

React calls `detachDeletedInstance` only for host **components**; a host text
node never gets one. `removeChild` only unlinked, so nothing ever destroyed the
node. React calls `removeChild` from the deletion path alone, never to move a
node, so it now destroys the child and its subtree and unregisters their
handlers. The later `detachDeletedInstance` for a host component is then a
no-op, which the recursive destroy already tolerates.

`getRetainedElementCount()` on the test renderer is what proves it.
`getTreeJson` walks from the root, so it cannot see a node that was detached and
never freed.

## A second root silently took over the first

```ts
const renderer = createRenderer()
const a = createRoot(renderer)
const b = createRoot(renderer)   // took the window, the root id and the events
```

A renderer owns one window, one native `root_id` and one event map, and the
second root replaced all three. Worse, unmounting **either** root deleted the
shared entry in `containersByRenderer`, so every handler on the other root went
dead.

`attachRoot` now throws when a different container already owns the renderer,
and `detachRoot` takes the container so only the owner can remove the entry.
Sequential remounts still work, which is what `render()` relies on.

Session: ses_fc10c354effe0X3SbzUKVPca44
`AGENTS.md` replaces the "custom elements are invisible to automation" section,
which is now wrong, with the two rules a new element has to follow:

- give it a host-derived GPUI id, or it silently loses hover, active, pointer
  capture, implicit scroll, its accessibility node, and any state gpui keeps.
  `<img>` had no id, which is why an animated GIF never left frame zero
- call `apply_interactive_styles`, never `apply_styles`, on a stateful root

Plus how bounds are recorded, and the trap behind it: containers add a
`bounds_tracker` child, leaves and `<anchored>` use gpui's `on_painted`, and
neither may move to prepaint because `gpui::list()` prepaints a speculative row
range and then rolls it back.

`README.md` drops the claim that `<img>`, `<svg>`, `<anchored>`, `<diff>` and
`<markdown>` have no painted bounds, says `hover` and `active` work everywhere
except `<virtual-list>`, and states the one-root-per-renderer rule.

Session: ses_fc10c354effe0X3SbzUKVPca44
The section body was updated when the gpui listener moved from prepaint to
paint, but the heading kept the old name, which is the line a reader skims.

Session: ses_fc10c354effe0X3SbzUKVPca44
All three `extends Props`, so `onClick`, `onMouseEnter` and `onMouseLeave`
type-check on them. All three returned `supported_events() -> &[]`, so the
registry filtered `ctx.events` down to nothing and no handler was ever
installed. The prop compiled, the JS listener registered, and nothing happened.

This is the same defect as `<text>`, in three more places.

```tsx
<img src={avatar} onClick={openProfile} />
<anchored side="bottom" onMouseLeave={close}>{items}</anchored>
```

`wire_standard_events` is now generic over `StatefulInteractiveElement` rather
than taking a `Stateful<Div>`. `<img>` and `<svg>` are gpui leaves and cannot
hold the `bounds_tracker` child that `custom_surface` adds, but `.id(..)` makes
both `Stateful`, so they can take the same listeners.

Also in the same area:

- the empty `<diff>` branch returned before `custom_surface`, so a diff with no
  changes was the one state with no automation bounds and no click or hover
  events. It goes through the same path as the non-empty branch now
- the `<anchored>` comment still said its box is read during prepaint. It has
  been `on_painted` since the gpui submodule bump, and `AGENTS.md` now forbids
  prepaint for exactly the `List::prepaint` rollback reason
- `<img>` painted its "no src" fallback with a raw string, which `AGENTS.md`
  forbids: a raw child is invisible to `getPaintedText()`, so that state could
  only be tested by screenshot. It goes through `chrome_text`

Found by a review against the original audit plan, which named the wider-type
problem for styles and stopped there.

Session: ses_fc10c354effe0X3SbzUKVPca44
- `README.md` claimed **every element type** records painted bounds.
  `<virtual-list>` records none: gpui's list is not an interactive element, so
  there is nothing to record a box against. It is safe today only because
  `VirtualListProps` does not extend `Props` and so takes no `testId`. The claim
  is now scoped to elements that accept `testId`, and the exception is named
- `AGENTS.md` said the two id namespaces cannot collide, which is true only for
  as long as nobody writes `.id(<index>)`. `impl From<usize> for ElementId`
  makes that the idiomatic gpui row id, and it lands in the same `Integer`
  namespace as a host id. Every per-row id in this crate is a formatted name for
  that reason, and the rule is now written down
- the changeset is `minor`, not `patch`. `VirtualListShared.style` became
  `Omit<StyleDesc, "hover" | "active">`, so an app passing either stops
  compiling
- the changeset now records that a filled `<text>` takes mouse hits. Routing
  `<text>` through the same builder as `<div>` means `should_occlude` applies to
  it, so a label with an opaque background stops clicks reaching what is behind
  it, like an HTML element with a background. The old text builder inserted no
  hitbox at all. `element-identity.test.tsx` pins the new behaviour
- the `<img>` element-state test explained the offscreen window wrong. The
  `focus: false` comes from gpui's `VisualTestContext::open_offscreen_window`,
  not from anything in `test_renderer.rs`

Session: ses_fc10c354effe0X3SbzUKVPca44
The two commits it described are gone. `Make every RetainedTree mutation total`
and `Report a rejected mutation instead of dropping it silently` were removed
from this branch after review: every failure they guarded needs a hand-written
call to the napi surface, React cannot produce any of them, and the cost did not
match.

- ~480 lines, an `is_ancestor` walk on every valid `appendChild`, and a public
  `createElement` return type change reaching napi, wasm, batching and the React
  types
- a batch skipped an impossible op and logged it while a direct call threw, so
  the same mutation behaved differently depending on transport, and a skipped op
  left React believing a commit it does not have

If the hardening comes back it should be one atomic prevalidation pass beside
`resolve_styles`, with one policy for both entry points.

Session: ses_fc10c354effe0X3SbzUKVPca44
Publish a new @gpuix/cli package with a goke-powered `gpuix new` command. The command downloads the official example app, replaces its workspace React dependency with the latest npm release, and installs the generated project with Bun.

Update the quickstart, example documentation, workspace metadata, release notes, and CI publish order. Add a real GitHub-backed integration test and shell completion support.

Session: ses_fb87b7e28fferSvDT71z3OadR1
Move @gpuix/cli into a focused publish job that runs its integration test and publishes without waiting for native platform test jobs. This keeps an unrelated Windows GPU example failure from blocking the platform-independent scaffolder.

Session: ses_fb87b7e28fferSvDT71z3OadR1
Resolve the executable path before comparing it with import.meta.url. Package managers launch the gpuix binary through a symlink, so the previous direct comparison made @gpuix/cli@0.1.0 exit without parsing any command.

Add a symlink regression test and release @gpuix/cli@0.1.1. Consume the original feature changeset because 0.1.0 is already published.

Session: ses_fb87b7e28fferSvDT71z3OadR1
# Conflicts:
#	.github/workflows/ci.yml
#	examples/compile.ts
#	packages/native/src/custom_elements/anchored.rs
#	packages/native/src/custom_elements/code.rs
#	packages/native/src/custom_elements/diff.rs
#	packages/native/src/custom_elements/img.rs
#	packages/native/src/custom_elements/input.rs
#	packages/native/src/custom_elements/markdown.rs
#	packages/native/src/renderer.rs
#	packages/react/src/types/host.ts
#	zed
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​jszip@​3.10.1100100848070
Addedcargo/​lightningcss@​1.0.0-alpha.729510010010070
Addednpm/​rimraf@​6.1.310010010083100

View full report

@mateo-m

mateo-m commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

Moved to remorses#50.

@mateo-m mateo-m closed this Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants