Skip to content

feat(canvas): HTML+CSS embed renderer with Chromium-aligned architecture#622

Merged
softmarshmallow merged 3 commits intomainfrom
feature/crazy-shtern
Apr 4, 2026
Merged

feat(canvas): HTML+CSS embed renderer with Chromium-aligned architecture#622
softmarshmallow merged 3 commits intomainfrom
feature/crazy-shtern

Conversation

@softmarshmallow
Copy link
Copy Markdown
Member

@softmarshmallow softmarshmallow commented Apr 4, 2026

Summary

  • Add self-contained htmlcss rendering engine (src/htmlcss/) that converts HTML+CSS to Skia Pictures for opaque canvas embedding
  • Add HTMLEmbedNode — new node type with Embed/Convert dialog in grida-dev
  • Three-phase pipeline (Collect → Layout → Paint) inspired by Chromium's Blink renderer
  • Chromium research doc covering ComputedStyle, LayoutNG, inline layout, paint phases

What's supported

Layout: block (margin collapsing via Taffy), flex, grid, subpixel precision
Box model: width/height/min/max, padding, margin, border (solid/dashed/dotted), per-corner elliptical border-radius
Background: solid color, linear/radial/conic gradients, multi-layer
Text: font properties, text-decoration (simultaneous underline + line-through), text-align, text-transform, white-space, letter/word-spacing
Inline elements: <strong>, <em>, <code>, <kbd>, <mark>, <s>, <del>, <u>, <ins>, <small> — merged into single Paragraph with per-run styles and InlineBoxDecoration (background, border, border-radius, padding via Skia placeholders)
Lists: <ul>/<ol> with disc/circle/square/decimal/lower-alpha/upper-alpha markers
Effects: opacity, visibility, overflow clipping, box-shadow (outer), mix-blend-mode

Test plan

  • cargo check -p cg -p grida-canvas-wasm -p grida-dev — 0 errors, 0 warnings
  • cargo test -p cg --lib -- htmlcss — 9 tests pass
  • cargo build -p cg --examples — all examples build
  • Golden image test: cargo run -p cg --example golden_htmlcss renders fixture HTML files to tmp directory
  • Manual: cargo run -p grida-dev --release -- --system-fonts → drop HTML file → Embed dialog → verify rendering

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • HTML+CSS embed renderer (render & measure HTML/CSS to pictures) and a golden PNG export tool.
    • Editor: import HTML as Embed or Convert; new HTML-embed element with resize/transform support.
    • Default list marker styling for ul/ol/menu.
  • Documentation

    • Detailed HTML+CSS renderer guide and Chromium rendering pipeline research.
  • Tests

    • New test suite covering HTML+CSS rendering scenarios.

…tecture

Add a self-contained `htmlcss` rendering engine that converts HTML+CSS to
Skia Pictures for opaque embedding on the canvas (HTMLEmbedNode).

## Architecture

Three-phase pipeline inspired by Chromium's Blink renderer:
1. **Collect** (Stylo DOM → StyledElement tree) — no Skia objects
2. **Layout** (StyledElement → Taffy → LayoutBox tree) — block/flex/grid
3. **Paint** (LayoutBox → Skia Picture) — backgrounds, borders, text

## HTMLEmbedNode

New node type for rendering HTML as an opaque picture on the canvas.
Registered across all cg match arms (schema, painter, layout, geometry,
resources, scene_graph, cost_prediction, compositor).

## CSS Support

- **Layout**: block (margin collapsing), flex, grid via Taffy; subpixel
  precision (disable_rounding)
- **Box model**: width/height/min/max, padding, margin, border (all sides,
  solid/dashed/dotted), per-corner elliptical border-radius
- **Background**: solid color, linear/radial/conic gradients, multi-layer
- **Text**: font properties, text-decoration (bitfield — simultaneous
  underline + line-through), text-align, text-transform, white-space,
  letter/word-spacing
- **Inline elements**: merged into single Paragraph with per-run styles;
  OpenBox/CloseBox placeholders for padding/border spacing (Chromium
  kOpenTag/kCloseTag model); InlineBoxDecoration with background,
  border-radius, border via get_rects_for_range()
- **Lists**: ul/ol with disc/circle/square/decimal/lower-alpha/upper-alpha
  markers, nested list counters
- **Effects**: opacity, visibility, overflow clipping, box-shadow (outer),
  mix-blend-mode

## grida-dev

- Embed/Convert dialog (rfd) for HTML file drops
- Resize support for HTMLEmbedNode
- Content-height measurement via measure_content_height()

## Documentation

- `docs/wg/feat-2d/htmlcss.md` — feature doc with full CSS property table
- `docs/wg/research/chromium/blink-rendering-pipeline.md` — Chromium
  research: ComputedStyle groups, LayoutNG, inline layout, paint phases,
  list markers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 4, 2026

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

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Apr 4, 2026 4:15pm
6 Skipped Deployments
Project Deployment Actions Updated (UTC)
code Ignored Ignored Apr 4, 2026 4:15pm
legacy Ignored Ignored Apr 4, 2026 4:15pm
backgrounds Skipped Skipped Apr 4, 2026 4:15pm
blog Skipped Skipped Apr 4, 2026 4:15pm
grida Skipped Skipped Apr 4, 2026 4:15pm
viewer Skipped Skipped Apr 4, 2026 4:15pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 4, 2026

Walkthrough

Adds a full HTML+CSS embed renderer and a new Node::HTMLEmbed node type wired through schema, factory, layout, painter, scene/runtime, tooling, docs, and fixtures. Also adds UA stylesheet defaults for list marker types.

Changes

