L'arbre de nœuds de Figma est riche mais bruité. Frame, Group, Component, Instance, Text, Rectangle, Ellipse, Vector — chacun a des dizaines de champs optionnels, certains en conflit les uns avec les autres. Un agent travaillant directement sur la réponse brute de l'API Figma dépense son budget cognitif à comprendre le schéma plutôt qu'à écrire du code.

figmascope normalise l'arbre en quatre types de nœuds : stack, overlay, absolute et leaf. Chaque nœud dans chaque fichier screens/*.json est l'un de ces quatre. Rien d'autre.

Les quatre types

stack

Un stack est un nœud avec Figma Auto Layout — layoutMode: "VERTICAL" ou layoutMode: "HORIZONTAL". Ses enfants sont positionnés en flux le long de l'axe. Les espaces et le padding sont explicites.

{
  "kind": "stack",
  "id": "123:456",
  "name": "ContentColumn",
  "axis": "vertical",
  "gap": 16,
  "paddingTop": 24,
  "paddingBottom": 24,
  "paddingLeft": 20,
  "paddingRight": 20,
  "primaryAxisAlignItems": "SPACE_BETWEEN",
  "counterAxisAlignItems": "CENTER",
  "width": 390,
  "height": 844,
  "fills": [{ "type": "SOLID", "color": "#1a1a2e" }],
  "children": [ ... ]
}

Se mappe sur Jetpack Compose comme une Column (axe vertical) ou une Row (axe horizontal). Les champs primaryAxisAlignItems et counterAxisAlignItems se mappent respectivement sur Arrangement et Alignment. gap devient Arrangement.spacedBy().

overlay

Un overlay est un nœud dont les enfants ont des positions absolues en son sein. Figma le représente comme un Frame avec layoutMode: "NONE" où les enfants ont des coordonnées absoluteBoundingBox. Le type overlay capture cela sans que l'agent ait à raisonner sur des coordonnées brutes pour comprendre la structure.

{
  "kind": "overlay",
  "id": "123:789",
  "name": "CardOverlay",
  "width": 358,
  "height": 200,
  "fills": [{ "type": "SOLID", "color": "#ffffff" }],
  "children": [
    {
      "kind": "absolute",
      "offset": { "x": 16, "y": 16 },
      "child": { ... }
    }
  ]
}

Se mappe sur Compose comme une Box. Les enfants sont positionnés avec Modifier.offset() ou Modifier.align() selon comment leurs positions se rapportent aux limites du parent.

absolute

Un nœud absolute est un wrapper qui porte un seul enfant à un décalage (x, y) spécifique dans son overlay parent. C'est un nœud structurel léger — il existe pour préserver la relation spatiale de Figma sans confondre position et contenu.

{
  "kind": "absolute",
  "offset": { "x": 24, "y": 140 },
  "child": {
    "kind": "leaf",
    "name": "BadgeLabel",
    "text": "NEW",
    "stringRef": "badge.new",
    ...
  }
}

Dans Compose, cela devient typiquement un enfant d'une Box avec Modifier.offset(x.dp, y.dp). Les valeurs de décalage sont candidates à la substitution de tokens si des tokens d'espacement existent dans cette plage.

leaf

Un leaf est un nœud terminal — pas d'enfants. Il a du style (fills, strokes, rayon de coin) et optionnellement du contenu textuel. Les leaves textuels ont un champ text (la chaîne littérale) et un champ stringRef (la clé de ressource i18n depuis strings.json).

{
  "kind": "leaf",
  "id": "123:101",
  "name": "SpeedLabel",
  "text": "Speed Test",
  "stringRef": "speed.test",
  "fontSize": 18,
  "fontWeight": 600,
  "fills": [{ "type": "SOLID", "color": "#ffffff" }],
  "width": 160,
  "height": 28
}

Se mappe sur Compose comme un composable Text() pour le contenu textuel, ou comme une Surface, Box ou Image pour les leaves non-textuels selon le type de fill.

Les quatre types se mappent un-à-un sur le modèle compositionnel de n'importe quel framework UI. Les stacks sont des mises en page flux. Les overlays sont des conteneurs positionnés en z. Les absolutes portent des métadonnées spatiales. Les leaves sont du contenu. C'est tout. La taxonomie des nœuds Figma a plus de 12 types — l'IR la réduit à quatre.

Comment le type est déterminé

La logique de classification :

  1. Si layoutMode est "VERTICAL" ou "HORIZONTAL"stack
  2. Si layoutMode est "NONE" et le nœud a des enfants → overlay (enfants encapsulés comme absolute)
  3. Si un nœud est l'enfant direct d'un overlay et porte une position → absolute
  4. Si le nœud n'a pas d'enfants et n'est pas un wrapper absolute → leaf

L'avertissement layout-mode-none-inferred dans _meta.json se déclenche quand un Frame avec layoutMode: "NONE" a des enfants avec des boîtes englobantes qui se chevauchent de manière non triviale. figmascope le traite comme un overlay, mais note l'inférence parce que certains frames layoutMode: "NONE" en pratique sont juste des conteneurs pour un seul enfant (zéro chevauchement) et pourraient être simplifiés. L'avertissement laisse l'agent décider comment le gérer.

absoluteBoundingBox — pourquoi il est préservé

