Communitygithub.com

l4ur4oliveira/astroville

Dieses Dokument enthält wertvolle Erkenntnisse aus der Erstellung des AstroVille Design Systems in Penpot über den MCP-Server und hilft, diese Fallstricke in zukünftigen Projekten zu vermeiden.

Was ist astroville?

Dieses Repository dient als Leitfaden zur Lösung häufiger Probleme bei der programmatischen Gestaltung von Penpot über den MCP-Server. Es basiert auf den Erfahrungen beim Aufbau des AstroVille Design Systems und behandelt neun spezifische Probleme, die bei der Positionierung von Elementen, der Textformatierung, Schatten, Farbverläufen und dem Export auftreten. Der wichtigste Grundsatz ist die "Group-First"-Regel: Inhalte immer in einer Gruppe kapseln und relative Koordinaten verwenden, um Positionierungsfehler zu vermeiden. Der Leitfaden enthält reproduzierbare Fixes, Codebeispiele und eine empfohlene Seitenstruktur für Designsysteme. Er richtet sich an Entwickler und KI-Agenten, die Penpot programmatisch über MCP steuern und dabei typische, undokumentierte Verhaltensweisen umgehen möchten.

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

In Ihrer bevorzugten KI fragen

Öffnet einen neuen Chat, in dem dieser Agent-Skill bereits geladen ist.

Dokumentation

Penpot MCP - Leitfaden zur Problemlösung

Zweck

Dieses Dokument enthält wertvolle Erkenntnisse aus der Erstellung des AstroVille Design Systems in Penpot über den MCP-Server. Ziel ist es, diese Penpot-Eigenheiten in zukünftigen Projekten nicht neu entdecken zu müssen. Viele dieser Verhaltensweisen sind undokumentiert und treten erst zur Laufzeit auf, wenn Elemente exportiert oder gruppiert werden.

Die wichtigste Erkenntnis: Den Inhalt eines Bereichs immer in einer Gruppe kapseln und dann alles innerhalb der Gruppe mit einfachen (x, y)-Koordinaten relativ zum Ursprung der Gruppe positionieren. Dies reduziert Positionsfehler drastisch.


TL;DR – Die "Group-First"-Regel

Für jeden übergeordneten Bereich eines Penpot-Designs (z.B. eine Tafel für "Farben", "Typografie", "Abstände"):

  1. Erstelle die Tafel.
  2. Kapsle alle ihre Kinder in einer Gruppe (Penpot erstellt diese automatisch, wenn du penpot.group([...children]) aufrufst).
  3. Positioniere die Gruppe selbst an der gewünschten oberen linken Ecke deines Inhaltsbereichs, z.B. penpotUtils.setParentXY(group, 64, 40).
  4. Innerhalb der Gruppe verwenden alle Kindpositionen einfache (0, 0)-basierte Koordinaten relativ zum Gruppenursprung.
  5. Positioniere die Kinder IMMER zuerst bei (0, 0)-relativen Koordinaten, dann gruppiere sie, nicht umgekehrt.

Dieses Muster macht die Positionierung vorhersagbar und den Export korrekt.


Problem 1 – Kinder einer Karte erhalten nach dem Gruppieren negative parentX / parentY

Symptom

Nach dem Aufruf von penpot.group([...children]) zum Kapseln des Inhalts eines Bereichs erhalten die verschachtelten Kinder innerhalb von Karten negative parentX / parentY-Werte. Beispielsweise landet ein Rechteck, das relativ zu seiner Karte bei (0, 0) sein sollte, bei (-64, -210) – außerhalb der Kartengrenzen gezeichnet.

Visuell erscheinen die Kartenhintergründe (die farbigen Farbfelder) leer, da das eigentliche Füllrechteck außerhalb positioniert ist.

Grundursache

Wenn du Formen gruppierst, die bereits an absoluten Positionen auf einer übergeordneten Tafel angeordnet waren, berechnet Penpot den Begrenzungsrahmen der Gruppe. Die Gruppe selbst wird bei (minX, minY) ihrer Mitglieder positioniert. Dann wird für jedes Kind dessen parentX / parentY relativ zum neuen Ursprung der Gruppe neu berechnet.

Wenn die absolute Position eines Kindes beispielsweise (64, 218) war und die Gruppe bei (64, 218) landet, wird das parentX/parentY des Kindes zu (0, 0). Wenn sich die Karte, die das Kind enthält, jedoch bei (64, 218) befand und die Gruppe beispielsweise bei (64, 40) landet, erhält alles innerhalb der Karte ein negatives parentY, da die Karte "höher" als der Ursprung der Gruppe liegt.

Lösung

Positioniere Kinder immer an Koordinaten relativ zum späteren Ursprung der Gruppe (0, 0), BEVOR du penpot.group() aufrufst. Nach dem Gruppieren wird der Ursprung der Gruppe zu (0, 0) für ihre Kinder.

