canicode 0.11.0 → 0.11.1

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.
@@ -6,7 +6,9 @@ disable-model-invocation: false
6
6
 
7
7
  # CanICode Roundtrip — True Design-to-Code Roundtrip
8
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.
9
+ **Channel contrast:** **`canicode-gotchas`** stores answers in **local** `.claude/skills/canicode-gotchas/SKILL.md` only (memo no Figma write). **`canicode-roundtrip`** (**this skill**) writes to the **Figma canvas** via Plugin API (`use_figma`). If you only need Q&A persistence, use gotchas; if you need annotations and fixes on the file, use roundtrip.
10
+
11
+ 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 gotchas were captured, then generate code. Success means **gotchas answered and carried into annotations / writes** — not a numeric grade bump (analyze still reports grade for continuity; roundtrip success is lint-first).
10
12
 
11
13
  ## Prerequisites
12
14
 
@@ -30,6 +32,8 @@ If `use_figma` is unavailable in the current session, **Do NOT proceed to Step 1
30
32
 
31
33
  See the Edge Case **No Figma MCP server** below for the one-way fallback when Figma MCP genuinely cannot be installed — the precheck above is for the common "installed but not restarted" case, not a replacement for that fallback.
32
34
 
35
+ **canicode MCP (same cold-session pattern):** If `analyze` / `gotcha-survey` MCP tools are missing but `.mcp.json` lists canicode, you are on the `npx canicode …` fallback. Tell the user to restart the host or reload MCP after `claude mcp add canicode …` (or the Cursor equivalent) so the canicode tools appear — same communication fix as #433; the CLI path is not an error.
36
+
33
37
  ### Step 1: Analyze the design
34
38
 
35
39
  If the `analyze` MCP tool is available, call it with the user's Figma URL:
@@ -82,87 +86,10 @@ npx canicode gotcha-survey "<figma-url>" --json
82
86
 
83
87
  If `questions` is empty, skip to **Step 6**.
84
88
 
