Grida Canvas: introduce Tray organizational primitive#606
Grida Canvas: introduce Tray organizational primitive#606softmarshmallow merged 15 commits intomainfrom
Conversation
Add Tray — a canvas-level organizational primitive analogous to Figma SECTION. Has explicit dimensions, fills, strokes, and corner radius; never clips or applies effects; children are freely-placed root-level containers. - schema: TrayNodeRec, NodeTypeTag::Tray, GeoNodeKind::Tray - FlatBuffers: Tray table added to grida.fbs; generated grida.rs updated - I/O: From<JSONGroupNode> for TrayNodeRec; FBS encode/decode round-trip - layout: Tray excluded from Taffy (independently-positioned children) - painter: Tray renders fills/strokes/corner_radius without clip_path - geometry: geo_input_from_schema() helper; Tray always resolves from schema - fixtures: l0_tray scene with five Tray variants - docs: docs/wg/feat-tray/index.md design doc
Introduce a new `--overlay` flag for benchmarks to measure the combined cost of content rendering and SurfaceUI overlay drawing. This includes updates to the benchmark arguments, runner, and relevant documentation. The overlay cost is now accounted for in performance metrics, allowing for better analysis of rendering performance with UI elements. Additionally, ensure GPU benchmarks are run sequentially to avoid unreliable results due to resource contention.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (2)
✅ Files skipped from review due to trivial changes (1)
WalkthroughAdds a new "Tray" node across schema, I/O, editor, renderer, queries, tests, and docs; introduces opt-in SurfaceUI overlay benchmarking with per-frame overlay draw/flush; migrates gradient shader construction to Skia gradient APIs; and adds paint-group query APIs with WASM/FFI bindings. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant User as User
participant Editor as Editor
participant IO as IO/Import
participant Scene as SceneGraph
participant Renderer as Renderer
participant GPU as GPU
participant Bench as BenchRunner
User->>Editor: Insert Tray (Shift+F) / edit properties
Editor->>IO: Encode/export tray (JSON/FBS)
IO->>Scene: Decode → Node::Tray / update scene graph
Scene->>Renderer: Flatten layers (tray → shape layer)
Renderer->>GPU: flush() (content submission)
Bench->>Renderer: if --overlay enabled, draw SurfaceUI overlay
Renderer->>Renderer: flush_overlay() (submit overlay draws)
Renderer->>GPU: flush overlay submission
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
…nts, and full editor integration - Add "tray" to insert ToolMode, bind Shift+F hotkey - Implement initialNode factory with defaults (white fill, 10% black stroke, corner radius 2) - Add TrayWidget DOM renderer, register in node switch and renderer index - Enforce tray parent constraint: tray can only be child of Scene or another Tray - self_moveNode rejects invalid reparenting - Canvas translate hierarchy change respects tray constraint - Insert-and-resize auto-wrap checks moveNode return value before adjusting position - Add tray to graph policy (can_be_parent), layer tree (isItemFolder), insertion targeting - Add allows_hierarchy_change for tray (children can enter/exit during canvas drag) - Add ICSSDimension, IRectangularShapeTrait, rotation to TrayNode schema - Add tray to property supports map (fill, stroke, cornerRadius, children, strokeWidth) - Add SquareDashedIcon for tray in layer panel - Add user-facing docs, manual test cases, and 8 unit tests (all passing) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add Tray tool option to the toolbar and update the corresponding UI components - Implement shortcut for Tray tool using Shift+F - Refactor ToolGroupItem to ToolsGroup for better management of tool options - Ensure Tray tool is available in both the playground and starter kit toolbars
…t API Migrate gradient.rs from deprecated Shader::*_gradient() methods to shaders::linear_gradient/radial_gradient/sweep_gradient with the new GradientColors + Gradient + Interpolation types. Verified 7/8 gradient goldens byte-identical; radial has max Δ6/255 from upstream Skia rasterizer change (m132→m134), not the API migration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Update golden_sk_mask, golden_sk_text_backdrop_blur_path, and skia_bench_cache_image examples from Shader::*_gradient() to shaders::*_gradient() with the new Gradient/GradientColors types. Resolves all cargo check warnings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5830e68c98
ℹ️ 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".
| // If node is a container or tray, use it as target parent (paste as child) | ||
| if (node.type === "container" || node.type === "tray") { | ||
| return node_id; |
There was a problem hiding this comment.
Reject non-tray paste parents when clipboard contains trays
resolvePasteTargetParents now accepts both container and tray targets unconditionally, but it never checks whether the copied subtree includes a tray node. If a user copies a tray and pastes with a container selected, this returns the container ID and paste inserts the tray under that container, violating the new invariant that trays may only be children of scene/tray. This creates hierarchy states that move/transform explicitly reject, so paste behavior is inconsistent with the rest of the editor rules.
Useful? React with 👍 / 👎.
| draft.tool.node === "tray" | ||
| ? __get_tray_insertion_target(draft) | ||
| : __get_insertion_target(draft); |
There was a problem hiding this comment.
Include trays in standard insert parent targeting
The insert drag-start path routes all non-tray tools through __get_insertion_target, so this branch decides parent assignment for rectangle/text/image/etc insertions. Because that helper still only resolves container hits, drawing inside a tray falls back to scene-level insertion instead of parenting into the tray, which breaks the expected “tray behaves like a container for child insertion” interaction.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 18
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
format/grida.fbs (1)
257-264:⚠️ Potential issue | 🟠 MajorAppending
Trayhere renumbers the existingNodeTypewire values.Line 263 inserts
Trayin the middle of the enum, so every variant afterGroupshifts to a new numeric value. That breaks the stability of any persisted/generated consumer that relies onNodeType.Suggested fix
enum NodeType : ubyte { Exception, Scene, // groups and containers Group, - Tray, InitialContainer, Container, BooleanOperation, // shapes Rectangle, Ellipse, Polygon, RegularPolygon, RegularStarPolygon, Path, Line, Vector, // text TextSpan, + Tray, }As per coding guidelines, "Append new enum variants to FlatBuffers enums; never reorder or renumber existing variants."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@format/grida.fbs` around lines 257 - 264, The new enum variant was inserted in the middle of the FlatBuffers enum NodeType which renumbers subsequent wire values; move the newly added Tray variant to the end of the NodeType definition (append, do not insert or reorder existing entries) so existing numeric values remain stable, and ensure any comments/documentation reflect the append-only change for future additions.packages/grida-canvas-schema/grida.ts (1)
2863-2882:⚠️ Potential issue | 🟠 MajorDon't route
traythrough theUnknownNodefallback.Line 2865 makes
trayshare the generic fallback that only seeds a partial node shape. A minimalTrayNodePrototypepassed throughcreate_packed_scene_document_from_prototype()will therefore be missing required tray fields likelayout_positioning,layout_target_width,layout_target_height,stroke_width,stroke_cap, andstroke_join.Suggested fix
case "boolean": case "group": - case "tray": case "component": case "instance": case "template_instance": { // Remove children from prototype before spreading to prevent leakage const { children, ...prototypeWithoutChildren } = prototype as any; @@ id: id, } as UnknownNode; } + case "tray": { + const { children, ...prototypeWithoutChildren } = prototype as any; + return { + name: "tray", + type: "tray", + active: true, + locked: false, + opacity: 1, + rotation: 0, + layout_positioning: "absolute", + layout_target_width: + prototypeWithoutChildren.layout_target_width ?? 0, + layout_target_height: + prototypeWithoutChildren.layout_target_height ?? 0, + stroke_width: prototypeWithoutChildren.stroke_width ?? 0, + stroke_cap: prototypeWithoutChildren.stroke_cap ?? "butt", + stroke_join: prototypeWithoutChildren.stroke_join ?? "miter", + ...factory_default_traits.DEFAULT_RECTANGULAR_CORNER_RADIUS, + ...prototypeWithoutChildren, + id: id, + } satisfies TrayNode; + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/grida-canvas-schema/grida.ts` around lines 2863 - 2882, The switch currently routes "tray" into the generic UnknownNode fallback in the function that builds nodes from a prototype (the case handling that returns UnknownNode using prototypeWithoutChildren), which drops required Tray fields; update the switch so "tray" is handled explicitly instead of grouped with boolean/group/component/instance/template_instance: create a dedicated branch for "tray" that returns a TrayNode (or at minimum includes all required tray-specific properties such as layout_positioning, layout_target_width, layout_target_height, stroke_width, stroke_cap, stroke_join, etc.), seeding sensible defaults and spreading prototypeWithoutChildren to preserve incoming values, and ensure the returned object is typed as the Tray node type rather than UnknownNode.crates/grida-dev/src/bench/load_bench.rs (1)
203-225:⚠️ Potential issue | 🟡 MinorTeach
parent_infoabout tray parents too.Line 222 adds
Trayto the node-type label, but the adjacentparent_infomatch still falls through to"Leaf"for tray parents. That makeslayout-diffoutput misleading for exactly the new hierarchy this PR introduces.Suggested fix
scene.graph.get_node(&pid).ok().map(|p| match p { cg::node::schema::Node::Container(_) => "Container", + cg::node::schema::Node::Tray(_) => "Tray", cg::node::schema::Node::Group(_) => "Group", cg::node::schema::Node::BooleanOperation(_) => "BoolOp", cg::node::schema::Node::InitialContainer(_) => "ICB", _ => "Leaf", })Also applies to: 229-241
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/grida-dev/src/bench/load_bench.rs` around lines 203 - 225, The parent_info match needs to treat Tray nodes as parents rather than falling through to "Leaf": update the match that produces parent_info (the one inspecting scene.graph.get_node(&parent_id) / parent_info) to include the pattern cg::node::schema::Node::Tray(_) alongside other parent variants (e.g., Container, Group, InitialContainer) so tray parents return the appropriate non‑"Leaf" label; apply the same change to the other analogous parent_info block referenced in the diff (the second occurrence around lines 229–241) so both places handle Tray consistently with the new type_name addition.
🧹 Nitpick comments (7)
crates/grida-canvas/src/painter/gradient.rs (1)
86-97: Consolidate the opacity wrapper into a helper function.The opacity-mask pattern is duplicated identically across all four gradient builders (lines 86–97, 135–146, 188–199, 275–286). Additionally,
build_gradient_stops()rounds and clamps alpha values (line 13), while these four opacity wrappers truncate directly with(gradient.opacity * 255.0) as u8, introducing inconsistency. Extract a shared helper to unify the opacity wrapping logic and ensure consistent alpha conversion across all gradient types.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/grida-canvas/src/painter/gradient.rs` around lines 86 - 97, Extract a shared helper (e.g., wrap_with_opacity_mask(shader: skia_safe::Shader, opacity: f32) -> skia_safe::Shader) and replace the duplicated blocks in each gradient builder so they call this helper instead of duplicating the blend logic; inside the helper use the same alpha conversion and clamping logic as build_gradient_stops() (don’t cast via (opacity * 255.0) as u8—reuse or replicate its rounding/clamping) to create the skia_safe::Color for skia_safe::shaders::color, then return skia_safe::shaders::blend(skia_safe::BlendMode::DstIn, shader, opacity_shader) when opacity < 1.0 and the original shader otherwise, and update all four places that currently construct opacity_shader to call this helper.crates/grida-canvas/examples/skia_bench/skia_bench_cache_image.rs (1)
121-125: Eliminate heap allocations from the benchmark's timed path to reduce allocator noise.
draw_complex()runs inside the uncached measurement loop (run_benchmark(false, ...)), and theVec<Color4f>allocations at lines 121 and 201 for fixed-size color lists inject allocator overhead that obscures the drawing signal. Replace with stack arrays.♻️ Suggested replacement
- let colors4f: Vec<_> = colors.iter().map(|c| Color4f::from(*c)).collect(); + let colors4f = [ + Color4f::from(colors[0]), + Color4f::from(colors[1]), + Color4f::from(colors[2]), + ]; @@ - let radial_colors4f: Vec<_> = radial_colors.iter().map(|c| Color4f::from(*c)).collect(); + let radial_colors4f = [ + Color4f::from(radial_colors[0]), + Color4f::from(radial_colors[1]), + ];🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/grida-canvas/examples/skia_bench/skia_bench_cache_image.rs` around lines 121 - 125, The benchmark is allocating Vec<Color4f> inside draw_complex's timed path (the local colors4f Vec used when constructing gradient_shader::Gradient::new, and the other fixed-size color Vec created elsewhere in the same function), which adds allocator noise; replace those heap allocations with fixed-size stack arrays (e.g., define a [Color4f; N] by calling Color4f::from on each known color and pass a slice &colors4f to gradient_shader::GradientColors::new) and similarly ensure positions is a stack array or slice; update both places where a Vec<Color4f> is created so the gradient construction uses a stack-allocated &[Color4f] instead of a Vec to eliminate the allocation from the timed path.crates/grida-canvas/src/painter/geometry.rs (1)
275-302: Consider deduplicating tray/container rounded-rect shape construction.This tray branch is correct, but it now duplicates the container shape path almost verbatim. A shared helper for “bounds + corner radius/smoothing →
PainterShape” would reduce future drift.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/grida-canvas/src/painter/geometry.rs` around lines 275 - 302, Extract the duplicated logic that turns bounds + corner radius/smoothing into a PainterShape into a single helper (e.g., a function like shape_from_bounds_and_corners) and replace the Node::Tray branch with a call to that helper; the helper should accept width, height, corner_radius and corner_smoothing and internally call build_orthogonal_smooth_rrect_path + PainterShape::from_path when smoothing > 0, build_rrect + PainterShape::from_rrect when corner_radius > 0 and smoothing == 0, and Rect::from_xywh + PainterShape::from_rect when corner_radius is zero, so both the Tray code and the existing Container code can call the same function instead of duplicating the logic (references: Node::Tray, build_orthogonal_smooth_rrect_path, RRectShape, build_rrect, PainterShape::from_path, PainterShape::from_rrect, PainterShape::from_rect, Rect::from_xywh).crates/grida-canvas/src/painter/painter_debug_node.rs (1)
538-591: Extract the shared rectangular-parent rendering path.This branch now duplicates most of
Node::Container’s fill/children/stroke pipeline. A small helper parameterized by “effects” and “clip children” would reduce drift risk the next time one of these container-like nodes changes.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/grida-canvas/src/painter/painter_debug_node.rs` around lines 538 - 591, Extract the duplicated rectangular-parent rendering logic (fills → children → strokes) into a small helper method (e.g., render_rect_parent or render_container_like) and call it from Node::Tray (and the existing Node::Container) to avoid drift; the helper should accept the node id and node data, the cache and graph references, plus parameters for LayerEffects (effects) and a boolean for whether children are clipped (clip_children), and internally perform the steps currently duplicated: get local_transform via cache.get_transform(id), call self.painter.with_transform and with_opacity, compute bounds via cache.get_world_bounds(id), build_shape(node, &bounds), use math2::transform::AffineTransform::identity().matrix when calling self.painter.with_blendmode with the provided effects, call self.painter.draw_fills(&shape, &n.fills), iterate children via graph.get_children/get_node and either draw them directly or wrap with clipping depending on clip_children, then finally call self.painter.draw_strokes(...) using n.render_bounds_stroke_width() and n.stroke_style fields; replace the Node::Tray branch with a call to this new helper passing LayerEffects::default() and clip_children=false.test/canvas-tray-node-behavior.md (1)
2-5: Keep the manual spec's area token consistent.
area: hierarchydoesn't match eitherTC-CANVAS-TRAY-001or the filenamecanvas-tray-node-behavior.md, which makes this harder to group with the othercanvas/hierarchycases. Please use the same area token in the file name and test-case ID.As per coding guidelines, "Name manual test case files using the pattern
{module}-{area}-{short-description}.md" and "Assign unique IDs to manual test cases using the formatTC-{MODULE}-{AREA}-{NNN}, incrementing NNN within each module+area combination."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@test/canvas-tray-node-behavior.md` around lines 2 - 5, The area token in the test front-matter is incorrect: change the `area:` value from "hierarchy" to "tray" so it matches the file name `canvas-tray-node-behavior.md` and the test-case ID `TC-CANVAS-TRAY-001`; ensure the front-matter uses `module: canvas` and `area: tray` and that the test ID remains `TC-CANVAS-TRAY-001` to conform to the `{module}-{area}-{NNN}` naming guidelines.editor/grida-canvas/reducers/methods/__tests__/move-tray.test.ts (1)
192-202: Assert rejected tray moves are true no-ops.These cases only check the parent link. The reducer contract here is stricter: a rejected tray move should leave the node's placement/order untouched as well, so please snapshot the moved tray plus the affected link arrays and assert nothing else changed.
Also applies to: 289-307
crates/grida-canvas/src/node/schema.rs (1)
1567-1606: Consider extracting shared geometry/fill logic to reduce duplication.The
to_own_shape(),NodeFillsMixin, andNodeGeometryMixinimplementations are identical toContainerNodeRec. A macro or shared helper could reduce duplication, though keeping them separate is also reasonable if you anticipate divergence.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/grida-canvas/src/node/schema.rs` around lines 1567 - 1606, TrayNodeRec duplicates geometry/fill code present in ContainerNodeRec: extract the shared logic by creating a small helper or macro and reuse it in both implementations—move the to_own_shape conversion and the NodeFillsMixin/NodeGeometryMixin behavior into a shared function/macro (referencing to_own_shape, NodeFillsMixin::set_fill, set_fills, NodeGeometryMixin::has_stroke_geometry, render_bounds_stroke_width, rectangular_stroke_width) and call/expand that from TrayNodeRec and ContainerNodeRec so both types delegate to the same implementation rather than duplicating the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/grida-canvas/examples/tool_gen_fixtures.rs`:
- Line 52: Add round-trip serialization tests for the new Tray node type by
following existing patterns: in crates/grida-canvas/tests/fbs_roundtrip.rs add a
test that uses fixtures::l0_tray::build() (matching other tests like
SceneNode/RectangleNode) to encode and then decode the Tray and assert
equality/roundtrip integrity; and in
packages/grida-canvas-io/__tests__/format-roundtrip.test.ts add a Jest test that
imports the corresponding tray fixture and calls the same roundtrip helper used
by other node tests to ensure encode/decode parity. Ensure the tests reference
the fixture symbol (fixtures::l0_tray::build and the TS fixture import) and
mirror the assertions and setup used by existing node roundtrip tests.
In `@crates/grida-canvas/src/cache/geometry.rs`:
- Around line 406-445: The Tray branch currently ignores child return values and
returns only the tray rect; change GeoNodeKind::Tray (inside build_recursive) to
collect each child's returned bounds when iterating children, compute a union of
world_bounds with all child_bounds (and likewise union/expand render_bounds with
children's absolute_render_bounds), then use that unioned rectangle for the
GeometryEntry fields (absolute_bounding_box and absolute_render_bounds) and as
the function return value; keep local_bounds/world_transform calculation and
cache.entries.insert(*id, entry) but replace the stored/returned world_bounds
with the union so overflowing descendants are included in ancestor unions.
In `@crates/grida-canvas/src/io/io_grida_fbs.rs`:
- Line 3698: The SystemNodeTrait name is being hardcoded to "" when calling
encode_system_node_trait, which strips tray labels; update the call in
io_grida_fbs.rs to pass the actual node name (use the record's name field, e.g.,
r.name) instead of "" so SystemNodeTrait.name preserves tray labels — if the
name can be optional, pass an empty string only as a fallback when r.name is
None/empty. Ensure you update the invocation of encode_system_node_trait (the
call that currently reads encode_system_node_trait(fbb, node_id, "", r.active,
false)) to use the real name variable.
- Around line 3699-3706: The tray encoding is creating a concrete
layout_container by calling encode_container_layout(...) which materializes
layout_container: Some(...); instead, stop serializing a default container for
trays by either (A) replacing this call with the non-container encoder (e.g. use
encode_layout(...) / an equivalent helper that only encodes position +
layout_dimensions and leaves layout_container None) when you have no
container/child, or (B) change encode_container_layout to detect the combination
of LayoutContainerStyle::default() and child == None and return None for
layout_container (or otherwise avoid writing layout_container) so trays preserve
the "no layout" invariant; update the call site that currently invokes
encode_container_layout(fbb, &r.position, &r.layout_dimensions,
&LayoutContainerStyle::default(), &None) accordingly.
In `@crates/grida-canvas/src/io/io_grida.rs`:
- Around line 1319-1337: The current From<JSONGroupNode> for TrayNodeRec zeros
many visual/layout fields; update the conversion to copy the shared
rectangular/style fields from node.base into TrayNodeRec instead of defaults:
set rotation from node.base.rotation, position via
json_position_to_layout_basis(&node.base.position, ...) (matching
ContainerNodeRec usage), compute layout_dimensions from node.base.width/height
plus min/max/aspect constraints, map corner_radius and corner_smoothing from
node.base, convert fills and strokes from node.base.fills/strokes into Paints,
and set stroke_style/stroke_width from node.base (and keep mask mapping). Locate
the From implementation for TrayNodeRec and replace the defaulted assignments
with these mappings using the same helpers ContainerNodeRec already uses.
In `@crates/grida-canvas/src/node/schema.rs`:
- Around line 1442-1469: Add round-trip serialization/deserialization tests for
the newly added TrayNodeRec: update the Rust test file
crates/grida-canvas/tests/fbs_roundtrip.rs to construct a TrayNodeRec with
explicit fields (opacity, blend_mode, mask, rotation, position using multiple
LayoutPositioningBasis variants, layout_dimensions, corner_radius and
corner_smoothing, fills, strokes, stroke_style, stroke_width), serialize it to
the flatbuffer format and deserialize back asserting equality; also add a
matching test in packages/grida-canvas-io/__tests__/format-roundtrip.test.ts
that builds an equivalent TrayNodeRec object, round-trips it through the
TypeScript serializer/parser and asserts the resulting object preserves fills,
strokes, corner_radius and positioning variants. Ensure tests reference the
TrayNodeRec type and use existing helper functions in those test files for
round-trip encode/decode to keep style consistent.
In `@crates/grida-canvas/src/surface/ui/render.rs`:
- Around line 245-297: collect_labeled_nodes currently only handles root trays
and their immediate children, missing nested trays and containers; change the
Tray branch to recursively walk the tray subtree (e.g., DFS/BFS using
graph.get_children and graph.get_node) so that for each descendant Node::Tray
you push a badge NodeLabel (using root_id/child_id as appropriate and computing
badge_bg via first_solid_fill) and for each descendant Node::Container you push
a plain NodeLabel; ensure you still honor graph.get_name and skip nodes that
graph.get_node returns Err for, and prefer extracting the recursion into a small
helper inside collect_labeled_nodes to avoid duplicating logic.
In `@crates/grida-dev/src/bench/runner.rs`:
- Around line 92-98: measure_frame's new overlay flag isn't propagated to the
stable-frame path: measure_settle(renderer) still records only content settle
time, undercounting settle_us when --overlay is enabled; update the API/usage so
the overlay flag is passed into measure_settle (or add a parameter like overlay:
bool) and have measure_settle include overlay work when true so settle_us
reflects overlay cost; locate calls to measure_settle and the measure_frame
implementation and propagate the overlay boolean through those call sites and
into the settle timing logic to include overlay timing in the returned per-stage
breakdown.
- Around line 1176-1184: The frameloop path never receives the new overlay state
because run_frameloop_pan_pass() isn't being passed the ov() closure/value;
update the call site for run_frameloop_pan_pass (the function that mirrors
Application::frame()) to accept and use the same ov plumbing by passing ov() (or
its result) into it so it can construct/use OverlayBenchState when overlay is
true; ensure the signature and internal use of run_frameloop_pan_pass are
adjusted to accept the overlay parameter/closure consistent with the other
scenario families that now thread OverlayBenchState.
In `@docs/editor/features/tray.md`:
- Around line 60-62: The docs currently claim round-trip support ("Trays export
back as Sections") but export isn't implemented; in the "## Figma Compatibility"
section update the final sentence to state import-only support (e.g., "Sections
become Trays; export back to Figma is not yet supported") so it no longer
promises tray export; target the paragraph that starts with "Trays map directly
to Figma **Sections**" and replace the round-trip sentence accordingly.
In `@docs/wg/feat-tray/index.md`:
- Around line 73-78: The doc's non-goals/invariants incorrectly state that Tray
has "No styling" and cannot serialize fills/strokes, conflicting with the
implemented model; update the sections that list Tray constraints (the bullet
items including "No styling" and related serialization notes, and the similar
content around lines 84-102) to reflect that Tray supports fills, strokes, and
corner-radius (and is serialized accordingly), remove the absolute prohibition
on styling/serialization, and ensure the implementation section and invariants
consistently state that Trays can be styled while keeping other constraints
(e.g., placement at Scene level or under other Trays).
- Around line 67-68: The doc contains contradictory claims about Figma export:
update the two places that talk about Tray ↔ Section round-trip (the "Figma
compatibility" bullet that currently says "Round-trips cleanly" and the
future-work/export bullet that says export is not implemented) so they match;
either change the "Figma compatibility" statement to note export is not
implemented yet, or update the future-work item to state export is implemented
and remove the TODO—make the same edit for both occurrences referenced (the
Figma compatibility bullet and the later future-work/export note) so the file
docs/wg/feat-tray/index.md is consistent.
In `@editor/grida-canvas/reducers/event-target.reducer.ts`:
- Around line 460-464: The click-insert path still calls
__get_insertion_target(draft) and thus allows a tray to be inserted under a
Container; update the click-create logic to use the tray-specific parent
resolver by checking draft.tool.node === "tray" and calling
__get_tray_insertion_target(draft) instead of __get_insertion_target(draft)
wherever insertion parent is computed for tray creation (including the shown
block and the other occurrences around lines 1220-1235); ensure both the drag
and click creation flows use the same conditional selection between
__get_tray_insertion_target and __get_insertion_target.
In `@editor/grida-canvas/utils/insertion-targeting.ts`:
- Around line 51-56: The mapping over selection reads node.type without checking
for a missing node, so a stale id will throw; update the .map((node_id) => { ...
}) callback to mirror resolveInsertTargetParent() by first fetching const node =
dq.__getNodeById(state, node_id) and returning null if !node before accessing
node.type (i.e., guard the node, and return null for missing nodes so the rest
of the pipeline falls back correctly); reference dq.__getNodeById, selection
mapping callback, and resolveInsertTargetParent() when making the change.
In `@format/grida.fbs`:
- Around line 1385-1398: The FlatBuffers schema needs a coordinated
schema_version bump to reflect the new TrayNode payload: update
CanvasDocument.schema_version in the .fbs to a new higher version (matching your
project's versioning convention) and update the TypeScript constant
grida.program.document.SCHEMA_VERSION to the identical value so writers and
readers stay in sync; ensure any related compatibility logic
(isSchemaCompatible) continues to treat the new version correctly and adjust any
tests or docs that assert the previous schema_version.
In `@packages/grida-canvas-io-figma/lib.ts`:
- Around line 1586-1587: Add round-trip tests for the new TrayNode schema
variant: in the TypeScript format-roundtrip.test.ts add a new "baseTray" test
case that constructs a node with type: "tray" (matching the TrayNode shape used
in lib.ts) and asserts encode->decode stability using the same helper/assertion
pattern as other cases; in the Rust fbs_roundtrip.rs add a gen_tray test that
creates the equivalent tray fixture, runs the crate's roundtrip encode/decode
helper, and asserts equality/consistency like the existing gen_* tests. Ensure
the tests exercise the TrayNode schema encoding and decoding paths and use the
same naming and helper utilities as other roundtrip tests.
In `@packages/grida-canvas-io/format.ts`:
- Around line 6235-6292: The tray constructor is incorrectly preserving decoded
layer effects by spreading (effects || {}) into the returned object; update the
tray function (tray) to NOT include any decoded effects (do not spread effects
or add fe_* fields) so tray nodes never carry effects, and mirror this
normalization in the encode path for tray nodes (remove/strip fe_* fields when
encoding tray) so both format.decode.format.tray and the corresponding encode
logic treat TrayNode as effect-free.
- Around line 2131-2185: Add explicit round-trip tests for TrayNode: create a
TrayNode instance exercising stroke_cap, stroke_join, stroke_width,
rectangular_stroke_width_top/right/bottom/left,
rectangular_corner_radius_top_left/top_right/bottom_left/bottom_right,
corner_smoothing, and both fill and stroke paints (use the same paints(...)
helper shape used in format.ts), serialize it with the same encode path (calling
format.shape.encode.rectangularStrokeGeometryTrait,
format.shape.encode.rectangularCornerRadiusTrait,
format.paint.encode.fillPaints/ strokePaints and the TrayNode encoder), then
deserialize and assert the deserialized object equals the original; implement
this in the Rust round-trip test suite (mirror existing fbs roundtrip helpers
used for other nodes) and in the TypeScript format-roundtrip.test.ts (create
grida.program.nodes.TrayNode, run the IO encoder/decoder and deep-equal assert)
so future regressions in TrayNode encoding/decoding are caught.
---
Outside diff comments:
In `@crates/grida-dev/src/bench/load_bench.rs`:
- Around line 203-225: The parent_info match needs to treat Tray nodes as
parents rather than falling through to "Leaf": update the match that produces
parent_info (the one inspecting scene.graph.get_node(&parent_id) / parent_info)
to include the pattern cg::node::schema::Node::Tray(_) alongside other parent
variants (e.g., Container, Group, InitialContainer) so tray parents return the
appropriate non‑"Leaf" label; apply the same change to the other analogous
parent_info block referenced in the diff (the second occurrence around lines
229–241) so both places handle Tray consistently with the new type_name
addition.
In `@format/grida.fbs`:
- Around line 257-264: The new enum variant was inserted in the middle of the
FlatBuffers enum NodeType which renumbers subsequent wire values; move the newly
added Tray variant to the end of the NodeType definition (append, do not insert
or reorder existing entries) so existing numeric values remain stable, and
ensure any comments/documentation reflect the append-only change for future
additions.
In `@packages/grida-canvas-schema/grida.ts`:
- Around line 2863-2882: The switch currently routes "tray" into the generic
UnknownNode fallback in the function that builds nodes from a prototype (the
case handling that returns UnknownNode using prototypeWithoutChildren), which
drops required Tray fields; update the switch so "tray" is handled explicitly
instead of grouped with boolean/group/component/instance/template_instance:
create a dedicated branch for "tray" that returns a TrayNode (or at minimum
includes all required tray-specific properties such as layout_positioning,
layout_target_width, layout_target_height, stroke_width, stroke_cap,
stroke_join, etc.), seeding sensible defaults and spreading
prototypeWithoutChildren to preserve incoming values, and ensure the returned
object is typed as the Tray node type rather than UnknownNode.
---
Nitpick comments:
In `@crates/grida-canvas/examples/skia_bench/skia_bench_cache_image.rs`:
- Around line 121-125: The benchmark is allocating Vec<Color4f> inside
draw_complex's timed path (the local colors4f Vec used when constructing
gradient_shader::Gradient::new, and the other fixed-size color Vec created
elsewhere in the same function), which adds allocator noise; replace those heap
allocations with fixed-size stack arrays (e.g., define a [Color4f; N] by calling
Color4f::from on each known color and pass a slice &colors4f to
gradient_shader::GradientColors::new) and similarly ensure positions is a stack
array or slice; update both places where a Vec<Color4f> is created so the
gradient construction uses a stack-allocated &[Color4f] instead of a Vec to
eliminate the allocation from the timed path.
In `@crates/grida-canvas/src/node/schema.rs`:
- Around line 1567-1606: TrayNodeRec duplicates geometry/fill code present in
ContainerNodeRec: extract the shared logic by creating a small helper or macro
and reuse it in both implementations—move the to_own_shape conversion and the
NodeFillsMixin/NodeGeometryMixin behavior into a shared function/macro
(referencing to_own_shape, NodeFillsMixin::set_fill, set_fills,
NodeGeometryMixin::has_stroke_geometry, render_bounds_stroke_width,
rectangular_stroke_width) and call/expand that from TrayNodeRec and
ContainerNodeRec so both types delegate to the same implementation rather than
duplicating the code.
In `@crates/grida-canvas/src/painter/geometry.rs`:
- Around line 275-302: Extract the duplicated logic that turns bounds + corner
radius/smoothing into a PainterShape into a single helper (e.g., a function like
shape_from_bounds_and_corners) and replace the Node::Tray branch with a call to
that helper; the helper should accept width, height, corner_radius and
corner_smoothing and internally call build_orthogonal_smooth_rrect_path +
PainterShape::from_path when smoothing > 0, build_rrect +
PainterShape::from_rrect when corner_radius > 0 and smoothing == 0, and
Rect::from_xywh + PainterShape::from_rect when corner_radius is zero, so both
the Tray code and the existing Container code can call the same function instead
of duplicating the logic (references: Node::Tray,
build_orthogonal_smooth_rrect_path, RRectShape, build_rrect,
PainterShape::from_path, PainterShape::from_rrect, PainterShape::from_rect,
Rect::from_xywh).
In `@crates/grida-canvas/src/painter/gradient.rs`:
- Around line 86-97: Extract a shared helper (e.g.,
wrap_with_opacity_mask(shader: skia_safe::Shader, opacity: f32) ->
skia_safe::Shader) and replace the duplicated blocks in each gradient builder so
they call this helper instead of duplicating the blend logic; inside the helper
use the same alpha conversion and clamping logic as build_gradient_stops()
(don’t cast via (opacity * 255.0) as u8—reuse or replicate its
rounding/clamping) to create the skia_safe::Color for skia_safe::shaders::color,
then return skia_safe::shaders::blend(skia_safe::BlendMode::DstIn, shader,
opacity_shader) when opacity < 1.0 and the original shader otherwise, and update
all four places that currently construct opacity_shader to call this helper.
In `@crates/grida-canvas/src/painter/painter_debug_node.rs`:
- Around line 538-591: Extract the duplicated rectangular-parent rendering logic
(fills → children → strokes) into a small helper method (e.g.,
render_rect_parent or render_container_like) and call it from Node::Tray (and
the existing Node::Container) to avoid drift; the helper should accept the node
id and node data, the cache and graph references, plus parameters for
LayerEffects (effects) and a boolean for whether children are clipped
(clip_children), and internally perform the steps currently duplicated: get
local_transform via cache.get_transform(id), call self.painter.with_transform
and with_opacity, compute bounds via cache.get_world_bounds(id),
build_shape(node, &bounds), use
math2::transform::AffineTransform::identity().matrix when calling
self.painter.with_blendmode with the provided effects, call
self.painter.draw_fills(&shape, &n.fills), iterate children via
graph.get_children/get_node and either draw them directly or wrap with clipping
depending on clip_children, then finally call self.painter.draw_strokes(...)
using n.render_bounds_stroke_width() and n.stroke_style fields; replace the
Node::Tray branch with a call to this new helper passing LayerEffects::default()
and clip_children=false.
In `@test/canvas-tray-node-behavior.md`:
- Around line 2-5: The area token in the test front-matter is incorrect: change
the `area:` value from "hierarchy" to "tray" so it matches the file name
`canvas-tray-node-behavior.md` and the test-case ID `TC-CANVAS-TRAY-001`; ensure
the front-matter uses `module: canvas` and `area: tray` and that the test ID
remains `TC-CANVAS-TRAY-001` to conform to the `{module}-{area}-{NNN}` naming
guidelines.
🪄 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: 6d071cb5-3a8e-4f6e-9151-99d448bd4b8a
⛔ Files ignored due to path filters (2)
Cargo.lockis excluded by!**/*.lockcrates/grida-canvas/src/io/generated/grida.rsis excluded by!**/generated/**
📒 Files selected for processing (61)
.agents/skills/cg-perf/SKILL.mdcrates/grida-canvas/Cargo.tomlcrates/grida-canvas/examples/fixtures/README.mdcrates/grida-canvas/examples/fixtures/l0_tray.rscrates/grida-canvas/examples/fixtures/mod.rscrates/grida-canvas/examples/golden_sk_mask.rscrates/grida-canvas/examples/golden_sk_text_backdrop_blur_path.rscrates/grida-canvas/examples/skia_bench/skia_bench_cache_image.rscrates/grida-canvas/examples/tool_gen_fixtures.rscrates/grida-canvas/examples/tool_io_grida.rscrates/grida-canvas/examples/tool_io_svg.rscrates/grida-canvas/src/cache/geometry.rscrates/grida-canvas/src/io/id_converter.rscrates/grida-canvas/src/io/io_grida.rscrates/grida-canvas/src/io/io_grida_fbs.rscrates/grida-canvas/src/layout/engine.rscrates/grida-canvas/src/layout/into_taffy.rscrates/grida-canvas/src/node/factory.rscrates/grida-canvas/src/node/scene_graph.rscrates/grida-canvas/src/node/schema.rscrates/grida-canvas/src/painter/geometry.rscrates/grida-canvas/src/painter/gradient.rscrates/grida-canvas/src/painter/layer.rscrates/grida-canvas/src/painter/painter_debug_node.rscrates/grida-canvas/src/resources/mod.rscrates/grida-canvas/src/runtime/scene.rscrates/grida-canvas/src/surface/ui/render.rscrates/grida-dev/Cargo.tomlcrates/grida-dev/src/bench/args.rscrates/grida-dev/src/bench/load_bench.rscrates/grida-dev/src/bench/runner.rsdocs/editor/features/README.mddocs/editor/features/tray.mddocs/editor/shortcuts/index.mddocs/wg/feat-tray/index.mdeditor/grida-canvas-hosted/playground/uxhost-actions.tseditor/grida-canvas-hosted/playground/uxhost-toolbar.tsxeditor/grida-canvas-react-renderer-dom/nodes/index.tseditor/grida-canvas-react-renderer-dom/nodes/node.tsxeditor/grida-canvas-react-renderer-dom/nodes/tray.tsxeditor/grida-canvas-react-starter-kit/starterkit-hierarchy/tree-node.tsxeditor/grida-canvas-react-starter-kit/starterkit-icons/node-type-icon.tsxeditor/grida-canvas-react-starter-kit/starterkit-toolbar/index.tsxeditor/grida-canvas-react-starter-kit/starterkit-toolbar/utils.tseditor/grida-canvas-react/viewport/hotkeys.tsxeditor/grida-canvas/editor.i.tseditor/grida-canvas/policy.tseditor/grida-canvas/reducers/event-target.reducer.tseditor/grida-canvas/reducers/methods/__tests__/move-tray.test.tseditor/grida-canvas/reducers/methods/move.tseditor/grida-canvas/reducers/methods/transform.tseditor/grida-canvas/reducers/tools/initial-node.tseditor/grida-canvas/utils/insertion-targeting.tseditor/grida-canvas/utils/supports.tsfixtures/test-grida/L0.gridaformat/grida.fbspackages/grida-canvas-io-figma/lib.tspackages/grida-canvas-io/format.tspackages/grida-canvas-schema/grida.tspackages/grida-format/turbo.jsontest/canvas-tray-node-behavior.md
| ), | ||
| ("L0-container", fixtures::l0_container::build()), | ||
| ("L0-group", fixtures::l0_group::build()), | ||
| ("L0-tray", fixtures::l0_tray::build()), |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check for Tray coverage in FlatBuffers round-trip tests
rg -n -i 'tray' crates/grida-canvas/tests/
rg -n -i 'tray' packages/grida-canvas-io/__tests__/Repository: gridaco/grida
Length of output: 39
🏁 Script executed:
#!/bin/bash
# Verify the test files exist and examine their structure
echo "=== Checking Rust round-trip tests ==="
if [ -f "crates/grida-canvas/tests/fbs_roundtrip.rs" ]; then
echo "File exists:"
wc -l crates/grida-canvas/tests/fbs_roundtrip.rs
else
echo "File does NOT exist"
fi
echo ""
echo "=== Checking TypeScript round-trip tests ==="
if [ -f "packages/grida-canvas-io/__tests__/format-roundtrip.test.ts" ]; then
echo "File exists:"
wc -l packages/grida-canvas-io/__tests__/format-roundtrip.test.ts
else
echo "File does NOT exist"
fi
echo ""
echo "=== Searching for Tray in grida.fbs schema ==="
rg -n -i 'tray' format/grida.fbs || echo "No Tray found in schema"
echo ""
echo "=== Checking for Tray-related structs in Rust schema ==="
rg -n -i 'tray' crates/grida-canvas/src/node/schema.rs || echo "No Tray in Rust schema"
echo ""
echo "=== Checking for Tray in TypeScript schema ==="
rg -n -i 'tray' packages/grida-canvas-schema/grida.ts || echo "No Tray in TS schema"Repository: gridaco/grida
Length of output: 1604
🏁 Script executed:
#!/bin/bash
# Check the structure of the Rust round-trip test file
echo "=== Rust round-trip tests: first 100 lines ==="
head -100 crates/grida-canvas/tests/fbs_roundtrip.rs
echo ""
echo "=== Rust round-trip tests: searching for node type patterns ==="
rg -n 'fn test_.*node|#\[test\]' crates/grida-canvas/tests/fbs_roundtrip.rs | head -30
echo ""
echo "=== TypeScript round-trip tests: first 80 lines ==="
head -80 packages/grida-canvas-io/__tests__/format-roundtrip.test.ts
echo ""
echo "=== TypeScript round-trip tests: test case patterns ==="
rg -n 'describe|it\(' packages/grida-canvas-io/__tests__/format-roundtrip.test.ts | head -30Repository: gridaco/grida
Length of output: 7974
🏁 Script executed:
#!/bin/bash
# Extract all node type definitions from the schema
echo "=== All node types defined in grida.fbs ==="
rg -o 'union Node \{[^}]+\}' format/grida.fbs -A 30 | head -50
echo ""
echo "=== Count of node type definitions ==="
rg 'union Node' format/grida.fbs -A 50 | grep -E '^\s+[A-Z][a-zA-Z]+Node,' | wc -l
echo ""
echo "=== Extract test function names from Rust (which node types are tested) ==="
rg 'fn (test_gen_\w+|gen_\w+_node)' crates/grida-canvas/tests/fbs_roundtrip.rs -o | sed 's/fn //' | sort
echo ""
echo "=== Check if any Tray node is tested in Rust ==="
rg -i 'tray' crates/grida-canvas/tests/fbs_roundtrip.rs
echo ""
echo "=== Extract TS test names for node types ==="
rg 'roundtrips.*Node' packages/grida-canvas-io/__tests__/format-roundtrip.test.ts -o | sort -uRepository: gridaco/grida
Length of output: 3464
🏁 Script executed:
#!/bin/bash
# Get complete list of Rust test functions
echo "=== All test functions in Rust round-trip tests ==="
rg '^fn (test_|gen_)' crates/grida-canvas/tests/fbs_roundtrip.rs -o | sed 's/^fn //' | sort
echo ""
echo "=== All node types mentioned in Rust test code ==="
rg '(Node::|TrayNode|RectangleNode|Ellipse|Vector|Boolean|Scene|Container|Group|Line|Path|RegularPolygon|RegularStar|Text|Image)' crates/grida-canvas/tests/fbs_roundtrip.rs -o | sort -u | head -30Repository: gridaco/grida
Length of output: 424
🏁 Script executed:
#!/bin/bash
# Get test function names more carefully
echo "=== Test function definitions ==="
rg 'fn (test_|gen_)\w+\(' crates/grida-canvas/tests/fbs_roundtrip.rs | grep -o 'fn [^(]*' | sed 's/fn //' | sort -u
echo ""
echo "=== Check specifically for Tray in any form ==="
rg -i 'tray|\"tray\"' crates/grida-canvas/tests/fbs_roundtrip.rs && echo "Found Tray" || echo "Tray not found in Rust tests"
echo ""
echo "=== Check TypeScript for Tray explicitly ==="
rg -i 'baseTray|\"tray\"|TrayNode' packages/grida-canvas-io/__tests__/format-roundtrip.test.ts && echo "Found Tray" || echo "Tray not found in TypeScript tests"Repository: gridaco/grida
Length of output: 1050
🏁 Script executed:
#!/bin/bash
# Verify with proper escaping
echo "=== Check for Tray in Rust tests ==="
rg -i 'tray' crates/grida-canvas/tests/fbs_roundtrip.rs; echo "Exit code: $?"
echo ""
echo "=== Check for Tray in TypeScript tests ==="
rg -i 'tray' packages/grida-canvas-io/__tests__/format-roundtrip.test.ts; echo "Exit code: $?"
echo ""
echo "=== Show context around Tray definition in examples ==="
cat -n crates/grida-canvas/examples/tool_gen_fixtures.rs | sed -n '45,60p'Repository: gridaco/grida
Length of output: 951
Add round-trip tests for the new Tray node type.
Round-trip serialization tests for Tray are missing from both crates/grida-canvas/tests/fbs_roundtrip.rs (Rust) and packages/grida-canvas-io/__tests__/format-roundtrip.test.ts (TypeScript). Since Tray is a new schema node type, add comprehensive tests matching the patterns used for other node types (SceneNode, ContainerNode, RectangleNode, etc.) to ensure encode/decode integrity.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/grida-canvas/examples/tool_gen_fixtures.rs` at line 52, Add round-trip
serialization tests for the new Tray node type by following existing patterns:
in crates/grida-canvas/tests/fbs_roundtrip.rs add a test that uses
fixtures::l0_tray::build() (matching other tests like SceneNode/RectangleNode)
to encode and then decode the Tray and assert equality/roundtrip integrity; and
in packages/grida-canvas-io/__tests__/format-roundtrip.test.ts add a Jest test
that imports the corresponding tray fixture and calls the same roundtrip helper
used by other node tests to ensure encode/decode parity. Ensure the tests
reference the fixture symbol (fixtures::l0_tray::build and the TS fixture
import) and mirror the assertions and setup used by existing node roundtrip
tests.
| // Tray has explicit dimensions (like Container) but no clipping. | ||
| // Children render inside the tray bounds. | ||
| GeoNodeKind::Tray => { | ||
| let local_bounds = Rectangle { | ||
| x: 0.0, | ||
| y: 0.0, | ||
| width: geo.width, | ||
| height: geo.height, | ||
| }; | ||
|
|
||
| let world_transform = parent_world.compose(&geo.transform); | ||
| let world_bounds = transform_rect(&local_bounds, &world_transform); | ||
| let render_bounds = inflate_rect_sides(world_bounds, &geo.render_bounds_inflation); | ||
|
|
||
| if let Some(children) = graph.get_children(id) { | ||
| for child_id in children { | ||
| Self::build_recursive( | ||
| child_id, | ||
| &world_transform, | ||
| Some(*id), | ||
| cache, | ||
| graph, | ||
| geo_inputs, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| let entry = GeometryEntry { | ||
| transform: geo.transform, | ||
| absolute_transform: world_transform, | ||
| bounding_box: local_bounds, | ||
| absolute_bounding_box: world_bounds, | ||
| absolute_render_bounds: render_bounds, | ||
| parent: parent_id, | ||
| dirty_transform: false, | ||
| dirty_bounds: false, | ||
| }; | ||
| cache.entries.insert(*id, entry); | ||
| world_bounds | ||
| } |
There was a problem hiding this comment.
Return tray child bounds to ancestor unions.
This branch walks children but ignores their bounds and returns only the tray rect. Since trays do not clip, overflowing descendants won't expand ancestor bounds/culling the way GeoNodeKind::Container does.
💡 Suggested fix
GeoNodeKind::Tray => {
let local_bounds = Rectangle {
x: 0.0,
y: 0.0,
width: geo.width,
height: geo.height,
};
let world_transform = parent_world.compose(&geo.transform);
let world_bounds = transform_rect(&local_bounds, &world_transform);
+ let mut union_world_bounds = world_bounds;
let render_bounds = inflate_rect_sides(world_bounds, &geo.render_bounds_inflation);
if let Some(children) = graph.get_children(id) {
for child_id in children {
- Self::build_recursive(
+ let child_bounds = Self::build_recursive(
child_id,
&world_transform,
Some(*id),
cache,
graph,
geo_inputs,
);
+ union_world_bounds = rect::union(&[union_world_bounds, child_bounds]);
}
}
let entry = GeometryEntry {
transform: geo.transform,
@@
};
cache.entries.insert(*id, entry);
- world_bounds
+ union_world_bounds
}🤖 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 406 - 445, The Tray
branch currently ignores child return values and returns only the tray rect;
change GeoNodeKind::Tray (inside build_recursive) to collect each child's
returned bounds when iterating children, compute a union of world_bounds with
all child_bounds (and likewise union/expand render_bounds with children's
absolute_render_bounds), then use that unioned rectangle for the GeometryEntry
fields (absolute_bounding_box and absolute_render_bounds) and as the function
return value; keep local_bounds/world_transform calculation and
cache.entries.insert(*id, entry) but replace the stored/returned world_bounds
with the union so overflowing descendants are included in ancestor unions.
| parent_id: &str, | ||
| position: &str, | ||
| ) -> flatbuffers::WIPOffset<fbs::NodeSlot<'a>> { | ||
| let sys = encode_system_node_trait(fbb, node_id, "", r.active, false); |
There was a problem hiding this comment.
Preserve tray names in SystemNodeTrait.
Line 3698 hardcodes "" for the node name. Tray labels are decoded from SystemNodeTrait.name, so any named tray will reopen unlabeled after a .grida round-trip.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/grida-canvas/src/io/io_grida_fbs.rs` at line 3698, The SystemNodeTrait
name is being hardcoded to "" when calling encode_system_node_trait, which
strips tray labels; update the call in io_grida_fbs.rs to pass the actual node
name (use the record's name field, e.g., r.name) instead of "" so
SystemNodeTrait.name preserves tray labels — if the name can be optional, pass
an empty string only as a fallback when r.name is None/empty. Ensure you update
the invocation of encode_system_node_trait (the call that currently reads
encode_system_node_trait(fbb, node_id, "", r.active, false)) to use the real
name variable.
| impl From<JSONGroupNode> for TrayNodeRec { | ||
| fn from(node: JSONGroupNode) -> Self { | ||
| // TODO: JSONNode::Tray reuses JSONGroupNode shape; visual fields default | ||
| // until a dedicated JSONTrayNode is introduced. | ||
| TrayNodeRec { | ||
| active: node.base.active, | ||
| opacity: node.base.opacity, | ||
| blend_mode: node.base.blend_mode.into(), | ||
| mask: node.base.mask.map(|m| m.into()), | ||
| rotation: 0.0, | ||
| position: Default::default(), | ||
| layout_dimensions: Default::default(), | ||
| corner_radius: Default::default(), | ||
| corner_smoothing: Default::default(), | ||
| fills: Paints::default(), | ||
| strokes: Paints::default(), | ||
| stroke_style: StrokeStyle::default(), | ||
| stroke_width: StrokeWidth::default(), | ||
| } |
There was a problem hiding this comment.
Don't zero out tray geometry and styles on import.
TrayNodeRec is built almost entirely from defaults here. Loading a tray from JSON will lose its position, size, fills, strokes, stroke width, and corner radius.
💡 Suggested mapping
Map the tray fields from node.base instead of defaulting them:
rotationpositionviajson_position_to_layout_basis(...)layout_dimensionsfromwidth/heightand the min/max/aspect-ratio fieldscorner_radius/corner_smoothingfills/strokesstroke_style/stroke_width
JSONUnknownNodeProperties already carries the same shared rectangular/style fields that ContainerNodeRec uses.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/grida-canvas/src/io/io_grida.rs` around lines 1319 - 1337, The current
From<JSONGroupNode> for TrayNodeRec zeros many visual/layout fields; update the
conversion to copy the shared rectangular/style fields from node.base into
TrayNodeRec instead of defaults: set rotation from node.base.rotation, position
via json_position_to_layout_basis(&node.base.position, ...) (matching
ContainerNodeRec usage), compute layout_dimensions from node.base.width/height
plus min/max/aspect constraints, map corner_radius and corner_smoothing from
node.base, convert fills and strokes from node.base.fills/strokes into Paints,
and set stroke_style/stroke_width from node.base (and keep mask mapping). Locate
the From implementation for TrayNodeRec and replace the defaulted assignments
with these mappings using the same helpers ContainerNodeRec already uses.
| .map((node_id) => { | ||
| const node = dq.__getNodeById(state, node_id); | ||
|
|
||
| // If node is a container, use it as target parent (paste as child) | ||
| if (node.type === "container") { | ||
| // If node is a container or tray, use it as target parent (paste as child) | ||
| if (node.type === "container" || node.type === "tray") { | ||
| return node_id; |
There was a problem hiding this comment.
Guard missing nodes before reading node.type.
This path doesn't mirror resolveInsertTargetParent()'s !node check. If selection contains a stale id, paste will throw here instead of falling back to null.
💡 Suggested fix
selection
.map((node_id) => {
const node = dq.__getNodeById(state, node_id);
+ if (!node) return null;
// If node is a container or tray, use it as target parent (paste as child)
if (node.type === "container" || node.type === "tray") {
return node_id;
}📝 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.
| .map((node_id) => { | |
| const node = dq.__getNodeById(state, node_id); | |
| // If node is a container, use it as target parent (paste as child) | |
| if (node.type === "container") { | |
| // If node is a container or tray, use it as target parent (paste as child) | |
| if (node.type === "container" || node.type === "tray") { | |
| return node_id; | |
| .map((node_id) => { | |
| const node = dq.__getNodeById(state, node_id); | |
| if (!node) return null; | |
| // If node is a container or tray, use it as target parent (paste as child) | |
| if (node.type === "container" || node.type === "tray") { | |
| return node_id; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@editor/grida-canvas/utils/insertion-targeting.ts` around lines 51 - 56, The
mapping over selection reads node.type without checking for a missing node, so a
stale id will throw; update the .map((node_id) => { ... }) callback to mirror
resolveInsertTargetParent() by first fetching const node =
dq.__getNodeById(state, node_id) and returning null if !node before accessing
node.type (i.e., guard the node, and return null for missing nodes so the rest
of the pipeline falls back correctly); reference dq.__getNodeById, selection
mapping callback, and resolveInsertTargetParent() when making the change.
| /// Node variant: Tray. | ||
| /// | ||
| /// A canvas-level organizational primitive (maps to Figma SECTION). | ||
| /// Has explicit dimensions, fills, strokes, corner radius. | ||
| /// No effects, no layout, no clipping. | ||
| /// Children are freely placed and treated as root-level containers. | ||
| table TrayNode { | ||
| node: SystemNodeTrait (required); | ||
| layer:LayerTrait (required); | ||
| stroke_geometry:RectangularStrokeGeometryTrait; | ||
| corner_radius:RectangularCornerRadiusTrait; | ||
| fill_paints:[PaintStackItem]; | ||
| stroke_paints:[PaintStackItem]; | ||
| } |
There was a problem hiding this comment.
Advance the document schema version for tray-bearing files.
Lines 1385-1398 and 1567-1580 add a new payload that writers can now emit, but the reviewed changes do not show the coordinated CanvasDocument.schema_version / grida.program.document.SCHEMA_VERSION bump. Since isSchemaCompatible() treats every 0.91.x file as compatible, older readers will accept tray-bearing documents instead of rejecting them up front.
As per coding guidelines, "When schema writer behavior changes meaningfully (especially for breaking changes), bump CanvasDocument.schema_version in the FlatBuffers schema and keep it in sync with TS grida.program.document.SCHEMA_VERSION."
Also applies to: 1567-1580
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@format/grida.fbs` around lines 1385 - 1398, The FlatBuffers schema needs a
coordinated schema_version bump to reflect the new TrayNode payload: update
CanvasDocument.schema_version in the .fbs to a new higher version (matching your
project's versioning convention) and update the TypeScript constant
grida.program.document.SCHEMA_VERSION to the identical value so writers and
readers stay in sync; ensure any related compatibility logic
(isSchemaCompatible) continues to treat the new version correctly and adjust any
tests or docs that assert the previous schema_version.
| type: "tray", | ||
| } satisfies grida.program.nodes.TrayNode; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Check for round-trip tests covering TrayNode and SECTION conversion.
# Check TypeScript round-trip tests
echo "=== TypeScript round-trip tests ==="
fd -i 'roundtrip' -e ts -e tsx --exec grep -l -i 'tray' {} \; 2>/dev/null || echo "No TypeScript roundtrip tests found for tray"
# Check for any tests involving SECTION or tray conversion
echo ""
echo "=== Test files mentioning tray or section ==="
rg -l -i 'tray|section' --type ts -g '*test*' -g '*spec*' 2>/dev/null | head -20
# Check Rust round-trip tests
echo ""
echo "=== Rust round-trip tests ==="
fd 'fbs_roundtrip' -e rs --exec grep -l -i 'tray' {} \; 2>/dev/null || echo "No Rust roundtrip tests found for tray"Repository: gridaco/grida
Length of output: 985
🏁 Script executed:
#!/bin/bash
# Find the schema file defining TrayNode
echo "=== Looking for TrayNode schema definition ==="
find . -name "*.fbs" -o -name "*schema*" -type f | head -20
rg -l "TrayNode" --type=ts --type=tsx --type=rs 2>/dev/null | head -10
echo ""
echo "=== Checking TypeScript roundtrip test file ==="
cat -n packages/grida-canvas-io/__tests__/format-roundtrip.test.ts 2>/dev/null | head -100
echo ""
echo "=== Checking Rust roundtrip test file ==="
cat -n crates/grida-canvas/tests/fbs_roundtrip.rs 2>/dev/null | head -150
echo ""
echo "=== Checking file under review for SECTION → TrayNode conversion ==="
sed -n '1580,1595p' packages/grida-canvas-io-figma/lib.ts 2>/dev/nullRepository: gridaco/grida
Length of output: 11136
🏁 Script executed:
#!/bin/bash
# Check if TrayNode is tested in the TypeScript roundtrip file
echo "=== Checking for TrayNode in TypeScript roundtrip test ==="
grep -n -i "tray" packages/grida-canvas-io/__tests__/format-roundtrip.test.ts
echo ""
echo "=== Total lines in TypeScript roundtrip test ==="
wc -l packages/grida-canvas-io/__tests__/format-roundtrip.test.ts
echo ""
echo "=== Checking for TrayNode in Rust roundtrip test ==="
grep -n -i "tray" crates/grida-canvas/tests/fbs_roundtrip.rs
echo ""
echo "=== Total lines in Rust roundtrip test ==="
wc -l crates/grida-canvas/tests/fbs_roundtrip.rs
echo ""
echo "=== Check schema file for TrayNode definition ==="
grep -n "TrayNode\|tray" format/grida.fbs | head -30Repository: gridaco/grida
Length of output: 466
🏁 Script executed:
#!/bin/bash
# Check what node types ARE tested in the TypeScript roundtrip file
echo "=== Node types tested in TypeScript roundtrip ==="
grep -o "base[A-Za-z]*" packages/grida-canvas-io/__tests__/format-roundtrip.test.ts | sort -u
echo ""
echo "=== Node types tested in Rust roundtrip ==="
grep -o "Node::[A-Za-z]*" crates/grida-canvas/tests/fbs_roundtrip.rs | sort -u | head -20
echo ""
echo "=== Check if TrayNode is a new addition by examining grida.fbs context ==="
sed -n '1385,1405p' format/grida.fbs
echo ""
echo "=== Check what types are in the union with TrayNode ==="
sed -n '1570,1600p' format/grida.fbsRepository: gridaco/grida
Length of output: 2377
Add round-trip tests for TrayNode schema change.
TrayNode (a new schema variant for Figma SECTION conversion) is missing from both the TypeScript (packages/grida-canvas-io/__tests__/format-roundtrip.test.ts) and Rust (crates/grida-canvas/tests/fbs_roundtrip.rs) roundtrip test suites. Per project learnings, all schema changes require round-trip tests to ensure encode/decode stability. Add a baseTray test case in the TypeScript file and a gen_tray test in the Rust file.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/grida-canvas-io-figma/lib.ts` around lines 1586 - 1587, Add
round-trip tests for the new TrayNode schema variant: in the TypeScript
format-roundtrip.test.ts add a new "baseTray" test case that constructs a node
with type: "tray" (matching the TrayNode shape used in lib.ts) and asserts
encode->decode stability using the same helper/assertion pattern as other cases;
in the Rust fbs_roundtrip.rs add a gen_tray test that creates the equivalent
tray fixture, runs the crate's roundtrip encode/decode helper, and asserts
equality/consistency like the existing gen_* tests. Ensure the tests exercise
the TrayNode schema encoding and decoding paths and use the same naming and
helper utilities as other roundtrip tests.
| case "tray": { | ||
| const trayNode = node as grida.program.nodes.TrayNode; | ||
|
|
||
| // Encode traits and paints | ||
| const strokeGeometryOffset = | ||
| format.shape.encode.rectangularStrokeGeometryTrait(builder, { | ||
| stroke_cap: trayNode.stroke_cap, | ||
| stroke_join: trayNode.stroke_join, | ||
| stroke_width: trayNode.stroke_width, | ||
| stroke_dash_array: trayNode.stroke_dash_array, | ||
| rectangular_stroke_width_top: | ||
| trayNode.rectangular_stroke_width_top, | ||
| rectangular_stroke_width_right: | ||
| trayNode.rectangular_stroke_width_right, | ||
| rectangular_stroke_width_bottom: | ||
| trayNode.rectangular_stroke_width_bottom, | ||
| rectangular_stroke_width_left: | ||
| trayNode.rectangular_stroke_width_left, | ||
| }); | ||
| const cornerRadiusOffset = | ||
| format.shape.encode.rectangularCornerRadiusTrait(builder, { | ||
| rectangular_corner_radius_top_left: | ||
| trayNode.rectangular_corner_radius_top_left, | ||
| rectangular_corner_radius_top_right: | ||
| trayNode.rectangular_corner_radius_top_right, | ||
| rectangular_corner_radius_bottom_left: | ||
| trayNode.rectangular_corner_radius_bottom_left, | ||
| rectangular_corner_radius_bottom_right: | ||
| trayNode.rectangular_corner_radius_bottom_right, | ||
| corner_smoothing: trayNode.corner_smoothing, | ||
| }); | ||
| const fillPaintsFiltered = paints(trayNode, "fill"); | ||
| const fillPaintsOffset = format.paint.encode.fillPaints( | ||
| builder, | ||
| fillPaintsFiltered, | ||
| fbs.TrayNode.createFillPaintsVector | ||
| ); | ||
| const strokePaintsFiltered = paints(trayNode, "stroke"); | ||
| const strokePaintsOffset = format.paint.encode.strokePaints( | ||
| builder, | ||
| strokePaintsFiltered, | ||
| fbs.TrayNode.createStrokePaintsVector | ||
| ); | ||
|
|
||
| fbs.TrayNode.startTrayNode(builder); | ||
| fbs.TrayNode.addNode(builder, systemNodeTraitOffset); | ||
| fbs.TrayNode.addLayer(builder, layerOffset); | ||
| fbs.TrayNode.addStrokeGeometry(builder, strokeGeometryOffset); | ||
| fbs.TrayNode.addCornerRadius(builder, cornerRadiusOffset); | ||
| fbs.TrayNode.addFillPaints(builder, fillPaintsOffset); | ||
| fbs.TrayNode.addStrokePaints(builder, strokePaintsOffset); | ||
| nodeOffset = fbs.TrayNode.endTrayNode(builder); | ||
| nodeType = fbs.Node.TrayNode; | ||
| break; | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
{
fd 'fbs_roundtrip\.rs$' crates
fd 'format-roundtrip\.test\.ts$' packages
} | while read -r file; do
echo "=== $file ==="
rg -n -C2 'tray|TrayNode|NodeType\.Tray' "$file" || true
doneRepository: gridaco/grida
Length of output: 175
Add round-trip tests for tray in both Rust and TypeScript test suites.
The tray node encoding is implemented in format.ts (lines 2131–2185 and related sections), but there is no corresponding coverage in crates/grida-canvas/tests/fbs_roundtrip.rs or packages/grida-canvas-io/__tests__/format-roundtrip.test.ts. Add explicit round-trip assertions for tray serialization and deserialization to both suites to catch data-loss or regression bugs.
Applies to: 6235–6292, 6559–6569
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/grida-canvas-io/format.ts` around lines 2131 - 2185, Add explicit
round-trip tests for TrayNode: create a TrayNode instance exercising stroke_cap,
stroke_join, stroke_width, rectangular_stroke_width_top/right/bottom/left,
rectangular_corner_radius_top_left/top_right/bottom_left/bottom_right,
corner_smoothing, and both fill and stroke paints (use the same paints(...)
helper shape used in format.ts), serialize it with the same encode path (calling
format.shape.encode.rectangularStrokeGeometryTrait,
format.shape.encode.rectangularCornerRadiusTrait,
format.paint.encode.fillPaints/ strokePaints and the TrayNode encoder), then
deserialize and assert the deserialized object equals the original; implement
this in the Rust round-trip test suite (mirror existing fbs roundtrip helpers
used for other nodes) and in the TypeScript format-roundtrip.test.ts (create
grida.program.nodes.TrayNode, run the IO encoder/decoder and deep-equal assert)
so future regressions in TrayNode encoding/decoding are caught.
| export function tray( | ||
| n: fbs.TrayNode, | ||
| id: string, | ||
| systemNode: fbs.SystemNodeTrait, | ||
| layer: fbs.LayerTrait | null, | ||
| opacity: number, | ||
| layoutFields: ReturnType<typeof format.layout.decode.nodeLayout>, | ||
| effects?: grida.program.nodes.i.IEffects | ||
| ): grida.program.nodes.TrayNode { | ||
| const strokeGeometry = n.strokeGeometry(); | ||
| const cornerRadius = n.cornerRadius(); | ||
| const fillPaints = format.paint.decode.fillPaints(n); | ||
| const strokePaints = format.paint.decode.strokePaints(n); | ||
|
|
||
| const strokeGeometryProps = | ||
| format.shape.decode.rectangularStrokeGeometryTrait(strokeGeometry); | ||
| const cornerRadiusProps = | ||
| format.shape.decode.rectangularCornerRadiusTrait(cornerRadius); | ||
|
|
||
| const baseName = systemNode.name() ?? "node"; | ||
|
|
||
| return { | ||
| type: "tray", | ||
| id, | ||
| name: baseName, | ||
| active: systemNode.active(), | ||
| locked: systemNode.locked(), | ||
| opacity, | ||
| ...(fillPaints ? { fill_paints: fillPaints } : {}), | ||
| ...(strokePaints ? { stroke_paints: strokePaints } : {}), | ||
| stroke_width: | ||
| format.shape.decode.deriveStrokeWidth(strokeGeometryProps), | ||
| stroke_cap: strokeGeometryProps.stroke_cap, | ||
| stroke_join: strokeGeometryProps.stroke_join, | ||
| ...(strokeGeometryProps.stroke_dash_array | ||
| ? { stroke_dash_array: strokeGeometryProps.stroke_dash_array } | ||
| : {}), | ||
| rectangular_corner_radius_top_left: | ||
| cornerRadiusProps.rectangular_corner_radius_top_left, | ||
| rectangular_corner_radius_top_right: | ||
| cornerRadiusProps.rectangular_corner_radius_top_right, | ||
| rectangular_corner_radius_bottom_left: | ||
| cornerRadiusProps.rectangular_corner_radius_bottom_left, | ||
| rectangular_corner_radius_bottom_right: | ||
| cornerRadiusProps.rectangular_corner_radius_bottom_right, | ||
| corner_smoothing: cornerRadiusProps.corner_smoothing, | ||
| rectangular_stroke_width_top: | ||
| strokeGeometryProps.rectangular_stroke_width_top, | ||
| rectangular_stroke_width_right: | ||
| strokeGeometryProps.rectangular_stroke_width_right, | ||
| rectangular_stroke_width_bottom: | ||
| strokeGeometryProps.rectangular_stroke_width_bottom, | ||
| rectangular_stroke_width_left: | ||
| strokeGeometryProps.rectangular_stroke_width_left, | ||
| ...layoutFields, | ||
| layout_positioning: layoutFields.layout_positioning ?? "relative", | ||
| ...(effects || {}), | ||
| } satisfies grida.program.nodes.TrayNode; |
There was a problem hiding this comment.
Drop decoded layer effects from tray nodes.
Tray is supposed to never apply effects, but this constructor spreads any decoded LayerTrait.effects() back onto the returned node. That preserves unsupported state in imported documents instead of normalizing it away at the format boundary.
🔧 Minimal guard
- export function tray(
- n: fbs.TrayNode,
- id: string,
- systemNode: fbs.SystemNodeTrait,
- layer: fbs.LayerTrait | null,
- opacity: number,
- layoutFields: ReturnType<typeof format.layout.decode.nodeLayout>,
- effects?: grida.program.nodes.i.IEffects
- ): grida.program.nodes.TrayNode {
+ export function tray(
+ n: fbs.TrayNode,
+ id: string,
+ systemNode: fbs.SystemNodeTrait,
+ layer: fbs.LayerTrait | null,
+ opacity: number,
+ layoutFields: ReturnType<typeof format.layout.decode.nodeLayout>
+ ): grida.program.nodes.TrayNode {
@@
- ...(effects || {}),
} satisfies grida.program.nodes.TrayNode;
}
@@
- nodes[id] = nodeTypes.tray(
- typedNode as fbs.TrayNode,
- id,
- systemNode,
- layer,
- opacity,
- layoutFields,
- decodedEffects
- );
+ nodes[id] = nodeTypes.tray(
+ typedNode as fbs.TrayNode,
+ id,
+ systemNode,
+ layer,
+ opacity,
+ layoutFields
+ );If tray objects can arrive with fe_* fields on the TS side, the encode path should make the same normalization.
Also applies to: 6559-6569
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/grida-canvas-io/format.ts` around lines 6235 - 6292, The tray
constructor is incorrectly preserving decoded layer effects by spreading
(effects || {}) into the returned object; update the tray function (tray) to NOT
include any decoded effects (do not spread effects or add fe_* fields) so tray
nodes never carry effects, and mirror this normalization in the encode path for
tray nodes (remove/strip fe_* fields when encoding tray) so both
format.decode.format.tray and the corresponding encode logic treat TrayNode as
effect-free.
There was a problem hiding this comment.
Actionable comments posted: 16
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
crates/grida-dev/src/bench/load_bench.rs (1)
222-239:⚠️ Potential issue | 🟡 MinorParent-type diagnostics still misclassify Tray parents as
Leaf.Line 222 adds Tray type labeling, but parent classification (Lines 233-239) still lacks
Node::Tray(_). This makeslayout-diffoutput misleading when diffs are under trays.🛠️ Suggested patch
let parent_info = scene .graph .get_parent(&id) .and_then(|pid| { scene.graph.get_node(&pid).ok().map(|p| match p { cg::node::schema::Node::Container(_) => "Container", + cg::node::schema::Node::Tray(_) => "Tray", cg::node::schema::Node::Group(_) => "Group", cg::node::schema::Node::BooleanOperation(_) => "BoolOp", cg::node::schema::Node::InitialContainer(_) => "ICB", _ => "Leaf", }) })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/grida-dev/src/bench/load_bench.rs` around lines 222 - 239, The parent classification is missing a branch for cg::node::schema::Node::Tray, causing Tray parents to be labeled as "Leaf"; update the closure used to compute parent_info (the scene.graph.get_parent(...).and_then(|pid| { scene.graph.get_node(&pid).ok().map(|p| match p { ... }) })) to include a case for cg::node::schema::Node::Tray(_) => "Tray" (similar to the top-level node_type handling) so trays are reported correctly by layout-diff.editor/grida-canvas/utils/insertion-targeting.ts (1)
16-31:⚠️ Potential issue | 🟠 MajorMake tray target resolution payload-aware.
These helpers still resolve the parent purely from the current selection. If a container is selected, inserting or pasting a tray will still target that container, which conflicts with the new
Scene | Trayparent constraint and can surface as an invalid insert/paste destination. Pass the inserted/copied top-level node types into this resolver and validate the candidate parent before returning it.Also applies to: 43-69
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@editor/grida-canvas/utils/insertion-targeting.ts` around lines 16 - 31, resolveInsertTargetParent currently chooses a parent only from selection, which can return a container/tray even when the node being inserted is a top-level Tray/Scene and invalid there; update resolveInsertTargetParent (and the sibling helper around lines 43-69) to accept an additional parameter describing the top-level node type(s) being inserted/copied, then after you compute the candidate parent (using dq.__getNodeById and dq.getParentId) validate that the parent can legally contain those types (enforce Scene | Tray parent constraints) and return null if it’s not a valid destination; ensure the function signature and call sites are updated so paste/insert logic supplies the inserted node type(s).packages/grida-canvas-io/format.ts (1)
386-405:⚠️ Potential issue | 🟠 MajorAdd round-trip tests for
TrayNodein both Rust and TypeScript suites.TrayNode is a new FlatBuffers schema variant but has no coverage in
crates/grida-canvas/tests/fbs_roundtrip.rsorpackages/grida-canvas-io/__tests__/format-roundtrip.test.ts. Both test suites must include a tray case that verifies preservation of fills, strokes, per-side stroke widths, and rectangular corner radii across the encode/decode cycle. This prevents wire format drift before.gridafiles ship with the new type.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/grida-canvas-io/format.ts` around lines 386 - 405, Add round-trip tests exercising the new TrayNode variant in both test suites: in the Rust test file crates/grida-canvas/tests/fbs_roundtrip.rs add a TrayNode case that builds a node with fills, strokes, per-side stroke widths, and rectangular corner radii, then runs the existing FlatBuffers encode/decode roundtrip helpers and asserts all properties are preserved; and in packages/grida-canvas-io/__tests__/format-roundtrip.test.ts add a corresponding TypeScript case that constructs a tray node, passes it through the same encode/decode utilities used by the file (use the existing NODE_TYPE_ENCODE/NODE_TYPE_DECODE mappings and the same roundtrip helpers), and assert fills, stroke, individual side strokeWidths, and corner radii are identical after decode. Ensure the test nodes use fbs.NodeType.Tray / TrayNode and the grida.program.nodes.Node shape so the maps NODE_TYPE_ENCODE and NODE_TYPE_DECODE handle the tray variant during the roundtrip.packages/grida-canvas-schema/grida.ts (1)
2863-2883:⚠️ Potential issue | 🟡 MinorAdd explicit defaults for TrayNode in factory (matching Container pattern).
The
"tray"case (line 2865) is handled in the generic fallback branch that lacks tray-specific defaults. In contrast, the"container"case (lines 2844-2858) explicitly setscorner_radius: 0andDEFAULT_RECTANGULAR_CORNER_RADIUS.TrayNode requires
stroke_width(fromIStrokeinterface) andcorner_radiusproperties, but the tray factory path doesn't set these explicitly—it relies entirely onprototypeWithoutChildrento provide them. This creates inconsistency and risk if any tray prototype lacks these properties.The
@ts-expect-errorandTODOcomments confirm this area is incomplete. Apply the same pattern as container: set explicit defaults for stroke-related and corner-radius properties before spreading the prototype.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/grida-canvas-schema/grida.ts` around lines 2863 - 2883, The "tray" branch in the node factory currently falls through to the generic prototype spread and doesn't set tray-specific defaults (e.g., stroke_width and corner_radius) causing possible missing IStroke/IShape fields; update the "tray" case in the same switch that returns an UnknownNode to explicitly set defaults before spreading prototypeWithoutChildren (like container does), e.g., include stroke_width: 1 (or your standard default), stroke_align/stroke_style if used, corner_radius: 0 and any DEFAULT_RECTANGULAR_CORNER_RADIUS constant, then spread prototypeWithoutChildren and set id — ensure the change is applied in the case handling that currently destructures children into prototypeWithoutChildren and returns the UnknownNode so trays always get these default stroke/corner properties.
🧹 Nitpick comments (5)
crates/grida-canvas/src/painter/geometry.rs (1)
275-302: Extract shared rounded-rect shape construction for Container and Tray.This block duplicates the existing Container logic almost verbatim; pulling both into one helper will prevent future drift.
♻️ Refactor sketch
+fn build_roundable_box_shape( + width: f32, + height: f32, + corner_radius: CornerRadius, + corner_smoothing: CornerSmoothing, +) -> PainterShape { + if !corner_radius.is_zero() { + if corner_smoothing.value() > 0.0 { + let smooth = OrthogonalSmoothRRectShape { + width, + height, + corner_radius, + corner_smoothing, + }; + PainterShape::from_path(build_orthogonal_smooth_rrect_path(&smooth)) + } else { + let rrect = build_rrect(&RRectShape { + width, + height, + corner_radius, + }); + PainterShape::from_rrect(rrect) + } + } else { + PainterShape::from_rect(Rect::from_xywh(0.0, 0.0, width, height)) + } +} + - Node::Container(n) => { ... } + Node::Container(n) => { + build_roundable_box_shape(bounds.width, bounds.height, n.corner_radius, n.corner_smoothing) + } ... - Node::Tray(n) => { ... } + Node::Tray(n) => { + build_roundable_box_shape(bounds.width, bounds.height, n.corner_radius, n.corner_smoothing) + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/grida-canvas/src/painter/geometry.rs` around lines 275 - 302, The Tray rounded-rect construction duplicates the Container logic; extract a reusable helper (e.g., build_resolved_rect_shape or make_rounded_shape) that accepts width, height, corner_radius, and corner_smoothing and returns a PainterShape using the same decision flow (use OrthogonalSmoothRRectShape + build_orthogonal_smooth_rrect_path -> PainterShape::from_path when corner_smoothing > 0, else build_rrect with RRectShape -> PainterShape::from_rrect, and fallback to Rect -> PainterShape::from_rect when radius is zero). Replace the duplicated block inside Node::Tray and the Container handling to call this new helper, keeping the same helper inputs and returning a PainterShape so callers remain unchanged.editor/grida-canvas/reducers/tools/initial-node.ts (1)
325-325: Minor: Tray ignoresconstraints.strokeparameter.The tray case hardcodes
stroke_paints: [tray_stroke]regardless of theconstraints.strokeparameter. While other shape nodes also don't use stroke constraints (onlylinedoes), this may limit flexibility if callers expect to control single vs multi-stroke mode for trays.If this is intentional (trays always use multi-stroke), consider adding a brief comment explaining the design decision.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@editor/grida-canvas/reducers/tools/initial-node.ts` at line 325, The tray node currently hardcodes stroke_paints: [tray_stroke] and ignores the constraints.stroke parameter; update the tray case in initial-node.ts (the tray creation code that sets stroke_paints and uses tray_stroke) to respect constraints.stroke by branching: if constraints.stroke indicates single-stroke, set the stroke representation to the single-stroke form expected by the node schema, otherwise keep the array multi-stroke form (e.g., [tray_stroke]); alternatively, if trays must always be multi-stroke, add a short comment near stroke_paints explaining that trays intentionally ignore constraints.stroke.editor/grida-canvas/reducers/methods/__tests__/move-tray.test.ts (1)
188-308: Please mirror one tray-parenting case through the drag/transform path.These assertions only exercise the explicit
mvaction. The tray guard also changed intransform.ts, so the pointer reparent path can still drift without at least one transform-level regression covering the same valid/invalid targets.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@editor/grida-canvas/reducers/methods/__tests__/move-tray.test.ts` around lines 188 - 308, Add a test that exercises the transform/drag reparent path (not just the explicit "mv" action) to mirror one of the tray-parenting cases; e.g., replicate the "move tray into another tray — accepted" case but dispatch a transform/drag action (the same dispatch helper used in these tests, targeting the transform path guarded by transform.ts) to move "tray2" into "tray1", then assert that state.document.links["tray1"] contains "tray2" and state.document.links["scene"] does not. Ensure the new test references the same helpers: createState, dispatch, context and targets "tray2" -> "tray1", so the transform.ts tray guard is exercised.crates/grida-canvas/src/painter/gradient.rs (1)
202-287: Diamond gradient functions correctly updated.Both
diamond_gradient_paintanddiamond_gradient_shaderuse the same SKSL shader logic. The SKSL string is duplicated between the two functions (lines 215-223 and 254-262).♻️ Consider extracting SKSL constant
+const DIAMOND_SKSL: &str = r#" + uniform shader gradient; + half4 main(float2 coord) { + float2 p = coord - float2(0.5, 0.5); + float t = (abs(p.x) + abs(p.y)) * 2.0; + t = clamp(t, 0.0, 1.0); + return gradient.eval(float2(t, 0.0)); + } +"#; + pub fn diamond_gradient_paint(...) { ... - const SKSL: &str = r#"..."#; + if let Ok(effect) = skia_safe::RuntimeEffect::make_for_shader(DIAMOND_SKSL, None) { ... } pub fn diamond_gradient_shader(...) { ... - const SKSL: &str = r#"..."#; + let effect = skia_safe::RuntimeEffect::make_for_shader(DIAMOND_SKSL, None).ok()?; ... }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/grida-canvas/src/painter/gradient.rs` around lines 202 - 287, Duplicate SKSL string is defined in both diamond_gradient_paint and diamond_gradient_shader; extract it to a single shared constant (e.g. DIAMOND_GRADIENT_SKSL) and replace the two inline SKSL literals with that constant so both functions reference the same RuntimeEffect source; update usages in RuntimeEffect::make_for_shader(...) in diamond_gradient_paint and diamond_gradient_shader to pass the new constant.crates/grida-dev/src/bench/runner.rs (1)
1574-1597: FrameLoop scenarios don't receive overlay parameter.The
run_frameloop_pan_passfunction (called at line 1580) doesn't accept an overlay parameter, unlike other pass runners. This appears intentional given FrameLoop's different timing model, but consider documenting this difference or adding overlay support for consistency if the real application overlay is rendered during FrameLoop sessions.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/grida-dev/src/bench/runner.rs` around lines 1574 - 1597, The frameloop branch calls run_frameloop_pan_pass without any overlay support, causing FrameLoop scenarios (frameloop_scenarios) to miss the overlay rendering that other passes use; either add overlay support to run_frameloop_pan_pass (update its signature to accept an overlay parameter, thread that overlay through its implementation and all call sites, and ensure the overlay is rendered in the frameloop timing loop) or explicitly document the intentional omission by adding a clear comment/docstring next to run_frameloop_pan_pass and where frameloop_scenarios are defined, describing why overlay is not applied during FrameLoop sessions. Ensure you update ScenarioResult/ScenarioParams usage only if you choose to expose overlay via scenario configuration.
🤖 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/cache/geometry.rs`:
- Around line 406-445: The Tray branch in build_recursive currently returns only
world_bounds so overflowing children don't contribute to ancestor bounds; change
GeoNodeKind::Tray to mirror the Container branch: initialize an
accumulated_bounds with world_bounds, call Self::build_recursive for each child
and union each child's returned bounds into accumulated_bounds, compute
render_bounds = inflate_rect_sides(accumulated_bounds,
&geo.render_bounds_inflation) (or union the child's render bounds similarly),
set the GeometryEntry fields (absolute_bounding_box and absolute_render_bounds)
to the accumulated/unioned bounds (while keeping transform/absolute_transform as
before), store the entry in cache.entries and return the accumulated_bounds so
ancestors see tray+children extents.
In `@crates/grida-canvas/src/io/id_converter.rs`:
- Line 160: Add round-trip tests that cover the Tray node conversion
(JSONNode::Tray -> Node::Tray via TrayNodeRec::from) in both the Rust and
TypeScript test suites: create a Rust test in the fbs_roundtrip.rs test module
that constructs a JSONNode::Tray with representative fields (including
optional/edge cases), serializes to FBS and deserializes back to Node::Tray and
asserts equality; likewise add a TypeScript test in format-roundtrip.test.ts
that uses the existing IO round-trip helpers to serialize a Tray JSON node and
assert the deserialized object matches the original. Ensure tests exercise
typical and boundary field values so Tray conversion is fully covered.
In `@crates/grida-canvas/src/io/io_grida.rs`:
- Around line 1319-1339: The From<JSONGroupNode> for TrayNodeRec implementation
currently discards visual/layout fields; instead map the shared fields from
node.base into the TrayNodeRec (use node.base.position,
node.base.layout_dimensions/size, node.base.corner_radius,
node.base.corner_smoothing, node.base.fills/strokes/stroke_style/stroke_width,
and mask/rotation if present) rather than defaults so trays preserve their
geometry and styles when converting; update the conversion inside the
From<JSONGroupNode> impl and add a JSON tray regression test that reads a .grida
tray JSON, converts via From<JSONGroupNode>, and asserts the TrayNodeRec fields
match the original node.base values.
In `@crates/grida-canvas/src/node/factory.rs`:
- Around line 167-194: The create_tray_node constructor currently leaves fills
and strokes empty causing trays to be invisible; update TrayNodeRec
initialization in create_tray_node to set fills to a default white solid paint,
strokes to a default 10% black solid paint, and set corner_radius to 2 and
stroke_width/stroke_style to reflect a 1px inside stroke per the documented
defaults; target the fields fills, strokes, corner_radius, stroke_width and
stroke_style (e.g., stroke_align) when making these changes so new trays render
as the documented white rectangle with a 10% black inside 1px stroke and 2px
corner radius.
In `@crates/grida-canvas/src/node/schema.rs`:
- Around line 996-1004: The Tray variant currently propagates its mask into the
render path despite docs saying trays have no clipping; update the Tray handling
so it forces no mask: set mask: None in the Node::Tray branch that builds
NodeLayerCore (e.g. in extract_layer_core()), and ensure the Node::mask()
implementation returns None for Node::Tray (and stop using TrayNodeRec.mask in
those arms), so TrayNodeRec is treated as non-masking across the render/extract
paths.
In `@crates/grida-canvas/src/painter/gradient.rs`:
- Around line 2-32: The code uses the nonexistent GradientColors::new API;
update make_gradient to use the skia-safe 0.93.1 enum GradientShaderColors
(import skia_safe::gradient_shader::GradientShaderColors) and pass the correct
variant instead of GradientColors::new — e.g. use
GradientShaderColors::ColorsInSpace(colors, None) when you have Color4f slices
(or GradientShaderColors::Colors(&color_slice_as_Color) if using Color), keep
passing the positions and tile_mode to Gradient::new and leave
Interpolation::default() as before; adjust make_gradient's signature and any
callers if you switch between Color and Color4f to match the chosen
GradientShaderColors variant.
In `@crates/grida-canvas/src/painter/layer.rs`:
- Around line 551-585: The tray branch currently creates a non-clipping
PainterPictureShapeLayer (clip_path: None) but still returns mask: n.mask, which
lets build_render_commands wrap the tray subtree in a MaskGroup and
inadvertently clip siblings; change the FlattenResult returned by this branch to
always use mask: None (i.e., unconditionally ignore n.mask for trays) so trays
cannot emit masks — update the FlattenResult construction here (the block that
builds PainterPictureLayer::Shape / PainterPictureShapeLayer and calls
Self::build_render_commands) to set mask to None.
In `@crates/grida-canvas/src/surface/ui/render.rs`:
- Around line 45-50: BADGE_BG_FALLBACK is too transparent so the badge
composited over light/no-fill trays yields a near-white pill with white text;
update the fallback to a much higher alpha (e.g., 0.7–0.9) or, better, compute
the badge’s composited color and derive the foreground text color from that
composite instead of ignoring alpha. Locate uses of BADGE_BG_FALLBACK (and
related constants BADGE_HOVER_DARKEN and BADGE_STROKE_DARKEN) and either
increase the fallback alpha value or perform compositing with the
canvas/background before running your contrast/text-color selection logic (apply
the same change to the other occurrences referenced around the other blocks).
- Around line 428-429: The label vertical bounds currently use label_top =
screen_tl[1] - title_height - badge_gap_y and label_bottom = screen_tl[1], which
lets tray badges overlap the child's title band because only BADGE_GAP_Y is
reserved; when the parent is a tray, add an extra lift equal to TITLE_BAR_HEIGHT
- BADGE_GAP_Y (i.e., increase label_top by TITLE_BAR_HEIGHT - BADGE_GAP_Y) so
badges sit above the child-title band. Apply the same adjustment to the other
block handling labels (around the 504-536 region) and gate the extra offset
behind the tray/container check so non-tray behavior is unchanged.
In `@docs/editor/features/tray.md`:
- Around line 60-62: Update the "## Figma Compatibility" section to stop
claiming export support: remove or revise the sentence "The round-trip is clean
— Trays export back as Sections" and instead state only that Figma Sections
import as Trays (e.g., "When you import a Figma file, Sections become Trays.")
and add a short note that Figma export is not yet implemented / is future work;
target the text under the "Figma Compatibility" heading where the current
import/export lines appear.
In `@docs/wg/feat-tray/index.md`:
- Around line 73-91: The docs for the Tray model are out of date: update the "No
styling" line and the Constraints table to reflect the PR's runtime/schema
additions (explicit fills, strokes, corner radius, and dimensions) so the spec
matches the implementation; replace the "No styling: Label and boundary only"
text with a concise list of supported visual properties (e.g., fills, strokes,
cornerRadius, width/height) and adjust any rows mentioning "Rendering" or
"Styling" to show that Trays now carry explicit visual properties while still
being canvas-only and non-clipping; ensure occurrences of the Tray concept in
this file (the bullets and the Constraints table) reference these implemented
fields so future work aligns with the schema changes.
- Around line 47-59: The fenced code blocks showing the Scene/Tray/Container
diagrams are missing language tags, causing markdownlint MD040; update each
triple-backtick fence (the block with the Scene tree around the Tray
("Authentication flows") and the second similar block later) to include an
explicit language tag such as ```text (or another suitable tag) so both fenced
blocks are lint-clean; ensure you update both occurrences referenced in the
comment (the block at the top and the one at lines 95-102).
In `@editor/grida-canvas/reducers/event-target.reducer.ts`:
- Around line 460-464: The click-insert path currently uses
__get_insertion_target but should treat tray inserts like the drag-create path;
update the click handler (where draft.tool.node and the insert target are
resolved—see __self_evt_on_click) to use the same ternary logic as the drag
branch: if draft.tool.node === "tray" call __get_tray_insertion_target(draft)
otherwise call __get_insertion_target(draft), or alternatively explicitly
prevent single-click inserts when draft.tool.node === "tray"; ensure you modify
the same resolution point that now uses __get_insertion_target so tray parents
are resolved consistently.
In `@format/grida.fbs`:
- Line 263: Update the TypeScript schema model to include the new Tray/TrayNode
definitions (add a TrayNode interface/type and ensure it is referenced in the
Node union equivalents in packages/grida-canvas-schema/grida.ts) and add
round-trip tests that serialize and deserialize a Tray node: add a Tray case to
the Rust fbs_roundtrip.rs tests (create a fbs::Tray node, round-trip via
FlatBuffers and assert equality) and add a corresponding TypeScript test in
packages/grida-canvas-io/__tests__/format-roundtrip.test.ts that writes a
TrayNode into the TS model, serializes and deserializes via the format layer,
and asserts the resulting object matches the original; ensure the TrayNode
mapping aligns with the fbs::Node union and update any model exports so TrayNode
is available for the tests.
In `@packages/grida-canvas-io/format.ts`:
- Around line 6256-6291: The tray object construction is incorrectly spreading
the full nodeLayout() payload (layoutFields) and the whole effects object into
tray objects; restrict the tray shape to only tray-supported layout-related
fields (e.g., keep layout_positioning with defaulting as already present) and
remove the wholesale spread of effects. Locate the tray return object where
layoutFields and effects are spread (the code building the { type: "tray", ... }
object) and replace the layoutFields spread with a pick of only allowed keys (or
explicitly list allowed layout props), and remove or whitelist specific effect
properties instead of using ...(effects || {}), ensuring nodeLayout() and
effects are not rehydrated onto trays.
In `@test/canvas-tray-node-behavior.md`:
- Around line 1-13: The frontmatter in test/canvas-tray-node-behavior.md is
inconsistent with the filename and ID: either pick module=canvas + area=tray or
keep area=hierarchy and then update the filename and ID to match; ensure the
frontmatter fields id, module, area, and title all use the same module+area
tokens and casing (ID must follow TC-{MODULE}-{AREA}-{NNN} with NNN unique
within that module+area), rename the file if you change the area to follow
{module}-{area}-{short-description}.md, and set date to 2026-03-28 to match the
repo date; update the id number if needed to avoid collisions within that
module+area.
---
Outside diff comments:
In `@crates/grida-dev/src/bench/load_bench.rs`:
- Around line 222-239: The parent classification is missing a branch for
cg::node::schema::Node::Tray, causing Tray parents to be labeled as "Leaf";
update the closure used to compute parent_info (the
scene.graph.get_parent(...).and_then(|pid| {
scene.graph.get_node(&pid).ok().map(|p| match p { ... }) })) to include a case
for cg::node::schema::Node::Tray(_) => "Tray" (similar to the top-level
node_type handling) so trays are reported correctly by layout-diff.
In `@editor/grida-canvas/utils/insertion-targeting.ts`:
- Around line 16-31: resolveInsertTargetParent currently chooses a parent only
from selection, which can return a container/tray even when the node being
inserted is a top-level Tray/Scene and invalid there; update
resolveInsertTargetParent (and the sibling helper around lines 43-69) to accept
an additional parameter describing the top-level node type(s) being
inserted/copied, then after you compute the candidate parent (using
dq.__getNodeById and dq.getParentId) validate that the parent can legally
contain those types (enforce Scene | Tray parent constraints) and return null if
it’s not a valid destination; ensure the function signature and call sites are
updated so paste/insert logic supplies the inserted node type(s).
In `@packages/grida-canvas-io/format.ts`:
- Around line 386-405: Add round-trip tests exercising the new TrayNode variant
in both test suites: in the Rust test file
crates/grida-canvas/tests/fbs_roundtrip.rs add a TrayNode case that builds a
node with fills, strokes, per-side stroke widths, and rectangular corner radii,
then runs the existing FlatBuffers encode/decode roundtrip helpers and asserts
all properties are preserved; and in
packages/grida-canvas-io/__tests__/format-roundtrip.test.ts add a corresponding
TypeScript case that constructs a tray node, passes it through the same
encode/decode utilities used by the file (use the existing
NODE_TYPE_ENCODE/NODE_TYPE_DECODE mappings and the same roundtrip helpers), and
assert fills, stroke, individual side strokeWidths, and corner radii are
identical after decode. Ensure the test nodes use fbs.NodeType.Tray / TrayNode
and the grida.program.nodes.Node shape so the maps NODE_TYPE_ENCODE and
NODE_TYPE_DECODE handle the tray variant during the roundtrip.
In `@packages/grida-canvas-schema/grida.ts`:
- Around line 2863-2883: The "tray" branch in the node factory currently falls
through to the generic prototype spread and doesn't set tray-specific defaults
(e.g., stroke_width and corner_radius) causing possible missing IStroke/IShape
fields; update the "tray" case in the same switch that returns an UnknownNode to
explicitly set defaults before spreading prototypeWithoutChildren (like
container does), e.g., include stroke_width: 1 (or your standard default),
stroke_align/stroke_style if used, corner_radius: 0 and any
DEFAULT_RECTANGULAR_CORNER_RADIUS constant, then spread prototypeWithoutChildren
and set id — ensure the change is applied in the case handling that currently
destructures children into prototypeWithoutChildren and returns the UnknownNode
so trays always get these default stroke/corner properties.
---
Nitpick comments:
In `@crates/grida-canvas/src/painter/geometry.rs`:
- Around line 275-302: The Tray rounded-rect construction duplicates the
Container logic; extract a reusable helper (e.g., build_resolved_rect_shape or
make_rounded_shape) that accepts width, height, corner_radius, and
corner_smoothing and returns a PainterShape using the same decision flow (use
OrthogonalSmoothRRectShape + build_orthogonal_smooth_rrect_path ->
PainterShape::from_path when corner_smoothing > 0, else build_rrect with
RRectShape -> PainterShape::from_rrect, and fallback to Rect ->
PainterShape::from_rect when radius is zero). Replace the duplicated block
inside Node::Tray and the Container handling to call this new helper, keeping
the same helper inputs and returning a PainterShape so callers remain unchanged.
In `@crates/grida-canvas/src/painter/gradient.rs`:
- Around line 202-287: Duplicate SKSL string is defined in both
diamond_gradient_paint and diamond_gradient_shader; extract it to a single
shared constant (e.g. DIAMOND_GRADIENT_SKSL) and replace the two inline SKSL
literals with that constant so both functions reference the same RuntimeEffect
source; update usages in RuntimeEffect::make_for_shader(...) in
diamond_gradient_paint and diamond_gradient_shader to pass the new constant.
In `@crates/grida-dev/src/bench/runner.rs`:
- Around line 1574-1597: The frameloop branch calls run_frameloop_pan_pass
without any overlay support, causing FrameLoop scenarios (frameloop_scenarios)
to miss the overlay rendering that other passes use; either add overlay support
to run_frameloop_pan_pass (update its signature to accept an overlay parameter,
thread that overlay through its implementation and all call sites, and ensure
the overlay is rendered in the frameloop timing loop) or explicitly document the
intentional omission by adding a clear comment/docstring next to
run_frameloop_pan_pass and where frameloop_scenarios are defined, describing why
overlay is not applied during FrameLoop sessions. Ensure you update
ScenarioResult/ScenarioParams usage only if you choose to expose overlay via
scenario configuration.
In `@editor/grida-canvas/reducers/methods/__tests__/move-tray.test.ts`:
- Around line 188-308: Add a test that exercises the transform/drag reparent
path (not just the explicit "mv" action) to mirror one of the tray-parenting
cases; e.g., replicate the "move tray into another tray — accepted" case but
dispatch a transform/drag action (the same dispatch helper used in these tests,
targeting the transform path guarded by transform.ts) to move "tray2" into
"tray1", then assert that state.document.links["tray1"] contains "tray2" and
state.document.links["scene"] does not. Ensure the new test references the same
helpers: createState, dispatch, context and targets "tray2" -> "tray1", so the
transform.ts tray guard is exercised.
In `@editor/grida-canvas/reducers/tools/initial-node.ts`:
- Line 325: The tray node currently hardcodes stroke_paints: [tray_stroke] and
ignores the constraints.stroke parameter; update the tray case in
initial-node.ts (the tray creation code that sets stroke_paints and uses
tray_stroke) to respect constraints.stroke by branching: if constraints.stroke
indicates single-stroke, set the stroke representation to the single-stroke form
expected by the node schema, otherwise keep the array multi-stroke form (e.g.,
[tray_stroke]); alternatively, if trays must always be multi-stroke, add a short
comment near stroke_paints explaining that trays intentionally ignore
constraints.stroke.
🪄 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: 81729c85-b8c2-4743-a953-99f650c273f3
⛔ Files ignored due to path filters (2)
Cargo.lockis excluded by!**/*.lockcrates/grida-canvas/src/io/generated/grida.rsis excluded by!**/generated/**
📒 Files selected for processing (61)
.agents/skills/cg-perf/SKILL.mdcrates/grida-canvas/Cargo.tomlcrates/grida-canvas/examples/fixtures/README.mdcrates/grida-canvas/examples/fixtures/l0_tray.rscrates/grida-canvas/examples/fixtures/mod.rscrates/grida-canvas/examples/golden_sk_mask.rscrates/grida-canvas/examples/golden_sk_text_backdrop_blur_path.rscrates/grida-canvas/examples/skia_bench/skia_bench_cache_image.rscrates/grida-canvas/examples/tool_gen_fixtures.rscrates/grida-canvas/examples/tool_io_grida.rscrates/grida-canvas/examples/tool_io_svg.rscrates/grida-canvas/src/cache/geometry.rscrates/grida-canvas/src/io/id_converter.rscrates/grida-canvas/src/io/io_grida.rscrates/grida-canvas/src/io/io_grida_fbs.rscrates/grida-canvas/src/layout/engine.rscrates/grida-canvas/src/layout/into_taffy.rscrates/grida-canvas/src/node/factory.rscrates/grida-canvas/src/node/scene_graph.rscrates/grida-canvas/src/node/schema.rscrates/grida-canvas/src/painter/geometry.rscrates/grida-canvas/src/painter/gradient.rscrates/grida-canvas/src/painter/layer.rscrates/grida-canvas/src/painter/painter_debug_node.rscrates/grida-canvas/src/resources/mod.rscrates/grida-canvas/src/runtime/scene.rscrates/grida-canvas/src/surface/ui/render.rscrates/grida-dev/Cargo.tomlcrates/grida-dev/src/bench/args.rscrates/grida-dev/src/bench/load_bench.rscrates/grida-dev/src/bench/runner.rsdocs/editor/features/README.mddocs/editor/features/tray.mddocs/editor/shortcuts/index.mddocs/wg/feat-tray/index.mdeditor/grida-canvas-hosted/playground/uxhost-actions.tseditor/grida-canvas-hosted/playground/uxhost-toolbar.tsxeditor/grida-canvas-react-renderer-dom/nodes/index.tseditor/grida-canvas-react-renderer-dom/nodes/node.tsxeditor/grida-canvas-react-renderer-dom/nodes/tray.tsxeditor/grida-canvas-react-starter-kit/starterkit-hierarchy/tree-node.tsxeditor/grida-canvas-react-starter-kit/starterkit-icons/node-type-icon.tsxeditor/grida-canvas-react-starter-kit/starterkit-toolbar/index.tsxeditor/grida-canvas-react-starter-kit/starterkit-toolbar/utils.tseditor/grida-canvas-react/viewport/hotkeys.tsxeditor/grida-canvas/editor.i.tseditor/grida-canvas/policy.tseditor/grida-canvas/reducers/event-target.reducer.tseditor/grida-canvas/reducers/methods/__tests__/move-tray.test.tseditor/grida-canvas/reducers/methods/move.tseditor/grida-canvas/reducers/methods/transform.tseditor/grida-canvas/reducers/tools/initial-node.tseditor/grida-canvas/utils/insertion-targeting.tseditor/grida-canvas/utils/supports.tsfixtures/test-grida/L0.gridaformat/grida.fbspackages/grida-canvas-io-figma/lib.tspackages/grida-canvas-io/format.tspackages/grida-canvas-schema/grida.tspackages/grida-format/turbo.jsontest/canvas-tray-node-behavior.md
| // Tray has explicit dimensions (like Container) but no clipping. | ||
| // Children render inside the tray bounds. | ||
| GeoNodeKind::Tray => { | ||
| let local_bounds = Rectangle { | ||
| x: 0.0, | ||
| y: 0.0, | ||
| width: geo.width, | ||
| height: geo.height, | ||
| }; | ||
|
|
||
| let world_transform = parent_world.compose(&geo.transform); | ||
| let world_bounds = transform_rect(&local_bounds, &world_transform); | ||
| let render_bounds = inflate_rect_sides(world_bounds, &geo.render_bounds_inflation); | ||
|
|
||
| if let Some(children) = graph.get_children(id) { | ||
| for child_id in children { | ||
| Self::build_recursive( | ||
| child_id, | ||
| &world_transform, | ||
| Some(*id), | ||
| cache, | ||
| graph, | ||
| geo_inputs, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| let entry = GeometryEntry { | ||
| transform: geo.transform, | ||
| absolute_transform: world_transform, | ||
| bounding_box: local_bounds, | ||
| absolute_bounding_box: world_bounds, | ||
| absolute_render_bounds: render_bounds, | ||
| parent: parent_id, | ||
| dirty_transform: false, | ||
| dirty_bounds: false, | ||
| }; | ||
| cache.entries.insert(*id, entry); | ||
| world_bounds | ||
| } |
There was a problem hiding this comment.
Return the tray subtree bounds to the parent DFS.
This branch stores the tray's own rectangle, which is fine, but it also returns only world_bounds. Any child that overflows the tray never contributes to ancestor scene/group bounds, so the new non-clipping tray behavior gets lost for parent culling / fit-to-content flows. Please mirror the container branch and return the union of tray + child bounds.
🛠️ Suggested fix
GeoNodeKind::Tray => {
let local_bounds = Rectangle {
x: 0.0,
y: 0.0,
width: geo.width,
height: geo.height,
};
let world_transform = parent_world.compose(&geo.transform);
let world_bounds = transform_rect(&local_bounds, &world_transform);
+ let mut union_world_bounds = world_bounds;
let render_bounds = inflate_rect_sides(world_bounds, &geo.render_bounds_inflation);
if let Some(children) = graph.get_children(id) {
for child_id in children {
- Self::build_recursive(
+ let child_bounds = Self::build_recursive(
child_id,
&world_transform,
Some(*id),
cache,
graph,
geo_inputs,
);
+ union_world_bounds = rect::union(&[union_world_bounds, child_bounds]);
}
}
let entry = GeometryEntry {
transform: geo.transform,
absolute_transform: world_transform,
bounding_box: local_bounds,
absolute_bounding_box: world_bounds,
absolute_render_bounds: render_bounds,
parent: parent_id,
dirty_transform: false,
dirty_bounds: false,
};
cache.entries.insert(*id, entry);
- world_bounds
+ union_world_bounds
}📝 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.
| // Tray has explicit dimensions (like Container) but no clipping. | |
| // Children render inside the tray bounds. | |
| GeoNodeKind::Tray => { | |
| let local_bounds = Rectangle { | |
| x: 0.0, | |
| y: 0.0, | |
| width: geo.width, | |
| height: geo.height, | |
| }; | |
| let world_transform = parent_world.compose(&geo.transform); | |
| let world_bounds = transform_rect(&local_bounds, &world_transform); | |
| let render_bounds = inflate_rect_sides(world_bounds, &geo.render_bounds_inflation); | |
| if let Some(children) = graph.get_children(id) { | |
| for child_id in children { | |
| Self::build_recursive( | |
| child_id, | |
| &world_transform, | |
| Some(*id), | |
| cache, | |
| graph, | |
| geo_inputs, | |
| ); | |
| } | |
| } | |
| let entry = GeometryEntry { | |
| transform: geo.transform, | |
| absolute_transform: world_transform, | |
| bounding_box: local_bounds, | |
| absolute_bounding_box: world_bounds, | |
| absolute_render_bounds: render_bounds, | |
| parent: parent_id, | |
| dirty_transform: false, | |
| dirty_bounds: false, | |
| }; | |
| cache.entries.insert(*id, entry); | |
| world_bounds | |
| } | |
| // Tray has explicit dimensions (like Container) but no clipping. | |
| // Children render inside the tray bounds. | |
| GeoNodeKind::Tray => { | |
| let local_bounds = Rectangle { | |
| x: 0.0, | |
| y: 0.0, | |
| width: geo.width, | |
| height: geo.height, | |
| }; | |
| let world_transform = parent_world.compose(&geo.transform); | |
| let world_bounds = transform_rect(&local_bounds, &world_transform); | |
| let mut union_world_bounds = world_bounds; | |
| let render_bounds = inflate_rect_sides(world_bounds, &geo.render_bounds_inflation); | |
| if let Some(children) = graph.get_children(id) { | |
| for child_id in children { | |
| let child_bounds = Self::build_recursive( | |
| child_id, | |
| &world_transform, | |
| Some(*id), | |
| cache, | |
| graph, | |
| geo_inputs, | |
| ); | |
| union_world_bounds = rect::union(&[union_world_bounds, child_bounds]); | |
| } | |
| } | |
| let entry = GeometryEntry { | |
| transform: geo.transform, | |
| absolute_transform: world_transform, | |
| bounding_box: local_bounds, | |
| absolute_bounding_box: world_bounds, | |
| absolute_render_bounds: render_bounds, | |
| parent: parent_id, | |
| dirty_transform: false, | |
| dirty_bounds: false, | |
| }; | |
| cache.entries.insert(*id, entry); | |
| union_world_bounds | |
| } |
🤖 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 406 - 445, The Tray
branch in build_recursive currently returns only world_bounds so overflowing
children don't contribute to ancestor bounds; change GeoNodeKind::Tray to mirror
the Container branch: initialize an accumulated_bounds with world_bounds, call
Self::build_recursive for each child and union each child's returned bounds into
accumulated_bounds, compute render_bounds =
inflate_rect_sides(accumulated_bounds, &geo.render_bounds_inflation) (or union
the child's render bounds similarly), set the GeometryEntry fields
(absolute_bounding_box and absolute_render_bounds) to the accumulated/unioned
bounds (while keeping transform/absolute_transform as before), store the entry
in cache.entries and return the accumulated_bounds so ancestors see
tray+children extents.
| fn convert_json_node(json_node: JSONNode) -> Node { | ||
| match json_node { | ||
| JSONNode::Group(group) => Node::Group(GroupNodeRec::from(group)), | ||
| JSONNode::Tray(tray) => Node::Tray(TrayNodeRec::from(tray)), |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify tray coverage in required round-trip tests
fd -i 'fbs_roundtrip.rs|format-roundtrip.test.ts'
echo "---- Rust round-trip test coverage ----"
rg -n -C2 --type=rust '\bTray\b|tray' crates/grida-canvas/tests/fbs_roundtrip.rs
echo "---- TypeScript round-trip test coverage ----"
rg -n -C2 --type=ts '\bTray\b|tray' packages/grida-canvas-io/__tests__/format-roundtrip.test.tsRepository: gridaco/grida
Length of output: 245
Add round-trip test coverage for Tray node type.
The Tray conversion at line 160 lacks corresponding round-trip tests. Per schema change requirements, add test cases for Tray serialization/deserialization in both:
crates/grida-canvas/tests/fbs_roundtrip.rs(Rust)packages/grida-canvas-io/__tests__/format-roundtrip.test.ts(TypeScript)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/grida-canvas/src/io/id_converter.rs` at line 160, Add round-trip tests
that cover the Tray node conversion (JSONNode::Tray -> Node::Tray via
TrayNodeRec::from) in both the Rust and TypeScript test suites: create a Rust
test in the fbs_roundtrip.rs test module that constructs a JSONNode::Tray with
representative fields (including optional/edge cases), serializes to FBS and
deserializes back to Node::Tray and asserts equality; likewise add a TypeScript
test in format-roundtrip.test.ts that uses the existing IO round-trip helpers to
serialize a Tray JSON node and assert the deserialized object matches the
original. Ensure tests exercise typical and boundary field values so Tray
conversion is fully covered.
| impl From<JSONGroupNode> for TrayNodeRec { | ||
| fn from(node: JSONGroupNode) -> Self { | ||
| // TODO: JSONNode::Tray reuses JSONGroupNode shape; visual fields default | ||
| // until a dedicated JSONTrayNode is introduced. | ||
| TrayNodeRec { | ||
| active: node.base.active, | ||
| opacity: node.base.opacity, | ||
| blend_mode: node.base.blend_mode.into(), | ||
| mask: node.base.mask.map(|m| m.into()), | ||
| rotation: 0.0, | ||
| position: Default::default(), | ||
| layout_dimensions: Default::default(), | ||
| corner_radius: Default::default(), | ||
| corner_smoothing: Default::default(), | ||
| fills: Paints::default(), | ||
| strokes: Paints::default(), | ||
| stroke_style: StrokeStyle::default(), | ||
| stroke_width: StrokeWidth::default(), | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Don't default imported trays to an empty rect.
JSONGroupNode.base already contains the tray's position, dimensions, corner radius, paints, and stroke settings, but this converter throws them all away and emits a default TrayNodeRec. A tray loaded from .grida JSON will therefore deserialize as an unstyled 0-sized tray, so the new node type does not round-trip through JSON correctly. Please map the shared fields here and add a tray JSON regression test.
🛠️ Suggested mapping
impl From<JSONGroupNode> for TrayNodeRec {
fn from(node: JSONGroupNode) -> Self {
- // TODO: JSONNode::Tray reuses JSONGroupNode shape; visual fields default
- // until a dedicated JSONTrayNode is introduced.
+ let stroke_width: StrokeWidth = build_unknown_stroke_width(&node.base).into();
+ let width = match node.base.width {
+ CSSDimension::Auto => None,
+ CSSDimension::LengthPX(length) => Some(length),
+ };
+ let height = match node.base.height {
+ CSSDimension::Auto => None,
+ CSSDimension::LengthPX(length) => Some(length),
+ };
+
TrayNodeRec {
active: node.base.active,
opacity: node.base.opacity,
blend_mode: node.base.blend_mode.into(),
mask: node.base.mask.map(|m| m.into()),
- rotation: 0.0,
- position: Default::default(),
- layout_dimensions: Default::default(),
- corner_radius: Default::default(),
- corner_smoothing: Default::default(),
- fills: Paints::default(),
- strokes: Paints::default(),
- stroke_style: StrokeStyle::default(),
- stroke_width: StrokeWidth::default(),
+ rotation: node.base.rotation,
+ position: json_position_to_layout_basis(
+ node.base.layout_inset_left,
+ node.base.layout_inset_top,
+ node.base.layout_inset_right,
+ node.base.layout_inset_bottom,
+ ),
+ layout_dimensions: LayoutDimensionStyle {
+ layout_target_width: width,
+ layout_target_height: height,
+ layout_min_width: node.base.layout_min_width,
+ layout_max_width: node.base.layout_max_width,
+ layout_min_height: node.base.layout_min_height,
+ layout_max_height: node.base.layout_max_height,
+ layout_target_aspect_ratio: node.base.layout_target_aspect_ratio,
+ },
+ corner_radius: merge_corner_radius(
+ node.base.corner_radius,
+ node.base.rectangular_corner_radius_top_left,
+ node.base.rectangular_corner_radius_top_right,
+ node.base.rectangular_corner_radius_bottom_right,
+ node.base.rectangular_corner_radius_bottom_left,
+ ),
+ corner_smoothing: CornerSmoothing::new(node.base.corner_smoothing.unwrap_or(0.0)),
+ fills: merge_paints(node.base.fill, node.base.fill_paints),
+ strokes: merge_paints(node.base.stroke, node.base.stroke_paints),
+ stroke_style: StrokeStyle {
+ stroke_align: node.base.stroke_align.unwrap_or(StrokeAlign::Inside),
+ stroke_cap: node.base.stroke_cap.unwrap_or_default(),
+ stroke_join: node.base.stroke_join.unwrap_or_default(),
+ stroke_miter_limit: node.base.stroke_miter_limit.unwrap_or_default(),
+ stroke_dash_array: node.base.stroke_dash_array.map(StrokeDashArray::from),
+ },
+ stroke_width,
}
}
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/grida-canvas/src/io/io_grida.rs` around lines 1319 - 1339, The
From<JSONGroupNode> for TrayNodeRec implementation currently discards
visual/layout fields; instead map the shared fields from node.base into the
TrayNodeRec (use node.base.position, node.base.layout_dimensions/size,
node.base.corner_radius, node.base.corner_smoothing,
node.base.fills/strokes/stroke_style/stroke_width, and mask/rotation if present)
rather than defaults so trays preserve their geometry and styles when
converting; update the conversion inside the From<JSONGroupNode> impl and add a
JSON tray regression test that reads a .grida tray JSON, converts via
From<JSONGroupNode>, and asserts the TrayNodeRec fields match the original
node.base values.
| /// Creates a new tray node with default values (Figma SECTION equivalent) | ||
| pub fn create_tray_node(&self) -> TrayNodeRec { | ||
| TrayNodeRec { | ||
| active: true, | ||
| opacity: Self::DEFAULT_OPACITY, | ||
| blend_mode: LayerBlendMode::default(), | ||
| mask: None, | ||
| rotation: 0.0, | ||
| position: Default::default(), | ||
| layout_dimensions: LayoutDimensionStyle { | ||
| layout_target_width: Some(Self::DEFAULT_SIZE.width), | ||
| layout_target_height: Some(Self::DEFAULT_SIZE.height), | ||
| ..Default::default() | ||
| }, | ||
| corner_radius: Default::default(), | ||
| corner_smoothing: Default::default(), | ||
| fills: Paints::default(), | ||
| strokes: Default::default(), | ||
| stroke_style: StrokeStyle { | ||
| stroke_align: Self::DEFAULT_STROKE_ALIGN, | ||
| stroke_cap: StrokeCap::default(), | ||
| stroke_join: StrokeJoin::default(), | ||
| stroke_miter_limit: StrokeMiterLimit::default(), | ||
| stroke_dash_array: None, | ||
| }, | ||
| stroke_width: StrokeWidth::Uniform(Self::DEFAULT_STROKE_WIDTH), | ||
| } | ||
| } |
There was a problem hiding this comment.
Factory-created trays start invisible.
This constructor leaves both fills and strokes empty, so the new 100×100 tray renders with no visible surface or border despite carrying a 1px stroke width. That also diverges from the documented tray defaults in this PR (white fill, 10% black inside stroke, radius 2).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/grida-canvas/src/node/factory.rs` around lines 167 - 194, The
create_tray_node constructor currently leaves fills and strokes empty causing
trays to be invisible; update TrayNodeRec initialization in create_tray_node to
set fills to a default white solid paint, strokes to a default 10% black solid
paint, and set corner_radius to 2 and stroke_width/stroke_style to reflect a 1px
inside stroke per the documented defaults; target the fields fills, strokes,
corner_radius, stroke_width and stroke_style (e.g., stroke_align) when making
these changes so new trays render as the documented white rectangle with a 10%
black inside 1px stroke and 2px corner radius.
| Node::Tray(n) => NodeLayerCore { | ||
| active: n.active, | ||
| opacity: n.opacity, | ||
| blend_mode: n.blend_mode, | ||
| mask: n.mask, | ||
| clips_content: false, | ||
| has_effects: false, // Tray never has effects | ||
| node_type: NodeTypeTag::Tray, | ||
| is_flex: false, |
There was a problem hiding this comment.
The tray contract and implementation disagree on clipping.
The new doc block says trays have “no clipping”, but TrayNodeRec still exposes mask, and the new extract_layer_core() / Node::mask() arms pass it through to the render path. That makes trays mask-capable even though masking is clipping semantics. Unless masking is intentionally part of the tray design, force mask: None for this variant.
Also applies to: 1180-1184, 1442-1453
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/grida-canvas/src/node/schema.rs` around lines 996 - 1004, The Tray
variant currently propagates its mask into the render path despite docs saying
trays have no clipping; update the Tray handling so it forces no mask: set mask:
None in the Node::Tray branch that builds NodeLayerCore (e.g. in
extract_layer_core()), and ensure the Node::mask() implementation returns None
for Node::Tray (and stop using TrayNodeRec.mask in those arms), so TrayNodeRec
is treated as non-masking across the render/extract paths.
|
|
||
| // groups and containers | ||
| Group, | ||
| Tray, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Check if NodeType enum is used in serialization or just for metadata.
# Look for NodeType usage in the Rust I/O code.
echo "=== Searching for NodeType usage in Rust codebase ==="
rg -n "NodeType" crates/grida-canvas/src/io/
echo ""
echo "=== Checking if NodeType is written to FBS files ==="
rg -n -C3 "node_type|NodeType" crates/grida-canvas/src/io/io_grida_fbs.rsRepository: gridaco/grida
Length of output: 7777
🏁 Script executed:
#!/bin/bash
echo "=== Checking if NodeType is a field in grida.fbs tables ==="
rg -n "NodeType" format/grida.fbs | head -20
echo ""
echo "=== Searching for CanvasDocument.schema_version in grida.fbs ==="
rg -n -A10 "table CanvasDocument" format/grida.fbs | head -30
echo ""
echo "=== Checking TrayNode table definition ==="
rg -n -A15 "table TrayNode" format/grida.fbsRepository: gridaco/grida
Length of output: 1510
🏁 Script executed:
#!/bin/bash
echo "=== Checking if schema_version was updated in TypeScript ==="
rg -n "SCHEMA_VERSION" packages/grida-canvas-schema/grida.ts | head -10
echo ""
echo "=== Checking if Rust schema.rs was updated ==="
rg -n "TrayNode\|Tray" crates/grida-canvas/src/node/schema.rs | head -10
echo ""
echo "=== Checking if round-trip tests were added ==="
grep -n "Tray\|tray" crates/grida-canvas/tests/fbs_roundtrip.rs || echo "No Tray test found"
grep -n "Tray\|tray" packages/grida-canvas-io/__tests__/format-roundtrip.test.ts || echo "No Tray test found"Repository: gridaco/grida
Length of output: 366
🏁 Script executed:
#!/bin/bash
echo "=== Checking Rust schema.rs file structure ==="
head -100 crates/grida-canvas/src/node/schema.rs | grep -E "pub enum|pub struct" | head -20
echo ""
echo "=== Checking for existing Node enum in schema.rs ==="
rg -n "pub enum Node" crates/grida-canvas/src/node/schema.rs -A20 | head -40
echo ""
echo "=== Checking TypeScript grida.ts for Node type ==="
rg -n "export type Node\|export interface Node" packages/grida-canvas-schema/grida.ts -A5 | head -30
echo ""
echo "=== Verify if schema_version is part of this PR (check git diff context) ==="
cd format && git log --oneline -1 grida.fbs 2>/dev/null || echo "Not a git repo context"Repository: gridaco/grida
Length of output: 1439
🏁 Script executed:
#!/bin/bash
echo "=== Checking TypeScript grida.ts for Node discriminated union ==="
rg -n "type Node\|interface Node" packages/grida-canvas-schema/grida.ts -B2 -A10 | head -50
echo ""
echo "=== Checking if TrayNode is exported in TS ==="
rg -n "TrayNode\|Tray" packages/grida-canvas-schema/grida.ts
echo ""
echo "=== Checking roundtrip test files exist ==="
ls -la crates/grida-canvas/tests/fbs_roundtrip.rs 2>/dev/null && echo "Rust test exists" || echo "Rust test NOT found"
ls -la packages/grida-canvas-io/__tests__/format-roundtrip.test.ts 2>/dev/null && echo "TS test exists" || echo "TS test NOT found"
echo ""
echo "=== Checking what nodes are tested in roundtrip ==="
rg -n "Node::" crates/grida-canvas/tests/fbs_roundtrip.rs | head -15
</parameter>Repository: gridaco/grida
Length of output: 1454
🏁 Script executed:
#!/bin/bash
echo "=== Checking TS grida.ts file size and structure ==="
wc -l packages/grida-canvas-schema/grida.ts
echo ""
echo "=== Searching for any Tray reference in TS ==="
rg -n "tray\|Tray" packages/grida-canvas-schema/ -i
echo ""
echo "=== Checking Node enum in Rust roundtrip - looking for Tray ==="
rg -n "Node::Tray" crates/grida-canvas/tests/fbs_roundtrip.rs
echo ""
echo "=== Checking TS roundtrip for Tray ==="
rg -n "Tray\|tray" packages/grida-canvas-io/__tests__/format-roundtrip.test.ts -iRepository: gridaco/grida
Length of output: 305
Remove concern about NodeType enum reordering; address missing TypeScript model and test coverage.
The NodeType enum insertion is not a breaking change—NodeType is not a persisted field (it's commented out in line 1495 of the schema). The actual node dispatch uses the fbs::Node union, which correctly appends TrayNode at the end. However, the following critical updates are missing:
-
TypeScript model update:
packages/grida-canvas-schema/grida.tshas noTrayNodedefinition. Per guidelines, the TS document model must be updated whenever the FlatBuffers schema changes. -
Round-trip tests: No test cases for
Traynode in eithercrates/grida-canvas/tests/fbs_roundtrip.rs(Rust) orpackages/grida-canvas-io/__tests__/format-roundtrip.test.ts(TypeScript). Per guidelines, add round-trip tests for all schema changes.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@format/grida.fbs` at line 263, Update the TypeScript schema model to include
the new Tray/TrayNode definitions (add a TrayNode interface/type and ensure it
is referenced in the Node union equivalents in
packages/grida-canvas-schema/grida.ts) and add round-trip tests that serialize
and deserialize a Tray node: add a Tray case to the Rust fbs_roundtrip.rs tests
(create a fbs::Tray node, round-trip via FlatBuffers and assert equality) and
add a corresponding TypeScript test in
packages/grida-canvas-io/__tests__/format-roundtrip.test.ts that writes a
TrayNode into the TS model, serializes and deserializes via the format layer,
and asserts the resulting object matches the original; ensure the TrayNode
mapping aligns with the fbs::Node union and update any model exports so TrayNode
is available for the tests.
| return { | ||
| type: "tray", | ||
| id, | ||
| name: baseName, | ||
| active: systemNode.active(), | ||
| locked: systemNode.locked(), | ||
| opacity, | ||
| ...(fillPaints ? { fill_paints: fillPaints } : {}), | ||
| ...(strokePaints ? { stroke_paints: strokePaints } : {}), | ||
| stroke_width: | ||
| format.shape.decode.deriveStrokeWidth(strokeGeometryProps), | ||
| stroke_cap: strokeGeometryProps.stroke_cap, | ||
| stroke_join: strokeGeometryProps.stroke_join, | ||
| ...(strokeGeometryProps.stroke_dash_array | ||
| ? { stroke_dash_array: strokeGeometryProps.stroke_dash_array } | ||
| : {}), | ||
| rectangular_corner_radius_top_left: | ||
| cornerRadiusProps.rectangular_corner_radius_top_left, | ||
| rectangular_corner_radius_top_right: | ||
| cornerRadiusProps.rectangular_corner_radius_top_right, | ||
| rectangular_corner_radius_bottom_left: | ||
| cornerRadiusProps.rectangular_corner_radius_bottom_left, | ||
| rectangular_corner_radius_bottom_right: | ||
| cornerRadiusProps.rectangular_corner_radius_bottom_right, | ||
| corner_smoothing: cornerRadiusProps.corner_smoothing, | ||
| rectangular_stroke_width_top: | ||
| strokeGeometryProps.rectangular_stroke_width_top, | ||
| rectangular_stroke_width_right: | ||
| strokeGeometryProps.rectangular_stroke_width_right, | ||
| rectangular_stroke_width_bottom: | ||
| strokeGeometryProps.rectangular_stroke_width_bottom, | ||
| rectangular_stroke_width_left: | ||
| strokeGeometryProps.rectangular_stroke_width_left, | ||
| ...layoutFields, | ||
| layout_positioning: layoutFields.layout_positioning ?? "relative", | ||
| ...(effects || {}), |
There was a problem hiding this comment.
Don’t hydrate container/effect traits onto trays.
layoutFields here is the full nodeLayout() payload, so this spread can reintroduce layout_mode, padding/gap, and axis-alignment props, and effects is appended wholesale. That makes trays round-trip unsupported behaviors even though they’re supposed to be non-layout / non-effects primitives.
🧩 Narrow the decoded tray shape to tray-supported fields
return {
type: "tray",
id,
name: baseName,
active: systemNode.active(),
locked: systemNode.locked(),
opacity,
...(fillPaints ? { fill_paints: fillPaints } : {}),
...(strokePaints ? { stroke_paints: strokePaints } : {}),
stroke_width:
format.shape.decode.deriveStrokeWidth(strokeGeometryProps),
stroke_cap: strokeGeometryProps.stroke_cap,
stroke_join: strokeGeometryProps.stroke_join,
...(strokeGeometryProps.stroke_dash_array
? { stroke_dash_array: strokeGeometryProps.stroke_dash_array }
: {}),
rectangular_corner_radius_top_left:
cornerRadiusProps.rectangular_corner_radius_top_left,
rectangular_corner_radius_top_right:
cornerRadiusProps.rectangular_corner_radius_top_right,
rectangular_corner_radius_bottom_left:
cornerRadiusProps.rectangular_corner_radius_bottom_left,
rectangular_corner_radius_bottom_right:
cornerRadiusProps.rectangular_corner_radius_bottom_right,
corner_smoothing: cornerRadiusProps.corner_smoothing,
rectangular_stroke_width_top:
strokeGeometryProps.rectangular_stroke_width_top,
rectangular_stroke_width_right:
strokeGeometryProps.rectangular_stroke_width_right,
rectangular_stroke_width_bottom:
strokeGeometryProps.rectangular_stroke_width_bottom,
rectangular_stroke_width_left:
strokeGeometryProps.rectangular_stroke_width_left,
- ...layoutFields,
+ layout_inset_left: layoutFields.layout_inset_left,
+ layout_inset_top: layoutFields.layout_inset_top,
+ layout_inset_right: layoutFields.layout_inset_right,
+ layout_inset_bottom: layoutFields.layout_inset_bottom,
+ layout_target_width: layoutFields.layout_target_width,
+ layout_target_height: layoutFields.layout_target_height,
+ rotation: layoutFields.rotation ?? 0,
layout_positioning: layoutFields.layout_positioning ?? "relative",
- ...(effects || {}),
} satisfies grida.program.nodes.TrayNode;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/grida-canvas-io/format.ts` around lines 6256 - 6291, The tray object
construction is incorrectly spreading the full nodeLayout() payload
(layoutFields) and the whole effects object into tray objects; restrict the tray
shape to only tray-supported layout-related fields (e.g., keep
layout_positioning with defaulting as already present) and remove the wholesale
spread of effects. Locate the tray return object where layoutFields and effects
are spread (the code building the { type: "tray", ... } object) and replace the
layoutFields spread with a pick of only allowed keys (or explicitly list allowed
layout props), and remove or whitelist specific effect properties instead of
using ...(effects || {}), ensuring nodeLayout() and effects are not rehydrated
onto trays.
Introduces .agents/skills/vision/ with ask.py — a uv-run script that queries images through a local Ollama vision model (qwen3.5, gemma3, etc.) without loading them into the main agent context. Supports describe, custom prompts, model selection, ping health check, and system info. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Introduced `encode_positional_layout` function for tray nodes, allowing for layout encoding without container or child styles. - Updated tray documentation to clarify Figma export limitations and enhanced node hierarchy details. - Adjusted event target reducer to handle tray-specific insertion logic. - Modified test case metadata to reflect area changes and updated date.
There was a problem hiding this comment.
Actionable comments posted: 3
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/reducers/event-target.reducer.ts (2)
140-195:⚠️ Potential issue | 🟡 MinorBlock-scope the
insertswitch case to avoid lexical leakage (lint error).
const parentat Line 141 is declared directly under acasewithout braces. This triggersnoSwitchDeclarationsand can cause cross-case scope hazards.Suggested fix
- case "insert": + case "insert": { const parent = draft.tool.node === "tray" ? __get_tray_insertion_target(draft) : __get_insertion_target(draft); @@ if (nnode.type === "tspan") { draft.content_edit_mode = { type: "text", node_id: nnode.id }; } break; + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@editor/grida-canvas/reducers/event-target.reducer.ts` around lines 140 - 195, The "insert" switch case declares block-scoped variables (parent, nnode, relpos, center_translate_delta, nnode_relative_position) directly under case which triggers noSwitchDeclarations; fix by adding a block around the case body (case "insert": { ... break; }) so all declarations are lexically contained, keeping existing calls to __get_tray_insertion_target/draft.tool.node, __get_insertion_target, self_try_insert_node, self_select_cursor_tool, self_selectNode, and the try/catch/reportError logic unchanged inside the new braces.
492-499:⚠️ Potential issue | 🟡 MinorTemporary fill suppression/restoration uses mismatched properties.
For container/tray insert-and-resize, Line 498 clears
fill, but finalize restoresfill_paints(Line 1110). If rendering relies onfill_paints, the temporary “see-through while dragging” behavior won’t apply reliably.Suggested fix
- if (draft.tool.node === "container" || draft.tool.node === "tray") { + if (draft.tool.node === "container" || draft.tool.node === "tray") { pending = { node_id: nnode.id, prototype: JSON.parse(JSON.stringify(nnode)), }; // UX: temporary remove fill to let user see whats behind. - (nnode as grida.program.nodes.ContainerNode).fill = undefined; + (nnode as grida.program.nodes.ContainerNode).fill = undefined; + (nnode as grida.program.nodes.ContainerNode).fill_paints = undefined; }Also applies to: 1105-1111
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@editor/grida-canvas/reducers/event-target.reducer.ts` around lines 492 - 499, The temporary transparency logic mutates a different property than finalize restores: the code clears nnode.fill but finalize expects fill_paints, breaking the see-through UX; update the temporary suppression to operate on the same property used by finalize (use fill_paints consistently), i.e., when creating pending in the insert-and-resize branch (where draft.tool.node === "container" || "tray" and nnode is cast to grida.program.nodes.ContainerNode) save and clear pending.prototype.fill_paints (or set it to an empty array/undefined) instead of mutating fill, and ensure the finalize/restore logic reads/writes pending.prototype.fill_paints so saved paints are correctly restored.
🧹 Nitpick comments (1)
.agents/skills/vision/scripts/ask.py (1)
73-83: Narrow_check_server()exception handling to expected Ollama exceptions.The broad
Exceptioncatch can hide unexpected defects and complicate diagnostics. Replace with specific exceptions thatollama.list()raises:
httpx.ConnectError— connection failures (server not running, unreachable)ollama._types.ResponseError— API errors (non-2xx HTTP responses)Example:
try: ollama.list() except httpx.ConnectError: print( "error: cannot reach Ollama — server not running\n" "hint: start Ollama with `ollama serve`", file=sys.stderr, ) sys.exit(1) except ollama._types.ResponseError as e: print( f"error: Ollama server error — {e.error}\n" f"hint: check your Ollama installation", file=sys.stderr, ) sys.exit(1)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.agents/skills/vision/scripts/ask.py around lines 73 - 83, Narrow the broad except in _check_server(): replace the generic "except Exception" with two specific handlers—catch httpx.ConnectError to handle connection failures (print a message about Ollama not running and hint to run `ollama serve` then sys.exit(1)), and catch ollama._types.ResponseError as e to report API/server errors (print the server error detail from e and a hint to check Ollama installation then sys.exit(1)); update the import usage or fully-qualified names so httpx.ConnectError and ollama._types.ResponseError are resolvable in the scope where _check_server() calls ollama.list().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.agents/skills/vision/scripts/ask.py:
- Around line 312-314: The current validation uses image_path.exists(), which
will allow directories (including those with image-like suffixes); update the
check to reject non-files by using image_path.is_file() (or explicitly checking
image_path.is_dir()) so that only regular files are accepted; modify the block
around image_path.exists() / image_path to call image_path.is_file() and print
the same error and exit if it returns False, referencing the image_path variable
in the validation.
- Around line 49-60: The list VISION_NAME_FRAGMENTS is over-broad because the
generic "qwen" fragment causes text-only Qwen models to be treated as
vision-capable; update VISION_NAME_FRAGMENTS by removing the generic "qwen"
entry and instead include only the explicit vision suffixes used by Qwen vision
models (e.g., add "qwen2.5vl", "qwen3-vl", "qwen3.5vl" or other exact
vision-specific fragments your deployment supports) so only true vision variants
are matched.
- Line 208: Replace the relative command names passed to subprocess.run with
absolute binary paths to mitigate PATH-based execution risks: locate the
subprocess.run calls that invoke "vm_stat" and "sysctl" (the call assigning to
result and the subsequent sysctl invocation) and change their argv[0] to the
system absolute paths (e.g., /usr/bin/vm_stat or /usr/sbin/vm_stat and
/usr/sbin/sysctl as appropriate on target platforms); keep capture_output, text,
and timeout arguments unchanged and ensure the commands still execute correctly
on the intended macOS environment.
---
Outside diff comments:
In `@editor/grida-canvas/reducers/event-target.reducer.ts`:
- Around line 140-195: The "insert" switch case declares block-scoped variables
(parent, nnode, relpos, center_translate_delta, nnode_relative_position)
directly under case which triggers noSwitchDeclarations; fix by adding a block
around the case body (case "insert": { ... break; }) so all declarations are
lexically contained, keeping existing calls to
__get_tray_insertion_target/draft.tool.node, __get_insertion_target,
self_try_insert_node, self_select_cursor_tool, self_selectNode, and the
try/catch/reportError logic unchanged inside the new braces.
- Around line 492-499: The temporary transparency logic mutates a different
property than finalize restores: the code clears nnode.fill but finalize expects
fill_paints, breaking the see-through UX; update the temporary suppression to
operate on the same property used by finalize (use fill_paints consistently),
i.e., when creating pending in the insert-and-resize branch (where
draft.tool.node === "container" || "tray" and nnode is cast to
grida.program.nodes.ContainerNode) save and clear pending.prototype.fill_paints
(or set it to an empty array/undefined) instead of mutating fill, and ensure the
finalize/restore logic reads/writes pending.prototype.fill_paints so saved
paints are correctly restored.
---
Nitpick comments:
In @.agents/skills/vision/scripts/ask.py:
- Around line 73-83: Narrow the broad except in _check_server(): replace the
generic "except Exception" with two specific handlers—catch httpx.ConnectError
to handle connection failures (print a message about Ollama not running and hint
to run `ollama serve` then sys.exit(1)), and catch ollama._types.ResponseError
as e to report API/server errors (print the server error detail from e and a
hint to check Ollama installation then sys.exit(1)); update the import usage or
fully-qualified names so httpx.ConnectError and ollama._types.ResponseError are
resolvable in the scope where _check_server() calls ollama.list().
🪄 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: 5349d299-95c7-4118-8537-d085d7515267
📒 Files selected for processing (7)
.agents/skills/vision/SKILL.md.agents/skills/vision/scripts/ask.pycrates/grida-canvas/src/io/io_grida_fbs.rsdocs/editor/features/tray.mddocs/wg/feat-tray/index.mdeditor/grida-canvas/reducers/event-target.reducer.tstest/canvas-tray-node-behavior.md
✅ Files skipped from review due to trivial changes (4)
- .agents/skills/vision/SKILL.md
- docs/editor/features/tray.md
- docs/wg/feat-tray/index.md
- test/canvas-tray-node-behavior.md
🚧 Files skipped from review as they are similar to previous changes (1)
- crates/grida-canvas/src/io/io_grida_fbs.rs
| VISION_NAME_FRAGMENTS = [ | ||
| "qwen", | ||
| "gemma3", | ||
| "vl", | ||
| "vision", | ||
| "llava", | ||
| "moondream", | ||
| "bakllava", | ||
| "cogvlm", | ||
| "minicpm-v", | ||
| "phi3.5-vision", | ||
| ] |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In the Ollama Python client, what metadata or API field reliably indicates that a model accepts image inputs, and are plain qwen* models always vision-capable?
💡 Result:
In the Ollama Python client, use ollama.show(model) to retrieve model details. The reliable indicator for image input support is the "capabilities" array in the response containing "vision". Vision models also have vision-specific fields in "model_info" (e.g., "gemma3.vision.block_count", "gemma3.mm.tokens_per_image") and often "projector" in details. Plain qwen* models (e.g., qwen2.5, qwen3.5 without -vl suffix) are not always vision-capable. Vision variants are explicitly tagged as qwen2.5vl*, qwen3-vl*, etc., while base qwen* are text-only language models.
Citations:
- 1: https://docs.ollama.com/api-reference/show-model-details
- 2: https://mintlify.com/ollama/ollama/api/endpoints/show
- 3: https://docs.ollama.com/capabilities/vision
- 4: https://mintlify.com/ollama/ollama/features/vision
- 5: https://ollama.com/library/qwen3.5
- 6: https://www.ollama.com/library/qwen2.5vl:3b-q4_K_M
- 7: https://ollama.com/library/qwen3-vl:2b-thinking
Avoid classifying all qwen* models as vision-capable.
Including "qwen" in VISION_NAME_FRAGMENTS misclassifies text-only Qwen models (qwen2.5, qwen3.5) as vision-capable, causing image requests to fail at runtime. Vision variants are explicitly suffixed (qwen2.5vl, qwen3-vl, qwen3.5vl).
Suggested patch
VISION_NAME_FRAGMENTS = [
- "qwen",
+ "qwen2.5vl",
+ "qwen3-vl",
+ "qwen3.5vl",
"gemma3",
"vl",
"vision",
"llava",📝 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.
| VISION_NAME_FRAGMENTS = [ | |
| "qwen", | |
| "gemma3", | |
| "vl", | |
| "vision", | |
| "llava", | |
| "moondream", | |
| "bakllava", | |
| "cogvlm", | |
| "minicpm-v", | |
| "phi3.5-vision", | |
| ] | |
| VISION_NAME_FRAGMENTS = [ | |
| "qwen2.5vl", | |
| "qwen3-vl", | |
| "qwen3.5vl", | |
| "gemma3", | |
| "vl", | |
| "vision", | |
| "llava", | |
| "moondream", | |
| "bakllava", | |
| "cogvlm", | |
| "minicpm-v", | |
| "phi3.5-vision", | |
| ] |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.agents/skills/vision/scripts/ask.py around lines 49 - 60, The list
VISION_NAME_FRAGMENTS is over-broad because the generic "qwen" fragment causes
text-only Qwen models to be treated as vision-capable; update
VISION_NAME_FRAGMENTS by removing the generic "qwen" entry and instead include
only the explicit vision suffixes used by Qwen vision models (e.g., add
"qwen2.5vl", "qwen3-vl", "qwen3.5vl" or other exact vision-specific fragments
your deployment supports) so only true vision variants are matched.
| pages_free = 0 | ||
| pages_inactive = 0 | ||
| page_size = 4096 | ||
| result = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=5) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify bare executable usage in subprocess.run calls for this file.
rg -nP 'subprocess\.run\(\[\s*"[^/"]+' .agents/skills/vision/scripts/ask.pyRepository: gridaco/grida
Length of output: 152
🏁 Script executed:
sed -n '200,225p' .agents/skills/vision/scripts/ask.pyRepository: gridaco/grida
Length of output: 1183
🏁 Script executed:
rg -nP 'subprocess\.run' .agents/skills/vision/scripts/ask.pyRepository: gridaco/grida
Length of output: 194
🌐 Web query:
Bandit S607 subprocess partial path security rule
💡 Result:
Bandit S607 (also known as B607 in some documentation) is a security rule from the Bandit Python static analysis tool that detects starting a process with a partial executable path. This occurs when code invokes external executables using methods like subprocess.Popen without a fully qualified path (i.e., paths not starting with '/'), relying instead on the PATH environment variable. Why it's a security risk: Attackers can manipulate the PATH variable or filesystem to place a malicious executable with the same name, which gets executed with the privileges of the Python process. Severity: Low Confidence: High CWE: CWE-78 (OS Command Injection), though more specifically related to CWE-426 (Untrusted Search Path). Affected functions: Configurable via shell_injection in Bandit config, including subprocess.Popen, os.system, os.execl, etc., across subprocess, shell, and no_shell sections. Bad example: from subprocess import Popen Popen('gcc --version', shell=False) # Partial path 'gcc' Good practice: Popen('/usr/bin/gcc --version', shell=False) # Full path This rule is also implemented in tools like Ruff (S607) and DeepSource (BAN-B607). Official Bandit documentation confirms it scans for paths lacking a leading '/' in process-spawning calls.
Citations:
- 1: https://bandit.readthedocs.io/en/latest/plugins/b607_start_process_with_partial_path.html
- 2: https://docs.astral.sh/ruff/rules/start-process-with-partial-path/
- 3: https://deepsource.com/directory/python/issues/BAN-B607
- 4: https://bandit.readthedocs.io/en/1.7.3/plugins/
- 5: https://docs.astral.sh/ruff/rules/
🌐 Web query:
macOS vm_stat sysctl absolute path /usr/bin /usr/sbin
💡 Result:
On macOS, vm_stat is located at /usr/bin/vm_stat and sysctl is located at /usr/bin/sysctl. vm_stat is a standard user command for displaying Mach virtual memory statistics, documented in section 1 of the man pages (BSD General Commands Manual). Section 1 man pages correspond to executables in /usr/bin. sysctl has both a library function (sysctl(3), section 3) and a command-line utility (sysctl(8), section 8). The command-line utility sysctl for getting/setting kernel state is in section 8 (System Manager's Manual), but on macOS/BSD systems, section 8 commands are located in /usr/sbin. However, multiple sources including path configurations and command usage indicate the sysctl binary is actually in /usr/bin/sysctl, as /usr/bin contains most standard user-accessible system utilities including sysctl. To verify on a macOS system, use which vm_stat and which sysctl, both returning /usr/bin/.... In macOS System Integrity Protection, these paths are protected and standard.
Citations:
- 1: https://www.unix.com/man-page/osx/1/vm_stat?os=osx§ion=1&query=vm_stat
- 2: https://github.com/st3fan/osx-10.9/blob/master/system_cmds-597.1.1/vm_stat.tproj/vm_stat.c
- 3: https://developer.apple.com/library/archive/documentation/Performance/Conceptual/ManagingMemory/Articles/VMPages.html
- 4: [BUG] macOS Match Activity Monitor with Memory Usage Stats fastfetch-cli/fastfetch#2171
- 5: https://manpagez.com/man/8/sysctl
- 6: https://leopard-adc.pepas.com/documentation/Darwin/Reference/ManPages/man8/sysctl.8.html
- 7: https://ss64.com/osx/sysctl.html
Use absolute binary paths in subprocess.run to prevent PATH-based execution risks.
Both vm_stat and sysctl are invoked via partial executable paths, which triggers S607 (Bandit security rule) and weakens command execution safety.
Suggested patch
- result = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=5)
+ result = subprocess.run(
+ ["/usr/bin/vm_stat"], capture_output=True, text=True, timeout=5
+ )
@@
- result2 = subprocess.run(
- ["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=5
- )
+ result2 = subprocess.run(
+ ["/usr/bin/sysctl", "-n", "hw.memsize"],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )Also applies to line 216 (not 217).
🧰 Tools
🪛 Ruff (0.15.7)
[error] 208-208: Starting a process with a partial executable path
(S607)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.agents/skills/vision/scripts/ask.py at line 208, Replace the relative
command names passed to subprocess.run with absolute binary paths to mitigate
PATH-based execution risks: locate the subprocess.run calls that invoke
"vm_stat" and "sysctl" (the call assigning to result and the subsequent sysctl
invocation) and change their argv[0] to the system absolute paths (e.g.,
/usr/bin/vm_stat or /usr/sbin/vm_stat and /usr/sbin/sysctl as appropriate on
target platforms); keep capture_output, text, and timeout arguments unchanged
and ensure the commands still execute correctly on the intended macOS
environment.
| if not image_path.exists(): | ||
| print(f"error: file not found: {image_path}", file=sys.stderr) | ||
| sys.exit(1) |
There was a problem hiding this comment.
Reject directories when validating image input.
Path.exists() is not enough; a directory with an image-like suffix would pass and fail later.
Suggested patch
image_path = Path(args.image)
if not image_path.exists():
print(f"error: file not found: {image_path}", file=sys.stderr)
sys.exit(1)
+ if not image_path.is_file():
+ print(f"error: not a file: {image_path}", file=sys.stderr)
+ sys.exit(1)📝 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.
| if not image_path.exists(): | |
| print(f"error: file not found: {image_path}", file=sys.stderr) | |
| sys.exit(1) | |
| if not image_path.exists(): | |
| print(f"error: file not found: {image_path}", file=sys.stderr) | |
| sys.exit(1) | |
| if not image_path.is_file(): | |
| print(f"error: not a file: {image_path}", file=sys.stderr) | |
| sys.exit(1) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.agents/skills/vision/scripts/ask.py around lines 312 - 314, The current
validation uses image_path.exists(), which will allow directories (including
those with image-like suffixes); update the check to reject non-files by using
image_path.is_file() (or explicitly checking image_path.is_dir()) so that only
regular files are accepted; modify the block around image_path.exists() /
image_path to call image_path.is_file() and print the same error and exit if it
returns False, referencing the image_path variable in the validation.
Mirror Rust collect_labeled_nodes() logic: trays get badge-style bars, root containers and tray-child containers get plain labels. Thread parentNodeId through FloatingBar → useSingleSelection so tray-child overlays reposition when the parent tray moves. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Renamed `ImportFromGridaFileJsonDialog` to `ImportFromGridaDialog` for clarity. - Updated file acceptance criteria to include `.grida1` alongside `.grida`. - Improved user feedback for file import processes and error handling. - Adjusted related components and hooks to reflect the new naming and functionality.
…ration Introduce `IDocumentPropertiesQueryProvider` with `queryPaintGroups(ids, target, options)` that abstracts paint aggregation across DOM and canvas (WASM) backends. Rust side: `query::paint` module with hash-based O(n) grouping, recursive/flat traversal, early-exit limit, and `PaintSource`/`ChildrenIter` traits for testability. WASM side: `query_paint_groups` C-ABI export accepting explicit node IDs with recursive flag, plus JS paint format conversion (externally-tagged enum → cg.Paint). React side: `useMixedPaints` now delegates to the provider and suppresses re-queries during active gestures (translate/scale/rotate) since paints are stable during geometric mutations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (3)
editor/grida-canvas-react-starter-kit/starterkit-import/from-grida.tsx (1)
44-47: Preserve the concrete loader error message in the toast.Line 45 currently masks actionable details (for example, schema incompatibility) coming from
io.load.🛠️ Proposed tweak
- } catch (error) { - toast.error("Failed to parse the file. Please check the format."); - console.error(error); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to parse the file. Please check the format."; + toast.error(message); + console.error(error); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@editor/grida-canvas-react-starter-kit/starterkit-import/from-grida.tsx` around lines 44 - 47, The catch block in from-grida.tsx currently shows a generic toast and masks the concrete loader error from io.load; update the catch to extract the real error message (e.g., if (error instanceof Error) msg = error.message || String(error)) and include that message in the toast.error text (e.g., "Failed to parse the file: " + msg) while preserving the existing console.error(error) call so the original stack is still logged; ensure this change targets the catch around the io.load call in from-grida.tsx.crates/grida-canvas/src/query/paint.rs (2)
154-165: Consider HashSet for node_ids if groups become large.The
contains(&id)check onVec<NodeId>is O(n) per node. While acceptable for typical selection sizes, if paint groups grow large (many nodes sharing a paint), this could become a performance concern.For future optimization, consider using
HashSet<NodeId>internally and converting toVecat the end, or accepting the current tradeoff since selection sizes are typically small.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/grida-canvas/src/query/paint.rs` around lines 154 - 165, The current code does an O(n) contains check on groups[idx].node_ids (a Vec<NodeId>) when merging nodes into a paint group; replace node_ids with a HashSet<NodeId> (e.g., change the collection type used inside the Group struct) so membership checks and insertions are O(1), update places that iterate or extend node_ids to handle a set, and if a Vec is required by the API convert the HashSet to a Vec at the end; update references to groups, node_ids, hash_to_indices, paint, and id accordingly to preserve logic.
213-216: Stroke paint support is pending implementation.The
PaintTarget::Strokevariant currently returns an empty vector with a TODO comment. This is a safe placeholder, but stroke-based paint grouping won't work until implemented.Would you like me to help implement the
Node::strokes()accessor and complete this implementation, or open an issue to track this?🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/grida-canvas/src/query/paint.rs` around lines 213 - 216, The PaintTarget::Stroke arm currently returns an empty Vec; implement Node::strokes() and use it here to return the node's stroke paint entries similarly to how fills are handled: add a Node::strokes() accessor that returns the vector/iterator of stroke paint nodes (matching whatever Node::fills() returns), then replace the Vec::new() in PaintTarget::Stroke with code that obtains the current node via the same lookup used for fills and maps/collects its stroke paint descriptors into the expected return type. Ensure names match existing symbols (PaintTarget::Stroke and Node::strokes) and follow the same error/None handling used in the Fill branch.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@editor/grida-canvas-react/use-data-transfer.ts`:
- Around line 609-615: The toast message inside the file import check (the block
that tests file.name.toLowerCase().endsWith(".grida") ||
file.name.toLowerCase().endsWith(".grida1")) still only mentions “import .grida
files”; update the toast.info text so it references both extensions (e.g., “Use
[File] > [Open .grida or .grida1] to import .grida/.grida1 files”) so the hint
matches the condition in that if block.
In `@editor/grida-canvas-react/use-mixed-properties.ts`:
- Around line 123-132: The current useMemo builds ids from paints, so when
paints is empty (no active fills) ids collapses and gated UI loses the
selection; instead compute ids from the original queried target list (the
selection/target source used to fetch/group paints) and keep that separate from
paints grouping. Change the useMemo for ids to derive from the stable
selection/queriedTargetIds (not paints), e.g., compute ids = Array.from(new
Set(queriedTargetIds)) and include queriedTargetIds in the dependency array,
leaving paints solely for grouped paint results.
---
Nitpick comments:
In `@crates/grida-canvas/src/query/paint.rs`:
- Around line 154-165: The current code does an O(n) contains check on
groups[idx].node_ids (a Vec<NodeId>) when merging nodes into a paint group;
replace node_ids with a HashSet<NodeId> (e.g., change the collection type used
inside the Group struct) so membership checks and insertions are O(1), update
places that iterate or extend node_ids to handle a set, and if a Vec is required
by the API convert the HashSet to a Vec at the end; update references to groups,
node_ids, hash_to_indices, paint, and id accordingly to preserve logic.
- Around line 213-216: The PaintTarget::Stroke arm currently returns an empty
Vec; implement Node::strokes() and use it here to return the node's stroke paint
entries similarly to how fills are handled: add a Node::strokes() accessor that
returns the vector/iterator of stroke paint nodes (matching whatever
Node::fills() returns), then replace the Vec::new() in PaintTarget::Stroke with
code that obtains the current node via the same lookup used for fills and
maps/collects its stroke paint descriptors into the expected return type. Ensure
names match existing symbols (PaintTarget::Stroke and Node::strokes) and follow
the same error/None handling used in the Fill branch.
In `@editor/grida-canvas-react-starter-kit/starterkit-import/from-grida.tsx`:
- Around line 44-47: The catch block in from-grida.tsx currently shows a generic
toast and masks the concrete loader error from io.load; update the catch to
extract the real error message (e.g., if (error instanceof Error) msg =
error.message || String(error)) and include that message in the toast.error text
(e.g., "Failed to parse the file: " + msg) while preserving the existing
console.error(error) call so the original stack is still logged; ensure this
change targets the catch around the io.load call in from-grida.tsx.
🪄 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: f2d3d85f-2bb5-4f14-b0dd-ea1848e419cb
📒 Files selected for processing (20)
crates/grida-canvas-wasm/lib/modules/canvas-bindings.d.tscrates/grida-canvas-wasm/lib/modules/canvas.tscrates/grida-canvas-wasm/src/wasm_application.rscrates/grida-canvas/src/node/schema.rscrates/grida-canvas/src/query/hierarchy.rscrates/grida-canvas/src/query/mod.rscrates/grida-canvas/src/query/paint.rseditor/grida-canvas-hosted/playground/uxhost-menu.tsxeditor/grida-canvas-react-starter-kit/starterkit-import/from-grida.tsxeditor/grida-canvas-react-starter-kit/starterkit-import/index.tseditor/grida-canvas-react/use-data-transfer.tseditor/grida-canvas-react/use-mixed-properties.tseditor/grida-canvas-react/viewport/surface-hooks.tseditor/grida-canvas-react/viewport/surface.tsxeditor/grida-canvas-react/viewport/ui/floating-bar.tsxeditor/grida-canvas/backends/dom-content.tseditor/grida-canvas/backends/wasm.tseditor/grida-canvas/editor.i.tseditor/grida-canvas/editor.tspackages/grida-canvas-io/index.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- editor/grida-canvas/editor.i.ts
| // Check for .grida / .grida1 files and show helpful message | ||
| if ( | ||
| file.name.toLowerCase().endsWith(".grida") || | ||
| file.name.toLowerCase().endsWith(".grida1") | ||
| ) { | ||
| toast.info("Use [File] > [Open .grida] to import .grida files"); | ||
| continue; |
There was a problem hiding this comment.
Update the hint text to mention .grida1 too.
Line 614 still says “import .grida files” even though Lines 611-612 now route .grida1 the same way.
✏️ Proposed copy fix
- toast.info("Use [File] > [Open .grida] to import .grida files");
+ toast.info("Use [File] > [Open .grida] to import .grida/.grida1 files");🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@editor/grida-canvas-react/use-data-transfer.ts` around lines 609 - 615, The
toast message inside the file import check (the block that tests
file.name.toLowerCase().endsWith(".grida") ||
file.name.toLowerCase().endsWith(".grida1")) still only mentions “import .grida
files”; update the toast.info text so it references both extensions (e.g., “Use
[File] > [Open .grida or .grida1] to import .grida/.grida1 files”) so the hint
matches the condition in that if block.
| // Collect all node IDs across groups for display-gating | ||
| const ids = useMemo(() => { | ||
| const set = new Set<string>(); | ||
| for (const group of paints) { | ||
| for (const id of group.ids) { | ||
| set.add(id); | ||
| } | ||
| } | ||
|
|
||
| return paintGroups; | ||
| }, [slice.paintEntries]); | ||
| return Array.from(set); | ||
| }, [paints]); |
There was a problem hiding this comment.
Don't infer target ids from the returned paint groups.
Line 123 rebuilds ids only from groups that already contain an active fill. Once the user clears fill on every selected node, paints becomes empty here and ids collapses to [], so any UI gated by ids loses the current targets and can no longer offer the add-fill path for that same selection. Keep the queried target ids separate from grouped paint results.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@editor/grida-canvas-react/use-mixed-properties.ts` around lines 123 - 132,
The current useMemo builds ids from paints, so when paints is empty (no active
fills) ids collapses and gated UI loses the selection; instead compute ids from
the original queried target list (the selection/target source used to
fetch/group paints) and keep that separate from paints grouping. Change the
useMemo for ids to derive from the stable selection/queriedTargetIds (not
paints), e.g., compute ids = Array.from(new Set(queriedTargetIds)) and include
queriedTargetIds in the dependency array, leaving paints solely for grouped
paint results.
day-321-trays.mp4
Summary
Adds Tray — a canvas-level organizational primitive analogous to Figma Section: explicit dimensions, fills, strokes, and corner radius; it does not clip or apply effects; children are freely placed root-level containers.
This PR spans the Rust canvas engine, grida-dev benchmarks, and editor integration (tool, shortcut, hierarchy rules, DOM renderer, tests, and docs).
Canvas /
cg(Rust)TrayNodeRec,NodeTypeTag::Tray,GeoNodeKind::Traygrida.fbs; generatedgrida.rsupdatedFrom<JSONGroupNode>forTrayNodeRec; FBS encode/decode round-tripclip_pathgeo_input_from_schema()helper; Tray always resolves from schemal0_trayscene with five Tray variantsdocs/wg/feat-tray/index.mddesign docgrida-dev--overlayflag for benchmarks to measure combined cost of content rendering and SurfaceUI overlay drawing; overlay cost included in performance metricsEditor
ToolMode; Shift+F shortcutinitialNodefactory (e.g. white fill, ~10% black stroke, corner radius 2)TrayWidgetrenderer, registered in node switch and renderer indexself_moveNode, canvas translate hierarchy, insert-and-resize auto-wrap)can_be_parent, layer tree (isItemFolder), insertion targeting;allows_hierarchy_changefor canvas dragICSSDimension,IRectangularShapeTrait, rotation; property supports (fill, stroke, corner radius, children, stroke width)SquareDashedIconfor tray in layer panelHow to test
cg--overlayto compare overlay-inclusive timingsSummary by CodeRabbit
New Features
Documentation
Tests