Skip to content

feat(htmlcss): in-tree Skia-backed SVG renderer (htmlcss::svg)#698

Open
softmarshmallow wants to merge 59 commits intomainfrom
canary
Open

feat(htmlcss): in-tree Skia-backed SVG renderer (htmlcss::svg)#698
softmarshmallow wants to merge 59 commits intomainfrom
canary

Conversation

@softmarshmallow
Copy link
Copy Markdown
Member

@softmarshmallow softmarshmallow commented Apr 28, 2026

Summary

Introduces htmlcss::svg, a new in-tree Skia-backed SVG renderer that replaces all skia_safe::svg::Dom usage in the htmlcss module. Pipeline shape is Blink-derived (parse → style → layout → paint); resvg/usvg are secondary references. Companion design study at docs/wg/feat-2d/htmlcss-svg.md.

Why

htmlcss already accepts SVG content in two places — standalone .svg bytes and inline <svg> — and both delegated to Skia's svg::Dom (SkSVGDOM). Per Chromium's own docs, SkSVGDOM is "for embedders that need standalone SVG rendering without DOM/CSS/JS integration" — Blink itself does not use it. We want the same thing Blink wants: a pipeline that integrates with the rest of htmlcss (Stylo cascade, font repository, image provider) and validates against the resvg-test-suite (1,679 fixtures already on disk under fixtures/local/resvg-test-suite/).

What's in this PR

Design studydocs/wg/feat-2d/htmlcss-svg.md. Captures the architecture decisions (Blink lineage, what we adopt vs. differ from Blink / usvg / resvg) before code lands. Each section ends with an explicit Adopt / Differ line so deviations are auditable in one pass.

New modulecrates/grida/src/htmlcss/svg/:

  • dom/ — XML → typed SvgDocument via roxmltree. Path d= parser with arc-to-cubic decomposition. <switch> resolved at parse time.
  • style/ — temporary in-tree CSS subset; the Stylo bridge is future work.
  • layout/ — viewport + viewBox / preserveAspectRatio matrix, transform composition, bbox propagation, <use> shadow-instance expansion.
  • resources/url(#id) resolution for paint servers (gradients, patterns), clippers, maskers, markers, and the filter-effect graph.
  • paint/ — Blink-shaped painter family: root_painter, container_painter, shape_painter, image_painter, text_painter, marker_painter, pattern_painter. One DFS pass; isolate-on-effect group rule (resvg formulation).
  • context.rsRenderContext bundling ImageProvider / FontResolver / CssLoader.

Wiring changes in htmlcss/{mod,paint,collect,style}.rs:

  • htmlcss::render_svg now routes through htmlcss::svg::render_to_picture instead of skia_safe::svg::Dom.
  • htmlcss::paint::paint_inline_svg now routes through htmlcss::svg::render_into.
  • ReplacedContent::svg_xml, collect::serialize_svg_subtree, and collect::detect_svg_element stay as-is — they already extract the <svg> subtree as XML, which is what the new entry points consume.

Supporting research under docs/wg/research/chromium/:

  • svg/module-structure.md — directory organization in third_party/blink/renderer/ (the directory-level companion to pipeline.md).
  • svg/clip-path.md, svg/text-on-path.md, svg/fe-image.md, svg/fe-tile.md — feature-level Blink surveys cited from the design study.
  • external-css.md@import / <link rel=stylesheet> lifecycle, cycle detection, cascade ordering.

What's deliberately not in this PR

Everything Blink does that doesn't apply to a static, single-shot, embedder-side renderer:

  • No SMIL, CSS animations, or Web Animations on SVG.
  • No scripting, no baseVal/animVal tear-offs.
  • No invalidation graph (single-shot renderer; nothing to invalidate).
  • No hit testing, no accessibility surface.
  • No compositor property trees — we emit a single Picture and let Skia handle internal compositing.

The Stylo bridge is also out of scope; the in-tree CSS subset under style/ is explicitly a placeholder for it.

Status

Best-effort renderer: features still under construction (notably text, filters) render unsupported elements as gaps rather than falling back to SkSVGDOM. The intent is to make missing pixels visible so the work surfaces. Per-feature implementation proceeds through .agents/skills/dev-render-htmlcss-feature/SKILL.md, validated against the resvg-test-suite reftest corpus.

Test plan

  • cargo test -p grida --test htmlcss_svg_checkpoint1 passes.
  • Existing render_svg / paint_inline_svg tests in htmlcss/ still pass with the new backend.
  • resvg-test-suite reftest baseline does not regress (grida_dev reftest runner).
  • cargo check -p grida -p grida-canvas-wasm -p grida_dev clean.

Summary by CodeRabbit

  • New Features

    • Shipping a full HTML/CSS SVG renderer: inline & standalone SVGs with shapes, paths, rich text (including text-on-path), gradients, patterns, clip-paths, masks, filters, markers, images and ; improved CSS/@import, font and image resolution.
  • Tools

    • Reftest dashboard, baking workflow for Chrome baselines, inspector/summary commands, and an example CLI to render SVG→PNG.
  • Tests

    • New visual checkpoint and architecture tests for the SVG pipeline.
  • Chore

    • CLI/reftest tooling now targets the new renderer (previous Skia DOM backend removed).
  • Documentation

    • Extensive design and research docs mapping SVG pipeline and Chromium comparisons.

Adds upstream surveys covering Blink's SVG rendering (clip-path, feImage,
feTile, textPath, module structure) plus external-CSS lifecycle, and a
companion feat-2d doc for htmlcss-svg.

Rewrites the research skill to enforce pure-survey docs: forbid Grida
content in research docs, add a pre-save review checklist, drop plan-doc
coupling.
@softmarshmallow softmarshmallow added documentation Improvements or additions to documentation svg labels Apr 28, 2026
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
blog Ready Ready Preview, Comment Apr 29, 2026 7:41pm
docs Ready Ready Preview, Comment Apr 29, 2026 7:41pm
grida Ready Ready Preview, Comment Apr 29, 2026 7:41pm
viewer Ready Ready Preview, Comment Apr 29, 2026 7:41pm
3 Skipped Deployments
Project Deployment Actions Updated (UTC)
backgrounds Ignored Ignored Preview Apr 29, 2026 7:41pm
code Ignored Ignored Apr 29, 2026 7:41pm
legacy Ignored Ignored Apr 29, 2026 7:41pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 28, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a3e83cea-15de-410c-955f-309b09e51727

📥 Commits

Reviewing files that changed from the base of the PR and between b8845db and 2b5f8ce.

📒 Files selected for processing (42)
  • .agents/skills/dev-render-htmlcss-feature/SKILL.md
  • .agents/skills/dev-render-htmlcss-svg-feature/SKILL.md
  • .agents/skills/render-reftest/SKILL.md
  • .gitignore
  • crates/grida/src/htmlcss/svg/README.md
  • crates/grida/src/htmlcss/svg/layout/bbox.rs
  • crates/grida/src/htmlcss/svg/layout/transform.rs
  • crates/grida/src/htmlcss/svg/layout/viewport.rs
  • crates/grida/src/htmlcss/svg/paint/clip_path_clipper.rs
  • crates/grida/src/htmlcss/svg/paint/effects.rs
  • crates/grida/src/htmlcss/svg/paint/scoped_svg_paint_state.rs
  • crates/grida/src/htmlcss/svg/paint/svg_container_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_image_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_marker_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_shape_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs
  • crates/grida/src/htmlcss/svg/paint/svg_use_painter.rs
  • crates/grida/src/htmlcss/svg/resources/clipper.rs
  • crates/grida/src/htmlcss/svg/resources/filter.rs
  • crates/grida/src/htmlcss/svg/resources/gradient.rs
  • crates/grida/src/htmlcss/svg/resources/masker.rs
  • crates/grida/src/htmlcss/svg/resources/pattern.rs
  • crates/grida/src/htmlcss/svg/resources/svg_filter_builder.rs
  • crates/grida/src/htmlcss/svg/resources/svg_resources.rs
  • crates/grida_dev/AGENTS.md
  • crates/grida_dev/scripts/reftest-bake-chrome.sh
  • crates/grida_dev/scripts/reftest-run.sh
  • crates/grida_dev/scripts/reftest-view.sh
  • crates/grida_dev/scripts/reftest_bake_chrome.ts
  • crates/grida_dev/scripts/reftest_dashboard.html
  • crates/grida_dev/src/reftest/args.rs
  • crates/grida_dev/src/reftest/bake.rs
  • crates/grida_dev/src/reftest/compare.rs
  • crates/grida_dev/src/reftest/config.rs
  • crates/grida_dev/src/reftest/inspect.rs
  • crates/grida_dev/src/reftest/mod.rs
  • crates/grida_dev/src/reftest/oracles.rs
  • crates/grida_dev/src/reftest/render.rs
  • crates/grida_dev/src/reftest/report.rs
  • crates/grida_dev/src/reftest/runner.rs
  • crates/grida_dev/src/reftest/summary.rs
  • crates/grida_dev/src/reftest/view.rs
✅ Files skipped from review due to trivial changes (5)
  • crates/grida_dev/src/reftest/compare.rs
  • crates/grida/src/htmlcss/svg/README.md
  • crates/grida_dev/AGENTS.md
  • .agents/skills/dev-render-htmlcss-feature/SKILL.md
  • .gitignore
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/grida_dev/scripts/reftest-view.sh
  • crates/grida_dev/scripts/reftest-run.sh

Walkthrough

Adds an in-tree SVG renderer under htmlcss::svg with parsing via DemoDom, CSS support, layout/viewport/transform helpers, full paint pipeline (shapes, text, images, markers, ), resource resolvers (gradients, filters, patterns, masks, clipPaths), tests, examples, reftest tooling, and docs; routes inline/standalone SVG rendering through the new renderer and removes the prior skia-svG example.

Changes

