platform/figma-enhanced/SKILL.md

name: figma-enhanced description: >- Advanced Figma and FigJam editing through the official Figma MCP. Use for custom editable ERD tables, row ports on one or both sides, connector magnet and arrow-direction jank, tldraw visual matching, exact SVG role icons, patterned shadows, or close visual and structural validation. Also use when the user explicitly distinguishes the official/other Figma MCP from Codex Apps Figma tools.

figma-enhanced

Build on the mandatory Figma skills. Load figma-use before every use_figma call and add figma-use-figjam for /board/ URLs.

Route tools deliberately

  • When the user asks for the official (or "other") Figma MCP, use the official Figma MCP server's tools under whatever prefix the harness gives them (e.g. mcp__plugin_figma_figma__* in Claude Code), not a Codex Apps Figma connector.
  • For FigJam, call get_figjam before writing. Use get_screenshot for visual state and use_figma only when JavaScript inspection or mutation is required.
  • Include skillNames: "figma-use,figma-use-figjam" in FigJam use_figma calls.

Work incrementally

  1. Inspect the relevant section and one representative table.
  2. Identify stable grids, face bounds, row centers, existing connectors, and naming conventions.
  3. Apply small mutation batches. Keep mass creation to at most five port/magnet pairs per call.
  4. Return every created, mutated, and removed node ID.
  5. Re-read connector bindings after mutations.
  6. Validate structurally and with screenshots. Fix discrepancies before continuing.

Treat a failed use_figma script as atomic. Read the error before retrying.

Model editable ERD row ports

Separate the visible port from the sole interactive connector target:

  • Render the visible circle as a small editable FRAME/SVG node.
  • Lock the visible circle so it does not compete for connector drops.
  • Create one unlocked, transparent native SHAPE_WITH_TEXT ellipse per visible circle. This is both the quick-create affordance and the only connector target.
  • Keep the native target at its 16 × 16 minimum, center it vertically on the 10px visible circle, and align its outward edge with the visible circle's outward edge.
  • Name targets with an explicit side suffix such as [Pufferlib Kit] ERD Quick Create / table.row / L or / R.
  • Keep exactly one target per circle. Two-sided rows therefore have two circles and two targets, one per side.

Do not overlay an unlocked 1 × 1 ellipse when quick create is enabled. The two targets compete during a manual drop; FigJam can select the inner side of the tiny ellipse and create a U-turn at the row. A controlled comparison showed the native quick-create target attaching cleanly where the competing ellipse folded.

Use the port geometry rather than hardcoded canvas coordinates:

const target = figma.createShapeWithText()
target.shapeType = "ELLIPSE"
target.resize(16, 16)
target.x = side === "L" ? port.x : port.x + port.width - target.width
target.y = port.y + (port.height - target.height) / 2
target.fills = [{ type: "SOLID", color: { r: 1, g: 1, b: 1 }, opacity: 0.01 }]
target.strokes = []
parent.appendChild(target)

Quick create is a native FigJam shape affordance, not a connector or node flag. Do not lock the native target; locked FigJam shapes do not expose quick create. FigJam-created connectors may still use AUTO initially, so normalize their endpoint sides after creation.

When adding the opposite side:

  • Clone the existing visual port so tone and stroke stay exact.
  • Rename only the side suffix.
  • Position it from the table face border, not the section bounds.
  • Create one matching native quick-create target.
  • Exclude detached or floating test endpoints by confirming the port center aligns with a real face border.
  • Audit by grouping ports by section and row name; every real row should contain both L and R.

Patterned shadows can expand grid or container bounds. Derive the face border from the actual face vector or a known-correct port; do not assume the grid’s outer width is the table border.

Preserve connector bindings

When replacing targets:

  1. Create all replacement native quick-create targets.
  2. Build an old-ID to new-node map.
  3. Rebind connectorStart and connectorEnd before removing old targets.
  4. Apply connector styling after endpoint assignments because endpoint changes may reset route properties.
  5. Verify no endpoint references an old target, then remove old targets in small batches.

Use automatic approach direction only when the route may legitimately enter from either side:

connector.connectorEnd = { endpointNodeId: target.id, magnet: "AUTO" }
connector.strokeWeight = desiredWeight
connector.cornerRadius = desiredRadius
connector.connectorStartStrokeCap = "NONE"
connector.connectorEndStrokeCap = "ARROW_LINES"

For ERD row ports, pin the connector to the port's outward side. A native target worked cleanly with AUTO in a controlled test, but explicit sides keep routing deterministic. Derive the side from the target name and set it explicitly:

const side = node.name.endsWith(" / L") ? "LEFT" : node.name.endsWith(" / R") ? "RIGHT" : null
if (side) connector.connectorEnd = { endpointNodeId: node.id, magnet: side }

Normalize both endpoints of every current ERD connector after manual quick creation, then reapply strokeWeight, cornerRadius, and caps. A tested quick-created connector defaulted to a 4px stroke and 24px radius; reset it to the diagram's thin style, typically 1.5px, 8px, NONE at the start, and ARROW_LINES at the end.

Do not add transparent routing hulls around custom tables. A locked native hull, both nested in the table section and moved beside the connector, did not change FigJam's elbow route. Endpoint-side normalization fixed the route.

The Plugin API exposes no node-level allowed-magnet or quick-create-style setting. Static components can supply native quick create, but guaranteed automatic normalization of every future connector requires a plugin or widget; otherwise rerun endpoint normalization after new edges are created.

If an endpoint folds or appears inverted, inspect the final segment and verify the explicit outward side visually.

Use native open arrow caps for manually reroutable diagrams. Figma’s pointed tip scales with connector stroke weight, so reduce the stroke to reduce the tip. Avoid detached vector arrowheads; they do not follow later route edits.

Match a tldraw reference faithfully

  • Inspect the live tldraw document and its theme or custom-shape source when available. Use screenshots as visual confirmation, not the primary specification.
  • Derive face fill, band fill, border tone, grid proportions, typography, role glyphs, and patterned shadow independently.
  • Keep title and engine labels unfilled when the reference uses a true border break. Split or mask the stroke at the text bounds instead of adding a plate that leaks outside the border.
  • Preserve manual editability: text stays text, role icons stay editable vectors, and relationships stay native connectors.

For exact icons, import the real source SVG path with figma.createNodeFromSvg. Preserve its view box and natural size, then position the returned editable frame. Do not approximate an SK or FK glyph by reusing PK primitives.

Validate the hard parts

Check both structure and render:

  • Real row count equals paired left/right port count.
  • Every visible port has exactly one unlocked, visible native quick-create target.
  • No legacy ellipse magnets remain when native quick create is enabled.
  • Detached test endpoints remain excluded from row audits.
  • Existing connectors still reference live nodes.
  • Connector strokes, corner radii, caps, colors, labels, and routes remain intact.
  • Role icon counts and names match the actual badges.
  • Table face, footer band, header band, grid rules, border gaps, and shadow align with the reference.

Connector-only screenshots omit surrounding port context. Use a temporary slice for a tight attachment crop, then remove it in the same script:

const slice = figma.createSlice()
slice.x = absoluteX - paddingX
slice.y = absoluteY - paddingY
slice.resize(width, height)
const temporaryNodeId = slice.id
await slice.screenshot({ scale: 4, contentsOnly: false })
slice.remove()
return { createdNodeIds: [temporaryNodeId], removedNodeIds: [temporaryNodeId] }

Report the verified counts and the user-visible outcome, not the implementation transcript.