@zseven-w/openpencil 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1344,10 +1344,16 @@ async function pushLiveDocument(doc) {
1344
1344
  const syncUrl = cachedUrl ?? await getSyncUrl();
1345
1345
  if (!syncUrl) return;
1346
1346
  try {
1347
+ const body = JSON.stringify({ document: doc });
1348
+ const bodyBytes = new TextEncoder().encode(body).byteLength;
1347
1349
  await fetch(`${syncUrl}/api/mcp/document`, {
1348
1350
  method: "POST",
1349
- headers: { "Content-Type": "application/json" },
1350
- body: JSON.stringify({ document: doc })
1351
+ headers: {
1352
+ "Content-Type": "application/json",
1353
+ "x-openpencil-client-id": "mcp-server:live-canvas",
1354
+ "x-openpencil-body-bytes": String(bodyBytes)
1355
+ },
1356
+ body
1351
1357
  });
1352
1358
  } catch {
1353
1359
  clearSyncUrl();
@@ -3032,9 +3038,18 @@ var init_design_prompt = __esm({
3032
3038
  cjk: "cjk-typography",
3033
3039
  examples: "examples"
3034
3040
  };
3035
- INTRO = `You are generating designs for OpenPencil, a vector design tool.
3036
- Use batch_design (for multi-node designs with DSL) or insert_node (for single node trees with JSON).
3037
- Both support postProcess=true for automatic role defaults, icon resolution, and layout sanitization.
3041
+ INTRO = `You are working with OpenPencil, a vector design tool.
3042
+
3043
+ TOOL SELECTION \u2014 match the user's intent:
3044
+ - READ/INSPECT the canvas: batch_get (search nodes, get IDs), snapshot_layout (spatial overview), get_selection (selected nodes)
3045
+ - CREATE new designs: batch_design (DSL, multi-node), insert_node (JSON, single tree) \u2014 both support postProcess=true
3046
+ - MODIFY existing nodes: update_node (change properties), replace_node (swap entirely)
3047
+ - DELETE/REMOVE elements: delete_node (remove by ID) \u2014 always batch_get first to find the correct ID
3048
+ - MOVE/COPY: move_node, copy_node
3049
+
3050
+ IMPORTANT: When the user asks to read, inspect, find, or look at existing content, use batch_get or snapshot_layout \u2014 do NOT create new nodes.
3051
+ When the user asks to delete or remove something, use batch_get to find it, then delete_node \u2014 do NOT create new nodes.
3052
+
3038
3053
  Each node must follow the PenNode schema below.`;
3039
3054
  DESIGN_TYPE_DETECTION = `DESIGN TYPE DETECTION:
3040
3055
  Classify by the design's PURPOSE to choose the correct root frame size \u2014 reason about intent, do not keyword-match:
@@ -3272,7 +3287,7 @@ async function handleOpenDocument(params) {
3272
3287
  hasThemes: !!doc.themes && Object.keys(doc.themes).length > 0
3273
3288
  },
3274
3289
  context: buildDocumentContext(doc),
3275
- designPrompt: isEmpty ? buildDesignPrompt() : 'Document has existing content. Use batch_design or insert_node with postProcess=true to add/modify designs. For complex multi-section designs, use the layered workflow: design_skeleton \u2192 design_content (per section) \u2192 design_refine. Call get_design_prompt(section="planning") for layered workflow guide, or get_design_prompt() for full guidelines.'
3290
+ designPrompt: isEmpty ? buildDesignPrompt() : 'Document has existing content. Match your action to the user intent:\n- READ/INSPECT: Use batch_get (search by type/name/ID) or snapshot_layout to see what is on the canvas.\n- DELETE/REMOVE: Use batch_get to find the target node ID, then delete_node to remove it.\n- MODIFY: Use update_node to change properties of existing nodes.\n- ADD NEW: Use batch_design or insert_node with postProcess=true.\nFor complex multi-section designs, use the layered workflow: design_skeleton \u2192 design_content \u2192 design_refine. Call get_design_prompt(section="planning") for layered workflow guide, or get_design_prompt() for full guidelines.'
3276
3291
  };
3277
3292
  }
3278
3293
  function buildDocumentContext(doc) {
@@ -3676,14 +3691,17 @@ async function handleBatchDesign(params) {
3676
3691
  const pageId = params.pageId;
3677
3692
  const bindings = /* @__PURE__ */ new Map();
3678
3693
  const results = [];
3679
- const lines = params.operations.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("//"));
3694
+ const errors = [];
3695
+ const lines = splitOperations(params.operations);
3680
3696
  for (const line of lines) {
3681
3697
  try {
3682
3698
  await executeLine(line, doc, bindings, results, pageId);
3683
3699
  } catch (err2) {
3684
- throw new Error(
3685
- `Error executing "${line}": ${err2 instanceof Error ? err2.message : String(err2)}`
3686
- );
3700
+ const preview = line.length > 200 ? `${line.slice(0, 200)}...` : line;
3701
+ errors.push({
3702
+ line: preview,
3703
+ error: err2 instanceof Error ? err2.message : String(err2)
3704
+ });
3687
3705
  }
3688
3706
  }
3689
3707
  let postProcessed = false;
@@ -3716,14 +3734,56 @@ async function handleBatchDesign(params) {
3716
3734
  return {
3717
3735
  results,
3718
3736
  nodeCount: countNodes2(getDocChildren(doc, pageId)),
3719
- postProcessed: postProcessed || void 0
3737
+ postProcessed: postProcessed || void 0,
3738
+ errors: errors.length > 0 ? errors : void 0
3720
3739
  };
3721
3740
  }
3741
+ function splitOperations(raw) {
3742
+ const result = [];
3743
+ let buf = "";
3744
+ let depth = 0;
3745
+ let inString = false;
3746
+ let escape = false;
3747
+ for (let i = 0; i < raw.length; i++) {
3748
+ const ch = raw[i];
3749
+ buf += ch;
3750
+ if (escape) {
3751
+ escape = false;
3752
+ continue;
3753
+ }
3754
+ if (ch === "\\" && inString) {
3755
+ escape = true;
3756
+ continue;
3757
+ }
3758
+ if (ch === '"') {
3759
+ inString = !inString;
3760
+ continue;
3761
+ }
3762
+ if (inString) continue;
3763
+ if (ch === "(" || ch === "[" || ch === "{") depth++;
3764
+ else if (ch === ")" || ch === "]" || ch === "}") depth--;
3765
+ else if (ch === "\n" && depth === 0) {
3766
+ const trimmed = buf.trim();
3767
+ if (trimmed && !trimmed.startsWith("//")) result.push(trimmed);
3768
+ buf = "";
3769
+ }
3770
+ }
3771
+ const tail = buf.trim();
3772
+ if (tail && !tail.startsWith("//")) result.push(tail);
3773
+ return result;
3774
+ }
3722
3775
  async function executeLine(line, doc, bindings, results, pageId) {
3723
3776
  const assignMatch = line.match(/^(\w+)\s*=\s*([ICRMG])\((.+)\)$/);
3777
+ const bindlessAssignMatch = !assignMatch && line.match(/^([ICRG])\((.+)\)$/);
3724
3778
  const callMatch = line.match(/^([UDM])\((.+)\)$/);
3725
- if (assignMatch) {
3726
- const [, binding, op, argsStr] = assignMatch;
3779
+ const effectiveAssign = assignMatch ?? (bindlessAssignMatch ? [
3780
+ line,
3781
+ `_auto_${results.length}_${bindlessAssignMatch[1]}`,
3782
+ bindlessAssignMatch[1],
3783
+ bindlessAssignMatch[2]
3784
+ ] : null);
3785
+ if (effectiveAssign) {
3786
+ const [, binding, op, argsStr] = effectiveAssign;
3727
3787
  switch (op) {
3728
3788
  case "I": {
3729
3789
  const { parent, data } = parseInsertArgs(argsStr, bindings);
@@ -3993,18 +4053,73 @@ function applyDescendantOverrides(node, descendants) {
3993
4053
  }
3994
4054
  function parseJsonArg2(str) {
3995
4055
  const trimmed = str.trim();
4056
+ let parsed;
3996
4057
  try {
3997
- return sanitizeObject(JSON.parse(trimmed));
4058
+ parsed = JSON.parse(trimmed);
3998
4059
  } catch {
3999
4060
  }
4000
- let normalized = trimmed;
4001
- normalized = normalized.replace(/(?<=\{|,)\s*(\w+)\s*:/g, ' "$1":');
4002
- normalized = replaceSingleQuoteDelimiters(normalized);
4003
- try {
4004
- return sanitizeObject(JSON.parse(normalized));
4005
- } catch {
4006
- throw new Error(`Failed to parse JSON: ${str.slice(0, 200)}`);
4061
+ if (parsed === void 0) {
4062
+ let normalized = trimmed;
4063
+ normalized = normalized.replace(/(?<=\{|,)\s*(\w+)\s*:/g, ' "$1":');
4064
+ normalized = replaceSingleQuoteDelimiters(normalized);
4065
+ normalized = normalized.replace(/,\s*""\s*:\s*[^,}\]]+/g, "");
4066
+ normalized = normalized.replace(/,(\s*[}\]])/g, "$1");
4067
+ try {
4068
+ parsed = JSON.parse(normalized);
4069
+ } catch (err2) {
4070
+ const snippet = str.slice(0, 300);
4071
+ throw new Error(
4072
+ `Failed to parse JSON (${err2 instanceof Error ? err2.message : "unknown"}): ${snippet}${str.length > 300 ? "..." : ""}`
4073
+ );
4074
+ }
4007
4075
  }
4076
+ return sanitizeObject(normalizeNodeShape(parsed));
4077
+ }
4078
+ function normalizeNodeShape(input) {
4079
+ if (Array.isArray(input)) return input.map(normalizeNodeShape);
4080
+ if (!input || typeof input !== "object") return input;
4081
+ const obj = input;
4082
+ if ("fill" in obj) {
4083
+ obj.fill = normalizeFillField(obj.fill);
4084
+ }
4085
+ if ("stroke" in obj) {
4086
+ obj.stroke = normalizeStrokeField(obj.stroke);
4087
+ }
4088
+ if (Array.isArray(obj.children)) {
4089
+ obj.children = obj.children.map((c) => normalizeNodeShape(c));
4090
+ }
4091
+ return obj;
4092
+ }
4093
+ function normalizeFillField(value) {
4094
+ if (value == null) return value;
4095
+ if (typeof value === "string") {
4096
+ return [{ type: "solid", color: value }];
4097
+ }
4098
+ if (typeof value === "object" && !Array.isArray(value)) {
4099
+ return [value];
4100
+ }
4101
+ return value;
4102
+ }
4103
+ function normalizeStrokeField(value) {
4104
+ if (value == null) return value;
4105
+ if (typeof value === "string") {
4106
+ return { thickness: 1, fill: [{ type: "solid", color: value }] };
4107
+ }
4108
+ if (typeof value !== "object") return value;
4109
+ const stroke = value;
4110
+ if (stroke.color != null && stroke.fill == null) {
4111
+ stroke.fill = [{ type: "solid", color: stroke.color }];
4112
+ delete stroke.color;
4113
+ }
4114
+ if (stroke.fill != null) {
4115
+ stroke.fill = normalizeFillField(stroke.fill);
4116
+ }
4117
+ if (stroke.type != null && stroke.color != null && stroke.fill == null) {
4118
+ stroke.fill = [{ type: stroke.type, color: stroke.color }];
4119
+ delete stroke.type;
4120
+ delete stroke.color;
4121
+ }
4122
+ return stroke;
4008
4123
  }
4009
4124
  function replaceSingleQuoteDelimiters(str) {
4010
4125
  const chars = [];
@@ -11808,6 +11923,8 @@ function mapSingleFill(paint) {
11808
11923
  type: "image",
11809
11924
  url,
11810
11925
  mode: mapScaleMode(paint.imageScaleMode),
11926
+ originalSize: normalizeOriginalSize(paint.originalImageWidth, paint.originalImageHeight),
11927
+ transform: normalizeImageTransform(paint.transform),
11811
11928
  opacity: paint.opacity
11812
11929
  };
11813
11930
  }
@@ -11819,6 +11936,26 @@ function gradientAngleFromTransform(m) {
11819
11936
  const mathAngle = Math.atan2(m.m10, m.m00) * (180 / Math.PI);
11820
11937
  return Math.round(90 - mathAngle);
11821
11938
  }
11939
+ function normalizeOriginalSize(width, height) {
11940
+ if (typeof width !== "number" || typeof height !== "number" || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
11941
+ return void 0;
11942
+ }
11943
+ return { width, height };
11944
+ }
11945
+ function normalizeImageTransform(transform) {
11946
+ if (!transform) return void 0;
11947
+ if (Math.abs(transform.m00 - 1) <= IMAGE_TRANSFORM_EPSILON && Math.abs(transform.m01) <= IMAGE_TRANSFORM_EPSILON && Math.abs(transform.m02) <= IMAGE_TRANSFORM_EPSILON && Math.abs(transform.m10) <= IMAGE_TRANSFORM_EPSILON && Math.abs(transform.m11 - 1) <= IMAGE_TRANSFORM_EPSILON && Math.abs(transform.m12) <= IMAGE_TRANSFORM_EPSILON) {
11948
+ return void 0;
11949
+ }
11950
+ return {
11951
+ m00: transform.m00,
11952
+ m01: transform.m01,
11953
+ m02: transform.m02,
11954
+ m10: transform.m10,
11955
+ m11: transform.m11,
11956
+ m12: transform.m12
11957
+ };
11958
+ }
11822
11959
  function mapScaleMode(mode) {
11823
11960
  switch (mode) {
11824
11961
  case "FIT":
@@ -11829,11 +11966,13 @@ function mapScaleMode(mode) {
11829
11966
  return "fill";
11830
11967
  }
11831
11968
  }
11969
+ var IMAGE_TRANSFORM_EPSILON;
11832
11970
  var init_figma_fill_mapper = __esm({
11833
11971
  "packages/pen-figma/src/figma-fill-mapper.ts"() {
11834
11972
  "use strict";
11835
11973
  init_define_import_meta_env();
11836
11974
  init_figma_color_utils();
11975
+ IMAGE_TRANSFORM_EPSILON = 1e-6;
11837
11976
  }
11838
11977
  });
11839
11978
 
@@ -11870,8 +12009,8 @@ function mapStrokeAlign(align) {
11870
12009
  return void 0;
11871
12010
  }
11872
12011
  }
11873
- function mapStrokeJoin(join5) {
11874
- switch (join5) {
12012
+ function mapStrokeJoin(join6) {
12013
+ switch (join6) {
11875
12014
  case "MITER":
11876
12015
  return "miter";
11877
12016
  case "BEVEL":
@@ -13266,6 +13405,335 @@ var init_import = __esm({
13266
13405
  }
13267
13406
  });
13268
13407
 
13408
+ // apps/cli/src/commands/skill-bundle.json
13409
+ var skill_bundle_default;
13410
+ var init_skill_bundle = __esm({
13411
+ "apps/cli/src/commands/skill-bundle.json"() {
13412
+ skill_bundle_default = {
13413
+ version: "0.7.0",
13414
+ files: {
13415
+ "skills/openpencil-design/SKILL.md": '---\nname: openpencil-design\ndescription: Use when designing UI with OpenPencil \u2014 creating layouts via op CLI, batch design DSL, or MCP tools. Covers PenNode schema, semantic roles, typography, color, spacing, and common component patterns.\n---\n\n# OpenPencil Design\n\nGenerate 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.\n\n## When to Use\n\n- Creating or modifying UI designs in `.op` files\n- Using the `op` CLI to script design operations\n- Designing via MCP tools (`batch_design`, `insert_node`, `design_skeleton`)\n- Need reference for PenNode schema, roles, or layout rules\n\n## Quick Reference \u2014 `op` CLI\n\n```bash\n# App control\nop start [--desktop|--web] # Launch app\nop stop # Stop running instance\nop status # Check if running\n\n# Document\nop open [file.op] # Open file or connect to live canvas\nop save <file.op> # Save current document\nop get [--depth N] [--pretty] # Get document tree\nop selection [--depth N] # Get current canvas selection\nop read-nodes [id...] [--depth N] [--vars] # Read node subtree(s) with optional variable resolution\nop layout [--parent P] [--depth N] # Snapshot layout tree with computed positions\nop find-space [--direction D] [--width N] [--height N] # Find empty space on canvas\n\n# Node operations\nop insert \'<json>\' [--parent P] # Insert node (--index N, --post-process)\nop update <id> \'<json>\' # Update node\nop delete <id> # Delete node\nop move <id> <parent> [index] # Move node\nop copy <id> <parent> # Deep-copy node\nop replace <id> \'<json>\' # Replace node\n\n# Batch design\nop design \'<dsl>\' # Batch design DSL (inline, @file, or stdin) [--canvas-width N]\n\n# Layered workflow\nop design:skeleton \'<json>\' # Create section structure\nop design:content <id> \'<json>\' # Populate section content\nop design:refine --root-id <id> # Validate + auto-fix (resolves icons) [--canvas-width N]\n\n# Import\nop import:svg <file.svg> [--parent P] # Import SVG as editable nodes\nop import:figma <file.fig> [--out out.op] # Convert Figma .fig to .op document\n\n# Pages\nop page list # List all pages\nop page add [--name N] # Add a new page\nop page remove <id> # Remove a page\nop page rename <id> \'<name>\' # Rename a page\nop page reorder <id> <index> # Move page to position\nop page duplicate <id> # Clone page with new IDs\n\n# Variables & Themes\nop vars / op vars:set \'<json>\' # Variables (--replace to replace all)\nop themes / op themes:set \'<json>\' # Themes (--replace to replace all)\nop theme:save <file.optheme> # Save current theme as preset file\nop theme:load <file.optheme> # Load a theme preset file\nop theme:list <directory> # List .optheme presets in directory\n\n# Codegen pipeline\nop codegen:plan \'<json>\' # Submit codegen plan (framework, rootIds, options)\nop codegen:submit \'<json>\' # Submit a code chunk for a node\nop codegen:assemble [--framework F] # Assemble all submitted chunks into final output\nop codegen:clean # Clear codegen state\n```\n\nGlobal flags: `--file <path>`, `--page <id>`, `--pretty`. Inputs: inline string, `@filepath`, or `-` (stdin).\n\n## Building Designs \u2014 Two Approaches\n\n### Approach 1: `op insert` (Recommended)\n\nThe 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.\n\n```bash\n# Create root frame, capture its ID\nROOT=$(op insert \'{"type":"frame","name":"Page","width":375,"height":812,"layout":"vertical"}\' \\\n | python3 -c "import sys,json; print(json.load(sys.stdin)[\'nodeId\'])")\n\n# Insert children using --parent\nop insert --parent "$ROOT" \'{"type":"text","content":"Hello","fontSize":28,"fontWeight":700}\'\n\n# Post-process: resolve icons, validate layout\nop design:refine --root-id "$ROOT"\n```\n\n### Approach 2: Batch Design DSL\n\nOne operation per line. Bind results with `name=` for later reference. Best for simple, flat structures.\n\n> **Limitation:** The DSL parser cannot handle deeply nested JSON (e.g., `children` arrays with nested objects, or multiple levels of array nesting). Keep each `I()` call to a **single level of nesting**. For complex nodes with children, use separate `I()` calls for parent and children, or use `op insert --parent`.\n\n```\nroot=I(null, { "type": "frame", "width": 1200, "layout": "vertical" })\nnav=I(root, { "type": "frame", "role": "navbar", "height": 72 })\nU(nav, { "fill": [{"type": "solid", "color": "#FFFFFF"}] })\ncard2=C(card1, grid, { "name": "Card 2" })\nM(sidebar, main, 0)\nD(old_section)\nR(old_btn, { "type": "rectangle", "role": "button" })\n```\n\n| Op | Syntax | Action |\n|----|--------|--------|\n| `I` | `name=I(parent, { node })` | Insert |\n| `U` | `U(ref, { updates })` | Update |\n| `C` | `name=C(source, parent, { overrides })` | Copy |\n| `R` | `name=R(ref, { node })` | Replace |\n| `M` | `M(ref, parent, index?)` | Move |\n| `D` | `D(ref)` | Delete |\n| `G` | `name=G(parent, "search", "query")` | Generate image via search |\n\n**DSL safe pattern** \u2014 always insert parent and children separately:\n\n```\nbtn=I(form, {"type":"rectangle","role":"button","width":"fill_container","height":50,"cornerRadius":12,"fill":[{"type":"solid","color":"#111111"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"})\nI(btn, {"type":"text","content":"Submit","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]})\n```\n\n## STRICT JSON Rules\n\nWhen emitting PenNode JSON (via `op insert`, `op design`, `batch_design`, `insert_node`), you MUST produce strictly valid JSON. Common mistakes that break parsing:\n\n- **Every property MUST have both a key and a value**. NEVER emit `": 50` or `: 50` without a key name. This often happens when you truncate/reformat \u2014 double-check.\n- **Every key MUST be a double-quoted non-empty string.**\n- **`fill` is ALWAYS an array**: `"fill": [{"type": "solid", "color": "#hex"}]`. Shorthand like `"fill": "#hex"` works but the array form is the canonical shape.\n- **`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).\n- **NO trailing commas** before `}` or `]`.\n- **NO comments** inside JSON (`//` or `/* */`).\n- Use **straight double quotes** `"`, not smart/curly quotes.\n- **`content` for text, NOT `text`**: `{"type": "text", "content": "Hello"}`.\n- **`iconFontName` for icons, NOT `iconName` or `icon`**: `{"type": "icon_font", "iconFontName": "lock"}`.\n- Before finalizing the JSON, mentally verify: every key has a value, every value has a key, all brackets balance.\n\n## PenNode Schema\n\n### Common Properties\n\n```json\n{\n "type": "frame|rectangle|text|ellipse|line|polygon|path|image|icon_font|group|ref",\n "name": "Display Name",\n "role": "semantic-role",\n "x": 0, "y": 0,\n "rotation": 0, "opacity": 1, "visible": true\n}\n```\n\n### Container Properties (frame, rectangle, group, ellipse)\n\n```json\n{\n "width": 400, // number | "fill_container" | "fit_content"\n "height": 300,\n "layout": "vertical", // "none" | "vertical" | "horizontal"\n "gap": 16,\n "padding": [16, 24], // number | [v, h] | [top, right, bottom, left]\n "justifyContent": "center", // "start" | "center" | "end" | "space_between" | "space_around"\n "alignItems": "center", // "start" | "center" | "end"\n "clipContent": true,\n "cornerRadius": 12, // number | [tl, tr, br, bl]\n "fill": [{ "type": "solid", "color": "#FFFFFF" }],\n "stroke": { "thickness": 1, "fill": [{ "type": "solid", "color": "#E5E7EB" }], "align": "inside", "dashPattern": [5, 3] },\n "effects": [{ "type": "shadow", "offsetX": 0, "offsetY": 4, "blur": 12, "spread": 0, "color": "rgba(0,0,0,0.08)" }],\n "children": []\n}\n```\n\n### Text\n\n```json\n{\n "type": "text",\n "content": "Hello", // string or StyledTextSegment[]\n "fontSize": 16, "fontFamily": "Inter", "fontWeight": 600,\n "textAlign": "center", // "left" | "center" | "right"\n "textGrowth": "fixed-width", // "auto" | "fixed-width" | "fixed-width-height"\n "lineHeight": 1.5, "letterSpacing": 0,\n "fill": [{ "type": "solid", "color": "#111111" }]\n}\n```\n\nRich text: `"content": [{ "text": "Bold ", "fontWeight": "bold" }, { "text": "normal" }]`\n\n### Icons \u2014 Two Options\n\n#### Option A: `icon_font` (RECOMMENDED \u2014 renders directly, no post-processing needed)\n\n```json\n{ "type": "icon_font", "name": "Lock Icon", "iconFontName": "lock",\n "width": 20, "height": 20,\n "fill": [{ "type": "solid", "color": "#6B7280" }] }\n```\n\n**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`.\n\nWorks in ALL contexts: CLI, MCP tools, or direct `.op` files \u2014 no `design:refine` required.\n\n#### Option B: `path` (requires post-processing)\n\n```json\n{ "type": "path", "name": "HeartIcon", "width": 24, "height": 24,\n "fill": [{ "type": "solid", "color": "#111111" }] }\n```\n\nPascalCase + "Icon" suffix. Auto-resolved from Lucide set during post-processing.\n\n> **Path icons need post-processing.** After inserting path nodes, run `op design:refine --root-id <id>` or use `op 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 \u2014 **prefer `icon_font` in MCP contexts.**\n\n### Image\n\n```json\n{ "type": "image", "src": "https://example.com/photo.jpg", "width": 400, "height": 300,\n "objectFit": "crop", "cornerRadius": 12 }\n```\n\nAI image placeholders (resolved by `design:refine`):\n\n```json\n{ "type": "image", "width": 400, "height": 300,\n "imagePrompt": "A modern office workspace with natural light",\n "imageSearchQuery": "modern office workspace" }\n```\n\nImage adjustments (all -100 to 100): `exposure`, `contrast`, `saturation`, `temperature`, `tint`, `highlights`, `shadows`.\n\n### Polygon\n\n```json\n{ "type": "polygon", "polygonCount": 6, "width": 80, "height": 80, "cornerRadius": 4,\n "fill": [{ "type": "solid", "color": "#6366F1" }] }\n```\n\n### Icon Font\n\n```json\n{ "type": "icon_font", "iconFontName": "lucide:home", "width": 24, "height": 24,\n "fill": [{ "type": "solid", "color": "#111111" }] }\n```\n\n### Line\n\n```json\n{ "type": "line", "x2": 200, "y2": 0,\n "stroke": { "thickness": 1, "fill": [{ "type": "solid", "color": "#E5E7EB" }] } }\n```\n\n### Fill Types\n\n```json\n{ "type": "solid", "color": "#3B82F6" }\n{ "type": "linear_gradient", "angle": 135,\n "stops": [{ "offset": 0, "color": "#6366F1" }, { "offset": 1, "color": "#8B5CF6" }] }\n{ "type": "radial_gradient", "cx": 0.5, "cy": 0.5, "radius": 0.5,\n "stops": [{ "offset": 0, "color": "#FFF" }, { "offset": 1, "color": "#000" }] }\n{ "type": "image", "url": "https://example.com/texture.jpg", "mode": "fill" }\n```\n\nImage fill modes: `fill`, `fit`, `crop`, `tile`, `stretch`. Image fill also supports adjustment filters (`exposure`, `contrast`, `saturation`, etc.).\n\n### Ref Node (Component Instance)\n\n```json\n{ "type": "ref", "ref": "reusable-frame-id",\n "descendants": { "child-id": { "content": "Override text" } } }\n```\n\nReferences a `frame` with `reusable: true`. Override specific descendant properties via `descendants`.\n\n### Design Variables\n\nReference with `$` prefix: `"color": "$primaryColor"`, `"gap": "$spacing"`.\n\n## Semantic Roles\n\nRoles declare intent \u2014 the engine applies smart defaults. Always prefer roles over manual styling.\n\n| Category | Roles |\n|----------|-------|\n| **Layout** | `section`, `row`, `column`, `centered-content`, `divider`, `spacer` |\n| **Navigation** | `navbar`, `nav-links`, `nav-link` |\n| **Interactive** | `button`, `icon-button`, `badge`, `tag`, `pill`, `input`, `form-input`, `search-bar` |\n| **Cards** | `card`, `feature-card`, `stat-card`, `pricing-card`, `image-card` |\n| **Content** | `hero`, `feature-grid`, `cta-section`, `footer`, `testimonial`, `stats-section` |\n| **Typography** | `heading`, `subheading`, `body-text`, `caption`, `label` |\n| **Media** | `avatar`, `icon`, `phone-mockup`, `screenshot-frame` |\n| **Table** | `table`, `table-row`, `table-header`, `table-cell` |\n| **Form** | `form-group` |\n\nKey defaults:\n- `navbar` \u2192 height: 56-72, horizontal, space_between, center-aligned\n- `button` \u2192 padding: [12, 24], cornerRadius: 8, centered\n- `card` \u2192 vertical, gap: 12, cornerRadius: 12, padding: 24\n- `heading` \u2192 lineHeight: 1.2, letterSpacing: -0.5\n- `body-text` \u2192 fill_container, textGrowth: fixed-width, lineHeight: 1.5\n\n## Layout Rules\n\n1. **NEVER set x/y on children inside layout containers** \u2014 engine positions them\n2. **Siblings must use same width strategy** \u2014 all `fill_container` or all fixed\n3. **NEVER `fill_container` inside `fit_content` parent** \u2014 circular dependency\n4. Cards in horizontal row: ALL `width: "fill_container"`, `height: "fill_container"`\n\n### Sizing Decision\n\n| Question | Answer |\n|----------|--------|\n| Stretch to fill? | `"fill_container"` |\n| Shrink to content? | `"fit_content"` |\n| Exact size? | number (px) |\n\n### Design Type Sizing\n\n| Type | Width | Height |\n|------|-------|--------|\n| Landing page | 1200 | 0 (auto) |\n| Mobile screen | 375 | 812 |\n| Dashboard | 1200 | 0 (auto) |\n\n## Design Principles\n\n### Typography\n\n```\nDisplay: 40-56px 700 letterSpacing: -1.5 lineHeight: 1.1 "Space Grotesk"\nHeading: 28-36px 700 letterSpacing: -0.5 lineHeight: 1.2 "Space Grotesk"\nSubheading: 20-24px 600 letterSpacing: -0.25 lineHeight: 1.3 "Space Grotesk"\nBody: 15-18px 400 letterSpacing: 0 lineHeight: 1.5 "Inter"\nCaption: 13-14px 400 letterSpacing: 0 lineHeight: 1.4 "Inter"\n```\n\nCJK: use `"Noto Sans SC/JP/KR"`, lineHeight >= 1.3, letterSpacing: 0 always.\n\n### Color\n\n```\nPrimary text: #111111 Secondary: #6B7280 Subtle: #9CA3AF\nBackground: #FFFFFF Surface: #F9FAFB Border: #E5E7EB\n```\n\nMax 2 saturated colors. WCAG AA: 4.5:1 body, 3:1 large. Dark bg: `#0F172A`, not `#000000`.\n\n### Spacing (8px grid)\n\n```\nRelated: 8-16px Components: 16-24px\nGroups: 24-32px Sections: 48-80px Page padding: 80px\n```\n\n### Shadows\n\n```json\n// Subtle (cards)\n{ "type": "shadow", "offsetY": 1, "blur": 3, "color": "rgba(0,0,0,0.05)" }\n// Medium (dropdowns)\n{ "type": "shadow", "offsetY": 4, "blur": 12, "color": "rgba(0,0,0,0.08)" }\n// Elevated (modals)\n{ "type": "shadow", "offsetY": 8, "blur": 24, "spread": -4, "color": "rgba(0,0,0,0.12)" }\n```\n\n### Copy Rules\n\nHeadlines: 2-6 words. Subtitles: max 15 words. Buttons: 1-3 words. No lorem ipsum. No emoji as icons.\n\n## Layered Workflow\n\nFor complex multi-section pages, use the three-step skeleton \u2192 content \u2192 refine flow:\n\n| Step | MCP Tool | CLI Equivalent |\n|------|----------|----------------|\n| 1. Create section structure | `design_skeleton` | `op design:skeleton \'<json>\'` |\n| 2. Populate each section | `design_content` (with `postProcess: true`) | `op design:content <section-id> \'<json>\'` |\n| 3. Validate + auto-fix | `design_refine` | `op design:refine --root-id <id>` |\n\n`design:refine` resolves icon names \u2192 SVG paths, fixes layout issues, and validates the tree. **Always run as the final step.**\n\n## Codegen Pipeline\n\nFor incremental, framework-aware code generation from the design tree:\n\n| Step | CLI Command | MCP Tool | Description |\n|------|------------|----------|-------------|\n| 1. Plan | `op codegen:plan \'<json>\'` | `codegen_plan` | Declare framework, root node IDs, and options |\n| 2. Submit | `op codegen:submit \'<json>\'` | `codegen_submit_chunk` | Submit generated code for individual nodes |\n| 3. Assemble | `op codegen:assemble --framework react` | `codegen_assemble` | Combine all chunks into the final output |\n| 4. Clean | `op codegen:clean` | `codegen_clean` | Clear server-side codegen state |\n\nThe plan JSON shape:\n```json\n{ "framework": "react", "rootIds": ["frame-1"], "options": { "tailwind": true } }\n```\n\nThe submit JSON shape:\n```json\n{ "nodeId": "card-1", "code": "<Card className=\\"...\\">...</Card>", "imports": ["Card"] }\n```\n\nSupported frameworks: `react`, `html`, `vue`, `svelte`, `flutter`, `swiftui`, `compose`, `rn` (React Native), `css`.\n\n## Multi-Page Documents\n\n```bash\nop page list # List all pages with IDs\nop page add --name "Settings" # Add a new page\nop page remove <page-id> # Remove a page\nop page rename <page-id> \'New Name\' # Rename a page\nop page reorder <page-id> 2 # Move page to index 2\nop page duplicate <page-id> # Clone page with new IDs\n```\n\nUse `--page <id>` on any command to target a specific page. Without it, commands operate on the first page.\n\n## Common Patterns\n\nPatterns below show `op insert --parent` commands. Each pattern is copy-paste ready.\n\n### Navbar\n\n```bash\nNAV=$(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)\nop insert --parent "$NAV" \'{"type":"text","content":"Brand","fontSize":20,"fontWeight":700,"fontFamily":"Space Grotesk"}\'\nLINKS=$(op insert --parent "$NAV" \'{"type":"frame","role":"nav-links","layout":"horizontal","gap":32,"width":"fit_content","height":"fit_content"}\' | ID)\nop insert --parent "$LINKS" \'{"type":"text","role":"nav-link","content":"Features","fontSize":15}\'\nop insert --parent "$LINKS" \'{"type":"text","role":"nav-link","content":"Pricing","fontSize":15}\'\nCTA=$(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)\nop insert --parent "$CTA" \'{"type":"text","content":"Get Started","fontSize":14,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]}\'\n```\n\n### Hero\n\n```bash\nHERO=$(op insert --parent "$ROOT" \'{"type":"frame","role":"hero","width":"fill_container","height":"fit_content","layout":"vertical","padding":[100,80],"gap":24,"alignItems":"center"}\' | ID)\nop 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}\'\nop 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"}]}\'\nBTNS=$(op insert --parent "$HERO" \'{"type":"frame","layout":"horizontal","gap":12,"width":"fit_content","height":"fit_content"}\' | ID)\nB1=$(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)\nop insert --parent "$B1" \'{"type":"text","content":"Start Free","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]}\'\nB2=$(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)\nop insert --parent "$B2" \'{"type":"text","content":"View Demo","fontSize":16,"fontWeight":600}\'\n```\n\n### Feature Card (in horizontal grid, ALL cards must use fill_container)\n\n```bash\nCARD=$(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)\nop insert --parent "$CARD" \'{"type":"path","name":"ZapIcon","width":24,"height":24,"fill":[{"type":"solid","color":"#111111"}]}\'\nop insert --parent "$CARD" \'{"type":"text","content":"Lightning Fast","fontSize":20,"fontWeight":600}\'\nop 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"}]}\'\n```\n\n### Form Input\n\n```bash\nGRP=$(op insert --parent "$FORM" \'{"type":"frame","role":"form-group","layout":"vertical","gap":8,"width":"fill_container"}\' | ID)\nop insert --parent "$GRP" \'{"type":"text","role":"label","content":"Email","fontSize":14,"fontWeight":500}\'\nINP=$(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)\nop insert --parent "$INP" \'{"type":"path","name":"MailIcon","width":18,"height":18,"fill":[{"type":"solid","color":"#9CA3AF"}]}\'\nop insert --parent "$INP" \'{"type":"text","content":"you@example.com","fontSize":15,"fill":[{"type":"solid","color":"#9CA3AF"}]}\'\n```\n\n### Footer\n\n```bash\nFOOTER=$(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)\nCOL1=$(op insert --parent "$FOOTER" \'{"type":"frame","layout":"vertical","gap":16,"width":240}\' | ID)\nop insert --parent "$COL1" \'{"type":"text","content":"Brand","fontSize":20,"fontWeight":700,"fontFamily":"Space Grotesk"}\'\nop insert --parent "$COL1" \'{"type":"text","content":"Building the future of design.","fontSize":14,"lineHeight":1.6,"fill":[{"type":"solid","color":"#6B7280"}]}\'\nCOL2=$(op insert --parent "$FOOTER" \'{"type":"frame","layout":"vertical","gap":12,"width":"fit_content"}\' | ID)\nop insert --parent "$COL2" \'{"type":"text","content":"Product","fontSize":14,"fontWeight":600}\'\nop insert --parent "$COL2" \'{"type":"text","content":"Features","fontSize":14,"fill":[{"type":"solid","color":"#6B7280"}]}\'\nop insert --parent "$COL2" \'{"type":"text","content":"Pricing","fontSize":14,"fill":[{"type":"solid","color":"#6B7280"}]}\'\n```\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| Setting x/y inside layout container | Remove x/y \u2014 engine auto-positions |\n| Cards with different width strategies | All siblings: same sizing (`fill_container`) |\n| `fill_container` child in `fit_content` parent | Use fixed width or switch parent to `fill_container` |\n| Pure black text `#000000` | Use `#111111` or `#0F172A` |\n| Heavy drop shadows | Use subtle `rgba(0,0,0,0.05-0.12)` |\n| Emoji as icons | Use path nodes with icon names |\n| Lorem ipsum placeholder | Write realistic, concise copy |\n| Fixed height on text | Use `textGrowth: "fixed-width"` instead |\n| Space Grotesk for CJK | Use `"Noto Sans SC/JP/KR"` |\n| Negative letterSpacing on CJK | Always 0 for CJK text |\n| Missing post-process after insert | Run `op design:refine --root-id <id>` after building the tree |\n| Icons inserted but not visible | Path nodes need `design:refine` or `--post-process` to resolve SVG |\n| Using DSL `I()` with inline `children` | DSL parser fails on nested JSON \u2014 insert parent and children separately |\n| Missing `postProcess: true` in MCP | Always set for MCP tool calls |\n\n## Full Example \u2014 `op insert` Workflow (Recommended)\n\nBuild a complete mobile login page using `op insert --parent`. This is the most reliable approach.\n\n```bash\n#!/bin/bash\nset -e\nID() { python3 -c "import sys,json; print(json.load(sys.stdin)[\'nodeId\'])"; }\n\n# Root frame (mobile)\nROOT=$(op insert \'{"type":"frame","name":"Login","width":375,"height":812,"layout":"vertical","fill":[{"type":"solid","color":"#FFFFFF"}]}\' | ID)\n\n# Header\nTOP=$(op insert --parent "$ROOT" \'{"type":"frame","width":"fill_container","height":"fit_content","layout":"vertical","padding":[80,32,40,32],"gap":14,"alignItems":"center"}\' | ID)\nop insert --parent "$TOP" \'{"type":"path","name":"ShieldIcon","width":48,"height":48,"fill":[{"type":"solid","color":"#6366F1"}]}\'\nop insert --parent "$TOP" \'{"type":"text","content":"Welcome Back","fontSize":28,"fontWeight":700,"fontFamily":"Space Grotesk","letterSpacing":-0.5,"textAlign":"center"}\'\n\n# Form\nFORM=$(op insert --parent "$ROOT" \'{"type":"frame","width":"fill_container","height":"fit_content","layout":"vertical","padding":[0,32],"gap":20}\' | ID)\n\n# Email input\nGRP=$(op insert --parent "$FORM" \'{"type":"frame","role":"form-group","layout":"vertical","gap":8,"width":"fill_container"}\' | ID)\nop insert --parent "$GRP" \'{"type":"text","role":"label","content":"Email","fontSize":14,"fontWeight":500}\'\nINP=$(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)\nop insert --parent "$INP" \'{"type":"path","name":"MailIcon","width":18,"height":18,"fill":[{"type":"solid","color":"#9CA3AF"}]}\'\nop insert --parent "$INP" \'{"type":"text","content":"you@example.com","fontSize":15,"fill":[{"type":"solid","color":"#9CA3AF"}]}\'\n\n# Login button\nBTN=$(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)\nop insert --parent "$BTN" \'{"type":"text","content":"Sign In","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]}\'\n\n# IMPORTANT: resolve icons + validate layout\nop design:refine --root-id "$ROOT"\n```\n\n## DSL Example \u2014 Landing Page\n\nDSL is suitable for simpler structures. **Avoid inline `children`** \u2014 insert parent and children as separate operations.\n\n```\nroot=I(null, {"type":"frame","name":"Landing","width":1200,"height":0,"layout":"vertical","fill":[{"type":"solid","color":"#FFFFFF"}]})\n\nnav=I(root, {"type":"frame","role":"navbar","width":"fill_container","height":72,"layout":"horizontal","padding":[0,80],"justifyContent":"space_between","alignItems":"center"})\nI(nav, {"type":"text","content":"Acme","fontSize":20,"fontWeight":700,"fontFamily":"Space Grotesk"})\nlinks=I(nav, {"type":"frame","role":"nav-links","layout":"horizontal","gap":32,"width":"fit_content","height":"fit_content"})\nI(links, {"type":"text","role":"nav-link","content":"Features","fontSize":15})\nI(links, {"type":"text","role":"nav-link","content":"Pricing","fontSize":15})\ncta=I(nav, {"type":"rectangle","role":"button","padding":[10,24],"cornerRadius":8,"fill":[{"type":"solid","color":"#111111"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"})\nI(cta, {"type":"text","content":"Get Started","fontSize":14,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]})\n\nhero=I(root, {"type":"frame","role":"hero","width":"fill_container","height":"fit_content","layout":"vertical","padding":[100,80],"gap":24,"alignItems":"center"})\nI(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})\nI(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"}]})\nbtns=I(hero, {"type":"frame","layout":"horizontal","gap":12,"width":"fit_content","height":"fit_content"})\nb1=I(btns, {"type":"rectangle","role":"button","padding":[14,32],"cornerRadius":10,"fill":[{"type":"solid","color":"#111111"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"})\nI(b1, {"type":"text","content":"Start Free","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]})\nb2=I(btns, {"type":"rectangle","role":"button","padding":[14,32],"cornerRadius":10,"fill":[{"type":"solid","color":"#F3F4F6"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"})\nI(b2, {"type":"text","content":"View Demo","fontSize":16,"fontWeight":600})\n\nfeat=I(root, {"type":"frame","role":"section","width":"fill_container","height":"fit_content","layout":"vertical","padding":[80,80],"gap":48,"alignItems":"center"})\nI(feat, {"type":"text","role":"heading","content":"Everything you need","fontSize":36,"fontWeight":700,"fontFamily":"Space Grotesk","textAlign":"center","letterSpacing":-0.5})\ngrid=I(feat, {"type":"frame","role":"feature-grid","width":"fill_container","layout":"horizontal","gap":24})\nc1=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"}]})\nI(c1, {"type":"path","name":"ZapIcon","width":24,"height":24,"fill":[{"type":"solid","color":"#111111"}]})\nI(c1, {"type":"text","content":"Lightning Fast","fontSize":20,"fontWeight":600})\nI(c1, {"type":"text","role":"body-text","content":"Sub-second builds with smart caching.","fontSize":15,"lineHeight":1.6,"fill":[{"type":"solid","color":"#6B7280"}]})\nc2=C(c1, grid, {})\nU(c2+"/0", {"name":"ShieldIcon"})\nU(c2+"/1", {"content":"Enterprise Security"})\nU(c2+"/2", {"content":"SOC 2 certified with end-to-end encryption."})\nc3=C(c1, grid, {})\nU(c3+"/0", {"name":"GitBranchIcon"})\nU(c3+"/1", {"content":"Git-Native Workflow"})\nU(c3+"/2", {"content":"Preview deploys on every push with instant rollback."})\n```\n',
13416
+ ".claude-plugin/plugin.json": '{\n "name": "openpencil-skill",\n "description": "Design skill for OpenPencil \u2014 op CLI, batch DSL, MCP tools, PenNode schema, and UI design best practices",\n "version": "0.7.0",\n "author": {\n "name": "ZSeven-W",\n "email": "xkayshen@gmail.com"\n },\n "homepage": "https://github.com/zseven-w/openpencil-skill",\n "repository": "https://github.com/zseven-w/openpencil-skill",\n "license": "MIT",\n "keywords": [\n "design",\n "ui",\n "vector",\n "openpencil",\n "cli",\n "mcp",\n "dsl"\n ]\n}\n',
13417
+ ".claude-plugin/marketplace.json": '{\n "name": "openpencil-skill",\n "description": "Design skill for OpenPencil \u2014 op CLI, batch DSL, MCP tools, and UI best practices",\n "owner": {\n "name": "ZSeven-W",\n "email": "xkayshen@gmail.com"\n },\n "plugins": [\n {\n "name": "openpencil-skill",\n "description": "Design skill for OpenPencil \u2014 op CLI, batch DSL, MCP tools, PenNode schema, and UI design best practices",\n "version": "0.7.0",\n "source": "./",\n "author": {\n "name": "ZSeven-W",\n "email": "xkayshen@gmail.com"\n }\n }\n ]\n}\n',
13418
+ ".cursor-plugin/plugin.json": '{\n "name": "openpencil-skill",\n "displayName": "OpenPencil Design",\n "description": "Design skill for OpenPencil \u2014 op CLI, batch DSL, MCP tools, PenNode schema, and UI design best practices",\n "version": "0.7.0",\n "author": {\n "name": "ZSeven-W",\n "email": "xkayshen@gmail.com"\n },\n "homepage": "https://github.com/zseven-w/openpencil-skill",\n "repository": "https://github.com/zseven-w/openpencil-skill",\n "license": "MIT",\n "keywords": [\n "design",\n "ui",\n "vector",\n "openpencil",\n "cli",\n "mcp",\n "dsl"\n ],\n "skills": "./skills/"\n}\n',
13419
+ "package.json": '{\n "name": "openpencil-skill",\n "version": "0.7.0",\n "type": "module"\n}\n',
13420
+ "GEMINI.md": "@./skills/openpencil-design/SKILL.md\n",
13421
+ "gemini-extension.json": '{\n "name": "openpencil-skill",\n "description": "Design skill for OpenPencil \u2014 op CLI, batch DSL, MCP tools, and UI best practices",\n "version": "0.7.0",\n "contextFileName": "GEMINI.md"\n}\n'
13422
+ }
13423
+ };
13424
+ }
13425
+ });
13426
+
13427
+ // apps/cli/src/commands/install.ts
13428
+ var install_exports = {};
13429
+ __export(install_exports, {
13430
+ cmdInstall: () => cmdInstall,
13431
+ cmdUninstall: () => cmdUninstall
13432
+ });
13433
+ function which(cmd) {
13434
+ try {
13435
+ return (0, import_node_child_process2.execSync)(`which ${cmd} 2>/dev/null`, { encoding: "utf-8" }).trim() || null;
13436
+ } catch {
13437
+ return null;
13438
+ }
13439
+ }
13440
+ function detectTargets() {
13441
+ const found = [];
13442
+ if (which("claude")) found.push("claude");
13443
+ if (which("codex")) found.push("codex");
13444
+ if ((0, import_node_fs3.existsSync)((0, import_node_path7.join)((0, import_node_os4.homedir)(), ".cursor"))) found.push("cursor");
13445
+ if (which("gemini")) found.push("gemini");
13446
+ if (which("opencode")) found.push("opencode");
13447
+ return found;
13448
+ }
13449
+ function log(msg) {
13450
+ process.stderr.write(msg + "\n");
13451
+ }
13452
+ function logOk(target, msg) {
13453
+ log(` \u2713 ${target}: ${msg}`);
13454
+ }
13455
+ function logSkip(target, msg) {
13456
+ log(` - ${target}: ${msg}`);
13457
+ }
13458
+ function logErr(target, msg) {
13459
+ log(` \u2717 ${target}: ${msg}`);
13460
+ }
13461
+ function writeBundleTo(dest, fileFilter) {
13462
+ const files = skill_bundle_default.files;
13463
+ for (const [relativePath, content] of Object.entries(files)) {
13464
+ if (fileFilter && !fileFilter(relativePath)) continue;
13465
+ const fullPath = (0, import_node_path7.join)(dest, relativePath);
13466
+ (0, import_node_fs3.mkdirSync)((0, import_node_path7.dirname)(fullPath), { recursive: true });
13467
+ (0, import_node_fs3.writeFileSync)(fullPath, content);
13468
+ }
13469
+ }
13470
+ function ensureRepo(dest) {
13471
+ if ((0, import_node_fs3.existsSync)((0, import_node_path7.join)(dest, ".git"))) {
13472
+ (0, import_node_child_process2.execSync)("git pull --ff-only 2>/dev/null", { cwd: dest, stdio: "ignore" });
13473
+ } else {
13474
+ (0, import_node_fs3.mkdirSync)((0, import_node_path7.dirname)(dest), { recursive: true });
13475
+ (0, import_node_child_process2.execSync)(`git clone --depth 1 ${REPO_URL} "${dest}"`, { stdio: "ignore" });
13476
+ }
13477
+ }
13478
+ function ensureSkillDir(dest, fileFilter) {
13479
+ if (hasBundledFiles) {
13480
+ (0, import_node_fs3.mkdirSync)(dest, { recursive: true });
13481
+ writeBundleTo(dest, fileFilter);
13482
+ } else {
13483
+ ensureRepo(dest);
13484
+ }
13485
+ }
13486
+ function installClaude() {
13487
+ const target = "Claude Code";
13488
+ try {
13489
+ if (hasBundledFiles) {
13490
+ const cacheDir = (0, import_node_path7.join)(
13491
+ (0, import_node_os4.homedir)(),
13492
+ ".claude",
13493
+ "plugins",
13494
+ "cache",
13495
+ SKILL_NAME,
13496
+ SKILL_NAME,
13497
+ skill_bundle_default.version
13498
+ );
13499
+ (0, import_node_fs3.mkdirSync)(cacheDir, { recursive: true });
13500
+ writeBundleTo(cacheDir);
13501
+ const registryPath = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".claude", "plugins", "installed_plugins.json");
13502
+ const registry2 = (0, import_node_fs3.existsSync)(registryPath) ? JSON.parse((0, import_node_fs3.readFileSync)(registryPath, "utf-8")) : { version: 2, plugins: {} };
13503
+ const key = `${SKILL_NAME}@${SKILL_NAME}`;
13504
+ const now = (/* @__PURE__ */ new Date()).toISOString();
13505
+ registry2.plugins[key] = [
13506
+ {
13507
+ scope: "user",
13508
+ installPath: cacheDir,
13509
+ version: skill_bundle_default.version,
13510
+ installedAt: registry2.plugins[key]?.[0]?.installedAt ?? now,
13511
+ lastUpdated: now
13512
+ }
13513
+ ];
13514
+ (0, import_node_fs3.writeFileSync)(registryPath, JSON.stringify(registry2, null, 2) + "\n");
13515
+ const marketplacesPath = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".claude", "plugins", "known_marketplaces.json");
13516
+ const marketplaces = (0, import_node_fs3.existsSync)(marketplacesPath) ? JSON.parse((0, import_node_fs3.readFileSync)(marketplacesPath, "utf-8")) : {};
13517
+ if (!marketplaces[SKILL_NAME]) {
13518
+ marketplaces[SKILL_NAME] = {
13519
+ source: { source: "github", repo: REPO },
13520
+ installLocation: (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".claude", "plugins", "marketplaces", SKILL_NAME),
13521
+ lastUpdated: now
13522
+ };
13523
+ (0, import_node_fs3.writeFileSync)(marketplacesPath, JSON.stringify(marketplaces, null, 2) + "\n");
13524
+ }
13525
+ logOk(target, `installed v${skill_bundle_default.version} (bundled)`);
13526
+ } else {
13527
+ try {
13528
+ (0, import_node_child_process2.execSync)(`claude plugin marketplace add ${REPO}`, { stdio: "ignore" });
13529
+ } catch {
13530
+ }
13531
+ (0, import_node_child_process2.execSync)(`claude plugin install ${SKILL_NAME}@${SKILL_NAME}`, { stdio: "ignore" });
13532
+ logOk(target, "installed");
13533
+ }
13534
+ } catch (e) {
13535
+ logErr(target, e instanceof Error ? e.message : String(e));
13536
+ }
13537
+ }
13538
+ function installCodex() {
13539
+ const target = "Codex";
13540
+ try {
13541
+ const cloneDir = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".codex", SKILL_NAME);
13542
+ ensureSkillDir(cloneDir);
13543
+ const skillsDir = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".agents", "skills");
13544
+ (0, import_node_fs3.mkdirSync)(skillsDir, { recursive: true });
13545
+ const linkPath = (0, import_node_path7.join)(skillsDir, SKILL_NAME);
13546
+ const linkTarget = (0, import_node_path7.join)(cloneDir, "skills");
13547
+ if (!(0, import_node_fs3.existsSync)(linkPath)) {
13548
+ (0, import_node_fs3.symlinkSync)(linkTarget, linkPath);
13549
+ }
13550
+ logOk(target, hasBundledFiles ? `installed v${skill_bundle_default.version} (bundled)` : "installed");
13551
+ } catch (e) {
13552
+ logErr(target, e instanceof Error ? e.message : String(e));
13553
+ }
13554
+ }
13555
+ function installCursor() {
13556
+ const target = "Cursor";
13557
+ try {
13558
+ const destDir = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".cursor", "plugins", SKILL_NAME);
13559
+ ensureSkillDir(destDir);
13560
+ logOk(target, hasBundledFiles ? `installed v${skill_bundle_default.version} (bundled)` : "installed");
13561
+ } catch (e) {
13562
+ logErr(target, e instanceof Error ? e.message : String(e));
13563
+ }
13564
+ }
13565
+ function installGemini() {
13566
+ const target = "Gemini CLI";
13567
+ try {
13568
+ const destDir = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".gemini", "extensions", SKILL_NAME);
13569
+ ensureSkillDir(destDir);
13570
+ logOk(target, hasBundledFiles ? `installed v${skill_bundle_default.version} (bundled)` : "installed");
13571
+ } catch (e) {
13572
+ logErr(target, e instanceof Error ? e.message : String(e));
13573
+ }
13574
+ }
13575
+ function installOpenCode() {
13576
+ const target = "OpenCode";
13577
+ try {
13578
+ const configPath = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".config", "opencode", "opencode.json");
13579
+ const pluginEntry = `${SKILL_NAME}@git+${REPO_URL}`;
13580
+ if ((0, import_node_fs3.existsSync)(configPath)) {
13581
+ const config = JSON.parse((0, import_node_fs3.readFileSync)(configPath, "utf-8"));
13582
+ const plugins = config.plugin ?? [];
13583
+ if (!plugins.some((p) => p.includes(SKILL_NAME))) {
13584
+ plugins.push(pluginEntry);
13585
+ config.plugin = plugins;
13586
+ (0, import_node_fs3.writeFileSync)(configPath, JSON.stringify(config, null, 2) + "\n");
13587
+ logOk(target, "added to opencode.json");
13588
+ } else {
13589
+ logSkip(target, "already configured");
13590
+ }
13591
+ } else {
13592
+ (0, import_node_fs3.mkdirSync)((0, import_node_path7.join)((0, import_node_os4.homedir)(), ".config", "opencode"), { recursive: true });
13593
+ (0, import_node_fs3.writeFileSync)(configPath, JSON.stringify({ plugin: [pluginEntry] }, null, 2) + "\n");
13594
+ logOk(target, "created opencode.json");
13595
+ }
13596
+ } catch (e) {
13597
+ logErr(target, e instanceof Error ? e.message : String(e));
13598
+ }
13599
+ }
13600
+ function uninstallClaude() {
13601
+ const target = "Claude Code";
13602
+ try {
13603
+ if (hasBundledFiles) {
13604
+ const cacheParent = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".claude", "plugins", "cache", SKILL_NAME);
13605
+ if ((0, import_node_fs3.existsSync)(cacheParent)) (0, import_node_fs3.rmSync)(cacheParent, { recursive: true });
13606
+ const registryPath = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".claude", "plugins", "installed_plugins.json");
13607
+ if ((0, import_node_fs3.existsSync)(registryPath)) {
13608
+ const registry2 = JSON.parse((0, import_node_fs3.readFileSync)(registryPath, "utf-8"));
13609
+ delete registry2.plugins[`${SKILL_NAME}@${SKILL_NAME}`];
13610
+ (0, import_node_fs3.writeFileSync)(registryPath, JSON.stringify(registry2, null, 2) + "\n");
13611
+ }
13612
+ logOk(target, "uninstalled");
13613
+ } else {
13614
+ (0, import_node_child_process2.execSync)(`claude plugin uninstall ${SKILL_NAME}@${SKILL_NAME}`, { stdio: "ignore" });
13615
+ logOk(target, "uninstalled");
13616
+ }
13617
+ } catch (e) {
13618
+ logErr(target, e instanceof Error ? e.message : String(e));
13619
+ }
13620
+ }
13621
+ function uninstallCodex() {
13622
+ const target = "Codex";
13623
+ try {
13624
+ const linkPath = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".agents", "skills", SKILL_NAME);
13625
+ if ((0, import_node_fs3.existsSync)(linkPath)) (0, import_node_fs3.unlinkSync)(linkPath);
13626
+ const cloneDir = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".codex", SKILL_NAME);
13627
+ if ((0, import_node_fs3.existsSync)(cloneDir)) (0, import_node_fs3.rmSync)(cloneDir, { recursive: true });
13628
+ logOk(target, "uninstalled");
13629
+ } catch (e) {
13630
+ logErr(target, e instanceof Error ? e.message : String(e));
13631
+ }
13632
+ }
13633
+ function uninstallCursor() {
13634
+ const target = "Cursor";
13635
+ try {
13636
+ const dir = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".cursor", "plugins", SKILL_NAME);
13637
+ if ((0, import_node_fs3.existsSync)(dir)) (0, import_node_fs3.rmSync)(dir, { recursive: true });
13638
+ logOk(target, "uninstalled");
13639
+ } catch (e) {
13640
+ logErr(target, e instanceof Error ? e.message : String(e));
13641
+ }
13642
+ }
13643
+ function uninstallGemini() {
13644
+ const target = "Gemini CLI";
13645
+ try {
13646
+ const dir = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".gemini", "extensions", SKILL_NAME);
13647
+ if ((0, import_node_fs3.existsSync)(dir)) (0, import_node_fs3.rmSync)(dir, { recursive: true });
13648
+ logOk(target, "uninstalled");
13649
+ } catch (e) {
13650
+ logErr(target, e instanceof Error ? e.message : String(e));
13651
+ }
13652
+ }
13653
+ function uninstallOpenCode() {
13654
+ const target = "OpenCode";
13655
+ try {
13656
+ const configPath = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".config", "opencode", "opencode.json");
13657
+ if ((0, import_node_fs3.existsSync)(configPath)) {
13658
+ const config = JSON.parse((0, import_node_fs3.readFileSync)(configPath, "utf-8"));
13659
+ const plugins = config.plugin ?? [];
13660
+ config.plugin = plugins.filter((p) => !p.includes(SKILL_NAME));
13661
+ (0, import_node_fs3.writeFileSync)(configPath, JSON.stringify(config, null, 2) + "\n");
13662
+ logOk(target, "removed from opencode.json");
13663
+ } else {
13664
+ logSkip(target, "not configured");
13665
+ }
13666
+ } catch (e) {
13667
+ logErr(target, e instanceof Error ? e.message : String(e));
13668
+ }
13669
+ }
13670
+ function resolveTargets(targetFlag, mode) {
13671
+ if (targetFlag) {
13672
+ const t = targetFlag.toLowerCase();
13673
+ if (!ALL_TARGETS.includes(t)) {
13674
+ log(`Unknown target: "${targetFlag}". Available: ${ALL_TARGETS.join(", ")}`);
13675
+ process.exit(1);
13676
+ }
13677
+ return [t];
13678
+ }
13679
+ const detected = detectTargets();
13680
+ if (detected.length === 0 && mode === "install") {
13681
+ log("No supported AI coding agents detected.");
13682
+ log(`Supported: ${ALL_TARGETS.join(", ")}`);
13683
+ log("Use --target <name> to install for a specific agent.");
13684
+ process.exit(1);
13685
+ }
13686
+ return detected;
13687
+ }
13688
+ function cmdInstall(flags) {
13689
+ const targets = resolveTargets(flags.target, "install");
13690
+ log(`Installing ${SKILL_NAME} for: ${targets.join(", ")}`);
13691
+ if (!hasBundledFiles) log("(no embedded bundle \u2014 using git clone fallback)");
13692
+ log("");
13693
+ for (const t of targets) INSTALLERS[t]();
13694
+ log("");
13695
+ log("Done. Restart your agent to load the skill.");
13696
+ }
13697
+ function cmdUninstall(flags) {
13698
+ const targets = resolveTargets(flags.target, "uninstall");
13699
+ log(`Uninstalling ${SKILL_NAME} from: ${targets.join(", ")}`);
13700
+ log("");
13701
+ for (const t of targets) UNINSTALLERS[t]();
13702
+ log("");
13703
+ log("Done.");
13704
+ }
13705
+ var import_node_child_process2, import_node_fs3, import_node_path7, import_node_os4, REPO, REPO_URL, SKILL_NAME, ALL_TARGETS, hasBundledFiles, INSTALLERS, UNINSTALLERS;
13706
+ var init_install = __esm({
13707
+ "apps/cli/src/commands/install.ts"() {
13708
+ "use strict";
13709
+ init_define_import_meta_env();
13710
+ import_node_child_process2 = require("node:child_process");
13711
+ import_node_fs3 = require("node:fs");
13712
+ import_node_path7 = require("node:path");
13713
+ import_node_os4 = require("node:os");
13714
+ init_skill_bundle();
13715
+ REPO = "zseven-w/openpencil-skill";
13716
+ REPO_URL = `https://github.com/${REPO}.git`;
13717
+ SKILL_NAME = "openpencil-skill";
13718
+ ALL_TARGETS = ["claude", "codex", "cursor", "gemini", "opencode"];
13719
+ hasBundledFiles = Object.keys(skill_bundle_default.files).length > 0;
13720
+ INSTALLERS = {
13721
+ claude: installClaude,
13722
+ codex: installCodex,
13723
+ cursor: installCursor,
13724
+ gemini: installGemini,
13725
+ opencode: installOpenCode
13726
+ };
13727
+ UNINSTALLERS = {
13728
+ claude: uninstallClaude,
13729
+ codex: uninstallCodex,
13730
+ cursor: uninstallCursor,
13731
+ gemini: uninstallGemini,
13732
+ opencode: uninstallOpenCode
13733
+ };
13734
+ }
13735
+ });
13736
+
13269
13737
  // apps/cli/src/commands/layout.ts
