# TOURS.md — Visites guidées (TourGuide.js)

**Règle du projet : toute nouvelle fonctionnalité visible à l'écran livre sa visite guidée dans la même branche que la fonctionnalité.**

Pas dans une branche « tutoriels » plus tard. Une visite écrite six semaines après l'écran est écrite par quelqu'un qui a oublié pourquoi le bouton est là — et c'est précisément ce que la visite doit expliquer.

Bibliothèque : [`@sjmc11/tourguidejs`](https://tourguidejs.com/docs/) v0.0.27.

---

## 1. Le modèle en une phrase

Le JSX porte une **ancre** (`data-tour="..."`), le fichier de langue porte le **texte**, le fichier `.tour.ts` porte **l'ordre et les droits**. Les trois sont reliés par convention, et deux tests les empêchent de diverger.

```
resources/js/lib/tour/
  types.ts              types (ne pas modifier sans raison)
  registry.ts           logique PURE : droits, ancres manquantes, tri
  registry.test.ts      tests de cette logique
  use-tour.tsx          le hook React (instancie TourGuide)
  tours/
    index.ts            LE registre — toute visite y est déclarée
    dashboard.tour.ts   un fichier par visite
    reservations.tour.ts
resources/js/components/tour/tour-launcher.tsx   bouton « ? » de l'en-tête
lang/fr/tours.php  /  lang/en/tours.php          tous les textes
```

---

## 2. Ajouter une visite à une nouvelle fonctionnalité — les 5 gestes

### Geste 1 — Poser les ancres dans le JSX

Sur chaque élément que la visite doit surligner :

```tsx
<Card data-tour="depenses-filters">
<Button data-tour="depenses-create">
<TabsList data-tour="depenses-tabs">
```

Règles :

- **Un nom d'ancre = `<visite>-<élément>`**, en minuscules-tirets. Il est unique dans toute la visite.
- **Viser l'élément le plus PETIT qui porte le sens.** Surligner un conteneur pleine page ne montre rien : tout est « en évidence », donc rien ne l'est.
- Les composants `shadcn/ui` (`Button`, `Card`, `SelectTrigger`, `TabsList`…) relaient les attributs inconnus jusqu'au DOM : `data-tour` s'y pose directement. Un composant maison qui ne répand pas ses props demande un `<div data-tour="...">` autour.

### Geste 2 — Écrire les textes dans les DEUX langues

Dans `lang/fr/tours.php` **et** `lang/en/tours.php` :

```php
'depenses.filters.title'   => 'Filtrer les dépenses',
'depenses.filters.content' => 'Les critères se combinent, et la liste se met à jour toute seule.',
```

Règles :

- **Les clés sont PLATES et pointées.** `'depenses.filters.title' => '...'`, jamais un tableau imbriqué. Le prop Inertia `translations` est typé `Record<string, Record<string, string>>` : une clé imbriquée arriverait comme un objet et le dialogue afficherait `[object Object]`. Un test le vérifie.
- **FR et EN portent exactement les mêmes clés.** Un test le vérifie.
- Le `content` accepte du HTML simple (`<strong>`, `<br>`, `<em>`).
- **Dire ce que l'écran ne dit pas déjà.** « Ceci est le bouton Ajouter » ne sert personne. « Les dates sont contrôlées contre le calendrier et bloquées sur vos canaux dès l'enregistrement » sert. Une étape qui paraphrase son propre libellé est une étape à supprimer.
- **⚠️ Ne JAMAIS nommer nos fournisseurs ni nos partenaires techniques.** La visite guidée est lue par les franchisés et par toute personne à qui l'on montre l'écran : c'est de la documentation publique. On décrit **l'effet métier** (« bloquées sur vos canaux de distribution », « après une synchronisation des canaux »), jamais le nom du prestataire qui l'exécute, ni un nom de table, de service ou d'API interne.

### Geste 3 — Déclarer la visite

`resources/js/lib/tour/tours/depenses.tour.ts` :

```ts
import type { AppTour } from '../types';

export const depensesTour: AppTour = {
    id: 'depenses',                     // = préfixe des clés de langue + clé localStorage
    components: ['depenses/index'],     // nom EXACT passé à Inertia::render()
    steps: [
        { anchor: 'depenses-filters', key: 'filters', order: 10 },
        { anchor: 'depenses-create',  key: 'create',  order: 20, permission: 'depense.create' },
        { anchor: 'depenses-table',   key: 'table',   order: 30 },
    ],
};
```

Puis l'ajouter au registre, `tours/index.ts` :

```ts
export const TOURS: AppTour[] = [dashboardTour, reservationsTour, depensesTour];
```

Champs d'une étape :

| Champ | Effet |
|---|---|
| `anchor` | valeur du `data-tour` visé |
| `key` | racine des clés de langue : `<id>.<key>.title` / `.content` |
| `order` | ordre croissant, **unique dans la visite** (un test le vérifie) |
| `permission` | l'étape disparaît si l'utilisateur n'a pas la permission (un admin passe outre) |
| `adminOnly` | l'étape n'est montrée qu'au siège |
| `optional` | l'ancre a le droit d'être absente — aucun avertissement |
| `fixed` | l'élément est en `position: fixed` |
| `openOnEnter` | l'ancre est un déclencheur de menu / popover : on l'ouvre en arrivant sur l'étape, on le referme en la quittant |

**`openOnEnter` — quand une étape décrit un contenu replié.** Une étape qui parle des entrées d'un menu fermé décrit quelque chose que l'utilisateur ne voit pas. Poser le drapeau ouvre le menu pendant l'étape :

```ts
{ anchor: 'dashboard-card-menu', key: 'cardMenu', order: 70, openOnEnter: true, optional: true },
```

L'ancre doit alors être le **déclencheur lui-même** (`DropdownMenuTrigger`, `PopoverTrigger`…) : l'ouverture se fait en le cliquant et l'état se lit sur son `data-state`, la convention Radix. Pendant la visite le contenu déployé est **visible mais non cliquable** — la feuille de style de TourGuide neutralise tout ce qui est hors du dialogue, et c'est voulu : une étape ne doit pas pouvoir emmener l'utilisateur ailleurs. La fermeture est assurée sur les quatre sorties possibles (étape suivante, précédente, croix/Échap, « Terminer »).

Au niveau de la visite : `autoStart: true` la lance d'elle-même à la première venue (une seule fois, mémorisé dans le localStorage). **À réserver aux écrans d'entrée.** Une visite qui s'impose est une visite qu'on ferme sans lire.

### Geste 4 — `optional`, et quand le mettre

Une ancre absente ne casse jamais la visite : l'étape est retirée. Mais si elle n'est **pas** `optional`, un avertissement part en console — c'est le filet qui attrape les `data-tour` renommés dans le JSX et oubliés dans le `.tour.ts`.

Mettre `optional: true` **uniquement** quand l'absence est un état NORMAL :

- élément derrière un droit (`adminOnly`, `permission`) ;
- élément d'un onglet non ouvert ;
- élément d'une liste qui peut être vide (« aucune réservation aujourd'hui »).

