figma-ops
Router and composition workflows for Figma via the official Figma MCP: which Figma skill/tool for which job, capture-to-canvas (screenshots or shotcraft crops into a Figma file), moodboard and reference-board composition (grid, plus, loose-plus arrangements with a deterministic l
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/figma-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Figma Operations
The front door for Figma work. The official Figma plugin ships eleven skills that own the Plugin API surface; the community ships fifty more for tokens, components, a11y and docs. This skill does not replicate any of them. It does three things they don't:
- Routes — says which skill or tool to load for a given job (§1).
- Composes — capture → curate → arrange workflows for moodboards, reference boards and any "put these images in Figma, beautifully" request (§3–§5).
- Guards — the canvas craft rules that sit above the API rules: aspect ratio, z-order, overlap budgets, verification loops, page hygiene (§6).
Always load
figma-usebefore anyuse_figmacall. It is the source of truth for the Plugin API and its gotchas. This skill assumes it is loaded and cites its references rather than restating them.
1. Router — which skill for which job
| The job | Load | Tool it wraps |
|---|---|---|
| Any script that mutates or reads the canvas | figma-use (mandatory prerequisite) |
use_figma |
| Design → code, implement a screen | the design-to-code guidance served as MCP resource skill://figma/figma-design-to-code/SKILL.md (not a plugin skill) |
get_design_context, get_screenshot |
| SwiftUI ↔ Figma, either direction | figma-swiftui |
get_design_context, use_figma |
| Build a page/screen from app code | figma-generate-design + figma-use |
use_figma, generate_figma_design |
| Tokens, variables, component library | figma-generate-library + figma-use |
use_figma, search_design_system |
| Map components to code | figma-code-connect |
Code Connect tools |
| New blank file | figma-create-new-file |
create_new_file |
| Mermaid-class diagram in FigJam | figma-generate-diagram |
generate_diagram |
| FigJam board content | figma-use-figjam + figma-use |
use_figma |
| Slides deck | figma-use-slides + figma-use |
use_figma |
| Motion / animation | figma-use-motion, figma-implement-motion |
use_figma, get_motion_context |
| Images or SVGs into a file | this skill §4 | upload_assets |
| Moodboard, reference board, composed arrangement | this skill §3–§5 | upload_assets + use_figma |
| Live-site captures as source imagery | shotcraft → this skill §4 |
— |
| Design-token export/import, a11y audits, variable CRUD | community skills — see references/skill-map.md | varies |
Two routing rules the tables can't express:
- A read-only inspection comes first, always. Pages, existing frames, fonts, naming
conventions.
figma-use§9 has the scripts. Never create before you have looked. get_metadataneeds editor access, not viewer. "You don't have edit access" on a file you can open in the browser means the share is view-only. Ask for editor, or check you are on the right account (§2).
2. Multi-account routing
One Figma OAuth token binds to exactly one Figma account. If the user belongs to more than one org (agency + client, personal + work), the correct setup is one MCP server per account — typically the plugin server for one and a claude.ai connector for the other. They expose separate tool namespaces; pick the account per call by prefix.
- Confirm identity with
whoamion each server before assuming which file it can see. - Do not consolidate to one server: every account switch would become an interactive OAuth re-auth, impossible from a non-interactive session.
- The durable fact is mechanism → account (plugin = X, connector = Y). Connector UUIDs change on re-add — never key notes on them.
- A View-only team seat fails the same way as a view-only share.
whoamilists seats.
3. Composition workflow — capture → curate → compose
The phases, each with an exit gate. Post a short checklist before each phase and a
summary after (the phase contract in figma-generate-library §1 is the model —
visible progress, decisions surfaced, never silently defaulting).
| Phase | Output | Exit gate |
|---|---|---|
| 0 Brief | Sources list, art direction (2–3 sentences), palette/vocabulary if any, target file + page | User has confirmed the source list; blockers named (attached-not-sent images, login-walled sites) |
| 1 Capture | Screenshots / section crops / user-supplied images on disk, at true pixel dimensions | Every file exists; dimensions read from the file header, not assumed |
| 2 Curate | Shortlist with one line per image saying what it contributes | Every image has been looked at by the agent. Unvetted images never enter a composition |
| 3 Upload | Nodes in the target file, named after their source files | Node IDs returned and recorded (§7 ledger) |
| 4 Arrange | Images resized to true aspect ratio and placed per a chosen pattern (§5) | Screenshot reviewed; collisions and defects fixed before adding chrome |
| 5 Dress | Typography, hairlines, marks, labels — only what the brief's vocabulary calls for | Second screenshot; nothing overlaps text; single accent rule honoured if one exists |
| 6 Hand back | Rendered PNG sent to the user; page name; what was left open | The user sees the render, not just a description of it |
Hard gates:
- No
use_figmamutation before Phase 0's source list is confirmed. - No image placed before it has been opened and looked at (Phase 2). The gate exists because the mistakes that slip through are obvious to eyes and invisible to exit codes.
- No chrome (labels, rules, marks) before the bare arrangement screenshot is clean.
- Build on a new page; never restructure the user's existing page in place. Deleting the old pages is a separate, explicit request (§6).
4. Capture → canvas
Sources. Three kinds, in rising order of value for a moodboard:
- Full-page screenshots — good for scroll strips, bad for boards (a 16,000px strip compressed to board width is an unreadable column; use the viewport shot instead).
- Section crops — the useful unit.
shotcraft'sprobe-sections.mjsfinds them structurally;capture.mjswithelements[]/scrollTotakes targeted ones. - Designed comps the user supplies — usually the strongest material; these are the aesthetic executed, not referenced.
Stage first. scripts/stage-assets.mjs takes a folder and/or a shortlist, copies
each image to a staging directory under a meaningful slug (07-ref-collected-system.png,
never IMG_0997.PNG — the filename becomes the Figma layer name on upload), reads
true pixel dimensions from the file header, and emits the planner's input JSON.
It exits 10 until every image has a subject (--names) and an arm (--arms): the
grouping is a human decision and the script refuses to guess it.
node scripts/stage-assets.mjs --list shortlist.txt --dir ./refs --out ./staged \
--names names.json --arms arms.json --json > board.json
Upload. upload_assets with count: N, then POST each staged file as
multipart/form-data with a file field. Record the returned placedOnNodeId per
file into board.json's id fields (§7 ledger).
The 400×300 trap. Uploaded images land as 400×300 frames with scaleMode: FILL,
which crops. The frame tells you nothing about the image; only the header does.
resize() every frame to the planner's w×h before placing it.
Uploads land on whichever page is current for the upload tool — not necessarily
the page your last script switched to. Find them by ID and appendChild them where
they belong.
5. Arrangement patterns
Three patterns, in the order a session usually discovers them. Details, coordinates and the reasoning behind each choice live in references/moodboard-composition.md.
| Pattern | When | Character |
|---|---|---|
| Column grid | Reference sheet, equal-weight items, "make it aligned" | 3 columns + 2-col spans; every rotation 0; captions left-aligned to column |
| Rigid plus | Groups have a meaning along each axis (medium ↑↓, temperature ←→) | Four arms from a centre element, ring sizes stepping ~0.72× outward, axes drawn as hairlines with terminal dots |
| Loose plus | "Organic", "clustered around the centre", "loosely a plus" | Same grouping, axes removed, inner ring overlapping the centre by ~40px and nudged off-axis, second ring tucked onto the inner ring's corners (≤ 250×80), wordmark floating in the eye |
Rules that held across all three:
- Rotation is opt-in. Zero degrees unless the user asks for angles; "organic" means offset and overlap, not tilt.
- Group by what images share — type, aesthetic, or colour — and let the axes mean something. A plus with arbitrary arms is just a cross.
- Sizes step toward the centre. Largest adjacent to the centre, ~0.72× per ring.
- Overlap has a budget. Inner ring onto centre ≤ 40px; neighbour corners ≤ 250×80. The planner reports every overlap and exits 10 when one exceeds budget.
- A centre element is allowed to be typographic. The brand at the centre of its influences reads better than a borrowed image there — but once images overlap it, strip its chrome (strokes, ticks, metadata) or it reads as a broken box.
- Vocabulary in the quadrants or corners, never in the cluster.
Plan coordinates with the script rather than by hand, and generate the placement script rather than typing it:
node scripts/plan-layout.mjs --input board.json --mode loose --jitter 24 --seed 7 --json > plan.json
node scripts/emit-placement.mjs --plan plan.json --phase backdrop # → use_figma
node scripts/emit-placement.mjs --plan plan.json --phase place --backdrop 45:2 --captions
plan-layout emits backdrop-relative {id, x, y, w, h} placements in z-order,
canvas size, and an overlap report (exit 10 over budget). --jitter adds seeded
irregularity for "organic" without losing reproducibility — same seed, same plan.
emit-placement turns the plan into the exact use_figma script, and refuses
any image whose plan entry is vetted: false — the look-at-it gate as data, not
memory. Full flow in references/place-and-verify.md.
6. Canvas craft rules (above the API)
Learned the expensive way; each one cost a round-trip this skill now saves.
- Hairline stroke on every dark image. A dark site on a dark ground disappears; a
1px
SOFT45–55% stroke,strokeAlign: OUTSIDE, defines the edge. Checkfillsbefore concluding an image "didn't render". - Z-order is append order. Anything that overlaps must be appended after what it
sits on. Send axis lines to the back with
insertChild(0, …). - Font style names are per-family, verify them. Inter uses
"Semi Bold"; Archivo uses"SemiBold".listAvailableFontsAsync()first, always. - Load fonts before any text property, including
textAlignHorizontalon an existing node. Scripts are atomic — a font error means nothing ran, so fix and retry safely (figma-usegotchas: canonical text-edit recipe). - Never filter nodes by size to find your marks.
width <= 38also matched the 9px footer squares and moved them 440px. Track IDs; filter by name or ID. - Deleting a label deletes only the text. Its leader line and dot stay. Remove the whole triplet, or rebuild all captions after a re-layout rather than nudging.
- Verify mechanically, then screenshot. Read the board back (the read-only script
in references/place-and-verify.md) and run
scripts/verify-board.mjs --plan plan.json --board readback.json. It catches what eyes don't: frames still at 400×300, drift, inverted z-order, budget breaches, missing strokes, rotation. Thenget_screenshoton the board node atmaxDimension1400–1800 for what programs can't judge: collisions of meaning, stranded chrome, whether it is any good. Node-level screenshots (contentsOnly) are for detail, not verification. - Renders go to the user.
curlthe screenshot URL todesign/exports/and send the file; a description of a board is not a board. - Page deletion is explicit and last. Switch
currentPageto the survivor first (you cannot remove the current page), clone-don't-move when building alternatives, and remind the user that version history holds the deleted pages.
7. State ledger for long builds
Context gets summarised mid-build. Keep a ledger on disk from Phase 3 onward:
{ "file": "<fileKey>", "page": "44:3", "backdrop": "45:2",
"images": { "07-ref-collected-system": "44:5" },
"chrome": { "captions": ["38:2","38:3"], "vocab": ["47:2"] },
"exports": ["design/exports/board-v3.png"] }
Re-read it at the start of every turn; reconstruct by name (page.query('FRAME[name^=07-]'))
if it is missing. Idempotency is by node name — never re-upload an image whose name
already exists on the page.
8. Decision forks — ask, don't default
Ask when two arrangements are both defensible and the brief doesn't decide; present each with its cost. Do not ask about things the source decides (an image's aspect ratio, whether a dark image needs a stroke). Rejected work is never built on — if the user says "not jaunty angles", every rotation goes to zero before the next screenshot, not just the new ones.
References
- references/moodboard-composition.md — the three patterns with coordinates, grouping logic, and what each round taught.
- references/capture-to-canvas.md — shotcraft → upload → true-AR placement, end to end, with the dimension reader.
- references/skill-map.md — the official + community Figma skill landscape and what this skill deliberately leaves to them.
- references/lessons.md — the session log this skill was distilled from, kept as evidence for the rules in §6.
- references/place-and-verify.md — the last mile:
fill in node IDs, emit the two
use_figmascripts, read the board back, verify. scripts/stage-assets.mjs— folder/shortlist → slug-named copies + true dimensions → planner JSON (vetted: falseby construction). Exits 10 until subjects and arms are supplied.scripts/plan-layout.mjs— deterministic layout planner (grid / plus / loose), seeded--jitter, overlap budget, captions andvettedpassed through.scripts/emit-placement.mjs— plan → exactuse_figmascripts (backdrop, place, optional captions). Refuses unvetted images.scripts/verify-board.mjs— board read-back vs plan: size, position, rotation, z-order, overlap budget, strokes. Exit 10 with findings.scripts/verify-freshness.mjs—--offline: every skill the router names exists in the local plugin cache;--live: the community index vsskill-map.md.assets/plus-layout.example.json— the real 14-image Agntik fixture.assets/light-board.example.json— a second real fixture (8 light-ground captures, smaller centre, slug IDs, captions) so defaults are validated on more than one board.
The pipeline, end to end: shotcraft (or a folder) → stage-assets → look at every
image, set vetted, caption, arm → plan-layout → emit-placement (backdrop)
→ upload_assets → fill IDs → emit-placement (place) → read back → verify-board
→ get_screenshot → dress → send the render.
Files (claude-mods)
-
assets
-
light-board.example.json 2.6 KB
{ "_notes": "Second real fixture: 8 light-ground captures from four shotcraft library runs (Sep 2026), mixed section/viewport crops. Exists so the planner defaults are validated on a board that is not the one they were tuned on. ids are slugs (pre-upload) - exercises the name-as-identity path; captions and vetted:true exercise the passthrough.", "centre": { "w": 900, "h": 900 }, "images": [ { "id": "anthropic-macbook-home-menu--viewport", "name": "anthropic-macbook-home-menu--viewport", "w": 2560, "h": 1600, "arm": "N", "ring": 1, "vetted": true, "caption": "ANTHROPIC // HOME MENU VIEWPORT" }, { "id": "anthropic-macbook-claude--viewport", "name": "anthropic-macbook-claude--viewport", "w": 2560, "h": 1600, "arm": "N", "ring": 2, "vetted": true, "caption": "ANTHROPIC // CLAUDE VIEWPORT" }, { "id": "lovewestside-macbook-sectn-01-what-s-on--section", "name": "lovewestside-macbook-sectn-01-what-s-on--section", "w": 2560, "h": 1589, "arm": "S", "ring": 1, "vetted": true, "caption": "LOVEWESTSIDE // SECTN 01 WHAT S ON SECTION" }, { "id": "lovewestside-macbook-search-map--viewport", "name": "lovewestside-macbook-search-map--viewport", "w": 2560, "h": 1600, "arm": "S", "ring": 2, "vetted": true, "caption": "LOVEWESTSIDE // SEARCH MAP VIEWPORT" }, { "id": "australianballet-macbook-home--viewport", "name": "australianballet-macbook-home--viewport", "w": 2560, "h": 1600, "arm": "E", "ring": 1, "vetted": true, "caption": "AUSTRALIANBALLET // HOME VIEWPORT" }, { "id": "australianballet-macbook-our-dancers--viewport", "name": "australianballet-macbook-our-dancers--viewport", "w": 2560, "h": 1600, "arm": "E", "ring": 2, "vetted": true, "caption": "AUSTRALIANBALLET // OUR DANCERS VIEWPORT" }, { "id": "healthpartners-macbook-members--viewport", "name": "healthpartners-macbook-members--viewport", "w": 2560, "h": 1600, "arm": "W", "ring": 1, "vetted": true, "caption": "HEALTHPARTNERS // MEMBERS VIEWPORT" }, { "id": "healthpartners-macbook-dental--viewport", "name": "healthpartners-macbook-dental--viewport", "w": 2560, "h": 1600, "arm": "W", "ring": 2, "vetted": true, "caption": "HEALTHPARTNERS // DENTAL VIEWPORT" } ] } -
plus-layout.example.json 1.8 KB
{ "_notes": "Real fixture from the Agntik brand moodboard (Sep 2026): 14 references grouped by what they share. N = paper/document, S = product UI, E = full-field Pop orange, W = ink ground / display type. w/h are TRUE source pixel dimensions read from the file headers, never the 400x300 upload frame. Also the input for tests/run.sh.", "centre": { "w": 1040, "h": 1040 }, "images": [ { "id": "44:4", "name": "serro-wordmark", "w": 2560, "h": 1491, "arm": "N", "ring": 1 }, { "id": "44:5", "name": "collected-system", "w": 1920, "h": 1080, "arm": "N", "ring": 2 }, { "id": "44:6", "name": "factory-signal-deploy", "w": 2048, "h": 1152, "arm": "N", "ring": 3 }, { "id": "44:7", "name": "droid-display", "w": 2048, "h": 1152, "arm": "N", "ring": 4 }, { "id": "44:8", "name": "cloudflare-region-earth","w": 2560, "h": 1704, "arm": "S", "ring": 1 }, { "id": "44:9", "name": "conductorai-agentic", "w": 2560, "h": 1432, "arm": "S", "ring": 2 }, { "id": "44:10", "name": "conductorai-cases", "w": 2560, "h": 2258, "arm": "S", "ring": 3 }, { "id": "44:11", "name": "op1-ultra", "w": 2048, "h": 1152, "arm": "E", "ring": 1 }, { "id": "44:12", "name": "droids-active", "w": 2048, "h": 1152, "arm": "E", "ring": 2 }, { "id": "44:13", "name": "brasshands-04", "w": 2560, "h": 1491, "arm": "E", "ring": 3 }, { "id": "44:14", "name": "deploy-droids", "w": 2048, "h": 1152, "arm": "E", "ring": 4 }, { "id": "44:2", "name": "conductorai-hero", "w": 2560, "h": 1600, "arm": "W", "ring": 1 }, { "id": "44:15", "name": "brand-systems", "w": 2048, "h": 1152, "arm": "W", "ring": 2 }, { "id": "44:16", "name": "serro-field-report", "w": 2048, "h": 1152, "arm": "W", "ring": 3 } ] }
-
-
references
-
capture-to-canvas.md 4.2 KB
# Capture → canvas, end to end The mechanical half of the composition workflow: getting source imagery from the web (or from the user's folder) into a Figma file as correctly-sized frames with sensible layer names. Everything here is deterministic; the taste lives in [moodboard-composition.md](moodboard-composition.md). ## 1. Capture (shotcraft, or the user's own images) For live sites, `shotcraft` is the capture tool. Its three modes map onto what a board needs: | Mode | Script | Use for | |---|---|---| | Probe | `probe-site.mjs --url U --json` | Bot-wall detection (exit 8 → headed Chrome via `SHOTCRAFT_LAUNCH`) and page-set suggestion | | Sections | `probe-sections.mjs --url U --out DIR --top 7` | Structural section crops, ranked — the useful unit for boards | | Targeted | `capture.mjs --config C` with `elements[]`, `scrollTo`, `colorScheme` | Nav lockups, hero at viewport, dark-mode variants, mid-page moments | Two capture lessons: - **Element selectors are guesses until you have looked at the markup.** `"header"` timed out on two of six sites (sticky/zero-height). Inspect, then select. - **Full-page strips are for showreels, not boards.** A 2560×16886 page at 210px wide is a black column. Use the `--viewport` shot for the hero. Regenerate the shotcraft viewer (`contact-sheet.mjs --dir RUN` per run, then `--hub` over the library root) or the new runs will not appear in it — the hub is a static generated index. ## 2. Vet by eye Open every image before it goes near the canvas. The user's supplied comps are often the strongest material and the least described ("images attached" turned out to be eight designed brand comps, not the robot photos the captions implied). Write one line per image saying what it contributes — that line becomes its caption later. ## 3–4. Stage: slugs + true dimensions in one step `scripts/stage-assets.mjs` does both jobs the old way did by hand: ```bash node scripts/stage-assets.mjs --list shortlist.txt --dir E:/refs --out ./staged --json # → exit 10: every image listed with w/h and flags [needs-subject] / [needs-arm] ``` Look at the flagged images (Phase 2 — you were going to anyway), then supply the two human decisions as small JSON maps and re-run for exit 0: ```bash echo '{"IMG_0997.PNG":"collected system","IMG_0998.PNG":"serro field report"}' > names.json echo '{"07-ref-collected-system":"N","08-ref-serro-field-report":"W"}' > arms.json node scripts/stage-assets.mjs --list shortlist.txt --dir E:/refs --out ./staged \ --names names.json --arms arms.json --json > board.json ``` What it guarantees: originals untouched; copies named `NN-<source>-<subject>.<ext>` (the `NN` keeps upload order == layer order; shotcraft filenames are parsed into `<domain>-<page>`); dimensions read from the PNG/JPEG/GIF header, never from the upload frame; `board.json` is valid `plan-layout.mjs` input as-is (`id` is the slug until upload assigns a node ID — overwrite it then). If you need the header logic elsewhere: PNG width/height are big-endian uint32 at bytes 16 and 20; JPEG walks markers to the first SOFn (`C0`–`CF` except `C4/C8/CC`), height then width; GIF is little-endian uint16 at 6 and 8. ## 5. Upload 1. `upload_assets({ fileKey, count: N })` → N single-use `submitUrl`s (10-minute expiry). 2. POST each file: `curl -F "file=@path" "$url"` (multipart, `file` field). 3. Record `placedOnNodeId` per file in the ledger. Uploads land on whichever page is current for the upload tool; find by ID and reparent. Limits: 10 MB per asset, 60 URLs per call. SVGs import as vector trees (no fill semantics). ## 6. Resize to true aspect ratio, then place Every uploaded frame is 400×300 with `scaleMode: FILL`. One `use_figma` loop: `appendChild(parent)` → `rotation = 0` → `resize(w, h)` from the planner → `x, y` → hairline stroke. Append order is z-order; the planner's placement order already puts overlappers after what they overlap. ## 7. Verify `get_screenshot` on the backdrop node (`maxDimension` 1400–1800). Look for: images invisible against the ground (dark on dark — add stroke), frames still 400×300 (resize missed), overlaps beyond budget, anything the planner reported. Fix, then dress with captions and marks. -
lessons.md 4 KB
# Lessons — the session this skill was distilled from One brand moodboard (Agntik, Evolution 7, 2026-09-05): six live sites captured with shotcraft, eight user comps, fourteen images composed three ways in one Figma file. Each entry is a rule in SKILL.md §6 with the incident that earned it. Kept so a future edit to the rule can check it against the evidence. ## Access and accounts - **"You don't have edit access"** on both `get_metadata` and `get_screenshot` while the file opened fine in the browser. Cause: viewer share. Second cause, minutes later: the wrong account entirely — two Figma MCP servers were configured (plugin = one org, claude.ai connector = another), and the file lived in the org the plugin server could not see. `whoami` on each server settled it in one call. - Do not consolidate the two servers. A token binds to one account; consolidating turns every org switch into an interactive OAuth flow. ## Capture - Four `header` element crops timed out (`scrollIntoViewIfNeeded`) on two sites. A guessed selector, not a tool fault. Inspect markup before naming selectors. - The shell wrapper reported exit 0; `capture.mjs` had exited 10 (partial failures). Read the tool's exit, not the wrapper's. - The shotcraft hub at its `.lab` URL was a static index dated six weeks earlier. New runs were on disk and invisible. Regenerate per-run sheets and the hub after every capture; filed as a bug against shotcraft (serve-time regeneration). ## Upload and placement - All fourteen uploads landed as 400×300 `FILL` frames — including a 2560×16886 page strip. Dimensions must come from the file header; the frame lies. - That 16886px strip, squeezed to 210px wide, rendered as an empty outlined box. Parked off-board; replaced later by the viewport shot, which is what the moodboard had actually cited ("console logs as hero"). - Deleting the strip's *label* removed only the text node; its leader line and dot stayed behind as debris. Remove the triplet, or rebuild all captions. - A dark site on the petrol ground was invisible; diagnostics showed the fill was present and correct. A 1px hairline stroke was the fix, not a move. ## Composition rounds 1. **Organic first attempt** — rotations −3°…+2.5°, overlap by feel. Feedback: no jaunty angles; aligned; minimal overlap; drop the palette chips. 2. **Column grid** — clean, aligned, zero overlap. Feedback: too rigid; try a plus, grouped by similarity, larger toward the centre, some type on its own. 3. **Rigid plus** — axes drawn, vocabulary in the quadrants. Feedback: interesting, still rigid; looser; more clustered at the centre. 4. **Loose plus** — axes removed, inner ring overlapping the centre, chrome stripped from the centre block. Accepted. Cost of the arc: ~12 `use_figma` calls that were re-derivations of the same geometry. Hence `scripts/plan-layout.mjs`. ## Self-inflicted, caught by screenshot or by counts - Palette heading placed on top of a caption; a note placed over an image. Fixed by moving the caption above its image and lifting the block 92px (cleared by 9px — verified numerically, not by eye). - White and Pop swatches landed on the cream part of a comp: white-on-white. Fixed by clearing the band above. - Crop-mark repositioning filtered `width <= 38`; that also matched the 9px footer squares and moved them 440px. The returned count (8, not 4) was the tell. Track IDs; never select by size. - `textAlignHorizontal` on an existing node threw "unloaded font". Atomic failure, nothing changed; load the font, retry. - Centre block chrome (stroke, ticks, edge crosshairs, corner metadata) read as a broken box once the inner ring overlapped it. Strip it; float the wordmark. ## Hand-back - Every accepted round was rendered (`get_screenshot` → `curl` → `design/exports/`) and *sent* as a file. The description never substituted for the picture. - Old pages were deleted only when the user pasted their URLs and asked. Current page switched to the survivor first; version history noted as the recovery path. -
moodboard-composition.md 4.4 KB
# Moodboard composition — three patterns, one session The arrangements below were built in sequence for one brand board (Agntik, Sep 2026) and each one was the answer to the previous one's feedback. Kept in that order because the *order* is the lesson: a composition request usually needs two or three rounds, and knowing the likely next round saves one. All coordinates are backdrop-relative Figma units. All rotations are 0°. ## Grouping before geometry Every pattern started from the same sort. Thirteen images, characterised by what they shared, produced four clusters that later became the arms of the plus: | Cluster | Shared trait | Members | |---|---|---| | Paper / document | cream and bone grounds, ink type, registration marks — print objects | serro wordmark, COLLECTED, FACTORY, DROID | | Product UI | the actual websites; white, live data | Cloudflare, Agentic Search, Case Studies | | Pop field | full-bleed orange | OP-1, DROIDS ACTIVE, Brass Hands 04, DEPLOY DROIDS | | Ink ground | near-black grounds, big display type | Brand Systems, serro field report, ConductorAI hero | The fourth cluster had two members until the ConductorAI *viewport* shot replaced its unusable full-page strip — a reminder that a thin cluster is often a capture gap, not a grouping problem. ## Pattern 1 — column grid ("all elements should be aligned") Three columns at x = 70 / 833 / 1596, 733 wide, 30 gutters; hero images span two columns (1496). Every image at true aspect ratio; captions left-aligned to the column edge 14px below each image; a four-column vocabulary footer sharing the same outer margins (70 → 2330) so both grids resolve to the same edges. - What it solved: rotation and collisions from a first "organic" attempt. - What it lacked: a reason for the order. Ragged column ends read as omissions. - Keep for: reference sheets, equal-weight items, anything that will be scanned. ## Pattern 2 — rigid plus (axes drawn) Centre element 1040×1040 at the cross origin; four arms; ring widths stepping ~0.72× outward (N/S 1040 → 760 → 540 → 380; E/W 1040 → 700 → 480 → 330); 72 gap from the centre, 56 between rings; two hairline axes behind the arms ending in solid dots with a mono callout at each terminal; the four vocabulary categories in the four quadrants, anchored to the centre block's diagonal corners. - What it solved: meaning. Vertical = medium (paper ↑, screen ↓); horizontal = temperature (ink ←, Pop →). The plus is an argument, the images are evidence. - What it lacked: warmth. Drawn axes + symmetric rings read as a diagram. - Keep for: decks and rationale documents where the reader needs the structure stated. ## Pattern 3 — loose plus ("more organic, clustered around the centre") Same grouping, axes removed. Ring 1 overlaps the centre by 40px and is nudged off-axis (serro left, Cloudflare right, OP-1 up, hero down); ring 2 tucks onto ring 1's corner by ≤ 250×80; outer pieces stagger ±18% of their width off the axis; the wordmark floats in the eye with no block chrome; vocabulary moved to the backdrop's four corners, out of the cluster. - What it solved: the plus is implied by density, not drawn. Reads as composed. - The defect it introduced and how it was fixed: the centre block's stroke, ticks and corner metadata became fragments once images overlapped it. Stripping the chrome and floating the wordmark (196px, 111px clear each side) fixed it in one call. - Keep for: moodboards, brand boards, anything meant to be *felt* before it is read. `scripts/plan-layout.mjs --mode loose` reproduces this geometry from the image dimensions; `assets/plus-layout.example.json` is the exact input. ## Rules that survived all three rounds 1. Group first; geometry second. The arms/columns only work once the groups mean something. 2. Zero rotation unless asked. "Organic" was satisfied by offset and overlap. 3. Largest toward the centre, stepping ~0.72× per ring. 4. Overlap budget: ≤ 40px onto the centre, ≤ 250×80 onto a neighbour. 5. A typographic centre beats a borrowed image — the brand at the centre of its influences — but strip its chrome the moment images overlap it. 6. Dark images need a hairline stroke on a dark ground or they vanish. 7. The single-accent rule, if the brief has one, is enforced on the board itself: one Pop square, not a Pop panel. 8. Screenshot between every structural change; fix collisions before adding chrome. 9. Build each round on a new page; delete the old ones only when told to. -
place-and-verify.md 4.4 KB
# Place and verify — the last mile, generated and checked Everything between "I have a plan" and "the board is right" used to be hand-typed. Now it is three scripts and two `use_figma` calls, and the canvas is checked against the plan by a program rather than by squinting. ``` board.json ──plan-layout──▶ plan.json ──emit-placement──▶ use_figma (backdrop) │ │ └──emit-placement──▶ use_figma (place) │ read-back script (below) ──▶ readback.json │ verify-board ◀────────────┘ → exit 0 or findings ``` ## 1. Fill in node IDs after upload `plan.json` carries slugs as `id` until upload. After `upload_assets`, overwrite each `id` with the returned `placedOnNodeId`, and set `vetted: true` on every image you have actually opened and looked at. A one-liner with `jq` or a short node script — the point is that the plan file, not the conversation, is where these facts live. ## 2. Backdrop ```bash node scripts/emit-placement.mjs --plan plan.json --phase backdrop --page "Moodboard — Plus" --fill 002D3C ``` Paste the output into `use_figma` (with `figma-use` loaded). It creates or reuses the page, creates the backdrop at the plan's canvas size, and returns `{ pageId, backdropId }`. ## 3. Place ```bash node scripts/emit-placement.mjs --plan plan.json --phase place --backdrop 45:2 --captions ``` Paste into `use_figma`. The generated loop appends each image to the backdrop **in plan order (= z-order)**, zeroes rotation, resizes to the plan's true-aspect size, positions, strokes, and — with `--captions` — drops a mono caption under any image whose plan entry has a `caption`. It refuses to run if any image is `vetted: false` (exit 10 at generation time) unless you pass `--allow-unvetted`. ## 4. Read the board back Run this read-only script in `use_figma`, save the returned JSON as `readback.json`: ```js // figma-ops read-back — no mutations. BACKDROP_ID from the backdrop phase. const BACKDROP_ID = "45:2"; const page = figma.root.children.find(p => p.name === "Moodboard — Plus"); await figma.setCurrentPageAsync(page); const bg = await figma.getNodeByIdAsync(BACKDROP_ID); const children = bg.children.map((n, index) => ({ id: n.id, name: n.name, type: n.type, index, x: n.x, y: n.y, w: n.width, h: n.height, rotation: n.rotation, hasStroke: Array.isArray(n.strokes) && n.strokes.length > 0 })); return { backdrop: { id: bg.id, w: bg.width, h: bg.height }, children }; ``` `index` is the child's position in the backdrop — the canvas's actual z-order. ## 5. Verify ```bash node scripts/verify-board.mjs --plan plan.json --board readback.json ``` Exit 0 means every planned node is present, at plan size (±2px), at plan position, unrotated, inside the backdrop, stroked, with overlaps inside budget and stacking in plan order. Exit 10 lists what is wrong, one finding per line, with the fix implied by the kind: | Finding | Usually means | Fix | |---|---|---| | `size … still the 400x300 upload frame` | the place loop never ran for that node, or ran before the resize | re-run place for that id | | `position` | a later edit nudged it | re-run place, or update the plan if the nudge was intentional | | `z-order` | overlapper appended before its base | re-run place (plan order is z-order) | | `overlap` | manual moves broke the budget | re-plan with tighter `--corner`/`--tuck`, or accept and update `--budget` | | `stroke` | image added outside the emitter | add the hairline | | `missing` | wrong page, or the node was deleted | check `page`, re-upload | Then — and only then — `get_screenshot` for the things a program can't judge. ## Why this shape The session that produced this skill had two defects the eye missed and a program would not have: fourteen frames sitting at 400×300 (spotted only because a diagnostic call returned the numbers), and four 9px squares displaced 440px by a size-based filter (spotted because a returned count was 8 where 4 was expected). Both are one-line findings from `verify-board`. The screenshot still matters — it is the only judge of whether the board is *good* — but it should be the last check, not the first. -
skill-map.md 4.8 KB
# The Figma skill landscape — and what figma-ops leaves to it Surveyed 2026-09-05. Three families exist; this skill is a router over them plus the composition workflows none of them cover. ## Official plugin skills (Claude Code `figma` plugin) Cached under `~/.claude/plugins/cache/claude-plugins-official/figma/<ver>/skills/`. `figma-use` is the foundation every other one loads alongside. | Skill | Owns | Load it for | |---|---|---| | `figma-use` | Plugin API rules, `use_figma` gotchas, text-edit recipe, page rules, efficient APIs, incremental workflow, error recovery | **every** `use_figma` call | | `figma-generate-design` | Page/screen from app code; component discovery order (Code Connect → existing screens → `search_design_system`); parallel capture; "don't default to Inter" | composed views from a codebase | | `figma-generate-library` | Design systems: phase contract, state ledger on disk, idempotency by name, decision forks, token architecture | tokens, variants, libraries | | `figma-code-connect` | `.figma.ts` mappings | design ↔ code component binding | | `figma-create-new-file` | `create_new_file` prerequisites | blank files | | `figma-generate-diagram` | Mermaid → FigJam | diagrams | | `figma-use-figjam` / `figma-use-slides` / `figma-use-motion` | editor-specific API deltas | FigJam, Slides, animation | | `figma-implement-motion` | Figma motion → code | animation implementation | | `figma-swiftui` | SwiftUI ↔ Figma | iOS | ## Official community skills (`figma/community-resources/agent_skills`) Clustered by design domain; descriptions are outcome-first ("audits", "generates", "extracts"). `bulk-capture` captures many live pages in parallel via `generate_figma_design` — the nearest neighbour to this skill's capture phase. **Inventory (33, as of 2026-09-05).** `scripts/verify-freshness.mjs --live` diffs this list against the index; a name missing here is drift — add it, don't delete the check. | Category | Skills | |---|---| | Accessibility | `apca-compliance-figma`, `audit-accessibility-figma`, `lint-design-figma`, `scan-code-accessibility-figma` | | AI behaviour | `emote-behavioral-contracts` | | Components | `analyze-component-set-figma`, `arrange-component-set-figma`, `component-properties-figma`, `deep-component-figma`, `design-react-api`, `generate-component-doc-figma`, `reconstruct-component-figma` | | Design generation | `bridge-ds`, `build-slides-figma`, `bulk-capture` | | Design process | `annotations-figma`, `check-design-parity-figma`, `delight-audit`, `design-narrative`, `screens-to-ia` | | Design systems | `design-system-inventory-figma`, `ds-init-figma`, `ds-compliance-audit`, `export-tokens-figma`, `generate-tokens-from-figma`, `import-tokens-figma`, `library-variables-figma`, `manage-variables-figma`, `setup-design-tokens-figma` | | FigJam | `create-figjam-content`, `figjam-builder`, `workshop-board` | | Localisation | `localeflow` | ## southleft `figma-console-mcp-skills` (22) Tokens, components, a11y, versioning (REST + `$FIGMA_TOKEN`), docs, FigJam, Slides. Its organising principle is the one this skill adopts: *load `figma-use` alongside; it is the source of truth for the API; extend native capability, never replicate what the MCP tools already do.* ## Other community (patterns worth knowing) - **Hosseinkm89/figma-skills** — auto-layout refactor, layer rename, contrast audit. Symptom-based triggers ("this file has no auto-layout"); results delivered as a designed Figma page with jump links; one workflow per skill. - **nafiurrahmanniloy/figma-skill** — design → code for seven frameworks. - **Figma's "10 skills" blog** — `/better-interface`, `/component-handoff`, `/ui-state-expander`, `/superfuture-design-review` etc. Skills as reviewers and expanders, not just builders. ## The gap this skill fills Nothing above covers **capture → curate → compose**: bringing brand references, screenshots or a designer's own comps into Figma and arranging them with intent. Nor does anything route across the ~65 skills, or handle the two-account reality of agency work. `figma-ops` owns exactly those three things and cites the rest. ## Patterns borrowed (as ideas, not code) | From | Pattern | Where it lives here | |---|---|---| | `figma-generate-library` §1 | phase checklist → progress → summary | SKILL.md §3 | | `figma-generate-library` §4, §6 | disk ledger, idempotency by name, ask only at genuine forks, never build on rejected work | SKILL.md §7, §8 | | `figma-generate-design` | hard gates ("no mutation until discovery is done"), verify the product font | SKILL.md §3 gates, §6 | | southleft | extend `figma-use`, never replicate | the whole shape of this skill | | Hosseinkm89 | symptom-based triggers; render results, don't describe them | `description`; §3 Phase 6 | | `parallel-ops` (this repo) | a router skill for a family | SKILL.md §1 |
-
-
scripts
-
emit-placement.mjs 7.8 KB · in bundle
-
plan-layout.mjs 13.5 KB · in bundle
-
stage-assets.mjs 10.2 KB · in bundle
-
verify-board.mjs 6.9 KB · in bundle
-
verify-freshness.mjs 6.1 KB · in bundle
-
-
tests
-
check-invariants.mjs 2.3 KB · in bundle
-
run.sh 13.7 KB
#!/usr/bin/env bash # Self-test for figma-ops. # # Offline-deterministic (no Figma, no network). Exercises scripts/plan-layout.mjs # against the shipped fixture and asserts the documented exit codes, output shape, # and the geometric invariants the SKILL.md promises (true aspect ratio preserved, # every image inside the canvas margin, overlap budget honoured, grid has no # overlaps at all). Resolves paths relative to itself so it works in the repo and # once installed to ~/.claude/skills/figma-ops/. # # Usage: bash tests/run.sh # Exit: 0 all pass (or skipped: no node), 1 one or more failures # # Canvas behaviour (upload_assets, use_figma) cannot be tested offline; those rules # are enforced by the SKILL.md gates, not by this suite. set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SKILL="$(dirname "$HERE")" PLAN="$SKILL/scripts/plan-layout.mjs" FIX="$SKILL/assets/plus-layout.example.json" if ! command -v node >/dev/null 2>&1; then echo "figma-ops self-test: node not found — skipping (exit 0)"; exit 0 fi PASS=0; FAIL=0 ok() { PASS=$((PASS+1)); printf ' PASS %s\n' "$1"; } no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; } expect_exit() { [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; } echo "=== figma-ops self-test ===" # ── contract: --help, usage, bad input ─────────────────────────────────────── node "$PLAN" --help >/dev/null 2>&1; expect_exit "--help" 0 $? node "$PLAN" >/dev/null 2>&1; expect_exit "no --input is usage error" 2 $? node "$PLAN" --input "$FIX" --mode sideways >/dev/null 2>&1; expect_exit "unknown mode is usage error" 2 $? node "$PLAN" --input /nonexistent.json >/dev/null 2>&1; expect_exit "missing file is bad input" 3 $? echo '{"images":[{"id":"x","w":100}]}' | node "$PLAN" --input - >/dev/null 2>&1; expect_exit "missing h is bad input" 3 $? echo '{"images":[{"id":"x","w":100,"h":50,"arm":"Q"}]}' | node "$PLAN" --input - >/dev/null 2>&1; expect_exit "unknown arm is bad input" 3 $? # stdout must be data-only: JSON parses even when stderr has diagnostics OUT="$(node "$PLAN" --input "$FIX" --mode loose --json 2>/dev/null)"; RC=$? expect_exit "loose plan on fixture" 0 $RC node -e 'JSON.parse(require("fs").readFileSync(0,"utf8"))' <<<"$OUT" >/dev/null 2>&1 && ok "stdout is valid JSON" || no "stdout is not valid JSON" # ── invariants via a small node checker ────────────────────────────────────── check() { # $1 = mode, $2 = expected exit local mode="$1" want="$2" json rc json="$(node "$PLAN" --input "$FIX" --mode "$mode" --json 2>/dev/null)"; rc=$? expect_exit "mode $mode exit code" "$want" "$rc" # Checker is a separate file: a `node -` heredoc cannot also take the plan on # stdin (the second redirection wins and node executes the JSON as a script). node "$HERE/check-invariants.mjs" "$mode" "$FIX" <<<"$json" [[ $? -eq 0 ]] && ok "mode $mode invariants" || no "mode $mode invariants" } check loose 0 check plus 0 check grid 0 # ── budget breach surfaces as exit 10 (still emits output) ─────────────────── node "$PLAN" --input "$FIX" --mode loose --tuck 400 --budget 10x10 --json >/dev/null 2>&1 expect_exit "over-budget overlap exits 10" 10 $? # ── stage-assets.mjs ────────────────────────────────────────────────────────── STAGE="$SKILL/scripts/stage-assets.mjs" SB="$(mktemp -d)"; trap 'rm -rf "$SB"' EXIT # Git Bash mktemp returns /tmp/..., which node on Windows resolves against the current # drive (wrong). cygpath -m yields C:/... which both bash and node accept. command -v cygpath >/dev/null 2>&1 && SB="$(cygpath -m "$SB")" mkdir -p "$SB/src/shotcraft_example.com/macbook" "$SB/out" # Synthetic fixtures: a real 3x2 PNG (zlib via node), a JPEG that is only SOI+SOF0 # (the dimension reader walks markers, it never decodes pixels), and a GIF header. node - "$SB/src" <<'EOF' const fs = require('fs'), zlib = require('zlib'), path = require('path'); const dir = process.argv[2]; function crc(buf){let c=~0;for(const b of buf){c^=b;for(let k=0;k<8;k++)c=(c>>>1)^(0xEDB88320&-(c&1));}return ~c>>>0;} function chunk(t,d){const len=Buffer.alloc(4);len.writeUInt32BE(d.length);const td=Buffer.concat([Buffer.from(t),d]);const c=Buffer.alloc(4);c.writeUInt32BE(crc(td));return Buffer.concat([len,td,c]);} function png(w,h){const ihdr=Buffer.alloc(13);ihdr.writeUInt32BE(w,0);ihdr.writeUInt32BE(h,4);ihdr[8]=8;ihdr[9]=2;const raw=Buffer.alloc((1+w*3)*h);return Buffer.concat([Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]),chunk('IHDR',ihdr),chunk('IDAT',zlib.deflateSync(raw)),chunk('IEND',Buffer.alloc(0))]);} function jpg(w,h){const sof=Buffer.alloc(19);sof[0]=0xFF;sof[1]=0xC0;sof.writeUInt16BE(17,2);sof[4]=8;sof.writeUInt16BE(h,5);sof.writeUInt16BE(w,7);sof[9]=3;return Buffer.concat([Buffer.from([0xFF,0xD8]),sof,Buffer.from([0xFF,0xD9])]);} function gif(w,h){const b=Buffer.alloc(13);b.write('GIF89a',0,'ascii');b.writeUInt16LE(w,6);b.writeUInt16LE(h,8);return b;} fs.writeFileSync(path.join(dir,'shotcraft_example.com/macbook/abc123-example-macbook-sectn-02-hero--section.png'), png(3,2)); fs.writeFileSync(path.join(dir,'IMG_0001.JPG'), jpg(40,30)); fs.writeFileSync(path.join(dir,'brand-sheet.gif'), gif(8,4)); fs.writeFileSync(path.join(dir,'broken.png'), Buffer.from('not a png')); EOF node "$STAGE" --help >/dev/null 2>&1; expect_exit "stage --help" 0 $? node "$STAGE" --out "$SB/out" >/dev/null 2>&1; expect_exit "stage without input is usage error" 2 $? node "$STAGE" --dir "$SB/src" --out "$SB/out" >/dev/null 2>&1; expect_exit "corrupt png is bad input" 3 $? rm "$SB/src/broken.png" # no names/arms -> staged, but exit 10 with flags printf '%s\n' "$SB/src/shotcraft_example.com/macbook/abc123-example-macbook-sectn-02-hero--section.png" > "$SB/list.txt" J="$(node "$STAGE" --list "$SB/list.txt" --dir "$SB/src" --out "$SB/out" --json 2>/dev/null)"; RC=$? expect_exit "stage flags missing subject/arm with exit 10" 10 $RC node -e ' const j=JSON.parse(require("fs").readFileSync(0,"utf8")); const by=Object.fromEntries(j.images.map(i=>[i.name,i])); let f=0; const say=(o,m)=>{console.log(` ${o?"PASS":"FAIL"} [stage] ${m}`); if(!o)f++;}; say(j.images.length===3, "three images staged"); say(by["01-example-hero"] && by["01-example-hero"].w===3 && by["01-example-hero"].h===2, "shotcraft name -> 01-example-hero, PNG dims 3x2"); say(by["02-ref-needs-name"] && by["02-ref-needs-name"].w===40 && by["02-ref-needs-name"].h===30, "IMG_0001.JPG -> needs-name, JPEG dims 40x30"); say(by["03-ref-brand-sheet"] && by["03-ref-brand-sheet"].w===8 && by["03-ref-brand-sheet"].h===4, "brand-sheet.gif -> keeps subject, GIF dims 8x4"); say(by["02-ref-needs-name"].flags.includes("needs-subject"), "meaningless name is flagged needs-subject"); say(j.images.every(i=>i.flags.includes("needs-arm")), "every image flagged needs-arm without --arms"); say(require("fs").existsSync(by["01-example-hero"].file), "staged copy exists on disk"); process.exit(f?1:0)' <<<"$J" && ok "stage output shape" || no "stage output shape" [[ -f "$SB/src/IMG_0001.JPG" ]] && ok "originals untouched" || no "originals untouched" # names + arms supplied -> exit 0, planner accepts the output end to end echo '{"IMG_0001.JPG":"Collected System"}' > "$SB/names.json" echo '{"01-example-hero":"N","02-ref-collected-system":"E","03-ref-brand-sheet":"S"}' > "$SB/arms.json" rm -rf "$SB/out" node "$STAGE" --list "$SB/list.txt" --dir "$SB/src" --out "$SB/out" --names "$SB/names.json" --arms "$SB/arms.json" --json > "$SB/board.json" 2>/dev/null expect_exit "stage with names+arms exits 0" 0 $? grep -q '"name": "02-ref-collected-system"' "$SB/board.json" && ok "--names override becomes the slug" || no "--names override becomes the slug" node "$PLAN" --input "$SB/board.json" --mode loose --json >/dev/null 2>&1; expect_exit "planner accepts stage output" 0 $? grep -q '"vetted": false' "$SB/board.json" && ok "stage emits vetted:false by construction" || no "stage emits vetted:false by construction" # ── second fixture: defaults must hold on a board they were not tuned on ───── FIX2="$SKILL/assets/light-board.example.json" for m in loose plus grid; do node "$PLAN" --input "$FIX2" --mode "$m" --json 2>/dev/null > "$SB/fix2-$m.json"; expect_exit "second fixture plans in $m" 0 $? done grep -q '"caption"' "$SB/fix2-loose.json" && ok "caption passes through the planner" || no "caption passes through the planner" # ── seeded jitter: deterministic per seed, different across seeds ──────────── node "$PLAN" --input "$FIX" --mode loose --jitter 24 --seed 7 --json 2>/dev/null > "$SB/j7a.json" node "$PLAN" --input "$FIX" --mode loose --jitter 24 --seed 7 --json 2>/dev/null > "$SB/j7b.json" node "$PLAN" --input "$FIX" --mode loose --jitter 24 --seed 8 --json 2>/dev/null > "$SB/j8.json" cmp -s "$SB/j7a.json" "$SB/j7b.json" && ok "same seed -> identical plan" || no "same seed -> identical plan" cmp -s "$SB/j7a.json" "$SB/j8.json" && no "different seed -> different plan" || ok "different seed -> different plan" node "$PLAN" --input "$FIX" --mode loose --jitter 24 --seed 7 >/dev/null 2>&1; expect_exit "jittered plan stays within budget" 0 $? # ── emit-placement.mjs ──────────────────────────────────────────────────────── EMIT="$SKILL/scripts/emit-placement.mjs" node "$EMIT" --help >/dev/null 2>&1; expect_exit "emit --help" 0 $? node "$EMIT" --plan "$SB/j7a.json" --phase place >/dev/null 2>&1; expect_exit "emit place without --backdrop is usage" 2 $? node "$EMIT" --plan "$SB/j7a.json" --phase backdrop > "$SB/bd.js" 2>/dev/null; expect_exit "emit backdrop" 0 $? node "$EMIT" --plan "$SB/j7a.json" --phase place --backdrop 45:2 --captions > "$SB/pl.js" 2>/dev/null; expect_exit "emit place (real node ids)" 0 $? # generated code must be valid inside use_figma's async wrapper node -e 'const AF=Object.getPrototypeOf(async function(){}).constructor; for (const f of process.argv.slice(1)) new AF("figma", require("fs").readFileSync(f,"utf8"));' "$SB/bd.js" "$SB/pl.js" 2>/dev/null && ok "generated scripts parse as async use_figma bodies" || no "generated scripts parse as async use_figma bodies" grep -c '^ \["44:' "$SB/pl.js" | grep -q '^14$' && ok "place script carries all 14 ids" || no "place script carries all 14 ids" node "$EMIT" --plan "$SB/fix2-loose.json" --phase place --backdrop 45:2 >/dev/null 2>&1; expect_exit "slug ids (pre-upload) are refused as bad input" 3 $? node -e 'const p=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); p.placements[2].vetted=false; require("fs").writeFileSync(process.argv[2],JSON.stringify(p));' "$SB/j7a.json" "$SB/unvetted.json" node "$EMIT" --plan "$SB/unvetted.json" --phase place --backdrop 45:2 >/dev/null 2>&1; expect_exit "unvetted image is refused with exit 10" 10 $? node "$EMIT" --plan "$SB/unvetted.json" --phase place --backdrop 45:2 --allow-unvetted >/dev/null 2>&1; expect_exit "--allow-unvetted overrides" 0 $? # ── verify-board.mjs ────────────────────────────────────────────────────────── VERIFY="$SKILL/scripts/verify-board.mjs" node "$VERIFY" --help >/dev/null 2>&1; expect_exit "verify --help" 0 $? node - "$SB/j7a.json" "$SB/rb-good.json" "$SB/rb-bad.json" <<'EOF' const fs=require('fs');const [plan,good,bad]=process.argv.slice(2); const p=JSON.parse(fs.readFileSync(plan,'utf8')); const kids=p.placements.filter(x=>x.id!=='centre').map((x,i)=>({id:x.id,name:x.name,type:'FRAME',index:i,x:x.x,y:x.y,w:x.w,h:x.h,rotation:0,hasStroke:true})); fs.writeFileSync(good,JSON.stringify({backdrop:{id:'45:2',w:p.canvas.w,h:p.canvas.h},children:kids})); kids[3].w=400;kids[3].h=300; kids[5].rotation=2.5; [kids[7].index,kids[8].index]=[kids[8].index,kids[7].index]; kids[1].hasStroke=false; fs.writeFileSync(bad,JSON.stringify({backdrop:{id:'45:2',w:p.canvas.w,h:p.canvas.h},children:kids})); EOF node "$VERIFY" --plan "$SB/j7a.json" --board "$SB/rb-good.json" >/dev/null 2>&1; expect_exit "faithful read-back verifies clean" 0 $? R="$(node "$VERIFY" --plan "$SB/j7a.json" --board "$SB/rb-bad.json" --json 2>/dev/null)"; expect_exit "broken read-back exits 10" 10 $? for kind in size rotation z-order stroke; do grep -q "\"kind\": \"$kind\"" <<<"$R" && ok "verify reports $kind" || no "verify reports $kind" done grep -q '400x300 upload frame' <<<"$R" && ok "verify names the 400x300 trap" || no "verify names the 400x300 trap" # ── verify-freshness.mjs (offline only; --live is for the scheduled freshness run) ── FRESH="$SKILL/scripts/verify-freshness.mjs" node "$FRESH" --help >/dev/null 2>&1; expect_exit "freshness --help" 0 $? node "$FRESH" >/dev/null 2>&1; expect_exit "freshness without mode is usage" 2 $? # With a plugin cache present this asserts the router table is TRUE (exit 0); without # one it skips (also exit 0). Exit 10 here means SKILL.md §1 names a skill that no # longer exists in the installed Figma plugin — fix the table, don't relax the test. node "$FRESH" --offline >/dev/null 2>&1; expect_exit "router names only skills that exist (or skipped)" 0 $? # Synthetic cache: a router-named skill missing must be STALE (exit 10) mkdir -p "$SB/cache/figma-use"; : > "$SB/cache/figma-use/SKILL.md" node "$FRESH" --offline --cache "$SB/cache" >/dev/null 2>&1; expect_exit "missing routed skill in cache -> stale exit 10" 10 $? echo "=== $PASS passed, $FAIL failed ===" [[ $FAIL -eq 0 ]]
-
-
SKILL.md 16.6 KB
--- name: figma-ops description: "Router and composition workflows for Figma via the official Figma MCP: which Figma skill/tool for which job, capture-to-canvas (screenshots or shotcraft crops into a Figma file), moodboard and reference-board composition (grid, plus, loose-plus arrangements with a deterministic layout planner), canvas craft rules the API skills don't cover, and multi-account routing. Triggers on: figma, moodboard, brand board, reference board, put these screenshots in figma, compose in figma, arrange images in figma, plus arrangement, which figma skill, figma account, upload to figma, delete figma page." license: MIT when_to_use: "Any Figma request that isn't obviously a single official skill's job: 'put these screenshots in Figma', 'make a moodboard from these references', 'arrange this more organically', 'which Figma skill do I use for X', 'it says I don't have edit access', 'this file is in my other Figma account', 'delete the old pages'." argument-hint: "[route|compose|verify] [figma-url]" compatibility: "Official Figma MCP server (Claude Code figma plugin) for canvas work; Node 18+ for scripts/*.mjs; shotcraft optional for live-site capture" allowed-tools: "Read Write Bash Glob Grep AskUserQuestion" metadata: author: claude-mods related-skills: color-ops, icon-ops, frontend-design --- # Figma Operations The front door for Figma work. The official Figma plugin ships eleven skills that own the Plugin API surface; the community ships fifty more for tokens, components, a11y and docs. **This skill does not replicate any of them.** It does three things they don't: 1. **Routes** — says which skill or tool to load for a given job (§1). 2. **Composes** — capture → curate → arrange workflows for moodboards, reference boards and any "put these images in Figma, beautifully" request (§3–§5). 3. **Guards** — the canvas craft rules that sit *above* the API rules: aspect ratio, z-order, overlap budgets, verification loops, page hygiene (§6). > **Always load `figma-use` before any `use_figma` call.** It is the source of truth > for the Plugin API and its gotchas. This skill assumes it is loaded and cites its > references rather than restating them. ## 1. Router — which skill for which job | The job | Load | Tool it wraps | |---|---|---| | Any script that mutates or reads the canvas | `figma-use` (mandatory prerequisite) | `use_figma` | | Design → code, implement a screen | the design-to-code guidance served as MCP resource `skill://figma/figma-design-to-code/SKILL.md` (not a plugin skill) | `get_design_context`, `get_screenshot` | | SwiftUI ↔ Figma, either direction | `figma-swiftui` | `get_design_context`, `use_figma` | | Build a page/screen *from* app code | `figma-generate-design` + `figma-use` | `use_figma`, `generate_figma_design` | | Tokens, variables, component library | `figma-generate-library` + `figma-use` | `use_figma`, `search_design_system` | | Map components to code | `figma-code-connect` | Code Connect tools | | New blank file | `figma-create-new-file` | `create_new_file` | | Mermaid-class diagram in FigJam | `figma-generate-diagram` | `generate_diagram` | | FigJam board content | `figma-use-figjam` + `figma-use` | `use_figma` | | Slides deck | `figma-use-slides` + `figma-use` | `use_figma` | | Motion / animation | `figma-use-motion`, `figma-implement-motion` | `use_figma`, `get_motion_context` | | Images or SVGs into a file | **this skill §4** | `upload_assets` | | Moodboard, reference board, composed arrangement | **this skill §3–§5** | `upload_assets` + `use_figma` | | Live-site captures as source imagery | `shotcraft` → this skill §4 | — | | Design-token export/import, a11y audits, variable CRUD | community skills — see [references/skill-map.md](references/skill-map.md) | varies | Two routing rules the tables can't express: - **A read-only inspection comes first, always.** Pages, existing frames, fonts, naming conventions. `figma-use` §9 has the scripts. Never create before you have looked. - **`get_metadata` needs editor access, not viewer.** "You don't have edit access" on a file you can open in the browser means the share is view-only. Ask for editor, or check you are on the right account (§2). ## 2. Multi-account routing One Figma OAuth token binds to exactly one Figma account. If the user belongs to more than one org (agency + client, personal + work), the correct setup is **one MCP server per account** — typically the plugin server for one and a claude.ai connector for the other. They expose separate tool namespaces; pick the account per call by prefix. - Confirm identity with `whoami` on each server before assuming which file it can see. - Do **not** consolidate to one server: every account switch would become an interactive OAuth re-auth, impossible from a non-interactive session. - The durable fact is *mechanism → account* (plugin = X, connector = Y). Connector UUIDs change on re-add — never key notes on them. - A View-only team seat fails the same way as a view-only share. `whoami` lists seats. ## 3. Composition workflow — capture → curate → compose The phases, each with an exit gate. Post a short checklist before each phase and a summary after (the phase contract in `figma-generate-library` §1 is the model — visible progress, decisions surfaced, never silently defaulting). | Phase | Output | Exit gate | |---|---|---| | **0 Brief** | Sources list, art direction (2–3 sentences), palette/vocabulary if any, target file + page | User has confirmed the source list; blockers named (attached-not-sent images, login-walled sites) | | **1 Capture** | Screenshots / section crops / user-supplied images on disk, at true pixel dimensions | Every file exists; dimensions read from the file header, not assumed | | **2 Curate** | Shortlist with one line per image saying *what it contributes* | **Every image has been looked at by the agent.** Unvetted images never enter a composition | | **3 Upload** | Nodes in the target file, named after their source files | Node IDs returned and recorded (§7 ledger) | | **4 Arrange** | Images resized to true aspect ratio and placed per a chosen pattern (§5) | Screenshot reviewed; collisions and defects fixed *before* adding chrome | | **5 Dress** | Typography, hairlines, marks, labels — only what the brief's vocabulary calls for | Second screenshot; nothing overlaps text; single accent rule honoured if one exists | | **6 Hand back** | Rendered PNG sent to the user; page name; what was left open | The user sees the render, not just a description of it | **Hard gates:** - No `use_figma` mutation before Phase 0's source list is confirmed. - No image placed before it has been opened and looked at (Phase 2). The gate exists because the mistakes that slip through are obvious to eyes and invisible to exit codes. - No chrome (labels, rules, marks) before the bare arrangement screenshot is clean. - Build on a **new page**; never restructure the user's existing page in place. Deleting the old pages is a separate, explicit request (§6). ## 4. Capture → canvas **Sources.** Three kinds, in rising order of value for a moodboard: 1. Full-page screenshots — good for scroll strips, bad for boards (a 16,000px strip compressed to board width is an unreadable column; use the viewport shot instead). 2. Section crops — the useful unit. `shotcraft`'s `probe-sections.mjs` finds them structurally; `capture.mjs` with `elements[]`/`scrollTo` takes targeted ones. 3. Designed comps the user supplies — usually the strongest material; these are the aesthetic *executed*, not referenced. **Stage first.** `scripts/stage-assets.mjs` takes a folder and/or a shortlist, copies each image to a staging directory under a meaningful slug (`07-ref-collected-system.png`, never `IMG_0997.PNG` — the filename becomes the Figma layer name on upload), reads **true pixel dimensions from the file header**, and emits the planner's input JSON. It exits 10 until every image has a subject (`--names`) and an arm (`--arms`): the grouping is a human decision and the script refuses to guess it. ```bash node scripts/stage-assets.mjs --list shortlist.txt --dir ./refs --out ./staged \ --names names.json --arms arms.json --json > board.json ``` **Upload.** `upload_assets` with `count: N`, then POST each staged file as `multipart/form-data` with a `file` field. Record the returned `placedOnNodeId` per file into `board.json`'s `id` fields (§7 ledger). **The 400×300 trap.** Uploaded images land as 400×300 frames with `scaleMode: FILL`, which *crops*. The frame tells you nothing about the image; only the header does. `resize()` every frame to the planner's `w×h` before placing it. **Uploads land on whichever page is current for the upload tool** — not necessarily the page your last script switched to. Find them by ID and `appendChild` them where they belong. ## 5. Arrangement patterns Three patterns, in the order a session usually discovers them. Details, coordinates and the reasoning behind each choice live in [references/moodboard-composition.md](references/moodboard-composition.md). | Pattern | When | Character | |---|---|---| | **Column grid** | Reference sheet, equal-weight items, "make it aligned" | 3 columns + 2-col spans; every rotation 0; captions left-aligned to column | | **Rigid plus** | Groups have a *meaning* along each axis (medium ↑↓, temperature ←→) | Four arms from a centre element, ring sizes stepping ~0.72× outward, axes drawn as hairlines with terminal dots | | **Loose plus** | "Organic", "clustered around the centre", "loosely a plus" | Same grouping, axes *removed*, inner ring overlapping the centre by ~40px and nudged off-axis, second ring tucked onto the inner ring's corners (≤ 250×80), wordmark floating in the eye | Rules that held across all three: - **Rotation is opt-in.** Zero degrees unless the user asks for angles; "organic" means offset and overlap, not tilt. - **Group by what images share** — type, aesthetic, or colour — and let the axes *mean* something. A plus with arbitrary arms is just a cross. - **Sizes step toward the centre.** Largest adjacent to the centre, ~0.72× per ring. - **Overlap has a budget.** Inner ring onto centre ≤ 40px; neighbour corners ≤ 250×80. The planner reports every overlap and exits 10 when one exceeds budget. - **A centre element is allowed to be typographic.** The brand at the centre of its influences reads better than a borrowed image there — but once images overlap it, strip its chrome (strokes, ticks, metadata) or it reads as a broken box. - **Vocabulary in the quadrants or corners, never in the cluster.** Plan coordinates with the script rather than by hand, and generate the placement script rather than typing it: ```bash node scripts/plan-layout.mjs --input board.json --mode loose --jitter 24 --seed 7 --json > plan.json node scripts/emit-placement.mjs --plan plan.json --phase backdrop # → use_figma node scripts/emit-placement.mjs --plan plan.json --phase place --backdrop 45:2 --captions ``` `plan-layout` emits backdrop-relative `{id, x, y, w, h}` placements in z-order, canvas size, and an overlap report (exit 10 over budget). `--jitter` adds seeded irregularity for "organic" without losing reproducibility — same seed, same plan. `emit-placement` turns the plan into the exact `use_figma` script, and **refuses any image whose plan entry is `vetted: false`** — the look-at-it gate as data, not memory. Full flow in [references/place-and-verify.md](references/place-and-verify.md). ## 6. Canvas craft rules (above the API) Learned the expensive way; each one cost a round-trip this skill now saves. - **Hairline stroke on every dark image.** A dark site on a dark ground disappears; a 1px `SOFT` 45–55% stroke, `strokeAlign: OUTSIDE`, defines the edge. Check `fills` before concluding an image "didn't render". - **Z-order is append order.** Anything that overlaps must be appended *after* what it sits on. Send axis lines to the back with `insertChild(0, …)`. - **Font style names are per-family, verify them.** Inter uses `"Semi Bold"`; Archivo uses `"SemiBold"`. `listAvailableFontsAsync()` first, always. - **Load fonts before *any* text property**, including `textAlignHorizontal` on an existing node. Scripts are atomic — a font error means nothing ran, so fix and retry safely (`figma-use` gotchas: canonical text-edit recipe). - **Never filter nodes by size to find your marks.** `width <= 38` also matched the 9px footer squares and moved them 440px. Track IDs; filter by name or ID. - **Deleting a label deletes only the text.** Its leader line and dot stay. Remove the whole triplet, or rebuild all captions after a re-layout rather than nudging. - **Verify mechanically, then screenshot.** Read the board back (the read-only script in [references/place-and-verify.md](references/place-and-verify.md)) and run `scripts/verify-board.mjs --plan plan.json --board readback.json`. It catches what eyes don't: frames still at 400×300, drift, inverted z-order, budget breaches, missing strokes, rotation. *Then* `get_screenshot` on the board node at `maxDimension` 1400–1800 for what programs can't judge: collisions of meaning, stranded chrome, whether it is any good. Node-level screenshots (`contentsOnly`) are for detail, not verification. - **Renders go to the user.** `curl` the screenshot URL to `design/exports/` and send the file; a description of a board is not a board. - **Page deletion is explicit and last.** Switch `currentPage` to the survivor first (you cannot remove the current page), clone-don't-move when building alternatives, and remind the user that version history holds the deleted pages. ## 7. State ledger for long builds Context gets summarised mid-build. Keep a ledger on disk from Phase 3 onward: ```json { "file": "<fileKey>", "page": "44:3", "backdrop": "45:2", "images": { "07-ref-collected-system": "44:5" }, "chrome": { "captions": ["38:2","38:3"], "vocab": ["47:2"] }, "exports": ["design/exports/board-v3.png"] } ``` Re-read it at the start of every turn; reconstruct by name (`page.query('FRAME[name^=07-]')`) if it is missing. Idempotency is by node name — never re-upload an image whose name already exists on the page. ## 8. Decision forks — ask, don't default Ask when two arrangements are both defensible and the brief doesn't decide; present each with its cost. Do **not** ask about things the source decides (an image's aspect ratio, whether a dark image needs a stroke). Rejected work is never built on — if the user says "not jaunty angles", every rotation goes to zero before the next screenshot, not just the new ones. ## References - [references/moodboard-composition.md](references/moodboard-composition.md) — the three patterns with coordinates, grouping logic, and what each round taught. - [references/capture-to-canvas.md](references/capture-to-canvas.md) — shotcraft → upload → true-AR placement, end to end, with the dimension reader. - [references/skill-map.md](references/skill-map.md) — the official + community Figma skill landscape and what this skill deliberately leaves to them. - [references/lessons.md](references/lessons.md) — the session log this skill was distilled from, kept as evidence for the rules in §6. - [references/place-and-verify.md](references/place-and-verify.md) — the last mile: fill in node IDs, emit the two `use_figma` scripts, read the board back, verify. - `scripts/stage-assets.mjs` — folder/shortlist → slug-named copies + true dimensions → planner JSON (`vetted: false` by construction). Exits 10 until subjects and arms are supplied. - `scripts/plan-layout.mjs` — deterministic layout planner (grid / plus / loose), seeded `--jitter`, overlap budget, captions and `vetted` passed through. - `scripts/emit-placement.mjs` — plan → exact `use_figma` scripts (backdrop, place, optional captions). Refuses unvetted images. - `scripts/verify-board.mjs` — board read-back vs plan: size, position, rotation, z-order, overlap budget, strokes. Exit 10 with findings. - `scripts/verify-freshness.mjs` — `--offline`: every skill the router names exists in the local plugin cache; `--live`: the community index vs `skill-map.md`. - `assets/plus-layout.example.json` — the real 14-image Agntik fixture. - `assets/light-board.example.json` — a second real fixture (8 light-ground captures, smaller centre, slug IDs, captions) so defaults are validated on more than one board. The pipeline, end to end: shotcraft (or a folder) → `stage-assets` → look at every image, set `vetted`, `caption`, `arm` → `plan-layout` → `emit-placement` (backdrop) → `upload_assets` → fill IDs → `emit-placement` (place) → read back → `verify-board` → `get_screenshot` → dress → send the render.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.