Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6935ef0
fix(embed): update iframe and embed source URLs from 'refig' to 'figma'
softmarshmallow Apr 2, 2026
7047401
feat(embed): enhance embed functionality with new endpoints and file …
softmarshmallow Apr 2, 2026
28f96a8
fix(io): build FeNoise table offsets outside FlatBuffers vector block
softmarshmallow Apr 2, 2026
212ef5a
feat(tools): add Affine Transform Visualizer tool and update sitemap
softmarshmallow Apr 2, 2026
7adc37d
feat(embed): add keyboard shortcuts for node navigation and viewport …
softmarshmallow Apr 2, 2026
d2df60a
fix(cg): include content origin offset in PathNode/Polygon/Vector geo…
softmarshmallow Apr 3, 2026
6ad2760
feat(io-figma): support fillOverrideTable for per-region fill overrides
softmarshmallow Apr 3, 2026
9f2fd3e
fix(cg): convert Container/Tray rotation from degrees to radians in s…
softmarshmallow Apr 4, 2026
b877113
fix(io-figma): propagate all non-identity container transforms into c…
softmarshmallow Apr 4, 2026
7c0e48e
fix(io-figma): skip redundant strokeGeometry on BOOLEAN_OPERATION wit…
softmarshmallow Apr 4, 2026
9f4c457
fmt
softmarshmallow Apr 4, 2026
de58e8c
feat(io-figma): faux-list rendering for Figma list import
softmarshmallow Apr 4, 2026
3f58dae
docs(wg): document Figma strokeGeometry alignment as compositing inst…
softmarshmallow Apr 4, 2026
28fff00
fix(io-figma): handle strokeAlign as compositing instruction via pain…
softmarshmallow Apr 4, 2026
49dbcf7
Merge branch 'main' of https://github.com/gridaco/grida into canary
softmarshmallow Apr 4, 2026
43f5844
merge chore
softmarshmallow Apr 4, 2026
624cef8
feat(io-figma): implement INSIDE strokeAlign via boolean intersection
softmarshmallow Apr 4, 2026
6f5f482
feat(uxhost-menu): enable geometry path preference in PlaygroundMenuC…
softmarshmallow Apr 4, 2026
8815d39
wasm 0.91.0-canary.17
softmarshmallow Apr 4, 2026
a859954
fix test
softmarshmallow Apr 4, 2026
7e00970
fix(io-figma): address PR review — AABB child rebase, H/V TODOs, doc …
softmarshmallow Apr 4, 2026
b6c7e31
refig - bump wasm
softmarshmallow Apr 4, 2026
ce1d9a6
docs(refig): enhance documentation for headless rendering and interac…
softmarshmallow Apr 4, 2026
653d147
refactor(embed): update file conversion methods for in-memory processing
softmarshmallow Apr 4, 2026
7b2fbe8
feat(cli): add convert command for Figma to Grida format conversion
softmarshmallow Apr 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .agents/skills/io-grida/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,27 @@ When you need to invalidate old files (field renumbering, semantic changes, remo
Format: `MAJOR.MINOR.PATCH-prerelease+build` (e.g. `"0.91.0-beta+20260311"`).

See `format/AGENTS.md` for the full review checklist.

## Debugging FlatBuffers Issues

### Verifying bytes

Use `flatbuffers::root::<fbs::GridaFile>(&bytes)` (not `root_unchecked`) in a Rust test to run the FlatBuffers verifier. It reports the exact field chain with the bad offset.

```rust
use cg::io::generated::grida::grida as fbs;
let result = flatbuffers::root::<fbs::GridaFile>(&bytes);
```

Note: the TS FlatBuffers decoder is more lenient than Rust — a TS-side round-trip may pass even when the bytes are structurally invalid. Always verify with the Rust verifier.

### Inspecting .grida files

```sh
cargo run --example tool_io_grida -- path/to/file.grida --list-scenes
cargo run --example tool_io_grida -- path/to/file.grida --scene 0 --verbose
```

### Cross-boundary tests

`crates/grida-canvas-wasm/lib/__test__/bench-load-scene.test.ts` includes tests that TS-encode → WASM-decode `.grida` fixtures. These catch issues that only surface across the TS→Rust boundary.
50 changes: 50 additions & 0 deletions crates/grida-canvas-wasm/lib/__test__/bench-load-scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@ import { readFileSync, existsSync, readdirSync } from "node:fs";
import { resolve } from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
import { Scene } from "../modules/canvas";
import { io } from "../../../../packages/grida-canvas-io/index";

/** Directory for local (gitignored) benchmark fixtures. */
const LOCAL_FIXTURES_DIR = resolve(__dirname, "fixtures/local");

/** Shared test fixtures (committed). */
const SHARED_FIXTURES_DIR = resolve(__dirname, "../../../../fixtures/test-grida");

let module: any;

beforeAll(async () => {
Expand Down Expand Up @@ -149,3 +153,49 @@ describe("bench: load_scene (WASM-on-Node)", () => {
});
}
});

