# Figma Technical Reference — API Quirks

> Figma Plugin API 边角 quirk 汇总。**Path A（Figma mockup）专用**。
> 这些是工具 quirk，不是设计规则——违反不等于设计错误，但忽略会导致实现 bug。
> 设计规则 → [`design-process.md`](./design-process.md) + [`domain-tvu.md`](./domain-tvu.md)
>
> 原来的 M17/M18/M19/M20，从 `mockup-conventions.md` 迁入（2026-05-12 重构）。

---

## Q1 — 1px 节点：先 probe `.fills` 再判视觉性质（原 M17）

看到 metadata 里 `width=N height=1` 或 `width=1 height=N`：

| `.fills` 状态 | 视觉性质 |
|---|---|
| 有 fill | visible divider |
| 无 fill（空数组） | transparent spacer（auto-layout 用） |

**禁止凭几何尺寸猜视觉性质**。复刻前必查 fills 字段。

**实证**: 原 mockup `2956:4746`（1090×1）当 divider 抄进 master，实际是 spacer，导致 inline 横线穿过标题中线。

---

## Q2 — 绑 Color Variable 与设 opacity 必须分两步（原 M18）

`setBoundVariableForPaint(paint, 'color', v)` API quirk：返回的新 paint **opacity 默认 1**，丢失输入 paint 的 opacity 字段。**单步合并必失败**。

正确两步法：

```js
// Step 1: bind variable
const bound = figma.variables.setBoundVariableForPaint(
  { type: 'SOLID', color: { r: 1, g: 1, b: 1 } },
  'color', variable
);
node.fills = [bound];
// Step 2: 通过 fresh fills mutation 强制 opacity
node.fills = node.fills.map(f => ({ ...f, opacity: 0.18 }));
```

**实证**: 本 session 4 次 opacity 失效（State Pill / Card Header badge / Pipeline chip / Auto-refresh widget）都源于此。

---

## Q3 — Master 改完后 instance 需 force-sync（原 M19）

Figma instance 一旦显式被 set 过某字段（哪怕来自 API 静默副作用），就标 override，之后 master 改该字段**不再 propagate**。

对于已知 instance override 字段（fills / opacity / 嵌套子节点样式），master 改完**必须脚本扫一次 instance force-sync**：

```js
for (const inst of instances) {
  inst.fills = master.fills.map(f => ({ ...f }));
}
```

仅 variant property 改动可信赖自动继承。**Clone 后必 probe sample instance 与 master 比对**——clone 是 override 传染最快的路径。

---

## Q4 — Column 宽度：FIXED vs FILL 按内容类型分类（原 M20）

**禁止"全 FIXED / 全 FILL"一刀切**。每列按内容属性独立判断：

| 内容类型 | 策略 |
|---|---|
| 真定长（icon 集合 / numerical badge / pill+短时长） | **FIXED** + 按内容计算合适宽度 |
| 真变长（URL / user text / event title） | **FILL grow=N** + `truncation=ENDING` |
| 定长 data 但 responsive display（hash / long ID 大屏多显示字符）| **FILL grow=N** + `textAutoResize=TRUNCATE` + `truncation=ENDING` |

混合 FIXED/FILL 策略是 responsive design 正解。

**实证**: 本 session 3 轮列宽迭代（全 FILL grow=2/1/1 → 全 FILL grow=1 → 混合 FIXED/FILL）才到位，根因是试图"用同一机制处理所有列"。

---

## Q5 — FRAME vs GROUP：canvas 名称标签行为差异

Figma canvas 上 FRAME 节点会在左上角显示节点名称标签，GROUP 不会。

| 节点类型 | canvas 名称标签 | 适用场景 |
|---|---|---|
| **FRAME** | ✅ 显示（固定，不可隐藏） | Section header、UI frame、规则卡等需要语义化标识的容器 |
| **GROUP** | ❌ 不显示 | 条件标签 chip、state group 等注释 overlay——不想在 canvas 上留额外视觉噪音 |

**实践规则**：annotation overlay（箭头下方条件标签、flow state 分组）一律用 GROUP；需要在 canvas 上标识的结构容器（section header、rules card）用 FRAME 并给语义化名称。

**实证（M23 UX 交付注释）**：条件标签若用 FRAME，canvas 上出现 "Frame 123" 标签漂浮在流程图中间，阅读干扰严重；改为 GROUP 后标签消失。

```js
// ✅ GROUP：无 canvas 名称标签
const grp = figma.group([rect, text], page);
grp.name = '_';  // 最短名，进一步降噪

// ⚠️ FRAME：canvas 上会显示 frame 名称
const fr = figma.createFrame();
fr.name = 'UX · Feature — Rules';  // 给语义名，因为 FRAME 名可见
```

---

## Q7 — 嵌套 instance 的 `paint.opacity` 不持久（强化 Q3）

**复现**（MicroApps Console Plan B 2026-05-14）：

1. Master 组件 A 的某个内部 frame B 有 fills paint.opacity = 0.18（per Q2 两步法）
2. 创建 A 的 instance A'；A' 内部对应的 B' 的 fills paint.opacity 读出 **1.0**（没继承）
3. 显式覆盖 `B'.fills = B'.fills.map(p => ({...p, opacity:0.18}))`；**当下** probe 显示 0.18 ✓
4. **下一个 use_figma call 再 probe B'**，opacity 又变 **1.0**——Figma 在每次 plugin 上下文重置时从某种"默认状态"重新计算 paint properties，instance override 在嵌套层级不持久

