# vizir.bet — Design system, handoff développement (HTML / CSS / JS)

Document de référence pour l’implémentation. Stack : **HTML statique + CSS + JavaScript modules natifs**, sans framework ni bundler obligatoire.
Source visuelle : `Planche de composants v2.dc.html`. Source des valeurs : `tokens.css`.

**Cinq règles non négociables**

1. **Le HTML est la source de vérité.** Tout contenu indexable et lisible est écrit dans le fichier HTML de la page. Aucun composant n’injecte de texte de contenu au chargement. Test de recette : `curl` la page, le `h1` et le contenu doivent y être.
2. **Les composants améliorent, ils ne construisent pas.** Un élément personnalisé enveloppe du HTML déjà correct et lui ajoute un comportement. Sans JavaScript, la page reste lisible et navigable (dégradation prévue pour chaque composant, section 5).
3. **Le natif d’abord.** `<details>` avant un accordéon maison, `<dialog>` avant une modale maison, `popover` avant une infobulle maison, `<table>` avant une grille de `div`, `<button>`/`<a>` avant tout élément cliquable. On ne réécrit pas ce que le navigateur fait déjà mieux (focus, `Échap`, `inert`, couche supérieure).
4. **Aucune valeur en dur** dans un composant : uniquement les variables de `tokens.css`.
5. Le contrat d’accessibilité listé sous chaque composant est implémenté **dans le composant ou dans le gabarit HTML**, jamais laissé à la charge de la page.

---

## 1. Architecture

### Deux familles de composants

| Famille | Quand | Forme | Exemples |
|---|---|---|---|
| **Classe CSS** | présentation pure, aucun état | `class="btn btn--primary"` sur du HTML natif | `.btn`, `.tag`, `.label`, `.card`, `.field`, `.msg`, `.avatar`, `.stat`, `.skeleton`, `.list-row`, `.nav-item`, `.state-screen`, `table.picks` |
| **Élément personnalisé** | état, clavier, synchronisation, ouverture/fermeture | `<vzr-…>` en **light DOM**, préfixe `vzr-` | `vzr-tabs`, `vzr-radio-group`, `vzr-chip-group`, `vzr-date-strip`, `vzr-date-picker`, `vzr-dialog`, `vzr-popover`, `vzr-fav-toggle`, `vzr-form`, `vzr-toasts`, `vzr-theme-toggle`, `vzr-menu` |

Ne pas créer `<vzr-button>` : un bouton n’a pas de comportement propre, et l’envelopper casse la soumission de formulaire, le focus et la sémantique. Un bouton est un `<button class="btn btn--primary">`.

### Light DOM, pas de shadow DOM

Décision structurante, motivée par l’accessibilité et le SEO :

- `aria-controls`, `aria-labelledby`, `aria-describedby`, `label for` **ne traversent pas** une frontière de shadow DOM. Or presque tous nos composants en dépendent (accordéon, onglets, champs, dialogues).
- Le contenu en shadow DOM injecté par JS n’est pas garanti indexable.
- Les styles globaux de `tokens.css` s’appliquent sans `::part` ni duplication.

Donc : **aucun `attachShadow`**. Les éléments personnalisés lisent et pilotent leurs enfants réels. Le shadow DOM reste envisageable plus tard pour un widget isolé et sans contenu (ex. un graphique), jamais pour la coque ou les formulaires.

### `<template>` : pour ce qui est répété à l’exécution seulement

Un `<template>` sert à cloner des fragments créés **après** le chargement : ligne de toast, cellule de jour du calendrier, ligne de conseil ajoutée en direct, message d’erreur de formulaire. Le contenu initial de la page n’est jamais un template — il est dans le HTML.

```html
<template id="tpl-toast">
  <div class="msg msg--toast" role="status">
    <i class="ph" aria-hidden="true"></i>
    <span class="msg__text"></span>
  </div>
</template>
```

```js
const frag = document.getElementById('tpl-toast').content.cloneNode(true);
```

### Pas de FOUC, pas de CLS

Ne jamais masquer un composant non défini (`vzr-…:not(:defined) { display: none }`) : c’est un contenu invisible pour l’utilisateur et un saut de mise en page. On réserve la place à la place :

```css
vzr-date-strip:not(:defined) { display: block; min-height: 88px; }
vzr-tabs:not(:defined)       { display: block; }
```

Le HTML interne étant déjà correct, l’état non défini est simplement l’état sans JavaScript.

---

## 2. Arborescence

```
/
  index.html
  turf.html                     turf/2026-10-14.html      (une page par date indexable)
  sports.html                   favoris.html
  blog.html                     blog/comment-lire-une-cote.html
  forum.html                    forum/turf.html           forum/turf/r1-vincennes-42.html
  forum/nouveau-sujet.html
  formules.html                 paiement.html             profil.html
  connexion.html                inscription.html          mot-de-passe-oublie.html
  403.html                      404.html
  css/
    tokens.css                  jetons + base + focus + .sr-only (fourni)
    layout.css                  coque, grille, points de rupture
    components/                 un fichier par composant : btn.css, tag.css, picks.css…
    main.css                    @layer + @import (concaténé en production)
  js/
    core/
      base-element.js           VzrElement : classe de base
      focus-trap.js             (secours pour les navigateurs sans <dialog>)
      roving-tabindex.js        tabindex tournant + flèches
      media.js                  matchMedia partagé, un seul écouteur par requête
      theme.js                  application + persistance du thème
      toast.js                  API toast globale
      format.js                 cotes, pourcentages, dates, heures en fr-FR
    components/                 un fichier par élément personnalisé
    main.js                     point d’entrée : import des composants
  partials/                     header.html, footer.html, compliance.html (étape d’inclusion)
  assets/fonts/  assets/img/
  sitemap.xml  robots.txt  manifest.webmanifest
```

**Chargement**

```html
<link rel="stylesheet" href="/css/main.css">
<script type="module" src="/js/main.js"></script>   <!-- différé par nature -->
```

Modules ES natifs : aucun bundler nécessaire en développement. En production, concaténer CSS et JS (esbuild ou un simple script `cat` + minification) pour limiter les requêtes ; le code n’a pas à changer.

**Coque répétée.** L’en-tête, le pied de page et le bandeau légal doivent être du **vrai HTML dans chaque page** (liens crawlables). Ne pas les générer en JS. Deux options acceptables : une étape d’inclusion au build (Eleventy, `html-includes`, ou un script Node de 20 lignes assemblant `partials/`), ou des inclusions serveur (SSI, `include` PHP). L’élément `<vzr-menu>` n’ajoute que l’ouverture du panneau, jamais le contenu du menu.

