canicode 0.11.2 → 0.11.3

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.
@@ -22,6 +22,7 @@ canicode analyze <url> --config ./my-config.json
22
22
  "excludeNodeTypes": ["VECTOR", "BOOLEAN_OPERATION", "SLICE"],
23
23
  "excludeNodeNames": ["chatbot", "ad-banner", "custom-widget"],
24
24
  "gridBase": 2,
25
+ "codegenReadyMinGrade": "A",
25
26
  "rules": {
26
27
  "no-auto-layout": { "score": -15, "severity": "blocking", "enabled": true },
27
28
  "raw-value": { "score": -5 },
@@ -37,6 +38,7 @@ canicode analyze <url> --config ./my-config.json
37
38
  | `excludeNodeTypes` | `string[]` | `[]` | Figma node types to skip entirely (node + children) |
38
39
  | `excludeNodeNames` | `string[]` | `[]` | Name patterns to skip (case-insensitive word match) |
39
40
  | `gridBase` | `number` | `2` | Spacing grid unit for `irregular-spacing` |
41
+ | `codegenReadyMinGrade` | `Grade` | `"A"` | Minimum grade for `isReadyForCodeGen` (one of `S`, `A+`, `A`, `B+`, `B`, `C+`, `C`, `D`, `F`). CLI `--ready-min-grade` and MCP `codegenReadyMinGrade` param override this. |
40
42
  | `rules` | `object` | — | Per-rule overrides (see below) |
41
43
 
42
44
  ### Node Exclusions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canicode",
3
- "version": "0.11.2",
3
+ "version": "0.11.3",
4
4
  "mcpName": "io.github.let-sunny/canicode",
5
5
  "description": "Lint Figma designs for AI code-gen and roundtrip the answers back into the file. CLI + MCP server.",
6
6
  "type": "module",
@@ -28,6 +28,7 @@
28
28
  "build:plugin": "bash scripts/build-plugin.sh",
29
29
  "sync-docs": "tsx scripts/sync-rule-docs.ts",
30
30
  "check:skill-determinism": "tsx scripts/check-skill-determinism.ts",
31
+ "check:server-json": "tsx scripts/check-server-json.ts",
31
32
  "clean": "rm -rf dist skills"
32
33
  },
