figma-diff
by atinseau
README.md
# figma-diff
Compare une page web a une frame Figma et renvoie un verdict machine. Concu pour etre appele
par un agent en boucle : il lit Figma et le DOM lui-meme, et ne renvoie qu un resume borne.
Ni maquette ni capture d ecran ne transitent par le contexte du modele, donc le cout d un appel
ne depend pas de la taille de la page.
Bun + TypeScript 7. Pas de Node.
## Comment il lit la maquette
Pas d API REST Figma (6 requetes par mois en gratuit), pas de protocole MCP dans le chemin des
donnees. L outil interroge directement l endpoint HTTP `/rpc` que le leader de
[figma-mcp-bridge](https://github.com/gethopp/figma-mcp-bridge) expose sur `localhost:1994`,
celui qu utilisent ses propres instances MCP suiveuses. Les donnees viennent du plugin ouvert
dans Figma desktop : pas de jeton, pas de quota, et la maquette lue est celle que tu as sous les
yeux, modifications non enregistrees comprises.
```
Figma desktop ──plugin──▶ bridge :1994 ──/rpc──▶ figma-diff ──▶ report.json
▲ │
page live ──────Chromium headless────────────────────┘ ▼
l agent
```
Il faut donc que le serveur MCP `figma-bridge` tourne et que le plugin soit ouvert dans le
fichier. Sinon l outil echoue avec un message qui le dit.
## Installation
```bash
bun install
bun run browser # telecharge Chromium pour Playwright
```
## Serveur MCP
Declare-le aupres de ton agent, a cote de `figma-bridge` :
```json
{
"figma-diff": {
"command": "bun",
"args": ["run", "/chemin/absolu/vers/figma-diff/src/server.ts"]
}
}
```
Un seul tool, `compare_page_to_frame`. Entrees : `url`, `node`, `map`, et en option `fileKey`,
`width`, `height`, `pixel`, `port`, `top`, `out`.
```json
{
"conforme": false,
"ecarts": 12,
"calques": 34,
"pixelRatio": null,
"issues": [{ "layer": "Hero/CTA", "selector": "button.cta", "prop": "padLeft",
"design": 24, "web": 16, "delta": -8 }],
"unmappedLayers": ["Hero/Badge"],
"rapport": "/tmp/figma-diff-4029-12345.json"
}
```
Trois regles tiennent ce contrat. `conforme: false` n est pas une erreur : `isError` est reserve
aux pannes reelles, bridge eteint, node introuvable, page morte — un agent qui recoit une erreur
relance au lieu de corriger. `delta` vaut toujours web moins design, donc negatif signifie trop
petit cote web. `issues` est plafonne a `top`, mais `rapport` pointe toujours vers le fichier
complet, ce qui garde le cout d un appel constant quelle que soit la page.
L orchestration n appartient pas a cet outil. Quand rappeler, quand abandonner, c est le harness
ou l agent qui decide.
### Ce que l agent lit
Le README ne lui est pas adresse : sa seule documentation est le schema du tool. Tout y est donc
porte, la marche a suivre pour obtenir un id de frame, la maniere de construire la carte, et la
signification de chaque champ de sortie. Deux points comptent plus que les autres, parce qu ils
sont silencieusement mal interpretables : `delta` vaut toujours web moins design, et un
`frameWidth` different de `viewport` rend toute position incomparable. Des tests verifient que
ces explications restent en place.
L ensemble pese environ 8,5 ko, soit a peu pres 2000 tokens, charges une fois par session.
## Ligne de commande
```bash
bun run src/cli.ts --url http://localhost:3000 --node "4029:12345" --map map.json
```
| Entree | Role | Defaut |
| --- | --- | --- |
| `--url` | Page a mesurer : URL servie ou chemin de fichier HTML | requis |
| `--node` | Id de frame Figma, deux-points | requis |
| `--fileKey` | Fichier cible si plusieurs sont connectes | fichier unique |
| `--map` | Carte calque vers selecteur CSS | `map.json` |
| `--out` | Chemin du rapport | `report.json` |
| `--port` | Port du bridge | `1994` ou `FIGMA_BRIDGE_PORT` |
| `--width` | Largeur du viewport | largeur de la frame |
| `--height` | Hauteur du viewport | `900` |
| `--pixel` | Ajoute la diff pixel, export PNG automatique | desactive |
| `--top` | Ecarts detailles sur stdout | `25` |
| `--maxPixelRatio` | Seuil d echec de la diff pixel | `0.002` |
Code de sortie 0 si conforme, 1 s il reste des ecarts, 2 si l appel est mal forme.
L id de frame se lit dans l URL Figma (`?node-id=4029-12345`), en remplacant le tiret par
deux-points, ou via le tool `get_selection` du bridge.
## Quelle page est comparee
Trois facons de la designer, selon ce que tu as sous la main.
### Une page deja mise en etat par ton agent (SPA)
Si ton agent pilote un Chrome headless via `chrome-devtools-mcp`, il a souvent deja fait le
travail : connexion, navigation, ouverture d un panneau, remplissage d un formulaire. Mesurer
dans un navigateur neuf perdrait tout cela. On rejoint donc le sien par CDP :
```bash
bun run src/cli.ts --cdp http://localhost:9222 --target /tarifs --node "4029:12345"
```
Sans `--url`, rien n est navigue : la page est mesuree exactement dans l etat ou elle se trouve.
`--target` choisit l onglet par fragment d URL quand plusieurs sont ouverts, sinon le premier
onglet non vide est pris. La deconnexion ne ferme pas le navigateur de l agent.
Deux effets de bord assumes, tous deux sur demande explicite seulement : passer `--url` navigue
et efface l etat, passer `--width` redimensionne. Par defaut, le viewport de la page est adopte
tel quel et reporte dans `viewport` ; s il ne correspond pas a la largeur de la frame, la sortie
le signale, parce que comparer des positions a des largeurs differentes ne veut rien dire.
### Une URL servie, ou un fichier local
Celle que tu designes, et rien d autre : l outil ne lance aucun serveur. Deux formes sont
acceptees.
```bash
bun run src/cli.ts --url http://localhost:3000/tarifs --node "4029:12345"
bun run src/cli.ts --url ./dist/tarifs.html --node "4029:12345"
```
Un chemin de fichier est converti en `file://`, donc une page generee mais pas encore servie
reste mesurable sans monter un serveur pour rien. Un projet avec dev server se mesure sur son
URL, route par route : une frame Figma correspond a une page, donc a un appel.
La page doit etre dans l etat a mesurer au moment de l appel. Pour une SPA, passer l URL
profonde de la route, ou preferer le mode attache ci-dessus.
## Methode recommandee
**Une frame, une route, un appel.** Une frame Figma correspond a une page ; ne cherche pas a tout
mesurer d un coup. Pour du responsive, un appel par breakpoint avec `--width` et la frame
correspondante.
**Construis la carte par iterations.** Commence par les blocs structurants, sections et composants
principaux, une dizaine d entrees. Lance, lis `unmappedLayers`, ajoute ce qui compte. Un calque
purement decoratif ou un groupe technique Figma n a pas besoin d entree. La carte n a pas vocation
a etre exhaustive, seulement a couvrir ce qui doit etre juste.
**Corrige dans l ordre du rapport.** `presence`, `visibilite` et `selecteur` viennent en tete parce
qu ils invalident les mesures en dessous : tant qu un selecteur est mort ou ambigu, les ecarts de ce
calque ne veulent rien dire. Ensuite, si `viewport` differe de `frameWidth`, regle la largeur avant
de lire la moindre position. Le reste seulement apres.
**Cherche la cause commune, pas les symptomes.** Une meme propriete fausse sur plusieurs calques
est presque toujours une seule regle CSS, une variable ou un token. Un `gap` faux sort accompagne
des elements qu il a decales : corriger le `gap` fait disparaitre les trois lignes.
**Garde `--pixel` hors de la boucle.** Il est plus lent, plus bruyant, et ses zones servent a
trouver ce que la carte ne couvre pas. Un passage en fin de parcours, pour completer la carte, puis
on repasse en comparaison de valeurs.
**Versionne `map.json` avec le code.** C est l artefact durable : il survit aux refontes de
structure et documente quel element implemente quel calque. Le rapport, lui, est jetable.
**Ne vise pas zero a tout prix.** Si un ecart est un choix assume, retire le calque de la carte ou
corrige la maquette. Un seuil qu on relache pour faire passer la mesure ne mesure plus rien.
## La carte des calques
`map.json` associe un nom de calque Figma a un selecteur CSS. Voir `map.example.json`.
Seuls les calques listes sont compares ; les autres remontent dans `unmappedLayers` a chaque
appel, de quoi completer la carte sans la deviner.
## Comment la comparaison fonctionne
Ce n est pas une comparaison d images, et pas non plus un parcours d arbre. Pour chaque entree de
la carte, l outil lit les valeurs du calque cote Figma et celles de l element cote DOM, puis
compare des nombres et des couleurs. Les positions sont ramenees dans le repere de la frame de
chaque cote, jamais comparees de proche en proche.
Consequence importante : la structure de la page n a pas a ressembler a la hierarchie Figma.
Balises differentes, enveloppes supplementaires, grid la ou la maquette a un auto-layout, tout
cela est sans effet tant que le rendu correspond. La carte est le seul point de contact entre les
deux mondes, et c est ce qui rend l outil utilisable sur du code reel plutot que sur du code
genere pour ressembler a la maquette. `test/site/refactor.html` le demontre : meme rendu, DOM sans
rapport, zero ecart.
La limite de ce choix est symetrique : un calque que la carte ne nomme pas n est pas mesure, et un
calque Figma qui correspond a plusieurs elements de la page ne peut pas etre exprime. C est a cela
que sert `--pixel`, qui compare en plus un export de la frame au screenshot de la page et signale
les zones divergentes avec l element DOM qui s y trouve.
### Pourquoi les deux, et pas seulement la diff pixel
Sur la page de test, la comparaison de valeurs nomme 14 ecarts avec leur valeur cible ; la diff
pixel rend un ratio de 0,00236 et six zones. Les deux voient des choses differentes.
La diff pixel attrape ce que la carte ignore : les titres des cartes 2 et 3, absents de la carte,
ressortent comme zones deplacees. Mais elle rate le fond de la carte 2, passe de `#ffffff` a
`#f9fafb`, parce que l ecart est sous son seuil ; et elle rate le rayon des bordures pour la meme
raison. Baisser le seuil les ferait apparaitre, au prix du bruit de rendu.
Car c est la le point : entre un export Figma et un rendu navigateur, l iso pixel parfait n existe
pas. Le rasterisation des textes, l antialiasing et le placement subpixel different par
construction. Il y a donc toujours un seuil, donc toujours une zone d aveuglement en dessous et du
bruit au-dessus. Et une zone magenta ne dit ni quelle propriete est fausse, ni quelle valeur
atteindre : elle localise, elle n explique pas.
La comparaison de valeurs, elle, atteint le zero exact quand la page est conforme, et sort
`padLeft: figma=24 web=16`, directement actionnable. Elle ne voit en revanche que ce que la carte
nomme et que la liste de proprietes couvre : ni ombres, ni degrades, ni icones, ni images.
Bref : la diff pixel pour ne rien manquer, la comparaison de valeurs pour savoir quoi corriger.
## Ce que la comparaison ne voit pas
Elle lit les valeurs calculees par le navigateur, donc `bold`, `1.5rem`, `rgb(37, 99, 235)` et un
interligne sans unite sont deja resolus en `700`, `24px` et `#2563eb` : aucune de ces ecritures ne
produit de faux ecart. Trois situations la mettraient en defaut si elles n etaient pas traitees, et
elles passent en tete du rapport parce qu elles rendent le reste des mesures douteux : un selecteur
qui ne matche rien (`presence`), un element present mais masque dont la boite vaut zero
(`visibilite`), et un selecteur qui matche plusieurs elements dont un seul est mesure (`selecteur`).
Un angle mort connu reste : `getComputedStyle` renvoie la police declaree, pas la police
reellement utilisee pour le rendu. Si `Inter` est declaree mais absente, la comparaison passe
alors que la page rend en fallback. `document.fonts.check` ne permet pas de le detecter, il
repond vrai pour une famille inexistante. Seule la diff pixel attrape ce cas.
## Ce qui est compare, et ce qui ne l est pas
Position et taille relatives a la frame, typographie (taille, graisse, famille, interligne,
interlettrage), couleurs de texte et de fond, rayon, paddings, gap, contenu textuel.
Tolerances : 2px en position et taille, 1px en espacement, 0.5px en typographie, zero sur la
graisse. Une propriete qu une seule des deux sources sait exprimer est ignoree plutot que
comptee comme un ecart.
Hors perimetre : les etats (hover, focus, disabled), les pseudo-elements, les ombres et
degrades, les breakpoints autres que celui mesure, et tout calque absent de la carte. Pour du
responsive, lancer un appel par frame et par largeur.
`--pixel` recupere en plus un export PNG de la frame via le bridge, le compare au screenshot de
la page, regroupe les pixels divergents en zones et nomme l element DOM sous chacune. Cela
rattrape ce que la carte ne couvre pas, au prix du bruit habituel des diffs pixel.
Une page derriere authentification, ou un etat construit par interaction, se mesure via `--cdp`
en rejoignant le navigateur de l agent. Le mode autonome, lui, part toujours d une session vierge.
## Tests
```bash
bun test # 70 tests
bun run typecheck
```
Six niveaux : la logique de comparaison et la lecture de l arbre Figma sans I/O, l extraction
DOM dans un vrai Chromium, le mode attache contre un navigateur lance avec un port de debug, le
CLI complet contre un faux bridge, le contrat MCP via un vrai client stdio, et un e2e sur un
site complet. Le mode attache verifie explicitement que la mesure ne detruit ni l etat construit
dans la page ni le navigateur de l agent.
`test/site/` contient une landing page, sa maquette de reference et quatre variantes : les ecarts
injectes par `defects.css`, les memes valeurs ecrites autrement (`equivalents.html`), une
structure DOM sans rapport avec Figma (`refactor.html`) et les pieges de mesure
(`pieges.html`). Le fichier `defects.css` injecte
huit ecarts de nature differente : element absent, padding, taille de police, graisse, couleur de
texte, couleur de fond, gap, rayon, interlettrage et contenu textuel. Le test verifie que chacun
ressort avec la bonne valeur cible et le bon sens de correction, et surtout que la version
conforme de la meme page ne produit aucun ecart. Un outil qui signale tout ne sert a rien : cette
seconde moitie du test compte autant que la premiere.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues