Communitygithub.com

l4ur4oliveira/astroville

Este documento recopila lecciones aprendidas construyendo el Sistema de Diseño AstroVille en Penpot a través del servidor MCP. El objetivo es **evitar redescubrir estas peculiaridades de Penpot** en proyectos futuros. Muchos de estos comportamientos no están documentados y solo aparecen en tiempo de ejecución al exportar o agrupar elementos.

¿Qué es astroville?

Esta guía detalla los problemas y soluciones encontrados al construir el Sistema de Diseño AstroVille usando Penpot y su servidor MCP. Está diseñada para desarrolladores y diseñadores que trabajan con la API de Penpot (como en entornos de agente de IA) y que desean automatizar la creación de componentes de diseño, la disposición de tableros y la exportación de activos. La habilidad cubre errores críticos como el posicionamiento incorrecto de hijos después de agrupar (`Group`), el manejo de `lineHeight` en texto, problemas con `fontSize` que resulta en dimensiones 1x1, la limitación de `letterSpacing` a valores numéricos, fallos silenciosos en la exportación de PNG, la desaparición de contenido de tarjetas al agrupar, las dimensiones fijas de la raíz de página, el formato correcto de sombras con objetos `Color`, y la normalización de coordenadas de gradiente. La lección más importante es la regla de 'Agrupar primero': siempre envolver el contenido de cada sección en un `Group`, posicionar los hijos con coordenadas relativas a `(0, 0)` del grupo, y luego posicionar el grupo en sí. Esta habilidad es compatible con cualquier plataforma que use la API de Penpot, como Claude Code o Cursor, y proporciona fragmentos de código JavaScript verificados para solucionar cada problema.

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

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Documentación

Guía de Resolución de Problemas de Penpot MCP

Propósito

Este documento recopila lecciones aprendidas construyendo el Sistema de Diseño AstroVille en Penpot a través del servidor MCP. El objetivo es evitar redescubrir estas peculiaridades de Penpot en proyectos futuros. Muchos de estos comportamientos no están documentados y solo aparecen en tiempo de ejecución al exportar o agrupar elementos.

La lección más crítica: siempre envuelve el contenido de una sección en un Group, luego posiciona todo dentro del Grupo usando coordenadas simples (x, y) relativas al origen del grupo. Esto reduce drásticamente los errores de posicionamiento.


TL;DR — La Regla de "Agrupa Primero"

Para cada sección de alto nivel de un diseño de Penpot (por ejemplo, un tablero que representa "Colores", "Tipografía", "Espaciado"):

  1. Crea el tablero.
  2. Envuelve todos sus hijos en un Group (Penpot lo crea automáticamente cuando llamas a penpot.group([...children])).
  3. Posiciona el Group en la esquina superior izquierda deseada de tu área de contenido, por ejemplo penpotUtils.setParentXY(group, 64, 40).
  4. Dentro del Group, todas las posiciones de los hijos usan coordenadas simples basadas en (0, 0) relativas al origen del grupo.
  5. Posiciona siempre los hijos PRIMERO en coordenadas relativas a (0, 0), LUEGO agrúpalos, no al revés.

Este patrón es lo que hace que el posicionamiento sea predecible y que la exportación funcione correctamente.


Problema 1 — Los hijos de una tarjeta obtienen parentX / parentY negativos después de agrupar

Síntoma

Después de llamar a penpot.group([...children]) para envolver el contenido de una sección, los hijos anidados dentro de las tarjetas terminan con valores parentX / parentY negativos. Por ejemplo, un Rectángulo que debería estar en (0, 0) relativo a su tarjeta termina en (-64, -210) — dibujado fuera de los límites de la tarjeta.

Visualmente, los fondos de las tarjetas (los mosaicos de color) aparecen vacíos porque el Rectángulo de relleno real está posicionado fuera.

Causa raíz

Cuando agrupas formas que ya estaban colocadas en posiciones absolutas en un tablero padre, Penpot calcula el cuadro delimitador del grupo. El grupo mismo se posiciona en (minX, minY) de sus miembros. Luego, para cada hijo, su parentX / parentY se recalcula relativo al nuevo origen del grupo.

Si la posición absoluta de un hijo era, digamos, (64, 218) y el grupo termina en (64, 218), el parentX/parentY del hijo se convierte en (0, 0). Pero si la tarjeta que contiene al hijo estaba en (64, 218) y el grupo termina en, digamos, (64, 40), entonces todo dentro de la tarjeta obtiene un parentY negativo porque la tarjeta está "más arriba" que el origen del grupo.