Falls du bereits gruppiert hast und die Kinder falsch ausgerichtet sind, erfolgt die Wiederherstellung wie folgt:

  1. penpot.ungroup(group) – zurück zur flachen Struktur.
  2. Subtrahiere den beabsichtigten Gruppenversatz von der y- (oder x-)Position jedes Kindes.
  3. penpot.group(children) erneut aufrufen.
  4. penpotUtils.setParentXY(group, desiredParentX, desiredParentY).

Konkret (das Muster, das funktioniert hat):

// 1. Speichere die ursprünglichen absoluten Positionen, die du beibehalten möchtest.
const originalPositions = children.map(c => ({ c, x: c.x, y: c.y }));

// 2. Verschiebe jedes Kind um -groupOffset (damit sie relativ zum Gruppenursprung sind).
for (const child of children) {
  child.y -= GROUP_OFFSET_Y;   // z.B. 40
}

// 3. Gruppiere sie.
const group = penpot.group(children);

// 4. Positioniere die Gruppe selbst.
if (group) {
  penpotUtils.setParentXY(group, group.parentX, GROUP_OFFSET_Y);
}

Problem 2 – Textformen mit fester lineHeight (Pixelwert) werden extrem hoch dargestellt

Symptom

Eine Textform mit fontSize: 48 und lineHeight: 56 (ein Pixelwert) endet mit bounds.height: 2689 (oder einem ähnlich absurden Wert). Der Textumbruch erfolgt vertikal – der tatsächlich dargestellte Text erscheint weit unterhalb der Stelle, an der du ihn platziert hast (oft bei y: 1313 in absoluten Koordinaten), sodass der Text unsichtbar ist.

Grundursache

Penpots lineHeight akzeptiert einen numerischen Multiplikator-String wie "1.2", lehnt aber einige Werte wie "56" oder andere nicht-numerische, nicht-multiplikative Werte ab. Wenn ein ungültiger Wert übergeben wird, verwendet Penpot stillschweigend eine Standardzeilenhöhe, die enorm sein kann, was zu Umbrüchen führt. Der SVG-Export bestätigt den Fehler – das y-Attribut des <text>-Elements meldet einen großen Wert wie 1386.44.

Auch wenn ein scheinbar gültiger Pixel-lineHeight-Wert akzeptiert wird, kann die Begrenzungsrahmenberechnung für Text mit auto-width seltsame Werte wie width: 338 aber height: 2689 erzeugen, weil die Text-Engine innerhalb der Grenzen umbricht, die der Text zum Zeitpunkt der Erstellung zu haben glaubt.

Lösung

Verwende immer lineHeight: "1.2" (einen String-Multiplikator) anstelle von Pixelwerten. Die Textform hat dann korrekte, vorhersagbare Begrenzungsrahmen:

const t = penpot.createText("ASTROGENESIS");
parent.appendChild(t);
t.resize(800, 80);                  // resize VOR dem Setzen von fontSize
t.x = 64;
t.y = 100;
t.fontFamily = "Space Grotesk";
t.fontWeight = "700";
t.fontSize = 48;
t.lineHeight = "1.2";               // ← verwende "1.2", nicht "56"
t.fills = [{ fillColor: "#F8FAFC", fillOpacity: 1 }];

Dies funktioniert, unabhängig davon, ob fontSize vor oder nach dem Anhängen gesetzt wird, solange resize() VOR fontSize aufgerufen wird und lineHeight ein Multiplikator-String ist.


Problem 3 – fontSize-Änderung nach dem Anhängen führt zu 1×1-Abmessungen, wenn resize() nicht zuerst aufgerufen wird

Symptom

Ein mit penpot.createText() erstelltes Textelement, das an ein übergeordnetes Element angehängt und dann mit fontSize: 32 versehen wird, hat zunächst width: 1, height: 1. Nach einiger Zeit erholen sich die Begrenzungsrahmen, aber der Text wird möglicherweise an unerwarteten Positionen dargestellt.

Grundursache

Penpot misst Textabmessungen eifrig anhand der aktuellen Begrenzungsrahmen. Wenn der Text erstellt wird und zum Zeitpunkt der Messung durch die Text-Engine 1×1-Abmessungen hat, behandelt die Engine den Text als auf 1 Pixel breit beschränkt und bricht aggressiv um.

Lösung

Führe immer resize(width, height) VOR dem Setzen von fontSize aus. Dies gibt dem Text einen bekannten Begrenzungsrahmen. Der einfachste Helfer:

function makeText(parent, chars, opts) {
  const t = penpot.createText(chars);
  parent.appendChild(t);
  t.resize(opts.w || 400, opts.h || 60);  // resize zuerst
  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";  // Multiplikator, NICHT Pixel
  t.fills = [{ fillColor: opts.color || "#F8FAFC", fillOpacity: 1 }];
  if (opts.transform === "uppercase") t.textTransform = "uppercase";
  return t;
}

Problem 4 – Zeichenabstand mit em-Einheiten wird abgelehnt

Symptom

Das Setzen von text.letterSpacing = "0.08em" schlägt fehl mit Value not valid: 0.08em. Code: :letterSpacing.

Lösung

Penpots letterSpacing akzeptiert nur numerische Werte (interpretiert als 1/100 em oder ähnlich), keine Strings mit em-Suffix. Verwende "1" (oder 0, "2", usw.) anstelle von "0.08em". Für eine feinere Steuerung verwende eine kleine Zahl wie "1" oder "2".


Problem 5 – penpot.generateMarkup() gibt Inhalte zurück, die der PNG-Export nicht rendert

Symptom

Du erstellst Formen, das SVG-Markup zeigt sie korrekt an (<rect>, <text>, Gradient-<stop>s, usw.), aber export_shape gibt ein leeres PNG zurück oder eines, dem die erwarteten Inhalte fehlen. Es gibt keine Fehlermeldungen.

Grundursache

Es wurden zwei Hauptursachen beobachtet:

  1. Verschachtelte Tafeln mit clipContent: true und kleinen Abmessungen können den sichtbaren Bereich beschneiden. Penpot exportiert anhand des Begrenzungsrahmens, aber der innere Inhalt befindet sich außerhalb davon.
  2. Gruppen-Wrapper mit verschobenem Inhalt (Problem 1 oben) führen dazu, dass Penpots Export veraltete textBounds verwendet. Das SVG-Markup zeigt möglicherweise die Elemente an, aber der Export berücksichtigt die Versätze nicht.

Lösung

  • Vertraue auf das SVG-Markup (penpot.generateMarkup) als Grundlage der Wahrheit, wenn etwas nicht gerendert wird.
  • Nach der Behebung von Problem 1 (Kinder an korrekten relativen Positionen) funktionieren Exporte korrekt.
  • Überprüfe dies, indem du zuerst kleine einzelne Tafeln exportierst, um zu isolieren, welche fehlschlägt.

Problem 6 – Die Kinder einer Bereichstafel verschwinden nach dem Gruppieren

Symptom

Nach dem Gruppieren sind die Farbfelder / Karten / Beschriftungen des Bereichs aus dem sichtbaren Export verschwunden, aber sie existieren noch in der Datenstruktur. Die Karten-Container sind sichtbar (ihre Ränder, Füllungen), aber ihr Inhalt (Rechteck, Text-Kinder) wird außerhalb der Karte gezeichnet.

Grundursache

Dies ist dasselbe wie Problem 1 – die Kinder der Karten haben negative parentX / parentY und werden außerhalb der Kartengrenzen gezeichnet.

Lösung

Gehe nach dem Gruppieren durch die Karten und setze die Position jedes verschachtelten Kindes explizit auf seine beabsichtigte Position relativ zu seiner Karte zurück. Beispielsweise in einer Farbkarte mit einem Rechteck-Farbfeld bei (0, 0) und drei Textbeschriftungen bei (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);    // Rechteck oben links in der Karte
  penpotUtils.setParentXY(ch[1], 16,  132);  // Beschriftung
  penpotUtils.setParentXY(ch[2], 16,  154);  // Token-Name
  penpotUtils.setParentXY(ch[3], 16,  174);  // Hex-Wert
}

Problem 7 – Seitenwurzel (page.root) hat feste, winzige Abmessungen, sodass breite Tafeln den Export stören können

Symptom

Die Seitenwurzel-Tafel von Penpot ist immer 0.01 × 0.01 groß – ihre Abmessungen können nicht geändert werden. Alles, was du zu ihr hinzufügst, lebt in absoluten Koordinaten außerhalb des Begrenzungsrahmens der Wurzel.

Grundursache

Penpot behandelt die Seitenwurzel als Platzhalter; alle echten Inhalte leben in benutzererstellten Tafeln, die an absoluten Positionen auf der Seite platziert sind.

Lösung

Dies ist in Ordnung, solange du alles in benannten Tafeln mit ihren eigenen absoluten Koordinaten behältst (wie du es normalerweise tust). Versuche einfach nicht, page.root.resize() zu verwenden. Wenn du einen Master-Container benötigst, platziere die Kind-Tafeln direkt auf page.root, anstatt sie in einer Master-Tafel zu verschachteln.


Problem 8 – Schatten: color muss { color: "#hex", opacity: 0..1 } sein, nicht { r, g, b, a }

Symptom

Das Setzen eines Schattens wie:

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
}];