---

## 3. CSS

### Couches

```css
/* main.css */
@layer tokens, base, layout, components, utilities;
@import url("tokens.css")            layer(tokens);
@import url("layout.css")            layer(layout);
@import url("components/btn.css")    layer(components);
/* … */
```

Les couches évitent la guerre de spécificité sans un seul `!important`.

### Nommage

BEM allégé, une classe racine par composant : `.btn`, `.btn--primary`, `.btn--sm`, `.btn__icon`. Les états viennent des attributs réels, pas de classes : `[aria-pressed="true"]`, `[aria-current="page"]`, `[aria-expanded="true"]`, `[open]`, `[disabled]`, `[data-tone="fort"]`. Une seule source de vérité pour l’état, lisible par les technologies d’assistance **et** par le CSS.

```css
.chip[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
.chip[aria-pressed="true"] .chip__check { display: inline-flex; }
```

### Règles de style

- Aucun rayon dans tout le système, sauf la bulle de compteur de notifications (`--radius-badge`).
- Chiffres comparables (cotes, ROI, prix, dates) : `font-variant-numeric: tabular-nums`.
- Cibles tactiles : `min-height: var(--tap)` / `min-width: var(--tap)`, sans exception (48px en barre de navigation basse).
- Focus : la règle `:focus-visible` de `tokens.css` suffit ; sur un aplat d’accent, ajouter `.focus-inset`.
- `prefers-reduced-motion` déjà neutralisé globalement dans `tokens.css`.
- Icônes Phosphor via la feuille web (`<i class="ph ph-star" aria-hidden="true">`) ou, mieux pour la performance, un sprite SVG local des ~30 glyphes utilisés (`<svg class="icon" aria-hidden="true"><use href="/assets/img/icons.svg#star">`). Choisir une méthode et ne pas mélanger.

---

## 4. Convention des éléments personnalisés

```js
// js/core/base-element.js
export class VzrElement extends HTMLElement {
  static tag = '';
  static define() { if (!customElements.get(this.tag)) customElements.define(this.tag, this); }

  connectedCallback() {
    if (this._ready) return;            // reconnexion : ne pas réinitialiser
    this._ready = true;
    this._controller = new AbortController();
    this.init(this._controller.signal);
  }
  disconnectedCallback() { this._controller?.abort(); }

  init(_signal) {}                       // à surcharger
  $(sel)  { return this.querySelector(sel); }
  $$(sel) { return [...this.querySelectorAll(sel)]; }
  emit(name, detail) {
    return this.dispatchEvent(new CustomEvent(`vzr:${name}`, { detail, bubbles: true }));
  }
}
```

Conventions :

- **API = attributs HTML** en kebab-case (`side="bottom"`, `week-of="2026-10-12"`), lus dans `init()` et reflétés via `attributeChangedCallback` quand ils changent à l’exécution.
- **Sortie = événements** `vzr:*` qui remontent (`vzr:change`, `vzr:open`, `vzr:close`, `vzr:toggle`). Aucun composant n’appelle directement un autre composant.
- Tous les écouteurs prennent le `signal` de l'`AbortController` : le nettoyage est automatique.
- Un composant ne touche jamais au DOM d’une autre page ni au `document.body` (exception assumée : `vzr-toasts`, `theme.js`).
- `id` : si un composant a besoin de lier `aria-controls`, il utilise l'`id` déjà présent dans le HTML ; s’il en génère un, préfixe `vzr-` + compteur.

---

## 5. Catalogue

Pour chaque entrée : **la forme HTML**, l’API, l’accessibilité, et la **dégradation sans JS**.

### 5.1 Boutons — `.btn`, `.icon-btn`

```html
<button type="button" class="btn btn--primary">Voir les formules</button>
<a href="/formules.html" class="btn btn--secondary btn--sm">Comparer</a>
<button type="button" class="btn btn--primary" aria-busy="true">Envoi…</button>
<button type="button" class="icon-btn" aria-label="Notifications, 3 non lues">
  <i class="ph ph-bell" aria-hidden="true"></i>
  <span class="icon-btn__badge" aria-hidden="true">3</span>
</button>
```

| Taille | Police | Padding fond plein | Padding avec bordure 1px |
|---|---|---|---|
| défaut | 14px | 12px 24px | 11px 23px |
| `.btn--sm` | 13px | 10px 20px | 9px 19px |

Variantes : `--primary` (`--accent`), `--secondary` (bordure `--border-fn`), `--tertiary` (texte `--accent`, padding 12px 8px), `--danger` (réservé aux actions irréversibles). La v1 comptait huit paddings différents : ne pas en réintroduire.

**A11y** — `type` toujours explicite. Chargement : `aria-busy="true"` **et** libellé changé (« Envoi… »). Préférer `aria-disabled="true"` + soumission bloquée à `disabled` quand l’utilisateur doit pouvoir atteindre le bouton pour comprendre le blocage. `.icon-btn` fait 44×44 même pour un glyphe de 14px, porte toujours un `aria-label`, et sa bulle de compteur est `aria-hidden` (le nombre est dans le libellé).

### 5.2 `.tag` · `.chip` + `<vzr-chip-group>` · `.label`

```html
<span class="tag" data-tone="fort"><span class="sr-only">Confiance : </span>Fort</span>
<span class="tag tag--sm" data-tone="won">
  <i class="ph ph-check" aria-hidden="true"></i><span class="sr-only">Résultat : </span>Gagné
</span>
```

`data-tone` : `faible | moyen | eleve | won | lost | np` → jetons `--conf-*` / `--result-*`. **L’échelle de confiance compte trois niveaux : faible, moyen, élevé** (le niveau « nul » a été supprimé ; le jeton `--conf-nul-*` sert désormais à l’état de résultat « Non partant »). `won`/`lost` portent une icône en plus de la couleur. Le préfixe `.sr-only` est **obligatoire** : la couleur et la colonne ne portent jamais seules l’information.

```html
<vzr-chip-group>
  <div role="group" aria-label="Niveau de confiance">
    <button type="button" class="chip" aria-pressed="true">
      <i class="ph ph-check chip__check" aria-hidden="true"></i>Fort
    </button>
    <button type="button" class="chip" aria-pressed="false">Moyen</button>
  </div>
</vzr-chip-group>
```