**对比**（top-level instance vs nested）：

| Instance 层级 | paint.opacity override | 是否持久 |
|---|---|---|
| 顶层 instance（直接是 frame child）| `inst.fills = ...` | ✅ 持久 |
| 顶层 instance 内的 frame 子节点（nested）| 同样语法 | ❌ 下次 probe 重置 |

**解药 — 用 child Rectangle + `node.opacity` 替代 paint.opacity**：

```js
// 反例：靠 paint.opacity 实现 translucent bg → 在嵌套 instance 内不持久
badge.fills = [bf(brand, 0.18)];

// 正例：在 master 内把 alpha 效果做成独立的子节点
badge.fills = [];                                // outer 清空
badge.cornerRadius = 0;
const bg = figma.createRectangle();
bg.fills = [bf(brand)];                          // full opacity at paint level
bg.opacity = 0.18;                                // ★ node-level opacity（可继承嵌套 instance）
bg.cornerRadius = 999;
badge.insertChild(0, bg);                         // 第一个子节点（最底层）
bg.layoutPositioning = "ABSOLUTE";
bg.constraints = { horizontal: "STRETCH", vertical: "STRETCH" };
bg.x = 0; bg.y = 0;
bg.resize(badge.width, badge.height);
```

**Why** `node.opacity` 可继承而 `paint.opacity` 不行：Figma instance 继承机制下，**节点级属性（`node.opacity`, `node.visible`, `node.rotation` 等）会通过 component → instance link 同步**；**paint 数组本身是值类型 + 浮动 override**，嵌套层级丢失。

**实例后处理**（如已存在 instance）：仅 master 改成 child-Rectangle 模式后，instance 不会自动同步原 outer badge 的 paint=transparent 状态。**必须脚本 force-clear instance outer fills**：

```js
for (const cardInst of allCardInstances) {
  const badge = cardInst.findOne(/* selector */);
  if (badge) { badge.fills = []; badge.strokes = []; }
}
```

**判断何时该用此 pattern**：

| 元素需求 | Pattern |
|---|---|
| 顶层（非嵌套）instance 需要 translucent bg | `paint.opacity` 二步法（Q2）+ 直接 set instance fills（持久）|
| **任何在组件内部需要 translucent bg 的 frame**（badge / chip / overlay）| **child Rectangle + node.opacity**（per 本 Q7） |
| 多层 alpha 叠加 | 拆成多个 child Rectangle，每个有自己 node.opacity |

**对外开发体验**：library 真正补齐 `--brand-bg-18` / `--blue-bg-18` 等 alpha 预混合 token 后，此 workaround 可退役。**已登记库债 BRIDGE-MOCKUP-001**。

---

## Q6 — VECTOR 节点不支持 `strokeEndCap`，箭头必须手动画

`node.strokeEndCap = 'TRIANGLE_ARROW'` 在 VECTOR 节点上**静默失败**（属性赋值不报错，但无效果）。只有 LINE 节点支持 `strokeEndCap`。

流程连线的标准形态是 **起点圆点（白填充 + 蓝边）● + 终点箭头 ▶**（`●————▶`）——一眼看出从哪到哪，不靠读者猜方向。起点圆点用 ELLIPSE（白色填充 + 2px 蓝色边框），箭头主干 + 终点三角用 VECTOR 手动画进 `vectorPaths.data`，两者打 GROUP 当一条连线：

```js
const DOT_D = 6, GAP = 2, AH = 8;             // 圆点直径 / 圆点与主干间隙 / 箭头头部尺寸
const BLUE = { type:'SOLID', color:{ r:0.2, g:0.64, b:0.99 } };  // #33A4FD（M23 cyan accent，线/边/箭头唯一色）
const WHITE = { type:'SOLID', color:{ r:1, g:1, b:1 } };         // #FFFFFF（圆点填充）

// 1) 起点圆点：白色实心填充 + 2px 蓝色边框（标明"从这里出发"；白填充比空心环利落不糊）
const dot = figma.createEllipse();
dot.resize(DOT_D, DOT_D);
dot.fills = [WHITE];                          // 白色实心填充
dot.strokes = [BLUE]; dot.strokeWeight = 2;   // 2px 蓝色边框
dot.x = x1; dot.y = midY - DOT_D / 2;         // 垂直居中对齐主干

// 2) 主干 + 终点三角（从圆点右缘 + GAP 起笔，别和圆点重叠）
//    vectorPaths.data 是相对 vec.x/y 的局部坐标，不是 canvas 绝对坐标
const shaftX = x1 + DOT_D + GAP, len = x2 - shaftX;
const vec = figma.createVector();
vec.vectorPaths = [{
  windingRule: 'EVENODD',
  data: `M 0 ${AH} L ${len - AH} ${AH} M ${len - AH * 2} 0 L ${len} ${AH} L ${len - AH * 2} ${AH * 2}`
}];
vec.strokeWeight = 2; vec.strokes = [BLUE];
vec.x = shaftX; vec.y = midY - AH;

// 3) 圆点 + 箭头打 GROUP（沿用 M23 注释元素 GROUP 范式，canvas 不显名称标签）
const arrow = figma.group([dot, vec], page);
arrow.name = 'Vector';                        // 维持 M23 第 4 层命名
```

**常见错误**：把 canvas 绝对坐标直接写入 `vectorPaths.data`——路径会偏移到节点位置的两倍处。`data` 内坐标始终是相对于 `vec.x / vec.y` 的局部坐标。

