{
  "generated_by": "node bin/index.mjs",
  "read_first": "INDEX.md — le catalogue lisible. Ici : les ratios, les benchmarks et les tokens.",
  "naming": "<famille>[-NN]-<mots> pour un pattern, ref-NN-<slug> pour une référence.",
  "families": [
    "card",
    "chart",
    "diagram",
    "layout",
    "list",
    "shape",
    "tag",
    "title"
  ],
  "media": [
    "slide",
    "web",
    "email",
    "print",
    "social"
  ],
  "counts": {
    "patterns": 57,
    "refs": 19,
    "checkable": 50
  },
  "patterns": [
    {
      "id": "card-01-ghost-icon",
      "name": "Carte-argument à icône fantôme",
      "family": "card",
      "ref": "ref-02-ghost-icon-claim",
      "media": [
        "slide",
        "social",
        "web"
      ],
      "intent": "Asséner UN seul argument sur toute une largeur, avec une icône énorme mais presque invisible qui donne du volume sans voler l'attention au titre.",
      "when_to_use": "Bloc de réassurance ou de promesse produit (confidentialité, garantie, engagement) sur une landing ou une slide. Un argument par carte, jamais deux.",
      "avoid_when": "Il y a plus d'une idée à faire passer, ou l'icône doit être comprise (ici elle est décorative). Pour une rangée de 3 bénéfices, prendre un pattern de grille, pas celui-ci.",
      "tags": [
        "carte",
        "argument",
        "reassurance",
        "icone",
        "confidentialite",
        "landing",
        "hero",
        "monochrome",
        "claim"
      ],
      "vars": [
        {
          "name": "--vl-surface",
          "role": "fond de la carte",
          "default": "#EDEDED"
        },
        {
          "name": "--vl-ghost",
          "role": "couleur de l'icône, à ~4 % de luminosité du fond de carte",
          "default": "#E2E2E2"
        },
        {
          "name": "--vl-ink",
          "role": "couleur du titre",
          "default": "#444444"
        },
        {
          "name": "--vl-muted",
          "role": "couleur du sous-titre",
          "default": "#A9A9A9"
        },
        {
          "name": "--vl-ghost-size",
          "role": "largeur de la colonne icône",
          "default": "250px"
        },
        {
          "name": "--vl-gutter",
          "role": "gouttière icône / texte",
          "default": "90px"
        }
      ],
      "slots": [
        "icon",
        "title",
        "subtitle"
      ],
      "geometry": null,
      "benchmarks": [],
      "pptx": null,
      "html": "patterns/card-01-ghost-icon.html"
    },
    {
      "id": "card-02-notched-brief",
      "name": "Carte chanfreinée de second rang",
      "family": "card",
      "ref": "ref-06-orange-notched",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Porter une catégorie nommée et son explication sur un aplat neutre, en rangée, sans jamais rivaliser avec la vignette d'accent posée à côté.",
      "when_to_use": "Deux à trois segments, audiences, motions commerciales ou options présentés côte à côte — typiquement à droite d'une vignette statistique qui, elle, porte le chiffre.",
      "avoid_when": "Il n'y a qu'une seule carte (le second rang n'a plus de premier rang à côté, l'aplat neutre n'y sert à rien), ou l'explication dépasse quatre lignes — ce n'est plus un libellé commenté, c'est un paragraphe qui mérite sa colonne.",
      "tags": [
        "carte",
        "segment",
        "audience",
        "categorie",
        "chanfrein",
        "notch",
        "second-rang",
        "rangee",
        "deck",
        "libelle"
      ],
      "vars": [
        {
          "name": "--vl-steel",
          "role": "aplat neutre de second rang",
          "default": "#C6D0CF"
        },
        {
          "name": "--vl-black",
          "role": "libellé",
          "default": "#141414"
        },
        {
          "name": "--vl-ink-on-steel",
          "role": "corps sur l'aplat neutre",
          "default": "#3E4746"
        },
        {
          "name": "--vl-notch",
          "role": "longueur du chanfrein",
          "default": "32px"
        }
      ],
      "slots": [
        "label",
        "body"
      ],
      "geometry": {
        "root": ".vl-brief",
        "frame": [
          430,
          340
        ],
        "ratios": {
          "pad_x_over_width": 0.079,
          "notch_over_width": 0.074,
          "body_top_over_height": 0.63
        },
        "type_px": {
          "label": 12,
          "body": 17
        },
        "pad_ratio": {
          "top": 0.074,
          "x": 0.079,
          "bottom": 0.084
        }
      },
      "benchmarks": [
        {
          "name": "chanfrein ≈ 7,5 % de la largeur",
          "measure": "notch('.vl-brief') / W",
          "expect": 0.075,
          "tol": 0.015
        },
        {
          "name": "chanfrein sur le coin HAUT-droit (l'orientation distingue le second rang)",
          "measure": "notchCorner('.vl-brief') === 'tr' ? 1 : 0",
          "expect": 1
        },
        {
          "name": "marges intérieures identiques à la vignette d'accent (≈ 8 %)",
          "measure": "num('.vl-brief','paddingLeft') / W",
          "expect": 0.08,
          "tol": 0.02
        },
        {
          "name": "libellé en micro-capitales : ≤ 14 px",
          "measure": "num('.vl-brief__label','fontSize')",
          "max": 14
        },
        {
          "name": "le corps ne rivalise pas avec la carte d'accent : ≤ 20 px",
          "measure": "num('.vl-brief__body','fontSize')",
          "max": 20
        },
        {
          "name": "corps collé en bas (haut du bloc entre 45 % et 85 % de la hauteur)",
          "measure": "box('.vl-brief__body').top / H",
          "min": 0.45,
          "max": 0.85
        },
        {
          "name": "aucun débordement sous la marge basse",
          "measure": "H - num('.vl-brief','paddingBottom') - box('.vl-brief__body').bottom",
          "min": -1
        },
        {
          "name": "les deux cartes de la rangée alignent leur corps sur la MÊME ligne",
          "measure": "Math.abs(box('.vl-brief-row > :first-child .vl-brief__body').top - box('.vl-brief-row > :last-child .vl-brief__body').top)",
          "expect": 0,
          "tol": 1
        },
        {
          "name": "contraste du corps ≥ 4,5 (petit corps sur aplat clair : seuil plein)",
          "measure": "contrast('.vl-brief__body')",
          "min": 4.5
        },
        {
          "name": "contraste du libellé ≥ 7 (il porte la catégorie, il doit claquer)",
          "measure": "contrast('.vl-brief__label')",
          "min": 7
        }
      ],
      "pptx": {
        "primitive": "notched_rect",
        "emitter": "kit/vl_pptx.py:brief_card",
        "pattern": "card-02-notched-brief"
      },
      "html": "patterns/card-02-notched-brief.html"
    },
    {
      "id": "card-03-stat-accent",
      "name": "Vignette statistique sur accent",
      "family": "card",
      "ref": "ref-06-orange-notched",
      "media": [
        "slide",
        "social",
        "web"
      ],
      "intent": "Faire porter UN chiffre par un aplat d'accent chanfreiné, avec son cadrage (micro-libellé au-dessus) et son commentaire (corps collé en bas), de façon à ce que le chiffre se lise avant le texte.",
      "when_to_use": "Preuve chiffrée d'une slide : croissance, volume livré, part de marché. Une seule par slide, posée en tête de rangée ou en recouvrement du bas d'une photo pour ancrer l'accent.",
      "avoid_when": "Le chiffre a besoin d'être comparé à d'autres (il faut un graphique), il n'y a pas de commentaire à mettre (la vignette se vide et le chanfrein devient un ornement seul), ou le texte descend sous 16 px — sur cet orange le contraste ne tient plus (3,4:1, valable seulement en grand corps).",
      "tags": [
        "stat",
        "chiffre",
        "kpi",
        "vignette",
        "carte",
        "accent",
        "chanfrein",
        "notch",
        "flèche",
        "deck",
        "preuve"
      ],
      "vars": [
        {
          "name": "--vl-orange",
          "role": "aplat d'accent porteur",
          "default": "#F2551E"
        },
        {
          "name": "--vl-white",
          "role": "chiffre et glyphe",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-ink-muted-invert",
          "role": "corps sur l'aplat (jamais du blanc pur, mais pas plus sombre que 3:1 non plus)",
          "default": "#FFF1E9"
        },
        {
          "name": "--vl-notch",
          "role": "longueur du chanfrein",
          "default": "32px"
        }
      ],
      "slots": [
        "kicker",
        "figure",
        "arrow",
        "body"
      ],
      "geometry": {
        "root": ".vl-stat",
        "frame": [
          430,
          340
        ],
        "ratios": {
          "pad_x_over_width": 0.079,
          "notch_over_width": 0.074,
          "figure_over_kicker": 5.33,
          "body_top_over_height": 0.66
        },
        "type_px": {
          "kicker": 12,
          "figure": 64,
          "body": 17,
          "arrow": 34
        },
        "pad_ratio": {
          "top": 0.074,
          "x": 0.079,
          "bottom": 0.084
        }
      },
      "benchmarks": [
        {
          "name": "chanfrein ≈ 7,5 % de la largeur",
          "measure": "notch('.vl-stat') / W",
          "expect": 0.075,
          "tol": 0.015
        },
        {
          "name": "chanfrein sur le coin BAS-droit (l'orientation marque le premier rang)",
          "measure": "notchCorner('.vl-stat') === 'br' ? 1 : 0",
          "expect": 1
        },
        {
          "name": "marge intérieure gauche ≈ 8 % de la largeur",
          "measure": "num('.vl-stat','paddingLeft') / W",
          "expect": 0.08,
          "tol": 0.02
        },
        {
          "name": "saut typographique chiffre / micro-libellé ≥ 4,5",
          "measure": "num('.vl-stat__figure','fontSize') / num('.vl-stat__kicker','fontSize')",
          "min": 4.5
        },
        {
          "name": "le corps est bien en bas (haut du bloc entre 50 % et 85 % de la hauteur)",
          "measure": "box('.vl-stat__body').top / H",
          "min": 0.5,
          "max": 0.85
        },
        {
          "name": "aucun débordement du corps sous la marge basse",
          "measure": "H - num('.vl-stat','paddingBottom') - box('.vl-stat__body').bottom",
          "min": -1
        },
        {
          "name": "le corps s'arrête AVANT le chanfrein (il ne rentre pas dans la coupe)",
          "measure": "W - num('.vl-stat','paddingRight') - box('.vl-stat__body').right",
          "min": -1
        },
        {
          "name": "glyphe calé dans le coin haut-droit (≤ 12 % du bord)",
          "measure": "(W - box('.vl-stat__arrow').right) / W",
          "max": 0.12
        },
        {
          "name": "contraste du chiffre ≥ 3 (grand corps, seuil WCAG large text)",
          "measure": "contrast('.vl-stat__figure')",
          "min": 3
        },
        {
          "name": "corps ≥ 16 px — en dessous, 3,4:1 sur cet orange n'est plus lisible",
          "measure": "num('.vl-stat__body','fontSize')",
          "min": 16
        },
        {
          "name": "mesure de garde : le corps ne dépasse pas 34 caractères de large",
          "measure": "box('.vl-stat__body').w / num('.vl-stat__body','fontSize')",
          "max": 19
        },
        {
          "name": "contraste du corps ≥ 3 (l'aplat d'accent ne pardonne rien en dessous)",
          "measure": "contrast('.vl-stat__body')",
          "min": 3
        }
      ],
      "pptx": {
        "primitive": "notched_rect",
        "emitter": "kit/vl_pptx.py:stat_block",
        "pattern": "card-03-stat-accent"
      },
      "html": "patterns/card-03-stat-accent.html"
    },
    {
      "id": "card-04-stat-barchart",
      "name": "Carte statistique à barres empilées",
      "family": "card",
      "ref": "ref-11-finance-dashboard-mint",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Porter UN KPI de premier rang (un total et sa tendance) sur un aplat noir, en-tête calé en haut et chiffre + mini-histogramme collés en bas, de façon à ce que le nombre se lise avant tout.",
      "when_to_use": "Carte d'accroche d'un dashboard ou d'une slide produit : le chiffre du mois et sa décomposition sur deux à quatre périodes. L'aplat noir la pose en premier rang face aux cartes blanches autour.",
      "avoid_when": "Il faut comparer des valeurs précises entre séries (le sommet arrondi et l'absence d'axe faussent la lecture fine — passer à un vrai graphique gradué), ou il n'y a pas de tendance à montrer : sans les barres, la carte n'est plus qu'un chiffre isolé.",
      "tags": [
        "carte",
        "stat",
        "kpi",
        "histogramme",
        "barres",
        "empile",
        "dashboard",
        "noir",
        "premier-rang",
        "menthe",
        "lilas"
      ],
      "vars": [
        {
          "name": "--vl-surface-dark",
          "role": "aplat de la carte de premier rang",
          "default": "#141414"
        },
        {
          "name": "--vl-mint",
          "role": "part principale de la barre (rang 1)",
          "default": "#B4E3A0"
        },
        {
          "name": "--vl-lilac",
          "role": "part de second temps de la barre (rang 2)",
          "default": "#B6ABE6"
        },
        {
          "name": "--vl-radius-card",
          "role": "rayon de la carte",
          "default": "26px"
        }
      ],
      "slots": [
        "title",
        "kicker",
        "figure",
        "bars"
      ],
      "geometry": {
        "root": ".vl-sales",
        "frame": [
          776,
          330
        ],
        "ratios": {
          "pad_over_width": 0.054,
          "figure_top_over_height": 0.72
        },
        "type_px": {
          "title": 18,
          "figure": 46,
          "label": 11
        },
        "pad_ratio": {
          "top": 0.091,
          "x": 0.054,
          "bottom": 0.091
        }
      },
      "benchmarks": [
        {
          "name": "marges intérieures ≈ 5,4 % de la largeur",
          "measure": "num('.vl-sales','paddingLeft') / W",
          "expect": 0.054,
          "tol": 0.02
        },
        {
          "name": "en-tête calé en haut (< 12 % de la hauteur)",
          "measure": "box('.vl-sales__head').top / H",
          "max": 0.12
        },
        {
          "name": "chiffre héros collé en bas (haut du nombre au-delà de 60 % de la hauteur)",
          "measure": "box('.vl-sales__num').top / H",
          "min": 0.6
        },
        {
          "name": "le chiffre héros appartient à l'échelle display : ≥ 40 px",
          "measure": "num('.vl-sales__num','fontSize')",
          "min": 40
        },
        {
          "name": "hiérarchie : le chiffre est au moins 2× le titre",
          "measure": "num('.vl-sales__num','fontSize') / num('.vl-sales__title','fontSize')",
          "min": 2
        },
        {
          "name": "la barre Novembre domine Septembre (la tendance se voit sans lire)",
          "measure": "box('.vl-bars__grp:last-child .vl-bars__stack').h - box('.vl-bars__grp:first-child .vl-bars__stack').h",
          "min": 20
        },
        {
          "name": "dans chaque barre la MENTHE (rang 1) l'emporte sur le LILAS (rang 2)",
          "measure": "box('.vl-bars__grp:last-child .vl-bars__mint').h - box('.vl-bars__grp:last-child .vl-bars__lilac').h",
          "min": 1
        },
        {
          "name": "bas des barres alignés sur le bas du chiffre (± 4 px)",
          "measure": "Math.abs(box('.vl-bars').bottom - box('.vl-sales__num').bottom)",
          "max": 4
        },
        {
          "name": "contraste du titre blanc sur noir ≥ 7 (il porte le nom de la carte)",
          "measure": "contrast('.vl-sales__title')",
          "min": 7
        },
        {
          "name": "aucun débordement du contenu hors de la carte",
          "measure": "overflow('.vl-sales')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/card-04-stat-barchart.html"
    },
    {
      "id": "card-05-balance-gauge",
      "name": "Carte solde à jauge compteur",
      "family": "card",
      "ref": "ref-11-finance-dashboard-mint",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Montrer un solde et sa progression dans un grand panneau menthe : une jauge « compteur » (part acquise en plein, part restante en hachures) surmonte un chiffre héros posé en bas, la tendance en regard.",
      "when_to_use": "Carte centrale d'un dashboard où un montant unique doit dominer ET porter une notion d'avancement vers un objectif. Le panneau teinté plein la distingue des cartes de détail.",
      "avoid_when": "Il n'y a pas de progression à raconter (la jauge devient un ornement — un simple grand chiffre suffit), ou il faut plusieurs séries : une jauge ne lit qu'un ratio à la fois.",
      "tags": [
        "carte",
        "solde",
        "jauge",
        "compteur",
        "gauge",
        "progression",
        "dashboard",
        "menthe",
        "panneau",
        "chiffre-hero"
      ],
      "vars": [
        {
          "name": "--vl-mint",
          "role": "aplat du panneau + fond des hachures",
          "default": "#B4E3A0"
        },
        {
          "name": "--vl-mint-deep",
          "role": "trait des hachures (reste à courir)",
          "default": "#8FCE72"
        },
        {
          "name": "--vl-ink",
          "role": "arc acquis de la jauge + chiffre héros",
          "default": "#181A17"
        },
        {
          "name": "--vl-lilac-deep",
          "role": "curseur de la jauge",
          "default": "#9A8ADE"
        },
        {
          "name": "--vl-radius-sub",
          "role": "rayon du panneau interne",
          "default": "20px"
        }
      ],
      "slots": [
        "title",
        "nav",
        "gauge",
        "trend",
        "figure"
      ],
      "geometry": {
        "root": ".vl-balance",
        "frame": [
          776,
          494
        ],
        "ratios": {
          "pad_over_width": 0.054,
          "panel_over_height": 0.72
        },
        "type_px": {
          "title": 18,
          "figure": 46,
          "avg": 11
        },
        "pad_ratio": {
          "top": 0.064,
          "x": 0.054,
          "bottom": 0.064
        }
      },
      "benchmarks": [
        {
          "name": "marges intérieures ≈ 5,4 % de la largeur",
          "measure": "num('.vl-balance','paddingLeft') / W",
          "expect": 0.054,
          "tol": 0.02
        },
        {
          "name": "le panneau menthe occupe l'essentiel de la carte (> 65 % de la hauteur)",
          "measure": "box('.vl-panel').h / H",
          "min": 0.65
        },
        {
          "name": "jauge centrée horizontalement dans le panneau (± 4 px)",
          "measure": "Math.abs((box('.vl-gauge').left + box('.vl-gauge').w / 2) - (box('.vl-panel').left + box('.vl-panel').w / 2))",
          "max": 4
        },
        {
          "name": "chiffre héros ancré en bas-DROITE du panneau (au-delà du milieu)",
          "measure": "box('.vl-balance__big').left / W",
          "min": 0.5
        },
        {
          "name": "tendance ancrée en bas-GAUCHE, à gauche du chiffre héros (pas de recouvrement)",
          "measure": "box('.vl-balance__big').left - box('.vl-balance__stat').right",
          "min": 8
        },
        {
          "name": "chiffre et tendance sur la MÊME ligne de pied (± 12 px)",
          "measure": "Math.abs(box('.vl-balance__big').bottom - box('.vl-balance__stat').bottom)",
          "max": 12
        },
        {
          "name": "le chiffre héros appartient à l'échelle display : ≥ 40 px",
          "measure": "num('.vl-balance__big','fontSize')",
          "min": 40
        },
        {
          "name": "contraste du titre ≥ 7 (il nomme la carte)",
          "measure": "contrast('.vl-balance__title')",
          "min": 7
        },
        {
          "name": "contraste du chiffre héros sur la menthe ≥ 4,5",
          "measure": "contrast('.vl-balance__big')",
          "min": 4.5
        },
        {
          "name": "aucun débordement du contenu hors de la carte",
          "measure": "overflow('.vl-balance')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/card-05-balance-gauge.html"
    },
    {
      "id": "card-06-forecast-timeline",
      "name": "Carte prévision — timeline + sous-cartes",
      "family": "card",
      "ref": "ref-11-finance-dashboard-mint",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Raconter une trajectoire dans une carte : une timeline verticale de jalons datés à gauche, et à droite deux sous-cartes teintées qui opposent la valeur constatée (menthe, présent) à la projection (lilas, futur).",
      "when_to_use": "Carte « perspective » d'un dashboard : quelques jalons dans le temps plus deux chiffres clés, l'un acquis l'autre projeté. Le code couleur menthe/lilas fait lire présent vs futur sans légende.",
      "avoid_when": "Il y a plus de cinq ou six jalons (les points se resserrent et la colonne devient illisible — passer à une frise horizontale), ou un seul chiffre à montrer : les deux sous-cartes n'ont alors plus d'opposition à porter.",
      "tags": [
        "carte",
        "prevision",
        "forecast",
        "timeline",
        "frise",
        "jalons",
        "sous-cartes",
        "dashboard",
        "menthe",
        "lilas",
        "projection"
      ],
      "vars": [
        {
          "name": "--vl-mint",
          "role": "sous-carte du présent (valeur constatée)",
          "default": "#B4E3A0"
        },
        {
          "name": "--vl-lilac",
          "role": "sous-carte du futur (projection)",
          "default": "#B6ABE6"
        },
        {
          "name": "--vl-hairline",
          "role": "axe de la timeline",
          "default": "#E3E5E1"
        },
        {
          "name": "--vl-ink",
          "role": "point d'horizon (jalon plein) + encre",
          "default": "#181A17"
        },
        {
          "name": "--vl-radius-sub",
          "role": "rayon des sous-cartes",
          "default": "20px"
        }
      ],
      "slots": [
        "head",
        "timeline",
        "tile-present",
        "tile-forecast"
      ],
      "geometry": {
        "root": ".vl-forecast",
        "frame": [
          748,
          616
        ],
        "ratios": {
          "pad_over_width": 0.053,
          "left_col_over_width": 0.43
        },
        "type_px": {
          "title": 18,
          "figure": 30,
          "year": 14,
          "desc": 11
        },
        "pad_ratio": {
          "top": 0.05,
          "x": 0.053,
          "bottom": 0.05
        }
      },
      "benchmarks": [
        {
          "name": "marges intérieures ≈ 5,3 % de la largeur",
          "measure": "num('.vl-forecast','paddingLeft') / W",
          "expect": 0.053,
          "tol": 0.02
        },
        {
          "name": "colonne de gauche (timeline) ≈ 43 % de la largeur",
          "measure": "box('.vl-forecast__left').w / W",
          "expect": 0.43,
          "tol": 0.06
        },
        {
          "name": "la timeline porte bien 4 jalons",
          "measure": "document.querySelectorAll('.vl-tl__it').length",
          "expect": 4
        },
        {
          "name": "sous-cartes EMPILÉES : la menthe (présent) est au-dessus de la lilas (futur)",
          "measure": "box('.vl-fc--lilac').top - box('.vl-fc--mint').bottom",
          "min": 8
        },
        {
          "name": "les deux chiffres clés sont au plancher figure (≥ 28 px)",
          "measure": "Math.min(num('.vl-fc--mint .vl-fc__big','fontSize'), num('.vl-fc--lilac .vl-fc__big','fontSize'))",
          "min": 28
        },
        {
          "name": "jauge de progression remplie mais non pleine (fill < track)",
          "measure": "box('.vl-progress__fill').w / box('.vl-progress').w",
          "max": 0.95
        },
        {
          "name": "contraste du titre ≥ 7 (il nomme la carte)",
          "measure": "contrast('.vl-forecast__ttl')",
          "min": 7
        },
        {
          "name": "contraste du chiffre présent sur la menthe ≥ 4,5",
          "measure": "contrast('.vl-fc--mint .vl-fc__big')",
          "min": 4.5
        },
        {
          "name": "contraste du chiffre projeté sur le lilas ≥ 4,5",
          "measure": "contrast('.vl-fc--lilac .vl-fc__big')",
          "min": 4.5
        },
        {
          "name": "aucun débordement du contenu hors de la carte",
          "measure": "overflow('.vl-forecast')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/card-06-forecast-timeline.html"
    },
    {
      "id": "card-07-transactions",
      "name": "Carte transaction — identité + ligne de valeur",
      "family": "card",
      "ref": "ref-11-finance-dashboard-mint",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Poser un intitulé de flux (badge + titre + acteurs) en haut d'une carte blanche et sa ligne de valeur unique sur aplat doux collée en bas, sans jamais faire de la carte un tableau.",
      "when_to_use": "Bloc « dernière opération » ou « flux en cours » d'un dashboard : une identité, un groupe d'acteurs, une seule paire chiffrée. La pile d'avatars qui se recouvre signale un échange entre plusieurs parties.",
      "avoid_when": "Il y a plus d'une ligne de valeur à montrer : au-delà d'une, ce n'est plus cette carte mais une LISTE, qui demande un séparateur et un rythme propres. Et sans acteurs à représenter, la pile d'avatars devient un ornement vide.",
      "tags": [
        "carte",
        "transaction",
        "avatars",
        "pile",
        "flux",
        "dashboard",
        "blanc",
        "ligne-valeur",
        "crypto"
      ],
      "vars": [
        {
          "name": "--vl-surface-soft",
          "role": "aplat doux de la ligne de valeur",
          "default": "#F1F2EF"
        },
        {
          "name": "--vl-surface-dark",
          "role": "fond du badge et de la pastille de devise",
          "default": "#141414"
        },
        {
          "name": "--vl-lilac",
          "role": "pastille de menu (fin de la pile d'avatars)",
          "default": "#B6ABE6"
        },
        {
          "name": "--vl-radius-card",
          "role": "rayon de la carte",
          "default": "26px"
        }
      ],
      "slots": [
        "badge",
        "title",
        "avatars",
        "value-row"
      ],
      "geometry": {
        "root": ".vl-txn",
        "frame": [
          748,
          208
        ],
        "ratios": {
          "pad_over_width": 0.049,
          "badge_over_width": 0.08
        },
        "type_px": {
          "title": 18,
          "pair": 15,
          "sub": 11
        },
        "pad_ratio": {
          "top": 0.125,
          "x": 0.049,
          "bottom": 0.125
        }
      },
      "benchmarks": [
        {
          "name": "marges intérieures ≈ 4,9 % de la largeur",
          "measure": "num('.vl-txn','paddingLeft') / W",
          "expect": 0.049,
          "tol": 0.02
        },
        {
          "name": "badge carré (largeur ≈ hauteur)",
          "measure": "Math.abs(box('.vl-txn__badge').w - box('.vl-txn__badge').h)",
          "max": 1
        },
        {
          "name": "les avatars se RECOUVREENT (le 2e commence avant la fin du 1er)",
          "measure": "box('.vl-txn__av--2').left - box('.vl-txn__av--1').right",
          "max": -2
        },
        {
          "name": "ligne de valeur collée en bas (son bas touche la marge basse, ± 3 px)",
          "measure": "Math.abs((H - num('.vl-txn','paddingBottom')) - box('.vl-txn__row').bottom)",
          "max": 3
        },
        {
          "name": "le libellé de paire occupe la largeur restante (flex:1 → > 55 % de la carte)",
          "measure": "box('.vl-txn__pair').w / W",
          "min": 0.55
        },
        {
          "name": "contraste du titre ≥ 7 (il nomme le flux)",
          "measure": "contrast('.vl-txn__title')",
          "min": 7
        },
        {
          "name": "contraste de la valeur sur l'aplat doux ≥ 4,5 (petit corps, seuil plein)",
          "measure": "contrast('.vl-txn__pair')",
          "min": 4.5
        },
        {
          "name": "aucun débordement du contenu hors de la carte",
          "measure": "overflow('.vl-txn')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/card-07-transactions.html"
    },
    {
      "id": "card-08-orb-chain-total",
      "name": "Solde total en chaîne d'orbes",
      "family": "card",
      "ref": "ref-13-glass-fintech-dashboard",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Poser un total en chiffre héros et le décomposer juste en dessous en une chaîne de disques tangents dont un seul — le plus gros et le seul coloré — porte le support principal, les actions empilées à l'opposé.",
      "when_to_use": "Un montant unique domine l'écran ET se répartit sur 3 à 4 supports qu'il faut montrer d'un coup d'œil, avec une action primaire à offrir dans le même bloc.",
      "avoid_when": "Les parts ne somment pas au total affiché (la chaîne devient un ornement mensonger), ou il y a plus de quatre supports : au-delà, les disques rétrécissent sous le corps lisible et un tableau fait mieux.",
      "tags": [
        "carte",
        "solde",
        "total",
        "orbe",
        "disque",
        "chaine",
        "repartition",
        "dashboard",
        "verre",
        "action-primaire",
        "chiffre-hero"
      ],
      "vars": [
        {
          "name": "--vl-surface-glass",
          "role": "verre de la carte",
          "default": "rgba(255, 255, 255, 0.45)"
        },
        {
          "name": "--vl-surface-glass-strong",
          "role": "verre des orbes secondaires et des pilules",
          "default": "rgba(255, 255, 255, 0.68)"
        },
        {
          "name": "--vl-violet",
          "role": "cœur du dégradé de l'orbe principale",
          "default": "#8B5CF6"
        },
        {
          "name": "--vl-violet-deep",
          "role": "aplat de repli de l'orbe + glyphe de devise",
          "default": "#5B7CF0"
        },
        {
          "name": "--vl-violet-halo",
          "role": "halo diffus de l'orbe principale",
          "default": "rgba(123, 106, 240, 0.42)"
        },
        {
          "name": "--vl-surface-dark",
          "role": "aplat de l'action primaire",
          "default": "#101625"
        }
      ],
      "slots": [
        "label",
        "figure",
        "fx",
        "orbs",
        "actions"
      ],
      "geometry": {
        "root": ".vl-orbc",
        "frame": [
          1512,
          276
        ],
        "ratios": {
          "orb_over_width": 0.082,
          "lead_over_orb": 1.242,
          "overlap_over_orb": 0.0645,
          "actions_over_width": 0.123
        },
        "type_px": {
          "label": 12,
          "figure": 44,
          "orb": 20,
          "orb_lead": 22,
          "micro": 11
        },
        "pad_ratio": {
          "top": 0.0159,
          "x": 0.0185,
          "bottom": 0.0159
        }
      },
      "benchmarks": [
        {
          "name": "l'orbe principale est plus grosse que ses voisines d'un rapport ≈ 1,24",
          "measure": "box('.vl-orbc__orb--lead').w / box('.vl-orbc__orb').w",
          "expect": 1.24,
          "tol": 0.08
        },
        {
          "name": "les orbes se TOUCHENT sans se recouvrir (chevauchement ≤ 10 % du diamètre)",
          "measure": "(box('.vl-orbc__orb').right - box('.vl-orbc__orb--lead').left) / box('.vl-orbc__orb').w",
          "min": 0,
          "max": 0.1
        },
        {
          "name": "le chiffre héros appartient à l'échelle display : ≥ 40 px",
          "measure": "num('.vl-orbc__fig','fontSize')",
          "min": 40
        },
        {
          "name": "le chiffre héros pèse au moins le double du chiffre d'une orbe",
          "measure": "num('.vl-orbc__fig','fontSize') / num('.vl-orbc__orbfig','fontSize')",
          "min": 2
        },
        {
          "name": "la chaîne laisse la place aux actions : elle occupe moins de la moitié de la largeur",
          "measure": "box('.vl-orbc__orbs').w / W",
          "max": 0.5
        },
        {
          "name": "les actions sont ancrées à droite, sans recouvrir la chaîne",
          "measure": "box('.vl-orbc__actions').left - box('.vl-orbc__orbs').right",
          "min": 24
        },
        {
          "name": "une seule action pleine : la seconde pilule est opaque, la première non",
          "measure": "(Number((cs('.vl-orbc__pill--solid','backgroundColor').match(/[\\d.]+/g)||[0,0,0,1])[3]) || 1) - (Number((cs('.vl-orbc__pill','backgroundColor').match(/[\\d.]+/g)||[0,0,0,1])[3]) || 1)",
          "min": 0.25
        },
        {
          "name": "contraste du chiffre héros sur le verre ≥ 7",
          "measure": "contrast('.vl-orbc__fig')",
          "min": 7
        },
        {
          "name": "contraste du chiffre de l'orbe principale sur son aplat ≥ 3",
          "measure": "contrast('.vl-orbc__orb--lead .vl-orbc__orbfig')",
          "min": 3
        },
        {
          "name": "aucun débordement du contenu hors de la carte",
          "measure": "overflow('.vl-orbc')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/card-08-orb-chain-total.html"
    },
    {
      "id": "card-09-gradient-metric-curve",
      "name": "KPI sur aplat coloré à courbe annotée",
      "family": "card",
      "ref": "ref-13-glass-fintech-dashboard",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Faire de la seule carte colorée d'un écran le porteur d'un indicateur unique : la pile libellé / chiffre / cadrage en haut-gauche, une courbe sans axe qui traverse la carte derrière elle, annotée à ses deux extrémités seulement.",
      "when_to_use": "Un indicateur de premier rang doit émerger d'un tableau de bord par ailleurs neutre, et sa trajectoire compte autant que sa valeur du jour.",
      "avoid_when": "Deux indicateurs se disputent le premier rang (deux cartes colorées et il n'y en a plus aucune), ou les valeurs intermédiaires doivent être lisibles : ici la courbe n'a ni axe ni grille, elle donne une forme, pas des chiffres.",
      "tags": [
        "carte",
        "kpi",
        "indicateur",
        "courbe",
        "sparkline",
        "degrade",
        "aplat",
        "annotation",
        "trajectoire",
        "dashboard",
        "premier-rang"
      ],
      "vars": [
        {
          "name": "--vl-blue",
          "role": "extrémité saturée de l'aplat",
          "default": "#4F7CF7"
        },
        {
          "name": "--vl-blue-light",
          "role": "extrémité claire de l'aplat",
          "default": "#8FB6FA"
        },
        {
          "name": "--vl-ink-invert",
          "role": "texte, courbe mesurée et points",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-ink-invert-muted",
          "role": "cadrage temporel + prolongement décoratif de la courbe",
          "default": "rgba(255, 255, 255, 0.72)"
        },
        {
          "name": "--vl-surface-glass-soft",
          "role": "bouton rond translucide",
          "default": "rgba(255, 255, 255, 0.24)"
        }
      ],
      "slots": [
        "label",
        "figure",
        "sub",
        "action",
        "curve",
        "tags"
      ],
      "geometry": {
        "root": ".vl-metric",
        "frame": [
          790,
          238
        ],
        "ratios": {
          "curve_span_over_width": 0.696,
          "rise_over_height": 0.412,
          "btn_over_width": 0.043
        },
        "type_px": {
          "label": 15,
          "figure": 32,
          "micro": 11
        },
        "pad_ratio": {
          "top": 0.0304,
          "x": 0.0354,
          "bottom": 0.0304
        }
      },
      "benchmarks": [
        {
          "name": "le chiffre pèse au moins le double du libellé qui le nomme",
          "measure": "num('.vl-metric__fig','fontSize') / num('.vl-metric__label','fontSize')",
          "min": 2
        },
        {
          "name": "la pile de KPI tient dans la moitié gauche : la courbe garde sa place",
          "measure": "box('.vl-metric__head').w / W",
          "min": 0.85
        },
        {
          "name": "le chiffre ne descend pas dans la moitié basse de la carte (là où passe la courbe)",
          "measure": "box('.vl-metric__fig').bottom / H",
          "max": 0.5
        },
        {
          "name": "l'annotation d'arrivée est au-dessus de celle de départ : la courbe monte",
          "measure": "box('.vl-metric__tag--from').top - box('.vl-metric__tag--to').top",
          "min": 60
        },
        {
          "name": "les deux annotations encadrent la course mesurée (≥ 60 % de la largeur)",
          "measure": "(box('.vl-metric__tag--to').left - box('.vl-metric__tag--from').left) / W",
          "min": 0.6
        },
        {
          "name": "exactement deux annotations : on annote les bouts, pas les points intermédiaires",
          "measure": "document.querySelectorAll('.vl-metric__tag').length",
          "expect": 2,
          "tol": 0
        },
        {
          "name": "la courbe est en fond et occupe toute la carte",
          "measure": "box('.vl-metric__curve').w / W",
          "expect": 1,
          "tol": 0.02
        },
        {
          "name": "contraste du chiffre sur l'aplat ≥ 3 (blanc sur bleu saturé)",
          "measure": "contrast('.vl-metric__fig')",
          "min": 3
        },
        {
          "name": "contraste du libellé sur l'aplat ≥ 3",
          "measure": "contrast('.vl-metric__label')",
          "min": 3
        },
        {
          "name": "aucun débordement du contenu hors de la carte",
          "measure": "overflow('.vl-metric')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/card-09-gradient-metric-curve.html"
    },
    {
      "id": "card-10-kpi-notch-tile",
      "name": "Vignette de preuve chiffrée à double chanfrein",
      "family": "card",
      "ref": "ref-15-lilac-notched-kpi",
      "media": [
        "slide",
        "social",
        "web"
      ],
      "intent": "Poser UNE preuve chiffrée dans une vignette presque carrée à silhouette de ticket — tuile d'icône en haut, chiffre et son suffixe sur la même ligne de base, libellé collé dessous, commentaire relégué en bas à droite — et n'en accentuer qu'une seule dans la rangée, par le fond et rien d'autre.",
      "when_to_use": "Bande de trois à quatre preuves de même rang sur une landing ou une slide « pourquoi nous » : taux, gain de temps, volume. Une seule vignette prend la variante `--accent`, celle qu'on veut faire lire en premier.",
      "avoid_when": "Il n'y a qu'un chiffre à montrer (la rangée n'existe plus, et l'accent n'a plus rien à surpasser), le commentaire dépasse deux lignes courtes (il rejoint le libellé et le décalage à droite devient illisible), ou plusieurs vignettes sont colorées — deux accents dans une rangée annulent le seul geste de hiérarchie du système.",
      "tags": [
        "stat",
        "chiffre",
        "kpi",
        "pourcentage",
        "vignette",
        "carte",
        "rangee",
        "accent",
        "chanfrein",
        "notch",
        "icone",
        "tuile",
        "landing",
        "preuve",
        "saas"
      ],
      "vars": [
        {
          "name": "--vl-card",
          "role": "fond de la vignette neutre",
          "default": "#FCFBFF"
        },
        {
          "name": "--vl-violet",
          "role": "départ du dégradé d'accent, et couleur du glyphe sur la vignette neutre",
          "default": "#6B3FE4"
        },
        {
          "name": "--vl-violet-light",
          "role": "arrivée du dégradé ET couleur de repli — le point le plus clair, donc celui que la mesure de contraste doit lire",
          "default": "#9A7BF0"
        },
        {
          "name": "--vl-ink",
          "role": "chiffre et libellé sur la vignette neutre",
          "default": "#2E2166"
        },
        {
          "name": "--vl-ink-muted",
          "role": "note du bas sur la vignette neutre (relevée à #9B96C4, remontée pour tenir 4,5:1)",
          "default": "#6E67A0"
        },
        {
          "name": "--vl-tile",
          "role": "aplat de la tuile d'icône, vignette neutre",
          "default": "#EBE7FB"
        },
        {
          "name": "--vl-tile-invert",
          "role": "aplat de la tuile d'icône sur l'accent (blanc translucide)",
          "default": "rgba(255,255,255,0.22)"
        },
        {
          "name": "--vl-notch",
          "role": "longueur du chanfrein, posée sur les DEUX coins de la diagonale",
          "default": "46px"
        },
        {
          "name": "--vl-radius",
          "role": "rayon des deux coins non coupés",
          "default": "17px"
        }
      ],
      "slots": [
        "tile",
        "num",
        "suffix",
        "label",
        "note"
      ],
      "geometry": {
        "root": ".vl-kpi",
        "frame": [
          420,
          450
        ],
        "ratios": {
          "width_over_height": 0.933,
          "notch_over_width": 0.11,
          "radius_over_width": 0.04,
          "pad_x_over_width": 0.09,
          "tile_over_width": 0.188,
          "gutter_over_width": 0.102,
          "figure_over_label": 3.18,
          "suffix_over_figure": 0.426,
          "note_top_over_height": 0.795
        },
        "type_px": {
          "num": 108,
          "suffix": 46,
          "label": 34,
          "note": 20
        },
        "pad_ratio": {
          "top": 0.09,
          "x": 0.09,
          "bottom": 0.081
        }
      },
      "benchmarks": [
        {
          "name": "vignette presque carrée, un rien plus haute que large",
          "measure": "W / H",
          "expect": 0.933,
          "tol": 0.03
        },
        {
          "name": "chanfrein ≈ 11 % de la largeur",
          "measure": "notch('.vl-kpi') / W",
          "expect": 0.11,
          "tol": 0.02
        },
        {
          "name": "le polygone porte 6 sommets : DEUX coins coupés, pas un ni quatre",
          "measure": "(cs('.vl-kpi','clipPath').match(/,/g) || []).length + 1",
          "expect": 6,
          "tol": 0
        },
        {
          "name": "la coupe commence au coin haut-droit (l'autre est son opposé sur la diagonale)",
          "measure": "notchCorner('.vl-kpi') === 'tr' ? 1 : 0",
          "expect": 1
        },
        {
          "name": "les coins non coupés gardent un rayon ≈ 4 % de la largeur",
          "measure": "num('.vl-kpi','borderTopLeftRadius') / W",
          "expect": 0.04,
          "tol": 0.015
        },
        {
          "name": "marge intérieure gauche ≈ 9 % de la largeur",
          "measure": "num('.vl-kpi','paddingLeft') / W",
          "expect": 0.09,
          "tol": 0.02
        },
        {
          "name": "tuile d'icône carrée",
          "measure": "box('.vl-kpi__tile').w / box('.vl-kpi__tile').h",
          "expect": 1,
          "tol": 0.02
        },
        {
          "name": "la tuile reste un repère, jamais un sujet : ≤ 22 % de la largeur",
          "measure": "box('.vl-kpi__tile').w / W",
          "max": 0.22
        },
        {
          "name": "saut typographique chiffre / libellé ≥ 2,8",
          "measure": "num('.vl-kpi__num','fontSize') / num('.vl-kpi__label','fontSize')",
          "min": 2.8
        },
        {
          "name": "le suffixe pèse entre 35 % et 50 % du chiffre",
          "measure": "num('.vl-kpi__suffix','fontSize') / num('.vl-kpi__num','fontSize')",
          "min": 0.35,
          "max": 0.5
        },
        {
          "name": "le suffixe est posé À DROITE du chiffre, jamais dessous",
          "measure": "box('.vl-kpi__suffix').left - box('.vl-kpi__num').right",
          "max": 14,
          "min": -1
        },
        {
          "name": "lignes de base alignées : l'écart de fond de boîte reste sous 10 % du corps du chiffre",
          "measure": "Math.abs(box('.vl-kpi__num').bottom - box('.vl-kpi__suffix').bottom) / num('.vl-kpi__num','fontSize')",
          "max": 0.1
        },
        {
          "name": "libellé collé au chiffre : moins d'un demi-corps de libellé les sépare",
          "measure": "(box('.vl-kpi__label').top - box('.vl-kpi__figure').bottom) / num('.vl-kpi__label','fontSize')",
          "max": 0.5
        },
        {
          "name": "la note est bien en bas (haut du bloc entre 72 % et 88 % de la hauteur)",
          "measure": "box('.vl-kpi__note').top / H",
          "min": 0.72,
          "max": 0.88
        },
        {
          "name": "la note est au fer à DROITE : c'est son bord droit qui colle la marge",
          "measure": "W - num('.vl-kpi','paddingRight') - box('.vl-kpi__note').right",
          "expect": 0,
          "tol": 1.5
        },
        {
          "name": "la note tient dans la moitié droite : c'est un aparté, pas un paragraphe",
          "measure": "box('.vl-kpi__note').left / W",
          "min": 0.4
        },
        {
          "name": "la note s'arrête AVANT le chanfrein du bas-gauche",
          "measure": "box('.vl-kpi__note').left - num('.vl-kpi','paddingLeft')",
          "min": 0
        },
        {
          "name": "contraste du chiffre ≥ 3 (mesuré sur l'extrémité CLAIRE du dégradé)",
          "measure": "contrast('.vl-kpi__num')",
          "min": 3
        },
        {
          "name": "contraste de la note ≥ 3 — c'est elle qui plafonne, pas le chiffre",
          "measure": "contrast('.vl-kpi__note')",
          "min": 3
        },
        {
          "name": "aucun débordement du contenu hors de la vignette",
          "measure": "overflow('.vl-kpi')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/card-10-kpi-notch-tile.html"
    },
    {
      "id": "card-11-corner-arrow-tile",
      "name": "Vignette d'entrée à flèche de coin",
      "family": "card",
      "ref": "ref-17-mono-bento-legal",
      "media": [
        "web",
        "slide"
      ],
      "intent": "Faire d'un module une ENTRÉE cliquable sans bouton ni liseré : le titre en haut à gauche, une flèche oblique ancrée au coin opposé — jamais alignée sur le titre — et l'illustration qui occupe tout le bas de la vignette au lieu d'être centrée dedans.",
      "when_to_use": "Grille de services, sommaire de rubriques, tuiles d'entrée d'un accueil : trois à cinq vignettes de même rang dont chacune mène ailleurs. Le titre tient sur deux lignes courtes, l'illustration porte la respiration, et la flèche est le seul signe d'interaction de l'écran.",
      "avoid_when": "La vignette porte un chiffre (elle devient une carte de KPI, cf. card-10-kpi-notch-tile : le chiffre veut l'aplomb du bas, l'illustration aussi, et les deux ne cohabitent pas), le titre dépasse trois lignes (il rejoint la flèche et le coin cesse d'être libre), ou la vignette n'est pas cliquable — une flèche qui ne mène nulle part est un mensonge d'interface.",
      "tags": [
        "vignette",
        "carte",
        "tuile",
        "entree",
        "service",
        "rubrique",
        "fleche",
        "arrow",
        "coin",
        "illustration",
        "bento",
        "accueil",
        "landing",
        "monochrome"
      ],
      "vars": [
        {
          "name": "--vl-module",
          "role": "aplat de la vignette",
          "default": "#EDEDED"
        },
        {
          "name": "--vl-ink",
          "role": "titre et flèche",
          "default": "#111111"
        },
        {
          "name": "--vl-ink-soft",
          "role": "ligne d'explication sous l'illustration",
          "default": "#5F5F5F"
        },
        {
          "name": "--vl-line",
          "role": "trait de l'illustration — une valeur d'encre, pas un gris de fond",
          "default": "rgba(17,17,17,0.55)"
        },
        {
          "name": "--vl-radius-module",
          "role": "rayon de la vignette",
          "default": "24px"
        }
      ],
      "slots": [
        "title",
        "art",
        "sub"
      ],
      "geometry": {
        "root": ".vl-tile",
        "frame": [
          466,
          302
        ],
        "ratios": {
          "width_over_height": 1.543,
          "radius_over_width": 0.0515,
          "pad_over_width": 0.06,
          "arrow_over_width": 0.064,
          "title_block_over_width": 0.74,
          "art_over_height": 0.45
        },
        "type_px": {
          "title": 23,
          "sub": 16
        },
        "pad_ratio": {
          "top": 0.06,
          "x": 0.06,
          "bottom": 0.06
        }
      },
      "benchmarks": [
        {
          "name": "vignette nettement paysage : W / H ≈ 1,54",
          "measure": "W / H",
          "expect": 1.543,
          "tol": 0.12
        },
        {
          "name": "rayon ≈ 5 % de la largeur",
          "measure": "num('.vl-tile','borderTopLeftRadius') / W",
          "expect": 0.0515,
          "tol": 0.015
        },
        {
          "name": "marge intérieure identique à gauche et en haut : la vignette n'a pas de sens de lecture privilégié",
          "measure": "Math.abs(num('.vl-tile','paddingLeft') - num('.vl-tile','paddingTop'))",
          "max": 1
        },
        {
          "name": "la flèche est ANCRÉE au coin : son bord droit colle la marge intérieure",
          "measure": "W - num('.vl-tile','paddingRight') - box('.vl-tile__arrow').right",
          "expect": 0,
          "tol": 1.5
        },
        {
          "name": "… et son bord haut aussi",
          "measure": "Math.abs(box('.vl-tile__arrow').top - num('.vl-tile','paddingTop'))",
          "max": 1.5
        },
        {
          "name": "la flèche reste un repère, jamais un sujet : ≤ 9 % de la largeur",
          "measure": "box('.vl-tile__arrow').w / W",
          "max": 0.09
        },
        {
          "name": "le titre ne court JAMAIS sous la flèche : au moins 8 px les séparent",
          "measure": "box('.vl-tile__arrow').left - box('.vl-tile__title').right",
          "min": 8
        },
        {
          "name": "le titre colle le coin haut-gauche",
          "measure": "Math.abs(box('.vl-tile__title').left - num('.vl-tile','paddingLeft')) + Math.abs(box('.vl-tile__title').top - num('.vl-tile','paddingTop'))",
          "max": 3
        },
        {
          "name": "l'illustration occupe toute la largeur utile — elle n'est pas une vignette dans la vignette",
          "measure": "box('.vl-tile__art').w / (W - num('.vl-tile','paddingLeft') - num('.vl-tile','paddingRight'))",
          "min": 0.95
        },
        {
          "name": "l'illustration prend au moins 40 % de la hauteur : c'est elle qui porte la respiration",
          "measure": "box('.vl-tile__art').h / H",
          "min": 0.4
        },
        {
          "name": "la ligne d'explication est bien en bas",
          "measure": "box('.vl-tile__sub').bottom / H",
          "min": 0.86
        },
        {
          "name": "saut typographique titre / explication ≥ 1,35",
          "measure": "num('.vl-tile__title','fontSize') / num('.vl-tile__sub','fontSize')",
          "min": 1.35
        },
        {
          "name": "aucun liseré",
          "measure": "num('.vl-tile','borderTopWidth')",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "aucune ombre portée",
          "measure": "cs('.vl-tile','boxShadow') === 'none' ? 1 : 0",
          "expect": 1
        },
        {
          "name": "contraste du titre ≥ 4,5",
          "measure": "contrast('.vl-tile__title')",
          "min": 4.5
        },
        {
          "name": "contraste de l'explication ≥ 4,5",
          "measure": "contrast('.vl-tile__sub')",
          "min": 4.5
        },
        {
          "name": "aucun débordement",
          "measure": "overflow('.vl-tile')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/card-11-corner-arrow-tile.html"
    },
    {
      "id": "card-12-inverted-kpi-row",
      "name": "Rangée de KPI dont un seul est inversé — et plus large",
      "family": "card",
      "ref": "ref-18-lime-sales-dashboard",
      "media": [
        "web",
        "slide"
      ],
      "intent": "Ouvrir un tableau de bord par trois indicateurs de même nature dont UN SEUL est de premier rang, en lui appliquant deux fois le même geste de hiérarchie : l'aplat inversé ET une colonne 5 % plus large. Chaque carte range ses quatre informations dans le même ordre — libellé en haut, pastille de renvoi ancrée au coin opposé, chiffre poussé en bas, note collée dessous.",
      "when_to_use": "Bandeau d'ouverture d'un tableau de bord ou d'un rapport : trois à quatre valeurs comparables (un solde, un revenu, un volume) dont une seule commande l'attention. Le chiffre est court — quatre à six signes — et la note tient sur une ligne.",
      "avoid_when": "Les valeurs ne sont pas comparables entre elles (la rangée suggère une lecture de gauche à droite qui n'existe pas), il y en a plus de quatre (la carte inversée cesse de dominer et la largeur différenciée devient un défaut d'alignement), ou plusieurs cartes doivent être mises en avant — deux aplats inversés dans une rangée annulent le seul geste de hiérarchie du système.",
      "tags": [
        "kpi",
        "chiffre",
        "stat",
        "rangee",
        "dashboard",
        "tableau-de-bord",
        "carte",
        "inverse",
        "hierarchie",
        "solde",
        "revenu",
        "accent",
        "renvoi"
      ],
      "vars": [
        {
          "name": "--vl-module",
          "role": "aplat des cartes secondaires",
          "default": "#F2F2F2"
        },
        {
          "name": "--vl-invert",
          "role": "aplat de la carte de premier rang — UNE SEULE par rangée",
          "default": "#000000"
        },
        {
          "name": "--vl-ink",
          "role": "libellé et chiffre sur carte secondaire",
          "default": "#0B0B0B"
        },
        {
          "name": "--vl-ink-soft",
          "role": "note de bas de carte",
          "default": "#6B6B6B"
        },
        {
          "name": "--vl-on-invert",
          "role": "encre sur la carte inversée",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-on-invert-soft",
          "role": "note sur la carte inversée",
          "default": "rgba(255,255,255,0.66)"
        },
        {
          "name": "--vl-ramp-2",
          "role": "liseré de la pastille de renvoi, cartes secondaires",
          "default": "#CCCCCC"
        },
        {
          "name": "--vl-radius-module",
          "role": "rayon d'une carte",
          "default": "29px"
        },
        {
          "name": "--vl-gutter",
          "role": "gouttière de la rangée",
          "default": "16px"
        }
      ],
      "slots": [
        "label",
        "go",
        "fig",
        "note"
      ],
      "geometry": {
        "root": ".vl-kpirow",
        "frame": [
          1286,
          219
        ],
        "ratios": {
          "height_over_width": 0.17,
          "lead_over_second": 1.05,
          "gutter_over_width": 0.0124,
          "radius_over_card_width": 0.069,
          "go_over_card_width": 0.109,
          "fig_over_label": 3.1
        },
        "type_px": {
          "fig": 62,
          "label": 20,
          "note": 15
        },
        "pad_ratio": {
          "top": 0.137,
          "x": 0.076,
          "bottom": 0.119
        }
      },
      "benchmarks": [
        {
          "name": "rangée basse : sa hauteur vaut ≈ 17 % de sa largeur",
          "measure": "H / W",
          "expect": 0.17,
          "tol": 0.03
        },
        {
          "name": "LA CARTE INVERSÉE EST AUSSI LA PLUS LARGE — le geste de hiérarchie est appliqué deux fois",
          "measure": "box('.vl-kpirow__card--lead').w / box('.vl-kpirow__card--b').w",
          "expect": 1.05,
          "tol": 0.03
        },
        {
          "name": "… mais à peine : au-delà de 12 % d'écart, ce n'est plus une hiérarchie, c'est une grille cassée",
          "measure": "box('.vl-kpirow__card--lead').w / box('.vl-kpirow__card--b').w",
          "max": 1.12
        },
        {
          "name": "UNE SEULE carte inversée dans la rangée",
          "measure": "[...document.querySelectorAll('.vl-kpirow > *')].filter((e) => lum(parseRgb(getComputedStyle(e).backgroundColor)) < 0.2).length",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "les trois cartes partagent leur ligne haute et leur ligne basse",
          "measure": "Math.abs(box('.vl-kpirow__card--lead').top - box('.vl-kpirow__card--c').top) + Math.abs(box('.vl-kpirow__card--lead').bottom - box('.vl-kpirow__card--c').bottom)",
          "max": 1.5
        },
        {
          "name": "gouttière ≈ 1,2 % de la largeur de rangée",
          "measure": "num('.vl-kpirow','columnGap') / W",
          "expect": 0.0124,
          "tol": 0.006
        },
        {
          "name": "la pastille de renvoi est ancrée au coin haut-droit de sa carte",
          "measure": "box('.vl-kpirow__card--lead').right - num('.vl-kpirow__card--lead','paddingRight') - box('.vl-kpirow__go').right",
          "expect": 0,
          "tol": 1.5
        },
        {
          "name": "la pastille est un disque",
          "measure": "box('.vl-kpirow__go').w / box('.vl-kpirow__go').h",
          "expect": 1,
          "tol": 0.02
        },
        {
          "name": "le libellé ne court jamais sous la pastille",
          "measure": "box('.vl-kpirow__go').left - box('.vl-kpirow__label').right",
          "min": 8
        },
        {
          "name": "le chiffre est POUSSÉ EN BAS : son haut passe la mi-hauteur de la carte",
          "measure": "(box('.vl-kpirow__fig').top - box('.vl-kpirow__card--lead').top) / box('.vl-kpirow__card--lead').h",
          "min": 0.45
        },
        {
          "name": "saut typographique chiffre / libellé ≥ 2,6",
          "measure": "num('.vl-kpirow__fig','fontSize') / num('.vl-kpirow__label','fontSize')",
          "min": 2.6
        },
        {
          "name": "la note est collée sous le chiffre : moins d'un demi-corps de note les sépare",
          "measure": "(box('.vl-kpirow__note').top - box('.vl-kpirow__fig').bottom) / num('.vl-kpirow__note','fontSize')",
          "max": 0.6
        },
        {
          "name": "aucun liseré sur les cartes",
          "measure": "num('.vl-kpirow__card--b','borderTopWidth')",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "contraste du chiffre inversé ≥ 4,5",
          "measure": "contrast('.vl-kpirow__fig')",
          "min": 4.5
        },
        {
          "name": "contraste de la note inversée ≥ 4,5 — c'est elle qui plafonne, pas le chiffre",
          "measure": "contrast('.vl-kpirow__note')",
          "min": 4.5
        },
        {
          "name": "aucun débordement de la rangée",
          "measure": "overflow('.vl-kpirow')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/card-12-inverted-kpi-row.html"
    },
    {
      "id": "card-13-email-figure-band",
      "name": "Bande de trois chiffres pour un mail — colonnes en cellules, filets d'un pixel",
      "family": "card",
      "ref": "ref-04-swiss-investor-blue",
      "media": [
        "email"
      ],
      "intent": "Poser trois indicateurs de même nature en une bande dans un mail, sans aplat de carte ni encadrement : les colonnes sont des cellules à largeur en pourcentage sur une table à disposition fixe — la seule répartition en parts égales qu'Outlook respecte — et les séparateurs sont des filets de cellule, pas des colonnes intercalaires. La couleur ne fait qu'une chose : elle porte les chiffres.",
      "when_to_use": "Ouvrir ou clore une lettre d'information par trois valeurs comparables (un encours, une variation, un horizon) que le lecteur doit pouvoir saisir sans lire le corps. Le chiffre est court — six signes au plus — et son libellé tient sur une ligne.",
      "avoid_when": "Les valeurs ne sont pas comparables (la bande impose une lecture de gauche à droite qui n'existe pas), il y en a plus de trois (au-delà, les colonnes tombent sous 120 px et les chiffres se coupent sur mobile), ou la lettre est déjà dense en chiffres — une bande de chiffres au milieu d'un texte de chiffres ne hiérarchise plus rien.",
      "tags": [
        "email",
        "mailing",
        "newsletter",
        "chiffre",
        "kpi",
        "indicateur",
        "bande",
        "colonnes",
        "filet",
        "outlook",
        "table",
        "stat",
        "resume"
      ],
      "vars": [
        {
          "name": "--vl-white",
          "role": "aplat du bloc — plein, jamais translucide",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-black",
          "role": "filets verticaux de séparation",
          "default": "#0D0D0D"
        },
        {
          "name": "--vl-blue",
          "role": "les chiffres — le SEUL élément chromatique de la bande",
          "default": "#1B44D8"
        },
        {
          "name": "--vl-ink-muted",
          "role": "libellé au-dessus et note en dessous",
          "default": "#6E6E6E"
        }
      ],
      "slots": [
        "label",
        "fig",
        "note"
      ],
      "geometry": {
        "root": ".vl-emband",
        "frame": [
          520,
          88
        ],
        "ratios": {
          "column_over_width": 0.3333,
          "fig_over_label": 3.4,
          "height_over_width": 0.169,
          "rule_px": 1
        },
        "type_px": {
          "fig": 34,
          "label": 10,
          "note": 10
        },
        "pad_ratio": {
          "top": 0.004,
          "x": 0.031,
          "bottom": 0.004
        }
      },
      "benchmarks": [
        {
          "name": "520 px — le slot exact de layout-09-email-envelope (600 − 2 × 40)",
          "measure": "W",
          "expect": 520,
          "tol": 0
        },
        {
          "name": "TROIS COLONNES DE MÊME LARGEUR — un filet de cellule ne doit pas décaler la répartition",
          "measure": "Math.max(...[...document.querySelectorAll('.vl-emband__cell')].map((e) => e.getBoundingClientRect().width)) - Math.min(...[...document.querySelectorAll('.vl-emband__cell')].map((e) => e.getBoundingClientRect().width))",
          "max": 1.5
        },
        {
          "name": "chaque colonne vaut un tiers de la bande",
          "measure": "box('.vl-emband__cell').w / W",
          "expect": 0.3333,
          "tol": 0.01
        },
        {
          "name": "la bande reste une bande : sa hauteur ne dépasse pas le quart de sa largeur",
          "measure": "H / W",
          "max": 0.25
        },
        {
          "name": "les trois colonnes partagent leur ligne haute et leur ligne basse",
          "measure": "[...document.querySelectorAll('.vl-emband__cell')].reduce((a, e, i, t) => a + Math.abs(e.getBoundingClientRect().top - t[0].getBoundingClientRect().top) + Math.abs(e.getBoundingClientRect().bottom - t[0].getBoundingClientRect().bottom), 0)",
          "max": 1.5
        },
        {
          "name": "DEUX séparateurs pour trois colonnes : la bande n'est pas encadrée",
          "measure": "[...document.querySelectorAll('.vl-emband__cell')].filter((e) => parseFloat(getComputedStyle(e).borderLeftWidth) > 0).length",
          "expect": 2,
          "tol": 0
        },
        {
          "name": "un séparateur est un filet, pas un trait : un pixel",
          "measure": "num('.vl-emband__cell--split','borderLeftWidth')",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "aucun rayon — angles vifs, et zéro dégradation dans Outlook",
          "measure": "['.vl-emband','.vl-emband__cell'].reduce((a, s) => a + parseFloat(getComputedStyle(document.querySelector(s)).borderTopLeftRadius), 0)",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "saut typographique chiffre / libellé ≥ 3 — la charte n'a pas de taille intermédiaire",
          "measure": "num('.vl-emband__fig','fontSize') / num('.vl-emband__label','fontSize')",
          "min": 3
        },
        {
          "name": "CHAQUE LIBELLÉ ET CHAQUE NOTE TIENNENT SUR UNE LIGNE — au-delà, les colonnes cessent d'avoir la même assise",
          "measure": "Math.max(...[...document.querySelectorAll('.vl-emband__note, .vl-emband__label')].map((e) => e.getBoundingClientRect().height / parseFloat(getComputedStyle(e).lineHeight)))",
          "max": 1.05
        },
        {
          "name": "LE CHIFFRE EST LE SEUL ÉLÉMENT CHROMATIQUE — trois, pas un de plus",
          "measure": "[...document.querySelectorAll('.vl-emband *')].filter((e) => { const c = parseRgb(getComputedStyle(e).color); return Math.max(...c) - Math.min(...c) > 40; }).length",
          "expect": 3,
          "tol": 0
        },
        {
          "name": "… et il est vraiment coloré : la couleur du chiffre n'est pas un gris",
          "measure": "(() => { const c = parseRgb(cs('.vl-emband__fig','color')); return Math.max(...c) - Math.min(...c); })()",
          "min": 40
        },
        {
          "name": "contraste du chiffre ≥ 4,5",
          "measure": "contrast('.vl-emband__fig')",
          "min": 4.5
        },
        {
          "name": "contraste du libellé ≥ 4,5 — c'est le gris qui plafonne, pas le chiffre",
          "measure": "contrast('.vl-emband__label')",
          "min": 4.5
        },
        {
          "name": "contraste de la note ≥ 4,5",
          "measure": "contrast('.vl-emband__note')",
          "min": 4.5
        },
        {
          "name": "plancher de lisibilité : aucun corps sous 10 px",
          "measure": "Math.min(...[...document.querySelectorAll('.vl-emband *')].filter((e) => !e.children.length && e.textContent.trim()).map((e) => parseFloat(getComputedStyle(e).fontSize)))",
          "min": 10
        },
        {
          "name": "aucun débordement",
          "measure": "overflow('.vl-emband')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/card-13-email-figure-band.html"
    },
    {
      "id": "card-14-numbered-steps",
      "name": "Rangée d'étapes numérotées, un seul accent",
      "family": "card",
      "ref": "ref-05-proposal-acid-yellow",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Poser une méthode en quatre temps sur une seule rangée, en tenant DEUX rails : le chiffre de chaque étape pend du même plafond, son texte pousse depuis le même plancher — donc une phrase de deux lignes et une de quatre ne décalent rien. Une seule carte est accentuée, et elle l'est par le FOND et rien d'autre ; la dernière est noire parce qu'elle est une fin, pas un second accent.",
      "when_to_use": "Une démarche, un process ou un plan en 3 à 5 étapes de même nature, dont une seule est « celle qui compte » (l'étape courante, le livrable qui décide). Sur une slide de méthode, une page « comment on travaille », un bloc de parcours.",
      "avoid_when": "Les étapes ne sont pas de même nature ou n'ont pas de rang (là il faut une liste, pas une rangée) ; il en faut plus de cinq (les colonnes tombent sous la largeur d'un mot) ; ou deux étapes doivent être accentuées — le pattern porte l'accent UNIQUE, et le dupliquer supprime le point d'entrée du regard.",
      "tags": [
        "etapes",
        "process",
        "methode",
        "numerotee",
        "rangee",
        "chiffre",
        "accent",
        "accent-unique",
        "timeline",
        "parcours",
        "carte",
        "plan"
      ],
      "vars": [
        {
          "name": "--vl-nsteps-w",
          "role": "la largeur de la rangée — la bande de contenu qui la reçoit",
          "default": "1456px"
        },
        {
          "name": "--vl-nsteps-h",
          "role": "la hauteur de la rangée — une COTE, jamais un reste de colonne : c'est elle qui tient la surface accentuée sous le plafond de la charte",
          "default": "440px"
        },
        {
          "name": "--vl-nsteps-gap",
          "role": "la gouttière entre deux étapes",
          "default": "var(--vl-gap)"
        },
        {
          "name": "--vl-nsteps-radius",
          "role": "le rayon des cartes — constante de charte en longueur, pas une fraction du côté",
          "default": "var(--vl-r-step)"
        },
        {
          "name": "--vl-nsteps-pad",
          "role": "la marge intérieure d'une carte, la même sur les quatre côtés",
          "default": "32px"
        },
        {
          "name": "--vl-nsteps-plain",
          "role": "le fond des étapes ordinaires",
          "default": "var(--vl-white)"
        },
        {
          "name": "--vl-nsteps-accent",
          "role": "le fond de l'ÉTAPE ACCENTUÉE — le seul emploi de la couleur d'accent dans le fragment",
          "default": "var(--vl-acid)"
        },
        {
          "name": "--vl-nsteps-end",
          "role": "le fond de l'étape terminale : un aplat sombre, qui marque une FIN et non un second accent",
          "default": "var(--vl-black)"
        },
        {
          "name": "--vl-nsteps-ink",
          "role": "l'encre sur fond clair et sur l'accent",
          "default": "var(--vl-black)"
        },
        {
          "name": "--vl-nsteps-ink-invert",
          "role": "l'encre sur l'aplat sombre",
          "default": "var(--vl-white)"
        },
        {
          "name": "--vl-nsteps-muted",
          "role": "l'encre du corps sur fond clair",
          "default": "var(--vl-ink-muted)"
        },
        {
          "name": "--vl-nsteps-muted-invert",
          "role": "l'encre du corps sur l'aplat sombre",
          "default": "var(--vl-ink-muted-invert)"
        },
        {
          "name": "--vl-font-body",
          "role": "la famille du fragment — ÉPINGLÉE : le chiffre de 90 est cadré par sa chasse, donc le fragment ne peut pas hériter la police de la page qui le colle",
          "default": "\"Hanken Grotesk\", \"Helvetica Neue\", Arial, sans-serif"
        }
      ],
      "slots": [
        "n",
        "label",
        "body"
      ],
      "geometry": {
        "root": ".vl-nsteps",
        "frame": [
          1456,
          440
        ],
        "ratios": {
          "height_over_width": 0.302,
          "step_over_width": 0.2376,
          "gutter_over_width": 0.0165,
          "pad_over_step_width": 0.0925,
          "n_over_label": 4.74,
          "accent_over_slide": 0.106
        },
        "type_px": {
          "n": 90,
          "label": 19,
          "body": 19
        },
        "pad_ratio": {
          "top": 0.0727,
          "x": 0.022,
          "bottom": 0.0727
        }
      },
      "benchmarks": [
        {
          "name": "la rangée est BORNÉE en hauteur : 0,30 de sa largeur — ce n'est pas un reste de colonne",
          "measure": "H / W",
          "expect": 0.302,
          "tol": 0.02
        },
        {
          "name": "quatre colonnes ÉGALES — aucune étape ne prime par la taille",
          "measure": "Math.max(...[1,2,3,4].map(i => box('.vl-nsteps__step:nth-child(' + i + ')').w)) / Math.min(...[1,2,3,4].map(i => box('.vl-nsteps__step:nth-child(' + i + ')').w))",
          "expect": 1,
          "tol": 0.005
        },
        {
          "name": "…et l'ÉTAPE ACCENTUÉE n'est pas plus large que les autres : l'accent ne se pose que par le fond",
          "measure": "box('.vl-nsteps__step--on').w / box('.vl-nsteps__step:nth-child(1)').w",
          "expect": 1,
          "tol": 0.005
        },
        {
          "name": "gouttière constante entre les quatre, à 1,65 % de la largeur",
          "measure": "(box('.vl-nsteps__step:nth-child(2)').left - box('.vl-nsteps__step:nth-child(1)').right) / W",
          "expect": 0.0165,
          "tol": 0.004
        },
        {
          "name": "RAIL DU HAUT — les quatre chiffres pendent du même plafond (écart nul, en fraction de la hauteur)",
          "measure": "(Math.max(...[1,2,3,4].map(i => box('.vl-nsteps__step:nth-child(' + i + ') .vl-nsteps__n').top)) - Math.min(...[1,2,3,4].map(i => box('.vl-nsteps__step:nth-child(' + i + ') .vl-nsteps__n').top))) / H",
          "expect": 0,
          "tol": 0.004
        },
        {
          "name": "RAIL DU BAS — les quatre textes poussent du même plancher (écart nul, en fraction de la hauteur)",
          "measure": "(Math.max(...[1,2,3,4].map(i => box('.vl-nsteps__step:nth-child(' + i + ') .vl-nsteps__txt').bottom)) - Math.min(...[1,2,3,4].map(i => box('.vl-nsteps__step:nth-child(' + i + ') .vl-nsteps__txt').bottom))) / H",
          "expect": 0,
          "tol": 0.004
        },
        {
          "name": "…et les deux rails tiennent alors même que les blocs de texte n'ont PAS la même hauteur — c'est là qu'une pile centrée ondulerait",
          "measure": "Math.max(...[1,2,3,4].map(i => box('.vl-nsteps__step:nth-child(' + i + ') .vl-nsteps__txt').h)) / Math.min(...[1,2,3,4].map(i => box('.vl-nsteps__step:nth-child(' + i + ') .vl-nsteps__txt').h))",
          "expect": 1.4,
          "tol": 0.35
        },
        {
          "name": "UN SEUL ACCENT dans toute la rangée — la règle de la charte, comptée",
          "measure": "document.querySelectorAll('.vl-nsteps__step--on').length",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "…et l'aplat sombre n'en est pas un second : son fond n'est pas la couleur d'accent",
          "measure": "getComputedStyle(document.querySelector('.vl-nsteps__step--end')).backgroundColor === getComputedStyle(document.querySelector('.vl-nsteps__step--on')).backgroundColor ? 1 : 0",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "L'ACCENT NE SE POSE QUE PAR LE FOND : ni bordure, ni ombre sur la carte accentuée",
          "measure": "num('.vl-nsteps__step--on','borderTopWidth') + (cs('.vl-nsteps__step--on','boxShadow') === 'none' ? 0 : 1)",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "…ni chiffre grossi : le corps du 02 est celui du 01",
          "measure": "num('.vl-nsteps__step--on .vl-nsteps__n','fontSize') / num('.vl-nsteps__step:nth-child(1) .vl-nsteps__n','fontSize')",
          "expect": 1,
          "tol": 0.01
        },
        {
          "name": "LA SURFACE ACCENTUÉE TIENT SOUS LE PLAFOND DE CHARTE : la carte jaune couvre 10,6 % d'une slide 1600×900, pour un plafond de 12 %",
          "measure": "(box('.vl-nsteps__step--on').w * box('.vl-nsteps__step--on').h) / (1600 * 900)",
          "expect": 0.106,
          "tol": 0.014
        },
        {
          "name": "CONTRE-ÉPREUVE — c'est bien la BORNE qui tient la règle : à hauteur libre de colonne (700), la même carte, toujours seule accentuée, dépasse le plafond de 40 %",
          "measure": "(() => { const r = document.querySelector('.vl-nsteps'); const old = r.style.getPropertyValue('--vl-nsteps-h'); r.style.setProperty('--vl-nsteps-h','700px'); r.getBoundingClientRect(); const b = document.querySelector('.vl-nsteps__step--on').getBoundingClientRect(); const out = (b.width * b.height) / (1600 * 900) / 0.12; if (old) r.style.setProperty('--vl-nsteps-h', old); else r.style.removeProperty('--vl-nsteps-h'); return out; })()",
          "expect": 1.4,
          "tol": 0.08
        },
        {
          "name": "le chiffre domine son libellé d'un facteur 4,7 — c'est le rang qui se lit en premier, pas l'intitulé",
          "measure": "num('.vl-nsteps__n','fontSize') / num('.vl-nsteps__label','fontSize')",
          "expect": 4.74,
          "tol": 0.3
        },
        {
          "name": "rayon en LONGUEUR, constante de charte : 32 px, soit 9 % de la largeur d'une carte",
          "measure": "num('.vl-nsteps__step','borderTopLeftRadius') / box('.vl-nsteps__step:nth-child(1)').w",
          "expect": 0.0925,
          "tol": 0.015
        },
        {
          "name": "L'ENCRE SUIT SON SOL, sur les trois fonds — le corps de chaque carte tient 4,5:1 (pire cas mesuré)",
          "measure": "Math.min(contrast('.vl-nsteps__step:nth-child(1) .vl-nsteps__body'), contrast('.vl-nsteps__step--on .vl-nsteps__body'), contrast('.vl-nsteps__step--end .vl-nsteps__body'))",
          "expect": 5.4,
          "tol": 0.9
        },
        {
          "name": "…et les libellés aussi, sur les trois fonds",
          "measure": "Math.min(contrast('.vl-nsteps__step:nth-child(1) .vl-nsteps__label'), contrast('.vl-nsteps__step--on .vl-nsteps__label'), contrast('.vl-nsteps__step--end .vl-nsteps__label'))",
          "expect": 14,
          "tol": 4
        },
        {
          "name": "rien ne déborde d'une carte",
          "measure": "[1,2,3,4].reduce((s,i) => s + overflow('.vl-nsteps__step:nth-child(' + i + ')'), 0)",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "AUTONOMIE TYPOGRAPHIQUE — un serif imposé à la page hôte ne change pas d'un pixel l'encre du chiffre : le fragment épingle sa famille",
          "measure": "(() => { const ink = () => { const r = document.createRange(); r.selectNodeContents(document.querySelector('.vl-nsteps__n')); return r.getBoundingClientRect().width; }; const a = ink(); const h = document.documentElement, old = h.style.fontFamily; h.style.fontFamily = 'Georgia, \"Times New Roman\", serif'; const b = ink(); h.style.fontFamily = old; return b / a; })()",
          "expect": 1,
          "tol": 0.01
        }
      ],
      "pptx": null,
      "html": "patterns/card-14-numbered-steps.html"
    },
    {
      "id": "chart-01-stadium-bars",
      "name": "Barres à sommet arrondi",
      "family": "chart",
      "ref": "ref-03-bento-dark-pitch",
      "media": [
        "slide",
        "social",
        "web"
      ],
      "intent": "Un histogramme réduit à l'essentiel : des barres à sommet en demi-cercle, aucune ligne d'axe, des légendes en pilules.",
      "when_to_use": "Trois à six valeurs d'une même série sur un axe temporel, dans un deck. La progression doit se voir en une seconde, sans lecture de grille.",
      "avoid_when": "Il faut comparer plusieurs séries, lire des valeurs précises, ou les écarts sont faibles — le sommet arrondi mange quelques pixels de hauteur et fausse la comparaison fine. Dans ce cas : barres droites et axe gradué.",
      "tags": [
        "graphique",
        "barres",
        "histogramme",
        "dataviz",
        "deck",
        "revenus",
        "progression",
        "arrondi",
        "axe"
      ],
      "vars": [
        {
          "name": "--vl-accent",
          "role": "barres intermédiaires",
          "default": "#CFE3F5"
        },
        {
          "name": "--vl-accent-2",
          "role": "barres intermédiaires",
          "default": "#8FBBE4"
        },
        {
          "name": "--vl-accent-3",
          "role": "barre de fin de série",
          "default": "#5E9BD6"
        },
        {
          "name": "--vl-surface-tile",
          "role": "fond des pilules d'axe",
          "default": "#3A3A3A"
        }
      ],
      "slots": [
        "values",
        "bars",
        "axis"
      ],
      "geometry": null,
      "benchmarks": [],
      "pptx": null,
      "html": "patterns/chart-01-stadium-bars.html"
    },
    {
      "id": "chart-02-isotype",
      "name": "Isotype à taux de remplissage",
      "family": "chart",
      "ref": "ref-03-bento-dark-pitch",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Raconter une proportion en unités comptables (24 pictogrammes sur 40) au lieu d'un pourcentage abstrait.",
      "when_to_use": "Une part qui porte sur des ÊTRES ou des OBJETS dénombrables — clients, sièges, sites, jours. Le lecteur voit la quantité avant de lire le chiffre.",
      "avoid_when": "La grandeur est continue (chiffre d'affaires, température, durée) : il n'y a rien à compter. Et au-delà d'environ 60 pictogrammes plus personne ne compte — passer à une barre.",
      "tags": [
        "graphique",
        "isotype",
        "pictogramme",
        "proportion",
        "pourcentage",
        "dataviz",
        "comptage",
        "part"
      ],
      "vars": [
        {
          "name": "--vl-accent-2",
          "role": "couleur de la part remplie",
          "default": "#8FBBE4"
        },
        {
          "name": "--vl-surface-light",
          "role": "couleur de la part vide",
          "default": "#FFFFFF"
        }
      ],
      "slots": [
        "figure",
        "label",
        "grid"
      ],
      "geometry": null,
      "benchmarks": [],
      "pptx": null,
      "html": "patterns/chart-02-isotype.html"
    },
    {
      "id": "chart-03-accent-column-callout",
      "name": "Histogramme à colonne accentuée et info-bulle",
      "family": "chart",
      "ref": "ref-13-glass-fintech-dashboard",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Donner la forme d'une série en quelques colonnes flottantes sans axe ni graduation, et n'en chiffrer qu'une seule — la courante — par une info-bulle sombre ancrée à sa colonne.",
      "when_to_use": "Une série courte (4 à 7 périodes) sert de contexte à UNE valeur qui, elle, doit être lue exactement : mois en cours, dernier trimestre, pic à commenter.",
      "avoid_when": "Plusieurs valeurs doivent être lues précisément (il faudrait autant d'info-bulles, et l'accent perd tout sens), ou les écarts entre colonnes sont faibles : sans axe, un écart de 5 % ne se voit pas.",
      "tags": [
        "histogramme",
        "barres",
        "colonnes",
        "accent",
        "info-bulle",
        "tooltip",
        "serie",
        "periode",
        "dashboard",
        "sans-axe",
        "verre"
      ],
      "vars": [
        {
          "name": "--vl-surface-glass",
          "role": "verre de la carte",
          "default": "rgba(255, 255, 255, 0.45)"
        },
        {
          "name": "--vl-surface-glass-strong",
          "role": "colonnes neutres et pilule de période",
          "default": "rgba(255, 255, 255, 0.68)"
        },
        {
          "name": "--vl-blue",
          "role": "pied du dégradé de la colonne accentuée",
          "default": "#4F7CF7"
        },
        {
          "name": "--vl-blue-light",
          "role": "sommet du dégradé de la colonne accentuée",
          "default": "#8FB6FA"
        },
        {
          "name": "--vl-surface-dark",
          "role": "aplat de l'info-bulle et de son point de repère",
          "default": "#101625"
        }
      ],
      "slots": [
        "title",
        "period",
        "columns",
        "callout"
      ],
      "geometry": {
        "root": ".vl-callout",
        "frame": [
          700,
          238
        ],
        "ratios": {
          "col_over_width": 0.0914,
          "pitch_over_col": 2.219,
          "bar_radius_over_col": 0.4219,
          "tall_over_height": 0.5882
        },
        "type_px": {
          "title": 15,
          "pill": 11,
          "cap": 10,
          "tip": 11
        },
        "pad_ratio": {
          "top": 0.0343,
          "x": 0.04,
          "bottom": 0.0343
        }
      },
      "benchmarks": [
        {
          "name": "une seule colonne accentuée",
          "measure": "document.querySelectorAll('.vl-callout__col--on').length",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "une seule info-bulle, et elle est portée par la colonne accentuée",
          "measure": "document.querySelectorAll('.vl-callout__tip').length + (document.querySelector('.vl-callout__col--on .vl-callout__tip') ? 0 : 10)",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "la colonne accentuée est la plus haute de la série",
          "measure": "box('.vl-callout__col--on .vl-callout__bar').h - Math.max(...[...document.querySelectorAll('.vl-callout__col:not(.vl-callout__col--on) .vl-callout__bar')].map((e) => e.getBoundingClientRect().height))",
          "min": 20
        },
        {
          "name": "le pas vaut environ le double de la largeur de colonne (le vide fait lire l'accent)",
          "measure": "(box('.vl-callout__col:nth-child(2)').left - box('.vl-callout__col:nth-child(1)').left) / box('.vl-callout__col').w",
          "expect": 2.22,
          "tol": 0.3
        },
        {
          "name": "les colonnes flottent : le rayon vaut ≈ 0,43 × la largeur, en haut ET en bas",
          "measure": "num('.vl-callout__bar','borderBottomLeftRadius') / box('.vl-callout__bar').w",
          "expect": 0.4219,
          "tol": 0.06
        },
        {
          "name": "la série remplit sa carte : la plus haute colonne dépasse 55 % de la hauteur",
          "measure": "box('.vl-callout__col--on .vl-callout__bar').h / H",
          "min": 0.55
        },
        {
          "name": "l'info-bulle reste dans la carte (ancrée à la colonne, elle peut en sortir)",
          "measure": "box('.vl-callout__box').right - W",
          "max": 0
        },
        {
          "name": "l'info-bulle partage la ligne d'en-tête sans percuter le titre ni la pilule de période",
          "measure": "Math.min(box('.vl-callout__box').left - box('.vl-callout__title').right, box('.vl-callout__pill').left - box('.vl-callout__box').right)",
          "min": 8
        },
        {
          "name": "contraste du texte de l'info-bulle sur son aplat ≥ 7",
          "measure": "contrast('.vl-callout__box')",
          "min": 7
        },
        {
          "name": "contraste des légendes de période ≥ 4,5 malgré leur petit corps",
          "measure": "contrast('.vl-callout__cap')",
          "min": 4.5
        },
        {
          "name": "aucun débordement du contenu hors de la carte",
          "measure": "overflow('.vl-callout')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/chart-03-accent-column-callout.html"
    },
    {
      "id": "chart-04-unit-textured-bar",
      "name": "Barre à unité déclarée",
      "family": "chart",
      "ref": "ref-16-cobalt-graph-paper",
      "media": [
        "print",
        "slide",
        "social",
        "web"
      ],
      "intent": "Donner à un histogramme une UNITÉ visible sans lui faire porter le comptage : la hauteur de la barre est exactement proportionnelle à la valeur, la texture qui la remplit est un peigne dont la période vaut une unité déclarée, et le montant exact est écrit au-dessus — la texture donne l'échelle, elle ne compte jamais à la place du chiffre.",
      "when_to_use": "Sur une comparaison de deux à quatre montants dont l'écart EST le message (coût cumulé, budget, dette), quand le lecteur doit sentir l'ordre de grandeur avant de lire les chiffres. Excellent sur une charte à deux couleurs, où la texture remplace l'aplat plein sans rien coûter en palette.",
      "avoid_when": "Au-delà de quatre barres — le peigne devient du bruit moiré ; sur une série temporelle, où l'unité de l'axe suffit ; ou quand la plus petite valeur descend sous deux périodes, la texture disparaissant alors dans un trait plein qui MENT sur l'échelle (un benchmark le refuse).",
      "tags": [
        "histogramme",
        "barres",
        "unité",
        "échelle",
        "texture",
        "peigne",
        "coût",
        "comparaison",
        "cumulé",
        "isotype",
        "deux couleurs",
        "monochrome"
      ],
      "vars": [
        {
          "name": "--vl-utb-period",
          "role": "période du peigne = UNE unité déclarée, à l'échelle du tracé",
          "default": "14.286px"
        },
        {
          "name": "--vl-ink",
          "role": "encre des barres et du chrome",
          "default": "#1F2BE0"
        },
        {
          "name": "--vl-ink-faint",
          "role": "barre atténuée — la valeur qu'on ne veut PAS voir dominer",
          "default": "rgba(31,43,224,0.20)"
        },
        {
          "name": "--vl-paper",
          "role": "papier, et creux du peigne",
          "default": "#F0EBDE"
        },
        {
          "name": "--vl-near",
          "role": "encre du montant écrit",
          "default": "#14163A"
        },
        {
          "name": "--vl-rule",
          "role": "épaisseur de la ligne de base et du liseré",
          "default": "1.25px"
        }
      ],
      "slots": [
        {
          "name": "value",
          "role": "le montant EXACT, écrit au-dessus de chaque barre — jamais facultatif"
        },
        {
          "name": "label",
          "role": "l'identité de la barre, sur UNE ligne"
        },
        {
          "name": "unit",
          "role": "la phrase qui déclare l'unité et l'exactitude de l'échelle — c'est le contrat du pattern"
        }
      ],
      "geometry": {
        "root": ".vl-utb",
        "frame": [
          704,
          620
        ],
        "ratios": {
          "plot_height_over_width": 0.568,
          "gap_over_width": 0.08,
          "period_over_plot_height": 0.0357,
          "value_over_label": 2.14
        },
        "type_px": {
          "value": 47,
          "label": 22,
          "unit": 22
        },
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "LE CONTRAT — le rapport des hauteurs EST le rapport des valeurs",
          "measure": "(box('.vl-utb__col:nth-child(2) .vl-utb__bar').h / box('.vl-utb__col:nth-child(1) .vl-utb__bar').h) / (Number($('.vl-utb__col:nth-child(2) .vl-utb__bar').dataset.value) / Number($('.vl-utb__col:nth-child(1) .vl-utb__bar').dataset.value))",
          "expect": 1,
          "tol": 0.01
        },
        {
          "name": "le contrat tient aussi sur la plus petite barre",
          "measure": "(box('.vl-utb__col:nth-child(3) .vl-utb__bar').h / box('.vl-utb__col:nth-child(1) .vl-utb__bar').h) / (Number($('.vl-utb__col:nth-child(3) .vl-utb__bar').dataset.value) / Number($('.vl-utb__col:nth-child(1) .vl-utb__bar').dataset.value))",
          "expect": 1,
          "tol": 0.01
        },
        {
          "name": "la période du peigne vaut EXACTEMENT l'unité déclarée",
          "measure": "parseFloat(getComputedStyle($('.vl-utb')).getPropertyValue('--vl-utb-period')) / (Number($('.vl-utb').dataset.unit) * Number($('.vl-utb').dataset.scaleH) / Number($('.vl-utb').dataset.scaleV))",
          "expect": 1,
          "tol": 0.02
        },
        {
          "name": "la plus petite barre garde au moins DEUX périodes — sinon la texture ment",
          "measure": "box('.vl-utb__col:nth-child(3) .vl-utb__bar').h / parseFloat(getComputedStyle($('.vl-utb')).getPropertyValue('--vl-utb-period'))",
          "min": 2
        },
        {
          "name": "chaque barre porte son montant écrit",
          "measure": "[1,2,3].every((i) => text('.vl-utb__col:nth-child(' + i + ') .vl-utb__value').length > 0) ? 1 : 0",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "le montant est AU-DESSUS de la barre, jamais dedans",
          "measure": "box('.vl-utb__col:nth-child(1) .vl-utb__value').bottom <= box('.vl-utb__col:nth-child(1) .vl-utb__bar').top ? 1 : 0",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "les barres reposent sur UNE ligne de base commune",
          "measure": "Math.max(box('.vl-utb__col:nth-child(1) .vl-utb__bar').bottom, box('.vl-utb__col:nth-child(2) .vl-utb__bar').bottom, box('.vl-utb__col:nth-child(3) .vl-utb__bar').bottom) - Math.min(box('.vl-utb__col:nth-child(1) .vl-utb__bar').bottom, box('.vl-utb__col:nth-child(2) .vl-utb__bar').bottom, box('.vl-utb__col:nth-child(3) .vl-utb__bar').bottom)",
          "expect": 0,
          "tol": 0.5
        },
        {
          "name": "la phrase d'unité est présente — c'est elle qui rend le peigne lisible",
          "measure": "text('.vl-utb__unit').length > 12 ? 1 : 0",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "le montant écrit domine son libellé d'un cran franc",
          "measure": "num('.vl-utb__value','fontSize') / num('.vl-utb__label','fontSize')",
          "expect": 2.14,
          "tol": 0.25
        }
      ],
      "pptx": {
        "primitive": "textured_bar",
        "note": "En .pptx le peigne se fait en remplissage à motif (pattern fill) à pas fixe, pas en N rectangles empilés : au-delà de ~30 crans le fichier devient illisible à l'édition, et c'est le fichier que le client ouvre."
      },
      "html": "patterns/chart-04-unit-textured-bar.html"
    },
    {
      "id": "chart-05-tile-heatmap",
      "name": "Heatmap en tuiles arrondies, rampe achromatique + marque de catégorie",
      "family": "chart",
      "ref": "ref-18-lime-sales-dashboard",
      "media": [
        "web",
        "slide"
      ],
      "intent": "Lire une matrice canal × période d'un seul coup d'œil, en faisant cohabiter DEUX échelles dans la même grille sans qu'elles se mélangent : une rampe de gris qui encode l'intensité (le noir est le maximum), et une couleur d'accent qui n'est PAS un cran de cette rampe mais une marque de catégorie posée par-dessus. Les cellules sont des tuiles arrondies largement espacées — jamais un damier jointif.",
      "when_to_use": "Matrice de quatre à six lignes sur quatre à huit colonnes dont on veut la SILHOUETTE et non les valeurs : performance par canal et par semaine, densité d'activité, couverture. Les libellés de lignes vivent hors de la grille, à gauche, sans filet de séparation.",
      "avoid_when": "Le lecteur doit relire une valeur précise (une heatmap ne se lit pas au chiffre près — il faut un tableau), la matrice dépasse huit colonnes (les tuiles deviennent des traits et le rayon n'a plus de sens), ou l'intensité est déjà encodée par une couleur : deux échelles chromatiques dans une même grille sont indémêlables, et c'est exactement ce que l'achromatisme de la rampe évite ici.",
      "tags": [
        "heatmap",
        "matrice",
        "grille",
        "intensite",
        "rampe",
        "canal",
        "dashboard",
        "tuile",
        "categorie",
        "accent",
        "achromatique",
        "densite"
      ],
      "vars": [
        {
          "name": "--vl-module",
          "role": "aplat du module qui porte la matrice",
          "default": "#F2F2F2"
        },
        {
          "name": "--vl-app",
          "role": "aplat des pilules d'outil du module",
          "default": "#FEFEFE"
        },
        {
          "name": "--vl-ramp-1",
          "role": "cran 1 de la rampe — le plus faible",
          "default": "#E3E3E3"
        },
        {
          "name": "--vl-ramp-2",
          "role": "cran 2",
          "default": "#CCCCCC"
        },
        {
          "name": "--vl-ramp-3",
          "role": "cran 3",
          "default": "#999999"
        },
        {
          "name": "--vl-ramp-4",
          "role": "cran 4",
          "default": "#666666"
        },
        {
          "name": "--vl-ramp-5",
          "role": "cran 5 — le maximum de l'échelle d'intensité",
          "default": "#000000"
        },
        {
          "name": "--vl-accent",
          "role": "marque de CATÉGORIE — hors rampe, jamais un cran de plus",
          "default": "#5FE85C"
        },
        {
          "name": "--vl-ink",
          "role": "titre et libellés de lignes",
          "default": "#0B0B0B"
        },
        {
          "name": "--vl-ink-soft",
          "role": "sous-titre du module",
          "default": "#6B6B6B"
        },
        {
          "name": "--vl-radius-tile",
          "role": "rayon d'une cellule",
          "default": "20px"
        }
      ],
      "slots": [
        "title",
        "sub",
        "tools",
        "rows",
        "cells"
      ],
      "geometry": {
        "root": ".vl-heat",
        "frame": [
          605,
          431
        ],
        "ratios": {
          "label_col_over_width": 0.248,
          "cell_gap_x_over_width": 0.026,
          "cell_gap_y_over_width": 0.023,
          "cell_ratio_w_over_h": 1.67,
          "radius_over_cell_height": 0.38
        },
        "type_px": {
          "title": 27,
          "sub": 16,
          "row": 17
        },
        "pad_ratio": {
          "top": 0.065,
          "x": 0.05,
          "bottom": 0.065
        }
      },
      "benchmarks": [
        {
          "name": "les cellules sont des TUILES, pas un damier : le rayon vaut au moins un tiers de leur hauteur",
          "measure": "num('.vl-heat__cell','borderTopLeftRadius') / box('.vl-heat__cell').h",
          "min": 0.33
        },
        {
          "name": "… et jamais des pilules : le rayon reste sous la demi-hauteur",
          "measure": "num('.vl-heat__cell','borderTopLeftRadius') / box('.vl-heat__cell').h",
          "max": 0.5
        },
        {
          "name": "cellule nettement paysage",
          "measure": "box('.vl-heat__cell').w / box('.vl-heat__cell').h",
          "min": 1.2
        },
        {
          "name": "les tuiles respirent : la gouttière vaut au moins 12 % de la largeur d'une cellule",
          "measure": "num('.vl-heat__grid','columnGap') / box('.vl-heat__cell').w",
          "min": 0.12
        },
        {
          "name": "les libellés de lignes sont HORS de la grille de tuiles",
          "measure": "box('.vl-heat__cell').left - box('.vl-heat__row').right",
          "min": 8
        },
        {
          "name": "aucun filet de séparation entre les lignes",
          "measure": "num('.vl-heat__row','borderBottomWidth')",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "LA RAMPE EST ACHROMATIQUE : aucun de ses cinq crans n'a de saturation",
          "measure": "['1','2','3','4','5'].reduce((m, n) => { const c = parseRgb(cs('.vl-heat__cell[data-ramp=\"' + n + '\"]','backgroundColor')); return Math.max(m, Math.max(...c) - Math.min(...c)); }, 0)",
          "max": 6
        },
        {
          "name": "L'ACCENT N'EST PAS UN CRAN DE LA RAMPE : il est franchement coloré",
          "measure": "(() => { const c = parseRgb(cs('.vl-heat__cell[data-v=\"on\"]','backgroundColor')); return Math.max(...c) - Math.min(...c); })()",
          "min": 60
        },
        {
          "name": "la rampe est MONOTONE : chaque cran est plus sombre que le précédent",
          "measure": "(() => { const l = ['1','2','3','4','5'].map((n) => lum(parseRgb(cs('.vl-heat__cell[data-ramp=\"' + n + '\"]','backgroundColor')))); return l.every((v, i) => i === 0 || v < l[i - 1]) ? 1 : 0; })()",
          "expect": 1
        },
        {
          "name": "l'accent reste minoritaire : au plus un quart des cellules le portent",
          "measure": "document.querySelectorAll('.vl-heat__cell[data-v=\"on\"]').length / document.querySelectorAll('.vl-heat__cell').length",
          "max": 0.28
        },
        {
          "name": "le titre et son sous-titre sont collés : moins d'un demi-corps de sous-titre les sépare",
          "measure": "(box('.vl-heat__sub').top - box('.vl-heat__title').bottom) / num('.vl-heat__sub','fontSize')",
          "max": 0.5
        },
        {
          "name": "les outils sont poussés au bord droit du module",
          "measure": "W - num('.vl-heat','paddingRight') - box('.vl-heat__tools').right",
          "expect": 0,
          "tol": 1.5
        },
        {
          "name": "saut typographique titre / libellé de ligne ≥ 1,4",
          "measure": "num('.vl-heat__title','fontSize') / num('.vl-heat__row','fontSize')",
          "min": 1.4
        },
        {
          "name": "contraste des libellés de lignes ≥ 4,5",
          "measure": "contrast('.vl-heat__row')",
          "min": 4.5
        },
        {
          "name": "contraste du sous-titre ≥ 4,5",
          "measure": "contrast('.vl-heat__sub')",
          "min": 4.5
        },
        {
          "name": "aucun débordement du module",
          "measure": "overflow('.vl-heat')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/chart-05-tile-heatmap.html"
    },
    {
      "id": "diagram-01-layer-stack",
      "name": "Pile de couches isométriques légendées",
      "family": "diagram",
      "ref": "ref-14-layer-stack-coral",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Montrer qu'un objet — un produit, une marque, un système logiciel — se décompose en couches ORDONNÉES dont le rang est l'information : des losanges isométriques empilés, d'opacité décroissante, chacun relié par un filet au nom de sa couche et à une ligne d'explication.",
      "when_to_use": "Trois à cinq couches, un ordre qui compte (ce qu'on touche en haut, ce qui porte en bas), et une phrase à dire sur chacune : interface / services / données, essence / valeur / vision, produit / méthode / recherche.",
      "avoid_when": "Les éléments sont de même rang (une pile dit une hiérarchie, même quand on ne la pense pas), leur nombre dépasse cinq (les losanges du bas s'effacent avant d'être comptés), ou une grandeur doit se lire : ici rien n'est proportionnel à rien — c'est un schéma, pas un graphique.",
      "tags": [
        "schema",
        "diagramme",
        "couches",
        "layers",
        "pile",
        "empilement",
        "isometrique",
        "losange",
        "architecture",
        "stack",
        "frontend",
        "backend",
        "hierarchie",
        "filet",
        "legende",
        "opacite",
        "profondeur"
      ],
      "vars": [
        {
          "name": "--vl-coral",
          "role": "la teinte UNIQUE des couches — c'est l'opacité, pas la couleur, qui distingue les rangs",
          "default": "#E4886A"
        },
        {
          "name": "--vl-layer-alpha-1",
          "role": "opacité de la couche de tête (la seule pleine)",
          "default": "1"
        },
        {
          "name": "--vl-layer-alpha-2",
          "role": "opacité de la deuxième couche",
          "default": "0.44"
        },
        {
          "name": "--vl-layer-alpha-3",
          "role": "opacité de la troisième couche — même rapport à la deuxième que la deuxième à la première",
          "default": "0.18"
        },
        {
          "name": "--vl-ink",
          "role": "le nom de la couche",
          "default": "#3A3835"
        },
        {
          "name": "--vl-ink-muted",
          "role": "la ligne d'explication",
          "default": "#6E6863"
        },
        {
          "name": "--vl-hairline",
          "role": "le filet de rappel entre un plan et son libellé",
          "default": "#97918C"
        },
        {
          "name": "--vl-track-title",
          "role": "approche des titres en capitales",
          "default": "0.13em"
        },
        {
          "name": "--vl-track-body",
          "role": "approche du corps en capitales",
          "default": "0.05em"
        }
      ],
      "slots": [
        "name",
        "note"
      ],
      "geometry": {
        "root": ".vl-layers",
        "frame": [
          1040,
          420
        ],
        "ratios": {
          "plane_h_over_w": 0.53,
          "pitch_over_plane_h": 0.571,
          "plane_w_over_width": 0.317,
          "rule_start_over_plane_w": 0.655,
          "rule_gap_over_width": 0.027,
          "label_x_over_width": 0.413
        },
        "type_px": {
          "name": 22,
          "note": 14
        },
        "pad_ratio": {
          "top": 0.0212,
          "x": 0,
          "bottom": 0.0212
        }
      },
      "benchmarks": [
        {
          "name": "un plan est un carré en projection isométrique : deux fois plus large que haut",
          "measure": "box('.vl-layers__plane--1').h / box('.vl-layers__plane--1').w",
          "expect": 0.53,
          "tol": 0.04
        },
        {
          "name": "le pas est le même entre toutes les couches (une pile irrégulière se lit comme un accident)",
          "measure": "Math.abs((box('.vl-layers__plane--2').top - box('.vl-layers__plane--1').top) - (box('.vl-layers__plane--3').top - box('.vl-layers__plane--2').top))",
          "max": 1
        },
        {
          "name": "le pas vaut 0,57 hauteur de plan : assez pour que les plans se distinguent, assez peu pour qu'ils fassent pile",
          "measure": "(box('.vl-layers__plane--2').top - box('.vl-layers__plane--1').top) / box('.vl-layers__plane--1').h",
          "expect": 0.571,
          "tol": 0.05
        },
        {
          "name": "le centre d'une couche échappe au plan qui la surplombe — sans quoi son filet ne peut pas en sortir",
          "measure": "Math.min(...[[1,2],[2,3]].map(([a,b]) => (box('.vl-layers__plane--'+b).top + box('.vl-layers__plane--'+b).h/2) - box('.vl-layers__plane--'+a).bottom))",
          "min": 2
        },
        {
          "name": "l'opacité décroît d'un rapport CONSTANT d'une couche à la suivante (progression géométrique)",
          "measure": "(num('.vl-layers__plane--2','opacity') / num('.vl-layers__plane--1','opacity')) / (num('.vl-layers__plane--3','opacity') / num('.vl-layers__plane--2','opacity'))",
          "expect": 1,
          "tol": 0.15
        },
        {
          "name": "la couche de tête est la seule pleine",
          "measure": "num('.vl-layers__plane--1','opacity')",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "la dernière couche est un fantôme : au moins 4 fois plus effacée que la première",
          "measure": "num('.vl-layers__plane--1','opacity') / num('.vl-layers__plane--3','opacity')",
          "min": 4
        },
        {
          "name": "chaque filet est à la hauteur du centre de SA couche : la correspondance est géométrique, pas déduite de l'ordre",
          "measure": "Math.max(...[1,2,3].map(i => Math.abs(box('.vl-layers__row--'+i+' .vl-layers__rule').top - (box('.vl-layers__plane--'+i).top + box('.vl-layers__plane--'+i).h/2))))",
          "max": 2
        },
        {
          "name": "chaque filet touche le milieu de la ligne de titre, pas l'interligne sous elle",
          "measure": "Math.max(...[1,2,3].map(i => Math.abs(box('.vl-layers__row--'+i+' .vl-layers__rule').top - (box('.vl-layers__row--'+i+' .vl-layers__name').top + box('.vl-layers__row--'+i+' .vl-layers__name').h/2))))",
          "max": 2
        },
        {
          "name": "le filet naît SOUS le losange (à 0,655 de sa largeur) : sa longueur visible est dictée par la forme",
          "measure": "(box('.vl-layers__row--1 .vl-layers__rule').left - box('.vl-layers__plane--1').left) / box('.vl-layers__plane--1').w",
          "expect": 0.655,
          "tol": 0.05
        },
        {
          "name": "le filet s'arrête avant le texte : il désigne, il ne souligne pas",
          "measure": "(box('.vl-layers__row--1 .vl-layers__name').left - box('.vl-layers__row--1 .vl-layers__rule').right) / W",
          "expect": 0.027,
          "tol": 0.008
        },
        {
          "name": "la colonne de libellés et la pile occupent la même bande verticale",
          "measure": "Math.abs((box('.vl-layers__row--1').top + box('.vl-layers__row--3').bottom) / 2 - (box('.vl-layers__plane--1').top + box('.vl-layers__plane--3').bottom) / 2) / H",
          "max": 0.05
        },
        {
          "name": "plancher de lisibilité : le corps ne descend pas sous 1,2 % de la largeur du schéma",
          "measure": "num('.vl-layers__note','fontSize') / W",
          "min": 0.012
        },
        {
          "name": "contraste de la ligne d'explication sur le fond ≥ 4,5",
          "measure": "contrast('.vl-layers__note')",
          "min": 4.5
        },
        {
          "name": "aucun débordement hors du cadre du schéma",
          "measure": "overflow('.vl-layers')",
          "max": 0
        }
      ],
      "pptx": {
        "primitive": "diamond_stack",
        "emitter": "kit/vl_pptx.py:layer_stack",
        "pattern": "diagram-01-layer-stack",
        "note": "Le losange est un `MSO_SHAPE.DIAMOND` — pour une fois la forme native de PowerPoint EST exactement le `clip-path` du fragment, elle n'a aucun ajustement qui dérive avec la proportion (contrairement au snip du chanfrein, cf. `notched_rect`). Le point dur est ailleurs : python-pptx n'expose AUCUNE transparence, or ici l'opacité N'EST PAS un effet mais l'information elle-même — sans elle les trois plans sortent identiques et le rang disparaît. Elle s'écrit donc dans le XML du remplissage (`<a:alpha>`), et le moteur de rendu composite les recouvrements comme le navigateur. La hauteur n'est pas un argument de l'émetteur : elle se déduit du nombre de couches et du pas, sinon on pourrait écraser la pile depuis l'appel."
      },
      "html": "patterns/diagram-01-layer-stack.html"
    },
    {
      "id": "diagram-02-branch-timeline",
      "name": "Timeline à branches en équerre",
      "family": "diagram",
      "ref": "ref-19-acid-clinical-timeline",
      "media": [
        "web",
        "slide"
      ],
      "intent": "Accrocher des objets de niveaux différents à une chronologie sans jamais tracer une diagonale : un rail horizontal daté, des jalons ronds accentués posés DESSUS, et des dérivations en équerre arrondie — descente verticale puis départ horizontal — qui mènent aux objets de la période. La diagonale dirait « transition » ; l'équerre dit « appartenance », et c'est la seule chose que ce diagramme a à dire.",
      "when_to_use": "Historique d'un dossier où chaque période porte plusieurs objets hétérogènes (un traitement, une mesure, un examen) : la position sur le rail donne la date, l'équerre donne le rattachement, et la taille de l'objet dérivé donne son rang. Deux à quatre branches par vue.",
      "avoid_when": "Les objets sont tous de même nature et de même rang (une simple liste datée est plus lisible), les dates doivent être proportionnelles (rien ici n'est à l'échelle du temps : c'est une structure, pas un graphique), ou il y a plus de cinq branches — les équerres se croisent et l'appartenance redevient illisible.",
      "tags": [
        "timeline",
        "chronologie",
        "branche",
        "equerre",
        "rail",
        "jalon",
        "historique",
        "dossier",
        "structure",
        "derivation",
        "diagramme"
      ],
      "vars": [
        {
          "name": "--vl-sheet",
          "role": "le SOL du diagramme — gris, pour que les objets blancs qu'on y pose montent sans liseré",
          "default": "#DFDFD7"
        },
        {
          "name": "--vl-accent",
          "role": "aplat des jalons — ce qu'il faut compter, jamais ce qui va mal",
          "default": "#F5FE49"
        },
        {
          "name": "--vl-veil-deep",
          "role": "aplat du seul jalon d'action (ajouter)",
          "default": "#1B1D1D"
        },
        {
          "name": "--vl-ink",
          "role": "libellé de période",
          "default": "#262C2C"
        },
        {
          "name": "--vl-ink-soft",
          "role": "sous-libellé, tracé des équerres",
          "default": "#5F615E"
        },
        {
          "name": "--vl-module",
          "role": "aplat d'un objet dérivé de second rang (pilule)",
          "default": "#F0EFEB"
        },
        {
          "name": "--vl-module-hi",
          "role": "aplat d'un objet dérivé de premier rang (carte)",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-recover",
          "role": "extrémité « amélioration » du dégradé de rail — le pendant de --vl-alert",
          "default": "#7FA88C"
        },
        {
          "name": "--vl-alert",
          "role": "extrémité « dégradation » du dégradé de rail",
          "default": "#E8756B"
        }
      ],
      "slots": [
        "rail",
        "pins",
        "branches",
        "objects"
      ],
      "geometry": {
        "root": ".vl-branchline",
        "frame": [
          1330,
          300
        ],
        "ratios": {
          "rail_thickness_over_height": 0.0067,
          "pin_over_height": 0.153,
          "elbow_drop_over_height": 0.32,
          "elbow_radius_over_drop": 0.146
        },
        "type_px": {
          "month": 22,
          "week": 13
        },
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "le rail est un FILET horizontal, pas une barre",
          "measure": "box('.vl-branchline__rail').h",
          "max": 3
        },
        {
          "name": "… et il traverse la vue : au moins 90 % de la largeur",
          "measure": "box('.vl-branchline__rail').w / W",
          "min": 0.9
        },
        {
          "name": "les jalons sont posés SUR le rail : leur centre coïncide avec lui",
          "measure": "(() => { const r = box('.vl-branchline__rail'); return Math.max(...[...document.querySelectorAll('.vl-branchline__pin')].map((e) => { const b = e.getBoundingClientRect(); const c = b.top + b.height / 2 - (window.scrollY || 0); return Math.abs((c - $(window.__VL_ROOT__).getBoundingClientRect().top) - (r.top + r.h / 2)); })); })()",
          "max": 2.5
        },
        {
          "name": "un jalon est un disque",
          "measure": "box('.vl-branchline__pin').w / box('.vl-branchline__pin').h",
          "expect": 1,
          "tol": 0.02
        },
        {
          "name": "les jalons de période portent l'accent — ce qu'il faut compter",
          "measure": "(() => { const c = parseRgb(cs('.vl-branchline__pin','backgroundColor')); return Math.max(...c) - Math.min(...c); })()",
          "min": 60
        },
        {
          "name": "UN SEUL jalon d'action, et il est sombre",
          "measure": "[...document.querySelectorAll('.vl-branchline__pin')].filter((e) => lum(parseRgb(getComputedStyle(e).backgroundColor)) < 0.2).length",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "L'ÉQUERRE DESCEND D'ABORD, PART À L'HORIZONTALE ENSUITE",
          "measure": "(() => { const d = $('.vl-branchline__elbow path').getAttribute('d'); return /v/.test(d) && /h/.test(d) && d.indexOf('v') < d.indexOf('h') ? 1 : 0; })()",
          "expect": 1
        },
        {
          "name": "… et JAMAIS en diagonale : aucun segment oblique dans le tracé",
          "measure": "/[lL]/.test($('.vl-branchline__elbow path').getAttribute('d')) ? 1 : 0",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "le coude est ARRONDI : le tracé porte un arc",
          "measure": "/[aA]/.test($('.vl-branchline__elbow path').getAttribute('d')) ? 1 : 0",
          "expect": 1
        },
        {
          "name": "l'équerre part du jalon : leurs abscisses coïncident à quelques pixels près",
          "measure": "Math.abs((box('.vl-branchline__elbow').left + 12) - (box('.vl-branchline__pin').left + box('.vl-branchline__pin').w / 2))",
          "max": 14
        },
        {
          "name": "tout ce qui est dérivé est SOUS le rail",
          "measure": "(() => { const r = box('.vl-branchline__rail'); return Math.min(...[...document.querySelectorAll('.vl-branchline__obj')].map((e) => box('#' + e.id).top)) - (r.top + r.h); })()",
          "min": 20
        },
        {
          "name": "l'objet dérivé commence APRÈS le départ horizontal de son équerre",
          "measure": "box('#obj-a').left - box('.vl-branchline__elbow').right",
          "min": -4
        },
        {
          "name": "l'objet de premier rang est plus large que celui de second rang",
          "measure": "box('#obj-b').w / box('#obj-a').w",
          "min": 1.3
        },
        {
          "name": "le sous-libellé est collé sous le libellé de période",
          "measure": "(box('.vl-branchline__week').top - box('.vl-branchline__month').bottom) / num('.vl-branchline__week','fontSize')",
          "max": 0.6
        },
        {
          "name": "contraste du libellé de période ≥ 4,5",
          "measure": "contrast('.vl-branchline__month')",
          "min": 4.5
        },
        {
          "name": "contraste du sous-libellé ≥ 4,5",
          "measure": "contrast('.vl-branchline__week')",
          "min": 4.5
        },
        {
          "name": "les objets dérivés sont PLUS CLAIRS que le sol qui les porte",
          "measure": "lum(parseRgb(cs('#obj-b','backgroundColor'))) - lum(parseRgb(cs('.vl-branchline','backgroundColor')))",
          "min": 0.05
        },
        {
          "name": "aucun débordement",
          "measure": "overflow('.vl-branchline')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/diagram-02-branch-timeline.html"
    },
    {
      "id": "diagram-03-time-scrubber",
      "name": "Ruban temporel à fenêtre de sélection",
      "family": "diagram",
      "ref": "ref-19-acid-clinical-timeline",
      "media": [
        "web"
      ],
      "intent": "Donner à un écran sa position de lecture dans un axe plus long que lui : l'année entière tient dans une pilule claire — mois écrits, événements en pastilles rondes, comptage en exposant acide —, et une FENÊTRE SOMBRE À POIGNÉES posée dessus marque l'intervalle affiché au-dessus. La fenêtre reprend les mêmes pastilles en inverse : c'est ce qui dit qu'on regarde le même axe, et pas un second objet.",
      "when_to_use": "Sous une vue détaillée qui ne montre qu'une tranche d'un historique long : dossier, journal, calendrier de production. L'axe entier reste lisible, la tranche courante est désignée, et le lecteur sait toujours où il est sans changer d'écran.",
      "avoid_when": "L'axe tient entièrement dans la vue (le ruban ne désigne alors plus rien), les événements sont trop nombreux pour être des pastilles distinctes (il faut une densité, pas des objets), ou la sélection n'est pas déplaçable — des poignées qui ne se saisissent pas sont un mensonge d'interface.",
      "tags": [
        "timeline",
        "ruban",
        "scrubber",
        "brush",
        "navigateur",
        "selection",
        "fenetre",
        "axe",
        "historique",
        "mois",
        "evenement",
        "poignee",
        "minimap"
      ],
      "vars": [
        {
          "name": "--vl-module-hi",
          "role": "aplat du ruban",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-module",
          "role": "aplat d'une pastille d'événement, hors fenêtre",
          "default": "#F0EFEB"
        },
        {
          "name": "--vl-veil-deep",
          "role": "aplat de la fenêtre de sélection ET du repère « aujourd'hui »",
          "default": "#1B1D1D"
        },
        {
          "name": "--vl-accent",
          "role": "pastille de COMPTAGE, en exposant — jamais une alerte",
          "default": "#F5FE49"
        },
        {
          "name": "--vl-ink",
          "role": "libellés de mois hors fenêtre",
          "default": "#262C2C"
        },
        {
          "name": "--vl-ink-soft",
          "role": "millésime, séparateurs",
          "default": "#5F615E"
        },
        {
          "name": "--vl-on-veil",
          "role": "libellés dans la fenêtre",
          "default": "#FFFFFF"
        }
      ],
      "slots": [
        "now",
        "scale",
        "window"
      ],
      "geometry": {
        "root": ".vl-scrub",
        "frame": [
          1330,
          74
        ],
        "ratios": {
          "now_over_height": 0.784,
          "event_over_height": 0.514,
          "badge_over_event": 0.474,
          "window_over_width": 0.301,
          "grip_over_height": 0.351
        },
        "type_px": {
          "month": 14,
          "badge": 11
        },
        "pad_ratio": {
          "top": 0,
          "x": 0.006,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "le ruban est une pilule pleine",
          "measure": "num('.vl-scrub','borderTopLeftRadius') / H",
          "min": 0.5
        },
        {
          "name": "ruban bas : sa hauteur vaut au plus 8 % de sa largeur",
          "measure": "H / W",
          "max": 0.08
        },
        {
          "name": "LA FENÊTRE EST SOMBRE là où le ruban est clair",
          "measure": "lum(parseRgb(cs('.vl-scrub','backgroundColor'))) / lum(parseRgb(cs('.vl-scrub__window','backgroundColor')))",
          "min": 6
        },
        {
          "name": "… et c'est une pilule, comme le ruban qui la porte",
          "measure": "num('.vl-scrub__window','borderTopLeftRadius') / box('.vl-scrub__window').h",
          "min": 0.5
        },
        {
          "name": "la fenêtre est une TRANCHE : entre 10 % et 45 % de l'axe",
          "measure": "box('.vl-scrub__window').w / W",
          "min": 0.1,
          "max": 0.45
        },
        {
          "name": "la fenêtre tient DANS le ruban, jamais à côté",
          "measure": "Math.min(box('.vl-scrub__window').left, W - box('.vl-scrub__window').right)",
          "min": 0
        },
        {
          "name": "DEUX poignées, une à chaque extrémité de la fenêtre",
          "measure": "document.querySelectorAll('.vl-scrub__grip').length",
          "expect": 2,
          "tol": 0
        },
        {
          "name": "les poignées sont AUX BORDS de la fenêtre, pas au milieu",
          "measure": "(() => { const w = box('.vl-scrub__window'); const g = [...document.querySelectorAll('.vl-scrub__grip')].map((e) => e.getBoundingClientRect()); const r = $(window.__VL_ROOT__).getBoundingClientRect(); const xs = g.map((b) => b.left + b.width / 2 - r.left); return Math.max(Math.abs(Math.min(...xs) - w.left), Math.abs(Math.max(...xs) - w.right)); })()",
          "max": 24
        },
        {
          "name": "les pastilles de la fenêtre ont la MÊME taille que celles du ruban — c'est le même axe",
          "measure": "box('.vl-scrub__window .vl-scrub__ev').h / box('.vl-scrub__scale .vl-scrub__ev').h",
          "expect": 1,
          "tol": 0.02
        },
        {
          "name": "… mais elles sont INVERSÉES : plus sombres que celles du ruban",
          "measure": "lum(bgOf($('.vl-scrub__scale .vl-scrub__ev'))) - lum(bgOf($('.vl-scrub__window .vl-scrub__ev')))",
          "min": 0.3
        },
        {
          "name": "une pastille d'événement est un disque",
          "measure": "box('.vl-scrub__ev').w / box('.vl-scrub__ev').h",
          "expect": 1,
          "tol": 0.02
        },
        {
          "name": "LE COMPTAGE EST EN EXPOSANT : la pastille de comptage déborde en haut ET à droite de son événement",
          "measure": "(box('.vl-scrub__ev').top - box('.vl-scrub__n').top > 0 && box('.vl-scrub__n').right - box('.vl-scrub__ev').right > 0) ? 1 : 0",
          "expect": 1
        },
        {
          "name": "le comptage porte l'accent : il compte, il n'alerte pas",
          "measure": "(() => { const c = parseRgb(cs('.vl-scrub__n','backgroundColor')); return Math.max(...c) - Math.min(...c); })()",
          "min": 60
        },
        {
          "name": "… et il reste minuscule : au plus la moitié de sa pastille",
          "measure": "box('.vl-scrub__n').h / box('.vl-scrub__ev').h",
          "max": 0.55
        },
        {
          "name": "le repère « aujourd'hui » est le seul objet sombre HORS de la fenêtre",
          "measure": "[...document.querySelectorAll('.vl-scrub__scale *, .vl-scrub__now')].filter((e) => { const p = (getComputedStyle(e).backgroundColor.match(/[\\d.]+/g) || []).map(Number); const a = p.length === 4 ? p[3] : (p.length === 3 ? 1 : 0); return a > 0.9 && lum(p.slice(0, 3)) < 0.2; }).length",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "contraste d'un libellé de mois hors fenêtre ≥ 4,5",
          "measure": "contrast('.vl-scrub__scale .vl-scrub__month')",
          "min": 4.5
        },
        {
          "name": "contraste d'un libellé de mois DANS la fenêtre ≥ 4,5",
          "measure": "contrast('.vl-scrub__window .vl-scrub__month')",
          "min": 4.5
        },
        {
          "name": "aucun débordement du ruban",
          "measure": "overflow('.vl-scrub')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/diagram-03-time-scrubber.html"
    },
    {
      "id": "layout-01-nested-bento",
      "name": "Bento à cartes imbriquées",
      "family": "layout",
      "ref": "ref-03-bento-dark-pitch",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Structurer une slide dense en trois niveaux (carte → sous-carte → tuile) sans une seule bordure ni ombre, en ne faisant décroître que le rayon et le padding.",
      "when_to_use": "Slide de chiffres ou de preuves où il faut caser 4 à 8 informations sans que ça ressemble à un tableau. C'est le squelette par défaut d'un deck investisseur moderne.",
      "avoid_when": "Il n'y a qu'une seule idée sur la slide — l'imbrication crée alors une hiérarchie qui ne correspond à rien. Et sur un support imprimé en petit format, les tuiles descendent sous le seuil de lisibilité.",
      "tags": [
        "bento",
        "layout",
        "slide",
        "cartes",
        "imbrication",
        "rayon",
        "hierarchie",
        "deck",
        "investisseur",
        "grille"
      ],
      "vars": [
        {
          "name": "--vl-radius-card",
          "role": "rayon du niveau 1",
          "default": "24px"
        },
        {
          "name": "--vl-radius-sub",
          "role": "rayon du niveau 2",
          "default": "20px"
        },
        {
          "name": "--vl-radius-tile",
          "role": "rayon du niveau 3",
          "default": "16px"
        },
        {
          "name": "--vl-gap",
          "role": "gouttière entre cartes de même niveau",
          "default": "12px"
        }
      ],
      "slots": [
        "title",
        "body",
        "tiles"
      ],
      "geometry": null,
      "benchmarks": [],
      "pptx": null,
      "html": "patterns/layout-01-nested-bento.html"
    },
    {
      "id": "layout-02-swiss-frame",
      "name": "Cadre suisse header / footer",
      "family": "layout",
      "ref": "ref-04-swiss-investor-blue",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Poser le cadre d'une slide de deck : une marge unique, deux barres de micro-typo à trois zones et un filet d'un pixel — de quoi rendre une suite de slides reconnaissable sans y ajouter le moindre ornement.",
      "when_to_use": "Deck de plus de trois slides qui doit tenir comme un document : le header rappelle la section, le footer numérote. C'est aussi ce qui autorise les fonds pleins à alterner sans que le deck se disloque.",
      "avoid_when": "Slide unique, visuel de réseau social, ou support projeté de loin : à 8 px les barres ne se lisent pas, elles ne sont là que pour tenir la page.",
      "tags": [
        "layout",
        "cadre",
        "header",
        "footer",
        "deck",
        "slide",
        "suisse",
        "filet",
        "micro-typo",
        "grille",
        "marge"
      ],
      "vars": [
        {
          "name": "--vl-hairline",
          "role": "filet sous le header sur fond clair",
          "default": "#00000018"
        },
        {
          "name": "--vl-hairline-invert",
          "role": "filet sur fond plein (bleu ou noir)",
          "default": "#FFFFFF33"
        },
        {
          "name": "--vl-white",
          "role": "fond des slides claires",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-blue",
          "role": "fond des slides pleines",
          "default": "#1B44D8"
        },
        {
          "name": "--vl-ink-muted",
          "role": "corps de texte sur fond clair",
          "default": "#6E6E6E"
        }
      ],
      "slots": [
        "mark",
        "section",
        "meta",
        "body",
        "footer-left",
        "footer-center",
        "footer-right"
      ],
      "geometry": null,
      "benchmarks": [],
      "pptx": null,
      "html": "patterns/layout-02-swiss-frame.html"
    },
    {
      "id": "layout-03-glass-board",
      "name": "Écran en verre dépoli — fond dégradé + modules",
      "family": "layout",
      "ref": "ref-13-glass-fintech-dashboard",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Emboîter les surfaces d'un écran dense en DEUX couches seulement, sans un trait ni une couleur de séparation : un fond en dégradé qui accueille, et des modules de blanc translucide posés dessus, flou d'arrière-plan compris.",
      "when_to_use": "Dashboard, écran produit ou slide de démo où l'on veut de la profondeur sans ajouter de matière graphique — et où le fond doit rester visible à travers les modules (c'est lui qui donne la couleur de l'ensemble).",
      "avoid_when": "Le support ne rend pas le flou d'arrière-plan (export .pptx, mailing, impression) : le verre s'aplatit en deux gris presque identiques et toute la hiérarchie disparaît. Éviter aussi sur un fond uni — sans dégradé derrière, le dépoli ne se voit pas.",
      "tags": [
        "ecran",
        "verre",
        "glassmorphism",
        "depoli",
        "flou",
        "backdrop",
        "degrade",
        "deux-couches",
        "dashboard",
        "translucide",
        "profondeur"
      ],
      "vars": [
        {
          "name": "--vl-grad-1",
          "role": "extrémité lavande du dégradé de fond",
          "default": "#A3B0DA"
        },
        {
          "name": "--vl-grad-3",
          "role": "extrémité claire du dégradé de fond",
          "default": "#C8CEDA"
        },
        {
          "name": "--vl-surface-glass",
          "role": "verre d'un module (blanc à 45 %)",
          "default": "rgba(255, 255, 255, 0.45)"
        },
        {
          "name": "--vl-surface-glass-strong",
          "role": "verre d'un sous-bloc (blanc à 68 %)",
          "default": "rgba(255, 255, 255, 0.68)"
        },
        {
          "name": "--vl-sheen",
          "role": "liseré de lumière qui remplace la bordure",
          "default": "rgba(255, 255, 255, 0.45)"
        },
        {
          "name": "--vl-blur",
          "role": "rayon du flou d'arrière-plan",
          "default": "18px"
        },
        {
          "name": "--vl-margin",
          "role": "marge de fond autour des modules",
          "default": "44px"
        }
      ],
      "slots": [
        "modules",
        "sub"
      ],
      "geometry": {
        "root": ".vl-glass-board",
        "frame": [
          1600,
          900
        ],
        "ratios": {
          "margin_over_width": 0.0275,
          "gap_over_width": 0.01375,
          "modules_over_width": 0.945
        },
        "type_px": {
          "label": 15,
          "figure": 44,
          "sub": 12
        },
        "pad_ratio": {
          "top": 0.0275,
          "x": 0.0275,
          "bottom": 0.0275
        }
      },
      "benchmarks": [
        {
          "name": "le module est translucide : le dégradé passe au travers",
          "measure": "Number((cs('.vl-glass-board__card','backgroundColor').match(/[\\d.]+/g)||[0,0,0,1])[3]) || 1",
          "max": 0.55
        },
        {
          "name": "le sous-bloc est plus blanc que le module (≥ 12 points d'alpha)",
          "measure": "(Number((cs('.vl-glass-board__sub','backgroundColor').match(/[\\d.]+/g)||[0,0,0,1])[3]) || 1) - (Number((cs('.vl-glass-board__card','backgroundColor').match(/[\\d.]+/g)||[0,0,0,1])[3]) || 1)",
          "min": 0.12
        },
        {
          "name": "le rayon décroît avec l'imbrication : module > sous-bloc",
          "measure": "num('.vl-glass-board__card','borderTopLeftRadius') - num('.vl-glass-board__sub','borderTopLeftRadius')",
          "min": 6
        },
        {
          "name": "le flou d'arrière-plan est bien posé sur le module (sinon ce n'est pas du verre)",
          "measure": "/blur/.test(cs('.vl-glass-board__card','backdropFilter') + cs('.vl-glass-board__card','webkitBackdropFilter')) ? 1 : 0",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "aucune bordure dessinée : c'est le liseré de lumière qui découpe",
          "measure": "num('.vl-glass-board__card','borderTopWidth') + num('.vl-glass-board__sub','borderTopWidth')",
          "max": 0
        },
        {
          "name": "DEUX couches et pas trois : aucun panneau intermédiaire entre le fond et un module",
          "measure": "[...document.querySelectorAll('.vl-glass-board__card')].filter((e) => e.parentElement.classList.contains('vl-glass-board') || e.parentElement.classList.contains('vl-glass-board__row')).length - document.querySelectorAll('.vl-glass-board__card').length",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "marge de fond ≥ gouttière (le liseré de dégradé qui fait flotter les modules)",
          "measure": "num('.vl-glass-board','paddingLeft') - num('.vl-glass-board','rowGap')",
          "min": 0
        },
        {
          "name": "les modules laissent respirer le fond : ils tiennent sous 95 % de la largeur",
          "measure": "box('.vl-glass-board__card--wide').w / W",
          "max": 0.95
        },
        {
          "name": "format PPT : la scène est en 16:9",
          "measure": "W / H",
          "expect": 1.7778,
          "tol": 0.01
        },
        {
          "name": "contraste du libellé sur le verre ≥ 7 (fond effectif = le dégradé composité)",
          "measure": "contrast('.vl-glass-board__label')",
          "min": 7
        },
        {
          "name": "aucun débordement du contenu hors d'un module",
          "measure": "overflow('.vl-glass-board__card--wide')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/layout-03-glass-board.html"
    },
    {
      "id": "layout-04-graph-paper-cells",
      "name": "Papier millimétré et ses cases",
      "family": "layout",
      "ref": "ref-16-cobalt-graph-paper",
      "media": [
        "print",
        "slide",
        "social",
        "web"
      ],
      "intent": "Faire d'une trame de papier millimétré le TON du fond, puis y poser des objets qui sont des cases de cette trame — pas de la matière posée dessus : le pas de la grille de cellules et la marge qui la centre sont des multiples entiers du pas de la trame, si bien que le contenu paraît découpé dans le papier.",
      "when_to_use": "Quand le sujet se compte en unités discrètes — demi-journées, sièges, jours, tickets, sites — et qu'on veut que le fond le dise avant le texte. Aussi comme socle de deck : la trame reste sur toutes les slides et fait la reconnaissance d'une slide à l'autre sans qu'aucun ornement ne soit ajouté.",
      "avoid_when": "Quand le sujet est continu (une courbe, un flux, une durée), quand la charte a déjà un motif — deux trames se battent et aucune ne gagne — ou dès que du texte de lecture devrait passer PAR-DESSUS la trame à plus de ~14 % d'opacité : le fond ne réduit jamais la lisibilité du premier plan, c'est la seule loi que ce pattern peut faire tomber.",
      "tags": [
        "papier millimétré",
        "graph paper",
        "trame",
        "grille",
        "fond",
        "calendrier",
        "demi-journée",
        "case",
        "cellule",
        "isotype",
        "deux couleurs",
        "monochrome",
        "verdict",
        "trois états"
      ],
      "vars": [
        {
          "name": "--vl-grid",
          "role": "encre de la trame — TENUE entre 6 et 14 % d'opacité",
          "default": "rgba(31,43,224,0.10)"
        },
        {
          "name": "--vl-grid-step",
          "role": "période de la trame — l'unité dont tout le reste est un multiple",
          "default": "35.2px"
        },
        {
          "name": "--vl-cell-pitch",
          "role": "pas d'une case (bord à bord) — DOIT être un multiple entier du pas de trame",
          "default": "70.4px"
        },
        {
          "name": "--vl-cell-gap",
          "role": "gouttière entre deux cases, prise DANS le pas",
          "default": "6px"
        },
        {
          "name": "--vl-paper",
          "role": "papier",
          "default": "#F0EBDE"
        },
        {
          "name": "--vl-paper-2",
          "role": "case neutralisée (non ouvré)",
          "default": "#E6E0CE"
        },
        {
          "name": "--vl-ink",
          "role": "encre d'accent — case pleine, filet, libellé mono",
          "default": "#1F2BE0"
        },
        {
          "name": "--vl-ink-faint",
          "role": "liseré d'une case vide",
          "default": "rgba(31,43,224,0.20)"
        },
        {
          "name": "--vl-near",
          "role": "encre de lecture",
          "default": "#14163A"
        },
        {
          "name": "--vl-rule",
          "role": "épaisseur du liseré",
          "default": "1.25px"
        },
        {
          "name": "--vl-margin",
          "role": "retrait haut et bas du panneau",
          "default": "92px"
        }
      ],
      "slots": [
        {
          "name": "head",
          "role": "libellé mono de la grille, centré"
        },
        {
          "name": "grid",
          "role": "les cases — 7 colonnes par défaut, l'en-tête de jours compris"
        },
        {
          "name": "legend",
          "role": "la clé des trois états ; obligatoire dès que deux états coexistent"
        }
      ],
      "geometry": {
        "root": ".vl-gpc",
        "frame": [
          704,
          600
        ],
        "ratios": {
          "grid_step_over_width": 0.05,
          "cell_pitch_over_grid_step": 2,
          "side_margin_over_grid_step": 3,
          "gap_over_cell_pitch": 0.085
        },
        "type_px": {
          "head": 22,
          "day": 22,
          "legend": 22
        },
        "pad_ratio": {
          "top": 0.131,
          "x": 0.15,
          "bottom": 0.131
        }
      },
      "benchmarks": [
        {
          "name": "la période de trame vaut 5 % de la largeur",
          "measure": "parseFloat(cs('.vl-gpc','backgroundSize')) / W",
          "expect": 0.05,
          "tol": 0.005
        },
        {
          "name": "LE GESTE — le pas des cases est un multiple ENTIER du pas de trame",
          "measure": "(box('.vl-gpc__cell:nth-of-type(2)').left - box('.vl-gpc__cell:nth-of-type(1)').left) / parseFloat(cs('.vl-gpc','backgroundSize'))",
          "expect": 2,
          "tol": 0.04
        },
        {
          "name": "la marge qui centre la grille est elle aussi un multiple entier du pas de trame",
          "measure": "box('.vl-gpc__grid').left / parseFloat(cs('.vl-gpc','backgroundSize'))",
          "expect": 3,
          "tol": 0.04
        },
        {
          "name": "la trame RECULE — opacité tenue entre 6 et 14 %",
          "measure": "parseFloat((cs('.vl-gpc','backgroundImage').match(/rgba?\\([^)]*?,\\s*([0-9.]+)\\s*\\)/) || [0,0])[1])",
          "min": 0.06,
          "max": 0.14
        },
        {
          "name": "les trois états de case partagent EXACTEMENT la même boîte",
          "measure": "Math.max(box('.vl-gpc__cell.is-full').w, box('.vl-gpc__cell.is-half').w, box('.vl-gpc__cell.is-off').w) - Math.min(box('.vl-gpc__cell.is-full').w, box('.vl-gpc__cell.is-half').w, box('.vl-gpc__cell.is-off').w)",
          "expect": 0,
          "tol": 0.5
        },
        {
          "name": "une case est carrée",
          "measure": "box('.vl-gpc__cell.is-full').w / box('.vl-gpc__cell.is-full').h",
          "expect": 1,
          "tol": 0.02
        },
        {
          "name": "la demi-case remplit la MOITIÉ de sa case, à 2 % près",
          "measure": "box('.vl-gpc__cell.is-half > b').h / box('.vl-gpc__cell.is-half').h",
          "expect": 0.5,
          "tol": 0.02
        },
        {
          "name": "le texte posé SUR la trame reste au-dessus du plancher WCAG",
          "measure": "contrast('.vl-gpc__legend')",
          "min": 4.5
        },
        {
          "name": "la gouttière est prise DANS le pas — elle ne le gonfle jamais",
          "measure": "(box('.vl-gpc__cell:nth-of-type(1)').w + parseFloat(cs('.vl-gpc__grid','columnGap'))) / (box('.vl-gpc__cell:nth-of-type(2)').left - box('.vl-gpc__cell:nth-of-type(1)').left)",
          "expect": 1,
          "tol": 0.02
        }
      ],
      "pptx": {
        "primitive": "cell_grid",
        "note": "En .pptx la trame se pose en image de fond de MASQUE (une seule fois), jamais en 400 rectangles ; les cases restent des formes, seules elles portent la donnée."
      },
      "html": "patterns/layout-04-graph-paper-cells.html"
    },
    {
      "id": "layout-05-nav-pill-bar",
      "name": "Barre de menu en pilule flottante",
      "family": "layout",
      "ref": "ref-17-mono-bento-legal",
      "media": [
        "web"
      ],
      "intent": "Poser la navigation d'une page comme un OBJET posé dans la marge — un aplat arrondi qui flotte au lieu d'un bandeau collé au bord — et n'y employer qu'une seule ressource de hiérarchie : le rang de blanc, le badge de marque et le bouton d'action étant plus CLAIRS que la barre qui les porte.",
      "when_to_use": "En-tête d'une landing ou d'une application dont le fond est déjà clair, quand la navigation ne doit pas se lire comme un chrome de navigateur : la barre reste dans la marge de page, à la même distance des bords que les modules qui la suivent, et le premier module commence juste dessous.",
      "avoid_when": "La page défile sous un en-tête collant (une pilule flottante qui se fige au bord perd exactement ce qui la justifie), le fond est sombre ou photographique (le rang de blanc n'a plus de sens : le blanc devient la couleur la plus forte de l'écran), ou la navigation compte plus de cinq entrées — au-delà, les liens rattrapent le bouton et la respiration qui fait l'objet disparaît.",
      "tags": [
        "navigation",
        "menu",
        "barre",
        "header",
        "entete",
        "pilule",
        "landing",
        "logo",
        "wordmark",
        "bouton",
        "cta",
        "monochrome",
        "web",
        "app"
      ],
      "vars": [
        {
          "name": "--vl-module",
          "role": "aplat de la barre",
          "default": "#EDEDED"
        },
        {
          "name": "--vl-object",
          "role": "blanc des objets posés DANS la barre (badge, bouton) — plus clair que la page",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-ink",
          "role": "wordmark, téléphone, libellé du bouton",
          "default": "#111111"
        },
        {
          "name": "--vl-ink-soft",
          "role": "liens de menu (relevé #6E6E6E, posé #5F5F5F pour tenir 4,5:1)",
          "default": "#5F5F5F"
        },
        {
          "name": "--vl-radius-bar",
          "role": "rayon de la barre — un rayon franc, JAMAIS une demi-hauteur",
          "default": "20px"
        },
        {
          "name": "--vl-radius-pill",
          "role": "rayon du bouton et du badge, eux vraiment en pilule",
          "default": "999px"
        }
      ],
      "slots": [
        "badge",
        "mark",
        "links",
        "cta"
      ],
      "geometry": {
        "root": ".vl-navbar",
        "frame": [
          1326,
          69
        ],
        "ratios": {
          "height_over_width": 0.052,
          "radius_over_height": 0.29,
          "badge_over_height": 0.696,
          "cta_over_height": 0.71,
          "inset_over_height": 0.145,
          "link_gap_over_height": 0.638
        },
        "type_px": {
          "mark": 30,
          "link": 16,
          "cta": 15
        },
        "pad_ratio": {
          "top": 0,
          "x": 0.0075,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "barre basse : sa hauteur vaut ≈ 5 % de sa largeur",
          "measure": "H / W",
          "expect": 0.052,
          "tol": 0.012
        },
        {
          "name": "un RAYON, pas une pilule : le rayon reste sous 45 % de la hauteur",
          "measure": "num('.vl-navbar','borderTopLeftRadius') / H",
          "min": 0.18,
          "max": 0.45
        },
        {
          "name": "le badge, LUI, est un disque parfait",
          "measure": "box('.vl-navbar__badge').w / box('.vl-navbar__badge').h",
          "expect": 1,
          "tol": 0.02
        },
        {
          "name": "badge ≈ 70 % de la hauteur de barre — il loge dedans, il ne la déborde pas",
          "measure": "box('.vl-navbar__badge').h / H",
          "expect": 0.696,
          "tol": 0.06
        },
        {
          "name": "gouttière constante : l'écart du badge au bord gauche égale son écart au bord haut",
          "measure": "Math.abs(box('.vl-navbar__badge').left - box('.vl-navbar__badge').top)",
          "max": 1.5
        },
        {
          "name": "le bouton est PLUS CLAIR que la barre — c'est le seul geste de hiérarchie du système",
          "measure": "lum(parseRgb(cs('.vl-navbar__cta','backgroundColor'))) - lum(parseRgb(cs('.vl-navbar','backgroundColor')))",
          "min": 0.01
        },
        {
          "name": "le badge aussi est plus clair que la barre",
          "measure": "lum(parseRgb(cs('.vl-navbar__badge','backgroundColor'))) - lum(parseRgb(cs('.vl-navbar','backgroundColor')))",
          "min": 0.01
        },
        {
          "name": "le bouton EST une pilule : son rayon vaut au moins sa demi-hauteur",
          "measure": "num('.vl-navbar__cta','borderTopLeftRadius') / box('.vl-navbar__cta').h",
          "min": 0.5
        },
        {
          "name": "le bloc de droite colle la marge intérieure de la barre",
          "measure": "W - num('.vl-navbar','paddingRight') - box('.vl-navbar__cta').right",
          "expect": 0,
          "tol": 1.5
        },
        {
          "name": "le wordmark et les liens ne se touchent jamais : au moins deux hauteurs de barre les séparent",
          "measure": "(box('.vl-navbar__links').left - box('.vl-navbar__mark').right) / H",
          "min": 2
        },
        {
          "name": "saut typographique wordmark / lien ≥ 1,7",
          "measure": "num('.vl-navbar__mark','fontSize') / num('.vl-navbar__links','fontSize')",
          "min": 1.7
        },
        {
          "name": "aucun liseré : la barre se détache par son aplat, pas par un trait",
          "measure": "num('.vl-navbar','borderTopWidth')",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "aucune ombre portée",
          "measure": "cs('.vl-navbar','boxShadow') === 'none' ? 1 : 0",
          "expect": 1
        },
        {
          "name": "contraste des liens ≥ 4,5 sur l'aplat de la barre",
          "measure": "contrast('.vl-navbar__links')",
          "min": 4.5
        },
        {
          "name": "contraste du libellé de bouton ≥ 4,5 sur son blanc",
          "measure": "contrast('.vl-navbar__cta')",
          "min": 4.5
        },
        {
          "name": "aucun débordement du contenu hors de la barre",
          "measure": "overflow('.vl-navbar')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/layout-05-nav-pill-bar.html"
    },
    {
      "id": "layout-06-bento-l-span",
      "name": "Bento 3 × 3 à deux enjambements en L",
      "family": "layout",
      "ref": "ref-17-mono-bento-legal",
      "media": [
        "web",
        "slide"
      ],
      "intent": "Tenir cinq modules de tailles très différentes sur une VRAIE grille 3 × 3, en n'employant que deux enjambements — un vertical à gauche, un horizontal à droite — qui se CROISENT sans jamais se chevaucher : c'est ce croisement qui supprime toute frontière horizontale continue et fait qu'un écran dense cesse de se lire comme un tableau.",
      "when_to_use": "Accueil, sommaire de services, page d'entrée d'application : un contenu principal qui doit dominer sans occuper la moitié de l'écran, un contenu long à gauche (liste, nuage, sommaire), et deux entrées secondaires de même rang en haut. Le module dominant prend l'aplat inversé, et il est le SEUL.",
      "avoid_when": "Les modules sont de même rang (une grille régulière dit alors la vérité et se refond sans douleur), le contenu du module haut à gauche varie beaucoup en longueur (l'enjambement vertical fige sa hauteur), ou l'écran doit se replier en une colonne sur mobile — les deux enjambements y perdent tout sens et il faut une autre composition, pas une adaptation de celle-ci.",
      "tags": [
        "bento",
        "grille",
        "grid",
        "layout",
        "accueil",
        "landing",
        "dashboard",
        "enjambement",
        "span",
        "module",
        "monochrome",
        "hierarchie",
        "hero"
      ],
      "vars": [
        {
          "name": "--vl-module",
          "role": "aplat des modules neutres",
          "default": "#EDEDED"
        },
        {
          "name": "--vl-invert",
          "role": "aplat du module dominant — UN SEUL par écran",
          "default": "#000000"
        },
        {
          "name": "--vl-ink",
          "role": "encre sur module neutre",
          "default": "#111111"
        },
        {
          "name": "--vl-on-invert",
          "role": "encre sur le module dominant",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-gutter",
          "role": "gouttière UNIQUE, horizontale et verticale",
          "default": "26px"
        },
        {
          "name": "--vl-radius-module",
          "role": "rayon d'un module",
          "default": "24px"
        }
      ],
      "slots": [
        "a",
        "b",
        "c",
        "hero",
        "d"
      ],
      "geometry": {
        "root": ".vl-bento",
        "frame": [
          1326,
          830
        ],
        "ratios": {
          "gutter_over_width": 0.0196,
          "col_1_over_inner": 0.269,
          "col_2_over_inner": 0.366,
          "col_3_over_inner": 0.364,
          "row_1_over_inner": 0.389,
          "row_2_over_inner": 0.298,
          "row_3_over_inner": 0.313,
          "a_over_b_height": 1.854,
          "hero_over_b_width": 2.052,
          "radius_over_width": 0.018
        },
        "type_px": {},
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "TROIS colonnes, pas deux : l'écran n'est pas deux piles côte à côte",
          "measure": "cs('.vl-bento','gridTemplateColumns').trim().split(/\\s+/).length",
          "expect": 3,
          "tol": 0
        },
        {
          "name": "TROIS rangées",
          "measure": "cs('.vl-bento','gridTemplateRows').trim().split(/\\s+/).length",
          "expect": 3,
          "tol": 0
        },
        {
          "name": "gouttière UNIQUE : l'horizontale et la verticale sont la même valeur",
          "measure": "Math.abs(num('.vl-bento','columnGap') - num('.vl-bento','rowGap'))",
          "max": 0.5
        },
        {
          "name": "gouttière ≈ 2 % de la largeur",
          "measure": "num('.vl-bento','columnGap') / W",
          "expect": 0.0196,
          "tol": 0.006
        },
        {
          "name": "les trois modules du haut partagent leur ligne supérieure",
          "measure": "Math.abs(box('.vl-bento__a').top - box('.vl-bento__c').top) + Math.abs(box('.vl-bento__b').top - box('.vl-bento__c').top)",
          "max": 1.5
        },
        {
          "name": "l'enjambement vertical : A vaut deux rangées + une gouttière",
          "measure": "box('.vl-bento__a').h / box('.vl-bento__b').h",
          "expect": 1.854,
          "tol": 0.08
        },
        {
          "name": "l'enjambement horizontal : le hero vaut deux colonnes + une gouttière",
          "measure": "box('.vl-bento__hero').w / box('.vl-bento__b').w",
          "expect": 2.052,
          "tol": 0.08
        },
        {
          "name": "LES DEUX ENJAMBEMENTS SE CROISENT : le bas de A descend sous le haut du hero",
          "measure": "box('.vl-bento__a').bottom - box('.vl-bento__hero').top",
          "min": 40
        },
        {
          "name": "… mais ils ne se chevauchent JAMAIS : une gouttière pleine les sépare",
          "measure": "box('.vl-bento__hero').left - box('.vl-bento__a').right",
          "expect": 26,
          "tol": 2
        },
        {
          "name": "le pied de grille est droit : D et le hero finissent sur la même ligne",
          "measure": "Math.abs(box('.vl-bento__d').bottom - box('.vl-bento__hero').bottom)",
          "max": 1.5
        },
        {
          "name": "aucune frontière horizontale continue : le bas de B ne coïncide avec le bas d'aucun de ses voisins de gauche",
          "measure": "Math.min(Math.abs(box('.vl-bento__b').bottom - box('.vl-bento__a').bottom), Math.abs(box('.vl-bento__b').bottom - box('.vl-bento__d').top))",
          "min": 20
        },
        {
          "name": "le module dominant est au moins DEUX fois l'aire d'un module secondaire",
          "measure": "(box('.vl-bento__hero').w * box('.vl-bento__hero').h) / (box('.vl-bento__b').w * box('.vl-bento__b').h)",
          "min": 2
        },
        {
          "name": "UN SEUL aplat sombre dans toute la grille",
          "measure": "[...document.querySelectorAll('.vl-bento > *')].filter((e) => lum(parseRgb(getComputedStyle(e).backgroundColor)) < 0.2).length",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "aucun liseré sur les modules",
          "measure": "num('.vl-bento__b','borderTopWidth') + num('.vl-bento__hero','borderTopWidth')",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "aucune ombre portée",
          "measure": "cs('.vl-bento__b','boxShadow') === 'none' ? 1 : 0",
          "expect": 1
        },
        {
          "name": "contraste de l'encre sur le module dominant ≥ 4,5",
          "measure": "contrast('.vl-bento__hero')",
          "min": 4.5
        },
        {
          "name": "aucun débordement de la grille",
          "measure": "overflow('.vl-bento')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/layout-06-bento-l-span.html"
    },
    {
      "id": "layout-07-icon-rail-shell",
      "name": "Coque d'application à rail d'icônes",
      "family": "layout",
      "ref": "ref-18-lime-sales-dashboard",
      "media": [
        "web"
      ],
      "intent": "Poser le cadre d'un tableau de bord en ne dépensant AUCUNE couleur pour la navigation : un rail d'icônes étroit du même blanc que l'application — séparé par un filet d'un pixel et jamais par un aplat —, un en-tête à recherche en pilule, et un titre de page qui est le seul très grand corps de l'écran. Tout le budget de couleur reste disponible pour les données.",
      "when_to_use": "Application ou tableau de bord à navigation stable de six à dix entrées, sur fond clair : le rail donne l'accès, l'en-tête donne la recherche et le compte, le titre donne le contexte, et le contenu commence dessous en pleine largeur restante. L'entrée active est marquée par l'inversion, jamais par une teinte.",
      "avoid_when": "La navigation a des libellés (le rail d'icônes seules devient illisible au-delà de huit entrées sans texte), l'application est sombre (le filet d'un pixel disparaît et il faut un vrai aplat de rail), ou la couleur de marque doit être visible en permanence — ce système la réserve aux données, et l'y ramener supprime sa seule règle.",
      "tags": [
        "coque",
        "shell",
        "rail",
        "sidebar",
        "navigation",
        "dashboard",
        "tableau-de-bord",
        "recherche",
        "header",
        "entete",
        "titre",
        "application",
        "icone"
      ],
      "vars": [
        {
          "name": "--vl-app",
          "role": "fond de l'application ET du rail — la même valeur, c'est le principe",
          "default": "#FEFEFE"
        },
        {
          "name": "--vl-module",
          "role": "aplat des puits d'icône",
          "default": "#F2F2F2"
        },
        {
          "name": "--vl-hairline",
          "role": "le filet qui détache le rail — la SEULE séparation de la coque",
          "default": "#E6E6E6"
        },
        {
          "name": "--vl-invert",
          "role": "aplat de l'entrée active du rail",
          "default": "#000000"
        },
        {
          "name": "--vl-ink",
          "role": "icônes, titre de page",
          "default": "#0B0B0B"
        },
        {
          "name": "--vl-ink-soft",
          "role": "texte d'invite de la recherche",
          "default": "#6B6B6B"
        },
        {
          "name": "--vl-on-invert",
          "role": "icône de l'entrée active",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-rail-w",
          "role": "largeur du rail",
          "default": "95px"
        },
        {
          "name": "--vl-radius-pill",
          "role": "rayon de la recherche et des pilules d'en-tête",
          "default": "999px"
        }
      ],
      "slots": [
        "nav",
        "search",
        "account",
        "title",
        "actions",
        "content"
      ],
      "geometry": {
        "root": ".vl-shell",
        "frame": [
          1440,
          640
        ],
        "ratios": {
          "rail_over_width": 0.066,
          "header_over_width": 0.048,
          "search_over_width": 0.299,
          "title_over_width": 0.046,
          "margin_over_width": 0.021
        },
        "type_px": {
          "title": 66,
          "search": 16
        },
        "pad_ratio": {
          "top": 0.017,
          "x": 0.021,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "rail étroit : ≈ 6,6 % de la largeur, jamais une colonne",
          "measure": "box('.vl-shell__rail').w / W",
          "expect": 0.066,
          "tol": 0.02
        },
        {
          "name": "LE RAIL EST DU MÊME BLANC QUE L'APPLICATION — c'est de la coque, pas un module",
          "measure": "Math.abs(lum(bgOf($('.vl-shell__rail'))) - lum(bgOf($('.vl-shell__main'))))",
          "max": 0.01
        },
        {
          "name": "… et il s'en détache par un FILET d'un pixel, seule séparation de la coque",
          "measure": "num('.vl-shell__rail','borderRightWidth')",
          "min": 0.5,
          "max": 1.6
        },
        {
          "name": "le rail court sur toute la hauteur",
          "measure": "box('.vl-shell__rail').h / H",
          "expect": 1,
          "tol": 0.01
        },
        {
          "name": "UNE SEULE entrée active dans le rail",
          "measure": "document.querySelectorAll('.vl-shell__rail .is-on').length",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "l'entrée active est marquée par l'INVERSION, pas par une teinte : son fond est achromatique",
          "measure": "(() => { const c = parseRgb(cs('.vl-shell__rail .is-on','backgroundColor')); return Math.max(...c) - Math.min(...c); })()",
          "max": 6
        },
        {
          "name": "… et c'est un disque",
          "measure": "num('.vl-shell__rail .is-on','borderTopLeftRadius') / box('.vl-shell__rail .is-on').h",
          "min": 0.5
        },
        {
          "name": "la recherche est une pilule",
          "measure": "num('.vl-shell__search','borderTopLeftRadius') / box('.vl-shell__search').h",
          "min": 0.5
        },
        {
          "name": "la recherche occupe environ un tiers de la largeur : assez pour être une zone, pas assez pour être le sujet",
          "measure": "box('.vl-shell__search').w / W",
          "min": 0.22,
          "max": 0.4
        },
        {
          "name": "le bloc de compte est poussé au bord droit",
          "measure": "box('.vl-shell__main').right - num('.vl-shell__main','paddingRight') - box('.vl-shell__account').right",
          "expect": 0,
          "tol": 1.5
        },
        {
          "name": "le titre de page est le PLUS GRAND corps de la coque, et de loin",
          "measure": "num('.vl-shell__title','fontSize') / num('.vl-shell__search','fontSize')",
          "min": 3
        },
        {
          "name": "le titre est ferré sur la marge de contenu, comme tout ce qui suivra",
          "measure": "Math.abs(box('.vl-shell__title').left - (box('.vl-shell__main').left + num('.vl-shell__main','paddingLeft')))",
          "max": 2
        },
        {
          "name": "le contenu prend toute la largeur restante",
          "measure": "box('.vl-shell__content').w / (box('.vl-shell__main').w - num('.vl-shell__main','paddingLeft') - num('.vl-shell__main','paddingRight'))",
          "expect": 1,
          "tol": 0.01
        },
        {
          "name": "aucune ombre nulle part dans la coque",
          "measure": "['.vl-shell__rail', '.vl-shell__main'].filter((s) => cs(s, 'boxShadow') !== 'none').length",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "contraste de l'invite de recherche ≥ 4,5",
          "measure": "contrast('.vl-shell__search')",
          "min": 4.5
        },
        {
          "name": "aucun débordement de la coque",
          "measure": "overflow('.vl-shell')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/layout-07-icon-rail-shell.html"
    },
    {
      "id": "layout-08-sheet-handle-tab",
      "name": "Feuille modale à poignée soudée par congés concaves",
      "family": "layout",
      "ref": "ref-19-acid-clinical-timeline",
      "media": [
        "web"
      ],
      "intent": "Ouvrir un dossier par-dessus une application sans que la modale se lise comme « une carte de plus » : un voile sombre qui PORTE de l'interface (onglets, fermeture), une feuille claire qui saigne à quelques pixels des bords, et un onglet-poignée titré soudé à cette feuille par DEUX congés concaves — la seule courbe inversée du corpus, et ce qui fait que la poignée appartient à la feuille au lieu de flotter au-dessus d'elle.",
      "when_to_use": "Dossier, fiche détaillée ou vue plein écran ouverte depuis une liste, quand le contexte sous-jacent doit rester senti sans être lisible. Le voile accueille la navigation transverse (onglets de section, fermeture), la feuille accueille le contenu, et la poignée porte le titre de ce qu'on a ouvert.",
      "avoid_when": "La feuille est blanche (les modules blancs qu'elle portera devront s'entourer d'un liseré, et le système n'en a nulle part), le voile ne porte rien (il redevient une simple opacité, et deux rectangles empilés suffisent), ou la vue est une PAGE et non une couche — la poignée dit « ceci est ouvert par-dessus », ce qui devient un mensonge sur une page de plein droit.",
      "tags": [
        "modale",
        "sheet",
        "feuille",
        "voile",
        "scrim",
        "overlay",
        "onglet",
        "poignee",
        "tab",
        "congé",
        "concave",
        "dossier",
        "fiche",
        "application"
      ],
      "vars": [
        {
          "name": "--vl-veil",
          "role": "voile sombre — il PORTE de l'interface, ce n'est pas un bezel",
          "default": "#4A4A48"
        },
        {
          "name": "--vl-sheet",
          "role": "feuille — GRISE, jamais blanche : c'est ce qui fait monter les modules posés dessus",
          "default": "#DFDFD7"
        },
        {
          "name": "--vl-module-hi",
          "role": "aplat des onglets et du bouton de fermeture, posés sur le voile",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-ink",
          "role": "titre de la poignée, libellés d'onglets",
          "default": "#262C2C"
        },
        {
          "name": "--vl-radius-sheet",
          "role": "rayon de la feuille ET de la poignée",
          "default": "34px"
        },
        {
          "name": "--vl-notch",
          "role": "rayon des DEUX congés concaves qui soudent la poignée à la feuille",
          "default": "40px"
        },
        {
          "name": "--vl-bleed",
          "role": "saignée de voile visible autour de la feuille",
          "default": "20px"
        }
      ],
      "slots": [
        "tabs",
        "close",
        "handle",
        "content"
      ],
      "geometry": {
        "root": ".vl-sheet",
        "frame": [
          1440,
          929
        ],
        "ratios": {
          "bleed_over_width": 0.0139,
          "tabbar_over_height": 0.128,
          "handle_over_width": 0.247,
          "handle_h_over_height": 0.089,
          "notch_over_handle_h": 0.482,
          "close_over_width": 0.042
        },
        "type_px": {
          "handle": 48,
          "tab": 15
        },
        "pad_ratio": {
          "top": 0.037,
          "x": 0.024,
          "bottom": 0.037
        }
      },
      "benchmarks": [
        {
          "name": "LA FEUILLE EST GRISE, PAS BLANCHE — c'est ce qui fait monter les modules sans liseré",
          "measure": "lum(parseRgb(cs('.vl-sheet__panel','backgroundColor')))",
          "min": 0.5,
          "max": 0.85
        },
        {
          "name": "le voile est franchement sombre : au moins 6 fois moins lumineux que la feuille",
          "measure": "lum(parseRgb(cs('.vl-sheet','backgroundColor'))) / lum(parseRgb(cs('.vl-sheet__panel','backgroundColor')))",
          "max": 0.17
        },
        {
          "name": "LE VOILE PORTE DE L'INTERFACE : au moins deux objets vivent dessus, hors de la feuille",
          "measure": "[...document.querySelectorAll('.vl-sheet > *')].filter((e) => !e.classList.contains('vl-sheet__panel')).length",
          "min": 2
        },
        {
          "name": "la saignée de voile est fine et ÉGALE des deux côtés",
          "measure": "Math.abs(box('.vl-sheet__panel').left - (W - box('.vl-sheet__panel').right))",
          "max": 1.5
        },
        {
          "name": "… et elle vaut ≈ 1,4 % de la largeur : un liseré de contexte, pas une marge",
          "measure": "box('.vl-sheet__panel').left / W",
          "expect": 0.0139,
          "tol": 0.008
        },
        {
          "name": "LA POIGNÉE EST SOUDÉE : son bas coïncide au pixel près avec le haut de la feuille",
          "measure": "Math.abs(box('.vl-sheet__handle').bottom - box('.vl-sheet__panel').top)",
          "max": 1
        },
        {
          "name": "… par DEUX congés, un de chaque côté — pas un seul",
          "measure": "[getComputedStyle($('.vl-sheet__handle'), '::before').backgroundImage, getComputedStyle($('.vl-sheet__handle'), '::after').backgroundImage].filter((v) => v.includes('radial-gradient')).length",
          "expect": 2,
          "tol": 0
        },
        {
          "name": "les congés sont bien CONCAVES : le quart de disque évidé est transparent",
          "measure": "[getComputedStyle($('.vl-sheet__handle'), '::before').backgroundImage, getComputedStyle($('.vl-sheet__handle'), '::after').backgroundImage].filter((v) => /rgba\\(0,\\s*0,\\s*0,\\s*0\\)/.test(v)).length",
          "expect": 2,
          "tol": 0
        },
        {
          "name": "la poignée est RENTRÉE : elle ne part pas du bord gauche de la feuille",
          "measure": "box('.vl-sheet__handle').left - box('.vl-sheet__panel').left",
          "min": 20
        },
        {
          "name": "… et elle reste minoritaire : au plus un tiers de la largeur",
          "measure": "box('.vl-sheet__handle').w / W",
          "max": 0.34
        },
        {
          "name": "le congé vaut environ la moitié de la hauteur de poignée",
          "measure": "parseFloat(getComputedStyle($('.vl-sheet__handle'), '::before').width) / box('.vl-sheet__handle').h",
          "expect": 0.482,
          "tol": 0.15
        },
        {
          "name": "le bouton de fermeture vit sur le VOILE, à gauche de la poignée",
          "measure": "box('.vl-sheet__handle').left - box('.vl-sheet__close').right",
          "min": 4
        },
        {
          "name": "… et c'est un disque",
          "measure": "box('.vl-sheet__close').w / box('.vl-sheet__close').h",
          "expect": 1,
          "tol": 0.02
        },
        {
          "name": "le titre de la poignée est le plus grand corps de la coque",
          "measure": "num('.vl-sheet__title','fontSize') / num('.vl-sheet__tab','fontSize')",
          "min": 2.5
        },
        {
          "name": "contraste du titre sur la feuille ≥ 4,5",
          "measure": "contrast('.vl-sheet__title')",
          "min": 4.5
        },
        {
          "name": "contraste d'un onglet sur son aplat ≥ 4,5",
          "measure": "contrast('.vl-sheet__tab')",
          "min": 4.5
        },
        {
          "name": "aucun débordement de la feuille",
          "measure": "overflow('.vl-sheet__panel')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/layout-08-sheet-handle-tab.html"
    },
    {
      "id": "layout-09-email-envelope",
      "name": "Enveloppe de mailing — garde, colonne de 600, trois aplats pleins",
      "family": "layout",
      "ref": "ref-04-swiss-investor-blue",
      "media": [
        "email"
      ],
      "intent": "Poser la coquille d'un mailing avec ce que le moteur Word d'Outlook sait réellement rendre : une table de garde pleine largeur qui porte le fond, une colonne de 600 px centrée qui porte tout le contenu, et l'espacement entièrement délégué au padding des cellules — jamais à une gouttière, une position ou un calcul. Les trois zones (bandeau, corps, pied) sont trois aplats PLEINS et différents, ce qui est à la fois la signature de la charte et le seul modèle de hiérarchie qu'un client mail rende à l'identique partout.",
      "when_to_use": "Toute lettre d'information ou mailing transactionnel à composer soi-même : c'est le squelette dans lequel on pose ensuite les blocs de contenu (list-06-email-digest, card-13-email-figure-band, tous deux calibrés sur le slot de 520 px). Le bandeau porte l'émetteur et le numéro d'édition, le pied porte les mentions et le désabonnement — deux zones qu'on ne peut pas omettre et qu'on réécrit sinon à chaque envoi.",
      "avoid_when": "Le message est une page web déguisée (une landing dans un mail se lit mal et se coupe), il doit tenir plusieurs colonnes de contenu côte à côte au-delà de deux (elles ne s'empilent pas sur mobile sans requête de média, que la plupart des clients ignorent), ou la charte visée repose sur des rayons, des ombres ou des couleurs translucides — Outlook les rend en angles vifs, sans ombre et en couleur pleine, et le résultat n'est plus la charte.",
      "tags": [
        "email",
        "mailing",
        "newsletter",
        "lettre",
        "coquille",
        "gabarit",
        "outlook",
        "table",
        "600",
        "bandeau",
        "pied",
        "desabonnement",
        "bouton",
        "cta",
        "enveloppe"
      ],
      "vars": [
        {
          "name": "--vl-bg",
          "role": "fond de la garde — la couleur de la fenêtre de lecture autour de la colonne",
          "default": "#D9D2C7"
        },
        {
          "name": "--vl-white",
          "role": "aplat de la colonne : titre, corps, bouton",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-blue",
          "role": "aplat du bandeau ET du bouton — la couleur d'action, jamais un accent posé sur du blanc",
          "default": "#1B44D8"
        },
        {
          "name": "--vl-black",
          "role": "aplat du pied, et encre du titre",
          "default": "#0D0D0D"
        },
        {
          "name": "--vl-ink-muted",
          "role": "rubrique au-dessus du titre",
          "default": "#6E6E6E"
        },
        {
          "name": "--vl-ink-muted-invert",
          "role": "mentions légales, sur l'aplat du pied",
          "default": "#D3D3D3"
        }
      ],
      "slots": [
        "wordmark",
        "issue",
        "kicker",
        "title",
        "body",
        "cta",
        "legal",
        "unsub"
      ],
      "geometry": {
        "root": ".vl-emenv__col",
        "frame": [
          600,
          611
        ],
        "ratios": {
          "cell_pad_over_width": 0.0667,
          "band_pad_y_over_width": 0.0333,
          "title_over_lede": 2.5,
          "cta_height_over_width": 0.077
        },
        "type_px": {
          "title": 40,
          "lede": 16,
          "wordmark": 15,
          "cta": 13,
          "micro": 11
        },
        "pad_ratio": {
          "top": 0.0667,
          "x": 0.0667,
          "bottom": 0.05
        }
      },
      "benchmarks": [
        {
          "name": "LA COLONNE FAIT EXACTEMENT 600 px — la largeur de sécurité du canal, pas une échelle",
          "measure": "W",
          "expect": 600,
          "tol": 0
        },
        {
          "name": "marge de cellule ≈ 6,7 % de la colonne (40 px)",
          "measure": "num('.vl-emenv__head','paddingLeft') / W",
          "expect": 0.0667,
          "tol": 0.006
        },
        {
          "name": "toutes les zones partagent la MÊME marge latérale — un mail qui décale se lit comme deux mails",
          "measure": "Math.abs(num('.vl-emenv__head','paddingLeft') - num('.vl-emenv__band','paddingLeft')) + Math.abs(num('.vl-emenv__head','paddingLeft') - num('.vl-emenv__foot','paddingLeft'))",
          "max": 0.5
        },
        {
          "name": "la colonne est centrée dans la garde",
          "measure": "Math.abs((0 - box('.vl-emenv').left) - (box('.vl-emenv').right - W))",
          "max": 1.5
        },
        {
          "name": "TROIS aplats, et trois DIFFÉRENTS : bandeau, corps, pied",
          "measure": "new Set(['.vl-emenv__band','.vl-emenv__body','.vl-emenv__foot'].map((s) => getComputedStyle(document.querySelector(s)).backgroundColor)).size",
          "expect": 3,
          "tol": 0
        },
        {
          "name": "aucun aplat translucide : Outlook ignore l'alpha et rendrait la couleur PLEINE",
          "measure": "Math.min(...['.vl-emenv__band','.vl-emenv__body','.vl-emenv__foot','.vl-emenv__cta','.vl-emenv__col'].map((s) => { const p = (getComputedStyle(document.querySelector(s)).backgroundColor.match(/[\\d.]+/g) || []).map(Number); return p.length === 4 ? p[3] : 1; }))",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "aucun rayon nulle part — signature de la charte, et zéro dégradation dans Outlook",
          "measure": "['.vl-emenv__col','.vl-emenv__band','.vl-emenv__cta','.vl-emenv__foot'].reduce((a, s) => a + parseFloat(getComputedStyle(document.querySelector(s)).borderTopLeftRadius), 0)",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "le bouton passe le plancher tactile de 44 px",
          "measure": "box('.vl-emenv__cta').h",
          "min": 44
        },
        {
          "name": "le bouton reste un bouton : pas plus de la moitié de la colonne",
          "measure": "box('.vl-emenv__cta').w / W",
          "max": 0.5
        },
        {
          "name": "saut typographique titre / corps ≥ 2,4 — la charte n'a pas de taille intermédiaire",
          "measure": "num('.vl-emenv__title','fontSize') / num('.vl-emenv__lede','fontSize')",
          "min": 2.4
        },
        {
          "name": "PLANCHER DE LISIBILITÉ : aucun corps de texte sous 10 px, micro-typo comprise",
          "measure": "Math.min(...[...document.querySelectorAll('.vl-emenv__col *')].filter((e) => !e.children.length && e.textContent.trim()).map((e) => parseFloat(getComputedStyle(e).fontSize)))",
          "min": 10
        },
        {
          "name": "contraste du nom d'émetteur sur le bandeau ≥ 4,5",
          "measure": "contrast('.vl-emenv__wordmark')",
          "min": 4.5
        },
        {
          "name": "contraste du numéro d'édition ≥ 4,5 — c'est le plus petit corps du bandeau",
          "measure": "contrast('.vl-emenv__issue')",
          "min": 4.5
        },
        {
          "name": "contraste du libellé de bouton ≥ 4,5",
          "measure": "contrast('.vl-emenv__ctalink')",
          "min": 4.5
        },
        {
          "name": "contraste de la rubrique grise ≥ 4,5 — trois lots de suite, la source la plaçait juste dessous",
          "measure": "contrast('.vl-emenv__kicker')",
          "min": 4.5
        },
        {
          "name": "contraste des mentions de pied ≥ 4,5",
          "measure": "contrast('.vl-emenv__legal')",
          "min": 4.5
        },
        {
          "name": "le désabonnement reste identifiable sans la couleur : il est souligné",
          "measure": "cs('.vl-emenv__unsub','textDecorationLine').includes('underline') ? 1 : 0",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "aucun débordement de la colonne",
          "measure": "overflow('.vl-emenv__col')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/layout-09-email-envelope.html"
    },
    {
      "id": "layout-10-bleed-column-inset",
      "name": "Lit d'images à fond perdu et incrustation",
      "family": "layout",
      "ref": "ref-10-campaign-board-red",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Faire porter une slide entière par les IMAGES, sans marge ni carte : deux photos à fond perdu qui touchent les quatre bords de la scène, séparées par une COUTURE unique de la couleur de page — jamais une gouttière symétrique —, et une troisième image beaucoup plus petite posée par-dessus l'une d'elles, sans partager une seule arête avec le lit. C'est cette incrustation flottante, et elle seule, qui donne de la profondeur à une composition entièrement plate.",
      "when_to_use": "Slide de campagne, d'ouverture de chapitre ou de moodboard, où l'image EST le propos et le texte se pose dessus. Vaut aussi pour un hero web plein écran. La typographie ne fait pas partie de ce pattern : elle se pose par-dessus, avec title-02-condensed-overlay-stack.",
      "avoid_when": "Les images n'ont pas de casting couleur commun — deux photos qui ne partagent aucune dominante à fond perdu se lisent comme deux slides collées, et la couture ne les rattrape pas. Écarter aussi quand il faut légender chaque image : ce lit n'a de place pour aucun texte sous les images, seulement dessus.",
      "tags": [
        "image",
        "photo",
        "collage",
        "fond-perdu",
        "bleed",
        "incrustation",
        "overlay",
        "colonnes",
        "hero",
        "campagne",
        "moodboard"
      ],
      "vars": [
        {
          "name": "--vl-slide",
          "role": "couleur de page — c'est elle qui apparaît dans la couture entre les deux colonnes",
          "default": "#EDEAE3"
        },
        {
          "name": "--vl-gutter",
          "role": "largeur de la couture entre les deux colonnes (un filet, pas une gouttière)",
          "default": "8px"
        }
      ],
      "slots": [
        "image-a",
        "image-b",
        "inset"
      ],
      "geometry": {
        "root": ".vl-bci",
        "frame": [
          1600,
          900
        ],
        "ratios": {
          "col_a_over_frame": 0.47,
          "seam_over_frame": 0.005,
          "inset_left_over_frame": 0.65,
          "inset_w_over_frame": 0.28,
          "inset_h_over_frame": 0.31,
          "inset_area_over_frame": 0.0868
        },
        "type_px": {},
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "colonne A à fond perdu : elle touche le bord haut de la scène",
          "measure": "box('.vl-bci__col--a').top / H",
          "expect": 0,
          "tol": 0.001
        },
        {
          "name": "colonne A à fond perdu : elle touche le bord bas de la scène",
          "measure": "(H - box('.vl-bci__col--a').bottom) / H",
          "expect": 0,
          "tol": 0.001
        },
        {
          "name": "colonne A à fond perdu : elle touche le bord gauche de la scène",
          "measure": "box('.vl-bci__col--a').left / W",
          "expect": 0,
          "tol": 0.001
        },
        {
          "name": "colonne B à fond perdu : elle touche le bord droit de la scène",
          "measure": "(W - box('.vl-bci__col--b').right) / W",
          "expect": 0,
          "tol": 0.001
        },
        {
          "name": "les deux colonnes couvrent toute la hauteur de la scène",
          "measure": "(box('.vl-bci__col--a').h + box('.vl-bci__col--b').h) / (2 * H)",
          "expect": 1,
          "tol": 0.002
        },
        {
          "name": "la couture est un FILET : ≤ 1 % de la largeur de scène",
          "measure": "(box('.vl-bci__col--b').left - box('.vl-bci__col--a').right) / W",
          "max": 0.01
        },
        {
          "name": "la couture existe : les colonnes ne se touchent pas et ne se chevauchent pas",
          "measure": "(box('.vl-bci__col--b').left - box('.vl-bci__col--a').right) / W",
          "min": 0.002
        },
        {
          "name": "colonnes de largeurs INÉGALES : un lit coupé, pas un diptyque",
          "measure": "box('.vl-bci__col--a').w / box('.vl-bci__col--b').w",
          "min": 0.8,
          "max": 0.96
        },
        {
          "name": "l'incrustation reste une incrustation : ≤ 12 % de l'aire de la scène",
          "measure": "(box('.vl-bci__inset').w * box('.vl-bci__inset').h) / (W * H)",
          "max": 0.12
        },
        {
          "name": "l'incrustation est visible : ≥ 5 % de l'aire de la scène",
          "measure": "(box('.vl-bci__inset').w * box('.vl-bci__inset').h) / (W * H)",
          "min": 0.05
        },
        {
          "name": "l'incrustation FLOTTE : elle ne touche aucun bord de la scène",
          "measure": "Math.min(box('.vl-bci__inset').left, box('.vl-bci__inset').top, W - box('.vl-bci__inset').right, H - box('.vl-bci__inset').bottom) / W",
          "min": 0.02
        },
        {
          "name": "l'incrustation ne partage aucune arête verticale avec la couture",
          "measure": "Math.min(Math.abs(box('.vl-bci__inset').left - box('.vl-bci__col--b').left), Math.abs(box('.vl-bci__inset').right - box('.vl-bci__col--b').left)) / W",
          "min": 0.05
        },
        {
          "name": "l'incrustation est posée SUR une colonne : entièrement dans la colonne B",
          "measure": "(box('.vl-bci__inset').left - box('.vl-bci__col--b').left) / W",
          "min": 0
        },
        {
          "name": "l'incrustation est en format paysage, comme le lit qui la porte",
          "measure": "box('.vl-bci__inset').w / box('.vl-bci__inset').h",
          "min": 1.2,
          "max": 2.2
        },
        {
          "name": "scène au format 16:9 — c'est une slide, pas une vignette",
          "measure": "W / H",
          "expect": 1.7778,
          "tol": 0.01
        },
        {
          "name": "aucune marge de scène : les images occupent toute la largeur, couture comprise",
          "measure": "(box('.vl-bci__col--a').w + box('.vl-bci__col--b').w) / W",
          "min": 0.985
        }
      ],
      "pptx": null,
      "html": "patterns/layout-10-bleed-column-inset.html"
    },
    {
      "id": "layout-11-hero-card-on-photo",
      "name": "Carte de hero incrustée sur un sol photographique",
      "family": "layout",
      "ref": "ref-07-retro-brand-hero",
      "media": [
        "web"
      ],
      "intent": "Poser la coque d'un hero de page en DEUX couches seulement : une photo pleine page qui est le sol de la page — pas un décor de maquette, pas une mise en scène —, et une carte unie qui y est INCRUSTÉE, marges de sol strictement égales à gauche et à droite, rayon discret et ombre descendante qui la fait flotter. Le sol reste une photo : posée sur un aplat de sa propre couleur, la carte perd son bord et le hero n'a plus de sol du tout.",
      "when_to_use": "Hero de page d'accueil ou de landing dont on veut que la photo appartienne à la PAGE et non à une mise en scène du design : la carte porte toute la typographie, la photo n'affleure qu'en cadre autour d'elle. Sert aussi de coque à un écran de couverture web où le contenu doit rester lisible sur une image mouvante.",
      "avoid_when": "La photo est une mise en scène du design (une maquette posée sur un bureau, une ombre d'objet physique, une perspective) : il faut alors la retirer, pas l'incruster — c'est exactement l'arbitrage rendu entre cette référence et ref-08. Écarter aussi quand le contenu doit saigner jusqu'aux bords : ce pattern n'a de sens que si la carte laisse voir son sol sur ses quatre côtés.",
      "tags": [
        "hero",
        "landing",
        "photo",
        "image",
        "incrustation",
        "carte",
        "flottant",
        "ombre",
        "coque",
        "shell",
        "accueil"
      ],
      "vars": [
        {
          "name": "--vl-card",
          "role": "aplat de la carte incrustée — doit trancher sur le sol, jamais s'y fondre",
          "default": "#FBF7EC"
        },
        {
          "name": "--vl-radius-card",
          "role": "rayon de la carte : un liseré, jamais une pilule",
          "default": "6px"
        },
        {
          "name": "--vl-shadow-card",
          "role": "ombre descendante et diffuse — c'est elle, et elle seule, qui fait flotter la carte",
          "default": "0 24px 60px rgba(0, 0, 0, 0.18)"
        },
        {
          "name": "--vl-margin",
          "role": "marge intérieure de la carte, sur laquelle se ferre tout ce qu'on y pose",
          "default": "44px"
        },
        {
          "name": "--vl-ground-sky-hi",
          "role": "haut du ciel du sol photographique",
          "default": "#D7E6EC"
        },
        {
          "name": "--vl-ground-sky-lo",
          "role": "bas du ciel — et repli opaque de la scène, ce que lit le lint de composition",
          "default": "#BBD3DE"
        },
        {
          "name": "--vl-ground-sun",
          "role": "halo solaire diffusé dans le ciel",
          "default": "#F4EFE2"
        },
        {
          "name": "--vl-ground-sea-hi",
          "role": "mer, près du rivage",
          "default": "#7FA8BC"
        },
        {
          "name": "--vl-ground-sea-lo",
          "role": "mer, au large",
          "default": "#5E8AA0"
        },
        {
          "name": "--vl-ground-foam",
          "role": "écume et lignes de houle",
          "default": "#EFEAE0"
        },
        {
          "name": "--vl-ground-sand-wet",
          "role": "sable mouillé, laisses de marée",
          "default": "#C7B49A"
        },
        {
          "name": "--vl-ground-sand-dry",
          "role": "sable sec",
          "default": "#E4D5BC"
        }
      ],
      "slots": [
        "ground",
        "content"
      ],
      "geometry": {
        "root": ".vl-hcop",
        "frame": [
          1440,
          900
        ],
        "ratios": {
          "card_w_over_frame": 0.7806,
          "card_h_over_frame_h": 0.7422,
          "ground_margin_x_over_frame": 0.1097,
          "ground_margin_y_over_frame_h": 0.1289,
          "card_radius_over_frame": 0.0042,
          "shadow_offset_y_over_frame_h": 0.0267,
          "shadow_blur_over_frame": 0.0417,
          "card_pad_over_card_w": 0.0391
        },
        "type_px": {},
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "marges de sol gauche et droite ÉGALES : c'est une incrustation, pas une carte poussée dans un coin",
          "measure": "Math.abs(box('.vl-hcop__card').left - (W - box('.vl-hcop__card').right)) / W",
          "max": 0.002
        },
        {
          "name": "marge de sol latérale : 11 % de la largeur de page",
          "measure": "box('.vl-hcop__card').left / W",
          "expect": 0.1097,
          "tol": 0.005
        },
        {
          "name": "marges de sol haute et basse égales",
          "measure": "Math.abs(box('.vl-hcop__card').top - (H - box('.vl-hcop__card').bottom)) / H",
          "max": 0.002
        },
        {
          "name": "marge de sol verticale : 12,9 % de la hauteur de page",
          "measure": "box('.vl-hcop__card').top / H",
          "expect": 0.1289,
          "tol": 0.006
        },
        {
          "name": "la carte occupe 78 % de la largeur de page",
          "measure": "box('.vl-hcop__card').w / W",
          "expect": 0.7806,
          "tol": 0.008
        },
        {
          "name": "la carte occupe 74 % de la hauteur de page",
          "measure": "box('.vl-hcop__card').h / H",
          "expect": 0.7422,
          "tol": 0.008
        },
        {
          "name": "la carte n'est JAMAIS à fond perdu : le sol reste visible sur ses quatre côtés",
          "measure": "Math.min(box('.vl-hcop__card').left, box('.vl-hcop__card').top, W - box('.vl-hcop__card').right, H - box('.vl-hcop__card').bottom) / W",
          "min": 0.06
        },
        {
          "name": "le sol couvre TOUTE la page : aucune bande de fond nu autour de la photo",
          "measure": "(box('.vl-hcop__ground').w * box('.vl-hcop__ground').h) / (W * H)",
          "expect": 1,
          "tol": 0.002
        },
        {
          "name": "le sol n'est PAS un aplat de la couleur de la carte : sans écart, la carte perd son bord",
          "measure": "(() => { const p = (s) => (getComputedStyle(document.querySelector(s)).backgroundColor.match(/[\\d.]+/g) || []).map(Number); const a = p('.vl-hcop'), b = p('.vl-hcop__card'); return Math.max(Math.abs(a[0]-b[0]), Math.abs(a[1]-b[1]), Math.abs(a[2]-b[2])) / 255; })()",
          "min": 0.06
        },
        {
          "name": "la carte FLOTTE : une ombre portée est déclarée",
          "measure": "cs('.vl-hcop__card','boxShadow') === 'none' ? 0 : 1",
          "expect": 1,
          "tol": 0.001
        },
        {
          "name": "l'ombre tombe VERS LE BAS : décalage vertical ≥ 1 % de la hauteur de page",
          "measure": "(() => { const m = (cs('.vl-hcop__card','boxShadow').match(/-?[\\d.]+px/g) || []).map(parseFloat); return m[1] / H; })()",
          "min": 0.01
        },
        {
          "name": "l'ombre est DIFFUSE, jamais un liseré dur : flou ≥ 2 % de la largeur de page",
          "measure": "(() => { const m = (cs('.vl-hcop__card','boxShadow').match(/-?[\\d.]+px/g) || []).map(parseFloat); return m[2] / W; })()",
          "min": 0.02
        },
        {
          "name": "le rayon de la carte est un LISERÉ, pas une pilule : ≤ 0,8 % de la largeur de page",
          "measure": "num('.vl-hcop__card','borderTopLeftRadius') / W",
          "min": 0.001,
          "max": 0.008
        },
        {
          "name": "scène de HERO WEB au ratio 1,6 — ce n'est pas une slide 16:9",
          "measure": "W / H",
          "expect": 1.6,
          "tol": 0.01
        }
      ],
      "pptx": null,
      "html": "patterns/layout-11-hero-card-on-photo.html"
    },
    {
      "id": "layout-12-nav-three-zone",
      "name": "Nav en trois zones, nom centré sur le conteneur",
      "family": "layout",
      "ref": "ref-07-retro-brand-hero",
      "media": [
        "web",
        "slide"
      ],
      "intent": "Poser une navigation en trois zones — liens à gauche, nom de marque au centre, bouton d'action à droite — dont le nom est calé sur l'axe du CONTENEUR et non entre ses deux voisins. Les zones latérales n'ont jamais la même largeur : un centrage entre elles fait dériver le nom de plusieurs dizaines de pixels, et rien ne le signale tant qu'on ne le mesure pas. La zone centrale sort donc du flux, ce qui est la seule construction où l'axe du nom ne dépend pas de ce qu'on écrit à ses côtés.",
      "when_to_use": "Nav de page vitrine ou de hero où le nom de marque doit être le point d'équilibre visuel de la rangée, avec une baseline en italique sous le nom et un seul bouton d'action. Tient dans n'importe quel conteneur : c'est l'axe du conteneur qui commande, pas la largeur de la scène.",
      "avoid_when": "Il y a plus d'une action à droite, ou plus de quatre liens à gauche : les zones latérales finissent par mordre sur le nom, et le pattern n'a alors plus de centre à défendre. Écarter aussi quand le nom de marque est un logotype large — au-delà d'un tiers de la rangée, le centrage optique cesse de se voir et une nav en deux zones est plus honnête.",
      "tags": [
        "nav",
        "navigation",
        "header",
        "menu",
        "logo",
        "marque",
        "bouton",
        "cta",
        "trois-zones",
        "centrage-optique",
        "baseline"
      ],
      "vars": [
        {
          "name": "--vl-card",
          "role": "fond de la rangée — celui du conteneur qui la porte, jamais un aplat propre",
          "default": "#FBF7EC"
        },
        {
          "name": "--vl-ink",
          "role": "encre des liens, du nom et de la baseline",
          "default": "#1E1B18"
        },
        {
          "name": "--vl-accent",
          "role": "aplat du bouton — le seul de la rangée",
          "default": "#B2201C"
        },
        {
          "name": "--vl-on-accent",
          "role": "libellé du bouton, posé sur l'accent",
          "default": "#FBF7EC"
        },
        {
          "name": "--vl-radius-pill",
          "role": "rayon du bouton",
          "default": "4px"
        }
      ],
      "slots": [
        "links",
        "brand",
        "action"
      ],
      "geometry": {
        "root": ".vl-n3z",
        "frame": [
          1036,
          40
        ],
        "ratios": {
          "logo_center_over_frame": 0.5,
          "cta_h_over_frame_h": 0.85,
          "links_gap_over_frame": 0.0251,
          "baseline_over_name_fontsize": 0.5
        },
        "type_px": {
          "name": 22,
          "links": 13,
          "cta": 12,
          "baseline": 11
        },
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "le nom est centré sur l'axe du CONTENEUR",
          "measure": "Math.abs((box('.vl-n3z__logo').left + box('.vl-n3z__logo').right) / 2 - W / 2) / W",
          "max": 0.004
        },
        {
          "name": "et il n'est PAS centré entre ses deux voisins : la dérive évitée est réelle, pas théorique",
          "measure": "Math.abs((box('.vl-n3z__links').right + box('.vl-n3z__cta').left) / 2 - W / 2) / W",
          "min": 0.01
        },
        {
          "name": "les deux zones latérales ont des largeurs INÉGALES — sinon le centrage optique serait sans objet",
          "measure": "Math.abs(box('.vl-n3z__links').w - box('.vl-n3z__cta').w) / W",
          "min": 0.02
        },
        {
          "name": "les liens sont ferrés sur le bord gauche du conteneur",
          "measure": "box('.vl-n3z__links').left / W",
          "max": 0.002
        },
        {
          "name": "le bouton est ferré sur le bord droit du conteneur",
          "measure": "(W - box('.vl-n3z__cta').right) / W",
          "max": 0.002
        },
        {
          "name": "les trois zones ne se chevauchent jamais : du blanc de part et d'autre du nom",
          "measure": "Math.min(box('.vl-n3z__logo').left - box('.vl-n3z__links').right, box('.vl-n3z__cta').left - box('.vl-n3z__logo').right) / W",
          "min": 0.03
        },
        {
          "name": "la baseline est SECONDAIRE : au plus 60 % du corps du nom",
          "measure": "num('.vl-n3z__baseline','fontSize') / num('.vl-n3z__name','fontSize')",
          "min": 0.35,
          "max": 0.6
        },
        {
          "name": "plancher de lecture web : la baseline italique ne descend pas sous 11 px (DOCTRINE §7)",
          "measure": "num('.vl-n3z__baseline','fontSize')",
          "min": 11
        },
        {
          "name": "contraste des liens de nav sur le fond de la rangée",
          "measure": "contrast('.vl-n3z__links a')",
          "min": 4.5
        },
        {
          "name": "contraste de la baseline UNE FOIS SON OPACITÉ COMPOSITÉE — c'est là que la source échoue d'habitude",
          "measure": "(() => { const el = document.querySelector('.vl-n3z__baseline'); const s = getComputedStyle(el); const o = parseFloat(s.opacity); const c = (s.color.match(/[\\d.]+/g) || []).map(Number); const b = (getComputedStyle(document.querySelector('.vl-n3z')).backgroundColor.match(/[\\d.]+/g) || []).map(Number); const mix = [0,1,2].map((i) => c[i] * o + b[i] * (1 - o)); const L = (p) => { const f = p.map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2]; }; const a = L(mix), d = L(b.slice(0, 3)); const [hi, lo] = a > d ? [a, d] : [d, a]; return (hi + 0.05) / (lo + 0.05); })()",
          "min": 4.5
        },
        {
          "name": "le bouton est le SEUL aplat de la rangée",
          "measure": "[...document.querySelectorAll('.vl-n3z *')].filter((e) => { const a = (getComputedStyle(e).backgroundColor.match(/[\\d.]+/g) || []); return a.length === 3 || (a.length === 4 && Number(a[3]) > 0.02); }).length",
          "expect": 1,
          "tol": 0.001
        },
        {
          "name": "la rangée tient sur UNE ligne : rien ne déborde en hauteur",
          "measure": "overflow('.vl-n3z') / H",
          "max": 0.02
        }
      ],
      "pptx": null,
      "html": "patterns/layout-12-nav-three-zone.html"
    },
    {
      "id": "layout-13-image-triptych",
      "name": "Triptyque d'images en bande",
      "family": "layout",
      "ref": "ref-07-retro-brand-hero",
      "media": [
        "web",
        "slide",
        "print"
      ],
      "intent": "Poser trois images de façon qu'elles se lisent comme UN bloc et non comme trois objets : une hauteur COMMUNE, une gouttière constante et petite devant une cellule, une bande remplie exactement d'un bord à l'autre. La hauteur, les trois poids de largeur et — quand la hauteur est laissée libre — le ratio qui la déduit sont les seuls paramètres : c'est par eux que la même bande donne un triptyque portrait à poids égaux dans un coin, ou une frise à cellules inégales sur toute une largeur.",
      "when_to_use": "Un bloc de trois visuels qui doit peser comme une masse unique — contrepoids d'un titre, frise de pied de page, bandeau de trois vues d'un même sujet. Se pose dans n'importe quelle largeur : la bande la remplit, et sa hauteur est soit imposée, soit déduite du ratio de la première cellule. Des poids inégaux sont un usage NORMAL du pattern, pas une entorse — c'est même eux qui font qu'une bande se lit comme une suite de photographies plutôt que comme une grille.",
      "avoid_when": "Une des trois images doit être regardée pour elle-même : la bande écrase les hiérarchies, et une image à mettre en avant demande une composition à mise en avant. Écarter aussi au-delà de trois cellules — à quatre, chaque vignette passe sous le quart de la largeur et la bande devient une planche de contact.",
      "tags": [
        "images",
        "triptyque",
        "bande",
        "frise",
        "photos",
        "grille",
        "gouttiere",
        "ratio",
        "flex",
        "media"
      ],
      "vars": [
        {
          "name": "--vl-gap",
          "role": "gouttière — la MÊME entre les deux paires, c'est elle qui fait la bande",
          "default": "12px"
        },
        {
          "name": "--vl-radius-photo",
          "role": "rayon des trois cellules, identique par construction",
          "default": "8px"
        },
        {
          "name": "--vl-ph-a-hi",
          "role": "image A (vague) — haut du dégradé d'eau",
          "default": "#86B0C4"
        },
        {
          "name": "--vl-ph-a-lo",
          "role": "image A — eau profonde, sert aussi à la masse de la vague",
          "default": "#3F6B84"
        },
        {
          "name": "--vl-ph-a-crest",
          "role": "image A — crête d'écume",
          "default": "#F1EDE3"
        },
        {
          "name": "--vl-ph-b-sky",
          "role": "image B (parasol) — ciel",
          "default": "#C9DEE7"
        },
        {
          "name": "--vl-ph-b-canvas",
          "role": "image B — toile du parasol, le seul rappel de l'accent hors typographie",
          "default": "#D8442E"
        },
        {
          "name": "--vl-ph-b-canvas-lo",
          "role": "image B — face à l'ombre de la toile",
          "default": "#92251C"
        },
        {
          "name": "--vl-ph-b-pole",
          "role": "image B — mât",
          "default": "#EDE4D2"
        },
        {
          "name": "--vl-ph-c-hi",
          "role": "image C (dune) — crête éclairée",
          "default": "#EFE1C6"
        },
        {
          "name": "--vl-ph-c-lo",
          "role": "image C — sable à l'ombre, sert aussi au sable de l'image B",
          "default": "#B9A181"
        }
      ],
      "slots": [
        "cell-a",
        "cell-b",
        "cell-c"
      ],
      "geometry": {
        "root": ".vl-tri",
        "frame": [
          472,
          199
        ],
        "ratios": {
          "cell_ratio": 0.75,
          "gap_over_frame": 0.0254,
          "gap_over_cell": 0.0804,
          "cell_w_over_frame": 0.3164
        },
        "type_px": {},
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "LA LOI DE LA BANDE — hauteur COMMUNE aux trois cellules : un pied et un sommet uniques",
          "measure": "(() => { const h = [...document.querySelectorAll('.vl-tri__cell')].map((e) => e.getBoundingClientRect().height); return (Math.max(...h) - Math.min(...h)) / H; })()",
          "max": 0.005
        },
        {
          "name": "à poids égaux le même ratio en DÉCOULE — corollaire du cas ref-07, pas la loi (cf. le reparamétrage ref-08 plus bas)",
          "measure": "(() => { const r = [...document.querySelectorAll('.vl-tri__cell')].map((e) => { const b = e.getBoundingClientRect(); return b.width / b.height; }); return Math.max(...r) - Math.min(...r); })()",
          "max": 0.01
        },
        {
          "name": "GOUTTIÈRE CONSTANTE : les deux intervalles sont le même à moins de 0,2 % près",
          "measure": "(() => { const c = [...document.querySelectorAll('.vl-tri__cell')].map((e) => e.getBoundingClientRect()); return Math.abs((c[1].left - c[0].right) - (c[2].left - c[1].right)) / W; })()",
          "max": 0.002
        },
        {
          "name": "gouttière PETITE devant une cellule : au-delà, elle sépare au lieu de relier",
          "measure": "(() => { const c = [...document.querySelectorAll('.vl-tri__cell')].map((e) => e.getBoundingClientRect()); return (c[1].left - c[0].right) / c[0].width; })()",
          "min": 0.03,
          "max": 0.14
        },
        {
          "name": "la bande remplit sa largeur EXACTEMENT : rien ne reste à droite",
          "measure": "(W - box('.vl-tri__cell:nth-child(3)').right) / W",
          "max": 0.002
        },
        {
          "name": "ni à gauche",
          "measure": "box('.vl-tri__cell:nth-child(1)').left / W",
          "max": 0.002
        },
        {
          "name": "les poids par défaut sont ÉGAUX : trois largeurs identiques",
          "measure": "(() => { const w = [...document.querySelectorAll('.vl-tri__cell')].map((e) => e.getBoundingClientRect().width); return (Math.max(...w) - Math.min(...w)) / W; })()",
          "max": 0.003
        },
        {
          "name": "les poids commandent bien la largeur : la base flex est à 0, pas au contenu",
          "measure": "cs('.vl-tri__cell','flexBasis') === '0px' ? 1 : 0",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "ratio PORTRAIT par défaut (3:4) — c'est un paramètre, l'assertion verrouille sa valeur, pas le pattern",
          "measure": "box('.vl-tri__cell:nth-child(1)').w / box('.vl-tri__cell:nth-child(1)').h",
          "expect": 0.75,
          "tol": 0.01
        },
        {
          "name": "UNE SEULE BANDE, DEUX RÉFÉRENCES — reparamétrée en ref-08 (1060 large, gouttière 16, hauteur 380, poids 1 / 1,31 / 1,14), elle rend les cellules relevées sur le deck : 298 / 390,3 / 339,7",
          "measure": "(() => { const c = document.querySelector('.vl-tri').cloneNode(true); c.style.cssText = 'width:1060px;--vl-gap:16px;--vl-tri-h:380px;--vl-tri-ratio:auto;--vl-tri-w1:1;--vl-tri-w2:1.31;--vl-tri-w3:1.14'; document.body.appendChild(c); const b = [...c.querySelectorAll('.vl-tri__cell')].map((e) => e.getBoundingClientRect()); const want = [298, 390.3, 339.7]; const err = Math.max(...b.map((r, i) => Math.abs(r.width - want[i]))) / 1060; c.remove(); return err; })()",
          "max": 0.002
        },
        {
          "name": "et c'est bien la HAUTEUR qui les tient, pas le ratio : reparamétrée, la bande garde une hauteur unique alors que les trois ratios s'écartent de plus de 0,2",
          "measure": "(() => { const c = document.querySelector('.vl-tri').cloneNode(true); c.style.cssText = 'width:1060px;--vl-gap:16px;--vl-tri-h:380px;--vl-tri-ratio:auto;--vl-tri-w1:1;--vl-tri-w2:1.31;--vl-tri-w3:1.14'; document.body.appendChild(c); const b = [...c.querySelectorAll('.vl-tri__cell')].map((e) => e.getBoundingClientRect()); const hs = b.map((r) => r.height), rs = b.map((r) => r.width / r.height); const flat = (Math.max(...hs) - Math.min(...hs)) / 380; const spread = Math.max(...rs) - Math.min(...rs); const full = Math.abs((b[2].right - b[0].left) - 1060) / 1060; c.remove(); return (flat <= 0.005 && spread >= 0.2 && full <= 0.002) ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "chaque cellule CLIPPE son image : une bande pleine n'a pas de bord de fond visible",
          "measure": "[...document.querySelectorAll('.vl-tri__cell')].filter((e) => getComputedStyle(e).overflow === 'hidden').length",
          "expect": 3,
          "tol": 0.001
        },
        {
          "name": "l'image REMPLIT sa cellule : aucune bande de fond entre l'image et son cadre",
          "measure": "(() => { const c = document.querySelector('.vl-tri__cell').getBoundingClientRect(); const s = document.querySelector('.vl-tri__cell > svg').getBoundingClientRect(); return Math.max(Math.abs(c.width - s.width), Math.abs(c.height - s.height)) / c.width; })()",
          "max": 0.002
        },
        {
          "name": "même rayon sur les trois — un rayon qui varie recompte les objets",
          "measure": "new Set([...document.querySelectorAll('.vl-tri__cell')].map((e) => getComputedStyle(e).borderRadius)).size",
          "expect": 1,
          "tol": 0.001
        },
        {
          "name": "la hauteur de la bande est celle des cellules : rien ne déborde en dessous",
          "measure": "Math.abs(box('.vl-tri__cell:nth-child(1)').h - H) / H",
          "max": 0.005
        }
      ],
      "pptx": null,
      "html": "patterns/layout-13-image-triptych.html"
    },
    {
      "id": "layout-14-statement-first",
      "name": "Page qui parle avant de se nommer",
      "family": "layout",
      "ref": "ref-08-swiss-studio-hero",
      "media": [
        "web",
        "slide"
      ],
      "intent": "Inverser l'ordre de lecture d'un hero : la phrase parle en premier dans un petit corps, la navigation lui fait face à la même hauteur, et le nom de marque n'arrive qu'ensuite — mais il prend alors toute la largeur utile, sans qu'AUCUNE taille intermédiaire n'existe entre les deux. C'est ce saut sans palier, et le blanc plus large qui le précède, qui font qu'un nom devient un événement au lieu d'être le plus gros d'une série.",
      "when_to_use": "Une page d'accueil ou une couverture dont le message vaut mieux que le nom : studio, agence, maison déjà connue de ceux à qui elle parle. Aussi quand la charte n'a aucun accent coloré — la composition tient entièrement par l'échelle et le blanc, donc elle survit à une palette de trois valeurs.",
      "avoid_when": "Le nom doit être reconnu AVANT le message — page institutionnelle, premier contact avec un public qui ne connaît pas la maison : ici le nom arrive en second, et cet ordre suppose que la phrase se suffit. Écarter aussi quand le nom tient sur deux lignes : le saut d'échelle repose sur un mot unique qui sature la largeur utile, et sur deux lignes le nom cesse d'être un signe pour redevenir un bloc de texte.",
      "tags": [
        "hero",
        "accueil",
        "ordre-de-lecture",
        "echelle",
        "wordmark",
        "nav",
        "bande",
        "grotesk",
        "neutre",
        "blanc"
      ],
      "vars": [
        {
          "name": "--vl-card",
          "role": "fond de la page — c'est la page elle-même, il n'y a pas de sol derrière",
          "default": "#F6F5F2"
        },
        {
          "name": "--vl-ink",
          "role": "encre du nom et de la nav",
          "default": "#111111"
        },
        {
          "name": "--vl-ink-soft",
          "role": "encre de la phrase — le seul texte à LIRE, donc le seul à ne pas être au maximum du contraste",
          "default": "#2A2A28"
        },
        {
          "name": "--vl-margin",
          "role": "marge de page, égale sur les quatre côtés",
          "default": "40px"
        },
        {
          "name": "--vl-radius-card",
          "role": "rayon de la page",
          "default": "4px"
        },
        {
          "name": "--vl-radius-photo",
          "role": "rayon des cellules de la bande de pied",
          "default": "8px"
        },
        {
          "name": "--vl-gap-photo",
          "role": "gouttière de la bande de pied",
          "default": "16px"
        }
      ],
      "slots": [
        "statement",
        "nav",
        "mark",
        "strip"
      ],
      "geometry": {
        "root": ".vl-sf",
        "frame": [
          1140,
          848
        ],
        "ratios": {
          "margin_over_frame": 0.0351,
          "margin_over_frame_h": 0.0472,
          "statement_measure_over_frame": 0.614,
          "strip_h_over_frame_h": 0.448,
          "strip_w_over_frame": 0.9298,
          "mark_over_statement_size": 7.44
        },
        "type_px": {
          "statement": 27,
          "nav": 18,
          "mark": 201
        },
        "pad_ratio": {
          "top": 0.0351,
          "x": 0.0351,
          "bottom": 0.0351
        }
      },
      "benchmarks": [
        {
          "name": "L'ORDRE DE LECTURE — phrase, puis nom, puis bande : trois zones qui ne se chevauchent jamais",
          "measure": "(() => { const s = box('.vl-sf__statement'), m = box('.vl-sf__mark'), t = box('.vl-sf__strip'); return (s.bottom <= m.top + 1 && m.bottom <= t.top + 1) ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "la phrase parle EN PREMIER : elle affleure la marge haute, rien n'est au-dessus d'elle",
          "measure": "box('.vl-sf__statement').top / H",
          "expect": 0.0472,
          "tol": 0.004
        },
        {
          "name": "la nav lui FAIT FACE : même bord haut à moins de 1 % de la hauteur près",
          "measure": "Math.abs(box('.vl-sf__nav').top - box('.vl-sf__statement').top) / H",
          "max": 0.01
        },
        {
          "name": "et elle est fer à DROITE sur la marge : les deux blocs de tête tiennent les deux bords",
          "measure": "(W - box('.vl-sf__nav').right) / W",
          "expect": 0.0351,
          "tol": 0.004
        },
        {
          "name": "les deux blocs de tête ne se croisent jamais : la mesure de la phrase laisse la place à la nav",
          "measure": "(box('.vl-sf__nav').left - box('.vl-sf__statement').right) / W",
          "min": 0.02
        },
        {
          "name": "SAUT D'ÉCHELLE : le corps du nom vaut au moins 7 fois celui de la phrase",
          "measure": "num('.vl-sf__mark','fontSize') / num('.vl-sf__statement','fontSize')",
          "min": 7,
          "max": 9
        },
        {
          "name": "et SANS PALIER : le troisième corps de la page (la nav) est plus PETIT que la phrase, pas entre elle et le nom",
          "measure": "num('.vl-sf__nav','fontSize') < num('.vl-sf__statement','fontSize') ? 1 : 0",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "le blanc qui SÉPARE la phrase du nom est plus large que celui qui suit le nom — c'est lui qui fait lever les yeux",
          "measure": "(box('.vl-sf__mark').top - box('.vl-sf__statement').bottom) / (box('.vl-sf__strip').top - box('.vl-sf__mark').bottom)",
          "min": 1.15
        },
        {
          "name": "la bande tombe en PIED : son bas affleure la marge basse",
          "measure": "(H - box('.vl-sf__strip').bottom) / H",
          "expect": 0.0472,
          "tol": 0.004
        },
        {
          "name": "elle occupe TOUTE la largeur utile — d'une marge à l'autre, jamais une bande centrée plus étroite",
          "measure": "box('.vl-sf__strip').w / W",
          "expect": 0.9298,
          "tol": 0.004
        },
        {
          "name": "et elle pèse près de 45 % de la hauteur : assez pour FERMER la page, pas assez pour la partager en deux",
          "measure": "box('.vl-sf__strip').h / H",
          "expect": 0.448,
          "tol": 0.02
        },
        {
          "name": "marges ÉGALES sur les quatre côtés — une seule valeur, c'est ce qui fait que la page est la surface",
          "measure": "(() => { const s = getComputedStyle(document.querySelector('.vl-sf')); const p = ['paddingTop','paddingRight','paddingBottom','paddingLeft'].map((k) => parseFloat(s[k])); return (Math.max(...p) - Math.min(...p)) / W; })()",
          "max": 0.001
        },
        {
          "name": "rien ne franchit la marge : mesuré sur l'ENCRE du nom (un h1 est un bloc — sa boîte fait la largeur utile quoi qu'on y écrive, donc elle ne prouve rien)",
          "measure": "(() => { const el = document.querySelector('.vl-sf__mark'); const r = document.createRange(); r.selectNodeContents(el); const ink = r.getBoundingClientRect(); const lim = W - 2 * num('.vl-sf','paddingLeft'); return ink.width / lim; })()",
          "min": 0.985,
          "max": 1
        },
        {
          "name": "la phrase reste une ADRESSE et pas un paragraphe : sa mesure est bornée sous les deux tiers de la page",
          "measure": "box('.vl-sf__statement').w / W",
          "min": 0.35,
          "max": 0.65
        }
      ],
      "pptx": null,
      "html": "patterns/layout-14-statement-first.html"
    },
    {
      "id": "layout-15-primitive-mosaic",
      "name": "Planche de primitives, cellule carrée",
      "family": "layout",
      "ref": "ref-01-bento-pills-2030",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Composer une couverture qui ne dit rien et se lit quand même : une planche de formes pleines posées sur une grille dont toutes les cellules sont le MÊME carré, une gouttière unique et petite, et un seul creux placé exprès. Aucune taille n'est choisie objet par objet — une primitive ne se distingue que par son RAYON, ce qui fait que la planche entière se reparamètre d'une seule variable. Et parce que 4 colonnes × 3 rangées de carrés plafonnent arithmétiquement à un aspect de 3/2, elle se cale sur la HAUTEUR de sa scène et se centre : la loi d'aspect fait partie du pattern, elle n'est pas une contrainte qu'on subit.",
      "when_to_use": "Une couverture, un intercalaire ou une ouverture de chapitre qui doit porter une identité SANS texte — rapport annuel, design system, dossier de marque. Aussi comme fond de section quand le contenu arrive ailleurs : la planche donne un vocabulaire de formes (les trois rayons) que le reste du document réemploie, et c'est cet écartement de rayons, pas la couleur, qui fait la signature.",
      "avoid_when": "Il y a du contenu à ranger : ce n'est PAS un bento. Un bento range des blocs de contenu dans des cartes de tailles choisies ; ici la taille n'est pas choisie et une cellule ne porte rien. Écarter aussi dès que la scène impose de REMPLIR un 16:9 — le bloc plafonne à 1,5 d'aspect et vaut 1,342 en pratique ; l'étirer casserait les cellules carrées, donc les deux discs et les deux teardrops. Et écarter en format social portrait, pour la raison inverse : une planche à 1,34 posée dans un 4:5 laisse un tiers de la hauteur vide.",
      "tags": [
        "couverture",
        "geometrie",
        "grille",
        "primitives",
        "sans-texte",
        "bento",
        "carre",
        "rayon",
        "mosaique",
        "annee"
      ],
      "vars": [
        {
          "name": "--vl-mos-cell",
          "role": "le côté de la cellule CARRÉE — le seul paramètre d'échelle : il fixe la planche entière et les trois rayons, qui en sont des fractions",
          "default": "232px"
        },
        {
          "name": "--vl-mos-gap",
          "role": "la gouttière, unique — la même horizontalement et verticalement, et c'est elle qui commande l'aspect du bloc entre 4/3 et sa borne de 3/2",
          "default": "19px"
        },
        {
          "name": "--vl-ink",
          "role": "l'encre des primitives neutres (squircle, teardrops, rails)",
          "default": "#0B0B0B"
        },
        {
          "name": "--vl-accent",
          "role": "l'accent des discs et le départ du dégradé — la seule couleur de la planche",
          "default": "#E8342A"
        },
        {
          "name": "--vl-accent-deep",
          "role": "l'arrivée du dégradé de la pilule",
          "default": "#8E1114"
        },
        {
          "name": "--vl-white",
          "role": "le remplissage clair des enfants emboîtés (pastille, extrémité claire de la barre)",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-on-accent",
          "role": "l'encre de l'année, sur le dégradé de la pilule",
          "default": "#FFFFFF"
        }
      ],
      "slots": [
        "year"
      ],
      "geometry": {
        "root": ".vl-mos",
        "frame": [
          985,
          734
        ],
        "ratios": {
          "cell_over_frame": 0.2355,
          "gutter_over_frame": 0.0193,
          "gutter_over_cell": 0.0819,
          "aspect": 1.342,
          "aspect_ceiling": 1.5,
          "span2_over_frame": 0.4904,
          "radius_stadium_over_cell": 0.5,
          "radius_squircle_over_cell": 0.3,
          "radius_teardrop_over_cell": 0.22,
          "inset_over_cell": 0.068,
          "gradient_bar_over_rail": 0.4477,
          "filled_cells_over_total": 0.9167
        },
        "type_px": {
          "year": 190.8
        }
      },
      "benchmarks": [
        {
          "name": "CELLULE CARRÉE — l'unité de la planche est un carré, et c'est ce qui autorise le disc : dans une cellule à 1,43 il devient une ellipse, ou laisse un mou par côté qui détruit le serrage",
          "measure": "box('.vl-mos > :nth-child(1)').w / box('.vl-mos > :nth-child(1)').h",
          "expect": 1,
          "tol": 0.005
        },
        {
          "name": "quatre colonnes ÉGALES et trois rangées égales : aucune taille n'est décidée cellule par cellule",
          "measure": "(() => { const s = getComputedStyle(document.querySelector('.vl-mos')); const v = [...s.gridTemplateColumns.split(/\\s+/), ...s.gridTemplateRows.split(/\\s+/)].map(parseFloat); return (Math.max(...v) - Math.min(...v)) / Math.max(...v); })()",
          "max": 0.003
        },
        {
          "name": "UNE seule gouttière : l'écart horizontal et l'écart vertical sont le même — deux écarts qui ne veulent rien dire, c'est la dette de DOCTRINE §9",
          "measure": "Math.abs((box('.vl-mos > :nth-child(2)').left - box('.vl-mos > :nth-child(1)').right) - (box('.vl-mos > :nth-child(4)').top - box('.vl-mos > :nth-child(1)').bottom)) / W",
          "max": 0.001
        },
        {
          "name": "et elle est PETITE devant la cellule (8 %) : élargie, la planche cesse d'être une surface et redevient une collection d'objets",
          "measure": "(box('.vl-mos > :nth-child(2)').left - box('.vl-mos > :nth-child(1)').right) / box('.vl-mos > :nth-child(1)').w",
          "expect": 0.0819,
          "tol": 0.006
        },
        {
          "name": "LOI D'ASPECT — la planche vaut 1,342 : c'est ce que 4 × 3 cellules carrées PRODUISENT, pas une décision de cadrage",
          "measure": "W / H",
          "expect": 1.342,
          "tol": 0.01
        },
        {
          "name": "…et 3/2 est une BORNE qu'aucune gouttière n'atteint : de 0 à 1000 px de gouttière l'aspect reste entre 4/3 et 1,5 — un 16:9 (1,778) exigerait une gouttière NÉGATIVE, ce n'est pas un arbitrage",
          "measure": "(() => { const el = document.querySelector('.vl-mos'); let worst = 0; for (const g of ['0px', '19px', '60px', '200px', '1000px']) { el.style.setProperty('--vl-mos-gap', g); const b = el.getBoundingClientRect(); worst = Math.max(worst, b.width / b.height); } el.style.removeProperty('--vl-mos-gap'); return worst; })()",
          "min": 1.333,
          "max": 1.5
        },
        {
          "name": "PORTÉE 2 COLONNES — le rail fait exactement deux cellules plus une gouttière, jamais une largeur posée à la main",
          "measure": "box('.vl-mos > :nth-child(2)').w / (2 * box('.vl-mos > :nth-child(1)').w + (box('.vl-mos > :nth-child(2)').left - box('.vl-mos > :nth-child(1)').right))",
          "expect": 1,
          "tol": 0.003
        },
        {
          "name": "PORTÉE 2 RANGÉES — la pilule fait exactement deux cellules plus une gouttière",
          "measure": "box('.vl-mos > :nth-child(5)').h / (2 * box('.vl-mos > :nth-child(1)').h + (box('.vl-mos > :nth-child(4)').top - box('.vl-mos > :nth-child(1)').bottom))",
          "expect": 1,
          "tol": 0.003
        },
        {
          "name": "UN SEUL CREUX, et il est en r2c4 : c'est le placement EXPLICITE du teardrop et du toggle qui l'empêche d'être comblé par le flux automatique de la grille",
          "measure": "(() => { const root = document.querySelector('.vl-mos'); const rb = root.getBoundingClientRect(); const kids = [...root.children].map((e) => e.getBoundingClientRect()); const u = kids[0].width, g = kids[1].left - kids[0].right; const empty = []; for (let r = 0; r < 3; r++) for (let c = 0; c < 4; c++) { const x = rb.left + c * (u + g) + u / 2, y = rb.top + r * (u + g) + u / 2; if (!kids.some((k) => x > k.left && x < k.right && y > k.top && y < k.bottom)) empty.push(r * 4 + c); } return empty.length === 1 && empty[0] === 7 ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "la planche est EXACTEMENT remplie : sa largeur est 4 cellules + 3 gouttières, il ne reste rien à droite",
          "measure": "(4 * box('.vl-mos > :nth-child(1)').w + 3 * (box('.vl-mos > :nth-child(2)').left - box('.vl-mos > :nth-child(1)').right)) / W",
          "expect": 1,
          "tol": 0.003
        },
        {
          "name": "aucune primitive ne déborde de la planche : une forme qui sort de la grille casserait le pourtour de feuille sans qu'aucun alignement ne bouge",
          "measure": "(() => { const root = document.querySelector('.vl-mos'); const r = root.getBoundingClientRect(); return [...root.children].every((e) => { const b = e.getBoundingClientRect(); return b.left >= r.left - 0.5 && b.right <= r.right + 0.5 && b.top >= r.top - 0.5 && b.bottom <= r.bottom + 0.5; }) ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "TROIS RAYONS ÉCARTÉS — le squircle et le teardrop restent à plus de 25 % l'un de l'autre : rapprochés, les deux primitives se confondent et la planche n'en porte plus qu'une",
          "measure": "(num('.vl-mos__p--squircle', 'borderTopLeftRadius') - num('.vl-mos__p--tear-tr', 'borderTopLeftRadius')) / num('.vl-mos__p--squircle', 'borderTopLeftRadius')",
          "min": 0.25
        },
        {
          "name": "le rayon du stadium est écrit en LONGUEUR : sur une boîte 2:1 il vaut la moitié du PETIT côté. `50%` en donnerait la moitié de CHAQUE axe, c'est-à-dire une ellipse — le piège était dans le relevé, sur la primitive la plus fréquente de la planche",
          "measure": "num('.vl-mos__p--rail', 'borderTopLeftRadius') / box('.vl-mos > :nth-child(2)').h",
          "expect": 0.5,
          "tol": 0.005
        },
        {
          "name": "l'ANNÉE sature sa pilule à 88 % — mesurée sur l'ENCRE (un Range), pas sur la boîte du span, qui ne prouverait rien",
          "measure": "(() => { const el = document.querySelector('.vl-mos__year'); const r = document.createRange(); r.selectNodeContents(el); return r.getBoundingClientRect().width / box('.vl-mos__p--pill').w; })()",
          "expect": 0.88,
          "tol": 0.04
        },
        {
          "name": "…et cette saturation NE DÉPEND PAS de la police de l'hôte : le fragment épingle sa famille, parce que 1,216 est la chasse d'un grotesk précis. Servi dans une autre famille, le même calcul saturait 97 % et les chiffres léchaient le bord — vert au harnais, faux au rendu",
          "measure": "(() => { const root = document.querySelector('.vl-mos'); const prev = root.style.fontFamily; root.style.fontFamily = 'Georgia, \"Times New Roman\", serif'; const el = document.querySelector('.vl-mos__year'); const r = document.createRange(); r.selectNodeContents(el); const sat = r.getBoundingClientRect().width / box('.vl-mos__p--pill').w; root.style.fontFamily = prev; return sat; })()",
          "expect": 0.88,
          "tol": 0.04,
          "notes": "La même valeur que le benchmark précédent, et c'est TOUT l'objet : la mesure est refaite après avoir imposé un serif à la racine du fragment. Un fragment qui hérite sa famille de son hôte fait dépendre sa géométrie du harnais qui l'ouvre — `check.mjs` sert « Helvetica Neue » en tête, `render.mjs` la police système : les deux ne donnent pas le même rendu, et seul le premier était mesuré."
        },
        {
          "name": "et elle reste lisible : le blanc est mesuré sur l'extrémité CLAIRE du dégradé, celle qui décide du contraste",
          "measure": "contrast('.vl-mos__year')",
          "min": 3
        }
      ],
      "pptx": null,
      "html": "patterns/layout-15-primitive-mosaic.html"
    },
    {
      "id": "layout-16-header-tripartite",
      "name": "Header tri-parti à filet, centre sur l'axe",
      "family": "layout",
      "ref": "ref-05-proposal-acid-yellow",
      "media": [
        "slide",
        "web",
        "print"
      ],
      "intent": "Poser le bandeau d'appareil qui court en tête de chaque page d'un document — émetteur à gauche, mention centrale, pile et jeton de bord à droite, un filet sous les trois — de façon que la zone centrale soit sur l'AXE du conteneur et n'en bouge plus, quels que soient les libellés de ses voisines. Deux rails `minmax(0, 1fr)` qui se font face l'y tiennent par construction. Et parce qu'un appareil n'est pas un titre, il n'a qu'UN corps et DEUX niveaux d'encre : la hiérarchie s'y lit à l'encre et à la position, jamais à la taille.",
      "when_to_use": "Le bandeau répété d'un deck, d'un rapport, d'une proposition ou d'une page de documentation : trois informations de nature différente (qui parle, à qui / quand, de quoi) qui doivent se lire comme une réglure et non comme du texte, et rester STRICTEMENT identiques d'une page à l'autre. C'est aussi la bonne construction dès qu'un centre doit tenir son axe entre deux voisins de longueurs libres.",
      "avoid_when": "Une des trois zones porte un objet et non du texte (logotype large, bouton, avatar) : au-delà d'un tiers de la bande, la zone latérale mord sur le centre et le pattern n'a plus de centre à défendre — `layout-12-nav-three-zone` traite ce cas. Écarter aussi quand la bande doit hiérarchiser par la TAILLE (un titre de page dans le header) : cela dément la loi du corps unique, et il faut alors un titre sous le bandeau, pas dedans.",
      "tags": [
        "header",
        "bandeau",
        "en-tete",
        "trois-zones",
        "tripartite",
        "filet",
        "hairline",
        "centrage",
        "axe",
        "micro-caps",
        "capitales",
        "appareil",
        "pied-de-page",
        "grid"
      ],
      "vars": [
        {
          "name": "--vl-htri-w",
          "role": "la largeur de la bande — celle de la zone de contenu de la page, marges comprises",
          "default": "1456px"
        },
        {
          "name": "--vl-htri-size",
          "role": "le corps UNIQUE du bandeau : toutes les zones l'ont, primaires comprises",
          "default": "13px"
        },
        {
          "name": "--vl-htri-lh",
          "role": "l'interligne des piles de deux lignes",
          "default": "1.4"
        },
        {
          "name": "--vl-htri-track",
          "role": "l'interlettrage des capitales — sans lui, un micro-corps en capitales se referme",
          "default": "0.12em"
        },
        {
          "name": "--vl-htri-gap",
          "role": "le blanc minimal de part et d'autre de la zone centrale",
          "default": "56px"
        },
        {
          "name": "--vl-htri-split",
          "role": "ce qui sépare la pile de droite de son jeton de bord",
          "default": "64px"
        },
        {
          "name": "--vl-htri-pad",
          "role": "du bas de la dernière ligne au filet",
          "default": "18px"
        },
        {
          "name": "--vl-htri-ink",
          "role": "l'encre du rang PRIMAIRE — l'un des deux seuls niveaux du bandeau",
          "default": "var(--vl-black)"
        },
        {
          "name": "--vl-htri-muted",
          "role": "l'encre du rang atténué, qui est celle de la bande par défaut",
          "default": "var(--vl-ink-muted)"
        },
        {
          "name": "--vl-htri-rule",
          "role": "le filet qui souligne les trois zones d'un bord à l'autre",
          "default": "var(--vl-hairline)"
        }
      ],
      "slots": [
        "left",
        "center",
        "right",
        "edge"
      ],
      "geometry": {
        "root": ".vl-htri",
        "frame": [
          1456,
          55
        ],
        "ratios": {
          "height_over_width": 0.038,
          "center_axis_over_width": 0.5,
          "rule_gap_over_width": 0.013,
          "left_ink_over_right_ink": 0.4
        },
        "type_px": {
          "micro": 13
        },
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0.012
        }
      },
      "benchmarks": [
        {
          "name": "LA ZONE CENTRALE EST SUR L'AXE DU CONTENEUR",
          "measure": "Math.abs((box('.vl-htri__zone--c').left + box('.vl-htri__zone--c').right) / 2 - W / 2) / W",
          "max": 0.003
        },
        {
          "name": "…et c'est un MÉCANISME : les deux rails latéraux ont exactement la même largeur",
          "measure": "box('.vl-htri__zone--l').w / box('.vl-htri__zone--r').w",
          "expect": 1,
          "tol": 0.005
        },
        {
          "name": "…alors que ce qu'ils PORTENT n'a pas du tout la même largeur d'encre",
          "measure": "(() => { const ink = (s) => { const r = document.createRange(); r.selectNodeContents(document.querySelector(s)); return r.getBoundingClientRect().width; }; return ink('.vl-htri__zone--l') / ink('.vl-htri__zone--r'); })()",
          "max": 0.75
        },
        {
          "name": "CONTRE-ÉPREUVE DU PIÈGE — un centrage entre les deux VOISINS, lui, dériverait de plus de 2 % de la largeur",
          "measure": "(() => { const O = document.querySelector('.vl-htri').getBoundingClientRect(); const ink = (s) => { const r = document.createRange(); r.selectNodeContents(document.querySelector(s)); const b = r.getBoundingClientRect(); return { left: b.left - O.left, right: b.right - O.left }; }; return Math.abs((ink('.vl-htri__zone--l').right + ink('.vl-htri__zone--r').left) / 2 - W / 2) / W; })()",
          "min": 0.02
        },
        {
          "name": "CONTRE-ÉPREUVE DE LA CONSTRUCTION — un libellé de gauche PLUS LARGE QUE SON RAIL, et insécable, ne déplace toujours pas l'axe du centre",
          "measure": "(() => { const el = document.querySelector('.vl-htri__mark'); const old = el.textContent; el.textContent = '©northbeamconsultingpartnersinternationalgroupholdingsworldwide'; const O = document.querySelector('.vl-htri').getBoundingClientRect(); const c = document.querySelector('.vl-htri__zone--c').getBoundingClientRect(); const out = Math.abs((c.left + c.right) / 2 - O.left - W / 2) / W; el.textContent = old; return out; })()",
          "max": 0.004
        },
        {
          "name": "le jeton de droite est poussé au bord EXTRÊME de la bande",
          "measure": "(() => { const O = document.querySelector('.vl-htri').getBoundingClientRect(); const r = document.createRange(); r.selectNodeContents(document.querySelector('.vl-htri__edge')); return (O.right - r.getBoundingClientRect().right) / W; })()",
          "max": 0.003
        },
        {
          "name": "…et la marque touche le bord gauche, symétriquement",
          "measure": "(() => { const O = document.querySelector('.vl-htri').getBoundingClientRect(); const r = document.createRange(); r.selectNodeContents(document.querySelector('.vl-htri__mark')); return (r.getBoundingClientRect().left - O.left) / W; })()",
          "max": 0.003
        },
        {
          "name": "UN SEUL CORPS dans tout le bandeau — c'est ce qui le fait lire comme une réglure",
          "measure": "new Set([...document.querySelectorAll('.vl-htri, .vl-htri *')].map((e) => getComputedStyle(e).fontSize)).size",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "DEUX niveaux d'encre, et pas trois",
          "measure": "new Set([...document.querySelectorAll('.vl-htri, .vl-htri *')].map((e) => getComputedStyle(e).color)).size",
          "expect": 2,
          "tol": 0
        },
        {
          "name": "LA HIÉRARCHIE NE PASSE PAS PAR LA TAILLE : le jeton primaire a le corps ET la graisse de la mention atténuée qui le précède",
          "measure": "num('.vl-htri__edge','fontSize') / num('.vl-htri__pair','fontSize') + (cs('.vl-htri__edge','fontWeight') === cs('.vl-htri__pair','fontWeight') ? 0 : 1)",
          "expect": 1,
          "tol": 0.01
        },
        {
          "name": "CAPITALES PISTÉES — la seule casse d'appareil de la charte : au moins 0,1 em d'interlettrage",
          "measure": "cs('.vl-htri','textTransform') === 'uppercase' ? num('.vl-htri','letterSpacing') / num('.vl-htri','fontSize') : 0",
          "min": 0.1
        },
        {
          "name": "RAIL DU HAUT — les trois zones pendent du même plafond",
          "measure": "(Math.max(...['--l','--c','--r'].map((z) => box('.vl-htri__zone' + z).top)) - Math.min(...['--l','--c','--r'].map((z) => box('.vl-htri__zone' + z).top))) / H",
          "expect": 0,
          "tol": 0.01
        },
        {
          "name": "le filet court sous les TROIS zones, d'un bord à l'autre",
          "measure": "num('.vl-htri','borderBottomWidth')",
          "min": 1
        },
        {
          "name": "…et il respire : le blanc entre la dernière ligne et le filet vaut 1,3 % de la largeur",
          "measure": "(box('.vl-htri').bottom - num('.vl-htri','borderBottomWidth') - Math.max(...['--l','--c','--r'].map((z) => box('.vl-htri__zone' + z).bottom))) / W",
          "expect": 0.0124,
          "tol": 0.003
        },
        {
          "name": "les zones ne se chevauchent jamais : du blanc de part et d'autre du centre",
          "measure": "(() => { const ink = (s) => { const r = document.createRange(); r.selectNodeContents(document.querySelector(s)); return r.getBoundingClientRect(); }; return Math.min(ink('.vl-htri__zone--c').left - ink('.vl-htri__zone--l').right, ink('.vl-htri__zone--r').left - ink('.vl-htri__zone--c').right) / W; })()",
          "min": 0.03
        },
        {
          "name": "APPAREIL, PAS TITRE : la bande reste sous 4 % de sa largeur en hauteur",
          "measure": "H / W",
          "max": 0.04
        },
        {
          "name": "contraste du rang atténué — c'est lui qui porte le plus de texte, et c'est lui qui échoue d'habitude",
          "measure": "contrast('.vl-htri__zone--c div')",
          "min": 4.5
        },
        {
          "name": "contraste du rang primaire",
          "measure": "contrast('.vl-htri__edge')",
          "min": 7
        },
        {
          "name": "rien ne déborde de la bande",
          "measure": "overflow('.vl-htri')",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "AUTONOMIE TYPOGRAPHIQUE — un serif imposé à la page hôte ne déplace pas d'un pixel le bord d'encre du jeton : le fragment épingle sa famille",
          "measure": "(() => { const ink = () => { const r = document.createRange(); r.selectNodeContents(document.querySelector('.vl-htri__edge')); return r.getBoundingClientRect().width; }; const a = ink(); const h = document.documentElement, old = h.style.fontFamily; h.style.fontFamily = 'Georgia, \"Times New Roman\", serif'; const b = ink(); h.style.fontFamily = old; return b / a; })()",
          "expect": 1,
          "tol": 0.01
        }
      ],
      "pptx": null,
      "html": "patterns/layout-16-header-tripartite.html"
    },
    {
      "id": "list-01-giant-numbers",
      "name": "Colonnes à numéro géant",
      "family": "list",
      "ref": "ref-04-swiss-investor-blue",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Aligner trois à quatre arguments de même rang en colonnes, chacun ouvert par un numéro ou un chiffre démesuré qui sert à la fois de repère de lecture et de seule tache de couleur du bloc.",
      "when_to_use": "Bas de slide : les trois causes d'un problème, les trois étapes d'une solution, les quatre chiffres d'une traction. Trois ou quatre colonnes ; à cinq, le numéro cesse d'être un repère et devient une texture.",
      "avoid_when": "Les entrées ne sont pas de même rang (une hiérarchie déguisée en liste), ou chacune demande plus de trois lignes de texte : ce n'est alors plus une liste, c'est une section.",
      "tags": [
        "liste",
        "numerotation",
        "colonnes",
        "chiffre",
        "kpi",
        "argument",
        "deck",
        "slide",
        "filet",
        "suisse",
        "etapes"
      ],
      "vars": [
        {
          "name": "--vl-blue",
          "role": "couleur du numéro sur fond clair",
          "default": "#1B44D8"
        },
        {
          "name": "--vl-hairline",
          "role": "filet vertical entre colonnes, fond clair",
          "default": "#00000018"
        },
        {
          "name": "--vl-hairline-invert",
          "role": "filet vertical sur fond plein",
          "default": "#FFFFFF33"
        },
        {
          "name": "--vl-ink-muted",
          "role": "corps de texte sous le numéro",
          "default": "#6E6E6E"
        }
      ],
      "slots": [
        "num",
        "body"
      ],
      "geometry": null,
      "benchmarks": [],
      "pptx": null,
      "html": "patterns/list-01-giant-numbers.html"
    },
    {
      "id": "list-02-ruled-index",
      "name": "Sommaire à filets numérotés",
      "family": "list",
      "ref": "ref-06-orange-notched",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Lister les sections d'un document en donnant à chacune son libellé, une ligne d'explication et son numéro à droite, séparées par un filet fin qui appartient à l'entrée qu'il suit.",
      "when_to_use": "Sommaire d'un deck ou d'une proposition, 3 à 6 entrées, en colonne de droite face au titre. Aussi valable pour une liste d'étapes ou de livrables numérotés.",
      "avoid_when": "Plus de 6 entrées (les filets deviennent une trame et plus personne ne lit), ou les entrées n'ont pas d'explication — une liste de libellés nus n'a pas besoin de cette structure, un simple empilement suffit.",
      "tags": [
        "sommaire",
        "index",
        "toc",
        "liste",
        "filet",
        "numero",
        "hairline",
        "deck",
        "etapes"
      ],
      "vars": [
        {
          "name": "--vl-hairline",
          "role": "filet sous chaque entrée (opacité comprise)",
          "default": "#00000015"
        },
        {
          "name": "--vl-black",
          "role": "libellé et numéro",
          "default": "#141414"
        },
        {
          "name": "--vl-ink-muted",
          "role": "explication",
          "default": "#6F6F6C"
        }
      ],
      "slots": [
        "label",
        "desc",
        "number"
      ],
      "geometry": {
        "root": ".vl-idx",
        "frame": [
          700,
          420
        ],
        "ratios": {
          "label_over_desc": 1.41
        },
        "type_px": {
          "label": 24,
          "desc": 17
        },
        "pad_ratio": {
          "top": 0.043,
          "x": 0,
          "bottom": 0.043
        }
      },
      "benchmarks": [
        {
          "name": "numéro et libellé alignés sur la MÊME première ligne",
          "measure": "Math.abs(box('.vl-idx__row:first-child .vl-idx__no').top - box('.vl-idx__row:first-child .vl-idx__label').top)",
          "expect": 0,
          "tol": 2
        },
        {
          "name": "numéro et libellé au MÊME corps",
          "measure": "num('.vl-idx__no','fontSize') / num('.vl-idx__label','fontSize')",
          "expect": 1,
          "tol": 0.01
        },
        {
          "name": "saut libellé / explication entre 1,2 et 1,8 (proche = famille, loin = titre)",
          "measure": "num('.vl-idx__label','fontSize') / num('.vl-idx__desc','fontSize')",
          "min": 1.2,
          "max": 1.8
        },
        {
          "name": "filet fin : 1 px, pas un trait",
          "measure": "num('.vl-idx__row','borderBottomWidth')",
          "max": 1.5
        },
        {
          "name": "toutes les entrées ont le même padding vertical",
          "measure": "Math.abs(num('.vl-idx__row:nth-child(2)','paddingTop') - num('.vl-idx__row:nth-child(3)','paddingTop')) + Math.abs(num('.vl-idx__row:nth-child(2)','paddingBottom') - num('.vl-idx__row:nth-child(3)','paddingBottom'))",
          "expect": 0,
          "tol": 0.5
        },
        {
          "name": "la 1re entrée n'a pas de padding haut (le bloc commence à sa ligne)",
          "measure": "num('.vl-idx__row:first-child','paddingTop')",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "le filet reste collé à son entrée : gouttière ≤ 45 % du pas",
          "measure": "(box('.vl-idx__row:nth-child(2)').top - box('.vl-idx__row:nth-child(1)').bottom) / (box('.vl-idx__row:nth-child(2)').top - box('.vl-idx__row:nth-child(1)').top)",
          "max": 0.45
        },
        {
          "name": "explication bornée à ~44 caractères",
          "measure": "box('.vl-idx__desc').w / num('.vl-idx__desc','fontSize')",
          "max": 25
        },
        {
          "name": "contraste de l'explication ≥ 4,5",
          "measure": "contrast('.vl-idx__desc')",
          "min": 4.5
        }
      ],
      "pptx": {
        "primitive": "rows_hairline",
        "pattern": "list-02-ruled-index",
        "note": "Pas encore d'émetteur : le champ annonçait un `index_rows` qui n'a jamais été écrit, ce qui promettait à deck-builder une sortie .pptx inexistante. Ce qu'il faudra y traiter : le filet de séparation est une RANGÉE, pas une bordure — en .pptx il se pose en connecteur pleine largeur entre deux lignes, et l'interligne se règle sur lui, pas sur la boîte de texte."
      },
      "html": "patterns/list-02-ruled-index.html"
    },
    {
      "id": "list-03-two-column-toc",
      "name": "Sommaire numéroté 2 colonnes",
      "family": "list",
      "ref": "ref-04-swiss-investor-blue",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Faire tenir un sommaire de dix à quatorze sections sur une seule slide, en deux colonnes numérotées, lisible d'un coup d'œil et refermable aussitôt.",
      "when_to_use": "Deck long qu'on présente à quelqu'un qui doit savoir combien de temps ça va durer : investisseur, comité, client. Idéalement 10 à 14 entrées.",
      "avoid_when": "Moins de huit entrées (une seule colonne suffit et la deuxième sonne creux), ou entrées de longueur très inégale : à 11 px en capitales, une entrée qui passe sur deux lignes casse l'alignement des filets.",
      "tags": [
        "sommaire",
        "toc",
        "agenda",
        "numerotation",
        "colonnes",
        "deck",
        "slide",
        "navigation",
        "filet",
        "suisse"
      ],
      "vars": [
        {
          "name": "--vl-blue",
          "role": "fond plein de la slide de sommaire",
          "default": "#1B44D8"
        },
        {
          "name": "--vl-hairline-invert",
          "role": "filet sous chaque entrée",
          "default": "#FFFFFF33"
        },
        {
          "name": "--vl-ink-muted-invert",
          "role": "numéro d'entrée, en retrait du libellé",
          "default": "#D3D3D3"
        }
      ],
      "slots": [
        "head",
        "note",
        "entries"
      ],
      "geometry": null,
      "benchmarks": [],
      "pptx": null,
      "html": "patterns/list-03-two-column-toc.html"
    },
    {
      "id": "list-04-due-rows",
      "name": "Liste d'échéances à quatre zones",
      "family": "list",
      "ref": "ref-13-glass-fintech-dashboard",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Rendre une liste d'échéances balayable sans un seul filet : quatre zones tenues par une grille commune, une seule ligne surlignée, une seule pilule d'imminence, et le montant en unique colonne alignée à droite.",
      "when_to_use": "Trois à six échéances, abonnements ou factures à venir, où l'œil doit trouver l'urgente et comparer les montants sans lire les lignes.",
      "avoid_when": "Il faut plus de quatre informations par ligne (c'est un tableau qu'il faut, avec ses en-têtes), ou plus d'une ligne est urgente : la pilule d'imminence ne code quelque chose que tant qu'elle est unique.",
      "tags": [
        "liste",
        "echeances",
        "paiements",
        "abonnements",
        "lignes",
        "grille",
        "montant",
        "imminence",
        "surlignage",
        "dashboard",
        "verre"
      ],
      "vars": [
        {
          "name": "--vl-surface-glass",
          "role": "verre de la carte",
          "default": "rgba(255, 255, 255, 0.45)"
        },
        {
          "name": "--vl-surface-glass-strong",
          "role": "fond de la ligne surlignée + pastilles de logo",
          "default": "rgba(255, 255, 255, 0.68)"
        },
        {
          "name": "--vl-blue",
          "role": "pilule d'imminence (la seule couleur de la liste)",
          "default": "#4F7CF7"
        },
        {
          "name": "--vl-surface-dark",
          "role": "aplat du bouton « tout voir »",
          "default": "#101625"
        },
        {
          "name": "--vl-ink-muted",
          "role": "dates, second rang de l'identité",
          "default": "#6B7186"
        }
      ],
      "slots": [
        "title",
        "action",
        "rows"
      ],
      "geometry": {
        "root": ".vl-due",
        "frame": [
          1512,
          254
        ],
        "ratios": {
          "identity_over_width": 0.2646,
          "amount_over_width": 0.086,
          "row_over_height": 0.2205,
          "mark_over_width": 0.0238
        },
        "type_px": {
          "title": 15,
          "name": 13,
          "plan": 13,
          "amount": 14,
          "micro": 11
        },
        "pad_ratio": {
          "top": 0.0159,
          "x": 0.0185,
          "bottom": 0.0159
        }
      },
      "benchmarks": [
        {
          "name": "une seule ligne surlignée",
          "measure": "document.querySelectorAll('.vl-due__row--on').length",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "une seule pilule d'imminence",
          "measure": "document.querySelectorAll('.vl-due__flag').length",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "les montants sont alignés à droite sur la même verticale (± 1 px)",
          "measure": "Math.max(...[...document.querySelectorAll('.vl-due__amount')].map((e) => e.getBoundingClientRect().right)) - Math.min(...[...document.querySelectorAll('.vl-due__amount')].map((e) => e.getBoundingClientRect().right))",
          "max": 1
        },
        {
          "name": "les libellés d'offre démarrent sur la même verticale (grille commune, pas un flux)",
          "measure": "Math.max(...[...document.querySelectorAll('.vl-due__plan')].map((e) => e.getBoundingClientRect().left)) - Math.min(...[...document.querySelectorAll('.vl-due__plan')].map((e) => e.getBoundingClientRect().left))",
          "max": 1
        },
        {
          "name": "la zone d'identité tient ≈ 28 % de la largeur",
          "measure": "box('.vl-due__who').w / W",
          "expect": 0.2646,
          "tol": 0.05
        },
        {
          "name": "aucun filet de séparation entre les lignes",
          "measure": "num('.vl-due__row','borderBottomWidth') + num('.vl-due__row','borderTopWidth')",
          "max": 0
        },
        {
          "name": "le surlignage déborde des marges du titre : il tient toute la largeur utile",
          "measure": "box('.vl-due__row--on').w / W",
          "min": 0.94
        },
        {
          "name": "le montant est plus lourd que l'offre qu'il chiffre",
          "measure": "num('.vl-due__amount','fontWeight') - num('.vl-due__plan','fontWeight')",
          "min": 100
        },
        {
          "name": "contraste du nom d'échéance ≥ 7",
          "measure": "contrast('.vl-due__name')",
          "min": 7
        },
        {
          "name": "contraste des dates atténuées ≥ 4,5",
          "measure": "contrast('.vl-due__date')",
          "min": 4.5
        },
        {
          "name": "contraste de la pilule d'imminence sur son aplat ≥ 3",
          "measure": "contrast('.vl-due__flag')",
          "min": 3
        },
        {
          "name": "aucun débordement du contenu hors de la carte",
          "measure": "overflow('.vl-due')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/list-04-due-rows.html"
    },
    {
      "id": "list-05-vitals-strip",
      "name": "Bandeau de constantes, sans carte ni filet",
      "family": "list",
      "ref": "ref-19-acid-clinical-timeline",
      "media": [
        "web",
        "slide"
      ],
      "intent": "Aligner cinq mesures hétérogènes sur une seule ligne SANS aucun séparateur : chacune est un couple micro-libellé / valeur, l'unité étant un cran plus petite ET plus claire que le nombre qu'elle suit. C'est ce double affaiblissement de l'unité — taille et encre — qui permet de lire cinq valeurs d'affilée sans filet, sans carte et sans que l'œil confonde un nombre avec l'unité de son voisin.",
      "when_to_use": "Bandeau d'état en tête d'une fiche : constantes d'un patient, indicateurs d'une machine, chiffres-clés d'un dossier. La première mesure peut être qualitative et prendre un corps plus grand — c'est le sujet de la fiche, pas une valeur de plus.",
      "avoid_when": "Les valeurs sont comparables entre elles (une rangée de cartes ou un tableau dit mieux la comparaison), il y en a plus de six (le bandeau se met à défiler et perd son unique atout, la lecture d'un seul coup d'œil), ou l'une d'elles est en alerte : ce bandeau n'a aucune ressource pour le dire, et l'y ajouter demande une couleur que le système réserve ailleurs.",
      "tags": [
        "constantes",
        "vitals",
        "bandeau",
        "strip",
        "mesure",
        "unite",
        "micro-libelle",
        "fiche",
        "dossier",
        "kpi",
        "sans-filet"
      ],
      "vars": [
        {
          "name": "--vl-ink",
          "role": "valeur",
          "default": "#262C2C"
        },
        {
          "name": "--vl-ink-soft",
          "role": "micro-libellé ET unité — la même encre affaiblie pour les deux",
          "default": "#5F615E"
        }
      ],
      "slots": [
        "items"
      ],
      "geometry": {
        "root": ".vl-vitals",
        "frame": [
          1040,
          62
        ],
        "ratios": {
          "gap_over_width": 0.051,
          "unit_over_value": 0.5,
          "label_over_value": 0.467,
          "lead_over_value": 1.233
        },
        "type_px": {
          "lead": 37,
          "value": 30,
          "unit": 15,
          "label": 14
        },
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "une seule ligne : toutes les mesures partagent leur ligne de base basse",
          "measure": "(() => { const b = [...document.querySelectorAll('.vl-vitals > *')].map((e) => e.getBoundingClientRect().bottom); return Math.max(...b) - Math.min(...b); })()",
          "max": 1.5
        },
        {
          "name": "AUCUN séparateur : pas un filet entre deux mesures",
          "measure": "[...document.querySelectorAll('.vl-vitals > *')].reduce((m, e) => m + parseFloat(getComputedStyle(e).borderRightWidth || 0), 0)",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "… et pas de fond non plus : la mesure n'est pas une carte",
          "measure": "[...document.querySelectorAll('.vl-vitals > *')].filter((e) => { const p = (getComputedStyle(e).backgroundColor.match(/[\\d.]+/g) || []).map(Number); return p.length === 4 ? p[3] > 0 : p.length === 3; }).length",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "le vide qui sépare deux mesures vaut au moins 1,4 corps de valeur — c'est lui, le séparateur",
          "measure": "num('.vl-vitals','columnGap') / num('.vl-vitals__item:nth-child(2) .vl-vitals__value','fontSize')",
          "min": 1.4
        },
        {
          "name": "L'UNITÉ EST PLUS PETITE que le nombre qu'elle suit — au plus la moitié",
          "measure": "num('.vl-vitals__item:nth-child(2) .vl-vitals__unit','fontSize') / num('.vl-vitals__item:nth-child(2) .vl-vitals__value','fontSize')",
          "max": 0.55
        },
        {
          "name": "… ET PLUS CLAIRE : c'est le double affaiblissement qui rend le bandeau lisible sans filet",
          "measure": "lum(parseRgb(cs('.vl-vitals__item:nth-child(2) .vl-vitals__unit','color'))) - lum(parseRgb(cs('.vl-vitals__item:nth-child(2) .vl-vitals__value','color')))",
          "min": 0.05
        },
        {
          "name": "le micro-libellé est AU-DESSUS de sa valeur, jamais à côté",
          "measure": "box('.vl-vitals__item:nth-child(2) .vl-vitals__value').top - box('.vl-vitals__item:nth-child(2) .vl-vitals__label').bottom",
          "min": 0,
          "max": 12
        },
        {
          "name": "… et calé sur le même fer à gauche",
          "measure": "Math.abs(box('.vl-vitals__item:nth-child(2) .vl-vitals__label').left - box('.vl-vitals__item:nth-child(2) .vl-vitals__value').left)",
          "max": 1.5
        },
        {
          "name": "le micro-libellé reste un cadrage : au plus la moitié du corps de sa valeur",
          "measure": "num('.vl-vitals__item:nth-child(2) .vl-vitals__label','fontSize') / num('.vl-vitals__item:nth-child(2) .vl-vitals__value','fontSize')",
          "max": 0.55
        },
        {
          "name": "la mesure de tête est plus grande que les autres — c'est le sujet de la fiche",
          "measure": "num('.vl-vitals__lead .vl-vitals__value','fontSize') / num('.vl-vitals__item:nth-child(2) .vl-vitals__value','fontSize')",
          "min": 1.15
        },
        {
          "name": "… mais elle ne fait pas un titre : au plus 1,6 fois",
          "measure": "num('.vl-vitals__lead .vl-vitals__value','fontSize') / num('.vl-vitals__item:nth-child(2) .vl-vitals__value','fontSize')",
          "max": 1.6
        },
        {
          "name": "contraste d'une valeur ≥ 4,5",
          "measure": "contrast('.vl-vitals__item:nth-child(2) .vl-vitals__value')",
          "min": 4.5
        },
        {
          "name": "contraste du micro-libellé ≥ 4,5 — c'est lui qui plafonne",
          "measure": "contrast('.vl-vitals__item:nth-child(2) .vl-vitals__label')",
          "min": 4.5
        },
        {
          "name": "aucun débordement du bandeau",
          "measure": "overflow('.vl-vitals')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/list-05-vitals-strip.html"
    },
    {
      "id": "list-06-email-digest",
      "name": "Digest de newsletter — entrées numérotées séparées par un filet",
      "family": "list",
      "ref": "ref-04-swiss-investor-blue",
      "media": [
        "email"
      ],
      "intent": "Aligner les trois à six entrées d'une lettre d'information dans un mail, chacune tenant en rubrique, titre, chapô et lien, sans qu'aucune ne se lise comme un encadré : la colonne du numéro est une CELLULE de largeur fixe et la séparation un filet posé en haut de chaque entrée — retiré sur la première, pour que le filet sépare au lieu d'encadrer.",
      "when_to_use": "Le corps d'une lettre d'information périodique : plusieurs sujets de même rang, chacun résumé en deux lignes et renvoyant vers un article. Le numéro donne un ordre de lecture explicite, ce qui vaut mieux qu'un tri implicite quand tous les titres pèsent le même poids.",
      "avoid_when": "Une seule entrée (le numéro et le filet n'ont plus rien à séparer, une simple accroche suffit), plus de six (la lettre devient un sommaire et personne ne descend), ou les entrées ne sont pas de même nature — un digest suggère une lecture de haut en bas qui devient fausse dès qu'une entrée est d'un autre genre que les autres.",
      "tags": [
        "email",
        "mailing",
        "newsletter",
        "digest",
        "sommaire",
        "entrees",
        "articles",
        "numerote",
        "filet",
        "separateur",
        "chapo",
        "lien",
        "outlook",
        "table"
      ],
      "vars": [
        {
          "name": "--vl-white",
          "role": "aplat du bloc — plein, jamais translucide",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-black",
          "role": "titres, filets de séparation et lien",
          "default": "#0D0D0D"
        },
        {
          "name": "--vl-blue",
          "role": "le numéro d'entrée — le SEUL élément chromatique du bloc",
          "default": "#1B44D8"
        },
        {
          "name": "--vl-ink-muted",
          "role": "rubrique et chapô",
          "default": "#6E6E6E"
        }
      ],
      "slots": [
        "num",
        "kicker",
        "title",
        "lede",
        "link"
      ],
      "geometry": {
        "root": ".vl-emdig",
        "frame": [
          520,
          594
        ],
        "ratios": {
          "num_col_over_width": 0.1077,
          "title_over_lede": 1.27,
          "num_over_title": 1.37,
          "item_pad_top_over_width": 0.05
        },
        "type_px": {
          "num": 26,
          "title": 19,
          "lede": 15,
          "kicker": 10,
          "link": 12
        },
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0.046
        }
      },
      "benchmarks": [
        {
          "name": "520 px — le slot exact de layout-09-email-envelope (600 − 2 × 40)",
          "measure": "W",
          "expect": 520,
          "tol": 0
        },
        {
          "name": "colonne du numéro ≈ 10,8 % de la largeur (56 px : le chiffre plus sa gouttière)",
          "measure": "box('.vl-emdig__num').w / W",
          "expect": 0.1077,
          "tol": 0.012
        },
        {
          "name": "LE FILET SÉPARE, IL N'ENCADRE PAS : la première entrée n'a pas de filet haut",
          "measure": "num('.vl-emdig__item--first','borderTopWidth')",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "… et les deux suivantes en portent un, d'un pixel",
          "measure": "[...document.querySelectorAll('.vl-emdig__item')].filter((e) => parseFloat(getComputedStyle(e).borderTopWidth) === 1).length",
          "expect": 2,
          "tol": 0
        },
        {
          "name": "aucune bordure basse : le bloc ne se referme pas sur lui-même",
          "measure": "[...document.querySelectorAll('.vl-emdig__item')].reduce((a, e) => a + parseFloat(getComputedStyle(e).borderBottomWidth), 0)",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "LE BLEU NE COLORE QUE LES NUMÉROS — trois éléments chromatiques, pas un de plus",
          "measure": "[...document.querySelectorAll('.vl-emdig *')].filter((e) => { const c = parseRgb(getComputedStyle(e).color); return Math.max(...c) - Math.min(...c) > 40; }).length",
          "expect": 3,
          "tol": 0
        },
        {
          "name": "le numéro pèse plus que le titre qu'il ordonne",
          "measure": "num('.vl-emdig__num','fontSize') / num('.vl-emdig__title','fontSize')",
          "min": 1.25
        },
        {
          "name": "saut typographique titre / chapô ≥ 1,2",
          "measure": "num('.vl-emdig__title','fontSize') / num('.vl-emdig__lede','fontSize')",
          "min": 1.2
        },
        {
          "name": "les entrées respirent autant qu'elles se séparent : l'air au-dessus du filet vaut au moins un corps de titre",
          "measure": "num('.vl-emdig__item','paddingBottom') / num('.vl-emdig__title','fontSize')",
          "min": 1
        },
        {
          "name": "le numéro et sa rubrique partagent leur ligne haute",
          "measure": "Math.abs(box('.vl-emdig__num').top - box('.vl-emdig__kicker').top)",
          "max": 4
        },
        {
          "name": "contraste du chapô gris ≥ 4,5 — le piège payé trois lots de suite",
          "measure": "contrast('.vl-emdig__lede')",
          "min": 4.5
        },
        {
          "name": "contraste de la rubrique ≥ 4,5 : c'est le plus petit corps du bloc",
          "measure": "contrast('.vl-emdig__kicker')",
          "min": 4.5
        },
        {
          "name": "contraste du titre ≥ 7",
          "measure": "contrast('.vl-emdig__title')",
          "min": 7
        },
        {
          "name": "contraste du numéro bleu ≥ 4,5",
          "measure": "contrast('.vl-emdig__num')",
          "min": 4.5
        },
        {
          "name": "le lien reste identifiable sans la couleur : il est souligné",
          "measure": "cs('.vl-emdig__link','textDecorationLine').includes('underline') ? 1 : 0",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "aucun débordement",
          "measure": "overflow('.vl-emdig')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/list-06-email-digest.html"
    },
    {
      "id": "list-07-hairline-spec-table",
      "name": "Tableau libellé/valeur à filets débordants",
      "family": "list",
      "ref": "ref-10-campaign-board-red",
      "media": [
        "slide",
        "web",
        "print"
      ],
      "intent": "Poser les caractéristiques d'un lot — livrables, formats, cadence — en couples libellé / valeur séparés par des filets d'un pixel et demi qui DÉBORDENT la marge de texte des deux côtés. Le filet n'est pas la bordure d'un bloc, c'est une règle de la page que le module traverse : il part avant le libellé et finit après la valeur, et c'est ce seul décalage qui distingue une table de spécifications d'un encadré.",
      "when_to_use": "Slide ou page de livrables, de portée, de mentions techniques : deux à cinq couples dont le libellé est un mot et la valeur une énumération. Il complète title-02-condensed-overlay-stack sur la même scène, sous le titre, et n'a besoin d'aucune surface pour tenir.",
      "avoid_when": "Les valeurs sont des NOMBRES à comparer entre eux, ou il y a plus de deux colonnes : ce module cale sa valeur à droite d'une colonne étroite, ce qui aligne les fins de ligne et non les ordres de grandeur — pour des chiffres, prendre list-04-due-rows. Écarter aussi quand le module ne peut pas déborder la marge de texte de la page (carte, encadré, cellule) : rentré dans un conteneur, le débord disparaît et il ne reste qu'un tableau à filets ordinaire.",
      "tags": [
        "tableau",
        "filets",
        "hairline",
        "libellé",
        "valeur",
        "spécifications",
        "livrables",
        "débord",
        "capitales",
        "micro-caps",
        "campagne"
      ],
      "vars": [
        {
          "name": "--vl-red",
          "role": "couleur unique du module — filets ET texte, jamais deux encres",
          "default": "#C82C15"
        },
        {
          "name": "--vl-hst-rule",
          "role": "épaisseur du filet ; reste un cheveu, sous 0,4 % de la largeur du module",
          "default": "2.5px"
        },
        {
          "name": "--vl-hst-inset",
          "role": "de combien le filet déborde le libellé À GAUCHE — c'est la marge de texte de la page, pas un réglage du module",
          "default": "12.23%"
        },
        {
          "name": "--vl-hst-tail",
          "role": "de combien le filet déborde la valeur À DROITE",
          "default": "4.29%"
        },
        {
          "name": "--vl-hst-val-w",
          "role": "largeur maximale de la colonne de valeur — elle reste une colonne de mesure, pas un paragraphe",
          "default": "270px"
        },
        {
          "name": "--vl-hst-size",
          "role": "corps des micro-capitales, libellé et valeur confondus",
          "default": "13px"
        }
      ],
      "slots": [
        "rows"
      ],
      "geometry": {
        "root": ".vl-hst",
        "frame": [
          1120,
          278
        ],
        "ratios": {
          "frame_over_slide_width": 0.7,
          "rule_over_frame": 0.00223,
          "inset_over_frame": 0.1223,
          "tail_over_frame": 0.0429,
          "value_width_over_frame": 0.2411,
          "pad_bottom_over_pad_top": 1.43
        },
        "type_px": {
          "label": 13,
          "value": 13
        },
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "le filet DÉBORDE le libellé par la gauche — la marge de texte de la page",
          "measure": "(box('.vl-hst__lab').left - box('.vl-hst__row').left) / W",
          "expect": 0.1223,
          "tol": 0.012
        },
        {
          "name": "il déborde AUSSI par la droite : le débord n'est pas un simple retrait de bloc",
          "measure": "(box('.vl-hst__row').right - box('.vl-hst__val').right) / W",
          "min": 0.025,
          "max": 0.08
        },
        {
          "name": "le filet traverse TOUT le module, sur toute sa largeur",
          "measure": "box('.vl-hst__row').w / W",
          "expect": 1,
          "tol": 0.002
        },
        {
          "name": "filet en CHEVEU : sous 0,4 % de la largeur du module, et jamais nul",
          "measure": "num('.vl-hst__row', 'borderTopWidth') / W",
          "min": 0.0008,
          "max": 0.004
        },
        {
          "name": "chaque ligne porte son propre filet, la PREMIÈRE comprise",
          "measure": "[...document.querySelectorAll('.vl-hst__row')].filter((e) => parseFloat(getComputedStyle(e).borderTopWidth) > 0).length / document.querySelectorAll('.vl-hst__row').length",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "AUCUN filet de fermeture en bas — sinon le module redevient un cadre",
          "measure": "[...document.querySelectorAll('.vl-hst__row')].reduce((a, e) => a + parseFloat(getComputedStyle(e).borderBottomWidth), 0)",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "aucune bordure VERTICALE : les colonnes ne sont pas dessinées, elles sont ferrées",
          "measure": "[...document.querySelectorAll('.vl-hst__row')].reduce((a, e) => { const s = getComputedStyle(e); return a + parseFloat(s.borderLeftWidth) + parseFloat(s.borderRightWidth); }, 0)",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "aucune SURFACE : ce module est fait de traits, il ne pose pas de fond",
          "measure": "cs('.vl-hst', 'backgroundColor') === 'rgba(0, 0, 0, 0)' && cs('.vl-hst__row', 'backgroundColor') === 'rgba(0, 0, 0, 0)' ? 1 : 0",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "respiration ASYMÉTRIQUE : le couple est accroché au filet qui l'ouvre",
          "measure": "num('.vl-hst__row', 'paddingBottom') / num('.vl-hst__row', 'paddingTop')",
          "min": 1.2,
          "max": 2.2
        },
        {
          "name": "la valeur reste une COLONNE étroite, pas un paragraphe pleine largeur",
          "measure": "box('.vl-hst__val').w / W",
          "min": 0.14,
          "max": 0.28
        },
        {
          "name": "la valeur est ferrée à DROITE : ce sont les fins de ligne qui s'alignent",
          "measure": "cs('.vl-hst__val', 'textAlign') === 'right' ? 1 : 0",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "le vide entre le libellé et sa valeur est le vrai séparateur du couple",
          "measure": "(box('.vl-hst__val').left - box('.vl-hst__lab').right) / W",
          "min": 0.25
        },
        {
          "name": "la valeur COURT sur plusieurs lignes — une énumération, pas une étiquette",
          "measure": "box('.vl-hst__val').h / num('.vl-hst__val', 'fontSize')",
          "min": 2.5
        },
        {
          "name": "libellé et valeur PENDENT du filet : leurs premières lignes s'alignent",
          "measure": "Math.abs(box('.vl-hst__lab').top - box('.vl-hst__val').top) / num('.vl-hst__lab', 'fontSize')",
          "max": 0.2
        },
        {
          "name": "libellé et valeur partagent le MÊME corps : aucune hiérarchie de taille",
          "measure": "num('.vl-hst__lab', 'fontSize') / num('.vl-hst__val', 'fontSize')",
          "expect": 1,
          "tol": 0.001
        },
        {
          "name": "le libellé tient le plancher de contraste du texte courant",
          "measure": "contrast('.vl-hst__lab')",
          "min": 4.5
        },
        {
          "name": "la valeur aussi — c'est le même rouge, il doit passer aux deux endroits",
          "measure": "contrast('.vl-hst__val')",
          "min": 4.5
        },
        {
          "name": "rien ne déborde en hauteur : chaque ligne contient son texte",
          "measure": "overflow('.vl-hst')",
          "expect": 0,
          "tol": 0
        }
      ],
      "pptx": null,
      "html": "patterns/list-07-hairline-spec-table.html"
    },
    {
      "id": "shape-02-teardrop-quadrant",
      "name": "Carré arrondi à un seul coin droit",
      "family": "shape",
      "ref": "ref-01-bento-pills-2030",
      "media": [
        "slide",
        "web",
        "social"
      ],
      "intent": "Donner à un aplat une ORIENTATION sans lui ajouter la moindre pointe, flèche ou étiquette : un carré dont trois coins sont arrondis du même rayon et dont le quatrième reste vif. La masse est neutre, le coin droit désigne un quadrant — et deux exemplaires posés sur des coins opposés se lisent comme miroir l'un de l'autre, ce qui pose une diagonale dans une composition qui n'a aucun texte pour la porter.",
      "when_to_use": "Dans un vocabulaire de formes pleines — couverture, intercalaire, planche de primitives — où il faut une forme qui ne soit ni un cercle ni un carré ni une gélule, et qui POINTE. Aussi seule, en pastille de coin ou en fond de glyphe, quand la carte doit signaler à quel bord elle appartient : le coin droit se pose contre le bord, les trois arrondis regardent l'intérieur.",
      "avoid_when": "Sur une image ou une vidéo : le coin vif se lit comme un recadrage raté, exactement comme le chanfrein de `shape-notched-corner`. Sur du contenu à lire, aussi — les trois arrondis mangent les quatre angles du bloc de texte de façon dissymétrique, et l'interligne ne rattrape pas. Et dès que la boîte cesse d'être proche du carré : à 2:1 le rayon en longueur reste circulaire mais les trois arrondis n'occupent plus la même part des côtés longs et courts, la forme se lit comme une gélule ébréchée.",
      "tags": [
        "forme",
        "primitive",
        "teardrop",
        "coin",
        "rayon",
        "orientation",
        "quadrant",
        "aplat",
        "couverture",
        "geometrie"
      ],
      "vars": [
        {
          "name": "--vl-td-cell",
          "role": "le côté de la tuile — la forme n'a pas de taille propre, elle remplit sa cellule",
          "default": "232px"
        },
        {
          "name": "--vl-td-radius",
          "role": "le rayon des TROIS coins arrondis, en LONGUEUR (jamais un %) — 0,220 du côté ici, et la forme ne vit qu'entre 0,12 et 0,50",
          "default": "51.04px"
        },
        {
          "name": "--vl-td-gap",
          "role": "l'écart entre les quatre orientations de la démonstration",
          "default": "19px"
        },
        {
          "name": "--vl-ink",
          "role": "l'aplat de la forme",
          "default": "#0B0B0B"
        }
      ],
      "slots": [],
      "geometry": {
        "root": ".vl-td",
        "frame": [
          483,
          483
        ],
        "ratios": {
          "cell_over_frame": 0.4803,
          "gap_over_frame": 0.0393,
          "radius_over_cell": 0.22,
          "radius_floor": 0.12,
          "radius_ceiling": 0.5,
          "sharp_corners_per_tile": 1,
          "orientations": 4
        }
      },
      "benchmarks": [
        {
          "name": "la tuile est CARRÉE : c'est ce qui fait que les trois arrondis occupent la même part de chaque côté, et donc que la masse reste neutre pendant que le coin droit, seul, oriente",
          "measure": "box('.vl-td__q--tl').w / box('.vl-td__q--tl').h",
          "expect": 1,
          "tol": 0.005
        },
        {
          "name": "UN SEUL coin droit, sur les quatre orientations — deux et la forme redevient une gélule, zéro et c'est un squircle, trois et c'est un onglet",
          "measure": "(() => { const P = ['borderTopLeftRadius','borderTopRightRadius','borderBottomRightRadius','borderBottomLeftRadius']; return ['tl','tr','br','bl'].every((k) => P.map((p) => num('.vl-td__q--' + k, p)).filter((v) => v < 0.5).length === 1) ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "les TROIS autres coins portent le MÊME rayon : trois rayons qui diffèrent, c'est une forme organique, plus une primitive",
          "measure": "(() => { const P = ['borderTopLeftRadius','borderTopRightRadius','borderBottomRightRadius','borderBottomLeftRadius']; let worst = 0; for (const k of ['tl','tr','br','bl']) { const v = P.map((p) => num('.vl-td__q--' + k, p)).filter((x) => x >= 0.5); worst = Math.max(worst, (Math.max(...v) - Math.min(...v)) / Math.max(...v)); } return worst; })()",
          "max": 0.003
        },
        {
          "name": "et les quatre ORIENTATIONS partagent ce rayon : ce sont quatre poses d'une même forme, pas quatre formes",
          "measure": "(() => { const P = ['borderTopLeftRadius','borderTopRightRadius','borderBottomRightRadius','borderBottomLeftRadius']; const all = ['tl','tr','br','bl'].flatMap((k) => P.map((p) => num('.vl-td__q--' + k, p)).filter((x) => x >= 0.5)); return (Math.max(...all) - Math.min(...all)) / Math.max(...all); })()",
          "max": 0.003
        },
        {
          "name": "QUADRANT — le coin droit de chaque tuile pointe le coin du bloc qu'elle occupe, et les quatre pointent quatre coins distincts : c'est cette règle qui interdit de permuter deux exemplaires diagonalement opposés",
          "measure": "(() => { const P = ['borderTopLeftRadius','borderTopRightRadius','borderBottomRightRadius','borderBottomLeftRadius']; const want = { tl: 0, tr: 1, br: 2, bl: 3 }; const r = document.querySelector('.vl-td').getBoundingClientRect(); return ['tl','tr','br','bl'].every((k) => { const sharp = P.map((p) => num('.vl-td__q--' + k, p)).findIndex((v) => v < 0.5); const b = document.querySelector('.vl-td__q--' + k).getBoundingClientRect(); const left = b.left - r.left < r.width / 2, top = b.top - r.top < r.height / 2; const quad = top ? (left ? 0 : 1) : (left ? 3 : 2); return sharp === want[k] && quad === want[k]; }) ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "le rayon vaut 0,220 du côté — le réglage de `ref-01`, choisi pour rester écarté du squircle (0,300) de la même planche : rapprochés, les deux primitives se confondent",
          "measure": "num('.vl-td__q--tl', 'borderTopRightRadius') / box('.vl-td__q--tl').w",
          "expect": 0.22,
          "tol": 0.006
        },
        {
          "name": "ARC DE CERCLE, jamais une ellipse — le fragment est REPARAMÉTRÉ sur une cellule aplatie (232 × 160) et la courbe est sondée au point : un rayon écrit en `%` y résoudrait 51 px sur un axe et 35 sur l'autre, et le sondage à 45° tomberait hors de la forme",
          "measure": "(() => { const set = document.querySelector('.vl-td'); set.style.gridTemplateRows = '160px 160px'; const q = document.querySelector('.vl-td__q--tl'); const b = q.getBoundingClientRect(); const r = parseFloat(getComputedStyle(q).borderTopRightRadius); const cx = b.right - r, cy = b.top + r; const at = (k, t) => document.elementFromPoint(cx + Math.cos(t) * r * k, cy - Math.sin(t) * r * k) === q; let ok = 1; for (const a of [10, 30, 45, 60, 80]) { const t = a * Math.PI / 180; if (!at(0.90, t) || at(1.10, t)) ok = 0; } set.style.gridTemplateRows = ''; return ok; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "BANDE DE RAYON — de 0,12 à 0,50 du côté, la forme garde exactement un coin droit, trois rayons égaux, et un rayon EFFECTIF qui suit le paramètre ; au-delà de 0,50 deux arrondis adjacents sommeraient plus que le côté et le navigateur les remettrait tous à l'échelle",
          "measure": "(() => { const set = document.querySelector('.vl-td'); const P = ['borderTopLeftRadius','borderTopRightRadius','borderBottomRightRadius','borderBottomLeftRadius']; const cell = document.querySelector('.vl-td__q--tl').getBoundingClientRect().width; let worst = 0; for (const f of [0.12, 0.22, 0.35, 0.50]) { set.style.setProperty('--vl-td-radius', (cell * f) + 'px'); for (const k of ['tl','tr','br','bl']) { const v = P.map((p) => parseFloat(getComputedStyle(document.querySelector('.vl-td__q--' + k))[p]) || 0); const round = v.filter((x) => x >= 0.5); if (v.filter((x) => x < 0.5).length !== 1 || round.length !== 3) { set.style.removeProperty('--vl-td-radius'); return 1; } worst = Math.max(worst, (Math.max(...round) - Math.min(...round)) / Math.max(...round), Math.abs(Math.max(...round) / (cell * f) - 1)); } } set.style.removeProperty('--vl-td-radius'); return worst; })()",
          "max": 0.005
        }
      ],
      "pptx": null,
      "html": "patterns/shape-02-teardrop-quadrant.html"
    },
    {
      "id": "shape-03-stadium-track",
      "name": "Rail stadium à enfant emboîté",
      "family": "shape",
      "ref": "ref-01-bento-pills-2030",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Poser une piste à bouts ronds qui porte un objet EMBOÎTÉ — barre de progression, interrupteur, jauge — sans que le liseré entre les deux s'épaississe dans les courbes. Une seule loi le tient : l'enfant est inset d'une constante sur ses quatre côtés et son rayon vaut celui du rail moins cet inset, ce qui fait tomber les deux centres de courbure au même point. Deux remplissages en découlent, une barre partielle et une pastille, et ce ne sont pas deux objets : c'est le même rail avec deux longueurs d'enfant.",
      "when_to_use": "Partout où une piste doit porter une position ou un état : barre de progression, interrupteur, jauge de segment, pastille de statut dans un tableau de bord. Aussi comme primitive de composition dans un vocabulaire de formes pleines — c'est la forme la plus fréquente d'une planche géométrique, parce qu'elle est la seule à porter une orientation LONGUE sans texte.",
      "avoid_when": "Sur une boîte proche du carré : à moins de 1,6 d'aspect les deux bouts ronds se rejoignent, le rail devient une gélule courte puis un disque, et l'enfant emboîté n'a plus de course à parcourir — c'est un anneau, pas une piste. Éviter aussi dès que l'enfant doit toucher un bord : l'inset est constant sur les quatre côtés par construction, et le rendre nul d'un côté détruit la concentricité, donc l'objet même du pattern. Et en mailing : Outlook rend `border-radius` et les dégradés de façon imprévisible — voir `node bin/emit.mjs --audit --target email`.",
      "tags": [
        "forme",
        "primitive",
        "stadium",
        "pilule",
        "rail",
        "toggle",
        "progression",
        "jauge",
        "rayon",
        "concentrique"
      ],
      "vars": [
        {
          "name": "--vl-track-h",
          "role": "la hauteur du rail — le seul paramètre d'échelle : elle fixe le rayon (h/2) et l'inset (0,068 h)",
          "default": "232px"
        },
        {
          "name": "--vl-track-w",
          "role": "la longueur du rail — au moins 1,6 fois la hauteur, sans quoi la piste n'a plus de course",
          "default": "483px"
        },
        {
          "name": "--vl-track-inset",
          "role": "l'écart de l'enfant, CONSTANT sur les quatre côtés — posé par le padding du rail, jamais côté par côté",
          "default": "15.776px"
        },
        {
          "name": "--vl-track-fill",
          "role": "la longueur de la barre, en fraction du rail — le seul paramètre de contenu du pattern",
          "default": "0.4477"
        },
        {
          "name": "--vl-track-gap",
          "role": "l'écart entre les deux remplissages de la démonstration",
          "default": "19px"
        },
        {
          "name": "--vl-ink",
          "role": "l'aplat du rail",
          "default": "#0B0B0B"
        },
        {
          "name": "--vl-white",
          "role": "l'enfant emboîté — pastille, et extrémité claire du dégradé",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-accent",
          "role": "l'extrémité chaude du dégradé de la barre",
          "default": "#E8342A"
        }
      ],
      "slots": [],
      "geometry": {
        "root": ".vl-track-set",
        "frame": [
          483,
          483
        ],
        "ratios": {
          "track_aspect": 2.082,
          "radius_over_height": 0.5,
          "inset_over_height": 0.068,
          "inner_radius_over_height": 0.432,
          "fill_over_track": 0.4477,
          "knob_over_height": 0.864
        }
      },
      "benchmarks": [
        {
          "name": "STADIUM — le rayon vaut exactement la moitié du PETIT côté, et il est écrit en LONGUEUR : un `50%` rendrait 50 % de CHAQUE axe, soit 241,5 de rayon horizontal contre 116 de vertical sur une boîte 2:1, c'est-à-dire une ellipse",
          "measure": "num('.vl-track--knob', 'borderTopLeftRadius') / box('.vl-track--knob').h",
          "expect": 0.5,
          "tol": 0.003
        },
        {
          "name": "…et le bout rond est un ARC DE CERCLE de rayon h/2, sondé au point sur six angles — la mesure ne lit pas la valeur déclarée, elle interroge la forme PEINTE : un rayon en pourcentage y échoue dès 135°, là où lire la valeur calculée ne verrait qu'un « 50 % » d'apparence saine",
          "measure": "(() => { const rail = document.querySelector('.vl-track--bar'); const b = rail.getBoundingClientRect(); const R = b.height / 2; const cx = b.left + R, cy = b.top + b.height / 2; const at = (k, t) => { const e = document.elementFromPoint(cx + Math.cos(t) * R * k, cy - Math.sin(t) * R * k); return !!e && (e === rail || rail.contains(e)); }; let ok = 1; for (const a of [110, 135, 160, 200, 225, 250]) { const t = a * Math.PI / 180; if (!at(0.90, t) || at(1.10, t)) ok = 0; } return ok; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "INSET CONSTANT sur les quatre côtés : c'est le padding du rail qui pose l'enfant, jamais une marge choisie côté par côté",
          "measure": "(() => { const P = ['paddingTop','paddingRight','paddingBottom','paddingLeft'].map((p) => num('.vl-track--knob', p)); return (Math.max(...P) - Math.min(...P)) / Math.max(...P); })()",
          "max": 0.003
        },
        {
          "name": "…et il vaut 0,068 de la hauteur du rail — assez pour que le liseré se lise, assez peu pour que l'enfant reste l'objet et le rail le contenant",
          "measure": "num('.vl-track--knob', 'paddingTop') / box('.vl-track--knob').h",
          "expect": 0.068,
          "tol": 0.004
        },
        {
          "name": "CONTOURS CONCENTRIQUES — le rayon de l'enfant est celui du rail MOINS l'inset, sur les deux remplissages. Écrit à part, le liseré s'épaissirait dans les coins sans qu'aucun alignement ne bouge",
          "measure": "(() => { let worst = 0; for (const [r, f] of [['.vl-track--bar','.vl-track__fill--gradient'], ['.vl-track--knob','.vl-track__fill--knob']]) { worst = Math.max(worst, Math.abs((num(r, 'borderTopLeftRadius') - num(f, 'borderTopLeftRadius')) / num(r, 'paddingTop') - 1)); } return worst; })()",
          "max": 0.01
        },
        {
          "name": "…ce qui se vérifie à l'endroit où ça compte : les deux CENTRES DE COURBURE tombent au même point, donc l'écart des deux bords est le même dans la courbe que sur la droite",
          "measure": "(() => { let worst = 0; for (const [r, f] of [['.vl-track--bar','.vl-track__fill--gradient'], ['.vl-track--knob','.vl-track__fill--knob']]) { const cr = box(r).left + num(r, 'borderTopLeftRadius'), cf = box(f).left + num(f, 'borderTopLeftRadius'); const vr = box(r).top + box(r).h / 2, vf = box(f).top + box(f).h / 2; worst = Math.max(worst, Math.abs(cr - cf) / box(r).h, Math.abs(vr - vf) / box(r).h); } return worst; })()",
          "max": 0.002
        },
        {
          "name": "la pastille est CARRÉE de boîte — elle fait exactement la hauteur utile du rail, inset compris des deux côtés",
          "measure": "box('.vl-track__fill--knob').w / box('.vl-track__fill--knob').h",
          "expect": 1,
          "tol": 0.005
        },
        {
          "name": "…et elle sort RONDE sans qu'on l'écrive : son rayon vaut la moitié de son côté, ce qui n'est pas une déclaration mais la conséquence de la soustraction ci-dessus. Une primitive dont l'usage principal tombe d'une loi, c'est le signe que la loi est la bonne",
          "measure": "num('.vl-track__fill--knob', 'borderTopLeftRadius') / box('.vl-track__fill--knob').w",
          "expect": 0.5,
          "tol": 0.005
        },
        {
          "name": "la BARRE ne remplit pas son rail (44,8 %) : c'est ce qui fait lire une piste portant une position, et non un aplat à bouts ronds",
          "measure": "box('.vl-track__fill--gradient').w / box('.vl-track--bar').w",
          "expect": 0.4477,
          "tol": 0.01
        },
        {
          "name": "…et elle part du bord GAUCHE du rail, à l'inset près : une barre centrée dans sa piste ne dit plus d'où elle progresse",
          "measure": "(box('.vl-track__fill--gradient').left - box('.vl-track--bar').left) / num('.vl-track--bar', 'paddingLeft')",
          "expect": 1,
          "tol": 0.02
        },
        {
          "name": "le dégradé DÉCLARE sa couleur de repli, opaque : sans elle l'enfant a un fond transparent, toute mesure de couleur le compose avec le rail, et un contrôle de composition déclare la couche redondante — piège payé sur le deck de `ref-01`",
          "measure": "(() => { const p = (cs('.vl-track__fill--gradient', 'backgroundColor').match(/[\\d.]+/g) || []).map(Number); return p.length >= 3 && (p.length === 4 ? p[3] : 1) >= 1 ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "la pastille se DÉTACHE de son rail : les deux fonds sont mesurés l'un contre l'autre, parce qu'un interrupteur dont l'état ne se voit pas n'est pas un interrupteur",
          "measure": "(() => { const a = lum(bgOf($('.vl-track__fill--knob'))), b = lum(bgOf($('.vl-track--knob'))); const [hi, lo] = a > b ? [a, b] : [b, a]; return (hi + 0.05) / (lo + 0.05); })()",
          "min": 3
        },
        {
          "name": "REPARAMÉTRAGE — de 120 à 400 px de hauteur de rail, le rayon reste h/2 et l'écart des contours reste l'inset : la loi ne dépend d'aucune des valeurs d'ici",
          "measure": "(() => { const set = document.querySelector('.vl-track-set'); let worst = 0; for (const h of ['120px', '232px', '300px', '400px']) { set.style.setProperty('--vl-track-h', h); for (const [r, f] of [['.vl-track--bar','.vl-track__fill--gradient'], ['.vl-track--knob','.vl-track__fill--knob']]) { const el = document.querySelector(r), fl = document.querySelector(f); const R = parseFloat(getComputedStyle(el).borderTopLeftRadius), rr = parseFloat(getComputedStyle(fl).borderTopLeftRadius), pad = parseFloat(getComputedStyle(el).paddingTop); worst = Math.max(worst, Math.abs(R / el.getBoundingClientRect().height - 0.5), Math.abs((R - rr) / pad - 1)); } } set.style.removeProperty('--vl-track-h'); return worst; })()",
          "max": 0.01
        }
      ],
      "pptx": null,
      "html": "patterns/shape-03-stadium-track.html"
    },
    {
      "id": "shape-04-asterisk-mark",
      "name": "Astérisque de marque, six branches",
      "family": "shape",
      "ref": "ref-05-proposal-acid-yellow",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Poser l'accent d'une charte « neutre + une couleur » au point de fixation, pour presque rien : un astérisque à six branches — trois traits croisés à 60° — dont l'encre couvre un demi pour cent de la slide là où le plafond d'accent est de 12 %. Un seul dessin, trois emplois (seul dans un carré noir, en fin de titre, en fin de phrase), et une encre qui change avec le sol : jaune sur le noir, encre sur le fond clair.",
      "when_to_use": "Une charte qui n'a qu'un accent et refuse qu'il tapisse : le marqueur lui donne un point d'entrée sans dépenser de surface, donc il peut réapparaître de slide en slide sans que la règle tombe. Sert aussi de signature de marque partout où un logotype serait de trop — couverture, clôture, fin de titre, fin de phrase — et de glyphe de puce dans une liste courte.",
      "avoid_when": "Le marqueur doit porter une INFORMATION (renvoi de note, obligation d'un champ) : il n'a pas de cible, il est déclaré décoratif, et deux astérisques dans une même page cesseraient de se distinguer. Écarter aussi quand la charte a déjà un accent qui occupe de la surface sur la même slide — le marqueur serait le deuxième objet accentué, ce que la règle interdit — et sur un sol dont la luminance approche celle de l'accent : la contre-épreuve du fragment mesure ce cas à 1,03:1.",
      "tags": [
        "asterisque",
        "marqueur",
        "marque",
        "accent",
        "glyphe",
        "svg",
        "symbol",
        "signature",
        "fluo",
        "contraste"
      ],
      "vars": [
        {
          "name": "--vl-acid",
          "role": "la couleur d'accent — elle ne se pose que sur le carré noir, jamais sur le fond clair de la charte",
          "default": "#EAFF00"
        },
        {
          "name": "--vl-black",
          "role": "le carré qui porte le marqueur, et l'encre du marqueur quand il est posé sur le fond clair",
          "default": "#111111"
        },
        {
          "name": "--vl-ink-muted",
          "role": "encre du corps de texte qui reçoit l'emploi en fin de phrase",
          "default": "#6C6C6C"
        },
        {
          "name": "--vl-am-radius",
          "role": "rayon du carré, en LONGUEUR : c'est une constante de charte, pas une fraction de la boîte — deux carrés de tailles différentes portent le même",
          "default": "19px"
        },
        {
          "name": "--vl-am-fill",
          "role": "part du côté du carré occupée par le marqueur, la même à toute taille",
          "default": "0.54"
        },
        {
          "name": "--vl-am-stroke",
          "role": "épaisseur du trait dans le repère du symbole (72) — 1/8 de la boîte du marqueur",
          "default": "9"
        }
      ],
      "slots": [
        "title",
        "body"
      ],
      "geometry": {
        "root": ".vl-am",
        "frame": [
          620,
          491
        ],
        "ratios": {
          "mark_over_box": 0.54,
          "stroke_over_mark": 0.125,
          "branch_span": 6,
          "inline_over_font": 0.6
        },
        "type_px": {
          "title": 67,
          "body": 19
        },
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "SIX BRANCHES, COMPTÉES SUR UN TOUR COMPLET de la forme PEINTE — 720 sondages à 0,85 du rayon, six arcs peints et pas un de plus",
          "measure": "(() => { const H = (m, x, y) => { const t = document.elementFromPoint(x, y); return !!t && t !== m && m.contains(t); }; const ring = (m, k) => { const b = m.getBoundingClientRect(); const R = b.width * 32 / 72; const cx = b.left + b.width / 2, cy = b.top + b.height / 2; const out = []; for (let d = 0; d < 360; d += 0.5) { const a = d * Math.PI / 180; out.push(H(m, cx + Math.cos(a) * R * k, cy + Math.sin(a) * R * k)); } return out; }; const r = ring(document.querySelector('.vl-am__box--lg .vl-am__mark'), 0.85); let n = 0; for (let i = 0; i < r.length; i++) if (r[i] && !r[(i + r.length - 1) % r.length]) n++; return n; })()",
          "expect": 6,
          "tol": 0
        },
        {
          "name": "et elles tombent À 60° L'UNE DE L'AUTRE : l'axe de chaque branche est relevé au barycentre des points peints en travers d'elle, l'écart à sa direction nominale reste sous 1,5°",
          "measure": "(() => { const H = (m, x, y) => { const t = document.elementFromPoint(x, y); return !!t && t !== m && m.contains(t); }; const dev = (m, deg) => { const b = m.getBoundingClientRect(); const R = b.width * 32 / 72; const cx = b.left + b.width / 2, cy = b.top + b.height / 2; const a = deg * Math.PI / 180; const ux = Math.cos(a), uy = Math.sin(a), px = -uy, py = ux, r = 0.85 * R; const bx = cx + ux * r, by = cy + uy * r; let s = 0, n = 0; for (let t = -0.3 * R; t <= 0.3 * R; t += 0.5) { if (H(m, bx + px * t, by + py * t)) { s += t; n++; } } return n ? Math.abs(Math.atan((s / n) / r) * 180 / Math.PI) : 90; }; const m = document.querySelector('.vl-am__box--lg .vl-am__mark'); return Math.max(...[30, 90, 150, 210, 270, 330].map((a) => dev(m, a))); })()",
          "max": 1.5
        },
        {
          "name": "et les SIX BRANCHES ONT LA MÊME LONGUEUR : le tracé est encore là à 0,90 du rayon, il n'y est plus à 1,10 — sur les six",
          "measure": "(() => { const P = (el, deg, k) => { const b = el.getBoundingClientRect(); const R = b.width * 32 / 72; const a = deg * Math.PI / 180; const t = document.elementFromPoint(b.left + b.width / 2 + Math.cos(a) * R * k, b.top + b.height / 2 + Math.sin(a) * R * k); return !!t && t !== el && el.contains(t); }; const m = document.querySelector('.vl-am__box--lg .vl-am__mark'); return [30, 90, 150, 210, 270, 330].every((a) => P(m, a, 0.9) && !P(m, a, 1.1)) ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "UN SEUL DESSIN POUR TOUS LES EMPLOIS — un `symbol`, et tous les `use` le visent : deux emplois ne peuvent pas diverger d'un degré",
          "measure": "(() => { const r = document.querySelector('.vl-am'); const sy = r.querySelectorAll('symbol'); const us = [...r.querySelectorAll('use')]; const h = new Set(us.map((u) => u.getAttribute('href') || u.getAttribute('xlink:href'))); return (sy.length === 1 && us.length >= 3 && h.size === 1 && h.has('#' + sy[0].id)) ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "LE MARQUEUR PREND L'ENCRE DE SON SOL : chacun des trois emplois tient 3:1 contre son fond effectif — la forme est son identité, pas la couleur",
          "measure": "(() => { const ms = [...document.querySelectorAll('.vl-am__mark')]; return Math.min(...ms.map((m) => { const a = lum(parseRgb(getComputedStyle(m).stroke)), b = lum(bgOf(m)); const [hi, lo] = a > b ? [a, b] : [b, a]; return (hi + 0.05) / (lo + 0.05); })); })()",
          "min": 3
        },
        {
          "name": "CONTRE-ÉPREUVE — le même jaune posé sur le fond clair de la charte : 1,03:1. La règle « le jaune ne descend jamais sur le fond clair » n'est pas une préférence de goût",
          "measure": "(() => { const acid = getComputedStyle(document.querySelector('.vl-am__mark--on-dark')).stroke; const sol = bgOf(document.querySelector('.vl-am__mark--inline')); const a = lum(parseRgb(acid)), b = lum(sol); const [hi, lo] = a > b ? [a, b] : [b, a]; return (hi + 0.05) / (lo + 0.05); })()",
          "max": 1.1
        },
        {
          "name": "le marqueur est CENTRÉ dans son carré — décalé, il cesse d'être une estampille et devient un objet posé",
          "measure": "(() => { const bx = [...document.querySelectorAll('.vl-am__box')]; return Math.max(...bx.map((b) => { const B = b.getBoundingClientRect(), M = b.querySelector('.vl-am__mark').getBoundingClientRect(); return Math.max(Math.abs((B.left + B.right) / 2 - (M.left + M.right) / 2), Math.abs((B.top + B.bottom) / 2 - (M.top + M.bottom) / 2)) / B.width; })); })()",
          "max": 0.005
        },
        {
          "name": "et il occupe LA MÊME FRACTION du carré aux deux tailles : 0,54 — une estampille qui grandirait autrement que son support cesserait d'être la même",
          "measure": "(() => { const bx = [...document.querySelectorAll('.vl-am__box')]; const f = bx.map((b) => b.querySelector('.vl-am__mark').getBoundingClientRect().width / b.getBoundingClientRect().width); return Math.max(...f) - Math.min(...f); })()",
          "max": 0.005
        },
        {
          "name": "…à 0,54 du côté, la valeur de la charte",
          "measure": "(() => { const b = document.querySelector('.vl-am__box--lg'); return b.querySelector('.vl-am__mark').getBoundingClientRect().width / b.getBoundingClientRect().width; })()",
          "expect": 0.54,
          "tol": 0.01
        },
        {
          "name": "LE RAYON DU CARRÉ EST UNE CONSTANTE DE CHARTE, PAS UNE FRACTION : deux carrés dont les côtés sont dans un rapport > 2 portent le MÊME rayon absolu",
          "measure": "(() => { const l = document.querySelector('.vl-am__box--lg'), s = document.querySelector('.vl-am__box--sm'); const rl = parseFloat(getComputedStyle(l).borderTopLeftRadius), rs = parseFloat(getComputedStyle(s).borderTopLeftRadius); const cl = l.getBoundingClientRect().width, cs = s.getBoundingClientRect().width; return (Math.abs(rl - rs) <= 0.5 && cl / cs > 2) ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "L'ASTÉRISQUE LAISSE PASSER LES DEUX TIERS DE SA BOÎTE — couverture peinte relevée au sondage : c'est un marqueur de traits ouverts, pas une pastille",
          "measure": "(() => { const m = document.querySelector('.vl-am__box--lg .vl-am__mark'); const b = m.getBoundingClientRect(); const N = 81; let hit = 0; for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) { const x = b.left + (i + 0.5) * b.width / N, y = b.top + (j + 0.5) * b.height / N; const t = document.elementFromPoint(x, y); if (t && t !== m && m.contains(t)) hit++; } return hit / (N * N); })()",
          "min": 0.25,
          "max": 0.35
        },
        {
          "name": "ET IL NE DÉPENSE PRESQUE RIEN DU BUDGET D'ACCENT : à son plus grand emploi, l'encre jaune couvre moins de 1 % d'une slide 1600×900, pour un plafond de charte de 12 %",
          "measure": "(() => { const m = document.querySelector('.vl-am__box--lg .vl-am__mark'); const b = m.getBoundingClientRect(); const N = 81; let hit = 0; for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) { const x = b.left + (i + 0.5) * b.width / N, y = b.top + (j + 0.5) * b.height / N; const t = document.elementFromPoint(x, y); if (t && t !== m && m.contains(t)) hit++; } return (hit / (N * N)) * b.width * b.height / (1600 * 900); })()",
          "max": 0.01
        },
        {
          "name": "EN LIGNE, LE MARQUEUR RESTE SOUS LA HAUTEUR DE CAPITALE de ce qu'il signe — au-dessus il devient le dernier mot de la phrase",
          "measure": "(() => { const ms = [...document.querySelectorAll('.vl-am__mark--inline')]; return Math.max(...ms.map((m) => { const p = m.parentElement, s = getComputedStyle(p); const c = document.createElement('canvas').getContext('2d'); c.font = s.fontWeight + ' ' + s.fontSize + ' ' + s.fontFamily; return m.getBoundingClientRect().height / c.measureText('H').actualBoundingBoxAscent; })); })()",
          "min": 0.6,
          "max": 1
        },
        {
          "name": "le marqueur est DÉCORATIF et le déclare : il signe, il ne renvoie à rien — un astérisque annoncé aux lecteurs d'écran promettrait une note qui n'existe pas",
          "measure": "(() => { const ms = [...document.querySelectorAll('.vl-am svg')]; return ms.every((m) => m.getAttribute('aria-hidden') === 'true') ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "racine TRANSPARENTE et sans marge : le marqueur se pose dans une composition qui porte déjà son fond",
          "measure": "(() => { const s = getComputedStyle(document.querySelector('.vl-am')); const p = ['paddingTop','paddingRight','paddingBottom','paddingLeft'].map((k) => parseFloat(s[k])); return (s.backgroundColor === 'rgba(0, 0, 0, 0)' && Math.max(...p) === 0) ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        }
      ],
      "pptx": null,
      "html": "patterns/shape-04-asterisk-mark.html"
    },
    {
      "id": "shape-notched-corner",
      "name": "Carte à coin chanfreiné",
      "family": "shape",
      "ref": "ref-06-orange-notched",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Donner à un aplat coloré une signature de forme reconnaissable — UN coin coupé à 45° — sans rien ajouter au contenu ni recourir à un rayon, une ombre ou un liseré.",
      "when_to_use": "Sur les aplats d'accent d'un deck ou d'une landing qui doivent se reconnaître d'une slide à l'autre : carte statistique, carte de second rang, pastille de glyphe. Une seule orientation par famille de contenu.",
      "avoid_when": "Sur une image (la coupe se lit comme un recadrage raté), sur une carte du même fond que la slide (la coupe devient invisible et le lecteur ne voit qu'un angle sale), ou sur plus d'un coin — deux coins coupés font un losange, pas une signature.",
      "tags": [
        "chanfrein",
        "notch",
        "coin",
        "clip-path",
        "carte",
        "aplat",
        "forme",
        "signature",
        "deck",
        "corporate"
      ],
      "vars": [
        {
          "name": "--vl-notch",
          "role": "longueur de la coupe à 45° (≈ 7,5 % de la largeur de la carte)",
          "default": "32px"
        },
        {
          "name": "--vl-orange",
          "role": "aplat d'accent",
          "default": "#F2551E"
        },
        {
          "name": "--vl-steel",
          "role": "aplat de second rang",
          "default": "#C6D0CF"
        }
      ],
      "slots": [],
      "geometry": {
        "root": ".vl-nc--br",
        "frame": [
          208,
          200
        ],
        "ratios": {
          "notch_over_width": 0.075
        }
      },
      "benchmarks": [
        {
          "name": "chanfrein ≈ 7,5 % de la largeur (br)",
          "measure": "notch('.vl-nc--br') / W",
          "expect": 0.075,
          "tol": 0.025
        },
        {
          "name": "les 4 orientations coupent de la MÊME longueur",
          "measure": "Math.max(notch('.vl-nc--br'),notch('.vl-nc--tr'),notch('.vl-nc--bl'),notch('.vl-nc--tl')) - Math.min(notch('.vl-nc--br'),notch('.vl-nc--tr'),notch('.vl-nc--bl'),notch('.vl-nc--tl'))",
          "expect": 0,
          "tol": 0.5
        },
        {
          "name": "un seul coin coupé : la coupe reste sous 12 % de la petite dimension",
          "measure": "notch('.vl-nc--br') / Math.min(W, H)",
          "max": 0.2
        }
      ],
      "pptx": {
        "primitive": "notched_rect",
        "emitter": "kit/vl_pptx.py:notched_rect",
        "pattern": "shape-notched-corner"
      },
      "html": "patterns/shape-notched-corner.html"
    },
    {
      "id": "tag-01-gooey-capsule",
      "name": "Capsule néon soudée — mot-clé + glyphe",
      "family": "tag",
      "ref": "ref-12-neon-capsule-tags",
      "media": [
        "slide",
        "social",
        "web"
      ],
      "intent": "Faire d'un mot-clé un OBJET : un contour continu qui soude un cercle de glyphe à une pilule de texte par deux congés concaves, pour qu'un terme isolé pèse autant qu'un titre sans être plus gros.",
      "when_to_use": "Valoriser des mots-clés, des noms de produits, de modules, d'offres ou de compétences sur une slide, un hero ou une couverture — seul, ou empilé en liste. C'est le pattern quand il faut donner du poids à des termes qui n'ont ni chiffre ni phrase autour.",
      "avoid_when": "Le libellé dépasse deux mots (la pilule devient une barre et le cou n'est plus lisible), l'élément est cliquable (ça ressemble à un bouton et le cou casse la cible), ou l'icône doit être COMPRISE — à 0,6u dans un cercle, elle marque une catégorie, elle n'explique rien.",
      "tags": [
        "capsule",
        "pilule",
        "etiquette",
        "tag",
        "mot-cle",
        "keyword",
        "neon",
        "contour",
        "outline",
        "icone",
        "soudure",
        "gooey",
        "sombre",
        "slide",
        "hero"
      ],
      "vars": [
        {
          "name": "--vl-neon",
          "role": "le trait ET le texte — c'est la seule couleur du pattern",
          "default": "#4FE383"
        },
        {
          "name": "--vl-cap-u",
          "role": "l'unité dont TOUTE la géométrie dérive (hauteur de rangée = 1,255 × u) ; elle n'est pas libre — le trait vaut 0,055 u et Chrome ARRONDIT une bordure CSS au pixel entier là où il ne le fait pas d'un stroke SVG, donc une valeur qui ne tombe pas sur un trait quasi entier rouvre la soudure à l'œil",
          "default": "91px"
        }
      ],
      "slots": [
        "icon",
        "label"
      ],
      "geometry": {
        "root": ".vl-cap",
        "frame": [
          372,
          114
        ],
        "ratios": {
          "head_over_height": 1.4562,
          "label_over_height": 0.8406,
          "stroke_over_height": 0.0438,
          "font_over_height": 0.494,
          "icon_over_height": 0.478,
          "circle_r_over_u": 0.6,
          "cap_r_over_u": 0.5,
          "fillet_r_over_u": 0.28,
          "centres_gap_over_u": 1.2
        },
        "type_px": {
          "label": 56.4
        },
        "pad_ratio": {
          "top": 0,
          "x": 0.143,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "tête (cercle + cou + calotte) ≈ 1,456 × la hauteur de rangée",
          "measure": "box('.vl-cap__head').w / H",
          "expect": 1.4562,
          "tol": 0.02
        },
        {
          "name": "pilule ≈ 84 % de la hauteur : le cercle déborde en haut ET en bas",
          "measure": "box('.vl-cap__label').h / H",
          "expect": 0.8406,
          "tol": 0.01
        },
        {
          "name": "trait unique — la bordure de la pilule vaut 4,4 % de la hauteur (= le stroke SVG)",
          "measure": "num('.vl-cap__label','borderTopWidth') / H",
          "expect": 0.0438,
          "tol": 0.005
        },
        {
          "name": "contour continu : la pilule commence là où la tête finit (±0,6 px)",
          "measure": "Math.abs(box('.vl-cap__label').left - box('.vl-cap__head').right)",
          "max": 0.6
        },
        {
          "name": "contour continu : la calotte droite est collée à la pilule (±0,6 px)",
          "measure": "Math.abs(box('.vl-cap__tail').left - box('.vl-cap__label').right)",
          "max": 0.6
        },
        {
          "name": "pilule centrée sur la tête — débord haut = débord bas",
          "measure": "Math.abs((box('.vl-cap__label').top - box('.vl-cap__head').top) - (box('.vl-cap__head').bottom - box('.vl-cap__label').bottom))",
          "max": 0.6
        },
        {
          "name": "les trois pièces pavent la capsule sans trou ni recouvrement",
          "measure": "box('.vl-cap').w - (box('.vl-cap__head').w + box('.vl-cap__label').w + box('.vl-cap__tail').w)",
          "expect": 0,
          "tol": 0.6
        },
        {
          "name": "corps ≈ 49 % de la hauteur de rangée (le mot remplit la pilule)",
          "measure": "num('.vl-cap__label','fontSize') / H",
          "expect": 0.494,
          "tol": 0.03
        },
        {
          "name": "capitales — la capsule ne prend pas de bas-de-casse",
          "measure": "cs('.vl-cap__label','textTransform') === 'uppercase' ? 1 : 0",
          "expect": 1
        },
        {
          "name": "glyphe centré dans le cercle : centre à (H/2, H/2), ±1 px",
          "measure": "Math.max(Math.abs(box('.vl-cap__icon').left + box('.vl-cap__icon').w / 2 - H / 2), Math.abs(box('.vl-cap__icon').top + box('.vl-cap__icon').h / 2 - H / 2))",
          "max": 1
        },
        {
          "name": "glyphe entre 40 % et 55 % du diamètre du cercle — au-delà il touche le trait",
          "measure": "box('.vl-cap__icon').w / H",
          "min": 0.4,
          "max": 0.55
        },
        {
          "name": "le mot respire plus à droite (calotte ronde) qu'à gauche (jonction droite)",
          "measure": "num('.vl-cap__label','paddingRight') / num('.vl-cap__label','paddingLeft')",
          "min": 1.2
        },
        {
          "name": "contraste du mot ≥ 4,5 — le néon sur le vert sombre n'a aucune excuse",
          "measure": "contrast('.vl-cap__label')",
          "min": 4.5
        }
      ],
      "pptx": null,
      "html": "patterns/tag-01-gooey-capsule.html"
    },
    {
      "id": "tag-02-centered-cloud",
      "name": "Nuage de mots-clés centré rangée par rangée",
      "family": "tag",
      "ref": "ref-17-mono-bento-legal",
      "media": [
        "web",
        "slide"
      ],
      "intent": "Poser une dizaine de mots-clés de même rang au bas d'un module sans en faire une liste : chaque RANGÉE est centrée sur elle-même, si bien que les lignes de largeur inégale dessinent une silhouette de nuage — c'est le centrage par rangée, et pas le centrage du bloc, qui produit l'effet.",
      "when_to_use": "Fin d'une vignette de service, pied d'une carte de rubrique, bandeau de compétences : dix à quinze termes courts qu'on veut donner à VOIR en volume plutôt qu'à lire un par un, sans hiérarchie entre eux et sans ordre significatif.",
      "avoid_when": "L'ordre compte (un nuage le détruit visuellement), les termes sont cliquables et de longueurs très inégales (les cibles deviennent illisibles à balayer), il y en a moins de six — trois rangées sont le minimum pour qu'une silhouette existe — ou l'un d'eux doit être mis en avant : ce système n'a aucune ressource pour distinguer une pilule des autres.",
      "tags": [
        "tag",
        "mot-cle",
        "keyword",
        "nuage",
        "cloud",
        "pilule",
        "pill",
        "chip",
        "competences",
        "services",
        "wrap",
        "monochrome"
      ],
      "vars": [
        {
          "name": "--vl-object",
          "role": "aplat d'une pilule — PLUS CLAIR que le module qui la porte",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-ink",
          "role": "texte d'une pilule",
          "default": "#111111"
        },
        {
          "name": "--vl-radius-pill",
          "role": "rayon d'une pilule",
          "default": "999px"
        }
      ],
      "slots": [
        "items"
      ],
      "geometry": {
        "root": ".vl-cloud",
        "frame": [
          287,
          140
        ],
        "ratios": {
          "pill_height_over_width": 0.091,
          "gap_x_over_width": 0.024,
          "gap_y_over_width": 0.028,
          "pad_x_over_pill_height": 0.46
        },
        "type_px": {
          "tag": 11
        },
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "les pilules passent à la ligne : c'est un nuage, pas une barre qui déborde",
          "measure": "cs('.vl-cloud','flexWrap') === 'wrap' ? 1 : 0",
          "expect": 1
        },
        {
          "name": "CHAQUE RANGÉE est centrée sur elle-même — l'écart à gauche égale l'écart à droite, ligne par ligne",
          "measure": "(() => { const r = $('.vl-cloud').getBoundingClientRect(); const rows = {}; for (const e of document.querySelectorAll('.vl-cloud > *')) { const b = e.getBoundingClientRect(); const k = Math.round(b.top); (rows[k] = rows[k] || []).push(b); } return Math.max(...Object.values(rows).map((g) => Math.abs((Math.min(...g.map((b) => b.left)) - r.left) - (r.right - Math.max(...g.map((b) => b.right)))))) / r.width; })()",
          "max": 0.012
        },
        {
          "name": "au moins TROIS rangées : en dessous, aucune silhouette n'existe",
          "measure": "new Set([...document.querySelectorAll('.vl-cloud > *')].map((e) => Math.round(e.getBoundingClientRect().top))).size",
          "min": 3
        },
        {
          "name": "les rangées sont de largeurs INÉGALES — sinon c'est un pavé centré, pas un nuage",
          "measure": "(() => { const rows = {}; for (const e of document.querySelectorAll('.vl-cloud > *')) { const b = e.getBoundingClientRect(); const k = Math.round(b.top); (rows[k] = rows[k] || []).push(b); } const w = Object.values(rows).map((g) => Math.max(...g.map((b) => b.right)) - Math.min(...g.map((b) => b.left))); return (Math.max(...w) - Math.min(...w)) / W; })()",
          "min": 0.06
        },
        {
          "name": "aucune rangée orpheline : la plus courte porte au moins deux pilules",
          "measure": "(() => { const rows = {}; for (const e of document.querySelectorAll('.vl-cloud > *')) { const k = Math.round(e.getBoundingClientRect().top); rows[k] = (rows[k] || 0) + 1; } return Math.min(...Object.values(rows)); })()",
          "min": 2
        },
        {
          "name": "une pilule EST une pilule : rayon ≥ sa demi-hauteur",
          "measure": "num('.vl-cloud > *','borderTopLeftRadius') / box('.vl-cloud > *').h",
          "min": 0.5
        },
        {
          "name": "la pilule est PLUS CLAIRE que le fond qui la porte — le rang de blanc se dépense vers le haut",
          "measure": "lum(parseRgb(cs('.vl-cloud > *','backgroundColor'))) - lum(bgOf($('.vl-cloud')))",
          "min": 0.01
        },
        {
          "name": "pilule basse : sa hauteur reste sous 12 % de la largeur du nuage",
          "measure": "box('.vl-cloud > *').h / W",
          "max": 0.12
        },
        {
          "name": "la gouttière verticale est un peu plus large que l'horizontale : les rangées se lisent avant les colonnes",
          "measure": "num('.vl-cloud','rowGap') - num('.vl-cloud','columnGap')",
          "min": 0,
          "max": 6
        },
        {
          "name": "aucun terme ne se coupe : chaque pilule tient sur une ligne",
          "measure": "[...document.querySelectorAll('.vl-cloud > *')].filter((e) => e.getBoundingClientRect().height > box('.vl-cloud > *').h * 1.4).length",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "aucun liseré sur les pilules",
          "measure": "num('.vl-cloud > *','borderTopWidth')",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "contraste du texte de pilule ≥ 4,5",
          "measure": "contrast('.vl-cloud > *')",
          "min": 4.5
        },
        {
          "name": "aucun débordement du nuage",
          "measure": "overflow('.vl-cloud')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/tag-02-centered-cloud.html"
    },
    {
      "id": "tag-03-action-pill-bar",
      "name": "Barre d'actions en pilules, une seule pleine",
      "family": "tag",
      "ref": "ref-18-lime-sales-dashboard",
      "media": [
        "web"
      ],
      "intent": "Aligner filtres et action principale sur une même ligne sans hiérarchie typographique : toutes les commandes sont des pilules de MÊME hauteur, les filtres portent un puits d'icône plus clair que la pilule qui le contient, et UNE SEULE pilule — la dernière — est pleine. C'est la position et le remplissage qui disent le rang, jamais la taille.",
      "when_to_use": "Ligne de commandes d'un tableau de bord ou d'un rapport, posée à droite du titre de page : une à trois portées de filtre (période, segment, vue) suivies de l'action qui sort de l'écran (exporter, partager, créer). Un filtre peut porter deux lignes — son intitulé au-dessus de sa valeur — sans changer la hauteur de la barre.",
      "avoid_when": "Il y a plus de quatre commandes (la ligne devient une barre d'outils et demande des groupes), deux actions sont de premier rang (deux pilules pleines annulent le seul geste de rang), ou les commandes doivent se replier sous le titre : la barre suppose qu'elle tient sur une ligne, et son alignement à droite n'a plus de sens dès qu'elle passe à la ligne.",
      "tags": [
        "action",
        "filtre",
        "pilule",
        "pill",
        "barre",
        "toolbar",
        "dashboard",
        "export",
        "periode",
        "segment",
        "cta",
        "puits",
        "icone"
      ],
      "vars": [
        {
          "name": "--vl-module",
          "role": "aplat d'une pilule de filtre",
          "default": "#F2F2F2"
        },
        {
          "name": "--vl-app",
          "role": "aplat du puits d'icône — plus clair que la pilule qui le porte",
          "default": "#FEFEFE"
        },
        {
          "name": "--vl-invert",
          "role": "aplat de l'unique pilule pleine",
          "default": "#000000"
        },
        {
          "name": "--vl-ink",
          "role": "valeur d'un filtre, libellé de l'action",
          "default": "#0B0B0B"
        },
        {
          "name": "--vl-ink-soft",
          "role": "intitulé d'un filtre, au-dessus de sa valeur",
          "default": "#6B6B6B"
        },
        {
          "name": "--vl-on-invert",
          "role": "encre sur la pilule pleine",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-radius-pill",
          "role": "rayon — toujours une pilule pleine, jamais un rayon franc",
          "default": "999px"
        }
      ],
      "slots": [
        "filters",
        "action"
      ],
      "geometry": {
        "root": ".vl-actionbar",
        "frame": [
          672,
          62
        ],
        "ratios": {
          "pill_height_over_width": 0.092,
          "well_over_pill_height": 0.742,
          "gap_over_pill_height": 0.194,
          "value_over_label": 1.23
        },
        "type_px": {
          "value": 16,
          "label": 13,
          "action": 17
        },
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "toutes les pilules ont EXACTEMENT la même hauteur — la taille ne dit jamais le rang",
          "measure": "(() => { const h = [...document.querySelectorAll('.vl-actionbar > *')].map((e) => e.getBoundingClientRect().height); return Math.max(...h) - Math.min(...h); })()",
          "max": 1
        },
        {
          "name": "toutes sont des pilules : rayon ≥ demi-hauteur, sans exception",
          "measure": "(() => { const r = [...document.querySelectorAll('.vl-actionbar > *')].map((e) => parseFloat(getComputedStyle(e).borderTopLeftRadius) / e.getBoundingClientRect().height); return Math.min(...r); })()",
          "min": 0.5
        },
        {
          "name": "UNE SEULE pilule pleine dans la barre",
          "measure": "[...document.querySelectorAll('.vl-actionbar > *')].filter((e) => lum(parseRgb(getComputedStyle(e).backgroundColor)) < 0.2).length",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "… et c'est la DERNIÈRE : l'action de sortie ferme la ligne",
          "measure": "document.querySelector('.vl-actionbar').lastElementChild.classList.contains('vl-actionbar__pill--solid') ? 1 : 0",
          "expect": 1
        },
        {
          "name": "le puits d'icône est un disque",
          "measure": "box('.vl-actionbar__well').w / box('.vl-actionbar__well').h",
          "expect": 1,
          "tol": 0.02
        },
        {
          "name": "le puits est PLUS CLAIR que la pilule qui le porte — le rang de blanc se dépense vers le haut",
          "measure": "lum(parseRgb(cs('.vl-actionbar__well','backgroundColor'))) - lum(parseRgb(cs('.vl-actionbar__pill','backgroundColor')))",
          "min": 0.01
        },
        {
          "name": "puits ≈ 74 % de la hauteur de pilule : il loge dedans, il ne la remplit pas",
          "measure": "box('.vl-actionbar__well').h / box('.vl-actionbar__pill').h",
          "expect": 0.742,
          "tol": 0.07
        },
        {
          "name": "gouttière intérieure constante : l'écart du puits au bord gauche égale son écart au bord haut",
          "measure": "Math.abs((box('.vl-actionbar__well').left - box('.vl-actionbar__pill').left) - (box('.vl-actionbar__well').top - box('.vl-actionbar__pill').top))",
          "max": 1.5
        },
        {
          "name": "l'intitulé est PLUS PETIT que la valeur qu'il coiffe",
          "measure": "num('.vl-actionbar__value','fontSize') / num('.vl-actionbar__label','fontSize')",
          "min": 1.15
        },
        {
          "name": "… mais le saut reste faible : ce n'est pas un titre, c'est un cadrage",
          "measure": "num('.vl-actionbar__value','fontSize') / num('.vl-actionbar__label','fontSize')",
          "max": 1.6
        },
        {
          "name": "la pilule pleine n'a PAS de puits : elle n'est pas un filtre déguisé",
          "measure": "document.querySelectorAll('.vl-actionbar__pill--solid .vl-actionbar__well').length",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "la barre reste une ligne : aucune pilule ne descend sous la première",
          "measure": "(() => { const t = [...document.querySelectorAll('.vl-actionbar > *')].map((e) => e.getBoundingClientRect().top); return Math.max(...t) - Math.min(...t); })()",
          "max": 1
        },
        {
          "name": "contraste de la valeur d'un filtre ≥ 4,5",
          "measure": "contrast('.vl-actionbar__value')",
          "min": 4.5
        },
        {
          "name": "contraste de l'intitulé ≥ 4,5 — c'est lui qui plafonne",
          "measure": "contrast('.vl-actionbar__label')",
          "min": 4.5
        },
        {
          "name": "contraste du libellé de l'action pleine ≥ 4,5",
          "measure": "contrast('.vl-actionbar__pill--solid')",
          "min": 4.5
        },
        {
          "name": "aucun débordement de la barre",
          "measure": "overflow('.vl-actionbar')",
          "max": 0
        }
      ],
      "pptx": null,
      "html": "patterns/tag-03-action-pill-bar.html"
    },
    {
      "id": "title-01-leading-rule",
      "name": "Titre à filet d'accent collé",
      "family": "title",
      "ref": "ref-06-orange-notched",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Marquer le titre d'une slide d'un filet vertical d'accent sans ajouter ni couleur au texte ni élément décoratif : le filet est le seul accent du titre, et il est collé au mot.",
      "when_to_use": "Titre de slide en capitales grasses sur 1 à 3 lignes, dans un deck qui répète ce marqueur d'une slide à l'autre — c'est sa répétition qui en fait une charte.",
      "avoid_when": "Le titre est déjà coloré ou posé sur un aplat d'accent (deux accents se neutralisent), ou il est centré : un filet collé à un bloc centré désaxe la composition sans rien signifier.",
      "tags": [
        "titre",
        "filet",
        "barre",
        "accent",
        "typo",
        "capitales",
        "deck",
        "marqueur",
        "leading-rule"
      ],
      "vars": [
        {
          "name": "--vl-orange",
          "role": "couleur du filet",
          "default": "#F2551E"
        },
        {
          "name": "--vl-black",
          "role": "couleur du titre",
          "default": "#141414"
        }
      ],
      "slots": [
        "title"
      ],
      "geometry": {
        "root": ".vl-tlr",
        "frame": [
          760,
          160
        ],
        "ratios": {
          "rule_width_over_font_size": 0.081
        },
        "type_px": {
          "title": 74
        },
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "filet ≈ 8 % du corps du titre (6 px pour 74 px)",
          "measure": "num('.vl-tlr','borderLeftWidth') / num('.vl-tlr','fontSize')",
          "expect": 0.081,
          "tol": 0.03
        },
        {
          "name": "filet COLLÉ : aucun padding entre le filet et la première lettre",
          "measure": "num('.vl-tlr','paddingLeft')",
          "expect": 0,
          "tol": 0
        },
        {
          "name": "le filet court sur toute la hauteur du bloc (variante bloc)",
          "measure": "box('.vl-tlr').h / (num('.vl-tlr','fontSize') * 1.05 * 2)",
          "min": 0.95,
          "max": 1.05
        },
        {
          "name": "variante en ligne : filet à hauteur de capitale (70 à 85 % du corps)",
          "measure": "box('.vl-tlr-inline').h / num('.vl-tlr-right','fontSize')",
          "min": 0.7,
          "max": 0.85
        },
        {
          "name": "les deux variantes ont la MÊME épaisseur de filet",
          "measure": "Math.abs(num('.vl-tlr','borderLeftWidth') - box('.vl-tlr-inline').w)",
          "expect": 0,
          "tol": 0.5
        },
        {
          "name": "titre en capitales",
          "measure": "cs('.vl-tlr','textTransform') === 'uppercase' ? 1 : 0",
          "expect": 1
        },
        {
          "name": "contraste du titre ≥ 7",
          "measure": "contrast('.vl-tlr')",
          "min": 7
        }
      ],
      "pptx": {
        "primitive": "rect_rule",
        "emitter": "kit/vl_pptx.py:title_leading_rule",
        "pattern": "title-leading-rule"
      },
      "html": "patterns/title-01-leading-rule.html"
    },
    {
      "id": "title-02-condensed-overlay-stack",
      "name": "Cadre typographique condensé en overlay",
      "family": "title",
      "ref": "ref-10-campaign-board-red",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Tenir une slide entière avec du TEXTE SEUL — aucun aplat, aucun trait, aucune boîte — en posant trois blocs sur la scène : un titre condensé noir à l'interligne écrasé, le numéro de section entre parenthèses au MÊME fer que lui mais à l'autre bout de la colonne, et un contrepoids en micro-capitales au coin diagonalement opposé. Le cadre est transparent : il se pose par-dessus des images sans rien leur enlever.",
      "when_to_use": "Ouverture de chapitre, slide de campagne ou hero plein écran dont le fond est une image (ou un aplat) : ce cadre est la couche de texte, il se superpose à layout-10-bleed-column-inset. Il vaut aussi seul, sur une slide de couleur, quand un chapitre doit s'annoncer sans décor.",
      "avoid_when": "Le titre dépasse trois ou quatre mots, ou la slide porte déjà du corps de texte à lire : ce cadre suppose que le titre EST le contenu et que tout le reste est une marque de coin. Écarter aussi quand la charte n'a pas de grotesque condensée — à largeur normale, l'interligne écrasé fait se toucher les lettres au lieu des lignes, et le bloc devient illisible.",
      "tags": [
        "titre",
        "condensé",
        "capitales",
        "overlay",
        "numéro",
        "section",
        "parenthèses",
        "micro-caps",
        "campagne",
        "chapitre",
        "typo"
      ],
      "vars": [
        {
          "name": "--vl-red",
          "role": "couleur de l'ensemble du cadre — titre, numéro et contrepoids partagent la même",
          "default": "#C82C15"
        },
        {
          "name": "--vl-white",
          "role": "couleur de repli du contrepoids quand son coin tombe sur une image sombre (modificateur --on-image)",
          "default": "#FFFFFF"
        },
        {
          "name": "--vl-cos-fer",
          "role": "le fer unique de la colonne de gauche — titre ET numéro, jamais deux valeurs",
          "default": "8.56%"
        }
      ],
      "slots": [
        "title",
        "num",
        "note"
      ],
      "geometry": {
        "root": ".vl-cos",
        "frame": [
          1600,
          900
        ],
        "ratios": {
          "fer_over_frame": 0.0856,
          "title_size_over_frame": 0.065,
          "num_size_over_frame": 0.04875,
          "note_size_over_frame": 0.008125,
          "note_width_over_frame": 0.105,
          "title_over_note_size": 8
        },
        "type_px": {
          "title": 104,
          "num": 78,
          "note": 13
        },
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "FER UNIQUE : le numéro tombe exactement sur le bord gauche du titre",
          "measure": "Math.abs(box('.vl-cos__num').left - box('.vl-cos__title').left) / W",
          "max": 0.002
        },
        {
          "name": "le fer vaut ≈ 8,6 % de la largeur de scène",
          "measure": "box('.vl-cos__title').left / W",
          "expect": 0.0856,
          "tol": 0.006
        },
        {
          "name": "interligne ÉCRASÉ : le titre passe sous 0,9 fois son corps",
          "measure": "num('.vl-cos__title','lineHeight') / num('.vl-cos__title','fontSize')",
          "max": 0.9
        },
        {
          "name": "le titre est bien MULTILIGNE : sa hauteur vaut au moins 1,6 corps",
          "measure": "box('.vl-cos__title').h / num('.vl-cos__title','fontSize')",
          "min": 1.6
        },
        {
          "name": "le titre ouvre la scène : il commence dans le premier cinquième de la hauteur",
          "measure": "box('.vl-cos__title').top / H",
          "max": 0.2
        },
        {
          "name": "la colonne est TENDUE : le numéro est à plus d'un tiers de hauteur du titre",
          "measure": "(box('.vl-cos__num').top - box('.vl-cos__title').bottom) / H",
          "min": 0.33
        },
        {
          "name": "le numéro reste dans la scène, sans coller le bord bas",
          "measure": "(H - box('.vl-cos__num').bottom) / H",
          "min": 0.04
        },
        {
          "name": "le numéro est PLUS PETIT que le titre — un repère, pas un second titre",
          "measure": "num('.vl-cos__num','fontSize') / num('.vl-cos__title','fontSize')",
          "min": 0.55,
          "max": 0.85
        },
        {
          "name": "le numéro n'est PAS condensé, contrairement au titre",
          "measure": "cs('.vl-cos__num','fontVariationSettings').includes('\"wdth\" 100') ? 1 : 0",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "le titre EST condensé : largeur d'axe sous 90",
          "measure": "parseFloat((cs('.vl-cos__title','fontVariationSettings').match(/\"wdth\" ([\\d.]+)/) || [0, 999])[1])",
          "max": 90
        },
        {
          "name": "le contrepoids est au coin OPPOSÉ en horizontale : au-delà des trois quarts",
          "measure": "box('.vl-cos__note').left / W",
          "min": 0.75
        },
        {
          "name": "le contrepoids est au coin OPPOSÉ en verticale : sous le pied du titre",
          "measure": "(box('.vl-cos__note').top - box('.vl-cos__title').bottom) / H",
          "min": 0.25
        },
        {
          "name": "colonne ÉTROITE : le contrepoids tient dans ~10 % de la largeur de scène",
          "measure": "box('.vl-cos__note').w / W",
          "min": 0.06,
          "max": 0.14
        },
        {
          "name": "le contrepoids court sur plusieurs lignes — c'est un bloc, pas une légende",
          "measure": "box('.vl-cos__note').h / num('.vl-cos__note','fontSize')",
          "min": 2.5
        },
        {
          "name": "écart d'échelle d'au moins 7 entre le titre et le plus petit corps",
          "measure": "num('.vl-cos__title','fontSize') / num('.vl-cos__note','fontSize')",
          "min": 7
        },
        {
          "name": "aucun croisement : le titre s'arrête avant la colonne du contrepoids",
          "measure": "(box('.vl-cos__note').left - box('.vl-cos__title').right) / W",
          "min": 0.02
        },
        {
          "name": "le contrepoids ne déborde pas la scène par la droite",
          "measure": "(W - box('.vl-cos__note').right) / W",
          "min": 0.02
        },
        {
          "name": "le plus petit corps tient le plancher de contraste du texte courant",
          "measure": "contrast('.vl-cos__note')",
          "min": 4.5
        },
        {
          "name": "le titre aussi — il n'est pas dispensé parce qu'il est gros",
          "measure": "contrast('.vl-cos__title')",
          "min": 4.5
        },
        {
          "name": "racine TRANSPARENTE : ce cadre se pose SUR une image, il n'en masque aucune",
          "measure": "cs('.vl-cos','backgroundColor') === 'rgba(0, 0, 0, 0)' ? 1 : 0",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "scène au format 16:9 — c'est une slide, pas une vignette",
          "measure": "W / H",
          "expect": 1.7778,
          "tol": 0.01
        }
      ],
      "pptx": null,
      "html": "patterns/title-02-condensed-overlay-stack.html"
    },
    {
      "id": "title-03-wordmark-bottom-left",
      "name": "Wordmark en pied, contrepoids diagonal",
      "family": "title",
      "ref": "ref-07-retro-brand-hero",
      "media": [
        "web",
        "slide",
        "social"
      ],
      "intent": "Fermer une page d'accueil par le nom de la marque plutôt que de l'ouvrir par lui : wordmark de deux lignes, fer à gauche, posé sur la marge BASSE, et une réserve visuelle au coin diagonalement opposé qui l'empêche de faire basculer la page. Les deux blocs ne se croisent jamais en hauteur — c'est la seule chose qui fait lire une diagonale plutôt que deux objets empilés.",
      "when_to_use": "Hero ou couverture dont le nom de marque est le dernier mot, pas le premier : le contenu parle d'abord (nav, images, accroche), la signature arrive en pied. Demande un nom court, coupable en deux lignes, et une zone d'au moins deux fois la hauteur du wordmark pour que la diagonale respire.",
      "avoid_when": "Le nom doit être lu EN PREMIER — un titre de section, une couverture de deck : le poser en bas inverse l'ordre de lecture et le contrepoids devient le sujet. Écarter aussi quand le nom ne se coupe pas en deux lignes de longueur voisine : une ligne courte sur une ligne longue casse le bloc, et un wordmark d'une seule ligne n'a plus rien à équilibrer.",
      "tags": [
        "wordmark",
        "logotype",
        "marque",
        "hero",
        "pied",
        "diagonale",
        "contrepoids",
        "display",
        "deux-lignes",
        "interligne-serre"
      ],
      "vars": [
        {
          "name": "--vl-accent",
          "role": "encre du wordmark — la couleur de marque, ici sur son plus gros objet",
          "default": "#B2201C"
        },
        {
          "name": "--vl-ink",
          "role": "filet de la réserve du contrepoids ; disparaît dès qu'on la remplit",
          "default": "#1E1B18"
        },
        {
          "name": "--vl-radius-photo",
          "role": "rayon de la réserve — celui du bloc visuel qui viendra s'y loger",
          "default": "8px"
        }
      ],
      "slots": [
        "mark",
        "counter"
      ],
      "geometry": {
        "root": ".vl-wbl",
        "frame": [
          1036,
          506
        ],
        "ratios": {
          "mark_size_over_frame": 0.1429,
          "mark_leading": 0.82,
          "counter_w_over_frame": 0.4556,
          "counter_h_over_frame_h": 0.3933
        },
        "type_px": {
          "mark": 148
        },
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "le wordmark est ferré sur le bord GAUCHE de la zone",
          "measure": "box('.vl-wbl__mark').left / W",
          "max": 0.003
        },
        {
          "name": "et posé sur la marge BASSE : c'est ce qui en fait une signature, pas un titre",
          "measure": "(H - box('.vl-wbl__mark').bottom) / H",
          "max": 0.01
        },
        {
          "name": "interligne ÉCRASÉ sous 1 : les deux lignes font UN bloc",
          "measure": "num('.vl-wbl__mark','lineHeight') / num('.vl-wbl__mark','fontSize')",
          "min": 0.72,
          "max": 0.9
        },
        {
          "name": "le wordmark est bien sur DEUX lignes, pas une",
          "measure": "box('.vl-wbl__mark').h / num('.vl-wbl__mark','fontSize')",
          "min": 1.55,
          "max": 1.85
        },
        {
          "name": "le contrepoids est ferré sur le bord DROIT",
          "measure": "(W - box('.vl-wbl__counter').right) / W",
          "max": 0.003
        },
        {
          "name": "et sur le bord HAUT — le coin diagonalement opposé, pas un flottement",
          "measure": "box('.vl-wbl__counter').top / H",
          "max": 0.003
        },
        {
          "name": "DIAGONALE, axe horizontal : les deux centres se séparent d'au moins un tiers de la largeur",
          "measure": "(box('.vl-wbl__counter').left + box('.vl-wbl__counter').right - box('.vl-wbl__mark').left - box('.vl-wbl__mark').right) / (2 * W)",
          "min": 0.33
        },
        {
          "name": "DIAGONALE, axe vertical : et d'au moins un tiers de la hauteur",
          "measure": "(box('.vl-wbl__mark').top + box('.vl-wbl__mark').bottom - box('.vl-wbl__counter').top - box('.vl-wbl__counter').bottom) / (2 * H)",
          "min": 0.33
        },
        {
          "name": "AUCUN CROISEMENT EN HAUTEUR : du blanc franc entre le pied du contrepoids et le haut du wordmark",
          "measure": "(box('.vl-wbl__mark').top - box('.vl-wbl__counter').bottom) / H",
          "min": 0.06
        },
        {
          "name": "la rangée est SATURÉE : côte à côte, il ne resterait pas de quoi séparer les deux blocs — la diagonale est subie, pas décorative",
          "measure": "(W - box('.vl-wbl__mark').w - box('.vl-wbl__counter').w) / W",
          "max": 0.07
        },
        {
          "name": "le wordmark tient ≈ la moitié de la largeur de la zone",
          "measure": "box('.vl-wbl__mark').w / W",
          "min": 0.45,
          "max": 0.65
        },
        {
          "name": "le contrepoids est une MASSE, pas un filet : plus d'un tiers de la hauteur",
          "measure": "box('.vl-wbl__counter').h / H",
          "min": 0.3
        },
        {
          "name": "l'axe de LARGEUR du display est bien ouvert — un wordmark en largeur normale est un autre pattern",
          "measure": "parseFloat((cs('.vl-wbl__mark','fontVariationSettings').match(/\"wdth\" ([\\d.]+)/) || [0, 0])[1])",
          "min": 110
        },
        {
          "name": "capitales par le CSS, pas par la frappe : le nom reste lisible dans le markup",
          "measure": "cs('.vl-wbl__mark','textTransform') === 'uppercase' ? 1 : 0",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "le wordmark porte la couleur de marque, et il tient le plancher de contraste malgré sa taille",
          "measure": "contrast('.vl-wbl__mark')",
          "min": 4.5
        },
        {
          "name": "racine TRANSPARENTE : cette zone est le corps d'un conteneur qui a déjà son fond",
          "measure": "cs('.vl-wbl','backgroundColor') === 'rgba(0, 0, 0, 0)' ? 1 : 0",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "aucune BOÎTE ne sort de la zone — ni en bas, ni à droite, ni en amont des deux bords opposés",
          "measure": "Math.max(box('.vl-wbl__mark').right - W, box('.vl-wbl__mark').bottom - H, box('.vl-wbl__counter').right - W, box('.vl-wbl__counter').bottom - H, -box('.vl-wbl__mark').left, -box('.vl-wbl__counter').top) / W",
          "max": 0.002
        },
        {
          "name": "ce que l'interligne négatif fait dépasser sous le wordmark est du VIDE : en capitales, ce qui descend sous la ligne de base est le débord optique des rondes, pas un jambage",
          "measure": "(() => { const el = document.querySelector('.vl-wbl__mark'); const s = getComputedStyle(el); const c = document.createElement('canvas').getContext('2d'); c.font = s.fontWeight + ' ' + s.fontSize + ' ' + s.fontFamily; return c.measureText(el.textContent.toUpperCase()).actualBoundingBoxDescent / parseFloat(s.fontSize); })()",
          "max": 0.03
        }
      ],
      "pptx": null,
      "html": "patterns/title-03-wordmark-bottom-left.html"
    },
    {
      "id": "title-04-name-fills-measure",
      "name": "Nom réglé à la mesure, ® sous la capitale",
      "family": "title",
      "ref": "ref-08-swiss-studio-hero",
      "media": [
        "web",
        "slide"
      ],
      "intent": "Régler un nom de marque à la LARGEUR plutôt qu'à une taille : le corps n'est pas choisi dans l'échelle de la charte, il est déduit de la mesure disponible, et l'encre du mot affleure les deux marges à 99,6 %. Le ® qui le suit est calé sous la hauteur de capitale — un quart du corps, remonté de 55 % de la capitale — pour qu'il signe le nom sans devenir sa douzième lettre.",
      "when_to_use": "Un hero, une couverture ou un pied de page dont le nom de marque doit faire ENSEIGNE : un mot unique, court, qui prend toute la mesure et devient une forme avant d'être un texte. Aussi partout où une charte sans accent coloré doit produire un point fort — ici c'est l'échelle qui fait l'accent, donc trois valeurs d'encre suffisent.",
      "avoid_when": "Le nom tient sur deux mots ou deux lignes : le réglage suppose une ligne unique que l'on peut étirer d'une marge à l'autre, et deux lignes redonnent au nom un statut de bloc de texte, ce qui est un autre pattern (title-03-wordmark-bottom-left). Écarter aussi quand le mot doit rester à taille FIXE d'un support à l'autre — un logotype normé, une signature de charte : ici le corps change avec la largeur du conteneur, c'est le principe.",
      "tags": [
        "wordmark",
        "nom-de-marque",
        "logotype",
        "hero",
        "echelle",
        "largeur-utile",
        "registered",
        "exposant",
        "grotesk",
        "container-query"
      ],
      "vars": [
        {
          "name": "--vl-ink",
          "role": "encre du nom — la valeur la plus contrastée de la charte, c'est le plus gros objet de la page",
          "default": "#111111"
        },
        {
          "name": "--vl-mark-chasse",
          "role": "la CHASSE du mot : rapport mesuré largeur-d'encre / corps, pour CE mot dans CETTE graisse à CETTE approche. C'est lui qui convertit la largeur disponible en corps — il se remesure dès qu'on change le mot, la police ou l'approche, jamais il ne s'estime",
          "default": "5.270"
        }
      ],
      "slots": [
        "mark"
      ],
      "geometry": {
        "root": ".vl-nfm",
        "frame": [
          1060,
          185
        ],
        "ratios": {
          "ink_fill_of_measure": 0.996,
          "mark_leading": 0.92,
          "mark_tracking": -0.035,
          "r_size_over_mark": 0.26,
          "r_top_over_cap": 0.82,
          "r_baseline_rise_over_cap": 0.548
        },
        "type_px": {
          "mark": 201
        },
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "L'ENCRE SATURE LA MESURE — c'est tout le pattern : 99,6 % de la largeur, et jamais plus de 100 %",
          "measure": "(() => { const r = document.createRange(); r.selectNodeContents(document.querySelector('.vl-nfm__mark')); return r.getBoundingClientRect().width / W; })()",
          "min": 0.99,
          "max": 1
        },
        {
          "name": "et elle affleure les DEUX marges — pas un mot large centré dans sa ligne",
          "measure": "(() => { const b = document.querySelector('.vl-nfm').getBoundingClientRect(); const r = document.createRange(); r.selectNodeContents(document.querySelector('.vl-nfm__mark')); const i = r.getBoundingClientRect(); return Math.max(Math.abs(i.left - b.left), Math.abs(b.right - i.right)) / W; })()",
          "max": 0.006
        },
        {
          "name": "LE CORPS EST UNE FONCTION DE LA LARGEUR, pas un nombre : reparamétrée à 700 px, la zone garde exactement le même taux de remplissage",
          "measure": "(() => { const root = document.querySelector('.vl-nfm'); const ref = (() => { const r = document.createRange(); r.selectNodeContents(root.querySelector('.vl-nfm__mark')); return r.getBoundingClientRect().width / root.getBoundingClientRect().width; })(); const c = root.cloneNode(true); c.style.width = '700px'; document.body.appendChild(c); const r2 = document.createRange(); r2.selectNodeContents(c.querySelector('.vl-nfm__mark')); const fill = r2.getBoundingClientRect().width / c.getBoundingClientRect().width; const ratio = parseFloat(getComputedStyle(c.querySelector('.vl-nfm__mark')).fontSize) / parseFloat(getComputedStyle(root.querySelector('.vl-nfm__mark')).fontSize); c.remove(); return (Math.abs(fill - ref) <= 0.003 && Math.abs(ratio - 700 / W) <= 0.003) ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "et le mécanisme qui le permet est BIEN ARMÉ : sans contexte de conteneur, `cqw` retomberait sur la fenêtre et le réglage serait faux SANS erreur",
          "measure": "cs('.vl-nfm','containerType') === 'inline-size' ? 1 : 0",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "UNE SEULE LIGNE : la hauteur de la boîte vaut l'interligne, jamais deux fois",
          "measure": "box('.vl-nfm__mark').h / num('.vl-nfm__mark','fontSize')",
          "min": 0.85,
          "max": 1
        },
        {
          "name": "interligne écrasé sous 1 — la boîte du nom ne porte pas d'air qu'il n'occupe pas",
          "measure": "num('.vl-nfm__mark','lineHeight') / num('.vl-nfm__mark','fontSize')",
          "min": 0.85,
          "max": 0.99
        },
        {
          "name": "approche NÉGATIVE : elle fait partie du réglage, c'est elle qui achète la hauteur du corps",
          "measure": "num('.vl-nfm__mark','letterSpacing') / num('.vl-nfm__mark','fontSize')",
          "max": -0.02
        },
        {
          "name": "casse MIXTE, et par la frappe : capitaliser ce nom en ferait un titre, or c'est une signature",
          "measure": "(cs('.vl-nfm__mark','textTransform') === 'none' && /[a-z]/.test(text('.vl-nfm__mark'))) ? 1 : 0",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "LE ® EST UN QUART DU CORPS — à sa taille naturelle il ferait une douzième lettre",
          "measure": "num('.vl-nfm__r','fontSize') / num('.vl-nfm__mark','fontSize')",
          "min": 0.22,
          "max": 0.32
        },
        {
          "name": "et il ne PÈSE pas dans la mesure : moins de 5 % de l'encre totale",
          "measure": "(() => { const r = document.createRange(); r.selectNodeContents(document.querySelector('.vl-nfm__mark')); return box('.vl-nfm__r').w / r.getBoundingClientRect().width; })()",
          "max": 0.05
        },
        {
          "name": "SON SOMMET TOMBE SOUS LA LIGNE DE CAPITALE (82 % de la capitale) — au-dessus, il flotte et le bloc perd son bord haut franc",
          "measure": "(() => { const mk = document.querySelector('.vl-nfm__mark'), rg = document.querySelector('.vl-nfm__r'); const sm = getComputedStyle(mk), sr = getComputedStyle(rg); const c = document.createElement('canvas').getContext('2d'); c.font = sm.fontWeight + ' ' + sm.fontSize + ' ' + sm.fontFamily; const hm = c.measureText('H'); const rr = document.createRange(); rr.selectNodeContents(mk.firstChild); const baseline = rr.getBoundingClientRect().top + hm.fontBoundingBoxAscent; c.font = sr.fontWeight + ' ' + sr.fontSize + ' ' + sr.fontFamily; const rm = c.measureText(rg.textContent); const rBase = rg.getBoundingClientRect().top + rm.fontBoundingBoxAscent; return (baseline - (rBase - rm.actualBoundingBoxAscent)) / hm.actualBoundingBoxAscent; })()",
          "min": 0.6,
          "max": 0.99
        },
        {
          "name": "et son PIED reste dans la moitié haute des capitales : c'est un exposant, pas un signe posé sur la ligne de base",
          "measure": "(() => { const mk = document.querySelector('.vl-nfm__mark'), rg = document.querySelector('.vl-nfm__r'); const sm = getComputedStyle(mk), sr = getComputedStyle(rg); const c = document.createElement('canvas').getContext('2d'); c.font = sm.fontWeight + ' ' + sm.fontSize + ' ' + sm.fontFamily; const hm = c.measureText('H'); const rr = document.createRange(); rr.selectNodeContents(mk.firstChild); const baseline = rr.getBoundingClientRect().top + hm.fontBoundingBoxAscent; c.font = sr.fontWeight + ' ' + sr.fontSize + ' ' + sr.fontFamily; const rm = c.measureText(rg.textContent); const rBase = rg.getBoundingClientRect().top + rm.fontBoundingBoxAscent; return (baseline - (rBase + rm.actualBoundingBoxDescent)) / hm.actualBoundingBoxAscent; })()",
          "min": 0.45
        },
        {
          "name": "le nom tient le plancher de contraste — un gros corps n'en dispense pas, c'est le seul objet de la page",
          "measure": "contrast('.vl-nfm__mark')",
          "min": 4.5
        },
        {
          "name": "racine TRANSPARENTE et sans marge : cette zone est la largeur UTILE d'un conteneur qui porte déjà son fond",
          "measure": "(() => { const s = getComputedStyle(document.querySelector('.vl-nfm')); const p = ['paddingTop','paddingRight','paddingBottom','paddingLeft'].map((k) => parseFloat(s[k])); return (s.backgroundColor === 'rgba(0, 0, 0, 0)' && Math.max(...p) === 0) ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        }
      ],
      "pptx": null,
      "html": "patterns/title-04-name-fills-measure.html"
    },
    {
      "id": "title-05-hyphen-break",
      "name": "Titre display soudé par un trait d'union",
      "family": "title",
      "ref": "ref-05-proposal-acid-yellow",
      "media": [
        "slide",
        "web"
      ],
      "intent": "Souder une locution de deux mots en un composé unique de deux lignes, en remplaçant son ESPACE par un trait d'union frappé : « Business Proposal » devient `Business-` / `Proposal`. Le retour n'est pas calculé par la mesure, il est écrit — donc il ne bouge jamais — et le trait d'union, posé en encre la plus à droite du bloc, termine la silhouette au lieu de signaler une coupure oubliée.",
      "when_to_use": "Une couverture, une slide de clôture, un hero : partout où un display de deux mots doit faire MASSE plutôt que ligne, sur une charte qui n'a pas d'accent coloré à dépenser sur son titre. Le trait d'union est alors le seul signe graphique du bloc, et il coûte zéro couleur. Sert aussi à ancrer un titre au fer à gauche d'une colonne dont le bas porte autre chose : les deux lignes serrées font un bloc franc, pas un texte qui coule.",
      "avoid_when": "La locution ne tient pas en deux mots, ou le second mot est plus long que le premier plus son trait : le trait d'union retombe alors À L'INTÉRIEUR de la silhouette et se lit comme une césure ratée — c'est la seule contrainte dure du pattern. Écarter aussi en capitales (le trait se centre sur une hauteur d'x absente et devient un tiret de séparation), et pour un nom de marque d'un seul mot qu'on veut régler à la largeur — c'est title-04-name-fills-measure.",
      "tags": [
        "titre",
        "display",
        "trait-union",
        "cesure",
        "couverture",
        "cloture",
        "locution",
        "interligne-ecrase",
        "fer-a-gauche",
        "grotesk"
      ],
      "vars": [
        {
          "name": "--vl-black",
          "role": "encre du titre — une seule valeur pour tout le bloc, trait d'union compris : c'est ce qui le fait lire comme un signe typographique et non comme un ornement",
          "default": "#111111"
        },
        {
          "name": "--vl-hb-size",
          "role": "corps du display, pris dans l'échelle de la charte (109 px sur une slide 1600×900)",
          "default": "109px"
        },
        {
          "name": "--vl-hb-leading",
          "role": "interligne, écrasé sous 1 — un composé soudé par un trait ne peut pas être écarté par de l'air",
          "default": "0.98"
        },
        {
          "name": "--vl-hb-tracking",
          "role": "approche négative du display",
          "default": "-0.03em"
        },
        {
          "name": "--vl-hb-gap",
          "role": "écart entre les deux spécimens de la démonstration — sans effet sur la règle",
          "default": "72px"
        }
      ],
      "slots": [
        "title"
      ],
      "geometry": {
        "root": ".vl-hb",
        "frame": [
          905,
          214
        ],
        "ratios": {
          "leading": 0.98,
          "tracking": -0.03,
          "block_height_in_lines": 2
        },
        "type_px": {
          "display": 109
        },
        "pad_ratio": {
          "top": 0,
          "x": 0,
          "bottom": 0
        }
      },
      "benchmarks": [
        {
          "name": "DEUX lignes, exactement — le composé est un bloc, pas un paragraphe qui coule",
          "measure": "(() => { const lines = (el) => { const r = document.createRange(); r.selectNodeContents(el); const rs = [...r.getClientRects()].filter((x) => x.width > 0.5 && x.height > 0.5); const m = new Map(); rs.forEach((x) => { const k = Math.round(x.top); const q = m.get(k); m.set(k, q ? { top: k, left: Math.min(q.left, x.left), right: Math.max(q.right, x.right) } : { top: k, left: x.left, right: x.right }); }); return [...m.values()].sort((a, b) => a.top - b.top); }; const t = [...document.querySelectorAll('.vl-hb__t')]; return t.every((e) => lines(e).length === 2) ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "LE RETOUR EST FRAPPÉ, PAS CALCULÉ : le même titre sans son <br>, à mesure libre, tient sur UNE ligne — les deux lignes ne doivent donc rien à la largeur",
          "measure": "(() => { const lines = (el) => { const r = document.createRange(); r.selectNodeContents(el); const rs = [...r.getClientRects()].filter((x) => x.width > 0.5 && x.height > 0.5); const m = new Map(); rs.forEach((x) => { const k = Math.round(x.top); const q = m.get(k); m.set(k, q ? { top: k, left: Math.min(q.left, x.left), right: Math.max(q.right, x.right) } : { top: k, left: x.left, right: x.right }); }); return [...m.values()].sort((a, b) => a.top - b.top); }; const t = [...document.querySelectorAll('.vl-hb__t')]; const ok = t.every((e) => { const c = e.cloneNode(true); c.querySelectorAll('br').forEach((x) => x.remove()); c.style.cssText += ';position:absolute;left:-9999px;width:max-content;'; document.body.appendChild(c); const n = lines(c).length; c.remove(); return n === 1; }); return ok ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "et ce n'est pas une CÉSURE : le trait d'union remplace l'ESPACE d'une locution de deux mots entiers, jamais une syllabe coupée",
          "measure": "(() => { const t = [...document.querySelectorAll('.vl-hb__t')]; return t.every((e) => { const p = e.dataset.phrase || ''; const rendu = e.textContent.replace(/\\s+/g, ''); return /\\s/.test(p) && !p.includes('-') && rendu.replace(/-/g, '') === p.replace(/\\s+/g, '') && rendu.includes('-'); }) ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "LE TRAIT D'UNION EST L'ENCRE LA PLUS À DROITE DU BLOC — c'est la loi qui ordonne les deux mots, et non une égalité de longueur entre les lignes",
          "measure": "(() => { const lines = (el) => { const r = document.createRange(); r.selectNodeContents(el); const rs = [...r.getClientRects()].filter((x) => x.width > 0.5 && x.height > 0.5); const m = new Map(); rs.forEach((x) => { const k = Math.round(x.top); const q = m.get(k); m.set(k, q ? { top: k, left: Math.min(q.left, x.left), right: Math.max(q.right, x.right) } : { top: k, left: x.left, right: x.right }); }); return [...m.values()].sort((a, b) => a.top - b.top); }; const t = [...document.querySelectorAll('.vl-hb__t')]; return Math.min(...t.map((e) => { const tn = e.firstChild; const r = document.createRange(); r.setStart(tn, tn.length - 1); r.setEnd(tn, tn.length); const h = r.getBoundingClientRect().right; const l2 = lines(e)[1]; return (h - l2.right) / parseFloat(getComputedStyle(e).fontSize); })); })()",
          "min": 0.02
        },
        {
          "name": "les deux lignes partagent UN SEUL FER À GAUCHE — sans lui le trait d'union ne termine plus une silhouette, il flotte",
          "measure": "(() => { const lines = (el) => { const r = document.createRange(); r.selectNodeContents(el); const rs = [...r.getClientRects()].filter((x) => x.width > 0.5 && x.height > 0.5); const m = new Map(); rs.forEach((x) => { const k = Math.round(x.top); const q = m.get(k); m.set(k, q ? { top: k, left: Math.min(q.left, x.left), right: Math.max(q.right, x.right) } : { top: k, left: x.left, right: x.right }); }); return [...m.values()].sort((a, b) => a.top - b.top); }; const t = [...document.querySelectorAll('.vl-hb__t')]; return Math.max(...t.map((e) => { const [a, c] = lines(e); return Math.abs(c.left - a.left) / W; })); })()",
          "max": 0.002
        },
        {
          "name": "interligne ÉCRASÉ sous 1 — les deux lignes se touchent presque, sinon la soudure ne se voit pas",
          "measure": "num('.vl-hb__t','lineHeight') / num('.vl-hb__t','fontSize')",
          "min": 0.9,
          "max": 0.995
        },
        {
          "name": "et la boîte du titre ne vaut QUE ses deux interlignes : aucun air ajouté au-dessus ni au-dessous",
          "measure": "box('.vl-hb__t').h / (num('.vl-hb__t','fontSize') * (num('.vl-hb__t','lineHeight') / num('.vl-hb__t','fontSize')))",
          "min": 1.95,
          "max": 2.05
        },
        {
          "name": "approche NÉGATIVE : elle resserre le composé, un display à approche nulle se disloque",
          "measure": "num('.vl-hb__t','letterSpacing') / num('.vl-hb__t','fontSize')",
          "max": -0.02
        },
        {
          "name": "GRAISSE DISPLAY — sous 700 le trait d'union redevient un filet et cesse d'être un signe",
          "measure": "(() => { const w = getComputedStyle(document.querySelector('.vl-hb__t')).fontWeight; return parseFloat(w) >= 700 ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "casse MIXTE, et par la frappe — en capitales le trait se centre sur une hauteur d'x absente",
          "measure": "(() => { const e = document.querySelector('.vl-hb__t'); const s = getComputedStyle(e); return (s.textTransform === 'none' && /[a-z]/.test(e.textContent) && /[A-Z]/.test(e.textContent)) ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "césure automatique VERROUILLÉE : le seul trait d'union du titre est celui qui est écrit",
          "measure": "(() => { const s = getComputedStyle(document.querySelector('.vl-hb__t')); return (s.hyphens === 'manual' || s.webkitHyphens === 'manual') ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        },
        {
          "name": "le titre tient le plancher de contraste — c'est le plus gros objet de la slide, il n'en est pas dispensé",
          "measure": "contrast('.vl-hb__t')",
          "min": 4.5
        },
        {
          "name": "racine TRANSPARENTE et sans marge : ce bloc se pose dans une colonne qui porte déjà son fond",
          "measure": "(() => { const s = getComputedStyle(document.querySelector('.vl-hb')); const p = ['paddingTop','paddingRight','paddingBottom','paddingLeft'].map((k) => parseFloat(s[k])); return (s.backgroundColor === 'rgba(0, 0, 0, 0)' && Math.max(...p) === 0) ? 1 : 0; })()",
          "expect": 1,
          "tol": 0
        }
      ],
      "pptx": null,
      "html": "patterns/title-05-hyphen-break.html"
    }
  ],
  "refs": [
    {
      "id": "ref-01-bento-pills-2030",
      "name": "Bento pills 2030 — couverture géométrique",
      "patterns": [
        "layout-15-primitive-mosaic",
        "shape-02-teardrop-quadrant",
        "shape-03-stadium-track"
      ],
      "deck": "decks/ref-01-bento-pills-2030.html",
      "tokens": {
        "--vl-bg": "#F5F4F2",
        "--vl-ink": "#0B0B0B",
        "--vl-accent": "#E8342A",
        "--vl-accent-deep": "#8E1114",
        "--vl-white": "#FFFFFF",
        "--vl-on-accent": "#FFFFFF",
        "--vl-index": "#6E6C68",
        "--vl-cell": "232px",
        "--vl-gutter": "19px",
        "--vl-margin": "58.5px",
        "--vl-inset": "15.78px",
        "--vl-radius-stadium": "116px",
        "--vl-radius-squircle": "69.6px",
        "--vl-radius-teardrop": "51.04px"
      },
      "type": {
        "display": "\"Helvetica Neue\", Helvetica, Inter, Arial, sans-serif",
        "body": "\"Helvetica Neue\", Helvetica, Inter, Arial, sans-serif",
        "scale": {
          "year": "calc(100cqw / 1.216) / 0.92 / 700 — 190,8px sur une pilule de 232, soit 0.1193 Ws. Ce n'est PAS une taille de charte : c'est la largeur de la pilule divisée par la chasse d'encre de « 20 » (1,070 em en Helvetica Neue Bold), réglée pour saturer 88 % de la boîte.",
          "index": "24px / 1 / 400 / 0.18em — 0.0150 Ws, le plancher de la scène 1600 (23 px) tenu de justesse et assumé : c'est le seul texte non typographique de la planche."
        }
      }
    },
    {
      "id": "ref-02-ghost-icon-claim",
      "name": "Gris fantôme — argument unique",
      "patterns": [
        "card-01-ghost-icon"
      ],
      "deck": "decks/ref-02-ghost-icon-claim.html",
      "tokens": {
        "--vl-bg": "#F7F7F7",
        "--vl-surface": "#EDEDED",
        "--vl-ghost": "#E2E2E2",
        "--vl-ink": "#444444",
        "--vl-muted": "#A9A9A9",
        "--vl-radius-card": "48px",
        "--vl-pad-card": "60px 76px",
        "--vl-gutter": "90px"
      },
      "type": {
        "display": "-apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Inter, Arial, sans-serif",
        "body": "-apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Inter, Arial, sans-serif",
        "accent": "Georgia, \"Times New Roman\", \"Iowan Old Style\", serif",
        "scale": {
          "title": "52px / 1.18 / 700 / -0.015em",
          "subtitle": "25px / 1.35 / 400 / 0"
        }
      }
    },
    {
      "id": "ref-03-bento-dark-pitch",
      "name": "Bento sombre — pitch investisseur",
      "patterns": [
        "chart-01-stadium-bars",
        "chart-02-isotype",
        "layout-01-nested-bento"
      ],
      "deck": "decks/ref-03-bento-dark-pitch.html",
      "tokens": {
        "--vl-bg": "#2E2E2E",
        "--vl-board": "#2E2E2E",
        "--vl-surface-light": "#FFFFFF",
        "--vl-surface-dark": "#111111",
        "--vl-surface-tile": "#3A3A3A",
        "--vl-accent": "#CFE3F5",
        "--vl-accent-2": "#8FBBE4",
        "--vl-accent-3": "#5E9BD6",
        "--vl-ink": "#111111",
        "--vl-ink-muted": "#6B6B6B",
        "--vl-ink-invert": "#FFFFFF",
        "--vl-ink-invert-muted": "#A0A0A0",
        "--vl-hairline": "#D5D5D5",
        "--vl-hairline-invert": "#4A4A4A",
        "--vl-radius-card": "24px",
        "--vl-radius-sub": "20px",
        "--vl-radius-tile": "16px",
        "--vl-gap": "12px"
      },
      "type": {
        "display": "-apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Inter, Arial, sans-serif",
        "body": "-apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Inter, Arial, sans-serif",
        "scale": {
          "title": "44px / 1.06 / 500 / -0.02em",
          "figure-xl": "56px / 1 / 700 / -0.02em",
          "figure": "22px / 1.1 / 700 / -0.01em",
          "lead": "20px / 1.25 / 500",
          "body": "14px / 1.5 / 400",
          "micro": "11px / 1.35 / 400"
        }
      }
    },
    {
      "id": "ref-04-swiss-investor-blue",
      "name": "Suisse maximaliste — investor deck bleu",
      "patterns": [
        "card-13-email-figure-band",
        "layout-02-swiss-frame",
        "layout-09-email-envelope",
        "list-01-giant-numbers",
        "list-03-two-column-toc",
        "list-06-email-digest"
      ],
      "deck": "decks/ref-04-swiss-investor-blue.html",
      "tokens": {
        "--vl-bg": "#D9D2C7",
        "--vl-board": "#D9D2C7",
        "--vl-blue": "#1B44D8",
        "--vl-black": "#0D0D0D",
        "--vl-white": "#FFFFFF",
        "--vl-ink-muted": "#6E6E6E",
        "--vl-ink-muted-invert": "#D3D3D3",
        "--vl-hairline": "#00000018",
        "--vl-hairline-invert": "#FFFFFF33",
        "--vl-ph-a": "#C9C9C9",
        "--vl-ph-b": "#5A5A5A",
        "--vl-margin": "48px"
      },
      "type": {
        "display": "\"Helvetica Neue\", Inter, Arial, \"Archivo Black\", sans-serif",
        "body": "\"Helvetica Neue\", Inter, Arial, sans-serif",
        "scale": {
          "monster": "76px / 0.92 / 900 / -0.02em / UPPERCASE",
          "lead": "26px / 1.2 / 500",
          "number-giant": "88px / 1 / 900 / -0.03em",
          "body": "13px / 1.5 / 400",
          "micro": "8px / 1.4 / 500 / 0.12em / UPPERCASE"
        }
      }
    },
    {
      "id": "ref-05-proposal-acid-yellow",
      "name": "Proposal jaune acide",
      "patterns": [
        "card-14-numbered-steps",
        "layout-16-header-tripartite",
        "shape-04-asterisk-mark",
        "title-05-hyphen-break"
      ],
      "deck": "decks/ref-05-proposal-acid-yellow.html",
      "tokens": {
        "--vl-bg": "#EFEFED",
        "--vl-board": "#DCDCD8",
        "--vl-white": "#FFFFFF",
        "--vl-black": "#111111",
        "--vl-acid": "#EAFF00",
        "--vl-ink-muted": "#6C6C6C",
        "--vl-ink-muted-invert": "#9A9A9A",
        "--vl-hairline": "#11111118",
        "--vl-hairline-invert": "#FFFFFF24",
        "--vl-blob-light": "#E8E8E6",
        "--vl-blob-dark": "#171717",
        "--vl-margin": "72px",
        "--vl-gap": "24px",
        "--vl-gap-block": "32px",
        "--vl-r-media": "19px",
        "--vl-r-list": "22px",
        "--vl-r-band": "26px",
        "--vl-r-step": "32px"
      },
      "type": {
        "display": "\"Hanken Grotesk\", \"Helvetica Neue\", Arial, sans-serif",
        "body": "\"Hanken Grotesk\", \"Helvetica Neue\", Arial, sans-serif",
        "scale": {
          "display": "109px / 0.98 / 800 / -0.03em (couverture et clôture, casse mixte, césure écrite)",
          "title": "67px / 1.02 / 800 / -0.03em (titre de section)",
          "stat": "90px / 1 / 800 / -0.03em (chiffre de carte numérotée)",
          "body": "19px / 1.55 / 400",
          "label": "19px / 1.3 / 700 (label sous un chiffre)",
          "label-sm": "16px / 1.2 / 700 (le (01) d'une carte de liste)",
          "micro": "13px / 1.4 / 700 / 0.12em / UPPERCASE"
        }
      }
    },
    {
      "id": "ref-06-orange-notched",
      "name": "Corporate orange chanfreiné",
      "patterns": [
        "card-02-notched-brief",
        "card-03-stat-accent",
        "list-02-ruled-index",
        "shape-notched-corner",
        "title-01-leading-rule"
      ],
      "deck": "decks/ref-06-orange-notched.html",
      "tokens": {
        "--vl-bg": "#F0EFEC",
        "--vl-board": "#DEDDD9",
        "--vl-white": "#FFFFFF",
        "--vl-black": "#141414",
        "--vl-orange": "#F2551E",
        "--vl-steel": "#C6D0CF",
        "--vl-ink-muted": "#6A6A67",
        "--vl-ink-muted-invert": "#FFF1E9",
        "--vl-ink-on-steel": "#3E4746",
        "--vl-hairline": "#00000015",
        "--vl-ph-a": "#C9C9C9",
        "--vl-ph-b": "#4C4C4C",
        "--vl-ph-dark-a": "#6A6A6A",
        "--vl-ph-dark-b": "#141414",
        "--vl-notch": "34px",
        "--vl-margin": "60px"
      },
      "type": {
        "display": "\"Helvetica Neue\", Inter, Arial, sans-serif",
        "body": "\"Helvetica Neue\", Inter, Arial, sans-serif",
        "scale": {
          "title": "74px / 1.05 / 800 / -0.005em / UPPERCASE",
          "title-lg": "76px / 1.05 / 800 (couverture)",
          "title-sm": "24px / 1.05 / 800 / 0.02em (sous-titre de colonne, libellé d'index)",
          "stat": "64px / 1 / 800 / -0.02em",
          "body": "17px / 1.55 / 400",
          "micro": "12px / 1.4 / 700 / 0.14em / UPPERCASE"
        }
      }
    },
    {
      "id": "ref-07-retro-brand-hero",
      "name": "Retro brand hero (crème + rouge de marque, sol photographique)",
      "patterns": [
        "layout-11-hero-card-on-photo",
        "layout-12-nav-three-zone",
        "layout-13-image-triptych",
        "title-03-wordmark-bottom-left"
      ],
      "deck": "decks/ref-07-retro-brand-hero.html",
      "tokens": {
        "--vl-card": "#FBF7EC",
        "--vl-bg": "#FBF7EC",
        "--vl-ink": "#1E1B18",
        "--vl-accent": "#B2201C",
        "--vl-on-accent": "#FBF7EC",
        "--vl-ground-sky-hi": "#D7E6EC",
        "--vl-ground-sky-lo": "#BBD3DE",
        "--vl-ground-sun": "#F4EFE2",
        "--vl-ground-sea-hi": "#7FA8BC",
        "--vl-ground-sea-lo": "#5E8AA0",
        "--vl-ground-foam": "#EFEAE0",
        "--vl-ground-sand-wet": "#C7B49A",
        "--vl-ground-sand-dry": "#E4D5BC",
        "--vl-ph-a-hi": "#86B0C4",
        "--vl-ph-a-lo": "#3F6B84",
        "--vl-ph-a-crest": "#F1EDE3",
        "--vl-ph-b-sky": "#C9DEE7",
        "--vl-ph-b-canvas": "#D8442E",
        "--vl-ph-b-canvas-lo": "#92251C",
        "--vl-ph-b-pole": "#EDE4D2",
        "--vl-ph-c-hi": "#EFE1C6",
        "--vl-ph-c-lo": "#B9A181",
        "--vl-radius-card": "6px",
        "--vl-radius-photo": "8px",
        "--vl-radius-pill": "4px",
        "--vl-gap": "12px",
        "--vl-margin": "44px",
        "--vl-shadow-card": "0 24px 60px rgba(0, 0, 0, 0.18)"
      },
      "type": {
        "display": "Archivo, \"Archivo Black\", Impact, sans-serif",
        "body": "Archivo, Inter, -apple-system, \"Helvetica Neue\", Arial, sans-serif",
        "scale": {
          "wordmark": "148px / 0.82 / 900 / -0.01em",
          "logo": "22px / 1 / 900",
          "nav": "13px / 1 / 500",
          "button": "12px / 1 / 700 / 0.06em",
          "baseline": "11px / 1 / 400 italic"
        }
      }
    },
    {
      "id": "ref-08-swiss-studio-hero",
      "name": "Suisse studio — hero web Studioform®",
      "patterns": [
        "layout-14-statement-first",
        "title-04-name-fills-measure"
      ],
      "deck": "decks/ref-08-swiss-studio-hero.html",
      "tokens": {
        "--vl-bg": "#F6F5F2",
        "--vl-board": "#F6F5F2",
        "--vl-card": "#F6F5F2",
        "--vl-ink": "#111111",
        "--vl-ink-soft": "#2A2A28",
        "--vl-radius-card": "4px",
        "--vl-radius-photo": "8px",
        "--vl-margin": "40px",
        "--vl-gap-photo": "16px"
      },
      "type": {
        "display": "\"Helvetica Neue\", Helvetica, Inter, Arial, sans-serif",
        "body": "\"Helvetica Neue\", Helvetica, Inter, Arial, sans-serif",
        "scale": {
          "wordmark": "204px / 1 / 700 / -0.035em — se règle à la largeur, pas au nombre",
          "registered": "0.30em du wordmark, en exposant",
          "statement": "27px / 1.4 / 400 — 3 lignes, ≈ 46 caractères par ligne",
          "nav": "18px / 1 / 400"
        }
      }
    },
    {
      "id": "ref-09-zine-annotated-blue",
      "name": "Zine annoté bleu",
      "patterns": [],
      "deck": "decks/ref-09-zine-annotated-blue.html",
      "tokens": {
        "--vl-paper": "#FFFFFF",
        "--vl-board": "#D4D4D4",
        "--vl-ink": "#111111",
        "--vl-ink-soft": "#6F6F6F",
        "--vl-blue": "#2F3FE0",
        "--vl-rule": "#DCDCDC",
        "--vl-photo-light": "#E4E4E4",
        "--vl-photo-mid": "#A8A8A8",
        "--vl-photo-dark": "#1A1A1A",
        "--vl-margin": "96px",
        "--vl-col": "208px",
        "--vl-gutter": "32px",
        "--vl-measure": "448px",
        "--vl-stroke": "3.5px"
      },
      "type": {
        "display": "Inter, \"Helvetica Neue\", Arial, sans-serif",
        "body": "Inter, \"Helvetica Neue\", Arial, sans-serif",
        "scale": {
          "title": "80px / 1 / 500 / -0.02em (couverture ET sections, bas de casse, bleu)",
          "sub": "32px / 1.25 / 600 (intertitre de colonne, encre)",
          "body": "20px / 1.45 / 400 (justifié, hyphens auto, gris, mesure 448)",
          "caption": "15px / 1.35 / 500 (légende d'image, libellé de liste)",
          "micro": "16px / 1 / 500 / 0.18em (rail vertical, folio)"
        }
      }
    },
    {
      "id": "ref-10-campaign-board-red",
      "name": "Planche de campagne — condensée rouge",
      "patterns": [
        "layout-10-bleed-column-inset",
        "list-07-hairline-spec-table",
        "title-02-condensed-overlay-stack"
      ],
      "deck": "decks/ref-10-campaign-board-red.html",
      "tokens": {
        "--vl-bg": "#EDEAE3",
        "--vl-board": "#EDEAE3",
        "--vl-slide": "#EDEAE3",
        "--vl-red": "#C82C15",
        "--vl-white": "#FFFFFF",
        "--vl-inset": "137px",
        "--vl-gutter": "8px",
        "--vl-rule": "2.5px",
        "--vl-cow-white": "#F2EFE7",
        "--vl-cow-shade": "#D8D1C1",
        "--vl-cow-brown": "#5A3520",
        "--vl-cow-brown-hi": "#8A5732",
        "--vl-jkt-red": "#C6281B",
        "--vl-jkt-red-hi": "#EC5233",
        "--vl-jkt-red-lo": "#8A150F",
        "--vl-jkt-sky": "#74A9D6",
        "--vl-jkt-sky-lo": "#A8C6DE",
        "--vl-jkt-btn": "#26100D",
        "--vl-road-sky": "#D2DEE6",
        "--vl-road-tree": "#2A4622",
        "--vl-road-green": "#5F8C46",
        "--vl-road-green-lo": "#3B5C2C",
        "--vl-road-dirt": "#C3A57E",
        "--vl-road-dirt-hi": "#DDC59F",
        "--vl-road-rut": "#A88964",
        "--vl-road-figure": "#201E1A",
        "--vl-hand-bg": "#D9DBD3",
        "--vl-hand-bg-lo": "#B4B8AE",
        "--vl-glove": "#F3F0E8",
        "--vl-glove-hi": "#FFFFFF",
        "--vl-glove-sh": "#CBC5B7",
        "--vl-vial-glass": "#DEE3E1",
        "--vl-vial-cap": "#131110",
        "--vl-vial-drop": "#6A6660",
        "--vl-room-red": "#CE2A1C",
        "--vl-room-red-lo": "#8E120C",
        "--vl-room-fig": "#6B0F0A",
        "--vl-cact-bg": "#B5AFA0",
        "--vl-cact-bg-lo": "#8E8A7C",
        "--vl-cact-green": "#6C8860",
        "--vl-cact-green-hi": "#95AC86",
        "--vl-cact-green-lo": "#3D5436",
        "--vl-cact-spine": "#D6402A",
        "--vl-cact-spine-hi": "#F2A794"
      },
      "type": {
        "display": "Archivo, \"Archivo Black\", Anton, Impact, sans-serif",
        "body": "Archivo, Inter, \"Helvetica Neue\", sans-serif",
        "scale": {
          "title": "104px / 0.84 / 900 / wdth 85 / -0.01em / UPPERCASE",
          "section-number": "78px / 1 / 600 / wdth 100 — parenthèses comprises",
          "micro-caps": "13px / 1.45 / 700 / 0.02em / UPPERCASE — colonnes de ~16 caractères",
          "table-label": "13px / 1.45 / 700 / 0.02em / UPPERCASE"
        }
      }
    },
    {
      "id": "ref-11-finance-dashboard-mint",
      "name": "Dashboard bento clair — menthe & lilas",
      "patterns": [
        "card-04-stat-barchart",
        "card-05-balance-gauge",
        "card-06-forecast-timeline",
        "card-07-transactions"
      ],
      "deck": "decks/ref-11-finance-dashboard-mint.html",
      "tokens": {
        "--vl-bg": "#E6EAE4",
        "--vl-board": "#E6EAE4",
        "--vl-surface-light": "#FFFFFF",
        "--vl-surface-soft": "#F1F2EF",
        "--vl-surface-dark": "#141414",
        "--vl-mint": "#B4E3A0",
        "--vl-mint-deep": "#8FCE72",
        "--vl-lilac": "#B6ABE6",
        "--vl-lilac-deep": "#9A8ADE",
        "--vl-ink": "#181A17",
        "--vl-ink-muted": "#7C817A",
        "--vl-ink-on-mint": "#3D5A30",
        "--vl-ink-on-lilac": "#3E3270",
        "--vl-ink-invert": "#FFFFFF",
        "--vl-ink-invert-muted": "#9CA09A",
        "--vl-hairline": "#E3E5E1",
        "--vl-hairline-invert": "#333333",
        "--vl-radius-card": "26px",
        "--vl-radius-sub": "20px",
        "--vl-radius-chip": "14px",
        "--vl-gap": "20px"
      },
      "type": {
        "display": "Inter, -apple-system, \"Helvetica Neue\", Arial, sans-serif",
        "body": "Inter, -apple-system, \"Helvetica Neue\", Arial, sans-serif",
        "scale": {
          "figure-xl": "46px / 1 / 700 / -0.02em",
          "figure": "30px / 1.04 / 700 / -0.02em",
          "title": "18px / 1.15 / 600 / -0.01em",
          "lead": "15px / 1.3 / 500",
          "body": "13px / 1.45 / 400",
          "micro": "11px / 1.35 / 500"
        }
      }
    },
    {
      "id": "ref-12-neon-capsule-tags",
      "name": "Néon vert — étiquettes capsules sur fond sombre",
      "patterns": [
        "tag-01-gooey-capsule"
      ],
      "deck": "decks/ref-12-neon-capsule-tags.html",
      "tokens": {
        "--vl-bg": "#26331F",
        "--vl-neon": "#4FE383",
        "--vl-neon-soft": "#4FE38344",
        "--vl-ink-invert": "#FFFFFF",
        "--vl-cap-u": "91px",
        "--vl-margin": "60px"
      },
      "type": {
        "display": "Inter, \"Helvetica Neue\", Arial, sans-serif",
        "body": "Inter, \"Helvetica Neue\", Arial, sans-serif",
        "scale": {
          "capsule": "0.62 × u / 1 / 500 / 0.005em / UPPERCASE",
          "title": "68px / 1.05 / 700 / -0.01em / UPPERCASE",
          "body": "17px / 1.5 / 400",
          "micro": "12px / 1.4 / 600 / 0.12em / UPPERCASE"
        }
      }
    },
    {
      "id": "ref-13-glass-fintech-dashboard",
      "name": "Dashboard en verre dépoli — lavande & bleu",
      "patterns": [
        "card-08-orb-chain-total",
        "card-09-gradient-metric-curve",
        "chart-03-accent-column-callout",
        "layout-03-glass-board",
        "list-04-due-rows"
      ],
      "deck": "decks/ref-13-glass-fintech-dashboard.html",
      "tokens": {
        "--vl-bg": "#B4BEDD",
        "--vl-grad-1": "#A3B0DA",
        "--vl-grad-2": "#98A9D6",
        "--vl-grad-3": "#C8CEDA",
        "--vl-surface-glass": "rgba(255, 255, 255, 0.45)",
        "--vl-surface-glass-strong": "rgba(255, 255, 255, 0.68)",
        "--vl-surface-glass-soft": "rgba(255, 255, 255, 0.24)",
        "--vl-surface-dark": "#101625",
        "--vl-sheen": "rgba(255, 255, 255, 0.45)",
        "--vl-blue": "#4F7CF7",
        "--vl-blue-light": "#8FB6FA",
        "--vl-violet": "#8B5CF6",
        "--vl-violet-deep": "#5B7CF0",
        "--vl-violet-halo": "rgba(123, 106, 240, 0.42)",
        "--vl-ink": "#1A1F2E",
        "--vl-ink-muted": "#565C72",
        "--vl-ink-invert": "#FFFFFF",
        "--vl-ink-invert-muted": "rgba(255, 255, 255, 0.72)",
        "--vl-radius-card": "28px",
        "--vl-radius-sub": "18px",
        "--vl-gap": "22px",
        "--vl-margin": "44px",
        "--vl-blur": "18px"
      },
      "type": {
        "display": "Inter, -apple-system, \"Helvetica Neue\", Arial, sans-serif",
        "body": "Inter, -apple-system, \"Helvetica Neue\", Arial, sans-serif",
        "scale": {
          "figure-xl": "45px / 1 / 600 / -0.025em",
          "figure": "32px / 1.02 / 600 / -0.025em",
          "figure-orb": "20px / 1.05 / 600 / -0.02em",
          "title": "15px / 1.2 / 500 / -0.01em",
          "body": "13px / 1.35 / 500",
          "micro": "11px / 1.3 / 400"
        }
      }
    },
    {
      "id": "ref-14-layer-stack-coral",
      "name": "Schéma de couches — corail sur blanc cassé",
      "patterns": [
        "diagram-01-layer-stack"
      ],
      "deck": "decks/ref-14-layer-stack-coral.html",
      "tokens": {
        "--vl-bg": "#F1EFEC",
        "--vl-coral": "#E4886A",
        "--vl-layer-alpha-1": "1",
        "--vl-layer-alpha-2": "0.44",
        "--vl-layer-alpha-3": "0.18",
        "--vl-ink": "#3A3835",
        "--vl-ink-muted": "#6E6863",
        "--vl-hairline": "#97918C",
        "--vl-track-title": "0.13em",
        "--vl-track-body": "0.05em"
      },
      "type": {
        "display": "Archivo, -apple-system, \"Helvetica Neue\", Arial, sans-serif",
        "body": "Archivo, -apple-system, \"Helvetica Neue\", Arial, sans-serif",
        "scale": {
          "title": "34px / 1.2 / 300 / 0.13em / uppercase",
          "body": "21px / 1.75 / 400 / 0.05em / uppercase"
        }
      }
    },
    {
      "id": "ref-15-lilac-notched-kpi",
      "name": "Lavande SaaS à vignettes chanfreinées",
      "patterns": [
        "card-10-kpi-notch-tile"
      ],
      "deck": "decks/ref-15-lilac-notched-kpi.html",
      "tokens": {
        "--vl-bg": "#F4F4FB",
        "--vl-card": "#FCFBFF",
        "--vl-white": "#FFFFFF",
        "--vl-violet": "#6B3FE4",
        "--vl-violet-light": "#9A7BF0",
        "--vl-figure": "#4C1FD6",
        "--vl-ink": "#2E2166",
        "--vl-ink-muted": "#6E67A0",
        "--vl-tile": "#EBE7FB",
        "--vl-tile-invert": "rgba(255,255,255,0.22)",
        "--vl-on-accent": "#FFFFFF",
        "--vl-on-accent-muted": "#FFFFFF",
        "--vl-notch": "46px",
        "--vl-radius": "17px",
        "--vl-margin": "120px"
      },
      "type": {
        "display": "Montserrat, Poppins, Inter, \"Helvetica Neue\", Arial, sans-serif",
        "body": "Montserrat, Poppins, Inter, \"Helvetica Neue\", Arial, sans-serif",
        "scale": {
          "figure": "108px / 1 / 800 / -0.03em",
          "suffix": "46px / 1 / 800 / -0.01em",
          "label": "34px / 1.25 / 700 / -0.01em",
          "note": "20px / 1.45 / 500"
        }
      }
    },
    {
      "id": "ref-16-cobalt-graph-paper",
      "name": "Papier millimétré cobalt",
      "patterns": [
        "chart-04-unit-textured-bar",
        "layout-04-graph-paper-cells"
      ],
      "deck": "decks/ref-16-cobalt-graph-paper.html",
      "tokens": {
        "--vl-paper": "#F0EBDE",
        "--vl-paper-2": "#E6E0CE",
        "--vl-ink": "#1F2BE0",
        "--vl-ink-faint": "rgba(31,43,224,0.20)",
        "--vl-grid": "rgba(31,43,224,0.10)",
        "--vl-near": "#14163A",
        "--vl-grid-step": "35.2px",
        "--vl-cell-pitch": "70.4px",
        "--vl-cell-gap": "6px",
        "--vl-rule": "1.25px",
        "--vl-margin": "92px"
      },
      "type": {
        "display": "Newsreader, Georgia, \"Times New Roman\", serif",
        "body": "\"Hanken Grotesk\", Inter, system-ui, sans-serif",
        "mono": "\"DM Mono\", ui-monospace, SFMono-Regular, Menlo, monospace",
        "scale": {
          "title": "123px / 0.96 / 400 / -0.008em",
          "headline": "57px / 1.1 / 400 / -0.005em",
          "figure": "47px / 1.12 / 400",
          "body": "25px / 1.5 / 400",
          "mono": "22px / 1.42 / 400 / 0.06em"
        }
      }
    },
    {
      "id": "ref-17-mono-bento-legal",
      "name": "Bento monochrome, hiérarchie par la surface",
      "patterns": [
        "card-11-corner-arrow-tile",
        "layout-05-nav-pill-bar",
        "layout-06-bento-l-span",
        "tag-02-centered-cloud"
      ],
      "deck": "decks/ref-17-mono-bento-legal.html",
      "tokens": {
        "--vl-page": "#FEFEFE",
        "--vl-module": "#EDEDED",
        "--vl-object": "#FFFFFF",
        "--vl-invert": "#000000",
        "--vl-ink": "#111111",
        "--vl-ink-soft": "#5F5F5F",
        "--vl-on-invert": "#FFFFFF",
        "--vl-on-invert-soft": "rgba(255,255,255,0.62)",
        "--vl-line": "rgba(17,17,17,0.55)",
        "--vl-radius-page": "38px",
        "--vl-radius-module": "24px",
        "--vl-radius-bar": "20px",
        "--vl-radius-pill": "999px",
        "--vl-gutter": "26px",
        "--vl-margin": "57px",
        "--vl-bar-h": "69px",
        "--vl-col-1": "343fr",
        "--vl-col-2": "466fr",
        "--vl-col-3": "464fr",
        "--vl-row-1": "302fr",
        "--vl-row-2": "232fr",
        "--vl-row-3": "243fr"
      },
      "type": {
        "display": "Inter, \"Helvetica Neue\", Arial, sans-serif",
        "body": "Inter, \"Helvetica Neue\", Arial, sans-serif",
        "mono": "\"DM Mono\", ui-monospace, SFMono-Regular, Menlo, monospace",
        "scale": {
          "claim": "46px / 1.14 / 500 / -0.02em",
          "wordmark": "30px / 1.1 / 700 / -0.02em",
          "title": "23px / 1.24 / 500 / -0.01em",
          "body": "16px / 1.45 / 500",
          "tag": "11px / 1.2 / 500"
        }
      }
    },
    {
      "id": "ref-18-lime-sales-dashboard",
      "name": "Tableau de bord achromatique à accent vert dosé",
      "patterns": [
        "card-12-inverted-kpi-row",
        "chart-05-tile-heatmap",
        "layout-07-icon-rail-shell",
        "tag-03-action-pill-bar"
      ],
      "deck": "decks/ref-18-lime-sales-dashboard.html",
      "tokens": {
        "--vl-app": "#FEFEFE",
        "--vl-module": "#F2F2F2",
        "--vl-module-warm": "#FBF9EC",
        "--vl-invert": "#000000",
        "--vl-accent": "#5FE85C",
        "--vl-ink": "#0B0B0B",
        "--vl-ink-soft": "#6B6B6B",
        "--vl-on-invert": "#FFFFFF",
        "--vl-on-invert-soft": "rgba(255,255,255,0.66)",
        "--vl-hairline": "#E6E6E6",
        "--vl-ramp-1": "#E3E3E3",
        "--vl-ramp-2": "#CCCCCC",
        "--vl-ramp-3": "#999999",
        "--vl-ramp-4": "#666666",
        "--vl-ramp-5": "#000000",
        "--vl-radius-module": "29px",
        "--vl-radius-tile": "20px",
        "--vl-radius-pill": "999px",
        "--vl-gutter": "16px",
        "--vl-gutter-row": "11px",
        "--vl-rail-w": "95px",
        "--vl-margin": "30px",
        "--vl-header-h": "69px"
      },
      "type": {
        "display": "Montserrat, Poppins, Inter, \"Helvetica Neue\", Arial, sans-serif",
        "body": "Montserrat, Poppins, Inter, \"Helvetica Neue\", Arial, sans-serif",
        "mono": "\"DM Mono\", ui-monospace, SFMono-Regular, Menlo, monospace",
        "scale": {
          "page": "66px / 1.06 / 500 / -0.02em",
          "figure": "62px / 1.0 / 700 / -0.025em",
          "title": "27px / 1.2 / 500 / -0.01em",
          "label": "19px / 1.3 / 500",
          "micro": "16px / 1.35 / 500"
        }
      }
    },
    {
      "id": "ref-19-acid-clinical-timeline",
      "name": "Feuille clinique grise sur voile, accent acide",
      "patterns": [
        "diagram-02-branch-timeline",
        "diagram-03-time-scrubber",
        "layout-08-sheet-handle-tab",
        "list-05-vitals-strip"
      ],
      "deck": "decks/ref-19-acid-clinical-timeline.html",
      "tokens": {
        "--vl-veil": "#4A4A48",
        "--vl-veil-deep": "#1B1D1D",
        "--vl-sheet": "#DFDFD7",
        "--vl-module": "#F0EFEB",
        "--vl-module-hi": "#FFFFFF",
        "--vl-accent": "#F5FE49",
        "--vl-alert": "#E8756B",
        "--vl-recover": "#7FA88C",
        "--vl-ink": "#262C2C",
        "--vl-ink-soft": "#5F615E",
        "--vl-on-veil": "#FFFFFF",
        "--vl-on-veil-soft": "rgba(255,255,255,0.72)",
        "--vl-radius-sheet": "34px",
        "--vl-radius-module": "22px",
        "--vl-radius-pill": "999px",
        "--vl-notch": "26px",
        "--vl-bleed": "20px",
        "--vl-tabbar-h": "106px",
        "--vl-ribbon-h": "74px",
        "--vl-gutter": "18px",
        "--vl-margin": "34px"
      },
      "type": {
        "display": "Montserrat, Poppins, Inter, \"Helvetica Neue\", Arial, sans-serif",
        "body": "Montserrat, Poppins, Inter, \"Helvetica Neue\", Arial, sans-serif",
        "mono": "\"DM Mono\", ui-monospace, SFMono-Regular, Menlo, monospace",
        "scale": {
          "sheet": "48px / 1.05 / 500 / -0.02em",
          "diagnosis": "37px / 1.1 / 500 / -0.015em",
          "value": "30px / 1.1 / 600 / -0.01em",
          "label": "24px / 1.2 / 500 / -0.01em",
          "micro": "14px / 1.3 / 500"
        }
      }
    }
  ]
}