Cohort / File(s) Summary
CSS Styling Foundation
crates/csscascade/src/cascade.rs
Added UA stylesheet defaults: ul/menulist-style-type: disc, ollist-style-type: decimal.
HTML+CSS Rendering Pipeline
crates/grida-canvas/src/htmlcss/mod.rs, .../types.rs, .../style.rs, .../collect.rs, .../layout.rs, .../paint.rs
New htmlcss module: three-phase pipeline (collect_styled_tree → compute_layout → paint_to_picture). Adds styled IR, CSS enums, Taffy layout integration with Skia text measurement, and Skia painting of backgrounds/borders/text/inline decorations and gradients.
Node Schema & Factory
crates/grida-canvas/src/node/schema.rs, .../factory.rs
Added Node::HTMLEmbed and HTMLEmbedNodeRec with geometry, effects, html, fills; NodeFactory helper to construct HTMLEmbed nodes.
Painter Layer & Painter
crates/grida-canvas/src/painter/layer.rs, .../painter.rs
Added PainterPictureLayer::HtmlEmbed / PainterPictureHtmlEmbedLayer; painter invokes crate::htmlcss::render, integrates blend/transform/clip/opacity/effects, and draws fallback on render error.
Layout & Scene Integration
crates/grida-canvas/src/layout/into_taffy.rs, .../engine.rs, .../scene_graph.rs
Map HTMLEmbed to Taffy Style (size), provide schema size/position handling, and treat HTMLEmbed as a leaf in scene graph geometry extraction.
Promotions, Costs & Resources
crates/grida-canvas/src/cache/compositor/promotion.rs, .../runtime/cost_prediction.rs, .../scene.rs, .../resources/mod.rs
Include HtmlEmbed in effect scanning, cost estimation, compositor layer-base extraction, and image URL extraction.
Painter Geometry & Debugging
crates/grida-canvas/src/painter/geometry.rs, .../painter_debug_node.rs, crates/grida-canvas/examples/tool_io_*.rs
Build shape (rect/rrect) for HTMLEmbed, add debug-draw stub, and classify HTMLEmbed in example tooling outputs.
Library Export
crates/grida-canvas/src/lib.rs
Exported new public module htmlcss.
Examples & Tools
crates/grida-canvas/examples/golden_htmlcss.rs, .../tool_io_grida.rs, .../tool_io_svg.rs
Added golden HTML→PNG example and updated example tools to recognize HTMLEmbed in node classification.
Dev UI & Editor
crates/grida-dev/src/main.rs, .../editor/mutation.rs, .../bench/load_bench.rs, crates/grida-dev/Cargo.toml
Added import dialog (rfd dep) offering Embed/Convert, embed import flow creating HTMLEmbed nodes, enabled resize/transform access for HTMLEmbed, and updated bench labeling.
IR & Types
crates/grida-canvas/src/htmlcss/style.rs, .../types.rs
New StyledElement IR, inline-run model, background/gradient structs, and many CSS-like enums and defaults.
Docs & Fixtures
docs/wg/feat-2d/htmlcss.md, docs/wg/research/chromium/*.md, fixtures/test-html/L0/*.html
Documentation of htmlcss pipeline and Blink rendering notes; added/updated HTML fixtures for inline elements and lists.

Sequence Diagram(s)

sequenceDiagram
    participant HTML as HTML Source
    participant Collect as Phase 1\ncollect_styled_tree()
    participant Stylo as Stylo Cascade
    participant Styled as StyledElement\nTree
    participant Layout as Phase 2\ncompute_layout()
    participant Taffy as Taffy Layout
    participant LayoutTree as LayoutBox\nTree
    participant Paint as Phase 3\npaint_to_picture()
    participant Skia as Skia Picture

    HTML->>Collect: provide HTML string
    Collect->>Stylo: parse & resolve CSS
    Stylo->>Styled: produce ComputedValues -> StyledElement
    Styled->>Layout: styled root
    Layout->>Taffy: build nodes + text measure callbacks
    Taffy->>LayoutTree: computed positions/sizes
    LayoutTree->>Paint: positioned LayoutBox tree
    Paint->>Skia: draw backgrounds, borders, text, inline decorations
    Skia-->>HTML: return Picture (with cull rect)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • gridaco/grida#608 — Related to UA stylesheet edits for list-style defaults (same cascade file).
  • gridaco/grida#614 — Related to painter layer/effects plumbing touched by HtmlEmbed layer integration.

Suggested labels

canvas

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately and concisely describes the main change: adding an HTML+CSS embed renderer with Chromium-aligned architecture. It is specific, clear, and directly reflects the primary work in the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 feature/crazy-shtern

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

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: bbc301fe98

ℹ️ 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 +38 to +42
let layout_root = layout::compute_layout(&root, width, fonts);
let content_height = layout_root.height;

Ok(paint::paint_to_picture(
&layout_root,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Respect embed viewport height in HTML picture rendering

This renderer ignores the caller-provided height and always records the picture at content_height, so an HTMLEmbed node cannot enforce its own viewport height after resize. In the current paint path, HTML layers are clipped to the picture cull rect, which means content can extend past the node's intended height instead of being clipped to the layer bounds. This causes incorrect rendering whenever the node height differs from the document's intrinsic content height.

Useful? React with 👍 / 👎.

Comment on lines +693 to +696
if let Some(len) = lp.0.to_length() {
CssLength::Px(len.px())
} else {
CssLength::Auto
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve percentage sizes instead of converting them to auto

Percentage width/height values are dropped here because LengthPercentage is converted only through to_length(), and non-length values fall back to CssLength::Auto. This makes common CSS like width: 50% or height: 100% lay out incorrectly as auto-sized boxes in Taffy. The conversion should emit CssLength::Percent when a percentage is present.

Useful? React with 👍 / 👎.

Comment on lines +303 to +307
items.push(InlineRunItem::Text(TextRun {
text: run.text.clone(),
font: el.font.clone(),
color: el.color,
decoration: 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.

P2 Badge Preserve child run styling when flattening inline groups

Flattening a child InlineGroup rewrites each text run with the parent element's font/color, which erases nested inline styling. For example, nested tags like <strong>...<em>...</em>...</strong> will lose the inner run's specific style during collection. Reusing the original run style is necessary to keep mixed inline formatting accurate.

Useful? React with 👍 / 👎.

@softmarshmallow softmarshmallow added cg Core Graphics css labels Apr 4, 2026
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: 8

Caution

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

⚠️ Outside diff range comments (1)
crates/grida-canvas/src/resources/mod.rs (1)

147-153: ⚠️ Potential issue | 🟠 Major

Image URL extraction skips most HTMLEmbed nodes due link-iteration scope.

At Line [150], scene.graph.iter() walks parent→children link entries, not all graph nodes. Leaf HTMLEmbed nodes without children are never visited, so their fill image URLs are missed.

🐛 Proposed fix
 fn extract_image_urls(scene: &Scene) -> Vec<String> {
     use crate::node::schema::Node;
     let mut urls = Vec::new();
-    for (id, _) in scene.graph.iter() {
-        let Ok(node) = scene.graph.get_node(&id) else {
-            continue;
-        };
+    for (_id, node) in scene.graph.nodes_iter() {
 
         match node {
             Node::Image(n) => {

Also applies to: 225-227

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

In `@crates/grida-canvas/src/resources/mod.rs` around lines 147 - 153, The
extract_image_urls function currently iterates scene.graph.iter() which
traverses parent→children link entries and therefore skips leaf nodes (like
HTMLEmbed) that have no children; update extract_image_urls to iterate all graph
nodes instead (e.g., use the graph's node collection/keys or nodes() iterator)
so every Node is visited, then for each Node (match on Node::HTMLEmbed and other
variants) extract fill image URLs via the same logic; apply the same change for
the other occurrence noted around lines 225-227 so HTMLEmbed leaf nodes are
included.
🧹 Nitpick comments (11)
crates/grida-canvas/src/painter/painter_debug_node.rs (1)

696-698: Acceptable placeholder consistent with sibling node types.

The empty match arm with TODO mirrors the pattern used for AttributedText and Markdown (lines 690-694). The production painter in painter.rs has full HTMLEmbed rendering support per the context snippets, so this is appropriate for initial implementation.

Would you like me to draft the debug rendering implementation for HTMLEmbed, drawing the shape bounds with a distinctive style (similar to how draw_error_node works)?

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

In `@crates/grida-canvas/src/painter/painter_debug_node.rs` around lines 696 -
698, Implement the debug rendering for Node::HTMLEmbed by following the same
placeholder pattern used for AttributedText and Markdown: draw the node bounds
and a distinctive debug style (like the existing draw_error_node behavior) so
HTMLEmbed is visible in debug mode; locate the match arm for Node::HTMLEmbed in
painter_debug_node.rs and call the same helper(s) used for error/debug
visualization (e.g., draw_error_node or the bounding-shape drawing routine) with
the HTMLEmbed node's layout/rect and a clear debug color/style.
docs/wg/research/chromium/blink-rendering-pipeline.md (2)

1-4: Add format: md frontmatter for MDX compatibility.

The frontmatter is missing the format: md field. Since this file doesn't use JSX/MDX features and contains angle brackets in code blocks, adding format: md will prevent potential MDX parsing issues.

📝 Proposed fix
 ---
 title: "Blink Rendering Pipeline — Style, Layout, Paint"
+format: md
 tags: [research, chromium, css, layout, paint]
 ---
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/wg/research/chromium/blink-rendering-pipeline.md` around lines 1 - 4,
The frontmatter for the Markdown document titled "Blink Rendering Pipeline —
Style, Layout, Paint" is missing the format specifier; update the YAML
frontmatter at the top of the file to include format: md so the block contains
title, tags and format: md to ensure MDX parsers treat this file as plain
Markdown (no JSX) and avoid parsing errors from angle-bracket code samples.

19-29: Add language specifier to fenced code block.

The diagram code block could use text or plaintext as a language specifier to satisfy the linter, though this is purely cosmetic for ASCII art diagrams.

📝 Proposed fix
-```
+```text
 DOM + CSSOM
     ↓
 [1. Style]     ComputedStyle per element
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/wg/research/chromium/blink-rendering-pipeline.md` around lines 19 - 29,
The fenced ASCII diagram block lacks a language specifier; update the
triple-backtick fence surrounding the diagram (the block that begins with "DOM +
CSSOM" and the arrows/steps [1. Style]...[4. Composite]) to include a language
token such as text or plaintext (e.g., change ``` to ```text) so the linter is
satisfied.
crates/grida-canvas/examples/golden_htmlcss.rs (1)

25-27: Consider reusing FontRepository for efficiency.

fonts() is called for each file, creating a new FontRepository each time. For directories with many HTML files, this could add overhead. Consider passing a shared FontRepository to render_to_png:

♻️ Proposed refactor for performance
-fn render_to_png(html: &str, width: f32, name: &str, out_dir: &Path) {
-    let fonts = fonts();
+fn render_to_png(html: &str, width: f32, name: &str, out_dir: &Path, fonts: &FontRepository) {
     let picture = htmlcss::render(html, width, 600.0, &fonts).expect("render failed");

Then update callers to pass a shared fonts instance created once in main().

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

In `@crates/grida-canvas/examples/golden_htmlcss.rs` around lines 25 - 27, The
render_to_png function currently calls fonts() each time which constructs a new
FontRepository per call; refactor render_to_png to accept a shared
FontRepository reference (e.g., &FontRepository) instead of calling fonts()
internally, update its signature and body to use that passed repository, and
then modify main to create a single FontRepository once and pass it into
render_to_png for each file processed to avoid repeatedly rebuilding the font
repository.
crates/grida-canvas/src/htmlcss/collect.rs (1)

55-111: Debug string matching for list style types is fragile but acceptable.

The use of format!("{:?}", lst) and string matching is a workaround for Stylo's generated enum types. The comment at lines 56-66 explains the rationale well.

Consider adding a comment noting that this approach may break if Stylo changes its Debug representation:

📝 Optional: Add maintenance note
 /// Generate marker text for a list item.
 ///
 /// Mirrors Chromium's `ListMarker::MarkerText()` which uses `CounterStyle`
 /// to produce the prefix (bullet character or formatted number).
+///
+/// NOTE: Uses Debug format string matching because Stylo's ListStyleType
+/// is a generated enum without stable pattern matching. This may need
+/// updates if Stylo changes its Debug representation.
 fn generate_marker_text<T: std::fmt::Debug>(lst: &T, ordinal: i32) -> Option<String> {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/src/htmlcss/collect.rs` around lines 55 - 111, Add a
short maintenance note above the generate_marker_text function explaining that
the current approach relies on format!("{:?}", lst) and string matching of
Stylo's Debug output (matching variants like "Disc", "Circle", "Decimal", etc.)
and may break if Stylo changes its Debug representation or enum variant names;
mention that callers use to_roman and ordinal-based logic and that a future
improvement would be to switch to a typed API or pattern-match the generated
enum when available.
crates/grida-canvas/src/htmlcss/mod.rs (1)

21-47: Clarify the unused _height parameter.

The _height parameter is intentionally unused since the renderer computes content height via Taffy's MaxContent layout. Consider either:

  1. Documenting this behavior in the doc comment, or
  2. Removing the parameter if height constraints aren't planned

The current implementation correctly uses layout_root.height (computed content height) for the picture bounds.

📝 Option 1: Document the parameter
 /// Render HTML+CSS to a Skia Picture.
+///
+/// # Parameters
+/// - `html`: HTML+CSS source string
+/// - `width`: Available width for text wrapping
+/// - `_height`: Reserved for future use (content height is computed automatically)
+/// - `fonts`: Font repository for text rendering
 pub fn render(
     html: &str,
     width: f32,
     _height: f32,
     fonts: &FontRepository,
 ) -> Result<skia_safe::Picture, String> {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/src/htmlcss/mod.rs` around lines 21 - 47, The render
function's _height parameter is intentionally unused because content height is
computed from layout::compute_layout and used via layout_root.height; update the
render signature or docs to reflect this: either remove the unused _height
parameter from pub fn render(...) if no external height constraint will be
supported, or add a short doc comment above pub fn render explaining that height
is ignored and layout uses Taffy's MaxContent to determine content height (see
layout::compute_layout and layout_root.height) so future readers know why
_height is unused.
crates/grida-canvas/src/htmlcss/paint.rs (1)

248-253: Minor: Unnecessary as scalar cast.

The scalar type is already f32 in skia-safe, so 0.5 as scalar is redundant. Same applies to lines 265-266.

📝 Cleaner syntax
     skia_safe::shaders::radial_gradient(
-        (skia_safe::Point::new(0.5, 0.5), 0.5 as scalar),
+        (skia_safe::Point::new(0.5, 0.5), 0.5),
         &gradient,
         Some(&matrix),
     )
     skia_safe::shaders::sweep_gradient(
         skia_safe::Point::new(0.5, 0.5),
-        (0.0 as scalar, 360.0 as scalar),
+        (0.0, 360.0),
         &gradient,
         Some(&matrix),
     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/src/htmlcss/paint.rs` around lines 248 - 253, The
radial_gradient shader calls use an unnecessary cast "0.5 as scalar" (and
similar occurrences around the same function), so remove the redundant "as
scalar" casts in the skia_safe::shaders::radial_gradient invocations (and any
other places in this file where 0.5 is cast to scalar) and just pass 0.5 (or
0.5f32) directly to match the scalar type expected by skia_safe::Point and the
radial_gradient function.
crates/grida-canvas/src/htmlcss/layout.rs (4)

309-315: Consider reusing map_align_items for align_self mapping.

The align_self mapping duplicates the logic in map_align_items. While minor, consolidating would reduce code duplication.

♻️ Reuse map_align_items
-        align_self: el.align_self.map(|a| match a {
-            types::AlignItems::Start => taffy::AlignSelf::FlexStart,
-            types::AlignItems::End => taffy::AlignSelf::FlexEnd,
-            types::AlignItems::Center => taffy::AlignSelf::Center,
-            types::AlignItems::Stretch => taffy::AlignSelf::Stretch,
-            types::AlignItems::Baseline => taffy::AlignSelf::Baseline,
-        }),
+        align_self: el.align_self.map(|a| {
+            // AlignSelf variants match AlignItems
+            match map_align_items(a) {
+                taffy::AlignItems::FlexStart => taffy::AlignSelf::FlexStart,
+                taffy::AlignItems::FlexEnd => taffy::AlignSelf::FlexEnd,
+                taffy::AlignItems::Center => taffy::AlignSelf::Center,
+                taffy::AlignItems::Stretch => taffy::AlignSelf::Stretch,
+                taffy::AlignItems::Baseline => taffy::AlignSelf::Baseline,
+                _ => taffy::AlignSelf::Auto,
+            }
+        }),

Or create a dedicated map_align_self helper that reuses the core logic.

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

In `@crates/grida-canvas/src/htmlcss/layout.rs` around lines 309 - 315, The
align_self mapping duplicates map_align_items logic; modify the code that sets
align_self to call the existing map_align_items helper (or create a small
map_align_self that delegates to map_align_items) instead of matching again.
Update the block that currently maps el.align_self to use
map_align_items(el.align_self) (or call map_align_self(el.align_self)), ensuring
the helper accepts the same types::AlignItems input and returns taffy::AlignSelf
so the conversion is centralized and duplication removed.

69-78: Consider propagating or logging Taffy layout errors.

The Result from compute_layout_with_measure is discarded with let _ = .... While layout errors are unlikely given valid tree construction, silently ignoring failures could mask issues during development.

♻️ Optional: Add error handling
-    let _ = taffy.compute_layout_with_measure(
+    taffy.compute_layout_with_measure(
         taffy_root,
         taffy::Size {
             width: AvailableSpace::Definite(available_width),
             height: AvailableSpace::MaxContent,
         },
         |known_dimensions, available_space, _node_id, context, _style| {
             text_measure_func(known_dimensions, available_space, context, &fc)
         },
-    );
+    ).expect("Taffy layout computation failed");

Or return a Result from compute_layout if graceful handling is needed.

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

In `@crates/grida-canvas/src/htmlcss/layout.rs` around lines 69 - 78, The call to
taffy.compute_layout_with_measure currently discards its Result (let _ = ...),
so update compute_layout to handle failures: capture the Result from
compute_layout_with_measure (for the taffy_root call that passes
text_measure_func and &fc), and either propagate the error by returning a Result
from the surrounding compute_layout function or log the error (using the crate's
logger) with context including taffy_root and the error details; ensure you
reference the same symbols (taffy.compute_layout_with_measure, taffy_root,
text_measure_func, fc) so the error path is handled instead of ignored.

480-493: LineHeight::Normal uses 1.5, which differs from typical browser default (~1.2).

CSS line-height: normal typically resolves to approximately 1.1-1.2 depending on the font. Using 1.5 may produce looser line spacing than expected for HTML content.

♻️ Consider using a browser-typical value
         types::LineHeight::Normal => {
             ts.set_height_override(true);
-            ts.set_height(1.5);
+            ts.set_height(1.2);  // Closer to browser default for most fonts
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/src/htmlcss/layout.rs` around lines 480 - 493, The code
treats types::LineHeight::Normal as 1.5 which is larger than typical browser
defaults; update the handling in the match arm for types::LineHeight::Normal so
ts.set_height_override(true) is followed by setting ts.set_height to a
browser-typical value (e.g. ~1.15–1.2) or compute a normal line-height from font
metrics if available instead of hardcoding 1.5; adjust the logic around
font.line_height, ts.set_height_override, ts.set_height and consider using
font.size if you need to convert px->unitless.

459-467: Platform-specific font family mapping may cause rendering issues on non-macOS.

The generic font families are mapped to macOS-specific fonts (.AppleSystemUIFont, Menlo). On Windows/Linux, these fonts may not exist, potentially causing fallback to unexpected fonts.

♻️ Consider cross-platform font mapping
     let families: Vec<String> = font
         .families
         .iter()
         .map(|s| match s.as_str() {
-            "system-ui" | "-apple-system" | "BlinkMacSystemFont" => {
-                ".AppleSystemUIFont".to_string()
-            }
-            "sans-serif" => "Helvetica".to_string(),
-            "serif" => "Times".to_string(),
-            "monospace" => "Menlo".to_string(),
+            "system-ui" | "-apple-system" | "BlinkMacSystemFont" => {
+                // Skia typically handles system-ui; keep as-is or use platform detection
+                "system-ui".to_string()
+            }
+            "sans-serif" => "sans-serif".to_string(),
+            "serif" => "serif".to_string(),
+            "monospace" => "monospace".to_string(),
             other => other.to_string(),
         })
         .collect();

Or implement platform detection to select appropriate concrete fonts.

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

In `@crates/grida-canvas/src/htmlcss/layout.rs` around lines 459 - 467, The
current font-family mapping closure (the map matching s.as_str() that returns
".AppleSystemUIFont", "Helvetica", "Times", "Menlo", or other.to_string()) uses
macOS-specific concrete fonts which can break rendering on Windows/Linux; update
the mapping in that closure to return cross-platform safe values instead (e.g.,
keep "system-ui" or map to generic families like "sans-serif", "serif",
"monospace") or add platform detection (using cfg!(target_os = "windows") /
cfg!(target_os = "linux") branches) to choose appropriate concrete fonts per OS;
locate the closure that performs map(|s| match s.as_str() { ... }) in layout.rs
and replace macOS-only entries (".AppleSystemUIFont", "Menlo") with either
generic family names or OS-specific alternatives via cfg checks.
🤖 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-canvas/src/htmlcss/layout.rs`:
- Around line 514-516: The code hardcodes TextDecorationStyle::Solid when
applying decorations in layout.rs (ts.set_decoration_style) which ignores the
font.decoration_style; replace the hardcoded value by converting
font.decoration_style through the existing mapping in sk/mappings.rs (use the
mapping function that returns a textlayout::TextDecorationStyle) and pass that
result to ts.set_decoration_style; make the identical change in the other
occurrence in painter/markdown.rs so both places use the mapped
font.decoration_style instead of Solid.
- Around line 195-199: The computation of max_width inside the text measuring
code incorrectly sets AvailableSpace::MinContent to 0.0 causing premature
wrapping; update the match arm for AvailableSpace::MinContent (the same match
that assigns to max_width using known_dimensions.width.unwrap_or(w)) to use
100_000.0 (same as AvailableSpace::MaxContent) so min-content measures the
unwrapped intrinsic width instead of forcing wraps.

In `@crates/grida-canvas/src/htmlcss/paint.rs`:
- Around line 273-299: paint_borders currently draws straight lines with
canvas.draw_line and ignores StyledElement border-radius; update paint_borders
to detect when any corner radius > 0 (from the element's border radius data on
StyledElement) and switch to rounded-rect drawing: compute inner/outer rects
adjusted by each side's width and radii, build a rounded rectangle stroke (use
canvas.draw_round_rect or construct a path with rounded corners) and paint it
with border_paint for each side or stroke the rounded rect so borders follow the
radii; keep the existing straight-line path as the fallback when all corner
radii are zero. Ensure you reference paint_borders, border_paint,
StyledElement.border and the Canvas draw_round_rect/path APIs when making the
change.

In `@crates/grida-canvas/src/htmlcss/types.rs`:
- Around line 67-73: The BoxSizing enum currently marks BorderBox as the default
but the CSS spec initial value is content-box; update the default so ContentBox
is the Default. Locate the BoxSizing enum (symbols: BoxSizing, ContentBox,
BorderBox, #[derive(Default)]/#[default]) and move the #[default] attribute to
the ContentBox variant (or implement Default for BoxSizing to return
BoxSizing::ContentBox) so the Rust default matches the CSS initial value.

In `@crates/grida-canvas/src/node/schema.rs`:
- Line 1171: Add the missing FlatBuffers table and union entry for the new Rust
variant: define a HTMLEmbed table in format/grida.fbs matching the Rust
HTMLEmbedNodeRec/HTMLEmbedNode fields, then append the HTMLEmbed member to the
union Node so the HTMLEmbed variant can be serialized/deserialized; after
updating grida.fbs run flatc --schema --binary -o /tmp/grida-fbs-check
format/grida.fbs to validate compilation and then add round-trip tests
exercising the HTMLEmbed case in crates/grida-canvas/tests/fbs_roundtrip.rs and
packages/grida-canvas-io/__tests__/format-roundtrip.test.ts to ensure
encode/decode fidelity.

In `@crates/grida-canvas/src/painter/painter.rs`:
- Around line 2130-2136: The Markdown/HTMLEmbed branch drops base opacity when
effects is non-empty because draw_shape_with_effects is called directly; wrap
the draw_shape_with_effects call in the same opacity wrapper used elsewhere so
opacity is applied before effects. Concretely, replace the direct call to
self.draw_shape_with_effects(effects, shape, draw_content) with a call to
self.with_opacity(opacity, Some(&shape.rect), ||
self.draw_shape_with_effects(effects, shape, draw_content)) (or equivalent
closure/adapter) so that with_opacity is invoked for the branch where effects
are present (mirroring how with_opacity is used for shape/text/vector paths).

In `@crates/grida-dev/src/main.rs`:
- Around line 377-395: ask_html_import_mode() currently returns a bool so
callers (e.g., load_master_scenes_from_path) cannot tell a user dismissed the
dialog from an explicit "Convert" selection, causing a dismissed dialog to be
treated as Convert; change ask_html_import_mode() to return an
Option<HtmlImportMode> (or a small enum HtmlImportMode { Embed, Convert }) where
Some(Embed)/Some(Convert) correspond to explicit choices and None represents
dismissal, update the match on MessageDialogResult in ask_html_import_mode to
return the appropriate Some variant or None for cancel/close, and update
load_master_scenes_from_path (and the other occurrence around the second
reported block) to abort the load when None is returned instead of treating it
as Convert.
- Around line 347-357: The measurement uses a new FontRepository (temp_fonts)
with repo.enable_system_fallback(), causing mismatch with the renderer's font
setup; change measure_content_height(&html_source, width, &temp_fonts) to use
the runtime's actual font configuration instead of constructing
temp_fonts—either pass the existing runtime FontRepository/byte store into
cg::htmlcss::measure_content_height or build the temp repo from the runtime
flags (respecting use_system_fonts and embedded-font settings) so the
measurement and renderer share the same font fallback/embedding behavior (refer
to FontRepository, enable_system_fallback, use_system_fonts,
measure_content_height, and html_source to locate the code).

---

Outside diff comments:
In `@crates/grida-canvas/src/resources/mod.rs`:
- Around line 147-153: The extract_image_urls function currently iterates
scene.graph.iter() which traverses parent→children link entries and therefore
skips leaf nodes (like HTMLEmbed) that have no children; update
extract_image_urls to iterate all graph nodes instead (e.g., use the graph's
node collection/keys or nodes() iterator) so every Node is visited, then for
each Node (match on Node::HTMLEmbed and other variants) extract fill image URLs
via the same logic; apply the same change for the other occurrence noted around
lines 225-227 so HTMLEmbed leaf nodes are included.

---

Nitpick comments:
In `@crates/grida-canvas/examples/golden_htmlcss.rs`:
- Around line 25-27: The render_to_png function currently calls fonts() each
time which constructs a new FontRepository per call; refactor render_to_png to
accept a shared FontRepository reference (e.g., &FontRepository) instead of
calling fonts() internally, update its signature and body to use that passed
repository, and then modify main to create a single FontRepository once and pass
it into render_to_png for each file processed to avoid repeatedly rebuilding the
font repository.

In `@crates/grida-canvas/src/htmlcss/collect.rs`:
- Around line 55-111: Add a short maintenance note above the
generate_marker_text function explaining that the current approach relies on
format!("{:?}", lst) and string matching of Stylo's Debug output (matching
variants like "Disc", "Circle", "Decimal", etc.) and may break if Stylo changes
its Debug representation or enum variant names; mention that callers use
to_roman and ordinal-based logic and that a future improvement would be to
switch to a typed API or pattern-match the generated enum when available.

In `@crates/grida-canvas/src/htmlcss/layout.rs`:
- Around line 309-315: The align_self mapping duplicates map_align_items logic;
modify the code that sets align_self to call the existing map_align_items helper
(or create a small map_align_self that delegates to map_align_items) instead of
matching again. Update the block that currently maps el.align_self to use
map_align_items(el.align_self) (or call map_align_self(el.align_self)), ensuring
the helper accepts the same types::AlignItems input and returns taffy::AlignSelf
so the conversion is centralized and duplication removed.
- Around line 69-78: The call to taffy.compute_layout_with_measure currently
discards its Result (let _ = ...), so update compute_layout to handle failures:
capture the Result from compute_layout_with_measure (for the taffy_root call
that passes text_measure_func and &fc), and either propagate the error by
returning a Result from the surrounding compute_layout function or log the error
(using the crate's logger) with context including taffy_root and the error
details; ensure you reference the same symbols
(taffy.compute_layout_with_measure, taffy_root, text_measure_func, fc) so the
error path is handled instead of ignored.
- Around line 480-493: The code treats types::LineHeight::Normal as 1.5 which is
larger than typical browser defaults; update the handling in the match arm for
types::LineHeight::Normal so ts.set_height_override(true) is followed by setting
ts.set_height to a browser-typical value (e.g. ~1.15–1.2) or compute a normal
line-height from font metrics if available instead of hardcoding 1.5; adjust the
logic around font.line_height, ts.set_height_override, ts.set_height and
consider using font.size if you need to convert px->unitless.
- Around line 459-467: The current font-family mapping closure (the map matching
s.as_str() that returns ".AppleSystemUIFont", "Helvetica", "Times", "Menlo", or
other.to_string()) uses macOS-specific concrete fonts which can break rendering
on Windows/Linux; update the mapping in that closure to return cross-platform
safe values instead (e.g., keep "system-ui" or map to generic families like
"sans-serif", "serif", "monospace") or add platform detection (using
cfg!(target_os = "windows") / cfg!(target_os = "linux") branches) to choose
appropriate concrete fonts per OS; locate the closure that performs map(|s|
match s.as_str() { ... }) in layout.rs and replace macOS-only entries
(".AppleSystemUIFont", "Menlo") with either generic family names or OS-specific
alternatives via cfg checks.

In `@crates/grida-canvas/src/htmlcss/mod.rs`:
- Around line 21-47: The render function's _height parameter is intentionally
unused because content height is computed from layout::compute_layout and used
via layout_root.height; update the render signature or docs to reflect this:
either remove the unused _height parameter from pub fn render(...) if no
external height constraint will be supported, or add a short doc comment above
pub fn render explaining that height is ignored and layout uses Taffy's
MaxContent to determine content height (see layout::compute_layout and
layout_root.height) so future readers know why _height is unused.

In `@crates/grida-canvas/src/htmlcss/paint.rs`:
- Around line 248-253: The radial_gradient shader calls use an unnecessary cast
"0.5 as scalar" (and similar occurrences around the same function), so remove
the redundant "as scalar" casts in the skia_safe::shaders::radial_gradient
invocations (and any other places in this file where 0.5 is cast to scalar) and
just pass 0.5 (or 0.5f32) directly to match the scalar type expected by
skia_safe::Point and the radial_gradient function.

In `@crates/grida-canvas/src/painter/painter_debug_node.rs`:
- Around line 696-698: Implement the debug rendering for Node::HTMLEmbed by
following the same placeholder pattern used for AttributedText and Markdown:
draw the node bounds and a distinctive debug style (like the existing
draw_error_node behavior) so HTMLEmbed is visible in debug mode; locate the
match arm for Node::HTMLEmbed in painter_debug_node.rs and call the same
helper(s) used for error/debug visualization (e.g., draw_error_node or the
bounding-shape drawing routine) with the HTMLEmbed node's layout/rect and a
clear debug color/style.

In `@docs/wg/research/chromium/blink-rendering-pipeline.md`:
- Around line 1-4: The frontmatter for the Markdown document titled "Blink
Rendering Pipeline — Style, Layout, Paint" is missing the format specifier;
update the YAML frontmatter at the top of the file to include format: md so the
block contains title, tags and format: md to ensure MDX parsers treat this file
as plain Markdown (no JSX) and avoid parsing errors from angle-bracket code
samples.
- Around line 19-29: The fenced ASCII diagram block lacks a language specifier;
update the triple-backtick fence surrounding the diagram (the block that begins
with "DOM + CSSOM" and the arrows/steps [1. Style]...[4. Composite]) to include
a language token such as text or plaintext (e.g., change ``` to ```text) so the
linter is satisfied.
🪄 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: 05705b5e-2b92-4a03-9aaa-744e4db3affb

📥 Commits

Reviewing files that changed from the base of the PR and between cc37a09 and bbc301f.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (33)
  • crates/csscascade/src/cascade.rs
  • crates/grida-canvas/examples/golden_htmlcss.rs
  • crates/grida-canvas/examples/tool_io_grida.rs
  • crates/grida-canvas/examples/tool_io_svg.rs
  • crates/grida-canvas/src/cache/compositor/promotion.rs
  • crates/grida-canvas/src/htmlcss/collect.rs
  • crates/grida-canvas/src/htmlcss/layout.rs
  • crates/grida-canvas/src/htmlcss/mod.rs
  • crates/grida-canvas/src/htmlcss/paint.rs
  • crates/grida-canvas/src/htmlcss/style.rs
  • crates/grida-canvas/src/htmlcss/types.rs
  • crates/grida-canvas/src/layout/engine.rs
  • crates/grida-canvas/src/layout/into_taffy.rs
  • crates/grida-canvas/src/lib.rs
  • crates/grida-canvas/src/node/factory.rs
  • crates/grida-canvas/src/node/scene_graph.rs
  • crates/grida-canvas/src/node/schema.rs
  • crates/grida-canvas/src/painter/geometry.rs
  • crates/grida-canvas/src/painter/layer.rs
  • crates/grida-canvas/src/painter/painter.rs
  • crates/grida-canvas/src/painter/painter_debug_node.rs
  • crates/grida-canvas/src/resources/mod.rs
  • crates/grida-canvas/src/runtime/cost_prediction.rs
  • crates/grida-canvas/src/runtime/scene.rs
  • crates/grida-dev/Cargo.toml
  • crates/grida-dev/src/bench/load_bench.rs
  • crates/grida-dev/src/editor/mutation.rs
  • crates/grida-dev/src/main.rs
  • docs/wg/feat-2d/htmlcss.md
  • docs/wg/research/chromium/blink-rendering-pipeline.md
  • docs/wg/research/chromium/index.md
  • fixtures/test-html/L0/text-inline-elements.html
  • fixtures/test-html/L0/text-lists.html

Comment thread crates/grida-canvas/src/htmlcss/layout.rs Outdated
Comment on lines +514 to +516
if decoration != textlayout::TextDecoration::NO_DECORATION {
ts.set_decoration_type(decoration);
ts.set_decoration_style(textlayout::TextDecorationStyle::Solid);
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify TextDecorationStyle variants in cg::prelude match Skia's variants
ast-grep --pattern 'pub enum TextDecorationStyle {
  $$$
}'

Repository: gridaco/grida

Length of output: 795


🏁 Script executed:

#!/bin/bash
# Check the actual code at lines 514-516
sed -n '510,520p' crates/grida-canvas/src/htmlcss/layout.rs

Repository: gridaco/grida

Length of output: 518


🏁 Script executed:

#!/bin/bash
# Search for TextDecorationStyle in textlayout or related modules
rg 'enum TextDecorationStyle' --type rust

Repository: gridaco/grida

Length of output: 123


🏁 Script executed:

#!/bin/bash
# Check if font.decoration_style exists in FontProps
rg 'decoration_style' crates/grida-canvas/src/htmlcss/layout.rs -B 2 -A 2

Repository: gridaco/grida

Length of output: 347


🏁 Script executed:

#!/bin/bash
# Find textlayout module to check TextDecorationStyle variants
rg 'mod textlayout|textlayout::TextDecorationStyle' crates/grida-canvas/src/ -B 2 -A 5 --type rust | head -50

Repository: gridaco/grida

Length of output: 2602


🏁 Script executed:

#!/bin/bash
# Search for FontProps struct and decoration_style field
rg 'struct FontProps|pub decoration_style' crates/grida-canvas/src/ --type rust -B 2 -A 10

Repository: gridaco/grida

Length of output: 1987


🏁 Script executed:

#!/bin/bash
# Check what textlayout module is being used (could be external crate)
rg 'use.*textlayout' crates/grida-canvas/src/htmlcss/layout.rs

Repository: gridaco/grida

Length of output: 177


Apply font decoration style mapping for CSS text-decoration-style support.

The font.decoration_style field is ignored; TextDecorationStyle::Solid is hardcoded at lines 515-516 in layout.rs (and identically at painter/markdown.rs). This prevents CSS text-decoration-style variations (dashed, dotted, wavy, double) from rendering. A conversion mapping already exists in sk/mappings.rs for all variants.

Update both occurrences to use the font's decoration style:

Fix for layout.rs
     if decoration != textlayout::TextDecoration::NO_DECORATION {
         ts.set_decoration_type(decoration);
-        ts.set_decoration_style(textlayout::TextDecorationStyle::Solid);
+        ts.set_decoration_style(font.decoration_style.into());
📝 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
if decoration != textlayout::TextDecoration::NO_DECORATION {
ts.set_decoration_type(decoration);
ts.set_decoration_style(textlayout::TextDecorationStyle::Solid);
if decoration != textlayout::TextDecoration::NO_DECORATION {
ts.set_decoration_type(decoration);
ts.set_decoration_style(font.decoration_style.into());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/src/htmlcss/layout.rs` around lines 514 - 516, The code
hardcodes TextDecorationStyle::Solid when applying decorations in layout.rs
(ts.set_decoration_style) which ignores the font.decoration_style; replace the
hardcoded value by converting font.decoration_style through the existing mapping
in sk/mappings.rs (use the mapping function that returns a
textlayout::TextDecorationStyle) and pass that result to
ts.set_decoration_style; make the identical change in the other occurrence in
painter/markdown.rs so both places use the mapped font.decoration_style instead
of Solid.

Comment on lines +273 to +299
fn paint_borders(canvas: &Canvas, style: &StyledElement, w: f32, h: f32) {
let b = &style.border;

if b.top.width > 0.0 && b.top.style != types::BorderStyle::None {
let paint = border_paint(&b.top);
let by = b.top.width / 2.0;
canvas.draw_line((0.0, by), (w, by), &paint);
}

if b.bottom.width > 0.0 && b.bottom.style != types::BorderStyle::None {
let paint = border_paint(&b.bottom);
let by = h - b.bottom.width / 2.0;
canvas.draw_line((0.0, by), (w, by), &paint);
}

if b.left.width > 0.0 && b.left.style != types::BorderStyle::None {
let paint = border_paint(&b.left);
let bx = b.left.width / 2.0;
canvas.draw_line((bx, 0.0), (bx, h), &paint);
}

if b.right.width > 0.0 && b.right.style != types::BorderStyle::None {
let paint = border_paint(&b.right);
let bx = w - b.right.width / 2.0;
canvas.draw_line((bx, 0.0), (bx, h), &paint);
}
}
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there are test fixtures or docs that mention border + radius behavior
rg -n "border.*radius" fixtures/test-html/

Repository: gridaco/grida

Length of output: 3764


🏁 Script executed:

#!/bin/bash
# Check paint-border-radius.html test to see if it includes borders
cat fixtures/test-html/L0/paint-border-radius.html

Repository: gridaco/grida

Length of output: 1992


🏁 Script executed:

#!/bin/bash
# Search for border-radius handling in paint.rs
rg -n "border.radius|border_radius" crates/grida-canvas/src/htmlcss/paint.rs -A 2 -B 2

Repository: gridaco/grida

Length of output: 1188


🏁 Script executed:

#!/bin/bash
# Check how borders are drawn in paint.rs and if border-radius is considered
rg -n "paint_borders|draw_line" crates/grida-canvas/src/htmlcss/paint.rs -A 5

Repository: gridaco/grida

Length of output: 1489


🏁 Script executed:

#!/bin/bash
# Look for border-radius handling in the entire htmlcss module
rg -n "border.radius|border_radius" crates/grida-canvas/src/htmlcss/ --type rs

Repository: gridaco/grida

Length of output: 83


Border painting doesn't account for border-radius.

The paint_borders function draws borders using simple straight lines via canvas.draw_line() without considering the element's border-radius property. When both are present, this creates visual artifacts where borders don't follow the rounded corners. The dedicated test fixture paint-border-radius.html only tests background rendering with border-radius; it doesn't test borders combined with border-radius, leaving this case untested.

To fix this, paint_borders should use canvas.draw_round_rect() or path-based drawing to render borders that respect the border-radius values when present.

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

In `@crates/grida-canvas/src/htmlcss/paint.rs` around lines 273 - 299,
paint_borders currently draws straight lines with canvas.draw_line and ignores
StyledElement border-radius; update paint_borders to detect when any corner
radius > 0 (from the element's border radius data on StyledElement) and switch
to rounded-rect drawing: compute inner/outer rects adjusted by each side's width
and radii, build a rounded rectangle stroke (use canvas.draw_round_rect or
construct a path with rounded corners) and paint it with border_paint for each
side or stroke the rounded rect so borders follow the radii; keep the existing
straight-line path as the fallback when all corner radii are zero. Ensure you
reference paint_borders, border_paint, StyledElement.border and the Canvas
draw_round_rect/path APIs when making the change.

Comment thread crates/grida-canvas/src/htmlcss/types.rs Outdated
BooleanOperation(BooleanPathOperationNodeRec),
Image(ImageNodeRec),
Markdown(MarkdownNodeRec),
HTMLEmbed(HTMLEmbedNodeRec),
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify the expected round-trip test files exist
fd -a 'fbs_roundtrip.rs'
fd -a 'format-roundtrip.test.ts'

# Check whether HTMLEmbed was appended/covered in round-trip tests
rg -n -C2 'HTMLEmbed|HTML.?Embed|Node::HTMLEmbed' \
  crates/grida-canvas/tests/fbs_roundtrip.rs \
  packages/grida-canvas-io/__tests__/format-roundtrip.test.ts

Repository: gridaco/grida

Length of output: 197


🏁 Script executed:

#!/bin/bash

# Check if HTMLEmbed exists at line 1171 in schema.rs
echo "=== Checking schema.rs for HTMLEmbed around line 1171 ==="
sed -n '1165,1180p' crates/grida-canvas/src/node/schema.rs

echo ""
echo "=== Checking fbs_roundtrip.rs test structure ==="
head -100 crates/grida-canvas/tests/fbs_roundtrip.rs

echo ""
echo "=== Checking what other union members are in tests ==="
rg -n 'Node::' crates/grida-canvas/tests/fbs_roundtrip.rs | head -20

Repository: gridaco/grida

Length of output: 5419


🏁 Script executed:

#!/bin/bash

# Check if HTMLEmbed is in the FlatBuffers schema
echo "=== Checking grida.fbs for HTMLEmbed ==="
rg -n 'HTMLEmbed' format/grida.fbs

# Check the union definition in grida.fbs
echo ""
echo "=== Looking for Node union definition ==="
rg -B5 -A20 'union Node' format/grida.fbs | head -60

Repository: gridaco/grida

Length of output: 3094


🏁 Script executed:

#!/bin/bash

# Search entire repo for HTMLEmbed
echo "=== Searching entire repo for HTMLEmbed ==="
rg -n 'HTMLEmbed' --type rust --type typescript

echo ""
echo "=== Check if HTMLEmbedNodeRec is defined in schema.rs ==="
rg -n 'HTMLEmbedNodeRec' crates/grida-canvas/src/node/schema.rs

echo ""
echo "=== Check the Node enum in schema.rs to see full context ==="
rg -B2 -A2 'enum Node' crates/grida-canvas/src/node/schema.rs | head -80

Repository: gridaco/grida

Length of output: 743


🏁 Script executed:

#!/bin/bash

# Check the end of the Node union in grida.fbs to see what was last added
echo "=== Last entries in Node union in grida.fbs ==="
rg -B3 'union Node' format/grida.fbs | tail -40

echo ""
echo "=== Check if grida.fbs is still valid (search for Markdown which should be there) ==="
rg -n 'MarkdownNode' format/grida.fbs

Repository: gridaco/grida

Length of output: 718


🏁 Script executed:

#!/bin/bash

# Search for Markdown in grida.fbs (case-sensitive)
echo "=== Search for Markdown in grida.fbs ==="
rg -i 'markdown' format/grida.fbs

echo ""
echo "=== Show the full Node union definition in grida.fbs ==="
rg -A100 '^union Node {' format/grida.fbs | head -120

Repository: gridaco/grida

Length of output: 262


🏁 Script executed:

#!/bin/bash

# Check if HTMLEmbed or related mentions exist anywhere in format directory
echo "=== Search entire format directory for HTMLEmbed ==="
rg 'HTMLEmbed' format/

echo ""
echo "=== Count of Node variants in schema.rs ==="
rg '^\s+[A-Z]\w+\(' crates/grida-canvas/src/node/schema.rs | grep -c '^\s'

echo ""
echo "=== List all Node variants in schema.rs ==="
rg '^\s+[A-Z]\w+\(' crates/grida-canvas/src/node/schema.rs | head -30

Repository: gridaco/grida

Length of output: 1164


🏁 Script executed:

#!/bin/bash

# Check if grida.fbs exists and its size
ls -lah format/grida.fbs

echo ""
echo "=== First 50 lines of grida.fbs ==="
head -50 format/grida.fbs

echo ""
echo "=== Search for 'union Node' in grida.fbs ==="
grep -n 'union Node' format/grida.fbs

Repository: gridaco/grida

Length of output: 3200


🏁 Script executed:

#!/bin/bash

# Check what's after the union Node line
echo "=== Content around union Node definition ==="
sed -n '/^union Node/,/^}/p' format/grida.fbs

Repository: gridaco/grida

Length of output: 378


🏁 Script executed:

#!/bin/bash

# Check when HTMLEmbed was added to schema.rs
echo "=== Check git history for HTMLEmbed in schema.rs ==="
git log --oneline -p crates/grida-canvas/src/node/schema.rs | grep -B5 -A5 'HTMLEmbed' | head -50

echo ""
echo "=== Check if there are any pending changes or TODOs about HTMLEmbed ==="
rg -i 'todo|fixme|xxx' crates/grida-canvas/src/node/schema.rs | grep -i 'html'

echo ""
echo "=== Check if grida.fbs has any references to HTML ==="
rg -i 'html' format/grida.fbs

Repository: gridaco/grida

Length of output: 1585


🏁 Script executed:

#!/bin/bash

# Check the README for how to compile/validate grida.fbs
echo "=== Check format/README.md for compilation instructions ==="
cat format/README.md 2>/dev/null || echo "No README.md found"

echo ""
echo "=== Look for flatc validation/compilation in CI or build scripts ==="
fd -a '*.yml' -o '*.yaml' -o 'Makefile' -o '*.sh' | xargs rg -l 'flatc' 2>/dev/null || echo "No flatc references found"

Repository: gridaco/grida

Length of output: 3811


Add HTMLEmbedNode table to grida.fbs and append to union Node to complete the schema change.

HTMLEmbed was added to the Rust runtime model in schema.rs but is missing from the FlatBuffers schema in format/grida.fbs. This breaks serialization; the variant cannot be encoded or decoded without a corresponding FlatBuffers table definition. After adding the table and union member, validate compilation with flatc --schema --binary -o /tmp/grida-fbs-check format/grida.fbs (see format/README.md), then add round-trip test coverage in both crates/grida-canvas/tests/fbs_roundtrip.rs and packages/grida-canvas-io/__tests__/format-roundtrip.test.ts.

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

In `@crates/grida-canvas/src/node/schema.rs` at line 1171, Add the missing
FlatBuffers table and union entry for the new Rust variant: define a HTMLEmbed
table in format/grida.fbs matching the Rust HTMLEmbedNodeRec/HTMLEmbedNode
fields, then append the HTMLEmbed member to the union Node so the HTMLEmbed
variant can be serialized/deserialized; after updating grida.fbs run flatc
--schema --binary -o /tmp/grida-fbs-check format/grida.fbs to validate
compilation and then add round-trip tests exercising the HTMLEmbed case in
crates/grida-canvas/tests/fbs_roundtrip.rs and
packages/grida-canvas-io/__tests__/format-roundtrip.test.ts to ensure
encode/decode fidelity.

Comment on lines +2130 to +2136
if opacity >= 1.0 && effects.is_empty() {
draw_content();
} else if effects.is_empty() {
self.with_opacity(opacity, Some(&shape.rect), draw_content);
} else {
self.draw_shape_with_effects(effects, shape, draw_content);
}
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

Opacity is dropped for Markdown/HTMLEmbed layers whenever effects is non-empty.

In these branches the else arm jumps straight to draw_shape_with_effects(), but unlike the shape/text/vector paths there is no with_opacity() / combined-opacity wrapper around that call. Any shadow/blur/glass therefore makes base.opacity a no-op for these layer types.

Also applies to: 2208-2214

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

In `@crates/grida-canvas/src/painter/painter.rs` around lines 2130 - 2136, The
Markdown/HTMLEmbed branch drops base opacity when effects is non-empty because
draw_shape_with_effects is called directly; wrap the draw_shape_with_effects
call in the same opacity wrapper used elsewhere so opacity is applied before
effects. Concretely, replace the direct call to
self.draw_shape_with_effects(effects, shape, draw_content) with a call to
self.with_opacity(opacity, Some(&shape.rect), ||
self.draw_shape_with_effects(effects, shape, draw_content)) (or equivalent
closure/adapter) so that with_opacity is invoked for the branch where effects
are present (mirroring how with_opacity is used for shape/text/vector paths).

Comment on lines +347 to +357
let width = 800.0f32;
let temp_fonts = {
use cg::resources::ByteStore;
use cg::runtime::font_repository::FontRepository;
let mut repo =
FontRepository::new(std::sync::Arc::new(std::sync::Mutex::new(ByteStore::new())));
repo.enable_system_fallback();
repo
};
let height =
cg::htmlcss::measure_content_height(&html_source, width, &temp_fonts).unwrap_or(600.0);
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

measure_content_height() is using a different font configuration than the renderer.

This helper builds a fresh FontRepository, unconditionally enables system fallback, and never inherits the runtime's use_system_fonts / embedded-font setup. On the default --system-fonts=false path the measured height can diverge from the actual render enough to clip or over-size the embed.

🤖 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 - 357, The measurement uses a
new FontRepository (temp_fonts) with repo.enable_system_fallback(), causing
mismatch with the renderer's font setup; change
measure_content_height(&html_source, width, &temp_fonts) to use the runtime's
actual font configuration instead of constructing temp_fonts—either pass the
existing runtime FontRepository/byte store into
cg::htmlcss::measure_content_height or build the temp repo from the runtime
flags (respecting use_system_fonts and embedded-font settings) so the
measurement and renderer share the same font fallback/embedding behavior (refer
to FontRepository, enable_system_fallback, use_system_fonts,
measure_content_height, and html_source to locate the code).

Comment on lines +377 to +395
/// Show a dialog asking the user to choose between Embed and Convert for HTML files.
/// Returns true for Embed, false for Convert.
fn ask_html_import_mode() -> bool {
use rfd::MessageButtons;
use rfd::MessageDialog;
use rfd::MessageDialogResult;

let result = MessageDialog::new()
.set_title("HTML Import Mode")
.set_description("How would you like to import this HTML file?\n\n\u{2022} Embed \u{2014} Render as opaque picture (CSS-accurate, non-editable)\n\u{2022} Convert \u{2014} Convert to editable Grida IR nodes (lossy CSS)")
.set_buttons(MessageButtons::OkCancelCustom("Embed".to_string(), "Convert".to_string()))
.show();

match result {
MessageDialogResult::Ok => true,
MessageDialogResult::Custom(s) if s == "Embed" => true,
_ => false,
}
}
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

Dialog dismissal currently falls through to Convert.

ask_html_import_mode() only returns a bool, so the caller can't distinguish “user chose Convert” from “user dismissed the dialog”. load_master_scenes_from_path() treats every false as Convert, so closing/escaping the chooser still replaces the current scene with an imported document. Return an enum/Option here and abort the load when the dialog is dismissed.

Also applies to: 496-502

🤖 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 377 - 395, ask_html_import_mode()
currently returns a bool so callers (e.g., load_master_scenes_from_path) cannot
tell a user dismissed the dialog from an explicit "Convert" selection, causing a
dismissed dialog to be treated as Convert; change ask_html_import_mode() to
return an Option<HtmlImportMode> (or a small enum HtmlImportMode { Embed,
Convert }) where Some(Embed)/Some(Convert) correspond to explicit choices and
None represents dismissal, update the match on MessageDialogResult in
ask_html_import_mode to return the appropriate Some variant or None for
cancel/close, and update load_master_scenes_from_path (and the other occurrence
around the second reported block) to abort the load when None is returned
instead of treating it as Convert.

…content

- Fix percentage width/height values being dropped to auto in extract_size
  and extract_max_size (use to_percentage() fallback)
- Fix inline group flattening overwriting nested child run styling — preserve
  original font/color for nested inline elements like <strong><em>text</em></strong>
- Fix box-sizing default to content-box per CSS spec (was border-box)
- Fix MinContent measure returning 0 — use Skia's min_intrinsic_width() for
  correct word-break minimum width; MaxContent uses max_intrinsic_width()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel vercel Bot temporarily deployed to Preview – backgrounds April 4, 2026 15:43 Inactive
@vercel vercel Bot temporarily deployed to Preview – blog April 4, 2026 15:43 Inactive
@vercel vercel Bot temporarily deployed to Preview – grida April 4, 2026 15:43 Inactive
@vercel vercel Bot temporarily deployed to Preview – viewer April 4, 2026 15:43 Inactive
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: 1

🧹 Nitpick comments (2)
crates/grida-canvas/src/htmlcss/layout.rs (2)

128-157: Consider using reference-counted types to avoid cloning text content.

Lines 137 and 152 clone TextRun and InlineRunItem vectors into the TextMeasure context. For documents with large text content, this could impact memory and performance. Using Rc<TextRun> and Rc<Vec<InlineRunItem>> would allow sharing without ownership transfer.

This is acceptable for an initial implementation but worth revisiting if performance profiling reveals allocation pressure.

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

In `@crates/grida-canvas/src/htmlcss/layout.rs` around lines 128 - 157, The code
currently clones large text data when creating TextMeasure in the
StyledNode::Text and StyledNode::InlineGroup branches; modify TextMeasure to
hold reference-counted data (e.g., Rc<TextRun> and Rc<Vec<InlineRunItem>> or
Rc<[InlineRunItem]>) and then pass Rc::clone(...) into new_leaf_with_context
instead of cloning the run/items so StyledNode::Text uses Rc::new(run) /
Rc::clone(&run_rc) and StyledNode::InlineGroup uses Rc::new(group.items) /
Rc::clone(&items_rc); update the TextMeasure type and any callers to use
Rc-wrapped types to enable shared ownership without expensive cloning.

486-499: LineHeight::Normal uses 1.5 instead of CSS default ~1.2.

Line 489 sets height to 1.5 for LineHeight::Normal. The CSS specification's normal line-height is typically around 1.0–1.2 depending on the font. Using 1.5 provides more generous spacing but may cause layout discrepancies when rendering HTML that expects CSS-compliant behavior.

If this is intentional for improved readability, consider documenting the deviation.

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

In `@crates/grida-canvas/src/htmlcss/layout.rs` around lines 486 - 499, The code
handling types::LineHeight::Normal currently forces ts.set_height(1.5) which
deviates from the CSS "normal" (~1.0–1.2); update the Branch handling
LineHeight::Normal in the match (the block that calls
ts.set_height_override(true) and ts.set_height(...)) to use a CSS-consistent
default (e.g., 1.2 or derive from font metrics) instead of 1.5, and if 1.5 was
intentional keep the value but add a comment/docstring noting the deliberate
deviation; reference the match on font.line_height and the ts.set_height /
ts.set_height_override calls when making the change.
🤖 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-canvas/src/htmlcss/layout.rs`:
- Around line 461-480: The current mapping of CSS generic families in the
closure that builds `families` (used to create `family_refs` and passed to
`ts.set_font_families`) uses single platform-specific names; replace each
mapping with a prioritized, cross-platform list of fallback family names (e.g.,
for system-ui use Apple, Windows and generic fallbacks; for
sans-serif/serif/monospace include common Windows/Linux/Mac fonts and generic
names) so that `families` contains multiple alternatives per input family, then
pass the flattened ordered list to `ts.set_font_families(&family_refs)` (mirror
the fallback pattern used in `markdown.rs` and `text_edit/skia_layout.rs` when
constructing the arrays before calling `set_font_families`).

---

Nitpick comments:
In `@crates/grida-canvas/src/htmlcss/layout.rs`:
- Around line 128-157: The code currently clones large text data when creating
TextMeasure in the StyledNode::Text and StyledNode::InlineGroup branches; modify
TextMeasure to hold reference-counted data (e.g., Rc<TextRun> and
Rc<Vec<InlineRunItem>> or Rc<[InlineRunItem]>) and then pass Rc::clone(...) into
new_leaf_with_context instead of cloning the run/items so StyledNode::Text uses
Rc::new(run) / Rc::clone(&run_rc) and StyledNode::InlineGroup uses
Rc::new(group.items) / Rc::clone(&items_rc); update the TextMeasure type and any
callers to use Rc-wrapped types to enable shared ownership without expensive
cloning.
- Around line 486-499: The code handling types::LineHeight::Normal currently
forces ts.set_height(1.5) which deviates from the CSS "normal" (~1.0–1.2);
update the Branch handling LineHeight::Normal in the match (the block that calls
ts.set_height_override(true) and ts.set_height(...)) to use a CSS-consistent
default (e.g., 1.2 or derive from font metrics) instead of 1.5, and if 1.5 was
intentional keep the value but add a comment/docstring noting the deliberate
deviation; reference the match on font.line_height and the ts.set_height /
ts.set_height_override calls when making the change.
🪄 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: 2f0ea457-0ffa-4e20-a263-0c3f15be0ca8

📥 Commits

Reviewing files that changed from the base of the PR and between bbc301f and 0850570.

📒 Files selected for processing (3)
  • crates/grida-canvas/src/htmlcss/collect.rs
  • crates/grida-canvas/src/htmlcss/layout.rs
  • crates/grida-canvas/src/htmlcss/types.rs
✅ Files skipped from review due to trivial changes (1)
  • crates/grida-canvas/src/htmlcss/types.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/grida-canvas/src/htmlcss/collect.rs

Comment on lines +461 to +480
// Map CSS generic families to platform-concrete names
let families: Vec<String> = font
.families
.iter()
.map(|s| match s.as_str() {
"system-ui" | "-apple-system" | "BlinkMacSystemFont" => {
".AppleSystemUIFont".to_string()
}
"sans-serif" => "Helvetica".to_string(),
"serif" => "Times".to_string(),
"monospace" => "Menlo".to_string(),
other => other.to_string(),
})
.collect();
let family_refs: Vec<&str> = families.iter().map(|s| s.as_str()).collect();
if family_refs.is_empty() {
ts.set_font_families(&["Helvetica", "Arial"]);
} else {
ts.set_font_families(&family_refs);
}
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if Skia's FontCollection handles generic family names directly
rg -n "FontCollection|set_font_families|font.*families" crates/grida-canvas/src/ --type=rust -B2 -A2 | head -40

Repository: gridaco/grida

Length of output: 3412


🏁 Script executed:

# Search for how set_font_families is used throughout the codebase
rg -n "set_font_families" crates/ --type=rust -B5 -A5

Repository: gridaco/grida

Length of output: 34063


🏁 Script executed:

# Look for any platform-specific code around font handling
rg -n "cfg.*target|target_os|windows|macos|linux" crates/grida-canvas/src/htmlcss/ --type=rust -B2 -A2

Repository: gridaco/grida

Length of output: 39


🏁 Script executed:

# Check if there's any documentation or examples of Skia's FontCollection behavior
rg -n "FontCollection|skia_safe::textlayout" crates/ --type=rust -B2 -A2 | head -60

Repository: gridaco/grida

Length of output: 5317


🏁 Script executed:

# Get more context around the problematic code in layout.rs
sed -n '450,490p' crates/grida-canvas/src/htmlcss/layout.rs

Repository: gridaco/grida

Length of output: 1299


🏁 Script executed:

# Check what platform fonts are actually available in Skia's defaults
rg -n "Helvetica|Arial|Menlo|Consolas|DejaVu" crates/grida-canvas/ --type=rust | head -20

Repository: gridaco/grida

Length of output: 2521


Font family fallbacks need cross-platform alternatives.

The generic font family mappings (lines 466-471) currently use platform-specific fonts that don't exist on non-Apple platforms:

  • .AppleSystemUIFont is macOS-only
  • Helvetica and Times are unreliable on Windows/Linux
  • Menlo is macOS-only

Provide multiple platform-specific alternatives in priority order, as shown in markdown.rs (line 132) and text_edit/skia_layout.rs (line 232):

Corrected cross-platform fallback pattern
 let families: Vec<String> = font
     .families
     .iter()
     .map(|s| match s.as_str() {
-        "system-ui" | "-apple-system" | "BlinkMacSystemFont" => {
-            ".AppleSystemUIFont".to_string()
-        }
-        "sans-serif" => "Helvetica".to_string(),
-        "serif" => "Times".to_string(),
-        "monospace" => "Menlo".to_string(),
+        "system-ui" | "-apple-system" | "BlinkMacSystemFont" => "system-ui".to_string(),
+        "sans-serif" => "sans-serif".to_string(),
+        "serif" => "serif".to_string(),
+        "monospace" => "monospace".to_string(),
         other => other.to_string(),
     })
     .collect();

Then set fallback families in set_font_families() with platform alternatives:

 let family_refs: Vec<&str> = families.iter().map(|s| s.as_str()).collect();
 if family_refs.is_empty() {
-    ts.set_font_families(&["Helvetica", "Arial"]);
+    ts.set_font_families(&["Helvetica", "Arial", "Segoe UI", "sans-serif"]);
 } else {
-    ts.set_font_families(&family_refs);
+    // For monospace, add Windows/Linux alternatives
+    let mut all_families = family_refs.clone();
+    if family_refs.contains(&"monospace") {
+        all_families = vec!["Menlo", "Courier New", "Consolas", "monospace"];
+    }
+    ts.set_font_families(&all_families);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/src/htmlcss/layout.rs` around lines 461 - 480, The
current mapping of CSS generic families in the closure that builds `families`
(used to create `family_refs` and passed to `ts.set_font_families`) uses single
platform-specific names; replace each mapping with a prioritized, cross-platform
list of fallback family names (e.g., for system-ui use Apple, Windows and
generic fallbacks; for sans-serif/serif/monospace include common
Windows/Linux/Mac fonts and generic names) so that `families` contains multiple
alternatives per input family, then pass the flattened ordered list to
`ts.set_font_families(&family_refs)` (mirror the fallback pattern used in
`markdown.rs` and `text_edit/skia_layout.rs` when constructing the arrays before
calling `set_font_families`).

Stylo uses a process-global DOM slot that is not thread-safe. When
tests run in parallel (CI), concurrent access to bootstrap_dom()
causes "Why are we here?" panics in Stylo's traversal.rs.

Add a static Mutex guard to all htmlcss tests that use Stylo to
serialize their execution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel vercel Bot temporarily deployed to Preview – blog April 4, 2026 16:14 Inactive
@vercel vercel Bot temporarily deployed to Preview – backgrounds April 4, 2026 16:14 Inactive
@vercel vercel Bot temporarily deployed to Preview – grida April 4, 2026 16:14 Inactive
@vercel vercel Bot temporarily deployed to Preview – viewer April 4, 2026 16:14 Inactive
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: 2

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

70-73: This mutex is local to this file, not crate-shared.

If the goal is to serialize Stylo across htmlcss and html tests, Line 73 does not achieve that: a private htmlcss::tests::TEST_LOCK is not importable by sibling test modules. Move the lock into a shared #[cfg(test)] pub(crate) helper/module if other Stylo-using tests need the same guard.

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

In `@crates/grida-canvas/src/htmlcss/mod.rs` around lines 70 - 73, The TEST_LOCK
Mutex defined in this file is private to htmlcss and won't serialize tests
across sibling modules; create a shared test-only guard by moving the mutex into
a common #[cfg(test)] pub(crate) helper (e.g. a tests::sync or test_utils
module) and expose it as pub(crate) static TEST_LOCK: Mutex<()> so both the
htmlcss tests and the html module's tests can import and lock the same Mutex
before running Stylo-dependent code; update references in htmlcss tests (and
html tests) to use the new pub(crate) TEST_LOCK.
🤖 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-canvas/src/htmlcss/mod.rs`:
- Around line 21-24: The public height parameter on render is currently unused;
either make height drive the viewport or remove it. Fix by plumbing a single
viewport-height decision through the rendering pipeline: accept the height in
render(html, width, height), pass that height into the layout stage (so
layout_root uses it rather than computing from width only), ensure the paint
stage receives the same layout_root.height, and make the empty-document path and
measure_content_height() return consistent bounds when height is specified so
fixed-height embeds get a real fixed viewport. Update any helper functions that
compute or read layout_root.height (e.g., layout, paint, measure_content_height)
to accept or consult the passed-in height so all entrypoints agree.
- Line 27: The module must enforce serialization when calling Stylo: wrap all
calls to collect::collect_styled_tree() inside an internal static mutex so
callers don't need to manage TEST_LOCK. Add a private static (e.g., lazy_static
or once_cell) Mutex/MutexGuard in this module and acquire the lock at the start
of render() and measure_content_height() before calling
collect::collect_styled_tree(), then release after the call; keep the lock scope
minimal and mirror the synchronization pattern used by tests but internal to
this module so production callers (painter.rs, grida-dev) are safe.

---

Nitpick comments:
In `@crates/grida-canvas/src/htmlcss/mod.rs`:
- Around line 70-73: The TEST_LOCK Mutex defined in this file is private to
htmlcss and won't serialize tests across sibling modules; create a shared
test-only guard by moving the mutex into a common #[cfg(test)] pub(crate) helper
(e.g. a tests::sync or test_utils module) and expose it as pub(crate) static
TEST_LOCK: Mutex<()> so both the htmlcss tests and the html module's tests can
import and lock the same Mutex before running Stylo-dependent code; update
references in htmlcss tests (and html tests) to use the new pub(crate)
TEST_LOCK.
🪄 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: 7ceb8bd5-2b4a-4dbc-9668-b5d0dda467cc

📥 Commits

Reviewing files that changed from the base of the PR and between 0850570 and ef8e03d.

📒 Files selected for processing (1)
  • crates/grida-canvas/src/htmlcss/mod.rs

Comment on lines +21 to +24
pub fn render(
html: &str,
width: f32,
_height: f32,
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

The public height parameter is currently a no-op.

Line 24 is ignored end-to-end: layout runs from width alone, painting uses layout_root.height, and the empty-document path records a 1.0-high picture while measure_content_height() returns 0.0. Fixed-height embeds therefore never get a fixed-height viewport, and the two entrypoints do not even agree on empty-content bounds.

Either plumb a single viewport-height decision through layout/paint, or remove height until that behavior exists.

Also applies to: 29-45, 52-61

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

In `@crates/grida-canvas/src/htmlcss/mod.rs` around lines 21 - 24, The public
height parameter on render is currently unused; either make height drive the
viewport or remove it. Fix by plumbing a single viewport-height decision through
the rendering pipeline: accept the height in render(html, width, height), pass
that height into the layout stage (so layout_root uses it rather than computing
from width only), ensure the paint stage receives the same layout_root.height,
and make the empty-document path and measure_content_height() return consistent
bounds when height is specified so fixed-height embeds get a real fixed
viewport. Update any helper functions that compute or read layout_root.height
(e.g., layout, paint, measure_content_height) to accept or consult the passed-in
height so all entrypoints agree.

_height: f32,
fonts: &FontRepository,
) -> Result<skia_safe::Picture, String> {
let root = collect::collect_styled_tree(html)?;
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find crates/grida-canvas -name "mod.rs" | grep htmlcss

Repository: gridaco/grida

Length of output: 95


🏁 Script executed:

cat -n crates/grida-canvas/src/htmlcss/mod.rs | head -70

Repository: gridaco/grida

Length of output: 2557


🏁 Script executed:

cat -n crates/grida-canvas/src/htmlcss/mod.rs | sed -n '1,100p'

Repository: gridaco/grida

Length of output: 3781


🏁 Script executed:

rg "collect_styled_tree" --type rs -B 3 -A 3 | head -50

Repository: gridaco/grida

Length of output: 83


🏁 Script executed:

rg "render\(|measure_content_height\(" --type rs -B 2 -A 2 | grep -A 5 -B 5 "htmlcss"

Repository: gridaco/grida

Length of output: 83


🏁 Script executed:

cat -n crates/grida-canvas/src/htmlcss/collect.rs | head -40

Repository: gridaco/grida

Length of output: 1916


🏁 Script executed:

rg "htmlcss::render|htmlcss::measure_content_height" | head -30

Repository: gridaco/grida

Length of output: 524


🏁 Script executed:

rg "use.*htmlcss" -A 5 -B 2 | head -60

Repository: gridaco/grida

Length of output: 748


🏁 Script executed:

find crates/grida-canvas/src -name "*.rs" -exec grep -l "render\|measure_content_height" {} \;

Repository: gridaco/grida

Length of output: 3457


🏁 Script executed:

cat -n crates/grida-canvas/src/painter/painter.rs | grep -A 10 -B 10 "htmlcss::render"

Repository: gridaco/grida

Length of output: 1471


🏁 Script executed:

cat -n crates/grida-canvas/src/painter/layer.rs | grep -A 10 -B 10 "htmlcss"

Repository: gridaco/grida

Length of output: 889


🏁 Script executed:

cat -n crates/grida-dev/src/main.rs | grep -A 10 -B 10 "measure_content_height"

Repository: gridaco/grida

Length of output: 924


🏁 Script executed:

cat -n crates/grida-canvas/src/painter/painter.rs | sed -n '2150,2180p'

Repository: gridaco/grida

Length of output: 2022


🏁 Script executed:

rg "STYLO_LOCK|Mutex.*htmlcss" -B 3 -A 3

Repository: gridaco/grida

Length of output: 39


🏁 Script executed:

cat -n crates/grida-canvas/src/runtime/frame_loop.rs | head -50

Repository: gridaco/grida

Length of output: 2500


🏁 Script executed:

rg "thread_state::initialize" -B 3 -A 3

Repository: gridaco/grida

Length of output: 2854


🏁 Script executed:

cat -n crates/grida-canvas/src/htmlcss/collect.rs | sed -n '25,40p'

Repository: gridaco/grida

Length of output: 855


🏁 Script executed:

rg "pub fn render|pub fn measure_content_height" -A 1 crates/grida-canvas/src/htmlcss/mod.rs

Repository: gridaco/grida

Length of output: 137


Enforce Stylo serialization at the API boundary, not the caller's responsibility.

The public functions render() (line 27) and measure_content_height() (line 57) call collect::collect_styled_tree(), which accesses Stylo's process-global DOM slot. The module documentation states "Calls must be serialized externally," but this contract is not enforced. Production calls in painter.rs and grida-dev invoke these functions without synchronization, risking race conditions or panics on concurrent access. Tests protect themselves with TEST_LOCK, but production code does not.

Move the serialization responsibility into the module by wrapping Stylo access with an internal static mutex, similar to the approach suggested in the diff.

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

In `@crates/grida-canvas/src/htmlcss/mod.rs` at line 27, The module must enforce
serialization when calling Stylo: wrap all calls to
collect::collect_styled_tree() inside an internal static mutex so callers don't
need to manage TEST_LOCK. Add a private static (e.g., lazy_static or once_cell)
Mutex/MutexGuard in this module and acquire the lock at the start of render()
and measure_content_height() before calling collect::collect_styled_tree(), then
release after the call; keep the lock scope minimal and mirror the
synchronization pattern used by tests but internal to this module so production
callers (painter.rs, grida-dev) are safe.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cg Core Graphics css

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant