extrafigma 0.1.0__py3-none-any.whl

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.
extrafigma/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
extrafigma/api.py ADDED
@@ -0,0 +1,117 @@
1
+ import time
2
+ from typing import Optional
3
+
4
+ import httpx
5
+
6
+ BASE_URL = "https://api.figma.com/v1"
7
+ # Figma returns very large Retry-After values for monthly quota exhaustion.
8
+ # We cap the sleep and surface a clear error instead.
9
+ _MAX_RETRY_SLEEP = 60.0
10
+
11
+
12
+ class FigmaAPIError(Exception):
13
+ def __init__(self, status_code: int, message: str):
14
+ self.status_code = status_code
15
+ super().__init__(f"Figma API {status_code}: {message}")
16
+
17
+
18
+ class FigmaAuthError(FigmaAPIError):
19
+ pass
20
+
21
+
22
+ class FigmaNotFoundError(FigmaAPIError):
23
+ pass
24
+
25
+
26
+ class FigmaPermissionError(FigmaAPIError):
27
+ pass
28
+
29
+
30
+ class FigmaRateLimitError(FigmaAPIError):
31
+ def __init__(self, retry_after: Optional[float] = None, quota_exhausted: bool = False):
32
+ self.retry_after = retry_after
33
+ self.quota_exhausted = quota_exhausted
34
+ msg = (
35
+ "Monthly API quota exhausted. Resets on the 1st of next month.\n"
36
+ " Upgrade your Figma seat to Dev/Full for 10 req/min instead of 6/month."
37
+ if quota_exhausted
38
+ else "Rate limited after retries"
39
+ )
40
+ super().__init__(429, msg)
41
+
42
+
43
+ class FigmaClient:
44
+ def __init__(self, pat: str):
45
+ self._client = httpx.Client(
46
+ headers={"X-Figma-Token": pat},
47
+ timeout=90.0,
48
+ )
49
+
50
+ def _get(self, path: str, params: Optional[dict] = None) -> dict:
51
+ url = f"{BASE_URL}{path}"
52
+ for attempt in range(3):
53
+ resp = self._client.get(url, params=params or {})
54
+ if resp.status_code == 200:
55
+ return resp.json()
56
+ if resp.status_code == 429:
57
+ retry_after = float(resp.headers.get("Retry-After", 10))
58
+ # Large values (>5 min) indicate monthly quota exhaustion, not a burst limit.
59
+ if retry_after > _MAX_RETRY_SLEEP:
60
+ raise FigmaRateLimitError(retry_after=retry_after, quota_exhausted=True)
61
+ print(f" Rate limited. Waiting {retry_after:.0f}s...")
62
+ time.sleep(retry_after)
63
+ continue
64
+ if resp.status_code == 401:
65
+ raise FigmaAuthError(401, "Invalid PAT. Run: extrafigma auth login")
66
+ if resp.status_code == 404:
67
+ raise FigmaNotFoundError(404, f"Not found: {path}")
68
+ if resp.status_code == 403:
69
+ raise FigmaPermissionError(403, resp.text[:200])
70
+ resp.raise_for_status()
71
+ raise FigmaRateLimitError()
72
+
73
+ def get_file(self, file_key: str) -> dict:
74
+ return self._get(f"/files/{file_key}", params={"geometry": "paths"})
75
+
76
+ def get_nodes(self, file_key: str, node_ids: list[str]) -> dict:
77
+ """Return {node_id: document_node} for the given node IDs."""
78
+ if not node_ids:
79
+ return {}
80
+ data = self._get(
81
+ f"/files/{file_key}/nodes",
82
+ params={"ids": ",".join(node_ids)},
83
+ )
84
+ return {
85
+ nid: info.get("document", {})
86
+ for nid, info in data.get("nodes", {}).items()
87
+ if info
88
+ }
89
+
90
+ def get_images(
91
+ self,
92
+ file_key: str,
93
+ ids: list[str],
94
+ scale: int = 2,
95
+ format: str = "png",
96
+ ) -> dict:
97
+ """Return {node_id: signed_url}. Batches handled by caller."""
98
+ if not ids:
99
+ return {}
100
+ params: dict = {"ids": ",".join(ids), "format": format}
101
+ if format == "png":
102
+ params["scale"] = scale
103
+ elif format == "svg":
104
+ params["svg_outline_text"] = "false" # keep <text> elements, not paths
105
+ params["svg_include_id"] = "false"
106
+ params["svg_simplify_stroke"] = "true"
107
+ data = self._get(f"/images/{file_key}", params=params)
108
+ return data.get("images", {})
109
+
110
+ def close(self) -> None:
111
+ self._client.close()
112
+
113
+ def __enter__(self) -> "FigmaClient":
114
+ return self
115
+
116
+ def __exit__(self, *_) -> None:
117
+ self.close()
extrafigma/auth.py ADDED
@@ -0,0 +1,33 @@
1
+ import json
2
+ import stat
3
+ from pathlib import Path
4
+
5
+
6
+ def get_credentials_path() -> Path:
7
+ config_dir = Path.home() / ".config" / "extrafigma"
8
+ config_dir.mkdir(parents=True, exist_ok=True)
9
+ return config_dir / "credentials.json"
10
+
11
+
12
+ def save_pat(pat: str) -> None:
13
+ path = get_credentials_path()
14
+ path.write_text(json.dumps({"pat": pat}, indent=2))
15
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
16
+
17
+
18
+ def load_pat() -> str:
19
+ path = get_credentials_path()
20
+ if not path.exists():
21
+ return ""
22
+ try:
23
+ return json.loads(path.read_text()).get("pat", "")
24
+ except (json.JSONDecodeError, OSError):
25
+ return ""
26
+
27
+
28
+ def delete_credentials() -> bool:
29
+ path = get_credentials_path()
30
+ if path.exists():
31
+ path.unlink()
32
+ return True
33
+ return False
extrafigma/cli.py ADDED
@@ -0,0 +1,95 @@
1
+ from typing import Optional
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.text import Text
6
+
7
+ from extrafigma.auth import load_pat, save_pat, delete_credentials, get_credentials_path
8
+ from extrafigma.utils.file_key import parse_file_key
9
+
10
+ console = Console()
11
+ app = typer.Typer(
12
+ name="extrafigma",
13
+ help="Pull Figma files into agent-readable local folders.",
14
+ no_args_is_help=True,
15
+ )
16
+ auth_app = typer.Typer(help="Manage your Figma Personal Access Token.")
17
+ app.add_typer(auth_app, name="auth")
18
+
19
+
20
+ def _require_pat() -> str:
21
+ """Return stored PAT, prompting the user to set one if not found."""
22
+ pat = load_pat()
23
+ if pat:
24
+ return pat
25
+ console.print("[yellow]No Figma PAT found.[/yellow] You only need to do this once.\n")
26
+ console.print("[dim]Get yours at: Figma → Settings → Security → Personal Access Tokens[/dim]")
27
+ console.print("[dim]Required scopes: file_content:read, file_metadata:read[/dim]\n")
28
+ pat = typer.prompt("Paste your Figma PAT", hide_input=True).strip()
29
+ if not pat:
30
+ console.print("[red]✗ No PAT provided.[/red]")
31
+ raise typer.Exit(1)
32
+ save_pat(pat)
33
+ console.print("[green]✓ PAT saved — won't ask again.[/green]\n")
34
+ return pat
35
+
36
+
37
+ @auth_app.command("login")
38
+ def auth_login(
39
+ pat: Optional[str] = typer.Option(None, "--pat", help="PAT to store (skips interactive prompt)"),
40
+ ):
41
+ """Store your Figma Personal Access Token."""
42
+ if pat:
43
+ pat = pat.strip()
44
+ else:
45
+ console.print("[dim]Generate a PAT at: Figma → Settings → Security → Personal Access Tokens[/dim]")
46
+ console.print("[dim]Required scopes: file_content:read, file_metadata:read[/dim]\n")
47
+ pat = typer.prompt("Paste your Figma PAT", hide_input=True).strip()
48
+ if not pat:
49
+ console.print("[red]✗ No PAT provided.[/red]")
50
+ raise typer.Exit(1)
51
+ save_pat(pat)
52
+ console.print("[green]✓ PAT saved.[/green]")
53
+
54
+
55
+ @auth_app.command("status")
56
+ def auth_status():
57
+ """Show PAT status."""
58
+ creds = get_credentials_path()
59
+ if not creds.exists():
60
+ console.print("[red]✗ No PAT stored.[/red] Run: extrafigma auth login")
61
+ raise typer.Exit(1)
62
+ pat = load_pat()
63
+ if not pat:
64
+ console.print("[red]✗ credentials.json exists but PAT is empty.[/red]")
65
+ raise typer.Exit(1)
66
+ masked = pat[:8] + "..." + pat[-4:] if len(pat) > 12 else "***"
67
+ console.print(f"[green]✓ PAT set[/green] ({masked})")
68
+ console.print(f" Stored at: {creds}")
69
+
70
+
71
+ @auth_app.command("logout")
72
+ def auth_logout():
73
+ """Remove stored credentials."""
74
+ if delete_credentials():
75
+ console.print("[green]✓ Credentials removed.[/green]")
76
+ else:
77
+ console.print("No credentials to remove.")
78
+
79
+
80
+ @app.command()
81
+ def pull(
82
+ url: str = typer.Argument(..., help="Figma file URL or file key"),
83
+ folder: str = typer.Argument(..., help="Output folder path"),
84
+ ):
85
+ """Pull a Figma file to a local agent-readable folder."""
86
+ from extrafigma.puller.pull import pull as do_pull
87
+
88
+ pat = _require_pat()
89
+
90
+ file_key = parse_file_key(url)
91
+ if not file_key:
92
+ console.print(f"[red]✗ Could not parse a file key from:[/red] {url}")
93
+ raise typer.Exit(1)
94
+
95
+ do_pull(file_key=file_key, figma_url=url, output_dir=folder, pat=pat)
File without changes
@@ -0,0 +1,206 @@
1
+ import re
2
+
3
+ from extrafigma.utils.color import rgba_to_hex, find_nearest
4
+ from extrafigma.utils.layout import distill_layout
5
+
6
+
7
+ def _safe_name(name: str) -> str:
8
+ name = name.lower().replace(" ", "-")
9
+ name = re.sub(r"[^a-z0-9-]", "", name)
10
+ return name or "unnamed"
11
+
12
+
13
+ class Distiller:
14
+ def __init__(self, color_map: dict, type_map: dict):
15
+ self.color_map = color_map # hex -> token_name
16
+ self.type_map = type_map # styleId -> token_name
17
+ # Populated during distillation: node_id -> filename (e.g. "icon-name.svg")
18
+ self.svg_ids: dict[str, str] = {}
19
+
20
+ def distill(self, node: dict) -> dict:
21
+ ntype = node.get("type", "UNKNOWN")
22
+ handlers = {
23
+ "FRAME": self._frame,
24
+ "COMPONENT": self._frame,
25
+ "COMPONENT_SET": self._frame,
26
+ "SECTION": self._frame,
27
+ "GROUP": self._group,
28
+ "TEXT": self._text,
29
+ "INSTANCE": self._instance,
30
+ "RECTANGLE": self._rect,
31
+ "ELLIPSE": self._rect,
32
+ "LINE": self._rect,
33
+ "VECTOR": self._vector,
34
+ "BOOLEAN_OPERATION": self._vector,
35
+ "IMAGE": self._image,
36
+ }
37
+ handler = handlers.get(ntype)
38
+ if handler:
39
+ return handler(node)
40
+ return {"type": "UNKNOWN", "nodeType": ntype, "name": node.get("name", "")}
41
+
42
+ # ── children ───────────────────────────────────────────────────────────────
43
+
44
+ def _children(self, node: dict, absolute: bool = False) -> list:
45
+ out = []
46
+ for child in node.get("children", []):
47
+ if child.get("type") == "GROUP":
48
+ out.extend(self._flatten_group(child, absolute=absolute))
49
+ else:
50
+ distilled = self.distill(child)
51
+ if absolute:
52
+ bb = child.get("absoluteBoundingBox") or {}
53
+ if bb.get("x") is not None:
54
+ distilled["x"] = bb["x"]
55
+ if bb.get("y") is not None:
56
+ distilled["y"] = bb["y"]
57
+ out.append(distilled)
58
+ return out
59
+
60
+ def _flatten_group(self, group: dict, absolute: bool = False) -> list:
61
+ out = []
62
+ for child in group.get("children", []):
63
+ if child.get("type") == "GROUP":
64
+ out.extend(self._flatten_group(child, absolute=absolute))
65
+ else:
66
+ distilled = self.distill(child)
67
+ if absolute:
68
+ bb = child.get("absoluteBoundingBox") or {}
69
+ if bb.get("x") is not None:
70
+ distilled["x"] = bb["x"]
71
+ if bb.get("y") is not None:
72
+ distilled["y"] = bb["y"]
73
+ out.append(distilled)
74
+ return out
75
+
76
+ # ── node handlers ──────────────────────────────────────────────────────────
77
+
78
+ def _frame(self, node: dict) -> dict:
79
+ bb = node.get("absoluteBoundingBox") or node.get("absoluteRenderBounds") or {}
80
+ layout = distill_layout(node)
81
+ is_absolute = layout.get("mode") == "absolute"
82
+ result: dict = {
83
+ "type": "FRAME",
84
+ "name": node.get("name", ""),
85
+ "width": bb.get("width"),
86
+ "height": bb.get("height"),
87
+ "layout": layout,
88
+ "background": self._fills(node.get("fills", [])),
89
+ "borderRadius": node.get("cornerRadius") or 0,
90
+ }
91
+ children = self._children(node, absolute=is_absolute)
92
+ if children:
93
+ result["children"] = children
94
+ return result
95
+
96
+ def _group(self, node: dict) -> dict:
97
+ bb = node.get("absoluteBoundingBox") or {}
98
+ result: dict = {
99
+ "type": "FRAME",
100
+ "name": node.get("name", ""),
101
+ "width": bb.get("width"),
102
+ "height": bb.get("height"),
103
+ "layout": {"mode": "absolute"},
104
+ "background": "transparent",
105
+ "borderRadius": 0,
106
+ }
107
+ children = self._flatten_group(node, absolute=True)
108
+ if children:
109
+ result["children"] = children
110
+ return result
111
+
112
+ def _text(self, node: dict) -> dict:
113
+ style = node.get("style", {})
114
+ bb = node.get("absoluteBoundingBox") or {}
115
+ out: dict = {
116
+ "type": "TEXT",
117
+ "name": node.get("name", ""),
118
+ "content": node.get("characters", ""),
119
+ "style": self._type(node),
120
+ "color": self._fills(node.get("fills", [])),
121
+ "align": style.get("textAlignHorizontal", "LEFT").lower(),
122
+ }
123
+ if node.get("layoutSizingHorizontal") == "FIXED" and bb.get("width"):
124
+ out["maxWidth"] = bb["width"]
125
+ return out
126
+
127
+ def _instance(self, node: dict) -> dict:
128
+ return {
129
+ "type": "INSTANCE",
130
+ "name": node.get("name", ""),
131
+ "component": node.get("name", ""),
132
+ "props": {},
133
+ }
134
+
135
+ def _rect(self, node: dict) -> dict:
136
+ bb = node.get("absoluteBoundingBox") or node.get("absoluteRenderBounds") or {}
137
+ return {
138
+ "type": "RECTANGLE",
139
+ "name": node.get("name", ""),
140
+ "width": bb.get("width"),
141
+ "height": bb.get("height"),
142
+ "fill": self._fills(node.get("fills", [])),
143
+ "borderRadius": node.get("cornerRadius") or 0,
144
+ }
145
+
146
+ def _vector(self, node: dict) -> dict:
147
+ node_id = node.get("id", "")
148
+ filename = f"{_safe_name(node.get('name', 'vector'))}.svg"
149
+ if node_id:
150
+ # Deduplicate: same name → same file
151
+ self.svg_ids[node_id] = filename
152
+ return {
153
+ "type": "VECTOR",
154
+ "name": node.get("name", ""),
155
+ "src": f"assets/{filename}",
156
+ }
157
+
158
+ def _image(self, node: dict) -> dict:
159
+ bb = node.get("absoluteBoundingBox") or {}
160
+ return {
161
+ "type": "IMAGE",
162
+ "name": node.get("name", ""),
163
+ "src": f"assets/{_safe_name(node.get('name', 'image'))}.png",
164
+ "width": bb.get("width"),
165
+ "height": bb.get("height"),
166
+ }
167
+
168
+ # ── token resolution ───────────────────────────────────────────────────────
169
+
170
+ def _fills(self, fills: list) -> str:
171
+ visible = [f for f in fills if f.get("visible", True)]
172
+ if not visible:
173
+ return "transparent"
174
+ fill = visible[0]
175
+ ftype = fill.get("type", "")
176
+ if ftype == "IMAGE":
177
+ return "image"
178
+ if ftype != "SOLID":
179
+ return "gradient"
180
+
181
+ hex_val = rgba_to_hex(fill.get("color", {}))
182
+ opacity = fill.get("opacity", 1.0)
183
+
184
+ if hex_val in self.color_map:
185
+ name = self.color_map[hex_val]
186
+ return f"colors/{name}/{int(opacity * 100)}" if opacity < 1.0 else f"colors/{name}"
187
+
188
+ nearest = find_nearest(hex_val, self.color_map, tolerance=2)
189
+ if nearest:
190
+ return f"colors/{nearest} # ~approx"
191
+
192
+ return f"#{hex_val} # not in tokens"
193
+
194
+ def _type(self, node: dict) -> "str | dict":
195
+ styles_ref = node.get("styles", {})
196
+ sid = styles_ref.get("text") or node.get("textStyleId")
197
+ if sid and sid in self.type_map:
198
+ return f"typography/{self.type_map[sid]}"
199
+ style = node.get("style", {})
200
+ return {
201
+ "fontFamily": style.get("fontFamily"),
202
+ "fontSize": style.get("fontSize"),
203
+ "fontWeight": style.get("fontWeight"),
204
+ "lineHeight": style.get("lineHeightPx"),
205
+ "letterSpacing": style.get("letterSpacing", 0),
206
+ }
@@ -0,0 +1,51 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def write_index_md(
5
+ output_dir: Path,
6
+ file_name: str,
7
+ figma_url: str,
8
+ pages: list[dict],
9
+ tokens: dict,
10
+ pulled_at: str,
11
+ ) -> None:
12
+ frame_count = sum(
13
+ len([c for c in p.get("children", []) if c.get("type") != "SLICE"])
14
+ for p in pages
15
+ )
16
+ color_count = len(tokens.get("color_tokens", {}))
17
+ type_count = len(tokens.get("type_tokens", {}))
18
+ date = pulled_at[:10]
19
+
20
+ lines = [
21
+ f"# {file_name}",
22
+ "",
23
+ f"Pulled from Figma on {date}. {len(pages)} page{'s' if len(pages) != 1 else ''}, {frame_count} frames.",
24
+ "",
25
+ "## How to use this folder",
26
+ "1. Read `tokens/` first — use token names, never raw values.",
27
+ "2. For each screen: read `page.md`, then each `frame.json` + `.png`.",
28
+ "3. Use the `.png` as the visual reference to verify your implementation.",
29
+ "",
30
+ "## Pages",
31
+ "",
32
+ ]
33
+
34
+ for page in pages:
35
+ pname = page.get("name", "Page")
36
+ frames = [c for c in page.get("children", []) if c.get("type") != "SLICE"]
37
+ lines.append(f"### {pname} ({len(frames)} frames)")
38
+ lines.append(f"→ pages/{pname}/page.md")
39
+ lines.append("")
40
+
41
+ lines += [
42
+ "## Design tokens",
43
+ "",
44
+ f"→ tokens/colors.json — {color_count} color{'s' if color_count != 1 else ''}",
45
+ f"→ tokens/typography.json — {type_count} type style{'s' if type_count != 1 else ''}",
46
+ "",
47
+ "## Source",
48
+ f"→ {figma_url}",
49
+ ]
50
+
51
+ (output_dir / "index.md").write_text("\n".join(lines))
@@ -0,0 +1,38 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def write_page_md(page_dir: Path, page_node: dict) -> None:
5
+ name = page_node.get("name", "Page")
6
+ frames = [c for c in page_node.get("children", []) if c.get("type") != "SLICE"]
7
+
8
+ lines = [
9
+ f"# {name}",
10
+ "",
11
+ f"> {len(frames)} frame{'s' if len(frames) != 1 else ''}",
12
+ "",
13
+ ]
14
+
15
+ for frame in frames:
16
+ fname = frame.get("name", "Frame")
17
+ bb = frame.get("absoluteBoundingBox") or {}
18
+ w = bb.get("width")
19
+ h = bb.get("height")
20
+ size = f"{int(w)}×{int(h)}" if w and h else ""
21
+
22
+ layout_mode = frame.get("layoutMode", "NONE")
23
+ if layout_mode == "HORIZONTAL":
24
+ layout_desc = "Flex row."
25
+ elif layout_mode == "VERTICAL":
26
+ layout_desc = "Flex column."
27
+ else:
28
+ layout_desc = "Absolute positioning."
29
+
30
+ header = f"## {fname}"
31
+ if size:
32
+ header += f" ({size})"
33
+ lines.append(header)
34
+ lines.append(layout_desc)
35
+ lines.append(f"→ {fname}.frame.json | {fname}.png")
36
+ lines.append("")
37
+
38
+ (page_dir / "page.md").write_text("\n".join(lines))
@@ -0,0 +1,176 @@
1
+ import json
2
+ import re
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+
6
+ from extrafigma.api import FigmaClient, FigmaAuthError, FigmaNotFoundError, FigmaPermissionError, FigmaRateLimitError
7
+ from extrafigma.puller.distiller import Distiller
8
+ from extrafigma.puller.token_extractor import TokenExtractor
9
+ from extrafigma.puller.renderer import export_frames, export_svgs
10
+ from extrafigma.puller.page_writer import write_page_md
11
+ from extrafigma.puller.index_writer import write_index_md
12
+ from extrafigma.puller.snapshot import write_meta
13
+
14
+ _CLAUDE_MD = """\
15
+ # ExtraFigma — Agent Instructions
16
+
17
+ This folder was produced by `extrafigma pull`. It contains a complete,
18
+ distilled representation of a Figma design file.
19
+
20
+ ## Start here
21
+ 1. Read `index.md` — overview of all pages and frames
22
+ 2. Read `tokens/colors.json` and `tokens/typography.json`
23
+ 3. For each frame: read `page.md`, then `<Frame>.frame.json`, then open `<Frame>.png`
24
+
25
+ ## The implementation loop (per frame)
26
+ 1. Read the frame.json — this is the exact layout spec
27
+ 2. Open the .png — this is the visual ground truth
28
+ 3. Implement the component
29
+ 4. Visually compare your output to the .png
30
+ 5. Fix any discrepancies, then move to the next frame
31
+
32
+ ## Rules
33
+ - NEVER hardcode a color. Always use a token: `colors/brand-blue`
34
+ - NEVER hardcode a font size. Always use a token: `typography/heading-xl`
35
+ - When you see `type: INSTANCE`, use the matching component from your codebase
36
+ - When you see `type: VECTOR`, use the `.svg` file at the `src` path
37
+ - The .png is always right. If frame.json and .png disagree, follow the .png
38
+
39
+ ## Layout translation
40
+
41
+ | frame.json | CSS |
42
+ |----------------------------------|---------------------------------|
43
+ | layout.direction: "row" | flex-direction: row |
44
+ | layout.direction: "column" | flex-direction: column |
45
+ | layout.mainAxis: "center" | justify-content: center |
46
+ | layout.mainAxis: "flex-start" | justify-content: flex-start |
47
+ | layout.mainAxis: "space-between" | justify-content: space-between |
48
+ | layout.crossAxis: "center" | align-items: center |
49
+ | layout.gap: 24 | gap: 24px |
50
+ | layout.padding.* | padding: top right bottom left |
51
+ | widthMode: "fill" | flex: 1 / width: 100% |
52
+ | widthMode: "hug" | width: fit-content |
53
+ | widthMode: "fixed" | width: <value>px |
54
+ | layout.mode: "absolute" | position: relative on parent |
55
+ | x / y on child of absolute frame | position: absolute; left/top |
56
+
57
+ ## Token usage
58
+
59
+ In CSS: var(--colors-brand-blue)
60
+ In Tailwind (after tailwind.config.js): text-brand-blue
61
+ In JS/TS: theme.colors['brand-blue']
62
+
63
+ ## Colors flagged `# not in tokens`
64
+ Designer used a raw value without a named style. Use it but add a TODO comment
65
+ for design system cleanup.
66
+ """
67
+
68
+
69
+ def _safe_filename(name: str) -> str:
70
+ name = re.sub(r"[^\w\s-]", "", name)
71
+ name = re.sub(r"[\s]+", "-", name.strip())
72
+ return name or "unnamed"
73
+
74
+
75
+ def pull(file_key: str, figma_url: str, output_dir: str, pat: str) -> None:
76
+ out = Path(output_dir)
77
+ out.mkdir(parents=True, exist_ok=True)
78
+ pulled_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
79
+
80
+ try:
81
+ with FigmaClient(pat) as api:
82
+ # 1. Full file
83
+ print("Fetching file structure...")
84
+ file_data = api.get_file(file_key)
85
+ file_name = file_data.get("name", "Figma File")
86
+ version = file_data.get("version", "")
87
+ document = file_data.get("document", {})
88
+ pages = document.get("children", [])
89
+
90
+ # 2. Token extraction (2-step: style node fetch + tree walk fallback)
91
+ print("Extracting design tokens...")
92
+ extractor = TokenExtractor(file_data)
93
+ tokens = extractor.extract(document, api=api, file_key=file_key)
94
+
95
+ tokens_dir = out / "tokens"
96
+ tokens_dir.mkdir(exist_ok=True)
97
+ (tokens_dir / "colors.json").write_text(
98
+ json.dumps({"source": "figma_styles", "tokens": tokens["color_tokens"]}, indent=2)
99
+ )
100
+ (tokens_dir / "typography.json").write_text(
101
+ json.dumps({"source": "figma_styles", "tokens": tokens["type_tokens"]}, indent=2)
102
+ )
103
+
104
+ # 3. Distill frames
105
+ print("Distilling frames...")
106
+ distiller = Distiller(
107
+ color_map=tokens["color_map"],
108
+ type_map=tokens["type_map"],
109
+ )
110
+
111
+ all_frame_ids: list[str] = []
112
+ id_to_png_path: dict[str, Path] = {}
113
+
114
+ for page in pages:
115
+ page_name = _safe_filename(page.get("name", "Page"))
116
+ page_dir = out / "pages" / page_name
117
+ page_dir.mkdir(parents=True, exist_ok=True)
118
+
119
+ top_frames = [c for c in page.get("children", []) if c.get("type") != "SLICE"]
120
+
121
+ for frame in top_frames:
122
+ fname = _safe_filename(frame.get("name", "Frame"))
123
+ distilled = distiller.distill(frame)
124
+ (page_dir / f"{fname}.frame.json").write_text(json.dumps(distilled, indent=2))
125
+
126
+ fid = frame.get("id", "")
127
+ if fid:
128
+ all_frame_ids.append(fid)
129
+ id_to_png_path[fid] = page_dir / f"{fname}.png"
130
+
131
+ write_page_md(page_dir, page)
132
+
133
+ # 4. PNG export
134
+ print("Exporting frame PNGs...")
135
+ downloaded = export_frames(
136
+ api=api,
137
+ file_key=file_key,
138
+ frame_ids=all_frame_ids,
139
+ id_to_path=id_to_png_path,
140
+ )
141
+ print(f" {downloaded}/{len(all_frame_ids)} PNGs saved.")
142
+
143
+ # 5. SVG export
144
+ if distiller.svg_ids:
145
+ print(f"Exporting {len(distiller.svg_ids)} SVG icons...")
146
+ assets_dir = out / "assets"
147
+ svg_count = export_svgs(
148
+ api=api,
149
+ file_key=file_key,
150
+ svg_ids=distiller.svg_ids,
151
+ assets_dir=assets_dir,
152
+ )
153
+ print(f" {svg_count}/{len(distiller.svg_ids)} SVGs saved.")
154
+
155
+ # 6. Index + metadata
156
+ write_index_md(out, file_name, figma_url, pages, tokens, pulled_at)
157
+ write_meta(out, file_key, file_name, figma_url, version, pages, pulled_at)
158
+
159
+ # 7. Agent instructions
160
+ (out / "CLAUDE.md").write_text(_CLAUDE_MD)
161
+
162
+ print(f"\nDone. {len(all_frame_ids)} frames pulled to {out}/")
163
+
164
+ except FigmaRateLimitError as exc:
165
+ print(f"\n✗ {exc}")
166
+ raise SystemExit(1)
167
+ except FigmaAuthError as exc:
168
+ print(f"\n✗ {exc}")
169
+ raise SystemExit(1)
170
+ except FigmaNotFoundError as exc:
171
+ print(f"\n✗ {exc}")
172
+ print(" Check the URL and that you have view access in Figma.")
173
+ raise SystemExit(1)
174
+ except FigmaPermissionError as exc:
175
+ print(f"\n✗ Permission denied: {exc}")
176
+ raise SystemExit(1)
@@ -0,0 +1,113 @@
1
+ import time
2
+ from pathlib import Path
3
+
4
+ import httpx
5
+
6
+ _PNG_BATCH = 20
7
+ _SVG_BATCH = 200
8
+
9
+
10
+ def export_frames(
11
+ api,
12
+ file_key: str,
13
+ frame_ids: list[str],
14
+ id_to_path: dict[str, Path],
15
+ scale: int = 2,
16
+ ) -> int:
17
+ """Download 2× PNGs for all frame IDs. Returns count of successfully saved files."""
18
+ if not frame_ids:
19
+ return 0
20
+
21
+ total = len(frame_ids)
22
+ downloaded = 0
23
+
24
+ for i in range(0, total, _PNG_BATCH):
25
+ batch = frame_ids[i : i + _PNG_BATCH]
26
+ end = min(i + _PNG_BATCH, total)
27
+ print(f" Exporting frames {i + 1}–{end}/{total}...")
28
+
29
+ try:
30
+ urls = api.get_images(file_key, batch, scale=scale, format="png")
31
+ except Exception:
32
+ # Large frames can exceed Figma's render limit at 2×; retry at 1×
33
+ if scale > 1:
34
+ print(f" Batch too large at {scale}×, retrying at 1×...")
35
+ try:
36
+ urls = api.get_images(file_key, batch, scale=1, format="png")
37
+ except Exception as exc2:
38
+ print(f" Warning: image export failed for batch: {exc2}")
39
+ continue
40
+ else:
41
+ urls = {}
42
+ for fid in batch:
43
+ try:
44
+ urls.update(api.get_images(file_key, [fid], scale=1, format="png"))
45
+ except Exception as exc2:
46
+ print(f" Warning: could not export frame {fid}: {exc2}")
47
+
48
+ for frame_id, url in urls.items():
49
+ if not url:
50
+ continue
51
+ target = id_to_path.get(frame_id)
52
+ if not target:
53
+ continue
54
+ try:
55
+ resp = httpx.get(url, timeout=60.0, follow_redirects=True)
56
+ resp.raise_for_status()
57
+ target.parent.mkdir(parents=True, exist_ok=True)
58
+ target.write_bytes(resp.content)
59
+ downloaded += 1
60
+ except Exception as exc:
61
+ print(f" Warning: could not download PNG for {frame_id}: {exc}")
62
+
63
+ if i + _PNG_BATCH < total:
64
+ time.sleep(0.5)
65
+
66
+ return downloaded
67
+
68
+
69
+ def export_svgs(
70
+ api,
71
+ file_key: str,
72
+ svg_ids: dict[str, str],
73
+ assets_dir: Path,
74
+ ) -> int:
75
+ """Download SVGs for all collected VECTOR node IDs. Returns count saved."""
76
+ if not svg_ids:
77
+ return 0
78
+
79
+ assets_dir.mkdir(parents=True, exist_ok=True)
80
+ node_ids = list(svg_ids.keys())
81
+ total = len(node_ids)
82
+ saved = 0
83
+
84
+ for i in range(0, total, _SVG_BATCH):
85
+ batch = node_ids[i : i + _SVG_BATCH]
86
+ end = min(i + _SVG_BATCH, total)
87
+ print(f" Exporting SVGs {i + 1}–{end}/{total}...")
88
+
89
+ try:
90
+ urls = api.get_images(file_key, batch, format="svg")
91
+ except Exception as exc:
92
+ print(f" Warning: SVG export failed for batch: {exc}")
93
+ continue
94
+
95
+ for node_id, url in urls.items():
96
+ if not url:
97
+ # Node could not be rendered (e.g. remote library component)
98
+ continue
99
+ filename = svg_ids.get(node_id)
100
+ if not filename:
101
+ continue
102
+ try:
103
+ resp = httpx.get(url, timeout=30.0, follow_redirects=True)
104
+ resp.raise_for_status()
105
+ (assets_dir / filename).write_text(resp.text, encoding="utf-8")
106
+ saved += 1
107
+ except Exception as exc:
108
+ print(f" Warning: could not download SVG {filename}: {exc}")
109
+
110
+ if i + _SVG_BATCH < total:
111
+ time.sleep(0.5)
112
+
113
+ return saved
@@ -0,0 +1,34 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+
5
+ def write_meta(
6
+ output_dir: Path,
7
+ file_key: str,
8
+ file_name: str,
9
+ figma_url: str,
10
+ version: str,
11
+ pages: list[dict],
12
+ pulled_at: str,
13
+ token_source: str = "styles",
14
+ ) -> None:
15
+ meta_dir = output_dir / ".extrafigma"
16
+ meta_dir.mkdir(parents=True, exist_ok=True)
17
+
18
+ frame_count = sum(
19
+ len([c for c in p.get("children", []) if c.get("type") != "SLICE"])
20
+ for p in pages
21
+ )
22
+
23
+ data = {
24
+ "file_key": file_key,
25
+ "file_name": file_name,
26
+ "version": version,
27
+ "pulled_at": pulled_at,
28
+ "figma_url": figma_url,
29
+ "token_source": token_source,
30
+ "pages": [p.get("name", "") for p in pages],
31
+ "frame_count": frame_count,
32
+ }
33
+
34
+ (meta_dir / "meta.json").write_text(json.dumps(data, indent=2))
@@ -0,0 +1,162 @@
1
+ import re
2
+
3
+ from extrafigma.utils.color import rgba_to_hex
4
+
5
+ _NODE_BATCH = 50
6
+
7
+
8
+ def _slugify(name: str) -> str:
9
+ """'Brand/Blue 500' -> 'brand-blue-500'"""
10
+ name = name.replace("/", "-").replace(" ", "-")
11
+ name = re.sub(r"[^a-zA-Z0-9-]", "", name)
12
+ name = re.sub(r"-+", "-", name)
13
+ return name.lower().strip("-")
14
+
15
+
16
+ class TokenExtractor:
17
+ def __init__(self, file_data: dict):
18
+ # {styleId: {name, styleType, node_id}} from the top-level file response
19
+ self.style_meta: dict = file_data.get("styles", {})
20
+ self.color_map: dict[str, str] = {} # hex -> token_name
21
+ self.style_id_to_color: dict[str, str] = {} # styleId -> token_name
22
+ self.type_map: dict[str, str] = {} # styleId -> token_name
23
+ self.color_tokens: dict[str, str] = {} # token_name -> "#RRGGBB"
24
+ self.type_tokens: dict[str, dict] = {} # token_name -> font props
25
+
26
+ def extract(self, document: dict, api=None, file_key: str = "") -> dict:
27
+ # Step 1: build name maps from style metadata (names only, no values yet)
28
+ fill_sids: list[str] = []
29
+ text_sids: list[str] = []
30
+ for sid, meta in self.style_meta.items():
31
+ stype = meta.get("styleType") or meta.get("style_type", "")
32
+ slug = _slugify(meta["name"])
33
+ if stype == "FILL":
34
+ self.style_id_to_color[sid] = slug
35
+ fill_sids.append(sid)
36
+ elif stype == "TEXT":
37
+ self.type_map[sid] = slug
38
+ text_sids.append(sid)
39
+
40
+ # Step 2: fetch style nodes directly to get actual paint/font values
41
+ if api and file_key and self.style_meta:
42
+ self._fetch_style_nodes(api, file_key, fill_sids, text_sids)
43
+
44
+ # Step 3: tree walk as fallback for any style not yet resolved
45
+ self._walk(document)
46
+
47
+ return {
48
+ "color_map": self.color_map,
49
+ "style_id_to_color": self.style_id_to_color,
50
+ "type_map": self.type_map,
51
+ "color_tokens": self.color_tokens,
52
+ "type_tokens": self.type_tokens,
53
+ }
54
+
55
+ def _fetch_style_nodes(
56
+ self, api, file_key: str, fill_sids: list[str], text_sids: list[str]
57
+ ) -> None:
58
+ # Build node_id -> style_id reverse map
59
+ nid_to_sid: dict[str, str] = {}
60
+ for sid, meta in self.style_meta.items():
61
+ nid = meta.get("node_id") or meta.get("nodeId", "")
62
+ if nid:
63
+ nid_to_sid[nid] = sid
64
+
65
+ all_nids = list(nid_to_sid.keys())
66
+ if not all_nids:
67
+ return
68
+
69
+ # Batch fetch — Tier 2, 50 IDs per request
70
+ node_docs: dict[str, dict] = {}
71
+ for i in range(0, len(all_nids), _NODE_BATCH):
72
+ batch = all_nids[i : i + _NODE_BATCH]
73
+ try:
74
+ node_docs.update(api.get_nodes(file_key, batch))
75
+ except Exception as exc:
76
+ print(f" Warning: could not fetch style nodes batch: {exc}")
77
+
78
+ fill_set = set(fill_sids)
79
+ text_set = set(text_sids)
80
+
81
+ for nid, doc in node_docs.items():
82
+ sid = nid_to_sid.get(nid)
83
+ if not sid:
84
+ continue
85
+
86
+ if sid in fill_set:
87
+ slug = self.style_id_to_color.get(sid)
88
+ if not slug or slug in self.color_tokens:
89
+ continue
90
+ fills = doc.get("fills", [])
91
+ if fills and fills[0].get("type") == "SOLID" and fills[0].get("visible", True):
92
+ hex_val = rgba_to_hex(fills[0]["color"])
93
+ self.color_map.setdefault(hex_val, slug)
94
+ self.color_tokens[slug] = f"#{hex_val}"
95
+
96
+ elif sid in text_set:
97
+ slug = self.type_map.get(sid)
98
+ if not slug or slug in self.type_tokens:
99
+ continue
100
+ style = doc.get("style", {})
101
+ if style:
102
+ self.type_tokens[slug] = {
103
+ "fontFamily": style.get("fontFamily"),
104
+ "fontSize": style.get("fontSize"),
105
+ "fontWeight": style.get("fontWeight"),
106
+ "lineHeight": style.get("lineHeightPx"),
107
+ "letterSpacing": style.get("letterSpacing", 0),
108
+ }
109
+
110
+ def _walk(self, node: dict) -> None:
111
+ """Fallback: collect token values from nodes that reference named styles."""
112
+ styles_ref = node.get("styles", {})
113
+
114
+ # Fill style
115
+ fill_sid = (
116
+ styles_ref.get("fill")
117
+ or styles_ref.get("fills")
118
+ or node.get("fillStyleId")
119
+ )
120
+ if fill_sid and fill_sid in self.style_meta:
121
+ slug = self.style_id_to_color.get(fill_sid) or _slugify(self.style_meta[fill_sid]["name"])
122
+ self.style_id_to_color.setdefault(fill_sid, slug)
123
+ if slug not in self.color_tokens:
124
+ fills = node.get("fills", [])
125
+ if fills and fills[0].get("type") == "SOLID" and fills[0].get("visible", True):
126
+ hex_val = rgba_to_hex(fills[0]["color"])
127
+ self.color_map.setdefault(hex_val, slug)
128
+ self.color_tokens[slug] = f"#{hex_val}"
129
+
130
+ # Stroke style
131
+ stroke_sid = (
132
+ styles_ref.get("stroke")
133
+ or styles_ref.get("strokes")
134
+ or node.get("strokeStyleId")
135
+ )
136
+ if stroke_sid and stroke_sid in self.style_meta:
137
+ slug = self.style_id_to_color.get(stroke_sid) or _slugify(self.style_meta[stroke_sid]["name"])
138
+ self.style_id_to_color.setdefault(stroke_sid, slug)
139
+ if slug not in self.color_tokens:
140
+ strokes = node.get("strokes", [])
141
+ if strokes and strokes[0].get("type") == "SOLID" and strokes[0].get("visible", True):
142
+ hex_val = rgba_to_hex(strokes[0]["color"])
143
+ self.color_map.setdefault(hex_val, slug)
144
+ self.color_tokens[slug] = f"#{hex_val}"
145
+
146
+ # Text style
147
+ text_sid = styles_ref.get("text") or node.get("textStyleId")
148
+ if text_sid and text_sid in self.style_meta:
149
+ slug = self.type_map.get(text_sid) or _slugify(self.style_meta[text_sid]["name"])
150
+ self.type_map.setdefault(text_sid, slug)
151
+ if slug not in self.type_tokens:
152
+ style = node.get("style", {})
153
+ self.type_tokens[slug] = {
154
+ "fontFamily": style.get("fontFamily"),
155
+ "fontSize": style.get("fontSize"),
156
+ "fontWeight": style.get("fontWeight"),
157
+ "lineHeight": style.get("lineHeightPx"),
158
+ "letterSpacing": style.get("letterSpacing", 0),
159
+ }
160
+
161
+ for child in node.get("children", []):
162
+ self._walk(child)
File without changes
@@ -0,0 +1,36 @@
1
+ from typing import Optional
2
+
3
+
4
+ def rgba_to_hex(color: dict) -> str:
5
+ """Convert Figma color dict (r,g,b as 0-1 floats) to 6-char uppercase hex."""
6
+ r = max(0, min(255, round(color.get("r", 0) * 255)))
7
+ g = max(0, min(255, round(color.get("g", 0) * 255)))
8
+ b = max(0, min(255, round(color.get("b", 0) * 255)))
9
+ return f"{r:02X}{g:02X}{b:02X}"
10
+
11
+
12
+ def find_nearest(hex_val: str, token_map: dict, tolerance: int = 2) -> Optional[str]:
13
+ """Find the nearest color token within per-channel tolerance."""
14
+ try:
15
+ tr = int(hex_val[0:2], 16)
16
+ tg = int(hex_val[2:4], 16)
17
+ tb = int(hex_val[4:6], 16)
18
+ except (ValueError, IndexError):
19
+ return None
20
+
21
+ best_name: Optional[str] = None
22
+ best_dist = float("inf")
23
+
24
+ for candidate, name in token_map.items():
25
+ try:
26
+ cr = int(candidate[0:2], 16)
27
+ cg = int(candidate[2:4], 16)
28
+ cb = int(candidate[4:6], 16)
29
+ except (ValueError, IndexError):
30
+ continue
31
+ dist = max(abs(tr - cr), abs(tg - cg), abs(tb - cb))
32
+ if dist <= tolerance and dist < best_dist:
33
+ best_dist = dist
34
+ best_name = name
35
+
36
+ return best_name
@@ -0,0 +1,18 @@
1
+ import re
2
+ from typing import Optional
3
+
4
+ _URL_PATTERNS = [
5
+ r"figma\.com/(?:design|file|proto|make)/([A-Za-z0-9_-]+)",
6
+ ]
7
+ _RAW_KEY = re.compile(r"^[A-Za-z0-9_-]{6,}$")
8
+
9
+
10
+ def parse_file_key(url_or_key: str) -> Optional[str]:
11
+ url_or_key = url_or_key.strip()
12
+ for pattern in _URL_PATTERNS:
13
+ m = re.search(pattern, url_or_key)
14
+ if m:
15
+ return m.group(1)
16
+ if _RAW_KEY.match(url_or_key):
17
+ return url_or_key
18
+ return None
@@ -0,0 +1,40 @@
1
+ _DIRECTION = {
2
+ "HORIZONTAL": "row",
3
+ "VERTICAL": "column",
4
+ }
5
+
6
+ _AXIS_ALIGN = {
7
+ "MIN": "flex-start",
8
+ "CENTER": "center",
9
+ "MAX": "flex-end",
10
+ "SPACE_BETWEEN": "space-between",
11
+ "BASELINE": "baseline",
12
+ }
13
+
14
+ _SIZING = {
15
+ "FIXED": "fixed",
16
+ "HUG": "hug",
17
+ "FILL": "fill",
18
+ }
19
+
20
+
21
+ def distill_layout(node: dict) -> dict:
22
+ mode = node.get("layoutMode", "NONE")
23
+ if mode == "NONE":
24
+ return {"mode": "absolute"}
25
+ return {
26
+ "mode": "flex",
27
+ "direction": _DIRECTION.get(mode, "column"),
28
+ "mainAxis": _AXIS_ALIGN.get(node.get("primaryAxisAlignItems", "MIN"), "flex-start"),
29
+ "crossAxis": _AXIS_ALIGN.get(node.get("counterAxisAlignItems", "MIN"), "flex-start"),
30
+ "gap": node.get("itemSpacing", 0),
31
+ "padding": {
32
+ "top": node.get("paddingTop", 0),
33
+ "right": node.get("paddingRight", 0),
34
+ "bottom": node.get("paddingBottom", 0),
35
+ "left": node.get("paddingLeft", 0),
36
+ },
37
+ "wrap": node.get("layoutWrap") == "WRAP",
38
+ "widthMode": _SIZING.get(node.get("layoutSizingHorizontal", "FIXED"), "fixed"),
39
+ "heightMode": _SIZING.get(node.get("layoutSizingVertical", "FIXED"), "fixed"),
40
+ }
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: extrafigma
3
+ Version: 0.1.0
4
+ Summary: Pull Figma files into agent-readable local folders
5
+ Project-URL: Homepage, https://github.com/think41/extrafigma
6
+ Project-URL: Repository, https://github.com/think41/extrafigma
7
+ Project-URL: Issues, https://github.com/think41/extrafigma/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai,cli,codegen,design,figma
11
+ Classifier: Environment :: Console
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Multimedia :: Graphics
18
+ Classifier: Topic :: Software Development :: Code Generators
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: httpx>=0.27.0
21
+ Requires-Dist: rich>=13.0.0
22
+ Requires-Dist: typer>=0.12.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # ExtraFigma
26
+
27
+ A pull-only CLI that converts Figma files into agent-readable local folders. Drop the output into your project and let Claude Code (or any AI coding agent) implement pixel-perfect frontend code from the design.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install extrafigma
33
+ # or
34
+ uvx extrafigma --help
35
+ ```
36
+
37
+ ## Quick start
38
+
39
+ ```bash
40
+ extrafigma pull "https://www.figma.com/design/abc123/My-App" ./my-design
41
+ ```
42
+
43
+ On first run you'll be prompted for a Figma Personal Access Token. Get one at **Figma → Settings → Security → Personal Access Tokens** (scopes needed: `file_content:read`, `file_metadata:read`).
44
+
45
+ The token is stored at `~/.config/extrafigma/credentials.json` and reused for all future pulls.
46
+
47
+ ## What gets extracted
48
+
49
+ ```
50
+ my-design/
51
+ CLAUDE.md ← instructions for AI agents
52
+ index.md ← overview of all pages and frames
53
+ tokens/
54
+ colors.json ← named color styles → hex values
55
+ typography.json ← named text styles → font properties
56
+ assets/
57
+ icon-name.svg ← exported SVG icons (VECTOR nodes)
58
+ pages/
59
+ Page-1/
60
+ page.md
61
+ Hero.frame.json ← distilled layout tree (CSS-friendly)
62
+ Hero.png ← 2× frame render (visual ground truth)
63
+ ```
64
+
65
+ ### `frame.json` structure
66
+
67
+ Every frame is distilled to a CSS-friendly layout tree:
68
+
69
+ ```json
70
+ {
71
+ "type": "FRAME",
72
+ "name": "Hero",
73
+ "width": 1440,
74
+ "height": 900,
75
+ "layout": { "mode": "flex", "direction": "column", "gap": 24 },
76
+ "background": "colors/brand-primary",
77
+ "borderRadius": 0,
78
+ "children": [
79
+ {
80
+ "type": "TEXT",
81
+ "content": "Welcome",
82
+ "style": "typography/heading-xl",
83
+ "color": "colors/gray-800"
84
+ }
85
+ ]
86
+ }
87
+ ```
88
+
89
+ Named Figma Styles become tokens (`colors/brand-primary`, `typography/heading-xl`). Raw hex values that weren't saved as styles are flagged with `# not in tokens`.
90
+
91
+ ## Auth commands
92
+
93
+ ```bash
94
+ extrafigma auth login # interactive prompt
95
+ extrafigma auth login --pat <token> # non-interactive (CI-friendly)
96
+ extrafigma auth status # check stored PAT
97
+ extrafigma auth logout # remove stored PAT
98
+ ```
99
+
100
+ ## Rate limits
101
+
102
+ Figma's REST API limits `GET /v1/files` calls based on your seat type:
103
+
104
+ | Seat | Limit |
105
+ |------|-------|
106
+ | View / Collab | 6 per month |
107
+ | Dev / Full (Starter) | 10 per minute |
108
+ | Dev / Full (Professional) | 15 per minute |
109
+
110
+ Use an account with an **editor seat** on the file for meaningful usage.
111
+
112
+ ## Known limitations
113
+
114
+ - **Design tokens require named Figma Styles.** If the designer applied colors directly without saving them as named styles, tokens will be empty. Variables (Figma Professional+) are not supported — use Styles instead.
115
+ - **SVG export skips remote library components.** Icons that are instances of components from external shared libraries cannot be exported via the REST API. They'll appear as `INSTANCE` nodes in the frame JSON.
116
+ - **x/y positions** are canvas-space coordinates. For absolute-layout frames, subtract the parent's x/y to get relative positions.
117
+
118
+ ## How it works
119
+
120
+ 1. `GET /v1/files/:key` — fetches the full document tree
121
+ 2. Batch-fetches style node data to resolve exact color/font values
122
+ 3. Distills each top-level frame to a CSS-friendly JSON tree
123
+ 4. Exports frame PNGs at 2× resolution
124
+ 5. Exports VECTOR nodes as SVGs in a second pass
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,21 @@
1
+ extrafigma/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ extrafigma/api.py,sha256=08W_3A2chsOB04k9-4g_48RDYF2cixxQIjuIAu8H_70,3933
3
+ extrafigma/auth.py,sha256=NuzuFYPXuYZ3m4ac1nYup6qeEP5N4Skh01c9J85NlJM,788
4
+ extrafigma/cli.py,sha256=Z87xIrbGKnYXvmrm9_yLbMDf-8s9LaCRP-mo25aMKag,3285
5
+ extrafigma/puller/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ extrafigma/puller/distiller.py,sha256=nf3jzDUoXMsR2_YT_gOT0opODGKjdjVAsUiub8xFxfU,7916
7
+ extrafigma/puller/index_writer.py,sha256=HMPmuqyk0VPKU15EzHOh9MRSlkUnhgM8M_Pn9w_GgJw,1603
8
+ extrafigma/puller/page_writer.py,sha256=1UsuwZgns46p93Vl9U8luI2OoNErrGLRdNVfVbvXITc,1148
9
+ extrafigma/puller/pull.py,sha256=YY9KbhOVvB2gjPGcTgI07FpNyQwLkfDgFAwJB-02w9Y,7205
10
+ extrafigma/puller/renderer.py,sha256=CZpc06QT3wWOhFSWd0VbemJJoNeaR5Zzjfs6lwFnz6I,3592
11
+ extrafigma/puller/snapshot.py,sha256=HohJpIbY4nVShvk82UENPVJYhFojjKoeAoHwLJdgGgo,831
12
+ extrafigma/puller/token_extractor.py,sha256=Occ9gKSOu-M0GXzyN4rwEH6L7hMz260MSVSAW9u03-g,6832
13
+ extrafigma/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ extrafigma/utils/color.py,sha256=NjhuPbqht2zvxb4ZbnfcH3jqyUl-VW1Bl2-If9Or_iE,1187
15
+ extrafigma/utils/file_key.py,sha256=oDAy5yUg4htaviBJtpfpme-x540mOUsXi-tbPObtoPw,458
16
+ extrafigma/utils/layout.py,sha256=fgm9LI1Qv9S4_88KloBGqdTLdeJ7rQI3z97CNtOCp1E,1222
17
+ extrafigma-0.1.0.dist-info/METADATA,sha256=IYgDhT3GplNYMlUJNm79SvHWgR2OIO4Y9heuiiuHen0,4280
18
+ extrafigma-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
19
+ extrafigma-0.1.0.dist-info/entry_points.txt,sha256=oBXbCiyMPtfYY0Fhspu5l-CbTAqf0VprJA7kfK9wOkQ,50
20
+ extrafigma-0.1.0.dist-info/licenses/LICENSE,sha256=-KeMNo2TQu_j6tdBpkf2djhjRBd5bGNULsX1YxQN0EU,1064
21
+ extrafigma-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ extrafigma = extrafigma.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Think41
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.