|
| 1 | +# Figma MCP |
| 2 | + |
| 3 | +Mandatory reading whenever you are implementing UI from Figma through the Figma MCP (`get_design_context`, |
| 4 | +`get_metadata`, `get_screenshot`, `use_figma`) or when the user provides a `figma.com/...` URL. This file only |
| 5 | +covers the translation layer — the rest of the Mistica docs (`patterns.md`, `components.md`, `layout.md`, |
| 6 | +`design-tokens.md`) still apply. |
| 7 | + |
| 8 | +## Prime directive: read the DOM verbatim |
| 9 | + |
| 10 | +The Figma MCP response gives you two things: |
| 11 | + |
| 12 | +- a **screenshot** that shows what the design should look like |
| 13 | +- a **DOM** (React + Tailwind) that shows what the designer specified |
| 14 | + |
| 15 | +Use the DOM as the source of truth for every numeric and structural decision. The screenshot only validates |
| 16 | +that your implementation matches the designer's intent. |
| 17 | + |
| 18 | +**If you cannot point at a line in the DOM to justify a value, do not write that value.** Do not pick "nearby |
| 19 | +nicer" numbers. Do not default to Mistica's 16 / 24 / 32 vertical rhythm when the DOM is explicit. If Figma |
| 20 | +says `72`, use `72`; if Figma says `justify-between`, use `space="between"` — not an invented fixed gap. |
| 21 | + |
| 22 | +## Mapping Figma flex to Mistica layout primitives |
| 23 | + |
| 24 | +| Figma / Tailwind | Mistica | |
| 25 | +| --------------------------------------- | --------------------------------------------------- | |
| 26 | +| `flex gap-[Npx]` (vertical, `flex-col`) | `Stack space={N}` | |
| 27 | +| `flex gap-[Npx]` (horizontal) | `Inline space={N}` | |
| 28 | +| `justify-between` | `Inline space="between"` | |
| 29 | +| `justify-around` | `Inline space="around"` | |
| 30 | +| `justify-evenly` | `Inline space="evenly"` | |
| 31 | +| `items-center` | `alignItems="center"` on `Inline` | |
| 32 | +| `flex-wrap` | `wrap` on `Inline` | |
| 33 | +| `p-[Npx]` / `px-[Npx]` / `py-[Npx]` | `Box padding={N}` / `paddingX={N}` / `paddingY={N}` | |
| 34 | +| `rounded-[var(--radii/container,...)]` | `Boxed` (or `skinVars.borderRadii.container`) | |
| 35 | +| `bg-[var(--background...)]` | `ResponsiveLayout variant` or `Boxed variant` | |
| 36 | + |
| 37 | +Each spacing primitive has its own allowed scale. Figma values outside the scale must be rounded to the |
| 38 | +nearest allowed value and noted — never silently apply arbitrary CSS. |
| 39 | + |
| 40 | +| Primitive | Allowed values | |
| 41 | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | |
| 42 | +| `Box` `padding*` | `0 \| 2 \| 4 \| 8 \| 12 \| 16 \| 20 \| 24 \| 32 \| 40 \| 48 \| 56 \| 64 \| 72 \| 80` | |
| 43 | +| `Stack` `space` | `0 \| 2 \| 4 \| 8 \| 12 \| 16 \| 24 \| 32 \| 40 \| 48 \| 56 \| 64 \| 72 \| 80` + `"between" \| "around" \| "evenly"` | |
| 44 | +| `Inline` `space` | `-16 \| -12 \| -8 \| -4 \| -2 \| 0 \| 2 \| 4 \| 8 \| 12 \| 16 \| 24 \| 32 \| 40 \| 48 \| 56 \| 64` + `"between" \| "around" \| "evenly"` | |
| 45 | + |
| 46 | +`Inline` notably allows negative values and caps at 64; `Stack` starts at 0 and caps at 80; `Box` includes |
| 47 | +`20` which the others don't. If Figma says `gap-[10px]`, it doesn't fit any of these — round to `8` or `12` |
| 48 | +and flag it, don't invent a CSS override. |
| 49 | + |
| 50 | +## The single-child gap trap |
| 51 | + |
| 52 | +A `flex gap-[Npx]` class applied to a wrapper that has **only one child** renders no space — gap is between |
| 53 | +siblings, not around a solo child. Figma's MCP output often stacks wrappers this way: |
| 54 | + |
| 55 | +```tsx |
| 56 | +<div className="flex gap-[72px] ..."> |
| 57 | + <div className="flex ..."> |
| 58 | + {' '} |
| 59 | + {/* single child */} |
| 60 | + <FirstColumn /> |
| 61 | + </div> |
| 62 | + <div className="flex gap-[48px] ..."> |
| 63 | + {' '} |
| 64 | + {/* single child — the 48 never renders */} |
| 65 | + <SecondColumn /> |
| 66 | + </div> |
| 67 | + <div className="flex gap-[48px] ..."> |
| 68 | + {' '} |
| 69 | + {/* single child — the 48 never renders */} |
| 70 | + <ThirdColumn /> |
| 71 | + </div> |
| 72 | +</div> |
| 73 | +``` |
| 74 | + |
| 75 | +The real spacing here is `72px` (from the outer wrapper). The `48px` on each inner wrapper is dead CSS. When |
| 76 | +you see this pattern, use the parent's gap and ignore the child wrappers' gaps. |
| 77 | + |
| 78 | +## Don't snap Figma values to Mistica's rhythm |
| 79 | + |
| 80 | +Mistica's 16 / 24 / 32 vertical-rhythm guidance in `patterns.md` and `layout.md` is for **greenfield |
| 81 | +composition** — UI you are designing yourself. It is not a reason to override explicit Figma values. When the |
| 82 | +DOM specifies a spacing, use it literally. |
| 83 | + |
| 84 | +## Tokens over literal values |
| 85 | + |
| 86 | +The MCP output often contains hex colors (`#262423`), CSS custom properties |
| 87 | +(`var(--backgroundalternative,#fefaf5)`), and raw border-radius values |
| 88 | +(`rounded-[var(--radii/container,16px)]`). These must be translated: |
| 89 | + |
| 90 | +- Colors → `skinVars.colors.*` (or `skinVars.rawColors.*` with `applyAlpha`) |
| 91 | +- Border radii → `skinVars.borderRadii.*`, or a Mistica component that handles it (`Boxed`, cards) |
| 92 | +- Spacing tokens → `skinVars.spacing.*` where applicable |
| 93 | + |
| 94 | +Never keep a hex literal or a `var(--...)` reference in app code. If the right token doesn't exist, the design |
| 95 | +is ahead of the skin — flag it and extend the skin instead of hardcoding. |
| 96 | + |
| 97 | +## Fonts |
| 98 | + |
| 99 | +Ignore per-node `font-[family-name:var(--fontfamily/fontfamily,'Movistar_Sans:Medium',...)]` and |
| 100 | +`font-['On_Air:Regular',...]` classes. Font family is set **once globally** under `ThemeContextProvider` via |
| 101 | +`GlobalStyles` — the active skin's font (see `fonts.md`) is the source of truth. Per-node font families in the |
| 102 | +MCP output are leaked style from the Figma file, not designer intent. |
| 103 | + |
| 104 | +Font weight is handled by the Mistica text components (`Text1`-`Text4` accept `light` / `regular` / `medium` / |
| 105 | +`bold`; `Text5`-`Text10` and `Title1`-`Title4` have a fixed weight per skin). Map Figma's `font-weight/text5` |
| 106 | +to the matching component (e.g. `Text5`), not to a CSS `font-weight`. |
| 107 | + |
| 108 | +## `CodeConnectSnippet` wrappers: gather before you choose |
| 109 | + |
| 110 | +MCP responses wrap mapped components in `<CodeConnectSnippet>`. **The snippet is never authoritative.** It's a |
| 111 | +hint about which Mistica component the designer used — not a source of truth for any specific prop value. |
| 112 | +Individual values inside it may happen to be correct, may be stale placeholders, or may look right but map to |
| 113 | +the wrong semantic slot. You cannot tell reliably from the snippet alone which is which, so don't try. |
| 114 | + |
| 115 | +Don't classify snippet values into "trustworthy" and "untrustworthy". The classification is itself where |
| 116 | +mistakes come from. Instead: for every composite component, gather all available information first, then pick |
| 117 | +props from the combined picture. |
| 118 | + |
| 119 | +### How to gather |
| 120 | + |
| 121 | +For any CodeConnect-wrapped **composite component** — one with multiple content slots (`headline`, `pretitle`, |
| 122 | +`title`, `subtitle`, `description`, `extra`, `slot`, `buttonPrimary`, `buttonSecondary`, `buttonLink`, |
| 123 | +`asset`, etc.) — re-fetch the node with Code Connect disabled before mapping props: |
| 124 | + |
| 125 | +``` |
| 126 | +get_design_context({ |
| 127 | + nodeId: "<the collapsed node id>", |
| 128 | + fileKey: "<same fileKey>", |
| 129 | + disableCodeConnect: true, |
| 130 | + excludeScreenshot: true // optional, saves tokens if you already have one |
| 131 | +}) |
| 132 | +``` |
| 133 | + |
| 134 | +That returns the real child tree: the actual text nodes, their font-size tokens, the actual image aspect |
| 135 | +ratio, Tag instances with their `type` (e.g. the `--tagbackgroundinfo` CSS variable tells you `type="info"`), |
| 136 | +child slots that correspond to `extra` / `slot` / `headline`, sibling buttons, etc. |
| 137 | + |
| 138 | +Use `get_metadata` on the same node when you also need to understand which children are component instances |
| 139 | +vs. raw nodes. |
| 140 | + |
| 141 | +The usual composites: `Hero`, `CoverHero`, `Header`, `CoverCard`, `MediaCard`, `DataCard`, `NakedCard`, |
| 142 | +`PosterCard`, `DisplayMediaCard`, `Callout`, `EmptyState`, `EmptyStateCard`, `Row`, `BoxedRow`, anything with |
| 143 | +a slot. Pure atoms (`IconTruckRegular` and similar, plain `ButtonPrimary`/`ButtonSecondary`/`ButtonLink`) have |
| 144 | +a single content slot — the snippet plus the screenshot is usually enough, but if anything looks off, drill in |
| 145 | +anyway. |
| 146 | + |
| 147 | +### How snippets go wrong |
| 148 | + |
| 149 | +Some failure modes to keep in mind — not as a checklist of things to detect, but as reasons to gather the |
| 150 | +underlying data rather than read values off the snippet: |
| 151 | + |
| 152 | +- **Stub content** — `title="Title"`, `description="Description"`, `imageSrc="https://example.com/image.jpg"`, |
| 153 | + `aspectRatio="16:9"` on non-landscape media. |
| 154 | +- **Values that happen to match the schema but don't match the design** — `variant="default"` on a card that |
| 155 | + actually renders inverse; `type="info"` where the real node resolves to `--tagbackgroundpromo`; |
| 156 | + `size="default"` on a snap-size card. These pass type checks silently. |
| 157 | +- **Mis-mapped content slots** — a price block under a title looks like it belongs in `description`, but the |
| 158 | + real node lives in a separate content slot that maps to `extra`. The snippet may say `description="..."`, |
| 159 | + may omit it, may stub it — only the real DOM tells you the correct prop. |
| 160 | +- **Stale prop names after an API change** — `backgroundImage` on `CoverCard` when the current API is |
| 161 | + `imageSrc`; `isInverse` where `variant` is now preferred. |
| 162 | +- **Noisy artifacts** — `🔄ReplaceSlot="5267:4885"`, `asset="ERROR"`, `footer="false"` (string instead of |
| 163 | + boolean), conflicting component imports at the top of the output. |
| 164 | + |
| 165 | +### Rule of thumb |
| 166 | + |
| 167 | +Every prop value you write — text, enum, aspect ratio, boolean — should be something you picked after reading |
| 168 | +the real (non-CodeConnect) DOM, not something you copied from the snippet. If you can't say which node in the |
| 169 | +drilled-in DOM justifies the value, gather more before committing it. |
| 170 | + |
| 171 | +## Assets: always download, store, and serve locally |
| 172 | + |
| 173 | +Figma MCP asset URLs (`https://www.figma.com/api/mcp/asset/<uuid>`) are valid for only ~7 days. Do **not** |
| 174 | +inline them anywhere — not in committed code, not in dev-only code, not as "temporary" placeholders. Every |
| 175 | +time you would paste a Figma MCP URL, download the asset first, save it into the project (e.g. |
| 176 | +`public/images/...` or `src/assets/...`), and reference the local path. |
| 177 | + |
| 178 | +Do **not** substitute unrelated stock photos (Unsplash, Picsum, Lorem Picsum, etc.) for the designer's assets. |
| 179 | +The real images are part of the design — an Xbox controller in the Figma means Xbox, not a random |
| 180 | +phone-on-a-table from a stock library. Substituting unrelated images is the same failure class as picking an |
| 181 | +unrelated spacing value. |
| 182 | + |
| 183 | +The right workflow for every image in a Figma design: |
| 184 | + |
| 185 | +1. Drill into the node (per the composite section above) to get the real asset URL — initial CodeConnect stubs |
| 186 | + often use `example.com/image.jpg` placeholders that hide the actual URL. |
| 187 | +2. Download the file (`curl -o public/images/<name>.<ext> <mcp-asset-url>`). Use names that describe the |
| 188 | + content (`hero-fibra.png`, `partner-eurosport.svg`), not the Figma UUID. |
| 189 | +3. Reference it from code via the local path (e.g. `/images/hero-fibra.png` in Vite projects, `/images/...` or |
| 190 | + `import heroFibra from './assets/hero-fibra.png'` in bundler-aware setups). |
| 191 | +4. If you cannot resolve an asset — the URL 404s, the node has no fill, the design legitimately has no image |
| 192 | + there — say so explicitly and ask, rather than inventing a stock replacement. |
| 193 | + |
| 194 | +The only acceptable exception is when the Figma file itself uses a stub URL (`https://example.com/image.jpg`), |
| 195 | +in which case drilling in confirmed there is no real asset, and a placeholder is appropriate — but it should |
| 196 | +still be a committed asset in the project (e.g. a branded silhouette or a neutral grey rectangle), not a live |
| 197 | +external URL. |
| 198 | + |
| 199 | +## Verification checklist |
| 200 | + |
| 201 | +Before closing out a section, be able to justify every decision against the DOM: |
| 202 | + |
| 203 | +- [ ] For every composite component on the page (card, hero, header, callout, row…), you fetched the node with |
| 204 | + `disableCodeConnect: true` before writing props — regardless of whether the stub looked filled in or |
| 205 | + not. |
| 206 | +- [ ] Every text node in the screenshot maps to a specific Mistica prop that you can point at in the real |
| 207 | + (non-CodeConnect) DOM. The price line could be `description`, `extra`, or a custom slot; only the |
| 208 | + drilled-in DOM tells you which. |
| 209 | +- [ ] Every non-text prop (`aspectRatio`, `size`, `variant`, image URL, boolean flags) traces to an attribute |
| 210 | + you read from the real DOM, not from the CodeConnect stub. These match the schema even when wrong and |
| 211 | + typechecks won't catch them. |
| 212 | +- [ ] Every `space={N}` / `padding={N}` / `gap={N}` traces to a `gap-[Npx]` / `p-[Npx]` class on a wrapper |
| 213 | + that actually renders it (not a single-child wrapper), or to a nearest-scale round with a one-line |
| 214 | + comment explaining the original value. |
| 215 | +- [ ] Every `space="between" | "around" | "evenly"` traces to the matching `justify-*` class. |
| 216 | +- [ ] Every color uses `skinVars.colors.*`, not a hex or `var(--...)` literal. |
| 217 | +- [ ] Every font decision comes from the global `GlobalStyles` + the skin's defaults, not from a per-node |
| 218 | + class. |
| 219 | +- [ ] Every mapped component uses current Mistica props, not the literal attribute list from the |
| 220 | + `CodeConnectSnippet`. |
| 221 | + |
| 222 | +If you can't check an item off against the DOM, re-read the DOM (with `disableCodeConnect: true` if the node |
| 223 | +is CodeConnect-mapped) before committing the value. |
0 commit comments