Skip to content

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

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

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

Conversation

@pathscale

@pathscale pathscale commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Part of one change across five repos: the Blitz control surface is centralised in blitz-control-protocol, and a headless browser stops compiling a Tauri runtime to read a page.

Merge order across repos

  1. ps-blitz #95 publishes 0.4.8
  2. ps-observability #21 publishes blitz-control-protocol 0.5.0 and ps-qa 0.7.1
  3. this PR publishes tauri-runtime-blitz 0.4.0
  4. chuzz #45 publishes its coordinated release
  5. UI #289 publishes UI 3.2.0, followed by approved consumers

Each resolves the previous by caret at build time, so wait for the publish, not the merge.

Proven

  • headless dependency tree: zero tauri-runtime-blitz, zero tauri, and cargo says so unprompted: patch tauri-runtime-blitz v0.4.0 was not used in the crate graph
  • cargo tree -p ps-qa: 146 lines, zero blitz-dom, blitz-script, boa, tauri, winit, wgpu
  • both chuzz binaries build

Known remaining

  • winit is still in the headless graph at 6, via ps-blitz-shell <- blitz-control-protocol, because the protocol crate's capture feature takes the shell for frame timings. Splitting the rasteriser from the diagnostics finishes it.
  • chuzz-control still exists with its own copy of the vocabulary. Its dead dependency edge is removed; the crate is not.
  • AgencyZero's UI migration builds against the packed UI candidate and its focused native UI flow passes 12/12. Core-specific validation is tracked separately.

The current CI failure is expected until blitz-control-protocol 0.5.0 publishes from the preceding PR. Owner review and approval are required before publication.

meh added 13 commits September 9, 2026 18:14
`semantic_role` mapped `td` and `th` to the same `cell`, so every table in the
fleet arrived with no way to tell a column's title from a value under it. A
check that means "the Version column" had nothing to ask for.

HTML-AAM maps `<th>` to `columnheader` or `rowheader` by its `scope`, and
blitz-dom's own accessibility tree already does this in
`packages/blitz-dom/src/accessibility.rs`. The two trees over the same document
disagreed; this makes them agree, including the fallback to `columnheader` when
no `scope` is written, which is the ordinary `<thead>` case.
The role list in `semantic_name` said `cell` and `row` were deliberately absent
because their content is a whole subtree. That objection belongs to `generic`,
not to these: ARIA gives `cell`, `gridcell`, `columnheader`, `rowheader` and
`row` nameFrom author and contents, and a row's name being the run of its cells
is what a screen reader announces when the caret enters the row.

The cost of leaving them out was every table in the fleet. `<td>worktable</td>`,
a direct text child, came back as `cell ""`, so a table of crate names, versions
and column types was a grid of anonymous boxes. Two site suites reported their
tables as absent from the accessibility tree; the nodes were there and every one
of them was nameless, which from outside is the same thing.

The list moves to `names_from_contents` because the text walker needs the same
answer, and a role list written twice goes stale once.
`semantic_role` had no `section` arm, so `<section aria-label="a named
section">` arrived as `generic` with a name on it. HTML-AAM maps a `<section>`
with an accessible name to `region` and one without to `generic`, and both
halves are load-bearing: the named case is how a page says which part of itself
this is, and promoting the unnamed case would put a landmark around every block
on a page that uses `<section>` as a synonym for `<div>`.

Decided from attributes rather than from the computed name, because the name is
not known at this point and computing it here would walk a section's whole
subtree once per element in the document. `aria-labelledby` counts towards the
landmark even though `semantic_name` does not yet resolve that reference, which
is a gap in naming rather than a reason to withhold the role.