**实证（M23 UX 交付注释）**：flow state 箭头用 `strokeEndCap = 'TRIANGLE_ARROW'` 无效；改为 `vectorPaths.data` 手动画箭头头部后正常渲染。

---

## Q8 — HORIZONTAL auto-layout 子节点定位：`insertChild(index, n)` 不是 `appendChild`

**复现**（MicroApps Console Plan B 2026-05-14）：

往 horizontal auto-layout 父 Frame（如 breadcrumb row "All Apps · AV Sync · ..."）插入新 icon instance 时：

```js
// 错：appendChild 把 icon 排到 children 末尾 → auto-layout 把它放到 row 最右边
parent.appendChild(iconInst);
iconInst.x = textNode.x;  // ← 这个 x 设置被 auto-layout 忽略

// 对：insertChild(0, ...) 把 icon 放到 children 开头 → 显示在 row 最左边
parent.insertChild(0, iconInst);
```

**Why**：HORIZONTAL / VERTICAL auto-layout 父 Frame **完全忽略子节点的 x/y 设置**，仅按 children 数组顺序 + itemSpacing 计算位置。`appendChild` 把节点加到数组末尾，所以 icon 自动排到行尾。

**判断父节点是否 auto-layout**：

```js
const layoutMode = parent.layoutMode;  // 'HORIZONTAL' / 'VERTICAL' / 'NONE'
if (layoutMode === 'NONE') {
  // free positioning — 用 node.x / node.y
} else {
  // auto-layout — 用 insertChild(index, child)，index 决定显示顺序
}
```

**实证（Plan B breadcrumb fix）**：4 处 "‹ All Apps · AV Sync ..." breadcrumb，先 `appendChild(prevIcon)` 后 icon 显示在最右（错位 ~250 px）→ 改 `insertChild(0, prevIcon)` 后正确显示在 "All Apps" 之前。

**附带教训**：在 auto-layout 父节点下，**不要**调试时通过 `node.x = ...` 期望生效；先 probe `parent.layoutMode` 确认架构。

**逃生口 + 子节点 positioning（2026-06-15 FB-10014 补）**：个别子节点要**绝对定位**时，设 `child.layoutPositioning = 'ABSOLUTE'` 后 `x/y` 才生效（其余 AUTO 子节点仍按 flow）。所以重排既有 auto-layout（表格/容器）前要 probe 的不只 `parent.layoutMode`，**还有每个子节点的 `layoutPositioning`**（既有 cell 可能是 AUTO flow，新加的可能被你设成了 ABSOLUTE）。重排表格列两条路：① **改 children 顺序 + `itemSpacing`**（推荐，保持 flow 对齐、列宽一致）；② 个别浮动元素 `ABSOLUTE` + x。**实证 FB-10014（Config-T 表加 Priority 列）**：直接对 AUTO flow cell 设 x 全被忽略；新 Priority cell 设 ABSOLUTE 又浮在 flow 上方与邻列重叠 → 最终改回 AUTO + `insertChild` 到第 3 位 + 调 `itemSpacing`/table `padding` 做全宽才对。全宽对齐靠**改 table 的 `paddingLeft/Right`**（不是逐 cell 移 x）。

---

## Q9 — `figma.createSection` 不自动 fit children（必须显式 resize）

`figma.createSection()` 给定默认 496×496 尺寸，**`appendChild` 后不会自动调整**——children 添加/移动/移除后 section 可能比 children 小（部分 children 视觉溢出），也可能比 children 大（视觉留白）。

### 修正

每次 section 内 child 创建/移动/移除后，**手动重算 bounds + resize**：

```js
function fitSection(sec, padding = 80) {
  if (sec.type !== 'SECTION' || !sec.children.length) return;
  let maxR = 0, maxB = 0;
  for (const c of sec.children) {
    maxR = Math.max(maxR, c.x + c.width);
    maxB = Math.max(maxB, c.y + c.height);
  }
  sec.resizeWithoutConstraints(maxR + padding, maxB + padding);
}
```

### Acceptance
- mockup wrap-up 协议：所有创建的 section 在 session 结束前必跑 fit check（probe bounds vs children bbox，不匹配则 resize）
- handoff doc 必含 section 最终尺寸 + children count + bounds 距 section 边距

### 实证
- **2026-05-20** Video Sync Multi-Session mockup：v0 中 section 给定 496×496，3 个 frame (1920×1080) appendChild 后 section 没自动放大，frames 视觉溢出 section 外 → 用户抓 "frames 没被 section 包住" → fit 算法 resize 到 4960×2400。

---

## Q10 — Mixed-font TEXT 操作：font load 何时需要、何时不需要

TEXT node 含 mixed font（不同 range 用不同 family/style），调用 `figma.loadFontAsync(textNode.fontName)` 会抛 **"Cannot unwrap symbol"**——因为 `fontName` 是 `figma.mixed` symbol，不能 unwrap 取 `{family, style}`。

### 区分需要 font load 的操作

| 操作 | 需要 font load? |
|---|---|
| 改 `text.characters` | **是**（修改文本内容） |
| `text.fontSize = N` | 是 |
| `text.fontName = {...}` | 是（新 font） |
| `text.fills = [...]` | **否**（只改颜色） |
| `text.setRangeFills(s, e, [...])` | **否**（range-fill 不动 font） |
| `text.setRangeFontName(s, e, {...})` | 是（新 range font） |
| `text.setRangeFontSize(s, e, N)` | 是 |