Ne PAS le mettre « au cas où » : c'est exactement ce qui laisse une visite se vider en silence, refonte après refonte.

### Geste 5 — Lancer les tests

```bash
npx vitest run resources/js/lib/tour/registry.test.ts
```

```bash
php artisan test --filter=Tour
```

Le second **échoue en nommant les clés manquantes** si une étape déclarée n'a pas ses deux textes dans les deux langues. C'est voulu : sans lui, l'oubli ne se verrait qu'en production, où le helper `t()` affiche la clé brute — l'utilisateur lirait `depenses.filters.title` en gros dans le dialogue.

---

## 3. Pièges déjà payés — ne pas les repayer

| Piège | Ce qui se passe | La règle |
|---|---|---|
| **`allowDialogOverlap` laissé à `false`** | Sur une ancre large, le dialogue sort de l'écran et le texte est coupé. Dans `core/dialog.ts` la correction horizontale est écrite `shift({ crossAxis: allowDialogOverlap })` : à `false`, floating-ui n'a pas le droit de ramener le dialogue dans la fenêtre. | Déjà réglé à `true` dans `use-tour.tsx`. **Ne pas le repasser à `false`.** |
| **`import` statique de la bibliothèque** | `npm run build:ssr` casse : le bundle est en UMD et référence `self` dès l'évaluation. | L'`import()` est **dynamique**, dans un effet. Ne jamais le remonter en tête de fichier. |
| **Importer `src/scss/tour.scss`** (ce que dit la doc officielle) | Le dépôt n'a pas de `sass` à lui — il n'arrive que comme dépendance transitive du paquet. Le jour où celle-ci saute, le build casse. | `app.tsx` importe `dist/css/tour.min.css`. |
| **Oublier la ligne dans `AppServiceProvider`** | Le fichier de langue est parfait mais ne quitte jamais le serveur : l'écran affiche les clés brutes. La liste des namespaces partagés est **écrite à la main**. | `'tours' => __('tours')` y est déjà. Un test le vérifie. |
| **Renommer un `data-tour` sans toucher au `.tour.ts`** | L'étape disparaît sans bruit. | L'avertissement console existe pour ça — le lire. |
| **Mesurer avant que React ait peint** | Ancres à 0×0, dialogue collé en haut à gauche. | Le double `requestAnimationFrame` du démarrage automatique est là pour ça. |
| **Cibler un conteneur pleine page** | Le surlignage ne désigne plus rien. | Viser le plus petit élément porteur de sens. |
| **Largeur de dialogue fixe** | 380 px sur un téléphone de 375 px : dialogue posé à x = −5, texte coupé au bord. Le client étant unique pour l'onglet, une largeur fixée au constructeur suit l'utilisateur d'un écran à l'autre. | `startTour` passe `tourLaunchOptions({ steps, labels, viewportWidth })` à `setOptions` à **chaque lancement** ; c'est elle qui calcule `dialogMaxWidth` via `tourDialogMaxWidth` (`registry.ts`, testées, et l'appel lui-même vérifié dans `registry.test.ts`). Ne pas remettre de largeur fixe. |
| **Textes trop longs** | Mesuré à 375 × 812 : 6 fenêtres sur 7 de la visite du Yield Manager sortaient de l'écran (540 à 670 px de haut), boutons compris — la visite devenait impossible à terminer. | **2 à 3 phrases, ~280 caractères visibles au plus** par étape, en langage simple. Le détail (ordre des colonnes, cas de droits, exceptions) appartient à l'écran, pas à la visite. `yield-tour.test.ts` tient la limite pour les visites du Yield Manager. |

