# Refonte du parcours d'entrée — wizard fusionné + tutoriel interactif

**Date** : 2026-07-25
**Branche** : `feature/onboarding-wizard-tutorial`
**Statut** : design validé, plan d'implémentation à écrire

---

## 1. Problème

Les retours utilisateurs signalent une incompréhension globale du parcours : où cliquer, à quoi
sert chaque écran, quels prérequis techniques brancher, et quelle valeur en attendre.

L'audit du code montre que ce n'est pas un défaut d'écran isolé mais un défaut de structure :
**quatre mécanismes d'onboarding coexistent sans se connaître**, chacun avec sa propre définition
d'un utilisateur « prêt ».

| Système | Demande | Stocke | Bloquant |
|---|---|---|---|
| Wizard `/onboarding` | langue, forfait, équipe, token Mangools | `user.onboardingCompletedAt` | oui (redirect) |
| `FirstSiteOnboardingDialog` | le site | rien | non, fermable |
| Checklist d'activation | site, GSC, mots-clés, enrichissement, équipe | rien (dérivé DB) | jamais |
| États vides des pages | incohérent | — | — |

La contradiction centrale : le wizard exige quatre choses dont aucune n'est nécessaire et ne
demande jamais de site ; la checklist en exige quatre autres que le wizard n'évoque jamais. Un
utilisateur peut donc porter `onboardingCompletedAt` non nul avec 0/5 à l'activation. C'est le cas
nominal, pas un cas limite.

### Ruptures identifiées

1. **Signup muet sans provider mail.** Quand `env.emailEnabled` est faux, `setVerificationSent`
   n'est jamais appelé (`src/components/signup-form.tsx:186-190`) : aucun feedback, aucune
   redirection.
2. **La sortie du wizard est le bouton le moins visible.** Étape « Outils » : le primaire
   « Connecter » est désactivé tant que le champ est vide, « Terminer » n'apparaît qu'après succès,
   et la seule issue est un « Passer » en `variant="ghost"`
   (`src/components/onboarding/steps/tools-step.tsx:105-119`). Le layout n'offre ni retour, ni
   logout, ni aide (`src/app/onboarding/layout.tsx`).
3. **Le premier écran du produit est un tableau vide.** Le wizard renvoie sur
   `/keywords/reservoir` ; sans site la page rend `Aucun résultat.`
   (`src/components/ui/data-table.tsx:348`), sans titre ni CTA.
4. **Checkout Stripe sauté en silence.** `onboarding-wizard.tsx:46` conditionne le checkout à
   `planSelection && activeOrgId`. La race « session sans org active » est documentée
   (`src/lib/active-org.ts:15-33`) : l'utilisateur ayant choisi Pro est renvoyé dans l'app sans
   paiement ni message.
5. **Onze dépendances silencieuses**, dont : membre sans `site_access` voyant une app vide sans
   explication (`src/lib/sites/access.ts:83-90`) ; `targetCountries` optionnel au formulaire mais
   cassant les mots-clés associés (`no_location`) et la ventilation GSC ; worker de snapshots
   résolvant uniquement le rôle owner (`src/lib/gsc/worker-token.ts:25-38`), laissant
   `/content/stats` vide à jamais si c'est un admin qui a connecté Google ; clés d'environnement
   absentes faisant *disparaître* des fonctionnalités au lieu d'afficher « non configuré ».

---

## 2. Décisions

| # | Décision | Justification |
|---|---|---|
| D1 | **Fusion** : une seule machine à états remplace les trois systèmes | Seule option qui supprime la contradiction à la racine plutôt que de la contourner |
| D2 | L'objectif de première session est **un plan de contenu** | Choix produit de l'utilisateur |
| D3 | **GSC devient une des trois sources** de l'étape « mots-clés », pas une étape requise | Un site neuf n'a pas de données GSC ; l'exiger bloquerait les utilisateurs qui ont le plus besoin de l'outil |
| D4 | Tutoriel = **mini-tours à la première visite de chaque section** | Répond au « je ne sais pas à quoi sert cet écran » sans imposer un grand tour abandonné en route |
| D5 | **3 tours** en v1 : Réservoir, Recherche, Planning | Couvre exactement le chemin critique ; valide le mécanisme sur un périmètre serré |
| D6 | Les 4 correctifs isolés partent en **branche séparée, livrée d'abord** | Bugs indépendants ; le checkout sauté coûte de l'argent et ne doit pas attendre la refonte |
| D7 | L'atterrissage final est **`/keywords/planning`**, pas `/content/planning` | `/content` est gaté en production (voir §9, risque R1) |

---

## 3. Le chemin critique

Chaîne de dépendances réelle extraite du code :

```
site (URL + pays cible) → mots-clés (GSC | CSV | manuel) → [enrichissement]
  → ciblage d'un pays en Recherche (pose le stage "planning")
  → /keywords/planning → Dispatch → brief de contenu
```

Le wizard actuel ne couvre aucun maillon de cette chaîne. Le nouveau l'épouse intégralement.

**`targetCountries` devient obligatoire** au formulaire de création de site. Il est aujourd'hui
optionnel alors que c'est un maillon dur : sans lui `getRelatedKeywords` échoue en `no_location`
(`src/lib/mangools/related-core.ts:105-111`) et `syncGscKeywordsAction` ne ventile plus par pays
(`src/lib/gsc/actions.ts:215`), dans les deux cas sans jamais en expliquer la cause.

---

## 4. Modèle unique : une machine à états dérivée

### Étapes

Un seul catalogue remplace `ACTIVATION_STEP_IDS` et les étapes du wizard.

| id | classe | dérivation | surface |
|---|---|---|---|
| `create-site` | **requis, bloquant** | ≥1 ligne `sites` sur l'org | wizard étape 1 |
| `add-keywords` | **requis** | ≥1 `keywords` joint sur l'org | wizard étape 2 |
| `target-country` | **requis** | ≥1 ligne `keyword_targets` au statut `planned` sur le site | wizard étape 4 |
| `enrich-keywords` | recommandé | ≥1 keyword avec `volume_primary IS NOT NULL` | wizard étape 3, skippable |
| `connect-gsc` | latéral | ≥1 site avec `gscProperty` non nul | checklist |
| `invite-teammate` | latéral | membre non-owner ou invitation pending | checklist |

