Skip to content

refactor: one control surface, and no Tauri runtime in a headless browser - #95

Merged
pathscale merged 15 commits into
masterfrom
feat/one-control-surface
Sep 11, 2026
Merged

refactor: one control surface, and no Tauri runtime in a headless browser#95
pathscale merged 15 commits into
masterfrom
feat/one-control-surface

Conversation

@pathscale

@pathscale pathscale commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Prepare the 0.4.8 engine release for the shared control protocol and UI consumers. The native select work from #94 is included; #96 is closed and subsumed.

Review restored missing option labels and selectedness, corrected text-node geometry, and made client rectangles follow painted two-dimensional transforms, including ancestor transforms. Native pointer activation and Worktables zoom/drag exposed the geometry problem through actual application behavior.

Validation: the select accessibility, inline text, transformed geometry and full local engine suites pass. Native ps-qa fixture groups pass with the coordinated host, including select changes and exactly one activation of a transformed button with exact client dimensions. Worktables passes 112/112, including real drag/cancel/undo and zoom geometry; UI passes 273 checks across 75 native fixtures.

The previous CI failure used chuzz master, which did not implement the pointer input exercised by the new fixture. Matching protocol, runtime and host commits are now pinned, with unpublished dependencies patched from those checkouts. Nested checkouts are excluded from this Cargo workspace to prevent incorrect workspace inheritance. The native fixture explicitly uses border-box sizing and exact dimensions. Coordinated CI run 34632611122 passes at 97ca22c, including the refreshed WebSocket listener and contrast-settling stack.

Release order: ps-blitz 0.4.8 → ps-observability #21 (protocol 0.5.0 / ps-qa 0.7.1) → tauri-runtime-blitz #57 (0.4.0) → chuzz #45 → UI and consumers, including AgencyZero. Verify each registry publication before advancing.

Owner PR review and approval are required before merging and publishing. The packed UI candidate and final consumer sweep are complete; remaining failures are documented backend or unpublished-registry boundaries in the UI release review.

meh added 9 commits September 9, 2026 21:29
`stylo_taffy` mapped `Position::Sticky` onto `taffy::Position::Relative`
and stopped there. Relative is the right layout answer: a sticky box
takes its flow position and reserves its space there. Nothing supplied
the adjustment on top of it, so every sticky navbar and document sidebar
on the fleet scrolled away, on documents 5,000 to 16,700px tall.

Worse than nothing, in fact. Taffy applies the inset of a relative box as
a displacement of its flow position, and a sticky inset is a threshold
rather than an offset, so `position:sticky;top:24px` was drawn 24px below
where it belonged before anything had scrolled at all. Sticky boxes now
reach taffy with no inset and the engine reads the authored values back
from the computed style when it applies the adjustment.

`resolve_sticky_positions` runs after layout and after a scroll, and
writes the displacement straight into the box's `final_layout` location.
Paint, hit testing and `absolute_position` all read that one field, so
writing it is what makes them agree; a parallel offset would have to be
threaded through each of them and would disagree the moment one was
missed. The price is that the pass has to recover the flow position it
started from, which `sticky_offsets` records and `resolve_layout` clears
along with the locations rounding rewrites.

Both halves of the CSS rule are here: the box is held between its own
flow position and the far edge of its containing block, so it pins when
the page scrolls past it and travels away with its section rather than
floating over the next one. Percentage insets resolve against the sticky
view rectangle. The scrollport is the nearest scrolling ancestor's
padding box, or the viewport when nothing between the box and the root
scrolls, which matches where a scroll that reaches the root element is
forwarded.

The scroll path is hooked as well as the resolve path: a wheel event does
not necessarily produce a style and layout pass, and a header that only
unstuck on the next restyle is a header that visibly lags the scroll.
Paint translates the whole tree by the negated viewport scroll, and a
hoisted fixed layer sits in that tree like everything else, so a fixed
box held its document position and left the screen exactly like flow
content. Measured: an overlay authored `top: 0`, on a page scrolled 800px
down, painted at screen y=-800.

Its document position has to track the scroll for its screen position to
hold still, which is the whole of the correction. Applied in the same
shape as the sticky adjustment and next to it, because it is the same
kind of thing: a displacement that depends on a scroll position rather
than on layout, written into the box so that paint, hit testing and
client rects cannot disagree about it. One value for the document rather
than one per node, since every viewport-anchored box shares the same
containing block and therefore the same displacement.

Boxes under a transformed ancestor are untouched: that ancestor is their
containing block, so they are not viewport-anchored, and the hoist walk
already leaves them alone.

This does not give a fixed box the containing block CSS asks for. That is
still the root element, which takes its height from its content, so
`bottom` and `inset` resolve against the document. Reproduced on the
reported case: a fixed panel with `bottom: 0; height: 816px` on a 7,934px
document lays out at y=7118, which is where a chat panel was found
"opening off screen", and `inset: 0` sizes 1000x7934 rather than
1000x700. Closing that needs an initial containing block distinct from
the root element, which `opposite_insets_should_size_against_the_viewport`
has recorded as an ignored test since the hoist landed.
… tree

