diff --git a/.isu/issues.json b/.isu/issues.json index a063dae..029c4d0 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -1,5 +1,5 @@ { - "next_id": 395, + "next_id": 396, "issues": [ { "id": 1, @@ -4816,6 +4816,19 @@ "author": "piefev", "state": "open", "created_at": "2026-07-16T15:17:34Z" + }, + { + "id": 395, + "repo": "we", + "title": "Opera parity remains blocked after viewport media and asset snapshot fixes", + "body": "Parent: isu issue 281. Related: isu issue 335.\n\nThis pass improved opera.com real-web parity by making stylesheet/image loading viewport-aware, honoring deferred stylesheet media rewrites, selecting picture/srcset candidates, evaluating hover/unknown/or media queries correctly, accepting prefixed flex display aliases, resolving rem units against the computed html font size, and seeding Opera responsive CSS, hero PNG fallbacks, logo SVG, and all nine WOFF2 font files from latinext.css.\n\nRepro:\n`cargo run -p we-e2e -- --scenario crates/e2e/scenarios/real-web/opera.com.we --out-dir crates/e2e/artifacts`\n\nCurrent result after the pass:\n- desktop L59: `39.28% match (745991/1228500 px differ, tol=4, max_diff=0.1000%)`\n- mobile L70: `51.74% match (158844/329160 px differ, tol=4, max_diff=0.1000%)`\n- perf: about `12s` wall, `901MB` peak RSS\n\nArtifacts:\n- `crates/e2e/artifacts/real-web/opera.com/desktop.png`\n- `crates/e2e/artifacts/real-web/opera.com/desktop.png.diff.png`\n- `crates/e2e/artifacts/real-web/opera.com/desktop_dom.txt`\n- `crates/e2e/artifacts/real-web/opera.com/mobile.png`\n- `crates/e2e/artifacts/real-web/opera.com/mobile.png.diff.png`\n- `crates/e2e/artifacts/real-web/opera.com/mobile_dom.txt`\n\nObserved blockers:\n- The committed Chromium golden includes hydrated UI that the static offline DOM does not render: the cookie consent panel is present in the snapshot but CSS leaves `.cookie-consent__wrapper` at `display:none`; Chromium shows it after page JS hydration.\n- Desktop header logo and nav icons are empty `` placeholders in the static DOM and need the Opera hydration bundle/icon path to populate them.\n- Seeding the Opera homepage JS bundle during this attempt caused the scenario to hang for more than two minutes before it was interrupted, so simply caching the bundle is not yet viable.\n- All nine WOFF2 files referenced by `latinext.dd84bea6c095.css` are now cached, but the harness still reports nine web-font load failures, indicating a font-loader/WOFF2 support gap rather than missing snapshot bytes.\n\nAcceptance: make the Opera scenario hydrate the cookie panel/header icon state and load the cached web fonts without hanging, then remove the `# xfail` marker from `crates/e2e/scenarios/real-web/opera.com.we` only when issue 281 passes against `opera.com.{desktop,mobile}.chromium.expected.png` within the default screenshot threshold.", + "labels": [ + "real-web" + ], + "assigned": [], + "author": "piefev", + "state": "open", + "created_at": "2026-07-17T04:53:41Z" } ] } diff --git a/crates/browser/src/css_loader.rs b/crates/browser/src/css_loader.rs index 4c8ebaa..80bacd8 100644 --- a/crates/browser/src/css_loader.rs +++ b/crates/browser/src/css_loader.rs @@ -4,7 +4,9 @@ //! fetches external CSS resources, resolves `@import` rules, and merges //! everything into a single `Stylesheet` for style resolution. +use we_css::media::{parse_media_query_list, MediaContext}; use we_css::parser::{ImportRule, Parser, Rule, Stylesheet}; +use we_css::tokenizer::Tokenizer; use we_dom::{Document, NodeData, NodeId}; use we_net::referrer::ReferrerPolicy; use we_url::{Origin, Url}; @@ -53,7 +55,29 @@ pub fn collect_stylesheets( base_url: &Url, ) -> Stylesheet { let document_origin = base_url.origin(); - collect_stylesheets_with_origin(doc, loader, base_url, &document_origin) + collect_stylesheets_with_origin_and_media(doc, loader, base_url, &document_origin, None) +} + +/// Collect all CSS rules for a specific viewport. +/// +/// This evaluates `` attributes against the same viewport later +/// used by style resolution. It also handles common deferred stylesheet links +/// that start as `media="print"` and switch media in an `onload` handler. +pub fn collect_stylesheets_for_viewport( + doc: &Document, + loader: &mut ResourceLoader, + base_url: &Url, + viewport: (f32, f32), +) -> Stylesheet { + let document_origin = base_url.origin(); + let media_ctx = MediaContext::from_viewport(viewport.0, viewport.1); + collect_stylesheets_with_origin_and_media( + doc, + loader, + base_url, + &document_origin, + Some(&media_ctx), + ) } /// Collect all CSS rules with explicit Same-Origin Policy enforcement. @@ -62,6 +86,16 @@ pub fn collect_stylesheets_with_origin( loader: &mut ResourceLoader, base_url: &Url, document_origin: &Origin, +) -> Stylesheet { + collect_stylesheets_with_origin_and_media(doc, loader, base_url, document_origin, None) +} + +fn collect_stylesheets_with_origin_and_media( + doc: &Document, + loader: &mut ResourceLoader, + base_url: &Url, + document_origin: &Origin, + media_ctx: Option<&MediaContext>, ) -> Stylesheet { let mut all_rules: Vec = Vec::new(); let mut style_nodes = Vec::new(); @@ -79,7 +113,7 @@ pub fn collect_stylesheets_with_origin( media, referrer_policy, } => { - if !media_matches(&media) { + if !media_matches_context(&media, media_ctx) { continue; } match fetch_stylesheet_with_policy( @@ -165,7 +199,7 @@ fn classify_style_node(doc: &Document, node: NodeId) -> StyleSource { // Must have href match doc.get_attribute(node, "href") { Some(href) if !href.is_empty() => { - let media = doc.get_attribute(node, "media").map(|m| m.to_string()); + let media = effective_stylesheet_media(doc, node); let referrer_policy = doc .get_attribute(node, "referrerpolicy") .and_then(ReferrerPolicy::parse); @@ -182,6 +216,32 @@ fn classify_style_node(doc: &Document, node: NodeId) -> StyleSource { } } +fn effective_stylesheet_media(doc: &Document, node: NodeId) -> Option { + doc.get_attribute(node, "onload") + .and_then(extract_onload_media_assignment) + .or_else(|| doc.get_attribute(node, "media").map(|m| m.to_string())) +} + +fn extract_onload_media_assignment(handler: &str) -> Option { + let marker = "this.media"; + let start = handler.find(marker)?; + let tail = &handler[start + marker.len()..]; + let eq = tail.find('=')?; + let value = tail[eq + 1..].trim_start(); + let quote = value.chars().next()?; + if quote != '\'' && quote != '"' { + return None; + } + let after_quote = &value[quote.len_utf8()..]; + let end = after_quote.find(quote)?; + let media = after_quote[..end].trim(); + if media.is_empty() { + None + } else { + Some(media.to_string()) + } +} + /// Collect concatenated text content from child text nodes of an element. fn collect_text_content(doc: &Document, node: NodeId) -> String { let mut text = String::new(); @@ -214,6 +274,57 @@ fn media_matches(media: &Option) -> bool { } } +fn media_matches_context(media: &Option, ctx: Option<&MediaContext>) -> bool { + let Some(ctx) = ctx else { + return media_matches(media); + }; + media_attribute_matches(media.as_deref(), ctx) +} + +pub(crate) fn media_attribute_matches(media: Option<&str>, ctx: &MediaContext) -> bool { + let Some(raw) = media else { + return true; + }; + let raw = raw.trim(); + if raw.is_empty() { + return true; + } + + raw.split(',') + .any(|query| single_media_query_matches(query.trim(), ctx)) +} + +fn single_media_query_matches(query: &str, ctx: &MediaContext) -> bool { + if query.is_empty() { + return true; + } + if starts_with_unknown_media_type(query) { + return false; + } + + let tokens = Tokenizer::tokenize(query); + let list = parse_media_query_list(&tokens); + !list.queries.is_empty() && list.evaluate(ctx) +} + +fn starts_with_unknown_media_type(query: &str) -> bool { + let trimmed = query.trim_start(); + if trimmed.starts_with('(') { + return false; + } + + let first = trimmed + .split(|c: char| c.is_ascii_whitespace() || c == '(') + .next() + .unwrap_or("") + .to_ascii_lowercase(); + + !matches!( + first.as_str(), + "" | "all" | "screen" | "print" | "not" | "only" + ) +} + /// Fetch an external stylesheet and resolve its `@import` rules. fn fetch_stylesheet( loader: &mut ResourceLoader, @@ -670,6 +781,32 @@ mod tests { assert!(media_matches(&Some("All".to_string()))); } + #[test] + fn media_attribute_range_matches_viewport() { + let ctx = MediaContext::from_viewport(800.0, 600.0); + assert!(media_attribute_matches(Some("(min-width: 768px)"), &ctx)); + assert!(!media_attribute_matches(Some("(max-width: 767px)"), &ctx)); + } + + #[test] + fn media_attribute_unknown_type_does_not_match() { + let ctx = MediaContext::from_viewport(800.0, 600.0); + assert!(!media_attribute_matches(Some("handheld"), &ctx)); + } + + #[test] + fn extracts_stylesheet_onload_media_assignment() { + assert_eq!( + extract_onload_media_assignment("this.media='(min-width: 768px)'; this.onload=null"), + Some("(min-width: 768px)".to_string()) + ); + assert_eq!( + extract_onload_media_assignment("this.media=\"all\""), + Some("all".to_string()) + ); + assert_eq!(extract_onload_media_assignment("console.log('x')"), None); + } + // ----------------------------------------------------------------------- // collect_stylesheets with inline