blitz-dom reports `Role::Section` for every `<section>`; that is the one place
in this change where the two trees do not converge, and the HTML-AAM rule is
the one a QA suite and a screen reader both act on.
`role="tooltip"` was not on the nameFrom-contents list, so a tooltip arrived as
a node saying that some explanation is on screen without saying what the
explanation is. ARIA names a tooltip from its contents, and this is the same
class of finding as the live region one: the roles whose entire purpose is to
say one thing were the roles that reported nothing.
`<option value="u64">` is how a `<datalist>` is written, and naming an option
from its contents alone left every entry in one anonymous. HTML resolves an
option's label as the `label` attribute if present, then the option's text, and
browsers fall back to `value` when there is neither, which is what a person sees
in the list.

Empty contents no longer end the search either. The contents arm returned
`Some("")` for a role on the nameFrom-contents list, which stopped the chain and
made every fallback below it unreachable.
A control with a short label for narrow viewports and a long one for wide ones
writes both and shows one. `sm:hidden` on the first and `hidden sm:inline` on
the second came back as "Book Book a diagnostic": a name no viewer sees at any
width, and one no check can be written against, because the string it asserts
depends on which half the author expected to win.

`name_text` now skips an element child that is not rendered, which is
`display: none`, `visibility: hidden`, the `hidden` attribute and
`aria-hidden="true"` -- the set `node_is_individually_visible` already answers,
and the set accname states directly. Text nodes are not filtered: a text node
has no display of its own and is present exactly when the element holding it is.

`LabelIndex` reads its labels the same way, through `name_text` rather than
`textContent`. A label becomes a name the moment it reaches a control, so the
half of a responsive label that is not rendered, and a `<style>` inside one, had
to be left out on that path too.
`inspect_document` skipped every node that was not an element, so text that is
not some element's accessible name could not be read at all: a paragraph, a
`<pre>`, a code block, the prose of a documentation page. A check could assert
that a control exists and not that the page says anything.

That is not only a limit on checks. A browser exposes those text runs, and so
does blitz-dom, whose accessibility tree gives every text node `Role::TextRun`
carrying its content. This tree disagreed with both, and a QA host reading it
had no way to tell "the page renders no prose" from "the tree does not carry
prose".

Exposed as `role: "text"`, with the text as the name, which is the field a
harness already matches on and the shape the ARIA snapshot formats use. A text
run has nothing to press and no state, so `enabled` is true, `selected` is false
and `value` is empty; `visible` follows the chain of boxes it is drawn in rather
than a box of its own, because a text node has no client rect.

Text that already names a node is left out. It is in the tree, as that node's
name, and that node is the one a harness can act on; reporting it twice would
make "the page says this once" false and hand a check a node it cannot click for
words it can already read. `<style>` and `<script>` content is excluded for the
reason `name_text` excludes it.

Only the agent snapshot. The diagnostics snapshot in `snapshot_document` reports
a layout and computed-style row per node, and those rows describe element boxes;
adding text runs to it would add rows with nothing in them.
A version bump on master is the release trigger, and this is what carries the
semantic tree fixes to the QA hosts that read it: tables that are named rather
than a grid of anonymous boxes, header cells that say which column they head,
named sections as landmarks, and the text a page shows in its own right.

A patch bump on purpose. The role and name changes correct what a node reports
rather than change the shape of the wire type, and the new `text` node is
additive, so every consumer pinned at `^0.3` picks these up without a repin.
Exposing text made every text node ask each element above it whether that
element is already named by those words, and the owned `semantic_role` built a
`String` per ancestor to compare against a fixed list and drop it. On a page of
a few thousand nodes that is thousands of allocations per inspection, and the
inspector is polled, not called once.

A role is either a `&'static str` or the `role` attribute's own text, so
`semantic_role_ref` hands both back borrowed and `semantic_role` is one
`to_owned` on top of it.
Text runs are new ids in a tree a harness drives by id, so the first thing a
suite will do with one is press it by accident. It resolves to no client rect,
which the activation path already answers with `notInteractable`; this pins that
down so the answer cannot become a panic inside the control server.
An element named by pointing at other elements arrived anonymous, because
the reference was never resolved. Two independent reports: a QA agent
gave a dashboard card on ui-starter-app an identity this way, found the
region came back unnamed and fell back to `aria-label`; and the section
rule added in this branch reads `aria-labelledby` to decide an element is
a `region`, so such a section got the landmark role and an empty name,
which is worse than the wrapper it used to be reported as.

