# 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`。

VECTOR 节点画箭头必须在 `vectorPaths.data` 里手动包含箭头头部路径：

```js
// vectorPaths.data 是相对于 vec.x/y 的局部坐标，不是 canvas 绝对坐标
const len = x2 - x1;   // 箭头长度（局部坐标）
const AH = 8;           // 箭头头部尺寸（px）

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.x = x1（canvas 绝对坐标定位，path data 在此基础上相对偏移）
```

**常见错误**：把 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` 确认架构。
