T4 round 0: iced is eliminated, and the probe that cleared it proved nothing

Round 0 is the ladder's cheapest hard gate: for each candidate, a desk survey
and a demonstrated accessibility route -- a window exposing one node with a
role and a name, read back through the platform adapter. It is first precisely
so that a candidate with no accessibility story costs one round instead of
four.

C1 (egui/eframe 0.35.0) passes on a first-party route: egui-winit's accesskit
feature is literally dep:accesskit_winit, so the chain runs eframe ->
egui-winit -> accesskit_winit -> accesskit_unix and inherits that crate's
window-lifecycle handling. C2 (vello 0.9.0 + winit 0.30.13) passes on the
manual accesskit_winit route the contract names, wired into the same
ApplicationHandler that owns the vello renderer.

C3 (iced 0.14.0) FAILS, and the interesting part is that it first reported as
a pass. The probe registered a hand-built two-node tree through
accesskit_unix::Adapter, which takes no window handle at all -- only handlers
-- and registers with AT-SPI from process identity. It read back cleanly. It
also happened to label its button exactly as iced's own view() labelled a
button, so the transcript looked as though iced had produced it. Deleting iced
from that probe would produce the identical readback. Round 0 asks whether the
CANDIDATE exposes a route; a process-level side channel answers a different
question.

The verdict is recorded with dual attribution, because two distinct things
went wrong. The probe-design defect is the false positive above. The candidate
limitation -- which alone fails the round -- is that iced 0.14 ships no
accessibility integration anywhere (accesskit appears in no iced crate
manifest) and its stock runner hands application code neither the winit
ActiveEventLoop nor a pre-visibility Window, both of which every
accesskit_winit::Adapter constructor requires. That scoping matters and is
deliberate: iced_winit documents a conversion module for users implementing a
custom event loop, so a hand-built shell remains conceivable but unproven, and
would mean owning the shell. Upstream iced #552 is still open.

The evidence file keeps the verifier's factual READBACK: PASS beneath a
ROUND-0 RESULT: FAIL annotation rather than being rewritten. A corrected record
that erases the false positive teaches nobody why it was false.

Two findings carried forward. C1's frame node is unnamed -- its readback path
is application:'probe-egui' / frame:'' / button:'...' where C2 names its frame
-- which is non-disqualifying here but means a screen-reader user hears an
unnamed window, and round 3 must check it. And AT-SPI application registration
is gated behind two settings that are off by default; without both, probes
connect to the bus and enumerate zero applications, which would read as a
candidate failure rather than the environment absence it is.

The spike workspace lives outside the root workspace with its own lockfile;
the root gains one line, exclude = ["spikes"], and nothing else. Round 0's
probes depend on no epiphany crate, which is what makes this round independent
of the pinned source baseline the later rounds need.

Root gate unchanged: fmt clean, clippy -D warnings clean, 1371 tests passed 0
failed. Spike workspace gated separately: fmt clean, all probes build --locked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSX4zSLgKvtiXaPjnMqLGz
This commit is contained in:
Levi Neuwirth 2026-07-28 19:01:51 -04:00
parent 0c8b8db1a5
commit 496dfd5640
15 changed files with 6538 additions and 0 deletions

View File

