Skip to main content

atmos/os_lib/js/
dom_bridge.rs

1//! JS ⇄ レンダラの DOM ブリッジ。
2//!
3//! `dom::parse_html` の結果を **ドキュメント順** にフラット化した軽量アリーナを持ち、
4//! JS から `document.getElementById` 等で参照されるノードと、`textContent` /
5//! `style` / `classList` の変更、`addEventListener('click')` で登録されたリスナを保持する。
6//!
7//! レンダラは毎レイアウトで `last_html` を再パースするため、ブリッジは
8//! 「同一 HTML を同一ドキュメント順で歩けばインデックスが一致する」性質を使い、
9//! `apply_overrides` で再パース木へ JS の変更を焼き戻してから CSS カスケードに渡す。
10//! これにより既存のレンダリング経路をそのまま再利用できる(HTML 再シリアライズ不要)。
11
12use alloc::collections::BTreeMap;
13use alloc::collections::BTreeSet;
14use alloc::format;
15use alloc::string::String;
16use alloc::string::ToString;
17use alloc::vec::Vec;
18
19use super::value::Value;
20use crate::os_lib::dom::{Node, NodeType};
21use crate::os_lib::layout::intersection_observer::RootMargin;
22use crate::os_lib::layout::resize_observer::ResizeObserverBoxOptions;
23
24/// ブリッジ上の 1 ノード。要素またはテキスト。
25pub struct DomNode {
26    /// 要素タグ(小文字)。テキストノードは空。
27    pub tag: String,
28    pub is_text: bool,
29    pub id: String,
30    pub classes: Vec<String>,
31    /// インライン style(プロパティ→値)。
32    pub style: BTreeMap<String, String>,
33    /// その他の属性(id/class/style 以外も含む生の値)。
34    pub attrs: BTreeMap<String, String>,
35    /// テキストノードの内容、または textContent 上書き値。
36    pub text_override: Option<String>,
37    /// 構築時のテキスト(textContent ゲッタ用)。
38    pub initial_text: String,
39    pub parent: Option<usize>,
40    pub children: Vec<usize>,
41    /// JS が classes を変更したか(apply で焼き戻す)。
42    pub classes_dirty: bool,
43    /// JS が style を変更したか。
44    pub style_dirty: bool,
45}
46
47/// IntersectionObserver の監視対象要素
48#[derive(Clone)]
49pub struct ObservedTarget {
50    pub target: Value,
51    pub target_idx: usize,
52    pub prev_ratio: Option<f64>,
53    pub prev_is_intersecting: bool,
54}
55
56/// IntersectionObserver の登録エントリ
57#[derive(Clone)]
58pub struct IntersectionObserverReg {
59    pub id: u32,
60    pub callback: Value,
61    pub root: Value,
62    pub root_margin: RootMargin,
63    pub thresholds: Vec<f64>,
64    pub targets: Vec<ObservedTarget>,
65    pub queued_records: Vec<Value>,
66}
67
68/// ResizeObserver の監視対象要素
69#[derive(Clone)]
70pub struct ObservedResizeTarget {
71    pub target: Value,
72    pub target_idx: usize,
73    pub box_option: ResizeObserverBoxOptions,
74    pub last_inline_size: f64,
75    pub last_block_size: f64,
76}
77
78/// ResizeObserver の登録エントリ
79#[derive(Clone)]
80pub struct ResizeObserverReg {
81    pub id: u32,
82    pub callback: Value,
83    pub targets: Vec<ObservedResizeTarget>,
84}
85
86/// MutationObserver の登録エントリ。
87pub struct MutObsReg {
88    pub id: u32,
89    pub target_idx: usize,
90    pub child_list: bool,
91    pub attributes: bool,
92    pub subtree: bool,
93    pub callback: Value,
94    pub observer: Value,
95    /// `observe(target, {attributeOldValue: true})`。変更前の属性値を
96    /// レコードの `oldValue` として公開するかどうか。
97    pub attribute_old_value: bool,
98    /// `observe(target, {attributeFilter: [...]})`。指定時はこの一覧に
99    /// 含まれる属性名の変化のみ通知する(`None` は全属性を監視)。
100    pub attribute_filter: Option<Vec<String>>,
101    /// `observe(target, {characterData: true})`(丸ごと未対応だった。
102    /// テキストノードの`.data`/`.nodeValue`/`textContent`変更を監視する
103    /// オプション自体が存在せず、対応する通知ヘルパ〔`notify_character_
104    /// data`〕も無かったため、`characterData`を指定してもコールバックが
105    /// 一切呼ばれなかった。`childList`/`attributes`は既に対応済みで
106    /// `characterData`だけ抜けていた兄弟ギャップ。2026-07-17 発見・実装)。
107    pub character_data: bool,
108    /// `observe(target, {characterDataOldValue: true})`。変更前のテキスト
109    /// 値をレコードの`oldValue`として公開するかどうか(`attributeOldValue`
110    /// と対)。
111    pub character_data_old_value: bool,
112}
113
114/// MutationObserver に届けるレコード(Rust 側内部表現)。
115pub struct MutRec {
116    pub type_: &'static str, // "childList" | "attributes"
117    pub target_idx: usize,
118    pub added: Vec<usize>,
119    pub removed: Vec<usize>,
120    pub attribute_name: Option<String>,
121    /// `attributeOldValue: true` が指定された観察者向けにのみ Some を積む。
122    pub old_value: Option<String>,
123}
124
125/// click 等のイベントリスナ登録。
126pub struct Listener {
127    pub node: usize,
128    pub event: String,
129    pub func: Value,
130    /// キャプチャフェーズで発火するリスナか(addEventListener の capture オプション)。
131    pub capture: bool,
132    /// 一度発火したら自動削除されるか(once オプション)。
133    pub once: bool,
134    /// 一意 ID(once 削除や removeEventListener の照合に使う)。
135    pub id: u64,
136    /// `addEventListener(type, fn, {signal})`(丸ごと未対応だった。
137    /// `AbortController` でリスナを一括解除する定番パターン。2026-07-14
138    /// 発見・実装)。`signal.aborted` を都度チェックし、abort 済みなら
139    /// 発火しない簡略実装(`abort` イベント発火と同時に即座に物理削除は
140    /// せず、次回参照時に遅延評価する。実務上は「以後発火しなくなる」
141    /// という観測可能な挙動が同じであれば十分なため)。
142    pub signal: Option<Value>,
143}
144
145/// `AbortSignal` オブジェクトの `.aborted` が `true` か。
146pub fn is_signal_aborted(signal: &Value) -> bool {
147    matches!(signal, Value::Object(o) if matches!(o.borrow().props.get("aborted"), Some(Value::Bool(true))))
148}
149
150fn is_listener_aborted(l: &Listener) -> bool {
151    l.signal.as_ref().is_some_and(is_signal_aborted)
152}
153
154/// DOM ブリッジ本体。`JsRuntime` が `Rc<RefCell<>>` で保持し、レンダラと共有する。
155pub struct DomBridge {
156    pub nodes: Vec<DomNode>,
157    pub listeners: Vec<Listener>,
158    /// JS が DOM を変更したフラグ。ホストはこれを見て再レイアウトする。
159    pub dirty: bool,
160    /// set_style 以外の DOM 変更(ノード追加/削除/クラス/テキスト/属性変更等)や set_css_text があったか
161    pub structural_dirty: bool,
162    /// set_style で変更されたプロパティの一覧: (node_idx, prop_kebab, new_val)
163    pub pending_style_updates: Vec<(usize, String, String)>,
164    /// JS が `createElement('script')` で作った `<script>`(文書へ挿入されたら実行する)。
165    pub js_created_scripts: alloc::collections::BTreeSet<usize>,
166    /// 文書へ挿入されて実行待ちの `<script>`(ホストが取得・実行し `load`/`error` を発火する)。
167    /// 【2026-10-17】動的に挿入した script を実行する仕組みが無く、YouTube の埋め込み等
168    /// (スクリプトを挿入して `onload` を待つ)が先へ進まなかった。
169    pub pending_dynamic_scripts: Vec<usize>,
170    /// 要素に JS が付けた独自プロパティ(expando。`el.foo = 1`、組込みメソッドの上書きも含む)。
171    /// 【2026-10-17】要素のハンドル(`Obj::dom`)はアクセスのたびに作り直すため、未知の
172    /// プロパティへの代入は捨てられていた。YouTube の player は API メソッドを要素自身に付け、
173    /// 埋め込み側は `getElementById(...).getApiInterface` で準備完了を待つ。
174    pub expandos: BTreeMap<usize, BTreeMap<String, Value>>,
175    /// 昇格済みのカスタム要素の原型(要素の添字 → 定義したクラスの prototype)。
176    /// 【2026-10-20】要素のハンドルはアクセスのたびに作り直すので、原型は要素側に持たせる。
177    pub node_protos: BTreeMap<usize, super::value::ObjRef>,
178    /// カスタム要素の反応(昇格・connected/disconnected)の待ち行列。
179    /// DOM の変更(`notify_child_list`・複製)が積み、`Interp::process_ce_reactions` が処理する。
180    pub ce_queue: Vec<CeEvent>,
181    /// 構築済みかどうか(未構築なら getElementById 等は空を返す)。
182    pub built: bool,
183    /// レイアウト後の要素矩形 (x, y, w, h)(ページ座標)。getBoundingClientRect /
184    /// offsetWidth 等が参照。レンダラが flatten 後に populate する。
185    pub rects: BTreeMap<usize, (i32, i32, i32, i32)>,
186    /// レイアウト後の各要素のborder幅 (top, right, bottom, left)。
187    pub border_widths: BTreeMap<usize, (i32, i32, i32, i32)>,
188    /// 算出スタイル(カスケード後の specified_values、kebab-case キー)。
189    /// getComputedStyle が参照。レンダラがスタイル適用後に populate する。
190    pub computed_styles: BTreeMap<usize, BTreeMap<String, String>>,
191    /// サブスクロールコンテナの現在 scrollTop (node_idx → px)。
192    /// JS が `el.scrollTop = N` で書き込み、レンダラが再レイアウト後に同期する。
193    pub scroll_tops: BTreeMap<usize, i32>,
194    /// サブスクロールコンテナの現在 scrollLeft (node_idx → px)。
195    /// JS が `el.scrollLeft = N` で書き込み、レンダラが再レイアウト後に同期する。
196    pub scroll_lefts: BTreeMap<usize, i32>,
197    /// サブスクロールコンテナの scrollHeight (node_idx → px)。
198    /// レンダラが parse_and_layout 後に populate する(コンテナ高 + scroll_max_y)。
199    pub scroll_heights: BTreeMap<usize, i32>,
200    /// サブスクロールコンテナの scrollWidth (node_idx → px)。
201    /// レンダラが parse_and_layout 後に populate する(コンテナ幅 + scroll_max_x)。
202    pub scroll_widths: BTreeMap<usize, i32>,
203    /// リスナ ID の発番カウンタ(once / removeEventListener 照合用)。
204    pub next_listener_id: u64,
205    /// 登録済み MutationObserver。
206    pub mut_observers: Vec<MutObsReg>,
207    /// DOM 変更時に積まれるレコードキュー(observer_id, record)。
208    pub mut_records: Vec<(u32, MutRec)>,
209    /// MutationObserver の ID 発番カウンタ。
210    pub next_mut_obs_id: u32,
211    /// 登録済み IntersectionObserver。
212    pub io_observers: Vec<IntersectionObserverReg>,
213    /// IntersectionObserver の ID 発番カウンタ。
214    pub next_io_obs_id: u32,
215    /// 登録済み ResizeObserver。
216    pub ro_observers: Vec<ResizeObserverReg>,
217    /// ResizeObserver の ID 発番カウンタ。
218    pub next_ro_obs_id: u32,
219    /// `setPointerCapture`/`releasePointerCapture`/`hasPointerCapture`
220    /// (Pointer Events。丸ごと未対応だった)が捕捉中の (node_idx, pointerId) 集合。
221    /// この処理系のイベント配信はヒットテストベースのため、実際にその後の
222    /// pointer イベントをこの要素へ強制配送するところまでは配線しない
223    /// (`hasPointerCapture` が仕様どおりの真偽を返せるようにする状態管理のみの
224    /// 簡略実装。`inert` 属性のポインタ操作抑制のみ実装と同種の部分対応)。
225    pub pointer_captures: BTreeSet<(usize, i32)>,
226    /// `document.activeElement`/`element.focus()`/`.blur()`(HTML5。丸ごと
227    /// 未対応だった)。現在フォーカス中のノード index。`WebEngine::focused_id`
228    /// (レンダラ側の文字列キー版フォーカス状態)とは別に、JS 側から直接
229    /// 読み書きできる node index ベースの状態としてここに持つ。レンダラの
230    /// 実クリック/`autofocus` によるフォーカス変更も同期される(詳細は
231    /// `layout.rs`/`render.rs` の `focused_id` 代入箇所を参照)。id/name 属性を
232    /// 持たない要素へのレンダラ起点フォーカスは同期対象外(レンダラ自身の
233    /// 既存の制約と同じ)。
234    pub focused_idx: Option<usize>,
235    /// `window.scrollTo(x, y)`/`window.scroll(x, y)`(丸ごと未対応だった。
236    /// 実装はあったが `SCROLL_Y` という孤立した `static` へ書き込むのみで、
237    /// `window.scrollY`/実際のレンダラ描画位置のどちらにも一切反映されない
238    /// 死んだ経路だった。2026-07-16 発見)。JS→レンダラの片方向同期用の
239    /// 「保留中の絶対スクロール先」。`focused_idx`と同じ「レンダラが毎フレーム
240    /// 消費する」設計だが、フォーカスと違い同一フレーム内での即時反映が
241    /// 不要(次の描画で反映されれば十分)なため、`render.rs`の`draw()`冒頭
242    /// 1箇所でのみ消費すればよく、`.focus()`のような10箇所以上への配線は
243    /// 不要(改修規模がずっと小さい)。
244    pub pending_scroll_abs_y: Option<i32>,
245    /// `window.scrollBy(x, y)`(丸ごと未対応だった。`dom_noop`のまま)。
246    /// 複数回呼ばれた場合は加算し、次の描画で一括反映する。
247    pub pending_scroll_by_y: Option<i32>,
248    /// `element.onchange = fn`のような GlobalEventHandlers IDL 属性(関数値)の
249    /// 永続的な保管先。以前は`Obj::dom(idx)`(呼び出す度に新規生成される使い捨ての
250    /// ラッパーオブジェクト)自身の`.props`へ書き込んでいたため、代入直後に
251    /// そのRcが破棄されると値ごと消え、`dispatchEvent`側が同じ`Obj::dom(idx)`を
252    /// 新規生成して読み直しても常に見つからない(=ハンドラが一切発火しない)
253    /// 静かなバグだった。`listeners`(addEventListener系)と同じくDOM構造体側に
254    /// 持たせることで、どの`Obj::dom(idx)`インスタンス経由でも一貫して読み書き
255    /// できるようにする。キーは`(node_idx, "onchange"等のプロパティ名)`。
256    pub on_handlers: BTreeMap<(usize, String), Value>,
257    /// Document に紐づく単一の Selection オブジェクト(W3C Selection API)。
258    pub selection: Option<Value>,
259    /// Web Components: カスタム要素レジストリ
260    pub custom_elements: crate::os_lib::custom_elements::CustomElementRegistry,
261    /// Web Components: Shadow DOM レジストリ
262    pub shadow_tree: crate::os_lib::shadow_dom::ShadowTreeRegistry,
263    /// 子 `<iframe>` から `window.parent.postMessage` / `window.top.postMessage` で送られた保留メッセージ列 `(data, origin)`
264    pub pending_parent_messages: Vec<(Value, String)>,
265    /// 親ページから `<iframe src="...">` の `contentWindow.postMessage` で送られた保留メッセージ列 `(target_src, data, origin)`
266    pub pending_child_messages: Vec<(String, Value, String)>,
267}
268
269impl Default for DomBridge {
270    fn default() -> Self {
271        Self::new()
272    }
273}
274
275/// `ValidityState`(HTML5 制約検証)の個別フラグ。以前は `element.validity` が
276/// `valid`/`valueMissing`/`customError` の3つしか公開せず、`input.validity.
277/// patternMismatch`/`.typeMismatch`/`.rangeOverflow` 等、特定の制約だけを狙い撃ちして
278/// カスタムエラーメッセージを出し分ける定番パターンが常に `undefined`(falsy)に
279/// なっていた。
280#[derive(Default)]
281pub struct ValidityFlags {
282    pub value_missing: bool,
283    pub type_mismatch: bool,
284    pub pattern_mismatch: bool,
285    pub too_long: bool,
286    pub too_short: bool,
287    pub range_overflow: bool,
288    pub range_underflow: bool,
289    pub step_mismatch: bool,
290    pub bad_input: bool,
291    pub custom_error: bool,
292}
293impl ValidityFlags {
294    pub fn valid(&self) -> bool {
295        !(self.value_missing
296            || self.type_mismatch
297            || self.pattern_mismatch
298            || self.too_long
299            || self.too_short
300            || self.range_overflow
301            || self.range_underflow
302            || self.step_mismatch
303            || self.bad_input
304            || self.custom_error)
305    }
306}
307
308// ===== セレクタパーサ(`matches`/`closest`/`querySelector(All)` 共通) =====
309// 純粋ロジックは `crate::os_lib::dom_selector` に集約。
310use crate::os_lib::dom_selector::*;
311
312impl DomBridge {
313    pub fn new() -> Self {
314        DomBridge {
315            nodes: Vec::new(),
316            listeners: Vec::new(),
317            dirty: false,
318            structural_dirty: false,
319            pending_style_updates: Vec::new(),
320            js_created_scripts: alloc::collections::BTreeSet::new(),
321            pending_dynamic_scripts: Vec::new(),
322            expandos: BTreeMap::new(),
323            node_protos: BTreeMap::new(),
324            ce_queue: Vec::new(),
325            built: false,
326            rects: BTreeMap::new(),
327            border_widths: BTreeMap::new(),
328            computed_styles: BTreeMap::new(),
329            scroll_tops: BTreeMap::new(),
330            scroll_lefts: BTreeMap::new(),
331            scroll_heights: BTreeMap::new(),
332            scroll_widths: BTreeMap::new(),
333            next_listener_id: 1,
334            mut_observers: Vec::new(),
335            mut_records: Vec::new(),
336            next_mut_obs_id: 1,
337            io_observers: Vec::new(),
338            next_io_obs_id: 1,
339            ro_observers: Vec::new(),
340            next_ro_obs_id: 1,
341            pointer_captures: BTreeSet::new(),
342            focused_idx: None,
343            pending_scroll_abs_y: None,
344            pending_scroll_by_y: None,
345            on_handlers: BTreeMap::new(),
346            selection: None,
347            custom_elements: crate::os_lib::custom_elements::CustomElementRegistry::new(),
348            shadow_tree: crate::os_lib::shadow_dom::ShadowTreeRegistry::new(),
349            pending_parent_messages: Vec::new(),
350            pending_child_messages: Vec::new(),
351        }
352    }
353
354    /// 未初期化の DOM ブリッジに最小限の html/head/body 骨格を遅延生成する。
355    pub fn ensure_default_document(&mut self) {
356        if self.nodes.is_empty() {
357            self.nodes.push(DomNode {
358                tag: String::from("html"),
359                is_text: false,
360                id: String::new(),
361                classes: Vec::new(),
362                style: BTreeMap::new(),
363                attrs: BTreeMap::new(),
364                text_override: None,
365                initial_text: String::new(),
366                parent: None,
367                children: alloc::vec![1, 2],
368                classes_dirty: false,
369                style_dirty: false,
370            });
371            self.nodes.push(DomNode {
372                tag: String::from("head"),
373                is_text: false,
374                id: String::new(),
375                classes: Vec::new(),
376                style: BTreeMap::new(),
377                attrs: BTreeMap::new(),
378                text_override: None,
379                initial_text: String::new(),
380                parent: Some(0),
381                children: Vec::new(),
382                classes_dirty: false,
383                style_dirty: false,
384            });
385            self.nodes.push(DomNode {
386                tag: String::from("body"),
387                is_text: false,
388                id: String::new(),
389                classes: Vec::new(),
390                style: BTreeMap::new(),
391                attrs: BTreeMap::new(),
392                text_override: None,
393                initial_text: String::new(),
394                parent: Some(0),
395                children: Vec::new(),
396                classes_dirty: false,
397                style_dirty: false,
398            });
399            self.built = true;
400        }
401    }
402
403    /// `element.onXXX = fn`(関数値)の永続的な書き込み。
404    pub fn set_on_handler(&mut self, node: usize, key: &str, val: Value) {
405        self.on_handlers.insert((node, key.to_string()), val);
406    }
407    /// `element.onXXX`の読み出し。未設定なら`None`。
408    pub fn get_on_handler(&self, node: usize, key: &str) -> Option<Value> {
409        self.on_handlers.get(&(node, key.to_string())).cloned()
410    }
411
412    /// `element.setPointerCapture(pointerId)`。
413    pub fn set_pointer_capture(&mut self, idx: usize, pointer_id: i32) {
414        self.pointer_captures.insert((idx, pointer_id));
415    }
416    /// `element.releasePointerCapture(pointerId)`。
417    pub fn release_pointer_capture(&mut self, idx: usize, pointer_id: i32) {
418        self.pointer_captures.remove(&(idx, pointer_id));
419    }
420    /// `element.hasPointerCapture(pointerId)`。
421    pub fn has_pointer_capture(&self, idx: usize, pointer_id: i32) -> bool {
422        self.pointer_captures.contains(&(idx, pointer_id))
423    }
424
425    /// MutationObserver: childList 変化を記録するヘルパ。
426    fn notify_child_list(&mut self, parent_idx: usize, added: Vec<usize>, removed: Vec<usize>) {
427        if !self.custom_elements.is_empty() {
428            for &a in &added {
429                self.ce_queue.push(CeEvent::Inserted(a));
430            }
431            if self.is_attached(parent_idx) {
432                for &r in &removed {
433                    self.ce_queue.push(CeEvent::Removed(r));
434                }
435            }
436        }
437        for obs in &self.mut_observers {
438            let watches = obs.child_list
439                && (obs.target_idx == parent_idx
440                    || (obs.subtree && self.is_ancestor_of(obs.target_idx, parent_idx)));
441            if watches {
442                self.mut_records.push((
443                    obs.id,
444                    MutRec {
445                        type_: "childList",
446                        target_idx: parent_idx,
447                        added: added.clone(),
448                        removed: removed.clone(),
449                        attribute_name: None,
450                        old_value: None,
451                    },
452                ));
453            }
454        }
455    }
456
457    /// MutationObserver: 属性変化を記録するヘルパ。`attributeFilter` が指定された
458    /// 観察者はその一覧に無い属性名を無視し、`attributeOldValue` が指定された
459    /// 観察者にのみ変更前の値をレコードへ積む(以前はどちらのオプションも
460    /// 読み取られておらず、`attributeFilter` 指定時も無関係な属性の変化まで
461    /// 全て通知され、`oldValue` は常に `null` 固定だった)。
462    fn notify_attribute(&mut self, idx: usize, attr_name: &str, old_value: Option<&str>) {
463        // 昇格済みのカスタム要素で監視対象の属性なら attributeChangedCallback を積む。
464        if self.node_protos.contains_key(&idx) && !attr_name.starts_with('_') {
465            let observed = self
466                .nodes
467                .get(idx)
468                .and_then(|n| self.custom_elements.get(&n.tag.to_lowercase()))
469                .is_some_and(|d| d.observes_attribute(attr_name));
470            if observed {
471                let new_value = self.get_attr(idx, attr_name);
472                self.ce_queue.push(CeEvent::Attr(
473                    idx,
474                    attr_name.to_string(),
475                    old_value.map(String::from),
476                    new_value,
477                ));
478            }
479        }
480        for obs in &self.mut_observers {
481            let watches = obs.attributes
482                && (obs.target_idx == idx
483                    || (obs.subtree && self.is_ancestor_of(obs.target_idx, idx)))
484                && obs
485                    .attribute_filter
486                    .as_ref()
487                    .is_none_or(|f| f.iter().any(|n| n == attr_name));
488            if watches {
489                self.mut_records.push((
490                    obs.id,
491                    MutRec {
492                        type_: "attributes",
493                        target_idx: idx,
494                        added: Vec::new(),
495                        removed: Vec::new(),
496                        attribute_name: Some(String::from(attr_name)),
497                        old_value: if obs.attribute_old_value {
498                            Some(old_value.unwrap_or("").to_string())
499                        } else {
500                            None
501                        },
502                    },
503                ));
504            }
505        }
506    }
507
508    /// MutationObserver: テキストノードの内容変化を記録するヘルパ(丸ごと
509    /// 未対応だった。`notify_attribute`と対になる`characterData`版。
510    /// `idx`は変化した**テキストノード自身**のインデックス〔仕様上
511    /// `CharacterData`のmutationはテキストノード自身がtargetになる〕。
512    /// 2026-07-17 発見・実装)。
513    fn notify_character_data(&mut self, idx: usize, old_value: Option<&str>) {
514        for obs in &self.mut_observers {
515            let watches = obs.character_data
516                && (obs.target_idx == idx
517                    || (obs.subtree && self.is_ancestor_of(obs.target_idx, idx)));
518            if watches {
519                self.mut_records.push((
520                    obs.id,
521                    MutRec {
522                        type_: "characterData",
523                        target_idx: idx,
524                        added: Vec::new(),
525                        removed: Vec::new(),
526                        attribute_name: None,
527                        old_value: if obs.character_data_old_value {
528                            Some(old_value.unwrap_or("").to_string())
529                        } else {
530                            None
531                        },
532                    },
533                ));
534            }
535        }
536    }
537
538    /// レンダラがレイアウト後に要素矩形を登録する(getBoundingClientRect 等が参照)。
539    pub fn set_rect(&mut self, idx: usize, x: i32, y: i32, w: i32, h: i32) {
540        self.rects.insert(idx, (x, y, w, h));
541    }
542    pub fn get_rect(&self, idx: usize) -> Option<(i32, i32, i32, i32)> {
543        self.rects.get(&idx).copied()
544    }
545    pub fn set_border_widths(&mut self, idx: usize, top: i32, right: i32, bottom: i32, left: i32) {
546        self.border_widths.insert(idx, (top, right, bottom, left));
547    }
548    pub fn get_border_widths(&self, idx: usize) -> (i32, i32, i32, i32) {
549        self.border_widths.get(&idx).copied().unwrap_or((0, 0, 0, 0))
550    }
551    pub fn clear_rects(&mut self) {
552        self.rects.clear();
553        self.border_widths.clear();
554    }
555
556    /// 算出スタイルを登録(kebab-case プロパティ→値)。
557    pub fn set_computed_style(&mut self, idx: usize, prop: &str, val: &str) {
558        self.computed_styles
559            .entry(idx)
560            .or_default()
561            .insert(String::from(prop), String::from(val));
562    }
563    /// 算出スタイルを取得(無ければインライン style → 空)。
564    pub fn get_computed(&self, idx: usize, prop: &str) -> String {
565        if let Some(m) = self.computed_styles.get(&idx) {
566            if let Some(v) = m.get(prop) {
567                return v.clone();
568            }
569        }
570        self.nodes
571            .get(idx)
572            .and_then(|n| n.style.get(prop))
573            .cloned()
574            .unwrap_or_default()
575    }
576    pub fn clear_computed(&mut self) {
577        self.computed_styles.clear();
578    }
579
580    /// ダーティ状態を全クリアする。
581    pub fn clear_dirty(&mut self) {
582        self.dirty = false;
583        self.structural_dirty = false;
584        self.pending_style_updates.clear();
585    }
586
587    /// 構造的な DOM 変更(ノード追加/削除/クラス/テキスト/属性変更等)を記録する。
588    pub fn mark_structural_dirty(&mut self) {
589        self.dirty = true;
590        self.structural_dirty = true;
591    }
592
593    /// 新しいページの DOM 木からブリッジを再構築する(リスナはクリア)。
594    pub fn build_from(&mut self, root: &Node) {
595        self.nodes.clear();
596        self.listeners.clear();
597        self.selection = None;
598        self.clear_dirty();
599        self.built = true;
600        Self::collect(root, None, &mut self.nodes);
601        // 各要素の initial_text をドキュメント順走査で集約。
602        let n = self.nodes.len();
603        for i in 0..n {
604            if !self.nodes[i].is_text {
605                let t = self.collect_text(i);
606                self.nodes[i].initial_text = t;
607            }
608        }
609        // `<details name="...">`排他グループの初期状態正規化(HTML5。丸ごと
610        // 未対応だった)。クリック操作時の排他挙動は`render.rs`側で対応
611        // 済みだが、HTML記述時点で同じ`name`を持つ複数の`<details open>`が
612        // 並んでいた場合、仕様上は文書順で最後の1つだけが開いた状態のまま
613        // 残り、それより前のものは自動的に閉じられる必要がある。この
614        // パース直後の正規化が漏れていた。2026-07-18 発見・実装)。
615        {
616            let mut last_open_by_name: alloc::collections::BTreeMap<String, usize> = alloc::collections::BTreeMap::new();
617            for i in 0..n {
618                let node = &self.nodes[i];
619                if node.tag == "details" && node.attrs.contains_key("open") {
620                    if let Some(name) = node.attrs.get("name").filter(|s| !s.is_empty()) {
621                        last_open_by_name.insert(name.clone(), i);
622                    }
623                }
624            }
625            for i in 0..n {
626                let node = &self.nodes[i];
627                if node.tag == "details" && node.attrs.contains_key("open") {
628                    if let Some(name) = node.attrs.get("name").filter(|s| !s.is_empty()) {
629                        if last_open_by_name.get(name) != Some(&i) {
630                            self.nodes[i].attrs.remove("open");
631                        }
632                    }
633                }
634            }
635        }
636    }
637
638    /// 木をドキュメント順(プリオーダ)に再帰収集。子インデックスを記録する。
639    fn collect(node: &Node, parent: Option<usize>, out: &mut Vec<DomNode>) -> usize {
640        let idx = out.len();
641        let dn = match &node.node_type {
642            NodeType::Text(t) => DomNode {
643                tag: String::new(),
644                is_text: true,
645                id: String::new(),
646                classes: Vec::new(),
647                style: BTreeMap::new(),
648                attrs: BTreeMap::new(),
649                text_override: None,
650                initial_text: t.clone(),
651                parent,
652                children: Vec::new(),
653                classes_dirty: false,
654                style_dirty: false,
655            },
656            NodeType::Element {
657                tag_name,
658                attributes,
659                classes,
660                id,
661            } => {
662                let style =
663                    parse_inline_style(attributes.get("style").map(|s| s.as_str()).unwrap_or(""));
664                DomNode {
665                    tag: tag_name.clone(),
666                    is_text: false,
667                    id: id.clone().unwrap_or_default(),
668                    classes: classes.clone(),
669                    style,
670                    attrs: attributes.clone(),
671                    text_override: None,
672                    initial_text: String::new(),
673                    parent,
674                    children: Vec::new(),
675                    classes_dirty: false,
676                    style_dirty: false,
677                }
678            }
679        };
680        out.push(dn);
681        let mut kids = Vec::new();
682        for c in &node.children {
683            kids.push(Self::collect(c, Some(idx), out));
684        }
685        out[idx].children = kids;
686        idx
687    }
688
689    /// 部分木のテキストを連結(textContent 相当)。現在値(override 優先)で集約。
690    fn collect_text(&self, idx: usize) -> String {
691        let node = &self.nodes[idx];
692        if node.tag == "#comment" {
693            return node.text_override.clone().unwrap_or_else(|| node.initial_text.clone());
694        }
695        let mut s = String::new();
696        self.collect_text_into(idx, &mut s);
697        s
698    }
699    fn collect_text_into(&self, idx: usize, out: &mut String) {
700        let node = &self.nodes[idx];
701        if node.is_text {
702            out.push_str(node.text_override.as_deref().unwrap_or(&node.initial_text));
703            return;
704        }
705        for &c in &node.children {
706            self.collect_text_into(c, out);
707        }
708    }
709
710    // ---- 参照系 ----
711
712    /// id → ノード添字の対応表を 1 回で作る。
713    ///
714    /// 【2026-08-05】`get_element_by_id` は全ノードの線形走査で、
715    /// しかも各ノードで `is_attached` が親を辿る。
716    /// レイアウト後の矩形反映では**要素 1 個ごとに**これを呼んでおり、
717    /// O(要素 × ノード × 深さ) になっていた(実測で要素・ノードとも約 369)。
718    ///
719    /// 走査 1 回で表を作れば、引く側は O(log N) で済む。
720    /// 条件(テキストノードでない・id が空でない・ルートから到達可能)は
721    /// `get_element_by_id` と揃えてある。同じ id が複数あれば先勝ち
722    /// (`find` と同じ)。
723    ///
724    /// 計算量: **O(N × D)**(N はノード数、D は木の深さ)。
725    pub fn build_id_index(&self) -> alloc::collections::BTreeMap<&str, usize> {
726        let mut map: alloc::collections::BTreeMap<&str, usize> =
727            alloc::collections::BTreeMap::new();
728        if !self.built {
729            return map;
730        }
731        for i in 0..self.nodes.len() {
732            let n = &self.nodes[i];
733            if n.is_text || n.id.is_empty() {
734                continue;
735            }
736            if !self.is_attached(i) {
737                continue;
738            }
739            map.entry(n.id.as_str()).or_insert(i);
740        }
741        map
742    }
743
744    pub fn get_element_by_id(&self, id: &str) -> Option<usize> {
745        if !self.built {
746            return None;
747        }
748        if let Some(rest) = id.strip_prefix("_node_idx_") {
749            if let Ok(idx) = rest.parse::<usize>() {
750                if idx < self.nodes.len() && !self.nodes[idx].is_text && self.is_attached(idx) {
751                    return Some(idx);
752                }
753            }
754        }
755        (0..self.nodes.len())
756            .find(|&i| !self.nodes[i].is_text && self.nodes[i].id == id && self.is_attached(i))
757    }
758
759    /// node[0](ルート)から到達可能か。createElement 直後や remove 済みは false。
760    /// `node.isConnected`(丸ごと未対応だった)向けに公開する。
761    pub fn is_attached(&self, idx: usize) -> bool {
762        let mut cur = idx;
763        let mut guard = 0;
764        loop {
765            if cur == 0 {
766                return true;
767            }
768            match self.nodes.get(cur).and_then(|n| n.parent) {
769                Some(p) => {
770                    cur = p;
771                    guard += 1;
772                    if guard > self.nodes.len() {
773                        return false;
774                    }
775                }
776                None => return false,
777            }
778        }
779    }
780
781    /// 単純セレクタ(`#id` / `.class` / `tag`、先頭一致)にマッチする最初のノード。
782    pub fn query(&self, selector: &str) -> Option<usize> {
783        self.query_all(selector).into_iter().next()
784    }
785
786    /// セレクタにマッチする全ノード(ドキュメント順)。以前は `node_matches` と同じ
787    /// 単純セレクタ限定の簡略実装が独立に重複しており、`querySelectorAll('div.active')`
788    /// 等のコンパウンドセレクタが常に空になる同型のバグだった。`node_matches` と
789    /// 共通の `compound_matches`/`match_selector_chain` に統一して解消する。
790    pub fn query_all(&self, selector: &str) -> Vec<usize> {
791        self.query_all_scoped(selector, None)
792    }
793
794    /// `query_all` の `:scope` 対応版。`element.querySelectorAll`/`querySelector`
795    /// の起点要素を `scope_root` として渡すと、セレクタ中の `:scope` 疑似クラス
796    /// (Selectors Level 4。丸ごと未対応だった)がそのノードにのみ一致するように
797    /// なる(詳細は `node_matches_scoped` 参照)。
798    pub fn query_all_scoped(&self, selector: &str, scope_root: Option<usize>) -> Vec<usize> {
799        let sel = selector.trim();
800        let mut out = Vec::new();
801        if (!self.built && scope_root.is_none()) || sel.is_empty() {
802            return out;
803        }
804        for i in 0..self.nodes.len() {
805            if let Some(root) = scope_root {
806                if i != root && !self.is_ancestor_of(root, i) {
807                    continue;
808                }
809            } else if !self.is_attached(i) {
810                continue;
811            }
812            if self.node_matches_scoped(i, sel, scope_root) {
813                out.push(i);
814            }
815        }
816        out
817    }
818
819    // ---- 変更系 ----
820
821    /// 要素の textContent を設定。子孫テキストノードがあれば最初に値を入れ残りを空に、
822    /// 無ければ既存の子を捨てて単一テキスト子を作る(createElement したノード等)。
823    pub fn set_text_content(&mut self, idx: usize, val: &str) {
824        if idx >= self.nodes.len() {
825            return;
826        }
827        if self.nodes[idx].tag == "#comment" {
828            let old_val = self.nodes[idx]
829                .text_override
830                .as_deref()
831                .unwrap_or(&self.nodes[idx].initial_text)
832                .to_string();
833            self.nodes[idx].text_override = Some(val.to_string());
834            if old_val != val {
835                self.notify_character_data(idx, Some(&old_val));
836            }
837            self.mark_structural_dirty();
838            return;
839        }
840        // 要素の textContent への代入は、子をすべて取り除き、値が空でなければテキストノード
841        // 1 つに置き換える(DOM 標準)。【2026-10-18】従来は子孫のテキストノードの中身を
842        // 書き換えるだけで子の要素を残していた。YouTube のレンダラは `textContent = ""` の後に
843        // 新しい中身を追加して差し替えるため、古い操作部が残り二重になっていた(押しても
844        // 破棄済みの古いボタンに当たり、一時停止できなかった)。
845        if !self.nodes[idx].is_text {
846            let removed: Vec<usize> = core::mem::take(&mut self.nodes[idx].children);
847            for &c in &removed {
848                if let Some(n) = self.nodes.get_mut(c) {
849                    n.parent = None;
850                }
851            }
852            let mut added = Vec::new();
853            if !val.is_empty() {
854                let t = self.new_text_node(val);
855                self.nodes[t].parent = Some(idx);
856                self.nodes[idx].children.push(t);
857                added.push(t);
858            }
859            if !removed.is_empty() || !added.is_empty() {
860                self.notify_child_list(idx, added, removed);
861            }
862            self.mark_structural_dirty();
863            return;
864        }
865        let text_nodes = self.descendant_text_nodes(idx);
866        {
867            for (k, t) in text_nodes.iter().enumerate() {
868                let new_val = if k == 0 { val.to_string() } else { String::new() };
869                // `characterData`監視(丸ごと未対応だった。テキストノードの
870                // 内容が実際に変わった場合のみ通知する。詳細は
871                // `notify_character_data`のdocコメント参照。2026-07-17
872                // 発見・実装)。
873                let old_val = self.nodes[*t]
874                    .text_override
875                    .as_deref()
876                    .unwrap_or(&self.nodes[*t].initial_text)
877                    .to_string();
878                self.nodes[*t].text_override = Some(new_val.clone());
879                if old_val != new_val {
880                    self.notify_character_data(*t, Some(&old_val));
881                }
882            }
883        }
884        self.mark_structural_dirty();
885    }
886
887    /// `node.normalize()`(丸ごと未対応だった。DOM 標準メソッドで、連続する隣接テキスト
888    /// ノードを1つに連結し、空のテキストノードを取り除く。`textContent` を何度も
889    /// 部分的に書き換えた後などに呼ばれる定番の掃除用メソッド)。子孫を再帰的に処理する。
890    pub fn normalize_node(&mut self, idx: usize) {
891        if idx >= self.nodes.len() || self.nodes[idx].is_text {
892            return;
893        }
894        let children = self.nodes[idx].children.clone();
895        let mut run_start: Option<usize> = None;
896        let mut to_remove: Vec<usize> = Vec::new();
897        for &c in &children {
898            if self.nodes[c].is_text {
899                match run_start {
900                    None => run_start = Some(c),
901                    Some(first) => {
902                        let more = self.text_of(c);
903                        let mut cur = self.text_of(first);
904                        cur.push_str(&more);
905                        self.nodes[first].text_override = Some(cur);
906                        to_remove.push(c);
907                    }
908                }
909            } else {
910                run_start = None;
911                self.normalize_node(c);
912            }
913        }
914        for &c in &children {
915            if self.nodes.get(c).map(|n| n.is_text).unwrap_or(false)
916                && !to_remove.contains(&c)
917                && self.text_of(c).is_empty()
918            {
919                to_remove.push(c);
920            }
921        }
922        for r in to_remove {
923            self.remove_node(r);
924        }
925    }
926    /// テキストノードの実効テキスト(`text_override` があればそれ、無ければ
927    /// `initial_text`)。`collect_text_into` と同じ参照パターン。
928    fn text_of(&self, idx: usize) -> String {
929        let n = &self.nodes[idx];
930        n.text_override.as_deref().unwrap_or(&n.initial_text).to_string()
931    }
932
933    // ---- 構造変更(createElement / appendChild / removeChild / innerHTML) ----
934
935    fn new_element_node(&mut self, tag: &str) -> usize {
936        let idx = self.nodes.len();
937        let lower_tag = tag.to_lowercase();
938        let mut attrs = BTreeMap::new();
939        if self.custom_elements.is_defined(&lower_tag) {
940            attrs.insert(String::from("_defined"), String::from("true"));
941        }
942        self.nodes.push(DomNode {
943            tag: lower_tag,
944            is_text: false,
945            id: String::new(),
946            classes: Vec::new(),
947            style: BTreeMap::new(),
948            attrs,
949            text_override: None,
950            initial_text: String::new(),
951            parent: None,
952            children: Vec::new(),
953            classes_dirty: false,
954            style_dirty: false,
955        });
956        idx
957    }
958
959    fn new_text_node(&mut self, text: &str) -> usize {
960        let idx = self.nodes.len();
961        self.nodes.push(DomNode {
962            tag: String::new(),
963            is_text: true,
964            id: String::new(),
965            classes: Vec::new(),
966            style: BTreeMap::new(),
967            attrs: BTreeMap::new(),
968            text_override: Some(text.to_string()),
969            initial_text: text.to_string(),
970            parent: None,
971            children: Vec::new(),
972            classes_dirty: false,
973            style_dirty: false,
974        });
975        idx
976    }
977
978    /// `document.createElement(tag)`。デタッチ状態のノードを返す。
979    pub fn create_element(&mut self, tag: &str) -> usize {
980        let idx = self.new_element_node(tag);
981        if tag.eq_ignore_ascii_case("script") {
982            self.js_created_scripts.insert(idx);
983        }
984        idx
985    }
986
987    /// `<script>` の `(src 属性, type 属性, 本文)`。
988    pub fn script_source(&self, idx: usize) -> (Option<String>, String, String) {
989        let Some(n) = self.nodes.get(idx) else { return (None, String::new(), String::new()) };
990        let src = n.attrs.get("src").filter(|s| !s.trim().is_empty()).cloned();
991        let ty = n.attrs.get("type").cloned().unwrap_or_default();
992        let text = match &n.text_override {
993            Some(t) => t.clone(),
994            None => n
995                .children
996                .iter()
997                .filter_map(|&c| self.nodes.get(c))
998                .filter(|c| c.is_text)
999                .map(|c| c.text_override.clone().unwrap_or_else(|| c.initial_text.clone()))
1000                .collect(),
1001        };
1002        (src, ty, text)
1003    }
1004
1005    /// 文書(ノード 0 を根とする木)に繋がっているか。
1006    pub fn is_connected(&self, mut idx: usize) -> bool {
1007        let mut guard = 0;
1008        while let Some(p) = self.nodes.get(idx).and_then(|n| n.parent) {
1009            idx = p;
1010            guard += 1;
1011            if guard > 10_000 {
1012                return false;
1013            }
1014        }
1015        idx == 0
1016    }
1017
1018    /// 挿入された部分木の中の、JS が作った未実行の `<script>` を実行待ちに積む
1019    /// (HTML の「already started」と同じく 1 回だけ。パーサ・innerHTML 由来の script は対象外)。
1020    fn queue_inserted_scripts(&mut self, roots: &[usize]) {
1021        if self.js_created_scripts.is_empty() {
1022            return;
1023        }
1024        let mut stack: Vec<usize> = roots.to_vec();
1025        while let Some(i) = stack.pop() {
1026            if self.js_created_scripts.contains(&i) && self.is_connected(i) {
1027                self.js_created_scripts.remove(&i);
1028                self.pending_dynamic_scripts.push(i);
1029            }
1030            if let Some(n) = self.nodes.get(i) {
1031                stack.extend(n.children.iter().copied());
1032            }
1033        }
1034    }
1035
1036    /// `document.createTextNode(text)`。
1037    pub fn create_text_node(&mut self, text: &str) -> usize {
1038        self.new_text_node(text)
1039    }
1040
1041    /// `document.createComment(text)`。
1042    pub fn create_comment(&mut self, text: &str) -> usize {
1043        let idx = self.nodes.len();
1044        self.nodes.push(DomNode {
1045            tag: String::from("#comment"),
1046            is_text: false,
1047            id: String::new(),
1048            classes: Vec::new(),
1049            style: BTreeMap::new(),
1050            attrs: BTreeMap::new(),
1051            text_override: Some(text.to_string()),
1052            initial_text: text.to_string(),
1053            parent: None,
1054            children: Vec::new(),
1055            classes_dirty: false,
1056            style_dirty: false,
1057        });
1058        idx
1059    }
1060
1061    /// `document.createDocumentFragment()`(丸ごと未対応だった)。仕様どおり、
1062    /// `#fragment` という特殊タグの要素として表現する。この要素自身は決して
1063    /// `root` から到達可能(`is_attached`)にならず、`append_child`/`insert_before`
1064    /// に渡されると自身ではなく子要素群がそのまま挿入先へ移動し、自身は空になる
1065    /// (実 DOM の DocumentFragment の「挿入すると中身だけが移動する」挙動を再現)。
1066    pub fn create_document_fragment(&mut self) -> usize {
1067        self.new_element_node("#fragment")
1068    }
1069
1070    /// Web Components: `element.attachShadow({ mode: 'open' | 'closed' })`
1071    pub fn attach_shadow(
1072        &mut self,
1073        host_idx: usize,
1074        mode: crate::os_lib::shadow_dom::ShadowRootMode,
1075    ) -> Result<usize, crate::os_lib::shadow_dom::AttachShadowError> {
1076        let tag = match self.nodes.get(host_idx) {
1077            Some(n) if !n.is_text => n.tag.clone(),
1078            _ => return Err(crate::os_lib::shadow_dom::AttachShadowError::DisallowedTag),
1079        };
1080        // ShadowRoot は DocumentFragment 相当の仮想ルートノード `#shadow-root` として生成
1081        let root_idx = self.new_element_node("#shadow-root");
1082        self.shadow_tree.attach_shadow(host_idx, &tag, mode, root_idx)?;
1083        self.structural_dirty = true;
1084        self.dirty = true;
1085        Ok(root_idx)
1086    }
1087
1088    /// ホスト要素の ShadowRoot ノード index を取得(accessible な場合のみ)。
1089    pub fn get_shadow_root(&self, host_idx: usize) -> Option<usize> {
1090        self.shadow_tree
1091            .get_by_host(host_idx)
1092            .filter(|d| d.is_accessible_via_element())
1093            .map(|d| d.root_node_idx)
1094    }
1095
1096    /// W3C DOM Parsing: `Range.prototype.createContextualFragment(fragment)`
1097    pub fn create_contextual_fragment(&mut self, context_node_idx: Option<usize>, html: &str) -> usize {
1098        let mut context_tag = alloc::string::String::from("div");
1099        if let Some(idx) = context_node_idx {
1100            if let Some(node) = self.nodes.get(idx) {
1101                if node.is_text {
1102                    if let Some(p) = node.parent {
1103                        if let Some(pn) = self.nodes.get(p) {
1104                            if !pn.is_text && !pn.tag.starts_with('#') {
1105                                context_tag = pn.tag.clone();
1106                            }
1107                        }
1108                    }
1109                } else if !node.tag.starts_with('#') {
1110                    context_tag = node.tag.clone();
1111                }
1112            }
1113        }
1114
1115        let (wrapped, depth) = crate::os_lib::dom_fragment::context_wrapper(&context_tag, html);
1116        let root = crate::os_lib::dom::parse_html(&wrapped);
1117        let frag_idx = self.create_document_fragment();
1118        let children = crate::os_lib::dom_fragment::extract_fragment_children(&root, depth);
1119
1120        let mut imported_children = Vec::new();
1121        for child in children {
1122            let ci = self.import_node(child);
1123            self.nodes[ci].parent = Some(frag_idx);
1124            imported_children.push(ci);
1125        }
1126        self.nodes[frag_idx].children = imported_children;
1127        frag_idx
1128    }
1129
1130    /// `template.content`(`<template>` が丸ごと未対応だった)。仕様上 `<template>`
1131    /// の子は「不活性」で通常の DOM ツリーには存在せず、代わりに `.content`
1132    /// (`DocumentFragment`)経由でのみアクセスできる。この処理系は HTML パース時に
1133    /// `template` を特別扱いしないため子が普通にライブツリーへ入ってしまうが、
1134    /// 初回アクセス時にその子を `#fragment` ノードへ移し替え(内部専用属性
1135    /// `_content_frag` にフラグメントの index をキャッシュ)、以後は同じフラグメントを
1136    /// 返すことで「以後 `<template>` 自身は空、`.content` だけが中身を持つ」という
1137    /// 仕様どおりの見た目に近づける。
1138    pub fn template_content(&mut self, idx: usize) -> usize {
1139        if let Some(f) = self
1140            .get_attr(idx, "_content_frag")
1141            .and_then(|s| s.parse::<usize>().ok())
1142        {
1143            return f;
1144        }
1145        let f = self.create_document_fragment();
1146        let kids = core::mem::take(&mut self.nodes[idx].children);
1147        for &k in &kids {
1148            self.nodes[k].parent = Some(f);
1149        }
1150        self.nodes[f].children = kids;
1151        self.set_attr(idx, "_content_frag", &f.to_string());
1152        f
1153    }
1154
1155    /// `child` が DocumentFragment(`#fragment` タグ)なら、その子要素群を
1156    /// 親から切り離して返し、フラグメント自身は空にする。それ以外は `[child]` を
1157    /// そのまま返す(`append_child`/`insert_before` の共通前処理)。
1158    fn expand_fragment(&mut self, child: usize) -> Vec<usize> {
1159        if self.nodes.get(child).map(|n| n.tag == "#fragment" || n.tag == "#document-fragment").unwrap_or(false) {
1160            let kids = core::mem::take(&mut self.nodes[child].children);
1161            for &k in &kids {
1162                self.nodes[k].parent = None;
1163            }
1164            kids
1165        } else {
1166            alloc::vec![child]
1167        }
1168    }
1169
1170    /// `parent.appendChild(child)`。既存の親から外して付け替える。`child` が
1171    /// DocumentFragment の場合は仕様どおりその子要素群を代わりに追加する。
1172    pub fn append_child(&mut self, parent: usize, child: usize) {
1173        if parent >= self.nodes.len() || child >= self.nodes.len() || parent == child {
1174            return;
1175        }
1176        let kids = self.expand_fragment(child);
1177        if kids.is_empty() {
1178            return;
1179        }
1180        for &k in &kids {
1181            self.detach(k);
1182            self.nodes[k].parent = Some(parent);
1183            self.nodes[parent].children.push(k);
1184        }
1185        self.mark_structural_dirty();
1186        self.queue_inserted_scripts(&kids);
1187        self.notify_child_list(parent, kids, Vec::new());
1188    }
1189
1190    /// `parent.removeChild(child)` / `child.remove()`。
1191    pub fn remove_child(&mut self, parent: usize, child: usize) {
1192        let p = if parent < self.nodes.len() { Some(parent) } else { self.nodes.get(child).and_then(|n| n.parent) };
1193        self.detach(child);
1194        self.mark_structural_dirty();
1195        if let Some(p) = p { self.notify_child_list(p, Vec::new(), alloc::vec![child]); }
1196    }
1197    pub fn remove_node(&mut self, idx: usize) {
1198        let p = self.nodes.get(idx).and_then(|n| n.parent);
1199        self.detach(idx);
1200        self.mark_structural_dirty();
1201        if let Some(p) = p { self.notify_child_list(p, Vec::new(), alloc::vec![idx]); }
1202    }
1203
1204    /// `parent.insertBefore(new_child, ref_child)`。ref が None/不在なら末尾に追加。
1205    /// `new_child` が DocumentFragment の場合は仕様どおりその子要素群を代わりに挿入する。
1206    pub fn insert_before(&mut self, parent: usize, new_child: usize, ref_child: Option<usize>) {
1207        if parent >= self.nodes.len() || new_child >= self.nodes.len() || parent == new_child {
1208            return;
1209        }
1210        let kids = self.expand_fragment(new_child);
1211        if kids.is_empty() {
1212            return;
1213        }
1214        let pos = ref_child.and_then(|r| self.nodes[parent].children.iter().position(|&c| c == r));
1215        for (i, &k) in kids.iter().enumerate() {
1216            self.detach(k);
1217            self.nodes[k].parent = Some(parent);
1218            match pos {
1219                Some(p) => self.nodes[parent].children.insert(p + i, k),
1220                None => self.nodes[parent].children.push(k),
1221            }
1222        }
1223        self.mark_structural_dirty();
1224        self.queue_inserted_scripts(&kids);
1225        self.notify_child_list(parent, kids, Vec::new());
1226    }
1227
1228    /// `parent.replaceChild(new_child, old_child)`。old を new で置換。
1229    pub fn replace_child(&mut self, parent: usize, new_child: usize, old_child: usize) {
1230        if parent >= self.nodes.len() || new_child >= self.nodes.len() {
1231            return;
1232        }
1233        if !self.nodes[parent].children.contains(&old_child) {
1234            return;
1235        }
1236        self.detach(new_child); // どこかに属していれば外す(位置が変わりうる)
1237        if let Some(p) = self.nodes[parent]
1238            .children
1239            .iter()
1240            .position(|&c| c == old_child)
1241        {
1242            self.nodes[old_child].parent = None;
1243            self.nodes[parent].children[p] = new_child;
1244            self.nodes[new_child].parent = Some(parent);
1245            self.mark_structural_dirty();
1246            self.queue_inserted_scripts(&[new_child]);
1247            self.notify_child_list(parent, alloc::vec![new_child], alloc::vec![old_child]);
1248        }
1249    }
1250
1251    /// `node.cloneNode(deep)`。属性/テキストをコピーした新ノードを作る(親なし)。deep なら子も再帰。
1252    pub fn clone_node(&mut self, idx: usize, deep: bool) -> usize {
1253        let c = self.clone_node_inner(idx, deep);
1254        if c != idx && !self.custom_elements.is_empty() {
1255            self.ce_queue.push(CeEvent::Created(c));
1256        }
1257        c
1258    }
1259
1260    fn clone_node_inner(&mut self, idx: usize, deep: bool) -> usize {
1261        if idx >= self.nodes.len() {
1262            return idx;
1263        }
1264        let (is_text, tag, id, classes, style, attrs, text_override, initial_text, children) = {
1265            let n = &self.nodes[idx];
1266            (
1267                n.is_text,
1268                n.tag.clone(),
1269                n.id.clone(),
1270                n.classes.clone(),
1271                n.style.clone(),
1272                n.attrs.clone(),
1273                n.text_override.clone(),
1274                n.initial_text.clone(),
1275                n.children.clone(),
1276            )
1277        };
1278        let new_idx = self.nodes.len();
1279        self.nodes.push(DomNode {
1280            tag,
1281            is_text,
1282            id,
1283            classes,
1284            style,
1285            attrs,
1286            text_override,
1287            initial_text,
1288            parent: None,
1289            children: Vec::new(),
1290            classes_dirty: false,
1291            style_dirty: false,
1292        });
1293        if deep && !is_text {
1294            for c in children {
1295                let cc = self.clone_node_inner(c, true);
1296                self.nodes[cc].parent = Some(new_idx);
1297                self.nodes[new_idx].children.push(cc);
1298            }
1299        }
1300        // `<template>.content` は通常の `children` ではなく `_content_frag` 属性
1301        // 経由で参照される遅延切り出し済み `#fragment` のため、上の通常の
1302        // children クローンでは複製されない。属性は他の属性と同様に文字列と
1303        // してそのままコピーされるので、対処しないとクローン先の
1304        // `template.content` が元の(同じ)フラグメントを指したまま共有されて
1305        // しまうバグだった(`.content` に一度でもアクセス済みの `<template>` を
1306        // `cloneNode()` した場合にのみ顕在化する)。仕様上 `<template>` の
1307        // content クローンは `deep` フラグに関わらず常に行われる特殊規則のため、
1308        // `if deep` の外で無条件に複製する。
1309        let frag_attr = self.nodes[new_idx]
1310            .attrs
1311            .get("_template_content_idx")
1312            .or_else(|| self.nodes[new_idx].attrs.get("_content_frag"))
1313            .cloned();
1314        if let Some(frag_idx) = frag_attr.and_then(|s| s.parse::<usize>().ok()) {
1315            let new_frag = self.clone_node_inner(frag_idx, true);
1316            self.nodes[new_idx]
1317                .attrs
1318                .insert("_template_content_idx".to_string(), new_frag.to_string());
1319        }
1320        self.mark_structural_dirty();
1321        new_idx
1322    }
1323
1324    /// 親の children から idx を外す。
1325    fn detach(&mut self, idx: usize) {
1326        if idx >= self.nodes.len() {
1327            return;
1328        }
1329        if let Some(p) = self.nodes[idx].parent {
1330            if let Some(pos) = self.nodes[p].children.iter().position(|&c| c == idx) {
1331                self.nodes[p].children.remove(pos);
1332            }
1333        }
1334        self.nodes[idx].parent = None;
1335    }
1336
1337    /// 単一ノードを基準ノード idx の相対位置へ挿入(insertAdjacent* 共通)。
1338    pub fn insert_adjacent_node(&mut self, idx: usize, position: &str, node: usize) {
1339        match position {
1340            "beforeend" => self.append_child(idx, node),
1341            "afterbegin" => {
1342                let first = self
1343                    .nodes
1344                    .get(idx)
1345                    .and_then(|n| n.children.first().copied());
1346                self.insert_before(idx, node, first);
1347            }
1348            "beforebegin" => {
1349                if let Some(p) = self.nodes.get(idx).and_then(|n| n.parent) {
1350                    self.insert_before(p, node, Some(idx));
1351                }
1352            }
1353            "afterend" => {
1354                if let Some(p) = self.nodes.get(idx).and_then(|n| n.parent) {
1355                    let next = self.nodes.get(p).and_then(|n| {
1356                        n.children
1357                            .iter()
1358                            .position(|&c| c == idx)
1359                            .and_then(|i| n.children.get(i + 1).copied())
1360                    });
1361                    self.insert_before(p, node, next);
1362                }
1363            }
1364            _ => {}
1365        }
1366    }
1367
1368    /// `element.insertAdjacentHTML(position, html)`。html をパースして相対位置へ挿入。
1369    pub fn insert_adjacent_html(&mut self, idx: usize, position: &str, html: &str) {
1370        let root = crate::os_lib::dom::parse_html(html);
1371        let imported: Vec<usize> = root.children.iter().map(|c| self.import_node(c)).collect();
1372        if position == "afterbegin" {
1373            // 基準(元の先頭子)を一度だけ捕捉して順序保持で前挿入。
1374            let first = self
1375                .nodes
1376                .get(idx)
1377                .and_then(|n| n.children.first().copied());
1378            for node in imported {
1379                self.insert_before(idx, node, first);
1380            }
1381        } else {
1382            for node in imported {
1383                self.insert_adjacent_node(idx, position, node);
1384            }
1385        }
1386        self.mark_structural_dirty();
1387    }
1388
1389    /// `element.innerHTML = html`。フラグメントをパースして子を置換する。
1390    /// `appendChild`/`removeChild`/`insertBefore`/`replaceChild` は全て
1391    /// `notify_child_list` 経由で MutationObserver に通知していたが、
1392    /// `innerHTML` 代入だけがこの呼び出しを欠いており、最も頻繁に使われる
1393    /// DOM 書き換え手段にもかかわらず childList 変化が一切観察できなかった。
1394    pub fn set_inner_html(&mut self, idx: usize, html: &str) {
1395        if idx >= self.nodes.len() || self.nodes[idx].is_text {
1396            return;
1397        }
1398        let root = crate::os_lib::dom::parse_html(html);
1399        // 既存の子を切り離す。
1400        let old: Vec<usize> = self.nodes[idx].children.clone();
1401        for &c in &old {
1402            self.nodes[c].parent = None;
1403        }
1404        self.nodes[idx].children.clear();
1405        // パース結果(合成ルートの子)を import して付ける。
1406        let mut new_children = Vec::new();
1407        for child in &root.children {
1408            let ci = self.import_node(child);
1409            self.nodes[ci].parent = Some(idx);
1410            new_children.push(ci);
1411        }
1412        self.nodes[idx].children = new_children.clone();
1413        self.mark_structural_dirty();
1414        self.notify_child_list(idx, new_children, old);
1415    }
1416
1417    /// `element.outerHTML = html`。自ノードを HTML パース結果のノード群で置き換える。
1418    pub fn set_outer_html(&mut self, idx: usize, html: &str) {
1419        if idx >= self.nodes.len() {
1420            return;
1421        }
1422        let parent = match self.nodes[idx].parent {
1423            Some(p) => p,
1424            None => return,
1425        };
1426        let pos = match self.nodes[parent].children.iter().position(|&c| c == idx) {
1427            Some(p) => p,
1428            None => return,
1429        };
1430        let root = crate::os_lib::dom::parse_html(html);
1431        let mut new_inserted = Vec::new();
1432        for child in &root.children {
1433            let ci = self.import_node(child);
1434            self.nodes[ci].parent = Some(parent);
1435            new_inserted.push(ci);
1436        }
1437        self.nodes[idx].parent = None;
1438        self.nodes[parent].children.remove(pos);
1439        for (i, &ci) in new_inserted.iter().enumerate() {
1440            self.nodes[parent].children.insert(pos + i, ci);
1441        }
1442        self.mark_structural_dirty();
1443        self.notify_child_list(parent, new_inserted, alloc::vec![idx]);
1444    }
1445
1446    /// `dom::Node` 部分木をブリッジへ取り込み、ルート idx を返す。
1447    pub fn import_node(&mut self, node: &Node) -> usize {
1448        match &node.node_type {
1449            NodeType::Text(t) => self.new_text_node(t),
1450            NodeType::Element {
1451                tag_name,
1452                attributes,
1453                classes,
1454                id,
1455            } => {
1456                let idx = self.new_element_node(tag_name);
1457                {
1458                    let style = parse_inline_style(
1459                        attributes.get("style").map(|s| s.as_str()).unwrap_or(""),
1460                    );
1461                    let n = &mut self.nodes[idx];
1462                    n.tag = tag_name.clone();
1463                    n.id = id.clone().unwrap_or_default();
1464                    n.classes = classes.clone();
1465                    n.style = style;
1466                    n.attrs = attributes.clone();
1467                }
1468                let mut kids = Vec::new();
1469                for c in &node.children {
1470                    let ci = self.import_node(c);
1471                    self.nodes[ci].parent = Some(idx);
1472                    kids.push(ci);
1473                }
1474                self.nodes[idx].children = kids;
1475                idx
1476            }
1477        }
1478    }
1479
1480    /// XML/SVG パースで取り込まれた部分木をマークする(tagName で元のケースを保持するため)。
1481    pub fn mark_subtree_xml(&mut self, idx: usize) {
1482        if let Some(n) = self.nodes.get_mut(idx) {
1483            n.attrs.insert("_xml".into(), "1".into());
1484            let kids = n.children.clone();
1485            for k in kids {
1486                self.mark_subtree_xml(k);
1487            }
1488        }
1489    }
1490
1491    /// ブリッジツリー(node[0] をルート)を `dom::Node` へ変換する。
1492    /// JS の変更(textContent/style/class/動的ノード)が反映された描画用ツリー。
1493    pub fn to_dom_node(&self) -> Node {
1494        if self.nodes.is_empty() {
1495            return Node::new_text(String::new());
1496        }
1497        self.build_node(0)
1498    }
1499
1500    fn build_node(&self, idx: usize) -> Node {
1501        let n = &self.nodes[idx];
1502        if n.is_text {
1503            return Node::new_text(
1504                n.text_override
1505                    .clone()
1506                    .unwrap_or_else(|| n.initial_text.clone()),
1507            );
1508        }
1509        let mut attrs = n.attrs.clone();
1510        let id = if n.id.is_empty() {
1511            let gen_id = alloc::format!("_node_idx_{}", idx);
1512            attrs.insert("id".to_string(), gen_id.clone());
1513            Some(gen_id)
1514        } else {
1515            attrs.insert("id".to_string(), n.id.clone());
1516            Some(n.id.clone())
1517        };
1518        if n.classes.is_empty() {
1519            attrs.remove("class");
1520        } else {
1521            attrs.insert("class".to_string(), n.classes.join(" "));
1522        }
1523        if n.style.is_empty() {
1524            attrs.remove("style");
1525        } else {
1526            attrs.insert("style".to_string(), serialize_inline_style(&n.style));
1527        }
1528        let mut node = Node::new_element_direct(n.tag.clone(), attrs, id, n.classes.clone());
1529        for &c in &n.children {
1530            node.children.push(self.build_node(c));
1531        }
1532        node
1533    }
1534
1535    /// 要素の textContent(現在値)を取得。
1536    pub fn get_text_content(&self, idx: usize) -> String {
1537        if idx >= self.nodes.len() {
1538            return String::new();
1539        }
1540        self.collect_text(idx)
1541    }
1542
1543    fn descendant_text_nodes(&self, idx: usize) -> Vec<usize> {
1544        let mut out = Vec::new();
1545        self.descendant_text_into(idx, &mut out);
1546        out
1547    }
1548    fn descendant_text_into(&self, idx: usize, out: &mut Vec<usize>) {
1549        let node = &self.nodes[idx];
1550        if node.is_text {
1551            out.push(idx);
1552            return;
1553        }
1554        for &c in &node.children {
1555            self.descendant_text_into(c, out);
1556        }
1557    }
1558
1559    // `style.xxx = ...`/`style.cssText = ...` は `class`/他の属性と同じく
1560    // `MutationObserver`(`attributes:true` + `attributeFilter:['style']` 等)が
1561    // 観察できるべき「style」属性の変化にもかかわらず、`class_add`/`class_remove`
1562    // と同型で `notify_attribute` を一切呼んでいなかった非対称バグ。
1563    pub fn set_style(&mut self, idx: usize, prop: &str, val: &str) {
1564        let key = css_prop_from_camel(prop);
1565        let (old_value, changed) = {
1566            let Some(n) = self.nodes.get_mut(idx) else {
1567                return;
1568            };
1569            let old_value = serialize_inline_style(&n.style);
1570            let changed = if val.is_empty() {
1571                n.style.remove(&key).is_some()
1572            } else {
1573                n.style.insert(key.clone(), val.to_string()).as_deref() != Some(val)
1574            };
1575            if changed {
1576                n.style_dirty = true;
1577            }
1578            (old_value, changed)
1579        };
1580        // 【2026-10-09】値が変わらない代入では再レイアウトを要求しない。scroll リスナが
1581        // 毎回同じ値を入れる定番の書き方(sugi-lab.net の `header.style.background = ...`)で、
1582        // スクロールの度に全体の再レイアウト(QEMU で約 350ms)が走っていた。
1583        // MutationObserver への通知は仕様どおり続ける(属性の再設定は記録される)。
1584        if changed {
1585            self.dirty = true;
1586            if let Some(pos) = self.pending_style_updates.iter().position(|(i, k, _)| *i == idx && k == &key) {
1587                self.pending_style_updates[pos].2 = val.to_string();
1588            } else {
1589                self.pending_style_updates.push((idx, key, val.to_string()));
1590            }
1591        }
1592        self.notify_attribute(idx, "style", Some(&old_value));
1593    }
1594
1595    pub fn get_style(&self, idx: usize, prop: &str) -> String {
1596        let raw = self
1597            .nodes
1598            .get(idx)
1599            .and_then(|n| n.style.get(&css_prop_from_camel(prop)).cloned())
1600            .unwrap_or_default();
1601        strip_style_priority(&raw).0.to_string()
1602    }
1603
1604    /// `element.style.getPropertyPriority(name)`(丸ごと未対応だった。
1605    /// 2026-07-16 発見)。`!important` は生の値文字列末尾にそのまま保持
1606    /// されている(`set_style`/`css_prop_from_camel` 参照)ため、
1607    /// `strip_style_priority` で判定するだけで済む。
1608    pub fn get_style_priority(&self, idx: usize, prop: &str) -> String {
1609        let raw = self
1610            .nodes
1611            .get(idx)
1612            .and_then(|n| n.style.get(&css_prop_from_camel(prop)).cloned())
1613            .unwrap_or_default();
1614        if strip_style_priority(&raw).1 {
1615            "important".to_string()
1616        } else {
1617            String::new()
1618        }
1619    }
1620
1621    /// `element.style.cssText` の読み出し。以前は丸ごと未対応で、`cssText` を単なる
1622    /// 通常の CSS プロパティ名として扱う既存の汎用経路(`get_style`)に落ちてしまい、
1623    /// 常に空文字列を返していた(`serialize_inline_style` 自体は属性文字列の再構築で
1624    /// 既に使われていたが、JS からの読み出しには配線されていなかった)。
1625    pub fn get_css_text(&self, idx: usize) -> String {
1626        self.nodes
1627            .get(idx)
1628            .map(|n| serialize_inline_style(&n.style))
1629            .unwrap_or_default()
1630    }
1631    /// `element.style.cssText = "..."` の書き込み。単一プロパティとして扱われ常に
1632    /// 無視されていたのを、既存のスタイル属性パーサ(`parse_inline_style`)で丸ごと
1633    /// 置き換えるよう実装する。
1634    pub fn set_css_text(&mut self, idx: usize, text: &str) {
1635        let old_value = {
1636            let Some(n) = self.nodes.get_mut(idx) else {
1637                return;
1638            };
1639            let old_value = serialize_inline_style(&n.style);
1640            n.style = parse_inline_style(text);
1641            n.style_dirty = true;
1642            old_value
1643        };
1644        self.mark_structural_dirty();
1645        self.notify_attribute(idx, "style", Some(&old_value));
1646    }
1647
1648    /// 診断用: DOM の骨組み(`tag#id.class [style]`)を字下げ付きで最大 `max` 行返す。
1649    /// テキストノードは省く。実サイトの JS がどんな要素を組み立てたかを追うためのもの。
1650    pub fn outline(&self, max: usize) -> Vec<String> {
1651        let mut out = Vec::new();
1652        let mut stack: Vec<(usize, usize)> = self
1653            .nodes
1654            .iter()
1655            .enumerate()
1656            .filter(|(_, n)| n.parent.is_none() && !n.is_text)
1657            .map(|(i, _)| (i, 0))
1658            .rev()
1659            .collect();
1660        while let Some((idx, depth)) = stack.pop() {
1661            if out.len() >= max {
1662                break;
1663            }
1664            let Some(n) = self.nodes.get(idx) else { continue };
1665            if n.is_text {
1666                continue;
1667            }
1668            let mut line = String::new();
1669            for _ in 0..depth.min(30) {
1670                line.push(' ');
1671            }
1672            line.push_str(&n.tag);
1673            if !n.id.is_empty() {
1674                line.push('#');
1675                line.push_str(&n.id);
1676            }
1677            for c in n.classes.iter().take(3) {
1678                line.push('.');
1679                line.push_str(c);
1680            }
1681            if !n.style.is_empty() {
1682                line.push_str(" [");
1683                for (k, v) in n.style.iter().take(4) {
1684                    line.push_str(k);
1685                    line.push(':');
1686                    line.extend(v.chars().take(70));
1687                    line.push(';');
1688                }
1689                line.push(']');
1690            }
1691            if let Some(src) = n.attrs.get("src") {
1692                line.push_str(" src=");
1693                line.extend(src.chars().take(60));
1694            }
1695            // 直下のテキスト(エラー表示の文言などを読むため)。
1696            let text: String = n
1697                .children
1698                .iter()
1699                .filter_map(|&c| self.nodes.get(c))
1700                .filter(|c| c.is_text)
1701                .map(|c| c.text_override.as_deref().unwrap_or(&c.initial_text))
1702                .collect::<Vec<_>>()
1703                .join("");
1704            let text = n.text_override.as_deref().filter(|t| !t.is_empty()).unwrap_or(text.as_str()).trim();
1705            if !text.is_empty() && n.tag != "script" && n.tag != "style" {
1706                line.push_str(" \"");
1707                line.extend(text.chars().take(80));
1708                line.push('"');
1709            }
1710            out.push(line);
1711            for &c in n.children.iter().rev() {
1712                stack.push((c, depth + 1));
1713            }
1714        }
1715        out
1716    }
1717
1718    /// `element.outerHTML`(読み出し専用。書き込み側は要素の親子関係の組み替えが
1719    /// 必要で改修規模が大きいため今回は見送り)が丸ごと未対応だった。自身のタグ・属性
1720    /// (id/class/style を含む)・子孫を再帰的に HTML 文字列へ直列化する。
1721    pub fn get_outer_html(&self, idx: usize) -> String {
1722        let mut out = String::new();
1723        self.serialize_node_html(idx, &mut out);
1724        out
1725    }
1726    /// `element.innerHTML` の読み出しが、実際にはマークアップ(子要素タグ)を含めず
1727    /// `textContent` と同じ「全テキストを連結しただけ」の値を返してしまっていたバグ。
1728    /// `<div>a<b>bold</b></div>` の `innerHTML` が仕様どおりの `"a<b>bold</b>"` ではなく
1729    /// `"abold"` になっていた。自身のタグは含めず、子ノードだけを直列化する。
1730    pub fn get_inner_html(&self, idx: usize) -> String {
1731        let mut out = String::new();
1732        if let Some(n) = self.nodes.get(idx) {
1733            for &c in &n.children {
1734                self.serialize_node_html(c, &mut out);
1735            }
1736        }
1737        out
1738    }
1739    fn serialize_node_html(&self, idx: usize, out: &mut String) {
1740        let Some(n) = self.nodes.get(idx) else { return };
1741        if n.is_text {
1742            let text = n.text_override.as_deref().unwrap_or(&n.initial_text);
1743            // 生テキスト要素(script/style 等)の中身はエスケープしない(HTML の直列化アルゴリズム)。
1744            // 【2026-10-18】`<style>` の `a>b` が `a&gt;b` になり、取り出した CSS が壊れていた。
1745            let raw_parent = n
1746                .parent
1747                .and_then(|p| self.nodes.get(p))
1748                .is_some_and(|p| matches!(p.tag.as_str(), "script" | "style" | "xmp" | "iframe" | "noembed" | "noframes" | "plaintext"));
1749            if raw_parent {
1750                out.push_str(text);
1751            } else {
1752                out.push_str(&html_escape_text(text));
1753            }
1754            return;
1755        }
1756        if n.tag == "#comment" {
1757            out.push_str("<!--");
1758            out.push_str(n.text_override.as_deref().unwrap_or(&n.initial_text));
1759            out.push_str("-->");
1760            return;
1761        }
1762        out.push('<');
1763        out.push_str(&n.tag);
1764        if !n.id.is_empty() {
1765            out.push_str(&format!(" id=\"{}\"", html_escape_attr(&n.id)));
1766        }
1767        if !n.classes.is_empty() {
1768            out.push_str(&format!(" class=\"{}\"", html_escape_attr(&n.classes.join(" "))));
1769        }
1770        if !n.style.is_empty() {
1771            out.push_str(&format!(
1772                " style=\"{}\"",
1773                html_escape_attr(&serialize_inline_style(&n.style))
1774            ));
1775        }
1776        // `n.attrs` には HTML パース時の生の `id`/`class` もそのまま残っている場合が
1777        // あり(`n.id`/`n.classes` は別途専用フィールドとして持つ)、除外しないと
1778        // `id="t" id="t"` のように重複出力してしまう。`_` 始まりの内部専用属性
1779        // (`_content_frag`/`_return_value`/`_custom_validity` 等、実装詳細を
1780        // `set_attr`/`get_attr` 経由で持ち回るための housekeeping 用の値)も
1781        // 除外しないと `outerHTML`/`innerHTML` にそのまま漏れて見えてしまう
1782        // バグだった。
1783        for (k, v) in &n.attrs {
1784            if k == "id" || k == "class" || k == "style" || k.starts_with('_') {
1785                continue;
1786            }
1787            out.push(' ');
1788            out.push_str(k);
1789            out.push_str("=\"");
1790            out.push_str(&html_escape_attr(v));
1791            out.push('"');
1792        }
1793        out.push('>');
1794        // void 要素(自身終了タグを持たない)。
1795        if matches!(
1796            n.tag.as_str(),
1797            "br" | "img" | "input" | "hr" | "meta" | "link" | "area" | "base" | "col"
1798                | "embed" | "param" | "source" | "track" | "wbr"
1799        ) {
1800            return;
1801        }
1802        for &c in &n.children {
1803            self.serialize_node_html(c, out);
1804        }
1805        out.push_str("</");
1806        out.push_str(&n.tag);
1807        out.push('>');
1808    }
1809
1810    // `classList.add`/`.remove` は `class` 属性を書き換えるにもかかわらず、
1811    // `setAttribute('class', ...)`/`removeAttribute()` と違って `notify_attribute`
1812    // を一切呼んでおらず、`MutationObserver` に「class」の変化が通知されない
1813    // 非対称バグだった(`set_attr`/`remove_attr` は既に通知していた)。
1814    pub fn class_add(&mut self, idx: usize, cls: &str) {
1815        let old_value = {
1816            let Some(n) = self.nodes.get_mut(idx) else {
1817                return;
1818            };
1819            if n.classes.iter().any(|c| c == cls) {
1820                return;
1821            }
1822            let old_value = n.classes.join(" ");
1823            n.classes.push(cls.to_string());
1824            n.classes_dirty = true;
1825            old_value
1826        };
1827        self.mark_structural_dirty();
1828        self.notify_attribute(idx, "class", Some(&old_value));
1829    }
1830    pub fn class_remove(&mut self, idx: usize, cls: &str) {
1831        let old_value = {
1832            let Some(n) = self.nodes.get_mut(idx) else {
1833                return;
1834            };
1835            let before = n.classes.len();
1836            let old_value = n.classes.join(" ");
1837            n.classes.retain(|c| c != cls);
1838            if n.classes.len() == before {
1839                return;
1840            }
1841            n.classes_dirty = true;
1842            old_value
1843        };
1844        self.mark_structural_dirty();
1845        self.notify_attribute(idx, "class", Some(&old_value));
1846    }
1847    pub fn class_toggle(&mut self, idx: usize, cls: &str) -> bool {
1848        if self.class_contains(idx, cls) {
1849            self.class_remove(idx, cls);
1850            false
1851        } else {
1852            self.class_add(idx, cls);
1853            true
1854        }
1855    }
1856    pub fn class_contains(&self, idx: usize, cls: &str) -> bool {
1857        self.nodes
1858            .get(idx)
1859            .map(|n| n.classes.iter().any(|c| c == cls))
1860            .unwrap_or(false)
1861    }
1862
1863    /// `<select>`配下の`<option>`要素を全て集める(`<optgroup>`でグループ化
1864    /// された`<option>`も含む再帰探索。丸ごと未対応だった。以前は直接の子
1865    /// のみを見る実装だったため、`<select><optgroup label="Fruits">
1866    /// <option>Apple</option></optgroup></select>`という非常によく使われる
1867    /// カテゴリ分けパターンで、`<optgroup>`配下の`<option>`が`select.
1868    /// options`/フォーム送信のどちらからも一切見えなくなる実害のある
1869    /// バグだった。`<option>`要素自身は子に更に`<option>`を持たない
1870    /// (仕様上そのような入れ子は無意味)ため、`<option>`に達したら
1871    /// そこで再帰を打ち切る。2026-07-17 発見・実装)。
1872    fn collect_descendant_options(&self, idx: usize, out: &mut Vec<usize>) {
1873        let Some(n) = self.nodes.get(idx) else { return };
1874        for &c in &n.children {
1875            let Some(cn) = self.nodes.get(c) else { continue };
1876            if cn.tag == "option" {
1877                out.push(c);
1878            } else {
1879                self.collect_descendant_options(c, out);
1880            }
1881        }
1882    }
1883    /// `<select>` の option 子要素一覧(`<optgroup>`配下も含む)。`select.
1884    /// options`(丸ごと未対応だった。動的にドロップダウンを構築する定番
1885    /// イディオムで使われる)向けに公開する。
1886    pub fn select_options(&self, idx: usize) -> Vec<usize> {
1887        let mut out = Vec::new();
1888        self.collect_descendant_options(idx, &mut out);
1889        out
1890    }
1891    /// option 要素の実効値: `value` 属性があればそれ、無ければテキスト内容
1892    /// (仕様どおり。`collect_form_data` の select フォールバックと同じ規則)。
1893    fn option_value(&self, opt_idx: usize) -> String {
1894        self.get_attr(opt_idx, "value").unwrap_or_else(|| {
1895            self.nodes
1896                .get(opt_idx)
1897                .map(|n| n.text_override.clone().unwrap_or_else(|| n.initial_text.clone()))
1898                .unwrap_or_default()
1899        })
1900    }
1901    /// `select.selectedOptions`(`<select multiple>` で選択中の option 一覧を得る
1902    /// 定番イディオム。`FormData` の multi-select 対応と同じく `selected` 属性を
1903    /// 真実源とする)。単一選択の `<select>` でも仕様どおり動作する
1904    /// (`selected` が無ければ既定選択の最初の option を1件返す)。
1905    pub fn selected_option_indices(&self, idx: usize) -> Vec<usize> {
1906        let options = self.select_options(idx);
1907        let selected: Vec<usize> = options.iter().copied().filter(|&o| self.has_attr(o, "selected")).collect();
1908        if !selected.is_empty() {
1909            selected
1910        } else {
1911            options.first().copied().into_iter().collect()
1912        }
1913    }
1914    /// `select.value`(丸ごと未対応だった。`<select>` 自身に `value` 属性は無く、
1915    /// 選択状態は子の `<option selected>` が持つため、汎用の属性直結 getter では
1916    /// 常に空文字列になっていた)。`selected` な option を優先し、無ければ
1917    /// HTML の既定選択どおり最初の option にフォールバックする。
1918    pub fn select_effective_value(&self, idx: usize) -> String {
1919        let options = self.select_options(idx);
1920        let target = options
1921            .iter()
1922            .copied()
1923            .find(|&o| self.has_attr(o, "selected"))
1924            .or_else(|| options.first().copied());
1925        match target {
1926            Some(o) => self.option_value(o),
1927            None => String::new(),
1928        }
1929    }
1930    /// フォーム系コントロールの「現在の実効値」を文字列で解決する。
1931    /// `checkValidity()`/`.validationMessage`/`.validity`が素朴に
1932    /// `get_attr(idx,"value")`だけを見ており、`value`属性を持たない
1933    /// `<select>`(選択状態は子`<option selected>`が持つ)や、初期値が
1934    /// `value`属性ではなくテキストノードで表現される`<textarea>`では
1935    /// 常に空文字列として(=未入力として)誤って検証されてしまうバグの
1936    /// 共通修正用。`.value`ゲッター(`dom_props.rs`)と同じフォールバック
1937    /// 規則をここに一本化する。
1938    pub fn effective_form_value(&self, idx: usize) -> String {
1939        let tag = self.nodes.get(idx).map(|n| n.tag.as_str()).unwrap_or("");
1940        if tag == "select" {
1941            self.select_effective_value(idx)
1942        } else if tag == "textarea" {
1943            self.get_attr(idx, "value").unwrap_or_else(|| {
1944                self.nodes
1945                    .get(idx)
1946                    .map(|n| n.initial_text.clone())
1947                    .unwrap_or_default()
1948            })
1949        } else {
1950            self.get_attr(idx, "value").unwrap_or_default()
1951        }
1952    }
1953    /// `select.value = v`(丸ごと未対応だった)。value が一致する最初の option へ
1954    /// 選択を移す(他の option の `selected` は解除。仕様どおり単一選択)。
1955    pub fn set_select_value(&mut self, idx: usize, val: &str) {
1956        let options = self.select_options(idx);
1957        for &o in &options {
1958            if self.option_value(o) == val {
1959                self.set_attr(o, "selected", "selected");
1960            } else {
1961                self.remove_attr(o, "selected");
1962            }
1963        }
1964        self.mark_structural_dirty();
1965    }
1966    /// `select.selectedIndex`(丸ごと未対応だった)。`selected` な option の位置、
1967    /// 無ければ option が1つ以上あれば `0`(既定選択)、無ければ `-1`。
1968    pub fn select_selected_index(&self, idx: usize) -> i32 {
1969        let options = self.select_options(idx);
1970        match options.iter().position(|&o| self.has_attr(o, "selected")) {
1971            Some(p) => p as i32,
1972            None if !options.is_empty() => 0,
1973            None => -1,
1974        }
1975    }
1976    /// `select.selectedIndex = n`。
1977    pub fn set_select_selected_index(&mut self, idx: usize, n: i32) {
1978        let options = self.select_options(idx);
1979        for (i, &o) in options.iter().enumerate() {
1980            if i as i32 == n {
1981                self.set_attr(o, "selected", "selected");
1982            } else {
1983                self.remove_attr(o, "selected");
1984            }
1985        }
1986        self.mark_structural_dirty();
1987    }
1988    /// `select.length = n`(HTML5。`select.options.length`と同じ値を返す
1989    /// ゲッター兼、`n`より末尾のoptionを切り捨てる/不足分を空の`<option>`で
1990    /// 埋めるセッター。丸ごと未対応だった)。仕様どおり、新しい長さが現在より
1991    /// 小さければ末尾から削除、大きければ空の`<option>`を追加する。
1992    pub fn set_select_length(&mut self, idx: usize, n: usize) {
1993        let options = self.select_options(idx);
1994        if n < options.len() {
1995            for &o in &options[n..] {
1996                if let Some(parent) = self.nodes.get(o).and_then(|node| node.parent) {
1997                    self.remove_child(parent, o);
1998                }
1999            }
2000        } else {
2001            for _ in options.len()..n {
2002                let opt = self.create_element("option");
2003                self.append_child(idx, opt);
2004            }
2005        }
2006        self.mark_structural_dirty();
2007    }
2008    /// `option.index`(HTML5。自身が属する`<select>`内での0始まりの位置。
2009    /// `select.selectedIndex`の対になる、`<option>`側のIDL属性だが丸ごと
2010    /// 未対応だった)。`<select>`祖先が無い場合は仕様どおり`0`(要素が
2011    /// 「optionのリスト」に属さない場合の既定値。`<datalist>`配下は
2012    /// 対象外の簡略実装)。
2013    pub fn option_index(&self, idx: usize) -> i32 {
2014        match self.closest_tag(idx, "select") {
2015            Some(sel) => self
2016                .select_options(sel)
2017                .iter()
2018                .position(|&o| o == idx)
2019                .map(|p| p as i32)
2020                .unwrap_or(0),
2021            None => 0,
2022        }
2023    }
2024
2025    pub fn get_attr(&self, idx: usize, name: &str) -> Option<String> {
2026        let n = self.nodes.get(idx)?;
2027        match name {
2028            "id" => Some(n.id.clone()),
2029            "class" => Some(n.classes.join(" ")),
2030            _ => n.attrs.get(name).cloned(),
2031        }
2032    }
2033    pub fn set_attr(&mut self, idx: usize, name: &str, val: &str) {
2034        let old_value = self.get_attr(idx, name);
2035        // 【2026-10-18】値が変わらない設定と、処理系の内部属性(`_paused` 等、`_` 始まり)は
2036        // レイアウトに影響しないので再レイアウトを招かない(MutationObserver への通知は従来どおり)。
2037        // YouTube の操作部は再生中に同じ属性を毎回設定し直し、1 回 2 秒の再レイアウトが続いていた。
2038        let unchanged = old_value.as_deref() == Some(val) && (name != "id" || !val.is_empty());
2039        if unchanged || name.starts_with('_') {
2040            if let Some(n) = self.nodes.get_mut(idx) {
2041                match name {
2042                    "id" => n.id = val.to_string(),
2043                    "class" => {}
2044                    _ => {
2045                        n.attrs.insert(name.to_string(), val.to_string());
2046                    }
2047                }
2048            }
2049            self.notify_attribute(idx, name, old_value.as_deref());
2050            return;
2051        }
2052        if let Some(n) = self.nodes.get_mut(idx) {
2053            match name {
2054                "id" => n.id = val.to_string(),
2055                "class" => {
2056                    n.classes = val.split_whitespace().map(String::from).collect();
2057                    n.classes_dirty = true;
2058                }
2059                _ => {
2060                    n.attrs.insert(name.to_string(), val.to_string());
2061                }
2062            }
2063            self.mark_structural_dirty();
2064        }
2065        self.notify_attribute(idx, name, old_value.as_deref());
2066    }
2067
2068    /// 属性が存在するか(空値の boolean 属性も存在として true。id/class は専用フィールド)。
2069    pub fn has_attr(&self, idx: usize, name: &str) -> bool {
2070        match self.nodes.get(idx) {
2071            Some(n) => match name {
2072                "id" => !n.id.is_empty(),
2073                "class" => !n.classes.is_empty(),
2074                _ => n.attrs.contains_key(name),
2075            },
2076            None => false,
2077        }
2078    }
2079
2080    /// 属性を削除(id/class フィールドも同期)。
2081    pub fn remove_attr(&mut self, idx: usize, name: &str) {
2082        // `set_attr` は属性変化を MutationObserver へ通知していたが、`remove_attr`
2083        // だけこの通知呼び出しが漏れていた非対称ペア(`removeAttribute()` も仕様上
2084        // "attributes" タイプの MutationRecord を発行すべき)。
2085        let old_value = self.get_attr(idx, name);
2086        // 無い属性の削除・内部属性はレイアウトに影響しない(`set_attr` と同じ理由)。
2087        if !self.has_attr(idx, name) || name.starts_with('_') {
2088            if let Some(n) = self.nodes.get_mut(idx) {
2089                n.attrs.remove(name);
2090            }
2091            self.notify_attribute(idx, name, old_value.as_deref());
2092            return;
2093        }
2094        if let Some(n) = self.nodes.get_mut(idx) {
2095            match name {
2096                "id" => n.id = String::new(),
2097                "class" => {
2098                    n.classes.clear();
2099                    n.classes_dirty = true;
2100                }
2101                _ => {
2102                    n.attrs.remove(name);
2103                }
2104            }
2105            self.mark_structural_dirty();
2106        }
2107        self.notify_attribute(idx, name, old_value.as_deref());
2108    }
2109
2110    /// W3C HTML5 §3.2.6.2 / DOMStringMap: `data-*` 属性から camelCase の dataset マップを抽出する。
2111    pub fn get_dataset(&self, idx: usize) -> BTreeMap<String, String> {
2112        match self.nodes.get(idx) {
2113            Some(n) => crate::os_lib::dom_dataset::extract_dataset(&n.attrs),
2114            None => BTreeMap::new(),
2115        }
2116    }
2117
2118    /// `<input type="radio">` の排他選択(同じ `name` を持つグループ内で1つだけが
2119    /// `checked` になる、というラジオボタンの最も基本的な仕様上の挙動)。`idx` を
2120    /// checked にする際、同じ `name` を持つ他の radio 全ての `checked` 属性を外す。
2121    /// フォーム所有者(`<form>` 単位のスコープ)は区別せず、同じ `name` を持つ
2122    /// document 内の全 radio を対象にする簡略実装。
2123    /// `<details name="...">`(HTML5。同じ`name`を持つ`<details>`同士は
2124    /// ラジオボタングループのように排他的に開閉する——1つを開くと同じ
2125    /// グループの他の`<details>`は自動的に閉じる——仕様が丸ごと未対応
2126    /// だった。`uncheck_radio_group_siblings`と同じ「`name`属性が一致する
2127    /// 兄弟(ドキュメント全体、DOM上の兄弟関係は問わない)を探して状態を
2128    /// 揃える」パターンをそのまま適用する。2026-07-18 発見・実装)。
2129    pub fn close_details_group_siblings(&mut self, idx: usize) -> alloc::vec::Vec<usize> {
2130        let name = match self.nodes.get(idx).and_then(|n| n.attrs.get("name")) {
2131            Some(n) if !n.is_empty() => n.clone(),
2132            _ => return alloc::vec::Vec::new(),
2133        };
2134        let siblings: alloc::vec::Vec<usize> = self
2135            .nodes
2136            .iter()
2137            .enumerate()
2138            .filter(|(i, n)| {
2139                *i != idx
2140                    && n.tag == "details"
2141                    && n.attrs.get("name").map(|s| s.as_str()) == Some(name.as_str())
2142                    && n.attrs.contains_key("open")
2143            })
2144            .map(|(i, _)| i)
2145            .collect();
2146        for &i in &siblings {
2147            self.remove_attr(i, "open");
2148        }
2149        siblings
2150    }
2151
2152    pub fn uncheck_radio_group_siblings(&mut self, idx: usize) {
2153        let name = match self.nodes.get(idx).and_then(|n| n.attrs.get("name")) {
2154            Some(n) if !n.is_empty() => n.clone(),
2155            _ => return,
2156        };
2157        let siblings: Vec<usize> = self
2158            .nodes
2159            .iter()
2160            .enumerate()
2161            .filter(|(i, n)| {
2162                *i != idx
2163                    && n.tag == "input"
2164                    && n.attrs.get("type").map(|t| t.as_str()) == Some("radio")
2165                    && n.attrs.get("name").map(|s| s.as_str()) == Some(name.as_str())
2166            })
2167            .map(|(i, _)| i)
2168            .collect();
2169        for i in siblings {
2170            self.remove_attr(i, "checked");
2171            self.set_attr(i, "_live_checked", "false");
2172        }
2173    }
2174
2175    /// 単一ノードがセレクタ(コンパウンド + 結合子、カンマ区切りは OR)にマッチするか。
2176    /// `matches()`/`closest()`/`querySelector(All)` 共通の実装本体。以前は `#id`/`.class`/
2177    /// `tag`/`*` の単純セレクタしか認識せず、`div.active`(コンパウンド)や `[data-x]`
2178    /// (属性セレクタ)、`.a > .b`(結合子)を渡すと文字列全体を tag 名として比較する
2179    /// 分岐に落ち、常に false になる静かな破壊バグだった(`:not()`/`:nth-child()`/
2180    /// `:is()`/`:where()` 等の疑似クラスは引き続き非対応。改修規模を絞るための
2181    /// 意図的な割り切り)。
2182    pub fn node_matches(&self, idx: usize, selector: &str) -> bool {
2183        self.node_matches_scoped(idx, selector, None)
2184    }
2185
2186    /// `node_matches` の `:scope` 対応版。`scope_root` に `element.
2187    /// querySelectorAll`/`matches`/`closest` 等の起点ノードを渡すと、セレクタ中の
2188    /// `:scope` 疑似クラス(Selectors Level 4。丸ごと未対応だった)がそのノードに
2189    /// のみ一致するようになる。`scope_root` が `None`(`document.querySelectorAll`
2190    /// 等、起点を持たない文脈)の場合は `:scope` は常に不一致として扱う簡略実装。
2191    pub fn node_matches_scoped(&self, idx: usize, selector: &str, scope_root: Option<usize>) -> bool {
2192        if self.nodes.get(idx).map(|n| n.is_text).unwrap_or(true) {
2193            return false;
2194        }
2195        for part in split_top_level_commas(selector) {
2196            let steps = build_steps(&tokenize_selector_group(part.trim()));
2197            if steps.is_empty() {
2198                continue;
2199            }
2200            let last = steps.len() - 1;
2201            if self.compound_matches(idx, &steps[last].compound, scope_root)
2202                && self.match_selector_chain(idx, &steps, last, scope_root)
2203            {
2204                return true;
2205            }
2206        }
2207        false
2208    }
2209
2210    /// `idx` がコンパウンドセレクタ(タグ/`#id`/`.class`/`[attr...]`)に一致するか。
2211    fn compound_matches(&self, idx: usize, c: &CompoundSel, scope_root: Option<usize>) -> bool {
2212        if c.is_scope && Some(idx) != scope_root {
2213            return false;
2214        }
2215        let n = match self.nodes.get(idx) {
2216            Some(n) if !n.is_text => n,
2217            _ => return false,
2218        };
2219        if let Some(tag) = &c.tag {
2220            if !n.tag.eq_ignore_ascii_case(tag) {
2221                return false;
2222            }
2223        }
2224        if let Some(id) = &c.id {
2225            if &n.id != id {
2226                return false;
2227            }
2228        }
2229        if !c.classes.iter().all(|cls| n.classes.iter().any(|nc| nc == cls)) {
2230            return false;
2231        }
2232        for attr in &c.attrs {
2233            let val = match n.attrs.get(&attr.name) {
2234                Some(v) => v,
2235                None => return false,
2236            };
2237            let ok = match attr.op {
2238                AttrOp::Exists => true,
2239                AttrOp::Eq => val == &attr.value,
2240                AttrOp::Prefix => val.starts_with(attr.value.as_str()),
2241                AttrOp::Suffix => val.ends_with(attr.value.as_str()),
2242                AttrOp::Contains => !attr.value.is_empty() && val.contains(attr.value.as_str()),
2243                AttrOp::Word => val.split_whitespace().any(|w| w == attr.value),
2244            };
2245            if !ok {
2246                return false;
2247            }
2248        }
2249        for not_sel in &c.not {
2250            if self.compound_matches(idx, not_sel, scope_root) {
2251                return false;
2252            }
2253        }
2254        if !c.is_list.is_empty()
2255            && !c.is_list.iter().any(|s| self.compound_matches(idx, s, scope_root))
2256        {
2257            return false;
2258        }
2259        if !c.pos.is_empty() {
2260            let (position, count) = self.element_sibling_position(idx);
2261            for p in &c.pos {
2262                let ok = match p {
2263                    PosPseudo::First => position == 1,
2264                    PosPseudo::Last => position == count,
2265                    PosPseudo::Only => position == 1 && count == 1,
2266                    PosPseudo::Nth(f) => nth_formula_matches(*f, position),
2267                    PosPseudo::NthLast(f) => nth_formula_matches(*f, count - position + 1),
2268                };
2269                if !ok {
2270                    return false;
2271                }
2272            }
2273        }
2274        if !c.pos_of_type.is_empty() {
2275            let (position, count) = self.element_type_sibling_position(idx);
2276            for p in &c.pos_of_type {
2277                let ok = match p {
2278                    PosPseudo::First => position == 1,
2279                    PosPseudo::Last => position == count,
2280                    PosPseudo::Only => position == 1 && count == 1,
2281                    PosPseudo::Nth(f) => nth_formula_matches(*f, position),
2282                    PosPseudo::NthLast(f) => nth_formula_matches(*f, count - position + 1),
2283                };
2284                if !ok {
2285                    return false;
2286                }
2287            }
2288        }
2289        if c.is_root {
2290            // :root は通常 <html> 要素、または親を持たないツリールート要素
2291            let is_root_elem = n.tag == "html" || n.parent.is_none();
2292            if !is_root_elem {
2293                return false;
2294            }
2295        }
2296        if c.is_empty {
2297            // :empty は子要素(Element)および非空テキストノードを持たない要素
2298            let has_content = n.children.iter().any(|&cid| {
2299                if let Some(child) = self.nodes.get(cid) {
2300                    !child.is_text || !child.text_override.as_deref().unwrap_or(&child.initial_text).is_empty()
2301                } else {
2302                    false
2303                }
2304            });
2305            if has_content {
2306                return false;
2307            }
2308        }
2309        if c.is_checked {
2310            let checked = if let Some(lc) = n.attrs.get("_live_checked") {
2311                lc == "true"
2312            } else {
2313                n.attrs.contains_key("checked")
2314            };
2315            if !checked {
2316                return false;
2317            }
2318        }
2319        if c.is_disabled {
2320            if !n.attrs.contains_key("disabled") {
2321                return false;
2322            }
2323        }
2324        if c.is_enabled {
2325            if n.attrs.contains_key("disabled") {
2326                return false;
2327            }
2328        }
2329        true
2330    }
2331
2332    /// `idx` の「要素兄弟のみを数えた」1始まりの位置と、要素兄弟の総数を返す
2333    /// (テキストノードは数えない。CSS の `:nth-child()` 等と同じ規則)。
2334    fn element_sibling_position(&self, idx: usize) -> (usize, usize) {
2335        let parent = match self.nodes.get(idx).and_then(|n| n.parent) {
2336            Some(p) => p,
2337            None => return (1, 1),
2338        };
2339        let siblings = match self.nodes.get(parent) {
2340            Some(n) => &n.children,
2341            None => return (1, 1),
2342        };
2343        let elements: Vec<usize> = siblings
2344            .iter()
2345            .filter(|&&c| !self.nodes.get(c).map(|n| n.is_text).unwrap_or(true))
2346            .copied()
2347            .collect();
2348        let count = elements.len();
2349        let position = elements.iter().position(|&c| c == idx).map(|p| p + 1).unwrap_or(0);
2350        (position, count)
2351    }
2352
2353    /// `element_sibling_position` と同じだが、同じタグ名の兄弟のみを数える
2354    /// (`:first-of-type`/`:last-of-type`/`:nth-of-type()` 用)。
2355    fn element_type_sibling_position(&self, idx: usize) -> (usize, usize) {
2356        let tag = match self.nodes.get(idx) {
2357            Some(n) => n.tag.clone(),
2358            None => return (1, 1),
2359        };
2360        let parent = match self.nodes.get(idx).and_then(|n| n.parent) {
2361            Some(p) => p,
2362            None => return (1, 1),
2363        };
2364        let siblings = match self.nodes.get(parent) {
2365            Some(n) => &n.children,
2366            None => return (1, 1),
2367        };
2368        let same_type: Vec<usize> = siblings
2369            .iter()
2370            .filter(|&&c| self.nodes.get(c).map(|n| !n.is_text && n.tag == tag).unwrap_or(false))
2371            .copied()
2372            .collect();
2373        let count = same_type.len();
2374        let position = same_type.iter().position(|&c| c == idx).map(|p| p + 1).unwrap_or(0);
2375        (position, count)
2376    }
2377
2378    /// `idx` は `steps[step_i]` に既に一致した状態で呼ばれる。`steps[0..step_i]` を
2379    /// 結合子に従って祖先/兄弟方向へ再帰的に検証する。
2380    fn match_selector_chain(
2381        &self,
2382        idx: usize,
2383        steps: &[SelStep],
2384        step_i: usize,
2385        scope_root: Option<usize>,
2386    ) -> bool {
2387        if step_i == 0 {
2388            return true;
2389        }
2390        let combinator = match steps[step_i].combinator {
2391            Some(c) => c,
2392            None => return true,
2393        };
2394        match combinator {
2395            Combinator::Descendant => {
2396                let mut cur = self.nodes.get(idx).and_then(|n| n.parent);
2397                let mut guard = 0;
2398                while let Some(p) = cur {
2399                    if self.compound_matches(p, &steps[step_i - 1].compound, scope_root)
2400                        && self.match_selector_chain(p, steps, step_i - 1, scope_root)
2401                    {
2402                        return true;
2403                    }
2404                    cur = self.nodes.get(p).and_then(|n| n.parent);
2405                    guard += 1;
2406                    if guard > self.nodes.len() {
2407                        break;
2408                    }
2409                }
2410                false
2411            }
2412            Combinator::Child => match self.nodes.get(idx).and_then(|n| n.parent) {
2413                Some(p) => {
2414                    self.compound_matches(p, &steps[step_i - 1].compound, scope_root)
2415                        && self.match_selector_chain(p, steps, step_i - 1, scope_root)
2416                }
2417                None => false,
2418            },
2419            Combinator::Adjacent => match self.prev_element_sibling(idx) {
2420                Some(p) => {
2421                    self.compound_matches(p, &steps[step_i - 1].compound, scope_root)
2422                        && self.match_selector_chain(p, steps, step_i - 1, scope_root)
2423                }
2424                None => false,
2425            },
2426            Combinator::General => {
2427                for p in self.preceding_element_siblings(idx) {
2428                    if self.compound_matches(p, &steps[step_i - 1].compound, scope_root)
2429                        && self.match_selector_chain(p, steps, step_i - 1, scope_root)
2430                    {
2431                        return true;
2432                    }
2433                }
2434                false
2435            }
2436        }
2437    }
2438
2439    /// `idx` の直前の要素兄弟(テキストノードは飛ばす)。
2440    fn prev_element_sibling(&self, idx: usize) -> Option<usize> {
2441        let parent = self.nodes.get(idx)?.parent?;
2442        let siblings = &self.nodes.get(parent)?.children;
2443        let pos = siblings.iter().position(|&c| c == idx)?;
2444        siblings[..pos].iter().rev().find(|&&c| !self.nodes.get(c).map(|n| n.is_text).unwrap_or(true)).copied()
2445    }
2446
2447    /// `idx` より前にある全ての要素兄弟(テキストノードは飛ばす、ドキュメント順)。
2448    fn preceding_element_siblings(&self, idx: usize) -> Vec<usize> {
2449        let parent = match self.nodes.get(idx).and_then(|n| n.parent) {
2450            Some(p) => p,
2451            None => return Vec::new(),
2452        };
2453        let siblings = match self.nodes.get(parent) {
2454            Some(n) => &n.children,
2455            None => return Vec::new(),
2456        };
2457        let pos = match siblings.iter().position(|&c| c == idx) {
2458            Some(p) => p,
2459            None => return Vec::new(),
2460        };
2461        siblings[..pos]
2462            .iter()
2463            .filter(|&&c| !self.nodes.get(c).map(|n| n.is_text).unwrap_or(true))
2464            .copied()
2465            .collect()
2466    }
2467
2468    /// anc が idx の祖先か(idx 自身は含まない)。
2469    pub fn is_ancestor_of(&self, anc: usize, idx: usize) -> bool {
2470        let mut cur = idx;
2471        let mut guard = 0;
2472        while let Some(p) = self.nodes.get(cur).and_then(|n| n.parent) {
2473            if p == anc {
2474                return true;
2475            }
2476            cur = p;
2477            guard += 1;
2478            if guard > self.nodes.len() {
2479                break;
2480            }
2481        }
2482        false
2483    }
2484
2485    /// `Node.compareDocumentPosition(other)`(DOM標準)が丸ごと未対応だった。
2486    /// `this_idx`(reference node)から見た `other_idx` の位置関係をビットマスクで返す
2487    /// (`DOCUMENT_POSITION_*` 定数は呼び出し側 `builtins.rs` で定義)。祖先/子孫関係は
2488    /// 既存の `is_ancestor_of` で判定し、それ以外(兄弟や別分岐)は両者の祖先鎖を根まで
2489    /// 辿って最も近い共通祖先(LCA)の子リスト内での順序を比較する。根が異なる
2490    /// (disconnected)場合、仕様上 PRECEDING/FOLLOWING の割当ては実装依存でよいため
2491    /// ノードindexの大小で決定的に決める。
2492    pub fn document_position(&self, this_idx: usize, other_idx: usize) -> u32 {
2493        const DISCONNECTED: u32 = 0x01;
2494        const PRECEDING: u32 = 0x02;
2495        const FOLLOWING: u32 = 0x04;
2496        const CONTAINS: u32 = 0x08;
2497        const CONTAINED_BY: u32 = 0x10;
2498        const IMPLEMENTATION_SPECIFIC: u32 = 0x20;
2499
2500        if this_idx == other_idx {
2501            return 0;
2502        }
2503        if self.is_ancestor_of(other_idx, this_idx) {
2504            // other は this の祖先(other が this を含む)。
2505            return CONTAINS | PRECEDING;
2506        }
2507        if self.is_ancestor_of(this_idx, other_idx) {
2508            // other は this の子孫(this が other を含む)。
2509            return CONTAINED_BY | FOLLOWING;
2510        }
2511        let chain_of = |start: usize| -> Vec<usize> {
2512            let mut chain = alloc::vec![start];
2513            let mut cur = start;
2514            let mut guard = 0;
2515            while let Some(p) = self.nodes.get(cur).and_then(|n| n.parent) {
2516                chain.push(p);
2517                cur = p;
2518                guard += 1;
2519                if guard > self.nodes.len() {
2520                    break;
2521                }
2522            }
2523            chain.reverse(); // ルート→自身の順に。
2524            chain
2525        };
2526        let chain_a = chain_of(this_idx);
2527        let chain_b = chain_of(other_idx);
2528        if chain_a.first() != chain_b.first() {
2529            // 根が異なる = disconnected。順序は実装依存でよいので index で決定的に決める。
2530            let order = if this_idx < other_idx { FOLLOWING } else { PRECEDING };
2531            return DISCONNECTED | IMPLEMENTATION_SPECIFIC | order;
2532        }
2533        let mut i = 0;
2534        while i < chain_a.len() && i < chain_b.len() && chain_a[i] == chain_b[i] {
2535            i += 1;
2536        }
2537        if i == 0 || i >= chain_a.len() || i >= chain_b.len() {
2538            // 分岐点が見つからない(理論上到達しないはずだが防御的に)。
2539            return DISCONNECTED | IMPLEMENTATION_SPECIFIC;
2540        }
2541        let lca = chain_a[i - 1];
2542        let child_a = chain_a[i];
2543        let child_b = chain_b[i];
2544        let siblings = match self.nodes.get(lca) {
2545            Some(n) => &n.children,
2546            None => return DISCONNECTED | IMPLEMENTATION_SPECIFIC,
2547        };
2548        let pos_a = siblings.iter().position(|&c| c == child_a);
2549        let pos_b = siblings.iter().position(|&c| c == child_b);
2550        match (pos_a, pos_b) {
2551            (Some(pa), Some(pb)) if pa < pb => FOLLOWING,
2552            (Some(pa), Some(pb)) if pa > pb => PRECEDING,
2553            _ => DISCONNECTED | IMPLEMENTATION_SPECIFIC,
2554        }
2555    }
2556
2557    /// idx 自身を含め祖先方向へ、指定タグの最初の要素を返す(`<form>` 解決等)。
2558    /// `element.offsetParent`(HTML5。丸ごと未対応だった)。祖先を遡り、算出
2559    /// `position` が `static` 以外の最初の要素を返す。無ければ `<body>`
2560    /// (見つからなければ `None` = 仕様どおり `null`)。
2561    pub fn offset_parent(&self, idx: usize) -> Option<usize> {
2562        let mut cur = self.nodes.get(idx).and_then(|n| n.parent);
2563        let mut body_idx = None;
2564        let mut guard = 0;
2565        while let Some(c) = cur {
2566            if self.nodes.get(c).map(|n| !n.is_text && n.tag == "body").unwrap_or(false) {
2567                body_idx = Some(c);
2568            }
2569            let pos = self.get_computed(c, "position");
2570            if pos == "absolute" || pos == "relative" || pos == "fixed" || pos == "sticky" {
2571                return Some(c);
2572            }
2573            cur = self.nodes.get(c).and_then(|n| n.parent);
2574            guard += 1;
2575            if guard > self.nodes.len() {
2576                break;
2577            }
2578        }
2579        body_idx
2580    }
2581
2582    pub fn closest_tag(&self, idx: usize, tag: &str) -> Option<usize> {
2583        let mut cur = Some(idx);
2584        let mut guard = 0;
2585        while let Some(c) = cur {
2586            if self
2587                .nodes
2588                .get(c)
2589                .map(|n| !n.is_text && n.tag == tag)
2590                .unwrap_or(false)
2591            {
2592                return Some(c);
2593            }
2594            cur = self.nodes.get(c).and_then(|n| n.parent);
2595            guard += 1;
2596            if guard > self.nodes.len() {
2597                break;
2598            }
2599        }
2600        None
2601    }
2602
2603    /// フォーム系コントロールが実際に属する`<form>`を解決する。HTML5仕様では
2604    /// `form="formId"`属性(`<form>`の子孫でなくても、その`id`で明示的に関連付ける
2605    /// 「form owner」機構)が祖先探索より優先される。以前は`closest_tag(idx,
2606    /// "form")`(祖先探索のみ)しか実装されておらず、`<form id="f1">`の外に置かれた
2607    /// `<input form="f1">`(フォーム外配置は`<form>`の視覚レイアウトを変えずに
2608    /// コントロールだけ離れた場所に置きたい場合の定番パターン)が
2609    /// `element.form`で`null`になり、`<form>.checkValidity()`/送信データ収集からも
2610    /// 漏れる、という兄弟ギャップだった。
2611    pub fn associated_form(&self, idx: usize) -> Option<usize> {
2612        if let Some(form_id) = self.get_attr(idx, "form") {
2613            if let Some(fi) = self.get_element_by_id(&form_id) {
2614                if self.nodes.get(fi).map(|n| n.tag == "form").unwrap_or(false) {
2615                    return Some(fi);
2616                }
2617            }
2618        }
2619        self.closest_tag(idx, "form")
2620    }
2621
2622    /// `<form id="f1">`に属する`tags`に含まれるタグの全フォームコントロールを
2623    /// 収集する。子孫に加えて、`form="f1"`属性でフォーム外から明示的に関連付けられた
2624    /// コントロール(`associated_form`と対になる「form owner」機構)も含む。
2625    /// `checkValidity()`/`FormData`収集/`form.elements`の各`<form>`祖先ループが
2626    /// フォーム外配置のコントロールを見落としていた兄弟ギャップの修正に使う
2627    /// (呼び出し側ごとに対象タグが異なる: `checkValidity`/`FormData`は
2628    /// `input`/`select`/`textarea`のみ、`form.elements`はそれに`button`も含む)。
2629    pub fn form_associated_controls(&self, form_idx: usize, tags: &[&str]) -> Vec<usize> {
2630        let mut out = self.descendants_by_tags(form_idx, tags);
2631        if let Some(form_id) = self.get_attr(form_idx, "id") {
2632            if !form_id.is_empty() {
2633                for (i, n) in self.nodes.iter().enumerate() {
2634                    if n.is_text || out.contains(&i) {
2635                        continue;
2636                    }
2637                    if !tags.contains(&n.tag.as_str()) {
2638                        continue;
2639                    }
2640                    if n.attrs.get("form").map(|v| v == &form_id).unwrap_or(false) {
2641                        out.push(i);
2642                    }
2643                }
2644            }
2645        }
2646        out
2647    }
2648
2649    /// ドキュメント順で最初の指定タグ要素。
2650    pub fn first_by_tag(&self, tag: &str) -> Option<usize> {
2651        (0..self.nodes.len())
2652            .find(|&i| !self.nodes[i].is_text && self.nodes[i].tag == tag && self.is_attached(i))
2653    }
2654
2655    /// 子のうち要素(非テキスト)ノードの index 一覧。
2656    pub fn element_children(&self, idx: usize) -> Vec<usize> {
2657        self.nodes
2658            .get(idx)
2659            .map(|n| {
2660                n.children
2661                    .iter()
2662                    .copied()
2663                    .filter(|c| self.nodes.get(*c).map(|cn| !cn.is_text).unwrap_or(false))
2664                    .collect()
2665            })
2666            .unwrap_or_default()
2667    }
2668
2669    /// `root` 以下(自身は含まない)の子孫のうち、tag が `tags` のいずれかに一致するものを
2670    /// 文書順で収集する。フォーム配下の入力欄列挙(バリデーション)に使用。
2671    /// `element.getElementsByTagName(tag)` 用の子孫走査。`tag` が `"*"` なら全要素。
2672    ///
2673    /// # なぜ文書全体の検索を使わないか
2674    ///
2675    /// `query_all` は `is_attached` で**文書に繋がっていないノードを除外**する。
2676    /// 仕様では `getElementsByTagName` は**文書への接続に関係なく**自分の子孫を
2677    /// 返すので、切り離した要素(`createElement` した直後など)では結果が
2678    /// 空になってしまう。
2679    ///
2680    /// jQuery 1.8.2 の機能検出はまさにこれをやる:
2681    /// 切り離した `<div>` に `innerHTML` を入れ、`getElementsByTagName("a")[0]`
2682    /// の `style` を触る。空が返ると `undefined` の `style` を読んで落ち、
2683    /// **jQuery 全体が初期化できなくなっていた**。
2684    ///
2685    /// なお `is_attached` は根(索引 0)まで遡る実装のため、**空の DOM では
2686    /// 最初に作った要素自身が索引 0 になり偶然「接続済み」になる**。
2687    /// 単体試験を空のランタイムで書くとこの不具合を再現できない
2688    /// (実際に 5 回取り逃がした)。試験は実ページの DOM で確かめること。
2689    pub fn descendants_by_tag_name(&self, root: usize, tag: &str) -> Vec<usize> {
2690        let want_all = tag == "*";
2691        let lower = tag.to_ascii_lowercase();
2692        let mut out = Vec::new();
2693        let mut stack: Vec<usize> = self.element_children(root);
2694        let mut i = 0;
2695        while i < stack.len() {
2696            let idx = stack[i];
2697            i += 1;
2698            if let Some(n) = self.nodes.get(idx) {
2699                if !n.is_text && (want_all || n.tag.eq_ignore_ascii_case(&lower)) {
2700                    out.push(idx);
2701                }
2702                for &c in &n.children {
2703                    if self.nodes.get(c).map(|cn| !cn.is_text).unwrap_or(false) {
2704                        stack.push(c);
2705                    }
2706                }
2707            }
2708        }
2709        out
2710    }
2711
2712    pub fn descendants_by_tags(&self, root: usize, tags: &[&str]) -> Vec<usize> {
2713        let mut out = Vec::new();
2714        let mut stack: Vec<usize> = self.element_children(root);
2715        // element_children は逆順処理だと順序が崩れるため、明示的に前順走査する。
2716        let mut i = 0;
2717        while i < stack.len() {
2718            let idx = stack[i];
2719            i += 1;
2720            if let Some(n) = self.nodes.get(idx) {
2721                if !n.is_text && tags.iter().any(|t| n.tag == *t) {
2722                    out.push(idx);
2723                }
2724                // 子をその場に展開(文書順を保つため idx の直後ではなく末尾へ)。
2725                for &c in &n.children {
2726                    if self.nodes.get(c).map(|cn| !cn.is_text).unwrap_or(false) {
2727                        stack.push(c);
2728                    }
2729                }
2730            }
2731        }
2732        out
2733    }
2734
2735    /// `<fieldset disabled>`の子孫コントロールは、自身に`disabled`属性が無くても
2736    /// 暗黙に無効化される(HTML5仕様の「fieldset disabling」)。以前は要素自身の
2737    /// `disabled`属性の有無しか見ておらず、`<fieldset disabled>`配下の`<input>`は
2738    /// `.disabled`が常に`false`、制約バリデーションも無効化されずに常に対象内の
2739    /// ままになっていた。祖先を`parent`で遡り`<fieldset disabled>`が1つでも
2740    /// あれば無効とみなす(`<fieldset>`の最初の子`<legend>`直下のコントロールは
2741    /// 無効化対象外という仕様上の例外は、この処理系のフォーム系テストでは実用上
2742    /// ほぼ現れないため今回は簡略化して見送る)。
2743    pub fn is_disabled(&self, idx: usize) -> bool {
2744        let n = match self.nodes.get(idx) {
2745            Some(n) => n,
2746            None => return false,
2747        };
2748        if n.attrs.contains_key("disabled") {
2749            return true;
2750        }
2751        let mut cur = n.parent;
2752        while let Some(i) = cur {
2753            let anc = match self.nodes.get(i) {
2754                Some(a) => a,
2755                None => break,
2756            };
2757            if anc.tag == "fieldset" && anc.attrs.contains_key("disabled") {
2758                return true;
2759            }
2760            cur = anc.parent;
2761        }
2762        false
2763    }
2764
2765    /// `ValidityState` の各フラグを個別に計算する(`validate_field` は最初に見つかった
2766    /// 違反のメッセージ1件しか返さないため、`.validity.patternMismatch` のような特定
2767    /// 制約の狙い撃ちには使えなかった)。`validate_field` と同じ規則を、短絡させずに
2768    /// 全項目評価する。
2769    pub fn validity_flags(&self, idx: usize, value: &str) -> ValidityFlags {
2770        let mut f = ValidityFlags::default();
2771        let n = match self.nodes.get(idx) {
2772            Some(n) => n,
2773            None => return f,
2774        };
2775        // 仕様上「制約検証の対象候補」になれるのは`button`/`input`/`select`/
2776        // `textarea`の4タグのみ(`output`は明示的に対象外、`fieldset`/
2777        // `object`等の非フォームコントロールも対象外)。以前はタグを一切
2778        // チェックしていなかったため、`<output>`/`<div>`のような要素に
2779        // 誤って`required`属性を付けると`valueMissing`が立ってしまう
2780        // バグだった(`willValidate`側で発見・修正した同根のバグ。
2781        // 2026-07-18 発見・修正)。
2782        if n.is_text
2783            || self.is_disabled(idx)
2784            || !matches!(n.tag.as_str(), "button" | "input" | "select" | "textarea")
2785        {
2786            return f;
2787        }
2788        let input_type = n
2789            .attrs
2790            .get("type")
2791            .map(|s| s.trim().to_lowercase())
2792            .unwrap_or_default();
2793        if matches!(
2794            input_type.as_str(),
2795            "submit" | "button" | "reset" | "hidden" | "image"
2796        ) {
2797            return f;
2798        }
2799        f.custom_error = n
2800            .attrs
2801            .get("_custom_validity")
2802            .map(|s| !s.is_empty())
2803            .unwrap_or(false);
2804        let trimmed = value.trim();
2805        // checkbox/radio は「値」でなく「チェック状態」で required を判定する
2806        // (`validate_field` と同じ規則。詳細はそちらのコメント参照)。
2807        f.value_missing = n.attrs.contains_key("required") && !self.required_satisfied(n, &input_type, trimmed);
2808        if matches!(input_type.as_str(), "checkbox" | "radio") || value.is_empty() {
2809            return f; // checkbox/radio、または空値は required 以外のチェックを適用しない(仕様どおり)。
2810        }
2811        let char_len = value.chars().count();
2812        if let Some(ml) = n.attrs.get("minlength").and_then(|v| v.trim().parse::<usize>().ok()) {
2813            f.too_short = char_len < ml;
2814        }
2815        if let Some(ml) = n.attrs.get("maxlength").and_then(|v| v.trim().parse::<usize>().ok()) {
2816            f.too_long = char_len > ml;
2817        }
2818        match input_type.as_str() {
2819            "email" => f.type_mismatch = !Self::is_valid_email_value(n, value),
2820            "url" => {
2821                f.type_mismatch = !(value.starts_with("http://") || value.starts_with("https://"))
2822            }
2823            // `type="date"`/`type="month"`の形式チェックが丸ごと未対応だった
2824            // (`input.valueAsDate`は既に`input_value_as_date_ms()`で`YYYY-MM-DD`/
2825            // `YYYY-MM`の妥当性を判定していたのに、`validity.typeMismatch`は
2826            // `email`/`url`だけしか見ておらず、不正な日付文字列を代入しても
2827            // 常に`typeMismatch: false`だった)。新規パーサは書かず既存関数を
2828            // そのまま再利用する。
2829            // `min`/`max`(日付の範囲制約。HTML5仕様上`type="date"`/`type="month"`も
2830            // `type="number"`同様に`min`/`max`属性で範囲を制約できるが、
2831            // `range_overflow`/`range_underflow`は元々`number`/`range`型でしか
2832            // 計算されておらず、日付型には丸ごと存在しなかった)は
2833            // `input_value_as_date_ms()`でms値に変換して比較するだけで、
2834            // 文字列比較用の新規ロジックは不要(ISO 8601形式は辞書式順序と
2835            // 時系列順序が一致するが、あえて数値のms比較で仕様どおりに扱う)。
2836            // `type="datetime-local"`も`input_value_as_date_ms()`が既に
2837            // `YYYY-MM-DDTHH:MM`形式をサポートしているため、`date`/`month`と
2838            // 同じ扱いに含めるだけで対応できる(`week`/`time`はこの関数自体が
2839            // 対応していないため対象外のまま)。
2840            "date" | "month" | "datetime-local" => match super::builtins::input_value_as_date_ms(&input_type, value) {
2841                Some(ms) => {
2842                    if let Some(min) = n
2843                        .attrs
2844                        .get("min")
2845                        .and_then(|v| super::builtins::input_value_as_date_ms(&input_type, v))
2846                    {
2847                        f.range_underflow = ms < min;
2848                    }
2849                    if let Some(max) = n
2850                        .attrs
2851                        .get("max")
2852                        .and_then(|v| super::builtins::input_value_as_date_ms(&input_type, v))
2853                    {
2854                        f.range_overflow = ms > max;
2855                    }
2856                }
2857                None => f.type_mismatch = true,
2858            },
2859            // `type="range"`もmin/max/stepの数値制約検証は`type="number"`と全く同じ
2860            // (`input.stepUp()`/`.stepDown()`/`.valueAsNumber`は既に両方を同列に
2861            // 扱っていたのに、`validity.rangeOverflow`等の制約検証だけ`number`のみに
2862            // 限定されており、range型ではJSから範囲外の値を代入しても常に
2863            // `range_overflow`/`range_underflow`/`step_mismatch`が`false`固定になる
2864            // 兄弟ギャップだった)。
2865            "number" | "range" => match value.trim().parse::<f64>() {
2866                Ok(num) => {
2867                    if let Some(min) = n.attrs.get("min").and_then(|v| v.trim().parse::<f64>().ok()) {
2868                        f.range_underflow = num < min;
2869                    }
2870                    if let Some(max) = n.attrs.get("max").and_then(|v| v.trim().parse::<f64>().ok()) {
2871                        f.range_overflow = num > max;
2872                    }
2873                    let step_attr = n.attrs.get("step").map(|s| s.trim());
2874                    if step_attr != Some("any") {
2875                        let step = step_attr
2876                            .and_then(|s| s.parse::<f64>().ok())
2877                            .filter(|s| *s > 0.0)
2878                            .unwrap_or(1.0);
2879                        let base = n
2880                            .attrs
2881                            .get("min")
2882                            .and_then(|v| v.trim().parse::<f64>().ok())
2883                            .unwrap_or(0.0);
2884                        let steps = (num - base) / step;
2885                        f.step_mismatch = libm::fabs(steps - libm::round(steps)) > 1e-9;
2886                    }
2887                }
2888                Err(_) => f.bad_input = true,
2889            },
2890            _ => {}
2891        }
2892        if let Some(pat) = n.attrs.get("pattern") {
2893            if !pat.is_empty() {
2894                f.pattern_mismatch = !Self::pattern_matches(pat, value);
2895            }
2896        }
2897        f
2898    }
2899
2900    /// `required`制約が充足されているかを判定する。`checkbox`は自身の`checked`、
2901    /// `radio`は同じ`name`グループ内のいずれかが`checked`であれば充足(HTML5
2902    /// 仕様どおり)。それ以外の`input`/`select`/`textarea`は`value`(trim後)が
2903    /// 空でなければ充足。`validate_field`/`validity_flags`の両方から共通で使う。
2904    fn required_satisfied(&self, n: &DomNode, input_type: &str, trimmed_value: &str) -> bool {
2905        let is_checked = |node: &DomNode| {
2906            if let Some(lc) = node.attrs.get("_live_checked") {
2907                lc == "true"
2908            } else {
2909                node.attrs.contains_key("checked")
2910            }
2911        };
2912        match input_type {
2913            "checkbox" => is_checked(n),
2914            "radio" => {
2915                let name = n.attrs.get("name").cloned().unwrap_or_default();
2916                if name.is_empty() {
2917                    is_checked(n)
2918                } else {
2919                    self.nodes.iter().any(|other| {
2920                        other.tag == "input"
2921                            && other
2922                                .attrs
2923                                .get("type")
2924                                .map(|t| t.eq_ignore_ascii_case("radio"))
2925                                .unwrap_or(false)
2926                            && other.attrs.get("name").map(|nm| nm == &name).unwrap_or(false)
2927                            && is_checked(other)
2928                    })
2929                }
2930            }
2931            _ => !trimmed_value.is_empty(),
2932        }
2933    }
2934
2935    /// HTML5 制約バリデーション。フィールド要素 `idx` と現在値 `value` を検査し、
2936    /// 違反があればユーザー向けメッセージを返す(None = 妥当)。
2937    /// 対応: setCustomValidity(_custom_validity) / required / minlength / maxlength /
2938    /// pattern / type=email / type=url / type=number,range(min,max,step)。
2939    /// disabled/readonly や type=submit/button/hidden は検証対象外。
2940    pub fn validate_field(&self, idx: usize, value: &str) -> Option<String> {
2941        let n = self.nodes.get(idx)?;
2942        if n.is_text {
2943            return None;
2944        }
2945        // 無効・隠し要素はスキップ(`<fieldset disabled>`祖先による暗黙の無効化も
2946        // 含む。詳細は`is_disabled`のコメント参照)。
2947        if self.is_disabled(idx) {
2948            return None;
2949        }
2950        // `validity_flags`で発見・修正したのと同根のバグ:制約検証の対象候補に
2951        // なれるのは`button`/`input`/`select`/`textarea`の4タグのみだが、タグを
2952        // 一切チェックしていなかった。`form.checkValidity()`側は`form_associated_
2953        // controls`で事前に`input`/`textarea`/`select`へ絞り込んでいるため実害が
2954        // 無かったが、`element.checkValidity()`を非候補要素(`<output>`/`<div>`
2955        // 等)へ直接呼ぶ経路はこのガードが無いまま`validate_field`へ落ちており、
2956        // 誤って`required`属性を付けると本来検証対象外のはずが invalid 扱いに
2957        // なる実害のあるバグだった。2026-07-18 発見・修正)。
2958        if !matches!(n.tag.as_str(), "button" | "input" | "select" | "textarea") {
2959            return None;
2960        }
2961        let input_type = n
2962            .attrs
2963            .get("type")
2964            .map(|s| s.trim().to_lowercase())
2965            .unwrap_or_default();
2966        if matches!(
2967            input_type.as_str(),
2968            "submit" | "button" | "reset" | "hidden" | "image"
2969        ) {
2970            return None;
2971        }
2972        // 1) setCustomValidity で設定されたカスタムメッセージが最優先。
2973        if let Some(cv) = n.attrs.get("_custom_validity") {
2974            if !cv.is_empty() {
2975                return Some(cv.clone());
2976            }
2977        }
2978        let trimmed = value.trim();
2979        // 2) required: 空なら違反。ただし checkbox/radio は「値」ではなく
2980        // 「チェック状態」で判定する別のセマンティクス(HTML5仕様: checkbox は
2981        // 自身の checked、radio は同じ name グループ内のいずれかが checked で
2982        // あれば充足)のため `required_satisfied` に委譲する。以前は
2983        // checkbox/radio も他の input と同じ「value 属性が空か」で判定しており、
2984        // `value` 属性を明示しない(=チェック済みでも常に空文字列扱いになる)
2985        // チェックボックスは常に required 違反、逆に `value="yes"` だけ設定して
2986        // 未チェックのチェックボックスは常に妥当、というチェック状態と無関係な
2987        // 結果になっていた。
2988        if n.attrs.contains_key("required") && !self.required_satisfied(n, &input_type, trimmed) {
2989            return Some(match input_type.as_str() {
2990                "checkbox" => String::from("このフィールドをオンにしてください。"),
2991                "radio" => String::from("いずれかを選択してください。"),
2992                _ => String::from("このフィールドを入力してください。"),
2993            });
2994        }
2995        if matches!(input_type.as_str(), "checkbox" | "radio") {
2996            return None;
2997        }
2998        // 空かつ非required なら以降の形式チェックは不要。
2999        if value.is_empty() {
3000            return None;
3001        }
3002        // 3) minlength / maxlength(文字数)。
3003        let char_len = value.chars().count();
3004        if let Some(ml) = n
3005            .attrs
3006            .get("minlength")
3007            .and_then(|v| v.trim().parse::<usize>().ok())
3008        {
3009            if char_len < ml {
3010                return Some(alloc::format!(
3011                    "{}文字以上で入力してください(現在{}文字)。",
3012                    ml,
3013                    char_len
3014                ));
3015            }
3016        }
3017        if let Some(ml) = n
3018            .attrs
3019            .get("maxlength")
3020            .and_then(|v| v.trim().parse::<usize>().ok())
3021        {
3022            if char_len > ml {
3023                return Some(alloc::format!(
3024                    "{}文字以下で入力してください(現在{}文字)。",
3025                    ml,
3026                    char_len
3027                ));
3028            }
3029        }
3030        // 4) type 別の形式チェック。
3031        match input_type.as_str() {
3032            "email" if !Self::is_valid_email_value(n, value) => {
3033                return Some(String::from("メールアドレスを入力してください。"));
3034            }
3035            "url" if !(value.starts_with("http://") || value.starts_with("https://")) => {
3036                return Some(String::from("URL を入力してください。"));
3037            }
3038            // `type="date"`/`type="month"`の形式・min/max範囲チェック(詳細は
3039            // `validity_flags`側の同種コメント参照。既存の`input_value_as_date_ms()`を
3040            // そのまま再利用)。
3041            // `type="datetime-local"`も`input_value_as_date_ms()`が既に
3042            // `YYYY-MM-DDTHH:MM`形式をサポートしているため、`date`/`month`と
3043            // 同じ扱いに含めるだけで対応できる(`week`/`time`はこの関数自体が
3044            // 対応していないため対象外のまま)。
3045            "date" | "month" | "datetime-local" => match super::builtins::input_value_as_date_ms(&input_type, value) {
3046                Some(ms) => {
3047                    if let Some(min) = n
3048                        .attrs
3049                        .get("min")
3050                        .and_then(|v| super::builtins::input_value_as_date_ms(&input_type, v))
3051                    {
3052                        if ms < min {
3053                            return Some(alloc::format!(
3054                                "{}以降の日付を入力してください。",
3055                                n.attrs.get("min").cloned().unwrap_or_default()
3056                            ));
3057                        }
3058                    }
3059                    if let Some(max) = n
3060                        .attrs
3061                        .get("max")
3062                        .and_then(|v| super::builtins::input_value_as_date_ms(&input_type, v))
3063                    {
3064                        if ms > max {
3065                            return Some(alloc::format!(
3066                                "{}以前の日付を入力してください。",
3067                                n.attrs.get("max").cloned().unwrap_or_default()
3068                            ));
3069                        }
3070                    }
3071                }
3072                None => return Some(String::from("正しい日付を入力してください。")),
3073            },
3074            // `type="range"`も`type="number"`と同じ min/max/step 検証規則を適用する
3075            // (詳細は`validity_flags`側の同種コメント参照。以前は`range`型だけ
3076            // 検証が丸ごと欠けていた)。
3077            "number" | "range" => match value.trim().parse::<f64>() {
3078                Ok(num) => {
3079                    if let Some(min) = n
3080                        .attrs
3081                        .get("min")
3082                        .and_then(|v| v.trim().parse::<f64>().ok())
3083                    {
3084                        if num < min {
3085                            return Some(alloc::format!("{}以上の値を入力してください。", min));
3086                        }
3087                    }
3088                    if let Some(max) = n
3089                        .attrs
3090                        .get("max")
3091                        .and_then(|v| v.trim().parse::<f64>().ok())
3092                    {
3093                        if num > max {
3094                            return Some(alloc::format!("{}以下の値を入力してください。", max));
3095                        }
3096                    }
3097                    // `stepMismatch`(HTML5 制約検証。min/max はあったが step が丸ごと
3098                    // 未対応だった)。`step="any"` は制約無し。既定 step は 1、step の
3099                    // 基準点(仕様上の「step base」)は `min` があればそれ、無ければ 0。
3100                    let step_attr = n.attrs.get("step").map(|s| s.trim());
3101                    if step_attr != Some("any") {
3102                        let step = step_attr
3103                            .and_then(|s| s.parse::<f64>().ok())
3104                            .filter(|s| *s > 0.0)
3105                            .unwrap_or(1.0);
3106                        let base = n
3107                            .attrs
3108                            .get("min")
3109                            .and_then(|v| v.trim().parse::<f64>().ok())
3110                            .unwrap_or(0.0);
3111                        let steps = (num - base) / step;
3112                        if libm::fabs(steps - libm::round(steps)) > 1e-9 {
3113                            return Some(alloc::format!(
3114                                "{} の倍数(基準値 {})で入力してください。",
3115                                step,
3116                                base
3117                            ));
3118                        }
3119                    }
3120                }
3121                Err(_) => return Some(String::from("数値を入力してください。")),
3122            },
3123            _ => {}
3124        }
3125        // 5) pattern(簡易: 完全一致のリテラル比較、ワイルドカードのみ対応)。
3126        if let Some(pat) = n.attrs.get("pattern") {
3127            if !pat.is_empty() && !Self::pattern_matches(pat, value) {
3128                return Some(String::from("要求された形式で入力してください。"));
3129            }
3130        }
3131        None
3132    }
3133
3134    /// 簡易メールアドレス検証(完全な RFC ではなく実用的な近似)。
3135    fn is_valid_email(s: &str) -> bool {
3136        let (local, domain) = match s.split_once('@') {
3137            Some(v) => v,
3138            None => return false,
3139        };
3140        if local.is_empty() || domain.is_empty() {
3141            return false;
3142        }
3143        if domain.contains('@') {
3144            return false;
3145        }
3146        // ドメインに '.' があり、前後が非空。
3147        match domain.split_once('.') {
3148            Some((a, b)) => !a.is_empty() && !b.is_empty(),
3149            None => false,
3150        }
3151    }
3152
3153    /// `<input type="email" multiple>`(HTML5。`multiple`属性がある email input は
3154    /// カンマ区切りで複数アドレスを入力できる仕様だが、以前は`is_valid_email(value)`
3155    /// を値全体にそのまま適用しており、2件目以降のアドレスやカンマ自体を含む文字列は
3156    /// 単一アドレスとして常に不正と誤判定されていた(逆に`multiple`が無いのに
3157    /// カンマ区切りを入力した場合はこのメソッドを経由しないため今までどおり単一
3158    /// アドレスとして正しく不正判定される)。空要素(`"a@b.com,"`のような末尾カンマ)
3159    /// は仕様上不正。
3160    fn is_valid_email_value(n: &DomNode, value: &str) -> bool {
3161        if n.attrs.contains_key("multiple") {
3162            value.split(',').all(|part| Self::is_valid_email(part.trim()))
3163        } else {
3164            Self::is_valid_email(value)
3165        }
3166    }
3167
3168    /// `<input pattern="...">`(HTML Living Standard の制約検証。仕様上は値全体を
3169    /// `^(?:pattern)$` として完全一致させる)が、以前はメタ文字を含む複雑なパターンを
3170    /// 「誤検知で送信を妨げないため」全て無条件で通過させる簡易近似だった。この
3171    /// エンジンには既に本物の正規表現実装(`js::regex::Regex`)があるため、それを
3172    /// そのまま使えば近似ではなく正しく検証できる(`\d`/`[...]`/`+`/`.*` 等、
3173    /// HTML pattern で使われる構文は通常の JS RegExp と同じ)。
3174    fn pattern_matches(pat: &str, val: &str) -> bool {
3175        let anchored = format!("^(?:{})$", pat);
3176        let re = super::regex::Regex::new(&anchored, "");
3177        let chars: alloc::vec::Vec<char> = val.chars().collect();
3178        re.find_at(&chars, 0).is_some()
3179    }
3180
3181    /// 属性名の一覧(id/class を先頭に、続いてその他属性)。
3182    pub fn attr_names(&self, idx: usize) -> Vec<String> {
3183        let mut out = Vec::new();
3184        if let Some(n) = self.nodes.get(idx) {
3185            if !n.id.is_empty() {
3186                out.push(String::from("id"));
3187            }
3188            if !n.classes.is_empty() {
3189                out.push(String::from("class"));
3190            }
3191            // id/class は専用フィールドで既に出力済みなので attrs 側では除外。
3192            // `_` 始まりの内部専用属性(`_content_frag` 等)も
3193            // `serialize_node_html` と同じ理由で除外する(以前は
3194            // `getAttributeNames()`/`hasAttributes()` にそのまま漏れていた)。
3195            for k in n.attrs.keys() {
3196                if k != "id" && k != "class" && !k.starts_with('_') {
3197                    out.push(k.clone());
3198                }
3199            }
3200        }
3201        out
3202    }
3203
3204    pub fn add_listener(&mut self, node: usize, event: &str, func: Value) {
3205        self.add_listener_opts(node, event, func, false, false);
3206    }
3207
3208    /// capture / once オプション付きでリスナを登録する。重複(同一 node+event+func+capture)
3209    /// は DOM 仕様どおり無視する。戻り値は発番した ID。
3210    pub fn add_listener_opts(
3211        &mut self,
3212        node: usize,
3213        event: &str,
3214        func: Value,
3215        capture: bool,
3216        once: bool,
3217    ) -> u64 {
3218        self.add_listener_opts_signal(node, event, func, capture, once, None)
3219    }
3220
3221    /// `add_listener_opts` の `{signal}` 対応版。`signal` が既に abort 済みなら
3222    /// 仕様どおり登録自体を行わない。
3223    pub fn add_listener_opts_signal(
3224        &mut self,
3225        node: usize,
3226        event: &str,
3227        func: Value,
3228        capture: bool,
3229        once: bool,
3230        signal: Option<Value>,
3231    ) -> u64 {
3232        if signal.as_ref().is_some_and(is_signal_aborted) {
3233            return 0;
3234        }
3235        // 同一 (node, event, func, capture) の重複登録は無視(DOM 仕様)。
3236        if self.listeners.iter().any(|l| {
3237            l.node == node && l.event == event && l.capture == capture && l.func.same_ref(&func)
3238        }) {
3239            return 0;
3240        }
3241        let id = self.next_listener_id;
3242        self.next_listener_id += 1;
3243        self.listeners.push(Listener {
3244            node,
3245            event: event.to_string(),
3246            func,
3247            capture,
3248            once,
3249            id,
3250            signal,
3251        });
3252        id
3253    }
3254
3255    /// removeEventListener: 同一 (node, event, func, capture) のリスナを削除する。
3256    pub fn remove_listener(&mut self, node: usize, event: &str, func: &Value, capture: bool) {
3257        self.listeners.retain(|l| {
3258            !(l.node == node && l.event == event && l.capture == capture && l.func.same_ref(func))
3259        });
3260    }
3261
3262    /// ID 指定でリスナを削除する(once 発火後の自動削除に使う)。
3263    pub fn remove_listener_by_id(&mut self, id: u64) {
3264        self.listeners.retain(|l| l.id != id);
3265    }
3266
3267    /// 指定イベント型のリスナがページ内に1つでも登録されているか(ホバー判定の省略用)。
3268    pub fn has_listener(&self, event: &str) -> bool {
3269        self.listeners.iter().any(|l| l.event == event)
3270    }
3271
3272    /// 指定ノードに登録された指定イベントのリスナ関数を返す(呼び出しは呼び元で)。
3273    /// バブリングフェーズ用(capture=false のみ)。`{signal}` が abort 済みの
3274    /// リスナは除外する。
3275    pub fn listeners_for(&self, node: usize, event: &str) -> Vec<Value> {
3276        self.listeners
3277            .iter()
3278            .filter(|l| l.node == node && l.event == event && !l.capture && !is_listener_aborted(l))
3279            .map(|l| l.func.clone())
3280            .collect()
3281    }
3282
3283    /// 指定ノード・イベントのリスナを (func, capture, once, id) 付きで返す。
3284    /// `want_capture` でキャプチャ/バブルのどちらのフェーズかを絞り込む。
3285    /// `{signal}` が abort 済みのリスナは除外する。
3286    pub fn listeners_phase(
3287        &self,
3288        node: usize,
3289        event: &str,
3290        want_capture: bool,
3291    ) -> Vec<(Value, bool, u64)> {
3292        self.listeners
3293            .iter()
3294            .filter(|l| l.node == node && l.event == event && l.capture == want_capture && !is_listener_aborted(l))
3295            .map(|l| (l.func.clone(), l.once, l.id))
3296            .collect()
3297    }
3298
3299    /// 指定ノードに当該イベントのリスナ(フェーズ問わず)が在るか。
3300    pub fn has_listener_on(&self, node: usize, event: &str) -> bool {
3301        self.listeners
3302            .iter()
3303            .any(|l| l.node == node && l.event == event)
3304    }
3305
3306    /// 再パースした DOM 木へ JS の変更を焼き戻す。木はビルド時と同一ドキュメント順前提。
3307    pub fn apply_overrides(&self, root: &mut Node) {
3308        let mut counter = 0usize;
3309        self.apply_node(root, &mut counter);
3310    }
3311    fn apply_node(&self, node: &mut Node, counter: &mut usize) {
3312        let idx = *counter;
3313        *counter += 1;
3314        if let Some(dn) = self.nodes.get(idx) {
3315            match &mut node.node_type {
3316                NodeType::Text(t) => {
3317                    if let Some(ov) = &dn.text_override {
3318                        *t = ov.clone();
3319                    }
3320                }
3321                NodeType::Element {
3322                    attributes,
3323                    classes,
3324                    ..
3325                } => {
3326                    if dn.classes_dirty {
3327                        *classes = dn.classes.clone();
3328                        attributes.insert("class".to_string(), dn.classes.join(" "));
3329                    }
3330                    if dn.style_dirty {
3331                        attributes.insert("style".to_string(), serialize_inline_style(&dn.style));
3332                    }
3333                }
3334            }
3335        }
3336        for c in &mut node.children {
3337            self.apply_node(c, counter);
3338        }
3339    }
3340}
3341
3342/// `outerHTML` 直列化用の最小限の HTML エスケープ(テキストノード本文)。
3343fn html_escape_text(s: &str) -> String {
3344    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
3345}
3346/// `outerHTML` 直列化用の最小限の HTML エスケープ(属性値。`"` も escape する)。
3347fn html_escape_attr(s: &str) -> String {
3348    s.replace('&', "&amp;").replace('"', "&quot;")
3349}
3350
3351/// 生の宣言値末尾の `!important`(大小文字・空白ゆれ許容)を切り離す。
3352/// `(値本体, important か)` を返す。CSS カスケード側(`css.rs`)の
3353/// `!important` パーサとは別実装だが、`element.style.getPropertyValue`/
3354/// `.getPropertyPriority`(丸ごと未対応だった。2026-07-16 発見)専用の
3355/// 軽量版。
3356fn strip_style_priority(raw: &str) -> (&str, bool) {
3357    let trimmed = raw.trim_end();
3358    if let Some(pos) = trimmed.to_ascii_lowercase().rfind("!important") {
3359        if trimmed.get(pos..).unwrap_or("").eq_ignore_ascii_case("!important") {
3360            let before = trimmed.get(..pos).unwrap_or("").trim_end();
3361            return (before, true);
3362        }
3363    }
3364    (raw, false)
3365}
3366
3367/// `color: red; font-size: 12px` → マップ。
3368fn parse_inline_style(s: &str) -> BTreeMap<String, String> {
3369    let mut m = BTreeMap::new();
3370    for decl in s.split(';') {
3371        let decl = decl.trim();
3372        if decl.is_empty() {
3373            continue;
3374        }
3375        if let Some(colon) = decl.find(':') {
3376            let k = decl.get(..colon).unwrap_or("").trim().to_lowercase();
3377            let v = decl.get(colon + 1..).unwrap_or("").trim().to_string();
3378            // 値が空の宣言は不正なので持たない(`style.cssText = "display: "` 等。カスタムプロパティは除く)。
3379            if !k.is_empty() && (!v.is_empty() || k.starts_with("--")) {
3380                m.insert(k, v);
3381            }
3382        }
3383    }
3384    m
3385}
3386
3387fn serialize_inline_style(m: &BTreeMap<String, String>) -> String {
3388    let mut parts = Vec::new();
3389    for (k, v) in m {
3390        parts.push(format!("{}: {}", k, v));
3391    }
3392    parts.join("; ")
3393}
3394
3395/// `backgroundColor` → `background-color`。既にケバブならそのまま。
3396fn css_prop_from_camel(prop: &str) -> String {
3397    if prop.contains('-') || !prop.chars().any(|c| c.is_ascii_uppercase()) {
3398        return prop.to_lowercase();
3399    }
3400    let mut out = String::new();
3401    for c in prop.chars() {
3402        if c.is_ascii_uppercase() {
3403            out.push('-');
3404            out.push(c.to_ascii_lowercase());
3405        } else {
3406            out.push(c);
3407        }
3408    }
3409    out
3410}
3411
3412/// カスタム要素の反応の契機。
3413#[derive(Debug, Clone, PartialEq)]
3414pub enum CeEvent {
3415    /// 監視対象の属性が変わった(要素, 属性名, 旧値, 新値)。
3416    Attr(usize, String, Option<String>, Option<String>),
3417    /// 部分木が挿入された(根の添字)。
3418    Inserted(usize),
3419    /// 接続された部分木が取り除かれた(根の添字)。
3420    Removed(usize),
3421    /// 複製で部分木が作られた(根の添字。未接続)。
3422    Created(usize),
3423}
3424
3425impl DomBridge {
3426    /// `root` を含む部分木の要素の添字(木順)。
3427    pub fn subtree_elements(&self, root: usize) -> Vec<usize> {
3428        let mut out = Vec::new();
3429        let mut stack = alloc::vec![root];
3430        while let Some(i) = stack.pop() {
3431            let Some(n) = self.nodes.get(i) else { continue };
3432            if n.is_text {
3433                continue;
3434            }
3435            out.push(i);
3436            for &c in n.children.iter().rev() {
3437                stack.push(c);
3438            }
3439        }
3440        out
3441    }
3442}