figma-to-html-pixel-perfect 1.0.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/bin/install.js ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ /* Installs the figma-to-html-pixel-perfect skill for Claude Code.
3
+ *
4
+ * npx figma-to-html-pixel-perfect → ~/.claude/skills/… (personal, all projects)
5
+ * npx figma-to-html-pixel-perfect --project → ./.claude/skills/… (this project only)
6
+ *
7
+ * Copies SKILL.md, scripts/ and references/ from this package. No dependencies.
8
+ */
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+
13
+ const SKILL = 'figma-to-html-pixel-perfect';
14
+ const SRC = path.join(__dirname, '..');
15
+ const project = process.argv.includes('--project');
16
+ const destRoot = project
17
+ ? path.join(process.cwd(), '.claude', 'skills')
18
+ : path.join(os.homedir(), '.claude', 'skills');
19
+ const dest = path.join(destRoot, SKILL);
20
+
21
+ function copyDir(src, dst) {
22
+ fs.mkdirSync(dst, { recursive: true });
23
+ for (const e of fs.readdirSync(src, { withFileTypes: true })) {
24
+ if (['node_modules', '.git', 'bin', 'package.json'].includes(e.name)) continue;
25
+ const s = path.join(src, e.name), d = path.join(dst, e.name);
26
+ e.isDirectory() ? copyDir(s, d) : fs.copyFileSync(s, d);
27
+ }
28
+ }
29
+
30
+ const existed = fs.existsSync(path.join(dest, 'SKILL.md'));
31
+ copyDir(SRC, dest);
32
+ fs.chmodSync(dest, 0o755);
33
+
34
+ console.log(`${existed ? 'Updated' : 'Installed'} ${SKILL} → ${dest}
35
+ `);
36
+ console.log(`Next steps:
37
+ 1. Figma token (one-time):
38
+ echo 'YOUR_TOKEN' > ~/.figma_token && chmod 600 ~/.figma_token
39
+ (figma.com → Settings → Security → Personal access tokens, scope "File content: Read")
40
+ 2. Restart your Claude Code session — the skill is picked up automatically,
41
+ or invoke it with /${SKILL}
42
+ 3. Docs: ${dest}/README.md`);
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "figma-to-html-pixel-perfect",
3
+ "version": "1.0.0",
4
+ "description": "Claude Code skill: Figma → pixel-perfect HTML/CSS with a self-verifying fidelity pipeline (114 documented failure modes). Running this package installs the skill into ~/.claude/skills.",
5
+ "bin": {
6
+ "figma-to-html-pixel-perfect": "bin/install.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "SKILL.md",
11
+ "README.md",
12
+ "scripts",
13
+ "references"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/fieldqqq/figma-to-html-perfect-pixel.git"
18
+ },
19
+ "keywords": ["claude", "claude-code", "skill", "figma", "html", "css", "pixel-perfect", "design-to-code"],
20
+ "author": "fieldqqq",
21
+ "license": "MIT",
22
+ "engines": { "node": ">=16" }
23
+ }
@@ -0,0 +1,68 @@
1
+ # Accessibility Checklist (WCAG 2.2 AA target)
2
+
3
+ Verify without changing the approved visual direction. Where a fix would change the
4
+ visible design, flag it as a Usability Issue and request approval.
5
+
6
+ ## Structure & Semantics
7
+
8
+ - [ ] Landmarks used correctly (`header`, `nav`, `main`, `footer`, `aside`)
9
+ - [ ] One `main` per page
10
+ - [ ] Headings are ordered (no skipped levels; single logical h1)
11
+ - [ ] Lists use `ul`/`ol`/`li`; tables use `th`/`caption` where relevant
12
+ - [ ] Buttons for actions, links for navigation
13
+ - [ ] No duplicate `id` attributes
14
+
15
+ ## Keyboard
16
+
17
+ - [ ] All interactive elements are reachable by Tab
18
+ - [ ] Focus order is logical and matches visual order
19
+ - [ ] No keyboard traps
20
+ - [ ] Visible focus indicator on every focusable element
21
+ - [ ] Focus outline not removed without an accessible replacement
22
+ - [ ] Custom widgets (menu, tabs, accordion, modal) support expected keys
23
+ - [ ] Modal traps focus and restores it on close; Esc closes
24
+
25
+ ## Names, Roles, Values
26
+
27
+ - [ ] All form controls have associated `<label>` (or `aria-label`)
28
+ - [ ] Icon-only buttons have accessible names
29
+ - [ ] Images have meaningful `alt`; decorative images use `alt=""`
30
+ - [ ] ARIA used only when native HTML is insufficient
31
+ - [ ] Dynamic updates announced (`aria-live` where appropriate)
32
+ - [ ] Error messages programmatically associated (`aria-describedby`)
33
+
34
+ ## Color & Contrast
35
+
36
+ - [ ] Body text contrast ≥ 4.5:1
37
+ - [ ] Large text (≥ 24px, or ≥ 19px bold) contrast ≥ 3:1
38
+ - [ ] UI component / graphical object contrast ≥ 3:1
39
+ - [ ] Information is not conveyed by color alone
40
+ - [ ] Focus indicator contrast ≥ 3:1 against adjacent colors
41
+
42
+ ## Target Size & Input (WCAG 2.2)
43
+
44
+ - [ ] Touch targets ≥ 24×24 px (prefer ≥ 44×44 px)
45
+ - [ ] Adequate spacing between adjacent targets
46
+ - [ ] Dragging actions have a single-pointer alternative
47
+ - [ ] No content relies solely on hover to be accessible
48
+
49
+ ## Motion & Media
50
+
51
+ - [ ] `prefers-reduced-motion` respected for all non-essential animation
52
+ - [ ] Entrance animation cannot hide content if the script fails (hiding rule is scoped to
53
+ a class the script adds; never CSS-only `opacity: 0`)
54
+ - [ ] No content flashes more than 3 times per second
55
+ - [ ] Auto-playing/moving content can be paused or stopped
56
+
57
+ ## Forms
58
+
59
+ - [ ] Required fields indicated in text, not color alone
60
+ - [ ] Inline validation is associated with its field
61
+ - [ ] Error summary provided for longer forms
62
+ - [ ] Success confirmation is announced
63
+
64
+ ## Verification Tools
65
+
66
+ - [ ] Automated pass (axe / Lighthouse) with issues triaged
67
+ - [ ] Manual keyboard-only walkthrough completed
68
+ - [ ] Screen reader spot-check on key flows
@@ -0,0 +1,113 @@
1
+ # Motion Guidelines
2
+
3
+ There are two kinds of motion, and they are governed by opposite rules.
4
+
5
+ | | Motion **in** the Figma file | Motion **you would invent** |
6
+ |---|---|---|
7
+ | What it is | Part of the design | An enhancement |
8
+ | Mode | **A — required** | **C — needs approval** (or a standing instruction) |
9
+ | Ask first? | **No. Build it.** | Yes (or confirm the standing instruction) |
10
+ | Label it? | No — it *is* the design | Yes — every value is inferred |
11
+
12
+ Not implementing motion the file specifies is a fidelity bug, exactly like a wrong colour.
13
+
14
+ ## 1. Read the motion the file already has — first, always
15
+
16
+ ```bash
17
+ grep -l '"interactions"' figma/nodes/*.json # non-empty => the design specifies motion
18
+ ```
19
+
20
+ Never write "the design has no animation" without having run this.
21
+
22
+ Per interaction, record:
23
+
24
+ | Field | Meaning |
25
+ |---|---|
26
+ | `trigger.type` | `ON_HOVER`, `ON_CLICK`, `ON_DRAG`, `AFTER_TIMEOUT` |
27
+ | `trigger.timeout` | autoplay interval, in **seconds** |
28
+ | `actions[].transition.type` | `SMART_ANIMATE`, `DISSOLVE`, `PUSH`, … |
29
+ | `actions[].transition.duration` | **seconds** — multiply by 1000 |
30
+ | `actions[].transition.easing.type` | `GENTLE`, `SLOW`, `LINEAR`, `EASE_*`, `CUSTOM_CUBIC_BEZIER` |
31
+ | `effects[]` | real `DROP_SHADOW` / `BACKGROUND_BLUR` to reproduce |
32
+
33
+ ### Figma easing → CSS
34
+
35
+ Figma's named easings are springs; CSS has none. Approximate, and say that you did.
36
+
37
+ | Figma | Character | Reasonable CSS |
38
+ |---|---|---|
39
+ | `GENTLE` | soft settle, slight overshoot | `cubic-bezier(0.34, 1.16, 0.64, 1)` |
40
+ | `SLOW` | long, pure decelerate | `cubic-bezier(0.33, 1, 0.68, 1)` |
41
+ | `EASE_OUT` | standard decelerate | `cubic-bezier(0.16, 1, 0.3, 1)` |
42
+ | `CUSTOM_CUBIC_BEZIER` | exact | copy `easingFunctionCubicBezier` verbatim |
43
+
44
+ Two traps:
45
+
46
+ - Spring durations (often 800–1300 ms) are **settle** times, not perceived times. Reproduce
47
+ the design's number — the design outranks your taste — and note the tension with the
48
+ microinteraction ranges below.
49
+ - `AFTER_TIMEOUT` implies an autoplaying carousel. Check the file actually contains the
50
+ other slides. If it has only one, **do not invent them**: implement the timing you can
51
+ and report the missing slides.
52
+
53
+ ## 2. When the file specifies no motion
54
+
55
+ Then, and only then, infer it — from the design, not from habit. Read the mood off what the
56
+ file *does* specify:
57
+
58
+ - **Easings and durations already in use** — if everything is slow and gentle, a snappy
59
+ 200 ms bounce is wrong.
60
+ - **Type and colour** — high-contrast display serif with a muted palette reads editorial
61
+ and restrained.
62
+ - **Effects** — heavy blur and soft shadows imply soft, layered motion, not snap.
63
+
64
+ Then pick the smallest motion that serves that mood: fade plus a short rise (12–20 px), a
65
+ generous duration, a decelerating curve, a light stagger. Label every inferred value.
66
+
67
+ Propose it in this form:
68
+
69
+ > **Optional enhancement — [section/component]**
70
+ > I recommend `[motion]` because `[specific reason]`.
71
+ > Behaviour: `[trigger, duration, easing, movement]`.
72
+ > This is not in the Figma file. Should I add it?
73
+
74
+ ## 3. Motion must never hide content
75
+
76
+ Entrance animation that sets `opacity: 0` in CSS is a blank page whenever the script fails,
77
+ JS is off, or the tab is throttled — `IntersectionObserver` callbacks and CSS transitions do
78
+ not run in a hidden tab.
79
+
80
+ - Scope the hiding rule to a class the script adds: `html.motion [data-reveal] { opacity: 0 }`.
81
+ - Reveal on `IntersectionObserver` **and** a passive `scroll` sweep **and** `visibilitychange`.
82
+ - Never depend on `requestAnimationFrame` for the first pass.
83
+ - Verify by disabling the script: the page must be fully visible.
84
+
85
+ ## 4. Default ranges (only when nothing is specified)
86
+
87
+ | Category | Duration | Use |
88
+ |---|---|---|
89
+ | Microinteraction | 120–220 ms | hover, press, toggle, focus |
90
+ | Component transition | 180–320 ms | accordion, tab, dropdown |
91
+ | Section entrance | 350–650 ms | hero reveal, scroll-linked reveal |
92
+
93
+ Defaults, not mandates — and always outranked by a value the file states.
94
+
95
+ ## 5. Quality rules
96
+
97
+ Motion must reinforce hierarchy or give feedback; stay subtle; never block input; never
98
+ cause layout shift (animate `transform`/`opacity`, not `width`/`top`); avoid heavy parallax
99
+ and long entrance sequences; preserve keyboard use; and respect `prefers-reduced-motion`.
100
+
101
+ ```css
102
+ @media (prefers-reduced-motion: reduce) {
103
+ *, *::before, *::after {
104
+ animation-duration: 0.01ms !important;
105
+ animation-iteration-count: 1 !important;
106
+ transition-duration: 0.01ms !important;
107
+ scroll-behavior: auto !important;
108
+ }
109
+ }
110
+ ```
111
+
112
+ Prefer targeted handling over the blanket reset for essential state changes (an accordion
113
+ should still open — instantly, not never).
@@ -0,0 +1,121 @@
1
+ # Visual Review Checklist
2
+
3
+ Use this checklist during **Step 5 — Visual Comparison** and **Step 7 — Final Verification**.
4
+ Compare the implementation against the reference at identical viewport dimensions.
5
+
6
+ ## Build gate (before you look at anything)
7
+
8
+ - [ ] `figma_lint.py` passes — no `gap`, `font-size` or colour that the design never uses
9
+ - [ ] `<br>` count does not exceed the newlines in `characters`
10
+ - [ ] Type values came from each TEXT node's `style`, not from memory
11
+ - [ ] Spacing came from `itemSpacing` / `padding*`, not from a scale you like
12
+ - [ ] No design value is hidden inside `clamp()` at the design width
13
+ - [ ] Every icon is an exported vector, not an inline `<svg>` drawn from memory
14
+ - [ ] No gradient/box placeholder remains where the design has a photo
15
+
16
+ ## Gate (do this first, per section)
17
+
18
+ - [ ] A reference image for **this** section exists and I have actually looked at it
19
+ - [ ] The section was screenshot-compared against that reference after being built
20
+ - [ ] I am not relying on matching heights/spacing numbers as proof of visual fidelity
21
+ - [ ] Text case (UPPER/lower), real button labels, and asset→node mapping were checked
22
+ against the node JSON (`characters`, `textCase`, `visible`) — not assumed
23
+ - [ ] Nodes with `visible:false` **and** nodes whose cumulative ancestor `opacity` is 0
24
+ were excluded — `visible:true` does not mean "renders"
25
+ - [ ] Per-character overrides (`styleOverrideTable`) were read, not just `style`
26
+ - [ ] Motion the file specifies (`interactions[]`) is implemented, not skipped
27
+ - [ ] The **text audit** for this section was run (`figma_report.py --only <section>`)
28
+ while building it — not deferred to the end
29
+ - [ ] Opacity/animation assertions were made with transitions disabled — a hidden tab
30
+ freezes transitions and reports the mid-flight value
31
+
32
+ ## Layout & Structure
33
+
34
+ - [ ] Max content width matches the reference container
35
+ - [ ] Container gutters / horizontal padding match
36
+ - [ ] Section vertical spacing (top/bottom) matches
37
+ - [ ] Grid column count and gap match
38
+ - [ ] Flex/grid alignment (start/center/end/space-between) matches
39
+ - [ ] Internal component padding matches
40
+ - [ ] Sticky / fixed elements behave as designed
41
+ - [ ] No unintended overflow (horizontal or vertical)
42
+ - [ ] Z-index / stacking order of overlapping elements is correct
43
+
44
+ ## Typography
45
+
46
+ - [ ] Font family matches (or documented fallback in use)
47
+ - [ ] Font weight matches per text role
48
+ - [ ] Font size matches per breakpoint
49
+ - [ ] Line height matches
50
+ - [ ] Letter spacing matches
51
+ - [ ] Text transform (uppercase/capitalize) matches
52
+ - [ ] Text alignment matches
53
+ - [ ] Text color matches
54
+ - [ ] Heading hierarchy is correct and ordered
55
+ - [ ] Paragraph max-width / measure matches
56
+ - [ ] Text wraps at the same points as the reference
57
+
58
+ ## Color & Surface
59
+
60
+ - [ ] Background colors match
61
+ - [ ] Surface / card colors match
62
+ - [ ] Accent / brand colors match
63
+ - [ ] Border colors and widths match
64
+ - [ ] Gradients match (direction, stops, colors)
65
+ - [ ] Opacity / transparency values match
66
+
67
+ ## Depth & Shape
68
+
69
+ - [ ] Border radius matches per element
70
+ - [ ] Shadows match (offset, blur, spread, color)
71
+ - [ ] Blur / backdrop-filter matches
72
+ - [ ] Dividers and separators match
73
+
74
+ ## Imagery & Icons
75
+
76
+ - [ ] Image dimensions and aspect ratios match
77
+ - [ ] object-fit / crop matches the reference
78
+ - [ ] Focal point of cropped images is preserved
79
+ - [ ] Icons match size, stroke weight, and alignment
80
+ - [ ] SVG vs raster usage is appropriate
81
+ - [ ] Background images position and scale correctly
82
+ - [ ] No missing or placeholder assets remain
83
+
84
+ ## Spacing Precision (spot-check with dev tools)
85
+
86
+ - [ ] Gaps between repeated cards/items are exact
87
+ - [ ] Button padding matches
88
+ - [ ] Icon-to-text spacing matches
89
+ - [ ] Label-to-field spacing in forms matches
90
+
91
+ ## Cross-Viewport
92
+
93
+ - [ ] Desktop matches the desktop reference frame
94
+ - [ ] Tablet behaves correctly (if a frame is supplied)
95
+ - [ ] Mobile matches the mobile reference frame
96
+ - [ ] Transitions between breakpoints are clean (no jumps/overlap)
97
+
98
+ ## Coverage — what the report does not prove
99
+
100
+ - [ ] I told the user that colour/radius/stroke/shadow of non-text elements, z-order,
101
+ hover and focus states, motion timings, and other viewports are **not** covered
102
+ - [ ] I looked at the difference-blend overlay myself and said what I saw
103
+
104
+ ## Difference Log
105
+
106
+ Record every remaining discrepancy before declaring completion:
107
+
108
+ | Area | Difference | Cause | Fix | Status |
109
+ |---|---|---|---|---|
110
+ | | | | | |
111
+
112
+ ## Icons — the traps that look like success
113
+
114
+ - [ ] Every icon in the build is an `<img>` pointing at a file extracted from the design.
115
+ - [ ] You **opened the extracted icons and looked at them**, on a background that contrasts
116
+ with them. White icons on a white contact sheet look like missing files.
117
+ - [ ] No icon is a photo (a shape with an `IMAGE` fill), a carousel dot, or a decorative ring.
118
+ - [ ] No icon is a component's placeholder artwork buried under a photo.
119
+ - [ ] A frame holding several icons was split, not exported as one.
120
+ - [ ] Each icon renders at the size of its node in the design, not the size of its ink.
121
+ - [ ] The fidelity report distinguishes *absent* from *placed wrong*; both are read.
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env python3
2
+ """Discovery: find everything in the file you are about to ignore.
3
+
4
+ Run this FIRST, before the node dump, before the renders, before a single line of CSS —
5
+ and show the output to the user. It answers four questions that decide the whole job:
6
+
7
+ 1. how many design widths are in this file? -> breakpoints you must build, not guess
8
+ 2. is there an icon library? -> icons you must export, not draw
9
+ 3. do any interactions point at hover variants? -> states you must reproduce, not invent
10
+ 4. how many page-sized frames are there? -> screens you may have been asked for
11
+
12
+ Missing any of these is not a small error. Guessing a mobile layout that the designer
13
+ already drew, or hand-drawing icons that ship as components in the same file, produces
14
+ work that is wrong on purpose.
15
+
16
+ Usage:
17
+ python3 figma_discover.py <fileKey> [--nodes figma/nodes] [--json out.json]
18
+
19
+ Token: env FIGMA_TOKEN, else ~/.figma_token
20
+ """
21
+ import argparse, glob, json, os, pathlib, re, sys, urllib.request
22
+ from collections import Counter, defaultdict
23
+
24
+ API = "https://api.figma.com/v1"
25
+ ICON_MAX = 64 # a frame this small is an icon, not a screen
26
+ SCREEN_MIN_H = 1200 # a screen is tall
27
+ SCREEN_MIN_W, SCREEN_MAX_W = 320, 1920
28
+ # Names that mean "pasted asset", not "screen we must build". Scratch pages are full of
29
+ # them and they otherwise drown the breakpoint list.
30
+ ASSET_RE = re.compile(
31
+ r"^(image|screencapture|group|rectangle|vector|ellipse|union|mask|isolation_mode|"
32
+ r"frame \d+|frame \d{6,})", re.I)
33
+
34
+
35
+ def token():
36
+ if os.environ.get("FIGMA_TOKEN"):
37
+ return os.environ["FIGMA_TOKEN"].strip()
38
+ p = pathlib.Path.home() / ".figma_token"
39
+ if p.exists():
40
+ return p.read_text().strip()
41
+ sys.exit("No token. See SKILL.md §0.1")
42
+
43
+
44
+ CACHE = pathlib.Path("figma/_api_cache")
45
+
46
+
47
+ def get(path, tok, required=True):
48
+ """Cache every response. EVERY REST endpoint shares a rate-limit budget — the render
49
+ endpoint exhausts first, but /files and /nodes will 429 too. A cached answer is always
50
+ better than a dead run."""
51
+ CACHE.mkdir(parents=True, exist_ok=True)
52
+ key = CACHE / (re.sub(r"[^\w.-]", "_", path)[:120] + ".json")
53
+ if key.exists():
54
+ return json.loads(key.read_text())
55
+ req = urllib.request.Request(API + path, headers={"X-Figma-Token": tok})
56
+ try:
57
+ with urllib.request.urlopen(req, timeout=90) as r:
58
+ data = json.load(r)
59
+ key.write_text(json.dumps(data))
60
+ return data
61
+ except urllib.error.HTTPError as e:
62
+ body = e.read(200).decode(errors="replace")
63
+ if e.code == 429:
64
+ ra = e.headers.get("Retry-After")
65
+ msg = f"429 rate limited on {path}. Retry-After: {ra}"
66
+ if required:
67
+ sys.exit(msg + "\n Every REST endpoint shares a budget, not just /images.")
68
+ print(f" ! {msg} — skipping this part of discovery")
69
+ return None
70
+ if e.code == 403 and "not exportable" in body:
71
+ sys.exit("403 File not exportable — the owner disabled export/copy/share (§0.2).")
72
+ sys.exit(f"HTTP {e.code}: {body}")
73
+
74
+
75
+ def size(n):
76
+ bb = n.get("absoluteBoundingBox") or {}
77
+ return round(bb.get("width", 0)), round(bb.get("height", 0))
78
+
79
+
80
+ def hover_destinations(nodes_dir):
81
+ """destinationId of every ON_HOVER action, and whether we already have that node."""
82
+ dests, have = Counter(), set()
83
+ for f in glob.glob(os.path.join(nodes_dir, "*.json")):
84
+ d = json.load(open(f))
85
+ d = d["document"] if "document" in d else d
86
+
87
+ def walk(n):
88
+ if n.get("visible") is False:
89
+ return
90
+ have.add(n.get("id"))
91
+ for it in n.get("interactions") or []:
92
+ if (it.get("trigger") or {}).get("type") == "ON_HOVER":
93
+ for a in it.get("actions") or []:
94
+ if a.get("destinationId"):
95
+ dests[a["destinationId"]] += 1
96
+ for c in n.get("children", []) or []:
97
+ walk(c)
98
+
99
+ walk(d)
100
+ return dests, have
101
+
102
+
103
+ def main():
104
+ ap = argparse.ArgumentParser()
105
+ ap.add_argument("fileKey")
106
+ ap.add_argument("--nodes", default="figma/nodes",
107
+ help="cached node JSON, to find hover destinations (optional)")
108
+ ap.add_argument("--json", default=None)
109
+ a = ap.parse_args()
110
+
111
+ doc = get(f"/files/{a.fileKey}?depth=2", token())["document"]
112
+
113
+ widths = Counter()
114
+ screens, icons, icon_pages, components, skipped = [], [], [], [], 0
115
+ for page in doc.get("children", []):
116
+ pname = page.get("name", "")
117
+ kids = page.get("children") or []
118
+ is_icon_page = "icon" in pname.lower() and bool(kids)
119
+ if is_icon_page:
120
+ icon_pages.append(pname)
121
+ for k in kids:
122
+ w, h = size(k)
123
+ name = k.get("name", "")
124
+ entry = {"page": pname, "name": name, "w": w, "h": h, "type": k.get("type")}
125
+ if k.get("type") in ("COMPONENT", "COMPONENT_SET"):
126
+ components.append(entry)
127
+ if 0 < w <= ICON_MAX and 0 < h <= ICON_MAX:
128
+ icons.append(entry)
129
+ continue
130
+ if is_icon_page or ASSET_RE.match(name):
131
+ skipped += 1
132
+ continue
133
+ if (SCREEN_MIN_W <= w <= SCREEN_MAX_W and h >= SCREEN_MIN_H
134
+ and h >= w * 1.5):
135
+ widths[w] += 1
136
+ screens.append(entry)
137
+
138
+ out = {"widths": dict(widths), "screens": screens, "icons": icons,
139
+ "icon_pages": icon_pages, "components": components}
140
+
141
+ print("CANDIDATE DESIGN WIDTHS (a heuristic — confirm with the user which are breakpoints)")
142
+ print(f" (ignored {skipped} pasted assets and icon-page frames)")
143
+ for w, n in widths.most_common():
144
+ tallest = max((s for s in screens if s["w"] == w), key=lambda s: s["h"])
145
+ print(f" {w:>5}px x{n:<3} tallest: {tallest['name'][:44]!r} ({tallest['h']}px, page {tallest['page']!r})")
146
+ if len(widths) > 1:
147
+ print("\n -> More than one width. Some of these are cards or scratch frames, not")
148
+ print(" breakpoints. Ask the user which are real, then run the whole loop per")
149
+ print(" confirmed width. If a mobile frame exists, INFERRING a mobile layout")
150
+ print(" instead of building that frame is a fabrication.\n")
151
+ else:
152
+ print("\n -> one width only; responsive behaviour is genuinely inferred (label it).\n")
153
+
154
+ # An icon page's real components live below depth 2; fetch those pages properly.
155
+ lib = []
156
+ for page in doc.get("children", []):
157
+ if page.get("name") in icon_pages:
158
+ node = get(f"/files/{a.fileKey}/nodes?ids={page['id']}", token(), required=False)
159
+ if not node:
160
+ continue
161
+ root = node["nodes"][page["id"]]["document"]
162
+
163
+ def walk(n):
164
+ w, h = size(n)
165
+ # the icons themselves, whatever type the designer used
166
+ if 0 < w <= ICON_MAX and 0 < h <= ICON_MAX and n.get("name"):
167
+ lib.append({"name": n["name"], "id": n["id"], "w": w, "h": h})
168
+ return
169
+ for c in n.get("children", []) or []:
170
+ walk(c)
171
+
172
+ walk(root)
173
+ out["icon_library"] = lib
174
+
175
+ print(f"ICON LIBRARY: {len(icons)} icon-sized top-level frames"
176
+ + (f"; icon pages: {icon_pages}" if icon_pages else ""))
177
+ if lib:
178
+ names = sorted({i["name"] for i in lib})
179
+ print(f" {len(lib)} icons inside those pages ({len(names)} distinct names)")
180
+ print(" sample: " + ", ".join(names[:8]))
181
+ if icons or lib:
182
+ print(" -> export these. Hand-drawing an icon that ships in the file is never acceptable.\n")
183
+ else:
184
+ print(" -> none found; icons must still be extracted from the page SVG, not drawn.\n")
185
+
186
+ print(f"COMPONENTS / VARIANTS at top level: {len(components)}")
187
+ if components:
188
+ print(" -> reuse implies shared CSS components; variants imply states.\n")
189
+ else:
190
+ print()
191
+
192
+ if os.path.isdir(a.nodes):
193
+ dests, have = hover_destinations(a.nodes)
194
+ missing = [d for d in dests if d not in have]
195
+ print(f"HOVER VARIANTS: {sum(dests.values())} ON_HOVER actions -> {len(dests)} destination nodes")
196
+ print(f" not in your cache: {len(missing)} {missing[:5]}")
197
+ if missing:
198
+ print(" -> fetch them and compare your :hover against the real variant (§9.0).\n")
199
+ out["hover_destinations"] = {"all": list(dests), "missing": missing}
200
+ else:
201
+ print("HOVER VARIANTS: no node cache yet; re-run after figma_pull.py\n")
202
+
203
+ page_screens = defaultdict(list)
204
+ for s in screens:
205
+ page_screens[s["page"]].append(s["name"])
206
+ print("PAGE-SIZED FRAMES PER FIGMA PAGE (are you being asked for one screen, or a site?)")
207
+ for p, names in page_screens.items():
208
+ print(f" {p!r}: {len(names)}")
209
+
210
+ if a.json:
211
+ pathlib.Path(a.json).write_text(json.dumps(out, indent=2))
212
+ print(f"\nwrote {a.json}")
213
+
214
+ print("\nShow this output to the user before you write any code.")
215
+
216
+
217
+ if __name__ == "__main__":
218
+ main()
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """Report every font the design actually renders, and which ones the user must supply.
3
+
4
+ Run this once, before writing any CSS, and paste the output to the user (§0.3, §17.A).
5
+
6
+ It is careful about three things that trip people up:
7
+ * `characters` styling can be overridden per character -> also read `styleOverrideTable`
8
+ * `visible: false` subtrees never render -> skipped
9
+ * a node can be `visible: true` and still render nothing -> effective (cumulative)
10
+ ancestor `opacity` of 0 is skipped too
11
+
12
+ Usage:
13
+ python3 figma_fonts.py figma/nodes/*.json
14
+ """
15
+ import json, sys
16
+ from collections import Counter
17
+
18
+ # Common families served free by Google Fonts. Not exhaustive — anything not listed is
19
+ # reported as "verify"; check fonts.google.com before asking the user for it.
20
+ GOOGLE = {
21
+ "abril fatface", "bodoni moda", "cormorant garamond", "dm sans", "dm serif display",
22
+ "eb garamond", "figtree", "fraunces", "geist", "ibm plex sans", "inter", "instrument sans",
23
+ "jost", "karla", "lato", "libre baskerville", "lora", "manrope", "merriweather",
24
+ "montserrat", "mulish", "nunito", "open sans", "outfit", "playfair display", "poppins",
25
+ "prata", "public sans", "quicksand", "raleway", "roboto", "rubik", "source sans 3",
26
+ "space grotesk", "spectral", "syne", "urbanist", "work sans",
27
+ }
28
+
29
+
30
+ def collect(paths):
31
+ faces = Counter() # postScriptName -> render count
32
+ families = {} # postScriptName -> family
33
+ for p in paths:
34
+ doc = json.load(open(p))
35
+ doc = doc["document"] if "document" in doc else doc
36
+
37
+ def walk(n, opacity=1.0):
38
+ if n.get("visible") is False:
39
+ return
40
+ opacity *= n.get("opacity", 1)
41
+ if opacity == 0: # renders nothing, needs no font
42
+ return
43
+ if n.get("type") == "TEXT":
44
+ styles = [n.get("style", {})]
45
+ styles += list((n.get("styleOverrideTable") or {}).values())
46
+ for st in styles:
47
+ ps = st.get("fontPostScriptName")
48
+ if not ps:
49
+ continue
50
+ faces[ps] += 1
51
+ families[ps] = st.get("fontFamily") or ps.split("-")[0]
52
+ for c in n.get("children", []) or []:
53
+ walk(c, opacity)
54
+
55
+ walk(doc)
56
+ return faces, families
57
+
58
+
59
+ def main():
60
+ paths = sys.argv[1:]
61
+ if not paths:
62
+ sys.exit(__doc__)
63
+ faces, families = collect(paths)
64
+ if not faces:
65
+ sys.exit("No TEXT nodes found — wrong files?")
66
+
67
+ free, licensed = [], []
68
+ for ps, n in faces.most_common():
69
+ (free if (families[ps] or "").lower() in GOOGLE else licensed).append((ps, families[ps], n))
70
+
71
+ print("Fonts this design actually renders\n")
72
+ if licensed:
73
+ print("LICENSED — the user must supply these (drop into design/fonts/):")
74
+ for ps, fam, n in licensed:
75
+ print(f" {ps}.woff2 (or .otf) family={fam!r} used x{n}")
76
+ if free:
77
+ print("\nFREE — load these yourself from a CDN, do not ask the user:")
78
+ for ps, fam, n in free:
79
+ print(f" {ps} family={fam!r} used x{n}")
80
+
81
+ print("\nNotes:")
82
+ print(" * families not in the built-in Google list are reported as LICENSED —")
83
+ print(" check fonts.google.com before asking the user for one.")
84
+ print(" * hidden nodes and nodes with effective opacity 0 were excluded, so this")
85
+ print(" list is what the page really needs.")
86
+ print(" * font files cannot be extracted from the REST API or from an SVG export.")
87
+
88
+
89
+ if __name__ == "__main__":
90
+ main()