Cohort / File(s) Summary
SVG Renderer Entry & Context
crates/grida/src/htmlcss/svg/mod.rs, crates/grida/src/htmlcss/svg/context.rs, crates/grida/src/htmlcss/svg/error.rs
Adds public entrypoints (render_into, render_to_picture*), RenderContext (images/css/fonts), and SvgError.
SVG DOM Helpers & Parsers
crates/grida/src/htmlcss/svg/dom/...
.../attrs.rs, .../element.rs, .../href.rs, .../parser.rs, .../path_d.rs, mod.rs
New DOM helper layer: attribute parsers (length/color/viewBox/transform/points), element-kind dispatch, href/fragment utilities, SVG-root detection via DemoDom, and permissive d= → Skia Path parser.
SVG Style/Cascade
crates/grida/src/htmlcss/svg/style/{mod,cascade,stylesheet,stylo_bridge}.rs
Implements inline <style> parsing with @import preload and cycle guard, selector matching, specificity, and single-property cascade lookup; stylo_bridge stub added.
Layout & Geometry
crates/grida/src/htmlcss/svg/layout/..., crates/grida/src/htmlcss/svg/geometry/...
Adds viewport/viewBox matrix logic, element object-bbox, transform-origin resolution, and CSS basic-shape parsing/builders for clip-paths.
Paint Pipeline & Painters
crates/grida/src/htmlcss/svg/paint/... (many files)
Introduces PaintCtx, painters and trait surface (root/container/shape/text/image/marker/use), clipping, visibility, effects (opacity/filter), marker placement, text shaping, and picture recording helpers.
Resources (gradients/filters/patterns/masks/clipper)
crates/grida/src/htmlcss/svg/resources/...
New resource system: Resources index, gradient→Shader, pattern→repeating Shader via PictureRecorder, filter builder → Skia ImageFilter DAG, clipPath→Path resolver, mask resolver, resource container trait and cache placeholder.
Integration & htmlcss updates
crates/grida/src/htmlcss/{mod,collect,paint,style}.rs, crates/grida/src/htmlcss/paint.rs, crates/grida/src/htmlcss/style.rs
Routes standalone/inline SVG rendering through htmlcss::svg and updates inline-SVG paint to call render_into (inline path now accepts ImageProvider); docs/comments updated.
Examples, Tools & Reftest
crates/grida_dev/src/reftest/*, crates/grida_dev/examples/render_one_svg.rs, crates/grida/examples/*, crates/grida_dev/scripts/*
Removes old tool_sk_svgdom example, adds render_one_svg example, reworks reftest runner/args to use htmlcss renderer, adds bake/view/inspect/summary subcommands and Playwright bake scripts/dashboard.
Tests & Architecture Checks
crates/grida/tests/{htmlcss_svg_architecture,htmlcss_svg_checkpoint1}.rs
Adds module-phase architecture test enforcing layering and visual checkpoint tests rendering SVG fixtures.
Docs & Research
docs/wg/research/chromium/**, docs/wg/feat-2d/htmlcss-svg.md, .agents/skills/**
Adds extensive SVG research/design docs, test-suite/process docs, and narrows .agents/skills/research/SKILL.md scope to upstream surveys/research docs.
Small edits & housekeeping
crates/grida/examples/tool_gen_bench_fixture.rs, crates/grida/src/htmlcss/collect.rs, .gitignore
Removed two wildcard imports from an example, adjusted doc-comments to use in-tree renderer, ignored reftest package files.

Sequence Diagram(s)

sequenceDiagram
    participant Host as Host API
    participant Entry as htmlcss::svg Entry
    participant Parser as DemoDom Parser
    participant Style as Stylesheet
    participant Resources as Resources Index
    participant Root as Svg Root Painter
    participant Container as Container Painter
    participant Element as Element Painter
    participant Canvas as Skia Canvas

    Host->>Entry: render_into(canvas, svg_xml, viewport, images)
    Entry->>Parser: parse_dom(svg_xml)
    Parser->>Style: Stylesheet::collect(dom, css_loader)
    Entry->>Resources: Resources::build(dom, css_loader)
    Entry->>Root: paint_root(canvas, dom, viewport, RenderContext)
    Root->>Root: compute viewBox→viewport CTM, apply root clip/filter/opacity
    Root->>Container: paint_children(canvas, ctx, root_id)
    Container->>Container: iterate children, cascade properties
    Container->>Element: dispatch to element-specific painter
    Element->>Resources: resolve paint-server/filter/mask/clip
    Element->>Canvas: draw (fill/stroke/text/image/marker)
    Container-->>Root: children painted
    Root-->>Entry: Ok / SvgError
    Entry-->>Host: return
Loading
sequenceDiagram
    participant Paint as Container Painter
    participant Clip as Clipper
    participant Filter as Filter Resolver
    participant Mask as Mask Resolver
    participant Recorder as PictureRecorder
    participant Canvas as Skia Canvas

    Paint->>Paint: read transform, opacity, clip-path, filter, mask
    Paint->>Clip: apply_clip_path(raw)
    Clip->>Canvas: canvas.clip_path(path, Intersect)
    Paint->>Filter: resolve_filter_chain(value)
    Filter->>Recorder: may record subtree → ImageFilter
    Paint->>Mask: resolve mask → MaskInvocation
    Mask->>Recorder: paint mask content into layer (DstIn)
    Paint->>Canvas: save_layer(alpha) if opacity<1
    Paint->>Canvas: concat_matrix(transform)
    Paint->>Paint: dispatch element painter
    Paint->>Canvas: restore() layers in reverse order
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested labels

canvas

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch canary

@softmarshmallow softmarshmallow changed the title docs(research): Blink SVG render surveys + research-skill tightening feat(htmlcss): in-tree Skia-backed SVG renderer (htmlcss::svg) Apr 28, 2026
Strict module structure aligned with Chromium/Blink's `core/svg/`,
`core/layout/svg/`, and `core/paint/` split. Reftest score
unchanged at 89.50% (1679 fixtures, baseline match — zero regression).

Moves:
- paint/pattern_painter.rs -> resources/pattern.rs (pattern is a
  resource container; produces Shader output, not a paint operation)
- layout/use_expand.rs -> paint/svg_use_painter.rs (paint logic
  belongs in paint/, not layout/)
- paint/basic_shape_clip.rs -> geometry/basic_shape.rs (new
  geometry/ module owns shape -> SkPath data)

Renames (Blink-aligned filenames):
- paint/{root,container,shape,image,text,marker,object}_painter.rs
  -> paint/svg_*_painter.rs
- paint/paint_state.rs -> paint/scoped_svg_paint_state.rs
- paint/clip.rs -> paint/clip_path_clipper.rs
- resources/container.rs -> resources/svg_resources.rs
- resources/filter_effect.rs -> resources/svg_filter_builder.rs

New files:
- error.rs (extracted SvgError from mod.rs)
- geometry/mod.rs + basic_shape.rs (phase-neutral geometry module)
- layout/layout_svg_element.rs (bridge type for future migration)
- paint/svg_painter.rs (uniform SvgPainter trait contract)
- resources/svg_resource_container.rs (uniform resource trait)
- resources/cache.rs (per-client cache scaffolding)
- style/stylo_bridge.rs (placeholder for Stylo migration)
- tests/htmlcss_svg_architecture.rs (CI rule enforcement: no Skia
  operation types in dom/style/geometry/layout, no Canvas argument
  in resources/, phase dependency direction)

Also cleans up 15 pre-existing clippy warnings (doc_lazy_continuation,
single_match, manual_strip, etc.) so the commit hook passes.

Snapshot of pre-refactor state: backup/svg-pre-refactor

See docs/wg/research/chromium/svg/module-structure.md for the
Blink layout this mirrors.
…rop dead filter::apply

Continues the Blink-aligned reorganization with the items previously
flagged as regression risk. Reftest score unchanged at 89.50%
(1679 fixtures, exact baseline match).

dom/element.rs split into three:
- dom/element.rs: keeps ElementKind, get_attr, element_kind (pure DOM)
- paint/visibility.rs: is_painted, has_display_none, is_visible_self,
  is_visible_inherited (paint dispatch concerns)
- style/cascade.rs: cascade_property, get_attr_or_style (single funnel
  for property resolution; future Stylo migration target)

Updates the three callers (svg_root_painter, svg_container_painter,
svg_shape_painter) to import from the new locations.

paint/svg_text_painter.rs (2,468 LOC) split into a directory:
- svg_text_painter/mod.rs: orchestrator, attribute flattening, layout
- svg_text_painter/shaping.rs (~190 LOC): SkShaper integration,
  ShapedGlyph, compute_kerned_advances, shape_text
Further text submodules (textPath, layout algorithm) deferred — their
cross-dependencies risk regression for marginal gain.

Removes resources/filter.rs::apply (dead code; the painter applies
filters inline at svg_container_painter.rs). Eliminates the only
resources/ Canvas violation, removing two allowlist entries from
tests/htmlcss_svg_architecture.rs.

README module map updated.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de1265b890

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +348 to +350
let needle = format!("{}=", name);
let start = tag.find(&needle)? + needle.len();
let rest = &tag[start..];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse SVG width/height attributes with token boundaries

The new SVG embed sizing helper looks up width/height with a raw substring search (find("width=")), which can match unrelated attributes like stroke-width on the root <svg> and return the wrong intrinsic size. In that case imported SVGs get an incorrect node size (often tiny), so the embed path renders at the wrong scale. Please parse attributes with boundary-aware matching (or reuse an XML parser) so only exact width/height attributes are considered.

Useful? React with 👍 / 👎.

Comment on lines +36 to +40
let mut anc = node.parent;
while let Some(id) = anc {
if id == target_id {
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detect non-ancestor <use> cycles before recursing

The cycle guard only checks target_id against the current node and its ancestors, so mutual references between siblings (e.g. #a -> #b -> #a) still recurse until MAX_USE_DEPTH. That paints intermediate content multiple times and can significantly skew output/perf on cyclic fixtures instead of stopping at the first detected loop. Track visited <use>/target ids in the recursion chain so any repeated target aborts immediately.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
crates/grida_dev/src/reftest/args.rs (1)

21-31: ⚠️ Potential issue | 🟡 Minor

Help text still names the removed grida::htmlcss::render_svg entrypoint.

This backend now routes through the new htmlcss::svg renderer, so these comments are stale and point readers at an API this PR is explicitly retiring. Please update the wording to the current entrypoint (render_to_picture* or the reftest helper) so future debugging starts in the right place.

Also applies to: 84-89

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida_dev/src/reftest/args.rs` around lines 21 - 31, Update the stale
help text that references the removed entrypoint grida::htmlcss::render_svg to
instead mention the current htmlcss::svg renderer entrypoint(s) such as
render_to_picture (or render_to_picture_sync/async if applicable) or the reftest
helper used in this PR; locate the text blocks describing the Htmlcss backend
(references to "render_svg" in the help/comments around the Htmlcss choice) and
replace the old API name and description with a brief note pointing users to
htmlcss::svg's render_to_picture* entrypoint or the reftest helper so readers
are directed to the correct code path.
docs/wg/research/chromium/svg/index.md (1)

25-45: ⚠️ Potential issue | 🟡 Minor

Add the frontmatter fields required for maintained Markdown docs.

This is a meaningful edit to an actively maintained research index, but the page still has no format: md, description, or keywords in frontmatter. Please add them as part of this update. As per coding guidelines, docs/{wg,reference,editor,forms,platform,with-figma,design,math}/**/*.{md,mdx} should add SEO frontmatter with title, description, and keywords when meaningfully editing actively maintained doc pages, and docs/**/*.md should add format: md for plain Markdown.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/wg/research/chromium/svg/index.md` around lines 25 - 45, Add YAML
frontmatter at the top of index.md including: format: "md", title: a concise
page title (e.g., "SVG Research Index"), description: one-sentence summary of
the page, and keywords: a short array of relevant terms (e.g.,
["SVG","Chromium","rendering","layout"]); ensure the frontmatter is valid YAML
(--- delimited) and placed before the existing table so the page conforms to the
docs frontmatter policy.
crates/grida/src/htmlcss/paint.rs (1)

800-816: ⚠️ Potential issue | 🟠 Major

Inline <svg> failure still falls back to placeholder rendering

When content.svg_xml exists and paint_inline_svg fails, control falls through and draws the generic placeholder. That contradicts the “no fallback” intent and makes inline SVG failures look like <img> missing-resource failures.

Suggested control-flow fix
-    let svg_handled = if let Some(ref xml) = content.svg_xml {
-        paint_inline_svg(canvas, xml.as_bytes(), w, h, images)
-    } else {
-        false
-    };
-
-    if svg_handled {
+    if let Some(ref xml) = content.svg_xml {
+        let _ = paint_inline_svg(canvas, xml.as_bytes(), w, h, images);
         canvas.restore();
         return;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/paint.rs` around lines 800 - 816, The current flow
lets inline SVG failures fall back to the generic image placeholder: when
content.svg_xml is Some and paint_inline_svg(...) returns false we must still
treat that as terminal. Update the control flow around
svg_handled/paint_inline_svg so that if content.svg_xml.is_some() and
paint_inline_svg(...) returns false you restore the canvas and return early (no
fallback to images.get or placeholder). Locate the logic using svg_handled,
content.svg_xml, paint_inline_svg, canvas.restore, and images.get (or image_opt)
and change it so any inline-SVG path that fails exits immediately instead of
letting execution fall through.
🟠 Major comments (28)
crates/grida/src/htmlcss/svg/paint/svg_text_painter/shaping.rs-157-168 (1)

157-168: ⚠️ Potential issue | 🟠 Major

Do not borrow the next cluster's advance for zero-glyph characters.

position(|g| g.cluster >= start_byte) makes a combining mark or other char with no glyph at start_byte inherit the next character's advance. That breaks the zero-advance behavior described on Lines 121-123 and shifts per-character positioning forward.

🩹 Proposed fix
-        let start_glyph = shaped.iter().position(|g| g.cluster >= start_byte);
+        let start_glyph = shaped.iter().position(|g| g.cluster == start_byte);
         let end_glyph = shaped.iter().position(|g| g.cluster >= end_byte);
crates/grida/src/htmlcss/svg/paint/svg_text_painter/shaping.rs-194-199 (1)

194-199: ⚠️ Potential issue | 🟠 Major

Stop forcing every shaped run to LTR.

This always passes true for left_to_right, so RTL text will shape and cluster in the wrong direction. Arabic/Hebrew SVG text needs direction to come from the caller or a bidi pass.