`create-site` est la seule étape réellement bloquante : l'application n'a aucun sens sans site
(toutes les pages data court-circuitent leur fetch quand `siteId` est nul). Les autres sont
guidées mais différables.

### Search Console : le compte Google n'est pas le compte d'inscription

Hypothèse à ne jamais faire dans le wizard : **l'administrateur ne s'inscrit pas forcément avec le
compte Google qui détient sa Search Console.** Le cas nominal est même l'inverse en agence.

Ce que la configuration permet déjà : `accountLinking` est activé avec
`trustedProviders: ["google"]` et **`allowDifferentEmails: true`** (`src/lib/auth.ts:127-131`), et
`getOrgOwnerGscToken` itère sur *plusieurs* comptes Google par utilisateur
(`src/lib/gsc/owner.ts:141`). Rattacher un compte Google distinct de l'email d'inscription
fonctionne donc, et le code l'anticipe.

Les deux pièges qui subsistent, à traiter explicitement dans l'UI :

1. **S'inscrire avec Google ne connecte pas Search Console.** Le compte est lié sans le scope
   `webmasters.readonly`, donc `connected` reste faux et l'état `missingScope` s'active
   (`owner.ts:94,141`). L'utilisateur croit avoir connecté ; il doit passer par « Reconnecter ».
   Le wizard doit formuler l'action comme « connecte le compte Google qui a accès à ta Search
   Console », jamais comme « connecte ton compte ».
2. **Le worker de snapshots ne lit que le token du rôle `owner`**
   (`src/lib/gsc/worker-token.ts:30`, `eq(member.role, "owner")`), alors que la sync in-app
   accepte owner **ou** admin. Si un admin est le seul à avoir rattaché Search Console, la
   synchronisation manuelle marche et les snapshots ne tournent jamais — sans erreur visible.
   Le wizard doit donc inciter le **propriétaire** de l'organisation à faire le rattachement, et
   l'étape latérale `connect-gsc` de la checklist doit signaler le cas quand il se présente.

L'alignement du worker sur la même règle que la sync in-app est un correctif distinct, hors
périmètre.

### Révision de l'étape finale (reconnaissance du 25/07)

L'étape `first-brief`, initialement dérivée de `content_items`, est **retirée du chemin critique**.
La reconnaissance a établi qu'elle est infranchissable en première session :

- Le bouton « Envoyer en production » de Dispatch est gaté par
  `serp.state === "ok" && onpage.state === "ok" && brief.state === "ok"` : il exige une SERP
  importée, un crawl on-page terminé **et** un brief YourTextGuru/Lumenia importé. Les trois sont
  faux sur un compte neuf.
- La route `/keywords/dispatch/[id]` est **orpheline** : aucun `href` ni `router.push` du repo n'y
  mène. Seuls des `revalidatePath` la mentionnent.
- Dispatch est à usage unique par pays : `createContentItemAction` fait passer le
  `keyword_target` de `planned` à `live`, et `getDispatchData` ne retient que les `planned`.
  Revenir sur l'URL après envoi donne un `notFound()`.

Le chemin critique s'arrête donc au **ciblage d'un pays**, qui rend `/keywords/planning` peuplé et
Dispatch atteignable. C'est le premier état où l'utilisateur voit un plan de travail constitué à
partir de ses propres données. Le brief complet reste l'objectif produit, mais il relève d'une
seconde session — et sa faisabilité dépend de chantiers distincts (import SERP, crawl, brief).

### Le ciblage pays est aujourd'hui incohérent

Trois chemins UI mènent au « ciblage », avec trois résultats différents :

| Chemin | `keyword_targets` | `keywords.stages` | Résultat |
|---|---|---|---|
| Cellule de la CoverageMatrix → « planifié » | oui, `planned` | oui | **le seul correct** |
| Barre flottante « Cibler » (bulk) | oui, `candidate` | non | n'apparaît jamais dans Planning |
| Bouton « → Planning » | non | oui | Planning le montre, Dispatch fait `notFound()` |

Le wizard appelle donc directement `setKeywordTargetAction`, seul chemin qui écrit les deux faits.
L'harmonisation des trois chemins est un correctif distinct, hors périmètre de cette refonte.

### Correction d'une dérivation fausse

L'étape actuelle `first-enrichment` est dérivée de `keywords.enrichment_exports != '{}'`
(`src/lib/onboarding/activation/derive.ts`). Or cette colonne trace les **exports de listes vers un
outil tiers** (ELEVA-49), pas l'enrichissement. Un utilisateur ayant parfaitement enrichi ses
mots-clés via Mangools n'a donc jamais cette étape cochée tant qu'il n'a rien exporté — et la
checklist la déclare requise.

La nouvelle dérivation utilise `volume_primary`, colonne générée STORED, `NULL` quand la source
primaire n'a pas de métrique (`src/lib/db/domain-schema.ts:216-218`). Elle est indexable et
mesure réellement ce que son nom annonce.

### Principe directeur

**Toute complétion est dérivée de la base, jamais stockée.** L'état « terminé mais vide » devient
alors impossible à représenter — c'est la propriété qui règle le problème de fond, pas une
correction cosmétique.

Le calcul étend `deriveActivation()` (`src/lib/onboarding/activation/derive.ts:15-81`), qui fait
déjà 5 `EXISTS` en `Promise.all`. On passe à 7 requêtes, exécutées **dans le RSC de la sidebar**
et non plus côté client (voir §7).

### Sort de `onboardingCompletedAt`

La colonne est **conservée mais son sens change** : elle ne signifie plus « onboarding terminé »
mais « a vu le wizard au moins une fois ». Elle sert uniquement à distinguer un premier passage
d'un retour, jamais à décider si l'utilisateur est prêt.

Le gate de `src/app/(app)/layout.tsx:62` devient :

```
si les étapes requises ne sont pas complètes ET que l'utilisateur n'a pas explicitement différé
   → wizard
sinon → app
```

Le « différé » est un état de session (pas de persistance) : cliquer « je le ferai plus tard »
dépose l'utilisateur dans l'app avec la checklist ouverte, et le wizard ne se réimpose pas à
chaque navigation.

### Cas du membre sans droit de création

Le rôle est vérifié **avant** l'entrée dans le wizard. Un membre sans `hasFullSiteAccess()`
(`src/lib/sites/actions.ts:43-45`) ne voit pas l'étape 1 mais un écran dédié lui expliquant qu'un
administrateur doit lui attribuer un site, avec le nom des owners de l'org. Cela supprime le
cul-de-sac actuel où il traverse une app entièrement vide sans explication.

