Files
qwen3-6-lora/data/schemas/PENPOT_API_VERIFIED.md
T
aleleba d9629c44ae Phase 6.1: capture verified Penpot API ground truth for the LoRA #2 dataset
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.
2026-07-30 16:46:33 +00:00

21 KiB
Raw Blame History

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_imageno existe en este deployment. El overview la menciona en prosa; la lista de herramientas es autoritativa. Ruta correcta: await penpot.uploadMediaUrl(name, url).
  • export_shape con filePath — el parámetro está eliminado del schema en este deployment (ver PENPOT_DEPLOYMENT_NOTES.md). Inventarlo es invención de parámetros.
  • export_shape con scalescale es propiedad del método de plugin shape.export(config) (penpot_api_docs.md Captura 29), no de la herramienta MCP. No hay forma de pedir 2x.
  • Llamar high_level_overview dos 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() 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) setea growType a 'fixed' en silencio. Restaurarlo si se quiere auto-sizing.
  • width y height son read-only; resize() es la única vía.
  • parentX/parentY/boardX/boardY/bounds son read-only; usar penpotUtils.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:

  1. El bloque instructions verbatim como mensaje system en ~30 % de los seeds (el resto sin él, para robustez en las dos condiciones).
  2. En todo seed creativo, reasoning_content que 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