### 修正

**起手批量 try-load 所有可能用到的 styles**，避免运行时撞到未 load 的 style：

```js
const STYLES = ['Regular', 'Medium', 'SemiBold', 'Bold', 'Light'];
for (const st of STYLES) {
  try { await figma.loadFontAsync({ family: 'Roboto', style: st }); } catch(e) {}
}
try { await figma.loadFontAsync({ family: 'Noto Sans SC', style: 'Regular' }); } catch(e) {}
```

对 mixed font TEXT 改 fills，**跳过 loadFontAsync 即可**：

```js
// ❌ throws "Cannot unwrap symbol" 当 t 是 mixed font
await figma.loadFontAsync(t.fontName);
t.setRangeFills(0, 10, [solid(BLUE)]);

// ✅ 不 load font，直接 setRangeFills
t.setRangeFills(0, 10, [solid(BLUE)]);
```

### 实证
- **2026-05-20** Video Sync Multi-Session：Parameters header 是 mixed font（"Parameters" Roboto SemiBold + " · Premier_League_Main" Roboto Regular），调 `loadFontAsync(t.fontName)` 抛 "Cannot unwrap symbol"，删 loadFontAsync 后 setRangeFills 正常。

---

## Q11 — Clone instance 继承 variant 状态（含 disable / loading 等）

`instance.clone()` 复制时**保留所有 variant properties 当前值**。如果源 instance 处于 `status=disable` / `status=loading` 等非 default 状态，clone 出来的也是这个状态——容易踩坑，特别是从 "Syncing 进行中" 这种 active-state sibling clone 时。

### 反例

```js
// Plan B Syncing frame 里 Parameters/Video Sync 实例的 Cancel + Sync 按钮是 status=disable 状态
const params = await figma.getNodeByIdAsync('915:25490');
const myParams = params.clone();
// myParams 里的 Sync button 也是 disable —— 视觉灰掉，跟"默认 ready to sync"语义不符
```

### 修正

clone 后**显式重置 variant properties**到 default：

```js
const myParams = params.clone();
const buttons = myParams.findAll(n =>
  n.type === 'INSTANCE' && n.mainComponent && n.mainComponent.parent &&
  n.mainComponent.parent.name === 'Button/dark L'
);
for (const b of buttons) {
  if (b.componentProperties?.status?.value !== 'default') {
    b.setProperties({ status: 'default' });
  }
}
```

### Acceptance
- clone instance from "active state" sibling (Syncing / Synced / Hover / Disabled) 后必 audit 关键 variant 属性是否需要 reset
- handoff doc 必标注 clone 来源 + 状态调整

### 实证
- **2026-05-20** Video Sync Multi-Session M3 (OFF row selected)：clone Parameters/Video Sync 自 Plan B Syncing frame，结果 Cancel + Sync 按钮都是 `status=disable` 灰掉的 → 用户抓 "OFF 状态下 Sync 按钮目前是错误的" → `setProperties({status:'default'})` 修复。

---

## Q12 — Section 内 child 负 y 坐标（chip-above-frame 场景）

放置 annotation chip 在 frame **上方**（chip.y = frame.y - chip.height - gap）时，如果 frame.y 本身较小（如 frame.y=80），chip.y 可能 ≤ 0 或 < 0。Section 不会自动 shift bounds 向上扩，导致 chip 视觉溢出 section 边界。

### 修正

完成 chip 放置后，**扫描所有 children 的 y 坐标**，把 minY < 80（留给 section 顶部 padding）的整批 shift 下来：

```js
let minY = 99999;
for (const c of section.children) { if (c.y < minY) minY = c.y; }
const shift = (minY < 80) ? (80 - minY) : 0;
if (shift > 0) {
  for (const c of section.children) { c.y = c.y + shift; }
}
// 然后跑 fitSection (Q9) 重算 width/height
```

### Acceptance
- 任何 chip-above-frame layout 后必跑 negative-y check + shift
- handoff doc 标注最终 layout bounds (minX, minY, maxX, maxY)

### 实证
- **2026-05-20** Video Sync Multi-Session v6：建 chip 50px above frame top，frame.y=80, chip.height=80 → chip.y=-50 → 整 section 视觉错位。Fix: shift all children +151 + re-fit。

---

## Q13 — Mockup root frame sizing：默认 hug contents 而非 fixed

含**动态内容**（dynamic text / 多个 component instance / 不定数量 children）的 mockup root frame 或子 frame，**height 必须用 hug-contents 模式**，不能写死 fixed pixel。否则内容超出 fixed height 会被剪裁或溢出 frame 边界。

### 设定方式（Figma plugin API）

```js
frame.layoutMode = 'VERTICAL';            // 或 'HORIZONTAL'
frame.primaryAxisSizingMode = 'AUTO';     // 主轴 hug contents
frame.counterAxisSizingMode = 'FIXED';    // 副轴可固定 width
// 副轴也想 hug 改 'AUTO' (但通常 mockup 想锁宽度方便对齐)
```

或在 Figma UI 选 frame → 右侧 panel → 选 "Hug contents" 高度模式。

### 何时用 FIXED

只有**确知最终内容固定 + 用于占位 / 截屏对位 / fixed-ratio 卡片**的 frame 才用 fixed height。绝大多数 mockup 不属于此场景。

### Acceptance