describe("cross-boundary: TS encode → WASM decode", () => {
/**
* Shared .grida fixtures: TS decodes → re-encodes → WASM loads → switchScene.
* Validates that the TS FlatBuffers encoder produces bytes the Rust decoder accepts.
*/
const sharedFixtures = existsSync(SHARED_FIXTURES_DIR)
? readdirSync(SHARED_FIXTURES_DIR)
.filter((f) => f.endsWith(".grida"))
.sort()
: [];

function createFile(name: string, bytes: Uint8Array): File {
const blob = new Blob([bytes as BlobPart], {
type: "application/octet-stream",
});
return new File([blob], name, { type: "application/octet-stream" });
}

for (const fixture of sharedFixtures) {
it(`${fixture}: TS re-encode → WASM loadSceneGrida → switchScene`, async () => {
const raw = new Uint8Array(
readFileSync(resolve(SHARED_FIXTURES_DIR, fixture))
);
const file = createFile(fixture, raw);
const loaded = await io.load(file);
if (loaded.document.scenes_ref.length === 0) return;

const sceneId = loaded.document.scenes_ref[0]!;
const reEncoded = io.GRID.encode(loaded.document);

const scene = createRasterScene();
scene.loadSceneGrida(reEncoded);

const wasmIds = scene.loadedSceneIds();
expect(wasmIds).toContain(sceneId);
expect(() => scene.switchScene(sceneId)).not.toThrow();

scene.dispose();
});
Comment on lines +176 to +195
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 try/finally to always dispose WASM scenes.

If any assertion fails before Line 194, the scene is not disposed. Wrap the body in try/finally so cleanup is guaranteed.

♻️ Suggested fix
   for (const fixture of sharedFixtures) {
     it(`${fixture}: TS re-encode → WASM loadSceneGrida → switchScene`, async () => {
@@
-      const scene = createRasterScene();
-      scene.loadSceneGrida(reEncoded);
-
-      const wasmIds = scene.loadedSceneIds();
-      expect(wasmIds).toContain(sceneId);
-      expect(() => scene.switchScene(sceneId)).not.toThrow();
-
-      scene.dispose();
+      const scene = createRasterScene();
+      try {
+        scene.loadSceneGrida(reEncoded);
+
+        const wasmIds = scene.loadedSceneIds();
+        expect(wasmIds).toContain(sceneId);
+        expect(() => scene.switchScene(sceneId)).not.toThrow();
+      } finally {
+        scene.dispose();
+      }
     });
   }
📝 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
it(`${fixture}: TS re-encode → WASM loadSceneGrida → switchScene`, async () => {
const raw = new Uint8Array(
readFileSync(resolve(SHARED_FIXTURES_DIR, fixture))
);
const file = createFile(fixture, raw);
const loaded = await io.load(file);
if (loaded.document.scenes_ref.length === 0) return;
const sceneId = loaded.document.scenes_ref[0]!;
const reEncoded = io.GRID.encode(loaded.document);
const scene = createRasterScene();
scene.loadSceneGrida(reEncoded);
const wasmIds = scene.loadedSceneIds();
expect(wasmIds).toContain(sceneId);
expect(() => scene.switchScene(sceneId)).not.toThrow();
scene.dispose();
});
it(`${fixture}: TS re-encode → WASM loadSceneGrida → switchScene`, async () => {
const raw = new Uint8Array(
readFileSync(resolve(SHARED_FIXTURES_DIR, fixture))
);
const file = createFile(fixture, raw);
const loaded = await io.load(file);
if (loaded.document.scenes_ref.length === 0) return;
const sceneId = loaded.document.scenes_ref[0]!;
const reEncoded = io.GRID.encode(loaded.document);
const scene = createRasterScene();
try {
scene.loadSceneGrida(reEncoded);
const wasmIds = scene.loadedSceneIds();
expect(wasmIds).toContain(sceneId);
expect(() => scene.switchScene(sceneId)).not.toThrow();
} finally {
scene.dispose();
}
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas-wasm/lib/__test__/bench-load-scene.test.ts` around lines
176 - 195, The test creates a WASM scene via createRasterScene() and may
return/dispose only on the success path, so wrap the test body after creating
scene in a try/finally: call createRasterScene() to produce scene, then place
all operations (scene.loadSceneGrida, loaded checks, expect(...) assertions,
scene.switchScene) inside a try block and call scene.dispose() unconditionally
in the finally block to guarantee cleanup even if an assertion throws.

}

if (sharedFixtures.length === 0) {
it("no shared .grida fixtures found (skipped)", () => {});
}
});
2 changes: 1 addition & 1 deletion crates/grida-canvas-wasm/lib/bin/grida-canvas-wasm.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions crates/grida-canvas-wasm/lib/bin/grida_canvas_wasm.wasm
Git LFS file not shown
90 changes: 89 additions & 1 deletion crates/grida-canvas-wasm/lib/modules/canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ const ApplicationCommandID = {
Pan: 4,
SelectAll: 5,
DeselectAll: 6,
SelectChildren: 7,
SelectParent: 8,
SelectNextSibling: 9,
SelectPreviousSibling: 10,
ZoomToFit: 13,
ZoomToSelection: 14,
ZoomTo100: 15,
} as const;

// Surface response bitmask (matches Rust pack_surface_response)
Expand Down Expand Up @@ -766,6 +773,82 @@ export class Scene {
this.module._command(this.appptr, ApplicationCommandID.DeselectAll, 0, 0);
}

/**
* Navigate selection to direct children of the current selection (Enter).
*/
selectChildren() {
this._assertAlive();
this.module._command(
this.appptr,
ApplicationCommandID.SelectChildren,
0,
0
);
}

/**
* Navigate selection to parent of the current selection (Shift+Enter).
*/
selectParent() {
this._assertAlive();
this.module._command(this.appptr, ApplicationCommandID.SelectParent, 0, 0);
}

/**
* Navigate selection to next sibling, wrapping around (Tab).
*/
selectNextSibling() {
this._assertAlive();
this.module._command(
this.appptr,
ApplicationCommandID.SelectNextSibling,
0,
0
);
}

/**
* Navigate selection to previous sibling, wrapping around (Shift+Tab).
*/
selectPreviousSibling() {
this._assertAlive();
this.module._command(
this.appptr,
ApplicationCommandID.SelectPreviousSibling,
0,
0
);
}

/**
* Zoom the viewport to fit all content (Shift+1).
*/
zoomToFit() {
this._assertAlive();
this.module._command(this.appptr, ApplicationCommandID.ZoomToFit, 0, 0);
}

/**
* Zoom the viewport to fit the current selection (Shift+2).
*/
zoomToSelection() {
this._assertAlive();
this.module._command(
this.appptr,
ApplicationCommandID.ZoomToSelection,
0,
0
);
}

/**
* Reset viewport zoom to 100% (Shift+0).
*/
zoomTo100() {
this._assertAlive();
this.module._command(this.appptr, ApplicationCommandID.ZoomTo100, 0, 0);
}

highlightStrokes(opts?: {
nodes?: string[];
style?: { strokeWidth?: number; stroke?: string };
Expand Down Expand Up @@ -1184,7 +1267,12 @@ const PAINT_TYPE_MAP: Record<string, string> = {
/**
* Convert a CGColor from Rust u8 array `[r, g, b, a]` to JS RGBA32F object.
*/
function convertColor(c: number[]): { r: number; g: number; b: number; a: number } {
function convertColor(c: number[]): {
r: number;
g: number;
b: number;
a: number;
} {
return { r: c[0] / 255, g: c[1] / 255, b: c[2] / 255, a: c[3] / 255 };
}

Expand Down
4 changes: 2 additions & 2 deletions crates/grida-canvas-wasm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@grida/canvas-wasm",
"version": "0.91.0-canary.16",
"version": "0.91.0-canary.17",
"private": false,
"description": "WASM bindings for Grida Canvas",
"keywords": [
Expand All @@ -23,7 +23,7 @@
"build": "tsup",
"dev": "tsup --watch",
"prepack": "just build",
"prepublishOnly": "[ $(du -sk dist 2>/dev/null | cut -f1) -lt 15360 ]",
"prepublishOnly": "[ $(du -sk dist 2>/dev/null | cut -f1) -lt 20480 ]",
"serve": "serve -p 4020",
"test": "vitest run",
"typecheck": "tsc --noEmit"
Expand Down
32 changes: 28 additions & 4 deletions crates/grida-canvas/src/cache/geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ struct GeoInput {
transform: AffineTransform,
width: f32,
height: f32,
/// Content origin offset within the node's local space.
/// Non-zero for Path, Polygon, and Vector nodes whose shape data
/// is offset from the transform origin.
content_origin_x: f32,
content_origin_y: f32,
kind: GeoNodeKind,
render_bounds_inflation: RenderBoundsInflation,
}
Expand Down Expand Up @@ -472,8 +477,8 @@ impl GeometryCache {

GeoNodeKind::Leaf => {
let local_bounds = Rectangle {
x: 0.0,
y: 0.0,
x: geo.content_origin_x,
y: geo.content_origin_y,
width: geo.width,
height: geo.height,
};
Expand Down Expand Up @@ -552,13 +557,17 @@ impl GeometryCache {
/// Build a `GeoInput` directly from schema data, bypassing any layout result.
fn geo_input_from_schema(geo: &NodeGeoData) -> GeoInput {
GeoInput {
// geo.rotation is in degrees (from Container/Tray); convert to
// radians for AffineTransform::new which expects radians.
transform: AffineTransform::new(
geo.schema_transform.x(),
geo.schema_transform.y(),
geo.rotation,
geo.rotation.to_radians(),
),
width: geo.schema_width,
height: geo.schema_height,
content_origin_x: geo.content_origin_x,
content_origin_y: geo.content_origin_y,
kind: geo.kind,
render_bounds_inflation: geo.render_bounds_inflation,
}
Expand Down Expand Up @@ -586,22 +595,33 @@ fn resolve_layout(
transform: geo.schema_transform,
width: geo.schema_width,
height: geo.schema_height,
content_origin_x: 0.0,
content_origin_y: 0.0,
kind: geo.kind,
render_bounds_inflation: geo.render_bounds_inflation,
},
GeoNodeKind::InitialContainer => GeoInput {
transform: geo.schema_transform,
width: viewport_size.width,
height: viewport_size.height,
content_origin_x: 0.0,
content_origin_y: 0.0,
kind: geo.kind,
render_bounds_inflation: geo.render_bounds_inflation,
},
GeoNodeKind::Container => {
if let Some(computed) = layout_result.and_then(|r| r.get(id)) {
GeoInput {
transform: AffineTransform::new(computed.x, computed.y, geo.rotation),
// geo.rotation is in degrees; convert to radians.
transform: AffineTransform::new(
computed.x,
computed.y,
geo.rotation.to_radians(),
),
width: computed.width,
height: computed.height,
content_origin_x: 0.0,
content_origin_y: 0.0,
kind: geo.kind,
render_bounds_inflation: geo.render_bounds_inflation,
}
Expand Down Expand Up @@ -684,6 +704,8 @@ fn resolve_layout(
transform: local_transform,
width,
height,
content_origin_x: 0.0,
content_origin_y: 0.0,
kind: geo.kind,
render_bounds_inflation: geo.render_bounds_inflation,
}
Expand Down Expand Up @@ -727,6 +749,8 @@ fn resolve_layout(
transform: local_transform,
width,
height,
content_origin_x: geo.content_origin_x,
content_origin_y: geo.content_origin_y,
kind: geo.kind,
render_bounds_inflation: geo.render_bounds_inflation,
}
Expand Down
Loading
Loading