---

## 5. Le wizard

**Révision du 2026-07-27, sur décision utilisateur.** Cinq étapes. Search Console devient une
étape à part entière, en position 2, et **ne bloque jamais** : c'est la seule source qui apporte
des données réelles sans rien saisir, mais un site neuf n'en a pas, et la sortie est le chemin
normal dans ce cas — pas une punition. La version précédente la noyait parmi trois chemins de
l'étape « tes mots-clés », ce qui masquait à la fois sa valeur et son inapplicabilité aux sites
récents.

| # | Étape | Contenu | Sortie |
|---|---|---|---|
| 1 | **Ton site** | nom, URL, langue principale, **pays cible (obligatoire)** | site créé, cookie site actif posé |
| 2 | **Search Console** | lier le compte Google, mapper la propriété du site — **facultative** | données réelles, ou rien |
| 3 | **Tes mots-clés** | import CSV · ajout manuel | ≥1 keyword |
| 4 | **Enrichissement** | Mangools ou SE-Ranking, ou passer explicitement | métriques, ou rien |
| 5 | **Ton premier plan** | choisir un mot-clé, cibler un pays, générer le brief | `content_items` créé |

**Sources d'enrichissement réellement disponibles**, relevé du 2026-07-27 : Mangools et
SE-Ranking sont câblées, Search Console aussi. `ahrefs` et `semrush` figurent dans l'énumération
`KeywordSource` (`src/lib/keywords/types.ts:13`) et dans `KEYWORD_SOURCES`, **mais aucun module ne
les alimente** — pas de `src/lib/ahrefs/` ni de `src/lib/semrush/`. L'écran d'import ne doit donc
pas les proposer comme des options actives.

Ce qui disparaît du wizard : la langue (déjà réglée par la négociation `Accept-Language` et
modifiable dans les réglages), le forfait (le trial démarre seul ; le choix appartient à la page
Facturation), l'équipe (étape latérale de la checklist).

**Règles d'ergonomie**, en réponse directe à la rupture n°2 :

- L'action de sortie n'est jamais moins visible que l'action d'engagement. « Passer cette étape »
  est un bouton `outline`, jamais `ghost`.
- Le layout expose en permanence un lien d'aide et un logout.
- Chaque étape indique où l'on en est (« 2 sur 4 ») et ce que l'étape débloque.
- Une étape différée reste visible dans la checklist, jamais perdue.

---

## 6. La checklist

Elle n'est plus une seconde liste : **c'est la même machine à états, en vue reprise**. Elle affiche
les étapes requises non faites, puis les étapes latérales, avec pour chacune un CTA qui pointe vers
l'endroit exact où agir.

Corrections par rapport à l'existant :

- Le CTA de `create-site` pointe vers l'ouverture directe du formulaire, plus vers `/projects` —
  une page qui **n'est pas dans la sidebar** et n'est atteignable que via le sélecteur de projet.
- `connect-gsc` est scindé en deux faits distincts (compte Google lié avec le scope
  `webmasters.readonly`, puis propriété mappée à un site), parce que cocher une case unique masque
  aujourd'hui deux actions dont la seconde est impossible sans site.
- Un membre qui ne peut pas exécuter une étape ne la voit pas présentée comme requise.

---

## 7. Correction de performance

`ActivationCard` est aujourd'hui un composant client qui se refetch au montage, à chaque
changement de `pathname`, à chaque changement de site, et sur `window.focus`
(`src/components/onboarding/activation-card.tsx:47-77`). Cela déclenche 5 requêtes `EXISTS` à
chaque navigation, pour un composant qui ne s'affiche plus une fois l'activation faite.

La dérivation remonte dans le RSC de la sidebar et est passée en props, comme le prévoyait le
design d'origine (`docs/superpowers/specs/2026-07-02-activation-checklist-design.md:61`). Seules
les mutations (dismiss, restore) restent des Server Actions.

---

## 8. Le tutoriel interactif

### Mécanisme

Rendu par **driver.js** (à ajouter ; aucune librairie de tour n'est présente aujourd'hui — seul
`@onboardjs/*` existe, pour de la logique de flow, sans spotlight DOM).

Un tour se déclenche à l'arrivée sur une section si, et seulement si, les trois conditions sont
réunies :

1. le tour n'a jamais été terminé ni rejeté par cet utilisateur ;
2. **la précondition de contenu est remplie** — la page a des données à montrer ;
3. aucun autre tour n'est en cours.

La condition 2 est la règle qui gouverne tout le système : *un tour ne démarre jamais sur un écran
vide*. On n'explique pas le Planning en pointant un tableau à zéro ligne. Si la précondition n'est
pas remplie, le tour est simplement reporté à la prochaine visite — sans rien enregistrer.

### Déclaration

Les tours sont de la donnée, pas du code :

```ts
type Tour = {
  id: TourId;                    // "reservoir" | "recherche" | "planning"
  route: string;                 // route de rattachement
  precondition: (ctx) => boolean;// ex. rowCount > 0
  steps: {
    anchor: string;              // valeur d'un attribut data-tour
    titleKey: string;            // clé i18n
    bodyKey: string;
  }[];
};
```

Les points s'ancrent sur des attributs **`data-tour="..."`** posés sur les éléments cibles, jamais
sur des sélecteurs CSS ou des classes utilitaires : un refactor de style ne doit pas casser le
tutoriel en silence.

### Contenu v1 (3 tours, 3 à 5 points chacun)

- **Réservoir** — ce qu'est le réservoir (staging de recherche, *pas* du rank tracking), le bouton
  Importer, le menu Sync et sa pastille de fraîcheur, les pastilles de source.
- **Recherche** — le sous-ensemble au stage `research`, les colonnes GSC, l'action de ciblage d'un
  pays et ce qu'elle déclenche.
- **Planning** — ce qui arrive ici et pourquoi, les indicateurs de préparation, l'accès au Dispatch.

### Rejouabilité

Un bouton discret dans l'en-tête de page relance le tour de la section courante à tout moment. Il
est visible même après complétion : c'est la seule porte de sortie pour quelqu'un qui a cliqué
« Passer » trop vite.

