Skip to content

perf(canvas): skip ViewportCull bitset build when all leaves visible#628

Merged
softmarshmallow merged 1 commit intocanaryfrom
feature/jovial-rosalind
Apr 5, 2026
Merged

perf(canvas): skip ViewportCull bitset build when all leaves visible#628
softmarshmallow merged 1 commit intocanaryfrom
feature/jovial-rosalind

Conversation

@softmarshmallow
Copy link
Copy Markdown
Member

@softmarshmallow softmarshmallow commented Apr 5, 2026

Summary

  • ViewportCull::from_plan allocated an O(N) VisibilitySet bitset every full-draw frame even when every leaf in the scene was visible (the common fit-zoom case on large scenes).
  • Make the bitset optional: None = all visible → is_leaf_visible short-circuits to true. Detect the case by comparing live region count + promoted count against the LayerList length.
  • Saves ~1.8–2.4% per full draw on large all-visible scenes (scales linearly with node count), no regression on culled scenes.

Measured impact (Criterion, CPU raster, bench_viewport_culling)

Scenario Baseline After Change
50k/all_visible 24.678 ms 24.077 ms -2.44% (p=0.00)
5k/all_visible 3.881 ms 3.818 ms -1.76% (p=0.00)
50k/zoomed_in_1pct 2.249 ms no change (p=0.23)
50k/empty_offscreen 2.207 ms no change (p=0.16)
5k/partial_25pct, corner_5pct, zoomed_in_1pct no change

Why this helps view-only camera performance

At fit-zoom on large scenes (135K-node fixtures, dashboards, pasteboards), every node is in the visible set. The previous code iterated all ~136K leaf ids, resized a Vec<u64>, and OR-ed in bits on every full-draw frame. The new path allocates nothing and the per-leaf check is a single match on Option. Culled frames pay one extra Option match branch per leaf, which the benchmarks show is below noise.

Test plan

  • cargo test -p cg — 673 unit + 33 doc tests pass
  • cargo check -p cg -p grida-canvas-wasm -p grida-dev — all three crates compile
  • cargo clippy -p cg --no-deps — no new warnings in changed file
  • cargo fmt --all
  • Criterion before/after on bench_viewport_culling (all_visible + culled scenarios)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added Markdown Embed node type supporting GitHub-flavored markdown with rich formatting (headings, lists, code blocks, tables).
    • Auto-height adjustment for markdown content based on width.
    • Drag-and-drop support for .md and .markdown files to create embedded nodes.
  • Chores

    • Replaced direct markdown rendering with HTML+CSS-based approach for improved formatting support and consistency.

ViewportCull::from_plan allocated and populated a VisibilitySet bitset
of every visible leaf id on every full-draw frame. At fit-zoom on large
scenes (135K-node fixture, dashboards, pasteboards) every leaf is
visible, so the bitset is O(N) work whose answer is always "true".

Change the cull to carry an Option<VisibilitySet>: None represents the
"all visible" case and is_leaf_visible short-circuits to true. Detect
the case by comparing live region count + promoted count against the
LayerList length (regions and promoted are disjoint — see the
region/promoted partitioning in draw_layers_with_scene_cache_skip).

Criterion (CPU raster, bench_viewport_culling):
  50k/all_visible   24.678ms -> 24.077ms  -2.44% (p=0.00)
  5k/all_visible     3.881ms ->  3.818ms  -1.76% (p=0.00)
  partial/corner/zoomed_in/empty: no regression (p > 0.16)

The fast path only fires when every leaf is visible; culled frames pay
one extra Option match branch per leaf which the benchmarks show is
below noise. Savings scale linearly with node count, targeting the
view-only fit-zoom path on large scenes.

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

vercel Bot commented Apr 5, 2026

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

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Apr 5, 2026 7:26am
6 Skipped Deployments
Project Deployment Actions Updated (UTC)
code Ignored Ignored Apr 5, 2026 7:26am
legacy Ignored Ignored Apr 5, 2026 7:26am
backgrounds Skipped Skipped Apr 5, 2026 7:26am
blog Skipped Skipped Apr 5, 2026 7:26am
grida Skipped Skipped Apr 5, 2026 7:26am
viewer Skipped Skipped Apr 5, 2026 7:26am

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 5, 2026

Walkthrough

This PR replaces the Node::Markdown variant with Node::MarkdownEmbed, shifting markdown rendering from an embedded Skia-based approach to an HTML+CSS pipeline. It removes the painter/markdown.rs module (876 lines), adds new htmlcss modules for markdown styling and faux-table layout, updates IO and schema layers, and introduces FrameRenderStrategy for per-frame rendering decisions across layout, painter, and runtime subsystems.

Changes

