Blog von Obi Madu
Zurück zu allen Artikeln
DesignFrontend

Penpot und Design to Code

Eine Einführung in Penpot als Designtool, und wie sein offenes Dateiformat, das Token-Modell und die Plugin-API Design-to-Code zu einem echten Workflow machen statt zu einem Handoff.

Penpot und Design to Code

Die meisten Designtools behandeln Code als nachgelagertes Problem. Du entwirfst in einer proprietären Datei, übergibst sie und hoffst, dass der Entwickler die Spezifikation richtig liest. Penpot baut auf der gegenteiligen Annahme auf. Das Dateiformat ist offen, das Token-Modell folgt einem W3C-Standard, sodass es sich ohne Übersetzungsschicht auf Code-Variablen abbilden lässt, und die Plugin-API ermöglicht es, das Design programmatisch auszulesen.

Ich habe eine Session damit verbracht, Penpot über die Plugin-API zu steuern, um ein Design-System zu konsolidieren und ein Guide-Board neu aufzubauen. Der Rest dieses Posts ist, was ich darüber gelernt habe, wie Penpot für diesen Workflow gebaut ist, und welche Reibung dabei aufgetaucht ist.

Das Dateiformat ist offen

Design-to-Code beginnt mit der Datei. Wenn dein Design in einem geschlossenen Binär-Blob liegt, bist du auf den Export angewiesen, den dir der Hersteller gibt.

Eine .penpot-Datei ist ein ZIP-Archiv, das lesbare JSON-Metadaten neben binären Assets wie Bildern enthält. Kein proprietärer Blob. Das v3-Format enthält ein Manifest, Dateimetadaten, Seiten, Shapes, Bibliotheks-Assets, Storage-Objekte, Plugin-Daten und eingebettete Bilder.

Die Geschichte ist es wert zu kennen. Das v1-.penpot-Format war ein eigener Binär-Blob. Penpot bot zusätzlich einen separaten .zip-Export an, der aus SVG und JSON bestand, offen aber ineffizient. Das aktuelle v3-.penpot-Format ist das Beste von beiden: ein ZIP-Container mit JSON-Metadaten, der sowohl einsehbar als auch effizient ist. Deine Designdaten gehören dir, in Formaten, die du lesen kannst.

Das ist das Fundament, auf dem alles andere aufbaut. Du kannst deine Daten ohne Erlaubnis herausbekommen.

Tokens sind der Vertrag

Tokens sind die Brücke zwischen Design und Code. Ein Token in Penpot ist ein benannter Wert: eine Farbe, ein Typografie-Stil, ein Border-Radius, ein Abstandswert. Wenn du einen Token auf einen Shape anwendest, merkt sich der Shape sowohl den Wert als auch die Bindung.

Wenn du einen Shape über die Plugin-API ausliest, bekommst du beides:

shape.tokens.fill    // "canvas"
shape.fills          // [{ fillColor: "#000000", fillOpacity: 1 }]

shape.tokens.fill ist der Token-Name als String. shape.fills ist der aufgelöste visuelle Wert. Die Bindung und der Wert, Seite an Seite.

Das ist der Vertrag. Wenn der Penpot-Token primary auf die CSS-Variable --primary abgebildet wird, dann wird ein Shape mit tokens.fill === "primary" direkt auf background: var(--primary) abgebildet. Der Token-Name in der Designdatei wird zum Token-Namen in der Codebase. Keine Übersetzungsschicht, kein Raten, welcher Hex-Wert zu welcher Rolle gehört.

Das funktioniert, weil Penpots Tokens dem W3C Design Tokens Format Module folgen. Penpot ist das erste Designtool, das diesen Standard nativ integriert, entwickelt in Zusammenarbeit mit Tokens Studio. Die Token-Namen sind nicht Penpot-spezifisch. Sie liegen in einem Format, das jedes Tool lesen kann, das den Standard unterstützt.

Tokens leben in Sets

Die API-Form spiegelt das Modell wider. TokenCatalog lässt dich keine Tokens direkt hinzufügen. Es stellt nur Set-Operationen bereit:

interface TokenCatalog {
  addSet(...)
  getSetById(...)
  sets
  themes
}

interface TokenSet {
  tokens: Token[]
  addToken({ type, name, value }): Token
}

Tokens werden auf einem TokenSet erstellt, nicht auf dem Katalog. Die Doku sagt es klar: "Tokens are contained in sets." Es gibt keinen freischwebenden Token.

Eine Sache, die man kennen sollte: Penpot liest Punkte in Token-Namen als Pfade. Ich habe Ordner-Präfixe in Token-Namen gesteckt, wie colors.primary, und einen zusätzlichen colors-Ordner unter dem Colors-Token-Typ bekommen. Flache Namen haben es behoben.

Themes sind, wie Light und Dark funktionieren

Wer von Figma kommt, würde erwarten, dass Light und Dark Modi einer Collection sind. Penpot teilt das anders auf. Light und Dark sind Themes, und ein Theme ist eine Kombination von Sets.