33
34
  "files": [
@@ -42,16 +42,32 @@ npx canicode gotcha-survey "<figma-url-or-fixture-path>" --json
42
42
 
43
43
  Either channel returns:
44
44
  - `designGrade`: overall grade (S, A+, A, B+, B, C+, C, D, F)
45
- - `isReadyForCodeGen`: whether the design can be implemented without gotchas
45
+ - `isReadyForCodeGen`: recommendation flag — `true` suggests the survey can be skipped, but the user always confirms
46
46
  - `questions`: array of gotcha questions (may be empty)
47
47
 
48
- ### Step 2: Check if survey is needed
48
+ ### Step 2: Surface grade as informational banner
49
49
 
50
- If `isReadyForCodeGen` is `true` or `questions` is empty:
51
- - Tell the user: "This design scored **{designGrade}** and is ready for code generation — no gotchas to resolve."
50
+ Show the grade as a preamble — it is informational only, not a flow gate:
51
+
52
+ ```
53
+ Design scored **{designGrade}**.
54
+ ```
55
+
56
+ Then branch on `questions`:
57
+
58
+ **If `questions` is empty** (regardless of `isReadyForCodeGen`):
59
+ - Tell the user: "No gotchas surfaced — nothing to write."
52
60
  - Do NOT write to `.claude/skills/canicode-gotchas/SKILL.md`.
53
61
  - Stop here.
54
62
 
63
+ **If `questions` is non-empty AND `isReadyForCodeGen` is `true`** (high-grade design with optional questions):
64
+ - Tell the user: "**{N}** optional gotcha(s) surfaced. Would you like to review them?"
65
+ - **yes** → proceed to **Step 3**.
66
+ - **no** → do NOT write to `.claude/skills/canicode-gotchas/SKILL.md`; stop here.
67
+
68
+ **If `questions` is non-empty AND `isReadyForCodeGen` is `false`**:
69
+ - Proceed to **Step 3** (no additional message needed; the banner grade already communicates urgency).
70
+
55
71
  ### Step 3 — preamble: match the user's language
56
72
 
57
73
  Before rendering any question, detect the user's conversation language from their recent messages in **this** session. Korean vs. English vs. other is usually unambiguous; when ambiguous, default to English and ask the user once which language they prefer.
@@ -214,7 +230,7 @@ The Workflow region above must never be touched.
214
230
 
215
231
  ## Edge Cases
216
232
 
217
- - **No questions returned**: The design is ready for code generation. Inform the user and stop (Step 2). Do not touch `.claude/skills/canicode-gotchas/SKILL.md`.
233
+ - **No questions returned**: No gotchas surfaced. Inform the user and stop (Step 2). Do not touch `.claude/skills/canicode-gotchas/SKILL.md`.
218
234
  - **Re-run on the same design**: Replace that design's section in place (matched by `Design key`) — preserve the original `#NNN` number. Do NOT append a duplicate.
219
235
  - **Re-run on a different design**: Append a new section with the next `#NNN`. Prior designs' sections are untouched.
220
236
  - **Workflow region**: Never modified. If you notice the Workflow region has been edited by the user, leave their edits alone — only the `# Collected Gotchas` region is under skill control.
@@ -59,7 +59,7 @@ npx canicode analyze "<figma-url>" --json
59
59
 
60
60
  The response includes:
61
61
  - `scores.overall.grade`: design grade (S, A+, A, B+, B, C+, C, D, F)
62
- - `isReadyForCodeGen`: boolean gate for gotcha skip
62
+ - `isReadyForCodeGen`: recommendation flag `true` suggests the survey can be skipped, but the user always confirms
63
63
  - `issues`: array of design issues found
64
64
  - `summary`: human-readable analysis summary
65
65
 
@@ -69,14 +69,28 @@ Show the user a brief summary:
69
69
  Design grade: **{grade}** ({percentage}%) — {issueCount} issues found.
70
70
  ```
71
71
 
72
- ### Step 2: Gate check if gotchas are needed
72
+ ### Step 2: Surface grade as informational banner
73
73
 
74
- If `isReadyForCodeGen` is `true` (grade S, A+, or A):
75
- - Tell the user: "This design scored **{grade}** — ready for code generation with no gotchas needed."
76
- - Skip directly to **Step 6**.
74
+ Show the grade as a preamble banner — it is informational only, not a flow gate:
77
75
 
78
- If `isReadyForCodeGen` is `false` (grade B+ or below):
79
- - Tell the user: "This design scored **{grade}** — running gotcha survey to identify implementation pitfalls."
76
+ ```
77
+ Design scored **{grade}** ({percentage}%).
78
+ ```
79
+
80
+ Then branch on `questions` (not on `isReadyForCodeGen`):
81
+
82
+ **If `questions` is empty** (regardless of `isReadyForCodeGen`):
83
+ - Tell the user: "No gotchas surfaced. Continue to code generation?"
84
+ - If yes → proceed to **Step 6**.
85
+ - If no → stop.
86
+
87
+ **If `questions` is non-empty AND `isReadyForCodeGen` is `true`** (high-grade design with optional questions):
88
+ - Tell the user: "**{N}** optional gotcha(s) surfaced. Would you like to review them, or skip to code generation?"
89
+ - **review** → proceed to **Step 3**.
90
+ - **skip** → proceed to **Step 6**.
91
+
92
+ **If `questions` is non-empty AND `isReadyForCodeGen` is `false`** (grade B+ or below):
93
+ - Tell the user: "Running gotcha survey to surface implementation pitfalls."
80
94
  - Proceed to **Step 3**.
81
95
 
82
96
  ### Step 3: Run gotcha survey and collect answers
@@ -93,8 +107,6 @@ gotcha-survey({ input: "<figma-url>" })
93
107
  npx canicode gotcha-survey "<figma-url>" --json
94
108
  ```
95
109
 
96
- If `questions` is empty, skip to **Step 6**.
97
-
98
110
  #### Step 3 — preamble: match the user's language
99
111
 
100
112
  Detect the user's conversation language from their recent messages in **this** session (Korean vs. English vs. other is usually unambiguous; when ambiguous, default to English and ask once). When the user's language is non-English, localize only the human-readable rendering of questions, `why`, `Hint:`, `Example:`, and the batch shared-prompt wording (including the `split` / `skip` / `n/a` affordance sentence). Keep identifiers and structural markers English: `ruleId`, `nodeId`, severity label in brackets, and the entire upsert-section markdown scaffolding (`## #NNN — …`, `Design key`, `#### Skipped (N)`) — downstream tools grep these, and `renderGotchaSection` is the source of truth for on-disk markdown (ADR-016). In the Appendix Step 3 upsert, pass the user's answer through **verbatim** into `answers[<nodeId>].answer`; do **not** back-translate — `figma-implement-design` is cross-language by design (#461). See `.claude/skills/canicode-gotchas/SKILL.md` Step 3 preamble for the full rule.
@@ -6,7 +6,7 @@
6
6
  // globalThis.__canicodeBootstrapResult and throws ReferenceError so the agent re-prepends the
7
7
  // installer on the next batch.
8
8
  (function __canicodeBootstrap() {
9
- var expected = "0.11.2";
9
+ var expected = "0.11.3";
10
10
  var src = figma.root.getSharedPluginData("canicode", "helpersSrc");
11
11
  var actual = figma.root.getSharedPluginData("canicode", "helpersVersion");
12
12
  if (!src) {
@@ -3,7 +3,7 @@
3
3
  // figma.root via setSharedPluginData so subsequent batches can prepend the much smaller
4
4
  // helpers-bootstrap.js instead of re-pasting ~31KB every call (#424, ADR-020).
5
5
  var __CANICODE_HELPERS_SRC__ = "var CanICodeRoundtrip = (function (exports) {\n 'use strict';\n\n // src/core/roundtrip/annotations.ts\n function stripAnnotations(annotations) {\n const input = annotations ?? [];\n const out = [];\n for (const a of input) {\n const hasLM = typeof a.labelMarkdown === \"string\" && a.labelMarkdown.length > 0;\n const hasLabel = typeof a.label === \"string\" && a.label.length > 0;\n if (!hasLM && !hasLabel) continue;\n const base = hasLM ? { labelMarkdown: a.labelMarkdown } : { label: a.label };\n if (a.categoryId) base.categoryId = a.categoryId;\n if (Array.isArray(a.properties) && a.properties.length > 0) {\n base.properties = a.properties;\n }\n out.push(base);\n }\n return out;\n }\n async function ensureCanicodeCategories() {\n const api = figma.annotations;\n const existing = await api.getAnnotationCategoriesAsync();\n const byLabel = new Map(existing.map((c) => [c.label, c.id]));\n async function ensure(label, color) {\n const cached = byLabel.get(label);\n if (cached) return cached;\n const created = await api.addAnnotationCategoryAsync({ label, color });\n byLabel.set(label, created.id);\n return created.id;\n }\n const result = {\n gotcha: await ensure(\"canicode:gotcha\", \"blue\"),\n flag: await ensure(\"canicode:flag\", \"green\"),\n fallback: await ensure(\"canicode:fallback\", \"yellow\")\n };\n const legacyAutoFix = byLabel.get(\"canicode:auto-fix\");\n if (legacyAutoFix) result.legacyAutoFix = legacyAutoFix;\n return result;\n }\n function upsertCanicodeAnnotation(node, input) {\n if (!node || !(\"annotations\" in node)) return false;\n const { ruleId, markdown, categoryId, properties } = input;\n const legacyPrefix = `**[canicode] ${ruleId}**`;\n const footer = `\\u2014 *${ruleId}*`;\n let bodyText = markdown;\n if (bodyText.startsWith(legacyPrefix)) {\n bodyText = bodyText.slice(legacyPrefix.length).replace(/^\\s*\\n+/, \"\");\n }\n const trimmed = bodyText.replace(/\\s+$/, \"\");\n const body = trimmed.endsWith(footer) ? trimmed : `${trimmed}\n\n${footer}`;\n const existing = stripAnnotations(node.annotations);\n const entry = { labelMarkdown: body };\n if (categoryId) entry.categoryId = categoryId;\n if (properties && properties.length > 0) entry.properties = properties;\n const matchesRuleId = (text) => {\n if (typeof text !== \"string\") return false;\n return text.startsWith(legacyPrefix) || text.includes(footer);\n };\n const idx = existing.findIndex(\n (a) => matchesRuleId(a.labelMarkdown) || matchesRuleId(a.label)\n );\n if (idx >= 0) existing[idx] = entry;\n else existing.push(entry);\n try {\n node.annotations = existing;\n return true;\n } catch (e) {\n const msg = String(e?.message ?? e);\n const isNodeTypeReject = /invalid property .+ for a .+ node/i.test(msg);\n if (!entry.properties || !isNodeTypeReject) throw e;\n delete entry.properties;\n if (idx >= 0) existing[idx] = entry;\n node.annotations = existing;\n return true;\n }\n }\n\n // src/core/roundtrip/annotation-payload.ts\n var CANICODE_JSON_FENCE = \"```canicode-json\";\n function formatIntentValueForDisplay(value) {\n if (value === void 0) return \"undefined\";\n if (value === null) return \"null\";\n if (typeof value === \"object\") {\n try {\n return `\\`${JSON.stringify(value)}\\``;\n } catch {\n return String(value);\n }\n }\n return `\\`${String(value)}\\``;\n }\n function buildCodegenDirective(args) {\n const { sceneNodeId, intent } = args;\n const val = intent.value === void 0 ? \"undefined\" : JSON.stringify(intent.value);\n return `When generating code for node ${sceneNodeId}, set ${intent.field} to ${val} (user intent, scope: ${intent.scope}). Prefer this over the current Figma scene value when they disagree.`;\n }\n function sceneOutcomeToAck(result, reason) {\n return reason !== void 0 ? { result, reason } : { result };\n }\n function buildOutcomeHumanLine(args) {\n if (args.skippedDefinitionDueToAdr012) {\n const adrHint = \" Canicode skipped writing the source component without `allowDefinitionWrite: true` (ADR-012 safer default). The instance-level change did not apply as intended in the scene.\";\n if (args.reason === \"silent-ignore\") {\n return \"**Scene write outcome:** The write ran, but the property value did not change on this instance (silent-ignore).\" + adrHint;\n }\n return \"**Scene write outcome:** Figma rejected an instance-level change\" + (args.errorMessage ? `: ${args.errorMessage}` : \"\") + \".\" + adrHint;\n }\n if (args.reason === \"silent-ignore\") {\n return \"**Scene write outcome:** The write ran, but the property value did not change on this instance (silent-ignore). No source definition was available to escalate.\";\n }\n if (args.reason === \"override-error\") {\n return \"**Scene write outcome:** Figma rejected an instance-level change\" + (args.errorMessage ? `: ${args.errorMessage}` : \"\") + \". No source definition was available to escalate.\";\n }\n return \"**Scene write outcome:** Could not apply automatically\" + (args.errorMessage ? `: ${args.errorMessage}` : \"\") + \".\";\n }\n function buildAdr012PropagationParagraph(args) {\n const { componentName, replicaCount } = args;\n const fanOutHint = typeof replicaCount === \"number\" && replicaCount >= 2 ? ` This batched question covers ${replicaCount} instance scenes \\u2014 changing **${componentName}** at the definition still affects every inheriting instance, not just one row in the batch.` : \"\";\n return `Canicode's safer default (ADR-012) is to skip writing the source component **${componentName}** without explicit opt-in, because that write propagates to every non-overridden instance of **${componentName}** in the file.${fanOutHint} Prefer a manual override on **this** instance when you only need a local fix. Use \\`allowDefinitionWrite: true\\` only when you intend to change **${componentName}** for all inheriting instances \\u2014 it is not a neutral shortcut for a single-instance tweak.`;\n }\n function buildDefinitionWriteSkippedBody(args) {\n const {\n ruleId,\n sceneNodeId,\n componentName,\n reason,\n errorMessage,\n replicaCount,\n intent\n } = args;\n const ackIntent = intent ? {\n field: intent.field,\n value: intent.value,\n scope: intent.scope\n } : void 0;\n const sceneWriteOutcome = sceneOutcomeToAck(\"user-declined-propagation\", \"adr-012-opt-in-disabled\");\n const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;\n const jsonBlock = {\n v: 1,\n ruleId,\n nodeId: sceneNodeId,\n ...ackIntent ? { intent: ackIntent } : {},\n sceneWriteOutcome,\n ...codegenDirective ? { codegenDirective } : {}\n };\n const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;\n const outcomeLine = buildOutcomeHumanLine({\n reason,\n ...errorMessage !== void 0 ? { errorMessage } : {},\n skippedDefinitionDueToAdr012: true\n });\n const adrBlock = buildAdr012PropagationParagraph({\n componentName,\n ...replicaCount !== void 0 ? { replicaCount } : {}\n });\n const proseParts = [userAnswerLine, outcomeLine, adrBlock].filter(\n (p) => p !== null\n );\n const prose = proseParts.join(\"\\n\\n\");\n return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);\n }\n function buildNoDefinitionFallbackBody(args) {\n const { ruleId, sceneNodeId, reason, errorMessage, intent } = args;\n const ackIntent = intent ? { field: intent.field, value: intent.value, scope: intent.scope } : void 0;\n const outcomeResult = reason === \"silent-ignore\" ? \"silent-ignored\" : reason === \"override-error\" ? \"api-rejected\" : \"api-rejected\";\n const sceneWriteOutcome = sceneOutcomeToAck(\n outcomeResult,\n reason === \"silent-ignore\" ? \"silent-ignore-no-definition\" : \"no-definition-escalation\"\n );\n const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;\n const jsonBlock = {\n v: 1,\n ruleId,\n nodeId: sceneNodeId,\n ...ackIntent ? { intent: ackIntent } : {},\n sceneWriteOutcome,\n ...codegenDirective ? { codegenDirective } : {}\n };\n const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;\n const outcomeLine = buildOutcomeHumanLine({\n reason,\n ...errorMessage !== void 0 ? { errorMessage } : {},\n skippedDefinitionDueToAdr012: false\n });\n const prose = [userAnswerLine, outcomeLine].filter((p) => p !== null).join(\"\\n\\n\");\n return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);\n }\n function buildDefinitionTierFailureBody(args) {\n const { ruleId, sceneNodeId, intent, kind, errorMessage } = args;\n const sceneWriteOutcome = sceneOutcomeToAck(\n kind === \"read-only-library\" ? \"api-rejected\" : \"api-rejected\",\n kind === \"read-only-library\" ? \"definition-read-only\" : \"definition-write-failed\"\n );\n const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;\n const jsonBlock = {\n v: 1,\n ruleId,\n nodeId: sceneNodeId,\n ...intent ? {\n intent: {\n field: intent.field,\n value: intent.value,\n scope: intent.scope\n }\n } : {},\n sceneWriteOutcome,\n ...codegenDirective ? { codegenDirective } : {}\n };\n const human = kind === \"read-only-library\" ? \"source component lives in an external library and is read-only from this file \\u2014 apply the fix in the library file itself.\" : `could not apply at source definition: ${errorMessage}`;\n const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;\n const outcomeLine = `**Scene write outcome:** ${human}`;\n const prose = [userAnswerLine, outcomeLine].filter((p) => p !== null).join(\"\\n\\n\");\n return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);\n }\n function appendJsonFenceAndFooter(prose, jsonBlock, ruleId) {\n const footer = `\\u2014 *${ruleId}*`;\n const hasIntent = jsonBlock.intent !== void 0;\n if (!hasIntent) {\n return `${prose}\n\n${footer}`;\n }\n const jsonText = JSON.stringify(jsonBlock, null, 0);\n return `${prose}\n\n${CANICODE_JSON_FENCE}\n${jsonText}\n\\`\\`\\`\n\n${footer}`;\n }\n var FENCED_JSON_RE = new RegExp(\n `${CANICODE_JSON_FENCE.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}\\\\s*([\\\\s\\\\S]*?)\\\\s*\\`\\`\\``,\n \"m\"\n );\n function parseCanicodeJsonPayloadFromMarkdown(text) {\n const m = FENCED_JSON_RE.exec(text);\n if (!m?.[1]) return void 0;\n try {\n const raw = JSON.parse(m[1].trim());\n if (!raw || typeof raw !== \"object\") return void 0;\n const o = raw;\n if (o.v !== 1 || typeof o.ruleId !== \"string\") return void 0;\n return raw;\n } catch {\n return void 0;\n }\n }\n\n // src/core/roundtrip/apply-with-instance-fallback.ts\n var DEFINITION_WRITE_SKIPPED_EVENT = \"cic_roundtrip_definition_write_skipped\";\n function categoryIdForAnnotate(categories, kind, roundtripIntent) {\n if (kind === \"adr012-definition-skipped\") {\n return categories.fallback;\n }\n if (roundtripIntent !== void 0) {\n return categories.gotcha;\n }\n return categories.flag;\n }\n function resolveSourceComponentName(definition, question) {\n if (definition && typeof definition.name === \"string\" && definition.name) {\n return definition.name;\n }\n const ic = question.instanceContext;\n if (ic && typeof ic.sourceComponentName === \"string\" && ic.sourceComponentName) {\n return ic.sourceComponentName;\n }\n return \"the source component\";\n }\n async function routeToDefinitionOrAnnotate(definition, writeFn, ctx) {\n if (definition && !ctx.allowDefinitionWrite && ctx.reason !== \"non-override-error\") {\n const componentName = resolveSourceComponentName(definition, ctx.question);\n const replicaCount = typeof ctx.question.replicas === \"number\" && Number.isInteger(ctx.question.replicas) ? ctx.question.replicas : void 0;\n if (ctx.categories) {\n upsertCanicodeAnnotation(ctx.scene, {\n ruleId: ctx.question.ruleId,\n markdown: buildDefinitionWriteSkippedBody({\n ruleId: ctx.question.ruleId,\n sceneNodeId: ctx.scene.id,\n componentName,\n reason: ctx.reason,\n ...ctx.errorMessage !== void 0 ? { errorMessage: ctx.errorMessage } : {},\n ...replicaCount !== void 0 ? { replicaCount } : {},\n ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {}\n }),\n categoryId: categoryIdForAnnotate(\n ctx.categories,\n \"adr012-definition-skipped\",\n ctx.roundtripIntent\n )\n });\n }\n ctx.telemetry?.(DEFINITION_WRITE_SKIPPED_EVENT, {\n ruleId: ctx.question.ruleId,\n reason: ctx.reason\n });\n return {\n icon: \"\\u{1F4DD}\",\n label: \"definition write skipped (opt-in disabled)\"\n };\n }\n if (!definition) {\n if (ctx.categories) {\n const markdown = buildNoDefinitionFallbackBody({\n ruleId: ctx.question.ruleId,\n sceneNodeId: ctx.scene.id,\n reason: ctx.reason,\n ...ctx.errorMessage !== void 0 ? { errorMessage: ctx.errorMessage } : {},\n ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {}\n });\n upsertCanicodeAnnotation(ctx.scene, {\n ruleId: ctx.question.ruleId,\n markdown,\n categoryId: categoryIdForAnnotate(\n ctx.categories,\n \"other-failure\",\n ctx.roundtripIntent\n )\n });\n }\n return ctx.reason === \"silent-ignore\" ? { icon: \"\\u{1F4DD}\", label: \"silent-ignore, annotated\" } : { icon: \"\\u{1F4DD}\", label: `error: ${ctx.errorMessage ?? \"\"}` };\n }\n try {\n await writeFn(definition);\n return {\n icon: \"\\u{1F310}\",\n label: ctx.reason === \"silent-ignore\" ? \"source definition (silent-ignore fallback)\" : \"source definition\"\n };\n } catch (defErr) {\n const defMsg = String(defErr?.message ?? defErr);\n const isRemoteReadOnly = definition.remote === true || /read-only/i.test(defMsg);\n if (ctx.categories) {\n upsertCanicodeAnnotation(ctx.scene, {\n ruleId: ctx.question.ruleId,\n markdown: buildDefinitionTierFailureBody({\n ruleId: ctx.question.ruleId,\n sceneNodeId: ctx.scene.id,\n ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {},\n kind: isRemoteReadOnly ? \"read-only-library\" : \"definition-error\",\n errorMessage: defMsg\n }),\n categoryId: categoryIdForAnnotate(\n ctx.categories,\n \"other-failure\",\n ctx.roundtripIntent\n )\n });\n }\n return {\n icon: \"\\u{1F4DD}\",\n label: isRemoteReadOnly ? \"external library (read-only)\" : `definition error: ${defMsg}`\n };\n }\n }\n async function applyWithInstanceFallback(question, writeFn, context = {}) {\n const { categories, allowDefinitionWrite = false, telemetry, roundtripIntent } = context;\n const scene = await figma.getNodeByIdAsync(question.nodeId);\n if (!scene) return { icon: \"\\u{1F4DD}\", label: \"missing node\" };\n const definition = question.sourceChildId ? await figma.getNodeByIdAsync(question.sourceChildId) : null;\n try {\n const changed = await writeFn(scene);\n if (changed === false) {\n return routeToDefinitionOrAnnotate(definition, writeFn, {\n question,\n scene,\n categories,\n reason: \"silent-ignore\",\n allowDefinitionWrite,\n telemetry,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n });\n }\n return { icon: \"\\u2705\", label: \"instance/scene\" };\n } catch (e) {\n const msg = String(e?.message ?? e);\n const looksLikeInstanceOverride = /cannot be overridden/i.test(msg) || /override/i.test(msg);\n if (!looksLikeInstanceOverride) {\n return routeToDefinitionOrAnnotate(null, writeFn, {\n question,\n scene,\n categories,\n reason: \"non-override-error\",\n errorMessage: msg,\n allowDefinitionWrite,\n telemetry,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n });\n }\n return routeToDefinitionOrAnnotate(definition, writeFn, {\n question,\n scene,\n categories,\n reason: \"override-error\",\n errorMessage: msg,\n allowDefinitionWrite,\n telemetry,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n });\n }\n }\n\n // src/core/roundtrip/apply-property-mod.ts\n async function resolveVariableByName(name) {\n const locals = await figma.variables.getLocalVariablesAsync();\n return locals.find((v) => v.name === name) ?? null;\n }\n function parseValue(raw) {\n if (raw && typeof raw === \"object\" && \"variable\" in raw) {\n const v = raw;\n const parsed = { kind: \"binding\", name: v.variable };\n if (\"fallback\" in v) parsed.fallback = v.fallback;\n return parsed;\n }\n if (raw && typeof raw === \"object\" && \"fallback\" in raw) {\n return { kind: \"scalar\", scalar: raw.fallback };\n }\n return { kind: \"scalar\", scalar: raw };\n }\n function isPaintProp(prop) {\n return prop === \"fills\" || prop === \"strokes\";\n }\n function applyPropertyBinding(target, prop, variable) {\n if (isPaintProp(prop)) {\n const current = target[prop];\n if (current === figma.mixed || !Array.isArray(current)) return false;\n const paints = current;\n const bound = paints.map(\n (paint) => figma.variables.setBoundVariableForPaint(paint, \"color\", variable)\n );\n target[prop] = bound;\n return true;\n }\n target.setBoundVariable(prop, variable);\n return true;\n }\n function buildRoundtripIntentFromPropertyAnswer(question, answerValue) {\n const raw = question.targetProperty;\n if (raw === void 0) return void 0;\n const props = Array.isArray(raw) ? raw : [raw];\n if (props.length === 0) return void 0;\n if (props.length === 1) {\n const prop = props[0];\n const perProp = answerValue && typeof answerValue === \"object\" && !(\"variable\" in answerValue) && !Array.isArray(answerValue) ? answerValue[prop] : answerValue;\n const parsed = parseValueForIntent(perProp);\n if (parsed === void 0) return void 0;\n return { field: prop, value: parsed, scope: \"instance\" };\n }\n const obj = answerValue && typeof answerValue === \"object\" && !(\"variable\" in answerValue) && !Array.isArray(answerValue) ? answerValue : void 0;\n const picked = {};\n for (const p of props) {\n if (obj && p in obj && obj[p] !== void 0) picked[p] = obj[p];\n }\n if (Object.keys(picked).length === 0) return void 0;\n return {\n field: props.join(\", \"),\n value: picked,\n scope: \"instance\"\n };\n }\n function parseValueForIntent(raw) {\n if (raw && typeof raw === \"object\" && \"variable\" in raw) {\n return { variable: raw.variable };\n }\n if (raw && typeof raw === \"object\" && \"fallback\" in raw) {\n return raw.fallback;\n }\n return raw;\n }\n function applyPropertyScalar(target, prop, scalar) {\n const rec = target;\n const before = rec[prop];\n rec[prop] = scalar;\n if (rec[prop] === before && before !== scalar) return false;\n return true;\n }\n async function applyPropertyMod(question, answerValue, context = {}) {\n const roundtripIntent = buildRoundtripIntentFromPropertyAnswer(\n question,\n answerValue\n );\n const props = Array.isArray(question.targetProperty) ? question.targetProperty : question.targetProperty !== void 0 ? [question.targetProperty] : [];\n return applyWithInstanceFallback(\n question,\n async (target) => {\n if (!target) return void 0;\n let changed = void 0;\n for (const prop of props) {\n if (!(prop in target)) continue;\n const perProp = answerValue && typeof answerValue === \"object\" && !(\"variable\" in answerValue) && !Array.isArray(answerValue) ? answerValue[prop] : answerValue;\n const parsed = parseValue(perProp);\n if (parsed.kind === \"binding\") {\n const variable = await resolveVariableByName(parsed.name);\n if (variable) {\n applyPropertyBinding(target, prop, variable);\n continue;\n }\n if (parsed.fallback !== void 0) {\n if (!applyPropertyScalar(target, prop, parsed.fallback)) {\n changed = false;\n }\n }\n continue;\n }\n if (parsed.scalar === void 0) continue;\n if (!applyPropertyScalar(target, prop, parsed.scalar)) {\n changed = false;\n }\n }\n return changed;\n },\n {\n ...context,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n }\n );\n }\n\n // src/core/roundtrip/probe-definition-writability.ts\n async function probeDefinitionWritability(questions) {\n const verdict = /* @__PURE__ */ new Map();\n const unwritableNames = [];\n const seenName = /* @__PURE__ */ new Set();\n for (const q of questions) {\n const id = q.sourceChildId;\n if (!id) continue;\n if (verdict.has(id)) continue;\n const node = await figma.getNodeByIdAsync(id);\n const writability = resolveWritability(node);\n const isUnwritable = writability.isUnwritable;\n verdict.set(id, isUnwritable ? \"unwritable\" : \"writable\");\n if (isUnwritable) {\n const name = typeof writability.componentName === \"string\" && writability.componentName || typeof node?.name === \"string\" && node.name || q.instanceContext?.sourceComponentName || id;\n if (!seenName.has(name)) {\n seenName.add(name);\n unwritableNames.push(name);\n }\n }\n }\n const totalCount = verdict.size;\n let unwritableCount = 0;\n for (const v of verdict.values()) if (v === \"unwritable\") unwritableCount++;\n return {\n totalCount,\n unwritableCount,\n unwritableSourceNames: unwritableNames,\n allUnwritable: totalCount > 0 && unwritableCount === totalCount,\n partiallyUnwritable: unwritableCount > 0 && unwritableCount < totalCount\n };\n }\n function resolveWritability(node) {\n if (node === null) return { isUnwritable: true };\n if (\"remote\" in node && typeof node.remote === \"boolean\") {\n return { isUnwritable: node.remote === true };\n }\n const containing = findContainingComponent(node);\n if (!containing) {\n return { isUnwritable: false };\n }\n const isUnwritable = \"remote\" in containing && containing.remote === true;\n return {\n isUnwritable,\n ...isUnwritable && typeof containing.name === \"string\" ? { componentName: containing.name } : {}\n };\n }\n function findContainingComponent(node) {\n let cur = node;\n for (let i = 0; i < 100 && cur; i++) {\n if (cur.type === \"COMPONENT\" || cur.type === \"COMPONENT_SET\") return cur;\n cur = cur.parent ?? null;\n }\n return null;\n }\n\n // src/core/roundtrip/read-acknowledgments.ts\n var FOOTER_RE = /—\\s+\\*([A-Za-z0-9-]+)\\*\\s*$/;\n var LEGACY_PREFIX_RE = /^\\*\\*\\[canicode\\]\\s+([A-Za-z0-9-]+)\\*\\*/;\n function extractAcknowledgmentsFromNode(node, canicodeCategoryIds) {\n if (!node || !(\"annotations\" in node)) return [];\n const annotations = node.annotations ?? [];\n if (annotations.length === 0) return [];\n const out = [];\n for (const a of annotations) {\n const text = (typeof a.labelMarkdown === \"string\" && a.labelMarkdown.length > 0 ? a.labelMarkdown : \"\") || (typeof a.label === \"string\" && a.label.length > 0 ? a.label : \"\");\n if (!text) continue;\n if (canicodeCategoryIds) {\n if (!a.categoryId || !canicodeCategoryIds.has(a.categoryId)) continue;\n }\n const ruleId = extractRuleId(text);\n if (!ruleId) continue;\n const payload = parseCanicodeJsonPayloadFromMarkdown(text);\n const payloadAligned = payload && payload.ruleId === ruleId;\n out.push({\n nodeId: node.id,\n ruleId,\n ...payloadAligned && payload.intent ? { intent: payload.intent } : {},\n ...payloadAligned && payload.sceneWriteOutcome ? { sceneWriteOutcome: payload.sceneWriteOutcome } : {},\n ...payloadAligned && payload.codegenDirective ? { codegenDirective: payload.codegenDirective } : {}\n });\n }\n return out;\n }\n function extractRuleId(text) {\n const footer = FOOTER_RE.exec(text);\n if (footer) return footer[1] ?? null;\n const legacy = LEGACY_PREFIX_RE.exec(text);\n if (legacy) return legacy[1] ?? null;\n return null;\n }\n async function readCanicodeAcknowledgments(rootNodeId, categories) {\n const root = await figma.getNodeByIdAsync(rootNodeId);\n if (!root) return [];\n const canicodeCategoryIds = categories ? new Set(\n [\n categories.gotcha,\n categories.flag,\n categories.fallback,\n categories.legacyAutoFix\n ].filter((id) => typeof id === \"string\" && id.length > 0)\n ) : void 0;\n const out = [];\n walk(root, canicodeCategoryIds, out);\n return out;\n }\n function safeChildren(node) {\n try {\n const c = node.children;\n return Array.isArray(c) ? c : [];\n } catch {\n return [];\n }\n }\n function walk(node, canicodeCategoryIds, out) {\n try {\n const local = extractAcknowledgmentsFromNode(node, canicodeCategoryIds);\n for (const a of local) out.push(a);\n } catch {\n }\n for (const child of safeChildren(node)) {\n if (child && typeof child === \"object\") walk(child, canicodeCategoryIds, out);\n }\n }\n\n // src/core/roundtrip/compute-roundtrip-tally.ts\n function computeRoundtripTally(args) {\n const { stepFourReport, reanalyzeResponse } = args;\n const { resolved, annotated, definitionWritten, skipped } = stepFourReport;\n const { issueCount, acknowledgedCount } = reanalyzeResponse;\n if (acknowledgedCount > issueCount) {\n throw new Error(\n `computeRoundtripTally: reanalyzeResponse.acknowledgedCount (${acknowledgedCount}) cannot exceed issueCount (${issueCount}). Acknowledged issues are a subset of remaining issues.`\n );\n }\n return {\n X: resolved,\n Y: annotated,\n Z: definitionWritten,\n W: skipped,\n N: resolved + annotated + definitionWritten + skipped,\n V: issueCount,\n V_ack: acknowledgedCount,\n V_open: issueCount - acknowledgedCount\n };\n }\n\n // src/core/roundtrip/apply-auto-fix.ts\n function pickNodeName(issue, resolved) {\n if (resolved && typeof resolved.name === \"string\" && resolved.name.length > 0) {\n return resolved.name;\n }\n if (typeof issue.nodePath === \"string\" && issue.nodePath.length > 0) {\n const segments = issue.nodePath.split(/\\s*[›>/]\\s*/);\n const tail = segments[segments.length - 1];\n if (tail && tail.length > 0) return tail;\n }\n return issue.nodeId;\n }\n function mapInstanceFallbackIcon(result) {\n if (result.icon === \"\\u2705\") return \"\\u{1F527}\";\n return result.icon;\n }\n async function applyAutoFix(issue, context) {\n const { categories } = context;\n const ruleId = issue.ruleId;\n if (issue.targetProperty === \"name\" && typeof issue.suggestedName === \"string\") {\n const suggestedName = issue.suggestedName;\n const question = {\n nodeId: issue.nodeId,\n ruleId,\n ...issue.sourceChildId ? { sourceChildId: issue.sourceChildId } : {}\n };\n const result = await applyWithInstanceFallback(\n question,\n (target) => {\n if (target) {\n target.name = suggestedName;\n }\n },\n {\n categories,\n ...context.allowDefinitionWrite !== void 0 ? { allowDefinitionWrite: context.allowDefinitionWrite } : {},\n ...context.telemetry !== void 0 ? { telemetry: context.telemetry } : {}\n }\n );\n const sceneAfter = await figma.getNodeByIdAsync(issue.nodeId);\n return {\n outcome: mapInstanceFallbackIcon(result),\n nodeId: issue.nodeId,\n nodeName: pickNodeName(issue, sceneAfter),\n ruleId,\n label: result.label\n };\n }\n const scene = await figma.getNodeByIdAsync(issue.nodeId);\n const markdown = issue.message ?? `Auto-flagged: ${ruleId}`;\n if (scene) {\n upsertCanicodeAnnotation(scene, {\n ruleId,\n markdown,\n categoryId: categories.flag,\n ...issue.annotationProperties && issue.annotationProperties.length > 0 ? { properties: issue.annotationProperties } : {}\n });\n }\n return {\n outcome: \"\\u{1F4DD}\",\n nodeId: issue.nodeId,\n nodeName: pickNodeName(issue, scene),\n ruleId,\n label: scene ? `annotation added to canicode:flag \\u2014 ${ruleId}` : `missing node (annotation skipped) \\u2014 ${ruleId}`\n };\n }\n async function applyAutoFixes(issues, context) {\n const out = [];\n for (const issue of issues) {\n if (issue.applyStrategy !== \"auto-fix\") {\n out.push({\n outcome: \"\\u23ED\\uFE0F\",\n nodeId: issue.nodeId,\n nodeName: pickNodeName(issue, null),\n ruleId: issue.ruleId,\n label: `skipped \\u2014 applyStrategy is ${issue.applyStrategy ?? \"absent\"}`\n });\n continue;\n }\n out.push(await applyAutoFix(issue, context));\n }\n return out;\n }\n\n // src/core/roundtrip/remove-canicode-annotations.ts\n var LEGACY_CANICODE_PREFIX = \"**[canicode]\";\n function isCanicodeAnnotation(annotation, categories) {\n const canicodeIds = new Set(\n [\n categories.gotcha,\n categories.flag,\n categories.fallback,\n categories.legacyAutoFix\n ].filter((id) => Boolean(id))\n );\n if (annotation.categoryId && canicodeIds.has(annotation.categoryId)) {\n return true;\n }\n if (annotation.labelMarkdown?.startsWith(LEGACY_CANICODE_PREFIX)) {\n return true;\n }\n return false;\n }\n function removeCanicodeAnnotations(annotations, categories) {\n return annotations.filter((a) => !isCanicodeAnnotation(a, categories));\n }\n\n exports.applyAutoFix = applyAutoFix;\n exports.applyAutoFixes = applyAutoFixes;\n exports.applyPropertyMod = applyPropertyMod;\n exports.applyWithInstanceFallback = applyWithInstanceFallback;\n exports.computeRoundtripTally = computeRoundtripTally;\n exports.ensureCanicodeCategories = ensureCanicodeCategories;\n exports.extractAcknowledgmentsFromNode = extractAcknowledgmentsFromNode;\n exports.isCanicodeAnnotation = isCanicodeAnnotation;\n exports.probeDefinitionWritability = probeDefinitionWritability;\n exports.readCanicodeAcknowledgments = readCanicodeAcknowledgments;\n exports.removeCanicodeAnnotations = removeCanicodeAnnotations;\n exports.resolveVariableByName = resolveVariableByName;\n exports.stripAnnotations = stripAnnotations;\n exports.upsertCanicodeAnnotation = upsertCanicodeAnnotation;\n\n return exports;\n\n})({});\n";
6
- var __CANICODE_HELPERS_VERSION__ = "0.11.2";
6
+ var __CANICODE_HELPERS_VERSION__ = "0.11.3";
7
7
  (0, eval)(__CANICODE_HELPERS_SRC__);
8
8
  try {
9
9
  figma.root.setSharedPluginData("canicode", "helpersSrc", __CANICODE_HELPERS_SRC__);
@@ -42,16 +42,32 @@ npx canicode gotcha-survey "<figma-url-or-fixture-path>" --json
42
42
 
43
43
  Either channel returns:
44
44
  - `designGrade`: overall grade (S, A+, A, B+, B, C+, C, D, F)
45
- - `isReadyForCodeGen`: whether the design can be implemented without gotchas
45
+ - `isReadyForCodeGen`: recommendation flag — `true` suggests the survey can be skipped, but the user always confirms
46
46
  - `questions`: array of gotcha questions (may be empty)
47
47
 
48
- ### Step 2: Check if survey is needed
48
+ ### Step 2: Surface grade as informational banner
49
49
 
50
- If `isReadyForCodeGen` is `true` or `questions` is empty:
51
- - Tell the user: "This design scored **{designGrade}** and is ready for code generation — no gotchas to resolve."
50
+ Show the grade as a preamble — it is informational only, not a flow gate:
51
+
52
+ ```
53
+ Design scored **{designGrade}**.
54
+ ```
55
+
56
+ Then branch on `questions`:
57
+
58
+ **If `questions` is empty** (regardless of `isReadyForCodeGen`):
59
+ - Tell the user: "No gotchas surfaced — nothing to write."
52
60
  - Do NOT write to `.claude/skills/canicode-gotchas/SKILL.md`.
53
61
  - Stop here.
54
62
 
63
+ **If `questions` is non-empty AND `isReadyForCodeGen` is `true`** (high-grade design with optional questions):
64
+ - Tell the user: "**{N}** optional gotcha(s) surfaced. Would you like to review them?"
65
+ - **yes** → proceed to **Step 3**.
66
+ - **no** → do NOT write to `.claude/skills/canicode-gotchas/SKILL.md`; stop here.
67
+
68
+ **If `questions` is non-empty AND `isReadyForCodeGen` is `false`**:
69
+ - Proceed to **Step 3** (no additional message needed; the banner grade already communicates urgency).
70
+
55
71
  ### Step 3 — preamble: match the user's language
56
72
 
57
73
  Before rendering any question, detect the user's conversation language from their recent messages in **this** session. Korean vs. English vs. other is usually unambiguous; when ambiguous, default to English and ask the user once which language they prefer.
@@ -214,7 +230,7 @@ The Workflow region above must never be touched.
214
230
 
215
231
  ## Edge Cases
216
232
 
217
- - **No questions returned**: The design is ready for code generation. Inform the user and stop (Step 2). Do not touch `.claude/skills/canicode-gotchas/SKILL.md`.
233
+ - **No questions returned**: No gotchas surfaced. Inform the user and stop (Step 2). Do not touch `.claude/skills/canicode-gotchas/SKILL.md`.
218
234
  - **Re-run on the same design**: Replace that design's section in place (matched by `Design key`) — preserve the original `#NNN` number. Do NOT append a duplicate.
219
235
  - **Re-run on a different design**: Append a new section with the next `#NNN`. Prior designs' sections are untouched.
220
236
  - **Workflow region**: Never modified. If you notice the Workflow region has been edited by the user, leave their edits alone — only the `# Collected Gotchas` region is under skill control.
@@ -59,7 +59,7 @@ npx canicode analyze "<figma-url>" --json
59
59
 
60
60
  The response includes:
61
61
  - `scores.overall.grade`: design grade (S, A+, A, B+, B, C+, C, D, F)
62
- - `isReadyForCodeGen`: boolean gate for gotcha skip
62
+ - `isReadyForCodeGen`: recommendation flag `true` suggests the survey can be skipped, but the user always confirms
63
63
  - `issues`: array of design issues found
64
64
  - `summary`: human-readable analysis summary
65
65
 
@@ -69,14 +69,28 @@ Show the user a brief summary:
69
69
  Design grade: **{grade}** ({percentage}%) — {issueCount} issues found.
70
70
  ```
71
71
 
72
- ### Step 2: Gate check if gotchas are needed
72
+ ### Step 2: Surface grade as informational banner
73
73
 
74
- If `isReadyForCodeGen` is `true` (grade S, A+, or A):
75
- - Tell the user: "This design scored **{grade}** — ready for code generation with no gotchas needed."
76
- - Skip directly to **Step 6**.
74
+ Show the grade as a preamble banner — it is informational only, not a flow gate:
77
75
 
78
- If `isReadyForCodeGen` is `false` (grade B+ or below):
79
- - Tell the user: "This design scored **{grade}** — running gotcha survey to identify implementation pitfalls."
76
+ ```
77
+ Design scored **{grade}** ({percentage}%).
78
+ ```
79
+
80
+ Then branch on `questions` (not on `isReadyForCodeGen`):
81
+
82
+ **If `questions` is empty** (regardless of `isReadyForCodeGen`):
83
+ - Tell the user: "No gotchas surfaced. Continue to code generation?"
84
+ - If yes → proceed to **Step 6**.
85
+ - If no → stop.
86
+
87
+ **If `questions` is non-empty AND `isReadyForCodeGen` is `true`** (high-grade design with optional questions):
88
+ - Tell the user: "**{N}** optional gotcha(s) surfaced. Would you like to review them, or skip to code generation?"
89
+ - **review** → proceed to **Step 3**.
90
+ - **skip** → proceed to **Step 6**.
91
+
92
+ **If `questions` is non-empty AND `isReadyForCodeGen` is `false`** (grade B+ or below):
93
+ - Tell the user: "Running gotcha survey to surface implementation pitfalls."
80
94
  - Proceed to **Step 3**.
81
95
 
82
96
  ### Step 3: Run gotcha survey and collect answers
@@ -93,8 +107,6 @@ gotcha-survey({ input: "<figma-url>" })
93
107
  npx canicode gotcha-survey "<figma-url>" --json
94
108
  ```
95
109
 
96
- If `questions` is empty, skip to **Step 6**.
97
-
98
110
  #### Step 3 — preamble: match the user's language
99
111
 
100
112
  Detect the user's conversation language from their recent messages in **this** session (Korean vs. English vs. other is usually unambiguous; when ambiguous, default to English and ask once). When the user's language is non-English, localize only the human-readable rendering of questions, `why`, `Hint:`, `Example:`, and the batch shared-prompt wording (including the `split` / `skip` / `n/a` affordance sentence). Keep identifiers and structural markers English: `ruleId`, `nodeId`, severity label in brackets, and the entire upsert-section markdown scaffolding (`## #NNN — …`, `Design key`, `#### Skipped (N)`) — downstream tools grep these, and `renderGotchaSection` is the source of truth for on-disk markdown (ADR-016). In the Appendix Step 3 upsert, pass the user's answer through **verbatim** into `answers[<nodeId>].answer`; do **not** back-translate — `figma-implement-design` is cross-language by design (#461). See `.claude/skills/canicode-gotchas/SKILL.md` Step 3 preamble for the full rule.
@@ -6,7 +6,7 @@
6
6
  // globalThis.__canicodeBootstrapResult and throws ReferenceError so the agent re-prepends the
7
7
  // installer on the next batch.
8
8
  (function __canicodeBootstrap() {
9
- var expected = "0.11.2";
9
+ var expected = "0.11.3";
10
10
  var src = figma.root.getSharedPluginData("canicode", "helpersSrc");
11
11
  var actual = figma.root.getSharedPluginData("canicode", "helpersVersion");
12
12
  if (!src) {
@@ -3,7 +3,7 @@
3
3
  // figma.root via setSharedPluginData so subsequent batches can prepend the much smaller
4
4
  // helpers-bootstrap.js instead of re-pasting ~31KB every call (#424, ADR-020).
5
5
  var __CANICODE_HELPERS_SRC__ = "var CanICodeRoundtrip = (function (exports) {\n 'use strict';\n\n // src/core/roundtrip/annotations.ts\n function stripAnnotations(annotations) {\n const input = annotations ?? [];\n const out = [];\n for (const a of input) {\n const hasLM = typeof a.labelMarkdown === \"string\" && a.labelMarkdown.length > 0;\n const hasLabel = typeof a.label === \"string\" && a.label.length > 0;\n if (!hasLM && !hasLabel) continue;\n const base = hasLM ? { labelMarkdown: a.labelMarkdown } : { label: a.label };\n if (a.categoryId) base.categoryId = a.categoryId;\n if (Array.isArray(a.properties) && a.properties.length > 0) {\n base.properties = a.properties;\n }\n out.push(base);\n }\n return out;\n }\n async function ensureCanicodeCategories() {\n const api = figma.annotations;\n const existing = await api.getAnnotationCategoriesAsync();\n const byLabel = new Map(existing.map((c) => [c.label, c.id]));\n async function ensure(label, color) {\n const cached = byLabel.get(label);\n if (cached) return cached;\n const created = await api.addAnnotationCategoryAsync({ label, color });\n byLabel.set(label, created.id);\n return created.id;\n }\n const result = {\n gotcha: await ensure(\"canicode:gotcha\", \"blue\"),\n flag: await ensure(\"canicode:flag\", \"green\"),\n fallback: await ensure(\"canicode:fallback\", \"yellow\")\n };\n const legacyAutoFix = byLabel.get(\"canicode:auto-fix\");\n if (legacyAutoFix) result.legacyAutoFix = legacyAutoFix;\n return result;\n }\n function upsertCanicodeAnnotation(node, input) {\n if (!node || !(\"annotations\" in node)) return false;\n const { ruleId, markdown, categoryId, properties } = input;\n const legacyPrefix = `**[canicode] ${ruleId}**`;\n const footer = `\\u2014 *${ruleId}*`;\n let bodyText = markdown;\n if (bodyText.startsWith(legacyPrefix)) {\n bodyText = bodyText.slice(legacyPrefix.length).replace(/^\\s*\\n+/, \"\");\n }\n const trimmed = bodyText.replace(/\\s+$/, \"\");\n const body = trimmed.endsWith(footer) ? trimmed : `${trimmed}\n\n${footer}`;\n const existing = stripAnnotations(node.annotations);\n const entry = { labelMarkdown: body };\n if (categoryId) entry.categoryId = categoryId;\n if (properties && properties.length > 0) entry.properties = properties;\n const matchesRuleId = (text) => {\n if (typeof text !== \"string\") return false;\n return text.startsWith(legacyPrefix) || text.includes(footer);\n };\n const idx = existing.findIndex(\n (a) => matchesRuleId(a.labelMarkdown) || matchesRuleId(a.label)\n );\n if (idx >= 0) existing[idx] = entry;\n else existing.push(entry);\n try {\n node.annotations = existing;\n return true;\n } catch (e) {\n const msg = String(e?.message ?? e);\n const isNodeTypeReject = /invalid property .+ for a .+ node/i.test(msg);\n if (!entry.properties || !isNodeTypeReject) throw e;\n delete entry.properties;\n if (idx >= 0) existing[idx] = entry;\n node.annotations = existing;\n return true;\n }\n }\n\n // src/core/roundtrip/annotation-payload.ts\n var CANICODE_JSON_FENCE = \"```canicode-json\";\n function formatIntentValueForDisplay(value) {\n if (value === void 0) return \"undefined\";\n if (value === null) return \"null\";\n if (typeof value === \"object\") {\n try {\n return `\\`${JSON.stringify(value)}\\``;\n } catch {\n return String(value);\n }\n }\n return `\\`${String(value)}\\``;\n }\n function buildCodegenDirective(args) {\n const { sceneNodeId, intent } = args;\n const val = intent.value === void 0 ? \"undefined\" : JSON.stringify(intent.value);\n return `When generating code for node ${sceneNodeId}, set ${intent.field} to ${val} (user intent, scope: ${intent.scope}). Prefer this over the current Figma scene value when they disagree.`;\n }\n function sceneOutcomeToAck(result, reason) {\n return reason !== void 0 ? { result, reason } : { result };\n }\n function buildOutcomeHumanLine(args) {\n if (args.skippedDefinitionDueToAdr012) {\n const adrHint = \" Canicode skipped writing the source component without `allowDefinitionWrite: true` (ADR-012 safer default). The instance-level change did not apply as intended in the scene.\";\n if (args.reason === \"silent-ignore\") {\n return \"**Scene write outcome:** The write ran, but the property value did not change on this instance (silent-ignore).\" + adrHint;\n }\n return \"**Scene write outcome:** Figma rejected an instance-level change\" + (args.errorMessage ? `: ${args.errorMessage}` : \"\") + \".\" + adrHint;\n }\n if (args.reason === \"silent-ignore\") {\n return \"**Scene write outcome:** The write ran, but the property value did not change on this instance (silent-ignore). No source definition was available to escalate.\";\n }\n if (args.reason === \"override-error\") {\n return \"**Scene write outcome:** Figma rejected an instance-level change\" + (args.errorMessage ? `: ${args.errorMessage}` : \"\") + \". No source definition was available to escalate.\";\n }\n return \"**Scene write outcome:** Could not apply automatically\" + (args.errorMessage ? `: ${args.errorMessage}` : \"\") + \".\";\n }\n function buildAdr012PropagationParagraph(args) {\n const { componentName, replicaCount } = args;\n const fanOutHint = typeof replicaCount === \"number\" && replicaCount >= 2 ? ` This batched question covers ${replicaCount} instance scenes \\u2014 changing **${componentName}** at the definition still affects every inheriting instance, not just one row in the batch.` : \"\";\n return `Canicode's safer default (ADR-012) is to skip writing the source component **${componentName}** without explicit opt-in, because that write propagates to every non-overridden instance of **${componentName}** in the file.${fanOutHint} Prefer a manual override on **this** instance when you only need a local fix. Use \\`allowDefinitionWrite: true\\` only when you intend to change **${componentName}** for all inheriting instances \\u2014 it is not a neutral shortcut for a single-instance tweak.`;\n }\n function buildDefinitionWriteSkippedBody(args) {\n const {\n ruleId,\n sceneNodeId,\n componentName,\n reason,\n errorMessage,\n replicaCount,\n intent\n } = args;\n const ackIntent = intent ? {\n field: intent.field,\n value: intent.value,\n scope: intent.scope\n } : void 0;\n const sceneWriteOutcome = sceneOutcomeToAck(\"user-declined-propagation\", \"adr-012-opt-in-disabled\");\n const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;\n const jsonBlock = {\n v: 1,\n ruleId,\n nodeId: sceneNodeId,\n ...ackIntent ? { intent: ackIntent } : {},\n sceneWriteOutcome,\n ...codegenDirective ? { codegenDirective } : {}\n };\n const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;\n const outcomeLine = buildOutcomeHumanLine({\n reason,\n ...errorMessage !== void 0 ? { errorMessage } : {},\n skippedDefinitionDueToAdr012: true\n });\n const adrBlock = buildAdr012PropagationParagraph({\n componentName,\n ...replicaCount !== void 0 ? { replicaCount } : {}\n });\n const proseParts = [userAnswerLine, outcomeLine, adrBlock].filter(\n (p) => p !== null\n );\n const prose = proseParts.join(\"\\n\\n\");\n return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);\n }\n function buildNoDefinitionFallbackBody(args) {\n const { ruleId, sceneNodeId, reason, errorMessage, intent } = args;\n const ackIntent = intent ? { field: intent.field, value: intent.value, scope: intent.scope } : void 0;\n const outcomeResult = reason === \"silent-ignore\" ? \"silent-ignored\" : reason === \"override-error\" ? \"api-rejected\" : \"api-rejected\";\n const sceneWriteOutcome = sceneOutcomeToAck(\n outcomeResult,\n reason === \"silent-ignore\" ? \"silent-ignore-no-definition\" : \"no-definition-escalation\"\n );\n const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;\n const jsonBlock = {\n v: 1,\n ruleId,\n nodeId: sceneNodeId,\n ...ackIntent ? { intent: ackIntent } : {},\n sceneWriteOutcome,\n ...codegenDirective ? { codegenDirective } : {}\n };\n const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;\n const outcomeLine = buildOutcomeHumanLine({\n reason,\n ...errorMessage !== void 0 ? { errorMessage } : {},\n skippedDefinitionDueToAdr012: false\n });\n const prose = [userAnswerLine, outcomeLine].filter((p) => p !== null).join(\"\\n\\n\");\n return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);\n }\n function buildDefinitionTierFailureBody(args) {\n const { ruleId, sceneNodeId, intent, kind, errorMessage } = args;\n const sceneWriteOutcome = sceneOutcomeToAck(\n kind === \"read-only-library\" ? \"api-rejected\" : \"api-rejected\",\n kind === \"read-only-library\" ? \"definition-read-only\" : \"definition-write-failed\"\n );\n const codegenDirective = intent !== void 0 ? buildCodegenDirective({ sceneNodeId, intent }) : void 0;\n const jsonBlock = {\n v: 1,\n ruleId,\n nodeId: sceneNodeId,\n ...intent ? {\n intent: {\n field: intent.field,\n value: intent.value,\n scope: intent.scope\n }\n } : {},\n sceneWriteOutcome,\n ...codegenDirective ? { codegenDirective } : {}\n };\n const human = kind === \"read-only-library\" ? \"source component lives in an external library and is read-only from this file \\u2014 apply the fix in the library file itself.\" : `could not apply at source definition: ${errorMessage}`;\n const userAnswerLine = intent !== void 0 ? `**User answered:** ${formatIntentValueForDisplay(intent.value)} for **${intent.field}** (scope: ${intent.scope}).` : null;\n const outcomeLine = `**Scene write outcome:** ${human}`;\n const prose = [userAnswerLine, outcomeLine].filter((p) => p !== null).join(\"\\n\\n\");\n return appendJsonFenceAndFooter(prose, jsonBlock, ruleId);\n }\n function appendJsonFenceAndFooter(prose, jsonBlock, ruleId) {\n const footer = `\\u2014 *${ruleId}*`;\n const hasIntent = jsonBlock.intent !== void 0;\n if (!hasIntent) {\n return `${prose}\n\n${footer}`;\n }\n const jsonText = JSON.stringify(jsonBlock, null, 0);\n return `${prose}\n\n${CANICODE_JSON_FENCE}\n${jsonText}\n\\`\\`\\`\n\n${footer}`;\n }\n var FENCED_JSON_RE = new RegExp(\n `${CANICODE_JSON_FENCE.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}\\\\s*([\\\\s\\\\S]*?)\\\\s*\\`\\`\\``,\n \"m\"\n );\n function parseCanicodeJsonPayloadFromMarkdown(text) {\n const m = FENCED_JSON_RE.exec(text);\n if (!m?.[1]) return void 0;\n try {\n const raw = JSON.parse(m[1].trim());\n if (!raw || typeof raw !== \"object\") return void 0;\n const o = raw;\n if (o.v !== 1 || typeof o.ruleId !== \"string\") return void 0;\n return raw;\n } catch {\n return void 0;\n }\n }\n\n // src/core/roundtrip/apply-with-instance-fallback.ts\n var DEFINITION_WRITE_SKIPPED_EVENT = \"cic_roundtrip_definition_write_skipped\";\n function categoryIdForAnnotate(categories, kind, roundtripIntent) {\n if (kind === \"adr012-definition-skipped\") {\n return categories.fallback;\n }\n if (roundtripIntent !== void 0) {\n return categories.gotcha;\n }\n return categories.flag;\n }\n function resolveSourceComponentName(definition, question) {\n if (definition && typeof definition.name === \"string\" && definition.name) {\n return definition.name;\n }\n const ic = question.instanceContext;\n if (ic && typeof ic.sourceComponentName === \"string\" && ic.sourceComponentName) {\n return ic.sourceComponentName;\n }\n return \"the source component\";\n }\n async function routeToDefinitionOrAnnotate(definition, writeFn, ctx) {\n if (definition && !ctx.allowDefinitionWrite && ctx.reason !== \"non-override-error\") {\n const componentName = resolveSourceComponentName(definition, ctx.question);\n const replicaCount = typeof ctx.question.replicas === \"number\" && Number.isInteger(ctx.question.replicas) ? ctx.question.replicas : void 0;\n if (ctx.categories) {\n upsertCanicodeAnnotation(ctx.scene, {\n ruleId: ctx.question.ruleId,\n markdown: buildDefinitionWriteSkippedBody({\n ruleId: ctx.question.ruleId,\n sceneNodeId: ctx.scene.id,\n componentName,\n reason: ctx.reason,\n ...ctx.errorMessage !== void 0 ? { errorMessage: ctx.errorMessage } : {},\n ...replicaCount !== void 0 ? { replicaCount } : {},\n ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {}\n }),\n categoryId: categoryIdForAnnotate(\n ctx.categories,\n \"adr012-definition-skipped\",\n ctx.roundtripIntent\n )\n });\n }\n ctx.telemetry?.(DEFINITION_WRITE_SKIPPED_EVENT, {\n ruleId: ctx.question.ruleId,\n reason: ctx.reason\n });\n return {\n icon: \"\\u{1F4DD}\",\n label: \"definition write skipped (opt-in disabled)\"\n };\n }\n if (!definition) {\n if (ctx.categories) {\n const markdown = buildNoDefinitionFallbackBody({\n ruleId: ctx.question.ruleId,\n sceneNodeId: ctx.scene.id,\n reason: ctx.reason,\n ...ctx.errorMessage !== void 0 ? { errorMessage: ctx.errorMessage } : {},\n ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {}\n });\n upsertCanicodeAnnotation(ctx.scene, {\n ruleId: ctx.question.ruleId,\n markdown,\n categoryId: categoryIdForAnnotate(\n ctx.categories,\n \"other-failure\",\n ctx.roundtripIntent\n )\n });\n }\n return ctx.reason === \"silent-ignore\" ? { icon: \"\\u{1F4DD}\", label: \"silent-ignore, annotated\" } : { icon: \"\\u{1F4DD}\", label: `error: ${ctx.errorMessage ?? \"\"}` };\n }\n try {\n await writeFn(definition);\n return {\n icon: \"\\u{1F310}\",\n label: ctx.reason === \"silent-ignore\" ? \"source definition (silent-ignore fallback)\" : \"source definition\"\n };\n } catch (defErr) {\n const defMsg = String(defErr?.message ?? defErr);\n const isRemoteReadOnly = definition.remote === true || /read-only/i.test(defMsg);\n if (ctx.categories) {\n upsertCanicodeAnnotation(ctx.scene, {\n ruleId: ctx.question.ruleId,\n markdown: buildDefinitionTierFailureBody({\n ruleId: ctx.question.ruleId,\n sceneNodeId: ctx.scene.id,\n ...ctx.roundtripIntent !== void 0 ? { intent: ctx.roundtripIntent } : {},\n kind: isRemoteReadOnly ? \"read-only-library\" : \"definition-error\",\n errorMessage: defMsg\n }),\n categoryId: categoryIdForAnnotate(\n ctx.categories,\n \"other-failure\",\n ctx.roundtripIntent\n )\n });\n }\n return {\n icon: \"\\u{1F4DD}\",\n label: isRemoteReadOnly ? \"external library (read-only)\" : `definition error: ${defMsg}`\n };\n }\n }\n async function applyWithInstanceFallback(question, writeFn, context = {}) {\n const { categories, allowDefinitionWrite = false, telemetry, roundtripIntent } = context;\n const scene = await figma.getNodeByIdAsync(question.nodeId);\n if (!scene) return { icon: \"\\u{1F4DD}\", label: \"missing node\" };\n const definition = question.sourceChildId ? await figma.getNodeByIdAsync(question.sourceChildId) : null;\n try {\n const changed = await writeFn(scene);\n if (changed === false) {\n return routeToDefinitionOrAnnotate(definition, writeFn, {\n question,\n scene,\n categories,\n reason: \"silent-ignore\",\n allowDefinitionWrite,\n telemetry,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n });\n }\n return { icon: \"\\u2705\", label: \"instance/scene\" };\n } catch (e) {\n const msg = String(e?.message ?? e);\n const looksLikeInstanceOverride = /cannot be overridden/i.test(msg) || /override/i.test(msg);\n if (!looksLikeInstanceOverride) {\n return routeToDefinitionOrAnnotate(null, writeFn, {\n question,\n scene,\n categories,\n reason: \"non-override-error\",\n errorMessage: msg,\n allowDefinitionWrite,\n telemetry,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n });\n }\n return routeToDefinitionOrAnnotate(definition, writeFn, {\n question,\n scene,\n categories,\n reason: \"override-error\",\n errorMessage: msg,\n allowDefinitionWrite,\n telemetry,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n });\n }\n }\n\n // src/core/roundtrip/apply-property-mod.ts\n async function resolveVariableByName(name) {\n const locals = await figma.variables.getLocalVariablesAsync();\n return locals.find((v) => v.name === name) ?? null;\n }\n function parseValue(raw) {\n if (raw && typeof raw === \"object\" && \"variable\" in raw) {\n const v = raw;\n const parsed = { kind: \"binding\", name: v.variable };\n if (\"fallback\" in v) parsed.fallback = v.fallback;\n return parsed;\n }\n if (raw && typeof raw === \"object\" && \"fallback\" in raw) {\n return { kind: \"scalar\", scalar: raw.fallback };\n }\n return { kind: \"scalar\", scalar: raw };\n }\n function isPaintProp(prop) {\n return prop === \"fills\" || prop === \"strokes\";\n }\n function applyPropertyBinding(target, prop, variable) {\n if (isPaintProp(prop)) {\n const current = target[prop];\n if (current === figma.mixed || !Array.isArray(current)) return false;\n const paints = current;\n const bound = paints.map(\n (paint) => figma.variables.setBoundVariableForPaint(paint, \"color\", variable)\n );\n target[prop] = bound;\n return true;\n }\n target.setBoundVariable(prop, variable);\n return true;\n }\n function buildRoundtripIntentFromPropertyAnswer(question, answerValue) {\n const raw = question.targetProperty;\n if (raw === void 0) return void 0;\n const props = Array.isArray(raw) ? raw : [raw];\n if (props.length === 0) return void 0;\n if (props.length === 1) {\n const prop = props[0];\n const perProp = answerValue && typeof answerValue === \"object\" && !(\"variable\" in answerValue) && !Array.isArray(answerValue) ? answerValue[prop] : answerValue;\n const parsed = parseValueForIntent(perProp);\n if (parsed === void 0) return void 0;\n return { field: prop, value: parsed, scope: \"instance\" };\n }\n const obj = answerValue && typeof answerValue === \"object\" && !(\"variable\" in answerValue) && !Array.isArray(answerValue) ? answerValue : void 0;\n const picked = {};\n for (const p of props) {\n if (obj && p in obj && obj[p] !== void 0) picked[p] = obj[p];\n }\n if (Object.keys(picked).length === 0) return void 0;\n return {\n field: props.join(\", \"),\n value: picked,\n scope: \"instance\"\n };\n }\n function parseValueForIntent(raw) {\n if (raw && typeof raw === \"object\" && \"variable\" in raw) {\n return { variable: raw.variable };\n }\n if (raw && typeof raw === \"object\" && \"fallback\" in raw) {\n return raw.fallback;\n }\n return raw;\n }\n function applyPropertyScalar(target, prop, scalar) {\n const rec = target;\n const before = rec[prop];\n rec[prop] = scalar;\n if (rec[prop] === before && before !== scalar) return false;\n return true;\n }\n async function applyPropertyMod(question, answerValue, context = {}) {\n const roundtripIntent = buildRoundtripIntentFromPropertyAnswer(\n question,\n answerValue\n );\n const props = Array.isArray(question.targetProperty) ? question.targetProperty : question.targetProperty !== void 0 ? [question.targetProperty] : [];\n return applyWithInstanceFallback(\n question,\n async (target) => {\n if (!target) return void 0;\n let changed = void 0;\n for (const prop of props) {\n if (!(prop in target)) continue;\n const perProp = answerValue && typeof answerValue === \"object\" && !(\"variable\" in answerValue) && !Array.isArray(answerValue) ? answerValue[prop] : answerValue;\n const parsed = parseValue(perProp);\n if (parsed.kind === \"binding\") {\n const variable = await resolveVariableByName(parsed.name);\n if (variable) {\n applyPropertyBinding(target, prop, variable);\n continue;\n }\n if (parsed.fallback !== void 0) {\n if (!applyPropertyScalar(target, prop, parsed.fallback)) {\n changed = false;\n }\n }\n continue;\n }\n if (parsed.scalar === void 0) continue;\n if (!applyPropertyScalar(target, prop, parsed.scalar)) {\n changed = false;\n }\n }\n return changed;\n },\n {\n ...context,\n ...roundtripIntent !== void 0 ? { roundtripIntent } : {}\n }\n );\n }\n\n // src/core/roundtrip/probe-definition-writability.ts\n async function probeDefinitionWritability(questions) {\n const verdict = /* @__PURE__ */ new Map();\n const unwritableNames = [];\n const seenName = /* @__PURE__ */ new Set();\n for (const q of questions) {\n const id = q.sourceChildId;\n if (!id) continue;\n if (verdict.has(id)) continue;\n const node = await figma.getNodeByIdAsync(id);\n const writability = resolveWritability(node);\n const isUnwritable = writability.isUnwritable;\n verdict.set(id, isUnwritable ? \"unwritable\" : \"writable\");\n if (isUnwritable) {\n const name = typeof writability.componentName === \"string\" && writability.componentName || typeof node?.name === \"string\" && node.name || q.instanceContext?.sourceComponentName || id;\n if (!seenName.has(name)) {\n seenName.add(name);\n unwritableNames.push(name);\n }\n }\n }\n const totalCount = verdict.size;\n let unwritableCount = 0;\n for (const v of verdict.values()) if (v === \"unwritable\") unwritableCount++;\n return {\n totalCount,\n unwritableCount,\n unwritableSourceNames: unwritableNames,\n allUnwritable: totalCount > 0 && unwritableCount === totalCount,\n partiallyUnwritable: unwritableCount > 0 && unwritableCount < totalCount\n };\n }\n function resolveWritability(node) {\n if (node === null) return { isUnwritable: true };\n if (\"remote\" in node && typeof node.remote === \"boolean\") {\n return { isUnwritable: node.remote === true };\n }\n const containing = findContainingComponent(node);\n if (!containing) {\n return { isUnwritable: false };\n }\n const isUnwritable = \"remote\" in containing && containing.remote === true;\n return {\n isUnwritable,\n ...isUnwritable && typeof containing.name === \"string\" ? { componentName: containing.name } : {}\n };\n }\n function findContainingComponent(node) {\n let cur = node;\n for (let i = 0; i < 100 && cur; i++) {\n if (cur.type === \"COMPONENT\" || cur.type === \"COMPONENT_SET\") return cur;\n cur = cur.parent ?? null;\n }\n return null;\n }\n\n // src/core/roundtrip/read-acknowledgments.ts\n var FOOTER_RE = /—\\s+\\*([A-Za-z0-9-]+)\\*\\s*$/;\n var LEGACY_PREFIX_RE = /^\\*\\*\\[canicode\\]\\s+([A-Za-z0-9-]+)\\*\\*/;\n function extractAcknowledgmentsFromNode(node, canicodeCategoryIds) {\n if (!node || !(\"annotations\" in node)) return [];\n const annotations = node.annotations ?? [];\n if (annotations.length === 0) return [];\n const out = [];\n for (const a of annotations) {\n const text = (typeof a.labelMarkdown === \"string\" && a.labelMarkdown.length > 0 ? a.labelMarkdown : \"\") || (typeof a.label === \"string\" && a.label.length > 0 ? a.label : \"\");\n if (!text) continue;\n if (canicodeCategoryIds) {\n if (!a.categoryId || !canicodeCategoryIds.has(a.categoryId)) continue;\n }\n const ruleId = extractRuleId(text);\n if (!ruleId) continue;\n const payload = parseCanicodeJsonPayloadFromMarkdown(text);\n const payloadAligned = payload && payload.ruleId === ruleId;\n out.push({\n nodeId: node.id,\n ruleId,\n ...payloadAligned && payload.intent ? { intent: payload.intent } : {},\n ...payloadAligned && payload.sceneWriteOutcome ? { sceneWriteOutcome: payload.sceneWriteOutcome } : {},\n ...payloadAligned && payload.codegenDirective ? { codegenDirective: payload.codegenDirective } : {}\n });\n }\n return out;\n }\n function extractRuleId(text) {\n const footer = FOOTER_RE.exec(text);\n if (footer) return footer[1] ?? null;\n const legacy = LEGACY_PREFIX_RE.exec(text);\n if (legacy) return legacy[1] ?? null;\n return null;\n }\n async function readCanicodeAcknowledgments(rootNodeId, categories) {\n const root = await figma.getNodeByIdAsync(rootNodeId);\n if (!root) return [];\n const canicodeCategoryIds = categories ? new Set(\n [\n categories.gotcha,\n categories.flag,\n categories.fallback,\n categories.legacyAutoFix\n ].filter((id) => typeof id === \"string\" && id.length > 0)\n ) : void 0;\n const out = [];\n walk(root, canicodeCategoryIds, out);\n return out;\n }\n function safeChildren(node) {\n try {\n const c = node.children;\n return Array.isArray(c) ? c : [];\n } catch {\n return [];\n }\n }\n function walk(node, canicodeCategoryIds, out) {\n try {\n const local = extractAcknowledgmentsFromNode(node, canicodeCategoryIds);\n for (const a of local) out.push(a);\n } catch {\n }\n for (const child of safeChildren(node)) {\n if (child && typeof child === \"object\") walk(child, canicodeCategoryIds, out);\n }\n }\n\n // src/core/roundtrip/compute-roundtrip-tally.ts\n function computeRoundtripTally(args) {\n const { stepFourReport, reanalyzeResponse } = args;\n const { resolved, annotated, definitionWritten, skipped } = stepFourReport;\n const { issueCount, acknowledgedCount } = reanalyzeResponse;\n if (acknowledgedCount > issueCount) {\n throw new Error(\n `computeRoundtripTally: reanalyzeResponse.acknowledgedCount (${acknowledgedCount}) cannot exceed issueCount (${issueCount}). Acknowledged issues are a subset of remaining issues.`\n );\n }\n return {\n X: resolved,\n Y: annotated,\n Z: definitionWritten,\n W: skipped,\n N: resolved + annotated + definitionWritten + skipped,\n V: issueCount,\n V_ack: acknowledgedCount,\n V_open: issueCount - acknowledgedCount\n };\n }\n\n // src/core/roundtrip/apply-auto-fix.ts\n function pickNodeName(issue, resolved) {\n if (resolved && typeof resolved.name === \"string\" && resolved.name.length > 0) {\n return resolved.name;\n }\n if (typeof issue.nodePath === \"string\" && issue.nodePath.length > 0) {\n const segments = issue.nodePath.split(/\\s*[›>/]\\s*/);\n const tail = segments[segments.length - 1];\n if (tail && tail.length > 0) return tail;\n }\n return issue.nodeId;\n }\n function mapInstanceFallbackIcon(result) {\n if (result.icon === \"\\u2705\") return \"\\u{1F527}\";\n return result.icon;\n }\n async function applyAutoFix(issue, context) {\n const { categories } = context;\n const ruleId = issue.ruleId;\n if (issue.targetProperty === \"name\" && typeof issue.suggestedName === \"string\") {\n const suggestedName = issue.suggestedName;\n const question = {\n nodeId: issue.nodeId,\n ruleId,\n ...issue.sourceChildId ? { sourceChildId: issue.sourceChildId } : {}\n };\n const result = await applyWithInstanceFallback(\n question,\n (target) => {\n if (target) {\n target.name = suggestedName;\n }\n },\n {\n categories,\n ...context.allowDefinitionWrite !== void 0 ? { allowDefinitionWrite: context.allowDefinitionWrite } : {},\n ...context.telemetry !== void 0 ? { telemetry: context.telemetry } : {}\n }\n );\n const sceneAfter = await figma.getNodeByIdAsync(issue.nodeId);\n return {\n outcome: mapInstanceFallbackIcon(result),\n nodeId: issue.nodeId,\n nodeName: pickNodeName(issue, sceneAfter),\n ruleId,\n label: result.label\n };\n }\n const scene = await figma.getNodeByIdAsync(issue.nodeId);\n const markdown = issue.message ?? `Auto-flagged: ${ruleId}`;\n if (scene) {\n upsertCanicodeAnnotation(scene, {\n ruleId,\n markdown,\n categoryId: categories.flag,\n ...issue.annotationProperties && issue.annotationProperties.length > 0 ? { properties: issue.annotationProperties } : {}\n });\n }\n return {\n outcome: \"\\u{1F4DD}\",\n nodeId: issue.nodeId,\n nodeName: pickNodeName(issue, scene),\n ruleId,\n label: scene ? `annotation added to canicode:flag \\u2014 ${ruleId}` : `missing node (annotation skipped) \\u2014 ${ruleId}`\n };\n }\n async function applyAutoFixes(issues, context) {\n const out = [];\n for (const issue of issues) {\n if (issue.applyStrategy !== \"auto-fix\") {\n out.push({\n outcome: \"\\u23ED\\uFE0F\",\n nodeId: issue.nodeId,\n nodeName: pickNodeName(issue, null),\n ruleId: issue.ruleId,\n label: `skipped \\u2014 applyStrategy is ${issue.applyStrategy ?? \"absent\"}`\n });\n continue;\n }\n out.push(await applyAutoFix(issue, context));\n }\n return out;\n }\n\n // src/core/roundtrip/remove-canicode-annotations.ts\n var LEGACY_CANICODE_PREFIX = \"**[canicode]\";\n function isCanicodeAnnotation(annotation, categories) {\n const canicodeIds = new Set(\n [\n categories.gotcha,\n categories.flag,\n categories.fallback,\n categories.legacyAutoFix\n ].filter((id) => Boolean(id))\n );\n if (annotation.categoryId && canicodeIds.has(annotation.categoryId)) {\n return true;\n }\n if (annotation.labelMarkdown?.startsWith(LEGACY_CANICODE_PREFIX)) {\n return true;\n }\n return false;\n }\n function removeCanicodeAnnotations(annotations, categories) {\n return annotations.filter((a) => !isCanicodeAnnotation(a, categories));\n }\n\n exports.applyAutoFix = applyAutoFix;\n exports.applyAutoFixes = applyAutoFixes;\n exports.applyPropertyMod = applyPropertyMod;\n exports.applyWithInstanceFallback = applyWithInstanceFallback;\n exports.computeRoundtripTally = computeRoundtripTally;\n exports.ensureCanicodeCategories = ensureCanicodeCategories;\n exports.extractAcknowledgmentsFromNode = extractAcknowledgmentsFromNode;\n exports.isCanicodeAnnotation = isCanicodeAnnotation;\n exports.probeDefinitionWritability = probeDefinitionWritability;\n exports.readCanicodeAcknowledgments = readCanicodeAcknowledgments;\n exports.removeCanicodeAnnotations = removeCanicodeAnnotations;\n exports.resolveVariableByName = resolveVariableByName;\n exports.stripAnnotations = stripAnnotations;\n exports.upsertCanicodeAnnotation = upsertCanicodeAnnotation;\n\n return exports;\n\n})({});\n";
6
- var __CANICODE_HELPERS_VERSION__ = "0.11.2";
6
+ var __CANICODE_HELPERS_VERSION__ = "0.11.3";
7
7
  (0, eval)(__CANICODE_HELPERS_SRC__);
8
8
  try {
9
9
  figma.root.setSharedPluginData("canicode", "helpersSrc", __CANICODE_HELPERS_SRC__);