`vzr-chip-group` bascule `aria-pressed`, expose `value` (tableau) et émet `vzr:change`. Filtres cumulables uniquement — un choix exclusif est un `vzr-radio-group`. **Sans JS** : les puces sont des boutons de formulaire (`name`/`value`) dans un `<form method="get">`, la soumission recharge la page filtrée.

`.label` : capitales Archivo. `md` 12px / `.08em` / `font-stretch 85%` (titre de section, nom de réunion) · `.label--sm` 11px / `.06em` (en-tête de colonne, groupe de filtres, libellé de ROI) · `.label--accent` (catégorie d’article). **Quand l’étiquette ouvre une section, la balise est `h2`/`h3` avec la classe**, pas un `span`.

Ne pas confondre : `.tag` = aplat informatif · `.chip` = contour sélectionnable · `.label` = capitales structurelles.

### 5.3 Formulaires — `.field` + natifs + `<vzr-form>`

```html
<div class="field">
  <label class="field__label" for="cp">Code postal</label>
  <input class="input" id="cp" name="postal-code" type="text" inputmode="numeric"
         autocomplete="postal-code" required
         aria-invalid="true" aria-describedby="cp-hint cp-err">
  <p class="field__hint" id="cp-hint">5 chiffres.</p>
  <p class="field__error" id="cp-err">
    <i class="ph ph-warning-circle" aria-hidden="true"></i>Format invalide.
  </p>
</div>
```

- Mot de passe : `.input` + `.icon-btn` en `trailing`, `aria-label="Afficher le mot de passe"` + `aria-pressed`.
- Date : `.input` + `.icon-btn` calendrier ouvrant `vzr-date-picker`.
- Listes déroulantes : `<select class="select">` natif (chevron décoratif en `pointer-events:none`). Pas de liste de `div role="option"` sans clavier, comme en v1.
- Erreur : libellé et message en `--danger`, bordure portée à **2px**, icône `warning-circle`. Jamais la couleur seule.
- Case à cocher : `accent-color: var(--accent)`, 20×20, zone cliquable de 44px portée par le `<label>`.
- Jamais de `placeholder` en remplacement d’un libellé. `autocomplete` systématique (`email`, `current-password`, `cc-number`, `postal-code`…).

`<vzr-form>` enveloppe le `<form>` : validation à la soumission via l’API de contrainte native (`checkValidity`, `setCustomValidity`), rendu des messages dans `.field__error`, pose de `aria-invalid`/`aria-describedby`, focus sur le premier champ invalide, et résumé en tête de formulaire dans un conteneur `role="alert"`. `novalidate` sur le `<form>` pour maîtriser les messages, **jamais** sans `vzr-form` chargé. **Sans JS** : validation native du navigateur + validation serveur, les deux doivent exister.

### 5.4 `<vzr-tabs>` et `<vzr-radio-group>` (contrôle segmenté)

Un seul gabarit visuel `.segmented`, **deux sémantiques distinctes** — c’est le correctif le plus important de la passe.

Le contrôle change un panneau de contenu dans la même page :

```html
<vzr-tabs>
  <div class="segmented" role="tablist" aria-label="Type de pari">
    <button type="button" class="segmented__item" role="tab" id="t-g"
            aria-selected="true" aria-controls="p-g" tabindex="0">Pari gagnant</button>
    <button type="button" class="segmented__item" role="tab" id="t-p"
            aria-selected="false" aria-controls="p-p" tabindex="-1">Pari placé</button>
  </div>
  <div id="p-g" role="tabpanel" aria-labelledby="t-g">…</div>
  <div id="p-p" role="tabpanel" aria-labelledby="t-p" hidden>…</div>
</vzr-tabs>
```

Le contrôle est un choix de formulaire (périodicité, moyen de paiement) :

```html
<vzr-radio-group name="paiement" value="carte">
  <div class="segmented segmented--grid" role="radiogroup" aria-label="Moyen de paiement">
    <button type="button" role="radio" aria-checked="true" tabindex="0" value="carte">…</button>
    <button type="button" role="radio" aria-checked="false" tabindex="-1" value="sepa">…</button>
  </div>
  <input type="hidden" name="paiement" value="carte">
</vzr-radio-group>
```

Les deux utilisent `roving-tabindex.js` : un seul élément dans l’ordre de tabulation, flèches ← →, `Début`/`Fin`. Icône seule : nom accessible en `.sr-only` dans le bouton (pas un `aria-label` qui écraserait un texte visible). Un filtre cumulable n’est ni l’un ni l’autre : `vzr-chip-group`.

**Sans JS** : `vzr-tabs` → les panneaux sont visibles à la suite, précédés de leurs titres (retirer `hidden` par défaut, c’est le JS qui le pose). `vzr-radio-group` → remplacer par de vrais `<input type="radio">` stylés en `.segmented` ; c’est la variante recommandée pour Paiement et Formules, où la robustesse compte plus que le rendu.

### 5.5 `<details class="disclosure">` — accordéon

```html
<details class="disclosure disclosure--row" open>
  <summary><h3 class="disclosure__title"><i class="ph ph-horse" aria-hidden="true"></i>Turf</h3></summary>
  <div class="disclosure__panel">…</div>
</details>
```

Trois gabarits, un seul mécanisme : `--label` (réunion turf), `--row` (discipline), `--footer` (colonne de pied de page sous 768px). Remplace trois composants de la v1.

`<details>` fournit gratuitement l’état, le clavier, l’annonce d’expansion et le fonctionnement sans JS. Le marqueur natif est masqué (`summary::-webkit-details-marker`, `list-style: none`) et remplacé par un chevron dont **le glyphe change** (`caret-right` → `caret-down`) plutôt qu’une simple rotation, qui disparaît sous `prefers-reduced-motion`.

Élément personnalisé `<vzr-accordion>` **optionnel**, uniquement si un seul panneau doit rester ouvert : il écoute `toggle` et referme les frères. Sans JS, tous les panneaux s’ouvrent — dégradation acceptable.

Pied de page : les colonnes sont des `<details open>` sous 768px et des colonnes normales au-dessus (`@media (min-width: 768px) { .disclosure--footer > summary { display: none } .disclosure--footer > .disclosure__panel { display: block } }` avec `details[open]` forcé côté CSS, aucun JS).

### 5.6 `table.picks` + `<vzr-fav-toggle>` — listes de conseils

Deuxième correctif majeur : ce sont des **données tabulaires avec en-tête de colonnes**, donc un vrai tableau.

