From 5d76b73bfe7cea99b5c117b1baefb6e3f7b41786 Mon Sep 17 00:00:00 2001 From: Universe Date: Fri, 24 Apr 2026 02:10:11 +0900 Subject: [PATCH 1/4] feat(grida_wpt): new crate for rendering-test harness + WPT plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the HTML/CSS golden producer out of `cg --example golden_htmlcss` into a dedicated `grida_wpt` crate. Lean deps (no winit/GL) so external runners — the in-tree refbrowser pipeline today, upstream wptrunner next — can invoke the binary cheaply. Three modes on the `render` subcommand: - `--suite ` — matches the existing refbrowser L0 pipeline; 64/64 fixtures byte-identical to the prior `cg --example` output. - `--fixture`/`--dir` — ad-hoc local renders. Directory scan accepts .html/.htm/.xht/.xhtml (shared HTML_EXTENSIONS const). - `--url ` — fetches a page and renders it; consumed by the Grida WPT fork's wptrunner plugin. Also extends `.xht`/`.xhtml` stem support in `refbrowser_render.ts` so WPT-style filenames round-trip through the oracle. WPT plugin setup and fork links: docs/contributing/wpt.md. The fork itself: https://github.com/gridaco/wpt --- .agents/skills/cg-reftest/SKILL.md | 25 +- .../cg-reftest/scripts/refbrowser_render.ts | 6 +- .../skills/dev-cg-htmlcss-feature/SKILL.md | 6 +- Cargo.lock | 43 ++- Cargo.toml | 1 + .../grida-canvas/examples/golden_htmlcss.rs | 325 ------------------ crates/grida_wpt/Cargo.toml | 35 ++ crates/grida_wpt/README.md | 83 +++++ crates/grida_wpt/src/fetch.rs | 79 +++++ crates/grida_wpt/src/lib.rs | 7 + crates/grida_wpt/src/main.rs | 155 +++++++++ crates/grida_wpt/src/render.rs | 173 ++++++++++ crates/grida_wpt/src/suite.rs | 98 ++++++ crates/grida_wpt/src/wpt/mod.rs | 6 + docs/contributing/wpt.md | 147 ++++++++ fixtures/test-html/README.md | 4 +- 16 files changed, 844 insertions(+), 349 deletions(-) delete mode 100644 crates/grida-canvas/examples/golden_htmlcss.rs create mode 100644 crates/grida_wpt/Cargo.toml create mode 100644 crates/grida_wpt/README.md create mode 100644 crates/grida_wpt/src/fetch.rs create mode 100644 crates/grida_wpt/src/lib.rs create mode 100644 crates/grida_wpt/src/main.rs create mode 100644 crates/grida_wpt/src/render.rs create mode 100644 crates/grida_wpt/src/suite.rs create mode 100644 crates/grida_wpt/src/wpt/mod.rs create mode 100644 docs/contributing/wpt.md diff --git a/.agents/skills/cg-reftest/SKILL.md b/.agents/skills/cg-reftest/SKILL.md index 0f058ab48a..2b8cb5bc1f 100644 --- a/.agents/skills/cg-reftest/SKILL.md +++ b/.agents/skills/cg-reftest/SKILL.md @@ -283,7 +283,7 @@ fixtures/test-html/ └── L0.coverage.json ── aspirational scope; tracks progress │ - ├── cargo run -p cg --example golden_htmlcss -- --suite + ├── cargo run -p grida_wpt -- render --suite │ └─► $TMPDIR/grida-htmlcss-goldens/.png (cg actual) │ └── refbrowser_render.ts --suite @@ -376,18 +376,16 @@ pnpm --filter @grida/reftest exec tsx \ --out-dir /tmp/refbrowser-verify ``` -**2. Render actuals (our pipeline)** — the `golden_htmlcss` example -reads the same suite JSON, resolves `extra_css` relative to the suite -file, and applies each stylesheet via +**2. Render actuals (our pipeline)** — the `grida_wpt render` +CLI reads the same suite JSON, resolves `extra_css` relative to the +suite file, and applies each stylesheet via `htmlcss::with_extra_stylesheets` before rendering, so the cascade is symmetric with Chromium. ```sh -cargo run -p cg --example golden_htmlcss -- \ - --suite fixtures/test-html/suites/L0.exact.json - -mkdir -p target/refbrowser/L0.exact/actual -cp "${TMPDIR:-/tmp}/grida-htmlcss-goldens/"*.png target/refbrowser/L0.exact/actual/ +cargo run -p grida_wpt -- render \ + --suite fixtures/test-html/suites/L0.exact.json \ + --out-dir target/refbrowser/L0.exact/actual ``` **3. Diff via `@grida/reftest`** — format-agnostic, same bucket layout @@ -432,7 +430,7 @@ coupled defaults wire this up: `refbrowser_render.ts`). Root canvas default bg is dropped; PNG alpha encodes "did the CSS cascade draw here?" - **cg** clears its Skia surface with `Color::TRANSPARENT` and - renders at viewport dims (in `examples/golden_htmlcss.rs`). + renders at viewport dims (in `crates/grida_wpt/src/render.rs`). - **Both sides** apply `_reftest/transparent-body.css` via `extra_css`. `!important` forces `html, body { background: transparent }`, so fixtures with `body { background: #fff }` @@ -1024,10 +1022,9 @@ pnpm --filter @grida/reftest exec tsx .agents/skills/cg-reftest/scripts/refbrows --out-dir target/refbrowser/expected # 2. Render actuals via our cg pipeline -cargo run -p cg --example golden_htmlcss -- \ - --suite fixtures/test-html/suites/L0.exact.json -mkdir -p target/refbrowser/actual -cp "${TMPDIR:-/tmp}/grida-htmlcss-goldens/"*.png target/refbrowser/actual/ +cargo run -p grida_wpt -- render \ + --suite fixtures/test-html/suites/L0.exact.json \ + --out-dir target/refbrowser/actual # 3. Diff actuals against Chromium oracle, write bucketed report pnpm --filter @grida/reftest exec reftest \ diff --git a/.agents/skills/cg-reftest/scripts/refbrowser_render.ts b/.agents/skills/cg-reftest/scripts/refbrowser_render.ts index c35622feb5..702f7c5127 100644 --- a/.agents/skills/cg-reftest/scripts/refbrowser_render.ts +++ b/.agents/skills/cg-reftest/scripts/refbrowser_render.ts @@ -11,7 +11,7 @@ * │ + helper CSS │ │ (full-page screen) │ │ │ * └────────────────┘ └─────────────────────┘ └──────────────────┘ * - * Pair with `cargo run -p cg --example golden_htmlcss --suite` on the + * Pair with `cargo run -p grida_wpt -- render --suite` on the * actual side, then diff via `@grida/reftest`. * * ## Usage @@ -149,7 +149,7 @@ async function resolveSuite(suitePath: string): Promise { const extra_css = merged.extra_css.map((rel) => path.resolve(suiteDir, rel) ); - const stem = path.basename(entry.path).replace(/\.html?$/i, ""); + const stem = path.basename(entry.path).replace(/\.(html?|xht|xhtml)$/i, ""); return { htmlPath, stem, config: { ...merged, extra_css } }; }); } @@ -258,7 +258,7 @@ async function main() { ); } else { const htmlPath = path.resolve(args.fixture!); - const stem = path.basename(htmlPath).replace(/\.html?$/i, ""); + const stem = path.basename(htmlPath).replace(/\.(html?|xht|xhtml)$/i, ""); resolved = [{ htmlPath, stem, config: DEFAULTS }]; console.log(`refbrowser: rendering 1 fixture (ad-hoc, defaults only)`); } diff --git a/.agents/skills/dev-cg-htmlcss-feature/SKILL.md b/.agents/skills/dev-cg-htmlcss-feature/SKILL.md index c61ecc8d2b..c586ff10a5 100644 --- a/.agents/skills/dev-cg-htmlcss-feature/SKILL.md +++ b/.agents/skills/dev-cg-htmlcss-feature/SKILL.md @@ -167,7 +167,7 @@ the suite config is wrong and the score will be zero. terms. **Exit when.** `cargo check -p cg` is clean, existing tests pass, -and the fixture renders through `golden_htmlcss --suite` without +and the fixture renders through `grida_wpt render --suite` without error. Similarity score is measured in phase 5 — do not gate on it here. @@ -181,8 +181,8 @@ in the change: 1. Render expecteds (Playwright Chromium) into `target/refbrowser//expected`. -2. Render actuals (`cargo run -p cg --example golden_htmlcss -- ---suite …`). +2. Render actuals (`cargo run -p grida_wpt -- render --suite … +--out-dir target/refbrowser//actual`). 3. Diff with `@grida/reftest`, threshold 0 (the strict default). 4. Read similarity against the suite's `gate.floor`. diff --git a/Cargo.lock b/Cargo.lock index 763f3e6c28..aad5e70698 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1802,6 +1802,19 @@ dependencies = [ "zip 8.2.0", ] +[[package]] +name = "grida_wpt" +version = "0.0.0" +dependencies = [ + "cg", + "clap", + "serde", + "serde_json", + "skia-safe", + "ureq", + "url", +] + [[package]] name = "h2" version = "0.4.10" @@ -1958,7 +1971,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.4", ] [[package]] @@ -3815,7 +3828,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.4", ] [[package]] @@ -3932,6 +3945,7 @@ version = "0.23.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -5165,6 +5179,22 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.4" @@ -5519,6 +5549,15 @@ dependencies = [ "string_cache_codegen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.4", +] + [[package]] name = "webpki-roots" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 52158423be..e2a43b2269 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/grida-canvas-wasm", "crates/grida-canvas-fonts", "crates/grida-dev", + "crates/grida_wpt", "crates/csscascade", "crates/math2", ] diff --git a/crates/grida-canvas/examples/golden_htmlcss.rs b/crates/grida-canvas/examples/golden_htmlcss.rs deleted file mode 100644 index 69e78cfae7..0000000000 --- a/crates/grida-canvas/examples/golden_htmlcss.rs +++ /dev/null @@ -1,325 +0,0 @@ -/// HTML+CSS renderer golden test tool. -/// -/// Renders HTML files to PNG for visual inspection. Output goes to a -/// temporary directory (printed to stderr) so generated images don't -/// bloat the repository. -/// -/// ## Usage -/// -/// cargo run -p cg --example golden_htmlcss -- \ -/// --suite fixtures/test-html/suites/L0.exact.json -/// -/// cargo run -p cg --example golden_htmlcss -- [FILE_OR_DIR...] -/// -/// If no arguments given, renders built-in L0 fixtures. -/// If FILE_OR_DIR is given, renders ad-hoc (no sidecar config). -/// -/// ## Suite JSON shape -/// -/// { -/// "defaults": { -/// "viewport": { "width": 600, "height": 800 }, -/// "extra_css": ["../_reftest/hide-text.css"] -/// }, -/// "fixtures": [ -/// { "path": "../L0/box-dimensions.html", -/// "viewport": { "width": 600, "height": 522 } } -/// ] -/// } -/// -/// Per-fixture entries inherit and override `defaults`. All paths -/// (`fixtures[].path`, `extra_css[]`) resolve **relative to the suite -/// file**. `gate` and other fields unknown to this tool are ignored. -use cg::htmlcss; -use cg::resources::ByteStore; -use cg::runtime::font_repository::FontRepository; -use serde::Deserialize; -use skia_safe::{surfaces, Color}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; - -fn build_fonts() -> FontRepository { - let mut repo = FontRepository::new(Arc::new(Mutex::new(ByteStore::new()))); - repo.enable_system_fallback(); - repo -} - -#[derive(Debug, Default, Clone, Copy, Deserialize)] -#[serde(default)] -struct Viewport { - width: Option, - height: Option, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default)] -struct FixtureConfig { - extra_css: Vec, - viewport: Viewport, -} - -#[derive(Debug, Deserialize)] -struct SuiteEntry { - path: String, - #[serde(default)] - extra_css: Option>, - #[serde(default)] - viewport: Option, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default)] -struct SuiteFile { - defaults: FixtureConfig, - fixtures: Vec, -} - -const DEFAULT_WIDTH: f32 = 600.0; -const DEFAULT_HEIGHT: f32 = 600.0; - -/// Resolve a fixture entry against suite defaults. Suite-relative -/// paths are anchored at `suite_dir`. Viewport width/height inherit -/// from `defaults` and fall back to the built-in defaults. -fn resolve_entry( - entry: &SuiteEntry, - defaults: &FixtureConfig, - suite_dir: &Path, -) -> (PathBuf, Vec, f32, f32) { - let html = suite_dir.join(&entry.path); - let css_rel: &[String] = entry.extra_css.as_deref().unwrap_or(&defaults.extra_css); - let css_abs: Vec = css_rel.iter().map(|r| suite_dir.join(r)).collect(); - let vp = entry.viewport.unwrap_or(defaults.viewport); - let width = vp - .width - .or(defaults.viewport.width) - .unwrap_or(DEFAULT_WIDTH); - let height = vp - .height - .or(defaults.viewport.height) - .unwrap_or(DEFAULT_HEIGHT); - (html, css_abs, width, height) -} - -/// Populate `cache[abs]` if absent. Missing files warn; absent keys -/// are treated as a no-op at injection time. -fn ensure_css_cached(cache: &mut HashMap, abs: &Path) { - if cache.contains_key(abs) { - return; - } - match std::fs::read_to_string(abs) { - Ok(s) => { - cache.insert(abs.to_path_buf(), s); - } - Err(e) => eprintln!(" warn: failed to read {}: {e}", abs.display()), - } -} - -fn render_to_png( - html: &str, - width: f32, - height: f32, - name: &str, - out_dir: &Path, - fonts: &FontRepository, -) { - let picture = - htmlcss::render(html, width, height, fonts, &htmlcss::NoImages).expect("render failed"); - // Full-viewport dims match Chromium's fullPage footprint; transparent clear - // lets PNG alpha double as the reftest content mask. - let w = width.max(1.0) as i32; - let h = height.max(1.0) as i32; - - let mut surface = surfaces::raster_n32_premul((w, h)).expect("surface"); - let canvas = surface.canvas(); - canvas.clear(Color::TRANSPARENT); - canvas.draw_picture(&picture, None, None); - - let image = surface.image_snapshot(); - let data = image - .encode(None, skia_safe::EncodedImageFormat::PNG, None) - .unwrap(); - let path = out_dir.join(format!("{name}.png")); - std::fs::write(&path, data.as_bytes()).unwrap(); - eprintln!(" {name}: {w}x{h} → {}", path.display()); -} - -fn render_with_extras( - html_path: &Path, - extras_abs: &[PathBuf], - width: f32, - height: f32, - out_dir: &Path, - fonts: &FontRepository, - css_cache: &mut HashMap, -) { - let html = match std::fs::read_to_string(html_path) { - Ok(s) => s, - Err(e) => { - eprintln!(" warn: failed to read {}: {e}", html_path.display()); - return; - } - }; - let name = html_path - .file_stem() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - - for abs in extras_abs { - ensure_css_cached(css_cache, abs); - } - let extras: Vec<&str> = extras_abs - .iter() - .filter_map(|p| css_cache.get(p).map(String::as_str)) - .collect(); - let html = if extras.is_empty() { - html - } else { - htmlcss::with_extra_stylesheets(&html, &extras) - }; - - render_to_png(&html, width, height, &name, out_dir, fonts); -} - -fn render_suite(suite_path: &Path, out_dir: &Path, fonts: &FontRepository) { - let raw = std::fs::read_to_string(suite_path) - .unwrap_or_else(|e| panic!("failed to read {}: {e}", suite_path.display())); - let suite: SuiteFile = serde_json::from_str(&raw) - .unwrap_or_else(|e| panic!("failed to parse {}: {e}", suite_path.display())); - let suite_dir = suite_path.parent().unwrap_or(Path::new(".")); - - eprintln!( - "Rendering {} fixture(s) from suite {}", - suite.fixtures.len(), - suite_path.display() - ); - let mut css_cache: HashMap = HashMap::new(); - for entry in &suite.fixtures { - let (html_path, extras_abs, width, height) = - resolve_entry(entry, &suite.defaults, suite_dir); - render_with_extras( - &html_path, - &extras_abs, - width, - height, - out_dir, - fonts, - &mut css_cache, - ); - } -} - -fn render_directory(dir: &Path, out_dir: &Path, fonts: &FontRepository) { - let mut entries: Vec = std::fs::read_dir(dir) - .expect("failed to read directory") - .filter_map(|e| e.ok().map(|e| e.path())) - .filter(|p| { - p.extension() - .map(|ext| ext == "html" || ext == "htm") - .unwrap_or(false) - }) - .collect(); - entries.sort(); - - eprintln!( - "Rendering {} HTML files from {}", - entries.len(), - dir.display() - ); - let mut css_cache: HashMap = HashMap::new(); - for path in &entries { - render_with_extras( - path, - &[], - DEFAULT_WIDTH, - DEFAULT_HEIGHT, - out_dir, - fonts, - &mut css_cache, - ); - } -} - -/// Parse `argv` into (`suite_path`, positional args). If `--suite P` -/// is present, those two tokens are removed from the positional list. -fn parse_args(argv: &[String]) -> (Option, Vec) { - let mut suite: Option = None; - let mut positional: Vec = Vec::new(); - let mut i = 0; - while i < argv.len() { - let a = &argv[i]; - if a == "--suite" { - let v = argv - .get(i + 1) - .unwrap_or_else(|| panic!("--suite requires a path argument")); - suite = Some(v.clone()); - i += 2; - } else if a.starts_with("--") { - // Unknown long flag. If the next token looks like a value - // (doesn't start with `-`) swallow it too, so `--foo bar` - // doesn't leak `bar` into the positional stream and get - // treated as a file path. - match argv.get(i + 1) { - Some(next) if !next.starts_with('-') => i += 2, - _ => i += 1, - } - } else { - positional.push(a.clone()); - i += 1; - } - } - (suite, positional) -} - -fn main() { - let argv: Vec = std::env::args().skip(1).collect(); - let (suite, positional) = parse_args(&argv); - - // Output to system temp directory - let out_dir = std::env::temp_dir().join("grida-htmlcss-goldens"); - std::fs::create_dir_all(&out_dir).expect("failed to create output directory"); - eprintln!("Output: {}", out_dir.display()); - - let fonts = build_fonts(); - - if let Some(suite_path) = suite { - render_suite(Path::new(&suite_path), &out_dir, &fonts); - eprintln!("Done. Files in: {}", out_dir.display()); - return; - } - - if positional.is_empty() { - let fixture_dir = PathBuf::from(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../fixtures/test-html/L0" - )); - if fixture_dir.is_dir() { - render_directory(&fixture_dir, &out_dir, &fonts); - } else { - eprintln!("No fixture directory found at {}", fixture_dir.display()); - eprintln!("Pass --suite or HTML files as arguments."); - } - } else { - let mut css_cache: HashMap = HashMap::new(); - for arg in &positional { - let path = PathBuf::from(arg); - if path.is_dir() { - render_directory(&path, &out_dir, &fonts); - } else if path.is_file() { - render_with_extras( - &path, - &[], - DEFAULT_WIDTH, - DEFAULT_HEIGHT, - &out_dir, - &fonts, - &mut css_cache, - ); - } else { - eprintln!("Skipping {}: not a file or directory", path.display()); - } - } - } - - eprintln!("Done. Files in: {}", out_dir.display()); -} diff --git a/crates/grida_wpt/Cargo.toml b/crates/grida_wpt/Cargo.toml new file mode 100644 index 0000000000..7be0ab2d40 --- /dev/null +++ b/crates/grida_wpt/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "grida_wpt" +version = "0.0.0" +edition = "2021" +publish = false +license = "Apache-2.0" +description = "Grida rendering test harness. Houses the golden producer, refbrowser integration, and (future) Web Platform Tests runner plugin." + +# A thin umbrella crate for rendering-test tooling. The binary is the +# primary entry point used by: +# +# * the in-tree refbrowser pipeline (Chromium oracle via Playwright, +# diffed against our cg htmlcss renderer) +# * the (planned) WPT integration via wptrunner (Python harness calls +# this binary as the rendering "product") +# +# Kept intentionally lean: no winit/glutin/GL. Headless raster only. + +[dependencies] +cg = { path = "../grida-canvas" } +skia-safe = { version = "0.93.1", features = ["textlayout"] } +clap = { version = "4.5", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +# Blocking HTTP client for `--url` mode (WPT serves tests over localhost). +# Blocking (not async) to keep the CLI single-threaded and tokio-free. +ureq = "2" +url = "2" + +[[bin]] +name = "grida_wpt" +path = "src/main.rs" + +[lints] +workspace = true diff --git a/crates/grida_wpt/README.md b/crates/grida_wpt/README.md new file mode 100644 index 0000000000..11e719b9d7 --- /dev/null +++ b/crates/grida_wpt/README.md @@ -0,0 +1,83 @@ +# grida_wpt + +Rendering-test harness for the Grida Canvas (`cg`) crate. + +**Two consumers:** + +- the in-tree **refbrowser pipeline** — Chromium-parity checks on L0 + fixtures. Invoked via `grida_wpt render --suite …`. See + [cg-reftest skill](../../.agents/skills/cg-reftest/SKILL.md). +- the upstream **Web Platform Tests** — spec-conformance checks + driven by `wptrunner` calling `grida_wpt render --url …`. Plugin + lives in the [`gridaco/wpt`](https://github.com/gridaco/wpt) fork; + setup in [docs/contributing/wpt.md](../../docs/contributing/wpt.md). + +Kept intentionally headless: **no winit/glutin/GL deps**. External +runners (Playwright TS, `wptrunner` Python, CI workers) can invoke +the binary without pulling a GUI toolchain. + +The crate is named for its strategic anchor (WPT) but is expected to +grow to host adjacent test infra — the existing SVG reftest runner, +refig suites, and future paint reftests — as they are promoted out of +`grida-dev`. + +## Usage + +### Suite mode (matches the in-tree L0 refbrowser pipeline) + +```sh +cargo run -p grida_wpt -- render \ + --suite fixtures/test-html/suites/L0.exact.json \ + --out-dir target/refbrowser/L0.exact/actual +``` + +### Single fixture / directory + +```sh +cargo run -p grida_wpt -- render --fixture path/to/test.html +cargo run -p grida_wpt -- render --dir path/to/fixtures/ +``` + +### URL mode (used by wptrunner) + +```sh +cargo run -p grida_wpt -- render \ + --url http://127.0.0.1:8000/css/css-transforms/2d-rotate-001.html \ + --out /tmp/test.png \ + --width 800 --height 600 +``` + +The executor in the [WPT fork](https://github.com/gridaco/wpt) shells +out to this mode once per reftest screenshot. External resources +(``, ``) are **not** resolved yet. + +### Output + +Default output directory: `${TMPDIR}/grida-htmlcss-goldens/`. Pass +`--out-dir` to override. + +## Suite JSON schema + +Shared with the TypeScript oracle +(`.agents/skills/cg-reftest/scripts/refbrowser_render.ts`) and +existing suite files at `fixtures/test-html/suites/*.json`. + +```json +{ + "defaults": { + "viewport": { "width": 600, "height": 800 }, + "extra_css": ["../_reftest/hide-text.css"] + }, + "fixtures": [ + { + "path": "../L0/box-dimensions.html", + "viewport": { "width": 600, "height": 522 } + } + ] +} +``` + +Per-fixture entries inherit and override `defaults`. Paths resolve +relative to the suite file. Unknown fields (`gate`, `wait_for`, +`full_page`, `name`, `description`) are consumed by other tools and +ignored here. diff --git a/crates/grida_wpt/src/fetch.rs b/crates/grida_wpt/src/fetch.rs new file mode 100644 index 0000000000..cdf49d4d25 --- /dev/null +++ b/crates/grida_wpt/src/fetch.rs @@ -0,0 +1,79 @@ +//! HTTP fetch helper for `--url` mode. +//! +//! WPT tests are served from a local `wpt serve` (or equivalent). We +//! fetch the HTML as a string and render it with the `cg::htmlcss` +//! pipeline exactly as if it had been read from disk. External +//! asset URLs (``, ``) are *not* resolved here — +//! the cg htmlcss renderer does not yet support a base URL. When +//! external-stylesheet support lands (plan P4) this module should +//! grow to thread a base URL through to the renderer. + +use std::time::Duration; + +/// Fetch a URL as UTF-8 text. Panics on non-2xx or network errors — +/// the CLI is expected to be supervised by `wptrunner`, which treats +/// a non-zero exit as a CRASH and surfaces it in the log. +pub fn fetch_text(url: &str) -> String { + let agent = ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(5)) + .timeout_read(Duration::from_secs(30)) + .build(); + let resp = agent + .get(url) + .call() + .unwrap_or_else(|e| panic!("GET {url} failed: {e}")); + resp.into_string() + .unwrap_or_else(|e| panic!("GET {url} body read failed: {e}")) +} + +/// Derive a filename stem from a URL path. Strips the leading path +/// and trailing `.html`/`.xht`/`.xhtml`/`.htm` extension. Falls back +/// to `"page"` if the URL has no path component. +pub fn stem_from_url(url: &str) -> String { + let parsed = match url::Url::parse(url) { + Ok(u) => u, + Err(_) => return "page".to_string(), + }; + let last = parsed + .path_segments() + .and_then(|mut s| s.rfind(|p| !p.is_empty())) + .unwrap_or("page"); + let lower = last.to_ascii_lowercase(); + for ext in [".xhtml", ".html", ".htm", ".xht"] { + if let Some(stem) = lower.strip_suffix(ext) { + return last[..stem.len()].to_string(); + } + } + last.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stem_basic() { + assert_eq!( + stem_from_url("http://web-platform.test:8000/css/css-transforms/2d-rotate-001.html"), + "2d-rotate-001" + ); + } + + #[test] + fn stem_xht() { + assert_eq!( + stem_from_url("http://example.com/foo/bar-baz.xht"), + "bar-baz" + ); + } + + #[test] + fn stem_no_extension() { + assert_eq!(stem_from_url("http://example.com/foo/bar"), "bar"); + } + + #[test] + fn stem_empty_path() { + assert_eq!(stem_from_url("http://example.com/"), "page"); + } +} diff --git a/crates/grida_wpt/src/lib.rs b/crates/grida_wpt/src/lib.rs new file mode 100644 index 0000000000..2d4c61ea6d --- /dev/null +++ b/crates/grida_wpt/src/lib.rs @@ -0,0 +1,7 @@ +//! Grida rendering-test harness. See crate README for the overview; +//! usage docs live in `docs/contributing/wpt.md`. + +pub mod fetch; +pub mod render; +pub mod suite; +pub mod wpt; diff --git a/crates/grida_wpt/src/main.rs b/crates/grida_wpt/src/main.rs new file mode 100644 index 0000000000..f5a4a1ac6e --- /dev/null +++ b/crates/grida_wpt/src/main.rs @@ -0,0 +1,155 @@ +//! `grida_wpt` CLI. +//! +//! Subcommands: +//! +//! * `render` — HTML+CSS fixtures → PNG via `cg::htmlcss` (the +//! "actual" side of the reftest pair). +//! +//! Future: a `wpt` subcommand for the `wptrunner` product glue. + +use clap::{Parser, Subcommand}; +use grida_wpt::{fetch, render}; +use std::path::{Path, PathBuf}; + +const DEFAULT_OUT_DIRNAME: &str = "grida-htmlcss-goldens"; + +fn default_out_dir() -> PathBuf { + std::env::temp_dir().join(DEFAULT_OUT_DIRNAME) +} + +#[derive(Debug, Parser)] +#[command( + name = "grida_wpt", + about = "Grida rendering-test harness (golden producer + WPT plugin).", + version +)] +struct Cli { + #[command(subcommand)] + command: Cmd, +} + +#[derive(Debug, Subcommand)] +enum Cmd { + /// Render HTML/CSS fixtures to PNG via the cg htmlcss renderer. + Render(RenderArgs), +} + +#[derive(Debug, clap::Args)] +struct RenderArgs { + /// Suite JSON file. Fixtures and `extra_css` resolve relative to it. + #[arg(long, value_name = "PATH", conflicts_with_all = ["fixture", "dir", "url"])] + suite: Option, + + /// Single HTML fixture to render (ad-hoc, no suite context). + #[arg(long, value_name = "PATH", conflicts_with_all = ["dir", "url"])] + fixture: Option, + + /// Directory of HTML fixtures to render at default viewport. + /// Accepts .html, .htm, .xht, and .xhtml files. + #[arg(long, value_name = "DIR", conflicts_with = "url")] + dir: Option, + + /// URL to fetch and render (WPT serves tests over localhost). The + /// HTML body is fetched via blocking HTTP and rendered at the + /// default viewport. External assets referenced by the page + /// (stylesheets, images) are NOT resolved — see adoption plan P4. + #[arg(long, value_name = "URL")] + url: Option, + + /// Explicit output file path. Overrides `--out-dir` when set; + /// intended for `wptrunner` which names PNGs deterministically. + #[arg(long, value_name = "FILE", conflicts_with = "out_dir")] + out: Option, + + /// Output directory for rendered PNGs. Default: + /// `${TMPDIR}/grida-htmlcss-goldens`. + #[arg(long, value_name = "DIR")] + out_dir: Option, + + /// Viewport width override (px). Default from suite / built-in. + #[arg(long, value_name = "PX")] + width: Option, + + /// Viewport height override (px). Default from suite / built-in. + #[arg(long, value_name = "PX")] + height: Option, +} + +fn main() { + let cli = Cli::parse(); + match cli.command { + Cmd::Render(args) => cmd_render(args), + } +} + +fn cmd_render(args: RenderArgs) { + let fonts = render::build_fonts(); + + if let Some(url) = args.url.as_deref() { + render_from_url(url, &args, &fonts); + return; + } + + let out_dir = args.out_dir.clone().unwrap_or_else(default_out_dir); + std::fs::create_dir_all(&out_dir).expect("failed to create output directory"); + eprintln!("Output: {}", out_dir.display()); + + if let Some(suite_path) = args.suite.as_deref() { + render::render_suite(suite_path, &out_dir, &fonts); + } else if let Some(fixture) = args.fixture.as_deref() { + render_one(fixture, &out_dir, &fonts, &args); + } else if let Some(dir) = args.dir.as_deref() { + render::render_directory(dir, &out_dir, &fonts); + } else { + let fallback = PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/test-html/L0" + )); + render::render_directory(&fallback, &out_dir, &fonts); + } + + eprintln!("Done. Files in: {}", out_dir.display()); +} + +fn render_from_url( + url: &str, + args: &RenderArgs, + fonts: &cg::runtime::font_repository::FontRepository, +) { + eprintln!("Fetching {url}"); + let html = fetch::fetch_text(url); + let width = args.width.unwrap_or(grida_wpt::suite::DEFAULT_WIDTH); + let height = args.height.unwrap_or(grida_wpt::suite::DEFAULT_HEIGHT); + + let (out_dir, stem) = resolve_out(args, || fetch::stem_from_url(url)); + std::fs::create_dir_all(&out_dir).expect("failed to create output directory"); + render::render_to_png(&html, width, height, &stem, &out_dir, fonts); +} + +fn render_one( + html_path: &Path, + out_dir: &Path, + fonts: &cg::runtime::font_repository::FontRepository, + args: &RenderArgs, +) { + let mut cache = std::collections::HashMap::new(); + let width = args.width.unwrap_or(grida_wpt::suite::DEFAULT_WIDTH); + let height = args.height.unwrap_or(grida_wpt::suite::DEFAULT_HEIGHT); + render::render_with_extras(html_path, &[], width, height, out_dir, fonts, &mut cache); +} + +/// Resolve output destination from `--out` (explicit file path) or +/// `--out-dir` + derived stem. Returns `(directory, stem)` so the +/// renderer can write `{directory}/{stem}.png`. +fn resolve_out(args: &RenderArgs, default_stem: impl FnOnce() -> String) -> (PathBuf, String) { + if let Some(out) = args.out.as_deref() { + let dir = out.parent().unwrap_or(Path::new(".")).to_path_buf(); + let stem = out + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(default_stem); + return (dir, stem); + } + let dir = args.out_dir.clone().unwrap_or_else(default_out_dir); + (dir, default_stem()) +} diff --git a/crates/grida_wpt/src/render.rs b/crates/grida_wpt/src/render.rs new file mode 100644 index 0000000000..7e42cfa5e0 --- /dev/null +++ b/crates/grida_wpt/src/render.rs @@ -0,0 +1,173 @@ +//! Golden producer: HTML+CSS → PNG via the `cg::htmlcss` renderer. +//! +//! This is the "actual" side of the reftest pair. The "expected" side +//! is produced by Playwright Chromium (see `.agents/skills/cg-reftest/ +//! scripts/refbrowser_render.ts`). Both sides write PNGs with +//! transparent backgrounds so pixel alpha doubles as the content +//! mask during diffing. + +use crate::suite::{self, ResolvedFixture, SuiteFile}; +use cg::htmlcss; +use cg::resources::ByteStore; +use cg::runtime::font_repository::FontRepository; +use skia_safe::{surfaces, Color}; +use std::collections::hash_map::Entry; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +/// File extensions treated as HTML fixtures by directory-scan mode +/// and URL-stem derivation. Lowercase, no leading dot. +pub const HTML_EXTENSIONS: &[&str] = &["html", "htm", "xht", "xhtml"]; + +pub fn build_fonts() -> FontRepository { + let mut repo = FontRepository::new(Arc::new(Mutex::new(ByteStore::new()))); + repo.enable_system_fallback(); + repo +} + +/// Missing files warn once to stderr and contribute nothing to +/// injection (callers skip absent keys silently). +fn ensure_css_cached(cache: &mut HashMap, abs: &Path) { + if let Entry::Vacant(slot) = cache.entry(abs.to_path_buf()) { + match std::fs::read_to_string(abs) { + Ok(s) => { + slot.insert(s); + } + Err(e) => eprintln!(" warn: failed to read {}: {e}", abs.display()), + } + } +} + +/// Render an HTML string to `{out_dir}/{name}.png`. +pub fn render_to_png( + html: &str, + width: f32, + height: f32, + name: &str, + out_dir: &Path, + fonts: &FontRepository, +) { + let picture = + htmlcss::render(html, width, height, fonts, &htmlcss::NoImages).expect("render failed"); + // Full-viewport dims match Chromium's fullPage footprint; transparent clear + // lets PNG alpha double as the reftest content mask. + let w = width.max(1.0) as i32; + let h = height.max(1.0) as i32; + + let mut surface = surfaces::raster_n32_premul((w, h)).expect("surface"); + let canvas = surface.canvas(); + canvas.clear(Color::TRANSPARENT); + canvas.draw_picture(&picture, None, None); + + let image = surface.image_snapshot(); + let data = image + .encode(None, skia_safe::EncodedImageFormat::PNG, None) + .unwrap(); + let path = out_dir.join(format!("{name}.png")); + std::fs::write(&path, data.as_bytes()) + .unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display())); + eprintln!(" {name}: {w}x{h} → {}", path.display()); +} + +/// Injects `extras_abs` via `htmlcss::with_extra_stylesheets` so the +/// cascade is symmetric with the refbrowser oracle. +pub fn render_with_extras( + html_path: &Path, + extras_abs: &[PathBuf], + width: f32, + height: f32, + out_dir: &Path, + fonts: &FontRepository, + css_cache: &mut HashMap, +) { + let html = match std::fs::read_to_string(html_path) { + Ok(s) => s, + Err(e) => { + eprintln!(" warn: failed to read {}: {e}", html_path.display()); + return; + } + }; + let name = html_path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + for abs in extras_abs { + ensure_css_cached(css_cache, abs); + } + let extras: Vec<&str> = extras_abs + .iter() + .filter_map(|p| css_cache.get(p).map(String::as_str)) + .collect(); + let html = if extras.is_empty() { + html + } else { + htmlcss::with_extra_stylesheets(&html, &extras) + }; + + render_to_png(&html, width, height, &name, out_dir, fonts); +} + +pub fn render_suite(suite_path: &Path, out_dir: &Path, fonts: &FontRepository) { + let suite: SuiteFile = suite::load(suite_path).unwrap_or_else(|e| panic!("{e}")); + let suite_dir = suite_path.parent().unwrap_or(Path::new(".")); + + eprintln!( + "Rendering {} fixture(s) from suite {}", + suite.fixtures.len(), + suite_path.display() + ); + let mut css_cache: HashMap = HashMap::new(); + for entry in &suite.fixtures { + let ResolvedFixture { + html, + extra_css, + width, + height, + } = suite::resolve_entry(entry, &suite.defaults, suite_dir); + render_with_extras( + &html, + &extra_css, + width, + height, + out_dir, + fonts, + &mut css_cache, + ); + } +} + +/// Renders every file matching [`HTML_EXTENSIONS`] at default +/// viewport dimensions. +pub fn render_directory(dir: &Path, out_dir: &Path, fonts: &FontRepository) { + let mut entries: Vec = std::fs::read_dir(dir) + .expect("failed to read directory") + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| { + p.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| HTML_EXTENSIONS.iter().any(|e| e.eq_ignore_ascii_case(ext))) + .unwrap_or(false) + }) + .collect(); + entries.sort(); + + eprintln!( + "Rendering {} HTML files from {}", + entries.len(), + dir.display() + ); + let mut css_cache: HashMap = HashMap::new(); + for path in &entries { + render_with_extras( + path, + &[], + suite::DEFAULT_WIDTH, + suite::DEFAULT_HEIGHT, + out_dir, + fonts, + &mut css_cache, + ); + } +} diff --git a/crates/grida_wpt/src/suite.rs b/crates/grida_wpt/src/suite.rs new file mode 100644 index 0000000000..b0fd3f66b7 --- /dev/null +++ b/crates/grida_wpt/src/suite.rs @@ -0,0 +1,98 @@ +//! Suite config parsing for HTML/CSS golden runs. +//! +//! The JSON schema is shared between this Rust crate and the +//! TypeScript refbrowser oracle (`.agents/skills/cg-reftest/scripts/ +//! refbrowser_render.ts`). Unknown fields (`gate`, `wait_for`, +//! `full_page`, `name`, `description`) are consumed by other tools +//! and deliberately ignored here. +//! +//! ```json +//! { +//! "defaults": { +//! "viewport": { "width": 600, "height": 800 }, +//! "extra_css": ["../_reftest/hide-text.css"] +//! }, +//! "fixtures": [ +//! { "path": "../L0/box-dimensions.html", +//! "viewport": { "width": 600, "height": 522 } } +//! ] +//! } +//! ``` +//! +//! Per-fixture entries inherit and override `defaults`. All paths +//! (`fixtures[].path`, `extra_css[]`) resolve **relative to the suite +//! file**. + +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +pub const DEFAULT_WIDTH: f32 = 600.0; +pub const DEFAULT_HEIGHT: f32 = 600.0; + +#[derive(Debug, Default, Clone, Copy, Deserialize)] +#[serde(default)] +pub struct Viewport { + pub width: Option, + pub height: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct FixtureDefaults { + pub extra_css: Vec, + pub viewport: Viewport, +} + +#[derive(Debug, Deserialize)] +pub struct SuiteEntry { + pub path: String, + #[serde(default)] + pub extra_css: Option>, + #[serde(default)] + pub viewport: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct SuiteFile { + pub defaults: FixtureDefaults, + pub fixtures: Vec, +} + +/// Resolved fixture after merging defaults and anchoring paths. +#[derive(Debug)] +pub struct ResolvedFixture { + pub html: PathBuf, + pub extra_css: Vec, + pub width: f32, + pub height: f32, +} + +/// Resolve a fixture entry against suite defaults. Suite-relative +/// paths are anchored at `suite_dir`. Viewport width/height inherit +/// from `defaults` and fall back to the built-in defaults. +pub fn resolve_entry( + entry: &SuiteEntry, + defaults: &FixtureDefaults, + suite_dir: &Path, +) -> ResolvedFixture { + let html = suite_dir.join(&entry.path); + let css_rel: &[String] = entry.extra_css.as_deref().unwrap_or(&defaults.extra_css); + let extra_css: Vec = css_rel.iter().map(|r| suite_dir.join(r)).collect(); + let vp = entry.viewport.unwrap_or(defaults.viewport); + let width = vp.width.unwrap_or(DEFAULT_WIDTH); + let height = vp.height.unwrap_or(DEFAULT_HEIGHT); + ResolvedFixture { + html, + extra_css, + width, + height, + } +} + +/// Read and parse a suite JSON file. +pub fn load(suite_path: &Path) -> Result { + let raw = std::fs::read_to_string(suite_path) + .map_err(|e| format!("failed to read {}: {e}", suite_path.display()))?; + serde_json::from_str(&raw).map_err(|e| format!("failed to parse {}: {e}", suite_path.display())) +} diff --git a/crates/grida_wpt/src/wpt/mod.rs b/crates/grida_wpt/src/wpt/mod.rs new file mode 100644 index 0000000000..e76efd4e84 --- /dev/null +++ b/crates/grida_wpt/src/wpt/mod.rs @@ -0,0 +1,6 @@ +//! WPT-specific integration. Reserved for anything exclusively a WPT +//! concern (as opposed to the generic golden producer in +//! [`crate::render`]): `MANIFEST.json` parsing, fuzzy-ref tolerance +//! handling, future daemon-mode IPC for wptrunner. Empty until a +//! caller needs something here — the Python plugin lives in the +//! `gridaco/wpt` fork. diff --git a/docs/contributing/wpt.md b/docs/contributing/wpt.md new file mode 100644 index 0000000000..75d4a75365 --- /dev/null +++ b/docs/contributing/wpt.md @@ -0,0 +1,147 @@ +# Contributing to Grida | Web Platform Tests + +This guide covers how to run the upstream [web-platform-tests][wpt] +suite against Grida's `cg::htmlcss` renderer. It is relevant only if +you are working on the htmlcss renderer and want to validate against +the CSS spec; for day-to-day Chromium-parity work use the in-tree +refbrowser pipeline (see [cg-reftest skill][cg-reftest]). + +Status: **PoC**. Reftest pair-matching works end-to-end. Testharness +and crashtest executors are not yet wired. Most tests fail — the goal +right now is to make the plumbing reliable, not to ship a pass rate. + +[wpt]: https://web-platform-tests.org/ +[cg-reftest]: ../../.agents/skills/cg-reftest/SKILL.md + +## One-time setup + +### 1. Clone the Grida WPT fork as a sibling + +The WPT tree with Grida's product plugin lives at +[`gridaco/wpt`](https://github.com/gridaco/wpt). Clone it as a sibling +of the grida repo: + +```sh +cd /path/to/grida/.. # parent of the grida/ checkout +git clone --depth=1 https://github.com/gridaco/wpt.git wpt +``` + +Layout after cloning: + +``` +Apps/grida/ +├── grida/ # this repo +└── wpt/ # the fork +``` + +Do not use a random upstream WPT checkout — it lacks the `grida` +product registration. + +### 2. Build `grida_wpt` (release mode) + +```sh +cargo build -p grida_wpt --release +``` + +## Run a test + +From the `wpt/` sibling: + +```sh +cd ../wpt + +./wpt run \ + --binary=../grida/target/release/grida_wpt \ + grida \ + css/css-transforms/2d-rotate-001.html +``` + +Expected output: `SUITE_START` → `TEST_START` → `TEST_END: FAIL, +expected PASS`. The FAIL is not a setup error — it means the pipeline +ran end-to-end and cg's rendering of the pair did not match within +fuzzy tolerance. See the [primitives inventory][inventory] for what +feature gaps cause most of the fails. + +[inventory]: https://github.com/gridaco/grida/issues + +### Wider runs + +```sh +./wpt run \ + --binary=../grida/target/release/grida_wpt \ + grida \ + css/css-transforms/ +``` + +The plugin is reftest-only; `testharness`, `crashtest`, `wdspec`, and +`print-reftest` types warn "Unsupported test type" and are skipped. + +## What the plugin does + +Pipeline: `wptrunner` invokes `grida_wpt render --url --out +` once per reftest screenshot. Both `test.html` and `ref.html` +render through `cg::htmlcss`. Wptrunner's built-in +`RefTestImplementation` compares the two PNGs, honoring `` tolerances. + +Source: + +- **Rust side:** [`crates/grida_wpt/`](../../crates/grida_wpt/) in + this repo. The `render` subcommand handles `--url` mode. +- **Python side:** `tools/wptrunner/wptrunner/browsers/grida.py` and + `executors/executorgrida.py` in the fork. See `README-GRIDA.md` at + the fork root for the exact list of changed files. + +## Known gotchas + +- **No `/etc/hosts` edits needed.** Our plugin's `env_options` + hardcodes `server_host: 127.0.0.1` and the Python executor + rewrites `web-platform.test:` URLs to `127.0.0.1:` + before invoking `grida_wpt`. Do **not** pass `--enable-dns`; + wptrunner has an unrelated bug where its TCP readiness probe + cannot detect a UDP-only DNS server. +- **No auto-install of the binary.** Always pass `--binary=` + explicitly. Grida does not implement the `install_webdriver` + hooks. +- **External stylesheets / images are not resolved.** `grida_wpt` + fetches the test HTML but does not follow `` or ``. Tests that depend on external + support files will mismatch until the adoption plan's P4 lands. + +## Troubleshooting + +**`Unknown product 'grida'`** → You are pointing at the wrong WPT +checkout. Confirm `git remote get-url origin` in `../wpt` is +`https://github.com/gridaco/wpt` (or a fork thereof). + +**`Missing hosts file configuration`** → Same cause; the fork's +`tools/wpt/run.py` adds `grida` to the skip-list. If this error fires, +the fork is stale or the wrong clone. + +**Tests CRASH with network error in stderr** → `wptserve` did not +start. Check for port conflicts on 8000–8003 / 8443–8446 / 9000. +`pkill -f "wpt serve"` and retry. + +## Keeping the fork current + +When upstream WPT drifts, rebase the Grida commit onto latest master +inside the fork: + +```sh +cd ../wpt +git fetch upstream master # add upstream remote first if needed +git rebase upstream/master +git push --force-with-lease origin master +``` + +Three patches to carry: `tools/wpt/run.py`, `tools/wpt/browser.py`, +`tools/wptrunner/wptrunner/products.py`. The two plugin files +(`grida.py`, `executorgrida.py`) are new, so rebase-clean. + +## See also + +- [grida_wpt crate README](../../crates/grida_wpt/README.md) — Rust + side usage (including the refbrowser-pipeline use of `grida_wpt +render --suite`). +- [adoption plan](https://github.com/gridaco/grida/issues) + — strategic rationale for adopting WPT. diff --git a/fixtures/test-html/README.md b/fixtures/test-html/README.md index f33610fe53..43dd1aa6f4 100644 --- a/fixtures/test-html/README.md +++ b/fixtures/test-html/README.md @@ -115,7 +115,7 @@ natural cull: ``` To find the right height, render the fixture once with -`golden_htmlcss --suite` and read the reported `WxH`. Update the +`grida_wpt render --suite` and read the reported `WxH`. Update the suite entry's `viewport.height` to match. Re-render refbrowser; both sides should now be at identical dimensions. @@ -156,7 +156,7 @@ and block flow. The suite defaults already pull it in; see fixtures skill for the full authoring checklist. 4. **Register it** — add an entry to `suites/L0.coverage.json`: - Paint fixtures: `{ "path": "../L0/.html" }` (inherits `defaults.viewport`). - - Layout fixtures: run `cargo run -p cg --example golden_htmlcss -- --suite …`, read the reported `WxH`, then + - Layout fixtures: run `cargo run -p grida_wpt -- render --suite …`, read the reported `WxH`, then ```json { "path": "../L0/.html", "viewport": { "width": 600, "height": } } From 18bbc0176b809d8ca8d928f1670c1ec5f42da827 Mon Sep 17 00:00:00 2001 From: Universe Date: Fri, 24 Apr 2026 11:22:54 +0900 Subject: [PATCH 2/4] feat(htmlcss): inline-block siblings via Taffy flex-wrap emulation Taffy has no inline formatting context, so a block container holding `display: inline-block` siblings previously either stacked them vertically (when preserved as elements) or dropped their width/height (when flattened into an InlineGroup). Both broke the WPT ref pattern where two or more explicit-size inline-blocks should sit side-by-side. - collect.rs: preserve inline-block as an element when it has an explicit width or height instead of flattening into the inline path. - layout.rs: at Taffy-style time, detect containers whose element children are all inline-block (>=2, no text or other block children) and switch the container to Flex + flex-wrap:wrap + align-items:start. Lands layout-display-inline-block.html in L0.exact (65 fixtures, all 100.00%). Unlocks WPT refs that rely on inline-block for horizontal layout (37% of css-flexbox, 30% of css-position refs). --- crates/grida-canvas/src/htmlcss/collect.rs | 12 ++++- crates/grida-canvas/src/htmlcss/layout.rs | 33 ++++++++++++ .../L0/layout-display-inline-block.html | 51 +++++++++++++++++++ fixtures/test-html/suites/L0.exact.json | 3 +- 4 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 fixtures/test-html/L0/layout-display-inline-block.html diff --git a/crates/grida-canvas/src/htmlcss/collect.rs b/crates/grida-canvas/src/htmlcss/collect.rs index cb29cea2a7..723e1d3c54 100644 --- a/crates/grida-canvas/src/htmlcss/collect.rs +++ b/crates/grida-canvas/src/htmlcss/collect.rs @@ -432,8 +432,16 @@ fn collect_element_with_counter( // Widgets with intrinsic sizes need their own Taffy node // for sizing to work — don't flatten them into inline groups. - let is_inline = child.display == types::Display::Inline - || child.display == types::Display::InlineBlock; + // Same for inline-block with explicit sizing: its width/height + // would be lost inside the inline path. Layout later emulates + // inline-block flow by mapping the parent to Taffy flex-wrap + // when all of its element children are inline-block. + let inline_block_with_size = child.display == types::Display::InlineBlock + && (child.width != types::CssLength::Auto + || child.height != types::CssLength::Auto); + let is_inline = (child.display == types::Display::Inline + || child.display == types::Display::InlineBlock) + && !inline_block_with_size; if is_inline && !child.widget.is_widget() && child.replaced.is_none() { collect_inline_items(&child, &mut pending_inline); } else { diff --git a/crates/grida-canvas/src/htmlcss/layout.rs b/crates/grida-canvas/src/htmlcss/layout.rs index 7c03e12a3b..c99967c9a7 100644 --- a/crates/grida-canvas/src/htmlcss/layout.rs +++ b/crates/grida-canvas/src/htmlcss/layout.rs @@ -126,6 +126,19 @@ fn build_taffy_node( apply_replaced_intrinsic_size(&mut style, replaced, images); } + // Emulate inline-block sibling flow via Taffy flex-wrap. Taffy has no + // inline formatting context, so a block container holding only + // inline-block siblings would otherwise stack them vertically. Only + // apply when ≥2 inline-block element children exist and no text or + // non-inline-block elements would be misrouted through flex. + if style.display == taffy::Display::Block && should_emulate_inline_block_container(el) { + style.display = taffy::Display::Flex; + style.flex_wrap = taffy::FlexWrap::Wrap; + // Override default `stretch` so inline-blocks keep their + // own block-size instead of filling the container's line height. + style.align_items = Some(taffy::AlignItems::Start); + } + // Build child nodes let mut child_ids: Vec = Vec::new(); @@ -176,6 +189,26 @@ fn build_taffy_node( taffy.new_with_children(style, &child_ids).unwrap() } +/// Returns true when `el` should lay out its children as a horizontal +/// flex-wrap row to emulate inline-block flow. Only safe when the +/// container holds ≥2 inline-block element siblings and no text or +/// non-inline-block element children (those would require a real inline +/// formatting context to mix with inline-blocks correctly). +fn should_emulate_inline_block_container(el: &StyledElement) -> bool { + let mut inline_block_count = 0usize; + for child in &el.children { + match child { + StyledNode::Element(child_el) => match child_el.display { + types::Display::InlineBlock => inline_block_count += 1, + types::Display::None => {} + _ => return false, + }, + StyledNode::Text(_) | StyledNode::InlineGroup(_) => return false, + } + } + inline_block_count >= 2 +} + /// Taffy context for text/inline leaf nodes. Stores inline items so the /// measure function can build a Skia Paragraph with placeholders at any /// available width. diff --git a/fixtures/test-html/L0/layout-display-inline-block.html b/fixtures/test-html/L0/layout-display-inline-block.html new file mode 100644 index 0000000000..7bb679ed5a --- /dev/null +++ b/fixtures/test-html/L0/layout-display-inline-block.html @@ -0,0 +1,51 @@ + + + + + Layout: display: inline-block + + + +
+
+
+
+
+
+ + diff --git a/fixtures/test-html/suites/L0.exact.json b/fixtures/test-html/suites/L0.exact.json index 2e21f54343..5716626e4f 100644 --- a/fixtures/test-html/suites/L0.exact.json +++ b/fixtures/test-html/suites/L0.exact.json @@ -85,6 +85,7 @@ { "path": "../L0/paint-border-style-dashed.html" }, { "path": "../L0/paint-filter-drop-shadow.html" }, { "path": "../L0/paint-transform-matrix.html" }, - { "path": "../L0/paint-filter-blur.html" } + { "path": "../L0/paint-filter-blur.html" }, + { "path": "../L0/layout-display-inline-block.html" } ] } From 631501ba83b43a0ca2a2b99986db54c9652962c0 Mon Sep 17 00:00:00 2001 From: Universe Date: Fri, 24 Apr 2026 14:03:36 +0900 Subject: [PATCH 3/4] chore(stylo): drop fork and upgrade to stylo 0.16 from crates.io MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit servo/stylo#273 has shipped, so we can drop the `gridaco/stylo` fork pin that existed solely to get that fix ahead of release. API adaptations required by the 0.9 → 0.16 bump: - `selectors` fork replaced with crates.io `selectors 0.37`. - `TElement` now requires `AttributeProvider`; implemented for both the real adapter and the standalone reference example. - `ensure_data`/`borrow_data`/`mutate_data` return the new `ElementDataRef/Mut` types; per-element storage migrated from `AtomicRefCell>` to `OnceLock`. `clear_data` is a no-op (matches Gecko's reuse-allocation semantics). - `Stylist::flush` dropped its root/snapshot arguments. - Module moves: `style::servo::media_queries::{Device,FontMetricsProvider}` → `style::device::{Device, servo::FontMetricsProvider}`; `QueryFontMetricsFlags` moved from `specified::font` to `computed::font`. - `BorderSideWidth` is now a newtype over `Au`; unwrap via `.0.to_f32_px()` and gate by border-style so `none/hidden` still zero the used width (the fork did this implicitly). - `OverflowClipMargin` became a struct; access via `.offset`. - `GenericShapeRadius::Length` keeps its `NonNegative` wrapper. - `outline_has_nonzero_width()` removed; replaced by explicit outline-style + width checks. - Grid pref plumbing moved from `stylo_config` to `stylo_static_prefs::set_pref!`, which is what stylo 0.16 reads. Verified: - `cargo check` / `cargo test` pass across cg, csscascade, grida-dev, grida-canvas-wasm (771 unit tests on cg). - WASM debug build + Node smoke test pass. - Refbrowser L0.exact: 65/65 at 100.00%. L0.coverage: avg 99.70%, no new regressions (sub-100 fixtures are all in the already-documented divergence surfaces: gradients, dotted borders, blur shadows, outline AA). --- Cargo.lock | 85 +++++++++++-------- crates/csscascade/Cargo.toml | 11 ++- .../csscascade/examples/exp_impl_telement.rs | 84 +++++++++--------- crates/csscascade/src/adapter.rs | 60 ++++++------- crates/csscascade/src/cascade.rs | 14 ++- crates/csscascade/src/dom.rs | 17 ++-- crates/csscascade/src/tree/mod.rs | 6 +- crates/grida-canvas/Cargo.toml | 4 +- crates/grida-canvas/src/html/mod.rs | 17 +++- crates/grida-canvas/src/htmlcss/collect.rs | 49 +++++++---- 10 files changed, 191 insertions(+), 156 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aad5e70698..c4e74c8f53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -687,7 +687,7 @@ dependencies = [ "serde_json", "skia-safe", "stylo", - "stylo_config", + "stylo_static_prefs", "taffy", "tokio", "unicode-segmentation", @@ -2542,12 +2542,6 @@ dependencies = [ "web_atoms", ] -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "math2" version = "0.0.2" @@ -4079,8 +4073,9 @@ dependencies = [ [[package]] name = "selectors" -version = "0.33.0" -source = "git+https://github.com/gridaco/stylo#ae78004b3056c90b66dbba4d92edc0edbc24bc49" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9" dependencies = [ "bitflags 2.9.1", "cssparser", @@ -4189,7 +4184,8 @@ dependencies = [ [[package]] name = "servo_arc" version = "0.4.3" -source = "git+https://github.com/gridaco/stylo#ae78004b3056c90b66dbba4d92edc0edbc24bc49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" dependencies = [ "serde", "stable_deref_trait", @@ -4408,10 +4404,29 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "stylo" -version = "0.9.0" -source = "git+https://github.com/gridaco/stylo#ae78004b3056c90b66dbba4d92edc0edbc24bc49" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf16d4d7f37038ec5d0eb0289045a5d797b1b85d3366d6d2bfaf1f9a55471739" dependencies = [ "app_units", "arrayvec", @@ -4426,10 +4441,8 @@ dependencies = [ "indexmap", "itertools 0.14.0", "itoa", - "lazy_static", "log", "malloc_size_of_derive", - "matches", "mime", "new_debug_unreachable", "num-derive", @@ -4448,8 +4461,9 @@ dependencies = [ "smallvec", "static_assertions", "string_cache", + "strum", + "strum_macros", "stylo_atoms", - "stylo_config", "stylo_derive", "stylo_dom", "stylo_malloc_size_of", @@ -4467,22 +4481,19 @@ dependencies = [ [[package]] name = "stylo_atoms" -version = "0.9.0" -source = "git+https://github.com/gridaco/stylo#ae78004b3056c90b66dbba4d92edc0edbc24bc49" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0ccc231ff982eb5e89a9670275046b1283f1218537e1e498e4fafd41ee78c7e" dependencies = [ "string_cache", "string_cache_codegen", ] -[[package]] -name = "stylo_config" -version = "0.9.0" -source = "git+https://github.com/gridaco/stylo#ae78004b3056c90b66dbba4d92edc0edbc24bc49" - [[package]] name = "stylo_derive" -version = "0.9.0" -source = "git+https://github.com/gridaco/stylo#ae78004b3056c90b66dbba4d92edc0edbc24bc49" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e23de306a4574a16fabcdac5d711af99a458a96a7a1d5d6aeb6c312c1a18c22a" dependencies = [ "darling", "proc-macro2", @@ -4493,8 +4504,9 @@ dependencies = [ [[package]] name = "stylo_dom" -version = "0.9.0" -source = "git+https://github.com/gridaco/stylo#ae78004b3056c90b66dbba4d92edc0edbc24bc49" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b09f4863058955f38d5fee5ae04baa60b2f71735ef406ad35cb01911dfc811d" dependencies = [ "bitflags 2.9.1", "stylo_malloc_size_of", @@ -4502,8 +4514,9 @@ dependencies = [ [[package]] name = "stylo_malloc_size_of" -version = "0.9.0" -source = "git+https://github.com/gridaco/stylo#ae78004b3056c90b66dbba4d92edc0edbc24bc49" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bcb103ab4c0ec6fe0cdd71d092d6bd82a5a012489ccf4c4bd1874a659423316" dependencies = [ "app_units", "cssparser", @@ -4519,13 +4532,15 @@ dependencies = [ [[package]] name = "stylo_static_prefs" -version = "0.9.0" -source = "git+https://github.com/gridaco/stylo#ae78004b3056c90b66dbba4d92edc0edbc24bc49" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe8c72f25bcf5b076b1ad183eaaaf1f2e30cb244d77a74e2b4631c41254f768" [[package]] name = "stylo_traits" -version = "0.9.0" -source = "git+https://github.com/gridaco/stylo#ae78004b3056c90b66dbba4d92edc0edbc24bc49" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765956b903dbc474540399789062c4a4fcdf3a55d41c4e7919f5e78fc234e30c" dependencies = [ "app_units", "bitflags 2.9.1", @@ -4811,7 +4826,8 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "to_shmem" version = "0.3.0" -source = "git+https://github.com/gridaco/stylo#ae78004b3056c90b66dbba4d92edc0edbc24bc49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab187810ca1e6aaa4c97a06492aac9ade2ffae6a301fd2aac103656f5a69edb" dependencies = [ "cssparser", "servo_arc", @@ -4824,7 +4840,8 @@ dependencies = [ [[package]] name = "to_shmem_derive" version = "0.1.0" -source = "git+https://github.com/gridaco/stylo#ae78004b3056c90b66dbba4d92edc0edbc24bc49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ba1f5563024b63bb6acb4558452d9ba737518c1d11fcc1861febe98d1e31cf4" dependencies = [ "darling", "proc-macro2", diff --git a/crates/csscascade/Cargo.toml b/crates/csscascade/Cargo.toml index 9c302b393d..8223c1cf68 100644 --- a/crates/csscascade/Cargo.toml +++ b/crates/csscascade/Cargo.toml @@ -19,12 +19,11 @@ crate-type = ["rlib"] [dependencies] # css parsing # (+2.5mb wasm32-unknown-emscripten) -# https://github.com/servo/stylo/issues/272 / The issue has been fixed upstream, yet not released. sticking to fork until the next release -stylo = { git = "https://github.com/gridaco/stylo" } -selectors = { git = "https://github.com/gridaco/stylo", package = "selectors" } -stylo_dom = { git = "https://github.com/gridaco/stylo", package = "stylo_dom" } -stylo_atoms = { git = "https://github.com/gridaco/stylo", package = "stylo_atoms" } -style_traits = { git = "https://github.com/gridaco/stylo", package = "stylo_traits" } +stylo = "0.16" +selectors = "0.37" +stylo_dom = "0.16" +stylo_atoms = "0.16" +style_traits = { package = "stylo_traits", version = "0.16" } url = "2.5" euclid = "0.22.11" diff --git a/crates/csscascade/examples/exp_impl_telement.rs b/crates/csscascade/examples/exp_impl_telement.rs index 1380eea512..e0b65c66a0 100644 --- a/crates/csscascade/examples/exp_impl_telement.rs +++ b/crates/csscascade/examples/exp_impl_telement.rs @@ -52,6 +52,8 @@ mod cascade { StyleContext, StyleSystemOptions, ThreadLocalStyleContext, }; use style::data::ElementStyles; + use style::device::Device; + use style::device::servo::FontMetricsProvider; use style::dom::TElement; use style::font_metrics::FontMetrics; use style::media_queries::{MediaList, MediaType}; @@ -59,7 +61,6 @@ mod cascade { use style::properties::style_structs::Font; use style::queries::values::PrefersColorScheme; use style::servo::animation::DocumentAnimationSet; - use style::servo::media_queries::{Device, FontMetricsProvider}; use style::servo::selector_parser::SnapshotMap; use style::servo_arc::Arc as ServoArc; use style::shared_lock::{SharedRwLock, SharedRwLockReadGuard, StylesheetGuards}; @@ -69,9 +70,8 @@ mod cascade { use style::stylist::{RuleInclusion, Stylist}; use style::traversal::resolve_style; use style::traversal_flags::TraversalFlags; - use style::values::computed::font::GenericFontFamily; + use style::values::computed::font::{GenericFontFamily, QueryFontMetricsFlags}; use style::values::computed::{CSSPixelLength, Length}; - use style::values::specified::font::QueryFontMetricsFlags; use style_traits::{CSSPixel, DevicePixel}; use stylo_atoms::Atom; use url::Url; @@ -127,15 +127,11 @@ body { } } - pub(crate) fn flush(&mut self, document: HtmlDocument) { + pub(crate) fn flush(&mut self, _document: HtmlDocument) { let guard = self.stylesheet_lock.read(); let guards = StylesheetGuards::same(&guard); trace_dom!("cascade: flushing stylist"); - let _ = self.stylist.flush::( - &guards, - document.root_element(), - Some(&self.snapshot_map), - ); + let _ = self.stylist.flush(&guards); } pub(crate) fn style_document(&mut self, document: HtmlDocument) -> usize { @@ -360,17 +356,15 @@ mod demo_dom { io::{self, Cursor}, }; - use atomic_refcell::AtomicRefCell; use html5ever::tendril::TendrilSink; use html5ever::{driver::ParseOpts, parse_document}; use markup5ever::interface::tree_builder::{ ElemName as ElemNameTrait, ElementFlags, NodeOrText, QuirksMode, TreeSink, }; use markup5ever::{Attribute, LocalName, Namespace, QualName}; - use style::{ - LocalName as StyleLocalName, Namespace as StyleNamespace, data::ElementData, - values::AtomIdent, - }; + use std::sync::OnceLock as StdOnceLock; + use style::data::ElementDataWrapper; + use style::{LocalName as StyleLocalName, Namespace as StyleNamespace, values::AtomIdent}; use stylo_atoms::Atom as WeakAtom; use tendril::StrTendril; @@ -426,7 +420,7 @@ mod demo_dom { document: NodeId, quirks_mode: QuirksMode, pub errors: Vec, - element_data: Vec>>, + element_data: Vec>, } unsafe impl Sync for DemoDom {} @@ -461,7 +455,7 @@ mod demo_dom { &self.nodes[id.idx()] } - pub(crate) fn element_data_slot(&self, id: NodeId) -> &AtomicRefCell> { + pub(crate) fn element_data_slot(&self, id: NodeId) -> &StdOnceLock { &self.element_data[id.idx()] } @@ -735,7 +729,7 @@ mod demo_dom { }) .collect(); - let element_data = nodes.iter().map(|_| AtomicRefCell::new(None)).collect(); + let element_data = nodes.iter().map(|_| StdOnceLock::new()).collect(); DemoDom { nodes, @@ -988,7 +982,6 @@ mod demo_dom { mod stylo_dom { use std::{borrow::Borrow, sync::OnceLock}; - use atomic_refcell::{AtomicRef, AtomicRefCell, AtomicRefMut}; use euclid::default::Size2D; use markup5ever::{Attribute, Namespace as HtmlNamespace, ns}; use selectors::attr::{AttrSelectorOperation, CaseSensitivity, NamespaceConstraint}; @@ -999,8 +992,8 @@ mod stylo_dom { use style::Namespace as StyleNamespace; use style::applicable_declarations::ApplicableDeclarationBlock; use style::context::SharedStyleContext; - use style::data::ElementData; - use style::dom::{LayoutIterator, OpaqueNode, TElement, TNode}; + use style::data::{ElementDataMut, ElementDataRef, ElementDataWrapper}; + use style::dom::{AttributeProvider, LayoutIterator, OpaqueNode, TElement, TNode}; use style::properties::PropertyDeclarationBlock; use style::selector_parser::{ AttrValue as SelectorAttrValue, Lang, PseudoElement, SelectorImpl, @@ -1114,7 +1107,7 @@ mod stylo_dom { HtmlNode(self.0) } - fn data_slot(&self) -> &'static AtomicRefCell> { + fn data_slot(&self) -> &'static std::sync::OnceLock { dom().element_data_slot(self.0) } @@ -1480,47 +1473,33 @@ mod stylo_dom { 0 } - unsafe fn ensure_data(&self) -> AtomicRefMut<'_, style::data::ElementData> { + unsafe fn ensure_data(&self) -> ElementDataMut<'_> { trace_dom!("TElement::ensure_data {:?}", self); let slot = self.data_slot(); - let mut cell = slot.borrow_mut(); - if cell.is_none() { - *cell = Some(ElementData::default()); - } - AtomicRefMut::map(cell, |opt| opt.as_mut().unwrap()) + slot.get_or_init(ElementDataWrapper::default).borrow_mut() } unsafe fn clear_data(&self) { trace_dom!("TElement::clear_data {:?}", self); - let slot = self.data_slot(); - *slot.borrow_mut() = None; + // OnceLock-backed storage: we cannot reset the slot safely, and + // callers in the cascade driver never rely on clearing data between + // passes. Leaving the entry in place matches Stylo's Gecko backend, + // which also reuses the allocation. } fn has_data(&self) -> bool { trace_dom!("TElement::has_data {:?}", self); - self.data_slot().borrow().is_some() + self.data_slot().get().is_some() } - fn borrow_data(&self) -> Option> { + fn borrow_data(&self) -> Option> { trace_dom!("TElement::borrow_data {:?}", self); - let slot = self.data_slot(); - let cell = slot.borrow(); - if cell.is_some() { - Some(AtomicRef::map(cell, |opt| opt.as_ref().unwrap())) - } else { - None - } + self.data_slot().get().map(|w| w.borrow()) } - fn mutate_data(&self) -> Option> { + fn mutate_data(&self) -> Option> { trace_dom!("TElement::mutate_data {:?}", self); - let slot = self.data_slot(); - let cell = slot.borrow_mut(); - if cell.is_some() { - Some(AtomicRefMut::map(cell, |opt| opt.as_mut().unwrap())) - } else { - None - } + self.data_slot().get().map(|w| w.borrow_mut()) } fn skip_item_display_fixup(&self) -> bool { @@ -1626,6 +1605,19 @@ mod stylo_dom { } } + impl AttributeProvider for HtmlElement { + fn get_attr(&self, attr: &style::LocalName, namespace: &StyleNamespace) -> Option { + self.attr_iter() + .filter(|(a, _)| { + let dom_ns: &str = &a.name.ns; + let sel_ns: &str = namespace.as_ref(); + dom_ns == sel_ns + }) + .find(|(_, stored)| *stored == attr) + .map(|(a, _)| a.value.to_string()) + } + } + impl ::selectors::Element for HtmlElement { type Impl = Impl; diff --git a/crates/csscascade/src/adapter.rs b/crates/csscascade/src/adapter.rs index 2402465638..0adee967ae 100644 --- a/crates/csscascade/src/adapter.rs +++ b/crates/csscascade/src/adapter.rs @@ -15,7 +15,6 @@ use std::borrow::Borrow; use std::sync::OnceLock; use std::sync::atomic::{AtomicPtr, Ordering}; -use atomic_refcell::{AtomicRef, AtomicRefMut}; use euclid::default::Size2D; use markup5ever::{Attribute, Namespace as HtmlNamespace, ns}; use selectors::attr::{AttrSelectorOperation, CaseSensitivity, NamespaceConstraint}; @@ -26,8 +25,8 @@ use selectors::{OpaqueElement, sink::Push}; use style::Namespace as StyleNamespace; use style::applicable_declarations::ApplicableDeclarationBlock; use style::context::SharedStyleContext; -use style::data::ElementData; -use style::dom::{LayoutIterator, OpaqueNode, TElement, TNode}; +use style::data::{ElementDataMut, ElementDataRef, ElementDataWrapper}; +use style::dom::{AttributeProvider, LayoutIterator, OpaqueNode, TElement, TNode}; use style::properties::PropertyDeclarationBlock; use style::selector_parser::{AttrValue as SelectorAttrValue, Lang, PseudoElement, SelectorImpl}; use style::servo_arc::{Arc, ArcBorrow}; @@ -173,7 +172,7 @@ impl HtmlElement { HtmlNode(self.0) } - fn data_slot(&self) -> &'static atomic_refcell::AtomicRefCell> { + fn data_slot(&self) -> &'static OnceLock { dom().element_data_slot(self.0) } @@ -528,42 +527,28 @@ impl ::style::dom::TElement for HtmlElement { 0 } - unsafe fn ensure_data(&self) -> AtomicRefMut<'_, style::data::ElementData> { + unsafe fn ensure_data(&self) -> ElementDataMut<'_> { let slot = self.data_slot(); - let mut cell = slot.borrow_mut(); - if cell.is_none() { - *cell = Some(ElementData::default()); - } - AtomicRefMut::map(cell, |opt| opt.as_mut().unwrap()) + slot.get_or_init(ElementDataWrapper::default).borrow_mut() } unsafe fn clear_data(&self) { - let slot = self.data_slot(); - *slot.borrow_mut() = None; + // OnceLock-backed storage: we cannot reset the slot safely, and + // callers in the cascade driver never rely on clearing data between + // passes. Leaving the entry in place matches Stylo's Gecko backend, + // which also reuses the allocation. } fn has_data(&self) -> bool { - self.data_slot().borrow().is_some() + self.data_slot().get().is_some() } - fn borrow_data(&self) -> Option> { - let slot = self.data_slot(); - let cell = slot.borrow(); - if cell.is_some() { - Some(AtomicRef::map(cell, |opt| opt.as_ref().unwrap())) - } else { - None - } + fn borrow_data(&self) -> Option> { + self.data_slot().get().map(|w| w.borrow()) } - fn mutate_data(&self) -> Option> { - let slot = self.data_slot(); - let cell = slot.borrow_mut(); - if cell.is_some() { - Some(AtomicRefMut::map(cell, |opt| opt.as_mut().unwrap())) - } else { - None - } + fn mutate_data(&self) -> Option> { + self.data_slot().get().map(|w| w.borrow_mut()) } fn skip_item_display_fixup(&self) -> bool { @@ -654,6 +639,23 @@ impl ::style::dom::TElement for HtmlElement { } } +// --------------------------------------------------------------------------- +// style::dom::AttributeProvider +// --------------------------------------------------------------------------- + +impl AttributeProvider for HtmlElement { + fn get_attr(&self, attr: &style::LocalName, namespace: &StyleNamespace) -> Option { + self.attr_iter() + .filter(|(a, _)| { + let dom_ns: &str = &a.name.ns; + let sel_ns: &str = namespace.as_ref(); + dom_ns == sel_ns + }) + .find(|(_, stored)| *stored == attr) + .map(|(a, _)| a.value.to_string()) + } +} + // --------------------------------------------------------------------------- // selectors::Element // --------------------------------------------------------------------------- diff --git a/crates/csscascade/src/cascade.rs b/crates/csscascade/src/cascade.rs index 69bf63b7a9..b9f6e88878 100644 --- a/crates/csscascade/src/cascade.rs +++ b/crates/csscascade/src/cascade.rs @@ -16,6 +16,8 @@ use style::context::{ RegisteredSpeculativePainter, RegisteredSpeculativePainters, SharedStyleContext, StyleContext, StyleSystemOptions, ThreadLocalStyleContext, }; +use style::device::Device; +use style::device::servo::FontMetricsProvider; use style::dom::TElement; use style::font_metrics::FontMetrics; use style::media_queries::{MediaList, MediaType}; @@ -23,7 +25,6 @@ use style::properties::ComputedValues; use style::properties::style_structs::Font; use style::queries::values::PrefersColorScheme; use style::servo::animation::DocumentAnimationSet; -use style::servo::media_queries::{Device, FontMetricsProvider}; use style::servo::selector_parser::SnapshotMap; use style::servo_arc::Arc as ServoArc; use style::shared_lock::{SharedRwLock, SharedRwLockReadGuard, StylesheetGuards}; @@ -31,9 +32,8 @@ use style::stylesheets::{AllowImportRules, DocumentStyleSheet, Origin, Styleshee use style::stylist::{RuleInclusion, Stylist}; use style::traversal::resolve_style; use style::traversal_flags::TraversalFlags; -use style::values::computed::font::GenericFontFamily; +use style::values::computed::font::{GenericFontFamily, QueryFontMetricsFlags}; use style::values::computed::{CSSPixelLength, Length}; -use style::values::specified::font::QueryFontMetricsFlags; use style_traits::{CSSPixel, DevicePixel}; use stylo_atoms::Atom; use url::Url; @@ -199,14 +199,10 @@ impl CascadeDriver { } /// Flush the stylist so it picks up all appended sheets. - pub fn flush(&mut self, document: HtmlDocument) { + pub fn flush(&mut self, _document: HtmlDocument) { let guard = self.stylesheet_lock.read(); let guards = StylesheetGuards::same(&guard); - let _ = self.stylist.flush::( - &guards, - document.root_element(), - Some(&self.snapshot_map), - ); + let _ = self.stylist.flush(&guards); } /// Resolve styles for every element under `document`. diff --git a/crates/csscascade/src/dom.rs b/crates/csscascade/src/dom.rs index d84f2217bd..9f5bf8ab7f 100644 --- a/crates/csscascade/src/dom.rs +++ b/crates/csscascade/src/dom.rs @@ -11,20 +11,21 @@ use std::{ io::{self, Cursor}, }; -use atomic_refcell::AtomicRefCell; use html5ever::tendril::TendrilSink; use html5ever::{driver::ParseOpts, parse_document}; use markup5ever::interface::tree_builder::{ ElemName as ElemNameTrait, ElementFlags, NodeOrText, QuirksMode, TreeSink, }; use markup5ever::{Attribute, LocalName, Namespace, QualName}; +use std::sync::OnceLock; use style::context::QuirksMode as StyleQuirksMode; +use style::data::ElementDataWrapper; use style::properties::parse_style_attribute; use style::servo_arc::Arc; use style::stylesheets::{CssRuleType, UrlExtraData}; use style::{ - LocalName as StyleLocalName, Namespace as StyleNamespace, data::ElementData, - properties::PropertyDeclarationBlock, shared_lock::Locked, values::AtomIdent, + LocalName as StyleLocalName, Namespace as StyleNamespace, properties::PropertyDeclarationBlock, + shared_lock::Locked, values::AtomIdent, }; use stylo_atoms::Atom as WeakAtom; use tendril::StrTendril; @@ -96,8 +97,10 @@ pub struct DemoDom { document: NodeId, quirks_mode: QuirksMode, pub errors: Vec, - /// Per-node slot for Stylo [`ElementData`] (only meaningful for elements). - pub(crate) element_data: Vec>>, + /// Per-node slot for Stylo [`ElementDataWrapper`] (only meaningful for + /// elements). Populated lazily the first time Stylo's traversal calls + /// `ensure_data` on the element. + pub(crate) element_data: Vec>, } // SAFETY: The DOM is frozen after parsing; no mutable aliasing across threads. @@ -130,7 +133,7 @@ impl DemoDom { &self.nodes[id.idx()] } - pub(crate) fn element_data_slot(&self, id: NodeId) -> &AtomicRefCell> { + pub(crate) fn element_data_slot(&self, id: NodeId) -> &OnceLock { &self.element_data[id.idx()] } @@ -409,7 +412,7 @@ impl TreeSink for DemoDomBuilder { }) .collect(); - let element_data = nodes.iter().map(|_| AtomicRefCell::new(None)).collect(); + let element_data = nodes.iter().map(|_| OnceLock::new()).collect(); DemoDom { nodes, diff --git a/crates/csscascade/src/tree/mod.rs b/crates/csscascade/src/tree/mod.rs index 444deb9a15..8fa299807b 100644 --- a/crates/csscascade/src/tree/mod.rs +++ b/crates/csscascade/src/tree/mod.rs @@ -21,19 +21,19 @@ use std::mem; use std::rc::Rc; use std::sync::{Arc, OnceLock}; use style::context::QuirksMode; +use style::device::Device; +use style::device::servo::FontMetricsProvider; use style::font_metrics::FontMetrics; use style::media_queries::MediaType; use style::properties::style_structs::Font; use style::properties::{self, LonghandId}; use style::queries::values::PrefersColorScheme; -use style::servo::media_queries::{Device, FontMetricsProvider}; use style::servo_arc::Arc as ServoArc; use style::shared_lock::SharedRwLock; use style::stylist::Stylist; use style::values::computed::CSSPixelLength; use style::values::computed::Length; -use style::values::computed::font::GenericFontFamily; -use style::values::specified::font::QueryFontMetricsFlags; +use style::values::computed::font::{GenericFontFamily, QueryFontMetricsFlags}; use tendril::StrTendril; /// Options that control how a [`Tree`] is serialized back to HTML. diff --git a/crates/grida-canvas/Cargo.toml b/crates/grida-canvas/Cargo.toml index 40392fa5e1..ed12573a22 100644 --- a/crates/grida-canvas/Cargo.toml +++ b/crates/grida-canvas/Cargo.toml @@ -42,8 +42,8 @@ taffy = "0.9.2" usvg = { path = "../../third_party/usvg" } # html/css cascade (Stylo-based style resolution) csscascade = { path = "../csscascade" } -stylo = { git = "https://github.com/gridaco/stylo" } -style_config = { git = "https://github.com/gridaco/stylo", package = "stylo_config" } +stylo = "0.16" +stylo_static_prefs = "0.16" # markdown parsing # (+0.25mb wasm32-unknown-emscripten@opt-level=3) pulldown-cmark = "0.13.0" diff --git a/crates/grida-canvas/src/html/mod.rs b/crates/grida-canvas/src/html/mod.rs index 128491a066..581c0c730c 100644 --- a/crates/grida-canvas/src/html/mod.rs +++ b/crates/grida-canvas/src/html/mod.rs @@ -1116,10 +1116,19 @@ fn line_direction_to_alignment( fn css_border_to_cg(style: &ComputedValues) -> (Paints, StrokeWidth, StrokeStyle) { let border = style.get_border(); - let top_w = border.border_top_width.to_f32_px(); - let right_w = border.border_right_width.to_f32_px(); - let bottom_w = border.border_bottom_width.to_f32_px(); - let left_w = border.border_left_width.to_f32_px(); + // Stylo stores the unresolved `medium` default (3px) in `BorderSideWidth` + // even when `border-style` is `none`/`hidden`. Treat those as zero-width. + use style::values::specified::border::BorderStyle as BS; + let width_for = |w: &style::values::computed::BorderSideWidth, s: BS| -> f32 { + match s { + BS::None | BS::Hidden => 0.0, + _ => w.0.to_f32_px(), + } + }; + let top_w = width_for(&border.border_top_width, border.border_top_style); + let right_w = width_for(&border.border_right_width, border.border_right_style); + let bottom_w = width_for(&border.border_bottom_width, border.border_bottom_style); + let left_w = width_for(&border.border_left_width, border.border_left_style); let has_border = top_w > 0.0 || right_w > 0.0 || bottom_w > 0.0 || left_w > 0.0; if !has_border { diff --git a/crates/grida-canvas/src/htmlcss/collect.rs b/crates/grida-canvas/src/htmlcss/collect.rs index 723e1d3c54..fb7d152f83 100644 --- a/crates/grida-canvas/src/htmlcss/collect.rs +++ b/crates/grida-canvas/src/htmlcss/collect.rs @@ -37,7 +37,9 @@ pub(crate) fn collect_styled_tree(html: &str) -> Result, S // Without this, `display: grid` is not parsed (gated behind a pref). use std::sync::Once; static GRID_PREF: Once = Once::new(); - GRID_PREF.call_once(|| style_config::set_bool("layout.grid.enabled", true)); + GRID_PREF.call_once(|| { + stylo_static_prefs::set_pref!("layout.grid.enabled", true); + }); let dom = DemoDom::parse_from_bytes(html.as_bytes()).map_err(|e| format!("HTML parse error: {e}"))?; @@ -1019,7 +1021,7 @@ fn extract_style(tag: &str, style: &ComputedValues) -> StyledElement { let bx = style.get_box(); el.overflow_x = map_overflow(bx.overflow_x); el.overflow_y = map_overflow(bx.overflow_y); - el.overflow_clip_margin = style.get_margin().clone_overflow_clip_margin().px(); + el.overflow_clip_margin = style.get_margin().clone_overflow_clip_margin().offset.px(); // Position { @@ -1289,28 +1291,42 @@ fn extract_border(style: &ComputedValues, current_color: CGColor) -> BorderBox { BorderBox { top: BorderSide { - width: b.border_top_width.to_f32_px(), + width: side_width(&b.border_top_width, b.border_top_style), color: extract_color(&style.clone_border_top_color()), style: map_border_style(b.border_top_style), }, right: BorderSide { - width: b.border_right_width.to_f32_px(), + width: side_width(&b.border_right_width, b.border_right_style), color: extract_color(&style.clone_border_right_color()), style: map_border_style(b.border_right_style), }, bottom: BorderSide { - width: b.border_bottom_width.to_f32_px(), + width: side_width(&b.border_bottom_width, b.border_bottom_style), color: extract_color(&style.clone_border_bottom_color()), style: map_border_style(b.border_bottom_style), }, left: BorderSide { - width: b.border_left_width.to_f32_px(), + width: side_width(&b.border_left_width, b.border_left_style), color: extract_color(&style.clone_border_left_color()), style: map_border_style(b.border_left_style), }, } } +/// Stylo stores the unresolved `medium` default (3px) in `BorderSideWidth` +/// even when `border-style` is `none`/`hidden`. CSS resolves the used width +/// to zero in that case, so apply the same adjustment locally. +fn side_width( + w: &style::values::computed::BorderSideWidth, + s: style::values::specified::border::BorderStyle, +) -> f32 { + use style::values::specified::border::BorderStyle as BS; + match s { + BS::None | BS::Hidden => 0.0, + _ => w.0.to_f32_px(), + } +} + /// Extract CSS `border-image` properties (Chromium: NinePieceImage). /// /// Returns `Some(BorderImage)` when `border-image-source` is set to a @@ -1372,10 +1388,10 @@ fn extract_border_image(style: &ComputedValues, current_color: CGColor) -> Optio // LengthPercentage is an absolute value. Auto = use slice value. let biw = &b.border_image_width; let border_widths = [ - b.border_top_width.to_f32_px(), - b.border_right_width.to_f32_px(), - b.border_bottom_width.to_f32_px(), - b.border_left_width.to_f32_px(), + side_width(&b.border_top_width, b.border_top_style), + side_width(&b.border_right_width, b.border_right_style), + side_width(&b.border_bottom_width, b.border_bottom_style), + side_width(&b.border_left_width, b.border_left_style), ]; let resolve_bisw = |v: &style::values::computed::BorderImageSideWidth, border_w: f32| -> Option { @@ -1421,10 +1437,6 @@ fn extract_border_image(style: &ComputedValues, current_color: CGColor) -> Optio fn extract_outline(style: &ComputedValues) -> Outline { let o = style.get_outline(); - if !o.outline_has_nonzero_width() { - return Outline::default(); - } - // outline-style: Auto | BorderStyle(bs) let outline_style = { use style::values::computed::OutlineStyle; @@ -1438,6 +1450,11 @@ fn extract_outline(style: &ComputedValues) -> Outline { return Outline::default(); } + let width = o.outline_width.0.to_f32_px(); + if width <= 0.0 { + return Outline::default(); + } + // outline-color defaults to currentcolor per CSS spec let color = o .outline_color @@ -1446,7 +1463,7 @@ fn extract_outline(style: &ComputedValues) -> Outline { .unwrap_or_else(|| abs_color_to_cg(&style.get_inherited_text().color)); Outline { - width: o.outline_width.to_f32_px(), + width, color, style: outline_style, offset: o.outline_offset.to_f32_px(), @@ -2182,7 +2199,7 @@ fn extract_clip_path(style: &ComputedValues) -> ClipPath { } }; let resolve_radius = |r: &GenericShapeRadius< - style::values::computed::NonNegativeLengthPercentage, + style::values::computed::LengthPercentage, >| -> ShapeRadius { match r { From 2b43c78efe565f169d8dbafe3eeb8fbcf2a09fb4 Mon Sep 17 00:00:00 2001 From: Universe Date: Fri, 24 Apr 2026 15:47:52 +0900 Subject: [PATCH 4/4] chore(wpt): `just wpt [target]` recipe for one-liner runs Wraps the `cargo build` + `./wpt run` dance into a single command with sensible defaults (whole `css/` suite, structured report to `target/wpt-report.json`). Doc updated to lead with the recipe; raw `./wpt run` invocation relegated to an "under the hood" section for contributors who need custom flags. --- docs/contributing/wpt.md | 54 +++++++++++++++++++++++----------------- justfile | 20 ++++++++++++++- 2 files changed, 50 insertions(+), 24 deletions(-) diff --git a/docs/contributing/wpt.md b/docs/contributing/wpt.md index 75d4a75365..1ade8ecdc9 100644 --- a/docs/contributing/wpt.md +++ b/docs/contributing/wpt.md @@ -37,45 +37,53 @@ Apps/grida/ Do not use a random upstream WPT checkout — it lacks the `grida` product registration. -### 2. Build `grida_wpt` (release mode) +That's it — `just wpt` handles build + binary path + report output. -```sh -cargo build -p grida_wpt --release -``` - -## Run a test +## Run -From the `wpt/` sibling: +From this repo: ```sh -cd ../wpt - -./wpt run \ - --binary=../grida/target/release/grida_wpt \ - grida \ - css/css-transforms/2d-rotate-001.html +just wpt css/css-transforms/2d-rotate-001.html # one file (pilot test) +just wpt css/css-transforms/ # one subsuite +just wpt # all of css/ (default) ``` -Expected output: `SUITE_START` → `TEST_START` → `TEST_END: FAIL, -expected PASS`. The FAIL is not a setup error — it means the pipeline -ran end-to-end and cg's rendering of the pair did not match within -fuzzy tolerance. See the [primitives inventory][inventory] for what -feature gaps cause most of the fails. +Expected output on the pilot: `SUITE_START` → `TEST_START` → +`TEST_END: FAIL, expected PASS`. The FAIL is not a setup error — it +means the pipeline ran end-to-end and cg's rendering of the pair did +not match within fuzzy tolerance. See the [primitives inventory][inventory] +for what feature gaps cause most of the fails. [inventory]: https://github.com/gridaco/grida/issues -### Wider runs +Wide runs write a structured report to `target/wpt-report.json`. Mine +it with `jq`: ```sh -./wpt run \ - --binary=../grida/target/release/grida_wpt \ - grida \ - css/css-transforms/ +jq '.results | group_by(.status) | map({status: .[0].status, count: length})' \ + target/wpt-report.json ``` The plugin is reftest-only; `testharness`, `crashtest`, `wdspec`, and `print-reftest` types warn "Unsupported test type" and are skipped. +### Under the hood + +`just wpt` runs: + +```sh +cargo build -p grida_wpt --release +cd ../wpt && ./wpt run \ + --binary=/target/release/grida_wpt \ + --log-wptreport=/target/wpt-report.json \ + --log-mach-level=info \ + grida +``` + +Skip the recipe and call `./wpt run` directly if you need custom +flags (`--include-file`, `--repeat`, etc.). + ## What the plugin does Pipeline: `wptrunner` invokes `grida_wpt render --url --out diff --git a/justfile b/justfile index a36525c15b..5e40b0e2d1 100644 --- a/justfile +++ b/justfile @@ -32,4 +32,22 @@ serve canvas wasm: # Package canvas WASM package canvas wasm: - just --justfile crates/grida-canvas-wasm/justfile package \ No newline at end of file + just --justfile crates/grida-canvas-wasm/justfile package + +# Run web-platform-tests against the grida_wpt binary. Requires the +# `gridaco/wpt` fork cloned as a sibling (../wpt). Default target is +# the whole css/ suite; pass a narrower path for pilot runs. +# See docs/contributing/wpt.md for details. +# +# just wpt # css/ (the default) +# just wpt css/css-transforms/ # one subsuite +# just wpt css/css-transforms/2d-rotate-001.html # one file +wpt target="css/": + cargo build -p grida_wpt --release + cd ../wpt && ./wpt run \ + --binary="{{justfile_directory()}}/target/release/grida_wpt" \ + --log-wptreport="{{justfile_directory()}}/target/wpt-report.json" \ + --log-mach=- \ + --log-mach-level=info \ + grida \ + {{target}} \ No newline at end of file