Solución

Posiciona siempre a los hijos en coordenadas relativas al origen eventual del grupo (0, 0) ANTES de llamar a penpot.group(). Después de agrupar, el origen del grupo se convierte en (0, 0) para sus hijos.

Si ya agrupaste y los hijos están desalineados, la recuperación es:

  1. penpot.ungroup(group) — desagrupa de vuelta.
  2. Resta el desplazamiento del grupo previsto de la posición y (o x) de cada hijo.
  3. Vuelve a llamar penpot.group(children).
  4. penpotUtils.setParentXY(group, desiredParentX, desiredParentY).

Concretamente (el patrón que funcionó):

// 1. Guarda las posiciones absolutas originales que deseas preservar.
const originalPositions = children.map(c => ({ c, x: c.x, y: c.y }));

// 2. Desplaza cada hijo por -groupOffset (para que sean relativas al origen del grupo).
for (const child of children) {
  child.y -= GROUP_OFFSET_Y;   // p.ej. 40
}

// 3. Agrúpalos.
const group = penpot.group(children);

// 4. Posiciona el grupo mismo.
if (group) {
  penpotUtils.setParentXY(group, group.parentX, GROUP_OFFSET_Y);
}

Problema 2 — Las formas de texto con lineHeight fijo (valor en píxeles) se renderizan con una altura enorme

Síntoma

Una forma de texto con fontSize: 48 y lineHeight: 56 (un valor en píxeles) termina con bounds.height: 2689 (o un valor absurdo similar). El texto se ajusta verticalmente — el texto renderizado real aparece mucho más abajo de donde lo colocaste (a menudo en y: 1313 en términos absolutos), por lo que el texto es invisible.

Causa raíz

El lineHeight de Penpot acepta una cadena multiplicadora numérica como "1.2", pero rechaza algunos valores como "56" o cualquier valor no numérico que no sea un multiplicador. Cuando se pasa un valor no válido, Penpot usa silenciosamente una altura de línea predeterminada que puede ser enorme, causando ajuste de línea. La exportación SVG confirma el error: el atributo y del elemento <text> informa un valor enorme como 1386.44.

Además, incluso cuando un lineHeight en píxeles aparentemente válido es aceptado, el cálculo de los límites para texto de auto-width puede producir valores extraños como width: 338 pero height: 2689, porque el motor de texto ajusta dentro de los límites que el texto cree que tiene en el momento de la creación.

Solución

Usa siempre lineHeight: "1.2" (un multiplicador de cadena) en lugar de valores en píxeles. La forma de texto tendrá entonces límites correctos y predecibles:

const t = penpot.createText("ASTROGENESIS");
parent.appendChild(t);
t.resize(800, 80);                  // redimensiona ANTES de establecer fontSize
t.x = 64;
t.y = 100;
t.fontFamily = "Space Grotesk";
t.fontWeight = "700";
t.fontSize = 48;
t.lineHeight = "1.2";               // ← usa "1.2", no "56"
t.fills = [{ fillColor: "#F8FAFC", fillOpacity: 1 }];

Esto funciona tanto si fontSize se establece antes o después de anexar, siempre que resize() se llame ANTES de fontSize y lineHeight sea una cadena multiplicadora.


Problema 3 — El cambio de fontSize después de anexar da dimensiones 1×1 si no se llama a resize() primero

Síntoma

Un elemento de texto creado con penpot.createText(), anexado a un padre, y luego se le asigna fontSize: 32 termina con width: 1, height: 1 inicialmente. Después de un retraso, los límices se recuperan, pero el texto puede renderizarse en posiciones inesperadas.

Causa raíz

Penpot mide las dimensiones del texto ansiosamente usando los límites actuales. Si el texto se crea y tiene dimensiones 1×1 en el momento en que el motor de fuentes mide, lo trata como restringido a 1 píxel de ancho y ajusta agresivamente.

Solución

Siempre resize(width, height) ANTES de establecer fontSize. Esto le da al texto un cuadro delimitador conocido. El ayudante más simple:

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

Problema 4 — El espaciado entre letras con unidades em es rechazado

Síntoma

Establecer text.letterSpacing = "0.08em" falla con Value not valid: 0.08em. Code: :letterSpacing.

Solución

El letterSpacing de Penpot solo acepta valores numéricos (interpretados como 1/100 em, o similar), no cadenas con sufijo em. Usa "1" (o 0, "2", etc.) en lugar de "0.08em". Para un control más preciso usa un número pequeño como "1" o "2".