```html
<table class="picks" data-discipline="turf" data-mode="tip">
  <caption class="label">R1 · Vincennes — conseils du 14 octobre</caption>
  <thead>
    <tr>
      <th scope="col" class="picks__fav"><span class="sr-only">Favori</span></th>
      <th scope="col">Partant</th>
      <th scope="col" class="picks__odds">Cote</th>
      <th scope="col" class="picks__tag">Confiance</th>
    </tr>
  </thead>
  <tbody>
    <tr><th scope="colgroup" colspan="4" class="picks__race">C3 – Prix de Cornulier – 14h35</th></tr>
    <tr>
      <td class="picks__fav">
        <vzr-fav-toggle pick="fw-42">
          <button type="button" class="icon-btn" aria-pressed="false"
                  aria-label="Ajouter Fantastic Wind aux favoris">
            <i class="ph ph-star" aria-hidden="true"></i>
          </button>
        </vzr-fav-toggle>
      </td>
      <td><span class="picks__name">Fantastic Wind</span>
          <span class="picks__meta">Driver – J. Bazire · Entr. – Y. Béthouart</span></td>
      <td class="picks__odds">4,50</td>
      <td class="picks__tag">
        <span class="tag tag--sm" data-tone="fort"><span class="sr-only">Confiance : </span>Fort</span>
        <button type="button" class="icon-btn" popovertarget="pop-conf-fort"
                aria-label="Comprendre le niveau de confiance">
          <i class="ph ph-info" aria-hidden="true"></i>
        </button>
      </td>
    </tr>
  </tbody>
</table>
```

Un seul gabarit pour turf / football / tennis (`data-discipline`) et pour conseil / résultat (`data-mode`). Ce qui varie : la cellule de gauche (favori en turf, icône de discipline en sport), le contenu de la cellule principale, la colonne de droite (`.tag` de confiance + info, ou `.tag` de résultat). Largeurs par jetons `--col-*`, lues par `thead` et `tbody`.

**Variante dense (< 480px)** : la table reste une table, les cellules sont repositionnées en grille par CSS (`.picks--dense` ou une simple requête média sur `.picks`). En v1, en `div`, un lecteur d’écran lisait « 4,50 » sans jamais annoncer « Cote » ; ne pas revenir en arrière au motif du responsive.

`<vzr-fav-toggle>` : bascule `aria-pressed`, échange le glyphe (`star` → `star-fill`), appelle l’API et annonce le résultat via `toast.js`. Libellé nominatif (« Ajouter Fantastic Wind aux favoris »), pas vingt fois le même. **Sans JS** : le bouton est dans un `<form method="post" action="/favoris">` — la ligne reste fonctionnelle.

Typographie de contenu : tiret demi-cadratin « – » comme séparateur de champs, heures « 13h50 », décimales à la virgule.

### 5.7 `.list-row` — sujets de forum, entrées de menu, favoris

```html
<ul class="list">
  <li>
    <a class="list-row" href="/forum/turf/r1-vincennes-42.html" data-state="pinned">
      <span class="avatar avatar--sm" aria-hidden="true">JM</span>
      <span class="list-row__body">
        <span class="list-row__title">
          <i class="ph ph-push-pin" aria-hidden="true"></i>
          <span class="sr-only">Sujet épinglé. </span>R1 Vincennes — vos pronostics
        </span>
        <span class="list-row__meta">J. Morel · aujourd’hui 09:12 · 47 réponses</span>
      </span>
      <span class="list-row__trailing">
        <span class="sr-only">Réponses non lues</span><span class="dot" aria-hidden="true"></span>
      </span>
    </a>
  </li>
</ul>
```

Troisième correctif majeur : chaque état a un équivalent textuel (`.sr-only` pour épinglé et non lu, « Clos » visible dans la méta). L’état clos utilise `--text-2`, **pas** l’opacité 0,6 de la v1 (l’opacité fait tomber le contraste sous 3:1 et n’est pas compensable). `ul > li`, un seul `<a>` couvrant la ligne (jamais deux liens vers la même cible), hauteur minimale 44px.

### 5.8 `.card` — formule, espace de forum, article

Coque commune : filet 1px `--rule`, aucun arrondi, padding 24px. `.card--highlighted` = filet 2px `--accent` (formule recommandée). `.card--locked` = couleur `--text-2` + filet `--rule-2` + étiquette « Verrouillé » ; **jamais** l’opacité 0,55 de la v1.

- `.card--plan` : nom, prix tabulaire + « /mois », bénéfices en `<ul>` (pas des `<br>`), CTA `.btn` pleine largeur.
- `.card--space` : icône de discipline, compteur de sujets, titre porteur du lien, trois états (accessible / non abonné / hors formule).
- `.card--article` : image 4:3 sans arrondi avec `width`/`height` explicites, `.label--accent` de catégorie, titre 19px, chapô 14px (`-webkit-line-clamp: 3`), « Lire plus » + flèche. Grille 3 colonnes ≥ 1024px, 2 en tablette, 1 en mobile.

**A11y** — le titre porte le lien (`<h3><a href>`), la flèche « Accéder / Lire plus » est `aria-hidden` : sinon deux liens sont annoncés pour une seule cible. Au survol, seule la flèche se décale de 4px.

### 5.9 `<vzr-date-strip>` et `<vzr-date-picker>`

```html
<vzr-date-strip value="2026-10-14" week-of="2026-10-12">
  <div class="datestrip__head">
    <span class="label label--sm">Semaine 42</span>
    <button type="button" class="icon-btn" data-week="-1" aria-label="Semaine précédente">…</button>
    <button type="button" class="icon-btn" data-week="1" aria-label="Semaine suivante">…</button>
  </div>
  <div class="datestrip__days" role="radiogroup" aria-label="Date des conseils">
    <button type="button" class="day" role="radio" aria-checked="false" tabindex="-1"
            value="2026-10-12"><span class="day__dow">lun.</span><span class="day__num">12</span></button>
    <button type="button" class="day" role="radio" aria-checked="true" tabindex="0"
            value="2026-10-14">…</button>
    <button type="button" class="day" role="radio" aria-checked="false" tabindex="-1"
            aria-current="date" value="2026-10-15">…</button>
  </div>
</vzr-date-strip>
```

La barre de semaine et le calendrier partagent la classe `.day` (états passé / aujourd’hui / sélectionné). Le calendrier n’est pas un composant à part : c’est une grille de `.day` dans un `[popover]` (desktop) ou un `<dialog class="sheet">` (mobile), clonée depuis `<template id="tpl-month">`.

