# Tutoriel interactif — mini-tours par section — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Expliquer chaque grande section de l'application par un mini-tour de trois à cinq points, déclenché à la première visite — et jamais sur un écran vide.

**Architecture:** Les tours sont de la donnée : un identifiant, une route, une précondition de contenu, une liste de points ancrés sur des attributs `data-tour`. Un composant client unique lit la route courante, décide s'il doit démarrer, et délègue le rendu à driver.js. La progression vit dans une table dédiée, par utilisateur.

**Tech Stack:** Next 16 App Router, React 19, driver.js, Drizzle/Postgres, next-intl, bun.

**Branche :** `feature/onboarding-tours`, créée depuis **`pre-dev`** — c'est là que la fusion du parcours et l'import Mangools ont été intégrés, `dev` ne les a pas encore.

**Spec de référence :** `docs/superpowers/specs/2026-07-25-onboarding-wizard-tutorial-design.md`, section 8.

## Global Constraints

- **Package manager : bun**, et toute nouvelle dépendance s'installe derrière Socket Firewall : `sfw bun add driver.js`.
- **Aucun import direct de `radix-ui`.** driver.js gère son propre overlay ; ne pas le combiner avec un composant de `src/components/ui/` positionné.
- **La règle non négociable du système : un tour ne démarre jamais sur un écran vide.** Toute précondition qui n'est pas remplie reporte le tour sans rien enregistrer.
- **Ancrage par `data-tour="<nom>"` uniquement.** Jamais de sélecteur CSS ni de classe utilitaire : un refactor de style ne doit pas casser le tutoriel en silence.
- **i18n : `messages/en.json` ET `messages/fr.json`**, namespace `Tour`, arborescence `Tour.<tourId>.<stepKey>.title|body`.
- **Tests DB** : `describe.skipIf(!RUN_DB)`, `mock.module("server-only", () => ({}))`, imports dynamiques dans `beforeEach`, `assertTestDb()` avant tout `delete`, pas de `db.end()`.
- **Ne jamais cibler un élément situé dans une `Dialog` modale** en v1 : le repo a déjà rencontré des conflits entre overlays et `body { pointer-events: none }`.
- **Ne jamais créer de tour sur `/content/*` ni `/backlink/overview`** : ces routes rendent l'écran « En construction » en production.

---

### Task 1 : Persistance de la progression

**Files:**
- Modify: `src/lib/db/domain-schema.ts`
- Create: `drizzle/<numéro>_tour_progress.sql` (généré)
- Create: `src/lib/tours/progress.ts`
- Create: `src/lib/tours/progress.test.ts`
- Create: `src/lib/tours/actions.ts`
- Create: `src/lib/tours/actions.test.ts`

**Interfaces:**
- Produces: `tourProgress` (table Drizzle), `getTourProgress(userId): Promise<Record<string, TourStatus>>`, `setTourProgress(userId, tourId, status): Promise<void>`, `markTourAction(tourId: string, status: TourStatus): Promise<void>`.

- [ ] **Step 1: Déclarer la table**

Dans `src/lib/db/domain-schema.ts`, à côté de `activationDismissals` :

```ts
/**
 * Progression du tutoriel, PAR UTILISATEUR (pas par organisation) : un tour
 * explique l'interface, pas les données d'une org. Changer d'organisation ne
 * doit pas rejouer les mêmes tours.
 */
export const tourProgress = pgTable(
  "tour_progress",
  {
    userId: text("user_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    tourId: text("tour_id").notNull(),
    status: text("status").$type<"completed" | "dismissed">().notNull(),
    updatedAt: timestamp("updated_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
  },
  (t) => [primaryKey({ columns: [t.userId, t.tourId] })],
);
```

- [ ] **Step 2: Générer et appliquer la migration**

```bash
bun run db:generate
bun run db:migrate:test
```

Relire le SQL généré avant de l'appliquer ailleurs. Ne pas lancer `db:migrate` sur la base de dev pendant qu'un `next dev` tourne sans re-tester ensuite : le pool `postgres.js` peut produire un burst transitoire « column does not exist » qui n'est pas un vrai échec.