Une reprise **demandée et affichée est consommée**, même si l'utilisateur quitte la page sans
terminer ni fermer le tour. Cliquer « revoir » est un geste explicite ; changer de page n'en est pas
un, et n'a donc pas à redemander la reprise. Le drapeau « déjà lancé pendant cette visite », lui,
est bien rendu au démontage — c'est ce qui permet au double montage du mode strict de rejouer le
tour au second passage. Les deux vivent dans le même objet de session mais suivent des règles
opposées : voir `sessionAfterTeardown` (`src/lib/tours/plan.ts`).

### Ce que le halo éclaire vraiment

driver.js découpe la boîte du **seul** élément visé ; le halo ne s'étend jamais aux éléments
voisins. Un point dont le texte annonce plusieurs zones alors que l'ancre en désigne une seule ment
donc à l'utilisateur. Cas rencontré : le point `planning.readiness` annonçait « trois colonnes —
SERP, On-page et Brief » avec l'ancre sur le seul en-tête « On-page ».

Un tableau HTML n'offre aucun élément qui enveloppe exactement un sous-ensemble de colonnes : les
seuls candidats (`<tr>`, `<thead>`, le conteneur) contiennent les onze colonnes. Grouper les trois
sous un `<th colSpan={3}>` supposerait une seconde ligne d'en-tête, c'est-à-dire une refonte
visible du tableau pour le confort d'un halo. **La règle retenue est donc l'inverse : le texte du
point est écrit pour ce qui est réellement éclairé** — il prend la colonne ancrée pour sujet et
situe ses voisines. Déplacer une ancre oblige à relire le texte du point, dans les deux catalogues.

### Un clic hors du halo rejette le tour — et c'est voulu

Observation de la vérification navigateur : pendant un tour, un **vrai** clic sur la barre latérale
ne navigue pas. Il atterrit sur l'overlay de driver.js (un `<path>` SVG plein écran, `pointer-events:
auto`), qui émet `overlayClick` → `destroy()` → `onDestroyStarted` → statut `dismissed`. Il faut donc
deux clics pour quitter une page à tour : le premier rejette, le second navigue.

**Aucun correctif.** C'est le contrat d'une bibliothèque modale, et il est cohérent avec le nôtre :
cliquer hors du halo EST un geste explicite de rejet, exactement comme Échap ou la croix — les trois
passent d'ailleurs par le même rappel. Le tour est récupérable à tout moment par le bouton
« revoir ».

Conséquence à connaître pour la relecture : la règle « quitter une page ne consomme pas un tour »
n'est donc pas le chemin normal, c'est un **filet**. Elle couvre les départs qui ne passent pas par
un clic sur l'overlay — retour arrière du navigateur, redirection, `router.refresh()`, fermeture
d'onglet, ou un clic sur l'élément mis en avant lui-même (driver.js le laisse interactif). Les
scénarios de navigation de la QA ont été validés par clic **programmatique**, qui contourne la
détection de cible : concluant pour le routage, muet sur ce point-ci.

### Le décor de driver.js est à traduire séparément

Le compteur de progression et les trois boutons (« Previous », « Next », « Done ») sont écrits par
la bibliothèque, pas par nous : non renseignés, ils restent en anglais sous des textes traduits, et
**le test de parité en/fr ne peut pas le voir** puisqu'ils n'existent dans aucun catalogue. Ils
vivent sous `Tour.ui.*` et sont passés à driver.js par `buildDriverLabels`.

Piège du compteur : driver.js attend un gabarit à marqueurs `{{current}}` / `{{total}}`, que
next-intl refuse de parser (ce n'est pas de l'ICU). Le catalogue porte donc `{current}` / `{total}`
et reçoit les marqueurs de driver.js **en valeur** — next-intl compose le gabarit, driver.js le
remplit.

---

## 9. Modèle de données

Une seule table nouvelle. L'audit confirme qu'aucun champ existant ne convient :
`onboardingCompletedAt` est un drapeau global unique, et `activation_dismissals` est binaire par
organisation — ni l'un ni l'autre ne mémorise un état par tour.

```
tour_progress
  userId    text  FK user (cascade)
  tourId    text
  status    text  -- "completed" | "dismissed"
  updatedAt timestamptz default now()
  PRIMARY KEY (userId, tourId)