Cohort / File(s) Summary
Node Schema & Type Refactoring
crates/grida-canvas/src/node/schema.rs, crates/grida-canvas/src/node/factory.rs
Renamed NodeTypeTag::Markdown to MarkdownEmbed, replaced Node::Markdown(MarkdownNodeRec) with Node::MarkdownEmbed(MarkdownEmbedNodeRec), and updated the factory's public constructor from create_markdown_node() to create_markdown_embed_node().
JSON & FlatBuffers Serialization
crates/grida-canvas/src/io/io_grida.rs, crates/grida-canvas/src/io/io_grida_fbs.rs, crates/grida-canvas/src/io/id_converter.rs
Added JSONNode::MarkdownEmbed(JSONMarkdownEmbedNode) variant and corresponding FlatBuffers encode/decode pipelines. Extended IdConverter to handle the new JSON variant. 113 lines added for FBS decode/encode implementations with markdown string and fill paint handling.
Layout & Taffy Transformation
crates/grida-canvas/src/layout/engine.rs, crates/grida-canvas/src/layout/into_taffy.rs, crates/grida-canvas/src/layout/...*
Updated layout engine to match Node::MarkdownEmbed and replaced the From<&MarkdownNodeRec> impl with From<&MarkdownEmbedNodeRec> for Taffy style conversion. Geometry extraction and regression tests updated to use the embed variant.
Painter & Rendering Layer
crates/grida-canvas/src/painter/layer.rs, crates/grida-canvas/src/painter/geometry.rs, crates/grida-canvas/src/painter/painter_debug_node.rs, crates/grida-canvas/src/painter/mod.rs
Renamed PainterPictureLayer::Markdown to MarkdownEmbed with corresponding struct rename. Updated layer geometry construction and debug rendering. Removed public module export pub mod markdown;.
Markdown Rendering Pipeline
crates/grida-canvas/src/painter/markdown.rs (deleted), crates/grida-canvas/src/painter/painter.rs
Deleted 876-line Skia-based markdown renderer. Updated Painter to invoke htmlcss::markdown_to_styled_html() + htmlcss::render() instead, with gray rectangle fallback on render failure and clipping via picture cull rect.
HTML/CSS Markdown Support
crates/grida-canvas/src/htmlcss/github_markdown.rs, crates/grida-canvas/src/htmlcss/faux_table.rs, crates/grida-canvas/src/htmlcss/mod.rs, crates/grida-canvas/src/htmlcss/collect.rs
Added GITHUB_MARKDOWN_CSS static with embedded stylesheet via include_str!. Introduced apply_faux_table_style() and apply_faux_table_style_by_tag() for CSS table-to-flexbox translation. Added markdown_to_styled_html() function wrapping markdown in HTML document with GitHub-flavored CSS and four new rendering regression tests.
CSS Assets & Documentation
crates/grida-canvas/assets/css/grida-markdown.css, fixtures/css/grida-markdown.css, fixtures/css/README.md
Added 167-line light-themed markdown stylesheet scoped to .markdown-body with typography, headings, code blocks, tables, blockquotes, and image styling. Updated fixtures documentation with stylesheet description.
Frame Rendering Strategy
crates/grida-canvas/src/runtime/frame_strategy.rs, crates/grida-canvas/src/runtime/mod.rs, crates/grida-canvas/src/runtime/scene.rs
Introduced FrameRenderStrategy struct computing per-frame booleans (image-cache usage/capture, compositor usage, plan deferral) from zoom, backend type, stability, and config. Wired into render pipeline to gate cache fast paths, snapshot capture, and compositor updates.
Compositor & Cost Prediction
crates/grida-canvas/src/cache/compositor/promotion.rs, crates/grida-canvas/src/runtime/cost_prediction.rs
Updated effect detection and cost estimation to match PainterPictureLayer::MarkdownEmbed instead of Markdown.
Scene Graph & Resources
crates/grida-canvas/src/node/scene_graph.rs, crates/grida-canvas/src/resources/mod.rs
Updated geometry extraction and image URL collection to handle Node::MarkdownEmbed variant.
CLI Tools & Examples
crates/grida-canvas/examples/tool_io_grida.rs, crates/grida-canvas/examples/tool_io_svg.rs, crates/grida-dev/src/bench/load_bench.rs, crates/grida-dev/src/main.rs
Updated node-type classification and layout reporting to use MarkdownEmbed label. Replaced scene_from_markdown_path with scene_from_markdown_embed_path, measuring content height via htmlcss::render() instead of using hardcoded dimensions.
Editor Document & Mutation
crates/grida-dev/src/editor/document.rs, crates/grida-dev/src/editor/mutation.rs
Updated resize handling to detect Node::MarkdownEmbed and apply content-driven height measurement. Extended mutation helpers (node_supports_resize, node_transform_mut, node_size_mut) to match embed variant.
Editor API & Commands
editor/grida-canvas/editor.i.ts, editor/grida-canvas/editor.ts, editor/grida-canvas/reducers/tools/initial-node.ts
Added createMarkdownEmbedNode(markdown?: string) API method and implementation, plus initial-node builder support for "markdown_embed" type with default 400x300 sizing and template markdown content.
Editor UI & Widgets
editor/grida-canvas-react-renderer-dom/nodes/markdown-embed.tsx, editor/grida-canvas-react-renderer-dom/nodes/index.ts, editor/grida-canvas-react-renderer-dom/nodes/node.tsx
Added MarkdownEmbedWidget DOM fallback component with monospace font, pre-wrap whitespace, and padding. Registered widget in renderer index and updated node renderer dispatch logic to treat markdown_embed equivalently to richtext.
File I/O & Clipboard
packages/grida-canvas-io/index.ts, editor/grida-canvas-react/use-data-transfer.ts
Extended io.clipboard.filetype() to recognize .md/.markdown/.mdown/.mkd and text/markdown MIME type. Added insertMarkdown() capability to useInsertFile() for drop/paste handling with position-based node creation and extension stripping.
FlatBuffers Schema & TS Codegen
format/grida.fbs, packages/grida-canvas-io/format.ts
Added MarkdownEmbed enum variant and MarkdownEmbedNode table (with MarkdownEmbedNodeProperties for fill paints, markdown string, corner radius). Implemented TS encode/decode functions with paint/radius trait handling.
Canvas Schema
packages/grida-canvas-schema/grida.ts
Added MarkdownEmbedNode and ComputedMarkdownEmbedNode interfaces with markdown: string field. Updated node unions and prototype helper types to include the new embed variant.
AI Tool Integration
editor/grida-canvas-hosted/ai/tools/canvas-use.ts
Updated make_from_markdown tool to call createMarkdownEmbedNode() instead of createTextNode(), creating styled GitHub-flavored markdown embed nodes.
Documentation
docs/wg/feat-2d/optimization.md
Added optimization entries 49–50 describing eager-render gate at zoom >= 0.5 and centralized FrameRenderStrategy computation for frame-pipeline decision gates.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Editor as Editor UI
    participant Renderer as Render Pipeline
    participant HTMLCSSModule as htmlcss Module
    participant SkiaPaint as Skia Picture
    
    rect rgba(0, 200, 100, 0.5)
    Note over User,SkiaPaint: New MarkdownEmbed Rendering Flow
    User->>Editor: Create MarkdownEmbedNode with markdown text
    Editor->>Renderer: render_frame_with_plan_state()
    Renderer->>Renderer: Compute FrameRenderStrategy
    Renderer->>HTMLCSSModule: markdown_to_styled_html(markdown)
    HTMLCSSModule->>HTMLCSSModule: Wrap in <html> with GitHub CSS
    HTMLCSSModule-->>Renderer: HTML string
    Renderer->>HTMLCSSModule: render(html_string, fonts)
    HTMLCSSModule-->>Renderer: SkiaRenderingContext (measured height)
    Renderer->>SkiaPaint: Draw rendered content via Picture
    SkiaPaint-->>Renderer: Picture with cull rect
    Renderer->>Editor: Display rendered markdown node
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

performance, canvas, cg

🚥 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 PR title clearly and concisely describes the main performance optimization: skipping ViewportCull bitset allocation when all leaves are visible, which is the primary change across 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/jovial-rosalind

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

ℹ️ 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 +2149 to +2151
0.0,
cull.width(),
cull.height(),
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 Clip markdown embed rendering to node bounds

The markdown embed paint path now clips to picture.cull_rect() instead of the node’s own size, so when markdown content is taller than the node frame (e.g. newly created/imported embeds with fixed 300px height), the text is drawn outside the node and can overlap neighboring layers. In PainterPictureLayer::MarkdownEmbed, this regresses previous behavior that clipped to md_layer.width/md_layer.height; the clip should be bounded by the node frame (or intersected with it).

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.

Actionable comments posted: 11

Caution

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

⚠️ Outside diff range comments (3)
packages/grida-canvas-schema/grida.ts (2)

2924-2952: ⚠️ Potential issue | 🟠 Major

Do not route markdown_embed through the generic fallback.

This branch constructs a node without the required markdown field, so createNodeDataFromPrototypeWithoutChildren({ type: "markdown_embed" }, id) returns an invalid object and pushes undefined content handling downstream.

🛠️ Suggested fix
+        case "markdown_embed": {
+          return {
+            name: "markdown_embed",
+            type: "markdown_embed",
+            active: true,
+            locked: false,
+            opacity: 1,
+            z_index: 0,
+            rotation: 0,
+            layout_target_width: 100,
+            layout_target_height: 100,
+            layout_positioning: "absolute",
+            markdown: "",
+            ...prototype,
+            id: id,
+          } as MarkdownEmbedNode;
+        }
         case "bitmap":
         case "ellipse":
         case "iframe":
         case "image":
         case "line":
