--- url: 'https://openpencil.ink/user-guide/canvas-navigation.md' description: >- Panning, zooming, ruler guides, snapping, and distance measurements in OpenPencil. --- # Canvas Navigation The canvas is your infinite workspace. You can pan and zoom freely to navigate your design. ## Panning Move the visible area of the canvas without affecting any objects. * Space + drag — hold Space and drag anywhere on the canvas * **Middle mouse drag** — press and drag the middle mouse button * **Two-finger trackpad** — swipe with two fingers on a trackpad * Shift + mouse wheel — pan horizontally ## Hand Tool Press H to activate the hand tool for continuous panning. Any drag on the canvas pans the viewport without needing to hold Space. Switch to another tool (e.g., **V** for Select) to deactivate. ## Zooming Zoom in and out centered on your cursor position. * Ctrl + scroll (or ⌘ + scroll on Mac) — scroll up to zoom in, scroll down to zoom out * **Pinch gesture** — pinch on a trackpad to zoom in/out * **Keyboard shortcuts** — see table below Pinch-to-zoom on UI panels (layers, properties) is prevented so it doesn't accidentally change the browser zoom level. ## Keyboard Shortcuts | Action | Mac | Windows / Linux | |--------|-----|-----------------| | Pan | Space + drag | Space + drag | | Hand tool | H | H | | Zoom in | ⌘+ | Ctrl + + | | Zoom out | ⌘− | Ctrl + − | | Zoom in / out | + / − | + / − | | Zoom to 100% | ⇧0 or ⌘0 | Shift + 0 or Ctrl + 0 | | Zoom to fit | ⇧1 | Shift + 1 | | Zoom to selection | ⇧2 | Shift + 2 | | Zoom to next / previous frame | N / ⇧N | N / Shift + N | | Next / previous page | Page Down / Page Up | Page Down / Page Up | | Pixel grid | ⇧' | Shift + ' | | Snap to pixel grid | ⇧⌘' | Shift + Ctrl + ' | | Rulers | ⇧R | Shift + R | | Multiplayer cursors | ⌥⌘\ | Alt + Ctrl + \ | ## Frames and Pages N selects the next top-level frame on the page and zooms to it; Shift + N goes back. Frames inside sections count, hidden ones do not, and the order runs left to right, then top to bottom, wrapping at the ends. With a layer inside a frame selected, the step starts from that frame. Page Down and Page Up open the next and previous page in the Pages panel's order. ## Pixel Grid Zoomed in far enough that each document pixel covers eight screen pixels (800% on a standard display, 400% on a Retina one), the canvas shows a line at every pixel, as in Figma. Turn it off or on with **View → Pixel Grid**. ## Ruler Guides Enable **View → Rulers**, then drag from the top ruler for a horizontal guide or the left ruler for a vertical guide. Drop onto the page for a canvas guide, or onto a frame for a guide in that frame's coordinates. * Click a guide to select it; drag it to reposition it or transfer it between the page and a frame. * Hold Option / Alt while dragging an existing guide to duplicate it. * Drag a guide back onto a ruler to remove it, or use its context menu's **Remove guide** action. * Guide changes support undo/redo and are preserved in `.fig` files. ## Snapping Under **View → Preferences**, toggle **Snap to Geometry**, **Snap to Objects**, and **Snap to Pixel Grid** independently. Preferences are saved between sessions. Geometry and object snapping help align vector points, moved layers, and resized edges with nearby geometry, objects, guides, and frame bounds. Alignment lines appear for those targets; pixel-grid rounding does not draw an alignment line for every pixel. With **Snap to Pixel Grid** on, moved layers, resized edges, and drawn layers land on whole pixels at any zoom, even when they started between pixels. A snap to an object or guide wins over the grid. Arrow-key nudges and values typed in the properties panel keep their fractions. Hold Control during a layer drag to temporarily bypass object and pixel snapping, including on macOS where this is Control, not Command. ## Distance Measurements Select a layer, hold Option on macOS or Alt on Windows/Linux, and hover another layer to see temporary distance measurements. Releasing the modifier clears the overlay; it does not add guides or change the document. ## Command Palette Press ⌘K on macOS or Ctrl + K on Windows/Linux to search editor and application actions. Select a result to run it; unavailable actions remain subject to the current selection and document state. The palette also moves between pages. Before you type, it lists the pages you visited recently in this tab, most recent first, and **Go to page…** opens a list of every page. Typing a page name finds it too. Pages where collaborators or AI agents are working list their names; the Pages panel marks the same pages with their colors. ## Tips * Zooming always targets the cursor position, so point at what you want to see closer. * The hand tool is useful when you need to pan frequently — it stays active until you switch tools. * See [Selection & Manipulation](./selection-and-manipulation) for how to work with objects on the canvas. --- --- url: 'https://openpencil.ink/user-guide/selection-and-manipulation.md' description: >- Selecting, moving, resizing, rotating, duplicating, and organizing nodes in OpenPencil. --- # Selection & Manipulation Select objects to move, resize, rotate, duplicate, and organize them on the canvas. ## Selecting * **Click** a node to select it (deselects everything else). As in Figma, a click inside a top-level frame selects that frame's direct child, such as a nested frame or a group, rather than the deepest layer; click a top-level frame by its title, anywhere when it is empty, or by its gaps and padding when it has auto layout * **Hover** an auto layout frame to see its direct children outlined with dotted lines; while a single layer is selected, the border of its auto layout parent is dotted * **Double-click** to go one level deeper, or click again inside a selected frame, component, or instance; once a layer is selected, clicks reach its siblings and cousins directly * ⌘ + click (Ctrl + click on Windows and Linux) selects the deepest layer under the cursor; holding ⌘ highlights that layer before you click * Shift + click to add or remove a node from the current selection. A layer and its own parent or child are never selected together, so Shift-clicking one replaces the other * **Double-click a frame's or component's name** above it on the canvas to rename it in place * **Marquee drag** — drag on empty canvas to draw a selection rectangle; intersecting nodes are selected on release. Started inside a top-level frame without auto layout or a section, it selects that container's layers; from the page, a frame or section that holds layers is selected only when fully enclosed * ⌘A — select all nodes on the current page * Enter — select the children of the selected layers; on a vector it edits the path and on text it edits the text * Shift + Enter or \ — select the parent of the selected layers * Tab / Shift + Tab — select the next or previous sibling, going down or up the layers panel and wrapping at the ends * **Click empty canvas** — deselect all ## Moving * **Drag** a selected node to move it (all selected nodes move together) * **Arrow keys** — nudge selected nodes by 1 px * Shift + arrow keys — nudge by 10 px ## Resizing Selected nodes show 8 resize handles (4 corners + 4 edge midpoints). Drag any handle to resize. * Shift + drag a corner handle to constrain proportions ## Moving into and out of frames A dragged node lands in the frame under the cursor when you release it, however little of it is inside, and leaves its frame as soon as the cursor does. Groups and locked frames never take dropped nodes, and a component set takes back only its own variants; a node inside a group stays in it unless you drop it on a different frame, and the group resizes to fit what it holds. A group whose last node leaves is removed. Pressing inside a selected frame, group, or component set drags it rather than the node under the cursor. * Shift + drag — lock the move to one axis * Hold Space while dragging — keep the nodes in their current parents * ⌃ + drag (Ctrl + drag on Windows and Linux) — turn off snapping; dropped into an auto-layout frame, the node is positioned absolutely Locked nodes in a selection stay where they are when the rest of it moves. ## Rotating Hover just outside a corner handle to see the rotation cursor. Drag to rotate. * Shift + drag snaps rotation to 15° increments ## Duplicating * Alt + drag (⌥ + drag on Mac) — duplicate the selected node and move the copy * ⌘D — duplicate in place; a lone top-level frame's copy, on the page or in a section, goes into the first free space to its right Duplicates keep their names. Duplicating a main component this way creates an instance of it. ## Pasting ⌘V keeps the copied position. Pasted into a selected frame, nodes keep their offset from the frame they were copied from, and are centered along any axis where they don't fit; nodes that would land out of view are centered in it. Use **Paste here** in the [context menu](./context-menu) to paste at the cursor. ## Deleting Press Backspace or Delete to remove all selected nodes. ## Z-Order Change the stacking order of nodes within their parent: * **]** — bring to front (top of sibling list) * **\[** — send to back (bottom of sibling list) ## Visibility & Lock * ⇧⌘H — toggle visibility. Hidden nodes don't render but stay in the layers panel. * ⇧⌘L — toggle lock. Locked nodes can't be selected or moved on canvas. ## Move to Page Move selected nodes to a different page via the [context menu](./context-menu). The nodes are reparented under the target page's canvas. ## Editing several layers With several layers selected, the properties panel shows what they have in common and reads **Mixed** where they differ; an edit changes all of them in one undo step. * **Layout** shows their sizes, text resizing when text is selected, flow when every layer is a frame, and clip content for the frames among them. When the layers form one row or column, **Spacing** shows the gap between them; typing a value spaces them evenly from the first layer. * **Typography** appears whenever text is selected and changes only the text. * **Stroke** shows the stroke of the layers that have one; editing it strokes every selected layer. * **Selection colors** lists every fill and stroke colour in the selection, including inside frames and groups, most used first. Changing one recolours it everywhere it is used. ## Sections A section takes in the sibling nodes it fully covers when you draw it, move it, or resize it over them. ## Keyboard Shortcuts | Action | Mac | Windows / Linux | |--------|-----|-----------------| | Select all | ⌘A | Ctrl + A | | Duplicate | ⌘D | Ctrl + D | | Duplicate + move | ⌥ + drag | Alt + drag | | Move along one axis | ⇧ + drag | Shift + drag | | Keep current parent | Space while dragging | Space while dragging | | Move without snapping | ⌃ + drag | Ctrl + drag | | Delete | ⌫ / Delete | Backspace / Delete | | Nudge 1 px | Arrow keys | Arrow keys | | Nudge 10 px | ⇧ + Arrow keys | Shift + Arrow keys | | Bring to front | ] | ] | | Send to back | \[ | \[ | | Toggle visibility | ⇧⌘H | Shift + Ctrl + H | | Toggle lock | ⇧⌘L | Shift + Ctrl + L | ## Tips * Use the [Layers & Pages](./layers-and-pages) panel to see and reorder nodes when they overlap. * See [Context Menu](./context-menu) for additional actions like grouping and component creation. --- --- url: 'https://openpencil.ink/user-guide/context-menu.md' description: >- Right-click context menu actions in OpenPencil — clipboard, z-order, grouping, components, and more. --- # Context Menu Right-click on the canvas to open the context menu. If you right-click on a node, it is selected first. Right-clicking on empty canvas clears the selection. ## Copy/Paste as The **Copy/Paste as** submenu offers additional clipboard formats for the selected node(s): | Action | Shortcut (Mac) | Shortcut (Win/Linux) | |--------|----------------|----------------------| | Copy as text | — | — | | Copy as SVG | — | — | | Copy as PNG | ⇧⌘C | Shift + Ctrl + C | | Copy as JSX | — | — | * **Copy as text** — copies visible text content from the selection * **Copy as SVG** — copies the node tree as SVG markup * **Copy as PNG** — renders at 2× and places on the system clipboard (paste into Slack, Notion, etc.) * **Copy as JSX** — copies the OpenPencil JSX representation for use with `renderJsx()` ## Clipboard Actions | Action | Shortcut (Mac) | Shortcut (Win/Linux) | |--------|----------------|----------------------| | Copy | ⌘C | Ctrl + C | | Cut | ⌘X | Ctrl + X | | Paste here | ⌘V | Ctrl + V | | Duplicate | ⌘D | Ctrl + D | | Delete | ⌫ | Backspace / Delete | Clipboard actions are disabled when nothing is selected (except Paste, which is available when the clipboard has content). ## Z-Order | Action | Shortcut | |--------|----------| | Bring to front | ] | | Send to back | \[ | Moves the selected node to the top or bottom of its parent's child list. ## Grouping | Action | Shortcut (Mac) | Shortcut (Win/Linux) | |--------|----------------|----------------------| | Group | ⌘G | Ctrl + G | | Ungroup | ⇧⌘G | Shift + Ctrl + G | | Add auto layout | ⇧A | Shift + A | * **Group** requires 2 or more selected nodes * **Ungroup** appears when a group is selected — children are reparented to the group's parent * **Add auto layout** wraps the selection in a new [auto layout](./auto-layout) frame ## Component Actions Component actions are displayed in purple to match the component color theme. | Action | Shortcut (Mac) | Shortcut (Win/Linux) | Available on | |--------|----------------|----------------------|--------------| | Create component | ⌥⌘K | Ctrl + Alt + K | Any selection | | Create component set | ⇧⌘K | Shift + Ctrl + K | 2+ selected components | | Create instance | — | — | Components (no shortcut) | | Go to main component | — | — | Instances | | Detach instance | ⌥⌘B | Ctrl + Alt + B | Instances | See [Components](./components) for details on the component workflow. ## Visibility & Lock | Action | Shortcut (Mac) | Shortcut (Win/Linux) | |--------|----------------|----------------------| | Hide / Show | ⇧⌘H | Shift + Ctrl + H | | Lock / Unlock | ⇧⌘L | Shift + Ctrl + L | The label toggles based on the node's current state (e.g., "Hide" for a visible node, "Show" for a hidden one). ## Move to Page The **Move to page** submenu lists all pages except the current one. Select a page to reparent the selected nodes under that page's canvas. ## Tips * Right-clicking empty canvas gives you access to Paste — useful for placing content at a specific location. * Component actions only appear when relevant (e.g., "Create instance" only for component nodes). * The context menu mirrors the keyboard shortcuts — it's a good way to discover shortcuts you don't know yet. --- --- url: 'https://openpencil.ink/user-guide/drawing-shapes.md' description: >- Creating rectangles, ellipses, lines, frames, sections, polygons, and stars in OpenPencil. --- # Drawing Shapes The bottom toolbar provides tools for creating shapes, frames, and sections. Select a tool, then click and drag on the canvas to draw. ## Toolbar Tools | Tool | Shortcut | Description | |------|----------|-------------| | Rectangle | R | Draws a rectangle | | Ellipse | O | Draws an ellipse | | Line | L | Draws a line | | Frame | F or A | Draws a frame (container for other nodes) | | Section | Shift + S | Draws a section (takes in the siblings it fully covers) | ## Shapes Flyout The shapes flyout (accessible from the toolbar) includes additional shapes: * **Polygon** — creates a polygon with 3 sides by default (triangle) * **Star** — creates a 5-pointed star with 0.38 inner radius Polygon and Star have no keyboard shortcut — access them from the shapes flyout in the toolbar. ## Customizing the Toolbar Right-click the toolbar and choose **Customize toolbar…**, or open **Settings → Toolbar**, to change it: * Turn a tool's switch off to hide it. Hidden tools keep their keyboard shortcuts. **Move** is always shown. * Drag a tool to reorder it. Dropped inside a box it joins that flyout; dropped in the gap between boxes it becomes a button of its own; dropped onto another tool, the two share a flyout. * A tool's **⋯** menu moves it up or down, groups it with the tool above, or ungroups it from its flyout. Its grip also moves it with ↑ and ↓. * **Insert icon** runs a command rather than picking a tool, so it can move and hide but not join a flyout. **Reset to default** restores the original layout. Tools added in later versions join their default flyout, so a customized toolbar still gets them. ## Constrained Drawing Hold Shift while dragging to constrain the shape: * Rectangle → square (equal width and height) * Ellipse → circle * Line → snaps to 0°/45°/90° angles ## Shape Properties After drawing a shape, select it to edit its properties in the Design tab of the properties panel. ### Fill Every shape can have a fill. The fill section supports: * **Solid color** — pick via the HSV color picker or type a hex value * **Gradient** — Linear, Radial, Angular, or Diamond with editable gradient stops. While the gradient's picker is open, the canvas shows its handles: drag an end or a radius dot to move it, hold Shift to turn it in 15° steps, and drag a stop's square to slide the stop along the line * **Image** — select an image file as the fill ### Stroke Add an outline to any shape. Stroke properties include: * **Width** — uniform or per-side (Top/Right/Bottom/Left) via the side selector dropdown * **Color** — solid color with opacity * **Alignment** — Inside, Center, or Outside the shape boundary (clip-based rendering matches Figma behavior) * **Cap style** — None, Round, Square, Arrow Lines, Arrow Equilateral (for open paths) * **Join style** — Miter, Bevel, Round * **Dash pattern** — dash-on/dash-off ### Corner Radius Available for rectangles, frames, components, instances, polygons, and stars. Click the independent corners toggle to set each corner (top-left, top-right, bottom-left, bottom-right) separately; a polygon's or star's corners share one radius, which rounds its inner corners too. On the canvas, hover a selected rectangle to show a handle inside each corner and drag one to round the corners. Equal corners change together and differing ones one at a time; hold ⌥ to swap the two and ⇧ to round to tens. A polygon or star has one radius handle, below its top point, which rounds all of its corners; drag the handle on its next point around the centre to change the point count, and a star's handle on its first inner point towards or away from the centre to change its ratio. The handles hide while the shape is under 108 pixels across on screen; zoom in to reach them. ### Effects Add visual effects from the Effects section: * **Drop Shadow** — offset, blur radius, spread, color * **Inner Shadow** — same controls, rendered inside the shape * **Layer Blur** — blurs the entire node * **Background Blur** — blurs content behind the node * **Foreground Blur** — blurs content in front Click **+** to add an effect. Each effect row is collapsible with inline controls. Toggle the eye icon to enable/disable an effect. ## Frames and Sections **Frames** are containers. Drag shapes into a frame to make them children, or start drawing inside one: a new shape goes into the frame under the point where you start, except locked frames, and joins the end of an auto-layout flow. Drawing a frame over existing nodes puts the unlocked ones it fully covers inside it. New frames clip their content, as in Figma (turn off **Clip content** to let children show outside), and frames support [auto layout](./auto-layout). Select the Frame tool to browse collapsible presets for phones, tablets, desktops, presentations, watches, paper, social media, Figma Community assets, and archived devices in the Design panel. Choosing a preset creates a named frame centered in the viewport and returns to the Select tool. With an existing frame selected, use its Frame preset dropdown to resize it without changing its name. **Sections** are top-level containers that take in the sibling nodes they fully cover when drawn, moved, or resized. They're useful for organizing large canvases into logical areas. Sections display a title pill that you can drag. ## Keyboard Shortcuts | Action | Mac | Windows / Linux | |--------|-----|-----------------| | Rectangle tool | R | R | | Ellipse tool | O | O | | Line tool | L | L | | Frame tool | F or A | F or A | | Section tool | ⇧S | Shift + S | | Constrain to square/circle | Shift + drag | Shift + drag | ## Tips * Sections can only exist at the top level — they can't be nested inside frames. * Use frames with [auto layout](./auto-layout) to build responsive layouts. * [Export](./exporting) individual shapes or groups as images via the properties panel or context menu. --- --- url: 'https://openpencil.ink/user-guide/text-editing.md' description: >- Creating and editing text with rich formatting, fonts, and inline editing in OpenPencil. --- # Text Editing Create text nodes and edit them directly on the canvas with full rich text support. ## Creating Text Press T to activate the text tool, then click on the canvas. An empty text node appears with a blinking cursor — start typing immediately. ## Inline Editing Double-click any existing text node to enter inline editing mode. A blue outline appears around the text to indicate edit mode. Click outside the text node to commit and exit editing. Text is rendered directly on the canvas — there's no separate text input overlay. ## Cursor Navigation | Action | Mac | Windows / Linux | |--------|-----|-----------------| | Move left/right | ← / → | ← / → | | Move up/down | ↑ / ↓ | ↑ / ↓ | | Move by word | ⌥← / ⌥→ | Ctrl + ← / Ctrl + → | | Move to line start/end | ⌘← / ⌘→ | Home / End | Hold Shift with any movement key to extend the selection. ## Text Selection * **Click** inside a text node to position the cursor * **Click + drag** to select a range of text * **Double-click** a word to select it * **Triple-click** to select all text in the node ## Rich Text Formatting Apply formatting to selected text, or toggle the style for the entire node when nothing is selected. | Action | Mac | Windows / Linux | |--------|-----|-----------------| | Bold | ⌘B | Ctrl + B | | Italic | ⌘I | Ctrl + I | | Underline | ⌘U | Ctrl + U | Strikethrough is available via the **S** toggle button in the Typography section of the properties panel (no keyboard shortcut — ⌘S is used for Save). Formatting is applied per character. When you type between a bold and regular segment, the new text inherits the style of the preceding segment. The **B / I / U / S** toggle buttons in the Typography section of the properties panel also apply formatting. ## Editing Operations | Action | Mac | Windows / Linux | |--------|-----|-----------------| | Delete word before cursor | ⌥⌫ | Ctrl + Backspace | | Delete to line start | ⌘⌫ | — | | Cut | ⌘X | Ctrl + X | | Copy | ⌘C | Ctrl + C | | Paste | ⌘V | Ctrl + V | ## Font Picker Open the font picker in the Typography section of the properties panel to change the font family. The picker features: * **Search filter** — type to narrow the font list * **Font preview** — each font name is rendered in its own typeface * **Virtual scroll** — handles large font lists efficiently * **Scroll-to-current** — the current font is highlighted when the picker opens ## Text on a Path Imported Figma text-on-path layers retain their curved glyph layout. You can edit their text and typography, resize and select them, and save or export the result while preserving the path layout. This is support for existing imported path-text layers, not a separate drawing tool for attaching arbitrary text to a new path. ## Font Weight Change the font weight in the Typography section of the properties panel. Available weights depend on the selected font family (e.g., Regular, Medium, Bold, Black). ## Font Sources * **Default font** — Inter is loaded automatically * **Desktop app** — system fonts plus enabled Google Fonts, Fontsource, Bunny Fonts, and Fontshare catalogs * **Browser** — system fonts are available in Chrome and Edge with local-font permission. Enabled Fontsource, Bunny Fonts, and Fontshare providers can load online fonts, subject to network access and browser CORS rules; Google Fonts is disabled in the OpenPencil browser app * **Downloaded fonts** — the desktop app caches downloaded faces for reuse on the same machine ## Missing Fonts and Substitutions When a requested family or style cannot be loaded, OpenPencil displays a warning above the editor instead of silently treating fallback rendering as faithful typography. Expand the warning to see every affected face and its active substitute. Use **Select layers** to locate all affected text nodes or **Retry fonts** after changing network access, local-font permission, or provider settings. A style may be synthesized from another loaded face in the same family; a missing family falls back to Inter when available. ## Tips * The font list is preloaded at startup so the picker opens without delay. * IME input (Chinese, Japanese, Korean) is fully supported. * Rich text formatting is preserved when opening and saving .fig files. * See [Components](./components) for how text overrides work in component instances. --- --- url: 'https://openpencil.ink/user-guide/pen-tool.md' description: Drawing vector paths with bezier curves using the pen tool in OpenPencil. --- # Pen Tool The pen tool creates vector paths using a vector network data model, compatible with Figma's .fig format. ## Activating Press P to activate the pen tool. ## Placing Points * **Click** to place a corner point (straight-line segment) * **Click + drag** to place a curve point with bezier tangent handles — the drag direction and length control the curve shape * **Hold Space** while dragging (without releasing the mouse button) to move the point itself Click multiple points to build a path segment by segment. A preview line extends from the last placed point to your cursor as you move. ## Closing a Path Click on the **first point** of the path to close it into a loop. Closed paths can be filled. ## Open Paths Press Escape to commit the current path as an open path. Open paths render as strokes only — they're not filled. ## Vector Networks Paths in OpenPencil use vector networks — a more flexible model than simple point lists that supports branching paths and complex topology. This is the same model Figma uses, so paths round-trip perfectly in .fig files. ## Pencil Press ⇧P, or pick Pencil from the pen tool's menu in the toolbar, to draw freehand. Each stroke becomes its own vector layer, smoothed into curves you can edit like any other path, and the pencil stays active for the next stroke. * Zoom in to keep more detail: the curve follows your stroke to within a few screen pixels. * Hold Shift to draw straight, horizontally or vertically, from where you pressed it. * End a stroke where it started to close it into a loop. * A stroke goes into the frame that holds all of it, otherwise onto the page. * New strokes contrast with what they are drawn on. Change the stroke of the last one you drew, and the pencil keeps that style. ## Keyboard Shortcuts | Action | Mac | Windows / Linux | |--------|-----|-----------------| | Pen tool | P | P | | Pencil | ⇧P | Shift+P | | Commit open path | Escape | Escape | ## Tips * The preview line always starts from the last placed point — it won't jump to (0,0). * Drag longer when placing a curve point to make the curve wider. * After creating a path, use the properties panel to adjust its fill, stroke, and effects. --- --- url: 'https://openpencil.ink/user-guide/vector-edit.md' description: >- How to edit vector path geometry: anchors, bezier handles, modifiers, and Pen tool actions in edit mode. --- # Vector Object Editing Vector Object Editing mode lets you change a curve's **geometry**: anchor positions, segment shape, and bezier handles.\ In this mode, you edit the path itself, not standard object transforms. ## Entering the Mode * Select a vector object with the Select tool. * **Double-click the curve**. This activates geometry editing for the selected vector. ## Exiting the Mode * Press Escape. * Or switch to another editing context. ## What Changes in This Mode * The normal transform bounding box is disabled for the object. * Anchor, segment, and handle editing becomes available. * Cursor behavior does not switch to resize/rotate at bbox corners. ## Basic Actions ### Move an Anchor * Drag an anchor point. * Connected segments and path shape update live in preview. ### Edit a Bezier Handle * Drag a handle on the anchor. * By default, behavior follows the anchor's current handle composition. ## Handle Drag Modifiers | Action | Mac | Windows / Linux | |----------|-----|-----------------| | Continuous (Smooth / Continuous) | Cmd + drag | Ctrl + drag | | Corner (Independent handles) | Option + drag | Alt + drag | | Direction lock (length only) | Shift + drag | Shift + drag | ### Continuous: Cmd/Ctrl + drag * The active handle is constrained to the same line as the sister handle. * Only the active handle length changes. * Use this for smooth transitions without a corner break. ### Corner: Option/Alt + drag * The active handle is edited independently. * The sister handle stays in place. * Use this to create a sharp corner transition. ### Direction Lock: Shift + drag For anchors with **Continuous** or **Symmetric** composition: * handle direction is locked to the value from **before the current drag started**; * dragging changes only handle length (or lengths, depending on composition). ## Bend Override by Dragging an Anchor When you drag an anchor while holding Cmd/Ctrl, the editor selects the target handle by **segment attachment direction** at that anchor (not by nearest neighbor-point distance).\ This also works on multi-branch vector-web anchors: once resolved, the target handle stays locked for the current drag. ## Using the Pen Tool in Edit Mode With the Pen tool active: * **Click a segment** to insert a new anchor (split segment). * **Click an open-path endpoint** to resume drawing from that point. * **Option/Alt + click an anchor** to delete it (when topology allows). For path creation and closing behavior, see [Pen Tool](./pen-tool.md). ## Practical Workflow 1. Draw a shape with the Pen tool. 2. Double-click the curve to enter Vector Object Editing mode. 3. Move anchors to refine the silhouette. 4. Drag handles: * with Cmd/Ctrl for smooth continuous transitions, * with Option/Alt for independent edits, * with Shift for length-only edits. 5. Press Escape to exit. --- --- url: 'https://openpencil.ink/user-guide/icons.md' description: >- Search open-source icon sets, insert icons, and swap or recolor them in OpenPencil. --- # Icons OpenPencil searches the open-source icon sets published through [Iconify](https://iconify.design), such as Lucide, Material Symbols, Tabler, and Phosphor. A placed icon remembers which icon it is, so you can swap it for another or change its color later, and exports write it as that icon. Searching and placing icons needs a network connection to the Iconify API. ## Inserting an Icon Open the icon picker in any of these ways: * Click the **Insert icon** button at the end of the toolbar * Choose **Object → Insert Icon…** * Search for **Insert Icon** in the command palette (⌘K) Before you type, the picker shows the icons already placed in this file and the ones you picked lately. Type what you're looking for, such as `arrow` or `heart`, to search every set; each result shows a preview and the set it comes from. Pick one to place it, 24 × 24, in the middle of the view, inside the frame you're editing if you've entered one. The new icon is selected, and ⌘Z removes it in one step. ## Choosing an Icon Set The set button at the end of the search field (**All icon sets** at first) narrows the picker to one set. Sets are grouped by kind, such as Material or Logos; type to find one by name, such as `tabler`. Each set shows how many icons it has. With a set chosen, the picker shows the whole set, loading more as you scroll, and a search looks only in that set. Hover over an icon, or move to it with the arrow keys, to see its name, set, and license below the grid. The picker remembers the set you chose last, and **Swap icon** opens on the set of the icon you're swapping. ## The Icon Section Selecting a placed icon adds an **Icon** section to the Design panel. * **Swap icon** opens the picker and draws the chosen icon in place of the current one. The icon keeps its size, position, and color. * **Icon color** changes the color of the icon's own paths. Icons drawn in several fixed colors, such as brand logos, keep those colors and don't show this field. Each swap or color change is one undo step; dragging in the color picker undoes as a single change. An icon is a frame of vector paths, named after the icon, such as `cat`, and marked with a smiley in the layer tree. Unlike other frames on the page, it has no title on the canvas, and resizing it scales its paths. Its layers, constraints, effects, and the rest of the Design panel work as they do for any frame. ## Editing an Icon's Paths You can edit an icon's paths like any vector. Once its points, paths, or own colors change, the Icon section says **Modified**: * The icon exports as the paths it now draws rather than by its name, so the edit is kept. * **Reset** (the circular arrow) draws the original icon again, at the same size and color. * Swapping asks first, since a swap draws a new icon and discards the edit. Resizing the icon or changing its color doesn't count as an edit. **Detach icon**, in the Icon section, the canvas menu, or **Object → Detach Icon**, makes an icon plain artwork: it keeps its paths but is no longer swapped, recolored, or exported as an icon. Like every icon command, it's one undo step. ## Icons in Exports * **Design JSX** writes a placed icon as ``. * **HTML** writes it as inline SVG marked with `data-icon`, with its paths painted in `currentColor` and the icon's color set on the ``, so changing that one `color` recolors it. * **SVG and images** draw it like any other vector. Icons placed by AI agents, MCP clients, and design JSX are icons in the same way, so the Icon section works on them too. --- --- url: 'https://openpencil.ink/user-guide/layers-and-pages.md' description: 'Managing layers, pages, and the properties panel in OpenPencil.' --- # Layers & Pages The editor interface has three main panels: layers (left), canvas (center), and properties (right). All panels are resizable by dragging the dividers. ## Layers Panel The layers panel on the left displays the document hierarchy as a tree. ### Tree View Nodes are shown in a collapsible tree. Click the chevron next to a frame, group, or component to expand or collapse its children. ### Drag Reorder Drag layers to reorder them. Nodes higher in the list render on top. ### Visibility Toggle Click the eye icon next to any layer to hide or show it on the canvas. Hidden nodes remain in the tree. ### Rename Double-click a layer name to rename it inline. Press Enter or click away to commit, Escape to cancel. ### Selection Sync Clicking a layer in the panel selects the corresponding node on the canvas, and vice versa. ## Pages Panel The pages panel shows all pages in the document. * **Switch page** — click a page tab to make it active. The canvas switches to that page and restores its viewport position. * **Add page** — click the add button to create a new page * **Delete page** — remove the current page * **Rename page** — double-click the page name for inline editing. Pressing Enter or Escape, or clicking away, commits the rename. Each page has its own canvas and viewport state. ## Documents and Recovery The Home workspace lists recent local documents and documents from configured storage providers. Use grid or list view, or open multiple selected files into separate editor tabs. In **Settings → General → Recovery**, **Automatically preserve unsaved work** controls local recovery copies. With recovery enabled, unsaved documents without a file path remain recoverable even after their tabs close. When OpenPencil offers **Recover unsaved work**, choose **Restore** to reopen a document or **Discard** to remove its recovery copy. Disabling recovery stops automatic preservation and removes recovery copies owned by currently open documents. It does not bulk-delete snapshots from previously closed documents. Recovery is local to this installation/browser profile; it is not a replacement for saving a file, cloud synchronization, or a backup. ## Properties Panel The properties panel on the right has three tabs: ### Design Tab Shows the properties of the selected node(s), organized in sections: * **Appearance** — opacity, corner radius (with independent corner toggle), visibility * **Fill** — solid color, gradients (linear, radial, angular, diamond), image fills, variable bindings * **Stroke** — color, width, cap, join, dash pattern * **Effects** — drop shadow, inner shadow, layer blur, background blur, foreground blur * **Typography** — font family, size, weight, B/I/U/S formatting buttons (visible for text nodes) * **Layout** — [auto layout](./auto-layout) controls (visible for auto-layout frames) * **Export** — scale, format, and export button (see [Exporting](./exporting)) When no nodes are selected, the Design tab shows page-level properties including the canvas background color. ### Code Tab Displays the selected node as code with syntax highlighting, line numbers, and a copy button. A format toggle lets you switch between two output modes: * **OpenPencil JSX** — custom component tree compatible with `renderJSX()` for programmatic round-trip * **Tailwind CSS v4** — HTML with utility classes (`
`) ready to paste into React/Vue projects ### AI Tab An [AI chat interface](../programmable/ai-chat) (also toggled with ⌘J) that can create and modify design elements via natural language. Configure reusable models and provider connections in Settings, and browse locally saved conversations. ## Keyboard Shortcuts | Action | Mac | Windows / Linux | |--------|-----|-----------------| | Toggle AI chat | ⌘J | Ctrl + J | ## Mobile Layout On mobile and small screens, the side panels are replaced by a swipeable bottom drawer. Tabs at the top of the drawer switch between Layers, Properties, Design, and Code views. The toolbar collapses to a compact horizontal strip with category switching. ## Tips * Panel widths are saved automatically — they persist across reloads. * Use the layers panel to find overlapping nodes that are hard to click on the canvas. * The [context menu](./context-menu) provides additional actions for selected nodes. * See [Selection & Manipulation](./selection-and-manipulation) for z-order shortcuts (]/\[) and visibility/lock toggles. --- --- url: 'https://openpencil.ink/user-guide/exporting.md' description: >- Export images, SVG, and .fig subsets, and open .fig or .pen documents in OpenPencil. --- # Exporting Export individual nodes as images or `.fig` subsets, and open full `.fig` or `.pen` documents. ## Image Export Select a node and use the Export section in the properties panel. ### Export Settings * **Scale** — 0.5×, 0.75×, 1×, 1.5×, 2×, 3×, or 4× (hidden for SVG — vectors are resolution-independent) * **Format** — PNG (transparent background), JPG (white background), WEBP (transparent background), SVG (vector), `.fig` (native document subset) You can add multiple export settings to export the same node at different scales or formats in one go. A live preview with a checkerboard background shows what will be exported. ### Export Methods | Method | Mac | Windows / Linux | |--------|-----|-----------------| | Keyboard shortcut | ⇧⌘E | Shift + Ctrl + E | | Context menu | Right-click → Export… | Right-click → Export… | | Properties panel | Click "Export" button | Click "Export" button | The exported file is saved via a native dialog (desktop) or browser download. ## Copy/Paste as In addition to file export, you can copy the selection to the clipboard in multiple formats via the context menu (right-click → Copy/Paste as): | Action | Shortcut (Mac) | Shortcut (Win/Linux) | |--------|----------------|----------------------| | Copy as text | — | — | | Copy as SVG | — | — | | Copy as PNG | ⇧⌘C | Shift + Ctrl + C | | Copy as JSX | — | — | * **Copy as text** — copies visible text content from the selection * **Copy as SVG** — copies the selection as SVG markup (paste into code editors, Inkscape, etc.) * **Copy as PNG** — renders at 2× and copies to the clipboard (ready to paste into Slack, Notion, etc.) * **Copy as JSX** — copies the OpenPencil JSX representation (compatible with `renderJsx()`) ## .fig File Operations OpenPencil uses the .fig format for full documents — the same binary format as Figma. ### Opening Files | Action | Mac | Windows / Linux | |--------|-----|-----------------| | Open file | ⌘O | Ctrl + O | A file picker dialog opens, filtered for `.fig` and `.pen` files. On the desktop app, this uses the native OS dialog. You can also drag a `.fig`, `.pen`, or other supported document onto the window; it opens in a new tab. Images and SVG files dropped on the canvas are placed in the document instead, and any other file reports that it can't be opened. ### Saving Files | Action | Mac | Windows / Linux | |--------|-----|-----------------| | Save | ⌘S | Ctrl + S | | Save As | ⇧⌘S | Shift + Ctrl + S | * **Save** overwrites the currently open file without a dialog * **Save As** opens a save dialog to choose a new location Saved files are compressed and include a thumbnail image for preview in file browsers. ### Round-trip Compatibility Files exported from OpenPencil can be opened in Figma, and vice versa. The .fig format preserves all node types, properties, fills, strokes, effects, vector data, and layout settings. ## Keyboard Shortcuts | Action | Mac | Windows / Linux | |--------|-----|-----------------| | Export selection | ⇧⌘E | Shift + Ctrl + E | | Copy as PNG | ⇧⌘C | Shift + Ctrl + C | | Open file | ⌘O | Ctrl + O | | Save | ⌘S | Ctrl + S | | Save As | ⇧⌘S | Shift + Ctrl + S | ## Tips * Use 2× or 3× scale when exporting for high-DPI screens. * JPG always uses a white background — use PNG or WEBP if you need transparency. * Use SVG export when you need a vector format for further editing in Illustrator, Inkscape, or code. * The thumbnail in exported .fig files enables preview in file browsers and Figma's file picker. --- --- url: 'https://openpencil.ink/user-guide/cloud-storage.md' description: >- Keep documents and component libraries in your own S3-compatible bucket — AWS S3, Backblaze B2, Cloudflare R2, MinIO, and others. --- # Cloud Storage OpenPencil can keep documents in a bucket you own on any S3-compatible service: AWS S3, Backblaze B2, Cloudflare R2, MinIO, and others. There is no OpenPencil server in between; the editor talks to your bucket directly with your own access keys. ## Connect a Bucket Open **Settings → Cloud storage**, select **S3 storage**, and fill in: | Field | What to enter | | --------------------- | --------------------------------------------------------------------------------- | | **Endpoint** | The provider's S3 API URL, including any required path. See the examples below. | | **Bucket** | The bucket name. | | **Region** | Optional. Left empty, it is read from the endpoint, or `us-east-1` when it can't be. | | **Access key ID** | An access key with read, write, list, and delete access to the bucket. | | **Secret access key** | The key's secret. | Endpoint examples: * AWS S3: `https://s3.eu-west-1.amazonaws.com` * Backblaze B2: `https://s3.eu-central-003.backblazeb2.com` * Cloudflare R2: `https://.r2.cloudflarestorage.com` * MinIO: `http://localhost:9000`, or wherever your server listens Select **Test connection** to check the endpoint, keys, and bucket before saving, then **Save**. OpenPencil addresses objects as `{endpoint}/{bucket}/{key}`, so the bucket name never goes into the endpoint. Access keys are kept in the system credential store in the desktop app. In the browser they are encrypted in browser storage, or kept only until you close the session when **Settings → General → Remember API keys on this device** is off. ## Allow the Web App in CORS The browser can only reach a bucket that allows requests from the page's origin, so the web app needs a CORS rule on the bucket. The desktop app sends its requests natively and needs none. Add a rule like this one, with the origins you open OpenPencil from: ```json [ { "AllowedHeaders": ["*"], "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"], "AllowedOrigins": ["https://openpencil.ink"], "ExposeHeaders": ["ETag", "Content-Range", "x-amz-request-id", "x-amz-id-2", "x-amz-version-id"], "MaxAgeSeconds": 3600 } ] ``` Set it in the AWS console under the bucket's **Permissions → Cross-origin resource sharing**, in the Cloudflare dashboard under the R2 bucket's **Settings → CORS policy**, or with your provider's CLI. When the rule is missing, **Test connection** says the browser could not reach the bucket from the current site. ## Save a Document to Storage Open or create a document, then choose **File → Save to storage…**. The document is saved into the bucket and the tab stays bound to it: **Save** and auto-save now write to storage instead of a local file. A local `.fig` you had open keeps its last saved contents. To open stored documents, choose **File → Open storage workspace…**. The workspace lists every document in the bucket with a preview read from the document itself, so listing a large file downloads only its embedded thumbnail. ## How Saving Works Saving writes the document on this device first and uploads it in the background, so editing never waits for the network. If an upload fails or you are offline, it is retried with increasing delays, and pending work resumes when you reconnect or change storage settings. Each document is uploaded whole. When two devices save the same document, the last upload wins. Documents you have opened stay cached on the device for faster reopening, up to 500 MB. Only copies that are fully uploaded are removed when the cache is full, and they are downloaded again the next time you open them. ## Component Libraries The bucket can also hold component libraries. In **Assets → Manage libraries**, switch the source to **Storage** to publish and enable libraries there instead of on this device. Publishing uses conditional writes, so two people publishing the same library at once can't overwrite each other's revision. See [Components](./components#component-libraries) for publishing and updating libraries. ## What's in the Bucket OpenPencil writes only under two prefixes and leaves everything else in the bucket alone: * `open_pencil_storage/canvases/` — each document as `.fig`, with its name and modification time in `.meta.json`. * `open-pencil/libraries/` — each library's `manifest.json` and its published revisions. ## Provider Notes * **Cloudflare R2** needs the exact web app origin in its CORS rule; it does not accept wildcard origins such as `https://*.example.com`. * **MinIO** answers CORS requests from any origin unless you configure otherwise. * **`rclone serve s3`** works with two limits. It rejects CORS preflight requests, so the web app needs a reverse proxy in front of it that answers them; the desktop app does not. It also ignores conditional writes, so concurrent library publishes are not detected. --- --- url: 'https://openpencil.ink/user-guide/auto-layout.md' description: >- Flex and grid layout in OpenPencil — direction, gap, padding, alignment, child sizing, and CSS Grid tracks. --- # Auto Layout Auto layout positions children automatically within a frame. It supports two modes: **flex** (horizontal/vertical flow) and **grid** (rows and columns with track sizing). ## Enabling Auto Layout * Select a frame and press ⇧A (Shift + A) to toggle auto layout on or off * Select loose nodes (without a parent frame) and press ⇧A to wrap them in a new auto-layout frame When wrapping a selection, nodes are sorted by visual position: left-to-right for horizontal layout, top-to-bottom for vertical. ## Layout Direction Choose how children are arranged: * **Horizontal** — children flow left to right * **Vertical** — children flow top to bottom * **Wrap** — children wrap to the next row/column when they run out of space ## Spacing ### Gap The space between adjacent children. Set a single value that applies between all children. ### Padding The space between the frame edge and its children. Set a uniform value for all sides, or expand to set each side independently (top, right, bottom, left). ## Alignment ### Justify (main axis) Controls how children are distributed along the layout direction: * **Start** — children pack to the beginning * **Center** — children are centered * **End** — children pack to the end * **Space between** — children spread with equal space between them ### Align (cross axis) Controls how children are positioned perpendicular to the layout direction: * **Start** — children align to the start * **Center** — children are centered * **End** — children align to the end * **Stretch** — children stretch to fill the cross axis ## Child Sizing Each child in an auto-layout frame can have its own sizing mode: * **Fixed** — uses the child's explicit width/height * **Fill** — stretches to fill available space in the parent * **Hug** — shrinks to fit the child's content ## Drag Reordering Within an auto-layout frame, drag a child to reorder it among its siblings. A visual insertion indicator shows where the child will be dropped. Dragging a node from elsewhere into an auto-layout frame inserts it the same way; in a wrapping frame, it goes into the line under the cursor. Drawing inside an auto-layout frame adds the new node to the end of its flow. Hold ⌃ (Ctrl on Windows and Linux) while dragging to ignore auto layout: the node is dropped where you release it as an absolute-positioned child. ## Properties Panel When an auto-layout frame is selected, the Layout section in the properties panel shows all auto-layout controls: direction, gap, padding, justify, and align. ## Keyboard Shortcuts | Action | Mac | Windows / Linux | |--------|-----|-----------------| | Toggle auto layout | ⇧A | Shift + A | ## CSS Grid Grid layout arranges children in rows and columns with explicit track sizing. ### Enabling Grid Select a frame with auto layout enabled and click the grid icon in the layout toolbar to switch from flex to grid. ### Track Sizing Define column and row tracks with three sizing modes: * **fr** — fractional unit, divides available space proportionally * **px** — fixed pixel size * **auto** — sizes to fit content Example: three columns of `1fr 200px 1fr` creates a layout with a fixed center column and flexible sides. ### Grid Gaps Set separate horizontal (column) and vertical (row) gaps between cells. ### Child Positioning Children are placed into grid cells automatically in row order. You can override placement with column/row start and span values in the child's layout properties. ### JSX and Tailwind Export Grid layouts export to JSX with Tailwind classes: `grid grid-cols-3`, `gap-x-4 gap-y-2`, `col-start-2 row-span-2`. ## Tips * Auto layout recomputes immediately after creation, so the selection bounds update right away. * Nest auto-layout frames for complex responsive layouts (e.g., a vertical frame containing horizontal rows). * Use "Fill" sizing to make a child take up remaining space, like a flex-grow: 1 in CSS. * Use grid for dashboard layouts, galleries, and forms — anything with a two-dimensional structure. * See [Drawing Shapes](./drawing-shapes) for creating the frames that auto layout applies to. * See [Components](./components) for using auto layout within reusable components. --- --- url: 'https://openpencil.ink/user-guide/components.md' description: >- Creating reusable components, instances, component sets, overrides, and live sync in OpenPencil. --- # Components Components are reusable design elements. Edit the main component and all its instances update automatically. ## Browse Components Open the **Assets** tab in the left panel to browse local components and enabled libraries. Use grid or list view, search by component name, and select a component to see its details. You can insert an asset by clicking it, pressing Enter, or dragging it onto the canvas. Local assets are grouped by source page. Published library assets remain available when their revision has been downloaded, including when the remote provider is temporarily offline. ## Creating a Component Select a frame or group and press ⌥⌘K (Ctrl + Alt + K). The selection becomes a reusable component. Any other layer, or several layers, is wrapped in a new white component at their bounding box, in the topmost layer's place in the layer list; a single wrapped layer gives the component its name. Components display a purple label with a diamond icon above them. ## Component Sets and Variants Select two or more components and press ⇧⌘K (Shift + Ctrl + K) to combine them into a component set — a container with a dashed purple border and 20 px padding around its children, as in Figma. Sets made by scripts with `figma.combineAsVariants()` wrap their components exactly, as Figma's plugin API does. Each component in a set can define values across multiple variant dimensions, such as `Size=Small`, `State=Hover`, and `Theme=Dark`. OpenPencil supports sparse combinations, so a set does not need every possible combination. The top-left variant is the default and is used as the fallback when an update no longer contains an exact combination. Select a component and press **Add variant** in the panel header to turn it into a component set, as Figma does: the component becomes `Property 1=Default` and a copy named `Property 1=Variant2` appears below it. On a set, **Add variant** copies its last variant with the next value. The set's variant properties are rows of its **Properties** list; click one to rename it or to add, rename, drag to reorder, or remove its values. Removing a value variants still use moves them to another value first, and variants that share every value are named in a notice that selects them. ## Component Properties Components and component sets support reusable text, boolean visibility, instance-swap, and slot properties. Select an instance to edit its values without detaching it. Properties and their values are preserved when saving and reopening `.fig` files. To link a layer of a main component to a property, use the link button where its field lives: **Typography** for text content, **Appearance** for visibility, and the instance header for the swapped component. Create a property from the layer's current value or pick an existing one; the field then shows the property instead of its value. Typing into linked text sets the property — its default in the main component, the instance's value in an instance. Select the main component to see its **Properties**. Click a property to rename it, change its default, see and unlink the layers it drives, or delete it, and drag properties to reorder them. **+** adds a text, boolean, or instance-swap property. To show a nested instance's properties on instances of the component, choose **+ → Nested instances** and tick it, as Figma's **Expose properties from nested instances** does. Instances of the component then list its properties under its name. Only an instance whose component has properties can be exposed. ## Slots A slot is a frame of a main component whose content each instance can change. Select a frame inside a main component and choose **Create slot** from the context menu or the Slots section, or select other layers to wrap them in a new slot frame. Slot settings set a description, the components it prefers, and how many items it holds; an instance outside those limits shows a warning. In an instance, add, reorder, and remove a slot's items, or reset it to the component's content. ## Behaviours and Preview A behaviour makes a main component or component set work like a real control, after [Reka UI](https://reka-ui.com)'s primitives: Button, Text field, Textarea, Number field, Toggle, Switch, Checkbox, Radio, Radio group, Toggle group, Slider, Progress, Tabs, Collapsible, or Accordion. Select the component and use **+** in the **Behaviour** section to choose one. The section lists what the control needs: * **Values** — the property that holds each value: a variant or boolean property for On, Checked, Pressed, Open, Filled, or Disabled (with the variant values that mean on and off), a text property for a field's text, or a slider's own minimum, maximum, step, and default. * **Parts** — the slot that draws each part, such as a switch's thumb, a slider's track, range, and thumb, or a tab list. A group's items slot holds instances of its radios, toggles, or collapsibles. * **States** — a variant property whose values draw default, hover, pressed, focus, and disabled. Values named like those states are matched automatically. Rows the control requires come first; the rest are under **More options**. When a component has nothing to bind yet, a row offers to create it: a text layer and text property, Off and On variants, or a slot, and **States** offers **Add state variants**, which adds a Default, Hover, Pressed, Focus, and Disabled variant of the component. A lone main component that gets variants this way becomes a component set, and a slot added to a set appears in every variant. Until the control works, a line under its name says what is still needed; clicking it goes there. Press ⌥⌘↩ (Ctrl + Alt + Enter), choose **View → Preview**, or use ▶ next to Share to preview the canvas. Each top-level layer holding controls runs as live Reka UI components over the canvas, drawn by the component's variants: switches flip, sliders drag, tabs and accordions open, and text fields are real inputs with the browser's caret, selection, and paste. Preview never changes the document or its history. **Reset** puts every control back as designed, and Esc or the pill's close button returns to editing. In split view, each canvas previews on its own. Behaviours are saved in `.fig` files and published with library components. ## Component Libraries A component library publishes reusable components as an immutable revision. Each published asset has stable library, asset, and revision identity, so different instances can remain on different revisions until you explicitly update them. ### Publish a Library 1. Create the components and component sets you want to share. 2. Open **Assets**, then select **Manage libraries**. 3. Select **Publish library**. 4. Enter a stable library ID and display name. The library ID is locked after the first publication. 5. Optionally search the change list and enter a revision description. 6. Select the added, modified, renamed, or removed assets to include. 7. Confirm the destination and select **Publish library**. On later publications, unchecked changes remain pending. Unchanged assets keep their previous published definitions, and removed definitions remain available while documents still reference their historical revision. ### Enable and Insert Library Assets Open **Assets → Manage libraries** to enable a published library. Its components appear in the Assets panel alongside local components. Insert one by clicking it, using the keyboard, or dragging it onto the canvas. Published definitions are read-only in consuming documents. Edit the source document and publish another revision to change a definition. Instances linked to those definitions remain editable through their component properties and overrides. ### Review and Accept Updates Open **Manage libraries → Updates** to discover newer revisions. Discovery does not modify the document. You can review the current and updated instance side by side, navigate between affected instances, and then update: * The selected instance * All instances of one asset * Instances on the current page * Instances across all pages OpenPencil preserves compatible text, visibility, and instance-swap assignments. If an exact variant no longer exists, the review identifies the top-left fallback before you accept it. Applying an update creates an undo entry. ### Local, Storage, and Offline Use Libraries can use the local browser catalog or a configured storage provider. Remote publication uses immutable revision objects and a conditional latest pointer, preventing two publishers from silently overwriting each other. Downloaded revisions are cached locally. A document can continue rendering and inserting downloaded definitions while offline. Integrity failures are reported instead of being hidden by cached data. ### Saving Consumer Documents Enabled-library bindings and materialized definitions are saved with `.fig` documents. Reopening a consumer file preserves its linked instances and revision identities, even when its remote library is unavailable. ## Creating Instances Right-click a component and select **Create instance** from the context menu. The instance appears 40 px to the right of the source component, visually identical. Instance creation is available only through the context menu — there's no toolbar button. ## Detaching an Instance Select an instance and press ⌥⌘B (Ctrl + Alt + B) to detach it. The instance becomes a regular frame with no link to the original component. All overrides are baked in. ## Go to Main Component Right-click an instance and select **Go to main component**. The editor navigates to and selects the main component, switching pages if needed. ## Live Sync When you edit a component, all its instances update automatically, as in Figma: every property an instance does not override follows the component. That covers size, fills, strokes and their weight, alignment, caps, joins, and dashes, effects, opacity, blend mode, corner radii and smoothing, layout settings and grids, clipping, shared styles, and text, from its font and size to letter spacing, line height, case, decoration, alignment, and styled ranges. A layer inside an instance also takes its position, rotation, flips, constraints, and mask from the component. Move or rotate a layer in the component and it moves in every instance. When an instance has a different size than its component, the layer is placed by its constraints, so a layer pinned to the right keeps its distance from the right edge. Inside auto layout, the layout places it. Sync triggers automatically after node updates, moves, and resizes within a component. ## Overrides Instances can override specific properties without breaking the sync link. When a property is overridden on an instance, that property is skipped during sync — other properties continue to update from the main component. ### Overridable Properties A layer inside an instance can override its name, text, styled ranges, and font, every visual and text property listed above, its size, and whether it is visible or locked. Its position, rotation, flips, constraints, and mask cannot be overridden: they always come from the component. Scripts that set its position, rotation, constraints, or mask get Figma's error. Slot content is the instance's own and stays editable. Overrides in a `.fig` file come back as overrides when you open it, and are saved the way Figma writes them. ### New Children When you add a child to a component, all existing instances gain a cloned copy automatically. Child order in instances always matches the component. ## Hit Testing Components and instances are opaque containers — clicking on a child selects the component itself, not the child. **Double-click** to enter the component and select children inside it. ## Visual Treatment | Element | Appearance | |---------|------------| | Component label | Purple with diamond icon, always visible | | Instance label | Purple with diamond icon, always visible | | Component set border | Dashed purple outline | ## Keyboard Shortcuts | Action | Mac | Windows / Linux | |--------|-----|-----------------| | Create component | ⌥⌘K | Ctrl + Alt + K | | Create component set | ⇧⌘K | Shift + Ctrl + K | | Detach instance | ⌥⌘B | Ctrl + Alt + B | | Preview | ⌥⌘↩ | Ctrl + Alt + Enter | ## Tips * Editing text inside an instance creates an override — the text won't be overwritten when the component changes. * Use component sets to organize multidimensional variants such as size, state, and theme. * Publish reusable assets from their source document; published definitions are intentionally read-only in consumer documents. * Review updates before accepting them when a revision removes an exact variant combination. * See [Context Menu](./context-menu) for all component-related actions. --- --- url: 'https://openpencil.ink/user-guide/variables.md' description: 'Design variables, collections, modes, and fill bindings in OpenPencil.' --- # Variables Variables store reusable design tokens — colors, spacing values, and other properties — that can be bound to nodes. Change a variable's value and every node using it updates. ## Opening the Variables Dialog Open it from **View → Variables…**, by searching "Variables" in the command palette, or, with no nodes selected, from the Variables section of the Design tab. **Expand** in the dialog's corner gives it most of the window. The dialog lists the active collection's variables on the left, edits the selected variable or the collection on the right, and shows the stylesheet they produce below. In a narrow window or on a phone it shows one mode at a time, and a variable, the collection settings, or the stylesheet opens over the list with a back button. ## Collections Variables are organized into collections. In a wide dialog they are listed in a sidebar with how many variables each holds; narrower, they are tabs, or a menu on a phone. * **Switch collection** — click it in the sidebar or its tab * **Create collection** — click **+** next to Collections, or the folder button in the toolbar * **Rename or delete** — with no variable selected, the right side edits the collection: change its name, or delete it from the **⋯** menu next to the name * **Switch attribute** — the attribute that turns manually switched modes on, `data-theme` by default for a collection named Theme; type another name, such as `data-color-scheme`, to match an existing codebase ## Modes Each collection can have multiple modes (e.g., Light and Dark). Modes appear as value columns in the list, and a variable has a value for each mode. Manage them in the collection settings: * **Add mode** — click **+** next to Modes * **Rename** — edit the mode's name * **Duplicate, set as default, delete** — use the **⋯** menu next to the mode The default mode is **Always on** and goes in `:root`. Every other mode has **Applies when**, which says when the mode takes over in the exported stylesheet; the CSS it writes is shown underneath: | Applies when | CSS | | --- | --- | | **Switched manually** | the collection's switch attribute set to the mode, such as `[data-theme="dark"]` for a Theme collection's Dark mode | | **System is in dark mode** / **System is in light mode** | `@media (prefers-color-scheme: dark)` / `light` | | **High contrast is on** | `@media (prefers-contrast: more)` | | **Reduced motion is on** | `@media (prefers-reduced-motion: reduce)` | | **Screen is narrower than** / **Screen is wider than** a width | `@media (max-width: 640px)` / `min-width` | | **Container is narrower than** / **Container is wider than** a width | `@container (max-width: 640px)` / `min-width` | | **Custom CSS** | any selector, or a `@media`, `@supports`, or `@container` query | On the canvas, a layer shows a mode when you set the layer to it, whatever the condition. In exported code, a manually switched mode is turned on by adding its attribute to an element, so layers set to it export with that attribute. Layers set to a mode with any other condition, custom selectors included, export with literal values instead of tokens, because the stylesheet, not the layer, decides when that mode applies. ## Managing Variables Variables are grouped by the folders in their names (`Brand/Primary` appears as *Primary* under *Brand*), with their CSS name and one value per mode. * **Create variable** — click **Create variable** (or **+**) and pick a type; the new variable opens for editing * **Select** — click a row, or move with the arrow keys and press Enter. Shift-click selects a range, and Cmd-click (Ctrl-click on Windows and Linux) adds or removes one * **Filter** — type in the search bar to filter by name, CSS name, description, or value, such as `--color-brand`, a hex color, or the variable an alias points at, forgiving typos as the command palette does; click a group in the sidebar to show only that group and the groups inside it, or use the filter button to show only some types * **Rename or edit in place** — double-click a name, or a number or text value, in the list * **Right-click** — rename, duplicate, move to a group (or a new one), or delete the selected variables; Delete or Backspace deletes them too * **Reorder** — drag a row; the order is kept in the file * **Several selected** — the right side moves them into a group, duplicates them, or deletes them * **Undo and redo** — Cmd+Z and Cmd+Shift+Z or Cmd+Y (Ctrl on Windows and Linux) work in the dialog as on the canvas, one step per change. Enter commits a field and returns to the list; while a field has text you have not committed, Cmd+Z undoes the typing Selecting a variable edits: * **Name** and **CSS name** — leave the CSS name empty to derive it from the name and scopes, such as `--color-brand-primary`. Type the name without its `--`; a name CSS cannot use is flagged and not saved * **Unit** — for numbers, `px`, `rem`, `%`, `ms`, `s`, `deg`, or none; values are entered in that unit * **Value** (or **Values**, one per mode) — a color opens the color picker. The variable button next to a value points it at another variable of the same type, an alias that follows that variable; **Detach variable** turns it back into the value it showed * **CSS expression** — for numbers, a value such as `clamp(1rem, 4vw, 1.5rem)` written instead of the number in CSS, while the canvas keeps drawing the number * **Scopes** — which properties the variable is offered for * **Description** * **Hide from publishing** — files that use this one as a library do not see the variable ## Stylesheet The bottom of the dialog shows the active collection as CSS custom properties. The copy button copies the whole document's variables as CSS or as a Tailwind v4 theme, so aliases to other collections resolve. ## Importing and Exporting Design Tokens Variables and styles move between OpenPencil, Figma, and code as [W3C design tokens](https://www.designtokens.org/): * **Export:** in the stylesheet's copy menu, choose **Export as design tokens**. The zip holds one file per collection mode, which Figma imports as a mode; text and effect styles as typography and shadow tokens; and a resolver that combines them for tools such as Style Dictionary * **Import:** click **Import design tokens** in the toolbar and pick token files or a zip. Files from Figma, a resolver with the files it names, and Tokens Studio themes all work * **Choose where it goes:** before anything changes, the dialog shows each collection and mode it found and where it goes: a new collection or mode, an existing one, or nowhere. Variables and styles with the same name are updated and the rest is added; nothing is deleted. It counts what will be added, updated, and skipped, and lists why each skipped token was left out * The import is one step: **Undo** takes it all back ## Binding Variables to Fills In the Fill section of the properties panel, use the variable picker to bind a color variable to a node's fill. * **Bind** — select a color variable from the picker. The fill shows a purple badge with the variable name. * **Detach** — click the detach button on the badge to remove the binding. The fill reverts to the resolved color value. When the variable's value changes (or when switching modes), all bound fills update automatically. ## Tips * Use collections to group related tokens (e.g., "Primitives" for raw colors, "Semantic" for role-based aliases, "Spacing" for layout values). * Modes are useful for theme switching — define Light and Dark mode values in the same collection. * Variables support aliases — a "Semantic" collection can reference values from a "Primitives" collection. * See [Drawing Shapes](./drawing-shapes) for how fills and the color picker work. --- --- url: 'https://openpencil.ink/user-guide/checking-designs.md' description: >- Find accessibility, consistency, and structure issues with the Lint panel and canvas issue markers. --- # Checking Designs OpenPencil checks the current page as you work and points out layers that break common design rules: text that is hard to read, controls that are too small to tap, colors that should use a variable, and spacing off the scale. ## Lint Panel Open the **Lint** tab in the right panel. Its badge shows how many errors and warnings the page has. * **Page** lists every issue on the current page; **Selection** narrows the list to the selected layers and everything inside them; **Document** lists every page's issues, tagging the ones on other pages. Clicking one of those switches to its page. Pages of a large `.fig` file that have not been opened yet are checked once you open them. * Issues are grouped by rule, most severe first. Errors and warnings start expanded; suggestions start collapsed. * The severity buttons under the scope switch filter errors, warnings, and suggestions on and off. * Hover a group title to read what the rule checks and why. Hover an issue to highlight its layer on the canvas. Click it to select the layer; if it is off screen, the canvas pans to it, and zooms out only when the layer does not fit. ## Fixing Issues Rows that can be fixed in one step show a button on hover: * A hardcoded color that matches one of the document's color variables binds to it (link button). The row names the variable. * Subpixel positions and sizes round to whole pixels. Values that auto layout or text resizing sets, and vector artwork and the parts of groups, are left alone. * Off-scale corner radius and spacing change to the nearest scale value, and text below the minimum size grows to it (wand button). These change the design, so they apply one row at a time. * A group converts to a frame in place, keeping its layers, position, and look (frame button), and a hidden layer can be deleted (trash button). Neither is offered for locked layers or layers inside a component or instance, whose structure belongs to the component. Binding colors and rounding pixels keep the design as it looks, so their groups also offer **Bind all** or **Fix all**. Every fix is a single undo step. Other issues show the value that needs attention, such as a contrast ratio or a touch target size, so you can fix them in the Design panel. ## Canvas Markers Layers with errors and warnings carry a marker at their top-right corner: red for errors, amber for warnings. Suggestions appear only in the Lint panel. * Hover a marker to see its issues. Click it to select the layer and open its issues in the Lint panel. * Markers that would overlap merge into one showing their combined count. * When a layer is too small to see at the current zoom, its marker moves to the nearest enclosing layer large enough to point at. * Errors and warnings outside the visible canvas are pinned to its edge, pointing toward them. Hover a pin to see them; click it to bring the nearest of the most severe into view and open it in Lint. * Hidden layers and layers clipped out of view by a frame keep their issues in the panel but get no marker. Pages with errors or warnings show their count in the page list, checked in the background while you work. The Layers panel marks the same layers: a layer with errors or warnings shows the most severe as an icon, and a collapsed layer with issues inside it shows a dot. Turn markers and Layers panel marks on or off with **View → Design issues**, or with **Show issues on canvas** in the Lint panel's settings menu. ## In the Code Panel The Code tab underlines errors and warnings on the Design JSX, HTML, Vue, or React of their layers, on the property that causes the issue when there is one, such as `size={10}` for small text, or the class that styles the layer. Hover the underline to read the issue. The underlines follow your edits while you change the code live. ## Rules The settings menu in the Lint panel switches between rule presets: * **Recommended** — balanced defaults for everyday work. * **Strict** — every rule as a warning or error. * **Accessibility** — contrast, touch target size, and text size only. Turn off a single rule from its group's menu. **Turn on turned-off rules** in the settings menu brings them back. Presets and turned-off rules are saved between sessions. The same rules run from the command line with `openpencil lint`; see [Inspecting Files](/programmable/cli/inspecting). --- --- url: 'https://openpencil.ink/programmable/sdk.md' description: Build OpenPencil-powered editors with headless Vue composables and primitives. --- # Vue SDK `@open-pencil/vue` exists so OpenPencil can be more than a standalone design app. The goal is to make OpenPencil a toolkit you can embed into other products, internal tools, and workflow-specific editors — not just a single default UI. The OpenPencil app is one composition of that toolkit. The SDK is how you build a different one. It gives you: * injected editor context * CanvasKit-backed canvas rendering * selection, commands, menu, property-panel, and variables composables * headless structural primitives like `PageListRoot`, `PropertyListRoot`, and `ToolbarRoot` * built-in i18n primitives for menus, panels, dialogs, and custom locale pickers ## Start here ## Why the SDK exists Different products and teams need different editing surfaces. Sometimes you want a full design editor. Sometimes you want a focused canvas inside another app. Sometimes you want an internal workflow tool, a template editor, or an AI-assisted editing surface built around a narrow use case. The SDK is the layer that makes those possible. ## Design principles * **Headless first**: logic and structure, not app styling * **Composable over wrapper**: use composables when there is no meaningful structural coordination * **Intentional public API**: stable exports from `packages/vue/src/index.ts` * **Framework-aware**: Vue integration over `@open-pencil/core` ## How to think about the package The SDK has two main layers: 1. **Composables** for editor state and actions 2. **Primitives** for meaningful UI structure If you only need editor state and actions, start with composables. If you are building reusable editor UI building blocks, start with primitives. ## API sections * [Components](/programmable/sdk/api/components/) * [Composables](/programmable/sdk/api/composables/) * [Advanced](/programmable/sdk/api/advanced/) --- --- url: 'https://openpencil.ink/programmable/sdk/getting-started.md' description: 'Set up @open-pencil/vue with createEditor, provideEditor, and a canvas.' --- # SDK Getting Started ## Installation ```bash bun add @open-pencil/core @open-pencil/scene-graph @open-pencil/vue canvaskit-wasm ``` The SDK lives in the monorepo and is published as `@open-pencil/vue`. The current development version requires Vue `^3.5.41` and, when using its optional CanvasKit peer, `canvaskit-wasm >=0.41.1`. Check the installed package's peer requirements when using an older release. ```ts import { createEditor } from '@open-pencil/core/editor' import { provideEditor, useCanvas } from '@open-pencil/vue' ``` ## Mental model There are three layers: 1. `@open-pencil/core` — framework-agnostic editor engine 2. `@open-pencil/vue` — Vue composables and headless primitives 3. your app — styling, routing, file flows, product-specific UI ## Minimal setup ### 1. Create an editor ```ts twoslash // @module: esnext // @moduleResolution: bundler // ---cut--- import { reactive } from 'vue' import { createDefaultEditorState, createEditor } from '@open-pencil/core/editor' import { SceneGraph } from '@open-pencil/scene-graph' const graph = new SceneGraph() const page = graph.getPages()[0] if (!page) throw new Error('Expected an initial page') const editor = createEditor({ graph, state: reactive(createDefaultEditorState(page.id)), getViewportSize: () => ({ width: 1200, height: 800 }), }) ``` Core state is framework-neutral; passing reactive state lets Vue controls observe editor changes. For a resizable editor, have `getViewportSize` return the current canvas container dimensions. `width` and `height` are not `EditorOptions` properties. Icons come from Iconify by default. To serve them from elsewhere, such as a bundled or self-hosted set, pass an `IconProvider` from `@open-pencil/core/icons` as `icons`; the editor's commands, its icon picker, and its AI tools all use it. ### 2. Provide it to Vue ```vue ``` You can think of this as the provider layer for the editor tree. The docs prefer `provideEditor()` directly because that is the current real API surface. ### 3. Attach a canvas ```vue ``` ## Using composables Once the editor is provided, child components can read selection and issue commands: ```ts import { useEditorCommands, useSelectionState } from '@open-pencil/vue' const selection = useSelectionState() const commands = useEditorCommands() ``` ## Basic example ```vue ``` ## Migrating from v0.14.0 These changes describe the current development version; use them when upgrading beyond v0.14.0. * **Scene Graph overrides:** replace `SceneNode.overrides` records with `instanceOverrides`, whose `self` and `descendants` maps distinguish instance-level and descendant overrides. Use the public override helpers from `@open-pencil/scene-graph` rather than treating this as a simple field rename. * **Derived geometry:** rename `figmaDerivedLayout` to `derivedLayout`, `figmaDerivedTextGlyphs` to `derivedTextGlyphs`, and the exported `FigmaDerivedTextGlyph` type to `DerivedTextGlyph`. * **Binding providers:** implement `getBindingId()` and handle `unresolved`. For `edit-variable`, replace `setValue()` with `prepareEdit()`, which captures the edit key, value, setter, and restoration callback. See [BindableValue](./api/components/bindable-value). * **Translations:** replace `useDialogMessages()` and `dialogMessages` with the relevant product-domain composables and catalogs, such as `useSettingsMessages()` or `useRenameMessages()`. Catalog keys have also moved; do not just rename the import. See [useI18n](./api/composables/use-i18n). * **CanvasKit:** use `PathBuilder` for mutable construction and retain the returned paths from immutable `Path` operations instead of expecting in-place mutation. * **Custom tools:** use native Valibot `input` schemas and execution metadata instead of `params`, `ParamDef`, or `paramToZod()`. Programmatic MCP integrations use MCP SDK v2 server/client types; see [MCP](../mcp-server). ## Next steps * [Architecture](./architecture) * [API Reference](./api/) * [useEditor](./api/composables/use-editor) * [useCanvas](./api/composables/use-canvas) * [useI18n](./api/composables/use-i18n) --- --- url: 'https://openpencil.ink/programmable/sdk/architecture.md' description: >- Folder structure, public API boundaries, and composition patterns in @open-pencil/vue. --- # SDK Architecture `@open-pencil/vue` is the Vue-facing layer over `@open-pencil/core`. It does not own the editor model itself. It adapts the core editor into: * Vue injection * reactive composables * headless structural primitives * canvas and input wiring ## Folder structure This package is organized by domain. ### Component families * `Canvas/` * `ColorPicker/` * `FillPicker/` * `FontPicker/` * `GradientEditor/` * `LayerTree/` * `PageList/` * `PropertyList/` * `PropertySection/` * `SegmentedControl/` * `NumberField/` * `Toolbar/` These contain structural/headless primitives and local helpers. ### Controls `controls/` contains property-panel and editor control composables: * `usePosition` * `useLayout` * `useAppearance` * `useColorModel` * `useTypography` * `useExport` * `useFillControls` * `useStrokeControls` * `useEffectsControls` * `useNodeProps` * `usePropScrub` * `useEditorPropertyList` ### Variables `VariablesEditor/` contains variables-domain composables and state wiring. ### Selection `selection/` contains selection-derived editor state and capabilities. ### Context `context/` contains editor injection helpers: * `EDITOR_KEY` * `provideEditor` * `useEditor` ### Internal `internal/` contains cross-cutting utilities not intended as primary headless primitives. ## Public API philosophy ### Prefer composables If the problem is mostly control logic, state derivation, or editor actions, expose a composable. ### Keep headless primitives for meaningful structure Use component roots when they coordinate structure, children, slots, or context. Examples: * `PageListRoot` * `PropertyListRoot` * `PropertySectionRoot` * `SegmentedControlRoot` * `ToolbarRoot` ### Avoid broad context-dump slots Prefer focused slot props or direct composable usage over giant `v-slot="ctx"` payloads. Controlled primitives such as `PropertyListRoot` emit semantic events; editor selection and undo wiring belongs in an adapter or control composable, not the primitive. ## App vs SDK responsibility ### SDK owns * editor integration * reusable headless logic * reusable UI structure without styling assumptions * canvas rendering integration ### App owns * styling * layout shells * routing * product file flows * toasts, menus, and app-specific UX ## Practical rule of thumb If a piece of logic could be reused in a different OpenPencil-based app without bringing app styling with it, it probably belongs in `@open-pencil/vue`. ## Related pages * [SDK Getting Started](./getting-started) * [API Reference](./api/) --- --- url: 'https://openpencil.ink/programmable/sdk/guides/custom-editor-shell.md' description: >- Build your own editor shell with provideEditor, CanvasRoot, menus, panels, and toolbars. --- # Custom Editor Shell A typical OpenPencil Vue app has three layers: 1. `@open-pencil/core` creates the editor 2. `@open-pencil/vue` adapts it into Vue composables and headless primitives 3. your app renders the shell, styling, and product UX ## Why this matters The built-in OpenPencil app is only one possible shell. You can build a very different one for a focused workflow: an embedded editor inside another product, an internal asset tool, a template editor, an annotation UI, or an AI-assisted editing surface with custom controls. That is the main reason the SDK exists. ## Recommended composition A practical shell often looks like this: * provider at the top with `provideEditor()` * canvas in the center * page/layer navigation on one side * properties on the other side * menus and toolbars driven by composables ## Example ```vue ``` The fixed viewport dimensions above keep the example short. In a resizable shell, return the actual canvas container size from `getViewportSize`; do not include the surrounding sidebars or toolbar. See [SDK Getting Started](../getting-started) for installation requirements and migration notes. ## Why this split works * the SDK owns editor integration and reusable headless logic * your app owns layout, styling, and product-specific actions * composables can power menus and panels without extra wrapper components ## Related APIs * [provideEditor](../api/composables/provide-editor) * [useCanvas](../api/composables/use-canvas) * [ToolbarRoot](../api/components/toolbar-root) * [PageListRoot](../api/components/page-list-root) * [LayerTreeRoot](../api/components/layer-tree-root) --- --- url: 'https://openpencil.ink/programmable/sdk/guides/property-panels.md' description: Build property panels with control composables and headless list primitives. --- # Property Panels Property panels in `@open-pencil/vue` are intentionally composable-first. If a panel mostly needs selection-derived values and update actions, prefer composables. If a panel needs reusable array/list structure, use a headless primitive like `PropertyListRoot`. ## Common control composables For standard property sections, start with: * `usePosition()` * `useLayout()` * `useAppearance()` * `useTypography()` * `useExport()` For list-style panels, use: * `useFillControls()` * `useStrokeControls()` * `useEffectsControls()` ## Binding-aware fields Compose `BindableValueRoot` around fields that can reference variables or external design tokens. The primitive is presentation-agnostic, but binding-aware interfaces should keep focus non-destructive: * Show variable identity while the field is idle; expose the resolved value in supporting UI such as a tooltip. * Focusing or opening the picker must not detach a binding. * Apply `detach-on-edit`, `readonly-when-bound`, or `edit-variable` only when the user actually changes the value. * Put explicit detach actions in the picker rather than on a destructive one-click field icon. * Keep binding replacement, detach-on-edit, and multi-target changes in one provider batch. OpenPencil's app skin uses a violet variable-name pill at rest and reveals the resolved numeric value when NumberField enters editing mode. Custom editor shells can present the same headless state differently. ## Example: position panel ```vue ``` ## Example: fills panel ```vue ``` ## Rule of thumb * use composables for direct control logic * use structural primitives when repeated list/tree/slot coordination is the hard part ## Related APIs * [usePosition](../api/composables/use-position) * [useLayout](../api/composables/use-layout) * [useAppearance](../api/composables/use-appearance) * [useTypography](../api/composables/use-typography) * [useFillControls](../api/composables/use-fill-controls) * [useStrokeControls](../api/composables/use-stroke-controls) * [useEffectsControls](../api/composables/use-effects-controls) * [PropertyListRoot](../api/components/property-list-root) --- --- url: 'https://openpencil.ink/programmable/sdk/guides/navigation-panels.md' description: >- Build page and layer sidebars with PageListRoot, LayerTreeRoot, and selection state. --- # Navigation Panels OpenPencil sidebars usually combine two concerns: * page navigation * layer navigation The Vue SDK provides headless primitives for both. ## Page navigation Use `PageListRoot` or `usePageList()`. ```vue
``` ## Layer navigation Use `LayerTreeRoot` when you want SDK-managed tree structure but app-owned presentation. ```vue ``` ## Practical pattern A common layout is: * pages at the top of the sidebar * layers below * details or inline rename controls embedded in your row components ## Related APIs * [usePageList](../api/composables/use-page-list) * [PageListRoot](../api/components/page-list-root) * [LayerTreeRoot](../api/components/layer-tree-root) * [useSelectionState](../api/composables/use-selection-state) --- --- url: 'https://openpencil.ink/programmable/sdk/api.md' description: >- Reference documentation for @open-pencil/vue components, composables, and advanced APIs. --- # API Reference The Vue SDK reference is organized into three sections. ## Suggested path * Start with **Components** if you are building reusable editor UI primitives. * Start with **Composables** if you are wiring editor state and actions. * Use **Advanced** only when you need lower-level helpers or primitive contexts. --- --- url: 'https://openpencil.ink/programmable/sdk/api/components.md' description: Component reference for headless Vue primitives in @open-pencil/vue. --- # Components `@open-pencil/vue` exposes headless structural primitives for canvas wiring, navigation UI, property panels, and focused input controls. ## Core editor primitives ## Property panel primitives ## Pickers and inputs --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/canvas-root.md' description: Headless canvas primitive for OpenPencil rendering surfaces. --- # CanvasRoot `CanvasRoot` is the structural canvas primitive in `@open-pencil/vue`. Use it when you want SDK-provided canvas structure and context with app-owned layout and styling. ## Related APIs * [useCanvas](../composables/use-canvas) * [useCanvasInput](../composables/use-canvas-input) * [useTextEdit](../composables/use-text-edit) --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/canvas-surface.md' description: Canvas element primitive that binds to the nearest CanvasRoot context. --- # CanvasSurface `CanvasSurface` renders the actual `` element used by the SDK canvas stack. Use it inside `CanvasRoot` when you want SDK-managed canvas refs and rendering integration, but app-owned layout and styling. ## Related APIs * [CanvasRoot](./canvas-root) * [useCanvasContext](../advanced/use-canvas-context) * [useCanvas](../composables/use-canvas) --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/layer-tree-root.md' description: Headless structural primitive for layer tree interfaces. --- # LayerTreeRoot `LayerTreeRoot` is the SDK primitive for rendering a layer tree with app-owned markup and styling. Use it when you want reusable tree structure and interaction wiring without built-in presentation. ## Related APIs * [useSelectionState](../composables/use-selection-state) --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/layer-tree-item.md' description: Headless row primitive for a single layer tree node. --- # LayerTreeItem `LayerTreeItem` renders one layer-tree row and exposes selection, expand, visibility, lock, and rename handlers through its default slot. Use it when you want app-owned row markup with SDK-provided layer-tree behavior. ## Related APIs * [LayerTreeRoot](./layer-tree-root) * [useLayerTree](../advanced/use-layer-tree) * [useLayerDrag](../advanced/use-layer-drag) --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/toolbar-root.md' description: Headless structural primitive for editor toolbar UIs. --- # ToolbarRoot `ToolbarRoot` is the headless toolbar primitive from `@open-pencil/vue`. Use it when you want reusable toolbar structure and context with your own buttons, styling, and layout. ## Related APIs * [useEditorCommands](../composables/use-editor-commands) * [useSelectionCapabilities](../composables/use-selection-capabilities) --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/toolbar-item.md' description: Headless toolbar item primitive for a single editor tool. --- # ToolbarItem `ToolbarItem` exposes active-state and selection behavior for one toolbar tool. Use it inside `ToolbarRoot` when you want custom button markup but shared tool-selection wiring. ## Related APIs * [ToolbarRoot](./toolbar-root) * [useToolbar](../advanced/use-toolbar) * [useToolbarState](../advanced/use-toolbar-state) --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/page-list-root.md' description: Headless structural primitive for page list UIs. --- # PageListRoot `PageListRoot` is a headless structural primitive for page list interfaces. It provides slot props for: * pages * current page id * divider detection * page actions like add, switch, rename, and delete ## Usage Use it when you want SDK-provided page-list structure with app-specific rendering and styling. ## Basic example ```vue
``` ## Related APIs * [usePageList](../composables/use-page-list) --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/property-section.md' description: Headless collapsible anatomy for property-panel sections. --- # PropertySection PropertySection supplies collapsible section anatomy and canonical open, empty, and disabled state attributes without imposing presentation. ## Anatomy * `PropertySectionRoot` — controlled or uncontrolled Collapsible state * `PropertySectionHeader` — structural header container * `PropertySectionTitle` — accessible Collapsible trigger * `PropertySectionActions` — sibling action area, avoiding nested buttons * `PropertySectionContent` — collapsible content region * `PropertySectionEmptyAction` — empty-only action that opens before emitting `activate` ```vue twoslash ``` ## Generated API reference --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/property-grid.md' description: Headless field-grid and action-rail anatomy for property panels. --- # PropertyGrid `PropertyGridRoot` separates responsive property fields from optional intrinsic-width actions. It exposes structural data attributes without imposing column widths, gaps, or presentation. ```vue twoslash ``` Themes can target `data-slot="fields"`, `data-slot="actions"`, `data-columns`, and `data-distribution`. The `wide-first` distribution is semantic; consumers choose its exact ratio. ## Generated API reference --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/segmented-control.md' description: Accessible selection and action-only segmented controls. --- # SegmentedControl SegmentedControl delegates selection, roving focus, and keyboard behavior to Reka UI. Single and multiple modes retain selection; action mode emits commands without toggle state. ## Selection mode ```vue twoslash ``` ## Action mode Use `mode="action"` for grouped commands such as flip and rotate. Arrow keys move focus, while Space or Enter emits `action(value)` without leaving a selected segment. The complete state matrix is shown in the [PropertySection demo](./property-section). ## Generated API reference --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/property-list-root.md' description: 'Precisely typed headless list anatomy for fills, strokes, and effects.' --- # PropertyList PropertyList is a controlled, headless list primitive for fills, strokes, and effects. The `propKey` discriminator gives slots and actions exact `Fill`, `Stroke`, or `Effect` types. Editor mutation and undo behavior stay in `useEditorPropertyList()` or an application adapter. ## Anatomy * `PropertyListRoot` — controlled items, identity, mixed state, and semantic events * `PropertyListItem` — exact item type plus `data-hidden` and `data-dragging` * `PropertyListAdd` — adds a typed item * `PropertyListRemove` — removes an indexed item * `PropertyListVisibility` — toggles indexed visibility and exposes `aria-pressed` ```vue twoslash ``` See the [PropertySection demo](./property-section) for the shared interactive state matrix. ## Editor adapter OpenPencil panels use `useEditorPropertyList(propKey)` to connect controlled events to selection, multi-node updates, undo batching, and reordering. Third-party SDK consumers can provide their own state adapter without an OpenPencil editor context. ## Generated API reference --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/property-list-item.md' description: 'Headless item primitive for a single fills, strokes, or effects row.' --- # PropertyListItem `PropertyListItem` exposes update, patch, remove, and visibility handlers for one array item inside `PropertyListRoot`. Use it when building custom list-row UIs for fills, strokes, or effects. ## Related APIs * [PropertyListRoot](./property-list-root) * [usePropertyList](../advanced/use-property-list) * [useNodeProps](../advanced/use-node-props) --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/color-picker-root.md' description: Headless popover-based color picker with interaction lifecycle events. --- # ColorPickerRoot `ColorPickerRoot` composes a color swatch trigger with a popover surface while leaving the editor UI to its slots. The trigger slot receives the current swatch style; the default slot receives the current scene-graph color. `openChange` reports the complete picker interaction boundary. `cancel` fires before an Escape close, allowing BindableValue consumers to roll back a variable detach and paint update together. Opening or focusing the picker does not emit a color update. ```vue twoslash ``` ## Generated API reference ## Related APIs * [ColorInputRoot](./color-input-root) * [useColorModel](../composables/use-color-model) * [BindableValue](./bindable-value) --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/color-input-root.md' description: Headless color input helper with hex parsing and update helpers. --- # ColorInputRoot `ColorInputRoot` is a headless helper for color input UIs. It derives a hex value from a color and exposes update helpers for hex and full-color changes. ## Props ## Events ## Slots ## Example ```vue ``` ## Related APIs * [ColorPickerRoot](./color-picker-root) --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/channel-slider.md' description: Accessible scalar channel slider for OkHCL controls. --- # ChannelSlider `ChannelSlider` provides the same Root/Track/Thumb composition and keyboard behavior as Reka's Slider while accepting an arbitrary scalar channel. OpenPencil uses it only for OkHCL channels; standard RGB, HSL, and HSB controls should use Reka `ColorSlider` directly. ## Anatomy * `ChannelSliderRoot` — controlled value, range, step, orientation, and accessible channel label * `ChannelSliderTrack` — polymorphic track element * `ChannelSliderThumb` — slider thumb with range ARIA and formatted value text Arrow keys step the value. Page Up and Page Down use larger steps, while Home and End move to the range boundaries. The primitive is temporary until [Reka issue #2798](https://github.com/unovue/reka-ui/issues/2798) provides first-class OkHCL support. ```vue twoslash ``` ## Generated API reference ## Related APIs * [useColorModel](../composables/use-color-model) * [FillRoot](./fill-root) --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/fill-root.md' description: Headless fill category state and conversion actions. --- # FillRoot `FillRoot` owns solid, gradient, and image category state without owning a popover or visual swatch. Compose it around the picker surface appropriate for your application. Its default slot exposes the current fill, category, transparency, swatch background, and grouped conversion actions. Category changes emit immutable `Fill` values and are no-ops when the requested category is already active. ```vue twoslash ``` ## Generated API reference ## Related APIs * [FillSwatch](./fill-swatch) * [ChannelSlider](./channel-slider) * [useColorModel](../composables/use-color-model) --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/fill-swatch.md' description: 'Binding-aware semantic preview for solid, gradient, and image fills.' --- # FillSwatch `FillSwatch` exposes a semantic preview for a fill without imposing application styling. Its slot provides the effective fill, preview background, category, and transparency state. The root also publishes `data-fill-type`, `data-fill-category`, and `data-transparent` for styling and tests. When nested in `BindableValueRoot`, a bound solid fill previews the provider's resolved color and forwards the binding state attributes. Reading or focusing a swatch never detaches the binding. Consumers can place a checkerboard below the slot's `background` value for transparent paints. ```vue twoslash ``` ## Generated API reference ## Related APIs * [FillRoot](./fill-root) * [BindableValue](./bindable-value) --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/font-picker-root.md' description: Headless searchable font picker built on Reka Combobox. --- # FontPickerRoot `FontPickerRoot` is a headless searchable font picker built on Reka UI Combobox primitives. ## Props ## Model ## Events ## Slots ## Example ```vue ``` ## Related APIs * [useTypography](../composables/use-typography) --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/gradient-editor-root.md' description: Headless root primitive for gradient stop editing. --- # GradientEditorRoot `GradientEditorRoot` is a headless root primitive for gradient editing. It owns: * active stop state * subtype switching * stop add/remove/update logic * active color editing * derived bar background ## Props ## Events ## Slots ### Default slot props ```ts { stops: GradientStop[] subtype: GradientSubtype subtypes: Array<{ value: GradientSubtype; label: string }> activeStopIndex: number activeColor: Color barBackground: string setSubtype: (type: GradientSubtype) => void selectStop: (index: number) => void addStop: () => void removeStop: (index: number) => void updateStopPosition: (index: number, position: number) => void updateStopColor: (index: number, hex: string) => void updateStopOpacity: (index: number, opacity: number) => void updateActiveColor: (color: Color) => void dragStop: (index: number, position: number) => void } ``` ## Example ```vue ``` ## Related APIs * [GradientEditorBar](./gradient-editor-bar) * [GradientEditorStop](./gradient-editor-stop) --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/gradient-editor-bar.md' description: Headless draggable bar primitive for gradient stops. --- # GradientEditorBar `GradientEditorBar` is the draggable bar primitive used inside gradient editors. ## Props ## Events ## Slots ### Default slot props ```ts { stops: GradientStop[] activeStopIndex: number barBackground: string barRef: (el: unknown) => void onStopPointerDown: (index: number, event: PointerEvent) => void onPointerMove: (event: PointerEvent) => void onPointerUp: () => void draggingIndex: number | null } ``` ## Example ```vue ``` ## Related APIs * [GradientEditorRoot](./gradient-editor-root) * [GradientEditorStop](./gradient-editor-stop) --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/gradient-editor-stop.md' description: Accessible primitive for a selected or draggable gradient stop. --- # GradientEditorStop `GradientEditorStop` renders a polymorphic gradient stop with documented selection and dragging state. Interactive stops use the slider role and expose their percentage position through ARIA. Use interactive stops on the gradient bar. Arrow keys nudge by `positionStep`; holding Shift uses a 10× step. Home and End move to either boundary, while Delete and Backspace emit `remove` when the stop is removable. Handled keys stop propagation so editor-level deletion and movement shortcuts do not run. Native Tab order cycles between stops. Set `interactive="false"` when reusing the primitive around a composite stop row. The row still exposes slot actions and `data-selected`/`data-dragging`, but does not enter the slider tab order. ```vue twoslash ``` ## Generated API reference ## Related APIs * [GradientEditorRoot](./gradient-editor-root) * [GradientEditorBar](./gradient-editor-bar) * [useColorModel](../composables/use-color-model) --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/number-field.md' description: >- Headless numeric field primitives with scrubbing, expressions, and keyboard stepping. --- # NumberField The NumberField family provides a headless numeric control with pointer scrubbing, mixed values, keyboard stepping, safe arithmetic expressions, units, trailing actions, and binding-aware state. ## Anatomy * `NumberFieldRoot` — renderless state and interaction owner * `NumberFieldLeading` — leading label or icon * `NumberFieldValue` — non-editing value * `NumberFieldInput` — editing input with spinbutton semantics * `NumberFieldUnit` — unit text * `NumberFieldTrailing` — trailing action area * `NumberFieldMenu` — field menu area ## Root slot The default slot receives `modelValue`, `displayValue`, `draftValue`, `placeholder`, all state booleans, `state`, `actions`, and `attrs`. Bind `attrs` to the focusable outer element. It contains the canonical spinbutton ARIA contract, keyboard/focus handlers, and `data-editing`, `data-scrubbing`, `data-mixed`, `data-disabled`, and `data-bound` attributes. ## Expressions and keyboard Committed input accepts absolute arithmetic such as `12*8+4`, relative operations such as `+10`, `-4`, `*2`, and `/3`, and percentages such as `50%` when `max` is finite. The parser only accepts numbers, parentheses, and `+ - * /`; it never evaluates JavaScript. Arrow keys step by `step`. Shift multiplies the step by 10 and Alt multiplies it by 0.1. Enter commits and Escape restores the interaction-start value. ## Example ```vue twoslash ``` ## Generated API reference The following tables are extracted from the Vue source and its JSDoc during the documentation build. --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/bindable-value.md' description: Provider-driven value binding primitives for custom editor controls. --- # BindableValue BindableValue composes variable or token binding with fields without coupling the field to a specific editor store. Applications supply a `BindingProvider`; NumberField consumes the context automatically when nested beneath `BindableValueRoot`. ## Anatomy * `BindableValueRoot` — binding state, policy, resolved value, picker state, and actions * `BindableValueTrigger` — polymorphic bind-picker trigger * `BindableValuePicker` — renderless Reka Combobox composition ## Policies * `detach-on-edit` unbinds targets on the first value mutation and keeps the complete interaction in one provider undo batch. * `readonly-when-bound` blocks field editing, scrubbing, and keyboard stepping. * `edit-variable` uses `provider.prepareEdit()` to capture a stable edit key, current value, setter, and restoration callback instead of changing the target value. Focusing a bound NumberField or opening its picker is non-destructive. The policy starts only when the user types a changed draft, steps the value, or crosses the pointer-scrub threshold. Committing an unchanged field creates no undo entry. Cancellation rolls back an open provider batch. Providers without undo support still receive binding changes, with binding snapshots restored where possible. ## Provider example ```ts twoslash import type { Variable } from '@open-pencil/scene-graph' import type { BindingProvider, BindingTarget } from '@open-pencil/vue' const variable: Variable = { id: 'spacing/md', name: 'Spacing / Medium', type: 'FLOAT', collectionId: 'spacing', valuesByMode: { default: 16 }, description: '', hiddenFromPublishing: false } const values = new Map([[variable.id, 16]]) const bindings = new Map() const getBindingId = (target: BindingTarget) => bindings.get(`${target.nodeId}:${target.path}`) const resolve: BindingProvider['resolve'] = id => values.get(id) const provider: BindingProvider = { listVariables: () => [variable], filterVariables: term => variable.name.toLowerCase().includes(term.toLowerCase()) ? [variable] : [], getBindingId, getBound: target => getBindingId(target) === variable.id ? variable : undefined, getState: targets => { const ids = new Set(targets.map(getBindingId)) if (ids.size === 0 || (ids.size === 1 && ids.has(undefined))) return 'unbound' if (ids.size > 1) return 'mixed' if (!ids.has(variable.id)) return 'unresolved' const resolved = targets.map(target => resolve(variable.id, target)) if (resolved.some(value => value === undefined)) return 'unresolved' return new Set(resolved).size > 1 ? 'mixed' : 'bound' }, resolve, bind: (target: BindingTarget, variableId) => { bindings.set(`${target.nodeId}:${target.path}`, variableId) }, unbind: (target: BindingTarget) => { bindings.delete(`${target.nodeId}:${target.path}`) } } ``` ## Generated API reference The following tables are extracted from the Vue source and JSDoc during the documentation build. --- --- url: 'https://openpencil.ink/programmable/sdk/api/components/layout-controls-root.md' description: Headless root primitive for auto-layout and sizing controls. --- # LayoutControlsRoot `LayoutControlsRoot` exposes the slot contract returned by `useLayout()` as a structural primitive. Use it when you want a reusable layout-controls shell with app-owned markup. Width and height fields can remain editable while their axis uses Hug or Fill. On the first actual numeric mutation, `updateAxisSize()` records the sizing transition and changes that axis to Fixed. Compose the field with `BindableValue` and an interaction-batch-capable provider when sizing mode, variable detachment, and the numeric value must commit or roll back as one undo step. Focus and picker opening do not change sizing mode. Use `setAxisSizing('width', mode)` or `setAxisSizing('height', mode)` for sizing menus. The older axis-specific setters are not part of the current contract. ## Generated API reference The following tables are extracted from the Vue source and JSDoc during the documentation build. ## Related APIs * [useLayout](../composables/use-layout) * [BindableValue](./bindable-value) * [Property Panels guide](../../guides/property-panels) --- --- url: >- https://openpencil.ink/programmable/sdk/api/components/constraints-control-root.md description: Headless constraint state and actions for frame children. --- # ConstraintsControlRoot `ConstraintsControlRoot` exposes horizontal and vertical resize constraints for the current eligible selection. It is active for children of frames, components, component sets, and instances. Normal auto-layout children and top-level page layers are excluded; absolutely positioned auto-layout children remain eligible. The default slot provides concrete or mixed axis values and undo-aware actions for setting modes or building an interactive pin control. Multi-selection changes are grouped into one undo entry. Constraint values match the Figma Plugin API: `MIN`, `CENTER`, `MAX`, `STRETCH`, and `SCALE`. ## Generated API reference The following tables are extracted from the Vue source and JSDoc during the documentation build. ## Related APIs * [PositionControlsRoot](./position-controls-root) * [LayoutControlsRoot](./layout-controls-root) * [Property Panels guide](../../guides/property-panels) --- --- url: >- https://openpencil.ink/programmable/sdk/api/components/appearance-controls-root.md description: >- Headless root primitive for opacity, visibility, blend mode, corner radius, and smoothing controls. --- # AppearanceControlsRoot `AppearanceControlsRoot` exposes the slot contract returned by `useAppearance()` as a structural primitive. Use it when you want reusable appearance controls with custom presentation. The root owns selection-derived presentation decisions, including `showIndependentCorners`. That state becomes active when the selected node explicitly uses independent corners or when an imported node contains unequal corner values with a stale uniform flag. Consumers should render from this state rather than maintaining a parallel local expansion ref. `cornerSmoothingPercent` exposes the normalized scene value as `0…100` or `MIXED`. Update it through the corner actions using normalized `0…1` values. Multi-node independent-corner toggles, smoothing edits, and per-corner commits are grouped into one undo entry while preserving each node's original value. ## Generated API reference The following tables are extracted from the Vue source and JSDoc during the documentation build. ## Related APIs * [useAppearance](../composables/use-appearance) * [Property Panels guide](../../guides/property-panels) --- --- url: >- https://openpencil.ink/programmable/sdk/api/components/position-controls-root.md description: 'Headless root primitive for position, size, alignment, and transform controls.' --- # PositionControlsRoot `PositionControlsRoot` exposes position, size, rotation, align, flip, and rotate handlers for the current selection. Use it when you want custom position controls without reimplementing editor wiring. ## Related APIs * [usePosition](../composables/use-position) * [Property Panels guide](../../guides/property-panels) --- --- url: >- https://openpencil.ink/programmable/sdk/api/components/typography-controls-root.md description: 'Headless root primitive for font, alignment, and formatting controls.' --- # TypographyControlsRoot `TypographyControlsRoot` exposes typography state and handlers from `useTypography()` as a structural primitive. Use it when you want custom typography controls with SDK-managed font and formatting behavior. ## Related APIs * [useTypography](../composables/use-typography) * [FontPickerRoot](./font-picker-root) * [Property Panels guide](../../guides/property-panels) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables.md' description: Core composable APIs in @open-pencil/vue. --- # Composables These are the main composables most `@open-pencil/vue` consumers will use. ## Context and canvas * [provideEditor](./provide-editor) * [useEditor](./use-editor) * [useCanvas](./use-canvas) * [useCanvasInput](./use-canvas-input) * [useTextEdit](./use-text-edit) ## Selection and commands * [useSelectionState](./use-selection-state) * [useSelectionCapabilities](./use-selection-capabilities) * [useEditorCommands](./use-editor-commands) * [useMenuModel](./use-menu-model) ## Property panels * [usePosition](./use-position) * [useLayout](./use-layout) * [useConstraints](./use-constraints) * [useComponentProperties](./use-component-properties) * [useAppearance](./use-appearance) * [useSharedStyleBinding](./use-shared-style-binding) * [useColorModel](./use-color-model) * [useMask](./use-mask) * [useVariableModes](./use-variable-modes) * [useTypography](./use-typography) * [useExport](./use-export) * [useFillControls](./use-fill-controls) * [useStrokeControls](./use-stroke-controls) * [useEffectsControls](./use-effects-controls) * [useSelectionLayout](./use-selection-layout) * [useSelectionColors](./use-selection-colors) ## Document workspaces * [useDocumentWorkspace](./use-document-workspace) ## Variables, navigation, and localization * [usePageList](./use-page-list) * [useI18n](./use-i18n) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/provide-editor.md' description: Provide an OpenPencil editor instance to a Vue subtree using injection. --- # provideEditor `provideEditor(editor)` makes an OpenPencil editor available to descendant composables and headless primitives through Vue injection. This is the foundation for `useEditor()`. ## Usage ```ts import { provideEditor } from '@open-pencil/vue' provideEditor(editor) ``` ## Basic example ```vue ``` ## Notes The current SDK uses `provideEditor()` and `useEditor()` directly. Some older examples and error messages still refer to an `OpenPencilProvider` component, but the injection model is the real API surface to prefer in docs and app code. ## Related APIs * [useEditor](./use-editor) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-editor.md' description: Access the current injected OpenPencil editor instance. --- # useEditor `useEditor()` returns the current injected OpenPencil editor. It is the main entry point for SDK composables and headless primitives that need editor access. ## Usage `useEditor()` must be called inside a subtree where `provideEditor(editor)` has already been called. ```ts import { useEditor } from '@open-pencil/vue' const editor = useEditor() ``` ## Basic example ```vue ``` ## Practical examples ### Read selected nodes ```ts const editor = useEditor() const selected = editor.getSelectedNodes() ``` ### Trigger commands ```ts const editor = useEditor() editor.zoomToFit() editor.undoAction() ``` ## Error behavior If called outside an editor provider tree, `useEditor()` throws with a helpful message. That is intentional — this API should fail loudly when the editor context is missing. ## Related APIs * [provideEditor](./provide-editor) * [useCanvas](./use-canvas) * [useSelectionState](./use-selection-state) * [useEditorCommands](./use-editor-commands) ## Type ```ts function useEditor(): Editor ``` --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-canvas.md' description: >- Attach CanvasKit-backed rendering to a canvas element for an OpenPencil editor. --- # useCanvas `useCanvas()` connects an editor to a real `` element. It handles: * CanvasKit initialization * surface creation * render scheduling * resize handling * optional ruler visibility * renderer readiness callback ## Usage ```ts import { ref } from 'vue' import { useCanvas, useEditor } from '@open-pencil/vue' const canvasRef = ref(null) const editor = useEditor() useCanvas(canvasRef, editor) ``` ## Basic example ```vue ``` ## Practical examples ### Disable rulers for an embedded preview ```ts useCanvas(canvasRef, editor, { showRulers: false, }) ``` ### Keep drawing buffer for screenshots ```ts useCanvas(canvasRef, editor, { preserveDrawingBuffer: true, }) ``` ## Notes * `useCanvas()` is renderer-facing and browser-only in practice * it is responsible for the live canvas pipeline, not app-level file flows * it should usually be paired with `useCanvasInput()` for interaction handling ## Related APIs * [useEditor](./use-editor) * [useCanvasInput](./use-canvas-input) * [useTextEdit](./use-text-edit) ## Type ```ts interface UseCanvasOptions { showRulers?: boolean preserveDrawingBuffer?: boolean onReady?: () => void } function useCanvas( canvasRef: Ref, editor: Editor, options?: UseCanvasOptions, ): void ``` --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-canvas-input.md' description: >- Wire canvas pointer input, dragging, selection, resize, rotation, and tool behavior. --- # useCanvasInput `useCanvasInput()` connects pointer and mouse interaction to the editor canvas. It handles interaction concerns like: * selection * dragging * resize * rotation * panning * pen/draw flows * text editing interaction * scope-aware hit testing ## Usage This composable is typically paired with `useCanvas()` and hit-test helpers from the renderer. ```ts useCanvasInput( canvasRef, editor, hitTestSectionTitle, hitTestComponentLabel, hitTestFrameTitle, ) ``` ## Basic example ```ts const canvas = useCanvas(canvasRef, editor) useCanvasInput( canvasRef, editor, canvas.hitTestSectionTitle, canvas.hitTestComponentLabel, canvas.hitTestFrameTitle, ) ``` ## Practical examples ### Track cursor movement in canvas space ```ts useCanvasInput( canvasRef, editor, hitTestSectionTitle, hitTestComponentLabel, hitTestFrameTitle, (cx, cy) => { console.log(cx, cy) }, ) ``` ## Notes This composable is lower-level than most panel logic. It is best suited for editor shells and canvas containers. ## Related APIs * [useCanvas](./use-canvas) * [useEditor](./use-editor) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-text-edit.md' description: >- Manage DOM text editing, composition, formatting, and syncing for canvas text nodes. --- # useTextEdit `useTextEdit()` bridges DOM text input and the editor’s canvas text editing model. It coordinates: * textarea-backed text input * IME composition * caret blinking * delete/backspace behavior * formatting commands like bold/italic/underline * syncing text changes back into the graph ## Usage ```ts useTextEdit(canvasRef, editor) ``` ## Basic example Use this in the canvas owner component together with `useCanvas()` and `useCanvasInput()`. ## Practical examples ### Support formatting shortcuts `useTextEdit()` already handles keyboard formatting actions like bold, italic, and underline while text editing is active. ### Keep canvas and text editor in sync It updates graph text and style runs as the user types or edits formatted ranges. ## Notes This is a canvas/editor integration composable, not a generic text-field composable. ## Related APIs * [useCanvas](./use-canvas) * [useCanvasInput](./use-canvas-input) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-selection-state.md' description: >- Reactive selection-derived editor state for current node, count, and selection type. --- # useSelectionState `useSelectionState()` exposes reactive selection-derived state from the current editor. Use it when you need to render UI based on: * whether anything is selected * how many nodes are selected * the primary selected node * whether the current selection is an instance, component, or group ## Usage ```ts import { useSelectionState } from '@open-pencil/vue' const selection = useSelectionState() ``` ## Basic example ```vue ``` ## What it returns Useful values include: * `selectedIds` * `hasSelection` * `selectedNode` * `selectedCount` * `selectedNodeType` * `isInstance` * `isComponent` * `isGroup` * `canCreateComponentSet` ## Practical examples ### Show instance-only actions ```ts const { isInstance } = useSelectionState() ``` ### Enable component-set creation UI ```ts const { canCreateComponentSet } = useSelectionState() ``` ## Related APIs * [useSelectionCapabilities](./use-selection-capabilities) * [useEditorCommands](./use-editor-commands) * [useEditor](./use-editor) --- --- url: >- https://openpencil.ink/programmable/sdk/api/composables/use-selection-capabilities.md description: Derive command-friendly booleans for selection-driven UI and actions. --- # useSelectionCapabilities `useSelectionCapabilities()` exposes reactive booleans for whether common editor actions are currently allowed. Use it when building: * menus * toolbars * keyboard shortcuts * action buttons * contextual panels ## Usage ```ts import { useSelectionCapabilities } from '@open-pencil/vue' const caps = useSelectionCapabilities() ``` ## Basic example ```vue ``` ## Practical examples ### Gate menu entries ```ts const { canMoveToPage, canGoToMainComponent } = useSelectionCapabilities() ``` ### Enable zoom commands only when useful ```ts const { canZoomToSelection } = useSelectionCapabilities() ``` ## Related APIs * [useSelectionState](./use-selection-state) * [useEditorCommands](./use-editor-commands) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-editor-commands.md' description: 'Build menus, actions, and command-driven UI on top of the editor.' --- # useEditorCommands `useEditorCommands()` exposes a command-oriented layer over editor actions. It is useful when building: * app menus * context menus * toolbars * keyboard-command adapters * page-move submenus ## Usage ```ts import { useEditorCommands } from '@open-pencil/vue' const { commands, menuItem, runCommand, moveSelectionToPage, otherPages } = useEditorCommands() ``` ## Basic example ```ts const { menuItem } = useEditorCommands() const editMenu = [ menuItem('edit.undo', '⌘Z'), menuItem('edit.redo', '⇧⌘Z'), { separator: true }, menuItem('selection.delete'), ] ``` ## Practical examples ### Run a command directly ```ts const { runCommand } = useEditorCommands() runCommand('selection.duplicate') ``` ### Build a “move to page” submenu ```ts const { otherPages, moveSelectionToPage } = useEditorCommands() const items = otherPages.value.map(page => ({ label: page.name, action: () => moveSelectionToPage(page.id), })) ``` ## Related APIs * [useMenuModel](./use-menu-model) * [useSelectionState](./use-selection-state) * [useEditor](./use-editor) ## Main types ```ts type EditorCommandId = | 'edit.undo' | 'edit.redo' | 'selection.selectAll' | 'selection.duplicate' | 'selection.delete' | 'selection.group' | 'selection.ungroup' | 'selection.createComponent' | 'selection.createComponentSet' | 'selection.createInstance' | 'selection.detachInstance' | 'selection.goToMainComponent' | 'selection.wrapInAutoLayout' | 'selection.bringToFront' | 'selection.sendToBack' | 'selection.toggleVisibility' | 'selection.toggleLock' | 'selection.moveToPage' | 'view.zoom100' | 'view.zoomFit' | 'view.zoomSelection' ``` --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-menu-model.md' description: Build app and canvas menu models from the current editor state. --- # useMenuModel `useMenuModel()` builds higher-level menu structures on top of editor commands and selection state. It is useful when you want ready-to-render menu groups instead of composing commands manually. ## Usage ```ts import { useMenuModel } from '@open-pencil/vue' const { appMenu, canvasMenu, selectionLabelMenu } = useMenuModel() ``` ## Basic example ```ts const { canvasMenu } = useMenuModel() ``` Render `canvasMenu.value` into your context menu component. ## Practical examples ### App-style top menu `appMenu` groups entries into: * Edit * View * Object * Arrange ### Context menu with page moves `canvasMenu` includes dynamic items like “Move to page” based on current selection and available pages. ### Selection labels `selectionLabelMenu` exposes context-sensitive labels like: * `Hide` / `Show` * `Lock` / `Unlock` ## Related APIs * [useEditorCommands](./use-editor-commands) * [useSelectionState](./use-selection-state) * [useSelectionCapabilities](./use-selection-capabilities) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-position.md' description: >- Read and update selected node position, size, rotation, alignment, and flipping. --- # usePosition `usePosition()` is a control composable for position-related UI. It exposes selected-node values like: * `x` * `y` * `width` * `height` * `rotation` `x`, `y`, and `rotation` read as Figma's properties panel shows them: the top-left of the turned layer's box on the canvas, measured from its frame or page, and its counterclockwise angle. `updateProp('x' | 'y' | 'rotation', value)` moves or turns each selected node so it shows that value, turning about its center. `panelProp(key)` gives the shared value across a multi-selection, or `MIXED`. and actions like: * align * flip * rotate * scrub/update numeric properties ## Usage ```ts import { usePosition } from '@open-pencil/vue' const position = usePosition() ``` ## Basic example ```ts const { x, y, width, height, rotation, updateProp, commitProp } = usePosition() ``` ## Practical examples ### Align selected nodes ```ts position.align('horizontal', 'center') position.align('vertical', 'min') ``` ### Flip selection ```ts position.flip('horizontal') position.flip('vertical') ``` ### Rotate selection ```ts position.rotate(90) ``` ## Related APIs * [useLayout](./use-layout) * [useAppearance](./use-appearance) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-layout.md' description: 'Work with auto-layout, sizing, padding, alignment, and grid tracks.' --- # useLayout `useLayout()` is the main control composable for layout-related panels. It exposes state and actions for: * flex vs grid mode * width/height sizing and editable derived dimensions * minimum and maximum size limits * padding * alignment * grid template track editing ## Usage ```ts import { useLayout } from '@open-pencil/vue' const layout = useLayout() ``` ## Axis sizing ```ts const { widthSizing, heightSizing, setAxisSizing, updateAxisSize, commitAxisSize, } = useLayout() setAxisSizing('width', 'HUG') setAxisSizing('height', 'FILL') ``` Connect `updateAxisSize()` and `commitAxisSize()` to a numeric field. Editing a Hug or Fill value switches only that axis to Fixed on the first actual mutation: ```vue ``` For one-step commit and Escape rollback across a sizing-mode change, variable detachment, and the numeric value, compose the NumberField with `BindableValue` using a provider that implements interaction batches. Merely focusing the field does not switch its sizing mode. ## Aspect ratio lock ```ts const { aspectRatioLocked, aspectRatioLockable, toggleAspectRatioLock } = useLayout() ``` `toggleAspectRatioLock()` locks the selected layer to its current proportions or unlocks it. While it is locked, `updateAxisSize()` changes both sides. `aspectRatioLockable` is false for a layer inside an instance, which keeps its component's lock. ## Size limits ```ts layout.addSizeLimit('minWidth') layout.setSizeLimitToCurrent('minWidth') layout.removeSizeLimit('minWidth') ``` ## Practical examples ### Toggle between uniform and individual padding UI ```ts layout.toggleIndividualPadding() ``` ### Update grid tracks ```ts layout.updateGridTrack('gridTemplateColumns', 0, { sizing: 'FIXED', value: 240 }) layout.addTrack('gridTemplateRows') ``` ### Change alignment ```ts layout.setAlignment('CENTER', 'MAX') ``` ## Related APIs * [LayoutControlsRoot](../components/layout-controls-root) * [BindableValue](../components/bindable-value) * [usePosition](./use-position) * [useEditor](./use-editor) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-constraints.md' description: Read and update resize constraints for eligible frame children. --- # useConstraints `useConstraints()` provides selection-derived horizontal and vertical constraint state plus undo-aware actions. ```ts import { useConstraints } from '@open-pencil/vue' const constraints = useConstraints() constraints.setAxis('horizontal', 'STRETCH') constraints.togglePin('vertical', 'trailing', false) ``` * `active` requires every selected node to be an eligible child of a frame-like container. * `horizontal` and `vertical` return `MIN`, `CENTER`, `MAX`, `STRETCH`, `SCALE`, or `MIXED`. * Multi-selection updates are grouped into one undo entry. * Normal auto-layout children are excluded; absolutely positioned children remain eligible. Use `constraintPins()` and `toggleConstraintPin()` to build a custom accessible pin diagram without duplicating mode mapping. ## Related APIs * [ConstraintsControlRoot](../components/constraints-control-root) * [PositionControlsRoot](../components/position-controls-root) * [Property Panels guide](../../guides/property-panels) --- --- url: >- https://openpencil.ink/programmable/sdk/api/composables/use-component-properties.md description: >- Read and edit variant, text, boolean, and instance-swap properties on instances. --- # useComponentProperties `useComponentProperties()` exposes compatible component properties for the selected instances and an undo-aware value action. ```ts import { useComponentProperties } from '@open-pencil/vue' const { active, controls, setValue } = useComponentProperties() // Property IDs are stable Figma definition IDs when imported from .fig. setValue('12:34', 'Enabled') ``` Each item in `controls` contains: * `id` and `name` from the component property definition; * `type`: `VARIANT`, `TEXT`, `BOOLEAN`, or `INSTANCE_SWAP`; * `value`: a string or `MIXED`; * `options` for variant and instance-swap controls. `active` is true only when every selected node is an instance and each instance exposes the same ordered property IDs and types. Compatible multi-selection changes are grouped into one undo entry. For live text inputs, call `setTextValue(propertyId, value)` on each model update and `flush()` on blur or Enter. Text appears immediately, while rapid changes share an undo entry. Batches also finish after an idle pause, a selection/page change, or scope disposal. `setValue()` remains a discrete edit. Text and boolean properties update the referenced instance descendant. Instance-swap properties replace the referenced nested instance. Variant changes swap the main component and then reapply non-variant assignments, so custom labels, visibility, and nested swaps survive the change and its undo/redo cycle. Imported `.fig` definitions retain typed defaults, property references, assignments, and preferred instance-swap values. Missing swap targets remain explicit rather than silently selecting another component. ## Related APIs * [useSelectionState](./use-selection-state) * [useSharedStyleBinding](./use-shared-style-binding) * [Property Panels guide](../../guides/property-panels) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-appearance.md' description: >- Control visibility, opacity, corner radius, and smoothing for the current selection. --- # useAppearance `useAppearance()` is the appearance-focused control composable for property panels. It exposes selection-derived UI state for: * visibility * opacity * corner radius * corner smoothing as a normalized percentage * independent corner radii, including imported unequal-corner state * blend mode ## Usage ```ts import { useAppearance } from '@open-pencil/vue' const appearance = useAppearance() ``` ## Basic example ```ts const { visibilityState, opacityPercent, cornerRadiusValue, cornerSmoothingPercent, showIndependentCorners, toggleVisibility, toggleIndependentCorners, } = useAppearance() ``` ## Practical examples ### Toggle selection visibility ```ts appearance.toggleVisibility() ``` ### Edit per-corner radii ```ts appearance.updateCornerProp('topLeftRadius', 12) appearance.commitCornerProp('topLeftRadius', 12, 8) ``` ### Edit corner smoothing ```ts appearance.updateCornerProp('cornerSmoothing', 0.75) appearance.commitCornerProp('cornerSmoothing', 0.75, 0) ``` Render the per-corner editor from `showIndependentCorners`. It accounts for both the explicit scene-node flag and imported nodes whose corner values differ. Multi-selection toggles and commits are grouped into one undo entry. ## Related APIs * [SDK API Overview](../) * [useLayout](./use-layout) * [useTypography](./use-typography) --- --- url: >- https://openpencil.ink/programmable/sdk/api/composables/use-shared-style-binding.md description: 'Apply and detach local fill, stroke, text, effect, and grid styles.' --- # useSharedStyleBinding `useSharedStyleBinding(kind)` exposes the selected nodes' shared-style reference, compatible local style definitions, and undo-aware bind/detach actions. ```ts import { useSharedStyleBinding } from '@open-pencil/vue' const fillStyle = useSharedStyleBinding('fill') fillStyle.bind('1:120') fillStyle.unbind() ``` Supported kinds are `fill`, `stroke`, `text`, `effect`, and `grid`. * `active` requires every selected node to support the requested style domain. * `styleId` is the shared ID, `null`, or `MIXED`. * `styles` lists compatible local definitions imported with the document. * `bind(id)` applies supported style properties and the reference in one undo step. * `unbind()` keeps the resolved properties and removes only the reference. * Multi-selection changes are grouped into one undo entry. Manual edits to fills, strokes, supported text properties, effects, or layout grids automatically detach the matching style reference. Other style domains remain bound. OpenPencil currently consumes styles already present in a document. Creating, renaming, publishing, and synchronizing style libraries is outside this composable's scope. ## Related APIs * [useFillControls](./use-fill-controls) * [useStrokeControls](./use-stroke-controls) * [useTypography](./use-typography) * [useEffectsControls](./use-effects-controls) * [Property Panels guide](../../guides/property-panels) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-color-model.md' description: 'Build precise reactive color controls across RGB, HSL, HSB, and OkHCL.' --- # useColorModel `useColorModel()` is the shared color-state and conversion layer for custom color pickers. It accepts a scene-graph color, exposes Reka-compatible RGB/HSL/HSB values, and keeps optional OkHCL intent separate from the gamut-mapped preview color. The composable does not require an editor context and does not mutate the scene graph directly. Pass callbacks to connect it to your own state or persistence layer. ## Basic usage ```ts import { ref } from 'vue' import { useColorModel } from '@open-pencil/vue' import type { Color } from '@open-pencil/scene-graph' const color = ref({ r: 0.25, g: 0.5, b: 0.9, a: 1 }) const model = useColorModel({ color, onUpdate: (nextColor) => { color.value = nextColor }, }) model.updateHSLChannel('s', 72) model.updateAlpha(0.8) ``` The returned `hex`, `rekaColor`, `rgb`, `hsl`, and `hsb` values are computed refs. Use `updateHex()` for hex input; it preserves the current alpha. RGB conversion keeps fractional channel precision rather than rounding through 8-bit values. ## Format state The built-in format identifiers are `hex`, `rgb`, `hsl`, `hsb`, and `okhcl`. The type remains extensible so applications can add another presentation format without changing the model. ```ts const model = useColorModel({ color, defaultFormat: 'hsl', onFormatChange: (format) => savePreferredFormat(format), }) model.setFormat('okhcl') ``` Pass `format` for controlled state. Without it, `setFormat()` updates local reactive state. ## OkHCL intent A scene-graph color contains a renderable RGBA value, but it cannot retain an out-of-gamut OkHCL source by itself. Pass the source intent separately and persist patches through `onUpdateOkHCL`: ```ts const model = useColorModel({ color, okhcl: () => storedOkhcl.value, onUpdateOkHCL: (patch) => { storedOkhcl.value = { ...storedOkhcl.value, ...patch } }, }) model.updateOkHCLChannel('c', 0.24) ``` When no OkHCL callback is supplied, OkHCL edits emit a gamut-mapped scene color through `onUpdate`. Conversion and gamut handling reuse OpenPencil's culori-backed core color APIs. ## Slider presentation `sliderPreview`, `sliderGradient`, `okhclSliderPreview`, and `okhclSliderGradient` provide the colors and CSS gradients needed by slider skins. They contain presentation data only; keyboard, pointer, and ARIA behavior belongs to the slider primitive. ## Update behavior * RGB values use the `0–255` presentation range. * HSL and HSB saturation/lightness/brightness use `0–100`. * OkHCL lightness and alpha use `0–1`; chroma is non-negative; hue wraps into `0–360`. * Alpha is preserved across color-space conversions. * No-op updates do not call mutation callbacks. * Editing hue on an achromatic color creates a visible color rather than remaining neutral. ## Related APIs * [ColorPickerRoot](../components/color-picker-root) * [ColorInputRoot](../components/color-input-root) * [useFillControls](./use-fill-controls) * [useStrokeControls](./use-stroke-controls) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-typography.md' description: >- Read and update font, alignment, case, truncation, and OpenType features for text nodes. --- # useTypography `useTypography()` is the text-property control composable for text editing panels. It works on the text layers in the selection: `nodes` holds them, `node` is the first, and every action changes all of them in one undo step, so a selection of text and other layers edits only the text. It exposes: * font family * font weight, and `weights`: the weights the selected layers' families have, read from installed fonts and web font providers, every weight while a family's faces are unknown * font size * formatting state * missing-font status * horizontal and vertical alignment, including justification * text case and direction * ending truncation and maximum lines * OpenType feature toggles * list style (`listType`, `null` when the paragraphs differ) and hanging lists * helpers for changing family, weight, alignment, and decorations * `merged(key)` and `fontFeature(tag)`, which return the shared value or `MIXED` ## Usage ```ts import { useTypography } from '@open-pencil/vue' const typography = useTypography() ``` ## Basic example ```ts const { fontFamily, fontWeight, fontSize, activeFormatting, setFamily, setWeight, setAlign, setVerticalAlign, setTextCase, setTruncation, setFontFeature, } = useTypography() ``` ## Practical examples ### Load and switch a font family ```ts const typography = useTypography({ fontLoader: { load: async (family, style) => { await myFontLoader(family, style) }, }, }) ``` ### Toggle formatting As in Figma, a family switch keeps each layer's weight and slant where the new family has them, otherwise the nearest weight with the same slant, and `toggleBold` or `toggleItalic` leave a layer alone when its family has no such face. ```ts typography.toggleBold() typography.toggleItalic() typography.toggleDecoration('UNDERLINE') typography.setTextCase('UPPER') typography.setVerticalAlign('CENTER') typography.setTruncation('ENDING') typography.setFontFeature('LIGA', false) ``` ### Make a list While the text is being edited, `setListType` and `setParagraphSpacing` change the paragraphs under the caret or selection; otherwise they change the whole text. `paragraphSpacing(field)` reads `listSpacing`, `paragraphSpacing`, or `paragraphIndent` for those paragraphs, `MIXED` when they differ, and `previewParagraphSpacing` shows a value while a field is dragged without an undo step. ```ts typography.setListType('UNORDERED') typography.setHangingList(true) typography.setParagraphSpacing('listSpacing', 8) ``` ## Related APIs * [useTextEdit](./use-text-edit) * [useSelectionState](./use-selection-state) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-export.md' description: Manage export settings like scale and format for the current selection. --- # useExport `useExport()` is the export-panel composable for selected nodes. It manages: * export settings rows * selected node ids * export name labeling * supported scales and formats (`formats` ids and labeled `formatOptions`) ## Usage ```ts import { useExport } from '@open-pencil/vue' const exportState = useExport() ``` ## Basic example ```ts const { settings, nodeName, scales, formats, formatOptions, addSetting, updateScale, updateFormat, } = useExport() ``` ## Practical examples ### Add another export preset ```ts exportState.addSetting() ``` ### Change the first export to 2x WEBP ```ts exportState.updateScale(0, 2) exportState.updateFormat(0, 'webp') ``` ## Related APIs * [useSelectionState](./use-selection-state) * [useEditor](./use-editor) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-fill-controls.md' description: Fill-panel composable with default fill behavior. --- # useFillControls `useFillControls()` is the fill-property composable used by fill editing UIs. It adds a reusable default fill value. ## Usage ```ts import { useFillControls } from '@open-pencil/vue' const fills = useFillControls() ``` ## What it gives you It exposes: * `defaultFill` ## Practical examples ### Add a new fill row ```ts propertyList.add(fills.defaultFill) ``` ## Related APIs * [PropertyListRoot](../components/property-list-root) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-stroke-controls.md' description: >- Stroke-panel state and actions for alignment, sides, caps, joins, and miter limits. --- # useStrokeControls `useStrokeControls()` is the stroke-property composable used by stroke editing panels. It provides: * stroke align options * side presets like all, top, bottom, left, right, custom * default stroke data * helpers for per-side border weights * mixed-selection cap, join, and miter-limit state * undo-batched cap and join updates * preview and commit actions for miter-limit fields ## Usage ```ts import { useStrokeControls } from '@open-pencil/vue' const strokes = useStrokeControls() ``` ## Basic example ```ts const { alignOptions, sideOptions, currentAlign, currentSides, selectSide } = useStrokeControls() ``` ## Practical examples ### Set stroke alignment ```ts strokes.updateAlign('INSIDE') ``` ### Limit a stroke to one side ```ts strokes.selectSide('TOP') ``` ### Edit stroke geometry ```ts strokes.setCap('ROUND') strokes.setJoin('BEVEL') strokes.updateMiterLimit(8) strokes.commitMiterLimit(8) ``` `advancedActive` is true only when every selected node has at least one stroke. `cap`, `join`, and `miterLimit` return the shared value or `MIXED` for a mixed selection. Alignment, sides, and border weights act on every selected node in one undo step. `currentAlign()` and `borderWeight(side)` return the shared value or `MIXED`; a node without strokes counts with the alignment it keeps for new strokes. ## Related APIs * [PropertyListRoot](../components/property-list-root) --- --- url: >- https://openpencil.ink/programmable/sdk/api/composables/use-effects-controls.md description: >- Effects-panel helpers for shadows, blurs, expansion state, and scrub/commit flows. --- # useEffectsControls `useEffectsControls()` is the effects-property composable used by effects panels. It provides helpers for: * default effects * shadow vs blur logic * expanded item state * scrub-preview editing * commit-on-finish updates * effect type and color changes ## Usage ```ts import { useEffectsControls } from '@open-pencil/vue' const effects = useEffectsControls() ``` ## Basic example ```ts const { effectOptions, createDefaultEffect, toggleExpand, scrubEffect, commitEffect } = useEffectsControls() ``` ## Practical examples ### Add a default effect ```ts const effect = effects.createDefaultEffect() ``` ### Preview scrub changes, then commit ```ts effects.scrubEffect(node, index, { radius: 12 }) effects.commitEffect(node, index, { radius: 12 }) ``` ## Related APIs * [PropertyListRoot](../components/property-list-root) --- --- url: >- https://openpencil.ink/programmable/sdk/api/composables/use-selection-layout.md description: >- Layout of a multi-selection as Figma shows it, with text resizing, flow, clip content, and spacing. --- # useSelectionLayout `useSelectionLayout()` reads and edits the layout of the selected layers the way Figma's panel does for several layers. Each value is the one the relevant layers share, `MIXED` when they differ, or `undefined` when no selected layer has it: * `textResize` — resizing of the selected text layers * `layoutMode` — flow, when every selected layer can hold an auto layout * `clipsContent` — clip content of the layers that can clip * `spacing` and `spacingAxis` — the gap between neighbours when the layers form one row or column Each action changes only the layers it applies to, in one undo step. ## Usage ```ts import { useSelectionLayout } from '@open-pencil/vue' const layout = useSelectionLayout() layout.setTextResize('AUTO_HEIGHT') layout.setLayoutMode('VERTICAL') layout.toggleClipsContent() layout.setSpacing(16) ``` `setSpacing` keeps the first layer of the row in place and spaces the rest after it. ## Related APIs * [useLayout](./use-layout) * [useSelectionColors](./use-selection-colors) --- --- url: >- https://openpencil.ink/programmable/sdk/api/composables/use-selection-colors.md description: >- Figma's Selection colors list for the current selection, with an edit that recolours every paint using a colour. --- # useSelectionColors `useSelectionColors()` lists each solid fill and stroke colour of the selected layers and their descendants, most used first. Effect colours and colours bound to a variable are left out. `shown` is true when Figma shows the list: the Fill and Stroke sections can't already show every colour, because they differ across the selection or a selected layer has children. ## Usage ```ts import { useSelectionColors } from '@open-pencil/vue' const selection = useSelectionColors() // Recolour every paint that uses the first colour. selection.replace(0, { color: { r: 0, g: 0.5, b: 1, a: 1 }, opacity: 1 }) ``` Edits made in quick succession, such as dragging in a colour picker, become one undo step. Call `begin()` when a row's picker opens and `finish()` when it closes to keep the list's order steady while the colour changes. ## Related APIs * [useSelectionLayout](./use-selection-layout) * [useFillControls](./use-fill-controls) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-mask.md' description: Read and update mask state for the selected node. --- # useMask `useMask()` provides headless state and actions for mask controls in a property panel. ## Usage ```ts import { useMask } from '@open-pencil/vue' const { active, maskType, setMaskType } = useMask() ``` * `active` is `true` when exactly one selected node is being used as a mask. * `maskType` is the selected node's `ALPHA`, `VECTOR`, or `LUMINANCE` mask type. * `setMaskType(type)` updates the active mask with undo support. Use the `selection.toggleMask` editor command to turn masking on or off so toolbar, context-menu, and keyboard behavior stays consistent. ## Related APIs * [useAppearance](./use-appearance) * [useEditorCommands](./use-editor-commands) * [SDK API Overview](../) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-variable-modes.md' description: Read and set the variable modes the selection or the current page shows. --- # useVariableModes `useVariableModes()` provides headless state and actions for Figma's Apply variable mode control: the collections a mode can be set for on the selection, or on the current page when nothing is selected, and the mode each one shows. ## Usage ```ts import { useVariableModes } from '@open-pencil/vue' const { choices, applied, setMode } = useVariableModes() ``` * `choices` lists the collections with more than one mode that the selected layers use, through their own bindings or variables those alias; for a page, every such collection. Each has its `modes`, its `value` (the mode set on every target, `MIXED` when they differ, or `null` for Auto), and the `inherited` mode the targets show without one of their own. * `applied` is the part of `choices` with a mode set on at least one target, which a panel shows as rows. * `setMode(collectionId, modeId)` sets the mode on every target in one undo step; `null` clears it, so the targets inherit their parent's mode again. `variableModeChoices(editor, nodes)` and `setVariableMode(editor, nodes, collectionId, modeId)` do the same for any layers, outside a component. ## Related APIs * [useAppearance](./use-appearance) * [SDK API Overview](../) --- --- url: >- https://openpencil.ink/programmable/sdk/api/composables/use-document-workspace.md description: 'Manage document lists, refreshes, lazy previews, and workspace events.' --- # useDocumentWorkspace `useDocumentWorkspace()` provides headless state for document browsers backed by local or remote storage. It manages: * initial, manual, focused-window, reconnect, and interval refreshes * deduplicated refresh requests and source invalidation events * lazy preview loading with bounded concurrency * preview object URL creation and cleanup * per-preview errors ## Usage ```ts import { useDocumentWorkspace } from '@open-pencil/vue' const workspace = useDocumentWorkspace({ source: { refresh: () => documentService.list(), loadPreview: (id) => documentService.loadPreview(id), subscribe: (listener) => documentService.subscribe(listener), }, refreshInterval: 60_000, }) ``` The source must return items with `id`, `name`, and `updatedAt`. A changed `updatedAt` value invalidates an existing or in-flight preview. ## Lazy previews Apply `previewDirective` to the element that should trigger loading, then resolve the current object URL with `previewURL()`: ```vue ``` When `IntersectionObserver` is unavailable, the directive loads the preview immediately. ## Errors `error` contains the latest document-list refresh failure. Preview failures are available by document ID through `previewErrors`; use `onPreviewError` when errors should also be reported to an application service. ```ts const workspace = useDocumentWorkspace({ source, onPreviewError(id, error) { reportPreviewError({ id, error }) }, }) ``` Calling `loadPreview(id)` retries a failed preview. Successful loads clear the corresponding preview error. ## Cleanup The composable revokes generated object URLs and unsubscribes from the source when its component unmounts. Call `clearPreviews()` when switching an external workspace or provider without unmounting the component. --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-page-list.md' description: 'Read pages and drive page switching, creation, deletion, and renaming.' --- # usePageList `usePageList()` is the page-management composable behind page list UIs. It exposes: * `pages` * `currentPageId` * `switchPage` * `addPage` * `deletePage` * `renamePage` ## Usage ```ts import { usePageList } from '@open-pencil/vue' const pageList = usePageList() ``` ## Basic example ```ts const { pages, currentPageId, switchPage, addPage } = usePageList() ``` ## Practical examples ### Switch pages ```ts switchPage(pageId) ``` ### Create a new page ```ts addPage() ``` ## Related APIs * [PageListRoot](../components/page-list-root) * [useMenuModel](./use-menu-model) --- --- url: 'https://openpencil.ink/programmable/sdk/api/composables/use-i18n.md' description: Read localized OpenPencil UI messages and switch the active SDK locale. --- # useI18n `useI18n()` is a compatibility aggregate returning reactive translation groups and locale controls. New components should import only the product-domain composables they need, rather than subscribe to every catalog. ```ts twoslash import { useSettingsMessages, useRenameMessages } from '@open-pencil/vue' const settings = useSettingsMessages() const rename = useRenameMessages() ``` Each domain composable returns a reactive ref. Read `.value` in script; Vue unwraps it in templates. See [Locale APIs](../advanced/locale-apis) for locale switching without the aggregate. ## Usage ```ts import { useI18n } from '@open-pencil/vue' const { menu, commands, panels, locale, availableLocales, localeLabels, setLocale } = useI18n() ``` ## Returns Translation refs: `ai`, `automation`, `code`, `collaboration`, `commands`, `common`, `credentials`, `diagnostics`, `editor`, `files`, `fonts`, `media`, `menu`, `pages`, `panels`, `recovery`, `rendering`, `rename`, `settings`, `storage`, `tools`, `updates`, `variables`, and `variableTypes`. Locale controls: `locale`, `availableLocales`, `localeLabels`, and `setLocale`. The former `dialogs` group, `useDialogMessages()`, and `dialogMessages` are removed in the development version after v0.14.0. Use the owning domain, such as `settings`, `rename`, or `recovery`, and update the corresponding message keys. ## Basic example ```vue ``` ## Notes * locale changes are reactive across all SDK message groups * the SDK also exports lower-level locale primitives when you need direct store access ## Related APIs * [useMenuModel](./use-menu-model) * [SDK Locale APIs](../advanced/locale-apis) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced.md' description: Lower-level and specialized APIs in @open-pencil/vue. --- # Advanced These APIs are public, but they are more specialized than the main component and composable surface. ## Selection and scene helpers * [useNodeProps](./use-node-props) * [useSceneComputed](./use-scene-computed) * [usePropScrub](./use-prop-scrub) ## Picker, variables, locale, and editor internals * [useColorVariableBinding](./use-color-variable-binding) * [useColorBindingProvider](./use-color-binding-provider) * [useGradientStops](./use-gradient-stops) * [useFontPicker](./use-font-picker) * [useOkHCL](./use-okhcl) * [useVariables](./use-variables) * [Locale APIs](./locale-apis) * [useToolbarState](./use-toolbar-state) * [useNodeFontStatus](./use-node-font-status) ## Editor-shell utilities * [useLayerDrag](./use-layer-drag) * [useInlineRename](./use-inline-rename) * [useCanvasDrop](./use-canvas-drop) * [extractImageFilesFromClipboard](./extract-image-files-from-clipboard) * [useViewportKind](./use-viewport-kind) * [toolCursor](./tool-cursor) ## Primitive context helpers * [useCanvasContext](./use-canvas-context) * [useLayerTree](./use-layer-tree) * [useToolbar](./use-toolbar) * [usePropertyList](./use-property-list) * [useNumberField](./use-number-field) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-node-props.md' description: Low-level selection and mixed-value helper for property panels. --- # useNodeProps `useNodeProps()` is the low-level property-panel helper behind higher-level composables like `useAppearance`, `useLayout`, and `useTypography`. Use it when you need mixed-value detection, multi-selection updates, array-item editing, or undo-aware property commits. ## Related APIs * [useAppearance](../composables/use-appearance) * [useLayout](../composables/use-layout) * [useTypography](../composables/use-typography) * [PropertyListRoot](../components/property-list-root) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-scene-computed.md' description: Convenience wrapper for scene-derived computed state. --- # useSceneComputed `useSceneComputed(fn)` is a thin computed wrapper used to make scene-backed derived state explicit in higher-level composables. Use it when you want intent-revealing computed state that clearly depends on editor scene data. ## Related APIs * [useSelectionState](../composables/use-selection-state) * [useSelectionCapabilities](../composables/use-selection-capabilities) * [useNodeProps](./use-node-props) --- --- url: >- https://openpencil.ink/programmable/sdk/api/advanced/use-color-variable-binding.md description: Variable-binding helper for fill and stroke color editors. --- # useColorVariableBinding `useColorVariableBinding(kind)` exposes search, binding, and unbinding helpers for color variables used by fill and stroke editors. Use it when building color UIs that need to connect fills or strokes to design variables. ## Usage ```ts import { useColorVariableBinding } from '@open-pencil/vue' const fillBinding = useColorVariableBinding('fills') const strokeBinding = useColorVariableBinding('strokes') ``` ## Related APIs * [useFillControls](../composables/use-fill-controls) * [useStrokeControls](../composables/use-stroke-controls) * [FillRoot](../components/fill-root) --- --- url: >- https://openpencil.ink/programmable/sdk/api/advanced/use-color-binding-provider.md description: OpenPencil COLOR variable provider for BindableValue fields. --- # useColorBindingProvider `useColorBindingProvider()` adapts OpenPencil COLOR variables to the generic `BindingProvider` contract. It resolves current-mode colors, binds indexed paint paths, creates variables in a Colors collection, updates all collection modes when requested, and exposes editor undo transactions. Use it with `BindableValueRoot` and explicit targets such as `fills/0/color` or `strokes/0/color`. Picker focus and opening remain non-destructive; the consumer chooses when an actual color mutation begins and commits. ```ts twoslash import type { Color } from '@open-pencil/scene-graph' import type { BindingTarget } from '@open-pencil/vue' import { useColorBindingProvider } from '@open-pencil/vue' const provider = useColorBindingProvider() const targets: BindingTarget[] = [ { nodeId: 'rectangle-id', path: 'fills/0/color' } ] const value: Color = { r: 0.2, g: 0.5, b: 0.9, a: 1 } provider.getState(targets) provider.resolve('variable-id') ``` The composable requires an editor provided by `provideEditor()`. ## Related APIs * [BindableValue](../components/bindable-value) * [FillSwatch](../components/fill-swatch) * [useColorModel](../composables/use-color-model) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-gradient-stops.md' description: Gradient-stop state and mutation helper for fill editors. --- # useGradientStops `useGradientStops(fill, onUpdate)` manages active-stop state, subtype switching, stop dragging, and color or opacity updates for gradient fills. Use it when building custom gradient editors beyond the packaged primitives. ## Related APIs * [GradientEditorRoot](../components/gradient-editor-root) * [GradientEditorBar](../components/gradient-editor-bar) * [GradientEditorStop](../components/gradient-editor-stop) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-font-picker.md' description: Searchable font-picker state and selection helper. --- # useFontPicker `useFontPicker(options)` manages available font families, search state, open state, and family selection for font-picker UIs. Use it when building custom font-picking UIs beyond `FontPickerRoot`. ## Related APIs * [FontPickerRoot](../components/font-picker-root) * [useTypography](../composables/use-typography) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-prop-scrub.md' description: Low-level helper for drag-to-scrub property updates with commit support. --- # usePropScrub `usePropScrub(editor)` coordinates live property updates during scrubbing and commits undo-aware changes when the interaction finishes. Use it when building numeric controls that scrub selected node properties directly. ## Related APIs * [NumberField](../components/number-field) * [useNodeProps](./use-node-props) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-layer-drag.md' description: Drag-and-drop wiring helper for layer tree reordering. --- # useLayerDrag `useLayerDrag(editor, indentPerLevel?)` wires pragmatic-drag-and-drop behavior for layer tree rows and maps drop instructions to editor reorder operations. Use it when extending or replacing the default layer-tree drag UI. ## Related APIs * [LayerTreeRoot](../components/layer-tree-root) * [LayerTreeItem](../components/layer-tree-item) * [useLayerTree](./use-layer-tree) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-inline-rename.md' description: Inline rename state and focus-management helper. --- # useInlineRename `useInlineRename(onCommit)` manages editing state, focus, outside-click handling, and keyboard behavior for inline rename flows. Use it in custom page lists, layer trees, or similar rename-in-place UIs. ## Related APIs * [PageListRoot](../components/page-list-root) * [LayerTreeItem](../components/layer-tree-item) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-toolbar-state.md' description: Presentation-oriented toolbar state helper for mobile category paging. --- # useToolbarState `useToolbarState()` returns mobile-category paging state and helpers like `goPrev()` and `goNext()` for responsive toolbar shells. Use it when building toolbar layouts on top of `ToolbarRoot`. ## Related APIs * [ToolbarRoot](../components/toolbar-root) * [ToolbarItem](../components/toolbar-item) * [useToolbar](./use-toolbar) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-node-font-status.md' description: Missing-font status helper for text nodes. --- # useNodeFontStatus `useNodeFontStatus(node)` returns missing-font information for a text-node getter. Use it in typography panels and warnings that need to surface unavailable font families. ## Related APIs * [useTypography](../composables/use-typography) * [TypographyControlsRoot](../components/typography-controls-root) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-canvas-drop.md' description: Image file drag-and-drop helper for canvas surfaces. --- # useCanvasDrop `useCanvasDrop(canvasRef, editor)` wires dragenter, dragover, dragleave, and drop handling so image files dropped on the canvas are placed into the scene. Use it when building custom canvas surfaces that accept image drops. ## Related APIs * [CanvasRoot](../components/canvas-root) * [CanvasSurface](../components/canvas-surface) * [extractImageFilesFromClipboard](./extract-image-files-from-clipboard) --- --- url: >- https://openpencil.ink/programmable/sdk/api/advanced/extract-image-files-from-clipboard.md description: Clipboard utility for extracting accepted image files. --- # extractImageFilesFromClipboard `extractImageFilesFromClipboard(event)` filters clipboard files down to accepted image types used by the canvas drop flow. Use it when implementing paste-from-clipboard image workflows. ## Related APIs * [useCanvasDrop](./use-canvas-drop) * [useCanvas](../composables/use-canvas) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/tool-cursor.md' description: Helper that resolves the cursor string for an editor tool. --- # toolCursor `toolCursor(tool, override?)` maps an editor tool to the cursor the SDK should use, while still allowing an explicit override. Use it when building custom canvas shells or tool UIs that need consistent cursor behavior. ## Related APIs * [useCanvas](../composables/use-canvas) * [useEditorCommands](../composables/use-editor-commands) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-canvas-context.md' description: Primitive context helper for CanvasRoot descendants. --- # useCanvasContext `useCanvasContext()` reads the local canvas context provided by `CanvasRoot`. Use it inside descendants like `CanvasSurface` or your own custom canvas children. ## Related APIs * [CanvasRoot](../components/canvas-root) * [CanvasSurface](../components/canvas-surface) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-layer-tree.md' description: Primitive context helper for LayerTreeRoot descendants. --- # useLayerTree `useLayerTree()` reads the local layer-tree context provided by `LayerTreeRoot`. Use it inside custom layer-tree descendants that need access to tree items, selection state, expansion state, or row actions. ## Related APIs * [LayerTreeRoot](../components/layer-tree-root) * [LayerTreeItem](../components/layer-tree-item) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-toolbar.md' description: Primitive context helper for ToolbarRoot descendants. --- # useToolbar `useToolbar()` reads the local toolbar context provided by `ToolbarRoot`. Use it inside toolbar descendants that need access to tools, active state, or tool selection helpers. ## Related APIs * [ToolbarRoot](../components/toolbar-root) * [ToolbarItem](../components/toolbar-item) * [useToolbarState](./use-toolbar-state) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-property-list.md' description: Primitive context helper for PropertyListRoot descendants. --- # usePropertyList `usePropertyList()` reads the local property-list context provided by `PropertyListRoot`. Use it inside descendants that need controlled items, mixed-state information, or row-level handlers for fills, strokes, or effects. It does not access an editor. OpenPencil editor panels can use `useEditorPropertyList(propKey)` in an adapter component to connect the controlled primitive to selection, undo batching, and multi-node mutations. ## Related APIs * [PropertyListRoot](../components/property-list-root) * [PropertyListItem](../components/property-list-item) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-number-field.md' description: Context helper for custom NumberField descendants. --- # useNumberField `useNumberField()` reads the context provided by `NumberFieldRoot`. Use it inside custom field parts that need the canonical value, draft, state attributes, ARIA attributes, or actions. It throws when called outside a `NumberFieldRoot` subtree. ```ts import { useNumberField } from '@open-pencil/vue' const field = useNumberField() field.actions.startEdit() ``` See [NumberField](../components/number-field) for the complete context and anatomy. --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-okhcl.md' description: Persist OkHCL intent for selected node fills and strokes. --- # useOkHCL `useOkHCL()` is the editor-aware adapter for OkHCL fill and stroke metadata. It reads stored color intent, updates nodes with undo, reports preview gamut information, and remembers the selected field format for each fill or stroke. Use [`useColorModel()`](../composables/use-color-model) for framework-agnostic conversion, channel editing, and slider presentation. Use `useOkHCL()` only where those edits need to be persisted to an OpenPencil editor. ## Usage ```ts import { useOkHCL } from '@open-pencil/vue' const okhcl = useOkHCL() const color = okhcl.getFillOkHCLColor(node, 0) okhcl.updateFillOkHCL(node, 0, { c: 0.2 }) ``` ## Format state ```ts const format = okhcl.getFieldFormat(node, 0, 'fill') okhcl.setFillFieldFormat(node, 0, 'okhcl') ``` Selecting `okhcl` initializes intent from the fill or stroke's current RGBA color. The returned `fieldOptions` can be used to build a format selector. ## Preview information ```ts const preview = okhcl.getFillPreviewInfo(node, 0) // { previewColorSpace, clipped } ``` Preview information reflects the document render color space and whether the stored OkHCL intent needed gamut mapping. ## Returns * `getFillOkHCLColor()` / `getStrokeOkHCLColor()` * `getFillPreviewInfo()` / `getStrokePreviewInfo()` * `getFieldFormat()` * `setFillFieldFormat()` / `setStrokeFieldFormat()` * `updateFillOkHCL()` / `updateStrokeOkHCL()` * `fieldOptions` ## Related APIs * [useColorModel](../composables/use-color-model) * [useFillControls](../composables/use-fill-controls) * [useStrokeControls](../composables/use-stroke-controls) * [ColorPickerRoot](../components/color-picker-root) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-variables.md' description: 'Read and mutate variable collections, variables, and variable values.' --- # useVariables `useVariables()` is the lower-level variables composable behind the higher-level variables editor helpers. Use it when you want direct control over collections, active modes, filtering, and CRUD operations without taking the full table/dialog abstraction. ## Usage ```ts import { useVariables } from '@open-pencil/vue' const variables = useVariables() ``` ## Returns * `collections` * `activeCollectionId` * `activeCollection` * `activeModes` * `variables` * `searchTerm` * `setSearchTerm()` * `setActiveCollection()` * `addCollection()` * `renameCollection()` * `addVariable()` * `removeVariable()` * `renameVariable()` * `updateVariableValue()` --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/locale-apis.md' description: Lower-level locale stores and metadata exported by @open-pencil/vue. --- # Locale APIs In addition to `useI18n()`, the Vue SDK exports lower-level locale primitives for advanced integrations: * `locale` * `localeSetting` * `setLocale()` * `AVAILABLE_LOCALES` * `LOCALE_LABELS` Use these when you want direct store access, need to integrate locale state with a larger app shell, or want locale metadata without subscribing to the full `useI18n()` return object. ## Usage ```ts import { locale, localeSetting, setLocale, AVAILABLE_LOCALES, LOCALE_LABELS, } from '@open-pencil/vue' ``` ## Notes * `locale` is the resolved active locale store * `localeSetting` is the persisted user preference store * `setLocale()` updates the preference and active locale together * `AVAILABLE_LOCALES` and `LOCALE_LABELS` are useful for custom pickers ## Related APIs * [useI18n](../composables/use-i18n) --- --- url: 'https://openpencil.ink/programmable/sdk/api/advanced/use-viewport-kind.md' description: Read coarse mobile and desktop viewport flags for responsive editor shells. --- # useViewportKind `useViewportKind()` returns simple responsive flags used by OpenPencil editor UI. Use it when your shell needs a light abstraction over breakpoints instead of wiring `useBreakpoints()` directly. ## Usage ```ts import { useViewportKind } from '@open-pencil/vue' const { isMobile, isDesktop } = useViewportKind() ``` ## Returns * `isMobile` * `isDesktop` ## Related APIs * [useCanvas](../composables/use-canvas) --- --- url: 'https://openpencil.ink/programmable.md' description: >- AI chat, CLI, JSX renderer, MCP server, and other automation surfaces built on the OpenPencil editor engine. --- # Automation OpenPencil treats design files as data. Every operation available in the editor — creating shapes, setting fills, managing auto-layout, exporting assets — is also available from the terminal, from AI agents, and from code. No plugins to install, no API keys, no waiting list. The editor UI and the automation interfaces use the same engine. If you can do it by clicking, you can do it by scripting. ## The bigger idea OpenPencil is not just meant to be a design app. It is also meant to be a toolkit: something you can embed into other products, wrap with your own UI, and use to build editing workflows that fit your own domain. That is why the automation surface matters. The app, the CLI, the AI tools, the JSX renderer, the MCP server, and the SDK all build on the same underlying editor engine. ## AI Chat The built-in assistant has access to 90+ tools that cover the full surface of the editor. Describe what you want in natural language — "add a 16px drop shadow to all buttons", "create a card component with dark mode variant", "export every frame on this page at 2×". [AI Chat →](./ai-chat) ## Collaboration Real-time multiplayer editing over peer-to-peer WebRTC. No server, no account. Share a room link and edit together with live cursors and follow mode. Document state syncs via CRDT, so edits merge automatically even on flaky connections. [Collaboration →](./collaboration) ## Vue SDK Build OpenPencil-powered editors with the same Vue SDK the app uses internally. The SDK exposes editor context, canvas wiring, selection state, command models, property-panel composables, and headless primitives. [Vue SDK →](./sdk/) ## JSX Renderer Describe UI as JSX — the same syntax LLMs already know from React. A single call can create an entire component tree with frames, text, auto-layout, fills, and strokes. Compact, declarative, and diffable. Going the other direction, export any selection back to JSX with Tailwind classes — useful for handing off to development or feeding designs back into an LLM. [JSX Renderer →](./jsx-renderer) ## CLI Inspect, lint, export, and analyze design documents without opening the editor. List pages, search nodes, extract design tokens, catch layout or accessibility issues, and render to PNG — all from the terminal with machine-readable JSON output. The CLI also connects to the running desktop app via RPC, so you can script the editor while you're using it: open, save, and switch documents, undo, change settings, and call any MCP tool. [Inspecting Files](./cli/inspecting) · [Exporting](./cli/exporting) · [Analyzing Designs](./cli/analyzing) · [Scripting](./cli/scripting) · [Controlling the App](./cli/app-control) ## MCP Server Connect Claude Code, Cursor, Windsurf, or any MCP-compatible client to OpenPencil. The server exposes 90 tools for reading, creating, and modifying designs — the same tools the built-in AI chat uses. Runs over stdio or HTTP with session support. [MCP Server →](./mcp-server) ## URL scheme The desktop app registers `openpencil://`, so a published page — a Storybook story, a design review, a README — can link straight to a layer: ``` openpencil://open?file=web/design/hikyo.pen&node=Button/Large/Default ``` `file` is a repository-relative path ending in `.pen` or `.fig`; absolute paths and `.` or `..` segments are refused. `node` is optional. Both values are URL-encoded — path separators may stay literal, but a literal `+` must be sent as `%2B` — and a repeated key takes its last value. The app matches `file` against the paths of the open tabs as a whole trailing segment sequence, and focuses that tab without re-reading the document, so a file that moved or turned unreadable since it opened still gets its layer selected. The first open tab whose path ends with the requested path wins, which matters when two checkouts have the same file open. Segments are compared the way the platform's filesystem does: ASCII-case-insensitively on macOS and Windows, exactly on Linux, so `Web/Design/hikyo.pen` and `web/design/hikyo.pen` are the same file on a Mac and two different ones on Linux. If no open tab matches, a file picker asks for the file once; the picked file must end with the same relative path, otherwise the link is cancelled. No path is joined onto a root and no filesystem access is granted beyond what the picker returns. A file the link actually opens — the picked one — joins the recent-files list like any other file you open; focusing a tab that was already open does not touch the list, because nothing was opened. With a node name, the app selects every layer carrying that exact name on the current page and zooms the view to the whole selection. When the current page has none, it switches to the first page that does, loading pages as needed. An unknown name shows a notice and leaves the document open. Opening a file and selecting layers is all the scheme can do. The web app takes the same link from its own address bar: ``` https://openpencil.ink/?file=https://raw.githubusercontent.com/open-pencil/open-pencil/master/tests/fixtures/pencil_button.pen&node=Button/Large/Default ``` Here `file` is an absolute `https:` URL ending in `.pen` or `.fig` — the web app has no filesystem, so a relative path, an `http:` URL or any other extension is refused with a console warning and nothing else. The extension is read off the URL's path, so a query string on the linked file changes nothing. A fragment is dropped before the fetch: it never reaches the server, so `…/hikyo.pen#a` and `…/hikyo.pen#b` open one tab, not two. `node` behaves exactly as above: the same exact-name selection and zoom, the same notice when no layer carries the name. Both values are URL-encoded, a literal `+` must be sent as `%2B`, and a repeated key takes its last value, as on the desktop. The link is handled on any route, so `/share/?file=…` and `/demo?file=…` work like `/?file=…`. The browser fetches the file cross-origin, so the host must allow it: `raw.githubusercontent.com` sends `Access-Control-Allow-Origin: *` and works. The request carries no credentials and refuses to follow redirects, which keeps an `https:` link from being bounced to a plaintext one — a `https://github.com///raw/...` URL redirects to `raw.githubusercontent.com` and is therefore refused, so link to the raw host directly. `file` and `node` are stripped from the address bar through the router as soon as they are read — before the fetch, and also when the link was refused — so a reload does not re-open the document, and neither a copied URL nor a later in-app navigation carries the link payload. A linked document is capped at 64 MiB: the body is counted as it streams in, not trusted from `Content-Length`, and the request is aborted the moment it goes over, with the link reporting that the file exceeds 64 MiB. A deployment that serves the app under a Content-Security-Policy must allow the linked host in `connect-src`, otherwise the fetch is blocked and the link reports that it could not open the file. On macOS the scheme belongs to the installed app bundle, so links reach an installed build and not a `tauri dev` process. On Windows and Linux the link arrives through the deep-link plugin, including when the app is not running yet: the link is queued at startup and handled once the editor is ready; on Linux the bundled desktop entry passes the link through `%U`. ## Why Open? Figma is a closed platform. Their MCP server is read-only. CDP browser access was killed in version 126. Design files live in a proprietary format on someone else's servers. Plugin development requires a custom runtime with limited APIs. OpenPencil is the alternative: open source, MIT licensed, every operation scriptable, data stored locally. Your design files are yours — inspect them, transform them, pipe them into CI, feed them to an LLM. No permission needed. --- --- url: 'https://openpencil.ink/reference/cli.md' description: 'Complete reference for all openpencil commands, options, and flags.' --- # CLI Reference Document commands accept a `.fig` file as a positional argument. When omitted, the CLI connects to the running desktop app via RPC. `documents`, `undo`, `redo`, and `settings` always act on the running app. ## info Show document info — pages, node counts, fonts, file size. ```sh openpencil info [file] [--json] ``` | Option | Description | |--------|-------------| | `--json` | Output as JSON | ## tree Print the node hierarchy. ```sh openpencil tree [file] [options] ``` | Option | Description | |--------|-------------| | `--page` | Page name (default: first page) | | `--depth` | Max depth (default: unlimited) | | `--json` | Output as JSON | ## find Search nodes by name or type. ```sh openpencil find [file] [options] ``` | Option | Description | |--------|-------------| | `--name` | Node name (partial match, case-insensitive) | | `--type` | Node type: `FRAME`, `TEXT`, `RECTANGLE`, `INSTANCE`, etc. | | `--page` | Page name (default: all pages) | | `--limit` | Max results (default: 100) | | `--json` | Output as JSON | ## node Show detailed properties of a node. ```sh openpencil node [file] --id [--json] ``` | Option | Description | |--------|-------------| | `--id` | **Required.** Node ID (e.g. `1:23`) | | `--json` | Output as JSON | ## pages List all pages in the document. ```sh openpencil pages [file] [--json] ``` | Option | Description | |--------|-------------| | `--json` | Output as JSON | ## variables List design variables and collections. ```sh openpencil variables [file] [options] ``` | Option | Description | |--------|-------------| | `--collection` | Filter by collection name | | `--type` | Filter by type: `COLOR`, `FLOAT`, `STRING`, `BOOLEAN` | | `--json` | Output as JSON | ## tokens Print design variables as a stylesheet of CSS custom properties. Default mode values go in `:root`; every other mode overrides them under its condition, `[data-=""]` unless the mode names a selector or `@media` query. Aliases stay `var()` references. Tokens or modes that cannot be written are listed on stderr. ```sh openpencil tokens [file] [options] ``` | Option | Description | |--------|-------------| | `--format` | `css` (default), `tailwind` for a Tailwind v4 `@theme` with a `@custom-variant` per mode, or `dtcg` for W3C design token files | | `--out`, `-o` | Folder the `dtcg` files are written to; required for that format | | `--collection` | Filter by collection name | | `--type` | Filter by type: `COLOR`, `FLOAT`, `STRING`, `BOOLEAN` | | `--json` | Output `{ css, tokenCount, issues }` as JSON | ## export Export to PNG, JPG, WEBP, SVG, JSX, HTML, `.fig`, or Storybook stories. ```sh openpencil export [file] [options] ``` | Option | Alias | Description | |--------|-------|-------------| | `--format` | `-f` | `png` (default), `jpg`, `webp`, `svg`, `pdf`, `pptx`, `jsx`, `tailwind-jsx`, `html`, `fig`, `storybook` | | `--output` | `-o` | Output file path (default: `.`); a directory for `storybook` (default: `-stories`) | | `--scale` | `-s` | Export scale (default: 1) | | `--quality` | `-q` | Quality 0–100, JPG/WEBP only (default: 90) | | `--page` | | Page name (default: first page; `fig`, `pptx`, and `storybook` default to every page) | | `--node` | | Node ID to export (default: all top-level nodes) | | `--style` | | JSX style: `openpencil` (default), `tailwind` (same as `-f tailwind-jsx`) | | `--html` | | HTML mode: `fragment` (default), `standalone` | | `--css` | | HTML CSS output: `inline` (default), `tailwind` | | `--assets` | | Standalone HTML assets: `inline` (default), `external` | | `--fonts` | | Standalone HTML font output: `assets`, `none` (default) | | `--framework` | | Storybook framework: `react` (default), `vue`, `html` | | `--design-images` | | Storybook: render a PNG per variant for the Design panel (default: on; `--no-design-images` to skip) | | `--watch` | | Storybook: re-export whenever the document is saved | | `--beside` | | Storybook: write each document's stories into the document's own folder; the file argument can then be several files or a quoted glob | | `--thumbnail` | | Export page thumbnail instead of full render | | `--width` | | Thumbnail width (default: 1920) | | `--height` | | Thumbnail height (default: 1080) | ## import Import HTML/CSS/Tailwind into an editable OpenPencil document. ```sh openpencil import page.html [options] ``` | Option | Alias | Description | |--------|-------|-------------| | `--format` | `-f` | Output format: `fig` (default), `json` | | `--output` | `-o` | Output file path (default: `.`) | | `--css` | | CSS file to apply before conversion | | `--css-text` | | Inline CSS text to apply before conversion | | `--tailwind` | | Tailwind utility candidates to compile and apply | | `--tailwind-file` | | File containing Tailwind utility candidates | | `--page-name` | | Scene graph page name (default: `DOM/CSS`) | | `--json` | | Print a machine-readable summary | Examples: ```sh openpencil import card.html --css card.css -o card.fig openpencil import card.html --tailwind "flex flex-col gap-3 w-80 p-6 rounded-xl bg-white" -o card.fig ``` ## eval Execute JavaScript with the Figma Plugin API. ```sh openpencil eval [file] [options] ``` | Option | Alias | Description | |--------|-------|-------------| | `--code` | `-c` | JavaScript code to execute | | `--stdin` | | Read code from stdin | | `--write` | `-w` | Write changes back to the input file | | `--output` | `-o` | Write to a different file | | `--json` | | Output as JSON | | `--quiet` | `-q` | Suppress output | ## analyze colors Analyze color palette usage across the document. ```sh openpencil analyze colors [file] [options] ``` | Option | Description | |--------|-------------| | `--limit` | Max colors to show (default: 30) | | `--threshold` | Distance threshold for clustering similar colors, 0–50 (default: 15) | | `--similar` | Show similar color clusters | | `--json` | Output as JSON | ## analyze typography Analyze font family, size, and weight distribution. ```sh openpencil analyze typography [file] [options] ``` | Option | Description | |--------|-------------| | `--group-by` | Group by: `family`, `size`, `weight` (default: show all styles) | | `--limit` | Max styles to show (default: 30) | | `--json` | Output as JSON | ## analyze spacing Analyze gap and padding values across auto-layout frames. ```sh openpencil analyze spacing [file] [options] ``` | Option | Description | |--------|-------------| | `--grid` | Base grid size to check against (default: 8) | | `--json` | Output as JSON | ## analyze clusters Find repeated node patterns — potential components. ```sh openpencil analyze clusters [file] [options] ``` | Option | Description | |--------|-------------| | `--limit` | Max clusters to show (default: 20) | | `--min-size` | Min node size in px (default: 30) | | `--min-count` | Min instances to form a cluster (default: 2) | | `--json` | Output as JSON | ## diff create Patch that turns one node tree into another, as JSX attribute changes plus moved, added, and removed children. Children match by name; see [Comparing designs](/programmable/cli/comparing) for the format. ```sh openpencil diff create [file] --from --to [options] ``` | Option | Description | |--------|-------------| | `--from` | Source node ID | | `--to` | Target node ID | | `--depth` | Max tree depth (default: 10) | | `--json` | Output as JSON | ## diff jsx Structural diff between two nodes as design JSX. ```sh openpencil diff jsx [file] --from --to [--json] ``` ## diff show Preview the patch that setting JSX attributes on a node would produce, without changing it. ```sh openpencil diff show [file] --attributes '' [--json] ``` `--attributes` takes attributes as the JSX export writes them, such as `'w={200} bg="#FF0000"'`. ## diff apply Apply a patch from `diff create`, `diff show`, or `diff files`. Every node must still match the patch's old values unless `--force` is set, and nothing changes unless every hunk applies. ```sh openpencil diff apply [file] [options] ``` | Option | Alias | Description | |--------|-------|-------------| | `--dry-run` | | Validate and list changes without applying | | `--force` | | Apply even when current values differ from the patch | | `--write` | `-w` | Write changes back to the input file | | `--output` | `-o` | Write to a different file | | `--json` | | Output as JSON | Pass `-` as the patch path to read it from stdin. ## diff visual Pixel diff between two rendered nodes, written as a PNG with changed pixels in red. ```sh openpencil diff visual [file] --from --to --output [options] ``` | Option | Alias | Description | |--------|-------|-------------| | `--output` | `-o` | Diff PNG path | | `--scale` | | Render scale before the max-edge limit (default: 1) | | `--max-edge` | | Maximum image width or height (default: 1280) | | `--threshold` | | Color tolerance 0–1; smaller is stricter (default: 0.1) | | `--json` | | Output as JSON | ## diff files Structural diff of two documents, page by page. Pages match by name and nodes by name path, so two versions of a file compare even though their node IDs differ. Exits with status 1 when the documents differ and 2 when the options are invalid, such as a `--page` neither document has. ```sh openpencil diff files [options] ``` | Option | Description | |--------|-------------| | `--page` | Compare only the page with this name | | `--depth` | Max tree depth below each page (default: unlimited) | | `--json` | Output as JSON | ## documents Manage documents (tabs) in the running app. See [Controlling the App](/programmable/cli/app-control). ```sh openpencil documents list [--json] openpencil documents open [--json] openpencil documents new [--path ] [--json] openpencil documents save [--path ] [--document-id ] [--json] openpencil documents close [--save | --discard] [--path ] [--document-id ] [--json] openpencil documents activate [--page-id ] [--json] ``` | Option | Description | |--------|-------------| | `--path` | `.fig` path to create or save to; relative to the current directory | | `--save` | `close`: save unsaved changes first | | `--discard` | `close`: close without saving; unsaved changes are lost | | `--document-id` | Target document; defaults to the active tab | | `--page-id` | Page to switch the activated document to | | `--json` | Output the result and target document as JSON | ## undo / redo Undo or redo the newest change made through the CLI or MCP in the running app. Fails when the newest change was made in the editor; see [Controlling the App](/programmable/cli/app-control#undo-and-redo). ```sh openpencil undo [--document-id ] [--json] openpencil redo [--document-id ] [--json] ``` ## settings Read and change editor settings in the running app by dotted key. Values parse as JSON, falling back to plain strings. ```sh openpencil settings get [key] [--json] openpencil settings set [--json] ``` See [Controlling the App](/programmable/cli/app-control#settings) for the available keys. ## tool List, describe, and call the editor tools that the MCP server exposes. ```sh openpencil tool list [--json] openpencil tool describe [--json] openpencil tool call [file] [options] ``` | Option | Alias | Description | |--------|-------|-------------| | `--args` | | Tool arguments as a JSON object | | `--args-file` | | Read arguments from a JSON file, or `-` for stdin | | `--write` | `-w` | Headless: write changes back to the input file | | `--output` | `-o` | Headless: write changes to a different file | | `--document-id` | | App: target document | | `--page-id` | | App: target page | | `--json` | | Output as JSON | --- --- url: 'https://openpencil.ink/programmable/cli/inspecting.md' description: >- Browse node trees, search by name or type, and dig into properties from the terminal. --- # Inspecting Files The CLI lets you explore design documents without opening the editor. Every command also works on the live app — just omit the file argument. ::: tip Install ```sh npm install -g @open-pencil/cli # or bun add -g @open-pencil/cli ``` ::: ## Document Info Get a quick overview — page count, total nodes, fonts used, file size: ```sh openpencil info design.fig ``` ## Font Diagnostics Report requested font faces, available sources, substitutions, and affected layers: ```sh openpencil fonts design.fig openpencil fonts design.fig --json openpencil fonts --document-id tab-123 --page-id 0:1 ``` File mode checks all document pages using the CLI host's available fonts without downloading online fonts. Live mode reports the targeted app document/page, whose loaded fonts may differ from the CLI host. Faces are reported as `available`, `substituted`, or `unresolved`; JSON output includes `faithful`, `faces`, and `issues`. Use [export font policies](./exporting#font-substitution-policy) to warn about or reject substitutions during file-backed raster and PDF exports. ## Node Tree Print the full node hierarchy: ```sh openpencil tree design.fig ``` ``` [0] [page] "Getting started" (0:46566) [0] [section] "" (0:46567) [0] [frame] "Body" (0:46568) [0] [frame] "Introduction" (0:46569) [0] [frame] "Introduction Card" (0:46570) [0] [frame] "Guidance" (0:46571) ``` ## Find Nodes Search by type: ```sh openpencil find design.fig --type TEXT ``` Search by name: ```sh openpencil find design.fig --name "Button" ``` Both flags can be combined to narrow results further. ## Query with XPath Use XPath selectors to find nodes by type, attributes, and tree structure: ```sh openpencil query design.fig "//FRAME" ``` ### Useful patterns **By type:** ```sh openpencil query design.fig "//TEXT" # All text nodes openpencil query design.fig "//COMPONENT" # All components openpencil query design.fig "//INSTANCE" # All instances ``` **By attributes:** ```sh openpencil query design.fig "//FRAME[@width < 300]" # Frames under 300px wide openpencil query design.fig "//*[@cornerRadius > 0]" # Rounded corners openpencil query design.fig "//*[@visible = false]" # Hidden nodes openpencil query design.fig "//TEXT[@fontSize >= 24]" # Large text openpencil query design.fig "//*[@opacity < 1]" # Semi-transparent nodes ``` **By name and text content:** ```sh openpencil query design.fig "//TEXT[contains(@name, 'Button')]" # Name contains 'Button' openpencil query design.fig "//TEXT[contains(@text, 'Hello')]" # Text content contains 'Hello' ``` **By hierarchy:** ```sh openpencil query design.fig "//SECTION//TEXT" # Text inside sections openpencil query design.fig "//FRAME/TEXT" # Direct text children of frames openpencil query design.fig "//COMPONENT_SET//INSTANCE" # Instances inside component sets ``` ### Queryable attributes `name`, `width`, `height`, `x`, `y`, `visible`, `opacity`, `cornerRadius`, `fontSize`, `fontFamily`, `fontWeight`, `layoutMode`, `itemSpacing`, `paddingTop`, `paddingRight`, `paddingBottom`, `paddingLeft`, `strokeWeight`, `rotation`, `locked`, `blendMode`, `text`, `lineHeight`, `letterSpacing` ### Example output ``` Found 5 nodes [0] [frame] "Logo 92×32" (0:9) [1] [frame] "logo-short-6 31×32" (0:10) [2] [frame] "wrapper 128×73" (0:20) [3] [frame] "pen-drawing 148×52" (0:21) [4] [frame] "surprised-emoji 32×32" (0:26) ``` ## Node Details Inspect all properties of a specific node by its ID: ```sh openpencil node design.fig --id 1:23 ``` ## Pages List all pages in the document: ```sh openpencil pages design.fig ``` ## Variables List design variables and their collections: ```sh openpencil variables design.fig ``` ## Live App Mode When the desktop app is running, omit the file argument — the CLI connects via RPC and operates on the live canvas: ```sh openpencil documents list # list open document/page IDs openpencil tree # inspect the active live document openpencil tree --document-id tab-123 --page-id 0:1 openpencil eval --document-id tab-123 --page-id 0:1 -c "..." ``` Use `openpencil documents list --json` in agent workflows, then pass `--document-id` and `--page-id` explicitly instead of relying on the visible active tab/page. To open, save, switch, and close documents, undo, change settings, or call any editor tool, see [Controlling the App](/programmable/cli/app-control). ## Lint Designs Check documents for naming, layout, structure, and accessibility issues: ```sh openpencil lint design.fig openpencil lint design.pen --preset strict openpencil lint design.fig --rule color-contrast openpencil lint design.fig --list-rules openpencil lint design.fig --fix -o fixed.fig ``` Use `--json` for machine-readable output; each message carries its `fix` and `suggestions` as data. `--fix` applies the safe fixes — binding colors to the color variable they match and rounding geometry to whole pixels — and writes the result to the `.fig` file given with `-o`. ## JSON Output All commands support `--json` for machine-readable output — pipe into `jq`, feed to CI scripts, or process with other tools: ```sh openpencil tree design.fig --json | jq '.[] | .name' ``` --- --- url: 'https://openpencil.ink/programmable/cli/exporting.md' description: >- Export document content to images, PDF, PowerPoint, `.fig`, JSX, HTML, or Storybook stories, with font-substitution policies for raster and PDF output. --- # Exporting Export designs from the terminal — raster images, vectors, PDF, editable PowerPoint, `.fig` subsets, JSX code, HTML, or Storybook stories. ## Image Export ```sh openpencil export design.fig # PNG (default) openpencil export design.fig -f jpg -s 2 -q 90 # JPG at 2×, quality 90 openpencil export design.fig -f webp -s 3 # WEBP at 3× openpencil export design.fig -f svg # SVG vector openpencil export design.fig -f pdf # PDF openpencil export design.fig -f pptx # editable PowerPoint openpencil export design.fig -f fig --page "Page 1" # export one page as .fig openpencil export design.fig -f fig --node 1:23 # export one node as .fig openpencil export design.fig -f html --css tailwind # export an HTML fragment with Tailwind classes ``` Options: * `-f` — format: `png`, `jpg`, `webp`, `svg`, `pdf`, `pptx`, `jsx`, `html`, `fig`, `storybook` * `-s` — export scale (default: `1`) * `-q` — quality: `0`–`100` (JPG/WEBP only) * `-o` — output path * `--page` — page name * `--node` — specific node ID ## Font Substitution Policy For file-backed PNG, JPG, WEBP, and PDF exports, choose how missing or substituted fonts are handled: ```sh openpencil export design.fig -f pdf --font-policy strict openpencil export design.fig -f png --font-policy warn openpencil export design.fig -f webp --font-policy allow ``` * `warn` (default) — report substitutions and continue exporting. * `strict` — stop with a nonzero exit status if font preparation cannot faithfully resolve the requested faces. * `allow` — export without the additional font-fidelity check. Run [`openpencil fonts`](./inspecting#font-diagnostics) first to inspect affected faces. Export preparation may use configured font providers, so its result can differ from the offline file diagnostic. This policy does not apply to exports from the running app, or to SVG, PowerPoint, JSX, HTML, and `.fig` output. It checks font resolution, not complete visual equivalence with Figma. ## JSX Export Export as JSX with Tailwind utility classes: ```sh openpencil export design.fig -f tailwind-jsx # or: -f jsx --style tailwind ``` Output: ```html

Card Title

Description text

``` Also supports `--style openpencil` for the native JSX format (see [JSX Renderer](../jsx-renderer)). ## HTML Export Export as an HTML fragment with inline styles by default, or Tailwind utility classes: ```sh openpencil export design.fig -f html openpencil export design.fig -f html --css tailwind ``` Use `--html standalone` for a browser-openable HTML document with reset styles and a page wrapper. Standalone HTML is intended as a useful visual/code handoff, not a pixel-perfect renderer replacement: ```sh openpencil export design.fig -f html --html standalone --css inline openpencil export design.fig -f html --html standalone --css tailwind openpencil export design.fig -f html --html standalone --css tailwind --assets external ``` Standalone Tailwind output is compiled during export; it does not depend on the Tailwind browser runtime. Use `--assets external` to write CSS and extracted image assets next to the HTML file. Use `--fonts assets` with external assets to resolve detected SceneGraph text fonts through OpenPencil's configured web-font providers and emit local `@font-face` files. HTML export is available in file mode. ### Tokens in exported code HTML and Tailwind JSX write variable-bound properties as the design tokens they come from: a fill bound to `Primary` is `background-color: var(--color-primary)`, and in Tailwind `bg-primary`, or `bg-(--name)` for a token outside Tailwind's namespaces. A layer set to another mode gets that mode's attribute, such as `data-theme="dark"`, so the tokens resolve as the canvas draws them. A value stays literal where CSS would not reproduce it: the layer no longer draws the variable's value, the token is unitless where a length is needed, or the layer sits in a mode that only its own condition, such as a `@media` query, can select. Standalone HTML includes the stylesheet for the tokens it uses; for fragments and JSX, generate it with `openpencil tokens` (below). ## Design Tokens Write the document's variables as CSS custom properties: ```sh openpencil tokens design.fig > tokens.css openpencil tokens design.fig --format tailwind > theme.css openpencil tokens design.fig --collection Theme --type COLOR ``` Each collection's default mode goes in `:root`. Every other mode overrides the values that differ under its condition: `[data-theme="dark"]` for a Theme collection's Dark mode, or the selector or `@media` query the mode names. Aliases stay `var()` references and are declared again in each mode that changes what they point to, so switching `data-theme` on any element restyles everything built on it. ```css :root { --color-blue-500: #3B82F5; --color-primary: var(--color-blue-500); } /* Theme: Dark */ [data-theme="dark"] { --color-primary: var(--color-blue-300); } ``` `--format tailwind` puts tokens with a Tailwind v4 namespace (`--color-*`, `--spacing-*`, `--radius-*`, `--text-*`, …) in `@theme`, so `bg-primary` and `rounded-card` work, and adds a `@custom-variant` per mode, so `dark:bg-surface` follows the same condition. Import the file after `@import "tailwindcss";`. Names come from the variable's code syntax when it names a custom property (`--x` or `var(--x)`), otherwise from its name and type: `Blue/500` as a color is `--color-blue-500`. Tokens that cannot be written, such as boolean variables or a condition that is not a selector or query, are listed on stderr and left out. The command works in file mode and against the running app. ### W3C design tokens `--format dtcg` writes the variables and styles as [W3C design tokens](https://www.designtokens.org/tr/2025.10/format/) into a folder: ```sh openpencil tokens design.fig --format dtcg --out tokens ``` Each collection mode is one file, `Theme/Light.tokens.json` and `Theme/Dark.tokens.json`, in the shape Figma imports as a mode: lengths in `px`, times in `s`, booleans as numbers marked `com.figma.type`, and aliases to another collection naming it in `com.figma.aliasData`. Text and effect styles become typography and shadow tokens in `styles.tokens.json`, kept apart because Figma's import refuses composite tokens. `tokens.resolver.json` is a DTCG resolver that switches each collection's modes and layers the rest, for tools such as Style Dictionary. OpenPencil's own fields, such as a token's `rem` unit, CSS name, scopes, or `clamp()` expression, and each mode's condition, ride in `$extensions["dev.openpencil"]`, so the export reads back exactly. Import token files into a document with the `import_design_tokens` tool, passing each file's path and text: ```sh openpencil tool call import_design_tokens design.fig --args-file tokens.json --write ``` It reads a file per mode grouped by folder, a resolver with the files it refers to, or a Tokens Studio file with `$themes`. Collections and modes with existing names, and variables and styles by name, are updated; the rest is added, and nothing is deleted. The result lists what was added, updated, and skipped, with the reason for each skip. ## Storybook Export Generate one CSF3 `.stories.ts` file per component set or component: ```sh openpencil export design.fig -f storybook # React stories in ./design-stories/ openpencil export design.fig -f storybook --framework vue -o src/stories openpencil export design.fig -f storybook --css tailwind # components styled with Tailwind openpencil export design.fig -f storybook --framework html --page "Components" openpencil export design.pen -f storybook -o src/stories --watch # re-export on every save openpencil export 'src/**/*.pen' -f storybook --beside --watch # stories next to each design openpencil export design.fig -f storybook --rules stories.json # stories and titles by rule ``` ### Fewer stories in the sidebar Each story file opens with a `Default` story, the variant the design shows at rest, with every control. Every other variant or state story is tagged `variant`, and each file is tagged `openpencil` and `page:`, such as `page:icons`. Storybook's sidebar filter selects by these tags, and `.storybook/main.ts` can set what it shows by default, such as one story per component and no icons page: ```ts const config: StorybookConfig = { // … tags: { variant: { defaultFilterSelection: 'exclude' }, 'page:icons': { excludeFromSidebar: true } } } ``` ### Stories and titles by rule What the tags can't do, such as leaving components out or laying an icon set out as a gallery, a rules file decides. The export reads `openpencil.stories.json` from the working directory when it is there, or the file `--rules` names, again on every export when watching: ```json { "rules": [ { "match": "**", "title": "Design system/{page}/{name}" }, { "match": "Icons/**", "stories": "gallery" }, { "match": "**/_*", "stories": "none" } ] } ``` A rule applies to every story file whose `/` path matches its glob, by the names in the document: the page's, and the component set's or component's, or the prefix of slash-named components, such as `Icons/icon`. Every matching rule applies in order, so a later rule overrides an earlier one, each field on its own. `generate: false` keeps static stories for a Vue or React export where it would generate a component, such as for an icon set. `stories` is `variants`, a story per variant or state, as without rules; `single`, the `Default` story alone; `gallery`, `Default` and a `Gallery` story laying every variant out with its name, for static stories; or `none`, which leaves the file out. `title` is the file's title, with `{document}`, `{page}`, `{name}`, and `{path}`, the title it would have, filled in; titles that end up the same are kept apart as titles always are. Rules match the document's names, never a renamed title, so renaming a file never changes which rules apply to it. ### Designs next to their stories Keep each component's design file in the component's folder and export with `--beside`: each document's stories, design images, and `.openpencil-stories.json` manifest go into that document's own folder, next to the component's code. A Storybook `stories` glob such as `../src/**/*.stories.ts` in `.storybook/main.ts` then picks them up without further configuration. Pass several documents, or a quoted glob such as `'src/**/*.pen'` that OpenPencil expands itself (Node.js 22 or later). Several documents need `--beside` or `--output`; `--page` works with one document only. Documents are exported one after another, and when one fails the rest are still exported before the command exits with an error. `--watch` watches every matched document; a document created after the watch started needs another run. Stories are titled by the document's file name, and then the page when more than one page has components: `pricing.fig` gives `pricing/Plan picker`, so documents exported together keep their stories apart. Each variant of a component set becomes a story, and its variant properties become `select` controls, so switching a control shows the matching variant. A set with a behaviour gets the control's own props instead: a Switch or Checkbox has a `checked` boolean, a Toggle `pressed`, a Collapsible `open`, and `disabled` when the set draws a disabled state; hover, pressed, and focus stay stories of their own. Standalone components named with slashes, such as `Button/Primary` and `Button/Secondary`, are grouped into one `Button` file with a `Variant` control. A combination the design has no variant for throws a named error in Storybook rather than showing a different variant. Stories render the component as HTML with inline styles, like `-f html`, so they need no OpenPencil runtime; `--framework` (`react`, `vue`, or `html`) only changes the wrapper and the `Meta`/`StoryObj` import from `@storybook/react-vite`, `@storybook/vue3-vite`, or `@storybook/html-vite`. Text uses the document's font families: the export fetches their files from the web font providers the editor uses into `openpencil//` with a `fonts.css` every story file imports, so stories draw in those fonts, never in a fallback first. The document's design variables go there too, as custom properties in a `tokens.css` every story file imports, so styles bound to variables resolve as the design draws them. A family no provider has, such as Menlo, falls back to the browser's fonts, which the export warns about; `--fonts none` ships no font files. Static stories don't export text, boolean, or instance-swap properties yet. With `--framework vue` or `--framework react`, every component set and standalone component becomes a real component instead, `.vue` or `.tsx` with a `.module.css` (with `--css tailwind`, Tailwind utilities in its markup, its states as variants such as `data-[state=checked]:`, for a project that builds Tailwind v4), except slash-named components grouped into one file, and any a rule sets `"generate": false` for, which keep static stories. A component without a behaviour is the design's own elements: its root is the element the design draws, a `div` for a frame, and it takes the root element's own props, such as `onClick` and `class`. Its variant properties are props it sets as `data-*` attributes, so a `State=hover` variant is a prop like any other; its boolean properties are boolean props that show or hide the layers bound to them; and its slot properties are content it shows in place of the design's, a named `` in Vue and a `ReactNode` prop in React, falling back to the design's content. A component whose behaviour is a Button, Switch, Checkbox, Toggle, Collapsible, Tabs, Radio group, Toggle group, Accordion, Slider, Progress, Number field, Text field, or Textarea is built on Reka UI or on Radix UI's `radix-ui` package, and its interaction states, such as hover and disabled, become real `:hover` and `data-disabled` styles. Its variants are compiled into a stylesheet keyed on the states Reka and Radix set (`data-state`, `data-disabled`, hover, and focus), and every other variant property is a prop the component sets as a `data-*` attribute. In Vue its value is a `v-model` such as `checked` or `open`; in React the component takes the Radix root's props, such as `checked`, `defaultChecked`, and `onCheckedChange`. Its text properties are string props, defaulting to the design's words, which the layers bound to them draw, with a text control in its stories. Tabs take the open tab as `value` (`v-model:value`, or `defaultValue` and `onValueChange` in React), named by each trigger's words as a slug, such as `account` or `password`, and starting on the first; each trigger and its panel are Reka's or Radix's `Tabs` parts around the design's layers, every trigger drawn like the design's other triggers and taking the first trigger's look while its tab is active, and the stories get a `select` control and a story per tab. A Radio group, Toggle group, or Accordion writes two components: the group, which chooses one item the same way, starting on the item the design draws on, and `Item` from its items' component set, built as the group's item primitive (`RadioGroupItem`, `ToggleGroupItem`, or `AccordionItem` with its trigger in a header) and taking the `value` it stands for. The group uses it once per item in its items slot, with each item's value, named by its label property or else its words, and its text values. A Slider, Progress, or Number field binds its number as `value` (`v-model:value`, or `defaultValue` in React, a list for Radix's slider), within the range its behaviour sets, starting where the design does; the slider's range and thumb and the progress indicator drop the place the design draws them at, since the value sets it, and stories get a range control and a story at each end. A Text field or Textarea draws its text layer as a native `input` or `textarea` binding `value`, which in React takes the component's props; when the design draws the field filled and empty, the empty field's words are the placeholder and its look the placeholder's, and the filled look shows while the field has text. Reka UI's number field draws the input and steppers in Vue; Radix has none, so React's is a native number input whose steppers change it by a step and which settles within its range when left. Focus on a slider's thumb or a field's input draws the control's focus state. A Radio is only generated as a group's item; a Toggle or Collapsible used on its own still becomes its own component. Inside a generated component, an instance of another component generated in the same export uses that component rather than drawing its layers: `` imported from `./Switch.vue` or `./Switch`, set to the variant, text, and `disabled` values the instance shows. A control the design draws on, such as a checked switch, starts on and stays operable: a local `ref` bound with `v-model` in Vue, `defaultChecked` in React. The parent only places it, so the control keeps its own look and state styles. Only an instance that draws exactly as its variant, apart from its property values and where its parent places it, uses the component; one with edited text no property sets, a fill of its own, or other changes draws its own layers, so the story keeps what the design shows. Instances of components not exported, or exported only as static stories, are drawn as before. A variant property whose default the document leaves empty starts at the default variant, the one in the set's top-left corner, which opens its stories. An icon placed from a set, unless its paths were edited, is Iconify's component, `` from `@iconify/vue` or `@iconify/react`, which loads the icon when shown. A layer filled with a shader plays it behind its content with the `shaders` library's `` from `shaders/vue` or `shaders/react`, each effect a child with the preset's props and telemetry off (`disableTelemetry`); browsers without WebGPU show nothing in its place. Its stories render the component, and a play function clicks the control and checks its state, so the Storybook project needs `reka-ui` or `radix-ui` installed, and `@iconify/vue` or `@iconify/react` when components hold icons, and `shaders` when layers fill with shaders. Stories carry `parameters.design` entries for [`@storybook/addon-designs`](https://github.com/storybookjs/addon-designs): * **OpenPencil** — when the document path is inside the current directory, an [`openpencil://` link](../index#url-scheme) that opens the document in the desktop app and selects the variant, or its component set when another layer shares the variant's name. The scheme addresses layers by name, so a story whose variant and component names are both shared by other layers gets no link. * **Design** — a 2× PNG of the variant, written to `.design/` next to the story and referenced with `new URL(…, import.meta.url)`, so Vite bundles it. Copy the parameter onto the story of your own component to compare the implementation with the design. `--no-design-images` skips rendering; font substitution follows `--font-policy` as in raster export. `-o` names the output directory. A `.openpencil-stories.json` manifest there records which document, as a path relative to the output directory, and which page generated each story and design image; commit it with the stories. An export replaces the files that a previous export of the same document generated there, including those of components since deleted or renamed; with `--page`, only that page's. It refuses, before changing anything, to overwrite any other file — a hand-written story, another document's, or a stray image. A one-page export whose file names shifted onto another page's stories asks for a full export instead. Without the manifest, existing stories count as someone else's, so remove them before exporting again. `--watch` keeps the command running and re-exports whenever the document is saved, so Storybook's hot reload follows the design; a save that cannot be read is reported and the watch continues. A missing `--page` or a `--font-policy strict` substitution still ends the command. ## Live App Mode Omit the file to export from the running app: ```sh openpencil export -f png # export the selection in the active document openpencil export --page "Components" -f png # export every layer of a page openpencil export --node 1:23 -f png # export one layer, on any page ``` `--page` takes a page name and `--page-id` a page ID from `openpencil documents list`; either exports that page without switching the app to it. Add `--document-id` to export from a document other than the active one. Live app mode supports PNG, JPG, WEBP, SVG, and PDF. PowerPoint, JSX, HTML, Storybook, and `.fig` exports require a file argument. File-mode thumbnail export is not currently supported. --- --- url: 'https://openpencil.ink/programmable/cli/analyzing.md' description: 'Audit colors, typography, spacing, and repeated patterns in .fig files.' --- # Analyzing Designs The `analyze` commands audit an entire design system from the terminal — find inconsistencies, extract the real palette, spot components waiting to be extracted. ## Colors ```sh openpencil analyze colors design.fig ``` Finds every color in the file, counts usage, and shows a visual histogram: ``` #1d1b20 ██████████████████████████████ 17155× #49454f ██████████████████████████████ 9814× #ffffff ██████████████████████████████ 8620× #6750a4 ██████████████████████████████ 3967× ``` ## Typography ```sh openpencil analyze typography design.fig ``` Lists every font family, size, and weight combination with usage counts. Useful for spotting one-off text styles that should be consolidated. ## Spacing ```sh openpencil analyze spacing design.fig ``` Audits gap and padding values across auto-layout frames. Helps identify spacing scale inconsistencies — e.g. a stray `13px` gap among otherwise `8/16/24` values. ## Clusters ```sh openpencil analyze clusters design.fig ``` Finds repeated node patterns that could be extracted into components: ``` 3771× frame "container" (100% match) size: 40×40, structure: Frame > [Frame] 2982× instance "Checkboxes" (100% match) size: 48×48, structure: Instance > [Frame] ``` ## JSON Output All analyze commands support `--json` for machine-readable output: ```sh openpencil analyze colors design.fig --json ``` Pipe into `jq`, feed into CI checks, or use in scripts that enforce design token budgets. --- --- url: 'https://openpencil.ink/programmable/cli/comparing.md' description: 'Diff nodes and documents structurally and visually, and apply patches.' --- # Comparing Designs The `diff` commands compare two nodes, preview or apply changes as patches, render pixel diffs, and compare whole documents. Each node command works on a file or, without one, on the document open in the running app. ## Patches ```sh openpencil diff create design.fig --from 1:23 --to 1:87 ``` Prints a patch that turns the first tree into the second. Its properties are the JSX attributes the [JSX export](../jsx-renderer#exporting-to-jsx) writes, so it covers everything the export does. Children match by name, and a reordered child reads as one move: ```diff @@ /Card #1:23 -rounded={8} +rounded={12} @@ /Card/Header #1:24 -bg="#FFFFFF" +bg="#F4F4F5" @@ /Card/Badge #1:26 moved to 0 @@ /Card/Note #1:27 removed @@ /Card/Price added to #1:23 at 3 +$9 ``` Each hunk names a node by path, for reading, and by ID, which locates it. `-` lines hold the old attribute values and `+` lines the new ones; an added node is its JSX. `diff show` previews setting attributes on a node without changing it, and prints the patch: ```sh openpencil diff show 1:24 design.fig --attributes 'bg="#F4F4F5" rounded={8}' > header.diff ``` `diff apply` applies a patch to the document whose IDs it names. Every node must still have the patch's old values unless `--force` skips that check, and nothing changes unless every hunk applies, so a patch never half-applies: ```sh openpencil diff apply header.diff design.fig --dry-run # validate first openpencil diff apply header.diff design.fig --write # save in place openpencil diff apply header.diff design.fig -o out.fig # save elsewhere ``` Updates change only the fields their attributes set, so node IDs, instance links, and state JSX does not describe stay intact. Removed nodes are deleted and added nodes are rendered from their JSX. ## JSX diff ```sh openpencil diff jsx design.fig --from 1:23 --to 1:87 ``` Compares two subtrees as a line diff of their design JSX, for reading rather than applying. ## Visual diff ```sh openpencil diff visual design.fig --from 1:23 --to 1:87 --output diff.png ``` Renders both nodes at the same scale and writes a PNG with changed pixels in red over a faded copy of the source. The report includes the changed pixel ratio and the changed region in source-node coordinates. `--scale` and `--max-edge` bound the render the same way `export_image` does; `--threshold` sets the color tolerance. ## Comparing documents ```sh openpencil diff files before.fig after.fig openpencil diff files before.fig after.fig --page "Mobile" --json ``` Compares two documents page by page. Pages match by name and nodes by name path, so two versions of a file compare even though node IDs differ; the patch applies to the first document. Patches do not add or remove pages: a page only one document has is listed by name and status instead. Like `diff(1)`, the command exits with status 1 when the documents differ. ## Agents The same operations are available as the `diff_create`, `diff_jsx`, `diff_show`, `diff_apply`, and `diff_visual` tools for the built-in AI chat and MCP clients. `diff_visual` returns its image to the model, so an agent can confirm that an edit touched only the intended region. --- --- url: 'https://openpencil.ink/programmable/cli/app-control.md' description: >- Open, save, switch, and close documents, undo and redo, change settings, and call any editor tool in the running app from the CLI. --- # Controlling the App While the desktop app is running, the CLI drives it over the same automation bridge as the [MCP server](/programmable/mcp-server). Every command on this page except headless `tool call` needs the running app. ## Documents Each open tab is a document with a stable ID. List them, then pass `--document-id` (and `--page-id`) to aim a command at a background tab without switching to it: ```sh openpencil documents list --json openpencil tree --document-id tab-123 --page-id 0:1 ``` Open, create, save, and close documents: ```sh openpencil documents open designs/landing.fig # opens in a new tab openpencil documents new --path designs/draft.fig # empty document, saved to the path openpencil documents save --document-id tab-123 openpencil documents save --document-id tab-123 --path designs/copy.fig openpencil documents close --document-id tab-123 openpencil documents close --document-id tab-123 --save # or --discard ``` Relative paths resolve against the shell's working directory. These commands never open a dialog in the app, because nobody may be there to answer it. Closing a document with unsaved changes fails unless you pass `--save` or `--discard`, and saving a document that has never been saved needs `--path`. Each command prints the document and page it acted on; with `--json` it prints `{ "result", "target" }`, so a script can read the new document's ID from `target.documentId`. To bring a tab to the front, optionally on a specific page: ```sh openpencil documents activate tab-123 --page-id 0:4 ``` ## Undo and redo Step back through changes made through the CLI and MCP, like **Edit → Undo** and **Edit → Redo**: ```sh openpencil undo --document-id tab-123 openpencil redo --document-id tab-123 --json ``` ```json { "result": { "applied": true, "label": "Set opacity", "scope": "document" }, "target": { "documentId": "tab-123", "documentName": "Landing", "pageId": "0:1", "pageName": "Page 1" } } ``` When there is nothing to undo or redo, the command says so and `result.applied` is `false`. The editor has one history, shared by you and every automation client, so **Edit → Undo** in the app steps back through any change, newest first. Automation is narrower: `undo` and `redo` only act on a step that the CLI or MCP made, and only while it is the newest one. If the newest change was made in the editor, the command fails and leaves it alone, so an agent can't revert your work. They also fail while the document is in vector edit mode, whose history belongs to the editing session. Each CLI or MCP command that edits the document is one undo step. An `eval` script is recorded against its target page: if it switches `figma.currentPage` and edits another page, those edits are not part of the undo step. ## Settings Read and change editor settings by dotted key: ```sh openpencil settings get openpencil settings get appearance.theme openpencil settings set appearance.theme light openpencil settings set editing.snapping.pixelGrid false openpencil settings set chat.maxAgentSteps 100 ``` Values are read as JSON when they parse (`false`, `100`, `"auto"`) and as plain strings otherwise. Invalid keys and values are rejected without changing anything. | Key | Values | |-----|--------| | `appearance.theme` | `dark`, `light`, `auto` | | `appearance.language` | `en`, `de`, `es`, `fr`, `it`, `ja`, `pl`, `ru`, `zh-CN` | | `appearance.animations` | `system`, `off` | | `appearance.accent` | JSON object: `{"kind":"preset","preset":"green"}` with `blue`, `purple`, `pink`, `red`, `orange`, `yellow`, `green`, `graphite`, or `{"kind":"custom","color":"#FF6600"}` | | `editing.snapping.geometry` | `true`, `false` | | `editing.snapping.objects` | `true`, `false` | | `editing.snapping.pixelGrid` | `true`, `false` | | `rendering.canvasMode` | `retained`, `tiled` (applies after a reload) | | `recovery.enabled` | `true`, `false` | | `chat.reasoningDisplay` | `collapsed`, `while-thinking`, `expanded` | | `chat.maxAgentSteps` | integer 1–1000 | | `chat.changePreviewSize` | `off`, `small`, `medium`, `large` | | `designCheck.showOnCanvas` | `true`, `false` | | `designCheck.preset` | `recommended`, `strict`, `accessibility` | | `designCheck.disabledRules` | JSON array of rule IDs, e.g. `["no-default-names"]`; see `openpencil lint --list-rules` | Credentials, AI models, MCP connections, storage, and tool access are deliberately not available here: an automation client can't read secrets or grant itself access. ## Calling tools Every editor tool the MCP server exposes is also available from the CLI. List them, inspect a tool's arguments as JSON Schema, and call it with a JSON object: ```sh openpencil tool list openpencil tool describe set_fill openpencil tool call set_fill --args '{"id":"0:5","color":"#2563eb"}' openpencil tool call set_fill --document-id tab-123 --args-file fill.json echo '{"name":"Icons"}' | openpencil tool call create_page --args-file - ``` Pass a file to run a tool headlessly instead. Changes to a file are kept only with `--write` (back to the input) or `--output`: ```sh openpencil tool call create_page design.fig --args '{"name":"Icons"}' --write openpencil tool call get_page_tree design.fig --json ``` For multi-step edits, [`eval`](./scripting) runs a whole script against the same Figma-compatible API. --- --- url: 'https://openpencil.ink/programmable/cli/scripting.md' description: >- Execute JavaScript with a Figma-compatible Plugin API to query, batch-modify, and generate designs. --- # Scripting `openpencil eval` runs JavaScript against an OpenPencil document with a Figma-compatible `figma` global. Use it for headless batch edits, inspection, fixture setup, and automation without opening the editor UI. ## Basic usage ```sh openpencil eval design.fig -c "return figma.currentPage.children.length" ``` The `-c` flag accepts JavaScript. Its last expression is the result, so `return` is optional; `await` works at the top level. A script whose last statement is not an expression, such as a declaration, has no result and prints nothing. ```sh openpencil eval design.fig -c " const frame = figma.createFrame() frame.name = 'Card' frame.resize(300, 200) frame.layoutMode = 'VERTICAL' frame.itemSpacing = 12 return { id: frame.id, name: frame.name } " ``` ## Query nodes ```sh openpencil eval design.fig -c " return figma.currentPage .findAll((node) => node.type === 'FRAME' && node.name.includes('Button')) .map((button) => ({ id: button.id, name: button.name, width: button.width, height: button.height })) " ``` ## Modify and save Use `--write` / `-w` to write changes back to the input file: ```sh openpencil eval design.fig -c " figma.currentPage.children.forEach((node) => { node.opacity = 0.5 }) " --write ``` Use `--output` / `-o` to write to a new file: ```sh openpencil eval design.fig -c "figma.currentPage.name = 'Updated'" -o updated.fig ``` ## Read scripts from stdin ```sh cat transform.js | openpencil eval design.fig --stdin --write ``` ## Live app mode Omit the file path to run against the currently open document in the desktop app: ```sh openpencil eval -c "return figma.currentPage.name" ``` The desktop app must be running with a document open. ## Output By default, non-TTY output is JSON. Use `--json` to force JSON output: ```sh openpencil eval design.fig -c "return figma.currentPage.children.map((n) => n.name)" --json ``` Use `--quiet` / `-q` to suppress output when only writing a file. ## The `openpencil` API Next to `figma`, scripts get `openpencil`: what OpenPencil adds to the Figma Plugin API, in the same style. The same global is available to the `eval` tool and the running app's automation. A main component or component set can [behave as a Reka UI control](/user-guide/components#behaviours-and-preview). Bind it by its own property and slot names: ```sh openpencil eval design.fig -w -c ' const set = figma.currentPage.findOne((n) => n.type === "COMPONENT_SET" && n.name === "Switch") const behaviour = openpencil.setBehaviour(set, "switch") .bindValue("value", "State") // On and Off guessed from the variants .bindPart("thumb", set.findOne((n) => n.name === "Thumb")) // the frame becomes a slot behaviour.states = "Interaction" behaviour.missing ' ``` * `openpencil.setBehaviour(node, kindOrSpec)` replaces a component's behaviour: a kind alone, or a spec such as `{ kind: "slider", parts: { track: "Track", thumb: "Thumb" }, numbers: { value: { min: 0, max: 10 } } }`. It returns the behaviour. * `openpencil.getBehaviour(node)` reads one, or `null`; a variant reads its set's. * A behaviour has `kind`, `spec` (by name), `missing` (required values and parts still unbound), and `states`, and `bindValue(valueId, property, { on, off })`, `bindPart(partId, slotNameOrFrame)`, `setNumber(valueId, { min, max, step, default })`, and `remove()`. * `openpencil.behaviourKinds` lists every kind with the values and parts it binds. * `openpencil.createSlot(frame)` makes a frame of a main component a slot and returns its name. Names the component does not have fail with an error that lists the ones it has. Comments people leave with the Comment tool are part of the document, so scripts and agents can read the feedback on a file, act on it, and answer: ```sh openpencil eval design.fig -w -c ' for (const comment of openpencil.getComments({ resolved: false })) { console.log(comment.author, comment.node?.name, comment.text) comment.reply("Bumped the title to 24px", { author: "Claude" }).resolve() } ' ``` * `openpencil.getComments(filter)` lists threads oldest first: all of them, or `{ resolved: false }` for open ones, `{ page }` for one page's, `{ author }` for those a person started or replied to. * `openpencil.addComment(text, { node, page, x, y, author })` pins a comment on a layer, which it follows, or at a point of a page; the current page by default. `author` is `"Agent"` when left out. * A comment has `id`, `text` (Markdown), `author`, `createdAt`, `resolved`, `page`, `node` (the top-level layer it is pinned to), `position`, and `replies`, and `reply(text, { author })`, `resolve()`, `reopen()`, and `remove()`. `openpencil.getComment(id)` finds one. ## Supported API surface The API is intentionally close to Figma's Plugin API, but it maps to OpenPencil's scene graph and file format. ### Document and pages * `figma.root` * `figma.currentPage` * `figma.currentPage.selection` * `figma.getNodeById(id)` * `figma.createPage()` ### Node creation * `figma.createFrame()` * `figma.createRectangle()` * `figma.createEllipse()` * `figma.createText()` * `figma.createLine()` * `figma.createPolygon()` * `figma.createStar()` * `figma.createVector()` * `figma.createNodeFromSvg(svg)` * `figma.createComponent()` * `figma.createSection()` ### Tree operations * `node.children` * `node.parent` * `node.appendChild(child)` * `node.insertChild(index, child)` * `node.clone()` * `node.remove()` * `node.findAll(callback?)` * `node.findOne(callback)` * `node.findChild(callback)` * `node.findChildren(callback?)` * `figma.group(nodes, parent)` * `figma.ungroup(node)` ### Components * `figma.createComponentFromNode(node)` * `figma.combineAsVariants(components, parent)` — properties come from names such as `State=On, Size=Large` * `component.createInstance()` * `instance.mainComponent` ### Variables * `figma.getLocalVariables(type?)` * `figma.getVariableById(id)` * `figma.getLocalVariableCollections()` * `figma.getVariableCollectionById(id)` * `figma.createVariable(name, type, collectionId, value?)` * `figma.setVariableValue(variableId, modeId, value)` * `figma.deleteVariable(id)` * `figma.createVariableCollection(name)` * `figma.deleteVariableCollection(id)` * `figma.bindVariable(nodeId, field, variableId)` * `figma.unbindVariable(nodeId, field)` ### Properties Common node properties are readable/writable through the proxy, including: * Geometry: `x`, `y`, `width`, `height`, `rotation`, `resize(width, height)` * Appearance: `fills`, `strokes`, `effects`, `opacity`, `visible`, `locked`, `blendMode`, `clipsContent` * Radius: `cornerRadius`, `topLeftRadius`, `topRightRadius`, `bottomRightRadius`, `bottomLeftRadius` * Text: `characters`, `fontSize`, `fontName`, `fontWeight`, `fills`, `letterSpacing`, `lineHeight`, and `textDecoration`, which read `figma.mixed` while characters differ and restyle every character when set, and alignment * Text ranges: `getStyledTextSegments(fields, start?, end?)`, and `getRange…` / `setRange…` for `FontSize`, `FontName`, `Fills`, `LetterSpacing`, `LineHeight`, `TextDecoration` with its `Style`, `Offset`, `Thickness`, `Color`, and `SkipInk`, `TextCase`, `Hyperlink` (a `{ type: 'URL' | 'NODE', value }` link, or `null`), `TextStyleId` and `FillStyleId` (set with `setRangeTextStyleIdAsync` and `setRangeFillStyleIdAsync`, which give the range the style's fields), and `getRangeBoundVariable` / `setRangeBoundVariable` for the text fields Figma binds, plus `getRangeFontWeight`, `getRangeAllFontNames`, and `getRangeOpenTypeFeatures`. Lengths read in pixels; a percent given to a setter is of the font size * Text lists and paragraphs: `getRangeListOptions` / `setRangeListOptions` for bulleted (`UNORDERED`) and numbered (`ORDERED`) items, `getRangeIndentation` / `setRangeIndentation` for nesting (1–5 for list items), `listSpacing`, `paragraphSpacing`, and `paragraphIndent` for the whole text (`figma.mixed` when paragraphs differ) or per paragraph with `getRangeListSpacing` / `setRangeListSpacing`, `getRangeParagraphSpacing` / `setRangeParagraphSpacing`, and `getRangeParagraphIndent` / `setRangeParagraphIndent`, and `hangingList` * Auto-layout: `layoutMode`, `primaryAxisAlignItems`, `counterAxisAlignItems`, `itemSpacing`, padding, sizing, and layout positioning fields * Stroke helpers: `strokeWeight`, `strokeAlign`, `dashPattern` ### Utilities * `figma.mixed` * `figma.createImage(data)` * `figma.loadFontAsync(fontName)` no-ops because OpenPencil does not gate text edits on plugin font loading * `figma.listAvailableFontsAsync()` returns host-provided fonts when available * `figma.getNodeByIdAsync(id)` and `instance.getMainComponentAsync()` resolve to the same nodes as `figma.getNodeById(id)` and `instance.mainComponent`, for scripts written for Figma's dynamic-page mode * `figma.notify(message)` logs a warning in headless mode * `instance.swapComponent(component)` points an instance at another component * A layer inside an instance refuses `x`, `y`, `rotation`, `relativeTransform`, `constraints`, `isMask`, and `maskType` with Figma's error, such as `in set_x: This property cannot be overridden in an instance: relative-transform`; those always follow the component. Slot content keeps them, and setting one on a slot's default content makes the instance own the slot * `component.createSlot()` adds a slot frame and its `SLOT` property; slot frames read `type: 'SLOT'`, `resetSlot()` brings back an instance slot's component content, and `limitViolations` lists the limits an instance slot breaks. `addComponentProperty` and `editComponentProperty` take a `description` and, for slots, `slotSettings` * `figma.viewport` ## Not yet Figma-compatible These Figma APIs are not exposed as compatible helpers yet: * `node.exportAsync()` * `node.setBoundVariable(field, variable)` * `figma.combineAsVariants(components, parent)` * Figma style APIs such as `figma.createPaintStyle()` / `figma.createTextStyle()` * Full vector boolean operation parity Use OpenPencil CLI export commands, core tools, or direct scene-graph helpers where available. --- --- url: 'https://openpencil.ink/programmable/jsx-renderer.md' description: >- Create editable designs using the same declarative authoring library as OpenPencil agents. --- # JSX Renderer OpenPencil's design JSX creates scene nodes, not browser DOM. You can use readable component trees, JavaScript expressions, and reusable functions to construct editable designs. ## Creating Designs The `render` tool accepts JSX strings in AI chat and MCP. In application or headless library code, import `Frame`, `Text`, and other authoring exports from `@open-pencil/design-jsx`, then create nodes with `renderTree` or `renderJSX` from `@open-pencil/core/design-jsx`, which add icons, SVG conversion, and layout. To write the trees as TSX, set `"jsxImportSource": "@open-pencil/design-jsx"` with `"jsx": "react-jsx"` in `tsconfig.json`, or add a `/** @jsxImportSource @open-pencil/design-jsx */` comment to the file. The scripting environment determines which APIs are exposed to `eval`; package exports are not automatically globals there. The [shared design-authoring reference](../reference/design-authoring) contains executable examples, layout guidance, and the supported syntax inventory. It is generated from the same source used by chat, ACP, codegen prompts, and the installable agent skill. ## Elements Use frames and text for composition, shape nodes for artwork, and real components and instances for reusable editor content. A JavaScript function alone does not create component identity. See the [element inventory](../reference/design-authoring#supported-syntax-inventory). ## Style Props Use Hug/Fill and flex or grid for content-driven sizing. Bind semantic variables instead of copying values. See [composition and layout](../reference/design-authoring#composition-and-layout), [paint and text](../reference/design-authoring#paint-text-and-artwork), and [variables and components](../reference/design-authoring#variables-and-components). ## Exporting to JSX ```sh openpencil export design.fig -f jsx # OpenPencil format openpencil export design.fig -f jsx --style tailwind # Tailwind classes ``` Exported OpenPencil JSX can be edited and rendered back into the document. The export writes hidden and locked layers, constraints, size limits, stacked, gradient, and image fills, strokes, effects, masks, and variable bindings, so rendering an export reproduces them and `diff_jsx` shows changes to any of them. Rich text with mixed styles, vector paths, layout grids, shared styles, and component property definitions are not written yet. Instances are written as frames with their content, so exported JSX stands on its own. See [CLI exports](./cli/exporting). ## Visual Diffing JSX makes structural design changes reviewable as code. Use `get_jsx` and `diff_jsx` to inspect changes, then verify the actual rendered result; a clean structural diff does not prove visual equivalence. --- --- url: 'https://openpencil.ink/programmable/native-api.md' description: 'Find OpenPencil''s public document, editor, layout, and design-authoring APIs.' --- # Native JavaScript APIs OpenPencil's native APIs are the ordinary JavaScript libraries used by the editor itself. There is no separate RPC-style authoring language or parallel agent implementation. | Responsibility | Public entry point | Start here | | --- | --- | --- | | Document nodes, hierarchy, variables, instances, geometry | `@open-pencil/scene-graph` | [Scene Graph](../reference/scene-graph) | | Editing actions, selection, undo, component properties, events | `@open-pencil/core/editor` | `createEditor()` and its inferred `Editor` type; [custom editor shell](./sdk/guides/custom-editor-shell) | | Declarative scene construction | `@open-pencil/design-jsx` for elements, helpers, and JSX export; `@open-pencil/core/design-jsx` to render them | [JSX guide](./jsx-renderer) and [authoring reference](../reference/design-authoring) | | Layout computation | `@open-pencil/core/layout` | `computeLayout(graph, frameId)` and `computeAllLayouts(graph, scopeId)` | | Figma-compatible scripting | `@open-pencil/core/figma-api` | [Compatibility](../reference/figma-compatibility) | | Schema-backed agent operations | `@open-pencil/core/tools` | [MCP](./mcp-server) | Use the owning package's public exports, not workspace source paths. Package declarations describe the current callable signatures; the table above identifies ownership rather than duplicating every method. ## Mutation boundaries Graph operations are low-level document mutations. They do not automatically constitute an editor undo transaction. Use editor actions for interactive edits that need selection, undo, component synchronization, and layout orchestration. Direct layout functions compute geometry; they are not replacements for editor actions. ## Agent access Library imports, agent tools, and the `eval` scripting environment are distinct entry points. The installed execution environment determines which objects and functions an agent can access. Do not assume `graph`, `editor`, or arbitrary package imports are available in `eval` simply because those APIs exist in the library. Existing Figma-compatible scripting remains supported. Shared authoring guidance is maintained in Core and composed into chat/codegen prompts and the installable skill. Examples belong with their implementation and should be exercised against the public API. --- --- url: 'https://openpencil.ink/reference/design-authoring.md' --- # OpenPencil design authoring This reference describes scene creation, not React DOM output. Use the `render` tool for JSX strings, or import `Frame`, `Text`, and other authoring exports from `@open-pencil/design-jsx` and render them with `renderTree` from `@open-pencil/core/design-jsx` in library code. Library exports are not automatically globals in agent `eval`; use only the bindings exposed by that execution environment. ## Composition and layout * `flex="row"` / `flex="col"` enables auto-layout. Use it for content; reserve explicit `x`/`y` or `position="absolute"` for intentional overlays and artwork. Without layout, children share the origin unless positioned. * `w` / `h` accept pixels, `"hug"` (content-sized), or `"fill"` (available space in a supported layout parent). Use Hug for notes, cards, and long pages instead of guessing heights. Fixed viewport sizes and artwork geometry are intentional exceptions. * `gap` controls spacing. `p`, `px`, `py`, and `pt`/`pr`/`pb`/`pl` control padding; longhands override shorthands. There is no margin shorthand. * `justify="start"|"end"|"center"|"between"` controls the primary axis; `items="start"|"end"|"center"|"stretch"|"baseline"` controls the cross axis. Distribution needs available space: `between` cannot create extra room in a Hug container. * `grow` distributes available space. Avoid circular Hug/Fill dependencies and redundant fixed widths on growing children. Keep Fill sizing through intermediate containers that should stretch. * For wrapping text in a column, prefer `w="fill"`; fixed-width text can use `textAutoResize="height"`. `maxLines` / `truncate` are intentional truncation, not fixes for accidental overflow. * `wrap` and `rowGap` enable wrapped flex rows. `grid`, `columns`, and `rows` enable grid (for example `columns="1fr 200px 1fr"` or `columns="repeat(7, 1fr)"`); tracks are `fr`, pixel lengths, `auto`, `repeat()`, and `minmax()`, which grows like its maximum. Grid children use `colStart`, `rowStart`, `colSpan`, and `rowSpan`. The current grid `gap` shorthand takes precedence over `columnGap` and `rowGap`. * `flow="auto"|"ltr"|"rtl"` controls container flow; text `dir` controls writing direction. Preserve these separately. * Outside auto-layout, and for `position="absolute"` children, `constraints={{ horizontal, vertical }}` sets how a layer follows its parent's resizing, with Figma's values in lowercase: `"min"`, `"center"`, `"max"`, `"stretch"`, or `"scale"`. `minW`, `maxW`, `minH`, and `maxH` bound a layer's size. `visible={false}` hides a layer and `locked` locks it. * Use measured node bounds and the existing `arrange` tool for independent artboards. Prefer layout constraints to calculating child coordinates; ordinary JavaScript arithmetic is appropriate when real geometry calculations are needed. ## Paint, text, and artwork * `bg` / `fill`, `stroke`, and text `color` accept colors and supported variable references. Set colors explicitly for predictable contrast. `fills` accepts structured paints; gradient helpers include `linearGradient`, `radialGradient`, `angularGradient`, and `diamondGradient`, each taking an array of stops and optional `{ opacity, transform }`: `fills={[linearGradient([['#3b82f6', 0], ['#8b5cf6', 1]])]}`. Without a `transform` a linear gradient runs left to right and the others are centred; `transform` is Figma's `gradientTransform`, mapping the layer's unit square onto gradient space. * `shader(effects, { opacity, visible, blendMode })` fills a layer with a shader from the [`shaders`](https://shaders.com) library: an array of effects such as `bg={shader([{ type: 'Aurora', props: { speed: 2 } }])}`, or a preset as shaders.com exports it, `{ components: [...] }`. The canvas shows the shader's first frame, preview plays it, and files and other formats keep that frame as an image. * `stroke` describes one stroke, refined by `strokeWidth`, `strokeAlign` (`"inside"`, `"center"`, `"outside"`), `strokeDash`, `strokeCap`, and `strokeJoin`. `strokes` takes several, as objects with `color`, `weight`, `align`, `dash`, `cap`, `join`, and `visible`; `strokeWeights={{ top, right, bottom, left }}` sets per-side widths. `dashPattern` sets the node's own dash pattern, separate from the per-stroke `strokeDash`. * `rounded` and `roundedTL`/`roundedTR`/`roundedBL`/`roundedBR` control corners. `strokeWidth`, `opacity`, `rotate`, and `blendMode` control appearance. `overflow="hidden"` clips content; do not hide accidental text overflow to make a broken layout appear correct. * `effects` accepts structured effects such as `dropShadow`, `innerShadow`, and `layerBlur`. `shadow` takes a CSS `box-shadow` list, such as `shadow="0 4 8 #0002, inset 0 1 0 #fff"`, and `blur` a layer blur radius; both are convenient shorthands. Effect helpers take `radius`, as Figma's effects do; when a JSX string is rendered, an option a paint or effect helper does not support is reported as a warning. * Text content belongs inside `Text`. Use `size`, `font`, `weight`, `italic`, `lineHeight`, `letterSpacing`, `textAlign`, `textAlignVertical`, `textDecoration`, and `textCase`. Decorations take CSS's names and values: `textDecorationStyle` (`solid`, `dotted`, `wavy`), `textDecorationColor` (`currentColor` follows the text), `textDecorationThickness` and `textUnderlineOffset` (pixels or `auto`), and `textDecorationSkipInk`. `lineHeight="auto"` follows the font, and `fills={[]}` paints no text. Verify fonts actually load before judging dimensions; do not assume every font is available. * `Span` styles some characters of a `Text` with `Text`'s own prop names: `size`, `font`, `weight`, `italic`, `color`, `fills`, `lineHeight`, `letterSpacing`, `textDecoration`, the decoration details, and `textCase`. `href` links its characters to a URL without changing how they look. Spans nest; the inner one wins. `Paragraph` writes one paragraph: `list="ordered"` or `list="unordered"` makes it a list item nested at `level` 1–5, and `spacing`, `listSpacing`, and `indent` override the `Text`'s `paragraphSpacing`, `listSpacing`, and `paragraphIndent` for it; `hangingList` on the `Text` hangs list markers outside the text box. A `Text` holds only `Paragraph`s or none; without them, a line break in its text still starts a paragraph. * `Icon` uses an Iconify name, size, and color. Prefer icons to emoji when reliable vector output is needed. Image fills belong on appropriate leaf shapes, not containers whose children must remain visible. * Design JSX props are the portable authoring interface. Some CSS-style aliases are supported, but this is not a browser CSS engine; do not assume arbitrary HTML, classes, or styles work. ## Variables and components * Create document variables before referencing them with `designVar('id-or-name')`. `defineVars` groups references; it does not create variable collections. * COLOR references work in paint props. FLOAT references work in `w`, `h`, `gap`, padding, corner radii, `strokeWidth`, `opacity`, text `size`/`fontSize`, `lineHeight`, and `letterSpacing`. Grid `columnGap`/`rowGap` and wrapped flex `rowGap` also support FLOAT references; grid `gap` overrides both axis-specific gaps. Use numbers or FLOAT references for these scalar props, not CSS unit strings. * References preserve real graph bindings, not just copied values. Set the intended collection mode on the parent before creating scalar-bound content: initial scalar layout resolves that inherited mode. This does not guarantee automatic scalar layout recomputation after a later mode switch. Verify resulting geometry as well as paint when changing modes. Missing or incorrectly typed scalar variables are errors. * `bind` maps supported scene-field paths to variable IDs or references when no shorthand exists. Use semantic tokens consistently rather than declaring unused collections. * A JSX string is one expression. Build repeated or computed content with JavaScript inside `{}` rather than writing each item out: `{days.map((day) => {day})}`, `{Array.from({ length: 35 }, (_, i) => …)}`, and conditionals. For longer logic, call an arrow function in place: `{(() => { const weeks = …; return weeks.map(…) })()}`; statements outside an expression do not run. * A reusable JavaScript function shares source code, not component identity. Use `Component`, `ComponentSet`, and `Instance` for editable main components and linked instances. * `Instance` resolves an existing component through `of`, `component`, or `componentId`. Component-set children named `variant=Primary`, for example, define variants that can be selected when instantiating the set. * `Component` and `ComponentSet` accept `properties`, an array of native property definitions (`id`, `name`, `type`, `defaultValue`). An `Instance` sets text, boolean, and swap properties by name, the way it picks a variant: ``. `properties` takes the same values keyed by property ID or name, for names that are not JSX identifiers or that design JSX already uses, such as `name`. Names survive a `.fig` save, which replaces authored IDs such as `message` with Figma IDs. Ordinary nodes do not accept `properties`. * Child `propertyRefs` connect fields to stable property IDs, for example `[{ propertyId: 'message', field: 'TEXT' }]`. Supported fields are `TEXT`, `VISIBLE`, and `INSTANCE_SWAP`; text and swap references require text and instance nodes respectively. References do not depend on layer names. * Instance assignments use the native string values (`'true'` / `'false'` for BOOLEAN properties and component IDs for swaps), and a property set as a prop also takes `true` / `false`, as in `Pinned={false}`. Assignments persist through component synchronization; unknown IDs and invalid values fail rather than silently creating inert overrides. Select variants through component-set variant props, not through instance property assignments. * Reuse existing local or library components before recreating them. Keep meaningful text, visibility, and swap properties exposed rather than hand-editing cloned child nodes. * Explicit instance `w` / `h` replace the inherited sizing mode on that axis; omitted dimensions retain the main component's sizing. Authored overrides survive component synchronization. Distinguish those placement constraints from the main component's default size, and verify actual bounds in narrower parents. Do not compensate for a sizing mismatch with guessed heights, clipping, or manually positioned siblings. ## Controls * A main component can behave as a Reka UI control in preview and code. Write it with Reka's names: `Switch.Root` is the component (a component set when its children are `Component` variants), and each Reka part is the slot that draws it, such as `Switch.Thumb`, `Slider.Track`, `Slider.Range`, `Slider.Thumb`, `Progress.Track`, `Progress.Indicator`, `Tabs.List`, `Collapsible.Trigger`, or `NumberField.Increment`. Parts in different variants share one slot. * The root names the properties that hold its values: `modelValue` (`open` on `Collapsible.Root`), `disabled`, and, for text fields, `filled`, as a variant or boolean property name, or `{ property, on, off }` when the variant values are not named like On and Off. `states` names the variant property that draws default, hover, pressed, focus, and disabled; a slider, progress bar, or number field takes `min`, `max`, `step`, and `defaultValue`. * `TextField.Input`, `Textarea.Input`, and `NumberField.Input` are the text layers whose text becomes the field's text property. `Tabs.Trigger` goes in `Tabs.List`; `Tabs.Content` panels may sit directly under `Tabs.Root`, the first showing. A part with children and no `flex`, `display`, `w`, or `h` of its own stacks them in a column that hugs them. * A group's item component is written on its own (`RadioGroup.Item`, `ToggleGroup.Item`, `Accordion.Item` with its `Accordion.Trigger` and `Accordion.Content`); the group's root then lists items as ``. ```tsx ``` ## Verification Inspect structure and actual rendered output. Node counts and `describe` diagnostics do not establish visual fidelity. Check wrapping with longer content, narrower containers, component edits, and relevant modes. Resolve overflow and contrast problems at their source. Reuse IDs returned by creation tools rather than repeatedly searching for the same nodes. The examples below are executed by the authoring-reference tests. Create the named variables before running a variable-bound example. ## Content-sized review note ```jsx June Lee Give the date a little more room at the bottom. ``` ## Variable-bound spacing and typography ```jsx A note that grows with its content. ``` ## Rich text with spans and lists ```jsx Version 2 is out now. Faster export PDF and SVG Fewer clicks ``` ## Supported syntax inventory Generated from the renderer metadata. This inventory lists accepted names, not arbitrary browser CSS support. **Elements:** `Frame`, `Text`, `Span`, `Paragraph`, `Rectangle`, `Ellipse`, `Line`, `Star`, `Polygon`, `Vector`, `Group`, `Section`, `Component`, `ComponentSet`, `Instance`, `View`, `Rect`, `Icon`, `Button.Root`, `Toggle.Root`, `Switch.Root`, `Switch.Thumb`, `Checkbox.Root`, `Checkbox.Indicator`, `RadioGroup.Root`, `RadioGroup.Item`, `RadioGroup.Indicator`, `ToggleGroup.Root`, `ToggleGroup.Item`, `Slider.Root`, `Slider.Track`, `Slider.Range`, `Slider.Thumb`, `Progress.Root`, `Progress.Track`, `Progress.Indicator`, `Tabs.Root`, `Tabs.List`, `Tabs.Trigger`, `Tabs.Content`, `Collapsible.Root`, `Collapsible.Trigger`, `Collapsible.Content`, `Accordion.Root`, `Accordion.Item`, `Accordion.Header`, `Accordion.Trigger`, `Accordion.Content`, `NumberField.Root`, `NumberField.Input`, `NumberField.Increment`, `NumberField.Decrement`, `TextField.Root`, `TextField.Input`, `Textarea.Root`, `Textarea.Input`. **Helpers:** `solid`, `gradient`, `linearGradient`, `radialGradient`, `angularGradient`, `diamondGradient`, `shader`, `dropShadow`, `innerShadow`, `layerBlur`, `backgroundBlur`, `foregroundBlur`, `designVar`, `defineVars`. **Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `constraints`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `strokeCap`, `strokeJoin`, `dashPattern`, `strokes`, `strokeWeights`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `mask`, `visible`, `locked`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `italic`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textDecorationStyle`, `textDecorationColor`, `textDecorationThickness`, `textUnderlineOffset`, `textDecorationSkipInk`, `textCase`, `paragraphSpacing`, `paragraphIndent`, `hangingList`, `list`, `level`, `spacing`, `listSpacing`, `indent`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `properties`, `propertyRefs`, `of`, `modelValue`, `open`, `disabled`, `filled`, `states`, `min`, `max`, `step`, `defaultValue`. --- --- url: 'https://openpencil.ink/programmable/mcp-server.md' description: >- Connect Claude Code, Cursor, Windsurf, and other MCP clients to OpenPencil for AI-assisted design inspection and editing. --- # MCP Server OpenPencil includes an MCP (Model Context Protocol) server that lets AI coding tools — Claude Code, Cursor, Windsurf, etc. — read and modify designs through the running app. Two transports: **stdio** for MCP clients, and **Streamable HTTP** for browser extensions and scripts. On macOS and Linux, local clients prefer a private Unix domain socket; Windows and unavailable sockets fall back to localhost TCP. Tool definitions own native Valibot input schemas, execution/mutation metadata, capabilities, and optional interface exposure exclusions. Tools are included by default; `exposure: { mcp: false, ai: false, webmcp: false }` can exclude them independently from each adapter. Exposure does not bypass execution support or user permissions: WebMCP still requires supported execution and explicit Off, Inspect, or Edit access. AI and MCP consume the same schema through Standard Schema; WebMCP derives its JSON Schema from that input. Numeric strings are accepted consistently across adapters, while non-finite values are rejected. Programmatic integrations use MCP SDK v2; custom tools replace the former `params`/`ParamDef` contract with `input` and execution metadata. ## Tool access settings Use **Settings → Tool access** (select **Local MCP**) to search and toggle the local server's tools, individually or by read-only/side-effect group. Group switches affect all group members, even during search. **Restore defaults** enables the configurable MCP tools again. Existing MCP preferences are preserved separately from the **Built-in AI** settings. Restart the MCP server, then reconnect stdio clients, to apply changes. For an externally managed server, restart its owning process. The list shows the tools the server offers, also while it is stopped; disabling a dedicated tool does not prevent an enabled script tool from performing the same operation. These switches are not a sandbox and do not configure remote MCP servers or WebMCP. ## Share only the selection {#selection-scope} Turn on **Share only the selection** in **Settings → MCP → Local server** to let MCP clients read only the layers you select. Clients then get `get_selection`, `get_node`, `get_page_tree`, `describe`, and `export_image`, and no tools that edit, open files, list documents, or change settings. Every node a call names must be a selected layer or inside one; `describe` and `export_image` read the selection when given no IDs, and `get_page_tree` needs a `root_id` from the selection. `export_image` returns the image but cannot write it to a file. With nothing selected, calls fail and ask the user to select layers. The server enforces this on every call it sends to the app, including `POST /rpc` and stdio clients, so a client cannot widen it. Restart the MCP server to apply the change. For a server you start yourself, set `OPENPENCIL_MCP_SCOPE=selection`; a stdio client can also set it to limit itself while the server shares the whole document. ## Browser-native WebMCP (experimental) {#webmcp} WebMCP is **off by default**. Open **Settings → MCP → WebMCP** and choose **Inspect** for read-only access or **Edit** to also allow scoped, undoable changes. **Off** unregisters all browser tools; changing modes revokes the previous registrations immediately. This preference is independent of local MCP authentication, tool switches, and outbound connections. For local testing, use a Chrome version exposing `document.modelContext`, enable `chrome://flags/#enable-webmcp-testing`, and relaunch the browser. Open a document, enable access in Settings, and connect a WebMCP-capable browser agent or the [Model Context Tool Inspector](https://developer.chrome.com/docs/ai/webmcp). Settings shows browser support and registration status. See the [Chrome WebMCP guide](https://developer.chrome.com/docs/ai/webmcp) for current availability. In supported browsers, OpenPencil registers the selected reviewed set of tools directly in the workspace. Browser agents can inspect nodes, JSX, variables, components, and design patterns, and edit existing layer properties and variable values without installing or connecting an MCP server. Tools target the document and page active when the call starts. Switching tabs does not redirect an in-flight call. Closing the workspace unregisters the tools. Tool inputs are validated and large inspection results require a narrower query. Oversized editing results are omitted with a committed-edit notice rather than reporting a successful edit as failed. Edits to geometry, paints, layout, text, and variable bindings/values commit synchronously as individual undoable operations. Failed edits roll back, and undo targets the original document/page even after a page switch. Cancellation prevents an edit from starting; cancellation after commit does not reverse it. Font loading finishes separately without holding a mutation transaction open. Atomic editing currently requires a document with at most 10,000 nodes and variables combined; this shared limit also applies when the same editing tools run through app AI/MCP. This surface does **not** expose structural creation/deletion, arbitrary JavaScript/JSX execution, image loading, filesystem operations, or credentials. Those tools retain their existing AI/MCP paths. WebMCP is an evolving browser proposal, not universally available; unsupported browsers continue to use OpenPencil normally. The stdio and HTTP integrations below remain independent. ## Install ```sh npm install -g @open-pencil/mcp ``` ## Stdio (Claude Code, Cursor, etc.) The stdio server discovers the running OpenPencil app automatically. It prefers the app's Unix domain socket on macOS and Linux and falls back to localhost TCP when needed. Make sure the desktop app is open with a document loaded. ### Claude Code Install the MCP package and register it with Claude Code: ```sh npm install -g @open-pencil/mcp claude mcp add --scope user open-pencil -- openpencil-mcp ``` Check the connection: ```sh claude mcp list ``` Claude Code asks before using each MCP tool unless you allow the server's tools. To auto-approve OpenPencil tools only, add this to `~/.claude/settings.json`: ```json { "permissions": { "allow": ["mcp__open-pencil__*"] } } ``` This is narrower than `--permission-mode bypassPermissions`, which skips prompts for every tool. You can also approve tools interactively from Claude's prompt by choosing “Yes, and don't ask again”. Example prompt: ```text Use the open-pencil MCP server to inspect the current page and create a small hero section on the canvas. ``` ### Other MCP clients Add to your MCP config (for example `.cursor/mcp.json`): ```json { "mcpServers": { "open-pencil": { "command": "openpencil-mcp" } } } ``` Or run from source without installing: ::: code-group ```json [Bun] { "mcpServers": { "open-pencil": { "command": "bun", "args": ["/path/to/open-pencil/packages/mcp/src/stdio.ts"] } } } ``` ```json [Node.js] { "mcpServers": { "open-pencil": { "command": "npx", "args": ["tsx", "/path/to/open-pencil/packages/mcp/src/stdio.ts"] } } } ``` ::: ## HTTP For browser extensions, scripts, CI, or any HTTP client: ```sh openpencil-mcp-http ``` Or from source: `bun packages/mcp/src/index.ts` / `npx tsx packages/mcp/src/index.ts` Security defaults: * Unix socket and discovery files are created with owner-only permissions on macOS and Linux. * TCP binds to `127.0.0.1` and uses port 7600 by default. * Authentication is enabled by default with a generated token stored in the private discovery file. * `eval` is disabled. * File operations are limited to `OPENPENCIL_MCP_ROOT` (defaults to the current working directory) and reject symlink escapes. * Only the desktop app's own origin (`tauri://localhost` and its `http(s)://tauri.localhost` variants) is allowed by default, so a server you start yourself works from the app without extra configuration. Set `OPENPENCIL_MCP_CORS_ORIGIN` to a comma-separated list to allow other origins, such as a worktree dev server. Set `PORT=0` to disable TCP on macOS and Linux. Windows requires TCP. Set `OPENPENCIL_MCP_SOCKET` to override the Unix socket path, or `OPENPENCIL_MCP_DISCOVERY_PATH` to override the discovery file location. To provide a stable token, set `OPENPENCIL_MCP_AUTH_TOKEN`; an explicitly empty value disables authentication and should only be used with a trusted local socket. Endpoints are available over both active transports: * `GET /health` — server and app connection status; never returns the auth token. * `POST /rpc` — authenticated live-app automation. * `POST /mcp` — MCP Streamable HTTP. Sessions use the `mcp-session-id` header. ## Workflow 1. **Discover targets** — call `list_documents` first when more than one document or page may be open. It returns stable `document_id` and page IDs. 2. **Open** — `open_file` to load an existing `.fig`, or `new_document` for a blank canvas. These return target metadata for the opened or created document. 3. **Read** — `get_page_tree`, `find_nodes`, `get_node`, `list_pages` 4. **Create** — `create_shape`, `render` (JSX) 5. **Modify** — `set_fill`, `set_stroke`, `set_layout`, `update_node`, `set_effects` 6. **Structure** — `reparent_node`, `group_nodes`, `clone_node`, `delete_node` 7. **Save** — `save_file` to write back to `.fig` 8. **Close** — `close_file` to close an open document tab. With unsaved changes it fails unless `unsaved` is `"save"` or `"discard"`; it never prompts in the app. `undo` and `redo` step back through the agent's own changes, and `activate_document` brings a tab to the front when the user should see it. Each MCP session shows in the app as an agent with a callsign, like the built-in chat: its cursor and outline sit on the layers each tool reads or changes, it rests after a quiet spell and leaves when the session ends, and people can follow it from their avatar. Collaborators in a shared room see it too. Most tools accept optional `document_id` and `page_id` fields. Pass them explicitly for agent workflows instead of relying on the visible active tab/page. `create_page` only creates a page; call `switch_page` separately when the workflow should change the active page. ## AI Agent Skill Teach your AI coding agent to use OpenPencil tools: ```sh npx skills add open-pencil/open-pencil ``` Works with Claude Code, Cursor, Windsurf, Codex, and any agent that supports [skills](https://skills.sh). The skill covers the CLI, MCP tools, JSX rendering, eval, and the running app's automation bridge. ## Tools OpenPencil currently registers 100+ shared design tools, plus MCP-only document and prompt operations when applicable. ### Document | Tool | Description | |------|-------------| | `open_file` | Open a `.fig` file for editing | | `close_file` | Close an open document tab; `unsaved: "save"` or `"discard"` decides what happens to unsaved changes | | `save_file` | Save the current document to a `.fig` file | | `new_document` | Create a new empty document | | `list_documents` | List open app documents/tabs and their pages | | `activate_document` | Bring a document tab to the front, optionally on a given page | ### History | Tool | Description | |------|-------------| | `undo` | Undo the newest change made through MCP or the CLI | | `redo` | Redo the newest change undone through MCP or the CLI | The history is shared with the person in the editor. `undo` and `redo` refuse when the newest step was made in the editor, so an agent never reverts the user's work. Each editing tool call is one undo step. An `eval` script is recorded against its target page, so edits it makes after switching `figma.currentPage` are not undoable. ### Settings | Tool | Description | |------|-------------| | `get_settings` | Read editor settings: appearance, snapping, canvas rendering, recovery, AI chat, and design check preferences | | `update_settings` | Change settings with a partial object shaped like `get_settings` output; invalid keys and values are rejected | Settings tools never expose credentials, AI models, MCP connections, storage, or tool access. The available keys are listed in [Controlling the App](/programmable/cli/app-control#settings). ### Read | Tool | Description | |------|-------------| | `get_selection` | Get the selected nodes, with their direct children by default; `depth` sets how many levels | | `get_page_tree` | Get the full node tree of the current page | | `get_current_page` | Get the current page name and ID | | `get_node` | Get detailed properties of a node by ID | | `find_nodes` | Find nodes by name pattern and/or type | | `get_components` | List all components in the document | | `list_pages` | List all pages | | `list_variables` | List design variables | | `list_collections` | List variable collections | | `list_fonts` | List fonts used in the current page | | `list_available_fonts` | List font families the current host can render | | `get_font_status` | Report requested faces, loaded sources, active substitutions, why an installed face could not be loaded, and affected nodes | | `page_bounds` | Get bounding box of all objects on the current page | | `node_bounds` | Get bounding box of a node | | `node_ancestors` | Get ancestor chain of a node | | `node_children` | Get direct children of a node | | `node_tree` | Get the subtree rooted at a node | | `node_bindings` | Get variable bindings on a node | ### Create | Tool | Description | |------|-------------| | `create_shape` | Create a shape (`FRAME`, `RECTANGLE`, `ELLIPSE`, `TEXT`, `LINE`, `STAR`, `POLYGON`, `SECTION`) | | `create_vector` | Create a vector node from a path string | | `create_slice` | Create an export slice | | `create_page` | Create a new page | | `render` | Render JSX to design nodes — create entire component trees in one call | | `create_component` | Convert a frame/group into a component | | `create_instance` | Create an instance of a component | | `create_slot` | Make a frame inside a main component a slot | | `set_behaviour` | Make a component behave as a Reka UI control, by its property and slot names; `null` removes it | | `get_behaviour` | Read a component's behaviour and what it still misses; without an ID, list every kind | | `node_to_component` | Convert an existing node into a component in-place | ### Modify | Tool | Description | |------|-------------| | `set_fill` | Set fill color (hex) | | `set_stroke` | Set stroke color, weight, alignment | | `set_effects` | Add shadow or blur effects | | `update_node` | Update position, size, opacity, corner radius, text, font | | `set_layout` | Set auto-layout (flexbox) — direction, spacing, padding, alignment | | `set_constraints` | Set resize constraints | | `set_rotation` | Set rotation angle in degrees | | `set_opacity` | Set opacity (0–1) | | `set_radius` | Set corner radius (uniform or per-corner) | | `set_minmax` | Set min/max width and height constraints | | `set_text` | Set text content of a `TEXT` node | | `set_font` | Set font family and weight | | `set_font_range` | Set font properties on a character range | | `set_text_resize` | Set text auto-resize mode (fixed/auto-width/auto-height) | | `set_visible` | Show or hide a node | | `set_blend` | Set blend mode | | `set_locked` | Lock or unlock a node | | `set_stroke_align` | Set stroke alignment (inside/center/outside) | | `set_text_properties` | Set text layout: alignment, auto-resize, text case, decoration, truncation | | `set_layout_child` | Configure auto-layout child: sizing, grow, alignment, absolute positioning | | `node_move` | Move a node to a new position | | `node_resize` | Resize a node | | `node_replace_with` | Replace a node with another node | | `arrange` | Align or distribute selected nodes | ### Structure | Tool | Description | |------|-------------| | `delete_node` | Delete a node | | `clone_node` | Duplicate a node | | `rename_node` | Rename a node | | `reparent_node` | Move a node into a different parent | | `select_nodes` | Select nodes by ID | | `group_nodes` | Group nodes | | `ungroup_node` | Ungroup a group | | `flatten_nodes` | Flatten nodes into a single vector | | `boolean_union` | Boolean union of two or more nodes | | `boolean_subtract` | Boolean subtraction | | `boolean_intersect` | Boolean intersection | | `boolean_exclude` | Boolean exclusion | ### Vector Path | Tool | Description | |------|-------------| | `path_get` | Get the path data of a vector node | | `path_set` | Set the path data of a vector node | | `path_scale` | Scale a vector path | | `path_flip` | Flip a vector path horizontally or vertically | | `path_move` | Translate a vector path | ### Export | Tool | Description | |------|-------------| | `export_image` | Export nodes as PNG, JPG, or WEBP. Returns base64-encoded image data | | `export_svg` | Export nodes as SVG markup | ### Viewport | Tool | Description | |------|-------------| | `viewport_get` | Get current viewport position and zoom level | | `viewport_set` | Set viewport position and zoom | | `viewport_zoom_to_fit` | Zoom viewport to fit specified nodes | ### Variables | Tool | Description | |------|-------------| | `get_variable` | Get a variable by ID or name | | `find_variables` | Find variables by name pattern or type | | `create_variable` | Create a new variable in a collection | | `set_variable` | Set a variable value in a mode | | `delete_variable` | Delete a variable | | `bind_variable` | Bind a variable to a node property | | `get_collection` | Get a variable collection by ID or name | | `create_collection` | Create a new variable collection | | `delete_collection` | Delete a variable collection | ### Analyze | Tool | Description | |------|-------------| | `analyze_colors` | Analyze color palette usage across the document | | `analyze_typography` | Analyze font/size/weight distribution | | `analyze_spacing` | Analyze gap and padding values | | `analyze_clusters` | Detect repeated patterns (potential components) | | `lint` | Check accessibility and consistency issues, with fixes and suggestions | | `lint_fix` | Apply safe lint fixes, and optionally the first suggestion of each finding | ### Diff | Tool | Description | |------|-------------| | `diff_create` | Patch that turns one node tree into another, as JSX attribute changes | | `diff_jsx` | Structural diff between two nodes as design JSX | | `diff_show` | Preview the patch that setting JSX attributes on a node would produce | | `diff_apply` | Apply a patch after checking the nodes still match its old values | | `diff_visual` | Pixel diff between two rendered nodes, returned as an image | ### Comments | Tool | Description | |------|-------------| | `get_comments` | Read the comments left on the canvas, open ones unless asked for resolved too, with their layer, page, and replies | | `add_comment` | Pin a comment on a layer or at a point of a page, in Markdown | | `reply_to_comment` | Answer a thread, which reopens it if it was resolved | | `resolve_comment` | Resolve a thread once its feedback is addressed, or reopen it | ### Navigation | Tool | Description | |------|-------------| | `switch_page` | Switch to a page by name or ID | ### Escape Hatch | Tool | Description | |------|-------------| | `eval` | Execute JavaScript with full Figma Plugin API access | Note: `eval` is available over stdio, but disabled in HTTP mode for security. --- --- url: 'https://openpencil.ink/programmable/ai-chat.md' description: Built-in AI assistant with 90+ tools for creating and modifying designs. --- # AI Chat Press ⌘J (Ctrl + J) to open the AI assistant. Describe what you want — it creates shapes, sets styles, manages layout, works with components, and analyzes your design. ## Setup The first time you open OpenPencil, guided setup asks what AI should help with and what you already use: a [coding agent](./coding-agents) such as Claude Code, Codex, Gemini CLI, or Pi in the desktop app, an API account, or a local or company server. It connects that access and assigns it to the **Design agent** and **Vision** roles, keeping anything you configured by hand. With OpenRouter you can sign in instead of pasting an API key. Skip it to start designing, and run it again from **Settings → AI & agents → Run guided setup**. To configure models by hand: 1. Open the AI chat panel (⌘J) 2. Click the settings icon 3. Add a model and configure its provider, model ID, credentials, and capabilities 4. Save the model and assign it to **Design agent** You can configure multiple reusable models and separately assign models for design work, reviews, fast tasks, and image input. Models using the same provider connection reuse its stored credential. The chat composer grows with multiline prompts and can pin the current canvas selection as explicit node context. Assistant messages show provider reasoning in collapsible sections and provide a per-response copy action. Image attachments remain available for visual references when a Vision model is configured. Streaming responses use a hardened Markdown renderer with Shiki-highlighted code blocks; unsafe link protocols and embedded data images are blocked. ## Step limit In **Settings → AI & agents → Chat**, set **Maximum steps per message** to a whole number from 1 to 1,000. The default is 50. Press Enter or leave the field to save a valid value; invalid drafts do not replace the saved preference. Higher limits allow longer tool-driven tasks but can increase latency and provider cost. The built-in AI captures this limit when each message starts. Stopping, remaining-step warnings, and the **Continue** action use that same budget. Changing it does not interrupt an ongoing request; the next message or continuation uses the new limit. A step is one model iteration and can include multiple tool calls. ACP and Pi agents manage their own limits. ## Tool access Open **Settings → Tool access** to choose which tools direct AI model connections can use. Search by name or description, expand read-only or side-effect groups, and toggle individual tools or an entire group. Group switches affect all tools in that group, not only search results. **Restore defaults** restores the default tool set: the core editing and inspection tools, the library tools (`get_components`, `list_libraries`, `insert_library_component`), comments, variables, `lint` and `lint_fix`, `export_image`, `list_pages`, and `create_page`. Extended tools such as `create_component` can be enabled individually. Preferences are saved locally and apply to the next message, including in an existing conversation. They do not change an already-running request. Enabling many tools increases the schemas sent to the model. The **Local MCP** segment has independent settings for clients connected to OpenPencil's MCP server, including ACP and Pi agents. Restart the server and reconnect stdio clients after changing those settings. Remote MCP connections, WebMCP access, and Pi's shell/filesystem permissions remain separate. Tool toggles control which tools are offered, not which operations scripts may perform. An enabled `eval` or other script-capable tool can perform design operations whose dedicated tools are disabled; these switches are not a sandbox. ## Saved Conversations Use **Conversation history** to return to a saved chat, start a **New chat**, or rename or delete a conversation. History and attachment previews are stored locally; **All chats** lets you browse transcripts from other documents. A conversation belonging to another document is read-only until you open that document. A saved agent transcript is not a guarantee that its external agent session can resume: when resumption is unavailable, start a new chat. Local history is not cloud synchronization or a backup. In the Chat settings beside the model overview, choose whether reasoning is **Collapsed by default**, **Expand while thinking**, or **Expanded by default**. Disclosure animations follow the app's reduced-motion preference. Expanding older reasoning does not force the conversation to scroll to the bottom. ## Supported Providers | Provider | Models | Setup | | ------------------------ | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | **OpenRouter** | Claude, GPT, Gemini, DeepSeek, Qwen, and others | API key from [openrouter.ai](https://openrouter.ai) | | **Anthropic** | Claude Sonnet 4.6, Claude Opus 4.6 | API key from [console.anthropic.com](https://console.anthropic.com) | | **OpenAI** | GPT-5.3 Codex, GPT-4.1, o3, o4-mini | API key from [platform.openai.com](https://platform.openai.com) | | **Google AI** | Gemini 3.1 Pro, Gemini 3 Flash | API key from [aistudio.google.dev](https://aistudio.google.dev) | | **Z.ai** | GLM-5.1, GLM-5, GLM-4.7, GLM-4.5 family | API key from [docs.z.ai](https://docs.z.ai/devpack/quick-start) | | **MiniMax** | MiniMax M3, M2.7, M2.7-highspeed, M2.5, M2.1 | API key from [platform.minimax.io](https://platform.minimax.io/user-center/basic-information/interface-key) | | **OpenAI-compatible** | Any endpoint with OpenAI API format | Custom base URL + key. Supports Completions and Responses API toggle. | | **Anthropic-compatible** | Any endpoint with Anthropic API format | Custom base URL + key | No backend, no subscription — your key talks directly to the provider. Browser requests are subject to each provider's CORS policy, and model deployments vary in how reliably they stream tool calls. See [BYOK provider and model compatibility](./byok-provider-compatibility) for measured results and reproduction steps. ## External MCP connections Desktop ACP agents can also use trusted remote [Model Context Protocol](https://modelcontextprotocol.io/) servers. In **Settings → MCP**, under MCP connections, add a named Streamable HTTP endpoint, optionally save a bearer token, and enable the connection. OpenPencil stores the token in the configured credential backend rather than ordinary settings and resolves it only when starting the ACP session. Remote servers must use HTTPS. Loopback HTTP endpoints are accepted for local development. Review and trust a server before enabling it: its tools may read external data or perform actions with the credentials you provide. OpenPencil's built-in design MCP server remains attached automatically and does not need to be added here. ## What It Can Do The configurable tool catalog covers these categories; the tools offered to a model depend on your Tool access settings: * **Create** — frames, shapes, text, components, pages. Renders JSX for complex layouts. * **Style** — fills, strokes, effects, opacity, corner radius, blend modes. * **Layout** — auto-layout, grid, alignment, spacing, sizing. * **Components** — create components, instances, component sets, and slots. Manage overrides. Give components Reka UI behaviours so they work in preview. * **Variables** — create/edit variables, collections, modes. Bind to fills. * **Query** — find nodes, XPath selectors, read properties, list pages, fonts, selection. * **Inspect** — `get_jsx` for JSX roundtrip view, `diff_create` and `diff_jsx` for structural diffs, `diff_visual` for pixel diffs, `describe` for semantic role and design issue detection. * **Analyze** — color palette, typography audit, spacing consistency, cluster detection. * **Export** — PNG, SVG, JSX with Tailwind classes. Vision-based verification via `export_image`. * **Vector** — boolean operations, path manipulation. ## Visual Verification The assistant can verify its work visually. With `export_image`, on by default, it can capture a screenshot after creating or modifying designs and checks the result against the original request. This catches layout issues, missing elements, and color mismatches that text-only responses would miss. `diff_visual`, enabled by default, compares an edited node with a reference copy and returns the changed pixels and region, so the assistant can confirm an edit stayed within its target. ## Stopping a reply **Stop** ends a reply at once. A tool call already running finishes, and its edit counts as part of the reply; calls still waiting for their turn change nothing. The reply is marked *Stopped*, its reasoning and any unfinished calls stop showing as running, and your next message tells the assistant whether those calls changed the document. ## Undoing the assistant's edits Each tool call that changes the document is one step in Edit → Undo. Undoing it with ⌘Z marks the call *Undone* in the chat. A reply with only some edits undone says how many; one with all of them undone is dimmed and marked *Changes reverted*. Redo removes the marks. The marks are saved with the conversation. **Revert changes** on a reply undoes its edits while nothing has been edited since, and **Restore changes** brings undone edits back while Redo still can. **Regenerate** and editing your last message undo the replaced reply's edits first when they can. Your next message tells the assistant which of its edits you undid or redid, so it does not build on edits that are gone. If you undo the assistant's edits while it is still working, it hears about it with its next tool call. ## Following the assistant With **Follow agents** on, the crosshair button at the top of the AI panel, your view follows the assistant while it works, including to another page when it edits layers there. Following ends when the reply finishes, is stopped, or fails. Clicking, scrolling, zooming, switching pages, or Escape stops following yourself. ## Example Prompts * "Create a card with a title, description, and a blue button" * "Make all buttons on this page use the same border radius" * "What fonts are used in this file?" * "Change the background of the selected frame to a gradient from blue to purple" * "Export the selected frame as SVG" * "Find all text nodes with font size less than 12" * "Describe the selected component — what role does it look like?" * "Show me the JSX for this frame" * "Make this component a switch that I can try in preview" ## Tips * Select nodes before asking — the assistant knows what's selected. * Be specific about colors, sizes, and positions for precise results. * The assistant can modify multiple nodes in one message. * You can browse other pages while a reply runs: the assistant keeps working on the page where the message started, and its previews show when you return. While you're away, the chat says which page it is working on, with **Go to page** to return. * Undo anything you don't like with Edit → Undo or **Revert changes**; the chat and the assistant both see it. * All layout is recomputed automatically after each tool execution. --- --- url: 'https://openpencil.ink/programmable/coding-agents.md' --- # Coding agents The OpenPencil desktop app can use a coding agent you already have — Claude Code, Codex, Gemini CLI, or [Pi](#pi) — as its design agent. The agent runs on your computer with your own subscription and chooses its own model; OpenPencil starts it when you send a message, through the [Agent Client Protocol](https://agentclientprotocol.com/) or, for Pi, its Harness companion, and gives it the canvas tools through OpenPencil's local [MCP server](./mcp-server). Coding agents need the desktop app; the browser cannot start programs on your computer. They take only the **Design agent** role. Visual review, plan reviews, and fast background work need an API model, which guided setup can add for you. ## What you need 1. The [OpenPencil desktop app](/download). 2. The agent's ACP program, installed globally (see each agent below). 3. OpenPencil's MCP server, installed globally. Use the version that matches your app: ```sh npm install -g @open-pencil/mcp ``` 4. The agent signed in to your account with its own command-line tool. Then open **Settings → AI & agents → Run guided setup**, choose the agent under **Coding agents on this computer**, and check that both the agent and the MCP server show as installed. **Check again** looks again after you install something. ### Let your agent set itself up If you already use one of these agents in a terminal, guided setup can do the typing for you: press **Copy setup prompt** on the agent's card and paste the prompt into the agent. It asks the agent to install its ACP program and the MCP server, confirm both are on your `PATH`, and make sure you are signed in. ## Claude Code ```sh npm install -g @agentclientprotocol/claude-agent-acp ``` Sign in by running `claude` and using `/login`. The ACP program uses the same account as Claude Code. ## Codex ```sh npm install -g @agentclientprotocol/codex-acp ``` Sign in with `codex login`. ## Gemini CLI ```sh npm install -g @google/gemini-cli ``` Gemini CLI speaks ACP itself, so there is no separate program. Run `gemini` once and choose a sign-in method. ## Pi Pi runs through OpenPencil's Harness companion instead of ACP. It needs Node.js 22.15 or later, or Bun. Guided setup installs the companion with one click; to install it yourself, use the version that matches your app: ```sh npm install -g @open-pencil/harness ``` OpenPencil uses the providers you signed in to in Pi and Pi's default model, so there is no key to paste. Sign in by running `pi` and using `/login`, and pick a default model with `/model`. To use another model in OpenPencil, enter it as `provider/model` in the Pi model's settings. An [AI Gateway](https://vercel.com/ai-gateway) key is optional; when you save one, Pi uses the gateway instead of your Pi sign-ins. OpenPencil reads only Pi's `settings.json` for the default model; your credentials stay with Pi. Your Pi extensions, themes, and prompt templates are not loaded into OpenPencil's sessions. Pi keeps a conversation's context while it runs; after Pi or OpenPencil restarts, the conversation continues in a fresh Pi session, because Pi's workspace lives in memory. ## Troubleshooting * **Shown as not found after installing.** Apps started from the Dock or Start menu do not see every folder your terminal adds to `PATH`. OpenPencil also looks in common global folders for npm, Bun, Volta, mise, and Homebrew; if your package manager installs elsewhere, add that folder to your login shell's `PATH` and restart OpenPencil. * **Shown as update needed.** The MCP server or Pi's companion does not match the app version. Press the update button, or run the command guided setup shows with the package manager that installed it, then press **Check again**. A chat that needs it says so and offers **Run guided setup**. * **The agent starts but cannot edit the canvas.** The MCP server is missing or not running. Install the matching `@open-pencil/mcp` version and restart OpenPencil. * **The agent asks you to sign in.** Sign in with the agent's own command-line tool as described above, then send your message again. --- --- url: 'https://openpencil.ink/programmable/byok-provider-compatibility.md' --- # BYOK provider and model compatibility OpenPencil has no backend. When you bring your own API key, the browser talks to the provider directly — so a provider only works in the **web build** if it sets CORS headers correctly, and a model is only useful for the AI chat panel if its **streaming tool calls** are well-formed. Both of those vary a lot between providers, and neither is documented by the providers themselves. This page records what has actually been measured. **This is a living list — please add to it.** See [Contributing results](#contributing-results). Results are dated because providers change behaviour without announcement. Treat anything older than a few months as unverified. *** ## Provider CORS support Whether the **web build** can reach the provider at all. The desktop build bypasses CORS entirely (it routes through `tauriFetch`), so a ❌ here still works on desktop. | Provider | Base URL | Browser | Notes | Tested | | -------------------- | ------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------- | ---------- | | OpenRouter | `https://openrouter.ai/api/v1` | ✅ | `ACAO: *` | 2026-07-30 | | OpenAI | `https://api.openai.com/v1` | ✅ | `ACAO: *` | 2026-07-30 | | Groq | `https://api.groq.com/openai/v1` | ✅ | `ACAO: *` | 2026-07-30 | | Google | `https://generativelanguage.googleapis.com` | ✅ | echoes origin | 2026-07-30 | | DeepSeek | `https://api.deepseek.com` | ✅ | echoes origin | 2026-07-30 | | Z.ai | `https://api.z.ai/api/anthropic` | ✅ | echoes origin | 2026-07-30 | | MiniMax | `https://api.minimax.io/v1` | ✅ | echoes origin | 2026-07-30 | | Nebius AI Studio | `https://api.studio.nebius.com/v1` | ✅ | `ACAO: *` | 2026-07-30 | | Nebius Token Factory | `https://api.tokenfactory..nebius.com/v1` | ✅ | `ACAO: *`; catalog differs per region | 2026-07-30 | | Anthropic | `https://api.anthropic.com` | ✅ | Requires `anthropic-dangerous-direct-browser-access: true`; OpenPencil sends it in the web app | 2026-07-30 | | Scaleway | `https://api.scaleway.ai//v1` | ⚠️ | `ACAO: *` on success, **absent on errors** — a bad key reads as a network failure | 2026-07-31 | | TensorX | `https://api.tensorx.ai/v1` | ⚠️ | `ACAO: *` on success, **absent on errors** (401/403/500). Reported to their support | 2026-07-31 | ### The common failure A provider that answers the `OPTIONS` preflight correctly but omits `Access-Control-Allow-Origin` on the **actual response** will fail in the browser with a generic `Failed to fetch`. OpenPencil surfaces that as *"Could not reach this endpoint from the browser"* — which is indistinguishable from a wrong API key. **Always verify with curl before assuming the app is at fault.** ### Test the success path, not just an error Several providers (Scaleway, TensorX) send CORS headers on a `200` but **not** on `401`/`403`/`500` — errors are rejected at a gateway that never adds the header. So an unauthenticated probe tells you nothing: it looks identical to a provider that is genuinely broken. Always test with a **valid key and a successful response**. Do not infer success-path CORS from an error response: providers can route successful and failed requests through different middleware. Record error-response CORS separately so users know whether an expired key, exhausted quota, or bad model name will surface as a useful API error or an opaque network failure. *** ## Model tool-calling quality The AI chat panel is an agent loop, so a model is only usable if it reliably emits **streaming** tool calls that the AI SDK can parse. Two independent things go wrong: * **`id` correctness** — if a streamed `tool_calls` delta introduces a new index without an `id`, `@ai-sdk/openai` throws `Expected 'id' to be a string.` and the stream dies. * **Argument validity** — arguments are streamed in fragments and concatenated. If the fragments are misrouted, the result isn't valid JSON and the tool silently never fires. Cost matters as much as correctness here. The chat panel is an agent loop that runs up to `MAX_AGENT_STEPS` (50) and resends the tool schemas on every step, so it is input-token heavy and a high per-token price compounds fast. Vision is measured by sending a solid-colour image and asking the model to name it. Tool-call and vision results are measurements; context limits and prices are catalog metadata, identified below with their source dates. **Record the output-token budget with every result** — it changes the outcome. Scaleway rows below were measured at `max_tokens: 16384`; Nebius and TensorX rows at 2000, so their "emits calls" figures are not directly comparable and any failure there may be starvation rather than incapacity. Most results below used provider-default reasoning settings. The separate reasoning comparison explicitly identifies each override it tested. Future rows must record any non-default `reasoning_effort` or `chat_template_kwargs.enable_thinking` value. The `Tools` cells use `prompts, bad ID deltas, valid arguments`: for example, `3/3 prompts · 0/238 bad IDs · 6/6 valid args`. A prompt passes when it emits at least one tool call; ID counts cover inspected streamed tool-call deltas; argument counts cover completed calls whose concatenated argument fragments parse as JSON. | Model | Provider | /1M in–out | Ctx¹ | Tools | Vision | Verdict | Tested | | ------------------------------------- | -------- | -------------- | ---- | ----------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------- | ---------- | | `mistral-small-3.2-24b-instruct-2506` | Scaleway | €0.15 – €0.35 | 128k | ✅ 3/3 prompts · 0/238 bad IDs · 6/6 valid args | ✅ | **Recommended** — cheapest with vision, fully clean | 2026-07-31 | | `gemma-4-26b-a4b-it` | Scaleway | €0.25 – €0.50 | 256k | ✅ 3/3 prompts · 0/77 bad IDs · 8/8 valid args | ✅ | Same but 2× the context — pick this for large documents | 2026-07-31 | | `gpt-oss-120b` | Scaleway | €0.15 – €0.60 | 128k | ✅ 3/3 prompts · 0/118 bad IDs · 3/3 valid args | ❌ | Cheap and clean, but text-only | 2026-07-31 | | `qwen3.6-35b-a3b` | Scaleway | €0.25 – €1.50 | 128k | ✅ 3/3 prompts · 0/106 bad IDs · 9/9 valid args | ✅ | Clean; heavy reasoner, needs the larger budget | 2026-07-31 | | `qwen3.5-397b-a17b` | Scaleway | €0.60 – €3.60 | 256k | ✅ 3/3 prompts · 0/92 bad IDs · 10/10 valid args | ✅ | Most calls per turn; strongest but priciest here | 2026-07-31 | | `mistral-medium-3.5-128b` | Scaleway | €1.50 – €7.50 | 256k | — not tested | ✅ | Vision confirmed; tool calling unverified | 2026-07-31 | | `glm-5.2` | Scaleway | €1.80 – €5.50 | 256k | — not tested | ❌ | API rejects images: "not a multimodal model" | 2026-07-31 | | `moonshotai/Kimi-K2.7-Code` | Nebius | $0.95 – $4.00 | 262k | ✅ 3/3 prompts · 0/114 bad IDs · 4/4 valid args² | ❌ | Clean, but pricier than the Scaleway set | 2026-07-30 | | `moonshotai/Kimi-K3` | Nebius | $3.00 – $15.00 | 1M | ✅ 3/3 prompts · ID coverage unavailable³ · 8/8 valid args² | ❌ | Flawless, but ~20× the cost of `gpt-oss-120b` | 2026-07-30 | | `moonshotai/kimi-k3` | TensorX | — | — | ⚠️ 2/3 prompts · ID coverage unavailable³ · 7/7 valid args² | — | One prompt did not emit a call | 2026-07-30 | | `openai/gpt-oss-120b` | Nebius | $0.15 – $0.60 | 128k | ❌ final argument chunk routed to the wrong index² | ❌ | Broken on Nebius only — same model is fine on Scaleway | 2026-07-30 | | `moonshotai/Kimi-K2.6` | Nebius | — | — | ⚠️ 1/3 prompts² | — | Finished on `stop`, not `length` — likely a real failure, but re-test at 16k before trusting this row | 2026-07-30 | ¹ Nominal catalog context limit from models.dev, retrieved 2026-07-31; not measured here.\ ² Measured with `max_tokens: 2000`; re-test failures at 16384 or the highest supported budget.\ ³ The original notes did not preserve the inspected ID-delta count, so ID coverage is unavailable. ### Auto-lookup via models.dev [models.dev](https://models.dev) publishes a machine-readable catalog at (~3 MB, 176 providers, including `scaleway`, `nebius`, `groq` and `openrouter`). Each model carries `tool_call`, `attachment` (vision), `reasoning`, `cost.input`/`cost.output`, `limit.context` and `modalities`. ```bash curl -s https://models.dev/api.json \ | jq '.scaleway.models["gemma-4-26b-a4b-it"] | {tool_call, attachment, reasoning, cost, limit}' ``` **Use it to prefill a row, not to fill in the measured columns.** Checked against the models above, it was wrong on two of seven: | Model | models.dev | Measured | | ---------------------------------- | ------------ | -------------------------------------------------- | | `scaleway/gpt-oss-120b` | vision true | Refuses: "I don't have the ability to view images" | | `scaleway/mistral-small-3.2-24b-…` | vision false | Correctly names the colour in an image | It also lists `nebius/openai/gpt-oss-120b` as vision `false` while marking the Scaleway copy `true` — the same model, contradicting itself — and reports `tool_call: true` for every model here, including `qwen3.6-35b-a3b` (silent on 2/3 prompts at `max_tokens: 2000`, but 3/3 at 16384) and Nebius's `gpt-oss-120b` (broken by the deployment bug below). That's the general shape of it: models.dev describes a model's **nominal** capabilities, while what breaks in practice is a property of the **deployment**. Catalog prices and context windows can prefill a row, but must be labeled as metadata with a source and retrieval date; tool-calling and vision require live tests. Prices shown in the table were transcribed from public pricing aggregators on 2026-07-31 rather than measured or verified against provider invoices. Because the original notes did not preserve per-row source URLs, treat every price as unverified historical metadata and confirm it against the provider's current pricing page before making a decision. New rows must include a source URL and a separate price-as-of date. The same model can behave differently on different providers — `gpt-oss-120b` is broken on Nebius and clean on Scaleway. **Record the provider alongside the model; a model row without one is not reproducible.** Note that "emits calls" counts prompts that produced at least one tool call, not how many. Models differ in style: `qwen3.5-397b-a17b` issued 3–4 parallel calls per turn where `gpt-oss-120b` issued one. In a 50-step agent loop, one-call-then-observe is legitimate rather than weaker, so don't read the count as a quality ranking. ### Known issue: vLLM misroutes the final argument chunk On some vLLM deployments the closing `}` of a tool call's arguments is emitted under `index + 1` with no `id`: ``` 1 [{"index":0,"id":"chatcmpl-tool-…","function":{"arguments":"","name":"create_node"},"type":"function"}] … deltas 2–11 stream the arguments, all index 0 12 [{"index":1,"function":{"arguments":"}"}}] ← wrong index, no id ``` This both crashes the stream *and* leaves call 0's arguments unterminated. Do not blindly attach an argument-only delta to the most recently opened call: concurrent or interleaved calls make that ambiguous and can corrupt another call. A client-side workaround may reattach the fragment only when exactly one serialized call is open; it must reject ambiguous streams. Synthesizing a missing `id` alone converts a visible crash into a silent no-op. Models that emit one complete tool call per chunk (Kimi-K3 on every provider tested) cannot hit this bug at all. **This is a deployment bug, not a model bug.** `gpt-oss-120b` misroutes on Nebius but streams cleanly on Scaleway — 0 violations across 135 deltas. Before writing a client-side workaround, check whether another provider serving the same model is unaffected. ### Known issue: reasoning models starve on a small output budget A reasoning model can spend its **entire** output budget thinking and never emit a tool call. The result is `finish_reason: length` with zero tool calls — no error, no partial output. From the outside it is indistinguishable from a model that simply can't call tools. Measured on `qwen3.6-35b-a3b`, same prompts, only `max_tokens` changed: | `max_tokens` | Result | | ------------ | ----------------------------------------------- | | 2000 | 1/3 prompts — the rest hit `length` mid-thought | | 16384 | 3/3 prompts, 0 bad ids, 9/9 valid args | One prompt needed 10,417 characters of reasoning before its first tool call. **This page originally scored two models as broken purely because the probe used 2000 tokens.** If a model produces no tool calls, check `finish_reason` before concluding anything — `length` means starved, `stop` means it genuinely declined. `reasoning_effort` is the other lever, though supported values are provider-specific: | Setting | Reasoning | Tool calls | | ---------------------------------------------------- | --------- | ---------- | | default | 6,583 ch | ❌ 0 | | `"reasoning_effort": "none"` | 0 ch | ✅ 3 | | `"reasoning_effort": "low"` | 5,088 ch | ✅ 3 | | `"chat_template_kwargs": {"enable_thinking": false}` | 5,133 ch | ✅ 3 | Only `"none"` actually eliminated reasoning on Scaleway; `"low"` had no measurable effect. OpenPencil cannot send this today — see [#454](https://github.com/open-pencil/open-pencil/issues/454). ### Known issue: reasoning models and the connection test Some models (e.g. Kimi-K2.6) spend output tokens on `reasoning` before `content`. The connection test uses `maxOutputTokens: 1`, so `content` comes back `null`. The test still passes because it only checks that the request didn't throw — but don't tighten it to assert on returned text, or reasoning models will fail it spuriously. *** ## Contributing results Add a row, keep it dated, and say how you tested. Negative results are as useful as positive ones. ### 1. Test CORS for an OpenAI-compatible endpoint The following recipes use the OpenAI-compatible `/chat/completions` contract, Bearer authentication, and request body. Do not use them unchanged for a provider's native API: adapt the endpoint, authentication, version headers, and body first. For example, Anthropic's native API uses `/v1/messages`, `x-api-key`, `anthropic-version`, and `anthropic-dangerous-direct-browser-access: true`. Run these checks from a Bash-compatible shell. Curl is not subject to CORS, so this inspects the headers a browser would enforce. A provider must pass both checks: the preflight must allow the origin, `POST` method, and requested headers, and a successful POST must include `Access-Control-Allow-Origin`. Read the key without placing it in shell history or the curl process arguments. The temporary curl configuration is owner-readable only and is removed on exit. ```bash set -euo pipefail BASE="https://api.example.com/v1" MODEL="MODEL" ORIGIN="https://openpencil.ink" read -rsp "API key: " API_KEY; printf '\n' CURL_CONFIG="$(mktemp)" PREFLIGHT_HEADERS="$(mktemp)" POST_HEADERS="$(mktemp)" chmod 600 "$CURL_CONFIG" trap 'rm -f "$CURL_CONFIG" "$PREFLIGHT_HEADERS" "$POST_HEADERS"' EXIT printf 'header = "Authorization: Bearer %s"\n' "$API_KEY" > "$CURL_CONFIG" unset API_KEY origin_allowed() { local file="$1" local value value="$(awk 'BEGIN { IGNORECASE=1 } /^access-control-allow-origin:/ { sub(/^[^:]+:[[:space:]]*/, ""); sub(/\r$/, ""); print; exit }' "$file")" [[ "$value" == "*" || "$value" == "$ORIGIN" ]] } # Browser preflight: verify the origin, POST method, and both requested headers. preflight_status="$(curl -sS -D "$PREFLIGHT_HEADERS" -o /dev/null -w '%{http_code}' \ -X OPTIONS "$BASE/chat/completions" \ -H "Origin: $ORIGIN" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: authorization,content-type")" [[ "$preflight_status" =~ ^2[0-9][0-9]$ ]] origin_allowed "$PREFLIGHT_HEADERS" grep -iqE '^access-control-allow-methods:.*(^|[,[:space:]])POST([,[:space:]]|$)' "$PREFLIGHT_HEADERS" grep -iqE '^access-control-allow-headers:.*(^|[,[:space:]])authorization([,[:space:]]|$)' "$PREFLIGHT_HEADERS" grep -iqE '^access-control-allow-headers:.*(^|[,[:space:]])content-type([,[:space:]]|$)' "$PREFLIGHT_HEADERS" # Successful response: use a valid key and model, then verify the origin header again. post_status="$(curl --config "$CURL_CONFIG" -sS -D "$POST_HEADERS" -o /dev/null -w '%{http_code}' \ -X POST "$BASE/chat/completions" \ -H "Origin: $ORIGIN" \ -H "content-type: application/json" \ -d "{\"model\":\"$MODEL\",\"max_tokens\":5,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}")" [[ "$post_status" =~ ^2[0-9][0-9]$ ]] origin_allowed "$POST_HEADERS" ``` These commands exit non-zero unless both responses satisfy the browser contract. Also repeat the POST with an invalid key and record error-response CORS separately. It does not prove whether successful requests work, but it determines whether users see useful authentication errors or only an opaque network failure. ### 2. Test tool-call streaming Ask the model to make several tool calls and save the raw SSE stream. Use at least 16384 output tokens when supported; if the endpoint accepts less, record its highest supported budget. ```bash STREAM_FILE="$(mktemp)" HTTP_STATUS_FILE="$(mktemp)" trap 'rm -f "$CURL_CONFIG" "$PREFLIGHT_HEADERS" "$POST_HEADERS" "$STREAM_FILE" "$HTTP_STATUS_FILE"' EXIT curl --config "$CURL_CONFIG" -sS -N -X POST "$BASE/chat/completions" \ -H "content-type: application/json" \ -d "{\"model\":\"$MODEL\",\"stream\":true,\"max_tokens\":16384, \"messages\":[{\"role\":\"user\",\"content\":\"Draw a frog: green ellipse body, two white eye circles, a mouth line. Use create_node for each.\"}], \"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"create_node\",\"description\":\"Create a node on the canvas\", \"parameters\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"ELLIPSE\",\"RECTANGLE\",\"LINE\",\"TEXT\"]}, \"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"},\"width\":{\"type\":\"number\"},\"height\":{\"type\":\"number\"}, \"fill\":{\"type\":\"string\"}},\"required\":[\"type\",\"x\",\"y\"]}}}]}" \ -o "$STREAM_FILE" -w '%{http_code}' > "$HTTP_STATUS_FILE" [[ "$(cat "$HTTP_STATUS_FILE")" =~ ^2[0-9][0-9]$ ]] ``` Parse each SSE event as JSON, group fragments by tool-call index, require an ID on the first delta for each index, and validate the concatenated arguments: ```bash STREAM_FILE="$STREAM_FILE" node <<'JS' const fs = require('node:fs') const calls = new Map() const finishReasons = new Set() let malformedFirstDeltas = 0 for (const line of fs.readFileSync(process.env.STREAM_FILE, 'utf8').split(/\r?\n/)) { if (!line.startsWith('data:')) continue const data = line.slice(5).trim() if (!data || data === '[DONE]') continue const event = JSON.parse(data) const choice = event.choices?.[0] if (choice?.finish_reason) finishReasons.add(choice.finish_reason) for (const call of choice?.delta?.tool_calls ?? []) { const current = calls.get(call.index) if (!current) { if (typeof call.id !== 'string' || !call.id) malformedFirstDeltas++ calls.set(call.index, { id: call.id, arguments: call.function?.arguments ?? '' }) } else { current.arguments += call.function?.arguments ?? '' } } } let invalidArguments = 0 for (const [index, call] of calls) { try { JSON.parse(call.arguments) } catch { invalidArguments++ console.error(`tool call ${index}: invalid arguments`) } } console.log({ calls: calls.size, finishReasons: [...finishReasons], malformedFirstDeltas, invalidArguments }) if (!calls.size) { console.error( finishReasons.has('length') ? 'no tool calls: output budget exhausted; retry with a larger budget' : 'no tool calls: model finished without calling a tool' ) process.exitCode = 1 } if (malformedFirstDeltas || invalidArguments) process.exitCode = 1 JS ``` Use more than one prompt. Failures are often intermittent and can depend on where JSON lands on a chunk boundary. Also record `finish_reason`; a `length` result requires a larger-budget retest before it can be classified as a tool-calling failure. ### 3. Add your row Include the compatibility test date, exact model ID, base URL pattern or regional hostname, output-token budget, reasoning configuration, and a one-line verdict. If something is broken, say what the failure looks like from the user's side — that's what makes the row actionable. Include the provider's list price, its source URL, and a separate price-as-of date. A model that streams flawlessly but costs 25× the alternative is not the right default for an agent loop, and a table without price provenance goes stale silently. **Record the `max_tokens` you used.** A reasoning model starved of output budget produces no tool calls and looks broken. Two models on this page were wrongly marked "avoid" for exactly that reason. Use at least 16384 when supported, and check `finish_reason` before recording a failure. ### A note on regional catalogs Some providers serve different models per region behind different hostnames. Nebius Token Factory, for example, carries Kimi-K3 on `eu-west2` but not on `us-central1`. Always record the exact base URL you tested — "provider X doesn't have model Y" is only true for the endpoint you checked. *** ## Security note In the browser, credentials default to encrypted IndexedDB persistence so users do not have to re-enter them; Settings also offers session-only storage, which keeps secrets in memory until the tab closes. The desktop build uses the OS credential store. Browser scripts running on the origin can still use a credential in place, so prefer scoped keys with spend caps where the provider offers them. --- --- url: 'https://openpencil.ink/programmable/collaboration.md' description: 'Real-time collaborative editing via P2P WebRTC — no server, no account.' --- # Collaboration Edit designs together in real time. Peers connect directly — no server relays your data, no account required. ## Sharing a Room 1. Click the share button in the top-right corner 2. Click **Share this file** — the link (`app.openpencil.ink/share/`) is copied 3. Send it to your collaborators Only Share puts a document into a room: the tab you share from becomes the room's tab and stays tied to its file. Anyone with the link can join. ## Joining a Room Open the link, or paste it (or just the room ID) into **Join** in the share panel or **Join room…** on Home. The room opens in a tab of its own, so the documents you already have open are never changed. On a computer, a browser also offers **Open in desktop app**, which opens the room in OpenPencil through an `openpencil://join` link. You join right away under a generated name such as *Teal Fox*; set your own in the share panel or in Settings, and it is used in every room. Rooms are not stored on a server: the file lives on the devices of the people who have been in the room, so a room tab opens its document only while one of them is online. Until then it says it is waiting, explains why, and opens the file as soon as someone who has it joins. A room you have been in before opens from this device's copy right away and syncs your changes when others return. **Leave room** in the share panel ends your part in the room. A tab that shared its document goes back to being that document; a tab that joined keeps the room's file as a local unsaved copy you can save. Each room tab keeps its own connection, so you can be in several rooms at once. ## What Syncs * **Document changes** — every edit (shapes, text, properties, layout) syncs instantly * **Cursors** — see where each collaborator is pointing, with their name and color * **Selections** — highlighted selections are visible to everyone * **Agents** — the built-in AI chat, ACP and Pi harness chats, and every connected MCP client appear as cursors at the layers they read or edit, each outlined label showing a sparkle and a callsign such as *Fern*. While the chat streams JSX, its cursor moves through the elements as they appear and outlines them. The cursor and outline have the color of the person running the agent, so you can tell whose agent it is. Only its name, kind, model, status, page, position, and edited layers are shared, never prompts or replies. ## Follow Mode Click a collaborator's avatar in the top bar to follow their viewport. Your canvas pans and zooms to match their view, and a frame in their color with a “Following …” bar shows whom you follow. Click the avatar again, press Esc, or click, scroll, zoom, or switch pages yourself to stop. Your own agents, the AI chat and MCP clients such as Claude Code or Cursor, are followed automatically while they work, so what they edit stays in view. Turn this off with the crosshair button at the top of the AI panel. If you stop following an agent while it works, it is left alone until it finishes and followed again on its next run. An avatar counts the agents that person runs. Hover over it to see each agent, what it is doing, and on which page, and click **Follow** next to an agent to keep the page and layers it is editing in view; following continues between its replies and stops when it leaves. The button after the avatars lists everyone in the room with their agents, and works from the keyboard. Your own avatar lists your agents — click one to rename it — and has **Leave room**. The share panel lists everyone in the room with the agents they run, what each agent is doing, and on which page. Follow an agent the same way to keep the page and layers it is editing in view; following continues between its replies and stops when it leaves. Double-click one of your own agents to rename it. ## How It Works Peers connect directly via WebRTC — your design data goes straight from browser to browser, never through a central server. The document state uses a CRDT (conflict-free replicated data type), so concurrent edits merge automatically without conflicts. Moving and reordering layers merges too. Each layer remembers every parent it has been moved into and its position among its siblings, and every peer works out the same layer tree from that history ([Evan Wallace's tree CRDT](https://madebyevan.com/algos/crdt-mutable-tree-hierarchy/)). Moves, reorders, and new layers from different people all apply; if two people move the same layer at once, one move wins on every peer. When moves made at the same time would put two layers inside each other, the later move is undone, and a layer whose new parent was deleted meanwhile returns to where it was. Everyone in a room needs a version of OpenPencil that records the layer tree the same way; versions that record it differently do not see each other's rooms. The room persists locally — if you refresh the page, you rejoin it automatically with the same state. ## Tips * Works in the browser and the desktop app * Room IDs are cryptographically random — only people with the link can join * Stale cursors are cleaned up automatically when someone disconnects --- --- url: 'https://openpencil.ink/reference/keyboard-shortcuts.md' description: >- Figma-compatible keyboard shortcut reference for OpenPencil tools, editing commands, view controls, and layer actions. --- # Keyboard Shortcuts Full Figma-compatible shortcut map. ✅ = implemented. ::: tip Symbol Legend ⌘ = Cmd (Mac) / Ctrl (Win/Linux) · ⇧ = Shift · ⌥ = Alt/Option · ⌃ = Ctrl · ⌫ = Backspace ::: ## Tools | Key | Tool | Status | |-----|------|--------| | V | Move/Select | ✅ | | K | Scale | 🔲 | | H | Hand | ✅ | | F or A | Frame | ✅ | | ⇧S | Section | ✅ | | R | Rectangle | ✅ | | O | Ellipse | ✅ | | L | Line | ✅ | | — | Polygon | ✅ (flyout only) | | — | Star | ✅ (flyout only) | | ⇧L | Arrow | 🔲 | | P | Pen | ✅ | | ⇧P | Pencil | ✅ | | T | Text | ✅ | | C | Comment | ✅ | | I | Eyedropper | 🔲 | ## File | Shortcut | Action | Status | |----------|--------|--------| | ⌘N | New Tab | ✅ | | ⌘T | New Tab | ✅ | | ⌘O | Open File | ✅ | | ⌘W | Close Tab | ✅ | | ⌘S | Save | ✅ | | ⇧⌘S | Save As | ✅ | | ⇧⌘E | Export… | ✅ | ## Edit | Shortcut | Action | Status | |----------|--------|--------| | ⌘Z | Undo | ✅ | | ⇧⌘Z | Redo | ✅ | | ⌘X | Cut | ✅ | | ⌘C | Copy | ✅ | | ⌘V | Paste | ✅ | | ⇧⌘V | Paste Over Selection | 🔲 | | ⌘D | Duplicate | ✅ | | ⌫ | Delete | ✅ | | ⌘A | Select All | ✅ | | ⇧⌘A | Select Inverse | ✅ | | ⇧⌘C | Copy as PNG | ✅ | | ⌥⌘C | Copy Properties | 🔲 | | ⌥⌘V | Paste Properties | 🔲 | ## View | Shortcut | Action | Status | |----------|--------|--------| | ⇧' | Pixel Grid | ✅ | | ⇧⌘' | Snap to Pixel Grid | ✅ | | ⌃G | Layout Guides | 🔲 | | ⇧R | Rulers | ✅ | | ⇧C | Show/Hide Comments | ✅ | | ⌘\ | Show/Hide UI | ✅ | | ⌥⌘↩ | Preview controls | ✅ | | ⌘= | Zoom In | ✅ | | ⌘- | Zoom Out | ✅ | | + / - | Zoom In / Out | ✅ | | ⌘0 | Zoom to 100% | ✅ | | ⇧0 | Zoom to 100% (alt) | ✅ | | ⌘1 | Zoom to Fit | ✅ | | ⌘2 | Zoom to Selection | ✅ | | ⇧1 | Zoom to Fit (alt) | ✅ | | ⇧2 | Zoom to Selection (alt) | ✅ | | N | Zoom to Next Frame | ✅ | | ⇧N | Zoom to Previous Frame | ✅ | | Page Down | Next Page | ✅ | | Page Up | Previous Page | ✅ | | Z | Zoom Tool | 🔲 | | ⌘J | Toggle AI Chat | ✅ | ## Selection | Shortcut | Action | Status | |----------|--------|--------| | ↩ | Select Children | ✅ | | ⇧↩ or \ | Select Parent | ✅ | | ⇥ | Select Next Sibling | ✅ | | ⇧⇥ | Select Previous Sibling | ✅ | ## Object | Shortcut | Action | Status | |----------|--------|--------| | ⌥⌘G | Frame Selection | 🔲 | | ⌘G | Group Selection | ✅ | | ⇧⌘G | Ungroup | ✅ | | ⇧A | Add Auto Layout | ✅ | | ⌥⌘K | Create Component | ✅ | | ⇧⌘K | Create Component Set | ✅ | | ⌥⌘B | Detach Instance | ✅ | | ⌘R | Rename Selection | ✅ | | ⌘] | Bring Forward | ✅ | | ⌥⌘] | Bring to Front | 🔲 | | ] | Bring to Front | ✅ | | ⌘\[ | Send Backward | ✅ | | ⌥⌘\[ | Send to Back | 🔲 | | \[ | Send to Back | ✅ | | ⇧H | Flip Horizontal | ✅ | | ⇧V | Flip Vertical | ✅ | | ⇧⌘H | Toggle Visibility | ✅ | | ⇧⌘L | Toggle Lock | ✅ | | ⌘E | Flatten | 🔲 | ## Text | Shortcut | Action | Status | |----------|--------|--------| | ⌘B | Bold | ✅ | | ⌘I | Italic | ✅ | | ⌘U | Underline | ✅ | | ⇧⌘X | Strikethrough | 🔲 | ## Canvas Interaction | Input | Action | Status | |-------|--------|--------| | Click | Select node | ✅ | | Shift+Click | Add/remove from selection | ✅ | | Alt+Drag | Duplicate and move | ✅ | | Shift+Drag (draw) | Constrain to square/circle | ✅ | | Shift+Drag (resize) | Maintain aspect ratio | ✅ | | Shift+Drag (rotate) | Snap to 15° | ✅ | | Middle mouse drag | Pan | ✅ | | Scroll | Pan | ✅ | | Ctrl+Scroll / Pinch | Zoom | ✅ | | Double-click text | Edit text inline | ✅ | | Drag onto frame | Reparent into frame | ✅ | | Escape | Deselect / Cancel | ✅ | --- --- url: 'https://openpencil.ink/reference/node-types.md' description: >- Reference for OpenPencil scene graph node types, Figma Kiwi schema mappings, and supported engine node behavior. --- # Node Types The scene graph supports 28 node types from Figma's Kiwi schema. Each node is identified by a GUID (`sessionID:localID`) and has a parent reference via `parentIndex`. The OpenPencil engine's `NodeType` union currently uses 18 of these types. ## Type Table 28 Figma schema types + 1 synthetic engine type. Types marked ✅ are in the engine's `NodeType` union (18 total). | Type | ID | Description | Engine | |------|----|-------------|--------| | `DOCUMENT` | 1 | Root node, one per file | — | | `CANVAS` | 2 | Page | ✅ | | `GROUP` | 3 | Group container | ✅ | | `FRAME` | 4 | Primary container (artboard), supports auto-layout | ✅ | | `BOOLEAN_OPERATION` | 5 | Union/subtract/intersect/exclude result | ✅ | | `VECTOR` | 6 | Freeform vector path | ✅ | | `STAR` | 7 | Star shape | ✅ | | `LINE` | 8 | Line | ✅ | | `ELLIPSE` | 9 | Ellipse/circle, supports arc data | ✅ | | `RECTANGLE` | 10 | Rectangle | ✅ | | `REGULAR_POLYGON` | 11 | Regular polygon (3–12 sides, engine uses `POLYGON`) | ✅ | | `ROUNDED_RECTANGLE` | 12 | Rectangle with smooth corners | ✅ | | `TEXT` | 13 | Text with rich formatting | ✅ | | `SLICE` | 14 | Export region | | | `SYMBOL` | 15 | Component (main, engine uses `COMPONENT`) | ✅ | | `INSTANCE` | 16 | Component instance | ✅ | | `STICKY` | 17 | FigJam sticky note | | | `SHAPE_WITH_TEXT` | 18 | FigJam shape | ✅ | | `CONNECTOR` | 19 | Connector line between nodes | ✅ | | `CODE_BLOCK` | 20 | FigJam code block | | | `WIDGET` | 21 | Plugin widget | | | `STAMP` | 22 | FigJam stamp | | | `MEDIA` | 23 | Video/GIF | | | `HIGHLIGHT` | 24 | FigJam highlight | | | `SECTION` | 25 | Canvas section (organizational, top-level only) | ✅ | | `SECTION_OVERLAY` | 26 | Section overlay | | | `WASHI_TAPE` | 27 | FigJam washi tape | | | `VARIABLE` | 28 | Variable definition node | | | `COMPONENT_SET` | — | Variant group container (synthetic, mapped from `SYMBOL`) | ✅ | ### Engine NodeType Union (18 types) The engine's `NodeType` uses simplified names. Some differ from the Kiwi schema: * `COMPONENT` → Kiwi `SYMBOL` (ID 15) * `COMPONENT_SET` → variant group container (no dedicated Kiwi ID, mapped from `SYMBOL` with variants) * `POLYGON` → Kiwi `REGULAR_POLYGON` (ID 11) ```typescript type NodeType = | 'CANVAS' | 'FRAME' | 'RECTANGLE' | 'ROUNDED_RECTANGLE' | 'ELLIPSE' | 'TEXT' | 'LINE' | 'STAR' | 'POLYGON' | 'VECTOR' | 'BOOLEAN_OPERATION' | 'GROUP' | 'SECTION' | 'COMPONENT' | 'COMPONENT_SET' | 'INSTANCE' | 'CONNECTOR' | 'SHAPE_WITH_TEXT' ``` ## Node Hierarchy ``` Document ├── Canvas (Page 1) │ ├── Section (top-level only, title pill, auto-adopts siblings) │ │ ├── Frame │ │ │ └── ...children │ │ └── Rectangle │ ├── Frame │ │ ├── Rectangle │ │ ├── Text │ │ └── Frame (nested) │ │ ├── Ellipse │ │ └── Instance (→ references Component) │ ├── Component │ │ └── ...children │ ├── Group │ │ └── ...children │ └── BooleanOperation │ └── ...operand shapes └── Canvas (Page 2) └── ... ``` ## Core Properties Every node carries these fields (subset of `NodeChange`): ### Identity & Tree * `guid` — unique identifier (`sessionID:localID`) * `type` — node type enum * `name` — display name * `phase` — `CREATED` or `REMOVED` * `parentIndex` — parent GUID + position string for z-ordering ### Transform * `size` — width/height vector * `transform` — 2×3 affine matrix * `rotation` — degrees ### Appearance * `fillPaints[]` — fill colors/gradients/images * `strokePaints[]` — stroke colors * `effects[]` — shadows, blurs * `opacity` — 0–1 * `blendMode` — `NORMAL`, `MULTIPLY`, `SCREEN`, etc. ### Shared styles Scene nodes model `fillStyleId`, `strokeStyleId`, `textStyleId`, `effectStyleId`, and `gridStyleId` as nullable Figma GUID strings. Imported local definitions are internal nodes with a `sharedStyleType` of `FILL`, `TEXT`, `EFFECT`, or `GRID`; `layoutGrids[]` contains promoted grid geometry. Manual property edits detach only the matching reference. ### Stroke * `strokeWeight` — stroke thickness * `strokeAlign` — `INSIDE` / `CENTER` / `OUTSIDE` * `strokeCap` — `NONE` / `ROUND` / `SQUARE` / `ARROW_LINES` / `ARROW_EQUILATERAL` * `strokeJoin` — `MITER` / `BEVEL` / `ROUND` * `dashPattern[]` — dash/gap lengths ### Corners * `cornerRadius` — uniform radius * `cornerSmoothing` — squircle amount (0–1) * Per-corner radii when `rectangleCornerRadiiIndependent` is true ### Visibility * `visible` — show/hide * `locked` — prevent editing ## Type-Specific Properties ### Text `fontSize`, `fontName`, `lineHeight`, `letterSpacing`, `textAlignHorizontal`, `textAlignVertical`, `textAutoResize`, `textData` (characters, style overrides, baselines, glyphs) ### Vector `vectorData` (vectorNetworkBlob, normalizedSize), `fillGeometry[]`, `strokeGeometry[]`, `handleMirroring`, `arcData` ### Layout (Frame) `stackMode`, `stackSpacing`, `stackPadding`, `stackJustify`, `stackCounterAlign`, `stackPrimarySizing`, `stackCounterSizing`, `stackChildPrimaryGrow`, `stackChildAlignSelf` ### Component `symbolData`, `componentKey`, `componentPropDefs[]`, `symbolDescription` ### Instance `overriddenSymbolID`, `symbolData.symbolOverrides[]`, `componentPropRefs[]`, `componentPropAssignments[]` In the SceneGraph, typed definitions live in `componentPropertyDefinitions`, descendant field links in `componentPropertyReferences`, and per-instance values in `componentPropertyAssignments`. Variant component values remain separate in `componentPropertyValues`. ## Paint ```typescript interface Fill { type: 'SOLID' | 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR' | 'GRADIENT_DIAMOND' | 'IMAGE' color: Color // {r, g, b, a} 0–1 floats opacity: number // 0–1 visible: boolean blendMode?: BlendMode gradientStops?: GradientStop[] // for gradients gradientTransform?: GradientTransform // 2×3 matrix imageHash?: string // for image fills imageScaleMode?: 'FILL' | 'FIT' | 'CROP' | 'TILE' imageTransform?: GradientTransform } ``` ## Effect ```typescript interface Effect { type: 'DROP_SHADOW' | 'INNER_SHADOW' | 'LAYER_BLUR' | 'BACKGROUND_BLUR' | 'FOREGROUND_BLUR' color: Color offset: { x: number; y: number } radius: number spread: number visible: boolean } ``` ## Stroke ```typescript interface Stroke { color: Color weight: number opacity: number visible: boolean align: 'INSIDE' | 'CENTER' | 'OUTSIDE' cap?: 'NONE' | 'ROUND' | 'SQUARE' | 'ARROW_LINES' | 'ARROW_EQUILATERAL' join?: 'MITER' | 'BEVEL' | 'ROUND' dashPattern?: number[] } ``` --- --- url: 'https://openpencil.ink/reference/scene-graph.md' description: >- Technical reference for OpenPencil's flat scene graph, node lookup, tree relationships, mutations, events, and traversal APIs. --- # Scene Graph ## In-Memory Representation Nodes live in a flat `Map` keyed by `GUID` string. The tree structure is maintained via `parentIndex` references. This gives O(1) lookup by ID and efficient traversal. ```typescript interface SceneGraph { nodes: Map root: string getNode(id: string): Node getChildren(id: string): Node[] getParent(id: string): Node | null getPages(): Node[] createNode(type: NodeType, parent: string, props: Partial): Node updateNode(id: string, changes: Partial): void deleteNode(id: string): void moveNode(id: string, newParent: string, position: string): void addPage(name: string): Node deletePage(id: string): void renamePage(id: string, name: string): void findByType(type: NodeType): Node[] findByName(pattern: string): Node[] hitTest(point: Vector, canvas: string): Node | null getNodesInRect(rect: Rect, canvas: string): Node[] } ``` ## Pages Documents support multiple pages (`CANVAS` nodes as direct children of the `DOCUMENT` root). Each page has its own child tree and independent viewport state (panX, panY, zoom, pageColor). The editor tracks `currentPageId` and renders only the active page's children. ## Sections `SECTION` nodes are top-level organizational containers (direct children of `CANVAS` only). They cannot nest inside frames or groups. Creating a section auto-adopts overlapping siblings. Sections display a title pill with luminance-adaptive text color. ## Hover State The editor state tracks `hoveredNodeId` — the node currently under the cursor. The renderer draws a shape-aware hover outline (following actual geometry for ellipses, rounded rects, vectors) for visual feedback before selection. ## Undo/Redo The system uses Figma's **inverse command** pattern. Each undo entry contains the forward changes and their automatically-computed inverse: | Operation | Forward | Inverse | |-----------|---------|---------| | Create node | `{guid, phase: CREATED, ...props}` | `{guid, phase: REMOVED}` | | Delete node | `{guid, phase: REMOVED}` | `{guid, phase: CREATED, ...allProps}` | | Change prop | `{guid, fill: "#F00"}` | `{guid, fill: "#00F"}` | | Move node | `{guid, parentIndex: newParent}` | `{guid, parentIndex: oldParent}` | Before applying any change, affected fields are snapshotted. The snapshot becomes the inverse. **Batching** — operations like drag-to-move produce hundreds of position changes per second. These are debounced into a single undo entry. `beginBatch`/`commitBatch` wraps multi-step operations. ## Layout Engine (Yoga) Figma's auto-layout properties map to Yoga flexbox: | Figma Property | Yoga Equivalent | |---|---| | `stackMode: HORIZONTAL` | `flexDirection: row` | | `stackMode: VERTICAL` | `flexDirection: column` | | `stackSpacing` | `gap` | | `stackPadding` | `padding` | | `stackJustify: MIN/CENTER/MAX/SPACE_BETWEEN` | `justifyContent` | | `stackCounterAlign` | `alignItems` | | `stackPrimarySizing: FIXED/HUG/FILL` | width/height + flex-grow | | `stackChildPrimaryGrow` | `flexGrow` | | `stackChildAlignSelf` | `alignSelf` | | `stackPositioning: ABSOLUTE` | `position: absolute` | ## Hit Testing Given a point in canvas coordinates, the scene graph returns the topmost visible node at that position. The algorithm: 1. Traverse visible nodes in reverse z-order (top to bottom) 2. Transform the test point into each node's local coordinate system 3. Check if the point is within the node's bounds (including rotation) 4. Return the first match For marquee selection, `getNodesInRect` returns all nodes whose bounds intersect the given rectangle. ## Extended Fill Types Fills support six types: `SOLID`, `GRADIENT_LINEAR`, `GRADIENT_RADIAL`, `GRADIENT_ANGULAR`, `GRADIENT_DIAMOND`, and `IMAGE`. Gradient fills carry `gradientStops` (color + position pairs) and a `gradientTransform` (2×3 matrix). Image fills reference blob data via `imageHash` with scale modes (`FILL`, `FIT`, `CROP`, `TILE`). ## Extended Stroke Properties Strokes support `cap` (`NONE`, `ROUND`, `SQUARE`, `ARROW_LINES`, `ARROW_EQUILATERAL`), `join` (`MITER`, `BEVEL`, `ROUND`), and `dashPattern` (array of dash/gap lengths) in addition to the base `color`, `weight`, `opacity`, `visible`, and `align` properties. ## Coordinate System Nodes store position and size relative to their parent. To get absolute (canvas) coordinates, walk up the parent chain applying transforms. The renderer uses this to draw nested frames with correct positioning. Rotation is stored in degrees and applied as part of the 2×3 transform matrix. Snap guides and hit testing account for rotation when computing visual bounds. --- --- url: 'https://openpencil.ink/reference/figma-compatibility.md' --- # Figma Feature Matrix Feature-by-feature comparison of Figma Design capabilities with Open Pencil's current implementation status. ::: tip Status Legend ✅ Supported — feature works end-to-end · 🟡 Partial — core behavior exists, some sub-features missing · 🔲 Not yet implemented ::: **Coverage:** 94 of 158 Figma feature items addressed — 76 ✅ fully supported, 18 🟡 partial, 64 🔲 not yet. Last updated: 2026-03-07. ## Interface & Navigation | Feature | Status | Notes | |---------|--------|-------| | Toolbar with design tools | ✅ | Bottom toolbar (UI3 style): Select, Frame, Section, Rectangle, Ellipse, Line, Text, Hand, Pen | | Layers panel (left sidebar) | ✅ | Tree view with expand/collapse, drag reorder, visibility toggle; resizable width | | Pages panel | ✅ | Add, delete, rename pages; per-page viewport state | | Properties panel (right sidebar) | ✅ | Sections: Appearance, Fill, Stroke, Effects, Typography, Layout, Position; resizable width | | Zoom & pan | ✅ | Ctrl + scroll, pinch, ⌘+ / ⌘− / ⌘0 (100%) / ⌘1 (fit) / ⌘2 (selection), Space + drag, middle mouse, hand tool (H) | | Canvas rulers | ✅ | Top/left rulers with selection highlight bands and coordinate badges | | Canvas background color | ✅ | Per-page background via properties panel | | Canvas guides | 🔲 | Figma supports draggable guides from rulers | | Actions menu / command palette | 🔲 | Figma's quick actions search | | Context menu | ✅ | Right-click with clipboard, z-order, grouping, component, visibility, lock, move-to-page actions | | Keyboard shortcuts | 🟡 | Tools, editing, components, z-order, visibility/lock, flip, text formatting, and canvas and layer navigation implemented; Scale, Arrow, Eyedropper, and the zoom tool not yet wired | | Find and replace | 🔲 | Text search/replace across document | | Layer outlines view | 🔲 | Wireframe view of all layers | | Custom file thumbnails | 🔲 | Thumbnail generated on export, but no custom thumbnail picker | | Nudge value settings | 🔲 | Default 1px/10px; Figma allows custom small/big nudge values | | App menu (browser mode) | ✅ | File, Edit, View, Object, Text, Arrange menus; Tauri uses native menus | | AI tools | 🟡 | 90 tools via OpenRouter + MCP server; no AI-generated images or AI-powered search yet | ## Layers & Shapes | Feature | Status | Notes | |---------|--------|-------| | Shape tools (Rectangle, Ellipse, Line, Polygon, Star) | ✅ | All basic shape types; polygon side count and star inner radius configurable | | Frames | ✅ | Clip content, independent coordinate system, and Figma-style creation and resize presets | | Groups | ✅ | ⌘G to group, ⇧⌘G to ungroup | | Sections | ✅ | Title pills, auto-adopt overlapping nodes, luminance-adaptive text | | Arc tool (arcs, semi-circles, rings) | ✅ | arcData with start/end angle and inner radius | | Pencil (freehand) tool | 🔲 | Figma's freehand drawing tool | | Masks | 🔲 | Shape masks for clipping layers | | Layer types & hierarchy | ✅ | 17 node types, flat Map + parent-child tree | | Select layers | ✅ | Click, shift-click, marquee selection | | Alignment & position | ✅ | Position, rotation, dimensions in properties panel | | Copy & paste objects | ✅ | Standard clipboard + Figma Kiwi binary format; Copy as text/SVG/PNG/JSX | | Scale layers proportionally | 🟡 | Shift-resize constrains proportions; no dedicated Scale tool (K) | | Lock & unlock layers | ✅ | ⇧⌘L toggles lock; locked nodes can't be selected/moved from canvas | | Toggle layer visibility | ✅ | Eye icon in layers panel + ⇧⌘H keyboard shortcut | | Rename layers | ✅ | Double-click inline rename in layers panel; Enter/Escape/blur to commit | | Bring to front / Send to back | ✅ | ] and \[ keyboard shortcuts; also in context menu | | Move to page | ✅ | Move selected nodes between pages via context menu | | Constraints (responsive resize) | 🔲 | Pin edges/center for parent resize behavior | | Smart selection (distribute/align) | 🔲 | Evenly space and align multi-selection | | Layout guides (columns, rows, grid) | 🔲 | Column/row/grid overlay guides on frames | | Measure distances between layers | 🔲 | Alt-hover to show distances | | Edit objects in bulk | ✅ | Multi-selection properties panel: edit position, size, appearance, fill, stroke, effects across multiple nodes; shared values display normally, differing values show "Mixed" | | Identify matching objects | 🔲 | Find similar layers | | Copy/paste properties | 🔲 | Copy fill/stroke/effects between layers | | Parent-child relationships | ✅ | Full hierarchy with parentIndex, reparenting via drag | ## Vector Tools | Feature | Status | Notes | |---------|--------|-------| | Vector networks | ✅ | Figma-compatible model, not simple paths | | Pen tool | ✅ | Corner points, bezier curves, open/closed paths | | Pencil tool | ✅ | Freehand strokes smoothed into curves, ⇧ for straight lines | | Edit vector layers | 🟡 | Creation works; advanced vertex editing (bend, delete points, join) limited | | Boolean operations (Union, Subtract, Intersect, Exclude) | 🔲 | Combine shapes with boolean ops | | Flatten layers | 🔲 | Merge vector paths into single path | | Convert strokes to paths | 🔲 | Outline Stroke command | | Convert text to paths | 🔲 | Flatten text to vector outlines | | Shape builder tool | 🔲 | Interactive boolean tool | | Offset path | 🔲 | Inset/outset a vector path | | Simplify path | 🔲 | Reduce vector point count | ## Text & Typography | Feature | Status | Notes | |---------|--------|-------| | Text tool & inline editing | ✅ | Canvas-native editing, phantom textarea, cursor/selection/word select, drag to select, double/triple-click, rich text style runs (⌘B / I / U, **S** button) | | Text on a path | 🟡 | Imports, renders, selects, resizes, edits, and exports Figma `TEXT_PATH`; character editing requires the referenced font's outlines to be available locally | | Text rendering (Paragraph API) | ✅ | CanvasKit Paragraph for shaping, line-breaking, metrics | | Font loading (system fonts) | ✅ | Inter default, font-kit in Tauri with OnceLock cache + preloading, queryLocalFonts in browser | | Font family & weight | ✅ | FontPicker with virtual scroll, search, CSS preview; weight selection in properties panel | | Font size & line height | ✅ | Editable in typography section | | Text alignment | 🟡 | Basic alignment; Figma has vertical alignment and auto-width/height modes | | Text styles | 🟡 | Per-selection bold/italic/underline/strikethrough (⌘B / I / U, **S** button); not yet reusable named text style presets | | Text resizing modes (auto, fixed, hug) | 🔲 | Figma's auto-width, auto-height, fixed-size text modes | | Bulleted & numbered lists | 🔲 | List formatting in text | | Links in text | 🔲 | Hyperlinks within text content | | Emojis & smart symbols | 🔲 | Emoji rendering and special characters | | OpenType features | 🔲 | Ligatures, stylistic alternates, tabular figures | | Variable fonts | 🔲 | Adjustable font axes (weight, width, slant) | | CJK text support | 🔲 | Chinese, Japanese, Korean text rendering | | RTL text support | 🔲 | Right-to-left text layout | | Icon fonts | 🔲 | Special handling for icon font glyphs | ## Color, Gradients & Images | Feature | Status | Notes | |---------|--------|-------| | Color picker (HSV) | ✅ | HSV square, hue slider, alpha slider, hex input | | Solid fills | ✅ | Hex color with opacity | | Linear gradient | ✅ | Gradient stops, transform handles | | Radial gradient | ✅ | Rendered via CanvasKit shaders | | Angular gradient | ✅ | Sweep/conic gradient support | | Diamond gradient | ✅ | Four-point diamond gradient | | Image fills | ✅ | Decoded from blob data with scale modes (fill, fit, crop, tile) | | Pattern fills | 🔲 | Repeating image/pattern fills | | Blend modes | 🔲 | Layer and fill blend modes (multiply, screen, overlay, etc.) | | Add images & videos | 🟡 | Image fills rendered; no drag-and-drop image import or video support | | Image property adjustment | 🔲 | Exposure, contrast, saturation, etc. | | Crop an image | 🔲 | Interactive image cropping | | Eyedropper tool | 🔲 | Sample colors from canvas | | Mixed selection color editing | 🔲 | Adjust colors across heterogeneous selection | | Color models (RGB, HSL, HSB, Hex) | 🟡 | HSV + Hex in picker; no HSL or RGB mode toggle | ## Effects & Properties | Feature | Status | Notes | |---------|--------|-------| | Drop shadow | ✅ | Offset, blur radius, color via CanvasKit filters | | Inner shadow | ✅ | Inset shadow effect | | Layer blur | ✅ | Gaussian blur on layer | | Background blur | ✅ | Blur content behind layer | | Foreground blur | ✅ | Blur in foreground | | Stroke weight | ✅ | Configurable in properties panel | | Stroke cap (round, square, arrow) | ✅ | `NONE`, `ROUND`, `SQUARE`, `ARROW_LINES`, `ARROW_EQUILATERAL` | | Stroke join (miter, bevel, round) | ✅ | All three join types | | Dash patterns | ✅ | Dash-on/dash-off stroke pattern | | Stroke alignment | ✅ | Inside/Center/Outside with clip-based rendering matching Figma behavior | | Individual stroke weights per side | ✅ | Top/Right/Bottom/Left with side selector dropdown | | Corner radius | ✅ | Uniform and per-corner radius with independent toggle in properties panel; canvas handles on rectangles, polygons, and stars | | Corner smoothing (iOS-style) | 🔲 | Figma's continuous corner rounding | | Multiple fills/strokes per layer | 🔲 | Figma allows stacking fills and strokes | ## Auto Layout | Feature | Status | Notes | |---------|--------|-------| | Horizontal & vertical flow | ✅ | Yoga WASM flexbox engine | | Toggle auto layout (⇧A) | ✅ | Toggle on frame or wrap selection | | Gap (spacing between children) | ✅ | Configurable in properties panel | | Padding (uniform & per-side) | ✅ | All four sides independently | | Justify content | ✅ | Start, center, end, space-between | | Align items | ✅ | Start, center, end, stretch | | Child sizing (fixed, fill, hug) | ✅ | Per-child sizing modes | | Wrap | ✅ | Flex wrap for multi-line layout | | Grid auto layout flow | ✅ | CSS Grid via Yoga fork — column/row tracks, gaps, spans | | Combined flows (nested) | ✅ | Nested auto-layout frames with different directions | | Drag reorder within auto layout | ✅ | Visual insertion indicator | | Min/max width and height | 🔲 | Figma supports min/max constraints on auto-layout children | ## Components & Design Systems | Feature | Status | Notes | |---------|--------|-------| | Create components | ✅ | ⌥⌘K creates from frame/group or wraps selection; component properties UI supports text, visibility, instance swap, and variants | | Component sets | ✅ | ⇧⌘K combines components; multidimensional sparse variant authoring with duplicate validation and top-left default | | Component instances | ✅ | Assets browsing, insertion, editable component properties and overrides, variant switching, live sync, and update review | | Variants | ✅ | Multidimensional sparse combinations, authoring, instance switching, duplicate validation, and top-left fallback | | Component properties | ✅ | Authoring and instance editing for boolean visibility, text, and instance-swap properties | | Override propagation | ✅ | Changes to main component propagate to all instances; overrides preserved | | Variables (color, number, string, boolean) | 🟡 | `COLOR` full UI (dialog, TanStack Table, inline editing, undo/redo, demo collections); `FLOAT`/STRING/BOOLEAN defined but no editing UI | | Variable collections & modes | 🟡 | Collections, modes, activeMode switching work; no variable-driven theming UI yet | | Styles (color, text, effect, layout) | 🔲 | Reusable named style presets | | Libraries (publish, share, update) | ✅ | Immutable local/storage revisions, selective publication, enablement, scoped update review, offline materialization, and `.fig` persistence | | Detach instance | ✅ | ⌥⌘B converts instance back to frame | | Go to main component | ✅ | Navigate to source component, cross-page | ## Prototyping | Feature | Status | Notes | |---------|--------|-------| | Prototype connections | 🔲 | Not supported yet | | Triggers (click, hover, drag, etc.) | 🔲 | Not supported yet | | Actions (navigate, overlay, scroll, etc.) | 🔲 | Not supported yet | | Animations & transitions | 🔲 | Not supported yet | | Smart animate | 🔲 | Auto-animate matching layers | | Overlays | 🔲 | Modal/popover prototyping | | Scroll & overflow behavior | 🔲 | Scrollable frames in prototypes | | Prototype flows | 🔲 | Named starting points | | Variables in prototypes | 🔲 | Conditional logic with variables | | Easing & spring animations | 🔲 | Custom animation curves | | Present & play prototypes | 🔲 | Fullscreen prototype viewer | ## Import & Export | Feature | Status | Notes | |---------|--------|-------| | .fig file import | ✅ | Full Kiwi codec: 194 definitions, ~390 fields per `NodeChange` | | .fig file export | ✅ | Kiwi encoding + Zstd compression + thumbnail generation; `COMPONENT`/COMPONENT\_SET mapped to `SYMBOL` for round-trip | | Save / Save As | ✅ | ⌘S / ⇧⌘S; native dialogs (Tauri), File System Access API (Chrome/Edge), download fallback (Safari) | | Figma clipboard (paste) | ✅ | Decode Kiwi binary from Figma clipboard | | Figma clipboard (copy) | ✅ | Encode Kiwi binary that Figma can read | | Sketch file import | 🔲 | .sketch file parsing | | Image/SVG/PDF export | 🟡 | PNG/JPG/WEBP/SVG export ✅; PDF export 🔲 | | Version history | 🔲 | Browse and restore previous versions | | Copy assets between tools | ✅ | Figma clipboard (Kiwi binary), Copy as text/SVG/PNG/JSX | ## Plugin API & Scripting | Feature | Status | Notes | |---------|--------|-------| | Eval command with Figma Plugin API | ✅ | Headless JavaScript execution with figma global object matching Figma's plugin surface | ## Collaboration & Dev Mode | Feature | Status | Notes | |---------|--------|-------| | Comments (pin, thread, resolve) | 🔲 | Not supported yet | | Real-time multiplayer | ✅ | P2P via Trystero + Yjs CRDT, cursors, follow mode; no server required | | Cursor chat | 🔲 | Inline chat bubbles at cursor | | Branching & merging | 🔲 | Version branches for design files | | Dev Mode (inspect) | 🟡 | Code tab shows JSX representation of selection; no CSS properties or handoff specs | | Code Connect | 🔲 | Link design components to code | | Code snippets | 🟡 | JSX export with syntax highlighting and copy; no CSS/Swift/Kotlin snippets | | Tailwind CSS v4 export | ✅ | Export as HTML with Tailwind utility classes from Code panel, CLI, or programmatically | | Figma for VS Code | 🔲 | Editor plugin integration | | MCP server | ✅ | @open-pencil/mcp with stdio + HTTP transports; 87 core tools + 3 file management tools = 90 total | | CLI tools | ✅ | Headless CLI: info, tree, find, export, analyze, node, pages, variables, eval; MCP server with stdio + HTTP | ## Figma Draw | Feature | Status | Notes | |---------|--------|-------| | Illustration tools | 🔲 | Figma Draw's specialized drawing tools | | Pattern transforms | 🔲 | Create repeating patterns with transforms | --- --- url: 'https://openpencil.ink/reference/dom-css-mapping.md' --- # DOM/CSS mapping reference OpenPencil maps browser-computed DOM/CSS styles into SceneGraph fields through `@open-pencil/dom-css`. Browser adapters should use native DOM/CSSOM and `getComputedStyle()` as the source of truth. Headless conversion is an approximation for tests and CLI usage. ## Layout | CSS | SceneGraph | Notes | |---|---|---| | `display: flex` / `inline-flex` | `layoutMode` | `flex-direction: row` maps to horizontal; `column` maps to vertical. | | `justify-content` | `primaryAxisAlign` | Supports start, center, end/flex-end, and space-between. | | `align-items` | `counterAxisAlign` | Supports start, center, end/flex-end, stretch, and baseline. | | `align-self` | `layoutAlignSelf` | Supports start, center, end/flex-end, stretch, and baseline. | | `flex-wrap: wrap` | `layoutWrap: WRAP` | Counter-axis spacing is preserved when gaps are available. | | `gap`, `row-gap`, `column-gap` | `itemSpacing`, `counterAxisSpacing` | Axis-aware: row and column gaps swap meaning for column flex direction. | | `padding-*` | `paddingTop/Right/Bottom/Left` | Browser-computed physical values are preferred. | | `position: absolute/fixed`, `left`, `top` | `layoutPositioning`, `x`, `y` | Right/bottom constraints are not mapped yet. | | `overflow: hidden/clip` | `clipsContent` | Other overflow values are ignored. | | `width`, `height`, min/max sizes | node size constraints | Browser-computed pixel values are preferred. | | `aspect-ratio` | fallback width/height sizing | Used when one axis is available and the other is `auto`/missing. | ## Paint, stroke, and effects | CSS | SceneGraph | Notes | |---|---|---| | `background-color` | solid fill | Transparent values are ignored. | | `border-color`, `border-*-color` | stroke color | First available border color is used. | | `border-width`, `border-*-width` | stroke weight / independent stroke weights | Side-specific widths set independent stroke weights. | | `border-style: dashed/dotted` | `dashPattern` | Unsupported border styles fall back to solid. | | `border-radius`, `border-*-radius` | corner radii | Independent corners are preserved when sides differ. | | `opacity` | node opacity | Numeric computed value. | | `box-shadow` | drop shadow | First simple outer shadow only; complex shadow lists require maintained parser or browser-computed support before mapping. | | `` | image fill | Data URL images are stored in the graph image map. | | `` | preserved source URL metadata | External URL fetching is not performed; the URL is retained for HTML round-trip. | | `object-fit: contain/cover` | image `FIT` / `FILL` scale mode | `scale-down` maps to `FIT`; other object-fit values are not mapped yet. | ## Text | CSS | SceneGraph | Notes | |---|---|---| | `color` | text fill | Uses core color parsing. | | `font-family` | `fontFamily` | Uses first family token. | | `font-size` | `fontSize` | Pixel/rem-ish numeric values. | | `font-weight` | `fontWeight` | Numeric values. | | `font-style: italic` | `italic` | Other styles ignored. | | `line-height` | `lineHeight` | Numeric computed values. | | `letter-spacing` | `letterSpacing` | Numeric computed values. | | `text-align` | horizontal text alignment | Supports center, right, justified; defaults left. | | `text-decoration-line` | underline / strikethrough | Decoration style/thickness are not mapped yet. | | `text-transform` | `textCase` | Uppercase, lowercase, and capitalize map to SceneGraph text case. | | `white-space: nowrap` | `maxLines = 1` | Other white-space values are not mapped yet. | | `text-shadow` | drop shadow effect | Simple shadows only. | ## Export HTML export, Tailwind HTML, and Tailwind JSX (`sceneNodesToTailwindJSX`) share one SceneGraph → CSS projection; Tailwind classes are derived from that CSS. | SceneGraph | CSS | Notes | |---|---|---| | auto layout | `display: flex`, `flex-direction`, gaps, padding | Row direction is the default and is not written. | | grid layout | `display: grid`, `grid-template-*`, gaps | Equal `1fr` tracks use `repeat(N, minmax(0, 1fr))`. | | grid child position | `grid-column`, `grid-row`, `*-start` | Spans are written before starts. | | `layoutGrow` in auto layout | `flex-grow: 1` | | | rotation | `transform: rotate()` | Rotates around the element center. | | drop and inner shadows | `box-shadow` | Every visible shadow, in order; inner shadows use `inset`. | | layer / background blur | `filter` / `backdrop-filter: blur()` | | | right-to-left layout or text | `direction: rtl` | | | section | `
` | | | colors | hex, or `rgba()` when translucent | Hex lets Tailwind match its palette. | ## Browser-oracle but not mapped yet These values are collected or covered by browser oracle tests but do not yet have a stable SceneGraph mapping: * complex gradients * CSS filters * multi-shadow lists * media-query-specific provenance * pseudo-elements ## Headless limitations The headless runtime uses maintained parsers for HTML (`parse5`) and stylesheets/inline declarations (`@acemir/cssom`), but still has limited approximations for selector matching, shorthand expansion, `calc()`, and simple shadows. Do not expand those with ad hoc parsers; prefer browser `getComputedStyle()` oracle coverage or maintained parser dependencies for new CSS behavior. --- --- url: 'https://openpencil.ink/reference/file-format.md' description: >- Technical reference for OpenPencil .fig and .pen document formats, Kiwi binary structure, import pipeline, and export behavior. --- # File Format ::: warning Figma round-trip limitations Complex Figma documents can lose visual fidelity when saved by OpenPencil and reopened in Figma, even without edits. Component content, colors, and text may differ or appear missing. This is an existing compatibility limitation; preserving the layer count does not guarantee the same appearance. Keep the original `.fig`, use **Save As** for an OpenPencil copy, and inspect the exported copy in Figma before replacing your original. ::: ## .fig File Structure A `.fig` file is a ZIP archive containing a Kiwi-encoded binary message: | Offset | Content | |--------|---------| | 0 | Magic header `fig-kiwi` (8 bytes) | | 8 | Version (4 bytes, uint32 LE) | | 12 | Schema length (4 bytes, uint32 LE) | | 16 | Compressed Kiwi schema | | … | Message length (4 bytes, uint32 LE) | | … | Compressed Kiwi message — `NodeChange[]` (entire document) | | … | Blob data — images, vector networks, fonts | ## Import Pipeline ``` .fig file → parse header → decompress Zstd → decode Kiwi schema → decode message → NodeChange[] → build SceneGraph → resolve blob refs → render on canvas ``` ## Export Pipeline ``` SceneGraph → NodeChange[] → Kiwi encode → compress (Zstd/deflate) → build ZIP (header + schema + message + thumbnail.png) → write .fig file ``` Export uses ⌘S (Save) and ⇧⌘S (Save As) with native OS dialogs on the desktop app. The exported file includes a `thumbnail.png` required by Figma for file preview. OpenPencil uses the first page named exactly `Cover` (case-insensitive), then the first page whose name contains `Cover`. The desktop app also caches this Cover after opening a file so Recent Files can prefer the generated preview without modifying the source `.fig`. Compression uses Zstd via Tauri Rust command on desktop, with deflate fallback in the browser. ## Kiwi Binary Codec The codec handles Figma's 194-definition Kiwi schema with `NodeChange` as the central type (~390 fields). Key components: | Module | Purpose | |--------|---------| | `kiwi-schema` | Kiwi parser (from [evanw/kiwi](https://github.com/nicolo-ribaudo/kiwi)), patched for ESM and sparse field IDs | | `codec.ts` | Encode/decode messages using the Kiwi schema | | `protocol.ts` | Wire format parsing and message type detection | | `schema.ts` | 194 message/enum/struct definitions | ### Sparse Field IDs Figma's schema uses non-contiguous field IDs (e.g. 1, 2, 5, 10 with gaps). The kiwi-schema parser handles this correctly. ### Compression `.fig` files use Zstd compression for both the schema and message payloads. Decompression uses the `fzstd` library. For export, Zstd compression is offloaded to a Tauri Rust command on the desktop app (better performance, correct frame headers). In the browser, deflate via `fflate` is used as a fallback. ## Supported Formats | Format | Open / Read | Save / Write | Export | |--------|-------------|--------------|--------| | `.fig` (Figma) | ✅ | ✅ | ✅ | | `.pen` (Pencil) | ✅ | — | — | | `.png` | — | — | ✅ | | `.jpg` | — | — | ✅ | | `.webp` | — | — | ✅ | | `.svg` | — | — | ✅ | | `.jsx` | — | — | ✅ | ## Clipboard Format Copy/paste uses the same Kiwi binary encoding: 1. **Copy** — encode selected `NodeChange[]` to Kiwi binary, compress, write to clipboard as `application/x-figma-design` MIME type 2. **Paste** — read clipboard, decompress, decode Kiwi binary, create nodes in scene graph Encoding happens synchronously in the copy event handler (not async Clipboard API) for browser compatibility. This enables bidirectional clipboard between OpenPencil and Figma. --- --- url: 'https://openpencil.ink/development/contributing.md' --- # Contributing The root [`CONTRIBUTING.md`](https://github.com/open-pencil/open-pencil/blob/master/CONTRIBUTING.md) is the source of truth for setup, pull-request requirements, validation, and commit expectations. The root [`AGENTS.md`](https://github.com/open-pencil/open-pencil/blob/master/AGENTS.md) holds the repository map and cross-cutting conventions, and links the `AGENTS.md` guide inside each package or app domain. Read the root file and every guide on the path to the folder you change; coding agents pick them up the same way. See [Architecture](/development/architecture) for the system overview and [Testing](/development/testing) for test ownership, fixtures, and commands. Use the root `AGENTS.md` map when exact package ownership or paths matter; do not infer ownership from an older copied tree. ## Quick start ```sh bun install bun run dev:portless # Editor at https://open-pencil.localhost bun run docs:dev # Docs at localhost:5173 ``` The complete gate before a pull request is `bun run check`, `bun run format`, `bun run test:unit`, and `bun run test`. Rendering and other pixel-affecting changes require targeted visual coverage. ## SDK documentation VitePress is the canonical public documentation, while Storybook is the internal component-state workshop. Shared Vue demos live beside their SDK primitives and are embedded in both surfaces. The docs Tailwind entry scans these demos, so examples use the same utility-first styling in both environments. Component API tables are extracted from Vue source and JSDoc with `vue-component-meta`. Keep descriptions next to public props, events, and slots instead of duplicating signatures in Markdown. VitePress processes SDK examples with Twoslash so imports and types stay aligned with the public package API. --- --- url: 'https://openpencil.ink/development/testing.md' --- # Testing ## Ownership and location Place a test with the source that owns its contract. Choose the runtime separately: using a browser does not automatically make a test application E2E, and importing several packages does not automatically make it cross-package integration. | Contract | Canonical location | | --------------------------------------- | -------------------------------------------------------------- | | Package unit and in-process integration | `packages//tests//**/*.test.ts` | | App services and state | `tests/app//**/*.test.ts` | | Cross-package/system integration | `tests/integration//` | | Application workflows and visuals | `tests/e2e//**/*.spec.ts` | | Native WebView and shell delivery | `tests/e2e/native/**/*.spec.ts` | | Figma compatibility acceptance | `tests/figma/**/*.spec.ts` | | Private tool contracts | `tools//tests/` | For example, `packages/core/src/canvas/text/shape.ts` maps to `packages/core/tests/canvas/text/shape.test.ts`; `src/app/demo/viewport.ts` maps to `tests/app/demo/viewport.test.ts`. A substantial module may have a matching test directory rather than one large test file. Application E2E, external-system acceptance and large contract suites are intentional exceptions to file-for-file mirroring. Core tests using SceneGraph still belong to Core. Central integration is for contracts without a single owning implementation, such as interoperability between published packages. Name that contract explicitly; do not use integration as a miscellaneous bucket. **Migration status:** much existing coverage remains under `tests/engine/**`, alongside package-local suites. The table is the agreed destination, not a claim that migration is complete. Until a domain migrates, extend its existing suite rather than create a second home. The repository-wide migration is separate from feature work. `bun run check:test-homes` keeps the debt shrinking: every test under `tests/engine` must be listed in `tools/dev/unit-tests/engine-baseline.txt`, so a new test fails the check until it lives in its owner's canonical home, and a moved file must be removed from the baseline. Scene Graph tests have migrated to `packages/scene-graph/tests`, and the editor domain to `packages/core/tests/editor` with its helpers under `packages/core/tests/helpers`; Core's `typecheck` script type-checks those tests. Discovery already covers the destinations: `tools/dev/unit-tests/src/shards.ts` groups tests by owner and lists each owner's canonical home (`packages//tests`, `tests/app`, `tests/integration`) together with the `tests/engine` directories it still owns, so `bun run test:unit` and the CI shards run a file from either place. Moving a domain is therefore a `git mv` plus import fixes; only a new owner or a new top-level home needs a shard entry. Do not move files into a directory the shard map does not list. `test:unit:quick` runs files in Bun worker processes (`--parallel`), which is the fastest local loop. CI shards still run each group in one process; `test:unit:isolated` (`--isolate`, also run nightly) gives every file a fresh global object and is the check to run when a suite passes alone but fails in a shard. Shards share one Bun process, so module-level state (a `fake-indexeddb/auto` import, an IndexedDB connection left open by `createEditorStore()`, a patched global) leaks into later files in the same shard. A package-local run (`bun test tests` inside the package) is a fresh process and will not reproduce that leak. Close what a test opens and restore what it patches. Run `bun --filter @open-pencil/core build` before unit tests. Files under `tests/` and under `packages/core/tests` resolve `@open-pencil/core` and its subpaths to `packages/core/src` through the root and Core tsconfig path mappings, but other package sources (`packages/dom-css/src`, `packages/mcp/src`, …) use their own tsconfig, where `@open-pencil/core/` falls back to the package `exports` and therefore to `dist`. One process can hold both copies; only `packages/vue` maps Core to `src`. Aligning the other package tsconfigs is a packaging change (it affects `tsdown` declaration output), not a test change. ## Test purpose * Unit tests cover isolated rules and state transitions. * Package integration tests exercise real collaborators owned by that package. * Browser integration tests cover browser CSS, WebGL, fonts and DOM behavior where that runtime matters. They need not launch the whole app if the contract can be exercised independently. * E2E tests exercise application workflows, accessibility and representative visual integration. * Native tests answer whether the real WebView or shell delivers an interaction. A CPU CanvasKit test is not native desktop acceptance; synthetic composition is not real IME acceptance. * Benchmarks belong to performance tooling and opt-in runs, not ordinary correctness E2E. Bounded assertions about redundant work may accompany a real interaction, but exhaustive cache policy belongs with the owning implementation. Test observable behavior and contracts, not source text. A test claiming a UI update must observe the UI, not merely read a Set after calling an editor action. ## Fixtures, builders, drivers and probes These responsibilities are distinct: | Kind | Responsibility | | -------------- | ---------------------------------------------------------------------------- | | Runner fixture | Acquire a context/editor/resource and guarantee teardown. | | Data builder | Construct deterministic plain inputs; no browser, assertions or hidden I/O. | | UI driver | Perform named user interactions through roles, labels and semantic controls. | | Probe | Read a bounded typed observation, or install scoped instrumentation. | | Asset fixture | Immutable document, font, image or reference data. | | Snapshot | Expected visual output colocated with its spec. | Owner-specific builders and support belong under that owner's `tests/helpers//`; owner-specific assets belong under `tests/fixtures/` within the owning package. Central `tests/helpers//` and `tests/fixtures//` are reserved for genuine shared consumers, runner adapters and large shared corpora. Keep existing LFS assets in place until all consumers are mapped; binary moves are not a prerequisite for clarity. Record font licenses and fixture provenance beside the assets. Do not import another package's test internals or import test support from production. Share production contracts through public exports. Promote test support only when multiple owners actually need it. Avoid catch-all `utils`, `store` and `test-utils` collections with mixed runtime dependencies. Keep temporary diagnostics and profiling specs in ignored scratch space; extract a small durable regression when an investigation finds a bug. Prefer test-runner-owned fixtures, such as Playwright `test.extend`, over implicit shared pages. Serial mode is for an intentionally dependent scenario, not a side effect of importing a helper. A failed screenshot must not silently prevent unrelated contracts from executing. A fixture's housekeeping should not be counted as separate native acceptance. ## Browser and native boundaries Specs should read as fixture setup, user action, observation and assertion. Do not scatter `window.openPencil`, store/renderer traversal, internal module URLs or resource-timing module discovery through specs. Keep unavoidable transport access in a small guarded adapter with named domain operations. Separate: 1. Explicit fixture setup mutations. 2. User interactions through the UI. 3. Read-only serializable observations. 4. Scoped instrumentation with restoration and disposal in `finally`. Do not replace global digging with a generic `evaluateEditor(callback)` escape hatch or a giant driver exposing the mutable store. Use public named types and precise result projections; do not maintain inconsistent handcrafted copies of node shapes. A UI test must not call the editor action instead of performing the interaction it claims to test. A renderer test may deliberately use a bounded renderer probe because rendering is its subject. Existing global-based helpers are migration debt, not the preferred API. Migrate a complete domain slice rather than adding forwarding-only wrappers. Test-only browser entrypoints must stay out of production artifacts. Do not enlarge production Window declarations for fixtures or counters. Native adapters retain guarded vendor-typed invocation and the test-only Cargo feature; browser and native drivers need not share an implementation or pretend they support the same capabilities. ## Execution and iteration Current commands: | Suite | Command | | ------------------------------ | ---------------------------- | | Engine/unit | `bun run test:unit` | | Quick unit, parallel | `bun run test:unit:quick` | | Quick unit, per-file isolation | `bun run test:unit:isolated` | | App browser E2E | `bun run test` | | Storybook browser | `bun run test:storybook` | | Figma acceptance | `bun run test:figma` | | Native WebView | `bun run test:native` | During implementation, run the affected unit files or one representative browser scenario. Inspect discovery changes without executing the whole suite when reorganizing files. Use the final CI gate after integration; do not rerun full suites for each edit. Report commands actually run, and distinguish focused coverage from full acceptance. Visual changes require inspection and committed coverage. Update only a justified affected snapshot, then rerun that test without update mode. Never relax tolerances or regenerate unrelated baselines to turn a failed run green. Browser GPU parity and real glyph coverage complement CPU rendering tests; they are not redundant merely because both compare pixels. ### When a run disagrees with the code Two environment faults look exactly like a code regression, and both have cost real debugging time. Browser suites resolve `@open-pencil/*` through each package's built `dist`, which Vite then pre-bundles. Rebuilding a package does not invalidate that cache, so a Playwright run can execute code from before your change — including throwing on a field you just added. If a browser run contradicts the source you are reading, delete `node_modules/.vite` and run again before believing it. Heavier `.fig` suites are sensitive to machine load. A full run alongside other work has reported failures that vanish on a quiet re-run, with the same suite taking three times as long. Re-run a failing heavy test alone before concluding anything, and compare against the same test on `master` under the same load rather than against a remembered baseline. Raising its timeout is still never the fix. ## Server ownership and worktrees The canonical `playwright.config.ts` owns app, Figma and Storybook projects. The `test`, `test:update`, `test:real-llm` and `test:figma` scripts select only the app server; `test:storybook` selects Storybook on port `6017`. Direct Playwright commands start both servers by default. Set `OPENPENCIL_TEST_SERVER=app`, `storybook` or `all`; `--project` selects tests, not servers. App tests start Vite from the current checkout and wait for its HTTP URL. Vite owns its MCP companion. Reuse is off by default and always off in CI. Default ports are app `1420` and MCP `7600`; concurrent worktrees need distinct free pairs: ```sh OPENPENCIL_TEST_SERVER=app OPENPENCIL_TEST_PORT=1482 OPENPENCIL_TEST_MCP_PORT=7682 \ bunx playwright test tests/e2e/properties/effect-panel.spec.ts --project=openpencil ``` Use the configured base URL and same-origin paths, never hard-coded ports in specs or adapters. For intentional debugging against a verified matching server, set `OPENPENCIL_TEST_REUSE_SERVER=1`. HTTP readiness alone does not prove checkout identity, so do not reuse servers for baseline comparisons. Portless remains the preferred interactive preview workflow, separate from managed fixed-port tests. ## External-system acceptance Storybook uses its own viewport, device scale and reduced-motion context. Its specs are excluded from app projects. Figma acceptance requires the desktop app's configured CDP connection. Native tests use an explicit test-only binary, separate application identity and test-owned credentials/profile. Never run acceptance against production secrets or clear user recovery data. Skip unsupported platforms rather than claim coverage. Ordinary WebDriver text input, trusted clipboard events, real IME and persistence across restarts are separate contracts. --- --- url: 'https://openpencil.ink/development/architecture.md' --- # Architecture ## System Overview For source identity, override evaluation, page sessions, and export contracts, see [.fig Reader Architecture](./fig-reader). ```mermaid graph TB subgraph Tauri["Tauri v2 Shell"] subgraph Editor["Editor (Web)"] UI["Vue 3 UI
Toolbar · Panels · Properties
Layers · Color Picker"] Skia["Skia CanvasKit (WASM, 7MB)
Vector rendering · Text shaping
Effects · Export"] subgraph Core["Core Engine (TS)"] SG[SceneGraph] --- Layout[Layout - Yoga] SG --- Selection Undo[Undo/Redo] --- Constraints Constraints --- HitTest[Hit Testing] end subgraph FileFormat["File Format Layer"] FigIO[".fig import/export"] --- Kiwi[Kiwi codec] Kiwi --- SVG[SVG export] end end MCP["MCP Server (90 tools, stdio+HTTP)"] Collab["P2P Collab (Trystero + Yjs)"] end ``` ## Editor Layout The UI follows Figma's UI3 layout — toolbar at the bottom, navigation on the left, properties on the right: * **Navigation panel (left)** — Layers tree, pages panel * **Canvas (center)** — Infinite canvas with CanvasKit rendering, zoom/pan * **Properties panel (right)** — Context-sensitive sections: Appearance, Fill, Stroke, Typography, Layout, Position * **Toolbar (bottom)** — Tool selection: Select, Frame, Section, Rectangle, Ellipse, Line, Text, Pen, Hand ## Components ### Rendering (CanvasKit WASM) The same rendering engine as Figma. CanvasKit provides GPU-accelerated 2D drawing with vector shapes, text shaping via Paragraph API, effects (shadows, blurs, blend modes), and export (PNG, SVG). The 7MB WASM binary loads at startup and creates a GPU surface on the HTML canvas. The renderer is split into focused modules in `packages/core/src/renderer/`: scene traversal, overlays, fills, strokes, shapes, effects, rulers, labels, and remote cursors. ### Scene Graph Flat `Map` keyed by GUID strings. Tree structure via `parentIndex` references. Provides O(1) lookup, efficient traversal, hit testing, and rectangular area queries for marquee selection. The graph emits typed events via nanoevents: `node:created`, `node:updated`, `node:deleted`, `node:reparented`, `node:reordered`. Subsystems subscribe to these instead of manual call-site wiring — the editor uses them for render invalidation and microtask-batched component instance sync, while the collab system uses them for Yjs propagation. See [Scene Graph Reference](/reference/scene-graph) for internals. ### Layout Engine (Yoga WASM) Meta's Yoga provides CSS flexbox and grid layout computation via a [fork](https://github.com/open-pencil/yoga/tree/grid) with CSS Grid support. A thin adapter maps Figma property names to Yoga equivalents: | Figma Property | Yoga Equivalent | |---|---| | `stackMode: HORIZONTAL` | `flexDirection: row` | | `stackMode: VERTICAL` | `flexDirection: column` | | `stackSpacing` | `gap` | | `stackPadding` | `padding` | | `stackJustify` | `justifyContent` | | `stackChildPrimaryGrow` | `flexGrow` | ### File Format (Kiwi Binary) Reuses Figma's Kiwi binary codec with 194 message/enum/struct definitions. Import: parse header → Zstd decompress → Kiwi decode → `NodeChange`\[] → scene graph. Export reverses the process with thumbnail generation. See [File Format Reference](/reference/file-format) for details. ### AI & Tools Tools are defined once in `packages/core/src/tools/`, split by domain: read, create, modify, structure, variables, vector, analyze. Each tool has typed params and an `execute(figma, args)` function. Adapters convert them for: * **AI chat** — valibot schemas, multi-provider (Anthropic, OpenAI, Google AI, OpenRouter, compatible endpoints) * **MCP server** — zod schemas, stdio + HTTP transports * **CLI** — available via the `eval` command, whose scripts get the `figma` Plugin API and the `openpencil` API 90+ core tools + 3 MCP file management tools. Includes XPath query (`query_nodes`), JSX inspection (`get_jsx`, `diff_jsx`), semantic description (`describe`), and vision-based verification (`export_image` returns images to the model). ### Behaviours and Preview A behaviour stored on a main component makes it work as a Reka UI control. Preview runs top-level layers holding controls as live islands: shadow-DOM projections laid over the canvas, with each control mounted as its Reka UI primitive and drawn by the component's variants. See [Behaviours and Preview](./behaviours-and-preview). ### Undo/Redo Inverse-command pattern. Before applying any change, affected fields are snapshotted. The snapshot becomes the inverse operation. Batching groups rapid changes (like drag) into single undo entries. ### Clipboard Figma-compatible bidirectional clipboard. Encodes/decodes Kiwi binary (same format as .fig files) via native browser copy/paste events. Handles vector path scaling, instance children, component set detection, and override application. ### P2P Collaboration Real-time peer-to-peer collaboration via Trystero (WebRTC) + Yjs CRDT. No server relay — signaling over MQTT public brokers, STUN/TURN for NAT traversal. Awareness protocol provides live cursors, selections, and presence. Local persistence via y-indexeddb. ### CLI-to-App RPC Bridge When the desktop app is running, CLI commands connect to it via WebSocket instead of requiring a .fig file. The automation server runs on `127.0.0.1:7600` (HTTP) and `127.0.0.1:7601` (WebSocket). Commands execute against the live editor state, enabling automation scripts and AI agents to interact with the running app. ## What's Next ### Full figma-use Tool Set The MCP server currently exposes 90 tools. The reference implementation in [figma-use](https://github.com/dannote/figma-use) has 118. The remaining tools cover advanced layout constraints, prototype connections, advanced component property editing, and bulk document operations. ### CI Design Tooling The headless CLI already supports `analyze colors/typography/spacing/clusters`. Next: GitHub Actions integration for automated design linting and visual regression in PRs. ### Prototyping Preview already runs components with behaviours as live controls. Next: reactions and variables that let controls drive the rest of a design, frame-to-frame transitions, interaction triggers, and overlays. ### Windows Code Signing macOS binaries are signed and notarized since v0.6.0. Windows Authenticode signing via Azure Code Signing is planned to remove the SmartScreen warning. --- --- url: 'https://openpencil.ink/development/fig-reader.md' --- # .fig Reader Architecture The reader interprets Figma source records into independently editable scene occurrences. One reader serves the application, CLI, MCP, and worker paths; the earlier importer and its repair pipeline are gone. ::: warning Implementation status The reader is the only import path: synchronous parsing, the document worker, page population, recovery, export, and clipboard paste all use it, and the previous importer is removed. Corpus-wide fidelity (export still reassigns node identities and drops some raw text metadata) and performance acceptance remain unfinished. The diagram describes responsibilities, not a claim of complete Figma parity. ::: ```mermaid flowchart TD Archive[FIG archive and resources] --> Sources[Indexed source records] Sources --> Closure[Selected pages and component dependencies] Closure --> Evaluation[Occurrence evaluation and provenance] Evaluation --> Graph[Editable SceneGraph] Graph --> Sessions[Page sessions and recovery] Graph --> Editor[Editor actions and undo] Editor --> Export[FIG export] Export --> Oracle[Figma reopen and visual validation] ``` ## Key distinctions * A **source record** identifies an archive node; an **occurrence** identifies one use of it. * An **owner** establishes the component scope for an override **claim**. * An effective value is not enough to determine whether it is inherited or overridden. * Saved appearance, editable layout, and exported-file fidelity are separate contracts. ```text Inherited default Explicit equal value Initial text "Badge" "Badge" Component changes "Updated" "Updated" Instance result "Updated" "Badge" ``` ## Package documentation The canonical implementation documentation lives alongside the package in [`packages/fig/docs`](https://github.com/open-pencil/open-pencil/tree/master/packages/fig/docs). This page is the development-site entry point, not a second copy of those contracts. Read in order: 1. [Architecture and ownership](https://github.com/open-pencil/open-pencil/blob/master/packages/fig/docs/architecture.md) 2. [Source model and identities](https://github.com/open-pencil/open-pencil/blob/master/packages/fig/docs/source-model.md) 3. [Instance evaluation and precedence](https://github.com/open-pencil/open-pencil/blob/master/packages/fig/docs/instance-evaluation.md) 4. [SceneGraph materialization](https://github.com/open-pencil/open-pencil/blob/master/packages/fig/docs/materialization.md) 5. [Document sessions, rollback, and recovery](https://github.com/open-pencil/open-pencil/blob/master/packages/fig/docs/document-sessions.md) 6. [Export contracts](https://github.com/open-pencil/open-pencil/blob/master/packages/fig/docs/export.md) 7. [Validation and oracle methodology](https://github.com/open-pencil/open-pencil/blob/master/packages/fig/docs/validation.md) See also [system architecture](./architecture), [testing](./testing), and the [development roadmap](./roadmap). Fixture provenance belongs next to fixtures; individual benchmark runs and temporary oracle results do not belong in architecture documentation. --- --- url: 'https://openpencil.ink/development/tech-stack.md' --- # Tech Stack ## Core Technologies | Layer | Technology | Why | |-------|-----------|-----| | **Rendering** | Skia CanvasKit WASM | Same engine as Figma — proven performance, GPU-accelerated, pixel-perfect | | **UI Framework** | Vue 3 + VueUse | Reactive composition API, excellent TypeScript support | | **Components** | Reka UI | Headless, accessible UI primitives (tree, slider, etc.) | | **Styling** | Tailwind CSS 4 | Utility-first, fast iteration, dark theme | | **Layout** | Yoga WASM | CSS flexbox and grid engine from Meta, battle-tested in React Native | | **File Format** | Kiwi binary + Zstd | Figma's own format — compact, fast parsing, .fig compatible | | **Collaboration** | Trystero + Yjs | P2P WebRTC via MQTT signaling, CRDT sync, y-indexeddb persistence | | **Color** | culori | Color space conversions (HSV, RGB, hex) | | **AI/MCP** | MCP SDK + Hono | 90+ tools for AI coding tools, stdio + HTTP transports | | **JSX Transform** | Sucrase | Lightweight (201 KB) JSX → JS, synchronous, browser-compatible | | **Events** | nanoevents | 108 bytes, typed event emitter for SceneGraph mutations | | **Desktop** | Tauri v2 | ~5MB native app (vs Electron's ~100MB), Rust backend | | **Build** | Vite 7 | Fast HMR, native ES modules | | **Testing** | Playwright + bun:test | Visual regression (E2E) + fast unit tests | | **Linting** | oxlint | Rust-based, orders of magnitude faster than ESLint | | **Formatting** | oxfmt | Rust-based formatter | | **Type Checking** | typescript-go (tsgo) | Native Go implementation of TypeScript type checker | ## Key Dependencies ```json { "canvaskit-wasm": "^0.41.1", "vue": "^3.5.41", "yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.3", "nanoevents": "^9.1.0", "sucrase": "^3.35.1", "reka-ui": "^2.10.3", "tailwindcss": "^4.3.3", "culori": "^4.0.2", "fzstd": "^0.1.1", "fflate": "^0.8.3", "@trystero-p2p/mqtt": "^0.26.0", "yjs": "^13.6.32", "y-indexeddb": "^9.0.12" } ``` ## Why Not... ### Why not SVG rendering? SVG is slow for complex documents. Every node is a DOM element — 10,000 nodes means 10,000 DOM nodes with layout, paint, and compositing overhead. CanvasKit draws everything to a single GPU surface. (Penpot still defaults to SVG rendering but has an opt-in Rust/Skia WASM renderer in development.) ### Why not Electron (like Figma desktop)? Tauri v2 uses the system webview (~5MB) instead of bundling Chromium (~100MB). The Rust backend provides native performance for file I/O and system integration. ### Why not React (like the original plan)? The project migrated from React to Vue 3 early in development. Vue's reactivity system and VueUse composables proved more ergonomic for the editor's state management needs. ### Why not custom layout engine? Yoga is maintained by Meta, battle-tested across billions of React Native devices, and implements the CSS flexbox spec. Building a custom engine would be months of work to reach the same correctness level. ## Additional Technologies | Technology | Purpose | Status | |-----------|---------|--------| | CSS Grid in Yoga | Grid-based auto layout | Shipped via [Yoga fork](https://github.com/open-pencil/yoga/tree/grid) (`@open-pencil/yoga-layout`) | --- --- url: 'https://openpencil.ink/development/roadmap.md' description: OpenPencil product roadmap and Figma compatibility tracking. --- # Roadmap OpenPencil is moving toward production-grade Figma compatibility while keeping design documents programmable, local-first, and fast on large files. ## Current focus * Improve `.fig` import/export fidelity against real Figma files and Figma's own rendering. * Make saves, recovery, storage synchronization, and automation resilient to crashes, interrupted writes, expired credentials, and temporary provider failures. * Keep large design systems responsive in the browser and desktop app. * Make international text reliable across platforms, starting with non-Latin font discovery, Arabic/Persian shaping, RTL layout, and broader CJK fixtures. * Treat the scene graph as a programmable design document: every important read, write, export, diff, and validation operation should be reachable through UI, CLI, MCP, and SDK surfaces. * Keep local files and local-first workflows first-class while making an optional OpenPencil Cloud backend and self-hosted deployments practical. ## Recently delivered v0.15.0 and v0.15.1 shipped work that earlier versions of this roadmap listed as in development: * Editable ruler guides, snapping to geometry and guides, and Option/Alt distance measurements. * Multidimensional component variants and published component libraries with revision review and linked-instance updates. * Local recovery of unsaved documents, saved AI conversations, and CLI font diagnostics. * Opt-in browser WebMCP access, separate controls for the local MCP server and remote connections, and per-tool permissions. * `openpencil://` and web links that open a document and select a layer. ### Current development version Since v0.15.1, the development branch adds: * Behaviours and preview mode: components that run as Reka UI controls, with a Controls page in the demo and Storybook controls for them. * Variables modelled as design tokens, edited as tokens, imported and exported as W3C design tokens, written as a CSS token stylesheet, and used by name in exported code. * Live design checks: a Lint panel with canvas markers and one-click fixes. * AI agents shown on the canvas and to collaborators, with follow mode for people and agents, per-page presence, and MCP agents in rooms. * AI chat turns that can be reverted, regenerated, and edited, with tool calls summarised by what they changed and a thinking level per message. * The Code tab linked to canvas layers both ways, and visual diff and patch tools, including `openpencil diff`. * Collaboration rooms that sync layer trees as a CRDT and open in their own tab. * Slots, progressive canvas previews while an AI provider streams JSX, Storybook export, HTML and Tailwind JSX export, and a faster occurrence-scoped `.fig` reader with wider override and variable export. * Comments pinned to layers and voice calls in shared rooms, with collaborators watching the AI chat's streamed JSX as it builds. * Vue and React components of the selection in the Code tab, with variants as props and Reka UI or Radix behaviour. * SVG and HTML imported as editable layers, with HTML and CSS laid out as a browser lays them out. * Shader fills from the open-source `shaders` library, on-canvas gradient and corner-radius handles, multi-selection editing, and accent colours with a customizable toolbar. These changes are not part of v0.15.1; they ship with the next release. See [AI chat](../programmable/ai-chat), [exporting](../user-guide/exporting), and [components and libraries](../user-guide/components). ### In development Work with open pull requests. It may change before it merges: * Storybook stories generated as Reka UI components for Vue and Radix UI components for React. * Component authoring: variant editing and canvas selection inside components. * The OpenPencil Cloud foundation. ## Near-term work ### Figma fidelity * Preserve and round-trip more Figma metadata safely. * Add visual regression coverage for full multi-page `.fig` documents. `bun tools/generate/visual-oracles/src/cli.ts export-fixtures` exports current smoke fixture pages to `/tmp` for manual comparison without committing large images; `tests/fixtures/figma-oracles/visual-comparison-report.json` records the current Figma-vs-OpenPencil oracle diff findings. * Close high-impact renderer gaps: remaining mask edge cases, blend isolation, pattern fills, and broader variable-font fixtures. * Improve boolean operation editing/export now that imported Figma `BOOLEAN_OPERATION` nodes remain boolean operations. ### Editor depth * Complete variable inspector coverage for common numeric/text/layout fields. * Extend component and instance authoring with deeper override inspection and slots. Multidimensional variant definitions, text, boolean, and instance-swap property authoring, exposed nested instances, instance property editing, and component-library publish/update workflows are already supported. * Treat variables, styles, components, and libraries as governed design-system assets with proposal, review, publish, update, migration, and conformance workflows. * Complete layout-grid style parity beyond existing grid geometry controls and editable page/frame guides. * Extend inspection aids beyond the existing Option/Alt measurements between selected and hovered layers. * Expand vector editing workflows without regressing imported vector fidelity. ### Reliability and international text * Make document saves atomic and recoverable, including unsaved documents created through MCP and interrupted local or remote writes. * Surface provider, model-budget, renderer, and automation failures with actionable recovery paths instead of silent stalls. * Make desktop automation recover from orphaned processes and renderer crashes without manual cleanup. * Prevent non-Latin font discovery and rendering crashes across platforms; add Arabic/Persian shaping and RTL layout, then broaden CJK and mixed-script visual fixtures. * Build a portable-font strategy for reproducible documents across machines on top of existing substitution visibility and agent-readable font status, including curated redistributable fonts, embedded or linked document fonts, and licensing metadata ([#502](https://github.com/open-pencil/open-pencil/issues/502), [#503](https://github.com/open-pencil/open-pencil/issues/503)). ### Cloud and self-hosting * Provide an optional OpenPencil Cloud backend for account-based workspace sync, sharing, collaboration, comments, and managed team libraries without making cloud accounts mandatory for the editor. * Support organizations and teams with invitations, viewer/editor/admin roles, link-sharing policies, service accounts, API tokens, and enterprise identity through standard OIDC/SSO integrations. * Publish documented backend APIs and webhooks for workspace, document, membership, comment, version, and automation events so Cloud and self-hosted deployments integrate with existing developer workflows. * Add workspace organization for projects, folders, templates, search, indexing, and server-generated previews while preserving stable document identity. * Add version history with automatic snapshots, named checkpoints, restore, retention controls, and an auditable record of important document and membership changes. * Provide a durable collaboration relay for deterministic initial sync, presence, reconnects, and restricted-network environments while keeping direct local/P2P workflows available where practical. * Publish a production-ready self-hosted deployment for teams that need their own storage, identity, network boundary, data location, and retention policy. * Make self-hosting maintainable with guided deployment, upgrades, backups, health monitoring, observability, and documented recovery procedures. * Provide explicit data-governance controls for export, deletion, encryption, retention, auditability, and deployment-region or residency requirements. * Keep AI and media capabilities BYOK so Cloud and self-hosted users connect and control their own model and provider credentials. * Let documents move between device-only, OpenPencil Cloud, self-hosted, and user-owned storage without losing identity or history. * Add explicit conflict, offline, sync-health, migration, backup, quota, and recovery UX for every remote deployment mode. ### Agent workflows * Polish the official `SKILL.md` guidance for OpenPencil so agents use the full inspect → act → render/measure → compare → iterate loop instead of relying on one-shot prompting. * Publish tested AI workflow recipes for common tasks: create from prompt, edit a selected design, compare against a screenshot or Figma reference, fix visual regressions, extract tokens, and batch-migrate files. * Accept screenshots and reference images as first-class agent inputs, and return selection/page/viewport renders as native image content to vision-capable MCP and chat clients. * Support opt-in web retrieval, external MCP connectors, and sandboxed code execution through explicit capability and permission boundaries rather than granting every model ambient access. * Make structured node-tree diffs a first-class, Git-friendly review artifact for UI, CLI, MCP, SDK, and CI edits instead of relying only on screenshot comparisons. * Expand design lint findings with expected/actual evidence and safe autofixes; measure rule precision before enabling lint rules as blocking gates. * Keep deterministic golden renders and replayable edit-operation histories so regressions can be reproduced from document state and actions, not only from final pixels. * Make agent workflows measurable by default: every substantial operation should be able to produce a render, structured diff, lint result, or comparison artifact. * Keep MCP, CLI, and SDK operations aligned so agent skills can run the same workflow in desktop, browser, CI, or headless file mode. ### Tooling and API parity * Maintain a public tool/API reference that maps editor operations to CLI commands, MCP tools, SDK APIs, and Figma Plugin API-compatible eval usage. * Add coverage tests that detect when a core editor capability exists in the UI but is missing from CLI/MCP/SDK, or vice versa. * Keep tool outputs structured enough for agents to chain safely: node IDs, bounds, diffs, render artifacts, diagnostics, and machine-readable error details. * Improve deterministic CLI/MCP export and comparison tools for CI. * Add more design linting and migration helpers for batch `.fig` and `.pen` workflows. * Make `.pen` a first-class editable save target across app, CLI, MCP, and SDK workflows rather than an import-only or automation-specific format. * Extend HTML, CSS, Tailwind, and JSX support from editable import toward URL-based website capture, direct JSX paste/edit workflows, and component-aware design↔code updates that preserve intentional code structure instead of regenerating whole files. * Provide an adapter contract for additional design sources such as Stitch and Pixso, prioritizing formats with documented or testable semantics over brittle UI scraping. * Package desktop-side MCP integration so local agent workflows do not require global installs. ### Performance and scale * Incremental layout and render invalidation for large documents. * Better renderer profiling surfaces for slow nodes, effects, masks, and imported files. * Smarter raster/retained caching that preserves fidelity during zoom and pan. ### Interactive shader layers * Add Unicorn Studio-style shader scenes as first-class design layers: animated gradients, particles, noise fields, metaballs, lighting, displacement, and pointer-reactive backgrounds. * Provide a preset-first editor for common generative visuals before exposing raw shader code. * Support timeline and interaction inputs such as time, pointer position, scroll, layer bounds, colors, variables, and imported image textures. * Render shader layers through CanvasKit/WebGL while keeping deterministic raster export for PNG/JPG/WEBP and thumbnails. * Store shader layer configuration in OpenPencil documents and export graceful fallbacks when a target format cannot preserve the live effect. ## Later ### SDK and embedded editor * Expand the documented Vue SDK and core package platform with complete example applications for custom editor shells, embedded design surfaces, and automation-specific UIs. * Provide maintained examples for read-only previews, editable canvases, design review surfaces, and agent-controlled editors. * Ship an official VS Code/Cursor extension ([#81](https://github.com/open-pencil/open-pencil/issues/81)) for previewing and opening `.fig`/`.pen` documents, connecting to the running editor, invoking CLI/MCP workflows, handing selections between code and canvas, and navigating between generated code and design nodes. Reuse the app, SDK, and automation bridge rather than implementing a second editor inside the extension. * Define public API stability and migration expectations across the reusable npm packages. * Keep the renderer, editor core, and tool registry framework-agnostic enough for headless and embedded use. ### Product depth * Prototyping: frame connections, triggers, overlays, transitions, preview mode, and AI/JSX-authorable interaction definitions. * Motion: reusable code-authored animation presets, timelines, easing, and deterministic playback/export that compose with prototype interactions and shader inputs. * Tables and data grids: structured rows, columns, headers, resizing, merged cells, and normal scene nodes inside cells instead of drawing tables as unrelated rectangles. * Comments: pins, threads, resolution state, and collaboration-aware display. * Extend shared libraries beyond the existing component publish/consume/update workflow to broader style management and governance. * Platform asset libraries: use licensed system-native and third-party icon sources, including SF Symbols where platform and redistribution rules allow, alongside the existing Iconify/Lucide workflow. * Figma Slides (`.deck`) interoperability: import/export, slide editing, presentation, speaker notes, and filmstrip workflows. This ranks below core Figma Design fidelity, reliability, Cloud/self-hosting, and international-text work. * Platform polish: Windows code signing, PWA support, packaged updater improvements, and desktop-side MCP bundling. ## Non-goals * Mandatory accounts or a cloud-only document model. OpenPencil Cloud, self-hosted backends, and user-owned remote storage must remain optional alongside local files. * A hosted service that requires OpenPencil to proxy users' AI provider keys; AI and media integrations remain BYOK even when a backend provides identity, sync, or collaboration. * Read-only automation surfaces that cannot modify documents. * Feature work that sacrifices `.fig` import/export fidelity for convenience. This section tracks OpenPencil's current compatibility with Figma Design features. It is based on Figma's public Help Center feature areas and the current OpenPencil scene graph, Kiwi import/export, CanvasKit renderer, UI panels, CLI, and MCP tools. Legend: * **✅ Supported** — implemented for common files and expected to work directly. * **◐ Partial** — implemented for important cases, but missing parity, UI, or edge-case behavior. * **↩ Round-trip only** — imported/preserved/exported for `.fig` fidelity, but not rendered or editable as a first-class OpenPencil feature. * **— Not supported** — not currently modeled or intentionally out of scope. Support tiers used for prioritization: 1. **Visual fidelity** — fields that change pixels in normal design exports. These get real Figma oracle fixtures, renderer tests, and visual metrics first. 2. **Round-trip fidelity** — fields that should survive read → write → Figma import but do not need OpenPencil UI/rendering yet. These need raw-preservation and invalidation tests. 3. **Product/runtime systems** — prototypes, libraries, FigJam, Slides, Dev Mode, CMS/AI, and media timelines. These stay schema-only or raw-preserved until OpenPencil has matching product concepts. 4. **Unsafe/internal metadata** — fields that can corrupt Figma import or overwrite user edits when stale. These are filtered or preserved only with fixture evidence. ## Official Figma feature areas Figma's design documentation groups features into these areas: * Layers, frames, groups, sections, shape layers, text, vectors, and boolean operations. * Fills, gradients, images, patterns, blend modes, strokes, effects, corner radius, and corner smoothing. * Auto layout: vertical, horizontal, wrap, grid, padding, gap, hug/fill/fixed/min/max, and ignore auto layout. * Components, instances, variants, component properties, slots, libraries, and library updates. * Variables: color, number, string, boolean, collections, modes, aliases, scopes, and prototype variables. * Prototyping: flows, hotspots, triggers, actions, overlays, smart animate, easing, conditionals, expressions, and variable actions. * Dev Mode: inspect, measurements, annotations, Code Connect, dev resources, ready-for-dev states, and Figma MCP. * Collaboration/file workflows: comments, version history, thumbnails, branches, library publishing, and multiplayer metadata. ## Figma compatibility matrix | Area | Import | Render | UI edit | Export round-trip | CLI/MCP | Notes | | ---------------------------------------------------- | -----: | -----: | ------: | ----------------: | ------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Pages / canvases | ✅ | ✅ | ✅ | ✅ | ✅ | Multi-page documents and per-page viewport are supported. | | Frames | ✅ | ✅ | ✅ | ✅ | ✅ | Includes clipping and auto-layout container behavior. | | Groups | ✅ | ✅ | ✅ | ✅ | ✅ | Grouping preserves visual positions. | | Sections | ✅ | ✅ | ✅ | ✅ | ✅ | Section rendering and title pills are OpenPencil-specific approximations. | | Rectangles / rounded rectangles | ✅ | ✅ | ✅ | ✅ | ✅ | Per-corner radii and smoothed corners render for fills, strokes, clips, masks, and effects. | | Ellipses / arcs | ✅ | ✅ | ◐ | ✅ | ✅ | `arcData` renders/exports; no full inspector controls. | | Lines | ✅ | ✅ | ✅ | ✅ | ✅ | Stroke caps, joins, dashes, alignment, and miter limits render and have inspector controls. | | Polygons / stars | ✅ | ✅ | ◐ | ✅ | ✅ | `pointCount` and `starInnerRadius` modeled. | | Text | ✅ | ✅ | ✅ | ✅ | ✅ | Case, justification, vertical alignment, truncation/max lines, common OpenType features, and derived Figma glyph fallback are supported; uncommon typography metadata remains round-trip only. | | Vectors / vector networks | ✅ | ✅ | ◐ | ✅ | ✅ | Vector edit support exists; Figma Draw tools are not fully replicated. | | Boolean operations | ✅ | ✅ | ◐ | ✅ | ✅ | Figma `BOOLEAN_OPERATION` nodes import/export as boolean operations; inspector editing remains limited. | | Components | ✅ | ✅ | ◐ | ✅ | ✅ | Component metadata, descriptions, links, and publish fields mostly round-trip. | | Component sets / variants | ✅ | ✅ | ◐ | ✅ | ✅ | Multidimensional variant definitions and common instance properties are editable; broader Figma-specific property parity remains incomplete. | | Instances / overrides | ✅ | ✅ | ◐ | ✅ | ✅ | Component-property refs and typed assignments are modeled and editable; raw symbol overrides and derived data remain preserved for fidelity. | | Slots | ✅ | ✅ | ✅ | ✅ | ✅ | Slot properties, settings, and instance slot content import, render, and round-trip. The editor creates, configures, and removes slots on main components and fills them in instances; scripts do the same through `createSlot()`, `resetSlot()`, and `limitViolations`. | | Connectors | ◐ | ◐ | — | ◐ | ◐ | Type exists, but Figma connector semantics are weak. | | Shape-with-text / FigJam shapes | ◐ | ◐ | — | ◐ | ◐ | Type exists, but not a full FigJam feature implementation. | | Slices | ◐ | — | ◐ | ◐ | ✅ | Slice-like export regions exist via tooling, not as true Figma slice nodes. | | FigJam / Slides / Code / CMS / Buzz node families | ↩ | — | — | ↩ | — | Current Kiwi schema recognizes many newer Figma node families (`TABLE`, `SLIDE`, `CODE_COMPONENT`, `CMS_RICH_TEXT`, `REPEATER`, `WEBPAGE`, etc.), but OpenPencil only preserves/round-trips them where safe; they are not first-class scene nodes. | | Solid fills | ✅ | ✅ | ✅ | ✅ | ✅ | Color variables supported for common fill cases. | | Gradients | ✅ | ✅ | ✅ | ✅ | ✅ | Linear/radial/angular/diamond support; Figma edge cases may differ. | | Image fills | ✅ | ✅ | ◐ | ✅ | ✅ | Fill/fit/crop/tile support exists; imported crop/tile affine transforms are applied, but exact Figma parity is still partial. | | Pattern / noise / custom fills | ✅ | ◐ | — | ✅ | — | Schema metadata imports/exports; Figma pattern fills with a referenced source node render as repeated source tiles with scale, spacing, alignment, and basic hex offsets. Noise/custom paints still render with a solid fallback pending real paint payload samples; Figma-authored noise/texture/glass effect payloads are captured separately. | | Video/GIF/media fills | ↩ | — | — | ↩ | — | Kiwi schema includes media paint/export enums, but OpenPencil has no video/GIF playback or media layer support. | | Layer/fill/effect blend modes | ✅ | ◐ | ✅ | ✅ | ✅ | Appearance, fill, and effect controls are exposed; Canvas applies common modes, while Figma isolation edge cases remain partial. | | Opacity | ✅ | ✅ | ✅ | ✅ | ✅ | Node opacity uses save layers in the renderer. | | Strokes | ✅ | ✅ | ✅ | ✅ | ✅ | Weight, alignment, dashes, and side weights are supported. | | Stroke caps / joins / miter limit | ✅ | ✅ | ✅ | ✅ | ✅ | Inspector controls support mixed cap/join/miter editing; CanvasKit rendering and `.fig` roundtrips preserve miter limits. | | Effects: shadows and blurs | ✅ | ✅ | ✅ | ✅ | ✅ | `showShadowBehindNode` is rendered but not exposed in UI. | | Fill / stroke / effect styles | ✅ | ✅ | ◐ | ✅ | ✅ | Imported local definitions are modeled and selectable with undo-safe detach; creating and publishing styles still needs a style manager. | | Corner radius | ✅ | ✅ | ✅ | ✅ | ✅ | Uniform and independent radii supported. | | Corner smoothing | ✅ | ✅ | ✅ | ✅ | ✅ | The inspector supports mixed smoothing percentages with undo; uniform and independent-radius corners render, while exact Figma parity still needs broader fixture tuning. | | Masks | ✅ | ◐ | — | ✅ | ✅ | Figma schema `mask`, `maskType`, and `maskIsOutline` fields import and export; common sibling alpha/vector/luminance mask stacks render, including consecutive mask layers. UI controls and deeper Figma edge cases remain incomplete. | | Auto layout: vertical/horizontal | ✅ | ✅ | ✅ | ✅ | ✅ | Yoga-backed layout. | | Auto layout: wrap | ✅ | ✅ | ✅ | ✅ | ✅ | UI toggle exists. | | Auto layout: grid | ✅ | ◐ | ◐ | ✅ | ✅ | CSS-grid-like support is partial; newer schema fields for grid child alignment and auto tracks are not fully exposed. | | Padding / gaps / alignment | ✅ | ✅ | ✅ | ✅ | ✅ | Common flex controls are exposed. | | Hug / fill / fixed sizing | ✅ | ✅ | ✅ | ✅ | ✅ | Min/max support is partial in UI. | | Ignore auto layout / absolute positioning | ✅ | ✅ | ◐ | ✅ | ✅ | Mode is modeled; UI coverage is partial. | | Strokes included in layout | ✅ | ◐ | — | ✅ | ✅ | Stored/exported and used in layout paths, but no obvious panel control. | | Reverse z-index / align-content | ✅ | ◐ | — | ✅ | ✅ | Modeled and exported; UI is limited. | | Constraints | ✅ | ◐ | ✅ | ✅ | ✅ | Horizontal and vertical pin, center, stretch, and scale modes are editable; imported edge-case parity remains partial. | | Layout grids / guides | ✅ | ✅ | ◐ | ✅ | ✅ | Layout-grid geometry and page/frame guides are editable, including guide creation, movement, duplication, transfer, and removal. Full grid-style management remains incomplete. | | Text styles | ✅ | ✅ | ◐ | ✅ | ✅ | Imported local text styles are modeled, selectable, and detachable; authoring and publishing style definitions still needs a style manager. | | Rich style runs | ✅ | ✅ | ◐ | ✅ | ✅ | Import/render/export support; editing mixed runs is partial. | | Text auto resize | ✅ | ✅ | ◐ | ✅ | ✅ | Used by renderer/layout; UI does not expose every mode. | | Text truncation / max lines | ✅ | ✅ | ✅ | ✅ | ✅ | Ending truncation and maximum-line controls are available in the inspector. | | Text case | ✅ | ◐ | ✅ | ✅ | ✅ | Original, upper, lower, and title case are editable; broader render parity still needs fixtures. | | Vertical text alignment | ✅ | ◐ | ✅ | ✅ | ✅ | Top, center, and bottom alignment are editable; imported edge-case parity needs more coverage. | | Justified text | ✅ | ◐ | ✅ | ✅ | ✅ | Justification is exposed in the typography inspector; render parity remains partial. | | Font variations / OpenType features | ✅ | ✅ | — | ✅ | — | Imported `fontVariations`, common ligature/caps/numeric OpenType fields, and raw `toggledOnOTFeatures` / `toggledOffOTFeatures` are applied to CanvasKit text styles and exported; UI controls are not exposed. | | Variables: collections/modes/aliases | ✅ | ◐ | ◐ | ✅ | ✅ | Color/number/string/boolean model exists; inspector coverage is still incomplete. | | Variables bound to fills/strokes | ✅ | ✅ | ✅ | ✅ | ✅ | Common color bindings render and edit. | | Variables bound to text/layout/visibility/effects | ◐ | ◐ | ◐ | ◐ | ✅ | Text content and numeric layout bindings resolve on read; colour, numeric, text, and visibility bindings also re-resolve when a variable or mode changes, font family ones do not. Effect bindings are not modelled. | | Variables in prototypes / expressions / conditionals | — | — | — | — | — | Depends on prototype system, which is not implemented. | | Libraries / publish / update review | ↩ | — | ◐ | ↩ | — | Figma library metadata survives round-trip. OpenPencil supports its own component-library publishing, catalogs, revision previews, and linked-instance updates, not synchronization with Figma-hosted libraries. | | Prototype flows / starting points | — | — | — | — | — | Not modeled. | | Prototype hotspots / triggers / actions | — | — | — | — | — | Not modeled. | | Prototype overlays / scroll-to | — | — | — | — | — | Not modeled. | | Smart animate / easing / spring / duration | — | — | — | — | — | Not modeled. | | Interactive components | — | — | — | — | — | Figma's component-level prototype connections are not supported; OpenPencil's own behaviours make components run as Reka UI controls in preview. | | Dev Mode inspect / measurements / annotations | — | ◐ | ◐ | — | ◐ | OpenPencil has CLI/MCP inspection and Option/Alt distance overlays, but not Figma Dev Mode annotations or its full UI. | | Code Connect / dev resources / ready-for-dev | — | — | — | — | — | Not modeled. | | Comments | — | — | — | — | — | Not modeled. | | Version history / branches | — | — | — | — | — | Not modeled. | | Real-time collaboration | — | ✅ | ✅ | — | — | OpenPencil has its own P2P collaboration, not Figma-compatible metadata. | ## Raw Kiwi metadata coverage OpenPencil deliberately preserves many Figma/Kiwi fields even when they are not rendered or editable. These live under `SceneNode.source.fig` and are applied late during `.fig` export. A schema coverage test compares the current `fig.kiwi` `NodeChange` fields against modeled codec fields, raw-preserved fields, and intentionally schema-only metadata buckets so drift stays visible. | Field group | Import/export | Render | UI | Fidelity impact | | ----------------------------------------------------------------------- | ------------: | -------: | --: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `source.fig.rawSize` | ✅ | Indirect | — | Preserves original Figma size for round-trip. Cleared when size is edited. | | `source.fig.rawTransform` | ✅ | Indirect | — | Preserves exact Figma transform. Cleared when transform is edited. | | `source.fig.rawNodeFields` | ✅ | Mixed | — | Late-applied to exported NodeChange for round-trip fidelity; modeled edits invalidate only matching stale raw fields, while unrelated prototype/library metadata survives. Raw-field and schema coverage tests guard preservation drift. | | `source.fig.layout` | ✅ | ✅ | ◐ | Preserves original Figma stack metadata while using normalized layout fields. | | `source.fig.symbolOverrides` | ✅ | Indirect | — | Important for instance override fidelity. | | `source.fig.componentPropAssignments` | ✅ | Indirect | ◐ | Used for component property fidelity; not raw-editable. | | `source.fig.derivedSymbolData` | ✅ | Indirect | — | Critical for instance-derived geometry/layout/text. | | `source.fig.derivedSymbolDataLayoutVersion` | ✅ | — | — | Figma bookkeeping. | | `source.fig.uniformScaleFactor` | ✅ | Indirect | — | Important for scaled instances. | | Style IDs: fill/stroke/text/effect/grid | ↩ | — | — | Preserves style linkage for Figma, but OpenPencil has no style manager yet. | | Component property refs/defs/specs | ✅ | Indirect | ◐ | Text, boolean, and instance-swap properties, their links, and exposed nested instances (`propsAreBubbled`) are authored in the panel. | | State-group metadata | ↩ | — | — | Preserved only. | | Version/sort/publish/library metadata | ↩ | — | ◐ | Figma metadata is preserved; OpenPencil's component-library publish/update workflow uses its own stable asset identities and revision review. | | Variable and parameter consumption maps | ✅ | ◐ | ◐ | Filtered/preserved for safe round-trip; normalized bindings cover common cases. | | Page fields: background, page type, guides | ↩ | ◐ | ◐ | Background color, background paints, page type, and guides round-trip for imported pages. Normalized page/frame guides render as overlays and support undoable editing. | | Text internals: `textData`, layout versions, font version, derived data | ✅ | ✅ | — | Important for text fidelity; most internals are not editable. Imported derived text data, leading trim, decoration style, underline decoration paint/offset/thickness/skip-ink, semantic font metadata, and raw OpenType feature toggles are preserved for round-trip when safe; decoration style/thickness/color and leading trim now render through CanvasKit, and raster export bounds account for decoration overflow. | | `fontVariations` | ✅ | ✅ | — | Variable font axes are imported, rendered, and exported for text nodes and style runs. | | Raw paint/effect/vector/geometry payloads | ✅ | ✅ | ◐ | Converted fields render; raw payloads preserve Figma import/export details, including mask, background paint, layout grid, export setting, and prototype interaction metadata where safe. | ## Highest-priority visual gaps These are parsed or visible in Figma docs and most likely to cause visible differences in real design files: 1. **Masks** — tune remaining exact Figma stack semantics beyond common alpha/vector/luminance and consecutive-mask paths. `tests/fixtures/figma-oracles/masks.json` records live Figma API values for alpha, vector, and luminance masks. 2. **Corner smoothing** — expand Figma fixture comparisons and tune remaining stroke/effect edge cases. 3. **Pattern/noise/custom fills** — tune first-class pattern rendering for nested/effectful pattern sources and exact Figma hex spacing. `tests/fixtures/figma-oracles/pattern-noise-custom-paints.json` captures a real async Figma `PATTERN` payload and Figma-authored noise/texture/glass effect payloads; real `NOISE` / `CUSTOM` paint payloads remain blocked on Figma-authored samples. 4. **Variable-font and rich text fixtures** — broaden real-file coverage for variable axes, derived text data, leading trim, decoration style, underline offset/skip-ink, semantic font metadata, and raw OpenType feature metadata; `tests/fixtures/figma-oracles/rich-text-decoration.json` captures the first live Figma rich-text oracle. 5. **Boolean operation editing** — improve inspector/tooling workflows for imported boolean-operation nodes. 6. **Layout grids and guides** — broaden grid-style parity and Figma edge-case coverage; common grid geometry and page/frame guide editing are already supported. 7. **Full component property and slot workflows** — support authoring, not just preserving imported payloads. 8. **Prototype/media/interaction metadata** — schema now includes more interaction, media runtime, animation, and slide fields; start by preserving flows/connections/runtime metadata before building playback. ## Code map | Concern | Files | | ---------------------------- | -------------------------------------------------------------------------------------------------------------- | | Scene graph fields | `packages/scene-graph/src/types.ts` | | Source edit tracking | `packages/scene-graph/src/source-metadata.ts` | | `.fig` metadata policy | `packages/fig/src/source-metadata.ts` | | Kiwi import mapping | `packages/fig/src/node-change/convert.ts` | | Kiwi export mapping | `packages/fig/src/node-change/export/node.ts`, `packages/fig/src/node-change/serialize.ts` | | Kiwi schema | `packages/kiwi/src/fig/schema/fig.kiwi`, `tests/engine/io/fig/import/schema-coverage.test.ts` | | Renderer dispatch | `packages/core/src/canvas/scene.ts` | | Fills / images / gradients | `packages/core/src/canvas/fills.ts` | | Strokes | `packages/core/src/canvas/strokes.ts` | | Effects / shadows | `packages/core/src/canvas/shadows.ts` | | Text rendering | `packages/core/src/canvas/text.ts`, `packages/core/src/canvas/text-derived.ts` | | Layout engine | `packages/core/src/layout/**` | | Property panels | `src/components/properties/**`, `packages/vue/src/controls/**` | | CLI | `packages/cli/src/index.ts`, `packages/cli/src/commands/**` | | MCP/tools | `packages/core/src/tools/**`, `packages/mcp/src/tool/registration.ts` | --- --- url: 'https://openpencil.ink/development/ai-chat.md' --- # AI chat replies How the built-in AI chat turns a message into edits, how a reply ends, and how what happens to its edits afterwards reaches the transcript and the model. ACP and Pi chats share the transcript, the saved conversation, and the marks on a reply, but run their own tool loop and agent presence. ## A message goes out `src/app/ai/chat/submission/use.ts` sends a message through the AI SDK's `Chat`. Before sending, it appends notes for the model to the text: the layers you referenced, and the edits you undid or redid since the model last heard (`context.ts`). The transcript shows only what you wrote; `stripModelNotes` removes the notes. Notes go on the newest message only. Earlier messages never change, so the provider can keep caching the conversation up to the new message. `history/continuation.ts` (`requestMessages`) leaves tool calls without a result out of the request. They stay in the saved transcript. ## A run `transports.ts` starts a run (`tools/run.ts`) when the message is sent, before the model is reached, so a Stop at any point ends a run that exists. A `Run` belongs to one reply and holds: * the page it works on, which your navigation does not move and the agent's `switch_page` does; * the step budget captured when the message started; * each page as it was before the run first edited it, for `diff_changes`; * every undo entry its calls pushed, with the tool and call that pushed it; * the calls that changed the document, and the undone calls it already told the model about. The chat's agent is one presence entry per document (`presence/registry.ts`). It shows as thinking when the run starts and points at the layers each call changed, on whichever page they are. With Follow agents on, following starts with the run and ends when the agent rests and none of your other agents is at work. An agent you chose to follow keeps you with it while it rests. ## A tool call `tools/index.ts` runs every call through `executeCall`: * Calls that change the document take a lock in turn, since whole-page snapshots must not interleave. A call that gets the lock after the reply ended changes nothing and returns an error. * The document change is captured, pushed as one undo step labelled `aiUndoLabel(tool)`, and recorded on the call's own run, the one it started in, even if a later reply has begun. * The change is recorded for the chat's change review, and the agent points at it. * A call's result reaches the model as JSON. `eval` converts what a script returns, as MCP and the CLI print it. * If you undid or redid the run's own edits since the model last heard, the result carries an `undoNote` saying so. ## A reply ends A run ends once, in one of three ways: finished (`onFinish`), failed (`handleError`), or stopped (the request's abort signal). The agent goes idle and its cursor leaves the canvas. A tool already running keeps going: its edit lands in its run, but cannot move the agent again. When the chat's own `onFinish` reports a reply that was stopped, failed, or cut off, `settleEndedReply` waits for the run's last edit, then settles the message (`settle.ts`): * reasoning and text are marked done; * each call that never reported back fails with whether it changed the document, which the model reads next time; * a stopped reply is marked stopped. Diagnostics record `chat.completed`, `chat.stopped`, or `chat.failed`. `submission/undone.ts` records the reply's turn (`turns.ts`) once the run settles, so an edit landing after Stop belongs to the reply. ## Undo and the model A turn keeps the reply's calls and their undo entries. On every undo history change, each turn works out which of its calls are undone with `UndoManager.stateOf`: an entry on the redo stack is undone, one on the undo stack applied. An entry that left the history keeps its last state; undone and then cleared from Redo by a new edit, it stays undone. Changes are written to the reply's metadata, and the transcript reads only the metadata. Edit → Undo and the chat's **Revert changes** therefore end in the same state: Revert undoes the reply's edits still applied, when they are the newest on the undo stack, and **Restore changes** redoes its undone edits, when Redo would bring exactly those back next. `undoToReport` compares each reply's undone calls with what the model was last told, and the next message carries the difference as a note. The report records the message that carried it. If that message is sent again, for example when you edit it, the report is made again. ## Saved conversations A reply's metadata (`chat/metadata.ts`) keeps whether you stopped it, and its edits with those undone and what the model was last told. Conversations save messages with `structuredClone`, so metadata is written from the plain message, never from the chat's reactive copy. Reopening a conversation settles any reply saved mid-way. The undo history does not survive closing a document, so a reopened reply keeps its marks but offers no Revert or Restore. ## Traces Save trace… in the conversation menu writes a file to reproduce what the agent did (`chat/trace/export.ts`). It holds the conversation's messages, its runs, and its diagnostic events. A run is one message sent and every step the model took to answer it (`chat/trace/recorder.ts`). It records the model and settings, the last message sent, and per step the response's finish reason, usage, warnings, and duration; a failed run keeps the error, and the step that failed is the one after the last recorded. Instructions and the tools offered are stored once under their fingerprints and shared by every run that used them. A step's request is not stored. It is the conversation through the run's last message, as `requestMessages` prepared it, plus the reply's earlier steps; `chat/trace/rebuild.ts` rebuilds it and compares its fingerprint with the one recorded when the step ran, so a rebuild that no longer matches what was sent is reported instead of replayed. Runs live in the chat database beside their conversation and are deleted with it. --- --- url: 'https://openpencil.ink/development/behaviours-and-preview.md' description: >- How components become working controls, how agents and scripts author them, and how preview runs them as live Reka UI islands. --- # Behaviours and Preview A behaviour makes a main component or component set work as a real control: a Switch, Slider, Tabs, Text field, and so on, after [Reka UI](https://reka-ui.com)'s primitives. It adds no node types. It describes which of the component's existing properties hold the control's values, which of its slots draw the control's parts, and which variants draw its interaction states. Preview then runs the control as the real Reka UI component, and code export can generate the same. ## The model The model lives in `@open-pencil/scene-graph` (`packages/scene-graph/src/behaviours/`). * **Kinds and contracts** (`kinds.ts`). `BEHAVIOUR_CONTRACTS` gives each kind its values and parts, keyed by kind, so a kind without a contract does not type-check. Contracts follow Reka UI's anatomy: every Reka subcomponent is a slot of the component, the component itself is the root, and a slot that holds repeated parts names the Reka part of its children, such as the tab list's triggers or a group's items. * **Storage** (`schema.ts`, `model.ts`). A behaviour is stored as the `behaviour` field of OpenPencil's plugin data on the component or set, with properties and slots bound by id. Its role is `content`, so it travels with library assets and counts towards their update hash. `.fig` export renames the bound ids to the GUIDs it gives the properties. * **Values**: a boolean is a variant property (with the values that mean on and off) or a boolean property; text is a text property; a number is the behaviour's own range, since Figma has no number property; a choice is which child of a slot is active. * **States**: a variant property whose values draw rest, hover, pressed, focus, and disabled. * **Specs** (`spec.ts`). `behaviourFromSpec` and `behaviourToSpec` convert between the stored behaviour and a spec that names properties and slots, which is how every authoring surface except the panel writes it. Errors name what the component has. ## Authoring surfaces All of them write the same model. | Surface | Where | Notes | |---|---|---| | Behaviour section | `src/components/properties/component-properties/behaviour/`, `useBehaviour` in `packages/vue` | Binds by id; Core's editor actions (`setBehaviour`, `addBehaviourText`, `addBehaviourVariant`, `addBehaviourPart`) create what is missing in one undo step. | | `openpencil` scripting API | `packages/core/src/openpencil-api/` | The `openpencil` global next to `figma` in every script runner, through `compileScript`. | | Tools | `packages/core/src/tools/create/behaviours.ts` | `set_behaviour`, `get_behaviour`, `create_slot`, built on the `openpencil` API. | | Design JSX | `packages/design-jsx/src/behaviours/`, `export/behaviours.ts` | Reka-named elements (`Switch.Root`, `Switch.Thumb`) render and export components with behaviours. | ## Preview islands Preview is a per-pane mode (`EditorViewState.play`). It runs controls as real components; it never simulates them on the canvas. 1. **Islands.** `playIslandRoots` (Core) picks the top-level layers of the page that hold an instance with a behaviour. The renderer skips them while the pane previews, and `PlayIslands` (`packages/vue/src/canvas/islands/`) mounts one island per root, laid over the canvas at the pan and zoom of the canvas's last drawn frame, so the islands never run ahead of a canvas still catching up while panning and zooming. 2. **Shadow DOM.** Each island renders into its own shadow root: app CSS does not reach in, island CSS does not leak out, fonts already loaded by the app apply, and the island moves with the canvas in the same frame. Wheel gestures over an island are passed to the canvas. 3. **State to design.** An island keeps each control's state (values, choice, number, typed text, hover, pressed, focus). Core's `resolvePlayState` copies the island's layers into a private graph and shows each instance in its state: the matching variant, boolean properties, and layers a control reveals, such as tab panels. The copy comes from the page extractor history and export use (`extractPageContext`), with every variant of each instance's set so it can switch, and the document's variables and images shared rather than copied, since a state never changes them. The document is never changed. 4. **Design to DOM.** The private graph is projected to DOM through `@open-pencil/dom-css`, the same projection HTML export uses, so island fidelity and export fidelity improve together. 5. **DOM to Reka.** Each projected layer has a role: a control's root, a part, a group's item, a tab trigger or panel, or a text input. `behaviourControls` and `controlRoles` in `@open-pencil/dom-css` (`packages/dom-css/src/behaviours/`) assign them, so code export reads the same roles. Roles wrap the layer in its Reka UI primitive with `asChild`, so Reka renders through the designed element. Typing, focus, keyboard navigation, and accessibility are the browser's and Reka's. Controls and DOM keys use layer paths (names below the island root), not node ids: a variant switch rebuilds an instance's layers with new ids, and a stable path keeps the DOM in place, including a focused input. ## Code export Export reads the same model as preview. `behaviourArgs` gives a component set its props: a boolean value drawn by an on/off variant property becomes `checked`, `pressed`, `open`, or `disabled`. `stateStyles` merges a set's variants into one markup tree, matching layers by layer path: * The rest variant is the base. Each other variant becomes a rule holding only what it changes, under conditions on the control's root. * The conditions are the attributes Reka and Radix set, `data-state` and `data-disabled`; the browser's `:hover`, `:active`, and `:focus-visible`; and a `data-*` attribute for any other variant property, which the generated component sets from its prop. * Layers only some variants draw stay in the tree, hidden where absent, and a label that reads differently is a layer per state. A combination the set doesn't draw combines from its parts, and a drawn combination keeps only what its parts don't already give. `stateStylesToCSS` writes the result as a stylesheet with a readable class per layer. `stateStylesToTailwind` writes it as utilities behind state variants, with a named group per control so a control nested in another reacts only to its own root. ## Adding a kind or a part 1. Add the kind to `BEHAVIOUR_KINDS` and its contract to `BEHAVIOUR_CONTRACTS` in scene-graph; part ids follow Reka's part names. 2. Map it in preview: its root and parts in `packages/vue/src/canvas/islands/controls/`, with the Reka primitives it uses. 3. Name its elements in design JSX: `REKA_ELEMENTS` in `packages/design-jsx/src/behaviours/index.ts` and the export's root table in `export/behaviours.ts`. 4. Label it in the Behaviour section (`labels.ts`) and translate the strings. 5. Cover the model and spec in scene-graph tests, preview in `tests/e2e/components/behaviours.spec.ts`, and JSX in `packages/core/tests/design-jsx/behaviours.test.ts`. --- --- url: 'https://openpencil.ink/development/navigation-performance.md' --- # Navigation performance benchmark The navigation benchmark measures the complete wheel/trackpad-to-render pipeline. It is intentionally separate from the synchronous microbenchmark in `tests/e2e/viewport/zoom-pan.spec.ts`: accepting JavaScript calls quickly does not prove smooth navigation. ## Renderer lifecycle and ownership The benchmark validates the retained and tiled renderer contracts documented in [Renderer lifecycle and ownership](./renderer-lifecycle.md). Structural renderer changes must preserve those generation, settlement, and CanvasKit ownership rules. ## What it captures Every run writes `recording.json`, `metrics.json`, and `environment.json`. When tracing is enabled, it also writes `trace.json.gz`: * `trace.json.gz` (when tracing is enabled): Chromium tracing data for Perfetto or Chrome tracing tools. * `recording.json`: wheel samples plus OpenPencil input, viewport, render, and retained-backing events. * `metrics.json`: frame pacing, input latency, zoom-anchor drift, viewport jumps, exact active-renderer settlement, and tiled scheduler throughput/cancellation when enabled. * `environment.json`: browser, runtime, replay mode, and source gesture information. OpenPencil emits User Timing marks under `openpencil:*`, including wheel receipt/flush, viewport mutation, render start/end, backing preview/build, crisp-backing completion, and exact tiled coverage. The recording also runs a continuous `requestAnimationFrame` heartbeat and observes browser Long Tasks, so display stalls remain visible even when OpenPencil does not render. ## Record a physical macOS trackpad gesture 1. Start a production preview (preferred) or development server: ```sh bun run build bun run preview -- --port 1420 ``` 2. Open: ```text http://localhost:1420/?test&no-chrome&no-rulers&navigation-benchmark ``` 3. In DevTools, start recording: ```js openPencil.test.navigation.startRecording('macbook-fast-pinch-reversal') ``` 4. Perform exactly one gesture, allow the canvas to become crisp, then stop and copy the result: ```js copy(JSON.stringify(openPencil.test.navigation.stopRecording(), null, 2)) ``` 5. Save the result under `tests/fixtures/navigation/gestures/`. Do not edit delta values or timestamps. Remove document names if they contain private information. Record at least slow pan, momentum pan, direction reversal, slow pinch, fast pinch in/out, pinch reversal, diagonal pan, and the personally observed failing gesture. Recordings from actual hardware must use `source: "macos-trackpad"`; generated fixtures use `source: "synthetic"`. ## Benchmark a real `.fig` fixture Use `current-document` with an explicit browser-served fixture path. The runner waits for loading and page population, applies layout through the normal document-open path, zooms to fit, and waits on the active renderer's explicit settlement contract before recording: ```sh bun run benchmark:navigation \ --url http://localhost:1420/ \ --gesture tests/fixtures/navigation/gestures/synthetic-pinch-reversal.json \ --mode dom \ --scenario current-document \ --document tests/fixtures/gold-preview.fig \ --no-trace \ --output artifacts/navigation-benchmark/gold-preview ``` `environment.json` records the resolved local document path so reports cannot silently mix generated and real-document runs. The runner serves the exact local bytes through an isolated Playwright route, so production Vite preview does not return its SPA fallback for non-public test fixtures. ## Replay with Chromium tracing ```sh bun run benchmark:navigation \ --url http://localhost:1420/ \ --gesture tests/fixtures/navigation/gestures/synthetic-pinch-reversal.json \ --mode cdp \ --scenario raster-stress \ --output artifacts/navigation-benchmark/current ``` `--mode cdp` sends browser input through the Chrome DevTools Protocol. `--mode dom` deterministically dispatches events inside the page and is useful for scheduler/correctness debugging. Neither mode is represented as physical WKWebView input. Use `--no-trace` for the lowest-overhead metrics pass. Run a separate diagnostic pass with tracing enabled, and add `--cpu-profile` only when stack sampling is needed: CPU profiling can perturb the timing being measured. Performance runs require a hardware-accelerated Metal/ANGLE context and fail if Chromium falls back to SwiftShader. Use `--software-gpu` only for portable correctness and CI smoke runs; never compare its timings with hardware results. Every run records the unmasked GL renderer and vendor in `environment.json`. Open the trace: ```sh open https://ui.perfetto.dev ``` Then load `trace.json.gz` and search for `openpencil:`. ## Baseline comparison Build and run the same gesture against `v0.14.0` and the candidate in separate clean worktrees, on the same machine and power state. Alternate baseline/candidate runs rather than completing every baseline run first. Use release builds, fixed 1280×800 CSS viewport and DPR 2, and at least five measured repetitions after one warmup. Do not gate shared CI on absolute timing. Dedicated benchmark hardware may gate on same-run baseline ratios and correctness invariants. Always retain raw traces for regressions. Compare two completed runs: ```sh bun tools/dev/navigation-benchmark/src/cli.ts compare \ --baseline artifacts/navigation-benchmark/v0.14.0/metrics.json \ --candidate artifacts/navigation-benchmark/current/metrics.json \ --output artifacts/navigation-benchmark/comparison.json ``` ## Metrics The report includes: * Frame interval median, p95, p99, maximum, and counts over 8.33/16.67/33.33/50 ms. * Render CPU interval and CanvasKit flush timing. * Wheel-to-viewport and wheel-to-render-end latency. * Maximum zoom focal-point drift in screen pixels. * Maximum presented viewport displacement between rendered frames. * Final input to exact active-renderer settlement: crisp retained backing for the existing renderer or exact visible tile coverage for tiled mode. * Tiled scheduler frame count, maximum jobs per frame, maximum measured job submission, over-budget jobs, deadline overrun, and cancelled obsolete jobs. The runner contains no warmup or settlement sleeps. `waitForSettlement()` requires an idle navigation lifecycle and renderer-owned exact coverage; its timeout rejects the benchmark and reports renderer state. ### Benchmark content mutation and cancellation Use the exact imported node ID plus a deterministic property mutation to measure selective refresh: ```sh bun run benchmark:navigation \ --url 'http://localhost:1420/?renderer=tiled' \ --gesture tests/fixtures/navigation/gestures/synthetic-repeated-pinch-reversal.json \ --mode dom \ --scenario current-document \ --document tests/fixtures/gold-preview.fig \ --mutate-node '0:5' \ --mutate-opacity 0.11 \ --no-trace \ --output artifacts/navigation-benchmark/gold-preview-mutation ``` Add `--replay-after-mutation` to wait until refresh is observably pending and then replay the gesture before exact settlement. This measures generation cancellation and final-viewport prioritization without a fixed delay. Mutation-only and combined runs record their mutation parameters in `environment.json`. For tiled runs, inspect `scheduler.cancelledJobs`, `maximumJobsPerFrame`, `maximumJobRenderMs`, `overBudgetJobs`, and `maximumDeadlineOverrunMs`. Averages alone are not acceptance criteria. Inspect p95/p99, maximum stalls, contiguous missed frames, motion discontinuities, and crisp-settlement latency. ## Required fixture matrix Permanent benchmark scenarios should cover: * **Light:** isolates input and scheduling overhead. * **Large flat:** stresses culling and traversal. * **Realistic:** nested frames, text, images, gradients, effects, masks, and instances. * **Raster stress:** expensive retained-backing creation and coverage changes. * **Imported `.fig`:** exercises imported-document rendering paths. Keep large or binary `.fig` fixtures in Git LFS. Synthetic fixture generation must be deterministic. ## Native macOS acceptance Chromium replay does not establish WKWebView or physical trackpad behavior. Before a release with navigation changes: 1. Build a release-mode Tauri application. 2. Record the same gesture on physical target hardware. 3. Capture Instruments **Time Profiler** and **Core Animation** traces, including OpenPencil and WebKit processes. 4. Check input delivery, main-thread/WASM work, GPU/compositor stalls, viewport continuity, and final crisp settlement. 5. Store the recording, metrics, hardware/macOS metadata, and Instruments trace as release artifacts. Native automation may add OS-level scroll injection, but it must preserve continuous-scroll and momentum phases before being called trackpad-equivalent. Synthetic composition or DOM wheel events are not native acceptance evidence. --- --- url: 'https://openpencil.ink/development/renderer-lifecycle.md' --- # Renderer lifecycle and ownership The CanvasKit renderer keeps hot drawing loops direct, but its retained and tiled paths follow explicit lifecycle contracts. Preserve these contracts when changing renderer structure or cache policy. ## Navigation and settlement The default renderer presents a committed retained backing while navigation is active. It must not rebuild that backing during pan, zoom, momentum, or settling, because reconstruction can collide with direction reversals and block input delivery. Once navigation becomes idle, it builds exact content incrementally and atomically replaces the committed backing. The tiled renderer is opt-in. It also presents retained fallback pixels while navigation is active and defers tile rasterization and presentation until idle. Visible exact coverage is the settlement boundary; overscan remains background work. Generation rules: * Navigation generations cancel obsolete queued tile work. * Content generations invalidate affected pictures and tiles. * Page, scene, position-preview, and font versions all participate in retained-cache validity. * A stale build or raster result must be deleted rather than installed. ## CanvasKit ownership CanvasKit objects are native/WASM resources and are not reclaimed reliably by JavaScript garbage collection. Ownership transfer must remain explicit. | Resource | Created by | Owner after success | Released by | | --- | --- | --- | --- | | Scene picture | Scene recorder | Renderer scene-picture state | Replacement, invalidation, renderer destruction | | Retained backing image | Completed backing build | Retained backing state | Replacement, invalidation, renderer destruction | | Retained build surface | Backing builder | Retained build state | Commit, cancellation, invalidation, renderer destruction | | Subtree picture | Subtree recorder | Subtree picture cache | Scope change, node invalidation, renderer destruction | | Chunk picture | Chunk recorder | `RenderChunkPictureCache` | Chunk invalidation or cache clear | | Tile image | `renderTile()` | Caller until installation, then `TileImageCache` | Stale-result discard, replacement, eviction, cache clear | | Tile surface | `TileSurfacePool` | Surface pool | Pool clear | | Effect raster image | Effect rasterizer | Effect raster cache | Node invalidation, eviction, cache clear | | CanvasKit path arrays | Geometry builder | Owning geometry cache | Node invalidation or renderer destruction | Do not introduce generic disposal wrappers into per-node or per-tile hot loops. Prefer narrow domain operations with obvious transfer points, and use `try/finally` around recorder, surface, paint-effect, and transform lifetimes. ## Performance-preserving refactors Safe structural refactors may split modules, name state records, extract telemetry, and document transitions. They must not change: * Frame budgets, cache limits, tile size, quantized levels, or overscan policy. * Visible-before-overscan priority. * Direct CanvasKit calls and preallocated temporary buffers in hot loops. * Atomic isolation for masks, opacity, blend modes, and blur scopes. * Exact settlement acknowledgement. Validate structural changes with the `gold-preview.fig` reversal and mutation-plus-reversal benchmarks, focused renderer tests, and committed canvas snapshots. Compare production builds rather than relying on synchronous microbenchmarks. --- --- url: 'https://openpencil.ink/development/renderer-profiler.md' description: >- Use the CanvasKit renderer profiler HUD and frame capture tools to investigate rendering performance. --- # Renderer Profiler OpenPencil includes a CanvasKit renderer profiler for debugging frame time, GPU timing, draw calls, cache behavior, and expensive render phases. ## Enable the HUD In the browser app, open the menu and choose: ```txt View → Profiler ``` The app toggles `store.toggleProfiler()`, which maps to `editor.renderer.profiler.toggle()`. The HUD is drawn directly on the Skia canvas so it measures the same rendering path as the document. It is not a DOM overlay. ## HUD metrics The profiler HUD shows: * **FPS / frame time** — smoothed frame cadence. * **CPU** — JavaScript/WASM render time for the frame. * **GPU** — latest available `EXT_disjoint_timer_query_webgl2` result when the browser exposes it. * **Nodes / culled nodes** — total visible scene work and viewport culling count. * **Draws** — WebGL draw calls counted through the instrumented context. * **Cache** — whether the scene picture cache was reused. * **Phases** — timings for renderer phases such as scene draw, picture replay/record, volatile overlays, section labels, selection, rulers, and flush. * **Frame graph** — rolling frame history with 60 fps / 30 fps / slow thresholds and GPU bars when available. GPU timing is asynchronous. The value shown is the latest completed GPU query, not necessarily the current frame. ## Implementation locations Core profiler code lives in: ```txt packages/core/src/profiler/ ``` Main entry points: * `render-profiler.ts` — `RenderProfiler` facade used by `SkiaRenderer`. * `frame/stats.ts` — rolling frame statistics. * `gpu-timer.ts` — WebGL timer query wrapper. * `draw-call-counter.ts` — WebGL draw-call instrumentation. * `phase-timer.ts` — phase timing and User Timing integration. * `hud-renderer.ts` — canvas HUD rendering. * `frame/capture.ts` and `speedscope-export.ts` — detailed capture and Speedscope export. Renderer integration lives under: ```txt packages/core/src/canvas/renderer*.ts packages/core/src/canvas/renderer/ ``` App wiring lives in: ```txt src/app/editor/profiler/index.ts src/app/shell/menu/schema.ts src/app/shell/menu/app-menu.ts ``` ## Programmatic use From app/editor code: ```ts store.toggleProfiler() ``` From a renderer instance: ```ts renderer.profiler.toggle() renderer.profiler.beginCapture() // render one or more frames const capture = renderer.profiler.endCapture() const speedscopeJson = renderer.profiler.exportSpeedscope() renderer.profiler.downloadSpeedscope() ``` Detailed captures are for targeted debugging. Keep the normal HUD path lightweight and avoid enabling expensive capture work unless a user or developer explicitly asks for it. ## Notes * The profiler is designed to be safe when disabled: no timing calls or allocations should be added to hot paths unless `profiler.enabled` / `profiler.capturing` is active. * GPU timing depends on browser and hardware support for `EXT_disjoint_timer_query_webgl2`. * If GPU timing is unavailable, the HUD still reports CPU time, draw calls, phases, node counts, and cache status. --- --- url: 'https://openpencil.ink/development/vector-conversion.md' description: >- Development notes for OpenPencil boolean operations, flattening, text outlines, stroke outlines, and vector geometry conversion. --- # Vector conversion OpenPencil has a shared path-conversion pipeline for commands that turn scene nodes into vector geometry. ## Commands * **Boolean operations** keep a live `BOOLEAN_OPERATION` container and render it with CanvasKit path operations. * **Flatten** replaces selected supported nodes with one persistent `VECTOR` node. * **Outline text** replaces selected supported text nodes with vector outlines. * **Outline stroke** replaces selected supported stroked nodes with vector stroke outlines. The editor, renderer-backed Figma API, and menu command enablement all use the same source-path checks so unsupported nodes fail safely instead of being silently dropped. ## Supported sources Supported sources include basic shapes, vectors, lines, nested boolean operations, and visual descendants inside groups, frames, components, and instances. Containers contribute their visible descendants, and their own fill/stroke if present. Text can be converted when all required font data is loaded. The outline engine supports multiline text, horizontal and vertical alignment, letter spacing, style runs, and loaded fallback glyphs for mixed-font text such as Latin plus CJK. ## Unsupported sources The conversion pipeline rejects these cases: * visible image fills * sections and component sets * text with missing font data or missing fallback glyphs * complex scripts that require shaping, such as Arabic, Hebrew, and Indic scripts Complex-script text stays unsupported until we can extract exact shaped glyph runs and positions from the rendering stack. ## Figma API flatten `FigmaAPI.flatten()` produces real vector geometry when a `SkiaRenderer` is attached with `api.setRenderer(renderer)`. In headless compatibility mode without a renderer, it keeps the historical placeholder behavior: the source nodes are replaced by a vector-sized placeholder without `vectorNetwork` geometry. --- --- url: 'https://openpencil.ink/getting-started.md' --- # Getting Started ## Try Online OpenPencil runs in the browser — no installation required. Open [app.openpencil.ink](https://openpencil.ink) to start designing. If you want to build on top of it instead of only using the default app, see the [Programmable](/programmable/) section and the [Vue SDK](/programmable/sdk/). ## Download Desktop App Pre-built binaries for macOS, Windows, and Linux are available on the [download page](/download). | Platform | Download | | --------------------- | -------------------- | | macOS (Apple Silicon) | `.dmg` (aarch64) | | macOS (Intel) | `.dmg` (x64) | | Windows (x64) | `.msi` / `.exe` | | Windows (ARM) | `.msi` / `.exe` | | Linux (x64) | `.AppImage` / `.deb` | ## System Requirements OpenPencil renders with CanvasKit on WebGL and relies on current web platform features, so it needs a recent browser engine. The web app supports Chrome 111, Edge 111, Firefox 128, and Safari 16.4 or later; Chromium-based browsers such as Brave and Opera follow their Chrome version. The desktop app renders in the system WebView, so its floor is the operating system's engine: | Platform | Requirement | | -------- | ----------- | | macOS | macOS 13 Ventura or later with the current Safari updates installed (Safari 16.4 shipped with macOS 13.3). | | Windows | Windows 10 or later with the Microsoft Edge WebView2 Evergreen runtime, which updates itself. | | Linux | WebKitGTK 2.40 or later (`webkit2gtk-4.1`). | When the engine is too old, OpenPencil shows what to update instead of a blank window. If you see that notice on a system that meets the requirements, use its "Report a problem" link, which prefills the browser and engine details. ## macOS via Homebrew ```sh brew install --cask openpencil ``` This installs the signed macOS app (Apple Silicon and Intel) from the [official Homebrew cask](https://formulae.brew.sh/cask/openpencil). Homebrew updates are reviewed upstream and may lag behind GitHub releases. For a release not yet available through Homebrew, use the [direct download](https://github.com/open-pencil/open-pencil/releases/latest). The desktop cask does not install the CLI; install it separately with `npm install -g @open-pencil/cli`. If you used the archived custom tap, migrate with: ```sh brew uninstall open-pencil/tap/open-pencil brew install --cask openpencil ``` ## Building from Source ### Prerequisites * [Bun](https://bun.sh/) (package manager and runtime) * [Rust](https://rustup.rs/) (for desktop app only) ## Installation ```sh git clone https://github.com/open-pencil/open-pencil.git cd open-pencil bun install ``` ## Development Server ```sh bun run dev ``` Opens the editor at `http://localhost:1420`. ## Available Scripts | Command | Description | | ------------------------------- | --------------------------------------------------- | | `bun run dev` | Dev server with HMR | | `bun run build` | Production build | | `bun run check` | Lint (oxlint) + typecheck (tsgo) | | `bun run test` | E2E visual regression (Playwright) | | `bun run test:update` | Regenerate screenshot baselines | | `bun run test:unit` | Unit tests (bun:test) | | `bun run docs:dev` | Documentation dev server | | `bun run docs:build` | Build documentation locally (fast validation build) | | `bun run docs:build:production` | Build deployable documentation, including LLM files | ## Desktop App (Tauri) The desktop app requires Rust and platform-specific prerequisites. ### macOS ```sh xcode-select --install cargo install tauri-cli --version "^2" bun run tauri dev ``` ### Windows 1. Install [Rust](https://rustup.rs/) with `stable-msvc` toolchain: ```sh rustup default stable-msvc ``` 2. Install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with "Desktop development with C++" workload 3. WebView2 is pre-installed on Windows 10 (1803+) and Windows 11 4. Run: ```sh bun run tauri dev ``` ### Linux Install system dependencies (Debian/Ubuntu): ```sh sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \ libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev ``` Then: ```sh bun run tauri dev ``` ### Build for Distribution ```sh bun run tauri build # Current platform bun run tauri build --target universal-apple-darwin # macOS universal ``` --- --- url: 'https://openpencil.ink/overview/features.md' --- # Features ## Figma .fig Files Open and save native Figma files directly. The import/export pipeline uses the same Kiwi binary codec as Figma — 194 schema definitions, ~390 fields per node. Save with ⌘S, Save As with ⇧⌘S. **Copy & paste with Figma** — select nodes in Figma, ⌘C, switch to OpenPencil, ⌘V. Fills, strokes, auto-layout, text, effects, corner radii, and vector networks are preserved. Works both ways. ## Drawing & Editing * **Shapes** — Rectangle (R), Ellipse (O), Line (L), Polygon, Star * **Pen tool** — vector networks (not simple paths), bezier curves with tangent handles * **Pencil** (⇧P) — freehand strokes smoothed into editable curves * **Text** — canvas-native editing with IME support, double-click to enter edit mode * **Rich text** — per-character bold (⌘B), italic (⌘I), underline (⌘U), strikethrough * **Auto-layout** — flexbox and CSS Grid via Yoga WASM: direction, gap, padding, justify, align, child sizing, grid tracks. ⇧A to toggle * **Components** — create (⌥⌘K), component sets (⇧⌘K), instances with override support, slots, live sync * **Behaviours and preview** — make components work as Reka UI controls (switch, slider, tabs, text field, accordion, …) and try them live over the canvas with ⌥⌘↩ ([Components](/user-guide/components#behaviours-and-preview)) * **Variables** — design tokens with collections, modes (Light/Dark), color/float/string/boolean types, variable binding * **Sections** — organizational containers with auto-adopting children and title pills ## Properties Panel Context-sensitive Design | Code | AI | Lint tabs: * **Appearance** — opacity, corner radius (uniform or per-corner), visibility * **Fill** — solid, gradient (linear/radial/angular/diamond), image * **Stroke** — color, weight, align (inside/center/outside), per-side weights, cap, join, dash * **Effects** — drop shadow, inner shadow, layer blur, background blur, foreground blur * **Typography** — font picker with virtual scroll and search, weight, size, alignment, style buttons * **Layout** — auto-layout controls when enabled * **Export** — scale, format (PNG/JPG/WEBP/SVG), live preview * **Code** — Design JSX, HTML, and Vue and React components for the selection, styled with CSS or Tailwind, with live two-way Design JSX editing that patches your code as layers change; the element around the cursor outlines its layer on the canvas, and design issues are underlined on the property that causes them * **Lint** — live design lint for the page or selection: low contrast, small touch targets, unbound colors, off-scale spacing, with issue markers on the canvas and one-click variable binding ([Checking Designs](/user-guide/checking-designs)) ## Rendering Skia (CanvasKit WASM) — the same rendering engine as Figma: * Gradient fills (linear, radial, angular, diamond) * Image fills with scale modes * Effects with per-node caching * Arc data (partial ellipses, donuts) * Viewport culling and paint reuse * Snap guides with rotation-aware alignment * Canvas rulers with selection badges * Hover highlight that follows actual geometry ## Undo/Redo Every operation is undoable — creation, deletion, moves, resizes, property changes, reparenting, layout changes, variable operations. Uses an inverse-command pattern. ⌘Z / ⇧⌘Z. ## Multi-Page Documents Add, delete, rename pages. Each page has independent viewport state. Double-click to rename inline. ## Multi-File Tabs Open multiple documents in tabs. ⌘T new tab, ⌘W close, ⌘O open file. ## Export * **Image** — PNG, JPG, WEBP at configurable scale (0.5×–4×). Via panel, context menu, or ⇧⌘E * **SVG** — shapes, text with style runs, gradients, effects, blend modes * **Tailwind JSX** — HTML with Tailwind v4 utility classes, ready for React or Vue * **Copy as** — text, SVG, PNG (⇧⌘C), or JSX via context menu CLI: `openpencil export design.fig -f jsx --style tailwind` ## AI Chat Press ⌘J to open the AI assistant. 90+ tools that can create shapes, set styles, manage layout, work with components and variables, run boolean operations, analyze design tokens, and export assets. Connect Anthropic, OpenAI, Google AI, OpenRouter, or any compatible endpoint. Tool calls display as collapsible timeline entries. Visual verification — the assistant renders its work and checks it against your request. Full undo support for all AI mutations. See [AI Chat](/programmable/ai-chat) for setup and provider details. ## MCP Server Connect Claude Code, Cursor, Windsurf, or any MCP client to read and write `.fig` files headlessly. 90+ tools. Two transports: stdio and HTTP. ```sh npm install -g @open-pencil/mcp ``` ```json { "mcpServers": { "open-pencil": { "command": "openpencil-mcp" } } } ``` See [MCP Tools reference](/programmable/mcp-server) for the full tool list. ## CLI Inspect, export, and analyze `.fig` files from the terminal: ```sh openpencil tree design.fig # Node tree openpencil find design.fig --type TEXT # Search openpencil export design.fig -f png # Render openpencil analyze colors design.fig # Color audit openpencil analyze clusters design.fig # Repeated patterns openpencil eval design.fig -c "..." # Figma Plugin API ``` When the desktop app is running, omit the file to control the live editor via RPC: ```sh openpencil tree # Live document openpencil export -f png # Screenshot canvas ``` All commands support `--json`. Install: `npm install -g @open-pencil/cli` (or `bun add -g @open-pencil/cli`). ## Real-Time Collaboration P2P via WebRTC — no server required. Share a link and edit together. * Live cursors with colored arrows and name pills * Presence avatars * Follow mode — click a peer to follow their viewport * Voice calls — talk with the people in the room, peer to peer, with mute, microphone and speaker choice, and a ring on whoever is speaking * Local persistence via IndexedDB * Secure room IDs via `crypto.getRandomValues()` ## Desktop & Web **Desktop** — Tauri v2, ~7 MB. macOS (signed & notarized), Windows, Linux. Native menus, offline, autosave. **Web** — runs at [app.openpencil.ink](https://openpencil.ink), installable as a PWA on mobile with touch-optimized UI. **Homebrew:** ```sh brew install --cask openpencil ``` ## Google Fonts Fallback When a font isn't available locally, OpenPencil fetches it from Google Fonts automatically. No manual installation needed when opening .fig files with unfamiliar fonts. --- --- url: 'https://openpencil.ink/overview/comparison.md' --- # Open Pencil vs Penpot: Architecture & Performance Comparison Why compare? OpenPencil exists because closed design platforms control what's possible. Understanding architectural differences shows what an open, local-first alternative can do differently. ::: info Penpot's WASM renderer Penpot 2.x includes a Rust/Skia WASM renderer (`render-wasm/v1`) that can be enabled via server flags or the `?wasm=true` URL parameter. The old SVG renderer remains the default. This page covers both. ::: ## 1. Scale & Codebase Size | Metric | Open Pencil | Penpot | |--------|-------------|--------| | Total LOC | **~26,000** | **~299,000** | | Source files | ~143 | ~2,900 | | Languages | TypeScript, Vue | Clojure, ClojureScript, Rust, JS, SQL, SCSS | | Rendering engine | ~3,200 LOC (TS, 10 files) | 22,000 LOC (Rust/Skia WASM) | | UI code | ~4,500 LOC | ~175,000 LOC (CLJS + SCSS) | | Backend | None (local-first) | 32,600 LOC + 151 SQL files | | LOC ratio | **1×** | **~11×** | Open Pencil is **~11× smaller** — and that's the whole point. It's not a simplification; it's a fundamentally different architecture. ## 2. Architecture ### Open Pencil: Monolithic Client ``` ┌─────────────────────────────────┐ │ Tauri (native shell) │ │ ┌───────────────────────────┐ │ │ │ Vue 3 + TypeScript │ │ │ │ ┌─────────┐ ┌──────────┐│ │ │ │ │ Editor │ │ Kiwi ││ │ │ │ │ Store │ │ Codec ││ │ │ │ └────┬─────┘ └──────────┘│ │ │ │ │ │ │ │ │ ┌────▼────────────────┐ │ │ │ │ │ Scene Graph (TS) │ │ │ │ │ │ Map │ │ │ │ │ └────┬────────────────┘ │ │ │ │ │ │ │ │ │ ┌────▼────┐ ┌──────────┐│ │ │ │ │ Skia │ │ Yoga ││ │ │ │ │CanvasKit│ │ Layout ││ │ │ │ │ (WASM) │ │ (WASM) ││ │ │ │ └─────────┘ └──────────┘│ │ │ └───────────────────────────┘ │ └─────────────────────────────────┘ ``` **Everything in one process.** No server, no database, no Docker. The scene graph is a flat `Map` in TypeScript. Rendering calls Skia CanvasKit directly from TS. Layout is Yoga WASM called synchronously. ### Penpot: Distributed Client-Server ``` ┌───────────────────────────────────────────────────────┐ │ Docker Compose │ │ ┌──────────────┐ ┌─────────────┐ ┌──────────────┐ │ │ │ Frontend │ │ Backend │ │ Exporter │ │ │ │ ClojureScript│ │ Clojure │ │ (Chromium) │ │ │ │ shadow-cljs │ │ JVM │ │ │ │ │ │ ┌─────────┐ │ │ ┌────────┐ │ └──────────────┘ │ │ │ │render- │ │ │ │Postgres│ │ │ │ │ │wasm │ │ │ │Valkey │ │ ┌──────────────┐ │ │ │ │(Rust→ │ │ │ │ MinIO │ │ │ MCP │ │ │ │ │ Skia │ │ │ └────────┘ │ │ Server │ │ │ │ │ WASM) │ │ │ │ └──────────────┘ │ │ │ └─────────┘ │ │ │ │ │ └──────────────┘ └─────────────┘ │ └───────────────────────────────────────────────────────┘ ``` **5+ services minimum.** PostgreSQL for persistence, Redis (Valkey) for pub/sub and caching, MinIO for asset storage, a JVM backend, a Node.js exporter (headless Chromium for server-side rendering), plus the ClojureScript frontend. Dev setup requires Docker Compose with custom networking. ### Verdict: Architecture Open Pencil's single-process architecture eliminates: * Network latency between frontend and backend * Serialization/deserialization overhead at service boundaries * Container orchestration complexity * Database query overhead for every operation Penpot's architecture is optimized for **multi-user server-hosted deployments**. Open Pencil is optimized for **instant local performance**. ## 3. Rendering Pipeline ### Open Pencil: TS → CanvasKit WASM (direct) ```typescript // renderer.ts — direct CanvasKit calls from TypeScript renderSceneToCanvas(canvas, graph, pageId) { // Iterate nodes, build Skia paths/paints, draw this.fillPaint.setColor(...) canvas.drawRRect(rrect, this.fillPaint) } ``` * **1 boundary crossing:** TS → WASM (CanvasKit) * Scene graph lives in JS heap — no serialization to render * \~3,200 LOC renderer (split into 10 focused files: scene, overlays, fills, strokes, shapes, effects, rulers, labels) ### Penpot: JS (compiled from CLJS) → Rust WASM → Skia Penpot 2.x includes a Rust/Skia WASM renderer (`render-wasm/v1`), opt-in via server flags or `?wasm=true`. When enabled, shapes are rendered through: ``` ClojureScript (compiled to JS) → decompose to primitives + binary-pack into WASM linear memory → Rust WASM (via Emscripten C FFI) → skia-safe (Rust Skia bindings) → Skia (WebGL) ``` When disabled (default), shapes render as an SVG DOM tree via React/Reagent — each shape is a DOM element. * **1 boundary crossing** (JS → WASM), same as Open Pencil — but with explicit serialization overhead: UUIDs split to 4×u32, transforms to 6×f32, fills/strokes binary-packed, base props batched into a 104-byte struct per shape * Tile-based rendering system with interest areas * 11 separate render surfaces (fills, strokes, shadows, etc.) * Global mutable state via `unsafe { STATE.as_mut() }` pattern * 22,000 LOC Rust render engine Penpot's tile system (`TileViewbox`, `TileTextureCache`, `TILE_SIZE_MULTIPLIER`) pre-renders tiles around the viewport and caches textures (up to 1024 entries). Open Pencil re-renders the full viewport every frame because CanvasKit called directly from TS is fast enough to not need tiling. ### Verdict: Rendering | Aspect | Open Pencil | Penpot | |--------|-------------|--------| | JS→WASM boundary | Direct (TS objects) | Binary-packed (104-byte base props struct) | | Rendering model | Immediate/full redraw | Tile-cached | | Surface management | 1 surface | 11 surfaces | | Memory overhead | Low (no tile cache) | High (1024 tile cache) | | Code complexity | ~3,200 LOC (10 files) | 22,000 LOC | | Unsafe code | None | `unsafe` global state | When Penpot's WASM renderer is enabled, both projects use Skia via JS→WASM. Open Pencil calls CanvasKit directly with TS objects. Penpot decomposes ClojureScript data into binary-packed structs, writes them to WASM linear memory, and renders through a 22,000 LOC Rust engine. When WASM is disabled (default), Penpot renders shapes as an SVG DOM tree. For small-to-medium documents, the direct CanvasKit path is faster. Penpot's tile system may win on extremely large canvases (100K+ shapes) where only a small viewport is visible — but the overhead is significant. ## 4. Scene Graph & Data Model ### Open Pencil ```typescript // Flat map, O(1) lookup nodes: Map // 29 node types from Figma's Kiwi schema // ~390 fields per NodeChange (Figma-compatible) ``` * TypeScript interfaces with strict types * GUIDs match Figma's `sessionID:localID` format * Direct property access — no indirection layers ### Penpot ```clojure ;; 20+ type definition files in common/src/app/common/types/ ;; shapes_builder.cljc, shapes_helpers.cljc ;; Separate type systems for: color, component, container, fills, ;; grid, modifiers, objects_map, page, path, etc. ``` * Data spread across `common/` (49,600 LOC of .cljc) * Separate geometry modules for flex layout (~6 files), grid layout (~5 files), constraints, bounds, corners, effects * Runtime schema validation (Malli) * Data must cross CLJS→Rust boundary for rendering ### Verdict: Data Model Open Pencil reuses Figma's proven schema (194 Kiwi definitions) directly in TypeScript — zero translation. Penpot maintains its own type system across Clojure/ClojureScript/Rust, requiring manual sync between all three. ## 5. Layout Engine ### Open Pencil: Yoga WASM (314 LOC) ```typescript import Yoga from 'yoga-layout' // Direct mapping: Figma stack* fields → Yoga flex properties const root = Yoga.Node.create() root.setFlexDirection(FlexDirection.Row) root.calculateLayout() applyYogaLayout(graph, frame, yogaRoot) ``` 314 lines total. Synchronous, in-process. ### Penpot: Dual Implementation 1. **ClojureScript** (common): `flex_layout/` (6 files), `grid_layout/` (5+ files) — custom implementations 2. **Rust WASM**: `flex_layout.rs` (741 LOC), `grid_layout.rs` (843 LOC) — reimplemented from scratch Penpot maintains **two independent layout engines** (CLJS and Rust) that must produce identical results. ### Verdict: Layout Open Pencil delegates to a battle-tested library (Yoga, used by React Native on billions of devices) in 314 lines. Penpot maintains ~3,000+ LOC of custom layout code duplicated across two languages. ## 6. File Format & Figma Compatibility ### Open Pencil * **Native Kiwi binary format** — same serialization as Figma uses internally * Direct `.fig` file import via extracted Kiwi codec (2,178 LOC schema + 551 LOC codec) * Figma clipboard paste support (reads Figma's Kiwi binary from the clipboard) * Wire-compatible with Figma's multiplayer protocol ### Penpot * **ZIP archive** (`.penpot` files) containing JSON manifests, per-file JSON data, binary assets, and thumbnails (v3 format) * SVG used for default rendering and export (opt-in WASM renderer available) * No native `.fig` import * Three format versions (v1 legacy Transit, v2, v3 JSON-in-ZIP) with migration system ### Verdict: File Format Open Pencil has a significant advantage — it can read Figma files natively and even paste Figma clipboard data. Penpot requires manual export/import and cannot open `.fig` files. ## 7. State Management & Undo ### Open Pencil ```typescript // 110 LOC — inverse command pattern class UndoManager { apply(entry: UndoEntry) { entry.forward(); this.undoStack.push(entry) } undo() { entry.inverse(); this.redoStack.push(entry) } } ``` 110 lines. Forward/inverse closures that capture minimal state. Batch support for multi-step operations. ### Penpot State management uses Potok (a Redux-like library for ClojureScript atoms). Events implement `UpdateEvent` (pure state→state) or `WatchEvent` (side effects via RxJS). Undo stores inverse change vectors (max 50 entries), with transactions to group rapid changes and auto-expiry after 20 seconds. ### Verdict: State Open Pencil's approach is simpler and lower overhead. Penpot's approach is more suitable for collaboration (changes are serializable), but at the cost of complexity. ## 8. Developer Experience | Metric | Open Pencil | Penpot | |--------|-------------|--------| | Dev setup | `bun install && bun dev` | Docker Compose + JVM + Node + Rust toolchain | | Hot reload | Vite HMR (~50ms) | shadow-cljs (seconds) | | Type checking | TypeScript (strict) | Runtime (Malli schemas) | | Build time | <5s (Vite) | Minutes (JVM startup + CLJS compile + Rust WASM) | | First contribution barrier | Low (TS/Vue) | High (Clojure + Rust + Docker) | | Desktop | Tauri v2 (~5MB) | N/A (browser-only) | | Hiring pool | Massive (TS/Vue devs) | Tiny (ClojureScript + Rust) | ## 9. Performance Characteristics | Scenario | Open Pencil | Penpot | |----------|-------------|--------| | Cold start | <2s (WASM load) | 10s+ (server + client + WASM) | | Operation latency | <1ms (in-process) | 10-50ms (network round-trip) | | Render frame | Direct Skia call | CLJS→JS→WASM FFI→Skia | | Memory baseline | ~50MB (browser tab) | ~300MB+ (JVM + Postgres + Valkey + browser) | | Offline capability | Full (local-first) | None (server-dependent) | | 10K shapes render | One pass, no caching | Tile-based with 11 surfaces | ## 10. What Penpot Does Better 1. **Server-side collaboration** — centralized multi-user editing with WebSockets, user accounts, and access control (Open Pencil uses P2P via Trystero + Yjs — no server, but also no access control or persistence beyond the session) 2. **PDF export** — headless Chromium export service for PDF rendering (OpenPencil exports SVG but not PDF yet) 3. **Plugin system** — full plugin API with sandboxed execution 4. **Design tokens** — native design token support 5. **CSS Grid layout** — custom implementation (Open Pencil uses Yoga fork with grid support) 6. **Self-hosting** — Docker-based deployment for teams 7. **Maturity** — years of production usage, battle-tested at scale ## 11. Scripting & Extensibility OpenPencil ships with an [`eval` command](/programmable/cli/scripting) that provides a Figma-compatible Plugin API for headless scripting — batch operations, automated testing, and AI-driven modifications all run without the GUI. On top of that, **90 AI tools** are available via built-in chat, MCP server (stdio + HTTP), and the CLI — covering read, create, modify, structure, variables, vector path, analyze (color/typography/spacing/clusters), diff, boolean operations, and arrangement. Penpot has a plugin system with sandboxed execution but no headless scripting API or MCP integration. ## Summary | Dimension | Winner | Why | |-----------|--------|-----| | **Architecture simplicity** | Open Pencil | Single process vs 5+ services | | **Rendering performance** | Open Pencil | Direct CanvasKit vs SVG DOM (default) or binary-packed WASM | | **Code maintainability** | Open Pencil | ~26K LOC in 1 language vs 299K in 4+ languages | | **Figma compatibility** | Open Pencil | Native Kiwi codec vs no .fig support | | **Developer onboarding** | Open Pencil | TS/Vue vs Clojure/Rust/Docker | | **Desktop experience** | Open Pencil | Tauri native vs browser-only | | **Layout engine** | Open Pencil | Yoga (proven) vs custom dual implementation | | **Collaboration** | Tie | Penpot: server-based with access control; Open Pencil: P2P via Trystero + Yjs, zero hosting | | **Self-hosting** | Penpot | Docker-ready vs desktop-only | | **Ecosystem maturity** | Penpot | Years of production vs early stage | Open Pencil is architecturally leaner — a single-process CanvasKit renderer in ~26K LOC of TypeScript, Figma-compatible by design. Penpot is a full-stack platform with ~299K LOC across Clojure, ClojureScript, Rust, and SCSS, plus a Docker service fleet. Both now offer real-time collaboration (different architectures: P2P vs server). Penpot has a plugin ecosystem and server-side PDF export; Open Pencil has Figma-compatible headless scripting, **90 AI/MCP tools**, SVG export, and a native desktop app. --- --- url: 'https://openpencil.ink/overview/privacy.md' description: >- What OpenPencil sends over the network, why it sends anonymous usage stats, and how to turn every part of it off. --- # Privacy and Usage Stats * Your designs, file names, layer names, text, prompts, and keys never leave your device unless you share or sync them yourself. * Official builds send one anonymous ping a day, with no ID. You can turn it off. * Detailed usage stats are sent only if you say yes when asked, and you can stop at any time. ## Why we collect anything OpenPencil is built by a small team. We need to know which platforms, file formats, and features people use so we can decide what to build next. We also need to show that OpenPencil is used, because that is what keeps the project funded. A count of active installs and a picture of which features matter answer both questions without knowing anything about you or your work. ## The daily ping Once a day, an official build sends this to `feedback.openpencil.ink`: ```http POST https://openpencil.ink/ping { "version": "0.16.0", "platform": "macos", "arch": "arm64", "app": "desktop", "browser": "unknown", "usageStats": "denied" } ``` It carries no ID, so pings from the same install cannot be linked. The answer to the usage stats question is the only way we learn how many people decline, since declining means nothing else is ever sent. The app stores only the date of its last ping, so it pings once a day. The server adds one to a counter for that day and build, and keeps nothing else. It does not log addresses, and it rejects a ping that carries any other field. The ping is on by default. Turn it off in **Settings → Privacy**. ## Usage stats Usage stats are off until you agree. OpenPencil asks once, in a small prompt that appears after the welcome screen on first launch, or on a later launch if you have not answered, with two equal choices: **No thanks** and **Share stats**. Nothing is selected for you, and you can change your answer later in **Settings → Privacy**. When you agree, the app creates a random install ID on your device. It is not linked to an account, an email address, or anything else about you. Usage events are sent with it, together with the build fields above ({{ buildFields }}). When you stop sharing, the app deletes the ID; agreeing again creates a new one. These are all the events the app can send. The service at `feedback.openpencil.ink` checks every event against this list and rejects any event, property, or value that is not on it, so a modified app cannot send anything else through it either. Accepted events go to [PostHog](https://posthog.com), a product analytics service, on its EU cloud. The request comes from our server, not your device, so PostHog never sees your address. Events create no person profiles and skip location lookups. We keep usage stats for 12 months. ## Turning it all off * **Settings → Privacy** has a switch for the ping and one for usage stats. * In the desktop app, starting OpenPencil with `OPENPENCIL_TELEMETRY=0` or `DO_NOT_TRACK=1` in its environment turns off both. * Only official releases send anything. Development servers, self-built apps, and forks send nothing, because the switch that enables sending is set only when we build a release. ## Cloud waitlist If you join the OpenPencil Cloud waitlist on this site, we store your email address and the optional note you add. We send one email to confirm the address and write again only when Cloud is ready for you. Addresses that are not confirmed are deleted after seven days, and every email has a link that removes yours. ## Other network requests OpenPencil is local-first, but some features reach the network. The desktop app checks for updates on its own; everything else below connects only when you use that feature, and only to the service named. | Feature | Where it connects | | --- | --- | | Update checks (desktop) | GitHub Releases, for the latest version number | | AI chat and image tools | The provider you configure, with your own key; the model list comes from models.dev | | Collaboration rooms | Other people in the room directly; public brokers introduce peers, and a TURN relay carries traffic when a direct connection is not possible | | Cloud storage | The storage provider you configure | | Fonts | Google Fonts, which can supply a font a document uses that is not installed | | Icons | The Iconify API, when you search or insert icons | | Stock images | Unsplash or Pexels, when you search them | The app, the feedback service, and this page are open source. The event list lives in [`packages/feedback/src/contract`](https://github.com/open-pencil/open-pencil/tree/master/packages/feedback/src/contract), and the tables above are generated from it. --- --- url: 'https://openpencil.ink/AGENTS.md' --- # Docs site Published VitePress site. `bun run docs:dev`, `bun run docs:build` for fast checks, and `bun run docs:build:production` for deployment output. `bun run check:docs` runs the integrity checks in `tools/checks/docs/` plus `check:authoring-reference`. * Keep routes under `/getting-started`, `/overview/**`, `/user-guide/**`, `/programmable/**`, `/reference/**`, and `/development/**`, plus the top-level `/download`; do not recreate `/guide/**`. Preserve moves in `packages/docs/public/_redirects`. * Link untranslated locale navigation to canonical English pages rather than adding placeholders. * `packages/docs/development/roadmap.md` tracks product direction, Figma compatibility gaps, raw metadata coverage, and the code map. `packages/docs/development/testing.md` is the canonical testing architecture. Explanations that would bloat an `AGENTS.md` belong under `packages/docs/development/`. * Component API data loaders share one `vue-component-meta` checker through `sharedChecker()` in `.vitepress/sdk/component-meta.ts`; VitePress evaluates each loader separately, so a checker per module meant one TypeScript program per page and about 7 GB of heap. * SDK pages embed the demos colocated with `packages/vue` primitives, derive API tables from source and JSDoc with `vue-component-meta`, and process examples with Twoslash so imports and types stay aligned with the public package API. The docs Tailwind entry scans those demos. * Generated authoring-reference copies under this site are checked, not edited; run `bun run generate:authoring-reference` (`packages/core/AGENTS.md`, Tools). * CI runs documentation integrity and the docs build for docs-only changes, not engine, browser, Storybook, or native suites (`tools/AGENTS.md`). ## Landing * The landing page (`.vitepress/theme/landing/`) mounts the app's own components, so the site takes the app's aliases, icon plugins, and build constants from `appSourceConfig()` in `@open-pencil/vite-config` (`vite/`) and its design tokens from `src/theme/tokens.css`. CanvasKit and core's bundled fonts are Vite asset imports handed to core in `landing/stage/engine-assets.ts`; do not copy them into `public/`. Server-rendered sections must not import from `landing/stage/`; they reach it through the client-only `ui/StageFrame.vue`, whose imports are guarded by `import.meta.env.SSR` so the server build never compiles the editor. * The landing (`.vitepress/theme/landing/**`) is part of the root `tsconfig.json`, `bun run lint`, and `bun run format`, so it is checked with the app; its `#docs/*` imports resolve there to `.vitepress/*`, and it alone may import the app through `@/` (`oxlint.json` overrides). * Each landing stage is one document pinned with `provideTabEditorStore()` and one WebGL canvas that mounts once and is never unmounted. Browsers cap WebGL contexts per page, so do not use the app's two-canvas `EditorCanvas` there or mount and unmount stages on scroll (`landing/stage/StageCanvas.vue`). * The collaboration stage (`landing/stage/collab/`) joins two stages to one room through `createLocalRoom()`, an in-page `CollabRoomTransport`, and the app's own `openRoomSession()`; it never reaches the network or IndexedDB. It is the only stage with two canvases, so the page holds one more WebGL context than it has stages. * Counts shown on the landing page come from source at build time through `landing/content/stats.data.ts`; do not hardcode them. Its roadmap is the short view of `development/roadmap.md` and changes with it. * The download page (`download.md` in each locale, `landing/download/`) shares the landing's look and catalog. `landing/download/releases.data.ts` reads the latest stable release and any newer beta from GitHub when the site builds, with `GITHUB_TOKEN` or `GH_TOKEN` when set, so the page stays static; a build that cannot reach GitHub links to the releases there instead. * The OpenPencil Cloud waitlist forms (Cloud section and roadmap entry) sign up through the app's `useWaitlistSignup()` in `src/app/cloud/waitlist/signup.ts`, the flow Settings uses: the typed feedback client, Turnstile, and the same checks as the service. Turnstile runs only on the sites the feedback service lists, so dev servers and previews show the form's unavailable state. * Landing copy lives in `landing/content/messages/`, one catalog per docs locale typed against `LandingMessages`, with `en.ts` as the source; change every catalog together. Each locale's `index.md` imports `LandingPage` and only its own catalog, so the theme never registers the landing and other docs pages download none of it. The embedded app follows the docs appearance switch and locale through `landing/stage/useDocsAppearance.ts`. * Production builds (`build:production`, `OPENPENCIL_LANDING_POSTERS=1`) show WebP stills of each stage until it is ready: the config names them by `landingPosterFingerprint()` in each landing page's `posters` frontmatter, and `tools/generate/landing-posters` captures them from the built site afterwards. Stills are named by `landing/posters.ts`; a stage reports `ready` and marks its canvas and panel with `data-poster-part`, and capture mode (`?poster-capture`) must start stages at once and keep them still (no autoplay). Development builds have no stills. * Landing pages set `landing: true`, which makes `.vitepress/landing-head.ts` preload CanvasKit and the scenes' fonts for fine pointers; on touch screens a stage starts only when tapped. Stage panels, the toolbar, and the preview controls are async components (`landing/stage/definitions.ts`), so a stage downloads only what it shows; keep new stage-specific code behind the same kind of import. * Landing logic is tested where its counterpart lives: the download page against the release catalog in `tools/release/release-packages/tests/native/download-page.test.ts`, its pure helpers in `tools/checks/docs/tests/download.test.ts`, and stills in `tools/generate/landing-posters/tests`. Keep such code free of `vitepress` imports so it loads under `bun test` (`landing/content/messages/fill.ts`). --- --- url: 'https://openpencil.ink/development/document-identity.md' description: >- How layers, variables, and collections get IDs, and which of them a saved .fig file keeps. --- # Document identity IDs follow Figma's `sessionID:localID` GUIDs, and both halves stay at or below 2^31 − 1 because Figma reads them as signed 32-bit integers and drops records beyond that. ## New IDs Every process picks a random session at startup, outside sessions 0 and 1, which FIG keeps for document records and counter GUIDs. IDs count up within it, so the editor, the CLI, and MCP never mint the same ID for different layers, whether they share a room or edit copies of one file. Tests and fixtures that need the same IDs on every run call `setIdSession` with a fixed session, or pass a generator to `new SceneGraph()`. ## Imported layers Reading a `.fig` file gives its layers handles from a counter of their own, `0:1`, `0:2`, and so on, in the order the reader builds them. The same file gets the same handles on every run, which is what lets `openpencil tree` print an ID that a later `openpencil node` or `diff apply` finds. Pages loaded later continue the counter, and a resumed session carries it in its checkpoint. A handle is not the layer's identity: the GUID the file saved it under is its `source.id`. ## Saving A layer read from the file keeps its saved GUID. A new layer is saved under its own ID when that ID has the GUID shape and no record in the archive, including records on pages never opened, already uses it. Otherwise it takes a counter GUID past every GUID in the file. Reopening the file makes the saved GUID the layer's `source.id`, so later saves keep it. ## Limits Random sessions make collisions between separate edits unlikely, not impossible, and nothing reconciles two copies of a file automatically. A shared room stays the authority for its live document; merging offline copies means applying the changes to one of them and sharing the result as a new room. --- --- url: 'https://openpencil.ink/download.md' description: >- Download the OpenPencil desktop app for macOS, Windows, and Linux, or install it with Homebrew. --- --- --- url: 'https://openpencil.ink/user-guide.md' description: >- Learn how to use OpenPencil — canvas navigation, drawing, text, components, auto-layout, and more. --- # User Guide OpenPencil is an open-source, Figma-compatible design editor — fully local, AI-native, and programmable. This guide covers everything you need to know to use the editor effectively. ::: tip Cross-platform shortcuts Throughout this guide, keyboard shortcuts use Mac notation: ⌘ = Command (Ctrl on Windows/Linux), ⌥ = Option (Alt), ⇧ = Shift. ::: ## Getting Around * [Canvas Navigation](./canvas-navigation) — panning, zooming, and the hand tool * [Selection & Manipulation](./selection-and-manipulation) — selecting, moving, resizing, rotating, and organizing nodes ## Creating Content * [Drawing Shapes](./drawing-shapes) — rectangles, ellipses, lines, frames, sections, polygons, and stars * [Text Editing](./text-editing) — creating and editing text with rich formatting * [Pen Tool](./pen-tool) — drawing vector paths with bezier curves * [Icons](./icons) — searching icon sets, inserting icons, and swapping or recoloring them ## Organizing & Managing * [Layers & Pages](./layers-and-pages) — the layers panel, pages, and properties panel * [Context Menu](./context-menu) — right-click actions for clipboard, grouping, components, and more * [Exporting](./exporting) — image export and .fig file operations * [Cloud Storage](./cloud-storage) — documents and libraries in your own S3-compatible bucket ## Advanced Features * [Auto Layout](./auto-layout) — flexbox-based automatic positioning * [Components](./components) — reusable components, instances, and overrides * [Variables](./variables) — design variables, collections, modes, and fill bindings * [Checking Designs](./checking-designs) — the Check panel, canvas issue markers, and lint rules