85
- #### Step 3a: Why the response carries a pre-grouped+batched view
86
-
87
- The naive "one-question-at-a-time" loop produces two well-known UX failures on real designs:
88
-
89
- - **Repeated Instance note (#370)** — when 10 consecutive questions share the same `instanceContext.sourceComponentId`, the standard "_Instance note: …source component **X**…_" paragraph prints 10 times. After the first occurrence it adds zero new information and consumes ~2 screens of vertical space.
90
- - **Repeated identical answer (#369)** — when 7 consecutive questions all carry the same `ruleId` (e.g. `missing-size-constraint`) and the user's reasonable answer would be the same for all of them (e.g. `min-width: 320px, max-width: 1200px`), the user types the same thing 7 times in a row.
91
-
92
- `gotcha-survey` already ships the resolution on its `groupedQuestions` field. Sort key (`(sourceComponentId ?? "_no-source", ruleId, nodeName)`), source-component grouping, and the batchable-rule whitelist (`missing-size-constraint`, `irregular-spacing`, `no-auto-layout`, `fixed-size-in-auto-layout`) all live in `core/gotcha/group-and-batch-questions.ts` with vitest coverage. Per ADR-016, do **not** re-implement the sort, partition, or whitelist in prose — iterate over `groupedQuestions.groups[].batches[]` directly.
93
-
94
- #### Step 3b: Prompt each group, then each batch within it
95
-
96
- For each `group` in `response.groupedQuestions.groups`:
97
-
98
- - **`group.instanceContext === null`** — this is the trailing group of non-instance questions. Skip the header and prompt each batch directly.
99
- - **`group.instanceContext !== null`** — emit the Instance note **once** as a group header (#370):
100
-
101
- ```
102
- ─────────────────────────────────────────
103
- The next {sum of batch.questions.length} questions all target instance children of source component **{instanceContext.sourceComponentName ?? instanceContext.sourceComponentId ?? "unknown"}** (definition node `{instanceContext.sourceNodeId}`). Layout and size fixes may need to apply on the source and propagate to all instances — you will be asked to confirm before any definition-level write.
104
- ─────────────────────────────────────────
105
- ```
106
-
107
- For each `batch` inside the group:
108
-
109
- - **`batch.questions.length === 1`** — render the standard single-question block for `batch.questions[0]`:
110
-
111
- ```
112
- **[{severity}] {ruleId}** — node: {nodeName}
113
-
114
- {question}
115
-
116
- > Hint: {hint}
117
- > Example: {example}
118
- ```
119
-
120
- **If `question.replicas` is present (#356 dedup)**, prepend one note above the standard block:
121
-
122
- ```
123
- _Replicas: This question represents **{replicas} instances** of the same source-component child sharing the same rule. Your single answer will be applied to all of them in Step 4 (one annotation/write per instance scene)._
124
- ```
125
-
126
- - **`batch.questions.length >= 2 && batch.batchable === true`** (#369) — render one batch prompt covering all members. Use `batch.totalScenes` (already summed across each member's `replicas`) for the Figma-scene fan-out hint:
127
-
128
- ```
129
- **[{severity}] {batch.ruleId}** — {batch.questions.length} instances:
130
- - {nodeName₁}{ruleSpecificContext₁}
131
- - {nodeName₂}{ruleSpecificContext₂}
132
- - …
133
-
134
- {sharedQuestionPrompt}
135
-
136
- Reply with one answer to apply to all {batch.questions.length}, or **split** to answer each individually.
137
-
138
- > Hint: {hint}
139
- > Example: {example}
140
- ```
141
-
142
- Where:
143
- - `sharedQuestionPrompt` is the rule's `question` text with the per-node noun replaced by the rule's plural noun (e.g. "These layers all use FILL sizing without min/max constraints. What size boundaries should they share?" instead of repeating "What size boundaries should this layer have?" N times).
144
- - `ruleSpecificContext` is short and rule-specific: e.g. for `missing-size-constraint` show the current `width`/`height` if the question has them; for `irregular-spacing` show the current `itemSpacing`; otherwise omit.
145
- - On `split`, fall back to the per-question loop for that batch only — keep the rest of the group's batches as-is.
146
-
147
- When `batch.totalScenes > batch.questions.length` (at least one member carries replicas), append one note so the user knows their single answer fans out further than the listed nodes:
148
-
149
- ```
150
- _Replicas: your one answer will land on **{batch.totalScenes}** Figma scenes total in Step 4 (some of these {batch.questions.length} questions already represent multiple instances of the same source-component child)._
151
- ```
152
-
153
- - **`batch.batchable === false`** is always rendered as a single-question prompt — the helper guarantees `questions.length === 1` for those (identity-typed answers like `non-semantic-name`, structural-mod rules).
154
-
155
- Wait for the user's answer before moving to the next batch. For each batch, the user may:
156
- - Answer the question directly (single value covers all batch members)
157
- - Say **split** (batch only) to fall back to per-question prompting for that batch
158
- - Say **skip** to skip the question / the entire batch
159
- - Say **n/a** if the question / the entire batch is not applicable
89
+ #### Step 3 grouped survey (`groupedQuestions`)
160
90
 
161
- When applying the batched answer, expand back to per-question records before storing the gotcha section format and Step 4 apply loop both expect one record per `nodeId`.
91
+ Iterate `groupedQuestions.groups[].batches[]`. Instance notes, batch prompts, replicas, split/skip/n/a, stdin upsert**[Appendix Step 3](https://github.com/let-sunny/canicode/blob/main/docs/roundtrip-protocol.md#appendix--step-3-grouped-survey-groupedquestions)**. Per ADR-016, do not re-implement grouping.
162
92
 
163
- After all questions are answered, **upsert this design's gotcha section** into `.claude/skills/canicode-gotchas/SKILL.md` in the user's project. Read the existing file, then either replace the section whose `Design key` matches `survey.designKey` (the canonical identifier the gotcha-survey response carries — see `/canicode-gotchas` Step 4a) or append a new numbered section under `# Collected Gotchas`. Never modify anything above the `# Collected Gotchas` heading — the region above it (frontmatter + workflow prose) is the skill loader contract installed by `canicode init`. See the `/canicode-gotchas` skill's "Upsert the gotcha section" step (Step 4) for the exact section format and matching rule.
164
-
165
- Then proceed to **Step 4** to apply answers to the Figma design.
166
93
 
167
94
  ### Step 4: Apply gotcha answers to Figma design
168
95
 
@@ -186,120 +113,9 @@ Every gotcha-survey question (and every entry in `analyzeResult.issues[]`) carri
186
113
  | `replicas` | `number` \| (absent) | Survey questions only (#356). Total instance count when this one question represents N instance-child issues sharing the same `(sourceComponentId, sourceNodeId, ruleId)` tuple. Absent for single-instance questions. |
187
114
  | `replicaNodeIds` | `string[]` \| (absent) | Survey questions only (#356). All OTHER instance scene node ids the answer should land on. The apply step iterates `[nodeId, ...replicaNodeIds]`. Absent when `replicas` is absent. |
188
115
 
189
- #### Instance-child property overridability (Plugin API)
190
-
191
- 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.
192
-
193
- 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).
194
-
195
- | Property | Raw-value write on instance child | Variable binding | Notes |
196
- |----------|----------------------------------|------------------|-------|
197
- | `node.name` | ✅ | — | Prefer scene node first. |
198
- | `annotations` | ✅ | — | Good fallback when another property cannot be set. |
199
- | `itemSpacing`, `paddingTop/Right/Bottom/Left` | ✅ | ✅ | |
200
- | `primaryAxisAlignItems`, `counterAxisAlignItems`, `layoutAlign` | ✅ | — | |
201
- | `cornerRadius`, `opacity` | ✅ | ✅ | |
202
- | `fills`, `strokes` (raw color) | ✅ | ✅ via `setBoundVariableForPaint(paint, "color", v)` | |
203
- | `layoutSizingHorizontal` / `layoutSizingVertical` | ✅ | — | |
204
- | `layoutMode` | ⚠️ on some nodes | — | Some instance children silently ignore the write (no throw, no change). |
205
- | **`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. |
206
- | `fontSize`, `lineHeight`, `letterSpacing`, `paragraphSpacing` (TEXT) | ✅ | ✅ | |
207
- | `characters` (TEXT) | ✅ | ✅ STRING variable | |
208
-
209
- #### Annotation `properties` matrix
210
-
211
- 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.
212
-
213
- 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`):
214
-
215
- | Node type | Accepted (scene) | Additionally rejected on instance child | Rejected in all contexts |
216
- |-----------|------------------|-----------------------------------------|--------------------------|
217
- | 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 |
218
- | 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 |
116
+ #### Instance-child matrix, annotation enum matrix, write tiers, probe, helpers
219
117
 
220
- `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.
221
-
222
- > **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.
223
-
224
- **Write policy (ordered tiers):**
225
-
226
- 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.
227
-
228
- 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 ✅.
229
- 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 🌐.
230
- 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 actual no-op (the property silently ignored the write or the override was rejected) and points to the source component as the correct write target. 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 📝.
231
-
232
- **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.
233
-
234
- **Pre-flight writability probe (#357).** Before showing the user the Definition write picker, call `CanICodeRoundtrip.probeDefinitionWritability(questions)` inside a small `use_figma` batch. The probe loads every distinct `sourceChildId` once and classifies it as writable or unwritable using the same detection as the runtime fallback (Experiment 10 `remote === true` and Experiment 11 unresolved-`null`). The result decides which version of the picker to show:
235
-
236
- ```javascript
237
- // Inside a use_figma batch:
238
- const probe = await CanICodeRoundtrip.probeDefinitionWritability(questions);
239
- return { events: [], probe };
240
- ```
241
-
242
- Branches on `probe`:
243
-
244
- - **`allUnwritable === true`** — every candidate source is in an external library (or unresolved). Opting in is structurally a no-op; every write would throw "Cannot write to internal and read-only node" and fall through to scene annotation anyway. Show the user a single-option picker:
245
-
246
- ```
247
- Definition write policy
248
-
249
- This file's source components live in an external library and are
250
- read-only from here ({unwritableSourceNames.join(", ")}). Tier 2
251
- propagation cannot fire — every "opt-in" write would fall through
252
- to a scene annotation regardless.
253
-
254
- ❯ 1. Annotate only (only viable option for this file)
255
- 2. Cancel — duplicate the library locally first to enable propagation
256
- ```
257
-
258
- Skip the opt-in branch entirely and call the helpers with the default `allowDefinitionWrite: false`.
259
-
260
- - **`partiallyUnwritable === true`** — some sources are local, some remote. Surface the split:
261
-
262
- ```
263
- Definition write policy
264
-
265
- {unwritableCount} of {totalCount} source components are remote
266
- (read-only) and will fall through to annotation; the remaining
267
- {totalCount - unwritableCount} are local and will propagate.
268
- Remote sources: {unwritableSourceNames.join(", ")}.
269
-
270
- Continue with allowDefinitionWrite: true?
271
- ```
272
-
273
- When confirmed, propagate to the local sources and let the helper's runtime fallback annotate the remote ones — the existing Experiment-10 retry path absorbs them without aborting the batch.
274
-
275
- - **`allUnwritable === false && partiallyUnwritable === false`** (the all-local / no-candidates case) — show the existing batch-level picker prose. No probe-driven adjustment needed.
276
-
277
- The probe is read-only and idempotent; running it before the picker adds one round-trip but saves the user a confusing "I opted in, why did I get annotations?" moment that #342 surfaced live on Simple Design System (Community).
278
-
279
- **Shared helpers (bundled)** — the deterministic helpers live in TypeScript at `src/core/roundtrip/*.ts` and are bundled to a single IIFE shipped next to this skill as `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.
280
-
281
- **Usage in a roundtrip session:**
282
-
283
- 1. Read `helpers.js` from the same directory as this skill once at the start of Step 4 — typically `.claude/skills/canicode-roundtrip/helpers.js` (Claude Code / default `canicode init`) or `.cursor/skills/canicode-roundtrip/helpers.js` (Cursor with `canicode init --cursor-skills`).
284
- 2. Prepend its contents verbatim at the top of every `use_figma` batch body — it registers a single global `CanICodeRoundtrip`.
285
- 3. Reference exposed globals as `CanICodeRoundtrip.*`:
286
- - `stripAnnotations(annotations)` — normalizes the D1 label/labelMarkdown mutex on readback.
287
- - `ensureCanicodeCategories()` — returns `{ gotcha, flag, fallback }` category id map (D4); idempotent, safe to call at the top of every batch. May also include `legacyAutoFix` when the file already carries the pre-#355 `canicode:auto-fix` category from earlier roundtrips — read-only on the canicode side, used only by Step 5 cleanup to sweep old annotations.
288
- - `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.
289
- - `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.
290
- - `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`.
291
- - `resolveVariableByName(name)` — local-variable exact-name lookup; returns `null` for remote library variables not imported into this file.
292
- - `probeDefinitionWritability(questions)` — async pre-flight (#357). Returns `{ totalCount, unwritableCount, unwritableSourceNames, allUnwritable, partiallyUnwritable }`. Use BEFORE the Definition write picker so the picker can drop the opt-in branch when every candidate is in an external library / unresolved (saves the user a wasted "I opted in, why did I get annotations?" decision). Read-only probe, dedupes by `sourceChildId`.
293
- - `extractAcknowledgmentsFromNode(node, canicodeCategoryIds?)` — synchronous pure helper (#371). Reads one node's annotations and returns `{ nodeId, ruleId }[]` for entries gated by canicode `categoryId` plus a recognisable `— *<ruleId>*` footer (or legacy `**[canicode] <ruleId>**` prefix). When `canicodeCategoryIds` is omitted, footer-text matching alone is sufficient (test mode).
294
- - `readCanicodeAcknowledgments(rootNodeId, categories?)` — async tree walker (#371). Loads `rootNodeId` via `figma.getNodeByIdAsync`, recurses through `children`, and accumulates one acknowledgment per recognised entry. Used at the top of Step 5a to harvest the side channel that lets the analysis pipeline distinguish "still broken" from "the designer has a plan" — pass the result straight to `analyze({ acknowledgments })`. Errors on individual nodes are swallowed so locked / external nodes don't abort the sweep.
295
- - `computeRoundtripTally({ stepFourReport, reanalyzeResponse })` — pure helper (#383). Takes the structured Step 4 outcome counts (`{ resolved, annotated, definitionWritten, skipped }`) plus a narrowed re-analyze view (`{ issueCount, acknowledgedCount }`) and returns `{ X, Y, Z, W, N, V, V_ack, V_open }`. Replaces the LLM-side emoji-bullet re-counting in Step 5 — render the returned object directly into the wrap-up templates. Throws when `acknowledgedCount > issueCount` (impossible state).
296
- - `applyAutoFix(issue, { categories, allowDefinitionWrite?, telemetry? })` — Strategy D entry point (#386). Branches on `targetProperty === "name" && suggestedName` once: renames the node via `applyWithInstanceFallback` (so naming auto-fixes share the same tier-1/2/3 policy as Strategy A) or writes a `categories.flag` annotation carrying `issue.message` and `issue.annotationProperties`. Returns one `AutoFixOutcome` (`{ outcome, nodeId, nodeName, ruleId, label }`) where `outcome` is `🔧` / `🌐` / `📝` so Step 4 can bump the structured `stepFourReport` counters without parsing prose. Replaces the inline JS the SKILL used to carry (per ADR-016).
297
- - `applyAutoFixes(issues, { categories, allowDefinitionWrite?, telemetry? })` — loop wrapper (#386). Filters `issues` to `applyStrategy === "auto-fix"` (skipped entries surface as `⏭️` outcomes for symmetry) and applies each one in sequence. Returns the full `AutoFixOutcome[]`.
298
- - `removeCanicodeAnnotations(annotations, categories)` — pure filter. Returns `annotations` with every canicode-authored entry removed (gates on `categories.gotcha` / `flag` / `fallback` / `legacyAutoFix` plus the legacy `**[canicode]` body prefix). Use after `stripAnnotations` in the Step 5 cleanup loop — replaces the inline filter predicate the SKILL used to carry. `isCanicodeAnnotation(annotation, categories)` is the single-entry version, exported for callers that need the predicate alone.
299
-
300
- 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.
301
-
302
- 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.
118
+ Full tables, Experiment 08/09 references, definition-write probe branches, and the bundled `CanICodeRoundtrip` API catalogue live in [`docs/roundtrip-protocol.md`](https://github.com/let-sunny/canicode/blob/main/docs/roundtrip-protocol.md) on `main`. Open it when you need the matrices or helper list do not re-derive write rules from memory (ADR-016).
303
119
 
304
120
  #### Strategy A: Property Modification — apply directly
305
121
 
@@ -330,50 +146,9 @@ Answer shape guide (LLM judgment — the user's answer is prose; parse according
330
146
 
331
147
  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.
332
148
 
333
- #### Strategy B: Structural Modification — confirm with user first
334
-
335
- Rules with `applyStrategy === "structural-mod"`. Show the proposed change and **ask for user confirmation** before applying.
336
-
337
- > **Instance-child guard (#368).** Strategy B mutations restructure the layer tree — `createComponentFromNode`, `flatten`, wrapper removal, instance-link reconnection. None of these compose safely with the Plugin API's instance-override rules: on a node where `question.isInstanceChild === true`, calling `createComponentFromNode` either throws *"Cannot create a component from a node inside an instance"* or detaches the parent instance entirely (the picked instance is replaced by a one-off frame, severing every existing override and propagation link). Restructuring deep-nested wrappers inside an instance child has the same risk surface — even when the call doesn't throw, the resulting structure cannot ride the source-component's propagation in future updates.
338
- >
339
- > Before showing the per-rule prompt below, check `question.isInstanceChild`. If it is true, **do not run the destructive call**. Surface this a/b prompt instead and default to **(a)**:
340
- >
341
- > ```
342
- > **{ruleId}** would normally restructure **{nodeName}** here, but this node lives inside instance **{instanceContext.parentInstanceNodeId}** of source component **{instanceContext.sourceComponentName or sourceComponentId or "unknown"}** (definition node `{instanceContext.sourceNodeId}`). On instance children Plugin API restructuring either fails outright or detaches the parent instance.
343
- >
344
- > ❯ a) Annotate the scene with a recommendation to apply the change on the source definition (safe — picks up via canicode-gotchas in code-gen, source designer can act on it later)
345
- > b) Detach the parent instance and attempt the restructuring on the resulting one-off frame (destructive — every existing instance override is lost and the node no longer rides the source component's propagation)
346
- > ```
347
- >
348
- > On **(a)**, route to Strategy C — call `upsertCanicodeAnnotation(scene, { ruleId: question.ruleId, markdown: "**Q:** … **A:** Apply on source definition `${instanceContext.sourceNodeId}` (`${instanceContext.sourceComponentName ?? "unknown"}`) — instance-child restructuring would detach the parent instance.", categoryId: categories.gotcha })`. Reference `instanceContext.sourceComponentName` and `instanceContext.sourceNodeId` in the body so the source designer can locate the target.
349
- >
350
- > On **(b)**, gate behind a second confirmation that explicitly names the side effects ("This will detach instance **{parentInstanceNodeId}** — all overrides on it will be lost and it will stop receiving updates from **{sourceComponentName}**. Type the parent instance name to confirm."). Only then execute the per-rule destructive call below.
351
- >
352
- > The same posture as ADR-012's `allowDefinitionWrite: false` default: instance-child structural mutations are off-by-default and require explicit user opt-in *per node*, not per batch — the destructive call here doesn't have a quiet fallback the way Strategy A's `applyWithInstanceFallback` does.
353
-
354
- **`non-layout-container`** — Convert Group/Section to Auto Layout frame:
355
- - Prompt: "I'll convert **{nodeName}** to an Auto Layout frame with {direction} layout and {spacing}px gap. Proceed?"
356
- - If confirmed: `applyPropertyMod(question, { layoutMode: "VERTICAL", itemSpacing: 12 })`.
357
-
358
- **`deep-nesting`** — Flatten intermediate wrappers or extract sub-component:
359
- - Prompt: "I'll flatten **{nodeName}** by {description from answer}. This changes the layer hierarchy. Proceed?"
360
- - Apply based on the specific answer (remove wrappers, convert padding, etc.).
361
-
362
- **`missing-component`** — Convert frame to reusable component:
363
- - Prompt: "I'll convert **{nodeName}** to a reusable component. Proceed?"
364
- - If confirmed:
365
- ```javascript
366
- const scene = await figma.getNodeByIdAsync(question.nodeId);
367
- if (scene && scene.type === "FRAME") {
368
- figma.createComponentFromNode(scene);
369
- }
370
- ```
371
-
372
- **`detached-instance`** — Reconnect to original component:
373
- - Prompt: "I'll reconnect **{nodeName}** to its original component. Any overrides will be preserved. Proceed?"
374
- - Requires finding the original component — if not identifiable, fall back to annotation.
149
+ #### Strategy B: Structural modification
375
150
 
376
- If the user **declines** any structural modification (or the instance-child guard above routes to **(a)**), add an annotation instead (same as Strategy C).
151
+ Instance-child guard and per-rule prompts **[Appendix Strategy B](https://github.com/let-sunny/canicode/blob/main/docs/roundtrip-protocol.md#appendix--strategy-b-structural-modification)**. Decline / guard Strategy C annotation.
377
152
 
378
153
  #### Strategy C: Annotation — record on the design for designer reference
379
154
 
@@ -418,7 +193,7 @@ const outcomes = await CanICodeRoundtrip.applyAutoFixes(analyzeResult.issues, {
418
193
  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.
419
194
  2. **Present structural modifications** (Strategy B) one by one, apply confirmed ones.
420
195
  3. **Batch all annotations** (Strategy C + declined structural mods) into a single `use_figma` call — use `categories.gotcha` for the category id.
421
- 4. **Batch all auto-fixes and annotations for lower-severity issues** (Strategy D) — use `categories.flag` for annotated ones (renamed from `autoFix` per #355 — the category means "flagged for designer attention", not "fixed"), `categories.fallback` is reserved for errors surfaced by `applyWithInstanceFallback` itself.
196
+ 4. **Batch all auto-fixes and annotations for lower-severity issues** (Strategy D) — use `categories.flag` for annotated ones (renamed from `autoFix` per #355 — the category means "flagged for designer attention", not "fixed"). `categories.fallback` from `applyWithInstanceFallback` is **only** the true ADR-012 path (annotate instead of propagating to a source definition); other helper annotate paths use `gotcha` or `flag` (#444).
422
197
 
423
198
  After applying, **emit a structured `stepFourReport`** alongside the human-readable per-question lines. Step 5 reads from this object — it does **not** re-parse the per-question lines (per ADR-016). Increment each counter as Strategy A/B/C/D complete:
424
199
 
@@ -433,6 +208,14 @@ Applied {N} changes to the Figma design:
433
208
  - 🔧 {nodeName}: auto-fixed to "Hover" (non-standard-naming)
434
209
  - 📝 {nodeName}: annotation added to canicode:flag — raw color needs token binding (raw-value)
435
210
 
211
+ After each emoji line above, mirror a **structured per-item row** so scene-write vs annotation fallback is visible every run (#435):
212
+
213
+ ```
214
+ {ruleId} @ {nodeName}
215
+ attempt: scene write (`question.targetProperty` / binding shape from answer)
216
+ result: {emoji outcome} ({short reason — e.g. silent-ignore ADR-012 → annotated, override-error → annotated, tier-2 propagated})
217
+ ```
218
+
436
219
  stepFourReport = {
437
220
  resolved: <count of ✅ + 🔧 + 🔗 lines>, // scene writes, auto-fix renames, variable bindings
438
221
  annotated: <count of 📝 lines>, // including ⏭️ declines that fell back to annotation
@@ -443,11 +226,15 @@ stepFourReport = {
443
226
 
444
227
  Hold `stepFourReport` in scope through Step 5 — it is the input to `CanICodeRoundtrip.computeRoundtripTally` below.
445
228
 
229
+ #### Auto-chain acknowledgments after apply (#440)
230
+
231
+ **After every Step 4 apply pass** (any Strategies A–D batch that ran), **do not wait for a separate user prompt** — in the **same session**, immediately run **Step 5a → Step 5b**: `readCanicodeAcknowledgments`, then `analyze({ input, acknowledgments })`. This is **not** conditional on the Step 4 summary containing a 📝 line: pure ✅ / 🔗 scene writes still need the re-analyze + tally for a consistent roundtrip report; when 📝 annotations exist, chaining is **mandatory** so REST analyze can see them — otherwise **`issueCount` stays flat** (`32 → 32`) even when gotchas were captured (#371). Emit the harvest + re-analyze before the conversational wrap-up so **`acknowledgedCount`** and `computeRoundtripTally` land in the **same** apply-summary response as the Step 4 totals.
232
+
446
233
  ### Step 5: Re-analyze and report what the roundtrip addressed
447
234
 
448
235
  #### Step 5a: Harvest canicode-authored annotations as acknowledgments (#371)
449
236
 
450
- Before re-running `analyze`, collect every `(nodeId, ruleId)` pair that Step 4 wrote as a Figma annotation. The REST API does not expose annotations, so this side channel is the only way the analysis pipeline learns that a roundtrip-touched issue is "the designer has a plan" rather than "still broken". Without it the grade and issue count look identical to the pre-roundtrip state — `32 → 32` — even when every gotcha has been captured per ADR-012.
237
+ Before re-running `analyze`, collect every `(nodeId, ruleId)` pair that Step 4 wrote as a Figma annotation. The REST API does not expose annotations, so this side channel is the only way the analysis pipeline learns that a roundtrip-touched issue is "the designer has a plan" rather than "still broken". Without it the **issue list** looks unchanged (`32 → 32` issues) — even when every gotcha has been captured per ADR-012.
451
238
 
452
239
  Run a short `use_figma` batch that walks the same subtree the original `analyze` covered (`targetNodeId` if you used one, else `figma.root.id`), reads canicode-categorised annotations, and serialises the result:
453
240
 
@@ -482,7 +269,7 @@ The response now carries:
482
269
  - `issues[i].acknowledged: true` (per matched issue) — survives into the report and downstream skills.
483
270
  - `summary` text — when `acknowledgedCount > 0`, the Total line reads `Total: N (A acknowledged via canicode annotations / N-A unaddressed)`.
484
271
 
485
- Under ADR-012's annotate-by-default policy, most instance-child gotchas route to 📝 annotations and do **not** move the numeric grade — but the half-weight density now produces a small visible movement when annotations are recognised. The headline for this step remains the **issues-delta** (what the roundtrip captured); grade movement is a secondary signal.
272
+ Under ADR-012's annotate-by-default policy, many writes become 📝 annotations. Treat **issues-delta + `acknowledgedCount`** as the headline success signal not grade movement (#423).
486
273
 
487
274
  **Tally** — call `CanICodeRoundtrip.computeRoundtripTally` with the structured `stepFourReport` you assembled in Step 4 and the re-analyze response from Step 5b. The helper handles every count derivation (`N = X + Y + Z + W`, `V_open = V - V_ack`) and validates that `acknowledgedCount` cannot exceed `issueCount`. Render the returned `{ X, Y, Z, W, N, V, V_ack, V_open }` straight into the templates below — do **not** re-derive any of these from the Step 4 prose:
488
275
 
@@ -498,7 +285,7 @@ const tally = CanICodeRoundtrip.computeRoundtripTally({
498
285
 
499
286
  If Step 4 produced no `stepFourReport` (e.g. user skipped every question, or no gotcha survey ran), pass an all-zero object — `tally.N === 0`, `tally.V_open === tally.V`, and the templates below render the breakdown with zeros rather than treating it as an error. (Skipping Step 5a and passing no `acknowledgments` argument is also valid in this case — the response simply has `acknowledgedCount: 0`.)
500
287
 
501
- **All gotcha issues resolved** (`V == 0`, i.e. re-analyze surfaces no remaining issues — note this is mostly independent of grade since ADR-012 annotations only move the score by the half-weight reduction enabled in Step 5b):
288
+ **All gotcha issues resolved** (`V == 0`, i.e. re-analyze surfaces no remaining issues):
502
289
  - Tell the user (fill in the counts from the tally above):
503
290
 
504
291
  ```
@@ -510,7 +297,7 @@ If Step 4 produced no `stepFourReport` (e.g. user skipped every question, or no
510
297
 
511
298
  V issues remaining (unresolved gotchas + non-actionable rules)
512
299
 
513
- Grade: {oldGrade} {newGrade}. Ready for code generation.
300
+ Ready for code generation. *(Optional:) Report still shows grade **{grade}** informational only.*
514
301
  ```
515
302
  - Clean up canicode annotations on fixed nodes via `use_figma`. Use the bundled `removeCanicodeAnnotations` helper — it gates on **categoryId** (the durable canicode-side identifier — the body no longer carries a `[canicode]` prefix per #353), includes `legacyAutoFix` if `ensureCanicodeCategories` returned it (pre-#355 `canicode:auto-fix` sweep), and also matches the legacy `**[canicode]` body prefix as a secondary marker for entries on files that have not been re-roundtripped yet. The match logic lives in `src/core/roundtrip/remove-canicode-annotations.ts` with vitest coverage so prose stays ADR-016-compliant:
516
303
  <!-- adr-016-ack: fan-out over an explicit small array of node IDs; the deterministic work lives inside removeCanicodeAnnotations -->
@@ -542,16 +329,16 @@ for (const id of nodeIds) {
542
329
  ↳ V_ack acknowledged via canicode annotations (carried into code-gen)
543
330
  ↳ V_open unaddressed (no annotation — your follow-up backlog)
544
331
 
545
- Grade: {oldGrade} → {newGrade}. Proceed to code generation with remaining context?
332
+ Proceed to code generation with remaining context? *(Optional footnote: report grade **{grade}**.)*
546
333
  ```
547
334
 
548
335
  When `V_ack == 0` (re-analyze returned `acknowledgedCount: 0`), keep the single `V issues remaining (unresolved gotchas + non-actionable rules)` line.
549
336
  - If yes → proceed to **Step 6** with remaining gotcha context.
550
- - If no → stop and emit the **Stop wrap-up** below; do **not** restate the grade as the lead.
337
+ - If no → stop and emit the **Stop wrap-up** below; lead with the delta, not grade.
551
338
 
552
339
  #### Wrap-up message rubric (Stop branch)
553
340
 
554
- When the user picks **Stop** here, the closing message is the *last thing the user sees of canicode* in this session. Keep the issues-delta as the headline (`✅ X / 📝 Y / 🌐 Z / ⏭️ W / V remaining`) grade movement, if any, belongs as a footnote line **after** the delta, not as the lead bullet. Reason: the value canicode delivers under the ADR-012 default is the annotation count carried into code-gen, not score movement (per [#341](https://github.com/let-sunny/canicode/issues/341), [#352](https://github.com/let-sunny/canicode/issues/352)).
341
+ When the user picks **Stop** here, the closing message is the *last thing the user sees of canicode* in this session. Keep the **issues-delta** as the headline (`✅ X / 📝 Y / 🌐 Z / ⏭️ W / V remaining`). Value delivered is **gotchas captured for code-gen** (#423). Optional single line: current report grade never lead with grade-only framing.
555
342
 
556
343
  ```
557
344
  Stopped — N issues addressed, V remaining for manual follow-up:
@@ -564,10 +351,10 @@ Stopped — N issues addressed, V remaining for manual follow-up:
564
351
  ↳ V_ack acknowledged via canicode annotations
565
352
  ↳ V_open unaddressed
566
353
 
567
- Grade: {oldGrade} {newGrade}.
354
+ *(Optional)* Report grade: **{grade}**.
568
355
  ```
569
356
 
570
- When `V_ack == 0`, drop the `↳` lines and leave a single `V remaining` row. Anti-pattern (do **not** lead with a grade-only sentence like "Grade: C → C+. Most size-constraint gotchas are now annotations…"). Lead with the delta block; mention grade once, on its own footnote line, plain prose only.
357
+ When `V_ack == 0`, drop the `↳` lines and leave a single `V remaining` row. Anti-pattern: leading with grade-only sentences. Lead with the delta block.
571
358
 
572
359
  ### Step 6: Implement with Figma MCP
573
360
 
@@ -585,7 +372,7 @@ Follow the **figma-implement-design** skill workflow to generate code from the F
585
372
 
586
373
  #### Wrap-up message rubric (post-handoff)
587
374
 
588
- After `figma-implement-design` returns, summarise the roundtrip in the same shape as the Step 5 / Stop wrap-up — issues-delta first, grade as a footnote, then the code-gen outcome. Do **not** lead with grade movement (per [#352](https://github.com/let-sunny/canicode/issues/352)):
375
+ After `figma-implement-design` returns, summarise the roundtrip in the same shape as the Step 5 / Stop wrap-up — issues-delta first, then code-gen outcome; grade at most one optional footline (#423).
589
376
 
590
377
  ```
591
378
  Roundtrip complete — N issues addressed, code generated:
@@ -598,21 +385,13 @@ Roundtrip complete — N issues addressed, code generated:
598
385
  ↳ V_ack acknowledged via canicode annotations
599
386
  ↳ V_open unaddressed
600
387
 
601
- Grade: {oldGrade} {newGrade}.
388
+ *(Optional)* Report grade: **{grade}**.
602
389
  Code: <files generated / next-step pointer from figma-implement-design>
603
390
  ```
604
391
 
605
392
  (Drop the `↳` lines when `V_ack == 0`.)
606
393
 
607
- ## Edge Cases
608
-
609
- - **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.
610
- - **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.
611
- - **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.
612
- - **User wants analysis only**: Suggest using `/canicode` instead — it runs analysis without the code generation phase.
613
- - **User wants gotcha survey only**: Suggest using `/canicode-gotchas` instead — it runs the survey and saves answers as a persistent skill file.
614
- - **Partial gotcha answers**: Apply only the answered questions. Skipped/n/a questions are neither applied nor annotated.
615
- - **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.
616
- - **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.
617
- - **Very large design (many gotchas)**: The gotcha survey already deduplicates sibling nodes and filters to blocking/risk plus `missing-info` from info-collection rules (#406). If there are still many questions, ask the user if they want to focus on blocking issues only.
618
- - **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. **The pre-flight `probeDefinitionWritability` (#357) detects both branches up-front** so the Definition write picker can drop the opt-in option entirely when every candidate is unwritable, saving the user a wasted decision before the runtime fallback kicks in.
394
+ ## Edge cases
395
+
396
+ Full list **[Appendix Edge Cases](https://github.com/let-sunny/canicode/blob/main/docs/roundtrip-protocol.md#appendix--edge-cases-full-list)**.
397
+