Du legst deine Basis-Tokens in einem Set ab: Markenfarben, Abstände, Radien. Du legst light-spezifische Farben in ein Light-Set und dark-spezifische Farben in ein Dark-Set. Dann kombinierst du sie zu Themes. Das Light-Theme aktiviert die Base- und Light-Sets. Das Dark-Theme aktiviert die Base- und Dark-Sets. Theme wechseln und das ganze Board tauscht.

Die API spiegelt das wider. TokenCatalog stellt sets und themes als Geschwister bereit. Du baust Sets, dann konfigurierst du, welche Sets jedes Theme aktiviert. Die themes-Eigenschaft stand oben im Snippet drin, und ich bin beim ersten Durchlesen drübergegangen.

Wie sich das zu Figma vergleicht

Wenn du von Figma kommst, ist das Mapping: Figma-Collections entsprechen Penpot-Sets, und Figma-Modes entsprechen Penpot-Themes. Eine Figma-Collection enthält verwandte Variablen, und ein Mode speichert parallele Werte für verschiedene Kontexte. In Penpot enthält ein Set verwandte Tokens, und ein Theme kombiniert Sets für einen Kontext.

Der Unterschied ist das Format. Figmas Variablen nutzen ein proprietäres Format. Um Tokens aus Figma im W3C-Standardformat herauszubekommen, braucht man ein Plugin wie Tokens Studio. Penpot exportiert das W3C-Format nativ. Beide implementieren dasselbe Konzept. Einer spricht den Standard, einer spricht seinen eigenen Dialekt.

Namenskollisionen gelten pro Set, nicht pro Typ

Ich wollte ein Base-Set mit allem: Farben, Typografie, Radien, Abstände. Die natürliche Benennung für Radien und Abstände war xs, sm, md, lg, xl.

Penpot hat das abgelehnt:

A token already exists at the path: xs or at a prefix thereof.

Die Eindeutigkeitsregel gilt pro Set, über alle Typen hinweg, nicht pro Typ. xs kann also nicht gleichzeitig ein borderRadius-Token und ein spacing-Token im selben Set sein.

Die Lösung waren Präfixe: radius-xs, radius-sm, space-xs, space-sm. Farben und Typografie behielten flache Namen, weil sie keine Namen mit irgendetwas anderem teilen. Die Kollision tauchte nur dort auf, wo zwei Skalen dieselben kurzen Namen wollten. Gut zu wissen, bevor man sein Token-Namensschema entwirft.

Wo Tokens gelten (und wo nicht)

Nicht jeder Token gilt für jede Eigenschaft. Die API erzwingt das.

Ich wollte jeden Abstands-Token als kleinen Balken zeigen, dessen Länge dem Token-Wert entsprach, und den Token auf diesen Balken anwenden. Die API hat das abgelehnt:

Field message is invalid.

Ich habe marginLeft, paddingLeft, paddingTop probiert. Alle mit demselben Fehler abgelehnt. Die dokumentierten Ziele für Spacing-Tokens sind Flex-Layout-Eigenschaften: rowGap, columnGap, die Padding-Props und die Margin-Props. In der Praxis, auf einem Board mit einem Flex-Layout, das über addFlexLayout() hinzugefügt wurde, akzeptierten nur rowGap und columnGap den Token. Padding- und Margin-Eigenschaften warfen weiterhin den Validierungsfehler.

Spacing-Tokens sind also keine universellen Dimensionen. Sie gelten für Layout-Gaps. Um einen Spacing-Token visuell darzustellen, habe ich ein kleines Board mit einem Flex-Layout gebaut und sein columnGap auf den Token gesetzt. Die zwei Rechtecke im Board, getrennt durch diese Gap, sind die sichtbare Darstellung des Abstandswerts.

Farb-, Typografie- und Border-Radius-Tokens gelten sauber für Fills, Text und Ecken. Das entspricht, wie man sie im Code verwenden würde: eine Farbe geht auf einen Fill, ein Typografie-Stil geht auf Text, ein Radius geht auf eine Ecke. Abstände gehen auf die Gaps zwischen Elementen, was in Penpot Flex-Layout-Gaps bedeutet.

Komponenten werden per Spec gemappt, nicht per Magie

Eine Penpot-Komponente ist ein Design-Objekt, keine Code-Datei. Als ich Komponenten über die API manipuliert habe, habe ich Objekte innerhalb der Penpot-Designdatei verändert: Rechtecke, Text, Gruppen, Bibliotheks-Komponenten. Nicht React, nicht CSS, nicht Dateien im Repo.

Eine Penpot-Komponente ist ein wiederverwendbares Design-Objekt mit einer Main-Instanz und Kopien. Die Main-Komponente bearbeiten und die Instanzen aktualisieren. Sie hat visuelle Eigenschaften: Fills, Strokes, Typografie, Layout, Tokens. Sie lebt nicht im Repository.

Um eine Penpot-Komponente in Code zu übersetzen, mappt man sie von Hand. Eine button-primary-Designkomponente wird zu einem React-Button mit CSS, das dieselben Token-Namen verwendet. Ein Penpot-Screen wird zu einem App-View. Die Token-Namen sind die Brücke. Das Design-Objekt und die Code-Komponente sind über Benennung und Spec verbunden, nicht über irgendeine automatische Konvertierung.

