Implement penpot_add_frame with auto-layout props and absolute-positioning fallback #198

Closed
opened 2026-09-14 23:11:13 +00:00 by david · 1 comment
Owner

Summary

Implement penpot_add_frame: stage a frame shape with the complete, schema-valid Penpot shape payload, optional auto-layout properties (flex direction, gap, padding, alignment, wrap), and a child-sizing/absolute-positioning fallback path. This is the container every other composition primitive adds into.

Background

Depends on: #197

The shape payload is the schema that produced "invalid shape found" until every piece was present. It was then pinned on Penpot 2.17, and this step must reproduce it exactly:

// shape object
{
  "id": "<client-generated uuid>", "type": "frame", "name": "…",
  "x": 0, "y": 0, "width": 320, "height": 180, "rotation": 0,
  "selrect": { "x":0,"y":0,"width":320,"height":180,"x1":0,"y1":0,"x2":320,"y2":180 },
  "points": [ {x,y} × 4 ],
  "transform":        { "a":1,"b":0,"c":0,"d":1,"e":0,"f":0 },
  "transformInverse": { "a":1,"b":0,"c":0,"d":1,"e":0,"f":0 },
  "frameId": "…", "parentId": "…",
  "fills": [ { "fillColor":"#FFD23F","fillOpacity":1,
               "fillColorRefId":"…","fillColorRefFile":"…" } ],
  "strokes": [],
  "shapes": []                      // frames only; vector of child uuids
}

// the change wrapper
{ "type":"add-obj", "id": "<same uuid>", "pageId":"…",
  "frameId":"…",                   // REQUIRED at change level
  "parentId":"…", "obj": { …shape above… } }

Two details that are easy to get wrong:

  • Top-level shapes use frameId/parentId = 00000000-0000-0000-0000-000000000000 (the root frame).
  • The server maintains the parent frame's shapes array when a child is added with parentId set to that frame — no follow-up mod-obj is needed or wanted.

Auto-layout field names and enums, confirmed on 2.17 (layoutGap and layoutPadding are objects, not numbers; the UI word "hug" is auto in the data model):

Field Type / values
layout flex | grid
layoutFlexDir row | row-reverse | column | column-reverse
layoutGap {rowGap, columnGap} with layoutGapType: "multiple"
layoutPadding {p1, p2, p3, p4}all four required — with layoutPaddingType: "multiple"
layoutAlignItems start | center | end | stretch
layoutJustifyContent start | center | end | space-between | space-around | space-evenly | stretch
layoutWrapType wrap | nowrap

Child-level layout fields (used by later primitives, but the frame primitive owns the schema helpers for them): layoutItemHSizing / layoutItemVSizing (fill | fix | auto), layoutItemAbsolute, layoutItemMargin, layoutItemAlignSelf, layoutItemMinW/MaxW/MinH/MaxH, layoutItemZIndex.

Errors: with Accept: application/json, validation failures return readable JSON with a precise explain (Malli path). Surface it verbatim.

Documentation Required

A separate process downloads these into the listed folders before this issue is implemented. Check the folders for the actual reference material before starting.

docs/reference/penpot-api/

docs/reference/pi-coding-agent/ and docs/reference/typebox/

Implementation Details

Create extensions/penpot/src/shapes.ts (pure payload builders — the unit-tested core) and extensions/penpot/src/tools/frame.ts, and register the tool in src/index.ts.

src/shapes.ts

  • baseShape({ id, type, name, x, y, width, height, rotation? }) → the full shape object with selrect, four points, transform, transformInverse, frameId, parentId, fills, strokes.
  • selrectFrom(x, y, w, h) and pointsFrom(...) — derive x1,y1,x2,y2 and the four corner points correctly; these are the fields most likely to be wrong by hand, so derive them, never accept them from the caller.
  • identityTransform(){a:1,b:0,c:0,d:1,e:0,f:0} for both transform and transformInverse (a non-rotated shape).
  • layoutProps(input) → the layout* fields, with layoutGap/layoutPadding mapped from friendly numbers into the required objects and layoutGapType/layoutPaddingType set to "multiple". All four padding sides must always be emitted (default the omitted ones sensibly, e.g. uniform padding).
  • addObjChange({ pageId, frameId, parentId, obj }) → the change wrapper, asserting the change-level frameId is present (it is required even for top-level shapes).
  • ROOT_ID = "00000000-0000-0000-0000-000000000000".