**A11y** — sélection = `role="radiogroup"` + `aria-checked` (la v1 utilisait `aria-pressed`, qui décrit une bascule, pas un choix exclusif). Aujourd’hui = `aria-current="date"`, distinct de la sélection. Un seul jour dans l’ordre de tabulation ; ← → change de jour, ↑ ↓ de semaine. Les cellules du calendrier sont des boutons de 44px (des `span` en v1). Fermeture : `Échap`, clic extérieur, focus restitué au déclencheur.

Repères : anneau = aujourd’hui, soulignement 3px `--accent` = sélection, jours passés en `--text-2`.

**Sans JS et pour le SEO** : chaque jour est un lien `<a href="/turf/2026-10-14.html">` quand la page de date existe (voir section 7 : les archives de résultats sont un actif SEO). L’élément intercepte le clic pour éviter le rechargement.

### 5.10 Surfaces flottantes — `popover`, `<dialog>`

Quatre composants de la v1 (infobulle ancrée, feuille mobile, feuille de filtres, panneau de menu, modale) reposent sur **deux mécanismes natifs**, pas sur un `Overlay` maison.

**Infobulle non modale → API `popover`**

```html
<button type="button" class="icon-btn" popovertarget="pop-conf-nul"
        aria-label="Comprendre le niveau de confiance"><i class="ph ph-info" aria-hidden="true"></i></button>

<div id="pop-conf-nul" popover class="popover" role="dialog" aria-labelledby="pop-conf-nul-t">
  <h4 id="pop-conf-nul-t">Confiance « Nul »</h4>
  <p>Ne signifie pas donnée manquante : le modèle déconseille délibérément ce pari.</p>
  <a href="/blog/methodologie.html">Lire l’article de méthodologie →</a>
</div>
```

Le natif gère la couche supérieure, `Échap`, la fermeture au clic extérieur et `aria-expanded` sur le déclencheur. `<vzr-popover>` n’ajoute que le positionnement (ancrage CSS quand disponible, sinon calcul JS) et la bascule vers une feuille sous 768px.

**Modale et feuille → `<dialog>`**

```html
<dialog class="modal modal--danger" id="dlg-suppr" aria-labelledby="dlg-t" aria-describedby="dlg-d">
  <i class="ph ph-warning-circle" aria-hidden="true"></i>
  <h2 id="dlg-t">Supprimer votre compte ?</h2>
  <p id="dlg-d">Action irréversible : toutes vos données seront supprimées.</p>
  <form method="dialog" class="modal__actions">
    <button class="btn btn--secondary btn--sm" value="annuler" autofocus>Annuler</button>
    <button class="btn btn--danger btn--sm" value="supprimer">Supprimer</button>
  </form>
</dialog>
```

Ouvert par `showModal()` : piège de focus, `Échap`, arrière-plan `inert`, `::backdrop` (couleur `--overlay`) — tout est natif, il n’y a plus de piège de focus à écrire (`focus-trap.js` reste en secours pour les très vieux navigateurs). `<vzr-dialog side="bottom">` ajoute uniquement le glissement de la feuille, le verrou de défilement du body et la restitution du focus si l’ouverture est venue d’un lien.

**Règles communes** — `role="dialog"` implicite pour `<dialog>`, `aria-labelledby` obligatoire, `aria-describedby` pour la modale. Focus initial sur le premier élément focalisable, **jamais** sur le bouton destructeur (`autofocus` sur « Annuler »). Le voile est cliquable pour fermer **et** doublé d’un bouton « Fermer » réel de 44px (la v1 n’avait qu’une icône non focalisable). Titre de la modale destructive = la question ; bouton = l’action nommée (« Supprimer »), pas « OK ».

`.sheet--filters` est une composition : `<dialog class="sheet">` + `vzr-chip-group` + `<select>` de tri + `.btn` pleine largeur « Appliquer », dans un `<form method="get">` afin que les filtres restent des URL partageables. Le nombre de filtres actifs est dans le libellé du déclencheur (« Filtres, 2 actifs »).

### 5.11 `.msg` + `<vzr-toasts>`

```html
<p class="msg msg--info" role="status"><i class="ph ph-info" aria-hidden="true"></i>Données momentanément indisponibles.</p>

<vzr-toasts id="vzr-toasts" role="status" aria-live="polite" aria-atomic="false"></vzr-toasts>
```

`.msg--info` (fond `--surface-2`, dans le flux), `.msg--toast` (fond `--surface`, bordure, ombre, ancré en haut centré). L’élément `<vzr-toasts>` est présent dans **chaque page** (dans la coque) : la région d’annonce doit exister **avant** l’apparition du message, sinon rien n’est lu. API : `toast('Pseudo mis à jour.', { tone: 'success' })`.

Information et succès ⇒ `role="status"` ; erreur ⇒ un conteneur `role="alert"` distinct. Auto-disparition ~3s pour les toasts non critiques uniquement ; une erreur reste jusqu’à action.

### 5.12 `.state-screen` — un gabarit pour sept écrans

```html
<section class="state-screen state-screen--center" data-tone="neutral">
  <i class="ph ph-lock-key" aria-hidden="true"></i>
  <p class="state-screen__eyebrow">Erreur 403</p>
  <h1>Accès refusé</h1>
  <p>Cette page est réservée aux utilisateurs disposant d’un compte.</p>
  <div class="state-screen__actions">…</div>
</section>
```

| Usage | icône | data-tone / alignement | actions |
|---|---|---|---|
| Réservé aux abonnés | `lock-simple` | neutral / start | Voir les formules |
| Discipline hors formule | `lock-simple` | neutral / start | Changer de formule + Comparer |
| Compte suspendu (modération) | `prohibit` | danger / start | Contacter le support |
| Abonnement expiré | `clock-counter-clockwise` | neutral / start | Réactiver + Voir les formules |
| 403 — compte requis | `lock-key` | neutral / center + eyebrow | Se connecter + S’inscrire |
| Liste vide | `calendar-x` | neutral / center | — |
| 404 | `compass` | neutral / center + eyebrow | Retour à l’accueil |

Règles métier conservées : la suspension est une décision de modération (rouge, pas de CTA de réactivation, le reste du site reste accessible) ; l’expiration propose de réactiver la formule **nommée** ; le 403 remplace toute la page. Un compte suspendu ou expiré ne voit jamais le forum, quelle que soit sa formule.