Problema 5 — penpot.generateMarkup() devuelve contenido que la exportación PNG no renderiza

Síntoma

Construyes formas, el marcado SVG las muestra correctamente (<rect>, <text>, paradas de gradiente <stop>, etc.), pero export_shape devuelve un PNG en blanco o uno al que le falta el contenido esperado. No hay mensajes de error.

Causa raíz

Dos causas principales observadas:

  1. Tableros anidados con clipContent: true y dimensiones pequeñas pueden recortar el área visible. Penpot exporta usando el cuadro delimitador, pero el contenido interno vive más allá de él.
  2. Envoltorios de grupo con contenido desplazado (Problema 1 anterior) hacen que la exportación de Penpot use textBounds obsoletos. El marcado SVG puede mostrar los elementos, pero la exportación no respeta los desplazamientos.

Solución

  • Confía en el marcado SVG (penpot.generateMarkup) como verdad fundamental cuando algo no se renderiza.
  • Después de solucionar el Problema 1 (hijos en posiciones relativas correctas), las exportaciones funcionan correctamente.
  • Verifica exportando primero tableros individuales pequeños para aislar cuál está fallando.

Problema 6 — Los hijos de un tablero de sección desaparecen después de agrupar

Síntoma

Después de agrupar, los mosaicos/tarjetas/etiquetas de la sección desaparecen de la exportación visible, pero aún existen en la estructura de datos. Los contenedores de las tarjetas aparecen (sus bordes, rellenos) pero su contenido (Rectángulo, hijos de Texto) se dibuja fuera de la tarjeta.

Causa raíz

Esto es lo mismo que el Problema 1: los hijos de las tarjetas tienen parentX / parentY negativos y se dibujan fuera de los límites de su tarjeta.

Solución

Después de agrupar, recorre las tarjetas y restablece explícitamente la posición de cada hijo anidado a su ubicación prevista relativa a su tarjeta. Por ejemplo, en una tarjeta de color con un Rectángulo de muestra en (0, 0) y tres etiquetas de texto en (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);    // Rectángulo en la parte superior izquierda de la tarjeta
  penpotUtils.setParentXY(ch[1], 16,  132);  // etiqueta
  penpotUtils.setParentXY(ch[2], 16,  154);  // nombre del token
  penpotUtils.setParentXY(ch[3], 16,  174);  // valor hexadecimal
}

Problema 7 — La raíz de la página (page.root) tiene dimensiones fijas diminutas, por lo que los tableros anchos pueden romper las exportaciones

Síntoma

El tablero raíz de la página de Penpot es siempre 0.01 × 0.01 en tamaño — sus dimensiones no se pueden redimensionar. Cualquier cosa que le agregues vive en coordenadas absolutas fuera del cuadro delimitador de la raíz.

Causa raíz

Penpot trata la raíz de la página como un marcador de posición; todo el contenido real vive en tableros creados por el usuario colocados en posiciones absolutas en la página.

Solución

Esto está bien si mantienes las cosas en tableros con nombre con sus propias coordenadas absolutas (como haces normalmente). Simplemente no intentes usar page.root.resize(). Si necesitas un contenedor maestro, coloca los tableros hijos directamente en page.root en lugar de anidarlos en un tablero maestro.


Problema 8 — Sombras: color debe ser { color: "#hex", opacity: 0..1 }, no { r, g, b, a }

Síntoma

Establecer una sombra como:

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

Se renderiza como negro con baja opacidad en lugar de violeta. La propiedad .shadows de la forma se lee de vuelta como:

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

Causa raíz

Shadow.color es un objeto Color de Penpot con la forma { color: "#hex", opacity: 0..1 } — no un objeto {r, g, b, a}. La forma incorrecta es ignorada silenciosamente y Penpot recurre a una sombra paralela negra predeterminada.

Solución

Usa la forma de objeto Color con una cadena hexadecimal y un campo opacity separado:

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

O usa penpot.shapesColors([shape]) para obtener una instancia de Color e inspeccionar sus campos antes de aplicar.


Problema 9 — Las coordenadas startX / startY / endX / endY del gradiente están normalizadas, no en píxeles

Síntoma

Establecer un gradiente con coordenadas de píxeles:

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

Se lee de vuelta con endX: ~1.0, endY: ~1.0 — Penpot normalizó silenciosamente el vector. El campo width mantiene la escala original, pero el vector de dirección ahora está en el espacio [0, 1].

Causa raíz

