Communitygithub.com

l4ur4oliveira/astroville

本文件記錄了透過 MCP 伺服器在 Penpot 上建構 AstroVille 設計系統時學到的艱辛教訓,目標是**避免在未來專案中重新發現這些 Penpot 怪癖**。

astroville 是什麼?

這份技能文件總結了在使用 Penpot MCP 伺服器建構 AstroVille 設計系統時所遇到的棘手問題與解決方案。其核心洞見是「群組優先」法則:將區段內容包裹在 Penpot 群組中,並使用相對於群組原點的簡單 (x, y) 座標來定位子元素,能大幅減少定位錯誤。文件詳細分析了九個常見問題,包括:群組化後子元素座標偏移導致內容消失、文字行高 (lineHeight) 設為像素值導致高度異常、字型大小設定順序不當導致文字尺寸為 1x1、字距 (letterSpacing) 不支援 em 單位、匯出空白 PNG、陰影顏色格式錯誤、漸層方向向量被正規化等。每個問題都附有根因分析與可複用的修復程式碼片段。此技能支援 Penpot 與任何透過 MCP 與 Penpot 互動的 AI 代理器(如 Claude Code)。它作為一份故障排除指南與最佳實務參考,能協助開發者避免重蹈覆轍,並建立可預測的設計系統工作流程。

相容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/l4ur4oliveira/astroville/tree/HEAD/.agents/skills/penpot-problem-solving

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

Penpot MCP 問題解決指南

目的

本文件記錄了透過 MCP 伺服器在 Penpot 上建構 AstroVille 設計系統時學到的艱辛教訓。目標是避免在未來專案中重新發現這些 Penpot 怪癖。許多這些行為是未記載的,只有在執行時期匯出或群組化元素時才會顯現。

最關鍵的教訓:務必將區段內容包裹在群組 (Group) 中,然後使用相對於群組原點的簡單 (x, y) 座標定位群組內的所有元素。這能大幅減少定位錯誤。


摘要 — 「群組優先」法則

對於 Penpot 設計中的每個頂層區段(例如代表「顏色」、「字體」、「間距」的面板),請執行以下步驟:

  1. 建立面板。
  2. 將其所有子元素包裹在一個群組中(當你呼叫 penpot.group([...children]) 時,Penpot 會自動建立此群組)。
  3. 將群組本身定位在內容區域的期望左上角,例如 penpotUtils.setParentXY(group, 64, 40)。
  4. 在群組內部,所有子元素的位置都使用以群組原點為基準的簡單 (0, 0) 座標。
  5. 務必先以 (0, 0) 相對座標定位子元素,然後再將它們群組化,而不是反過來。

這個模式能讓定位變得可預測,並確保匯出功能正常運作。


問題 1 — 卡片子元素在群組化後得到負數的 parentX / parentY

症狀

在呼叫 penpot.group([...children]) 包裹區段內容後,卡片內部的巢狀子元素最終得到了負數的 parentX / parentY 值。例如,一個本應位於 (0, 0)(相對於其卡片)的矩形,最終卻位於 (-64, -210) — 被繪製在卡片邊界之外。

視覺上,卡片背景(彩色色票)看起來是空的,因為實際的填色矩形被定位在外部。

根本原因

當你將已經在父面板上以絕對座標佈局的形狀進行群組化時,Penpot 會計算群組的邊界框。群組本身會被定位在其成員的 (minX, minY) 位置。然後,對於每個子元素,其 parentX / parentY 會根據群組的新原點重新計算。

如果某個子元素的絕對位置是 (64, 218),而群組最終位於 (64, 218),那麼該子元素的 parentX / parentY 就會變成 (0, 0)。但是,如果包含該子元素的卡片位於 (64, 218),而群組最終位於 (64, 40),那麼卡片內的所有東西都會得到負數的 parentY,因為卡片的位置「高於」群組的原點。

修復

務必在呼叫 penpot.group() 之前,將子元素定位在相對於最終群組原點 (0, 0) 的座標。群組化後,群組的原點對其子元素來說就變成了 (0, 0)。

如果你已經群組化且子元素對齊錯誤,恢復方法如下:

  1. penpot.ungroup(group) — 全部展平。
  2. 從每個子元素的 y(或 x)位置減去預期的群組偏移量。
  3. 再次執行 penpot.group(children)。
  4. 執行 penpotUtils.setParentXY(group, desiredParentX, desiredParentY)。

