Skip to main content

logicaffeine_compile/semantics/
dom.rs

1//! An in-process mock DOM — the shared oracle that gives the DOM builtins
2//! identical semantics on the tree-walker and the register VM (they both run
3//! `call_builtin`), so the sacred WASM==VM==tree-walker invariant holds even
4//! though the DOM is browser-only.
5//!
6//! A DOM-driving LOGOS program builds a node tree and mutates it; on the native
7//! tiers those operations land here (a thread-local handle table + an ordered
8//! op-log), and on the wasm tier the SAME operations land in the emitted module's
9//! `dom_*` host imports — a `MockDom` linker in tests, the real browser DOM in
10//! production. Because DOM ops emit no `Show`, the existing output differential
11//! passes trivially; the DOM EFFECTS are compared via [`op_log`]/[`render`], keyed
12//! by node **creation ordinal** (raw handles legitimately differ per tier), which
13//! the wasm `MockDom` reproduces because it creates nodes in the same order.
14//!
15//! Modeled on `logicaffeine_base::money`'s `AMBIENT_RATES` thread-local.
16
17use crate::interpreter::RuntimeValue;
18use std::cell::RefCell;
19use std::collections::BTreeMap;
20
21/// The largest request body `fetchSend` will put on the wire — exceeding it fails the
22/// request immediately with the `"toolarge"` transport error (never a silent truncation).
23/// Shared by every tier (native recorder, MockDom, browser host) so behavior cannot drift.
24pub const FETCH_REQUEST_BODY_CAP: usize = 4 * 1024 * 1024;
25
26/// The largest response body a fetch will deliver — a larger body is dropped and the
27/// request fails with `"toolarge"` (the callback still runs and can read the error).
28pub const FETCH_RESPONSE_BODY_CAP: usize = 4 * 1024 * 1024;
29
30/// The request headers `fetchSend` allows (lowercase). A header outside this list fails
31/// the request immediately with `"network: header not allowed: <name>"` — the browser
32/// would silently strip or reject forbidden headers per-engine; the contract makes the
33/// denial explicit and identical on every tier.
34pub const FETCH_ALLOWED_REQUEST_HEADERS: &[&str] = &[
35    "accept",
36    "accept-language",
37    "authorization",
38    "cache-control",
39    "content-type",
40    "idempotency-key",
41    "if-match",
42    "if-none-match",
43    "x-csrf-token",
44    "x-expected-version",
45    "x-requested-with",
46];
47
48/// The `<meta name=…>` a document carries its CSRF token in — written by the server that composes
49/// a page under a `## Session`, and read by every tier's host when the page makes a request.
50pub const CSRF_META_NAME: &str = logicaffeine_system::csrf::CSRF_META_NAME;
51
52/// Whether a request changes state on the server, and so must prove it came from the page the
53/// server handed out: `POST`, `PUT`, `PATCH`, `DELETE`.
54pub fn changes_state(method: &str) -> bool {
55    ["POST", "PUT", "PATCH", "DELETE"]
56        .iter()
57        .any(|m| m.eq_ignore_ascii_case(method))
58}
59
60/// Whether `url` goes to the page's own server: a URL with no scheme and no host of its own.
61/// Decided on the URL as the page wrote it, identically in every host.
62pub fn to_own_server(url: &str) -> bool {
63    !url.contains("://") && !url.starts_with("//")
64}
65
66/// One recorded DOM mutation. Nodes are referenced by their **creation ordinal**
67/// (the handle, assigned sequentially from 0 = `body`), so op-logs compare
68/// equal across tiers regardless of how each tier numbers its handles internally.
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub enum DomOp {
71    CreateElement {
72        node: u32,
73        tag: String,
74    },
75    CreateTextNode {
76        node: u32,
77        text: String,
78    },
79    SetText {
80        node: u32,
81        text: String,
82    },
83    SetHtml {
84        node: u32,
85        html: String,
86    },
87    ClearChildren {
88        node: u32,
89    },
90    SetAttribute {
91        node: u32,
92        name: String,
93        value: String,
94    },
95    AppendChild {
96        parent: u32,
97        child: u32,
98    },
99    SetValue {
100        node: u32,
101        value: String,
102    },
103    /// Attach an opaque host media stream (a `MediaStream` handle) to an element's `srcObject` —
104    /// the primitive that renders a peer's live video/audio in a `<video>`/`<audio>` tile.
105    SetStream {
106        node: u32,
107        stream: i64,
108    },
109    // --- WebRTC: the browser P2P leg. `peerConnection`/`dataChannel` mint opaque handles (an
110    // `RTCPeerConnection` / `RTCDataChannel` in the browser); the rest drive the SDP + ICE
111    // handshake and the DataChannel send.
112    //
113    // ★ THESE LAND IN THIS OP-LOG AND NOWHERE ELSE. This comment used to say they "lower to `rtc_*`
114    // host imports (real WebRTC in the browser, a mock in tests)" — there is no `rtc_*` import in
115    // the ABI, no mock, and nothing in `runtime.js`, so a reader was told a browser leg exists that
116    // does not. `wasm_host_abi_lock` writes the whole import surface down; `rtc_` appears nowhere
117    // in it. The ops are real on the native tiers and a browser page cannot reach them yet. ---
118    RtcPeerConnection {
119        pc: u32,
120    },
121    RtcDataChannel {
122        pc: u32,
123        channel: u32,
124        label: String,
125    },
126    RtcCreateOffer {
127        pc: u32,
128    },
129    RtcCreateAnswer {
130        pc: u32,
131    },
132    RtcSetLocal {
133        pc: u32,
134        sdp: String,
135    },
136    RtcSetRemote {
137        pc: u32,
138        sdp: String,
139    },
140    RtcAddIce {
141        pc: u32,
142        candidate: String,
143    },
144    RtcSend {
145        channel: u32,
146        bytes: u64,
147    },
148    AddEventListener {
149        node: u32,
150        event: String,
151    },
152    /// `setChecked(el, on)` — a checkbox's or radio's CHECKED state.
153    ///
154    /// Deliberately not a `SetAttribute`. In HTML `checked` is a PRESENCE, and in the DOM it is
155    /// two different things: the ATTRIBUTE is `defaultChecked` (what the box returns to on a form
156    /// reset) while the live state a visitor sees and a form submits is the PROPERTY. Writing the
157    /// attribute therefore leaves a box the page believes is checked drawn unchecked. Carrying it
158    /// as a boolean op means both tiers agree on presence and absence rather than on a string, and
159    /// `checked="false"` — which every browser reads as CHECKED — is unspellable.
160    SetChecked {
161        node: u32,
162        checked: bool,
163    },
164    /// ★ THIS LISTENER CANCELS THE BROWSER'S OWN ACTION for `event` on `node`.
165    ///
166    /// `on-submit.prevent={save}` means the page handles the submit and the browser must not ALSO
167    /// post the form — otherwise the request goes twice, once to the handler and once to the
168    /// server, which is the duplicate-submission bug every form library exists to avoid. It is a
169    /// property of the REGISTRATION, not of the event, so it is recorded where the listener is
170    /// attached rather than discovered when one fires.
171    PreventDefault {
172        node: u32,
173        event: String,
174    },
175    /// Detach a node from wherever it currently lives — the single-node counterpart to
176    /// `ClearChildren`, so a keyed diff drops exactly the rows it stopped rendering instead of
177    /// tearing down and rebuilding the whole parent.
178    RemoveNode {
179        node: u32,
180    },
181    /// Move `child` under `parent`, immediately before `before` — or last, when `before` is
182    /// `u32::MAX` (the same "no such element" sentinel documented in `patch.rs`; handle `0` is
183    /// `body` here, so it cannot double as the "append" marker). The op a keyed reorder's minimal
184    /// move set is built from: `child` keeps its identity, so a reused row keeps whatever focus,
185    /// selection or typed value it already carries.
186    InsertBefore {
187        parent: u32,
188        child: u32,
189        before: u32,
190    },
191    /// Remove an attribute a new render dropped — the deletion `SetAttribute` cannot express.
192    RemoveAttribute {
193        node: u32,
194        name: String,
195    },
196    /// Bind `node` to the next element under `parent` that the markup a [`DomOp::Hydrate`] opened
197    /// already holds, when its tag is `tag`; an element of another tag, or none, is a mismatch the
198    /// stream's apply counts, and `node` stays unbound.
199    Adopt {
200        parent: u32,
201        node: u32,
202        tag: String,
203    },
204    /// Bind `node` to its place in the run of text the markup holds under `parent` before the next
205    /// adopted element, holding `text`. The run is split along its claims when they spell it and
206    /// rebuilt when they do not: a text node carries nothing a visitor did to it.
207    AdoptText {
208        parent: u32,
209        node: u32,
210        text: String,
211    },
212    /// Register `event` on `node` with a bitmask of behavioural modifiers (`.prevent` / `.stop`
213    /// / `.once` / `.capture` / `.passive`) — the generalisation `AddEventListener` /
214    /// `PreventDefault` predate, carrying every modifier in one registration rather than one op
215    /// per behaviour.
216    Listen {
217        node: u32,
218        event: String,
219        flags: u8,
220    },
221    /// Remove a listener a new render dropped.
222    Unlisten {
223        node: u32,
224        event: String,
225    },
226    /// Detach `node` from its parent AND release the id of `node` and EVERY descendant — the
227    /// document-side half of heap reclamation, and ONE op per removed subtree root regardless of
228    /// its size: a 1,000-row list clear is 1,000 `FreeNode`s, not a `FreeNode` per node. The host
229    /// walks the (about to be detached) subtree once, deleting each id from its own node table,
230    /// rather than the emitter naming every descendant — a descendant has no `RemoveNode`/
231    /// `FreeNode` of its own; removing/freeing its ancestor is what removes/frees it.
232    FreeNode {
233        node: u32,
234    },
235    /// Open a hydration of `root`: the markup it holds is the page's first paint, drawn before the
236    /// program ran, and the stream's `Adopt`/`AdoptText` claim its nodes instead of creating them.
237    /// When the stream ends, whatever the markup holds that no claim bound is removed.
238    Hydrate {
239        root: u32,
240    },
241}
242
243/// One recorded navigation — the router's history op-log entry: a pushState (`replace: false`, a
244/// new history entry) or replaceState (`replace: true`, swap the current one) within the page, or a
245/// document load. Compares equal across tiers; the wasm `MockDom` records the same shape.
246#[derive(Clone, Debug, PartialEq, Eq)]
247pub enum RouteOp {
248    Navigate {
249        path: String,
250        replace: bool,
251    },
252    /// A document load at `path` (`location.assign`) — the page that asked is gone.
253    Load {
254        path: String,
255    },
256}
257
258#[derive(Clone, Debug, Default)]
259struct Node {
260    tag: String,
261    text: String,
262    /// Raw innerHTML set via `setHtml` — verbatim markup, rendered after `text`.
263    html: String,
264    value: String,
265    /// A checkbox/radio's live checked PROPERTY. Separate from `attrs` because it is a presence,
266    /// not a string: see [`DomOp::SetChecked`].
267    checked: bool,
268    attrs: BTreeMap<String, String>,
269    children: Vec<u32>,
270    is_text: bool,
271    /// Whether the control's value was last changed by the visitor's own editing — what makes
272    /// `minlength`/`maxlength` apply ([`Validity`]).
273    user_edited: bool,
274    /// Whether the visitor typed something the control cannot hold (a `type="number"` field given
275    /// text that is no number), so its value is empty — `badInput`.
276    bad_input: bool,
277}
278
279impl Node {
280    /// See [`focusable`].
281    fn focusable(&self) -> bool {
282        if self.is_text {
283            return false;
284        }
285        let has = |name: &str| self.attrs.contains_key(name);
286        let enabled = !has("disabled");
287        if has("tabindex") {
288            return true;
289        }
290        if self
291            .attrs
292            .get("contenteditable")
293            .is_some_and(|v| !v.eq_ignore_ascii_case("false"))
294        {
295            return true;
296        }
297        match self.tag.to_ascii_lowercase().as_str() {
298            "button" | "select" | "textarea" => enabled,
299            "input" => {
300                enabled
301                    && !self
302                        .attrs
303                        .get("type")
304                        .is_some_and(|t| t.eq_ignore_ascii_case("hidden"))
305            }
306            "a" | "area" => has("href"),
307            "iframe" | "summary" => true,
308            "audio" | "video" => has("controls"),
309            _ => false,
310        }
311    }
312}
313
314/// The selector engine reads the ONE parse the whole language shares: the static check that
315/// refuses a `When "#draft" is clicked:` naming an element no view renders reads the same
316/// grammar this answers against the live page, so they cannot drift apart.
317use logicaffeine_language::lgx::selector::{self, Compound};
318
319/// The mock DOM: a handle table of nodes + the ordered op-log. Handle 0 is always
320/// `body`, the render root.
321#[derive(Clone, Debug)]
322pub struct DomRecorder {
323    nodes: Vec<Node>,
324    /// The nodes a patch stream created, at `id - STREAM_ID_BASE`; `None` is an id free to rebind.
325    streamed: Vec<Option<Node>>,
326    log: Vec<DomOp>,
327    /// Registered handlers, `(node, event) → closure`. On the native tiers the
328    /// closure is a `RuntimeValue::Function` we later invoke to simulate an event.
329    listeners: Vec<(u32, String, RuntimeValue)>,
330    /// Every listener registration the page made, `(node, event) → flags` (the `LISTEN_*` bits) —
331    /// what [`dispatch_path`] walks. A closure the host can fire is not needed for a registration
332    /// to count: the page's own dispatcher answers for the node, and the browser needs only to
333    /// know that something listens there, and how.
334    registrations: BTreeMap<(u32, String), u8>,
335    /// The document's `<head>`, element by element — what a browser holds after the page's shell
336    /// arrived and the page merged its own `Head:` over it. `the title` reads its first `<title>`
337    /// and `Set the title to …` writes it, exactly as `document.title` does. Recorder state rather
338    /// than ops: the head is not painted, so it is not in the op-log the body is compared through.
339    head: Vec<HeadTag>,
340    /// What the page put on the clipboard, offered as a download, and raised as a notification.
341    ///
342    /// ★ RECORDED, NOT PERFORMED, ON A TIER WITH NO BROWSER — and recorded as a LIST for the two
343    /// that can happen more than once, because "did the page notify" and "how many times" are
344    /// different questions and a page that notifies twice is usually a bug. The clipboard holds one
345    /// thing by definition, so it is the last write.
346    clipboard: String,
347    downloads: Vec<(String, String)>,
348    notifications: Vec<(String, String)>,
349    /// Every frame the page published on a relay topic, `(topic, message)`, in order — see
350    /// [`publish`].
351    published: Vec<(String, String)>,
352    /// The element most recently brought into view (`Scroll to "#card".`) and the one whose text
353    /// was selected (`Select the text in "#draft".`), or the `None` handle.
354    ///
355    /// ★ RECORDER STATE, NOT AN OP. Neither is a change to the document — scrolling and selecting
356    /// are things a browser does TO a document that stays the same — so neither belongs in the
357    /// op-log the tiers are compared through. Held here so a test can still ask whether the page
358    /// asked, which is the only part any tier without a viewport can honestly answer.
359    scrolled_to: u32,
360    selected: u32,
361    /// The focused element (`Focus "#draft".`), or the `None` handle. Recorder state for the
362    /// same reason `title` is.
363    focused: u32,
364    /// Focus and blur the page's own element commands raised and the host has not delivered yet —
365    /// see [`take_raised_events`].
366    raised: Vec<(String, &'static str)>,
367    /// The file the drop being delivered carries, on a tier with no real transfer to read it from —
368    /// see [`set_dropped_file`]. Belongs to the document: a reload starts without one.
369    dropped_file: Option<DroppedFile>,
370    /// Whether the browser has stepped back through the history since the host last looked
371    /// ([`take_popped`]) — the `popstate` a `history.back()` raises once the step is taken.
372    popped: bool,
373    /// Whether the page asked the browser to load it again (`location.reload()`) — a request the
374    /// browser carries out once the running script is done ([`take_reload_request`]).
375    reload_requested: bool,
376    /// Where the page asked the browser to load another document (`location.assign`) — the last
377    /// ask wins, and the browser carries it out once the running script is done
378    /// ([`take_load_request`]); until then the page that asked is the one on screen.
379    load_requested: Option<String>,
380    /// The event currently being dispatched — what `eventValue`/`eventTarget` read.
381    current_event: Option<CurrentEvent>,
382    /// Outstanding fetches keyed by REQUEST ID (assigned sequentially from 1) —
383    /// the correlation handle that makes concurrent fetches unconfusable. Resolved
384    /// on command (never real IO) so callback order is deterministic across tiers.
385    fetches: BTreeMap<i64, PendingFetch>,
386    /// The next request id `fetchSend` mints.
387    next_fetch: i64,
388    /// Answers a test has arranged for requests it expects the page to send: `(method, url) →
389    /// (status, body)`. The sealed-world rule the rest of the suite follows — a page test must
390    /// not depend on a network, and a page whose data never arrives proves nothing.
391    ///
392    /// Keyed on method AND url together, because a stub that answered the wrong request is how a
393    /// test ends up proving something nobody asked for.
394    fetch_stubs: Vec<((String, String), (i64, String))>,
395    /// Delivered (or failed) responses, PER REQUEST — what `responseStatus`/
396    /// `responseBody`/`responseHeader`/`responseError` read. Once present, a
397    /// request is FINAL: later resolves/failures are suppressed (no double
398    /// delivery, and an aborted request can never deliver).
399    fetch_responses: BTreeMap<i64, FetchResponse>,
400    /// The request currently being delivered — what the `responseText` sugar reads.
401    current_fetch: Option<i64>,
402    /// The router — the location model shared by the tree-walker and the VM (the
403    /// wasm tier mirrors it host-side). `route_path` is `window.location.pathname`,
404    /// `route_query` its query string, `route_ops` the ordered pushState/replaceState
405    /// log, and `route_handlers` every LOGOS closure registered by `onRoute`, each of which a
406    /// popstate (back/forward) fires, in the order they were registered. Deterministic:
407    /// navigation records ops; a test `deliver_pop(path)` hands the handlers over (never a real
408    /// browser event).
409    route_path: String,
410    route_query: String,
411    route_ops: Vec<RouteOp>,
412    /// ★ THE BACK STACK, which `route_ops` is NOT. The op log records what a page DID (including
413    /// every `replaceState`, which creates no entry); going back needs to know where the page
414    /// would land, which is a different question. Index 0 is where the app booted, so `Go back.`
415    /// from the first page has nowhere to go and says so rather than inventing a path.
416    route_history: Vec<String>,
417    route_handlers: Vec<RuntimeValue>,
418    /// The hydration the stream being applied opened ([`DomOp::Hydrate`]), until the stream ends.
419    hydration: Option<Hydration>,
420    /// Ids that named a node a hydration renumbered: an adopted node answers to the id its stream
421    /// names for it, and the id this recorder minted for it before still reaches it, as a browser's
422    /// node table keeps every id it handed out for an element.
423    forwarded: BTreeMap<u32, u32>,
424}
425
426/// A stream adopting the markup a host node already holds — see [`DomOp::Hydrate`].
427#[derive(Clone, Debug, Default)]
428struct Hydration {
429    /// Every node whose children the stream claims — the root, then each adopted element — with the
430    /// child bound under it last, `None` before its first.
431    cursors: BTreeMap<u32, Option<u32>>,
432    /// The text claims waiting for the next element claim under the same parent, in stream order:
433    /// `(parent, node, text)`.
434    texts: Vec<(u32, u32, String)>,
435    /// Adopted elements whose content is raw markup (`SetHtml`), which no claim walks.
436    raw: std::collections::BTreeSet<u32>,
437    /// The attributes the stream wrote on each adopted element; the markup's others are dropped
438    /// when the stream ends — among them the `value` and `checked` a pre-render spells a control's
439    /// state with, which the stream writes as the properties they are.
440    written: BTreeMap<u32, std::collections::BTreeSet<String>>,
441    /// Element claims that found no element of their tag.
442    mismatches: i64,
443}
444
445#[derive(Clone, Debug)]
446struct CurrentEvent {
447    target: u32,
448    value: String,
449    /// The key that was pressed, for a keyboard event — what `eventKey` reads. Empty for
450    /// every other event, which is what a browser reports too.
451    key: String,
452}
453
454/// One outstanding `fetchSend` request as the recorder holds it.
455#[derive(Clone, Debug)]
456struct PendingFetch {
457    method: String,
458    url: String,
459    body: String,
460    headers: Vec<(String, String)>,
461    closure: RuntimeValue,
462    timeout_ms: i64,
463    aborted: bool,
464}
465
466/// A delivered response (or a structured transport failure: `status == 0` and
467/// `error` one of `"timeout"` | `"abort"` | `"toolarge"` | `"network: <detail>"`).
468#[derive(Clone, Debug, Default)]
469struct FetchResponse {
470    status: i64,
471    headers: Vec<(String, String)>,
472    body: String,
473    error: String,
474}
475
476/// One recorded fetch request — the tw≡VM parity surface for the request side
477/// (what was asked for, with what method/body/headers, and its control state).
478#[derive(Clone, Debug, PartialEq, Eq)]
479pub struct FetchRequest {
480    pub req: i64,
481    pub method: String,
482    pub url: String,
483    pub body: String,
484    pub headers: Vec<(String, String)>,
485    pub timeout_ms: i64,
486    pub aborted: bool,
487}
488
489impl Default for DomRecorder {
490    fn default() -> Self {
491        // Node 0 is `document.body`, the render root — pre-created so `body()` is a
492        // stable handle and the ordinals of user-created nodes start at 1.
493        DomRecorder {
494            head: Vec::new(),
495            focused: u32::MAX,
496            clipboard: String::new(),
497            downloads: Vec::new(),
498            notifications: Vec::new(),
499            published: Vec::new(),
500            scrolled_to: u32::MAX,
501            selected: u32::MAX,
502            raised: Vec::new(),
503            dropped_file: None,
504            popped: false,
505            reload_requested: false,
506            load_requested: None,
507            fetch_stubs: Vec::new(),
508            nodes: vec![Node {
509                tag: "body".to_string(),
510                ..Node::default()
511            }],
512            streamed: Vec::new(),
513            log: Vec::new(),
514            listeners: Vec::new(),
515            registrations: BTreeMap::new(),
516            current_event: None,
517            fetches: BTreeMap::new(),
518            next_fetch: 1,
519            fetch_responses: BTreeMap::new(),
520            current_fetch: None,
521            // The SPA root is the default location — a fresh run boots at "/".
522            route_path: "/".to_string(),
523            route_query: String::new(),
524            route_ops: Vec::new(),
525            route_history: vec!["/".to_string()],
526            route_handlers: Vec::new(),
527            hydration: None,
528            forwarded: BTreeMap::new(),
529        }
530    }
531}
532
533impl DomRecorder {
534    fn get(&self, h: u32) -> Option<&Node> {
535        let h = self.resolve(h);
536        match h.checked_sub(STREAM_ID_BASE) {
537            Some(i) => self.streamed.get(i as usize).and_then(Option::as_ref),
538            None => self.nodes.get(h as usize),
539        }
540    }
541    fn get_mut(&mut self, h: u32) -> Option<&mut Node> {
542        let h = self.resolve(h);
543        match h.checked_sub(STREAM_ID_BASE) {
544            Some(i) => self.streamed.get_mut(i as usize).and_then(Option::as_mut),
545            None => self.nodes.get_mut(h as usize),
546        }
547    }
548
549    /// The id the node `h` names answers to now — `h` itself, unless a hydration renumbered it.
550    fn resolve(&self, h: u32) -> u32 {
551        self.forwarded.get(&h).copied().unwrap_or(h)
552    }
553
554    fn body(&self) -> u32 {
555        0
556    }
557
558    /// Binds `node` to `fresh`: a stream id that is not live, or exactly the ordinal this recorder
559    /// would mint next — the one id below [`STREAM_ID_BASE`] a stream can name for a new node.
560    fn create_as(&mut self, node: u32, fresh: Node) -> Result<(), String> {
561        match node.checked_sub(STREAM_ID_BASE) {
562            Some(i) => {
563                let i = i as usize;
564                if self.streamed.len() <= i {
565                    self.streamed.resize_with(i + 1, || None);
566                }
567                if self.streamed[i].is_some() {
568                    return Err(format!(
569                        "the stream creates node {node}, which is still live"
570                    ));
571                }
572                self.streamed[i] = Some(fresh);
573            }
574            None if node as usize == self.nodes.len() => self.nodes.push(fresh),
575            None => {
576                return Err(format!(
577                    "the stream names node {node} for a new node, and this host mints its own ids \
578                     below {STREAM_ID_BASE}: the next is {}",
579                    self.nodes.len()
580                ))
581            }
582        }
583        Ok(())
584    }
585
586    fn create_element_as(&mut self, node: u32, tag: &str) -> Result<(), String> {
587        self.create_as(
588            node,
589            Node {
590                tag: tag.to_string(),
591                ..Node::default()
592            },
593        )?;
594        self.log.push(DomOp::CreateElement {
595            node,
596            tag: tag.to_string(),
597        });
598        Ok(())
599    }
600
601    fn create_text_node_as(&mut self, node: u32, text: &str) -> Result<(), String> {
602        self.create_as(
603            node,
604            Node {
605                text: text.to_string(),
606                is_text: true,
607                ..Node::default()
608            },
609        )?;
610        self.log.push(DomOp::CreateTextNode {
611            node,
612            text: text.to_string(),
613        });
614        Ok(())
615    }
616
617    fn create_element(&mut self, tag: &str) -> u32 {
618        let h = self.nodes.len() as u32;
619        self.nodes.push(Node {
620            tag: tag.to_string(),
621            ..Node::default()
622        });
623        self.log.push(DomOp::CreateElement {
624            node: h,
625            tag: tag.to_string(),
626        });
627        h
628    }
629
630    fn create_text_node(&mut self, text: &str) -> u32 {
631        let h = self.nodes.len() as u32;
632        self.nodes.push(Node {
633            text: text.to_string(),
634            is_text: true,
635            ..Node::default()
636        });
637        self.log.push(DomOp::CreateTextNode {
638            node: h,
639            text: text.to_string(),
640        });
641        h
642    }
643
644    fn set_text(&mut self, node: u32, text: &str) {
645        if let Some(n) = self.get_mut(node) {
646            n.text = text.to_string();
647        }
648        self.log.push(DomOp::SetText {
649            node,
650            text: text.to_string(),
651        });
652    }
653
654    fn set_html(&mut self, node: u32, html: &str) {
655        if self.adopting(node) {
656            self.hydration.as_mut().map(|h| h.raw.insert(node));
657        }
658        if let Some(n) = self.get_mut(node) {
659            n.html = html.to_string();
660        }
661        self.log.push(DomOp::SetHtml {
662            node,
663            html: html.to_string(),
664        });
665    }
666
667    /// Remove every child of `node` (and any raw innerHTML) — the immediate-mode
668    /// re-render primitive: the view clears its root, then rebuilds it.
669    fn clear_children(&mut self, node: u32) {
670        if let Some(n) = self.get_mut(node) {
671            n.children.clear();
672            n.html.clear();
673        }
674        self.log.push(DomOp::ClearChildren { node });
675    }
676
677    fn set_attribute(&mut self, node: u32, name: &str, value: &str) {
678        if let Some(written) = self
679            .hydration
680            .as_mut()
681            .and_then(|h| h.written.get_mut(&node))
682        {
683            written.insert(name.to_string());
684        }
685        if let Some(n) = self.get_mut(node) {
686            n.attrs.insert(name.to_string(), value.to_string());
687        }
688        self.log.push(DomOp::SetAttribute {
689            node,
690            name: name.to_string(),
691            value: value.to_string(),
692        });
693    }
694
695    fn append_child(&mut self, parent: u32, child: u32) {
696        let child = self.resolve(child);
697        if let Some(n) = self.get_mut(parent) {
698            n.children.push(child);
699        }
700        self.log.push(DomOp::AppendChild { parent, child });
701    }
702
703    fn set_value(&mut self, node: u32, value: &str) {
704        if self.adopting(node) {
705            if self.get(node).is_some_and(|n| n.user_edited) {
706                self.keep_the_visitors(node);
707                self.log.push(DomOp::SetValue {
708                    node,
709                    value: value.to_string(),
710                });
711                return;
712            }
713        }
714        if let Some(n) = self.get_mut(node) {
715            if n.value != value {
716                n.user_edited = false;
717                n.bad_input = false;
718            }
719            n.value = value.to_string();
720        }
721        self.log.push(DomOp::SetValue {
722            node,
723            value: value.to_string(),
724        });
725    }
726
727    /// The `<option>`s under a `<select>`, in document order, as `(value, selected-by-markup)` —
728    /// an option's value is its `value` attribute, or else its text.
729    fn options_of(&self, select: u32) -> Vec<(String, bool)> {
730        let mut out = Vec::new();
731        let mut stack: Vec<u32> = self
732            .get(select)
733            .map(|n| n.children.iter().rev().copied().collect())
734            .unwrap_or_default();
735        while let Some(h) = stack.pop() {
736            let Some(n) = self.get(h) else { continue };
737            if n.tag.eq_ignore_ascii_case("option") {
738                let value = n
739                    .attrs
740                    .get("value")
741                    .cloned()
742                    .unwrap_or_else(|| self.text_content(h));
743                out.push((value, n.attrs.contains_key("selected")));
744            } else {
745                stack.extend(n.children.iter().rev().copied());
746            }
747        }
748        out
749    }
750
751    /// The value a `<select>` shows before anyone chooses: its markup's `selected` option, or else
752    /// its first.
753    fn default_choice(&self, select: u32) -> Option<String> {
754        let options = self.options_of(select);
755        options
756            .iter()
757            .find(|(_, selected)| *selected)
758            .or(options.first())
759            .map(|(v, _)| v.clone())
760    }
761
762    /// The value of the option of `select` whose value — or else whose text — is `wanted`.
763    fn option_offering(&self, select: u32, wanted: &str) -> Option<String> {
764        if !self
765            .get(select)
766            .is_some_and(|n| n.tag.eq_ignore_ascii_case("select"))
767        {
768            return None;
769        }
770        let options = self.options_of(select);
771        if options.iter().any(|(v, _)| v == wanted) {
772            return Some(wanted.to_string());
773        }
774        let mut stack: Vec<u32> = self
775            .get(select)
776            .map(|n| n.children.clone())
777            .unwrap_or_default();
778        while let Some(h) = stack.pop() {
779            let Some(n) = self.get(h) else { continue };
780            if n.tag.eq_ignore_ascii_case("option") && self.text_content(h) == wanted {
781                return Some(
782                    n.attrs
783                        .get("value")
784                        .cloned()
785                        .unwrap_or_else(|| wanted.to_string()),
786                );
787            }
788            stack.extend(n.children.iter().copied());
789        }
790        None
791    }
792
793    fn set_checked(&mut self, node: u32, checked: bool) {
794        if self.adopting(node) {
795            if self
796                .get(node)
797                .is_some_and(|n| n.checked != n.attrs.contains_key("checked"))
798            {
799                self.keep_the_visitors(node);
800                self.log.push(DomOp::SetChecked { node, checked });
801                return;
802            }
803        }
804        if let Some(n) = self.get_mut(node) {
805            n.checked = checked;
806        }
807        self.log.push(DomOp::SetChecked { node, checked });
808    }
809
810    fn set_stream(&mut self, node: u32, stream: i64) {
811        self.log.push(DomOp::SetStream { node, stream });
812    }
813
814    fn rtc_peer_connection(&mut self) -> u32 {
815        let h = self.nodes.len() as u32;
816        self.nodes.push(Node::default());
817        self.log.push(DomOp::RtcPeerConnection { pc: h });
818        h
819    }
820
821    fn rtc_data_channel(&mut self, pc: u32, label: &str) -> u32 {
822        let h = self.nodes.len() as u32;
823        self.nodes.push(Node::default());
824        self.log.push(DomOp::RtcDataChannel {
825            pc,
826            channel: h,
827            label: label.to_string(),
828        });
829        h
830    }
831
832    fn rtc_create_offer(&mut self, pc: u32) {
833        self.log.push(DomOp::RtcCreateOffer { pc });
834    }
835
836    fn rtc_create_answer(&mut self, pc: u32) {
837        self.log.push(DomOp::RtcCreateAnswer { pc });
838    }
839
840    fn rtc_set_local(&mut self, pc: u32, sdp: &str) {
841        self.log.push(DomOp::RtcSetLocal {
842            pc,
843            sdp: sdp.to_string(),
844        });
845    }
846
847    fn rtc_set_remote(&mut self, pc: u32, sdp: &str) {
848        self.log.push(DomOp::RtcSetRemote {
849            pc,
850            sdp: sdp.to_string(),
851        });
852    }
853
854    fn rtc_add_ice(&mut self, pc: u32, candidate: &str) {
855        self.log.push(DomOp::RtcAddIce {
856            pc,
857            candidate: candidate.to_string(),
858        });
859    }
860
861    fn rtc_send(&mut self, channel: u32, bytes: u64) {
862        self.log.push(DomOp::RtcSend { channel, bytes });
863    }
864
865    fn get_text(&self, node: u32) -> String {
866        self.get(node).map(|n| n.text.clone()).unwrap_or_default()
867    }
868
869    fn get_value(&self, node: u32) -> String {
870        self.get(node).map(|n| n.value.clone()).unwrap_or_default()
871    }
872
873    fn get_checked(&self, node: u32) -> bool {
874        self.get(node).is_some_and(|n| n.checked)
875    }
876
877    /// Which toggle control, if any, this element is — what a CLICK on it means. A browser's
878    /// default action for a click on a checkbox is to flip `checked`; on a radio it is to select
879    /// it (a second click leaves it selected). Anything else has no such default, and a click on
880    /// it is only a click.
881    fn toggle_control(&self, node: u32) -> Option<ToggleControl> {
882        let n = self.get(node)?;
883        if n.tag != "input" {
884            return None;
885        }
886        match n.attrs.get("type").map(String::as_str) {
887            Some("checkbox") => Some(ToggleControl::Checkbox),
888            Some("radio") => Some(ToggleControl::Radio),
889            _ => None,
890        }
891    }
892
893    /// The element's TEXT CONTENT: its own text plus every descendant text node, in document
894    /// order — what a reader sees, and what `the text of "#n"` means. A hole renders as its own
895    /// text node, so an element's visible string is only ever the concatenation.
896    fn text_content(&self, node: u32) -> String {
897        let mut out = String::new();
898        fn go(dom: &DomRecorder, h: u32, out: &mut String) {
899            let Some(n) = dom.get(h) else { return };
900            out.push_str(&n.text);
901            for &c in &n.children {
902                go(dom, c, out);
903            }
904        }
905        go(self, node, &mut out);
906        out
907    }
908
909    /// Every node matching `selector`, in DOCUMENT order.
910    ///
911    /// The walk starts at the body root, so a node detached by a re-render is no longer in the
912    /// document and cannot match — exactly as in a browser. Scanning the node table instead
913    /// would keep handing back the previous render's stale elements.
914    fn query_selector_all(&self, selector: &str) -> Vec<u32> {
915        if let Some(place) = selector.strip_prefix(PLACE) {
916            return self.at_place(place).into_iter().collect();
917        }
918        let Some(parts) = selector::parse(selector) else {
919            return Vec::new();
920        };
921        let mut out = Vec::new();
922        let mut ancestors = Vec::new();
923        self.collect(0, &parts, &mut ancestors, &mut out);
924        out
925    }
926
927    /// Depth-first walk collecting matches. `ancestors` is the chain from the root, which is
928    /// what makes the descendant combinator (`.card h1`) mean what it says.
929    fn collect(&self, h: u32, parts: &[Compound], ancestors: &mut Vec<u32>, out: &mut Vec<u32>) {
930        let Some(n) = self.get(h) else { return };
931        if !n.is_text && self.matches_chain(h, parts, ancestors) {
932            out.push(h);
933        }
934        ancestors.push(h);
935        for &c in &n.children {
936            self.collect(c, parts, ancestors, out);
937        }
938        ancestors.pop();
939    }
940
941    /// The chain of ancestors from the root down to (but not including) `target`.
942    fn ancestors_of(&self, target: u32) -> Option<Vec<u32>> {
943        fn walk(d: &DomRecorder, target: u32, h: u32, path: &mut Vec<u32>) -> bool {
944            if h == target {
945                return true;
946            }
947            let Some(n) = d.get(h) else { return false };
948            path.push(h);
949            for &c in &n.children {
950                if walk(d, target, c, path) {
951                    return true;
952                }
953            }
954            path.pop();
955            false
956        }
957        let mut path = Vec::new();
958        walk(self, target, 0, &mut path).then_some(path)
959    }
960
961    /// Where `target` stands in the page — its child index under each ancestor from `body` down —
962    /// and its tag, or `None` when it is not an attached element.
963    fn place_of(&self, target: u32) -> Option<(Vec<usize>, String)> {
964        let node = self.get(target).filter(|n| !n.is_text && target != 0)?;
965        let chain = self.ancestors_of(target)?;
966        let mut indices = Vec::with_capacity(chain.len());
967        for (i, &parent) in chain.iter().enumerate() {
968            let child = chain.get(i + 1).copied().unwrap_or(target);
969            indices.push(
970                self.get(parent)?
971                    .children
972                    .iter()
973                    .position(|&c| c == child)?,
974            );
975        }
976        Some((indices, node.tag.to_ascii_lowercase()))
977    }
978
979    /// The element of `tag` standing at `indices` (child indices from `body` down), if any.
980    fn element_at(&self, indices: &[usize], tag: &str) -> Option<u32> {
981        let mut at = 0u32;
982        for &i in indices {
983            at = *self.get(at)?.children.get(i)?;
984        }
985        self.get(at)
986            .filter(|n| !n.is_text && n.tag.eq_ignore_ascii_case(tag))
987            .map(|_| at)
988    }
989
990    /// A place-form [`node_address`] (without its mark) resolved: `1/0/3:button`.
991    fn at_place(&self, place: &str) -> Option<u32> {
992        let (path, tag) = place.rsplit_once(':')?;
993        let indices: Vec<usize> = match path {
994            "" => Vec::new(),
995            path => path
996                .split('/')
997                .map(str::parse)
998                .collect::<Result<_, _>>()
999                .ok()?,
1000        };
1001        self.element_at(&indices, tag)
1002    }
1003
1004    /// A focused element no longer in the document took focus with it, as a browser's focus fixup
1005    /// leaves nothing focused once the focused node is gone.
1006    fn drop_detached_focus(&mut self) {
1007        if self.focused != u32::MAX && self.ancestors_of(self.focused).is_none() {
1008            self.focused = u32::MAX;
1009        }
1010    }
1011
1012    /// Whether `inner` is `outer` or lies inside it.
1013    fn holds(&self, outer: u32, inner: u32) -> bool {
1014        let mut stack = vec![outer];
1015        while let Some(h) = stack.pop() {
1016            if h == inner {
1017                return true;
1018            }
1019            if let Some(n) = self.get(h) {
1020                stack.extend(n.children.iter().copied());
1021            }
1022        }
1023        false
1024    }
1025
1026    /// `node` is leaving where it stands: when focus is on it or inside it, focus leaves the page, as
1027    /// a browser's focus fixup takes it — a move is a removal and an insertion, so moving the
1028    /// focused element loses focus too — and nothing a later paint builds takes it back. The
1029    /// browser's `blur` on the way out reaches no handler: the host has already dropped the
1030    /// element's listeners.
1031    fn leaving(&mut self, node: u32) {
1032        if self.focused != u32::MAX && self.holds(node, self.focused) {
1033            self.focused = u32::MAX;
1034        }
1035    }
1036
1037    /// Whether a visitor would SEE `target`.
1038    ///
1039    /// ★ THIS USED TO BE `query_selector(sel) >= 0` — present in the document — and `is hidden`
1040    /// its negation. So an element that exists and is not shown answered "visible", which makes
1041    /// the one question a page test asks about a toggle unable to fail: `hidden={flag}` and
1042    /// `class:hidden={flag}` are LGX's own ways of showing and hiding, and neither changes whether
1043    /// the element is in the document. `Expect "#error" to be visible.` passed in both states.
1044    ///
1045    /// Decided: the `hidden` attribute, an inline `display:none`/`visibility:hidden`, a `Style:`
1046    /// rule declaring either whose selector matches, and all of those on any ANCESTOR — hiding a
1047    /// container hides what is inside it. Not decided: anything needing a full cascade. This is a
1048    /// page test's question, not a rendering engine, and the boundary is written down rather than
1049    /// implied.
1050    fn shown(&self, target: u32) -> bool {
1051        let Some(chain) = self.ancestors_of(target) else {
1052            return false;
1053        };
1054        let hiding = self.hiding_rules();
1055        for h in chain.iter().copied().chain(std::iter::once(target)) {
1056            if h == 0 {
1057                continue;
1058            }
1059            let Some(n) = self.get(h) else { return false };
1060            if n.is_text {
1061                continue;
1062            }
1063            if n.attrs.contains_key("hidden") {
1064                return false;
1065            }
1066            if n.attrs.get("style").is_some_and(|v| declares_hidden(v)) {
1067                return false;
1068            }
1069            if hiding.iter().any(|parts| {
1070                self.ancestors_of(h)
1071                    .is_some_and(|a| self.matches_chain(h, parts, &a))
1072            }) {
1073                return false;
1074            }
1075        }
1076        true
1077    }
1078
1079    /// Every selector in the document's stylesheets whose rule declares an element invisible.
1080    ///
1081    /// This is what `class:hidden={flag}` toggles — the class moves, the rule does the hiding — so
1082    /// leaving it out would leave the framework's own idiom for showing and hiding uncovered.
1083    fn hiding_rules(&self) -> Vec<Vec<Compound>> {
1084        let mut out = Vec::new();
1085        let mut sheets = Vec::new();
1086        self.collect_stylesheets(0, &mut sheets);
1087        for css in sheets {
1088            let Ok(items) = logicaffeine_language::lgx::css::parse_items(&css) else {
1089                continue;
1090            };
1091            for item in items {
1092                let logicaffeine_language::lgx::css::CssItem::Rule { prelude, body } = item else {
1093                    continue;
1094                };
1095                if !declares_hidden(&body) {
1096                    continue;
1097                }
1098                // A stylesheet may legitimately group selectors with a comma; the page-query
1099                // engine refuses one, so the group is split here rather than handed to it.
1100                for one in prelude.split(',') {
1101                    if let Some(parts) = selector::parse(one.trim()) {
1102                        out.push(parts);
1103                    }
1104                }
1105            }
1106        }
1107        out
1108    }
1109
1110    /// Every `<style>` sheet under `h`, each its element's text content — the sheet a browser
1111    /// reads, however its text is split into nodes.
1112    fn collect_stylesheets(&self, h: u32, out: &mut Vec<String>) {
1113        let Some(n) = self.get(h) else { return };
1114        if n.tag == "style" {
1115            out.push(self.text_content(h));
1116        }
1117        for &c in &n.children {
1118            self.collect_stylesheets(c, out);
1119        }
1120    }
1121
1122    /// Whether `h` matches the last compound and some ancestor chain matches the ones before it.
1123    fn matches_chain(&self, h: u32, parts: &[Compound], ancestors: &[u32]) -> bool {
1124        let Some((last, rest)) = parts.split_last() else {
1125            return false;
1126        };
1127        if !self.matches_compound(h, last) {
1128            return false;
1129        }
1130        // Walk the required ancestors right-to-left; each must appear, in order, above `h`.
1131        let mut i = ancestors.len();
1132        for want in rest.iter().rev() {
1133            let mut found = false;
1134            while i > 0 {
1135                i -= 1;
1136                if self.matches_compound(ancestors[i], want) {
1137                    found = true;
1138                    break;
1139                }
1140            }
1141            if !found {
1142                return false;
1143            }
1144        }
1145        true
1146    }
1147
1148    fn matches_compound(&self, h: u32, c: &Compound) -> bool {
1149        let Some(n) = self.get(h) else { return false };
1150        if let Some(tag) = &c.tag {
1151            if &n.tag != tag {
1152                return false;
1153            }
1154        }
1155        if let Some(id) = &c.id {
1156            if n.attrs.get("id") != Some(id) {
1157                return false;
1158            }
1159        }
1160        for want in &c.classes {
1161            let has = n
1162                .attrs
1163                .get("class")
1164                .is_some_and(|v| v.split_whitespace().any(|k| k == want));
1165            if !has {
1166                return false;
1167            }
1168        }
1169        for (name, value) in &c.attrs {
1170            match (n.attrs.get(name), value) {
1171                (None, _) => return false,
1172                (Some(_), None) => {}
1173                (Some(have), Some(want)) if have == want => {}
1174                _ => return false,
1175            }
1176        }
1177        true
1178    }
1179
1180    /// The first match, or the `None` handle (`u32::MAX`).
1181    fn query_selector(&self, selector: &str) -> u32 {
1182        self.query_selector_all(selector)
1183            .first()
1184            .copied()
1185            .unwrap_or(u32::MAX)
1186    }
1187
1188    fn add_event_listener(&mut self, node: u32, event: &str, closure: RuntimeValue) {
1189        self.register(node, event);
1190        self.listeners.push((node, event.to_string(), closure));
1191        self.log.push(DomOp::AddEventListener {
1192            node,
1193            event: event.to_string(),
1194        });
1195    }
1196
1197    fn listener_for(&self, node: u32, event: &str) -> Option<RuntimeValue> {
1198        self.listeners
1199            .iter()
1200            .find(|(n, e, _)| *n == node && e == event)
1201            .map(|(_, _, c)| c.clone())
1202    }
1203
1204    // --- The correlated fetch engine (shared by the tree-walker and the VM; the
1205    // wasm MockDom and the browser host mirror it host-side). ---
1206
1207    /// Register a request and mint its id. Contract violations (an oversized body,
1208    /// a header outside the allowlist) FAIL the request immediately — it is final
1209    /// before it would ever touch the network.
1210    fn fetch_send(
1211        &mut self,
1212        method: &str,
1213        url: &str,
1214        body: &str,
1215        headers: Vec<(String, String)>,
1216        closure: RuntimeValue,
1217    ) -> i64 {
1218        let headers = self.request_headers(method, url, headers);
1219        let req = self.next_fetch;
1220        self.next_fetch += 1;
1221        let mut prefail: Option<String> = None;
1222        if body.len() > FETCH_REQUEST_BODY_CAP {
1223            prefail = Some("toolarge".to_string());
1224        } else if let Some((name, _)) = headers.iter().find(|(n, _)| {
1225            !FETCH_ALLOWED_REQUEST_HEADERS.contains(&n.to_ascii_lowercase().as_str())
1226        }) {
1227            prefail = Some(format!(
1228                "network: header not allowed: {}",
1229                name.to_ascii_lowercase()
1230            ));
1231        }
1232        self.fetches.insert(
1233            req,
1234            PendingFetch {
1235                method: method.to_string(),
1236                url: url.to_string(),
1237                body: body.to_string(),
1238                headers,
1239                closure,
1240                timeout_ms: 0,
1241                aborted: false,
1242            },
1243        );
1244        if let Some(error) = prefail {
1245            self.fetch_responses.insert(
1246                req,
1247                FetchResponse {
1248                    error,
1249                    ..FetchResponse::default()
1250                },
1251            );
1252        }
1253        req
1254    }
1255
1256    /// The headers a request goes out with: the page's own, and — for a state-changing request
1257    /// ([`changes_state`]) to the page's own server ([`to_own_server`]) that sets none — the CSRF
1258    /// token the document's `<meta name="csrf-token">` holds. `runtime.js`'s `requestHeaders` is the
1259    /// same rule in a browser.
1260    fn request_headers(
1261        &self,
1262        method: &str,
1263        url: &str,
1264        mut headers: Vec<(String, String)>,
1265    ) -> Vec<(String, String)> {
1266        let header = logicaffeine_language::parser::lgx_lower::CSRF_HEADER;
1267        let sets_one = headers.iter().any(|(n, _)| n.eq_ignore_ascii_case(header));
1268        if !changes_state(method) || !to_own_server(url) || sets_one {
1269            return headers;
1270        }
1271        let token = self.head.iter().find(|t| {
1272            t.tag == "meta"
1273                && t.attrs
1274                    .get("name")
1275                    .is_some_and(|n| n.eq_ignore_ascii_case(CSRF_META_NAME))
1276        });
1277        if let Some(token) = token.and_then(|t| t.attrs.get("content")) {
1278            headers.push((header.to_string(), token.clone()));
1279        }
1280        headers
1281    }
1282
1283    /// Abort a request: if nothing was delivered yet it becomes FINAL with the
1284    /// `"abort"` error, so a late network response can never fire its callback.
1285    fn fetch_abort(&mut self, req: i64) {
1286        if let Some(p) = self.fetches.get_mut(&req) {
1287            p.aborted = true;
1288        }
1289        self.fetch_responses
1290            .entry(req)
1291            .or_insert_with(|| FetchResponse {
1292                error: "abort".to_string(),
1293                ..FetchResponse::default()
1294            });
1295    }
1296
1297    /// Arm (or with `0` clear) the request's timeout. The recorder holds the value;
1298    /// firing is the driver's job (`fail_fetch(req, "timeout")` — the browser host
1299    /// arms a real timer that aborts the underlying fetch).
1300    fn fetch_set_timeout(&mut self, req: i64, millis: i64) {
1301        if let Some(p) = self.fetches.get_mut(&req) {
1302            p.timeout_ms = millis.max(0);
1303        }
1304    }
1305
1306    /// Deliver a response. `None` if the request is unknown or already FINAL
1307    /// (aborted / failed / delivered — no double delivery, ever). An oversized
1308    /// body is converted into a delivered `"toolarge"` failure: the callback
1309    /// still runs, and reads the error instead of a truncated body.
1310    fn fetch_resolve(
1311        &mut self,
1312        req: i64,
1313        status: i64,
1314        headers: &[(String, String)],
1315        body: &str,
1316    ) -> Option<RuntimeValue> {
1317        if self.fetch_responses.contains_key(&req) {
1318            return None;
1319        }
1320        let closure = self.fetches.get(&req).map(|p| p.closure.clone())?;
1321        let response = if body.len() > FETCH_RESPONSE_BODY_CAP {
1322            FetchResponse {
1323                error: "toolarge".to_string(),
1324                ..FetchResponse::default()
1325            }
1326        } else {
1327            FetchResponse {
1328                status,
1329                headers: headers.to_vec(),
1330                body: body.to_string(),
1331                error: String::new(),
1332            }
1333        };
1334        self.fetch_responses.insert(req, response);
1335        self.current_fetch = Some(req);
1336        Some(closure)
1337    }
1338
1339    /// Deliver a structured transport failure (`"timeout"` | `"abort"` |
1340    /// `"network: <detail>"` | `"toolarge"`). Same finality rule as
1341    /// [`Self::fetch_resolve`].
1342    fn fetch_fail(&mut self, req: i64, error: &str) -> Option<RuntimeValue> {
1343        if self.fetch_responses.contains_key(&req) {
1344            return None;
1345        }
1346        let closure = self.fetches.get(&req).map(|p| p.closure.clone())?;
1347        self.fetch_responses.insert(
1348            req,
1349            FetchResponse {
1350                error: error.to_string(),
1351                ..FetchResponse::default()
1352            },
1353        );
1354        self.current_fetch = Some(req);
1355        Some(closure)
1356    }
1357
1358    fn fetch_response(&self, req: i64) -> Option<&FetchResponse> {
1359        self.fetch_responses.get(&req)
1360    }
1361
1362    fn fetch_requests(&self) -> Vec<FetchRequest> {
1363        self.fetches
1364            .iter()
1365            .map(|(&req, p)| FetchRequest {
1366                req,
1367                method: p.method.clone(),
1368                url: p.url.clone(),
1369                body: p.body.clone(),
1370                headers: p.headers.clone(),
1371                timeout_ms: p.timeout_ms,
1372                aborted: p.aborted,
1373            })
1374            .collect()
1375    }
1376
1377    // --- The router (shared by the tree-walker and the VM; the wasm MockDom and the
1378    // browser host mirror it). ---
1379
1380    /// Record a navigation and move the current path. `replace` is the pushState
1381    /// (a new history entry) vs replaceState (swap the current one) distinction.
1382    fn navigate(&mut self, path: &str, replace: bool) {
1383        self.route_ops.push(RouteOp::Navigate {
1384            path: path.to_string(),
1385            replace,
1386        });
1387        self.route_path = path.to_string();
1388        // pushState adds an entry; replaceState swaps the one you are standing on. That is the
1389        // whole difference between them, and it is what decides where Back lands.
1390        if replace {
1391            let last = self.route_history.len() - 1;
1392            self.route_history[last] = path.to_string();
1393        } else {
1394            self.route_history.push(path.to_string());
1395        }
1396    }
1397
1398    /// Step back one history entry, answering the path landed on — `None` at the first entry,
1399    /// where a browser's Back button leaves the page alone.
1400    fn go_back(&mut self) -> Option<String> {
1401        if self.route_history.len() <= 1 {
1402            return None;
1403        }
1404        self.route_history.pop();
1405        let path = self
1406            .route_history
1407            .last()
1408            .cloned()
1409            .unwrap_or_else(|| "/".to_string());
1410        self.route_path = path.clone();
1411        self.popped = true;
1412        Some(path)
1413    }
1414
1415    /// A query param by name (`""` when absent — never a trap) via a tiny `k=v&k=v`
1416    /// scan of the current query string. The SAME parse the browser host uses.
1417    fn route_query_value(&self, name: &str) -> String {
1418        for pair in self.route_query.split('&') {
1419            if let Some((k, v)) = pair.split_once('=') {
1420                if k == name {
1421                    return v.to_string();
1422                }
1423            }
1424        }
1425        String::new()
1426    }
1427
1428    /// A deterministic serialization of the tree rooted at `body` — the render the
1429    /// parity assertion compares alongside the op-log.
1430    fn render(&self) -> String {
1431        let mut out = String::new();
1432        self.render_node(0, &mut out, Spelling::Recorded);
1433        out
1434    }
1435
1436    fn render_served(&self) -> String {
1437        let mut out = String::new();
1438        self.render_node(0, &mut out, Spelling::Served { chosen: None });
1439        out
1440    }
1441
1442    /// The value an `<option>` submits: its `value` attribute, or else its text.
1443    fn option_value(&self, h: u32) -> String {
1444        self.get(h)
1445            .and_then(|n| n.attrs.get("value").cloned())
1446            .unwrap_or_else(|| self.text_content(h))
1447    }
1448
1449    fn render_node(&self, h: u32, out: &mut String, spelling: Spelling) {
1450        let Some(n) = self.get(h) else { return };
1451        if n.is_text {
1452            escape_text(&n.text, out);
1453            return;
1454        }
1455        let served = matches!(spelling, Spelling::Served { .. });
1456        let option = n.tag.eq_ignore_ascii_case("option");
1457        let chosen = match spelling {
1458            Spelling::Served { chosen: Some(v) } if option => Some(self.option_value(h) == v),
1459            _ => None,
1460        };
1461        out.push('<');
1462        out.push_str(&n.tag);
1463        for (k, v) in &n.attrs {
1464            if chosen.is_some() && k.eq_ignore_ascii_case("selected") {
1465                continue;
1466            }
1467            out.push_str(&format!(" {k}=\""));
1468            escape_attr(v, out);
1469            out.push('"');
1470        }
1471        if chosen == Some(true) {
1472            out.push_str(" selected");
1473        }
1474        if n.checked {
1475            // Bare, because it is a presence. `checked="false"` is CHECKED in every browser
1476            // there is, so the only honest encoding of false is nothing at all.
1477            out.push_str(" checked");
1478        }
1479        // A `<select>`'s value is written only where it differs from the option its markup already
1480        // selects — the one rule a browser's document says it by, since the markup alone shows that
1481        // choice; every other control's value is written whenever it holds one.
1482        let select = n.tag.eq_ignore_ascii_case("select");
1483        let textarea = n.tag.eq_ignore_ascii_case("textarea");
1484        let shown = match select {
1485            true => self.default_choice(h).as_deref() != Some(n.value.as_str()),
1486            false => true,
1487        };
1488        // Served, a control's state is spelled the way HTML's parser reads it back: a `<select>`'s
1489        // value as the `selected` option, a `<textarea>`'s as its content.
1490        if shown && !n.value.is_empty() && !(served && (select || textarea)) {
1491            out.push_str(" value=\"");
1492            escape_attr(&n.value, out);
1493            out.push('"');
1494        }
1495        out.push('>');
1496        let inner = match spelling {
1497            Spelling::Served { .. } if select && shown && !n.value.is_empty() => Spelling::Served {
1498                chosen: Some(n.value.as_str()),
1499            },
1500            other => other,
1501        };
1502        // ★ A VOID ELEMENT HAS NO CLOSING TAG. `<input>`, `<br>`, `<img>` and their siblings hold
1503        // no content, and HTML's own parser treats a `</input>` as a stray tag — dropping it, or
1504        // (the `<br></br>` case) reading it as a SECOND element. This serializer closed everything
1505        // it wrote, so `largo build`'s pre-rendered page — the markup a crawler and a scripts-off
1506        // reader actually receive — served malformed HTML for every form on it. The server-side
1507        // `-> View` walk already consulted the same list, so the two renderers of one view
1508        // disagreed about the same element.
1509        if logicaffeine_language::lgx::html::is_void(&n.tag) {
1510            return;
1511        }
1512        // A raw-text element's content — `<style>`, `<script>` — is not markup: HTML's parser reads
1513        // it verbatim, so it is written verbatim, whether the element holds it as its own text or
1514        // as a text child. Escaped, `.list > li` would reach the browser as `.list &gt; li`, which
1515        // is no selector.
1516        let raw = logicaffeine_language::lgx::html::is_raw_text(&n.tag);
1517        // HTML's parser drops one newline straight after `<pre>`, `<listing>` and `<textarea>`, so
1518        // served content that begins with one is given a second for it to drop.
1519        let drops_newline = served
1520            && ["pre", "listing", "textarea"]
1521                .iter()
1522                .any(|t| n.tag.eq_ignore_ascii_case(t));
1523        if served && textarea && !n.value.is_empty() {
1524            if drops_newline && n.value.starts_with('\n') {
1525                out.push('\n');
1526            }
1527            escape_text(&n.value, out);
1528            out.push_str(&format!("</{}>", n.tag));
1529            return;
1530        }
1531        let leading = if n.text.is_empty() {
1532            n.children
1533                .first()
1534                .and_then(|&c| self.get(c))
1535                .filter(|t| t.is_text)
1536                .map(|t| t.text.as_str())
1537        } else {
1538            Some(n.text.as_str())
1539        };
1540        if drops_newline && leading.is_some_and(|t| t.starts_with('\n')) {
1541            out.push('\n');
1542        }
1543        if raw {
1544            out.push_str(&n.text);
1545        } else {
1546            escape_text(&n.text, out);
1547        }
1548        out.push_str(&n.html);
1549        for &c in &n.children {
1550            match self.get(c) {
1551                Some(t) if raw && t.is_text => out.push_str(&t.text),
1552                _ => self.render_node(c, out, inner),
1553            }
1554        }
1555        out.push_str(&format!("</{}>", n.tag));
1556    }
1557}
1558
1559/// How [`DomRecorder::render_node`] spells a document: as the recorder holds it — every tier's
1560/// comparison surface — or as a browser must be sent it to build the same page.
1561#[derive(Clone, Copy)]
1562enum Spelling<'v> {
1563    Recorded,
1564    /// `chosen`: the value of the `<select>` being written, when it is not the option its markup
1565    /// selects.
1566    Served {
1567        chosen: Option<&'v str>,
1568    },
1569}
1570
1571/// Escape text content so a value can never be read as markup — the guarantee that makes a
1572/// `{hole}` safe to fill with anything a user typed.
1573/// Whether a block of CSS declarations makes an element invisible.
1574///
1575/// `display:none` removes it from the layout; `visibility:hidden` keeps its space and paints
1576/// nothing. A visitor cannot see either, which is the question being asked.
1577fn declares_hidden(css: &str) -> bool {
1578    let flat: String = css
1579        .chars()
1580        .filter(|c| !c.is_whitespace())
1581        .collect::<String>()
1582        .to_ascii_lowercase();
1583    flat.contains("display:none") || flat.contains("visibility:hidden")
1584}
1585
1586fn escape_text(s: &str, out: &mut String) {
1587    out.push_str(&logicaffeine_language::lgx::html::escape_text(s));
1588}
1589
1590/// Escape an attribute value: the text escapes plus the quote that delimits it.
1591fn escape_attr(s: &str, out: &mut String) {
1592    out.push_str(&logicaffeine_language::lgx::html::escape_attr(s));
1593}
1594
1595thread_local! {
1596    /// The DOM the current native-tier run is building. Reset per run via [`reset`].
1597    static DOM: RefCell<DomRecorder> = RefCell::new(DomRecorder::default());
1598    /// The head of the shell this world's page is served in ([`arrive`]) — what every fresh load
1599    /// of the page starts from. Empty when no shell was named: a run that is not a page load.
1600    static SHELL: RefCell<Vec<HeadTag>> = const { RefCell::new(Vec::new()) };
1601}
1602
1603/// Start a fresh DOM (call before each tier's run so op-logs are comparable) — a fresh load of the
1604/// page: its head is the shell's again, whatever the last load merged into it.
1605pub fn reset() {
1606    DOM.with(|d| {
1607        *d.borrow_mut() = DomRecorder {
1608            head: SHELL.with(|s| s.borrow().clone()),
1609            ..DomRecorder::default()
1610        }
1611    });
1612    // The declared head belongs to the document being built, so it is reset with it. Left
1613    // behind, the previous page's title would ride onto the next one — and the CLI
1614    // renders every page of a site in one process.
1615    HEAD_HTML.with(|h| *h.borrow_mut() = None);
1616}
1617
1618/// `Reload the page.` as a browser reloads it: a fresh document loaded from the SAME server at the
1619/// SAME address, arriving in the same shell (`DomRecorder::next_document`).
1620pub fn reload() {
1621    let shell = SHELL.with(|s| s.borrow().clone());
1622    DOM.with(|d| {
1623        let mut d = d.borrow_mut();
1624        let before = std::mem::take(&mut *d);
1625        *d = before.next_document(shell);
1626    });
1627    HEAD_HTML.with(|h| *h.borrow_mut() = None);
1628}
1629
1630/// Another document loads at `url` — a link followed, or a [`request_load`] carried out. The page
1631/// that asked is gone; the location and the back stack move to `url`; the load is the first thing
1632/// the new document's navigation log records ([`RouteOp::Load`]); and the document is empty, since
1633/// what `url` serves is another page's content, outside the page under test. The test's own
1634/// answers for requests survive, as they do across a reload.
1635pub fn assign_location(url: &str) {
1636    DOM.with(|d| {
1637        let mut d = d.borrow_mut();
1638        let before = std::mem::take(&mut *d);
1639        *d = before.next_document(Vec::new());
1640    });
1641    HEAD_HTML.with(|h| *h.borrow_mut() = None);
1642    move_location(url);
1643    DOM.with(|d| {
1644        d.borrow_mut().route_ops.push(RouteOp::Load {
1645            path: url.to_string(),
1646        })
1647    });
1648}
1649
1650/// The browser moves to `url` to load a document there: the location, its query string and the
1651/// back stack move.
1652fn move_location(url: &str) {
1653    DOM.with(|d| {
1654        let mut d = d.borrow_mut();
1655        d.route_history.push(url.to_string());
1656        d.route_path = url.to_string();
1657        d.route_query = url
1658            .split_once('?')
1659            .map(|(_, q)| q.split('#').next().unwrap_or("").to_string())
1660            .unwrap_or_default();
1661    });
1662}
1663
1664/// A form the browser submitted itself (a `POST` no listener cancelled) is answered with a new
1665/// document: the browser is at `url`, the address it posted to, and the document is the response.
1666/// The document is the one a browser builds for the response's type: a `text/html` response is
1667/// the page it holds — its `<head>` and `<body>`, or the markup as the body when it is a fragment; a
1668/// JSON response is shown in the browser's JSON viewer, the raw text in a preformatted block; any
1669/// other response is shown as plain text. The test's answers for requests survive, as they survive
1670/// a reload.
1671pub fn follow_response(url: &str, content_type: &str, body: &str) {
1672    let media = content_type
1673        .split(';')
1674        .next()
1675        .unwrap_or("")
1676        .trim()
1677        .to_ascii_lowercase();
1678    let lower = body.to_ascii_lowercase();
1679    let section = |tag: &str| {
1680        let open = lower
1681            .find(&format!("<{tag}"))
1682            .and_then(|at| lower[at..].find('>').map(|e| at + e + 1))?;
1683        let close = lower.find(&format!("</{tag}>")).filter(|c| *c >= open)?;
1684        Some(body[open..close].to_string())
1685    };
1686    let head = match media.as_str() {
1687        "text/html" => section("head").map(|h| parse_head(&h)).unwrap_or_default(),
1688        "application/json" => {
1689            parse_head(r#"<meta name="color-scheme" content="light dark"><meta charset="utf-8">"#)
1690        }
1691        _ => parse_head(r#"<meta name="color-scheme" content="light dark">"#),
1692    };
1693    DOM.with(|d| {
1694        let mut d = d.borrow_mut();
1695        let before = std::mem::take(&mut *d);
1696        *d = before.next_document(head);
1697    });
1698    HEAD_HTML.with(|h| *h.borrow_mut() = None);
1699    move_location(url);
1700    let root = self::body();
1701    let preformatted = |style: Option<&str>| {
1702        let pre = create_element("pre");
1703        if let Some(style) = style {
1704            set_attribute(pre, "style", style);
1705        }
1706        let text = create_text_node(body);
1707        append_child(pre, text);
1708        append_child(root, pre);
1709    };
1710    match media.as_str() {
1711        "text/html" => set_html(root, section("body").as_deref().unwrap_or(body).trim()),
1712        "application/json" => {
1713            preformatted(None);
1714            let viewer = create_element("div");
1715            set_attribute(viewer, "class", "json-formatter-container");
1716            append_child(root, viewer);
1717        }
1718        _ => preformatted(Some("word-wrap: break-word; white-space: pre-wrap;")),
1719    }
1720}
1721
1722/// `assignLocation(url)` — `location.assign`: ask the browser to load the document at `url` once
1723/// the running script is done. Everything the page draws until then is drawn into the page that
1724/// asked, which the browser keeps on screen until the new document replaces it; a second ask
1725/// before then replaces the first.
1726pub fn request_load(url: &str) {
1727    DOM.with(|d| d.borrow_mut().load_requested = Some(url.to_string()));
1728}
1729
1730/// Where the page asked the browser to go since the last look ([`request_load`]) — taken, so the
1731/// host carries each request out once, by [`assign_location`].
1732pub fn take_load_request() -> Option<String> {
1733    DOM.with(|d| d.borrow_mut().load_requested.take())
1734}
1735
1736/// `location.reload()`: ask the browser to load the page again once the running script is done.
1737pub fn request_reload() {
1738    DOM.with(|d| d.borrow_mut().reload_requested = true);
1739}
1740
1741/// Whether the page asked to be loaded again since the last look ([`request_reload`]) — taken, so
1742/// the host carries each request out once.
1743pub fn take_reload_request() -> bool {
1744    DOM.with(|d| std::mem::take(&mut d.borrow_mut().reload_requested))
1745}
1746
1747/// Whether the browser stepped back through the history since the last look — the `popstate` the
1748/// host then delivers to every route handler, as a browser raises it after `history.back()`.
1749/// Taken, so each step is delivered once.
1750pub fn take_popped() -> bool {
1751    DOM.with(|d| std::mem::take(&mut d.borrow_mut().popped))
1752}
1753
1754impl DomRecorder {
1755    /// The recorder for the next document this browser loads, its head `head`. What the test
1756    /// arranged beyond the page — the answers it gave for requests (`Given that … answers …`) — and
1757    /// where the browser is — the location and its back stack — carry over; the document and
1758    /// everything the page did in it start over, exactly as [`reset`] starts them.
1759    ///
1760    /// ★ EVERY RECORDER FIELD IS CLASSIFIED HERE. The destructuring names each field of
1761    /// [`DomRecorder`] as kept or started over, so a field added to the recorder does not compile
1762    /// until someone decides which a new document does to it.
1763    fn next_document(self, head: Vec<HeadTag>) -> DomRecorder {
1764        let DomRecorder {
1765            // Kept: the test's server, and where the browser is.
1766            fetch_stubs,
1767            route_path,
1768            route_query,
1769            route_history,
1770            // Started over: the document and everything the page did in it.
1771            nodes: _,
1772            streamed: _,
1773            log: _,
1774            listeners: _,
1775            registrations: _,
1776            head: _,
1777            clipboard: _,
1778            downloads: _,
1779            notifications: _,
1780            published: _,
1781            scrolled_to: _,
1782            selected: _,
1783            focused: _,
1784            raised: _,
1785            current_event: _,
1786            fetches: _,
1787            next_fetch: _,
1788            fetch_responses: _,
1789            current_fetch: _,
1790            route_ops: _,
1791            route_handlers: _,
1792            dropped_file: _,
1793            popped: _,
1794            reload_requested: _,
1795            load_requested: _,
1796            hydration: _,
1797            forwarded: _,
1798        } = self;
1799        DomRecorder {
1800            fetch_stubs,
1801            route_path,
1802            route_query,
1803            route_history,
1804            head,
1805            ..DomRecorder::default()
1806        }
1807    }
1808}
1809
1810/// The file the drop being delivered carries: what `eventFileName`/`eventFileSize`/`eventFileType`/
1811/// `eventFileBytes` answer on a tier with no real transfer to read. The host that delivers a drop
1812/// sets it first (`page_host::Drive::DropFile`), exactly as a browser's drop event carries its
1813/// file into the listener it reaches.
1814pub fn set_dropped_file(file: DroppedFile) {
1815    DOM.with(|d| d.borrow_mut().dropped_file = Some(file));
1816}
1817
1818/// The file the drop being delivered carries ([`set_dropped_file`]) — no file (no name, no type,
1819/// no content) when no drop has carried one.
1820pub fn dropped_file() -> DroppedFile {
1821    DOM.with(|d| d.borrow().dropped_file.clone())
1822        .unwrap_or_default()
1823}
1824
1825/// A browser's own table for naming a file's type from its extension: Chromium 154.0.8037.57's
1826/// `net/base/mime_util.cc`, its `kPrimaryMappings` and then its `kSecondaryMappings`, searched in
1827/// this order for the first entry whose extensions name the file's, ASCII case-insensitively —
1828/// mirrored entry for entry, so the two can be read against each other. Between the two lists a
1829/// browser asks the visitor's desktop (Linux's shared MIME database, Windows' registry, macOS's
1830/// type system) for an extension the primary list does not name, so a secondary-list answer is
1831/// the browser's where the desktop is silent or agrees; that layer is the machine's, and the
1832/// portable part of the browser's rule is these two lists.
1833const BROWSER_PRIMARY_TYPES: &[(&str, &str)] = &[
1834    ("video/webm", "webm"),
1835    ("audio/mpeg", "mp3"),
1836    ("application/wasm", "wasm"),
1837    ("application/x-chrome-extension", "crx"),
1838    ("application/xhtml+xml", "xhtml,xht,xhtm"),
1839    ("audio/flac", "flac"),
1840    ("audio/matroska", "mka"),
1841    ("audio/mp3", "mp3"),
1842    ("audio/ogg", "ogg,oga,opus"),
1843    ("audio/wav", "wav"),
1844    ("audio/webm", "webm"),
1845    ("audio/x-m4a", "m4a"),
1846    ("image/avif", "avif"),
1847    ("image/gif", "gif"),
1848    ("image/jpeg", "jpeg,jpg,jpe"),
1849    ("image/jxl", "jxl"),
1850    ("image/png", "png"),
1851    ("image/apng", "png,apng"),
1852    ("image/svg+xml", "svg,svgz"),
1853    ("image/webp", "webp"),
1854    ("multipart/related", "mht,mhtml"),
1855    ("text/css", "css"),
1856    ("text/html", "html,htm,shtml,shtm"),
1857    ("text/javascript", "js,mjs"),
1858    ("text/xml", "xml"),
1859    ("video/matroska", "mkv"),
1860    ("video/mp4", "mp4,m4v"),
1861    ("video/ogg", "ogv,ogm"),
1862    ("text/csv", "csv"),
1863];
1864
1865const BROWSER_SECONDARY_TYPES: &[(&str, &str)] = &[
1866    ("image/x-icon", "ico"),
1867    ("application/epub+zip", "epub"),
1868    ("application/font-woff", "woff"),
1869    ("application/gzip", "gz,tgz"),
1870    ("application/javascript", "js"),
1871    ("application/json", "json"),
1872    ("application/msword", "doc,dot"),
1873    ("application/octet-stream", "bin,exe,com"),
1874    ("application/pdf", "pdf"),
1875    ("application/pkcs7-mime", "p7m,p7c,p7z"),
1876    ("application/pkcs7-signature", "p7s"),
1877    ("application/postscript", "ps,eps,ai"),
1878    ("application/rdf+xml", "rdf"),
1879    ("application/rss+xml", "rss"),
1880    ("application/rtf", "rtf"),
1881    ("application/vnd.android.package-archive", "apk"),
1882    ("application/vnd.mozilla.xul+xml", "xul"),
1883    ("application/vnd.ms-excel", "xls"),
1884    ("application/vnd.ms-powerpoint", "ppt"),
1885    (
1886        "application/vnd.openxmlformats-officedocument.presentationml.presentation",
1887        "pptx",
1888    ),
1889    (
1890        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1891        "xlsx",
1892    ),
1893    (
1894        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1895        "docx",
1896    ),
1897    ("application/x-gzip", "gz,tgz"),
1898    ("application/x-mpegurl", "m3u8"),
1899    ("application/x-shockwave-flash", "swf,swl"),
1900    ("application/x-tar", "tar"),
1901    ("application/x-x509-ca-cert", "cer,crt"),
1902    ("application/zip", "zip"),
1903    ("audio/webm", "weba"),
1904    ("image/bmp", "bmp"),
1905    ("image/jpeg", "jfif,pjpeg,pjp"),
1906    ("image/tiff", "tiff,tif"),
1907    ("image/vnd.microsoft.icon", "ico"),
1908    ("image/x-png", "png"),
1909    ("image/x-xbitmap", "xbm"),
1910    ("message/rfc822", "eml"),
1911    ("text/calendar", "ics"),
1912    ("text/html", "ehtml"),
1913    ("text/markdown", "md"),
1914    ("text/plain", "txt,text"),
1915    ("text/vtt", "vtt"),
1916    ("text/x-sh", "sh"),
1917    ("text/xml", "xsl,xbl,xslt"),
1918    ("video/mpeg", "mpeg,mpg,mpe"),
1919];
1920
1921/// The type a browser gives a file named `name`: by the extension after its last `.`, from the
1922/// browser's own table; `""` for a name with no extension, or with one the table does not name.
1923///
1924/// The ONE mapping every tier that synthesizes a file from its name reads — a drop driven by a
1925/// name alone, a `File` record that declares no type — so `file's type` on the tree-walker and the
1926/// lowered VM is what a visitor's browser reports for that name.
1927pub fn browser_mime_type(name: &str) -> &'static str {
1928    let Some(dot) = name.rfind('.') else {
1929        return "";
1930    };
1931    let ext = &name[dot + 1..];
1932    if ext.is_empty() {
1933        return "";
1934    }
1935    BROWSER_PRIMARY_TYPES
1936        .iter()
1937        .chain(BROWSER_SECONDARY_TYPES)
1938        .find(|(_, extensions)| {
1939            extensions
1940                .split(',')
1941                .any(|known| known.eq_ignore_ascii_case(ext))
1942        })
1943        .map_or("", |(mime_type, _)| mime_type)
1944}
1945
1946/// A file a drop carries: its name, the type its author declared for it when they declared one,
1947/// and its content. Its size is always its content's length, as a browser's `File.size` is; its
1948/// type ([`DroppedFile::mime_type`]) is the declared one, and otherwise what a browser names the
1949/// file from its name ([`browser_mime_type`]).
1950#[derive(Clone, Debug, Default, PartialEq, Eq)]
1951pub struct DroppedFile {
1952    pub name: String,
1953    pub declared_type: Option<String>,
1954    pub bytes: Vec<u8>,
1955}
1956
1957impl DroppedFile {
1958    /// A file known by its name alone: no declared type, no content.
1959    pub fn named(name: &str) -> DroppedFile {
1960        DroppedFile {
1961            name: name.to_string(),
1962            declared_type: None,
1963            bytes: Vec::new(),
1964        }
1965    }
1966
1967    /// The file `a new File with name … and size … and type … and bytes …` declares. Its content is
1968    /// `bytes` when they are declared; a `size` declared without them is that many zero bytes. A
1969    /// declared size that is not the count of the declared bytes describes no file a browser can
1970    /// hold, and is refused, as is a byte outside `0..=255`. A type left undeclared is the one a
1971    /// browser gives the name, exactly as a file known by its name alone gets it.
1972    pub fn declared(
1973        name: String,
1974        size: Option<i64>,
1975        declared_type: Option<String>,
1976        bytes: Option<Vec<i64>>,
1977    ) -> Result<DroppedFile, String> {
1978        let content = match (bytes, size) {
1979            (Some(bytes), size) => {
1980                if let Some(bad) = bytes.iter().find(|b| !(0..=255).contains(*b)) {
1981                    return Err(format!(
1982                        "the file {name:?} declares the byte {bad}, and a byte is a number from 0 \
1983                         to 255"
1984                    ));
1985                }
1986                if let Some(size) = size.filter(|s| *s != 0 && *s != bytes.len() as i64) {
1987                    return Err(format!(
1988                        "the file {name:?} declares size {size} and {} bytes; a file's size is the \
1989                         count of its bytes, so declare one or the other",
1990                        bytes.len()
1991                    ));
1992                }
1993                bytes.into_iter().map(|b| b as u8).collect()
1994            }
1995            (None, Some(size)) if size < 0 => {
1996                return Err(format!(
1997                    "the file {name:?} declares size {size}, and a size is never negative"
1998                ))
1999            }
2000            (None, Some(size)) => vec![0; size as usize],
2001            (None, None) => Vec::new(),
2002        };
2003        Ok(DroppedFile {
2004            name,
2005            declared_type,
2006            bytes: content,
2007        })
2008    }
2009
2010    /// The file's type as a handler reads it (`file's type`): the one its author declared, or the
2011    /// one a browser gives its name.
2012    pub fn mime_type(&self) -> &str {
2013        self.declared_type
2014            .as_deref()
2015            .unwrap_or_else(|| browser_mime_type(&self.name))
2016    }
2017
2018    /// The file a raised value names: a `File` record, read by [`DroppedFile::declared`]'s rule
2019    /// (a field the record leaves out, or holds as `nothing`, is undeclared), or a Text naming a
2020    /// file by its name alone ([`DroppedFile::named`]).
2021    pub fn from_value(value: &RuntimeValue) -> Result<DroppedFile, String> {
2022        let RuntimeValue::Struct(record) = value else {
2023            return Ok(DroppedFile::named(&value.to_display_string()));
2024        };
2025        if record.type_name != "File" {
2026            return Err(format!(
2027                "a dropped file is a `File` or its name as Text, and this is a {}",
2028                record.type_name
2029            ));
2030        }
2031        let field = |name: &str| {
2032            record
2033                .fields
2034                .get(name)
2035                .filter(|v| !matches!(v, RuntimeValue::Nothing))
2036        };
2037        let text = |name: &str| field(name).map(RuntimeValue::to_display_string);
2038        let size = match field("size") {
2039            Some(RuntimeValue::Int(n)) => Some(*n),
2040            Some(other) => {
2041                return Err(format!(
2042                    "a `File`'s size is an Int, and this one is {}",
2043                    other.type_name()
2044                ))
2045            }
2046            None => None,
2047        };
2048        let bytes = match field("bytes") {
2049            Some(RuntimeValue::List(list)) => {
2050                let list = list.borrow();
2051                let mut out = Vec::with_capacity(list.len());
2052                for i in 0..list.len() {
2053                    match list.get(i) {
2054                        Some(RuntimeValue::Int(n)) => out.push(n),
2055                        other => {
2056                            return Err(format!(
2057                                "a `File`'s bytes are Ints, and byte {} is {}",
2058                                i + 1,
2059                                other.map_or("missing".to_string(), |v| v.type_name().to_string())
2060                            ))
2061                        }
2062                    }
2063                }
2064                Some(out)
2065            }
2066            Some(other) => {
2067                return Err(format!(
2068                    "a `File`'s bytes are a Seq of Int, and these are {}",
2069                    other.type_name()
2070                ))
2071            }
2072            None => None,
2073        };
2074        DroppedFile::declared(text("name").unwrap_or_default(), size, text("type"), bytes)
2075    }
2076
2077    /// The file's size in bytes.
2078    pub fn size(&self) -> i64 {
2079        self.bytes.len() as i64
2080    }
2081
2082    /// The `File` record a handler binds: `name`, `size`, `type` and `bytes`.
2083    pub fn to_value(&self) -> RuntimeValue {
2084        let mut fields = std::collections::HashMap::new();
2085        fields.insert(
2086            "name".to_string(),
2087            RuntimeValue::Text(std::rc::Rc::new(self.name.clone())),
2088        );
2089        fields.insert("size".to_string(), RuntimeValue::Int(self.size()));
2090        fields.insert(
2091            "type".to_string(),
2092            RuntimeValue::Text(std::rc::Rc::new(self.mime_type().to_string())),
2093        );
2094        fields.insert(
2095            "bytes".to_string(),
2096            crate::semantics::builtins::bytes_to_seq(&self.bytes),
2097        );
2098        RuntimeValue::Struct(Box::new(crate::interpreter::StructValue {
2099            type_name: "File".to_string(),
2100            fields,
2101        }))
2102    }
2103}
2104
2105/// The page arrives, served in `document` — the shell HTML a browser loads it in: from now on this
2106/// world's document starts from that shell's head, this load and every reload after it, so a page
2107/// the host drives in process arrives in exactly the document a browser holds. The shell's body is
2108/// not read: the page paints its body itself.
2109pub fn arrive(document: &str) {
2110    let lower = document.to_ascii_lowercase();
2111    let head = match (lower.find("<head>"), lower.find("</head>")) {
2112        (Some(open), Some(close)) if open < close => {
2113            parse_head(&document[open + "<head>".len()..close])
2114        }
2115        _ => Vec::new(),
2116    };
2117    SHELL.with(|s| *s.borrow_mut() = head);
2118    reset();
2119}
2120
2121/// Everything a document holds: its tree, its once-per-mount declared head, and the shell it is
2122/// served in. What [`take`] saves and [`restore`] puts back.
2123#[derive(Default)]
2124pub struct DomState {
2125    dom: DomRecorder,
2126    head: Option<String>,
2127    shell: Vec<HeadTag>,
2128}
2129
2130/// Take the current document, leaving a fresh one in its place — [`reset`], but the document it
2131/// replaces comes back to the caller instead of being dropped. A nested run (a candidate page
2132/// tier compared against the reference) takes the caller's document before it paints its own, and
2133/// [`restore`]s it when it is done, so the caller's own document survives the comparison.
2134pub fn take() -> DomState {
2135    DomState {
2136        dom: DOM.with(RefCell::take),
2137        head: HEAD_HTML.with(RefCell::take),
2138        shell: SHELL.with(RefCell::take),
2139    }
2140}
2141
2142/// Put back a document a matching [`take`] returned.
2143pub fn restore(state: DomState) {
2144    DOM.with(|d| *d.borrow_mut() = state.dom);
2145    HEAD_HTML.with(|h| *h.borrow_mut() = state.head);
2146    SHELL.with(|s| *s.borrow_mut() = state.shell);
2147}
2148
2149/// Runs `change` against the document and keeps what it did only if it succeeds: on an `Err` the
2150/// document is exactly as it was before, op log included.
2151pub fn atomically<T>(change: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
2152    let before = DOM.with(|d| d.borrow().clone());
2153    let result = change();
2154    if result.is_err() {
2155        DOM.with(|d| *d.borrow_mut() = before);
2156    }
2157    result
2158}
2159
2160/// The ordered op-log recorded so far — the parity assertion surface.
2161pub fn op_log() -> Vec<DomOp> {
2162    DOM.with(|d| d.borrow().log.clone())
2163}
2164
2165/// A deterministic render of the current tree rooted at `body`.
2166/// The HTML of a node's CHILDREN — what a view value renders as. The fragment that holds a
2167/// rendered view is scaffolding, never markup, so it never appears in the output.
2168pub fn render_children(node: i64) -> String {
2169    DOM.with(|d| {
2170        let dom = d.borrow();
2171        let mut out = String::new();
2172        let kids = dom
2173            .get(node as u32)
2174            .map(|n| n.children.clone())
2175            .unwrap_or_default();
2176        for child in kids {
2177            dom.render_node(child, &mut out, Spelling::Recorded);
2178        }
2179        out
2180    })
2181}
2182
2183pub fn render() -> String {
2184    DOM.with(|d| d.borrow().render())
2185}
2186
2187/// [`render`] as a browser must be sent it to build the same page: a control's state is spelled
2188/// the way HTML's parser reads it back — a `<select>`'s value as its `selected` option, a
2189/// `<textarea>`'s as its content — and content a parser would lose a leading newline of carries
2190/// one more. What `largo build` puts in a pre-rendered page's body.
2191pub fn render_served() -> String {
2192    DOM.with(|d| d.borrow().render_served())
2193}
2194
2195thread_local! {
2196    /// The HTML a page's `Head:` rendered to, set once at mount.
2197    ///
2198    /// A thread-local beside the document recorder rather than a node inside it: the
2199    /// recorder models what a browser paints, and the head is not painted — it is part
2200    /// of the document the SERVER sends, read by `largo build` and `largo serve` when
2201    /// they compose the page. Putting it in the tree would put it in `render()`, which
2202    /// every op-log assertion in the suite reads as the body.
2203    static HEAD_HTML: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
2204}
2205
2206/// Record the head HTML a page rendered, and merge it into the document's head the way a browser
2207/// host does (`runtime.js`'s `mergeIntoHead`): each element with an identity replaces every element
2208/// of that identity already there, and joins the head at its end; one without is simply added.
2209/// Called once, at mount.
2210///
2211/// What is recorded ([`head_html`]) is the page's declared head after that same merge — a layout's
2212/// `Head:` and the page's own arrive as one string, and a server composing the document from it
2213/// must see the page's title, not both — each surviving tag exactly as it was written.
2214pub fn set_head_html(html: &str) {
2215    let mut declared: Vec<(Option<String>, &str)> = Vec::new();
2216    let mut rest = html;
2217    while let Some(open) = rest.find('<') {
2218        let tag = &rest[open..];
2219        let end = if tag.to_ascii_lowercase().starts_with("<title") {
2220            tag.to_ascii_lowercase()
2221                .find("</title>")
2222                .map(|e| e + "</title>".len())
2223        } else {
2224            tag.find('>').map(|e| e + 1)
2225        };
2226        let Some(end) = end else { break };
2227        let markup = &tag[..end];
2228        let identity = parse_head(markup).first().and_then(HeadTag::identity);
2229        if let Some(identity) = &identity {
2230            declared.retain(|(held, _)| held.as_ref() != Some(identity));
2231        }
2232        declared.push((identity, markup));
2233        rest = &tag[end..];
2234    }
2235    let merged: String = declared.iter().map(|(_, markup)| *markup).collect();
2236    HEAD_HTML.with(|h| *h.borrow_mut() = Some(merged));
2237    DOM.with(|d| {
2238        let head = &mut d.borrow_mut().head;
2239        for tag in parse_head(html) {
2240            if let Some(identity) = tag.identity() {
2241                head.retain(|t| t.identity().as_deref() != Some(identity.as_str()));
2242            }
2243            head.push(tag);
2244        }
2245    });
2246}
2247
2248/// The head HTML the last mounted page declared, if it declared one. `None` means the
2249/// page has no `Head:` — which is not the same as an EMPTY head, and the difference is
2250/// whether the default shell's title stands. What a server composes the page's document from.
2251pub fn head_html() -> Option<String> {
2252    HEAD_HTML.with(|h| h.borrow().clone())
2253}
2254
2255/// The document's head as it stands — the shell it arrived in with the page's own `Head:` merged
2256/// over it — element by element in the reference renderer's serialization. `None` when the head
2257/// holds no element.
2258pub fn document_head() -> Option<String> {
2259    DOM.with(|d| {
2260        let markup: String = d.borrow().head.iter().map(HeadTag::markup).collect();
2261        (!markup.is_empty()).then_some(markup)
2262    })
2263}
2264
2265/// One element of a document's `<head>`: its tag, its attributes, and the text it holds — a
2266/// `<title>`'s, a `<style>`'s or `<script>`'s raw content, nothing for a void element.
2267#[derive(Clone, Debug, Default, PartialEq, Eq)]
2268pub struct HeadTag {
2269    pub tag: String,
2270    /// Attribute name → decoded value, in name order.
2271    pub attrs: BTreeMap<String, String>,
2272    pub text: String,
2273}
2274
2275impl HeadTag {
2276    /// What makes two head elements THE SAME element — the identity a page's own version
2277    /// replaces; `None` for one that replaces nothing and is simply added. The keys of
2278    /// `runtime.js`'s `headIdentity`, read off the same attributes: an attribute counts by its
2279    /// presence and its own value, never by text that happens to appear inside another's value. A
2280    /// `<meta>` named with a `media` (`theme-color` for light and for dark) keys on both, so one
2281    /// never replaces the other.
2282    pub fn identity(&self) -> Option<String> {
2283        let attr = |name: &str| self.attrs.get(name).map(|v| v.to_ascii_lowercase());
2284        match self.tag.as_str() {
2285            "title" => Some("title".to_string()),
2286            "base" => Some("base".to_string()),
2287            "meta" if self.attrs.contains_key("charset") => Some("meta:charset".to_string()),
2288            "meta" => match (attr("name"), attr("property")) {
2289                (Some(name), _) => Some(match attr("media") {
2290                    Some(media) => format!("meta:name={name}@{media}"),
2291                    None => format!("meta:name={name}"),
2292                }),
2293                (None, Some(property)) => Some(format!("meta:property={property}")),
2294                (None, None) => None,
2295            },
2296            "link" => attr("rel").map(|rel| format!("link:rel={rel}")),
2297            _ => None,
2298        }
2299    }
2300
2301    /// The element in the reference renderer's serialization: attributes in name order, a void
2302    /// element unclosed, a raw-text element's content verbatim, any other text escaped.
2303    pub fn markup(&self) -> String {
2304        use logicaffeine_language::lgx::html::{is_raw_text, is_void};
2305        let mut out = format!("<{}", self.tag);
2306        for (name, value) in &self.attrs {
2307            out.push_str(&format!(" {name}=\""));
2308            escape_attr(value, &mut out);
2309            out.push('"');
2310        }
2311        out.push('>');
2312        if is_void(&self.tag) {
2313            return out;
2314        }
2315        if is_raw_text(&self.tag) {
2316            out.push_str(&self.text);
2317        } else {
2318            escape_text(&self.text, &mut out);
2319        }
2320        out.push_str(&format!("</{}>", self.tag));
2321        out
2322    }
2323}
2324
2325/// Head markup — a page's rendered `Head:`, or the head of the shell it is served in — as its
2326/// elements, in order. What a browser's parse keeps of it: element names and attribute names fold
2327/// to lower case, the first of two same-named attributes wins, character references decode (never
2328/// inside `<style>`/`<script>`), text between elements is formatting and is dropped, and an
2329/// `<html …>` is a statement about the document element, never an element of the head.
2330pub fn parse_head(markup: &str) -> Vec<HeadTag> {
2331    use logicaffeine_language::lgx::html::{decode_entities, is_raw_text, is_void};
2332    let mut out = Vec::new();
2333    let mut rest = markup;
2334    while let Some(lt) = rest.find('<') {
2335        rest = &rest[lt + 1..];
2336        if let Some(comment) = rest.strip_prefix("!--") {
2337            rest = comment.find("-->").map_or("", |end| &comment[end + 3..]);
2338            continue;
2339        }
2340        if rest.starts_with(['/', '!', '?']) {
2341            rest = rest.find('>').map_or("", |end| &rest[end + 1..]);
2342            continue;
2343        }
2344        let name_end = rest
2345            .find(|c: char| c.is_ascii_whitespace() || c == '>' || c == '/')
2346            .unwrap_or(rest.len());
2347        let tag = rest[..name_end].to_ascii_lowercase();
2348        rest = &rest[name_end..];
2349        let mut attrs = BTreeMap::new();
2350        loop {
2351            rest = rest.trim_start_matches(|c: char| c.is_ascii_whitespace());
2352            if let Some(after) = rest.strip_prefix('>') {
2353                rest = after;
2354                break;
2355            }
2356            if let Some(after) = rest.strip_prefix('/') {
2357                rest = after;
2358                continue;
2359            }
2360            if rest.is_empty() {
2361                break;
2362            }
2363            let name_end = rest
2364                .find(|c: char| c.is_ascii_whitespace() || c == '=' || c == '>' || c == '/')
2365                .unwrap_or(rest.len());
2366            let name = rest[..name_end].to_ascii_lowercase();
2367            rest = rest[name_end..].trim_start_matches(|c: char| c.is_ascii_whitespace());
2368            let value = match rest.strip_prefix('=') {
2369                None => String::new(),
2370                Some(after) => {
2371                    let after = after.trim_start_matches(|c: char| c.is_ascii_whitespace());
2372                    match after.chars().next() {
2373                        Some(quote @ ('"' | '\'')) => {
2374                            let body = &after[1..];
2375                            let end = body.find(quote).unwrap_or(body.len());
2376                            rest = body.get(end + 1..).unwrap_or("");
2377                            decode_entities(&body[..end])
2378                        }
2379                        _ => {
2380                            let end = after
2381                                .find(|c: char| c.is_ascii_whitespace() || c == '>')
2382                                .unwrap_or(after.len());
2383                            rest = &after[end..];
2384                            decode_entities(&after[..end])
2385                        }
2386                    }
2387                }
2388            };
2389            if !name.is_empty() {
2390                attrs.entry(name).or_insert(value);
2391            }
2392        }
2393        let mut text = String::new();
2394        if !is_void(&tag) {
2395            let close = format!("</{tag}");
2396            let end = rest.to_ascii_lowercase().find(&close).unwrap_or(rest.len());
2397            text = if is_raw_text(&tag) {
2398                rest[..end].to_string()
2399            } else {
2400                decode_entities(&rest[..end])
2401            };
2402            rest = &rest[end..];
2403            rest = rest.find('>').map_or("", |e| &rest[e + 1..]);
2404        }
2405        if tag != "html" && !tag.is_empty() {
2406            out.push(HeadTag { tag, attrs, text });
2407        }
2408    }
2409    out
2410}
2411
2412// --- The operations the DOM builtins call (handles are `i64` for the value model). ---
2413
2414pub fn body() -> i64 {
2415    DOM.with(|d| d.borrow().body() as i64)
2416}
2417pub fn create_element(tag: &str) -> i64 {
2418    DOM.with(|d| d.borrow_mut().create_element(tag) as i64)
2419}
2420pub fn create_text_node(text: &str) -> i64 {
2421    DOM.with(|d| d.borrow_mut().create_text_node(text) as i64)
2422}
2423/// A new element under the id a patch stream names for it — see [`STREAM_ID_BASE`].
2424pub fn create_element_as(node: i64, tag: &str) -> Result<(), String> {
2425    let id = u32::try_from(node)
2426        .map_err(|_| format!("the stream names node {node}, which is no node id"))?;
2427    DOM.with(|d| d.borrow_mut().create_element_as(id, tag))
2428}
2429/// A new text node under the id a patch stream names for it — see [`STREAM_ID_BASE`].
2430pub fn create_text_node_as(node: i64, text: &str) -> Result<(), String> {
2431    let id = u32::try_from(node)
2432        .map_err(|_| format!("the stream names node {node}, which is no node id"))?;
2433    DOM.with(|d| d.borrow_mut().create_text_node_as(id, text))
2434}
2435pub fn set_text(node: i64, text: &str) {
2436    DOM.with(|d| d.borrow_mut().set_text(node as u32, text));
2437}
2438pub fn set_html(node: i64, html: &str) {
2439    DOM.with(|d| d.borrow_mut().set_html(node as u32, html));
2440}
2441pub fn clear_children(node: i64) {
2442    DOM.with(|d| d.borrow_mut().clear_children(node as u32));
2443}
2444pub fn set_attribute(node: i64, name: &str, value: &str) {
2445    DOM.with(|d| d.borrow_mut().set_attribute(node as u32, name, value));
2446}
2447/// Set an attribute from a VALUE rather than from a string — the ONE place the boolean-attribute
2448/// rule lives, so no two tiers can render `disabled` differently.
2449///
2450/// An HTML boolean attribute's state is its PRESENCE: `disabled=""`, `disabled="disabled"` and
2451/// `disabled="false"` all disable the control, and the only way to say ENABLED is to leave the
2452/// attribute out. So a `Bool` in one of those names decides presence and nothing is written when
2453/// it is false; every other value is written as it always was, which is what keeps `disabled=""`
2454/// (present, in HTML) and `data-done={false}` (data, so it keeps its text) both correct.
2455///
2456/// The rule is keyed on the value's TYPE, not its truthiness — an empty Text is falsy and
2457/// `disabled=""` is disabled, so a truthiness rule would drop exactly the case HTML keeps.
2458pub fn set_attribute_value(node: i64, name: &str, value: &RuntimeValue) {
2459    // `checked` is the exception, and it is the important one. It is boolean in the MARKUP, but
2460    // in the DOM the attribute is `defaultChecked` — the state a form RESET returns the box to —
2461    // while what a visitor sees is the PROPERTY. The two coincide on a freshly built element,
2462    // which is what makes writing the attribute a trap: it works on the first paint and stops
2463    // the moment a reconciler patches an existing node instead of creating a new one.
2464    if name.eq_ignore_ascii_case("checked") {
2465        // A non-Bool means the author wrote the attribute at all, and in HTML writing it —
2466        // `checked`, `checked=""`, even `checked="false"` — checks the box.
2467        set_checked(node, !matches!(value, RuntimeValue::Bool(false)));
2468        return;
2469    }
2470    if logicaffeine_language::lgx::html::is_boolean_attribute(name) {
2471        if let RuntimeValue::Bool(on) = value {
2472            if *on {
2473                set_attribute(node, name, "");
2474            }
2475            return;
2476        }
2477    }
2478    let text = value.to_display_string();
2479    // ★ A URL ATTRIBUTE IS A NAVIGATION, AND ESCAPING DOES NOT REACH IT. Every hole in this
2480    // framework is escaped, and escaping is exactly the wrong tool here: `javascript:alert(1)`
2481    // contains no character HTML escaping touches, so it survives every guard the page has and
2482    // runs the moment a visitor follows the link. The danger is the SCHEME, not the syntax.
2483    //
2484    // Any page that renders a URL it did not author is in this position — a profile link, a
2485    // `?next=` parameter, a link inside user content. The value is replaced rather than dropped:
2486    // a missing `href` looks like a styling bug and sends the reader to the wrong question, while
2487    // `about:blank#blocked` is inert, greppable, and obviously deliberate when someone inspects it.
2488    if logicaffeine_language::lgx::html::is_url_attribute(name)
2489        && logicaffeine_language::lgx::html::is_executable_url(&text)
2490    {
2491        set_attribute(node, name, "about:blank#blocked");
2492        return;
2493    }
2494    set_attribute(node, name, &text);
2495}
2496pub fn append_child(parent: i64, child: i64) {
2497    DOM.with(|d| d.borrow_mut().append_child(parent as u32, child as u32));
2498}
2499pub fn set_value(node: i64, value: &str) {
2500    DOM.with(|d| d.borrow_mut().set_value(node as u32, value));
2501}
2502/// A control whose CHECKED state a click moves. See `DomRecorder::toggle_control`.
2503#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2504pub enum ToggleControl {
2505    /// A click FLIPS it.
2506    Checkbox,
2507    /// A click SELECTS it; clicking a selected radio leaves it selected.
2508    Radio,
2509}
2510
2511/// Whether `node` carries attribute `name` at all — the PRESENCE question an HTML boolean
2512/// attribute is asked with (`"#b" is disabled`). Comparing its value against a string would call
2513/// an enabled control disabled, since `disabled=""` is the enabled-looking spelling of disabled.
2514pub fn has_attribute(node: i64, name: &str) -> bool {
2515    DOM.with(|d| {
2516        d.borrow()
2517            .get(node as u32)
2518            .is_some_and(|n| n.attrs.contains_key(name))
2519    })
2520}
2521/// An element's tag, lowercased — what the browser's DEFAULT ACTION for a click turns on. A click
2522/// on an `<a href>` follows the link and a submit on a `<form>` posts it, whether or not the page
2523/// is listening; a runtime that only fires listeners answers neither.
2524pub fn tag_of(node: i64) -> Option<String> {
2525    DOM.with(|d| {
2526        d.borrow()
2527            .get(node as u32)
2528            .map(|n| n.tag.to_ascii_lowercase())
2529    })
2530}
2531
2532/// One of `node`'s attribute values.
2533pub fn attribute(node: i64, name: &str) -> Option<String> {
2534    DOM.with(|d| {
2535        d.borrow()
2536            .get(node as u32)
2537            .and_then(|n| n.attrs.get(name).cloned())
2538    })
2539}
2540
2541/// The `(name, value)` of every named field inside `node` — a form's payload, in document order.
2542///
2543/// A field with no `name` is not submitted; that is HTML's own rule, and it is how a form marks a
2544/// control as decoration (a search box's clear button) rather than data.
2545pub fn form_fields(node: i64) -> Vec<(String, String)> {
2546    DOM.with(|d| {
2547        let dom = d.borrow();
2548        let mut out = Vec::new();
2549        let mut stack = vec![node as u32];
2550        while let Some(id) = stack.pop() {
2551            let Some(n) = dom.get(id) else { continue };
2552            if let Some(name) = n.attrs.get("name") {
2553                if !name.is_empty() {
2554                    let value = if matches!(n.tag.to_ascii_lowercase().as_str(), "input")
2555                        && matches!(
2556                            n.attrs
2557                                .get("type")
2558                                .map(|t| t.to_ascii_lowercase())
2559                                .as_deref(),
2560                            Some("checkbox") | Some("radio")
2561                        ) {
2562                        // An unchecked box submits NOTHING — a browser omits it entirely rather
2563                        // than sending an empty value, and a server reading "present or absent"
2564                        // would see every box as ticked.
2565                        if !dom.get_checked(id) {
2566                            for c in n.children.iter().rev() {
2567                                stack.push(*c);
2568                            }
2569                            continue;
2570                        }
2571                        n.attrs
2572                            .get("value")
2573                            .cloned()
2574                            .unwrap_or_else(|| "on".to_string())
2575                    } else {
2576                        dom.control_value(id)
2577                    };
2578                    out.push((name.clone(), value));
2579                }
2580            }
2581            for c in n.children.iter().rev() {
2582                stack.push(*c);
2583            }
2584        }
2585        out
2586    })
2587}
2588
2589pub fn get_checked(node: i64) -> bool {
2590    DOM.with(|d| d.borrow().get_checked(node as u32))
2591}
2592pub fn toggle_control(node: i64) -> Option<ToggleControl> {
2593    DOM.with(|d| d.borrow().toggle_control(node as u32))
2594}
2595pub fn set_checked(node: i64, checked: bool) {
2596    DOM.with(|d| d.borrow_mut().set_checked(node as u32, checked));
2597}
2598/// Record that a listener on `node` for `event` cancels the browser's default action.
2599///
2600/// A declaration about the REGISTRATION, so it is recorded once when the listener is attached —
2601/// a host reads it and calls `preventDefault` when the event fires, and an in-process host reads
2602/// it to decide whether to perform the default action itself. Every tier therefore says the same
2603/// thing about the same page, which is what the op-log is for.
2604pub fn prevent_default(node: i64, event: &str) {
2605    DOM.with(|d| {
2606        let mut d = d.borrow_mut();
2607        let flags = d
2608            .registrations
2609            .entry((node as u32, event.to_string()))
2610            .or_insert(0);
2611        *flags |= LISTEN_PREVENT;
2612        d.log.push(DomOp::PreventDefault {
2613            node: node as u32,
2614            event: event.to_string(),
2615        })
2616    });
2617}
2618pub fn set_stream(node: i64, stream: i64) {
2619    DOM.with(|d| d.borrow_mut().set_stream(node as u32, stream));
2620}
2621pub fn rtc_peer_connection() -> i64 {
2622    DOM.with(|d| d.borrow_mut().rtc_peer_connection() as i64)
2623}
2624pub fn rtc_data_channel(pc: i64, label: &str) -> i64 {
2625    DOM.with(|d| d.borrow_mut().rtc_data_channel(pc as u32, label) as i64)
2626}
2627pub fn rtc_create_offer(pc: i64) -> String {
2628    DOM.with(|d| d.borrow_mut().rtc_create_offer(pc as u32));
2629    "offer".to_string()
2630}
2631pub fn rtc_create_answer(pc: i64) -> String {
2632    DOM.with(|d| d.borrow_mut().rtc_create_answer(pc as u32));
2633    "answer".to_string()
2634}
2635pub fn rtc_set_local(pc: i64, sdp: &str) {
2636    DOM.with(|d| d.borrow_mut().rtc_set_local(pc as u32, sdp));
2637}
2638pub fn rtc_set_remote(pc: i64, sdp: &str) {
2639    DOM.with(|d| d.borrow_mut().rtc_set_remote(pc as u32, sdp));
2640}
2641pub fn rtc_add_ice(pc: i64, candidate: &str) {
2642    DOM.with(|d| d.borrow_mut().rtc_add_ice(pc as u32, candidate));
2643}
2644pub fn rtc_send(channel: i64, bytes: u64) {
2645    DOM.with(|d| d.borrow_mut().rtc_send(channel as u32, bytes));
2646}
2647pub fn get_text(node: i64) -> String {
2648    DOM.with(|d| d.borrow().get_text(node as u32))
2649}
2650pub fn get_value(node: i64) -> String {
2651    DOM.with(|d| d.borrow().get_value(node as u32))
2652}
2653/// Whether a visitor would SEE the first element matching `selector` — see `Dom::shown`.
2654pub fn is_shown(selector: &str) -> bool {
2655    DOM.with(|d| {
2656        let d = d.borrow();
2657        match d.query_selector_all(selector).first() {
2658            Some(&h) => d.shown(h),
2659            None => false,
2660        }
2661    })
2662}
2663
2664pub fn query_selector(selector: &str) -> i64 {
2665    DOM.with(|d| handle(d.borrow().query_selector(selector)))
2666}
2667/// A node id as the value model carries it: the id itself, or `-1` for the `u32::MAX` "no such
2668/// node" — never a wrap through `i32`, which would turn every stream id negative.
2669fn handle(node: u32) -> i64 {
2670    if node == u32::MAX {
2671        -1
2672    } else {
2673        node as i64
2674    }
2675}
2676/// Every node matching `selector`, in document order.
2677pub fn query_selector_all(selector: &str) -> Vec<i64> {
2678    DOM.with(|d| {
2679        d.borrow()
2680            .query_selector_all(selector)
2681            .into_iter()
2682            .map(i64::from)
2683            .collect()
2684    })
2685}
2686/// The element's text content: its own text plus every descendant's, in document order.
2687pub fn text_content(node: i64) -> String {
2688    DOM.with(|d| d.borrow().text_content(node as u32))
2689}
2690/// `Set the title to …` — `document.title = …`: the head's first `<title>` holds the new text, or,
2691/// when the head has none, a new `<title>` holding it joins the head.
2692pub fn set_title(title: &str) {
2693    DOM.with(|d| {
2694        let head = &mut d.borrow_mut().head;
2695        match head.iter_mut().find(|t| t.tag == "title") {
2696            Some(element) => element.text = title.to_string(),
2697            None => head.push(HeadTag {
2698                tag: "title".to_string(),
2699                text: title.to_string(),
2700                ..HeadTag::default()
2701            }),
2702        }
2703    });
2704}
2705/// `the title` — `document.title`: the text of the head's first `<title>`, its ASCII whitespace
2706/// stripped from both ends and collapsed to single spaces; `""` when there is none.
2707pub fn title() -> String {
2708    DOM.with(|d| {
2709        d.borrow()
2710            .head
2711            .iter()
2712            .find(|t| t.tag == "title")
2713            .map(|t| {
2714                t.text
2715                    .split_ascii_whitespace()
2716                    .collect::<Vec<_>>()
2717                    .join(" ")
2718            })
2719            .unwrap_or_default()
2720    })
2721}
2722/// `Focus SEL.` — the element the page's keyboard input goes to.
2723/// `Copy "…" to the clipboard.` — the text a browser would have put on the clipboard.
2724pub fn write_clipboard(text: &str) {
2725    DOM.with(|d| d.borrow_mut().clipboard = text.to_string());
2726}
2727
2728/// What is on the clipboard (`""` when the page has never written to it).
2729pub fn clipboard() -> String {
2730    DOM.with(|d| d.borrow().clipboard.clone())
2731}
2732
2733/// `Download <content> as "<name>".` — a file the page offered, `(name, content)`.
2734pub fn offer_download(name: &str, content: &str) {
2735    DOM.with(|d| {
2736        d.borrow_mut()
2737            .downloads
2738            .push((name.to_string(), content.to_string()))
2739    });
2740}
2741
2742/// Every download the page has offered, in order.
2743pub fn downloads() -> Vec<(String, String)> {
2744    DOM.with(|d| d.borrow().downloads.clone())
2745}
2746
2747/// `Notify "…".` — a notification the page raised, `(message, tone)`.
2748pub fn notify(message: &str, tone: &str) {
2749    DOM.with(|d| {
2750        d.borrow_mut()
2751            .notifications
2752            .push((message.to_string(), tone.to_string()))
2753    });
2754}
2755
2756/// Every notification the page has raised, in order.
2757pub fn notifications() -> Vec<(String, String)> {
2758    DOM.with(|d| d.borrow().notifications.clone())
2759}
2760
2761/// `Publish M on "t".` — the frame the page handed to the transport, `(topic, message)`.
2762///
2763/// ★ RECORDED ON BOTH TIERS, which is the only way the OUTBOUND half can be checked at all.
2764/// A publish's local delivery shows up in the page's own HTML, so parity would look complete
2765/// while the leg that actually leaves the page went unsent on one tier — the same
2766/// half-a-construct shape this verb exists to close. In a browser this is the WebSocket frame;
2767/// here it is the record that one was sent.
2768pub fn publish(topic: &str, message: &str) {
2769    DOM.with(|d| {
2770        d.borrow_mut()
2771            .published
2772            .push((topic.to_string(), message.to_string()))
2773    });
2774}
2775
2776/// Every frame the page has published, in order.
2777pub fn published() -> Vec<(String, String)> {
2778    DOM.with(|d| d.borrow().published.clone())
2779}
2780
2781/// `Scroll to "#card".` — the element a browser would have brought into view.
2782pub fn scroll_to(node: i64) {
2783    DOM.with(|d| d.borrow_mut().scrolled_to = node as u32);
2784}
2785
2786/// The element most recently scrolled to, or `-1`.
2787pub fn scrolled_to() -> i64 {
2788    DOM.with(|d| handle(d.borrow().scrolled_to))
2789}
2790
2791/// `Select the text in "#draft".` — the element whose text a browser would have selected.
2792pub fn select_text(node: i64) {
2793    DOM.with(|d| d.borrow_mut().selected = node as u32);
2794}
2795
2796/// The element whose text was most recently selected, or `-1`.
2797pub fn selected() -> i64 {
2798    DOM.with(|d| handle(d.borrow().selected))
2799}
2800
2801pub fn set_focus(node: i64) {
2802    DOM.with(|d| {
2803        let mut d = d.borrow_mut();
2804        d.focused = node as u32;
2805    });
2806}
2807/// The focused element, or a negative handle when nothing is focused.
2808pub fn focused() -> i64 {
2809    DOM.with(|d| {
2810        let mut d = d.borrow_mut();
2811        d.drop_detached_focus();
2812        handle(d.focused)
2813    })
2814}
2815
2816/// Whether `node` can take keyboard focus, as a browser decides it: a control that is not
2817/// `disabled` (`button`, `select`, `textarea`, an `input` other than `type="hidden"`), a link or
2818/// image-map `area` with an `href`, an `iframe`, a `summary`, `audio`/`video` showing `controls`,
2819/// and any element carrying a `tabindex` or an editable `contenteditable`.
2820pub fn focusable(node: i64) -> bool {
2821    DOM.with(|d| d.borrow().get(node as u32).is_some_and(Node::focusable))
2822}
2823
2824/// Where the default action of `verb` on `node` sends keyboard focus
2825/// ([`crate::semantics::drive::Verb::focus_target`]): `Some(element)`, `Some(-1)` for off every
2826/// element, `None` when focus does not move.
2827pub fn focus_target(verb: crate::semantics::drive::Verb, node: i64) -> Option<i64> {
2828    use crate::semantics::drive::FocusTarget;
2829    match verb.focus_target() {
2830        FocusTarget::Unmoved => None,
2831        FocusTarget::Element => focusable(node).then_some(node),
2832        FocusTarget::PressedAncestor => DOM.with(|d| {
2833            let d = d.borrow();
2834            let chain = d.ancestors_of(node as u32).unwrap_or_default();
2835            let pressed = std::iter::once(node as u32).chain(chain.into_iter().rev());
2836            Some(
2837                pressed
2838                    .filter(|&h| h != 0)
2839                    .find(|&h| d.get(h).is_some_and(Node::focusable))
2840                    .map_or(-1, i64::from),
2841            )
2842        }),
2843    }
2844}
2845
2846/// Move keyboard focus to `target` (`-1`: off every element), as a browser's focusing steps do:
2847/// `(blurred, focused)` — the element that lost focus and the one that gained it, `-1` for none —
2848/// for the caller to raise `blur` and then `focus` on; `None` when focus is already there.
2849pub fn move_focus(target: i64) -> Option<(i64, i64)> {
2850    let before = focused();
2851    let target = if target < 0 { -1 } else { target };
2852    if before == target {
2853        return None;
2854    }
2855    set_focus(target);
2856    Some((before, target))
2857}
2858
2859/// The default focus action of `verb` on `node`, carried out: [`focus_target`], then
2860/// [`move_focus`]. What a driving verb does before it raises its own events.
2861pub fn focus_for(verb: crate::semantics::drive::Verb, node: i64) -> Option<(i64, i64)> {
2862    focus_target(verb, node).and_then(move_focus)
2863}
2864
2865/// `element.focus()`: a focusable element that does not have focus takes it, the element that had
2866/// it losing it first — and the `blur` and `focus` that raises wait in [`take_raised_events`] for
2867/// the host to deliver once the turn that asked has committed, as a browser delivers them.
2868/// Anything else is left alone: an element that cannot take focus, or already has it.
2869pub fn element_focus(node: i64) {
2870    if !focusable(node) {
2871        return;
2872    }
2873    if let Some((blurred, gained)) = move_focus(node) {
2874        raise_focus_events(blurred, gained);
2875    }
2876}
2877
2878/// `element.blur()`: the focused element loses focus to nothing and `blur` is raised on it
2879/// ([`take_raised_events`]); an element that does not have focus is left alone.
2880pub fn element_blur(node: i64) {
2881    if node >= 0 && focused() == node {
2882        if let Some((blurred, gained)) = move_focus(-1) {
2883            raise_focus_events(blurred, gained);
2884        }
2885    }
2886}
2887
2888fn raise_focus_events(blurred: i64, gained: i64) {
2889    let events: Vec<(String, &'static str)> = [(blurred, "blur"), (gained, "focus")]
2890        .into_iter()
2891        .filter(|(element, _)| *element >= 0)
2892        .map(|(element, event)| (node_address(element), event))
2893        .collect();
2894    DOM.with(|d| d.borrow_mut().raised.extend(events));
2895}
2896
2897/// The events the page's own element commands raised ([`element_focus`], [`element_blur`]) that
2898/// the host has not delivered yet, `(element, event)` in the order they were raised — taken, so
2899/// each is delivered once. Each element is found as the page stands NOW (it was recorded by its
2900/// [`node_address`], which survives the turn's repaint), and an event whose element has left the
2901/// page is dropped: nothing is listening there any more.
2902pub fn take_raised_events() -> Vec<(i64, &'static str)> {
2903    let raised = DOM.with(|d| std::mem::take(&mut d.borrow_mut().raised));
2904    raised
2905        .into_iter()
2906        .filter_map(|(address, event)| {
2907            let element = query_selector(&address);
2908            (element >= 0).then_some((element, event))
2909        })
2910        .collect()
2911}
2912
2913/// A selector naming `node` by the identity a browser's reconciler keeps it by through a repaint:
2914/// `#id` when the element carries an id that names it, `body` for the page's body, else its PLACE —
2915/// its child index under each ancestor from `body` down, and its tag — which [`query_selector`]
2916/// resolves to whatever element of that tag stands there now. The place form is not one any page
2917/// can spell: it begins with a character no selector contains, and is resolved before the selector
2918/// grammar is consulted. Empty for a node that is not an attached element.
2919pub fn node_address(node: i64) -> String {
2920    DOM.with(|d| d.borrow().address(node as u32))
2921}
2922
2923/// The mark a place-form [`node_address`] begins with — a character no selector a page writes
2924/// contains.
2925const PLACE: char = '\u{0}';
2926
2927/// The value a visitor's `Type`/`Choose` leaves in the control `selector` names before the verb
2928/// raises its events — the browser's own default action of typing (the text replaces what the
2929/// field held) and of choosing (the option whose value, or else whose text, is `value` becomes the
2930/// select's value). Recorder state rather than an op: it is the visitor's act on the document, not
2931/// something the page asked for. Other verbs, a control the verb cannot act on
2932/// ([`answers_by_itself`]), and a choice no option offers, leave it alone.
2933pub fn enter_value(verb: crate::semantics::drive::Verb, selector: &str, value: &str) {
2934    use crate::semantics::drive::Verb;
2935    DOM.with(|d| {
2936        let mut d = d.borrow_mut();
2937        let node = d.query_selector(selector);
2938        if !d.takes(verb, node, value) {
2939            return;
2940        }
2941        match verb {
2942            Verb::Type => {
2943                let Some(n) = d.get_mut(node) else { return };
2944                let (held, bad_input) = typed_value(n, value);
2945                n.value = held;
2946                n.bad_input = bad_input;
2947                n.user_edited = true;
2948            }
2949            Verb::Choose => {
2950                if let Some(chosen) = d.option_offering(node, value) {
2951                    if let Some(n) = d.get_mut(node) {
2952                        n.value = chosen;
2953                        n.user_edited = true;
2954                    }
2955                }
2956            }
2957            _ => {}
2958        }
2959    });
2960}
2961
2962/// Whether the browser's own default action of `verb` on the element `selector` names changes it —
2963/// typing enters text into a control that takes typed text, choosing selects an option the
2964/// `<select>` offers — so the verb is answered whether or not the page listens: the value it
2965/// entered is on the page, and the control's validity with it. A click, a press, a submit and a
2966/// hover answer only through a listener (a checkbox's click is answered by its toggle, where the
2967/// click is decided).
2968pub fn answers_by_itself(verb: crate::semantics::drive::Verb, selector: &str, value: &str) -> bool {
2969    DOM.with(|d| {
2970        let d = d.borrow();
2971        d.takes(verb, d.query_selector(selector), value)
2972    })
2973}
2974
2975/// The `<input>` types a visitor types text into.
2976const TEXT_ENTRY_TYPES: [&str; 8] = [
2977    "", "text", "search", "url", "tel", "email", "password", "number",
2978];
2979
2980impl DomRecorder {
2981    /// Whether `verb`'s default action applies to `node`: a `<textarea>` or a text-entry `<input>`
2982    /// takes typing, a `<select>` takes the choice of an option it offers — each only when the
2983    /// visitor can reach it (not disabled, read-only or hidden: [`DomRecorder::barred`]).
2984    fn takes(&self, verb: crate::semantics::drive::Verb, node: u32, value: &str) -> bool {
2985        use crate::semantics::drive::Verb;
2986        let Some(n) = self.get(node) else {
2987            return false;
2988        };
2989        let reachable = !self.barred(node);
2990        match verb {
2991            Verb::Type => {
2992                reachable
2993                    && (n.tag.eq_ignore_ascii_case("textarea")
2994                        || (n.tag.eq_ignore_ascii_case("input")
2995                            && TEXT_ENTRY_TYPES.contains(&input_type(n).as_str())))
2996            }
2997            Verb::Choose => reachable && self.option_offering(node, value).is_some(),
2998            Verb::Click | Verb::Submit | Verb::Press | Verb::Hover => false,
2999        }
3000    }
3001}
3002
3003/// What a control holds after a visitor types `text` into it, as a browser sanitizes typing: a
3004/// `number` field takes only the characters a number is written with; a `maxlength` cuts the text
3005/// off (in UTF-16 units); a single-line field drops line breaks; an `email` or `url` field drops
3006/// the whitespace around it (around each address, for `multiple`); and a `number` field holds only
3007/// a number — typed characters that make none leave it empty, with the bad input flagged.
3008fn typed_value(n: &Node, text: &str) -> (String, bool) {
3009    let tag = n.tag.to_ascii_lowercase();
3010    let ty = input_type(n);
3011    let mut held: String = match (tag.as_str(), ty.as_str()) {
3012        ("textarea", _) => text.to_string(),
3013        (_, "number") => text
3014            .chars()
3015            .filter(|c| c.is_ascii_digit() || matches!(c, '+' | '-' | '.' | 'e' | 'E'))
3016            .collect(),
3017        _ => text.chars().filter(|c| *c != '\n' && *c != '\r').collect(),
3018    };
3019    if let Some(max) = n
3020        .attrs
3021        .get("maxlength")
3022        .and_then(|m| m.trim().parse::<usize>().ok())
3023    {
3024        let mut units = 0;
3025        held = held
3026            .chars()
3027            .take_while(|c| {
3028                units += c.len_utf16();
3029                units <= max
3030            })
3031            .collect();
3032    }
3033    let trim = |s: &str| {
3034        s.trim_matches(|c: char| c.is_ascii_whitespace())
3035            .to_string()
3036    };
3037    match ty.as_str() {
3038        "email" if n.attrs.contains_key("multiple") => (
3039            held.split(',').map(trim).collect::<Vec<_>>().join(","),
3040            false,
3041        ),
3042        "email" | "url" => (trim(&held), false),
3043        "number" if valid_float(&held) => (held, false),
3044        "number" => (String::new(), !held.is_empty()),
3045        _ => (held, false),
3046    }
3047}
3048
3049/// An `<input>`'s type, lowercased (`text` when it names none); empty for any other element.
3050fn input_type(n: &Node) -> String {
3051    match n.tag.eq_ignore_ascii_case("input") {
3052        true => n
3053            .attrs
3054            .get("type")
3055            .map(|t| t.trim().to_ascii_lowercase())
3056            .unwrap_or_else(|| "text".to_string()),
3057        false => String::new(),
3058    }
3059}
3060
3061/// The default focus action of `verb` on the element `selector` names, carried out, as the events
3062/// it raises in the order a browser raises them: `blur` on the element that lost focus, then
3063/// `focus` on the one that gained it — each `(element, event)`, before the verb's own events.
3064/// Nothing when the selector names no element (the verb reports that itself) or focus stays put.
3065pub fn focus_by(verb: crate::semantics::drive::Verb, selector: &str) -> Vec<(i64, &'static str)> {
3066    let node = query_selector(selector);
3067    if node < 0 {
3068        return Vec::new();
3069    }
3070    match focus_for(verb, node) {
3071        Some((blurred, gained)) => [(blurred, "blur"), (gained, "focus")]
3072            .into_iter()
3073            .filter(|(element, _)| *element >= 0)
3074            .collect(),
3075        None => Vec::new(),
3076    }
3077}
3078/// Where an event dispatched at one element goes: the listeners it reaches, in order, and whether one
3079/// of them cancels its default action — [`dispatch_path`].
3080#[derive(Clone, Debug, Default, PartialEq, Eq)]
3081pub struct DispatchPath {
3082    /// Each node whose listener the event reaches, in the order it reaches them, named by
3083    /// [`node_address`] — so the host delivering to each finds it as the page stands when its turn
3084    /// comes, after the turns before it have repainted.
3085    pub stops: Vec<String>,
3086    /// Whether a listener the walk reaches cancels the event's default action: one registered with
3087    /// `.prevent` and not `.passive`.
3088    pub prevented: bool,
3089}
3090
3091/// The walk `event` dispatched at `target` takes, as a browser dispatches it: the capture-phase
3092/// listeners of the target's ancestors from the root down, the target itself, then — when the event
3093/// bubbles ([`event_bubbles`]) — the other ancestors' listeners from the target back up. A listener
3094/// registered `.stop` ends the walk after it. The target is always on the walk: whether anything
3095/// there answers is the page's own dispatcher's to say.
3096pub fn dispatch_path(target: i64, event: &str) -> DispatchPath {
3097    DOM.with(|d| d.borrow().dispatch_path(target as u32, event))
3098}
3099
3100/// The events a browser dispatches WITHOUT a bubbling phase — the ones a listener on an ancestor
3101/// hears only by capturing. Every other event bubbles.
3102pub const NON_BUBBLING_EVENTS: &[&str] = &[
3103    "focus",
3104    "blur",
3105    "mouseenter",
3106    "mouseleave",
3107    "pointerenter",
3108    "pointerleave",
3109    "load",
3110    "unload",
3111    "error",
3112    "abort",
3113    "scroll",
3114    "scrollend",
3115    "resize",
3116    "invalid",
3117    "toggle",
3118];
3119
3120/// Whether `event` bubbles in a browser — see [`NON_BUBBLING_EVENTS`].
3121pub fn event_bubbles(event: &str) -> bool {
3122    !NON_BUBBLING_EVENTS.contains(&event)
3123}
3124
3125/// A link a click lands on: the nearest `<a href>` that is the clicked element or holds it, as a
3126/// browser's activation behaviour finds it.
3127#[derive(Clone, Debug, PartialEq, Eq)]
3128pub struct Link {
3129    pub href: String,
3130    /// Whether following it moves THIS document: no `download`, and a `target` of this browsing
3131    /// context (none, `_self`, `_top`, `_parent`). A download, or a link to another window, leaves
3132    /// the page where it is.
3133    pub here: bool,
3134}
3135
3136/// The link a click on `node` lands on ([`Link`]), if any.
3137pub fn link_of(node: i64) -> Option<Link> {
3138    DOM.with(|d| {
3139        let d = d.borrow();
3140        let chain = d.ancestors_of(node as u32)?;
3141        let a = std::iter::once(node as u32)
3142            .chain(chain.into_iter().rev())
3143            .filter_map(|h| d.get(h))
3144            .find(|n| {
3145                !n.is_text && n.tag.eq_ignore_ascii_case("a") && n.attrs.contains_key("href")
3146            })?;
3147        let target = a
3148            .attrs
3149            .get("target")
3150            .map(|t| t.trim().to_ascii_lowercase())
3151            .unwrap_or_default();
3152        Some(Link {
3153            href: a.attrs.get("href").cloned().unwrap_or_default(),
3154            here: !a.attrs.contains_key("download")
3155                && matches!(target.as_str(), "" | "_self" | "_top" | "_parent"),
3156        })
3157    })
3158}
3159
3160/// Add a class to an element's class list, leaving the others alone.
3161pub fn add_class(node: i64, class: &str) {
3162    let current = DOM.with(|d| {
3163        d.borrow()
3164            .get(node as u32)
3165            .and_then(|n| n.attrs.get("class").cloned())
3166            .unwrap_or_default()
3167    });
3168    if current.split_whitespace().any(|c| c == class) {
3169        return;
3170    }
3171    let next = if current.is_empty() {
3172        class.to_string()
3173    } else {
3174        format!("{current} {class}")
3175    };
3176    set_attribute(node, "class", &next);
3177}
3178/// Remove a class from an element's class list, leaving the others alone.
3179pub fn remove_class(node: i64, class: &str) {
3180    let current = DOM.with(|d| {
3181        d.borrow()
3182            .get(node as u32)
3183            .and_then(|n| n.attrs.get("class").cloned())
3184            .unwrap_or_default()
3185    });
3186    let next: Vec<&str> = current.split_whitespace().filter(|c| *c != class).collect();
3187    set_attribute(node, "class", &next.join(" "));
3188}
3189pub fn add_event_listener(node: i64, event: &str, closure: RuntimeValue) {
3190    DOM.with(|d| {
3191        d.borrow_mut()
3192            .add_event_listener(node as u32, event, closure)
3193    });
3194}
3195
3196/// Record that a listener was attached, without handing the recorder a closure to fire.
3197///
3198/// A replayed stream names a listener whose handler is a closure in the program that emitted it,
3199/// so the listener is not the recorder's to call. The BROWSER still has to be told to listen: a
3200/// page reaches one as a stream of DOM calls, and a registration missing from that stream is a
3201/// registration the browser never hears about. The op-log is the contract between the tiers, so
3202/// it carries the attachment even when this recorder is not the thing that will fire it.
3203pub fn note_event_listener(node: i64, event: &str) {
3204    DOM.with(|d| {
3205        let mut d = d.borrow_mut();
3206        d.register(node as u32, event);
3207        d.log.push(DomOp::AddEventListener {
3208            node: node as u32,
3209            event: event.to_string(),
3210        })
3211    });
3212}
3213/// `eventKey() -> Text` — the key of the keyboard event being dispatched, `""` otherwise.
3214///
3215/// ★ THE ONE THING A KEY MODIFIER NEEDS. `on-keydown.enter` gates on which key was pressed, and
3216/// no accessor reported one — `eventValue` is the input's VALUE, which for a keydown is the text
3217/// so far, not the keystroke. So the browser tier registered a listener with no key comparison
3218/// while the reference tier gated correctly, and the modifier was refused rather than guessed.
3219pub fn event_key() -> String {
3220    DOM.with(|d| {
3221        d.borrow()
3222            .current_event
3223            .as_ref()
3224            .map(|e| e.key.clone())
3225            .unwrap_or_default()
3226    })
3227}
3228pub fn event_value() -> String {
3229    DOM.with(|d| {
3230        d.borrow()
3231            .current_event
3232            .as_ref()
3233            .map(|e| e.value.clone())
3234            .unwrap_or_default()
3235    })
3236}
3237pub fn event_target() -> i64 {
3238    DOM.with(|d| {
3239        d.borrow()
3240            .current_event
3241            .as_ref()
3242            .map(|e| e.target as i64)
3243            .unwrap_or(-1)
3244    })
3245}
3246
3247/// Simulate an event dispatch on the NATIVE tiers: look up the `(node, event)`
3248/// handler, set the current event (its target + value), and hand the closure back
3249/// to the caller to invoke (the interpreter owns closure invocation). Returns the
3250/// closure, or `None` if no handler is registered.
3251pub fn begin_dispatch(node: i64, event: &str, value: &str) -> Option<RuntimeValue> {
3252    begin_dispatch_keyed(node, event, value, "")
3253}
3254
3255/// [`begin_dispatch`], naming the key for a keyboard event so `eventKey` can report it.
3256pub fn begin_dispatch_keyed(
3257    node: i64,
3258    event: &str,
3259    value: &str,
3260    key: &str,
3261) -> Option<RuntimeValue> {
3262    DOM.with(|d| {
3263        let mut dom = d.borrow_mut();
3264        let closure = dom.listener_for(node as u32, event)?;
3265        dom.current_event = Some(CurrentEvent {
3266            target: node as u32,
3267            value: value.to_string(),
3268            key: key.to_string(),
3269        });
3270        Some(closure)
3271    })
3272}
3273pub fn end_dispatch() {
3274    DOM.with(|d| d.borrow_mut().current_event = None);
3275}
3276
3277// --- The correlated fetch contract. `fetch_send` mints the request id every other
3278// call is keyed by; `fetchText` is sugar over the same engine (GET, no body, no
3279// headers), so the two surfaces can never behave differently. ---
3280
3281/// `fetchSend(method, url, body, headers, onDone) -> Int` — register a request,
3282/// returning its correlation id. Contract violations (oversized body, disallowed
3283/// header) fail the request immediately with a structured error.
3284pub fn fetch_send(
3285    method: &str,
3286    url: &str,
3287    body: &str,
3288    headers: Vec<(String, String)>,
3289    closure: RuntimeValue,
3290) -> i64 {
3291    DOM.with(|d| {
3292        d.borrow_mut()
3293            .fetch_send(method, url, body, headers, closure)
3294    })
3295}
3296
3297/// The `fetchText(url, onDone)` sugar — a GET through the correlated engine.
3298pub fn fetch_start(url: &str, closure: RuntimeValue) {
3299    DOM.with(|d| {
3300        d.borrow_mut()
3301            .fetch_send("GET", url, "", Vec::new(), closure);
3302    });
3303}
3304
3305/// `fetchAbort(reqId)` — the request becomes FINAL with the `"abort"` error;
3306/// a late response can never deliver.
3307pub fn fetch_abort(req: i64) {
3308    DOM.with(|d| d.borrow_mut().fetch_abort(req));
3309}
3310
3311/// `fetchSetTimeout(reqId, millis)` — arm (`> 0`) or clear (`0`) the timeout.
3312pub fn fetch_set_timeout(req: i64, millis: i64) {
3313    DOM.with(|d| d.borrow_mut().fetch_set_timeout(req, millis));
3314}
3315
3316/// `responseStatus(reqId) -> Int` — the delivered HTTP status; `0` while pending
3317/// or on any transport failure (`responseError` then names the failure).
3318pub fn response_status(req: i64) -> i64 {
3319    DOM.with(|d| {
3320        d.borrow()
3321            .fetch_response(req)
3322            .map(|r| r.status)
3323            .unwrap_or(0)
3324    })
3325}
3326
3327/// `responseBody(reqId) -> Text` — the delivered body; `""` while pending or failed.
3328pub fn response_body(req: i64) -> String {
3329    DOM.with(|d| {
3330        d.borrow()
3331            .fetch_response(req)
3332            .map(|r| r.body.clone())
3333            .unwrap_or_default()
3334    })
3335}
3336
3337/// `responseHeader(reqId, name) -> Text` — a delivered response header,
3338/// case-insensitively; `""` when absent.
3339pub fn response_header(req: i64, name: &str) -> String {
3340    DOM.with(|d| {
3341        d.borrow()
3342            .fetch_response(req)
3343            .and_then(|r| {
3344                r.headers
3345                    .iter()
3346                    .find(|(n, _)| n.eq_ignore_ascii_case(name))
3347                    .map(|(_, v)| v.clone())
3348            })
3349            .unwrap_or_default()
3350    })
3351}
3352
3353/// `responseError(reqId) -> Text` — `""` when none (pending or delivered OK);
3354/// otherwise `"timeout"` | `"abort"` | `"toolarge"` | `"network: <detail>"`.
3355pub fn response_error(req: i64) -> String {
3356    DOM.with(|d| {
3357        d.borrow()
3358            .fetch_response(req)
3359            .map(|r| r.error.clone())
3360            .unwrap_or_default()
3361    })
3362}
3363
3364/// The `responseText()` sugar — the body of the request CURRENTLY being delivered
3365/// (set by [`begin_fetch_resolve`]/[`fail_fetch`], cleared by [`end_fetch_resolve`]).
3366pub fn response_text() -> String {
3367    DOM.with(|d| {
3368        let dom = d.borrow();
3369        dom.current_fetch
3370            .and_then(|req| dom.fetch_response(req).map(|r| r.body.clone()))
3371            .unwrap_or_default()
3372    })
3373}
3374
3375/// Every request the run registered, in id order — the tw≡VM parity surface for
3376/// the REQUEST side (method, url, body, headers, timeout, abort state).
3377/// The content type an answer a test arranged ([`stub_fetch`]) is served with, on every tier.
3378pub const STUB_CONTENT_TYPE: &str = "application/json";
3379
3380/// Arrange the answer to a request the page is expected to send. A later stub for the same
3381/// (method, url) REPLACES an earlier one, so a test can set the scene and then change it.
3382pub fn stub_fetch(method: &str, url: &str, status: i64, body: &str) {
3383    DOM.with(|d| {
3384        let mut d = d.borrow_mut();
3385        let key = (method.to_ascii_uppercase(), url.to_string());
3386        let value = (status, body.to_string());
3387        match d.fetch_stubs.iter_mut().find(|(k, _)| *k == key) {
3388            Some(slot) => slot.1 = value,
3389            None => d.fetch_stubs.push((key, value)),
3390        }
3391    });
3392}
3393
3394/// The answer arranged for `req`, if a test arranged one.
3395pub fn stubbed_answer(req: i64) -> Option<(i64, String)> {
3396    DOM.with(|d| {
3397        let d = d.borrow();
3398        let r = d.fetch_requests().into_iter().find(|r| r.req == req)?;
3399        let key = (r.method.to_ascii_uppercase(), r.url);
3400        d.fetch_stubs
3401            .iter()
3402            .find(|(k, _)| *k == key)
3403            .map(|(_, v)| v.clone())
3404    })
3405}
3406
3407pub fn fetch_requests() -> Vec<FetchRequest> {
3408    DOM.with(|d| d.borrow().fetch_requests())
3409}
3410
3411/// Deliver request `req` with `status`/`headers`/`body`: record its per-request
3412/// response, point the current-delivery cursor at it, and hand back its completion
3413/// closure for the caller to invoke. `None` if the request is unknown or already
3414/// FINAL (aborted/failed/delivered — the anti-double-delivery rule).
3415pub fn begin_fetch_resolve(
3416    req: i64,
3417    status: i64,
3418    headers: &[(String, String)],
3419    body: &str,
3420) -> Option<RuntimeValue> {
3421    DOM.with(|d| d.borrow_mut().fetch_resolve(req, status, headers, body))
3422}
3423
3424/// Deliver a structured transport failure for `req` (same finality rule).
3425pub fn fail_fetch(req: i64, error: &str) -> Option<RuntimeValue> {
3426    DOM.with(|d| d.borrow_mut().fetch_fail(req, error))
3427}
3428
3429pub fn end_fetch_resolve() {
3430    DOM.with(|d| d.borrow_mut().current_fetch = None);
3431}
3432
3433// --- The router contract. `route_path`/`route_query` read the location;
3434// `navigate`/`replace_route` record a navigation and move the current path;
3435// `on_route` registers a popstate handler closure. `init_route` seeds the initial
3436// location (a refresh / deep-link boots AT that path), and `deliver_pop` hands every
3437// registered handler over with the new path — the deterministic back/forward driver,
3438// mirroring `begin_fetch_resolve` (returns the closures for the caller to invoke). ---
3439
3440/// `routePath() -> Text` — the current location pathname (`"/"` at boot).
3441pub fn route_path() -> String {
3442    DOM.with(|d| d.borrow().route_path.clone())
3443}
3444
3445/// `routeQuery(name) -> Text` — a query param by name (`""` when absent).
3446pub fn route_query(name: &str) -> String {
3447    DOM.with(|d| d.borrow().route_query_value(name))
3448}
3449
3450/// `navigate(path)` — history.pushState: record the navigation, move the path.
3451pub fn navigate(path: &str) {
3452    DOM.with(|d| d.borrow_mut().navigate(path, false));
3453}
3454
3455/// `replaceRoute(path)` — history.replaceState: record the navigation (replace),
3456/// move the path.
3457pub fn replace_route(path: &str) {
3458    DOM.with(|d| d.borrow_mut().navigate(path, true));
3459}
3460
3461/// `onRoute(handler)` — register a popstate handler closure: a back/forward fires it, and every
3462/// other handler registered before it, as a browser's `popstate` reaches every listener.
3463pub fn on_route(handler: RuntimeValue) {
3464    DOM.with(|d| d.borrow_mut().route_handlers.push(handler));
3465}
3466
3467/// The recorded navigation op-log — the tw≡VM parity surface for the router.
3468pub fn route_ops() -> Vec<RouteOp> {
3469    DOM.with(|d| d.borrow().route_ops.clone())
3470}
3471
3472/// Seed the initial location (a refresh / deep-link: the app boots already AT this
3473/// path with this query string). Called by tests before a run.
3474pub fn init_route(path: &str, query: &str) {
3475    DOM.with(|d| {
3476        let mut dom = d.borrow_mut();
3477        dom.route_path = path.to_string();
3478        dom.route_query = query.to_string();
3479        // Booting AT a path means that path is where Back would stop, not "/".
3480        dom.route_history = vec![path.to_string()];
3481    });
3482}
3483
3484/// `Go back.` — step one entry back and hand over the route handlers to fire, exactly as a real
3485/// popstate would. `None` when there is nowhere to go: the first page a visitor lands on has no
3486/// previous entry, and a browser's Back button does nothing there rather than inventing one.
3487pub fn go_back() -> Option<(String, Vec<RuntimeValue>)> {
3488    DOM.with(|d| {
3489        let mut dom = d.borrow_mut();
3490        let path = dom.go_back()?;
3491        Some((path, dom.route_handlers.clone()))
3492    })
3493}
3494
3495/// Deliver a popstate (back/forward) at `path`: move the current path and hand back every
3496/// registered handler closure, in registration order, for the caller to invoke (the interpreter
3497/// owns closure invocation) — none when nothing is registered. Deterministic — never a real
3498/// browser event.
3499pub fn deliver_pop(path: &str) -> Vec<RuntimeValue> {
3500    DOM.with(|d| {
3501        let mut dom = d.borrow_mut();
3502        dom.route_path = path.to_string();
3503        dom.route_handlers.clone()
3504    })
3505}
3506
3507// ─── Wave 7.4: the retained-tree primitives `vdom.lg` needs and `runtime.js`/`rc` did not,
3508// wired through `semantics::patch`'s opcodes 14-21 ────────────────────────────────────────────
3509
3510/// Event-modifier bits a listener registration carries (`listenFlags`, `DomOp::Listen`) —
3511/// `.prevent` / `.stop` / `.once` / `.capture` / `.passive`, one bit each, in that order. Every
3512/// host dispatches by them as a browser does ([`dispatch_path`]), except `.once`, which the
3513/// lowering keeps itself: a host never retires a listener on its own.
3514pub const LISTEN_PREVENT: u8 = 1 << 0;
3515pub const LISTEN_STOP: u8 = 1 << 1;
3516pub const LISTEN_ONCE: u8 = 1 << 2;
3517pub const LISTEN_CAPTURE: u8 = 1 << 3;
3518pub const LISTEN_PASSIVE: u8 = 1 << 4;
3519
3520/// The first id of the range a patch stream's emitter names (`vdom.lg`'s `vdWire`); every id below
3521/// it is a creation ordinal this recorder minted itself, so the two never collide.
3522pub const STREAM_ID_BASE: u32 = 1 << 31;
3523
3524impl DomRecorder {
3525    /// The node whose `children` currently lists `node`, wherever in the tree that is. `Node`
3526    /// carries no parent pointer of its own — a keyed diff moves and removes nodes rarely enough,
3527    /// against a recorder small enough, that a search is the honest cost rather than a field kept
3528    /// consistent by every other mutator for one caller.
3529    fn parent_of(&self, node: u32) -> Option<u32> {
3530        let minted = self
3531            .nodes
3532            .iter()
3533            .enumerate()
3534            .map(|(i, n)| (i as u32, Some(n)));
3535        let streamed = self
3536            .streamed
3537            .iter()
3538            .enumerate()
3539            .map(|(i, n)| (i as u32 + STREAM_ID_BASE, n.as_ref()));
3540        minted
3541            .chain(streamed)
3542            .find(|(_, n)| n.is_some_and(|n| n.children.contains(&node)))
3543            .map(|(id, _)| id)
3544    }
3545
3546    fn remove_node(&mut self, node: u32) {
3547        let node = self.resolve(node);
3548        self.leaving(node);
3549        if let Some(parent) = self.parent_of(node) {
3550            if let Some(p) = self.get_mut(parent) {
3551                p.children.retain(|&c| c != node);
3552            }
3553        }
3554        self.log.push(DomOp::RemoveNode { node });
3555    }
3556
3557    fn insert_before(&mut self, parent: u32, child: u32, before: u32) {
3558        let (child, before) = (self.resolve(child), self.resolve(before));
3559        let moved = self.parent_of(child).is_some();
3560        let blurred = (moved && self.focused != u32::MAX && self.holds(child, self.focused))
3561            .then_some(self.focused);
3562        self.leaving(child);
3563        if let Some(cur) = self.parent_of(child) {
3564            if let Some(p) = self.get_mut(cur) {
3565                p.children.retain(|&c| c != child);
3566            }
3567        }
3568        if let Some(p) = self.get_mut(parent) {
3569            let at = if before == u32::MAX {
3570                p.children.len()
3571            } else {
3572                p.children
3573                    .iter()
3574                    .position(|&c| c == before)
3575                    .unwrap_or(p.children.len())
3576            };
3577            p.children.insert(at, child);
3578        }
3579        // A moved element keeps its listeners, so the `blur` its move raised reaches them once the
3580        // turn has committed, as a browser delivers it.
3581        if let Some(element) = blurred {
3582            let at = self.address(element);
3583            self.raised.push((at, "blur"));
3584        }
3585        self.log.push(DomOp::InsertBefore {
3586            parent,
3587            child,
3588            before,
3589        });
3590    }
3591
3592    fn remove_attribute(&mut self, node: u32, name: &str) {
3593        if let Some(n) = self.get_mut(node) {
3594            n.attrs.remove(name);
3595        }
3596        self.log.push(DomOp::RemoveAttribute {
3597            node,
3598            name: name.to_string(),
3599        });
3600    }
3601
3602    /// Release `node`'s slot AND every descendant's: detach `node` from its parent (if it still
3603    /// has one), then walk the whole subtree clearing each node's content and listeners, so
3604    /// `render()` and every page query see nothing of any of it — a freed id is exactly as gone
3605    /// as one that never existed. ONE op frees an entire subtree regardless of its size; a
3606    /// descendant is never freed by its own `FreeNode` — only ever as part of an ancestor's.
3607    fn free_node(&mut self, node: u32) {
3608        let node = self.resolve(node);
3609        self.leaving(node);
3610        if let Some(parent) = self.parent_of(node) {
3611            if let Some(p) = self.get_mut(parent) {
3612                p.children.retain(|&c| c != node);
3613            }
3614        }
3615        let mut stack = vec![node];
3616        while let Some(n) = stack.pop() {
3617            if let Some(existing) = self.get(n) {
3618                stack.extend(existing.children.iter().copied());
3619            }
3620            self.listeners.retain(|(ln, _, _)| *ln != n);
3621            self.registrations.retain(|(rn, _), _| *rn != n);
3622            match n.checked_sub(STREAM_ID_BASE) {
3623                Some(i) => {
3624                    if let Some(slot) = self.streamed.get_mut(i as usize) {
3625                        *slot = None;
3626                    }
3627                }
3628                None => {
3629                    if let Some(nn) = self.get_mut(n) {
3630                        *nn = Node::default();
3631                    }
3632                }
3633            }
3634        }
3635        self.log.push(DomOp::FreeNode { node });
3636    }
3637
3638    fn listen(&mut self, node: u32, event: &str, flags: u8) {
3639        let node = self.resolve(node);
3640        self.registrations.insert((node, event.to_string()), flags);
3641        self.log.push(DomOp::Listen {
3642            node,
3643            event: event.to_string(),
3644            flags,
3645        });
3646    }
3647
3648    fn unlisten(&mut self, node: u32, event: &str) {
3649        let node = self.resolve(node);
3650        self.registrations.remove(&(node, event.to_string()));
3651        self.log.push(DomOp::Unlisten {
3652            node,
3653            event: event.to_string(),
3654        });
3655    }
3656
3657    /// A listener for `event` on `node`, its flags kept when it is already registered.
3658    fn register(&mut self, node: u32, event: &str) {
3659        let node = self.resolve(node);
3660        self.registrations
3661            .entry((node, event.to_string()))
3662            .or_insert(0);
3663    }
3664
3665    /// The flags of the listener for `event` on `node`, if one is registered.
3666    fn registration(&self, node: u32, event: &str) -> Option<u8> {
3667        self.registrations.get(&(node, event.to_string())).copied()
3668    }
3669
3670    /// See [`dispatch_path`].
3671    fn dispatch_path(&self, target: u32, event: &str) -> DispatchPath {
3672        let ancestors = self.ancestors_of(target).unwrap_or_default();
3673        let mut stops = vec![];
3674        let mut prevented = false;
3675        let mut reach = |node: u32, flags: u8| {
3676            stops.push(node);
3677            prevented |= flags & LISTEN_PREVENT != 0 && flags & LISTEN_PASSIVE == 0;
3678            flags & LISTEN_STOP != 0
3679        };
3680        let capturing = ancestors.iter().filter_map(|&a| {
3681            self.registration(a, event)
3682                .filter(|f| f & LISTEN_CAPTURE != 0)
3683                .map(|f| (a, f))
3684        });
3685        let bubbling = ancestors.iter().rev().filter_map(|&a| {
3686            self.registration(a, event)
3687                .filter(|f| f & LISTEN_CAPTURE == 0)
3688                .map(|f| (a, f))
3689        });
3690        let at_target = std::iter::once((target, self.registration(target, event).unwrap_or(0)));
3691        let walk: Vec<(u32, u8)> = match event_bubbles(event) {
3692            true => capturing.chain(at_target).chain(bubbling).collect(),
3693            false => capturing.chain(at_target).collect(),
3694        };
3695        for (node, flags) in walk {
3696            if reach(node, flags) {
3697                break;
3698            }
3699        }
3700        let addresses = stops.into_iter().map(|n| self.address(n)).collect();
3701        DispatchPath {
3702            stops: addresses,
3703            prevented,
3704        }
3705    }
3706
3707    /// See [`node_address`].
3708    fn address(&self, node: u32) -> String {
3709        let id = self
3710            .get(node)
3711            .and_then(|n| n.attrs.get("id"))
3712            .filter(|id| !id.is_empty());
3713        if let Some(id) = id.filter(|id| self.query_selector(&format!("#{id}")) == node) {
3714            return format!("#{id}");
3715        }
3716        if node == self.body() {
3717            return "body".to_string();
3718        }
3719        match self.place_of(node) {
3720            Some((indices, tag)) => {
3721                let path: Vec<String> = indices.iter().map(usize::to_string).collect();
3722                format!("{PLACE}{}:{tag}", path.join("/"))
3723            }
3724            None => String::new(),
3725        }
3726    }
3727}
3728
3729impl DomRecorder {
3730    /// Whether `node` is an element the open hydration adopted — whose writes this stream makes
3731    /// bring the markup's own node in line rather than build a new one.
3732    fn adopting(&self, node: u32) -> bool {
3733        self.hydration
3734            .as_ref()
3735            .is_some_and(|h| h.written.contains_key(&node))
3736    }
3737
3738    /// A control the visitor changed before the page ran keeps the change, and the page hears it as
3739    /// the `input` it missed once the stream's turn has committed.
3740    fn keep_the_visitors(&mut self, node: u32) {
3741        let at = self.address(node);
3742        self.raised.push((at, "input"));
3743    }
3744
3745    fn hydrate(&mut self, root: u32) -> Result<(), String> {
3746        if self.hydration.is_some() {
3747            return Err(format!(
3748                "the stream hydrates node {root} while an earlier hydration in it is still open"
3749            ));
3750        }
3751        if let Some(n) = self.get_mut(root) {
3752            n.attrs
3753                .remove(logicaffeine_language::parser::lgx_lower::PRERENDERED_ATTR);
3754        }
3755        let mut h = Hydration::default();
3756        h.cursors.insert(root, None);
3757        self.hydration = Some(h);
3758        self.log.push(DomOp::Hydrate { root });
3759        Ok(())
3760    }
3761
3762    fn open_hydration(&self, op: &str) -> Result<(), String> {
3763        match self.hydration {
3764            Some(_) => Ok(()),
3765            None => Err(format!("the stream sends `{op}` outside a hydration: only a stream that opened one with `Hydrate` claims nodes the markup holds")),
3766        }
3767    }
3768
3769    fn mismatch(&mut self) {
3770        if let Some(h) = self.hydration.as_mut() {
3771            h.mismatches += 1;
3772        }
3773    }
3774
3775    /// Where the children of `parent` no claim has reached begin: just past `last`, the child bound
3776    /// under it last.
3777    fn unclaimed_from(&self, parent: u32, last: Option<u32>) -> usize {
3778        let kids = self
3779            .get(parent)
3780            .map(|n| n.children.as_slice())
3781            .unwrap_or(&[]);
3782        match last {
3783            Some(l) => kids
3784                .iter()
3785                .position(|&c| c == l)
3786                .map_or(kids.len(), |i| i + 1),
3787            None => 0,
3788        }
3789    }
3790
3791    /// An element the host put in the document for itself (`data-logos-host`), never page content.
3792    fn host_own(&self, h: u32) -> bool {
3793        self.get(h)
3794            .is_some_and(|n| !n.is_text && n.attrs.contains_key("data-logos-host"))
3795    }
3796
3797    /// The index of the first child of `parent` from `from` on that is a page element.
3798    fn next_element(&self, parent: u32, from: usize) -> Option<usize> {
3799        let kids = self
3800            .get(parent)
3801            .map(|n| n.children.clone())
3802            .unwrap_or_default();
3803        (from..kids.len())
3804            .find(|&i| self.get(kids[i]).is_some_and(|n| !n.is_text) && !self.host_own(kids[i]))
3805    }
3806
3807    /// The text claims waiting under `parent`, taken in stream order.
3808    fn take_texts(&mut self, parent: u32) -> Vec<(u32, String)> {
3809        let Some(h) = self.hydration.as_mut() else {
3810            return Vec::new();
3811        };
3812        let (mine, rest): (Vec<_>, Vec<_>) = std::mem::take(&mut h.texts)
3813            .into_iter()
3814            .partition(|(p, ..)| *p == parent);
3815        h.texts = rest;
3816        mine.into_iter()
3817            .map(|(_, node, text)| (node, text))
3818            .collect()
3819    }
3820
3821    /// Moves the node `old` names under `parent` to the stream id `new`, which the stream addresses
3822    /// it by from now on; `old` keeps reaching it.
3823    fn rebind(&mut self, parent: u32, old: u32, new: u32) -> Result<(), String> {
3824        let old = self.resolve(old);
3825        let node = match old.checked_sub(STREAM_ID_BASE) {
3826            Some(i) => self.streamed.get_mut(i as usize).and_then(Option::take),
3827            None => self.nodes.get_mut(old as usize).map(std::mem::take),
3828        }
3829        .ok_or_else(|| format!("the stream adopts node {old}, which holds nothing"))?;
3830        self.create_as(new, node)?;
3831        if let Some(p) = self.get_mut(parent) {
3832            for c in p.children.iter_mut().filter(|c| **c == old) {
3833                *c = new;
3834            }
3835        }
3836        for target in self.forwarded.values_mut().filter(|t| **t == old) {
3837            *target = new;
3838        }
3839        self.forwarded.insert(old, new);
3840        for held in [&mut self.focused, &mut self.scrolled_to, &mut self.selected] {
3841            if *held == old {
3842                *held = new;
3843            }
3844        }
3845        let moved: Vec<(String, u8)> = self
3846            .registrations
3847            .iter()
3848            .filter(|((n, _), _)| *n == old)
3849            .map(|((_, e), f)| (e.clone(), *f))
3850            .collect();
3851        for (event, flags) in moved {
3852            self.registrations.remove(&(old, event.clone()));
3853            self.registrations.insert((new, event), flags);
3854        }
3855        for (n, ..) in self.listeners.iter_mut().filter(|(n, ..)| *n == old) {
3856            *n = new;
3857        }
3858        Ok(())
3859    }
3860
3861    /// A new program's stream numbers its nodes from the first stream id again, so the nodes an
3862    /// earlier program's stream named — its page, which stays in the document for whoever holds it —
3863    /// take ids this recorder mints, and every stream id is free to bind.
3864    fn retire_stream(&mut self) {
3865        let mut renamed = BTreeMap::new();
3866        for (i, slot) in std::mem::take(&mut self.streamed).into_iter().enumerate() {
3867            if let Some(node) = slot {
3868                renamed.insert(STREAM_ID_BASE + i as u32, self.nodes.len() as u32);
3869                self.nodes.push(node);
3870            }
3871        }
3872        if renamed.is_empty() {
3873            return;
3874        }
3875        let name = |h: &mut u32| {
3876            if let Some(&minted) = renamed.get(h) {
3877                *h = minted;
3878            }
3879        };
3880        for n in &mut self.nodes {
3881            n.children.iter_mut().for_each(name);
3882        }
3883        self.forwarded.values_mut().for_each(name);
3884        for held in [&mut self.focused, &mut self.scrolled_to, &mut self.selected] {
3885            name(held);
3886        }
3887        if let Some(event) = self.current_event.as_mut() {
3888            name(&mut event.target);
3889        }
3890        for (n, ..) in &mut self.listeners {
3891            name(n);
3892        }
3893        self.registrations = std::mem::take(&mut self.registrations)
3894            .into_iter()
3895            .map(|((mut n, event), flags)| {
3896                name(&mut n);
3897                ((n, event), flags)
3898            })
3899            .collect();
3900    }
3901
3902    /// A text node the stream names, holding `text`, made where no text of the markup could hold it.
3903    fn fresh_text(&mut self, node: u32, text: &str) -> Result<u32, String> {
3904        self.create_as(
3905            node,
3906            Node {
3907                text: text.to_string(),
3908                is_text: true,
3909                ..Node::default()
3910            },
3911        )?;
3912        Ok(node)
3913    }
3914
3915    /// Makes the text between the children `from` and `to` of `parent` the text claims `run` name:
3916    /// the markup's text nodes split along the claims when the claims spell them, one node per claim
3917    /// otherwise; the host's own elements stay where they are. Answers where the settled run ends.
3918    fn settle_texts(
3919        &mut self,
3920        parent: u32,
3921        from: usize,
3922        to: usize,
3923        run: Vec<(u32, String)>,
3924    ) -> Result<usize, String> {
3925        let kids = self
3926            .get(parent)
3927            .map(|n| n.children.clone())
3928            .unwrap_or_default();
3929        let region = &kids[from..to];
3930        let gap: Vec<u32> = region
3931            .iter()
3932            .copied()
3933            .filter(|&c| self.get(c).is_some_and(|n| n.is_text))
3934            .collect();
3935        let held: Vec<String> = gap
3936            .iter()
3937            .map(|&c| self.get(c).map(|n| n.text.clone()).unwrap_or_default())
3938            .collect();
3939        let wanted: Vec<&str> = run.iter().map(|(_, t)| t.as_str()).collect();
3940        let mut settled: Vec<u32> = region
3941            .iter()
3942            .copied()
3943            .filter(|&c| self.host_own(c))
3944            .collect();
3945        match text_tiles(&held, &wanted) {
3946            Some(tiles) => {
3947                for (g, tile) in gap.iter().zip(&tiles) {
3948                    let mut kept = false;
3949                    for k in tile.clone() {
3950                        let (node, text) = &run[k];
3951                        if !kept && !text.is_empty() {
3952                            self.rebind(parent, *g, *node)?;
3953                            if let Some(n) = self.get_mut(*node) {
3954                                n.text = text.clone();
3955                            }
3956                            kept = true;
3957                            settled.push(*node);
3958                        } else {
3959                            settled.push(self.fresh_text(*node, text)?);
3960                        }
3961                    }
3962                }
3963                let trailing = tiles.last().map_or(0, |t| t.end);
3964                for (node, text) in &run[trailing..] {
3965                    settled.push(self.fresh_text(*node, text)?);
3966                }
3967            }
3968            None => {
3969                for (node, text) in &run {
3970                    settled.push(self.fresh_text(*node, text)?);
3971                }
3972            }
3973        }
3974        let end = from + settled.len();
3975        if let Some(p) = self.get_mut(parent) {
3976            p.children.splice(from..to, settled);
3977        }
3978        Ok(end)
3979    }
3980
3981    fn adopt(&mut self, parent: u32, node: u32, tag: &str) -> Result<(), String> {
3982        self.open_hydration("Adopt")?;
3983        self.log.push(DomOp::Adopt {
3984            parent,
3985            node,
3986            tag: tag.to_string(),
3987        });
3988        let Some(last) = self
3989            .hydration
3990            .as_ref()
3991            .and_then(|h| h.cursors.get(&parent).copied())
3992        else {
3993            self.mismatch();
3994            return Ok(());
3995        };
3996        let from = self.unclaimed_from(parent, last);
3997        let Some(at) = self.next_element(parent, from) else {
3998            self.mismatch();
3999            return Ok(());
4000        };
4001        let claimed = self.get(parent).map(|n| n.children[at]).unwrap_or(u32::MAX);
4002        if !self
4003            .get(claimed)
4004            .is_some_and(|n| n.tag.eq_ignore_ascii_case(tag))
4005        {
4006            self.mismatch();
4007            return Ok(());
4008        }
4009        let run = self.take_texts(parent);
4010        self.settle_texts(parent, from, at, run)?;
4011        self.rebind(parent, claimed, node)?;
4012        if let Some(h) = self.hydration.as_mut() {
4013            h.cursors.insert(parent, Some(node));
4014            h.cursors.insert(node, None);
4015            h.written.insert(node, Default::default());
4016        }
4017        Ok(())
4018    }
4019
4020    fn adopt_text(&mut self, parent: u32, node: u32, text: &str) -> Result<(), String> {
4021        self.open_hydration("AdoptText")?;
4022        self.log.push(DomOp::AdoptText {
4023            parent,
4024            node,
4025            text: text.to_string(),
4026        });
4027        match self.hydration.as_mut() {
4028            Some(h) if h.cursors.contains_key(&parent) => {
4029                h.texts.push((parent, node, text.to_string()))
4030            }
4031            _ => self.mismatch(),
4032        }
4033        Ok(())
4034    }
4035
4036    /// Ends the hydration the stream opened: each claimed parent's trailing text is settled, the
4037    /// markup no claim bound leaves, and an adopted element drops the attributes the stream did not
4038    /// write. Answers the mismatches; `0` when nothing hydrated.
4039    fn end_hydration(&mut self) -> Result<i64, String> {
4040        let Some(h) = self.hydration.clone() else {
4041            return Ok(0);
4042        };
4043        for (&parent, &last) in &h.cursors {
4044            if h.raw.contains(&parent) {
4045                continue;
4046            }
4047            let from = self.unclaimed_from(parent, last);
4048            let len = self.get(parent).map_or(0, |n| n.children.len());
4049            let to = self.next_element(parent, from).unwrap_or(len);
4050            let run = self.take_texts(parent);
4051            let end = self.settle_texts(parent, from, to, run)?;
4052            let rest: Vec<u32> = self
4053                .get(parent)
4054                .map(|n| n.children[end..].to_vec())
4055                .unwrap_or_default();
4056            let mut stays = Vec::new();
4057            for c in rest {
4058                if self.host_own(c) {
4059                    stays.push(c);
4060                    continue;
4061                }
4062                self.leaving(c);
4063            }
4064            if let Some(p) = self.get_mut(parent) {
4065                p.children.truncate(end);
4066                p.children.extend(stays);
4067            }
4068        }
4069        for (&el, written) in &h.written {
4070            if let Some(n) = self.get_mut(el) {
4071                n.attrs.retain(|name, _| written.contains(name));
4072            }
4073        }
4074        self.hydration = None;
4075        Ok(h.mismatches)
4076    }
4077}
4078
4079/// How the text claims `wanted` tile the markup's text nodes `held`, in order: for each held node,
4080/// the claims whose texts spell it exactly (an empty claim rides in any tile), or `None` when they
4081/// do not spell the held text. Claims past the last tile are empty.
4082fn text_tiles(held: &[String], wanted: &[&str]) -> Option<Vec<std::ops::Range<usize>>> {
4083    let mut tiles = Vec::with_capacity(held.len());
4084    let mut k = 0;
4085    for g in held {
4086        let start = k;
4087        let mut spelled = String::new();
4088        while spelled.len() < g.len() && k < wanted.len() {
4089            spelled.push_str(wanted[k]);
4090            k += 1;
4091        }
4092        if spelled != *g {
4093            return None;
4094        }
4095        tiles.push(start..k);
4096    }
4097    wanted[k..].iter().all(|t| t.is_empty()).then_some(tiles)
4098}
4099
4100pub fn remove_node(node: i64) {
4101    DOM.with(|d| d.borrow_mut().remove_node(node as u32));
4102}
4103
4104pub fn insert_before(parent: i64, child: i64, before: i64) {
4105    DOM.with(|d| {
4106        d.borrow_mut()
4107            .insert_before(parent as u32, child as u32, before as u32)
4108    });
4109}
4110
4111pub fn remove_attribute(node: i64, name: &str) {
4112    DOM.with(|d| d.borrow_mut().remove_attribute(node as u32, name));
4113}
4114
4115/// `listenFlags(el, event, flags)`: register `event` on `node` with the behavioural modifiers
4116/// `flags` names ([`LISTEN_PREVENT`] and its siblings), replacing the flags an earlier registration
4117/// gave it. [`dispatch_path`] walks by them.
4118pub fn listen(node: i64, event: &str, flags: u8) {
4119    DOM.with(|d| d.borrow_mut().listen(node as u32, event, flags));
4120}
4121
4122pub fn unlisten(node: i64, event: &str) {
4123    DOM.with(|d| d.borrow_mut().unlisten(node as u32, event));
4124}
4125
4126pub fn free_node(node: i64) {
4127    DOM.with(|d| d.borrow_mut().free_node(node as u32));
4128}
4129
4130/// `Hydrate(root)`: the markup `root` holds is the page's first paint, and the stream's claims
4131/// adopt its nodes — see [`DomOp::Hydrate`]. The root's `data-logos-prerendered` mark goes with it.
4132pub fn hydrate(root: i64) -> Result<(), String> {
4133    DOM.with(|d| d.borrow_mut().hydrate(root as u32))
4134}
4135
4136/// `Adopt(parent, node, tag)` — see [`DomOp::Adopt`].
4137pub fn adopt(parent: i64, node: i64, tag: &str) -> Result<(), String> {
4138    DOM.with(|d| d.borrow_mut().adopt(parent as u32, node as u32, tag))
4139}
4140
4141/// `AdoptText(parent, node, text)` — see [`DomOp::AdoptText`].
4142pub fn adopt_text(parent: i64, node: i64, text: &str) -> Result<(), String> {
4143    DOM.with(|d| d.borrow_mut().adopt_text(parent as u32, node as u32, text))
4144}
4145
4146/// A program starts: what an earlier program's stream drew keeps its place in the document under
4147/// ids this recorder mints, and the new program's stream binds its own from the first.
4148pub fn retire_stream() {
4149    DOM.with(|d| d.borrow_mut().retire_stream());
4150}
4151
4152/// Ends the hydration a stream opened, as the stream ends; answers its mismatches (`0` when the
4153/// stream hydrated nothing).
4154pub fn end_hydration() -> Result<i64, String> {
4155    DOM.with(|d| d.borrow_mut().end_hydration())
4156}
4157
4158// ─── Native constraint validation: what a browser checks before it submits a form ────────────
4159
4160/// Why a form control is invalid, flag for flag as a browser's `ValidityState` reports it. The
4161/// constraints modelled are `required`, `type="email"`/`url"`/`number"`, `minlength`/`maxlength`,
4162/// `pattern` and a number's `min`/`max`/`step`; `min`/`max`/`step` on the date and time types are
4163/// not modelled.
4164#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
4165pub struct Validity {
4166    pub value_missing: bool,
4167    pub type_mismatch: bool,
4168    pub too_short: bool,
4169    pub too_long: bool,
4170    pub pattern_mismatch: bool,
4171    pub range_underflow: bool,
4172    pub range_overflow: bool,
4173    pub step_mismatch: bool,
4174    pub bad_input: bool,
4175}
4176
4177impl Validity {
4178    /// No constraint fails.
4179    pub fn valid(&self) -> bool {
4180        *self == Validity::default()
4181    }
4182}
4183
4184impl DomRecorder {
4185    /// A control's value as a browser reports it: a checkbox's or radio's `value` attribute (`on`
4186    /// when it has none), whether ticked or not; any other control's what the visitor or the page
4187    /// last put there, else what its markup says — a `<select>`'s chosen option, anything else's
4188    /// `value` attribute.
4189    fn control_value(&self, h: u32) -> String {
4190        let Some(n) = self.get(h) else {
4191            return String::new();
4192        };
4193        if matches!(input_type(n).as_str(), "checkbox" | "radio") {
4194            return n
4195                .attrs
4196                .get("value")
4197                .cloned()
4198                .unwrap_or_else(|| "on".to_string());
4199        }
4200        if n.user_edited || !n.value.is_empty() {
4201            return n.value.clone();
4202        }
4203        match n.tag.eq_ignore_ascii_case("select") {
4204            true => self.default_choice(h).unwrap_or_default(),
4205            false => n.attrs.get("value").cloned().unwrap_or_default(),
4206        }
4207    }
4208
4209    /// Whether `h` is barred from constraint validation: disabled (itself, or by a disabled
4210    /// `<fieldset>` it is not in the first `<legend>` of), read-only where `readonly` applies, a
4211    /// hidden input, or inside a `<datalist>`.
4212    fn barred(&self, h: u32) -> bool {
4213        let Some(n) = self.get(h) else { return true };
4214        let has = |name: &str| n.attrs.contains_key(name);
4215        let ty = input_type(n);
4216        let readonly_applies = n.tag.eq_ignore_ascii_case("textarea")
4217            || matches!(
4218                ty.as_str(),
4219                "text"
4220                    | "search"
4221                    | "url"
4222                    | "tel"
4223                    | "email"
4224                    | "password"
4225                    | "date"
4226                    | "month"
4227                    | "week"
4228                    | "time"
4229                    | "datetime-local"
4230                    | "number"
4231            );
4232        if has("disabled") || (readonly_applies && has("readonly")) || ty == "hidden" {
4233            return true;
4234        }
4235        let chain = self.ancestors_of(h).unwrap_or_default();
4236        for (i, &a) in chain.iter().enumerate() {
4237            let Some(anc) = self.get(a) else { continue };
4238            if anc.tag.eq_ignore_ascii_case("datalist") {
4239                return true;
4240            }
4241            if anc.tag.eq_ignore_ascii_case("fieldset") && anc.attrs.contains_key("disabled") {
4242                let first_legend = anc.children.iter().copied().find(|&c| {
4243                    self.get(c)
4244                        .is_some_and(|x| !x.is_text && x.tag.eq_ignore_ascii_case("legend"))
4245                });
4246                let inside_legend = first_legend.is_some_and(|l| chain.get(i + 1) == Some(&l));
4247                if !inside_legend {
4248                    return true;
4249                }
4250            }
4251        }
4252        false
4253    }
4254
4255    /// The `<form>` a control belongs to: the one its `form` attribute names, else its nearest
4256    /// form ancestor.
4257    fn form_owner(&self, h: u32) -> Option<u32> {
4258        if let Some(id) = self.get(h).and_then(|n| n.attrs.get("form")) {
4259            let found = self.query_selector(&format!("#{id}"));
4260            return self
4261                .get(found)
4262                .filter(|f| f.tag.eq_ignore_ascii_case("form"))
4263                .map(|_| found);
4264        }
4265        let chain = self.ancestors_of(h)?;
4266        chain.into_iter().rev().find(|&a| {
4267            self.get(a)
4268                .is_some_and(|n| n.tag.eq_ignore_ascii_case("form"))
4269        })
4270    }
4271
4272    /// Whether some radio of `h`'s group — the same `name`, the same form owner — is ticked.
4273    fn radio_group_ticked(&self, h: u32) -> bool {
4274        let Some(n) = self.get(h) else { return false };
4275        let Some(name) = n.attrs.get("name").filter(|x| !x.is_empty()) else {
4276            return n.checked;
4277        };
4278        let owner = self.form_owner(h);
4279        let mut stack = vec![0u32];
4280        while let Some(at) = stack.pop() {
4281            let Some(x) = self.get(at) else { continue };
4282            if !x.is_text
4283                && input_type(x) == "radio"
4284                && x.attrs.get("name") == Some(name)
4285                && self.form_owner(at) == owner
4286                && x.checked
4287            {
4288                return true;
4289            }
4290            stack.extend(x.children.iter().rev().copied());
4291        }
4292        false
4293    }
4294
4295    fn validity(&self, h: u32) -> Result<Validity, String> {
4296        let mut v = Validity::default();
4297        let Some(n) = self.get(h) else { return Ok(v) };
4298        let tag = n.tag.to_ascii_lowercase();
4299        if n.is_text || !matches!(tag.as_str(), "input" | "select" | "textarea") || self.barred(h) {
4300            return Ok(v);
4301        }
4302        let ty = input_type(n);
4303        let value = self.control_value(h);
4304        if n.attrs.contains_key("required") {
4305            v.value_missing = match ty.as_str() {
4306                "checkbox" => !n.checked,
4307                "radio" => !self.radio_group_ticked(h),
4308                _ => value.is_empty(),
4309            };
4310        }
4311        let text_like = tag == "textarea"
4312            || matches!(
4313                ty.as_str(),
4314                "text" | "search" | "url" | "tel" | "email" | "password"
4315            );
4316        if text_like && n.user_edited && !value.is_empty() {
4317            let units = value.encode_utf16().count();
4318            let limit = |name: &str| {
4319                n.attrs
4320                    .get(name)
4321                    .and_then(|x| x.trim().parse::<usize>().ok())
4322            };
4323            v.too_long = limit("maxlength").is_some_and(|max| units > max);
4324            v.too_short = limit("minlength").is_some_and(|min| units < min);
4325        }
4326        let values: Vec<&str> = match (ty.as_str(), n.attrs.contains_key("multiple")) {
4327            ("email", true) => value
4328                .split(',')
4329                .map(|x| x.trim_matches(|c: char| c.is_ascii_whitespace()))
4330                .collect(),
4331            _ => vec![value.as_str()],
4332        };
4333        if !value.is_empty() {
4334            match ty.as_str() {
4335                "email" => v.type_mismatch = values.iter().any(|x| !valid_email(x)),
4336                "url" => v.type_mismatch = !valid_url(&value),
4337                _ => {}
4338            }
4339            if tag == "input"
4340                && matches!(
4341                    ty.as_str(),
4342                    "text" | "search" | "url" | "tel" | "email" | "password"
4343                )
4344            {
4345                if let Some(pattern) = n.attrs.get("pattern") {
4346                    for x in &values {
4347                        if !pattern_matches(pattern, x)? {
4348                            v.pattern_mismatch = true;
4349                        }
4350                    }
4351                }
4352            }
4353        }
4354        if ty == "number" {
4355            v.bad_input = n.bad_input;
4356            let num = |name: &str| {
4357                n.attrs
4358                    .get(name)
4359                    .filter(|x| valid_float(x))
4360                    .and_then(|x| x.parse::<f64>().ok())
4361            };
4362            if let Some(x) = value.parse::<f64>().ok().filter(|_| valid_float(&value)) {
4363                v.range_underflow = num("min").is_some_and(|min| x < min);
4364                v.range_overflow = num("max").is_some_and(|max| x > max);
4365                let step = match n.attrs.get("step").map(|s| s.trim().to_ascii_lowercase()) {
4366                    Some(any) if any == "any" => None,
4367                    Some(s) => Some(
4368                        Some(s.as_str())
4369                            .filter(|s| valid_float(s))
4370                            .and_then(|s| s.parse::<f64>().ok())
4371                            .filter(|s| *s > 0.0)
4372                            .unwrap_or(1.0),
4373                    ),
4374                    None => Some(1.0),
4375                };
4376                if let Some(step) = step {
4377                    let base = num("min").or_else(|| num("value")).unwrap_or(0.0);
4378                    let q = (x - base) / step;
4379                    v.step_mismatch = (q - q.round()).abs() > 1e-9 * q.abs().max(1.0);
4380                }
4381            }
4382        }
4383        Ok(v)
4384    }
4385}
4386
4387/// Whether `s` is an HTML valid floating-point number: an optional `-`, digits with an optional
4388/// fraction (or a fraction alone), and an optional exponent.
4389fn valid_float(s: &str) -> bool {
4390    let b = s.as_bytes();
4391    let mut i = usize::from(b.first() == Some(&b'-'));
4392    let digits = |i: &mut usize| {
4393        let start = *i;
4394        while *i < b.len() && b[*i].is_ascii_digit() {
4395            *i += 1;
4396        }
4397        *i > start
4398    };
4399    let whole = digits(&mut i);
4400    let fraction = if b.get(i) == Some(&b'.') {
4401        i += 1;
4402        if !digits(&mut i) {
4403            return false;
4404        }
4405        true
4406    } else {
4407        false
4408    };
4409    if !whole && !fraction {
4410        return false;
4411    }
4412    if matches!(b.get(i), Some(b'e') | Some(b'E')) {
4413        i += 1;
4414        if matches!(b.get(i), Some(b'+') | Some(b'-')) {
4415            i += 1;
4416        }
4417        if !digits(&mut i) {
4418            return false;
4419        }
4420    }
4421    i == b.len()
4422}
4423
4424/// Whether `s` is a valid e-mail address as HTML defines it: a local part of the permitted
4425/// characters, `@`, and a domain of dot-separated labels, each 1–63 letters, digits and inner
4426/// hyphens.
4427fn valid_email(s: &str) -> bool {
4428    let Some((local, domain)) = s.split_once('@') else {
4429        return false;
4430    };
4431    let local_ok = !local.is_empty()
4432        && local
4433            .chars()
4434            .all(|c| c.is_ascii_alphanumeric() || ".!#$%&'*+/=?^_`{|}~-".contains(c));
4435    let label_ok = |l: &str| {
4436        let b = l.as_bytes();
4437        (1..=63).contains(&b.len())
4438            && b[0].is_ascii_alphanumeric()
4439            && b[b.len() - 1].is_ascii_alphanumeric()
4440            && b.iter().all(|c| c.is_ascii_alphanumeric() || *c == b'-')
4441    };
4442    local_ok && domain.split('.').all(label_ok)
4443}
4444
4445/// Whether `s` parses as an absolute URL: a scheme, and — for the schemes a host is part of —
4446/// a non-empty host with no character a host cannot hold (an IPv6 literal in brackets holds hex
4447/// digits, `:` and `.`), and a port of digits.
4448fn valid_url(s: &str) -> bool {
4449    let Some((scheme, rest)) = s.split_once(':') else {
4450        return false;
4451    };
4452    let mut chars = scheme.chars();
4453    let scheme_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
4454        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
4455    if !scheme_ok {
4456        return false;
4457    }
4458    if !matches!(
4459        scheme.to_ascii_lowercase().as_str(),
4460        "http" | "https" | "ws" | "wss" | "ftp"
4461    ) {
4462        return true;
4463    }
4464    let authority = rest
4465        .trim_start_matches(['/', '\\'])
4466        .split(['/', '\\', '?', '#'])
4467        .next()
4468        .unwrap_or("");
4469    let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
4470    let (host_ok, port) = match host_port.strip_prefix('[') {
4471        Some(inner) => match inner.split_once(']') {
4472            Some((addr, tail)) => (
4473                !addr.is_empty()
4474                    && addr
4475                        .chars()
4476                        .all(|c| c.is_ascii_hexdigit() || matches!(c, ':' | '.')),
4477                tail.strip_prefix(':'),
4478            ),
4479            None => return false,
4480        },
4481        None => {
4482            let (host, port) = match host_port.rsplit_once(':') {
4483                Some((h, p)) => (h, Some(p)),
4484                None => (host_port, None),
4485            };
4486            let forbidden =
4487                |c: char| c.is_control() || c.is_whitespace() || "#%/:<>?@[\\]^|".contains(c);
4488            (!host.is_empty() && !host.chars().any(forbidden), port)
4489        }
4490    };
4491    host_ok && port.is_none_or(|p| p.chars().all(|c| c.is_ascii_digit()))
4492}
4493
4494/// Whether `value` matches a control's `pattern=` — an ECMAScript regular expression matched
4495/// against the WHOLE value (`^(?:pattern)$`), with the `v` flag a browser compiles it with: `\d`,
4496/// `\w` and `\s` are ASCII digits, word characters and JavaScript's whitespace, and a class may
4497/// nest classes and combine them with `--` and `&&`. A pattern that is not a valid expression
4498/// constrains nothing, as in a browser. The `v` flag's strings — `\q{…}` and the string properties
4499/// (`\p{RGI_Emoji}`) — are refused by name: the engine here cannot express them.
4500pub fn pattern_matches(pattern: &str, value: &str) -> Result<bool, String> {
4501    let refuse = |what: &str| {
4502        Err(format!(
4503            "`pattern=\"{pattern}\"` uses {what}, which the in-process tiers cannot check; a browser \
4504             can — write the constraint without it, or check the value in an action"
4505        ))
4506    };
4507    const STRING_PROPERTIES: &[&str] = &[
4508        "Basic_Emoji",
4509        "Emoji_Keycap_Sequence",
4510        "RGI_Emoji_Modifier_Sequence",
4511        "RGI_Emoji_Flag_Sequence",
4512        "RGI_Emoji_Tag_Sequence",
4513        "RGI_Emoji_ZWJ_Sequence",
4514        "RGI_Emoji",
4515    ];
4516    const JS_SPACE: &str = "\\t\\n\\x0B\\x0C\\r \\u{A0}\\u{1680}\\u{2000}-\\u{200A}\\u{2028}\\u{2029}\\u{202F}\\u{205F}\\u{3000}\\u{FEFF}";
4517    let mut out = String::from("^(?:");
4518    let chars: Vec<char> = pattern.chars().collect();
4519    let mut i = 0;
4520    let mut depth = 0usize;
4521    let mut class_atoms = 0usize;
4522    while i < chars.len() {
4523        let c = chars[i];
4524        if c == '\\' {
4525            let Some(&e) = chars.get(i + 1) else {
4526                return Ok(true);
4527            };
4528            if e == 'q' && chars.get(i + 2) == Some(&'{') {
4529                return refuse("a `\\q{…}` string literal (the `v` flag's set notation)");
4530            }
4531            if matches!(e, 'p' | 'P' | 'u') && chars.get(i + 2) == Some(&'{') {
4532                let name: String = chars[i + 3..].iter().take_while(|c| **c != '}').collect();
4533                if e != 'u' && STRING_PROPERTIES.contains(&name.as_str()) {
4534                    return refuse(&format!("the string property `\\p{{{name}}}`"));
4535                }
4536                let end = i + 3 + name.chars().count();
4537                if chars.get(end) != Some(&'}') {
4538                    return Ok(true);
4539                }
4540                out.extend(&chars[i..=end]);
4541                class_atoms += 1;
4542                i = end + 1;
4543                continue;
4544            }
4545            let in_class = depth > 0;
4546            let class = |set: &str, negated: bool| match (in_class, negated) {
4547                (true, false) => set.to_string(),
4548                (false, false) => format!("[{set}]"),
4549                (false, true) => format!("[^{set}]"),
4550                (true, true) => String::new(),
4551            };
4552            let translated = match e {
4553                'd' => Some(class("0-9", false)),
4554                'D' => Some(class("0-9", true)),
4555                'w' => Some(class("A-Za-z0-9_", false)),
4556                'W' => Some(class("A-Za-z0-9_", true)),
4557                's' => Some(class(JS_SPACE, false)),
4558                'S' => Some(class(JS_SPACE, true)),
4559                _ => None,
4560            };
4561            match translated {
4562                Some(t) if t.is_empty() => {
4563                    return refuse(&format!("a negated `\\{e}` inside a class"))
4564                }
4565                Some(t) => out.push_str(&t),
4566                None => {
4567                    out.push(c);
4568                    out.push(e);
4569                }
4570            }
4571            class_atoms += 1;
4572            i += 2;
4573            continue;
4574        }
4575        if depth > 0 {
4576            match c {
4577                ']' => {
4578                    depth -= 1;
4579                    class_atoms += 1;
4580                    out.push(c);
4581                }
4582                '[' => {
4583                    depth += 1;
4584                    class_atoms = 0;
4585                    out.push(c);
4586                    if chars.get(i + 1) == Some(&'^') {
4587                        out.push('^');
4588                        i += 1;
4589                    }
4590                }
4591                '-' | '&' if chars.get(i + 1) == Some(&c) => {
4592                    out.push(c);
4593                    out.push(c);
4594                    class_atoms = 0;
4595                    i += 2;
4596                    continue;
4597                }
4598                '-' if class_atoms == 0 || chars.get(i + 1) == Some(&']') => return Ok(true),
4599                '(' | ')' | '{' | '}' | '/' | '|' => return Ok(true),
4600                _ => {
4601                    out.push(c);
4602                    class_atoms += 1;
4603                }
4604            }
4605            i += 1;
4606            continue;
4607        }
4608        if c == '[' {
4609            depth = 1;
4610            class_atoms = 0;
4611            out.push(c);
4612            if chars.get(i + 1) == Some(&'^') {
4613                out.push('^');
4614                i += 1;
4615            }
4616            i += 1;
4617            continue;
4618        }
4619        out.push(c);
4620        i += 1;
4621    }
4622    if depth > 0 {
4623        return Ok(true);
4624    }
4625    out.push_str(")$");
4626    match fancy_regex::Regex::new(&out) {
4627        Ok(re) => re.is_match(value).map_err(|e| {
4628            format!("`pattern=\"{pattern}\"` could not be checked against {value:?}: {e}")
4629        }),
4630        Err(_) => Ok(true),
4631    }
4632}
4633
4634/// A control's value as a browser reports it — see [`Validity`].
4635pub fn control_value(node: i64) -> String {
4636    DOM.with(|d| d.borrow().control_value(node as u32))
4637}
4638
4639/// Why `node` is invalid ([`Validity`]) — every flag clear for an element with no constraints or
4640/// barred from them. Refused only for a `pattern=` this tier cannot check ([`pattern_matches`]).
4641pub fn validity(node: i64) -> Result<Validity, String> {
4642    DOM.with(|d| d.borrow().validity(node as u32))
4643}
4644
4645/// The `<form>` `node` belongs to, if any: itself when it is one, else its form owner.
4646pub fn form_of(node: i64) -> Option<i64> {
4647    DOM.with(|d| {
4648        let d = d.borrow();
4649        match d.get(node as u32) {
4650            Some(n) if !n.is_text && n.tag.eq_ignore_ascii_case("form") => Some(node),
4651            Some(_) => d.form_owner(node as u32).map(i64::from),
4652            None => None,
4653        }
4654    })
4655}
4656
4657/// The controls that stop `form` from being submitted, in document order: none when the form
4658/// says `novalidate` or its `submitter` says `formnovalidate`, else every control the form owns
4659/// whose [`validity`] is not valid.
4660pub fn form_constraints(form: i64, submitter: Option<i64>) -> Result<Vec<i64>, String> {
4661    DOM.with(|d| {
4662        let d = d.borrow();
4663        let has = |h: i64, name: &str| d.get(h as u32).is_some_and(|n| n.attrs.contains_key(name));
4664        if has(form, "novalidate") || submitter.is_some_and(|s| has(s, "formnovalidate")) {
4665            return Ok(Vec::new());
4666        }
4667        let mut invalid = Vec::new();
4668        let mut stack = vec![0u32];
4669        while let Some(at) = stack.pop() {
4670            let Some(x) = d.get(at) else { continue };
4671            if !x.is_text
4672                && matches!(
4673                    x.tag.to_ascii_lowercase().as_str(),
4674                    "input" | "select" | "textarea"
4675                )
4676                && d.form_owner(at) == Some(form as u32)
4677                && !d.validity(at)?.valid()
4678            {
4679                invalid.push(i64::from(at));
4680            }
4681            stack.extend(x.children.iter().rev().copied());
4682        }
4683        Ok(invalid)
4684    })
4685}
4686
4687/// Whether activating `node` submits its form: a `<button>` of type `submit` (a button's type when
4688/// it names none), or an `<input>` of type `submit` or `image`.
4689pub fn submits_its_form(node: i64) -> bool {
4690    DOM.with(|d| {
4691        let d = d.borrow();
4692        let Some(n) = d.get(node as u32).filter(|n| !n.is_text) else {
4693            return false;
4694        };
4695        let ty = n.attrs.get("type").map(|t| t.trim().to_ascii_lowercase());
4696        match n.tag.to_ascii_lowercase().as_str() {
4697            "button" => {
4698                ty.is_none_or(|t| t == "submit" || !matches!(t.as_str(), "reset" | "button"))
4699            }
4700            "input" => matches!(ty.as_deref(), Some("submit") | Some("image")),
4701            _ => false,
4702        }
4703    })
4704}