pi-mono-figma 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -1
- package/README.md +68 -3
- package/__tests__/code-connect.test.ts +32 -0
- package/__tests__/figma-assets.test.ts +38 -0
- package/__tests__/figma-component-hints.test.ts +23 -0
- package/__tests__/figma-implementation-layout.test.ts +47 -0
- package/__tests__/figma-search.test.ts +51 -0
- package/__tests__/figma-summarizer.test.ts +65 -0
- package/__tests__/fixtures/complex-auto-layout.json +115 -0
- package/__tests__/fixtures/component-instance.json +50 -0
- package/__tests__/fixtures/hidden-and-vectors.json +28 -0
- package/__tests__/fixtures/variables-and-styles.json +40 -0
- package/docs/live-selection-bridge.md +16 -0
- package/package.json +4 -1
- package/skills/figma/SKILL.md +38 -8
- package/src/code-connect.ts +110 -0
- package/src/figma-assets.ts +146 -0
- package/src/figma-client.ts +138 -2
- package/src/figma-component-hints.ts +87 -0
- package/src/figma-implementation.ts +264 -0
- package/src/figma-schemas.ts +57 -2
- package/src/figma-search.ts +195 -0
- package/src/figma-summarizer.ts +68 -1
- package/src/figma-tokens.ts +57 -0
- package/src/figma-tools.ts +86 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-mono-figma
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.2.0
|
|
4
4
|
|
|
5
5
|
### Minor Changes
|
|
6
6
|
|
|
@@ -12,6 +12,25 @@
|
|
|
12
12
|
- Updated tool descriptions, README, and skill guidance to prefer processed tools and keep raw JSON tools as debugging escape hatches.
|
|
13
13
|
- Added response caps, truncation metadata, and next-step suggestions for summarized node output.
|
|
14
14
|
|
|
15
|
+
### Added: Dev Mode parity helpers
|
|
16
|
+
|
|
17
|
+
- Added golden fixture tests for Figma summarization and design-to-code helper behavior.
|
|
18
|
+
- Added `figma_find_nodes_by_name` and `figma_find_nodes_by_text` for compact path-aware layer/text search.
|
|
19
|
+
- Enriched `figma_get_implementation_context` with CSS layout, responsive, accessibility, design token, framework, and starter snippet hints.
|
|
20
|
+
- Added `figma_extract_assets` for SVG/icon exports, node renders, image fill downloads, hashes, byte sizes, suggested names, and node-path manifests.
|
|
21
|
+
- Added `figma_find_code_connect_mapping` for bounded local Code Connect/Figma reference discovery.
|
|
22
|
+
- Added `figma_get_component_implementation_hints` for higher-level component implementation guidance.
|
|
23
|
+
- Documented live selection parity as future plugin/bridge work rather than a REST baseline feature.
|
|
24
|
+
|
|
25
|
+
## 0.1.2
|
|
26
|
+
|
|
27
|
+
### Patch Changes
|
|
28
|
+
|
|
29
|
+
### Fixed: package extension entrypoint
|
|
30
|
+
|
|
31
|
+
- Added the package root `index.ts` extension entrypoint so pi can load the Figma tools from the published package manifest.
|
|
32
|
+
- Documented the extension's benefits over Figma MCP in the package README.
|
|
33
|
+
|
|
15
34
|
## 0.1.1
|
|
16
35
|
|
|
17
36
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -2,16 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
A pi extension and skill package that exposes native Figma tools for design exploration and design-to-code workflows. The default tools return compact, LLM-ready design context instead of raw Figma JSON.
|
|
4
4
|
|
|
5
|
+
## Benefits over Figma MCP
|
|
6
|
+
|
|
7
|
+
This package is Pi-native and uses Figma's REST API directly, with tools designed specifically for LLM-friendly design exploration and design-to-code workflows.
|
|
8
|
+
|
|
9
|
+
- **No hosted Figma MCP quota path:** the extension calls Figma's REST API directly instead of using a hosted Claude/Figma connector quota path. It is still subject to Figma API limits, and the client smooths calls with a fixed 1s limiter plus a 5-minute TTL cache.
|
|
10
|
+
- **Better LLM-shaped output:** `figma_get_node_summary`, `figma_explain_node`, and `figma_get_implementation_context` avoid raw Figma JSON by default. They cap depth, skip hidden nodes, vector internals, and component internals unless requested, and return `metadata.nextSteps` when follow-up inspection would help.
|
|
11
|
+
- **Design-to-code specialization:** `figma_get_implementation_context` extracts fields, buttons, layout measurements, typography, colors, spacing, CSS/flex/grid hints, responsive guidance, accessibility hints, design tokens, assets, and optional framework starter snippets instead of simply relaying generic server output.
|
|
12
|
+
- **Safer local auth UX:** `figma_configure_auth` uses masked local prompting and stores the token in Pi auth storage. The model never sees the token.
|
|
13
|
+
- **Good raw escape hatches:** raw `figma_get_file` and `figma_get_nodes` tools are available for debugging, while tool descriptions steer agents toward processed tools first.
|
|
14
|
+
- **Local asset handling:** `figma_render_nodes` can download rendered images to an OS temp directory by default, while `figma_extract_assets` returns a manifest for SVG icons, node renders, and image fills with node paths, hashes, byte sizes, and suggested names. Persistent project directories are used only when `outputDir` is explicitly provided.
|
|
15
|
+
- **Broader inspection surface:** styles, variables, components, component sets, component search, metadata, text extraction, rendering, summaries, explanations, and implementation context are exposed as separate native tools.
|
|
16
|
+
|
|
5
17
|
## Tools
|
|
6
18
|
|
|
7
19
|
### Processed, LLM-ready tools (preferred)
|
|
8
20
|
|
|
9
21
|
- `figma_parse_url` — parse a Figma URL into `fileKey` and `nodeId`.
|
|
10
|
-
- `
|
|
22
|
+
- `figma_find_nodes_by_name` — search layer/node names in a file or subtree and return compact path-aware matches.
|
|
23
|
+
- `figma_find_nodes_by_text` — search visible text in a file or subtree and return matches with nearest parent context.
|
|
24
|
+
- `figma_render_nodes` — render node image URLs and optionally download assets locally. Downloads use an OS temp directory by default unless the user explicitly provides `outputDir`.
|
|
11
25
|
- `figma_get_node_summary` — fetch a compact structured summary of a node: name, type, size, layout, spacing, visual style, visible text, component properties, and shallow child hierarchy.
|
|
12
26
|
- `figma_extract_text` — return visible text nodes only.
|
|
13
27
|
- `figma_explain_node` — explain a node in Markdown using summary, visible text, hierarchy, and optional rendered asset.
|
|
14
|
-
- `figma_get_implementation_context` — return coding-ready design context: purpose, sections, fields/buttons, measurements, typography, colors, spacing, assets, and
|
|
28
|
+
- `figma_get_implementation_context` — return coding-ready design context: purpose, sections, fields/buttons, measurements, typography, colors, spacing, CSS layout, responsive hints, accessibility hints, design token resolution, assets, component hierarchy, and optional snippets.
|
|
29
|
+
- `figma_extract_assets` — extract SVG/icon exports, node renders, and image fills into a node-path manifest with hashes and local paths.
|
|
30
|
+
- `figma_find_code_connect_mapping` — scan the current repo for Code Connect files, `figma.connect(...)`, Figma URLs/node IDs, and component key references.
|
|
31
|
+
- `figma_get_component_implementation_hints` — combine summary, implementation context, variants/properties, tokens, assets, accessibility, optional Code Connect matches, and starter snippets.
|
|
15
32
|
- `figma_get_design_context` — fetch compact file context. With `nodeId`, returns target node summary plus location/sibling context; without `nodeId`, returns canvases and top-level frames only.
|
|
16
33
|
- `figma_get_node_metadata` — fetch compact spatial/layout metadata for one or more nodes.
|
|
17
34
|
- `figma_get_styles` — fetch named styles.
|
|
@@ -42,17 +59,61 @@ figma_explain_node
|
|
|
42
59
|
|
|
43
60
|
```text
|
|
44
61
|
figma_parse_url
|
|
62
|
+
figma_find_nodes_by_name or figma_find_nodes_by_text when the URL does not include the exact target node
|
|
45
63
|
figma_render_nodes
|
|
46
|
-
figma_get_implementation_context
|
|
64
|
+
figma_get_implementation_context with framework/styling when useful
|
|
47
65
|
figma_get_node_summary for specific subnodes if needed
|
|
48
66
|
```
|
|
49
67
|
|
|
68
|
+
Example implementation-context options:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
{
|
|
72
|
+
framework: "react",
|
|
73
|
+
styling: "styled-components",
|
|
74
|
+
resolveTokens: true,
|
|
75
|
+
includeCodeSnippets: true
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Finding a frame or layer before implementation
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
figma_get_design_context
|
|
83
|
+
figma_find_nodes_by_name query="Checkout" nodeId=<top-level-frame-if-known>
|
|
84
|
+
figma_find_nodes_by_text query="Submit" nodeId=<candidate-frame>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Extracting assets for a frame
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
figma_extract_assets assetTypes=["svgIcons", "nodeRenders", "imageFills"]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Use returned `nodePath`, `suggestedName`, `sha256`, and `bytes` to map downloaded files back to Figma layers and avoid duplicate assets.
|
|
94
|
+
Omit `outputDir` unless the user asked for files to be saved in a persistent project location; the default is an OS temp directory.
|
|
95
|
+
|
|
96
|
+
### Finding local Code Connect mappings
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
figma_find_code_connect_mapping fileKey=<fileKey> nodeId=<nodeId>
|
|
100
|
+
figma_get_component_implementation_hints includeCodeConnect=true framework="react"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Use Code Connect matches only as local implementation hints; no Figma write access is used.
|
|
104
|
+
|
|
50
105
|
### Debugging raw Figma data
|
|
51
106
|
|
|
52
107
|
```text
|
|
53
108
|
figma_get_nodes
|
|
54
109
|
```
|
|
55
110
|
|
|
111
|
+
## Live selection boundary
|
|
112
|
+
|
|
113
|
+
This package is REST/API-based and read-only. It can inspect files, nodes, styles, variables, renders, and local repository mappings when you provide a file key/node ID, but it does **not** currently know the live selection in an open Figma desktop/browser session.
|
|
114
|
+
|
|
115
|
+
True Dev Mode-style live selection parity would require a separate local bridge or Figma plugin that captures the current selection and exposes selected file/node IDs to Pi. See [`docs/live-selection-bridge.md`](docs/live-selection-bridge.md) for a future architecture sketch.
|
|
116
|
+
|
|
56
117
|
## Processed node options
|
|
57
118
|
|
|
58
119
|
Processed tools fetch nodes with compact depth limits and summarize safely:
|
|
@@ -63,6 +124,10 @@ Processed tools fetch nodes with compact depth limits and summarize safely:
|
|
|
63
124
|
includeHidden?: boolean; // default false
|
|
64
125
|
includeVectors?: boolean; // default false
|
|
65
126
|
includeComponentInternals?: boolean; // default false
|
|
127
|
+
framework?: "react" | "html" | "vue" | "angular" | "react-native";
|
|
128
|
+
styling?: "css" | "css-modules" | "styled-components" | "tailwind" | "inline";
|
|
129
|
+
resolveTokens?: boolean; // implementation context, default true
|
|
130
|
+
includeCodeSnippets?: boolean; // implementation context, default false
|
|
66
131
|
}
|
|
67
132
|
```
|
|
68
133
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { findCodeConnectMapping } from "../src/code-connect.js";
|
|
7
|
+
|
|
8
|
+
test("findCodeConnectMapping discovers figma.connect, URLs, and node IDs", async () => {
|
|
9
|
+
const root = await mkdtemp(join(tmpdir(), "figma-code-connect-"));
|
|
10
|
+
await writeFile(join(root, "Button.figma.tsx"), "figma.connect(Button, 'https://www.figma.com/design/FILE123/Name?node-id=1-2')\n");
|
|
11
|
+
await writeFile(join(root, "README.md"), "component key COMPONENT123\n");
|
|
12
|
+
const result = await findCodeConnectMapping({ cwd: root, fileKey: "FILE123", nodeId: "1:2", componentKey: "COMPONENT123" });
|
|
13
|
+
assert.ok(result.matches.some((match) => match.kind === "figma-connect"));
|
|
14
|
+
assert.ok(result.matches.some((match) => match.kind === "figma-file-reference"));
|
|
15
|
+
assert.ok(result.matches.some((match) => match.kind === "component-key-reference"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("findCodeConnectMapping ignores node_modules and enforces caps", async () => {
|
|
19
|
+
const root = await mkdtemp(join(tmpdir(), "figma-code-connect-"));
|
|
20
|
+
await mkdir(join(root, "node_modules"));
|
|
21
|
+
await writeFile(join(root, "node_modules", "Ignored.ts"), "figma.connect(Ignored)\n");
|
|
22
|
+
await writeFile(join(root, "One.ts"), "figma.connect(One)\nfigma.connect(Two)\n");
|
|
23
|
+
const result = await findCodeConnectMapping({ cwd: root, fileKey: "FILE123", maxMatches: 1 });
|
|
24
|
+
assert.equal(result.matches.length, 1);
|
|
25
|
+
assert.equal(result.matches[0]?.path, "One.ts");
|
|
26
|
+
assert.equal(result.metadata.truncated, true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("findCodeConnectMapping rejects rootDir outside cwd", async () => {
|
|
30
|
+
const root = await mkdtemp(join(tmpdir(), "figma-code-connect-"));
|
|
31
|
+
await assert.rejects(() => findCodeConnectMapping({ cwd: root, rootDir: "/", fileKey: "FILE123" }), /rootDir/);
|
|
32
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { collectAssetCandidates, formatFromPath, safeFilename, sha256 } from "../src/figma-assets.js";
|
|
4
|
+
|
|
5
|
+
const assetTree = {
|
|
6
|
+
id: "10:1",
|
|
7
|
+
name: "Hero Card",
|
|
8
|
+
type: "FRAME",
|
|
9
|
+
absoluteBoundingBox: { width: 320, height: 200 },
|
|
10
|
+
fills: [{ type: "IMAGE", imageRef: "abc123", scaleMode: "FILL" }],
|
|
11
|
+
children: [
|
|
12
|
+
{ id: "10:2", name: "Close icon", type: "VECTOR", absoluteBoundingBox: { width: 16, height: 16 } },
|
|
13
|
+
{ id: "10:3", name: "Logo Mark", type: "FRAME", absoluteBoundingBox: { width: 32, height: 32 } },
|
|
14
|
+
{ id: "10:4", name: "Hidden asset", type: "VECTOR", visible: false, absoluteBoundingBox: { width: 16, height: 16 } },
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
test("collectAssetCandidates detects icons, node renders, image fills, and paths", () => {
|
|
19
|
+
const result = collectAssetCandidates(assetTree, { assetTypes: ["svgIcons", "nodeRenders", "imageFills"] });
|
|
20
|
+
assert.ok(result.assets.some((asset) => asset.kind === "svgIcon" && asset.nodePath === "Hero Card > Close icon"));
|
|
21
|
+
assert.ok(result.assets.some((asset) => asset.kind === "nodeRender" && asset.nodePath === "Hero Card"));
|
|
22
|
+
assert.ok(result.assets.some((asset) => asset.kind === "imageFill" && asset.imageRef === "abc123"));
|
|
23
|
+
assert.equal(result.assets.some((asset) => asset.nodeName === "Hidden asset"), false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("collectAssetCandidates supports hidden nodes and caps results", () => {
|
|
27
|
+
const result = collectAssetCandidates(assetTree, { includeHidden: true, maxAssets: 1 });
|
|
28
|
+
assert.equal(result.assets.length, 1);
|
|
29
|
+
assert.equal(result.metadata.truncated, true);
|
|
30
|
+
assert.ok(result.metadata.truncatedReasons.some((reason) => reason.includes("maxAssets")));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("asset helper utilities normalize names, hashes, and formats", () => {
|
|
34
|
+
assert.equal(safeFilename("Close Icon / Primary"), "close-icon-primary");
|
|
35
|
+
assert.equal(sha256("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
|
36
|
+
assert.equal(formatFromPath("/tmp/icon.svg"), "svg");
|
|
37
|
+
assert.equal(formatFromPath("/tmp/photo.jpeg"), "jpg");
|
|
38
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { buildComponentImplementationHints } from "../src/figma-component-hints.js";
|
|
6
|
+
import { getImplementationContext, summarizeNode } from "../src/figma-summarizer.js";
|
|
7
|
+
|
|
8
|
+
async function fixture(name: string): Promise<unknown> {
|
|
9
|
+
return JSON.parse(await readFile(join(import.meta.dirname, "fixtures", name), "utf8"));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
test("buildComponentImplementationHints combines summary, variants, accessibility, tokens, and snippets", async () => {
|
|
13
|
+
const node = await fixture("component-instance.json");
|
|
14
|
+
const summary = summarizeNode(node, { depth: 3 });
|
|
15
|
+
const context = getImplementationContext(node, { framework: "react", includeCodeSnippets: true });
|
|
16
|
+
const hints = buildComponentImplementationHints(summary, context, { framework: "react", includeSnippet: true, includeCodeConnect: true }, { rootDir: "/repo", matches: [], metadata: { truncated: false, truncatedReasons: [], nextSteps: [] } });
|
|
17
|
+
assert.equal(hints.componentName, "SettingsModal");
|
|
18
|
+
assert.ok(hints.suggestedProps.some((prop) => prop.name === "children"));
|
|
19
|
+
assert.ok(hints.statesAndVariants.some((variant) => variant.name === "State"));
|
|
20
|
+
assert.ok(hints.accessibilityRequirements.some((hint) => hint.role === "dialog" || hint.role === "button"));
|
|
21
|
+
assert.match(String(hints.frameworkHints?.snippet), /export function SettingsModal/);
|
|
22
|
+
assert.ok(hints.metadata.nextSteps.some((step) => step.includes("Code Connect")));
|
|
23
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { getImplementationContext } from "../src/figma-summarizer.js";
|
|
6
|
+
import { buildCssLayoutHints, buildResponsiveHints } from "../src/figma-implementation.js";
|
|
7
|
+
|
|
8
|
+
async function fixture(name: string): Promise<unknown> {
|
|
9
|
+
return JSON.parse(await readFile(join(import.meta.dirname, "fixtures", name), "utf8"));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
test("buildCssLayoutHints maps auto-layout to CSS flex and grid hints", async () => {
|
|
13
|
+
const node = await fixture("complex-auto-layout.json");
|
|
14
|
+
const hints = buildCssLayoutHints(node);
|
|
15
|
+
assert.deepEqual((hints.css as Record<string, unknown>).display, "flex");
|
|
16
|
+
assert.equal((hints.css as Record<string, unknown>).flexDirection, "column");
|
|
17
|
+
assert.equal((hints.css as Record<string, unknown>).gap, "16px");
|
|
18
|
+
assert.equal((hints.css as Record<string, unknown>).padding, "20px 24px 20px 24px");
|
|
19
|
+
assert.ok(Array.isArray((hints.css as Record<string, unknown>).layoutGrids));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("buildResponsiveHints recommends fill, hug, fixed, and wrap behavior", async () => {
|
|
23
|
+
const node = await fixture("complex-auto-layout.json");
|
|
24
|
+
const hints = buildResponsiveHints(node);
|
|
25
|
+
assert.ok(hints.some((hint) => String(hint.name) === "Header Row" && (hint.recommendations as string[]).some((rec) => rec.includes("width: 100%"))));
|
|
26
|
+
assert.ok(hints.some((hint) => String(hint.name) === "Dashboard Card" && (hint.recommendations as string[]).some((rec) => rec.includes("Fixed width"))));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("implementation context includes layout, responsive, accessibility, tokens, and snippets", async () => {
|
|
30
|
+
const node = await fixture("variables-and-styles.json");
|
|
31
|
+
const context = getImplementationContext(node, {
|
|
32
|
+
framework: "react",
|
|
33
|
+
styling: "styled-components",
|
|
34
|
+
includeCodeSnippets: true,
|
|
35
|
+
tokenMap: {
|
|
36
|
+
styles: { "S:primary-fill": { name: "Color/Primary", type: "FILL" }, "S:text-button": { name: "Typography/Button", type: "TEXT" } },
|
|
37
|
+
variables: { "VariableID:color-primary": { name: "color.primary" }, "VariableID:text-on-primary": { name: "color.onPrimary" }, "VariableID:radius-md": { name: "radius.md" } },
|
|
38
|
+
collections: {},
|
|
39
|
+
warnings: [],
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
assert.ok(context.cssLayout);
|
|
43
|
+
assert.ok(context.accessibility?.some((hint) => hint.role === "button"));
|
|
44
|
+
assert.ok((context.designTokens?.resolved as Array<Record<string, unknown>>).some((token) => token.name === "Color/Primary"));
|
|
45
|
+
assert.equal(context.frameworkHints?.framework, "react");
|
|
46
|
+
assert.match(String(context.frameworkHints?.snippet), /styled\.section/);
|
|
47
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { findNodesByName, findNodesByText } from "../src/figma-search.js";
|
|
6
|
+
|
|
7
|
+
async function fixture(name: string): Promise<unknown> {
|
|
8
|
+
return JSON.parse(await readFile(join(import.meta.dirname, "fixtures", name), "utf8"));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
test("findNodesByName supports partial, exact, and case-sensitive matching", async () => {
|
|
12
|
+
const node = await fixture("complex-auto-layout.json");
|
|
13
|
+
assert.deepEqual(findNodesByName(node, { query: "button" }).matches.map((match) => match.name), ["Save button", "Button label"]);
|
|
14
|
+
assert.equal(findNodesByName(node, { query: "save button", exact: true }).matches.length, 1);
|
|
15
|
+
assert.equal(findNodesByName(node, { query: "save button", exact: true, caseSensitive: true }).matches.length, 0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("findNodesByText returns path and parent context", async () => {
|
|
19
|
+
const node = await fixture("complex-auto-layout.json");
|
|
20
|
+
const result = findNodesByText(node, { query: "Water risk" });
|
|
21
|
+
assert.equal(result.matches[0]?.text, "Water risk summary");
|
|
22
|
+
assert.equal(result.matches[0]?.parent?.name, "Header Row");
|
|
23
|
+
assert.match(result.matches[0]?.path ?? "", /Dashboard Card > Header Row > Title/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("findNodesByName respects hidden and vector filters", async () => {
|
|
27
|
+
const node = await fixture("hidden-and-vectors.json");
|
|
28
|
+
assert.equal(findNodesByName(node, { query: "Hidden" }).matches.length, 0);
|
|
29
|
+
assert.equal(findNodesByName(node, { query: "Hidden", includeHidden: true }).matches.length, 1);
|
|
30
|
+
assert.equal(findNodesByName(node, { query: "icon" }).matches.length, 0);
|
|
31
|
+
assert.equal(findNodesByName(node, { query: "icon", includeVectors: true }).matches.length, 1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("findNodesByText searches collapsed instance text but not vector internals", async () => {
|
|
35
|
+
const node = await fixture("component-instance.json");
|
|
36
|
+
const result = findNodesByText(node, { query: "Continue" });
|
|
37
|
+
assert.equal(result.matches.length, 1);
|
|
38
|
+
assert.equal(result.matches[0]?.parent?.name, "Primary CTA instance");
|
|
39
|
+
assert.ok(result.metadata.nextSteps.some((step) => step.includes("includeComponentInternals")));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("findNodesByName enforces depth and result caps", async () => {
|
|
43
|
+
const node = await fixture("complex-auto-layout.json");
|
|
44
|
+
const depthLimited = findNodesByName(node, { query: "Title", depth: 1 });
|
|
45
|
+
assert.equal(depthLimited.matches.length, 0);
|
|
46
|
+
assert.ok(depthLimited.metadata.truncatedReasons.some((reason) => reason.includes("depth limit")));
|
|
47
|
+
|
|
48
|
+
const capped = findNodesByName(node, { query: "", maxResults: 1 });
|
|
49
|
+
assert.equal(capped.matches.length, 1);
|
|
50
|
+
assert.ok(capped.metadata.truncatedReasons.some((reason) => reason.includes("maxResults")));
|
|
51
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { extractVisibleText, getImplementationContext, summarizeNode } from "../src/figma-summarizer.js";
|
|
6
|
+
|
|
7
|
+
async function fixture(name: string): Promise<unknown> {
|
|
8
|
+
const path = join(import.meta.dirname, "fixtures", name);
|
|
9
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
test("summarizeNode excludes hidden nodes and vector internals by default", async () => {
|
|
13
|
+
const node = await fixture("hidden-and-vectors.json");
|
|
14
|
+
const summary = summarizeNode(node, { depth: 3 });
|
|
15
|
+
assert.deepEqual(summary.visibleText, ["Visible copy"]);
|
|
16
|
+
assert.equal(summary.children?.some((child) => child.name === "Hidden text"), false);
|
|
17
|
+
assert.equal(summary.children?.some((child) => child.name === "Search icon"), false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("summarizeNode can include hidden nodes and vectors when requested", async () => {
|
|
21
|
+
const node = await fixture("hidden-and-vectors.json");
|
|
22
|
+
const summary = summarizeNode(node, { depth: 3, includeHidden: true, includeVectors: true });
|
|
23
|
+
assert.deepEqual(summary.visibleText, ["Visible copy", "Hidden copy"]);
|
|
24
|
+
assert.ok(summary.children?.some((child) => child.name === "Hidden text"));
|
|
25
|
+
assert.ok(summary.children?.some((child) => child.name === "Search icon"));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("component instances collapse structural internals while retaining useful text", async () => {
|
|
29
|
+
const node = await fixture("component-instance.json");
|
|
30
|
+
const summary = summarizeNode(node, { depth: 2 });
|
|
31
|
+
const instance = summary.children?.find((child) => child.type === "INSTANCE");
|
|
32
|
+
assert.equal(instance?.children, undefined);
|
|
33
|
+
assert.deepEqual(instance?.text, ["Continue"]);
|
|
34
|
+
assert.ok(summary.metadata?.truncatedReasons.some((reason) => reason.includes("Collapsed component instance")));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("extractVisibleText returns capped text metadata", async () => {
|
|
38
|
+
const node = await fixture("complex-auto-layout.json");
|
|
39
|
+
const result = extractVisibleText(node, { maxVisibleText: 1 });
|
|
40
|
+
assert.deepEqual(result.texts, ["Water risk summary"]);
|
|
41
|
+
assert.equal(result.metadata.truncated, true);
|
|
42
|
+
assert.ok(result.metadata.nextSteps.includes("Use figma_extract_text on a narrower child node to see more text."));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("getImplementationContext deterministically extracts typography colors spacing and controls", async () => {
|
|
46
|
+
const node = await fixture("complex-auto-layout.json");
|
|
47
|
+
const context = getImplementationContext(node, { depth: 3 });
|
|
48
|
+
assert.match(context.purpose, /Water risk summary/);
|
|
49
|
+
assert.equal(context.sections.length, 2);
|
|
50
|
+
assert.ok(context.buttons.some((button) => button.name === "Save button"));
|
|
51
|
+
assert.ok(context.typography.some((entry) => entry.fontFamily === "Inter" && entry.fontSize === 18));
|
|
52
|
+
assert.ok(context.colors.some((entry) => entry.hex === "#ffffff"));
|
|
53
|
+
assert.ok(context.spacing.some((entry) => entry.itemSpacing === 16));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("depth and children caps produce truncation metadata", async () => {
|
|
57
|
+
const node = await fixture("complex-auto-layout.json");
|
|
58
|
+
const depthLimited = summarizeNode(node, { depth: 1 });
|
|
59
|
+
assert.equal(depthLimited.metadata?.truncated, true);
|
|
60
|
+
assert.ok(depthLimited.metadata?.nextSteps.some((step) => step.includes("depth 2")));
|
|
61
|
+
|
|
62
|
+
const childLimited = summarizeNode(node, { depth: 2, maxChildren: 1 });
|
|
63
|
+
assert.equal(childLimited.children?.length, 1);
|
|
64
|
+
assert.ok(childLimited.metadata?.truncatedReasons.some((reason) => reason.includes("Capped children")));
|
|
65
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "1:1",
|
|
3
|
+
"name": "Dashboard Card",
|
|
4
|
+
"type": "FRAME",
|
|
5
|
+
"visible": true,
|
|
6
|
+
"absoluteBoundingBox": { "width": 360, "height": 240 },
|
|
7
|
+
"layoutMode": "VERTICAL",
|
|
8
|
+
"primaryAxisAlignItems": "MIN",
|
|
9
|
+
"counterAxisAlignItems": "STRETCH",
|
|
10
|
+
"layoutWrap": "NO_WRAP",
|
|
11
|
+
"layoutSizingHorizontal": "FIXED",
|
|
12
|
+
"layoutSizingVertical": "HUG",
|
|
13
|
+
"itemSpacing": 16,
|
|
14
|
+
"paddingLeft": 24,
|
|
15
|
+
"paddingRight": 24,
|
|
16
|
+
"paddingTop": 20,
|
|
17
|
+
"paddingBottom": 20,
|
|
18
|
+
"layoutGrids": [
|
|
19
|
+
{ "pattern": "COLUMNS", "count": 12, "gutterSize": 16, "sectionSize": 56 }
|
|
20
|
+
],
|
|
21
|
+
"fills": [
|
|
22
|
+
{ "type": "SOLID", "color": { "r": 1, "g": 1, "b": 1 }, "opacity": 1 }
|
|
23
|
+
],
|
|
24
|
+
"strokes": [
|
|
25
|
+
{
|
|
26
|
+
"type": "SOLID",
|
|
27
|
+
"color": { "r": 0.8, "g": 0.84, "b": 0.9 },
|
|
28
|
+
"opacity": 1
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"cornerRadius": 12,
|
|
32
|
+
"children": [
|
|
33
|
+
{
|
|
34
|
+
"id": "1:2",
|
|
35
|
+
"name": "Header Row",
|
|
36
|
+
"type": "FRAME",
|
|
37
|
+
"absoluteBoundingBox": { "width": 312, "height": 40 },
|
|
38
|
+
"layoutMode": "HORIZONTAL",
|
|
39
|
+
"primaryAxisAlignItems": "SPACE_BETWEEN",
|
|
40
|
+
"counterAxisAlignItems": "CENTER",
|
|
41
|
+
"layoutSizingHorizontal": "FILL",
|
|
42
|
+
"layoutSizingVertical": "HUG",
|
|
43
|
+
"itemSpacing": 8,
|
|
44
|
+
"children": [
|
|
45
|
+
{
|
|
46
|
+
"id": "1:3",
|
|
47
|
+
"name": "Title",
|
|
48
|
+
"type": "TEXT",
|
|
49
|
+
"absoluteBoundingBox": { "width": 140, "height": 24 },
|
|
50
|
+
"characters": "Water risk summary",
|
|
51
|
+
"style": {
|
|
52
|
+
"fontFamily": "Inter",
|
|
53
|
+
"fontSize": 18,
|
|
54
|
+
"fontWeight": 700,
|
|
55
|
+
"lineHeightPx": 24
|
|
56
|
+
},
|
|
57
|
+
"fills": [
|
|
58
|
+
{
|
|
59
|
+
"type": "SOLID",
|
|
60
|
+
"color": { "r": 0.05, "g": 0.1, "b": 0.18 },
|
|
61
|
+
"opacity": 1
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"id": "1:4",
|
|
67
|
+
"name": "More icon",
|
|
68
|
+
"type": "VECTOR",
|
|
69
|
+
"absoluteBoundingBox": { "width": 20, "height": 20 },
|
|
70
|
+
"fills": [
|
|
71
|
+
{
|
|
72
|
+
"type": "SOLID",
|
|
73
|
+
"color": { "r": 0.3, "g": 0.35, "b": 0.44 },
|
|
74
|
+
"opacity": 1
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"id": "1:5",
|
|
82
|
+
"name": "Save button",
|
|
83
|
+
"type": "FRAME",
|
|
84
|
+
"absoluteBoundingBox": { "width": 120, "height": 40 },
|
|
85
|
+
"layoutMode": "HORIZONTAL",
|
|
86
|
+
"primaryAxisAlignItems": "CENTER",
|
|
87
|
+
"counterAxisAlignItems": "CENTER",
|
|
88
|
+
"itemSpacing": 8,
|
|
89
|
+
"fills": [
|
|
90
|
+
{
|
|
91
|
+
"type": "SOLID",
|
|
92
|
+
"color": { "r": 0.05, "g": 0.42, "b": 0.95 },
|
|
93
|
+
"opacity": 1
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
"children": [
|
|
97
|
+
{
|
|
98
|
+
"id": "1:6",
|
|
99
|
+
"name": "Button label",
|
|
100
|
+
"type": "TEXT",
|
|
101
|
+
"characters": "Save",
|
|
102
|
+
"absoluteBoundingBox": { "width": 34, "height": 20 },
|
|
103
|
+
"style": { "fontFamily": "Inter", "fontSize": 14, "fontWeight": 600 },
|
|
104
|
+
"fills": [
|
|
105
|
+
{
|
|
106
|
+
"type": "SOLID",
|
|
107
|
+
"color": { "r": 1, "g": 1, "b": 1 },
|
|
108
|
+
"opacity": 1
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "2:1",
|
|
3
|
+
"name": "Settings modal",
|
|
4
|
+
"type": "FRAME",
|
|
5
|
+
"absoluteBoundingBox": { "width": 480, "height": 360 },
|
|
6
|
+
"children": [
|
|
7
|
+
{
|
|
8
|
+
"id": "2:2",
|
|
9
|
+
"name": "Primary CTA instance",
|
|
10
|
+
"type": "INSTANCE",
|
|
11
|
+
"componentId": "99:1",
|
|
12
|
+
"componentSetId": "99:0",
|
|
13
|
+
"componentProperties": {
|
|
14
|
+
"State": { "type": "VARIANT", "value": "Default" },
|
|
15
|
+
"Disabled": { "type": "BOOLEAN", "value": false }
|
|
16
|
+
},
|
|
17
|
+
"absoluteBoundingBox": { "width": 180, "height": 44 },
|
|
18
|
+
"children": [
|
|
19
|
+
{
|
|
20
|
+
"id": "2:3",
|
|
21
|
+
"name": "Hidden icon",
|
|
22
|
+
"type": "VECTOR",
|
|
23
|
+
"absoluteBoundingBox": { "width": 16, "height": 16 }
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "2:4",
|
|
27
|
+
"name": "Label",
|
|
28
|
+
"type": "TEXT",
|
|
29
|
+
"characters": "Continue",
|
|
30
|
+
"absoluteBoundingBox": { "width": 70, "height": 20 },
|
|
31
|
+
"style": { "fontFamily": "Inter", "fontSize": 14, "fontWeight": 600 }
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "2:5",
|
|
37
|
+
"name": "Email input field",
|
|
38
|
+
"type": "FRAME",
|
|
39
|
+
"absoluteBoundingBox": { "width": 300, "height": 48 },
|
|
40
|
+
"children": [
|
|
41
|
+
{
|
|
42
|
+
"id": "2:6",
|
|
43
|
+
"name": "Placeholder",
|
|
44
|
+
"type": "TEXT",
|
|
45
|
+
"characters": "Email address"
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "4:1",
|
|
3
|
+
"name": "Visibility Fixture",
|
|
4
|
+
"type": "FRAME",
|
|
5
|
+
"absoluteBoundingBox": { "width": 200, "height": 120 },
|
|
6
|
+
"children": [
|
|
7
|
+
{
|
|
8
|
+
"id": "4:2",
|
|
9
|
+
"name": "Visible text",
|
|
10
|
+
"type": "TEXT",
|
|
11
|
+
"characters": "Visible copy"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "4:3",
|
|
15
|
+
"name": "Hidden text",
|
|
16
|
+
"type": "TEXT",
|
|
17
|
+
"visible": false,
|
|
18
|
+
"characters": "Hidden copy"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "4:4",
|
|
22
|
+
"name": "Search icon",
|
|
23
|
+
"type": "VECTOR",
|
|
24
|
+
"absoluteBoundingBox": { "width": 16, "height": 16 },
|
|
25
|
+
"children": [{ "id": "4:5", "name": "Vector point", "type": "VECTOR" }]
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "3:1",
|
|
3
|
+
"name": "Tokenized Button",
|
|
4
|
+
"type": "FRAME",
|
|
5
|
+
"absoluteBoundingBox": { "width": 160, "height": 48 },
|
|
6
|
+
"layoutMode": "HORIZONTAL",
|
|
7
|
+
"primaryAxisAlignItems": "CENTER",
|
|
8
|
+
"counterAxisAlignItems": "CENTER",
|
|
9
|
+
"styles": { "fill": "S:primary-fill", "stroke": "S:border-default" },
|
|
10
|
+
"boundVariables": {
|
|
11
|
+
"fills": [{ "type": "VARIABLE_ALIAS", "id": "VariableID:color-primary" }],
|
|
12
|
+
"cornerRadius": { "type": "VARIABLE_ALIAS", "id": "VariableID:radius-md" }
|
|
13
|
+
},
|
|
14
|
+
"fills": [
|
|
15
|
+
{
|
|
16
|
+
"type": "SOLID",
|
|
17
|
+
"color": { "r": 0.02, "g": 0.35, "b": 0.9 },
|
|
18
|
+
"opacity": 1
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"cornerRadius": 8,
|
|
22
|
+
"children": [
|
|
23
|
+
{
|
|
24
|
+
"id": "3:2",
|
|
25
|
+
"name": "Label",
|
|
26
|
+
"type": "TEXT",
|
|
27
|
+
"characters": "Submit",
|
|
28
|
+
"style": { "fontFamily": "Inter", "fontSize": 14, "fontWeight": 600 },
|
|
29
|
+
"styles": { "text": "S:text-button" },
|
|
30
|
+
"boundVariables": {
|
|
31
|
+
"fills": [
|
|
32
|
+
{ "type": "VARIABLE_ALIAS", "id": "VariableID:text-on-primary" }
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"fills": [
|
|
36
|
+
{ "type": "SOLID", "color": { "r": 1, "g": 1, "b": 1 }, "opacity": 1 }
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|