From openpencil-skill
Designs UI layouts with OpenPencil via op CLI, batch design DSL, sandboxed JS scripts, or MCP tools. Covers PenNode schema, semantic roles, typography, color, spacing, and component patterns.
How this skill is triggered — by the user, by Claude, or both
Slash command
/openpencil-skill:openpencil-designThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Generate production-quality vector designs by writing PenNode JSON trees. Use the `op` CLI or MCP tools to create, read, update, and delete nodes on the OpenPencil canvas.
Generate production-quality vector designs by writing PenNode JSON trees. Use the op CLI or MCP tools to create, read, update, and delete nodes on the OpenPencil canvas.
.op filesop CLI to script design operationsbatch_design, insert_node, design_skeleton)op CLI# App control
op start [--desktop|--web] # Launch app
op stop # Stop running instance
op status # Check if running
# Document
op open [file.op] # Open file or connect to live canvas
op save <file.op> # Save current document
op get [--depth N] [--pretty] # Get document tree
op selection [--depth N] # Get current canvas selection
op read-nodes [id...] [--depth N] [--vars] # Read node subtree(s) with optional variable resolution
op layout [--parent P] [--depth N] # Snapshot layout tree with computed positions
op find-space [--direction D] [--width N] [--height N] # Find empty space on canvas
# Node operations
op insert '<json>' [--parent P] # Insert node (--index N, --post-process)
op update <id> '<json>' # Update node
op delete <id> # Delete node
op move <id> <parent> [index] # Move node
op copy <id> <parent> # Deep-copy node
op replace <id> '<json>' # Replace node
# Batch design
op design '<dsl>' # Batch design DSL (inline, @file, or stdin) [--canvas-width N]
op design @ui.js # Sandboxed JS script: I(parent, obj) + loops (.js/.mjs implies --script)
op design '<js>' --script # Same, inline/stdin (flag must FOLLOW the payload)
# Layered workflow
op design:skeleton '<json>' # Create section structure
op design:content <id> '<json>' # Populate section content
op design:refine --root-id <id> # Validate + auto-fix (resolves icons) [--canvas-width N]
# Import
op import:svg <file.svg> [--parent P] # Import SVG as editable nodes
op import:figma <file.fig> [--out out.op] # Convert Figma .fig to .op document
# Pages
op page list # List all pages
op page add [--name N] # Add a new page
op page remove <id> # Remove a page
op page rename <id> '<name>' # Rename a page
op page reorder <id> <index> # Move page to position
op page duplicate <id> # Clone page with new IDs
# Variables & Themes
op vars / op vars:set '<json>' # Variables (--replace to replace all)
op themes / op themes:set '<json>' # Themes (--replace to replace all)
op theme:save <file.optheme> # Save current theme as preset file
op theme:load <file.optheme> # Load a theme preset file
op theme:list <directory> # List .optheme presets in directory
# Codegen pipeline
op codegen:plan '<json>' # Submit codegen plan (framework, rootIds, options)
op codegen:submit '<json>' # Submit a code chunk for a node
op codegen:assemble [--framework F] # Assemble all submitted chunks into final output
op codegen:clean # Clear codegen state
Global flags: --file <path>, --page <id>, --pretty. Inputs: inline string, @filepath, or - (stdin).
op insert (Recommended)The most reliable way to build designs. Use --parent to specify the parent node. Capture the returned nodeId to reference later. Always finish with design:refine to resolve icons and validate layout.
# Create root frame, capture its ID
ROOT=$(op insert '{"type":"frame","name":"Page","width":375,"height":812,"layout":"vertical"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['nodeId'])")
# Insert children using --parent
op insert --parent "$ROOT" '{"type":"text","content":"Hello","fontSize":28,"fontWeight":700}'
# Post-process: resolve icons, validate layout
op design:refine --root-id "$ROOT"
One operation per line. Bind results with name= for later reference. Best for simple, flat structures.
Limitation: The DSL parser cannot handle deeply nested JSON (e.g.,
childrenarrays with nested objects, or multiple levels of array nesting). Keep eachI()call to a single level of nesting. For complex nodes with children, use separateI()calls for parent and children, or useop insert --parent.
root=I(null, { "type": "frame", "width": 1200, "layout": "vertical" })
nav=I(root, { "type": "frame", "role": "navbar", "height": 72 })
U(nav, { "fill": [{"type": "solid", "color": "#FFFFFF"}] })
card2=C(card1, grid, { "name": "Card 2" })
M(sidebar, main, 0)
D(old_section)
R(old_btn, { "type": "rectangle", "role": "button" })
| Op | Syntax | Action |
|---|---|---|
I | name=I(parent, { node }) | Insert |
U | U(ref, { updates }) | Update |
C | name=C(source, parent, { overrides }) | Copy |
R | name=R(ref, { node }) | Replace |
M | M(ref, parent, index?) | Move |
D | D(ref) | Delete |
G | name=G(parent, "search", "query") | Generate image via search |
DSL safe pattern — always insert parent and children separately:
btn=I(form, {"type":"rectangle","role":"button","width":"fill_container","height":50,"cornerRadius":12,"fill":[{"type":"solid","color":"#111111"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"})
I(btn, {"type":"text","content":"Submit","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]})
op design @ui.js # .js / .mjs file implies script mode
op design '<js code>' --script # inline or stdin; --script must FOLLOW the payload
Via MCP: call batch_design with the script argument (exactly ONE of nodes_json | operations | script per call). This is the SAME protocol OpenPencil's internal design agents use; follow the same contract:
Write a JavaScript program (no prose, no markdown fences) that builds the design by calling the global function I(parent, node):
const id = I(parent, { ...node... }); // inserts node, RETURNS its id (a string)
parent is null for a root frame, otherwise an id returned by an EARLIER I(...) call. A node is a child of X only if you call I(X, {...}).
I(...) is the ONLY function available — there is no console, and no other builder. Do not call console.log or any helper; just call I(...). Script mode is insert-only; use the DSL ops (Approach 2) for update/move/delete.
USE REAL JAVASCRIPT — const/let, arrays of data, and for...of / .forEach loops — to generate repeated structure (table rows, nav items, cards, list items) by looping over a data array. PREFER a loop over copy-pasting near-identical I(...) calls.
Each node object starts with type ("frame"/"text"/"rectangle"/"ellipse"/"path"/"icon_font") and uses camelCase props (cornerRadius, fontSize, fontWeight, justifyContent, alignItems, clipContent). Do NOT set x/y on children inside layout frames.
Example:
const sec = I(null, {type:"frame", name:"Clients", layout:"vertical", width:"fill_container", gap:0});
const tbl = I(sec, {type:"frame", layout:"vertical", width:"fill_container"});
const rows = [{name:"Alice Chen", status:"Active"}, {name:"Bob Ito", status:"VIP"}];
for (const r of rows) {
const row = I(tbl, {type:"frame", layout:"horizontal", width:"fill_container", padding:[12,16]});
const c1 = I(row, {type:"frame", width:"fill_container"}); I(c1, {type:"text", content:r.name});
const c2 = I(row, {type:"frame", width:"fill_container"}); I(c2, {type:"text", content:r.status});
}
Generate EVERY row/card/item with realistic values. Output ONLY the JavaScript program.
CLI/MCP notes:
children arrays are SAFE here — the engine serializes each object to perfect single-line JSON, so the DSL's single-level-of-nesting limitation does not apply. Prefer separate I() calls anyway when you need the parent binding."id" after the call; only the returned binding is meaningful, and only inside this script.When emitting PenNode JSON (via op insert, op design, batch_design, insert_node), you MUST produce strictly valid JSON. Common mistakes that break parsing:
": 50 or : 50 without a key name. This often happens when you truncate/reformat — double-check.fill is ALWAYS an array: "fill": [{"type": "solid", "color": "#hex"}]. Shorthand like "fill": "#hex" works but the array form is the canonical shape.stroke is an object with a fill array: "stroke": {"thickness": 1, "fill": [{"type": "solid", "color": "#hex"}]}. NEVER "stroke": {"thickness": 1, "color": "#hex"} or "stroke": "#hex" (parser auto-converts these but the correct shape is preferred).} or ].// or /* */).", not smart/curly quotes.content for text, NOT text: {"type": "text", "content": "Hello"}.iconFontName for icons, NOT iconName or icon: {"type": "icon_font", "iconFontName": "lock"}.{
"type": "frame|rectangle|text|ellipse|line|polygon|path|image|icon_font|group|ref",
"name": "Display Name",
"role": "semantic-role",
"x": 0, "y": 0,
"rotation": 0, "opacity": 1, "visible": true
}
{
"width": 400, // number | "fill_container" | "fit_content"
"height": 300,
"layout": "vertical", // "none" | "vertical" | "horizontal"
"gap": 16,
"padding": [16, 24], // number | [v, h] | [top, right, bottom, left]
"justifyContent": "center", // "start" | "center" | "end" | "space_between" | "space_around"
"alignItems": "center", // "start" | "center" | "end"
"clipContent": true,
"cornerRadius": 12, // number | [tl, tr, br, bl]
"fill": [{ "type": "solid", "color": "#FFFFFF" }],
"stroke": { "thickness": 1, "fill": [{ "type": "solid", "color": "#E5E7EB" }], "align": "inside", "dashPattern": [5, 3] },
"effects": [{ "type": "shadow", "offsetX": 0, "offsetY": 4, "blur": 12, "spread": 0, "color": "rgba(0,0,0,0.08)" }],
"children": []
}
{
"type": "text",
"content": "Hello", // string or StyledTextSegment[]
"fontSize": 16, "fontFamily": "Inter", "fontWeight": 600,
"textAlign": "center", // "left" | "center" | "right"
"textGrowth": "fixed-width", // "auto" | "fixed-width" | "fixed-width-height"
"lineHeight": 1.5, "letterSpacing": 0,
"fill": [{ "type": "solid", "color": "#111111" }]
}
Rich text: "content": [{ "text": "Bold ", "fontWeight": "bold" }, { "text": "normal" }]
icon_font (RECOMMENDED — renders directly, no post-processing needed){ "type": "icon_font", "name": "Lock Icon", "iconFontName": "lock",
"width": 20, "height": 20,
"fill": [{ "type": "solid", "color": "#6B7280" }] }
Field is iconFontName (NOT iconName, NOT icon). Values are lowercase kebab-case Lucide names: mail, lock, eye, eye-off, chrome, apple, message-circle, x, arrow-right, search, heart, star, check, plus, bell, home, user, settings, chevron-right, download, globe, layers, zap, shield, play.
Works in ALL contexts: CLI, MCP tools, or direct .op files — no design:refine required.
path (requires post-processing){ "type": "path", "name": "HeartIcon", "width": 24, "height": 24,
"fill": [{ "type": "solid", "color": "#111111" }] }
PascalCase + "Icon" suffix. Auto-resolved from Lucide set during post-processing.
Path icons need post-processing. After inserting path nodes, run
op design:refine --root-id <id>or useop insert --post-process. Without this, path icons won't render visually. The standalone MCP server (used by ACP agents) does NOT have hook implementations registered, so path icons will NOT resolve there — prefericon_fontin MCP contexts.
{ "type": "image", "src": "https://example.com/photo.jpg", "width": 400, "height": 300,
"objectFit": "crop", "cornerRadius": 12 }
AI image placeholders (resolved by design:refine):
{ "type": "image", "width": 400, "height": 300,
"imagePrompt": "A modern office workspace with natural light",
"imageSearchQuery": "modern office workspace" }
Image adjustments (all -100 to 100): exposure, contrast, saturation, temperature, tint, highlights, shadows.
{ "type": "polygon", "polygonCount": 6, "width": 80, "height": 80, "cornerRadius": 4,
"fill": [{ "type": "solid", "color": "#6366F1" }] }
{ "type": "icon_font", "iconFontName": "lucide:home", "width": 24, "height": 24,
"fill": [{ "type": "solid", "color": "#111111" }] }
{ "type": "line", "x2": 200, "y2": 0,
"stroke": { "thickness": 1, "fill": [{ "type": "solid", "color": "#E5E7EB" }] } }
{ "type": "solid", "color": "#3B82F6" }
{ "type": "linear_gradient", "angle": 135,
"stops": [{ "offset": 0, "color": "#6366F1" }, { "offset": 1, "color": "#8B5CF6" }] }
{ "type": "radial_gradient", "cx": 0.5, "cy": 0.5, "radius": 0.5,
"stops": [{ "offset": 0, "color": "#FFF" }, { "offset": 1, "color": "#000" }] }
{ "type": "image", "url": "https://example.com/texture.jpg", "mode": "fill" }
Image fill modes: fill, fit, crop, tile, stretch. Image fill also supports adjustment filters (exposure, contrast, saturation, etc.).
{ "type": "ref", "ref": "reusable-frame-id",
"descendants": { "child-id": { "content": "Override text" } } }
References a frame with reusable: true. Override specific descendant properties via descendants.
Reference with $ prefix: "color": "$primaryColor", "gap": "$spacing".
Roles declare intent — the engine applies smart defaults. Always prefer roles over manual styling.
| Category | Roles |
|---|---|
| Layout | section, row, column, centered-content, divider, spacer |
| Navigation | navbar, nav-links, nav-link |
| Interactive | button, icon-button, badge, tag, pill, input, form-input, search-bar |
| Cards | card, feature-card, stat-card, pricing-card, image-card |
| Content | hero, feature-grid, cta-section, footer, testimonial, stats-section |
| Typography | heading, subheading, body-text, caption, label |
| Media | avatar, icon, phone-mockup, screenshot-frame |
| Table | table, table-row, table-header, table-cell |
| Form | form-group |
Key defaults:
navbar → height: 56-72, horizontal, space_between, center-alignedbutton → padding: [12, 24], cornerRadius: 8, centeredcard → vertical, gap: 12, cornerRadius: 12, padding: 24heading → lineHeight: 1.2, letterSpacing: -0.5body-text → fill_container, textGrowth: fixed-width, lineHeight: 1.5fill_container or all fixedfill_container inside fit_content parent — circular dependencywidth: "fill_container", height: "fill_container"| Question | Answer |
|---|---|
| Stretch to fill? | "fill_container" |
| Shrink to content? | "fit_content" |
| Exact size? | number (px) |
| Type | Width | Height |
|---|---|---|
| Landing page | 1200 | 0 (auto) |
| Mobile screen | 375 | 812 |
| Dashboard | 1200 | 0 (auto) |
Display: 40-56px 700 letterSpacing: -1.5 lineHeight: 1.1 "Space Grotesk"
Heading: 28-36px 700 letterSpacing: -0.5 lineHeight: 1.2 "Space Grotesk"
Subheading: 20-24px 600 letterSpacing: -0.25 lineHeight: 1.3 "Space Grotesk"
Body: 15-18px 400 letterSpacing: 0 lineHeight: 1.5 "Inter"
Caption: 13-14px 400 letterSpacing: 0 lineHeight: 1.4 "Inter"
CJK: use "Noto Sans SC/JP/KR", lineHeight >= 1.3, letterSpacing: 0 always.
Primary text: #111111 Secondary: #6B7280 Subtle: #9CA3AF
Background: #FFFFFF Surface: #F9FAFB Border: #E5E7EB
Max 2 saturated colors. WCAG AA: 4.5:1 body, 3:1 large. Dark bg: #0F172A, not #000000.
Related: 8-16px Components: 16-24px
Groups: 24-32px Sections: 48-80px Page padding: 80px
// Subtle (cards)
{ "type": "shadow", "offsetY": 1, "blur": 3, "color": "rgba(0,0,0,0.05)" }
// Medium (dropdowns)
{ "type": "shadow", "offsetY": 4, "blur": 12, "color": "rgba(0,0,0,0.08)" }
// Elevated (modals)
{ "type": "shadow", "offsetY": 8, "blur": 24, "spread": -4, "color": "rgba(0,0,0,0.12)" }
Headlines: 2-6 words. Subtitles: max 15 words. Buttons: 1-3 words. No lorem ipsum. No emoji as icons.
For complex multi-section pages, use the three-step skeleton → content → refine flow:
| Step | MCP Tool | CLI Equivalent |
|---|---|---|
| 1. Create section structure | design_skeleton | op design:skeleton '<json>' |
| 2. Populate each section | design_content (with postProcess: true) | op design:content <section-id> '<json>' |
| 3. Validate + auto-fix | design_refine | op design:refine --root-id <id> |
design:refine resolves icon names → SVG paths, fixes layout issues, and validates the tree. Always run as the final step.
For incremental, framework-aware code generation from the design tree:
| Step | CLI Command | MCP Tool | Description |
|---|---|---|---|
| 1. Plan | op codegen:plan '<json>' | codegen_plan | Declare framework, root node IDs, and options |
| 2. Submit | op codegen:submit '<json>' | codegen_submit_chunk | Submit generated code for individual nodes |
| 3. Assemble | op codegen:assemble --framework react | codegen_assemble | Combine all chunks into the final output |
| 4. Clean | op codegen:clean | codegen_clean | Clear server-side codegen state |
The plan JSON shape:
{ "framework": "react", "rootIds": ["frame-1"], "options": { "tailwind": true } }
The submit JSON shape:
{ "nodeId": "card-1", "code": "<Card className=\"...\">...</Card>", "imports": ["Card"] }
Supported frameworks: react, html, vue, svelte, flutter, swiftui, compose, rn (React Native), css.
op page list # List all pages with IDs
op page add --name "Settings" # Add a new page
op page remove <page-id> # Remove a page
op page rename <page-id> 'New Name' # Rename a page
op page reorder <page-id> 2 # Move page to index 2
op page duplicate <page-id> # Clone page with new IDs
Use --page <id> on any command to target a specific page. Without it, commands operate on the first page.
Patterns below show op insert --parent commands. Each pattern is copy-paste ready.
NAV=$(op insert --parent "$ROOT" '{"type":"frame","role":"navbar","width":"fill_container","height":72,"layout":"horizontal","padding":[0,80],"justifyContent":"space_between","alignItems":"center","fill":[{"type":"solid","color":"#FFFFFF"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"#F3F4F6"}]}}' | ID)
op insert --parent "$NAV" '{"type":"text","content":"Brand","fontSize":20,"fontWeight":700,"fontFamily":"Space Grotesk"}'
LINKS=$(op insert --parent "$NAV" '{"type":"frame","role":"nav-links","layout":"horizontal","gap":32,"width":"fit_content","height":"fit_content"}' | ID)
op insert --parent "$LINKS" '{"type":"text","role":"nav-link","content":"Features","fontSize":15}'
op insert --parent "$LINKS" '{"type":"text","role":"nav-link","content":"Pricing","fontSize":15}'
CTA=$(op insert --parent "$NAV" '{"type":"rectangle","role":"button","padding":[10,24],"cornerRadius":8,"fill":[{"type":"solid","color":"#111111"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"}' | ID)
op insert --parent "$CTA" '{"type":"text","content":"Get Started","fontSize":14,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]}'
HERO=$(op insert --parent "$ROOT" '{"type":"frame","role":"hero","width":"fill_container","height":"fit_content","layout":"vertical","padding":[100,80],"gap":24,"alignItems":"center"}' | ID)
op insert --parent "$HERO" '{"type":"text","role":"heading","content":"Build something great","fontSize":56,"fontWeight":700,"fontFamily":"Space Grotesk","textAlign":"center","letterSpacing":-1.5,"lineHeight":1.1,"textGrowth":"fixed-width","width":800}'
op insert --parent "$HERO" '{"type":"text","role":"subheading","content":"The modern platform for teams who ship fast.","fontSize":18,"textAlign":"center","lineHeight":1.6,"textGrowth":"fixed-width","width":560,"fill":[{"type":"solid","color":"#6B7280"}]}'
BTNS=$(op insert --parent "$HERO" '{"type":"frame","layout":"horizontal","gap":12,"width":"fit_content","height":"fit_content"}' | ID)
B1=$(op insert --parent "$BTNS" '{"type":"rectangle","role":"button","padding":[14,32],"cornerRadius":10,"fill":[{"type":"solid","color":"#111111"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"}' | ID)
op insert --parent "$B1" '{"type":"text","content":"Start Free","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]}'
B2=$(op insert --parent "$BTNS" '{"type":"rectangle","role":"button","padding":[14,32],"cornerRadius":10,"fill":[{"type":"solid","color":"#F3F4F6"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"}' | ID)
op insert --parent "$B2" '{"type":"text","content":"View Demo","fontSize":16,"fontWeight":600}'
CARD=$(op insert --parent "$GRID" '{"type":"rectangle","role":"feature-card","width":"fill_container","height":"fill_container","layout":"vertical","padding":28,"gap":16,"cornerRadius":16,"fill":[{"type":"solid","color":"#F9FAFB"}]}' | ID)
op insert --parent "$CARD" '{"type":"path","name":"ZapIcon","width":24,"height":24,"fill":[{"type":"solid","color":"#111111"}]}'
op insert --parent "$CARD" '{"type":"text","content":"Lightning Fast","fontSize":20,"fontWeight":600}'
op insert --parent "$CARD" '{"type":"text","role":"body-text","content":"Sub-second builds with smart caching.","fontSize":15,"lineHeight":1.6,"fill":[{"type":"solid","color":"#6B7280"}]}'
GRP=$(op insert --parent "$FORM" '{"type":"frame","role":"form-group","layout":"vertical","gap":8,"width":"fill_container"}' | ID)
op insert --parent "$GRP" '{"type":"text","role":"label","content":"Email","fontSize":14,"fontWeight":500}'
INP=$(op insert --parent "$GRP" '{"type":"rectangle","role":"form-input","width":"fill_container","height":48,"cornerRadius":10,"layout":"horizontal","padding":[0,16],"gap":10,"alignItems":"center","fill":[{"type":"solid","color":"#F9FAFB"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"#E5E7EB"}]}}' | ID)
op insert --parent "$INP" '{"type":"path","name":"MailIcon","width":18,"height":18,"fill":[{"type":"solid","color":"#9CA3AF"}]}'
op insert --parent "$INP" '{"type":"text","content":"[email protected]","fontSize":15,"fill":[{"type":"solid","color":"#9CA3AF"}]}'
FOOTER=$(op insert --parent "$ROOT" '{"type":"frame","role":"footer","width":"fill_container","height":"fit_content","layout":"horizontal","padding":[48,80],"gap":80,"fill":[{"type":"solid","color":"#F9FAFB"}]}' | ID)
COL1=$(op insert --parent "$FOOTER" '{"type":"frame","layout":"vertical","gap":16,"width":240}' | ID)
op insert --parent "$COL1" '{"type":"text","content":"Brand","fontSize":20,"fontWeight":700,"fontFamily":"Space Grotesk"}'
op insert --parent "$COL1" '{"type":"text","content":"Building the future of design.","fontSize":14,"lineHeight":1.6,"fill":[{"type":"solid","color":"#6B7280"}]}'
COL2=$(op insert --parent "$FOOTER" '{"type":"frame","layout":"vertical","gap":12,"width":"fit_content"}' | ID)
op insert --parent "$COL2" '{"type":"text","content":"Product","fontSize":14,"fontWeight":600}'
op insert --parent "$COL2" '{"type":"text","content":"Features","fontSize":14,"fill":[{"type":"solid","color":"#6B7280"}]}'
op insert --parent "$COL2" '{"type":"text","content":"Pricing","fontSize":14,"fill":[{"type":"solid","color":"#6B7280"}]}'
| Mistake | Fix |
|---|---|
| Setting x/y inside layout container | Remove x/y — engine auto-positions |
| Cards with different width strategies | All siblings: same sizing (fill_container) |
fill_container child in fit_content parent | Use fixed width or switch parent to fill_container |
Pure black text #000000 | Use #111111 or #0F172A |
| Heavy drop shadows | Use subtle rgba(0,0,0,0.05-0.12) |
| Emoji as icons | Use path nodes with icon names |
| Lorem ipsum placeholder | Write realistic, concise copy |
| Fixed height on text | Use textGrowth: "fixed-width" instead |
| Space Grotesk for CJK | Use "Noto Sans SC/JP/KR" |
| Negative letterSpacing on CJK | Always 0 for CJK text |
| Missing post-process after insert | Run op design:refine --root-id <id> after building the tree |
| Icons inserted but not visible | Path nodes need design:refine or --post-process to resolve SVG |
Using DSL I() with inline children | DSL parser fails on nested JSON — insert parent and children separately |
Missing postProcess: true in MCP | Always set for MCP tool calls |
op insert Workflow (Recommended)Build a complete mobile login page using op insert --parent. This is the most reliable approach.
#!/bin/bash
set -e
ID() { python3 -c "import sys,json; print(json.load(sys.stdin)['nodeId'])"; }
# Root frame (mobile)
ROOT=$(op insert '{"type":"frame","name":"Login","width":375,"height":812,"layout":"vertical","fill":[{"type":"solid","color":"#FFFFFF"}]}' | ID)
# Header
TOP=$(op insert --parent "$ROOT" '{"type":"frame","width":"fill_container","height":"fit_content","layout":"vertical","padding":[80,32,40,32],"gap":14,"alignItems":"center"}' | ID)
op insert --parent "$TOP" '{"type":"path","name":"ShieldIcon","width":48,"height":48,"fill":[{"type":"solid","color":"#6366F1"}]}'
op insert --parent "$TOP" '{"type":"text","content":"Welcome Back","fontSize":28,"fontWeight":700,"fontFamily":"Space Grotesk","letterSpacing":-0.5,"textAlign":"center"}'
# Form
FORM=$(op insert --parent "$ROOT" '{"type":"frame","width":"fill_container","height":"fit_content","layout":"vertical","padding":[0,32],"gap":20}' | ID)
# Email input
GRP=$(op insert --parent "$FORM" '{"type":"frame","role":"form-group","layout":"vertical","gap":8,"width":"fill_container"}' | ID)
op insert --parent "$GRP" '{"type":"text","role":"label","content":"Email","fontSize":14,"fontWeight":500}'
INP=$(op insert --parent "$GRP" '{"type":"rectangle","role":"form-input","width":"fill_container","height":48,"cornerRadius":10,"layout":"horizontal","padding":[0,16],"gap":10,"alignItems":"center","fill":[{"type":"solid","color":"#F9FAFB"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"#E5E7EB"}]}}' | ID)
op insert --parent "$INP" '{"type":"path","name":"MailIcon","width":18,"height":18,"fill":[{"type":"solid","color":"#9CA3AF"}]}'
op insert --parent "$INP" '{"type":"text","content":"[email protected]","fontSize":15,"fill":[{"type":"solid","color":"#9CA3AF"}]}'
# Login button
BTN=$(op insert --parent "$FORM" '{"type":"rectangle","role":"button","width":"fill_container","height":50,"cornerRadius":12,"fill":[{"type":"solid","color":"#111111"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"}' | ID)
op insert --parent "$BTN" '{"type":"text","content":"Sign In","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]}'
# IMPORTANT: resolve icons + validate layout
op design:refine --root-id "$ROOT"
DSL is suitable for simpler structures. Avoid inline children — insert parent and children as separate operations.
root=I(null, {"type":"frame","name":"Landing","width":1200,"height":0,"layout":"vertical","fill":[{"type":"solid","color":"#FFFFFF"}]})
nav=I(root, {"type":"frame","role":"navbar","width":"fill_container","height":72,"layout":"horizontal","padding":[0,80],"justifyContent":"space_between","alignItems":"center"})
I(nav, {"type":"text","content":"Acme","fontSize":20,"fontWeight":700,"fontFamily":"Space Grotesk"})
links=I(nav, {"type":"frame","role":"nav-links","layout":"horizontal","gap":32,"width":"fit_content","height":"fit_content"})
I(links, {"type":"text","role":"nav-link","content":"Features","fontSize":15})
I(links, {"type":"text","role":"nav-link","content":"Pricing","fontSize":15})
cta=I(nav, {"type":"rectangle","role":"button","padding":[10,24],"cornerRadius":8,"fill":[{"type":"solid","color":"#111111"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"})
I(cta, {"type":"text","content":"Get Started","fontSize":14,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]})
hero=I(root, {"type":"frame","role":"hero","width":"fill_container","height":"fit_content","layout":"vertical","padding":[100,80],"gap":24,"alignItems":"center"})
I(hero, {"type":"text","role":"heading","content":"Ship faster with Acme","fontSize":56,"fontWeight":700,"fontFamily":"Space Grotesk","textAlign":"center","letterSpacing":-1.5,"lineHeight":1.1,"textGrowth":"fixed-width","width":800})
I(hero, {"type":"text","role":"subheading","content":"Turn ideas into production apps in minutes.","fontSize":18,"textAlign":"center","lineHeight":1.6,"textGrowth":"fixed-width","width":560,"fill":[{"type":"solid","color":"#6B7280"}]})
btns=I(hero, {"type":"frame","layout":"horizontal","gap":12,"width":"fit_content","height":"fit_content"})
b1=I(btns, {"type":"rectangle","role":"button","padding":[14,32],"cornerRadius":10,"fill":[{"type":"solid","color":"#111111"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"})
I(b1, {"type":"text","content":"Start Free","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]})
b2=I(btns, {"type":"rectangle","role":"button","padding":[14,32],"cornerRadius":10,"fill":[{"type":"solid","color":"#F3F4F6"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"})
I(b2, {"type":"text","content":"View Demo","fontSize":16,"fontWeight":600})
feat=I(root, {"type":"frame","role":"section","width":"fill_container","height":"fit_content","layout":"vertical","padding":[80,80],"gap":48,"alignItems":"center"})
I(feat, {"type":"text","role":"heading","content":"Everything you need","fontSize":36,"fontWeight":700,"fontFamily":"Space Grotesk","textAlign":"center","letterSpacing":-0.5})
grid=I(feat, {"type":"frame","role":"feature-grid","width":"fill_container","layout":"horizontal","gap":24})
c1=I(grid, {"type":"rectangle","role":"feature-card","width":"fill_container","height":"fill_container","layout":"vertical","padding":28,"gap":16,"cornerRadius":16,"fill":[{"type":"solid","color":"#F9FAFB"}]})
I(c1, {"type":"path","name":"ZapIcon","width":24,"height":24,"fill":[{"type":"solid","color":"#111111"}]})
I(c1, {"type":"text","content":"Lightning Fast","fontSize":20,"fontWeight":600})
I(c1, {"type":"text","role":"body-text","content":"Sub-second builds with smart caching.","fontSize":15,"lineHeight":1.6,"fill":[{"type":"solid","color":"#6B7280"}]})
c2=C(c1, grid, {})
U(c2+"/0", {"name":"ShieldIcon"})
U(c2+"/1", {"content":"Enterprise Security"})
U(c2+"/2", {"content":"SOC 2 certified with end-to-end encryption."})
c3=C(c1, grid, {})
U(c3+"/0", {"name":"GitBranchIcon"})
U(c3+"/1", {"content":"Git-Native Workflow"})
U(c3+"/2", {"content":"Preview deploys on every push with instant rollback."})
npx claudepluginhub zseven-w/openpencil-skill --plugin openpencil-skillCreates detailed ASCII wireframes for page layouts, component placement, responsive breakpoints, and content hierarchy. Use when planning new pages or redesigning existing layouts.
Generates UI/UX wireframes and mockups in draw.io at lo-fi, mid-fi, hi-fi fidelity levels using mockup shape libraries, frames for browser, iOS/Android mobile, and tablets.
Designs distinctive, non-generic web UIs using a strategy-first approach: define brand identity, typography, color system, then craft layout, components, motion, and accessibility. Activates on build/design requests to avoid AI-default aesthetics.