There was no notion of selectedness anywhere in the engine. An <option> was a
display:none node carrying a `selected` attribute nobody read, so a select
could be measured and pressed but could not say what it offered or what was
chosen. Its options reached the accessibility tree with a role and nothing
else: no label, no selected state, and no value on the select itself. A QA
harness driving worktables.dev's schema designer had nothing to assert
against.

SpecialElementData::Select(SelectData) holds one selectedness flag per option,
seeded at layout construction from the `selected` content attributes and from
HTML's "ask for a reset" step, which is why a plain <select> now reports its
first option rather than an empty value. Construction is idempotent the way
create_checkbox_input is, so a selection survives the next resolve; only the
option count is refreshed, which is how a script-added option gets an entry.

Positions rather than node ids: selectedIndex is the spec's own handle and a
position cannot dangle once the option is removed. The list of options is
recomputed from the subtree on every read, which flattens <optgroup> for free.
…bmittable

Three ways in, none of which worked.

`handle_click` had no arm for a select, so the walk fell through to the
no-match tail, which calls `clear_focus()`. Pressing a select actively
unfocused the page, and the keyboard handler is gated on focus, so the arrows
could not drive a control the user had just pressed. `checkable_activation_target`
gets the matching arm, because the two have to agree about where the walk ends.

Form submission had a standing TODO exactly where a select's serialization
belongs, so a select fell through to the generic tail and submitted its own
literal `value` attribute, which a select does not have. Every form containing
a picker posted the wrong body.

HTMLSelectElement existed only as an instanceof brand. `select.value` read an
attribute that is never there and reported the empty string; there was no
selectedIndex, no options, and no option.selected. Those go on the shared
Element prototype behind a tag guard, the way `value` already branches on
text_input_data(), and they write live state then snapshot, which is the rule
set_checked's comment lays down.
…en it does

There was no keyboard activation for anything but a text input, so a focussed
picker could not be driven at all. The arrows never even reached the keyboard
handler: the KeyDown default action scrolls the page on ArrowUp, ArrowDown,
Home and End and then returns, and `scroll_key_is_claimed_by`, the one way
past it, tested only for a text input. A press on a select scrolled the
document instead of choosing anything.

ArrowUp and ArrowDown step over disabled options and do not wrap; Home and End
go to the first and last selectable option. A selection that does not move
dispatches nothing, so a `change` listener does not fire on a no-op.

The keystroke is the commit, so `input` fires immediately and blitz-script
synthesises `change` from it, the way it already does for a checkbox. A select
does not wait for blur the way a text control does.

The QA case this unblocks: worktables.dev's schema designer picks its column
type and index backend by keyboard or not at all, which is why two composite
primary key rules there were implemented but untestable.
Still a character count: the options never reach layout, so nothing shapes
them and there is no real measurement to be had. What it counts changes.

`text_content().trim()` measured a labelled option's element text, which is
the one string the control never shows, and `trim()` only strips the ends, so
a label whose own words were split across source lines counted the newline and
the indentation before the next word. A picker with a `label` attribute came
out sized to hidden markup; one formatted across lines came out wider than the
same picker written on one line.
`blitz-script` takes `ps-blitz-debug-control` behind its `debug-control`
feature, and that crate lived in pathscale/ps-observability. So ps-blitz
depended on ps-observability, and the work that puts the Blitz control
surface into ps-observability's `blitz-control-protocol` would have made
ps-observability depend on ps-blitz for `blitz-dom`. Two repositories
pointing at each other have no release order: whichever publishes first
publishes against the other's previous version.

The crate is the lower half of the pair, so it comes here. It depends on
getrandom, serde and serde_json and nothing else, and nothing in it knows
about ps-observability's harness.

Its version stays on its own 0.3.x line rather than inheriting the
engine's 0.4, because it versions a loopback debug transport that does
not change when the engine does. The publish plan lists it before
`ps-blitz-script`, which needs it, and the existing crates.io version
check skips it on any release where its version has not moved.
There were two. This one built the AccessKit tree a screen reader reads;
a second copy in tauri-runtime-blitz's `agent.rs` built the semantic tree
an agent and the QA harness read. They were written months apart against
the same specification and nothing could compare them, because this table
was private and the other lived in another repository.

They had drifted. `<th>` is a column or row header here and arrived as a
plain `cell` there, so a check that wanted "the Version column" had
nothing to ask for while every header was spelled the same as the data
beneath it. That was one of eight naming defects found in the other copy.

`accessibility::implicit_role` is the table, now public, and
`build_accessibility_node` is its first caller. `accesskit` is
re-exported beside it so a consumer names the same `Role` type rather
than resolving a second copy of the crate.

Two rules move in with it, because they were only ever in the other copy
and belong to the mapping rather than to either consumer:

  - A `<section>` with `aria-label`, `aria-labelledby` or `title` is a
    `region`; without one it stays a plain section. HTML-AAM says both
    halves, and a named section is how a page says "this part is the
    connection settings".
  - `scope` on `<th>` was already here and is now stated once.

The role rules read a tag name and a handful of attributes, so the tests
build elements directly: blitz-dom holds the tree but does not parse
HTML, and a fixture here cannot be a string of markup.
@pathscale
pathscale merged commit f6d48b2 into master Sep 11, 2026
10 checks passed
@pathscale
pathscale deleted the feat/one-control-surface branch September 11, 2026 19:19
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