canicode 0.9.0 → 0.10.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canicode",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "mcpName": "io.github.let-sunny/canicode",
5
5
  "description": "Score your Figma designs with AI-calibrated rules. CLI + MCP server.",
6
6
  "type": "module",
@@ -17,20 +17,23 @@
17
17
  }
18
18
  },
19
19
  "scripts": {
20
- "build": "tsup --config tsup.config.ts",
20
+ "build": "tsup --config tsup.config.ts && pnpm build:roundtrip && pnpm bundle:skills",
21
21
  "build:web": "bash scripts/build-web.sh",
22
+ "build:roundtrip": "tsup --config tsup.roundtrip.config.ts",
23
+ "bundle:skills": "bash scripts/bundle-skills.sh",
22
24
  "dev": "tsup --watch",
23
25
  "test": "vitest",
24
26
  "test:run": "vitest run",
25
27
  "lint": "tsc --noEmit",
26
28
  "build:plugin": "bash scripts/build-plugin.sh",
27
29
  "sync-docs": "tsx scripts/sync-rule-docs.ts",
28
- "clean": "rm -rf dist"
30
+ "clean": "rm -rf dist skills"
29
31
  },
30
32
  "files": [
31
33
  "dist",
32
- "docs/CUSTOMIZATION.md",
33
- ".claude/skills/design-to-code/PROMPT.md"
34
+ "skills",
35
+ "README.md",
36
+ "docs/CUSTOMIZATION.md"
34
37
  ],
