diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs
index 0e4ccc5..729d124 100644
--- a/crates/obscura-browser/src/page.rs
+++ b/crates/obscura-browser/src/page.rs
@@ -438,14 +438,76 @@ fn materialize_linked_stylesheet_script(link_index: usize, css: &str) -> String
var links = document.querySelectorAll('link[rel~="stylesheet"]');
var link = links[{link_index}];
if (!link || !link.parentNode) return;
- var style = document.createElement('style');
- style.setAttribute('data-obscura-external-stylesheets', '');
- style.textContent = `{escaped_css}`;
- link.parentNode.insertBefore(style, link.nextSibling);
+ var style = null;
+ function effectiveMedia() {{
+ // Until the generic Element shim reflects HTMLLinkElement.media,
+ // `this.media = "all"` creates an own property while the parsed
+ // media="print" attribute remains unchanged.
+ if (Object.prototype.hasOwnProperty.call(link, 'media')) {{
+ return String(link.media || '');
+ }}
+ return link.getAttribute('media') || '';
+ }}
+ function applies() {{
+ var media = effectiveMedia().trim();
+ if (!media) return true;
+ try {{ return window.matchMedia(media).matches; }}
+ catch (_) {{ return false; }}
+ }}
+ function syncSheet() {{
+ var enabled = link.parentNode
+ && !link.disabled
+ && !link.hasAttribute('disabled')
+ && applies();
+ if (!enabled) {{
+ if (style && style.parentNode) style.parentNode.removeChild(style);
+ return;
+ }}
+ if (!style) {{
+ style = document.createElement('style');
+ style.setAttribute('data-obscura-external-stylesheets', '');
+ style.textContent = `{escaped_css}`;
+ }}
+ if (!style.parentNode) {{
+ link.parentNode.insertBefore(style, link.nextSibling);
+ }}
+ }}
+
+ // A non-matching sheet still loads and fires its event. Its handler
+ // may then make the sheet applicable (the common
+ // media=print/onload="this.media='all'" async-CSS pattern).
+ syncSheet();
+ try {{ link.dispatchEvent(new Event('load')); }}
+ finally {{ syncSheet(); }}
}})()"#
)
}
+/// Discover linked author sheets in document order.
+///
+/// Media queries control whether a loaded sheet participates in the cascade;
+/// they do not suppress its fetch or `load` event. Keep the index among all
+/// stylesheet links so the materialization script addresses the same node.
+fn linked_stylesheet_requests(dom: &DomTree) -> Vec<(usize, String)> {
+ let link_ids = dom
+ .query_selector_all("link[rel~=\"stylesheet\"]")
+ .unwrap_or_default();
+ let mut links = Vec::new();
+ for (link_index, lid) in link_ids.into_iter().enumerate() {
+ if let Some(node) = dom.get_node(lid) {
+ // Disabled alternate sheets remain dormant until script enables
+ // them. Media-gated sheets are different: they still load.
+ if node.get_attribute("disabled").is_some() {
+ continue;
+ }
+ if let Some(href) = node.get_attribute("href") {
+ links.push((link_index, href.to_string()));
+ }
+ }
+ }
+ links
+}
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct HydrationStats {
elements: usize,
@@ -772,53 +834,7 @@ impl Page {
async fn fetch_stylesheets(&mut self) {
let all_links = match &self.js {
- Some(js) => {
- js.with_dom(|dom| {
- // `rel` is a space-separated token list. VitePress and
- // other modern generators combine preload and stylesheet
- // (`rel="preload stylesheet"`); exact-value matching
- // silently skipped their entire CSS payload.
- let link_ids = dom
- .query_selector_all("link[rel~=\"stylesheet\"]")
- .unwrap_or_default();
- let mut links = Vec::new();
- for (link_index, lid) in link_ids.into_iter().enumerate() {
- if let Some(node) = dom.get_node(lid) {
- // Skip disabled stylesheets: a ``
- // does not apply until JS clears the attribute. Sites
- // ship alternate themes this way (e.g. Google devsite's
- // dark-theme.css is linked `disabled` and toggled by a
- // theme switcher); applying it painted whole pages dark
- // when Chromium renders them light. `disabled` is a
- // boolean attribute, so any presence means off.
- if node.get_attribute("disabled").is_some() {
- continue;
- }
- // Respect the link's `media` gate. A `` must not apply on screen (the
- // Guardian ships a print sheet that otherwise
- // overrode the screen masthead), and a `` must not
- // apply in our light desktop context (docs.python and
- // other Sphinx sites link pydoctheme_dark.css this way;
- // injecting it painted the page dark under a light
- // Chromium). We render the default light context, so
- // skip sheets gated to print-only or dark preference.
- if let Some(media) = node.get_attribute("media") {
- let compact: String = media.chars().filter(|c| !c.is_whitespace()).flat_map(|c| c.to_lowercase()).collect();
- let print_only = compact.contains("print") && !compact.contains("screen") && !compact.contains("all");
- if print_only || compact.contains("prefers-color-scheme:dark") {
- continue;
- }
- }
- if let Some(href) = node.get_attribute("href") {
- links.push((link_index, href.to_string()));
- }
- }
- }
- links
- }).unwrap_or_default()
- }
+ Some(js) => js.with_dom(linked_stylesheet_requests).unwrap_or_default(),
None => {
tracing::info!("fetch_stylesheets: no js runtime");
return;
@@ -2338,9 +2354,10 @@ fn url_matches_cdp_pattern(pattern: &str, url: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{
- materialize_linked_stylesheet_script, parse_import_url, rebase_css_urls,
- should_restore_hydration_snapshot, split_css_imports, script_response_is_executable,
- truncate_on_char_boundary, url_matches_cdp_pattern, HydrationStats,
+ linked_stylesheet_requests, materialize_linked_stylesheet_script, parse_import_url,
+ rebase_css_urls, should_restore_hydration_snapshot, split_css_imports,
+ script_response_is_executable, truncate_on_char_boundary, url_matches_cdp_pattern,
+ HydrationStats,
};
use obscura_dom::parse_html;
@@ -2484,6 +2501,123 @@ mod tests {
);
}
+ #[test]
+ fn media_gated_stylesheets_are_fetched_but_disabled_sheets_are_not() {
+ let dom = parse_html(
+ r#"
+
+
+ "#,
+ );
+
+ assert_eq!(
+ linked_stylesheet_requests(&dom),
+ vec![
+ (0, "screen.css".to_string()),
+ (1, "async.css".to_string()),
+ (2, "dark.css".to_string()),
+ ]
+ );
+ }
+
+ #[test]
+ fn print_media_onload_can_activate_a_fetched_stylesheet() {
+ let dom = parse_html(
+ r#"
+
+ "#,
+ );
+ let mut runtime = obscura_js::runtime::ObscuraJsRuntime::new();
+ runtime.set_dom(dom);
+ runtime.run_page_init();
+ runtime
+ .execute_script(
+ "",
+ &materialize_linked_stylesheet_script(0, ".target{color:red}"),
+ )
+ .expect("load and materialize async linked sheet");
+
+ let state = runtime
+ .with_dom(|dom| {
+ let link = dom
+ .query_selector("#async")
+ .expect("valid selector")
+ .expect("async link");
+ let styles = dom
+ .query_selector_all("style[data-obscura-external-stylesheets]")
+ .expect("valid selector");
+ (
+ dom.get_node(link)
+ .and_then(|node| {
+ node.get_attribute("data-loaded").map(str::to_owned)
+ }),
+ styles.first().map(|&nid| dom.text_content(nid)),
+ )
+ })
+ .expect("live DOM");
+
+ assert_eq!(
+ state.0.as_deref(),
+ Some("yes"),
+ "link load handler must run"
+ );
+ assert_eq!(
+ state.1.as_deref(),
+ Some(".target{color:red}"),
+ "the handler's `this.media = 'all'` must activate the sheet"
+ );
+ }
+
+ #[test]
+ fn true_print_stylesheet_loads_without_entering_screen_cascade() {
+ let dom = parse_html(
+ r#"
+
+ "#,
+ );
+ let mut runtime = obscura_js::runtime::ObscuraJsRuntime::new();
+ runtime.set_dom(dom);
+ runtime.run_page_init();
+ runtime
+ .execute_script(
+ "",
+ &materialize_linked_stylesheet_script(0, "body{display:none}"),
+ )
+ .expect("finish print linked sheet load");
+
+ let state = runtime
+ .with_dom(|dom| {
+ let link = dom
+ .query_selector("#print")
+ .expect("valid selector")
+ .expect("print link");
+ (
+ dom.get_node(link)
+ .and_then(|node| {
+ node.get_attribute("data-loaded").map(str::to_owned)
+ }),
+ dom.query_selector_all("style[data-obscura-external-stylesheets]")
+ .expect("valid selector")
+ .len(),
+ )
+ })
+ .expect("live DOM");
+
+ assert_eq!(
+ state.0.as_deref(),
+ Some("yes"),
+ "print link still fires load"
+ );
+ assert_eq!(
+ state.1, 0,
+ "print-only CSS must stay out of screen cascade"
+ );
+ }
+
#[test]
fn external_stylesheets_keep_their_positions_between_inline_sheets() {
let dom = parse_html(
diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs
index 295d972..8df5e62 100644
--- a/crates/obscura-render/src/dom.rs
+++ b/crates/obscura-render/src/dom.rs
@@ -4251,6 +4251,26 @@ fn is_full_span_column_subgrid(style: &crate::LayoutStyle) -> bool {
)
}
+/// Whether an item is wholly eligible for ordinary grid auto-placement.
+///
+/// The style model retains an explicit `grid-area:auto` as `Some(Line {
+/// Auto, Auto })`, while an omitted placement stays `None`. Both represent
+/// the same indefinite row and column spans to the grid placement algorithm.
+/// Raw named placements are never auto: an unresolved name must not enter the
+/// bounded subgrid reduction merely because it has no numeric `Line` yet.
+fn has_only_auto_grid_placement(style: &crate::LayoutStyle) -> bool {
+ if style.grid_column_raw.is_some() || style.grid_row_raw.is_some() {
+ return false;
+ }
+ let axis_is_auto = |line: Option<&taffy::Line>| {
+ line.is_none_or(|line| {
+ matches!(line.start, taffy::GridPlacement::Auto)
+ && matches!(line.end, taffy::GridPlacement::Auto)
+ })
+ };
+ axis_is_auto(style.grid_column.as_ref()) && axis_is_auto(style.grid_row.as_ref())
+}
+
/// Collect the deliberately bounded Grid Level 2 subset used by broad
/// "aligned rows" components: a full-span column subgrid, optionally nested
/// through more full-span column subgrids, whose final children auto-place one
@@ -4331,7 +4351,9 @@ fn collect_column_subgrid_descendants(
// This first subset intentionally excludes spanning and explicitly
// placed items. It is the common data-row/card-row pattern and keeps
// each descendant contribution attributable to one ancestor track.
- if child_style.grid_column.is_some()
+ // Explicit `grid-area:auto` remains ordinary auto-placement in both
+ // axes and is therefore equivalent to omitting all placement values.
+ if !has_only_auto_grid_placement(child_style)
|| child_style.position == Some(taffy::Position::Absolute)
|| child_style.margin_auto[1]
|| child_style.margin_auto[3]
diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs
index 69ea183..fd1ba33 100644
--- a/crates/obscura-render/src/paint.rs
+++ b/crates/obscura-render/src/paint.rs
@@ -3907,11 +3907,12 @@ fn background_image_rect(
_ => None,
}
});
- let (width, height) = if let Some(size) = expression_size {
- size
- } else if let Some(size) = explicit_size {
- size
- } else if let Some(fit) = fit {
+ // `cover` and `contain` are complete sizing algorithms, not contextual
+ // lengths. The style layer deliberately retains the authored token string
+ // for CSS math, so a keyword can also appear in `size_expression`; run the
+ // typed fit first or the unresolved token path falls back to the intrinsic
+ // size and silently bypasses fitting.
+ let (width, height) = if let Some(fit) = fit {
let (iw, ih) = intrinsic?;
let scale = match fit {
crate::ObjectFit::Cover => (box_rect.width / iw).max(box_rect.height / ih),
@@ -3919,6 +3920,10 @@ fn background_image_rect(
_ => 1.0,
};
(iw * scale, ih * scale)
+ } else if let Some(size) = expression_size {
+ size
+ } else if let Some(size) = explicit_size {
+ size
} else {
intrinsic.unwrap_or((box_rect.width, box_rect.height))
};
@@ -6394,6 +6399,38 @@ mod tests {
);
}
+ #[test]
+ fn cover_and_contain_background_sizes_fit_the_positioning_area() {
+ let image = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='200'%20height='100'%3E%3Crect%20width='200'%20height='100'%20fill='blue'/%3E%3C/svg%3E";
+ let tree = parse_html(&format!(
+ r#"
+
+
+ "#
+ ));
+ let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
+
+ let cover_edge = pixmap.pixel(50, 10).expect("cover edge");
+ assert!(
+ cover_edge.blue() > 200 && cover_edge.red() < 40,
+ "cover must scale the 2:1 image to the owner's 200px block axis: {cover_edge:?}"
+ );
+
+ let contain_outside = pixmap.pixel(150, 60).expect("contain letterbox");
+ assert!(
+ contain_outside.red() > 200 && contain_outside.blue() < 40,
+ "contain must leave the area outside its centered 100x50 image unpainted: {contain_outside:?}"
+ );
+ let contain_center = pixmap.pixel(150, 100).expect("contain center");
+ assert!(
+ contain_center.blue() > 200 && contain_center.red() < 40,
+ "contain must paint the fitted image through the center: {contain_center:?}"
+ );
+ }
+
#[test]
fn ordered_background_gradients_paint_every_translucent_layer() {
let tree = parse_html(
diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs
index aa99d4e..282e88f 100644
--- a/crates/obscura-render/tests/layout_test.rs
+++ b/crates/obscura-render/tests/layout_test.rs
@@ -2410,6 +2410,7 @@ fn nested_full_span_column_subgrid_shares_ancestor_tracks() {
grid-column:1 / -1;
grid-template-columns:subgrid;
}
+ .inner > * { grid-area:auto }
.inner { column-gap:20px }
.leaf { height:20px }
.a { width:40px }
@@ -2490,12 +2491,16 @@ fn unsafe_column_subgrid_cases_decline_the_fast_path() {
}
#narrow { width:200px }
#partial { width:500px }
+ #placed { width:500px }
+ #named { width:500px }
.subgrid, .inner {
display:grid;
grid-column:1 / -1;
grid-template-columns:subgrid;
}
#partial-row { grid-column:1 / 3 }
+ #placed-b { grid-column:2 }
+ #named-b { grid-column:missing-line }
.inner { column-gap:20px }
.leaf { height:20px }
.a { width:40px }
@@ -2516,6 +2521,20 @@ fn unsafe_column_subgrid_cases_decline_the_fast_path() {
+
+
"#,
);
let layout = layout_dom(&tree, (800.0, 600.0));
@@ -2524,6 +2543,10 @@ fn unsafe_column_subgrid_cases_decline_the_fast_path() {
let narrow_b = rect("narrow-b");
let partial = rect("partial");
let partial_b = rect("partial-b");
+ let placed = rect("placed");
+ let placed_b = rect("placed-b");
+ let named = rect("named");
+ let named_b = rect("named-b");
assert!(
(narrow.height - 60.0).abs() < 0.01 && (narrow_b.x - narrow.x).abs() < 0.01,
@@ -2533,6 +2556,16 @@ fn unsafe_column_subgrid_cases_decline_the_fast_path() {
(partial.height - 60.0).abs() < 0.01 && (partial_b.x - partial.x).abs() < 0.01,
"partial-span subgrid must retain the stacked fallback: {partial:?} {partial_b:?}"
);
+ assert!(
+ (placed.height - 40.0).abs() < 0.01 && placed_b.x > placed.x,
+ "an explicitly placed leaf must retain the existing placement fallback: \
+ {placed:?} {placed_b:?}"
+ );
+ assert!(
+ (named.height - 60.0).abs() < 0.01 && (named_b.x - named.x).abs() < 0.01,
+ "an unresolved named placement must not enter the numeric subgrid reduction: \
+ {named:?} {named_b:?}"
+ );
}
#[test]