---

## 4. Contenu asynchrone (FullCalendar, tableaux chargés à la demande)

Une ancre qui n'existe pas encore au lancement est simplement retirée. Pour un écran dont le contenu arrive après coup, deux options :

1. **Ancrer sur le conteneur**, présent dès le premier rendu, plutôt que sur une ligne ou une cellule ;
2. Marquer l'étape `optional: true` et l'accepter absente quand la donnée n'est pas là.

La bibliothèque expose aussi `beforeEnter` / `afterEnter` par étape (voir `src/types/TourGuideStep.ts`), non câblés ici. Si un écran l'exige vraiment, les ajouter à `AppTourStep` **et** à `use-tour.tsx` — et écrire le test qui va avec.

---

## 5. Vérification avant de dire « terminé »

Une visite qui compile n'est pas une visite qui marche. Le bug de dialogue coupé décrit plus haut passait `tsc`, ESLint, les deux builds et tous les tests — il ne s'est vu qu'à l'écran.

- [ ] `npx vitest run resources/js/lib/tour/registry.test.ts`
- [ ] `php artisan test --filter=Tour`
- [ ] `npm run build` **et** `npm run build:ssr`
- [ ] **Lancer la visite dans le navigateur, sur l'écran réel, et la parcourir jusqu'au bout** — chaque étape surligne le bon élément, aucun dialogue coupé par un bord
- [ ] La refaire à la taille d'un téléphone (375 × 812) : chaque fenêtre tient entière à l'écran, boutons compris
- [ ] La refaire en anglais (menu utilisateur → EN)
- [ ] La refaire avec un compte NON admin si la visite a des étapes `adminOnly` / `permission`

Pour rejouer une visite déjà terminée, vider sa clé dans le localStorage du navigateur (console) :

```js
Object.keys(localStorage).filter(k => k.includes('tourguide')).forEach(k => localStorage.removeItem(k))
```

