Skip to main content

lumen_scene/
spawn.rs

1//! `LayoutIR::spawn_into` walks the IR tree and spawns entities into the main world.
2//!
3//! Maps:
4//!
5//! * `width` / `height` / `flex` / `padding` / `margin` -> [`Style`].
6//! * `bg` + `radius` + `shadow` -> [`Visuals`].
7//! * `text` -> [`TextContent`]; color/size/align/wrap/max-lines -> [`TextStyle`].
8//! * `scroll` -> [`Scroll`] (with inline velocity) + [`ScrollOffset`].
9//! * `tab-index` -> [`TabIndex`].
10//! * Every spawned entity carries [`DirtyLayout`] so taffy runs on first tick.
11//! * Children link to parents via [`ChildOf`].
12
13use crate::computed::{ComputedStyle, StyleContext};
14use lumen_core::components::AuthoredStrings;
15use lumen_core::style_overrides::{Live, StyleOwner, StyleValues};
16
17use lumen_core::property_store::PropertyStore;
18use lumen_core::signals::signal_is_truthy;
19use lumen_ir::interpolate::{Scope, element_carries_placeholder, substitute_element};
20use lumen_ir::layout_ir::{Attributes, BindKind, Element, LayoutIR};
21use lumen_ir::translate::{TranslatedStrings, translate};
22
23/// `<if mode="...">` policy. `Render` despawn/respawns the subtree on
24/// each transition (default; cheap state-wise but loses focus / scroll
25/// / per-row signals across show/hide). `Hide` mounts once and toggles
26/// `Visible(bool)` on every descendant, preserving state.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
28pub enum IfMode {
29    /// Despawn the body on falsy -> truthy -> despawn each transition.
30    #[default]
31    Render,
32    /// Mount once; toggle `Visible` on falsy / truthy.
33    Hide,
34}
35
36impl From<lumen_ir::layout_ir::IfModeSpec> for IfMode {
37    fn from(s: lumen_ir::layout_ir::IfModeSpec) -> Self {
38        use lumen_ir::layout_ir::IfModeSpec;
39        match s {
40            IfModeSpec::Render => IfMode::Render,
41            IfModeSpec::Hide => IfMode::Hide,
42        }
43    }
44}
45
46/// Empty marker added alongside [`IfMarker`] for entities spawned from
47/// a `<dialog>` tag. Drives Esc-to-close + the focus trap
48/// (`lumen_core::components::FocusBoundary` is inserted alongside).
49/// The `IfMarker` carries the bound `open` signal name; this marker
50/// just distinguishes dialogs from generic `<if>` blocks.
51#[derive(bevy_ecs::component::Component, Clone, Copy, Debug, Default)]
52pub struct DialogMarker;
53
54/// The typography role an element was written with (`style="title-lg"`).
55/// Its text size is computed from it, and a restyle recomputes that from
56/// here; the attribute is the element's own, not a style a rule can set.
57#[derive(bevy_ecs::component::Component, Clone, Debug, PartialEq, Eq)]
58pub struct TypographyRole(pub std::sync::Arc<str>);
59
60/// `autofocus="true"` marker: when the containing `<dialog>` opens,
61/// [`manage_dialog_lifecycle`] moves focus here first.
62#[derive(bevy_ecs::component::Component, Clone, Copy, Debug, Default)]
63pub struct AutoFocus;
64
65/// `<button default="true">` marker: the dialog's DEFAULT button (Qt
66/// `QPushButton::setDefault`). Enter anywhere in the dialog activates
67/// it ([`activate_dialog_default_on_enter`]), and a close that went
68/// through it fires the `accepted` hook instead of `rejected`.
69#[derive(bevy_ecs::component::Component, Clone, Copy, Debug, Default)]
70pub struct DefaultButton;
71
72/// Per-dialog open/close bookkeeping maintained by
73/// [`manage_dialog_lifecycle`] - mirrors
74/// `lumen_primitives::PopupNavSession` for popups.
75#[derive(bevy_ecs::component::Component, Clone, Debug, Default)]
76pub struct DialogSession {
77    /// Open state last observed (edge detector).
78    pub open: bool,
79    /// Initial focus still pending: the dialog body mounts via
80    /// deferred commands one tick after the open edge, so the focus
81    /// pass retries until a target exists.
82    pub needs_focus: bool,
83    /// Focus holder at open time, restored on close (Qt/web modal
84    /// focus-restore contract).
85    pub prev_focus: Option<Entity>,
86    /// Whether the pre-open holder carried `FocusVisible`.
87    pub prev_focus_visible: bool,
88    /// The default button was activated during this open cycle -
89    /// resolves the close as `accepted` (exactly one of
90    /// accepted/rejected fires, on the close edge).
91    pub pending_accept: bool,
92}
93
94/// Marker for an `<if signal="...">` block. The reconciler mounts /
95/// dismounts the subtree based on the named signal's truthiness, or -
96/// when [`Self::eq`] is `Some`- on value equality.
97///
98/// A hidden [`IfMode::Hide`] block holds `display: none` in
99/// [`StyleOverrides`](lumen_core::style_overrides::StyleOverrides) (spec
100/// section 17.4: space is released and the relayout comes free via
101/// `Changed<Style>`), and lets go when it shows, so it shows with the
102/// display its styles give it then.
103#[derive(bevy_ecs::component::Component, Clone, Debug)]
104#[require(lumen_core::style_overrides::StyleOverrides)]
105pub struct IfMarker {
106    /// Signal name to evaluate.
107    pub signal_name: String,
108    /// Markup children kept around as the body template - spawned when
109    /// truthy, despawned when not.
110    pub body: Vec<Element>,
111    /// Last-known mount state so we can detect transitions cheaply.
112    pub currently_mounted: bool,
113    /// How to handle the transition (despawn vs hide).
114    pub mode: IfMode,
115    /// When `Some(expected)`, mounts only when `Signals[signal_name] == expected`.
116    /// When `None`, mounts when `Signals[signal_name]` is non-empty and not literal `"false"`/`"0"`.
117    pub eq: Option<String>,
118    /// [`IfMode::Hide`] only: the visibility state last applied to the
119    /// entity (`None` = never applied). The reconciler only touches
120    /// `Style` / `Visible` on a transition - re-inserting every tick
121    /// kept `FrameDirty` permanently set (D5).
122    pub applied_visible: Option<bool>,
123}
124
125impl StyleOwner for IfMarker {
126    const RANK: u8 = 20;
127}
128
129impl StyleOwner for ForMarker {
130    const RANK: u8 = 20;
131}
132
133/// Marker for a `<for each="...">` block. The reconciler reads the referenced [`ArraySignals`] entry each tick,
134/// expands `body` once per item (substituting `{field}` placeholders against the item record), and spawns/despawns child entities to match.
135/// The reconciler compares the key sequence it built last tick with the new one: an unchanged sequence mounts
136/// nothing, an appended prefix spawns only the new rows, a truncated prefix despawns only the trailing rows, and
137/// anything else (reorder, mid-insert, mid-remove) is a full despawn-and-respawn. A row whose key is kept but whose
138/// record changed is updated in place, on the entities it already has. `key_field` names the record field the keys
139/// come from; without it the item index is the key.
140///
141/// A virtualized block holds its width and height in
142/// [`StyleOverrides`](lumen_core::style_overrides::StyleOverrides), sized
143/// to every row it could show.
144#[derive(bevy_ecs::component::Component, Clone, Debug)]
145#[require(lumen_core::style_overrides::StyleOverrides)]
146pub struct ForMarker {
147    /// Name of the `ArraySignals` entry to iterate.
148    pub array_name: String,
149    /// Template body - the markup children of the `<for>` element.
150    pub body: Vec<Element>,
151    /// Stable-id field within each record (e.g. `"id"`). Optional; without
152    /// it the reconciler keys rows by item index.
153    pub key_field: Option<String>,
154    /// Cache of the keys (or item indices when `key_field` is absent)
155    /// currently materialized as children, so the reconciler can detect
156    /// no-op frames without re-walking the body template.
157    pub cached_keys: Vec<String>,
158    /// `<for virtualized="true">` opt-in. When set, and when the scene policy
159    /// leaves the windowing to the reconciler, it spawns only rows in the
160    /// visible scroll window plus a small buffer; each row is
161    /// absolute-positioned via inline `inset` from `row_height * row_index`.
162    pub virtualized: bool,
163    /// Per-row pixel height used by the virtualization windowing math.
164    /// Required when `virtualized = true`. Default 32 px when authors
165    /// turn on `virtualized` without specifying `row-height`.
166    pub row_height: f32,
167    /// Virtualized only: the `(row_index, key)` pairs currently mounted,
168    /// in child-list order (each entry owns `body.len()` consecutive
169    /// children). Spec section 15.3 windowed reuse - a window shift keeps every
170    /// row whose `(index, key)` is unchanged instead of respawning the
171    /// whole band.
172    pub win_rows: Vec<(usize, String)>,
173    /// Virtualized only: the body template with the CSS cascade already
174    /// applied (cascade-once-per-template). `None` until first use and
175    /// after every stylesheet change; rebuilt lazily by the reconciler.
176    /// Skipped (per-row cascade instead) when the template's `id` /
177    /// `class` attrs contain `{...}` placeholders, because then selector
178    /// matching depends on per-row substitution results.
179    pub cascaded_body: Option<std::sync::Arc<Vec<Element>>>,
180    /// The record each mounted row was built from, in the order of
181    /// [`Self::cached_keys`] (or of [`Self::win_rows`] when virtualized).
182    /// A kept row whose record no longer matches its entry here has its
183    /// placeholders resolved again and written onto the entities it
184    /// already has.
185    pub row_items: Vec<ArrayItem>,
186}
187
188/// Who mounts the rows of a virtualized `<for>`.
189#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
190pub enum Virtualization {
191    /// The reconciler windows the rows itself: it reads the nearest
192    /// `<scroll>` ancestor, mounts the visible band plus an overscan buffer,
193    /// and absolute-positions each row. What a desktop app does.
194    #[default]
195    Enabled,
196    /// The presentation layer already windows long lists, so the reconciler
197    /// mounts every row and leaves `virtualized="true"` to it.
198    HostManaged,
199}
200
201/// Who resolves the styles of a `<for>` row.
202#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
203pub enum RowStyle {
204    /// The reconciler runs the CSS cascade over each row it builds, so the
205    /// spawned entities carry resolved values. What a desktop app does.
206    #[default]
207    Cascade,
208    /// The presentation layer has its own cascade over the same stylesheet,
209    /// so the reconciler leaves row markup unresolved rather than doing the
210    /// work twice.
211    HostStyled,
212}
213
214/// What the presentation layer under this scene already does for itself, so
215/// [`reconcile_for_blocks`] can stop doing it.
216///
217/// The defaults are what a desktop app needs, and a world with no policy
218/// resource behaves as the defaults do. A host that brings its own windowing
219/// or its own cascade inserts a policy saying so.
220#[derive(bevy_ecs::resource::Resource, Clone, Copy, Debug, Default, PartialEq, Eq)]
221pub struct ScenePolicy {
222    /// Who mounts the rows of a virtualized `<for>`.
223    pub virtualization: Virtualization,
224    /// Who resolves the styles of a `<for>` row.
225    pub row_style: RowStyle,
226}
227
228use bevy_ecs::prelude::*;
229use lumen_core::prelude::*;
230use lumen_core::signals::ArrayItem;
231
232// The typography-role -> px table moved to `lumen-ir` (it backs the
233// `Attributes -> TextStyle` conversion, which the orphan rule forced into
234// `lumen-ir`). Re-export it so `crate::spawn::typography_role_to_px` - used
235// by `run::restyle` - keeps resolving.
236pub use lumen_ir::typography_role_to_px;
237
238/// Spawn an IR tree into a main world. Defined as a local extension trait
239/// because [`LayoutIR`] now lives in `lumen-ir` and an inherent `impl` on a
240/// foreign type isn't allowed; the spawn logic (which drives `lumenc`'s ECS
241/// walker) stays here.
242pub trait SpawnIntoWorld {
243    /// Spawn the IR tree into the given main world. Returns the root entity.
244    fn spawn_into(&self, world: &mut World) -> Entity;
245}
246
247impl SpawnIntoWorld for LayoutIR {
248    fn spawn_into(&self, world: &mut World) -> Entity {
249        if let Some(sheet) = self.combined_stylesheet.clone() {
250            world.insert_resource(LumenStylesheet(sheet));
251        }
252        let root = spawn_subtree(world, &self.root, None, Placeholders::Unresolved);
253        install_document_root(world, root);
254        root
255    }
256}
257
258/// The entity the app's markup tree was spawned onto. Whoever writes the
259/// root - `set_root_class`, the OS theme follow, the class-change
260/// watcher - asks here rather than guessing from the hierarchy, so there
261/// is one answer and it does not depend on the root happening to carry a
262/// class or on hot reload being on. A respawn (hot reload) overwrites it.
263#[derive(Resource, Debug, Clone, Copy)]
264pub struct DocumentRoot(pub Entity);
265
266/// Record `root` as the [`DocumentRoot`].
267///
268/// Every assembly records it, because everything that writes the root -
269/// `set_root_class`, the desktop's class-change watcher - asks here rather
270/// than guessing from the hierarchy. It used to be recorded by the desktop
271/// alone, which is why `set_root_class` reached nothing in a page or a
272/// server render.
273pub fn install_document_root(world: &mut World, root: Entity) {
274    world.insert_resource(DocumentRoot(root));
275}
276
277/// A compiled app spawns its tree and the fragments that tree names.
278///
279/// The tree alone is not the whole app: a `<template>` body and an `lmn!`
280/// block travel beside it in the artifact, and a script instantiates one by
281/// key while the app runs. Taking both from the artifact in one call is what
282/// keeps a page, a server render and a window building the same subtree,
283/// rather than each remembering to install the table on its own.
284impl SpawnIntoWorld for lumen_ir::artifact::CompiledApp {
285    fn spawn_into(&self, world: &mut World) -> Entity {
286        crate::fragments::install(world, self.fragments.clone());
287        self.ir.spawn_into(world)
288    }
289}
290
291/// Whether a subtree still carries `{...}` placeholders for the spawner to
292/// resolve.
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum Placeholders {
295    /// The tree is as the markup wrote it, so a `{name}` in it names a global
296    /// signal and is resolved against the app's current state on the way in.
297    Unresolved,
298    /// Something already walked this tree with the scope that owns it: a
299    /// `<for>` row, a fragment instantiation, or the tree an `<if>` body
300    /// arrived inside. What braces are left in it are text, and a second pass
301    /// would read a value that arrived as data as markup.
302    Resolved,
303}
304
305/// Spawn an [`Element`] subtree under `parent` (or as a new root when
306/// `None`) WITHOUT touching the global [`LumenStylesheet`] resource.
307///
308/// Used by the dev-only devtools overlay mount: it applies its own isolated
309/// stylesheet to the overlay IR before spawning, so it must not clobber the
310/// app's `<for>`-reconciler stylesheet the way [`LayoutIR::spawn_into`] does.
311pub fn spawn_subtree(
312    world: &mut World,
313    el: &Element,
314    parent: Option<Entity>,
315    placeholders: Placeholders,
316) -> Entity {
317    match with_globals_resolved(world, el, placeholders) {
318        Some(resolved) => spawn_element(world, &resolved, parent),
319        None => spawn_element(world, el, parent),
320    }
321}
322
323/// The subtree with every `{name}` its globals answer resolved, or `None`
324/// when there was nothing to resolve.
325///
326/// A global placeholder is read once, when the element is built. That is the
327/// same moment the web emitter reads it while writing the page, which is what
328/// makes a browser's first pass over a rendered document find the text it was
329/// going to write already there. A value that changes afterwards wants
330/// `bind-text`, which is a binding rather than a string built once.
331fn with_globals_resolved(
332    world: &World,
333    el: &Element,
334    placeholders: Placeholders,
335) -> Option<Element> {
336    if placeholders == Placeholders::Resolved || !tree_carries_placeholder(el) {
337        return None;
338    }
339    let store = world.get_resource::<PropertyStore>()?;
340    Some(substitute_element(el, &Scope::new(store)))
341}
342
343/// Whether anything in the subtree carries a placeholder, so a tree with none
344/// is spawned from the IR itself rather than from a copy of it.
345fn tree_carries_placeholder(el: &Element) -> bool {
346    element_carries_placeholder(el) || el.children.iter().any(tree_carries_placeholder)
347}
348
349/// World-resource wrapper for the combined skin + user CSS so the
350/// for-block reconciler can re-apply matching rules to runtime-
351/// substituted template clones.
352#[derive(Resource, Clone)]
353pub struct LumenStylesheet(pub lumen_ir::css::Stylesheet);
354
355/// Backing counter for [`lumen_core::components::DocumentOrder`]. One per
356/// world; `next_document_order` bumps it once per spawned element.
357#[derive(Resource, Default)]
358struct DocumentOrderCounter(u32);
359
360/// Next value for [`lumen_core::components::DocumentOrder`], assigned in
361/// the exact sequence `spawn_element` visits the parsed tree (depth-first,
362/// parent before children, siblings in markup order). Must run before the
363/// entity's `world.spawn(..)` call - `EntityWorldMut` holds `world`
364/// exclusively for its lifetime, so the resource bump can't be interleaved
365/// once that borrow starts.
366///
367/// This is the seed only. A subtree that mounts later - an `<if>` body,
368/// a `<for>` row, a node a script appends - continues the same counter,
369/// which places it after everything already in the world rather than
370/// where it sits in the tree. [`renumber_document_order`] restates the
371/// value from the live hierarchy once the shape settles.
372fn next_document_order(world: &mut World) -> u32 {
373    let mut counter = world.get_resource_or_insert_with(DocumentOrderCounter::default);
374    let n = counter.0;
375    counter.0 = counter.0.wrapping_add(1);
376    n
377}
378
379/// Restate [`lumen_core::components::DocumentOrder`] from the live
380/// hierarchy, so it means "where this element sits in the document"
381/// rather than "when it was spawned".
382///
383/// The spawn counter is only markup order for the tree the build walked.
384/// Anything that mounts afterwards is numbered at mount time, which puts
385/// it behind every element already in the world: a button inside an
386/// `<if>` was thrown past the whole list beside it, and the longer that
387/// list, the further Tab jumped over the button. Short lists hid the
388/// defect, because a couple of stops out of place still looks right.
389///
390/// The walk is depth-first, parent before children, siblings in
391/// `Children` order - which is the order they were attached, so it is
392/// markup order for the initial tree and mount order within a
393/// reconciler's own body. Roots are visited in their existing order, so
394/// a second root (the error banner, the devtools overlay) keeps its
395/// place. Only entities whose number actually moves are written, so a
396/// steady tree costs one query and nothing else.
397pub fn renumber_document_order(
398    mut commands: bevy_ecs::system::Commands,
399    reshaped: bevy_ecs::system::Query<(), Changed<bevy_ecs::hierarchy::Children>>,
400    roots: bevy_ecs::system::Query<
401        (Entity, &lumen_core::components::DocumentOrder),
402        Without<bevy_ecs::hierarchy::ChildOf>,
403    >,
404    children: bevy_ecs::system::Query<&bevy_ecs::hierarchy::Children>,
405    orders: bevy_ecs::system::Query<&lumen_core::components::DocumentOrder>,
406) {
407    // Nothing gained or lost a child this tick, so every relative
408    // position still holds.
409    if reshaped.is_empty() {
410        return;
411    }
412    let mut top: Vec<(u32, Entity)> = roots.iter().map(|(e, d)| (d.0, e)).collect();
413    top.sort_unstable();
414    // Depth-first with an explicit stack: an app can nest deeper than a
415    // recursive walk is comfortable with.
416    let mut stack: Vec<Entity> = top.into_iter().rev().map(|(_, e)| e).collect();
417    let mut n: u32 = 0;
418    while let Some(e) = stack.pop() {
419        if orders.get(e).ok().map(|d| d.0) != Some(n) {
420            // `try_insert`, because a later system in the same stage can
421            // despawn an entity this walk just numbered - a script
422            // handler rebuilding a list does exactly that - and the
423            // write lands after it. Losing the number is the right
424            // outcome there: the entity is gone, and the despawn
425            // reshapes the tree, so the next tick walks it again.
426            commands
427                .entity(e)
428                .try_insert(lumen_core::components::DocumentOrder(n));
429        }
430        n = n.wrapping_add(1);
431        if let Ok(kids) = children.get(e) {
432            for kid in kids.iter().rev() {
433                stack.push(kid);
434            }
435        }
436    }
437}
438
439/// The strings an element shows in the app's locale.
440///
441/// A `translatable="key"` element reads each of them off the catalogue
442/// message that key names, which is [`lumen_ir::translate::translate`]'s
443/// rule, shared with the web emitter so a page built for a locale reads
444/// like the app run in it. The spawner uses three of the four it returns;
445/// nothing on the desktop reads `alt`.
446///
447/// A `format="<spec>"` then renders whatever text is left for the app's
448/// locale. Both the catalogue and the formatter come off
449/// [`lumen_core::i18n::AppI18n`], the app's own half of the seam, rather
450/// than the process-wide hooks the script hosts use: a process running two
451/// Lumen apps must not render one app's markup in the other's locale. Text
452/// the spec cannot read stands as it is, so a translated sentence under a
453/// `number` format is still the sentence.
454///
455/// The spawner calls this once per element, and
456/// [`crate::i18n::retranslate_on_locale_change`] calls it again for every
457/// element that kept its [`AuthoredStrings`] when the app changes locale.
458/// One rule, asked twice.
459pub fn resolve_strings(
460    i18n: Option<&lumen_core::i18n::AppI18n>,
461    authored: &AuthoredStrings,
462    format: Option<&str>,
463) -> TranslatedStrings {
464    let mut strings = translate(authored, &|key| {
465        i18n.and_then(|i18n| i18n.try_translate(key))
466    });
467    let (Some(text), Some(spec)) = (&strings.text, format) else {
468        return strings;
469    };
470    if let Some(formatted) = i18n.and_then(|i18n| i18n.format(spec, text)) {
471        strings.text = Some(formatted);
472    }
473    strings
474}
475
476/// Alpha multiplier applied to a disabled entity when neither
477/// `disabled-bg` nor an explicit `:disabled { opacity }` was authored.
478/// CSS `disabled-opacity` (`Attributes::disabled_opacity_default`)
479/// overrides it; this is the single Rust-side fallback.
480pub(crate) const DISABLED_OPACITY_DEFAULT: f32 = 0.5;
481
482/// Read a `<toggle>` / `<switch>` track palette out of the cascaded
483/// attributes: the resting `bg` while unchecked, `:checked { bg }` while
484/// checked, `:disabled { bg }` while disabled.
485///
486/// `base` supplies each slot the cascade did not set: the built-in
487/// tokens at spawn, so a control is readable with no CSS at all, and the
488/// live palette on a restyle, so a theme flip that touches only one
489/// state leaves the others where markup put them.
490pub fn track_style_over(
491    base: lumen_primitives::TrackStyle,
492    attrs: &Attributes,
493) -> lumen_primitives::TrackStyle {
494    lumen_primitives::TrackStyle {
495        checked_bg: attrs.checked_bg.map(Into::into).unwrap_or(base.checked_bg),
496        unchecked_bg: attrs
497            .bg
498            .as_ref()
499            .and_then(|b| Fill::try_from(b).ok()?.as_solid())
500            .unwrap_or(base.unchecked_bg),
501        disabled_bg: attrs.disabled_bg.map(Into::into).or(base.disabled_bg),
502    }
503}
504
505/// Points an element's background at the image its `bg` names, or takes a
506/// background image away when `bg` names a colour or a gradient instead.
507///
508/// The image travels the same road as an `<image src>`: an
509/// [`lumen_assets::ImageSource`] the asset pipeline resolves, decodes and
510/// caches, marked [`lumen_assets::BackgroundImage`] so it paints behind
511/// the content and never sizes the element. An `<image>` keeps its `src` as
512/// its one source, so a `bg` image on one is ignored.
513///
514/// `attrs` is the element's whole resolved style: with no `bg` left, an
515/// image the element had is taken away, and with no `bg-fit` the image
516/// covers the box.
517pub fn apply_background_image(
518    ent: &mut bevy_ecs::world::EntityWorldMut<'_>,
519    tag: &str,
520    attrs: &Attributes,
521) {
522    use lumen_assets::{
523        AssetServer, BackgroundImage, Enqueued, ImageLoadFailed, ImageSource, LoadedImage,
524        LoadedSvg,
525    };
526    use lumen_ir::layout_ir::BgSpec;
527    if tag == "image" {
528        return;
529    }
530    let current = ent.get::<BackgroundImage>().copied();
531    match &attrs.bg {
532        Some(BgSpec::Image(path)) => {
533            let source = lumen_assets::resolve_source_path(path);
534            if current.is_none() || ent.get::<ImageSource>().is_none_or(|s| s.0 != source) {
535                // A new source starts from nothing: the old bitmap goes, and
536                // a decode still out for the old path is made stale so it
537                // cannot land on top of the new one.
538                ent.remove::<(LoadedImage, LoadedSvg, ImageLoadFailed, Enqueued)>();
539                let id = ent.id();
540                ent.world_scope(|world| {
541                    if let Some(mut server) = world.get_resource_mut::<AssetServer>() {
542                        server.bump_request_id(id);
543                    }
544                });
545                ent.insert(ImageSource(source));
546            }
547            let fit = attrs.bg_fit.map(ImageFit::from).unwrap_or(ImageFit::Cover);
548            if current != Some(BackgroundImage { fit }) {
549                ent.insert(BackgroundImage { fit });
550            }
551        }
552        Some(_) | None => {
553            if current.is_some() {
554                ent.remove::<(
555                    BackgroundImage,
556                    ImageSource,
557                    LoadedImage,
558                    LoadedSvg,
559                    ImageLoadFailed,
560                    Enqueued,
561                )>();
562            }
563        }
564    }
565}
566
567/// The in-app drag source a `drag-payload` attribute publishes. An empty
568/// payload names the element by its `id`.
569fn drag_source(attrs: &Attributes) -> Option<lumen_os_dnd::DragSource> {
570    let payload = attrs.drag_payload.as_ref()?;
571    let text = if payload.is_empty() {
572        attrs.id.clone().unwrap_or_default()
573    } else {
574        payload.clone()
575    };
576    Some(lumen_os_dnd::DragSource::new(text).with_effects(lumen_os_dnd::DropEffectSet::ANY))
577}
578
579fn spawn_element(world: &mut World, el: &Element, parent: Option<Entity>) -> Entity {
580    // Seed any synthetic signal defaults (currently only the
581    // `<tabs>` parser pass authors this - picks the first `<tab>` as
582    // the default active when no script has written the signal yet).
583    if let Some((name, default_value)) = &el.attrs.signal_seed
584        && let Some(mut store) = world.get_resource_mut::<PropertyStore>()
585        && store.get_global_str(name).is_none()
586    {
587        store.set_global_str(name, default_value.as_str());
588    }
589    // Two-way binds with an AUTHORED initial attr (`checked="true"`,
590    // `value="42"`, `text="..."`) seed the signal if nothing wrote it yet,
591    // so a sibling `bind-text` label shows the initial value without a
592    // script. Seed-if-absent only: a script's `signal(name, default)`
593    // publish or an earlier write wins, and the spawn-tick `Changed`
594    // detection on the widget component is deliberately NOT pushed
595    // (`push_*_to_signal` skips `is_added()` rows) - the widget's
596    // component default is not an authored value.
597    // The `<dropdown>` header button is excluded here: its `text` is the
598    // authored `placeholder`, not an initial value for the bound signal,
599    // and the dropdown pass already seeds the signal itself (above, via
600    // `signal_seed`, only when there's no placeholder). Letting this
601    // generic path run too would seed the value signal with the
602    // placeholder string.
603    if let Some(spec) = &el.attrs.bind
604        && el.attrs.dropdown_button.is_none()
605    {
606        let authored: Option<String> = match spec.kind {
607            BindKind::Checked => el
608                .attrs
609                .checked
610                .map(|b| if b { "true" } else { "false" }.to_string()),
611            BindKind::Value => el.attrs.value.map(|v| format!("{v}")),
612            BindKind::Text => el.attrs.text.clone(),
613        };
614        if let Some(v) = authored
615            && let Some(mut store) = world.get_resource_mut::<PropertyStore>()
616            && store.get_global_str(&spec.name).is_none()
617        {
618            store.set_global_str(&spec.name, v.as_str());
619        }
620    }
621    // Resolve the element's strings before the world is borrowed by `spawn`:
622    // a `translatable="key"` element reads the loaded catalogue.
623    let authored = AuthoredStrings::from(&el.attrs);
624    let strings = resolve_strings(
625        world.get_resource::<lumen_core::i18n::AppI18n>(),
626        &authored,
627        el.attrs.format.as_deref(),
628    );
629    let text = strings.text.clone();
630    // Every style component, from the one computation a restyle runs too.
631    // The parent is spawned before its children, so its `Scroll` marker is
632    // already in place.
633    let in_scroll = parent.is_some_and(|p| world.get::<Scroll>(p).is_some());
634    let computed = ComputedStyle::new(
635        &StyleContext::of_attrs(&el.tag, &el.attrs, in_scroll),
636        &el.attrs,
637    );
638    let knob_geometry = computed.knob_geometry.unwrap_or_default();
639    let knob_fill = computed.knob_fill;
640    let doc_order = next_document_order(world);
641    let mut entity = world.spawn((
642        DirtyLayout,
643        lumen_core::components::DocumentOrder(doc_order),
644    ));
645    if let Some(p) = parent {
646        entity.insert(ChildOf(p));
647    }
648    computed.insert(&mut entity);
649    // The typography role an element was written with (`style="title-lg"`)
650    // is part of what its text style is computed from, so a restyle reads
651    // it back.
652    if let Some(role) = el.attrs.style_role.as_deref() {
653        entity.insert(TypographyRole(role.into()));
654    }
655    // A use site the build could not finish: the script fills it by calling
656    // the function this names. Arguments arrive in parameter order, laid out
657    // when the tree was linked.
658    if let Some(use_site) = &el.frag_use {
659        entity.insert(lumen_core::components::PendingFill {
660            function: use_site.key.clone(),
661            args: use_site
662                .args
663                .iter()
664                .map(|(_, value)| value.clone())
665                .collect(),
666        });
667    }
668    if let Some(text) = &text {
669        entity.insert(TextContent(text.clone()));
670    }
671    if matches!(el.tag.as_str(), "input" | "textarea") {
672        let default_multiline = el.tag == "textarea";
673        entity.insert(TextInput {
674            placeholder: strings.placeholder.clone().unwrap_or_default(),
675            cursor: 0,
676            selection_anchor: None,
677            multiline: el.attrs.multiline.unwrap_or(default_multiline),
678        });
679    }
680    if matches!(el.tag.as_str(), "toggle" | "switch" | "checkbox") {
681        entity.insert(Toggleable {
682            checked: el.attrs.checked.unwrap_or(false),
683        });
684    }
685    if el.tag == "switch" {
686        // Explicit a11y role: a switch is announced as `Role::Switch`, not
687        // the `Role::CheckBox` that a bare `Toggleable` would derive.
688        entity.insert(lumen_core::components::A11yRole::Switch);
689    }
690    if el.tag == "slider" {
691        entity.insert(SliderValue::from(&el.attrs));
692    }
693    if el.tag == "checkbox" && el.attrs.indeterminate {
694        entity.insert(lumen_primitives::Indeterminate);
695    }
696    if el.tag == "radio"
697        && let (Some(group), Some(value)) = (&el.attrs.radio_group, &el.attrs.radio_value)
698    {
699        entity.insert(lumen_primitives::RadioButton {
700            group: group.clone(),
701            value: value.clone(),
702        });
703    }
704    if el.tag == "progress" {
705        // No `value` and no binding = indeterminate; a later `bind-value`
706        // pull flips it determinate.
707        let bar = lumen_primitives::ProgressBar::from(&el.attrs);
708        // The accessibility walk reads `A11yValue`, not `ProgressBar`;
709        // `apply_progress_bindings` keeps the two in step.
710        entity.insert(lumen_core::components::A11yValue {
711            now: f64::from(bar.value.unwrap_or(0.0)),
712            min: 0.0,
713            max: f64::from(bar.max),
714            step: 0.0,
715            text: None,
716        });
717        entity.insert(bar);
718    }
719    // Synthetic widget-part markers from the parser desugars.
720    if let Some(part) = el.attrs.part {
721        match part {
722            lumen_ir::layout_ir::WidgetPart::CheckboxBox => {
723                entity.insert(lumen_primitives::CheckboxBox);
724            }
725            lumen_ir::layout_ir::WidgetPart::RadioDot => {
726                entity.insert(lumen_primitives::RadioDot);
727            }
728            lumen_ir::layout_ir::WidgetPart::ProgressFill => {
729                entity.insert(lumen_primitives::ProgressFill);
730            }
731        }
732    }
733    // Attach Validation only when validation-specific attrs are set.
734    // `<slider min max>` already enforces bounds in SliderValue itself -
735    // pulling min/max into Validation for sliders would just duplicate
736    // the clamp. So sliders need a `required` or `pattern` opt-in.
737    // `<progress min/max>` bounds its own value in `ProgressBar` -
738    // same exemption as `<slider>`.
739    let validate_min_max = !matches!(el.tag.as_str(), "slider" | "progress")
740        && (el.attrs.min.is_some() || el.attrs.max.is_some());
741    if el.attrs.required || el.attrs.pattern.is_some() || validate_min_max {
742        entity.insert(lumen_core::components::Validation {
743            required: el.attrs.required,
744            pattern: el.attrs.pattern.clone(),
745            min: if validate_min_max { el.attrs.min } else { None },
746            max: if validate_min_max { el.attrs.max } else { None },
747            is_valid: true,
748        });
749    }
750    if el.attrs.drop_target {
751        entity.insert(DropTarget);
752        // In-app DnD accept filter (mirrors Qt `dragEnterEvent` +
753        // `acceptProposedAction`). No `accept="..."` => accept any payload,
754        // matching the file-drop path's "accept anything" default.
755        let accept = match &el.attrs.drop_accept {
756            Some(mime) => {
757                lumen_os_dnd::DropAccept::only([lumen_os_dnd::mime::MimeKind::from(mime.as_str())])
758            }
759            None => lumen_os_dnd::DropAccept::any(),
760        }
761        .with_effects(lumen_os_dnd::DropEffectSet::ANY);
762        entity.insert(accept);
763    }
764    // In-app DnD source: `drag-payload="..."` publishes a payload; an empty
765    // value derives it from the element `id`. Mirrors HTML5
766    // `dataTransfer.setData` / Qt `QMimeData`. Independent of `draggable`
767    // (which stays the "physically translate on drag" opt-in).
768    if let Some(source) = drag_source(&el.attrs) {
769        entity.insert(source);
770    }
771    if el.attrs.title_bar_drag {
772        entity.insert(lumen_core::components::TitleBarDraggable);
773    }
774    if let Some(spec) = &el.attrs.tooltip {
775        // `None` here means neither the inline attr nor a skin token
776        // supplied a value - the runtime defaults (500 ms / 12 px) are
777        // the single Rust-side fallback.
778        let defaults = lumen_primitives::TooltipSource::default();
779        entity.insert(lumen_primitives::TooltipSource {
780            text: strings.tooltip.clone().unwrap_or_default(),
781            delay_ms: spec.delay_ms.unwrap_or(defaults.delay_ms),
782            offset: spec.offset.unwrap_or(defaults.offset),
783        });
784    }
785    if let Some((signal_name, value)) = &el.attrs.tab_strip {
786        entity.insert(lumen_primitives::TabStripButton {
787            signal_name: signal_name.clone(),
788            value: value.clone(),
789        });
790    }
791    if let Some(spec) = &el.attrs.dropdown_button {
792        entity.insert(lumen_primitives::DropdownButton {
793            open_signal: spec.open_signal.clone(),
794            value_signal: spec.value_signal.clone(),
795            options: spec
796                .options
797                .iter()
798                .map(
799                    |(value, label, disabled)| lumen_primitives::DropdownOptionSpec {
800                        value: value.clone(),
801                        label: label.clone(),
802                        disabled: *disabled,
803                    },
804                )
805                .collect(),
806        });
807        // The header's text binding names the value signal, and the
808        // closed combobox shows the selected option's label rather than
809        // the value that signal holds. `apply_text_bindings` reads the
810        // mapping off this component as it writes, so the header never
811        // shows the raw value even for a tick.
812        entity.insert(lumen_core::components::BindTextLabels(
813            spec.options
814                .iter()
815                .map(|(value, label, _)| (value.clone(), label.clone()))
816                .collect(),
817        ));
818    }
819    if let Some((value_signal, value, open_signal)) = &el.attrs.dropdown_option {
820        entity.insert(lumen_primitives::DropdownOptionButton {
821            value_signal: value_signal.clone(),
822            value: value.clone(),
823            open_signal: open_signal.clone(),
824        });
825    }
826    if let Some((open_signal, item_id)) = &el.attrs.menu_item {
827        entity.insert(lumen_primitives::MenuItemButton {
828            open_signal: open_signal.clone(),
829            item_id: item_id.clone(),
830        });
831    }
832    // A popup row is clicked whether or not a skin gives it a background,
833    // so it is a pointer target either way. `tab-index="-1"` is that: in
834    // reach of the pointer and of the popup's own arrow-key navigation,
835    // out of the Tab chain, as a `QComboBox` or `QMenu` row is.
836    if (el.attrs.dropdown_option.is_some() || el.attrs.menu_item.is_some())
837        && el.attrs.tab_index.is_none()
838    {
839        entity.insert(TabIndex(-1));
840    }
841    if let Some(open_signal) = &el.attrs.popup_panel {
842        entity.insert((
843            lumen_primitives::PopupPanel {
844                open_signal: open_signal.clone(),
845            },
846            // Top-layer paint band: the panel must paint over all
847            // later-document-order content (inputs, textareas) it
848            // floats above.
849            lumen_core::render_world::OverlayLayer,
850        ));
851    }
852    if el.tag == "image"
853        && let Some(src) = &el.attrs.src
854    {
855        entity.insert(lumen_assets::ImageSource(std::path::PathBuf::from(src)));
856    }
857    apply_background_image(&mut entity, &el.tag, &el.attrs);
858    if let Some(tab) = el.attrs.tab_index {
859        entity.insert(TabIndex(tab));
860    }
861    if el.attrs.autofocus {
862        entity.insert(AutoFocus);
863    }
864    if el.attrs.default_button {
865        entity.insert(DefaultButton);
866    }
867    // The spec a `bind-text` write is rendered through: `resolve_strings`
868    // formatted the text this element spawned with, and
869    // `apply_text_bindings` formats every value the signal writes after.
870    if let Some(spec) = &el.attrs.format {
871        entity.insert(lumen_core::components::TextFormat(spec.clone()));
872    }
873    // What the markup wrote, kept so the element can be resolved again when
874    // the app changes locale: `TextContent` by then holds the message the
875    // old catalogue gave, or a number written the old locale's way, and
876    // neither can be turned back into what the author typed.
877    //
878    // A `format` element is kept for the same reason a translated one is: a
879    // formatted amount is locale output too, and `1.234,50` no longer says
880    // what `1234.5` said. An element with no key and no format carries
881    // nothing extra.
882    if authored.key.is_some() || authored.tooltip_key.is_some() || el.attrs.format.is_some() {
883        entity.insert(authored);
884    }
885    if let Some(spec) = &el.attrs.bind {
886        match spec.kind {
887            BindKind::Text => {
888                entity.insert(BindText::from(spec.name.clone()));
889                // `apply_text_bindings` queries `(&BindText, &mut
890                // TextContent)`; if the markup omits `text=""` we
891                // need to seed an empty TextContent so the first
892                // signal write has a target to update.
893                if text.is_none() {
894                    entity.insert(TextContent(String::new()));
895                }
896            }
897            BindKind::Checked => {
898                entity.insert(BindChecked(spec.name.clone()));
899            }
900            BindKind::Value => {
901                entity.insert(BindValue(spec.name.clone()));
902            }
903        }
904    }
905    // Per-entity (`$self.<field>`) and parent-entity (`$parent.<field>`)
906    // binding markers. The consumer systems are no-op stubs today
907    // (W-signal-design step 1) - we install the marker so the spawn
908    // surface is final ahead of the follow-up commit that wires the
909    // queries.
910    if let Some(field) = &el.attrs.bind_self_text {
911        entity.insert(BindSelfText::from(field.as_str()));
912        if text.is_none() {
913            entity.insert(TextContent(String::new()));
914        }
915    }
916    if let Some(field) = &el.attrs.bind_self_value {
917        entity.insert(BindSelfValue::from(field.as_str()));
918    }
919    if let Some(field) = &el.attrs.bind_self_checked {
920        entity.insert(BindSelfChecked::from(field.as_str()));
921    }
922    if let Some(field) = &el.attrs.bind_parent_text {
923        entity.insert(BindParentText::from(field.as_str()));
924        if text.is_none() {
925            entity.insert(TextContent(String::new()));
926        }
927    }
928    if let Some(field) = &el.attrs.bind_parent_value {
929        entity.insert(BindParentValue::from(field.as_str()));
930    }
931    if let Some(field) = &el.attrs.bind_parent_checked {
932        entity.insert(BindParentChecked::from(field.as_str()));
933    }
934    if let Some(id) = &el.attrs.id {
935        entity.insert(LumenId(id.clone()));
936    }
937    // `<a href="page">` - a real anchor. Attach the navigation target so a
938    // click on this element switches the active page (file-based pages).
939    if el.tag == "a"
940        && let Some(href) = &el.attrs.href
941    {
942        entity.insert(crate::routing::Anchor(href.clone()));
943        // An anchor is interactive, so it is a pointer target whether or
944        // not it paints anything - `tab-index="-1"` is exactly HTML's
945        // "focusable by pointer, not in the Tab chain". Links stay out
946        // of the Tab chain by default; an author opts one in with
947        // `tab-index="0"`, which wins because it was already applied
948        // above.
949        if el.attrs.tab_index.is_none() {
950            entity.insert(TabIndex(-1));
951        }
952    }
953    if !el.attrs.classes.is_empty() {
954        entity.insert(LumenClasses::from(el.attrs.classes.clone()));
955    }
956    // Kept for the restyle pass, which re-runs the cascade from the
957    // element's tag, classes and id alone and would otherwise let a tag
958    // rule overwrite what the markup set. A value still holding a `{...}`
959    // placeholder is a template's, not this element's.
960    let markup: Vec<(String, String)> = el
961        .attrs
962        .markup_styles
963        .iter()
964        .filter(|(_, value)| !value.contains('{'))
965        .cloned()
966        .collect();
967    if !markup.is_empty() {
968        entity.insert(lumen_core::components::MarkupStyle(markup.into()));
969    }
970    // Retain the tag on EVERY spawned element so the runtime can
971    // rebuild a `tag.class#id` cascade target and re-resolve computed
972    // style in place on a theme / media flip (see
973    // `run::reapply_computed_styles`). Pre-skins this was limited to
974    // entities with an id / class, which left tag-only rules (the whole
975    // per-OS skin surface: `button { ... }`, `root { bg }`, ...) frozen at
976    // their spawn-time theme.
977    if !el.tag.is_empty() {
978        entity.insert(lumen_core::components::LumenTag(el.tag.as_str().into()));
979    }
980    // W5.4: writing direction + language tag cascade. Both components
981    // are inherited at runtime by the respective resolver systems;
982    // explicit overrides set here win against the ancestor chain.
983    if let Some(dir) = el.attrs.dir {
984        entity.insert(dir);
985    }
986    if let Some(lang) = &el.attrs.lang {
987        entity.insert(lumen_core::components::Lang::from(lang.as_str()));
988    }
989    if el.attrs.draggable {
990        entity.insert(lumen_primitives::Draggable);
991    }
992    // Runtime-reactive disabled: `bind-disabled="signal"` mirrors the
993    // bind-checked plumbing - `apply_disabled_bindings` (dirty-gated)
994    // adds/removes the `Disabled` marker from the signal, and the
995    // `:disabled` styling flows through the `StateVisuals.disabled`
996    // patch populated above (so it reverses cleanly on re-enable).
997    // Two-way scroll-offset binding (W6 T6): `bind-scroll="signal"` on a
998    // scroll container. `apply_scroll_bindings` (dirty-gated) drives the
999    // vertical offset from the signal; `push_scroll_to_signal` mirrors
1000    // user scrolling back on settle. The component is inert without a
1001    // co-resident `Scroll` + `ScrollOffset` (the `<scroll>` tag spawns
1002    // both).
1003    if let Some(name) = &el.attrs.bind_scroll {
1004        entity.insert(lumen_core::components::BindScroll(name.clone()));
1005    }
1006    if let Some(name) = &el.attrs.bind_disabled {
1007        entity.insert(lumen_core::components::BindDisabled(name.clone()));
1008        if el.attrs.disabled {
1009            // Initial `disabled="true"` alongside the binding: install
1010            // the marker now; the runtime patch styles it on tick 1 and
1011            // the signal takes authority from its first write.
1012            entity.insert(lumen_core::components::Disabled);
1013        }
1014    } else if el.attrs.disabled {
1015        // Without a binding the marker is static for the element's whole
1016        // life, and its `:disabled` styles are its own (see
1017        // `ComputedStyle`).
1018        entity.insert(lumen_core::components::Disabled);
1019    }
1020    if let Some(axis) = el.attrs.scroll {
1021        let mut scroll = Scroll {
1022            axis: axis.into(),
1023            sensitivity: 1.0,
1024            inertia: 0.4,
1025            velocity: glam::Vec2::ZERO,
1026        };
1027        if let Some(s) = el.attrs.sensitivity {
1028            scroll.sensitivity = s;
1029        }
1030        if let Some(i) = el.attrs.inertia {
1031            scroll.inertia = i;
1032        }
1033        entity.insert((scroll, ScrollOffset::default()));
1034    } else {
1035        // CSS `overflow: scroll` promotes any element to a live scroll
1036        // container (web semantics): the walker already clips it, and
1037        // this component makes it wheel-/keyboard-scrollable. Skins use
1038        // it for the dropdown panel's max-visible-rows internal scroll
1039        // (`.dropdown-panel { max-height: ...; overflow-y: scroll; }`).
1040        use lumen_ir::layout_ir::OverflowSpec;
1041        let ox = el.attrs.overflow_x.or(el.attrs.overflow);
1042        let oy = el.attrs.overflow_y.or(el.attrs.overflow);
1043        let sx = matches!(ox, Some(OverflowSpec::Scroll));
1044        let sy = matches!(oy, Some(OverflowSpec::Scroll));
1045        if sx || sy {
1046            let axis = match (sx, sy) {
1047                (true, true) => lumen_core::input::ScrollAxis::Both,
1048                (true, false) => lumen_core::input::ScrollAxis::X,
1049                _ => lumen_core::input::ScrollAxis::Y,
1050            };
1051            let mut scroll = Scroll {
1052                axis,
1053                sensitivity: 1.0,
1054                inertia: 0.4,
1055                velocity: glam::Vec2::ZERO,
1056            };
1057            if let Some(s) = el.attrs.sensitivity {
1058                scroll.sensitivity = s;
1059            }
1060            if let Some(i) = el.attrs.inertia {
1061                scroll.inertia = i;
1062            }
1063            entity.insert((scroll, ScrollOffset::default()));
1064        }
1065    }
1066    let id = entity.id();
1067    // `<toggle>` / `<slider>` spawn a knob / thumb child so their state
1068    // is visible without author CSS. Both are absolute-positioned small
1069    // rounded tiles; `sync_toggle_visuals` / `sync_slider_thumb` in
1070    // lumen-primitives keep them in step with the control's state.
1071    // Knob / thumb fill: `knob-color` (markup attr or the CSS
1072    // `knob-color:` property, UA-seeded by the skins) wins; the runtime
1073    // `KNOB_FILL` constant is the single Rust-side fallback.
1074    if el.tag == "toggle" {
1075        world.spawn((
1076            knob_style(28.0, knob_geometry.inset),
1077            DirtyLayout,
1078            ChildOf(id),
1079            Visuals {
1080                fill: Some(Fill::Solid(knob_fill)),
1081                radius: 14.0,
1082                corner_radii: None,
1083                shadows: Vec::new(),
1084                border: None,
1085            },
1086            lumen_primitives::ToggleKnob,
1087        ));
1088    }
1089    if el.tag == "switch" {
1090        // Thumb seed: absolute-positioned tile parked at the off end.
1091        // `sync_switch_visuals` refines its size / radius / inset once the
1092        // track's laid-out rect is known and animates it on every flip.
1093        world.spawn((
1094            knob_style(20.0, knob_geometry.inset),
1095            DirtyLayout,
1096            ChildOf(id),
1097            Visuals {
1098                fill: Some(Fill::Solid(knob_fill)),
1099                radius: 10.0,
1100                corner_radii: None,
1101                shadows: Vec::new(),
1102                border: None,
1103            },
1104            lumen_primitives::SwitchThumb::default(),
1105        ));
1106    }
1107    if el.tag == "slider" {
1108        let thumb = knob_geometry.thumb_size;
1109        world.spawn((
1110            knob_style(thumb, 0.0),
1111            DirtyLayout,
1112            ChildOf(id),
1113            Visuals {
1114                fill: Some(Fill::Solid(knob_fill)),
1115                radius: thumb / 2.0,
1116                corner_radii: None,
1117                shadows: Vec::new(),
1118                border: None,
1119            },
1120            lumen_primitives::SliderThumb,
1121        ));
1122    }
1123    // A `<slot>` in a fragment body stands in for content the use site
1124    // passes; the instantiation path swaps the marked entity for the node it
1125    // was handed. An unfilled slot keeps whatever the body wrote inside it.
1126    if let Some(name) = crate::fragments::slot_name_of(el) {
1127        world
1128            .entity_mut(id)
1129            .insert(crate::fragments::SlotPlaceholder(name));
1130    }
1131    // An element with a registered custom tag becomes the widget registered
1132    // for it, built from the attributes it was written with. Its own
1133    // components are in place by now and its children are not, so the
1134    // widget sees the element the markup made and nothing it nests.
1135    if let Some(authored) = &el.attrs.widget_attributes
1136        && let Some(spawn) = lumen_widget::widget_spawner(&el.tag)
1137    {
1138        let attrs = lumen_widget::Attributes::from(authored.clone());
1139        spawn(id, &attrs, world);
1140    }
1141    // `<for each="...">` blocks attach a [`ForMarker`] to the spawned entity
1142    // and DEFER child spawning to the reconciler - children are the
1143    // body template, instantiated per-item against the named
1144    // ArraySignals at runtime.
1145    if el.tag == "for"
1146        && let Some(name) = &el.attrs.each
1147    {
1148        world.entity_mut(id).insert(ForMarker {
1149            array_name: name.clone(),
1150            body: el.children.clone(),
1151            key_field: el.attrs.key.clone(),
1152            cached_keys: Vec::new(),
1153            virtualized: el.attrs.virtualized,
1154            row_height: el.attrs.row_height.unwrap_or(32.0),
1155            win_rows: Vec::new(),
1156            cascaded_body: None,
1157            row_items: Vec::new(),
1158        });
1159    } else if (el.tag == "if" || el.tag == "dialog")
1160        && let Some(name) = &el.attrs.if_signal
1161    {
1162        world.entity_mut(id).insert(IfMarker {
1163            signal_name: name.clone(),
1164            body: el.children.clone(),
1165            currently_mounted: false,
1166            mode: el.attrs.if_mode.into(),
1167            eq: el.attrs.if_eq.clone(),
1168            applied_visible: None,
1169        });
1170        if el.tag == "dialog" {
1171            world.entity_mut(id).insert((
1172                DialogMarker,
1173                lumen_core::components::FocusBoundary,
1174                // Modal overlay: paint the dialog subtree in the
1175                // top-layer band, over all normal content.
1176                lumen_core::render_world::OverlayLayer,
1177            ));
1178        }
1179    } else {
1180        for child in &el.children {
1181            spawn_element(world, child, Some(id));
1182        }
1183    }
1184    id
1185}
1186
1187/// Bring a just-spawned tree in line with the state the app starts in, before
1188/// its first tick.
1189///
1190/// `on_start` has run by the time the tree spawns and its signal writes are in
1191/// the store, but the `<if>` and `<for>` bodies they open are mounted by the
1192/// reconcilers, which run on a tick. Run here once, so the tree the first
1193/// tick indexes already holds them: `on_ready`, which runs on that tick, finds
1194/// an element inside an `<if>` that starts truthy, and a node it appends
1195/// there attaches. Writes queued on the external buses (a host's typed
1196/// signal builtins, an embedder writing before the run) are taken in first.
1197///
1198/// One pass: an `<if>` inside a body mounted here mounts on the first tick.
1199pub fn settle_tree(world: &mut bevy_ecs::world::World) {
1200    use bevy_ecs::system::RunSystemOnce;
1201    // A world missing what a pass reads (a bare test app) skips that pass;
1202    // the first tick runs the same systems with the same result.
1203    let _ = world.run_system_once(lumen_core::property_store::drain_external_properties);
1204    let _ = world.run_system_once(lumen_core::signals::drain_external_signals);
1205    let _ = world.run_system_once(reconcile_for_blocks);
1206    let _ = world.run_system_once(reconcile_if_blocks);
1207}
1208
1209/// Reconcile every `<if>` block against the current [`PropertyStore`]
1210/// truthiness. Truthy: property exists AND value is not empty AND is not
1211/// literal `"false"` / `"0"`.
1212///
1213/// Mode policy:
1214///
1215/// * `IfMode::Render` (default) - despawn the body on falsy, respawn
1216///   on truthy. Cheap memory; loses focus / scroll / per-row signals
1217///   across the toggle.
1218/// * `IfMode::Hide` - mount the body once on the first truthy
1219///   transition. Hiding flows through `Style.display = Display::None`
1220///   on the if-marker entity (spec section 17.4: space is released, siblings
1221///   reflow on the next tick via `Changed<Style>`) and additionally
1222///   stamps `Visible(false)` so the render extract, hit-test, and
1223///   focus paths skip the subtree while all descendant state (focus,
1224///   scroll, per-row signals) survives. Both writes happen only on an
1225///   actual transition (D5) - steady ticks touch nothing, so
1226///   `FrameDirty` stays clear.
1227pub fn reconcile_if_blocks(
1228    store: bevy_ecs::system::Res<PropertyStore>,
1229    mut markers: bevy_ecs::system::Query<(
1230        Entity,
1231        &mut IfMarker,
1232        Option<&bevy_ecs::hierarchy::Children>,
1233    )>,
1234    mut styles: bevy_ecs::system::Query<(
1235        &mut lumen_core::components::Style,
1236        &mut lumen_core::style_overrides::StyleOverrides,
1237    )>,
1238    fade_targets: bevy_ecs::system::Query<(
1239        Option<&lumen_primitives::TransitionSpecs>,
1240        Option<&lumen_core::components::Opacity>,
1241    )>,
1242    mut commands: bevy_ecs::system::Commands,
1243) {
1244    for (parent_id, mut marker, children) in markers.iter_mut() {
1245        let value: Option<std::sync::Arc<str>> = store.get_global_str(&marker.signal_name);
1246        let value_str: Option<&str> = value.as_deref();
1247        let now_truthy = match &marker.eq {
1248            Some(expected) => value_str == Some(expected.as_str()),
1249            None => value_str.is_some_and(signal_is_truthy),
1250        };
1251        match marker.mode {
1252            IfMode::Render => {
1253                if now_truthy == marker.currently_mounted {
1254                    continue;
1255                }
1256                if now_truthy {
1257                    for tmpl in &marker.body {
1258                        // A gated body was walked with the tree it came in
1259                        // with, so its placeholders hold what the app was
1260                        // built from. That is the state the web emitter wrote
1261                        // the same branch with, which is what lets a browser
1262                        // adopt it instead of rebuilding it.
1263                        spawn_body_child(
1264                            &mut commands,
1265                            tmpl,
1266                            parent_id,
1267                            Placeholders::Resolved,
1268                            BodyChild::Gated,
1269                        );
1270                    }
1271                    // Entering transition on the block itself (children
1272                    // handle their own via `spawn_body_child`).
1273                    if let Ok((specs, cur)) = fade_targets.get(parent_id)
1274                        && let Some(bundle) = mount_fade_bundle(specs, cur.copied())
1275                    {
1276                        commands.entity(parent_id).insert(bundle);
1277                    }
1278                    marker.currently_mounted = true;
1279                } else {
1280                    if let Some(kids) = children {
1281                        for child in kids.iter() {
1282                            commands.entity(child).despawn();
1283                        }
1284                    }
1285                    marker.currently_mounted = false;
1286                }
1287            }
1288            IfMode::Hide => {
1289                // First-time mount happens on the first truthy tick:
1290                // spawn the subtree once; subsequent toggles only flip
1291                // visibility state.
1292                if now_truthy && !marker.currently_mounted {
1293                    for tmpl in &marker.body {
1294                        spawn_body_child(
1295                            &mut commands,
1296                            tmpl,
1297                            parent_id,
1298                            Placeholders::Resolved,
1299                            BodyChild::Gated,
1300                        );
1301                    }
1302                    marker.currently_mounted = true;
1303                }
1304                // D5: apply visibility only on a transition. The
1305                // unconditional per-tick re-insert kept Changed<Visible>
1306                // (and thus FrameDirty) permanently hot.
1307                if marker.applied_visible == Some(now_truthy) {
1308                    continue;
1309                }
1310                marker.applied_visible = Some(now_truthy);
1311                commands
1312                    .entity(parent_id)
1313                    .insert(lumen_core::components::Visible(now_truthy));
1314                // Entering transition on show (mount-direction only -
1315                // hide stays instant; CSS can't run removal transitions
1316                // without JS either). Check the `<if>` entity itself
1317                // (e.g. `<dialog>`) and its mounted body roots (e.g.
1318                // the synthetic dropdown / menu `.dropdown-panel`
1319                // children, which carry the CSS transition specs).
1320                if now_truthy {
1321                    let mut targets: Vec<Entity> = vec![parent_id];
1322                    if let Some(kids) = children {
1323                        targets.extend(kids.iter());
1324                    }
1325                    for target in targets {
1326                        if let Ok((specs, cur)) = fade_targets.get(target)
1327                            && let Some(bundle) = mount_fade_bundle(specs, cur.copied())
1328                        {
1329                            commands.entity(target).insert(bundle);
1330                        }
1331                    }
1332                }
1333                // section 17.4: hide releases the block's layout space via
1334                // Display::None; show lets go of it. The Style mutation
1335                // trips Changed<Style> -> DirtyLayout, so siblings reflow on
1336                // this same tick's LayoutSync.
1337                if let Ok((mut style, mut overrides)) = styles.get_mut(parent_id) {
1338                    let out = if now_truthy {
1339                        overrides.release::<IfMarker>()
1340                    } else {
1341                        let live = Live {
1342                            style: Some(&style),
1343                            ..Live::default()
1344                        };
1345                        let hidden = StyleValues {
1346                            display: Some(lumen_core::components::Display::None),
1347                            ..StyleValues::default()
1348                        };
1349                        overrides.hold::<IfMarker>(hidden, &live)
1350                    };
1351                    out.write_style(&mut style);
1352                }
1353            }
1354        }
1355    }
1356}
1357
1358/// On Escape, close the top-most open popup layer - one press peels off
1359/// exactly one layer.
1360///
1361/// Priority mirrors paint order (later-painted popups sit on top):
1362///
1363/// 1. an open `<dropdown>` panel (`__dropdown_open:*` signal is `true`),
1364/// 2. otherwise an open `<menu>` panel (`__menu_open:*` signal is `true`),
1365/// 3. otherwise the top-most open `<dialog>`.
1366///
1367/// Dropdown / menu panels are gated by `<if eq="true" mode="hide">`
1368/// blocks whose [`IfMarker::signal_name`] is the synthetic open signal,
1369/// so the same `IfMarker` query that drives dialogs finds them too -
1370/// no dependency on the panel's runtime marker components (which the
1371/// `<if>`-body template path does not propagate). Closing writes the
1372/// open signal to `false` (dropdown / menu) or `""` (dialog); the next
1373/// `reconcile_if_blocks` tick flips `Visible(false)` on the subtree
1374/// (`IfMode::Hide` preserves child state across the toggle).
1375///
1376/// Bare Esc keypresses with nothing open pass through untouched.
1377///
1378/// An Escape that cancelled an in-flight press
1379/// (`lumen_input::cancel_press_on_escape` sets
1380/// [`EscapePressCancel`](lumen_core::input::EscapePressCancel) earlier
1381/// in the Input stage) is consumed by the cancel - the dialog stays
1382/// open on that keystroke.
1383pub fn close_dialogs_on_escape(
1384    mut keys: bevy_ecs::message::MessageReader<lumen_core::input::KeyPressed>,
1385    press_cancel: Option<bevy_ecs::system::Res<lumen_core::input::EscapePressCancel>>,
1386    mut store: bevy_ecs::system::ResMut<PropertyStore>,
1387    blocks: bevy_ecs::system::Query<(
1388        &IfMarker,
1389        Option<&lumen_core::components::Visible>,
1390        Option<&DialogMarker>,
1391    )>,
1392) {
1393    let escape_pressed = keys.read().any(|k| {
1394        matches!(
1395            &k.key,
1396            lumen_core::input::Key::Named(lumen_core::input::NamedKey::Escape)
1397        )
1398    });
1399    if !escape_pressed {
1400        return;
1401    }
1402    if press_cancel.is_some_and(|f| f.0) {
1403        return;
1404    }
1405
1406    // 1. Top-most layer: an open dropdown panel.
1407    for (marker, _visible, _dialog) in &blocks {
1408        if marker.signal_name.starts_with("__dropdown_open:")
1409            && store.get_global_bool(&marker.signal_name) == Some(true)
1410        {
1411            store.set_global_bool(&marker.signal_name, false);
1412            return;
1413        }
1414    }
1415    // 2. Next: an open menu panel.
1416    for (marker, _visible, _dialog) in &blocks {
1417        if marker.signal_name.starts_with("__menu_open:")
1418            && store.get_global_bool(&marker.signal_name) == Some(true)
1419        {
1420            store.set_global_bool(&marker.signal_name, false);
1421            return;
1422        }
1423    }
1424    // 3. Finally: the top-most open `<dialog>`.
1425    for (marker, visible, dialog) in &blocks {
1426        if dialog.is_none() {
1427            continue;
1428        }
1429        let open = visible.map(|v| v.0).unwrap_or(true);
1430        if !open {
1431            continue;
1432        }
1433        let curr = store.get_global_str(&marker.signal_name);
1434        if curr.as_deref() != Some("") {
1435            store.set_global_str(&marker.signal_name, "");
1436            return;
1437        }
1438    }
1439}
1440
1441/// Collect every descendant of `root` (excluding `root` itself),
1442/// depth-first via `Children`.
1443fn collect_descendants(
1444    root: Entity,
1445    children: &bevy_ecs::system::Query<&bevy_ecs::hierarchy::Children>,
1446    out: &mut Vec<Entity>,
1447) {
1448    if let Ok(kids) = children.get(root) {
1449        for kid in kids.iter() {
1450            out.push(kid);
1451            collect_descendants(kid, children, out);
1452        }
1453    }
1454}
1455
1456/// Qt QDialog lifecycle (W5): initial focus on open, focus restore on
1457/// close, and the exactly-once accepted/rejected close event.
1458///
1459/// Open edge (`Visible` flips true):
1460/// - remember the current focus holder,
1461/// - focus the first `autofocus` descendant; else the first focusable
1462///   (`TabIndex >= 0`, enabled) descendant in markup order; else the
1463///   dialog panel itself. The body mounts via deferred commands one
1464///   tick after the edge, so the pass retries (`needs_focus`) until a
1465///   target exists.
1466///
1467/// Close edge:
1468/// - emit exactly one [`lumen_core::input::DialogClosed`] -
1469///   `accepted` when the default button drove the close
1470///   ([`DialogSession::pending_accept`]), `rejected` otherwise
1471///   (Escape, cancel buttons, script writes),
1472/// - restore focus to the pre-open holder when focus still sits
1473///   inside the dialog subtree (a close caused by focusing another
1474///   widget doesn't yank focus back) - mirrors
1475///   `lumen_primitives::popup_nav_lifecycle`.
1476///
1477/// The Tab focus trap itself is `lumen_input::cycle_focus_on_tab` +
1478/// the `FocusBoundary` the spawner puts on every `<dialog>`.
1479#[allow(clippy::too_many_arguments, clippy::type_complexity)]
1480pub fn manage_dialog_lifecycle(
1481    mut commands: bevy_ecs::system::Commands,
1482    mut tracker: bevy_ecs::system::ResMut<lumen_core::input::FocusTracker>,
1483    mut dialogs: bevy_ecs::system::Query<
1484        (
1485            Entity,
1486            Option<&lumen_core::components::Visible>,
1487            Option<&mut DialogSession>,
1488            Option<&lumen_core::components::LumenId>,
1489            &IfMarker,
1490        ),
1491        With<DialogMarker>,
1492    >,
1493    children: bevy_ecs::system::Query<&bevy_ecs::hierarchy::Children>,
1494    parents: bevy_ecs::system::Query<&bevy_ecs::hierarchy::ChildOf>,
1495    focusables: bevy_ecs::system::Query<
1496        (
1497            &lumen_core::components::TabIndex,
1498            Option<&lumen_core::components::DocumentOrder>,
1499        ),
1500        Without<lumen_core::components::Disabled>,
1501    >,
1502    autofocused: bevy_ecs::system::Query<
1503        Option<&lumen_core::components::DocumentOrder>,
1504        (With<AutoFocus>, Without<lumen_core::components::Disabled>),
1505    >,
1506    focus_visibles: bevy_ecs::system::Query<(), With<lumen_core::input::FocusVisible>>,
1507    live: bevy_ecs::system::Query<Entity, Without<bevy_ecs::resource::IsResource>>,
1508    mut closed_out: bevy_ecs::message::MessageWriter<lumen_core::input::DialogClosed>,
1509) {
1510    for (dialog_e, visible, session, lumen_id, marker) in dialogs.iter_mut() {
1511        let open = visible.map(|v| v.0).unwrap_or(false);
1512        let Some(mut session) = session else {
1513            // First sighting: seed the tracker WITHOUT treating the
1514            // initial state as an edge (a dialog that spawns closed
1515            // must not fire `rejected`; one that spawns open skips the
1516            // focus grab - it was never interactively opened).
1517            commands.entity(dialog_e).insert(DialogSession {
1518                open,
1519                ..Default::default()
1520            });
1521            continue;
1522        };
1523        if open && !session.open {
1524            // Open edge.
1525            session.open = true;
1526            session.needs_focus = true;
1527            session.pending_accept = false;
1528            session.prev_focus = tracker.0;
1529            session.prev_focus_visible = tracker
1530                .0
1531                .map(|e| focus_visibles.contains(e))
1532                .unwrap_or(false);
1533        } else if !open && session.open {
1534            // Close edge: exactly one accepted-or-rejected per cycle.
1535            session.open = false;
1536            session.needs_focus = false;
1537            let id = lumen_id
1538                .map(|i| i.0.clone())
1539                .unwrap_or_else(|| marker.signal_name.clone());
1540            closed_out.write(lumen_core::input::DialogClosed {
1541                entity: dialog_e,
1542                id,
1543                accepted: session.pending_accept,
1544            });
1545            session.pending_accept = false;
1546            // Focus restore - only when focus still sits inside the
1547            // dialog subtree (or on the dialog itself / nowhere).
1548            let focus_inside = match tracker.0 {
1549                None => true,
1550                Some(cur) => {
1551                    let mut e = cur;
1552                    loop {
1553                        if e == dialog_e {
1554                            break true;
1555                        }
1556                        match parents.get(e) {
1557                            Ok(co) => e = co.parent(),
1558                            Err(_) => break false,
1559                        }
1560                    }
1561                }
1562            };
1563            if focus_inside {
1564                if let Some(cur) = tracker.0 {
1565                    commands
1566                        .entity(cur)
1567                        .remove::<(lumen_core::input::Focused, lumen_core::input::FocusVisible)>();
1568                }
1569                match session.prev_focus.filter(|e| live.get(*e).is_ok()) {
1570                    Some(prev) => {
1571                        commands.entity(prev).insert(lumen_core::input::Focused);
1572                        if session.prev_focus_visible {
1573                            commands
1574                                .entity(prev)
1575                                .insert(lumen_core::input::FocusVisible);
1576                        }
1577                        tracker.0 = Some(prev);
1578                    }
1579                    None => {
1580                        tracker.0 = None;
1581                    }
1582                }
1583            }
1584        }
1585        if session.open && session.needs_focus {
1586            // Initial-focus pass: retried until the deferred body
1587            // mount produced a target.
1588            let mut descendants = Vec::new();
1589            collect_descendants(dialog_e, &children, &mut descendants);
1590            if descendants.is_empty() {
1591                continue; // body not mounted yet - retry next tick
1592            }
1593            let pick = |pred: &mut dyn FnMut(Entity) -> Option<u32>| -> Option<Entity> {
1594                let mut best: Option<(u32, Entity)> = None;
1595                for &d in &descendants {
1596                    if let Some(order) = pred(d) {
1597                        let candidate = (order, d);
1598                        if best.map(|b| candidate < b).unwrap_or(true) {
1599                            best = Some(candidate);
1600                        }
1601                    }
1602                }
1603                best.map(|(_, e)| e)
1604            };
1605            let target = pick(&mut |e| {
1606                autofocused
1607                    .get(e)
1608                    .ok()
1609                    .map(|doc| doc.map(|d| d.0).unwrap_or(u32::MAX))
1610            })
1611            .or_else(|| {
1612                pick(&mut |e| {
1613                    focusables
1614                        .get(e)
1615                        .ok()
1616                        .filter(|(ti, _)| ti.0 >= 0)
1617                        .map(|(_, doc)| doc.map(|d| d.0).unwrap_or(u32::MAX))
1618                })
1619            })
1620            .unwrap_or(dialog_e);
1621            if let Some(cur) = tracker.0
1622                && cur != target
1623            {
1624                commands
1625                    .entity(cur)
1626                    .remove::<(lumen_core::input::Focused, lumen_core::input::FocusVisible)>();
1627            }
1628            commands.entity(target).insert(lumen_core::input::Focused);
1629            tracker.0 = Some(target);
1630            session.needs_focus = false;
1631        }
1632    }
1633}
1634
1635/// Enter anywhere in an open `<dialog>` activates its DEFAULT button
1636/// (Qt QDialog default-button contract) - unless focus sits on a
1637/// button (its own Enter->click path wins) or on a multiline text
1638/// input (Enter inserts a newline). Single-line inputs DO trigger the
1639/// default (Qt/web: Enter in a line edit fires the default button)
1640/// alongside their own commit event.
1641///
1642/// The default button is the first enabled [`DefaultButton`]
1643/// descendant; with none marked, the first enabled `<button>` in
1644/// markup order (Qt autoDefault). The synthesized [`ClickEvent`] flows
1645/// through the normal click dispatchers, so scripts observe a regular
1646/// `on_click`. Activation through THIS path always marks the close
1647/// `accepted` ([`DialogSession::pending_accept`]); direct pointer
1648/// clicks mark it only for an explicitly `default="true"` button
1649/// ([`mark_dialog_accept_on_default_click`]).
1650#[allow(clippy::too_many_arguments, clippy::type_complexity)]
1651pub fn activate_dialog_default_on_enter(
1652    mut keys: bevy_ecs::message::MessageReader<lumen_core::input::KeyPressed>,
1653    tracker: bevy_ecs::system::Res<lumen_core::input::FocusTracker>,
1654    mut dialogs: bevy_ecs::system::Query<
1655        (
1656            Entity,
1657            Option<&lumen_core::components::Visible>,
1658            Option<&mut DialogSession>,
1659        ),
1660        With<DialogMarker>,
1661    >,
1662    children: bevy_ecs::system::Query<&bevy_ecs::hierarchy::Children>,
1663    tags: bevy_ecs::system::Query<&lumen_core::components::LumenTag>,
1664    inputs: bevy_ecs::system::Query<&lumen_core::components::TextInput>,
1665    defaults: bevy_ecs::system::Query<(), With<DefaultButton>>,
1666    disabled: bevy_ecs::system::Query<(), With<lumen_core::components::Disabled>>,
1667    orders: bevy_ecs::system::Query<&lumen_core::components::DocumentOrder>,
1668    mut clicks: bevy_ecs::message::MessageWriter<lumen_core::input::ClickEvent>,
1669) {
1670    let enter = keys.read().any(|k| {
1671        !k.repeat
1672            && matches!(
1673                &k.key,
1674                lumen_core::input::Key::Named(lumen_core::input::NamedKey::Enter)
1675            )
1676    });
1677    if !enter {
1678        return;
1679    }
1680    // Focus exclusions: a focused button consumes Enter itself; a
1681    // multiline input inserts a newline.
1682    if let Some(focused) = tracker.0 {
1683        if tags.get(focused).is_ok_and(|t| t.0.as_ref() == "button") {
1684            return;
1685        }
1686        if inputs.get(focused).is_ok_and(|i| i.multiline) {
1687            return;
1688        }
1689    }
1690    // Top-most open dialog.
1691    let Some((dialog_e, session)) = dialogs
1692        .iter_mut()
1693        .filter(|(_, vis, _)| vis.map(|v| v.0).unwrap_or(false))
1694        .map(|(e, _, s)| (e, s))
1695        .next()
1696    else {
1697        return;
1698    };
1699    let mut descendants = Vec::new();
1700    collect_descendants(dialog_e, &children, &mut descendants);
1701    let best_by_order = |pred: &dyn Fn(Entity) -> bool| -> Option<Entity> {
1702        descendants
1703            .iter()
1704            .copied()
1705            .filter(|&e| pred(e) && !disabled.contains(e))
1706            .min_by_key(|&e| (orders.get(e).map(|d| d.0).unwrap_or(u32::MAX), e))
1707    };
1708    let target = best_by_order(&|e| defaults.contains(e))
1709        .or_else(|| best_by_order(&|e| tags.get(e).is_ok_and(|t| t.0.as_ref() == "button")));
1710    let Some(target) = target else {
1711        return;
1712    };
1713    if let Some(mut session) = session {
1714        session.pending_accept = true;
1715    }
1716    clicks.write(lumen_core::input::ClickEvent {
1717        entity: target,
1718        position: glam::Vec2::ZERO,
1719        button: lumen_core::input::PointerButton::Primary,
1720        local: None,
1721    });
1722}
1723
1724/// A direct click on an explicitly-marked [`DefaultButton`] inside an
1725/// open dialog resolves that dialog's eventual close as `accepted`.
1726/// Text-child hit-shadowing is handled with the same ancestor walk the
1727/// control dispatchers use.
1728pub fn mark_dialog_accept_on_default_click(
1729    mut clicks: bevy_ecs::message::MessageReader<lumen_core::input::ClickEvent>,
1730    defaults: bevy_ecs::system::Query<(), With<DefaultButton>>,
1731    parents: bevy_ecs::system::Query<&bevy_ecs::hierarchy::ChildOf>,
1732    mut sessions: bevy_ecs::system::Query<&mut DialogSession, With<DialogMarker>>,
1733) {
1734    for ev in clicks.read() {
1735        // Resolve the clicked entity to a DefaultButton (self or
1736        // ancestor), then keep walking to the containing dialog.
1737        let mut cur = Some(ev.entity);
1738        let mut hit_default = false;
1739        while let Some(e) = cur {
1740            if !hit_default && defaults.contains(e) {
1741                hit_default = true;
1742            }
1743            if hit_default && let Ok(mut session) = sessions.get_mut(e) {
1744                session.pending_accept = true;
1745                break;
1746            }
1747            cur = parents.get(e).ok().map(|c| c.parent());
1748        }
1749    }
1750}
1751
1752/// Reconcile every `<for>` block against the current
1753/// [`ArraySignals`][lumen_core::signals::ArraySignals] state.
1754///
1755/// **Reconciliation policy (alpha7):**
1756/// * Equal key sequence -> nothing mounts or unmounts.
1757/// * In every case that keeps rows, a kept row whose record changed has
1758///   its placeholders resolved again and written onto its existing
1759///   entities, so it keeps focus / scroll / animation state and shows the
1760///   new record. A row the template no longer lines up with (a script
1761///   reshaped it, or the change reaches a nested `<for>` / `<if>` body or
1762///   a component use site) cannot follow in place and the block rebuilds.
1763/// * New keys are an extension of cached (cached is a prefix of new) ->
1764///   spawn only the appended items. Common case: `signal_array.push(...)`
1765///   adds one item, only the new row spawns; existing rows keep their
1766///   focus / scroll / `Signals` state.
1767/// * Cached keys are an extension of new (new is a prefix of cached) ->
1768///   despawn only the trailing entities. Common case: pop / clear-tail.
1769/// * Anything else (reorder, mid-insert, mid-remove) -> full rebuild.
1770///   Real keyed reconciliation across arbitrary reorders requires
1771///   re-anchoring `ChildOf` in bevy_ecs and lands as a follow-up.
1772#[allow(clippy::too_many_arguments)]
1773pub fn reconcile_for_blocks(
1774    array_signals: bevy_ecs::system::Res<lumen_core::signals::ArraySignals>,
1775    // Wave-D: `InterpolationSlot::Global(name)` substitutions read the
1776    // canonical typed PropertyStore so `{$theme}` inside a `<for>` body
1777    // resolves correctly. Pre wave-D this consulted the `Signals` resource;
1778    // every internal write now lands on PropertyStore so reading either
1779    // layer would yield the same result, but PropertyStore is the
1780    // canonical source post wave-D.
1781    store: bevy_ecs::system::Res<PropertyStore>,
1782    mut markers: bevy_ecs::system::Query<(
1783        Entity,
1784        &mut ForMarker,
1785        Option<&bevy_ecs::hierarchy::Children>,
1786    )>,
1787    mut commands: bevy_ecs::system::Commands,
1788    lookup: RowLookup,
1789    // Walk the [`ChildOf`] chain to locate the nearest [`Scroll`] ancestor's [`ScrollOffset`] and [`Transform`].
1790    // Virtualised for-blocks consult them to spawn only rows in the visible band; plain for-blocks
1791    // ignore these queries entirely.
1792    parents: bevy_ecs::system::Query<&bevy_ecs::hierarchy::ChildOf>,
1793    scroll_state: bevy_ecs::system::Query<
1794        (
1795            &lumen_core::input::ScrollOffset,
1796            &lumen_core::components::Transform,
1797        ),
1798        With<lumen_core::input::Scroll>,
1799    >,
1800    styles: bevy_ecs::system::Query<&lumen_core::components::Style>,
1801    stylesheet: Option<bevy_ecs::system::Res<LumenStylesheet>>,
1802    policy: Option<bevy_ecs::system::Res<ScenePolicy>>,
1803) {
1804    let policy = policy.map(|p| *p).unwrap_or_default();
1805    // Nothing wrote an array since the last pass, so every mounted row
1806    // still shows the record it was built from.
1807    let arrays_changed = array_signals.is_changed();
1808    let css_changed = stylesheet.as_ref().map(|s| s.is_changed()).unwrap_or(false);
1809    // Under `RowStyle::HostStyled` the rows reach a cascade of their own, so
1810    // every substitution below hands the template on unresolved.
1811    let row_css = match policy.row_style {
1812        RowStyle::Cascade => stylesheet.as_ref().map(|s| &s.0),
1813        RowStyle::HostStyled => None,
1814    };
1815    for (parent_id, mut marker, children) in markers.iter_mut() {
1816        // The row has no entity yet, so the block stands in as the
1817        // discriminator that keeps a typo'd field on a 1000-row list to one
1818        // warning.
1819        let report_missing = |field: &str| warn_missing_row_field_once(parent_id, field);
1820        // Borrow the array in place - the old `.to_vec()` deep-cloned
1821        // every `ArrayItem` HashMap (5 000 rows x per-field Strings) on
1822        // EVERY tick, dominating the idle reconcile cost on big grids.
1823        let items: &[ArrayItem] = array_signals
1824            .get(&marker.array_name)
1825            .unwrap_or(&[] as &[ArrayItem]);
1826
1827        // Virtualised branch: derive the visible row range from the nearest `<scroll>` ancestor's [`ScrollOffset`] and [`Transform`],
1828        // spawning only rows in that window plus an overscan buffer above
1829        // and below, and absolute-position each row at
1830        // `top = row_index * row_height` so layout stays predictable
1831        // regardless of which subset is mounted. Author opts in via
1832        // `<for virtualized="true" row-height="N">`.
1833        //
1834        // Spec section 15.3 windowed reuse: a window shift keeps every row whose
1835        // `(index, key)` is unchanged - only rows entering the window
1836        // spawn and rows leaving it despawn. Pre-fix, every 1-row shift
1837        // despawned + respawned the ENTIRE band (with a full per-row CSS
1838        // cascade), which is what made 5k-row wheel scrolling lag.
1839        if marker.virtualized && policy.virtualization == Virtualization::Enabled {
1840            let row_h = marker.row_height.max(1.0);
1841            // Walk ChildOf upward from the for-block looking for an
1842            // ancestor with a `Scroll` component. Read its scroll
1843            // offset + viewport height so the windowing math knows
1844            // where the user is looking. No scroll ancestor = window
1845            // anchored at the top with the viewport defaulting to a
1846            // tall default so apps still see some rows.
1847            let mut anc = parents.get(parent_id).ok().map(|c| c.parent());
1848            let mut offset_y = 0.0_f32;
1849            let mut viewport_h = 600.0_f32;
1850            while let Some(e) = anc {
1851                if let Ok((off, t)) = scroll_state.get(e) {
1852                    offset_y = off.0.y.max(0.0);
1853                    viewport_h = t.size.y.max(0.0);
1854                    break;
1855                }
1856                anc = parents.get(e).ok().map(|c| c.parent());
1857            }
1858            // Overscan: rows mounted beyond both window edges so a small
1859            // shift lands on already-mounted rows and the per-frame mount
1860            // cost amortises across several frames of scrolling.
1861            //
1862            // Considered and rejected (wave-3 scroll-p95 work): quantizing
1863            // the window to N-row pages to batch mounts. Measured on the
1864            // 10k-row bench it left p95 unchanged (per-row mount spikes
1865            // were never the p95 tail - external drive-loop jitter is)
1866            // and regressed p99 ~18.2 -> ~20.5 ms because the batched
1867            // 12-row mount tick costs more than any per-row tick. Per-row
1868            // shifting keeps the worst single tick small.
1869            const BUFFER_ROWS: usize = 8;
1870            let first = ((offset_y / row_h).floor() as i64 - BUFFER_ROWS as i64).max(0) as usize;
1871            let last =
1872                (((offset_y + viewport_h) / row_h).ceil() as usize + BUFFER_ROWS).min(items.len());
1873
1874            // Pin the for-block's height to total_rows * row_h so taffy
1875            // gives the scroll content the right extent. Without this
1876            // the for-block sizes to 0 (no flex children - every row is
1877            // position: absolute) and rows escape upward into the
1878            // scroll's siblings (e.g. the toolbar/header) because the
1879            // containing block has no height to clip against. The size is
1880            // held over the block's own styles, so author CSS on it
1881            // (padding, etc.) stays intact and a restyle keeps the size.
1882            //
1883            // Taffy's default `flex_shrink: 1.0` clamps the for-block
1884            // down to the parent scroll viewport even when we set an
1885            // explicit `height: content_h`. Pin `min_height` to the
1886            // same value so Taffy can't shrink it - that's what gives
1887            // the scroll a content extent larger than the viewport
1888            // (without it the user can't scroll down past viewport).
1889            let content_h = items.len() as f32 * row_h;
1890            let extent = lumen_core::components::Length::Px(content_h);
1891            // D4: only write when the size differs. An
1892            // unconditional write every steady tick re-triggered
1893            // Changed<Style> -> DirtyLayout -> relayout -> FrameDirty - a
1894            // permanent per-tick relayout/repaint loop on virtualized
1895            // for-blocks.
1896            if styles
1897                .get(parent_id)
1898                .map(|s| s.height != extent || s.min_height != extent)
1899                .unwrap_or(true)
1900            {
1901                let held = StyleValues {
1902                    width: Some(lumen_core::components::Length::Percent(100.0)),
1903                    height: Some(extent),
1904                    min_height: Some(extent),
1905                    ..StyleValues::default()
1906                };
1907                commands.entity(parent_id).queue(
1908                    move |mut ent: bevy_ecs::world::EntityWorldMut<'_>| {
1909                        lumen_core::style_overrides::hold_on::<ForMarker>(&mut ent, held);
1910                    },
1911                );
1912            }
1913
1914            // Desired `(index, key)` pairs for the current window. Keys
1915            // are built for WINDOW rows only - never all 5 000.
1916            let key_field = marker.key_field.clone();
1917            let key_of = |i: usize, item: &ArrayItem| -> String {
1918                key_field
1919                    .as_ref()
1920                    .and_then(|f| item.get(f).cloned())
1921                    .unwrap_or_else(|| i.to_string())
1922            };
1923            let desired: Vec<(usize, String)> = items
1924                .iter()
1925                .enumerate()
1926                .skip(first)
1927                .take(last.saturating_sub(first))
1928                .map(|(i, item)| (i, key_of(i, item)))
1929                .collect();
1930
1931            // Stylesheet changed (hot reload / first insert): drop the
1932            // cascaded-template cache so rows restyle on next spawn.
1933            if css_changed {
1934                marker.cascaded_body = None;
1935            }
1936
1937            // Steady state: same window -> nothing to do. Both lists run
1938            // in row-index order: `desired` is built that way and
1939            // `win_rows` is re-sorted into it at the end of this branch,
1940            // in step with the children list it indexes into.
1941            if children.is_some() && marker.win_rows == desired && !arrays_changed {
1942                continue;
1943            }
1944
1945            let body_len = marker.body.len().max(1);
1946            let kids: Vec<Entity> = children.map(|c| c.iter().collect()).unwrap_or_default();
1947            // Every mounted row owns `body_len` consecutive children in
1948            // spawn order. If something else despawned a child out from
1949            // under us, alignment is gone - fall back to a full rebuild.
1950            let aligned = kids.len() == marker.win_rows.len() * body_len;
1951
1952            // Rows to keep: `(index, key)` present in both the mounted
1953            // set and the desired window.
1954            let desired_by_idx: std::collections::HashMap<usize, &str> =
1955                desired.iter().map(|(i, k)| (*i, k.as_str())).collect();
1956            let mut new_win: Vec<(usize, String)> = Vec::new();
1957            let mut new_items: Vec<ArrayItem> = Vec::new();
1958            let mut patches: Vec<RowPatch> = Vec::new();
1959            let row_template = RowTemplate {
1960                body: &marker.body,
1961                store: &store,
1962                css: row_css,
1963            };
1964            if aligned {
1965                for (row_i, (idx, key)) in marker.win_rows.iter().enumerate() {
1966                    let slice = &kids[row_i * body_len..(row_i + 1) * body_len];
1967                    let was = marker.row_items.get(row_i);
1968                    // A mounted row's index points into the array it was
1969                    // built from, which may since have shrunk past it. Only
1970                    // a row still in the desired window reads its record,
1971                    // and every desired index is in bounds.
1972                    let item =
1973                        (desired_by_idx.get(idx) == Some(&key.as_str())).then(|| &items[*idx]);
1974                    // A kept row whose record changed follows it in place;
1975                    // one that cannot is dropped and mounts again below.
1976                    let kept = item.filter(|&item| {
1977                        was == Some(item)
1978                            || row_template.plan(
1979                                slice,
1980                                (was, item),
1981                                *idx,
1982                                &report_missing,
1983                                &lookup,
1984                                &mut patches,
1985                            )
1986                    });
1987                    if let Some(item) = kept {
1988                        new_win.push((*idx, key.clone()));
1989                        new_items.push(item.clone());
1990                    } else {
1991                        for child in slice {
1992                            commands.entity(*child).despawn();
1993                        }
1994                    }
1995                }
1996            } else {
1997                for child in &kids {
1998                    commands.entity(*child).despawn();
1999                }
2000            }
2001            let mounted: std::collections::HashSet<usize> =
2002                new_win.iter().map(|(i, _)| *i).collect();
2003
2004            // Cascade-once-per-template (spec section 15.3): apply the CSS
2005            // cascade to the body template a single time and substitute
2006            // per-row placeholders into the pre-cascaded clone. Falls
2007            // back to per-row cascade when `id` / `class` attrs carry
2008            // `{...}` placeholders (selector matching would then depend on
2009            // the substituted values).
2010            let per_row_cascade = body_has_dynamic_selector_attrs(&marker.body);
2011            let template: std::sync::Arc<Vec<Element>> = match (per_row_cascade, row_css) {
2012                (true, _) | (false, None) => std::sync::Arc::new(marker.body.clone()),
2013                (false, Some(_)) if marker.cascaded_body.is_some() => {
2014                    marker.cascaded_body.clone().expect("checked above")
2015                }
2016                (false, Some(sheet)) => {
2017                    let cascaded: Vec<Element> = marker
2018                        .body
2019                        .iter()
2020                        .map(|el| {
2021                            let mut c = el.clone();
2022                            cascade_element_tree(&mut c, sheet);
2023                            c
2024                        })
2025                        .collect();
2026                    let arc = std::sync::Arc::new(cascaded);
2027                    marker.cascaded_body = Some(arc.clone());
2028                    arc
2029                }
2030            };
2031
2032            for (i, key) in &desired {
2033                if mounted.contains(i) {
2034                    continue;
2035                }
2036                let item = &items[*i];
2037                let scope = Scope::new(&*store)
2038                    .with_row(item, *i)
2039                    .reporting_to(&report_missing);
2040                for tmpl in template.iter() {
2041                    // Substitute placeholders; run the cascade only on
2042                    // the per-row fallback path (pre-cascaded otherwise).
2043                    let inst = substitute_in_element_with_css(
2044                        tmpl,
2045                        &scope,
2046                        if per_row_cascade { row_css } else { None },
2047                    );
2048                    // The row lands in the absolute slot its index gives
2049                    // it inside the for-block: pinned at `top = i * row_h`
2050                    // and left = 0, with `width=100%` + `height=row_h`
2051                    // setting its size. Setting `right` / `bottom` too
2052                    // would over-constrain taffy (it expects either inset
2053                    // OR size, not both on the same axis) and the row
2054                    // collapses to content width.
2055                    let slot = StyleValues {
2056                        position: Some(lumen_core::components::Position::Absolute),
2057                        inset: Some(lumen_core::components::Edges {
2058                            top: *i as f32 * row_h,
2059                            right: f32::NAN,
2060                            bottom: f32::NAN,
2061                            left: 0.0,
2062                            ..lumen_core::components::Edges::default()
2063                        }),
2064                        height: Some(lumen_core::components::Length::Px(row_h)),
2065                        width: Some(lumen_core::components::Length::Percent(100.0)),
2066                        ..StyleValues::default()
2067                    };
2068                    spawn_body_child(
2069                        &mut commands,
2070                        &inst,
2071                        parent_id,
2072                        Placeholders::Resolved,
2073                        BodyChild::VirtualRow(Box::new(slot)),
2074                    );
2075                }
2076                new_win.push((*i, key.clone()));
2077                new_items.push(item.clone());
2078            }
2079            if !patches.is_empty() {
2080                commands.queue(move |world: &mut World| apply_row_patches(world, patches));
2081            }
2082            // Rows reach `Children` in mount order: a kept row holds the
2083            // slot it already had and a row entering the window appends
2084            // at the end, so a scroll that pulls rows in at the front
2085            // leaves the hierarchy out of row order. Everything that
2086            // walks the tree reads that order: `node.children()`, the
2087            // document order selectors resolve against,
2088            // `renumber_document_order` and the Tab order it feeds. So
2089            // sort the mounted rows back by row index, and move
2090            // `win_rows` with them since the keep-path above slices the
2091            // children list by its positions.
2092            let mut row_order: Vec<usize> = (0..new_win.len()).collect();
2093            row_order.sort_unstable_by_key(|&r| new_win[r].0);
2094            if row_order.iter().enumerate().any(|(slot, &r)| slot != r) {
2095                new_win = row_order.iter().map(|&r| new_win[r].clone()).collect();
2096                new_items = row_order.iter().map(|&r| new_items[r].clone()).collect();
2097                commands.queue(move |world: &mut World| {
2098                    sort_virtual_rows(world, parent_id, body_len, &row_order);
2099                });
2100            }
2101            marker.win_rows = new_win;
2102            marker.row_items = new_items;
2103            continue;
2104        }
2105
2106        // Build the new key list. If `key_field` is set, pull that field
2107        // from each record; otherwise fall back to the item's index.
2108        let new_keys: Vec<String> = items
2109            .iter()
2110            .enumerate()
2111            .map(|(i, item)| {
2112                marker
2113                    .key_field
2114                    .as_ref()
2115                    .and_then(|f| item.get(f).cloned())
2116                    .unwrap_or_else(|| i.to_string())
2117            })
2118            .collect();
2119
2120        let same_keys = new_keys == marker.cached_keys && children.is_some();
2121        if same_keys && !arrays_changed {
2122            continue;
2123        }
2124
2125        let body_len = marker.body.len();
2126        let cached = marker.cached_keys.clone();
2127        let appended = cached.len() < new_keys.len() && new_keys[..cached.len()] == cached[..];
2128        let trimmed = new_keys.len() < cached.len() && cached[..new_keys.len()] == new_keys[..];
2129        let kids: Vec<Entity> = children.map(|c| c.iter().collect()).unwrap_or_default();
2130
2131        // The rows both key lists share sit at the front, `body_len`
2132        // children each. One whose record changed follows it in place;
2133        // `None` when any of them cannot, and the block rebuilds below.
2134        let kept_rows =
2135            (same_keys || appended || trimmed).then(|| cached.len().min(new_keys.len()));
2136        let aligned = kids.len() == cached.len() * body_len;
2137        let followed = kept_rows.and_then(|kept| {
2138            let row_template = RowTemplate {
2139                body: &marker.body,
2140                store: &store,
2141                css: row_css,
2142            };
2143            let mut patches: Vec<RowPatch> = Vec::new();
2144            let mut moved = false;
2145            for (i, item) in items.iter().enumerate().take(kept) {
2146                let was = marker.row_items.get(i);
2147                if was == Some(item) {
2148                    continue;
2149                }
2150                moved = true;
2151                if !aligned {
2152                    return None;
2153                }
2154                let roots = &kids[i * body_len..(i + 1) * body_len];
2155                if !row_template.plan(
2156                    roots,
2157                    (was, item),
2158                    i,
2159                    &report_missing,
2160                    &lookup,
2161                    &mut patches,
2162                ) {
2163                    return None;
2164                }
2165            }
2166            Some((patches, moved))
2167        });
2168
2169        if let Some((patches, moved)) = followed {
2170            if !patches.is_empty() {
2171                commands.queue(move |world: &mut World| apply_row_patches(world, patches));
2172            }
2173            // Append-only: new = cached + tail. Spawn just the tail.
2174            if appended {
2175                for (i, item) in items.iter().enumerate().skip(cached.len()) {
2176                    let scope = Scope::new(&*store)
2177                        .with_row(item, i)
2178                        .reporting_to(&report_missing);
2179                    for tmpl in &marker.body {
2180                        let inst = substitute_in_element_with_css(tmpl, &scope, row_css);
2181                        spawn_body_child(
2182                            &mut commands,
2183                            &inst,
2184                            parent_id,
2185                            Placeholders::Resolved,
2186                            BodyChild::Row,
2187                        );
2188                    }
2189                }
2190            }
2191            // Trim-from-tail: cached = new + tail. Despawn the tail
2192            // entities (`body_len` per popped item, last-spawn-first).
2193            if trimmed {
2194                let drop_count = (cached.len() - new_keys.len()) * body_len;
2195                for child in kids.iter().rev().take(drop_count) {
2196                    commands.entity(*child).despawn();
2197                }
2198            }
2199            if moved || !same_keys {
2200                marker.row_items = items.to_vec();
2201            }
2202            marker.cached_keys = new_keys;
2203            continue;
2204        }
2205
2206        // Fallback: full rebuild. Despawn all children and spawn fresh.
2207        // Loses focus / scroll / per-row signal state - same caveat as
2208        // pre-alpha7. Triggered only when the diff isn't a clean append or
2209        // trim (e.g. midstream insert, reorder, replace).
2210        if let Some(kids) = children {
2211            for child in kids.iter() {
2212                commands.entity(child).despawn();
2213            }
2214        }
2215        for (i, item) in items.iter().enumerate() {
2216            let scope = Scope::new(&*store)
2217                .with_row(item, i)
2218                .reporting_to(&report_missing);
2219            for tmpl in &marker.body {
2220                let inst = substitute_in_element_with_css(tmpl, &scope, row_css);
2221                spawn_body_child(
2222                    &mut commands,
2223                    &inst,
2224                    parent_id,
2225                    Placeholders::Resolved,
2226                    BodyChild::Row,
2227                );
2228            }
2229        }
2230        marker.cached_keys = new_keys;
2231        marker.row_items = items.to_vec();
2232    }
2233}
2234
2235/// Which of an element's placeholder-carrying strings differ between two
2236/// instances of the same template: the strings
2237/// [`lumen_ir::interpolate::substitute_attrs`] resolves, grouped by the
2238/// component each one lands on.
2239#[derive(Clone, Copy, Debug, Default)]
2240struct RowStringChanges {
2241    /// `text` or `placeholder`, which reach the element through
2242    /// [`resolve_strings`].
2243    strings: bool,
2244    id: bool,
2245    classes: bool,
2246    src: bool,
2247    style_role: bool,
2248    drag_payload: bool,
2249}
2250
2251impl RowStringChanges {
2252    fn between(old: &Attributes, new: &Attributes) -> Self {
2253        Self {
2254            strings: old.text != new.text || old.placeholder != new.placeholder,
2255            id: old.id != new.id,
2256            classes: old.classes != new.classes,
2257            src: old.src != new.src,
2258            style_role: old.style_role != new.style_role,
2259            drag_payload: old.drag_payload != new.drag_payload,
2260        }
2261    }
2262
2263    fn any(self) -> bool {
2264        self.strings || self.id || self.classes || self.src || self.style_role || self.drag_payload
2265    }
2266}
2267
2268/// One in-place write a kept `<for>` row needs: the entity, the attributes
2269/// its template resolves to for the new record, and which of them moved.
2270type RowPatch = (Entity, Box<Attributes>, RowStringChanges);
2271
2272/// Whether a use site passes different arguments in two instances.
2273fn use_args_differ(old: &Element, new: &Element) -> bool {
2274    match (&old.frag_use, &new.frag_use) {
2275        (Some(a), Some(b)) => a.args != b.args,
2276        (None, None) => false,
2277        _ => true,
2278    }
2279}
2280
2281/// Whether two instances of one template subtree resolve every placeholder
2282/// the same way.
2283fn same_resolution(old: &Element, new: &Element) -> bool {
2284    !RowStringChanges::between(&old.attrs, &new.attrs).any()
2285        && !use_args_differ(old, new)
2286        && old.children.len() == new.children.len()
2287        && old
2288            .children
2289            .iter()
2290            .zip(&new.children)
2291            .all(|(a, b)| same_resolution(a, b))
2292}
2293
2294/// The children `spawn_element` builds for an element ahead of its markup
2295/// children: the knob of a `<toggle>` / `<switch>` and the thumb of a
2296/// `<slider>`.
2297fn generated_children(tag: &str) -> usize {
2298    usize::from(matches!(tag, "toggle" | "switch" | "slider"))
2299}
2300
2301/// Collect the writes that take the entities `entity` heads from the `old`
2302/// instance of a row template to the `new` one. `false` when the row cannot
2303/// follow in place: the tree no longer has the shape the template spawned,
2304/// or a change reaches something that holds its own copy of the template (a
2305/// nested `<for>` / `<if>` body, a component use site).
2306fn plan_row_element(
2307    entity: Entity,
2308    old: &Element,
2309    new: &Element,
2310    lookup: &RowLookup<'_, '_>,
2311    out: &mut Vec<RowPatch>,
2312) -> bool {
2313    if !new.tag.is_empty() && lookup.tags.get(entity).map(|t| &*t.0) != Ok(new.tag.as_str()) {
2314        return false;
2315    }
2316    if use_args_differ(old, new) {
2317        return false;
2318    }
2319    let changes = RowStringChanges::between(&old.attrs, &new.attrs);
2320    if changes.any() {
2321        out.push((entity, Box::new(new.attrs.clone()), changes));
2322    }
2323    if old.children.len() != new.children.len() {
2324        return false;
2325    }
2326    // A marker keeps its body as a template and builds from it later; a use
2327    // site hands its children to the fragment. Neither is walked here, so a
2328    // change that reaches into one cannot be followed in place.
2329    let holds_template = new.frag_use.is_some()
2330        || (new.tag == "for" && new.attrs.each.is_some())
2331        || (matches!(new.tag.as_str(), "if" | "dialog") && new.attrs.if_signal.is_some());
2332    if holds_template {
2333        return old
2334            .children
2335            .iter()
2336            .zip(&new.children)
2337            .all(|(a, b)| same_resolution(a, b));
2338    }
2339    if new.children.is_empty() {
2340        return true;
2341    }
2342    let skip = generated_children(&new.tag);
2343    let Some(kids) = lookup
2344        .children
2345        .get(entity)
2346        .ok()
2347        .and_then(|c| c.get(skip..skip + new.children.len()))
2348    else {
2349        return false;
2350    };
2351    kids.iter()
2352        .zip(old.children.iter().zip(&new.children))
2353        .all(|(&kid, (o, n))| plan_row_element(kid, o, n, lookup, out))
2354}
2355
2356/// The queries a kept row's walk reads to line its entities up with its
2357/// template.
2358#[derive(bevy_ecs::system::SystemParam)]
2359pub struct RowLookup<'w, 's> {
2360    children: bevy_ecs::system::Query<'w, 's, &'static bevy_ecs::hierarchy::Children>,
2361    tags: bevy_ecs::system::Query<'w, 's, &'static lumen_core::components::LumenTag>,
2362}
2363
2364/// What one `<for>` block's kept rows are rebuilt against when their
2365/// records change: the body template, the signals its `{$name}`
2366/// placeholders read, and the stylesheet its rows cascade through.
2367struct RowTemplate<'a> {
2368    body: &'a [Element],
2369    store: &'a PropertyStore,
2370    css: Option<&'a lumen_ir::css::Stylesheet>,
2371}
2372
2373impl RowTemplate<'_> {
2374    /// Collect the writes that take a kept row, whose top-level entities
2375    /// are `roots`, from the record `old` to `new`, both at `index`.
2376    /// `false` when the row cannot follow in place; see
2377    /// [`plan_row_element`].
2378    fn plan(
2379        &self,
2380        roots: &[Entity],
2381        (old, new): (Option<&ArrayItem>, &ArrayItem),
2382        index: usize,
2383        report_missing: &dyn Fn(&str),
2384        lookup: &RowLookup<'_, '_>,
2385        out: &mut Vec<RowPatch>,
2386    ) -> bool {
2387        if roots.len() != self.body.len() {
2388            return false;
2389        }
2390        let unknown = ArrayItem::new();
2391        let old_scope = Scope::new(self.store).with_row(old.unwrap_or(&unknown), index);
2392        let new_scope = Scope::new(self.store)
2393            .with_row(new, index)
2394            .reporting_to(report_missing);
2395        roots.iter().zip(self.body).all(|(&root, tmpl)| {
2396            let before = substitute_in_element_with_css(tmpl, &old_scope, self.css);
2397            let after = substitute_in_element_with_css(tmpl, &new_scope, self.css);
2398            plan_row_element(root, &before, &after, lookup, out)
2399        })
2400    }
2401}
2402
2403/// Write the planned in-place changes of kept `<for>` rows onto their
2404/// entities, the way `spawn_element` would have built them from the same
2405/// attributes.
2406fn apply_row_patches(world: &mut World, patches: Vec<RowPatch>) {
2407    use lumen_core::components::{BindText, LumenTag, TextInput};
2408    use lumen_core::input::Focused;
2409    use lumen_primitives::TooltipSource;
2410    let mut shown = world.query::<(
2411        Option<&mut TextContent>,
2412        Option<&mut TextInput>,
2413        Option<&mut TooltipSource>,
2414        Has<Focused>,
2415        Has<BindText>,
2416    )>();
2417    for (entity, attrs, changes) in patches {
2418        if world.get_entity(entity).is_err() {
2419            continue;
2420        }
2421        if changes.strings {
2422            let authored = AuthoredStrings::from(&*attrs);
2423            let strings = resolve_strings(
2424                world.get_resource::<lumen_core::i18n::AppI18n>(),
2425                &authored,
2426                attrs.format.as_deref(),
2427            );
2428            if world.get::<AuthoredStrings>(entity).is_some() {
2429                world.entity_mut(entity).insert(authored);
2430            }
2431            if let Ok((text, input, tooltip, focused, bound)) = shown.get_mut(world, entity) {
2432                // A `bind-text` element shows its signal, not the markup.
2433                let text = if bound { None } else { text };
2434                crate::i18n::write_strings(strings, text, input, tooltip, focused);
2435            }
2436        }
2437        let mut e = world.entity_mut(entity);
2438        // A new id, class or typography role can change what the element's
2439        // styles are, so the element restyles.
2440        if changes.id {
2441            match &attrs.id {
2442                Some(id) => e.insert(LumenId(id.clone())),
2443                None => e.remove::<LumenId>(),
2444            };
2445            e.insert(lumen_core::components::StyleDirty);
2446        }
2447        if changes.classes {
2448            if attrs.classes.is_empty() {
2449                e.remove::<LumenClasses>();
2450            } else {
2451                e.insert(LumenClasses::from(attrs.classes.clone()));
2452            }
2453            e.insert(lumen_core::components::StyleDirty);
2454        }
2455        if changes.src && e.get::<LumenTag>().is_some_and(|t| &*t.0 == "image") {
2456            match &attrs.src {
2457                Some(src) => e.insert(lumen_assets::ImageSource(std::path::PathBuf::from(src))),
2458                None => e.remove::<lumen_assets::ImageSource>(),
2459            };
2460        }
2461        if changes.style_role {
2462            match attrs.style_role.as_deref() {
2463                Some(role) => e.insert(TypographyRole(role.into())),
2464                None => e.remove::<TypographyRole>(),
2465            };
2466            e.insert(lumen_core::components::StyleDirty);
2467        }
2468        // An empty payload is the element's `id`, so a new id moves it too.
2469        if changes.drag_payload || changes.id {
2470            match drag_source(&attrs) {
2471                Some(source) => e.insert(source),
2472                None => e.remove::<lumen_os_dnd::DragSource>(),
2473            };
2474        }
2475    }
2476}
2477
2478/// True when any element in the template tree carries a `{...}` placeholder
2479/// inside a selector-relevant attr (`id` or `class`). Such templates must
2480/// run the CSS cascade per row (post-substitution) because rule matching
2481/// depends on the substituted values; everything else can use the
2482/// cascade-once-per-template cache in [`ForMarker::cascaded_body`].
2483fn body_has_dynamic_selector_attrs(body: &[Element]) -> bool {
2484    fn walk(el: &Element) -> bool {
2485        if el.attrs.id.as_deref().is_some_and(|id| id.contains('{')) {
2486            return true;
2487        }
2488        if el.attrs.classes.iter().any(|c| c.contains('{')) {
2489            return true;
2490        }
2491        el.children.iter().any(walk)
2492    }
2493    body.iter().any(walk)
2494}
2495
2496/// Apply the CSS cascade to `el` and every descendant, without any
2497/// placeholder substitution. Used to build the cascade-once template
2498/// cache for virtualized `<for>` rows.
2499fn cascade_element_tree(el: &mut Element, sheet: &lumen_ir::css::Stylesheet) {
2500    lumen_ir::css::reapply_detached(el, sheet, &lumen_ir::css::MediaContext::default());
2501    for child in el.children.iter_mut() {
2502        cascade_element_tree(child, sheet);
2503    }
2504}
2505
2506/// Substitute the placeholders in a template subtree against `scope`, then
2507/// re-run the cascade over the result when a stylesheet is given.
2508///
2509/// The cascade has to come after substitution for a template whose `id` or
2510/// `class` holds a placeholder: what a rule matches depends on the value
2511/// that landed there.
2512pub fn substitute_in_element_with_css(
2513    template: &Element,
2514    scope: &Scope<'_>,
2515    css: Option<&lumen_ir::css::Stylesheet>,
2516) -> Element {
2517    let mut instance = substitute_element(template, scope);
2518    if let Some(sheet) = css {
2519        cascade_element_tree(&mut instance, sheet);
2520    }
2521    instance
2522}
2523
2524/// Emit a `tracing::warn!` for a missing row field, deduplicated per
2525/// `(parent_id, field)` pair. A 1000-row `<for>` referencing a typo'd
2526/// field should warn once, not 1000 times.
2527fn warn_missing_row_field_once(parent_id: Entity, field: &str) {
2528    use std::collections::HashSet;
2529    use std::sync::Mutex;
2530    static SEEN: std::sync::OnceLock<Mutex<HashSet<(u64, String)>>> = std::sync::OnceLock::new();
2531    let key = (parent_id.to_bits(), field.to_string());
2532    let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
2533    let mut guard = seen.lock().expect("warn-once mutex poisoned");
2534    if guard.insert(key) {
2535        tracing::warn!(
2536            target: "lumenc::spawn::row",
2537            "<for> row field `{field}` not present on iteration record - substituting empty string"
2538        );
2539    }
2540}
2541
2542/// `spawn_element` analogue for the reconciler - must go through
2543/// `Commands` (deferred mutation) because the reconciler holds a query
2544/// borrow.
2545///
2546/// Implementation: queue a single world-closure that runs the real
2547/// [`spawn_element`] at command-apply time. The previous version was a
2548/// hand-mirrored copy of `spawn_element` that had drifted badly - it
2549/// never attached `TextInput`, `Toggleable` with its knob child,
2550/// `SliderValue` with its thumb child, `Scroll`/`ScrollOffset`,
2551/// `TabIndex`, `DropdownButton`/`DropdownOptionButton`/`MenuItemButton`,
2552/// `TabStripButton`, `TooltipSource`, `Validation`, `DocumentOrder`, ...
2553/// so every widget mounted inside an `<if>` / `<for>` / `<tab>` /
2554/// `<dialog>` body (tab panels compile to `<if eq>` gates, i.e. most of
2555/// a real app) spawned as a dead visual: toggles had no knob and no
2556/// state, sliders no thumb, inputs weren't focusable or editable,
2557/// dropdown headers didn't open on click, inner `<scroll>` containers
2558/// ignored the wheel, and Tab-cycle skipped the whole subtree (audit
2559/// root causes 5, 8, 9). Routing through `spawn_element` makes the two
2560/// paths structurally identical, forever. Commands apply in FIFO order,
2561/// so despawns queued before this in the same reconcile pass still land
2562/// first, and `parent` (spawned earlier in the queue, or pre-existing)
2563/// is alive by the time the closure runs.
2564fn spawn_body_child(
2565    commands: &mut bevy_ecs::system::Commands,
2566    el: &Element,
2567    parent: Entity,
2568    placeholders: Placeholders,
2569    kind: BodyChild,
2570) {
2571    let el = el.clone();
2572    commands.queue(move |world: &mut World| {
2573        let id = spawn_subtree(world, &el, Some(parent), placeholders);
2574        match kind {
2575            // A gated body carries the attributes the load-time cascade
2576            // resolved, against the color scheme and viewport the app
2577            // started with, so a page reached by navigation after
2578            // `set_color_scheme`, or a dialog opened after the OS flipped
2579            // to dark, would mount in the scheme the app booted with. The
2580            // restyle that runs before the frame resolves it against the
2581            // live ones.
2582            BodyChild::Gated => {
2583                world
2584                    .entity_mut(id)
2585                    .insert(lumen_core::components::StyleDirty);
2586            }
2587            BodyChild::Row => {}
2588            // A virtualized row sits in the slot its index gives it, which
2589            // the reconciler holds over whatever the row's styles say.
2590            BodyChild::VirtualRow(slot) => {
2591                let mut ent = world.entity_mut(id);
2592                ent.insert(VirtualRow);
2593                lumen_core::style_overrides::hold_on::<VirtualRow>(&mut ent, *slot);
2594            }
2595        }
2596        // Mount-direction transition (the CSS `@starting-style`
2597        // analogue): an element entering the tree with a
2598        // `transition: opacity ...` declaration starts fully transparent
2599        // and fades to its computed opacity. Gives fade-in dialogs /
2600        // menus / rows for free. Removal transitions (fade-out before
2601        // despawn) are intentionally NOT implemented - CSS itself can't
2602        // express them without JS, and holding despawns hostage to
2603        // animations would complicate every reconciler.
2604        let specs = world.get::<lumen_primitives::TransitionSpecs>(id).cloned();
2605        let cur = world.get::<Opacity>(id).copied();
2606        if let Some(bundle) = mount_fade_bundle(specs.as_ref(), cur) {
2607            world.entity_mut(id).insert(bundle);
2608        }
2609    });
2610}
2611
2612/// What a body element [`spawn_body_child`] mounts is.
2613enum BodyChild {
2614    /// The body of an `<if>` that just opened.
2615    Gated,
2616    /// A `<for>` row, which its template's cascade already styled.
2617    Row,
2618    /// A virtualized `<for>` row, held in the slot given.
2619    VirtualRow(Box<StyleValues>),
2620}
2621
2622/// Marker on a virtualized `<for>` row. The slot it sits in is held in
2623/// [`StyleOverrides`](lumen_core::style_overrides::StyleOverrides) over
2624/// the row's own styles.
2625#[derive(bevy_ecs::component::Component, Clone, Copy, Debug, Default)]
2626#[require(lumen_core::style_overrides::StyleOverrides)]
2627pub struct VirtualRow;
2628
2629impl StyleOwner for VirtualRow {
2630    const RANK: u8 = 20;
2631}
2632
2633/// Put a virtualized `<for>` block's mounted rows back in row-index
2634/// order. `row_order[slot]` is the current position of the row that
2635/// belongs at `slot`, and every row owns `body_len` consecutive
2636/// children. Queued as a command so this tick's row spawns and despawns
2637/// have landed by the time it reads the child list, and so the write
2638/// lands before [`renumber_document_order`] walks the hierarchy.
2639fn sort_virtual_rows(world: &mut World, parent: Entity, body_len: usize, row_order: &[usize]) {
2640    let Some(kids) = world
2641        .get::<bevy_ecs::hierarchy::Children>(parent)
2642        .map(|c| c.iter().collect::<Vec<Entity>>())
2643    else {
2644        return;
2645    };
2646    // Something else touched the child list, so the per-row slices no
2647    // longer line up. Leave it to the next reconcile, which rebuilds the
2648    // block on the same mismatch.
2649    if kids.len() != row_order.len() * body_len {
2650        return;
2651    }
2652    let mut ordered: Vec<Entity> = Vec::with_capacity(kids.len());
2653    for &row in row_order {
2654        ordered.extend_from_slice(&kids[row * body_len..(row + 1) * body_len]);
2655    }
2656    world.entity_mut(parent).insert_children(0, &ordered);
2657}
2658
2659/// Build the `(Opacity(0), OpacityTransition(0 -> target))` pair for a
2660/// freshly-mounted / freshly-shown element that declares a
2661/// `transition: opacity ...`. `None` when there is no (non-zero-duration)
2662/// opacity spec or the target opacity is already 0.
2663fn mount_fade_bundle(
2664    specs: Option<&lumen_primitives::TransitionSpecs>,
2665    current: Option<Opacity>,
2666) -> Option<(Opacity, lumen_primitives::OpacityTransition)> {
2667    let spec = specs?.for_property(lumen_primitives::TransitionProperty::Opacity)?;
2668    if spec.duration.is_zero() {
2669        return None;
2670    }
2671    let target = current.map(|o| o.0).unwrap_or(1.0);
2672    if target <= 0.0 {
2673        return None;
2674    }
2675    Some((
2676        Opacity(0.0),
2677        lumen_primitives::OpacityTransition(lumen_primitives::Transition::new(
2678            0.0,
2679            target,
2680            spec.duration,
2681            spec.easing,
2682        )),
2683    ))
2684}
2685
2686/// The UA-origin per-tag sizing defaults (D3 / task #29) live in
2687/// `skins/ua.css` now - an always-on stylesheet folded into the cascade
2688/// beneath any skin and beneath app CSS (see `crate::skins::UA` and
2689/// `run::loading::load_ir`), so a skinless app still gets a sizing
2690/// floor without a Rust-side table to keep in sync.
2691///
2692/// Four cases don't fit a plain CSS rule and stay here, running at spawn
2693/// and at every restyle after the cascade and inline attrs have been folded
2694/// into `attrs`:
2695///
2696/// - `<switch>` wants its UA `min-width` only when `width` is ALSO
2697///   unset (an explicit width already gives it a usable footprint).
2698///   CSS has no "set this property only if that other property is
2699///   unset" syntax, so this stays a Rust conditional instead of a
2700///   `ua.css` rule.
2701/// - A page host box fills the window. A multi-page app mounts each page
2702///   inside a synthetic gate keyed on the reserved active-page signal,
2703///   and that gate is the page's containing block: left at its content
2704///   size, a page whose shell asks for `height: 100%` resolves the
2705///   percentage against the shell itself and the bottom of the shell
2706///   lands past the bottom of the window. Pinning the gate to `<root>`'s
2707///   padding box gives every page the same full-window rect, which is
2708///   what Qt's `QStackedWidget` and Slint's page containers do; taking it
2709///   out of flow at the same time is what keeps the gates of the pages
2710///   that are NOT mounted - one empty sibling per page file - from
2711///   dividing the window between them. The gate is spelled `<if>`, and an
2712///   author's own `<if>` is an ordinary in-flow element, so the rule keys
2713///   on the signal rather than the tag and cannot be a `ua.css` selector.
2714/// - A `<dialog>` covers its parent: `position: absolute` with every
2715///   inset `0`, unless a rule or an attribute placed it. The rule would fit
2716///   in `ua.css`, but that sheet also reaches the web target's stylesheet,
2717///   where a modal dialog sits in the browser's top layer and has to stay
2718///   `position: fixed`.
2719/// - `<input>` / `<textarea>` default to `overflow: hidden` only when
2720///   neither the `overflow` shorthand nor `overflow-x` / `overflow-y`
2721///   was authored anywhere. `lumen_ir::css` never expands the `overflow`
2722///   shorthand into the longhands - they're independent properties to
2723///   the cascade, reconciled only afterward in `lumen_ir::convert` via
2724///   `.or()`, which doesn't know about origin. A plain `ua.css`
2725///   `overflow-x` rule would therefore out-rank an author's
2726///   shorthand-only `overflow: visible`, instead of losing to it as the
2727///   current behavior requires - so this checks the fully-resolved
2728///   `attrs` directly instead.
2729pub fn apply_ua_style_defaults(tag: &str, attrs: &Attributes, page_gate: bool, style: &mut Style) {
2730    if tag == "switch" && attrs.width.is_none() && attrs.min_width.is_none() {
2731        style.min_width = Length::Px(52.0);
2732    }
2733    if page_gate && attrs.position.is_none() {
2734        style.position = Position::Absolute;
2735        style.inset = Edges::default();
2736    }
2737    if tag == "dialog" {
2738        if attrs.position.is_none() {
2739            style.position = Position::Absolute;
2740        }
2741        if attrs.inset.is_none() {
2742            style.inset = Edges::default();
2743        }
2744    }
2745    if matches!(tag, "input" | "textarea") {
2746        // Long values scroll horizontally under the caret-keep-visible
2747        // offset (`TextInputScroll`) instead of painting past the field
2748        // box - clip like every real toolkit does. Author markup / CSS
2749        // overflow attrs still win.
2750        if attrs.overflow.is_none() && attrs.overflow_x.is_none() {
2751            style.overflow_x = Overflow::Hidden;
2752        }
2753        if attrs.overflow.is_none() && attrs.overflow_y.is_none() {
2754            style.overflow_y = Overflow::Hidden;
2755        }
2756    }
2757    if let Some(axis) = attrs.scroll {
2758        // A scroll container's automatic minimum size is zero (CSS
2759        // flexbox section 4.5): a `grow="1"` scroller in a fixed-height
2760        // column takes the space it is given and scrolls the rest,
2761        // instead of growing to its content and having nothing to scroll.
2762        // The layout engine keys that rule on `overflow`, which the
2763        // `scroll` attribute does not set, so it is set here: `scroll` on
2764        // the axes it scrolls and `hidden` on the other, as CSS computes
2765        // `visible` beside `scroll` and as the web target emits it.
2766        // Authored overflow attrs and `min-width` / `min-height` still win.
2767        let axis: ScrollAxis = axis.into();
2768        let overflow_for = |scrolls: bool| {
2769            if scrolls {
2770                Overflow::Scroll
2771            } else {
2772                Overflow::Hidden
2773            }
2774        };
2775        if attrs.overflow.is_none() && attrs.overflow_x.is_none() {
2776            style.overflow_x = overflow_for(axis.allows_x());
2777        }
2778        if attrs.overflow.is_none() && attrs.overflow_y.is_none() {
2779            style.overflow_y = overflow_for(axis.allows_y());
2780        }
2781    }
2782}
2783
2784#[cfg(test)]
2785mod ua_style_tests {
2786    use super::*;
2787    use lumen_ir::layout_ir::{OverflowSpec, ScrollAxisSpec};
2788
2789    fn ua_style(attrs: &Attributes) -> Style {
2790        let mut style = Style::from(attrs);
2791        apply_ua_style_defaults("scroll", attrs, false, &mut style);
2792        style
2793    }
2794
2795    #[test]
2796    fn a_scroller_scrolls_its_axes_and_clips_the_other() {
2797        let y = ua_style(&Attributes {
2798            scroll: Some(ScrollAxisSpec::Y),
2799            ..Attributes::default()
2800        });
2801        assert_eq!(
2802            (y.overflow_x, y.overflow_y),
2803            (Overflow::Hidden, Overflow::Scroll)
2804        );
2805        let x = ua_style(&Attributes {
2806            scroll: Some(ScrollAxisSpec::X),
2807            ..Attributes::default()
2808        });
2809        assert_eq!(
2810            (x.overflow_x, x.overflow_y),
2811            (Overflow::Scroll, Overflow::Hidden)
2812        );
2813        let both = ua_style(&Attributes {
2814            scroll: Some(ScrollAxisSpec::Both),
2815            ..Attributes::default()
2816        });
2817        assert_eq!(
2818            (both.overflow_x, both.overflow_y),
2819            (Overflow::Scroll, Overflow::Scroll)
2820        );
2821    }
2822
2823    #[test]
2824    fn an_authored_overflow_wins_over_the_scroller_default() {
2825        let s = ua_style(&Attributes {
2826            scroll: Some(ScrollAxisSpec::Both),
2827            overflow: Some(OverflowSpec::Hidden),
2828            ..Attributes::default()
2829        });
2830        assert_eq!(
2831            (s.overflow_x, s.overflow_y),
2832            (Overflow::Hidden, Overflow::Hidden)
2833        );
2834        let s = ua_style(&Attributes {
2835            scroll: Some(ScrollAxisSpec::Y),
2836            overflow_y: Some(OverflowSpec::Visible),
2837            ..Attributes::default()
2838        });
2839        assert_eq!(s.overflow_y, Overflow::Visible);
2840    }
2841}
2842
2843/// Absolute-position [`Style`] seed for the toggle knob / slider thumb
2844/// child. `size` is the initial square side; `inset_px` is the top/left
2845/// offset (right / bottom stay `NaN` = auto so taffy doesn't
2846/// over-constrain against the explicit size). The runtime sync systems
2847/// refine size + inset once the track's laid-out rect is known.
2848fn knob_style(size: f32, inset_px: f32) -> Style {
2849    Style {
2850        position: lumen_core::components::Position::Absolute,
2851        width: Length::Px(size),
2852        height: Length::Px(size),
2853        inset: lumen_core::components::Edges {
2854            left: inset_px,
2855            top: inset_px,
2856            right: f32::NAN,
2857            bottom: f32::NAN,
2858            ..Default::default()
2859        },
2860        ..Default::default()
2861    }
2862}
2863
2864#[cfg(test)]
2865mod body_spawn_tests {
2866    //! The reconciler spawn path must produce the same widget behavior
2867    //! as the initial `spawn_element` walk. It used to be a drifted
2868    //! hand-mirror that skipped `TextInput`, `Toggleable` + knob,
2869    //! `SliderValue` + thumb, `Scroll`, `TabIndex`, `DropdownButton`,
2870    //! `DocumentOrder`, ... - so every widget inside an `<if>` / `<for>`
2871    //! / `<tab>` body spawned inert (audit RC5 / RC8 / RC9).
2872    use super::*;
2873    use bevy_ecs::system::RunSystemOnce;
2874
2875    fn el(tag: &str) -> Element {
2876        Element {
2877            tag: tag.to_string(),
2878            ..Element::default()
2879        }
2880    }
2881
2882    #[test]
2883    fn if_body_children_spawn_with_full_widget_behavior() {
2884        let mut world = World::new();
2885        world.insert_resource(PropertyStore::default());
2886        world
2887            .resource_mut::<PropertyStore>()
2888            .set_global_str("open", "1");
2889
2890        let mut toggle = el("toggle");
2891        toggle.attrs.tab_index = Some(0);
2892        let mut slider = el("slider");
2893        slider.attrs.tab_index = Some(0);
2894        let mut input = el("input");
2895        input.attrs.tab_index = Some(0);
2896        let mut scroll = el("scroll");
2897        scroll.attrs.scroll = Some(lumen_ir::layout_ir::ScrollAxisSpec::Y);
2898        let mut header = el("button");
2899        header.attrs.dropdown_button = Some(lumen_ir::layout_ir::DropdownButtonSpec {
2900            open_signal: "__dropdown_open:x".to_string(),
2901            value_signal: "x".to_string(),
2902            options: vec![("a".to_string(), "A".to_string(), false)],
2903        });
2904
2905        world.spawn(IfMarker {
2906            signal_name: "open".to_string(),
2907            body: vec![toggle, slider, input, scroll, header],
2908            currently_mounted: false,
2909            mode: IfMode::Hide,
2910            eq: None,
2911            applied_visible: None,
2912        });
2913        world.run_system_once(reconcile_if_blocks).unwrap();
2914
2915        // <toggle> mounts stateful + with its knob child.
2916        let toggle_e = world
2917            .query::<(Entity, &Toggleable)>()
2918            .iter(&world)
2919            .next()
2920            .expect("toggle in an if-body must carry Toggleable")
2921            .0;
2922        let knob_parent = world
2923            .query_filtered::<&ChildOf, With<lumen_primitives::ToggleKnob>>()
2924            .iter(&world)
2925            .next()
2926            .expect("toggle in an if-body must spawn its knob child")
2927            .parent();
2928        assert_eq!(knob_parent, toggle_e, "knob is the toggle's child");
2929
2930        // <slider> mounts stateful + with its thumb child.
2931        let slider_e = world
2932            .query::<(Entity, &SliderValue)>()
2933            .iter(&world)
2934            .next()
2935            .expect("slider in an if-body must carry SliderValue")
2936            .0;
2937        let thumb_parent = world
2938            .query_filtered::<&ChildOf, With<lumen_primitives::SliderThumb>>()
2939            .iter(&world)
2940            .next()
2941            .expect("slider in an if-body must spawn its thumb child")
2942            .parent();
2943        assert_eq!(thumb_parent, slider_e, "thumb is the slider's child");
2944
2945        // <input> is editable/focusable; <scroll> receives the wheel;
2946        // the dropdown header opens on click.
2947        assert_eq!(
2948            world.query::<&TextInput>().iter(&world).count(),
2949            1,
2950            "input in an if-body must carry TextInput"
2951        );
2952        assert_eq!(
2953            world
2954                .query::<(&Scroll, &ScrollOffset)>()
2955                .iter(&world)
2956                .count(),
2957            1,
2958            "scroll in an if-body must carry Scroll + ScrollOffset"
2959        );
2960        assert_eq!(
2961            world
2962                .query::<&lumen_primitives::DropdownButton>()
2963                .iter(&world)
2964                .count(),
2965            1,
2966            "dropdown header in an if-body must carry DropdownButton"
2967        );
2968        // Unstyled controls still get UA fallback visuals: without a
2969        // `Visuals` component they paint nothing at all, so a skinless
2970        // app shows no groove and no field chrome.
2971        for (label, e) in [("slider", slider_e), ("toggle", toggle_e)] {
2972            assert!(
2973                world.get::<Visuals>(e).is_some(),
2974                "unstyled {label} must carry UA fallback Visuals"
2975            );
2976        }
2977        let input_e = world
2978            .query_filtered::<Entity, With<TextInput>>()
2979            .iter(&world)
2980            .next()
2981            .unwrap();
2982        assert!(
2983            world.get::<Visuals>(input_e).is_some(),
2984            "unstyled input must carry UA fallback Visuals"
2985        );
2986
2987        // Tab cycle: focusables carry TabIndex + DocumentOrder.
2988        assert_eq!(
2989            world
2990                .query::<(&TabIndex, &lumen_core::components::DocumentOrder)>()
2991                .iter(&world)
2992                .count(),
2993            3,
2994            "if-body focusables must carry TabIndex + DocumentOrder"
2995        );
2996    }
2997}
2998
2999#[cfg(test)]
3000mod document_order_tests {
3001    //! `DocumentOrder` is a position, not a spawn timestamp. A subtree
3002    //! that mounts after the initial walk - an `<if>` body, a `<for>`
3003    //! row - takes the counter values that were left over, which puts it
3004    //! behind every element already in the world. Tab then skipped a
3005    //! gated button into the list beside it, and the longer the list,
3006    //! the further the jump.
3007    use super::*;
3008    use bevy_ecs::system::RunSystemOnce;
3009
3010    fn el(tag: &str) -> Element {
3011        Element {
3012            tag: tag.to_string(),
3013            ..Element::default()
3014        }
3015    }
3016
3017    fn order_of(world: &World, e: Entity) -> u32 {
3018        world
3019            .get::<lumen_core::components::DocumentOrder>(e)
3020            .expect("every spawned element carries DocumentOrder")
3021            .0
3022    }
3023
3024    /// `<root>` = head button, `<if>` holding one button, a long list,
3025    /// tail button. The gated button mounts last and must still sort
3026    /// between the head and the list.
3027    fn tree_with_a_gate(rows: usize) -> (World, Entity, Entity, Entity, Entity) {
3028        let mut world = World::new();
3029        world.insert_resource(PropertyStore::default());
3030        world
3031            .resource_mut::<PropertyStore>()
3032            .set_global_str("open", "1");
3033
3034        let mut gate = el("if");
3035        gate.attrs.if_signal = Some("open".to_string());
3036        gate.children = vec![el("button")];
3037        let mut list = el("column");
3038        list.children = (0..rows).map(|_| el("button")).collect();
3039        let mut root = el("root");
3040        root.children = vec![el("button"), gate, list, el("button")];
3041
3042        let root_e = spawn_subtree(&mut world, &root, None, Placeholders::Unresolved);
3043        let kids: Vec<Entity> = world
3044            .get::<bevy_ecs::hierarchy::Children>(root_e)
3045            .map(|c| c.iter().collect())
3046            .expect("root has children");
3047        let (head, gate_e, list_e, tail) = (kids[0], kids[1], kids[2], kids[3]);
3048
3049        world.run_system_once(reconcile_if_blocks).unwrap();
3050        let gated = world
3051            .get::<bevy_ecs::hierarchy::Children>(gate_e)
3052            .and_then(|c| c.iter().next())
3053            .expect("the if body mounted");
3054        let first_row = world
3055            .get::<bevy_ecs::hierarchy::Children>(list_e)
3056            .and_then(|c| c.iter().next())
3057            .expect("the list has rows");
3058        world.run_system_once(renumber_document_order).unwrap();
3059        (world, head, gated, first_row, tail)
3060    }
3061
3062    #[test]
3063    fn a_mounted_if_body_sorts_where_it_sits_not_where_it_landed() {
3064        let (world, head, gated, first_row, tail) = tree_with_a_gate(500);
3065        assert!(
3066            order_of(&world, head) < order_of(&world, gated),
3067            "the gated button follows the head button"
3068        );
3069        assert!(
3070            order_of(&world, gated) < order_of(&world, first_row),
3071            "the gated button precedes the list beside it"
3072        );
3073        assert!(
3074            order_of(&world, first_row) < order_of(&world, tail),
3075            "the list precedes the tail button"
3076        );
3077    }
3078
3079    /// The same tree with an almost-empty list. The defect used to hide
3080    /// here, because being a couple of stops out of place still looks
3081    /// right; the ordering must not depend on the list's size at all.
3082    #[test]
3083    fn list_size_does_not_change_the_order() {
3084        let (world, head, gated, first_row, tail) = tree_with_a_gate(1);
3085        assert!(order_of(&world, head) < order_of(&world, gated));
3086        assert!(order_of(&world, gated) < order_of(&world, first_row));
3087        assert!(order_of(&world, first_row) < order_of(&world, tail));
3088    }
3089
3090    /// The walk is a fixed point: running it over a tree that did not
3091    /// move leaves every number where it was.
3092    #[test]
3093    fn a_settled_tree_is_left_alone() {
3094        let (mut world, _, gated, _, _) = tree_with_a_gate(4);
3095        let before = order_of(&world, gated);
3096        world.clear_trackers();
3097        world.run_system_once(renumber_document_order).unwrap();
3098        assert_eq!(order_of(&world, gated), before);
3099    }
3100}
3101
3102#[cfg(test)]
3103mod escape_tests {
3104    //! `close_dialogs_on_escape` - one Esc peels off the top-most open
3105    //! popup layer (dropdown -> menu -> dialog). Driven directly via
3106    //! `run_system_once` against a bare `World`.
3107    use super::*;
3108    use bevy_ecs::message::Messages;
3109    use bevy_ecs::system::RunSystemOnce;
3110    use lumen_core::input::{Key, KeyPressed, Modifiers, NamedKey};
3111
3112    fn if_marker(signal: &str) -> IfMarker {
3113        IfMarker {
3114            signal_name: signal.to_string(),
3115            body: Vec::new(),
3116            currently_mounted: true,
3117            mode: IfMode::Hide,
3118            eq: None,
3119            applied_visible: None,
3120        }
3121    }
3122
3123    fn press_escape(world: &mut World) {
3124        world
3125            .resource_mut::<Messages<KeyPressed>>()
3126            .write(KeyPressed {
3127                key: Key::Named(NamedKey::Escape),
3128                modifiers: Modifiers::default(),
3129                repeat: false,
3130            });
3131    }
3132
3133    fn run(world: &mut World) {
3134        world.run_system_once(close_dialogs_on_escape).unwrap();
3135        world.resource_mut::<Messages<KeyPressed>>().clear();
3136    }
3137
3138    #[test]
3139    fn escape_closes_dropdown_before_dialog() {
3140        let mut world = World::new();
3141        world.init_resource::<Messages<KeyPressed>>();
3142        world.insert_resource(PropertyStore::default());
3143
3144        // Dropdown open on top of an open dialog.
3145        world
3146            .resource_mut::<PropertyStore>()
3147            .set_global_bool("__dropdown_open:fruit", true);
3148        world.spawn(if_marker("__dropdown_open:fruit"));
3149        world
3150            .resource_mut::<PropertyStore>()
3151            .set_global_str("show_dialog", "1");
3152        world.spawn((if_marker("show_dialog"), DialogMarker));
3153
3154        // First Esc: only the dropdown collapses.
3155        press_escape(&mut world);
3156        run(&mut world);
3157        let store = world.resource::<PropertyStore>();
3158        assert_eq!(
3159            store.get_global_bool("__dropdown_open:fruit"),
3160            Some(false),
3161            "dropdown closed first"
3162        );
3163        assert_eq!(
3164            store.get_global_str("show_dialog").as_deref(),
3165            Some("1"),
3166            "dialog still open after one press"
3167        );
3168
3169        // Second Esc: now the dialog closes.
3170        press_escape(&mut world);
3171        run(&mut world);
3172        assert_eq!(
3173            world
3174                .resource::<PropertyStore>()
3175                .get_global_str("show_dialog")
3176                .as_deref(),
3177            Some(""),
3178            "dialog closes on the second press"
3179        );
3180    }
3181
3182    #[test]
3183    fn escape_closes_menu_before_dialog() {
3184        let mut world = World::new();
3185        world.init_resource::<Messages<KeyPressed>>();
3186        world.insert_resource(PropertyStore::default());
3187
3188        world
3189            .resource_mut::<PropertyStore>()
3190            .set_global_bool("__menu_open:file", true);
3191        world.spawn(if_marker("__menu_open:file"));
3192        world
3193            .resource_mut::<PropertyStore>()
3194            .set_global_str("show_dialog", "1");
3195        world.spawn((if_marker("show_dialog"), DialogMarker));
3196
3197        press_escape(&mut world);
3198        run(&mut world);
3199        let store = world.resource::<PropertyStore>();
3200        assert_eq!(store.get_global_bool("__menu_open:file"), Some(false));
3201        assert_eq!(store.get_global_str("show_dialog").as_deref(), Some("1"));
3202    }
3203
3204    /// Wave 3: an Escape that cancelled an in-flight press
3205    /// (`lumen_input::cancel_press_on_escape` set the consumed flag
3206    /// earlier in the Input stage) must not also close the dialog.
3207    #[test]
3208    fn escape_consumed_by_press_cancel_leaves_dialog_open() {
3209        let mut world = World::new();
3210        world.init_resource::<Messages<KeyPressed>>();
3211        world.insert_resource(PropertyStore::default());
3212        world
3213            .resource_mut::<PropertyStore>()
3214            .set_global_str("show_dialog", "1");
3215        world.spawn((if_marker("show_dialog"), DialogMarker));
3216
3217        // Press-cancel consumed this Escape.
3218        world.insert_resource(lumen_core::input::EscapePressCancel(true));
3219        press_escape(&mut world);
3220        run(&mut world);
3221        assert_eq!(
3222            world
3223                .resource::<PropertyStore>()
3224                .get_global_str("show_dialog")
3225                .as_deref(),
3226            Some("1"),
3227            "dialog stays open when the Escape cancelled a press"
3228        );
3229
3230        // Next Escape (no press in flight) closes as usual.
3231        world.insert_resource(lumen_core::input::EscapePressCancel(false));
3232        press_escape(&mut world);
3233        run(&mut world);
3234        assert_eq!(
3235            world
3236                .resource::<PropertyStore>()
3237                .get_global_str("show_dialog")
3238                .as_deref(),
3239            Some(""),
3240            "un-consumed Escape still closes the dialog"
3241        );
3242    }
3243}
3244
3245/// W5 dialog contract - initial focus, focus restore, default-button
3246/// Enter, and the exactly-once accepted/rejected close event. Driven
3247/// via `run_system_once` against a bare `World` (same style as
3248/// `escape_tests`).
3249#[cfg(test)]
3250mod dialog_contract_tests {
3251    use super::*;
3252    use bevy_ecs::message::Messages;
3253    use bevy_ecs::system::RunSystemOnce;
3254    use lumen_core::components::{DocumentOrder, LumenId, LumenTag, TabIndex, Visible};
3255    use lumen_core::input::{
3256        ClickEvent, DialogClosed, FocusTracker, Focused, Key, KeyPressed, Modifiers, NamedKey,
3257        PointerButton,
3258    };
3259
3260    fn if_marker(signal: &str) -> IfMarker {
3261        IfMarker {
3262            signal_name: signal.to_string(),
3263            body: Vec::new(),
3264            currently_mounted: true,
3265            mode: IfMode::Hide,
3266            eq: None,
3267            applied_visible: None,
3268        }
3269    }
3270
3271    /// Dialog with a body: an input (autofocus optional), and two
3272    /// buttons (Cancel, Confirm-with-default-marker).
3273    struct Fixture {
3274        dialog: Entity,
3275        input: Entity,
3276        cancel: Entity,
3277        confirm: Entity,
3278        opener: Entity,
3279    }
3280
3281    fn build(world: &mut World, autofocus_input: bool) -> Fixture {
3282        world.init_resource::<Messages<DialogClosed>>();
3283        world.init_resource::<Messages<KeyPressed>>();
3284        world.init_resource::<Messages<ClickEvent>>();
3285        world.insert_resource(FocusTracker(None));
3286        let opener = world
3287            .spawn((LumenTag("button".into()), TabIndex(0), DocumentOrder(0)))
3288            .id();
3289        let dialog = world
3290            .spawn((DialogMarker, if_marker("dlg_open"), Visible(false)))
3291            .id();
3292        let mut input = world.spawn((
3293            LumenTag("input".into()),
3294            TabIndex(0),
3295            DocumentOrder(10),
3296            ChildOf(dialog),
3297        ));
3298        if autofocus_input {
3299            input.insert(AutoFocus);
3300        }
3301        let input = input.id();
3302        let cancel = world
3303            .spawn((
3304                LumenTag("button".into()),
3305                TabIndex(0),
3306                DocumentOrder(11),
3307                ChildOf(dialog),
3308            ))
3309            .id();
3310        let confirm = world
3311            .spawn((
3312                LumenTag("button".into()),
3313                TabIndex(0),
3314                DocumentOrder(12),
3315                DefaultButton,
3316                ChildOf(dialog),
3317            ))
3318            .id();
3319        Fixture {
3320            dialog,
3321            input,
3322            cancel,
3323            confirm,
3324            opener,
3325        }
3326    }
3327
3328    fn run_lifecycle(world: &mut World) {
3329        world.run_system_once(manage_dialog_lifecycle).unwrap();
3330    }
3331
3332    fn set_open(world: &mut World, dialog: Entity, open: bool) {
3333        world.entity_mut(dialog).insert(Visible(open));
3334    }
3335
3336    fn drain_closed(world: &mut World) -> Vec<DialogClosed> {
3337        world
3338            .resource_mut::<Messages<DialogClosed>>()
3339            .drain()
3340            .collect()
3341    }
3342
3343    #[test]
3344    fn open_focuses_autofocus_descendant() {
3345        let mut world = World::new();
3346        let fx = build(&mut world, true);
3347        world.insert_resource(FocusTracker(Some(fx.opener)));
3348        run_lifecycle(&mut world); // seed session (closed)
3349        set_open(&mut world, fx.dialog, true);
3350        run_lifecycle(&mut world); // open edge + focus pass
3351        assert_eq!(world.resource::<FocusTracker>().0, Some(fx.input));
3352        assert!(world.get::<Focused>(fx.input).is_some());
3353    }
3354
3355    #[test]
3356    fn open_without_autofocus_focuses_first_focusable_descendant() {
3357        let mut world = World::new();
3358        let fx = build(&mut world, false);
3359        world.insert_resource(FocusTracker(Some(fx.opener)));
3360        run_lifecycle(&mut world);
3361        set_open(&mut world, fx.dialog, true);
3362        run_lifecycle(&mut world);
3363        assert_eq!(
3364            world.resource::<FocusTracker>().0,
3365            Some(fx.input),
3366            "input has the lowest DocumentOrder among focusables"
3367        );
3368    }
3369
3370    #[test]
3371    fn close_restores_previous_focus_and_fires_rejected_once() {
3372        let mut world = World::new();
3373        let fx = build(&mut world, true);
3374        world.insert_resource(FocusTracker(Some(fx.opener)));
3375        world.entity_mut(fx.opener).insert(Focused);
3376        run_lifecycle(&mut world);
3377        set_open(&mut world, fx.dialog, true);
3378        run_lifecycle(&mut world);
3379        assert_eq!(world.resource::<FocusTracker>().0, Some(fx.input));
3380        // Close (e.g. Escape wrote the signal; Visible flipped).
3381        set_open(&mut world, fx.dialog, false);
3382        run_lifecycle(&mut world);
3383        assert_eq!(
3384            world.resource::<FocusTracker>().0,
3385            Some(fx.opener),
3386            "focus returns to the pre-open holder"
3387        );
3388        let closed = drain_closed(&mut world);
3389        assert_eq!(closed.len(), 1, "exactly one close event");
3390        assert!(!closed[0].accepted, "non-default close is rejected");
3391        // Further idle ticks: no repeats.
3392        run_lifecycle(&mut world);
3393        run_lifecycle(&mut world);
3394        assert!(drain_closed(&mut world).is_empty(), "never fires twice");
3395    }
3396
3397    #[test]
3398    fn default_button_click_resolves_close_as_accepted_exactly_once() {
3399        let mut world = World::new();
3400        let fx = build(&mut world, true);
3401        run_lifecycle(&mut world);
3402        set_open(&mut world, fx.dialog, true);
3403        run_lifecycle(&mut world);
3404        // Pointer click lands on the confirm button's TEXT CHILD
3405        // (hit-shadowing) - the accept marker must still resolve.
3406        let text_child = world.spawn(ChildOf(fx.confirm)).id();
3407        world
3408            .resource_mut::<Messages<ClickEvent>>()
3409            .write(ClickEvent {
3410                entity: text_child,
3411                position: glam::Vec2::ZERO,
3412                button: PointerButton::Primary,
3413                local: None,
3414            });
3415        world
3416            .run_system_once(mark_dialog_accept_on_default_click)
3417            .unwrap();
3418        set_open(&mut world, fx.dialog, false);
3419        run_lifecycle(&mut world);
3420        let closed = drain_closed(&mut world);
3421        assert_eq!(closed.len(), 1);
3422        assert!(closed[0].accepted, "default-button close is accepted");
3423        assert_eq!(closed[0].id, "dlg_open", "falls back to the open signal");
3424        // Re-open + Escape-close: verdict resets to rejected.
3425        set_open(&mut world, fx.dialog, true);
3426        run_lifecycle(&mut world);
3427        set_open(&mut world, fx.dialog, false);
3428        run_lifecycle(&mut world);
3429        let closed = drain_closed(&mut world);
3430        assert_eq!(closed.len(), 1);
3431        assert!(
3432            !closed[0].accepted,
3433            "pending accept never leaks into the next cycle"
3434        );
3435    }
3436
3437    #[test]
3438    fn dialog_id_attribute_wins_over_signal_name() {
3439        let mut world = World::new();
3440        let fx = build(&mut world, true);
3441        world
3442            .entity_mut(fx.dialog)
3443            .insert(LumenId("demo-dialog".into()));
3444        run_lifecycle(&mut world);
3445        set_open(&mut world, fx.dialog, true);
3446        run_lifecycle(&mut world);
3447        set_open(&mut world, fx.dialog, false);
3448        run_lifecycle(&mut world);
3449        let closed = drain_closed(&mut world);
3450        assert_eq!(closed[0].id, "demo-dialog");
3451    }
3452
3453    fn press_enter(world: &mut World) {
3454        world
3455            .resource_mut::<Messages<KeyPressed>>()
3456            .write(KeyPressed {
3457                key: Key::Named(NamedKey::Enter),
3458                modifiers: Modifiers::default(),
3459                repeat: false,
3460            });
3461    }
3462
3463    fn run_enter(world: &mut World) {
3464        world
3465            .run_system_once(activate_dialog_default_on_enter)
3466            .unwrap();
3467        world.resource_mut::<Messages<KeyPressed>>().clear();
3468    }
3469
3470    fn synthesized_clicks(world: &mut World) -> Vec<Entity> {
3471        world
3472            .resource_mut::<Messages<ClickEvent>>()
3473            .drain()
3474            .map(|c| c.entity)
3475            .collect()
3476    }
3477
3478    #[test]
3479    fn enter_on_non_button_focus_activates_default() {
3480        let mut world = World::new();
3481        let fx = build(&mut world, true);
3482        run_lifecycle(&mut world);
3483        set_open(&mut world, fx.dialog, true);
3484        run_lifecycle(&mut world); // focus lands on the input
3485        press_enter(&mut world);
3486        run_enter(&mut world);
3487        assert_eq!(
3488            synthesized_clicks(&mut world),
3489            vec![fx.confirm],
3490            "Enter with focus on the (single-line) input clicks the default button"
3491        );
3492        assert!(
3493            world
3494                .get::<DialogSession>(fx.dialog)
3495                .unwrap()
3496                .pending_accept,
3497            "Enter-default marks the accept path"
3498        );
3499    }
3500
3501    #[test]
3502    fn enter_on_focused_button_does_not_double_activate() {
3503        let mut world = World::new();
3504        let fx = build(&mut world, false);
3505        run_lifecycle(&mut world);
3506        set_open(&mut world, fx.dialog, true);
3507        run_lifecycle(&mut world);
3508        // Focus the CANCEL button: Enter must go to it (via the normal
3509        // activate_focused_on_enter path), not the default button.
3510        world.insert_resource(FocusTracker(Some(fx.cancel)));
3511        press_enter(&mut world);
3512        run_enter(&mut world);
3513        assert!(
3514            synthesized_clicks(&mut world).is_empty(),
3515            "focused button consumes Enter itself"
3516        );
3517    }
3518
3519    #[test]
3520    fn enter_ignores_closed_dialogs() {
3521        let mut world = World::new();
3522        let fx = build(&mut world, false);
3523        run_lifecycle(&mut world);
3524        world.insert_resource(FocusTracker(None));
3525        press_enter(&mut world);
3526        run_enter(&mut world);
3527        assert!(synthesized_clicks(&mut world).is_empty());
3528        let _ = fx;
3529    }
3530
3531    #[test]
3532    fn enter_falls_back_to_first_enabled_button_without_default_marker() {
3533        let mut world = World::new();
3534        let fx = build(&mut world, false);
3535        world.entity_mut(fx.confirm).remove::<DefaultButton>();
3536        run_lifecycle(&mut world);
3537        set_open(&mut world, fx.dialog, true);
3538        run_lifecycle(&mut world);
3539        world.insert_resource(FocusTracker(Some(fx.input)));
3540        press_enter(&mut world);
3541        run_enter(&mut world);
3542        assert_eq!(
3543            synthesized_clicks(&mut world),
3544            vec![fx.cancel],
3545            "Qt autoDefault: first enabled button in markup order"
3546        );
3547    }
3548}
3549
3550#[cfg(test)]
3551mod css_spawn_wiring_tests {
3552    //! Phase 2 (skin-tokens): CSS-authored values resolved into
3553    //! `Attributes` must reach the runtime at SPAWN, not only via a live
3554    //! restyle - a value that only shows up after a theme flip and not on
3555    //! first launch is exactly the bug these tests pin.
3556    use super::*;
3557
3558    fn el(tag: &str) -> Element {
3559        Element {
3560            tag: tag.to_string(),
3561            ..Element::default()
3562        }
3563    }
3564
3565    #[test]
3566    fn input_gets_caret_width_and_password_character_from_css() {
3567        let mut world = World::new();
3568        let mut input = el("input");
3569        input.attrs.caret_width = Some(3.5);
3570        input.attrs.password_character = Some('#');
3571        let e = spawn_subtree(&mut world, &input, None, Placeholders::Unresolved);
3572
3573        assert_eq!(
3574            world.get::<lumen_core::components::CaretWidth>(e),
3575            Some(&lumen_core::components::CaretWidth(3.5))
3576        );
3577        assert_eq!(
3578            world.get::<lumen_core::components::PasswordCharacter>(e),
3579            Some(&lumen_core::components::PasswordCharacter('#'))
3580        );
3581    }
3582
3583    /// An unauthored `<input>` still carries both components, holding the
3584    /// `Default` fallback - inserted unconditionally, the same contract
3585    /// pattern as `KnobGeometry` / `PopupGap`, not left absent.
3586    #[test]
3587    fn input_gets_default_caret_width_and_password_character_when_unauthored() {
3588        let mut world = World::new();
3589        let e = spawn_subtree(&mut world, &el("input"), None, Placeholders::Unresolved);
3590
3591        assert_eq!(
3592            world.get::<lumen_core::components::CaretWidth>(e),
3593            Some(&lumen_core::components::CaretWidth::default())
3594        );
3595        assert_eq!(
3596            world.get::<lumen_core::components::PasswordCharacter>(e),
3597            Some(&lumen_core::components::PasswordCharacter::default())
3598        );
3599    }
3600
3601    /// An `<a href>` is interactive, so it takes the pointer whether or
3602    /// not it paints: hit-testing keys on the layout rect plus
3603    /// interactivity. `-1` keeps links out of the Tab chain, matching
3604    /// HTML's "focusable by pointer, not tabbable".
3605    #[test]
3606    fn anchor_is_a_pointer_target_without_a_background() {
3607        let mut world = World::new();
3608        let mut a = el("a");
3609        a.attrs.href = Some("about".to_string());
3610        let e = spawn_subtree(&mut world, &a, None, Placeholders::Unresolved);
3611
3612        assert!(
3613            world.get::<Visuals>(e).is_none(),
3614            "an unstyled anchor paints nothing"
3615        );
3616        assert_eq!(
3617            world.get::<TabIndex>(e).map(|t| t.0),
3618            Some(-1),
3619            "an anchor is a pointer target but not a Tab stop"
3620        );
3621    }
3622
3623    /// An authored `tab-index` on a link wins - that is how an author
3624    /// puts one in the Tab chain.
3625    #[test]
3626    fn authored_tab_index_on_an_anchor_wins() {
3627        let mut world = World::new();
3628        let mut a = el("a");
3629        a.attrs.href = Some("about".to_string());
3630        a.attrs.tab_index = Some(0);
3631        let e = spawn_subtree(&mut world, &a, None, Placeholders::Unresolved);
3632
3633        assert_eq!(world.get::<TabIndex>(e).map(|t| t.0), Some(0));
3634    }
3635
3636    /// A non-text-input element must not gain either component at all -
3637    /// they are only meaningful on `<input>` / `<textarea>`.
3638    #[test]
3639    fn non_input_does_not_get_caret_width_or_password_character() {
3640        let mut world = World::new();
3641        let e = spawn_subtree(&mut world, &el("column"), None, Placeholders::Unresolved);
3642
3643        assert!(world.get::<lumen_core::components::CaretWidth>(e).is_none());
3644        assert!(
3645            world
3646                .get::<lumen_core::components::PasswordCharacter>(e)
3647                .is_none()
3648        );
3649    }
3650
3651    /// `<textarea>` gets the same wiring as `<input>` - both share the
3652    /// gate.
3653    #[test]
3654    fn textarea_gets_caret_width_from_css() {
3655        let mut world = World::new();
3656        let mut textarea = el("textarea");
3657        textarea.attrs.caret_width = Some(4.0);
3658        let e = spawn_subtree(&mut world, &textarea, None, Placeholders::Unresolved);
3659
3660        assert_eq!(
3661            world.get::<lumen_core::components::CaretWidth>(e),
3662            Some(&lumen_core::components::CaretWidth(4.0))
3663        );
3664    }
3665
3666    /// `line-height` alone (see `lumen_ir::convert`'s
3667    /// `line_height_alone_produces_a_text_style` for the same case at the
3668    /// conversion layer) reaches the spawned `TextStyle`.
3669    #[test]
3670    fn text_style_carries_authored_line_height_at_spawn() {
3671        let mut world = World::new();
3672        let mut label = el("label");
3673        label.attrs.line_height = Some(lumen_ir::layout_ir::LineHeightSpec::Multiplier(1.6));
3674        let e = spawn_subtree(&mut world, &label, None, Placeholders::Unresolved);
3675
3676        let ts = world
3677            .get::<TextStyle>(e)
3678            .expect("line-height alone must still spawn a TextStyle");
3679        assert_eq!(
3680            ts.line_height,
3681            Some(lumen_core::components::LineHeightSpec::Multiplier(1.6))
3682        );
3683    }
3684
3685    /// Every new `scrollbar-*` field spawn was silently dropping (only
3686    /// `thumb` / `track` / `width` reached `ScrollbarStyle`) now converts
3687    /// and lands, including the ms -> s duration conversion for the fade
3688    /// timings.
3689    #[test]
3690    fn scrollbar_style_carries_the_new_css_fields_at_spawn() {
3691        let mut world = World::new();
3692        let mut column = el("column");
3693        column.attrs.scrollbar_thickness = Some(10.0);
3694        column.attrs.scrollbar_thickness_thin = Some(5.0);
3695        column.attrs.scrollbar_margin = Some(3.0);
3696        column.attrs.scrollbar_min_thumb = Some(24.0);
3697        column.attrs.scrollbar_hover_boost = Some(2.5);
3698        column.attrs.scrollbar_fade_delay_ms = Some(2000);
3699        column.attrs.scrollbar_fade_duration_ms = Some(500);
3700        let e = spawn_subtree(&mut world, &column, None, Placeholders::Unresolved);
3701
3702        let sb = world
3703            .get::<lumen_core::input::ScrollbarStyle>(e)
3704            .expect("authored scrollbar-* properties must spawn a ScrollbarStyle");
3705        assert_eq!(sb.thickness, 10.0);
3706        assert_eq!(sb.thickness_thin, 5.0);
3707        assert_eq!(sb.margin, 3.0);
3708        assert_eq!(sb.min_thumb, 24.0);
3709        assert_eq!(sb.hover_boost, 2.5);
3710        assert_eq!(sb.fade_delay_secs, 2.0, "2000ms -> 2.0s");
3711        assert_eq!(sb.fade_secs, 0.5, "500ms -> 0.5s");
3712    }
3713
3714    /// A field from this batch authored ALONE - nothing else
3715    /// scrollbar-related - must still spawn `ScrollbarStyle`. The guard
3716    /// used to check only `scrollbar_color` / `scrollbar_width`, so e.g.
3717    /// `scrollbar-thickness` on its own never got a component at all -
3718    /// the same silent-drop shape as the `TextStyle` guard bug.
3719    #[test]
3720    fn scrollbar_thickness_alone_spawns_scrollbar_style() {
3721        let mut world = World::new();
3722        let mut column = el("column");
3723        column.attrs.scrollbar_thickness = Some(12.0);
3724        let e = spawn_subtree(&mut world, &column, None, Placeholders::Unresolved);
3725
3726        assert!(
3727            world.get::<lumen_core::input::ScrollbarStyle>(e).is_some(),
3728            "scrollbar-thickness alone must spawn ScrollbarStyle"
3729        );
3730    }
3731
3732    /// A plain element with no scrollbar properties at all must not gain
3733    /// a `ScrollbarStyle` - the "harmless on non-scroll entities" claim
3734    /// in the spawn comment depends on absence, not a zeroed component.
3735    #[test]
3736    fn no_scrollbar_properties_spawns_no_scrollbar_style() {
3737        let mut world = World::new();
3738        let e = spawn_subtree(&mut world, &el("column"), None, Placeholders::Unresolved);
3739
3740        assert!(world.get::<lumen_core::input::ScrollbarStyle>(e).is_none());
3741    }
3742
3743    /// A world whose globals hold `name`, which is the state a page arrives
3744    /// with in a browser: the seed is applied before the tree is built.
3745    fn world_holding(pairs: &[(&str, &str)]) -> World {
3746        let mut world = World::new();
3747        let mut store = PropertyStore::default();
3748        for (name, value) in pairs {
3749            store.set_global_str(name, *value);
3750        }
3751        world.insert_resource(store);
3752        world
3753    }
3754
3755    #[test]
3756    fn a_placeholder_naming_a_global_is_resolved_when_the_element_is_built() {
3757        let mut world = world_holding(&[("name", "Ada")]);
3758        let mut label = el("label");
3759        label.attrs.text = Some("hello {name}".to_string());
3760        label.attrs.classes = vec!["tier-{name}".to_string()];
3761        let e = spawn_subtree(&mut world, &label, None, Placeholders::Unresolved);
3762
3763        assert_eq!(
3764            world.get::<TextContent>(e).map(|text| text.0.as_str()),
3765            Some("hello Ada"),
3766            "the web emitter writes the same string into the page"
3767        );
3768        let classes = world
3769            .get::<lumen_core::components::LumenClasses>(e)
3770            .expect("a class list");
3771        assert!(
3772            classes.0.iter().any(|class| &**class == "tier-Ada"),
3773            "{:?}",
3774            classes.0
3775        );
3776    }
3777
3778    #[test]
3779    fn a_placeholder_no_global_answers_stays_in_the_text() {
3780        let mut world = world_holding(&[]);
3781        let mut label = el("label");
3782        label.attrs.text = Some("hello {name}".to_string());
3783        let e = spawn_subtree(&mut world, &label, None, Placeholders::Unresolved);
3784
3785        assert_eq!(
3786            world.get::<TextContent>(e).map(|text| text.0.as_str()),
3787            Some("hello {name}")
3788        );
3789    }
3790
3791    #[test]
3792    fn a_tree_a_row_already_walked_is_not_walked_again() {
3793        let mut world = world_holding(&[("name", "Ada")]);
3794        let mut label = el("label");
3795        // What a row field held, written in by the row walk. It is text, and
3796        // reading it as a placeholder would put the global in its place.
3797        label.attrs.text = Some("{name}".to_string());
3798        let e = spawn_subtree(&mut world, &label, None, Placeholders::Resolved);
3799
3800        assert_eq!(
3801            world.get::<TextContent>(e).map(|text| text.0.as_str()),
3802            Some("{name}")
3803        );
3804    }
3805}
3806
3807#[cfg(test)]
3808mod virtualized_for_tests {
3809    //! A virtualized `<for>` remembers each mounted row by its index into
3810    //! the array it was built from. When the array shrinks under the
3811    //! window (a search filter), those indices can point past its end.
3812    use super::*;
3813    use bevy_ecs::system::RunSystemOnce;
3814    use lumen_core::signals::{ArrayItem, ArraySignals};
3815
3816    fn rows(ids: &[usize]) -> Vec<ArrayItem> {
3817        ids.iter()
3818            .map(|i| {
3819                let mut m = ArrayItem::default();
3820                m.insert("id".to_string(), i.to_string());
3821                m.insert("label".to_string(), format!("row {i}"));
3822                m
3823            })
3824            .collect()
3825    }
3826
3827    #[test]
3828    fn a_filter_that_shrinks_the_array_under_the_window_keeps_the_surviving_row() {
3829        let mut world = World::new();
3830        let mut arrays = ArraySignals::default();
3831        arrays.set("rows", rows(&[0, 1, 2]));
3832        world.insert_resource(arrays);
3833        world.insert_resource(PropertyStore::default());
3834
3835        let mut label = Element {
3836            tag: "label".to_string(),
3837            ..Element::default()
3838        };
3839        label.attrs.text = Some("{label}".to_string());
3840        let block = world
3841            .spawn((
3842                lumen_core::components::Style::default(),
3843                ForMarker {
3844                    array_name: "rows".into(),
3845                    body: vec![label],
3846                    key_field: Some("id".to_string()),
3847                    cached_keys: Vec::new(),
3848                    virtualized: true,
3849                    row_height: 20.0,
3850                    win_rows: Vec::new(),
3851                    cascaded_body: None,
3852                    row_items: Vec::new(),
3853                },
3854            ))
3855            .id();
3856        world.run_system_once(reconcile_for_blocks).unwrap();
3857        assert_eq!(world.get::<ForMarker>(block).unwrap().win_rows.len(), 3);
3858
3859        // The filter keeps only the last row: it moves to index 0, and the
3860        // rows mounted at indices 1 and 2 have no record left.
3861        world.resource_mut::<ArraySignals>().set("rows", rows(&[2]));
3862        world.run_system_once(reconcile_for_blocks).unwrap();
3863
3864        assert_eq!(
3865            world.get::<ForMarker>(block).unwrap().win_rows,
3866            vec![(0, "2".to_string())]
3867        );
3868        let kids: Vec<Entity> = world
3869            .get::<bevy_ecs::hierarchy::Children>(block)
3870            .map(|c| c.iter().collect())
3871            .unwrap_or_default();
3872        assert_eq!(kids.len(), 1);
3873        assert_eq!(
3874            world.get::<TextContent>(kids[0]).map(|t| t.0.as_str()),
3875            Some("row 2")
3876        );
3877    }
3878}