The 41 existing Penpot seeds contain hand-fabricated penpot_api_info and high_level_overview tool results that assert facts the server never said, which is how the model learned an API that does not exist. This adds four schema files that make the seed corpus mechanically verifiable against the real server instead. - penpot_api_docs.md: 34 verbatim captures of high_level_overview and penpot_api_info, each headed by the exact request that produced it. Every penpot_api_info tool result in a seed must be a subset of lines of this file, in original order. Records three places where the served docs contradict the runtime (addFlexLayout/addGridLayout copy-paste in the Grid section, flex.appendChild for grid children, withChildren vs includeChildren), plus the createText() example that is the direct cause of the production failure. - penpot_system_prompt.md: the server's instructions block verbatim. Goes as a system message into ~30% of the new seeds; it is the countermeasure to the "don't pick your own colours" rule that produces the grey boxes. - penpot_errors.md: the real error strings, including a section on silent failures that raise nothing at all and are why the read-back invariant exists. - PENPOT_API_VERIFIED.md: the allow-list. No seed may reference a member absent from it. Documents the four root causes (findShapeById arity 1, no shape.layout, createText() returning null, the #B1B2B5 default fill), the twelve anti-grey-box invariants, and the forbidden-pattern list the linter checks. Live re-verification of the error strings is still pending: the Penpot plugin is not currently connected, so it is deferred to the gate 5 baseline step, which needs the live connection anyway.
21 KiB
PENPOT_API_VERIFIED — allow-list de la API de Penpot
Este archivo es el contrato. Ningún seed de data/raw/seeds/penpot.jsonl puede usar un miembro de
la API de Penpot que no esté listado acá. scripts/07_lint_penpot_code.py lo verifica con hard-fail
sobre cada payload de code.
Si un seed necesita un miembro ausente de esta lista: primero se verifica contra penpot_api_info,
se agrega la captura verbatim a penpot_api_docs.md, y después se agrega acá con su evidencia.
Nunca al revés.
Dos fuentes de verdad, y no coinciden. La columna "documentado" es lo que dice el servidor
(penpot_api_docs.md); la columna "verificado" es lo que hace el runtime. Cuando difieren, manda el
runtime, y la discrepancia se anota explícitamente porque es material de entrenamiento: los seeds del
grupo C enseñan justamente a no confiar ciegamente en la documentación.
Procedencia de las verificaciones en vivo: sondeo de la sesión madre contra el archivo real del
usuario el 2026-07-30, creando shapes de prueba que se borraron al terminar (el archivo quedó en su
estado original: 0 shapes, 0 colores/tipografías/componentes en la biblioteca local, verificado con
penpotUtils.shapeStructure(penpot.root, 2)). Registrado en PLAN.md § "La API verificada" y en la
página de Docmost de la Fase 6.
0. Herramientas MCP disponibles — lista cerrada
Estas cuatro y ninguna más. Copiadas de data/schemas/penpot.json, que es byte a byte lo que el
modelo ve en inferencia.
| Herramienta | Parámetros | Requeridos |
|---|---|---|
execute_code |
code |
code |
export_shape |
shapeId, format (svg|png, default png), mode (shape|fill, default shape) |
shapeId |
high_level_overview |
(ninguno) | — |
penpot_api_info |
type, member |
type |
Prohibido y sondeado por el holdout:
import_image— no existe en este deployment. El overview la menciona en prosa; la lista de herramientas es autoritativa. Ruta correcta:await penpot.uploadMediaUrl(name, url).export_shapeconfilePath— el parámetro está eliminado del schema en este deployment (verPENPOT_DEPLOYMENT_NOTES.md). Inventarlo es invención de parámetros.export_shapeconscale—scalees propiedad del método de pluginshape.export(config)(penpot_api_docs.mdCaptura 29), no de la herramienta MCP. No hay forma de pedir 2x.- Llamar
high_level_overviewdos veces — su propia descripción lo prohíbe (If you have already read the 'Penpot High-Level Overview', you must not call this tool.). Por eso aparece en exactamente 2 de los 96 seeds.
1. El objeto penpot — miembros permitidos
Lista cerrada, de penpot_api_docs.md Captura 2.
Creación: createBoard(), createRectangle(), createEllipse(), createPath(),
createText(text), createBoolean(boolType, shapes), createShapeFromSvg(svgString),
createShapeFromSvgWithImages(svgString), createPage().
Medios: uploadMediaUrl(name, url), uploadMediaData(name, data, mimeType).
Estructura y navegación: root, currentPage, currentFile, selection, openPage(page),
group(shapes), ungroup(group, ...other), flatten(shapes).
Alineación: alignHorizontal(shapes, dir), alignVertical(shapes, dir),
distributeHorizontal(shapes), distributeVertical(shapes).
Generación: generateMarkup(shapes, {type}), generateStyle(shapes, {type, withPrelude, includeChildren}),
generateFontFaces(shapes).
Color: shapesColors(shapes), replaceColor(shapes, oldColor, newColor).
Contextos: library, fonts, history, viewport, theme, localStorage, utils,
currentUser, activeUsers.
PROHIBIDO — no existe
| Miembro inventado | Realidad |
|---|---|
penpot.createImage() |
No existe. Las imágenes van como fills = [{fillOpacity: 1, fillImage: imageData}] |
penpot.createComponent() |
Es penpot.library.local.createComponent(shapes) |
penpot.findShapeById() |
Es penpotUtils.findShapeById(id) o page.getShapeById(id) |
| cualquier miembro ausente de la Captura 2 | — |
2. penpotUtils — el objeto que inyecta el servidor MCP
Lista cerrada, de penpot_api_docs.md Captura 1. Distinto de penpot.utils (ContextUtils,
Captura 34), que solo tiene geometry y types.
| Firma | Aridad | Nota |
|---|---|---|
getPages() |
0 | {id, name}[] |
getPageById(id) |
1 | |
getPageByName(name) |
1 | |
shapeStructure(shape, maxDepth?) |
1-2 | Devuelve {id, name, type, children?, layout?} |
findShapeById(id) |
1 | Ver § 3.1. La forma de 2 argumentos es el bug #1 del diagnóstico |
findShape(predicate, root?) |
1-2 | Sin root busca en todas las páginas |
findShapes(predicate, root?) |
1-2 | |
isContainedIn(shape, container) |
2 | |
setParentXY(shape, parentX, parentY) |
3 | parentX/parentY son read-only, esta es la única vía |
analyzeDescendants(root, evaluator, maxDepth?) |
2-3 | Devuelve {shape, result}[] |
PROHIBIDO
penpotUtils.importImage(...) — no existe (penpotUtils.importImage is not a function).
3. Los cuatro errores que esta fase corrige
3.1 findShapeById tiene aridad 1
| Documentado | findShapeById(id: string): Shape | null (Captura 1) |
| Verificado | penpotUtils.findShapeById.length === 1. findShapeById(id) → {found: true}. findShapeById(page, id) → {found: false}, sin lanzar |
| Correcto | const s = penpotUtils.findShapeById(id); if (!s) { ... } |
| PROHIBIDO | penpotUtils.findShapeById(page, id) — 27 ocurrencias sobre 21 de los 41 seeds viejos |
El fallo es silencioso: devuelve null y revienta en la línea siguiente con
Cannot read properties of null (reading '<prop>'). El stack apunta al síntoma, no a la causa.
Familia de búsqueda completa y permitida:
| Forma | Cuándo |
|---|---|
penpotUtils.findShapeById(id) |
Id conocido, búsqueda global |
page.getShapeById(id) |
Id conocido, dentro de una página concreta |
penpotUtils.findShape(pred, root?) |
Primer match por predicado |
penpotUtils.findShapes(pred, root?) |
Todos los matches por predicado |
page.findShapes({name, nameLike, type}) |
Búsqueda por criterio declarativo (Captura 23) |
penpotUtils.getPageByName(name) |
Obtener la página primero |
3.2 shape.layout no existe
| Documentado | Board declara grid?: GridLayout y flex?: FlexLayout. No hay layout (Captura 3) |
| Verificado | 'layout' in shape === false; shape.layout === undefined. board.flex y board.grid devuelven null (no undefined) cuando no hay layout |
| Correcto | if (board.flex) { board.flex.dir = 'column'; } |
| PROHIBIDO | shape.layout, board.layout, !!form.layout — 8 ocurrencias sobre 5 seeds viejos |
Origen del error, y por qué hay un seed dedicado a desambiguarlo: la salida de
penpotUtils.shapeStructure() sí trae una clave layout ({id, name, type, children?, layout?}).
Es una clave del output del helper, no una propiedad del shape. Confundirlas es exactamente el error
que hay que desaprender.
3.3 createText() sin argumento devuelve null
| Documentado | Contrato: createText(text: string): null | Text. Ejemplo oficial: text = penpot.createText(); sin argumento (Captura 6) |
| Verificado | createText('Hola mundo') → Text. createText() → null. createText('') → null |
| Correcto | const t = penpot.createText('Margherita'); if (!t) throw new Error('createText devolvió null'); |
| PROHIBIDO | penpot.createText() y penpot.createText('') |
Un modelo que consulta la documentación y sigue el ejemplo obtiene null, no puede crear texto, y
abandona: quedan solo rectángulos. Ésa es la mitad del síntoma reportado.
3.4 El gris #B1B2B5
| Verificado | El fill por defecto de penpot.createRectangle() es #B1B2B5. El de createBoard() es blanco |
| Correcto | Invariante R1: todo shape creado recibe fills explícito antes de insertarse |
El único ejemplo de flex layout en los 41 seeds viejos crea un board de 400×60 con dos rectángulos a los
que nunca asigna fills — el ejemplo canónico de "armar un layout" en los datos de entrenamiento
produce exactamente dos cajas grises.
4. Texto
| Miembro | Estado | Detalle verificado |
|---|---|---|
characters |
✅ | El contenido renderizado |
fontSize |
✅ string | '48'. Los números se coercionan, pero el lint exige el string |
fontWeight |
✅ string | '700' |
lineHeight |
✅ string | '1.2' |
letterSpacing |
✅ string | '-1' (px). '-0.02' se lee de vuelta como "0": los negativos en em se ignoran en silencio |
fontFamily |
✅ | text.fontFamily = 'Work Sans' como string directo bindea (fontId: "gfont-work-sans"). 1914 fuentes disponibles |
growType |
✅ | 'auto-width' | 'auto-height' | 'fixed'. Default 'auto-width' |
align |
✅ | 'left' | 'center' | 'right' | 'justify' |
verticalAlign |
✅ | 'top' | 'center' | 'bottom' |
fills |
✅ | El color del texto va acá. Default [{fillColor:"#000000", fillOpacity:1}] |
textTransform, textDecoration, direction |
✅ | Captura 4 |
getRange(start, end) |
✅ | Devuelve TextRange |
applyTypography(typography) |
✅ | |
textAlign |
❌ LANZA | Cannot add property textAlign, object is not extensible. Mata todo el execute_code. Usar align |
color |
❌ LANZA | El objeto no es extensible. Usar fills |
font.applyToText(text) |
⚠️ | Funciona, pero deja fontId como un objeto UUID de ClojureScript filtrado. Preferir fontFamily como string |
Defaults de un Text recién creado, verificados: fontSize:"14", fontFamily:"sourcesanspro",
fontWeight:"400", fills:[{fillColor:"#000000",fillOpacity:1}], growType:"auto-width",
width/height = [1, 1].
Dimensionado (invariantes R4/R5/R6):
- El auto-sizing no es inmediato: dormir ~120 ms antes de leer el bounding box.
resize(w, h)seteagrowTypea'fixed'en silencio. Restaurarlo si se quiere auto-sizing.widthyheightson read-only;resize()es la única vía.parentX/parentY/boardX/boardY/boundsson read-only; usarpenpotUtils.setParentXY().
5. Fills, gradientes, strokes, sombras, radios
| Miembro | Estado | Detalle |
|---|---|---|
fills = [{fillColor, fillOpacity}] |
✅ | |
fills apilados |
✅ | Foto + scrim oscuro encima funciona |
fillColorGradient |
✅ | Coords normalizadas 0..1 con width: 1, aceptado verbatim |
fillImage |
✅ | fills = [{fillOpacity: 1, fillImage: img}]; img.keepAspectRatio = true para fotos |
fillColorRefFile / fillColorRefId |
✅ | Los pone libraryColor.asFill() |
strokes = [{strokeColor, strokeWidth, strokeStyle, strokeAlignment, strokeOpacity}] |
✅ | |
borderRadius, borderRadiusTopLeft… |
✅ | En rectángulo y board |
shadows = [{style, offsetX, offsetY, blur, spread, color}] |
✅ | |
Shadow.color |
⚠️ | Es un Color ({color: '#000000', opacity: 0.12}), no un Fill. {fillColor: ...} es el bug clásico |
blur |
✅ | |
opacity, blendMode |
✅ |
Gradiente, forma verificada:
fills = [{ fillOpacity: 1, fillColorGradient: {
type: 'linear', startX: 0, startY: 0, endX: 0, endY: 1, width: 1,
stops: [{ color: '#D62828', offset: 0 }, { color: '#F77F00', offset: 1 }]
}}]
6. Jerarquía e inserción
| Situación | Forma correcta |
|---|---|
| Padre sin layout | parent.insertChild(parent.children.length, shape) |
| Board con flex | board.appendChild(shape), llamado en orden visual |
| Board con grid | board.grid.appendChild(shape, row, column) — 1-based |
| Índice específico en flex | board.insertChild(index, shape), recordando el orden invertido |
| Reparentar | newParent.appendChild(shape) / insertChild(...) — quita del padre viejo, preserva x/y absolutos |
| PROHIBIDO | board.flex.appendChild(shape) — está roto |
Orden invertido, verificado. En dir: 'column' (y 'row'), apendeando PRIMERO y luego SEGUNDO, el
array children queda [SEGUNDO, PRIMERO, ...]: board.appendChild inserta al frente. Por eso hay
que llamarlo en orden visual.
La regla absoluta "nunca appendChild" que enseñan los seeds viejos es INCORRECTA. El overview la
califica él mismo (except in flex layout boards) y la sección de Flex Layout manda usar
board.appendChild. La política correcta es condicional, y hay 5 seeds del grupo A3 dedicados a ella.
Z-order: el orden del array children. Métodos: bringToFront(), sendToBack(),
bringForward(), sendBackward(), setParentIndex(index) (0-based).
7. Layouts
Flex
| Miembro | Estado | Detalle |
|---|---|---|
board.addFlexLayout() |
✅ | Devuelve FlexLayout |
board.flex |
✅ | Guard: if (board.flex). null si no hay layout |
dir |
✅ | 'row' | 'row-reverse' | 'column' | 'column-reverse' |
rowGap, columnGap |
✅ | No existe el shorthand gap |
alignItems, alignContent, justifyItems, justifyContent |
✅ | |
topPadding/rightPadding/bottomPadding/leftPadding, verticalPadding/horizontalPadding |
✅ | |
horizontalSizing / verticalSizing |
⚠️ | 'fill' | 'auto' | 'fit-content'. 'auto' se lee de vuelta como 'auto' pero el board NO crece (penpot#8520). Hay que board.resize() a mano |
wrap |
✅ | 'wrap' | 'nowrap' |
flex.appendChild |
❌ | Roto. Usar board.appendChild |
Grid
| Miembro | Estado | Detalle |
|---|---|---|
board.addGridLayout() |
✅ | No addFlexLayout(), como dice erróneamente el overview |
board.grid |
✅ | Guard: if (board.grid) |
addRow(type, value?), addColumn(type, value?) |
✅ | type: 'flex' | 'fixed' | 'percent' | 'auto' |
addRowAtIndex, addColumnAtIndex, removeRow, removeColumn, setRow, setColumn |
✅ | |
rows, columns |
✅ | Track[] = {type, value} |
grid.appendChild(shape, row, column) |
✅ | Índices 1-based. (s, 0, 0) se clampea y se lee [1,1]; (s, 1, 2) se lee [1,2]. El ejemplo 0-based de la doc de tipos está mal |
shape.layoutCell |
✅ | {row, rowSpan, column, columnSpan, areaName, position} — para leer de vuelta y reposicionar |
layoutChild (el hijo dentro de un board con layout)
| Miembro | Estado | Detalle |
|---|---|---|
layoutChild |
✅ | Existe solo DESPUÉS de insertar el shape en un board con layout |
horizontalSizing / verticalSizing |
✅ | 'fill' | 'auto' | 'fix' (no 'fixed', no 'fit-content') |
alignSelf |
✅ | |
absolute |
✅ | true saca al hijo del flujo del layout |
*Margin, maxWidth/maxHeight/minWidth/minHeight |
✅ | |
zIndex |
⚠️ | Está en el tipo pero se ignora en silencio (se setea 10, se lee 0). Usar el orden de children |
Invariante R8: todo efecto de layout se lee de vuelta del objeto retornado. El seed se entrena
sobre "titleStretched": true leído del runtime, no sobre la esperanza de que haya funcionado.
Invariante R9: wrappers abrazan (flex.horizontalSizing = 'fit-content'), secciones llenan
(layoutChild.horizontalSizing = 'fill'). Los dos idioms en el mismo seed para que el contraste sea
aprendible.
8. Imágenes
| Miembro | Estado |
|---|---|
await penpot.uploadMediaUrl(name, url) → ImageData |
✅ Única vía |
await penpot.uploadMediaData(name, bytes, mimeType) |
✅ |
fills = [{fillOpacity: 1, fillImage: img}] |
✅ |
img.keepAspectRatio = true |
✅ Para fotos |
penpot.createImage() |
❌ No existe |
penpotUtils.importImage() |
❌ penpotUtils.importImage is not a function |
herramienta MCP import_image |
❌ No existe en este deployment |
uploadMediaUrl devuelve una Promise y puede rechazar (Error uploading media): await dentro de
un try/catch.
9. Biblioteca local (tokens de diseño)
| Miembro | Estado | Detalle |
|---|---|---|
penpot.library.local |
✅ | |
library.local.createColor() |
✅ | Luego c.name = 'Brand/Primary'; c.color = '#D62828'; |
libraryColor.asFill() |
✅ | Devuelve un Fill con fillColorRefFile/fillColorRefId |
libraryColor.asStroke() |
✅ | |
library.local.createTypography() |
✅ | |
library.local.createComponent(shapes) |
✅ | |
component.instance() / mainInstance() |
✅ | |
penpot.library.connected, availableLibraries(), connectLibrary(id) |
✅ | |
typography.setFont(font, variant?) |
❌ | Está en el tipo, no existe en el runtime: t.setFont is not a function. Setear fontFamily/fontSize/fontWeight/lineHeight una por una |
10. Verificación programática (invariante R12)
| Miembro | Estado | Uso |
|---|---|---|
penpot.generateStyle(shapes, {type:'css', includeChildren:true}) |
✅ | Devuelve CSS real. La opción es includeChildren; withChildren (que usa el overview) se ignora |
penpot.generateMarkup(shapes, {type:'html'}) |
✅ | Devuelve HTML real |
penpotUtils.analyzeDescendants(root, evaluator, maxDepth?) |
✅ | Auditoría; el evaluador puede devolver funciones correctoras |
penpotUtils.isContainedIn(shape, container) |
✅ | Violaciones de contención |
penpotUtils.shapeStructure(shape, maxDepth) |
✅ | Overview del árbol. Su clave layout es del output, no del shape |
herramienta export_shape |
✅ | Inspección visual |
R12: nunca terminar sin mirar y medir. Todo seed compositivo cierra con export_shape + una
auditoría programática, y el mensaje final cita los números de esa auditoría.
11. Contramedida al system prompt del servidor
El servidor MCP inyecta verbatim (ver penpot_system_prompt.md):
NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to non-creative defaults such as white/black if you are lacking information).
Ninguno de los 41 seeds viejos tiene mensaje system, así que el modelo nunca se entrenó en
presencia de esta instrucción. En producción la aplica al revés y produce el gris.
Obligatorio en los seeds nuevos:
- El bloque
instructionsverbatim como mensajesystemen ~30 % de los seeds (el resto sin él, para robustez en las dos condiciones). - En todo seed creativo,
reasoning_contentque desambigüe explícitamente. La regla condiciona sobre "transferring styles from a Penpot design to code"; crear un diseño nuevo es el caso contrario: no hay diseño previo del que transferir, así que no hay "missing values" sobre los que asumir. Elegir una paleta deliberada es la tarea.
12. Los doce invariantes anti-caja-gris
Cada uno presente en los datos y verificado por scripts/07_lint_penpot_code.py, no solo enunciado
en prosa.
| # | Invariante |
|---|---|
| R1 | Todo shape creado recibe fills explícito antes de insertarse. Los wrappers puros de layout ponen fills = [] — transparente por intención, nunca "sin tocar" |
| R2 | Cero grises de placeholder. Todo hex debe pasar saturación > 10 o max < 100 o max > 220. El gris solo aparece en los tool results del grupo B8, y solo como input |
| R3 | Toda paleta es un set cerrado y nombrado de ≤ 8 roles, declarado al inicio del payload y referenciado por rol |
| R4 | Todo texto recibe characters + tamaño + fill + growType |
| R5 | Tamaño, peso e interlineado son strings entre comillas |
| R6 | Tras resize() sobre un texto, restaurar growType |
| R7 | Una card es fondo + radio + elevación |
| R8 | Todo hijo de layout declara su sizing, y el efecto se lee de vuelta del objeto retornado |
| R9 | Wrappers abrazan (fit-content), secciones llenan (fill) — los dos idioms en el mismo seed |
| R10 | Escala tipográfica, nunca tamaños ad-hoc: ≥ 3 tamaños distintos por pantalla compuesta |
| R11 | Espaciado en escala de 8 px |
| R12 | Nunca terminar sin mirar y medir: export_shape → auditoría → resumen que cita los números |
13. Resumen de patrones prohibidos (lo que el lint busca)
| Patrón | Por qué |
|---|---|
findShapeById( con coma en los argumentos |
Aridad 1; la forma de 2 devuelve null en silencio |
.layout en una línea sin shapeStructure |
La propiedad no existe |
.flex.appendChild( |
Roto |
appendChild( sobre un receptor sin evidencia de flex |
Solo es correcto en boards con flex |
fontSize/fontWeight/lineHeight/letterSpacing = <número> |
Son strings |
textAlign = |
Lanza y mata el execute_code |
importImage, import_image, createImage(, filePath |
No existen en este deployment |
Asignación a width/height/parentX/parentY/bounds |
Read-only |
gap = |
No hay shorthand; son rowGap/columnGap |
shadows = [{... color: {fillColor |
Shadow.color es un Color, no un Fill |
| Hex gris de placeholder fuera del grupo B8 | Invariante R2 |
console.log de algo que también se retorna |
Prohibición explícita del servidor |
setFont( |
No existe en el runtime |
createText() / createText('') |
Devuelven null |
high_level_overview en más de 2 seeds |
Su descripción prohíbe llamarlo dos veces |