---

## 6. Où poser l'ancre quand ce n'est pas un simple `<div>`

Les cinq gestes ci-dessus couvrent le cas courant. Trente-cinq visites plus tard, quatre situations reviennent assez souvent pour mériter leur règle. Les ignorer ne casse rien tout de suite — c'est le problème.

### 6.0 Une ancre plus haute que l'écran fait défiler la visite au pied de page

C'est le piège le plus coûteux, parce que le symptôme ne ressemble pas à sa cause : la visite s'ouvre **tout en bas de la page**, sur un contenu sans rapport, et paraît cassée.

La bibliothèque amène la cible à l'écran ainsi (`core/scrollTo.ts`) :

```ts
targetElem.scrollIntoView({ behavior: …, block: "end", inline: "nearest" })
```

`block: "end"` aligne le **bas** de la cible sur le bas de la fenêtre. Sur un élément qui tient à l'écran, c'est sans effet notable. Sur un élément de 2 000 px, cela défile jusqu'à son pied — donc au pied de la page.

Le cas rencontré : le rail d'étapes de l'assistant logement. Le `<ol>` ne fait que six entrées, mais son parent `flex flex-row` l'étire sur toute la hauteur de la ligne. Rien ne le laisse deviner en lisant le JSX — c'est le navigateur qui décide.

**Règle : viser le plus petit élément porteur de sens.** Ce que l'étape décrit n'a pas besoin d'être surligné en entier ; il reste visible juste à côté. Pour le rail, l'ancre est sur les `<li>`, jamais sur le `<ol>`.

Corollaire à connaître : `dialogTarget` (positionner le dialogue contre un autre élément) **ne résout pas ce problème**. Le défilement suit `target`, pas `dialogTarget` — on déplacerait la bulle en laissant la page en bas.

Et l'unicité, aussi : **deux étapes d'une même visite ne peuvent pas partager une ancre** (`registry.test.ts` le vérifie). Si le repli sur un élément plus petit fait entrer en collision deux étapes, c'est le signe qu'elles parlaient du même endroit — les fusionner, comme l'ont été « comment fonctionne l'assistant » et « étape 1 ».

### 6.1 Une ancre par élément : le piège des listes

Une étape de visite surligne **un** élément. `document.querySelector` rend le premier trouvé. Poser `data-tour` dans le rendu d'une ligne de tableau ou d'une carte de grille en crée donc autant qu'il y a de lignes, et la visite désigne la première — celle qui se trouve en haut au moment du lancement, ce qui n'est pas une propriété stable.

La convention retenue est de **transmettre l'identifiant de la ligne à ancrer** :

```tsx
// index.tsx
<DataTable columns={getOwnerColumns(t, owners[0]?.id ?? null)} data={owners} />
```

```tsx
// columns.tsx
export const getOwnerColumns = (t: Translations, tourRowId: number | null = null) => [ /* … */
    cell: ({ row }) => (
        <div data-tour={row.original.id === tourRowId ? 'owners-actions' : undefined}>…</div>
    ),
];
```

Le défaut `= null` garde la signature rétro-compatible : les autres appelants n'ont rien à changer et ne portent aucune ancre.

**Choisir la ligne selon ce que l'étape décrit**, pas systématiquement la première :

- l'étape parle du triangle d'alerte → viser la première ligne **qui en a un**, sinon l'étape disparaît dès que la ligne du haut est propre ;
- l'étape parle des actions d'une étape de pipeline → viser la première ligne **dont l'étape porte des actions** (`stageHasActions()` dans `FolderActions.tsx`), sinon l'ancre est un conteneur vide et le surlignage ne montre rien.

Le tri du tableau est client : l'ancre suit son enregistrement, où qu'il aille. C'est voulu.

### 6.2 Ne jamais poser d'ancre dans un composant partagé

`@/components/data-table`, `FiltersCard`, `TabsSwitcher`, `SectionSidebar` servent plusieurs écrans. Une ancre écrite en dur dans l'un d'eux existe sur **tous** — six cibles pour une étape.

Deux issues, dans cet ordre de préférence :

1. **Envelopper à l'appel**, dans la page :
   ```tsx
   <div data-tour="questionnaires-table">
       <DataTable columns={columns} data={items} />
   </div>
   ```