Das ist die ehrliche Version von Design-to-Code. Die Tools geben dir ein gemeinsames Vokabular (Tokens) und ein offenes Dateiformat. Sie schreiben dir keine Komponenten. Das Mapping ist immer noch eine Person, die das Design ansieht und den Code schreibt, aber der Vertrag ist explizit statt geraten. Ein Format wie DESIGN.md kann diese Token-Namen ins Repo tragen, sodass ein AI-Agent oder ein Entwickler dieselbe Quelle liest, die das Designtool exportiert.

Die API lässt dich alles inspizieren

Design-to-Code-Tooling muss das Design programmatisch lesen können. Die Plugin-API ist dafür gebaut.

History hat zwei Schichten, was einen Eindruck davon vermittelt, wie die API organisiert ist. Die Undo-History ist die In-Session-Bearbeitungs-Zeitleiste. Die Plugin-API kann Edits in einen Undo-Schritt gruppieren mit penpot.history.undoBlockBegin() und penpot.history.undoBlockFinish(block). Ein Batch von Edits in einen Block packen und ein Undo macht alles rückgängig.

Dateiversionen sind gespeicherte Checkpoints. Die API stellt penpot.currentFile.saveVersion("label") und penpot.currentFile.findVersions() bereit. Eine gespeicherte Version ist ein Punkt, zu dem man später, über Sessions hinweg, zurückkehren kann. Ich habe den konsolidierten Zustand als Version 1 über saveVersion gespeichert. Der Rückgabewert traf auf einen Serialisierungs-Bug im Wrapper, aber findVersions() bestätigte, dass die Version mit dem richtigen Label existierte. Das Speichern hat funktioniert, auch wenn der Rückgabewert sich nicht sauber serialisieren ließ.

Der Punkt ist, dass die Designdatei abfragbar ist. Man kann den Shape-Baum durchlaufen, Tokens und Fills lesen, Sets auflisten, Versionen speichern und nachverfolgen, was sich geändert hat. Das ist, was eine Design-to-Code-Pipeline braucht, um sich zu speisen.

Export und die praktische Realität

Export ist, wo ich die meiste Reibung hatte, und die Logs sind es wert, gelesen zu werden, weil sie zeigen, wie der Exporter funktioniert.

Das Export-Tool schlug immer wieder fehl. Erst mit http error, dann mit Timeouts nach 30 Sekunden. Ein 100x100-Test-Board mit einem roten Fill timete auch aus, also war der Board-Inhalt nicht das Problem.

Die Penpot-Exporter-Logs zeigten warum:

ERR [app.handlers.export-shapes] hint="unexpected error on single export"
page.goto: net::ERR_CONNECTION_REFUSED at http://penpot-frontend/render.html

Der Exporter ist ein Headless-Browser, der zu einer Render-URL navigiert und einen Screenshot macht. Er versuchte, http://penpot-frontend/render.html zu erreichen, und bekam Connection-Refused. Das ist ein Deployment-Problem. Der Exporter-Container konnte den Hostnamen des Frontend-Services nicht auflösen oder nicht erreichen.

Nach dem Fix änderten sich die Logs:

INF [app.renderer.bitmap] uri="https://penpot.example.com/render.html?..."

Der Exporter erreichte jetzt das Frontend über die öffentliche URL und renderte. Die exec:handle:end-Zeile zeigte, dass der Browser seine Arbeit beendet hatte. Das MCP-Tool, über das ich aufgerufen habe, timete weiterhin nach 30 Sekunden aus. Das Rendern war serverseitig abgeschlossen. Der Wrapper, der mir das Ergebnis zurückgab, war der Flaschenhals.

Das Fazit: Wenn ein Penpot-Export fehlschlägt, lies die Exporter-Logs. Der Fehler nennt die URL, die der Exporter zu erreichen versuchte. ERR_CONNECTION_REFUSED an einem internen Hostnamen bedeutet, dass der Exporter das Frontend nicht finden kann. Ein Timeout ohne Fehler in den Logs bedeutet meistens, dass das Rendern selbst langsam ist, oder dass der Tool-Wrapper die Grenze ist, nicht Penpot.

Was Penpot für Design to Code richtig macht

Penpot ist keine Magie. Du schreibst immer noch den Code. Was Penpot dir gibt, ist ein Designtool, das auf Annahmen gebaut ist, die Design-to-Code möglich machen:

  • Ein offenes Dateiformat, das du inspizieren und extrahieren kannst.
  • Ein Token-Modell basierend auf dem W3C-Standard, bei dem die Bindung und der Wert beide lesbar sind, und Themes Light und Dark handhaben, ohne Dateien zu duplizieren.
  • Eine Plugin-API, die es Tooling ermöglicht, das Design zu durchlaufen und die Verträge zu lesen.
  • Komponenten, die per Benennung und Spec mappen, ohne Lock-in, wie du sie implementierst.