Files
rust_browser/_bmad-output/implementation-artifacts/3-8-dom-bindings-via-web-api.md
Zachary D. Rowitsch 0b399becd6 Implement DOM bindings via web_api with code review fixes (§3.8)
Add full DOM manipulation capabilities from JavaScript: attribute access
(get/set/remove/has), element identity (id, className, tagName), classList
DOMTokenList, inline style CSSStyleDeclaration bridge, tree navigation
properties, and additional methods (cloneNode, contains, matches, closest,
outerHTML). Code review fixes: escape outerHTML attributes, deduplicate live
collections, correct Text/Comment node types, validate classList tokens,
fix cloneNode on Document nodes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:34:26 -04:00

27 KiB

Story 3.8: DOM Bindings via web_api

Status: done

Story

As a web developer using JavaScript, I want full DOM manipulation capabilities from JavaScript, So that dynamic web pages can create, modify, and style elements at runtime.

Acceptance Criteria

  1. Attribute access: element.getAttribute(name) returns attribute value or null. element.setAttribute(name, value) sets attribute and triggers dependent behavior (e.g., class attribute updates styling). element.removeAttribute(name) removes attribute. element.hasAttribute(name) returns boolean. Per DOM §7.3.

  2. Element identity properties: element.id (get/set) reflects the id attribute. element.className (get/set) reflects the class attribute. element.tagName returns uppercase tag name for HTML elements. Per DOM §5.4/§7.3.

  3. classList DOMTokenList: element.classList.add(...classes), .remove(...classes), .toggle(class, force?), .contains(class), .replace(old, new), .length, .item(index), [index] access. Modifying classList updates the class attribute and triggers style recomputation. Per DOM §7.1.

  4. Inline style access: element.style.propertyName (get/set) for camelCase CSS properties (e.g., element.style.backgroundColor = "red"). element.style.cssText (get/set) for full inline style string. element.style.getPropertyValue(name) and element.style.setProperty(name, value) for kebab-case access. Setting style triggers re-style/re-layout on next render. Per CSSOM §6.7.

  5. Tree navigation properties: node.parentNode, node.parentElement, node.childNodes (live NodeList), node.children (live HTMLCollection of elements only), node.firstChild, node.lastChild, node.nextSibling, node.previousSibling, node.firstElementChild, node.lastElementChild, node.nextElementSibling, node.previousElementSibling. All return HostObject or null. Per DOM §4.4.

  6. Additional element properties: element.outerHTML (getter), element.cloneNode(deep), element.contains(other), element.matches(selector), element.closest(selector), node.nodeType, node.nodeName, node.ownerDocument, element.childElementCount. Per DOM §4.4/§4.5.

  7. Integration tests verify each binding, DOM checklist is updated, and just ci passes.

What NOT to Implement

  • No element.dataset -- data-* attribute reflection deferred. getAttribute("data-foo") works as workaround.
  • No NamedNodeMap -- element.attributes collection deferred. Individual getAttribute/setAttribute sufficient.
  • No computed style -- window.getComputedStyle(element) deferred to Story 3.10 (Web API Exposure).
  • No element.scrollTop/scrollLeft/scrollWidth/scrollHeight -- scroll metrics deferred to Epic 5.
  • No element.offsetTop/offsetLeft/offsetWidth/offsetHeight/getBoundingClientRect() -- layout metrics deferred to Story 3.10.
  • No element.focus()/element.blur() -- focus management deferred to Epic 4.
  • No MutationObserver -- DOM observation API deferred.
  • No element.animate() -- Web Animations API out of scope.

Files to Modify

File Change
crates/web_api/src/dom_host/host_environment.rs Add attribute access (getAttribute/setAttribute/removeAttribute/hasAttribute), tree navigation properties (parentNode, childNodes, nextSibling, etc.), element identity (id, className, tagName), classList dispatch, style dispatch, cloneNode, contains, matches, closest, nodeType, nodeName, outerHTML.
crates/web_api/src/dom_host/mod.rs Add classList helper struct/methods. Add CSSStyleDeclaration helper for element.style bridge.
crates/web_api/src/dom_host/classlist.rs New file -- DOMTokenList implementation for classList.
crates/web_api/src/dom_host/style_bridge.rs New file -- CSSStyleDeclaration bridge: camelCase-to-kebab conversion, inline style parsing/serialization, property get/set.
crates/dom/src/document.rs Expose any missing DOM operations needed (most already exist). Add clone_node(id, deep) if not present. Add contains(ancestor_id, descendant_id).
crates/web_api/src/dom_host/live_collection.rs Add NodeList support (childNodes returns live NodeList of all node types, not just elements).
crates/web_api/src/dom_host/tests/dom_tests.rs Add tests for all new bindings.
tests/js_dom_tests.rs Add integration tests for attribute access, classList, style, navigation, cloneNode, matches, closest.
docs/HTML5_Implementation_Checklist.md Check off DOMTokenList, attribute access, navigation properties.

