diff --git a/figma-limitless-mcp/.gitignore b/figma-limitless-mcp/.gitignore index 048e21e..c6417ff 100644 --- a/figma-limitless-mcp/.gitignore +++ b/figma-limitless-mcp/.gitignore @@ -176,6 +176,11 @@ binaries/ # Server build output server/dist/ +# Extras companion (FigJam/Slides/Buzz): copies of the built bundle (kept in +# sync by plugin/scripts/sync-extras.mjs). The manifest.json itself IS tracked. +plugin/extras/code.js +plugin/extras/index.html + # Editor/IDE .idea/ .vscode/ diff --git a/figma-limitless-mcp/AGENTS.md b/figma-limitless-mcp/AGENTS.md index f9bbb7d..b5a89bf 100644 --- a/figma-limitless-mcp/AGENTS.md +++ b/figma-limitless-mcp/AGENTS.md @@ -37,6 +37,67 @@ Agent identity defaults to MCP clientInfo (`node.agentSupplier`); tools accept a APIs are beta ("subject to change" per Plugin API Update 127, June 2026). Verified live 2026-07-13: Motion styles enumerate; shaders API present (0 shaders in file). +## Design-system skills + structure linter (closed loop) + +Two offline halves that answer "how should the tokens be derived?" and "is the +structure right?" — no network, no Figma AI credits. + +- **Knowledge** — Markdown skills bundled in `server/skills/`, copied to + `dist/skills/` at build (`scripts/copy-skills.mjs`) and served by + `server/src/skills.ts`: `list_skills` (catalog), `read_skill(slug)` (full doc, + whitelisted slug — no path traversal), `get_build_recipe(step?)` (the canonical + Primitive→Semantic→Component order + the step's **actionable lint gate**). +- **Linter** — `lint_design_system` runs 51/57 detectors over a `LintSnapshot`. The + plugin's `lint_run` gathers the snapshot (variable graph + styles + components + + node bindings, after `loadAllPagesAsync()`) — plus **per-component enrichment** + from one bounded DFS (20k-node budget + `componentScanTruncated`): TEXT-style + `fontSize`, and per-component AGGREGATES (raw-paint/untyped-text booleans, min + font size, referenced-property-key union, variant tuples) — never raw node + trees, so the payload stays small. The DFS treats nested instances/components as + boundaries (their layers belong to their own root), so defects aren't + mis-attributed. The server runs the detectors, which are **pure functions** + `(snap, config?) => PartialFinding[]` in `server/src/lint/detectors/.ts`, + registered into the `DETECTORS` map via the `detectors/register.ts` side-effect + that `lint/index.ts` imports. Enrichment-driven detectors degrade to silent when + their fields are absent (old plugin build). Remaining 6 rules are deferred + (instance-override / frame-fingerprint / exportAsync / Code-Connect data). + +**The closed loop:** `get_build_recipe(step)` → build that tier → run the gate's +`run` call (`lint_design_system {only:[…]}`) → fix `severity:error` findings → +advance to `next_step`. `buildGate` splits each gate into `enforced_now` vs +`forward_declared` from the live registry at runtime, so a gate never asks the +lint tool for a rule whose detector hasn't landed. + +**Philosophy — advise, don't dictate (load-bearing).** Everyone's DS structure is +their own choice. Only *objectively broken* things are `severity:error` +(alias-target-resolves, scope-legal-for-resolved-type); every opinionated rule is +`warn`. The linter is **read-only — it never mutates the design system.** Rules +must only assert what they can prove: e.g. a11y contrast pairs fg↔bg *only* via +the explicit `on-` naming convention, never by guessing from suffixes (loose +matching fabricated 53 findings on a real 3-tier DS that just doesn't use `on-X`). + +**Opt-in config surface.** House-style / config-required rules (`semantic-role-allowlist`, +`top-segment-in-tier-vocabulary`, `numeric-scale-zero-padded`, `codesyntax-web-matches-name`) +are marked `defaultOn:false` in the registry and run ONLY when explicitly enabled +— so the default lint stays advisory-but-precise. `runLint`/`lint_design_system` +take `enable[]` / `disable[]` / `config{}` (per-rule, keyed by rule_id). Config is +validated in `lint/config.ts` (`resolveRuleConfig` → `LintConfigError`); bad/missing +config for an enabled rule is reported under `config_errors` and the rule skipped — +never a crash (detectors also run inside per-rule try/catch → `rule_failures`). The +report's `available_optin[]` advertises every off-by-default rule with its +`config_shape` + an `enable_hint`, so the AI can discover and turn them on. +`analyze(snap)` is WeakMap-memoized so the whole suite classifies the graph once. + +**Adding a detector** touches 3 places: the pure fn in +`server/src/lint/detectors/.ts` (signature `(snap, config?)`) + its bundle +export (already `Object.assign`ed in `detectors/register.ts`), and the rule entry +in `server/src/lint/registry.ts` (objectively-broken → `error`, else `warn`; add +`defaultOn:false` for house-style/config rules). For a **configurable/opt-in** +rule also add a `RULE_CONFIG` entry in `lint/config.ts` (shape + defaults + +validation) and read the resolved `config` arg in the detector. Then add a fixture +case to `server/test/` and, if it belongs to a build step, wire it into +`STEP_GATES` in `skills.ts`. + ## Conventions & invariants - **pnpm only** (workspace rule). Build: `pnpm install && pnpm run build` in each package. @@ -49,14 +110,24 @@ Motion styles enumerate; shaders API present (0 shaders in file). ## Testing -`scripts/e2e-live-test.mjs` is a live end-to-end suite that exercises ~50 tool -paths against a real Figma file. Prereq: Figma Desktop open with the plugin -running in a scratch file. Run `node scripts/e2e-live-test.mjs` from the repo -root (or `FILE_KEY= node …` to target one of several connected files). It -creates everything inside a dedicated test page and removes all artifacts -(page, styles, variables, collections, temp state) on exit — the file is left -as found. Only one MCP server may hold `:1994`; kill stragglers first -(`lsof -i :1994`) so the test runs as leader, not follower. +Two layers: + +- **Unit** — `cd server && pnpm test` (builds, then `node --test test/*.test.mjs`). + Runs against compiled `dist/`, no Figma needed — the lint detectors are pure + functions. Covers the a11y contrast regression (on-`X` pairing, same-colour + skip, suffix-only non-pairing) and the **golden clean-file fixture** (a correct + 3-tier DS that must produce 0 findings across the full suite — the anti-noise + ratchet). **Server/detector code cannot be hot-reloaded into a running MCP + session** (stdio, spawned once at session start), so verify detector changes + here offline; a live re-lint only reflects new code after a fresh session. +- **Live E2E** — `scripts/e2e-live-test.mjs` exercises ~50 tool paths against a + real Figma file. Prereq: Figma Desktop open with the plugin running in a scratch + file. Run `node scripts/e2e-live-test.mjs` from the repo root (or + `FILE_KEY= node …` to target one of several connected files). It creates + everything inside a dedicated test page and removes all artifacts (page, styles, + variables, collections, temp state) on exit — the file is left as found. Only + one MCP server may hold `:1994`; kill stragglers first (`lsof -i :1994`) so the + test runs as leader, not follower. ## Registration diff --git a/figma-limitless-mcp/plugin/extras/manifest.json b/figma-limitless-mcp/plugin/extras/manifest.json new file mode 100644 index 0000000..dae3594 --- /dev/null +++ b/figma-limitless-mcp/plugin/extras/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "Figma Limitless MCP — Extras (FigJam / Slides / Buzz)", + "id": "figma-limitless-mcp-extras", + "api": "1.0.0", + "main": "code.js", + "ui": "index.html", + "permissions": [ + "teamlibrary" + ], + "networkAccess": { + "allowedDomains": [ + "ws://localhost:1994" + ], + "reasoning": "Connects to the local figma-limitless-mcp server via WebSocket to stream Figma document data to AI tools" + }, + "documentAccess": "dynamic-page", + "editorType": [ + "figma", + "figjam", + "slides", + "buzz" + ] +} diff --git a/figma-limitless-mcp/plugin/package.json b/figma-limitless-mcp/plugin/package.json index 6ec1bd5..65745ec 100644 --- a/figma-limitless-mcp/plugin/package.json +++ b/figma-limitless-mcp/plugin/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "concurrently \"vite build --watch\" \"vite build --watch -c vite.config.main.ts\"", - "build": "vite build && vite build -c vite.config.main.ts", + "build": "vite build && vite build -c vite.config.main.ts && node scripts/sync-extras.mjs", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/figma-limitless-mcp/plugin/scripts/sync-extras.mjs b/figma-limitless-mcp/plugin/scripts/sync-extras.mjs new file mode 100644 index 0000000..416f230 --- /dev/null +++ b/figma-limitless-mcp/plugin/scripts/sync-extras.mjs @@ -0,0 +1,22 @@ +// Copies the built plugin bundle into extras/ so the companion manifest +// (FigJam / Slides / Buzz) can reference its code/ui in the SAME directory — +// Figma forbids a manifest's "main"/"ui" from pointing at a parent dir +// (../dist). The two manifests (plugin/manifest.json = figma+dev, and +// plugin/extras/manifest.json = figjam/slides/buzz) share one codebase; this +// keeps extras/ in lockstep with dist/ on every build. +import { copyFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const pluginDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +const extrasDir = join(pluginDir, "extras"); +mkdirSync(extrasDir, { recursive: true }); + +for (const [from, to] of [ + ["dist/code.js", "code.js"], + ["dist/index.html", "index.html"], +]) { + copyFileSync(join(pluginDir, from), join(extrasDir, to)); +} + +console.log("synced dist/ -> extras/ (code.js, index.html)"); diff --git a/figma-limitless-mcp/plugin/src/main/code.ts b/figma-limitless-mcp/plugin/src/main/code.ts index 918cb92..50ffb92 100644 --- a/figma-limitless-mcp/plugin/src/main/code.ts +++ b/figma-limitless-mcp/plugin/src/main/code.ts @@ -62,6 +62,29 @@ type RequestType = | "import_library_asset" | "list_library_variables" | "create_slot" + | "get_slots" + | "reset_slot" + | "append_to_slot" + | "create_sticky" + | "create_shape_with_text" + | "create_connector" + | "create_section" + | "create_table" + | "create_code_block" + | "create_gif" + | "create_slide" + | "create_slide_row" + | "set_slide_transition" + | "set_slide_skip" + | "focus_slide" + | "get_slide_grid" + | "set_slide_grid" + | "create_buzz_frame" + | "set_buzz_asset_type" + | "get_buzz_content" + | "set_buzz_text" + | "buzz_smart_resize" + | "lint_run" | "dev_resources"; type ServerRequestParams = Record & { @@ -886,16 +909,97 @@ const EDIT_REQUEST_TYPES = new Set([ "create_effect_style", "import_library_asset", "create_slot", + "reset_slot", + "append_to_slot", ]); const requireEditorMode = (toolName: RequestType): void => { - // Dev Mode is read-only — every figma.create*/setter throws at runtime there, - // and the resulting errors are confusing. Reject up front with a clear hint. - if (figma.editorType === "dev") { + // The plugin runs across multiple editors (figma/figjam/slides/buzz/dev), but + // design-canvas write tools only apply to the design-style surfaces. Reject up + // front with a clear, editor-specific hint instead of a confusing runtime error. + const editor = figma.editorType; + if (editor === "dev") { throw new Error( - `${toolName} requires the plugin to be opened in Figma's design editor (Dev Mode is read-only). Switch to the design editor and re-run.` + `${toolName} requires an editable surface — Dev Mode is read-only. Switch to the design editor and re-run.` ); } + if (editor === "figjam") { + throw new Error( + `${toolName} is a design-canvas tool and isn't available in FigJam. Use FigJam-native creation (figma.createSticky/createConnector/createSection/…) via execute_code, or run this tool in a Figma design file.` + ); + } +}; + +const FIGJAM_REQUEST_TYPES = new Set([ + "create_sticky", + "create_shape_with_text", + "create_connector", + "create_section", + "create_table", + "create_code_block", + "create_gif", +]); + +const requireFigJam = (toolName: RequestType): void => { + if (figma.editorType !== "figjam") { + throw new Error( + `${toolName} is a FigJam tool and only runs in a FigJam file (current editor: ${figma.editorType}). Open a FigJam board and re-run.` + ); + } +}; + +const SLIDES_REQUEST_TYPES = new Set([ + "create_slide", + "create_slide_row", + "set_slide_transition", + "set_slide_skip", + "focus_slide", + "get_slide_grid", + "set_slide_grid", +]); + +const requireSlides = (toolName: RequestType): void => { + if (figma.editorType !== "slides") { + throw new Error( + `${toolName} is a Figma Slides tool and only runs in a Slides file (current editor: ${figma.editorType}). Open a Slides file and re-run.` + ); + } +}; + +const BUZZ_REQUEST_TYPES = new Set([ + "create_buzz_frame", + "set_buzz_asset_type", + "get_buzz_content", + "set_buzz_text", + "buzz_smart_resize", +]); + +const requireBuzz = (toolName: RequestType): void => { + if (figma.editorType !== "buzz") { + throw new Error( + `${toolName} is a Figma Buzz tool and only runs in a Buzz file (current editor: ${figma.editorType}). Open a Buzz file and re-run.` + ); + } +}; + +const solidPaintFromHex = (hex: string): SolidPaint => ({ + type: "SOLID", + color: parseHexColor(hex), +}); + +// FigJam text lives in a TextSublayerNode; setting characters requires the +// sublayer's font loaded first (Inter Medium is the FigJam default). +const setSublayerText = async ( + sublayer: TextSublayerNode, + text: string +): Promise => { + const font = sublayer.fontName; + if (typeof font === "symbol") { + await figma.loadFontAsync({ family: "Inter", style: "Medium" }); + } else { + await figma.loadFontAsync(font); + } + sublayer.characters = text; }; const handleRequest = async ( @@ -905,6 +1009,15 @@ const handleRequest = async ( if (EDIT_REQUEST_TYPES.has(request.type)) { requireEditorMode(request.type); } + if (FIGJAM_REQUEST_TYPES.has(request.type)) { + requireFigJam(request.type); + } + if (SLIDES_REQUEST_TYPES.has(request.type)) { + requireSlides(request.type); + } + if (BUZZ_REQUEST_TYPES.has(request.type)) { + requireBuzz(request.type); + } switch (request.type) { case "get_document": return { @@ -2687,6 +2800,89 @@ const handleRequest = async ( variable.remove(); return { action: a.action, deleted: name }; } + case "rename_variable": { + if (typeof a.variableId !== "string" || typeof a.name !== "string") { + throw new Error("rename_variable needs variableId and name"); + } + const variable = await figma.variables.getVariableByIdAsync(a.variableId); + if (!variable) throw new Error(`Variable not found: ${a.variableId}`); + variable.name = a.name; + return { action: a.action, variableId: variable.id, name: variable.name }; + } + case "update_variable": { + if (typeof a.variableId !== "string") { + throw new Error("update_variable needs variableId"); + } + const variable = await figma.variables.getVariableByIdAsync(a.variableId); + if (!variable) throw new Error(`Variable not found: ${a.variableId}`); + if (Array.isArray(a.scopes)) { + variable.scopes = a.scopes as VariableScope[]; + } + if (typeof a.description === "string") { + variable.description = a.description; + } + if (typeof a.hiddenFromPublishing === "boolean") { + variable.hiddenFromPublishing = a.hiddenFromPublishing; + } + if (a.codeSyntax && typeof a.codeSyntax === "object") { + for (const [platform, value] of Object.entries( + a.codeSyntax as Record + )) { + if (typeof value === "string") { + variable.setVariableCodeSyntax( + platform as CodeSyntaxPlatform, + value + ); + } + } + } + return { action: a.action, variableId: variable.id }; + } + case "rename_collection": { + if (typeof a.collectionId !== "string" || typeof a.name !== "string") { + throw new Error("rename_collection needs collectionId and name"); + } + const collection = + await figma.variables.getVariableCollectionByIdAsync(a.collectionId); + if (!collection) throw new Error(`Collection not found: ${a.collectionId}`); + collection.name = a.name; + return { action: a.action, collectionId: collection.id, name: collection.name }; + } + case "delete_collection": { + if (typeof a.collectionId !== "string") { + throw new Error("delete_collection needs collectionId"); + } + const collection = + await figma.variables.getVariableCollectionByIdAsync(a.collectionId); + if (!collection) throw new Error(`Collection not found: ${a.collectionId}`); + const name = collection.name; + collection.remove(); + return { action: a.action, deleted: name }; + } + case "rename_mode": { + if ( + typeof a.collectionId !== "string" || + typeof a.modeId !== "string" || + typeof a.name !== "string" + ) { + throw new Error("rename_mode needs collectionId, modeId, and name"); + } + const collection = + await figma.variables.getVariableCollectionByIdAsync(a.collectionId); + if (!collection) throw new Error(`Collection not found: ${a.collectionId}`); + collection.renameMode(a.modeId, a.name); + return { action: a.action, modeId: a.modeId, name: a.name }; + } + case "remove_mode": { + if (typeof a.collectionId !== "string" || typeof a.modeId !== "string") { + throw new Error("remove_mode needs collectionId and modeId"); + } + const collection = + await figma.variables.getVariableCollectionByIdAsync(a.collectionId); + if (!collection) throw new Error(`Collection not found: ${a.collectionId}`); + collection.removeMode(a.modeId); + return { action: a.action, removedModeId: a.modeId }; + } default: throw new Error(`Unknown write_variables action: ${String(a.action)}`); } @@ -3748,6 +3944,824 @@ const handleRequest = async ( }, }; } + case "get_slots": { + const nodeId = request.nodeIds && request.nodeIds[0]; + if (!nodeId) throw new Error("nodeIds is required for get_slots"); + const node = await figma.getNodeByIdAsync(nodeId); + if (!node) throw new Error(`Node not found: ${nodeId}`); + const slots: SlotNode[] = []; + if (node.type === "SLOT") slots.push(node); + if ("findAll" in node) { + for (const found of (node as ChildrenMixin).findAll( + (n) => n.type === "SLOT" + )) { + slots.push(found as SlotNode); + } + } + const slotData = slots.map((s) => ({ + slotId: s.id, + name: s.name, + childCount: s.children.length, + limitViolations: s.limitViolations, + })); + let slotProperties: + | Array<{ + key: string; + description?: string; + slotSettings?: SlotSettings; + }> + | undefined; + if (node.type === "COMPONENT" || node.type === "COMPONENT_SET") { + slotProperties = Object.entries(node.componentPropertyDefinitions) + .filter(([, def]) => def.type === "SLOT") + .map(([key, def]) => ({ + key, + description: def.description, + slotSettings: def.slotSettings, + })); + } + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: node.id, slots: slotData, slotProperties }, + }; + } + case "reset_slot": { + const nodeId = request.nodeIds && request.nodeIds[0]; + if (!nodeId) throw new Error("nodeIds is required for reset_slot"); + const node = await figma.getNodeByIdAsync(nodeId); + if (!node || node.type !== "SLOT") { + throw new Error( + `reset_slot needs a SLOT node, got ${node?.type ?? "missing"}` + ); + } + node.resetSlot(); + return { + type: request.type, + requestId: request.requestId, + data: { slotId: node.id, reset: true }, + }; + } + case "append_to_slot": { + const params = request.params ?? {}; + const slotId = params.slotId; + const childId = params.nodeId; + if (typeof slotId !== "string" || typeof childId !== "string") { + throw new Error("append_to_slot needs slotId and nodeId"); + } + const slot = await figma.getNodeByIdAsync(slotId); + if (!slot || slot.type !== "SLOT") { + throw new Error( + `append_to_slot slotId must be a SLOT, got ${slot?.type ?? "missing"}` + ); + } + const child = await getSceneNodeById(childId); + if (typeof params.index === "number") { + slot.insertChild(params.index, child); + } else { + slot.appendChild(child); + } + return { + type: request.type, + requestId: request.requestId, + data: { + slotId: slot.id, + appended: child.id, + childCount: slot.children.length, + limitViolations: slot.limitViolations, + }, + }; + } + case "create_sticky": { + const params = request.params ?? {}; + const sticky = figma.createSticky(); + if (params.wide === true) sticky.isWideWidth = true; + if (typeof params.fillHex === "string") { + sticky.fills = [solidPaintFromHex(params.fillHex)]; + } + if (typeof params.text === "string") { + await setSublayerText(sticky.text, params.text); + } + positionNode(sticky, params.x, params.y); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: sticky.id, nodeName: sticky.name }, + }; + } + case "create_shape_with_text": { + const params = request.params ?? {}; + const shape = figma.createShapeWithText(); + if (typeof params.shapeType === "string") { + shape.shapeType = params.shapeType as ShapeWithTextNode["shapeType"]; + } + if (typeof params.fillHex === "string") { + shape.fills = [solidPaintFromHex(params.fillHex)]; + } + if (typeof params.text === "string") { + await setSublayerText(shape.text, params.text); + } + if (typeof params.width === "number" && typeof params.height === "number") { + shape.resize(params.width, params.height); + } + positionNode(shape, params.x, params.y); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: shape.id, shapeType: shape.shapeType }, + }; + } + case "create_connector": { + const params = request.params ?? {}; + const connector = figma.createConnector(); + const makeEndpoint = ( + nodeId: unknown, + x: unknown, + y: unknown, + magnet: unknown + ): ConnectorEndpoint => { + if (typeof nodeId === "string") { + return { + endpointNodeId: nodeId, + magnet: (typeof magnet === "string" ? magnet : "AUTO") as + | "NONE" + | "AUTO" + | "TOP" + | "LEFT" + | "BOTTOM" + | "RIGHT" + | "CENTER", + }; + } + return { + position: { + x: typeof x === "number" ? x : 0, + y: typeof y === "number" ? y : 0, + }, + }; + }; + connector.connectorStart = makeEndpoint( + params.startNodeId, + params.startX, + params.startY, + params.startMagnet + ); + connector.connectorEnd = makeEndpoint( + params.endNodeId, + params.endX, + params.endY, + params.endMagnet + ); + if (typeof params.lineType === "string") { + connector.connectorLineType = params.lineType as + | "ELBOWED" + | "STRAIGHT" + | "CURVED"; + } + if (typeof params.startCap === "string") { + connector.connectorStartStrokeCap = params.startCap as ConnectorStrokeCap; + } + if (typeof params.endCap === "string") { + connector.connectorEndStrokeCap = params.endCap as ConnectorStrokeCap; + } + if (typeof params.text === "string") { + await setSublayerText(connector.text, params.text); + } + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: connector.id }, + }; + } + case "create_section": { + const params = request.params ?? {}; + const section = figma.createSection(); + if (typeof params.name === "string") section.name = params.name; + if (typeof params.width === "number" && typeof params.height === "number") { + section.resizeWithoutConstraints(params.width, params.height); + } + positionNode(section, params.x, params.y); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: section.id, nodeName: section.name }, + }; + } + case "create_table": { + const params = request.params ?? {}; + const rows = typeof params.rows === "number" ? params.rows : 2; + const columns = typeof params.columns === "number" ? params.columns : 2; + const table = figma.createTable(rows, columns); + if (Array.isArray(params.cells)) { + const cellRows = params.cells as unknown[]; + for (let r = 0; r < cellRows.length && r < table.numRows; r++) { + const row = cellRows[r]; + if (!Array.isArray(row)) continue; + for (let c = 0; c < row.length && c < table.numColumns; c++) { + const value = row[c]; + if (typeof value === "string" && value.length > 0) { + await setSublayerText(table.cellAt(r, c).text, value); + } + } + } + } + positionNode(table, params.x, params.y); + return { + type: request.type, + requestId: request.requestId, + data: { + nodeId: table.id, + numRows: table.numRows, + numColumns: table.numColumns, + }, + }; + } + case "create_code_block": { + const params = request.params ?? {}; + if (typeof params.code !== "string") { + throw new Error("create_code_block needs code"); + } + const block = figma.createCodeBlock(); + block.code = params.code; + if (typeof params.language === "string") { + block.codeLanguage = params.language as CodeBlockNode["codeLanguage"]; + } + positionNode(block, params.x, params.y); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: block.id, codeLanguage: block.codeLanguage }, + }; + } + case "create_gif": { + const params = request.params ?? {}; + if (typeof params.hash !== "string") { + throw new Error("create_gif needs a media hash"); + } + const gif = figma.createGif(params.hash); + positionNode(gif, params.x, params.y); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: gif.id }, + }; + } + case "create_slide": { + const params = request.params ?? {}; + const row = typeof params.row === "number" ? params.row : undefined; + const col = typeof params.col === "number" ? params.col : undefined; + const slide = + row !== undefined && col !== undefined + ? figma.createSlide(row, col) + : row !== undefined + ? figma.createSlide(row) + : figma.createSlide(); + if (typeof params.backgroundHex === "string") { + slide.fills = [solidPaintFromHex(params.backgroundHex)]; + } + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: slide.id, nodeName: slide.name }, + }; + } + case "create_slide_row": { + const params = request.params ?? {}; + const row = typeof params.row === "number" ? params.row : undefined; + const slideRow = + row !== undefined ? figma.createSlideRow(row) : figma.createSlideRow(); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: slideRow.id }, + }; + } + case "set_slide_transition": { + const params = request.params ?? {}; + if (typeof params.nodeId !== "string") { + throw new Error("set_slide_transition needs a slide nodeId"); + } + const node = await figma.getNodeByIdAsync(params.nodeId); + if (!node || node.type !== "SLIDE") { + throw new Error( + `set_slide_transition needs a SLIDE node, got ${node?.type ?? "missing"}` + ); + } + const current = node.getSlideTransition(); + const transition: SlideTransition = { + style: (typeof params.style === "string" + ? params.style + : current.style) as SlideTransition["style"], + duration: + typeof params.duration === "number" ? params.duration : current.duration, + curve: (typeof params.curve === "string" + ? params.curve + : current.curve) as SlideTransition["curve"], + timing: { + type: (typeof params.timingType === "string" + ? params.timingType + : current.timing.type) as "ON_CLICK" | "AFTER_DELAY", + delay: + typeof params.delay === "number" ? params.delay : current.timing.delay, + }, + }; + node.setSlideTransition(transition); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: node.id, transition }, + }; + } + case "set_slide_skip": { + const params = request.params ?? {}; + if (typeof params.nodeId !== "string") { + throw new Error("set_slide_skip needs a slide nodeId"); + } + const node = await figma.getNodeByIdAsync(params.nodeId); + if (!node || node.type !== "SLIDE") { + throw new Error( + `set_slide_skip needs a SLIDE node, got ${node?.type ?? "missing"}` + ); + } + node.isSkippedSlide = params.skip === true; + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: node.id, isSkippedSlide: node.isSkippedSlide }, + }; + } + case "focus_slide": { + const params = request.params ?? {}; + if (typeof params.nodeId !== "string") { + throw new Error("focus_slide needs a slide nodeId"); + } + const node = await figma.getNodeByIdAsync(params.nodeId); + if (!node || node.type !== "SLIDE") { + throw new Error( + `focus_slide needs a SLIDE node, got ${node?.type ?? "missing"}` + ); + } + figma.currentPage.focusedSlide = node; + return { + type: request.type, + requestId: request.requestId, + data: { focusedSlideId: node.id }, + }; + } + case "get_slide_grid": { + const grid = figma.getSlideGrid(); + const idGrid = grid.map((rowArr) => + rowArr.map((s) => ({ id: s.id, name: s.name })) + ); + return { + type: request.type, + requestId: request.requestId, + data: { grid: idGrid, rows: idGrid.length }, + }; + } + case "set_slide_grid": { + const params = request.params ?? {}; + if (!Array.isArray(params.grid)) { + throw new Error("set_slide_grid needs grid (a 2D array of slide ids)"); + } + const inputRows = params.grid as unknown[]; + const slideGrid: SlideNode[][] = []; + for (const row of inputRows) { + if (!Array.isArray(row)) { + throw new Error("set_slide_grid: grid must be a 2D array"); + } + const slideRow: SlideNode[] = []; + for (const id of row) { + if (typeof id !== "string") { + throw new Error("set_slide_grid: each cell must be a slide id string"); + } + const n = await figma.getNodeByIdAsync(id); + if (!n || n.type !== "SLIDE") { + throw new Error(`set_slide_grid: not a SLIDE node: ${id}`); + } + slideRow.push(n); + } + slideGrid.push(slideRow); + } + figma.setSlideGrid(slideGrid); + return { + type: request.type, + requestId: request.requestId, + data: { rows: slideGrid.length }, + }; + } + case "create_buzz_frame": { + const params = request.params ?? {}; + const row = typeof params.row === "number" ? params.row : undefined; + const col = typeof params.col === "number" ? params.col : undefined; + const frame = + row !== undefined && col !== undefined + ? figma.buzz.createFrame(row, col) + : row !== undefined + ? figma.buzz.createFrame(row) + : figma.buzz.createFrame(); + if (typeof params.assetType === "string") { + figma.buzz.setBuzzAssetTypeForNode(frame, params.assetType as BuzzAssetType); + } + if (typeof params.backgroundHex === "string") { + frame.fills = [solidPaintFromHex(params.backgroundHex)]; + } + return { + type: request.type, + requestId: request.requestId, + data: { + nodeId: frame.id, + nodeName: frame.name, + assetType: figma.buzz.getBuzzAssetTypeForNode(frame), + }, + }; + } + case "set_buzz_asset_type": { + const params = request.params ?? {}; + if (typeof params.nodeId !== "string" || typeof params.assetType !== "string") { + throw new Error("set_buzz_asset_type needs nodeId and assetType"); + } + const node = await getSceneNodeById(params.nodeId); + figma.buzz.setBuzzAssetTypeForNode(node, params.assetType as BuzzAssetType); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: node.id, assetType: params.assetType }, + }; + } + case "get_buzz_content": { + const params = request.params ?? {}; + if (typeof params.nodeId !== "string") { + throw new Error("get_buzz_content needs nodeId"); + } + const node = await getSceneNodeById(params.nodeId); + const textFields = figma.buzz.getTextContent(node).map((f, i) => ({ + index: i, + value: f.value, + nodeId: f.node ? f.node.id : null, + })); + const mediaFields = figma.buzz.getMediaContent(node).map((f, i) => ({ + index: i, + type: f.type, + hash: f.hash, + nodeId: f.node ? f.node.id : null, + })); + return { + type: request.type, + requestId: request.requestId, + data: { + nodeId: node.id, + assetType: figma.buzz.getBuzzAssetTypeForNode(node), + textFields, + mediaFields, + }, + }; + } + case "set_buzz_text": { + const params = request.params ?? {}; + if (typeof params.nodeId !== "string") { + throw new Error("set_buzz_text needs nodeId"); + } + if (!Array.isArray(params.values)) { + throw new Error( + "set_buzz_text needs values (array of strings applied positionally to the asset's text fields)" + ); + } + const node = await getSceneNodeById(params.nodeId); + const fields = figma.buzz.getTextContent(node); + const values = params.values as unknown[]; + let updated = 0; + for (let i = 0; i < values.length && i < fields.length; i++) { + const value = values[i]; + if (typeof value === "string") { + await fields[i].setValueAsync(value); + updated++; + } + } + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: node.id, updated, totalFields: fields.length }, + }; + } + case "buzz_smart_resize": { + const params = request.params ?? {}; + if ( + typeof params.nodeId !== "string" || + typeof params.width !== "number" || + typeof params.height !== "number" + ) { + throw new Error("buzz_smart_resize needs nodeId, width, and height"); + } + const node = await getSceneNodeById(params.nodeId); + figma.buzz.smartResize(node, params.width, params.height); + return { + type: request.type, + requestId: request.requestId, + data: { nodeId: node.id, width: params.width, height: params.height }, + }; + } + case "lint_run": { + // Gather a serializable snapshot of the design system for the server-side + // linter. loadAllPagesAsync() first: the manifest is dynamic-page, so a + // whole-file component scan otherwise sees only the current page. + await figma.loadAllPagesAsync(); + const [collections, variables, paintStyles, textStyles, effectStyles] = + await Promise.all([ + figma.variables.getLocalVariableCollectionsAsync(), + figma.variables.getLocalVariablesAsync(), + figma.getLocalPaintStylesAsync(), + figma.getLocalTextStylesAsync(), + figma.getLocalEffectStylesAsync(), + ]); + + const serializeValue = (val: unknown): unknown => { + if ( + val && + typeof val === "object" && + (val as { type?: string }).type === "VARIABLE_ALIAS" + ) { + return { alias: (val as { id: string }).id }; + } + return val; + }; + + const snapVariables = variables.map((vr) => ({ + id: vr.id, + name: vr.name, + collectionId: vr.variableCollectionId, + resolvedType: vr.resolvedType, + scopes: vr.scopes, + hiddenFromPublishing: vr.hiddenFromPublishing, + codeSyntax: vr.codeSyntax, + description: vr.description, + valuesByMode: Object.fromEntries( + Object.entries(vr.valuesByMode).map(([modeId, val]) => [ + modeId, + serializeValue(val), + ]) + ), + })); + + const snapCollections = collections.map((c) => ({ + id: c.id, + name: c.name, + defaultModeId: c.defaultModeId, + modes: c.modes.map((m) => ({ modeId: m.modeId, name: m.name })), + })); + + const snapStyles = [ + ...paintStyles.map((s) => ({ id: s.id, name: s.name, styleType: "PAINT" })), + ...textStyles.map((s) => ({ + id: s.id, + name: s.name, + styleType: "TEXT", + fontSize: typeof s.fontSize === "number" ? s.fontSize : undefined, + fontFamily: s.fontName && typeof s.fontName === "object" ? s.fontName.family : undefined, + })), + ...effectStyles.map((s) => ({ id: s.id, name: s.name, styleType: "EFFECT" })), + ]; + + const componentNodes = figma.root.findAllWithCriteria({ + types: ["COMPONENT", "COMPONENT_SET"], + }); + + // Component-subtree enrichment (Wave 10b). One bounded DFS per non-variant + // root (sets + standalone components; variant children are covered by their + // set's walk). We emit AGGREGATES (booleans/counts/min/key-union), never raw + // node trees, so the payload stays small. A shared node budget caps total + // work; a component only gets `enriched:true` if its walk finished within + // budget — server detectors treat enriched!==true as "no data". + const COMPONENT_NODE_BUDGET = 20000; + const MAX_VARIANT_TUPLES = 400; + const RAW_PAINT_TYPES = new Set([ + "FRAME", "RECTANGLE", "ELLIPSE", "COMPONENT", "COMPONENT_SET", + ]); + let componentBudget = COMPONENT_NODE_BUDGET; + let componentScanTruncated = false; + + const isRawPaintArray = (paints: unknown, styleId: unknown, bound: unknown): boolean => { + if (styleId || bound) return false; // styled or bound -> not raw + if (!Array.isArray(paints)) return false; // undefined / figma.mixed + return paints.some( + (p) => + p && + (p as { visible?: boolean }).visible !== false && + typeof (p as { type?: string }).type === "string" && + ((p as { type: string }).type === "SOLID" || + (p as { type: string }).type.startsWith("GRADIENT")) + ); + }; + + interface Enrichment { + enriched: boolean; + hasRawPaintLayer: boolean; + rawPaintSample?: string; + textLayersMissingType: number; + textLayerSample?: string; + minTextFontSize?: number; + referencedPropKeys: string[]; + } + + const walkComponent = (root: SceneNode): Enrichment => { + const refKeys = new Set(); + let hasRaw = false; + let rawSample: string | undefined; + let textMissing = 0; + let textSample: string | undefined; + let minFont: number | undefined; + let truncated = false; + const stack: SceneNode[] = [root]; + while (stack.length > 0) { + if (componentBudget <= 0) { + truncated = true; + componentScanTruncated = true; + break; + } + componentBudget--; + const node = stack.pop() as SceneNode & Record; + try { + // Nested instances/components/sets are their OWN roots: an + // instance's children are the main component's cloned layers + // (linted when that component is walked), and a nested standalone + // component/set is walked separately — so we must NOT attribute + // their paint/text/font to THIS component, nor descend into them + // (which would also double-walk and burn the shared budget). A + // COMPONENT_SET root's direct variant children ARE part of the set, + // so they aren't boundaries. We still read a boundary node's own + // componentPropertyReferences (it may wire an exposed property of + // THIS component). + const isBoundary = + node !== root && + (node.type === "INSTANCE" || + ((node.type === "COMPONENT" || node.type === "COMPONENT_SET") && + !(root.type === "COMPONENT_SET" && node.parent === root))); + + const refs = node.componentPropertyReferences as Record | null; + if (refs) { + for (const v of Object.values(refs)) if (typeof v === "string") refKeys.add(v); + } + + if (!isBoundary) { + // Raw (unbound + unstyled) paint on a container-ish layer. + if (!hasRaw && RAW_PAINT_TYPES.has(node.type)) { + const bv = (node.boundVariables ?? {}) as Record; + if ( + isRawPaintArray(node.fills, node.fillStyleId, bv.fills) || + isRawPaintArray(node.strokes, node.strokeStyleId, bv.strokes) + ) { + hasRaw = true; + rawSample = node.name; + } + } + // Untyped TEXT (no text style, no bound type) + min font size. + if (node.type === "TEXT") { + const bv = (node.boundVariables ?? {}) as Record; + const styled = !!node.textStyleId; // "" -> false, mixed(symbol) -> true + const boundType = !!bv.fontSize || !!bv.lineHeight; + if (!styled && !boundType) { + textMissing++; + if (!textSample) textSample = node.name; + } + const fs = node.fontSize; + if (typeof fs === "number") minFont = minFont === undefined ? fs : Math.min(minFont, fs); + } + const kids = (node as { children?: readonly SceneNode[] }).children; + if (kids) for (const ch of kids) stack.push(ch); + } + } catch { + // A single unreadable node must not abort the walk. + } + } + return { + enriched: !truncated, + hasRawPaintLayer: hasRaw, + rawPaintSample: rawSample, + textLayersMissingType: textMissing, + textLayerSample: textSample, + minTextFontSize: minFont, + referencedPropKeys: [...refKeys], + }; + }; + + const snapComponents = componentNodes.map((n) => { + // A variant COMPONENT (child of a COMPONENT_SET) throws on + // componentPropertyDefinitions — only sets and standalone components + // expose it. Guard so the whole scan doesn't abort on one variant. + const isVariant = + n.type === "COMPONENT" && n.parent?.type === "COMPONENT_SET"; + const out: Record = { + id: n.id, + name: n.name, + type: n.type, + isVariant, + propertyDefinitions: isVariant ? undefined : n.componentPropertyDefinitions, + }; + if (isVariant) return out; // covered by its set's walk + try { + const e = walkComponent(n); + Object.assign(out, e); + } catch { + /* leave un-enriched */ + } + // COMPONENT_SET: realized variant tuples + default variant tuple. + if (n.type === "COMPONENT_SET") { + try { + const set = n as ComponentSetNode; + const tuples: Array> = []; + let tuplesTruncated = false; + for (const child of set.children) { + if (child.type !== "COMPONENT") continue; + if (tuples.length >= MAX_VARIANT_TUPLES) { + tuplesTruncated = true; + break; + } + tuples.push({ ...(child.variantProperties ?? {}) }); + } + out.variantTuples = tuples; + out.variantTuplesTruncated = tuplesTruncated; + const dv = set.defaultVariant; + out.defaultVariantTuple = dv ? { ...(dv.variantProperties ?? {}) } : undefined; + } catch { + /* no variant data */ + } + } + return out; + }); + + // Node -> variable bindings (for no-node-binds-primitive etc.). Recurse + // into each field's binding value to pull VARIABLE_ALIAS ids wherever + // they sit (scalar fields, fills[] arrays, nested paint boundVariables). + // Bounded so a huge file can't produce an unbounded payload. + figma.skipInvisibleInstanceChildren = true; + const collectAliasIds = (binding: unknown, into: string[]): void => { + if (!binding || typeof binding !== "object") return; + if (Array.isArray(binding)) { + for (const b of binding) collectAliasIds(b, into); + return; + } + const o = binding as Record; + if (o.type === "VARIABLE_ALIAS" && typeof o.id === "string") { + into.push(o.id); + return; + } + for (const val of Object.values(o)) collectAliasIds(val, into); + }; + const MAX_BINDINGS = 10000; + const nodeBindings: Array<{ + nodeId: string; + nodeName: string; + nodeType: string; + field: string; + variableId: string; + }> = []; + let bindingsTruncated = false; + const boundNodes = figma.root.findAll((n) => { + const bv = (n as { boundVariables?: unknown }).boundVariables; + return ( + !!bv && typeof bv === "object" && Object.keys(bv as object).length > 0 + ); + }); + scan: for (const n of boundNodes) { + const bv = (n as unknown as { boundVariables: Record }) + .boundVariables; + for (const [field, binding] of Object.entries(bv)) { + const ids: string[] = []; + collectAliasIds(binding, ids); + for (const variableId of ids) { + nodeBindings.push({ + nodeId: n.id, + nodeName: n.name, + nodeType: n.type, + field, + variableId, + }); + if (nodeBindings.length >= MAX_BINDINGS) { + bindingsTruncated = true; + break scan; + } + } + } + } + + return { + type: request.type, + requestId: request.requestId, + data: { + collections: snapCollections, + variables: snapVariables, + styles: snapStyles, + components: snapComponents, + nodeBindings, + bindingsTruncated, + componentScanTruncated, + meta: { + pageCount: figma.root.children.length, + scannedAllPages: true, + }, + }, + }; + } case "dev_resources": { const nodeId = request.nodeIds && request.nodeIds[0]; if (!nodeId) throw new Error("nodeIds is required for dev_resources"); @@ -3945,7 +4959,13 @@ figma.ui.onmessage = async (message) => { } }; - if (EDIT_REQUEST_TYPES.has(request.type) || request.type === "execute_code") { + if ( + EDIT_REQUEST_TYPES.has(request.type) || + FIGJAM_REQUEST_TYPES.has(request.type) || + SLIDES_REQUEST_TYPES.has(request.type) || + BUZZ_REQUEST_TYPES.has(request.type) || + request.type === "execute_code" + ) { mutationChain = mutationChain.then(run, run); } else { void run(); diff --git a/figma-limitless-mcp/server/package.json b/figma-limitless-mcp/server/package.json index 7a144a7..afc2492 100644 --- a/figma-limitless-mcp/server/package.json +++ b/figma-limitless-mcp/server/package.json @@ -11,7 +11,8 @@ "dist" ], "scripts": { - "build": "tsc" + "build": "tsc && node scripts/copy-skills.mjs", + "test": "pnpm build && node --test test/*.test.mjs" }, "license": "MIT", "dependencies": { diff --git a/figma-limitless-mcp/server/scripts/copy-skills.mjs b/figma-limitless-mcp/server/scripts/copy-skills.mjs new file mode 100644 index 0000000..2af7c23 --- /dev/null +++ b/figma-limitless-mcp/server/scripts/copy-skills.mjs @@ -0,0 +1,10 @@ +// tsc only emits .js — the bundled skill Markdown/JSON in server/skills/ must +// be copied into dist/skills/ so the compiled server (dist/skills.js) reads +// them at runtime via import.meta.url. Runs after tsc in `pnpm build`. +import { cpSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +cpSync(join(root, "skills"), join(root, "dist", "skills"), { recursive: true }); +console.log("copied skills/ -> dist/skills/"); diff --git a/figma-limitless-mcp/server/skills/accessibility-naming-conventions.md b/figma-limitless-mcp/server/skills/accessibility-naming-conventions.md new file mode 100644 index 0000000..10d0c49 --- /dev/null +++ b/figma-limitless-mcp/server/skills/accessibility-naming-conventions.md @@ -0,0 +1,188 @@ +# Accessibility & Naming Conventions + +> Niche, quality-raising checks: **contrast between semantic fg/bg token pairs**, **minimum text sizes**, **naming-convention conformance**, and **orphan / duplicate / unused / mis-tiered tokens**. Every rule below is detectable **locally** via Figma Plugin API 1.130 — no REST, no token, no network. Each rule names the exact API call that proves it. + +--- + +## Why this exists (the code-output pain) + +Design→code generation "always messes it up unless you build each part one by one" for two structural reasons, both fixable up front: + +1. **There is no guarantee that a foreground goes with the right background.** If `color/content/on-brand` is just a loose variable that happens to sit near `color/surface/brand`, the codegen has no way to know they are a *pair*. It picks a plausible-looking combination, ships it, and you discover the 1.4:1 contrast failure only after rendering the component. Contrast is an **emergent property of a pair**, so it must be encoded as a pair — by naming convention — and checked by the linter, not by eyeballing rendered components. +2. **Ad-hoc names don't survive the trip to code.** A token called `color/blue-btn-text-2` maps to no stable code identifier, so the generator invents a name (`blueBtnText2`, `btnTextBlue`, …) and the design and code namespaces drift apart. Strict kebab/slash naming with an approved vocabulary makes the code name **derivable and stable**, so the same token is the same identifier everywhere. + +The linter closes the loop: **build → `lint_design_system` → fix → re-lint**. You stop discovering structural drift by assembling components one at a time; the file tells you. + +--- + +## The one right way (canonical rules) + +Pick this structure. Do not improvise alternatives. + +### 1. Three token tiers, three collections — strict one-directional layering + +| Tier | Collection | Modes | Holds | May reference | +| --- | --- | --- | --- | --- | +| **Primitive** | `primitives` | 1 (`value`) | raw literals only | nothing (literals only) | +| **Semantic** | `theme` | ≥2 (`light`, `dark`, …) | **aliases only** | `primitives` only | +| **Component** | `component` | mirrors `theme` or 1 | **aliases only** | `theme` only | + +Hard layering laws (each is a linter rule): + +- A **primitive** value in every mode is a literal (`{r,g,b,a}` / number / string), **never** a `VARIABLE_ALIAS`. → `RULE TOK-01` +- A **semantic** value in every mode is a `VARIABLE_ALIAS` into `primitives`, **never** a literal. A raw hex sitting in the `theme` tier is the single most common cause of code drift. → `RULE TOK-02` +- A **component** value is a `VARIABLE_ALIAS` into `theme`. It must **not** alias a primitive directly (skipping the semantic tier defeats theming). → `RULE TOK-03` +- **Themes live in modes, not in names.** There is no `color/surface/base-dark`. Light/dark are two modes of the *same* `color/surface/base` variable. → `RULE TOK-04` + +### 2. Naming grammar (kebab + slash, closed vocabulary) + +- Every slash segment matches `^[a-z0-9]+(-[a-z0-9]+)*$` — lowercase kebab, no camelCase, no spaces, no underscores. → `RULE NAME-01` +- Path shape is `//`, depth **2–4** segments. → `RULE NAME-02` +- Numeric scale steps are **zero-padded to 3 digits** (`050`, `100`, `500`, `900`) so lexical and numeric sort agree. → `RULE NAME-03` +- **Primitive** names are ramps: `color//`, `space/`, `radius/`, `font-size/`. Raw hue words (`blue`, `amber`) are allowed here and **only** here. → `RULE NAME-04` +- **Semantic** names are roles from a **closed allowlist** — no raw hues, no component names. Approved color roles: `surface`, `content`, `border`, `icon`, plus intent modifiers `brand | neutral | info | success | warning | danger`, plus the pairing prefix `on-*`. A segment outside the allowlist is an ad-hoc name and fails. → `RULE NAME-05` +- Every non-hidden variable has a `codeSyntax.WEB` entry, and it is the dotted/kebab form of the path (`color.surface.brand`). Missing or mismatched codeSyntax is what forces the generator to guess. → `RULE NAME-06` +- No two non-hidden variables produce the **same code identifier** across collections (namespace collision). → `RULE NAME-07` + +### 3. The `on-*` pairing convention (this is what makes contrast checkable) + +**For every background token `color/surface/` there MUST exist a foreground token `color/content/on-` in the same `theme` collection.** That pair — and only that pair — is the contract the linter checks for contrast, in **every mode**. + +- Missing counterpart (`surface/x` with no `content/on-x`, or vice-versa) → `RULE PAIR-01`. +- For each pair, in each mode, resolve both aliases to concrete RGB and compute contrast (below). → `RULE A11Y-01`. + +This makes contrast a **deterministic, node-free computation over `valuesByMode`** — you never have to render a component to know if it passes. + +### 4. Minimum text size floor + +- No `TextStyle` and no `font-size` primitive is below **12px** (absolute floor). → `RULE A11Y-04` +- Default body text style is **≥ 16px**; a text style tagged/ named `body*` below 16px is a warning. → `RULE A11Y-05` +- The 4.5:1 vs 3:1 contrast threshold is chosen per style using the WCAG **large-text** rule: `fontSize ≥ 24px`, **or** `fontSize ≥ 18.66px` AND weight ≥ 700 (from `TextStyle.fontName.style`). → feeds `A11Y-01`. + +--- + +## Contrast math (compute locally — no rendering needed for solid opaque tokens) + +Figma variable colors are already `{r,g,b,a}` floats in `0..1`. For an **opaque solid** pair you have everything; compute WCAG directly. + +**WCAG 2.x relative luminance & ratio** (this is the enforcement gate): + +``` +linearize(c) = c <= 0.03928 ? c/12.92 : ((c + 0.055)/1.055) ** 2.4 // c in 0..1 +L = 0.2126*linearize(r) + 0.7152*linearize(g) + 0.0722*linearize(b) +ratio = (Llighter + 0.05) / (Ldarker + 0.05) // 1..21 +``` + +Thresholds (gate = **fail below**): + +| Context | WCAG min | +| --- | --- | +| Body / normal text | **4.5 : 1** | +| Large text (≥24px, or ≥18.66px bold) | **3 : 1** | +| Borders / icons / focus rings (`border/*`, `icon/*`) — graphical-object contrast, SC 1.4.11 | **3 : 1** | + +**APCA (advisory, reported not gated):** also emit APCA `Lc` from the resolved RGB using the bundled offline `apca-w3` (0.1.9) constants shipped in the recipe — do **not** approximate with a homemade polynomial. Advisory targets: `Lc ≥ 60` body, `Lc ≥ 45` large, `Lc ≥ 75` fine print. WCAG remains the pass/fail gate until WCAG 3 is final. → `RULE A11Y-02` (advisory). + +**When the raw-RGB shortcut is invalid — you MUST fall back to pixel sampling:** the WCAG formula above is only truthful for a **flat, fully-opaque solid** foreground on a **flat, fully-opaque solid** background. It lies when any of these is true: + +- a paint has `color.a < 1` or `paint.opacity < 1` (must composite over the actual backdrop first), +- the fill `type !== 'SOLID'` (gradient / image / video), +- effects, blend modes, or overlapping layers sit between fg and bg. + +In those cases resolve nothing — render truth instead: `node.exportAsync({format:'PNG'})` a small swatch of the fg-over-bg composite and sample pixel luminance. Detectable via `paint.opacity`, `paint.type`, `color.a`, `node.effects`, `node.blendMode`. → `RULE A11Y-03`. + +**Mode handling:** evaluate every pair in **every mode** of the `theme` collection straight from `valuesByMode`. Do **not** rely on per-node resolved-mode traversal for the gate — `node.explicitVariableModes` only carries explicit overrides and is unreliable for deep nodes. The mode-complete sweep over token pairs is both simpler and more complete. + +**Optional empirical sweep (advisory):** additionally, `figma.root.findAllWithCriteria({ types: ['TEXT'] })`; for each text node read `fills[0].boundVariables?.color` → variable `F`, walk ancestors to the nearest node with a bound solid fill variable `B`, and check `contrast(F,B)` per theme mode. This catches *rendered* pairs that dodged the `on-*` convention. It supplements, never replaces, the deterministic pair gate. + +--- + +## Orphan / duplicate / unused / mis-tiered detection + +Build one **usage set** = (every variable id in any node's `boundVariables` / `paints[].boundVariables`, via `findAllWithCriteria`) ∪ (every alias-target id found while scanning all variables' `valuesByMode`). + +- **Broken alias (error):** a `valuesByMode` `VARIABLE_ALIAS` whose target id no longer resolves via `getVariableByIdAsync`. → `RULE ORPH-01` +- **Unused (warn, advisory):** a variable id absent from the usage set. **Caveat:** cross-file library usage is invisible to the local Plugin API — a token used only in another file looks unused. Emit as a warning, never auto-delete. → `RULE ORPH-02` +- **Duplicate primitive (warn):** two primitives with identical resolved value in the same mode → consolidate to one. Compare `valuesByMode` after alias resolution. → `RULE ORPH-03` +- **Duplicate semantic intent (warn):** two `theme` tokens whose full alias chains land on the same primitive in the same mode AND share a role stem → likely a rename or a merge. → `RULE ORPH-04` + +--- + +## Worked example (real collection / variable / scope / alias names) + +### `primitives` (1 mode: `value`) + +``` +color/neutral/000 = #FFFFFF +color/neutral/900 = #111418 +color/blue/050 = #EFF4FF +color/blue/600 = #1D4ED8 +color/amber/300 = #FCD34D +font-size/100 = 14 (scope: FONT_SIZE) +font-size/200 = 16 (scope: FONT_SIZE) +``` + +### `theme` (2 modes: `light`, `dark`) — aliases only + +| Variable | `light` → alias | `dark` → alias | +| --- | --- | --- | +| `color/surface/base` | `color/neutral/000` | `color/neutral/900` | +| `color/content/on-base` | `color/neutral/900` | `color/neutral/000` | +| `color/surface/brand` | `color/blue/600` | `color/blue/600` | +| `color/content/on-brand` | `color/neutral/000` | `color/neutral/000` | +| `color/surface/warning` | `color/amber/300` | `color/amber/300` | +| `color/content/on-warning` | `color/neutral/000` ⚠️ | `color/neutral/000` ⚠️ | + +### `component` (mirrors `theme`) — aliases into `theme` only + +``` +button/primary/bg → color/surface/brand +button/primary/label → color/content/on-brand +``` + +### What the linter computes (per pair, per mode) + +- `surface/base` ↔ `content/on-base`: light `#FFFFFF`↔`#111418` ≈ **17 : 1** PASS; dark inverse PASS. +- `surface/brand` ↔ `content/on-brand`: `#1D4ED8`↔`#FFFFFF` ≈ **6.6 : 1** PASS (AA normal + AAA large). +- **`surface/warning` ↔ `content/on-warning`: `#FCD34D`↔`#FFFFFF` ≈ 1.36 : 1 → `A11Y-01` ERROR.** + +### The deliberate failure it catches, and the fix + +White-on-amber is the classic mistake a component-by-component build would ship. The linter flags it from `valuesByMode` alone — before any button exists. **Fix:** re-point `color/content/on-warning` (both modes) → `color/neutral/900` (`#111418`), giving ≈ **12 : 1**. Re-lint → green. + +This is exactly the guarantee that lets design→code stop being "build one piece, find the break, repeat." + +--- + +## How the linter detects each rule (Plugin API 1.130 grounding) + +| Rule | Detection surface | +| --- | --- | +| TOK-01/02/03/04 (tiering, aliasing, modes) | `getLocalVariableCollectionsAsync()` → `.modes` / `.defaultModeId`; `getLocalVariablesAsync()` → `.variableCollectionId`, `.valuesByMode` (literal vs `{type:'VARIABLE_ALIAS'}`), resolve target's collection via `getVariableByIdAsync` | +| NAME-01…05 (grammar, vocabulary) | `Variable.name`, `VariableCollection.name`, style `.name` — regex + allowlist | +| NAME-06 (codeSyntax) | `Variable.codeSyntax` (WEB/ANDROID/iOS keys) | +| NAME-07 (code collision) | derive identifier from `codeSyntax.WEB` ?? name; detect dupes | +| PAIR-01 (`on-*` completeness) | set-diff over `Variable.name` within `theme` collection | +| A11Y-01 (WCAG pair gate) | `valuesByMode` → resolve alias chain → RGB → luminance formula; large-text branch from `TextStyle.fontSize` + `fontName.style` | +| A11Y-02 (APCA advisory) | same resolved RGB → bundled `apca-w3` | +| A11Y-03 (pixel fallback trigger) | `paint.opacity`, `paint.type`, `color.a`, `node.effects`, `node.blendMode`; `node.exportAsync({format:'PNG'})` sampling | +| A11Y-04/05 (text floor) | `getLocalTextStylesAsync()` → `.fontSize`, `.fontName`; `font-size` primitives with scope `FONT_SIZE` | +| ORPH-01 (broken alias) | `valuesByMode` alias id → `getVariableByIdAsync` returns null | +| ORPH-02 (unused) | usage set from `findAllWithCriteria(...).boundVariables` + `paints[].boundVariables` ∪ alias refs; advisory (cross-file blind) | +| ORPH-03/04 (duplicates) | resolved `valuesByMode` value equality within tier | + +Anything **not** on this table is **not** locally detectable — e.g. real cross-file token usage, and true rendered contrast through library-component overlays you can't reach. Report those as advisory-only and say so; never gate on them. + +--- + +## Common mistakes to avoid + +- **Encoding a theme in the name** (`surface/base-dark`) instead of a mode. Breaks `TOK-04`, doubles your token count, and confuses codegen theming. +- **Literals in the semantic tier** (`color/surface/brand = #1D4ED8`). This is the #1 code-drift cause — semantic must alias. Breaks `TOK-02`. +- **Component tokens aliasing primitives directly**, skipping `theme`. Themes stop propagating. Breaks `TOK-03`. +- **Checking every fg against every bg.** Combinatorial noise. Check the **`on-*` pairs only** for the gate; use the node sweep as advisory. +- **Trusting the WCAG formula on translucent / gradient / effect-laden fills.** It will read "pass" on a lie. Fall back to `exportAsync` pixel sampling (`A11Y-03`). +- **Auto-deleting "unused" tokens.** Local usage is file-scoped; a library consumer elsewhere still needs it. Warn, don't delete (`ORPH-02`). +- **Missing / hand-wavy `codeSyntax`.** Without it the generator invents identifiers. Every non-hidden variable needs a WEB codeSyntax that matches its path (`NAME-06`). +- **Un-padded scale steps** (`color/blue/50` next to `color/blue/500`). Sort order breaks; pad to 3 (`NAME-03`). +- **Approximating APCA by hand.** Ship and call the vendored `apca-w3` constants; a homemade curve gives wrong `Lc`. diff --git a/figma-limitless-mcp/server/skills/canonical-structure.md b/figma-limitless-mcp/server/skills/canonical-structure.md new file mode 100644 index 0000000..537b090 --- /dev/null +++ b/figma-limitless-mcp/server/skills/canonical-structure.md @@ -0,0 +1,185 @@ + +# The Flawless Design-System Structure (Canonical Build Order) + +> This is the single source of truth the AI follows **top-to-bottom** when building or repairing a design system in Figma via `figma-limitless-mcp`. It exists to kill trial-and-error: the hardest problem — *how tokens are derived (primitive → semantic → component) and why code output "always messes it up unless we build each part one by one"* — is solved by making the derivation **explicit, ordered, and lint-gated**. Build one layer, lint it, fix, then descend. Never skip a gate. + +## 0. The Iron Law of the Three Tiers + +Every value in a correct system lives in exactly one of three collections, and data only ever flows **down one tier at a time**: + +``` +Primitive (raw literals, no aliases) e.g. color/blue/500 = #2F6FED + │ alias, exactly one tier down + ▼ +Semantic (aliases only, theme-aware modes) e.g. color/bg/accent → {color/blue/500} + │ alias, exactly one tier down + ▼ +Component (aliases only, single mode) e.g. button/bg/rest → {color/bg/accent} +``` + +Nodes and component layers **bind to Component tokens** (or Semantic where no component token is warranted) — **never to Primitives**. This is the rule `design-to-code-correctness` depends on: codegen reads the binding chain and emits `button.bg.rest` → `--color-bg-accent` → `#2F6FED`. Bind a node straight to `color/blue/500` and codegen emits a magic literal — the exact failure the owner keeps hitting. + +**Lint gate after this section:** `three-tier-collections-exist`, `no-node-binds-primitive`, `alias-one-tier-down`. + +--- + +## 1. Collections & Modes (build these first, empty) + +Create exactly these collections with these mode axes. **One theme axis per collection** — never mix brand and appearance modes in one collection. + +| Collection | Purpose | Modes (this is the whole mode set) | `defaultModeId` | +|---|---|---|---| +| **Primitives** | Raw literals. The palette, the raw scale. | **single mode** `Value` | `Value` | +| **Semantic** | Theme-aware roles. Aliases into Primitives. | `Light`, `Dark` (add `hc-light`/`hc-dark` only if HC is real) | `Light` (the base) | +| **Component** | Per-component tokens. Aliases into Semantic. | **single mode** `Value` | `Value` | + +Rules baked in here: +- Primitives + Component are **single-mode** (`primitive-component-single-mode`). Theming happens **only** in Semantic. If a primitive needs two values, it was mis-tiered — it is a semantic role. +- Mode names are consistent across the appearance axis (`consistent-mode-names-across-axis`), the default Semantic mode is the neutral base (`semantic-default-mode-is-base` → `Light`). +- Keep the mode count sane: `mode-count-ceiling` (soft cap, heuristic/config: appearance ≤ 4). Multi-brand goes in its **own** collection with its own alias discipline (`one-theme-axis-per-collection`, `multi-brand-alias-discipline`), never as extra modes stapled onto Semantic. + +**Lint gate:** `primitive-component-single-mode`, `mode-count-ceiling`, `semantic-default-mode-is-base`, `consistent-mode-names-across-axis`, `one-theme-axis-per-collection`. + +--- + +## 2. Tier 1 — Primitives (raw values only) + +Populate Primitives with **literals only — zero aliases** (`primitive-raw-values-only`). + +**Naming** (`hue-ramp-words-primitives-only`, `numeric-scale-zero-padded`, `name-kebab-segments`, `name-slash-structure-depth`): +``` +color/{hue}/{step} color/blue/500 color/slate/050 (steps zero-padded, 050→900) +space/{step} space/04 space/16 (numeric scale) +radius/{step} radius/sm radius/lg +font-size/{step} font-size/md +font-family/{role} font-family/sans +``` +- Hue words (`blue`, `slate`) are **legal in Primitives only** — they must never appear in Semantic/Component names. +- Slash depth ≤ 3, every segment kebab-case, numeric ramps zero-padded so they sort. + +**Scopes** — even primitives get typed scopes so they can't be mis-bound (`scope-legal-for-resolved-type`, `no-all-scopes-on-typed-token`): +- COLOR primitive → `ALL_FILLS, STROKE_COLOR` (not `ALL_SCOPES`). +- FLOAT space/gap primitive → `GAP, WIDTH_HEIGHT`. +- FLOAT radius primitive → `CORNER_RADIUS`. +- FLOAT font-size → `FONT_SIZE`. +- STRING font-family → `FONT_FAMILY`. + +**Publishing:** primitives are **hidden from publishing** (`primitive-hidden-from-publishing`) — consumers get Semantic/Component, not the raw palette. Deduplicate: no two primitives with the same value (`duplicate-primitive-value`). + +**Lint gate:** `primitive-raw-values-only`, `hue-ramp-words-primitives-only`, `numeric-scale-zero-padded`, `primitive-hidden-from-publishing`, `duplicate-primitive-value`, and the scope pack (`scope-legal-for-resolved-type`, `no-all-scopes-on-typed-token`, `non-redundant-scope-set`). + +--- + +## 3. Tier 2 — Semantic (aliases + theming, the derivation heart) + +This is where the owner's "how tokens should be derived" problem is actually answered. Every Semantic variable is an **alias into a Primitive** (`alias-one-tier-down`, `alias-target-resolves`) and is **populated in *every* mode** (`semantic-alias-in-every-mode`). + +**Role vocabulary is a closed allowlist** (`semantic-role-allowlist`) — the only legal top segments in Semantic: +``` +color/bg/* surface & fills color/bg/canvas, color/bg/surface, color/bg/accent +color/fg/* text & icons color/fg/default, color/fg/muted, color/fg/on-accent +color/border/* strokes color/border/default, color/border/focus +space/* layout spacing roles space/inset-md, space/stack-lg +radius/* role radii radius/control, radius/card +text/* composite type roles text/body-md, text/heading-lg +``` + +**The fg/bg pairing law** (`surface-on-pair-completeness`): every `color/bg/{role}` that carries content must have a matching `color/fg/on-{role}`. `bg/accent` ⇒ `fg/on-accent`. This is what makes contrast checkable and codegen's surface pairs complete. + +**Theming = re-aliasing per mode**, never new literals: +``` +color/bg/accent Light → {color/blue/600} Dark → {color/blue/400} +color/fg/default Light → {color/slate/900} Dark → {color/slate/050} +``` +Both modes alias primitives; only the *target* changes. This gives free, correct dark mode (`theming-with-modes`). + +**Scopes are role-matched** (`color-role-scope-match`, `dimension-role-scope-match`, `type-role-scope-match`): +| Role | Legal scopes | +|---|---| +| `color/bg/*` | `FRAME_FILL, SHAPE_FILL` | +| `color/fg/*` | `TEXT_FILL` (+ `SHAPE_FILL` for icons) | +| `color/border/*` | `STROKE_COLOR` | +| `space/*` | `GAP, WIDTH_HEIGHT` (inset roles: also padding via `GAP`) | +| `radius/*` | `CORNER_RADIUS` | +| `text/size` | `FONT_SIZE` | +No token ever carries `TEXT_CONTENT` (`no-text-content-scope-on-token` — that scope is for string content variables, not design tokens). + +**Lint gate:** `alias-one-tier-down`, `alias-target-resolves`, `semantic-alias-in-every-mode`, `semantic-role-allowlist`, `surface-on-pair-completeness`, `color-role-scope-match`, `dimension-role-scope-match`, `type-role-scope-match`, `no-text-content-scope-on-token`, `alias-graph-acyclic-max-depth-2`. + +--- + +## 4. Tier 3 — Component tokens (single mode, alias Semantic) + +Component tokens exist **only** where a component needs a named, swappable hook. Each **must alias a Semantic token** (`component-token-must-alias-semantic`) — never a Primitive, never a raw value. + +``` +button/bg/rest → {color/bg/accent} +button/bg/hover → {color/bg/accent-hover} +button/fg/label → {color/fg/on-accent} +button/radius → {radius/control} +``` +- Naming: `{component}/{part}/{state}` — kebab segments, depth ≤ 3. +- **Pass-through discipline** (`single-use-component-passthrough`): if a component token is a bare 1:1 alias used exactly once with no state variance, flag it — it may be redundant indirection; bind the layer to the Semantic token directly instead. +- Kill orphans: any Component (or Semantic) variable with zero bindings and zero downstream aliases is dead (`unused-variable-orphan`). + +**Lint gate:** `component-token-must-alias-semantic`, `single-use-component-passthrough`, `unused-variable-orphan`, `alias-graph-acyclic-max-depth-2` (re-run whole graph — max depth Component→Semantic→Primitive = 2 hops). + +--- + +## 5. Codegen contract (make design → code deterministic) + +The moment tokens are stable, lock the codegen surface. This is the layer that makes output reproducible instead of "messed up". + +Every **published** variable must carry: +- `codeSyntax.WEB` set (`published-variable-has-codesyntax-web`), **unique** across the system (`codesyntax-web-unique`), and **derivable from its name** (`codesyntax-web-matches-name` → `color/bg/accent` ⇒ `--color-bg-accent` or `colorBgAccent`, per convention). +- A `description` (`published-variable-has-description`) — the human/codegen intent. + +Every **ComponentSet** must have a code mapping (`component-set-has-code-mapping`) — the Plugin API supplies the prop keys via `componentPropertyDefinitions`; the mapping half is stored in the local MCP `get_code_mappings`/`set_code_mapping` store (local, no REST). This binds Figma variant props to code component props so codegen emits `