- [ ] **Step 3: Écrire le test de persistance**

Créer `src/lib/tours/progress.test.ts` :

```ts
import { beforeEach, describe, expect, it, mock } from "bun:test";

mock.module("server-only", () => ({}));

const RUN_DB = process.env.DB_TESTS === "1";

describe.skipIf(!RUN_DB)("tour progress (postgres)", () => {
  const USER = "tour-prog-user-1";

  // biome-ignore lint/suspicious/noExplicitAny: imports dynamiques
  let db: any;
  // biome-ignore lint/suspicious/noExplicitAny: imports dynamiques
  let authSchema: any;
  let getTourProgress: (userId: string) => Promise<Record<string, string>>;
  let setTourProgress: (
    userId: string,
    tourId: string,
    status: "completed" | "dismissed",
  ) => Promise<void>;

  beforeEach(async () => {
    ({ db } = await import("@/lib/db/client"));
    authSchema = await import("@/lib/db/auth-schema");
    const { assertTestDb } = await import("@/lib/db/assert-test-db");
    await assertTestDb();
    ({ getTourProgress, setTourProgress } = await import("./progress"));

    const { eq } = await import("drizzle-orm");
    await db.delete(authSchema.user).where(eq(authSchema.user.id, USER));
    await db.insert(authSchema.user).values({
      id: USER,
      name: "Tour User",
      email: "tour-prog@example.test",
      emailVerified: true,
      createdAt: new Date(),
      updatedAt: new Date(),
    });
  });

  it("retourne un objet vide quand rien n'a été enregistré", async () => {
    expect(await getTourProgress(USER)).toEqual({});
  });

  it("enregistre puis relit un tour terminé", async () => {
    await setTourProgress(USER, "reservoir", "completed");
    expect(await getTourProgress(USER)).toEqual({ reservoir: "completed" });
  });

  it("écrase le statut d'un même tour au lieu d'insérer une seconde ligne", async () => {
    await setTourProgress(USER, "reservoir", "dismissed");
    await setTourProgress(USER, "reservoir", "completed");
    expect(await getTourProgress(USER)).toEqual({ reservoir: "completed" });
  });
});
```

- [ ] **Step 4: Lancer le test pour vérifier qu'il échoue**

Run: `bun run test src/lib/tours/progress.test.ts`
Expected: FAIL — `Cannot find module './progress'`.

- [ ] **Step 5: Écrire le module**

Créer `src/lib/tours/progress.ts` :

```ts
import "server-only";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { tourProgress } from "@/lib/db/domain-schema";

export type TourStatus = "completed" | "dismissed";

export async function getTourProgress(
  userId: string,
): Promise<Record<string, TourStatus>> {
  const rows = await db
    .select({ tourId: tourProgress.tourId, status: tourProgress.status })
    .from(tourProgress)
    .where(eq(tourProgress.userId, userId));

  return Object.fromEntries(rows.map((r) => [r.tourId, r.status]));
}

export async function setTourProgress(
  userId: string,
  tourId: string,
  status: TourStatus,
): Promise<void> {
  await db
    .insert(tourProgress)
    .values({ userId, tourId, status })
    .onConflictDoUpdate({
      target: [tourProgress.userId, tourProgress.tourId],
      set: { status, updatedAt: new Date() },
    });
}
```

- [ ] **Step 6: Lancer le test pour vérifier qu'il passe**

Run: `bun run test src/lib/tours/progress.test.ts`
Expected: PASS — 3 tests.

- [ ] **Step 7: Exposer la seule action nécessaire**

Créer `src/lib/tours/actions.ts` :

```ts
"use server";

import { getActiveUserId } from "@/lib/active-org";
import { setTourProgress, type TourStatus } from "./progress";

export async function markTourAction(
  tourId: string,
  status: TourStatus,
): Promise<void> {
  const userId = await getActiveUserId();
  await setTourProgress(userId, tourId, status);
}
```

