canicode 0.12.1 → 0.12.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.
@@ -788,6 +788,432 @@ ${footer}`;
788
788
  return out;
789
789
  }
790
790
 
791
+ // src/core/roundtrip/apply-componentize.ts
792
+ var COMPONENTIZE_EVENT = "cic_roundtrip_componentize";
793
+ function isInsideInstance(node) {
794
+ let current = node.parent;
795
+ while (current) {
796
+ if (current.type === "INSTANCE") return true;
797
+ current = current.parent;
798
+ }
799
+ return false;
800
+ }
801
+ function isFreeFormParent(node) {
802
+ const parent = node.parent;
803
+ if (!parent) return true;
804
+ const layoutMode = parent["layoutMode"];
805
+ return layoutMode === void 0 || layoutMode === "NONE";
806
+ }
807
+ function resolveFinalName(desired, existing) {
808
+ if (!existing.has(desired)) {
809
+ return { finalName: desired, collisionResolved: false };
810
+ }
811
+ let counter = 2;
812
+ while (existing.has(`${desired} ${counter}`)) counter++;
813
+ return { finalName: `${desired} ${counter}`, collisionResolved: true };
814
+ }
815
+ function annotateFallback(node, ruleId, categories, body) {
816
+ if (!categories) return;
817
+ upsertCanicodeAnnotation(node, {
818
+ ruleId,
819
+ markdown: body,
820
+ categoryId: categories.flag
821
+ });
822
+ }
823
+ function applyComponentize(options) {
824
+ const { node, existingComponentNames, ruleId, categories, telemetry } = options;
825
+ if (isInsideInstance(node)) {
826
+ annotateFallback(
827
+ node,
828
+ ruleId,
829
+ categories,
830
+ `**Componentize skipped \u2014 node is inside an INSTANCE subtree.**
831
+
832
+ Re-running ${ruleId} componentize on a node inside an instance would either throw or destructively detach the surrounding instance (see roundtrip-protocol.md:286). Move the source frame outside the instance, or detach the parent instance intentionally before componentizing.`
833
+ );
834
+ telemetry?.(COMPONENTIZE_EVENT, {
835
+ ruleId,
836
+ outcome: "skipped-inside-instance"
837
+ });
838
+ return {
839
+ icon: "\u{1F4DD}",
840
+ label: "componentize skipped: inside instance",
841
+ outcome: "skipped-inside-instance"
842
+ };
843
+ }
844
+ if (isFreeFormParent(node)) {
845
+ annotateFallback(
846
+ node,
847
+ ruleId,
848
+ categories,
849
+ `**Componentize skipped \u2014 parent has no Auto Layout.**
850
+
851
+ Componentizing and swapping siblings under a free-form parent would require manual coordinate carryover that can mangle layout silently (ADR-023 decision A). Wrap the duplicates in an Auto Layout frame first, then re-run the roundtrip.`
852
+ );
853
+ telemetry?.(COMPONENTIZE_EVENT, {
854
+ ruleId,
855
+ outcome: "skipped-free-form-parent"
856
+ });
857
+ return {
858
+ icon: "\u{1F4DD}",
859
+ label: "componentize skipped: free-form parent",
860
+ outcome: "skipped-free-form-parent"
861
+ };
862
+ }
863
+ const desiredName = typeof node.name === "string" ? node.name : "Component";
864
+ const { finalName, collisionResolved } = resolveFinalName(
865
+ desiredName,
866
+ existingComponentNames
867
+ );
868
+ const create = figma.createComponentFromNode;
869
+ if (typeof create !== "function") {
870
+ annotateFallback(
871
+ node,
872
+ ruleId,
873
+ categories,
874
+ `**Componentize skipped \u2014 \`figma.createComponentFromNode\` unavailable.**
875
+
876
+ The Plugin API host did not expose the Create component primitive in this session. The FRAME has been flagged so the next roundtrip can retry.`
877
+ );
878
+ telemetry?.(COMPONENTIZE_EVENT, {
879
+ ruleId,
880
+ outcome: "error",
881
+ reason: "createComponentFromNode-missing"
882
+ });
883
+ return {
884
+ icon: "\u{1F4DD}",
885
+ label: "componentize skipped: createComponentFromNode unavailable",
886
+ outcome: "error"
887
+ };
888
+ }
889
+ try {
890
+ const created = create.call(figma, node);
891
+ created.name = finalName;
892
+ telemetry?.(COMPONENTIZE_EVENT, {
893
+ ruleId,
894
+ outcome: "componentized",
895
+ nameCollisionResolved: collisionResolved
896
+ });
897
+ const result = {
898
+ icon: "\u2705",
899
+ label: collisionResolved ? `componentized as "${finalName}" (renamed from collision)` : `componentized as "${finalName}"`,
900
+ outcome: "componentized",
901
+ newComponentId: created.id,
902
+ finalName
903
+ };
904
+ if (collisionResolved) result.nameCollisionResolved = true;
905
+ return result;
906
+ } catch (e) {
907
+ const msg = String(e?.message ?? e);
908
+ annotateFallback(
909
+ node,
910
+ ruleId,
911
+ categories,
912
+ `**Componentize failed \u2014 \`createComponentFromNode\` threw.**
913
+
914
+ Error: \`${msg}\`. The FRAME has been flagged so the designer can inspect the structure (locked layer, unsupported child mix, etc.) before the next roundtrip pass.`
915
+ );
916
+ telemetry?.(COMPONENTIZE_EVENT, {
917
+ ruleId,
918
+ outcome: "error",
919
+ reason: msg
920
+ });
921
+ return {
922
+ icon: "\u{1F4DD}",
923
+ label: `componentize failed: ${msg}`,
924
+ outcome: "error"
925
+ };
926
+ }
927
+ }
928
+
929
+ // src/core/roundtrip/apply-replace-with-instance.ts
930
+ var REPLACE_EVENT = "cic_roundtrip_replace_with_instance";
931
+ function isFreeFormParent2(parent) {
932
+ if (!parent) return true;
933
+ const layoutMode = parent["layoutMode"];
934
+ return layoutMode === void 0 || layoutMode === "NONE";
935
+ }
936
+ function annotateFallback2(node, ruleId, categories, body) {
937
+ if (!node || !categories) return;
938
+ upsertCanicodeAnnotation(node, {
939
+ ruleId,
940
+ markdown: body,
941
+ categoryId: categories.flag
942
+ });
943
+ }
944
+ function isComponentLike(type) {
945
+ return type === "COMPONENT" || type === "COMPONENT_SET";
946
+ }
947
+ async function applyReplaceWithInstance(options) {
948
+ const { mainComponentId, targetNodeId, ruleId, categories, telemetry } = options;
949
+ const [target, main] = await Promise.all([
950
+ figma.getNodeByIdAsync(targetNodeId),
951
+ figma.getNodeByIdAsync(mainComponentId)
952
+ ]);
953
+ if (!target) {
954
+ telemetry?.(REPLACE_EVENT, {
955
+ ruleId,
956
+ outcome: "skipped-prereq-missing",
957
+ reason: "target-missing"
958
+ });
959
+ return {
960
+ icon: "\u{1F4DD}",
961
+ label: `replace skipped: target node ${targetNodeId} missing`,
962
+ outcome: "skipped-prereq-missing"
963
+ };
964
+ }
965
+ if (!main) {
966
+ annotateFallback2(
967
+ target,
968
+ ruleId,
969
+ categories,
970
+ `**Replace skipped \u2014 main component \`${mainComponentId}\` not found.**
971
+
972
+ The componentize step (delta 1) likely failed earlier in this batch, or the main was deleted between componentize and swap. The FRAME has been flagged so the next roundtrip pass can re-derive the group.`
973
+ );
974
+ telemetry?.(REPLACE_EVENT, {
975
+ ruleId,
976
+ outcome: "skipped-prereq-missing",
977
+ reason: "main-missing"
978
+ });
979
+ return {
980
+ icon: "\u{1F4DD}",
981
+ label: `replace skipped: main ${mainComponentId} missing`,
982
+ outcome: "skipped-prereq-missing"
983
+ };
984
+ }
985
+ if (!isComponentLike(main.type)) {
986
+ annotateFallback2(
987
+ target,
988
+ ruleId,
989
+ categories,
990
+ `**Replace skipped \u2014 \`${mainComponentId}\` is not a COMPONENT.**
991
+
992
+ Resolved to a \`${main.type}\` node. Phase 3's swap step requires the main to be a \`COMPONENT\` or \`COMPONENT_SET\`. Check that componentize ran cleanly on the source frame before this call.`
993
+ );
994
+ telemetry?.(REPLACE_EVENT, {
995
+ ruleId,
996
+ outcome: "skipped-prereq-missing",
997
+ reason: "main-not-component",
998
+ resolvedType: main.type
999
+ });
1000
+ return {
1001
+ icon: "\u{1F4DD}",
1002
+ label: `replace skipped: main is ${main.type}, not COMPONENT`,
1003
+ outcome: "skipped-prereq-missing"
1004
+ };
1005
+ }
1006
+ if (target.id === main.id) {
1007
+ annotateFallback2(
1008
+ target,
1009
+ ruleId,
1010
+ categories,
1011
+ `**Replace skipped \u2014 target and main are the same node.**
1012
+
1013
+ This usually means the componentize source was passed in the swap set by mistake. The componentize source becomes the main; only the remaining sibling FRAMEs should be swapped.`
1014
+ );
1015
+ telemetry?.(REPLACE_EVENT, {
1016
+ ruleId,
1017
+ outcome: "skipped-prereq-missing",
1018
+ reason: "target-is-main"
1019
+ });
1020
+ return {
1021
+ icon: "\u{1F4DD}",
1022
+ label: "replace skipped: target equals main",
1023
+ outcome: "skipped-prereq-missing"
1024
+ };
1025
+ }
1026
+ const parent = target.parent;
1027
+ if (!parent) {
1028
+ annotateFallback2(
1029
+ target,
1030
+ ruleId,
1031
+ categories,
1032
+ `**Replace skipped \u2014 target has no parent.**
1033
+
1034
+ Cannot insert a new instance for an orphaned node. The FRAME has been flagged; no swap performed.`
1035
+ );
1036
+ telemetry?.(REPLACE_EVENT, {
1037
+ ruleId,
1038
+ outcome: "skipped-prereq-missing",
1039
+ reason: "no-parent"
1040
+ });
1041
+ return {
1042
+ icon: "\u{1F4DD}",
1043
+ label: "replace skipped: no parent",
1044
+ outcome: "skipped-prereq-missing"
1045
+ };
1046
+ }
1047
+ if (isFreeFormParent2(parent)) {
1048
+ annotateFallback2(
1049
+ target,
1050
+ ruleId,
1051
+ categories,
1052
+ `**Replace skipped \u2014 parent has no Auto Layout.**
1053
+
1054
+ Swapping a sibling FRAME with an instance under a free-form parent would require explicit coordinate carryover that can mangle layout silently (ADR-023 decision A). Wrap the duplicates in an Auto Layout frame first, then re-run the roundtrip.`
1055
+ );
1056
+ telemetry?.(REPLACE_EVENT, {
1057
+ ruleId,
1058
+ outcome: "skipped-free-form-parent"
1059
+ });
1060
+ return {
1061
+ icon: "\u{1F4DD}",
1062
+ label: "replace skipped: free-form parent",
1063
+ outcome: "skipped-free-form-parent"
1064
+ };
1065
+ }
1066
+ const create = main.createInstance;
1067
+ if (typeof create !== "function") {
1068
+ annotateFallback2(
1069
+ target,
1070
+ ruleId,
1071
+ categories,
1072
+ `**Replace skipped \u2014 \`createInstance\` unavailable on main.**
1073
+
1074
+ The Plugin API host did not expose \`createInstance\` on the resolved main (\`${main.type}\`). The FRAME has been flagged so the next roundtrip can retry once the host catches up.`
1075
+ );
1076
+ telemetry?.(REPLACE_EVENT, {
1077
+ ruleId,
1078
+ outcome: "error",
1079
+ reason: "createInstance-missing"
1080
+ });
1081
+ return {
1082
+ icon: "\u{1F4DD}",
1083
+ label: "replace skipped: createInstance unavailable",
1084
+ outcome: "error"
1085
+ };
1086
+ }
1087
+ try {
1088
+ const instance = create.call(main);
1089
+ const siblings = parent.children ?? [];
1090
+ const idx = siblings.findIndex((s) => s.id === target.id);
1091
+ const insert = parent.insertChild;
1092
+ const append = parent.appendChild;
1093
+ if (idx >= 0 && typeof insert === "function") {
1094
+ insert.call(parent, idx, instance);
1095
+ } else if (typeof append === "function") {
1096
+ append.call(parent, instance);
1097
+ } else {
1098
+ throw new Error(
1099
+ "parent exposes neither insertChild nor appendChild \u2014 cannot insert instance"
1100
+ );
1101
+ }
1102
+ if (typeof target.remove === "function") {
1103
+ target.remove();
1104
+ } else {
1105
+ throw new Error("target node missing `remove` \u2014 cannot detach old FRAME");
1106
+ }
1107
+ telemetry?.(REPLACE_EVENT, {
1108
+ ruleId,
1109
+ outcome: "replaced"
1110
+ });
1111
+ return {
1112
+ icon: "\u2705",
1113
+ label: `replaced with instance of "${main.name}"`,
1114
+ outcome: "replaced",
1115
+ newInstanceId: instance.id
1116
+ };
1117
+ } catch (e) {
1118
+ const msg = String(e?.message ?? e);
1119
+ annotateFallback2(
1120
+ target,
1121
+ ruleId,
1122
+ categories,
1123
+ `**Replace failed \u2014 Plugin API threw.**
1124
+
1125
+ Error: \`${msg}\`. The FRAME has been flagged so the designer can inspect (locked layer, parent restrictions, etc.) before the next roundtrip pass.`
1126
+ );
1127
+ telemetry?.(REPLACE_EVENT, {
1128
+ ruleId,
1129
+ outcome: "error",
1130
+ reason: msg
1131
+ });
1132
+ return {
1133
+ icon: "\u{1F4DD}",
1134
+ label: `replace failed: ${msg}`,
1135
+ outcome: "error"
1136
+ };
1137
+ }
1138
+ }
1139
+
1140
+ // src/core/roundtrip/apply-group-componentize.ts
1141
+ function summarizeReplaceCounts(results) {
1142
+ const total = results.length;
1143
+ if (total === 0) return "";
1144
+ const replaced = results.filter((r) => r.outcome === "replaced").length;
1145
+ const reasons = [];
1146
+ const freeForm = results.filter(
1147
+ (r) => r.outcome === "skipped-free-form-parent"
1148
+ ).length;
1149
+ const prereq = results.filter(
1150
+ (r) => r.outcome === "skipped-prereq-missing"
1151
+ ).length;
1152
+ const error = results.filter((r) => r.outcome === "error").length;
1153
+ if (freeForm > 0) reasons.push(`${freeForm} free-form parent`);
1154
+ if (prereq > 0) reasons.push(`${prereq} prereq missing`);
1155
+ if (error > 0) reasons.push(`${error} error`);
1156
+ const tail = reasons.length > 0 ? ` (${reasons.join(", ")})` : "";
1157
+ return `swapped ${replaced}/${total} siblings${tail}`;
1158
+ }
1159
+ async function applyGroupComponentize(options) {
1160
+ const { question, existingComponentNames, categories, telemetry } = options;
1161
+ const members = question.groupMembers;
1162
+ const firstId = members[0];
1163
+ if (firstId === void 0) {
1164
+ return {
1165
+ outcome: "missing-first-member",
1166
+ replaceResults: [],
1167
+ summary: "group componentize skipped: no members in group"
1168
+ };
1169
+ }
1170
+ const firstNode = await figma.getNodeByIdAsync(firstId);
1171
+ if (!firstNode) {
1172
+ return {
1173
+ outcome: "missing-first-member",
1174
+ replaceResults: [],
1175
+ summary: `group componentize skipped: first member ${firstId} not found`
1176
+ };
1177
+ }
1178
+ const componentizeResult = applyComponentize({
1179
+ node: firstNode,
1180
+ existingComponentNames,
1181
+ ruleId: question.ruleId,
1182
+ ...categories !== void 0 ? { categories } : {},
1183
+ ...telemetry !== void 0 ? { telemetry } : {}
1184
+ });
1185
+ if (componentizeResult.outcome !== "componentized") {
1186
+ return {
1187
+ outcome: "componentize-failed",
1188
+ componentizeResult,
1189
+ replaceResults: [],
1190
+ summary: `group componentize skipped: ${componentizeResult.label}`
1191
+ };
1192
+ }
1193
+ const newComponentId = componentizeResult.newComponentId;
1194
+ const swapTargets = members.slice(1);
1195
+ const replaceResults = [];
1196
+ for (const targetId of swapTargets) {
1197
+ const r = await applyReplaceWithInstance({
1198
+ mainComponentId: newComponentId,
1199
+ targetNodeId: targetId,
1200
+ ruleId: question.ruleId,
1201
+ ...categories !== void 0 ? { categories } : {},
1202
+ ...telemetry !== void 0 ? { telemetry } : {}
1203
+ });
1204
+ replaceResults.push(r);
1205
+ }
1206
+ const swapSummary = summarizeReplaceCounts(replaceResults);
1207
+ const finalName = componentizeResult.finalName ?? "(unnamed)";
1208
+ const summary = swapSummary.length > 0 ? `componentized "${finalName}", ${swapSummary}` : `componentized "${finalName}"`;
1209
+ return {
1210
+ outcome: "componentized-and-swapped",
1211
+ componentizeResult,
1212
+ replaceResults,
1213
+ summary
1214
+ };
1215
+ }
1216
+
791
1217
  // src/core/roundtrip/remove-canicode-annotations.ts
