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/LICENSE +21 -0
- package/README.md +217 -0
- package/SKILL.md +1855 -0
- package/bin/install.js +42 -0
- package/package.json +23 -0
- package/references/accessibility-checklist.md +68 -0
- package/references/animation-guidelines.md +113 -0
- package/references/visual-review-checklist.md +121 -0
- package/scripts/figma_discover.py +218 -0
- package/scripts/figma_fonts.py +90 -0
- package/scripts/figma_icons.py +391 -0
- package/scripts/figma_lint.py +246 -0
- package/scripts/figma_pull.py +153 -0
- package/scripts/figma_report.py +1320 -0
- package/scripts/figma_spec.py +93 -0
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()
|