Créer `src/lib/tours/actions.test.ts` avec la garde de surface d'endpoint :

```ts
import { describe, expect, it, mock } from "bun:test";

mock.module("server-only", () => ({}));

describe("surface de src/lib/tours/actions", () => {
  it("n'expose que markTourAction", async () => {
    const mod = await import("./actions");
    expect(Object.keys(mod).sort()).toEqual(["markTourAction"]);
  });
});
```

- [ ] **Step 8: Vérifier et commiter**

Run: `bun run test src/lib/tours && bunx --bun tsc --noEmit`
Expected: PASS.

```bash
git add src/lib/db/domain-schema.ts drizzle/ src/lib/tours/
git commit -m "feat(tours): persistance de la progression par utilisateur

Table tour_progress (userId, tourId) : ni onboardingCompletedAt ni
activation_dismissals ne savent memoriser un etat par tour."
```

---

### Task 2 : Catalogue de tours et règle de déclenchement

Le cœur du système, entièrement pur et donc entièrement testable.

**Files:**
- Create: `src/lib/tours/catalog.ts`
- Create: `src/lib/tours/should-start.ts`
- Create: `src/lib/tours/should-start.test.ts`

**Interfaces:**
- Consumes: `TourStatus` (Task 1).
- Produces: `TOURS`, `TourId`, `TourDef`, `tourForRoute(pathname)`, `shouldStartTour(input): boolean`.

- [ ] **Step 1: Écrire le test de la règle**

Créer `src/lib/tours/should-start.test.ts` :

```ts
import { describe, expect, it } from "bun:test";
import { shouldStartTour } from "./should-start";

const base = {
  status: undefined as "completed" | "dismissed" | undefined,
  hasContent: true,
  anotherTourRunning: false,
};

describe("shouldStartTour", () => {
  it("démarre à la première visite d'une page qui a du contenu", () => {
    expect(shouldStartTour(base)).toBe(true);
  });

  it("ne démarre jamais sur un écran vide", () => {
    expect(shouldStartTour({ ...base, hasContent: false })).toBe(false);
  });

  it("ne redémarre pas un tour déjà terminé", () => {
    expect(shouldStartTour({ ...base, status: "completed" })).toBe(false);
  });

  it("ne redémarre pas un tour rejeté", () => {
    expect(shouldStartTour({ ...base, status: "dismissed" })).toBe(false);
  });

  it("n'en démarre pas un second par-dessus un tour en cours", () => {
    expect(shouldStartTour({ ...base, anotherTourRunning: true })).toBe(false);
  });

  it("l'écran vide l'emporte sur tout le reste", () => {
    expect(
      shouldStartTour({ status: undefined, hasContent: false, anotherTourRunning: false }),
    ).toBe(false);
  });
});
```

- [ ] **Step 2: Lancer le test pour vérifier qu'il échoue**

Run: `bun run test src/lib/tours/should-start.test.ts`
Expected: FAIL — `Cannot find module './should-start'`.

- [ ] **Step 3: Écrire la règle**

Créer `src/lib/tours/should-start.ts` :

```ts
import type { TourStatus } from "./progress";

export type ShouldStartInput = {
  /** Statut enregistré pour ce tour, `undefined` si jamais vu. */
  status: TourStatus | undefined;
  /**
   * La page a-t-elle quelque chose à montrer ?
   * C'est la règle qui gouverne tout le système : on n'explique pas le
   * Planning en pointant un tableau à zéro ligne.
   */
  hasContent: boolean;
  anotherTourRunning: boolean;
};

export function shouldStartTour(input: ShouldStartInput): boolean {
  if (!input.hasContent) return false;
  if (input.anotherTourRunning) return false;
  return input.status === undefined;
}
```

- [ ] **Step 4: Lancer le test pour vérifier qu'il passe**

Run: `bun run test src/lib/tours/should-start.test.ts`
Expected: PASS — 6 tests.

- [ ] **Step 5: Écrire le catalogue**

Créer `src/lib/tours/catalog.ts` :