-        case "markdown_embed":
         case "richtext":
         case "tspan":
         case "vector":
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/grida-canvas-schema/grida.ts` around lines 2924 - 2952, The branch
grouping includes "markdown_embed" but constructs a generic node missing the
required markdown field; update the switch in
createNodeDataFromPrototypeWithoutChildren so "markdown_embed" is removed from
the generic fallback and instead implement a dedicated case for "markdown_embed"
that returns a node with the same base properties but includes the required
markdown field (e.g., markdown: "" or prototype.markdown) and any other
markdown-specific properties from prototype; ensure you reference
prototype.type, id and return the result as the correct node type rather than
letting it fall through to UnknownNode.

1230-1252: ⚠️ Potential issue | 🟠 Major

Bump the document schema version with this new node kind.

Adding markdown_embed changes the serialized document surface, but grida.program.document.SCHEMA_VERSION is still 0.91.0-beta+20260311. Older readers will report these files as compatible and then fail on a node type they do not know how to decode.

Based on learnings: Bump CanvasDocument.schema_version when writer behavior changes meaningfully, especially for Breaking changes, and keep it in sync with TS grida.program.document.SCHEMA_VERSION.

Also applies to: 2377-2407

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

In `@packages/grida-canvas-schema/grida.ts` around lines 1230 - 1252, The document
schema must be bumped to reflect the new markdown_embed node kind: update
CanvasDocument.schema_version to a new higher version string (and any other
serialized schema constant) and ensure it matches the TypeScript constant
grida.program.document.SCHEMA_VERSION; specifically, after adding the new node
union entries (ComputedNode and the node kind code paths where
MarkdownEmbedNode/ComputedMarkdownEmbedNode were added) increment the
schema_version value and update the corresponding TS SCHEMA_VERSION so readers
will reject older formats that lack the new node kind.
crates/grida-canvas/src/node/factory.rs (1)

383-399: ⚠️ Potential issue | 🟠 Major

Add MarkdownEmbed round-trip test cases to both Rust and TypeScript suites.

The factory method creates MarkdownEmbedNodeRec, and the schema defines MarkdownEmbedNode in the union (grida.fbs line 1604). However, neither crates/grida-canvas/tests/fbs_roundtrip.rs (1993 lines) nor packages/grida-canvas-io/__tests__/format-roundtrip.test.ts (3348 lines) contain any MarkdownEmbed test case. Add appended union member test coverage to both suites to prevent format drift.

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

In `@crates/grida-canvas/src/node/factory.rs` around lines 383 - 399, The factory
creates MarkdownEmbedNodeRec via create_markdown_embed_node but there are no
round-trip tests covering the MarkdownEmbed union member, so add test cases to
both Rust and TypeScript round-trip suites: in the Rust tests file
(crates/grida-canvas/tests/fbs_roundtrip.rs) append a new test that constructs a
MarkdownEmbedNodeRec (use create_markdown_embed_node), serializes and
deserializes through the same fbs schema union path used by other node tests and
asserts equality; likewise in the TypeScript suite
(packages/grida-canvas-io/__tests__/format-roundtrip.test.ts) add a matching
test that builds the MarkdownEmbed node object, runs the existing format
round-trip helpers, and asserts the round-tripped payload matches the original,
ensuring the MarkdownEmbed variant is covered in the union JSON/Flatbuffers
round-trip matrix.
🧹 Nitpick comments (3)
docs/wg/feat-2d/optimization.md (1)

879-884: Add blank line before the table (markdownlint MD058).

Static analysis flagged a missing blank line before the table at line 880. This can cause rendering issues in some Markdown parsers.

📝 Proposed fix
     Measured on 01-135k.perf.grida (135K nodes):
+
     | Scenario | Before p95 | After p95 | Before MAX | After MAX |
     |---|---|---|---|---|
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/wg/feat-2d/optimization.md` around lines 879 - 884, Add a blank line
immediately above the Markdown table that begins with "| Scenario | Before p95 |
After p95 | Before MAX | After MAX |" so the table is separated from the
preceding paragraph (fixes markdownlint MD058); simply insert one empty line
before that table block in optimization.md.
crates/grida-canvas/src/node/schema.rs (1)

1256-1277: Keep type_label() human-readable.

type_label() is documented as a user-facing label, but MarkdownEmbed leaks the internal enum rename. If this string is surfaced anywhere, Markdown reads better.

✏️ Minimal tweak
-            Node::MarkdownEmbed(_) => "MarkdownEmbed",
+            Node::MarkdownEmbed(_) => "Markdown",
🤖 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` around lines 1256 - 1277, The
type_label() match arm for the Node::MarkdownEmbed variant returns the internal
enum name "MarkdownEmbed" which is not user-friendly; update the match arm in
the type_label() method so Node::MarkdownEmbed(_) => "Markdown" (keep the return
type &'static str), leaving other arms unchanged so the user-facing label reads
"Markdown" instead of the internal variant name.
editor/grida-canvas/editor.ts (1)

847-875: Centralize the markdown_embed defaults.

EditorDocumentStore.createMarkdownEmbedNode() now hard-codes a second default bundle for this node type, and it already drifted from editor/grida-canvas/reducers/tools/initial-node.ts (markdown starts empty here but the tool-path default is prefilled). Different insertion paths will create visibly different nodes unless these defaults live in one shared helper/constants module.

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

In `@editor/grida-canvas/editor.ts` around lines 847 - 875, The
createMarkdownEmbedNode method hard-codes defaults for the "markdown_embed" node
that conflict with the defaults in
editor/grida-canvas/reducers/tools/initial-node.ts; extract the canonical
default object for markdown_embed into a shared constant (e.g.,
MARKDOWN_EMBED_DEFAULTS) in a common helper/constants module, update
EditorDocumentStore.createMarkdownEmbedNode to import and use that constant
(instead of inlining the bundle), and update the tools/initial-node.ts to import
the same constant so all insertion paths share identical defaults (ensure the
id-specific fields like _$id and idgen-generated id are still set at creation
time after cloning the shared defaults).
🤖 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/examples/tool_io_grida.rs`:
- Line 469: The inspector uses inconsistent labels: update the
Node::MarkdownEmbed match arm (currently returning "markdown") to return the new
type label "markdown_embed" so FBS/ZIP path matches the JSON path; locate the
string returned for Node::MarkdownEmbed in the match that maps Node variants to
type labels and change it to "markdown_embed".

In `@crates/grida-canvas/src/htmlcss/layout.rs`:
- Around line 329-333: The tag-based fallback apply_faux_table_style_by_tag
currently runs for any non-table computed display and can override an explicit
author display (e.g., display:flex) on table wrappers; change the logic so
apply_faux_table_style_by_tag(&el.tag, &mut style) is only called when the
element's computed display is the default table-wrapper value (i.e., only when
el.display equals the engine's default row-group/table-wrapper display),
otherwise skip the tag fallback so explicit el.display values are respected;
update the conditional around super::faux_table::apply_faux_table_style and the
check that gates calling apply_faux_table_style_by_tag to test el.display
against the default table-wrapper display.

