Communitygithub.com

l4ur4oliveira/astroville

Este documento captura lições valiosas obtidas durante a construção do Design System AstroVille no Penpot via servidor MCP. O objetivo é **evitar redescobrir essas peculiaridades do Penpot** em projetos futuros. Muitos desses comportamentos são indocumentados e só aparecem em tempo de execução quando elementos são exportados ou agrupados.

O que é astroville?

Este guia especializado documenta soluções para problemas encontrados ao usar o servidor MCP do Penpot para criar um Design System, especificamente o AstroVille. Ele aborda questões críticas como posicionamento incorreto de elementos filhos após agrupamento (que causa valores negativos de `parentX`/`parentY` e conteúdo 'fora da tela'), configuração de `lineHeight` como multiplicador de string para evitar alturas absurdas de texto, a ordem correta de operações para criar textos (redimensionar antes de definir tamanho da fonte para evitar dimensões de 1x1), e o uso da estrutura `Color` com string hexadecimal para sombras em vez de objetos RGBA. O guia fornece um padrão 'Group-First' que envolve sempre agrupar seções, posicionar os filhos com coordenadas relativas ao grupo e, em seguida, posicionar o grupo em si, resultando em uma exportação de design previsível e sem erros. Ele é útil para desenvolvedores que constroem sistemas de design complexos no Penpot programaticamente, especialmente aqueles que desejam evitar armadilhas comuns e garantir que suas exportações de PNG e SVG correspondam ao layout previsto.

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

Perguntar na sua IA favorita

Abre um novo chat com esta habilidade de agente já pré-carregada.

Documentação

Guia de Resolução de Problemas do MCP do Penpot

Propósito

Este documento captura lições valiosas obtidas durante a construção do Design System AstroVille no Penpot via servidor MCP. O objetivo é evitar redescobrir essas peculiaridades do Penpot em projetos futuros. Muitos desses comportamentos são indocumentados e só aparecem em tempo de execução quando elementos são exportados ou agrupados.

A lição mais crítica: sempre envolva o conteúdo da seção em um Grupo e, em seguida, posicione tudo dentro do Grupo usando coordenadas simples (x, y) relativas à origem do grupo. Isso reduz drasticamente os erros de posicionamento.


TL;DR — A Regra "Primeiro o Grupo"

Para cada seção de nível superior de um design no Penpot (por exemplo, uma prancha representando "Cores", "Tipografia", "Espaçamento"):

  1. Crie a prancha.
  2. Envolva todos os seus filhos em um Grupo (o Penpot cria isso automaticamente quando você chama penpot.group([...children])).
  3. Posicione o próprio Grupo no canto superior esquerdo desejado da sua área de conteúdo, por exemplo, penpotUtils.setParentXY(group, 64, 40).
  4. Dentro do Grupo, todas as posições dos filhos usam coordenadas simples baseadas em (0, 0) relativas à origem do grupo.
  5. Sempre posicione os filhos PRIMEIRO com coordenadas relativas a (0, 0), DEPOIS os agrupe, e não o contrário.

Esse padrão é o que torna o posicionamento previsível e a exportação correta.


Problema 1 — Filhos de um Card obtêm parentX / parentY negativos após o agrupamento

Sintoma

Após chamar penpot.group([...children]) para envolver o conteúdo de uma seção, os filhos aninhados dentro dos cards terminam com valores negativos de parentX / parentY. Por exemplo, um Retângulo que deveria estar em (0, 0) relativo ao seu card termina em (-64, -210) — desenhado fora dos limites do card.

Visualmente, os fundos do card (as amostras coloridas) aparecem vazios porque o Retângulo de preenchimento real está posicionado fora.

Causa raiz

Quando você agrupa formas que já foram dispostas em posições absolutas em uma prancha pai, o Penpot calcula a caixa delimitadora do grupo. O próprio grupo é posicionado em (minX, minY) de seus membros. Então, para cada filho, seu parentX / parentY é recalculado em relação à nova origem do grupo.

Se a posição absoluta de qualquer filho era, digamos, (64, 218) e o grupo termina em (64, 218), o parentX/parentY do filho se torna (0, 0). Mas se o card que contém o filho estava em (64, 218) e o grupo termina em, digamos, (64, 40), então tudo dentro do card obtém um parentY negativo porque o card está "mais alto" do que a origem do grupo.

Correção

Sempre posicione os filhos em coordenadas relativas à origem eventual do grupo (0, 0) ANTES de chamar penpot.group(). Após o agrupamento, a origem do grupo se torna (0, 0) para seus filhos.

Se você já agrupou e os filhos estão desalinhados, a recuperação é:

  1. penpot.ungroup(group) — desagrupe de volta.
  2. Subtraia o deslocamento do grupo pretendido da posição y (ou x) de cada filho.
  3. penpot.group(children) novamente.
  4. penpotUtils.setParentXY(group, desiredParentX, desiredParentY).

Concretamente (o padrão que funcionou):

// 1. Salve as posições absolutas originais que você deseja preservar.
const originalPositions = children.map(c => ({ c, x: c.x, y: c.y }));