```ts
/**
 * Déclaration des tours. Chaque point s'ancre sur un attribut
 * `data-tour="<anchor>"` posé sur l'élément cible — jamais sur un sélecteur
 * CSS, qu'un refactor de style casserait en silence.
 *
 * Interdits en v1 : /content/* et /backlink/overview (écran « En construction »
 * en production), et tout élément situé dans une Dialog modale.
 */

export const TOUR_IDS = ["reservoir", "recherche", "planning"] as const;
export type TourId = (typeof TOUR_IDS)[number];

export type TourStep = {
  /** Valeur de l'attribut data-tour de l'élément à mettre en avant. */
  anchor: string;
  /** Suffixe de clé i18n sous Tour.<tourId>.<key>. */
  key: string;
};

export type TourDef = {
  id: TourId;
  /** Route exacte de rattachement. */
  route: string;
  steps: readonly TourStep[];
};

export const TOURS: readonly TourDef[] = [
  {
    id: "reservoir",
    route: "/keywords/reservoir",
    steps: [
      { anchor: "reservoir-table", key: "whatIsIt" },
      { anchor: "keywords-import", key: "import" },
      { anchor: "keywords-sync", key: "sync" },
      { anchor: "reservoir-sources", key: "sources" },
    ],
  },
  {
    id: "recherche",
    route: "/keywords/recherche",
    steps: [
      { anchor: "research-table", key: "whatIsIt" },
      { anchor: "research-gsc-columns", key: "gscColumns" },
      { anchor: "research-matrix", key: "targeting" },
    ],
  },
  {
    id: "planning",
    route: "/keywords/planning",
    steps: [
      { anchor: "planning-table", key: "whatIsIt" },
      { anchor: "planning-readiness", key: "readiness" },
      { anchor: "planning-dispatch", key: "dispatch" },
    ],
  },
];

export function tourForRoute(pathname: string): TourDef | null {
  return TOURS.find((t) => t.route === pathname) ?? null;
}
```

- [ ] **Step 6: Vérifier et commiter**

Run: `bun run test src/lib/tours && bunx --bun tsc --noEmit`
Expected: PASS.

```bash
git add src/lib/tours/catalog.ts src/lib/tours/should-start.ts src/lib/tours/should-start.test.ts
git commit -m "feat(tours): catalogue declaratif et regle de declenchement

Un tour ne demarre jamais sur un ecran vide : la precondition de contenu
prime sur toutes les autres conditions."
```

---

### Task 3 : Le moteur de rendu

**Files:**
- Modify: `package.json` (ajout de `driver.js`)
- Create: `src/components/tours/tour-runner.tsx`
- Create: `src/components/tours/tour-replay-button.tsx`
- Modify: `src/app/(app)/layout.tsx`

**Interfaces:**
- Consumes: `tourForRoute`, `shouldStartTour`, `markTourAction`, `getTourProgress`.
- Produces: `TourRunner({ progress })`, `TourReplayButton()`.

> **PRÉREQUIS DUR — migrer la base de développement avant l'étape 4.**
> La tâche 1 n'a migré que la base de test, à raison. Mais l'étape 4 ci-dessous place
> `getTourProgress()` dans `src/app/(app)/layout.tsx`, c'est-à-dire le layout racine de
> **toute** l'application authentifiée : sur une base de dev sans la table `tour_progress`,
> chaque page répond 500 (`relation "tour_progress" does not exist`) — et la vérification
> visuelle de la tâche 4 devient impossible à mener.
> Lancer `bun run db:migrate` **avant** de monter le runner. Si un `next dev` tourne,
> le pool `postgres.js` peut produire un burst transitoire « does not exist » qui n'est
> pas un vrai échec : re-tester avant de diagnostiquer.

- [ ] **Step 1: Installer driver.js**

```bash
sfw bun add driver.js
```

Vérifier la version installée et la présence du CSS :

```bash
bunx --bun rg -n '"driver.js"' package.json
ls node_modules/driver.js/dist/
```