13270
13738
  var layout_exports = {};
13271
13739
  __export(layout_exports, {
@@ -13307,13 +13775,22 @@ init_define_import_meta_env();
13307
13775
  // apps/cli/package.json
13308
13776
  var package_default = {
13309
13777
  name: "@zseven-w/openpencil",
13310
- version: "0.7.0",
13778
+ version: "0.7.2",
13311
13779
  description: "CLI for OpenPencil \u2014 control the design tool from your terminal",
13780
+ homepage: "https://github.com/ZSeven-W/openpencil/tree/main/apps/cli",
13781
+ bugs: {
13782
+ url: "https://github.com/ZSeven-W/openpencil/issues"
13783
+ },
13312
13784
  license: "MIT",
13313
13785
  author: {
13314
13786
  name: "ZSeven-W",
13315
13787
  email: "xkayshen@gmail.com"
13316
13788
  },
13789
+ repository: {
13790
+ type: "git",
13791
+ url: "https://github.com/ZSeven-W/openpencil.git",
13792
+ directory: "apps/cli"
13793
+ },
13317
13794
  bin: {
13318
13795
  op: "dist/openpencil-cli.cjs"
13319
13796
  },
@@ -13325,8 +13802,8 @@ var package_default = {
13325
13802
  compile: "cd ../.. && bun run cli:compile"
13326
13803
  },
13327
13804
  dependencies: {
13328
- "@zseven-w/pen-figma": "0.7.0",
13329
- "@zseven-w/pen-mcp": "0.7.0"
13805
+ "@zseven-w/pen-figma": "0.7.2",
13806
+ "@zseven-w/pen-mcp": "0.7.2"
13330
13807
  }
13331
13808
  };
13332
13809
 
@@ -13417,6 +13894,11 @@ Import:
13417
13894
  op import:svg <file.svg> Import SVG file
13418
13895
  op import:figma <file.fig> Import Figma file
13419
13896
 
13897
+ Skill:
13898
+ op install [--target T] Install openpencil-skill for AI agents
13899
+ op uninstall [--target T] Uninstall openpencil-skill
13900
+ Targets: claude, codex, cursor, gemini, opencode (auto-detected if omitted)
13901
+
13420
13902
  Layout:
13421
13903
  op layout [--parent P] [--depth N]
13422
13904
  op find-space [--direction right|bottom|left|top]
@@ -13708,6 +14190,17 @@ async function main() {
13708
14190
  });
13709
14191
  break;
13710
14192
  }
14193
+ // --- Skill install ---
14194
+ case "install": {
14195
+ const { cmdInstall: cmdInstall2 } = await Promise.resolve().then(() => (init_install(), install_exports));
14196
+ cmdInstall2({ target: flags.target });
14197
+ break;
14198
+ }
14199
+ case "uninstall": {
14200
+ const { cmdUninstall: cmdUninstall2 } = await Promise.resolve().then(() => (init_install(), install_exports));
14201
+ cmdUninstall2({ target: flags.target });
14202
+ break;
14203
+ }
13711
14204
  // --- Layout ---
13712
14205
  case "layout": {
13713
14206
  const { cmdLayout: cmdLayout2 } = await Promise.resolve().then(() => (init_layout(), layout_exports));