// 2. Desloque cada filho em -groupOffset (para que fiquem relativos à origem do grupo).
for (const child of children) {
  child.y -= GROUP_OFFSET_Y;   // por exemplo, 40
}

// 3. Agrupe-os.
const group = penpot.group(children);

// 4. Posicione o próprio grupo.
if (group) {
  penpotUtils.setParentXY(group, group.parentX, GROUP_OFFSET_Y);
}

Problema 2 — Formas de texto com lineHeight fixo (valor em pixel) são renderizadas com altura enorme

Sintoma

Uma forma de texto com fontSize: 48 e lineHeight: 56 (um valor em pixel) termina com bounds.height: 2689 (ou outro valor absurdo semelhante). O texto quebra verticalmente — o texto renderizado real aparece muito abaixo de onde você o colocou (frequentemente em y: 1313 em termos absolutos), então o texto fica invisível.

Causa raiz

O lineHeight do Penpot aceita uma string multiplicadora numérica como "1.2", mas rejeita alguns valores como "56" ou qualquer valor não numérico e não multiplicador. Quando um valor inválido é passado, o Penpot usa silenciosamente uma altura de linha padrão que pode ser enorme, causando quebra de linha. A exportação SVG confirma o bug — o atributo y do elemento <text> relata um valor enorme como 1386.44.

Além disso, mesmo quando um lineHeight em pixel aparentemente válido é aceito, o cálculo dos limites para texto de auto-width pode produzir valores estranhos como width: 338 mas height: 2689, porque o mecanismo de texto quebra dentro dos limites que o texto acha que tem no momento da criação.

Correção

Sempre use lineHeight: "1.2" (um multiplicador de string) em vez de valores em pixel. A forma de Texto terá então limites corretos e previsíveis:

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

Isso funciona quer o fontSize seja definido antes ou depois de anexar, desde que resize() seja chamado ANTES do fontSize e lineHeight seja uma string multiplicadora.


Problema 3 — A alteração de fontSize após anexar fornece dimensões 1×1 se resize() não for chamado primeiro

Sintoma

Um elemento de texto criado com penpot.createText(), anexado a um pai, e então recebendo fontSize: 32 termina com width: 1, height: 1 inicialmente. Após algum atraso, os limites se recuperam, mas o texto pode ser renderizado em posições inesperadas.

Causa raiz

O Penpot mede as dimensões do texto ansiosamente usando os limites atuais. Se o texto é criado e tem dimensões 1×1 no momento em que o mecanismo de fonte mede, ele trata o texto como restrito a 1 pixel de largura e quebra agressivamente.

Correção

Sempre resize(largura, altura) ANTES de definir fontSize. Isso dá ao texto uma caixa delimitadora conhecida. O auxiliar mais simples:

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

Problema 4 — Espaçamento entre letras com unidades em é rejeitado

Sintoma

Definir text.letterSpacing = "0.08em" falha com Value not valid: 0.08em. Code: :letterSpacing.

Correção

O letterSpacing do Penpot aceita apenas valores numéricos (interpretados como 1/100 em, ou similar), não strings com sufixo em. Use "1" (ou 0, "2", etc.) em vez de "0.08em". Para controle mais preciso, use um número pequeno como "1" ou "2".


Problema 5 — penpot.generateMarkup() retorna conteúdo que a exportação PNG não renderiza

Sintoma

Você constrói formas, a marcação SVG as mostra corretamente (<rect>, <text>, paradas de gradiente <stop>, etc.), mas export_shape retorna um PNG em branco ou um que está faltando o conteúdo esperado. Não há mensagens de erro.

Causa raiz

Duas causas principais observadas:

  1. Pranchas aninhadas com clipContent: true e dimensões pequenas podem cortar a área visível. O Penpot exporta usando a caixa delimitadora, mas o conteúdo interno vive além dela.
  2. Wrappers de grupo com conteúdo deslocado (Problema 1 acima) fazem com que a exportação do Penpot use textBounds obsoletos. A marcação SVG pode mostrar os elementos, mas a exportação não honra os deslocamentos.

Correção

  • Confie na marcação SVG (penpot.generateMarkup) como verdade absoluta quando algo não está renderizando.
  • Após corrigir o Problema 1 (filhos nas posições relativas corretas), as exportações funcionam corretamente.
  • Verifique exportando pequenas pranchas individuais primeiro para isolar qual está falhando.

Problema 6 — Os filhos de uma prancha de seção desaparecem após o agrupamento

Sintoma

Após o agrupamento, as amostras/cards/rótulos da seção desaparecem da exportação visível, mas ainda existem na estrutura de dados. Os containers dos cards aparecem (suas bordas, preenchimentos), mas seu conteúdo (filhos Retângulo, Texto) é desenhado fora do card.

Causa raiz

Este é o mesmo que o Problema 1 — os filhos dos cards têm parentX / parentY negativos e são desenhados fora dos limites de seu card.

Correção