- [ ] **Step 2: Écrire le runner**

Créer `src/components/tours/tour-runner.tsx` :

```tsx
"use client";

import "driver.js/dist/driver.css";
import { driver } from "driver.js";
import { useTranslations } from "next-intl";
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
import { markTourAction } from "@/lib/tours/actions";
import { tourForRoute } from "@/lib/tours/catalog";
import type { TourStatus } from "@/lib/tours/progress";
import { shouldStartTour } from "@/lib/tours/should-start";

export function TourRunner({
  progress,
}: {
  progress: Record<string, TourStatus>;
}) {
  const pathname = usePathname();
  const t = useTranslations("Tour");
  const runningRef = useRef(false);
  // Un tour par montage de route : évite de relancer sur un re-render.
  const startedRef = useRef<string | null>(null);

  useEffect(() => {
    const tour = tourForRoute(pathname);
    if (!tour) return;
    if (startedRef.current === tour.id) return;

    // Précondition de contenu : l'ancre du premier point doit exister ET la
    // page doit se déclarer non vide via data-tour-has-content.
    const first = document.querySelector(`[data-tour="${tour.steps[0]?.anchor}"]`);
    const hasContent =
      first != null &&
      document.querySelector('[data-tour-has-content="true"]') != null;

    if (
      !shouldStartTour({
        status: progress[tour.id],
        hasContent,
        anotherTourRunning: runningRef.current,
      })
    ) {
      return;
    }

    const steps = tour.steps
      .filter((s) => document.querySelector(`[data-tour="${s.anchor}"]`))
      .map((s) => ({
        element: `[data-tour="${s.anchor}"]`,
        popover: {
          title: t(`${tour.id}.${s.key}.title`),
          description: t(`${tour.id}.${s.key}.body`),
        },
      }));

    if (steps.length === 0) return;

    startedRef.current = tour.id;
    runningRef.current = true;

    const d = driver({
      showProgress: true,
      allowClose: true,
      steps,
      onDestroyed: () => {
        runningRef.current = false;
      },
    });

    d.drive();

    return () => {
      // Nettoyage à la navigation : ne rien enregistrer, le tour sera reproposé.
      if (d.isActive()) d.destroy();
      runningRef.current = false;
    };
  }, [pathname, progress, t]);

  return null;
}
```

Le statut n'est enregistré que sur une fin ou un rejet explicites — voir l'étape suivante. Quitter la page ne consomme pas le tour.

- [ ] **Step 3: Enregistrer les issues explicites**

Compléter la configuration `driver()` avec les deux rappels qui écrivent la progression :

```ts
      onCloseClick: () => {
        void markTourAction(tour.id, "dismissed");
        d.destroy();
      },
      onNextClick: () => {
        if (d.isLastStep()) {
          void markTourAction(tour.id, "completed");
          d.destroy();
          return;
        }
        d.moveNext();
      },
```

Vérifier les noms exacts des rappels contre la version installée avant d'écrire :

```bash
bunx --bun rg -n "onCloseClick|onNextClick|isLastStep|moveNext" node_modules/driver.js/dist/driver.d.ts
```

Adapter si l'API diffère — c'est le seul point du plan dépendant d'une bibliothèque tierce.

- [ ] **Step 4: Monter le runner**

Dans `src/app/(app)/layout.tsx`, lire la progression côté serveur et la passer au runner. Placer le montage **après** la chaîne de modales, en respectant sa précédence : un tour ne doit jamais s'ouvrir par-dessus `UsernamePromptDialog`, qui n'est pas fermable.

```tsx
  const tourProgressMap = needsUsername ? {} : await getTourProgress(session.user.id);
```

```tsx
      {needsUsername ? null : <TourRunner progress={tourProgressMap} />}
```

- [ ] **Step 5: Bouton de reprise**

La relance passe par un évènement de fenêtre plutôt que par un état partagé : le
bouton vit dans l'en-tête des pages, le runner dans le layout, et les deux n'ont
pas d'ancêtre commun autre que le layout lui-même.