具體來說(已驗證可行的模式):

// 1. 儲存你想要保留的原始絕對位置。
const originalPositions = children.map(c => ({ c, x: c.x, y: c.y }));

// 2. 將每個子元素偏移 -groupOffset(使它們相對於群組原點)。
for (const child of children) {
  child.y -= GROUP_OFFSET_Y;   // 例如 40
}

// 3. 將它們群組化。
const group = penpot.group(children);

// 4. 定位群組本身。
if (group) {
  penpotUtils.setParentXY(group, group.parentX, GROUP_OFFSET_Y);
}

問題 2 — 具有固定 lineHeight(像素值)的文字形狀呈現出巨大的高度

症狀

一個 fontSize: 48 和 lineHeight: 56(像素值)的文字形狀,最終的 bounds.height 是 2689(或類似的荒謬值)。文字會垂直換行 — 實際渲染的文字會出現在你放置位置的下方很遠處(通常絕對 y 值是 1313),導致文字看不見。

根本原因

Penpot 的 lineHeight 接受一個數值倍數字串,例如 "1.2",但拒絕某些值,例如 "56" 或任何非數字、非倍數的值。當傳入無效值時,Penpot 會靜默使用一個可能極大的預設行高,導致換行問題。SVG 匯出確認了這個錯誤 — <text> 元素的 y 屬性回報了一個巨大的值,例如 1386.44。

此外,即使一個看似有效的像素 lineHeight 被接受了,auto-width 文字的邊界計算也可能產生奇怪的值,例如 width: 338 但 height: 2689,因為文字引擎在文字創建時認為的邊界內進行換行。

修復

務必使用 lineHeight: "1.2"(字串倍數) 而不是像素值。這樣文字形狀就會有正確、可預測的邊界:

const t = penpot.createText("ASTROGENESIS");
parent.appendChild(t);
t.resize(800, 80);                  // 在設定 fontSize 之前先 resize
t.x = 64;
t.y = 100;
t.fontFamily = "Space Grotesk";
t.fontWeight = "700";
t.fontSize = 48;
t.lineHeight = "1.2";               // ← 使用 "1.2",不要用 "56"
t.fills = [{ fillColor: "#F8FAFC", fillOpacity: 1 }];

無論 fontSize 是在 appendChild 之前或之後設定,只要在 fontSize 之前呼叫了 resize() 且 lineHeight 是倍數字串,這種方法都有效。


問題 3 — 在 appendChild 後改變 fontSize 會導致 1×1 的尺寸(如果未先呼叫 resize())

症狀

用 penpot.createText() 建立的文字元素,在附加到父元素後設定 fontSize: 32,結果初始時 width: 1, height: 1。過了一段時間後,邊界會恢復,但文字可能會在非預期位置渲染。

根本原因

Penpot 會使用當前邊界來急切地計算文字維度。如果在字型引擎測量時,文字剛剛被建立且維度是 1×1,它會將文字視為限制在 1 像素寬,並進行激進的換行。

修復

務必在設定 fontSize 之前先執行 resize(width, height)。這為文字提供了一個已知的邊界框。最簡單的輔助函式:

function makeText(parent, chars, opts) {
  const t = penpot.createText(chars);
  parent.appendChild(t);
  t.resize(opts.w || 400, opts.h || 60);  // 先 resize
  t.x = opts.x;
  t.y = opts.y;
  t.fontFamily = opts.fontFamily || "Inter Tight";
  t.fontWeight = opts.fontWeight || "400";
  t.fontSize = opts.fontSize || 14;
  t.lineHeight = opts.lineHeight || "1.2";  // 倍數,不是像素
  t.fills = [{ fillColor: opts.color || "#F8FAFC", fillOpacity: 1 }];
  if (opts.transform === "uppercase") t.textTransform = "uppercase";
  return t;
}

問題 4 — 使用 em 單位的字距 (letterspacing) 會被拒絕

症狀

設定 text.letterSpacing = "0.08em" 會失敗,並出現 Value not valid: 0.08em. Code: :letterSpacing。

修復

Penpot 的 letterSpacing 只接受數值(解釋為 1/100 em 或類似單位),不接受以 em 為後綴的字串。使用 "1"(或 0、"2" 等)而不是 "0.08em"。若要更精細的控制,請使用像 "1" 或 "2" 這樣的小數字。


