npx skills add ...
npx skills add penpot/penpot-ai-kit --skill penpot-component-factory
Build and maintain Penpot components with COMPLETE variant matrices — sizes, hierarchies, and all interactive states (default/hover/pressed/focus/disabled) — fully tokenized and correctly named. Use to create a new component with variants, fill in missing states, or normalize an existing component. Triggers: 'create a Button component with variants', 'build component variants', 'add hover/pressed/disabled states', 'make a variant matrix', 'turn this into a component with sizes', 'normalize this component'.
npx skills add penpot/penpot-ai-kit --skill penpot-component-factory
penpot-component-factory builds components and their full variant matrices; every mutation goes
through execute_code; validate visually with export_shape; read structure with
penpotUtils.shapeStructure (full tool surface: shared/penpot-mcp-tool-reference.md).
It builds a base component as a Board with flex layout (every value tokenized via penpot-foundations
tokens), generates variants across axes, combines them into a variant container
(penpotUtils.createVariantContainer on ≥ 2.17; penpot.createVariantFromComponents as the low-level
fallback), and verifies completeness.
Every interactive component ships every required state, and every value is a token. No
hardcoded fills/spacing; no missing Hover/Pressed/Focus/Disabled unless the system explicitly
says otherwise. Build one variant at a time; checkpoint before combining.
Full surface: shared/penpot-mcp-tool-reference.md. Key calls:
| Call | Why |
|---|---|
penpot.createBoard() + addFlexLayout() | base component container |
penpot.library.local.createComponent(shapes) | turn the base into a component |
shape.clone() | derive variants from the base |
penpotUtils.createVariantContainer([{ shape, properties }]) | preferred (≥ 2.17): container + axes + values in one call — behind the duplicate-file gate |
penpot.createVariantFromComponents(mainInstances) | low-level fallback when the helper is absent |
instance.switchVariant(pos, value) | demonstrate/switch variants (pos = index in variants.properties) |
export_shape | visual checkpoint of the matrix |
Gotcha numbers refer to shared/plugin-api-gotchas.md.
createBoard, NOT createFrame) with addFlexLayout(); set dir, gaps, padding, horizontalSizing/verticalSizing.layoutChild for per-child sizing/margins.clone() duplicates a shape with all properties — the basis for the variant matrix.penpotUtils.createVariantContainer([{ shape: mainInstance, properties: { Size: "Medium", State: "Default" } }]) (≥ 2.17) behind the duplicate-file gate; the low-level penpot.createVariantFromComponents(mainInstances) + renameProperty/addProperty/setVariantProperty path only as fallback. Switch with instance.switchVariant(pos, value) — pos is the index in variants.properties, not the axis name. Board.combineAsVariants(ids) is listed by penpot_api_info on 2.17 but unverified — do not use.setVariantProperty(pos, value) updates the variant properties but not the variant root's internal :variant-name, so the file fails backend referential-integrity validation: from then on every component-touching mutation is rejected with an HTTP 400 the plugin never surfaces — calls hang ~30 s, the session dies, unflushed mutations roll back. The same poison applies to addVariant() + rename, comp.instance() + detach() on a variant, createComponent on a detached variant instance, and shape.remove() on a variant board; recovery is manual. Reading variants and instancing a specific variant are safe. Safe strategy: build each state as a standalone Board and createComponent([board]) one at a time, named Component / State; Phase 3 (§6) carries the duplicate-file / verify-saves / fallback procedure.penpot_api_info('VariantContainer') / penpot_api_info('Variants') first.penpot-foundations), required axes.Property=Value.Act as a senior component engineer.
Visual self-review (mandatory): before every ✋ checkpoint that shows visual work, run the export → look → fix loop from
shared/visual-self-review.md— export the unit you just built, inspect the image yourself against the checklist, fix visible defects (max 2 iterations), and present that same export with any remaining defects named.
Phase 0 — Discovery. high_level_overview; read tokens (tokenOverview()) and existing components. Decide axes (references/01-variant-axes.md). ✋ Checkpoint: confirm the axis matrix.
Phase 1 — Base. Build the base Board with flex layout, tokenized (scripts/buildComponentBase.js), then createComponent. ✋ Checkpoint: review base (export_shape).
Phase 2 — Variants as components. For each matrix cell, clone the base, retokenize per state, and
createComponent it — createVariantFromComponents needs a component per variant. Use
scripts/createVariants.js (collects each cell's main-instance id). ✋ Checkpoint: review the cells.
Phase 3 — Group, name axes, organize. ⚠️ HIGH-RISK PHASE — read shared/plugin-api-gotchas.md
#12 first. Mutating variant components (setVariantProperty, axis renames, detaching/removing
variants) has corrupted files and hung all subsequent saves in live sessions (the backend rejects
every save with an unsurfaced HTTP 400). Before this phase: ✋ Checkpoint — ask the user to duplicate
the file (or confirm they accept the risk on a scratch file). Then apply one variant mutation,
verify the file still saves (a later read-only call must succeed and persist), and only then continue.
If any call hangs ~30 s, stop immediately and switch to the fallback below.
The variant flow: scripts/createVariantGroup.js is gated — it returns { halted: true } unless
storage.cf.fileDuplicatedConfirmed === true (set it in a prior call only after the user duplicated the
file and confirmed saves work). It then prefers
penpotUtils.createVariantContainer(records.map(r => ({ shape: main, properties: r.properties })))
(path "helper", ≥ 2.17: axes + values in one call), falling back to penpot.createVariantFromComponents
renameProperty/addProperty/setVariantProperty (path "legacy", kept verbatim in the script).
Either way it clears the container fill (#11), gives the container a flex layout (variants stack at
the same spot otherwise: container.flex || container.addFlexLayout(); row, gaps, padding, wrap) and
returns { containerId, path, properties, components, next }. Save verification: after the call,
make a trivial change in the Penpot UI and confirm it persists before any further mutation — a ~30 s
hang means the file is poisoned; discard the duplicate and switch to the fallback below. ✋ Checkpoint.Fallback (always safe): skip the variant container entirely. Keep the per-cell components from
Phase 2 as standalone components named Component / Axis=Value, … (e.g. Button / Size=Medium, State=Hover), created one at a time with a flush pause between calls, arranged in a labeled grid.
Penpot groups them by the / prefix and the user can convert them to real variants in the UI later.
Phase 4 — Validate. scripts/validateComponent.js — every required state present, all values tokenized, naming correct. Optional scripts/switchVariantDemo.js to prove switching. Report.
Default/Hover/Pressed/Focus/Disabled unless the system says otherwise.Property=Value, PascalCase (Size=Medium, State=Hover).layoutChild.absolute.Component / State components
if anything hangs.Standard axes:
Size = Small | Medium | LargeHierarchy = Primary | Secondary | TertiaryState = Default | Hover | Pressed | Focus | DisabledIcon = None | Left | RightThe matrix is the Cartesian product you need — keep it as small as the system allows (don't generate combinations the design language doesn't use). Each variant is a cloned base with state-specific token bindings.
Default review. Variant/component restructuring and detach() are never auto-applied
(shared/modes-and-policies.md).
Ledger keys under RUN_ID: phase, baseComponentId, created:[{variant:'Size=Medium,State=Hover', id}]. Re-derive with a component read after truncation.
| After phase | Artifacts shown | What we ask |
|---|---|---|
| 0 | proposed axis matrix | Approve axes? |
| 1 | base export_shape | Approve base? |
| 2 | variant grid export_shape | Approve variants? |
| 3 | variant container | Approve combine? |
shared/naming-conventions.md: component PascalCase (Button); variant Property=Value; internal
layers semantic (label, icon, button).
| Excuse | Why it's wrong | Countermeasure (halt) |
|---|---|---|
| "I'll add hover/disabled later." | Incomplete components ship broken states. | All required states are acceptance gates; build them now. |
| "A plain rectangle is fine for this state." | Hardcoded shapes drift from the system. | Clone the base and bind state tokens. |
| "I'll hardcode the hover color." | Breaks theming and governance. | Use/propose a semantic token (color.action.primary.hover.bg). |
| "Absolute-position the icon, it's easier." | Fights flex; breaks resizing. | Use flex order + layoutChild; absolute only with layoutChild.absolute and a reason. |
| "I'll assume the variant-creation method/args." | Variant API is easy to get wrong. | Prefer penpotUtils.createVariantContainer; low-level createVariantFromComponents only when the helper is absent; verify with penpot_api_info('Variants'). Never combineAsVariants (unverified). |
| "Variant mutation worked once — I'll batch the rest in one call." | One bad setVariantProperty poisons the file silently; every later save hangs (gotchas #12). | One mutation → verify saves persist → continue. On any ~30 s hang, stop and use the standalone-components fallback. |
penpot_api_info('Board'), penpot_api_info('VariantContainer'), penpot_api_info('Variants'), penpot_api_info('LibraryComponent').references/: 01-variant-axes.md, 02-flex-grid-layout.md, 03-variant-api.md, 04-instance-swap-detach.md, 05-anti-rationalization.md, 06-error-recovery.md.
scripts/: buildComponentBase.js, createVariants.js (one component per cell), createVariantGroup.js (gated; helper ≥ 2.17 or legacy path), switchVariantDemo.js, validateComponent.js.
Doctrine paths. shared/… and policies/… resolve inside this bundle in native installs (vendored by the installer); in a Claude Code plugin install they live at the plugin root — ${CLAUDE_PLUGIN_ROOT}/shared/…, two directories up from this file.