Créer `src/components/tours/tour-replay-button.tsx` :

```tsx
"use client";

import { CircleQuestionMarkIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { usePathname } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { tourForRoute } from "@/lib/tours/catalog";

export const TOUR_REPLAY_EVENT = "seo-insight:tour-replay";

export function TourReplayButton() {
  const pathname = usePathname();
  const t = useTranslations("Tour");
  const tour = tourForRoute(pathname);
  if (!tour) return null;

  return (
    <Tooltip>
      <TooltipTrigger
        render={
          <Button
            variant="ghost"
            size="icon-sm"
            aria-label={t("replay")}
            onClick={() => {
              window.dispatchEvent(
                new CustomEvent(TOUR_REPLAY_EVENT, { detail: tour.id }),
              );
            }}
          >
            <CircleQuestionMarkIcon />
          </Button>
        }
      />
      <TooltipContent>{t("replay")}</TooltipContent>
    </Tooltip>
  );
}
```

Vérifier la forme d'appel de `TooltipTrigger` contre le composant du repo avant
d'écrire — selon la version de shadcn installée il attend `render` ou `asChild` :

```bash
bunx --bun rg -n "TooltipTrigger" src/components/ui/tooltip.tsx src/components --glob '!ui/*' -m 3
```

Dans `src/components/tours/tour-runner.tsx`, ajouter un second effet qui écoute
l'évènement et relance le tour en ignorant la progression enregistrée :

```tsx
  useEffect(() => {
    function onReplay() {
      startedRef.current = null;
      setReplayNonce((n) => n + 1);
    }
    window.addEventListener(TOUR_REPLAY_EVENT, onReplay);
    return () => window.removeEventListener(TOUR_REPLAY_EVENT, onReplay);
  }, []);
```

Déclarer `const [replayNonce, setReplayNonce] = useState(0);` et ajouter
`replayNonce` aux dépendances de l'effet principal. Dans ce même effet, remplacer
`status: progress[tour.id]` par :

```tsx
        status: replayNonce > 0 ? undefined : progress[tour.id],
```

de sorte qu'une relance explicite passe outre le statut enregistré, sans pour
autant l'effacer en base.

Ajouter la clé `Tour.replay` dans les deux catalogues : `"Replay this tour"` /
`"Revoir ce tour"`. Monter le bouton dans l'en-tête des trois pages du catalogue.

- [ ] **Step 6: Vérifier et commiter**

Run: `bunx --bun tsc --noEmit && bun run build`
Expected: aucune erreur.

```bash
git add package.json bun.lock src/components/tours "src/app/(app)/layout.tsx"
git commit -m "feat(tours): moteur de rendu driver.js

Le tour ne demarre que si l'ancre du premier point existe et que la page
se declare non vide. Quitter la page ne consomme pas le tour."
```

---

### Task 4 : Ancres, contenus et vérification

**Files:**
- Modify: `src/components/keywords/reservoir.tsx`, `keywords-toolbar.tsx`, `sync-menu.tsx`
- Modify: `src/components/keywords/research/research-container.tsx`, `research/coverage-matrix.tsx`
- Modify: `src/components/keywords/planning/planning-table.tsx`
- Modify: `messages/en.json`, `messages/fr.json`

> **Chemins vérifiés le 2026-07-29** — les composants Recherche et Planning vivent
> désormais dans des sous-dossiers (`research/`, `planning/`). Le plan d'origine les
> cherchait à plat. Voisins utiles pour les ancres : `planning/readiness-dot.tsx`
> (indicateurs de préparation) et `research/targeting-bar.tsx` (ciblage par pays).

- [ ] **Step 1: Poser les ancres**

Ajouter l'attribut `data-tour` sur chacun des dix éléments déclarés au catalogue, et `data-tour-has-content="true"` sur le conteneur de chaque table **uniquement quand elle a au moins une ligne** :

```tsx
<div data-tour="reservoir-table" data-tour-has-content={rows.length > 0 ? "true" : undefined}>
```

Vérifier la couverture :