- 任何 mockup 生成 prompt 在描述 frame 创建时，root + 含动态内容 frame 必须**明示** primaryAxisSizingMode='AUTO'（hug）
- prompt 模板里 frame 创建段必含 "高度按 figma-technical-reference §Q13 走 hug contents"
- audit-style check（可选）：扫 Figma export 看 root frame 是否 hug

### 实证

- **2026-05-27** BRIDGE-MOCKUP-007 ROI v3 B 路径：root frame 设 `size: 1100×800` fixed，children 累积超过 800px → 视觉溢出，user 手动调整修复。Prompt 当时写"size: 1100×800"而未指明 sizingMode，AI 默认 fixed。Lesson：prompt 必须显式指定 sizing 模式，不能只给 width × height 数字。

---

## Q14 — Cross-node arrow / connector：必须 probe `absoluteBoundingBox` 拿真坐标（2026-06-01 新增）

任何"跨 2 个或更多 node 之间画 arrow / connector / 流程连线"的场景（M23 / M23.6 / M23.9 用例），画线之前**必须 probe 端点 node 的 `node.absoluteBoundingBox` 拿真实 canvas 坐标**，禁止凭 frame 尺寸 + 估算 offset 推断目标 Y/X。

### 反例

```js
// ❌ 错：凭 frame 顶 y + 估算 offset 推 "Mode 选择器在 frame 高度 50% 位置" → 自算 y
const arrowY = frameTopY + (frameH * 0.5);  // 实际位置不一定居中
vec.y = arrowY;
```

### 正确做法

```js
// ✅ 对：probe 真实坐标
const modeNode = await figma.getNodeByIdAsync(modeNodeId);
const bbox = modeNode.absoluteBoundingBox;  // { x, y, width, height }
const arrowY = bbox.y + bbox.height / 2;     // 真实中线
```

### 实证

- **2026-06-01** V4-1865 v5 流程连线：horizontal arrows between F3/F4/F5 第一版用 `frame.y + 1000 = 2360`（把 Mode 估算成 frame 中部）。实际 Mode group `absoluteBoundingBox.y = 1679` → 差 640px。Arrow 画在 Modem/Ethernet 折叠区位置而非 Mode radio 行旁边，必须删 + 重画。Lesson：跨 node 连线一律 probe `absoluteBoundingBox`，不算 offset。

### Acceptance

- 任何 use_figma 脚本要画 arrow / connector，且端点是某个 nested node（非 page 子直接节点）→ 脚本必含 `absoluteBoundingBox` probe
- 不允许出现 `arrowY = frameY + <估算数>` 这类硬编码 offset 推断

---

## Q15 — SECTION 子节点坐标是 section-relative，不是 page-absolute（2026-06-04 新增）

`SECTION` 的子节点 `x`/`y` 是**相对 section 原点**的（与 `FRAME` 子节点同），**不是** page-absolute。往 section `appendChild(node)` 后，若按"画布绝对坐标"思维给 `node.x` 赋值，真实落点 = `section.x + node.x`，会把内容整体推到 section **外侧很远处**——section 视觉为空，内容飘在旁边。

读回时 `node.x` 仍是你设的那个相对值，与 `section.x` 在错误心智模型里"自洽"，**普通读回无法证伪**；section 级 `get_screenshot` 又会把相邻节点一并取景，进一步掩盖偏移。唯一可靠校验是 `node.absoluteBoundingBox`。

> ⚠️ **高频复发坑（多 session 重踩）**：本规则 2026-06-04 立、2026-06-09 BM-1047 V3 再犯、2026-06-23 V4-2285 分页板 + TPC popover 又各踩一次（均"内容飘到 section 外、section 视觉为空"）。**根因 = Q15 在 figma-technical-reference.md 里、原先没进 mockup-conventions 路由表，scoped-load 起手扫不到 → 2026-06-24 已补路由表「往 Section 内 create/move/定位节点」触发行，强制起手 load 本 Q15 治本。** 起手往 section 放任何节点前，**先在脑子里答一句"我手上这个数是 section-relative 还是画布绝对？"**——拿到的目标坐标几乎都是画布绝对（probe 别的节点 `absoluteBoundingBox` 得来），赋给 section 子节点前必须 **减 section 原点** 转成相对，或用下方 delta 法。

### 反例

```js
// section 在 (19607, -5491)
const sec = await figma.getNodeByIdAsync(secId);
const clone = src.clone();
sec.appendChild(clone);
clone.x = 20007;          // ❌ 以为是画布绝对坐标，想"贴 section 左边距 400"
// 实际 absolute = 19607 + 20007 = 39614 → 飘到 section 右侧约 2 万 px
```

### 正确做法

```js
// 子节点坐标用 section-relative：想让内容贴左 400 → 直接 child.x = 400
clone.x = 400; clone.y = 700;
// 校验落点（决定性）：
const b = clone.absoluteBoundingBox;             // 真·画布坐标
const sb = sec.absoluteBoundingBox;
console.assert(b.x >= sb.x && b.x + b.width <= sb.x + sec.width, '子节点超出 section');
```

**delta 法（手上只有画布绝对目标值时最省心，免手算 section 原点）**：先 append，再用节点当前 `absoluteBoundingBox` 与目标绝对值之差去推 `x/y`，与 section 原点无关、不会算错正负号：

```js
sec.appendChild(node);
// 想让 node 的画布绝对左上角落在 (desiredAbsX, desiredAbsY)：
node.x += desiredAbsX - node.absoluteBoundingBox.x;   // 用 delta 推，不直接赋绝对值
node.y += desiredAbsY - node.absoluteBoundingBox.y;
// 仍按 Acceptance probe 一次 absoluteBoundingBox 确认在 section 边界内
```