In `@crates/grida-canvas/src/io/io_grida_fbs.rs`:
- Around line 4505-4506: decode_markdown_embed_node is extracting position with
r.transform.x()/y() but decode_shape_layout builds the transform using
from_box_center_raw, so those values are shifted by the rotation center; fix by
applying the inverse transform used in encode_basic_shape_node (call
reverse_from_box_center on the transform returned by decode_shape_layout or
otherwise reverse the from_box_center_raw offset) before reading x/y so you
retrieve the original layout position (mirror what encode_basic_shape_node does
using reverse_from_box_center).

In `@crates/grida-dev/src/editor/document.rs`:
- Around line 194-220: The measured height from measure_content_height in the
affects_w branch can be 0.0 and bypasses the 1px minimum used by the main resize
path; clamp the measured value before applying the resize by enforcing a minimum
(e.g., 1.0) and use that clamped value when constructing the
MutationCommand::Resize for id, so in the block handling Node::MarkdownEmbed /
Node::HTMLEmbed (where measured is set) replace the raw measured h with a
clamped_h = measured.max(1.0) (or equivalent) before calling
self.apply(&MutationCommand::Resize { id: *id, width: None, height:
Some(clamped_h) }).

In `@editor/grida-canvas-hosted/ai/tools/canvas-use.ts`:
- Around line 250-253: The description string in the make_from_markdown tool
currently promises “GitHub markdown styling”; update the description in the
export const make_from_markdown = tool({ ... }) block to avoid overstating
fidelity—change wording to something like “Grida markdown styling” or
“GitHub-like markdown styling” (or similar) so it references the actual
grida-markdown.css renderer instead of claiming exact GitHub styling; keep the
rest of the title/description fields and behavior unchanged.

In `@fixtures/css/grida-markdown.css`:
- Line 71: The font-family declarations using SF Mono need the family name
quoted; locate the declarations like `font-family: SF Mono, Menlo, Consolas,
monospace;` (and the second occurrence) and update both font stacks to quote the
family name as "SF Mono" so Stylelint no longer flags the rule.

In `@format/grida.fbs`:
- Around line 1490-1498: The FlatBuffers table MarkdownEmbedNodeProperties lacks
explicit field IDs; update the table declaration so each field (fill_paints,
markdown, corner_radius) has an explicit (id: N) annotation (e.g., assign
consecutive unique ids) to ensure schema evolvability and stable binary
compatibility; modify the table MarkdownEmbedNodeProperties to include these
(id: N) annotations for fill_paints, markdown, and corner_radius accordingly.
- Around line 280-283: Add round-trip tests that include the new union member
MarkdownEmbed: in the Rust test file fbs_roundtrip.rs add a test that constructs
a node/document containing the MarkdownEmbed variant (matching the struct in
crates/grida-canvas/src/node/schema.rs), serializes it with the existing FBS
encode helper, deserializes it back and asserts equality with the original; in
the TypeScript test file format-roundtrip.test.ts add a corresponding test that
builds the TS document model with MarkdownEmbed (from
packages/grida-canvas-schema/grida.ts), uses the same encode/decode helpers
already used by other cases, and asserts deep equality after round-trip. Use the
existing tests in those files as templates so you reuse the same helper
functions and assertion style.

In `@packages/grida-canvas-io/format.ts`:
- Around line 1918-1971: Add round-trip tests for MarkdownEmbedNode in the
TypeScript and Rust test suites: construct a MarkdownEmbedNode with non-default
fields (markdown string, corner_radius and per-corner
rectangular_corner_radius_*, corner_smoothing, and some fill paints), encode it
using the existing codec path that touches format.paint.encode.fillPaints and
format.shape.encode.rectangularCornerRadiusTrait and the fbs.MarkdownEmbedNode/
fbs.MarkdownEmbedNodeProperties flow, then decode and assert the decoded node
equals the original (deep equality) to validate full encode→decode fidelity;
mirror the same case in the Rust fbs roundtrip test using the crate's existing
roundtrip helper so the test covers Node::MarkdownEmbedNode variant and its
Properties.

In `@packages/grida-canvas-io/index.ts`:
- Around line 295-310: The filetype() helper recognizes markdown but
decodeFromClipboardItems() never preserves or checks for "text/markdown", so
clipboard.read() paths downgrade or lose markdown; update
decodeFromClipboardItems() to detect clipboard items with type "text/markdown"
(and common markdown extensions when converting File items) and return/preserve
the ValidFileType "text/markdown" instead of treating it as plain text or
dropping it; specifically, in decodeFromClipboardItems() add checks for
item.type === "text/markdown" (and the same ext-based logic used in filetype())
and map those branches to return the same result shape and MIME label that
filetype() produces so markdown is passed through end-to-end.

In `@packages/grida-canvas-schema/grida.ts`:
- Around line 2387-2399: The MarkdownEmbedNode interface currently extends
IRectangularShapeTrait which exposes corner_smoothing and
rectangular_stroke_width_* that the runtime ignores; to fix this, stop
over-promising by replacing the IRectangularShapeTrait extension on
MarkdownEmbedNode with a narrow trait that only includes the preserved
rounded-corner fill box properties (create or use an IRectangularFillTrait or
explicitly list allowed shape properties on MarkdownEmbedNode), or alternatively
add and wire the missing rectangular_stroke_width_* and corner_smoothing fields
through the Rust/render pipeline end-to-end; update the declaration of
MarkdownEmbedNode (and any related type aliases) to reference the new/narrowed
trait or explicit properties and remove the undesired fields so the public
schema matches runtime behavior.

---

Outside diff comments:
In `@crates/grida-canvas/src/node/factory.rs`:
- Around line 383-399: The factory creates MarkdownEmbedNodeRec via
create_markdown_embed_node but there are no round-trip tests covering the
MarkdownEmbed union member, so add test cases to both Rust and TypeScript
round-trip suites: in the Rust tests file
(crates/grida-canvas/tests/fbs_roundtrip.rs) append a new test that constructs a
MarkdownEmbedNodeRec (use create_markdown_embed_node), serializes and
deserializes through the same fbs schema union path used by other node tests and
asserts equality; likewise in the TypeScript suite
(packages/grida-canvas-io/__tests__/format-roundtrip.test.ts) add a matching
test that builds the MarkdownEmbed node object, runs the existing format
round-trip helpers, and asserts the round-tripped payload matches the original,
ensuring the MarkdownEmbed variant is covered in the union JSON/Flatbuffers
round-trip matrix.

In `@packages/grida-canvas-schema/grida.ts`:
- Around line 2924-2952: The branch grouping includes "markdown_embed" but
constructs a generic node missing the required markdown field; update the switch
in createNodeDataFromPrototypeWithoutChildren so "markdown_embed" is removed
from the generic fallback and instead implement a dedicated case for
"markdown_embed" that returns a node with the same base properties but includes
the required markdown field (e.g., markdown: "" or prototype.markdown) and any
other markdown-specific properties from prototype; ensure you reference
prototype.type, id and return the result as the correct node type rather than
letting it fall through to UnknownNode.
- Around line 1230-1252: The document schema must be bumped to reflect the new
markdown_embed node kind: update CanvasDocument.schema_version to a new higher
version string (and any other serialized schema constant) and ensure it matches
the TypeScript constant grida.program.document.SCHEMA_VERSION; specifically,
after adding the new node union entries (ComputedNode and the node kind code
paths where MarkdownEmbedNode/ComputedMarkdownEmbedNode were added) increment
the schema_version value and update the corresponding TS SCHEMA_VERSION so
readers will reject older formats that lack the new node kind.

