Communitygithub.com

l4ur4oliveira/astroville

Penpot MCPを使ったAstroVilleデザインシステム構築で得られた、貴重な教訓をまとめたガイドです。**将来のプロジェクトで同じPenpotの癖に苦労しない** ことを目的としています。これらの動作の多くはドキュメント化されておらず、エクスポートやグループ化時に初めて現れるランタイム時の挙動です。

astroville とは?

このスキルは、PenpotのMCPサーバーを介してデザインシステム(AstroVille)を構築する際に直面する、ドキュメント化されていない問題とその解決策を集約した実践的なガイドです。特に重要なのは「Group-Firstルール」で、すべてのトップレベルセクションのコンテンツをグループでラップし、グループの原点を基準に子要素を(0,0)ベースの単純な座標で配置することで、位置ずれを劇的に減らします。テキストのlineHeightは数値乗数文字列("1.2"など)を使用し、fontSize設定前にresize()を呼び出すこと、シャドウカラーには{r,g,b,a}ではなく{color: "#hex", opacity: n}の形式を使うこと、グラデーションのベクトルは0-1の正規化値で指定することなど、具体的なコーディングパターンが示されています。このスキルは主にPenpot MCPサーバーを利用するAIエージェント(Claude Code、Cursor、Codexなど)向けに設計されており、デザイントークン管理、UIコンポーネント生成、ドキュメント自動化のワークフローを効率化します。

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

お気に入りのAIに質問する

このエージェントスキルを事前に読み込んだ状態で新しいチャットを開きます。

ドキュメント

Penpot MCP 問題解決ガイド

目的

このドキュメントは、MCPサーバーを介してPenpot上でAstroVilleデザインシステムを構築する際に得られた、貴重な教訓をまとめたものです。目的は、将来のプロジェクトでこれらのPenpotの癖を再発見しない ことです。これらの動作の多くはドキュメント化されておらず、要素がエクスポートまたはグループ化される際にランタイムで初めて現れます。

最も重要な教訓: セクションのコンテンツは常にグループでラップし、グループの原点を基準にしたシンプルな (x, y) 座標を使用して、グループ内のすべての要素を配置すること。これにより、位置決めエラーが劇的に減少します。


TL;DR — 「グループ優先」ルール

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) にあるべきRectangleが (-64, -210) になり、カードの境界の外側に描画されます。

視覚的には、実際の塗りつぶしRectangleが外部に配置されているため、カードの背景(色見本)は空っぽに見えます。

根本原因

親ボード上ですでに絶対位置に配置されていたシェイプをグループ化すると、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を設定する前にリサイズ
t.x = 64;
t.y = 100;
t.fontFamily = "Space Grotesk";
t.fontWeight = "700";
t.fontSize = 48;
t.lineHeight = "1.2";               // ← "56"ではなく "1.2" を使用
t.fills = [{ fillColor: "#F8FAFC", fillOpacity: 1 }];

これは、fontSize の設定が追加の前後どちらであっても、resize() を fontSize の前に呼び出し、lineHeight が乗数文字列である限り機能します。


問題 3 — 追加後の fontSize 変更で、最初に resize() を呼び出さないと 1×1 の寸法になる

症状

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);  // 最初にリサイズ
  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 単位の文字間隔が拒否される

症状

text.letterSpacing = "0.08em" を設定すると、Value not valid: 0.08em. Code: :letterSpacing で失敗します。

修正方法

Penpotの letterSpacing は数値(1/100 em、または同様のものとして解釈)のみを受け付け、em 接尾辞の文字列は受け付けません。"0.08em" の代わりに "1" (または 0、"2" など) を使用します。より細かい制御が必要な場合は、"1" や "2" のような小さな数値を使用します。


問題 5 — penpot.generateMarkup() が返すコンテンツを PNG エクスポートがレンダリングしない

症状

シェイプを構築し、SVGマークアップにはそれらが正しく表示される(<rect>、<text>、グラデーションの <stop> など)にもかかわらず、export_shape は空白の PNG または期待されるコンテンツが欠落した PNG を返します。エラーメッセージはありません。

根本原因

観測された主な原因は2つ:

  1. clipContent: true で小さな寸法のネストされたボード が表示領域を切り取る可能性があります。Penpotはバウンディングボックスを使用してエクスポートしますが、内部コンテンツはその外側に存在します。
  2. コンテンツがシフトされたグループラッパー (上記問題1) により、Penpotのエクスポートが古い textBounds を使用する原因になります。SVGマークアップは要素を表示するかもしれませんが、エクスポートはオフセットを尊重しません。

修正方法

  • 何かがレンダリングされない場合、SVGマークアップ (penpot.generateMarkup) を信頼できる情報源として使用します。
  • 問題1(子要素が正しい相対位置にあること)を修正した後、エクスポートは正しく機能します。
  • 最初に小さな個別のボードをエクスポートして、どのボードが失敗しているかを特定して検証します。

問題 6 — グループ化後にセクションボードの子要素が消える

症状

グループ化後、セクションの見本/カード/ラベルが表示上のエクスポートから消えますが、データ構造内にはまだ存在します。カードのコンテナ(境界線、塗りつぶし)は表示されますが、そのコンテンツ(Rectangle、Text子要素)はカードの外側に描画されます。

根本原因

これは問題1と同じです — カードの子要素が負の parentX / parentY を持ち、カードの境界の外側に描画されます。

修正方法

グループ化後、カードをウォークスルーし、すべてのネストされた子要素の位置を、そのカードを基準とした意図された位置に明示的にリセット します。例えば、Rectangleの見本が (0, 0)、3つのテキストラベルが (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);    // Rectangle をカードの左上に
  penpotUtils.setParentXY(ch[1], 16,  132);  // ラベル
  penpotUtils.setParentXY(ch[2], 16,  154);  // トークン名
  penpotUtils.setParentXY(ch[3], 16,  174);  // 16進数値
}

問題 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 は { color: "#hex", opacity: 0..1 } という形式の Penpot Color オブジェクトであり、{r, g, b, a} オブジェクトではありません。間違った形式はサイレントに無視され、Penpotはデフォルトの黒いドロップシャドウにフォールバックします。

修正方法

16進数文字列と個別の 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で2番目のカラーポイントを長方形の右下隅にドラッグしました。その後の値は 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カード at (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. ボードを作成 (penpot.createBoard())
  2. 子要素をすべて グループの原点 (0, 0) を基準にして (x, y) で配置 (0 からオフセットを加算)
  3. penpot.group([...children]) でグループ化
  4. penpotUtils.setParentXY(group, boardX, yOffset) でグループをボード上の正しい絶対位置に配置
  5. fontSize を設定する前にすべてのテキスト要素を resize() する
  6. lineHeight にピクセル値ではなく "1.2" を使用する
  7. letterSpacing には数値 (例: "1") を使用し、"0.08em" は使わない
  8. シャドウの color は { color: "#hex", opacity: n } の形式を使用
  9. グラデーションベクトルは [0, 1] の正規化値を使用 (endX: 1, endY: 1)
  10. SVGマークアップの生成と小さな部分のエクスポートで確認 (export_shape → 期待通りにレンダリングされるか?)
  11. 各カードのネストされた子要素の parentX / parentY が正の値であることを確認(負の値は問題を示す)

最終更新: AstroVille デザインシステム v1.0

関連スキル