The attribute is a space-separated list of ids. Each referenced element's
rendered text is read in the order the ids are written and joined by a
single space, and the result outranks `aria-label`, which outranks the
host language's own labelling, which outranks name from contents.

Resolved out of `LabelIndex`, whose single pass over the document now
also records every `id` and the node holding it. The node rather than its
text: a reference is read for the few elements that carry one, and naming
every element up front to answer a question almost none of them ask would
walk each subtree twice per snapshot. So the index stays one pass, and a
reference costs a hash lookup per id plus the subtree walk that id's text
requires. Nothing here walks ancestors or scans the document per node.

A referenced element contributes its text even when it is not rendered,
which is the opposite of the rule applied inside a name's own subtree.
Both are accname: a node that is hidden and not referenced returns the
empty string, while one directly referenced is exempt from that check.
The exemption is the whole point of the pattern, since an element written
only to be pointed at is routinely not shown, and it stops at the element
named by the id: hidden elements inside it are still skipped.

A reference resolves through `name_text`, which reads text, and never
through `semantic_name`, which would consult the attribute again. A
self-reference is named by its own contents and terminates, and no cycle
between elements can exist to be guarded against.

An id that matches nothing contributes nothing rather than abandoning the
whole name, and a reference that resolves to no text at all is the
absence of a name rather than an empty one, so the rules below it stay
reachable.

Eight tests. Six fail against this branch's tip. The two that do not are
the fall-through guards, and both fail against the same resolver without
its emptiness filter, which is the shape that made a contents arm
returning `Some("")` unreachable earlier in this branch.
A runtime bridges Tauri to Blitz and owns a native window. It does not
proxy an inspection service, and this crate was doing exactly that: the
document reader, the action dispatch, the capture, the socket server and
a re-export of the wire vocabulary all lived here, so anything that
wanted to name an `AgentControlRequest` compiled Tauri to get it. On
Linux that reaches GTK, which is how a headless host ended up needing
system libraries to build a binary that opens nothing.

All of it is `blitz-control-protocol` now: `engine` reads and drives a
document, `capture` paints it offscreen, `server` is the socket, and
`in_process` is the transport an embedder holding the document calls
straight into. `agent.rs` and `agent_control_server.rs` are gone rather
than emptied, and nothing here re-exports what replaced them. A crate
that forwards a dependency has not removed the edge, only renamed it.

`set_agent_control_handler` and `set_diagnostics_handler` go with them.
The first is `blitz_control_protocol::lifecycle`, because what `Relaunch`
means is the embedder's answer and reaching a window runtime to register
it was the same misplacement one level up. The second let an embedder
replace the whole diagnostics answer, which is a runtime proxying an
inspection service by another name, and nothing installed one.

What is left is the wiring only a runtime can do: which window's document
is the active one, the listener's lifetime, what `Quit` does to an event
loop, and the native window composition a renderer capture cannot see.
No public item in this crate names a protocol type any more.

The manifest loses `endpoint-libs`, `keyboard-types`, `serde`,
`serde_json`, `base64`, `blitz-paint` and `anyrender_vello_cpu`: the
framing, the key table, the request types and the offscreen renderer of a
surface this crate no longer implements. `serde_json` returns as a
dev-dependency, which is what the IPC round-trip test always used it as.
A minor bump, not a patch. `set_agent_control_handler`,
`set_diagnostics_handler`, the `control_protocol` re-export and the
`agent`/`agent_control_server` exports are gone, so a build that named
any of them stops compiling rather than resolving to something subtly
different. That is the whole point of the removal, and a version that
claims compatibility with it would be lying.
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