---

Nitpick comments:
In `@crates/grida-canvas/src/node/schema.rs`:
- Around line 1256-1277: The type_label() match arm for the Node::MarkdownEmbed
variant returns the internal enum name "MarkdownEmbed" which is not
user-friendly; update the match arm in the type_label() method so
Node::MarkdownEmbed(_) => "Markdown" (keep the return type &'static str),
leaving other arms unchanged so the user-facing label reads "Markdown" instead
of the internal variant name.

In `@docs/wg/feat-2d/optimization.md`:
- Around line 879-884: Add a blank line immediately above the Markdown table
that begins with "| Scenario | Before p95 | After p95 | Before MAX | After MAX
|" so the table is separated from the preceding paragraph (fixes markdownlint
MD058); simply insert one empty line before that table block in optimization.md.

In `@editor/grida-canvas/editor.ts`:
- Around line 847-875: The createMarkdownEmbedNode method hard-codes defaults
for the "markdown_embed" node that conflict with the defaults in
editor/grida-canvas/reducers/tools/initial-node.ts; extract the canonical
default object for markdown_embed into a shared constant (e.g.,
MARKDOWN_EMBED_DEFAULTS) in a common helper/constants module, update
EditorDocumentStore.createMarkdownEmbedNode to import and use that constant
(instead of inlining the bundle), and update the tools/initial-node.ts to import
the same constant so all insertion paths share identical defaults (ensure the
id-specific fields like _$id and idgen-generated id are still set at creation
time after cloning the shared defaults).
🪄 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: fc9740be-5208-4635-a563-ffb73b0d7914

📥 Commits

Reviewing files that changed from the base of the PR and between 319c175 and a9b7fee.

