refactor: one control surface, and no Tauri runtime in a headless browser - #57
Merged
Conversation
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.
This was referenced Sep 11, 2026
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.
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
0.4.8blitz-control-protocol 0.5.0andps-qa 0.7.1tauri-runtime-blitz 0.4.0Each resolves the previous by caret at build time, so wait for the publish, not the merge.
Proven
tauri-runtime-blitz, zerotauri, and cargo says so unprompted:patch tauri-runtime-blitz v0.4.0 was not used in the crate graphcargo tree -p ps-qa: 146 lines, zero blitz-dom, blitz-script, boa, tauri, winit, wgpuKnown remaining
winitis still in the headless graph at 6, viaps-blitz-shell <- blitz-control-protocol, because the protocol crate'scapturefeature takes the shell for frame timings. Splitting the rasteriser from the diagnostics finishes it.chuzz-controlstill exists with its own copy of the vocabulary. Its dead dependency edge is removed; the crate is not.The current CI failure is expected until
blitz-control-protocol 0.5.0publishes from the preceding PR. Owner review and approval are required before publication.