canicode 0.12.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +436 -51
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +119 -27
- package/dist/index.js +259 -15
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +273 -19
- package/dist/mcp/server.js.map +1 -1
- package/package.json +1 -1
- package/skills/canicode-roundtrip/SKILL.md +62 -8
- package/skills/canicode-roundtrip/helpers-bootstrap.js +1 -1
- package/skills/canicode-roundtrip/helpers-installer.js +2 -2
- package/skills/canicode-roundtrip/helpers.js +41 -1
- package/skills/cursor/canicode-roundtrip/SKILL.md +62 -8
- package/skills/cursor/canicode-roundtrip/helpers-bootstrap.js +1 -1
- package/skills/cursor/canicode-roundtrip/helpers-installer.js +2 -2
- package/skills/cursor/canicode-roundtrip/helpers.js +41 -1
package/package.json
CHANGED
|
@@ -25,6 +25,8 @@ disable-model-invocation: false
|
|
|
25
25
|
|
|
26
26
|
**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.
|
|
27
27
|
|
|
28
|
+
**Output language (#546):** Detect the user's conversation language from their messages in **this** session. When the user is conversing in a non-English language (e.g. Korean, Japanese, Spanish), every human-readable line you render — Step 1 design summary, Step 2 grade banner, Step 3 question / `Hint:` / `Example:` / batch shared-prompt wording, Step 4 apply summary, Step 5 wrap-up rubric, Step 6 handoff line, Step 7 prompts and wrap-up — must be rendered in that language. Identifiers stay English: `ruleId`, `nodeId`, severity label in brackets, marker glyphs (📝/✅/🌐/⏭️), the upsert-section markdown scaffolding. The full localization scope and exclusions are in Step 3's preamble below. Default to English only when the user's language is genuinely ambiguous (and ask once).
|
|
29
|
+
|
|
28
30
|
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).
|
|
29
31
|
|
|
30
32
|
## Prerequisites
|
|
@@ -88,23 +90,30 @@ Design grade: **{grade}** ({percentage}%) — {issueCount} issues found.
|
|
|
88
90
|
|
|
89
91
|
### Step 1.5: Code Connect prerequisite pre-check (soft warn)
|
|
90
92
|
|
|
91
|
-
The closing step (Step 7) registers a Code Connect mapping for the just-implemented design, which requires
|
|
93
|
+
The closing step (Step 7) registers a Code Connect mapping for the just-implemented design, which requires three things:
|
|
94
|
+
|
|
95
|
+
1. The user's repo has `@figma/code-connect` installed.
|
|
96
|
+
2. `figma.config.json` is present at the repo root.
|
|
97
|
+
3. The target Figma component is published in a library (Figma UI: Assets panel → Publish library).
|
|
92
98
|
|
|
93
|
-
|
|
99
|
+
The first two are repo-side; the third is Figma-side. Pass the Figma URL to `canicode doctor --figma-url <url>` (added in #532) so all three surface here, before the survey:
|
|
94
100
|
|
|
95
101
|
```bash
|
|
96
|
-
npx canicode doctor
|
|
102
|
+
npx canicode doctor --figma-url "<the-figma-url-the-user-passed>"
|
|
97
103
|
```
|
|
98
104
|
|
|
105
|
+
Always quote the URL — zsh expands `?` in `?node-id=...` otherwise.
|
|
106
|
+
|
|
99
107
|
Branch on exit code:
|
|
100
108
|
|
|
101
|
-
- **Exit 0 (
|
|
109
|
+
- **Exit 0 (no blocking failures)** — silent. Continue to Step 2.
|
|
110
|
+
- The Figma publish-status check may render as `⚠️ inconclusive` (e.g. `FIGMA_TOKEN` not configured, network error, URL has no node-id). Inconclusive is not a failure: doctor stays informational, and Step 7d's actual `add_code_connect_map` call remains the authority. Print the inconclusive line for visibility but do not prompt.
|
|
102
111
|
- **Exit 1 (any check failed)** — print the doctor's remediation lines verbatim, then prompt:
|
|
103
112
|
> "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
113
|
- **Y** (default) — proceed to Step 2. Remember the prereq state so Step 7 can short-circuit without re-explaining.
|
|
105
114
|
- **n** — stop the whole roundtrip cleanly. Tell the user: "Set up Code Connect and re-invoke `/canicode-roundtrip` when ready."
|
|
106
115
|
|
|
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.
|
|
116
|
+
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. The publish-status check shifting the Figma-side prereq into this step (rather than discovering it after Step 7d's `add_code_connect_map` fails with "Published component not found") was the #532 motivation.
|
|
108
117
|
|
|
109
118
|
### Step 2: Surface grade as informational banner
|
|
110
119
|
|
|
@@ -152,9 +161,15 @@ Detect the user's conversation language from their recent messages in **this** s
|
|
|
152
161
|
|
|
153
162
|
Iterate `groupedQuestions.groups[].batches[]` and branch on `batch.batchMode` (`"safe"` — one uniform answer, `"opt-in"` — shared answer offered as default with per-node `split` override (#426), `"none"` — single-question). Instance notes, batch prompt templates per mode, replicas, split/skip/n/a, "skip remaining" early-exit affordance (surface before the first batch, re-surface every 3rd), 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.
|
|
154
163
|
|
|
164
|
+
**Pacing — one batch per message (#545):** Render exactly **one** batch per assistant message and **wait for the user's reply** before rendering the next. A `safe` / `opt-in` multi-instance batch is still **one** batch — render the shared prompt once and wait. Do **not** dump multiple batches in a single message and ask "Reply with answers numbered 1–N"; that defeats the paced Q&A UX. The total-batch count and the `skip remaining` affordance are surfaced once before batch 1 (and re-surfaced every 3rd batch per the appendix); they are not a license to bulk-render. The only exception is `skip remaining` — when the user invokes it, mark all unanswered batches as skipped and proceed straight to Step 4.
|
|
165
|
+
|
|
155
166
|
|
|
156
167
|
### Step 4: Apply gotcha answers to Figma design
|
|
157
168
|
|
|
169
|
+
#### Inline vs file staging (#531)
|
|
170
|
+
|
|
171
|
+
When the apply commands themselves are short (~≤ 200 lines / ~10 KB), assemble the `use_figma` `code` string inline in the same call — read the helper artifact, concatenate with the apply commands, and pass directly. Three tool calls (Write apply.js + Read combined → use_figma) for a 2–3 gotcha apply is overhead with no debug benefit beyond what `use_figma`'s own error message already provides. Only stage to `/tmp/canicode-apply.js` when the apply payload is large enough to bloat the model's reply (e.g. dozens of replicas, definition-write fan-out, or any single batch nearing the ~50KB `use_figma` ceiling). The helpers artifact may still be a separate Read; the staging tradeoff is about the **apply** payload, not the helpers.
|
|
172
|
+
|
|
158
173
|
#### Mandatory preflight — prepend one of the bundled helpers before any `CanICodeRoundtrip.*` call
|
|
159
174
|
|
|
160
175
|
`CanICodeRoundtrip` is **not** a Figma or MCP built-in. It is the global registered by a bundled IIFE shipped next to this skill — it only exists after you read the right artifact and prepend its contents verbatim at the top of every `use_figma` script string. Skipping this step throws `ReferenceError: 'CanICodeRoundtrip' is not defined` on the first `use_figma` batch.
|
|
@@ -271,6 +286,26 @@ Notes:
|
|
|
271
286
|
- `label` and `labelMarkdown` are mutually exclusive on write, but Figma returns both on readback. Never spread `scene.annotations` directly; always call `CanICodeRoundtrip.upsertCanicodeAnnotation` (or `CanICodeRoundtrip.stripAnnotations` if you truly need the normalized array).
|
|
272
287
|
- Prefer annotating the **scene** instance child so designers see the note where they work; mention in the markdown if the fix belongs on the source component but could not be applied (library/external).
|
|
273
288
|
|
|
289
|
+
##### Strategy C opt-out branch — `unmapped-component`
|
|
290
|
+
|
|
291
|
+
When `applyStrategy === "annotation"` AND `question.ruleId === "unmapped-component"` AND the user's answer expresses "intentionally unmapped" (LLM judgment on the prose — e.g. "skip permanently", "do not map", "intentionally unmapped"), call the dedicated opt-out helper instead of the standard `upsertCanicodeAnnotation` Q/A path:
|
|
292
|
+
|
|
293
|
+
<!-- adr-016-ack: single-helper call; no fan-out, the opt-out is per main component -->
|
|
294
|
+
```javascript
|
|
295
|
+
await CanICodeRoundtrip.applyUnmappedComponentOptOut(
|
|
296
|
+
{ nodeId: question.nodeId, ruleId: question.ruleId },
|
|
297
|
+
{ categories }
|
|
298
|
+
);
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
The helper writes a fenced canicode-json block with `kind: "rule-opt-out"` and `ruleId: "unmapped-component"` under `categories.gotcha`. The read-side pipeline (Step 5a + ADR-022 rule short-circuit) consumes this on subsequent analyze runs to suppress the rule for this node — see ADR-022 for the read-side pipeline that consumes this annotation.
|
|
302
|
+
|
|
303
|
+
Notes:
|
|
304
|
+
- **No prose body, no per-property intent.** The fence's `intent.kind` is the discriminator, not Q/A markdown.
|
|
305
|
+
- **No replica fan-out.** `unmapped-component` only fires on `COMPONENT` / `COMPONENT_SET` nodes (parser-driven main check). These never carry the `I…;…` instance-child id format, so `question.replicaNodeIds` is absent for this rule — do **not** iterate it. Writing an opt-out on an instance scene would no-op because the rule looks up the main component id when matching the ack.
|
|
306
|
+
- **Idempotent on re-apply.** The helper goes through `upsertCanicodeAnnotation`, so the footer-based dedup replaces an existing entry in place; running Step 4 twice yields one annotation, not two.
|
|
307
|
+
- **Distinct from a Step 3 skip.** Skipping a gotcha (`answer === "skip"` / `"n/a"`) drops the question without touching the design; the opt-out path writes a *permanent* suppression marker that survives across analyze runs. Choose the opt-out only when the user truly means "this component should never be code-connected"; for "skip for now", drop the question.
|
|
308
|
+
|
|
274
309
|
#### Strategy D: Auto-fix lower-severity issues from analysis
|
|
275
310
|
|
|
276
311
|
The gotcha survey covers blocking/risk severity plus `missing-info` severity from info-collection rules (#406 — currently `missing-prototype`, `missing-interaction-state`). All other lower-severity rules appear in `analyzeResult.issues[]` without a survey question. Each issue carries the same pre-computed fields (`applyStrategy`, `targetProperty`, `annotationProperties`, `suggestedName`, `isInstanceChild`, `sourceChildId`). The bundled helper handles the loop, the filter (`applyStrategy === "auto-fix"`), the naming-vs-annotation branch, and the per-issue outcome accumulator in one call:
|
|
@@ -367,6 +402,8 @@ The response now carries:
|
|
|
367
402
|
|
|
368
403
|
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).
|
|
369
404
|
|
|
405
|
+
**Grade-movement attribution (#547):** When the wrap-up shows a grade jump (e.g. `C+ → B+`), attribute the move to the resolved bucket (`✅` / `🔧` / `🌐`) explicitly so the user does not mis-infer that 📝 annotations contributed. Per ADR-012, annotations are zero-score by design — they carry context into code-gen but never move the grade. When `tally.Y > 0` (any 📝 annotated count), include a one-liner near the bucket tally clarifying this. Templates below already include the line; do not omit it.
|
|
406
|
+
|
|
370
407
|
**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:
|
|
371
408
|
|
|
372
409
|
```javascript
|
|
@@ -393,6 +430,8 @@ If Step 4 produced no `stepFourReport` (e.g. user skipped every question, or no
|
|
|
393
430
|
—
|
|
394
431
|
V issues remaining (unresolved gotchas + non-actionable rules)
|
|
395
432
|
|
|
433
|
+
*(When Y > 0)* 📝 annotations carry context into code-gen but do not change the grade — that is by design (ADR-012). Any grade movement comes from the ✅ / 🔧 / 🌐 buckets above.
|
|
434
|
+
|
|
396
435
|
Ready for code generation. *(Optional:) Report still shows grade **{grade}** — informational only.*
|
|
397
436
|
```
|
|
398
437
|
- 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:
|
|
@@ -425,6 +464,8 @@ for (const id of nodeIds) {
|
|
|
425
464
|
↳ V_ack acknowledged via canicode annotations (carried into code-gen)
|
|
426
465
|
↳ V_open unaddressed (no annotation — your follow-up backlog)
|
|
427
466
|
|
|
467
|
+
*(When Y > 0)* 📝 annotations carry context into code-gen but do not change the grade — that is by design (ADR-012). Any grade movement comes from the ✅ / 🔧 / 🌐 buckets above.
|
|
468
|
+
|
|
428
469
|
Proceed to code generation with remaining context? *(Optional footnote: report grade **{grade}**.)*
|
|
429
470
|
```
|
|
430
471
|
|
|
@@ -447,6 +488,8 @@ Stopped — N issues addressed, V remaining for manual follow-up:
|
|
|
447
488
|
↳ V_ack acknowledged via canicode annotations
|
|
448
489
|
↳ V_open unaddressed
|
|
449
490
|
|
|
491
|
+
*(When Y > 0)* 📝 annotations carry context into code-gen but do not change the grade — that is by design (ADR-012).
|
|
492
|
+
|
|
450
493
|
*(Optional)* Report grade: **{grade}**.
|
|
451
494
|
```
|
|
452
495
|
|
|
@@ -485,6 +528,8 @@ Roundtrip complete — N issues addressed, code generated:
|
|
|
485
528
|
↳ V_ack acknowledged via canicode annotations
|
|
486
529
|
↳ V_open unaddressed
|
|
487
530
|
|
|
531
|
+
*(When Y > 0)* 📝 annotations carry context into code-gen but do not change the grade — that is by design (ADR-012).
|
|
532
|
+
|
|
488
533
|
*(Optional)* Report grade: **{grade}**.
|
|
489
534
|
Code: <files generated / next-step pointer from figma-implement-design>
|
|
490
535
|
```
|
|
@@ -539,12 +584,19 @@ Call `get_code_connect_map` for the Figma component's node-id (from the original
|
|
|
539
584
|
|
|
540
585
|
#### Step 7d: Register the mapping
|
|
541
586
|
|
|
542
|
-
Call `add_code_connect_map` with the Figma node-id + generated code path
|
|
587
|
+
Call `add_code_connect_map` with the Figma node-id + generated code path. In single-mapping roundtrips this publishes the mapping synchronously — the server-side persistence happens at this call.
|
|
588
|
+
|
|
589
|
+
`send_code_connect_mappings` is a batch flush primitive intended for sessions that build up multiple mappings before publishing them as a transaction. In the single-mapping flow this skill drives, calling it after `add_code_connect_map` returns a duplicate / "no pending mappings" error because the mapping is already live. Treat that follow-up call as **optional**:
|
|
543
590
|
|
|
544
|
-
|
|
591
|
+
- **Recommended (single-mapping path):** skip `send_code_connect_mappings` entirely. `add_code_connect_map` is the publish point.
|
|
592
|
+
- **If you call it anyway** (e.g. defensive habit, or a future multi-mapping flow batches multiple `add_*` first): tolerate the duplicate / already-registered error explicitly. Do **not** narrate it as a failure to the user — the mapping is live. Verify with `get_code_connect_map` if confirmation is needed before the wrap-up line.
|
|
593
|
+
|
|
594
|
+
On success (i.e. `add_code_connect_map` returned without error), print:
|
|
545
595
|
> "Code Connect mapping registered: `<figma-component>` → `<code-path>`. Future roundtrips on screens containing this component will reuse the code."
|
|
546
596
|
|
|
547
|
-
|
|
597
|
+
The success line is **unconditional** once `add_code_connect_map` succeeds. Do not gate it on `send_code_connect_mappings` returning OK.
|
|
598
|
+
|
|
599
|
+
On failure of `add_code_connect_map` itself (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. The most common cause is the Figma component not being in a published library; #532 tracks shifting that check earlier into Step 1.5.
|
|
548
600
|
|
|
549
601
|
#### Wrap-up message rubric (with mapping outcome)
|
|
550
602
|
|
|
@@ -561,6 +613,8 @@ Roundtrip complete — N issues addressed, code generated, mapping <state>:
|
|
|
561
613
|
↳ V_ack acknowledged via canicode annotations
|
|
562
614
|
↳ V_open unaddressed
|
|
563
615
|
|
|
616
|
+
*(When Y > 0)* 📝 annotations carry context into code-gen but do not change the grade — that is by design (ADR-012).
|
|
617
|
+
|
|
564
618
|
Code: <files generated / next-step pointer from figma-implement-design>
|
|
565
619
|
Code Connect: <mapping outcome line>
|
|
566
620
|
```
|
|
@@ -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.12.
|
|
9
|
+
var expected = "0.12.2";
|
|
10
10
|
var src = figma.root.getSharedPluginData("canicode", "helpersSrc");
|
|
11
11
|
var actual = figma.root.getSharedPluginData("canicode", "helpersVersion");
|
|
12
12
|
if (!src) {
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// Prepend to the FIRST use_figma batch of a roundtrip session. Caches the helpers source on
|
|
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
|
-
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.12.
|
|
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 kind: \"property\",\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 ? { kind: \"property\", 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 kind: \"property\",\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 function buildIntentionallyUnmappedAnnotationBody(args) {\n const { sceneNodeId, ruleId } = args;\n const intent = {\n kind: \"rule-opt-out\",\n ruleId\n };\n const jsonBlock = {\n v: 1,\n ruleId,\n nodeId: sceneNodeId,\n intent,\n sceneWriteOutcome: { result: \"succeeded\", reason: \"rule-opt-out\" }\n };\n const prose = \"User marked this component as intentionally unmapped \\u2014 canicode will skip the unmapped-component check for this node on subsequent analyze runs.\";\n return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);\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/apply-unmapped-component-opt-out.ts\n async function applyUnmappedComponentOptOut(input, context) {\n const { nodeId, ruleId } = input;\n const { categories } = context;\n const scene = await figma.getNodeByIdAsync(nodeId);\n if (!scene) {\n return { icon: \"\\u{1F4DD}\", label: `missing node \\u2014 ${ruleId}` };\n }\n const markdown = buildIntentionallyUnmappedAnnotationBody({\n sceneNodeId: scene.id,\n ruleId\n });\n upsertCanicodeAnnotation(scene, {\n ruleId,\n markdown,\n categoryId: categories.gotcha\n });\n return { icon: \"\\u{1F4DD}\", label: `opt-out annotation written \\u2014 ${ruleId}` };\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.applyUnmappedComponentOptOut = applyUnmappedComponentOptOut;\n exports.applyWithInstanceFallback = applyWithInstanceFallback;\n exports.buildIntentionallyUnmappedAnnotationBody = buildIntentionallyUnmappedAnnotationBody;\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\n;globalThis.CanICodeRoundtrip = CanICodeRoundtrip;\n";
|
|
6
|
+
var __CANICODE_HELPERS_VERSION__ = "0.12.2";
|
|
7
7
|
(0, eval)(__CANICODE_HELPERS_SRC__);
|
|
8
8
|
try {
|
|
9
9
|
figma.root.setSharedPluginData("canicode", "helpersSrc", __CANICODE_HELPERS_SRC__);
|
|
@@ -132,6 +132,7 @@ ${footer}`;
|
|
|
132
132
|
intent
|
|
133
133
|
} = args;
|
|
134
134
|
const ackIntent = intent ? {
|
|
135
|
+
kind: "property",
|
|
135
136
|
field: intent.field,
|
|
136
137
|
value: intent.value,
|
|
137
138
|
scope: intent.scope
|
|
@@ -164,7 +165,7 @@ ${footer}`;
|
|
|
164
165
|
}
|
|
165
166
|
function buildNoDefinitionFallbackBody(args) {
|
|
166
167
|
const { ruleId, sceneNodeId, reason, errorMessage, intent } = args;
|
|
167
|
-
const ackIntent = intent ? { field: intent.field, value: intent.value, scope: intent.scope } : void 0;
|
|
168
|
+
const ackIntent = intent ? { kind: "property", field: intent.field, value: intent.value, scope: intent.scope } : void 0;
|
|
168
169
|
const outcomeResult = reason === "silent-ignore" ? "silent-ignored" : reason === "override-error" ? "api-rejected" : "api-rejected";
|
|
169
170
|
const sceneWriteOutcome = sceneOutcomeToAck(
|
|
170
171
|
outcomeResult,
|
|
@@ -201,6 +202,7 @@ ${footer}`;
|
|
|
201
202
|
nodeId: sceneNodeId,
|
|
202
203
|
...intent ? {
|
|
203
204
|
intent: {
|
|
205
|
+
kind: "property",
|
|
204
206
|
field: intent.field,
|
|
205
207
|
value: intent.value,
|
|
206
208
|
scope: intent.scope
|
|
@@ -232,6 +234,22 @@ ${jsonText}
|
|
|
232
234
|
|
|
233
235
|
${footer}`;
|
|
234
236
|
}
|
|
237
|
+
function buildIntentionallyUnmappedAnnotationBody(args) {
|
|
238
|
+
const { sceneNodeId, ruleId } = args;
|
|
239
|
+
const intent = {
|
|
240
|
+
kind: "rule-opt-out",
|
|
241
|
+
ruleId
|
|
242
|
+
};
|
|
243
|
+
const jsonBlock = {
|
|
244
|
+
v: 1,
|
|
245
|
+
ruleId,
|
|
246
|
+
nodeId: sceneNodeId,
|
|
247
|
+
intent,
|
|
248
|
+
sceneWriteOutcome: { result: "succeeded", reason: "rule-opt-out" }
|
|
249
|
+
};
|
|
250
|
+
const prose = "User marked this component as intentionally unmapped \u2014 canicode will skip the unmapped-component check for this node on subsequent analyze runs.";
|
|
251
|
+
return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);
|
|
252
|
+
}
|
|
235
253
|
var FENCED_JSON_RE = new RegExp(
|
|
236
254
|
`${CANICODE_JSON_FENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*([\\s\\S]*?)\\s*\`\`\``,
|
|
237
255
|
"m"
|
|
@@ -644,6 +662,26 @@ ${footer}`;
|
|
|
644
662
|
}
|
|
645
663
|
}
|
|
646
664
|
|
|
665
|
+
// src/core/roundtrip/apply-unmapped-component-opt-out.ts
|
|
666
|
+
async function applyUnmappedComponentOptOut(input, context) {
|
|
667
|
+
const { nodeId, ruleId } = input;
|
|
668
|
+
const { categories } = context;
|
|
669
|
+
const scene = await figma.getNodeByIdAsync(nodeId);
|
|
670
|
+
if (!scene) {
|
|
671
|
+
return { icon: "\u{1F4DD}", label: `missing node \u2014 ${ruleId}` };
|
|
672
|
+
}
|
|
673
|
+
const markdown = buildIntentionallyUnmappedAnnotationBody({
|
|
674
|
+
sceneNodeId: scene.id,
|
|
675
|
+
ruleId
|
|
676
|
+
});
|
|
677
|
+
upsertCanicodeAnnotation(scene, {
|
|
678
|
+
ruleId,
|
|
679
|
+
markdown,
|
|
680
|
+
categoryId: categories.gotcha
|
|
681
|
+
});
|
|
682
|
+
return { icon: "\u{1F4DD}", label: `opt-out annotation written \u2014 ${ruleId}` };
|
|
683
|
+
}
|
|
684
|
+
|
|
647
685
|
// src/core/roundtrip/compute-roundtrip-tally.ts
|
|
648
686
|
function computeRoundtripTally(args) {
|
|
649
687
|
const { stepFourReport, reanalyzeResponse } = args;
|
|
@@ -776,7 +814,9 @@ ${footer}`;
|
|
|
776
814
|
exports.applyAutoFix = applyAutoFix;
|
|
777
815
|
exports.applyAutoFixes = applyAutoFixes;
|
|
778
816
|
exports.applyPropertyMod = applyPropertyMod;
|
|
817
|
+
exports.applyUnmappedComponentOptOut = applyUnmappedComponentOptOut;
|
|
779
818
|
exports.applyWithInstanceFallback = applyWithInstanceFallback;
|
|
819
|
+
exports.buildIntentionallyUnmappedAnnotationBody = buildIntentionallyUnmappedAnnotationBody;
|
|
780
820
|
exports.computeRoundtripTally = computeRoundtripTally;
|
|
781
821
|
exports.ensureCanicodeCategories = ensureCanicodeCategories;
|
|
782
822
|
exports.extractAcknowledgmentsFromNode = extractAcknowledgmentsFromNode;
|
|
@@ -25,6 +25,8 @@ disable-model-invocation: false
|
|
|
25
25
|
|
|
26
26
|
**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.
|
|
27
27
|
|
|
28
|
+
**Output language (#546):** Detect the user's conversation language from their messages in **this** session. When the user is conversing in a non-English language (e.g. Korean, Japanese, Spanish), every human-readable line you render — Step 1 design summary, Step 2 grade banner, Step 3 question / `Hint:` / `Example:` / batch shared-prompt wording, Step 4 apply summary, Step 5 wrap-up rubric, Step 6 handoff line, Step 7 prompts and wrap-up — must be rendered in that language. Identifiers stay English: `ruleId`, `nodeId`, severity label in brackets, marker glyphs (📝/✅/🌐/⏭️), the upsert-section markdown scaffolding. The full localization scope and exclusions are in Step 3's preamble below. Default to English only when the user's language is genuinely ambiguous (and ask once).
|
|
29
|
+
|
|
28
30
|
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).
|
|
29
31
|
|
|
30
32
|
## Prerequisites
|
|
@@ -88,23 +90,30 @@ Design grade: **{grade}** ({percentage}%) — {issueCount} issues found.
|
|
|
88
90
|
|
|
89
91
|
### Step 1.5: Code Connect prerequisite pre-check (soft warn)
|
|
90
92
|
|
|
91
|
-
The closing step (Step 7) registers a Code Connect mapping for the just-implemented design, which requires
|
|
93
|
+
The closing step (Step 7) registers a Code Connect mapping for the just-implemented design, which requires three things:
|
|
94
|
+
|
|
95
|
+
1. The user's repo has `@figma/code-connect` installed.
|
|
96
|
+
2. `figma.config.json` is present at the repo root.
|
|
97
|
+
3. The target Figma component is published in a library (Figma UI: Assets panel → Publish library).
|
|
92
98
|
|
|
93
|
-
|
|
99
|
+
The first two are repo-side; the third is Figma-side. Pass the Figma URL to `canicode doctor --figma-url <url>` (added in #532) so all three surface here, before the survey:
|
|
94
100
|
|
|
95
101
|
```bash
|
|
96
|
-
npx canicode doctor
|
|
102
|
+
npx canicode doctor --figma-url "<the-figma-url-the-user-passed>"
|
|
97
103
|
```
|
|
98
104
|
|
|
105
|
+
Always quote the URL — zsh expands `?` in `?node-id=...` otherwise.
|
|
106
|
+
|
|
99
107
|
Branch on exit code:
|
|
100
108
|
|
|
101
|
-
- **Exit 0 (
|
|
109
|
+
- **Exit 0 (no blocking failures)** — silent. Continue to Step 2.
|
|
110
|
+
- The Figma publish-status check may render as `⚠️ inconclusive` (e.g. `FIGMA_TOKEN` not configured, network error, URL has no node-id). Inconclusive is not a failure: doctor stays informational, and Step 7d's actual `add_code_connect_map` call remains the authority. Print the inconclusive line for visibility but do not prompt.
|
|
102
111
|
- **Exit 1 (any check failed)** — print the doctor's remediation lines verbatim, then prompt:
|
|
103
112
|
> "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
113
|
- **Y** (default) — proceed to Step 2. Remember the prereq state so Step 7 can short-circuit without re-explaining.
|
|
105
114
|
- **n** — stop the whole roundtrip cleanly. Tell the user: "Set up Code Connect and re-invoke `/canicode-roundtrip` when ready."
|
|
106
115
|
|
|
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.
|
|
116
|
+
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. The publish-status check shifting the Figma-side prereq into this step (rather than discovering it after Step 7d's `add_code_connect_map` fails with "Published component not found") was the #532 motivation.
|
|
108
117
|
|
|
109
118
|
### Step 2: Surface grade as informational banner
|
|
110
119
|
|
|
@@ -152,9 +161,15 @@ Detect the user's conversation language from their recent messages in **this** s
|
|
|
152
161
|
|
|
153
162
|
Iterate `groupedQuestions.groups[].batches[]` and branch on `batch.batchMode` (`"safe"` — one uniform answer, `"opt-in"` — shared answer offered as default with per-node `split` override (#426), `"none"` — single-question). Instance notes, batch prompt templates per mode, replicas, split/skip/n/a, "skip remaining" early-exit affordance (surface before the first batch, re-surface every 3rd), 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.
|
|
154
163
|
|
|
164
|
+
**Pacing — one batch per message (#545):** Render exactly **one** batch per assistant message and **wait for the user's reply** before rendering the next. A `safe` / `opt-in` multi-instance batch is still **one** batch — render the shared prompt once and wait. Do **not** dump multiple batches in a single message and ask "Reply with answers numbered 1–N"; that defeats the paced Q&A UX. The total-batch count and the `skip remaining` affordance are surfaced once before batch 1 (and re-surfaced every 3rd batch per the appendix); they are not a license to bulk-render. The only exception is `skip remaining` — when the user invokes it, mark all unanswered batches as skipped and proceed straight to Step 4.
|
|
165
|
+
|
|
155
166
|
|
|
156
167
|
### Step 4: Apply gotcha answers to Figma design
|
|
157
168
|
|
|
169
|
+
#### Inline vs file staging (#531)
|
|
170
|
+
|
|
171
|
+
When the apply commands themselves are short (~≤ 200 lines / ~10 KB), assemble the `use_figma` `code` string inline in the same call — read the helper artifact, concatenate with the apply commands, and pass directly. Three tool calls (Write apply.js + Read combined → use_figma) for a 2–3 gotcha apply is overhead with no debug benefit beyond what `use_figma`'s own error message already provides. Only stage to `/tmp/canicode-apply.js` when the apply payload is large enough to bloat the model's reply (e.g. dozens of replicas, definition-write fan-out, or any single batch nearing the ~50KB `use_figma` ceiling). The helpers artifact may still be a separate Read; the staging tradeoff is about the **apply** payload, not the helpers.
|
|
172
|
+
|
|
158
173
|
#### Mandatory preflight — prepend one of the bundled helpers before any `CanICodeRoundtrip.*` call
|
|
159
174
|
|
|
160
175
|
`CanICodeRoundtrip` is **not** a Figma or MCP built-in. It is the global registered by a bundled IIFE shipped next to this skill — it only exists after you read the right artifact and prepend its contents verbatim at the top of every `use_figma` script string. Skipping this step throws `ReferenceError: 'CanICodeRoundtrip' is not defined` on the first `use_figma` batch.
|
|
@@ -271,6 +286,26 @@ Notes:
|
|
|
271
286
|
- `label` and `labelMarkdown` are mutually exclusive on write, but Figma returns both on readback. Never spread `scene.annotations` directly; always call `CanICodeRoundtrip.upsertCanicodeAnnotation` (or `CanICodeRoundtrip.stripAnnotations` if you truly need the normalized array).
|
|
272
287
|
- Prefer annotating the **scene** instance child so designers see the note where they work; mention in the markdown if the fix belongs on the source component but could not be applied (library/external).
|
|
273
288
|
|
|
289
|
+
##### Strategy C opt-out branch — `unmapped-component`
|
|
290
|
+
|
|
291
|
+
When `applyStrategy === "annotation"` AND `question.ruleId === "unmapped-component"` AND the user's answer expresses "intentionally unmapped" (LLM judgment on the prose — e.g. "skip permanently", "do not map", "intentionally unmapped"), call the dedicated opt-out helper instead of the standard `upsertCanicodeAnnotation` Q/A path:
|
|
292
|
+
|
|
293
|
+
<!-- adr-016-ack: single-helper call; no fan-out, the opt-out is per main component -->
|
|
294
|
+
```javascript
|
|
295
|
+
await CanICodeRoundtrip.applyUnmappedComponentOptOut(
|
|
296
|
+
{ nodeId: question.nodeId, ruleId: question.ruleId },
|
|
297
|
+
{ categories }
|
|
298
|
+
);
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
The helper writes a fenced canicode-json block with `kind: "rule-opt-out"` and `ruleId: "unmapped-component"` under `categories.gotcha`. The read-side pipeline (Step 5a + ADR-022 rule short-circuit) consumes this on subsequent analyze runs to suppress the rule for this node — see ADR-022 for the read-side pipeline that consumes this annotation.
|
|
302
|
+
|
|
303
|
+
Notes:
|
|
304
|
+
- **No prose body, no per-property intent.** The fence's `intent.kind` is the discriminator, not Q/A markdown.
|
|
305
|
+
- **No replica fan-out.** `unmapped-component` only fires on `COMPONENT` / `COMPONENT_SET` nodes (parser-driven main check). These never carry the `I…;…` instance-child id format, so `question.replicaNodeIds` is absent for this rule — do **not** iterate it. Writing an opt-out on an instance scene would no-op because the rule looks up the main component id when matching the ack.
|
|
306
|
+
- **Idempotent on re-apply.** The helper goes through `upsertCanicodeAnnotation`, so the footer-based dedup replaces an existing entry in place; running Step 4 twice yields one annotation, not two.
|
|
307
|
+
- **Distinct from a Step 3 skip.** Skipping a gotcha (`answer === "skip"` / `"n/a"`) drops the question without touching the design; the opt-out path writes a *permanent* suppression marker that survives across analyze runs. Choose the opt-out only when the user truly means "this component should never be code-connected"; for "skip for now", drop the question.
|
|
308
|
+
|
|
274
309
|
#### Strategy D: Auto-fix lower-severity issues from analysis
|
|
275
310
|
|
|
276
311
|
The gotcha survey covers blocking/risk severity plus `missing-info` severity from info-collection rules (#406 — currently `missing-prototype`, `missing-interaction-state`). All other lower-severity rules appear in `analyzeResult.issues[]` without a survey question. Each issue carries the same pre-computed fields (`applyStrategy`, `targetProperty`, `annotationProperties`, `suggestedName`, `isInstanceChild`, `sourceChildId`). The bundled helper handles the loop, the filter (`applyStrategy === "auto-fix"`), the naming-vs-annotation branch, and the per-issue outcome accumulator in one call:
|
|
@@ -367,6 +402,8 @@ The response now carries:
|
|
|
367
402
|
|
|
368
403
|
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).
|
|
369
404
|
|
|
405
|
+
**Grade-movement attribution (#547):** When the wrap-up shows a grade jump (e.g. `C+ → B+`), attribute the move to the resolved bucket (`✅` / `🔧` / `🌐`) explicitly so the user does not mis-infer that 📝 annotations contributed. Per ADR-012, annotations are zero-score by design — they carry context into code-gen but never move the grade. When `tally.Y > 0` (any 📝 annotated count), include a one-liner near the bucket tally clarifying this. Templates below already include the line; do not omit it.
|
|
406
|
+
|
|
370
407
|
**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:
|
|
371
408
|
|
|
372
409
|
```javascript
|
|
@@ -393,6 +430,8 @@ If Step 4 produced no `stepFourReport` (e.g. user skipped every question, or no
|
|
|
393
430
|
—
|
|
394
431
|
V issues remaining (unresolved gotchas + non-actionable rules)
|
|
395
432
|
|
|
433
|
+
*(When Y > 0)* 📝 annotations carry context into code-gen but do not change the grade — that is by design (ADR-012). Any grade movement comes from the ✅ / 🔧 / 🌐 buckets above.
|
|
434
|
+
|
|
396
435
|
Ready for code generation. *(Optional:) Report still shows grade **{grade}** — informational only.*
|
|
397
436
|
```
|
|
398
437
|
- 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:
|
|
@@ -425,6 +464,8 @@ for (const id of nodeIds) {
|
|
|
425
464
|
↳ V_ack acknowledged via canicode annotations (carried into code-gen)
|
|
426
465
|
↳ V_open unaddressed (no annotation — your follow-up backlog)
|
|
427
466
|
|
|
467
|
+
*(When Y > 0)* 📝 annotations carry context into code-gen but do not change the grade — that is by design (ADR-012). Any grade movement comes from the ✅ / 🔧 / 🌐 buckets above.
|
|
468
|
+
|
|
428
469
|
Proceed to code generation with remaining context? *(Optional footnote: report grade **{grade}**.)*
|
|
429
470
|
```
|
|
430
471
|
|
|
@@ -447,6 +488,8 @@ Stopped — N issues addressed, V remaining for manual follow-up:
|
|
|
447
488
|
↳ V_ack acknowledged via canicode annotations
|
|
448
489
|
↳ V_open unaddressed
|
|
449
490
|
|
|
491
|
+
*(When Y > 0)* 📝 annotations carry context into code-gen but do not change the grade — that is by design (ADR-012).
|
|
492
|
+
|
|
450
493
|
*(Optional)* Report grade: **{grade}**.
|
|
451
494
|
```
|
|
452
495
|
|
|
@@ -485,6 +528,8 @@ Roundtrip complete — N issues addressed, code generated:
|
|
|
485
528
|
↳ V_ack acknowledged via canicode annotations
|
|
486
529
|
↳ V_open unaddressed
|
|
487
530
|
|
|
531
|
+
*(When Y > 0)* 📝 annotations carry context into code-gen but do not change the grade — that is by design (ADR-012).
|
|
532
|
+
|
|
488
533
|
*(Optional)* Report grade: **{grade}**.
|
|
489
534
|
Code: <files generated / next-step pointer from figma-implement-design>
|
|
490
535
|
```
|
|
@@ -539,12 +584,19 @@ Call `get_code_connect_map` for the Figma component's node-id (from the original
|
|
|
539
584
|
|
|
540
585
|
#### Step 7d: Register the mapping
|
|
541
586
|
|
|
542
|
-
Call `add_code_connect_map` with the Figma node-id + generated code path
|
|
587
|
+
Call `add_code_connect_map` with the Figma node-id + generated code path. In single-mapping roundtrips this publishes the mapping synchronously — the server-side persistence happens at this call.
|
|
588
|
+
|
|
589
|
+
`send_code_connect_mappings` is a batch flush primitive intended for sessions that build up multiple mappings before publishing them as a transaction. In the single-mapping flow this skill drives, calling it after `add_code_connect_map` returns a duplicate / "no pending mappings" error because the mapping is already live. Treat that follow-up call as **optional**:
|
|
543
590
|
|
|
544
|
-
|
|
591
|
+
- **Recommended (single-mapping path):** skip `send_code_connect_mappings` entirely. `add_code_connect_map` is the publish point.
|
|
592
|
+
- **If you call it anyway** (e.g. defensive habit, or a future multi-mapping flow batches multiple `add_*` first): tolerate the duplicate / already-registered error explicitly. Do **not** narrate it as a failure to the user — the mapping is live. Verify with `get_code_connect_map` if confirmation is needed before the wrap-up line.
|
|
593
|
+
|
|
594
|
+
On success (i.e. `add_code_connect_map` returned without error), print:
|
|
545
595
|
> "Code Connect mapping registered: `<figma-component>` → `<code-path>`. Future roundtrips on screens containing this component will reuse the code."
|
|
546
596
|
|
|
547
|
-
|
|
597
|
+
The success line is **unconditional** once `add_code_connect_map` succeeds. Do not gate it on `send_code_connect_mappings` returning OK.
|
|
598
|
+
|
|
599
|
+
On failure of `add_code_connect_map` itself (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. The most common cause is the Figma component not being in a published library; #532 tracks shifting that check earlier into Step 1.5.
|
|
548
600
|
|
|
549
601
|
#### Wrap-up message rubric (with mapping outcome)
|
|
550
602
|
|
|
@@ -561,6 +613,8 @@ Roundtrip complete — N issues addressed, code generated, mapping <state>:
|
|
|
561
613
|
↳ V_ack acknowledged via canicode annotations
|
|
562
614
|
↳ V_open unaddressed
|
|
563
615
|
|
|
616
|
+
*(When Y > 0)* 📝 annotations carry context into code-gen but do not change the grade — that is by design (ADR-012).
|
|
617
|
+
|
|
564
618
|
Code: <files generated / next-step pointer from figma-implement-design>
|
|
565
619
|
Code Connect: <mapping outcome line>
|
|
566
620
|
```
|