epiphany/spikes/editor-toolkit/DECISIONS.md

140 lines
7.8 KiB
Markdown

# 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.