@ -1,5 +1,6 @@
[workspace]
resolver = "2"
exclude = ["spikes"]
members = [
"crates/epiphany-determinism",
"crates/epiphany-core",

1
spikes/editor-toolkit/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

5656
spikes/editor-toolkit/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
[workspace]
resolver = "2"
members = [
"probe-egui",
"probe-vello",
"probe-iced",
]
# a11y-verifier is a standalone Python script (a11y-verifier/verify.py), not
# a Cargo crate: it uses gi.repository.Atspi (the official AT-SPI2
# GObject-introspection binding), an "equivalent AT-SPI client" under the
# contract's Round 0 evidence rule. It is not a workspace member because it
# is not Rust.
# This workspace is deliberately outside the repo-root workspace (see the
# root Cargo.toml's `exclude = ["spikes"]`, and CONTRACT_EDITOR_T4_SPIKE.md
# pin 1). It is throwaway spike code for the T4 toolkit decision and is never
# intended to ship. It has its own committed Cargo.lock.
[workspace.package]
edition = "2021"
publish = false

View File

@ -0,0 +1,139 @@
# T4 toolkit spike — decisions and findings log
Governed by `spec/CONTRACT_EDITOR_T4_SPIKE.md`. This file records
implementation decisions and named deviations, per round.
## Round 0 — accessibility route + desk survey
**Environment prerequisite, not obvious from the contract:** on this
machine (sway, AT-SPI2 via `at-spi-bus-launcher` + `at-spi2-registryd`),
AT-SPI application registration is gated behind two settings that are
*off* by default even though the bus itself is always up:
```
gsettings set org.gnome.desktop.interface toolkit-accessibility true
gdbus call --session --dest org.a11y.Bus --object-path /org/a11y/bus \
--method org.freedesktop.DBus.Properties.Set org.a11y.Status \
ScreenReaderEnabled "<true>"
```
Without both, `Atspi.get_desktop(0)` enumerates **zero** applications even
while a probe process is alive, rendering, and actually connected to the
AT-SPI D-Bus (confirmed separately via `busctl --address
unix:path=$XDG_RUNTIME_DIR/at-spi/bus list`). This is a real environment
absence, not a candidate defect, and any later round run in a fresh
sandbox/session must redo both steps before trusting a `NOT RUN` verdict on
accessibility.
**Verifier substitution.** The contract's Round 0 evidence rule allows "a
small verifier binary using the `atspi` crate, **or an equivalent AT-SPI
client**". `a11y-verifier/verify.py` uses `gi.repository.Atspi` (the
official AT-SPI2 GObject-introspection binding — the same library behind
Orca and Accerciser) instead of the Rust `atspi` crate. This was a
deliberate substitution: the Rust crate's async zbus proxy API would have
had to be learned from source rather than from any working example, and
`pyatspi` was already confirmed reachable on this machine. It is a
standalone process, external to every probe, and performs a real tree walk
from the AT-SPI registry — it satisfies "a real client query of the tree",
not "printing your own struct".
**C1 (egui).** First-party route, the full chain being
`eframe``egui-winit``accesskit_winit``accesskit_unix`: `egui-winit`'s
`accesskit` feature is literally `dep:accesskit_winit`
(`egui-winit-0.35.0/Cargo.toml:55`), on by default through `eframe` in 0.35, and
`accesskit_winit` delegates to the platform crate. So C1 gets the
window-lifecycle handling that C3's bypass would have forfeited. No manual
wiring was needed. `probe-egui` draws one button; readback: **PASS**. See
`round0-evidence/c1-egui-readback.txt`.
**Carry forward — C1's frame node is unnamed.** C1's readback path is
`application:'probe-egui' / frame:'' / button:'EpiphanyProbeButton'`, where C2
and C3 both name their frame. The window title does not reach the AT-SPI frame
node under `eframe` 0.35's default wiring. **Non-disqualifying** — round 0
requires one node with a role *and* a name, and the button carries both — but
it is a real gap: a screen-reader user hears an unnamed window. Round 3
(accessibility semantics) must check it, since window identity is part of
navigation, and it should not be rediscovered there as a surprise.
**C2 (vello + winit).** Manual `accesskit_winit` route, exactly as named by
the contract: `probe-vello` builds the accessibility tree by hand
(`accesskit::TreeUpdate`) and drives it through
`accesskit_winit::Adapter::with_event_loop_proxy`, wired into the same
`winit::application::ApplicationHandler` that owns the vello
`RenderContext`/`Renderer`/`Scene` (the vello render pass is real, not a
stub — it draws a filled rounded rect every frame, following vello's own
`examples/simple` pattern at `linebender/vello@main`). Readback: **PASS**.
See `round0-evidence/c2-vello-readback.txt`.
**C3 (iced) — ROUND-0 RESULT: FAIL. Eliminated at round 0, adjudicated
2026-07-28 by coordinator review; no waiver sought or granted.** The initial
report recorded this as "PASS with a flagged deviation". That adjudication was
wrong and is corrected here. Under pin 14(c) C3's disqualifying set is not
passed; keeping it would require an explicit recorded ruling amendment, which
was declined.
**Dual attribution, because the two failures are different in kind.**
*Candidate limitation — this alone fails the round.* iced 0.14 ships **no
accessibility integration at all**: `accesskit` appears in no iced crate
manifest (verified across every `iced*` crate in the 0.14 tree). And its
**stock runner** exposes to application code neither a
`winit::event_loop::ActiveEventLoop` nor a pre-visibility
`winit::window::Window`; both appear only inside `iced_winit`'s own private
`ApplicationHandler` impl, with `create_window` at `iced_winit-0.14.0/src/lib.rs:350`
inside iced's runner. Every `accesskit_winit::Adapter` constructor requires
both and panics if the window is already visible.
**Scope this to the stock runner, deliberately:** `iced_winit`'s own docs offer
a `conversion` module "for users that decide to implement a custom event loop",
so a hand-built shell carrying a real route remains **conceivable but
unproven** — and it would mean owning the shell. Upstream iced #552 remains
open. "Provably closed" applies to the stock runner, not to iced in principle.
*Probe-design defect — why the first report read PASS.*
`accesskit_unix::Adapter::new()` takes **no window handle**, only handlers, and
registers with AT-SPI from process identity
(`accesskit_unix-0.22.1/src/context.rs`; `app_name()` reads
`std::env::current_exe()`). `probe-iced` therefore registered a **hand-built
static tree**, decoupled from iced's window, focus, and event lifecycle, with
every action discarded — while `view()` happened to label its button
identically, which is what made the transcript read as though iced produced it.
**Deleting iced from the probe would produce the identical readback.** That is
the disqualifying fact: round 0 asks whether the *candidate* exposes a route,
and a process-level side channel answers a different question. That
`accesskit_unix` sits one layer beneath `accesskit_winit` does not make it a
route *for the candidate* — that was the reasoning error, and it is recorded as
a probe defect rather than folded into the candidate's result.
Evidence is preserved rather than rewritten: `round0-evidence/c3-iced-readback.txt`
keeps the verifier's factual `READBACK: PASS` under a `ROUND-0 RESULT: FAIL`
annotation, so the false positive stays visible alongside its adjudication.
One consequence survives the corrected verdict and is worth carrying, because
it would apply to any future hand-built route: bypassing `accesskit_winit`
forfeits that crate's window-lifecycle handling — deactivation on window close,
multi-window disambiguation, focus-driven activation. Any real iced integration
would have to build and maintain that wiring itself rather than inheriting it,
which is a maintenance-surface fact, not merely a round-0 curiosity.
## Round 0 — desk survey
All version/date/MSRV figures were fetched live (crates.io API + GitHub
`Cargo.toml` at the released tag), not from memory or the contract's
2026-07-23 snapshot. See the Round 0 report for the full table.
Fill-rule documentation, quoted verbatim from source (not inferred from
behavior — that is round 1's job):
- **C1 (`lyon_tessellation` 1.0.20, via `lyon_path` 1.0.19):**
`pub enum FillRule { EvenOdd, NonZero }`, with
`DEFAULT_FILL_RULE: FillRule = FillRule::EvenOdd` and an explicit
`is_in(winding_number)` implementation for both.
- **C2 (`peniko` 0.6.1, vello's fill-style type):** `pub enum Fill { NonZero, EvenOdd }`,
each with a full doc comment ("All regions where the winding number of
the path is not zero will be filled" / "... is odd will be filled").
- **C3 (`iced_graphics` 0.14.0, `geometry::fill`):**
`pub enum Rule { NonZero, EvenOdd }`, doc pointing at the SVG
`fill-rule` spec, default `NonZero`.
All three document both rules explicitly. No candidate is eliminated on
this desk-survey item; round 1 is where it is actually tested.

View File

@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Round 0 accessibility readback verifier.
An AT-SPI client, independent of any candidate's own process, that walks
the live platform accessibility tree (via the AT-SPI2 registry over D-Bus)
looking for an accessible node with a given role and name. This is a real
client query of the tree, per CONTRACT_EDITOR_T4_SPIKE.md Round 0: "Setting
the node in your own process and printing your own struct is NOT a
readback."
Uses gi.repository.Atspi, the official GObject-introspection binding for
AT-SPI2 (the same library backing Orca and Accerciser). This is used in
place of the `atspi` Rust crate as an "equivalent AT-SPI client" (the
contract's own wording) — chosen because its API is stable, documented, and
already verified reachable on this machine, rather than reverse-engineering
an unfamiliar async zbus proxy API under this round's timebox. That
substitution is a named deviation, reported as such.
Usage:
verify.py --role "push button" --name "EpiphanyProbeButton" [--app-name SUBSTR] [--max-depth N] [--timeout SECONDS]
Exit code 0 and prints "READBACK: PASS" with the path from desktop root to
the matched node, if found within the timeout. Exit code 1 and prints
"READBACK: FAIL" with a dump of what *was* found, if the bus is reachable
but no match appears before the timeout. Exit code 2 and prints
"READBACK: NOT RUN" if the AT-SPI bus itself cannot be reached at all.
"""
import argparse
import sys
import time
try:
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
except Exception as exc: # pragma: no cover - environment probe
print(f"READBACK: NOT RUN — could not import gi.repository.Atspi: {exc}")
sys.exit(2)
def walk(node, role, name, app_name_substr, max_depth, path, found, all_seen):
if node is None:
return
try:
node_name = node.get_name()
except Exception:
node_name = "<error>"
try:
node_role = node.get_role_name()
except Exception:
node_role = "<error>"
all_seen.append(" / ".join(path + [f"{node_role}:{node_name!r}"]))
if node_role == role and node_name == name:
found.append(list(path) + [f"{node_role}:{node_name!r}"])
return
if max_depth <= 0:
return
try:
n = node.get_child_count()
except Exception:
return
for i in range(n):
try:
child = node.get_child_at_index(i)
except Exception:
continue
walk(
child,
role,
name,
app_name_substr,
max_depth - 1,
path + [f"{node_role}:{node_name!r}"],
found,
all_seen,
)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--role", required=True)
ap.add_argument("--name", required=True)
ap.add_argument("--app-name", default=None, help="only descend into apps whose name contains this substring")
ap.add_argument("--max-depth", type=int, default=12)
ap.add_argument("--timeout", type=float, default=20.0)
ap.add_argument("--poll-interval", type=float, default=0.5)
args = ap.parse_args()
try:
Atspi.init()
except Exception as exc:
print(f"READBACK: NOT RUN — Atspi.init() failed: {exc}")
sys.exit(2)
deadline = time.monotonic() + args.timeout
last_seen = []
attempt = 0
while time.monotonic() < deadline:
attempt += 1
try:
desktop = Atspi.get_desktop(0)
except Exception as exc:
print(f"READBACK: NOT RUN — Atspi.get_desktop(0) failed: {exc}")
sys.exit(2)
if desktop is None:
print("READBACK: NOT RUN — Atspi.get_desktop(0) returned None (no AT-SPI registry?)")
sys.exit(2)
found = []
all_seen = []
try:
n_apps = desktop.get_child_count()
except Exception as exc:
print(f"READBACK: NOT RUN — desktop.get_child_count() failed: {exc}")
sys.exit(2)
for i in range(n_apps):
try:
app = desktop.get_child_at_index(i)
except Exception:
continue
if app is None:
continue
try:
app_name = app.get_name()
except Exception:
app_name = "<error>"
if args.app_name and args.app_name not in app_name:
continue
walk(
app,
args.role,
args.name,
args.app_name,
args.max_depth,
[f"desktop"],
found,
all_seen,
)
last_seen = all_seen
if found:
print("READBACK: PASS")
print(f"attempt: {attempt}, elapsed: {args.timeout - (deadline - time.monotonic()):.2f}s")
print("path: " + " / ".join(found[0]))
print(f"apps enumerated: {n_apps}")
print("full tree (role:name) seen during the matching walk:")
for line in all_seen:
print(" " + line)
sys.exit(0)
time.sleep(args.poll_interval)
print("READBACK: FAIL")
print(f"no node with role={args.role!r} name={args.name!r} found within {args.timeout}s ({attempt} attempts)")
print("nodes actually seen (role:name), last attempt:")
if not last_seen:
print(" <none — desktop had 0 matching/enumerable apps>")
for line in last_seen:
print(" " + line)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,22 @@
[package]
name = "probe-egui"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
# Round 0 desk survey + accessibility-route probe for candidate C1
# (modern egui + lyon-tessellated meshes). See
# CONTRACT_EDITOR_T4_SPIKE.md Round 0. This probe does NOT depend on any
# epiphany crate (pin: Round 0 probes must not).
#
# accesskit is a default eframe feature (egui-winit/accesskit), so this is
# egui's first-party integration route, not a manual accesskit_winit wiring.
[dependencies]
eframe = "0.35"
egui = "0.35"
# Pulled in only so C1's desk-survey dependency count reflects the actual
# candidate pairing named by the contract (pin 16 step 3: "C1 = egui + lyon").
# Round 0 does not tessellate anything with it yet (that is round 1); it is
# declared here so `cargo tree` measures the real candidate.
lyon = "1.0"

View File

@ -0,0 +1,40 @@
//! Round 0 accessibility-route probe, candidate C1 (modern egui).
//!
//! Opens a minimal window containing exactly one interactive widget (a
//! button) with a distinctive accessible name. egui's first-party AccessKit
//! integration (the `accesskit` feature, on by default in eframe 0.35) is
//! relied upon to publish that widget into the platform accessibility tree.
//! No manual accesskit_winit wiring is used here — the whole point of this
//! probe is to test the first-party route.
//!
//! This binary does not exit on its own: it is meant to be launched, given
//! a moment to register with AT-SPI, queried by the `a11y-verifier` script,
//! and then killed by the harness.
fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([320.0, 180.0])
.with_title("EpiphanyProbeEgui"),
..Default::default()
};
eframe::run_native(
"EpiphanyProbeEgui",
options,
Box::new(|_cc| Ok(Box::new(ProbeApp))),
)
}
struct ProbeApp;
impl eframe::App for ProbeApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ui, |ui| {
// The one accessible node round 0 needs: a button with a
// distinctive, greppable name. egui buttons get AT-SPI role
// "push button" and their name from the label text.
let _ = ui.button("EpiphanyProbeButton");
});
}
}

View File

@ -0,0 +1,42 @@
[package]
name = "probe-iced"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
# Round 0 desk survey + accessibility-route probe for candidate C3 (iced).
# See CONTRACT_EDITOR_T4_SPIKE.md Round 0, and the candidate set's own
# framing of C3: "AccessKit lists egui among integrated projects and does
# not list iced, and iced's upstream accessibility issue (#552) remains
# open. A manual accesskit_winit route may exist; round 0 demonstrates it
# or C3 fails there."
#
# ROUND-0 RESULT: FAIL. C3 is eliminated at round 0 (adjudicated 2026-07-28,
# no waiver). This crate is retained only as the evidence.
#
# Candidate limitation — the fact that fails the round: iced 0.14 ships no
# accessibility integration at all (`accesskit` appears in no iced crate
# manifest), and its STOCK RUNNER exposes to application code neither a
# `winit::event_loop::ActiveEventLoop` nor a pre-visibility
# `winit::window::Window`; both appear only inside `iced_winit`'s own private
# `ApplicationHandler` impl, with `create_window` at `iced_winit-0.14.0/
# src/lib.rs:350` inside iced's runner. Every `accesskit_winit::Adapter`
# constructor requires them, and panics if the window is already visible.
# Scoped deliberately to the stock runner: `iced_winit`'s docs offer a
# `conversion` module "for users that decide to implement a custom event
# loop", so a hand-built shell is CONCEIVABLE BUT UNPROVEN — and would mean
# owning the shell. Upstream iced #552 remains open.
#
# Probe-design defect — why the first report read PASS: `accesskit_unix::
# Adapter::new()` takes no window handle, so this probe registered a
# hand-built static tree at process level, decoupled from iced's window,
# focus, and event lifecycle, with actions discarded. It reads back cleanly
# and proves nothing about iced: DELETING ICED FROM THIS PROBE WOULD PRODUCE
# THE IDENTICAL READBACK. `accesskit_unix` being the layer beneath
# `accesskit_winit` does not make it a route *for the candidate* — that was
# the reasoning error.
[dependencies]
iced = "0.14"
accesskit = "0.24"
accesskit_unix = "0.22"

View File

@ -0,0 +1,123 @@
//! Round 0 accessibility-route probe, candidate C3 (iced).
//!
//! **ROUND-0 RESULT: FAIL. This probe does NOT satisfy round 0, and is kept
//! only as the evidence of why.** Adjudicated 2026-07-28 by coordinator
//! review; see `../DECISIONS.md`.
//!
//! It drives `accesskit_unix::Adapter` directly, which takes no window handle
//! -- only handlers -- and registers with AT-SPI from process identity. The
//! tree below is hand-built here: one window node, one button node. It is NOT
//! derived from iced's widget tree, does not track iced or real focus changes
//! (it sets one static focus at startup and never revises it), and routes no
//! actions.
//! The button in `view()` carries the same label purely by construction, which
//! is what made the transcript read as if iced had produced it.
//!
//! **Deleting iced from this probe would produce the identical readback.**
//! That is the disqualifying fact: round 0 asks whether the *candidate*
//! exposes an accessibility route, and a process-level side channel answers a
//! different question. A probe that proves nothing about its subject is a
//! probe-design defect, and it is recorded as one.
//!
//! The candidate limitation underneath it is separate and is what actually
//! fails the round: iced 0.14 ships no accessibility integration (`accesskit`
//! appears in no iced crate manifest), and its **stock runner** hands
//! application code neither the winit `ActiveEventLoop` nor a pre-visibility
//! `winit::window::Window`, both of which every `accesskit_winit::Adapter`
//! constructor requires. Scope that to the stock runner: `iced_winit`'s own
//! docs note a `conversion` module "for users that decide to implement a
//! custom event loop", so a hand-built shell remains **conceivable but
//! unproven** -- and it would mean owning the shell. Upstream iced #552 is
//! still open.
use accesskit::{Action, ActionHandler, ActionRequest, ActivationHandler, DeactivationHandler};
use accesskit::{Node as AccessNode, NodeId as AccessNodeId, Role, Tree, TreeId, TreeUpdate};
use accesskit_unix::Adapter as UnixAdapter;
use iced::widget::{button, column, text};
use iced::Element;
const WINDOW_NODE_ID: AccessNodeId = AccessNodeId(0);
const BUTTON_NODE_ID: AccessNodeId = AccessNodeId(1);
const BUTTON_NAME: &str = "EpiphanyProbeButton";
const WINDOW_TITLE: &str = "EpiphanyProbeIced";
fn build_tree() -> TreeUpdate {
let mut root = AccessNode::new(Role::Window);
root.set_children(vec![BUTTON_NODE_ID]);
root.set_label(WINDOW_TITLE);
let mut button_node = AccessNode::new(Role::Button);
button_node.set_label(BUTTON_NAME);
button_node.add_action(Action::Focus);
TreeUpdate {
nodes: vec![(WINDOW_NODE_ID, root), (BUTTON_NODE_ID, button_node)],
tree: Some(Tree::new(WINDOW_NODE_ID)),
tree_id: TreeId::ROOT,
focus: BUTTON_NODE_ID,
}
}
/// Returns the full static tree synchronously, so no event-loop plumbing
/// is needed to answer AT-SPI's initial tree request.
struct StaticActivationHandler;
impl ActivationHandler for StaticActivationHandler {
fn request_initial_tree(&mut self) -> Option<TreeUpdate> {
Some(build_tree())
}
}
/// Round 0 does not exercise actions (that is round 3's job); accept and
/// discard.
struct NoopActionHandler;
impl ActionHandler for NoopActionHandler {
fn do_action(&mut self, _request: ActionRequest) {}
}
struct NoopDeactivationHandler;
impl DeactivationHandler for NoopDeactivationHandler {
fn deactivate_accessibility(&mut self) {}
}
struct ProbeState {
// Held for its lifetime, not read again: dropping it would tear down
// the AT-SPI registration.
_adapter: UnixAdapter,
}
impl Default for ProbeState {
fn default() -> Self {
let mut adapter = UnixAdapter::new(
StaticActivationHandler,
NoopActionHandler,
NoopDeactivationHandler,
);
// Force the tree to materialize now rather than waiting for an
// AT-SPI client's first request, and mark it focused so a reading
// client sees a live, focused application rather than an inert one.
adapter.update_if_active(build_tree);
adapter.update_window_focus_state(true);
Self { _adapter: adapter }
}
}
#[derive(Debug, Clone, Copy)]
enum Message {
Noop,
}
fn update(_state: &mut ProbeState, _message: Message) {}
fn view(_state: &ProbeState) -> Element<'_, Message> {
column![
text(WINDOW_TITLE),
button(BUTTON_NAME).on_press(Message::Noop),
]
.padding(20)
.into()
}
fn main() -> iced::Result {
iced::run(update, view)
}

View File

@ -0,0 +1,25 @@
[package]
name = "probe-vello"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
# Round 0 desk survey + accessibility-route probe for candidate C2
# (vello behind a winit shell). See CONTRACT_EDITOR_T4_SPIKE.md Round 0.
# This probe does NOT depend on any epiphany crate.
#
# vello ships no widget toolkit and no accessibility integration of its own
# (contract, candidate set: "the open questions are windowing and UI chrome
# ... accessibility wiring, and maturity"), so this is a MANUAL
# accesskit_winit route: we build the accessibility tree by hand and drive
# accesskit_winit::Adapter ourselves, wired into the same winit event loop
# that drives the vello RenderContext/Renderer/Scene.
[dependencies]
vello = "0.9"
winit = "0.30"
accesskit = "0.24"
accesskit_winit = "0.33"
pollster = "0.4"
anyhow = "1"

View File

@ -0,0 +1,263 @@
//! Round 0 accessibility-route probe, candidate C2 (vello + winit).
//!
//! vello ships no widget toolkit and no accessibility integration of its
//! own, so unlike C1 this is a MANUAL accesskit_winit wiring: the
//! accessibility tree (one window node containing one button node with a
//! distinctive name) is built by hand and pushed through
//! `accesskit_winit::Adapter`, driven from the same winit
//! `ApplicationHandler` that owns the vello `RenderContext`/`Renderer`/
//! `Scene`. This mirrors AccessKit's own upstream `winit` adapter example
//! (`adapters/winit/examples/simple.rs` at AccessKit/accesskit@main),
//! adapted to also drive a real vello render pass so the probe is a
//! genuine instance of "vello behind a winit shell", not accessibility
//! wiring alone.
use anyhow::Result;
use std::sync::Arc;
use accesskit::{
Action, Node as AccessNode, NodeId as AccessNodeId, Role, Tree, TreeId, TreeUpdate,
};
use accesskit_winit::{Adapter, Event as AccessKitEvent, WindowEvent as AccessKitWindowEvent};
use vello::kurbo::{Affine, RoundedRect};
use vello::peniko::Color;
use vello::util::{RenderContext, RenderSurface};
use vello::wgpu::{self, CurrentSurfaceTexture};
use vello::{AaConfig, Renderer, RendererOptions, Scene};
use winit::application::ApplicationHandler;
use winit::dpi::LogicalSize;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy};
use winit::window::{Window, WindowId};
const WINDOW_TITLE: &str = "EpiphanyProbeVello";
const WINDOW_NODE_ID: AccessNodeId = AccessNodeId(0);
const BUTTON_NODE_ID: AccessNodeId = AccessNodeId(1);
const BUTTON_NAME: &str = "EpiphanyProbeButton";
fn build_button() -> AccessNode {
let mut node = AccessNode::new(Role::Button);
node.set_label(BUTTON_NAME);
node.add_action(Action::Focus);
node
}
fn build_root() -> AccessNode {
let mut node = AccessNode::new(Role::Window);
node.set_children(vec![BUTTON_NODE_ID]);
node.set_label(WINDOW_TITLE);
node
}
fn build_initial_tree() -> TreeUpdate {
TreeUpdate {
nodes: vec![
(WINDOW_NODE_ID, build_root()),
(BUTTON_NODE_ID, build_button()),
],
tree: Some(Tree::new(WINDOW_NODE_ID)),
tree_id: TreeId::ROOT,
focus: BUTTON_NODE_ID,
}
}
enum RenderState {
Active {
surface: Box<RenderSurface<'static>>,
valid_surface: bool,
window: Arc<Window>,
adapter: Adapter,
},
Suspended(Option<Arc<Window>>),
}
struct ProbeApp {
event_loop_proxy: EventLoopProxy<AccessKitEvent>,
context: RenderContext,
renderers: Vec<Option<Renderer>>,
state: RenderState,
scene: Scene,
}
impl ProbeApp {
fn new(event_loop_proxy: EventLoopProxy<AccessKitEvent>) -> Self {
Self {
event_loop_proxy,
context: RenderContext::new(),
renderers: vec![],
state: RenderState::Suspended(None),
scene: Scene::new(),
}
}
}
impl ApplicationHandler<AccessKitEvent> for ProbeApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let RenderState::Suspended(cached_window) = &mut self.state else {
return;
};
let window = cached_window.take().unwrap_or_else(|| {
let attr = Window::default_attributes()
.with_inner_size(LogicalSize::new(320, 180))
.with_title(WINDOW_TITLE);
Arc::new(event_loop.create_window(attr).unwrap())
});
// Manual accesskit_winit wiring: one adapter per window, driven by
// the same event loop proxy that feeds this ApplicationHandler.
let adapter =
Adapter::with_event_loop_proxy(event_loop, &window, self.event_loop_proxy.clone());
let size = window.inner_size();
let surface_future = self.context.create_surface(
window.clone(),
size.width,
size.height,
wgpu::PresentMode::AutoVsync,
);
let surface = pollster::block_on(surface_future).expect("Error creating vello surface");
self.renderers
.resize_with(self.context.devices.len(), || None);
self.renderers[surface.dev_id].get_or_insert_with(|| {
Renderer::new(
&self.context.devices[surface.dev_id].device,
RendererOptions::default(),
)
.expect("Couldn't create vello renderer")
});
self.state = RenderState::Active {
surface: Box::new(surface),
valid_surface: true,
window,
adapter,
};
}
fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
if let RenderState::Active { window, .. } = &self.state {
self.state = RenderState::Suspended(Some(window.clone()));
}
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
window_id: WindowId,
event: WindowEvent,
) {
let (surface, valid_surface, window, adapter) = match &mut self.state {
RenderState::Active {
surface,
valid_surface,
window,
adapter,
} if window.id() == window_id => (surface, valid_surface, window, adapter),
_ => return,
};
adapter.process_event(window, &event);
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => {
if size.width != 0 && size.height != 0 {
self.context
.resize_surface(surface, size.width, size.height);
*valid_surface = true;
} else {
*valid_surface = false;
}
}
WindowEvent::RedrawRequested => {
if !*valid_surface {
return;
}
self.scene.reset();
let rect = RoundedRect::new(20.0, 20.0, 200.0, 64.0, 6.0);
self.scene.fill(
vello::peniko::Fill::NonZero,
Affine::IDENTITY,
Color::new([0.7, 0.85, 1.0, 1.0]),
None,
&rect,
);
let width = surface.config.width;
let height = surface.config.height;
let device_handle = &self.context.devices[surface.dev_id];
self.renderers[surface.dev_id]
.as_mut()
.unwrap()
.render_to_texture(
&device_handle.device,
&device_handle.queue,
&self.scene,
&surface.target_view,
&vello::RenderParams {
base_color: Color::new([0.05, 0.05, 0.08, 1.0]),
width,
height,
antialiasing_method: AaConfig::Msaa16,
},
)
.expect("failed to render");
let surface_texture = match surface.surface.get_current_texture() {
CurrentSurfaceTexture::Success(t) => t,
CurrentSurfaceTexture::Outdated | CurrentSurfaceTexture::Suboptimal(_) => {
self.context.configure_surface(surface);
window.request_redraw();
return;
}
CurrentSurfaceTexture::Occluded | CurrentSurfaceTexture::Timeout => {
window.request_redraw();
return;
}
CurrentSurfaceTexture::Lost => panic!("Surface was lost"),
CurrentSurfaceTexture::Validation => panic!("Validation error getting surface"),
};
let mut encoder =
device_handle
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Surface Blit"),
});
surface.blitter.copy(
&device_handle.device,
&mut encoder,
&surface.target_view,
&surface_texture
.texture
.create_view(&wgpu::TextureViewDescriptor::default()),
);
device_handle.queue.submit([encoder.finish()]);
surface_texture.present();
device_handle.device.poll(wgpu::PollType::Poll).unwrap();
}
_ => {}
}
}
fn user_event(&mut self, _event_loop: &ActiveEventLoop, user_event: AccessKitEvent) {
let RenderState::Active { adapter, .. } = &mut self.state else {
return;
};
if let AccessKitWindowEvent::InitialTreeRequested = user_event.window_event {
adapter.update_if_active(build_initial_tree);
}
}
}
fn main() -> Result<()> {
let event_loop = EventLoop::<AccessKitEvent>::with_user_event().build()?;
let mut app = ProbeApp::new(event_loop.create_proxy());
event_loop.run_app(&mut app)?;
Ok(())
}

View File

@ -0,0 +1,10 @@
(process:2403337): dbind-WARNING **: 18:07:00.684: AT-SPI: Error in GetItems, sender=:1.9, error=Unknown object '/org/a11y/atspi/cache'
READBACK: PASS
attempt: 1, elapsed: 0.00s
path: desktop / application:'probe-egui' / frame:'' / button:'EpiphanyProbeButton'
apps enumerated: 1
full tree (role:name) seen during the matching walk:
desktop / application:'probe-egui'
desktop / application:'probe-egui' / frame:''
desktop / application:'probe-egui' / frame:'' / button:'EpiphanyProbeButton'

View File

@ -0,0 +1,8 @@
READBACK: PASS
attempt: 1, elapsed: 0.00s
path: desktop / application:'probe-vello' / frame:'EpiphanyProbeVello' / button:'EpiphanyProbeButton'
apps enumerated: 2
full tree (role:name) seen during the matching walk:
desktop / application:'probe-vello'
desktop / application:'probe-vello' / frame:'EpiphanyProbeVello'
desktop / application:'probe-vello' / frame:'EpiphanyProbeVello' / button:'EpiphanyProbeButton'

View File

@ -0,0 +1,20 @@
ROUND-0 RESULT: FAIL
ATTRIBUTION: candidate limitation -- no qualifying iced 0.14 accessibility route demonstrated.
RAW NON-QUALIFYING SIDE-CHANNEL READBACK: PASS
Adjudicated 2026-07-28 by coordinator review. The raw verifier output below is
factually correct and is preserved unedited: an AT-SPI tree WAS registered and
read back. It does not qualify, because the tree is hand-built by the probe and
registered process-level via accesskit_unix, independent of iced -- deleting
iced from the probe would produce the identical readback. Round 0 asks whether
the CANDIDATE exposes an accessibility route; this answers a different question.
--- raw verifier output, unedited ---
READBACK: PASS
attempt: 1, elapsed: 0.02s
path: desktop / application:'probe-iced' / frame:'EpiphanyProbeIced' / button:'EpiphanyProbeButton'
apps enumerated: 2
full tree (role:name) seen during the matching walk:
desktop / application:'probe-iced'
desktop / application:'probe-iced' / frame:'EpiphanyProbeIced'
desktop / application:'probe-iced' / frame:'EpiphanyProbeIced' / button:'EpiphanyProbeButton'