**A11y** — le titre est le `h1` quand l’écran remplace la page (403, 404, suspension), un `h2` quand il remplace une section. `data-tone="danger"` ne colore que l’icône.

**Serveur** — l’écran est rendu **côté serveur** avec le bon code HTTP (403, 404, ou 200 pour une barrière d’abonnement) et le bon `<title>`. Le contenu réservé n’est **jamais** présent dans le HTML puis masqué en CSS : c’est du cloaking, sanctionnable, et trivialement contournable.

### 5.13 Coque — HTML + `<vzr-menu>`, `<vzr-theme-toggle>`

```html
<a class="skip-link" href="#content">Aller au contenu principal</a>
<header class="app-header">
  <a class="wordmark" href="/" aria-label="vizir.bet — accueil">…</a>
  <nav aria-label="Navigation principale">
    <ul>
      <li><a class="nav-item" href="/turf.html" aria-current="page">…Turf</a></li>
      …
    </ul>
  </nav>
  <div class="app-header__actions">…cloche, menu, CTA…</div>
</header>
<main id="content">…</main>
<nav class="bottom-nav" aria-label="Navigation principale">…</nav>
<aside class="compliance">…</aside>
<footer class="app-footer">…</footer>
<vzr-toasts …></vzr-toasts>
```

- Un seul en-tête pour tablette et desktop (seules les tailles changent). Ordre invariable des actions : cloche, menu, CTA.
- `.nav-item` partagé par l’en-tête et la barre basse. Quatre entrées à parité : Turf, Sports, Blog, Forum. Le menu ☰ ouvre `<dialog class="sheet sheet--right">` dont **le contenu est dans le HTML**.
- `<vzr-theme-toggle>` : trois états (clair / sombre / système ; le mode système manquait en v1), `role="radiogroup"`, boutons 44px. `theme.js` applique `data-theme` sur `<html>` et persiste en `localStorage`. Un script **synchrone et court** en `<head>` lit la préférence avant peinture pour éviter le flash :
  ```html
  <script>try{var t=localStorage.getItem('vzr-theme');document.documentElement.dataset.theme=
  t&&t!=='system'?t:(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
  ```
- Bandeau de conformité : filets haut et bas, 12px `--text-2`, texte légal invariable (jeu responsable, 18+, numéro d’aide). C’est du contenu, pas une région ARIA.
- `aria-current="page"` posé **dans le HTML** de chaque page (pas en JS) : c’est de l’information, pas du comportement.

**A11y** — `.skip-link` premier élément focalisable, visible au focus, cible `<main id="content">` (absent en v1). Navigations dans des `<nav aria-label>` distincts (« Navigation principale », « Pied de page »), entrées en `ul > li` avec de vrais liens (des `span` en v1). Barre basse : 48px de haut, libellé texte conservé sous l’icône.

### 5.14 `.share` + `<vzr-share>` — partage d’un contenu

```html
<vzr-share>
  <section class="share" aria-labelledby="share-title">
    <h2 class="label" id="share-title">Partager cet article</h2>
    <ul class="share__list">
      <li><button class="icon-btn icon-btn--outlined" type="button" data-share="copy"
                  aria-label="Copier le lien de l’article"><i class="ph ph-link" aria-hidden="true"></i></button></li>
      <li><a class="icon-btn icon-btn--outlined" target="_blank" rel="noopener"
             href="https://www.facebook.com/sharer/sharer.php?u=…"
             aria-label="Partager sur Facebook (nouvelle fenêtre)">…</a></li>
      <li><a … href="https://x.com/intent/post?url=…&amp;text=…" aria-label="Partager sur X (nouvelle fenêtre)">…</a></li>
      <li hidden><button … data-share="native" aria-label="Partager par une autre application">…</button></li>
    </ul>
  </section>
</vzr-share>
```

Ordre invariable : lien, Facebook, X, puis bouton système. Boutons = `.icon-btn--outlined` (filet 1px `--border-fn`, 44px, survol en accent) ; logos Phosphor à 20px (`link`, `facebook-logo`, `x-logo`, `share-network`), **jamais** un logo redessiné ni un aplat de couleur de marque.

**Réseaux retenus** — seuls les réseaux offrant un **point de partage web réel** ont une icône : Facebook (`sharer.php`) et X (`intent/post`). Instagram n’en expose aucun (pas d’équivalent de `sharer.php`) : pas d’icône Instagram, plutôt qu’un bouton qui copierait l’adresse en promettant un partage. Le partage vers Instagram passe par le bouton système sur mobile, où la feuille de l’appareil propose la vraie entrée. Même règle pour tout réseau ajouté ensuite.

**Source unique de l’URL** — `<vzr-share>` lit `<link rel="canonical">` et `document.title` ; l’URL n’est écrite qu’une fois dans le `<head>`, ce qui interdit de partager une adresse portant des paramètres de filtre. Le composant remplit les `href` de partage au chargement et retire (`hidden`) les commandes indisponibles : `data-share="native"` n’apparaît que si `navigator.share` existe, `copy` que si le presse-papiers répond.

**Confirmation** — la copie appelle `toast('Lien de l’article copié.', { tone: 'success' })` (§ 5.11) et s’affiche dans le `<vzr-toasts>` de la coque ; ton `danger` si le presse-papiers refuse, le message portant alors l’adresse en clair. Aucun composant de confirmation propre au partage.

**A11y** — `<section aria-labelledby>` étiquetée par son titre visible ; `<ul>` pour annoncer le nombre d’options. Chaque commande sans texte porte un `aria-label` qui nomme le réseau **et** l’effet (« (nouvelle fenêtre) », « copier le lien de l’article »), glyphe `aria-hidden`. Cibles 44px, `:focus-visible`, `rel="noopener"`.

**Sans JS** — Facebook et X sont de vrais liens et fonctionnent. « Copier le lien » et « Autre application » dépendent du script et sont retirés s’ils ne peuvent pas fonctionner : pas de bouton mort.

---

## 6. Carte page → composants