Chaque nœud dans l'IR retient son absoluteBoundingBox depuis Figma, même quand le parent est un stack (où les positions absolues sont théoriquement non pertinentes pour la mise en page) :

{
  "kind": "stack",
  "absoluteBoundingBox": { "x": 0, "y": 88, "width": 390, "height": 756 },
  ...
}

C'est là pour les agents qui ont besoin de raisonner sur les relations spatiales entre des nœuds qui ne partagent pas de relation parent-enfant directe. Superposer deux éléments de différentes branches de l'arbre nécessite de connaître leurs positions absolues, pas seulement leurs décalages relatifs. Le système de coordonnées est le canvas de Figma — origine en haut à gauche, y croissant vers le bas.

Cela aide aussi avec la référence croisée aux PNG. Le fichier _meta.json inclut un champ pngCount et les PNG exportés sont nommés par slug d'écran. Si vous comparez l'IR à la capture d'écran, absoluteBoundingBox vous permet de localiser des nœuds spécifiques dans l'image. Pour voir cela sur votre propre design, exportez un bundle depuis figmascope.dev.

Fills et strokes sur les types conteneurs

Un point de confusion fréquent : les nœuds stack et overlay peuvent avoir des fills, pas seulement les leaves. Une colonne avec une couleur d'arrière-plan est toujours un stack — elle a juste aussi un tableau fills. figmascope le préserve :

{
  "kind": "stack",
  "axis": "vertical",
  "fills": [{ "type": "SOLID", "color": "#f6f2ea" }],
  "cornerRadius": 12,
  ...
}

Dans Compose, cela devient typiquement une Column dans une Surface ou Card, avec la couleur de fill et le rayon de coin appliqués au conteneur. L'IR ne consolide pas le fill dans les enfants — il le garde sur le nœud où Figma l'a défini.

Les fills dégradés sur les types conteneurs émettent un avertissement background-gradient-not-supported:<name>. Le nœud est toujours présent dans l'IR avec ses autres champs intacts. L'agent devrait traiter le fill comme un TODO et soit approximer avec une couleur solide, soit générer un appel de dessin personnalisé, selon les notes de portée dans CONTEXT.md.

La relation stringRef + text

Les nœuds leaf textuels portent les deux :

Si le contenu d'un nœud texte a été supprimé de strings.json en raison d'une collision ou d'un filtre (numérique uniquement, vide, trop court), le leaf a toujours text mais stringRef sera absent. L'agent devrait se rabattre sur le littéral dans ce cas. Voir strings.json pour la logique complète de gestion des collisions.

Quand les deux sont présents, la contrainte CONTEXT.md dit d'utiliser stringRef. Le champ text est là pour le raisonnement de l'agent (pour qu'il sache ce que dit la chaîne sans ouvrir strings.json) et comme fallback.

Les instances de composants dans l'IR

Quand un nœud Figma est une INSTANCE d'un composant, le nœud IR porte deux champs supplémentaires :

{
  "kind": "stack",
  "componentId": "789:012",
  "componentName": "PrimaryButton",
  ...
}

Cela fait le lien avec components/inventory.json. L'agent sait que ce nœud est une instance de PrimaryButton plutôt qu'une mise en page sur mesure, et devrait référencer le composant existant plutôt que de générer du code dupliqué. La couverture complète de ceci est dans l'inventaire des composants.

Exemple d'écran réel

Un exemple simplifié mais structurellement exact d'un écran avec un en-tête, une colonne de contenu et un bouton flottant :

{
  "screen": "home",
  "root": {
    "kind": "overlay",
    "name": "HomeScreen",
    "width": 390,
    "height": 844,
    "fills": [{ "type": "SOLID", "color": "#0d0d1a" }],
    "children": [
      {
        "kind": "absolute",
        "offset": { "x": 0, "y": 0 },
        "child": {
          "kind": "stack",
          "name": "ContentArea",
          "axis": "vertical",
          "gap": 24,
          "paddingTop": 56,
          "paddingLeft": 20,
          "paddingRight": 20,
          "children": [
            {
              "kind": "leaf",
              "name": "Title",
              "text": "Speed Test",
              "stringRef": "speed.test",
              "fontSize": 28,
              "fontWeight": 700
            }
          ]
        }
      },
      {
        "kind": "absolute",
        "offset": { "x": 111, "y": 752 },
        "child": {
          "kind": "stack",
          "name": "StartButton",
          "componentId": "321:654",
          "componentName": "PrimaryButton",
          "axis": "horizontal",
          "gap": 8,
          "paddingTop": 16,
          "paddingBottom": 16,
          "paddingLeft": 32,
          "paddingRight": 32,
          "cornerRadius": 24,
          "fills": [{ "type": "SOLID", "color": "#7f5cfe" }]
        }
      }
    ]
  }
}

De cela, l'agent peut correctement générer un écran Compose Box avec une Column positionnée en absolu pour le contenu et un composant PrimaryButton positionné en absolu en bas. Chaque décision de mise en page est dérivable de l'IR sans deviner.

Pour comment les tokens s'appliquent aux valeurs d'espacement et de couleur dans cette structure, voir tokens.json expliqué. Pour le workflow d'agent complet utilisant cet IR avec Cursor ou Claude Code, voir Jetpack Compose depuis Figma. Essayez-le vous-même en collant votre URL Figma dans figmascope.