```

Le modèle est **par utilisateur**, pas par organisation : un tutoriel explique l'interface, pas les
données d'une org. Un utilisateur qui change d'organisation ne doit pas revoir les mêmes tours.

Aucune autre migration. `activation_dismissals` est conservée telle quelle pour la checklist.

---

## 10. i18n

Un nouveau namespace `Tour` dans **`messages/en.json` et `messages/fr.json`**. La parité des clés
est vérifiée par un test (`src/i18n/catalogs.test.ts`) : toute clé ajoutée d'un côté sans l'autre
fait échouer la suite. Seules deux locales existent (`src/i18n/config.ts` — pas de `es` ni `de`
malgré ce que suggèrent d'anciennes notes).

Les namespaces `Onboarding` et `Activation` sont retravaillés en place.

---

## 11. Suppressions

| Cible | Raison |
|---|---|
| `FirstSiteOnboardingDialog` | Absorbée par l'étape 1 ; supprime le 4ᵉ point d'entrée de « créer un site » et la modale qui réapparaît à chaque chargement |
| `@onboardjs/core` + `@onboardjs/react` | La lib ne produit qu'un `suggestedStepId` aussitôt recalculé depuis les flags serveur (`engine.tsx:65-66`) : fonctionnellement inerte, en `1.0.0-rc`, déjà en quarantaine |
| `engine.tsx`, `engine-steps.ts`, `activation-error-boundary.tsx` | Deviennent sans objet une fois OnboardJS retiré |
| Étapes `language`, `plan`, `team` du wizard | Hors chemin critique |
| Clés i18n mortes | `planFree`, `planFreeDesc`, `planComingSoon`, `planPro`, `planAgency` — vestiges d'un design abandonné, non référencées par `plan-step.tsx` |

Retirer OnboardJS supprime une dépendance en release candidate dont la disparition était déjà
anticipée dans un commentaire du code.

---

## 12. Hors périmètre

Ces quatre correctifs partent en branche `fix/onboarding-dead-ends`, livrée avant la refonte
(décision D6) :

1. Signup sans provider mail — afficher un état explicite.
2. Sortie du wizard — hiérarchie des boutons (couvert de fait par la refonte, mais le correctif
   isolé permet de livrer sans attendre).
3. Checkout Stripe sauté — résoudre l'org avant le checkout, et échouer bruyamment plutôt que
   silencieusement.
4. Membre sans site attribué — écran explicatif au lieu d'une app vide.

Également hors périmètre : l'ouverture de `/content` en production (décision produit distincte,
voir R1), les états vides des sections Backlink, et les tours des sections Pages et Backlink
(reportés en v2 par la décision D5).

### Le parcours doit rejouer à chaque ajout de site, pas seulement à l'inscription

**Défaut de la conception actuelle, à corriger.** Les flags d'activation sont dérivés **au niveau
de l'organisation** : `add-keywords` est vrai dès qu'*un* mot-clé existe sur *un* site de l'org.
Conséquence, un utilisateur qui ajoute un deuxième site se retrouve exactement dans la situation
qu'on corrige — un site vide, aucune donnée, aucune indication — pendant que la checklist affiche
une activation complète. C'est le même défaut que l'original, déplacé d'un cran.

Or l'ajout de site n'est pas un cas marginal : c'est le geste normal d'une agence, et le sélecteur
en propose l'entrée en permanence.

Direction à retenir : les étapes qui portent sur des **données** (`add-keywords`,
`enrich-keywords`, `target-country`, `connect-gsc`) doivent être dérivées **par site**, et non par
organisation. Seule `create-site` reste org-scopée, et `invite-teammate` aussi. La checklist
s'affiche alors pour le site actif, et rejoue naturellement à chaque nouveau site sans mécanisme
dédié — c'est la même machine à états, changée de portée.

**Révision du 2026-07-27 — le parcours s'ouvre à chaque création de site.** La version
précédente réservait le wizard plein écran au premier site, au motif qu'on n'enferme pas un
utilisateur expérimenté dans un parcours modal. Retour utilisateur en sens inverse : le flux
actuel oblige à revenir en arrière dans la checklist pour trouver l'étape suivante, et
l'incohérence entre « premier site » et « sites suivants » aggrave la confusion plutôt que de
l'alléger. Décision retenue : **créer un site ouvre toujours le même enchaînement guidé.**

L'objection d'origine tient toujours, mais elle est traitée autrement : chaque étape reste
explicitement échappable (« Passer cette étape » en `outline`, jamais `ghost`), donc l'utilisateur
expérimenté paie un clic, pas un enfermement.

Ce comportement **ne demande aucun mécanisme dédié** : il tombe du gate dérivé. `shouldEnterWizard`
s'appuie sur `requiredComplete`, et un site fraîchement créé a toutes ses étapes de données à
faux. Conséquence heureuse : `create-site` restant org-scopée, le parcours s'ouvre directement à
l'étape « tes mots-clés » pour un site déjà créé, au lieu de redemander une création.

Impact sur les plans : `deriveActivationFlags(orgId)` devient
`deriveActivationFlags(orgId, siteId)`, et `getActivationSnapshot()` lit le site actif.
**Tranché et livré** — branche `feature/gsc-site-authority`, tâche 9, fusionnée dans `pre-dev` le
2026-07-27. Le plan du wizard fusionné doit donc construire *sur* cette signature, pas la
réintroduire.

### Backlog — libellés non traduits du sélecteur de site

Le sélecteur affiche « Add site » en anglais juste au-dessus de « Gérer les projets » en français.
Plus largement, la reconnaissance a établi que tous les libellés de `SiteForm` et des comboboxes
sont en dur en français (« Nom », « Domaine », « Langue principale », « Pays cible »,
« Enregistrement… »), ainsi que ceux d'`AddProjectButton`, et que les messages d'erreur zod le sont
également (« Données invalides », « Pays inconnu : XX »). Comme le wizard réutilise `SiteForm` à son
étape 1, ces libellés deviennent la première chose que voit un nouvel utilisateur anglophone.

### Backlog — filtres par défaut dérivés du site actif

Sur toutes les pages `/keywords/*`, le filtre par défaut doit venir du **pays cible du site**, pas
d'un état vide ou mémorisé. Changer de site doit repositionner le filtre pays en conséquence : si
le site actif cible la France, on bascule sur `FR` immédiatement, sans que l'utilisateur ait à le
demander.

Le filtre **langue** ne doit pas subir le même traitement : un site peut cibler plusieurs pays dans
la même langue, et l'inverse. Le pays est le bon axe de défaut, la langue non.

Cela renforce l'obligation faite au pays cible à l'étape 1 du wizard : il ne conditionne pas
seulement les mots-clés associés et la ventilation GSC, il devient l'état par défaut de la lecture
de tout le module mots-clés. Conception différée.

### Backlog — repenser « Ajouter un mot-clé »

Dans le menu Importer, l'entrée « Ajouter un mot-clé — saisir un seul mot-clé manuellement » est
la moins engageante des quatre : elle demande à l'utilisateur de produire la donnée lui-même, au
moment précis où il attend que l'outil la lui apporte. La piste à explorer est de la remplacer par
une **découverte assistée** — proposer des mots-clés depuis Mangools ou SE-Ranking à partir d'une
graine ou du domaine du site.

La brique existe déjà en partie : `getRelatedKeywords` (`src/lib/mangools/related-core.ts`)
alimente le dialogue « Mots-clés associés », et `addRelatedKeywordsAction` sait les insérer. Il
manque le point d'entrée amont, quand l'utilisateur n'a encore aucun mot-clé sur lequel s'appuyer.

Conséquence pour la présente refonte : à l'étape 2 du wizard, la saisie manuelle est présentée en
dernier et sans emphase. Elle reste le filet de sécurité pour qui n'a ni GSC, ni CSV, ni
connecteur — pas un chemin recommandé. La conception du remplacement est différée.

### Backlog — chargement différé de driver.js — RÉGLÉ (2026-07-31)

`TourRunner` est monté dans le layout `(app)`, donc sur **toutes** les routes authentifiées, et il
importait `driver.js` ainsi que sa feuille de style au premier rendu. Or seules trois routes portent
un tour (`/keywords/reservoir`, `/keywords/recherche`, `/keywords/planning`) : partout ailleurs, la
librairie était chargée pour ne rien faire.

**Correctif.** La bibliothèque passe derrière `src/components/tours/driver-lazy.ts`, seul point du
dépôt à la nommer, demandé par `import()` **après** le verdict `start` — `planTour` n'a besoin
d'aucune ligne de driver.js pour le rendre. La feuille de style est dans ce module-là **et pas dans
le `.tsx`** : laissée dans le runner elle serait restée statique, et le gain se serait réduit au
seul JavaScript. Rattachée au module paresseux, elle part dans le même chunk asynchrone, dont le
bundler ne résout la promesse qu'une fois la feuille appliquée (`Promise.all([…css, …js])` dans le
stub produit) — la première bulle ne peut donc pas s'afficher nue.

**Annulation en vol.** Le chargement étant asynchrone, la fenêtre entre la demande et l'arrivée est
observable. Elle vit dans `src/lib/tours/launch.ts` (pur, `launch.test.ts`), et non dans le
composant : `run` n'ouvre rien si le démontage est déjà passé, et `abort` rend à
`sessionAfterTeardown` le verdict « parti non lu », qui compte désormais **trois** cas au lieu de
deux — bibliothèque jamais arrivée, tour encore à l'écran, tour déjà détruit par un rappel. Le
premier cas est neuf et porte la propriété la plus fragile du système : sans restitution, le double
montage du mode strict consomme le tour au premier passage et le chargement direct d'une route à
tour n'affiche plus rien.

**Mesure** (`next build`, `.next/diagnostics/route-bundle-stats.json`, empreinte `driver-popover`
cherchée dans les chunks du premier chargement) : **31 routes → 0**. −24 518 octets de JS non
compressé par route, et `driver.css` (3 041 octets) quitte la liste CSS bloquante de chacune — elle
n'est plus atteignable que par le stub asynchrone. Vérifié aussi au runtime : aucune requête
`driver` sur `/keywords/overview` ni `/projects`, les deux (CSS puis JS) sur les trois routes à
tour.

**Vérifié au navigateur** (dev, session `elevaseo99`, site ADVMFR, `tour_progress` vidée puis
restaurée) : chargement direct → tour affiché et stylé ; les trois tours déroulés en entier, avec
compteur, boutons et remontée `data-tour-lift` au bon point ; départ d'une page à tour pendant que
le chunk est encore en vol (insertion du script retardée à la main) → **rien** ne s'ouvre sur la
page suivante, seul son propre tour s'affiche, et le tour quitté est reproposé au retour ; reprise
manuelle servie une fois et jamais rejouée.

### Backlog — badge « En construction » tronqué dans la barre latérale (FR) — RÉGLÉ (2026-07-31)

> **État au 2026-07-31, branche `fix/tours-halo-and-debts`. Réglé.** La pastille textuelle des
> sous-entrées est remplacée par l'**icône ambre `ConstructionIcon`** — le traitement que ce dépôt
> donne déjà au même état sur les entrées de section — sous un `Tooltip` shadcn portant
> `Construction.badge`, doublé d'un `<span className="sr-only">` pour le nom accessible. Le libellé
> reçoit un `truncate` explicite. Détail et justification sous « Ce qui a été fait », en fin de
> section ; ce qui suit reste le diagnostic d'origine.

**Endroit exact** : `src/components/nav-main.tsx:73-76` — le badge des sous-entrées de menu, rendu
dans un `<span className="ml-auto rounded-sm bg-amber-100 px-1.5 py-0.5 text-[10px] …">` à
l'intérieur d'un `SidebarMenuSubButton`. Le libellé vient de `Construction.badge`
(`messages/fr.json`, `messages/en.json`).

En français, « En construction » est plus long que l'anglais « Under construction » ne le laisse
croire une fois posé après le titre de l'entrée : la largeur disponible dans la barre latérale ne
suffit plus et le badge est coupé. Le problème est de mise en page (largeur du conteneur et
troncature du bouton parent), pas de traduction — un libellé plus court le masquerait sans le
régler pour les autres langues du catalogue (es, de).

Repéré à la vérification navigateur du tutoriel (2026-07-31). **Hors périmètre de la branche
`feature/onboarding-tours`** : le badge n'appartient pas au tutoriel, aucun tour ne le vise, et il
est visible sur toutes les routes gatées indépendamment de cette branche.

**Ce qui a été fait (2026-07-31)**

Le diagnostic ci-dessus était juste sur la nature du défaut (gabarit) mais incomplet sur son
mécanisme, et la mesure navigateur l'a précisé. Le style de base de `SidebarMenuSubButton` ne
tronque que `[&>span:last-child]` : **la pastille lui volait ce rang**. Le libellé, privé de
troncature, passait donc à la ligne — mesuré à 40 px de haut dans une rangée `h-7` (28 px), donc
débordante et **chevauchant l'entrée suivante** — pendant que la pastille, elle, héritait du
`truncate` et affichait « En construc… » (77 px rendus pour 86 px nécessaires, rangée de 190 px).

**Parti pris : supprimer le texte de la rangée plutôt que lui disputer la largeur.** Aucune
pastille textuelle ne tient la promesse « quelle que soit la longueur du libellé » : 86 px de
pastille dans 190 px de rangée cèdent dès le prochain libellé un peu long, dans n'importe quelle
langue. Raccourcir la chaîne, réduire la police ou tronquer le libellé à la place de la pastille
déplacent le défaut au lieu de le supprimer — et tronquer un libellé de navigation est pire que
tronquer une décoration, puisque c'est par lui qu'on s'oriente. L'icône, elle, a une largeur fixe
de 14 px, indépendante du catalogue : es et de (absents de `src/i18n/config.ts`, qui ne déclare
aujourd'hui que `en` et `fr`) arriveront sans rien rouvrir.

Ce n'est pas un motif inventé : les **entrées de section** portent déjà exactement cette icône
ambre pour le même état (`nav-main.tsx`, `SidebarMenuButton`). Les sous-entrées s'y alignent, et la
lecture parent/enfant y gagne en cohérence.

Deux points d'implémentation méritent d'être retenus :

- l'icône est enveloppée dans un `<span>` — un `<svg>` enfant direct hériterait de
  `[&>svg]:text-sidebar-accent-foreground` (spécificité `0,2,1`) qui bat `text-amber-500`
  (`0,1,0`) et la repeindrait en gris ;
- le libellé reçoit un `truncate` explicite, puisqu'il n'est plus `span:last-child`.

Le texte n'est pas perdu : `Tooltip` shadcn au survol (`side="right"`, il sort de la barre et
s'affiche donc en entier) et `sr-only` pour les lecteurs d'écran — le nom accessible de l'entrée
reste « Vue d'ensemble En construction », comme avant. Aucune clé de catalogue ajoutée :
`Construction.badge` sert les deux. Le `TooltipProvider` est déjà fourni par `SidebarProvider`.

**Vérifié au navigateur** (d3k, port 3000, session `elevaseo99@gmail.com` / org `eleva seo`, bascule
de langue par le cookie `NEXT_LOCALE` — restauré ensuite) : en français les deux « Vue d'ensemble »
(BACKLINK et CONTENU) reviennent à une rangée de 28 px, libellé entier et non tronqué, icône
visible, infobulle « En construction » complète ; l'anglais est inchangé quant à la troncature
(aucune, avant comme après) et reçoit le même traitement — une divergence de forme entre langues
serait un défaut de plus ; la barre repliée (`data-collapsible=icon`, 48 px) masque les
sous-entrées comme prévu, zéro artefact ; et à 1024 px comme à 768 px de fenêtre rien ne se
retronque, la barre étant de largeur fixe.

**Note pour la suite** : le même `[&>span:last-child]:truncate` existe sur `SidebarMenuButton` et
n'atteint jamais les libellés de section (l'icône et le chevron les suivent). Sans conséquence
aujourd'hui — ce sont des libellés courts en majuscules — mais c'est le même piège.

### Backlog — halo posé sous la bande collante — REQUALIFIÉ (2026-07-31)

> **État au 2026-07-31, branche `fix/tours-halo-and-debts`.** Trois des quatre points concernés
> sont **réparés**, par un correctif d'**ordre de peinture** et non par correction de défilement.
> Il reste **un** point en dette : `planning-dispatch`. Le détail est en fin de section, sous
> « Ce qui a été fait » ; ce qui suit reste le diagnostic d'origine, toujours exact.

**Gestes de reproduction** : aller sur `/keywords/planning`, remonter en haut de la page, cliquer
« revoir ce tour », puis « Suivant ». Le point 1 (`planning-table`) fait défiler la page ; le point 2
(`planning-readiness`) en hérite. On ne voit qu'un rectangle sombre là où la bulle décrit la colonne
« On-page ». Reproduit à deux tailles de fenêtre. **Défiler à la main pendant le point 2 corrige
l'affichage** — driver.js recalcule sa découpe à chaque évènement `scroll`.

**Cause, vérifiée dans la source installée** (`node_modules/driver.js/dist/driver.js.mjs`, 1.8.0) :

- la fonction de défilement commence par `if (!e || v(e)) return` — **aucun défilement quand
  l'élément est déjà dans la fenêtre** ;
- `v(e)` n'est qu'un test de contenance de rectangle (`top >= 0 && bottom <= innerHeight`), **aveugle
  à tout élément peint par-dessus** ;
- le point 1 vise le conteneur du tableau, plus haut que la fenêtre : la bibliothèque le défile en
  `block: "start"`, ce qui **gare le haut du tableau sous la bande de pilotage collante**
  (`planning-table.tsx:272`, `sticky top-0 z-20`, fond opaque) ;
- l'en-tête visé se retrouve donc à `top >= 0` — « déjà visible » pour la bibliothèque — mais
  **derrière** la bande. La découpe de l'overlay est au bon endroit ; ce qu'elle révèle, c'est le
  fond de la bande.
- `driver.css` ne donne **aucun `z-index`** à `.driver-active-element` : l'élément mis en avant reste
  sous la bande dans l'ordre de peinture.

**Ce que la version installée n'offre pas** : la liste complète des réglages (`driver.js.d.ts`) ne
contient ni marge de défilement ni décalage de cible — `smoothScroll`, `allowScroll`, `stagePadding`,
`stageRadius`, `popoverOffset` sont les seuls voisins, et aucun ne déplace la cible (`stagePadding`
ne ferait qu'**agrandir** le rectangle sombre). Un `scroll-margin-top` en CSS est **inerte** ici :
il ne s'applique qu'à un défilement effectivement demandé, et il n'y en a pas.

**Correctif envisagé, et pourquoi il n'est pas dans cette branche** : un rappel `onHighlightStarted`
global qui, à partir du rectangle de l'ancre, corrige le défilement quand elle tombe trop haut. Deux
pièges qui font le coût :

1. **les ancres de la bande elle-même ne doivent pas être corrigées** — `keywords-import` et
   `keywords-sync` vivent DANS la bande collante ; les recentrer ferait sauter la page sans jamais
   les déplacer (elles restent épinglées), et ces deux points fonctionnent aujourd'hui. Il faut donc
   savoir reconnaître une ancre épinglée, ce qui demande de remonter les ancêtres en
   `getComputedStyle` — du câblage DOM, hors d'atteinte des tests ;
2. **le seuil** : soit une constante en pixels (qui redevient fausse en silence si la bande
   grandit), soit une fraction de la hauteur de fenêtre (qui élargit le rayon d'action à presque
   toutes les ancres hors bande).

**Le défaut touche DEUX tours, pas un.** `research-gsc-columns` (Recherche, 2ᵉ point) n'est pas
« latent » comme cette note l'a d'abord écrit : il a été **reproduit en navigateur** — halo en haut
à droite, rectangle sombre seul, masqué par la bande d'onglets collante (Tous / Shortlist / Exclus
/ Quick-wins GSC). Même cause, même remède manuel (défiler à la main pendant le point). Gestes :
`/keywords/recherche`, remonter en haut, « revoir ce tour », puis « Suivant ».

Dans les deux cas le défilement des **trois** tours change, ce qui demande une repasse navigateur
complète. Le document scrolle bien au niveau de la fenêtre (`SidebarInset` est un `<main>` sans
`overflow`), donc `window.scrollBy` est le bon levier le jour où on le fait.

Repéré à la vérification navigateur du 2026-07-31, **non traité** : défaut d'affichage sur un point
d'un tour, sans perte de donnée ni de progression, dont le correctif propre coûte plus que tout le
reste du lot et rouvre la vérification des trois tours.

#### Ce qui a été fait (2026-07-31)

La note ci-dessus tenait le manque de `z-index` pour un simple constat et en tirait un correctif par
**défilement**. C'est l'inverse qui a été retenu : **si le défaut est un ordre de peinture, le
remède en est un**. Rien n'oblige à déplacer la cible — il suffit de la peindre au-dessus de la
bande le temps du point.

**Ce qui rend la piste valide, vérifié en navigateur** (profil d3k, port 3000, org `eleva seo`,
site ADVMFR, `tour_progress` vidée puis restaurée à l'identique) :

- la bande (`sticky top-0 z-20`) et le tableau sont **frères** sous `ToolbarPanel`
  (`rounded-2xl border bg-card/30`, ni `overflow-hidden` ni `isolation`) : aucun contexte
  d'empilement ne les sépare, la comparaison de `z-index` a donc lieu ;
- un `<th>` n'étant pas positionné, `position: relative` est indispensable — **sans décalage**, donc
  sans effet sur le rectangle que driver.js découpe ;
- un fond opaque garantit que la découpe montre l'en-tête et non ce que la bande contient derrière
  lui.

**Le correctif**, en trois pièces :

1. `TourStep.underStickyBand?: true` dans `src/lib/tours/catalog.ts` — la **décision**, dans le
   catalogue, à côté des ancres. Portée par `resolveTourSteps` puis `buildDriveSteps` sous le nom
   `lift` (`src/lib/tours/plan.ts`), donc **entièrement dans le module pur**, testée cas par cas.
2. `TourRunner` pose et retire `data-tour-lift` dans les rappels `onHighlightStarted` /
   `onDeselected` de chaque point, par `toggleAttribute(ATTR, spec.lift)` — la valeur vient du
   module pur, **aucune comparaison n'est écrite dans le `.tsx`**. Le nettoyage de l'effet balaie ce
   qui resterait posé. `tour-runner.test.ts` asservit ce branchement sur la source.
3. Une règle unique dans `src/app/globals.css`, hors `@layer` :
   `[data-tour-lift] { position: relative; z-index: 30; background-color: var(--background) }`.

**Aucun seuil, aucune arithmétique, et aucun besoin de reconnaître une ancre épinglée** — les deux
pièges qui avaient fait renoncer tombent d'eux-mêmes. `keywords-import` et `keywords-sync` ne
portent simplement pas le drapeau ; même flaggées, les remonter serait sans effet puisqu'elles sont
déjà visibles.

**Trois points réparés**, observés sur des visites de première fois :

| Tour | Point | Avant | Après |
| --- | --- | --- | --- |
| Planning | 2 — `planning-readiness` | rectangle sombre | « On-page » lisible |
| Recherche | 2 — `research-gsc-columns` | rectangle sombre | « Pos. GSC » lisible |
| Réservoir | 4 — `reservoir-sources` | rectangle sombre | la pastille de source lisible |

Le troisième **n'était pas dans cette note** : il a été trouvé à la repasse des trois tours. Même
cause, même remède, même famille.

#### Ce qui reste en dette — `planning-dispatch` (3ᵉ point du tour Planning)

Le premier point du tour Planning gare aussi la **première ligne** du tableau sous la bande : au
3ᵉ point, la ligne occupe `top 40,5 → 101,5` pendant que la bande couvre `0 → 73`. La moitié haute
de la ligne — dont le terme lui-même — est masquée, sous une bulle qui dit « clique une ligne ».
Défaut réel, plus doux que les trois autres : la ligne reste reconnaissable.

**La remontée ne convient PAS ici, essayé et écarté en navigateur** : `planning-dispatch` vise un
`<tr>` **pleine largeur**. Remonté, il devient parfaitement lisible, mais son fond opaque **tranche
la barre d'outils en deux sur toute la largeur** — la recherche, « Filtres », « Importer » et
« Sync » se retrouvent coupés à mi-hauteur. On échange un masquage contre un autre, plus laid, et on
viole la règle « l'élément remonté ne doit pas déborder de son tableau ». Le drapeau est donc
volontairement laissé à faux, et le fait est écrit dans `catalog.ts` pour que personne ne le
« corrige » sans revoir le rendu.

Ce point-là relève bien du **correctif par défilement** décrit plus haut, avec ses deux pièges
intacts. Il reste en dette, seul.

---

## 13. Risques et points ouverts

**R1 — `/content` est gaté en production.** `UNDER_CONSTRUCTION_PREFIXES` inclut `/content`
(`src/lib/feature-status/index.ts:11-14`) et le blocage est actif dès `NODE_ENV=production` sauf si
`SHOW_WIP_FEATURES=1`, variable définie nulle part dans le déploiement. Conséquence : la page
Dispatch fonctionne et crée bien un `content_items`, mais l'utilisateur ne peut pas consulter
`/content/planning` en production — il produirait un plan invisible.

C'est la raison de la décision D7 : le wizard atterrit sur `/keywords/planning` et l'aha livrable
est le brief de Dispatch. Si l'ouverture de `/content` est décidée par ailleurs, l'étape 4 pourra
pointer plus loin sans changer le modèle.

**R2 — la dérivation de `first-brief` dépend d'une table dont l'UI est gatée.** `content_items` est
écrite depuis Dispatch, qui n'est pas gaté. La dérivation est donc valide en production. À
re-vérifier si le gate évolue.

**R3 — coût de la dérivation.** On passe de 5 à 7 `EXISTS` par calcul. Le déplacement vers le RSC
réduit néanmoins le volume total (un calcul par rendu de sidebar au lieu d'un par navigation *et*
par focus de fenêtre). À mesurer avec le timing `[query-timing]` déjà en place.

**R4 — driver.js et les portails Radix.** Le projet a déjà rencontré des conflits entre
bibliothèques de positionnement et les dialogues modaux Radix (`body { pointer-events: none }`).
Les tours ne doivent pas cibler d'éléments situés à l'intérieur d'un dialogue modal en v1.

---

## 14. Tests

- **Dérivation** : un test par étape, sur la base de test, couvrant l'état vide, l'état partiel et
  l'état complet. C'est le cœur du modèle — si la dérivation est juste, l'incohérence
  « terminé mais vide » ne peut plus exister.
- **Gate du wizard** : un utilisateur aux étapes requises complètes ne doit jamais y être renvoyé ;
  un utilisateur ayant différé ne doit pas y être renvoyé à chaque navigation.
- **Rôles** : un membre sans droit de création voit l'écran d'explication, jamais l'étape 1.
- **Préconditions de tour** : un tour dont la précondition est fausse ne démarre pas et
  n'enregistre rien.
- **i18n** : la parité en/fr est déjà couverte par `catalogs.test.ts`.
- **Vérification visuelle d3k** sur le parcours complet avec un compte neuf.
