figma-to-html-pixel-perfect 1.0.0 → 1.0.2
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/README.md +3 -3
- package/SKILL.md +23 -291
- package/package.json +15 -4
- package/references/animation-guidelines.md +175 -0
- package/references/failure-modes.md +118 -0
package/README.md
CHANGED
|
@@ -48,21 +48,21 @@ npx figma-to-html-pixel-perfect --project # this project only: ./.claude/skil
|
|
|
48
48
|
**One-liner without npm (pulls straight from GitHub)**
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
|
-
npx tiged fieldqqq/figma-to-html-perfect
|
|
51
|
+
npx tiged fieldqqq/figma-to-html-pixel-perfect \
|
|
52
52
|
~/.claude/skills/figma-to-html-pixel-perfect
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
**Personal skill (git — recommended if you want `git pull` updates)**
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
|
-
git clone https://github.com/fieldqqq/figma-to-html-perfect
|
|
58
|
+
git clone https://github.com/fieldqqq/figma-to-html-pixel-perfect \
|
|
59
59
|
~/.claude/skills/figma-to-html-pixel-perfect
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
**Project skill**
|
|
63
63
|
|
|
64
64
|
```bash
|
|
65
|
-
git clone https://github.com/fieldqqq/figma-to-html-perfect
|
|
65
|
+
git clone https://github.com/fieldqqq/figma-to-html-pixel-perfect \
|
|
66
66
|
.claude/skills/figma-to-html-pixel-perfect
|
|
67
67
|
```
|
|
68
68
|
|
package/SKILL.md
CHANGED
|
@@ -967,176 +967,17 @@ Do not use these blindly. Adapt breakpoints to the actual design.
|
|
|
967
967
|
## 9. Animation and Motion Policy
|
|
968
968
|
|
|
969
969
|
**Motion that exists in the Figma file is part of the design, not an enhancement.**
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
970
|
+
`interactions[]` in the node JSON = Mode A fidelity work: build it without asking, with the
|
|
971
|
+
file's own duration/easing (verified by the report's hover audit). Motion the file does NOT
|
|
972
|
+
specify is an enhancement: propose, get approval, then implement (Mode C).
|
|
973
973
|
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|---|---|---|
|
|
979
|
-
| `interactions[]` present in the node JSON | **A — required** | **No. Just build it.** |
|
|
980
|
-
| No motion in the file, user said nothing | B | Yes — propose it (§9.2) |
|
|
981
|
-
| No motion in the file, user said "add what suits it" | C (standing approval) | No, but label every value as inferred |
|
|
982
|
-
|
|
983
|
-
So the very first motion question is never "should there be animation?" — it is
|
|
984
|
-
**"what motion does this file already specify?"** (§9.0).
|
|
985
|
-
|
|
986
|
-
### 9.0 Extract the motion the design already has (do this first)
|
|
987
|
-
|
|
988
|
-
**Never assume "the Figma has no animation".** Prototype motion lives in the node JSON and
|
|
989
|
-
is easy to miss. Before designing anything, read it:
|
|
990
|
-
|
|
991
|
-
| Field | Where | What it tells you |
|
|
992
|
-
|---|---|---|
|
|
993
|
-
| `interactions[]` | any node | the whole prototype graph |
|
|
994
|
-
| `interactions[].trigger.type` | " | `ON_HOVER`, `ON_CLICK`, `ON_DRAG`, `AFTER_TIMEOUT` |
|
|
995
|
-
| `interactions[].trigger.timeout` | " | autoplay interval, in **seconds** |
|
|
996
|
-
| `actions[].transition.type` | " | `SMART_ANIMATE`, `DISSOLVE`, `PUSH`… |
|
|
997
|
-
| `actions[].transition.duration` | " | **seconds** — multiply by 1000 |
|
|
998
|
-
| `actions[].transition.easing.type` | " | `GENTLE`, `SLOW`, `LINEAR`, `EASE_*`, `CUSTOM_CUBIC_BEZIER` |
|
|
999
|
-
| `effects[]` | any node | real `DROP_SHADOW` / `BACKGROUND_BLUR` to reproduce |
|
|
1000
|
-
|
|
1001
|
-
```bash
|
|
1002
|
-
grep -c '"interactions"' figma/nodes/*.json # if this is > 0, motion is specified
|
|
1003
|
-
```
|
|
1004
|
-
|
|
1005
|
-
**Figma's named easings are springs; CSS has none.** Approximate, and say that you did:
|
|
1006
|
-
|
|
1007
|
-
| Figma | Character | Reasonable CSS |
|
|
1008
|
-
|---|---|---|
|
|
1009
|
-
| `GENTLE` | soft settle, slight overshoot | `cubic-bezier(0.34, 1.16, 0.64, 1)` |
|
|
1010
|
-
| `SLOW` | long, pure decelerate | `cubic-bezier(0.33, 1, 0.68, 1)` |
|
|
1011
|
-
| `EASE_OUT` | standard decelerate | `cubic-bezier(0.16, 1, 0.3, 1)` |
|
|
1012
|
-
| `CUSTOM_CUBIC_BEZIER` | exact | copy `easingFunctionCubicBezier` verbatim |
|
|
1013
|
-
|
|
1014
|
-
Two traps:
|
|
1015
|
-
|
|
1016
|
-
- Spring durations (often 800–1300 ms) are **settle** times, not perceived times. Reproduce
|
|
1017
|
-
the design's number — §4 puts the design above your taste — and note the tension with
|
|
1018
|
-
the 120–220 ms guidance in §9.4.
|
|
1019
|
-
- `AFTER_TIMEOUT` implies an autoplaying carousel. Check the file actually contains the
|
|
1020
|
-
other slides. If it has only one, **do not invent them** — implement the timing you can,
|
|
1021
|
-
and report the missing slides.
|
|
1022
|
-
|
|
1023
|
-
### 9.0.0 Verify the motion you shipped
|
|
1024
|
-
|
|
1025
|
-
The durations and easings live in `interactions[]`, so they are checkable without any extra
|
|
1026
|
-
fetch — `figma_report.py` compares each hovered node's `transition-duration` against the
|
|
1027
|
-
design. A hover you styled but never gave a transition, or gave your own comfortable 200ms,
|
|
1028
|
-
shows up as a row.
|
|
1029
|
-
|
|
1030
|
-
What the report cannot see is the *appearance* of the hover state. Fetch it:
|
|
1031
|
-
|
|
1032
|
-
```bash
|
|
1033
|
-
python3 scripts/figma_pull.py <fileKey> --hover <destinationId>[,<destinationId>...]
|
|
1034
|
-
```
|
|
1035
|
-
|
|
1036
|
-
Then look at the variant renders and match your `:hover` to them. Never invent a hover
|
|
1037
|
-
appearance for a node whose variant exists in the file.
|
|
1038
|
-
|
|
1039
|
-
### 9.0.1 When the design specifies no motion
|
|
1040
|
-
|
|
1041
|
-
Then, and only then, infer it — and infer it *from the design*, not from habit. Read the
|
|
1042
|
-
mood off what the file does specify:
|
|
1043
|
-
|
|
1044
|
-
- **Easing names and durations already used** (e.g. everything is `SLOW`/`GENTLE` at ~1 s →
|
|
1045
|
-
the product is calm and unhurried; a 200 ms bounce would be wrong).
|
|
1046
|
-
- **Type and colour** (high-contrast Didone + muted palette → editorial, restrained).
|
|
1047
|
-
- **Effects** (heavy `BACKGROUND_BLUR`, soft shadows → soft, layered motion, not snappy).
|
|
1048
|
-
|
|
1049
|
-
Then choose the smallest motion that serves the mood: fade + a short rise (12–20 px),
|
|
1050
|
-
generous duration, decelerating curve, subtle stagger. Label every inferred value.
|
|
1051
|
-
|
|
1052
|
-
### 9.0.2 Motion must never hide content
|
|
1053
|
-
|
|
1054
|
-
Entrance animation that sets `opacity: 0` in CSS is a broken page whenever the script fails,
|
|
1055
|
-
JS is off, or the tab is throttled (`IntersectionObserver` callbacks and transitions do not
|
|
1056
|
-
run in a hidden tab).
|
|
1057
|
-
|
|
1058
|
-
- Scope the hiding rule to a class the script adds: `html.motion [data-reveal] { opacity: 0 }`.
|
|
1059
|
-
- Reveal on `IntersectionObserver` **and** a passive `scroll` sweep **and** `visibilitychange`.
|
|
1060
|
-
- Never depend on `requestAnimationFrame` for the initial pass.
|
|
1061
|
-
- Verify by disabling the script: the page must be fully visible.
|
|
1062
|
-
|
|
1063
|
-
**Verifying opacity in a headless or background tab:** CSS transitions are frozen while
|
|
1064
|
-
`document.hidden` is true, so `getComputedStyle(el).opacity` returns the *mid-flight* value
|
|
1065
|
-
(usually `0`), not the end state. It will look like a bug that isn't there. Inject
|
|
1066
|
-
`* { transition: none !important; animation: none !important }` first, then assert three
|
|
1067
|
-
things: hidden before reveal, visible after reveal, and visible with the script's class
|
|
1068
|
-
removed entirely.
|
|
1069
|
-
|
|
1070
|
-
### 9.1 Do Not Add Animation Automatically
|
|
1071
|
-
|
|
1072
|
-
Before adding animation, explain:
|
|
1073
|
-
|
|
1074
|
-
- the exact section or component;
|
|
1075
|
-
- the proposed motion;
|
|
1076
|
-
- why it improves the experience;
|
|
1077
|
-
- whether it affects performance;
|
|
1078
|
-
- whether it changes the original design;
|
|
1079
|
-
- how reduced-motion users will be handled.
|
|
1080
|
-
|
|
1081
|
-
Then ask the user for approval.
|
|
1082
|
-
|
|
1083
|
-
### 9.2 Required Suggestion Format
|
|
1084
|
-
|
|
1085
|
-
Use this format:
|
|
1086
|
-
|
|
1087
|
-
> **Optional enhancement — [section/component]**
|
|
1088
|
-
> I recommend adding `[animation]` because `[specific UX or visual reason]`.
|
|
1089
|
-
> Suggested behavior: `[trigger, duration, easing, and movement]`.
|
|
1090
|
-
> This is not shown in the original Figma design. Should I add it?
|
|
1091
|
-
|
|
1092
|
-
Example:
|
|
1093
|
-
|
|
1094
|
-
> **Optional enhancement — Hero section**
|
|
1095
|
-
> I recommend a subtle staggered fade-and-rise for the headline, description, and CTA. It would make the first screen feel more polished without changing the layout. Suggested behavior: run once on page load, 420–600 ms, 12 px upward movement, and support `prefers-reduced-motion`. This is not shown in the original Figma design. Should I add it?
|
|
1096
|
-
|
|
1097
|
-
### 9.3 Suitable Motion Opportunities
|
|
1098
|
-
|
|
1099
|
-
Consider suggesting motion for:
|
|
1100
|
-
|
|
1101
|
-
- hero content entrance;
|
|
1102
|
-
- image reveal;
|
|
1103
|
-
- card hover feedback;
|
|
1104
|
-
- button hover and press states;
|
|
1105
|
-
- navigation underline;
|
|
1106
|
-
- accordion expansion;
|
|
1107
|
-
- tab switching;
|
|
1108
|
-
- modal entrance;
|
|
1109
|
-
- scroll-linked section reveal;
|
|
1110
|
-
- number counters;
|
|
1111
|
-
- testimonial carousel;
|
|
1112
|
-
- decorative background movement.
|
|
1113
|
-
|
|
1114
|
-
Do not suggest animation merely to make every section move.
|
|
1115
|
-
|
|
1116
|
-
### 9.4 Motion Quality Rules
|
|
1117
|
-
|
|
1118
|
-
Animation must:
|
|
1119
|
-
|
|
1120
|
-
- reinforce hierarchy or feedback;
|
|
1121
|
-
- be subtle;
|
|
1122
|
-
- avoid blocking user action;
|
|
1123
|
-
- avoid layout shift;
|
|
1124
|
-
- avoid excessive parallax;
|
|
1125
|
-
- avoid long entrance sequences;
|
|
1126
|
-
- support `prefers-reduced-motion`;
|
|
1127
|
-
- use transform and opacity when possible;
|
|
1128
|
-
- preserve keyboard usability.
|
|
1129
|
-
|
|
1130
|
-
Default motion ranges when no design specification exists:
|
|
1131
|
-
|
|
1132
|
-
- microinteraction: 120–220 ms;
|
|
1133
|
-
- component transition: 180–320 ms;
|
|
1134
|
-
- section entrance: 350–650 ms.
|
|
1135
|
-
|
|
1136
|
-
These are defaults, not mandatory values.
|
|
1137
|
-
|
|
1138
|
-
---
|
|
974
|
+
Implementation rules that prevent known regressions: run entrance/scroll reveals via WAAPI
|
|
975
|
+
(`el.animate`) — never a CSS transition that occupies a property a hover transition also
|
|
976
|
+
uses (FM113); include a no-JS/failsafe path so content can never stay hidden; honour
|
|
977
|
+
`prefers-reduced-motion`.
|
|
1139
978
|
|
|
979
|
+
Full policy, easing/duration translation tables and per-pattern guidance:
|
|
980
|
+
**`references/animation-guidelines.md`** — read it before implementing ANY motion.
|
|
1140
981
|
## 10. UX/UI Review Protocol
|
|
1141
982
|
|
|
1142
983
|
Review every section, but separate findings into the following categories.
|
|
@@ -1727,129 +1568,20 @@ Implement first, verify second, recommend third, and change the design only afte
|
|
|
1727
1568
|
|
|
1728
1569
|
---
|
|
1729
1570
|
|
|
1730
|
-
## 22. Known Failure Modes
|
|
1571
|
+
## 22. Known Failure Modes — references/failure-modes.md (MANDATORY reading points)
|
|
1731
1572
|
|
|
1732
|
-
|
|
1573
|
+
114 documented failure modes, every one observed in a real build. The full table lives in
|
|
1574
|
+
`references/failure-modes.md` — do NOT skip it; read it at these three moments:
|
|
1733
1575
|
|
|
1734
|
-
**
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1576
|
+
1. **Before building** any of: fonts/text (FM82-88, 96, 100), repeated-item controls —
|
|
1577
|
+
tabs/cards/logo strips (FM87, 93-95, 101, 106), buttons (FM86, 91, 96, 108), icons/SVG
|
|
1578
|
+
(FM104), images/carousels/backgrounds (FM98-99, 105, 107), section layout (FM84, 103,
|
|
1579
|
+
109), responsive (FM111).
|
|
1580
|
+
2. **Before verifying** — the verifier itself has failure modes: measurement viewport
|
|
1581
|
+
(FM110), stale CSS/JS (FM34, 92), reveal/animation interference (FM68, 113), matcher
|
|
1582
|
+
blind spots (FM112), API quotas (FM114).
|
|
1583
|
+
3. **When the user reports a defect** — find the matching FM class first; the fix pattern
|
|
1584
|
+
and the verify assertion are already written there.
|
|
1739
1585
|
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
| 1 | Built a section without looking at its reference | Right height, wrong design | §6.5.0 |
|
|
1743
|
-
| 2 | Bulk-downloaded every image fill | Render quota gone; `Retry-After` in days | §6.5.2 |
|
|
1744
|
-
| 3 | Background job's stdout buffered | Most renders failed, unnoticed | §6.5.2 |
|
|
1745
|
-
| 4 | Read `name` instead of `characters` | Button said the wrong thing | §6.5.3 |
|
|
1746
|
-
| 5 | Ignored `style.textCase` | Whole page in Title Case, design is UPPER | §6.5.3 |
|
|
1747
|
-
| 6 | Included `visible:false` nodes | Stale copy and ghost elements | §6.5.3 |
|
|
1748
|
-
| 7 | Trusted `textAlignHorizontal` | Centred text "corrected" to left | §6.5.3 |
|
|
1749
|
-
| 8 | Ignored `imageTransform` | Background photo upside-down | §6.5.3 |
|
|
1750
|
-
| 9 | Sloppy `imageRef` filter | Every card photo shuffled by one | §6.5.3 |
|
|
1751
|
-
| 10 | Named sections by order/height | Two sections swapped, one missing entirely | §6.5.3 |
|
|
1752
|
-
| 11 | Invented copy where the design had `"Label"` | Fabricated category names and brands | §6.5.3, §0.5 |
|
|
1753
|
-
| 12 | Guessed the fallback font | Wrong classification, wrong line breaks | §6.5.4 |
|
|
1754
|
-
| 13 | Reported matching heights as proof of fidelity | Every height exact, design still wrong | §6.5.0 |
|
|
1755
|
-
| 14 | Assumed "no animation in the design" | Thousands of `interactions[]` were sitting in the JSON | §5.6, §9.0 |
|
|
1756
|
-
| 15 | Read only `style`, not `styleOverrideTable` | Hero's italic words: wrong family *and* size | §5.3 |
|
|
1757
|
-
| 16 | Entrance motion hid content in CSS | Blank page whenever the script didn't run | §9.0.2 |
|
|
1758
|
-
| 17 | Relied on `requestAnimationFrame` | Nothing revealed in a hidden/throttled tab | §9.0.2 |
|
|
1759
|
-
| 18 | Passed `xmlns` to `xml.etree` as well | Duplicate attribute; every extracted icon broken | §0.5 |
|
|
1760
|
-
| 19 | Copied Figma's fixed px columns verbatim | Overflow between the design width and 1024px | §8 |
|
|
1761
|
-
| 20 | Misread `403 File not exportable` as a token problem | Chased duplicate/export workarounds that cannot work | §6.5.1 |
|
|
1762
|
-
| 21 | Asked for the font at delivery | Too late to act on | §0.3, §17.A |
|
|
1763
|
-
| 22 | Treated `visible:true` as "renders" | Counted an opacity-0 node; nearly demanded a font the design never shows | §6.5.3 |
|
|
1764
|
-
| 23 | Checked only heights | Container offset sideways by a doubled gutter; every height still "matched" | §18.0 |
|
|
1765
|
-
| 24 | Declared the build done from numbers | Handed the user a claim instead of a report they could check | §18.0 |
|
|
1766
|
-
| 25 | Verified only the boxes | Every section "matched"; 10 of 224 text nodes actually did | §18.0 |
|
|
1767
|
-
| 26 | Computed an SVG path's bbox by parsing numbers out of `d` | Curves and relative commands make it wrong; the logo silently cropped to a fragment. Measure with `getBBox()` in a browser | §0.5 |
|
|
1768
|
-
| 27 | Removed a layout rule to make a metric pass | Dropped `100vh` on the hero so the height check would go green — optimised for the report, not the design | §18.0 |
|
|
1769
|
-
| 28 | Invented spacing | `gap` values that exist nowhere in `itemSpacing`; became 129 vertical offsets in the audit | §6.5.5.1 |
|
|
1770
|
-
| 29 | Invented line breaks | 15 `<br>` against 9 real newlines in `characters`; 78 text nodes then "not found in DOM" | §6.5.5.1 |
|
|
1771
|
-
| 30 | Retyped sizes and colours from memory | Subtitle 18px vs 16px, one gold instead of the other | §6.5.5.1 |
|
|
1772
|
-
| 31 | Ran the audit only at the end | Hundreds of offsets landed at once instead of one section at a time | §6.5.5 |
|
|
1773
|
-
| 32 | Hand-drew icons from memory | 73 inline `<svg>` against 7 exported assets; every geometric check still passed | §6.5.5.1, §18.0 |
|
|
1774
|
-
| 33 | Never counted icons or images at all | Placeholders and wrong icons survived to "done" | §18.0 |
|
|
1775
|
-
| 34 | Believed a stale iframe | Verified a CSS fix that the browser had cached; re-check with a cache-busted reload | §6.5.5 |
|
|
1776
|
-
| 35 | Trusted the verifier without verifying it | The overlay was drawn at two different scales, so a mismatch would have looked like a match | §18.0 |
|
|
1777
|
-
| 36 | Linter skipped `var()` | Invented spacing hid inside custom properties and passed | §18.0 |
|
|
1778
|
-
| 37 | Report only iterated the slices that existed | A section with no reference was silently unverified | §18.0 |
|
|
1779
|
-
| 38 | Matched a design text to a whole element | Text sharing a parent with a `<span>` read as "not found"; repeated strings matched the wrong instance. Match the nearest **text run** to the expected position | §18.0 |
|
|
1780
|
-
| 39 | Mixed "wrong colour" with "no matching element" in one count | Produced a meaningless score for the box audit | §18.0 |
|
|
1781
|
-
| 40 | Believed `/files` and `/nodes` were not rate-limited | They 429 too; every REST endpoint shares a budget. Cache every response | §6.5.2 |
|
|
1782
|
-
| 41 | Never looked for other design widths | Wrote a guessed mobile layout while a mobile frame sat in the file | §6.5.0.5 |
|
|
1783
|
-
| 42 | Never looked for an icon library | Hand-drew dozens of icons the file already contained as components | §6.5.0.5, §0.5 |
|
|
1784
|
-
| 43 | Never fetched the hover variants `interactions[]` point at | Invented hover states that were specified in the file | §6.5.0.5, §9.0 |
|
|
1785
|
-
| 44 | Assumed the file held one screen | Built one page of what turned out to be a multi-page site | §6.5.0.5 |
|
|
1786
|
-
| 45 | Claimed icons needed an API call | They are already in the page SVG export; the claim excused hand-drawing them | §0.5 |
|
|
1787
|
-
| 46 | Exported a vector *group* as one icon | Two icons rendered stacked; looked plausible in a list, wrong on the page | §0.5 |
|
|
1788
|
-
| 47 | Implemented hover timings, verified none | The design states every duration; comparing them costs one audit | §9.0.0 |
|
|
1789
|
-
| 48 | Faked a stroke as an element | The design strokes a frame; the build draws a `<div>`. Only a stroke check sees it | §18.0 |
|
|
1790
|
-
| 49 | Counted "a photo is present" as correct | Identity needs an `imageRef` → filename map, or any photo passes | §18.0 |
|
|
1791
|
-
| 50 | Reported one breakpoint as if it were the design | Pass `--breakpoints` so uncovered widths are named in the report | §18.0, §6.5.0.5 |
|
|
1792
|
-
| 51 | Exported a vector a photo paints over | `visible:true, opacity:1` ≠ renders. A later sibling with an opaque fill buries it | §0.5 |
|
|
1793
|
-
| 52 | Called a photo an icon | A RECTANGLE/ELLIPSE with an IMAGE fill is a photo; exporting it sweeps up the paths behind it | §0.5 |
|
|
1794
|
-
| 53 | Read only `<path>` from the SVG export | Figma also emits `<circle>`, `<rect>`, `<ellipse>`, `<line>`, `<polygon>`. Icons using them came out empty | §0.5 |
|
|
1795
|
-
| 54 | Called a cluster of plain ellipses an icon | Carousel dots and rings are CSS shapes. An icon has a `VECTOR`/`BOOLEAN_OPERATION` in it | §0.5 |
|
|
1796
|
-
| 55 | Exported a frame of several icons as one | A pager's two arrows, a five-star row. Split when the children are containers, disjoint, and icon-sized | §0.5 |
|
|
1797
|
-
| 56 | Cropped the icon to its ink | The node's box is the icon's box. Cropping to the ink makes a 12px glyph fill a 40px button | §0.5 |
|
|
1798
|
-
| 57 | Said "icon missing" when it was 60px off | Absent and misplaced are different defects; one word for both hides the fix | §18.0 |
|
|
1799
|
-
| 58 | The report crashed and kept saying "measuring…" | A silent verifier reads as a passing one. Catch, banner, and syntax-check the report you emit | §18.0 |
|
|
1800
|
-
| 59 | Left the nav in normal flow | It overlays the hero in the design; in flow it pushes every y below it down by its own height | §6.5.5.1 |
|
|
1801
|
-
| 60 | Audited design → DOM only | Nothing you *add* to the page has a design node, so nothing looks for it. A stray toggle, a doubled arrow, a leftover element ships "verified" | §18.0 |
|
|
1802
|
-
| 61 | Checked that *an* icon was there, never *which* | The mail icon sat on the YouTube row and the report said 95/106. Compare what the icon draws | §18.0 |
|
|
1803
|
-
| 62 | Fingerprinted icons by hashing their geometry | The same icon reused on another card is sub-pixel-different: a hash calls every reuse a mismatch. And a filled star and an outlined star have identical geometry — a geometry-only fingerprint calls them equal | §18.0 |
|
|
1804
|
-
| 63 | Wrote a comparator and never tested it | `sameIcon` was defined and never called; the operator left behind compared object identity, so every icon read "wrong". Prove the comparator can tell a file from itself | §18.0 |
|
|
1805
|
-
| 64 | Drew a control the design draws as a vector | `appearance:none` plus a CSS triangle, *and* the exported chevron — two arrows on one select | §0.5 |
|
|
1806
|
-
| 65 | Text audit cried "missing" over copy plainly on screen | The design splits `5`/`Bedrooms`; the build merges them; a `<br>` drops the joining space; a label renders as `placeholder=`. Match grouped runs, `innerText`, and form attributes — not just standalone text nodes | §18.0 |
|
|
1807
|
-
| 66 | Reported one text number that hid the story | `10/224` read as "the text is broken" when 223 were present and only the *positions* were off. Decompose: present / positioned / sized / weighted / coloured | §18.0 |
|
|
1808
|
-
| 67 | Let missing or reworded copy reach the browser to be found | `figma_lint.py` now fails the build if any visible `characters` string is absent from the HTML source — caught before a server ever starts | §6.5.5.1 |
|
|
1809
|
-
| 68 | Measured a frozen mid-animation frame | The report's offscreen iframe has `document.hidden === true`, which freezes CSS transitions and IO. An entrance reveal (`opacity:0; translateY(18px)`) never completes, so **every** revealed element reads ~18px low and invisible — a perfectly-built page looked uniformly displaced. Force the final state (kill transitions/animations, neutralise reveal start-states) before measuring | §18.0 |
|
|
1810
|
-
| 69 | Picked an outer wrapper as the text box | A `<label>` inside a group inside a field all share the same `innerText`; the taller outer ones sit higher and report a false y-offset. Keep only the leaf-most candidate | §18.0 |
|
|
1811
|
-
| 70 | Compared ink-top to Figma's line-box-top | Figma's TEXT box top includes the line-height leading; the browser ink box starts at the glyph tops. The faithful analog is the element's own content-box top (when it wraps exactly that text) | §18.0 |
|
|
1812
|
-
| 71 | Report measured stale cached CSS | The iframe re-loaded the HTML but the browser served the PREVIOUS `styles.css`, so a just-applied fix read as still-broken (a `width:100%` that was live on disk but not in the measured frame). Cache-bust the page AND re-point its same-origin `<link>`/`<script>`/`<img>`, awaiting the stylesheets before measuring | §18.0, §6.5.5 |
|
|
1813
|
-
| 72 | A two-column space-between silently centred | `display:grid; justify-content:space-between` with fixed-width columns only distributes when the grid box spans the full width; left to shrink to content it centres, pushing one column inward by half the leftover | §6.5.5.1 |
|
|
1814
|
-
| 73 | Compared a placeholder against the element's text colour | A design TEXT that renders as an `<input>` placeholder shows the `::placeholder` colour, not the element's `color` (which paints typed text). Read the pseudo-element colour when the match came from a form-field attribute | §18.0 |
|
|
1815
|
-
| 74 | Merged number+label a design keeps as separate nodes | The build renders `"5 Bedrooms"` as one run; Figma stores `"5"` and `"Bedrooms"` separately. Match each substring's OWN range (fixed in FM75), then align it in CSS to the design's node x — a merged run whose substrings land at the design positions passes | §18.0, §6.5.5.1 |
|
|
1816
|
-
| 75 | Whole-element match used the container rect for a substring | When an element's `innerText` merely CONTAINED the wanted text ("5" in "5 Bedrooms"), the audit used `selectNodeContents(element)` — the whole element's rect, which starts at the element's left (an icon), not the substring. Every split number/label across every card read a false 20-40px x-offset while the build was pixel-exact. Match EQUALS with the element rect; hand CONTAINS to the substring-ranging fallback so the position is the substring's own | §18.0 |
|
|
1817
|
-
| 76 | Compared X at the left edge for centre/right-aligned text | Figma's `absoluteBoundingBox` is the LAYOUT box. A CENTER-aligned name in a full-column-width box has its ink far from the box's left edge, so comparing the built ink-left to the Figma box-left invents an offset the size of the box's slack (+195 on a centred name, +100 on a centred hero title). Read `textAlignHorizontal` and anchor the comparison at centre/right accordingly | §18.0 |
|
|
1818
|
-
| 77 | Measured a form field at its border, not its text | A design TEXT that renders as an input placeholder/label was compared at the control's border box, but the design node is the TEXT, which sits inside the field's content padding — a uniform false offset the size of the padding. Anchor the comparison at the field's content box | §18.0 |
|
|
1819
|
-
| 78 | Matched a `<select>` placeholder to a bare `<option>` | An `<option>` in a closed select has no layout box (rect at 0,0); the leaf-most-candidate filter then drops the real `<select>` in its favour and reports a nonsense −thousands-px offset. Match the control (`select`/`input`) via its `value`, and skip `<option>` in BOTH the attribute pass and the whole-element (innerText) pass, or the leaf-most filter keeps the zero-size option over the real select and the field reads "not found" | §18.0 |
|
|
1820
|
-
| 79 | Drove the text-position score to 98% while a blank logo and a mis-broken heading shipped | The per-text-node audit sees NEITHER images NOR line-breaks/alignment. A high `x/y` pass rate is not "looks like the design." After the numbers plateau, STOP reading them and LOOK: open the difference-blend, scan every section against its reference slice, and check images and headings by eye | §18.0 |
|
|
1821
|
-
| 80 | A wordmark logo vector-extracted to a solid white block | airbnb/agoda (line art) extracted fine; the Booking.com wordmark came out as 14 overlapping white paths that render as a filled rectangle. Complex/filled logos often do not survive vector extraction — LOOK at every extracted logo on its real background; if it is not the logo, use the raster (PNG) export instead | §0.5 |
|
|
1822
|
-
| 81 | A two-line heading indented its second line | `white-space: pre-line` plus a source newline inside the `<h2>` inherited the HTML's source indentation, pushing line 2 in ~260px — invisible to the position audit, which compares the whole heading's centre. Break lines with an explicit `<br>`, not a source newline | §6.5.5.1 |
|
|
1823
|
-
| 82 | Substitute font wrapped a heading to the wrong line count | A metrically-different fallback never reproduces design line breaks. Wire the real webfont first and assert `document.fonts.check('<w> <size>px "<family>"')` before trusting any heading geometry; never chase a wrap with size/spacing hacks | §0.4, §6.5.4 |
|
|
1824
|
-
| 83 | Flex-centred text box collapsed to a narrow column and over-wrapped | Inside flex/grid, `max-width` alone lets a text box shrink-to-fit. Set the Figma box width as `width` so wrapping matches | §6.5.5.1 |
|
|
1825
|
-
| 84 | Padding hacks pinned a heading's y but broke its box | Never tune paddings to hit a y-target. Rebuild the frame's own box model: panel padding = Figma inset, flow/flex children, bottom-anchored group via `margin-top:auto` | §6.5.5.1 |
|
|
1826
|
-
| 85 | Design fonts never verified as loading | Fail lint when a design font-family has no `@font-face`/webfont link reference; flag headings whose rendered line-count differs from the design node's | §0.4, §18.0 |
|
|
1827
|
-
| 86 | Pill button label wrapped to 2–3 lines | Design button labels are single-line: `white-space: nowrap` on the base button class | §6.5.5.1 |
|
|
1828
|
-
| 87 | Card foot faked the button's x with nudges + mega-gap | A two-ended row is `flex; justify-content:space-between; align-items:center` with a small min gap. Delete positional nudges — they hold at one width only | §6.5.5.1 |
|
|
1829
|
-
| 88 | Read only a TEXT node's base weight and missed a bold sub-run | Emphasis can live entirely in `styleOverrideTable`/`characterStyleOverrides`. Union base `style` with every override when reading weight/style/size/colour; compare the SET of weights against the CSS | §6.5.4, §18.0 |
|
|
1830
|
-
| 89 | Guessed the active/selected state's colour from a generic token | The selected variant is its own node with its own fills. Read the selected child's fill AND its TEXT fill; assert both against the `.is-active` rule | §6.5.4 |
|
|
1831
|
-
| 90 | Dropped a control's track/container fill | Segmented controls, toggle tracks and tab strips often have their own background fill. Read every container FRAME's `fills`; flag a visible solid fill whose CSS element is transparent | §6.5.4 |
|
|
1832
|
-
| 91 | Sized an interactive box by eyeballing padding | Buttons/badges are fixed w×h frames. Measure the rendered rect vs the Figma frame (±1px, border-box including border) and re-measure after every change — a comment is not verification | §6.5.5.1, §18.0 |
|
|
1833
|
-
| 92 | Rewriting `<link>.href` did NOT reparse the CSS | Verify a CSS edit by hard-navigating to a fresh URL (`?cb=<n>`), then assert the changed property's computed value BEFORE trusting any downstream measurement | §6.5.5, FM34 |
|
|
1834
|
-
| 93 | Text-hugging pills + per-item `left:` nudges made uneven gaps | When a control's repeated items share one frame size, set that w×h on each item, centre the label with `inline-flex`, and use the container `itemSpacing` as flex `gap`. Never rebuild spacing with per-item offsets | §6.5.5.1, FM87 |
|
|
1835
|
-
| 94 | Invented a grey container border + phantom shadow | Border colour/width come from `strokes`/`strokeWeight`; add `box-shadow` only if a DROP_SHADOW exists in `effects`. Flag CSS border/shadow colours that appear in no design stroke/effect | §6.5.4, FM90 |
|
|
1836
|
-
| 95 | Claimed "verified" after checking only colours | A repeated-item control passes only when ALL match: each item's w×h, the FULL sequence of inter-item gaps, container width, border colour+width, and shadow presence. Fills matching while geometry is wrong is the most common false "done" | §18.0, FM91 |
|
|
1837
|
-
| 96 | A house `letter-spacing` + transparent border bloated a button, then padding was fudged | Fix text metrics FIRST: match the button TEXT node's letterSpacing/weight/size/textCase exactly (often ls 0); a no-stroke button gets `border:0`; then the Figma padding yields the exact box. A ±1px box is a false pass while text metrics differ | §6.5.5.1, FM91 |
|
|
1838
|
-
| 97 | A margin hack rendered 7px while its comment claimed "Figma 20" | Measure the RENDERED divider→next-row gap and assert it equals the Figma metric (`nextNode.y − divider.y`, ±2). In a flex column, `margin-top` ADDS to the container `gap`. When the user says a gap is off, extract the Figma number first — never guess the direction | §6.5.5.1, FM95 |
|
|
1839
|
-
| 98 | A carousel was reduced to a static image | ≥2 small equal ELLIPSEs clustered on an image frame = slider pager. Reproduce the dots to spec and plan the slider (multiple assets + JS). The active indicator is often composite — an outer ring plus an inner dot: read both nodes | §6.5.4, §15 |
|
|
1840
|
-
| 99 | Trusted the JSON node-dump as complete | Exported JSON can omit component internals, masks and vector strokes. Cross-check against a rendered image of the frame and reproduce render-visible details the JSON lacks — pixels are ground truth, JSON is a lossy index | §0.4, §6.5.4 |
|
|
1841
|
-
| 100 | Fallback-era weight compensation survived the real font | After fixing a font substitution, re-derive every weight/size/ls that was fudged for the fallback from the Figma nodes, delete the "≈" comments, and `document.fonts.check` the exact weight. Verify each section's title weight individually — designs make exceptions | §0.4, FM85 |
|
|
1842
|
-
| 101 | Trailing meta drifted across cards with variable body length | When trailing elements share a baseline in the design, give the variable-length text a `min-height` sized to the longest case; assert the trailing elements' tops are equal across the row | §6.5.5.1, FM95 |
|
|
1843
|
-
| 102 | Audit checked heading fonts but never per-line alignment | For every multi-line heading also assert: computed `text-align`, `text-indent: 0`, and every line's start-x equal (`Range.selectNodeContents` → `getClientRects()`); inspect the HTML bytes around `<br />` for collapsed spaces | §6.5.4, FM95 |
|
|
1844
|
-
| 103 | Declared "flush" from ONE viewport width | `text-align:left` does not stop a `max-width` block being centred by inherited `margin:auto` — reset `margin-inline: 0`. Verify alignment at the design width, a wider (≥1920) and a narrower viewport, AND against the siblings that share the edge | §6.5.5, §7, FM102 |
|
|
1845
|
-
| 104 | Icon SVG and CSS both drew the ring — and the icon set was inconsistent | Open every icon's SVG source: a baked-in full-size `<rect rx>`/`<circle>` IS the container — the wrapper then adds none. Pick ONE owner of the shape, normalise the whole set, and verify every direction/state renders exactly one ring. `<img>` SVGs ignore CSS `color` | §6.5.4, §5 |
|
|
1846
|
-
| 105 | Mirrored background photo; a nudge broke two panels' shared top edge | Check a directional photo's orientation against the design; flip via an absolutely-positioned `::before` with `transform: scaleX(-1)` (background-image itself can't transform). Panels that share a Figma y have NO vertical offset — delete nudges and assert equal tops | §6.5.4, FM87 |
|
|
1847
|
-
| 106 | Logo strip passed on item sizes while the gaps were 3× too small | A logo row is a repeated-item control: measure every `item[i+1].left − item[i].right` against Figma; size the row container to the Figma row width and use `space-between` | §6.5.5.1, FM95 |
|
|
1848
|
-
| 107 | Transparent PNG padding shrank the mark; opacity and colour ignored | Raster logos: crop to the content bbox (declared box ≠ visible ink — check `naturalWidth`/`getbbox`); apply the node's group `opacity`; match the RENDERED colour, not the stored brand fill (`filter: brightness(0) invert(1)` for a white-unified strip) | §5, FM106 |
|
|
1849
|
-
| 108 | A generic container rule hijacked a nested button's padding AND display | Blanket rules (`.footer a { display:block; padding:5px 0 }`) silently steal a `.btn`'s box and centring. Give fixed-size buttons explicit w×h + their own later equal-specificity rule (`a.the-btn`); NEVER raise the broad selector's specificity (`:not(.btn)` out-ranked and broke sibling rows). Re-verify the whole container after any specificity change | §6.5.5.1, §13, FM91 |
|
|
1850
|
-
| 109 | Sections were vertically CENTRED inside locked min-heights — every y floated with content height | A "lock each section to its Figma frame height" rule paired `min-height` with `display:flex; justify-content:center`, so each section's content sat at (min-height − content)/2. Every content-height change re-shifted every y in the section, offsets oscillated between audit runs, and a dozen nudges were tuned against the drift. Figma sections are TOP-anchored: content y = the section's own padding. Lock heights with `min-height` + `justify-content:flex-start` and set padding-top = the first node's Figma y; never centre a section whose design ys are absolute. If audit offsets CHANGE between runs without related edits, suspect a centring/auto-margin ancestor first | §6.5.5.1, FM103 |
|
|
1851
|
-
| 110 | The measuring viewport's scrollbar shifted every x by a constant and failed the whole audit | The fidelity probe rendered the page at the design width, but the page scrolls, so the layout viewport was designW−15px: every centred/right-anchored element measured a constant ~7-15px left of Figma and the text audit failed wholesale (±4 tolerance < shift). Before ANY x-comparison, assert `document.documentElement.clientWidth === designWidth`; if short, widen the window/iframe by the scrollbar width. A CONSTANT x-offset across every node is a viewport artifact, not a build defect — fix the measurement, not the CSS. (figma_report.py now auto-widens its probe iframe.) | §6.5.5, §18.0 |
|
|
1852
|
-
| 111 | The page had zero overflow at the design width and broke at EVERY narrower one | Below-design-width overflow came from five distinct classes, all invisible at 1600: (a) fixed component widths (`.card { width:440px }`, media `width:910px`) — size components with `width:100%` and let the grid track carry the design px; (b) `repeat(N, 1fr)` tracks — `1fr` = `minmax(auto,1fr)` and blows out on nowrap/min-content children; always write `minmax(0,1fr)`; (c) late-in-file wide-viewport overrides out-cascading earlier narrow breakpoints at equal specificity — scope them with range media (`min-width:X and max-width:Y`); (d) an override written for the wrong display type (`grid-template-columns` on a still-flex element does nothing); (e) removed `flex-wrap` on a pill row. Verify with an overflow sweep at ~8 widths (design, 1440, 1280, 1024, 900, 768, 640, 375): `scrollWidth − clientWidth === 0` per width, and after every fix RE-ASSERT the changed property in the probe before re-measuring | §8, FM92, FM103 |
|
|
1853
|
-
| 112 | The verifier itself had five blind spots that read a correct build as broken (or hid real defects) | Fixed in figma_report.py; the classes recur in ANY audit tooling: (a) text inside a closed `<option>` has a 0×0 rect and, if collected, wins the leaf-most filter over the real `<select>` — skip option-descendant text nodes, match the control by value/aria-label; (b) a Figma box with an IMAGE fill exports its invisible SOLID underlay — never compare that colour to CSS, and treat a CSS background-image, a matched `<img>` itself, or a child img covering ≥80% as "image-covered"; (c) radii must be normalised before comparing: resolve `%` against the element box and clamp BOTH sides to min(w,h)/2 (Figma pill radii like 999/1353 = CSS 50%); (d) near-invisible strokes (opacity/alpha < .5) are not a border requirement, and a design stroke may render as any ONE border side — check all four; (e) the report's own cache-bust pass rewrote `src="x.svg"` to `x.svg?bust`, so its icon selector `src$=".svg"` matched nothing — after ANY self-modification of the page, selectors written against the original markup must be re-checked. Also: naming a new asset without checking for an existing file of that name overwrote an in-use icon (verify `ls` before `cp`) | §18.0, FM110 |
|
|
1854
|
-
| 113 | Hover-timing audit read 0ms everywhere; the entrance reveal was also silently overriding every hover transition | Three coupled findings. (a) The report froze animations (`transition:none!important`) to measure geometry, then read `transitionDuration` — measuring what it had itself disabled (0/26). Duration reads are now QUEUED and flushed after the freeze style is removed, in steady-state (entrance markers stripped, since a hidden probe never fires IntersectionObserver). (b) Wrapper/element/icon often share one box: keep the whole tie-set of candidates and let the duration read take the max — a lone "nearest" pick lands on the wrapper (0s) or the icon `<img>`. (c) REAL defect class: a CSS-transition entrance (`[data-reveal] { transition: opacity .82s, transform .82s }`) out-cascades the element's own hover transition (same `transform` property, higher specificity) — every card hovered at the reveal's 820ms instead of the design's 1022ms. Run entrances via WAAPI (`el.animate(...)`, inline `opacity:0` until shown) so the `transition` property is never occupied and hover timing stays the element's own. Also: this Browser-pane environment freezes the animation clock (rAF dead, `playState:running` with `currentTime` stuck at 0) AND blocks all programmatic scroll — "stuck at opacity 0" and "reveal never fires" there are environment artifacts; verify wiring (attrs stripped, animations created, computed transition durations) instead of watching pixels move | §9, §18.0, FM110 |
|
|
1855
|
-
| 114 | Assumed only the render endpoint was rate-limited; `/v1/files` (node JSON) also 429'd for hours | Figma REST quotas are plan-based and cover EVERY endpoint, not just renders — mid-project, `/v1/files` returned 429 Retry-After ~6h while the fill-URL endpoint still worked. The build survived only because all node JSON had been cached to disk on day one. Rule: the API is for ACQUISITION, the disk cache is the workspace — pull once into `figma/nodes/`, make every script read the cache, make the puller skip anything already on disk, and on any 429 report the Retry-After and continue offline from cache instead of blocking. Never design a step that needs the API on every run | §0.1, §6.5 |
|
|
1586
|
+
Working rule for NEW defects: fix the build, fix the verify stage so it would have caught
|
|
1587
|
+
it, and append a design-agnostic FM row to `references/failure-modes.md`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "figma-to-html-pixel-perfect",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
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
5
|
"bin": {
|
|
6
6
|
"figma-to-html-pixel-perfect": "bin/install.js"
|
|
@@ -14,10 +14,21 @@
|
|
|
14
14
|
],
|
|
15
15
|
"repository": {
|
|
16
16
|
"type": "git",
|
|
17
|
-
"url": "git+https://github.com/fieldqqq/figma-to-html-perfect
|
|
17
|
+
"url": "git+https://github.com/fieldqqq/figma-to-html-pixel-perfect.git"
|
|
18
18
|
},
|
|
19
|
-
"keywords": [
|
|
19
|
+
"keywords": [
|
|
20
|
+
"claude",
|
|
21
|
+
"claude-code",
|
|
22
|
+
"skill",
|
|
23
|
+
"figma",
|
|
24
|
+
"html",
|
|
25
|
+
"css",
|
|
26
|
+
"pixel-perfect",
|
|
27
|
+
"design-to-code"
|
|
28
|
+
],
|
|
20
29
|
"author": "fieldqqq",
|
|
21
30
|
"license": "MIT",
|
|
22
|
-
"engines": {
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=16"
|
|
33
|
+
}
|
|
23
34
|
}
|
|
@@ -111,3 +111,178 @@ and long entrance sequences; preserve keyboard use; and respect `prefers-reduced
|
|
|
111
111
|
|
|
112
112
|
Prefer targeted handling over the blanket reset for essential state changes (an accordion
|
|
113
113
|
should still open — instantly, not never).
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
# Full Motion Policy (moved from SKILL.md §9)
|
|
118
|
+
|
|
119
|
+
**Motion that exists in the Figma file is part of the design, not an enhancement.**
|
|
120
|
+
Reproducing it is Mode A fidelity work: implement it, do not ask permission, and do not
|
|
121
|
+
wait to be told. Failing to implement specified motion is a fidelity bug, exactly like a
|
|
122
|
+
wrong colour.
|
|
123
|
+
|
|
124
|
+
Only motion that is **absent** from the design is an enhancement (Mode C) and needs
|
|
125
|
+
approval.
|
|
126
|
+
|
|
127
|
+
| Situation | Mode | Ask first? |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| `interactions[]` present in the node JSON | **A — required** | **No. Just build it.** |
|
|
130
|
+
| No motion in the file, user said nothing | B | Yes — propose it (§9.2) |
|
|
131
|
+
| No motion in the file, user said "add what suits it" | C (standing approval) | No, but label every value as inferred |
|
|
132
|
+
|
|
133
|
+
So the very first motion question is never "should there be animation?" — it is
|
|
134
|
+
**"what motion does this file already specify?"** (§9.0).
|
|
135
|
+
|
|
136
|
+
### 9.0 Extract the motion the design already has (do this first)
|
|
137
|
+
|
|
138
|
+
**Never assume "the Figma has no animation".** Prototype motion lives in the node JSON and
|
|
139
|
+
is easy to miss. Before designing anything, read it:
|
|
140
|
+
|
|
141
|
+
| Field | Where | What it tells you |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| `interactions[]` | any node | the whole prototype graph |
|
|
144
|
+
| `interactions[].trigger.type` | " | `ON_HOVER`, `ON_CLICK`, `ON_DRAG`, `AFTER_TIMEOUT` |
|
|
145
|
+
| `interactions[].trigger.timeout` | " | autoplay interval, in **seconds** |
|
|
146
|
+
| `actions[].transition.type` | " | `SMART_ANIMATE`, `DISSOLVE`, `PUSH`… |
|
|
147
|
+
| `actions[].transition.duration` | " | **seconds** — multiply by 1000 |
|
|
148
|
+
| `actions[].transition.easing.type` | " | `GENTLE`, `SLOW`, `LINEAR`, `EASE_*`, `CUSTOM_CUBIC_BEZIER` |
|
|
149
|
+
| `effects[]` | any node | real `DROP_SHADOW` / `BACKGROUND_BLUR` to reproduce |
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
grep -c '"interactions"' figma/nodes/*.json # if this is > 0, motion is specified
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Figma's named easings are springs; CSS has none.** Approximate, and say that you did:
|
|
156
|
+
|
|
157
|
+
| Figma | Character | Reasonable CSS |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `GENTLE` | soft settle, slight overshoot | `cubic-bezier(0.34, 1.16, 0.64, 1)` |
|
|
160
|
+
| `SLOW` | long, pure decelerate | `cubic-bezier(0.33, 1, 0.68, 1)` |
|
|
161
|
+
| `EASE_OUT` | standard decelerate | `cubic-bezier(0.16, 1, 0.3, 1)` |
|
|
162
|
+
| `CUSTOM_CUBIC_BEZIER` | exact | copy `easingFunctionCubicBezier` verbatim |
|
|
163
|
+
|
|
164
|
+
Two traps:
|
|
165
|
+
|
|
166
|
+
- Spring durations (often 800–1300 ms) are **settle** times, not perceived times. Reproduce
|
|
167
|
+
the design's number — §4 puts the design above your taste — and note the tension with
|
|
168
|
+
the 120–220 ms guidance in §9.4.
|
|
169
|
+
- `AFTER_TIMEOUT` implies an autoplaying carousel. Check the file actually contains the
|
|
170
|
+
other slides. If it has only one, **do not invent them** — implement the timing you can,
|
|
171
|
+
and report the missing slides.
|
|
172
|
+
|
|
173
|
+
### 9.0.0 Verify the motion you shipped
|
|
174
|
+
|
|
175
|
+
The durations and easings live in `interactions[]`, so they are checkable without any extra
|
|
176
|
+
fetch — `figma_report.py` compares each hovered node's `transition-duration` against the
|
|
177
|
+
design. A hover you styled but never gave a transition, or gave your own comfortable 200ms,
|
|
178
|
+
shows up as a row.
|
|
179
|
+
|
|
180
|
+
What the report cannot see is the *appearance* of the hover state. Fetch it:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
python3 scripts/figma_pull.py <fileKey> --hover <destinationId>[,<destinationId>...]
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Then look at the variant renders and match your `:hover` to them. Never invent a hover
|
|
187
|
+
appearance for a node whose variant exists in the file.
|
|
188
|
+
|
|
189
|
+
### 9.0.1 When the design specifies no motion
|
|
190
|
+
|
|
191
|
+
Then, and only then, infer it — and infer it *from the design*, not from habit. Read the
|
|
192
|
+
mood off what the file does specify:
|
|
193
|
+
|
|
194
|
+
- **Easing names and durations already used** (e.g. everything is `SLOW`/`GENTLE` at ~1 s →
|
|
195
|
+
the product is calm and unhurried; a 200 ms bounce would be wrong).
|
|
196
|
+
- **Type and colour** (high-contrast Didone + muted palette → editorial, restrained).
|
|
197
|
+
- **Effects** (heavy `BACKGROUND_BLUR`, soft shadows → soft, layered motion, not snappy).
|
|
198
|
+
|
|
199
|
+
Then choose the smallest motion that serves the mood: fade + a short rise (12–20 px),
|
|
200
|
+
generous duration, decelerating curve, subtle stagger. Label every inferred value.
|
|
201
|
+
|
|
202
|
+
### 9.0.2 Motion must never hide content
|
|
203
|
+
|
|
204
|
+
Entrance animation that sets `opacity: 0` in CSS is a broken page whenever the script fails,
|
|
205
|
+
JS is off, or the tab is throttled (`IntersectionObserver` callbacks and transitions do not
|
|
206
|
+
run in a hidden tab).
|
|
207
|
+
|
|
208
|
+
- Scope the hiding rule to a class the script adds: `html.motion [data-reveal] { opacity: 0 }`.
|
|
209
|
+
- Reveal on `IntersectionObserver` **and** a passive `scroll` sweep **and** `visibilitychange`.
|
|
210
|
+
- Never depend on `requestAnimationFrame` for the initial pass.
|
|
211
|
+
- Verify by disabling the script: the page must be fully visible.
|
|
212
|
+
|
|
213
|
+
**Verifying opacity in a headless or background tab:** CSS transitions are frozen while
|
|
214
|
+
`document.hidden` is true, so `getComputedStyle(el).opacity` returns the *mid-flight* value
|
|
215
|
+
(usually `0`), not the end state. It will look like a bug that isn't there. Inject
|
|
216
|
+
`* { transition: none !important; animation: none !important }` first, then assert three
|
|
217
|
+
things: hidden before reveal, visible after reveal, and visible with the script's class
|
|
218
|
+
removed entirely.
|
|
219
|
+
|
|
220
|
+
### 9.1 Do Not Add Animation Automatically
|
|
221
|
+
|
|
222
|
+
Before adding animation, explain:
|
|
223
|
+
|
|
224
|
+
- the exact section or component;
|
|
225
|
+
- the proposed motion;
|
|
226
|
+
- why it improves the experience;
|
|
227
|
+
- whether it affects performance;
|
|
228
|
+
- whether it changes the original design;
|
|
229
|
+
- how reduced-motion users will be handled.
|
|
230
|
+
|
|
231
|
+
Then ask the user for approval.
|
|
232
|
+
|
|
233
|
+
### 9.2 Required Suggestion Format
|
|
234
|
+
|
|
235
|
+
Use this format:
|
|
236
|
+
|
|
237
|
+
> **Optional enhancement — [section/component]**
|
|
238
|
+
> I recommend adding `[animation]` because `[specific UX or visual reason]`.
|
|
239
|
+
> Suggested behavior: `[trigger, duration, easing, and movement]`.
|
|
240
|
+
> This is not shown in the original Figma design. Should I add it?
|
|
241
|
+
|
|
242
|
+
Example:
|
|
243
|
+
|
|
244
|
+
> **Optional enhancement — Hero section**
|
|
245
|
+
> I recommend a subtle staggered fade-and-rise for the headline, description, and CTA. It would make the first screen feel more polished without changing the layout. Suggested behavior: run once on page load, 420–600 ms, 12 px upward movement, and support `prefers-reduced-motion`. This is not shown in the original Figma design. Should I add it?
|
|
246
|
+
|
|
247
|
+
### 9.3 Suitable Motion Opportunities
|
|
248
|
+
|
|
249
|
+
Consider suggesting motion for:
|
|
250
|
+
|
|
251
|
+
- hero content entrance;
|
|
252
|
+
- image reveal;
|
|
253
|
+
- card hover feedback;
|
|
254
|
+
- button hover and press states;
|
|
255
|
+
- navigation underline;
|
|
256
|
+
- accordion expansion;
|
|
257
|
+
- tab switching;
|
|
258
|
+
- modal entrance;
|
|
259
|
+
- scroll-linked section reveal;
|
|
260
|
+
- number counters;
|
|
261
|
+
- testimonial carousel;
|
|
262
|
+
- decorative background movement.
|
|
263
|
+
|
|
264
|
+
Do not suggest animation merely to make every section move.
|
|
265
|
+
|
|
266
|
+
### 9.4 Motion Quality Rules
|
|
267
|
+
|
|
268
|
+
Animation must:
|
|
269
|
+
|
|
270
|
+
- reinforce hierarchy or feedback;
|
|
271
|
+
- be subtle;
|
|
272
|
+
- avoid blocking user action;
|
|
273
|
+
- avoid layout shift;
|
|
274
|
+
- avoid excessive parallax;
|
|
275
|
+
- avoid long entrance sequences;
|
|
276
|
+
- support `prefers-reduced-motion`;
|
|
277
|
+
- use transform and opacity when possible;
|
|
278
|
+
- preserve keyboard usability.
|
|
279
|
+
|
|
280
|
+
Default motion ranges when no design specification exists:
|
|
281
|
+
|
|
282
|
+
- microinteraction: 120–220 ms;
|
|
283
|
+
- component transition: 180–320 ms;
|
|
284
|
+
- section entrance: 350–650 ms.
|
|
285
|
+
|
|
286
|
+
These are defaults, not mandatory values.
|
|
287
|
+
|
|
288
|
+
---
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Known Failure Modes (all observed in real builds)
|
|
2
|
+
|
|
3
|
+
| # | Failure | Symptom | Rule |
|
|
4
|
+
|---|---|---|---|
|
|
5
|
+
| 1 | Built a section without looking at its reference | Right height, wrong design | §6.5.0 |
|
|
6
|
+
| 2 | Bulk-downloaded every image fill | Render quota gone; `Retry-After` in days | §6.5.2 |
|
|
7
|
+
| 3 | Background job's stdout buffered | Most renders failed, unnoticed | §6.5.2 |
|
|
8
|
+
| 4 | Read `name` instead of `characters` | Button said the wrong thing | §6.5.3 |
|
|
9
|
+
| 5 | Ignored `style.textCase` | Whole page in Title Case, design is UPPER | §6.5.3 |
|
|
10
|
+
| 6 | Included `visible:false` nodes | Stale copy and ghost elements | §6.5.3 |
|
|
11
|
+
| 7 | Trusted `textAlignHorizontal` | Centred text "corrected" to left | §6.5.3 |
|
|
12
|
+
| 8 | Ignored `imageTransform` | Background photo upside-down | §6.5.3 |
|
|
13
|
+
| 9 | Sloppy `imageRef` filter | Every card photo shuffled by one | §6.5.3 |
|
|
14
|
+
| 10 | Named sections by order/height | Two sections swapped, one missing entirely | §6.5.3 |
|
|
15
|
+
| 11 | Invented copy where the design had `"Label"` | Fabricated category names and brands | §6.5.3, §0.5 |
|
|
16
|
+
| 12 | Guessed the fallback font | Wrong classification, wrong line breaks | §6.5.4 |
|
|
17
|
+
| 13 | Reported matching heights as proof of fidelity | Every height exact, design still wrong | §6.5.0 |
|
|
18
|
+
| 14 | Assumed "no animation in the design" | Thousands of `interactions[]` were sitting in the JSON | §5.6, §9.0 |
|
|
19
|
+
| 15 | Read only `style`, not `styleOverrideTable` | Hero's italic words: wrong family *and* size | §5.3 |
|
|
20
|
+
| 16 | Entrance motion hid content in CSS | Blank page whenever the script didn't run | §9.0.2 |
|
|
21
|
+
| 17 | Relied on `requestAnimationFrame` | Nothing revealed in a hidden/throttled tab | §9.0.2 |
|
|
22
|
+
| 18 | Passed `xmlns` to `xml.etree` as well | Duplicate attribute; every extracted icon broken | §0.5 |
|
|
23
|
+
| 19 | Copied Figma's fixed px columns verbatim | Overflow between the design width and 1024px | §8 |
|
|
24
|
+
| 20 | Misread `403 File not exportable` as a token problem | Chased duplicate/export workarounds that cannot work | §6.5.1 |
|
|
25
|
+
| 21 | Asked for the font at delivery | Too late to act on | §0.3, §17.A |
|
|
26
|
+
| 22 | Treated `visible:true` as "renders" | Counted an opacity-0 node; nearly demanded a font the design never shows | §6.5.3 |
|
|
27
|
+
| 23 | Checked only heights | Container offset sideways by a doubled gutter; every height still "matched" | §18.0 |
|
|
28
|
+
| 24 | Declared the build done from numbers | Handed the user a claim instead of a report they could check | §18.0 |
|
|
29
|
+
| 25 | Verified only the boxes | Every section "matched"; 10 of 224 text nodes actually did | §18.0 |
|
|
30
|
+
| 26 | Computed an SVG path's bbox by parsing numbers out of `d` | Curves and relative commands make it wrong; the logo silently cropped to a fragment. Measure with `getBBox()` in a browser | §0.5 |
|
|
31
|
+
| 27 | Removed a layout rule to make a metric pass | Dropped `100vh` on the hero so the height check would go green — optimised for the report, not the design | §18.0 |
|
|
32
|
+
| 28 | Invented spacing | `gap` values that exist nowhere in `itemSpacing`; became 129 vertical offsets in the audit | §6.5.5.1 |
|
|
33
|
+
| 29 | Invented line breaks | 15 `<br>` against 9 real newlines in `characters`; 78 text nodes then "not found in DOM" | §6.5.5.1 |
|
|
34
|
+
| 30 | Retyped sizes and colours from memory | Subtitle 18px vs 16px, one gold instead of the other | §6.5.5.1 |
|
|
35
|
+
| 31 | Ran the audit only at the end | Hundreds of offsets landed at once instead of one section at a time | §6.5.5 |
|
|
36
|
+
| 32 | Hand-drew icons from memory | 73 inline `<svg>` against 7 exported assets; every geometric check still passed | §6.5.5.1, §18.0 |
|
|
37
|
+
| 33 | Never counted icons or images at all | Placeholders and wrong icons survived to "done" | §18.0 |
|
|
38
|
+
| 34 | Believed a stale iframe | Verified a CSS fix that the browser had cached; re-check with a cache-busted reload | §6.5.5 |
|
|
39
|
+
| 35 | Trusted the verifier without verifying it | The overlay was drawn at two different scales, so a mismatch would have looked like a match | §18.0 |
|
|
40
|
+
| 36 | Linter skipped `var()` | Invented spacing hid inside custom properties and passed | §18.0 |
|
|
41
|
+
| 37 | Report only iterated the slices that existed | A section with no reference was silently unverified | §18.0 |
|
|
42
|
+
| 38 | Matched a design text to a whole element | Text sharing a parent with a `<span>` read as "not found"; repeated strings matched the wrong instance. Match the nearest **text run** to the expected position | §18.0 |
|
|
43
|
+
| 39 | Mixed "wrong colour" with "no matching element" in one count | Produced a meaningless score for the box audit | §18.0 |
|
|
44
|
+
| 40 | Believed `/files` and `/nodes` were not rate-limited | They 429 too; every REST endpoint shares a budget. Cache every response | §6.5.2 |
|
|
45
|
+
| 41 | Never looked for other design widths | Wrote a guessed mobile layout while a mobile frame sat in the file | §6.5.0.5 |
|
|
46
|
+
| 42 | Never looked for an icon library | Hand-drew dozens of icons the file already contained as components | §6.5.0.5, §0.5 |
|
|
47
|
+
| 43 | Never fetched the hover variants `interactions[]` point at | Invented hover states that were specified in the file | §6.5.0.5, §9.0 |
|
|
48
|
+
| 44 | Assumed the file held one screen | Built one page of what turned out to be a multi-page site | §6.5.0.5 |
|
|
49
|
+
| 45 | Claimed icons needed an API call | They are already in the page SVG export; the claim excused hand-drawing them | §0.5 |
|
|
50
|
+
| 46 | Exported a vector *group* as one icon | Two icons rendered stacked; looked plausible in a list, wrong on the page | §0.5 |
|
|
51
|
+
| 47 | Implemented hover timings, verified none | The design states every duration; comparing them costs one audit | §9.0.0 |
|
|
52
|
+
| 48 | Faked a stroke as an element | The design strokes a frame; the build draws a `<div>`. Only a stroke check sees it | §18.0 |
|
|
53
|
+
| 49 | Counted "a photo is present" as correct | Identity needs an `imageRef` → filename map, or any photo passes | §18.0 |
|
|
54
|
+
| 50 | Reported one breakpoint as if it were the design | Pass `--breakpoints` so uncovered widths are named in the report | §18.0, §6.5.0.5 |
|
|
55
|
+
| 51 | Exported a vector a photo paints over | `visible:true, opacity:1` ≠ renders. A later sibling with an opaque fill buries it | §0.5 |
|
|
56
|
+
| 52 | Called a photo an icon | A RECTANGLE/ELLIPSE with an IMAGE fill is a photo; exporting it sweeps up the paths behind it | §0.5 |
|
|
57
|
+
| 53 | Read only `<path>` from the SVG export | Figma also emits `<circle>`, `<rect>`, `<ellipse>`, `<line>`, `<polygon>`. Icons using them came out empty | §0.5 |
|
|
58
|
+
| 54 | Called a cluster of plain ellipses an icon | Carousel dots and rings are CSS shapes. An icon has a `VECTOR`/`BOOLEAN_OPERATION` in it | §0.5 |
|
|
59
|
+
| 55 | Exported a frame of several icons as one | A pager's two arrows, a five-star row. Split when the children are containers, disjoint, and icon-sized | §0.5 |
|
|
60
|
+
| 56 | Cropped the icon to its ink | The node's box is the icon's box. Cropping to the ink makes a 12px glyph fill a 40px button | §0.5 |
|
|
61
|
+
| 57 | Said "icon missing" when it was 60px off | Absent and misplaced are different defects; one word for both hides the fix | §18.0 |
|
|
62
|
+
| 58 | The report crashed and kept saying "measuring…" | A silent verifier reads as a passing one. Catch, banner, and syntax-check the report you emit | §18.0 |
|
|
63
|
+
| 59 | Left the nav in normal flow | It overlays the hero in the design; in flow it pushes every y below it down by its own height | §6.5.5.1 |
|
|
64
|
+
| 60 | Audited design → DOM only | Nothing you *add* to the page has a design node, so nothing looks for it. A stray toggle, a doubled arrow, a leftover element ships "verified" | §18.0 |
|
|
65
|
+
| 61 | Checked that *an* icon was there, never *which* | The mail icon sat on the YouTube row and the report said 95/106. Compare what the icon draws | §18.0 |
|
|
66
|
+
| 62 | Fingerprinted icons by hashing their geometry | The same icon reused on another card is sub-pixel-different: a hash calls every reuse a mismatch. And a filled star and an outlined star have identical geometry — a geometry-only fingerprint calls them equal | §18.0 |
|
|
67
|
+
| 63 | Wrote a comparator and never tested it | `sameIcon` was defined and never called; the operator left behind compared object identity, so every icon read "wrong". Prove the comparator can tell a file from itself | §18.0 |
|
|
68
|
+
| 64 | Drew a control the design draws as a vector | `appearance:none` plus a CSS triangle, *and* the exported chevron — two arrows on one select | §0.5 |
|
|
69
|
+
| 65 | Text audit cried "missing" over copy plainly on screen | The design splits `5`/`Bedrooms`; the build merges them; a `<br>` drops the joining space; a label renders as `placeholder=`. Match grouped runs, `innerText`, and form attributes — not just standalone text nodes | §18.0 |
|
|
70
|
+
| 66 | Reported one text number that hid the story | `10/224` read as "the text is broken" when 223 were present and only the *positions* were off. Decompose: present / positioned / sized / weighted / coloured | §18.0 |
|
|
71
|
+
| 67 | Let missing or reworded copy reach the browser to be found | `figma_lint.py` now fails the build if any visible `characters` string is absent from the HTML source — caught before a server ever starts | §6.5.5.1 |
|
|
72
|
+
| 68 | Measured a frozen mid-animation frame | The report's offscreen iframe has `document.hidden === true`, which freezes CSS transitions and IO. An entrance reveal (`opacity:0; translateY(18px)`) never completes, so **every** revealed element reads ~18px low and invisible — a perfectly-built page looked uniformly displaced. Force the final state (kill transitions/animations, neutralise reveal start-states) before measuring | §18.0 |
|
|
73
|
+
| 69 | Picked an outer wrapper as the text box | A `<label>` inside a group inside a field all share the same `innerText`; the taller outer ones sit higher and report a false y-offset. Keep only the leaf-most candidate | §18.0 |
|
|
74
|
+
| 70 | Compared ink-top to Figma's line-box-top | Figma's TEXT box top includes the line-height leading; the browser ink box starts at the glyph tops. The faithful analog is the element's own content-box top (when it wraps exactly that text) | §18.0 |
|
|
75
|
+
| 71 | Report measured stale cached CSS | The iframe re-loaded the HTML but the browser served the PREVIOUS `styles.css`, so a just-applied fix read as still-broken (a `width:100%` that was live on disk but not in the measured frame). Cache-bust the page AND re-point its same-origin `<link>`/`<script>`/`<img>`, awaiting the stylesheets before measuring | §18.0, §6.5.5 |
|
|
76
|
+
| 72 | A two-column space-between silently centred | `display:grid; justify-content:space-between` with fixed-width columns only distributes when the grid box spans the full width; left to shrink to content it centres, pushing one column inward by half the leftover | §6.5.5.1 |
|
|
77
|
+
| 73 | Compared a placeholder against the element's text colour | A design TEXT that renders as an `<input>` placeholder shows the `::placeholder` colour, not the element's `color` (which paints typed text). Read the pseudo-element colour when the match came from a form-field attribute | §18.0 |
|
|
78
|
+
| 74 | Merged number+label a design keeps as separate nodes | The build renders `"5 Bedrooms"` as one run; Figma stores `"5"` and `"Bedrooms"` separately. Match each substring's OWN range (fixed in FM75), then align it in CSS to the design's node x — a merged run whose substrings land at the design positions passes | §18.0, §6.5.5.1 |
|
|
79
|
+
| 75 | Whole-element match used the container rect for a substring | When an element's `innerText` merely CONTAINED the wanted text ("5" in "5 Bedrooms"), the audit used `selectNodeContents(element)` — the whole element's rect, which starts at the element's left (an icon), not the substring. Every split number/label across every card read a false 20-40px x-offset while the build was pixel-exact. Match EQUALS with the element rect; hand CONTAINS to the substring-ranging fallback so the position is the substring's own | §18.0 |
|
|
80
|
+
| 76 | Compared X at the left edge for centre/right-aligned text | Figma's `absoluteBoundingBox` is the LAYOUT box. A CENTER-aligned name in a full-column-width box has its ink far from the box's left edge, so comparing the built ink-left to the Figma box-left invents an offset the size of the box's slack (+195 on a centred name, +100 on a centred hero title). Read `textAlignHorizontal` and anchor the comparison at centre/right accordingly | §18.0 |
|
|
81
|
+
| 77 | Measured a form field at its border, not its text | A design TEXT that renders as an input placeholder/label was compared at the control's border box, but the design node is the TEXT, which sits inside the field's content padding — a uniform false offset the size of the padding. Anchor the comparison at the field's content box | §18.0 |
|
|
82
|
+
| 78 | Matched a `<select>` placeholder to a bare `<option>` | An `<option>` in a closed select has no layout box (rect at 0,0); the leaf-most-candidate filter then drops the real `<select>` in its favour and reports a nonsense −thousands-px offset. Match the control (`select`/`input`) via its `value`, and skip `<option>` in BOTH the attribute pass and the whole-element (innerText) pass, or the leaf-most filter keeps the zero-size option over the real select and the field reads "not found" | §18.0 |
|
|
83
|
+
| 79 | Drove the text-position score to 98% while a blank logo and a mis-broken heading shipped | The per-text-node audit sees NEITHER images NOR line-breaks/alignment. A high `x/y` pass rate is not "looks like the design." After the numbers plateau, STOP reading them and LOOK: open the difference-blend, scan every section against its reference slice, and check images and headings by eye | §18.0 |
|
|
84
|
+
| 80 | A wordmark logo vector-extracted to a solid white block | airbnb/agoda (line art) extracted fine; the Booking.com wordmark came out as 14 overlapping white paths that render as a filled rectangle. Complex/filled logos often do not survive vector extraction — LOOK at every extracted logo on its real background; if it is not the logo, use the raster (PNG) export instead | §0.5 |
|
|
85
|
+
| 81 | A two-line heading indented its second line | `white-space: pre-line` plus a source newline inside the `<h2>` inherited the HTML's source indentation, pushing line 2 in ~260px — invisible to the position audit, which compares the whole heading's centre. Break lines with an explicit `<br>`, not a source newline | §6.5.5.1 |
|
|
86
|
+
| 82 | Substitute font wrapped a heading to the wrong line count | A metrically-different fallback never reproduces design line breaks. Wire the real webfont first and assert `document.fonts.check('<w> <size>px "<family>"')` before trusting any heading geometry; never chase a wrap with size/spacing hacks | §0.4, §6.5.4 |
|
|
87
|
+
| 83 | Flex-centred text box collapsed to a narrow column and over-wrapped | Inside flex/grid, `max-width` alone lets a text box shrink-to-fit. Set the Figma box width as `width` so wrapping matches | §6.5.5.1 |
|
|
88
|
+
| 84 | Padding hacks pinned a heading's y but broke its box | Never tune paddings to hit a y-target. Rebuild the frame's own box model: panel padding = Figma inset, flow/flex children, bottom-anchored group via `margin-top:auto` | §6.5.5.1 |
|
|
89
|
+
| 85 | Design fonts never verified as loading | Fail lint when a design font-family has no `@font-face`/webfont link reference; flag headings whose rendered line-count differs from the design node's | §0.4, §18.0 |
|
|
90
|
+
| 86 | Pill button label wrapped to 2–3 lines | Design button labels are single-line: `white-space: nowrap` on the base button class | §6.5.5.1 |
|
|
91
|
+
| 87 | Card foot faked the button's x with nudges + mega-gap | A two-ended row is `flex; justify-content:space-between; align-items:center` with a small min gap. Delete positional nudges — they hold at one width only | §6.5.5.1 |
|
|
92
|
+
| 88 | Read only a TEXT node's base weight and missed a bold sub-run | Emphasis can live entirely in `styleOverrideTable`/`characterStyleOverrides`. Union base `style` with every override when reading weight/style/size/colour; compare the SET of weights against the CSS | §6.5.4, §18.0 |
|
|
93
|
+
| 89 | Guessed the active/selected state's colour from a generic token | The selected variant is its own node with its own fills. Read the selected child's fill AND its TEXT fill; assert both against the `.is-active` rule | §6.5.4 |
|
|
94
|
+
| 90 | Dropped a control's track/container fill | Segmented controls, toggle tracks and tab strips often have their own background fill. Read every container FRAME's `fills`; flag a visible solid fill whose CSS element is transparent | §6.5.4 |
|
|
95
|
+
| 91 | Sized an interactive box by eyeballing padding | Buttons/badges are fixed w×h frames. Measure the rendered rect vs the Figma frame (±1px, border-box including border) and re-measure after every change — a comment is not verification | §6.5.5.1, §18.0 |
|
|
96
|
+
| 92 | Rewriting `<link>.href` did NOT reparse the CSS | Verify a CSS edit by hard-navigating to a fresh URL (`?cb=<n>`), then assert the changed property's computed value BEFORE trusting any downstream measurement | §6.5.5, FM34 |
|
|
97
|
+
| 93 | Text-hugging pills + per-item `left:` nudges made uneven gaps | When a control's repeated items share one frame size, set that w×h on each item, centre the label with `inline-flex`, and use the container `itemSpacing` as flex `gap`. Never rebuild spacing with per-item offsets | §6.5.5.1, FM87 |
|
|
98
|
+
| 94 | Invented a grey container border + phantom shadow | Border colour/width come from `strokes`/`strokeWeight`; add `box-shadow` only if a DROP_SHADOW exists in `effects`. Flag CSS border/shadow colours that appear in no design stroke/effect | §6.5.4, FM90 |
|
|
99
|
+
| 95 | Claimed "verified" after checking only colours | A repeated-item control passes only when ALL match: each item's w×h, the FULL sequence of inter-item gaps, container width, border colour+width, and shadow presence. Fills matching while geometry is wrong is the most common false "done" | §18.0, FM91 |
|
|
100
|
+
| 96 | A house `letter-spacing` + transparent border bloated a button, then padding was fudged | Fix text metrics FIRST: match the button TEXT node's letterSpacing/weight/size/textCase exactly (often ls 0); a no-stroke button gets `border:0`; then the Figma padding yields the exact box. A ±1px box is a false pass while text metrics differ | §6.5.5.1, FM91 |
|
|
101
|
+
| 97 | A margin hack rendered 7px while its comment claimed "Figma 20" | Measure the RENDERED divider→next-row gap and assert it equals the Figma metric (`nextNode.y − divider.y`, ±2). In a flex column, `margin-top` ADDS to the container `gap`. When the user says a gap is off, extract the Figma number first — never guess the direction | §6.5.5.1, FM95 |
|
|
102
|
+
| 98 | A carousel was reduced to a static image | ≥2 small equal ELLIPSEs clustered on an image frame = slider pager. Reproduce the dots to spec and plan the slider (multiple assets + JS). The active indicator is often composite — an outer ring plus an inner dot: read both nodes | §6.5.4, §15 |
|
|
103
|
+
| 99 | Trusted the JSON node-dump as complete | Exported JSON can omit component internals, masks and vector strokes. Cross-check against a rendered image of the frame and reproduce render-visible details the JSON lacks — pixels are ground truth, JSON is a lossy index | §0.4, §6.5.4 |
|
|
104
|
+
| 100 | Fallback-era weight compensation survived the real font | After fixing a font substitution, re-derive every weight/size/ls that was fudged for the fallback from the Figma nodes, delete the "≈" comments, and `document.fonts.check` the exact weight. Verify each section's title weight individually — designs make exceptions | §0.4, FM85 |
|
|
105
|
+
| 101 | Trailing meta drifted across cards with variable body length | When trailing elements share a baseline in the design, give the variable-length text a `min-height` sized to the longest case; assert the trailing elements' tops are equal across the row | §6.5.5.1, FM95 |
|
|
106
|
+
| 102 | Audit checked heading fonts but never per-line alignment | For every multi-line heading also assert: computed `text-align`, `text-indent: 0`, and every line's start-x equal (`Range.selectNodeContents` → `getClientRects()`); inspect the HTML bytes around `<br />` for collapsed spaces | §6.5.4, FM95 |
|
|
107
|
+
| 103 | Declared "flush" from ONE viewport width | `text-align:left` does not stop a `max-width` block being centred by inherited `margin:auto` — reset `margin-inline: 0`. Verify alignment at the design width, a wider (≥1920) and a narrower viewport, AND against the siblings that share the edge | §6.5.5, §7, FM102 |
|
|
108
|
+
| 104 | Icon SVG and CSS both drew the ring — and the icon set was inconsistent | Open every icon's SVG source: a baked-in full-size `<rect rx>`/`<circle>` IS the container — the wrapper then adds none. Pick ONE owner of the shape, normalise the whole set, and verify every direction/state renders exactly one ring. `<img>` SVGs ignore CSS `color` | §6.5.4, §5 |
|
|
109
|
+
| 105 | Mirrored background photo; a nudge broke two panels' shared top edge | Check a directional photo's orientation against the design; flip via an absolutely-positioned `::before` with `transform: scaleX(-1)` (background-image itself can't transform). Panels that share a Figma y have NO vertical offset — delete nudges and assert equal tops | §6.5.4, FM87 |
|
|
110
|
+
| 106 | Logo strip passed on item sizes while the gaps were 3× too small | A logo row is a repeated-item control: measure every `item[i+1].left − item[i].right` against Figma; size the row container to the Figma row width and use `space-between` | §6.5.5.1, FM95 |
|
|
111
|
+
| 107 | Transparent PNG padding shrank the mark; opacity and colour ignored | Raster logos: crop to the content bbox (declared box ≠ visible ink — check `naturalWidth`/`getbbox`); apply the node's group `opacity`; match the RENDERED colour, not the stored brand fill (`filter: brightness(0) invert(1)` for a white-unified strip) | §5, FM106 |
|
|
112
|
+
| 108 | A generic container rule hijacked a nested button's padding AND display | Blanket rules (`.footer a { display:block; padding:5px 0 }`) silently steal a `.btn`'s box and centring. Give fixed-size buttons explicit w×h + their own later equal-specificity rule (`a.the-btn`); NEVER raise the broad selector's specificity (`:not(.btn)` out-ranked and broke sibling rows). Re-verify the whole container after any specificity change | §6.5.5.1, §13, FM91 |
|
|
113
|
+
| 109 | Sections were vertically CENTRED inside locked min-heights — every y floated with content height | A "lock each section to its Figma frame height" rule paired `min-height` with `display:flex; justify-content:center`, so each section's content sat at (min-height − content)/2. Every content-height change re-shifted every y in the section, offsets oscillated between audit runs, and a dozen nudges were tuned against the drift. Figma sections are TOP-anchored: content y = the section's own padding. Lock heights with `min-height` + `justify-content:flex-start` and set padding-top = the first node's Figma y; never centre a section whose design ys are absolute. If audit offsets CHANGE between runs without related edits, suspect a centring/auto-margin ancestor first | §6.5.5.1, FM103 |
|
|
114
|
+
| 110 | The measuring viewport's scrollbar shifted every x by a constant and failed the whole audit | The fidelity probe rendered the page at the design width, but the page scrolls, so the layout viewport was designW−15px: every centred/right-anchored element measured a constant ~7-15px left of Figma and the text audit failed wholesale (±4 tolerance < shift). Before ANY x-comparison, assert `document.documentElement.clientWidth === designWidth`; if short, widen the window/iframe by the scrollbar width. A CONSTANT x-offset across every node is a viewport artifact, not a build defect — fix the measurement, not the CSS. (figma_report.py now auto-widens its probe iframe.) | §6.5.5, §18.0 |
|
|
115
|
+
| 111 | The page had zero overflow at the design width and broke at EVERY narrower one | Below-design-width overflow came from five distinct classes, all invisible at 1600: (a) fixed component widths (`.card { width:440px }`, media `width:910px`) — size components with `width:100%` and let the grid track carry the design px; (b) `repeat(N, 1fr)` tracks — `1fr` = `minmax(auto,1fr)` and blows out on nowrap/min-content children; always write `minmax(0,1fr)`; (c) late-in-file wide-viewport overrides out-cascading earlier narrow breakpoints at equal specificity — scope them with range media (`min-width:X and max-width:Y`); (d) an override written for the wrong display type (`grid-template-columns` on a still-flex element does nothing); (e) removed `flex-wrap` on a pill row. Verify with an overflow sweep at ~8 widths (design, 1440, 1280, 1024, 900, 768, 640, 375): `scrollWidth − clientWidth === 0` per width, and after every fix RE-ASSERT the changed property in the probe before re-measuring | §8, FM92, FM103 |
|
|
116
|
+
| 112 | The verifier itself had five blind spots that read a correct build as broken (or hid real defects) | Fixed in figma_report.py; the classes recur in ANY audit tooling: (a) text inside a closed `<option>` has a 0×0 rect and, if collected, wins the leaf-most filter over the real `<select>` — skip option-descendant text nodes, match the control by value/aria-label; (b) a Figma box with an IMAGE fill exports its invisible SOLID underlay — never compare that colour to CSS, and treat a CSS background-image, a matched `<img>` itself, or a child img covering ≥80% as "image-covered"; (c) radii must be normalised before comparing: resolve `%` against the element box and clamp BOTH sides to min(w,h)/2 (Figma pill radii like 999/1353 = CSS 50%); (d) near-invisible strokes (opacity/alpha < .5) are not a border requirement, and a design stroke may render as any ONE border side — check all four; (e) the report's own cache-bust pass rewrote `src="x.svg"` to `x.svg?bust`, so its icon selector `src$=".svg"` matched nothing — after ANY self-modification of the page, selectors written against the original markup must be re-checked. Also: naming a new asset without checking for an existing file of that name overwrote an in-use icon (verify `ls` before `cp`) | §18.0, FM110 |
|
|
117
|
+
| 113 | Hover-timing audit read 0ms everywhere; the entrance reveal was also silently overriding every hover transition | Three coupled findings. (a) The report froze animations (`transition:none!important`) to measure geometry, then read `transitionDuration` — measuring what it had itself disabled (0/26). Duration reads are now QUEUED and flushed after the freeze style is removed, in steady-state (entrance markers stripped, since a hidden probe never fires IntersectionObserver). (b) Wrapper/element/icon often share one box: keep the whole tie-set of candidates and let the duration read take the max — a lone "nearest" pick lands on the wrapper (0s) or the icon `<img>`. (c) REAL defect class: a CSS-transition entrance (`[data-reveal] { transition: opacity .82s, transform .82s }`) out-cascades the element's own hover transition (same `transform` property, higher specificity) — every card hovered at the reveal's 820ms instead of the design's 1022ms. Run entrances via WAAPI (`el.animate(...)`, inline `opacity:0` until shown) so the `transition` property is never occupied and hover timing stays the element's own. Also: this Browser-pane environment freezes the animation clock (rAF dead, `playState:running` with `currentTime` stuck at 0) AND blocks all programmatic scroll — "stuck at opacity 0" and "reveal never fires" there are environment artifacts; verify wiring (attrs stripped, animations created, computed transition durations) instead of watching pixels move | §9, §18.0, FM110 |
|
|
118
|
+
| 114 | Assumed only the render endpoint was rate-limited; `/v1/files` (node JSON) also 429'd for hours | Figma REST quotas are plan-based and cover EVERY endpoint, not just renders — mid-project, `/v1/files` returned 429 Retry-After ~6h while the fill-URL endpoint still worked. The build survived only because all node JSON had been cached to disk on day one. Rule: the API is for ACQUISITION, the disk cache is the workspace — pull once into `figma/nodes/`, make every script read the cache, make the puller skip anything already on disk, and on any 429 report the Retry-After and continue offline from cache instead of blocking. Never design a step that needs the API on every run | §0.1, §6.5 |
|