### Acceptance

- 任何 use_figma 脚本往 section（或任何容器）放/移子节点后，**至少对 1 个落点 probe `absoluteBoundingBox`** 确认在容器边界内，再继续——不靠 `setX` 后读回的同源相对值判断对错
- section 级 `get_screenshot` 不作为落点验证唯一依据（会卷入相邻节点）；优先 `absoluteBoundingBox` + `contentsOnly:true`

### 实证

- **2026-06-04** Graphics Insertion「1 Layer per line」用户手册流：克隆 9 帧 + 注释共 30 个子节点 `appendChild` 到目标 section 后，按"绝对坐标"设 `x=20007/y=-4791`，实际全部落到 section 右上约 (39614, -10282)，section 显示为空。多轮普通读回 + 截图都"自洽"没查出，直到用户指出"图在空 section 右侧很远处"，probe `absoluteBoundingBox` 才确认 20007→39614。修复：全部子节点相对坐标平移 `(-section.x, -section.y)` 归位。
- **2026-06-09** BM-1047 V3 split-wallet（**同坑复发，故升上方高频警示**）：V3 section `5399:2046` 内放 Design Spec 卡、流程连线（M23.6 Vector）两次拿 `sec.x + offset` 当绝对坐标设，节点被甩出 section。改用 section-relative / delta 法 + probe `absoluteBoundingBox` 校验后归位。立规后仍复发 → 本次加"起手先问相对还是绝对"自检 + delta 法显式备选。

---

## Q16 — Imported component-set instance：嵌套 override 文本不稳定，一脚本只改一次（2026-06-04 新增）

`importComponentSetByKeyAsync` 取来的 instance，其嵌套子节点（Title / Description / 内嵌 Button 的 label）是 **override 节点**，id 形如 `I<instId>;<a>;<b>`。在**同一个 use_figma 脚本里连续 set 多个**这种节点的 `.characters` 时，第一次 mutation 后**其余已捕获的 override 引用会失效** → 第二个 set 报 `Node with id "..." not found`（脚本 atomic，整体回滚，什么都没改）。甚至**先把多个节点 query/捕获再逐个读 `.name`** 也会在 mutation 后抛 not-found。

### 现象
- `inst.query('TEXT')` 一次拿到 4 个文本节点 id 都能读；但写第 2 个就 "node not found"。
- 用脚本开头探到的 override id（如 `I4947:1714;1545:21988`）跨脚本 / 跨其它 mutation 后失效。

### 正确做法
1. **一个脚本只做一次 `.characters` mutation**，每次都**重新解析 instance**（`getNodeByIdAsync(instId)` → `findAllWithCriteria({types:['TEXT']})` → 按 `characters` 内容或 `name` 现场匹配目标）。改 Title / Description / 按钮各一脚本。
2. **优先 component-property**：若该文本是 instance 的 `TEXT` component property（`inst.componentProperties` 里 `type==='TEXT'`）→ 用 `inst.setProperties({key:val})`，**不碰嵌套节点**，最稳。Notification 的 content 是 slot（`Notification_content#…` 非 TEXT prop）→ 退回 (1)。
3. 嵌套 Button 的 label 若是 Button 的 TEXT property → 找到那层 Button instance 设其 property；否则按 (1) 直接改文本节点。

```js
// ✅ 单脚本单 mutation + 现场重解析
const inst = await figma.getNodeByIdAsync('4947:1714');
const t = inst.findAllWithCriteria({types:['TEXT']}).find(n => /this is the title/i.test(n.characters));
for (const seg of t.getStyledTextSegments(['fontName'])) await figma.loadFontAsync(seg.fontName);
t.characters = 'Remove the on-air layer?';   // 下一个文本改动放到另一个脚本
```

### 实证
- **2026-06-04** Graphics Insertion Remove-confirm：用 Notification `pop confirm` instance，一脚本里连改 Title+Description+Delete → 第 2 个 set "node not found"（atomic 回滚 3 次）。拆成 Title / Description / Delete 各一脚本 + 现场 `findAllWithCriteria` 重解析后全部成功。

---

## Q17 — 克隆 absolute-positioned 子节点进 auto-layout 父：layoutPositioning + 尺寸不一定继承（2026-06-04 新增）

把一个在源父容器里 `layoutPositioning='ABSOLUTE'` 的节点 `clone()` 后 `appendChild` 到**另一个 auto-layout 父**时，clone **可能不保留 ABSOLUTE**，被父布局当成普通 flow 子节点接管位置；若该 frame 自身是 HUG sizing，还会**高度塌缩**（如塌成 1px）。设 `clone.x/y` 在此时被布局忽略，失效。

> **泛化（不止 clone）**：**任何**浮层（popover / dropdown / modal 暗背景 / tooltip）——无论 `clone()` 来的还是 `createFrame()` 新建的——`appendChild` 到一个**竖向 auto-layout 的真实 App 页**（TVU App 页模板如 `567:918` 是 `layoutMode='VERTICAL'`）时，**都会被堆叠到页面内容底部**（典型 `y≈1080`）且你设的 `x`/`y` 被忽略。浮层叠在页面某位置的唯一办法是 `child.layoutPositioning='ABSOLUTE'`，之后 `x`/`y` 才生效。这是"叠浮层做完整界面流（WYSIWYG）"的高频起手坑——见 [`design-process.md` §UX 交付物形态](./design-process.md)。