2. **Passer l'ancre en prop** quand l'enveloppe ne suffit pas — c'est le cas quand l'élément à viser est *à l'intérieur* du composant :
   ```tsx
   // section-sidebar.tsx — partagé avec bulk-edit, qui ne passe rien
   stepKeys?: string[];
   <li data-tour={stepKeys?.[index] ? `wizard-step-${stepKeys[index]}` : undefined}>
   ```

Même règle pour un composant maison qui ne répand pas ses props : lui ajouter un prop explicite (`dataTour?: string` sur `MissingItemsBadge`) plutôt que de l'envelopper si l'enveloppe change la mise en page.

### 6.3 Un élément fabriqué par une bibliothèque : l'ancre se pose à la main

FullCalendar construit ses pastilles lui-même : il n'existe aucun JSX où écrire l'attribut. Le point d'accroche est le rappel `eventDidMount`, qui reçoit l'élément :

```tsx
// La première pastille montée prend l'ancre. La recherche dans le document
// rend la chose auto-réparante : au changement d'onglet ou de vue, l'ancienne
// quitte le DOM et la suivante reprend l'ancre.
if (!document.querySelector('[data-tour="planification-event"]')) {
    el.setAttribute('data-tour', 'planification-event');
}
```

### 6.4 `openOnEnter` marche aussi sur un onglet — par accident

`needsToggle()` compare l'attribut `data-state` à la chaîne `'open'`. Un déclencheur de menu ou de popover Radix porte `open` / `closed` : la bascule fonctionne dans les deux sens.

Un `TabsTrigger` Radix, lui, porte `active` / `inactive`. Conséquence : `('active' === 'open') !== true` est vrai à l'entrée → l'onglet est cliqué et s'ouvre ; et faux à la sortie → **l'onglet n'est pas refermé**. C'est utilisable, et c'est ce que fait la visite des propriétaires pour montrer l'onglet des mandats — mais uniquement en **dernière étape**, pour que laisser l'utilisateur devant l'onglet ouvert soit sans conséquence.

Deux limites à connaître avant de s'en servir :

- les étapes sont résolues **au lancement**. Le contenu d'un onglet inactif n'est pas monté, donc ses ancres internes ne seront jamais trouvées : on ne peut décrire qu'**une** étape par onglet, ancrée sur le déclencheur ;
- si `needsToggle` change un jour, ce comportement disparaît sans que rien ne le signale.

### 6.5 Les icônes d'action se décrivent une par une

Les petites icônes au bout d'une ligne de tableau sont ce que l'utilisateur regarde en premier et comprend en dernier. Une visite qui dit « les actions de la ligne » sans les distinguer n'apprend rien : les dessins se ressemblent d'un écran à l'autre alors que les gestes, eux, diffèrent.

Règles retenues :

- **une étape par bouton**, ancrée sur le bouton lui-même — pas sur le conteneur qui les regroupe ;
- dire **ce que le bouton fait qu'on ne voit pas** : ouvre-t-il un nouvel onglet ? y a-t-il une confirmation ? l'envoi part-il au premier clic ? le geste est-il défaisable ?
- **nommer les asymétries**. C'est là que la visite gagne son coût : la corbeille des clients confirme, celle des réclamations efface aussi les fichiers du disque, celle des fournisseurs n'apparaît que si la suppression serait réelle, et « Annuler le contrat » des réservations ne demande rien du tout ;
- `openOnEnter` sur un **menu** déroulant, jamais sur un déclencheur de `Dialog` / `AlertDialog` : une fenêtre modale prend le focus et passe par-dessus le dialogue de la visite ;
- quand un bouton a **deux variantes exclusives** (actif / verrouillé, fenêtre au bureau / tiroir au mobile), poser la **même** ancre sur les deux : une seule est montée à la fois, il n'y a pas de doublon.

Les textes de ces étapes sont regroupés en fin de `lang/{fr,en}/tours.php`, sous « Actions de ligne » — les relire d'affilée est le seul moyen de repérer qu'un même dessin ne fait pas la même chose partout.

---

## 7. Le contenu des visites ne voyage plus avec les pages