```bash
bunx --bun rg -n 'data-tour="' src/components/keywords/ | wc -l
```

Attendu : dix ancres.

- [ ] **Step 2: Rédiger les contenus**

Ajouter le namespace `Tour` dans `messages/en.json` et `messages/fr.json`, avec pour chaque tour une entrée par `key` du catalogue, chacune portant `title` et `body`. Version française :

```json
  "Tour": {
    "reservoir": {
      "whatIsIt": {
        "title": "Ton réservoir de mots-clés",
        "body": "Tout ce que tu importes atterrit ici. C'est un espace de travail de recherche, pas un suivi de positions : rien n'est encore engagé."
      },
      "import": {
        "title": "Faire entrer des mots-clés",
        "body": "Search Console, un export CSV de Mangools, Semrush, SE Ranking ou Ahrefs, ou une saisie à la main. La source est détectée automatiquement."
      },
      "sync": {
        "title": "Enrichir et rafraîchir",
        "body": "Récupère volume, difficulté et CPC. La pastille orange signale une source qui n'a pas été synchronisée depuis plus de sept jours."
      },
      "sources": {
        "title": "D'où vient chaque donnée",
        "body": "Chaque pastille indique la source d'une métrique. Survole-la pour voir la valeur exacte et sa date."
      }
    },
    "recherche": {
      "whatIsIt": {
        "title": "L'étape de tri",
        "body": "Ici tu ne vois que les mots-clés que tu as versés à la recherche. C'est là qu'on décide ce qui mérite du travail."
      },
      "gscColumns": {
        "title": "Tes vraies positions",
        "body": "Ces colonnes viennent de ta Search Console. Elles montrent où tu te situes déjà, et donc les gains rapides."
      },
      "targeting": {
        "title": "Cibler un pays",
        "body": "Passe une cellule en « planifié » pour envoyer ce mot-clé dans ton plan de contenu pour ce pays."
      }
    },
    "planning": {
      "whatIsIt": {
        "title": "Ton plan de contenu",
        "body": "Chaque ligne est un mot-clé planifié pour un pays. C'est ta file de production."
      },
      "readiness": {
        "title": "Ce qui manque avant de rédiger",
        "body": "Ces indicateurs disent si la SERP, l'analyse on-page et le brief sont prêts. Les trois sont nécessaires pour lancer la production."
      },
      "dispatch": {
        "title": "Passer à la rédaction",
        "body": "Ouvre le détail d'une ligne pour consulter l'analyse et générer le brief à transmettre."
      }
    }
  }
```

Rédiger la version anglaise avec la même arborescence.

- [ ] **Step 3: Vérifier la parité i18n**

Run: `bun run test src/i18n`
Expected: PASS.

- [ ] **Step 4: Vérification visuelle**

Avec d3k, sur un compte disposant de données réelles :

1. Visiter `/keywords/reservoir` → le tour démarre, quatre points, la fermeture l'enregistre.
2. Recharger → le tour ne redémarre pas.
3. Visiter `/keywords/planning` sur un site **sans** ligne planifiée → aucun tour ne démarre. C'est la vérification la plus importante du plan.
4. Cliquer le bouton de reprise → le tour redémarre.

- [ ] **Step 5: Commit**

```bash
git add src/components/keywords messages/en.json messages/fr.json
git commit -m "feat(tours): ancres data-tour et contenus des trois tours

Reservoir, Recherche et Planning. Le conteneur de chaque table ne se
declare non vide qu'a partir d'une ligne, ce qui empeche le tour de
demarrer sur un ecran vide."
```

---

## Fin de branche

- [ ] `bun run test:all` vert.
- [ ] `bun run lint`, `bunx --bun tsc --noEmit`, `bun run build` sans erreur.
- [ ] Les quatre vérifications visuelles de la Task 4 passent, en particulier la troisième.
- [ ] Revue de `git diff dev..HEAD`.
- [ ] Merge ff-only dans `dev`, push, suppression de la branche. **Ne pas toucher à `main`.**