### 反例

```js
const clone = src.clone();          // src 在源 Content 里是 ABSOLUTE
content.appendChild(clone);          // content 是 HORIZONTAL auto-layout
clone.x = 24; clone.y = 739;         // ❌ 被 auto-layout 忽略；clone.h 塌成 1
```

### 正确做法

```js
const clone = src.clone();
content.appendChild(clone);
clone.layoutPositioning = 'ABSOLUTE';            // ★ 重新声明绝对定位（不依赖继承）
if (clone.layoutMode && clone.layoutMode !== 'NONE') {
  clone.primaryAxisSizingMode = 'FIXED';         // 防 HUG 塌缩
  clone.counterAxisSizingMode = 'FIXED';
}
clone.resize(632, 56);                            // 恢复显式尺寸
clone.x = 24; clone.y = 739;                       // ABSOLUTE 后 x/y 才生效
```

### Acceptance
- 凡 `clone()` 一个绝对定位节点并 append/insert 到 auto-layout 父 → 脚本必显式 set `layoutPositioning='ABSOLUTE'` + FIXED 尺寸 + resize，再 probe `absoluteBoundingBox` 确认落点（呼应 Q15）
- 另：**sticky footer 上方的滚动列表，列表容器须 FIXED 高 + `clipsContent` 收在 footer 之上**，不能靠 footer 不透明 bg 去盖溢出内容（会触边无分隔感、丢分割线）

### 实证
- **2026-06-04** Graphics Insertion 08 Preview(PVW)：从可编辑 Preview 帧克隆 Cancel/Apply-to-Output footer（源里是 Content 的 ABSOLUTE 子节点）进 08 的 HORIZONTAL auto-layout Content → clone 高度塌成 1px、y 被布局改写。设回 ABSOLUTE+FIXED+resize(632,56)+y=739 后正常。同时 1/行网格 3251px 未裁切致 footer 悬浮在卡片上 → 网格设 `primaryAxisSizingMode=FIXED`+`clipsContent`+resize 651 收住，露出 footer 自带的 deep-divider 顶边框作分割线。
- **2026-06-09** BM-1047 V3 split-wallet：把 token 菜单 popover / Stripe modal 暗背景**新建**后 `appendChild` 到 App 页 `567:918`（竖向 auto-layout），浮层被堆到页面底部 `y≈1080`、设的 `x/y` 全被忽略。给每个浮层设 `layoutPositioning='ABSOLUTE'` 后 `x/y` 才生效、正确叠在 chip 下方 / 页面之上。非 clone 也中招 → 即上方"泛化"条。

---

## Q18 — `figma.currentPage` ≠ 目标页：写操作前必须 setCurrentPageAsync（2026-06-01 新增）

`figma.currentPage` 随 Figma 桌面端当前活跃页面实时变化，**不等于** 模板节点所在的页面。跨页面克隆或批量创建时，若未显式切换页面，新节点会写入错误页面（当前活跃页）。

### 反例 & 正例

```js
// ❌ 反例：currentPage 是用户最后停在的页面，未必是 template 所在页
const clone = template.clone();
figma.currentPage.appendChild(clone); // 写到错误页面！

// ✅ 正例：从 template.parent 拿目标页，显式 setCurrentPageAsync
const targetPage = template.parent;           // template 所在页 = 目标页
await figma.setCurrentPageAsync(targetPage);  // 切换活跃页
const clone = template.clone();
targetPage.appendChild(clone);                 // 明确 append 到目标页
```

### Acceptance

- 任何 `use_figma` 写操作（clone / createFrame / appendChild）**前**：
  1. 确认 `template.parent.id` 是预期页面
  2. 调用 `await figma.setCurrentPageAsync(template.parent)`
- 批量操作前必须先单例验证：跑一个，截图确认在正确页面，再批量
- 批量操作后立即查 frame count：`targetPage.children.filter(c => targetNames.includes(c.name)).length` 应等于预期数量

### 实证

- **2026-06-01** Source type mockup batch：batch 脚本第一次运行时 `figma.currentPage` 是 "symbol" 页，22 个 frame 写入错误页面；修正后重跑，正确页面出现 44 个重复 frame（22 旧 + 22 新），需额外 2 轮脚本清理。根因：`page.appendChild(clone)` 时的 `page` 变量赋值正确，但验证阶段用 `figma.currentPage.children` 查找导致混乱。

---

## Q19 — TEXT node `.children` 访问直接抛 TypeError（须类型守卫）（2026-06-01 新增）

Figma Plugin API 中，TEXT node 的 `children` 属性 getter **直接抛 TypeError**（`no such property 'children' on TEXT node`），而非返回 `undefined` 或 `null`。因此 `if (node.children)` 无法保护，必须**先判断 node type**。

同样不支持 `.children` 的节点类型：`TEXT` / `VECTOR` / `BOOLEAN_OPERATION` / `STAR` / `ELLIPSE` / `RECTANGLE` / `LINE` / `POLYGON`。

### 修正

**所有** 递归遍历子节点前必须做类型守卫：

```js
function hasChildren(n) {
  return ['FRAME', 'GROUP', 'COMPONENT', 'INSTANCE',
          'SECTION', 'COMPONENT_SET'].includes(n.type);
}

// ❌ 危险：直接访问 children
for (const child of node.children) { ... }           // TEXT node → TypeError
if (node.children) { ... }                           // TEXT node → 仍然 TypeError（getter 抛错）
node.children?.length                                // TEXT node → 仍然抛（?. 不捕获 getter 异常）

// ✅ 安全
if (hasChildren(node)) {
  for (const child of node.children) { ... }
}
```

