diff --git a/CLAUDE.md b/CLAUDE.md index 41ca9a3..8378cf8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,7 @@ Other useful one-offs: - `cargo test -p buiy_core` — fast loop on the core crate. - `cargo run -p hello_button` — visual smoke test of the Phase 0 widget. - `cargo run -p hello_text` — visual smoke test of the text stack. +- `cargo run -p hello_bsn` — visual smoke test of the `bsn!` authoring path. - `cargo run -p capture` — regenerate the README screenshots headlessly (offscreen render-to-texture + GPU readback; needs a real wgpu adapter). - `BUIY_ACCEPT_SHAPING=1 cargo test -p buiy_core --test text_shaping_snapshots` — regenerate the `.snap` shaping snapshots (curated: review the diff before @@ -92,5 +93,6 @@ Other useful one-offs: - **Docs entry point:** `docs/README.md` is the master index of specs, plans, reports, and prior-art folders, grouped by area. Read it before adding any new doc or before searching for an existing one. The `organizing-buiy-docs` skill mirrors the conventions for on-demand loading. Cemented in `docs/specs/2026-05-07-docs-organization-design.md`. - **Prior-art workflow:** the `researching-prior-art` skill drives the 7-stage parallel-agent creation of a `docs/prior-art//` folder; the `using-prior-art` skill is the consumer-side flow that surfaces relevant folders during spec/plan/review work. - **Visual-bug verification (`buiy_verify`):** before adding/changing any visual, layout, paint-order, color, or render test — or adding a widget fixture, writing a reftest, or blessing a golden — use the `using-buiy-verification` skill (the task-oriented how-to: pick a tier, add a fixture, run the gates, gotchas). It mirrors the design spec `docs/specs/2026-06-15-buiy-verification-design/` and the crate root doc `crates/buiy_verify/src/lib.rs`. Rule of thumb: add a test at the **lowest tier that can observe the bug** (layout snapshot → display-list snapshot → invariant → reftest → golden); goldens are the last resort for the rasterization residue only. The GPU `--ignored` lane (Tiers 4–5) is additive and must pass on a GPU host; the headless gate must stay green without an adapter. +- **BSN authoring (`buiy_bsn`):** the thin `buiy_bsn` crate re-exports Bevy 0.19's `bsn!` / `bsn_list!` + spawn ext traits (no new syntax); it is reached via `buiy::bsn` and folded into `buiy::prelude`, so `use buiy::prelude::*;` brings `bsn!` into scope. Author the **decomposed components directly** (`bsn! { BoxModel { … } Background(…) }`) — `Style` is a `Bundle` builder, not a Component, so it is not `bsn!`-authorable. Widgets carry `#[require(...)]` contracts; **style them via the parameterized scene-fns in `buiy_widgets`** (`button("…")`, `text_input_*`, re-exported through `buiy::prelude`), never a single-field patch of a `#[require]`'d component (that drops the widget's other defaults — the § 4.1c suppression gotcha). Pin: `docs/specs/2026-06-18-buiy-bsn-integration-design.md`. _TODO: add language- and project-specific conventions (naming, error handling, testing, serialization, etc.) as they are established._ diff --git a/Cargo.toml b/Cargo.toml index 2a605f1..0057c32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,9 +19,11 @@ buiy_verify = { path = "crates/buiy_verify" } resolver = "2" members = [ "crates/buiy", + "crates/buiy_bsn", "crates/buiy_core", "crates/buiy_widgets", "crates/buiy_verify", + "examples/hello_bsn", "examples/hello_button", "examples/hello_text", "examples/capture", @@ -44,7 +46,7 @@ rust-version = "1.85" # 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"] } +bevy = { version = "0.19.0-rc.3", default-features = false, features = ["bevy_render", "bevy_core_pipeline", "bevy_winit", "bevy_window", "bevy_asset", "bevy_log", "bevy_picking", "bevy_scene", "x11", "wayland"] } taffy = "0.10" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -68,11 +70,12 @@ arboard = { version = "3.6", default-features = false } tracing = "0.1" # Phase 0 closeout deps (closeout plan 2026-05-08). bytemuck = { version = "1", features = ["derive"] } -accesskit = "0.21" -accesskit_winit = "0.29" +accesskit = "0.24" +accesskit_winit = "0.32" # guillotiere is only a *transitive* dep of bevy_image (not re-exported); -# pin it directly to the version bevy_image 0.18.1 resolves so a bevy patch -# bump cannot drop the atlas allocator. Spec atlas-and-text-seam.md § 2.1. +# pin it directly to the version bevy_image 0.19.0-rc.3 resolves (0.6.2, which +# satisfies bevy_image's "0.6.0" req) so a bevy patch bump cannot drop the atlas +# allocator. Spec atlas-and-text-seam.md § 2.1. guillotiere = "=0.6.2" smallvec = "1" # Tier-5 golden bless ledger (goldens.md § "The bless ledger"): `.toml` diff --git a/crates/buiy/Cargo.toml b/crates/buiy/Cargo.toml index 2326148..5fd696a 100644 --- a/crates/buiy/Cargo.toml +++ b/crates/buiy/Cargo.toml @@ -9,5 +9,6 @@ description = "A comprehensive UI library for Bevy with web-quality accessibilit [dependencies] bevy.workspace = true +buiy_bsn = { path = "../buiy_bsn" } buiy_core = { path = "../buiy_core" } buiy_widgets = { path = "../buiy_widgets" } diff --git a/crates/buiy/src/lib.rs b/crates/buiy/src/lib.rs index 07813f0..b0c4ec4 100644 --- a/crates/buiy/src/lib.rs +++ b/crates/buiy/src/lib.rs @@ -40,6 +40,31 @@ pub use buiy_core::{ theme::{Theme, UserPreferences, default_light_theme}, }; pub use buiy_widgets::{Button, OnPress, TextInput, WidgetsPlugin}; +// Widget BSN scene-fns (the mergeable styled-authoring path): `button(label)`, +// `text_input_single_line(placeholder)`, `text_input_multi_line(placeholder)`. +// They live in `buiy_widgets` (so they reuse the `#[require]` initializer fns — +// one source of truth) and surface here, where the widget + BSN surfaces +// converge, so `use buiy::prelude::*;` brings them in next to `bsn!`. (They are +// NOT re-exported through `buiy_bsn`, which stays widget-agnostic per spec +// § 4.2 — it must not take a `buiy_widgets` dependency.) +pub use buiy_widgets::scene::{button, text_input_multi_line, text_input_single_line}; + +// BSN authoring (docs/specs/2026-06-18-buiy-bsn-integration-design.md § 4.2). +// `buiy::bsn` is the named path to the authoring crate; the BSN prelude +// (`bsn!`, `bsn_list!`, the spawn extension traits) is folded into the +// `buiy` crate root so the existing `use buiy::*;` convention +// (`hello_button` / `hello_text`) brings `bsn!` into scope — and into the +// `buiy::prelude` module below for the explicit `use buiy::prelude::*;` form. +pub use buiy_bsn as bsn; +pub use buiy_bsn::prelude::*; + +/// The Buiy prelude. `use buiy::prelude::*;` brings the common Buiy surface — +/// components, plugins, widgets — and the BSN authoring macros (`bsn!`, +/// `bsn_list!`) + spawn extension traits into scope in one import. Mirrors the +/// flat crate-root re-export the examples use via `use buiy::*;`. +pub mod prelude { + pub use crate::*; +} // `buiy_core::render::ExtractedDraws` is intentionally NOT re-exported at // the crate root: it is a render-world resource only, populated during the diff --git a/crates/buiy/tests/plugin.rs b/crates/buiy/tests/plugin.rs index e59fa28..af3773f 100644 --- a/crates/buiy/tests/plugin.rs +++ b/crates/buiy/tests/plugin.rs @@ -1,6 +1,74 @@ use bevy::prelude::*; use buiy::BuiyPlugin; +/// The meta-crate BSN wiring (spec § 4.2): `use buiy::prelude::*;` must bring +/// the `bsn!` macro + the `spawn_scene` extension trait into scope, and a Buiy +/// component re-exported through `buiy` must author through them. Without this +/// wiring, `hello_bsn` (which depends only on `buiy`) could not reach `bsn!`. +#[test] +fn bsn_authoring_is_reachable_through_buiy_prelude() { + use buiy::prelude::*; + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(bevy::asset::AssetPlugin::default()) + .add_plugins(bevy::scene::ScenePlugin); + + // `Background`, `ColorToken`, `bsn!`, and `spawn_scene` all resolve through + // `buiy::prelude::*` — the single import a downstream user needs. + let id = app + .world_mut() + .spawn_scene(bsn! { + Background { color: { ColorToken::CurrentColor } } + }) + .expect("spawn_scene via buiy::prelude") + .id(); + + assert_eq!( + app.world().get::(id).expect("Background").color, + ColorToken::CurrentColor, + ); +} + +/// The widget scene-fns reach `hello_bsn` through `use buiy::prelude::*;` too, +/// and `button(label)` patches MERGE field-wise (the styled-authoring path): +/// patching `width` keeps the canonical padding. This is the form `hello_bsn` +/// uses to author styled widgets. +#[test] +fn widget_scene_fns_merge_through_buiy_prelude() { + use buiy::prelude::*; + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(bevy::asset::AssetPlugin::default()) + .add_plugins(bevy::scene::ScenePlugin) + .add_plugins(BuiyPlugin) + .add_plugins(bevy::input::InputPlugin); + + // `button` (the scene-fn), `bsn!`, `spawn_scene`, `BoxModel`, `Sizing`, + // `Length` all resolve through `buiy::prelude::*`. + let id = app + .world_mut() + .spawn_scene(bsn! { + button("Save") + BoxModel { width: { Sizing::Length(Length::Px(240.0)) } } + }) + .expect("spawn_scene") + .id(); + + let bm = app.world().get::(id).expect("BoxModel"); + assert_eq!(bm.width, Sizing::Length(Length::Px(240.0)), "width patched"); + assert_eq!( + bm.padding, + Edges::all(8.0), + "canonical padding merges through (scene-fn, not the suppression gotcha)" + ); + assert_eq!( + app.world().get::(id).expect("A11yLabel").0, + "Save" + ); +} + #[test] fn buiy_plugin_loads_in_correct_order() { let mut app = App::new(); @@ -64,6 +132,12 @@ fn facade_render_finish_registers_device_resources() { .add_plugins(bevy::input::InputPlugin) .add_plugins(BuiyPlugin); app.init_asset::(); + // Bevy 0.19: `CameraPlugin`'s `update_skinned_mesh_bounds` reads + // `Res>` (the second asset `MeshPlugin` + // inits alongside `Mesh`). A missing `Res` silently skipped under 0.18 but + // PANICS under 0.19's param validation. Real apps get it via `DefaultPlugins` + // → `MeshPlugin`; this hand-rolled stack must init it like `Mesh`. + app.init_asset::(); app.finish(); app.cleanup(); // The first frame is where the missing-resource param validation fired. diff --git a/crates/buiy_bsn/Cargo.toml b/crates/buiy_bsn/Cargo.toml new file mode 100644 index 0000000..f772682 --- /dev/null +++ b/crates/buiy_bsn/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "buiy_bsn" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "BSN (Bevy Scene Notation) authoring surface for Buiy: ergonomic re-exports of the bevy_scene `bsn!` macro + spawn extension traits." + +[dependencies] +# `bevy.workspace = true` inherits the workspace `bevy_scene` feature +# additively (resolver-2 union) — no per-crate feature edit needed. `bsn!`, +# `bsn_list!`, the `Scene`/`Template` machinery, and the spawn extension traits +# all ship inside the `bevy_scene` crate, reached here via `bevy::scene`. +bevy.workspace = true + +[dev-dependencies] +# The round-trip authorability tests author real Buiy components/widgets via +# `bsn!` and spawn them against a `World`, asserting the resulting components. +buiy_core = { path = "../buiy_core" } +buiy_widgets = { path = "../buiy_widgets" } diff --git a/crates/buiy_bsn/src/lib.rs b/crates/buiy_bsn/src/lib.rs new file mode 100644 index 0000000..1d810f9 --- /dev/null +++ b/crates/buiy_bsn/src/lib.rs @@ -0,0 +1,77 @@ +//! `buiy_bsn` — the BSN (Bevy Scene Notation) authoring surface for Buiy. +//! +//! BSN authoring shipped upstream in **Bevy 0.19** (PR #23413) inside the +//! `bevy_scene` crate: the [`bsn!`](bevy::scene::bsn) / +//! [`bsn_list!`](bevy::scene::bsn_list) macros and the +//! `Template` / `Scene` machinery. `bsn!` authoring is **compile-time and +//! reflection-free** — a component is authorable as soon as it is +//! `Component + Clone + Default` (the upstream blanket `Template` impl), which +//! every Buiy component already satisfies by construction. So this crate is +//! intentionally **thin**: it adds *no* new authoring syntax and does *not* +//! wrap or re-skin `bsn!`. The macro vocabulary is Bevy/Buiy component types +//! (Rust identifiers), per the dioxus prior-art lesson — "resist HTML +//! cosmetics; component types are Rust identifiers". +//! +//! Its sole job is **ergonomic re-exports**: it surfaces the BSN macros and +//! the spawn extension traits in a focused [`prelude`] so Buiy users reach +//! BSN authoring without taking a direct `bevy_scene` dependency or learning +//! Bevy's prelude layout. Everything here lives in `bevy::scene` (the +//! `bevy_scene` crate, enabled by the workspace `bevy_scene` feature). +//! +//! See: docs/specs/2026-06-18-buiy-bsn-integration-design.md § 4. +//! +//! # Authoring Buiy components in BSN +//! +//! ``` +//! # use bevy::prelude::*; +//! # use bevy::scene::ScenePlugin; +//! use buiy_bsn::prelude::*; +//! use buiy_core::render::components::Background; +//! use buiy_core::layout::BoxModel; +//! +//! # let mut app = App::new(); +//! # app.add_plugins((bevy::asset::AssetPlugin::default(), ScenePlugin)); +//! // Decomposed style components author directly — no `Style` builder in BSN. +//! let scene = bsn! { +//! Background { color: { buiy_core::render::color::ColorToken::CurrentColor } } +//! BoxModel { } +//! }; +//! app.world_mut().spawn_scene(scene).unwrap(); +//! ``` +//! +//! ## Why `ScenePlugin` is required +//! +//! [`WorldSceneExt::spawn_scene`](bevy::scene::WorldSceneExt::spawn_scene) +//! resolves the scene through the +//! `Assets` registry and reads the `AssetServer`, so a `World` +//! that spawns BSN scenes needs both `AssetPlugin` and `bevy::scene::ScenePlugin` +//! added. (Inline `bsn!` does not load any `.bsn` asset file — that loader is +//! deferred upstream — but the spawn path still routes through the asset +//! registry.) + +#![forbid(unsafe_code)] + +/// The Buiy BSN authoring prelude. +/// +/// Glob-import this (`use buiy_bsn::prelude::*;`) to bring the `bsn!` / +/// `bsn_list!` macros and the scene spawn extension traits into scope. These +/// re-export from `bevy::scene` (the `bevy_scene` crate); they are also folded +/// into `buiy::prelude`, so `use buiy::prelude::*;` brings them in too. +pub mod prelude { + /// The BSN authoring macros: [`bsn!`](bevy::scene::bsn) builds a `Scene`, + /// [`bsn_list!`](bevy::scene::bsn_list) builds a `SceneList`. + pub use bevy::scene::{bsn, bsn_list}; + + /// The scene spawn extension traits. `spawn_scene` / `apply_scene` live on + /// `World`, `Commands`, `EntityWorldMut`, and `EntityCommands` through + /// these traits. + pub use bevy::scene::{ + CommandsSceneExt, EntityCommandsSceneExt, EntityWorldMutSceneExt, WorldSceneExt, + }; + + /// The core scene types + the inline-observer / value helpers used inside + /// `bsn!` literals. `Scene`/`SceneList` are the values the macros expand + /// to; `on` attaches an entity observer; `template_value` inserts a + /// component value from an expression. + pub use bevy::scene::{Scene, SceneList, on, template_value}; +} diff --git a/crates/buiy_bsn/tests/round_trip.rs b/crates/buiy_bsn/tests/round_trip.rs new file mode 100644 index 0000000..563e892 --- /dev/null +++ b/crates/buiy_bsn/tests/round_trip.rs @@ -0,0 +1,223 @@ +//! BSN round-trip authorability — the headless "it works without a GPU" proof +//! (spec § 5). Authors real Buiy components/widgets with the **real** `bsn!` +//! macro (via `buiy_bsn::prelude`), spawns the scenes against a `World`, and +//! asserts the resulting entities carry the authored components with the +//! patched field values. This exercises the actual upstream `Template`/`Scene` +//! path — no mock. +//! +//! Required cases (spec § 5): +//! (a) bare plain-data components with field patches; +//! (b) a styled widget — the `#[require]` contract materialized AND the +//! patches applied (the load-bearing § 4.1a case); +//! (c) a `Children [ … ]`-nested subtree; +//! (d) a `#Name` entity ref. + +use bevy::MinimalPlugins; +use bevy::app::App; +use bevy::asset::AssetPlugin; +use bevy::ecs::hierarchy::Children; +use bevy::ecs::name::Name; +use bevy::scene::ScenePlugin; +// The crate under test: the BSN authoring surface. `bsn!` and `WorldSceneExt` +// (the `spawn_scene` extension) both resolve through this prelude — proving the +// `buiy_bsn` re-exports are sufficient to author Buiy components in BSN. +use buiy_bsn::prelude::*; +use buiy_core::a11y::A11yRole; +use buiy_core::components::Node; +use buiy_core::focus::Focusable; +use buiy_core::layout::{BoxModel, Display, Edges, FlexParams, Length, Overflow, Position, Sizing}; +use buiy_core::render::color::ColorToken; +use buiy_core::render::components::Background; +use buiy_widgets::{Button, TextInput, WidgetsPlugin}; +use std::borrow::Cow; + +/// A headless app with the BSN spawn machinery (`AssetPlugin` + `ScenePlugin` +/// back `spawn_scene`) plus the Buiy plugins that register the widget +/// required-components before first spawn (`CorePlugin` + `WidgetsPlugin` + +/// `BuiyTextPlugin`). No render plugin, no GPU. +fn bsn_test_app() -> App { + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(AssetPlugin::default()) + .add_plugins(ScenePlugin) + .add_plugins(buiy_core::CorePlugin) + // `TextInput`'s required `TextEditState`/`Text`/`FontSize` ride the + // text plugin's registrations; `WidgetsPlugin` registers the widget + // markers (and thus their required-components) before any spawn. + .add_plugins(buiy_core::text::BuiyTextPlugin::default()) + .add_plugins(WidgetsPlugin); + app +} + +/// (a) Bare plain-data components author and patch through the blanket +/// `Component + Clone + Default` template path — no `#[derive(FromTemplate)]`, +/// no reflection. +#[test] +fn case_a_plain_data_components_patch() { + let mut app = bsn_test_app(); + + let id = app + .world_mut() + .spawn_scene(bsn! { + Background { color: { ColorToken::Token(Cow::Borrowed("color.brand")) } } + BoxModel { + width: { Sizing::Length(Length::Px(64.0)) }, + padding: { Edges::all(4.0) }, + } + }) + .expect("spawn_scene") + .id(); + + let world = app.world(); + let bg = world.get::(id).expect("Background present"); + assert_eq!(bg.color, ColorToken::Token(Cow::Borrowed("color.brand"))); + + let bm = world.get::(id).expect("BoxModel present"); + // Patched fields applied… + assert_eq!(bm.width, Sizing::Length(Length::Px(64.0))); + assert_eq!(bm.padding, Edges::all(4.0)); + // …unspecified fields fall back to the component `Default`. + assert_eq!(bm.height, Sizing::default()); + assert_eq!(bm.margin, Edges::default()); +} + +/// (b) The load-bearing case (spec § 4.1a): a styled **widget**. Authoring the +/// bare `Button` marker materializes the full `#[require]` contract, and +/// explicit component patches layer over the required defaults. `Background` +/// is a named-field struct (not a tuple struct), so it patches as +/// `Background { color: … }`. +#[test] +fn case_b_styled_widget_require_plus_patches() { + let mut app = bsn_test_app(); + + let brand = ColorToken::Token(Cow::Borrowed("color.brand")); + let id = app + .world_mut() + .spawn_scene(bsn! { + Button + Background { color: { brand.clone() } } + BoxModel { width: { Sizing::Length(Length::Px(240.0)) } } + }) + .expect("spawn_scene") + .id(); + + let world = app.world(); + + // The `#[require]` contract materialized from the bare `Button` marker: + // the layout-visible Style decomposition + focus + a11y. + assert!(world.get::(id).is_some(), "Node (required)"); + assert!(world.get::(id).is_some(), "Display (required)"); + assert!(world.get::(id).is_some(), "Position (required)"); + assert!( + world.get::(id).is_some(), + "FlexParams (required)" + ); + assert!(world.get::(id).is_some(), "Overflow (required)"); + assert!(world.get::(id).is_some(), "Focusable (required)"); + assert_eq!( + world.get::(id).copied(), + Some(A11yRole::Button), + "A11yRole defaults to Button via #[require]" + ); + + // The explicit patches override the required defaults (required-components + // are a no-op when the component is explicitly inserted). + let bg = world.get::(id).expect("Background present"); + assert_eq!( + bg.color, brand, + "Background patch overrides the button default" + ); + let bm = world.get::(id).expect("BoxModel present"); + assert_eq!( + bm.width, + Sizing::Length(Length::Px(240.0)), + "BoxModel.width patch applied" + ); + // Authoring an explicit `BoxModel` patch *replaces* the widget's + // `#[require(BoxModel = button_box_model())]` initializer entirely: + // required-components only fill a *missing* component, and the patched + // `BoxModel` is present, so the require initializer is suppressed. A BSN + // patch therefore layers onto the **component `Default`** (padding 0), NOT + // onto the require initializer (which would have been 8px). This is + // upstream required-component semantics — author the full box when you + // patch it, or omit the patch to keep the widget's canonical box. + assert_eq!( + bm.padding, + Edges::default(), + "patching BoxModel suppresses the require initializer; unpatched fields are the component Default" + ); + assert_eq!(bm.padding, Edges::all(0.0)); +} + +/// (c) A `Children [ … ]`-nested subtree. The parens group multiple components +/// onto one child; nested children inherit the same authoring contract. +#[test] +fn case_c_children_nested_subtree() { + let mut app = bsn_test_app(); + + let id = app + .world_mut() + .spawn_scene(bsn! { + Node + Children [ + (Button BoxModel { width: { Sizing::Length(Length::Px(80.0)) } }), + (TextInput), + ] + }) + .expect("spawn_scene") + .id(); + + let world = app.world(); + let children = world.get::(id).expect("root has Children"); + assert_eq!(children.len(), 2, "two authored children"); + + // Child 0: a Button with the require contract + a width patch. + let c0 = children[0]; + assert!(world.get::