⛔ Files ignored due to path filters (1)
  • crates/grida-canvas/src/io/generated/grida.rs is excluded by !**/generated/**
📒 Files selected for processing (47)
  • crates/grida-canvas/assets/css/grida-markdown.css
  • 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/faux_table.rs
  • crates/grida-canvas/src/htmlcss/github_markdown.rs
  • crates/grida-canvas/src/htmlcss/layout.rs
  • crates/grida-canvas/src/htmlcss/mod.rs
  • crates/grida-canvas/src/io/id_converter.rs
  • crates/grida-canvas/src/io/io_grida.rs
  • crates/grida-canvas/src/io/io_grida_fbs.rs
  • crates/grida-canvas/src/layout/engine.rs
  • crates/grida-canvas/src/layout/into_taffy.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/markdown.rs
  • crates/grida-canvas/src/painter/mod.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/frame_strategy.rs
  • crates/grida-canvas/src/runtime/mod.rs
  • crates/grida-canvas/src/runtime/scene.rs
  • crates/grida-dev/src/bench/load_bench.rs
  • crates/grida-dev/src/editor/document.rs
  • crates/grida-dev/src/editor/mutation.rs
  • crates/grida-dev/src/main.rs
  • docs/wg/feat-2d/optimization.md
  • editor/grida-canvas-hosted/ai/tools/canvas-use.ts
  • editor/grida-canvas-react-renderer-dom/nodes/index.ts
  • editor/grida-canvas-react-renderer-dom/nodes/markdown-embed.tsx
  • editor/grida-canvas-react-renderer-dom/nodes/node.tsx
  • editor/grida-canvas-react/use-data-transfer.ts
  • editor/grida-canvas/editor.i.ts
  • editor/grida-canvas/editor.ts
  • editor/grida-canvas/reducers/tools/initial-node.ts
  • fixtures/css/README.md
  • fixtures/css/grida-markdown.css
  • format/grida.fbs
  • packages/grida-canvas-io/format.ts
  • packages/grida-canvas-io/index.ts
  • packages/grida-canvas-schema/grida.ts
💤 Files with no reviewable changes (2)
  • crates/grida-canvas/src/painter/mod.rs
  • crates/grida-canvas/src/painter/markdown.rs

Node::AttributedText(_) => "attributed_text",
Node::Error(_) => "error",
Node::Markdown(_) => "markdown",
Node::MarkdownEmbed(_) => "markdown",
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

Use the new type label consistently in the inspector.

The JSON path reports markdown_embed, but the FBS/ZIP path still reports markdown, so the same node type shows up under different names depending on the source format.

Suggested fix
-        Node::MarkdownEmbed(_) => "markdown",
+        Node::MarkdownEmbed(_) => "markdown_embed",
📝 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
Node::MarkdownEmbed(_) => "markdown",
Node::MarkdownEmbed(_) => "markdown_embed",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/examples/tool_io_grida.rs` at line 469, The inspector
uses inconsistent labels: update the Node::MarkdownEmbed match arm (currently
returning "markdown") to return the new type label "markdown_embed" so FBS/ZIP
path matches the JSON path; locate the string returned for Node::MarkdownEmbed
in the match that maps Node variants to type labels and change it to
"markdown_embed".

Comment on lines +329 to +333
// Faux-table: override display/flex for CSS table elements.
// Try display-based override first, then tag-based fallback for
// row-group wrappers (<thead>, <tbody>, <tfoot>).
if !super::faux_table::apply_faux_table_style(el.display, &mut style) {
super::faux_table::apply_faux_table_style_by_tag(&el.tag, &mut style);
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

Don't let the tag fallback override explicit display on table wrappers.

This branch runs for any non-table computed display, so <thead>/<tbody>/<tfoot> with author CSS like display:flex or display:block will still be forced back into faux-table mode here. Gate the tag fallback to the default table-wrapper case only; otherwise explicit display overrides can't opt out of the emulation.

🤖 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 329 - 333, The
tag-based fallback apply_faux_table_style_by_tag currently runs for any
non-table computed display and can override an explicit author display (e.g.,
display:flex) on table wrappers; change the logic so
apply_faux_table_style_by_tag(&el.tag, &mut style) is only called when the
element's computed display is the default table-wrapper value (i.e., only when
el.display equals the engine's default row-group/table-wrapper display),
otherwise skip the tag fallback so explicit el.display values are respected;
update the conditional around super::faux_table::apply_faux_table_style and the
check that gates calling apply_faux_table_style_by_tag to test el.display
against the default table-wrapper display.

Comment on lines +4505 to +4506
let x = r.transform.x();
let y = r.transform.y();
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

Round-trip data corruption: position extraction doesn't reverse from_box_center transform.

decode_markdown_embed_node uses decode_shape_layout, which creates the transform via from_box_center_raw. This shifts the translation component by the rotation center offset. Using r.transform.x() and r.transform.y() directly returns the shifted values, not the original layout position.

Compare with encode_basic_shape_node at line 3896-3897 which correctly uses reverse_from_box_center.

🐛 Proposed fix
 fn encode_markdown_embed_node<'a, A: flatbuffers::Allocator + 'a>(
     fbb: &mut flatbuffers::FlatBufferBuilder<'a, A>,
     r: &MarkdownEmbedNodeRec,
     node_id: &str,
     parent_id: &str,
     position: &str,
 ) -> flatbuffers::WIPOffset<fbs::NodeSlot<'a>> {
-    let x = r.transform.x();
-    let y = r.transform.y();
+    let (x, y) = reverse_from_box_center(&r.transform, r.size.width, r.size.height);
     let plt = affine_to_rotation_transform(&r.transform);
📝 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
let x = r.transform.x();
let y = r.transform.y();
fn encode_markdown_embed_node<'a, A: flatbuffers::Allocator + 'a>(
fbb: &mut flatbuffers::FlatBufferBuilder<'a, A>,
r: &MarkdownEmbedNodeRec,
node_id: &str,
parent_id: &str,
position: &str,
) -> flatbuffers::WIPOffset<fbs::NodeSlot<'a>> {
let (x, y) = reverse_from_box_center(&r.transform, r.size.width, r.size.height);
let plt = affine_to_rotation_transform(&r.transform);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/src/io/io_grida_fbs.rs` around lines 4505 - 4506,
decode_markdown_embed_node is extracting position with r.transform.x()/y() but
decode_shape_layout builds the transform using from_box_center_raw, so those
values are shifted by the rotation center; fix by applying the inverse transform
used in encode_basic_shape_node (call reverse_from_box_center on the transform
returned by decode_shape_layout or otherwise reverse the from_box_center_raw
offset) before reading x/y so you retrieve the original layout position (mirror
what encode_basic_shape_node does using reverse_from_box_center).

Comment on lines +194 to +220
if affects_w {
if let Ok(node) = self.scene.graph.get_node(id) {
let actual_w = new_width.unwrap_or(*w);
let measured = match node {
Node::MarkdownEmbed(n) => {
let html = cg::htmlcss::markdown_to_styled_html(&n.markdown);
cg::htmlcss::measure_content_height(
&html,
actual_w,
&app.renderer().fonts,
)
.ok()
}
Node::HTMLEmbed(n) => cg::htmlcss::measure_content_height(
&n.html,
actual_w,
&app.renderer().fonts,
)
.ok(),
_ => None,
};
if let Some(h) = measured {
mutated |= self.apply(&MutationCommand::Resize {
id: *id,
width: None,
height: Some(h),
});
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

Clamp the auto-measured height before applying it.

The primary resize path enforces a 1px minimum, but this follow-up bypasses that. Fresh MarkdownEmbed/HTMLEmbed nodes are initialized with empty content, so measure_content_height() can legitimately return 0.0 and collapse the node on the first width resize.

Suggested fix
-                    if let Some(h) = measured {
+                    if let Some(h) = measured {
                         mutated |= self.apply(&MutationCommand::Resize {
                             id: *id,
                             width: None,
-                            height: Some(h),
+                            height: Some(h.max(1.0)),
                         });
                     }
📝 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 affects_w {
if let Ok(node) = self.scene.graph.get_node(id) {
let actual_w = new_width.unwrap_or(*w);
let measured = match node {
Node::MarkdownEmbed(n) => {
let html = cg::htmlcss::markdown_to_styled_html(&n.markdown);
cg::htmlcss::measure_content_height(
&html,
actual_w,
&app.renderer().fonts,
)
.ok()
}
Node::HTMLEmbed(n) => cg::htmlcss::measure_content_height(
&n.html,
actual_w,
&app.renderer().fonts,
)
.ok(),
_ => None,
};
if let Some(h) = measured {
mutated |= self.apply(&MutationCommand::Resize {
id: *id,
width: None,
height: Some(h),
});
if affects_w {
if let Ok(node) = self.scene.graph.get_node(id) {
let actual_w = new_width.unwrap_or(*w);
let measured = match node {
Node::MarkdownEmbed(n) => {
let html = cg::htmlcss::markdown_to_styled_html(&n.markdown);
cg::htmlcss::measure_content_height(
&html,
actual_w,
&app.renderer().fonts,
)
.ok()
}
Node::HTMLEmbed(n) => cg::htmlcss::measure_content_height(
&n.html,
actual_w,
&app.renderer().fonts,
)
.ok(),
_ => None,
};
if let Some(h) = measured {
mutated |= self.apply(&MutationCommand::Resize {
id: *id,
width: None,
height: Some(h.max(1.0)),
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-dev/src/editor/document.rs` around lines 194 - 220, The measured
height from measure_content_height in the affects_w branch can be 0.0 and
bypasses the 1px minimum used by the main resize path; clamp the measured value
before applying the resize by enforcing a minimum (e.g., 1.0) and use that
clamped value when constructing the MutationCommand::Resize for id, so in the
block handling Node::MarkdownEmbed / Node::HTMLEmbed (where measured is set)
replace the raw measured h with a clamped_h = measured.max(1.0) (or equivalent)
before calling self.apply(&MutationCommand::Resize { id: *id, width: None,
height: Some(clamped_h) }).

Comment on lines 250 to +253
export const make_from_markdown = tool({
title: "Create Node from Markdown",
description:
"Create a node from a markdown string with new text node. Return the ID of the created node.",
"Create a styled markdown embed node from a GFM markdown string. Renders headings, lists, code blocks, tables, etc. with GitHub markdown styling. Return the ID of the created node.",
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

Avoid promising exact GitHub styling here.

The renderer/docs in this patch call the stylesheet grida-markdown.css, so saying “GitHub markdown styling” overstates fidelity and can skew the tool planner's expectations. “Grida markdown styling” or “GitHub-like markdown styling” would be safer.

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

In `@editor/grida-canvas-hosted/ai/tools/canvas-use.ts` around lines 250 - 253,
The description string in the make_from_markdown tool currently promises “GitHub
markdown styling”; update the description in the export const make_from_markdown
= tool({ ... }) block to avoid overstating fidelity—change wording to something
like “Grida markdown styling” or “GitHub-like markdown styling” (or similar) so
it references the actual grida-markdown.css renderer instead of claiming exact
GitHub styling; keep the rest of the title/description fields and behavior
unchanged.

Comment thread format/grida.fbs
Comment on lines +280 to 283

// embeds
MarkdownEmbed,
}
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 that MarkdownEmbed is handled in Rust schema and IO layers

echo "=== Checking Rust schema for MarkdownEmbed ==="
rg -n "MarkdownEmbed" crates/grida-canvas/src/node/schema.rs || echo "Not found in schema.rs"

echo ""
echo "=== Checking Rust IO for MarkdownEmbed ==="
rg -n "MarkdownEmbed" crates/grida-canvas/src/io/ || echo "Not found in io/"

echo ""
echo "=== Checking TS schema for markdown_embed ==="
rg -ni "markdown.?embed" packages/grida-canvas-schema/grida.ts || echo "Not found in grida.ts"

echo ""
echo "=== Checking round-trip tests ==="
rg -l "MarkdownEmbed\|markdown_embed" crates/grida-canvas/tests/ packages/grida-canvas-io/__tests__/ 2>/dev/null || echo "No test files found with MarkdownEmbed"

Repository: gridaco/grida

Length of output: 12282


🏁 Script executed:

#!/bin/bash

echo "=== Locating round-trip test files ==="
find . -name "fbs_roundtrip.rs" -o -name "format-roundtrip.test.ts" 2>/dev/null

echo ""
echo "=== Inspecting fbs_roundtrip.rs ==="
if [ -f "crates/grida-canvas/tests/fbs_roundtrip.rs" ]; then
  wc -l crates/grida-canvas/tests/fbs_roundtrip.rs
  echo ""
  head -100 crates/grida-canvas/tests/fbs_roundtrip.rs
fi

echo ""
echo "=== Inspecting format-roundtrip.test.ts ==="
if [ -f "packages/grida-canvas-io/__tests__/format-roundtrip.test.ts" ]; then
  wc -l packages/grida-canvas-io/__tests__/format-roundtrip.test.ts
  echo ""
  head -100 packages/grida-canvas-io/__tests__/format-roundtrip.test.ts
fi

Repository: gridaco/grida

Length of output: 6397


🏁 Script executed:

#!/bin/bash

echo "=== Rust test: Looking for gen_* tests and node type patterns ==="
rg "gen_.*\(\)|Node::" crates/grida-canvas/tests/fbs_roundtrip.rs | head -60

echo ""
echo "=== TS test: Looking for test cases and node types ==="
rg 'it\(|type:' packages/grida-canvas-io/__tests__/format-roundtrip.test.ts | head -80

echo ""
echo "=== Checking if MarkdownEmbed/markdown_embed appears anywhere in tests ==="
rg -i "markdown" crates/grida-canvas/tests/fbs_roundtrip.rs packages/grida-canvas-io/__tests__/format-roundtrip.test.ts

Repository: gridaco/grida

Length of output: 6846


Round-trip tests missing for MarkdownEmbed.

The Rust runtime model in crates/grida-canvas/src/node/schema.rs and TS document model in packages/grida-canvas-schema/grida.ts have been properly updated with MarkdownEmbed support. However, round-trip tests for this new union member are missing from both crates/grida-canvas/tests/fbs_roundtrip.rs and packages/grida-canvas-io/__tests__/format-roundtrip.test.ts. Add test cases for MarkdownEmbed to ensure encode/decode correctness across both Rust and TypeScript implementations.

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

In `@format/grida.fbs` around lines 280 - 283, Add round-trip tests that include
the new union member MarkdownEmbed: in the Rust test file fbs_roundtrip.rs add a
test that constructs a node/document containing the MarkdownEmbed variant
(matching the struct in crates/grida-canvas/src/node/schema.rs), serializes it
with the existing FBS encode helper, deserializes it back and asserts equality
with the original; in the TypeScript test file format-roundtrip.test.ts add a
corresponding test that builds the TS document model with MarkdownEmbed (from
packages/grida-canvas-schema/grida.ts), uses the same encode/decode helpers
already used by other cases, and asserts deep equality after round-trip. Use the
existing tests in those files as templates so you reuse the same helper
functions and assertion style.

Comment thread format/grida.fbs
Comment on lines +1490 to +1498
/// Properties for a Markdown embed node.
table MarkdownEmbedNodeProperties {
/// Background fill paints for the markdown container.
fill_paints:[PaintStackItem];
/// The GitHub Flavored Markdown source text.
markdown:string;
/// Per-corner rectangular corner radius.
corner_radius:RectangularCornerRadiusTrait;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Add explicit field IDs to MarkdownEmbedNodeProperties for schema evolvability.

Per coding guidelines, FlatBuffers fields should have explicit (id: N) annotations. This ensures stable binary compatibility when fields are added or reordered in the future.

📝 Proposed fix
 table MarkdownEmbedNodeProperties {
   /// Background fill paints for the markdown container.
-  fill_paints:[PaintStackItem];
+  fill_paints:[PaintStackItem] (id: 0);
   /// The GitHub Flavored Markdown source text.
-  markdown:string;
+  markdown:string (id: 1);
   /// Per-corner rectangular corner radius.
-  corner_radius:RectangularCornerRadiusTrait;
+  corner_radius:RectangularCornerRadiusTrait (id: 2);
 }

As per coding guidelines: "Use explicit field IDs (id: N) in FlatBuffers schema definitions and treat them as immutable".

📝 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
/// Properties for a Markdown embed node.
table MarkdownEmbedNodeProperties {
/// Background fill paints for the markdown container.
fill_paints:[PaintStackItem];
/// The GitHub Flavored Markdown source text.
markdown:string;
/// Per-corner rectangular corner radius.
corner_radius:RectangularCornerRadiusTrait;
}
/// Properties for a Markdown embed node.
table MarkdownEmbedNodeProperties {
/// Background fill paints for the markdown container.
fill_paints:[PaintStackItem] (id: 0);
/// The GitHub Flavored Markdown source text.
markdown:string (id: 1);
/// Per-corner rectangular corner radius.
corner_radius:RectangularCornerRadiusTrait (id: 2);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@format/grida.fbs` around lines 1490 - 1498, The FlatBuffers table
MarkdownEmbedNodeProperties lacks explicit field IDs; update the table
declaration so each field (fill_paints, markdown, corner_radius) has an explicit
(id: N) annotation (e.g., assign consecutive unique ids) to ensure schema
evolvability and stable binary compatibility; modify the table
MarkdownEmbedNodeProperties to include these (id: N) annotations for
fill_paints, markdown, and corner_radius accordingly.

Comment on lines +1918 to +1971
case "markdown_embed": {
const mdNode = node as grida.program.nodes.MarkdownEmbedNode;

const fillPaintsFiltered = paints(mdNode, "fill");
const fillPaintsOffset = format.paint.encode.fillPaints(
builder,
fillPaintsFiltered,
fbs.MarkdownEmbedNodeProperties.createFillPaintsVector
);
const markdownOffset = builder.createString(mdNode.markdown ?? "");
const mdCornerUniform = mdNode.corner_radius;
const cornerRadiusOffset =
format.shape.encode.rectangularCornerRadiusTrait(builder, {
rectangular_corner_radius_top_left:
mdNode.rectangular_corner_radius_top_left ?? mdCornerUniform,
rectangular_corner_radius_top_right:
mdNode.rectangular_corner_radius_top_right ?? mdCornerUniform,
rectangular_corner_radius_bottom_left:
mdNode.rectangular_corner_radius_bottom_left ??
mdCornerUniform,
rectangular_corner_radius_bottom_right:
mdNode.rectangular_corner_radius_bottom_right ??
mdCornerUniform,
corner_smoothing: mdNode.corner_smoothing,
});

fbs.MarkdownEmbedNodeProperties.startMarkdownEmbedNodeProperties(
builder
);
fbs.MarkdownEmbedNodeProperties.addFillPaints(
builder,
fillPaintsOffset
);
fbs.MarkdownEmbedNodeProperties.addMarkdown(
builder,
markdownOffset
);
fbs.MarkdownEmbedNodeProperties.addCornerRadius(
builder,
cornerRadiusOffset
);
const propertiesOffset =
fbs.MarkdownEmbedNodeProperties.endMarkdownEmbedNodeProperties(
builder
);

fbs.MarkdownEmbedNode.startMarkdownEmbedNode(builder);
fbs.MarkdownEmbedNode.addNode(builder, systemNodeTraitOffset);
fbs.MarkdownEmbedNode.addLayer(builder, layerOffset);
fbs.MarkdownEmbedNode.addProperties(builder, propertiesOffset);
nodeOffset = fbs.MarkdownEmbedNode.endMarkdownEmbedNode(builder);
nodeType = fbs.Node.MarkdownEmbedNode;
break;
}
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
set -euo pipefail

# 1) Locate expected schema/model/test files
fd -i 'grida.fbs$'
fd -i 'grida.ts$'
fd -i 'schema.rs$'
fd -i 'format-roundtrip.test.ts$'
fd -i 'fbs_roundtrip.rs$'

# 2) Verify MarkdownEmbed wiring across schema, TS codec/model, Rust runtime, and round-trip tests
rg -n --hidden -S 'MarkdownEmbedNode|markdown_embed|NodeType\.MarkdownEmbed' \
  -g '*grida.fbs' \
  -g '*format.ts' \
  -g '*grida.ts' \
  -g '*schema.rs' \
  -g '*format-roundtrip.test.ts' \
  -g '*fbs_roundtrip.rs'

Repository: gridaco/grida

Length of output: 4200


🏁 Script executed:

# Check if MarkdownEmbedNode appears in the TypeScript round-trip test file
grep -n 'MarkdownEmbedNode\|markdown_embed' packages/grida-canvas-io/__tests__/format-roundtrip.test.ts 2>/dev/null || echo "NOT FOUND in TS test"

Repository: gridaco/grida

Length of output: 77


🏁 Script executed:

# Check if MarkdownEmbedNode appears in the Rust round-trip test file  
grep -n 'MarkdownEmbedNode\|markdown_embed' crates/grida-canvas/tests/fbs_roundtrip.rs 2>/dev/null || echo "NOT FOUND in Rust test"

Repository: gridaco/grida

Length of output: 79


🏁 Script executed:

# Verify grida.fbs compiles with flatc
cd format && flatc --version && flatc -c grida.fbs 2>&1 | head -20 || echo "flatc check failed"

Repository: gridaco/grida

Length of output: 136


Add MarkdownEmbedNode round-trip tests in both Rust and TypeScript test files.

The schema, TypeScript model, Rust runtime, and codec are properly wired for MarkdownEmbedNode, but round-trip test coverage is missing. Add test cases in:

  • packages/grida-canvas-io/__tests__/format-roundtrip.test.ts
  • crates/grida-canvas/tests/fbs_roundtrip.rs

These tests are required to verify encode/decode round-trip fidelity when schema variants are added.

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

In `@packages/grida-canvas-io/format.ts` around lines 1918 - 1971, Add round-trip
tests for MarkdownEmbedNode in the TypeScript and Rust test suites: construct a
MarkdownEmbedNode with non-default fields (markdown string, corner_radius and
per-corner rectangular_corner_radius_*, corner_smoothing, and some fill paints),
encode it using the existing codec path that touches
format.paint.encode.fillPaints and
format.shape.encode.rectangularCornerRadiusTrait and the fbs.MarkdownEmbedNode/
fbs.MarkdownEmbedNodeProperties flow, then decode and assert the decoded node
equals the original (deep equality) to validate full encode→decode fidelity;
mirror the same case in the Rust fbs roundtrip test using the crate's existing
roundtrip helper so the test covers Node::MarkdownEmbedNode variant and its
Properties.

Comment on lines 295 to +310
export function filetype(
file: File
): [true, ValidFileType] | [false, string] {
const type = file.type || file.name.split(".").pop() || file.name;
// Always check extension — browsers often report inconsistent MIME
// types for markdown (text/plain, application/octet-stream, empty).
const ext = (file.name.split(".").pop() || "").toLowerCase();
const mime = (file.type || "").toLowerCase();
if (
mime === "text/markdown" ||
mime === "text/x-markdown" ||
ext === "md" ||
ext === "markdown" ||
ext === "mdown" ||
ext === "mkd"
) {
return [true, "text/markdown" as const];
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

Wire markdown through decodeFromClipboardItems() too.

These additions only become reachable from decode()'s DataTransferItem.kind === "file" path. decodeFromClipboardItems() still never checks for text/markdown, so navigator.clipboard.read() pastes will downgrade markdown to plain text or drop it entirely depending on the source.

🔧 Suggested follow-up in decodeFromClipboardItems()
       for (const clipboardItem of clipboardItems) {
         const types = clipboardItem.types;
 
         // Check for HTML first (Grida/Figma clipboard)
         if (types.includes("text/html")) {
           try {
             const blob = await clipboardItem.getType("text/html");
             const html = await blob.text();
@@
           } catch {}
         }
+
+        const markdownType = (
+          ["text/markdown", "text/x-markdown"] as const
+        ).find((type) => types.includes(type));
+        if (markdownType) {
+          try {
+            const blob = await clipboardItem.getType(markdownType);
+            const file = new File([blob], "clipboard.md", {
+              type: "text/markdown",
+            });
+            decodedItems.push({ type: "text/markdown", file });
+            continue;
+          } catch {}
+        }
 
         // Check for image/svg+xml file
         if (types.includes("image/svg+xml")) {

Also applies to: 332-348

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

In `@packages/grida-canvas-io/index.ts` around lines 295 - 310, The filetype()
helper recognizes markdown but decodeFromClipboardItems() never preserves or
checks for "text/markdown", so clipboard.read() paths downgrade or lose
markdown; update decodeFromClipboardItems() to detect clipboard items with type
"text/markdown" (and common markdown extensions when converting File items) and
return/preserve the ValidFileType "text/markdown" instead of treating it as
plain text or dropping it; specifically, in decodeFromClipboardItems() add
checks for item.type === "text/markdown" (and the same ext-based logic used in
filetype()) and map those branches to return the same result shape and MIME
label that filetype() produces so markdown is passed through end-to-end.

Comment on lines +2387 to +2399
export interface MarkdownEmbedNode
extends
i.IBaseNode,
i.ISceneNode,
i.ILayerTrait,
i.ILayoutChildTrait,
i.ICornerRadius,
i.IRectangularShapeTrait,
i.IFill<cg.Paint> {
readonly type: "markdown_embed";
/** GFM markdown source text. */
markdown: 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