### Acceptance

- 任何遍历节点树的脚本：循环前必有 `hasChildren(n)` 守卫
- 嵌套递归函数：每层 descent 前守卫，不能只在顶层守卫

### 实证

- **2026-06-01** Source type mockup batch 探查脚本：formContainer 内有 TEXT `"*"` 节点（绝对定位的必填标记），遍历时直接访问 `.children` 报 `TypeError: node.children: no such property 'children' on TEXT node`，脚本中断 → 加 `hasChildren()` 守卫后正常。

---

## Q20 — INSTANCE clone 后 `layoutSizingHorizontal = "FILL"` 可能静默失败（2026-06-01 新增）

部分组件实例（尤其含内部约束的复杂组件，如 `Elem/bar&input/normal` 等）clone 后设置 `layoutSizingHorizontal = "FILL"` 不报错，但实际值仍保持 `FIXED`。必须**设置后显式读回验证**，未生效则用 `resize()` 兜底。

```js
// ❌ 只设不验：实际仍 FIXED，但不会报错
clone.layoutSizingHorizontal = "FILL";

// ✅ 设置 + 验证 + fallback
try { clone.layoutSizingHorizontal = "FILL"; } catch(e) {}
if (clone.layoutSizingHorizontal !== "FILL") {
  // fallback：强制 resize 到容器宽度
  clone.resize(innerContainer.width, clone.height);
}
```

此外，从**窄容器克隆表格行**放入宽容器时，表格行的子节点宽度同样保持原始 FIXED 值。需递归处理所有行：

```js
if (hasChildren(tableClone)) {
  for (const row of tableClone.children) {
    try { row.layoutSizingHorizontal = "FILL"; } catch(e) {}
  }
}
```

### Acceptance

- clone INSTANCE 后：若节点将在 auto-layout 父容器中拉伸，必须验证 `layoutSizingHorizontal` 实际值
- 从宽度不同的源容器 clone 表格/列表类组件：递归对所有 row children 设 FILL
- wrap-up 截图 QA：重点检查多列表单区域右边缘是否对齐

### 实证

- **2026-06-01** SRT Listener 弹窗 Latency 组件：`Elem/bar&input/normal` INSTANCE clone 后设 `FILL` 无效（实际 FIXED 672px，容器 922px），导致 Latency 滑块短于其他表单项 → 截图发现 → 单独 `resize(922, 61)` 修复。
- **2026-06-01** NDI Discovery Server 表格行：从 672px 参考 frame 克隆 `Frame 2289` 放入 922px 容器，行宽保持 FIXED 672px → 右侧对不齐（用户反馈 "表格右侧没有对齐"）→ 递归设 FILL 修复。

---

## Q21 — Top bar 库组件的 `Tokens Remaining` 是隐藏子帧（`visible=true` 启用，非 boolean property）（2026-06-09 新增）

TVU library `Top bar`（M1，set key `918b928e…`）的 **Tokens Remaining 余额槽不是 component property**——它是组件内部一个**默认 `visible=false` 的子帧**，名字就叫 `Tokens Remaining`。想在产品页显示余额，**不能**去 `setProperties` 找一个 boolean（没有），要 `findOne(n => n.name === 'Tokens Remaining')` 后 `node.visible = true`。同理 `Show Menu#5170:3=false` 是隐藏菜单（匹配 micro-app 顶栏）——那个**是** boolean property，与 Tokens Remaining 的启用机制不同，别混。

### 坑：import 后 `findAll` 回调读 `.visible` / set visible 会撞坏的嵌套子节点 crash

import 来的 Top bar instance 内部有**坏的嵌套子节点**：`findAll` 的回调里只要**访问 `.visible`** （读或写）或其它 getter，遍历走到坏节点就整脚本 crash（atomic 回滚，什么都没改）。

```js
// ❌ 回调里读/设 .visible：走到坏的嵌套子节点 → crash，整脚本回滚
const slot = topbar.findAll(n => n.visible && /tokens/i.test(n.name))[0];   // crash
topbar.findAll(n => { n.visible = true; return false; });                    // crash

// ✅ findAll 回调只读 .name 定位，拿到节点后再单独 set
const slot = topbar.findAll(n => n.name === 'Tokens Remaining')[0];          // 回调只碰 .name
if (slot) slot.visible = true;                                               // 命中节点上单独设，不遍历坏节点
```

### Acceptance

- 启用 Top bar 余额槽 → 脚本必走 `findAll(n => n.name === '...')`（回调**只读 `.name`**）+ 命中后单独 `.visible = true`；不在 `findAll` 回调里访问 `.visible` 或别的 getter
- 余额数字是槽内 TEXT 子节点的 `.characters`，按 Q16（imported instance 嵌套 override 一脚本只改一次 + 现场重解析）改
- handoff 记录："Top bar 余额槽：`Tokens Remaining` 子帧 visible 启用（非 property）"

### 实证

- **2026-06-09** BM-1047 V3 split-wallet 顶栏 token 菜单：第一版自画 chip 当余额（违 M1）；改用库 Top bar 后想启用余额槽，`findAll` 回调里读 `.visible` 筛 → 走到坏嵌套子节点 crash 多次。改成回调只读 `.name` 定位 `Tokens Remaining` 子帧、命中后单独 `visible=true` 才稳。

