Skip to content

SVG import: text, gradients, nested transforms, and SVG-to-.grida FlatBuffers pipeline#587

Merged
softmarshmallow merged 32 commits intomainfrom
canary
Mar 19, 2026
Merged

SVG import: text, gradients, nested transforms, and SVG-to-.grida FlatBuffers pipeline#587
softmarshmallow merged 32 commits intomainfrom
canary

Conversation

@softmarshmallow
Copy link
Copy Markdown
Member

@softmarshmallow softmarshmallow commented Mar 17, 2026

Summary

Major improvements to the SVG import pipeline, covering text handling, gradient rendering, nested transforms, and a new end-to-end SVG → .grida FlatBuffers conversion path.

Changes

SVG Text Import

  • Implement <text> / <tspan> conversion to Grida IR (TextSpanNodeRec), supporting single-span and multi-span (multiline) text nodes
  • Add text import model documentation comparing SVG character-level positioning with Grida paragraph box model (docs/wg/feat-svg/text-import.md)

SVG Gradient & Paint Handling

  • Fix gradient bounds to use (x, y, width, height) instead of (width, height), preventing gradient shift on non-square shapes
  • Correct normalize_gradient_transform to account for bounding box origin

Nested Transforms & Geometry Cache

  • Fix world transform computation for nodes under Group parents in GeometryCache, differentiating layout containers from non-layout nodes
  • Add transforms-nested.svg test fixture

SVG → .grida FlatBuffers Conversion

  • Add svg_to_grida_bytes function to parse SVG and produce .grida FlatBuffers bytes directly
  • Expose via WASM bindings and integrate into the editor SVG import flow
  • Remove legacy svg_pack abstraction in favor of the streamlined conversion
  • Add SvgToGrida CLI command in grida-dev for batch conversion and cross-boundary codec testing
  • Add FBS cross-boundary tests (Rust encode → TypeScript decode)

SVG Test Fixtures

  • Add comprehensive L0 test fixtures: basic shapes, clip paths, gradients, masks, opacity, strokes, patterns, text/tspan, paths, use/defs, viewbox aspect ratio, and filter stubs

Documentation & Research

  • Add SVG <pattern> support doc with Chromium/resvg/Skia comparison (docs/wg/research/chromium/svg-pattern.md)
  • Add cg-reftest agent skill covering reftests, golden tests, probe tests, and data tests

Editor & Misc

  • Enhance mouse wheel handling for Cmd/Ctrl+scroll zoom
  • Set root nodes to absolute positioning for drag functionality
  • Fix CI caching behavior for Rust crates
  • Bump WASM to 0.91.0-canary.3
  • Update GridaFile FlatBuffers table documentation

Summary by CodeRabbit

  • New Features

    • Direct SVG → .grida binary import (editor/WASM/CLI), CLI batch SVG→grida command, and editor integration.
    • Customizable linear-gradient endpoints (xy1/xy2) and stroke dash-array support.
    • Cmd/Ctrl + scroll to zoom (regular scroll pans).
  • Bug Fixes

    • Fixed gradient transform math to respect shape bounds/origin.
    • More reliable text import (chunking, multiline positioning).
    • Improved preservation of nested group transforms.
  • Documentation

    • Added SVG reftest guidance, SVG text-import spec, and SVG pattern research.
  • Tests

    • Added cross-boundary FlatBuffers golden tests and expanded SVG import test coverage.

- Introduced a new SKILL.md file detailing the design and review process for computer-graphics rendering tests, specifically focusing on reftests and golden tests.
- Provided guidelines on when to use each type of test, including terminology and core decision rules for selecting between reftests and golden tests.
- Included test design guidance to ensure effective and reliable visual comparison tests, enhancing the overall testing framework for the Grida project.
@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 17, 2026

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

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Mar 19, 2026 5:31pm
grida Ready Ready Preview, Comment Mar 19, 2026 5:31pm
5 Skipped Deployments
Project Deployment Actions Updated (UTC)
code Ignored Ignored Mar 19, 2026 5:31pm
legacy Ignored Ignored Mar 19, 2026 5:31pm
backgrounds Skipped Skipped Mar 19, 2026 5:31pm
blog Skipped Skipped Mar 19, 2026 5:31pm
viewer Skipped Skipped Mar 19, 2026 5:31pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 17, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: faff1067-0063-4c4e-9d18-640d1a1dc087

📥 Commits

Reviewing files that changed from the base of the PR and between 29d4c7f and 350c413.

📒 Files selected for processing (2)
  • .gitignore
  • crates/grida-canvas-wasm/turbo.json
✅ Files skipped from review due to trivial changes (2)
  • .gitignore
  • crates/grida-canvas-wasm/turbo.json

Walkthrough

Refactors SVG import from a JSON/IR pack to Rust-produced FlatBuffers bytes, replacing svgPack with svgToDocument across WASM, Rust, and editor layers; adopts chunk-based text import, fixes gradient/bbox normalization, updates layout/geometry resolution, removes the @grida/io-svg TS package, and adds tests/docs for the new pipeline.

Changes