問題 5 — penpot.generateMarkup() 回傳的內容,PNG 匯出無法渲染

症狀

你建立了形狀,SVG 標記正確顯示了它們(<rect>、<text>、漸層 <stop> 等),但 export_shape 回傳了空白 PNG 或缺少預期內容的 PNG。沒有任何錯誤訊息。

根本原因

觀察到兩個主要原因:

  1. 巢狀面板啟用 clipContent: true 且尺寸過小可能會裁剪掉可見區域。Penpot 使用邊界框進行匯出,但內部內容存在於邊界框之外。
  2. 帶有偏移內容的群組包裝器(上述問題 1)導致 Penpot 的匯出使用舊的 textBounds。SVG 標記可能顯示了元素,但匯出過程未考慮這些偏移。

修復

  • 當某些東西無法渲染時,將 SVG 標記(penpot.generateMarkup)視為事實依據。
  • 修復問題 1(子元素位於正確的相對位置)後,匯出功能就能正確運作。
  • 先匯出小的個別面板來驗證,以隔離是哪個面板失敗。

問題 6 — 區段面板的子元素在群組化後消失

症狀

群組化後,區段的色票 / 卡片 / 標籤從可見匯出中消失,但它們仍然存在於資料結構中。卡片的容器會出現(它們的邊框、填色),但它們的內容(矩形、文字子元素)被繪製在卡片之外。

根本原因

這與問題 1 相同 — 卡片的子元素具有負數的 parentX / parentY,因此被繪製在其卡片邊界之外。

修復

群組化後,遍歷卡片並明確重置每個巢狀子元素的位置到其相對於卡片的預期位置。例如,在一個顏色卡片中,有一個矩形色票位於 (0, 0),三個文字標籤分別位於 (16, 132)、(16, 154)、(16, 174):

const cards = group.children.filter(c => c.name && c.name.startsWith("Color Card"));
for (const card of cards) {
  const ch = card.children;
  if (ch.length < 4) continue;
  penpotUtils.setParentXY(ch[0], 0,   0);    // 矩形位於卡片左上角
  penpotUtils.setParentXY(ch[1], 16,  132);  // 標籤
  penpotUtils.setParentXY(ch[2], 16,  154);  // 權杖名稱
  penpotUtils.setParentXY(ch[3], 16,  174);  // 十六進位值
}

問題 7 — 頁面根節點 (page.root) 具有固定的小尺寸,因此寬面板可能會破壞匯出

症狀

Penpot 頁面根面板的尺寸總是 0.01 × 0.01 — 其尺寸無法調整。你添加到其中的任何東西都位於根節點邊界框之外的絕對座標上。

根本原因

Penpot 將頁面根節點視為一個佔位符;所有真實內容都存在於使用者建立的、位於頁面上絕對座標的面板中。

修復

只要將內容保持在具名面板中,並帶有它們自己的絕對座標(像你通常所做的那樣),這就不是問題。只是不要嘗試使用 page.root.resize()。如果你需要一個主容器,請將子面板直接放置在 page.root 上,而不是將它們巢狀在一個主面板中。


問題 8 — 陰影:color 必須是 { color: "#hex", opacity: 0..1 },而不是 { r, g, b, a }

症狀

設定如下的陰影:

shape.shadows = [{
  color: { r: 139/255, g: 92/255, b: 246/255, a: 0.25 },
  offsetX: 0, offsetY: 0, blur: 24, spread: 0,
  style: "drop-shadow", visible: true
}];

結果渲染成低透明度的黑色,而不是紫色。該形狀的 .shadows 讀回來變成:

{ color: { color: "#000000", opacity: 0.2 }, ... }

根本原因

Shadow.color 是一個 Penpot Color 物件,其形狀是 { color: "#hex", opacity: 0..1 } — 不是 {r, g, b, a} 物件。錯誤的形狀會被靜默忽略,Penpot 會回退到預設的黑色投影。

修復

使用帶有十六進位字串和獨立 opacity 欄位的 Color 物件形狀:

shape.shadows = [{
  color: { color: "#8B5CF6", opacity: 0.25 },
  offsetX: 0, offsetY: 0, blur: 24, spread: 0,
  style: "drop-shadow", visible: true
}];

或者使用 penpot.shapesColors([shape]) 來取得一個 Color 實例,並在應用前檢查其欄位。