| Page | Composants |
|---|---|
| Turf | coque, `vzr-date-strip`, `vzr-date-picker`, `vzr-tabs`, `.sheet--filters`, `details.disclosure` (réunion), `table.picks`, `.tag`, `[popover]`, `.stat`, `.state-screen` |
| Sports | idem Turf, `.segmented--icon` pour la discipline |
| Favoris | coque, `vzr-tabs`, `.sheet--filters`, `table.picks`, `.list-row`, `.state-screen` |
| Blog | coque, `.card--article`, `.label`, `vzr-chip-group` (catégories) |
| Article | coque, `.label`, prose (`h2`/`h3`, listes, citations), `.share` + `vzr-share` (lien, Facebook, X), `.card--article` (à lire ensuite), JSON-LD `Article` |
| Forum | coque, `.card--space`, `.state-screen` |
| Forum espace | coque, `.list-row`, `.btn`, `.state-screen` |
| Forum sujet | coque, `.avatar`, `.list-row`, `.field` + `textarea`, `.icon-btn`, `<dialog>` (signalement) |
| Forum nouveau sujet | coque, `vzr-form`, `.field` + `input`/`select`/`textarea`, `.btn` |
| Formules | coque, `vzr-radio-group` (périodicité), `.card--plan`, `details.disclosure` (FAQ + JSON-LD `FAQPage`) |
| Paiement | coque, `vzr-radio-group` (moyen de paiement), `vzr-form`, `.field`, `.btn`, `.msg` |
| Profil | coque, `.avatar`, `vzr-form`, `.stat`, `<dialog class="modal--danger">`, `vzr-toasts`, `.state-screen` |
| Connexion / Inscription / Mot de passe oublié | `vzr-form`, `.field`, `.btn`, `.msg`, `.wordmark` |

---

## 7. SEO

### 7.1 Rendu et indexabilité

- **Une page = un fichier HTML complet.** Pas de rendu de contenu côté client, pas de navigation SPA. Tout ce qui doit être indexé (titres, conseils publics, résultats, articles, sujets de forum, offres) est dans la réponse HTML initiale.
- Les éléments personnalisés n’ajoutent **jamais** de contenu textuel indexable. Recette : `curl -s <url> | grep '<h1'` doit renvoyer le titre ; désactiver JS ne doit pas vider la page.
- Les liens sont des `<a href>` réels avec URL absolue résolvable — y compris les jours de la barre de dates et les entrées de menu (des `span` en v1 : invisibles pour un crawler).

### 7.2 `<head>` par page

```html
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Conseils turf du 14 octobre 2026 — R1 Vincennes | vizir.bet</title>   <!-- ≤ 60 car. -->
  <meta name="description" content="Analyse des partants de la R1 à Vincennes : cotes, niveau de confiance et résultats. Mise à jour quotidienne."> <!-- ≤ 155 -->
  <link rel="canonical" href="https://vizir.bet/turf/2026-10-14.html">
  <meta property="og:title" content="…"><meta property="og:description" content="…">
  <meta property="og:image" content="https://vizir.bet/assets/img/og-turf.jpg">
  <meta property="og:type" content="website"><meta property="og:locale" content="fr_FR">
  <meta name="twitter:card" content="summary_large_image">
  <meta name="theme-color" media="(prefers-color-scheme: light)" content="#FAFAFA">
  <meta name="theme-color" media="(prefers-color-scheme: dark)"  content="#0A0A0A">
  <link rel="icon" href="/assets/img/favicon.svg" type="image/svg+xml">
  <link rel="apple-touch-icon" href="/assets/img/icon-180.png">
  <link rel="manifest" href="/manifest.webmanifest">
</head>
```

`title` et `description` **uniques** par page, rédigés à la main (pas de gabarit automatique creux). Canonical absolue sur chaque page, y compris les variantes filtrées.

### 7.3 Structure sémantique

Un seul `h1` par page, hiérarchie `h1 → h2 → h3` sans saut. Repères : `header`, `nav`, `main`, `aside`, `footer`. Fil d’Ariane visible **et** balisé sur les pages profondes (article, sujet de forum, page de date). Le tableau de conseils avec `<caption>` et `<th scope>` est aussi un gain SEO : les données sont interprétables.

### 7.4 Données structurées (JSON-LD, une seule par page)

| Page | Type |
|---|---|
| Accueil | `Organization` + `WebSite` (avec `SearchAction` si une recherche existe) |
| Article de blog | `BlogPosting` (`headline`, `datePublished`, `dateModified`, `author`, `image`, `articleSection`) |
| Formules | `Service` + `Offer` (prix, devise, périodicité) |
| FAQ des formules | `FAQPage` — uniquement si les questions sont visibles dans la page |
| Sujet de forum | `DiscussionForumPosting` + `Comment` |
| Pages profondes | `BreadcrumbList` |

### 7.5 Contenu réservé aux abonnés

Point le plus délicat du projet. Trois règles :

1. **Jamais de cloaking** : ne pas servir le contenu complet aux robots et une barrière aux humains, ni masquer le contenu payant en CSS. Le `.state-screen` remplace le contenu côté serveur.
2. **Contenu partiellement payant** (article de blog avec suite réservée) : balisage paywall officiel — `"isAccessibleForFree": false` et `hasPart` avec `{"@type":"WebPageElement","isAccessibleForFree":false,"cssSelector":".paywalled"}`. Le début lisible est réellement lisible.
3. **Contenu intégralement réservé** (conseils du jour, forum) : `<meta name="robots" content="noindex, follow">` sur ces routes, plus une **page vitrine publique et indexable** par discipline qui explique la méthodologie et affiche les archives.

**Levier SEO principal : les archives de résultats.** Les conseils passés et leurs résultats n’ont plus de valeur commerciale mais une forte valeur de recherche (« résultat R1 Vincennes 14 octobre »). Les publier en accès libre sur des URL stables `/turf/2026-10-14.html`, indexables, avec le ROI constaté : c’est la meilleure preuve de crédibilité et la principale source de trafic organique. Le jour J reste réservé.

### 7.6 URL, filtres, pagination

- Minuscules, tirets, sans extension si le serveur le permet (`/turf/2026-10-14`), pas d’identifiant technique dans le chemin. Slug d’article descriptif.
- Filtres et tris en paramètres de requête (`?confiance=fort`), **canonical vers la page mère** et `noindex` si la combinaison ne mérite pas d’être indexée. Les filtres restent des URL partageables (`<form method="get">`) — bon pour l’utilisateur et pour le partage.
- Pagination : liens numérotés réels (`<a href="?page=2">`), jamais un défilement infini sans liens.
- Redirections 301 pour toute URL modifiée ; jamais de 404 silencieuse renvoyant un code 200.

### 7.7 `robots.txt` et `sitemap.xml`

```
User-agent: *
Allow: /
Disallow: /paiement
Disallow: /profil
Sitemap: https://vizir.bet/sitemap.xml
```

Sitemap généré au build, `lastmod` réel, pages `noindex` exclues. Une entrée par page de date d’archive.