IRectangularShapeTrait over-promises styling that the runtime does not carry.

This interface now exposes corner_smoothing and rectangular_stroke_width_*, but the visible Rust import/render path for markdown_embed only preserves the rounded-corner fill box. That leaves the public schema accepting fields that are silently ignored or lost on load. Either narrow the trait here or plumb those fields through end-to-end.

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

In `@packages/grida-canvas-schema/grida.ts` around lines 2387 - 2399, The
MarkdownEmbedNode interface currently extends IRectangularShapeTrait which
exposes corner_smoothing and rectangular_stroke_width_* that the runtime
ignores; to fix this, stop over-promising by replacing the
IRectangularShapeTrait extension on MarkdownEmbedNode with a narrow trait that
only includes the preserved rounded-corner fill box properties (create or use an
IRectangularFillTrait or explicitly list allowed shape properties on
MarkdownEmbedNode), or alternatively add and wire the missing
rectangular_stroke_width_* and corner_smoothing fields through the Rust/render
pipeline end-to-end; update the declaration of MarkdownEmbedNode (and any
related type aliases) to reference the new/narrowed trait or explicit properties
and remove the undesired fields so the public schema matches runtime behavior.

@softmarshmallow softmarshmallow changed the base branch from main to canary April 5, 2026 08:05
@softmarshmallow softmarshmallow merged commit 26c0511 into canary Apr 5, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant