canicode 0.11.4 → 0.12.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.
@@ -106,9 +106,9 @@ Override score, severity, or enable/disable individual rules:
106
106
  | Rule ID | Default Score | Default Severity |
107
107
  |---------|--------------|-----------------|
108
108
  | `fixed-size-in-auto-layout` | -6 | risk |
109
- | `missing-size-constraint` | -1 | missing-info |
109
+ | `missing-size-constraint` | 0 | note |
110
110
 
111
- **Code Quality (4 rules)**
111
+ **Code Quality (5 rules)**
112
112
 
113
113
  | Rule ID | Default Score | Default Severity |
114
114
  |---------|--------------|-----------------|
@@ -116,6 +116,7 @@ Override score, severity, or enable/disable individual rules:
116
116
  | `missing-component` | -7 | risk |
117
117
  | `detached-instance` | -4 | risk |
118
118
  | `variant-structure-mismatch` | -6 | risk |
119
+ | `unmapped-component` | 0 | note |
119
120
 
120
121
  **Token Management (2 rules)**
121
122
 
@@ -136,8 +137,8 @@ Override score, severity, or enable/disable individual rules:
136
137
 
137
138
  | Rule ID | Default Score | Default Severity |
138
139
  |---------|--------------|-----------------|
139
- | `missing-interaction-state` | -1 | missing-info |
140
- | `missing-prototype` | -1 | missing-info |
140
+ | `missing-interaction-state` | 0 | note |
141
+ | `missing-prototype` | 0 | note |
141
142
  <!-- RULE_TABLE_END -->
142
143
 
143
144
  ### Example Configs
@@ -184,7 +185,7 @@ Configure the canicode MCP server so Cursor exposes `analyze`, `gotcha-survey`,
184
185
 
185
186
  ### Which MCP file affects which host?
186
187
 
187
- Two different JSON locations are easy to confuse because both can live in a git repo and both use an `mcpServers` object. (See GitHub #436.)
188
+ Two different JSON locations are easy to confuse because both can live in a git repo and both use an `mcpServers` object.
188
189
 
189
190
  | Path | Read by | Purpose |
190
191
  | --- | --- | --- |
@@ -215,14 +216,14 @@ Create or merge into `.cursor/mcp.json` in your repository root:
215
216
 
216
217
  Use long-form `--package` (short `-p` can confuse some parsers). Set your Figma token once with `npx canicode init --token figd_…` — the MCP server reads `~/.canicode/config.json`; you do not need `FIGMA_TOKEN` in the MCP block unless your team prefers env injection.
217
218
 
218
- ### Figma MCP server id and tool names in Cursor (#437)
219
+ ### Figma MCP server id and tool names in Cursor
219
220
 
220
221
  Cursor may show an MCP **server id** in the UI that is **not** literally the key you typed in `mcpServers` (for example a workspace or project prefix). Skills and docs often say “the `figma` MCP” or “call `use_figma`” as shorthand — **your session’s truth is the live tool list** after MCP reload (Cursor: MCP / Tools panel), not the string `figma` or `use_figma` read from a config file alone.
221
222
 
222
223
  - If roundtrip fails with “cannot find `use_figma`” but Figma MCP shows as connected, open the tool list and note the **exact** tool identifier Cursor exposes (it may be namespaced).
223
224
  - Keep the Figma MCP entry in `.cursor/mcp.json` (or `~/.cursor/mcp.json`) per [Figma’s MCP docs](https://developers.figma.com/docs/figma-mcp-server/) — then rely on the host-reported tool name when wiring skills or debugging.
224
225
 
225
- ### Post-install checklist (MCP + skills + Figma) (#461)
226
+ ### Post-install checklist (MCP + skills + Figma)
226
227
 
227
228
  After editing MCP JSON or running `canicode init`:
228
229
 
@@ -239,7 +240,7 @@ After editing MCP JSON or running `canicode init`:
239
240
 
240
241
  > **Deterministic invocation (both hosts):** the SKILL.md `description` fields advertise TRIGGER conditions so the model auto-routes Figma-URL prompts to the matching skill, but model routing is non-deterministic. When you want guaranteed routing, invoke explicitly — `/canicode <figma-url>`, `/canicode-gotchas <figma-url>`, or `/canicode-roundtrip <figma-url>` (Claude Code), or the equivalent slash-command path in Cursor where supported. Both `@`-mention and slash-command invocation skip the description-based router.
241
242
 
242
- ### Manual test checklist (#407)
243
+ ### Manual test checklist
243
244
 
244
245
  - [ ] MCP: Cursor shows `canicode` connected and the tools list includes `gotcha-survey` (and `analyze` if testing roundtrip Step 1).
245
246
  - [ ] Figma MCP: `use_figma` is available when testing **roundtrip** (install + restart host if tools are missing).
@@ -266,7 +267,7 @@ Work through these checks in order before concluding that an MCP server is broke
266
267
  See the Step 4 preflight block in [`docs/roundtrip-protocol.md`](https://github.com/let-sunny/canicode/blob/main/docs/roundtrip-protocol.md) and the corresponding preflight check in the `canicode-roundtrip` SKILL.md for the full prepend procedure.
267
268
 
268
269
  4. **Size / delivery — measure the code string before assuming a server bug.**
269
- If the `use_figma` call silently fails, truncates, or the host reports the tool payload is too large, the `helpers.js` bundle combined with the apply script may exceed the host's per-message or per-tool-call limit. Measure with `Buffer.byteLength(code, "utf8")` (Node) or `wc -c` (shell). If the string is too large for the chat/tool input, write it to a file and paste it into the MCP `use_figma` UI directly instead of relying on the model to pass the full string inline. See also the delivery notes in [#462](https://github.com/let-sunny/canicode/issues/462) and [`docs/roundtrip-protocol.md`](https://github.com/let-sunny/canicode/blob/main/docs/roundtrip-protocol.md).
270
+ If the `use_figma` call silently fails, truncates, or the host reports the tool payload is too large, the `helpers.js` bundle combined with the apply script may exceed the host's per-message or per-tool-call limit. Measure with `Buffer.byteLength(code, "utf8")` (Node) or `wc -c` (shell). If the string is too large for the chat/tool input, write it to a file and paste it into the MCP `use_figma` UI directly instead of relying on the model to pass the full string inline. See also the delivery notes in [`docs/roundtrip-protocol.md`](https://github.com/let-sunny/canicode/blob/main/docs/roundtrip-protocol.md).
270
271
 
271
272
  5. **Common symptom → likely cause mapping:**
272
273
  - `ReferenceError: CanICodeRoundtrip is not defined` → `helpers.js` was not prepended (check 3 above).
@@ -276,6 +277,47 @@ Work through these checks in order before concluding that an MCP server is broke
276
277
 
277
278
  ---
278
279
 
280
+ ## Other agents (manual install)
281
+
282
+ CanICode ships three AgentSkills-compatible skills via the npm package. For Claude Code and Cursor, `npx canicode init` (or `--cursor-skills`) handles install automatically. For other AgentSkills-compatible hosts, copy the skill folders into a directory your host scans.
283
+
284
+ This is best-effort documentation, not a support commitment — hosts named below are listed by their published scan paths, not because canicode is tested against them.
285
+
286
+ ### What ships in the npm package
287
+
288
+ After `npm install canicode` (or via `npx canicode`), the skill files live under `node_modules/canicode/skills/`:
289
+
290
+ - `skills/canicode/SKILL.md`
291
+ - `skills/canicode-gotchas/SKILL.md`
292
+ - `skills/canicode-roundtrip/SKILL.md`
293
+ - `skills/canicode-roundtrip/helpers.js`
294
+ - `skills/canicode-roundtrip/helpers-bootstrap.js`
295
+ - `skills/canicode-roundtrip/helpers-installer.js`
296
+ - `skills/canicode-roundtrip/canicode-roundtrip-helpers.d.ts`
297
+
298
+ These are produced by `pnpm bundle:skills` (`scripts/bundle-skills.sh`) and ship as part of the npm tarball via `package.json` `files`. The four `helpers-*` files in `canicode-roundtrip/` are required for the Step 4 Plugin API write path — copy the whole folder, don't cherry-pick.
299
+
300
+ The package also contains a `skills/cursor/` subtree, which is a Cursor-specific variant (gotchas SKILL.md is stripped via `strip-cursor-gotcha-skill.mjs`). Use the canonical `skills/canicode*` folders above for non-Cursor manual installs — `skills/cursor/canicode-gotchas/SKILL.md` is missing the gotchas section by design.
301
+
302
+ ### Install into any AgentSkills-compatible host
303
+
304
+ Copy each of the three skill folders (`canicode/`, `canicode-gotchas/`, `canicode-roundtrip/`) into a directory your host scans for skills.
305
+
306
+ | Host | Workspace path | User path |
307
+ | --- | --- | --- |
308
+ | Claude Code | `.claude/skills/` | `~/.claude/skills/` |
309
+ | Cursor | `.cursor/skills/` | `~/.cursor/skills/` |
310
+ | OpenClaw ([scan-path doc](https://github.com/openclaw/openclaw/blob/main/docs/tools/skills.md)) | `./skills/` or `./.agents/skills/` | `~/.agents/skills/` or `~/.openclaw/skills/` |
311
+ | Generic AgentSkills runtime | see your host's docs | see your host's docs |
312
+
313
+ > **Pro tip:** for Claude Code or Cursor specifically, run `npx canicode init` (Claude Code) or `npx canicode init --cursor-skills` (Cursor) instead — those flows handle the copy plus token setup, and you avoid having two copies of the skill folders side by side.
314
+
315
+ ### MCP server registration
316
+
317
+ Most AgentSkills-compatible hosts also need the canicode MCP server registered through their own MCP config. The [Cursor MCP (canicode)](#cursor-mcp-canicode) section above shows the `mcpServers` JSON shape — most runtimes accept the same shape in a different config file (consult your host's docs for the exact path). The skills can call `npx canicode …` directly without MCP registration, but `analyze` / `gotcha-survey` invoked as MCP tools need the server entry.
318
+
319
+ ---
320
+
279
321
  ## Telemetry
280
322
 
281
323
  CanICode collects anonymous usage analytics via [PostHog](https://posthog.com) and error tracking via [Sentry](https://sentry.io). This helps improve the tool by understanding which features are used and catching errors early.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canicode",
3
- "version": "0.11.4",
3
+ "version": "0.12.0",
4
4
  "mcpName": "io.github.let-sunny/canicode",
5
5
  "description": "Lint Figma designs for AI code-gen and roundtrip the answers back into the file. CLI + MCP server.",
6
6
  "type": "module",
@@ -3,8 +3,10 @@ name: canicode-roundtrip
3
3
  description: |
4
4
  Run the full design-to-code roundtrip — analyze a Figma design, surface gotchas, write
5
5
  designer answers back into the file via the Figma Plugin API (use_figma), re-analyze to
6
- confirm the gotchas were captured, then hand off to figma-implement-design. Mutates the
7
- Figma file: requires Figma full seat + edit access.
6
+ confirm the gotchas were captured, hand off to figma-implement-design, then optionally
7
+ register a Code Connect mapping pointing the Figma component at the just-generated code
8
+ so future roundtrips reuse the implementation. Mutates the Figma file: requires Figma
9
+ full seat + edit access.
8
10
 
9
11
  TRIGGER when: the user shares a figma.com/design/... URL and wants production-quality
10
12
  code, or asks for "design-to-code roundtrip", "fix the gotchas", or "annotate the Figma
@@ -84,6 +86,26 @@ Show the user a brief summary:
84
86
  Design grade: **{grade}** ({percentage}%) — {issueCount} issues found.
85
87
  ```
86
88
 
89
+ ### Step 1.5: Code Connect prerequisite pre-check (soft warn)
90
+
91
+ The closing step (Step 7) registers a Code Connect mapping for the just-implemented design, which requires the user's repo to have `@figma/code-connect` installed and `figma.config.json` present. Surfacing missing prerequisites *now* avoids the "spent 10 minutes on the survey, then found out I can't actually save the mapping" surprise.
92
+
93
+ Run `canicode doctor` (added in #513). If the host is running the bundled CLI:
94
+
95
+ ```bash
96
+ npx canicode doctor
97
+ ```
98
+
99
+ Branch on exit code:
100
+
101
+ - **Exit 0 (all checks pass)** — silent. Continue to Step 2.
102
+ - **Exit 1 (any check failed)** — print the doctor's remediation lines verbatim, then prompt:
103
+ > "Code Connect is not configured in this repo. The roundtrip will still generate code, but the closing mapping step (Step 7) will be skipped. Continue anyway? (Y/n)"
104
+ - **Y** (default) — proceed to Step 2. Remember the prereq state so Step 7 can short-circuit without re-explaining.
105
+ - **n** — stop the whole roundtrip cleanly. Tell the user: "Set up Code Connect and re-invoke `/canicode-roundtrip` when ready."
106
+
107
+ Default is `Y` because many users genuinely just want code generation today and have not chosen to adopt Code Connect yet — the soft warn informs without blocking.
108
+
87
109
  ### Step 2: Surface grade as informational banner
88
110
 
89
111
  Show the grade as a preamble banner — it is informational only, not a flow gate:
@@ -444,9 +466,13 @@ Follow the **figma-implement-design** skill workflow to generate code from the F
444
466
 
445
467
  **If all issues were resolved in Steps 4-5**, no additional gotcha context is needed — the design speaks for itself.
446
468
 
447
- #### Wrap-up message rubric (post-handoff)
469
+ After `figma-implement-design` returns, **proceed to Step 7** (Code Connect close-out). Step 7 also owns the final wrap-up message do not print the post-handoff wrap-up here. The post-handoff wrap-up rubric below is only used when Step 7 is skipped at its entry condition (see Step 7 — Entry condition).
470
+
471
+ #### Wrap-up message rubric (post-handoff, fallback only)
448
472
 
449
- 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).
473
+ Used **only when Step 7 is skipped at its entry condition** (e.g., the user invoked the roundtrip on a screen-level node, not a single component). Otherwise the Step 7 wrap-up below replaces this one.
474
+
475
+ 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).
450
476
 
451
477
  ```
452
478
  Roundtrip complete — N issues addressed, code generated:
@@ -465,6 +491,80 @@ Code: <files generated / next-step pointer from figma-implement-design>
465
491
 
466
492
  (Drop the `↳` lines when `V_ack == 0`.)
467
493
 
494
+ ### Step 7: Close out with a Code Connect mapping
495
+
496
+ Final step of the roundtrip. Registers a Code Connect mapping pointing the Figma component at the just-generated code so future roundtrips on screens containing this component reuse the implementation instead of regenerating markup. Step 7 owns the final wrap-up — when this step runs (whether it ends in mapped, skipped, or failed), use the wrap-up rubric at the end of this section instead of the Step 6 fallback.
497
+
498
+ #### Step 7 — Entry condition (single-component scope)
499
+
500
+ v1 only fires Step 7 when the roundtrip was invoked against a **single Figma main component**. Multi-component mapping for screen-level roundtrips is out of scope (#515 calls this out as v1.5).
501
+
502
+ To decide: read the analyze response from Step 1 — the top-level node's `type` (or equivalent in `get_design_context`) tells you whether it is `COMPONENT` / `COMPONENT_SET` (single-component scope, run Step 7) versus `FRAME` / `SECTION` / `INSTANCE` containing many descendants (screen-level, skip Step 7).
503
+
504
+ - **Single-component (COMPONENT / COMPONENT_SET)** — proceed to Step 7a.
505
+ - **Screen-level (anything else)** — print one line: "Roundtrip invoked on a screen-level node — Code Connect mapping is per-component. Re-invoke `/canicode-roundtrip` against an individual main component to register a mapping." Then fall back to the Step 6 post-handoff wrap-up rubric (the fallback rubric immediately above this section). Do not print the Step 7 wrap-up.
506
+
507
+ #### Step 7a: Re-check prerequisites
508
+
509
+ Re-run `canicode doctor`. The pre-check in Step 1.5 may have failed and the user may have set up Code Connect mid-flow, or it may have passed and still pass — either way, this is the source of truth for the close-out branch.
510
+
511
+ - **Exit 0** — proceed to 7b.
512
+ - **Exit 1** — print the doctor's remediation, then exit cleanly with:
513
+ > "Roundtrip steps 1–6 succeeded. Code Connect mapping skipped because the prerequisites above are missing — set them up and re-invoke `/canicode-roundtrip` to register the mapping."
514
+
515
+ This step's failure must not retroactively fail the earlier steps; their output is independently valuable.
516
+
517
+ #### Step 7b: Confirm satisfaction with the generated code
518
+
519
+ ```
520
+ figma-implement-design generated the code at <path>. Are you satisfied with this implementation? (y/N)
521
+ ```
522
+
523
+ Default is `N` — the inverse of Step 1.5's default — because registering a mapping makes a permanent claim about which code represents this Figma component. Asking the user to opt in deliberately is the safer posture.
524
+
525
+ - **N or skip** — exit cleanly: "Mapping not registered. You can re-invoke `/canicode-roundtrip` later if you want to map a future revision."
526
+ - **y** — continue to 7c.
527
+
528
+ If `figma-implement-design`'s output did not surface the generated code path in a structured way, prompt the user for it before proceeding. Do not try to scan for "recently modified files" — too fragile.
529
+
530
+ #### Step 7c: Check existing mapping
531
+
532
+ Call `get_code_connect_map` for the Figma component's node-id (from the original input URL).
533
+
534
+ - If a mapping **exists** for this component, show the user the current mapping target and ask:
535
+ > "A Code Connect mapping already exists for this component, pointing at `<existing-path>`. Update it to `<new-path>`? (y/N)"
536
+ - **N** — exit cleanly, leaving the existing mapping intact.
537
+ - **y** — proceed to 7d.
538
+ - If **no mapping exists**, proceed straight to 7d.
539
+
540
+ #### Step 7d: Register the mapping
541
+
542
+ Call `add_code_connect_map` with the Figma node-id + generated code path, then `send_code_connect_mappings` to publish.
543
+
544
+ On success, print:
545
+ > "Code Connect mapping registered: `<figma-component>` → `<code-path>`. Future roundtrips on screens containing this component will reuse the code."
546
+
547
+ On failure (Figma MCP returns an error), print the error verbatim and tell the user the rest of the roundtrip succeeded — the mapping can be added later via Figma CLI (`figma connect publish`) or by re-invoking the roundtrip.
548
+
549
+ #### Wrap-up message rubric (with mapping outcome)
550
+
551
+ Extend the Step 6 wrap-up block with one final line describing the mapping outcome — `✅ mapped`, `⏭️ skipped (user)`, `⏭️ skipped (prereq)`, or `❌ failed`. Keep grade at most one optional footline (#423).
552
+
553
+ ```
554
+ Roundtrip complete — N issues addressed, code generated, mapping <state>:
555
+ ✅ X resolved
556
+ 📝 Y annotated on Figma (referenced during code-gen)
557
+ 🌐 Z definition writes propagated
558
+ ⏭️ W skipped
559
+
560
+ V issues remaining
561
+ ↳ V_ack acknowledged via canicode annotations
562
+ ↳ V_open unaddressed
563
+
564
+ Code: <files generated / next-step pointer from figma-implement-design>
565
+ Code Connect: <mapping outcome line>
566
+ ```
567
+
468
568
  ## Edge cases
469
569
 
470
570
  Full list — **[Appendix Edge Cases](https://github.com/let-sunny/canicode/blob/main/docs/roundtrip-protocol.md#appendix--edge-cases-full-list)**.
@@ -6,7 +6,7 @@
6
6
  // globalThis.__canicodeBootstrapResult and throws ReferenceError so the agent re-prepends the
7
7
  // installer on the next batch.
8
8
  (function __canicodeBootstrap() {
9
- var expected = "0.11.4";
9
+ var expected = "0.12.0";
10
10
  var src = figma.root.getSharedPluginData("canicode", "helpersSrc");
11
11
  var actual = figma.root.getSharedPluginData("canicode", "helpersVersion");
12
12
  if (!src) {
@@ -3,7 +3,7 @@
3
3
  // figma.root via setSharedPluginData so subsequent batches can prepend the much smaller
4
4
  // helpers-bootstrap.js instead of re-pasting ~31KB every call (#424, ADR-020).
5
5
  var __CANICODE_HELPERS_SRC__ = "var CanICodeRoundtrip = (function (exports) {\n 'use strict';\n\n // src/core/roundtrip/annotations.ts\n function stripAnnotations(annotations) {\n const input = annotations ?? [];\n const out = [];\n for (const a of input) {\n const hasLM = typeof a.labelMarkdown === \"string\" && a.labelMarkdown.length > 0;\n const hasLabel = typeof a.label === \"string\" && a.label.length > 0;\n if (!hasLM && !hasLabel) continue;\n const base = hasLM ? { labelMarkdown: a.labelMarkdown } : { label: a.label };\n if (a.categoryId) base.categoryId = a.categoryId;\n if (Array.isArray(a.properties) && a.properties.length > 0) {\n base.properties = a.properties;\n }\n out.push(base);\n }\n return out;\n }\n async function ensureCanicodeCategories() {\n const api = figma.annotations;\n const existing = await api.getAnnotationCategoriesAsync();\n const byLabel = new Map(existing.map((c) => [c.label, c.id]));\n async function ensure(label, color) {\n const cached = byLabel.get(label);\n if (cached) return cached;\n const created = await api.addAnnotationCategoryAsync({ label, color });\n byLabel.set(label, created.id);\n return created.id;\n }\n const result = {\n gotcha: await ensure(\"canicode:gotcha\", \"blue\"),\n flag: await ensure(\"canicode:flag\", \"green\"),\n fallback: await ensure(\"canicode:fallback\", \"yellow\")\n };\n const legacyAutoFix = byLabel.get(\"canicode:auto-fix\");\n if (legacyAutoFix) result.legacyAutoFix = legacyAutoFix;\n return result;\n }\n function upsertCanicodeAnnotation(node, input) {\n if (!node || !(\"annotations\" in node)) return false;\n const { ruleId, markdown, categoryId, properties } = input;\n const legacyPrefix = `**[canicode] ${ruleId}**`;\n const footer = `\\u2014 *${ruleId}*`;\n let bodyText = markdown;\n if (bodyText.startsWith(legacyPrefix)) {\n bodyText = bodyText.slice(legacyPrefix.length).replace(/^\\s*\\n+/, \"\");\n }\n const trimmed = bodyText.replace(/\\s+$/, \"\");\n const body = trimmed.endsWith(footer) ? trimmed : `${trimmed}\n\n${footer}`;\n const existing = stripAnnotations(node.annotations);\n const entry = { labelMarkdown: body };\n if (categoryId) entry.categoryId = categoryId;\n if (properties && properties.length > 0) entry.properties = properties;\n const matchesRuleId = (text) => {\n if (typeof text !== \"string\") return false;\n return text.startsWith(legacyPrefix) || text.includes(footer);\n };\n const idx = existing.findIndex(\n (a) => matchesRuleId(a.labelMarkdown) || matchesRuleId(a.label)\n );\n if (idx >= 0) existing[idx] = entry;\n else existing.push(entry);\n try {\n node.annotations = existing;\n return true;\n } catch (e) {\n const msg = String(e?.message ?? e);\n const isNodeTypeReject = /invalid property .+ for a .+ node/i.test(msg);\n if (!entry.properties || !isNodeTypeReject) throw e;\n delete entry.properties;\n if (idx >= 0) existing[idx] = entry;\n node.annotations = existing;\n return true;\n }\n }\n\n // src/core/roundtrip/annotation-payload.ts\n var CANICODE_JSON_FENCE = \"```canicode-json\";\n function formatIntentValueForDisplay(value) {\n if (value === void 0) return \"undefined\";\n if (value === null) return \"null\";\n if (typeof value === \"object\") {\n try {\n return `\\`${JSON.stringify(value)}\\``;\n } catch {\n return String(value);\n }\n }\n return `\\`${String(value)}\\``;\n }\n function buildCodegenDirective(args) {\n const { sceneNodeId, intent } = args;\n const val = intent.value === void 0 ? \"undefined\" : JSON.stringify(intent.value);\n return `When generating code for node ${sceneNodeId}, set ${intent.field} to ${val} (user intent, scope: ${intent.scope}). Prefer this over the current Figma scene value when they disagree.`;\n }\n function sceneOutcomeToAck(result, reason) {\n return reason !== void 0 ? { result, reason } : { result };\n }\n function buildOutcomeHumanLine(args) {\n if (args.skippedDefinitionDueToAdr012) {\n const adrHint = \" Canicode skipped writing the source component without `allowDefinitionWrite: true` (ADR-012 safer default). The instance-level change did not apply as intended in the scene.\";\n if (args.reason === \"silent-ignore\") {\n return \"**Scene write outcome:** The write ran, but the property value did not change on this instance (silent-ignore).\" + adrHint;\n }\n return \"**Scene write outcome:** Figma rejected an instance-level change\" + (args.errorMessage ? `: ${args.errorMessage}` : \"\") + \".\" + adrHint;\n }\n if (args.reason === \"silent-ignore\") {\n return \"**Scene write outcome:** The write ran, but the property value did not change on this instance (silent-ignore). No source definition was available to escalate.\";\n }\n if (args.reason === \"override-error\") {\n return \"**Scene write outcome:** Figma rejected an instance-level change\" + (args.errorMessage ? `: ${args.errorMessage}` : \"\") + \". No source definition was available to escalate.\";\n }\n return \"**Scene write outcome:** Could not apply automatically\" + (args.errorMessage ? `: ${args.errorMessage}` : \"\") + \".\";\n }\n function buildAdr012PropagationParagraph(args) {\n const { componentName, replicaCount } = args;\n const fanOutHint = typeof replicaCount === \"number\" && replicaCount >= 2 ? ` This batched question covers ${replicaCount} instance scenes \\u2014 changing **${componentName}** at the definition still affects every inheriting instance, not just one row in the batch.` : \"\";\n return `Canicode's safer default (ADR-012) is to skip writing the source component **${componentName}** without explicit opt-in, because that write propagates to every non-overridden instance of **${componentName}** in the file.${fanOutHint} Prefer a manual override on **this** instance when you only need a local fix. Use \\`allowDefinitionWrite: true\\` only when you intend to change **${componentName}** for all inheriting instances \\u2014 it is not a neutral shortcut for a single-instance tweak.`;\n }\n function buildDefinitionWriteSkippedBody(args) {\n const {\n ruleId,\n sceneNodeId,\n componentName,\n reason,\n errorMessage,\n replicaCount,\n intent\n } = args;\n const ackIntent = intent ? {\n field: intent.field,\n value: intent.value,\n scope: intent.scope\n } : void 0;\n const sceneWriteOutcome = sceneOutcomeToAck(\"user-declined-propagation\", \"adr-012-opt-in-disabled\");\n const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;\n const jsonBlock = {\n v: 1,\n ruleId,\n nodeId: sceneNodeId,\n ...ackIntent ? { intent: ackIntent } : {},\n sceneWriteOutcome,\n ...codegenDirective ? { codegenDirective } : {}\n };\n const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;\n const outcomeLine = buildOutcomeHumanLine({\n reason,\n ...errorMessage !== void 0 ? { errorMessage } : {},\n skippedDefinitionDueToAdr012: true\n });\n const adrBlock = buildAdr012PropagationParagraph({\n componentName,\n ...replicaCount !== void 0 ? { replicaCount } : {}\n });\n const proseParts = [userAnswerLine, outcomeLine, adrBlock].filter(\n (p) => p !== null\n );\n const prose = proseParts.join(\"\\n\\n\");\n return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);\n }\n function buildNoDefinitionFallbackBody(args) {\n const { ruleId, sceneNodeId, reason, errorMessage, intent } = args;\n const ackIntent = intent ? { field: intent.field, value: intent.value, scope: intent.scope } : void 0;\n const outcomeResult = reason === \"silent-ignore\" ? \"silent-ignored\" : reason === \"override-error\" ? \"api-rejected\" : \"api-rejected\";\n const sceneWriteOutcome = sceneOutcomeToAck(\n outcomeResult,\n reason === \"silent-ignore\" ? \"silent-ignore-no-definition\" : \"no-definition-escalation\"\n );\n const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;\n const jsonBlock = {\n v: 1,\n ruleId,\n nodeId: sceneNodeId,\n ...ackIntent ? { intent: ackIntent } : {},\n sceneWriteOutcome,\n ...codegenDirective ? { codegenDirective } : {}\n };\n const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;\n const outcomeLine = buildOutcomeHumanLine({\n reason,\n ...errorMessage !== void 0 ? { errorMessage } : {},\n skippedDefinitionDueToAdr012: false\n });\n const prose = [userAnswerLine, outcomeLine].filter((p) => p !== null).join(\"\\n\\n\");\n return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);\n }\n function buildDefinitionTierFailureBody(args) {\n const { ruleId, sceneNodeId, intent, kind, errorMessage } = args;\n const sceneWriteOutcome = sceneOutcomeToAck(\n kind === \"read-only-library\" ? \"api-rejected\" : \"api-rejected\",\n kind === \"read-only-library\" ? \"definition-read-only\" : \"definition-write-failed\"\n );\n const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;\n const jsonBlock = {\n v: 1,\n ruleId,\n nodeId: sceneNodeId,\n ...intent ? {\n intent: {\n field: intent.field,\n value: intent.value,\n scope: intent.scope\n }\n } : {},\n sceneWriteOutcome,\n ...codegenDirective ? { codegenDirective } : {}\n };\n const human = kind === \"read-only-library\" ? \"source component lives in an external library and is read-only from this file \\u2014 apply the fix in the library file itself.\" : `could not apply at source definition: ${errorMessage}`;\n const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;\n const outcomeLine = `**Scene write outcome:** ${human}`;\n const prose = [userAnswerLine, outcomeLine].filter((p) => p !== null).join(\"\\n\\n\");\n return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);\n }\n function appendJsonFenceAndFooter(prose, jsonBlock, ruleId) {\n const footer = `\\u2014 *${ruleId}*`;\n const hasIntent = jsonBlock.intent !== void 0;\n if (!hasIntent) {\n return `${prose}\n\n${footer}`;\n }\n const jsonText = JSON.stringify(jsonBlock, null, 0);\n return `${prose}\n\n${CANICODE_JSON_FENCE}\n${jsonText}\n\\`\\`\\`\n\n${footer}`;\n }\n var FENCED_JSON_RE = new RegExp(\n `${CANICODE_JSON_FENCE.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}\\\\s*([\\\\s\\\\S]*?)\\\\s*\\`\\`\\``,\n \"m\"\n );\n function parseCanicodeJsonPayloadFromMarkdown(text) {\n const m = FENCED_JSON_RE.exec(text);\n if (!m?.[1]) return void 0;\n try {\n const raw = JSON.parse(m[1].trim());\n if (!raw || typeof raw !== \"object\") return void 0;\n const o = raw;\n if (o.v !== 1 || typeof o.ruleId !== \"string\") return void 0;\n return raw;\n } catch {\n return void 0;\n }\n }\n\n // src/core/roundtrip/apply-with-instance-fallback.ts\n var DEFINITION_WRITE_SKIPPED_EVENT = \"cic_roundtrip_definition_write_skipped\";\n function categoryIdForAnnotate(categories, kind, roundtripIntent) {\n if (kind === \"adr012-definition-skipped\") {\n return categories.fallback;\n }\n if (roundtripIntent !== void 0) {\n return categories.gotcha;\n }\n return categories.flag;\n }\n function resolveSourceComponentName(definition, question) {\n if (definition && typeof definition.name === \"string\" && definition.name) {\n return definition.name;\n }\n const ic = question.instanceContext;\n if (ic && typeof ic.sourceComponentName === \"string\" && ic.sourceComponentName) {\n return ic.sourceComponentName;\n }\n return \"the source component\";\n }\n async function routeToDefinitionOrAnnotate(definition, writeFn, ctx) {\n if (definition && !ctx.allowDefinitionWrite && ctx.reason !== \"non-override-error\") {\n const componentName = resolveSourceComponentName(definition, ctx.question);\n const replicaCount = typeof ctx.question.replicas === \"number\" && Number.isInteger(ctx.question.replicas) ? ctx.question.replicas : void 0;\n if (ctx.categories) {\n upsertCanicodeAnnotation(ctx.scene, {\n ruleId: ctx.question.ruleId,\n markdown: buildDefinitionWriteSkippedBody({\n ruleId: ctx.question.ruleId,\n sceneNodeId: ctx.scene.id,\n componentName,\n reason: ctx.reason,\n ...ctx.errorMessage !== void 0 ? { errorMessage: ctx.errorMessage } : {},\n ...replicaCount !== void 0 ? { replicaCount } : {},\n ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {}\n }),\n categoryId: categoryIdForAnnotate(\n ctx.categories,\n \"adr012-definition-skipped\",\n ctx.roundtripIntent\n )\n });\n }\n ctx.telemetry?.(DEFINITION_WRITE_SKIPPED_EVENT, {\n ruleId: ctx.question.ruleId,\n reason: ctx.reason\n });\n return {\n icon: \"\\u{1F4DD}\",\n label: \"definition write skipped (opt-in disabled)\"\n };\n }\n if (!definition) {\n if (ctx.categories) {\n const markdown = buildNoDefinitionFallbackBody({\n ruleId: ctx.question.ruleId,\n sceneNodeId: ctx.scene.id,\n reason: ctx.reason,\n ...ctx.errorMessage !== void 0 ? { errorMessage: ctx.errorMessage } : {},\n ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {}\n });\n upsertCanicodeAnnotation(ctx.scene, {\n ruleId: ctx.question.ruleId,\n markdown,\n categoryId: categoryIdForAnnotate(\n ctx.categories,\n \"other-failure\",\n ctx.roundtripIntent\n )\n });\n }\n return ctx.reason === \"silent-ignore\" ? { icon: \"\\u{1F4DD}\", label: \"silent-ignore, annotated\" } : { icon: \"\\u{1F4DD}\", label: `error: ${ctx.errorMessage ?? \"\"}` };\n }\n try {\n await writeFn(definition);\n return {\n icon: \"\\u{1F310}\",\n label: ctx.reason === \"silent-ignore\" ? \"source definition (silent-ignore fallback)\" : \"source definition\"\n };\n } catch (defErr) {\n const defMsg = String(defErr?.message ?? defErr);\n const isRemoteReadOnly = definition.remote === true || /read-only/i.test(defMsg);\n if (ctx.categories) {\n upsertCanicodeAnnotation(ctx.scene, {\n ruleId: ctx.question.ruleId,\n markdown: buildDefinitionTierFailureBody({\n ruleId: ctx.question.ruleId,\n sceneNodeId: ctx.scene.id,\n ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {},\n kind: isRemoteReadOnly ? \"read-only-library\" : \"definition-error\",\n errorMessage: defMsg\n }),\n categoryId: categoryIdForAnnotate(\n ctx.categories,\n \"other-failure\",\n ctx.roundtripIntent\n )\n });\n }\n return {\n icon: \"\\u{1F4DD}\",\n label: isRemoteReadOnly ? \"external library (read-only)\" : `definition error: ${defMsg}`\n };\n }\n }\n async function applyWithInstanceFallback(question, writeFn, context = {}) {\n const { categories, allowDefinitionWrite = false, telemetry, roundtripIntent } = context;\n const scene = await figma.getNodeByIdAsync(question.nodeId);\n if (!scene) return { icon: \"\\u{1F4DD}\", label: \"missing node\" };\n const definition = question.sourceChildId ? await figma.getNodeByIdAsync(question.sourceChildId) : null;\n try {\n const changed = await writeFn(scene);\n if (changed === false) {\n return routeToDefinitionOrAnnotate(definition, writeFn, {\n question,\n scene,\n categories,\n reason: \"silent-ignore\",\n allowDefinitionWrite,\n telemetry,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n });\n }\n return { icon: \"\\u2705\", label: \"instance/scene\" };\n } catch (e) {\n const msg = String(e?.message ?? e);\n const looksLikeInstanceOverride = /cannot be overridden/i.test(msg) || /override/i.test(msg);\n if (!looksLikeInstanceOverride) {\n return routeToDefinitionOrAnnotate(null, writeFn, {\n question,\n scene,\n categories,\n reason: \"non-override-error\",\n errorMessage: msg,\n allowDefinitionWrite,\n telemetry,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n });\n }\n return routeToDefinitionOrAnnotate(definition, writeFn, {\n question,\n scene,\n categories,\n reason: \"override-error\",\n errorMessage: msg,\n allowDefinitionWrite,\n telemetry,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n });\n }\n }\n\n // src/core/roundtrip/apply-property-mod.ts\n async function resolveVariableByName(name) {\n const locals = await figma.variables.getLocalVariablesAsync();\n return locals.find((v) => v.name === name) ?? null;\n }\n function parseValue(raw) {\n if (raw && typeof raw === \"object\" && \"variable\" in raw) {\n const v = raw;\n const parsed = { kind: \"binding\", name: v.variable };\n if (\"fallback\" in v) parsed.fallback = v.fallback;\n return parsed;\n }\n if (raw && typeof raw === \"object\" && \"fallback\" in raw) {\n return { kind: \"scalar\", scalar: raw.fallback };\n }\n return { kind: \"scalar\", scalar: raw };\n }\n function isPaintProp(prop) {\n return prop === \"fills\" || prop === \"strokes\";\n }\n function applyPropertyBinding(target, prop, variable) {\n if (isPaintProp(prop)) {\n const current = target[prop];\n if (current === figma.mixed || !Array.isArray(current)) return false;\n const paints = current;\n const bound = paints.map(\n (paint) => figma.variables.setBoundVariableForPaint(paint, \"color\", variable)\n );\n target[prop] = bound;\n return true;\n }\n target.setBoundVariable(prop, variable);\n return true;\n }\n function buildRoundtripIntentFromPropertyAnswer(question, answerValue) {\n const raw = question.targetProperty;\n if (raw === void 0) return void 0;\n const props = Array.isArray(raw) ? raw : [raw];\n if (props.length === 0) return void 0;\n if (props.length === 1) {\n const prop = props[0];\n const perProp = answerValue && typeof answerValue === \"object\" && !(\"variable\" in answerValue) && !Array.isArray(answerValue) ? answerValue[prop] : answerValue;\n const parsed = parseValueForIntent(perProp);\n if (parsed === void 0) return void 0;\n return { field: prop, value: parsed, scope: \"instance\" };\n }\n const obj = answerValue && typeof answerValue === \"object\" && !(\"variable\" in answerValue) && !Array.isArray(answerValue) ? answerValue : void 0;\n const picked = {};\n for (const p of props) {\n if (obj && p in obj && obj[p] !== void 0) picked[p] = obj[p];\n }\n if (Object.keys(picked).length === 0) return void 0;\n return {\n field: props.join(\", \"),\n value: picked,\n scope: \"instance\"\n };\n }\n function parseValueForIntent(raw) {\n if (raw && typeof raw === \"object\" && \"variable\" in raw) {\n return { variable: raw.variable };\n }\n if (raw && typeof raw === \"object\" && \"fallback\" in raw) {\n return raw.fallback;\n }\n return raw;\n }\n function applyPropertyScalar(target, prop, scalar) {\n const rec = target;\n const before = rec[prop];\n rec[prop] = scalar;\n if (rec[prop] === before && before !== scalar) return false;\n return true;\n }\n async function applyPropertyMod(question, answerValue, context = {}) {\n const roundtripIntent = buildRoundtripIntentFromPropertyAnswer(\n question,\n answerValue\n );\n const props = Array.isArray(question.targetProperty) ? question.targetProperty : question.targetProperty !== void 0 ? [question.targetProperty] : [];\n return applyWithInstanceFallback(\n question,\n async (target) => {\n if (!target) return void 0;\n let changed = void 0;\n for (const prop of props) {\n if (!(prop in target)) continue;\n const perProp = answerValue && typeof answerValue === \"object\" && !(\"variable\" in answerValue) && !Array.isArray(answerValue) ? answerValue[prop] : answerValue;\n const parsed = parseValue(perProp);\n if (parsed.kind === \"binding\") {\n const variable = await resolveVariableByName(parsed.name);\n if (variable) {\n applyPropertyBinding(target, prop, variable);\n continue;\n }\n if (parsed.fallback !== void 0) {\n if (!applyPropertyScalar(target, prop, parsed.fallback)) {\n changed = false;\n }\n }\n continue;\n }\n if (parsed.scalar === void 0) continue;\n if (!applyPropertyScalar(target, prop, parsed.scalar)) {\n changed = false;\n }\n }\n return changed;\n },\n {\n ...context,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n }\n );\n }\n\n // src/core/roundtrip/probe-definition-writability.ts\n async function probeDefinitionWritability(questions) {\n const verdict = /* @__PURE__ */ new Map();\n const unwritableNames = [];\n const seenName = /* @__PURE__ */ new Set();\n for (const q of questions) {\n const id = q.sourceChildId;\n if (!id) continue;\n if (verdict.has(id)) continue;\n const node = await figma.getNodeByIdAsync(id);\n const writability = resolveWritability(node);\n const isUnwritable = writability.isUnwritable;\n verdict.set(id, isUnwritable ? \"unwritable\" : \"writable\");\n if (isUnwritable) {\n const name = typeof writability.componentName === \"string\" && writability.componentName || typeof node?.name === \"string\" && node.name || q.instanceContext?.sourceComponentName || id;\n if (!seenName.has(name)) {\n seenName.add(name);\n unwritableNames.push(name);\n }\n }\n }\n const totalCount = verdict.size;\n let unwritableCount = 0;\n for (const v of verdict.values()) if (v === \"unwritable\") unwritableCount++;\n return {\n totalCount,\n unwritableCount,\n unwritableSourceNames: unwritableNames,\n allUnwritable: totalCount > 0 && unwritableCount === totalCount,\n partiallyUnwritable: unwritableCount > 0 && unwritableCount < totalCount\n };\n }\n function resolveWritability(node) {\n if (node === null) return { isUnwritable: true };\n if (\"remote\" in node && typeof node.remote === \"boolean\") {\n return { isUnwritable: node.remote === true };\n }\n const containing = findContainingComponent(node);\n if (!containing) {\n return { isUnwritable: false };\n }\n const isUnwritable = \"remote\" in containing && containing.remote === true;\n return {\n isUnwritable,\n ...isUnwritable && typeof containing.name === \"string\" ? { componentName: containing.name } : {}\n };\n }\n function findContainingComponent(node) {\n let cur = node;\n for (let i = 0; i < 100 && cur; i++) {\n if (cur.type === \"COMPONENT\" || cur.type === \"COMPONENT_SET\") return cur;\n cur = cur.parent ?? null;\n }\n return null;\n }\n\n // src/core/roundtrip/read-acknowledgments.ts\n var FOOTER_RE = /—\\s+\\*([A-Za-z0-9-]+)\\*\\s*$/;\n var LEGACY_PREFIX_RE = /^\\*\\*\\[canicode\\]\\s+([A-Za-z0-9-]+)\\*\\*/;\n function extractAcknowledgmentsFromNode(node, canicodeCategoryIds) {\n if (!node || !(\"annotations\" in node)) return [];\n const annotations = node.annotations ?? [];\n if (annotations.length === 0) return [];\n const out = [];\n for (const a of annotations) {\n const text = (typeof a.labelMarkdown === \"string\" && a.labelMarkdown.length > 0 ? a.labelMarkdown : \"\") || (typeof a.label === \"string\" && a.label.length > 0 ? a.label : \"\");\n if (!text) continue;\n if (canicodeCategoryIds) {\n if (!a.categoryId || !canicodeCategoryIds.has(a.categoryId)) continue;\n }\n const ruleId = extractRuleId(text);\n if (!ruleId) continue;\n const payload = parseCanicodeJsonPayloadFromMarkdown(text);\n const payloadAligned = payload && payload.ruleId === ruleId;\n out.push({\n nodeId: node.id,\n ruleId,\n ...payloadAligned && payload.intent ? { intent: payload.intent } : {},\n ...payloadAligned && payload.sceneWriteOutcome ? { sceneWriteOutcome: payload.sceneWriteOutcome } : {},\n ...payloadAligned && payload.codegenDirective ? { codegenDirective: payload.codegenDirective } : {}\n });\n }\n return out;\n }\n function extractRuleId(text) {\n const footer = FOOTER_RE.exec(text);\n if (footer) return footer[1] ?? null;\n const legacy = LEGACY_PREFIX_RE.exec(text);\n if (legacy) return legacy[1] ?? null;\n return null;\n }\n async function readCanicodeAcknowledgments(rootNodeId, categories) {\n const root = await figma.getNodeByIdAsync(rootNodeId);\n if (!root) return [];\n const canicodeCategoryIds = categories ? new Set(\n [\n categories.gotcha,\n categories.flag,\n categories.fallback,\n categories.legacyAutoFix\n ].filter((id) => typeof id === \"string\" && id.length > 0)\n ) : void 0;\n const out = [];\n walk(root, canicodeCategoryIds, out);\n return out;\n }\n function safeChildren(node) {\n try {\n const c = node.children;\n return Array.isArray(c) ? c : [];\n } catch {\n return [];\n }\n }\n function walk(node, canicodeCategoryIds, out) {\n try {\n const local = extractAcknowledgmentsFromNode(node, canicodeCategoryIds);\n for (const a of local) out.push(a);\n } catch {\n }\n for (const child of safeChildren(node)) {\n if (child && typeof child === \"object\") walk(child, canicodeCategoryIds, out);\n }\n }\n\n // src/core/roundtrip/compute-roundtrip-tally.ts\n function computeRoundtripTally(args) {\n const { stepFourReport, reanalyzeResponse } = args;\n const { resolved, annotated, definitionWritten, skipped } = stepFourReport;\n const { issueCount, acknowledgedCount } = reanalyzeResponse;\n if (acknowledgedCount > issueCount) {\n throw new Error(\n `computeRoundtripTally: reanalyzeResponse.acknowledgedCount (${acknowledgedCount}) cannot exceed issueCount (${issueCount}). Acknowledged issues are a subset of remaining issues.`\n );\n }\n return {\n X: resolved,\n Y: annotated,\n Z: definitionWritten,\n W: skipped,\n N: resolved + annotated + definitionWritten + skipped,\n V: issueCount,\n V_ack: acknowledgedCount,\n V_open: issueCount - acknowledgedCount\n };\n }\n\n // src/core/roundtrip/apply-auto-fix.ts\n function pickNodeName(issue, resolved) {\n if (resolved && typeof resolved.name === \"string\" && resolved.name.length > 0) {\n return resolved.name;\n }\n if (typeof issue.nodePath === \"string\" && issue.nodePath.length > 0) {\n const segments = issue.nodePath.split(/\\s*[›>/]\\s*/);\n const tail = segments[segments.length - 1];\n if (tail && tail.length > 0) return tail;\n }\n return issue.nodeId;\n }\n function mapInstanceFallbackIcon(result) {\n if (result.icon === \"\\u2705\") return \"\\u{1F527}\";\n return result.icon;\n }\n async function applyAutoFix(issue, context) {\n const { categories } = context;\n const ruleId = issue.ruleId;\n if (issue.targetProperty === \"name\" && typeof issue.suggestedName === \"string\") {\n const suggestedName = issue.suggestedName;\n const question = {\n nodeId: issue.nodeId,\n ruleId,\n ...issue.sourceChildId ? { sourceChildId: issue.sourceChildId } : {}\n };\n const result = await applyWithInstanceFallback(\n question,\n (target) => {\n if (target) {\n target.name = suggestedName;\n }\n },\n {\n categories,\n ...context.allowDefinitionWrite !== void 0 ? { allowDefinitionWrite: context.allowDefinitionWrite } : {},\n ...context.telemetry !== void 0 ? { telemetry: context.telemetry } : {}\n }\n );\n const sceneAfter = await figma.getNodeByIdAsync(issue.nodeId);\n return {\n outcome: mapInstanceFallbackIcon(result),\n nodeId: issue.nodeId,\n nodeName: pickNodeName(issue, sceneAfter),\n ruleId,\n label: result.label\n };\n }\n const scene = await figma.getNodeByIdAsync(issue.nodeId);\n const markdown = issue.message ?? `Auto-flagged: ${ruleId}`;\n if (scene) {\n upsertCanicodeAnnotation(scene, {\n ruleId,\n markdown,\n categoryId: categories.flag,\n ...issue.annotationProperties && issue.annotationProperties.length > 0 ? { properties: issue.annotationProperties } : {}\n });\n }\n return {\n outcome: \"\\u{1F4DD}\",\n nodeId: issue.nodeId,\n nodeName: pickNodeName(issue, scene),\n ruleId,\n label: scene ? `annotation added to canicode:flag \\u2014 ${ruleId}` : `missing node (annotation skipped) \\u2014 ${ruleId}`\n };\n }\n async function applyAutoFixes(issues, context) {\n const out = [];\n for (const issue of issues) {\n if (issue.applyStrategy !== \"auto-fix\") {\n out.push({\n outcome: \"\\u23ED\\uFE0F\",\n nodeId: issue.nodeId,\n nodeName: pickNodeName(issue, null),\n ruleId: issue.ruleId,\n label: `skipped \\u2014 applyStrategy is ${issue.applyStrategy ?? \"absent\"}`\n });\n continue;\n }\n out.push(await applyAutoFix(issue, context));\n }\n return out;\n }\n\n // src/core/roundtrip/remove-canicode-annotations.ts\n var LEGACY_CANICODE_PREFIX = \"**[canicode]\";\n function isCanicodeAnnotation(annotation, categories) {\n const canicodeIds = new Set(\n [\n categories.gotcha,\n categories.flag,\n categories.fallback,\n categories.legacyAutoFix\n ].filter((id) => Boolean(id))\n );\n if (annotation.categoryId && canicodeIds.has(annotation.categoryId)) {\n return true;\n }\n if (annotation.labelMarkdown?.startsWith(LEGACY_CANICODE_PREFIX)) {\n return true;\n }\n return false;\n }\n function removeCanicodeAnnotations(annotations, categories) {\n return annotations.filter((a) => !isCanicodeAnnotation(a, categories));\n }\n\n exports.applyAutoFix = applyAutoFix;\n exports.applyAutoFixes = applyAutoFixes;\n exports.applyPropertyMod = applyPropertyMod;\n exports.applyWithInstanceFallback = applyWithInstanceFallback;\n exports.computeRoundtripTally = computeRoundtripTally;\n exports.ensureCanicodeCategories = ensureCanicodeCategories;\n exports.extractAcknowledgmentsFromNode = extractAcknowledgmentsFromNode;\n exports.isCanicodeAnnotation = isCanicodeAnnotation;\n exports.probeDefinitionWritability = probeDefinitionWritability;\n exports.readCanicodeAcknowledgments = readCanicodeAcknowledgments;\n exports.removeCanicodeAnnotations = removeCanicodeAnnotations;\n exports.resolveVariableByName = resolveVariableByName;\n exports.stripAnnotations = stripAnnotations;\n exports.upsertCanicodeAnnotation = upsertCanicodeAnnotation;\n\n return exports;\n\n})({});\n";
6
- var __CANICODE_HELPERS_VERSION__ = "0.11.4";
6
+ var __CANICODE_HELPERS_VERSION__ = "0.12.0";
7
7
  (0, eval)(__CANICODE_HELPERS_SRC__);
8
8
  try {
9
9
  figma.root.setSharedPluginData("canicode", "helpersSrc", __CANICODE_HELPERS_SRC__);
@@ -3,8 +3,10 @@ name: canicode-roundtrip
3
3
  description: |
4
4
  Run the full design-to-code roundtrip — analyze a Figma design, surface gotchas, write
5
5
  designer answers back into the file via the Figma Plugin API (use_figma), re-analyze to
6
- confirm the gotchas were captured, then hand off to figma-implement-design. Mutates the
7
- Figma file: requires Figma full seat + edit access.
6
+ confirm the gotchas were captured, hand off to figma-implement-design, then optionally
7
+ register a Code Connect mapping pointing the Figma component at the just-generated code
8
+ so future roundtrips reuse the implementation. Mutates the Figma file: requires Figma
9
+ full seat + edit access.
8
10
 
9
11
  TRIGGER when: the user shares a figma.com/design/... URL and wants production-quality
10
12
  code, or asks for "design-to-code roundtrip", "fix the gotchas", or "annotate the Figma
@@ -84,6 +86,26 @@ Show the user a brief summary:
84
86
  Design grade: **{grade}** ({percentage}%) — {issueCount} issues found.
85
87
  ```
86
88
 
89
+ ### Step 1.5: Code Connect prerequisite pre-check (soft warn)
90
+
91
+ The closing step (Step 7) registers a Code Connect mapping for the just-implemented design, which requires the user's repo to have `@figma/code-connect` installed and `figma.config.json` present. Surfacing missing prerequisites *now* avoids the "spent 10 minutes on the survey, then found out I can't actually save the mapping" surprise.
92
+
93
+ Run `canicode doctor` (added in #513). If the host is running the bundled CLI:
94
+
95
+ ```bash
96
+ npx canicode doctor
97
+ ```
98
+
99
+ Branch on exit code:
100
+
101
+ - **Exit 0 (all checks pass)** — silent. Continue to Step 2.
102
+ - **Exit 1 (any check failed)** — print the doctor's remediation lines verbatim, then prompt:
103
+ > "Code Connect is not configured in this repo. The roundtrip will still generate code, but the closing mapping step (Step 7) will be skipped. Continue anyway? (Y/n)"
104
+ - **Y** (default) — proceed to Step 2. Remember the prereq state so Step 7 can short-circuit without re-explaining.
105
+ - **n** — stop the whole roundtrip cleanly. Tell the user: "Set up Code Connect and re-invoke `/canicode-roundtrip` when ready."
106
+
107
+ Default is `Y` because many users genuinely just want code generation today and have not chosen to adopt Code Connect yet — the soft warn informs without blocking.
108
+
87
109
  ### Step 2: Surface grade as informational banner
88
110
 
89
111
  Show the grade as a preamble banner — it is informational only, not a flow gate:
@@ -444,9 +466,13 @@ Follow the **figma-implement-design** skill workflow to generate code from the F
444
466
 
445
467
  **If all issues were resolved in Steps 4-5**, no additional gotcha context is needed — the design speaks for itself.
446
468
 
447
- #### Wrap-up message rubric (post-handoff)
469
+ After `figma-implement-design` returns, **proceed to Step 7** (Code Connect close-out). Step 7 also owns the final wrap-up message do not print the post-handoff wrap-up here. The post-handoff wrap-up rubric below is only used when Step 7 is skipped at its entry condition (see Step 7 — Entry condition).
470
+
471
+ #### Wrap-up message rubric (post-handoff, fallback only)
448
472
 
449
- 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).
473
+ Used **only when Step 7 is skipped at its entry condition** (e.g., the user invoked the roundtrip on a screen-level node, not a single component). Otherwise the Step 7 wrap-up below replaces this one.
474
+
475
+ 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).
450
476
 
451
477
  ```
452
478
  Roundtrip complete — N issues addressed, code generated:
@@ -465,6 +491,80 @@ Code: <files generated / next-step pointer from figma-implement-design>
465
491
 
466
492
  (Drop the `↳` lines when `V_ack == 0`.)
467
493
 
494
+ ### Step 7: Close out with a Code Connect mapping
495
+
496
+ Final step of the roundtrip. Registers a Code Connect mapping pointing the Figma component at the just-generated code so future roundtrips on screens containing this component reuse the implementation instead of regenerating markup. Step 7 owns the final wrap-up — when this step runs (whether it ends in mapped, skipped, or failed), use the wrap-up rubric at the end of this section instead of the Step 6 fallback.
497
+
498
+ #### Step 7 — Entry condition (single-component scope)
499
+
500
+ v1 only fires Step 7 when the roundtrip was invoked against a **single Figma main component**. Multi-component mapping for screen-level roundtrips is out of scope (#515 calls this out as v1.5).
501
+
502
+ To decide: read the analyze response from Step 1 — the top-level node's `type` (or equivalent in `get_design_context`) tells you whether it is `COMPONENT` / `COMPONENT_SET` (single-component scope, run Step 7) versus `FRAME` / `SECTION` / `INSTANCE` containing many descendants (screen-level, skip Step 7).
503
+
504
+ - **Single-component (COMPONENT / COMPONENT_SET)** — proceed to Step 7a.
505
+ - **Screen-level (anything else)** — print one line: "Roundtrip invoked on a screen-level node — Code Connect mapping is per-component. Re-invoke `/canicode-roundtrip` against an individual main component to register a mapping." Then fall back to the Step 6 post-handoff wrap-up rubric (the fallback rubric immediately above this section). Do not print the Step 7 wrap-up.
506
+
507
+ #### Step 7a: Re-check prerequisites
508
+
509
+ Re-run `canicode doctor`. The pre-check in Step 1.5 may have failed and the user may have set up Code Connect mid-flow, or it may have passed and still pass — either way, this is the source of truth for the close-out branch.
510
+
511
+ - **Exit 0** — proceed to 7b.
512
+ - **Exit 1** — print the doctor's remediation, then exit cleanly with:
513
+ > "Roundtrip steps 1–6 succeeded. Code Connect mapping skipped because the prerequisites above are missing — set them up and re-invoke `/canicode-roundtrip` to register the mapping."
514
+
515
+ This step's failure must not retroactively fail the earlier steps; their output is independently valuable.
516
+
517
+ #### Step 7b: Confirm satisfaction with the generated code
518
+
519
+ ```
520
+ figma-implement-design generated the code at <path>. Are you satisfied with this implementation? (y/N)
521
+ ```
522
+
523
+ Default is `N` — the inverse of Step 1.5's default — because registering a mapping makes a permanent claim about which code represents this Figma component. Asking the user to opt in deliberately is the safer posture.
524
+
525
+ - **N or skip** — exit cleanly: "Mapping not registered. You can re-invoke `/canicode-roundtrip` later if you want to map a future revision."
526
+ - **y** — continue to 7c.
527
+
528
+ If `figma-implement-design`'s output did not surface the generated code path in a structured way, prompt the user for it before proceeding. Do not try to scan for "recently modified files" — too fragile.
529
+
530
+ #### Step 7c: Check existing mapping
531
+
532
+ Call `get_code_connect_map` for the Figma component's node-id (from the original input URL).
533
+
534
+ - If a mapping **exists** for this component, show the user the current mapping target and ask:
535
+ > "A Code Connect mapping already exists for this component, pointing at `<existing-path>`. Update it to `<new-path>`? (y/N)"
536
+ - **N** — exit cleanly, leaving the existing mapping intact.
537
+ - **y** — proceed to 7d.
538
+ - If **no mapping exists**, proceed straight to 7d.
539
+
540
+ #### Step 7d: Register the mapping
541
+
542
+ Call `add_code_connect_map` with the Figma node-id + generated code path, then `send_code_connect_mappings` to publish.
543
+
544
+ On success, print:
545
+ > "Code Connect mapping registered: `<figma-component>` → `<code-path>`. Future roundtrips on screens containing this component will reuse the code."
546
+
547
+ On failure (Figma MCP returns an error), print the error verbatim and tell the user the rest of the roundtrip succeeded — the mapping can be added later via Figma CLI (`figma connect publish`) or by re-invoking the roundtrip.
548
+
549
+ #### Wrap-up message rubric (with mapping outcome)
550
+
551
+ Extend the Step 6 wrap-up block with one final line describing the mapping outcome — `✅ mapped`, `⏭️ skipped (user)`, `⏭️ skipped (prereq)`, or `❌ failed`. Keep grade at most one optional footline (#423).
552
+
553
+ ```
554
+ Roundtrip complete — N issues addressed, code generated, mapping <state>:
555
+ ✅ X resolved
556
+ 📝 Y annotated on Figma (referenced during code-gen)
557
+ 🌐 Z definition writes propagated
558
+ ⏭️ W skipped
559
+
560
+ V issues remaining
561
+ ↳ V_ack acknowledged via canicode annotations
562
+ ↳ V_open unaddressed
563
+
564
+ Code: <files generated / next-step pointer from figma-implement-design>
565
+ Code Connect: <mapping outcome line>
566
+ ```
567
+
468
568
  ## Edge cases
469
569
 
470
570
  Full list — **[Appendix Edge Cases](https://github.com/let-sunny/canicode/blob/main/docs/roundtrip-protocol.md#appendix--edge-cases-full-list)**.
@@ -6,7 +6,7 @@
6
6
  // globalThis.__canicodeBootstrapResult and throws ReferenceError so the agent re-prepends the
7
7
  // installer on the next batch.
8
8
  (function __canicodeBootstrap() {
9
- var expected = "0.11.4";
9
+ var expected = "0.12.0";
10
10
  var src = figma.root.getSharedPluginData("canicode", "helpersSrc");
11
11
  var actual = figma.root.getSharedPluginData("canicode", "helpersVersion");
12
12
  if (!src) {
@@ -3,7 +3,7 @@
3
3
  // figma.root via setSharedPluginData so subsequent batches can prepend the much smaller
4
4
  // helpers-bootstrap.js instead of re-pasting ~31KB every call (#424, ADR-020).
5
5
  var __CANICODE_HELPERS_SRC__ = "var CanICodeRoundtrip = (function (exports) {\n 'use strict';\n\n // src/core/roundtrip/annotations.ts\n function stripAnnotations(annotations) {\n const input = annotations ?? [];\n const out = [];\n for (const a of input) {\n const hasLM = typeof a.labelMarkdown === \"string\" && a.labelMarkdown.length > 0;\n const hasLabel = typeof a.label === \"string\" && a.label.length > 0;\n if (!hasLM && !hasLabel) continue;\n const base = hasLM ? { labelMarkdown: a.labelMarkdown } : { label: a.label };\n if (a.categoryId) base.categoryId = a.categoryId;\n if (Array.isArray(a.properties) && a.properties.length > 0) {\n base.properties = a.properties;\n }\n out.push(base);\n }\n return out;\n }\n async function ensureCanicodeCategories() {\n const api = figma.annotations;\n const existing = await api.getAnnotationCategoriesAsync();\n const byLabel = new Map(existing.map((c) => [c.label, c.id]));\n async function ensure(label, color) {\n const cached = byLabel.get(label);\n if (cached) return cached;\n const created = await api.addAnnotationCategoryAsync({ label, color });\n byLabel.set(label, created.id);\n return created.id;\n }\n const result = {\n gotcha: await ensure(\"canicode:gotcha\", \"blue\"),\n flag: await ensure(\"canicode:flag\", \"green\"),\n fallback: await ensure(\"canicode:fallback\", \"yellow\")\n };\n const legacyAutoFix = byLabel.get(\"canicode:auto-fix\");\n if (legacyAutoFix) result.legacyAutoFix = legacyAutoFix;\n return result;\n }\n function upsertCanicodeAnnotation(node, input) {\n if (!node || !(\"annotations\" in node)) return false;\n const { ruleId, markdown, categoryId, properties } = input;\n const legacyPrefix = `**[canicode] ${ruleId}**`;\n const footer = `\\u2014 *${ruleId}*`;\n let bodyText = markdown;\n if (bodyText.startsWith(legacyPrefix)) {\n bodyText = bodyText.slice(legacyPrefix.length).replace(/^\\s*\\n+/, \"\");\n }\n const trimmed = bodyText.replace(/\\s+$/, \"\");\n const body = trimmed.endsWith(footer) ? trimmed : `${trimmed}\n\n${footer}`;\n const existing = stripAnnotations(node.annotations);\n const entry = { labelMarkdown: body };\n if (categoryId) entry.categoryId = categoryId;\n if (properties && properties.length > 0) entry.properties = properties;\n const matchesRuleId = (text) => {\n if (typeof text !== \"string\") return false;\n return text.startsWith(legacyPrefix) || text.includes(footer);\n };\n const idx = existing.findIndex(\n (a) => matchesRuleId(a.labelMarkdown) || matchesRuleId(a.label)\n );\n if (idx >= 0) existing[idx] = entry;\n else existing.push(entry);\n try {\n node.annotations = existing;\n return true;\n } catch (e) {\n const msg = String(e?.message ?? e);\n const isNodeTypeReject = /invalid property .+ for a .+ node/i.test(msg);\n if (!entry.properties || !isNodeTypeReject) throw e;\n delete entry.properties;\n if (idx >= 0) existing[idx] = entry;\n node.annotations = existing;\n return true;\n }\n }\n\n // src/core/roundtrip/annotation-payload.ts\n var CANICODE_JSON_FENCE = \"```canicode-json\";\n function formatIntentValueForDisplay(value) {\n if (value === void 0) return \"undefined\";\n if (value === null) return \"null\";\n if (typeof value === \"object\") {\n try {\n return `\\`${JSON.stringify(value)}\\``;\n } catch {\n return String(value);\n }\n }\n return `\\`${String(value)}\\``;\n }\n function buildCodegenDirective(args) {\n const { sceneNodeId, intent } = args;\n const val = intent.value === void 0 ? \"undefined\" : JSON.stringify(intent.value);\n return `When generating code for node ${sceneNodeId}, set ${intent.field} to ${val} (user intent, scope: ${intent.scope}). Prefer this over the current Figma scene value when they disagree.`;\n }\n function sceneOutcomeToAck(result, reason) {\n return reason !== void 0 ? { result, reason } : { result };\n }\n function buildOutcomeHumanLine(args) {\n if (args.skippedDefinitionDueToAdr012) {\n const adrHint = \" Canicode skipped writing the source component without `allowDefinitionWrite: true` (ADR-012 safer default). The instance-level change did not apply as intended in the scene.\";\n if (args.reason === \"silent-ignore\") {\n return \"**Scene write outcome:** The write ran, but the property value did not change on this instance (silent-ignore).\" + adrHint;\n }\n return \"**Scene write outcome:** Figma rejected an instance-level change\" + (args.errorMessage ? `: ${args.errorMessage}` : \"\") + \".\" + adrHint;\n }\n if (args.reason === \"silent-ignore\") {\n return \"**Scene write outcome:** The write ran, but the property value did not change on this instance (silent-ignore). No source definition was available to escalate.\";\n }\n if (args.reason === \"override-error\") {\n return \"**Scene write outcome:** Figma rejected an instance-level change\" + (args.errorMessage ? `: ${args.errorMessage}` : \"\") + \". No source definition was available to escalate.\";\n }\n return \"**Scene write outcome:** Could not apply automatically\" + (args.errorMessage ? `: ${args.errorMessage}` : \"\") + \".\";\n }\n function buildAdr012PropagationParagraph(args) {\n const { componentName, replicaCount } = args;\n const fanOutHint = typeof replicaCount === \"number\" && replicaCount >= 2 ? ` This batched question covers ${replicaCount} instance scenes \\u2014 changing **${componentName}** at the definition still affects every inheriting instance, not just one row in the batch.` : \"\";\n return `Canicode's safer default (ADR-012) is to skip writing the source component **${componentName}** without explicit opt-in, because that write propagates to every non-overridden instance of **${componentName}** in the file.${fanOutHint} Prefer a manual override on **this** instance when you only need a local fix. Use \\`allowDefinitionWrite: true\\` only when you intend to change **${componentName}** for all inheriting instances \\u2014 it is not a neutral shortcut for a single-instance tweak.`;\n }\n function buildDefinitionWriteSkippedBody(args) {\n const {\n ruleId,\n sceneNodeId,\n componentName,\n reason,\n errorMessage,\n replicaCount,\n intent\n } = args;\n const ackIntent = intent ? {\n field: intent.field,\n value: intent.value,\n scope: intent.scope\n } : void 0;\n const sceneWriteOutcome = sceneOutcomeToAck(\"user-declined-propagation\", \"adr-012-opt-in-disabled\");\n const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;\n const jsonBlock = {\n v: 1,\n ruleId,\n nodeId: sceneNodeId,\n ...ackIntent ? { intent: ackIntent } : {},\n sceneWriteOutcome,\n ...codegenDirective ? { codegenDirective } : {}\n };\n const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;\n const outcomeLine = buildOutcomeHumanLine({\n reason,\n ...errorMessage !== void 0 ? { errorMessage } : {},\n skippedDefinitionDueToAdr012: true\n });\n const adrBlock = buildAdr012PropagationParagraph({\n componentName,\n ...replicaCount !== void 0 ? { replicaCount } : {}\n });\n const proseParts = [userAnswerLine, outcomeLine, adrBlock].filter(\n (p) => p !== null\n );\n const prose = proseParts.join(\"\\n\\n\");\n return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);\n }\n function buildNoDefinitionFallbackBody(args) {\n const { ruleId, sceneNodeId, reason, errorMessage, intent } = args;\n const ackIntent = intent ? { field: intent.field, value: intent.value, scope: intent.scope } : void 0;\n const outcomeResult = reason === \"silent-ignore\" ? \"silent-ignored\" : reason === \"override-error\" ? \"api-rejected\" : \"api-rejected\";\n const sceneWriteOutcome = sceneOutcomeToAck(\n outcomeResult,\n reason === \"silent-ignore\" ? \"silent-ignore-no-definition\" : \"no-definition-escalation\"\n );\n const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;\n const jsonBlock = {\n v: 1,\n ruleId,\n nodeId: sceneNodeId,\n ...ackIntent ? { intent: ackIntent } : {},\n sceneWriteOutcome,\n ...codegenDirective ? { codegenDirective } : {}\n };\n const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;\n const outcomeLine = buildOutcomeHumanLine({\n reason,\n ...errorMessage !== void 0 ? { errorMessage } : {},\n skippedDefinitionDueToAdr012: false\n });\n const prose = [userAnswerLine, outcomeLine].filter((p) => p !== null).join(\"\\n\\n\");\n return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);\n }\n function buildDefinitionTierFailureBody(args) {\n const { ruleId, sceneNodeId, intent, kind, errorMessage } = args;\n const sceneWriteOutcome = sceneOutcomeToAck(\n kind === \"read-only-library\" ? \"api-rejected\" : \"api-rejected\",\n kind === \"read-only-library\" ? \"definition-read-only\" : \"definition-write-failed\"\n );\n const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;\n const jsonBlock = {\n v: 1,\n ruleId,\n nodeId: sceneNodeId,\n ...intent ? {\n intent: {\n field: intent.field,\n value: intent.value,\n scope: intent.scope\n }\n } : {},\n sceneWriteOutcome,\n ...codegenDirective ? { codegenDirective } : {}\n };\n const human = kind === \"read-only-library\" ? \"source component lives in an external library and is read-only from this file \\u2014 apply the fix in the library file itself.\" : `could not apply at source definition: ${errorMessage}`;\n const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;\n const outcomeLine = `**Scene write outcome:** ${human}`;\n const prose = [userAnswerLine, outcomeLine].filter((p) => p !== null).join(\"\\n\\n\");\n return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);\n }\n function appendJsonFenceAndFooter(prose, jsonBlock, ruleId) {\n const footer = `\\u2014 *${ruleId}*`;\n const hasIntent = jsonBlock.intent !== void 0;\n if (!hasIntent) {\n return `${prose}\n\n${footer}`;\n }\n const jsonText = JSON.stringify(jsonBlock, null, 0);\n return `${prose}\n\n${CANICODE_JSON_FENCE}\n${jsonText}\n\\`\\`\\`\n\n${footer}`;\n }\n var FENCED_JSON_RE = new RegExp(\n `${CANICODE_JSON_FENCE.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}\\\\s*([\\\\s\\\\S]*?)\\\\s*\\`\\`\\``,\n \"m\"\n );\n function parseCanicodeJsonPayloadFromMarkdown(text) {\n const m = FENCED_JSON_RE.exec(text);\n if (!m?.[1]) return void 0;\n try {\n const raw = JSON.parse(m[1].trim());\n if (!raw || typeof raw !== \"object\") return void 0;\n const o = raw;\n if (o.v !== 1 || typeof o.ruleId !== \"string\") return void 0;\n return raw;\n } catch {\n return void 0;\n }\n }\n\n // src/core/roundtrip/apply-with-instance-fallback.ts\n var DEFINITION_WRITE_SKIPPED_EVENT = \"cic_roundtrip_definition_write_skipped\";\n function categoryIdForAnnotate(categories, kind, roundtripIntent) {\n if (kind === \"adr012-definition-skipped\") {\n return categories.fallback;\n }\n if (roundtripIntent !== void 0) {\n return categories.gotcha;\n }\n return categories.flag;\n }\n function resolveSourceComponentName(definition, question) {\n if (definition && typeof definition.name === \"string\" && definition.name) {\n return definition.name;\n }\n const ic = question.instanceContext;\n if (ic && typeof ic.sourceComponentName === \"string\" && ic.sourceComponentName) {\n return ic.sourceComponentName;\n }\n return \"the source component\";\n }\n async function routeToDefinitionOrAnnotate(definition, writeFn, ctx) {\n if (definition && !ctx.allowDefinitionWrite && ctx.reason !== \"non-override-error\") {\n const componentName = resolveSourceComponentName(definition, ctx.question);\n const replicaCount = typeof ctx.question.replicas === \"number\" && Number.isInteger(ctx.question.replicas) ? ctx.question.replicas : void 0;\n if (ctx.categories) {\n upsertCanicodeAnnotation(ctx.scene, {\n ruleId: ctx.question.ruleId,\n markdown: buildDefinitionWriteSkippedBody({\n ruleId: ctx.question.ruleId,\n sceneNodeId: ctx.scene.id,\n componentName,\n reason: ctx.reason,\n ...ctx.errorMessage !== void 0 ? { errorMessage: ctx.errorMessage } : {},\n ...replicaCount !== void 0 ? { replicaCount } : {},\n ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {}\n }),\n categoryId: categoryIdForAnnotate(\n ctx.categories,\n \"adr012-definition-skipped\",\n ctx.roundtripIntent\n )\n });\n }\n ctx.telemetry?.(DEFINITION_WRITE_SKIPPED_EVENT, {\n ruleId: ctx.question.ruleId,\n reason: ctx.reason\n });\n return {\n icon: \"\\u{1F4DD}\",\n label: \"definition write skipped (opt-in disabled)\"\n };\n }\n if (!definition) {\n if (ctx.categories) {\n const markdown = buildNoDefinitionFallbackBody({\n ruleId: ctx.question.ruleId,\n sceneNodeId: ctx.scene.id,\n reason: ctx.reason,\n ...ctx.errorMessage !== void 0 ? { errorMessage: ctx.errorMessage } : {},\n ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {}\n });\n upsertCanicodeAnnotation(ctx.scene, {\n ruleId: ctx.question.ruleId,\n markdown,\n categoryId: categoryIdForAnnotate(\n ctx.categories,\n \"other-failure\",\n ctx.roundtripIntent\n )\n });\n }\n return ctx.reason === \"silent-ignore\" ? { icon: \"\\u{1F4DD}\", label: \"silent-ignore, annotated\" } : { icon: \"\\u{1F4DD}\", label: `error: ${ctx.errorMessage ?? \"\"}` };\n }\n try {\n await writeFn(definition);\n return {\n icon: \"\\u{1F310}\",\n label: ctx.reason === \"silent-ignore\" ? \"source definition (silent-ignore fallback)\" : \"source definition\"\n };\n } catch (defErr) {\n const defMsg = String(defErr?.message ?? defErr);\n const isRemoteReadOnly = definition.remote === true || /read-only/i.test(defMsg);\n if (ctx.categories) {\n upsertCanicodeAnnotation(ctx.scene, {\n ruleId: ctx.question.ruleId,\n markdown: buildDefinitionTierFailureBody({\n ruleId: ctx.question.ruleId,\n sceneNodeId: ctx.scene.id,\n ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {},\n kind: isRemoteReadOnly ? \"read-only-library\" : \"definition-error\",\n errorMessage: defMsg\n }),\n categoryId: categoryIdForAnnotate(\n ctx.categories,\n \"other-failure\",\n ctx.roundtripIntent\n )\n });\n }\n return {\n icon: \"\\u{1F4DD}\",\n label: isRemoteReadOnly ? \"external library (read-only)\" : `definition error: ${defMsg}`\n };\n }\n }\n async function applyWithInstanceFallback(question, writeFn, context = {}) {\n const { categories, allowDefinitionWrite = false, telemetry, roundtripIntent } = context;\n const scene = await figma.getNodeByIdAsync(question.nodeId);\n if (!scene) return { icon: \"\\u{1F4DD}\", label: \"missing node\" };\n const definition = question.sourceChildId ? await figma.getNodeByIdAsync(question.sourceChildId) : null;\n try {\n const changed = await writeFn(scene);\n if (changed === false) {\n return routeToDefinitionOrAnnotate(definition, writeFn, {\n question,\n scene,\n categories,\n reason: \"silent-ignore\",\n allowDefinitionWrite,\n telemetry,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n });\n }\n return { icon: \"\\u2705\", label: \"instance/scene\" };\n } catch (e) {\n const msg = String(e?.message ?? e);\n const looksLikeInstanceOverride = /cannot be overridden/i.test(msg) || /override/i.test(msg);\n if (!looksLikeInstanceOverride) {\n return routeToDefinitionOrAnnotate(null, writeFn, {\n question,\n scene,\n categories,\n reason: \"non-override-error\",\n errorMessage: msg,\n allowDefinitionWrite,\n telemetry,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n });\n }\n return routeToDefinitionOrAnnotate(definition, writeFn, {\n question,\n scene,\n categories,\n reason: \"override-error\",\n errorMessage: msg,\n allowDefinitionWrite,\n telemetry,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n });\n }\n }\n\n // src/core/roundtrip/apply-property-mod.ts\n async function resolveVariableByName(name) {\n const locals = await figma.variables.getLocalVariablesAsync();\n return locals.find((v) => v.name === name) ?? null;\n }\n function parseValue(raw) {\n if (raw && typeof raw === \"object\" && \"variable\" in raw) {\n const v = raw;\n const parsed = { kind: \"binding\", name: v.variable };\n if (\"fallback\" in v) parsed.fallback = v.fallback;\n return parsed;\n }\n if (raw && typeof raw === \"object\" && \"fallback\" in raw) {\n return { kind: \"scalar\", scalar: raw.fallback };\n }\n return { kind: \"scalar\", scalar: raw };\n }\n function isPaintProp(prop) {\n return prop === \"fills\" || prop === \"strokes\";\n }\n function applyPropertyBinding(target, prop, variable) {\n if (isPaintProp(prop)) {\n const current = target[prop];\n if (current === figma.mixed || !Array.isArray(current)) return false;\n const paints = current;\n const bound = paints.map(\n (paint) => figma.variables.setBoundVariableForPaint(paint, \"color\", variable)\n );\n target[prop] = bound;\n return true;\n }\n target.setBoundVariable(prop, variable);\n return true;\n }\n function buildRoundtripIntentFromPropertyAnswer(question, answerValue) {\n const raw = question.targetProperty;\n if (raw === void 0) return void 0;\n const props = Array.isArray(raw) ? raw : [raw];\n if (props.length === 0) return void 0;\n if (props.length === 1) {\n const prop = props[0];\n const perProp = answerValue && typeof answerValue === \"object\" && !(\"variable\" in answerValue) && !Array.isArray(answerValue) ? answerValue[prop] : answerValue;\n const parsed = parseValueForIntent(perProp);\n if (parsed === void 0) return void 0;\n return { field: prop, value: parsed, scope: \"instance\" };\n }\n const obj = answerValue && typeof answerValue === \"object\" && !(\"variable\" in answerValue) && !Array.isArray(answerValue) ? answerValue : void 0;\n const picked = {};\n for (const p of props) {\n if (obj && p in obj && obj[p] !== void 0) picked[p] = obj[p];\n }\n if (Object.keys(picked).length === 0) return void 0;\n return {\n field: props.join(\", \"),\n value: picked,\n scope: \"instance\"\n };\n }\n function parseValueForIntent(raw) {\n if (raw && typeof raw === \"object\" && \"variable\" in raw) {\n return { variable: raw.variable };\n }\n if (raw && typeof raw === \"object\" && \"fallback\" in raw) {\n return raw.fallback;\n }\n return raw;\n }\n function applyPropertyScalar(target, prop, scalar) {\n const rec = target;\n const before = rec[prop];\n rec[prop] = scalar;\n if (rec[prop] === before && before !== scalar) return false;\n return true;\n }\n async function applyPropertyMod(question, answerValue, context = {}) {\n const roundtripIntent = buildRoundtripIntentFromPropertyAnswer(\n question,\n answerValue\n );\n const props = Array.isArray(question.targetProperty) ? question.targetProperty : question.targetProperty !== void 0 ? [question.targetProperty] : [];\n return applyWithInstanceFallback(\n question,\n async (target) => {\n if (!target) return void 0;\n let changed = void 0;\n for (const prop of props) {\n if (!(prop in target)) continue;\n const perProp = answerValue && typeof answerValue === \"object\" && !(\"variable\" in answerValue) && !Array.isArray(answerValue) ? answerValue[prop] : answerValue;\n const parsed = parseValue(perProp);\n if (parsed.kind === \"binding\") {\n const variable = await resolveVariableByName(parsed.name);\n if (variable) {\n applyPropertyBinding(target, prop, variable);\n continue;\n }\n if (parsed.fallback !== void 0) {\n if (!applyPropertyScalar(target, prop, parsed.fallback)) {\n changed = false;\n }\n }\n continue;\n }\n if (parsed.scalar === void 0) continue;\n if (!applyPropertyScalar(target, prop, parsed.scalar)) {\n changed = false;\n }\n }\n return changed;\n },\n {\n ...context,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n }\n );\n }\n\n // src/core/roundtrip/probe-definition-writability.ts\n async function probeDefinitionWritability(questions) {\n const verdict = /* @__PURE__ */ new Map();\n const unwritableNames = [];\n const seenName = /* @__PURE__ */ new Set();\n for (const q of questions) {\n const id = q.sourceChildId;\n if (!id) continue;\n if (verdict.has(id)) continue;\n const node = await figma.getNodeByIdAsync(id);\n const writability = resolveWritability(node);\n const isUnwritable = writability.isUnwritable;\n verdict.set(id, isUnwritable ? \"unwritable\" : \"writable\");\n if (isUnwritable) {\n const name = typeof writability.componentName === \"string\" && writability.componentName || typeof node?.name === \"string\" && node.name || q.instanceContext?.sourceComponentName || id;\n if (!seenName.has(name)) {\n seenName.add(name);\n unwritableNames.push(name);\n }\n }\n }\n const totalCount = verdict.size;\n let unwritableCount = 0;\n for (const v of verdict.values()) if (v === \"unwritable\") unwritableCount++;\n return {\n totalCount,\n unwritableCount,\n unwritableSourceNames: unwritableNames,\n allUnwritable: totalCount > 0 && unwritableCount === totalCount,\n partiallyUnwritable: unwritableCount > 0 && unwritableCount < totalCount\n };\n }\n function resolveWritability(node) {\n if (node === null) return { isUnwritable: true };\n if (\"remote\" in node && typeof node.remote === \"boolean\") {\n return { isUnwritable: node.remote === true };\n }\n const containing = findContainingComponent(node);\n if (!containing) {\n return { isUnwritable: false };\n }\n const isUnwritable = \"remote\" in containing && containing.remote === true;\n return {\n isUnwritable,\n ...isUnwritable && typeof containing.name === \"string\" ? { componentName: containing.name } : {}\n };\n }\n function findContainingComponent(node) {\n let cur = node;\n for (let i = 0; i < 100 && cur; i++) {\n if (cur.type === \"COMPONENT\" || cur.type === \"COMPONENT_SET\") return cur;\n cur = cur.parent ?? null;\n }\n return null;\n }\n\n // src/core/roundtrip/read-acknowledgments.ts\n var FOOTER_RE = /—\\s+\\*([A-Za-z0-9-]+)\\*\\s*$/;\n var LEGACY_PREFIX_RE = /^\\*\\*\\[canicode\\]\\s+([A-Za-z0-9-]+)\\*\\*/;\n function extractAcknowledgmentsFromNode(node, canicodeCategoryIds) {\n if (!node || !(\"annotations\" in node)) return [];\n const annotations = node.annotations ?? [];\n if (annotations.length === 0) return [];\n const out = [];\n for (const a of annotations) {\n const text = (typeof a.labelMarkdown === \"string\" && a.labelMarkdown.length > 0 ? a.labelMarkdown : \"\") || (typeof a.label === \"string\" && a.label.length > 0 ? a.label : \"\");\n if (!text) continue;\n if (canicodeCategoryIds) {\n if (!a.categoryId || !canicodeCategoryIds.has(a.categoryId)) continue;\n }\n const ruleId = extractRuleId(text);\n if (!ruleId) continue;\n const payload = parseCanicodeJsonPayloadFromMarkdown(text);\n const payloadAligned = payload && payload.ruleId === ruleId;\n out.push({\n nodeId: node.id,\n ruleId,\n ...payloadAligned && payload.intent ? { intent: payload.intent } : {},\n ...payloadAligned && payload.sceneWriteOutcome ? { sceneWriteOutcome: payload.sceneWriteOutcome } : {},\n ...payloadAligned && payload.codegenDirective ? { codegenDirective: payload.codegenDirective } : {}\n });\n }\n return out;\n }\n function extractRuleId(text) {\n const footer = FOOTER_RE.exec(text);\n if (footer) return footer[1] ?? null;\n const legacy = LEGACY_PREFIX_RE.exec(text);\n if (legacy) return legacy[1] ?? null;\n return null;\n }\n async function readCanicodeAcknowledgments(rootNodeId, categories) {\n const root = await figma.getNodeByIdAsync(rootNodeId);\n if (!root) return [];\n const canicodeCategoryIds = categories ? new Set(\n [\n categories.gotcha,\n categories.flag,\n categories.fallback,\n categories.legacyAutoFix\n ].filter((id) => typeof id === \"string\" && id.length > 0)\n ) : void 0;\n const out = [];\n walk(root, canicodeCategoryIds, out);\n return out;\n }\n function safeChildren(node) {\n try {\n const c = node.children;\n return Array.isArray(c) ? c : [];\n } catch {\n return [];\n }\n }\n function walk(node, canicodeCategoryIds, out) {\n try {\n const local = extractAcknowledgmentsFromNode(node, canicodeCategoryIds);\n for (const a of local) out.push(a);\n } catch {\n }\n for (const child of safeChildren(node)) {\n if (child && typeof child === \"object\") walk(child, canicodeCategoryIds, out);\n }\n }\n\n // src/core/roundtrip/compute-roundtrip-tally.ts\n function computeRoundtripTally(args) {\n const { stepFourReport, reanalyzeResponse } = args;\n const { resolved, annotated, definitionWritten, skipped } = stepFourReport;\n const { issueCount, acknowledgedCount } = reanalyzeResponse;\n if (acknowledgedCount > issueCount) {\n throw new Error(\n `computeRoundtripTally: reanalyzeResponse.acknowledgedCount (${acknowledgedCount}) cannot exceed issueCount (${issueCount}). Acknowledged issues are a subset of remaining issues.`\n );\n }\n return {\n X: resolved,\n Y: annotated,\n Z: definitionWritten,\n W: skipped,\n N: resolved + annotated + definitionWritten + skipped,\n V: issueCount,\n V_ack: acknowledgedCount,\n V_open: issueCount - acknowledgedCount\n };\n }\n\n // src/core/roundtrip/apply-auto-fix.ts\n function pickNodeName(issue, resolved) {\n if (resolved && typeof resolved.name === \"string\" && resolved.name.length > 0) {\n return resolved.name;\n }\n if (typeof issue.nodePath === \"string\" && issue.nodePath.length > 0) {\n const segments = issue.nodePath.split(/\\s*[›>/]\\s*/);\n const tail = segments[segments.length - 1];\n if (tail && tail.length > 0) return tail;\n }\n return issue.nodeId;\n }\n function mapInstanceFallbackIcon(result) {\n if (result.icon === \"\\u2705\") return \"\\u{1F527}\";\n return result.icon;\n }\n async function applyAutoFix(issue, context) {\n const { categories } = context;\n const ruleId = issue.ruleId;\n if (issue.targetProperty === \"name\" && typeof issue.suggestedName === \"string\") {\n const suggestedName = issue.suggestedName;\n const question = {\n nodeId: issue.nodeId,\n ruleId,\n ...issue.sourceChildId ? { sourceChildId: issue.sourceChildId } : {}\n };\n const result = await applyWithInstanceFallback(\n question,\n (target) => {\n if (target) {\n target.name = suggestedName;\n }\n },\n {\n categories,\n ...context.allowDefinitionWrite !== void 0 ? { allowDefinitionWrite: context.allowDefinitionWrite } : {},\n ...context.telemetry !== void 0 ? { telemetry: context.telemetry } : {}\n }\n );\n const sceneAfter = await figma.getNodeByIdAsync(issue.nodeId);\n return {\n outcome: mapInstanceFallbackIcon(result),\n nodeId: issue.nodeId,\n nodeName: pickNodeName(issue, sceneAfter),\n ruleId,\n label: result.label\n };\n }\n const scene = await figma.getNodeByIdAsync(issue.nodeId);\n const markdown = issue.message ?? `Auto-flagged: ${ruleId}`;\n if (scene) {\n upsertCanicodeAnnotation(scene, {\n ruleId,\n markdown,\n categoryId: categories.flag,\n ...issue.annotationProperties && issue.annotationProperties.length > 0 ? { properties: issue.annotationProperties } : {}\n });\n }\n return {\n outcome: \"\\u{1F4DD}\",\n nodeId: issue.nodeId,\n nodeName: pickNodeName(issue, scene),\n ruleId,\n label: scene ? `annotation added to canicode:flag \\u2014 ${ruleId}` : `missing node (annotation skipped) \\u2014 ${ruleId}`\n };\n }\n async function applyAutoFixes(issues, context) {\n const out = [];\n for (const issue of issues) {\n if (issue.applyStrategy !== \"auto-fix\") {\n out.push({\n outcome: \"\\u23ED\\uFE0F\",\n nodeId: issue.nodeId,\n nodeName: pickNodeName(issue, null),\n ruleId: issue.ruleId,\n label: `skipped \\u2014 applyStrategy is ${issue.applyStrategy ?? \"absent\"}`\n });\n continue;\n }\n out.push(await applyAutoFix(issue, context));\n }\n return out;\n }\n\n // src/core/roundtrip/remove-canicode-annotations.ts\n var LEGACY_CANICODE_PREFIX = \"**[canicode]\";\n function isCanicodeAnnotation(annotation, categories) {\n const canicodeIds = new Set(\n [\n categories.gotcha,\n categories.flag,\n categories.fallback,\n categories.legacyAutoFix\n ].filter((id) => Boolean(id))\n );\n if (annotation.categoryId && canicodeIds.has(annotation.categoryId)) {\n return true;\n }\n if (annotation.labelMarkdown?.startsWith(LEGACY_CANICODE_PREFIX)) {\n return true;\n }\n return false;\n }\n function removeCanicodeAnnotations(annotations, categories) {\n return annotations.filter((a) => !isCanicodeAnnotation(a, categories));\n }\n\n exports.applyAutoFix = applyAutoFix;\n exports.applyAutoFixes = applyAutoFixes;\n exports.applyPropertyMod = applyPropertyMod;\n exports.applyWithInstanceFallback = applyWithInstanceFallback;\n exports.computeRoundtripTally = computeRoundtripTally;\n exports.ensureCanicodeCategories = ensureCanicodeCategories;\n exports.extractAcknowledgmentsFromNode = extractAcknowledgmentsFromNode;\n exports.isCanicodeAnnotation = isCanicodeAnnotation;\n exports.probeDefinitionWritability = probeDefinitionWritability;\n exports.readCanicodeAcknowledgments = readCanicodeAcknowledgments;\n exports.removeCanicodeAnnotations = removeCanicodeAnnotations;\n exports.resolveVariableByName = resolveVariableByName;\n exports.stripAnnotations = stripAnnotations;\n exports.upsertCanicodeAnnotation = upsertCanicodeAnnotation;\n\n return exports;\n\n})({});\n";
6
- var __CANICODE_HELPERS_VERSION__ = "0.11.4";
6
+ var __CANICODE_HELPERS_VERSION__ = "0.12.0";
7
7
  (0, eval)(__CANICODE_HELPERS_SRC__);
8
8
  try {
9
9
  figma.root.setSharedPluginData("canicode", "helpersSrc", __CANICODE_HELPERS_SRC__);