### 7.8 Performance (Core Web Vitals)

- Polices : `preload` des deux fichiers `woff2` réellement utilisés, `font-display: swap`, sous-ensemble latin, hébergées en local (pas d’appel Google Fonts en production).
- Images : `width`/`height` **toujours** présents (zéro CLS), `loading="lazy"` sauf la première image visible, `decoding="async"`, AVIF/WebP avec repli.
- CSS : la coque en critique inline (ou un seul fichier concaténé et mis en cache long) ; le reste dans `main.css`. Pas de framework CSS.
- JS : `type="module"` (différé par nature), aucun script bloquant sauf les six lignes de thème. Budget indicatif : < 30 ko de JS compressé pour l’ensemble des composants — atteignable, puisque `<details>`, `<dialog>` et `popover` sont natifs.
- Réserver la hauteur des `vzr-…:not(:defined)` pour éviter le CLS (section 1).

### 7.9 Spécificités jeu d’argent

- Mentions légales, conditions, politique de confidentialité et jeu responsable : pages réelles, indexables, liées depuis le pied de page de chaque page.
- Liens sortants vers des opérateurs : `rel="nofollow sponsored noopener"`.
- Bandeau de conformité présent sur toutes les pages (18+, numéro d’aide) — également un signal de confiance.
- Consentement/analytique : ne doit ni bloquer le rendu ni décaler la mise en page.

---

## 8. Accessibilité — état de la passe

**Corrigé par rapport à la v1**

- Éléments interactifs faits de `div` / `span` / `<i>` (croix de fermeture, puces de filtre, entrées de menu et de navigation, cellules de calendrier, bascule de thème) → vrais `button` / `a`.
- Listes de conseils → `<table>` avec `caption` et `th scope`.
- `role="tablist"` détourné pour des choix de formulaire → `radiogroup` (`vzr-radio-group`), tabindex tournant et navigation aux flèches.
- Sélection de date en `aria-pressed` → `radiogroup` + `aria-current="date"`.
- Accordéons → `<details>` natif : état, clavier et annonce gratuits, fonctionne sans JS.
- Modales et feuilles → `<dialog>` + `showModal()` : piège de focus, `Échap`, `inert` natifs. Focus initial jamais sur l’action destructrice.
- Infobulles → API `popover` : couche supérieure, fermeture légère, `aria-expanded` gérés par le navigateur.
- Anneau de focus généralisé par une règle `:focus-visible` unique.
- Couleurs désactivées portées à ≥ 4:1 (2,2:1 en v1).
- Opacités 0,55 / 0,6 (verrouillé, clos) remplacées par des jetons de couleur + une étiquette textuelle.
- Sens porté par la couleur (confiance, résultat, épinglé, non lu) doublé d’un texte visible ou `.sr-only`.
- Erreurs de formulaire : `aria-invalid`, `aria-describedby`, icône, bordure 2px, résumé `role="alert"`.
- Toasts : région `role="status"` présente dès le chargement (aucune annonce en v1).
- Lien d’évitement, `<main id="content">`, `nav aria-label`, `aria-current="page"`.
- Hiérarchie réelle `h1 → h3`, libellés des icônes seules, cibles 44px (48px en barre basse).
- `prefers-reduced-motion` respecté globalement.

**À vérifier à l’implémentation**

- `<html lang="fr">` et `<title>` unique par page.
- Zoom texte 200 % et largeur 320px sans perte de contenu (la table de conseils passe en dense, elle ne défile pas horizontalement).
- Contraste revérifié après tout ajustement de jeton : `--text-2` ≥ 4,5:1 sur `--bg` et `--surface` dans les deux thèmes.
- Ordre de tabulation = ordre visuel dans l’en-tête (logo, nav, cloche, menu, CTA).
- Paiement : libellés persistants, `autocomplete`, erreurs regroupées à la soumission, validation serveur en plus de la validation client.
- Contenu mis à jour en direct (résultats) : une seule région `aria-live="polite"`, jamais une annonce par ligne.
- Parcours complet sans JavaScript sur Connexion, Paiement, Formules et Turf : les quatre doivent rester utilisables.
- Tests : axe-core en CI (sur le HTML statique, c’est trivial), parcours clavier complet sur Turf / Paiement / Forum, VoiceOver + NVDA sur la table de conseils, Lighthouse ≥ 95 en Accessibilité et SEO.

---

## 9. Conventions de contenu

- Français, vouvoiement, pas d’emoji.
- Séparateur de champs : tiret demi-cadratin « – ». Heures « 13h50 ». Décimales à la virgule. Espace insécable avant « % » et « € ».
- Le rouge `--danger` est réservé : suspension de compte, actions irréversibles, erreurs de formulaire. Jamais pour « Déconnexion » ni « Supprimer la photo ».
- Le vert `--success` est réservé aux toasts de succès ; le vert de résultat gagné est un jeton distinct (`--result-won-bg`).
- Trois niveaux de confiance : « Faible », « Moyen », « Élevé », donnés séparément pour le pari gagnant et le pari placé. Chacun est accompagné de son infobulle d’explication ; « Faible » ne signifie pas donnée manquante, mais un risque élevé assumé par le modèle.
- Mention légale de conformité sur toutes les pages, texte non modifiable.

---

## 10. Ordre de construction conseillé

1. `tokens.css`, `layout.css`, la coque HTML (`partials/`) et l’étape d’inclusion. Une page vide mais complète : `<head>`, repères, lien d’évitement, bandeau légal, `vzr-toasts`.
2. Les classes CSS pures : `.btn`, `.label`, `.tag`, `.field`, `.card`, `.list-row`, `.msg`, `.state-screen`. Aucun JS. À ce stade, Connexion, Inscription, 403, 404 et Formules sont déjà livrables.
3. `base-element.js` + `roving-tabindex.js` + `theme.js` + `toast.js`.
4. `vzr-form`, `vzr-radio-group`, `vzr-tabs` → Paiement, Formules, Profil.
5. `table.picks` + `vzr-fav-toggle` + `vzr-chip-group` + `.sheet--filters` → Turf, Sports, Favoris.
6. `vzr-date-strip` + `vzr-date-picker` + pages de date d’archive (le levier SEO).
7. Forum : `.list-row`, `.card--space`, `<dialog>` de signalement.
8. Passe SEO : JSON-LD, sitemap, canonical, robots, `noindex` des routes réservées.
9. Passe de recette : axe-core, clavier, sans-JS, Lighthouse, zoom 200 %.
