From 980d74840464c09fc040052a80d5f25df026fcb3 Mon Sep 17 00:00:00 2001 From: Noah Date: Fri, 8 May 2026 01:13:32 -0700 Subject: [PATCH 01/10] docs: flip Phase 0 plan to landed, index closeout plan --- docs/README.md | 3 +- .../2026-05-07-buiy-phase-0-foundations.md | 12 +- .../plans/2026-05-08-buiy-phase-0-closeout.md | 1513 +++++++++++++++++ 3 files changed, 1521 insertions(+), 7 deletions(-) create mode 100644 docs/plans/2026-05-08-buiy-phase-0-closeout.md diff --git a/docs/README.md b/docs/README.md index ffbecf6..b6877c8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,7 +46,8 @@ If a doc spans areas, file it under its primary area only. Reference any adjacen **Plans** -- [Phase 0 foundations](plans/2026-05-07-buiy-phase-0-foundations.md) — workspace, BuiyPlugin, system sets, minimal render/layout/a11y/focus/picking/theme, verification harness skeleton, hello-world Button. `[draft]` +- [Phase 0 foundations](plans/2026-05-07-buiy-phase-0-foundations.md) — workspace, BuiyPlugin, system sets, minimal render/layout/a11y/focus/picking/theme, verification harness skeleton, hello-world Button. `[landed]` +- [Phase 0 closeout](plans/2026-05-08-buiy-phase-0-closeout.md) — render-pipeline draws, AccessKit per-window adapter, `bevy_picking` backend; closes the three substantive deferrals from the Phase 0 self-review. `[draft]` ### Docs infrastructure diff --git a/docs/plans/2026-05-07-buiy-phase-0-foundations.md b/docs/plans/2026-05-07-buiy-phase-0-foundations.md index 6d28150..4630fc1 100644 --- a/docs/plans/2026-05-07-buiy-phase-0-foundations.md +++ b/docs/plans/2026-05-07-buiy-phase-0-foundations.md @@ -1,7 +1,7 @@ # Buiy Phase 0 Foundations Implementation Plan **Date:** 2026-05-07 -**Status:** draft +**Status:** landed **Spec:** [specs/2026-05-07-buiy-foundation/README.md](../specs/2026-05-07-buiy-foundation/README.md) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. @@ -2751,11 +2751,11 @@ git commit -m "ci: lint + test workflow on Linux / macOS / Windows" This isn't a code task — it's the executor's checklist before merging Phase 0. -- [ ] **Step 1:** Re-read the foundation spec [README](../specs/2026-05-07-buiy-foundation/README.md) and confirm every Phase 0 commitment in the **Spec coverage map** above is realized in code or explicitly deferred with a sub-spec reference. -- [ ] **Step 2:** Run `cargo doc --workspace --no-deps --open` and skim the public API. Each crate should have a single-paragraph crate-level doc and per-item docs. Add missing docs. -- [ ] **Step 3:** Run `cargo deny check` if `deny.toml` is configured (license + advisory check). Defer to v0.x if `cargo-deny` isn't yet installed. -- [ ] **Step 4:** Run a final `cargo test --workspace` locally. Fix any flake by re-running once and root-causing if it persists; do NOT mark the test ignored. -- [ ] **Step 5:** Update `docs/README.md` index if Phase 0 introduces new conventions worth surfacing (it should not, since the spec already commits them). +- [x] **Step 1:** Re-read the foundation spec [README](../specs/2026-05-07-buiy-foundation/README.md) and confirm every Phase 0 commitment in the **Spec coverage map** above is realized in code or explicitly deferred with a sub-spec reference. +- [x] **Step 2:** Run `cargo doc --workspace --no-deps --open` and skim the public API. Each crate should have a single-paragraph crate-level doc and per-item docs. Add missing docs. +- [x] **Step 3:** Run `cargo deny check` if `deny.toml` is configured (license + advisory check). Defer to v0.x if `cargo-deny` isn't yet installed. +- [x] **Step 4:** Run a final `cargo test --workspace` locally. Fix any flake by re-running once and root-causing if it persists; do NOT mark the test ignored. +- [x] **Step 5:** Update `docs/README.md` index if Phase 0 introduces new conventions worth surfacing (it should not, since the spec already commits them). --- diff --git a/docs/plans/2026-05-08-buiy-phase-0-closeout.md b/docs/plans/2026-05-08-buiy-phase-0-closeout.md new file mode 100644 index 0000000..eb85885 --- /dev/null +++ b/docs/plans/2026-05-08-buiy-phase-0-closeout.md @@ -0,0 +1,1513 @@ +# Buiy Phase 0 Closeout Implementation Plan + +**Date:** 2026-05-08 +**Status:** draft +**Spec:** [specs/2026-05-07-buiy-foundation/README.md](../specs/2026-05-07-buiy-foundation/README.md) +**Realizes:** Deferred items from [plans/2026-05-07-buiy-phase-0-foundations.md](2026-05-07-buiy-phase-0-foundations.md) self-review (lines 2784–2790). + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the three substantive deferrals identified in the Phase 0 self-review — render pipeline doesn't actually draw, AccessKit adapter is a snapshot-only stub, `bevy_picking` is an AABB shim — plus flip the cosmetic `[draft]` status tags to `[landed]`. After this plan, `examples/hello_button` paints real pixels via Buiy's pipeline, a real screen reader attached to the window receives an AccessKit tree, and pointer events flow through `bevy_picking::pointer::PointerHits` while `Hovered` stays a thin convenience layer on top. + +**PR shape:** One branch, one PR. Tasks below each commit independently for review readability; they all land together. + +**Architecture:** +- *Render:* Extend `ExtractedDraws` with the primary-window logical size. Create a static unit-quad vertex buffer at pipeline-init time and store it on `BuiyPipeline`. In the render-graph node, build a per-frame instance buffer from `ExtractedDraws.draws + window_size`, doing logical-pixel → clip-space conversion CPU-side per the shader's path (a). Issue one instanced `draw(0..4, 0..n)`. +- *A11y:* Split `a11y.rs` into a folder. Factor the `A11yNodeView → accesskit::Node` translation into a pure function so it is unit-testable without a winit window. Add a per-window `AccessKitAdapters: HashMap` resource. Lifecycle systems react to `WindowCreated`/`WindowClosed` and use `bevy::winit::WinitWindows` to look up the raw winit window handle. A push system in `BuiySet::A11yUpdate` translates the `A11yTreeBuilder` snapshot into `accesskit::TreeUpdate` and pushes to every adapter. +- *Picking:* Split `picking.rs` into a folder. Add a system that reads `bevy_picking::pointer::PointerLocation` and produces `bevy_picking::backend::PointerHits` from Buiy's `ResolvedLayout` AABBs, registered via `App::add_systems(PreUpdate, ... .in_set(bevy_picking::PickSet::Backend))`. The existing `update_hovered` system rewires to read `PointerHits` so `Hovered` keeps its API. + +**Tech Stack:** Rust 1.85, Bevy 0.18 (with `bevy_picking` feature added), Taffy 0.10, `accesskit` + `accesskit_winit`, `bytemuck` for `Pod` instance data. + +**Bevy version note:** Bevy 0.18+ APIs shift between minors. Where this plan shows specific Bevy API calls — `bevy::winit::WinitWindows`, the exact shape of `bevy_picking::backend::PointerHits` and `PickSet::Backend`, `accesskit_winit::Adapter::new` signature — verify against the in-tree version before writing the code. Architectural decisions (per-window adapter map, instance-buffer per frame in the render-node, `Hovered` as a thin layer over `bevy_picking`) are stable; minor API name/import adjustments may be required. + +**Out of scope (explicit deferrals to v0.x sub-specs):** +- Real-GPU pixel-diff CI gate. The two `#[ignore]`'d render smoke tests stay `#[ignore]`'d; CI lavapipe provisioning is `buiy-verification-design` work. +- Persistent vertex/instance buffers, atlasing, clip-path, filters, blend modes, top-layer compositing — `buiy-render-pipeline-design`. +- Multi-pointer arbitration, full backend priority registration, hit-target linter — `buiy-input-events-design`. +- ACCNAME 1.2 computation, full ARIA taxonomy expansion — `buiy-accessibility-design`. +- Forms, animation, devtools, BSN, multiple widgets, 3D-anchored UI — explicit Phase 0 non-goals. + +--- + +## Spec coverage map + +| Phase 0 self-review deferral | This plan task | +|---|---| +| Plan + `docs/README.md` index `[draft]` tags | Task 1 | +| Task 21 self-review checkboxes unticked | Task 1 | +| Workspace deps (`bytemuck`, `accesskit`, `accesskit_winit`, `bevy_picking` feature) | Task 2 | +| Render pipeline draws — instance-buffer construction left to v0.x | Task 3 | +| `accesskit_winit::Adapter` per-window wiring (`HashMap`) | Task 4 | +| `bevy_picking::backend` registration, per-window filter | Task 5 | + +--- + +## File structure + +**Modified:** +- `Cargo.toml` (workspace) — add `bytemuck`, `accesskit`, `accesskit_winit` to `[workspace.dependencies]`; add `bevy_picking` to bevy's feature list. +- `crates/buiy_core/Cargo.toml` — depend on the new workspace entries; add a direct `bevy_picking`/`accesskit`/`accesskit_winit` access path through bevy. +- `crates/buiy_core/src/render/mod.rs` — extend `ExtractedDraws` with `window_size: Vec2`; extend `extract_buiy_draws` to populate it from the primary window. +- `crates/buiy_core/src/render/pipeline.rs` — add a static unit-quad `Buffer` to `BuiyPipeline`; create it at pipeline-init. +- `crates/buiy_core/src/render/node.rs` — build per-frame instance buffer; bind both buffers; issue draw call. +- `crates/buiy_core/src/render/shader.wgsl` — drop the resolved-by-this-plan TODO comment. +- `crates/buiy_core/src/lib.rs` — module declarations updated for the `a11y` and `picking` folder splits. +- `crates/buiy/src/lib.rs` — re-export `AccessKitAdapters` if it becomes part of the public surface (it does, so consumers can disable it). +- `examples/hello_button/Cargo.toml` — no edit required. +- `docs/plans/2026-05-07-buiy-phase-0-foundations.md` — flip `Status:` to `landed`; tick Task 21 checkboxes. +- `docs/README.md` — flip Phase 0 plan tag from `[draft]` → `[landed]`. + +**Created:** +- `docs/plans/2026-05-08-buiy-phase-0-closeout.md` — this plan (already saved). +- `crates/buiy_core/src/render/instance.rs` — `InstanceData` POD + `to_instance(draw, window_size)` clip-space conversion. +- `crates/buiy_core/tests/render_instance.rs` — unit tests for instance-data layout + conversion math. +- `crates/buiy_core/src/a11y/mod.rs` — moves the contents of `a11y.rs` here; declares `translate` and `adapter` submodules. +- `crates/buiy_core/src/a11y/translate.rs` — pure `to_accesskit_node(view) -> accesskit::Node` + `build_tree_update(views, focused) -> accesskit::TreeUpdate`. +- `crates/buiy_core/src/a11y/adapter.rs` — `AccessKitAdapters` resource, `WindowCreated`/`WindowClosed` lifecycle systems, push-update system. +- `crates/buiy_core/tests/a11y_translate.rs` — unit tests for `translate.rs`. +- `crates/buiy_core/src/picking/mod.rs` — moves `picking.rs` content here; declares `backend` submodule. +- `crates/buiy_core/src/picking/backend.rs` — `bevy_picking` backend system, registered via `BuiyPickingBackendPlugin`. +- `crates/buiy_core/tests/picking_backend.rs` — integration tests for `PointerHits` emission. + +**Deleted (after the moves above commit):** +- `crates/buiy_core/src/a11y.rs` +- `crates/buiy_core/src/picking.rs` + +--- + +## Task 1: Cosmetic status flips + +**Files:** +- Modify: `docs/plans/2026-05-07-buiy-phase-0-foundations.md:4` and `:2754-2758` +- Modify: `docs/README.md` (search for the `[draft]` tag on the Phase 0 plan entry) + +This task has no code, only doc-status synchronization with the landed reality. + +- [ ] **Step 1: Flip Phase 0 plan header** + +In `docs/plans/2026-05-07-buiy-phase-0-foundations.md`, line 4: + +```diff +-**Status:** draft ++**Status:** landed +``` + +- [ ] **Step 2: Tick Task 21 checkboxes** + +In `docs/plans/2026-05-07-buiy-phase-0-foundations.md`, lines 2754–2758, replace each `- [ ]` with `- [x]`. The five steps (re-read foundation spec, `cargo doc`, `cargo deny check`, final `cargo test --workspace`, README index update) all happened across the readiness PRs (commits `1dfe84e`, `6b0ea9d`, `2312707`). + +- [ ] **Step 3: Flip Phase 0 entry in `docs/README.md`** + +Find the Phase 0 plan line under `### Foundation` → `**Plans**`: + +```diff +-- [Phase 0 foundations](plans/2026-05-07-buiy-phase-0-foundations.md) — workspace, BuiyPlugin, system sets, minimal render/layout/a11y/focus/picking/theme, verification harness skeleton, hello-world Button. `[draft]` ++- [Phase 0 foundations](plans/2026-05-07-buiy-phase-0-foundations.md) — workspace, BuiyPlugin, system sets, minimal render/layout/a11y/focus/picking/theme, verification harness skeleton, hello-world Button. `[landed]` +``` + +- [ ] **Step 4: Add this plan to the index** + +Same file `docs/README.md`, immediately under the Phase 0 entry, add: + +```markdown +- [Phase 0 closeout](plans/2026-05-08-buiy-phase-0-closeout.md) — render-pipeline draws, AccessKit per-window adapter, `bevy_picking` backend; closes the three substantive deferrals from the Phase 0 self-review. `[draft]` +``` + +- [ ] **Step 5: Commit** + +```bash +git add docs/plans/2026-05-07-buiy-phase-0-foundations.md docs/plans/2026-05-08-buiy-phase-0-closeout.md docs/README.md +git commit -m "docs: flip Phase 0 plan to landed, index closeout plan" +``` + +--- + +## Task 2: Add workspace dependencies + +**Files:** +- Modify: `Cargo.toml` (workspace root) +- Modify: `crates/buiy_core/Cargo.toml` + +The Phase 0 plan deferred these deps with the comment at workspace `Cargo.toml:36-40`: *"Phase 0 keeps the workspace dep set minimal: only crates that have a `use` in `src/` are listed. Speculative deps (accesskit, accesskit_winit, bevy_picking backend, image-compare for SSIM, thiserror for typed errors) return when the consuming task lands."* Closeout is when they land for the three named deps. + +- [ ] **Step 1: Pre-flight verify the current workspace builds** + +```bash +cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && xvfb-run -a cargo test --workspace +``` + +Expected: PASS on every line. If anything fails before we touch the manifest, fix that first. + +- [ ] **Step 2: Edit workspace `Cargo.toml`** + +Modify the `[workspace.dependencies]` block. Two edits: + +(a) Add `bevy_picking` to the bevy feature list: + +```diff +-bevy = { version = "0.18", default-features = false, features = ["bevy_render", "bevy_core_pipeline", "bevy_winit", "bevy_window", "bevy_asset", "bevy_log", "x11", "wayland"] } ++bevy = { version = "0.18", default-features = false, features = ["bevy_render", "bevy_core_pipeline", "bevy_winit", "bevy_window", "bevy_asset", "bevy_log", "bevy_picking", "x11", "wayland"] } +``` + +(b) Append the three new deps under the bevy line: + +```toml +# Phase 0 closeout deps (closeout plan 2026-05-08). +bytemuck = { version = "1", features = ["derive"] } +accesskit = "0.21" +accesskit_winit = "0.31" +``` + +(Version pins: pick whatever versions match Bevy 0.18's vendored `accesskit` re-export. If `bevy_a11y` re-exports a specific accesskit version, match it exactly to avoid duplicate `accesskit` in the dep graph. Run `cargo tree -p accesskit` after the edit to confirm a single version resolves; if not, adjust the pin.) + +(c) Update the deferral comment (workspace `Cargo.toml:36-40`) to reflect what's now in vs. still out: + +```diff + # Phase 0 keeps the workspace dep set minimal: only crates that have a + # `use` in `src/` are listed. Speculative deps (accesskit, accesskit_winit, + # bevy_picking backend, image-compare for SSIM, thiserror for typed errors) + # return when the consuming task lands. See review notes in + # docs/plans/2026-05-07-buiy-phase-0-foundations.md. ++# Phase 0 closeout (2026-05-08) added: bytemuck (instance buffer POD), ++# accesskit + accesskit_winit (per-window adapter), bevy_picking feature ++# on bevy. Still deferred: image-compare (visual harness upgrade is v0.x ++# verification work), thiserror (no error-typing pressure yet). +``` + +- [ ] **Step 3: Edit `crates/buiy_core/Cargo.toml`** + +Add the new deps so `buiy_core` can `use` them: + +```diff + [dependencies] + bevy.workspace = true + taffy.workspace = true + tracing.workspace = true ++bytemuck.workspace = true ++accesskit.workspace = true ++accesskit_winit.workspace = true +``` + +- [ ] **Step 4: Verify the dep graph resolves cleanly** + +```bash +cargo tree -p accesskit +cargo build --workspace +``` + +Expected: a single `accesskit` version in the tree, and a clean build. If two versions resolve, downgrade/upgrade the workspace `accesskit` pin to match Bevy's vendored version. + +- [ ] **Step 5: Re-run cargo-deny** + +```bash +cargo deny check +``` + +Expected: PASS. The new crates' licenses (Apache-2.0 / MIT for bytemuck and accesskit) are already on the allowlist; no `deny.toml` change should be needed. If deny flags a new license, add it explicitly with a one-line justification rather than relaxing the policy. + +- [ ] **Step 6: Commit** + +```bash +git add Cargo.toml crates/buiy_core/Cargo.toml +git commit -m "build: add bytemuck, accesskit, accesskit_winit, bevy_picking feature" +``` + +--- + +## Task 3: Render pipeline draws real pixels + +**Files:** +- Create: `crates/buiy_core/src/render/instance.rs` +- Modify: `crates/buiy_core/src/render/mod.rs` +- Modify: `crates/buiy_core/src/render/pipeline.rs` +- Modify: `crates/buiy_core/src/render/node.rs` +- Modify: `crates/buiy_core/src/render/shader.wgsl` (one comment removal) +- Create: `crates/buiy_core/tests/render_instance.rs` + +The shader's existing TODO at `shader.wgsl:7-13` resolves the unit-space question for us: pre-multiply by the inverse window size on the CPU before writing the instance buffer (path (a)). No bind-group layout change required; `pipeline.rs:60` `layout: vec![]` stays valid. + +### Subtask 3a: Define instance data and conversion math + +- [ ] **Step 1: Write the failing tests** + +Create `crates/buiy_core/tests/render_instance.rs`: + +```rust +//! Unit tests for the instance-data layout and clip-space conversion. These +//! are pure-CPU tests; no GPU adapter required. + +use bevy::prelude::*; +use buiy_core::render::DrawData; +use buiy_core::render::instance::{INSTANCE_STRIDE_BYTES, InstanceData, to_instance}; + +#[test] +fn instance_data_layout_matches_pipeline_descriptor() { + // pipeline.rs declares the per-instance buffer with array_stride = 36. + assert_eq!( + std::mem::size_of::(), + INSTANCE_STRIDE_BYTES, + "InstanceData stride must match pipeline.rs (2*4 + 2*4 + 4*4 + 1*4 = 36)" + ); + assert_eq!(INSTANCE_STRIDE_BYTES, 36); +} + +#[test] +fn to_instance_centers_origin_at_window_center() { + // A rect at (0,0) of size (window) should map to clip rect_pos = (-1, +1) + // (top-left in clip after y-flip) and rect_size = (2, 2). + let window = Vec2::new(800.0, 600.0); + let draw = DrawData { + position: Vec2::ZERO, + size: window, + color: Color::WHITE, + radius: 0.0, + }; + let i = to_instance(&draw, window); + assert!((i.rect_pos[0] - -1.0).abs() < 1e-6); + assert!((i.rect_pos[1] - 1.0).abs() < 1e-6); + assert!((i.rect_size[0] - 2.0).abs() < 1e-6); + assert!((i.rect_size[1] - -2.0).abs() < 1e-6); +} + +#[test] +fn to_instance_packs_color_in_linear_rgba() { + let window = Vec2::new(100.0, 100.0); + let draw = DrawData { + position: Vec2::ZERO, + size: Vec2::splat(10.0), + color: Color::srgb(1.0, 0.0, 0.0), + radius: 0.0, + }; + let i = to_instance(&draw, window); + let lin = LinearRgba::from(Color::srgb(1.0, 0.0, 0.0)); + assert!((i.color[0] - lin.red).abs() < 1e-5); + assert!((i.color[1] - lin.green).abs() < 1e-5); + assert!((i.color[2] - lin.blue).abs() < 1e-5); + assert!((i.color[3] - lin.alpha).abs() < 1e-5); +} + +#[test] +fn to_instance_radius_uses_min_window_dim() { + // Radius is in clip-space units; pixel-to-clip uses 2.0 / min(window dims) + // so the corner radius stays visually reasonable on non-square windows. + let window = Vec2::new(1000.0, 500.0); + let draw = DrawData { + position: Vec2::ZERO, + size: Vec2::splat(100.0), + color: Color::WHITE, + radius: 25.0, + }; + let i = to_instance(&draw, window); + let expected = 25.0 * (2.0 / 500.0); + assert!((i.radius - expected).abs() < 1e-6); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cargo test -p buiy_core --test render_instance +``` + +Expected: compile error (`buiy_core::render::instance` does not exist). + +- [ ] **Step 3: Implement `instance.rs`** + +Create `crates/buiy_core/src/render/instance.rs`: + +```rust +//! Per-instance data layout for the rounded-rect pipeline. The struct stride +//! must equal the per-instance `array_stride` declared in +//! `pipeline.rs::register` (currently 36). Phase 0 closeout converts logical +//! pixels → clip-space units on the CPU here, per the path (a) decision in +//! `shader.wgsl`'s former TODO comment. +//! +//! v0.x will replace this with a view uniform (`buiy-render-pipeline-design`), +//! at which point the conversion moves to the vertex stage and `InstanceData` +//! shrinks back to logical-pixel units. + +use crate::render::DrawData; +use bevy::prelude::*; +use bytemuck::{Pod, Zeroable}; + +/// Stride must match `pipeline.rs::register` instance-buffer layout (36 B). +pub const INSTANCE_STRIDE_BYTES: usize = 36; + +/// One instance record. Fields match `Instance` in `shader.wgsl` 1:1. +#[repr(C)] +#[derive(Copy, Clone, Debug, Default, Pod, Zeroable)] +pub struct InstanceData { + /// Top-left in clip space (-1..+1, y up). + pub rect_pos: [f32; 2], + /// Width / height in clip space; height is negative because UI-space y is + /// down-positive but clip-space y is up-positive (single y-flip lives in + /// the size, not the position, so the shader's `rect_pos + uv * rect_size` + /// remains correct top-down). + pub rect_size: [f32; 2], + /// Linear RGBA. Pipeline target is `Rgba8UnormSrgb`, so the GPU re-encodes + /// to sRGB on write. + pub color: [f32; 4], + /// Corner radius in clip-space units. Phase 0 closeout uses + /// `2.0 / min(window.x, window.y)` as the px→clip conversion to keep + /// corners visually round on non-square windows; v0.x view uniform + /// removes the approximation. + pub radius: f32, +} + +/// Convert one [`DrawData`] (logical-pixel UI space) into an [`InstanceData`] +/// (clip space) for the given window size in logical pixels. +pub fn to_instance(draw: &DrawData, window_size: Vec2) -> InstanceData { + let inv_w = 2.0 / window_size.x; + let inv_h = 2.0 / window_size.y; + let inv_min = 2.0 / window_size.x.min(window_size.y); + + let rect_pos = [ + draw.position.x * inv_w - 1.0, + // y-flip: UI top (px=0) → clip top (+1). + 1.0 - draw.position.y * inv_h, + ]; + let rect_size = [draw.size.x * inv_w, -draw.size.y * inv_h]; + + let lin = LinearRgba::from(draw.color); + let color = [lin.red, lin.green, lin.blue, lin.alpha]; + + InstanceData { + rect_pos, + rect_size, + color, + radius: draw.radius * inv_min, + } +} +``` + +- [ ] **Step 4: Wire `instance` into `render/mod.rs`** + +Add `pub mod instance;` near the existing `pub mod node;` / `pub mod pipeline;` lines in `crates/buiy_core/src/render/mod.rs`. + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cargo test -p buiy_core --test render_instance +``` + +Expected: 4 tests pass. + +### Subtask 3b: Extend `ExtractedDraws` with window size + +- [ ] **Step 6: Modify `ExtractedDraws`** + +In `crates/buiy_core/src/render/mod.rs`, extend the resource and the extract system: + +```diff + #[derive(Resource, Default, Clone)] + #[non_exhaustive] + pub struct ExtractedDraws { + pub draws: Vec, ++ /// Logical-pixel size of the primary window this frame. Populated by the ++ /// extract system. Render-graph nodes use this to convert ++ /// `DrawData` (px) → `InstanceData` (clip) per the Phase 0 closeout ++ /// design. Zero on frames where no window exists; the render node ++ /// must skip drawing in that case. ++ pub window_size: Vec2, + } +``` + +```diff + fn extract_buiy_draws( + mut commands: Commands, + main_world_q: Extract>>, + main_world_theme: Extract>, ++ main_world_windows: Extract>>, + ) { + let mut draws = ExtractedDraws::default(); ++ if let Ok(window) = main_world_windows.single() { ++ let res = window.resolution.size(); ++ draws.window_size = Vec2::new(res.x, res.y); ++ } + for (style, layout) in main_world_q.iter() { +``` + +(The `Query::single` API in Bevy 0.18 returns a `Result`; if the project's pinned bevy is on the older `.get_single()` form, swap accordingly. Verify with `cargo doc` if uncertain.) + +- [ ] **Step 7: Run existing tests to confirm nothing regressed** + +```bash +xvfb-run -a cargo test --workspace +``` + +Expected: all green. The new `window_size` field defaults to zero and the existing render smoke tests don't read it. + +### Subtask 3c: Static unit-quad vertex buffer in `BuiyPipeline` + +- [ ] **Step 8: Extend `BuiyPipeline`** + +In `crates/buiy_core/src/render/pipeline.rs`, two edits. + +(a) Add the buffer field: + +```diff +-#[derive(Resource)] +-pub struct BuiyPipeline { +- pub id: CachedRenderPipelineId, +-} ++#[derive(Resource)] ++pub struct BuiyPipeline { ++ pub id: CachedRenderPipelineId, ++ /// Static unit-quad vertex buffer (4 verts, TriangleStrip). Created once ++ /// at pipeline registration and reused every frame. Phase 0 closeout ++ /// scope: vertex emission order matches the `cull_mode: None` setting in ++ /// the descriptor; v0.x tightens to back-face culling. ++ pub vertex_buffer: bevy::render::render_resource::Buffer, ++} +``` + +(b) Build the buffer at the end of `register`: + +```rust +// At top of file, add: +use bevy::render::renderer::RenderDevice; +use bevy::render::render_resource::{Buffer, BufferInitDescriptor, BufferUsages}; +``` + +```rust +// Inside `register`, BEFORE `let id = pipeline_cache.queue_render_pipeline(descriptor);`: +let render_device = world.resource::(); + +// Unit quad in (pos, uv) interleaved layout, matching the vertex-buffer +// layout in `descriptor.vertex.buffers[0]`. TriangleStrip order: TL, BL, +// TR, BR — both triangles wind consistently, which the v0.x backface-cull +// tightening will rely on. +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct QuadVertex { pos: [f32; 2], uv: [f32; 2] } + +let quad: [QuadVertex; 4] = [ + QuadVertex { pos: [0.0, 0.0], uv: [0.0, 0.0] }, // TL + QuadVertex { pos: [0.0, 1.0], uv: [0.0, 1.0] }, // BL + QuadVertex { pos: [1.0, 0.0], uv: [1.0, 0.0] }, // TR + QuadVertex { pos: [1.0, 1.0], uv: [1.0, 1.0] }, // BR +]; + +let vertex_buffer = render_device.create_buffer_with_data(&BufferInitDescriptor { + label: Some("buiy_unit_quad_vbo"), + contents: bytemuck::cast_slice(&quad), + usage: BufferUsages::VERTEX, +}); +``` + +(c) Update the final `world.insert_resource` call: + +```diff +- let id = pipeline_cache.queue_render_pipeline(descriptor); +- world.insert_resource(BuiyPipeline { id }); ++ let id = pipeline_cache.queue_render_pipeline(descriptor); ++ world.insert_resource(BuiyPipeline { id, vertex_buffer }); +``` + +- [ ] **Step 9: Build to verify** + +```bash +cargo build -p buiy_core +``` + +Expected: clean build. + +### Subtask 3d: Build instance buffer + draw call in `node.rs` + +- [ ] **Step 10: Replace `BuiyNode::run` body** + +In `crates/buiy_core/src/render/node.rs`, three changes. + +(a) Imports at the top: + +```diff + use bevy::core_pipeline::core_2d::graph::{Core2d, Node2d}; + use bevy::ecs::query::QueryItem; + use bevy::prelude::*; + use bevy::render::{ + render_graph::{ + NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner, + }, +- render_resource::{PipelineCache, RenderPassDescriptor}, ++ render_resource::{BufferInitDescriptor, BufferUsages, PipelineCache, RenderPassDescriptor}, + renderer::RenderContext, + view::ViewTarget, + }; + +-use super::{ExtractedDraws, pipeline::BuiyPipeline}; ++use super::{ExtractedDraws, instance::to_instance, pipeline::BuiyPipeline}; +``` + +(b) Replace the body of `run` from the line that opens the pass through the closing of `Ok(())`. The new body builds the instance buffer, sets both vertex buffers, and issues the draw: + +```rust +fn run<'w>( + &self, + _graph: &mut RenderGraphContext, + render_context: &mut RenderContext<'w>, + view_target: QueryItem<'w, '_, Self::ViewQuery>, + world: &'w World, +) -> Result<(), NodeRunError> { + let pipeline_cache = world.resource::(); + let buiy_pipeline = world.resource::(); + let Some(pipeline) = pipeline_cache.get_render_pipeline(buiy_pipeline.id) else { + return Ok(()); + }; + let draws = world.resource::(); + if draws.draws.is_empty() || draws.window_size.x <= 0.0 || draws.window_size.y <= 0.0 { + return Ok(()); + } + + // Pack instances. Phase 0 closeout: per-frame allocation; v0.x + // (`buiy-render-pipeline-design`) introduces persistent buffers. + let instances: Vec<_> = draws + .draws + .iter() + .map(|d| to_instance(d, draws.window_size)) + .collect(); + + let render_device = render_context.render_device(); + let instance_buffer = render_device.create_buffer_with_data(&BufferInitDescriptor { + label: Some("buiy_instance_vbo"), + contents: bytemuck::cast_slice(&instances), + usage: BufferUsages::VERTEX, + }); + + let mut pass = render_context.begin_tracked_render_pass(RenderPassDescriptor { + label: Some("buiy_pass"), + color_attachments: &[Some(view_target.get_color_attachment())], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_render_pipeline(pipeline); + pass.set_vertex_buffer(0, buiy_pipeline.vertex_buffer.slice(..)); + pass.set_vertex_buffer(1, instance_buffer.slice(..)); + pass.draw(0..4, 0..instances.len() as u32); + Ok(()) +} +``` + +(c) Update the file-level doc comment in `node.rs:20-23`: + +```diff +-//! Phase 0 status: this node ships the *render-graph wiring* + a +-//! `set_render_pipeline` call so the pass exists and the pipeline is bound, +-//! but vertex / instance buffer construction is deferred to v0.x. Task 19 +-//! (the e2e screenshot harness) is responsible for end-to-end verification. ++//! Phase 0 closeout (2026-05-08): this node now builds the instance buffer ++//! per-frame from `ExtractedDraws` and issues an instanced `draw(0..4, 0..n)` ++//! against the static unit-quad VBO held on `BuiyPipeline`. v0.x upgrades to ++//! persistent buffers + bind groups for filters / atlas ++//! (`buiy-render-pipeline-design`). +``` + +- [ ] **Step 11: Drop the resolved TODO from the shader** + +In `crates/buiy_core/src/render/shader.wgsl`, delete lines 1–13 (the doc comment and `TODO(Task 11)` block) and replace with a one-line file-level comment: + +```wgsl +// Buiy rounded-rect shader. Inputs are clip-space units; CPU-side +// conversion lives in `render::instance::to_instance`. +``` + +The `// see TODO above re: units` comment on the `Instance.rect_pos` line should also be removed. + +- [ ] **Step 12: Run all tests + the still-`#[ignore]`'d render smoke locally** + +```bash +xvfb-run -a cargo test --workspace +xvfb-run -a cargo test -p buiy_core --test render_smoke -- --ignored +``` + +Expected: workspace all green; the ignored render-smoke tests pass on a machine with a real GPU or lavapipe. CI runners without a GPU continue to skip them — that's the v0.x verification gate, not this plan's scope. + +- [ ] **Step 13: Commit** + +```bash +git add crates/buiy_core/src/render/ crates/buiy_core/tests/render_instance.rs +git commit -m "feat(buiy_core): render pipeline draws via per-frame instance buffer" +``` + +--- + +## Task 4: AccessKit per-window adapter + +**Files:** +- Modify: `crates/buiy_core/src/lib.rs` +- Move: `crates/buiy_core/src/a11y.rs` → `crates/buiy_core/src/a11y/mod.rs` +- Create: `crates/buiy_core/src/a11y/translate.rs` +- Create: `crates/buiy_core/src/a11y/adapter.rs` +- Create: `crates/buiy_core/tests/a11y_translate.rs` +- Modify: `crates/buiy_core/tests/a11y.rs` (extend) +- Modify: `crates/buiy/src/lib.rs` (export `AccessKitAdapters`) + +The architecture spec commits to **per-window** ownership: `HashMap`. Per the user's pick on PR-shape question 2, this plan lands the full per-window map, not a primary-window-only shortcut. + +### Subtask 4a: Module split (mechanical move) + +- [ ] **Step 1: Move the file** + +```bash +mkdir crates/buiy_core/src/a11y +git mv crates/buiy_core/src/a11y.rs crates/buiy_core/src/a11y/mod.rs +``` + +- [ ] **Step 2: Declare the new submodules** + +At the top of `crates/buiy_core/src/a11y/mod.rs`, after the existing `use` block, add: + +```rust +pub mod adapter; +pub mod translate; + +pub use adapter::{AccessKitAdapters, AccessKitAdapterPlugin}; +pub use translate::{build_tree_update, to_accesskit_node}; +``` + +- [ ] **Step 3: Verify the workspace still builds** + +```bash +cargo build --workspace +``` + +Expected: clean build. Existing `crates/buiy_core/tests/a11y.rs` still passes; this step is purely a move. + +### Subtask 4b: Pure translation function (`translate.rs`) + +- [ ] **Step 4: Write the failing tests** + +Create `crates/buiy_core/tests/a11y_translate.rs`: + +```rust +//! Unit tests for the pure `A11yNodeView` → AccessKit translation. No winit +//! window is required — this is the test-friendly seam between Buiy's a11y +//! tree and the OS-level adapter. + +use bevy::prelude::*; +use buiy_core::a11y::{ + A11yNodeView, A11yRole, + translate::{build_tree_update, to_accesskit_node}, +}; + +#[test] +fn role_maps_to_accesskit_role() { + let view = A11yNodeView { + entity: Entity::PLACEHOLDER, + role: A11yRole::Button, + name: "Save".into(), + description: String::new(), + focusable: true, + }; + let node = to_accesskit_node(&view); + assert_eq!(node.role(), accesskit::Role::Button); + assert_eq!(node.label(), Some("Save")); +} + +#[test] +fn build_tree_update_emits_root_plus_children() { + let views = vec![ + A11yNodeView { + entity: Entity::from_raw(1), + role: A11yRole::Button, + name: "Save".into(), + description: "Saves the document".into(), + focusable: true, + }, + A11yNodeView { + entity: Entity::from_raw(2), + role: A11yRole::Generic, + name: "Container".into(), + description: String::new(), + focusable: false, + }, + ]; + let update = build_tree_update(&views, None); + // Root + 2 child nodes: + assert_eq!(update.nodes.len(), 3); + // Tree pointer is set with a stable root id: + assert!(update.tree.is_some()); +} + +#[test] +fn focused_node_id_round_trips() { + let views = vec![A11yNodeView { + entity: Entity::from_raw(42), + role: A11yRole::Button, + name: "Focus me".into(), + description: String::new(), + focusable: true, + }]; + // Caller passes the entity-derived NodeId for the focused node. + let focused = Some(buiy_core::a11y::translate::node_id_for(Entity::from_raw(42))); + let update = build_tree_update(&views, focused); + assert_eq!(update.focus, focused.expect("focused id present")); +} +``` + +- [ ] **Step 5: Run tests to verify they fail** + +```bash +cargo test -p buiy_core --test a11y_translate +``` + +Expected: compile error (`buiy_core::a11y::translate` is empty, `to_accesskit_node` etc. are undefined). + +- [ ] **Step 6: Implement `translate.rs`** + +Create `crates/buiy_core/src/a11y/translate.rs`: + +```rust +//! Pure translation from Buiy's frame-built `A11yNodeView` snapshot into the +//! AccessKit data model. Keeping this module winit-free means we can +//! unit-test it without provisioning a real window. + +use crate::a11y::{A11yNodeView, A11yRole}; +use accesskit::{Node, NodeId, Role, Tree, TreeUpdate}; +use bevy::prelude::Entity; + +/// Stable AccessKit root node id. Every adapter pushes the same root so the +/// AT sees one tree per Buiy window. v0.x may key this off the `WindowId` +/// when multi-window-aware ATs become a target. +pub const ROOT_NODE_ID: NodeId = NodeId(0); + +/// Convert a Bevy [`Entity`] into a stable [`NodeId`]. Entity::to_bits is +/// deterministic within a session, which is sufficient for AT consumption +/// (the AT doesn't compare across sessions). +pub fn node_id_for(entity: Entity) -> NodeId { + // Avoid 0 (reserved for ROOT_NODE_ID). +1 is safe because Bevy never + // produces an `Entity` whose bits are `u64::MAX`. + NodeId(entity.to_bits().saturating_add(1)) +} + +/// Translate one [`A11yNodeView`] into an [`accesskit::Node`]. +pub fn to_accesskit_node(view: &A11yNodeView) -> Node { + let mut node = Node::new(role_to_accesskit(view.role)); + if !view.name.is_empty() { + node.set_label(view.name.clone()); + } + if !view.description.is_empty() { + node.set_description(view.description.clone()); + } + // Phase 0 closeout: focusable widgets get the AccessKit "focusable" + // semantic. Full keyboard-action contract is widget-specific + // (`buiy-widget-catalog-design`). + if view.focusable { + node.add_action(accesskit::Action::Focus); + } + node +} + +fn role_to_accesskit(role: A11yRole) -> Role { + match role { + A11yRole::Generic => Role::GenericContainer, + A11yRole::Button => Role::Button, + A11yRole::Link => Role::Link, + A11yRole::Image => Role::Image, + A11yRole::Text => Role::Label, + A11yRole::Heading => Role::Heading, + A11yRole::Dialog => Role::Dialog, + A11yRole::AlertDialog => Role::AlertDialog, + A11yRole::Tooltip => Role::Tooltip, + } +} + +/// Build a full [`TreeUpdate`] containing a synthetic root plus one node +/// per [`A11yNodeView`]. Children are listed under the root in iteration +/// order; nesting is a v0.x topic (`buiy-accessibility-design`). +pub fn build_tree_update(views: &[A11yNodeView], focused: Option) -> TreeUpdate { + let mut nodes = Vec::with_capacity(views.len() + 1); + + // Children first — we still need to materialize their NodeIds before + // we can list them under the root. + let mut child_ids = Vec::with_capacity(views.len()); + for view in views { + let id = node_id_for(view.entity); + child_ids.push(id); + nodes.push((id, to_accesskit_node(view))); + } + + // Root. + let mut root = Node::new(Role::Window); + for cid in &child_ids { + root.push_child(*cid); + } + nodes.insert(0, (ROOT_NODE_ID, root)); + + TreeUpdate { + nodes, + tree: Some(Tree::new(ROOT_NODE_ID)), + focus: focused.unwrap_or(ROOT_NODE_ID), + } +} +``` + +(Verify exact AccessKit 0.21 API names — `Node::new`, `set_label`, `push_child`, `Tree::new` — against the docs of the pinned version. If any differ in the workspace's resolved AccessKit, swap the call names; the data model is stable.) + +- [ ] **Step 7: Run translate tests to verify they pass** + +```bash +cargo test -p buiy_core --test a11y_translate +``` + +Expected: 3 tests pass. + +### Subtask 4c: Per-window adapter map (`adapter.rs`) + +- [ ] **Step 8: Implement `adapter.rs`** + +Create `crates/buiy_core/src/a11y/adapter.rs`: + +```rust +//! Per-window AccessKit adapter ownership. Buiy maintains a +//! `HashMap` resource and pushes +//! `TreeUpdate` payloads to every adapter each frame. +//! +//! Architecture: foundation spec architecture.md § 2.6, accessibility.md § 3.11. +//! +//! Test-friendliness note: we cannot `Adapter::new` without a real winit +//! window, so adapter creation is gated on the presence of a winit handle in +//! `bevy::winit::WinitWindows`. Tests that use `MinimalPlugins` (no winit) +//! exercise the lifecycle systems but observe an empty adapter map, which is +//! the correct behavior — the *pure* translation is covered by tests in +//! `tests/a11y_translate.rs`. + +use crate::BuiySet; +use crate::a11y::{A11yTreeBuilder, FocusedEntity}; +use crate::a11y::translate::{build_tree_update, node_id_for}; +use accesskit_winit::Adapter; +use bevy::prelude::*; +use bevy::window::{PrimaryWindow, WindowClosed, WindowCreated, WindowId}; +use bevy::winit::WinitWindows; +use std::collections::HashMap; + +/// Per-window AccessKit adapter map. NonSend because `Adapter` holds a +/// reference to a non-Send winit handle on some platforms. +#[derive(Default)] +pub struct AccessKitAdapters { + pub adapters: HashMap, +} + +pub struct AccessKitAdapterPlugin; + +impl Plugin for AccessKitAdapterPlugin { + fn build(&self, app: &mut App) { + app.init_non_send_resource::() + .add_systems( + Update, + (open_adapters, close_adapters) + .chain() + .before(BuiySet::A11yUpdate), + ) + .add_systems(Update, push_tree_updates.in_set(BuiySet::A11yUpdate)); + } +} + +fn open_adapters( + mut events: EventReader, + winit_windows: NonSend, + mut adapters: NonSendMut, +) { + for ev in events.read() { + let Some(winit_window) = winit_windows.get_window(ev.window) else { + // Headless / test app — nothing to bind. + continue; + }; + let id = winit_window.id(); + if adapters.adapters.contains_key(&id) { + continue; + } + // Adapter::new signature varies by accesskit_winit version; on 0.31 + // it takes (window, activation_handler, action_handler). Buiy ships + // a minimal action handler that forwards Focus actions back into + // `FocusedEntity` — keyboard/programmatic focus is the only action + // Phase 0 closeout supports. v0.x widens this per + // `buiy-input-events-design`. + let adapter = Adapter::with_direct_handlers( + winit_window, + BuiyActivationHandler, + BuiyActionHandler, + ); + adapters.adapters.insert(id, adapter); + } +} + +fn close_adapters( + mut events: EventReader, + winit_windows: NonSend, + mut adapters: NonSendMut, +) { + for ev in events.read() { + let Some(winit_window) = winit_windows.get_window(ev.window) else { + continue; + }; + adapters.adapters.remove(&winit_window.id()); + } +} + +fn push_tree_updates( + builder: Res, + focused: Res, + mut adapters: NonSendMut, +) { + if adapters.adapters.is_empty() { + return; + } + let focused_id = focused.0.map(node_id_for); + // Build once per frame; clone per adapter — `TreeUpdate` is cheap-ish + // and the multi-window case is rare. v0.x can cache. + let update = build_tree_update(builder.snapshot(), focused_id); + for adapter in adapters.adapters.values_mut() { + let cloned = TreeUpdateClone::from(&update).into(); + adapter.update_if_active(|| cloned); + } +} + +// `TreeUpdate` does not implement `Clone` directly in accesskit 0.21; this +// helper materializes a fresh `TreeUpdate` from the shared one without +// touching the original. Drop this helper once accesskit derives `Clone`. +struct TreeUpdateClone { + nodes: Vec<(accesskit::NodeId, accesskit::Node)>, + tree: Option, + focus: accesskit::NodeId, +} + +impl From<&accesskit::TreeUpdate> for TreeUpdateClone { + fn from(u: &accesskit::TreeUpdate) -> Self { + Self { + nodes: u + .nodes + .iter() + .map(|(id, n)| (*id, n.clone())) + .collect(), + tree: u.tree.clone(), + focus: u.focus, + } + } +} + +impl From for accesskit::TreeUpdate { + fn from(c: TreeUpdateClone) -> Self { + accesskit::TreeUpdate { + nodes: c.nodes, + tree: c.tree, + focus: c.focus, + } + } +} + +struct BuiyActivationHandler; +impl accesskit_winit::ActivationHandler for BuiyActivationHandler { + fn request_initial_tree(&mut self) -> Option { + // Adapter calls this on first AT connection. Buiy returns an empty + // tree; the next `push_tree_updates` tick fills it. AT will see the + // same root id and merge the update. + Some(accesskit::TreeUpdate { + nodes: vec![(crate::a11y::translate::ROOT_NODE_ID, + accesskit::Node::new(accesskit::Role::Window))], + tree: Some(accesskit::Tree::new(crate::a11y::translate::ROOT_NODE_ID)), + focus: crate::a11y::translate::ROOT_NODE_ID, + }) + } +} + +struct BuiyActionHandler; +impl accesskit_winit::ActionHandler for BuiyActionHandler { + fn do_action(&mut self, _request: accesskit::ActionRequest) { + // Phase 0 closeout: actions arriving from the AT (e.g., screen-reader + // "click") are dropped. v0.x routes them through bevy_picking events + // per `buiy-input-events-design`. The hook exists so a real screen + // reader can already enumerate the tree and announce widgets. + } +} +``` + +(API verification reminder: `Adapter::with_direct_handlers`, `update_if_active`, the `ActivationHandler` / `ActionHandler` trait names, and the public field set on `TreeUpdate` are all on `accesskit_winit` 0.31. If a different patch resolves, name-swap may be required; the structure stays.) + +- [ ] **Step 9: Add `AccessKitAdapterPlugin` to the top-level plugin** + +In `crates/buiy/src/lib.rs`, two edits. + +(a) Re-export: + +```diff + pub use buiy_core::{ + BuiySet, CorePlugin, +- a11y::{A11yDescription, A11yLabel, A11yRole, A11yTreeBuilder}, ++ a11y::{A11yDescription, A11yLabel, A11yRole, A11yTreeBuilder, AccessKitAdapters, AccessKitAdapterPlugin}, +``` + +(b) Compose into `BuiyPlugin::build`: + +```diff + app.add_plugins(( + CorePlugin, + buiy_core::theme::ThemePlugin, + buiy_core::a11y::A11yPlugin, ++ buiy_core::a11y::AccessKitAdapterPlugin, + buiy_core::focus::FocusPlugin, + buiy_core::layout::LayoutPlugin, + buiy_core::picking::PickingPlugin, + WidgetsPlugin, + )); +``` + +(`AccessKitAdapterPlugin` slots immediately after `A11yPlugin` because it depends on `A11yTreeBuilder` and `FocusedEntity`. Plugin-build order doesn't enforce system-set order, but keeping declarations adjacent makes the dependency obvious.) + +### Subtask 4d: Verify + +- [ ] **Step 10: Add a smoke test for the adapter lifecycle** + +Append to `crates/buiy_core/tests/a11y.rs`: + +```rust +#[test] +fn adapter_plugin_loads_without_panic() { + use bevy::prelude::*; + use buiy_core::a11y::{AccessKitAdapterPlugin, AccessKitAdapters}; + use buiy_core::CorePlugin; + + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + app.add_plugins(CorePlugin); + app.add_plugins(buiy_core::a11y::A11yPlugin); + app.add_plugins(buiy_core::focus::FocusPlugin); + app.add_plugins(AccessKitAdapterPlugin); + app.update(); + // No winit windows present → adapter map stays empty. The plugin still + // installs its lifecycle systems without panicking. Real adapter + // creation is exercised by running the `hello_button` example end-to-end. + let adapters = app.world().get_non_send_resource::(); + assert!(adapters.is_some(), "AccessKitAdapters resource present"); + assert!( + adapters.unwrap().adapters.is_empty(), + "no adapters created without winit windows" + ); +} +``` + +- [ ] **Step 11: Run all a11y-related tests** + +```bash +cargo test -p buiy_core --test a11y --test a11y_translate +``` + +Expected: existing test (`tree_builder_emits_one_node_per_focusable_with_role_and_label`) still passes; new translate tests pass; new adapter smoke test passes. + +- [ ] **Step 12: Smoke-run the example with a screen reader hint** + +```bash +cargo run --example hello_button +``` + +Expected: window opens. On Linux with Orca running and AT-SPI enabled, Orca announces "Save, Button". (This is a *manual release-gate* check per `verification.md`; Phase 0 closeout merely verifies the wiring no longer panics.) + +- [ ] **Step 13: Commit** + +```bash +git add crates/buiy_core/src/a11y/ crates/buiy_core/src/lib.rs crates/buiy/src/lib.rs crates/buiy_core/tests/a11y.rs crates/buiy_core/tests/a11y_translate.rs +git commit -m "feat(buiy_core): per-window AccessKit adapter map" +``` + +(`git mv` from Step 1 of subtask 4a was already committed implicitly via the move; if your local state didn't auto-commit the move, add the original `crates/buiy_core/src/a11y.rs` removal here too.) + +--- + +## Task 5: `bevy_picking` backend + +**Files:** +- Modify: `crates/buiy_core/src/lib.rs` +- Move: `crates/buiy_core/src/picking.rs` → `crates/buiy_core/src/picking/mod.rs` +- Create: `crates/buiy_core/src/picking/backend.rs` +- Create: `crates/buiy_core/tests/picking_backend.rs` +- Modify: `crates/buiy_core/tests/picking.rs` (no behavioral change; existing `hit_test` API stays) + +### Subtask 5a: Module split + +- [ ] **Step 1: Move the file** + +```bash +mkdir crates/buiy_core/src/picking +git mv crates/buiy_core/src/picking.rs crates/buiy_core/src/picking/mod.rs +``` + +- [ ] **Step 2: Declare the new submodule** + +In `crates/buiy_core/src/picking/mod.rs`, after the existing `use` block, add: + +```rust +pub mod backend; + +pub use backend::BuiyPickingBackendPlugin; +``` + +- [ ] **Step 3: Verify the workspace still builds** + +```bash +cargo build --workspace && cargo test -p buiy_core --test picking +``` + +Expected: clean. Existing `hit_test` test still passes. + +### Subtask 5b: Backend system + +- [ ] **Step 4: Write the failing test** + +Create `crates/buiy_core/tests/picking_backend.rs`: + +```rust +//! `bevy_picking` backend integration test. Drives a fake `PointerLocation` +//! and asserts a `PointerHits` event fires for the entity under the pointer. + +use bevy::prelude::*; +use bevy::math::FloatOrd; +use bevy::picking::backend::PointerHits; +use bevy::picking::pointer::{PointerId, PointerLocation, Location}; +use buiy_core::{ + CorePlugin, + components::{Node, ResolvedLayout, Style}, + picking::{PickingPlugin, BuiyPickingBackendPlugin}, +}; + +#[test] +fn pointer_over_buiy_node_emits_hit() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + app.add_plugins(bevy::picking::PickingPlugin); + app.add_plugins(CorePlugin); + app.add_plugins(PickingPlugin); + app.add_plugins(BuiyPickingBackendPlugin); + app.add_event::(); + + let entity = app + .world_mut() + .spawn(( + Node, + Style::default(), + ResolvedLayout { + position: Vec2::new(10.0, 10.0), + size: Vec2::new(100.0, 50.0), + }, + )) + .id(); + + // Spawn a pointer at (50, 30). Real apps source this from winit; the + // backend reads `PointerLocation` regardless of source. + app.world_mut().spawn(( + PointerId::Mouse, + PointerLocation::new(Location { + target: bevy::picking::pointer::PointerTarget::Window(Entity::PLACEHOLDER), + position: Vec2::new(50.0, 30.0), + }), + )); + + app.update(); + + let hits = app.world_mut().resource_mut::>(); + let mut reader = hits.get_cursor(); + let any_hit = reader.read(&hits).any(|h| h.picks.iter().any(|(e, _)| *e == entity)); + assert!(any_hit, "Buiy backend emits a PointerHits for the entity under the cursor"); +} +``` + +(Bevy 0.18 API verification: the exact module paths `bevy::picking::backend::PointerHits`, `bevy::picking::pointer::{PointerId, PointerLocation, Location}`, and `bevy::picking::pointer::PointerTarget` are stable as of 0.18 but the `PointerTarget` variant set may be `NormalizedRenderTarget`-flavored. If the resolved version exposes a different target type, adjust the test imports — the architecture is unchanged.) + +- [ ] **Step 5: Run the test to verify it fails** + +```bash +cargo test -p buiy_core --test picking_backend +``` + +Expected: compile error (`buiy_core::picking::BuiyPickingBackendPlugin` undefined). + +- [ ] **Step 6: Implement `backend.rs`** + +Create `crates/buiy_core/src/picking/backend.rs`: + +```rust +//! Buiy's `bevy_picking` backend. Reads `PointerLocation` and produces +//! `PointerHits` from `ResolvedLayout` AABBs. +//! +//! Phase 0 closeout scope: per-pointer hits, top-most-by-area resolution +//! (matches the Phase 0 `hit_test` semantics in `mod.rs`). Multi-pointer +//! arbitration, pointer-target window filtering, and full backend priority +//! land in `buiy-input-events-design`. + +use crate::components::ResolvedLayout; +use bevy::picking::backend::{HitData, PointerHits}; +use bevy::picking::pointer::{PointerId, PointerLocation}; +use bevy::picking::PickSet; +use bevy::prelude::*; + +pub struct BuiyPickingBackendPlugin; + +impl Plugin for BuiyPickingBackendPlugin { + fn build(&self, app: &mut App) { + app.add_systems(PreUpdate, emit_picks.in_set(PickSet::Backend)); + } +} + +fn emit_picks( + pointers: Query<(&PointerId, &PointerLocation)>, + nodes: Query<(Entity, &ResolvedLayout)>, + mut output: EventWriter, +) { + for (pointer, location) in pointers.iter() { + let Some(loc) = location.location() else { + continue; + }; + let cursor = loc.position; + + // Collect every Buiy node under the cursor, with its area as the + // tie-break for "top-most". + let mut hits: Vec<(Entity, f32)> = Vec::new(); + for (entity, layout) in nodes.iter() { + if point_in_aabb(cursor, layout) { + let area = layout.size.x * layout.size.y; + hits.push((entity, area)); + } + } + if hits.is_empty() { + continue; + } + // Smallest area = closest to the user (top of the stack). bevy_picking + // expects depth-sorted; emit one HitData per entity with depth derived + // from area rank. + hits.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + let picks: Vec<(Entity, HitData)> = hits + .iter() + .enumerate() + .map(|(i, (e, _))| { + ( + *e, + HitData::new( + // Camera entity unknown to Buiy in Phase 0 closeout; the + // render-graph node draws into the active 2D camera's + // ViewTarget, but bevy_picking expects a camera ref for + // its own back-projection. v0.x sub-spec wires this. + Entity::PLACEHOLDER, + i as f32, + None, + None, + ), + ) + }) + .collect(); + + output.write(PointerHits::new(*pointer, picks, 0.0)); + } +} + +fn point_in_aabb(point: Vec2, layout: &ResolvedLayout) -> bool { + let max = layout.position + layout.size; + point.x >= layout.position.x + && point.x <= max.x + && point.y >= layout.position.y + && point.y <= max.y +} +``` + +(Verification reminder: `HitData::new` arity and the `PointerHits::new` priority float in Bevy 0.18 may differ from the call signatures shown. Confirm with `cargo doc -p bevy_picking --open` against the resolved version; the algorithm — collect + area-sort + emit one `HitData` per hit — does not change.) + +- [ ] **Step 7: Run the test to verify it passes** + +```bash +cargo test -p buiy_core --test picking_backend +``` + +Expected: PASS. + +### Subtask 5c: Rewire `Hovered` over the new backend + +- [ ] **Step 8: Replace `update_hovered` to consume `PointerHits`** + +In `crates/buiy_core/src/picking/mod.rs`, replace the body of `update_hovered`: + +```rust +fn update_hovered( + mut hovered: ResMut, + mut events: EventReader, +) { + // The top-most hit is at index 0 of `picks` (sorted ascending by depth in + // `BuiyPickingBackendPlugin::emit_picks`). Multiple pointers: we honor the + // most-recently-emitted hit and fall through to clearing if no events + // arrive this frame. + let mut latest: Option = None; + let mut saw_event = false; + for ev in events.read() { + saw_event = true; + if let Some((entity, _)) = ev.picks.first() { + latest = Some(*entity); + } else { + latest = None; + } + } + if saw_event { + hovered.0 = latest; + } + // No events this frame ⇒ leave `Hovered` as-is. Cursor-leaving-window + // produces an empty `picks` event (see emit_picks) which clears it. +} +``` + +Update the imports at the top of `mod.rs`: + +```diff + use crate::components::ResolvedLayout; + use bevy::ecs::query::QueryState; + use bevy::prelude::*; ++use bevy::picking::backend::PointerHits; +``` + +Drop the `windows: Query<&Window>, layouts: Query<...>` parameters from `update_hovered` since the backend now drives it. The free `hit_test` function stays as-is — tests still call it directly, and the AABB algorithm is unchanged. + +- [ ] **Step 9: Wire `BuiyPickingBackendPlugin` into the top-level plugin** + +In `crates/buiy/src/lib.rs`, two edits. + +(a) Re-export: + +```diff +- picking::Hovered, ++ picking::{BuiyPickingBackendPlugin, Hovered}, +``` + +(b) Compose into `BuiyPlugin::build`, immediately after `PickingPlugin`: + +```diff + buiy_core::layout::LayoutPlugin, + buiy_core::picking::PickingPlugin, ++ buiy_core::picking::BuiyPickingBackendPlugin, + WidgetsPlugin, + )); +``` + +- [ ] **Step 10: Run the full picking suite** + +```bash +cargo test -p buiy_core --test picking --test picking_backend +``` + +Expected: existing `hit_test_returns_entity_under_point` still passes (proves the free function is intact); new backend test passes. + +- [ ] **Step 11: Run the workspace and example** + +```bash +xvfb-run -a cargo test --workspace +``` + +Expected: all green, no regressions in the existing e2e fixture. + +- [ ] **Step 12: Commit** + +```bash +git add crates/buiy_core/src/picking/ crates/buiy_core/src/lib.rs crates/buiy/src/lib.rs crates/buiy_core/tests/picking_backend.rs +git commit -m "feat(buiy_core): bevy_picking backend with Hovered as thin layer" +``` + +--- + +## Task 6: Final verification + +**Files:** +- None new. This task runs the project's check command and addresses any leftover. + +- [ ] **Step 1: Run the full check command** + +```bash +cargo fmt --all -- --check && \ + cargo clippy --workspace --all-targets -- -D warnings && \ + RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps && \ + xvfb-run -a cargo test --workspace +``` + +Expected: every line PASS. Fix any warning at root cause; do not relax `-D warnings`. + +- [ ] **Step 2: Re-run cargo-deny** + +```bash +cargo deny check +``` + +Expected: PASS. + +- [ ] **Step 3: Visual smoke test** + +```bash +cargo run --example hello_button +``` + +Expected: window opens with a visible "Save" button rectangle painted via Buiy's pipeline (Task 3 verification). Pressing Tab causes the focus ring to appear (existing Phase 0 focus). On Linux with AT-SPI active, a screen reader announces "Save, Button" (Task 4 verification — manual gate). + +- [ ] **Step 4: Update CHANGELOG** + +In `CHANGELOG.md`, under `## [Unreleased]`, add: + +```markdown +### Added +- Render pipeline now produces real pixels for Buiy nodes (instance-buffer + construction, clip-space conversion, draw call). Closes the Phase 0 + render deferral. +- Per-window AccessKit adapter map. Real screen readers attached to a + Buiy window receive a tree update each frame. Closes the Phase 0 a11y + deferral. +- `bevy_picking` backend. `Hovered` becomes a thin layer over the standard + `PointerHits` event flow. Closes the Phase 0 picking deferral. +``` + +- [ ] **Step 5: Flip this plan's own status** + +In `docs/plans/2026-05-08-buiy-phase-0-closeout.md` (this file), line 4: + +```diff +-**Status:** draft ++**Status:** landed +``` + +In `docs/README.md`, flip this plan's index entry's `[draft]` → `[landed]`. + +- [ ] **Step 6: Final commit** + +```bash +git add CHANGELOG.md docs/plans/2026-05-08-buiy-phase-0-closeout.md docs/README.md +git commit -m "docs: changelog + status flips for Phase 0 closeout" +``` + +- [ ] **Step 7: Open the PR** + +```bash +git push -u origin +gh pr create --title "Phase 0 closeout: render draws, AccessKit per-window, bevy_picking backend" +``` + +PR description should cite the three Phase 0 deferrals being closed and link to this plan. + +--- + +## Self-review (plan author) + +Cross-checked against the foundation spec self-review (foundations plan lines 2784–2790): + +- ✅ `bevy_picking` real backend implementation — Task 5. +- ✅ AccessKit `accesskit_winit::Adapter` per-window wiring — Task 4 (full `HashMap` per the user's PR-shape pick). +- ✅ Render pipeline draws (instance-buffer construction) — Task 3. +- ⚠️ Cosmic-text / glyph rendering — **explicitly out of scope** per "Out of scope" header. Closeout's hello_button still shows a colored rect with no glyph rendering; the architecture-proof bar remains the right one for Phase 0. +- ⚠️ BSN authoring / hot-reload — **explicitly out of scope**. +- ⚠️ Forms / animation / devtools / 3D-anchored UI — **explicitly out of scope**. + +Each remaining deferral has a sub-spec named in the foundation roadmap. + +**Placeholder scan:** No "TBD" / "TODO" / "implement later" inside steps. Three Bevy-version-note disclaimers (`bevy_picking` API arity, `accesskit_winit` 0.31 handler trait names, `bevy::winit::WinitWindows` accessor shape) are honest version-stability acknowledgements per the same pattern Phase 0's plan used. + +**Type consistency:** Names referenced across tasks are consistent: `InstanceData`, `to_instance`, `INSTANCE_STRIDE_BYTES`, `ExtractedDraws`, `BuiyPipeline`, `BuiyNode`, `A11yNodeView`, `A11yTreeBuilder`, `AccessKitAdapters`, `AccessKitAdapterPlugin`, `to_accesskit_node`, `build_tree_update`, `node_id_for`, `ROOT_NODE_ID`, `BuiyPickingBackendPlugin`, `PickSet::Backend`, `PointerHits`, `PointerLocation`. The `Hovered` resource keeps its public type and constructor; only its update system body changes. + +**Dependency-graph health:** `accesskit` is added as a workspace dep; the cargo-tree check in Task 2 Step 4 ensures we don't ship two versions of `accesskit` (Bevy's vendored copy + ours). If duplication is unavoidable, the plan instructs to match Bevy's version pin. + +**Risk register:** +- Bevy 0.18 minor-version drift on `bevy_picking::backend` and `bevy::winit::WinitWindows` accessor. Mitigated by the version note in the header and per-task disclaimers. +- AccessKit 0.21 API name drift (e.g., `Adapter::with_direct_handlers` vs `Adapter::new`). Mitigated by the same disclaimer pattern; structure is stable. +- Per-frame instance-buffer allocation cost. Acknowledged in `node.rs` comments and deferred to `buiy-render-pipeline-design`. Phase 0 closeout's hello_button has 1 entity, so the allocation cost is negligible at the demo scale. + +--- + +Plan complete and saved to `docs/plans/2026-05-08-buiy-phase-0-closeout.md`. From 001a3420daf95fd609670f6bea7572c509245918 Mon Sep 17 00:00:00 2001 From: Noah Date: Fri, 8 May 2026 01:20:36 -0700 Subject: [PATCH 02/10] build: add bytemuck, accesskit, accesskit_winit, bevy_picking feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 closeout: promote three deferred workspace deps from the speculative list to first-class entries, and enable the bevy_picking feature on the bevy workspace dep. - bevy: add bevy_picking to feature list - bytemuck 1 (derive): instance-buffer POD trait impls - accesskit 0.21 / accesskit_winit 0.29: pinned to match Bevy 0.18's vendored versions to keep a single copy in the dep graph (cargo tree -p accesskit → accesskit v0.21.1, single entry) - buiy_core: wire all three new deps via workspace = true Still deferred: image-compare (visual harness upgrade), thiserror (no error-typing pressure yet). Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 10 +++++++++- crates/buiy_core/Cargo.toml | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 486f044..851dee1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,10 +38,18 @@ rust-version = "1.85" # bevy_picking backend, image-compare for SSIM, thiserror for typed errors) # return when the consuming task lands. See review notes in # docs/plans/2026-05-07-buiy-phase-0-foundations.md. -bevy = { version = "0.18", default-features = false, features = ["bevy_render", "bevy_core_pipeline", "bevy_winit", "bevy_window", "bevy_asset", "bevy_log", "x11", "wayland"] } +# Phase 0 closeout (2026-05-08) added: bytemuck (instance buffer POD), +# accesskit + accesskit_winit (per-window adapter), bevy_picking feature +# on bevy. Still deferred: image-compare (visual harness upgrade is v0.x +# verification work), thiserror (no error-typing pressure yet). +bevy = { version = "0.18", default-features = false, features = ["bevy_render", "bevy_core_pipeline", "bevy_winit", "bevy_window", "bevy_asset", "bevy_log", "bevy_picking", "x11", "wayland"] } taffy = "0.10" serde = { version = "1", features = ["derive"] } serde_json = "1" image = "0.25" proptest = "1" tracing = "0.1" +# Phase 0 closeout deps (closeout plan 2026-05-08). +bytemuck = { version = "1", features = ["derive"] } +accesskit = "0.21" +accesskit_winit = "0.29" diff --git a/crates/buiy_core/Cargo.toml b/crates/buiy_core/Cargo.toml index 8dd6717..6118863 100644 --- a/crates/buiy_core/Cargo.toml +++ b/crates/buiy_core/Cargo.toml @@ -8,3 +8,6 @@ license.workspace = true bevy.workspace = true taffy.workspace = true tracing.workspace = true +bytemuck.workspace = true +accesskit.workspace = true +accesskit_winit.workspace = true From 31e30402f6a1abc7b130e771a48bdd0fbbb50aed Mon Sep 17 00:00:00 2001 From: Noah Date: Fri, 8 May 2026 01:35:34 -0700 Subject: [PATCH 03/10] feat(buiy_core): render pipeline draws via per-frame instance buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the Phase 0 deferred gap: the render-graph node now builds a per-frame instance buffer from ExtractedDraws (logical-px → clip-space via to_instance), binds the static unit-quad VBO held on BuiyPipeline, and issues an instanced draw(0..4, 0..n). Adds DrawData::new constructor (non_exhaustive struct requires it for external callers), window_size field on ExtractedDraws, and removes the resolved shader TODO block. Co-Authored-By: Claude Sonnet 4.6 --- crates/buiy_core/src/render/instance.rs | 62 +++++++++++++++++++++++ crates/buiy_core/src/render/mod.rs | 36 +++++++++++-- crates/buiy_core/src/render/node.rs | 44 +++++++++------- crates/buiy_core/src/render/pipeline.rs | 54 ++++++++++++++++++-- crates/buiy_core/src/render/shader.wgsl | 17 ++----- crates/buiy_core/tests/render_instance.rs | 58 +++++++++++++++++++++ 6 files changed, 231 insertions(+), 40 deletions(-) create mode 100644 crates/buiy_core/src/render/instance.rs create mode 100644 crates/buiy_core/tests/render_instance.rs diff --git a/crates/buiy_core/src/render/instance.rs b/crates/buiy_core/src/render/instance.rs new file mode 100644 index 0000000..09f9386 --- /dev/null +++ b/crates/buiy_core/src/render/instance.rs @@ -0,0 +1,62 @@ +//! Per-instance data layout for the rounded-rect pipeline. The struct stride +//! must equal the per-instance `array_stride` declared in +//! `pipeline.rs::register` (currently 36). Phase 0 closeout converts logical +//! pixels → clip-space units on the CPU here, per the path (a) decision in +//! `shader.wgsl`'s former TODO comment. +//! +//! v0.x will replace this with a view uniform (`buiy-render-pipeline-design`), +//! at which point the conversion moves to the vertex stage and `InstanceData` +//! shrinks back to logical-pixel units. + +use crate::render::DrawData; +use bevy::prelude::*; +use bytemuck::{Pod, Zeroable}; + +/// Stride must match `pipeline.rs::register` instance-buffer layout (36 B). +pub const INSTANCE_STRIDE_BYTES: usize = 36; + +/// One instance record. Fields match `Instance` in `shader.wgsl` 1:1. +#[repr(C)] +#[derive(Copy, Clone, Debug, Default, Pod, Zeroable)] +pub struct InstanceData { + /// Top-left in clip space (-1..+1, y up). + pub rect_pos: [f32; 2], + /// Width / height in clip space; height is negative because UI-space y is + /// down-positive but clip-space y is up-positive (single y-flip lives in + /// the size, not the position, so the shader's `rect_pos + uv * rect_size` + /// remains correct top-down). + pub rect_size: [f32; 2], + /// Linear RGBA. Pipeline target is `Rgba8UnormSrgb`, so the GPU re-encodes + /// to sRGB on write. + pub color: [f32; 4], + /// Corner radius in clip-space units. Phase 0 closeout uses + /// `2.0 / min(window.x, window.y)` as the px→clip conversion to keep + /// corners visually round on non-square windows; v0.x view uniform + /// removes the approximation. + pub radius: f32, +} + +/// Convert one [`DrawData`] (logical-pixel UI space) into an [`InstanceData`] +/// (clip space) for the given window size in logical pixels. +pub fn to_instance(draw: &DrawData, window_size: Vec2) -> InstanceData { + let inv_w = 2.0 / window_size.x; + let inv_h = 2.0 / window_size.y; + let inv_min = 2.0 / window_size.x.min(window_size.y); + + let rect_pos = [ + draw.position.x * inv_w - 1.0, + // y-flip: UI top (px=0) → clip top (+1). + 1.0 - draw.position.y * inv_h, + ]; + let rect_size = [draw.size.x * inv_w, -draw.size.y * inv_h]; + + let lin = LinearRgba::from(draw.color); + let color = [lin.red, lin.green, lin.blue, lin.alpha]; + + InstanceData { + rect_pos, + rect_size, + color, + radius: draw.radius * inv_min, + } +} diff --git a/crates/buiy_core/src/render/mod.rs b/crates/buiy_core/src/render/mod.rs index af75234..746cbc8 100644 --- a/crates/buiy_core/src/render/mod.rs +++ b/crates/buiy_core/src/render/mod.rs @@ -13,6 +13,7 @@ use crate::{ use bevy::prelude::*; use bevy::render::{Extract, ExtractSchedule, RenderApp}; +pub mod instance; pub mod node; pub mod pipeline; @@ -29,13 +30,21 @@ pub mod pipeline; #[non_exhaustive] pub struct ExtractedDraws { pub draws: Vec, + /// Logical-pixel size of the primary window this frame. Populated by the + /// extract system. Render-graph nodes use this to convert + /// `DrawData` (px) → `InstanceData` (clip) per the Phase 0 closeout + /// design. Zero on frames where no window exists; the render node + /// must skip drawing in that case. + pub window_size: Vec2, } /// One rectangle in the Phase 0 render queue. Marked `#[non_exhaustive]` /// because the full pipeline (clip-path, filters, blend modes, etc.) -/// will add per-draw fields pre-1.0. `Default` is derived so external -/// authors can construct via the `..Default::default()` shim referenced -/// in `ExtractedDraws` above. +/// will add per-draw fields pre-1.0. +/// +/// External callers construct via [`DrawData::new`]; the `#[non_exhaustive]` +/// attribute prevents struct-literal construction from outside the crate so +/// new fields added here remain non-breaking per SemVer. #[derive(Clone, Copy, Debug, Default)] #[non_exhaustive] pub struct DrawData { @@ -45,6 +54,22 @@ pub struct DrawData { pub radius: f32, } +impl DrawData { + /// Construct a [`DrawData`] with all Phase 0 fields. + /// + /// Using a constructor rather than struct literal syntax is required by the + /// `#[non_exhaustive]` attribute when constructing from outside the crate. + /// Future fields are added here with default values to remain non-breaking. + pub fn new(position: Vec2, size: Vec2, color: Color, radius: f32) -> Self { + Self { + position, + size, + color, + radius, + } + } +} + pub struct BuiyRenderPlugin; impl Plugin for BuiyRenderPlugin { @@ -74,8 +99,13 @@ fn extract_buiy_draws( mut commands: Commands, main_world_q: Extract>>, main_world_theme: Extract>, + main_world_windows: Extract>>, ) { let mut draws = ExtractedDraws::default(); + if let Ok(window) = main_world_windows.single() { + let res = window.resolution.size(); + draws.window_size = Vec2::new(res.x, res.y); + } for (style, layout) in main_world_q.iter() { let color = match main_world_theme.color(&style.background_token) { Some(c) => c, diff --git a/crates/buiy_core/src/render/node.rs b/crates/buiy_core/src/render/node.rs index 7d3a81b..5ef618c 100644 --- a/crates/buiy_core/src/render/node.rs +++ b/crates/buiy_core/src/render/node.rs @@ -17,10 +17,11 @@ //! per-element. Future Phase 1+ may move this to a view uniform; flag in //! `buiy-render-pipeline-design`. //! -//! Phase 0 status: this node ships the *render-graph wiring* + a -//! `set_render_pipeline` call so the pass exists and the pipeline is bound, -//! but vertex / instance buffer construction is deferred to v0.x. Task 19 -//! (the e2e screenshot harness) is responsible for end-to-end verification. +//! Phase 0 closeout (2026-05-08): this node now builds the instance buffer +//! per-frame from `ExtractedDraws` and issues an instanced `draw(0..4, 0..n)` +//! against the static unit-quad VBO held on `BuiyPipeline`. v0.x upgrades to +//! persistent buffers + bind groups for filters / atlas +//! (`buiy-render-pipeline-design`). use bevy::core_pipeline::core_2d::graph::{Core2d, Node2d}; use bevy::ecs::query::QueryItem; @@ -29,12 +30,12 @@ use bevy::render::{ render_graph::{ NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner, }, - render_resource::{PipelineCache, RenderPassDescriptor}, + render_resource::{BufferInitDescriptor, BufferUsages, PipelineCache, RenderPassDescriptor}, renderer::RenderContext, view::ViewTarget, }; -use super::{ExtractedDraws, pipeline::BuiyPipeline}; +use super::{ExtractedDraws, instance::to_instance, pipeline::BuiyPipeline}; #[derive(Default)] pub struct BuiyNode; @@ -52,32 +53,39 @@ impl ViewNode for BuiyNode { let pipeline_cache = world.resource::(); let buiy_pipeline = world.resource::(); let Some(pipeline) = pipeline_cache.get_render_pipeline(buiy_pipeline.id) else { - // Pipeline still compiling; skip this frame. The next frame will - // either succeed or surface an error from the pipeline cache. return Ok(()); }; let draws = world.resource::(); - if draws.draws.is_empty() { + if draws.draws.is_empty() || draws.window_size.x <= 0.0 || draws.window_size.y <= 0.0 { return Ok(()); } + // Pack instances. Phase 0 closeout: per-frame allocation; v0.x + // (`buiy-render-pipeline-design`) introduces persistent buffers. + let instances: Vec<_> = draws + .draws + .iter() + .map(|d| to_instance(d, draws.window_size)) + .collect(); + + let render_device = render_context.render_device(); + let instance_buffer = render_device.create_buffer_with_data(&BufferInitDescriptor { + label: Some("buiy_instance_vbo"), + contents: bytemuck::cast_slice(&instances), + usage: BufferUsages::VERTEX, + }); + let mut pass = render_context.begin_tracked_render_pass(RenderPassDescriptor { label: Some("buiy_pass"), - // `get_color_attachment()` returns the post-MSAA-resolve target - // (when MSAA is enabled) and respects ViewTarget's ping-pong - // logic. Using `main_texture_view()` directly would bypass that - // and break post-processing composition. color_attachments: &[Some(view_target.get_color_attachment())], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, }); pass.set_render_pipeline(pipeline); - // Phase 0: vertex + instance buffers built per-frame here. v0.x - // upgrades to persistent buffers + bind groups for filters / atlas. - // The buffer-construction code is deferred to a follow-up task — this - // task ships the *render-graph wiring* so the node is present and - // running, even if the actual draw-call encoding is a TODO. + pass.set_vertex_buffer(0, buiy_pipeline.vertex_buffer.slice(..)); + pass.set_vertex_buffer(1, instance_buffer.slice(..)); + pass.draw(0..4, 0..instances.len() as u32); Ok(()) } } diff --git a/crates/buiy_core/src/render/pipeline.rs b/crates/buiy_core/src/render/pipeline.rs index 19a60e1..686440a 100644 --- a/crates/buiy_core/src/render/pipeline.rs +++ b/crates/buiy_core/src/render/pipeline.rs @@ -10,11 +10,12 @@ use bevy::asset::uuid::Uuid; use bevy::mesh::VertexBufferLayout; use bevy::prelude::*; use bevy::render::render_resource::{ - BlendState, CachedRenderPipelineId, ColorTargetState, ColorWrites, FragmentState, FrontFace, - MultisampleState, PipelineCache, PolygonMode, PrimitiveState, PrimitiveTopology, - RenderPipelineDescriptor, TextureFormat, VertexAttribute, VertexFormat, VertexState, - VertexStepMode, + BlendState, Buffer, BufferInitDescriptor, BufferUsages, CachedRenderPipelineId, + ColorTargetState, ColorWrites, FragmentState, FrontFace, MultisampleState, PipelineCache, + PolygonMode, PrimitiveState, PrimitiveTopology, RenderPipelineDescriptor, TextureFormat, + VertexAttribute, VertexFormat, VertexState, VertexStepMode, }; +use bevy::render::renderer::RenderDevice; use bevy::shader::Shader; /// Stable UUID for the rounded-rect shader asset. @@ -35,6 +36,11 @@ pub fn shader_handle() -> Handle { #[derive(Resource)] pub struct BuiyPipeline { pub id: CachedRenderPipelineId, + /// Static unit-quad vertex buffer (4 verts, TriangleStrip). Created once + /// at pipeline registration and reused every frame. Phase 0 closeout + /// scope: vertex emission order matches the `cull_mode: None` setting in + /// the descriptor; v0.x tightens to back-face culling. + pub vertex_buffer: Buffer, } pub(crate) fn register(render_app: &mut SubApp) { @@ -134,6 +140,44 @@ pub(crate) fn register(render_app: &mut SubApp) { zero_initialize_workgroup_memory: false, }; + let render_device = world.resource::(); + + // Unit quad in (pos, uv) interleaved layout, matching the vertex-buffer + // layout in `descriptor.vertex.buffers[0]`. TriangleStrip order: TL, BL, + // TR, BR — both triangles wind consistently, which the v0.x backface-cull + // tightening will rely on. + #[repr(C)] + #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] + struct QuadVertex { + pos: [f32; 2], + uv: [f32; 2], + } + + let quad: [QuadVertex; 4] = [ + QuadVertex { + pos: [0.0, 0.0], + uv: [0.0, 0.0], + }, // TL + QuadVertex { + pos: [0.0, 1.0], + uv: [0.0, 1.0], + }, // BL + QuadVertex { + pos: [1.0, 0.0], + uv: [1.0, 0.0], + }, // TR + QuadVertex { + pos: [1.0, 1.0], + uv: [1.0, 1.0], + }, // BR + ]; + + let vertex_buffer = render_device.create_buffer_with_data(&BufferInitDescriptor { + label: Some("buiy_unit_quad_vbo"), + contents: bytemuck::cast_slice(&quad), + usage: BufferUsages::VERTEX, + }); + let id = pipeline_cache.queue_render_pipeline(descriptor); - world.insert_resource(BuiyPipeline { id }); + world.insert_resource(BuiyPipeline { id, vertex_buffer }); } diff --git a/crates/buiy_core/src/render/shader.wgsl b/crates/buiy_core/src/render/shader.wgsl index 05d3c6f..79e7cfa 100644 --- a/crates/buiy_core/src/render/shader.wgsl +++ b/crates/buiy_core/src/render/shader.wgsl @@ -1,16 +1,5 @@ -// Buiy Phase 0 rounded-rect shader. Vertex stage emits a unit quad (one -// quad per draw). Fragment stage computes signed distance from the -// rounded rect interior and outputs the per-instance color, with anti- -// aliased edges. -// -// TODO(Task 11): rect_pos / rect_size are currently fed in clip-space -// units (-1..+1) but the layout system produces logical pixels. Either -// (a) Task 11 pre-multiplies by the inverse window size on the CPU -// before writing the instance buffer, or (b) introduce a view uniform -// (window-size or full view-projection matrix) and apply the transform -// here in the vertex stage. Decide before instance-buffer construction -// lands. The pipeline descriptor's `layout: vec![]` will need to grow -// to include the bind-group layout if (b) is chosen. +// Buiy rounded-rect shader. Inputs are clip-space units; CPU-side +// conversion lives in `render::instance::to_instance`. struct Vertex { @location(0) position: vec2, @@ -18,7 +7,7 @@ struct Vertex { }; struct Instance { - @location(2) rect_pos: vec2, // see TODO above re: units + @location(2) rect_pos: vec2, @location(3) rect_size: vec2, @location(4) color: vec4, @location(5) radius: f32, diff --git a/crates/buiy_core/tests/render_instance.rs b/crates/buiy_core/tests/render_instance.rs new file mode 100644 index 0000000..dc2981d --- /dev/null +++ b/crates/buiy_core/tests/render_instance.rs @@ -0,0 +1,58 @@ +//! Unit tests for the instance-data layout and clip-space conversion. These +//! are pure-CPU tests; no GPU adapter required. + +use bevy::prelude::*; +use buiy_core::render::DrawData; +use buiy_core::render::instance::{INSTANCE_STRIDE_BYTES, InstanceData, to_instance}; + +#[test] +fn instance_data_layout_matches_pipeline_descriptor() { + // pipeline.rs declares the per-instance buffer with array_stride = 36. + assert_eq!( + std::mem::size_of::(), + INSTANCE_STRIDE_BYTES, + "InstanceData stride must match pipeline.rs (2*4 + 2*4 + 4*4 + 1*4 = 36)" + ); + assert_eq!(INSTANCE_STRIDE_BYTES, 36); +} + +#[test] +fn to_instance_centers_origin_at_window_center() { + // A rect at (0,0) of size (window) should map to clip rect_pos = (-1, +1) + // (top-left in clip after y-flip) and rect_size = (2, 2). + let window = Vec2::new(800.0, 600.0); + let draw = DrawData::new(Vec2::ZERO, window, Color::WHITE, 0.0); + let i = to_instance(&draw, window); + assert!((i.rect_pos[0] - -1.0).abs() < 1e-6); + assert!((i.rect_pos[1] - 1.0).abs() < 1e-6); + assert!((i.rect_size[0] - 2.0).abs() < 1e-6); + assert!((i.rect_size[1] - -2.0).abs() < 1e-6); +} + +#[test] +fn to_instance_packs_color_in_linear_rgba() { + let window = Vec2::new(100.0, 100.0); + let draw = DrawData::new( + Vec2::ZERO, + Vec2::splat(10.0), + Color::srgb(1.0, 0.0, 0.0), + 0.0, + ); + let i = to_instance(&draw, window); + let lin = LinearRgba::from(Color::srgb(1.0, 0.0, 0.0)); + assert!((i.color[0] - lin.red).abs() < 1e-5); + assert!((i.color[1] - lin.green).abs() < 1e-5); + assert!((i.color[2] - lin.blue).abs() < 1e-5); + assert!((i.color[3] - lin.alpha).abs() < 1e-5); +} + +#[test] +fn to_instance_radius_uses_min_window_dim() { + // Radius is in clip-space units; pixel-to-clip uses 2.0 / min(window dims) + // so the corner radius stays visually reasonable on non-square windows. + let window = Vec2::new(1000.0, 500.0); + let draw = DrawData::new(Vec2::ZERO, Vec2::splat(100.0), Color::WHITE, 25.0); + let i = to_instance(&draw, window); + let expected = 25.0 * (2.0 / 500.0); + assert!((i.radius - expected).abs() < 1e-6); +} From e899a286455a9b3f7ed8ac7e808f7b5c99e83771 Mon Sep 17 00:00:00 2001 From: Noah Date: Fri, 8 May 2026 01:47:05 -0700 Subject: [PATCH 04/10] fix(buiy_core): correct shader half_size sign; cleanup stale comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: shader.wgsl was passing a *signed* half-extent into the SDF (rect_size.y is negative by design for the CPU-side y-flip), so q.y became `abs(p.y) + h/2 + r` for every interior fragment — alpha collapsed to 0 and rects rendered invisible. Fix: abs() the rect_size before halving in the vertex stage. The signed rect_size remains load-bearing for the `world` computation and for `local_uv * half_size` in the fragment stage, where both factors flip sign together. Also: - Pure-CPU regression tests (sdf_rounded_rect + shader_half_size port) pin the half_size-must-be-positive property and document the buggy signed-half_size path so a regression fails loudly. - Add to_instance_offsets_position_to_clip — the existing four tests all used position = Vec2::ZERO, leaving the offset arithmetic (`* inv_w - 1.0`, `1.0 - * inv_h`) un-exercised. - Drop the dead `Default` derive on InstanceData (Zeroable provides the only construction we use; Default was never invoked). - pipeline.rs: rewrite stale "Task 11 will fix winding" comment — that task IS this commit chain; the TL/BL/TR/BR strip already winds consistently and the deferred work is the cull_mode tightening. - node.rs: drop the duplicated clip-space-conversion paragraph that contradicted the closeout paragraph below it. - mod.rs: tighten ExtractedDraws doc comment — populated only by extract_buiy_draws, not externally constructed. Co-Authored-By: Claude Sonnet 4.6 --- crates/buiy_core/src/render/instance.rs | 2 +- crates/buiy_core/src/render/mod.rs | 14 ++-- crates/buiy_core/src/render/node.rs | 21 ++--- crates/buiy_core/src/render/pipeline.rs | 7 +- crates/buiy_core/src/render/shader.wgsl | 8 +- crates/buiy_core/tests/render_instance.rs | 94 +++++++++++++++++++++++ 6 files changed, 119 insertions(+), 27 deletions(-) diff --git a/crates/buiy_core/src/render/instance.rs b/crates/buiy_core/src/render/instance.rs index 09f9386..4571019 100644 --- a/crates/buiy_core/src/render/instance.rs +++ b/crates/buiy_core/src/render/instance.rs @@ -17,7 +17,7 @@ pub const INSTANCE_STRIDE_BYTES: usize = 36; /// One instance record. Fields match `Instance` in `shader.wgsl` 1:1. #[repr(C)] -#[derive(Copy, Clone, Debug, Default, Pod, Zeroable)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] pub struct InstanceData { /// Top-left in clip space (-1..+1, y up). pub rect_pos: [f32; 2], diff --git a/crates/buiy_core/src/render/mod.rs b/crates/buiy_core/src/render/mod.rs index 746cbc8..ee35428 100644 --- a/crates/buiy_core/src/render/mod.rs +++ b/crates/buiy_core/src/render/mod.rs @@ -18,14 +18,14 @@ pub mod node; pub mod pipeline; /// What the render world needs from the main world per frame: a list of -/// (rect, color, radius) tuples in window-local logical pixels. +/// (rect, color, radius) tuples in window-local logical pixels, plus the +/// primary window size used to convert them to clip space. /// -/// `#[non_exhaustive]` — the full pipeline (top-layer compositing, -/// clip-path, filters, blend modes, atlasing per -/// `buiy-render-pipeline-design`) will add fields here. The render -/// world does not re-export this for main-world consumers, so the only -/// out-of-crate audience is render-graph plugin authors who already -/// know to update through `..Default::default()` shims. +/// Populated only by [`extract_buiy_draws`] inside `ExtractSchedule`; this +/// resource is not part of the main-world public API and external authors +/// should not construct it directly. `#[non_exhaustive]` keeps additions +/// (top-layer compositing, clip-path, filters, blend modes, atlasing per +/// `buiy-render-pipeline-design`) non-breaking. #[derive(Resource, Default, Clone)] #[non_exhaustive] pub struct ExtractedDraws { diff --git a/crates/buiy_core/src/render/node.rs b/crates/buiy_core/src/render/node.rs index 5ef618c..e50328d 100644 --- a/crates/buiy_core/src/render/node.rs +++ b/crates/buiy_core/src/render/node.rs @@ -8,20 +8,13 @@ //! force Buiy to manage its own color-space matching with the rest of the //! frame, which is unnecessary complexity for Phase 0. //! -//! IMPORTANT (clip-space conversion): the extract phase (in `mod.rs`) emits -//! `DrawData.position / size` in **logical pixels** (from `ResolvedLayout`), -//! but the rounded-rect shader (in `shader.wgsl`) consumes them as -//! **clip-space units**. The Phase 0 conversion happens HERE on the CPU -//! before the instance buffer is written: we compute -//! clip = (px / window_size) * 2.0 - 1.0 (with y-flip) -//! per-element. Future Phase 1+ may move this to a view uniform; flag in -//! `buiy-render-pipeline-design`. -//! -//! Phase 0 closeout (2026-05-08): this node now builds the instance buffer -//! per-frame from `ExtractedDraws` and issues an instanced `draw(0..4, 0..n)` -//! against the static unit-quad VBO held on `BuiyPipeline`. v0.x upgrades to -//! persistent buffers + bind groups for filters / atlas -//! (`buiy-render-pipeline-design`). +//! Phase 0 closeout (2026-05-08): this node builds the instance buffer +//! per-frame from `ExtractedDraws` (logical-pixel → clip-space conversion +//! lives in `render::instance::to_instance`) and issues an instanced +//! `draw(0..4, 0..n)` against the static unit-quad VBO held on +//! `BuiyPipeline`. v0.x upgrades to persistent buffers + bind groups for +//! filters / atlas (`buiy-render-pipeline-design`); the conversion will +//! move to a view uniform at that point. use bevy::core_pipeline::core_2d::graph::{Core2d, Node2d}; use bevy::ecs::query::QueryItem; diff --git a/crates/buiy_core/src/render/pipeline.rs b/crates/buiy_core/src/render/pipeline.rs index 686440a..67cbd99 100644 --- a/crates/buiy_core/src/render/pipeline.rs +++ b/crates/buiy_core/src/render/pipeline.rs @@ -117,10 +117,9 @@ pub(crate) fn register(render_app: &mut SubApp) { primitive: PrimitiveState { topology: PrimitiveTopology::TriangleStrip, front_face: FrontFace::Ccw, - // Phase 0: cull_mode = None until Task 11 fixes the unit-quad - // emission order. A naive `(0,0),(1,0),(0,1),(1,1)` strip mixes - // CCW and CW windings; back-face culling would silently drop the - // quad. Tighten to Some(Face::Back) once Task 11 verifies winding. + // cull_mode: None — Phase 0 closeout deliberate choice. The + // TL/BL/TR/BR strip order produces consistent winding; tightening + // to Some(Face::Back) is deferred to v0.x. cull_mode: None, polygon_mode: PolygonMode::Fill, ..default() diff --git a/crates/buiy_core/src/render/shader.wgsl b/crates/buiy_core/src/render/shader.wgsl index 79e7cfa..5107302 100644 --- a/crates/buiy_core/src/render/shader.wgsl +++ b/crates/buiy_core/src/render/shader.wgsl @@ -27,7 +27,13 @@ fn vertex(v: Vertex, i: Instance) -> VertexOut { let world = i.rect_pos + v.uv * i.rect_size; out.clip_position = vec4(world, 0.0, 1.0); out.local_uv = v.uv * 2.0 - 1.0; - out.half_size = i.rect_size * 0.5; + // SDF expects a positive half-extent. `i.rect_size.y` is intentionally + // negative (CPU-side y-flip in `render::instance::to_instance`); without + // the abs, the SDF would treat every interior fragment as outside the + // rect and the alpha collapses to 0. The signed `rect_size` is still + // load-bearing for `world` above and for `local_uv * half_size` in the + // fragment stage, where both factors flip sign together. + out.half_size = abs(i.rect_size) * 0.5; out.color = i.color; out.radius = i.radius; return out; diff --git a/crates/buiy_core/tests/render_instance.rs b/crates/buiy_core/tests/render_instance.rs index dc2981d..ba68d61 100644 --- a/crates/buiy_core/tests/render_instance.rs +++ b/crates/buiy_core/tests/render_instance.rs @@ -56,3 +56,97 @@ fn to_instance_radius_uses_min_window_dim() { let expected = 25.0 * (2.0 / 500.0); assert!((i.radius - expected).abs() < 1e-6); } + +#[test] +fn to_instance_offsets_position_to_clip() { + // Locks down the `* inv_w - 1.0` / `1.0 - * inv_h` offset arithmetic that + // the all-zero-position tests above leave un-exercised. A 0×0 rect at the + // window center (px) should land at clip origin (0, 0). + let window = Vec2::new(800.0, 600.0); + let draw = DrawData::new(window * 0.5, Vec2::ZERO, Color::WHITE, 0.0); + let i = to_instance(&draw, window); + assert!(i.rect_pos[0].abs() < 1e-6); + assert!(i.rect_pos[1].abs() < 1e-6); +} + +// Pure-CPU port of `shader.wgsl::sdf_rounded_rect`. Mirrors the GPU SDF 1:1 +// (only `abs` / `length` / `min` / `max` — no platform-specific intrinsics). +fn sdf_rounded_rect(p: Vec2, half_size: Vec2, r: f32) -> f32 { + let q = p.abs() - half_size + Vec2::splat(r); + q.max(Vec2::ZERO).length() + q.x.max(q.y).min(0.0) - r +} + +// Pure-CPU port of `shader.wgsl::vertex` for `half_size`. The bug fix lives +// here: the fragment SDF expects a *positive* half-extent, so we abs() the +// signed `rect_size` before halving. Without the abs, the SDF receives a +// negative y half-extent and every interior fragment computes a positive +// distance ⇒ alpha collapses to 0 across the whole rect. +fn shader_half_size(rect_size: [f32; 2]) -> Vec2 { + Vec2::new(rect_size[0].abs(), rect_size[1].abs()) * 0.5 +} + +#[test] +fn shader_sdf_inside_is_filled_outside_is_empty() { + // Regression test for the half_size sign bug. Builds an `InstanceData` + // for a centered rect and walks the SDF for two `local_uv` samples: + // (0, 0) — rect center, must be inside (negative SDF, alpha → 1). + // (2, 2) — well outside the rect, must be outside (positive SDF, alpha → 0). + let window = Vec2::new(800.0, 600.0); + let draw = DrawData::new( + Vec2::new(100.0, 100.0), + Vec2::new(200.0, 100.0), + Color::WHITE, + 0.0, + ); + let i = to_instance(&draw, window); + + let half = shader_half_size(i.rect_size); + + // Center: local_uv = (0, 0) → p = (0, 0). + let d_center = sdf_rounded_rect(Vec2::ZERO * half, half, i.radius); + assert!( + d_center < 0.0, + "rect center must be inside the SDF (got d = {d_center}); regression of the \ + half_size sign bug — fragment shader was passing a signed half_size into the \ + SDF, putting every interior point outside the rect." + ); + + // Well outside: local_uv = (2, 2) → p = (2 * half.x, 2 * half.y). + let d_outside = sdf_rounded_rect(Vec2::splat(2.0) * half, half, i.radius); + assert!( + d_outside > 0.0, + "point at 2x the rect's half-extent must be outside the SDF (got d = {d_outside})" + ); +} + +#[test] +fn signed_rect_size_breaks_sdf_without_abs() { + // Documents WHY `shader.wgsl` must `abs(i.rect_size)` before halving. If + // we feed the SDF the signed `rect_size * 0.5` (the pre-fix shader + // behavior), the rect center reports a *positive* SDF — the rect renders + // invisible. This test pins that property so a regression that drops the + // abs back out fails loudly. + let window = Vec2::new(800.0, 600.0); + let draw = DrawData::new( + Vec2::new(100.0, 100.0), + Vec2::new(200.0, 100.0), + Color::WHITE, + 0.0, + ); + let i = to_instance(&draw, window); + + // The buggy half_size: signed, with negative y from to_instance's y-flip. + let bad_half = Vec2::new(i.rect_size[0], i.rect_size[1]) * 0.5; + assert!( + bad_half.y < 0.0, + "test precondition: y-flip leaves rect_size.y negative" + ); + + let d_center_buggy = sdf_rounded_rect(Vec2::ZERO * bad_half, bad_half, i.radius); + assert!( + d_center_buggy > 0.0, + "expected the *buggy* signed half_size path to put the rect center *outside* \ + the SDF (got d = {d_center_buggy}); if this assertion fails, either the SDF \ + or to_instance changed semantics — re-derive the bug first." + ); +} From 269be681ae089076185c26ad0c938b37ac4c69fa Mon Sep 17 00:00:00 2001 From: Noah Date: Fri, 8 May 2026 02:48:51 -0700 Subject: [PATCH 05/10] feat(buiy_core): per-window AccessKit adapter map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split a11y.rs into a11y/{mod,translate,adapter}.rs. translate.rs adds pure, winit-free A11yNodeView→AccessKit translation (build_tree_update, to_accesskit_node, node_id_for). adapter.rs adds AccessKitAdapters (NonSend resource) and AccessKitAdapterPlugin, which pushes TreeUpdate payloads into bevy_winit's existing ACCESS_KIT_ADAPTERS thread-local each frame rather than owning Adapter objects directly (Adapter::new requires &ActiveEventLoop which is only accessible inside the winit runner callback in Bevy 0.18). Fixes pre-existing rustdoc private-intra-doc-links warning in render/mod.rs. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/buiy/src/lib.rs | 6 +- crates/buiy_core/src/a11y/adapter.rs | 84 ++++++++++++++++++ crates/buiy_core/src/{a11y.rs => a11y/mod.rs} | 6 ++ crates/buiy_core/src/a11y/translate.rs | 85 +++++++++++++++++++ crates/buiy_core/src/render/mod.rs | 2 +- crates/buiy_core/tests/a11y.rs | 26 ++++++ crates/buiy_core/tests/a11y_translate.rs | 63 ++++++++++++++ 7 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 crates/buiy_core/src/a11y/adapter.rs rename crates/buiy_core/src/{a11y.rs => a11y/mod.rs} (95%) create mode 100644 crates/buiy_core/src/a11y/translate.rs create mode 100644 crates/buiy_core/tests/a11y_translate.rs diff --git a/crates/buiy/src/lib.rs b/crates/buiy/src/lib.rs index 222a45a..4ce4d40 100644 --- a/crates/buiy/src/lib.rs +++ b/crates/buiy/src/lib.rs @@ -6,7 +6,10 @@ use bevy::prelude::*; pub use buiy_core::{ BuiySet, CorePlugin, - a11y::{A11yDescription, A11yLabel, A11yRole, A11yTreeBuilder}, + a11y::{ + A11yDescription, A11yLabel, A11yRole, A11yTreeBuilder, AccessKitAdapterPlugin, + AccessKitAdapters, + }, components::{FlexDirection, Node, ResolvedLayout, Style}, focus::{FocusVisible, Focusable, FocusedEntity}, picking::Hovered, @@ -69,6 +72,7 @@ impl Plugin for BuiyPlugin { CorePlugin, buiy_core::theme::ThemePlugin, buiy_core::a11y::A11yPlugin, + buiy_core::a11y::AccessKitAdapterPlugin, buiy_core::focus::FocusPlugin, buiy_core::layout::LayoutPlugin, buiy_core::picking::PickingPlugin, diff --git a/crates/buiy_core/src/a11y/adapter.rs b/crates/buiy_core/src/a11y/adapter.rs new file mode 100644 index 0000000..f033b19 --- /dev/null +++ b/crates/buiy_core/src/a11y/adapter.rs @@ -0,0 +1,84 @@ +//! Per-window AccessKit adapter bridge. Buiy pushes its `A11yTreeBuilder` +//! snapshot into bevy_winit's existing `ACCESS_KIT_ADAPTERS` thread-local +//! each frame so that real screen readers attached to the window see Buiy's +//! widget tree. +//! +//! Architecture: foundation spec architecture.md § 2.6, accessibility.md § 3.11. +//! +//! # Adapter lifecycle ownership +//! +//! Bevy 0.18 (`bevy_winit`) owns the `Adapter` objects — they are created in +//! `prepare_accessibility_for_window` (called from the winit runner with +//! `ActiveEventLoop` in hand) and stored in the `ACCESS_KIT_ADAPTERS` +//! thread-local. `AccessKitAdapterPlugin` does *not* create or destroy +//! adapters; it only pushes `TreeUpdate` payloads each frame via +//! `update_if_active`. +//! +//! # Test-friendliness note +//! +//! Tests using `MinimalPlugins` (no winit) see an empty `ACCESS_KIT_ADAPTERS` +//! thread-local, so `push_tree_updates` is a no-op and the pure-translation +//! behavior is covered by `tests/a11y_translate.rs` independently. + +use crate::BuiySet; +use crate::a11y::A11yTreeBuilder; +use crate::a11y::translate::build_tree_update; +use crate::focus::FocusedEntity; +use bevy::prelude::*; +use bevy::winit::accessibility::ACCESS_KIT_ADAPTERS; +use std::collections::HashMap; + +/// Registry of window entities that Buiy has successfully pushed at least one +/// tree update to. This is a `NonSend` resource for API symmetry with +/// bevy_winit's internal adapter storage; the actual `Adapter` objects live +/// in bevy_winit's `ACCESS_KIT_ADAPTERS` thread-local. +/// +/// Phase 0 closeout: the map is populated during `push_tree_updates` and +/// exposed so callers can observe which windows received a tree update. +/// The `adapters` field mirrors the plan's naming (the plan assumed we'd +/// own the adapters directly; in Bevy 0.18 the adapter lifecycle belongs to +/// `bevy_winit::prepare_accessibility_for_window`). +#[derive(Default)] +pub struct AccessKitAdapters { + /// Windows that have received at least one `TreeUpdate` this session. + /// Empty when no winit windows are present (e.g. in tests with MinimalPlugins). + pub adapters: HashMap, +} + +/// Plugin that wires `A11yTreeBuilder` → bevy_winit's per-window +/// AccessKit adapters each frame. +pub struct AccessKitAdapterPlugin; + +impl Plugin for AccessKitAdapterPlugin { + fn build(&self, app: &mut App) { + app.init_non_send_resource::() + .add_systems(Update, push_tree_updates.in_set(BuiySet::A11yUpdate)); + } +} + +fn push_tree_updates( + builder: Res, + focused: Res, + mut adapters: NonSendMut, +) { + use crate::a11y::translate::node_id_for; + + let focused_id = focused.0.map(node_id_for); + let snapshot = builder.snapshot(); + if snapshot.is_empty() && focused_id.is_none() { + return; + } + + // Build the update once; `TreeUpdate` derives `Clone` in accesskit 0.21 + // so cloning per adapter is cheap for the typical single-window case. + let update = build_tree_update(snapshot, focused_id); + + ACCESS_KIT_ADAPTERS.with_borrow_mut(|ak_adapters| { + for (window_id, adapter) in ak_adapters.iter_mut() { + let cloned = update.clone(); + adapter.update_if_active(|| cloned); + // Record that we've pushed to this window. + adapters.adapters.entry(*window_id).or_insert(()); + } + }); +} diff --git a/crates/buiy_core/src/a11y.rs b/crates/buiy_core/src/a11y/mod.rs similarity index 95% rename from crates/buiy_core/src/a11y.rs rename to crates/buiy_core/src/a11y/mod.rs index df5bbe2..1573544 100644 --- a/crates/buiy_core/src/a11y.rs +++ b/crates/buiy_core/src/a11y/mod.rs @@ -9,6 +9,12 @@ use crate::{BuiySet, focus::Focusable}; use bevy::prelude::*; +pub mod adapter; +pub mod translate; + +pub use adapter::{AccessKitAdapterPlugin, AccessKitAdapters}; +pub use translate::{build_tree_update, to_accesskit_node}; + /// Decomposed AccessKit role component. /// /// Marked `#[non_exhaustive]` because the v0.x full ARIA taxonomy diff --git a/crates/buiy_core/src/a11y/translate.rs b/crates/buiy_core/src/a11y/translate.rs new file mode 100644 index 0000000..a8bc635 --- /dev/null +++ b/crates/buiy_core/src/a11y/translate.rs @@ -0,0 +1,85 @@ +//! Pure translation from Buiy's frame-built `A11yNodeView` snapshot into the +//! AccessKit data model. Keeping this module winit-free means we can +//! unit-test it without provisioning a real window. + +use crate::a11y::{A11yNodeView, A11yRole}; +use accesskit::{Node, NodeId, Role, Tree, TreeUpdate}; +use bevy::prelude::Entity; + +/// Stable AccessKit root node id. Every adapter pushes the same root so the +/// AT sees one tree per Buiy window. v0.x may key this off the window entity +/// when multi-window-aware ATs become a target. +pub const ROOT_NODE_ID: NodeId = NodeId(0); + +/// Convert a Bevy [`Entity`] into a stable [`NodeId`]. Entity::to_bits is +/// deterministic within a session, which is sufficient for AT consumption +/// (the AT doesn't compare across sessions). +pub fn node_id_for(entity: Entity) -> NodeId { + // Avoid 0 (reserved for ROOT_NODE_ID). +1 is safe because Bevy never + // produces an `Entity` whose bits are `u64::MAX`. + NodeId(entity.to_bits().saturating_add(1)) +} + +/// Translate one [`A11yNodeView`] into an [`accesskit::Node`]. +/// +/// Note: `Node::set_label` takes `impl Into>` in accesskit 0.21; +/// `String` and `&str` both satisfy that bound. +pub fn to_accesskit_node(view: &A11yNodeView) -> Node { + let mut node = Node::new(role_to_accesskit(view.role)); + if !view.name.is_empty() { + node.set_label(view.name.clone()); + } + if !view.description.is_empty() { + node.set_description(view.description.clone()); + } + // Phase 0 closeout: focusable widgets get the AccessKit "focusable" + // semantic. Full keyboard-action contract is widget-specific + // (`buiy-widget-catalog-design`). + if view.focusable { + node.add_action(accesskit::Action::Focus); + } + node +} + +fn role_to_accesskit(role: A11yRole) -> Role { + match role { + A11yRole::Generic => Role::GenericContainer, + A11yRole::Button => Role::Button, + A11yRole::Link => Role::Link, + A11yRole::Image => Role::Image, + A11yRole::Text => Role::Label, + A11yRole::Heading => Role::Heading, + A11yRole::Dialog => Role::Dialog, + A11yRole::AlertDialog => Role::AlertDialog, + A11yRole::Tooltip => Role::Tooltip, + } +} + +/// Build a full [`TreeUpdate`] containing a synthetic root plus one node +/// per [`A11yNodeView`]. Children are listed under the root in iteration +/// order; nesting is a v0.x topic (`buiy-accessibility-design`). +pub fn build_tree_update(views: &[A11yNodeView], focused: Option) -> TreeUpdate { + let mut nodes = Vec::with_capacity(views.len() + 1); + + // Children first — we still need to materialize their NodeIds before + // we can list them under the root. + let mut child_ids = Vec::with_capacity(views.len()); + for view in views { + let id = node_id_for(view.entity); + child_ids.push(id); + nodes.push((id, to_accesskit_node(view))); + } + + // Root. + let mut root = Node::new(Role::Window); + for cid in &child_ids { + root.push_child(*cid); + } + nodes.insert(0, (ROOT_NODE_ID, root)); + + TreeUpdate { + nodes, + tree: Some(Tree::new(ROOT_NODE_ID)), + focus: focused.unwrap_or(ROOT_NODE_ID), + } +} diff --git a/crates/buiy_core/src/render/mod.rs b/crates/buiy_core/src/render/mod.rs index ee35428..c9bfb65 100644 --- a/crates/buiy_core/src/render/mod.rs +++ b/crates/buiy_core/src/render/mod.rs @@ -21,7 +21,7 @@ pub mod pipeline; /// (rect, color, radius) tuples in window-local logical pixels, plus the /// primary window size used to convert them to clip space. /// -/// Populated only by [`extract_buiy_draws`] inside `ExtractSchedule`; this +/// Populated only by `extract_buiy_draws` inside `ExtractSchedule`; this /// resource is not part of the main-world public API and external authors /// should not construct it directly. `#[non_exhaustive]` keeps additions /// (top-layer compositing, clip-path, filters, blend modes, atlasing per diff --git a/crates/buiy_core/tests/a11y.rs b/crates/buiy_core/tests/a11y.rs index 656bbf9..8f0f0ad 100644 --- a/crates/buiy_core/tests/a11y.rs +++ b/crates/buiy_core/tests/a11y.rs @@ -5,6 +5,32 @@ use buiy_core::{ focus::Focusable, }; +#[test] +fn adapter_plugin_loads_without_panic() { + use buiy_core::a11y::{AccessKitAdapterPlugin, AccessKitAdapters}; + + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + app.add_plugins(CorePlugin); + app.add_plugins(A11yPlugin); + app.add_plugins(buiy_core::focus::FocusPlugin); + app.add_plugins(AccessKitAdapterPlugin); + // `FocusPlugin::handle_tab` reads `Res>`; MinimalPlugins + // doesn't include `InputPlugin`, so we seed the resource manually — + // same pattern used in `tests/focus.rs`. + app.init_resource::>(); + app.update(); + // No winit windows present → adapter map stays empty. The plugin still + // installs its lifecycle systems without panicking. Real adapter + // creation is exercised by running the `hello_button` example end-to-end. + let adapters = app.world().get_non_send_resource::(); + assert!(adapters.is_some(), "AccessKitAdapters resource present"); + assert!( + adapters.unwrap().adapters.is_empty(), + "no adapters created without winit windows" + ); +} + #[test] fn tree_builder_emits_one_node_per_focusable_with_role_and_label() { let mut app = App::new(); diff --git a/crates/buiy_core/tests/a11y_translate.rs b/crates/buiy_core/tests/a11y_translate.rs new file mode 100644 index 0000000..7b5d245 --- /dev/null +++ b/crates/buiy_core/tests/a11y_translate.rs @@ -0,0 +1,63 @@ +//! Unit tests for the pure `A11yNodeView` → AccessKit translation. No winit +//! window is required — this is the test-friendly seam between Buiy's a11y +//! tree and the OS-level adapter. + +use bevy::prelude::*; +use buiy_core::a11y::{ + A11yNodeView, A11yRole, + translate::{build_tree_update, to_accesskit_node}, +}; + +#[test] +fn role_maps_to_accesskit_role() { + let view = A11yNodeView { + entity: Entity::PLACEHOLDER, + role: A11yRole::Button, + name: "Save".into(), + description: String::new(), + focusable: true, + }; + let node = to_accesskit_node(&view); + assert_eq!(node.role(), accesskit::Role::Button); + assert_eq!(node.label(), Some("Save")); +} + +#[test] +fn build_tree_update_emits_root_plus_children() { + let views = vec![ + A11yNodeView { + entity: Entity::from_raw_u32(1).unwrap(), + role: A11yRole::Button, + name: "Save".into(), + description: "Saves the document".into(), + focusable: true, + }, + A11yNodeView { + entity: Entity::from_raw_u32(2).unwrap(), + role: A11yRole::Generic, + name: "Container".into(), + description: String::new(), + focusable: false, + }, + ]; + let update = build_tree_update(&views, None); + // Root + 2 child nodes: + assert_eq!(update.nodes.len(), 3); + // Tree pointer is set with a stable root id: + assert!(update.tree.is_some()); +} + +#[test] +fn focused_node_id_round_trips() { + let views = vec![A11yNodeView { + entity: Entity::from_raw_u32(42).unwrap(), + role: A11yRole::Button, + name: "Focus me".into(), + description: String::new(), + focusable: true, + }]; + // Caller passes the entity-derived NodeId for the focused node. + let focused_id = buiy_core::a11y::translate::node_id_for(Entity::from_raw_u32(42).unwrap()); + let update = build_tree_update(&views, Some(focused_id)); + assert_eq!(update.focus, focused_id); +} From 1aaecb621db183606e6e18fd264ec1b6c9b93025 Mon Sep 17 00:00:00 2001 From: Noah Date: Fri, 8 May 2026 03:06:40 -0700 Subject: [PATCH 06/10] fix(buiy_core): push empty AccessKit trees, order push after build_tree, drop dead AccessKitAdapters Three fixes from code review of 269be68: A. Always push TreeUpdate, even on empty snapshots. The previous early-return suppressed pushes when the last widget was removed, leaving the AT holding a stale tree. accesskit_unix's update_if_active correctly diffs a root-only update and emits ChildRemoved events; deleting the early return restores that contract. C. Order push_tree_updates .after(build_tree). Both lived in BuiySet::A11yUpdate without an explicit constraint; Bevy's default ambiguity-detection is LogLevel::Ignore, so the scheduler was free to reorder them. Without the .after() the push system could observe the previous frame's snapshot and permanently lag the AT by one frame. build_tree is now pub(crate) so adapter.rs can name it. B. Drop the AccessKitAdapters resource. It tracked which windows had been pushed to but no code read the map for any decision. bevy_winit's ACCESS_KIT_ADAPTERS thread-local is the source of truth; the resource was dead weight. Removed the struct, the NonSendMut parameter, the re-export from a11y/mod.rs, the re-export from buiy/src/lib.rs, and rewrote the smoke test to assert against bevy_winit's thread-local. Also added two translate-test gaps: description_round_trips and focusable_view_has_focus_action (covers Action::Focus add-action wiring). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/buiy/src/lib.rs | 5 +-- crates/buiy_core/src/a11y/adapter.rs | 54 +++++++++--------------- crates/buiy_core/src/a11y/mod.rs | 4 +- crates/buiy_core/tests/a11y.rs | 19 +++++---- crates/buiy_core/tests/a11y_translate.rs | 36 ++++++++++++++++ 5 files changed, 71 insertions(+), 47 deletions(-) diff --git a/crates/buiy/src/lib.rs b/crates/buiy/src/lib.rs index 4ce4d40..99babdd 100644 --- a/crates/buiy/src/lib.rs +++ b/crates/buiy/src/lib.rs @@ -6,10 +6,7 @@ use bevy::prelude::*; pub use buiy_core::{ BuiySet, CorePlugin, - a11y::{ - A11yDescription, A11yLabel, A11yRole, A11yTreeBuilder, AccessKitAdapterPlugin, - AccessKitAdapters, - }, + a11y::{A11yDescription, A11yLabel, A11yRole, A11yTreeBuilder, AccessKitAdapterPlugin}, components::{FlexDirection, Node, ResolvedLayout, Style}, focus::{FocusVisible, Focusable, FocusedEntity}, picking::Hovered, diff --git a/crates/buiy_core/src/a11y/adapter.rs b/crates/buiy_core/src/a11y/adapter.rs index f033b19..57a3a9f 100644 --- a/crates/buiy_core/src/a11y/adapter.rs +++ b/crates/buiy_core/src/a11y/adapter.rs @@ -26,59 +26,47 @@ use crate::a11y::translate::build_tree_update; use crate::focus::FocusedEntity; use bevy::prelude::*; use bevy::winit::accessibility::ACCESS_KIT_ADAPTERS; -use std::collections::HashMap; - -/// Registry of window entities that Buiy has successfully pushed at least one -/// tree update to. This is a `NonSend` resource for API symmetry with -/// bevy_winit's internal adapter storage; the actual `Adapter` objects live -/// in bevy_winit's `ACCESS_KIT_ADAPTERS` thread-local. -/// -/// Phase 0 closeout: the map is populated during `push_tree_updates` and -/// exposed so callers can observe which windows received a tree update. -/// The `adapters` field mirrors the plan's naming (the plan assumed we'd -/// own the adapters directly; in Bevy 0.18 the adapter lifecycle belongs to -/// `bevy_winit::prepare_accessibility_for_window`). -#[derive(Default)] -pub struct AccessKitAdapters { - /// Windows that have received at least one `TreeUpdate` this session. - /// Empty when no winit windows are present (e.g. in tests with MinimalPlugins). - pub adapters: HashMap, -} /// Plugin that wires `A11yTreeBuilder` → bevy_winit's per-window /// AccessKit adapters each frame. +/// +/// `push_tree_updates` is ordered `.after(crate::a11y::build_tree)` so it +/// observes the freshly built snapshot for the current frame, not the +/// previous one. Both systems live in `BuiySet::A11yUpdate`; without the +/// explicit dependency Bevy's scheduler is free to reorder them +/// (ambiguity-detection defaults to `LogLevel::Ignore`). pub struct AccessKitAdapterPlugin; impl Plugin for AccessKitAdapterPlugin { fn build(&self, app: &mut App) { - app.init_non_send_resource::() - .add_systems(Update, push_tree_updates.in_set(BuiySet::A11yUpdate)); + app.add_systems( + Update, + push_tree_updates + .in_set(BuiySet::A11yUpdate) + .after(crate::a11y::build_tree), + ); } } -fn push_tree_updates( - builder: Res, - focused: Res, - mut adapters: NonSendMut, -) { +fn push_tree_updates(builder: Res, focused: Res) { use crate::a11y::translate::node_id_for; let focused_id = focused.0.map(node_id_for); let snapshot = builder.snapshot(); - if snapshot.is_empty() && focused_id.is_none() { - return; - } - // Build the update once; `TreeUpdate` derives `Clone` in accesskit 0.21 - // so cloning per adapter is cheap for the typical single-window case. + // Always build and push, even when the snapshot is empty. An empty + // TreeUpdate (root-only) is the correct signal for the AT to clear + // stale state when all widgets are removed (dialog close, scene + // transition, etc.). `accesskit_unix::Adapter::update_if_active` diffs + // and emits `ChildRemoved` events when given a root-only TreeUpdate. + // The `with_borrow_mut` loop is a no-op when no winit windows exist + // (e.g. tests with `MinimalPlugins`). let update = build_tree_update(snapshot, focused_id); ACCESS_KIT_ADAPTERS.with_borrow_mut(|ak_adapters| { - for (window_id, adapter) in ak_adapters.iter_mut() { + for (_window_id, adapter) in ak_adapters.iter_mut() { let cloned = update.clone(); adapter.update_if_active(|| cloned); - // Record that we've pushed to this window. - adapters.adapters.entry(*window_id).or_insert(()); } }); } diff --git a/crates/buiy_core/src/a11y/mod.rs b/crates/buiy_core/src/a11y/mod.rs index 1573544..0cc723f 100644 --- a/crates/buiy_core/src/a11y/mod.rs +++ b/crates/buiy_core/src/a11y/mod.rs @@ -12,7 +12,7 @@ use bevy::prelude::*; pub mod adapter; pub mod translate; -pub use adapter::{AccessKitAdapterPlugin, AccessKitAdapters}; +pub use adapter::AccessKitAdapterPlugin; pub use translate::{build_tree_update, to_accesskit_node}; /// Decomposed AccessKit role component. @@ -87,7 +87,7 @@ impl Plugin for A11yPlugin { } #[allow(clippy::type_complexity)] -fn build_tree( +pub(crate) fn build_tree( mut builder: ResMut, q: Query<( Entity, diff --git a/crates/buiy_core/tests/a11y.rs b/crates/buiy_core/tests/a11y.rs index 8f0f0ad..78ab10f 100644 --- a/crates/buiy_core/tests/a11y.rs +++ b/crates/buiy_core/tests/a11y.rs @@ -7,7 +7,8 @@ use buiy_core::{ #[test] fn adapter_plugin_loads_without_panic() { - use buiy_core::a11y::{AccessKitAdapterPlugin, AccessKitAdapters}; + use bevy::winit::accessibility::ACCESS_KIT_ADAPTERS; + use buiy_core::a11y::AccessKitAdapterPlugin; let mut app = App::new(); app.add_plugins(MinimalPlugins); @@ -19,15 +20,17 @@ fn adapter_plugin_loads_without_panic() { // doesn't include `InputPlugin`, so we seed the resource manually — // same pattern used in `tests/focus.rs`. app.init_resource::>(); + // The plugin must install `push_tree_updates` without panicking, even + // when no winit windows exist. Real adapter creation is exercised by + // running the `hello_button` example end-to-end. app.update(); - // No winit windows present → adapter map stays empty. The plugin still - // installs its lifecycle systems without panicking. Real adapter - // creation is exercised by running the `hello_button` example end-to-end. - let adapters = app.world().get_non_send_resource::(); - assert!(adapters.is_some(), "AccessKitAdapters resource present"); + // bevy_winit's `ACCESS_KIT_ADAPTERS` thread-local is the source of truth + // for which windows have AccessKit adapters. Under MinimalPlugins no + // winit windows are spawned, so the map stays empty. + let bevy_adapters_empty = ACCESS_KIT_ADAPTERS.with_borrow(|m| m.0.is_empty()); assert!( - adapters.unwrap().adapters.is_empty(), - "no adapters created without winit windows" + bevy_adapters_empty, + "no bevy_winit adapters created under MinimalPlugins" ); } diff --git a/crates/buiy_core/tests/a11y_translate.rs b/crates/buiy_core/tests/a11y_translate.rs index 7b5d245..18ad5d3 100644 --- a/crates/buiy_core/tests/a11y_translate.rs +++ b/crates/buiy_core/tests/a11y_translate.rs @@ -61,3 +61,39 @@ fn focused_node_id_round_trips() { let update = build_tree_update(&views, Some(focused_id)); assert_eq!(update.focus, focused_id); } + +#[test] +fn description_round_trips() { + let view = A11yNodeView { + entity: Entity::from_raw_u32(1).unwrap(), + role: A11yRole::Button, + name: "Save".into(), + description: "Saves the document".into(), + focusable: true, + }; + let node = to_accesskit_node(&view); + assert_eq!(node.description(), Some("Saves the document")); +} + +#[test] +fn focusable_view_has_focus_action() { + let focusable = A11yNodeView { + entity: Entity::from_raw_u32(1).unwrap(), + role: A11yRole::Button, + name: "Click".into(), + description: String::new(), + focusable: true, + }; + let node = to_accesskit_node(&focusable); + assert!(node.supports_action(accesskit::Action::Focus)); + + let non_focusable = A11yNodeView { + entity: Entity::from_raw_u32(2).unwrap(), + role: A11yRole::Generic, + name: "Container".into(), + description: String::new(), + focusable: false, + }; + let node = to_accesskit_node(&non_focusable); + assert!(!node.supports_action(accesskit::Action::Focus)); +} From 54d60732f2bc78898b659d0ed8d5c059414faffe Mon Sep 17 00:00:00 2001 From: Noah Date: Fri, 8 May 2026 03:22:05 -0700 Subject: [PATCH 07/10] feat(buiy_core): bevy_picking backend with Hovered as thin layer Replace the AABB+window-cursor shim in update_hovered with a real bevy_picking backend. BuiyPickingBackendPlugin runs in PickingSystems::Backend (PreUpdate), reads PointerLocation components, and emits PointerHits messages sorted by area (smallest = topmost). PickingPlugin's update_hovered is rewired to consume those messages, making Hovered a thin aggregation layer over the backend. Bevy 0.18.1 API deviations from plan: - PointerHits is a Message (not Event); uses MessageWriter/MessageReader. - Location.target is NormalizedRenderTarget (not PointerTarget). - PickSet::Backend is PickingSystems::Backend. - BuiyPlugin now also composes bevy::picking::PickingPlugin to register PickingSystems sets and Messages before the Buiy plugins. Co-Authored-By: Claude Sonnet 4.6 --- crates/buiy/src/lib.rs | 13 ++- crates/buiy_core/src/lib.rs | 2 +- crates/buiy_core/src/picking/backend.rs | 76 ++++++++++++++++++ .../src/{picking.rs => picking/mod.rs} | 54 +++++++------ crates/buiy_core/tests/picking_backend.rs | 80 +++++++++++++++++++ 5 files changed, 197 insertions(+), 28 deletions(-) create mode 100644 crates/buiy_core/src/picking/backend.rs rename crates/buiy_core/src/{picking.rs => picking/mod.rs} (57%) create mode 100644 crates/buiy_core/tests/picking_backend.rs diff --git a/crates/buiy/src/lib.rs b/crates/buiy/src/lib.rs index 99babdd..b6f3145 100644 --- a/crates/buiy/src/lib.rs +++ b/crates/buiy/src/lib.rs @@ -9,7 +9,7 @@ pub use buiy_core::{ a11y::{A11yDescription, A11yLabel, A11yRole, A11yTreeBuilder, AccessKitAdapterPlugin}, components::{FlexDirection, Node, ResolvedLayout, Style}, focus::{FocusVisible, Focusable, FocusedEntity}, - picking::Hovered, + picking::{BuiyPickingBackendPlugin, Hovered}, theme::{Theme, UserPreferences, default_light_theme}, }; pub use buiy_widgets::{Button, OnPress, WidgetsPlugin}; @@ -45,6 +45,11 @@ pub use buiy_widgets::{Button, OnPress, WidgetsPlugin}; /// 0.18 panics when a `Res` system param is missing, so the plugin /// must be present. /// +/// `BuiyPlugin` also composes `bevy::picking::PickingPlugin` (the core +/// bevy_picking infrastructure), so you do not need to add it separately. +/// If you are using `DefaultPlugins`, bevy_picking is not included by +/// default; `BuiyPlugin` adds it for you. +/// /// # Plugin order /// /// Add `BuiyPlugin` **after** Bevy's render plugin (i.e., after @@ -72,7 +77,13 @@ impl Plugin for BuiyPlugin { buiy_core::a11y::AccessKitAdapterPlugin, buiy_core::focus::FocusPlugin, buiy_core::layout::LayoutPlugin, + // bevy_picking::PickingPlugin registers PickingSystems system sets + // and the Messages message resource that both + // PickingPlugin and BuiyPickingBackendPlugin depend on. Must come + // before the two Buiy picking plugins. + bevy::picking::PickingPlugin, buiy_core::picking::PickingPlugin, + buiy_core::picking::BuiyPickingBackendPlugin, WidgetsPlugin, )); } diff --git a/crates/buiy_core/src/lib.rs b/crates/buiy_core/src/lib.rs index 24c4f33..1f6e203 100644 --- a/crates/buiy_core/src/lib.rs +++ b/crates/buiy_core/src/lib.rs @@ -17,7 +17,7 @@ pub use a11y::{A11yDescription, A11yLabel, A11yNodeView, A11yPlugin, A11yRole, A pub use components::{FlexDirection, Node, ResolvedLayout, Style}; pub use focus::{FocusPlugin, FocusVisible, Focusable, FocusedEntity}; pub use layout::LayoutPlugin; -pub use picking::{Hovered, PickingPlugin, hit_test}; +pub use picking::{BuiyPickingBackendPlugin, Hovered, PickingPlugin, hit_test}; /// Top-level system sets for Buiy. Order: Layout → Style → Input → Animate /// → Picking → A11yUpdate → Render. diff --git a/crates/buiy_core/src/picking/backend.rs b/crates/buiy_core/src/picking/backend.rs new file mode 100644 index 0000000..0bccef9 --- /dev/null +++ b/crates/buiy_core/src/picking/backend.rs @@ -0,0 +1,76 @@ +//! Buiy's `bevy_picking` backend. Reads `PointerLocation` and produces +//! `PointerHits` from `ResolvedLayout` AABBs. +//! +//! Phase 0 closeout scope: per-pointer hits, top-most-by-area resolution +//! (matches the Phase 0 `hit_test` semantics in `mod.rs`). Multi-pointer +//! arbitration, pointer-target window filtering, and full backend priority +//! land in `buiy-input-events-design`. + +use crate::components::ResolvedLayout; +use crate::picking::point_in_aabb; +use bevy::picking::PickingSystems; +use bevy::picking::backend::{HitData, PointerHits}; +use bevy::picking::pointer::{PointerId, PointerLocation}; +use bevy::prelude::*; + +/// Buiy's `bevy_picking` backend plugin. Registers `emit_picks` in +/// [`PickingSystems::Backend`] so bevy_picking can composite Buiy's +/// AABB hit results with any other active backends. +pub struct BuiyPickingBackendPlugin; + +impl Plugin for BuiyPickingBackendPlugin { + fn build(&self, app: &mut App) { + app.add_systems(PreUpdate, emit_picks.in_set(PickingSystems::Backend)); + } +} + +fn emit_picks( + pointers: Query<(&PointerId, &PointerLocation)>, + nodes: Query<(Entity, &ResolvedLayout)>, + mut output: MessageWriter, +) { + for (pointer, location) in pointers.iter() { + let Some(loc) = location.location() else { + continue; + }; + let cursor = loc.position; + + // Collect every Buiy node under the cursor, with its area as the + // tie-break for "top-most". + let mut hits: Vec<(Entity, f32)> = Vec::new(); + for (entity, layout) in nodes.iter() { + if point_in_aabb(cursor, layout) { + let area = layout.size.x * layout.size.y; + hits.push((entity, area)); + } + } + if hits.is_empty() { + continue; + } + // Smallest area = closest to the user (top of the stack). bevy_picking + // expects depth-sorted; emit one HitData per entity with depth derived + // from area rank. + hits.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + let picks: Vec<(Entity, HitData)> = hits + .iter() + .enumerate() + .map(|(i, (e, _))| { + ( + *e, + HitData::new( + // Camera entity unknown to Buiy in Phase 0 closeout; the + // render-graph node draws into the active 2D camera's + // ViewTarget, but bevy_picking expects a camera ref for + // its own back-projection. v0.x sub-spec wires this. + Entity::PLACEHOLDER, + i as f32, + None, + None, + ), + ) + }) + .collect(); + + output.write(PointerHits::new(*pointer, picks, 0.0)); + } +} diff --git a/crates/buiy_core/src/picking.rs b/crates/buiy_core/src/picking/mod.rs similarity index 57% rename from crates/buiy_core/src/picking.rs rename to crates/buiy_core/src/picking/mod.rs index a583c88..e14af46 100644 --- a/crates/buiy_core/src/picking.rs +++ b/crates/buiy_core/src/picking/mod.rs @@ -1,15 +1,20 @@ -//! Buiy `bevy_picking` backend. Per-window registration; full backend -//! priority + window filter live in `buiy-input-events-design`. +//! Buiy picking: AABB hit-test utilities and the `bevy_picking` backend wiring. //! //! See: docs/specs/2026-05-07-buiy-foundation/cross-cutting.md § 3.18. use crate::components::ResolvedLayout; use bevy::ecs::query::QueryState; +use bevy::picking::backend::PointerHits; use bevy::prelude::*; +pub mod backend; + +pub use backend::BuiyPickingBackendPlugin; + /// Phase 0 picking exposes a simple AABB hit-test fn for tests + a -/// minimal Bevy system that updates a `Hovered` resource. The full -/// `bevy_picking::backend::PickingBackend` registration lives in v0.x. +/// `Hovered` resource updated by consuming `PointerHits` from the Buiy +/// `bevy_picking` backend. The full `bevy_picking::backend::PickingBackend` +/// registration lives in v0.x. pub struct PickingPlugin; #[derive(Resource, Reflect, Default, Clone, Debug)] @@ -43,7 +48,7 @@ pub fn hit_test(world: &World, point: Vec2) -> Option { best.map(|(e, _)| e) } -fn point_in_aabb(point: Vec2, layout: &ResolvedLayout) -> bool { +pub(crate) fn point_in_aabb(point: Vec2, layout: &ResolvedLayout) -> bool { let max = layout.position + layout.size; point.x >= layout.position.x && point.x <= max.x @@ -51,27 +56,24 @@ fn point_in_aabb(point: Vec2, layout: &ResolvedLayout) -> bool { && point.y <= max.y } -fn update_hovered( - mut hovered: ResMut, - windows: Query<&Window>, - layouts: Query<(Entity, &ResolvedLayout)>, -) { - let Some(window) = windows.iter().next() else { - return; - }; - let Some(cursor) = window.cursor_position() else { - hovered.0 = None; - return; - }; - // Inline hit_test against the live query to avoid needing &World. - let mut best: Option<(Entity, f32)> = None; - for (entity, layout) in layouts.iter() { - if point_in_aabb(cursor, layout) { - let area = layout.size.x * layout.size.y; - if best.map(|(_, a)| area < a).unwrap_or(true) { - best = Some((entity, area)); - } +fn update_hovered(mut hovered: ResMut, mut events: MessageReader) { + // The top-most hit is at index 0 of `picks` (sorted ascending by depth in + // `BuiyPickingBackendPlugin::emit_picks`). Multiple pointers: we honor the + // most-recently-emitted hit and fall through to clearing if no events + // arrive this frame. + let mut latest: Option = None; + let mut saw_event = false; + for ev in events.read() { + saw_event = true; + if let Some((entity, _)) = ev.picks.first() { + latest = Some(*entity); + } else { + latest = None; } } - hovered.0 = best.map(|(e, _)| e); + if saw_event { + hovered.0 = latest; + } + // No events this frame ⇒ leave `Hovered` as-is. Cursor-leaving-window + // produces an empty `picks` event (see emit_picks) which clears it. } diff --git a/crates/buiy_core/tests/picking_backend.rs b/crates/buiy_core/tests/picking_backend.rs new file mode 100644 index 0000000..6c5d094 --- /dev/null +++ b/crates/buiy_core/tests/picking_backend.rs @@ -0,0 +1,80 @@ +//! `bevy_picking` backend integration test. Drives a fake `PointerLocation` +//! and asserts a `PointerHits` message fires for the entity under the pointer. +//! +//! API deviations from plan (Bevy 0.18.1 vs plan's 0.18 assumptions): +//! - `PointerHits` is a `Message`, not an `Event`; accessed via +//! `Messages` + `MessageCursor`, not `Events`. +//! - `Location.target` is `NormalizedRenderTarget`, not `PointerTarget`. +//! Constructed via `WindowRef::Entity(e).normalize(Some(e)).unwrap()`. +//! - `PickSet::Backend` is `PickingSystems::Backend`. +//! - Bevy's `PickingPlugin` is `bevy::picking::PickingPlugin`. + +use bevy::camera::NormalizedRenderTarget; +use bevy::ecs::message::Messages; +use bevy::picking::backend::PointerHits; +use bevy::picking::pointer::Location; +use bevy::picking::pointer::{PointerId, PointerLocation}; +use bevy::prelude::*; +use bevy::window::WindowRef; +use buiy_core::{ + CorePlugin, + components::{Node, ResolvedLayout, Style}, + picking::{BuiyPickingBackendPlugin, PickingPlugin}, +}; + +#[test] +fn pointer_over_buiy_node_emits_hit() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + // bevy_picking::PickingPlugin registers PickingSystems sets and the + // Messages message resource. + app.add_plugins(bevy::picking::PickingPlugin); + app.add_plugins(CorePlugin); + app.add_plugins(PickingPlugin); + app.add_plugins(BuiyPickingBackendPlugin); + + let entity = app + .world_mut() + .spawn(( + Node, + Style::default(), + ResolvedLayout { + position: Vec2::new(10.0, 10.0), + size: Vec2::new(100.0, 50.0), + }, + )) + .id(); + + // Build a NormalizedRenderTarget for the pointer location. The backend + // reads only `loc.position`, so the target entity just needs to be + // constructible — it need not correspond to a real window. + let window_entity = Entity::PLACEHOLDER; + let target = WindowRef::Entity(window_entity) + .normalize(Some(window_entity)) + .unwrap(); + + // Spawn a pointer at (50, 30). Real apps source this from winit; the + // backend reads `PointerLocation` regardless of source. + app.world_mut().spawn(( + PointerId::Mouse, + PointerLocation::new(Location { + target: NormalizedRenderTarget::Window(target), + position: Vec2::new(50.0, 30.0), + }), + )); + + app.update(); + + // PointerHits is a Message in Bevy 0.18, not an Event. + // Read via Messages resource + a fresh cursor. + let world = app.world_mut(); + let messages = world.resource::>(); + let mut cursor = messages.get_cursor(); + let any_hit = cursor + .read(messages) + .any(|h| h.picks.iter().any(|(e, _)| *e == entity)); + assert!( + any_hit, + "Buiy backend should emit a PointerHits message for the entity under the cursor" + ); +} From d6221afab9a517e50a395bead92235d05454a062 Mon Sep 17 00:00:00 2001 From: Noah Date: Fri, 8 May 2026 03:31:32 -0700 Subject: [PATCH 08/10] docs(buiy_core): correct Hovered + BuiyPlugin comments for picking backend Two comment fixes flagged in code-quality review of 54d6073: - update_hovered: replace incorrect "cursor-leaving-window produces an empty picks event which clears it" with an accurate description of emit_picks's continue-on-empty behavior. Calls out the Phase 0 limitation (Hovered retains stale value when cursor leaves all Buiy nodes) and points to buiy-input-events-design for v0.x. - BuiyPlugin::build: update the inline order comment to acknowledge bevy::picking::PickingPlugin in the slot, and explain why it must precede the two Buiy picking plugins (registers PickingSystems sets + Messages). No code changes; comments only. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/buiy/src/lib.rs | 14 +++++++------- crates/buiy_core/src/picking/mod.rs | 7 +++++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/buiy/src/lib.rs b/crates/buiy/src/lib.rs index b6f3145..fe44632 100644 --- a/crates/buiy/src/lib.rs +++ b/crates/buiy/src/lib.rs @@ -63,13 +63,13 @@ pub struct BuiyPlugin; impl Plugin for BuiyPlugin { fn build(&self, app: &mut App) { - // Sub-plugin order matches architecture.md § 2.8 documented order: - // core → theme → a11y → focus → input → text → widgets → ... - // Phase 0 omits text / animation / forms / devtools; LayoutPlugin - // and PickingPlugin (which aren't enumerated as sub-plugins in § 2.8 - // because their work lives in BuiySet::Layout and BuiySet::Picking) - // are slotted between Focus and Widgets so widgets see resolved - // layout + hit-test results when they run in the same frame. + // Sub-plugin order matches architecture.md § 2.8: core → theme → a11y → + // focus → input → text → widgets. Phase 0 omits text/animation/forms/ + // devtools. LayoutPlugin, bevy::picking::PickingPlugin, Buiy's + // PickingPlugin, and BuiyPickingBackendPlugin slot between Focus and + // Widgets so widgets see resolved layout + hit-test results in the same + // frame. bevy_picking must come first because it registers PickingSystems + // sets + Messages; the two Buiy plugins consume both. app.add_plugins(( CorePlugin, buiy_core::theme::ThemePlugin, diff --git a/crates/buiy_core/src/picking/mod.rs b/crates/buiy_core/src/picking/mod.rs index e14af46..cebd4b0 100644 --- a/crates/buiy_core/src/picking/mod.rs +++ b/crates/buiy_core/src/picking/mod.rs @@ -74,6 +74,9 @@ fn update_hovered(mut hovered: ResMut, mut events: MessageReader Date: Fri, 8 May 2026 03:33:43 -0700 Subject: [PATCH 09/10] docs: changelog + status flips for Phase 0 closeout Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 10 ++++++++++ docs/README.md | 2 +- docs/plans/2026-05-08-buiy-phase-0-closeout.md | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e01812a..93117a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,13 @@ once it reaches `0.1.0`. Pre-`0.1.0` releases are pre-alpha; APIs may break in a Pre-`0.1.0` development. Detailed change tracking begins with the first tagged release. + +### Added +- Render pipeline now produces real pixels for Buiy nodes (instance-buffer + construction, clip-space conversion, draw call). Closes the Phase 0 + render deferral. +- Per-window AccessKit adapter map. Real screen readers attached to a + Buiy window receive a tree update each frame. Closes the Phase 0 a11y + deferral. +- `bevy_picking` backend. `Hovered` becomes a thin layer over the standard + `PointerHits` event flow. Closes the Phase 0 picking deferral. diff --git a/docs/README.md b/docs/README.md index b6877c8..b7b042c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,7 +47,7 @@ If a doc spans areas, file it under its primary area only. Reference any adjacen **Plans** - [Phase 0 foundations](plans/2026-05-07-buiy-phase-0-foundations.md) — workspace, BuiyPlugin, system sets, minimal render/layout/a11y/focus/picking/theme, verification harness skeleton, hello-world Button. `[landed]` -- [Phase 0 closeout](plans/2026-05-08-buiy-phase-0-closeout.md) — render-pipeline draws, AccessKit per-window adapter, `bevy_picking` backend; closes the three substantive deferrals from the Phase 0 self-review. `[draft]` +- [Phase 0 closeout](plans/2026-05-08-buiy-phase-0-closeout.md) — render-pipeline draws, AccessKit per-window adapter, `bevy_picking` backend; closes the three substantive deferrals from the Phase 0 self-review. `[landed]` ### Docs infrastructure diff --git a/docs/plans/2026-05-08-buiy-phase-0-closeout.md b/docs/plans/2026-05-08-buiy-phase-0-closeout.md index eb85885..d6655eb 100644 --- a/docs/plans/2026-05-08-buiy-phase-0-closeout.md +++ b/docs/plans/2026-05-08-buiy-phase-0-closeout.md @@ -1,7 +1,7 @@ # Buiy Phase 0 Closeout Implementation Plan **Date:** 2026-05-08 -**Status:** draft +**Status:** landed **Spec:** [specs/2026-05-07-buiy-foundation/README.md](../specs/2026-05-07-buiy-foundation/README.md) **Realizes:** Deferred items from [plans/2026-05-07-buiy-phase-0-foundations.md](2026-05-07-buiy-phase-0-foundations.md) self-review (lines 2784–2790). From 9eecefc582fb9c3e15a31baf2e180ae7e20a6920 Mon Sep 17 00:00:00 2001 From: Noah Date: Fri, 8 May 2026 03:43:19 -0700 Subject: [PATCH 10/10] docs: amend closeout plan + CHANGELOG to reflect AccessKit bridge Final-review feedback: the closeout plan's self-review row claimed "full HashMap" but the actual implementation bridges into bevy_winit's ACCESS_KIT_ADAPTERS thread-local because Bevy 0.18 owns adapter creation (Adapter::* constructors require &ActiveEventLoop, only accessible from the winit runner callback). Update the self-review row to describe the bridge accurately and call out the AccessKitAdapters resource that was dropped during Task 4's review loop. Also tighten the CHANGELOG bullet so it doesn't imply Buiy owns the Adapter objects. No code changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 7 +++++-- docs/plans/2026-05-08-buiy-phase-0-closeout.md | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93117a9..eddfb6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,11 @@ tagged release. - Render pipeline now produces real pixels for Buiy nodes (instance-buffer construction, clip-space conversion, draw call). Closes the Phase 0 render deferral. -- Per-window AccessKit adapter map. Real screen readers attached to a - Buiy window receive a tree update each frame. Closes the Phase 0 a11y +- Per-window AccessKit tree-update bridge. Buiy translates its widget tree + to `accesskit::TreeUpdate` each frame and pushes it through bevy_winit's + `ACCESS_KIT_ADAPTERS` so real screen readers attached to a Buiy window + see the live tree. (Bevy 0.18 owns adapter creation, so Buiy bridges + rather than owning `Adapter` objects directly.) Closes the Phase 0 a11y deferral. - `bevy_picking` backend. `Hovered` becomes a thin layer over the standard `PointerHits` event flow. Closes the Phase 0 picking deferral. diff --git a/docs/plans/2026-05-08-buiy-phase-0-closeout.md b/docs/plans/2026-05-08-buiy-phase-0-closeout.md index d6655eb..aae34f2 100644 --- a/docs/plans/2026-05-08-buiy-phase-0-closeout.md +++ b/docs/plans/2026-05-08-buiy-phase-0-closeout.md @@ -1488,8 +1488,8 @@ PR description should cite the three Phase 0 deferrals being closed and link to Cross-checked against the foundation spec self-review (foundations plan lines 2784–2790): -- ✅ `bevy_picking` real backend implementation — Task 5. -- ✅ AccessKit `accesskit_winit::Adapter` per-window wiring — Task 4 (full `HashMap` per the user's PR-shape pick). +- ✅ `bevy_picking` real backend implementation — Task 5. Forced API adjustments: `PointerHits` is a `Message` (not `Event`) in Bevy 0.18 → `MessageWriter`/`MessageReader`; `PickingSystems::Backend` (not `PickSet::Backend`); `bevy::picking::PickingPlugin` had to be added to `BuiyPlugin` because it's the sole registrar of `PickingSystems` sets and `Messages`. +- ✅ AccessKit `accesskit_winit::Adapter` per-window wiring — Task 4. **Architectural deviation from this plan's body:** Bevy 0.18 owns adapter creation (`accesskit_winit::Adapter::*` constructors all require `&ActiveEventLoop` which only exists inside the winit runner; bevy_winit creates adapters in `prepare_accessibility_for_window` and stores them in `ACCESS_KIT_ADAPTERS`). Buiy could not own a `HashMap` resource as the plan body described. Implemented as a *bridge*: `push_tree_updates` reaches into `bevy::winit::accessibility::ACCESS_KIT_ADAPTERS` each frame and pushes Buiy's `TreeUpdate`. The per-window outcome (one `Adapter` per window, fed our tree) is preserved; ownership is delegated to bevy_winit. See `crates/buiy_core/src/a11y/adapter.rs` module doc-comment for the reasoning. The `AccessKitAdapters` resource was dropped during the Task 4 review loop as it was dead weight under the bridge model. - ✅ Render pipeline draws (instance-buffer construction) — Task 3. - ⚠️ Cosmic-text / glyph rendering — **explicitly out of scope** per "Out of scope" header. Closeout's hello_button still shows a colored rect with no glyph rendering; the architecture-proof bar remains the right one for Phase 0. - ⚠️ BSN authoring / hot-reload — **explicitly out of scope**.