Skip to content

HeroGPUI v3 parity: components, gallery docs engine, website - #1

Merged
SDSLeon merged 299 commits into
masterfrom
v3-parity
Aug 31, 2026
Merged

HeroGPUI v3 parity: components, gallery docs engine, website#1
SDSLeon merged 299 commits into
masterfrom
v3-parity

Conversation

@SDSLeon

@SDSLeon SDSLeon commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Brings the workspace to HeroUI v3.2.4 parity across the remaining
component surfaces, adds the gallery documentation engine, and introduces
the documentation website that will be deployed at porabuild.com/herogpui.

  • Components — v3 contracts for alert, toast, tooltip, dropdown,
    combo box / autocomplete / select (shared MatchesCache filtering),
    input, field, link, list box, radio group, slider, table, tag group,
    pagination, avatar, close button; matching semantic theme tokens; deep
    test files per surface. cargo test -p herogpui-components: 1450
    passed, 0 failed (64 binaries, --no-fail-fast).
  • Agent guides — root AGENTS.md reduced to an index; task guides live
    in docs/agents/ and scoped AGENTS.md files.
  • Gallery — Rust syntax highlighting + display-only rustfmt-style
    reflow for code blocks; expanded per-component examples and API
    reference metadata; regenerated llms.txt.
  • Website (web/) — Next.js static site in the Porabuild theme:
    66 component pages, 61 API references, 637 Rust examples, changelog,
    llms.txt — all generated from the Rust workspace by scripts/*.mjs.
    Component pages embed the real gallery compiled to WebAssembly
    (lazy, one per page, deep-linked, theme-following) when
    NEXT_PUBLIC_GALLERY_URL points at the built artifact.

Verification

  • cargo check --workspace clean; cargo fmt --all -- --check clean
  • Component suite: 1450 passed / 0 failed
  • web/: pnpm build (84 static pages, zero warnings), typecheck, lint,
    format all green

Deploy notes

The site mounts under porabuild.com/herogpui via a parent-zone rewrite;
see web/DEPLOYMENT.md (project creation, env vars, the wasm artifact
runbook, and the two load-bearing Rust-side details).

🤖 Generated with Claude Code

SDSLeon and others added 30 commits August 23, 2026 09:39
No v3 prop table asks for this, which is why the port never had it: React
Aria gives every popover-like surface `useOverlay`, so the docs only document
dismissal where it is configurable (`isDismissable` on a dialog backdrop). A
menu that closes only through its own trigger stayed open while the page
scrolled under it.

`util::dismissable` does both halves; the two halves are also separate,
because where each one attaches is forced by how gpui dispatches:

- `on_mouse_down_out` reads the element's own bounds, so it belongs on the
  panel -- the wrapper an absolute panel sits in has none, which would make
  every press inside the panel count as outside.
- a key event goes to the focused element and bubbles up, so a panel that
  claims the focus silences the keyboard inside it. Popover and the dropdown
  menu hold the focus themselves (`util::panel_focus`, gated on open -- the
  one-shot was being spent on a closed frame, which is why Escape did nothing
  at first). The date and colour pickers read Escape on their root instead,
  leaving the arrows to the calendar grid. Select and ComboBox already read
  Escape in the handler that reads their arrows; binding it again would close
  twice. Autocomplete is open *because* its field has the focus, so Escape
  sets a flag the next key clears.

Two shared-state defects surfaced while verifying it. `Dropdown` keyed its
open flag, phase, long-press and trigger id by constant strings, so pressing
any trigger on a page opened every menu on it -- it takes an `id` now, as do
Modal, Drawer and AlertDialog, whose phase, focus handle and drag offset were
shared the same way (visible with `HEROGPUI_OPEN_OVERLAYS=1`). Every gallery
demo passes its own.

`behaviour_audit.py` derives the claim per surface, the way it derives the
arrow keys: 55 behaviours claimed, 0 missing. Verified by driving the app --
Escape closes the popover and the dropdown, and only the pressed trigger
opens. Gate clean, 73 pages render.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`inert_audit.py` gains a second pass: any `use_keyed_state`, `controlled`,
`overlay_phase`, `focus_once`, `panel_focus` or `tab_stop_handle` key that is a
bare literal instead of one derived from the component id. That is the defect
the dropdown had -- one open flag for every menu on the page -- and running the
pass against the pre-fix source reports all three of its keys, so it detects
rather than merely passing.

AGENTS.md records where each half of a dismissal handler has to attach and why,
since both placements are forced by gpui's dispatch rather than by taste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two behaviours React Aria supplies that no v3 prop table mentions, so nothing
in the port had asked for them.

`NumberField` had no key handler at all: the steppers were the only way to
change it. It is `useSpinButton` -- the arrows step by `step`, Home and End run
to the bounds, and Page Up/Down fall back to a plain step because NumberField
passes no page handlers. The keys arrive at the focused input and bubble to the
group, which is where the handler goes.

`Tooltip` was hover-only, which makes it invisible to a keyboard user; v3's own
page says "shown on hover or focus". The wrapper now holds a focus handle and
asks `contains_focused`, so the trigger inside it reporting focus opens the tip.
The handle is a focus *parent*, not a tab stop, so it adds no stop of its own.

`behaviour_audit.py` derives both claims: 56 claimed, 0 missing. Verified by
driving the app -- three Up presses take the quantity from 5 to 8, and tabbing
to the arrow-tooltip trigger shows its tip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A field's keyboard is the platform's, so no v3 prop table mentions any of it,
and the port had only part of it. Four gaps, worst first:

- **Capitals were impossible.** The handler read `keystroke.key`, which is the
  key *cap* -- "a" for shift+a, "1" for shift+1 -- and then lowercased it.
  Typing "AbC dEf!" produced "abc def1". `keystroke.key_char` is what was
  actually typed, and it is the only source that gets a capital, a shifted
  symbol or a dead-key composition right. `InputOTP` read the same field the
  same way.
- **Copy and cut were missing** while paste was there: a field you can paste
  into and not copy out of is half a clipboard.
- **No word-wise motion.** Ctrl+Left/Right now crosses the separators and then
  the word, extending the selection with shift.
- **A multi-line field had no vertical motion**, so a paragraph could only be
  crossed one character at a time, and Home/End ran to the ends of the whole
  value instead of the line's.

The motion helpers are pure functions over `(value, cursor)` with the mutation
in thin wrappers, so the six new tests cover them without an `App` -- building
an `InputState` needs one for its focus handle, and none of this logic touches
it.

`capture2.ps1` now fails loudly on an invalid `-Keys` string. SendKeys rejects
the whole string when two chords follow each other (`^a^c` is invalid where
`^(a)^(c)` is fine), and thrown mid-loop it left the run going and produced a
screenshot of a control that had been sent nothing -- which reads exactly like a
component that ignores the keyboard, and cost three verification rounds.

Verified by driving the app: "AbC dEf!" types as written, Ctrl+A/C then paste
doubles the value, Ctrl+Left lands at the start of the previous word, and Up
plus Home in a text area reaches the start of the line above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The caret only ever sat where the value had left it, so the middle of a word
was unreachable with the mouse and nothing could be selected by hand -- in a
component library whose fields are its most-used control.

A mouse listener in gpui is handed the pointer position and nothing else, so
the text's own left edge has to come from somewhere: `canvas` is the only
element told its bounds, and a zero-width one at the head of the text row
reports exactly that. From there `shape_line` plus `closest_index_for_x` turns
an x into a byte index, and the state counts chars, so the conversion is one
slice. The font is captured at render time on purpose -- at event time the text
style stack is empty and the shaping would measure the wrong face.

A press places the caret, a move with the button down extends the selection,
and a password field maps its own bullet widths rather than the value's. A
multi-line field is excluded and says so: `shape_line` measures one line, and a
wrapped paragraph has no position gpui will report. Its caret still moves by
key, including up and down.

The derived-claims loop in `behaviour_audit.py` was also reading only EVIDENCE,
so an excused derived claim vanished instead of landing in the breakdown, and it
counted a component once per tuple it appears in. Both fixed: 61 claimed, 53
implemented, 8 excused, 0 missing.

Verified by dragging across a colour field's value in the running app: the
selection starts exactly where the press landed ("#00|85F5" selects "85F5").

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`click_count` on the mouse-down event is already there, and now that a pointer
x maps to a char index the rest is the two `word_target` calls that were written
for Ctrl+arrow. A masked field maps the bullet widths and slices the real value,
which have the same char count.

`capture2.ps1` gains `-Clicks n`, since a double click is a distinct gesture and
the presses have to land inside the system double-click time -- two separate
`-Click` runs are two separate clicks. Verified with it: double-clicking a
colour field's value selects "0085F5" and leaves the "#" out, which is the
word boundary. Also checked in dark mode, where the same field takes a capital
and a Ctrl+Left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sortable column header had a click listener and no focus handle, so sorting
was mouse-only: gpui fires a *focused* element's click listeners for Enter and
Space, and there was nothing to focus. Each sortable header is a tab stop now.
v3's grid roves one stop across its cells; this port gives each sortable header
its own, which is the part that matters.

The ring exposed the other half. `status-focused` draws *outside* the element,
and the first attempt filled the whole header solid accent -- a header cell has
no background of its own, so the ring's spread showed straight through it. v3
does not use `status-focused` here: `.table__cell` and `.table__column` are
`shadow-[inset_0_0_0_2px_var(--focus)]` with `rounded-lg`, and a focused row
draws that ring split across its cells (three-sided on the first and last) so it
reads as one continuous outline inside the row. gpui has no inset shadow and a
border would move the content, so `util::inset_focus_ring` is an absolutely
positioned 2px border to hang inside the element. The focused row uses it too,
which is what v3 draws and what the row's outset ring was not.

Verified by driving the app: Enter on the focused Name header moves the demo
from "Sorted by Name Ascending" to Descending with a proper inset ring, and
tabbing into the first table still rings the row the cursor is on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closing an overlay used to lose the focus outright, so the next Tab started the
page again. Two different fixes, because only a surface that *took* the focus
has anything to give back.

The popover no longer takes it: whatever opened it keeps it, Escape bubbles from
there to the popover's root, and the trigger keeps its own ring -- which is both
simpler and what a keyboard user sees in v3. The dropdown menu does need the
focus, since that is what makes its arrows work, so the trigger wrapper holds a
handle and the dismissal hands it back. That handle is deliberately not a tab
stop: gpui keeps any *tracked* handle in the tab order, so Tab carries on from
the trigger instead of from the top of the page.

A dialog cannot do either. It claims the focus on open and its trigger is the
caller's element, rendered outside the component, so there is no handle to
return to -- recorded as `no-handle-for-callers-trigger` rather than left
looking implemented.

Verified by driving the app: Escape closes the popover with the ring still on
its trigger, and Escape-then-Tab in the dropdown lands on the File button rather
than back at the nav rail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every popover list was `overflow-hidden`: v3's are `overflow-y-auto`
(`.select__popover`, `.dropdown__popover`, `.combo-box__popover`), so a list
longer than the panel was *clipped* -- the rows past the panel's height could
not be reached at all, by mouse or by keyboard. And nothing scrolled the
keyboard cursor into view, so the highlight walked off the bottom of the visible
rows and looked like the arrows had stopped working.

Select, ComboBox, Autocomplete, ListBox and the dropdown menu now scroll, and
each arrow move asks the scroller for the row it landed on. Two handle types are
needed, because a virtual list owns the scroll offset it computes its visible
range from (`UniformListScrollHandle`, `ScrollStrategy::Center`) while a plain
one is an ordinary scrolling div (`ScrollHandle::scroll_to_item`). The menu's
cap comes from the window: React Aria sizes a popover to the space the viewport
leaves, and a menu anchored anywhere in the window is the same problem.

Verified by driving the app: fifteen Downs in the thousand-option select scrolls
the list and leaves the ringed row on screen. The plain-list path is the same
call on gpui's other handle -- no gallery demo has a long non-virtual list to
drive it with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Page Up moved the calendar's *cursor* and nothing else: the grid draws the
month the state's anchor points at, so paging past the month boundary walked an
invisible caret while August stayed on screen. Both calendars now move the
anchor with the cursor, which is React Aria keeping the focused date visible.

Shift+Page Up and Page Down page by a year, and they exposed a second bug:
`bump_month` takes a *direction*, not a count, so it stepped a single month
whatever the magnitude -- shift+Page Up moved from August 2026 to July 2026.
`add_months` counts in months from year zero, wrapping in one place, with tests
for both directions and the day clamp (31 March back one month is 28 February).

Verified by driving the app: Page Down moves the grid to September 2026, and
shift+Page Up to August 2025.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The grid was a tab stop the user had to find: opening a picker put the panel on
screen and left the focus on the trigger, so the arrows did nothing until Tab
happened to land inside. React Aria moves the focus into the calendar as the
popover opens, which is also what makes Escape reach the picker's root -- the
key bubbles up from the grid.

`autofocus_grid` is crate-only on both calendars, because a standalone calendar
must *not* take the focus: the gallery renders several on one page and they
would fight over it.

Verified by driving the app: opening the controlled picker and pressing Right
twice moves the ring from today to the 25th.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ocus

v3 documents `Tab` cycles elements for Modal, Drawer and AlertDialog, and this
port let Tab wander straight out into the page behind. gpui has no focus trap
to reach for: `tab_group` gives its children their own *ordering* and nothing
more, and there is no API for "the stops inside this subtree", so a dialog
cannot ask where Tab would land. `util::trap_tab` moves and then checks --
`focus_next`, is the focus still inside the dialog's handle, and if not re-enter
from the far end (backwards means walking forward until it leaves and stepping
back once, bounded so an empty dialog cannot spin). It stops propagation,
because `app_focus_root` binds Tab higher up and both firing moves twice, and it
then sets `focus_visible` itself: that is what the root's handler would have
done, and a trapped Tab that moves without ringing looks like it did nothing.

The verification loop is the other half of this commit. `capture2.ps1` injects
real input, which Windows delivers only to the foreground window, so every
interactive capture raised the gallery and took the focus away from whatever the
user was doing. `.shots/drive.ps1` posts the input to the window instead, so it
stays parked off-screen and unfocused for the whole run:

    python .shots/sections.py Table
    .shots/drive.ps1 -Page Table -Section Sorting -Do "click:353,387 key:enter"

`HEROGPUI_SECTION` is what makes that fast: a page is far longer than any
window, and wheeling down N notches to photograph a section is slow and breaks
whenever a section above it changes. Naming the section renders only that one,
at the top of an otherwise empty page. A posted message cannot carry a modifier
(Windows keeps that state for real input and gpui asks it), so capitals and
chords still go through `capture2.ps1`.

Two smaller fixes in the same area: `cx.activate(true)` now respects
`HEROGPUI_UNFOCUSED`, since it raises *and focuses* the window -- the one thing
that flag exists to prevent -- and `HEROGPUI_WINDOW_SIZE` exists but is
documented as clamped: Windows caps a window at the monitor whether the size is
asked for at creation or later, so the section deep link is the way to fit a
subject into one capture.

Verified headlessly: four Tabs inside the alert dialog land on Delete and inside
the drawer on Done, both still inside their panels. Nine audits at zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`design_audit.py`'s CHECKS list is hand-written -- each row has to name where in
the Rust a metric lives -- so the audit could only ever be as complete as the
list, and nothing said how complete that was. `--coverage` answers that from the
sheets themselves: every rule, every measurable utility, resolved through the
same scales. The answer was 430 metrics declared and 67 compared.

Closing the first 66 of that gap found real differences, in the components where
v3's anatomy is not what this port had built:

- **The three dialogs.** v3's dialog is one `p-6` box with unpadded parts and
  the spacing between them from `+` rules (`mt-2`, `mt-5`); this had a padded
  header, a padded body and a padded footer with a separator between them, which
  is a shape v3 does not have. Sizes are `max-w-xs`…`max-w-lg` (320/384/448/512)
  rather than hand-picked widths, the heading is `text-base font-medium` (not 18
  semibold), the body is `text-muted`, the close trigger is `absolute end-4 top-4`
  and is the `CloseButton` component rather than a hand-rolled circle, and the
  alert dialog's icon is a `size-10 rounded-3xl` tile *inside* the header instead
  of a 36px disc floating in the corner.
- **Badge** had a fixed box, a pill radius, bold text and no label padding. v3
  gives it `min-h`/`min-w` per size (16/28/**32** -- the large one was 24), a
  radius *step* per size (xl/3xl/2xl, so a large badge is a rounded rectangle a
  pill cannot draw), `font-medium`, `gap-0.5`, a 1px ring, and `px-0.5` on the
  label.
- **Tag** and **Chip** have no height at all in v3: both are padding around one
  line (`px-2 py-1` and friends) with a radius step, where this port forced a
  height and a pill.
- **NumberField**'s group was 40px tall with 26px steppers; v3 is `h-9` with
  `w-10` slots.

The gallery's overlay demos are 320px tall now, not 120: a dialog's body is
`min-h-0 flex-1`, so a short frame squeezed it to nothing and drew a heading with
a footer stuck to it.

133 metrics compared, 0 mismatched, 0 unreadable -- including two ListBox checks
that had quietly stopped matching. 304 declared metrics remain unchecked and the
number is printed, so the next batch is a `--coverage` call away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second sweep through `design_audit.py --coverage`, and the biggest find is in
the most-used control in the library: **every field was 4px too tall.**
`input.rs` set its box to 40px where v3's is 36 -- `.input` is `px-3 py-2
text-sm`, and its siblings say so outright (`.input-group` and
`.search-field__group` are `min-h-9`, `.number-field__group` is `h-9`). The
constant for it, `util::FIELD_HEIGHT`, already existed and the Input simply did
not use it.

The rest, in the components v3 declares most geometry for:

- **Tabs**: the list has `p-1` and *no gap*; a tab is `h-8 px-4 rounded-3xl
  text-sm font-medium` (this had `px-14 py-6` and no height), the panel is
  `w-full p-2`, and the root's `gap-2` is between the list and the panel rather
  than between the tabs.
- **Table**: `px-4` on headers and `px-4 py-3` on cells (both were 12/10), and
  the column resizer is v3's `h-4 w-px bg-separator` grabber with an 8px margin
  either side -- it becomes `w-0.5 bg-accent` on hover, which is what
  `group_hover` is for -- where this drew an invisible 5px strip.
- **Pagination**: `gap-1` between items (was 16), `size-8` cells with no padding,
  `w-auto gap-1.5 px-2.5` nav buttons, and the root's `gap-4` moved to the root.
- **Calendars**: nav buttons are `size-6` with `rounded-2xl` on the single
  calendar and `rounded-xl` on the range one, icons are `size-4`, header cells
  `text-xs`, and the year picker's cells are `h-8 px-2.5` in a `gap-1 p-1` grid.
- **Avatar**: `--sm` is `rounded-2xl` where the others are `rounded-3xl`, and the
  fallback is `text-sm` rather than a share of the box.
- **`SizeXl` had no single scale.** v3 declares sizes per sheet and the two
  components using that vocabulary disagree: a swatch's `sm` is 24px, a
  spinner's is 16 (which is why `Spinner` has its own enum). The shared
  `px()` was 16/20/24/32/40 and matched neither, so it is now `swatch_px`,
  16/24/32/36/40, and the swatch default is `Md` (32px) rather than `Lg`.
- **InputOTP's separator** is `h-[2px] w-[6px] rounded-sm bg-separator` -- a bar,
  not a glyph -- so `separator()` takes no argument now, matching a v3 part that
  has no props.
- Alert descriptions and accordion indicators are `text-sm` and `size-4`.

`--coverage` also stops counting v3's declared *resets*: a `-0` utility says "no
minimum" or "no margin", which an element that never sets it already satisfies.
197 metrics compared, 0 mismatched, 0 unreadable, 205 still unchecked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same pattern as the dialogs: v3 pads the *box* and leaves its parts bare.
`.card` is `flex flex-col gap-3 p-4` with `__header`, `__content` and `__footer`
carrying no padding of their own -- this port padded all three inside a card that
padded nothing, so every section's inset was doubled. The header's title is
`text-sm leading-6 font-medium`, the content is `flex-1 gap-1`, and the footer is
just a centred row.

The switch's label is `text-base` inside `text-sm` content (a step *larger* than
what surrounds it, which is easy to miss), and the dropdown's menu is
`md:min-w-55` with `gap-0.5 p-1` rows where this had a 180px panel with `py-1.5`
and no gap.

211 metrics compared, 0 mismatched, 0 unreadable; 192 of v3's declared metrics
still unchecked. Every reference screenshot is re-captured, since the field
height moved 4px in the last commit and that shifts most pages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third sweep. The InputOTP digit is `.input-otp__slot-value`, `text-lg leading-6`
-- a step larger than the slot's own `text-sm`, which this drew everything at --
and its caret is `h-4 w-[2px] rounded-sm bg-field-placeholder` rather than a
1.5px accent bar sized from the text. The date and colour pickers' popovers are
`p-3` (this had 8px) and `min-w-62 px-2` (a fixed 264 before), a toast's title
and description are both `text-sm` with a `size-5` close button, and the year
picker's trigger is `gap-1 rounded-lg`, not the control radius.

The rest of the batch is CHECKS rows for values that were already right:
toggle-button's scale, the textarea's padding through `Input`, avatar and badge
boxes, chip text sizes, the checkbox content gap, the accordion trigger and the
alert title. 250 metrics compared, 0 mismatched, 0 unreadable; 156 of v3's
declared metrics remain unchecked, most of them the same number under a second
selector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Startup dominated every check: a screenshot or a smoke pass launched the
gallery, waited about four seconds for the first frame, acted, and killed it --
five minutes for the 73-page sweep and most of the wall-clock of a verification
round. The app can now be told what to show while it runs:
`HEROGPUI_CONTROL=<file>` polls a `key=value` file (`page`, `section`, `theme`,
`overlays`) and echoes `seq` into `<file>.ack` once a frame has been drawn with
the change, which is what a driver waits for instead of sleeping and hoping.

- `.shots/batch.ps1` drives one process through many steps (click, drag, key,
  type, wheel, capture) at about a third of a second each.
- `.shots/refresh.ps1` re-captures all 73 reference shots in 23s, was ~8 min.
- `.shots/smoke.ps1` walks all 73 routes in one process in 24s, was ~5 min, and
  relaunches only where a page actually dies -- a page is still only reported
  after it dies alone as well.

The section filter moved from an env read to a global so a step can deep-link a
heading rather than wheeling down to it, and the window height is settable
(`HEROGPUI_WINDOW_SIZE`), so a long page fits one capture. Nothing takes focus:
input is posted, and the window is parked off-screen unfocused.

Geometry, from `design_audit.py --coverage`: the colour-area thumb border is
3px (v3 declares it in plain CSS, so the audit now reads a border width from a
declaration as well as from `border-N`), and the alert, calendar, checkbox,
range-calendar, separator, tag-group, toolbar and tooltip anatomies match their
sheets. 279 metrics compared, 0 mismatched, 0 unreadable.

Component pages now show the exact Rust that renders each example beneath it,
plus a generated API panel, which is the shape of v3's own docs.
`design_audit.py --coverage` said 143 of the metrics v3 declares had no check
behind them. Reading them found real differences, and three of them were the
audit's own blind spots rather than missing rows:

- **A border width is not always a utility.** The colour-area thumb is
  `border: 3px solid white` in plain CSS, and every field applies Tailwind's
  bare `border` and then overrides the width with
  `var(--border-width-field)`, which chains through `--field-border-width: 0px`
  to nothing. Reading the utility alone claimed a 1px border on ten components
  that draw none -- v3's field states are rings for exactly that reason. The
  audit resolves the variable now, and an arbitrary utility
  (`[border-width:var(--x)]`) no longer parses as a `state:` variant.
- **`size-*` and `h-*`/`w-*` set the same properties.** The autocomplete clear
  button is `h-6 w-6` and then `size-5`, so 20px -- not a box that is two sizes
  at once.
- **`p-[3px]` cancelled by `-m-[3px]`** is a focus-ring allowance, not padding:
  the three dialog bodies have no inset.

What that left was real, and most of it was spacing v3 states once and this port
had guessed per component:

- Every field wrapper is `gap-1`; three were 6 and the checkbox group was 8.
- A dialog's container is `p-4 sm:p-10`, so a panel keeps 40px from the window
  edge. Both dialogs had a `pt`/`pb` on two placements and nothing on the sides.
- A menu row is `min-h-9 gap-3` at `text-sm`; ours was a 32px row with an 8px
  gap and 13.5px text. A list row's default height was 34.
- `.header` is `px-2 pt-1.5 pb-1 text-xs font-medium` -- both section labels
  (list box and dropdown) had their own sizes, one of them 11px.
- `.field-error` carries `px-1`, which `.error-message` does not.
- The segmented tabs are separated by a `w-px h-1/2 rounded-sm bg-muted/25`
  hairline, hidden on either side of the selected tab. This port drew none.
- A pagination nav button is `rounded-3xl` like every other link; it was 10px.
- The toast close button is `sm:border border-border sm:bg-overlay`.
- `.close-button`/`.autocomplete__clear-button` are `p-1` around the glyph.

Two v3 parts this port never rendered are now there: `Modal.Icon` (`size-10
rounded-3xl` above the heading, tinted by a role colour where v3 uses a class)
and `Pagination.Summary`.

319 metrics compared, 0 mismatched, 0 unreadable; unchecked is 74, and the 11
that cannot have a row say why (`drives-the-height` for a field whose `py-2`
this port spells as a 36px height, `restated-by-dropdown-menu` for the two
`.menu` values a dropdown overrides).
`--coverage` had 74 metrics with no row behind them; it now has none. Reading
the last of them found the anatomies this port had guessed at:

- A **primary table** is a `bg-surface-secondary px-1 pb-1` tray with the rows in
  a `bg-surface` block rounded `min(32px, --radius-2xl)` inside it. This drew one
  white bordered card, which is the block without its tray.
- A **colour picker trigger** is `inline-flex items-center gap-3 rounded-sm
  text-sm` -- a swatch beside its value, nothing more. This drew a bordered 40px
  pill, a control v3 does not have.
- A **swatch picker item** is `size-8 rounded-2xl border-2 border-transparent`
  whose swatch fills it, grows to `scale(1.1)` on hover and shrinks to
  `scale(0.77)` when selected, the border taking the swatch's own colour. gpui
  has no div transform, so each of those is the size it comes to -- which also
  restored the hover `state_audit.py` then reported missing.
- A **date segment** is `rounded-md px-0.5`, the two trigger glyphs are `size-4`,
  a **list row check** is `size-4`, and a range calendar's headers are `px-0.5`.
- `Tabs.Separator` is **opt-in per tab** (v3 deleted `hideSeparator` when it
  added the sub-component), so the hairline between segments is `TabItem::
  separator()` and still hides on either side of the selected tab.
- Two more v3 parts exist now: `Table.Footer` (`px-4 py-2.5`, where a table's
  pagination goes) and `Pagination.Summary`.

Three of the gaps were the reader, not the port, and each is written up in
AGENTS.md: a border width that resolves through `--field-border-width: 0px`, a
`size-5` that overrides an earlier `h-6 w-6`, and a `p-[3px]` cancelled by
`-m-[3px]`.

385 metrics compared, 0 mismatched, 0 unreadable, 0 unchecked. The 19 that no row
can name carry a reason instead (`drives-the-height`, `trigger-is-the-field`,
`restated-by-dropdown-menu`, `accordion-body`, `no-such-part`), so the number
cannot hide a hole. All 75 routes render; every other audit is still at zero.
**A default modal drew nothing between its heading and its footer.** The body
was a scroll container (`ModalScroll::Inside` is the default), and a gpui scroll
container in an auto-height flex column measures as *zero* -- neither a pixel
`max_h` nor v3s own `min-h-0 flex-1` spelling changes that, because a percentage
resolves against the panel and the panel is sized by its content. The body is
content-sized now and the container is what scrolls in both modes: one scrollbar
in the wrong place beats unreachable text. Verified by driving the demo, not by
reading the diff -- the text is there in the capture.

`set_overlays_open` moved only the keyed demos, so a control-file step with
`overlays=1` opened every overlay except the nine dialogs that hold a field of
their own -- which is why the Modal capture came back empty twice before the
cause was found. It sets those too.

The gallery now shows the parts the last commit added, where v3 shows them:

- Tabs "With Separator" uses `TabItem::separator()` on every tab but the first,
  which is v3s own instruction; it used to stand a `Separator` next to plain
  tabs, which demonstrated nothing.
- The Table "Pagination" example puts the pagination in `Table.Footer` with a
  `Pagination.Summary` ("1 to 2 of 6 results"), as v3 does, instead of below the
  table.
- The Modal "Usage" example composes `Modal.Icon`.

fmt, clippy, 124 tests, eleven audits and all 75 routes are clean; the 75
reference shots are refreshed.
`example_audit.py` matches example *names*, and a name is not a demo: the Tabs
"With Separator" section stood a `Separator` next to two plain tabs for months
and matched perfectly. `demo_audit.py` reads the code on both sides instead --
every JSX attribute in v3's ```tsx blocks, kept to the props v3 documents and
this port implements, against the builders the gallery's page actually calls.

308 props exercised by v3's docs; 51 of them were exercised by nothing here.

- **Eleven pages had no uncontrolled demo at all.** v3's Usage examples are
  `defaultValue={...}`, with "Controlled" as a separate example below; ours were
  controlled twice over, so the uncontrolled path -- the one that broke Tabs --
  was neither shown nor exercised. Slider, ColorArea, ColorSlider, ColorField,
  ColorPicker, DateField, DatePicker, DateRangePicker, NumberField, InputGroup
  and Autocomplete now seed the way v3 does, and dragging the uncontrolled
  slider moves it (verified by driving, 30 -> 27).
- **The Toast "Placements" demo ignored the placement it named.** Six buttons
  mapped over `ToastPlacement::*` and every one pushed into the same corner
  (`|(label, _placement)|`); the shell's viewport takes the page's choice now,
  and a toast lands at the top when you press "Top".
- **The Switch "Group" demos never used `SwitchGroup`** -- bare switches in a
  `col` and a `row`, so neither the component nor its `orientation` appeared.
- Bounds (`minValue`/`maxValue` on the date, time and number fields), the
  invalid state on two fields, `disabledKeys`, `onAction`, `onSubmit`,
  `onFocusChange`, `firstDayOfWeek`, `startName`/`endName`, `inputValue`,
  `selectedKey` and the v3-named aliases (`onChange`, `onSelectionChange`,
  `onExpandedChange`) were implemented, documented and undemonstrated.

Two of the fixes tripped other audits, which is the point of having them: a
comment between `(` and a section title hides the title from `example_audit.py`
(three "Usage" sections went missing), and a `value` written on every render
freezes the control unless the caller stores what comes back -- so the range
picker's controlled demo reads the range out of its state entity and keeps a
copy, which is what a controlled caller has to do anyway.

Twelve audits at zero, 124 tests, all 75 routes rendering, shots refreshed.
`design_audit.py --coverage` reached zero unchecked by recording five metrics as
`no-such-part`: parts v3's stylesheets declare that this port did not render.
That is an honest label and a poor answer, so the parts exist now.

- **A separator takes content.** `<Separator>OR</Separator>` is
  `.separator__container` -- `flex items-center gap-3` with a `shrink-0 grow`
  line on each side and centred `--muted` text between them. `Separator` is a
  `ParentElement` now, and the gallery's "With Content" demo uses it instead of
  hand-rolling the same three boxes.
- **A range calendar marks days.** `RangeCalendar::cell_indicator` draws
  `.range-calendar__cell-indicator` -- the `size-[3px] rounded-xs` dot a
  `Calendar` already had -- in the selected cell's foreground when the day is
  part of the range.
- **A range trigger has two ends.** v3 puts
  `.date-range-picker__range-separator` (`px-1`, `--field-placeholder`) between
  them; this formatted one string with an en dash inside it, so the separator was
  neither a part nor that colour.
- **A radio option takes a description.** v3 composes `<Description>` inside
  each `<Radio>`, which is what `.radio`'s `flex flex-col gap-1` spaces and
  `ps-7` indents; `RadioGroup::descriptions` takes the column, and the Usage
  demo now reads like v3's ("Includes 100 messages per month").

390 metrics compared, 0 mismatched, 0 unreadable, 0 unchecked, and the excused
list is down from 19 to 14 -- all four remaining reasons are about *where* a
value lives (a field's height, a dropdown restating `.menu`, a trigger that is
the field, a Disclosure borrowing the Accordion's body), not about something
missing.

Twelve audits at zero, 124 tests, all 75 routes rendering, shots refreshed.
`reason_audit.py` prints each recorded omission beside the v3 row it excuses,
and one of them no longer held: `Tooltip.trigger` was filed under
`no-keyboard-focus` because "nothing in this library is focusable yet". Every
control takes focus now, and the tooltip has been opening on keyboard focus for
a while -- so `trigger` is the choice between the two, `hover` (the default,
which React Aria also opens on focus) and `focus`, which the pointer cannot
open. 630 of v3's 750 documented props are implemented and the 120 omissions all
still hold.

Proving it needed the driver fixed twice over:

- **PowerShell variable names are case-insensitive**, so `$vk = Get-Vk $key`
  *is* an assignment to the `$VK` table it just read. The first keystroke of a
  step worked and the second threw `unknown key 'tab'` -- which reads like a
  missing entry, not a clobbered hashtable. `batch.ps1` and `drive.ps1` both did
  it.
- **Posted keys carry no modifiers**, so `key:shift+tab` arrives as a plain Tab
  and moves focus the wrong way. The way to show a focus-only behaviour is to
  click the control -- which focuses it and leaves `:focus-visible` off, because
  a pointer is not a keyboard -- and then press any key: the root sets
  focus-visible as the event bubbles. The tooltip appears over the focused
  button in the capture, and hovering it alone still shows nothing.

Twelve audits at zero, 124 tests, all 76 routes rendering.
`disclosure.css` gives it three rules: `.disclosure` is `relative`,
`.disclosure__trigger` is `inline-block` with a `size-4` indicator that turns
180 degrees when open, and `.disclosure__body` is `p-2`. This port rendered an
`Accordion` with one item and the `surface` variant instead -- a card, a 16px
padded trigger row and a separator, none of which that sheet declares. v3's own
example is a `<Button slot="trigger">` with a `Disclosure.Indicator` and a body
the caller styles, and `variant={isExpanded ? "secondary" : "tertiary"}` is how
it marks the open one.

So `Disclosure` draws its own trigger (a Button that follows that variant rule,
with the chevron as `end_content`) and its own `p-2` body, faded at
`.disclosure__content`'s 200ms `ease-out-quad` -- gpui cannot animate a height it
has not measured, so `Motion::DISCLOSURE` fades where v3 slides.
`DisclosureGroup` is a `w-full` column of those, which is what
`.disclosure-group` says it is; the accordion it used to build gave every row a
card v3 does not have.

That retires the last `accordion-body` excuse in the coverage report: 391
metrics compared, 0 mismatched, 0 unchecked, 13 excused, and all thirteen of
those are now about where a value lives rather than a part that is missing.

Twelve audits at zero, 124 tests, all 76 routes rendering.
The audits all started from something v3 *documents* -- a prop table, an example,
a metric, a state. None of them asked the blunt question: of the parts v3's
stylesheets draw, which does this port not draw at all? `design_audit.py` cannot
see one whose rule sets no measurable value, and a missing part reached it as an
excuse (`no-such-part`) that is easy to write and easy to leave.

`part_audit.py` reads the 282 `.component__part` selectors and asks whether the
port names each one -- the convention here is to cite the selector where the part
is drawn, and a metric compared in `design_audit.py` counts too. Three real gaps
came out of it:

- **The drawer had no handle.** `.drawer__handle` is a centred `h-1 w-9
  rounded-xs bg-separator` bar with `pb-2`, at the edge that moves: the
  affordance that says a sheet can be dragged shut. Dragging worked; nothing
  said so.
- **A selected swatch had no checkmark.** `.color-swatch-picker__indicator`
  spans the item and centres one at `size-1/3`, white by default and black over
  a light colour. This drew the ring and the shrink and left the check out.
- **A dismissible alert dialog had no close button.**
  `.alert-dialog__close-trigger` is `absolute end-4 top-4`, the corner the
  modal's already used. It appears only when `isDismissible` is set, which is
  what v3 means by a confirmation that can be skipped.
- **A wide table was clipped.** `.table__scroll-container` is `overflow-x-auto`.

The rest of the list was the port not saying what it draws: 56 parts are
recorded with a reason (a `--variant` this port spells as an enum, a trigger the
caller passes, an `<abbr>` for screen readers), 214 are cited, and 15 remain
unverified -- named "unverified" rather than "missing" because the audit cannot
tell a part that is drawn silently from one that is not drawn at all.

Also fixed: the part regex truncated `.fieldset__field_group` at the underscore
and reported a part that does not exist.

Thirteen audits at zero, 124 tests, all 76 routes rendering.
`.tabs__list-container__scroller` and its `scroll-prev`/`scroll-next` buttons
were among the parts nothing here named, and the reason was structural: the tab
row could never overflow. Four things had to be true before it could, and each
one was wrong on its own:

- The list has to be **bounded**: without `w_full` on the scroller the box grows
  to fit every tab.
- The list has to be a **flex item that does not shrink** (`.tabs__list` is
  `w-max min-w-full`, with the comment "grow with content so ScrollShadow can
  detect overflow"): a stretched row always fits its box, so the scroller is
  never scrollable. That needs `flex` on the scroller *and* `flex_shrink_0` on
  the list and the tabs.
- A tab's label must not **wrap**, or twelve tabs fold into the width they are
  given and the row fits again.
- The chevrons depend on a measurement that only exists **after** layout:
  `ScrollHandle::max_offset` is written during prepaint, so the render that
  decided whether to draw an arrow always read zeroes. A canvas at the end of the
  container reads the handle in place and stores the two flags, and that entity
  update is what asks for the frame that draws them.

Verified by driving: two clicks on `scroll-next` move the row 240px, both
chevrons then show, and the offset in the handle says -240.

The gallery's "Overflow" demo now frames the tabs in a 420px box, which is what
v3's own example does -- an unbounded demo cannot overflow and so showed nothing.

Thirteen audits at zero, 124 tests, all 76 routes rendering.
`part_audit.py` listed 14 parts as "unverified" -- named nowhere in the port, so
the audit could not tell a part drawn silently from one not drawn at all. Each is
now either cited where it is drawn or built:

- `.color-input-group__prefix` is the swatch v3's own ColorField example puts
  there, and `.color-input-group__suffix` is the slot after the value:
  `ColorField::suffix` exists now (v3 documents `ColorField.Suffix` as a
  sub-component), and the "Channel Editing" demo puts a degree sign in it.
- `.color-slider__track`/`__output`, `.combo-box__input-group`,
  `.date-input-group__input-container`/`__suffix`, `.fieldset__field_group`,
  `.table__resizable-container` and the two tab-scroller chevrons are cited
  where the port draws them.

228 of v3's 282 parts are cited, 57 carry a reason (a `--variant` this port
spells as an enum, a trigger the caller passes, an `<abbr>` for screen readers),
and none is unaccounted for.

The geometry audit is back to 391 metrics compared, 0 mismatched, 0 unreadable,
0 unchecked; two regexes needed widening after the comments moved.

Thirteen audits at zero, 124 tests, all 76 routes rendering.
`estimatedRowHeight`, `headingHeight`, `estimatedHeadingHeight` and
`loaderHeight` sat under one reason -- "gpui's `uniform_list` takes exactly one
number and gives every row that height" -- which was true of `uniform_list` and
false of gpui. `gpui::list` measures each row it builds and keeps a running
total, which is exactly the variable-height virtualizer v3 describes.

- `ListBox::estimated_row_height` and `Table::estimated_row_height` take that
  path; `rowHeight` still takes `uniform_list`, because measuring one row and
  multiplying is cheaper when it is true.
- `ListBox::heading_height` gives a section row its height, which a row in a
  virtual list cannot work out for itself.
- `Table::loader_height` fixes the load-more row.
- `ListState` is intrusive -- the caller holds it -- so it lives in the window's
  keyed store beside the scroll handles, and a change in the item count resets
  it.

That also uncovered a bug in the row builder: `w_full` was applied only when a
row had a *fixed* height, so the columns of a variable-height row bunched at the
left edge. The width is what a virtual row is given either way.

What is left of that reason is about rows this port does not have, and each says
which: `single-line-headings` (a section header here is one line, so there is no
variable height to estimate), `no-section-rows` (a Table groups with expandable
rows, not section headers) and `no-loader-row` (a ListBox has no load-more).
635 of v3's 750 documented props are implemented.

Both new paths are demonstrated on the Virtualization pages -- a thousand rows
of three different heights in the list, and a thousand two-height rows plus a
loader in the table.

Thirteen audits at zero, 124 tests, all 76 routes rendering.
61 of v3's documented props were recorded as `drawn-not-delegated`: values v3
hands *into* a render function -- `isHovered`, `isPressed`, `percentage`,
`formattedDate` -- which this port computes in order to draw the control. The
note said there was no closure to hand them to; the fix was to add one. Every
component that hands its children a function in v3 now has that closure here:

- Button / CloseButton / ToggleButton / Switch -> `content(|state|)`
- ListBox.Item / Dropdown.Item -> `item_content(|key, state|)`
- Tag -> `tag_content(|tag, state|)`, Radio -> `option_content(|label, state|)`
- Calendar.Cell / RangeCalendar.Cell -> `cell(|state|)`
- ProgressBar / Meter / ProgressCircle ValueLabel -> `value_content(|pct, text|)`
- ColorSlider.Output -> `output(|color, text|)`

`util::InteractiveState` carries the flags, and two of them cost a frame: gpui
reports a hover and a press to a *handler*, so a render can only read what the
last frame recorded. `util::interaction` is that keyed slot and
`util::track_interaction` wires the handlers -- both attached only when a closure
is set. A calendar's cell state carries `formattedDate` and the four flags (six
for a range, with the two ends), and the adjacent-month days reach the closure
too with `isOutsideMonth` set, because v3 draws them as cells.

678 of v3's 750 documented props are implemented, up from 630 at the start of the
session. What is left of that reason is the six fields' focus flags, under a name
that says why: a *field*'s children function returns the label, the group and the
messages, and this port takes those as `label`/`description`/`error_message` and
composes them itself -- a closure that replaced the whole field would delete the
chrome the component exists to draw. The states themselves are drawn, which
`state_audit.py` checks.

Two audits pushed back, which is what they are for: `inert_audit.py` reads an
instance from `new(` to its first `.into_any_element()`, so a render closure
ending with one hid the callback after it; and `demo_audit.py` then wanted the
new closures exercised, so the Switch's "Render Props" demo prints
`selected + hovered + focus-visible` from the state it is handed, and the colour
sliders draw a swatch beside the value from the colour they are handed.

Thirteen audits at zero, 124 tests, all 76 routes rendering.
The six field components documented a `content` render prop but drew their own
label and message rows, so a caller could not see the focus state v3 hands over.
`util::FieldFocus` carries the three flags React Aria passes (`isFocused`,
`isFocusWithin`, `isFocusVisible`) and each field calls the closure with them.

`ScrollShadow` resolved `Auto` by guessing; it now reads the tracked
`ScrollHandle` and reports the resolved value through `on_visibility_change`
from a zero-size canvas, which is what makes `Auto` truthful at both edges.

`ToastStore::subscribe` wraps `cx.observe` so a caller can watch the queue
without reaching for the entity.

api_audit: `Form.action` is `on_submit`, not an omission.

demo_audit is back to zero: 310 of the props v3's examples exercise are
exercised by the gallery, none outstanding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SDSLeon and others added 15 commits August 28, 2026 17:50
Co-Authored-By: Codex (gpt-5.6-sol) <noreply@openai.com>
Co-Authored-By: Codex (gpt-5.6-sol) <noreply@openai.com>
Co-Authored-By: Codex (gpt-5.6-sol) <noreply@openai.com>
Co-Authored-By: Codex (gpt-5.6-sol) <noreply@openai.com>
Co-Authored-By: Codex (gpt-5.6-sol) <noreply@openai.com>
Port the remaining v3.2.4 Kbd, Typography and FieldGroup surfaces: exact
resting metrics, states, reference metadata rows, gallery demos, focused
deep behavior tests, and the matching design-audit coverage.

Co-Authored-By: Codex (gpt-5.6-sol) <noreply@openai.com>
Port the Alert, Popover, Modal, Drawer, and AlertDialog surfaces to
HeroUI v3.2.4 part composition: close triggers move from built-in
CloseButton drawing to the shared close_trigger_part! macro, Popover
gains PopoverArrow, and the gallery demos, tests, parity audits, and
llms.txt reference are aligned with the new builders.

Co-Authored-By: Codex (gpt-5.6-sol) <noreply@openai.com>
Co-Authored-By: Codex (gpt-5.6-sol) <noreply@openai.com>
Co-Authored-By: Codex (gpt-5.6-sol) <noreply@openai.com>
Replace the 1,300-line root AGENTS.md with a short index and move the
guidance into docs/agents/ (workflow, components, parity, gallery) plus
scoped AGENTS.md files under crates/herogpui-components and gallery, the
structure CLAUDE.md already points at. Content is relocated and updated,
not dropped: commands, lint gates, verification matrix, GPUI 0.2.2
patterns, the pinned upstream contract, and capture rules.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Port the next tranche of components to the HeroUI v3.2.4 contracts: Alert
default icons per color role, toast queue and dismissal behavior, tooltip
and dropdown placement parts, combo box / autocomplete / select shared
suggestion filtering behind a MatchesCache (identity-keyed, shared-Rc rows
for virtual lists), input and field slot anatomy, link, list box, radio
group, slider geometry, table, tag group, pagination, avatar and close
button. Theme gains the matching semantic tokens. Deep test files cover
each new contract surface; the full component suite is green.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Add gallery/src/highlight.rs: a small Rust tokenizer feeding
StyledText::with_highlights, plus a display-only formatter that reflows
stringify! walls into rustfmt-shaped lines (breaks only at legal points,
never adds, drops or reorders a token). Gallery pages grow the matching
per-component examples and API reference metadata the documentation
extractor reads; llms.txt regenerated.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Regenerated component captures against the current gallery, with the
audit scripts extended for the new surfaces.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Next.js 16 (App Router, static output, basePath /herogpui for the
porabuild.com mount) in the Porabuild theme: 66 component pages across 15
categories, 61 API references extracted from the Rust workspace, 637 Rust
examples, a changelog from git history, and llms.txt as prerendered plain
text. All page data is generated by scripts/*.mjs from the workspace —
nothing hand-maintained.

Component pages carry a lazy, one-per-page WebAssembly frame
(NEXT_PUBLIC_GALLERY_URL): the real HeroGPUI gallery compiled to wasm,
deep-linked via ?story=<slug>, following the site theme live. The built
artifact and the copied screenshots are deploy-time inputs (gitignored,
see DEPLOYMENT.md section 6); everything the build needs from git is
committed. Gates green: build (84 pages), typecheck, lint, format.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Vercel's cached pnpm 12.1.0 Linux artifact is corrupt (its bin/pnpm
contains a stray prose line, so bash exits with a syntax error before
install runs). 10.34.5 is the latest healthy line, reads the same
lockfileVersion-9 file, and installs the tree cleanly — verified with a
frozen-lockfile install and a full production build.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
herogpui Ready Ready Preview Aug 31, 2026 8:13am

Request Review

public/shots/ (the native captures every component page embeds) and
public/gallery/ (the WebAssembly gallery artifact) are the site's content:
a remote build runs only next build — no Rust toolchain, no capture rig —
so both must ship in the tree for screenshots and live previews to work.
The 26 MB artifact updates only when the wasm build is regenerated, which
is infrequent; revisit a separate artifact host if the churn hurts.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The root .gitignore keeps the capture scripts scratch (their ~ prefix)
out of .shots; the copier now applies the same rule, so refreshes stop
sweeping ~typography-*.png and friends into the deployed shots.

Co-Authored-By: Claude Code <noreply@anthropic.com>
porabuild.com/herogpui is live (Vercel project herogpui, parent-zone
rewrites applied and deployed). DEPLOYMENT.md swaps the stale blocker
list for the deployed status, the reproduction recipe (deploy from the
repository root, Root Directory web, explicit install command), and the
pnpm 12.1.0 corrupt-artifact note; section 1 now matches the settings
actually in place. HANDOFF.md resolves its open-items list accordingly
and adds the deployment history with every failure hit on the way.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The gallery is an internal development tool launched from the workspace
or via cargo install — there is no npm distribution. Drop the npm/
launcher package, the npm-launcher CI job, the release workflow npm
publish job and its version/manifest plumbing, and the npm steps from
README and RELEASING. Keep the GitHub Release binaries and attestations:
they remain the signed record of what was released.

Also scope CI push triggers to master: every PR job ran twice — once for
the pull_request event and once for the branch push — which doubled the
checks list on every PR.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@SDSLeon
SDSLeon merged commit 4633fa6 into master Aug 31, 2026
3 of 4 checks passed
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.

1 participant