Penpot almacena la dirección del gradiente como un vector normalizado de 0 a 1. Los valores en píxeles se dividen por width. Si el vector resultante no estaba estrictamente en [0, 1] (por ejemplo, endX: 320 / 320 = 1.0 exactamente, o endY: 160 / 320 = 0.5), Penpot parece recortar y redondear.

En AstroVille, el usuario arrastró el segundo punto de color en la interfaz de usuario de Penpot para colocarlo en la esquina inferior derecha del rectángulo. Los valores posteriores fueron endX: ~1.0, endY: ~1.0. El posicionamiento de gradiente perfecto en píxeles no se expone como una propiedad escribible: la interfaz de usuario es la fuente de verdad para las posiciones de los controles del gradiente.

Solución

  • Para un gradiente horizontal (izquierda → derecha): endX: 1, endY: 0
  • Para diagonal (superior izquierda → inferior derecha): endX: 1, endY: 1
  • Para vertical (superior → inferior): endX: 0, endY: 1
  • Para posiciones de parada precisas del gradiente a lo largo de la dirección, usa offset (0–1) por parada.
  • Si las posiciones exactas de los controles del gradiente importan, hazlas en la interfaz de usuario de Penpot y lee de vuelta para confirmar.

Patrón Recomendado: La Plantilla de Página "Foundations" de AstroVille

La estructura que funcionó para la página "00 — Design System" de AstroVille:

page.root
├── "00 — Design System / Header" (tablero, 0, 0, 1440×120)
│   └── Group en (64, 40)
│       ├── "Design System"  texto en (0, 0)   → se renderiza en (64, 40)
│       └── "AstroVille — Foundations" texto en (0, 52) → (64, 92)
│
├── "01 — Colors" (tablero, 0, 200, 1440×1460)
│   └── Group en (64, 40)
│       ├── Textos de Título + Descripción en (0, 0), (0, 48)
│       ├── Etiqueta "Background" en (0, 100)
│       ├── 3 Tarjetas de Color en una fila en (0, 178), (264, 178), (528, 178)
│       │   Cada tarjeta tiene sus propios hijos en (0, 0), (16, 132), (16, 154), (16, 174)
│       └── ... 3 grupos más (Brand, Feedback, Text) en (0, 498), (0, 818), (0, 1138)
│
└── ... otras secciones con la misma estructura

Desplazamientos y de sección (relativos al (0, 0) del grupo):

Tipo de secciónDiseño
EncabezadoTítulo (0, 0), Subtítulo (0, 52)
Bloque de títuloTítulo (0, 0), Descripción (0, 48)
Encabezado de grupoEtiqueta (0, 100), Descripción (0, 130)
Fila de tarjetas3 tarjetas en (0, 178), (264, 178), (528, 178)
Siguiente fila320px debajo de la anterior: (0, 498), (0, 818), (0, 1138)

Ayudante verificado para crear tableros y textos:

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

Resumen de Causas Raíz

ProblemaCausa raízSolución
1. Hijos con parentX/Y negativosEl grupo cambia el origen, los hijos mantienen posiciones absolutas antiguasPosiciona los hijos en (0,0) relativos antes de agrupar
2. Altura de texto enormelineHeight en píxeles rechazado, se usa predeterminado enormeUsa "1.2" (cadena multiplicadora)
3. Texto 1×1fontSize evaluado antes de que el texto tenga límitesLlama a resize() antes de fontSize
4. El espaciado de letras fallaem no es un formato válido para letterSpacingUsa números como "1"
5. PNG en blancoRecorte del tablero anidado o desplazamiento del grupoConfía en SVG; corrige el Problema 1; exporta tableros pequeños primero
6. Las tarjetas pierden contenidoHijos con parentX/Y negativos (mismo que el Problema 1)Restablece explícitamente las posiciones de los hijos de las tarjetas
7. Raíz de página diminutapage.root es fijo 0.01×0.01Usa tableros con nombre; no redimensiones la raíz
8. Sombra negraFormato de color incorrecto (r,g,b,a)Usa { color: "#hex", opacity: 0..1 }
9. Gradiente incorrectoCoordenadas en píxeles normalizadas a 0–1Usa endX: 1, endY: 1 para guardar

Notas de la Traducción

Esta guía fue traducida para ayudar a los desarrolladores que trabajan con Penpot en entornos de habla hispana. Los nombres de funciones (como penpot.group, penpotUtils.setParentXY) y las variables de código se han dejado en su forma original en inglés para mantener la compatibilidad con la API de Penpot.

Skills relacionados