src/tools/frame.tspenpot_add_frame

Parameters: file_id (required), name, x, y, width, height, page_id (optional; default the file's first/only page when unambiguous, else required with a clear error), parent_id (optional; defaults to the root), fill_color/fill_color_ref (optional; the linked-asset form is added in the next issue — accept a plain hex here), and layout options: layout (flex/grid), layout_flex_dir, layout_gap (single number or {row, column}), layout_padding (single number or {top,right,bottom,left}), layout_align_items, layout_justify_content, layout_wrap.

  • Stage the add-obj change (never write directly).
  • Return the preview line plus the generated shape id in details, since the caller needs it to add children.
  • If a layout option is supplied without layout, infer layout: "flex" and say so in the result rather than silently ignoring the options.
  • Absolute positioning remains the default: with no layout options the frame is a plain absolutely-positioned board.

Acceptance Criteria

  • The staged add-obj change carries a complete shape: id, type, name, x, y, width, height, rotation, selrect (with x1..y2), four points, transform, transformInverse, frameId, parentId, fills, strokes, and shapes: [].
  • selrect, points and transform are derived from position/size, never supplied by the caller.
  • The change-level frameId is always present; top-level frames use ROOT_ID for both frameId and parentId.
  • layoutGap and layoutPadding are emitted as objects with their *Type: "multiple" companions; all four padding sides are always present.
  • All layout enum parameters are schema-constrained, so an invalid value is rejected before staging.
  • Supplying a layout option without layout infers flex and reports the inference.
  • Nothing is written until commit; tests assert no update-file call.
  • Unit tests cover selrect/points/transform derivation, the change wrapper, layout-prop mapping (including partial padding and {row, column} gaps), and the no-layout absolute default.

Test Plan

node --test extensions/penpot/src/shapes.test.ts extensions/penpot/src/tools/frame.test.ts

Live validation (requires PENPOT_URL/PENPOT_TOKEN, scratch file):

  1. Authorise the scratch file, stage one frame (layout: flex, layout_flex_dir: row, gap 12, padding 16) and commit.
  2. Confirm the tool reports a single-revision advance and that the frame is visible in the Penpot UI with the expected layer name.
  3. In the UI, confirm the frame's Layout panel shows direction row, gap 12 and padding 16 on all sides.
  4. Resize a child later (next issue) and confirm the frame reflows — recorded in this milestone's validation issue.

Note: the two-tool round trip (stage → commit) is deliberate; if the commit fails, the server's explain must be surfaced verbatim to make schema mistakes diagnosable.

## Summary Implement `penpot_add_frame`: stage a frame shape with the complete, schema-valid Penpot shape payload, optional auto-layout properties (flex direction, gap, padding, alignment, wrap), and a child-sizing/absolute-positioning fallback path. This is the container every other composition primitive adds into. ## Background **Depends on:** #197 The shape payload is the schema that produced "invalid shape found" until every piece was present. It was then pinned on Penpot 2.17, and this step must reproduce it exactly: ```jsonc // shape object { "id": "<client-generated uuid>", "type": "frame", "name": "…", "x": 0, "y": 0, "width": 320, "height": 180, "rotation": 0, "selrect": { "x":0,"y":0,"width":320,"height":180,"x1":0,"y1":0,"x2":320,"y2":180 }, "points": [ {x,y} × 4 ], "transform": { "a":1,"b":0,"c":0,"d":1,"e":0,"f":0 }, "transformInverse": { "a":1,"b":0,"c":0,"d":1,"e":0,"f":0 }, "frameId": "…", "parentId": "…", "fills": [ { "fillColor":"#FFD23F","fillOpacity":1, "fillColorRefId":"…","fillColorRefFile":"…" } ], "strokes": [], "shapes": [] // frames only; vector of child uuids } // the change wrapper { "type":"add-obj", "id": "<same uuid>", "pageId":"…", "frameId":"…", // REQUIRED at change level "parentId":"…", "obj": { …shape above… } } ``` Two details that are easy to get wrong: - **Top-level shapes** use `frameId`/`parentId` = `00000000-0000-0000-0000-000000000000` (the root frame). - The server **maintains the parent frame's `shapes` array** when a child is added with `parentId` set to that frame — no follow-up `mod-obj` is needed or wanted. Auto-layout field names and enums, confirmed on 2.17 (`layoutGap` and `layoutPadding` are **objects**, not numbers; the UI word "hug" is `auto` in the data model): | Field | Type / values | |---|---| | `layout` | `flex` \| `grid` | | `layoutFlexDir` | `row` \| `row-reverse` \| `column` \| `column-reverse` | | `layoutGap` | `{rowGap, columnGap}` with `layoutGapType: "multiple"` | | `layoutPadding` | `{p1, p2, p3, p4}` — **all four required** — with `layoutPaddingType: "multiple"` | | `layoutAlignItems` | `start` \| `center` \| `end` \| `stretch` | | `layoutJustifyContent` | `start` \| `center` \| `end` \| `space-between` \| `space-around` \| `space-evenly` \| `stretch` | | `layoutWrapType` | `wrap` \| `nowrap` | Child-level layout fields (used by later primitives, but the frame primitive owns the schema helpers for them): `layoutItemHSizing` / `layoutItemVSizing` (`fill` \| `fix` \| `auto`), `layoutItemAbsolute`, `layoutItemMargin`, `layoutItemAlignSelf`, `layoutItemMinW/MaxW/MinH/MaxH`, `layoutItemZIndex`. Errors: with `Accept: application/json`, validation failures return readable JSON with a precise `explain` (Malli path). Surface it verbatim. ## Documentation Required A separate process downloads these into the listed folders before this issue is implemented. Check the folders for the actual reference material before starting. **`docs/reference/penpot-api/`** - https://raw.githubusercontent.com/penpot/penpot/2.17.2/common/src/app/common/types/shape.cljc — the shape record: `selrect`, `points`, `transform`, `transformInverse`, fills/strokes, and the layout fields. - https://raw.githubusercontent.com/penpot/penpot/2.17.2/common/src/app/common/types/shape_tree.cljc — how children/parents and frame `shapes` arrays are maintained. - https://raw.githubusercontent.com/penpot/penpot/2.17.2/common/src/app/common/types/fills.cljc and .../stroke.cljc — fill/stroke record fields, including the asset-reference fields used by the next issue. - `<PENPOT_URL>/api/main/doc/openapi.json` — the `update-file`/`add-obj` change shape on this instance. - https://help.penpot.app/user-guide/design-systems/layout/ or https://help.penpot.app/user-guide/ — the designer-facing meaning of flex layout, gap, padding and sizing ("hug" = `auto`). - https://help.penpot.app/user-guide/boards/ — board (frame) semantics: positioning, clipping, resizing. **`docs/reference/pi-coding-agent/`** and **`docs/reference/typebox/`** - https://pi.dev/docs/latest/extensions — tool registration and structured `details`. - https://github.com/sinclairzx81/typebox — `Type.Union`/`Type.Literal` for the enum parameters (`layout_flex_dir`, `align_items`, …) so invalid enums are rejected before staging. ## Implementation Details Create `extensions/penpot/src/shapes.ts` (pure payload builders — the unit-tested core) and `extensions/penpot/src/tools/frame.ts`, and register the tool in `src/index.ts`. ### `src/shapes.ts` - `baseShape({ id, type, name, x, y, width, height, rotation? })` → the full shape object with `selrect`, four `points`, `transform`, `transformInverse`, `frameId`, `parentId`, `fills`, `strokes`. - `selrectFrom(x, y, w, h)` and `pointsFrom(...)` — derive `x1,y1,x2,y2` and the four corner points correctly; these are the fields most likely to be wrong by hand, so derive them, never accept them from the caller. - `identityTransform()` — `{a:1,b:0,c:0,d:1,e:0,f:0}` for both `transform` and `transformInverse` (a non-rotated shape). - `layoutProps(input)` → the `layout*` fields, with `layoutGap`/`layoutPadding` mapped from friendly numbers into the required objects and `layoutGapType`/`layoutPaddingType` set to `"multiple"`. All four padding sides must always be emitted (default the omitted ones sensibly, e.g. uniform padding). - `addObjChange({ pageId, frameId, parentId, obj })` → the change wrapper, asserting the change-level `frameId` is present (it is required even for top-level shapes). - `ROOT_ID = "00000000-0000-0000-0000-000000000000"`. ### `src/tools/frame.ts` — `penpot_add_frame` Parameters: `file_id` (required), `name`, `x`, `y`, `width`, `height`, `page_id` (optional; default the file's first/only page when unambiguous, else required with a clear error), `parent_id` (optional; defaults to the root), `fill_color`/`fill_color_ref` (optional; the linked-asset form is added in the next issue — accept a plain hex here), and layout options: `layout` (`flex`/`grid`), `layout_flex_dir`, `layout_gap` (single number or `{row, column}`), `layout_padding` (single number or `{top,right,bottom,left}`), `layout_align_items`, `layout_justify_content`, `layout_wrap`. - Stage the `add-obj` change (never write directly). - Return the preview line plus the generated shape id in `details`, since the caller needs it to add children. - If a layout option is supplied without `layout`, infer `layout: "flex"` and say so in the result rather than silently ignoring the options. - Absolute positioning remains the default: with no layout options the frame is a plain absolutely-positioned board. ## Acceptance Criteria - [ ] The staged `add-obj` change carries a complete shape: `id`, `type`, `name`, `x`, `y`, `width`, `height`, `rotation`, `selrect` (with `x1..y2`), four `points`, `transform`, `transformInverse`, `frameId`, `parentId`, `fills`, `strokes`, and `shapes: []`. - [ ] `selrect`, `points` and `transform` are derived from position/size, never supplied by the caller. - [ ] The change-level `frameId` is always present; top-level frames use `ROOT_ID` for both `frameId` and `parentId`. - [ ] `layoutGap` and `layoutPadding` are emitted as objects with their `*Type: "multiple"` companions; all four padding sides are always present. - [ ] All layout enum parameters are schema-constrained, so an invalid value is rejected before staging. - [ ] Supplying a layout option without `layout` infers `flex` and reports the inference. - [ ] Nothing is written until commit; tests assert no `update-file` call. - [ ] Unit tests cover `selrect`/`points`/`transform` derivation, the change wrapper, layout-prop mapping (including partial padding and `{row, column}` gaps), and the no-layout absolute default. ## Test Plan ```bash node --test extensions/penpot/src/shapes.test.ts extensions/penpot/src/tools/frame.test.ts ``` Live validation (requires `PENPOT_URL`/`PENPOT_TOKEN`, scratch file): 1. Authorise the scratch file, stage one frame (`layout: flex`, `layout_flex_dir: row`, gap 12, padding 16) and commit. 2. Confirm the tool reports a single-revision advance and that the frame is visible in the Penpot UI with the expected layer name. 3. In the UI, confirm the frame's Layout panel shows direction row, gap 12 and padding 16 on all sides. 4. Resize a child later (next issue) and confirm the frame reflows — recorded in this milestone's validation issue. Note: the two-tool round trip (stage → commit) is deliberate; if the commit fails, the server's `explain` must be surfaced verbatim to make schema mistakes diagnosable.
david closed this issue 2026-09-15 03:12:18 +00:00
Author
Owner

pi-loop opened and merged a pull request for this issue: #231

pi-loop opened and merged a pull request for this issue: https://git.excelera.net/david/pi-extensions-and-skills/pulls/231
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
david/pi-extensions-and-skills#198
No description provided.