問題 9 — 漸層 startX / startY / endX / endY 是正規化的,不是像素

症狀

使用像素座標設定漸層:

shape.fills = [{
  fillColorGradient: {
    type: "linear",
    startX: 0, startY: 0, endX: 320, endY: 160,
    stops: [...]
  },
  fillOpacity: 1
}];

讀回來時 endX 變成 ~1.0、endY 變成 ~1.0 — Penpot 靜默地正規化了向量。width 欄位保留了原始比例,但方向向量現在位於 [0, 1] 空間中。

根本原因

Penpot 將漸層方向儲存為一個正規化的 0–1 向量。像素值會除以 width。如果結果向量並非嚴格在 [0, 1] 範圍內(例如 endX: 320 / 320 = 1.0 正好,或 endY: 160 / 320 = 0.5),Penpot 似乎會進行裁剪和四捨五入。

在 AstroVille 中,使用者拖曳了 Penpot UI 中的第二個顏色點,將其放置在矩形的右下角。之後的值是 endX: ~1.0, endY: ~1.0。像素完美的漸層定位並未作為可寫入屬性公開 — UI 是漸層手柄位置的真實來源。

修復

  • 對於水平漸層(從左到右):endX: 1, endY: 0
  • 對於對角線漸層(從左上到右下):endX: 1, endY: 1
  • 對於垂直漸層(從上到下):endX: 0, endY: 1
  • 若要精確控制漸層停止點的位置,請對每個停止點使用 offset(0–1)。
  • 如果漸層手柄的精確位置很重要,請在 Penpot UI 中設定它們,並讀回以確認。

建議模式:AstroVille 的「基礎」頁面範本

適用於 AstroVille 的「00 — Design System」頁面的結構:

page.root
├── "00 — Design System / Header" (board, 0, 0, 1440×120)
│   └── Group at (64, 40)
│       ├── "Design System"  text at (0, 0)   → renders at (64, 40)
│       └── "AstroVille — Foundations" text at (0, 52) → (64, 92)
│
├── "01 — Colors" (board, 0, 200, 1440×1460)
│   └── Group at (64, 40)
│       ├── Title + Description texts at (0, 0), (0, 48)
│       ├── "Background" label at (0, 100)
│       ├── 3 Color Cards in a row at (0, 178), (264, 178), (528, 178)
│       │   Each card has its own children at (0, 0), (16, 132), (16, 154), (16, 174)
│       └── ... 3 more groups (Brand, Feedback, Text) at (0, 498), (0, 818), (0, 1138)
│
└── ... other sections with the same structure

區段 y 偏移量(相對於群組的 (0, 0)):

區段類型佈局
頁首標題 (0, 0),副標題 (0, 52)
標題區塊標題 (0, 0),說明 (0, 48)
群組標題標籤 (0, 100),說明 (0, 130)
卡片列3 張卡片位於 (0, 178), (264, 178), (528, 178)
下一列在上一列下方 320px 處: (0, 498), (0, 818), (0, 1138)

已驗證可行的輔助函式,用於建立面板和文字:

function makeText(parent, chars, opts) {
  const t = penpot.createText(chars);
  parent.appendChild(t);
  t.resize(opts.w || 400, opts.h || 60);
  t.x = opts.x;
  t.y = opts.y;
  t.fontFamily = opts.fontFamily || "Inter Tight";
  t.fontWeight = opts.fontWeight || "400";
  t.fontSize = opts.fontSize || 14;
  t.lineHeight = opts.lineHeight || "1.2";
  t.fills = [{ fillColor: opts.color || "#F8FAFC", fillOpacity: 1 }];
  if (opts.transform === "uppercase") t.textTransform = "uppercase";
  return t;
}

最終建議

  1. 總是使用群組。這是讓定位可預測的唯一可靠方法。
  2. 先定位子元素,再群組化。不要依賴群組化後的重新計算。
  3. 行高 (Line height) 使用倍數字串,而非像素。
  4. 在設定字型大小前先 resize() 文字。
  5. 陰影顏色使用 { color: "#hex", opacity: 0..1 } 格式。
  6. 漸層方向使用正規化的 0–1 值,而非像素。
  7. 信任 SVG 標記 (`penpot.generateMarkup) 作為疑難排解的真實來源。
  8. 在 Penpot UI 中處理精確的漸層手柄位置,並透過 penpot.shapesColors() 讀回。

相關技能