792
1218
  var LEGACY_CANICODE_PREFIX = "**[canicode]";
793
1219
  function isCanicodeAnnotation(annotation, categories) {
@@ -813,7 +1239,10 @@ ${footer}`;
813
1239
 
814
1240
  exports.applyAutoFix = applyAutoFix;
815
1241
  exports.applyAutoFixes = applyAutoFixes;
1242
+ exports.applyComponentize = applyComponentize;
1243
+ exports.applyGroupComponentize = applyGroupComponentize;
816
1244
  exports.applyPropertyMod = applyPropertyMod;
1245
+ exports.applyReplaceWithInstance = applyReplaceWithInstance;
817
1246
  exports.applyUnmappedComponentOptOut = applyUnmappedComponentOptOut;
818
1247
  exports.applyWithInstanceFallback = applyWithInstanceFallback;
819
1248
  exports.buildIntentionallyUnmappedAnnotationBody = buildIntentionallyUnmappedAnnotationBody;
@@ -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
@@ -159,6 +161,8 @@ Detect the user's conversation language from their recent messages in **this** s
159
161
 
160
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.
161
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
+
162
166
 
163
167
  ### Step 4: Apply gotcha answers to Figma design
164
168
 
@@ -257,6 +261,92 @@ The name must match **the variable's `name` field exactly** — including any sl
257
261
 
258
262
  Instance-child guard and per-rule prompts — **[Appendix Strategy B](https://github.com/let-sunny/canicode/blob/main/docs/roundtrip-protocol.md#appendix--strategy-b-structural-modification)**. Decline / guard → Strategy C annotation.
259
263
 
264
+ ##### Strategy B group componentize — Phase 3 (`missing-component:structure-repetition`)
265
+
266
+ When `applyStrategy === "structural-mod"` AND `question.ruleId === "missing-component"` AND `question.subType === "structure-repetition"` AND `question.groupMembers` is set, the question represents a fingerprint group of N FRAMEs the user can componentize-and-swap in one batch. The group spans both same-parent siblings and cross-parent matches found by the Stage 3 scope-wide pass (#557). Render the per-question prompt with the **group size** explicitly so the designer knows the scope before answering. Substitute `{nodeName}` with `question.nodeName` and `{others}` with `question.groupMembers.length - 1` (the count excluding the first member that becomes the new component); render in the user's session language:
267
+
268
+ - Korean: `> "{nodeName}" 외에 동일한 구조의 frame이 {others}개 더 있습니다 (총 {others + 1}개). 모두 컴포넌트화 할까요? (yes/no)`
269
+ - English: `> "{nodeName}" and {others} other frame(s) share the same structure ({others + 1} total). Componentize the whole group? (yes/no)`
270
+
271
+ On `yes`, compute the file-wide existing component name set once (decision C uses this for the suffix), then call the group orchestrator. On `no` / `skip`, drop the question without writing anything; the gotcha state is captured in the SKILL's section markdown either way.
272
+
273
+ <!-- adr-016-ack: existingComponentNames computed via figma.root API; orchestration loop lives in applyGroupComponentize -->
274
+ ```javascript
275
+ const existingComponentNames = new Set(
276
+ figma.root
277
+ .findAllWithCriteria({ types: ["COMPONENT", "COMPONENT_SET"] })
278
+ .map((c) => c.name)
279
+ );
280
+ const result = await CanICodeRoundtrip.applyGroupComponentize({
281
+ question: { ruleId: question.ruleId, groupMembers: question.groupMembers },
282
+ existingComponentNames,
283
+ categories,
284
+ });
285
+ // result.summary — e.g. `componentized "Card", swapped 3/4 siblings (1 free-form parent)`
286
+ // result.outcome — `componentized-and-swapped` | `componentize-failed` | `missing-first-member`
287
+ //
288
+ // Step 4 report counter mapping (bump ONCE per primitive call, not once per
289
+ // outcome bucket — counters track work performed, not aggregate verdicts):
290
+ // componentize step:
291
+ // componentizeResult.outcome === "componentized" → resolved (+1)
292
+ // componentizeResult.outcome === "skipped-*" or "error" → annotated (+1)
293
+ // each replace step (iterate result.replaceResults):
294
+ // replaceResult.outcome === "replaced" → resolved (+1)
295
+ // replaceResult.outcome === "skipped-*" or "error" → annotated (+1)
296
+ // missing-first-member → skipped (+1) and no further bumps
297
+ ```
298
+
299
+ Notes:
300
+ - **Free-form parents are refused per ADR-023 decision A.** When the group's parent (or any swap-target's parent) has no Auto Layout, the relevant primitive annotates the source FRAME with a "wrap in Auto Layout first" hint and skips the write. The summary string surfaces the count (`(1 free-form parent)`) so the designer sees the partial outcome at a glance.
301
+ - **Name collision auto-suffixes per ADR-023 decision C** (`Card 2`, `Card 3`, …). The orchestrator passes `existingComponentNames` to the componentize step which resolves the suffix and reports the rename in `result.componentizeResult.finalName`.
302
+ - **Per-member opt-out is not yet wired** — the orchestrator treats `groupMembers` as canonical. If the designer wants to exclude a specific member, that is currently a manual pre-edit (delete the entry from the question payload before calling) or a follow-up enhancement.
303
+ - **No replica fan-out.** Stage 3 questions never carry `replicaNodeIds` (the `#356` instance-child dedupe applies to single-node violations, not group-shaped ones). Iterate `groupMembers` instead.
304
+
305
+ ###### Code Connect handoff for the new component (Phase 3 delta 5, optional)
306
+
307
+ When `result.outcome === "componentized-and-swapped"` AND `result.componentizeResult.newComponentId` is set, the new component is a candidate for a Code Connect mapping so future roundtrips reuse the just-generated code instead of regenerating markup. This mirrors the Workflow 1 (#509) Step 7 close-out — same pre-check, same MCP tools, just sharing the closing question.
308
+
309
+ Per ADR-023 decision E this is **silent skip + one-line pointer** when prereqs are absent — Phase 3 does not own onboarding (Workflow 1 / #509 does). The check is the same `npx canicode doctor --figma-url <url>` already run at Step 1.5 (`prereqs.codeConnectReady` cached on the session) — do **not** re-run doctor here. If prereqs were missing, surface ONE line and move on:
310
+
311
+ > Code Connect 미설정이라 매핑 단계는 건너뜁니다. Workflow 1 (`/canicode-roundtrip <component-url>`)로 setup하면 다음부터 자동 매핑됩니다.
312
+
313
+ (English: `> Code Connect not configured — skipping mapping. Run Workflow 1 (\`/canicode-roundtrip <component-url>\`) to set it up; subsequent roundtrips map automatically.`)
314
+
315
+ When prereqs are ready, ask the satisfaction prompt and, on `yes`, register the mapping:
316
+
317
+ ```javascript
318
+ // 1. Probe the just-componentized main for an existing mapping (idempotency)
319
+ const existing = await mcp__figma__get_code_connect_map({
320
+ componentId: result.componentizeResult.newComponentId,
321
+ });
322
+ if (existing) {
323
+ // Already mapped — surface and move on. Do not overwrite without consent.
324
+ return;
325
+ }
326
+
327
+ // 2. Ask the user (one line, in session language)
328
+ // Korean: "이 새 component를 Code Connect 매핑으로 등록할까요? (yes/no)"
329
+ // English: "Register this new component with Code Connect? (yes/no)"
330
+
331
+ // 3. On yes, fetch suggestions and register the user's choice
332
+ const suggestions = await mcp__figma__get_code_connect_suggestions({
333
+ componentId: result.componentizeResult.newComponentId,
334
+ componentName: result.componentizeResult.finalName,
335
+ });
336
+ // Render suggestions to the user; on confirmation:
337
+ await mcp__figma__add_code_connect_map({
338
+ componentId: result.componentizeResult.newComponentId,
339
+ codePath: chosenSuggestion.path, // e.g. "src/components/Card.tsx"
340
+ codeName: chosenSuggestion.exportName, // e.g. "Card"
341
+ });
342
+ await mcp__figma__send_code_connect_mappings();
343
+ ```
344
+
345
+ Notes:
346
+ - **Inherits Step 1.5 prereq check.** Do not re-invoke `canicode doctor` — the cached result from Step 1.5 already covers `figma.config.json` + `@figma/code-connect` install + Figma publish status. Skipping the re-check keeps the close-out fast and avoids a second Figma round-trip.
347
+ - **No suggestions match → still surface the option.** When `get_code_connect_suggestions` returns empty, ask the user for a manual `codePath` + `codeName` (one prompt, optional). Skipping is always valid — the new component remains unmapped and Workflow 1 can register it later.
348
+ - **Wraps the Step 4 apply line** with one extra outcome marker: `+ Code Connect: Card → src/components/Card.tsx` on success, `+ Code Connect: skipped (prereq missing)` or `+ Code Connect: skipped (user declined)` on the two skip paths. Counts as part of the Phase 3 group's overall result for Step 5 tally — no new counter, just an annotation appended to the existing line.
349
+
260
350
  #### Strategy C: Annotation — record on the design for designer reference
261
351
 
262
352
  Rules with `applyStrategy === "annotation"` cannot be auto-fixed via Plugin API. Add the gotcha answer as a Figma annotation so designers see it in Dev Mode. Use the helper — it handles the D1 mutex, D2 in-place upsert, and D4 category assignment. When `question.replicaNodeIds` is present (#356), iterate the merged set so every replica instance gets the annotation:
@@ -398,6 +488,8 @@ The response now carries:
398
488
 
399
489
  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).
400
490
 
491
+ **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.
492
+
401
493
  **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:
402
494
 
403
495
  ```javascript
@@ -424,6 +516,8 @@ If Step 4 produced no `stepFourReport` (e.g. user skipped every question, or no
424
516
 
425
517
  V issues remaining (unresolved gotchas + non-actionable rules)
426
518
 
519
+ *(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.
520
+
427
521
  Ready for code generation. *(Optional:) Report still shows grade **{grade}** — informational only.*
428
522
  ```
429
523
  - 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:
@@ -456,6 +550,8 @@ for (const id of nodeIds) {
456
550
  ↳ V_ack acknowledged via canicode annotations (carried into code-gen)
457
551
  ↳ V_open unaddressed (no annotation — your follow-up backlog)
458
552
 
553
+ *(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.
554
+
459
555
  Proceed to code generation with remaining context? *(Optional footnote: report grade **{grade}**.)*
460
556
  ```
461
557
 
@@ -478,6 +574,8 @@ Stopped — N issues addressed, V remaining for manual follow-up:
478
574
  ↳ V_ack acknowledged via canicode annotations
479
575
  ↳ V_open unaddressed
480
576
 
577
+ *(When Y > 0)* 📝 annotations carry context into code-gen but do not change the grade — that is by design (ADR-012).
578
+
481
579
  *(Optional)* Report grade: **{grade}**.
482
580
  ```
483
581
 
@@ -516,6 +614,8 @@ Roundtrip complete — N issues addressed, code generated:
516
614
  ↳ V_ack acknowledged via canicode annotations
517
615
  ↳ V_open unaddressed
518
616
 
617
+ *(When Y > 0)* 📝 annotations carry context into code-gen but do not change the grade — that is by design (ADR-012).
618
+
519
619
  *(Optional)* Report grade: **{grade}**.
520
620
  Code: <files generated / next-step pointer from figma-implement-design>
521
621
  ```
@@ -599,6 +699,8 @@ Roundtrip complete — N issues addressed, code generated, mapping <state>:
599
699
  ↳ V_ack acknowledged via canicode annotations
600
700
  ↳ V_open unaddressed
601
701
 
702
+ *(When Y > 0)* 📝 annotations carry context into code-gen but do not change the grade — that is by design (ADR-012).
703
+
602
704
  Code: <files generated / next-step pointer from figma-implement-design>
603
705
  Code Connect: <mapping outcome line>
604
706
  ```
@@ -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.1";
9
+ var expected = "0.12.3";
10
10
  var src = figma.root.getSharedPluginData("canicode", "helpersSrc");
11
11
  var actual = figma.root.getSharedPluginData("canicode", "helpersVersion");
12
12
  if (!src) {