Wird als schwarz mit geringer Deckkraft anstelle von Violett gerendert. Die .shadows-Eigenschaft der Form liest sich zurück als:

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

Grundursache

Shadow.color ist ein Penpot-Color-Objekt in der Form { color: "#hex", opacity: 0..1 } – kein {r, g, b, a}-Objekt. Die falsche Form wird stillschweigend ignoriert und Penpot fällt auf einen standardmäßigen schwarzen Drop-Shadow zurück.

Lösung

Verwende die Color-Objektform mit einem Hex-String und einem separaten opacity-Feld:

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

Oder verwende penpot.shapesColors([shape]), um eine Color-Instanz zu erhalten, und untersuche ihre Felder, bevor du sie anwendest.


Problem 9 – Gradient startX / startY / endX / endY sind normalisiert, keine Pixel

Symptom

Das Setzen eines Farbverlaufs mit Pixelkoordinaten:

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

Liest sich zurück mit endX: ~1.0, endY: ~1.0 – Penpot hat den Vektor stillschweigend normalisiert. Das Feld width behält die ursprüngliche Skalierung, aber der Richtungsvektor liegt nun im Bereich [0, 1].

Grundursache

Penpot speichert die Gradientenrichtung als normalisierten 0–1-Vektor. Pixelwerte werden durch width dividiert. Wenn der resultierende Vektor nicht strikt im Bereich [0, 1] lag (z.B. endX: 320 / 320 = 1.0 exakt, oder endY: 160 / 320 = 0.5), scheint Penpot zu beschneiden und zu runden.

In AstroVille hat der Benutzer den zweiten Farbpunkt in der Penpot-UI gezogen, um ihn an der rechten unteren Ecke des Rechtecks zu platzieren. Die Werte danach waren endX: ~1.0, endY: ~1.0. Pixelgenaue Gradientenpositionierung ist nicht als beschreibbare Eigenschaft verfügbar – die UI ist die Quelle der Wahrheit für die Positionen der Gradientengriffe.

Lösung

  • Für einen horizontalen Gradienten (links → rechts): endX: 1, endY: 0
  • Für diagonalen Verlauf (oben-links → unten-rechts): endX: 1, endY: 1
  • Für vertikalen Verlauf (oben → unten): endX: 0, endY: 1
  • Für präzise Gradientenstopp-Positionen entlang der Richtung verwende offset (0–1) pro Stopp.
  • Wenn genaue Positionen der Gradientengriffe wichtig sind, nimm sie in der Penpot-UI vor und lies sie zur Bestätigung zurück.

Empfohlenes Muster: Die AstroVille "Foundations"-Seitenvorlage

Die Struktur, die für AstroVilles Seite "00 — Design System" funktioniert hat:

page.root
├── "00 — Design System / Header" (Tafel, 0, 0, 1440×120)
│   └── Gruppe bei (64, 40)
│       ├── "Design System"  Text bei (0, 0)   → wird bei (64, 40) gerendert
│       └── "AstroVille — Foundations" Text bei (0, 52) → (64, 92)
│
├── "01 — Colors" (Tafel, 0, 200, 1440×1460)
│   └── Gruppe bei (64, 40)
│       ├── Titel + Beschreibungstexte bei (0, 0), (0, 48)
│       ├── "Background"-Beschriftung bei (0, 100)
│       ├── 3 Farbkarten in einer Reihe bei (0, 178), (264, 178), (528, 178)
│       │   Jede Karte hat ihre eigenen Kinder bei (0, 0), (16, 132), (16, 154), (16, 174)
│       └── ... 3 weitere Gruppen (Brand, Feedback, Text) bei (0, 498), (0, 818), (0, 1138)
│
└── ... andere Bereiche mit derselben Struktur

Bereichs-y-Offsets (relativ zum (0, 0) der Gruppe):

BereichstypLayout
KopfzeileTitel (0, 0), Untertitel (0, 52)
TitelblockTitel (0, 0), Beschreibung (0, 48)
GruppenkopfBeschriftung (0, 100), Beschreibung (0, 130)
Kartenreihe3 Karten bei (0, 178), (264, 178), (528, 178)
Nächste Reihe320px unter der vorherigen: (0, 498), (0, 818), (0, 1138)

Verifiziert funktionierender Helfer zum Erstellen von Tafeln und Texten:

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;
}

Fazit

Die programmatische Arbeit mit Penpot über MCP erfordert ein Verständnis seiner besonderen Verhaltensweisen. Die "Group-First"-Regel, die Verwendung von lineHeight als Multiplikator-String, das Aufrufen von resize() vor fontSize und die korrekte Struktur von Farb- und Schattenobjekten sind entscheidend für zuverlässige Ergebnisse. Dieser Leitfaden fasst diese Erkenntnisse aus dem AstroVille-Projekt zusammen.

Verwandte Skills