Files
qwen3-6-lora/data/schemas/PENPOT_API_VERIFIED.md
T
aleleba 7a1577b9d7 Phase 6.4.25c: correct flex.appendChild -- live verification shows it works
Live probing against Penpot 2.16.2 (repeated, on a dedicated scratch page,
cleaned up afterwards) shows board.flex.appendChild(shape) is a real
function, distinct from board.appendChild, arity 1, throws nothing, and
preserves visual insertion order -- unlike board.appendChild, which still
inserts at the front as documented. The prior "broken" claim traces to the
MCP server's own high_level_overview() text (CRITICAL / BROKEN / NEVER use),
never verified with a live throw, and possibly confused with a real doc bug
that mislabels the grid 3-arg example as board.flex.

Removes flex.appendChild from the lint's FORBIDDEN patterns and from the
gate 5 forbidden-behavior veto, updates PENPOT_API_VERIFIED.md with the
verified facts, and drops the now-obsolete pattern from the replay filters
in 05_build_dataset.py and 07_build_lora2_mix.py.
2026-08-04 17:40:16 +00:00

442 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_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 `scale``scale` 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()` **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)` 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:**
```js
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.flex.appendChild(shape)` (preferido, orden visual) o `board.appendChild(shape)` (orden invertido, ver abajo) |
| 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 |
**ACTUALIZACIÓN (2026-08-04, hallazgo 54 de la Fase 6, ronda correctiva 6.4.25c): `board.flex.appendChild(shape)`
NO está roto — verificado en vivo, repetidas veces, contra Penpot 2.16.2.** Es una función real, de
aridad 1, distinta de `board.appendChild` (no es un alias: `board.flex.appendChild !== board.appendChild`,
`board.flex !== board`), que no lanza ninguna excepción y agrega el hijo correctamente, con `layoutChild`
funcionando bien después. La afirmación anterior de que estaba roto **nunca tuvo un error real capturado**
en `penpot_errors.md` (a diferencia de todos los demás comportamientos rotos documentados en este archivo,
que sí lo tienen). Su origen real: el `high_level_overview()` del servidor MCP (capturado verbatim en
`penpot_api_docs.md`, líneas 131-135) le dice al modelo, en mayúsculas y marcado `CRITICAL`, que
**`board.flex.appendChild` está BROKEN y que NUNCA lo use** — instrucción que la sesión original de
diagnóstico tomó como cierta sin verificarla empíricamente con un intento real de romperlo. Esa misma
sección de la doc mezcla además un bug real y distinto (el ejemplo de **grid** con 3 argumentos aparece
escrito como `board.flex.appendChild(shape, row, column)` en vez de `board.grid.appendChild(...)`, ver
`penpot_api_docs.md` línea 143), lo cual reforzó la confusión. **La documentación servida en vivo por el
MCP de Penpot está, en este punto, objetivamente equivocada** (verificado empíricamente, repetidas veces,
el 2026-08-04 contra Penpot 2.16.2) — o el comportamiento se arregló en Penpot después de escribirse esa
documentación, sin actualizarla. Cualquier seed nuevo que muestre al modelo leyendo `high_level_overview`
debería, idealmente, incluir el razonamiento de que esa afirmación puntual sobre `flex.appendChild` no es
confiable y de que hay que verificar con el runtime en vez de creerle a esa línea específica — el mismo
patrón de "la lista de tools es autoritativa, la prosa no" ya usado en el grupo C para `import_image`.
**Orden invertido, verificado — y por qué `board.flex.appendChild` es preferible.** En `dir: 'column'`
(y `'row'`), apendeando PRIMERO y luego SEGUNDO con `board.appendChild`, el array `children` queda
`[SEGUNDO, PRIMERO, ...]`: `board.appendChild` inserta **al frente**, así que hay que llamarlo en orden
inverso al visual para compensar. `board.flex.appendChild`, en cambio, preserva el **orden visual normal**
de las llamadas (PRIMERO, SEGUNDO, ...) — no necesita el truco de inversión. Para boards con flex, **usar
`board.flex.appendChild(shape)` en orden visual directo es la forma más simple y menos propensa a error.**
`board.appendChild` sigue siendo válido (y necesario para reparentar hacia boards sin layout, o cuando el
código ya maneja el truco de inversión de forma consistente), pero ya no es la única opción recomendada.
**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 —
esos seeds siguen siendo correctos sobre el comportamiento de `board.appendChild` (el orden invertido es
real), pero ya no reflejan que `board.flex.appendChild` sea una alternativa rota.
**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` | ✅ | **Funciona** (verificado 2026-08-04, hallazgo 54). Aridad 1, preserva orden visual — preferible a `board.appendChild` para insertar en boards con flex |
### 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 |
| `appendChild(` sobre un receptor sin evidencia de flex | Solo es correcto en boards con flex (`board.flex.appendChild` o `board.appendChild`) |
| `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 |