`lang/{fr,en}/tours.php` pèse plus de 100 Ko. Il était partagé comme les autres namespaces (`'tours' => __('tours')`) et repartait donc dans **chaque** réponse Inertia, sur chaque écran — la moitié de toutes les traductions partagées, pour un contenu que la plupart des pages n'affichent jamais.

Le partage est désormais coupé en deux :

| Ce qui descend | Où | Quand |
|---|---|---|
| Les 5 libellés `ui.*` | `translations.tours` | Sur chaque page — le bouton « ? » affiche son infobulle partout |
| Tout le reste | propriété **de premier niveau** `tours` | Uniquement sur requête partielle, quand une visite est lancée |

```php
// AppServiceProvider
'tours' => Inertia::optional(fn () => __('tours')),   // ← à la RACINE des props
```

**Ne pas imbriquer cette propriété dans `translations`.** `Response::resolvePartialProperties()` ne retire les propriétés `IgnoreFirstLoad` qu'à la racine du tableau, sans récursion : imbriquée, elle repartirait sur chaque page et le gain serait nul — silencieusement.

Côté React, `startTour()` la réclame **avant** de construire les étapes (donc avant de lire le DOM), et la mémorise pour la durée de l'onglet. `router.reload` conserve l'état et le défilement par construction — son type est `Omit<VisitOptions, 'preserveScroll' | 'preserveState'>` — ce qui compte ici : les ancres dépendent de ce que l'utilisateur a ouvert à l'écran.

Deux tests gardent l'ensemble : `test_le_contenu_des_visites_ne_part_pas_avec_chaque_page` (la propriété est absente au chargement) et `test_le_contenu_des_visites_descend_sur_requete_partielle` (elle arrive quand on la demande). Le second existe parce que le premier, seul, serait satisfait par une visite définitivement cassée.

---

## 8. Avant d'écrire une visite : l'écran a-t-il de quoi la lancer ?

Le bouton « ? » vit dans `AppLayout`. Une page qui ne passe pas par cette mise en page n'a pas de lanceur, et une visite y serait injouable — écrite pour rien.

```bash
grep -n "AppLayout" resources/js/pages/<la-page>.tsx
```

Le cas s'est présenté sur `recus/show`, page **publique** (sa route est déclarée avant le groupe `auth`) pour que le receveur signe depuis son téléphone. Correctif appliqué : le lanceur a été posé **dans le bloc réservé aux utilisateurs connectés**, avec `<TourLauncher tone="light" />` — le prop `tone` existe parce que les couleurs par défaut sont calculées pour l'en-tête vert foncé et seraient blanches sur blanc ailleurs. Le visiteur muni du lien public ne voit toujours rien de l'outil interne.

---

## 9. Ce qu'une étape doit dire — et la limite à ne pas franchir

Le critère est déjà écrit plus haut : dire ce que l'écran ne dit pas. En pratique, la matière se trouve dans le contrôleur et le service, pas dans le JSX. Les questions qui rapportent :

- **sur quelle date ce filtre porte-t-il vraiment ?** (arrivée, création, paiement, réponse — presque jamais celle qu'on croit) ;
- **qu'est-ce qu'un filtre vide veut dire ?** (« tous » ou « confirmées seulement ») ;
- **la fenêtre par défaut est-elle réellement appliquée, ou seulement affichée ?** ;
- **ce bouton envoie-t-il quelque chose à quelqu'un, tout de suite ?** ;
- **qu'est-ce qui est irréversible ?** ;
- **quel état ressemble à une panne sans en être une ?**

**Vérifier soi-même avant d'écrire.** Une visite est de la documentation : elle survit au ticket et sera lue comme vraie. Les affirmations structurantes — un effet de bord, un verrou, un calcul — se lisent dans le code, jamais dans un rapport intermédiaire ni dans un souvenir.

**Et ne jamais documenter un défaut comme si c'était une règle.** Quand le comportement est manifestement un bug, deux options : décrire l'effet sans le justifier (« enregistrer ici crée une seconde newsletter et renvoie les courriels — servez-vous-en pour réutiliser un contenu, jamais pour corriger un envoi »), et le porter dans `STATUS.md`. Écrire « c'est ainsi que cela fonctionne » sur un défaut le grave dans le produit.
