Conversation
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>
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
combo box / autocomplete / select (shared
MatchesCachefiltering),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: 1450passed, 0 failed (64 binaries,
--no-fail-fast).in docs/agents/ and scoped AGENTS.md files.
reflow for code blocks; expanded per-component examples and API
reference metadata; regenerated llms.txt.
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_URLpoints at the built artifact.Verification
cargo check --workspaceclean;cargo fmt --all -- --checkcleanweb/: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