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 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-pixel \
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-pixel \
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-pixel \
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
- Reproducing it is Mode A fidelity work: implement it, do not ask permission, and do not
971
- wait to be told. Failing to implement specified motion is a fidelity bug, exactly like a
972
- wrong colour.
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
- Only motion that is **absent** from the design is an enhancement (Mode C) and needs
975
- approval.
976
-
977
- | Situation | Mode | Ask first? |
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 (all observed in real builds)
1571
+ ## 22. Known Failure Modes references/failure-modes.md (MANDATORY reading points)
1731
1572
 
1732
- Every row below is a mistake that shipped. Re-read this before declaring a section done.
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
- **Standing rule when you find a new defect, close it in both stages.** Do not merely fix
1735
- the page. Ask: *what build rule would have prevented this*, and *what check would have
1736
- caught it*, then add both, add a row here, and add a line to the checklists. A defect that
1737
- is only fixed in the artefact will return in the next project. Everything you write must be
1738
- stated in terms of the design file, never in terms of one particular design.
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
- | # | Failure | Symptom | Rule |
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.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-pixel.git"
17
+ "url": "git+https://github.com/fieldqqq/figma-to-html-pixel-perfect.git"
18
18
  },
19
- "keywords": ["claude", "claude-code", "skill", "figma", "html", "css", "pixel-perfect", "design-to-code"],
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": { "node": ">=16" }
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 |