+
+
+
+ "#,
+ );
+ let mut runtime = obscura_js::runtime::ObscuraJsRuntime::new();
+ runtime.set_dom(dom);
+ runtime.set_url("https://example.test/scroll");
+ runtime.set_viewport(100.0, 80.0);
+ runtime.run_page_init();
+ page.js = Some(runtime);
+ page.url = Some(url::Url::parse("https://example.test/scroll").unwrap());
+
+ let before = page.screenshot(page.viewport).expect("top screenshot");
+ assert_eq!(
+ page.evaluate(
+ "return (document.getElementById('second').scrollIntoView(), window.scrollY)"
+ )
+ .as_f64(),
+ Some(80.0)
+ );
+ let after = page.screenshot(page.viewport).expect("scrolled screenshot");
+
+ assert_ne!(before, after, "Page screenshot must paint the scrolled viewport");
+ assert_eq!(
+ page.js.as_ref().expect("runtime").scroll_offset(),
+ (0.0, 80.0)
+ );
+ }
+
#[test]
fn truncate_never_splits_a_multibyte_char() {
// A caller-supplied expression whose byte 80 lands inside a multi-byte
diff --git a/crates/obscura-cdp/src/domains/page.rs b/crates/obscura-cdp/src/domains/page.rs
index 44f8f7c..e1849e6 100644
--- a/crates/obscura-cdp/src/domains/page.rs
+++ b/crates/obscura-cdp/src/domains/page.rs
@@ -406,25 +406,47 @@ pub async fn handle(
.get_session_page(session_id)
.map(|page| (page.viewport.0 as f64, page.viewport.1 as f64))
.unwrap_or((1280.0, 720.0));
- let content_height = ctx
+ let mut page_x = 0.0;
+ let mut page_y = 0.0;
+ let mut content_width = width;
+ let mut content_height = height;
+ if let Some(values) = ctx
.get_session_page_mut(session_id)
- .map(|p| p.evaluate("document.documentElement && document.documentElement.scrollHeight"))
- .and_then(|v| v.as_f64())
- .filter(|n| *n > 0.0)
- .unwrap_or(height);
+ .map(|page| {
+ page.evaluate(
+ "[window.scrollX, window.scrollY, \
+ document.documentElement && document.documentElement.scrollWidth, \
+ document.documentElement && document.documentElement.scrollHeight]",
+ )
+ })
+ .and_then(|value| value.as_array().cloned())
+ {
+ page_x = values.first().and_then(Value::as_f64).unwrap_or(0.0);
+ page_y = values.get(1).and_then(Value::as_f64).unwrap_or(0.0);
+ content_width = values
+ .get(2)
+ .and_then(Value::as_f64)
+ .filter(|value| *value > 0.0)
+ .unwrap_or(width);
+ content_height = values
+ .get(3)
+ .and_then(Value::as_f64)
+ .filter(|value| *value > 0.0)
+ .unwrap_or(height);
+ }
let layout_viewport = json!({
- "pageX": 0, "pageY": 0,
+ "pageX": page_x, "pageY": page_y,
"clientWidth": width, "clientHeight": height,
});
let visual_viewport = json!({
"offsetX": 0.0, "offsetY": 0.0,
- "pageX": 0.0, "pageY": 0.0,
+ "pageX": page_x, "pageY": page_y,
"clientWidth": width, "clientHeight": height,
"scale": 1.0, "zoom": 1.0,
});
let content_size = json!({
"x": 0.0, "y": 0.0,
- "width": width, "height": content_height,
+ "width": content_width, "height": content_height,
});
Ok(json!({
"layoutViewport": layout_viewport,
@@ -606,6 +628,59 @@ mod tests {
assert_eq!(content["height"].as_f64(), Some(720.0));
}
+ #[cfg(feature = "render")]
+ #[tokio::test]
+ async fn cdp_metrics_and_capture_follow_the_scrolled_viewport() {
+ let mut ctx = CdpContext::new();
+ let page_id = ctx.create_page();
+ let session_id = format!("{page_id}-session");
+ ctx.sessions.insert(session_id.clone(), page_id);
+ let session = Some(session_id);
+ ctx.get_session_page_mut(&session)
+ .expect("page")
+ .set_viewport((100.0, 80.0));
+
+ handle(
+ "navigate",
+ &json!({
+ "url": "data:text/html,",
+ "waitUntil": "load",
+ }),
+ &mut ctx,
+ &session,
+ )
+ .await
+ .expect("navigate");
+
+ let top = handle("captureScreenshot", &json!({}), &mut ctx, &session)
+ .await
+ .expect("top capture");
+ ctx.get_session_page_mut(&session)
+ .expect("page")
+ .evaluate("return (window.scrollTo(0, 80), window.scrollY)");
+
+ let metrics = handle("getLayoutMetrics", &json!({}), &mut ctx, &session)
+ .await
+ .expect("metrics");
+ assert_eq!(metrics["layoutViewport"]["pageY"].as_f64(), Some(80.0));
+ assert_eq!(metrics["visualViewport"]["pageY"].as_f64(), Some(80.0));
+ assert_eq!(metrics["contentSize"]["width"].as_f64(), Some(100.0));
+ assert!(
+ metrics["contentSize"]["height"].as_f64().unwrap_or_default() >= 160.0,
+ "contentSize must expose the scrollable document: {metrics}"
+ );
+
+ let scrolled = handle("captureScreenshot", &json!({}), &mut ctx, &session)
+ .await
+ .expect("scrolled capture");
+ let top_data = top["data"].as_str().expect("top png");
+ let scrolled_data = scrolled["data"].as_str().expect("scrolled png");
+ assert_ne!(
+ top_data, scrolled_data,
+ "CDP captureScreenshot must paint the current scroll offset"
+ );
+ }
+
#[tokio::test]
async fn unknown_page_method_still_errors() {
let mut ctx = CdpContext::new();
diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs
index 6e6be9a..f2864e8 100644
--- a/crates/obscura-cli/src/main.rs
+++ b/crates/obscura-cli/src/main.rs
@@ -641,10 +641,21 @@ async fn run_fetch(
// nor by the V8 watchdog (terminate_execution only unwinds JS bytecode, not
// native Rust running beneath a V8->op call). As an absolute backstop so one
// fetch can never wedge the worker, a daemon thread force-exits if the whole
- // operation overruns timeout + wait + grace. A normal fetch returns first and
- // the process exits before this fires.
+ // operation overruns navigation + every configured settle pass + grace. A
+ // normal fetch returns first and the process exits before this fires.
{
- let hard = Duration::from_secs(timeout_secs.saturating_add(wait_secs).saturating_add(10));
+ let settle_passes = if eval.is_some()
+ && (screenshot.is_some() || selector.is_some() || dump_specified)
+ {
+ 2
+ } else {
+ 1
+ };
+ let hard = Duration::from_secs(
+ timeout_secs
+ .saturating_add(wait_secs.saturating_mul(settle_passes))
+ .saturating_add(10),
+ );
std::thread::spawn(move || {
std::thread::sleep(hard);
eprintln!("obscura: hard timeout exceeded ({}s); forcing exit", hard.as_secs());
@@ -671,11 +682,62 @@ async fn run_fetch(
// pages stay fast.
page.settle(wait_secs.saturating_mul(1000)).await;
- // --screenshot renders the settled page to a PNG. Requires the render
- // feature; without it, page.screenshot is absent and we report clearly.
+ let mut deferred_eval_output = None;
+ if let Some(ref expr) = eval {
+ // Bound the eval by the same budget as navigation so a runaway
+ // expression (infinite loop, never-settling sync work) cannot hang.
+ let result = page.evaluate_with_timeout(expr, Duration::from_secs(timeout_secs));
+
+ // A bare --eval (no --selector, --dump, or --screenshot) returns the
+ // eval value directly, so synchronous expressions
+ // (JSON.stringify, ...) are unchanged. Screenshot captures continue
+ // below so an evaluation such as scrollTo() affects the painted
+ // viewport instead of being silently ignored.
+ if !dump_specified && selector.is_none() && screenshot.is_none() {
+ let rendered = match result {
+ serde_json::Value::String(s) => s,
+ serde_json::Value::Null => "null".to_string(),
+ other => other.to_string(),
+ };
+ write_or_print(rendered, output.as_ref()).await?;
+ context.save_cookies();
+ return Ok(());
+ }
+ if screenshot.is_some() {
+ deferred_eval_output = Some(result);
+ }
+
+ // --eval combined with --selector, --dump, and/or --screenshot
+ // typically kicks off async work (a fetch promise, a timer, a scroll
+ // listener) that writes the DOM. Drive the event loop again so that
+ // work completes, then fall through to selector/capture/dump instead
+ // of returning the still-pending eval value (issue #248).
+ page.settle(wait_secs.saturating_mul(1000)).await;
+ }
+
+ if let Some(ref sel) = selector {
+ let found = wait_for_selector(&mut page, sel, wait_secs).await;
+ if !found {
+ eprintln!("Warning: selector '{}' not found after {}s", sel, wait_secs);
+ }
+ }
+
+ // --screenshot renders the settled, optionally evaluated page to a PNG.
+ // Requires the render feature; without it, page.screenshot is absent and
+ // we report clearly.
if let Some(ref path) = screenshot {
#[cfg(feature = "render")]
{
+ let capture_state = deferred_eval_output.as_ref().map(|_| {
+ page.evaluate(
+ "(()=>({\
+ scrollX:window.scrollX,scrollY:window.scrollY,\
+ innerWidth:window.innerWidth,innerHeight:window.innerHeight,\
+ scrollWidth:document.documentElement?document.documentElement.scrollWidth:0,\
+ scrollHeight:document.documentElement?document.documentElement.scrollHeight:0\
+ }))()",
+ )
+ });
// Default CSS-pixel viewport, matching the engine's innerWidth/Height.
// OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to
// capture below-the-fold content in one shot).
@@ -684,6 +746,19 @@ async fn run_fetch(
Some(bytes) => std::fs::write(path, &bytes)?,
None => anyhow::bail!("screenshot failed: page has no DOM to render"),
}
+ // A screenshot+eval command used to ignore the expression
+ // completely. Emit both its value and a standard state sampled
+ // after the post-eval settle so automation can record the exact
+ // live viewport that was painted.
+ if let Some(result) = deferred_eval_output {
+ println!(
+ "{}",
+ serde_json::json!({
+ "evaluation": result,
+ "captureState": capture_state.unwrap_or(serde_json::Value::Null),
+ })
+ );
+ }
if !quiet {
eprintln!("Screenshot written: {} ({} bytes)", path.display(), std::fs::metadata(path).map(|m| m.len()).unwrap_or(0));
}
@@ -699,40 +774,6 @@ async fn run_fetch(
}
}
- if let Some(ref expr) = eval {
- // Bound the eval by the same budget as navigation so a runaway
- // expression (infinite loop, never-settling sync work) cannot hang.
- let result = page.evaluate_with_timeout(expr, Duration::from_secs(timeout_secs));
-
- // A bare --eval (no --selector, no --dump) returns the eval value
- // directly, so synchronous expressions (JSON.stringify, ...) are
- // unchanged.
- if !dump_specified && selector.is_none() {
- let rendered = match result {
- serde_json::Value::String(s) => s,
- serde_json::Value::Null => "null".to_string(),
- other => other.to_string(),
- };
- write_or_print(rendered, output.as_ref()).await?;
- context.save_cookies();
- return Ok(());
- }
-
- // --eval combined with --selector and/or --dump: the eval typically
- // kicks off async work (a fetch promise, a timer) that writes the DOM.
- // Drive the event loop again so that work completes, then fall through
- // to the selector wait and the dump path instead of returning the
- // still-pending eval value (issue #248).
- page.settle(wait_secs.saturating_mul(1000)).await;
- }
-
- if let Some(ref sel) = selector {
- let found = wait_for_selector(&mut page, sel, wait_secs).await;
- if !found {
- eprintln!("Warning: selector '{}' not found after {}s", sel, wait_secs);
- }
- }
-
let rendered = match dump {
DumpFormat::Html => dump_html(&page),
DumpFormat::Text => dump_text(&mut page),
diff --git a/crates/obscura-cli/tests/screenshot_after_eval.rs b/crates/obscura-cli/tests/screenshot_after_eval.rs
new file mode 100644
index 0000000..b685f71
--- /dev/null
+++ b/crates/obscura-cli/tests/screenshot_after_eval.rs
@@ -0,0 +1,73 @@
+#![cfg(feature = "render")]
+
+use std::process::Command;
+
+fn output_directory() -> std::path::PathBuf {
+ let unique = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("clock")
+ .as_nanos();
+ std::env::temp_dir().join(format!(
+ "obscura-screenshot-after-eval-{}-{unique}",
+ std::process::id()
+ ))
+}
+
+#[test]
+fn screenshot_is_captured_after_eval_scrolls_the_live_page() {
+ let directory = output_directory();
+ std::fs::create_dir_all(&directory).expect("temporary output directory");
+ let top = directory.join("top.png");
+ let scrolled = directory.join("scrolled.png");
+ let url = concat!(
+ "data:text/html,",
+ "",
+ "",
+ ""
+ );
+
+ let run = |path: &std::path::Path, eval: Option<&str>| {
+ let mut command = Command::new(env!("CARGO_BIN_EXE_obscura"));
+ command
+ .args(["fetch", url, "--screenshot"])
+ .arg(path)
+ .args(["--wait", "0", "--timeout", "5", "--quiet"])
+ .env("OBSCURA_SHOT_W", "100")
+ .env("OBSCURA_SHOT_H", "80");
+ if let Some(expression) = eval {
+ command.args(["--eval", expression]);
+ }
+ command.output().expect("run obscura fetch")
+ };
+
+ let top_output = run(&top, None);
+ assert!(
+ top_output.status.success(),
+ "top capture failed: {}",
+ String::from_utf8_lossy(&top_output.stderr)
+ );
+
+ let scrolled_output = run(
+ &scrolled,
+ Some(
+ "(()=>{window.scrollTo(0,80);return JSON.stringify({y:window.scrollY})})()",
+ ),
+ );
+ assert!(
+ scrolled_output.status.success(),
+ "scrolled capture failed: {}",
+ String::from_utf8_lossy(&scrolled_output.stderr)
+ );
+ let state: serde_json::Value =
+ serde_json::from_slice(&scrolled_output.stdout).expect("evaluation state JSON");
+ assert_eq!(state["captureState"]["scrollY"].as_f64(), Some(80.0));
+
+ let top_bytes = std::fs::read(&top).expect("top PNG");
+ let scrolled_bytes = std::fs::read(&scrolled).expect("scrolled PNG");
+ assert_ne!(
+ top_bytes, scrolled_bytes,
+ "the post-eval screenshot must paint the scrolled viewport"
+ );
+
+ std::fs::remove_dir_all(directory).expect("remove temporary output directory");
+}
diff --git a/crates/obscura-dom/src/selector.rs b/crates/obscura-dom/src/selector.rs
index 00692d1..88f3917 100644
--- a/crates/obscura-dom/src/selector.rs
+++ b/crates/obscura-dom/src/selector.rs
@@ -1031,6 +1031,22 @@ mod tests {
assert_eq!(node.as_element().unwrap().local.as_ref(), "span");
}
+ #[test]
+ fn compiled_nested_is_descendant_matches_with_ancestor_filter() {
+ let tree = parse_html(
+ r#"
x
"#,
+ );
+ let target = tree.get_element_by_id("target").unwrap();
+ let compiled = tree
+ .compile_rule_selector(r#":is(.\*\*\:\[\.line\]\:block *).line"#)
+ .unwrap();
+ let mut matcher = tree.matcher();
+ for ancestor in tree.ancestors(target).into_iter().rev() {
+ matcher.push_ancestor(&tree, ancestor);
+ }
+ assert!(matcher.matches(&tree, target, &compiled));
+ }
+
#[test]
fn test_query_selector_attribute() {
let tree = parse_html(
diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js
index 81807d9..3ef8fe4 100644
--- a/crates/obscura-js/js/bootstrap.js
+++ b/crates/obscura-js/js/bootstrap.js
@@ -657,8 +657,66 @@ globalThis.setInterval = (fn, delay = 0, ...args) => {
};
globalThis.clearInterval = (id) => { _intervals.delete(id); _clearedTimers.add(id); };
-globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
-globalThis.cancelAnimationFrame = globalThis.clearTimeout;
+
+// Animation callbacks are a rendering-phase batch, not zero-delay
+// microtasks. In particular, a callback which queues itself must yield to
+// timers, networking, and the embedder between frames. The old setTimeout(0)
+// alias eventually used Promise.resolve(), so a normal animation loop formed
+// an unbounded microtask chain and pinned V8 until the watchdog terminated it.
+const _RAF_FRAME_DELAY_MS = 16;
+let _rafPending = new Map();
+let _rafCurrentBatch = null;
+let _rafFrameScheduled = false;
+let _rafRunningFrame = false;
+
+function _scheduleAnimationFrame() {
+ if (_rafFrameScheduled || _rafRunningFrame || _rafPending.size === 0) return;
+ _rafFrameScheduled = true;
+ _scheduleAfter(_RAF_FRAME_DELAY_MS, () => {
+ _rafFrameScheduled = false;
+ if (_rafPending.size === 0) return;
+
+ // Swap before invoking anything. A callback requested while this batch is
+ // running therefore belongs to the next frame. Every callback in this
+ // batch receives the same rendering timestamp.
+ const batch = _rafPending;
+ _rafPending = new Map();
+ _rafCurrentBatch = batch;
+ _rafRunningFrame = true;
+ const timestamp = performance.now();
+ try {
+ for (const [id, callback] of batch) {
+ // cancelAnimationFrame() may remove a later callback while an earlier
+ // callback in the same frame is running.
+ if (!batch.has(id)) continue;
+ batch.delete(id);
+ try { callback(timestamp); }
+ catch (e) { console.error("Animation frame error:", e); }
+ }
+ } finally {
+ _rafRunningFrame = false;
+ _rafCurrentBatch = null;
+ _scheduleAnimationFrame();
+ }
+ });
+}
+
+globalThis.requestAnimationFrame = (fn) => {
+ if (typeof fn !== "function") {
+ throw new TypeError(
+ "Failed to execute 'requestAnimationFrame' on 'Window': parameter 1 is not of type 'Function'."
+ );
+ }
+ const id = ++_tid;
+ _rafPending.set(id, fn);
+ _scheduleAnimationFrame();
+ return id;
+};
+
+globalThis.cancelAnimationFrame = (id) => {
+ _rafPending.delete(id);
+ if (_rafCurrentBatch) _rafCurrentBatch.delete(id);
+};
globalThis.queueMicrotask = globalThis.queueMicrotask || ((fn) => Promise.resolve().then(fn));
class MessageChannel {
@@ -2687,49 +2745,103 @@ class Element extends Node {
// returning 100x20 there made every element appear off-screen and broke .click().
get clientWidth() { return this._isViewportRoot() ? (globalThis.innerWidth || 1280) : 100; }
get clientHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; }
- get scrollWidth() { return this._isViewportRoot() ? (globalThis.innerWidth || 1280) : 100; }
- get scrollHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; }
+ get scrollWidth() {
+ if (!this._isViewportRoot()) return 100;
+ const metrics = this._renderScrollMetrics();
+ return metrics ? metrics.scrollWidth : (globalThis.innerWidth || 1280);
+ }
+ get scrollHeight() {
+ if (!this._isViewportRoot()) return 20;
+ const metrics = this._renderScrollMetrics();
+ return metrics ? metrics.scrollHeight : (globalThis.innerHeight || 720);
+ }
_isViewportRoot() {
const t = this.tagName;
return t === 'HTML' || t === 'BODY';
}
- // No layout engine, so there is no real overflow to scroll and the offset is
- // deliberately NOT clamped: without real geometry any synthetic max is a
- // guess, and a max derived from a stub scroll box pins scrollTop at 0, which
- // deadlocks scroll-driven lazy loaders (no scroll -> no content -> no scroll).
- // We track the offset so scrollTop/scrollLeft round-trip, and fire a scroll
- // event on direct assignment -- lazy loaders that set `el.scrollTop = N` rely
- // on that event, and scrollTo/scrollBy below would otherwise be its only source.
- get scrollTop() { return this._scrollTop || 0; }
+ _renderScrollMetrics() {
+ if (typeof Deno.core.ops.op_layout_metrics !== 'function') return null;
+ try {
+ const raw = Deno.core.ops.op_layout_metrics();
+ return raw ? JSON.parse(raw) : null;
+ } catch (_e) {
+ return null;
+ }
+ }
+ _renderScrollOffset() {
+ if (typeof Deno.core.ops.op_scroll_offset !== 'function') return null;
+ try {
+ const raw = Deno.core.ops.op_scroll_offset();
+ return raw ? JSON.parse(raw) : null;
+ } catch (_e) {
+ return null;
+ }
+ }
+ _setRenderScroll(x, y) {
+ if (typeof Deno.core.ops.op_scroll_to !== 'function') return null;
+ try {
+ const raw = Deno.core.ops.op_scroll_to(+x || 0, +y || 0);
+ return raw ? JSON.parse(raw) : null;
+ } catch (_e) {
+ return null;
+ }
+ }
+ // Render builds clamp the viewport root against measured document overflow.
+ // Nested element scrollers still use synthetic, deliberately unclamped state:
+ // their contents do not yet participate in scroll-container layout, and a
+ // guessed maximum would pin lazy-loading scrollers at zero.
+ get scrollTop() {
+ if (this._isViewportRoot()) {
+ const offset = this._renderScrollOffset();
+ if (offset) return offset.y || 0;
+ }
+ return this._scrollTop || 0;
+ }
set scrollTop(v) {
v = +v;
const nv = Number.isFinite(v) && v > 0 ? v : 0;
- const changed = nv !== (this._scrollTop || 0);
- this._scrollTop = nv;
+ const old = this.scrollTop;
+ let actual = nv;
+ if (this._isViewportRoot()) {
+ const offset = this._renderScrollOffset();
+ const updated = offset && this._setRenderScroll(offset.x, nv);
+ if (updated) actual = updated.y || 0;
+ }
+ const changed = actual !== old;
+ this._scrollTop = actual;
if (changed && !this._scrollSuppress) this._fireScroll();
+ if (changed && this._isViewportRoot() &&
+ typeof globalThis.__obscura_recompute_intersections === "function") {
+ globalThis.__obscura_recompute_intersections();
+ }
+ }
+ get scrollLeft() {
+ if (this._isViewportRoot()) {
+ const offset = this._renderScrollOffset();
+ if (offset) return offset.x || 0;
+ }
+ return this._scrollLeft || 0;
}
- get scrollLeft() { return this._scrollLeft || 0; }
set scrollLeft(v) {
v = +v;
const nv = Number.isFinite(v) && v > 0 ? v : 0;
- const changed = nv !== (this._scrollLeft || 0);
- this._scrollLeft = nv;
+ const old = this.scrollLeft;
+ let actual = nv;
+ if (this._isViewportRoot()) {
+ const offset = this._renderScrollOffset();
+ const updated = offset && this._setRenderScroll(nv, offset.y);
+ if (updated) actual = updated.x || 0;
+ }
+ const changed = actual !== old;
+ this._scrollLeft = actual;
if (changed && !this._scrollSuppress) this._fireScroll();
+ if (changed && this._isViewportRoot() &&
+ typeof globalThis.__obscura_recompute_intersections === "function") {
+ globalThis.__obscura_recompute_intersections();
+ }
}
getBoundingClientRect() {
globalThis.__obscura_click_target = this;
- // documentElement and body span the full viewport. Without this every
- // hit test against them clips down to a 100x20 synthetic cell and
- // Document.elementFromPoint can never recurse into their children.
- if (this._isViewportRoot()) {
- const vw = globalThis.innerWidth || 1280;
- const vh = globalThis.innerHeight || 720;
- return {
- x: 0, y: 0, width: vw, height: vh,
- top: 0, right: vw, bottom: vh, left: 0,
- toJSON() { return this; },
- };
- }
// Real layout when the render feature is compiled in: ask the Rust layout
// cache for this element's border box. The op is absent in the default
// build, so probe with typeof and fall through to the synthetic rect below.
@@ -2740,15 +2852,32 @@ class Element extends Node {
var _r = JSON.parse(_geom);
if (_r && typeof _r.width === 'number') {
var _x = _r.x, _y = _r.y, _w = _r.width, _h = _r.height;
- return {
+ var _rect = {
x: _x, y: _y, width: _w, height: _h,
top: _y, right: _x + _w, bottom: _y + _h, left: _x,
toJSON() { return this; },
};
+ Object.defineProperty(_rect, "__obscuraViewportFixed", {
+ value: !!_r.viewportFixed,
+ enumerable: false,
+ });
+ return _rect;
}
}
} catch (_e) {}
}
+ // Default (non-render) builds keep viewport-sized roots. Without this
+ // synthetic fallback every hit test against them clips down to a 100x20
+ // cell and Document.elementFromPoint cannot recurse into their children.
+ if (this._isViewportRoot()) {
+ const vw = globalThis.innerWidth || 1280;
+ const vh = globalThis.innerHeight || 720;
+ return {
+ x: 0, y: 0, width: vw, height: vh,
+ top: 0, right: vw, bottom: vh, left: 0,
+ toJSON() { return this; },
+ };
+ }
// No layout engine (default build) or this node has no box: synthesize a
// deterministic position from the node id so Playwright's actionability
// polling still gets a stable, distinct rect for hit-testing (issue #45).
@@ -2790,7 +2919,40 @@ class Element extends Node {
set ariaHidden(v) { if (v == null) this.removeAttribute('aria-hidden'); else this.setAttribute('aria-hidden', String(v)); }
get ariaSelected() { return this.getAttribute('aria-selected'); }
set ariaSelected(v) { if (v == null) this.removeAttribute('aria-selected'); else this.setAttribute('aria-selected', String(v)); }
- scrollIntoView() { globalThis.__obscura_click_target = this; }
+ scrollIntoView(arg) {
+ globalThis.__obscura_click_target = this;
+ const rect = this.getBoundingClientRect();
+ // A viewport-fixed subtree is already expressed in the viewport's
+ // coordinate space and cannot be brought closer by moving the document.
+ if (rect.__obscuraViewportFixed) return;
+
+ let block = "start", inline = "nearest";
+ if (arg === false) block = "end";
+ else if (arg && typeof arg === "object") {
+ if (["start", "center", "end", "nearest"].includes(arg.block)) block = arg.block;
+ if (["start", "center", "end", "nearest"].includes(arg.inline)) inline = arg.inline;
+ }
+ const currentX = globalThis.scrollX || 0;
+ const currentY = globalThis.scrollY || 0;
+ const vw = globalThis.innerWidth || 1280;
+ const vh = globalThis.innerHeight || 720;
+ const align = (mode, start, end, size, viewportSize, current) => {
+ if (mode === "start") return current + start;
+ if (mode === "center") return current + start - (viewportSize - size) / 2;
+ if (mode === "end") return current + end - viewportSize;
+ // CSSOM View's nearest alignment: do nothing when fully visible or when
+ // the box spans both viewport edges; otherwise move the closer edge in.
+ if ((start >= 0 && end <= viewportSize) || (start < 0 && end > viewportSize)) {
+ return current;
+ }
+ if (start < 0) return current + start;
+ if (end > viewportSize) return current + end - viewportSize;
+ return current;
+ };
+ const left = align(inline, rect.left, rect.right, rect.width, vw, currentX);
+ const top = align(block, rect.top, rect.bottom, rect.height, vh, currentY);
+ globalThis.scrollTo({ left, top, behavior: arg && arg.behavior });
+ }
// scrollTo/scrollBy/scroll accept either (x, y) or a ScrollToOptions object.
// The setters fire a scroll event of their own, so suppress the per-axis ones
// here and emit a single event for the whole movement, the way a real browser
@@ -5498,104 +5660,182 @@ globalThis.NodeFilter = {
// ResizeObserver is defined earlier with real per-target firing; the stub
// that previously lived here was a no-op that clobbered the real class.
//
-// IntersectionObserver: without a layout engine we can't compute real
-// intersection geometry, so every observed target is treated as fully
-// in-viewport (`isIntersecting: true`, `intersectionRatio: 1`). Real
-// libraries lean on this in three patterns we must support:
-//
-// 1. Lazy load: observe(img) -> first intersection -> load src -> unobserve.
-// One fire is enough — covered by the initial microtask fire.
-// 2. Infinite scroll: observe(sentinel) -> on intersection load more ->
-// new sentinel mounts -> fire again. Needs re-fires as DOM grows.
-// 3. Reveal-on-scroll animations: observe(card) -> isIntersecting flips
-// true once and an animation runs. One fire is enough.
-//
-// To cover (2) without spinning forever, we burst-fire at an exponential
-// backoff schedule and ALSO re-fire whenever the DOM mutates (a strong
-// signal that the page just rendered something new). Per-observer total
-// fire cap stops us from looping on a never-disconnected observer.
+// Viewport IntersectionObserver. Render builds provide real, scroll-relative
+// target boxes through getBoundingClientRect. Element scroll-container roots
+// remain intentionally unsupported until their offsets participate in layout.
globalThis.__intersectionObservers = [];
+function _ioRect(x, y, width, height) {
+ return {
+ x, y, width, height,
+ top: y, left: x, right: x + width, bottom: y + height,
+ toJSON() { return this; },
+ };
+}
+function _ioMargins(value) {
+ const parts = String(value || "0px").trim().split(/\s+/);
+ if (parts.length < 1 || parts.length > 4) return null;
+ const parsed = parts.map((part) => {
+ const match = /^([-+]?(?:\d+(?:\.\d*)?|\.\d+))(px|%)$/.exec(part);
+ return match ? { value: Number(match[1]), unit: match[2] } : null;
+ });
+ if (parsed.some((part) => !part)) return null;
+ if (parsed.length === 1) return [parsed[0], parsed[0], parsed[0], parsed[0]];
+ if (parsed.length === 2) return [parsed[0], parsed[1], parsed[0], parsed[1]];
+ if (parsed.length === 3) return [parsed[0], parsed[1], parsed[2], parsed[1]];
+ return parsed;
+}
+function _ioUnsupportedRoot() {
+ const error = new Error(
+ "Obscura IntersectionObserver element roots require element scroll-container layout"
+ );
+ error.name = "NotSupportedError";
+ return error;
+}
globalThis.IntersectionObserver = class IntersectionObserver {
constructor(callback, options) {
+ if (typeof callback !== "function") {
+ throw new TypeError("IntersectionObserver callback must be a function");
+ }
this._callback = callback;
this._options = options || {};
+ if (this._options.root != null) throw _ioUnsupportedRoot();
+ this._margins = _ioMargins(this._options.rootMargin || "0px");
+ if (!this._margins) throw new SyntaxError("Invalid IntersectionObserver rootMargin");
+ const raw = this._options.threshold == null
+ ? [0]
+ : (Array.isArray(this._options.threshold) ? this._options.threshold : [this._options.threshold]);
+ this._thresholds = [...new Set(raw.map(Number))].sort((a, b) => a - b);
+ if (!this._thresholds.length) this._thresholds = [0];
+ if (this._thresholds.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) {
+ throw new RangeError("IntersectionObserver threshold must be between 0 and 1");
+ }
this._targets = new Set();
+ this._previous = new Map();
+ this._records = [];
this._connected = true;
- this._fireCount = 0;
+ this._deliveryPending = false;
globalThis.__intersectionObservers.push(this);
}
- _fireFor(targets) {
- if (!this._connected || !targets.length || this._fireCount >= 256) return;
- this._fireCount++;
- const records = targets.map(target => ({
- target,
- isIntersecting: true,
- intersectionRatio: 1,
- boundingClientRect: target.getBoundingClientRect
- ? target.getBoundingClientRect()
- : { x: 0, y: 0, width: 100, height: 20, top: 0, left: 0, right: 100, bottom: 20 },
- intersectionRect: target.getBoundingClientRect
- ? target.getBoundingClientRect()
- : { x: 0, y: 0, width: 100, height: 20, top: 0, left: 0, right: 100, bottom: 20 },
- rootBounds: { x: 0, y: 0, width: 1280, height: 720, top: 0, left: 0, right: 1280, bottom: 720 },
- time: Date.now(),
- }));
- try { this._callback(records, this); } catch (e) { /* IO callbacks must not propagate */ }
+ _rootBounds() {
+ const width = globalThis.innerWidth || 1280;
+ const height = globalThis.innerHeight || 720;
+ const resolve = (margin, basis) =>
+ margin.unit === "%" ? margin.value * basis / 100 : margin.value;
+ // IntersectionObserver resolves every rootMargin percentage against the
+ // root rectangle's width, including the block-axis sides.
+ const top = resolve(this._margins[0], width);
+ const right = resolve(this._margins[1], width);
+ const bottom = resolve(this._margins[2], width);
+ const left = resolve(this._margins[3], width);
+ return _ioRect(-left, -top, width + left + right, height + top + bottom);
}
- observe(el) {
- if (!el || !this._connected) return;
- if (this._targets.has(el)) return;
- this._targets.add(el);
- Promise.resolve().then(() => this._fireFor([el]));
- // Exponential burst to cover infinite-scroll sentinels that "re-arm"
- // after content lands. Without a real scroll/layout signal, we fake the
- // re-fire schedule. Beyond ~10s the page has usually settled.
- [120, 500, 1500, 3500, 7000].forEach(delay => {
- setTimeout(() => {
- if (this._connected && this._targets.has(el)) this._fireFor([el]);
- }, delay);
+ _entry(target) {
+ const rect = target.getBoundingClientRect();
+ const root = this._rootBounds();
+ const left = Math.max(rect.left, root.left);
+ const top = Math.max(rect.top, root.top);
+ const right = Math.min(rect.right, root.right);
+ const bottom = Math.min(rect.bottom, root.bottom);
+ const edgesTouch = right >= left && bottom >= top;
+ const width = Math.max(0, right - left);
+ const height = Math.max(0, bottom - top);
+ const targetArea = Math.max(0, rect.width) * Math.max(0, rect.height);
+ const area = width * height;
+ const isIntersecting = edgesTouch;
+ return {
+ target,
+ isIntersecting,
+ intersectionRatio: targetArea > 0 ? area / targetArea : (isIntersecting ? 1 : 0),
+ boundingClientRect: _ioRect(rect.x, rect.y, rect.width, rect.height),
+ intersectionRect: _ioRect(left, top, width, height),
+ rootBounds: root,
+ time: performance.now(),
+ };
+ }
+ _thresholdIndex(ratio) {
+ let index = 0;
+ while (index < this._thresholds.length && this._thresholds[index] <= ratio) index++;
+ return index;
+ }
+ _queueChanged(target, forceInitial) {
+ const entry = this._entry(target);
+ const previous = this._previous.get(target);
+ const changed = forceInitial || !previous ||
+ previous.isIntersecting !== entry.isIntersecting ||
+ this._thresholdIndex(previous.intersectionRatio) !==
+ this._thresholdIndex(entry.intersectionRatio);
+ this._previous.set(target, {
+ isIntersecting: entry.isIntersecting,
+ intersectionRatio: entry.intersectionRatio,
+ });
+ if (changed) this._records.push(entry);
+ }
+ _check(targets, forceInitial) {
+ if (!this._connected) return;
+ for (const target of targets) {
+ if (this._targets.has(target)) this._queueChanged(target, !!forceInitial);
+ }
+ if (!this._records.length || this._deliveryPending) return;
+ this._deliveryPending = true;
+ Promise.resolve().then(() => {
+ this._deliveryPending = false;
+ if (!this._connected || !this._records.length) return;
+ const records = this.takeRecords();
+ try { this._callback(records, this); } catch (e) {}
});
}
- unobserve(el) { this._targets.delete(el); }
+ observe(el) {
+ if (!el || !this._connected || this._targets.has(el)) return;
+ this._targets.add(el);
+ this._previous.delete(el);
+ this._check([el], true);
+ }
+ unobserve(el) {
+ this._targets.delete(el);
+ this._previous.delete(el);
+ }
disconnect() {
this._connected = false;
this._targets.clear();
- const idx = globalThis.__intersectionObservers.indexOf(this);
- if (idx >= 0) globalThis.__intersectionObservers.splice(idx, 1);
+ this._previous.clear();
+ this._records.length = 0;
+ const index = globalThis.__intersectionObservers.indexOf(this);
+ if (index >= 0) globalThis.__intersectionObservers.splice(index, 1);
}
- takeRecords() { return []; }
- get root() { return this._options.root || null; }
- get rootMargin() { return this._options.rootMargin || "0px 0px 0px 0px"; }
- get thresholds() {
- const t = this._options.threshold;
- if (t == null) return [0];
- return Array.isArray(t) ? t.slice() : [t];
+ takeRecords() { return this._records.splice(0); }
+ get root() { return null; }
+ get rootMargin() {
+ return this._margins.map((margin) => `${margin.value}${margin.unit}`).join(" ");
}
+ get thresholds() { return this._thresholds.slice(); }
};
-// When the DOM mutates (e.g. infinite scroll loads a batch of items), re-fire
-// every active IntersectionObserver so libraries observing dynamic content
-// see a fresh isIntersecting=true event. Uses the same per-observer fire cap
-// to prevent runaway loops if the page is mutating in a tight cycle.
(function() {
- const reFire = () => {
- for (const obs of globalThis.__intersectionObservers) {
- if (!obs._connected) continue;
- const ts = [...obs._targets];
- if (ts.length) obs._fireFor(ts);
- }
- };
- // Lazy-attach a single MutationObserver on document.body once the page is
- // ready, debounced via a microtask so a flurry of mutations only triggers
- // one IO sweep.
let pending = false;
+ const recompute = () => {
+ if (pending) return;
+ pending = true;
+ Promise.resolve().then(() => {
+ pending = false;
+ for (const observer of globalThis.__intersectionObservers) {
+ if (observer._connected && observer._targets.size) {
+ observer._check([...observer._targets], false);
+ }
+ }
+ });
+ };
+ globalThis.__obscura_recompute_intersections = recompute;
+ globalThis.addEventListener("resize", recompute);
const wireUp = () => {
if (!globalThis.document?.body) return;
- const mo = new MutationObserver(() => {
- if (pending) return;
- pending = true;
- Promise.resolve().then(() => { pending = false; reFire(); });
- });
- try { mo.observe(globalThis.document.body, {childList: true, subtree: true}); } catch {}
+ const observer = new MutationObserver(recompute);
+ try {
+ observer.observe(globalThis.document.body, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ characterData: true,
+ });
+ } catch {}
};
if (globalThis.document?.body) wireUp();
else Promise.resolve().then(wireUp);
@@ -6718,21 +6958,312 @@ globalThis.screenLeft = 0; globalThis.screenTop = 0;
globalThis.pageXOffset = 0; globalThis.pageYOffset = 0;
globalThis.scrollX = 0; globalThis.scrollY = 0;
+// Keep the JavaScript capability surface aligned with the declarations the
+// renderer actually implements. Reporting an unknown declaration as supported
+// is not harmless: Tailwind and other framework sheets use negative probes to
+// select legacy-browser fallbacks, which can replace their modern cascade.
+const _CSS_SUPPORTED_DECLARATIONS = new Set((
+ "display width height min-width min-height max-width max-height box-sizing aspect-ratio " +
+ "margin margin-top margin-right margin-bottom margin-left margin-inline margin-inline-start " +
+ "margin-inline-end margin-block margin-block-start margin-block-end padding padding-top " +
+ "padding-right padding-bottom padding-left padding-inline padding-inline-start padding-inline-end " +
+ "padding-block padding-block-start padding-block-end border-radius border border-width " +
+ "border-top-width border-right-width border-bottom-width border-left-width border-top border-right " +
+ "border-bottom border-left background background-color background-image background-size " +
+ "background-position background-clip -webkit-background-clip mask-image -webkit-mask-image " +
+ "mask-size -webkit-mask-size mask-repeat -webkit-mask-repeat color -webkit-text-fill-color fill " +
+ "stroke stroke-width border-color font-size font font-weight font-family font-style text-align " +
+ "text-transform text-decoration text-decoration-line line-height align-items justify-items " +
+ "place-items align-self justify-self place-self align-content justify-content place-content " +
+ "flex-direction flex-wrap flex-grow flex-shrink flex-basis flex order position float object-fit " +
+ "top right bottom left inset overflow overflow-x overflow-y scrollbar-gutter visibility opacity animation " +
+ "animation-name animation-fill-mode animation-iteration-count z-index clear vertical-align " +
+ "list-style list-style-type gap grid-gap row-gap grid-row-gap column-gap grid-column-gap " +
+ "border-spacing border-collapse grid-template-columns grid-template-rows grid-template-areas " +
+ "grid-template grid grid-auto-flow grid-area grid-column grid-row grid-column-start " +
+ "grid-column-end grid-row-start grid-row-end transform filter backdrop-filter " +
+ "-webkit-backdrop-filter perspective contain will-change content-visibility box-shadow " +
+ "-webkit-box-shadow"
+).split(/\s+/));
+
+const _CSS_SUPPORTED_COLOR_NAMES = new Set((
+ "transparent white black gray grey silver lightgray lightgrey darkgray darkgrey whitesmoke " +
+ "gainsboro red green lime blue navy yellow orange purple maroon teal aqua cyan fuchsia magenta " +
+ "olive darkblue mediumblue royalblue dodgerblue cornflowerblue steelblue deepskyblue skyblue " +
+ "lightskyblue lightblue powderblue cadetblue slateblue darkslateblue midnightblue indigo " +
+ "darkgreen forestgreen seagreen mediumseagreen limegreen yellowgreen olivedrab darkolivegreen " +
+ "greenyellow lightgreen palegreen springgreen mediumaquamarine aquamarine turquoise " +
+ "mediumturquoise darkcyan crimson firebrick darkred indianred tomato orangered coral salmon " +
+ "lightsalmon darksalmon hotpink deeppink pink lightpink palevioletred mediumvioletred violet " +
+ "orchid plum mediumpurple blueviolet darkviolet darkorchid darkmagenta lavender thistle gold " +
+ "goldenrod darkgoldenrod khaki darkkhaki peachpuff moccasin papayawhip wheat tan burlywood " +
+ "sandybrown peru chocolate sienna saddlebrown brown rosybrown darkorange lightyellow " +
+ "lightgoldenrodyellow lemonchiffon beige ivory azure mintcream honeydew snow seashell linen " +
+ "oldlace floralwhite ghostwhite aliceblue lavenderblush mistyrose cornsilk antiquewhite bisque " +
+ "blanchedalmond navajowhite dimgray dimgrey slategray slategrey lightslategray lightslategrey " +
+ "darkslategray darkslategrey"
+).split(/\s+/));
+
+function _cssSupportsColor(value) {
+ const raw = value.trim();
+ const lower = raw.toLowerCase();
+ if (_CSS_SUPPORTED_COLOR_NAMES.has(lower)) return true;
+ if (/^#[0-9a-f]{3,4}(?:[0-9a-f]{2}){0,2}$/i.test(lower)) {
+ return [4, 5, 7, 9].includes(lower.length);
+ }
+ if (lower.startsWith("var(") && lower.endsWith(")")) {
+ const comma = _cssTopLevelComma(raw.slice(4, -1));
+ return comma >= 0 && _cssSupportsColor(raw.slice(4 + comma + 1, -1));
+ }
+ if (/^rgba?\(/.test(lower) && lower.endsWith(")")) {
+ // Chromium accepts relative colors and Tailwind uses this exact syntax as
+ // a browser-family probe. Painting every relative-color expression is a
+ // separate concern from recognizing its valid conditional syntax.
+ if (/\bfrom\b/.test(lower)) {
+ return /^rgba?\(\s*from\s+\S[\s\S]*\)$/i.test(raw);
+ }
+ const parts = lower.slice(lower.indexOf("(") + 1, -1)
+ .split(/[,\s/]+/).filter(Boolean);
+ return parts.length >= 3 && parts.slice(0, 3)
+ .every((part) => /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)%?$/.test(part));
+ }
+ if (/^hsla?\(/.test(lower) && lower.endsWith(")")) {
+ const parts = lower.slice(lower.indexOf("(") + 1, -1)
+ .split(/[,\s/]+/).filter(Boolean);
+ return parts.length >= 3 && parts.slice(0, 3).every((part) =>
+ /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:deg|%)?$/.test(part));
+ }
+ if (/^okl(?:ab|ch)\(/.test(lower) && lower.endsWith(")")) {
+ const parts = lower.slice(lower.indexOf("(") + 1, -1)
+ .split(/[,\s/]+/).filter(Boolean);
+ return parts.length >= 3 && parts.slice(0, 3).every((part) =>
+ /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:deg|%)?$/.test(part));
+ }
+ if (lower.startsWith("light-dark(") && lower.endsWith(")")) {
+ const parts = _cssSplitTopLevel(
+ raw.slice("light-dark(".length, -1),
+ ","
+ );
+ return !!parts && parts.length === 2 &&
+ parts.every((part) => part.trim() && _cssSupportsColor(part));
+ }
+ if (lower.startsWith("color-mix(") && lower.endsWith(")")) {
+ const parts = _cssSplitTopLevel(lower.slice("color-mix(".length, -1), ",");
+ if (!parts || parts.length < 3 || !/^in\s+\S+$/i.test(parts[0].trim())) return false;
+ const color = (part) => _cssSupportsColor(part.trim().replace(/\s+[-+]?(?:\d+(?:\.\d*)?|\.\d+)%\s*$/, ""));
+ return color(parts[1]) && color(parts[2]);
+ }
+ return false;
+}
+
+function _cssTopLevelComma(text) {
+ let depth = 0, quote = "";
+ for (let i = 0; i < text.length; i++) {
+ const character = text[i];
+ if (quote) {
+ if (character === "\\") i++;
+ else if (character === quote) quote = "";
+ continue;
+ }
+ if (character === "'" || character === '"') quote = character;
+ else if (character === "(") depth++;
+ else if (character === ")") depth--;
+ else if (character === "," && depth === 0) return i;
+ if (depth < 0) return -1;
+ }
+ return -1;
+}
+
+function _cssSupportsDeclaration(name, value) {
+ name = name.trim().toLowerCase();
+ value = value.trim();
+ if (!value) return false;
+ if (name.startsWith("--")) return name.length > 2;
+ if (!_CSS_SUPPORTED_DECLARATIONS.has(name)) return false;
+ const lower = value.toLowerCase();
+ if (["initial", "inherit", "unset", "revert", "revert-layer"].includes(lower)) return true;
+ if (name === "display") {
+ return ["none", "flex", "inline-flex", "inline", "inline-block", "grid",
+ "inline-grid", "block", "flow-root", "contents"].includes(lower);
+ }
+ if (name === "position") {
+ return ["static", "relative", "absolute", "fixed", "sticky"].includes(lower);
+ }
+ if (name === "box-sizing") return ["content-box", "border-box"].includes(lower);
+ if (name === "float") return ["none", "left", "right"].includes(lower);
+ if (name === "object-fit") {
+ return ["fill", "contain", "cover", "none", "scale-down"].includes(lower);
+ }
+ if (name === "visibility") return ["visible", "hidden", "collapse"].includes(lower);
+ if (name === "scrollbar-gutter") {
+ return lower === "auto" || lower === "stable" || lower === "stable both-edges";
+ }
+ if (["color", "-webkit-text-fill-color", "background-color", "border-color"].includes(name)) {
+ return _cssSupportsColor(value);
+ }
+ return true;
+}
+
+// Return the contents only when one pair of parentheses encloses the complete
+// expression. Declaration leaves such as `(display:grid)` are then evaluated
+// by the same path as the two-argument overload.
+function _cssEnclosingGroup(text) {
+ if (!text.startsWith("(")) return null;
+ let depth = 0, quote = "";
+ for (let i = 0; i < text.length; i++) {
+ const character = text[i];
+ if (quote) {
+ if (character === "\\") i++;
+ else if (character === quote) quote = "";
+ continue;
+ }
+ if (character === "'" || character === '"') quote = character;
+ else if (character === "(") depth++;
+ else if (character === ")") {
+ depth--;
+ if (depth < 0) return null;
+ if (depth === 0) return i === text.length - 1 ? text.slice(1, i) : null;
+ }
+ }
+ return null;
+}
+
+function _cssSplitTopLevel(text, operator) {
+ const parts = [];
+ const isWord = /^[a-z]+$/i.test(operator);
+ let start = 0, depth = 0, quote = "";
+ for (let i = 0; i < text.length; i++) {
+ const character = text[i];
+ if (quote) {
+ if (character === "\\") i++;
+ else if (character === quote) quote = "";
+ continue;
+ }
+ if (character === "'" || character === '"') {
+ quote = character;
+ continue;
+ }
+ if (character === "(" || character === "[") depth++;
+ else if (character === ")" || character === "]") {
+ depth--;
+ if (depth < 0) return null;
+ } else if (depth === 0 &&
+ text.slice(i, i + operator.length).toLowerCase() === operator.toLowerCase() &&
+ (!isWord || (i > 0 && /\s/.test(text[i - 1]) &&
+ i + operator.length < text.length && /\s/.test(text[i + operator.length])))) {
+ const part = text.slice(start, i).trim();
+ if (!part) return null;
+ parts.push(part);
+ i += operator.length - 1;
+ start = i + 1;
+ }
+ }
+ if (depth !== 0 || quote || !parts.length) return null;
+ const tail = text.slice(start).trim();
+ if (!tail) return null;
+ parts.push(tail);
+ return parts;
+}
+
+function _cssHasTopLevelComma(text) {
+ let depth = 0, quote = "";
+ for (let i = 0; i < text.length; i++) {
+ const character = text[i];
+ if (quote) {
+ if (character === "\\") i++;
+ else if (character === quote) quote = "";
+ continue;
+ }
+ if (character === "\\") { i++; continue; }
+ if (character === "'" || character === '"') quote = character;
+ else if (character === "(" || character === "[") depth++;
+ else if (character === ")" || character === "]") depth--;
+ else if (character === "," && depth === 0) return true;
+ if (depth < 0) return false;
+ }
+ return false;
+}
+
+const _CSS_SUPPORTED_SIMPLE_PSEUDOS = new Set((
+ "hover active focus focus-visible focus-within enabled disabled checked link any-link visited " +
+ "first-child last-child only-child root empty scope first-of-type last-of-type only-of-type " +
+ "before after"
+).split(/\s+/));
+const _CSS_SUPPORTED_FUNCTIONAL_PSEUDOS = new Set((
+ "nth-child nth-of-type nth-last-child nth-last-of-type is where has host not"
+).split(/\s+/));
+
+function _cssSupportsSelector(selector) {
+ selector = selector.trim();
+ if (!selector || /[{};]/.test(selector)) return false;
+ const split = _cssSplitTopLevel(selector, ",");
+ if (!split && _cssHasTopLevelComma(selector)) return false;
+ const selectors = split || [selector];
+ return selectors.every((part) => {
+ part = part.trim();
+ if (!part || /^[>+~]/.test(part) || /[>+~]\s*$/.test(part)) return false;
+ let parens = 0, brackets = 0, quote = "";
+ for (let i = 0; i < part.length; i++) {
+ const character = part[i];
+ if (quote) {
+ if (character === "\\") i++;
+ else if (character === quote) quote = "";
+ continue;
+ }
+ if (character === "\\") { i++; continue; }
+ if (character === "'" || character === '"') quote = character;
+ else if (character === "(") parens++;
+ else if (character === ")") parens--;
+ else if (character === "[") brackets++;
+ else if (character === "]") brackets--;
+ else if (character === ":" && brackets === 0) {
+ const doubleColon = part[i + 1] === ":";
+ let end = i + (doubleColon ? 2 : 1);
+ const start = end;
+ while (end < part.length && /[a-z0-9_-]/i.test(part[end])) end++;
+ if (end === start) return false;
+ const name = part.slice(start, end).toLowerCase();
+ const functional = part[end] === "(";
+ if (doubleColon) {
+ if (functional || !["before", "after"].includes(name)) return false;
+ } else if (functional) {
+ if (!_CSS_SUPPORTED_FUNCTIONAL_PSEUDOS.has(name)) return false;
+ } else if (!_CSS_SUPPORTED_SIMPLE_PSEUDOS.has(name)) {
+ return false;
+ }
+ i = end - 1;
+ }
+ if (parens < 0 || brackets < 0) return false;
+ }
+ return !quote && parens === 0 && brackets === 0;
+ });
+}
+
+function _cssSupportsCondition(condition) {
+ condition = condition.trim();
+ if (!condition) return false;
+ const grouped = _cssEnclosingGroup(condition);
+ if (grouped !== null) return _cssSupportsCondition(grouped);
+ if (/^not\s/i.test(condition)) return !_cssSupportsCondition(condition.slice(3).trim());
+ const orParts = _cssSplitTopLevel(condition, "or");
+ if (orParts) return orParts.some(_cssSupportsCondition);
+ const andParts = _cssSplitTopLevel(condition, "and");
+ if (andParts) return andParts.every(_cssSupportsCondition);
+ if (/^selector\(/i.test(condition) && condition.endsWith(")")) {
+ return _cssSupportsSelector(condition.slice(condition.indexOf("(") + 1, -1));
+ }
+ const colon = condition.indexOf(":");
+ if (colon < 0) return false;
+ return _cssSupportsDeclaration(condition.slice(0, colon), condition.slice(colon + 1));
+}
+
globalThis.CSS = {
supports(prop, value){
try {
- var p, v;
- if (arguments.length >= 2) { p = String(prop).trim(); v = String(value).trim(); }
- else {
- var cond = String(prop).trim().replace(/^\(+|\)+$/g, "").trim();
- var idx = cond.indexOf(":");
- if (idx === -1) return false;
- p = cond.slice(0, idx).trim(); v = cond.slice(idx + 1).trim();
+ if (arguments.length >= 2) {
+ return _cssSupportsDeclaration(String(prop), String(value));
}
- if (!p || !v) return false;
- // The engine renders standard CSS; report it as supported so feature-gated
- // SPAs don't bail to /unsupported. (Previous stub always returned false.)
- return true;
+ return _cssSupportsCondition(String(prop));
} catch (e) { return false; }
},
escape(s){ return s; }
@@ -8290,8 +8821,9 @@ URL.revokeObjectURL = function(url) {
//
// The page offset is stored on the scrolling element rather than in separate
// window state, so window.scrollY and document.scrollingElement.scrollTop are
-// two views of one value, which is what pages assume. As with #431 there is no
-// layout, so the offset still cannot be clamped to a real maximum.
+// two views of one value, which is what pages assume. Render builds clamp that
+// shared root offset against measured document overflow; non-render builds keep
+// the legacy synthetic offset used by automation-only consumers.
function _scrollRoot() {
const doc = globalThis.document;
return (doc && doc.scrollingElement) || null;
@@ -8299,6 +8831,8 @@ function _scrollRoot() {
function _windowScroll(x, y, relative) {
const root = _scrollRoot();
if (!root) return;
+ const beforeLeft = root.scrollLeft || 0;
+ const beforeTop = root.scrollTop || 0;
let left, top;
if (x !== null && typeof x === 'object') { left = x.left; top = x.top; }
else { left = x; top = y; }
@@ -8308,6 +8842,9 @@ function _windowScroll(x, y, relative) {
if (top !== undefined) {
root.scrollTop = (relative ? (root.scrollTop || 0) : 0) + (+top || 0);
}
+ if ((root.scrollLeft || 0) === beforeLeft && (root.scrollTop || 0) === beforeTop) {
+ return;
+ }
// Async, matching the element path #431 added. Dispatched at the document
// AND the window: a page scroll event reaches both in Chrome, but
// Document.dispatchEvent here runs only its own listeners and does not
diff --git a/crates/obscura-js/src/lib.rs b/crates/obscura-js/src/lib.rs
index 7f6e2b3..4c14136 100644
--- a/crates/obscura-js/src/lib.rs
+++ b/crates/obscura-js/src/lib.rs
@@ -14,4 +14,4 @@ pub use v8_flags::set_v8_flags;
// Screenshot rasterization (PNG bytes) from the render layer. Available when the
// render feature (which enables obscura-render/paint) is compiled in.
#[cfg(feature = "render")]
-pub use obscura_render::screenshot_png;
+pub use obscura_render::{screenshot_png, screenshot_png_scrolled};
diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs
index f889932..81ad657 100644
--- a/crates/obscura-js/src/ops.rs
+++ b/crates/obscura-js/src/ops.rs
@@ -64,6 +64,14 @@ pub struct JsNetworkEvent {
pub timestamp: f64,
}
+#[cfg(feature = "render")]
+pub struct LayoutCache {
+ rects: std::collections::HashMap,
+ viewport_fixed: std::collections::HashSet,
+ sticky: obscura_render::StickyLayout,
+ content_size: (f32, f32),
+}
+
pub struct ObscuraState {
pub dom: Option,
pub url: String,
@@ -104,14 +112,17 @@ pub struct ObscuraState {
// Network.requestWillBeSent / responseReceived for them (issue #406).
pub js_network_events: Vec,
/// Lazy layout cache from obscura-render, plus the CSS-pixel viewport. The
- /// cache is computed on first geometry read and cleared on navigation. It is
- /// not invalidated on every DOM mutation yet (a follow-up), so geometry read
- /// mid-script may lag a frame; reading after settle is reliable.
+ /// cache is computed on first geometry read and cleared on navigation,
+ /// viewport changes, and DOM/style mutations that can affect layout.
#[cfg(feature = "render")]
- pub layout_cache:
- Option>,
+ pub layout_cache: Option,
#[cfg(feature = "render")]
pub viewport: (f32, f32),
+ /// Root scrolling offset in CSS pixels. With render enabled this is
+ /// clamped against the cached document overflow and is the single source
+ /// read by CSSOM geometry and screenshot paint.
+ #[cfg(feature = "render")]
+ pub scroll_offset: (f32, f32),
}
impl ObscuraState {
@@ -141,6 +152,8 @@ impl ObscuraState {
layout_cache: None,
#[cfg(feature = "render")]
viewport: (1280.0, 720.0),
+ #[cfg(feature = "render")]
+ scroll_offset: (0.0, 0.0),
}
}
}
@@ -180,8 +193,26 @@ fn op_dom(state: &OpState, #[string] cmd: String, #[string] arg1: String, #[stri
}
fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> String {
- let gs = state.borrow::().clone();
- let gs = gs.borrow();
+ let shared = state.borrow::().clone();
+ #[cfg(feature = "render")]
+ if matches!(
+ cmd.as_str(),
+ "set_attribute"
+ | "remove_attribute"
+ | "append_child"
+ | "remove_child"
+ | "insert_before"
+ | "set_inner_html"
+ | "set_inner_html_context"
+ | "set_text_content"
+ ) {
+ // Any attribute can participate in an author selector, so even a
+ // seemingly non-geometric attribute may change computed layout.
+ // Detached-node creation and every read-only op deliberately skip this
+ // invalidation; the next geometry/scroll read rebuilds at most once.
+ shared.borrow_mut().layout_cache = None;
+ }
+ let gs = shared.borrow();
let dom = match &gs.dom {
Some(d) => d,
None => return "null".to_string(),
@@ -2010,7 +2041,12 @@ pub fn build_extension() -> Extension {
// Only registered when the render feature is compiled in. bootstrap.js
// probes with typeof before calling, so the op's absence is a clean fallback.
#[cfg(feature = "render")]
- ops.push(op_layout_geometry());
+ {
+ ops.push(op_layout_geometry());
+ ops.push(op_layout_metrics());
+ ops.push(op_scroll_offset());
+ ops.push(op_scroll_to());
+ }
Extension {
name: "obscura_dom",
ops: std::borrow::Cow::Owned(ops),
@@ -2018,35 +2054,127 @@ pub fn build_extension() -> Extension {
}
}
+#[cfg(feature = "render")]
+fn ensure_layout_cache(state: &mut ObscuraState) -> Option<&LayoutCache> {
+ if state.layout_cache.is_none() {
+ let dom = state.dom.as_ref()?;
+ let laid = obscura_render::layout_dom(dom, state.viewport);
+ let viewport_fixed = laid.viewport_fixed_nodes(dom);
+ let sticky = laid.root_sticky_layout(dom, state.viewport);
+ let content_size = laid.scrolling_content_size(dom, state.viewport);
+ let mut rects = laid.rects;
+ for (&id, rect) in &mut rects {
+ if let Some((tx, ty)) = laid.translates.get(&id) {
+ rect.x += tx;
+ rect.y += ty;
+ }
+ }
+ state.layout_cache = Some(LayoutCache {
+ rects,
+ viewport_fixed,
+ sticky,
+ content_size,
+ });
+ }
+ state.layout_cache.as_ref()
+}
+
+#[cfg(feature = "render")]
+pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32)) -> (f32, f32) {
+ let viewport = state.viewport;
+ let content_size = ensure_layout_cache(state)
+ .map(|cache| cache.content_size)
+ .unwrap_or(viewport);
+ let max_x = (content_size.0 - viewport.0).max(0.0);
+ let max_y = (content_size.1 - viewport.1).max(0.0);
+ let x = if requested.0.is_finite() {
+ requested.0.clamp(0.0, max_x)
+ } else {
+ 0.0
+ };
+ let y = if requested.1.is_finite() {
+ requested.1.clamp(0.0, max_y)
+ } else {
+ 0.0
+ };
+ state.scroll_offset = (x, y);
+ state.scroll_offset
+}
+
/// Real border-box geometry for an element from the obscura-render layout
/// cache. The cache is computed lazily on first read and cleared on navigation
-/// (see `set_dom`). Returns JSON `{"x","y","width","height"}` in CSS pixels, or
-/// an empty string when the node has no box. Feature-gated.
+/// (see `set_dom`). Coordinates are viewport-relative after the shared root
+/// scroll offset, except for viewport-fixed subtrees. Returns JSON
+/// `{"x","y","width","height"}` in CSS pixels, or an empty string when the
+/// node has no box. Feature-gated.
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_layout_geometry(state: &OpState, #[string] nid_str: String) -> String {
let shared = state.borrow::().clone();
- {
- let mut gs = shared.borrow_mut();
- if gs.layout_cache.is_none() {
- if let Some(dom) = &gs.dom {
- let viewport = gs.viewport;
- gs.layout_cache = Some(obscura_render::layout_dom(dom, viewport).rects);
- }
- }
- }
- let gs = shared.borrow();
let nid: u32 = nid_str.parse().unwrap_or(0);
- if let Some(rect) = gs
- .layout_cache
- .as_ref()
- .and_then(|c| c.get(&obscura_dom::tree::NodeId::new(nid)))
- {
+ let nid = obscura_dom::tree::NodeId::new(nid);
+ let mut gs = shared.borrow_mut();
+ let scroll = gs.scroll_offset;
+ let viewport = gs.viewport;
+ if let Some(cache) = ensure_layout_cache(&mut gs) {
+ let Some(rect) = cache.rects.get(&nid) else {
+ return String::new();
+ };
+ let sticky_offset = cache.sticky.translation_for(nid, viewport, scroll);
+ let (x, y) = if cache.viewport_fixed.contains(&nid) {
+ (rect.x, rect.y)
+ } else {
+ (
+ rect.x + sticky_offset.0 - scroll.0,
+ rect.y + sticky_offset.1 - scroll.1,
+ )
+ };
+ let viewport_fixed = cache.viewport_fixed.contains(&nid);
return format!(
- "{{\"x\":{},\"y\":{},\"width\":{},\"height\":{}}}",
- rect.x, rect.y, rect.width, rect.height
+ "{{\"x\":{},\"y\":{},\"width\":{},\"height\":{},\"viewportFixed\":{}}}",
+ x, y, rect.width, rect.height, viewport_fixed
);
}
String::new()
}
+
+/// Root scrolling overflow in CSS pixels. The JS CSSOM probes this op only in
+/// render builds; default scraping builds retain their deliberately unbounded
+/// synthetic scrolling behavior.
+#[cfg(feature = "render")]
+#[op2]
+#[string]
+fn op_layout_metrics(state: &OpState) -> String {
+ let shared = state.borrow::().clone();
+ let mut gs = shared.borrow_mut();
+ let viewport = gs.viewport;
+ let content = ensure_layout_cache(&mut gs)
+ .map(|cache| cache.content_size)
+ .unwrap_or(viewport);
+ format!(
+ "{{\"scrollWidth\":{},\"scrollHeight\":{},\"clientWidth\":{},\"clientHeight\":{}}}",
+ content.0, content.1, viewport.0, viewport.1
+ )
+}
+
+#[cfg(feature = "render")]
+#[op2]
+#[string]
+fn op_scroll_offset(state: &OpState) -> String {
+ let shared = state.borrow::().clone();
+ let mut gs = shared.borrow_mut();
+ let requested = gs.scroll_offset;
+ let (x, y) = clamp_scroll_offset(&mut gs, requested);
+ format!("{{\"x\":{},\"y\":{}}}", x, y)
+}
+
+#[cfg(feature = "render")]
+#[op2]
+#[string]
+fn op_scroll_to(state: &OpState, x: f64, y: f64) -> String {
+ let shared = state.borrow::().clone();
+ let mut gs = shared.borrow_mut();
+ let (x, y) = clamp_scroll_offset(&mut gs, (x as f32, y as f32));
+ format!("{{\"x\":{},\"y\":{}}}", x, y)
+}
diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs
index 3885eea..6e944f9 100644
--- a/crates/obscura-js/src/runtime.rs
+++ b/crates/obscura-js/src/runtime.rs
@@ -10,6 +10,8 @@ use obscura_dom::DomTree;
pub use deno_core::v8::IsolateHandle;
use crate::module_loader::ObscuraModuleLoader;
+#[cfg(feature = "render")]
+use crate::ops::clamp_scroll_offset;
use crate::ops::{build_extension, ObscuraState, StoredNetworkResponseBody};
static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH"));
@@ -195,6 +197,7 @@ impl ObscuraJsRuntime {
#[cfg(feature = "render")]
{
gs.layout_cache = None;
+ gs.scroll_offset = (0.0, 0.0);
}
}
@@ -285,6 +288,12 @@ impl ObscuraJsRuntime {
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
return;
}
+ #[cfg(feature = "render")]
+ {
+ let mut state = self.state.borrow_mut();
+ state.viewport = (width as f32, height as f32);
+ state.layout_cache = None;
+ }
let _ = self.runtime.execute_script(
"",
format!(
@@ -294,11 +303,22 @@ impl ObscuraJsRuntime {
if(globalThis.visualViewport){{\
globalThis.visualViewport.width={width};\
globalThis.visualViewport.height={height};\
+ }}\
+ if(typeof globalThis.__obscura_recompute_intersections==='function'){{\
+ globalThis.__obscura_recompute_intersections();\
}}",
),
);
}
+ /// Current clamped root scroll offset shared by CSSOM geometry and paint.
+ #[cfg(feature = "render")]
+ pub fn scroll_offset(&self) -> (f32, f32) {
+ let mut state = self.state.borrow_mut();
+ let requested = state.scroll_offset;
+ clamp_scroll_offset(&mut state, requested)
+ }
+
/// Run __obscura_init() after all per-page properties (UA, platform, stealth, etc.)
/// have been set. Must be called once per page setup, after all set_* methods.
pub fn run_page_init(&mut self) {
@@ -1871,6 +1891,146 @@ mod tests {
);
}
+ #[test]
+ fn animation_frame_requires_a_callable_callback() {
+ let mut rt = setup_runtime("");
+ let result = rt
+ .evaluate(
+ r#"(() => {
+ try {
+ requestAnimationFrame(null);
+ return [false, ""];
+ } catch (error) {
+ return [error instanceof TypeError, error.name];
+ }
+ })()"#,
+ )
+ .unwrap();
+ assert_eq!(result, serde_json::json!([true, "TypeError"]));
+ }
+
+ #[tokio::test(flavor = "current_thread")]
+ async fn animation_frames_are_ordered_batches_with_rendering_timestamps() {
+ let mut rt = setup_runtime("");
+ rt.execute_script(
+ "animation-frame-order",
+ r#"
+ globalThis.__rafEvents = [];
+ globalThis.__rafStamps = [];
+ Promise.resolve().then(() => __rafEvents.push("microtask-before"));
+ setTimeout(() => __rafEvents.push("timer"), 1);
+ requestAnimationFrame((timestamp) => {
+ __rafEvents.push("raf-a");
+ __rafStamps.push(timestamp);
+ Promise.resolve().then(() => __rafEvents.push("microtask-in-raf"));
+ requestAnimationFrame((nextTimestamp) => {
+ __rafEvents.push("raf-next");
+ __rafStamps.push(nextTimestamp);
+ });
+ });
+ requestAnimationFrame((timestamp) => {
+ __rafEvents.push("raf-b");
+ __rafStamps.push(timestamp);
+ });
+ "#,
+ )
+ .unwrap();
+
+ rt.run_event_loop_bounded(150).await.unwrap();
+ let result = rt
+ .evaluate(
+ r#"[
+ __rafEvents,
+ __rafStamps.length,
+ __rafStamps[0] === __rafStamps[1],
+ __rafStamps[2] > __rafStamps[1]
+ ]"#,
+ )
+ .unwrap();
+ assert_eq!(
+ result,
+ serde_json::json!([
+ [
+ "microtask-before",
+ "timer",
+ "raf-a",
+ "raf-b",
+ "microtask-in-raf",
+ "raf-next"
+ ],
+ 3,
+ true,
+ true
+ ])
+ );
+ }
+
+ #[tokio::test(flavor = "current_thread")]
+ async fn cancel_animation_frame_removes_pending_and_current_batch_callbacks() {
+ let mut rt = setup_runtime("");
+ rt.execute_script(
+ "animation-frame-cancel",
+ r#"
+ globalThis.__rafEvents = [];
+ const pending = requestAnimationFrame(() => __rafEvents.push("pending"));
+ cancelAnimationFrame(pending);
+ let sameBatch;
+ requestAnimationFrame(() => {
+ __rafEvents.push("first");
+ cancelAnimationFrame(sameBatch);
+ });
+ sameBatch = requestAnimationFrame(() => __rafEvents.push("same-batch"));
+ "#,
+ )
+ .unwrap();
+
+ rt.run_event_loop_bounded(100).await.unwrap();
+ assert_eq!(
+ rt.evaluate("__rafEvents").unwrap(),
+ serde_json::json!(["first"])
+ );
+ }
+
+ #[tokio::test(flavor = "current_thread")]
+ async fn self_requeueing_animation_frame_yields_to_timer_tasks() {
+ let mut rt = setup_runtime("");
+ rt.execute_script(
+ "animation-frame-yield",
+ r#"
+ globalThis.__rafCount = 0;
+ globalThis.__rafStopped = false;
+ globalThis.__timerAfterAnimation = false;
+ let frameId = 0;
+ function frame() {
+ __rafCount++;
+ frameId = requestAnimationFrame(frame);
+ }
+ frameId = requestAnimationFrame(frame);
+ setTimeout(() => {
+ cancelAnimationFrame(frameId);
+ __rafStopped = true;
+ }, 55);
+ setTimeout(() => {
+ __timerAfterAnimation = true;
+ }, 65);
+ "#,
+ )
+ .unwrap();
+
+ rt.run_event_loop_bounded(200).await.unwrap();
+ let result = rt
+ .evaluate("[__rafCount, __rafStopped, __timerAfterAnimation]")
+ .unwrap();
+ let values = result.as_array().unwrap();
+ let frame_count = values[0].as_u64().unwrap();
+ assert!(
+ (2..=5).contains(&frame_count),
+ "expected a few paced animation frames before cancellation, got {frame_count}"
+ );
+ assert_eq!(values[1], serde_json::json!(true));
+ assert_eq!(values[2], serde_json::json!(true));
+ }
+
#[test]
fn test_document_url() {
let mut rt = setup_runtime("");
@@ -1892,6 +2052,38 @@ mod tests {
assert_eq!(count.as_f64().unwrap() as i64, 3);
}
+ #[test]
+ fn css_supports_matches_capabilities_and_boolean_conditions() {
+ let mut rt = setup_runtime("");
+ let result = rt
+ .evaluate(
+ r#"JSON.stringify([
+ CSS.supports("-webkit-hyphens", "none"),
+ CSS.supports("margin-trim", "inline"),
+ CSS.supports("-moz-orient", "inline"),
+ CSS.supports("color", "rgb(from red r g b)"),
+ CSS.supports("(((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b))))"),
+ CSS.supports("display", "grid"),
+ CSS.supports("(display:grid) and (selector(.card > *))"),
+ CSS.supports("not (unknown-engine-prop:value)"),
+ CSS.supports("selector(.card >)"),
+ CSS.supports("selector(:obscura-unknown)"),
+ CSS.supports("selector(.card,)"),
+ CSS.supports("scrollbar-gutter", "stable"),
+ CSS.supports("scrollbar-gutter", "floating"),
+ CSS.supports("color", "light-dark(rgb(1, 2, 3), color-mix(in srgb, white 50%, black))"),
+ CSS.supports("(color:light-dark(red, light-dark(white, black)))"),
+ CSS.supports("color", "light-dark(red)"),
+ CSS.supports("color", "light-dark(red, rgb(1, 2, 3)")
+ ])"#,
+ )
+ .unwrap();
+ assert_eq!(
+ result,
+ serde_json::json!("[false,false,false,true,false,true,true,true,false,false,false,true,false,true,true,false,false]")
+ );
+ }
+
#[test]
fn test_get_element_by_id() {
let mut rt = setup_runtime(r#"
Content
"#);
@@ -2500,6 +2692,7 @@ mod tests {
/// Issue #468: window.scrollTo/scrollBy/scroll were no-op stubs, so the
/// dominant infinite-scroll idiom never advanced the page offset.
+ #[cfg(not(feature = "render"))]
#[test]
fn window_scroll_methods_move_the_page_offset() {
let mut rt = setup_runtime(r#""#);
@@ -2528,6 +2721,7 @@ mod tests {
/// Issue #468: the page offset is one value, readable and writable through
/// either `window.scrollY` or `document.scrollingElement.scrollTop`.
+ #[cfg(not(feature = "render"))]
#[test]
fn window_scroll_offset_is_shared_with_the_scrolling_element() {
let mut rt = setup_runtime(r#""#);
@@ -2549,6 +2743,7 @@ mod tests {
/// Issue #468: a scroll event must reach listeners on both the window and
/// the document — that is the signal lazy loaders wait for.
+ #[cfg(not(feature = "render"))]
#[tokio::test(flavor = "current_thread")]
async fn window_scroll_fires_a_scroll_event() {
let mut rt = setup_runtime(r#""#);
@@ -2571,6 +2766,587 @@ mod tests {
assert_eq!(result.value.unwrap(), serde_json::json!([1, 1, 400]));
}
+ #[cfg(feature = "render")]
+ #[test]
+ fn rendered_window_scroll_clamps_and_geometry_is_viewport_relative() {
+ let dom = parse_html(
+ r#"
+
+
+