Figma 构建
这里的一切都曾静默失败。没有抛出异常,没有警告,画布只是看起来不对劲。
循环,优先于一切
标记构建并清空自己的输出。 否则,当Figma提供的是缓存bundle时,你会花很长时间调试“看起来一样”。
const BUILD = "15:03"; // 每次bundle时递增
function clearPrevious() {
const mine = figma.currentPage.children.filter(n => /^\d\d · /.test(n.name));
mine.forEach(n => n.remove());
return mine.length;
}
figma.closePlugin("Build " + BUILD + " — replaced " + removed + " frames.");
toast中的两个数字都具有诊断意义。过时的印记意味着bundle被缓存:删除插件、重新导入manifest.json、重新打开标签页。当画布上明显有画框时显示Replaced 0,也意味着同样的问题。
没有这个,一个重新创建画框的插件会在第一组之上堆叠第二组,而且没有任何信息告诉你你正在看哪一组。计算画框名称的副本数量是证明运行是否发生的方式。
“清除并重建”仅用于首次构建。 一旦文件进入审查阶段,评论会绑定到节点ID。删除一个画框并绘制新的会使其上的所有评论成为孤儿。在审查轮次中,应原地编辑现有节点(参见 审查循环)。
一个错误值摧毁整个构建
此文件中的每个失败都有相同的形式:API拒绝的单个值,在绘制过程中抛出,留下一个半渲染的页面,这会被解读为布局问题。典型元凶:负宽度、固定的10px文本高度、未加载的字重。
在边界处钳制,而不是信任算术。
function W1(v, min){ const n = Number(v); return (isFinite(n) && n > 0) ? n : (min || 1); }
所有宽度、高度和文本宽度都经过它。一次钳制代价是一个看起来错误的框;一次抛出代价是整个页面。
咬人的算术是仅在一个宽度下成立的固定减法:
const FEED = DW - PAD*2 - 400; // 1440时是840。390时是MINUS 50。
任何从画布宽度减去的常量都需要一个断点守卫,否则在小的画布上它会变成负数。
要识别的症状: 宽画框看起来完成,窄画框缺失或半绘制,与某些东西重叠。这不是定位错误。读取错误toast——它会指明属性和节点。
永远不要从原点开始绘制
从(0, 0)开始的插件会落到已经存在的内容之上,而且一个仅移除自身画框的清理器救不了你:共享一个页面的两个插件都认为画布是空的。
先测量,然后开始清理:
let startY = 0;
figma.currentPage.children.forEach(n => { startY = Math.max(startY, n.y + n.height); });
if (figma.currentPage.children.length) startY += 600;
在画布上标记每一行,并让清理器匹配构建产生的所有东西——画框和标签——否则重新运行会留下孤立的文本。
文本尺寸设置,严格按照此顺序
最昂贵的错误之一。Figma在其内容改变时重新计算文本节点的高度,而不是在你调整其大小时。
const str = String(s);
const t = figma.createText();
t.fontName = { family: "Inter", style: weight };
t.characters = str; // 1. 首先
t.fontSize = size; // 2. 然后设置样式
t.lineHeight = { unit: "PERCENT", value: 145 };
if (width) {
t.textAutoResize = "HEIGHT"; // 3. 在空节点上不会生效
t.resize(width, t.height); // 永远不要传递字面量高度
t.characters = str; // 4. 强制在新宽度下重新测量
} else {
t.textAutoResize = "WIDTH_AND_HEIGHT";
}
- 在节点有字符之前设置
textAutoResize = "HEIGHT"会被忽略。将内容放到最后而不移动该属性会使情况更糟。 resize(w, 10)将高度永远固定在10。传递t.height。- 重新赋值相同的字符串是双重保险的重新测量。
症状: 所有具有显式宽度的文本大约10px高,没有显式宽度的文本正确,且所有高度仅来自文本的画框会折叠到其内边距。
width 意味着固定宽度——哪个轴向并不明显
if (opts.horizontal) {
f.primaryAxisSizingMode = width ? "FIXED" : "AUTO"; // width 是主轴
f.counterAxisSizingMode = "AUTO";
} else {
f.counterAxisSizingMode = width ? "FIXED" : "AUTO"; // width 是交叉轴
f.primaryAxisSizingMode = "AUTO";
}
在水平画框上设置counterAxisSizingMode会固定其高度并让宽度自动包裹。以这种方式构建的每一行都没有多余的宽度,因此没有子元素可以增长进去。
在设置尺寸模式之前调整大小,否则显式大小会与自动包裹冲突。
填充行的列
function growCol(n) { n.layoutGrow = 1; n.counterAxisSizingMode = "FIXED"; return n; }
以width: null创建的垂直画框会自动包裹。赋予它layoutGrow = 1和STRETCH子元素,你就有了一个循环约束:父元素根据子元素大小调整,子元素根据父元素大小调整。Figma会以其最小值来解决。
症状: 卡片大约45px宽,正文每个字符一行,文本节点报告1 x 988。页面画框比应有高度高三倍,完全来自虚假的垂直空间。
优先向行传递显式宽度,而不是依赖STRETCH来解决。
测量,不要用眼睛看
几何信息比截图更快地回答了问题,并且它暴露了上述的所有错误。拉取get_metadata,然后:
- 高度 ≤ 12px → 文本或画框折叠
- 宽度为1 → 上述循环约束
- 页面远高于其HTML等效项 → 窄换行,而非内容
- 当你期望两个副本时只有一个画框副本 → 运行从未发生
完整性检查总数:块的y范围应无间隙地平铺其页面,最后一个块应恰好结束于页面高度处。
本地模拟插件
技能文件夹中的 tools/figma_stub.js 是一个准备好的严格模拟;node tools/test_figma_stub.js 证明它能在Figma抛出异常的地方抛出。
figma 全局对象的模拟会捕获抛出、null追加和结构错误,早于往返测试。它无法模拟文本度量,因此它证明代码能运行,但绝不证明布局是正确的。
让模拟像真实API一样严格,否则它会通过那些在Figma中会失败的构建。以下是每个都是因为模拟过于宽松而到达画布的错误:
resize(w, h){
if (!(w >= 0)) throw new Error('在 resize 中:属性 "width" 未能通过验证');
if (!(h >= 0)) throw new Error('在 resize 中:属性 "height" 未能通过验证');
...
}
set fontName(v){
if (!loaded.has(v.family + "|" + v.style))
throw new Error('无法使用未加载的字体系 "' + v.family + " " + v.style + '"');
...
}
appendChild(c){ if (!c) throw new Error("null 子元素"); ... }
为模拟提供 currentPage.children,并用真实页面已经包含的画框来初始化它——这就是原点冲突如何在本地显现的方式。
为了检查定位,模拟还需要计算自动布局高度:对于VERTICAL画框,求和子元素加上 itemSpacing 和内边距;对于HORIZONTAL画框,取最大值;当 primaryAxisSizingMode === "FIXED" 时返回固定值。粗略模拟足以捕获行重叠。
运行每个调度路径、每个断点和每种字体设置,而不仅仅是正常路径:
仅 Inter · Inter + 等宽 · 全部三种 · 仅 Inter Regular
desktop · mobile dark · light
字体:加载你将要求的,解析你得到的
分配未加载的字体会抛出。背后有两个陷阱:
- 你必须预加载代码要求的每个字重。 加载 Regular、Medium 和 Bold 然后请求 Semi Bold 是崩溃,等待使用它的卡片。
- 样式名称因字体系列而异。 Inter 使用
"Semi Bold";JetBrains Mono 使用"SemiBold"。用错误的拼写请求正确的字重仍然会抛出。
分别探测每个字重,记录实际加载的内容,并通过回退链解析每个请求,而不是假设:
const ALIAS = { "Semi Bold": ["Semi Bold","SemiBold","Medium","Bold","Regular"], … };
function font(family, style){
for (const s of ALIAS[style]) if (LOADED[family+"|"+s]) return { family, style:s };
for (const s of ALIAS[style]) if (LOADED["Inter|"+s]) return { family:"Inter", style:s };
return { family:"Inter", style:"Regular" };
}
永远不要在入口点使用 Promise.all 严格预加载——缺失的一个字重会杀死一个解析器本可以处理的构建。在关闭toast中报告你解析到的字体系列。
一个插件,一项任务
抵制在一个插件内部使用调度开关——基于编辑器类型、页面名称或选区。页面名称切换意味着用户必须执行一个仪式才能使事情工作,而当它静默地做错误的事情时,没有什么可读取的。
两个具有不同 id 和 name 值的manifest.json成本为零,并消除了整个问题类别。当重新导入的manifest携带一个它已知的 id 时,Figma还会重用缓存的注册,因此更改 id 是强制干净加载的方法。
读取评论
MCP 暴露了 get_metadata、get_design_context、get_screenshot、get_figjam、use_figma —— 它们都不能读取评论,而且Plugin API也没有评论访问权限。评论存在于REST API中。
使用技能文件夹中的 tools/figma_comments.py(Python 3,标准库):
T=~/.claude/skills/figma-build/tools/figma_comments.py
python3 $T check <文件-url> # 正常:令牌可以读取评论 (文件KEY上N个)
python3 $T fetch <文件-url> # 开放线程为JSON,过滤掉已解决和日志记录
python3 $T mark <文件-url> ID... --change "改变的内容"
python3 $T reply <文件-url> ROOT_ID "完成:logo 增大20%" # 需要写入作用域
它从 ~/.figma-token 读取令牌,并且永远不会打印它。从工作文件夹运行,以便日志记录文件存放在那里,或者设置 FIGMA_LEDGER。没有删除命令。如果报告SSL证书错误,说明Python没有根证书:改用 /usr/bin/python3,或者 pip install certifi。
作用域:文件评论 → 读取。将令牌存储在文件中(~/.figma-token,权限600),永远不要存储在对话中。如果此人无法运行shell命令,则写入一个包含占位符的文件,并用 open -a TextEdit 打开让其粘贴。
/v1/me 在仅评论作用域的令牌上返回 403。这是预期的,不是令牌损坏。/v1/files/:key/nodes 在没有 file_content:read 作用域时也会返回403。
每个评论都带有 client_meta:
{ "node_id": "2:95", "node_offset": { "x": 279, "y": 1257 } }
通过拉取 get_metadata 并找到其y范围包含偏移量的子画框,将其映射到一个块。线程以扁平形式返回——通过 parent_id 分组,并且回复通常携带实际指令,而根评论仅是一个指针。
评论是针对文件的。将内容移动到新文件会使它们丢失。
审查循环,当被要求“阅读我的评论”时
- 获取 使用
figma_comments.py fetch,它会将评论分组为线程。跳过已解决的(resolved_at已设置)以及已经存在于处理日志(下文)中的每个ID。旧评论会一直保留在文件中直到被解决,所以没有这两个过滤器,每次审查以及之后的每次对话都会重新应用上一轮的反馈。 - 映射 通过
client_meta将每个线程映射到其画框,如上所述。 - 在绘制前确认: 在一个简短的表格中列出每条评论、其映射到的画框以及计划的更改。钉在空白画布上的评论,或者模棱两可的评论,应该提问而不是猜测。
- 应用 更改原地:改变现有节点的属性,保持其ID不变。永远不要删除和重绘被评论的画框,因为那会使它的评论成为孤儿。通过MCP(
use_figma),画布直接更新。通过开发插件,在磁盘上编辑其代码,递增构建标记,然后请此人重新运行插件。 - 验证 使用
get_metadata和截图(参见测量,不要用眼睛看)。在插件路径上,在信任画布之前检查关闭toast是否显示新的标记。 - 逐条报告 哪些评论改变了什么。如果令牌具有写入作用域且此人希望,则在每个线程中回复。
- 记录 在回合结束前,使用
figma_comments.py mark记录每个已处理的评论ID(线程根和回复),包括那些你仅回答了问题的。 - 永远不要删除评论,即使被要求清理。它们是审查记录。旧反馈通过日志记录(而非删除)在后续轮次中被排除。如果某人需要在Figma中清理一轮,由他们自己解决评论。
处理日志。 一个在工作目录旁边的JSON文件:在项目文件夹中为 figma/comments-handled.json,否则为工作目录中的 figma-comments-handled.json。在每次审查开始时读取它,包括在新的对话中。
{ "<文件KEY>": { "<评论ID>": { "handled_at": "2026-11-03T14:05:00Z", "change": "logo 在1080x1920上增大20%" } } }
没有用于解决评论的API端点,因此解决操作由用户手动完成。
何时请求重新导入(仅插件路径):当toast显示过时的标记,或“replaced 0”而画框可见时。请他们删除插件,重新导入其 manifest.json 并重新打开标签页。如果失败,更改manifest的 id(参见一个插件,一项任务)。
大规模截图
每个页面一次截图加上局部裁剪,优于每个块一次调用。块的y坐标和高度来自 get_metadata,maxDimension 设置等于或高于画框高度会给出1:1的比例,并且裁剪会精确对齐。