Após o agrupamento, percorra os cards e redefina explicitamente a posição de cada filho aninhado para sua localização pretendida relativa ao seu card. Por exemplo, em um card de cor com uma amostra Retângulo em (0, 0) e três rótulos de Texto em (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);    // Retângulo no canto superior esquerdo do card
  penpotUtils.setParentXY(ch[1], 16,  132);  // rótulo
  penpotUtils.setParentXY(ch[2], 16,  154);  // nome do token
  penpotUtils.setParentXY(ch[3], 16,  174);  // valor hex
}

Problema 7 — A raiz da página (page.root) tem dimensões minúsculas fixas, então pranchas largas podem quebrar exportações

Sintoma

A prancha raiz da página do Penpot é sempre 0.01 × 0.01 de tamanho — suas dimensões não podem ser redimensionadas. Qualquer coisa que você adicionar a ela vive em coordenadas absolutas fora da caixa delimitadora da raiz.

Causa raiz

O Penpot trata a raiz da página como um placeholder; todo conteúdo real vive em pranchas criadas pelo usuário colocadas em posições absolutas na página.

Correção

Isso é normal se você mantiver as coisas em pranchas nomeadas com suas próprias coordenadas absolutas (como você faz normalmente). Apenas não tente usar page.root.resize(). Se você precisar de um contêiner mestre, coloque as pranchas filhas diretamente em page.root em vez de aninhá-las em uma prancha mestre.


Problema 8 — Sombras: color deve ser { color: "#hex", opacity: 0..1 }, não { r, g, b, a }

Sintoma

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

Renderiza como preto com baixa opacidade em vez de violeta. O .shadows da forma é lido de volta como:

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

Causa raiz

Shadow.color é um objeto Color do Penpot moldado como { color: "#hex", opacity: 0..1 } — não um objeto {r, g, b, a}. A forma errada é silenciosamente ignorada e o Penpot recorre a uma sombra projetada preta padrão.

Correção

Use a forma do objeto Color com uma string hexadecimal e um campo opacity separado:

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

Ou use penpot.shapesColors([shape]) para obter uma instância de Color e inspecionar seus campos antes de aplicar.


Problema 9 — startX / startY / endX / endY do gradiente são normalizados, não pixels

Sintoma

Definir um gradiente com coordenadas de pixel:

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

É lido de volta com endX: ~1.0, endY: ~1.0 — o Penpot normalizou silenciosamente o vetor. O campo width mantém a escala original, mas o vetor de direção agora está no espaço [0, 1].

Causa raiz

O Penpot armazena a direção do gradiente como um vetor normalizado 0–1. Valores de pixel são divididos por width. Se o vetor resultante não estivesse estritamente em [0, 1] (por exemplo, endX: 320 / 320 = 1.0 exatamente, ou endY: 160 / 320 = 0.5), o Penpot parece recortar e arredondar.

No AstroVille, o usuário arrastou o segundo ponto de cor na interface do Penpot para colocá-lo no canto inferior direito do retângulo. Os valores resultantes foram endX: ~1.0, endY: ~1.0. O posicionamento perfeito do gradiente em pixels não é exposto como uma propriedade gravável — a interface do usuário é a fonte da verdade para as posições das alças do gradiente.

Correção

  • Para um gradiente horizontal (esquerda → direita): endX: 1, endY: 0
  • Para diagonal (canto superior esquerdo → canto inferior direito): endX: 1, endY: 1
  • Para vertical (cima → baixo): endX: 0, endY: 1
  • Para posições precisas de paradas do gradiente ao longo da direção, use offset (0–1) por parada.
  • Se as posições exatas das alças do gradiente forem importantes, faça-as na interface do Penpot e leia de volta para confirmar.

Padrão Recomendado: O Modelo de Página "Fundações" do AstroVille

A estrutura que funcionou para a página "00 — Design System" do AstroVille:

page.root
├── "00 — Design System / Header" (prancha, 0, 0, 1440×120)
│   └── Grupo em (64, 40)
│       ├── Texto "Design System" em (0, 0)   → renderiza em (64, 40)
│       └── Texto "AstroVille — Foundations" em (0, 52) → (64, 92)
│
├── "01 — Colors" (prancha, 0, 200, 1440×1460)
│   └── Grupo em (64, 40)
│       ├── Textos Título + Descrição em (0, 0), (0, 48)
│       ├── Rótulo "Background" em (0, 100)
│       ├── 3 Cards de Cor em uma linha em (0, 178), (264, 178), (528, 178)
│       │   Cada card tem seus próprios filhos em (0, 0), (16, 132), (16, 154), (16, 174)
│       └── ... mais 3 grupos (Brand, Feedback, Text) em (0, 498), (0, 818), (0, 1138)
│
└── ... outras seções com a mesma estrutura

Deslocamentos y da seção (relativos ao (0, 0) do grupo):

Tipo de seçãoLayout
CabeçalhoTítulo (0, 0), Subtítulo (0, 52)
Bloco de títuloTítulo (0, 0), Descrição (0, 48)
Cabeçalho de grupoRótulo (0, 100), Descrição (0, 130)
Linha de card3 cards em (0, 178), (264, 178), (528, 178)
Próxima linha320px abaixo da anterior: (0, 498), (0, 818), (0, 1138)

Auxiliar verificado-funcional para criar pranchas e 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;
}

Habilidades Relacionadas