Tasks / Subtasks

  • Task 1: Attribute access APIs (AC: #1)

    • 1.1 Add getAttribute, setAttribute, removeAttribute, hasAttribute to call_method() in host_environment.rs:
      • getAttribute(name) → delegate to document.get_attribute(node_id, &name), return String or Null
      • setAttribute(name, value) → delegate to document.set_attribute(node_id, &name, &value), return Undefined
      • removeAttribute(name) → delegate to document.remove_attribute(node_id, &name) (add to dom crate if missing), return Undefined
      • hasAttribute(name) → check if attribute exists, return Boolean
      • All methods: validate that host object is Element type, TypeError otherwise
    • 1.2 Add remove_attribute(node_id, name) to crates/dom/src/document.rs if not already present
    • 1.3 Add unit tests and integration tests:
      • element.setAttribute("data-x", "hello"); element.getAttribute("data-x") === "hello"
      • element.removeAttribute("class"); element.hasAttribute("class") === false
      • Setting class attribute manually should work (classList tests in Task 3)
  • Task 2: Element identity and navigation properties (AC: #2, #5)

    • 2.1 Add element identity properties to get_property() in host_environment.rs:
      • "id"document.get_attribute(node_id, "id") or empty string
      • "className"document.get_attribute(node_id, "class") or empty string
      • "tagName"document.tag_name(node_id).to_uppercase() (HTML elements return uppercase per spec)
      • "nodeName" → same as tagName for elements; "#text" for text nodes, "#document" for document, "#comment" for comments, "#document-fragment" for fragments
      • "nodeType"1 (Element), 3 (Text), 8 (Comment), 9 (Document), 11 (DocumentFragment)
      • "ownerDocument" → return Document HostObject (DOCUMENT_HOST_ID)
      • "childElementCount" → count of element children
    • 2.2 Add id and className setters to set_property():
      • "id"document.set_attribute(node_id, "id", &value)
      • "className"document.set_attribute(node_id, "class", &value)
    • 2.3 Add tree navigation properties to get_property():
      • "parentNode"document.parent(node_id) → HostObject or Null
      • "parentElement"document.parent_element(node_id) → HostObject or Null (only if parent is element)
      • "firstChild"document.first_child(node_id) → HostObject or Null
      • "lastChild"document.last_child(node_id) → HostObject or Null (add to dom if missing)
      • "nextSibling"document.next_sibling(node_id) → HostObject or Null
      • "previousSibling"document.previous_sibling(node_id) → HostObject or Null (add to dom if missing)
      • "firstElementChild" → first child that is an element → HostObject or Null
      • "lastElementChild" → last child that is an element → HostObject or Null
      • "nextElementSibling" → next sibling that is an element → HostObject or Null
      • "previousElementSibling" → previous sibling that is an element → HostObject or Null
    • 2.4 Add childNodes property (live NodeList):
      • Return a HostObject representing a live NodeList (includes ALL child nodes: elements, text, comments)
      • Reuse LiveCollection pattern from live_collection.rs but for all node types
      • Support length, [index], item(index) access
    • 2.5 Add children property (live HTMLCollection):
      • Return a HostObject representing a live HTMLCollection (elements only)
      • Reuse existing LiveCollection infrastructure
    • 2.6 Add missing DOM helpers to crates/dom/src/document.rs:
      • last_child(id) if not present
      • previous_sibling(id) if not present
      • Helper iterators for element-only navigation
    • 2.7 Add tests for all navigation properties and identity getters
  • Task 3: classList DOMTokenList (AC: #3)

    • 3.1 Create crates/web_api/src/dom_host/classlist.rs:
      • ClassList is NOT a standalone object -- it's a live view of the element's class attribute
      • When JS accesses element.classList, return a HostObject with type "DOMTokenList" and a combined ID encoding both the element NodeId and a marker
      • ID encoding: CLASSLIST_ID_BASE + node_id (reserve ID range like LiveCollection)
    • 3.2 Implement DOMTokenList methods via call_method() dispatch on "DOMTokenList" type:
      • .add(...classes) → parse class attribute, add new classes, set attribute back
      • .remove(...classes) → parse, remove, set back
      • .toggle(class, force?) → toggle or force add/remove, return boolean
      • .contains(class) → check if class exists in attribute
      • .replace(oldClass, newClass) → replace if present, return boolean
      • .item(index) → return class at index or null
      • .length (via get_property) → number of classes
      • Index access (0, 1, etc.) → same as item()
    • 3.3 Ensure class attribute changes trigger style recomputation:
      • document.set_attribute() for class should be sufficient if the rendering pipeline re-matches selectors
      • Verify: After classList.add("highlight"), next render cycle applies .highlight styles
    • 3.4 Add "classList" to get_property() for Element type → return DOMTokenList HostObject
    • 3.5 Add tests:
      • el.classList.add("a", "b"); el.className === "a b"
      • el.classList.remove("a"); el.className === "b"
      • el.classList.toggle("c") === true; el.classList.contains("c") === true
      • el.classList.toggle("c") === false; el.classList.contains("c") === false
      • el.classList.replace("b", "d")
      • el.classList.length === 1
  • Task 4: Inline style access (AC: #4)

    • 4.1 Create crates/web_api/src/dom_host/style_bridge.rs:
      • CSSStyleDeclaration bridge between JS element.style.backgroundColor and DOM inline style attribute
      • camelCase to kebab-case conversion: backgroundColorbackground-color, marginTopmargin-top
      • kebab-case to camelCase for reading
      • Handle CSS vendor prefixes: webkitTransform-webkit-transform (low priority)
    • 4.2 Return CSSStyleDeclaration HostObject from get_property("style"):
      • Use ID encoding: STYLE_ID_BASE + node_id
      • Type name: "CSSStyleDeclaration"
    • 4.3 Implement style property get/set via get_property()/set_property() on "CSSStyleDeclaration" type:
      • Get: Parse element's style attribute, find matching property, return value string or empty string
      • Set: Parse existing style attribute, update/add property, serialize back to attribute
      • cssText (get): Return full style attribute value
      • cssText (set): Replace entire style attribute
    • 4.4 Implement getPropertyValue(name) and setProperty(name, value) via call_method():
      • Accept kebab-case property names ("background-color")
      • removeProperty(name) removes the property and returns old value
    • 4.5 Inline style parsing/serialization:
      • Parse: "color: red; font-size: 16px"Vec<(String, String)>
      • Serialize: Vec<(String, String)>"color: red; font-size: 16px"
      • Reuse CSS parser? The css crate can parse property values. For inline styles, a simple split on ; and : may suffice.
    • 4.6 Ensure style changes trigger re-render:
      • Setting style attribute via document.set_attribute() should invalidate style computation
    • 4.7 Add tests:
      • el.style.color = "red"; el.getAttribute("style") includes color: red
      • el.style.backgroundColor = "blue"el.getAttribute("style") includes background-color: blue
      • el.style.cssText = "margin: 10px" replaces all inline styles
      • el.style.getPropertyValue("color") === "red"
      • el.style.removeProperty("color") removes it
  • Task 5: Additional element methods (AC: #6)

    • 5.1 Implement element.cloneNode(deep) in call_method():
      • Shallow clone (deep=false): clone element and attributes, no children
      • Deep clone (deep=true): recursively clone element, attributes, and all descendants
      • Add clone_node(node_id, deep) to crates/dom/src/document.rs
      • Return new HostObject for cloned node
    • 5.2 Implement element.contains(other) in call_method():
      • Walk ancestors of other checking if any match element
      • Return boolean. element.contains(element) returns true (contains itself).
      • Add contains(ancestor_id, descendant_id) to dom crate if missing
    • 5.3 Implement element.matches(selector) in call_method():
      • Parse selector string using selectors crate
      • Check if element matches the selector
      • Return boolean
      • Reuse existing selector matching from document.query_selector() path
    • 5.4 Implement element.closest(selector) in call_method():
      • Walk ancestors (including self) checking matches(selector) on each
      • Return first matching ancestor as HostObject, or Null
    • 5.5 Implement element.outerHTML getter in get_property():
      • Serialize element AND its content to HTML string
      • Add outer_html(node_id) to dom crate (wraps inner_html with the element's own tag)
    • 5.6 Add tests for cloneNode, contains, matches, closest, outerHTML
  • Task 6: Testing and validation (AC: #7)

    • 6.1 Add integration tests in tests/js_dom_tests.rs:
      • Attribute access: get/set/remove/has
      • Identity properties: id, className, tagName
      • classList: add, remove, toggle, contains, replace
      • Style access: camelCase property set/get, cssText, getPropertyValue/setProperty
      • Navigation: parentNode, childNodes, firstChild, lastChild, nextSibling, previousSibling
      • Element navigation: firstElementChild, nextElementSibling, children
      • Methods: cloneNode (shallow and deep), contains, matches, closest
      • nodeType, nodeName for different node types
    • 6.2 Run all existing test suites to verify no regressions:
      • cargo test -p web_api
      • cargo test -p dom
      • cargo test -p rust_browser --test js_dom_tests
      • cargo test -p rust_browser --test js_tests
      • cargo test -p rust_browser --test js_events
      • cargo test -p rust_browser --test goldens
    • 6.3 Update docs/HTML5_Implementation_Checklist.md:
      • Check off DOMTokenList (classList)
      • Check off attribute access APIs
      • Check off navigation properties
      • Check off reflecting IDL attributes (id, className)
    • 6.4 Run just ci -- full validation pass

Dev Notes

Key Architecture Decisions

All bindings go through HostEnvironment trait. JS code accesses DOM via JsValue::HostObject { id, type_name }. The DomHost implementation of get_property(), set_property(), and call_method() dispatches based on type_name ("Element", "Document", "DOMTokenList", "CSSStyleDeclaration", "HTMLCollection", "NodeList"). No direct DOM access from JS engine.

classList and style return sub-HostObjects. element.classList returns HostObject { id: CLASSLIST_ID_BASE + node_id, type_name: "DOMTokenList" }. element.style returns HostObject { id: STYLE_ID_BASE + node_id, type_name: "CSSStyleDeclaration" }. These are live views -- they read/write the underlying DOM attribute each time.

ID space allocation pattern (existing):

u64::MAX              — DOCUMENT_HOST_ID
u64::MAX-2            — WINDOW_HOST_ID
u64::MAX-1000 ↓       — EVENT_HOST_ID_BASE
u64::MAX-100_000 ↓    — PROMISE_ID_START
u64::MAX-200_000 ↓    — COLLECTION_ID_BASE
0 ↑                   — DOM NodeIds (elements, text nodes)

New ranges needed:

u64::MAX-300_000 ↓    — CLASSLIST_ID_BASE (classList objects)
u64::MAX-400_000 ↓    — STYLE_ID_BASE (style objects)
u64::MAX-500_000 ↓    — NODELIST_ID_BASE (childNodes NodeLists)

Inline style parsing is simple string manipulation. Don't use the full CSS parser for element.style access. Split on ;, then : for each property. Serialize back with proper spacing. The full CSS parser is overkill for inline style get/set.

Implementation Patterns from Existing Code

Property getter dispatch (in host_environment.rs:get_property()):

match obj_type {
    "Element" | "DocumentFragment" => match property {
        "textContent" => { /* ... */ }
        "innerHTML" => { /* ... */ }
        // Add: "id", "className", "tagName", "parentNode", "childNodes", etc.
    },
    "Document" => match property {
        "readyState" => { /* ... */ }
        // ...
    },
    // Add: "DOMTokenList", "CSSStyleDeclaration", "NodeList"
}

Method call dispatch (in host_environment.rs:call_method()):

match obj_type {
    "Element" | "DocumentFragment" => match method {
        "appendChild" => { /* ... */ }
        // Add: "getAttribute", "setAttribute", "cloneNode", "matches", "closest"
    },
}

HostObject creation:

Ok(JsValue::HostObject {
    id: node_id.index() as u64,
    type_name: "Element".to_string(),
})

Critical Implementation Details

element.id setter must update the document's ID index. When element.id = "newId", the DOM's get_element_by_id() lookup must reflect the change. Verify that document.set_attribute(node_id, "id", value) updates the internal index.

classList mutations must update the class attribute. After classList.add("foo"), getAttribute("class") must include "foo". Implement classList by reading the current class attribute value, tokenizing on whitespace, mutating the token list, and writing back.

camelCase-to-kebab conversion for style properties:

fn camel_to_kebab(name: &str) -> String {
    // "backgroundColor" → "background-color"
    // "marginTop" → "margin-top"
    // "cssFloat" → "float" (special case)
    // "WebkitTransform" → "-webkit-transform" (if starts with uppercase)
}

childNodes returns ALL children (text, comment, element). This is different from children which returns only elements. The existing LiveCollection infrastructure may need adaptation since it currently only handles elements.

cloneNode(deep) must NOT copy event listeners. Per spec, cloned nodes don't carry over addEventListener registrations.

matches(selector) reuse. The selectors crate already has a matches() function used by querySelector. Reuse the same selector compilation and matching logic.

No hard dependencies. This story builds on existing web_api infrastructure. The property descriptor system (Story 3.5) is not required -- inline style and attribute access use the HostEnvironment bridge, not JS property descriptors.

Story 3.9 (Event Dispatch Completeness) builds on this story's DOM navigation (e.g., building ancestor chains for capture/bubble phases). Tree navigation properties from Task 2 will be used by event dispatch.

Previous Story Patterns

From Story 3.4 (ES modules) and 3.7 (strict mode):

  • HostEnvironment methods already have extensive match arms -- adding more follows the established pattern
  • All new host object types need ID range reservation to avoid collisions
  • Integration tests in tests/js_dom_tests.rs follow existing patterns
  • Run just ci after each task

Risk Assessment

MEDIUM: host_environment.rs file size. This file is already 1100+ lines. Adding attribute access, navigation, classList, and style dispatch will make it larger. Consider extracting DOMTokenList and CSSStyleDeclaration dispatch into separate files imported by host_environment.

MEDIUM: Inline style parsing correctness. CSS property values can contain colons and semicolons (e.g., background: url("data:image/png;base64,...") ). Simple string splitting may break on edge cases. Use a more robust parser that respects quotes and parentheses.

LOW: Tree navigation properties. Most DOM navigation methods already exist in the dom crate. Just need to wire them through HostEnvironment.

LOW: Attribute access. Direct delegation to existing dom crate methods.

Phased Implementation Strategy

Phase A -- Attributes + Identity (Tasks 1-2): Foundation. Wire existing DOM methods through HostEnvironment. Quick wins.

Phase B -- classList (Task 3): New sub-HostObject type with DOMTokenList protocol. Moderate complexity.

Phase C -- Style Bridge (Task 4): New sub-HostObject type with camelCase conversion. Moderate complexity.

Phase D -- Additional Methods (Task 5): cloneNode, contains, matches, closest. Each is standalone.

Phase E -- Testing + Validation (Task 6): After all bindings implemented.

Project Structure Notes

  • All DOM binding changes in crates/web_api/src/dom_host/ (Layer 1) -- no layer violations
  • DOM operations in crates/dom/src/ (Layer 1) -- may need minor additions
  • New files: classlist.rs, style_bridge.rs in crates/web_api/src/dom_host/
  • web_api depends on dom, css, selectors (all Layer 1, horizontal deps OK)
  • No unsafe code needed
  • No new external dependencies

References

  • DOM Living Standard §4.4 -- Interface Node -- parentNode, childNodes, firstChild, etc.
  • DOM Living Standard §4.5 -- Interface Element -- getAttribute, setAttribute, matches, closest
  • DOM Living Standard §7.1 -- DOMTokenList -- classList interface
  • DOM Living Standard §7.3 -- NamedNodeMap -- Attribute access
  • CSSOM §6.7 -- CSSStyleDeclaration -- Inline style access
  • [Source: crates/web_api/src/dom_host/host_environment.rs] -- Existing HostEnvironment impl (1100+ lines)
  • [Source: crates/web_api/src/dom_host/mod.rs] -- DomHost struct
  • [Source: crates/web_api/src/dom_host/live_collection.rs] -- LiveCollection pattern for HTMLCollection
  • [Source: crates/dom/src/document.rs] -- DOM operations (get_attribute, set_attribute, parent, children, etc.)
  • [Source: crates/web_api/src/lib.rs] -- WebApiFacade with ID space allocation constants
  • [Source: _bmad-output/planning-artifacts/epics.md#Story 3.8] -- Story requirements
  • [Source: _bmad-output/planning-artifacts/architecture.md#Web API Crate Scaling] -- Module-per-domain organization

Dev Agent Record

Agent Model Used

Claude Opus 4.6 (1M context)

Debug Log References

None — clean implementation with no blocking issues.

Completion Notes List

  • Task 1 (Attribute access): Added getAttribute, setAttribute, removeAttribute, hasAttribute to Element call_method dispatch. Added remove_attribute to DOM crate (ElementData + Document). Integration tests confirm get/set/remove/has roundtrip.
  • Task 2 (Identity + Navigation): Added id, className, tagName, nodeName, nodeType, ownerDocument, childElementCount, outerHTML to get_property. Added id/className setters. Added all tree navigation properties (parentNode, parentElement, firstChild, lastChild, nextSibling, previousSibling, firstElementChild, lastElementChild, nextElementSibling, previousElementSibling). Added childNodes (live NodeList) and children (live HTMLCollection). Added last_child, previous_sibling to DOM crate.
  • Task 3 (classList): Created DOMTokenList dispatch via CLASSLIST_ID_BASE + node_id encoding. Implemented add, remove, toggle, contains, replace, item, length, and index access. classList mutations write back to the class attribute.
  • Task 4 (Inline style): Created CSSStyleDeclaration bridge via STYLE_ID_BASE + node_id encoding. camelCase-to-kebab conversion for property get/set. cssText get/set. getPropertyValue, setProperty, removeProperty methods. Robust inline style parsing that respects quotes and parentheses.
  • Task 5 (Additional methods): Added cloneNode(deep), contains(other), matches(selector), closest(selector), outerHTML getter. Added clone_node, contains, outer_html to DOM crate.
  • Task 6 (Testing + validation): 40 new integration tests covering all new bindings. All 89 js_dom_tests pass. Full CI passes (fmt, lint, test). HTML5 checklist updated.

File List

  • crates/dom/src/node.rs — Added remove_attribute to ElementData
  • crates/dom/src/document.rs — Added last_child, previous_sibling, remove_attribute, clone_node, contains, outer_html
  • crates/web_api/src/dom_host/mod.rs — Added CLASSLIST_ID_BASE, STYLE_ID_BASE, NODELIST_ID_BASE constants; added node_to_host_object, next_element_sibling, previous_element_sibling, classlist_node_id, style_node_id helpers; added classlist and style_bridge module declarations
  • crates/web_api/src/dom_host/host_environment.rs — Added attribute access, identity, navigation, classList, style, cloneNode, contains, matches, closest, outerHTML bindings to get_property/set_property/call_method
  • crates/web_api/src/dom_host/classlist.rs — New file: DOMTokenList module doc
  • crates/web_api/src/dom_host/style_bridge.rs — New file: camel_to_kebab conversion, inline style parsing/serialization, unit tests
  • crates/web_api/src/dom_host/live_collection.rs — Added ChildNodes and Children variants to LiveCollectionQuery
  • tests/js_dom_tests.rs — Added 40 integration tests for all new DOM bindings
  • docs/HTML5_Implementation_Checklist.md — Checked off DOMTokenList, attribute access, navigation properties, identity properties, inline style access, additional element methods

Change Log

  • 2026-03-16: Implemented full DOM bindings for Story 3.8 — attribute access, element identity, tree navigation, classList, inline style, cloneNode/contains/matches/closest/outerHTML. All ACs satisfied.
  • 2026-03-16: Code review fixes (3 HIGH, 4 MEDIUM):
    • H1: Removed empty classlist.rs (misleading module shell with no code)
    • H2: Deduplicated live collection creation — childNodes/children now reuse existing collections instead of creating new ones on every access
    • H3: Fixed outerHTML to escape attribute values (matched innerHTML's escaping)
    • M1: node_to_host_object now returns correct type names for Text/Comment nodes; added Text/Comment dispatch to get_property/set_property/call_method
    • M2: cloneNode on Document now creates a Document node (not DocumentFragment)
    • M3: removeProperty return value already tested (no change needed)
    • M4: classList add/remove/toggle/replace now validate tokens per DOM §7.1 (throw on empty/whitespace); added 2 regression tests