Cohort / File(s) Summary
WASM & WASM bindings
crates/grida-canvas-wasm/lib/index.ts, crates/grida-canvas-wasm/lib/modules/svg-bindings.d.ts, crates/grida-canvas-wasm/lib/modules/svg.ts, crates/grida-canvas-wasm/src/wasm_svg.rs, crates/grida-canvas-wasm/package.json
Removed svgtypes re-exports and _grida_svg_pack; added _grida_svg_to_document returning length-prefixed FlatBuffers bytes; API now returns raw bytes/null instead of JSON error objects; package version bumped.
Rust SVG conversion & examples
crates/grida-canvas/src/io/io_svg.rs, crates/grida-canvas/examples/tool_svg_batch.rs, crates/grida-dev/src/main.rs
Replaced svg_pack JSON flow with svg_to_grida_bytes producing FlatBuffers Vec<u8>; example/tool and CLI added SvgToGrida batch exporter writing .grida files.
TypeScript editor + backend interfaces
editor/grida-canvas/backends/wasm.ts, editor/grida-canvas/editor.i.ts, editor/grida-canvas/editor.ts, editor/app/(dev)/canvas/tools/io-svg/page.tsx
Removed svgPack method and svgtypes imports; added `svgToDocument(svg): Uint8Array
TS package removals (io-svg)
packages/grida-canvas-io-svg/*, editor/package.json
Deleted @grida/io-svg package files, tests, fixtures, README, and package manifest; removed workspace dependency from editor/package.json.
Text import / SVG packing
crates/grida-canvas/src/svg/from_usvg_tree.rs, crates/grida-canvas/src/svg/pack.rs, crates/grida-canvas/tests/svg_pack.rs
Switched to chunk-based text model (one TextSpan per usvg chunk), recomputed chunk transforms, refactored append_text branches, removed anchor shift computation; tests updated for end-to-end import and added new verifications.
Gradient / paint handling
crates/grida-canvas/src/cg/svg.rs, crates/grida-canvas/src/io/io_grida.rs, packages/grida-canvas-cg/lib.ts, packages/grida-canvas-io/format.ts
Extended gradient bounds to (x,y,width,height) and threaded through normalization; added optional UV endpoints xy1/xy2 for linear gradients; updated paint conversion and FBS decode/encode for endpoints.
FlatBuffers encoding/decoding & FBS changes
crates/grida-canvas/src/io/io_grida_fbs.rs, format/grida.fbs, packages/grida-canvas-io/format.ts
Encoder now produces .grida FlatBuffers (added Node::Path→Vector parsing, group affine encoding, and stroke_dash_array support); GridaFile comment clarified; TS FBS decode/encode updated accordingly.
Geometry & layout resolution
crates/grida-canvas/src/cache/geometry.rs
Added is_layout_container_parent; only use layout-derived x/y/size when parent is a layout container; otherwise preserve full transform and fallback sizing.
Editor import key remapping utility
packages/grida-tree/src/lib.ts
Added tree.graph.remapKeys<T> to remap node keys and rewrite nodes/links/roots with fresh IDs.
Tests: cross-boundary & golden validations
packages/grida-canvas-io/__tests__/fbs-svg-cross-boundary.test.ts, crates/grida-canvas/tests/svg_pack.rs
Added TS tests to decode Rust .grida golden files and field-level checks; migrated Rust tests to validate new pipeline and added gradient/transform/text chunk assertions.
UI/UX & platform tweaks
crates/grida-dev/src/platform/native_application.rs, editor/app/.../page.tsx
MouseWheel now maps modifier-scroll to zoom and non-modifier to pan; UI hook simplified to use decoded document JSON directly.
Docs, fixtures, CI, misc
.agents/skills/cg-reftest/SKILL.md, docs/wg/feat-svg/*, docs/wg/research/chromium/*, fixtures/*, .github/workflows/test-crates.yml, crates/grida-canvas-wasm/turbo.json
Added documentation for CG tests, SVG text import, patterns, Chromium analysis; added prompts and fixture ignores; removed a CI cache option; added turborepo config for wasm crate test dependency.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Editor as Editor UI (TS)
participant CanvasWasm as Canvas WASM binding
participant WASM as WASM runtime
participant Rust as Rust SVG module
participant FBS as FlatBuffers bytes
participant TSDecoder as TS io.GRID.decode

Editor->>CanvasWasm: svgToDocument(svg string)
CanvasWasm->>WASM: call _grida_svg_to_document(ptr)
WASM->>Rust: FFI -> svg_to_grida_bytes(svg)
Rust->>FBS: sanitize & encode → length-prefixed .grida bytes
Rust-->>WASM: return ptr to (u32 len + payload) or 0
WASM->>CanvasWasm: read heap buffer → Uint8Array | null
CanvasWasm-->>Editor: Uint8Array | null
Editor->>TSDecoder: io.GRID.decode(bytes)
TSDecoder-->>Editor: Document model (decoded)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

cg, canvas, wasm32

🚥 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 title accurately and specifically describes the main changes: SVG text import, gradient handling, nested transforms, and the new SVG-to-.grida FlatBuffers pipeline. It is concise, clear, and directly reflects the primary focus of the PR.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch canary
📝 Coding Plan
  • Generate coding plan for human review comments

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.

- Updated the mouse wheel event handling to support zooming in and out using Cmd/Ctrl + scroll gestures, improving user experience on macOS and other platforms.
- Maintained existing panning functionality for standard scroll actions, ensuring consistent behavior across different input methods.
- Introduced sensitivity adjustments for zooming to provide a smoother experience when navigating through content.
- Added a variety of new prompts for single vector and SVG illustrations, including designs for logos, icons, and promotional materials.
- Enhanced the creative options available for users, catering to diverse design needs such as minimalistic logos, flat illustrations, and typographic posters.
- Improved the overall resource for generating visual content, supporting a wider range of artistic expressions.
- Modified the GitHub Actions workflow to save cache only when the branch is 'main', optimizing cache usage.
- Removed the option to add a Rust environment hash key, streamlining the caching process for Rust crates.
- Expanded the SKILL.md file to incorporate details about observation-based probe tests, including their design rules and conventions.
- Clarified the distinctions between reftests, golden tests, and probe tests, enhancing the guidance for visual comparison testing.
- Added new sections on probe test verification and future tooling, improving the overall documentation for rendering tests in the Grida project.
- Expanded SKILL.md to include definitions for data tests and their use cases, emphasizing assertions on scene graphs and computed values without rendering.
- Clarified the hierarchy of testing methods, prioritizing data tests over reftests, probe tests, and golden tests for efficiency.
- Updated decision rules and when to use each test type, improving guidance for developers on selecting appropriate testing strategies in rendering.
- Enhanced the GeometryCache to correctly apply world transforms for nodes under Group parents, ensuring accurate positioning when the layout engine is involved.
- Added logic to differentiate between layout containers and non-layout nodes, preserving transformations for nodes that do not participate in layout.
- Introduced new tests to verify the correctness of world transforms for nested groups and their children, ensuring reliable rendering of SVG structures.
- Eliminated the condition to save cache only for the 'main' branch in the GitHub Actions workflow, simplifying the caching logic.
- This change aims to enhance cache management for all branches, improving build efficiency across different development workflows.
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: f655aff1ec

ℹ️ 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 +712 to +713
// Decode FBS bytes into the editor's full document model
const fullDoc = io.GRID.decode(bytes);
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 scale/skew in the new SVG import path

createNodeFromSvg() now decodes the Rust-generated .grida bytes via io.GRID.decode(), but that decoder still drops non-rotational group transforms. packages/grida-canvas-io/format.ts:5413-5439 only reconstructs a group from layoutFields, and the newly added note there explicitly says post_layout_transform scale/skew are lost. As a result, any imported SVG that uses scaled or skewed <g> elements will look correct in the Rust/FBS round-trip but arrive in the editor without those transforms.

Useful? React with 👍 / 👎.

Comment on lines +266 to +270
// Apply from_bbox_inv = [1/w, 0, -x/w, 0, 1/h, -y/h]
// as a left-multiply: result = from_bbox_inv * transform
let inv_w = 1.0 / width;
let inv_h = 1.0 / height;
transform.matrix[0][0] *= inv_w;
transform.matrix[0][1] *= inv_w;
transform.matrix[0][2] *= inv_w;
transform.matrix[1][0] *= inv_h;
transform.matrix[1][1] *= inv_h;
transform.matrix[1][2] *= inv_h;
let m = &transform.matrix;
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 Undo objectBoundingBox gradients with the correct multiply order

For objectBoundingBox gradients, usvg has already post-concatenated the bbox matrix onto the gradient transform, so removing it needs transform * from_bbox_inv. This code applies from_bbox_inv * transform instead, which is why the new paint-linear-gradient-01 cross-boundary check still has to be marked it.fails: even a plain horizontal gradient keeps a stray transform and renders shifted/skewed on imported shapes.

Useful? React with 👍 / 👎.

Comment on lines 202 to +204
for chunk in text.chunks() {
let offset_x = chunk.x().unwrap_or(0.0);
let offset_y = chunk.y().unwrap_or(0.0);
let chunk_text = chunk.text();
for span in chunk.spans() {
if !span.is_visible() {
continue;
}
let start = span.start();
let end = span.end().min(chunk_text.len());
if start >= end {
continue;
}
let span_slice = &chunk_text[start..end];
if span_slice.trim().is_empty() {
continue;
}
let mut span_affine: AffineTransform = relative.into();
if offset_x != 0.0 || offset_y != 0.0 {
span_affine.translate(offset_x, offset_y);
}

let span_fill = span.fill().map(SVGFillAttributes::from);
let span_stroke = span.stroke().map(SVGStrokeAttributes::from);

spans.push(IRSVGTextSpanNode {
transform: CGTransform2D::from(span_affine),
text: span_slice.to_string(),
fill: span_fill,
stroke: span_stroke,
font_size: Some(span.font_size().get()),
anchor: chunk.anchor().into(),
});
let chunk_text = chunk.text().trim();

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 Stop trimming imported SVG text chunks

Calling trim() here removes leading/trailing spaces that are part of the SVG text payload. In common cases like Hello <tspan>world</tspan> or xml:space="preserve", usvg will hand us a chunk ending or starting with a space; trimming it turns Hello world into Helloworld and also makes the stored text disagree with the character positions used for dx/dy.

Useful? React with 👍 / 👎.

Comment thread crates/grida-canvas/src/svg/pack.rs Outdated
Comment on lines +177 to +181
span.text.as_str(),
span.fill.as_ref().or(text.fill.as_ref()),
span.stroke.as_ref().or(text.stroke.as_ref()),
span.font_size,
&text.bounds,
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 Anchor each imported text chunk with its own width

In the multi-chunk path every child span is passed the parent <text> bounds. append_text_span_node() later computes text-anchor by subtracting bounds.width, so multiline or multi-tspan text with text-anchor="middle"/"end" shifts every line by the same overall width instead of each chunk's width. The new text-anchor.svg fixture includes absolute-positioned tspans that depend on per-line anchoring, so those imports will be visibly misaligned.

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

Caution

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

⚠️ Outside diff range comments (2)
editor/grida-canvas/editor.i.ts (1)

429-441: ⚠️ Potential issue | 🟠 Major

Fix typo-lint blockers called out by CI.

Line 429 (MOVEMNT) and Line 441 (applyed) are currently failing the typos workflow. Please correct them (or add an explicit lint allowlist if intentional) to unblock CI.

Minimal text fixes
- * const threshold = Math.ceil(DEFAULT_SNAP_MOVEMNT_THRESHOLD_FACTOR / zoom);
+ * const threshold = Math.ceil(DEFAULT_SNAP_MOVEMENT_THRESHOLD_FACTOR / zoom);
...
- * snap threshold applyed when nudge (fake gesture) is applied
+ * snap threshold applied when nudge (fake gesture) is applied
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@editor/grida-canvas/editor.i.ts` around lines 429 - 441, The exported symbol
DEFAULT_SNAP_MOVEMNT_THRESHOLD_FACTOR and a nearby comment "applyed" contain
typos; rename the symbol to DEFAULT_SNAP_MOVEMENT_THRESHOLD_FACTOR and update
every usage/import of DEFAULT_SNAP_MOVEMNT_THRESHOLD_FACTOR across the codebase
(or add a deprecated alias if you must preserve compatibility), and correct the
comment word "applyed" to "applied" (in the comment starting "snap threshold
applyed when nudge..."); ensure tests/linters pass after updating references.
crates/grida-canvas/tests/svg_pack.rs (1)

88-100: ⚠️ Potential issue | 🟡 Minor

This test never asserts anything.

Both branches pass, and this also bypasses the sanitized production path used by assert_pack_ok(). As written, CI won’t tell you whether no-xmlns handling regressed.

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

In `@crates/grida-canvas/tests/svg_pack.rs` around lines 88 - 100, The test
currently doesn't assert anything because it matches Ok/Err and silently
continues; replace the match with a real assertion so CI catches regressions —
call the existing assert_pack_ok helper (or assert!(result.is_ok(), "..."))
instead of the match, referencing SVGPackedScene::new_from_svg_str to obtain the
result and use assert_pack_ok(svg) (or assert!(result.is_ok(), "no-xmlns SVG
must be accepted")) so the sanitized production path and actual assertion are
exercised.
🧹 Nitpick comments (6)
crates/grida-dev/src/platform/native_application.rs (1)

48-51: Consider extracting magic numbers as named constants.

The values 16.0 (line-to-pixel approximation) and 0.002 (zoom sensitivity) are reasonable heuristics, but naming them would clarify intent and make future tuning easier.

✨ Optional: extract constants
+const PIXELS_PER_LINE: f32 = 16.0;
+const SCROLL_ZOOM_SENSITIVITY: f32 = 0.002;
+
 fn handle_window_event(
     event: &WindowEvent,
     modifiers: &winit::keyboard::ModifiersState,
 ) -> ApplicationCommand {
     match event {
         // ...
         WindowEvent::MouseWheel { delta, .. } => {
             if modifiers.super_key() || modifiers.control_key() {
                 let dy = match delta {
                     MouseScrollDelta::PixelDelta(d) => d.y as f32,
-                    MouseScrollDelta::LineDelta(_, y) => *y * 16.0,
+                    MouseScrollDelta::LineDelta(_, y) => *y * PIXELS_PER_LINE,
                 };
-                let sensitivity: f32 = 0.002;
-                let zoom_delta = dy * sensitivity;
+                let zoom_delta = dy * SCROLL_ZOOM_SENSITIVITY;
                 ApplicationCommand::ZoomDelta { delta: zoom_delta }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-dev/src/platform/native_application.rs` around lines 48 - 51,
Extract the magic numbers into named constants and use them in the scroll->zoom
calculation: replace the literal 16.0 used in the MouseScrollDelta::LineDelta
branch with a const like LINE_DELTA_TO_PIXELS: f32 = 16.0 and replace the 0.002
assigned to sensitivity with a const like ZOOM_SENSITIVITY: f32 = 0.002; then
compute dy and zoom_delta using those constants (the code around
MouseScrollDelta::LineDelta, the sensitivity variable, and zoom_delta should
reference LINE_DELTA_TO_PIXELS and ZOOM_SENSITIVITY) so intent is clear and
tuning is easier.
crates/grida-canvas/src/cache/geometry.rs (1)

495-504: Consider extracting the parent check into a helper.

The parent_is_layout_container logic is duplicated between TextSpan (lines 401-410) and leaf nodes (lines 495-504). A small helper function would reduce duplication.

♻️ Extract helper function
+fn is_layout_container_parent(
+    parent_id: &Option<NodeId>,
+    graph: &SceneGraph,
+) -> bool {
+    parent_id
+        .as_ref()
+        .and_then(|pid| graph.get_node(pid).ok())
+        .map(|parent_node| {
+            matches!(parent_node, Node::Container(_) | Node::InitialContainer(_))
+        })
+        .unwrap_or(false)
+}

Then use is_layout_container_parent(&parent_id, graph) in both branches.

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

In `@crates/grida-canvas/src/cache/geometry.rs` around lines 495 - 504, The
parent_is_layout_container check is duplicated between TextSpan and leaf node
handling; extract this into a small helper function (e.g.,
is_layout_container_parent) that takes (&Option<Id>, &Graph) and returns a bool
by performing the current logic of checking parent_id.as_ref().and_then(|pid|
graph.get_node(pid).ok()).map(|parent_node| matches!(parent_node,
Node::Container(_) | Node::InitialContainer(_))). Replace the duplicated blocks
in TextSpan handling and the leaf node branch with calls to
is_layout_container_parent(&parent_id, graph) to remove duplication and improve
readability.
packages/grida-tree/src/lib.ts (1)

2382-2396: Input node objects are mutated when updating id property.

The function creates a new nodes record but reuses the original node objects. When node.id is updated (line 2393), it mutates the input subgraph.nodes entries. This may be intentional for performance, but it's surprising given the function appears to produce a "fresh-key version."

Consider either:

  1. Documenting this mutation behavior in the JSDoc
  2. Or shallow-copying the node before mutation if immutability is expected
Option A: Document the mutation
    * `@param` keygen - Function that generates a fresh key for each old key
    * `@returns` `{ graph, roots, keyMap }` with all keys replaced
+   * `@remarks` This function mutates `node.id` on input nodes that have a string `id` property.
    *
Option B: Shallow-copy to avoid mutation
      // 2. Remap nodes
      const nodes: Record<tree.Key, T> = {};
      for (const [oldKey, node] of Object.entries(subgraph.nodes)) {
        const newKey = keyMap[oldKey]!;
+       let remappedNode = node;
        // If the node has an `id` property, update it
        if (
          node &&
          typeof node === "object" &&
          "id" in (node as Record<string, unknown>) &&
          typeof (node as Record<string, unknown>).id === "string"
        ) {
-         (node as Record<string, unknown>).id = newKey;
+         remappedNode = { ...node, id: newKey } as T;
        }
-       nodes[newKey] = node as T;
+       nodes[newKey] = remappedNode;
      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/grida-tree/src/lib.ts` around lines 2382 - 2396, The code currently
mutates input objects from subgraph.nodes when updating their id; to avoid
surprising side-effects, shallow-copy each node before modifying its id (e.g.,
create a newObj from node via Object.assign({}, node) or {...node}), set
newObj.id = newKey, and assign nodes[newKey] = newObj; locate the logic that
iterates over subgraph.nodes, the variables node/newKey/keyMap/nodes, and
replace the in-place id mutation with a shallow-copy-and-modify approach (or
alternatively, if mutation is intended, update the function JSDoc to explicitly
state that subgraph.nodes entries are mutated).
crates/grida-canvas/src/io/io_svg.rs (1)

24-52: Avoid a fixed-width a{:06} ordering key here.

These position strings are consumed lexicographically during decode. Once counter reaches 1_000_000, a1000000 sorts before a999999, so very large imports can rebuild siblings in the wrong order. Reusing the encoder’s fractional-position generator would keep both code paths aligned.

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

In `@crates/grida-canvas/src/io/io_svg.rs` around lines 24 - 52, The current
walk_tree_order builds position_map entries using a fixed-width string format
"a{:06}" which will break lexicographic ordering once counter >= 1_000_000;
change walk_tree_order to stop using a fixed-width decimal padding and instead
call the same fractional-position generator used by the encoder (or central
utility that produces monotonic lexicographic position keys) to generate
position_map values for each NodeId, keeping id_map creation (format!("svg_{}",
node_id)) intact and ensuring walk_tree_order, position_map, and any code that
consumes these keys remain compatible with the encoder's position format.
crates/grida-canvas/tests/svg_pack.rs (1)

1186-1408: These are encode/decode tests, not true round-trips.

Every case stops at io_grida_fbs::decode(&bytes), so none of them exercise re-encoding with the decoded id_map and position_map. If the goal is to protect .grida compatibility, switch this block to decode_with_id_map() plus a second encode(); otherwise rename the tests so the coverage is explicit.

Based on learnings: Treat changes to format/grida.fbs (FlatBuffers schema) like public API changes—they ripple into Rust and TypeScript codecs and eventually into real user files.

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

In `@crates/grida-canvas/tests/svg_pack.rs` around lines 1186 - 1408, The tests
currently only call svg_to_grida_bytes(...) then io_grida_fbs::decode(&bytes)
which verifies decoding but not re-encoding; update each test that intends a
full round-trip to call io_grida_fbs::decode_with_id_map(&bytes) to obtain
(scene, id_map, position_map) and then call io_grida_fbs::encode(&scene,
&id_map, &position_map) (or the crate's equivalent encode function) and assert
the resulting bytes/structure (or byte-validity) to complete the
encode→decode→encode roundtrip; alternatively, if you only want to test decode
behavior, rename the tests (e.g., append _decode_only) to make coverage
explicit.
editor/grida-canvas/editor.ts (1)

724-758: This manual document rebuild is already lossy.

io.GRID.decode(bytes) gives you the full imported document, but this path reconstructs a new IPackedSceneDocument and hard-codes images, bitmaps, and properties to {}. A dedicated helper that strips the wrapper scene while preserving the rest of the decoded payload would be safer than re-encoding the schema shape here by hand.

Based on learnings: Treat changes to format/grida.fbs (FlatBuffers schema) like public API changes—they ripple into Rust and TypeScript codecs and eventually into real user files.

🤖 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 724 - 758, The current import
rebuild manually constructs an IPackedSceneDocument (packedDoc) and blanks
images/bitmaps/properties which causes data loss; instead use the full decoded
payload from io.GRID.decode(bytes) and strip only the wrapper scene while
preserving its nodes, links, images, bitmaps and properties, then remap IDs via
tree.graph.remapKeys(...) using this preserved payload and idgen.next();
implement or call a helper (e.g., create a helper like
stripWrapperSceneFromDecoded(decoded): IPackedSceneDocument) and replace the
packedDoc construction with that helper so remapKeys and subsequent code operate
on the complete decoded document rather than hard-coded empty maps.
🤖 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-wasm/lib/modules/svg.ts`:
- Around line 92-116: The toDocument method currently only frees the WASM result
buffer on the success path; change it so any non-zero pointer returned by
_grida_svg_to_document is always deallocated even if an exception occurs. After
calling this.module._grida_svg_to_document(svgPtr) capture the resultPtr in a
variable visible to the finally block, and in finally call
this.module._deallocate(resultPtr, 4 + len) (or deallocate just the 4-byte
prefix + payload) when resultPtr is non-zero; keep freeing the input string from
_alloc_string (svgPtr) as before. Ensure resultPtr and len are
initialized/guarded so the finally block can safely skip deallocation if
resultPtr is null/0 or len is unknown.

In `@crates/grida-canvas/src/svg/pack.rs`:
- Around line 175-183: The code is reusing the full text block bounds for every
span causing incorrect anchor shifts; update the call to append_text_span_node
so it uses per-span bounds (compute or pass span-specific bounds instead of
&text.bounds) or, if per-span bounds are not yet available, disable the
anchor-based shift for chunked spans inside append_text_span_node (detect when
multiple spans are present and skip the extra x-shift). Make the change around
the append_text_span_node invocation and ensure the function signature/logic
(append_text_span_node) accepts and uses those per-span bounds (or a flag to
skip anchor shift) so middle/end alignment uses each span’s own width rather
than text.bounds.

In `@crates/grida-dev/src/main.rs`:
- Around line 156-170: The loop that writes outputs keys them only by
file_stem() (see the use of path.file_stem(), name, and
output_dir.join(format!("{}.grida", name))), which causes collisions for
identically named files from different subdirectories; to fix, compute each
SVG's path relative to the input base and use that relative path (either
mirrored as subdirectories under output_dir or safely normalized into a unique
filename) when creating the output path, or alternatively scan svgs first to
detect duplicate stems and abort with a clear error; update the code that reads
path.file_stem() and builds out to use the relative path or duplicate-check
logic so writes no longer silently overwrite each other.

In `@docs/wg/feat-svg/pattern.md`:
- Around line 211-218: Update the checklist to require that any change to
format/grida.fbs is accompanied by corresponding updates to the runtime and
document models: modify the Rust runtime model at
crates/grida-canvas/src/node/schema.rs and the TypeScript document model at
packages/grida-canvas-schema/grida.ts when adding the new pattern paint (e.g.,
SVGPatternPaint / Paint::Pattern) so schema, runtime, and TS models stay in
sync; add a short guidance sentence like: "When modifying format/grida.fbs,
update the Rust runtime model at crates/grida-canvas/src/node/schema.rs and the
TS document model at packages/grida-canvas-schema/grida.ts together."

In `@docs/wg/feat-svg/text-import.md`:
- Around line 1-4: Add a YAML frontmatter block containing "format: md" at the
very top of the document that begins with the heading "# SVG Text Import Model"
so the page opts out of MDX parsing; ensure the frontmatter is the first thing
in the file (e.g., ---\nformat: md\n---) and that no MDX-specific syntax is
required elsewhere in the document.
- Around line 53-60: The fenced diagram block in docs/wg/feat-svg/text-import.md
is missing a language tag causing markdownlint MD040; update the opening fence
for the diagram (the triple-backtick block that contains the "SVG  Grida Scene
Graph" ASCII diagram) to include an explicit language identifier such as text
(i.e., change ``` to ```text) so the fenced diagram is properly annotated.

In `@editor/app/`(dev)/canvas/tools/io-svg/page.tsx:
- Around line 263-272: The effect clears document JSON on failed conversion but
doesn't update the mounted canvas, leaving the last import visible; after the
checks where setDocumentJson(undefined) is called (in the branches around
useEffect and after editorInstance.svgToDocument(raw) returning falsy), also
clear the mounted editor via editorInstance by loading an empty document or
calling its clear method (e.g., editorInstance.loadDocument(undefined) or
editorInstance.clear()) so the canvas and text panes stay in sync when raw is
empty or conversion fails.
- Around line 261-286: The hook currently sets result and packedScene to the
same stringified JSON (documentJson); instead, keep result as the decoded
document object and packedScene as the pre-rendered JSON string: after decoding
bytes with io.GRID.decode (used in editorInstance.svgToDocument -> bytes ->
io.GRID.decode), set a state for the raw document object (e.g., documentObj) and
a separate state for the JSON string (documentJson), set documentObj to the
decoded doc and documentJson to JSON.stringify(doc, null, 2), and return {
result: documentObj, packedScene: documentJson } so callers that
JSON.stringify(result) receive the actual object.

In `@editor/grida-canvas/editor.i.ts`:
- Around line 2705-2719: The interface docs are misleading: update the comment
for IDocumentSVGInterfaceProvider.svgToDocument (and the top summary) to state
that svgToDocument returns FlatBuffers bytes (Uint8Array | null) rather than an
IPackedSceneDocument directly, and note that callers must decode the bytes using
io.GRID.decode to obtain an IPackedSceneDocument; also adjust the earlier
summary that currently claims svgToDocument produces an IPackedSceneDocument to
reflect the actual return contract and mention svgOptimize similarly if needed.

In `@editor/grida-canvas/editor.ts`:
- Around line 760-769: The code currently picks the root via ids[0], which is
unsafe because insert() returns all created node ids in diff order; instead
obtain the imported root id from the remapping produced during the import (use
remappedRoots[0] or the value that maps the original packedDoc root to the new
id) and use that as rootId when calling getNodeById; update the root selection
logic in the SVG import path (replace the ids[0] usage with the remapped root
lookup) and keep the existing error check and return of
getNodeById<grida.program.nodes.ContainerNode>(rootId).

In `@packages/grida-canvas-io-svg/lib.ts`:
- Around line 276-301: makeTextSpan() is using each span's absolute transform
for layout_inset_* which double-applies the parent text group's translation
(textPosition); change it to use local offsets by subtracting the parent
textPosition from the span's translation (e.g., localLeft = spanPosition.left -
textPosition.left, localTop = spanPosition.top - textPosition.top) and assign
those local values to layout_inset_left/top. Apply the same adjustment to the
analogous span-handling block around lines 331-349.

In `@packages/grida-canvas-io/format.ts`:
- Around line 2289-2294: The decoder's fallback coordinates for gradient
endpoints (xy1 and xy2) differ from the encoder's defaults, causing mismatched
gradient orientation; update the decoder logic that assigns xy1 (currently using
xy1Obj ? ... : [0, 0.5]) and xy2 (xy2Obj ? ... : [1, 0.5]) to use the same
defaults the encoder writes (xy1 = [0, 0] and xy2 = [1, 0]) so decoded gradients
match encoded ones even when fields are missing.

---

Outside diff comments:
In `@crates/grida-canvas/tests/svg_pack.rs`:
- Around line 88-100: The test currently doesn't assert anything because it
matches Ok/Err and silently continues; replace the match with a real assertion
so CI catches regressions — call the existing assert_pack_ok helper (or
assert!(result.is_ok(), "...")) instead of the match, referencing
SVGPackedScene::new_from_svg_str to obtain the result and use
assert_pack_ok(svg) (or assert!(result.is_ok(), "no-xmlns SVG must be
accepted")) so the sanitized production path and actual assertion are exercised.

In `@editor/grida-canvas/editor.i.ts`:
- Around line 429-441: The exported symbol DEFAULT_SNAP_MOVEMNT_THRESHOLD_FACTOR
and a nearby comment "applyed" contain typos; rename the symbol to
DEFAULT_SNAP_MOVEMENT_THRESHOLD_FACTOR and update every usage/import of
DEFAULT_SNAP_MOVEMNT_THRESHOLD_FACTOR across the codebase (or add a deprecated
alias if you must preserve compatibility), and correct the comment word
"applyed" to "applied" (in the comment starting "snap threshold applyed when
nudge..."); ensure tests/linters pass after updating references.

---

Nitpick comments:
In `@crates/grida-canvas/src/cache/geometry.rs`:
- Around line 495-504: The parent_is_layout_container check is duplicated
between TextSpan and leaf node handling; extract this into a small helper
function (e.g., is_layout_container_parent) that takes (&Option<Id>, &Graph) and
returns a bool by performing the current logic of checking
parent_id.as_ref().and_then(|pid| graph.get_node(pid).ok()).map(|parent_node|
matches!(parent_node, Node::Container(_) | Node::InitialContainer(_))). Replace
the duplicated blocks in TextSpan handling and the leaf node branch with calls
to is_layout_container_parent(&parent_id, graph) to remove duplication and
improve readability.

In `@crates/grida-canvas/src/io/io_svg.rs`:
- Around line 24-52: The current walk_tree_order builds position_map entries
using a fixed-width string format "a{:06}" which will break lexicographic
ordering once counter >= 1_000_000; change walk_tree_order to stop using a
fixed-width decimal padding and instead call the same fractional-position
generator used by the encoder (or central utility that produces monotonic
lexicographic position keys) to generate position_map values for each NodeId,
keeping id_map creation (format!("svg_{}", node_id)) intact and ensuring
walk_tree_order, position_map, and any code that consumes these keys remain
compatible with the encoder's position format.

In `@crates/grida-canvas/tests/svg_pack.rs`:
- Around line 1186-1408: The tests currently only call svg_to_grida_bytes(...)
then io_grida_fbs::decode(&bytes) which verifies decoding but not re-encoding;
update each test that intends a full round-trip to call
io_grida_fbs::decode_with_id_map(&bytes) to obtain (scene, id_map, position_map)
and then call io_grida_fbs::encode(&scene, &id_map, &position_map) (or the
crate's equivalent encode function) and assert the resulting bytes/structure (or
byte-validity) to complete the encode→decode→encode roundtrip; alternatively, if
you only want to test decode behavior, rename the tests (e.g., append
_decode_only) to make coverage explicit.

In `@crates/grida-dev/src/platform/native_application.rs`:
- Around line 48-51: Extract the magic numbers into named constants and use them
in the scroll->zoom calculation: replace the literal 16.0 used in the
MouseScrollDelta::LineDelta branch with a const like LINE_DELTA_TO_PIXELS: f32 =
16.0 and replace the 0.002 assigned to sensitivity with a const like
ZOOM_SENSITIVITY: f32 = 0.002; then compute dy and zoom_delta using those
constants (the code around MouseScrollDelta::LineDelta, the sensitivity
variable, and zoom_delta should reference LINE_DELTA_TO_PIXELS and
ZOOM_SENSITIVITY) so intent is clear and tuning is easier.

In `@editor/grida-canvas/editor.ts`:
- Around line 724-758: The current import rebuild manually constructs an
IPackedSceneDocument (packedDoc) and blanks images/bitmaps/properties which
causes data loss; instead use the full decoded payload from
io.GRID.decode(bytes) and strip only the wrapper scene while preserving its
nodes, links, images, bitmaps and properties, then remap IDs via
tree.graph.remapKeys(...) using this preserved payload and idgen.next();
implement or call a helper (e.g., create a helper like
stripWrapperSceneFromDecoded(decoded): IPackedSceneDocument) and replace the
packedDoc construction with that helper so remapKeys and subsequent code operate
on the complete decoded document rather than hard-coded empty maps.

In `@packages/grida-tree/src/lib.ts`:
- Around line 2382-2396: The code currently mutates input objects from
subgraph.nodes when updating their id; to avoid surprising side-effects,
shallow-copy each node before modifying its id (e.g., create a newObj from node
via Object.assign({}, node) or {...node}), set newObj.id = newKey, and assign
nodes[newKey] = newObj; locate the logic that iterates over subgraph.nodes, the
variables node/newKey/keyMap/nodes, and replace the in-place id mutation with a
shallow-copy-and-modify approach (or alternatively, if mutation is intended,
update the function JSDoc to explicitly state that subgraph.nodes entries are
mutated).
🪄 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: 9d3036b9-048e-4f23-8ee3-0ffc6045316b

📥 Commits

Reviewing files that changed from the base of the PR and between 3d53c0f and f655aff.

⛔ Files ignored due to path filters (39)
  • crates/grida-canvas-wasm/lib/bin/grida_canvas_wasm.wasm is excluded by !**/*.wasm
  • crates/grida-canvas/src/io/generated/grida.rs is excluded by !**/generated/**
  • fixtures/test-svg/L0/basic-shapes.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/clip-paths.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/filters-feColorMatrix.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/filters-feComponentTransfer.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/filters-feComposite.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/filters-feConvolveMatrix.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/filters-feDiffuseLighting.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/filters-feDisplacementMap.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/filters-feDropShadow.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/filters-feFlood.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/filters-feGaussianBlur.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/filters-feMorphology.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/filters-feSpecularLighting.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/filters-feTurbulence.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/gradients.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/groups.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/masks.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/opacity.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/paint-linear-gradient-01.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/paint-linear-gradient-vertical-01.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/paint-radial-gradient-01.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/paints.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/paths.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/patterns.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/shapes.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/stroke-dasharray.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/strokes.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/text-anchor.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/text-generic-fonts.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/text-in-transformed-group.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/text-tspan.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/texts.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/transforms-nested.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/transforms.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/use-defs.svg is excluded by !**/*.svg
  • fixtures/test-svg/L0/viewbox-aspect-ratio.svg is excluded by !**/*.svg
  • packages/grida-canvas-io-svg/__tests__/test.svg is excluded by !**/*.svg
📒 Files selected for processing (37)
  • .agents/skills/cg-reftest/SKILL.md
  • .github/workflows/test-crates.yml
  • crates/grida-canvas-wasm/lib/bin/grida-canvas-wasm.js
  • crates/grida-canvas-wasm/lib/index.ts
  • crates/grida-canvas-wasm/lib/modules/svg-bindings.d.ts
  • crates/grida-canvas-wasm/lib/modules/svg.ts
  • crates/grida-canvas-wasm/package.json
  • crates/grida-canvas-wasm/src/wasm_svg.rs
  • crates/grida-canvas/examples/tool_svg_batch.rs
  • crates/grida-canvas/src/cache/geometry.rs
  • crates/grida-canvas/src/cg/svg.rs
  • crates/grida-canvas/src/io/io_grida.rs
  • crates/grida-canvas/src/io/io_grida_fbs.rs
  • crates/grida-canvas/src/io/io_svg.rs
  • crates/grida-canvas/src/svg/from_usvg_tree.rs
  • crates/grida-canvas/src/svg/pack.rs
  • crates/grida-canvas/tests/svg_pack.rs
  • crates/grida-dev/src/main.rs
  • crates/grida-dev/src/platform/native_application.rs
  • docs/wg/feat-svg/pattern.md
  • docs/wg/feat-svg/text-import.md
  • docs/wg/research/chromium/index.md
  • docs/wg/research/chromium/svg-pattern.md
  • editor/app/(dev)/canvas/tools/io-svg/page.tsx
  • editor/grida-canvas/backends/wasm.ts
  • editor/grida-canvas/editor.i.ts
  • editor/grida-canvas/editor.ts
  • fixtures/prompts/canvas-user-prompts.txt
  • fixtures/test-svg/.gitignore
  • format/grida.fbs
  • packages/grida-canvas-cg/lib.ts
  • packages/grida-canvas-io-svg/__tests__/convert.test.ts
  • packages/grida-canvas-io-svg/__tests__/fbs-cross-boundary.test.ts
  • packages/grida-canvas-io-svg/__tests__/svgson.example.json
  • packages/grida-canvas-io-svg/lib.ts
  • packages/grida-canvas-io/format.ts
  • packages/grida-tree/src/lib.ts
💤 Files with no reviewable changes (3)
  • crates/grida-canvas-wasm/lib/index.ts
  • packages/grida-canvas-io-svg/tests/svgson.example.json
  • .github/workflows/test-crates.yml

Comment thread crates/grida-canvas-wasm/lib/modules/svg.ts
Comment thread crates/grida-canvas/src/svg/pack.rs
Comment on lines +156 to +170
for path in &svgs {
let name = path.file_stem().unwrap().to_string_lossy();
let svg = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
eprintln!(" SKIP {} — read: {}", name, e);
skip += 1;
continue;
}
};
match svg_to_grida_bytes(&svg) {
Ok(bytes) => {
let out = output_dir.join(format!("{}.grida", name));
std::fs::write(&out, &bytes).expect("write");
ok += 1;
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 key outputs only by file_stem() in recursive mode.

In recursive mode, foo/icon.svg and bar/icon.svg both target icon.grida. The later write silently overwrites the earlier one, so some fixtures disappear from .generated and the cross-boundary suite ends up exercising whichever file happened to win last. Either reject duplicate stems up front or plumb relative paths through the generator and reader together.

🤖 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 156 - 170, The loop that writes
outputs keys them only by file_stem() (see the use of path.file_stem(), name,
and output_dir.join(format!("{}.grida", name))), which causes collisions for
identically named files from different subdirectories; to fix, compute each
SVG's path relative to the input base and use that relative path (either
mirrored as subdirectories under output_dir or safely normalized into a unique
filename) when creating the output path, or alternatively scan svgs first to
detect duplicate stems and abort with a clear error; update the code that reads
path.file_stem() and builds out to use the relative path or duplicate-check
logic so writes no longer silently overwrite each other.

Comment on lines +211 to +218
| File | Change |
| -------------------------------------------------------- | ------------------------------------------------------------ |
| `crates/grida-canvas/src/cg/svg.rs` | Add `SVGPatternPaint` struct and `SVGPaint::Pattern` variant |
| `crates/grida-canvas/src/svg/from_usvg.rs` | Convert `usvg::Paint::Pattern` instead of discarding |
| `crates/grida-canvas/src/cg/types.rs` | Possibly add `Paint::Pattern(...)` variant |
| `crates/grida-canvas/src/painter/paint.rs` | Handle pattern shader creation |
| `crates/grida-canvas-wasm/lib/modules/svg-bindings.d.ts` | Update `SVGPaint` type |
| `format/grida.fbs` | Add pattern paint type (if persisting to file format) |
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

Add the schema-model follow-ups to this checklist.

Changing format/grida.fbs is not self-contained here. If someone implements pattern persistence from this table alone, they can easily miss the required runtime model sync and leave the schema out of step with the Rust/TS document models. Please list crates/grida-canvas/src/node/schema.rs and packages/grida-canvas-schema/grida.ts alongside the FlatBuffers change. Based on learnings, "When modifying format/grida.fbs, update the Rust runtime model at crates/grida-canvas/src/node/schema.rs and the TS document model at packages/grida-canvas-schema/grida.ts together."

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

In `@docs/wg/feat-svg/pattern.md` around lines 211 - 218, Update the checklist to
require that any change to format/grida.fbs is accompanied by corresponding
updates to the runtime and document models: modify the Rust runtime model at
crates/grida-canvas/src/node/schema.rs and the TypeScript document model at
packages/grida-canvas-schema/grida.ts when adding the new pattern paint (e.g.,
SVGPatternPaint / Paint::Pattern) so schema, runtime, and TS models stay in
sync; add a short guidance sentence like: "When modifying format/grida.fbs,
update the Rust runtime model at crates/grida-canvas/src/node/schema.rs and the
TS document model at packages/grida-canvas-schema/grida.ts together."

Comment thread docs/wg/feat-svg/text-import.md
Comment on lines 263 to 272
useEffect(() => {
if (!raw) {
setConversion(undefined);
setPackedSceneJson(undefined);
setDocumentJson(undefined);
return;
}

const packed = editorInstance.svgPack(raw);

if (!packed) {
setConversion(undefined);
setPackedSceneJson(undefined);
const bytes = editorInstance.svgToDocument(raw);
if (!bytes) {
setDocumentJson(undefined);
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keep the preview and panes in sync on failed conversion.

Line 265 and Line 271 clear the JSON state, but the mounted editor keeps the previous document. After an empty or temporarily invalid edit, the text panes say “nothing loaded” while the canvas still shows the last successful import.

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

In `@editor/app/`(dev)/canvas/tools/io-svg/page.tsx around lines 263 - 272, The
effect clears document JSON on failed conversion but doesn't update the mounted
canvas, leaving the last import visible; after the checks where
setDocumentJson(undefined) is called (in the branches around useEffect and after
editorInstance.svgToDocument(raw) returning falsy), also clear the mounted
editor via editorInstance by loading an empty document or calling its clear
method (e.g., editorInstance.loadDocument(undefined) or editorInstance.clear())
so the canvas and text panes stay in sync when raw is empty or conversion fails.

Comment thread editor/grida-canvas/editor.i.ts
Comment thread editor/grida-canvas/editor.ts Outdated
Comment thread packages/grida-canvas-io-svg/lib.ts Outdated
Comment on lines +276 to +301
const makeTextSpan = (
spanData: svgtypes.ir.IRSVGTextSpanNode,
spanName: string,
fallbackFill: cg.Paint | undefined
): grida.program.nodes.TextNodePrototype => {
const spanPosition = map.extractTranslation(spanData.transform);
const fontSize = spanData.font_size ?? 16;
const { paint: spanFill } = map.fill(spanData.fill);

const { paint: textFill } = map.fill(textFillAttr);
return {
type: "tspan",
name: spanName,
text: spanData.text,
font_size: fontSize,
font_weight: 400,
font_kerning: true,
text_decoration_line: "none",
text_align: "left",
text_align_vertical: "top",
layout_positioning: "absolute",
layout_inset_left: spanPosition.left,
layout_inset_top: spanPosition.top,
layout_target_width: undefined,
layout_target_height: undefined,
fill: spanFill ?? fallbackFill,
} satisfies grida.program.nodes.TextNodePrototype;
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

Use local offsets for grouped text spans.

The group is already positioned at textPosition, but makeTextSpan() still writes each child's absolute translation into layout_inset_*. Any <text> with a non-zero transform will therefore apply the parent offset twice. pack.rs already strips the parent transform before appending grouped spans; this path needs the same adjustment.

Suggested fix
-        const makeTextSpan = (
+        const makeTextSpan = (
           spanData: svgtypes.ir.IRSVGTextSpanNode,
           spanName: string,
-          fallbackFill: cg.Paint | undefined
+          fallbackFill: cg.Paint | undefined,
+          parentOffset: { left: number; top: number } = { left: 0, top: 0 }
         ): grida.program.nodes.TextNodePrototype => {
           const spanPosition = map.extractTranslation(spanData.transform);
           const fontSize = spanData.font_size ?? 16;
           const { paint: spanFill } = map.fill(spanData.fill);

           return {
@@
-            layout_inset_left: spanPosition.left,
-            layout_inset_top: spanPosition.top,
+            layout_inset_left: spanPosition.left - parentOffset.left,
+            layout_inset_top: spanPosition.top - parentOffset.top,
             layout_target_width: undefined,
             layout_target_height: undefined,
             fill: spanFill ?? fallbackFill,
           } satisfies grida.program.nodes.TextNodePrototype;
         };
@@
-        const children = spans
-          .map((span, index) =>
-            makeTextSpan(span, `${name}_line_${index}`, textFill)
-          )
+        const children = spans
+          .map((span, index) =>
+            makeTextSpan(span, `${name}_line_${index}`, textFill, textPosition)
+          )
           .filter(Boolean) as grida.program.nodes.NodePrototype[];

Also applies to: 331-349

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

In `@packages/grida-canvas-io-svg/lib.ts` around lines 276 - 301, makeTextSpan()
is using each span's absolute transform for layout_inset_* which double-applies
the parent text group's translation (textPosition); change it to use local
offsets by subtracting the parent textPosition from the span's translation
(e.g., localLeft = spanPosition.left - textPosition.left, localTop =
spanPosition.top - textPosition.top) and assign those local values to
layout_inset_left/top. Apply the same adjustment to the analogous span-handling
block around lines 331-349.

Comment thread packages/grida-canvas-io/format.ts Outdated
- Deleted the @grida/io-svg package, including its implementation, tests, and documentation, to streamline the codebase.
- Updated references in pnpm-lock.yaml and package.json to reflect the removal of the SVG I/O functionality.
- Introduced new tests for cross-boundary FBS decoding in the grida-canvas-io package to ensure continued functionality without the removed package.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

♻️ Duplicate comments (1)
crates/grida-canvas-wasm/lib/modules/svg.ts (1)

95-116: ⚠️ Potential issue | 🟠 Major

Don't call _deallocate() with the placeholder length.

If anything throws after _grida_svg_to_document() returns but before the 4-byte prefix is parsed, finally frees the buffer as 4 + 0. The Rust side reconstructs a Vec with that exact size, so this is the wrong free length.

Suggested fix
-    let resultLen: number = 0;
+    let resultLen: number | null = null;
@@
-      resultLen =
-        heap[resultPtr] |
-        (heap[resultPtr + 1] << 8) |
-        (heap[resultPtr + 2] << 16) |
-        (heap[resultPtr + 3] << 24);
+      resultLen =
+        (heap[resultPtr] |
+          (heap[resultPtr + 1] << 8) |
+          (heap[resultPtr + 2] << 16) |
+          (heap[resultPtr + 3] << 24)) >>> 0;
@@
-      if (resultPtr !== 0) {
+      if (resultPtr !== 0 && resultLen !== null) {
         this.module._deallocate(resultPtr, 4 + resultLen);
       }

At minimum, guard the free on resultLen !== null. If you want to keep the failure path leak-free too, re-read the 4-byte prefix in finally before deallocating.

#!/bin/bash
set -euo pipefail

# Compare the Rust allocation contract with the JS cleanup path.
sed -n '1,20p' crates/grida-canvas-wasm/src/_internal.rs
sed -n '76,108p' crates/grida-canvas-wasm/src/wasm_svg.rs
sed -n '92,121p' crates/grida-canvas-wasm/lib/modules/svg.ts

Expected result: Rust allocates 4 + len and expects the same size on free, while the current JS cleanup uses the placeholder 0 until parsing succeeds.

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

In `@crates/grida-canvas-wasm/lib/modules/svg.ts` around lines 95 - 116, The
finally block may call this.module._deallocate(resultPtr, 4 + resultLen) with
resultLen still 0 if an exception occurred before parsing the 4-byte length from
the buffer returned by this.module._grida_svg_to_document; update the cleanup in
the svg conversion path (symbols: _grida_svg_to_document, resultPtr, resultLen,
_deallocate, _alloc_string) so you either (a) only call _deallocate when
resultLen has been successfully read (guard on resultLen being set) or (b) if
resultPtr !== 0 always re-read the 4-byte length from this.module.HEAPU8 inside
the finally to compute the correct 4 + resultLen before deallocating, ensuring
the Rust allocation size matches the deallocation size.
🧹 Nitpick comments (4)
packages/grida-canvas-io/__tests__/fbs-svg-cross-boundary.test.ts (1)

54-54: Minor: Simplify it.each parameter mapping.

The mapping goldens.map((g) => [g]) wraps each string in an array just to destructure it back. You can pass the array directly and use the element.

♻️ Optional simplification
-  it.each(goldens.map((g) => [g]))("%s decodes successfully", (name) => {
+  it.each(goldens)("%s decodes successfully", (name) => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/grida-canvas-io/__tests__/fbs-svg-cross-boundary.test.ts` at line
54, The test is needlessly wrapping each golden name in an array with
goldens.map((g) => [g]); simplify the table by passing goldens directly to
it.each and keep the test callback parameter as the single name (replace
it.each(goldens.map((g) => [g])) with it.each(goldens) and ensure the test
callback signature still uses (name) to receive each string).
crates/grida-canvas/tests/svg_pack.rs (3)

1217-1243: This roundtrip check can miss rotation/skew loss.

The fixture includes rotate(30), but the test only looks for any scale and any translation. A codec regression that zeroes the off-diagonal terms would still pass. Assert that at least one group transform keeps non-zero m01/m10, or compare the expected matrices per group.

Possible strengthening
     let has_translate = group_transforms.iter().any(|t| {
         t.matrix[0][2].abs() > 0.1 || t.matrix[1][2].abs() > 0.1
     });
     assert!(
         has_translate,
         "at least one group should have translation"
     );
+
+    let has_rotation = group_transforms.iter().any(|t| {
+        t.matrix[0][1].abs() > 0.01 || t.matrix[1][0].abs() > 0.01
+    });
+    assert!(
+        has_rotation,
+        "at least one group should preserve the rotate(30) off-diagonal terms"
+    );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/tests/svg_pack.rs` around lines 1217 - 1243, The test
currently verifies only scale and translation on group_transforms, which misses
rotation/skew loss; update the assertions to also ensure at least one group's
off-diagonal matrix elements are non-trivial by checking t.matrix[0][1] or
t.matrix[1][0] exceed a small epsilon (e.g., > 0.01) or alternatively compare
each group against the known expected matrices for the fixture; locate the test
that builds/iterates group_transforms and add an assertion similar to
has_rotate_or_skew that uses t.matrix[0][1] and t.matrix[1][0] to fail if all
off-diagonals are effectively zero.

660-700: Avoid keying this regression off DFS indexes.

These assertions depend on the exact traversal order and on rect lowering to Path, so harmless scene-graph refactors can fail the test even when world transforms are still correct. Prefer locating the target nodes by a stable predicate and then asserting their transforms.

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

In `@crates/grida-canvas/tests/svg_pack.rs` around lines 660 - 700, The test
currently depends on DFS indexes (transforms[1], [2], [4], [6]) which is
fragile; replace those index-based assertions by locating nodes with stable
predicates over the transforms vector: use transforms.iter().find(...) (or
.position) to find the Group/Path entries by their label (e.g., "Group" /
"Path") and by checking the transform values expected for L1 (translate ≈
(250,250)) and the rect-derived path positions (≈ (150,150) for L1, and NOT ≈
(250,250) for L2/L3) using the existing approx closure; then run the same
asserts on the found entries (and panic with a clear message if .find returns
None) instead of indexing into transforms by fixed positions.

1288-1300: The “full 2x3 matrix” claim isn’t covered yet.

These assertions only validate the 2x2 rotation block. If FBS encoding drops the translation terms from rotate(45, 0.5, 0.5), this test still passes. Add checks for m[0][2] and m[1][2] too.

Possible strengthening
     assert!(
         approx(m[0][1], -cos45) && approx(m[1][0], cos45),
         "off-diagonal should be ±sin(45°), got [{:.4}, {:.4}]",
         m[0][1],
         m[1][0]
     );
+
+    let expected_tx: f32 = 0.5;
+    let expected_ty: f32 = 0.5 - cos45;
+    assert!(
+        approx(m[0][2], expected_tx) && approx(m[1][2], expected_ty),
+        "translation should preserve rotate(45°, 0.5, 0.5), got [{:.4}, {:.4}]",
+        m[0][2],
+        m[1][2]
+    );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/tests/svg_pack.rs` around lines 1288 - 1300, The test
currently only checks the 2x2 rotation block (m[0][0], m[1][1], m[0][1],
m[1][0]) but ignores the translation terms; compute the expected translation for
a rotation about (0.5,0.5) as expected_t = 0.5 * ((1.0 - cos45) + sin45) and add
assertions that approx(m[0][2], expected_t) and approx(m[1][2], expected_t) hold
(use the same approx helper and error messages), updating the assertions in the
test that references m, cos45, sin45, and approx so the full 2x3 matrix is
validated.
🤖 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/svg/pack.rs`:
- Around line 152-170: The code currently always assigns text.transform to the
wrapper group and then tries to compute a local span transform by inverting
text.transform; when text_affine.inverse() returns None this causes the span
transform to be treated as if it's in the group's local space and results in
double application. Fix by detecting the non-invertible case: if
text_affine.inverse() returns None, do not set the group's transform (clear
group.transform or avoid assigning it) and leave local = span_affine so spans
remain in parent coordinates; otherwise, keep group.transform =
Some(text.transform.into()) and compute local = inv.compose(&span_affine) as
before. Update the logic around create_group_node / group.transform and the
local computation accordingly.

In `@editor/grida-canvas/editor.ts`:
- Around line 716-723: The code currently picks the scene via
fullDoc.scenes_ref[0]; change this to use the document's semantic root by
selecting fullDoc.entry_scene_id ?? fullDoc.scenes_ref[0] and if that resolves
to undefined throw or return early (fail fast) so downstream logic (the sceneId
binding and the subsequent removals of fullDoc.nodes and fullDoc.links via {
[sceneId]: ... }) never operates on an undefined sceneId; update the variable
named sceneId accordingly and ensure the guard triggers a clear error when
neither entry_scene_id nor scenes_ref[0] exist.

In `@packages/grida-tree/src/lib.ts`:
- Around line 2398-2412: The current remapping in keyMap for subgraph.links and
roots uses fallbacks (keyMap[childKey] ?? childKey and keyMap[r] ?? r) which can
leave dangling references and cause imported parents to bind to existing nodes
in the destination graph; update the remapping in the block that builds links
and newRoots to fail fast: when iterating subgraph.links and roots, if keyMap
does not contain a mapped value for any childKey or root r, throw an explicit
error (include context like the missing key and reference to subgraph id)
instead of using the original id; adjust code that builds links (the links
variable) and newRoots accordingly so Graph.import() never receives stale IDs.
- Around line 2376-2380: In remapKeys, guard against duplicate outputs from the
keygen callback by detecting when two different original keys map to the same
generated key: after building keyMap from subgraph.nodes (using keygen), iterate
keyMap values and collect duplicates (track seen generated keys and the original
keys that produced them) and throw an explicit error (or return a failure)
listing the conflicting original keys and the duplicated generated key;
reference remapKeys, keyMap, keygen, subgraph.nodes and subgraph.links so you
locate where to add this duplicate-check before applying the remap to
nodes/links.

---

Duplicate comments:
In `@crates/grida-canvas-wasm/lib/modules/svg.ts`:
- Around line 95-116: The finally block may call
this.module._deallocate(resultPtr, 4 + resultLen) with resultLen still 0 if an
exception occurred before parsing the 4-byte length from the buffer returned by
this.module._grida_svg_to_document; update the cleanup in the svg conversion
path (symbols: _grida_svg_to_document, resultPtr, resultLen, _deallocate,
_alloc_string) so you either (a) only call _deallocate when resultLen has been
successfully read (guard on resultLen being set) or (b) if resultPtr !== 0
always re-read the 4-byte length from this.module.HEAPU8 inside the finally to
compute the correct 4 + resultLen before deallocating, ensuring the Rust
allocation size matches the deallocation size.

---

Nitpick comments:
In `@crates/grida-canvas/tests/svg_pack.rs`:
- Around line 1217-1243: The test currently verifies only scale and translation
on group_transforms, which misses rotation/skew loss; update the assertions to
also ensure at least one group's off-diagonal matrix elements are non-trivial by
checking t.matrix[0][1] or t.matrix[1][0] exceed a small epsilon (e.g., > 0.01)
or alternatively compare each group against the known expected matrices for the
fixture; locate the test that builds/iterates group_transforms and add an
assertion similar to has_rotate_or_skew that uses t.matrix[0][1] and
t.matrix[1][0] to fail if all off-diagonals are effectively zero.
- Around line 660-700: The test currently depends on DFS indexes (transforms[1],
[2], [4], [6]) which is fragile; replace those index-based assertions by
locating nodes with stable predicates over the transforms vector: use
transforms.iter().find(...) (or .position) to find the Group/Path entries by
their label (e.g., "Group" / "Path") and by checking the transform values
expected for L1 (translate ≈ (250,250)) and the rect-derived path positions (≈
(150,150) for L1, and NOT ≈ (250,250) for L2/L3) using the existing approx
closure; then run the same asserts on the found entries (and panic with a clear
message if .find returns None) instead of indexing into transforms by fixed
positions.
- Around line 1288-1300: The test currently only checks the 2x2 rotation block
(m[0][0], m[1][1], m[0][1], m[1][0]) but ignores the translation terms; compute
the expected translation for a rotation about (0.5,0.5) as expected_t = 0.5 *
((1.0 - cos45) + sin45) and add assertions that approx(m[0][2], expected_t) and
approx(m[1][2], expected_t) hold (use the same approx helper and error
messages), updating the assertions in the test that references m, cos45, sin45,
and approx so the full 2x3 matrix is validated.

In `@packages/grida-canvas-io/__tests__/fbs-svg-cross-boundary.test.ts`:
- Line 54: The test is needlessly wrapping each golden name in an array with
goldens.map((g) => [g]); simplify the table by passing goldens directly to
it.each and keep the test callback parameter as the single name (replace
it.each(goldens.map((g) => [g])) with it.each(goldens) and ensure the test
callback signature still uses (name) to receive each string).
🪄 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: 20583a0c-f6c5-45cf-94aa-2f73ade681cb

📥 Commits

Reviewing files that changed from the base of the PR and between f655aff and 29d4c7f.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (16)
  • crates/grida-canvas-wasm/lib/modules/svg.ts
  • crates/grida-canvas/src/cache/geometry.rs
  • crates/grida-canvas/src/svg/pack.rs
  • crates/grida-canvas/tests/svg_pack.rs
  • docs/wg/feat-svg/text-import.md
  • editor/grida-canvas/editor.i.ts
  • editor/grida-canvas/editor.ts
  • editor/package.json
  • packages/grida-canvas-io-svg/README.md
  • packages/grida-canvas-io-svg/__tests__/convert.test.ts
  • packages/grida-canvas-io-svg/index.ts
  • packages/grida-canvas-io-svg/lib.ts
  • packages/grida-canvas-io-svg/package.json
  • packages/grida-canvas-io/__tests__/fbs-svg-cross-boundary.test.ts
  • packages/grida-canvas-io/format.ts
  • packages/grida-tree/src/lib.ts
💤 Files with no reviewable changes (6)
  • packages/grida-canvas-io-svg/README.md
  • editor/package.json
  • packages/grida-canvas-io-svg/package.json
  • packages/grida-canvas-io-svg/index.ts
  • packages/grida-canvas-io-svg/lib.ts
  • packages/grida-canvas-io-svg/tests/convert.test.ts
✅ Files skipped from review due to trivial changes (1)
  • crates/grida-canvas/src/cache/geometry.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • editor/grida-canvas/editor.i.ts

Comment on lines +152 to +170
let mut group = self.factory.create_group_node();
group.transform = Some(text.transform.into());
let group_id = self.graph.append_child(Node::Group(group), parent);
let group_parent = Parent::NodeId(group_id);

for span in &text.spans {
// Each chunk's transform already includes the text's relative
// transform (applied in convert_text). Since the group also
// carries the text transform, we need to strip it from each
// chunk to avoid double-application. The chunk transform is
// `text_relative * translate(chunk_x, chunk_y)`, so the local
// transform relative to the group is just `translate(chunk_x, chunk_y)`.
let text_affine: AffineTransform = text.transform.into();
let span_affine: AffineTransform = span.transform.into();
let local = if let Some(inv) = text_affine.inverse() {
inv.compose(&span_affine)
} else {
span_affine
};
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

Handle singular text transforms without double-applying them.

When text.transform.inverse() returns None, span_affine is already in the parent coordinate space. Reusing it under a wrapper group that still carries text.transform applies the text matrix twice.

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

In `@crates/grida-canvas/src/svg/pack.rs` around lines 152 - 170, The code
currently always assigns text.transform to the wrapper group and then tries to
compute a local span transform by inverting text.transform; when
text_affine.inverse() returns None this causes the span transform to be treated
as if it's in the group's local space and results in double application. Fix by
detecting the non-invertible case: if text_affine.inverse() returns None, do not
set the group's transform (clear group.transform or avoid assigning it) and
leave local = span_affine so spans remain in parent coordinates; otherwise, keep
group.transform = Some(text.transform.into()) and compute local =
inv.compose(&span_affine) as before. Update the logic around create_group_node /
group.transform and the local computation accordingly.

Comment on lines +716 to +723
const sceneId = fullDoc.scenes_ref[0];
const sceneChildren = fullDoc.links[sceneId] ?? [];

// Remove the scene node itself — we're inserting into the editor's
// existing scene, not creating a new one.
const { [sceneId]: _sceneNode, ...contentNodes } = fullDoc.nodes;
const { [sceneId]: _sceneLinks, ...contentLinks } = fullDoc.links;

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 document’s entry scene here.

This path decodes a full document, but it imports scenes_ref[0]. Elsewhere in this file the semantic root is entry_scene_id, so this becomes order-dependent and degrades badly when the decoded document has no first scene. Prefer fullDoc.entry_scene_id ?? fullDoc.scenes_ref[0] and fail fast if that value is missing.

Suggested guard
-    const sceneId = fullDoc.scenes_ref[0];
+    const sceneId = fullDoc.entry_scene_id ?? fullDoc.scenes_ref[0];
+    if (!sceneId) {
+      throw new Error("Decoded SVG document has no entry scene");
+    }
     const sceneChildren = fullDoc.links[sceneId] ?? [];
🤖 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 716 - 723, The code currently
picks the scene via fullDoc.scenes_ref[0]; change this to use the document's
semantic root by selecting fullDoc.entry_scene_id ?? fullDoc.scenes_ref[0] and
if that resolves to undefined throw or return early (fail fast) so downstream
logic (the sceneId binding and the subsequent removals of fullDoc.nodes and
fullDoc.links via { [sceneId]: ... }) never operates on an undefined sceneId;
update the variable named sceneId accordingly and ensure the guard triggers a
clear error when neither entry_scene_id nor scenes_ref[0] exist.

Comment on lines +2376 to +2380
// 1. Build the mapping
const keyMap: Record<tree.Key, tree.Key> = {};
for (const oldKey of Object.keys(subgraph.nodes)) {
keyMap[oldKey] = keygen(oldKey);
}
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

Guard remapKeys() against duplicate outputs from keygen.

A repeated generated key silently overwrites the first node/link pair, so one bad callback can drop part of the imported subtree with no error.

Suggested fix
       const keyMap: Record<tree.Key, tree.Key> = {};
+      const seen = new Set<tree.Key>();
       for (const oldKey of Object.keys(subgraph.nodes)) {
-        keyMap[oldKey] = keygen(oldKey);
+        const newKey = keygen(oldKey);
+        if (seen.has(newKey)) {
+          throw new Error(`remapKeys: duplicate generated key '${newKey}'`);
+        }
+        seen.add(newKey);
+        keyMap[oldKey] = newKey;
       }
📝 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
// 1. Build the mapping
const keyMap: Record<tree.Key, tree.Key> = {};
for (const oldKey of Object.keys(subgraph.nodes)) {
keyMap[oldKey] = keygen(oldKey);
}
// 1. Build the mapping
const keyMap: Record<tree.Key, tree.Key> = {};
const seen = new Set<tree.Key>();
for (const oldKey of Object.keys(subgraph.nodes)) {
const newKey = keygen(oldKey);
if (seen.has(newKey)) {
throw new Error(`remapKeys: duplicate generated key '${newKey}'`);
}
seen.add(newKey);
keyMap[oldKey] = newKey;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/grida-tree/src/lib.ts` around lines 2376 - 2380, In remapKeys, guard
against duplicate outputs from the keygen callback by detecting when two
different original keys map to the same generated key: after building keyMap
from subgraph.nodes (using keygen), iterate keyMap values and collect duplicates
(track seen generated keys and the original keys that produced them) and throw
an explicit error (or return a failure) listing the conflicting original keys
and the duplicated generated key; reference remapKeys, keyMap, keygen,
subgraph.nodes and subgraph.links so you locate where to add this
duplicate-check before applying the remap to nodes/links.

Comment on lines +2398 to +2412
// 3. Remap links
const links: Record<tree.Key, tree.Key[] | undefined> = {};
for (const [oldKey, children] of Object.entries(subgraph.links)) {
const newKey = keyMap[oldKey]!;
if (Array.isArray(children)) {
links[newKey] = children.map(
(childKey: tree.Key) => keyMap[childKey] ?? childKey
);
} else {
links[newKey] = undefined;
}
}

// 4. Remap roots
const newRoots = roots.map((r) => keyMap[r] ?? r);
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

Fail fast on dangling link/root references.

The ?? childKey / ?? r fallback keeps old IDs alive. Graph.import() later copies subgraph.links verbatim, so a stale child ID can accidentally bind an imported parent to an existing node in the destination graph instead of staying inside the imported subgraph.

Suggested fix
       const links: Record<tree.Key, tree.Key[] | undefined> = {};
       for (const [oldKey, children] of Object.entries(subgraph.links)) {
-        const newKey = keyMap[oldKey]!;
+        const newKey = keyMap[oldKey];
+        if (newKey === undefined) {
+          throw new Error(
+            `remapKeys: link parent '${oldKey}' is missing from subgraph.nodes`
+          );
+        }
         if (Array.isArray(children)) {
-          links[newKey] = children.map(
-            (childKey: tree.Key) => keyMap[childKey] ?? childKey
-          );
+          links[newKey] = children.map((childKey: tree.Key) => {
+            const mappedChild = keyMap[childKey];
+            if (mappedChild === undefined) {
+              throw new Error(
+                `remapKeys: child '${childKey}' referenced by '${oldKey}' is missing from subgraph.nodes`
+              );
+            }
+            return mappedChild;
+          });
         } else {
           links[newKey] = undefined;
         }
       }
 
       // 4. Remap roots
-      const newRoots = roots.map((r) => keyMap[r] ?? r);
+      const newRoots = roots.map((r) => {
+        const mappedRoot = keyMap[r];
+        if (mappedRoot === undefined) {
+          throw new Error(
+            `remapKeys: root '${r}' is missing from subgraph.nodes`
+          );
+        }
+        return mappedRoot;
+      });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/grida-tree/src/lib.ts` around lines 2398 - 2412, The current
remapping in keyMap for subgraph.links and roots uses fallbacks
(keyMap[childKey] ?? childKey and keyMap[r] ?? r) which can leave dangling
references and cause imported parents to bind to existing nodes in the
destination graph; update the remapping in the block that builds links and
newRoots to fail fast: when iterating subgraph.links and roots, if keyMap does
not contain a mapped value for any childKey or root r, throw an explicit error
(include context like the missing key and reference to subgraph id) instead of
using the original id; adjust code that builds links (the links variable) and
newRoots accordingly so Graph.import() never receives stale IDs.

- Introduced a new `turbo.json` file to define task dependencies, specifically linking the `test` task to the `build` task.
- This configuration aims to streamline the build and testing process within the project.
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