35
38
  "keywords": [
36
39
  "figma",
@@ -0,0 +1,76 @@
1
+ ---
2
+ name: canicode
3
+ description: Analyze Figma designs for development-friendliness and AI-friendliness scores
4
+ ---
5
+
6
+ # CanICode -- Figma Design Analysis
7
+
8
+ Analyze Figma design files to score how development-friendly and AI-friendly they are. Produces actionable reports with specific issues and fix suggestions.
9
+
10
+ ## Prerequisites
11
+
12
+ This skill works with either channel — the CLI or the canicode MCP server. Both return the same analysis; pick whichever is already set up. Requires either:
13
+ - A **saved fixture** (from `canicode calibrate-save-fixture`)
14
+ - A **FIGMA_TOKEN** for live Figma URLs
15
+
16
+ ## How to Analyze
17
+
18
+ ### From a Figma URL
19
+
20
+ ```bash
21
+ npx canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234" --token YOUR_TOKEN
22
+ ```
23
+
24
+ Or if FIGMA_TOKEN is set in environment:
25
+ ```bash
26
+ npx canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234"
27
+ ```
28
+
29
+ ### From a saved fixture
30
+
31
+ ```bash
32
+ npx canicode analyze fixtures/my-design
33
+ ```
34
+
35
+ ### Save a fixture for offline analysis
36
+
37
+ ```bash
38
+ npx canicode calibrate-save-fixture "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234" --output fixtures/my-design
39
+ ```
40
+
41
+ ## Analysis Options
42
+
43
+ ### Presets
44
+ - `--preset relaxed` — Downgrades blocking to risk, reduces scores by 50%
45
+ - `--preset dev-friendly` — Enables only pixel-critical and responsive-critical rules, disables the rest
46
+ - `--preset ai-ready` — Sets pixel-critical and token-management rule scores to 150% of defaults
47
+ - `--preset strict` — Increases all scores by 150%
48
+
49
+ ### Config overrides
50
+ ```bash
51
+ npx canicode analyze <input> --config ./my-config.json
52
+ ```
53
+
54
+ ### JSON output
55
+ ```bash
56
+ npx canicode analyze <input> --json
57
+ ```
58
+
59
+ ### Via MCP (when `canicode-mcp` is installed)
60
+
61
+ If the user has the canicode MCP server installed, prefer the MCP tool — it avoids the `npx` spawn overhead and reuses a warm Figma client:
62
+
63
+ ```
64
+ analyze({ input: "<figma-url-or-fixture-path>" })
65
+ ```
66
+
67
+ Options mirror the CLI: `preset`, `token`, `config`, `targetNodeId`, `json`. The `json` response field matches `npx canicode analyze --json` byte-for-byte, so downstream code can parse either source.
68
+
69
+ ## What It Reports
70
+
71
+ 16 rules across 6 categories: Pixel Critical, Responsive Critical, Code Quality, Token Management, Interaction, Semantic.
72
+
73
+ Each issue includes:
74
+ - Rule ID and severity (blocking / risk / missing-info / suggestion)
75
+ - Affected node with Figma deep link
76
+ - Why it matters, impact, and how to fix
@@ -0,0 +1,138 @@
1
+ ---
2
+ name: canicode-gotchas
3
+ description: Run a gotcha survey for a Figma design and save answers as a Claude Code skill file for code generation reference
4
+ ---
5
+
6
+ # CanICode Gotchas -- Design Gotcha Survey & Skill Writer
7
+
8
+ Run a gotcha survey on a Figma design to identify implementation pitfalls, collect developer answers, and save them as a skill file that code generation agents can reference automatically.
9
+
10
+ ## Prerequisites
11
+
12
+ - **canicode MCP server** (preferred): `claude mcp add canicode -e FIGMA_TOKEN=figd_xxx -- npx -y -p canicode canicode-mcp`
13
+ - **Without canicode MCP** (fallback): the `canicode gotcha-survey --json` CLI produces the same response shape — no MCP installation required.
14
+ - **FIGMA_TOKEN** configured for live Figma URLs
15
+
16
+ ## Workflow
17
+
18
+ ### Step 1: Run the gotcha survey
19
+
20
+ If the `gotcha-survey` MCP tool is available, call it with the user's Figma URL:
21
+
22
+ ```
23
+ gotcha-survey({ input: "<figma-url-or-fixture-path>" })
24
+ ```
25
+
26
+ **Without canicode MCP** — shell out to the CLI. The `--json` output parses identically:
27
+
28
+ ```bash
29
+ npx canicode gotcha-survey "<figma-url-or-fixture-path>" --json
30
+ ```
31
+
32
+ Either channel returns:
33
+ - `designGrade`: overall grade (S, A+, A, B+, B, C+, C, D, F)
34
+ - `isReadyForCodeGen`: whether the design can be implemented without gotchas
35
+ - `questions`: array of gotcha questions (may be empty)
36
+
37
+ ### Step 2: Check if survey is needed
38
+
39
+ If `isReadyForCodeGen` is `true` or `questions` is empty:
40
+ - Tell the user: "This design scored **{designGrade}** and is ready for code generation — no gotchas to resolve."
41
+ - Do NOT write a skill file.
42
+ - Stop here.
43
+
44
+ ### Step 3: Present questions to the user
45
+
46
+ For each question in the `questions` array, present it to the user one at a time:
47
+
48
+ ```
49
+ **[{severity}] {ruleId}** — node: {nodeName}
50
+
51
+ {question}
52
+
53
+ > Hint: {hint}
54
+ > Example: {example}
55
+ ```
56
+
57
+ Wait for the user's answer before moving to the next question. The user may:
58
+ - Answer the question directly
59
+ - Say "skip" to skip a question
60
+ - Say "n/a" if the question is not applicable
61
+
62
+ ### Step 4: Write the gotcha skill file
63
+
64
+ After collecting all answers, write the completed file to:
65
+
66
+ ```
67
+ .claude/skills/canicode-gotchas/SKILL.md
68
+ ```
69
+
70
+ This file goes in the **user's project** (current working directory), NOT in the canicode repo.
71
+
72
+ Always overwrite any existing file at this path — each run produces a fresh file based on the latest analysis.
73
+
74
+ ## Output Template
75
+
76
+ The written SKILL.md must follow this exact format:
77
+
78
+ ````markdown
79
+ ---
80
+ name: canicode-gotchas
81
+ description: Design gotcha answers for {designName} — reference during code generation
82
+ ---
83
+
84
+ # Design Gotchas — {designName}
85
+
86
+ Collected from canicode gotcha survey. Reference these answers when implementing this design.
87
+
88
+ ## Metadata
89
+
90
+ - **Figma URL**: {figmaUrl}
91
+ - **Grade**: {designGrade}
92
+ - **Analyzed at**: {analyzedAt}
93
+
94
+ ## Gotchas
95
+
96
+ ### {ruleId} — {nodeName}
97
+
98
+ - **Severity**: {severity}
99
+ - **Node ID**: {nodeId}
100
+ - **Instance context** (omit this bullet if `instanceContext` was not in the survey question): parent instance `parentInstanceNodeId`, source node `sourceNodeId`, component `sourceComponentName` / `sourceComponentId` when present — roundtrip apply uses this to write on the source definition when instance overrides fail.
101
+ - **Question**: {question}
102
+ - **Answer**: {userAnswer}
103
+
104
+ (repeat for each question)
105
+ ````
106
+
107
+ ### Field mapping
108
+
109
+ | Field | Source |
110
+ |-------|--------|
111
+ | `designName` | Figma file name or fixture name from the input |
112
+ | `figmaUrl` | The input URL or fixture path provided by the user |
113
+ | `designGrade` | `designGrade` from gotcha-survey response |
114
+ | `analyzedAt` | Current timestamp (ISO 8601) |
115
+ | `ruleId` | `ruleId` from each question |
116
+ | `nodeName` | `nodeName` from each question |
117
+ | `severity` | `severity` from each question (blocking / risk) |
118
+ | `nodeId` | `nodeId` from each question |
119
+ | `instanceContext` | When present on the question, copy `parentInstanceNodeId`, `sourceNodeId`, `sourceComponentId`, `sourceComponentName` into the bullet above (roundtrip / Plugin apply) |
120
+ | `question` | `question` from each question |
121
+ | `userAnswer` | The answer collected from the user in Step 3 |
122
+
123
+ ### Skipped questions
124
+
125
+ If the user skipped a question or said "n/a", still include it in the output with:
126
+
127
+ ```markdown
128
+ - **Answer**: _(skipped)_
129
+ ```
130
+
131
+ This ensures the code generation agent knows the gotcha exists even if no answer was provided.
132
+
133
+ ## Edge Cases
134
+
135
+ - **No questions returned**: The design is ready for code generation. Inform the user and stop (Step 2).
136
+ - **User wants to re-run**: Always overwrite the existing file. No merge or append — fresh output each time.
137
+ - **MCP tool not available**: Fall back to `npx canicode gotcha-survey <input> --json` — the CLI returns the same `GotchaSurvey` shape. If the CLI is also unavailable (e.g. no node runtime), tell the user to install the canicode MCP server or the `canicode` npm package (see Prerequisites).
138
+ - **Partial answers**: If the user stops mid-survey, write the file with answers collected so far. Mark remaining questions as _(skipped)_.
@@ -0,0 +1,367 @@
1
+ ---
2
+ name: canicode-roundtrip
3
+ description: Analyze Figma design, fix gotchas via Plugin API, re-analyze, then implement — true design-to-code roundtrip
4
+ disable-model-invocation: false
5
+ ---
6
+
7
+ # CanICode Roundtrip — True Design-to-Code Roundtrip
8
+
9
+ Orchestrate the full design-to-code roundtrip: analyze a Figma design for readiness, collect gotcha answers for problem areas, **apply fixes directly to the Figma design** via `use_figma`, re-analyze to verify the design improved, then generate code. The design itself gets better — the next analysis passes without gotchas.
10
+
11
+ ## Prerequisites
12
+
13
+ - **Figma MCP server** installed (provides `get_design_context`, `get_screenshot`, `use_figma`, and other Figma tools) — REQUIRED, there is no CLI fallback for `use_figma`
14
+ - **canicode MCP server** (preferred): `claude mcp add canicode -e FIGMA_TOKEN=figd_xxx -- npx -y -p canicode canicode-mcp`
15
+ - **Without canicode MCP** (fallback): Steps 1 (analyze) and 3 (gotcha-survey) shell out to `npx canicode <command> --json` — same JSON shape as the MCP tools. Step 4 (apply to Figma) still requires Figma MCP `use_figma`.
16
+ - **FIGMA_TOKEN** configured for live Figma URLs
17
+ - **Figma Full seat + file edit permission** (required for `use_figma` to modify the design)
18
+
19
+ ## Workflow
20
+
21
+ ### Step 1: Analyze the design
22
+
23
+ If the `analyze` MCP tool is available, call it with the user's Figma URL:
24
+
25
+ ```
26
+ analyze({ input: "<figma-url>" })
27
+ ```
28
+
29
+ **Without canicode MCP** — shell out to the CLI (same JSON shape):
30
+
31
+ ```bash
32
+ npx canicode analyze "<figma-url>" --json
33
+ ```
34
+
35
+ The response includes:
36
+ - `scores.overall.grade`: design grade (S, A+, A, B+, B, C+, C, D, F)
37
+ - `isReadyForCodeGen`: boolean gate for gotcha skip
38
+ - `issues`: array of design issues found
39
+ - `summary`: human-readable analysis summary
40
+
41
+ Show the user a brief summary:
42
+
43
+ ```
44
+ Design grade: **{grade}** ({percentage}%) — {issueCount} issues found.
45
+ ```
46
+
47
+ ### Step 2: Gate — check if gotchas are needed
48
+
49
+ If `isReadyForCodeGen` is `true` (grade S, A+, or A):
50
+ - Tell the user: "This design scored **{grade}** — ready for code generation with no gotchas needed."
51
+ - Skip directly to **Step 6**.
52
+
53
+ If `isReadyForCodeGen` is `false` (grade B+ or below):
54
+ - Tell the user: "This design scored **{grade}** — running gotcha survey to identify implementation pitfalls."
55
+ - Proceed to **Step 3**.
56
+
57
+ ### Step 3: Run gotcha survey and collect answers
58
+
59
+ If the `gotcha-survey` MCP tool is available, call it:
60
+
61
+ ```
62
+ gotcha-survey({ input: "<figma-url>" })
63
+ ```
64
+
65
+ **Without canicode MCP** — shell out to the CLI (same JSON shape):
66
+
67
+ ```bash
68
+ npx canicode gotcha-survey "<figma-url>" --json
69
+ ```
70
+
71
+ If `questions` is empty, skip to **Step 6**.
72
+
73
+ For each question in the `questions` array, present it to the user one at a time.
74
+
75
+ Build the message from the question fields. **If `question.instanceContext` is present**, prepend one line before the question body:
76
+
77
+ ```
78
+ _Instance note: This layer is inside an instance. Layout and size fixes may need to be applied on source component **{sourceComponentName or sourceComponentId or "unknown"}** (definition node `sourceNodeId`) and propagate to all instances — you will be asked to confirm before any definition-level write._
79
+ ```
80
+
81
+ Then the standard block:
82
+
83
+ ```
84
+ **[{severity}] {ruleId}** — node: {nodeName}
85
+
86
+ {question}
87
+
88
+ > Hint: {hint}
89
+ > Example: {example}
90
+ ```
91
+
92
+ Wait for the user's answer before moving to the next question. The user may:
93
+ - Answer the question directly
94
+ - Say "skip" to skip a question
95
+ - Say "n/a" if the question is not applicable
96
+
97
+ After all questions are answered, **save gotcha answers to file** at `.claude/skills/canicode-gotchas/SKILL.md` in the user's project. Always overwrite any existing file — each run produces a fresh file. Follow the format from the `/canicode-gotchas` skill.
98
+
99
+ Then proceed to **Step 4** to apply answers to the Figma design.
100
+
101
+ ### Step 4: Apply gotcha answers to Figma design
102
+
103
+ Extract the `fileKey` from the Figma URL (format: `figma.com/design/:fileKey/...`).
104
+
105
+ For each answered gotcha (skip questions answered with "skip" or "n/a"), branch on the pre-computed `question.applyStrategy`. The routing table, target properties, and instance-child resolution are resolved server-side by `canicode` — do NOT re-derive them from the rule id.
106
+
107
+ Use the **`nodeId` from the answered question**. When `question.isInstanceChild` is `true`, treat layout and size-constraint changes as **high impact**: applying them on the source definition affects **every instance** of that component in the file. Ask for explicit user confirmation before writing to the definition node.
108
+
109
+ #### Input shape from canicode
110
+
111
+ Every gotcha-survey question (and every entry in `analyzeResult.issues[]`) carries these pre-computed fields:
112
+
113
+ | Field | Type | Meaning |
114
+ |-------|------|---------|
115
+ | `applyStrategy` | `"property-mod"` \| `"structural-mod"` \| `"annotation"` \| `"auto-fix"` | Which strategy branch to enter (A/B/C/D). |
116
+ | `targetProperty` | `string` \| `string[]` \| (absent) | Figma Plugin-API property to write. Array when multiple properties move together (e.g. `no-auto-layout` → `["layoutMode", "itemSpacing"]`). Absent for structural/annotation rules. |
117
+ | `annotationProperties` | `Array<{ type: string }>` \| (absent) | Pre-computed Dev Mode annotation `properties` hint for the ruleId (+ subType). Pass directly to `upsertCanicodeAnnotation`. Absent when the rule has no mapping. See the annotation matrix below for the enum + node-type filtering (enforced by the helper's retry path). |
118
+ | `suggestedName` | `string` \| (absent) | Naming rules only — pre-capitalized value to write to `node.name` (e.g. `"Hover"`). |
119
+ | `isInstanceChild` | `boolean` | Whether the `nodeId` targets a node inside an INSTANCE subtree. |
120
+ | `sourceChildId` | `string` \| (absent) | Definition node id inside the source component. Use directly with `figma.getNodeByIdAsync`. |
121
+ | `instanceContext` | object \| (absent) | Survey questions only. `{ parentInstanceNodeId, sourceNodeId, sourceComponentId?, sourceComponentName? }` for the Step 3 user-facing note. |
122
+
123
+ #### Instance-child property overridability (Plugin API)
124
+
125
+ Most production nodes sit under `INSTANCE` subtrees. `canicode` flags these via `question.isInstanceChild` and, when resolvable, surfaces the definition node id as `question.sourceChildId` plus extra metadata on `question.instanceContext`. You do not need to parse node ids.
126
+
127
+ Matrix below is confirmed by Experiment 08 ([#290](https://github.com/let-sunny/canicode/issues/290)) probes on shallow + deep instance-child FRAMEs in the Simple Design System fixture. `✅` = raw-value write accepted, `❌` = throws *"cannot be overridden in an instance"*, `⚠️` = no error but value silently unchanged (must detect with before/after compare).
128
+
129
+ | Property | Raw-value write on instance child | Variable binding | Notes |
130
+ |----------|----------------------------------|------------------|-------|
131
+ | `node.name` | ✅ | — | Prefer scene node first. |
132
+ | `annotations` | ✅ | — | Good fallback when another property cannot be set. |
133
+ | `itemSpacing`, `paddingTop/Right/Bottom/Left` | ✅ | ✅ | |
134
+ | `primaryAxisAlignItems`, `counterAxisAlignItems`, `layoutAlign` | ✅ | — | |
135
+ | `cornerRadius`, `opacity` | ✅ | ✅ | |
136
+ | `fills`, `strokes` (raw color) | ✅ | ✅ via `setBoundVariableForPaint(paint, "color", v)` | |
137
+ | `layoutSizingHorizontal` / `layoutSizingVertical` | ✅ | — | |
138
+ | `layoutMode` | ⚠️ on some nodes | — | Some instance children silently ignore the write (no throw, no change). |
139
+ | **`minWidth`, `maxWidth`, `minHeight`, `maxHeight`** | ❌ on many nodes | **✅** | **Variable binding bypasses the override restriction** — prefer binding when the answer names a token. Raw values route to the definition node after confirmation. |
140
+ | `fontSize`, `lineHeight`, `letterSpacing`, `paragraphSpacing` (TEXT) | ✅ | ✅ | |
141
+ | `characters` (TEXT) | ✅ | ✅ STRING variable | |
142
+
143
+ #### Annotation `properties` matrix
144
+
145
+ Experiment 09 ([#290 follow-up](https://github.com/let-sunny/canicode/issues/290)) re-measured the full 33-value enum on a scene FRAME (`3077:9894`) and scene TEXT (`3077:9963`) in the Simple Design System fixture. The key finding: **the gate is node-type, not scene-vs-instance**. FRAMEs reject `fills`/`cornerRadius`/`opacity`/`maxWidth`/`effects` regardless of context. Instance children additionally lose `minWidth`/`minHeight`/`alignItems` on FRAMEs — these are instance-override restrictions layered on top.
146
+
147
+ Each row below covers the full 33-value enum (`width`, `height`, `maxWidth`, `minWidth`, `maxHeight`, `minHeight`, `fills`, `strokes`, `effects`, `strokeWeight`, `cornerRadius`, `textStyleId`, `textAlignHorizontal`, `fontFamily`, `fontStyle`, `fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`, `itemSpacing`, `padding`, `layoutMode`, `alignItems`, `opacity`, `mainComponent`, plus 8 grid props `gridRowGap`/`gridColumnGap`/`gridRowCount`/`gridColumnCount`/`gridRowAnchorIndex`/`gridColumnAnchorIndex`/`gridRowSpan`/`gridColumnSpan`):
148
+
149
+ | Node type | Accepted (scene) | Additionally rejected on instance child | Rejected in all contexts |
150
+ |-----------|------------------|-----------------------------------------|--------------------------|
151
+ | FRAME | `width`, `height`, `minWidth`, `minHeight`, `itemSpacing`, `padding`, `layoutMode`, `alignItems` | `minWidth`, `minHeight`, `alignItems` | `maxWidth`, `maxHeight`, `fills`, `strokes`, `effects`, `strokeWeight`, `cornerRadius`, `opacity`, `mainComponent`, all 8 text props, all 8 grid props |
152
+ | TEXT | `width`, `height`, `fills`, `textStyleId`, `fontFamily`, `fontStyle`, `fontSize`, `fontWeight`, `lineHeight` | not re-measured — Experiment 08 only probed `strokes`/`opacity`/`cornerRadius`/`effects`/`layoutMode`/`itemSpacing`/`padding` on instance-child TEXT, and all were rejected there too | `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `strokes`, `effects`, `strokeWeight`, `cornerRadius`, `opacity`, `textAlignHorizontal`, `letterSpacing`, `itemSpacing`, `padding`, `layoutMode`, `alignItems`, `mainComponent`, all 8 grid props |
153
+
154
+ `upsertCanicodeAnnotation` wraps the write in `try/catch`: if `properties` fails node-type validation it retries without them, so the markdown body always survives. You can pass `properties` speculatively.
155
+
156
+ > **Note:** This policy has shipped per ADR-012 (resolves [#295](https://github.com/let-sunny/canicode/issues/295)): **scene write by default; definition write is opt-in** behind `allowDefinitionWrite`. The bundled helper and the prose below match — reading one without the other is safe.
157
+
158
+ **Write policy (ordered tiers):**
159
+
160
+ The helper walks the tiers in order; variable binding is an alternative writeFn shape available at tiers 1 and 2 that bypasses the instance-child override gate (Experiment 08) — it is *not* a separate ordering position between the tiers.
161
+
162
+ 1. **Scene (instance) node** — `await figma.getNodeByIdAsync(question.nodeId)` and apply the write inside `try/catch`. If the answer names a design-system token (`{ variable: "name" }`), the helper calls `setBoundVariable` / `setBoundVariableForPaint` first and that binding bypasses the override gate — otherwise it performs a raw-value write. Success → done (local change only). Mark result with ✅.
163
+ 2. **Definition (source) node — opt-in only** — Runs only when the orchestrator passes `allowDefinitionWrite: true` on the helper context (after a batch-level confirmation naming the source component AND the propagation set). When the flag is off (the ADR-012 default), a recognized instance-override failure (override-error or silent-ignore) short-circuits here and routes directly to tier 3 — the definition node is never touched. When the flag is on, the helper loads `question.sourceChildId` (or walks `getMainComponentAsync()` if needed) and writes using the same bind-if-token-else-raw shape as tier 1; changes propagate to **every non-overridden instance** in the file (Experiment 10). Mark result with 🌐.
164
+ 3. **Annotation fallback — default path** — Under the ADR-012 default this is where override-errors and silent-ignores land: the helper annotates the **scene** node with markdown that names the source component as the recommended write target and notes that the instance kept its current value to avoid unintended fan-out. When `allowDefinitionWrite` is on, this tier also catches any definition-tier throw (e.g. Experiment 10 external-library read-only case, `mainComponent.remote === true` / *"Cannot write to internal and read-only node"*, and the `mainComponent === null` branch where `getMainComponentAsync()` resolves with no definition to name — see Experiment 11 / ADR-011). Either way, mark result with 📝.
165
+
166
+ **Confirmation is a batch-level concern — and only needed when opting in.** A `use_figma` call runs one JavaScript batch and cannot pause mid-batch for user input. Under the ADR-012 default (`allowDefinitionWrite: false`), no propagation happens, so no confirmation is required — override-errors annotate and move on. The orchestrator sets `allowDefinitionWrite: true` only after enumerating the likely propagation set to the user up-front and collecting **one confirmation for the whole batch** that names the source component(s) and the affected instance set. When describing impact, note that the write reaches every **non-overridden** instance — any instance with a local override for the same property keeps its override. The helper below never prompts — it assumes that if the flag is on, confirmation already happened.
167
+
168
+ **Shared helpers (bundled)** — the deterministic helpers live in TypeScript at `src/core/roundtrip/*.ts` and are bundled to a single IIFE at `.claude/skills/canicode-roundtrip/helpers.js`. `use_figma` only accepts a self-contained JS string, so the source of truth is TypeScript (with vitest coverage) and the bundle is the delivery artifact.
169
+
170
+ **Usage in a roundtrip session:**
171
+
172
+ 1. Read `.claude/skills/canicode-roundtrip/helpers.js` once at the start of Step 4.
173
+ 2. Prepend its contents verbatim at the top of every `use_figma` batch body — it registers a single global `CanICodeRoundtrip`.
174
+ 3. Reference exposed globals as `CanICodeRoundtrip.*`:
175
+ - `stripAnnotations(annotations)` — normalizes the D1 label/labelMarkdown mutex on readback.
176
+ - `ensureCanicodeCategories()` — returns `{ gotcha, autoFix, fallback }` category id map (D4); idempotent, safe to call at the top of every batch.
177
+ - `upsertCanicodeAnnotation(node, { ruleId, markdown, categoryId, properties })` — idempotent annotation upsert. Handles D1 mutex, D2 in-place replace by ruleId prefix, and the D3 `properties` node-type retry.
178
+ - `applyWithInstanceFallback(question, writeFn, { categories, allowDefinitionWrite, telemetry })` — three-tier write policy with silent-ignore detection. `allowDefinitionWrite` defaults to `false` per ADR-012 — override-errors and silent-ignores annotate the scene naming the source component instead of writing the definition. Set `true` only after a batch-level confirmation. `telemetry` is an optional `(event, props) => void` callback fired when a definition write is skipped (wiring point for future Node-side opt-in usage data). The `writeFn` may return `false` to signal "write accepted but value unchanged" so the helper can route to the next tier.
179
+ - `applyPropertyMod(question, answerValue, { categories, allowDefinitionWrite, telemetry })` — Strategy A entry point. Branches on `targetProperty` (single vs array) and answer shape (scalar, per-property object, `{ variable: "name" }` binding). Uses `setBoundVariableForPaint` for `fills` / `strokes` and `setBoundVariable` for scalar fields. Passes the full context through to `applyWithInstanceFallback`.
180
+ - `resolveVariableByName(name)` — local-variable exact-name lookup; returns `null` for remote library variables not imported into this file.
181
+
182
+ Keep each `writeFn` small so a throw does not abort unrelated writes. Experiment 08 findings informed every branch in the bundled helpers, and the batch-level confirmation contract still applies *when opting in*: if the orchestrator passes `allowDefinitionWrite: true`, it must have already collected one confirmation covering every potential definition write in the batch. Under the default, no confirmation is needed — the helper annotates the scene instead of propagating.
183
+
184
+ Wrap every property write in `CanICodeRoundtrip.applyWithInstanceFallback(question, async (target) => { ... }, { categories })` so failed or silently-ignored instance overrides route to the scene annotation (or, when the user has opted in, to the definition tier) instead of silently aborting the batch.
185
+
186
+ #### Strategy A: Property Modification — apply directly
187
+
188
+ Rules with `applyStrategy === "property-mod"`. Call the bundled helper — it branches on `question.targetProperty` (single vs array) and on each value type (scalar, multi-property object, `{ variable: "token-name" }` binding) automatically. Paint properties (`fills`, `strokes`) are bound with `setBoundVariableForPaint` per the Plugin API contract; scalar fields use `setBoundVariable`.
189
+
190
+ ```javascript
191
+ await CanICodeRoundtrip.applyPropertyMod(question, answerValue, { categories });
192
+ ```
193
+
194
+ Answer shape guide (LLM judgment — the user's answer is prose; parse accordingly):
195
+ - **`non-semantic-name`**: string — the new node name.
196
+ - **`irregular-spacing`**: number for gap (subType `gap`), or `{ paddingTop, paddingRight, paddingBottom, paddingLeft }` for padding.
197
+ - **`fixed-size-in-auto-layout`**: `"FILL"` \| `"HUG"` \| `"FIXED"` — applied to each axis listed in `targetProperty`.
198
+ - **`missing-size-constraint`**: partial `{ minWidth, maxWidth }` — include only the keys the answer supplied.
199
+ - **`no-auto-layout`**: `{ layoutMode, itemSpacing }`; optionally extend with padding/alignment from the answer.
200
+
201
+ **Variable binding** — whenever the answer names a design-system token (e.g. the user says the width should be `mobile-width`, the gap should be `space-m`, the color should be `Brand/Primary`), shape the value as `{ variable: "token-name" }` instead of a raw scalar. The helper calls `setBoundVariable` which **bypasses instance-child override restrictions**, so `minWidth`/`maxWidth`/color fields that raw writes cannot touch on an instance child will bind successfully. Mix shapes per-property — e.g. `{ minWidth: { variable: "mobile-width" }, maxWidth: 1440 }`.
202
+
203
+ The name must match **the variable's `name` field exactly** — including any slash path in the name (e.g. `"Brand/Primary"` matches only when the variable is literally named that way). Resolution is scoped to variables that `figma.variables.getLocalVariablesAsync()` returns: locally defined ones plus library variables that have already been imported into this file. If the token lives only in an unimported remote library, the binding step returns `null` and `applyPropertyMod` either falls through to a raw scalar (when the answer provided a `fallback` value) or records the miss — expose this as an annotation via the fallback category so the designer can import the variable and retry.
204
+
205
+ #### Strategy B: Structural Modification — confirm with user first
206
+
207
+ Rules with `applyStrategy === "structural-mod"`. Show the proposed change and **ask for user confirmation** before applying.
208
+
209
+ **`non-layout-container`** — Convert Group/Section to Auto Layout frame:
210
+ - Prompt: "I'll convert **{nodeName}** to an Auto Layout frame with {direction} layout and {spacing}px gap. Proceed?"
211
+ - If confirmed: `applyPropertyMod(question, { layoutMode: "VERTICAL", itemSpacing: 12 })`.
212
+
213
+ **`deep-nesting`** — Flatten intermediate wrappers or extract sub-component:
214
+ - Prompt: "I'll flatten **{nodeName}** by {description from answer}. This changes the layer hierarchy. Proceed?"
215
+ - Apply based on the specific answer (remove wrappers, convert padding, etc.).
216
+
217
+ **`missing-component`** — Convert frame to reusable component:
218
+ - Prompt: "I'll convert **{nodeName}** to a reusable component. Proceed?"
219
+ - If confirmed:
220
+ ```javascript
221
+ const scene = await figma.getNodeByIdAsync(question.nodeId);
222
+ if (scene && scene.type === "FRAME") {
223
+ figma.createComponentFromNode(scene);
224
+ }
225
+ ```
226
+
227
+ **`detached-instance`** — Reconnect to original component:
228
+ - Prompt: "I'll reconnect **{nodeName}** to its original component. Any overrides will be preserved. Proceed?"
229
+ - Requires finding the original component — if not identifiable, fall back to annotation.
230
+
231
+ If the user **declines** any structural modification, add an annotation instead (same as Strategy C).
232
+
233
+ #### Strategy C: Annotation — record on the design for designer reference
234
+
235
+ Rules with `applyStrategy === "annotation"` cannot be auto-fixed via Plugin API. Add the gotcha answer as a Figma annotation so designers see it in Dev Mode. Use the helper — it handles the D1 mutex, D2 in-place upsert, and D4 category assignment.
236
+
237
+ ```javascript
238
+ const scene = await figma.getNodeByIdAsync(question.nodeId);
239
+ CanICodeRoundtrip.upsertCanicodeAnnotation(scene, {
240
+ ruleId: question.ruleId,
241
+ markdown: `**Q:** ${question.question}\n**A:** ${answer}`,
242
+ categoryId: categories.gotcha,
243
+ // Optional: surface live property values in Dev Mode alongside the note.
244
+ // Only include types the node supports (FRAME vs TEXT — see matrix above).
245
+ properties: question.annotationProperties,
246
+ });
247
+ ```
248
+
249
+ Notes:
250
+ - `upsertCanicodeAnnotation` replaces an existing `**[canicode] <ruleId>**` entry on the same node instead of appending — reruns don't accumulate duplicates.
251
+ - `label` and `labelMarkdown` are mutually exclusive on write, but Figma returns both on readback. Never spread `scene.annotations` directly; always call `CanICodeRoundtrip.upsertCanicodeAnnotation` (or `CanICodeRoundtrip.stripAnnotations` if you truly need the normalized array).
252
+ - Prefer annotating the **scene** instance child so designers see the note where they work; mention in the markdown if the fix belongs on the source component but could not be applied (library/external).
253
+
254
+ #### Strategy D: Auto-fix lower-severity issues from analysis
255
+
256
+ The gotcha survey covers only blocking/risk severity. Lower-severity rules appear in `analyzeResult.issues[]` without a survey question. Each issue carries the same pre-computed fields (`applyStrategy`, `targetProperty`, `annotationProperties`, `suggestedName`, `isInstanceChild`, `sourceChildId`). Loop over them:
257
+
258
+ ```javascript
259
+ for (const issue of analyzeResult.issues) {
260
+ if (issue.applyStrategy !== "auto-fix") continue;
261
+
262
+ // Shape an ad-hoc question-like object so the same helpers apply.
263
+ const q = {
264
+ nodeId: issue.nodeId,
265
+ ruleId: issue.ruleId,
266
+ ...(issue.sourceChildId ? { sourceChildId: issue.sourceChildId } : {}),
267
+ };
268
+
269
+ if (issue.targetProperty === "name" && issue.suggestedName) {
270
+ // Naming rules — rename to the pre-computed suggestedName.
271
+ await CanICodeRoundtrip.applyWithInstanceFallback(q, async (target) => {
272
+ if (target) target.name = issue.suggestedName;
273
+ }, { categories });
274
+ } else {
275
+ // raw-value, missing-interaction-state, missing-prototype — designer judgment; annotate.
276
+ const scene = await figma.getNodeByIdAsync(issue.nodeId);
277
+ CanICodeRoundtrip.upsertCanicodeAnnotation(scene, {
278
+ ruleId: issue.ruleId,
279
+ markdown: issue.message,
280
+ categoryId: categories.autoFix,
281
+ // Optional: surface the live value for the affected property in Dev Mode.
282
+ properties: issue.annotationProperties,
283
+ });
284
+ }
285
+ }
286
+ ```
287
+
288
+ `suggestedName` is already capitalized for direct Plugin-API use (e.g. `"Hover"`, `"Default"`, `"Pressed"`). Do not transform it further.
289
+
290
+ #### Execution order
291
+
292
+ 0. **Initialize categories** — first batch calls `const categories = await CanICodeRoundtrip.ensureCanicodeCategories();` and keeps the result in scope for every subsequent call in the same script. (Or re-run ensure at the top of each `use_figma` batch — it is idempotent by label.)
293
+ 1. **Batch all property modifications** (Strategy A) into a single `use_figma` call for efficiency. Pass `{ categories }` to `applyWithInstanceFallback` so fallbacks land in the correct category.
294
+ 2. **Present structural modifications** (Strategy B) one by one, apply confirmed ones.
295
+ 3. **Batch all annotations** (Strategy C + declined structural mods) into a single `use_figma` call — use `categories.gotcha` for the category id.
296
+ 4. **Batch all auto-fixes and annotations for lower-severity issues** (Strategy D) — use `categories.autoFix` for annotated ones, `categories.fallback` is reserved for errors surfaced by `applyWithInstanceFallback` itself.
297
+
298
+ After applying, report what was done:
299
+
300
+ ```
301
+ Applied {N} changes to the Figma design:
302
+ - ✅ {nodeName}: renamed to "hero-section" (non-semantic-name) — scene/instance override
303
+ - 🌐 {nodeName}: minWidth applied on source definition (missing-size-constraint) — propagates to all instances
304
+ - ✅ {nodeName}: itemSpacing → 16px (irregular-spacing)
305
+ - 🔗 {nodeName}: minWidth bound to variable "mobile-width" (missing-size-constraint)
306
+ - ⏭️ {nodeName}: declined by user, added annotation (deep-nesting)
307
+ - 📝 {nodeName}: annotation added to canicode:gotcha (absolute-position-in-auto-layout)
308
+ - 🔧 {nodeName}: auto-fixed to "Hover" (non-standard-naming)
309
+ - 📝 {nodeName}: annotation added to canicode:auto-fix — raw color needs token binding (raw-value)
310
+ ```
311
+
312
+ ### Step 5: Re-analyze and verify
313
+
314
+ Run `analyze` again on the same Figma URL:
315
+
316
+ ```
317
+ analyze({ input: "<figma-url>" })
318
+ ```
319
+
320
+ Compare the new grade with the original:
321
+
322
+ **All gotcha issues resolved** (new grade is S, A+, or A):
323
+ - Tell the user: "Design improved from **{oldGrade}** to **{newGrade}** — all gotcha issues resolved. Ready for code generation."
324
+ - Clean up canicode annotations: remove annotations with `[canicode]` prefix from fixed nodes via `use_figma`. Apply `stripAnnotations` to avoid the D1 mutex:
325
+ ```javascript
326
+ const nodeIds = ["id1", "id2"]; // nodes that now pass
327
+ for (const id of nodeIds) {
328
+ const node = await figma.getNodeByIdAsync(id);
329
+ if (node && "annotations" in node) {
330
+ node.annotations = CanICodeRoundtrip.stripAnnotations(node.annotations).filter(
331
+ a => !a.labelMarkdown?.startsWith("**[canicode]")
332
+ );
333
+ }
334
+ }
335
+ ```
336
+ - Proceed to **Step 6**.
337
+
338
+ **Some issues remain**:
339
+ - Show what improved and what still needs attention.
340
+ - Ask: "Design improved from **{oldGrade}** to **{newGrade}**. {remainingCount} issues remain. Proceed to code generation?"
341
+ - If yes → proceed to **Step 6** with remaining gotcha context.
342
+ - If no → stop and let the user address remaining issues manually.
343
+
344
+ ### Step 6: Implement with Figma MCP
345
+
346
+ Follow the **figma-implement-design** skill workflow to generate code from the Figma design.
347
+
348
+ **If annotations or unresolved gotchas remain from Step 5**, provide them as additional context when implementing:
349
+
350
+ - Gotchas with severity **blocking** MUST be addressed — the design cannot be implemented correctly without this information
351
+ - Gotchas with severity **risk** SHOULD be addressed — they indicate potential issues that will surface later
352
+ - Reference the specific node IDs from gotcha answers to locate the affected elements in the design
353
+
354
+ **If all issues were resolved in Steps 4-5**, no additional gotcha context is needed — the design speaks for itself.
355
+
356
+ ## Edge Cases
357
+
358
+ - **No canicode MCP server**: Fall back to `npx canicode analyze --json` and `npx canicode gotcha-survey --json` — both CLI commands return the same shape as the MCP tools. The Figma MCP is still required for `use_figma` in Step 4; there is no CLI fallback for Figma design edits.
359
+ - **No Figma MCP server**: If `get_design_context` or `use_figma` is not found, tell the user to set up the Figma MCP server. Without it, the apply and code generation phases cannot proceed.
360
+ - **No edit permission**: If `use_figma` fails with a permission error, tell the user they need Full seat + file edit permission. Fall back to the one-way flow: skip Steps 4-5 and proceed directly to Step 6 with gotcha answers as code generation context.
361
+ - **User wants analysis only**: Suggest using `/canicode` instead — it runs analysis without the code generation phase.
362
+ - **User wants gotcha survey only**: Suggest using `/canicode-gotchas` instead — it runs the survey and saves answers as a persistent skill file.
363
+ - **Partial gotcha answers**: Apply only the answered questions. Skipped/n/a questions are neither applied nor annotated.
364
+ - **use_figma call fails for a node**: Report the error for that specific node, continue with other nodes. Failed property modifications become annotations so the context is not lost.
365
+ - **Re-analyze shows new issues**: Only address issues from the original gotcha survey. New issues may appear due to structural changes — report them but do not re-enter the gotcha loop.
366
+ - **Very large design (many gotchas)**: The gotcha survey already deduplicates sibling nodes and filters to blocking/risk severity only. If there are still many questions, ask the user if they want to focus on blocking issues only.
367
+ - **External library components**: Applies only when the orchestrator has set `allowDefinitionWrite: true`. Experiment 10's observed case is `getMainComponentAsync()` resolving with `mainComponent.remote === true` — writes then throw *"Cannot write to internal and read-only node"*. The `mainComponent === null` case is documented in the Plugin API but was not reproduced live in Experiment 10; Experiment 11 (#309) unit-test-covers the helper's routing for that branch (override-error + no `sourceChildId` → annotate with `could not apply automatically:` markdown — see ADR-011 Verification), so the code path is regression-locked while live Figma reproduction remains a manual fixture-seeding follow-up. Under the default (`allowDefinitionWrite: false`), the definition write never fires and this throw cannot surface.