🩹 Proposed fix
-pub(super) fn shape_text(text: &str, font: &Font) -> (Vec<ShapedGlyph>, Point) {
+pub(super) fn shape_text(
+    text: &str,
+    font: &Font,
+    left_to_right: bool,
+) -> (Vec<ShapedGlyph>, Point) {
     if text.is_empty() {
         return (Vec::new(), Point::new(0.0, 0.0));
     }
@@
     shaper.shape(
         text,
         font,
-        /* left_to_right */ true,
+        left_to_right,
         f32::INFINITY,
         &mut collector,
     );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_text_painter/shaping.rs` around lines
194 - 199, The code currently forces left-to-right shaping by calling
shaper.shape(..., /* left_to_right */ true), which breaks RTL scripts; change
the call site in shaping.rs to pass the actual direction instead of a hardcoded
true—either accept a boolean or an enum direction parameter from the caller
(e.g., a new left_to_right: bool or text_direction argument on the surrounding
function) or run a bidi pass and derive the direction there, then forward that
value into shaper.shape; update the function signature that calls shaper.shape
(and its callers) to propagate the real direction so Arabic/Hebrew text clusters
correctly.
crates/grida/src/htmlcss/svg/dom/attrs.rs-239-283 (1)

239-283: ⚠️ Potential issue | 🟠 Major

Reject malformed transform argument lists instead of defaulting them.

filter_map drops bad tokens, and the translate/scale/rotate branches then fill missing args with defaults. That turns invalid input like translate(foo) or rotate(45, 10) into a different valid matrix instead of returning None, which contradicts the contract on Lines 4-6.

🩹 Proposed fix
-        let nums: Vec<f32> = s[args_start..i]
-            .split(|c: char| c.is_ascii_whitespace() || c == ',')
-            .filter(|p| !p.is_empty())
-            .filter_map(|p| p.parse::<f32>().ok())
-            .collect();
+        let nums: Vec<f32> = s[args_start..i]
+            .split(|c: char| c.is_ascii_whitespace() || c == ',')
+            .filter(|p| !p.is_empty())
+            .map(|p| p.parse::<f32>())
+            .collect::<Result<_, _>>()
+            .ok()?;
@@
-            "translate" => {
+            "translate" if nums.len() == 1 || nums.len() == 2 => {
                 let tx = *nums.first().unwrap_or(&0.0);
                 let ty = *nums.get(1).unwrap_or(&0.0);
                 Matrix::translate((tx, ty))
             }
-            "scale" => {
+            "scale" if nums.len() == 1 || nums.len() == 2 => {
                 let sx = *nums.first().unwrap_or(&1.0);
                 let sy = *nums.get(1).unwrap_or(&sx);
                 Matrix::scale((sx, sy))
             }
-            "rotate" => {
+            "rotate" if nums.len() == 1 || nums.len() == 3 => {
                 let a = *nums.first().unwrap_or(&0.0);
                 if nums.len() == 3 {
                     Matrix::rotate_deg_pivot(a, (nums[1], nums[2]))
                 } else {
                     Matrix::rotate_deg(a)
                 }
             }
-            "skewX" => {
+            "skewX" if nums.len() == 1 => {
                 let a = nums.first().copied().unwrap_or(0.0).to_radians();
                 Matrix::skew((a.tan(), 0.0))
             }
-            "skewY" => {
+            "skewY" if nums.len() == 1 => {
                 let a = nums.first().copied().unwrap_or(0.0).to_radians();
                 Matrix::skew((0.0, a.tan()))
             }
             _ => return None,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/dom/attrs.rs` around lines 239 - 283, The parser
currently drops invalid tokens via filter_map into nums and then silently
supplies defaults, so inputs like translate(foo) or rotate(45,10) get coerced
into a different valid matrix; instead, change the token collection to fail on
any parse error (e.g. collect tokens as Result<Vec<f32>, _> and return None on
Err) and validate the argument count per transform exactly: "matrix" must have
6, "translate" must have 1 or 2, "scale" 1 or 2, "rotate" 1 or 3,
"skewX"/"skewY" exactly 1; if counts don’t match return None; keep the existing
Matrix::... constructors (Matrix::new_identity, Matrix::translate,
Matrix::scale, Matrix::rotate_deg/_pivot, Matrix::skew) but only call them after
successful parse and count validation.
crates/grida_dev/src/main.rs-389-448 (1)

389-448: ⚠️ Potential issue | 🟠 Major

Embed mode drops the source base path for external SVG resources.

Unlike scene_from_html_embed_path, this path neither preloads referenced assets nor preserves a base directory. Once the SVG is collapsed into node.html, relative <image href>, <feImage href>, or linked stylesheet URLs no longer have enough context to resolve against the dropped file, so local SVGs with external resources will render with missing content in embed mode.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida_dev/src/main.rs` around lines 389 - 448,
scene_from_svg_embed_path drops the SVG file's base path so relative resources
(image href, feImage, linked stylesheets) no longer resolve; fix it by
preserving the SVG's base directory and either preloading referenced assets into
HtmlEmbedScene.images (same mechanism used by scene_from_html_embed_path) or by
injecting a proper HTML base element into node.html pointing to the file:// base
(derived from path.parent()), and ensure the logic that discovers and loads
resources mirrors the resource-preload code in scene_from_html_embed_path
(update scene_from_svg_embed_path, HtmlEmbedScene construction, and
NodeFactory/HTMLEmbed creation accordingly).
crates/grida_dev/src/main.rs-347-363 (1)

347-363: ⚠️ Potential issue | 🟠 Major

Don't coerce arbitrary SVG length units into px.

attr() stops at the first non-numeric character, so values like 100%, 1em, or 10cm become concrete pixel sizes here. That bypasses the viewBox/measurement fallback and produces wrong embed dimensions for common responsive SVGs. Only accept unitless or explicit px lengths in this helper.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida_dev/src/main.rs` around lines 347 - 363, The helper attr()
currently strips after the first non-numeric char which converts values like
"100%", "1em", or "10cm" into pixels; change it to only accept unitless numbers
or explicit "px" units: after computing numeric_end from raw, extract unit =
raw[numeric_end..].trim(); if unit is empty or exactly "px" continue to parse
and return the f32 (keeping the existing v.max(1.0) clamp), otherwise return
None so non-px units (%, em, cm, etc.) are rejected and the viewBox/measurement
fallback can run; keep this logic inside attr() and the existing call site (the
if let (Some(w), Some(h)) = (attr(tag, "width"), attr(tag, "height")) { ... }
should continue to use the updated attr().
crates/grida/tests/htmlcss_svg_architecture.rs-90-120 (1)

90-120: ⚠️ Potential issue | 🟠 Major

This gate is too text-based to enforce the boundary reliably.

content.contains(...) misses common Rust forms like use skia_safe::{Canvas, Paint}; or aliased imports, and it can also fail on comments/docstrings that only mention a forbidden path. That gives you both false negatives and false positives in a test that is supposed to be authoritative.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/tests/htmlcss_svg_architecture.rs` around lines 90 - 120, The
test currently scans file text with content.contains(...) which yields false
positives/negatives; replace the substring check in assert_no_imports by parsing
each Rust file into a syn::File and visiting its AST to find actual import/use
trees and path usages (handle use trees, nested paths, aliased imports, and
explicit paths like skia_safe::Canvas) rather than raw text search; implement a
small visitor (using syn::visit::Visit) that records any Path or UseTree that
begins with a forbidden segment and then call is_allowlisted(&rel, f) for each
recorded match (preserve the violations push/format and panic behavior), keeping
rs_files_under, read, and is_allowlisted as helpers.
crates/grida/src/htmlcss/mod.rs-219-223 (1)

219-223: ⚠️ Potential issue | 🟠 Major

Don't promise malformed-input errors unless this path validates strictly.

The updated malformed-SVG test below now accepts Ok as well as Err, so callers can no longer treat render_svg() as an XML validity check even though this doc comment says they can. Either add a strict parse before render_to_picture, or relax the public contract/docs to match the best-effort behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/mod.rs` around lines 219 - 223, The doc comment for
render_svg promises Err "only when the input is malformed XML" but
render_to_picture is best-effort and can return Ok for invalid XML; update the
public contract to reflect that behavior: change the comment on fn render_svg to
say it performs a best-effort rendering and returns Err only on
rendering/parsing failures (not as a strict XML validator), and ensure callers
rely on this contract (or, if you prefer strict validation instead, perform a
strict parse step—e.g., using roxmltree::Document::parse or quick-xml—inside
render_svg before calling crate::htmlcss::svg::render_to_picture and return an
Err on parse failure). Include references to render_svg and
crate::htmlcss::svg::render_to_picture when making the change.
crates/grida/src/htmlcss/svg/layout/bbox.rs-8-93 (1)

8-93: 🛠️ Refactor suggestion | 🟠 Major

Use math2 for layout geometry and convert at paint boundary

This layout helper is doing core geometry math with skia_safe::Rect and manual min/max math. Please keep geometry in math2 types here and convert to Skia only at the paint/resource boundary.

As per coding guidelines, crates/grida/**/*.rs should “Use math2 crate for geometry and common math operations.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/layout/bbox.rs` around lines 8 - 93, The
function element_object_bbox (and any helpers like apply_node_transform)
currently does layout math with skia_safe::Rect and manual min/max; change it to
use math2 geometry types (e.g., math2::rect::Rect, math2::point::Point/vec2,
math2::size::Size) for all computations and aggregations, replace uses of
Rect::from_xywh, Rect::new, .join(), .is_empty(), and manual min/max with the
corresponding math2 constructors and methods, and have this function return a
math2 Rect (or internal geometry type); perform a single conversion from math2
Rect to skia_safe::Rect at the paint/resource boundary instead of inside layout
code.
crates/grida_dev/scripts/reftest-view.sh-34-40 (1)

34-40: ⚠️ Potential issue | 🟠 Major

Prevent destructive cleanup of existing result-dir content

The script can overwrite/remove pre-existing index.html / tests paths in RESULT_DIR, and ln -sf without -n behaves badly when the destination is an existing directory.

Proposed hardening
 LINK="$RESULT_DIR/index.html"
 TESTS_LINK="$RESULT_DIR/tests"
-ln -sf "$DASHBOARD" "$LINK"
+
+for p in "$LINK" "$TESTS_LINK"; do
+  if [[ -e "$p" && ! -L "$p" ]]; then
+    echo "refusing to overwrite non-symlink path: $p" >&2
+    exit 1
+  fi
+done
+
+ln -sfn "$DASHBOARD" "$LINK"
 if [[ -d "$FIXTURES_DIR" ]]; then
-  ln -sf "$FIXTURES_DIR" "$TESTS_LINK"
+  ln -sfn "$FIXTURES_DIR" "$TESTS_LINK"
 else
   echo "warning: fixtures not found at $FIXTURES_DIR — original SVG tile will be empty" >&2
 fi
-trap 'rm -f "$LINK" "$TESTS_LINK"' EXIT INT TERM
+trap '[[ -L "$LINK" ]] && rm -f "$LINK"; [[ -L "$TESTS_LINK" ]] && rm -f "$TESTS_LINK"' EXIT INT TERM
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida_dev/scripts/reftest-view.sh` around lines 34 - 40, The
cleanup/linking logic can destructively remove or replace real files/dirs in
RESULT_DIR because ln -sf will overwrite directories and the trap blindly rm -f
"$LINK" "$TESTS_LINK"; update the code to: create links with safe flags (use ln
-sfn or ln -s -n where supported) and before removing in the trap check that
each target is a symlink (e.g. test -L "$LINK" and test -L "$TESTS_LINK") and
only unlink/remove when true; also when creating links, detect if the
destination already exists and is not a symlink (a real file/dir) and skip
linking with a warning instead of forcing overwrite; apply these changes around
the variables LINK, TESTS_LINK, DASHBOARD and FIXTURES_DIR and the trap that
currently calls rm -f.
crates/grida/src/htmlcss/svg/style/cascade.rs-45-54 (1)

45-54: ⚠️ Potential issue | 🟠 Major

Inline !important is currently parsed as part of the value.

get_inline_style/get_attr_or_style never strip or track !important, so style="fill:red !important" becomes "red !important" and also loses to author !important rules in cascade_property. Parse the priority separately and hand downstream code the cleaned value.

Also applies to: 57-89

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/style/cascade.rs` around lines 45 - 54, The
issue is that inline styles with "!important" are not parsed separately, causing
the priority flag to be embedded in the value string and breaking proper
cascading. To fix this, modify the parsing logic in `get_inline_style` and
`get_attr_or_style` so that they extract and remove the "!important" priority
suffix from style values, returning the cleaned value and priority as separate
outputs. Adjust `cascade_property` and other relevant functions to handle these
separate components for proper style precedence. Apply the same fix for the code
portions between lines 57-89.
crates/grida_dev/scripts/reftest_dashboard.html-265-283 (1)

265-283: ⚠️ Potential issue | 🟠 Major

Don't inject report.json fields into innerHTML.

cat, t.name, and the derived image paths are copied straight into HTML and attribute contexts here. A crafted fixture/report name can execute script when someone opens the dashboard. Build these cards with DOM APIs (createElement/textContent) or escape every interpolated field before insertion.

Also applies to: 360-391

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida_dev/scripts/reftest_dashboard.html` around lines 265 - 283, Do
not inject untrusted values into innerHTML: replace the innerHTML assembly for
the "categories" element with DOM construction using document.createElement and
element.textContent/element.setAttribute so that variables like cat, c, pct (and
any t.name or derived image paths referenced elsewhere) are never interpolated
into HTML; create the outer card element, a span for the category name using
textContent, a container div for the progress track and an inner bar div whose
classList includes the computed barColor and whose style.width is set from
pct.toFixed(1) + "%", and numeric spans for pct and c.n using textContent, then
append these nodes to document.getElementById("categories"); apply the same
DOM-API replacement to the similar block referenced at lines 360-391.
crates/grida/src/htmlcss/svg/layout/viewport.rs-17-25 (1)

17-25: ⚠️ Potential issue | 🟠 Major

Handle missing and non-positive nested SVG sizes separately.

This branch treats omitted width/height as 0 and still paints children without the nested viewport/translation. That makes <svg x=... viewBox=...> render in the wrong coordinate space, and non-positive sizes should suppress rendering instead of painting.

Proposed fix
     let w = get_attr(node, "width").and_then(parse_length_px);
     let h = get_attr(node, "height").and_then(parse_length_px);
 
-    let viewport_w = w.unwrap_or(0.0);
-    let viewport_h = h.unwrap_or(0.0);
+    let (parent_w, parent_h) = ancestor_svg_viewport(ctx, id);
+    let viewport_w = w.unwrap_or(parent_w);
+    let viewport_h = h.unwrap_or(parent_h);
     if viewport_w <= 0.0 || viewport_h <= 0.0 {
-        paint_children(canvas, ctx, id);
         return;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/layout/viewport.rs` around lines 17 - 25, The
code treats missing width/height as 0 and returns early, which breaks nested
<svg x=... viewBox=...> layouts; instead only suppress rendering when an
explicit width or height is present and non-positive. Change the check to detect
presence: keep w and h as Option<f32>, compute viewport_w = w.unwrap_or(0.0) and
viewport_h = h.unwrap_or(0.0) as before, but replace the early-return condition
with something like: if (w.is_some() && viewport_w <= 0.0) || (h.is_some() &&
viewport_h <= 0.0) { return; } so omitted attributes do not trigger the return
and children are painted in the nested viewport/translation; reference get_attr,
parse_length_px, w, h, viewport_w, viewport_h, and paint_children.
crates/grida/src/htmlcss/svg/paint/clip_path_clipper.rs-37-49 (1)

37-49: ⚠️ Potential issue | 🟠 Major

Don't turn an unresolved <clipPath> into a paint bailout.

If lookup(id) finds a <clipPath> but resolve_to_path returns None, this returns false, and the caller drops the whole element. That makes empty/unsupported/cyclic clip paths hide content instead of falling back to “no clip”.

Proposed fix
     let bbox = element_object_bbox(ctx.dom, node);
     let Some(path) = clipper::resolve_to_path(ctx.dom, ctx.resources, target, bbox) else {
-        return !target_is_clip_path;
+        return true;
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/clip_path_clipper.rs` around lines 37 -
49, When clipper::resolve_to_path(ctx.dom, ctx.resources, target, bbox) returns
None we should not treat an unresolved <clipPath> as a paint bailout; update the
fallback so that if target_is_clip_path is true we return true (fall back to "no
clip") and only bail out for non-clipPath targets. Concretely, replace the
current fallback logic that returns !target_is_clip_path with logic that returns
true when target_is_clip_path (and preserves the existing bailout behavior for
non-clipPath cases), leaving ctx.resources.lookup, target_is_clip_path,
element_object_bbox, and the call to clipper::resolve_to_path intact.
crates/grida/src/htmlcss/svg/paint/visibility.rs-40-67 (1)

40-67: ⚠️ Potential issue | 🟠 Major

Let inline style win over display / visibility attributes.

These helpers short-circuit on the presentation attribute, so style="visibility:visible" cannot override visibility="hidden" and style="display:inline" cannot override display="none". That flips SVG/CSS cascade order for the paint gates.

Also applies to: 85-107

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/visibility.rs` around lines 40 - 67, The
code currently checks presentation attributes before inline style, letting
attributes override style; change both helpers to check inline style first and
return based on style_contains_pair(style, ...) if get_attr(node, "style") is
Some, then fall back to matches_attr(node, ...); specifically update
has_display_none and is_visible_self to test style (using get_attr and
style_contains_pair) before calling matches_attr so
style="display:inline"/"visibility:visible" can override display/visibility
presentation attributes.
crates/grida/src/htmlcss/svg/resources/pattern.rs-248-264 (1)

248-264: ⚠️ Potential issue | 🟠 Major

Don't collapse href-chain failure into “use the local pattern”.

If recursive resolution hits the depth cap or a cycle, this branch falls back to node_id and can still build a shader from the local node. That turns an invalid href chain into a paintable pattern instead of failing closed.

crates/grida/src/htmlcss/svg/paint/svg_container_painter.rs-71-76 (1)

71-76: ⚠️ Potential issue | 🟠 Major

<switch> should test effective visibility before picking a winner.

is_painted(n) only checks display:none and the child's own visibility. If the <switch> or an ancestor is visibility:hidden, the first child without an override is still selected, paints nothing, and incorrectly blocks later visible siblings. Use the inherited visibility result here instead.

Suggested fix
-        if !is_painted(n) {
+        if has_display_none(n) || !is_visible_inherited(ctx.dom, id) {
             continue;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_container_painter.rs` around lines 71
- 76, The selection logic for <switch> wrongly uses is_painted(n) which only
checks display and the node's own visibility; update the check to use the node's
effective/inherited visibility (i.e., the computed visibility that considers
ancestors and the <switch> itself) instead of is_painted(n) so that a child
hidden only by an ancestor doesn't block later siblings—replace the
is_painted(n) gate with a test against the effective visibility result before
calling paint_node(canvas, ctx, id).
crates/grida/src/htmlcss/svg/resources/pattern.rs-186-202 (1)

186-202: ⚠️ Potential issue | 🟠 Major

Don't record the pattern picture to the tile cell.

picture_bounds is currently tile.width()/tile.height(), so any valid pattern content that extends past one cell is clipped before to_shader repeats it. This breaks common cases like patternContentUnits="objectBoundingBox" with content larger than the tile. Record to a conservative content bounds, and let the shader's tile rect define repetition.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/resources/pattern.rs` around lines 186 - 202,
The recording currently uses picture_bounds sized to the tile
(Rect::from_xywh(0.0, 0.0, tile.width(), tile.height())), which clips content
that extends beyond a single cell; change the recording bounds used by
PictureRecorder::new()/recorder.begin_recording from the tile rect to a
conservative content bounds computed from the pattern content (for example the
content node's bounding box, the pattern's viewport or an expanded rect that
covers potential overflow) so children painted by paint_children are captured
fully; keep applying content_to_tile before painting and rely on the shader's
tile rect (used later in to_shader) to enforce repetition, and use
ctx.with_deeper_pattern and recorder.finish_recording_as_picture as before.
crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs-1976-1987 (1)

1976-1987: ⚠️ Potential issue | 🟠 Major

Split the font-size keyword cases.

parent.max(16.0) is wrong for all four keywords bundled here: medium/initial should resolve to 16px, while inherit and unset should preserve the parent size because font-size is inherited. The current branch changes small inherited sizes to 16px and lets large parents override medium.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs` around lines 1976
- 1987, Split the bundled font-size cases so inheritance and initial/default are
handled correctly: for v == "" or "medium" or "initial" return 16.0; for v ==
"inherit" or "unset" return parent (i.e., preserve the parent's size) instead of
parent.max(16.0); keep the other keyword branches (e.g., "xx-small", "smaller",
"larger") unchanged — update the match on v (using the existing v and parent
variables in the font-size parsing code in svg_text_painter/mod.rs) accordingly.
crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs-2249-2257 (1)

2249-2257: ⚠️ Potential issue | 🟠 Major

These out-of-range weight assertions contradict the implementation.

resolve_font_weight_token returns the inherited weight for invalid numeric values, but this test expects clamping to 1/1000. As written it will fail as soon as the unit tests run.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs` around lines 2249
- 2257, The test's expectations for out-of-range numeric font weights conflict
with the implementation of resolve_font_weight_token (which returns the
inherited weight for invalid numeric values); update the two assertions that
currently expect clamping ("0" and "1500") to assert the inherited value (use
400 as the inherited weight used in these tests) so they match
resolve_font_weight_token's behavior.
crates/grida/src/htmlcss/svg/paint/svg_marker_painter.rs-71-90 (1)

71-90: ⚠️ Potential issue | 🟠 Major

Assign start/end markers per subpath, not just once globally.

Every Move starts a new subpath, but only out.first_mut() and out.last_mut() are retyped to Start/End. Any earlier open subpath, plus the synthetic position emitted by Close, stays Mid, so marker-start/marker-end disappear on multi-subpath paths.

Also applies to: 191-228

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_marker_painter.rs` around lines 71 -
90, When starting a new subpath in Verb::Move, mark the pushed MarkerPosition as
Start instead of leaving it Mid and record its index (use subpath_start_idx and
out.push). When you begin a new Move while a previous subpath is still open,
update the previous subpath's last marker (using the stored subpath_start_idx
and the last index of that subpath in out) to MarkerKind::End. Likewise, when
handling Close (and when finishing the entire path), ensure the
synthetic/closing MarkerPosition is set to End for that subpath (update its
MarkerPosition.kind) rather than relying on global
out.first_mut()/out.last_mut(); use the per-subpath indices (subpath_start_idx,
subpath start/end indexes) to set Start/End for each subpath.
crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs-1669-1700 (1)

1669-1700: ⚠️ Potential issue | 🟠 Major

Handle the common font-size/line-height shorthand form.

The tokenizer never breaks on /, so font: italic 12px/14px sans-serif leaves size_tok as 12px/14px and the whole shorthand is discarded. That attached form is the normal CSS syntax.

Also applies to: 1807-1828

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs` around lines 1669
- 1700, The tokenizer in the font parsing loop (the variables tokens, buf,
in_quote inside the for ch in value.chars() block) never treats '/' as a
delimiter, so shorthand like "italic 12px/14px sans-serif" yields a single token
"12px/14px" and breaks font-size/line-height parsing; update the None branch of
the match to treat '/' like whitespace/delimiters (i.e., when ch == '/' and not
in a quote, flush buf into tokens and skip adding the slash) so size and
line-height become separate tokens; apply the same change to the duplicate logic
later around the 1807-1828 region to keep behavior consistent.
crates/grida/src/htmlcss/svg/style/stylesheet.rs-428-500 (1)

428-500: ⚠️ Potential issue | 🟠 Major

Make @import parsing case-insensitive end-to-end.

CSS treats both @import and url(...) case-insensitively, but scan_imports only searches for lowercase @import and parse_import_url only accepts lowercase url(. Valid sheets like @IMPORT URL(theme.css) won't preload or collect.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/style/stylesheet.rs` around lines 428 - 500,
scan_imports and parse_import_url currently only match lowercase forms so
mixed-case like `@IMPORT URL(...)` is missed; update parse_import_url to accept
"url(" case-insensitively (e.g., check rest[..4].eq_ignore_ascii_case("url(") or
lowercase the prefix before comparing) and update scan_imports to locate
`@import` in a case-insensitive way (don’t use rest.find("@import") literal;
search for '@' then test the following 6 bytes with
eq_ignore_ascii_case("import") and keep the same whitespace/boundary logic), and
also ensure parse_at_import’s use of parse_import_url remains correct with
trimmed body so both `parse_at_import`, `scan_imports`, and `parse_import_url`
handle ASCII case-insensitive keywords uniformly.
crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs-1125-1147 (1)

1125-1147: ⚠️ Potential issue | 🟠 Major

Support currentColor consistently for text strokes.

The fill path handles Paint::CurrentColor, but both stroke builders accept only Paint::Color, and resolve_current_color only looks at a literal color attribute. stroke="currentColor" or style="color: ..." on text/decoration anchors will therefore render as no stroke or black fallback.

Also applies to: 1460-1486, 2174-2195

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs` around lines 1125
- 1147, The stroke-handling code only accepts Paint::Color and ignores
Paint::CurrentColor; update the stroke branches (the block using
read_inherited(..., "stroke") and parse_paint(v) that currently matches
Paint::Color(c)) to also handle Paint::CurrentColor by calling
resolve_current_color(ctx, node) (or otherwise reading the computed text color)
and using that resolved SkColor for painting, applying stroke-opacity the same
way as for explicit colors; ensure you use the resolved color's alpha when
computing a for paint.set_alpha_f(a) and call draw_glyphs(canvas, run, font,
&paint) with the resulting paint. Apply the same change to the other analogous
stroke blocks mentioned (around the ranges corresponding to 1460-1486 and
2174-2195) so strokes honoring "currentColor" render consistently.
crates/grida/src/htmlcss/svg/paint/svg_root_painter.rs-95-100 (1)

95-100: ⚠️ Potential issue | 🟠 Major

Root-level effects bypass the CSS path.

Both branches only consult get_attr, so style="clip-path:...", style="filter:...", or stylesheet matches on the outer <svg> are ignored here even though container painting supports CSS-authored effects. Root clips/filters will silently disappear on those documents.

Also applies to: 145-167

crates/grida/src/htmlcss/svg/resources/gradient.rs-277-317 (1)

277-317: ⚠️ Potential issue | 🟠 Major

Normalize stop offsets before handing them to Skia.

Each stop offset is only clamped into [0, 1] here. SVG requires the resolved sequence to be nondecreasing, so a stop list like 0.8, 0.2, 1 should become 0.8, 0.8, 1, not stay out of order. Passing the raw values through will render the wrong gradient for those fixtures.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/resources/gradient.rs` around lines 277 - 317,
When building the vector of stops (the stops.push(Stop { ... }) block), ensure
the sequence of stop offsets is normalized to be nondecreasing per SVG rules:
clamp each parsed offset to [0.0,1.0] then replace it with
max(previous_normalized_offset, current_clamped_offset) so offsets like
0.8,0.2,1 become 0.8,0.8,1. Implement this by tracking a last_offset variable
(initial 0.0 or first clamped value) as you append Stop entries (referencing the
offset local, the stops Vec and the Stop struct) and use offset =
last_offset.max(offset.clamp(0.0,1.0)) before creating/pushing each Stop.
crates/grida/src/htmlcss/svg/paint/scoped_svg_paint_state.rs-82-89 (1)

82-89: ⚠️ Potential issue | 🟠 Major

Don't collapse nested <use> inheritance to a single node.

with_use_inherit overwrites the previous host, so once a clone expands another <use>, properties from the outer <use> stop participating in inheritance. Nested use shadow trees need a stack/chain, not a single slot.

Also applies to: 114-119

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/scoped_svg_paint_state.rs` around lines 82
- 89, The current single-slot design for tracking `<use>` inheritance (pub
use_inherit: Option<NodeId>) and the setter with_use_inherit overwrites the
previous host, which loses outer `<use>` properties for nested `<use>` trees;
change the representation to a stack/chain (e.g., Vec<NodeId> or a linked list)
and update associated APIs so pushing a new host pushes onto the stack and
popping restores the previous host; update references to use_inherit and the
with_use_inherit method (and the other locations around the 114-119 region) to
push/pop or peek instead of replace so nested use shadow trees correctly
accumulate inherited properties.
crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs-661-663 (1)

661-663: ⚠️ Potential issue | 🟠 Major

<textPath> glyphs still consume the parent coordinate lists.

You stop reading ancestor x/y/dx/dy once a textPath frame is hit, but consumed += 1 still advances every frame in stack. Characters inside <textPath> will therefore eat the parent <text> list entries and shift later sibling text to the wrong positions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs` around lines 661
- 663, The bug is that you blindly increment elem.consumed for every frame in
stack, which advances ancestor x/y/dx/dy entries even when the current glyph is
inside a <textPath>; change the loop so you only increment consumed on frames
that actually contributed coordinates for this glyph. Replace the unconditional
for elem in stack.iter_mut() { elem.consumed += 1; } with logic that walks stack
and increments elem.consumed only while the frame is a coordinate-owner (e.g.,
not a TextPath frame) — detect the TextPath frame via the same symbol used
elsewhere (Frame::TextPath or elem.is_text_path / elem.reads_coordinates) and
break/stop incrementing once you hit it so ancestors are not consumed by glyphs
inside a textPath.
crates/grida/src/htmlcss/svg/geometry/basic_shape.rs-384-388 (1)

384-388: ⚠️ Potential issue | 🟠 Major

Resolve ellipse radii per axis.

resolve_radius(..., false) still uses w plus circle-style closest/farthest-side math for both calls, so ry="50%" resolves against width and ellipse(closest-side) produces symmetric radii instead of horizontal/vertical ones. rx and ry need separate axis-aware resolution rules.

Also applies to: 465-478

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/geometry/basic_shape.rs` around lines 384 - 388,
The ellipse branch (BasicShape::Ellipse) resolves both rx and ry with the same
axis rules causing ry="50%" and closest/farthest-side math to use the width;
update the rx/ry resolve_radius calls so rx uses the horizontal axis rules and
distances and ry uses the vertical axis rules (pass the axis flag/params
appropriately — e.g. keep resolve_radius(*rx, w, h, cx - box_rect.left, cy -
box_rect.top, false) for the horizontal radius and call resolve_radius(*ry, w,
h, cx - box_rect.left, cy - box_rect.top, true) or the equivalent vertical-axis
variant), and apply the same fix to the other ellipse block around the later
range mentioned (the similar ellipse code at the other location).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 409b0993-5e32-4533-b8d3-8068a9525bdd

📥 Commits

Reviewing files that changed from the base of the PR and between 5e63162 and de1265b.

📒 Files selected for processing (73)
  • .agents/skills/research/SKILL.md
  • crates/grida/examples/tool_gen_bench_fixture.rs
  • crates/grida/examples/tool_sk_svgdom.rs
  • crates/grida/src/htmlcss/collect.rs
  • crates/grida/src/htmlcss/mod.rs
  • crates/grida/src/htmlcss/paint.rs
  • crates/grida/src/htmlcss/style.rs
  • crates/grida/src/htmlcss/svg/README.md
  • crates/grida/src/htmlcss/svg/context.rs
  • crates/grida/src/htmlcss/svg/dom/attrs.rs
  • crates/grida/src/htmlcss/svg/dom/element.rs
  • crates/grida/src/htmlcss/svg/dom/href.rs
  • crates/grida/src/htmlcss/svg/dom/mod.rs
  • crates/grida/src/htmlcss/svg/dom/parser.rs
  • crates/grida/src/htmlcss/svg/dom/path_d.rs
  • crates/grida/src/htmlcss/svg/error.rs
  • crates/grida/src/htmlcss/svg/geometry/basic_shape.rs
  • crates/grida/src/htmlcss/svg/geometry/mod.rs
  • crates/grida/src/htmlcss/svg/layout/bbox.rs
  • crates/grida/src/htmlcss/svg/layout/layout_svg_element.rs
  • crates/grida/src/htmlcss/svg/layout/mod.rs
  • crates/grida/src/htmlcss/svg/layout/transform.rs
  • crates/grida/src/htmlcss/svg/layout/viewport.rs
  • crates/grida/src/htmlcss/svg/mod.rs
  • crates/grida/src/htmlcss/svg/paint/clip_path_clipper.rs
  • crates/grida/src/htmlcss/svg/paint/effects.rs
  • crates/grida/src/htmlcss/svg/paint/mod.rs
  • crates/grida/src/htmlcss/svg/paint/scoped_svg_paint_state.rs
  • crates/grida/src/htmlcss/svg/paint/svg_container_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_image_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_marker_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_object_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_root_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_shape_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs
  • crates/grida/src/htmlcss/svg/paint/svg_text_painter/shaping.rs
  • crates/grida/src/htmlcss/svg/paint/svg_use_painter.rs
  • crates/grida/src/htmlcss/svg/paint/visibility.rs
  • crates/grida/src/htmlcss/svg/resources/cache.rs
  • crates/grida/src/htmlcss/svg/resources/clipper.rs
  • crates/grida/src/htmlcss/svg/resources/filter.rs
  • crates/grida/src/htmlcss/svg/resources/gradient.rs
  • crates/grida/src/htmlcss/svg/resources/masker.rs
  • crates/grida/src/htmlcss/svg/resources/mod.rs
  • crates/grida/src/htmlcss/svg/resources/paint_server.rs
  • crates/grida/src/htmlcss/svg/resources/pattern.rs
  • crates/grida/src/htmlcss/svg/resources/svg_filter_builder.rs
  • crates/grida/src/htmlcss/svg/resources/svg_resource_container.rs
  • crates/grida/src/htmlcss/svg/resources/svg_resources.rs
  • crates/grida/src/htmlcss/svg/style/cascade.rs
  • crates/grida/src/htmlcss/svg/style/mod.rs
  • crates/grida/src/htmlcss/svg/style/stylesheet.rs
  • crates/grida/src/htmlcss/svg/style/stylo_bridge.rs
  • crates/grida/tests/htmlcss_svg_architecture.rs
  • crates/grida/tests/htmlcss_svg_checkpoint1.rs
  • crates/grida_dev/examples/render_one_svg.rs
  • crates/grida_dev/scripts/reftest-run.sh
  • crates/grida_dev/scripts/reftest-view.sh
  • crates/grida_dev/scripts/reftest_dashboard.html
  • crates/grida_dev/src/main.rs
  • crates/grida_dev/src/reftest/args.rs
  • crates/grida_dev/src/reftest/render.rs
  • crates/grida_dev/src/reftest/runner.rs
  • docs/wg/feat-2d/htmlcss-svg.md
  • docs/wg/research/chromium/external-css.md
  • docs/wg/research/chromium/index.md
  • docs/wg/research/chromium/svg/clip-path.md
  • docs/wg/research/chromium/svg/fe-image.md
  • docs/wg/research/chromium/svg/fe-tile.md
  • docs/wg/research/chromium/svg/index.md
  • docs/wg/research/chromium/svg/module-structure.md
  • docs/wg/research/chromium/svg/text-on-path.md
💤 Files with no reviewable changes (2)
  • crates/grida/examples/tool_gen_bench_fixture.rs
  • crates/grida/examples/tool_sk_svgdom.rs

12 high-confidence correctness fixes from the CodeRabbit + Codex
review on PR #698. Reftest: 89.50% -> 89.38% (-0.12pp; 2 masking
fixtures degraded, all other categories identical). Unit tests:
200/202 -> 202/202 passing (two pre-existing failing tests
corrected).

Rendering correctness:
- shaping.rs: combining marks / zero-glyph chars now get advance 0
  (was inheriting the next cluster's advance via `cluster >= start`).
- visibility.rs: inline `style="..."` now wins over the presentation
  attribute for `display` / `visibility`, matching CSS specificity.
- clip_path_clipper.rs: unresolved `<clipPath>` renders unclipped
  rather than dropping the element (matches design study
  S-clip-path "Differ" formulation; this is the change that moved
  the masking score).
- gradient.rs: stop offsets normalized to non-decreasing per SVG
  1.1 §13.2.4 ("use the previous offset value" when out of order).
- cascade.rs: strip `!important` from inline-style values so
  downstream parsers see `red`, not `red !important`.
- stylesheet.rs: `@import` keyword and `url(` token now matched
  case-insensitively per CSS Syntax §4.3.
- scoped_svg_paint_state.rs: `<use>` inheritance switched from a
  single-slot `Option<NodeId>` to a stack of frames (`UseFrame`
  linked list, keeps `PaintCtx: Copy`). Adds non-ancestor cycle
  detection (`#a -> #b -> #a`) and proper inheritance through
  multi-level `<use>` nesting.
- svg_container_painter.rs: `<switch>` now skips candidates whose
  *inherited* visibility is hidden, not just their own — a hidden
  ancestor no longer pins the first child as winner.
- paint.rs: inline-SVG render failures terminate at the
  `paint_inline_svg` call instead of falling through to the gray
  `<img>` placeholder; matches the documented "no fallback" intent.

Tests:
- font_weight_named_and_numeric: was asserting clamping for
  out-of-range numeric values; impl correctly returns the inherited
  value per CSS Fonts 4 §3.2.4 (Blink:
  `ConsumeFontWeightNumber` returns nullptr -> cascade fallback).
  Test now matches the spec-correct behavior.
- length_px_handles_unit_suffix: was asserting `None` for `10em`;
  impl returns the documented default-context approximation
  (em/rem = 16px). Test updated to match the doc.

Doc cleanups:
- reftest/args.rs: `Htmlcss` backend description points at
  `htmlcss::svg::render_to_picture*` (the old `htmlcss::render_svg`
  is just a thin wrapper but the API surface in `htmlcss::svg` is
  the canonical entry).
- htmlcss/mod.rs: `render_svg`'s doc no longer claims the function
  is an XML validator — the parser is permissive by design.

Deferred from the review (not "deserved enough"):
- RTL shaping (no fixtures, design decision needed)
- Pattern href fallback / picture bounds (high regression risk on
  paint-servers fixtures)
- Per-subpath markers, font shorthand, currentColor for stroke,
  textPath consumed counter (text/marker logic risk)
- Root effects via CSS, nested-svg defaults, ellipse axes
  (moderate regression risk)
- math2 migration (out of scope)
- AST-based architecture test, doc frontmatter (style nits)

See the aggregate in the PR conversation for full triage.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
crates/grida/src/htmlcss/mod.rs (1)

3303-3305: Test intent and test name are now out of sync.

Lines 3303-3305 intentionally allow either Ok or Err, but the test name test_render_svg_malformed_errors still implies Err is required. Renaming would make CI output less confusing.

🧪 Suggested rename
-    fn test_render_svg_malformed_errors() {
+    fn test_render_svg_malformed_no_panic() {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/mod.rs` around lines 3303 - 3305, The test name
test_render_svg_malformed_errors is misleading because the block purposely
accepts either Ok or Err; rename the test function to reflect permissive
behavior (e.g., test_render_svg_malformed_permissive or
test_render_svg_malformed_accepts_ok_or_err) and update any references to that
function so CI output matches intent; locate the test definition named
test_render_svg_malformed_errors in the htmlcss::svg-related tests (mod.rs) and
change only the identifier and any occurrences used in annotations or test
harness.
crates/grida/src/htmlcss/svg/paint/svg_use_painter.rs (1)

88-94: Opacity layer restore relies on encompassing sym_restore.

The opacity save_layer opened at line 93 is implicitly restored by canvas.restore_to_count(sym_restore) at line 130. This works correctly because sym_restore was captured before the opacity layer was opened (line 88). However, this coupling could be fragile if future refactoring moves the sym_restore capture.

Consider adding a brief comment noting that sym_restore intentionally covers the opacity layer:

 let sym_restore = canvas.save();
+// This save covers any optional opacity layer opened below, plus the
+// clip/viewBox transforms — a single restore_to_count unwinds all.
 let opacity = group_opacity(target);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_use_painter.rs` around lines 88 - 94,
Add a brief inline comment near the sym_restore capture to document that
sym_restore (from canvas.save()) intentionally encloses the subsequent opacity
save_layer so that the later canvas.restore_to_count(sym_restore) will also
restore that layer; reference the variables/symbols sym_restore, canvas.save(),
opacity, canvas.save_layer and canvas.restore_to_count to make the coupling
explicit and guard against fragility during refactors.
crates/grida/src/htmlcss/svg/style/stylesheet.rs (1)

486-510: Keep scan_imports on the same parser path as the renderer.

This substring scan can preload URLs from places the renderer would ignore, like string literals containing @import or late-invalid @import rules after a style rule. Reusing parse_stylesheet and collecting only ParsedRule::Import keeps preloading behavior aligned with actual stylesheet parsing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/style/stylesheet.rs` around lines 486 - 510, The
current scan_imports substring scan can pick up `@import` occurrences that the
real parser would ignore (e.g., inside strings or invalid positions); replace
its implementation to call the same parser used by the renderer
(parse_stylesheet), iterate the returned rules, and collect URLs from
ParsedRule::Import only so preloading matches actual parsing behavior; use
parse_stylesheet(text) and for each rule that matches ParsedRule::Import extract
the import URL (replacing the existing parse_import_url-based substring logic)
so imports are discovered exactly as the renderer would handle them.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/grida/src/htmlcss/mod.rs`:
- Around line 219-223: The doc comment above the SVG render entry point in
crates::grida::htmlcss::mod.rs currently states "Returns `Err` only when SVG
structure is unrecoverable...", which is too narrow; update that docstring (the
comment for the SVG render entry-point function in this module) to say that Err
can also be returned for other failures from the SVG pipeline such as
non-structural parse errors or unsupported features (e.g., parser or pipeline
failures), so callers are not misled into assuming only
unrecoverable-structure/picture-recording cases produce Err.

In `@crates/grida/src/htmlcss/svg/paint/visibility.rs`:
- Around line 84-107: The visibility check in read_visibility should prioritize
inline style over the presentation attribute; update read_visibility so it first
reads get_attr(node, "style") and parses visibility declarations (the existing
style-splitting logic) returning Some(true/false) when found, and only if no
inline style visibility is present then fall back to matches_attr(node,
"visibility", ...) checks (the current attribute-based branches). Keep the same
comparison logic (eq_ignore_ascii_case against "visible", "hidden", "collapse")
and the same return semantics.

In `@crates/grida/src/htmlcss/svg/style/cascade.rs`:
- Around line 41-54: The inline-style readers (called by cascade_property via
get_inline_style) currently return the first matching declaration and drop the
!important flag; change the inline parser(s) to scan the entire inline
declaration block for the requested property and track the winning declaration
by CSS ordering (later declarations win) while preserving whether that winning
declaration had !important, then return the winning (value, important: bool)
(e.g., Option<(String, bool)>) instead of a single String; update
cascade_property to use that returned (value, important) when comparing against
the stylesheet candidate (matched which is Option<(value, spec, important: bool,
order)>) so inline !important and declaration order are considered correctly.

In `@crates/grida/src/htmlcss/svg/style/stylesheet.rs`:
- Around line 267-283: The comparison key ignores declaration position so
duplicate properties in the same rule never let later declarations win; modify
the key used when building candidate/prev_key (inside the loop over rule.decls)
to include the declaration's own order/position (e.g., use decl.order or the
decl index) instead of only rule.order, so cand_key and prev_key become
(decl.important, spec, rule.order, decl_order) and later declarations with equal
priority will correctly override earlier ones.

---

Nitpick comments:
In `@crates/grida/src/htmlcss/mod.rs`:
- Around line 3303-3305: The test name test_render_svg_malformed_errors is
misleading because the block purposely accepts either Ok or Err; rename the test
function to reflect permissive behavior (e.g.,
test_render_svg_malformed_permissive or
test_render_svg_malformed_accepts_ok_or_err) and update any references to that
function so CI output matches intent; locate the test definition named
test_render_svg_malformed_errors in the htmlcss::svg-related tests (mod.rs) and
change only the identifier and any occurrences used in annotations or test
harness.

In `@crates/grida/src/htmlcss/svg/paint/svg_use_painter.rs`:
- Around line 88-94: Add a brief inline comment near the sym_restore capture to
document that sym_restore (from canvas.save()) intentionally encloses the
subsequent opacity save_layer so that the later
canvas.restore_to_count(sym_restore) will also restore that layer; reference the
variables/symbols sym_restore, canvas.save(), opacity, canvas.save_layer and
canvas.restore_to_count to make the coupling explicit and guard against
fragility during refactors.

In `@crates/grida/src/htmlcss/svg/style/stylesheet.rs`:
- Around line 486-510: The current scan_imports substring scan can pick up
`@import` occurrences that the real parser would ignore (e.g., inside strings or
invalid positions); replace its implementation to call the same parser used by
the renderer (parse_stylesheet), iterate the returned rules, and collect URLs
from ParsedRule::Import only so preloading matches actual parsing behavior; use
parse_stylesheet(text) and for each rule that matches ParsedRule::Import extract
the import URL (replacing the existing parse_import_url-based substring logic)
so imports are discovered exactly as the renderer would handle them.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 17db9a62-44e1-46c6-94a2-4a237bec1f40

📥 Commits

Reviewing files that changed from the base of the PR and between de1265b and b8845db.

📒 Files selected for processing (15)
  • crates/grida/src/htmlcss/mod.rs
  • crates/grida/src/htmlcss/paint.rs
  • crates/grida/src/htmlcss/svg/dom/attrs.rs
  • crates/grida/src/htmlcss/svg/paint/clip_path_clipper.rs
  • crates/grida/src/htmlcss/svg/paint/scoped_svg_paint_state.rs
  • crates/grida/src/htmlcss/svg/paint/svg_container_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_shape_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs
  • crates/grida/src/htmlcss/svg/paint/svg_text_painter/shaping.rs
  • crates/grida/src/htmlcss/svg/paint/svg_use_painter.rs
  • crates/grida/src/htmlcss/svg/paint/visibility.rs
  • crates/grida/src/htmlcss/svg/resources/gradient.rs
  • crates/grida/src/htmlcss/svg/style/cascade.rs
  • crates/grida/src/htmlcss/svg/style/stylesheet.rs
  • crates/grida_dev/src/reftest/args.rs
✅ Files skipped from review due to trivial changes (1)
  • crates/grida_dev/src/reftest/args.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/grida/src/htmlcss/svg/paint/clip_path_clipper.rs
  • crates/grida/src/htmlcss/svg/resources/gradient.rs
  • crates/grida/src/htmlcss/svg/paint/svg_text_painter/shaping.rs
  • crates/grida/src/htmlcss/paint.rs

Comment on lines +219 to +223
/// Returns `Err` only when SVG structure is unrecoverable (e.g. no
/// `<svg>` element, picture-recording failure). The XML parser is
/// permissive — best-effort recoverable input may render as `Ok`
/// even if it isn't strictly well-formed. Use this as a render
/// entry point, not as an XML validator.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Docstring narrows Err conditions more than the implementation.

Line 219 says Err is only for unrecoverable structure/picture-recording cases, but this path can also surface non-structural parse/unsupported failures from the SVG pipeline. Please broaden the wording to avoid misleading callers.

✏️ Suggested doc fix
-/// Returns `Err` only when SVG structure is unrecoverable (e.g. no
-/// `<svg>` element, picture-recording failure). The XML parser is
-/// permissive — best-effort recoverable input may render as `Ok`
-/// even if it isn't strictly well-formed. Use this as a render
-/// entry point, not as an XML validator.
+/// Returns `Err` for unrecoverable SVG parse/structure/recording failures.
+/// The parser is permissive for some malformed inputs, so best-effort
+/// recovery may still render as `Ok`. Use this as a render entry point,
+/// not as an XML validator.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Returns `Err` only when SVG structure is unrecoverable (e.g. no
/// `<svg>` element, picture-recording failure). The XML parser is
/// permissive — best-effort recoverable input may render as `Ok`
/// even if it isn't strictly well-formed. Use this as a render
/// entry point, not as an XML validator.
/// Returns `Err` for unrecoverable SVG parse/structure/recording failures.
/// The parser is permissive for some malformed inputs, so best-effort
/// recovery may still render as `Ok`. Use this as a render entry point,
/// not as an XML validator.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/mod.rs` around lines 219 - 223, The doc comment
above the SVG render entry point in crates::grida::htmlcss::mod.rs currently
states "Returns `Err` only when SVG structure is unrecoverable...", which is too
narrow; update that docstring (the comment for the SVG render entry-point
function in this module) to say that Err can also be returned for other failures
from the SVG pipeline such as non-structural parse errors or unsupported
features (e.g., parser or pipeline failures), so callers are not misled into
assuming only unrecoverable-structure/picture-recording cases produce Err.

Comment on lines +84 to +107
fn read_visibility(node: &DemoNode) -> Option<bool> {
if matches_attr(node, "visibility", "visible") {
return Some(true);
}
if matches_attr(node, "visibility", "hidden") || matches_attr(node, "visibility", "collapse") {
return Some(false);
}
if let Some(style) = get_attr(node, "style") {
for decl in style.split(';') {
if let Some((k, v)) = decl.split_once(':') {
if k.trim().eq_ignore_ascii_case("visibility") {
let v = v.trim();
if v.eq_ignore_ascii_case("visible") {
return Some(true);
}
if v.eq_ignore_ascii_case("hidden") || v.eq_ignore_ascii_case("collapse") {
return Some(false);
}
}
}
}
}
None
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Specificity bug: read_visibility checks attribute before inline style.

This function checks the visibility attribute (lines 85-90) before the inline style="visibility:..." declaration (lines 91-105). CSS specificity requires inline styles to override presentation attributes.

This is inconsistent with has_display_none (lines 45-50) and is_visible_self (lines 60-65), which both correctly check inline style first. The inconsistency means an element with visibility="visible" style="visibility:hidden" would incorrectly report as visible during the ancestor walk.

🔧 Proposed fix to prioritize inline style
 fn read_visibility(node: &DemoNode) -> Option<bool> {
+    // Inline style overrides presentation attribute (CSS specificity).
+    if let Some(style) = get_attr(node, "style") {
+        for decl in style.split(';') {
+            if let Some((k, v)) = decl.split_once(':') {
+                if k.trim().eq_ignore_ascii_case("visibility") {
+                    let v = v.trim();
+                    if v.eq_ignore_ascii_case("visible") {
+                        return Some(true);
+                    }
+                    if v.eq_ignore_ascii_case("hidden") || v.eq_ignore_ascii_case("collapse") {
+                        return Some(false);
+                    }
+                }
+            }
+        }
+    }
+    // Fall through to presentation attribute.
     if matches_attr(node, "visibility", "visible") {
         return Some(true);
     }
     if matches_attr(node, "visibility", "hidden") || matches_attr(node, "visibility", "collapse") {
         return Some(false);
     }
-    if let Some(style) = get_attr(node, "style") {
-        for decl in style.split(';') {
-            if let Some((k, v)) = decl.split_once(':') {
-                if k.trim().eq_ignore_ascii_case("visibility") {
-                    let v = v.trim();
-                    if v.eq_ignore_ascii_case("visible") {
-                        return Some(true);
-                    }
-                    if v.eq_ignore_ascii_case("hidden") || v.eq_ignore_ascii_case("collapse") {
-                        return Some(false);
-                    }
-                }
-            }
-        }
-    }
     None
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn read_visibility(node: &DemoNode) -> Option<bool> {
if matches_attr(node, "visibility", "visible") {
return Some(true);
}
if matches_attr(node, "visibility", "hidden") || matches_attr(node, "visibility", "collapse") {
return Some(false);
}
if let Some(style) = get_attr(node, "style") {
for decl in style.split(';') {
if let Some((k, v)) = decl.split_once(':') {
if k.trim().eq_ignore_ascii_case("visibility") {
let v = v.trim();
if v.eq_ignore_ascii_case("visible") {
return Some(true);
}
if v.eq_ignore_ascii_case("hidden") || v.eq_ignore_ascii_case("collapse") {
return Some(false);
}
}
}
}
}
None
}
fn read_visibility(node: &DemoNode) -> Option<bool> {
// Inline style overrides presentation attribute (CSS specificity).
if let Some(style) = get_attr(node, "style") {
for decl in style.split(';') {
if let Some((k, v)) = decl.split_once(':') {
if k.trim().eq_ignore_ascii_case("visibility") {
let v = v.trim();
if v.eq_ignore_ascii_case("visible") {
return Some(true);
}
if v.eq_ignore_ascii_case("hidden") || v.eq_ignore_ascii_case("collapse") {
return Some(false);
}
}
}
}
}
// Fall through to presentation attribute.
if matches_attr(node, "visibility", "visible") {
return Some(true);
}
if matches_attr(node, "visibility", "hidden") || matches_attr(node, "visibility", "collapse") {
return Some(false);
}
None
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/visibility.rs` around lines 84 - 107, The
visibility check in read_visibility should prioritize inline style over the
presentation attribute; update read_visibility so it first reads get_attr(node,
"style") and parses visibility declarations (the existing style-splitting logic)
returning Some(true/false) when found, and only if no inline style visibility is
present then fall back to matches_attr(node, "visibility", ...) checks (the
current attribute-based branches). Keep the same comparison logic
(eq_ignore_ascii_case against "visible", "hidden", "collapse") and the same
return semantics.

Comment on lines +41 to +54
let matched = match (dom, sheet) {
(Some(d), Some(s)) => s.match_property(d, node, name),
_ => None,
};
if let Some((value, _spec, true, _order)) = &matched {
return Some(value.clone());
}
if let Some(v) = get_inline_style(node, name) {
return Some(v);
}
if let Some((v, _, _, _)) = matched {
return Some(v);
}
get_attr(node, name).map(|s| s.to_string())
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Inline style parsing loses declaration order and !important.

Both inline-style readers return the first matching declaration and discard whether it was !important. That makes style="fill:red; fill:blue" resolve to red, and it also means inline !important can never outrank a stylesheet !important in cascade_property. The inline parser needs to evaluate the whole declaration block, keep the winning inline declaration (value, important), and let cascade_property compare that against the stylesheet candidate.

Also applies to: 58-69, 76-89, 95-107

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/style/cascade.rs` around lines 41 - 54, The
inline-style readers (called by cascade_property via get_inline_style) currently
return the first matching declaration and drop the !important flag; change the
inline parser(s) to scan the entire inline declaration block for the requested
property and track the winning declaration by CSS ordering (later declarations
win) while preserving whether that winning declaration had !important, then
return the winning (value, important: bool) (e.g., Option<(String, bool)>)
instead of a single String; update cascade_property to use that returned (value,
important) when comparing against the stylesheet candidate (matched which is
Option<(value, spec, important: bool, order)>) so inline !important and
declaration order are considered correctly.

Comment on lines +267 to +283
for decl in &rule.decls {
if !decl.name.eq_ignore_ascii_case(name) {
continue;
}
let candidate = (decl.value.clone(), spec, decl.important, rule.order);
best = Some(match best {
None => candidate,
Some(prev) => {
let prev_key = (prev.2, prev.1, prev.3);
let cand_key = (candidate.2, candidate.1, candidate.3);
if cand_key > prev_key {
candidate
} else {
prev
}
}
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Later declarations in the same rule never win.

order is tracked per rule, so duplicate properties inside one declaration block produce identical comparison keys. Because Line 277 uses >, .x { fill:red; fill:blue } keeps red instead of the later blue. Either include declaration order in the key or treat equal keys as “later wins” while iterating in source order.

🛠️ Minimal fix
-                        if cand_key > prev_key {
+                        if cand_key >= prev_key {
                             candidate
                         } else {
                             prev
                         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/style/stylesheet.rs` around lines 267 - 283, The
comparison key ignores declaration position so duplicate properties in the same
rule never let later declarations win; modify the key used when building
candidate/prev_key (inside the loop over rule.decls) to include the
declaration's own order/position (e.g., use decl.order or the decl index)
instead of only rule.order, so cand_key and prev_key become (decl.important,
spec, rule.order, decl_order) and later declarations with equal priority will
correctly override earlier ones.

The field is still written by the comparator, but consumers now route
through a separately-computed `diff_pct` value (in reftest::runner).
Allow the dead_code warning so `cargo clippy --no-deps -- -D warnings`
stays clean while the reftest tooling overhaul is in flight.
CSS Values 4 §3.4 / CSS Paint Order Level 1: a property declaration
that contains any invalid token is invalid as a whole — the value
must be treated as if the property weren't set. Previously
`resolve_paint_order` silently skipped unknown keywords and used
the valid prefix ("stroke markers fill qwe" parsed as "stroke
markers fill"). Chrome/Firefox + the resvg-test-suite expecteds
fall back to default (`fill stroke markers`) when any token is
unknown.

Pass-rate: 1140 → 1141 (+1, 0.8000 → 0.8007).
- painting_paint-order_trailing-data: 0.940 → 1.000
…oke-width

CSS Masking 1 §1.2 / SVG 2: the `stroke-box` reference box for a
basic-shape `clip-path` value is the element's stroke bounding
box — the fill bbox expanded by half the stroke-width on every
side. Previously we collapsed `stroke-box` to `fill-box`, so
`clip-path: circle() stroke-box` clipped at the fill bbox and
chopped off half of the stroke ring.

Implementation: when the parsed `ReferenceBox::StrokeBox` is in
play, expand `element_object_bbox(node)` by `stroke_half_extent`
on all four sides. Stroke-width reads `stroke-width` attribute
(default 1px) and only applies when `stroke` is set to a non-`none`
value.

Pass-rate: 1141 → 1142 (+1, 0.8007 → 0.8014).
- masking_clipPath_circle-shorthand-with-stroke-box: 0.902 → 1.000
SVG 2 §11.4: `visibility: hidden` on a `<tspan>` makes the glyphs
invisible while preserving their advances in text layout. Previously
the painter rendered every glyph regardless — `<tspan
visibility="hidden">Text2</tspan>` rendered fully, instead of being
invisible with the surrounding text laid out around the reserved
space.

Implementation: in `paint_glyph_groups`, before the per-source run
draws, check `is_visible_inherited` for the source element. When
hidden/collapse, skip the run's fill+stroke. The advances were
already counted by `resolve_positions` upstream, so layout is
preserved (matching the expected `Text1 [gap] Text3` output).

`is_visible_inherited` walks ancestors so `<g
visibility="hidden"><text>...</text></g>` propagates down through
descendants, matching CSS inheritance. A descendant `<tspan
visibility="visible">` correctly overrides.

Pass-rate held at 1142 (0.8014). Both fixtures advanced toward
floor:
- painting_visibility_hidden-on-tspan: 0.834 → 0.908
- painting_visibility_collapse-on-tspan: 0.834 → 0.908
Residual is sub-pixel font-metric drift on the surrounding Text1 /
Text3, not a renderer bug.
CSS Values 4 §6.1.1: `1ch` = the advance width of the `0` (ZERO)
glyph in the inherited font. Previously we approximated it as
`0.5em` which under-shoots most proportional fonts (Noto Sans `0`
is ~0.55em). The 10ch-wide rect in `shapes_rect_ch-values.svg`
rendered ~10% too narrow.

Implementation: when resolving a `ch` length on shape attributes
(rect x/y/width/height etc.), walk the inherited `font-family`
chain through the FontResolver, build a `Font` at the inherited
font-size, and call `measure_str("0")`. Falls back to the 0.5em
approximation if the font can't be resolved.

Pass-rate: 1142 → 1143 (+1, 0.8014 → 0.8021).
- shapes_rect_ch-values: 0.738 → 1.000
CSS Images 3 / SVG 2: `image-rendering: auto` (the default for
`<feImage>`) uses smooth resampling. Skia's
`SamplingOptions::default()` is nearest-neighbor — produced blocky
upscaled images for fixtures whose `feImage href` points at a
source that gets stretched into a larger primitive subregion.

Switch to `FilterMode::Linear` for the bilinear resample. Matches
Chrome / resvg / Blink default.

Pass-rate held at 1160 (0.8140). All feImage fixtures advanced
toward floor:
- filters_feImage_simple-case: 0.734 → 0.765
- filters_feImage_embedded-png: 0.734 → 0.765
- filters_feImage_svg: 0.891 → 0.848 (regression on SVG-source —
  small, no crossing)
- filters_feImage_preserveAspectRatio=none: 0.883 → 0.896
- filters_feImage_with-subregion-1..5: improved across the board

Residual sub-pixel differences in remaining fixtures are font/
position drift, not the sampling mode. No fixture crossed 0.95.
`<marker overflow="inherit">` inside an `<svg overflow="visible">`
should inherit visible and skip the marker viewport clip. Previously
`inherit` fell to the default-hidden branch, so a marker child
larger than markerWidth/markerHeight (e.g. r=12 in a 3×3 viewport)
clipped to a tiny dot.

`is_overflow_visible(ctx, node)` walks the ancestor chain on
`inherit`, reading attribute or `style="…"`. visible/auto → true;
hidden/scroll/clip → false; root with no value → false (UA default
for marker).

Pass-rate: 1160 → 1161 (+1, 0.8140 → 0.8147).
- painting_overflow_inherit-on-marker: 0.822 → 1.000
SVG 2 §11.4: a `display:none` element does not contribute to its
parent's bounding box. Previously `element_object_bbox` walked all
element children of a `<g>` / `<svg>` / `<switch>` / `<a>`
unconditionally; a display-none child enlarged the union and
distorted any clip / mask / filter / pattern that referenced the
parent's bbox via `objectBoundingBox` units.

The `painting_display_bBox-impact.svg` fixture documents this:
`<g clip-path="url(#circle-bbox)"><rect display="none"
size=160/><rect size=120/></g>` — the clipPath is sized by g's
bbox, expected = 120-bbox circle, current = 160-bbox circle.

Skip display:none children in the bbox accumulator.

Pass-rate: 1161 → 1162 (+1, 0.8147 → 0.8154).
- painting_display_bBox-impact: 0.734 → 1.000
…bjects

SVG 2 §6.13: a path's object bounding box is the tight bbox of the
rendered geometry — Bézier/quadratic CONTROL points do not
contribute. Previously `element_object_bbox` for a `<path>` used
Skia's `path.bounds()` which returns the loose control-point bbox,
overshooting any path with control points outside the rendered
curve.

The `filters_filter_path-bbox.svg` fixture documents this with a Q
curve whose control point at (100, 45) sits well above the actual
curve apex (~y=65); the filter region (objectBoundingBox %) was
stretched up to y=45 instead of stopping at the apex.

Use `path.compute_tight_bounds()` instead.

Pass-rate: 1162 → 1163 (+1, 0.8154 → 0.8161).
- filters_filter_path-bbox: 0.802 → 1.000
…ing)

CSS Paint Order Level 1 / SVG 2 §11.3: `paint-order` reorders
fill/stroke (and markers, but text has no markers). Previously the
text painter hardcoded fill→stroke; a `<tspan paint-order="stroke
fill">` rendered with the default order, leaving the green fill
covered by the blue stroke instead of the spec-correct green fill
visible above the stroke.

Implementation: resolve fill/stroke into `SkPaint`s up-front, then
apply them in the order returned by `resolve_text_paint_order`.
Mirrors the iter 42 shape-painter fix (with the markers phase
folded out since text doesn't have markers). Markers tokens in the
`paint-order` value are silently consumed; invalid tokens fall back
to default per CSS Values 4 §3.4.

Pass-rate: 1163 → 1165 (+2, 0.8161 → 0.8175).
- painting_paint-order_on-text: 0.675 → 0.989
- painting_paint-order_on-tspan: 0.834 → 0.966
…object-bbox)

- resolve url() paint references for text fill
- text stroke resolves url() to gradient/pattern
- mask= on tspan/<a>; tighten text run bbox
- filter= on tspan/<a>
- coarse object-bbox for <text>
`read_inherited` and `resolve_font_size_at` walked the cloned
target's source-DOM parents alongside the `<use>` chain — so a
`<use font-size="48">` referencing `<text>` whose source DOM is
inside `<g font-size="18">` resolved to 18, with the source-DOM
sibling overriding the use's value.

Mirror the iter 7 fix from `inherited_paint`: bound the source-DOM
walk at the innermost `UseFrame::target_id`. When `node` descends
from the target, walk up to and including the target then break;
when `node` is the target itself (or unrelated), skip the source-
DOM walk entirely. The use chain still cascades from the use
element outward.

resvg-test-suite (consensus, pass_floor=0.95): 1176 → 1177 of 1425
(0.8253 → 0.8260). Fixes structure_defs_style-inheritance-on-text
(0.58 → 0.999).
…3.10

The `font:` shorthand resets every sub-property the grammar can
accept (font-style/weight/stretch/size/family) plus several it
can't (font-variant, font-kerning, font-size-adjust, line-height).
Parsing the shorthand and only returning the named sub-properties
let parent declarations of those longhands leak through — a
`<g style="font-kerning: none; font-weight: bold">` parenting a
`<text style="font: 50px sans">` resolved to bold and no-kerning,
when CSS says both should reset to their initial values.

`read_local_font` now returns the initial value ("normal") for
shorthand-resettable longhands when the shorthand is parsed but
didn't include them. `inherited_style_only` (used for the non-
presentation longhands like font-kerning) treats a `font:` on the
same or an ancestor element as a cascade barrier, returning
"normal" so cascades above the shorthand don't leak through.

resvg-test-suite (consensus, pass_floor=0.95): 1177 → 1178 of 1425
(0.8260 → 0.8267). Fixes text_font_font-shorthand (0.55 → 0.973).
`parse_dash_intervals_with_extent` resolved each token via
`parse_length_px`, which treats `em`/`ex`/`rem`/`ch` against the
constant-16 fallback rather than the cascaded `font-size`. A
`stroke-dasharray="2em 1em"` on a `<g font-size="20">` rendered
32-px / 16-px dashes instead of the spec's 40-px / 20-px.

Thread `ctx`/`node` through and resolve font-relative units the
same way `len_attr` does — `inherited_font_size` for em/ex,
`root_font_size` for rem, `ch_advance` for ch. % continues to
resolve against the viewport diagonal.

resvg-test-suite (consensus, pass_floor=0.95): 1178 → 1179 of 1425
(0.8267 → 0.8274). Fixes painting_stroke-dasharray_em-units
(0.63 → 1.000).
…rSpaceOnUse

`<mask maskUnits="userSpaceOnUse" x="30%" width="40%">` ignored
the percent values — `masker::resolve` had no SVG viewport on hand
and silently fell back to the masked element's bbox. Per SVG 2
§14.4 a `<length-percentage>` on these attributes resolves against
the user coordinate system at the point of reference, which for
`maskUnits=userSpaceOnUse` is the nearest SVG viewport.

Thread `(vw, vh)` from `nearest_svg_viewport` through `resolve` and
fold % into px against the appropriate axis (x/width against vw,
y/height against vh).

resvg-test-suite (consensus, pass_floor=0.95): 1179 → 1180 of 1425
(0.8274 → 0.8281). Fixes masking_mask_maskUnits=userSpaceOnUse-
with-percent (0.59 → 0.995).
`preserveAspectRatio="… slice"` scales the image to fully cover its
viewport box, with overflow clipped at the box edges (SVG 2 §8.13).
`compute_image_dst_rect` returns the (over-sized) scaled rect, but
the painter drew it without clipping — slice-mode `<image>` bled
past the box and overlapped sibling content. Add a viewport clip
when the computed dst rect extends past any edge.

resvg-test-suite (consensus, pass_floor=0.95): 1180 → 1183 of 1425
(0.8281 → 0.8302). Fixes structure_image_preserveAspectRatio=
xMinYMin-slice (0.69 → 0.971) plus two cross-cluster wins from the
same overflow path.
`<textPath xlink:href="#p">` folded the referenced `<path>`'s own
`transform` attribute into the path geometry but ignored
`transform-origin`. A `<path transform="rotate(90)" transform-
origin="center">` rotated around (0, 0) instead of the viewport
center, sending the textPath's arc-length walk along an off-screen
curve. Wrap the path's transform as `translate(o) * t * translate(
-o)` per CSS Transforms 1 §3.5, using the existing
`transform_origin_for` helper.

resvg-test-suite (consensus, pass_floor=0.95): held at 1183 of 1425
(0.8302). structure_transform-origin_on-text-path 0.65 → 0.775 —
text now follows the rotated path; residual is the same font-metric
drift that bottoms out other text-on-path fixtures around 0.85.
…ith aspect preservation

- rasterize inline SVG to host <image>'s pixel size
- preserve aspect ratio when rasterizing inline SVG
A nested `<svg viewBox="0 0 200 100">` with no explicit `width`/
`height` had `viewport_w/h = 0`, fell through `paint_nested_svg` to
`paint_children` raw, and the `viewBox` transform was silently
dropped — children rendered in the parent's coordinate space at
full scale. SVG 2 §5.1.2: when `viewBox` is present, missing
`width`/`height` default to `100%` of the containing block.

resvg-test-suite (consensus, pass_floor=0.95): 1185 → 1186 of 1425
(0.8316 → 0.8323). Fixes structure_svg_nested-svg-one-with-rect-
and-one-with-viewBox (0.75 → 1.000).
…ndGlyphs

- textLength on <text> (lengthAdjust=spacing)
- textLength lengthAdjust=spacingAndGlyphs
- per-tspan textLength
`<feImage>` upscales the source image into the filter primitive's
subregion via Skia's `image_filters::image`. We were passing plain
bilinear, which loses high-frequency edges on tile / logo sources —
fixtures showed a 1-2 pixel halo of differences along every edge of
the SVG-logo cluster. Switch to Mitchell bicubic (B=C=1/3), which
matches Chrome / Blink's default feImage upscaling much more
closely.

resvg-test-suite (consensus, pass_floor=0.95): 1187 → 1195 of 1425
(0.8330 → 0.8386). Fixes filters_feImage_simple-case (0.76 → 1.0),
embedded-png (0.76 → 1.0), with-subregion-{1..5} (0.92 → 1.0).
Per-character `draw_str` calls broke Arabic joining and Indic
cluster shaping — Skia's HarfBuzz integration only sees one
codepoint at a time, producing isolated forms. Detect runs that
contain Hebrew (U+0590-05FF), Arabic (U+0600-06FF + supplements
+ presentation forms), Syriac, Thaana, NKo, Samaritan, Mandaic,
Devanagari + other Indic blocks (U+0900-0DFF), and Tibetan, then
draw the contiguous portion as a single string anchored at the
first glyph's resolved position. Skia's shaper picks the right
joining / cluster forms.

resvg-test-suite (consensus, pass_floor=0.95): held at 1195 of
1425 (0.8386); average similarity 90.86% → 90.92%. Arabic /
bidi cluster scores shift in both directions: text_unicode-bidi_
bidi-override 0.90 → 0.92, text_tspan_bidi-reordering 0.81 → 0.88;
text_text_bidi-reordering 0.83 → 0.76, text_textLength_arabic
0.64 → 0.60. Net pass count unchanged — joining is now correct
but bidi reordering (RTL flip) still missing; needs HarfBuzz
direction flag + cluster-mapping reversal in
`compute_kerned_advances` to land. This commit is the first
half of the bidi work.
…IRI fallback, length-or-percent

Pure dedup pass after the 50 surgical commits left repeated patterns:

- Add `layout::transform::wrap_with_origin(m, origin)` and route the five
  `T(o) ∘ M ∘ T(-o)` callers (gradient, pattern, clipper, container, textPath)
  through it
- Drop `transform.rs::read_attr_or_style` (duplicated `style/cascade.rs::
  get_attr_or_style`) and use the shared helper
- Reuse `paint_server::paint_fallback` for the two funcIRI trail-color
  branches in svg_text_painter (text fill + text stroke)
- Promote `viewport.rs::length_or_percent` to `pub(crate)` and replace the
  marker painter's local `parse_len` closure with it

No behavior change — sites collapse to the existing helpers' logic.
…mmary, view, multi-oracle scoring

- Add `reftest bake` to drive Chrome/Puppeteer baking of resvg-test-suite
  fixtures (scripts/reftest_bake_chrome.mjs + reftest-bake-chrome.sh)
- Add `reftest inspect` for per-fixture diagnostics (prints oracle status,
  vs_expected / vs_chrome scores, and points at diff PNG)
- Add `reftest summary` (CLI + JSON) with worst-N consensus list
- Add `reftest view` to serve an interactive dashboard against a result dir
- Add multi-oracle scoring (consensus / disputed / UB) and surface
  per-oracle similarity in report.rs / dashboard.html
- Update reftest-run.sh / reftest-view.sh and dashboard CSS/JS
- Add `dev-render-htmlcss-svg-feature` skill (sibling to the HTML/CSS
  feature loop), update `render-reftest` skill notes, refresh
  htmlcss::svg README and grida_dev AGENTS.md, ignore the bake-cache dir
…one package.json)

Rewrites the Chrome-bake script as TypeScript using `@playwright/test`'s
`chromium` (already present in the workspace via @grida/reftest) and
invokes it through `pnpm --filter @grida/reftest exec tsx`.

This matches the pattern used by `.agents/skills/render-reftest/scripts/
refbrowser_render.ts` — the script resolves its dependency through the
monorepo's existing `node_modules` instead of needing its own
package.json/lockfile/node_modules tree alongside the script.

- Replace `reftest_bake_chrome.mjs` (puppeteer + standalone package.json)
  with `reftest_bake_chrome.ts` (playwright via workspace resolution)
- Update `bake.rs` to spawn `pnpm --filter @grida/reftest exec tsx` and
  drop the `ensure_puppeteer_installed` precheck
- Smoke-tested: chromium launches, fixture-discovery + filter work,
  exits cleanly when no fixtures match
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cg Core Graphics documentation Improvements or additions to documentation svg

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant