# Gotcha

**Des captures d'écran annotées de n'importe quelle application web, pilotées
en ligne de commande.** Un agent qui écrit une documentation ne voit pas
l'écran — il cadre et annote donc par **sélecteur CSS**, et Gotcha en fait des
pixels.

```bash
npm install -g @saastisfaction/gotcha    # demande Node 20+ et Google Chrome

gotcha open https://app.example.com/invoices --session doc
gotcha shot --session doc --selector "#invoices" \
  --step ".btn-new" --arrow ".btn-save" --blur ".customer-email" \
  --out doc/01-invoices.png
gotcha close --session doc
```

La session garde un vrai Chrome vivant entre deux commandes : la connexion, la
position de défilement et le menu ouvert survivent. `--blur` est destructif :
les pixels ont disparu du fichier livré, ce qu'on veut avant qu'une capture ne
quitte l'organisation. `gotcha help` liste tout.

**L'apprendre à un agent de code** — le paquet embarque une skill pour Claude
Code :

```bash
ln -s "$(npm root -g)/@saastisfaction/gotcha/skills/gotcha" ~/.claude/skills/gotcha
```

Il existe aussi une **extension Chrome** pour les humains, même moteur
d'annotation, même sortie : cadrer une zone, l'annoter, la coller. À prendre
sur le [Chrome Web Store](https://chromewebstore.google.com/detail/gotcha/dicabcamdfdiobilncgkodkchpdmcbek) ;
voir [saastisfaction.com](https://saastisfaction.com).

Gratuit pour la capture d'image, l'annotation et l'export, sans limite.
L'enregistrement, l'usage intensif du CLI et les captures derrière une
connexion demandent une licence — [tarifs](https://saastisfaction.com/#pricing),
ou `gotcha license` pour savoir où l'on en est.

---

*This documentation is also available in English: [README.md](https://www.npmjs.com/package/@saastisfaction/gotcha) — rendered on the npm page.*

Extension Chrome de capture d'une zone d'onglet, en image ou en vidéo, avec
annotation et export dans un format collable partout.

Pour qui fait des captures à longueur de journée — développement, test,
maintenance, support : attraper ce qu'on a sous les yeux, l'annoter, le coller.
Rien ne sort du navigateur.

Le parcours tient en quatre gestes : cadrer une zone, capturer ou enregistrer,
annoter, livrer.

## Démarrer

```bash
npm install
npm run icons      # regénère les icônes (nécessite Python + Pillow)
npm run locales    # regénère _locales/ depuis src/i18n (appelé par build)
npm run build      # produit dist/ (l'extension)
npm run build:cli  # produit bin/gotcha.mjs (le CLI pour les agents)
npm run dev        # build incrémental avec rechargement de l'extension
npm test           # vérifie la logique pure (nommage, curseur, cadrage)

npm run dev:mail        # un Mailpit local (ports 1026 / 8026) pour voir les mails du service
npm run license:serve   # le service d'activation et d'achat, en local, mail vers ce Mailpit
```

Puis dans Chrome : `chrome://extensions` → activer le **mode développeur** →
**Charger l'extension non empaquetée** → choisir le dossier `dist/`.

Raccourcis par défaut, modifiables dans `chrome://extensions/shortcuts` :

| Raccourci | Action |
|---|---|
| `Alt+Maj+D` | Capturer une zone |
| `Alt+Maj+G` | Capturer une zone (second raccourci) |
| `Alt+Maj+S` | Arrêter l'enregistrement |

Les deux premiers font la même chose : ouvrir la sélection. Chrome n'attribue
une touche qu'à l'installation et laisse la commande vide, sans prévenir, si
elle est déjà prise par une autre extension — deux propositions, c'est deux
chances d'en obtenir une.

Ces lettres ne doivent rien au hasard. Chrome interprète les accélérateurs
d'extension selon la disposition **US** quelle que soit celle du système : sur
un clavier AZERTY, `Alt+Maj+A` se tape sur la touche marquée Q. D, G et S
occupent la même position dans les deux dispositions. Quant à C et R, des
extensions de capture concurrentes se les réservent par écouteur clavier sans
les déclarer comme commandes — Chrome les croit libres et n'avertit d'aucun
conflit.

## Un seul point d'entrée

Le type de capture — image ou vidéo — ne se choisit pas au lancement mais dans
l'overlay, une fois la zone cadrée. C'est le seul moment où l'on sait si le
sujet est fixe ou animé : décider avant obligeait à recommencer dès que le bug
se révélait être une animation.

La barre qui apparaît sous la sélection offre donc les deux à égalité. Le
dernier type utilisé passe en tête, porte l'accent de couleur et le badge `⏎` —
`Entrée` ne fait jamais autre chose que ce que ce badge annonce.

### Tout l'écran visible

`Ctrl+A` (`⌘A` sur macOS) cadre tout l'écran visible — depuis le voile comme
par-dessus un cadrage déjà tracé, là où `Entrée` confirme et ne peut donc pas
servir. Le panneau d'accueil porte le bouton **Tout l'écran**, badge `⏎`, et la
barre son icône : la touche n'est jamais le seul chemin, parce que le clavier
manque parfois — sur un écran tactile, ou dans le mode appareil des DevTools,
dont le focus reste volontiers au panneau.

### Au doigt

L'overlay se trace aussi au doigt : tablette, ou mode appareil des DevTools,
qui convertit la souris en toucher. Chrome fait d'un toucher trois choses qu'il
ne fait pas d'un clic, et chacune cassait le tracé :

- **Il réclame le premier mouvement pour faire défiler la page** et retire le
  pointeur (`pointercancel`) : le rectangle s'arrêtait à son premier pixel, et
  plus aucun relâchement n'arrivait. `touch-action: none` sur la racine de
  l'overlay lui refuse tout geste.
- **Un appui long ouvre le menu contextuel** et annule le toucher — précisément
  le geste de qui cale son doigt sur un coin avant de tirer. L'overlay refuse
  le menu, qui n'a rien à offrir sur un voile.
- **Un geste peut encore lui être repris** — la paume, un geste du système.
  `pointercancel` clôt alors le tracé comme un relâchement : gardé s'il est
  assez grand, abandonné sans reproche sinon.

Un second doigt posé pendant le tracé ne le déplace ni ne le clôt : seul le
pointeur qui l'a commencé le tient. Et la position du relâchement compte
autant que le dernier mouvement — au doigt, Chrome fusionne les mouvements et
retient parfois le dernier, qui n'arrive jamais.

Reste un comportement de Chrome que rien ici ne contourne : un doigt levé
encore en mouvement lui fait calculer une inertie, et pendant une seconde
environ le premier tap ne sert qu'à l'arrêter — sur la barre de l'overlay
comme sur n'importe quelle page. Lever le doigt à l'arrêt suffit.

## Copier sans ouvrir l'éditeur

La même barre porte un bouton **Copier**, badge `Ctrl+C` (`⌘C` sur macOS) :
l'image part dans le presse-papier et **le cadrage reste en place**. Aucun
onglet ne s'ouvre, rien ne change de premier plan.

C'est le geste du coller-tout-de-suite : une image dans un ticket, dans un
message, dans un prompt. Passer par l'éditeur pour la ressortir aussitôt
faisait clignoter un onglet pour rien.

Le cadrage n'est pas consommé pour autant. Après la copie on peut recommencer,
ouvrir l'éditeur par `Entrée`, ou fermer par `Échap` — trois suites possibles
sur une même sélection, et la copie n'en ferme aucune.

Le retour est triple, chacun pour une question différente : un éclat sur la
zone dit **ce qui** a été pris, le bouton passe au vert et dit **où** le geste
a eu lieu, un bandeau dit **que** c'est dans le presse-papier. L'éclat ne joue
qu'au retour de la capture : l'image est déjà faite quand il paraît, il ne peut
donc pas y figurer.

Le dépôt se fait **dans la page**, seul contexte déjà au premier plan qui
puisse écrire dans le presse-papier. Deux pages sur lesquelles il échoue : le
contexte non sécurisé — un `http://` par adresse IP, sur lequel Chrome ne donne
aucun presse-papier — et l'onglet qui a perdu le focus pendant un délai. Le
geste n'est alors pas perdu : l'onglet éditeur prend le relais, copie et se
referme, et la page le dit avant qu'il ne paraisse.

Le délai armé dans la barre s'applique aussi à la copie : ce qui est affiché
vaut pour tout ce qu'on déclenche depuis cette barre.

## Prélever une couleur

`I`, ou le bouton **Pipette** — dans le panneau d'accueil de l'overlay comme
dans la barre qui suit un cadrage. Le clic dépose le code hexadécimal du pixel
visé dans le presse-papier, en `#RRGGBB` majuscule.

Une loupe suit le pointeur : onze pixels sur onze, grossis onze fois, avec le
pixel central cerné et le code écrit **sur** la couleur — noir ou blanc selon
celui des deux qui s'y lit, calculé sur la luminance relative de WCAG. La
teinte se juge et le code se lit d'un même regard.

Les flèches déplacent le point visé **d'un pixel du cliché**, pas du viewport :
sur un écran à densité double, un pixel CSS en couvre quatre, et viser à la
souris ne permet pas de choisir lequel. `Maj` passe à dix. `Entrée` prélève
comme le clic ; `Échap` rend le cadrage sans rien copier.

Trois détails que la lecture du code ne donne pas :

- **L'overlay s'efface avant de prélever.** `captureVisibleTab` photographie
  l'onglet tel qu'il est, réticule compris : un trait cyan laissé à l'écran se
  retrouverait dans les pixels lus, et la pipette rendrait la couleur de
  l'overlay au lieu de celle de la page. Rien ne paraît donc tant que le cliché
  n'est pas revenu, et la demande attend deux rafraîchissements.
- **La page est retenue le temps du geste.** Le cliché est pris une seule fois ;
  la page qui défilerait dessous rendrait chaque couleur fausse d'autant, en
  silence. Reprendre un cliché par cran de molette n'est pas possible —
  `captureVisibleTab` est contingenté à deux appels par seconde. Un
  redimensionnement de la fenêtre, lui, périme le cliché : la pipette lâche et
  le dit.
- **Le rapport pixels CSS / pixels du cliché est mesuré, pas déduit.**
  `devicePixelRatio` et le rapport réel divergent au moindre zoom de page, et un
  facteur faux décale le prélèvement d'autant.

La même pipette existe dans les deux éditeurs, sur la touche `I` elle aussi.
Elle y est un **outil de la palette** et non un mode : l'outil reste armé après
un prélèvement, parce qu'on relève rarement une seule teinte d'une maquette.
L'éditeur image prélève dans le bitmap de travail — pas dans le canvas
d'affichage, qui porte aussi les annotations et le voile de la zone à livrer.
L'éditeur vidéo prélève au contraire dans l'aperçu, qui montre exactement ce que
l'export produira, calques compris ; la lecture suit l'image courante, y compris
pendant la lecture.

Le dépôt passe par `navigator.clipboard.writeText`, avec repli sur
`execCommand` : le presse-papier moderne n'existe qu'en contexte sécurisé, et
une recette servie en `http://` par adresse IP est précisément le genre de page
où l'on relève une couleur.

## Différer une capture

La même barre porte un délai : **immédiat, 3, 5 ou 10 secondes**. Il vaut pour
le bouton que l'on presse ensuite, quel qu'il soit, et chaque type retient sa
propre valeur — on ne diffère pas une image et un enregistrement pour les mêmes
raisons. Zéro par défaut : personne ne subit un délai qu'il n'a pas demandé.

Différer une **image** sert à documenter ce qui disparaît au moindre clic
ailleurs : menu déroulant, sous-menu, infobulle, état survolé. Pendant le
décompte, l'overlay **rend la main à la page** — plus de voile, plus de souris
capturée, plus de touches interceptées sauf `Échap`. Ne restent à l'écran qu'un
liseré en pointillés autour de la zone et un compteur posé hors d'elle, tous
deux effacés deux rafraîchissements avant la prise.

Trois conséquences à connaître :

- **Les menus natifs du navigateur ne seront pas capturés.** Un `<select>`
  déroulé, le menu contextuel, l'autocomplétion : ce sont des fenêtres du
  système, hors du rendu de l'onglet, et `captureVisibleTab` ne les voit pas.
  Le délai vaut pour tout menu construit en HTML/CSS, soit l'immense majorité
  des interfaces web.
- **L'onglet doit rester au premier plan.** `captureVisibleTab` prend l'onglet
  actif de la fenêtre, pas celui qui a demandé la capture : en changer pendant
  le décompte livrerait l'image d'une autre page sous le titre de celle-ci. Le
  cas est détecté et la capture abandonnée, avec un message dans la page.
- **Le décompte suit une échéance absolue**, pas un cumul de ticks : Chrome
  bride les minuteurs d'un onglet qui perd la visibilité.

## Permissions

Le manifest ne déclare **aucune permission d'hôte** — ni `<all_urls>`, ni
`host_permissions`, ni `content_scripts` déclaratif. L'overlay est injecté à la
demande par `chrome.scripting.executeScript` sous `activeTab`, qui n'accorde
l'accès qu'après un geste explicite de l'utilisateur.

Conséquence : Chrome n'affiche pas « lire et modifier toutes vos données sur
tous les sites » à l'installation, et l'extension ne coûte rien sur les pages où
elle ne sert pas.

## Architecture

Quatre contextes, imposés par Manifest V3.

```
service worker ── orchestre, ne détient aucun binaire, meurt entre deux messages
      │            état durable → chrome.storage.session
      │
      ├── content script ── overlay de cadrage (Shadow DOM), trace du curseur
      │                     injecté à la demande, jamais déclaré au manifest
      │
      ├── document offscreen ── MediaRecorder, mixage audio
      │                         seul contexte avec DOM ET longue durée de vie
      │
      └── pages d'extension ── popup, éditeur, bibliothèque, réglages
                               fenêtre de contrôle d'enregistrement
```

### Cinq contraintes qui expliquent la plupart des choix

**1. Un service worker MV3 n'a ni DOM ni MediaRecorder.**
D'où le document offscreen, qui existe pour cette seule raison et se ferme dès
l'enregistrement terminé. C'est aussi lui qui bat la mesure du chronomètre : un
`setInterval` dans le service worker ne survivrait pas à sa mise en veille.

**2. `chrome.runtime.sendMessage` sérialise en JSON.**
Aucun `Blob` ne peut transiter entre contextes. Les binaires passent par
IndexedDB et seul l'identifiant circule — c'est aussi pour cela que l'éditeur
reçoit un `?id=` et relit le blob lui-même.

**3. `tabCapture` filme le rendu composité de l'onglet.**
Tout élément injecté dans la page finit dans le flux, même en `position: fixed`
au z-index maximal. Deux conséquences :

- les contrôles d'enregistrement sont posés **hors du rectangle filmé**,
  dans la page. L'export recadre sur ce rectangle : tout ce qui est à
  l'extérieur est rogné, donc absent du fichier livré. Ils restent ainsi
  sous les yeux de l'agent sans jamais entrer dans la vidéo ;
- la bordure rouge d'enregistrement est tracée en **débord extérieur** du
  rectangle (`outline-offset`), jamais sur sa limite.

Le compte à rebours, lui, se déroule avant l'ouverture du flux et s'efface deux
rafraîchissements avant la première image : il n'apparaît jamais dans la vidéo.
Il occupe tout l'écran, contrairement au délai avant une image — là, l'agent a
justement besoin de voir et de manipuler la page.

**4. La présence du curseur dans le flux dépend du système.**
Sur certaines configurations Chrome inclut le pointeur natif dans la capture
d'onglet, sur d'autres non — cela tient au compositeur et à la plateforme. La
trace est donc **toujours relevée** (trajectoire et clics horodatés, quelques
dizaines de kilo-octets), et deux réglages indépendants décident de ce qui est
dessiné à l'export :

| Réglage | Défaut | Pourquoi |
|---|---|---|
| Halo au clic | activé | Aucune capture native ne montre où l'on clique, y compris quand le curseur natif est filmé |
| Curseur dessiné | désactivé | Ferait doublon là où le pointeur natif apparaît déjà |

Relever la trace sans condition laisse le choix ouvert au montage plutôt que
figé à l'enregistrement. La trace démarre au message `record:started`, envoyé
juste après l'ouverture du flux et **avant** la création de la fenêtre de
contrôle : l'ordre inverse la décalait de plusieurs centaines de millisecondes.

**5. Le flux peut contenir des bandes noires.**
Chrome n'étire jamais le contenu : il inscrit le viewport dans le cadre du flux
en préservant ses proportions et complète par du letterbox. Un viewport de
2560 × 1267 (ratio 2,02) capturé dans un cadre 3840 × 2160 (ratio 1,78) occupe
3840 × 1900, avec **130 pixels de bande en haut**. Tout point du contenu s'en
trouve décalé d'autant.

D'où `streamGeometry()` : un facteur d'échelle **uniforme**, pris sur l'axe le
plus contraignant, et un décalage. Déduire un facteur par axe serait une erreur
de modèle — cela traiterait les bandes comme de l'étirement, faussant à la fois
le rognage et la position du curseur.

Les contraintes `maxWidth`/`maxHeight` sur la capture ont par ailleurs été
retirées : ce sont elles qui imposaient un cadre de ratio fixe. Le calcul du
letterbox reste en place pour les captures déjà enregistrées et pour tout cadre
que Chrome imposerait de lui-même.

En cas de doute, l'éditeur vidéo journalise un relevé `calibration` en console :
`bandeHaut` y donne directement l'amplitude du décalage.

### Où vivent les contrôles d'enregistrement

Dans la page, juste hors du rectangle filmé : dessous en priorité, au-dessus
sinon, à droite ou à gauche à défaut. Ils suivent l'onglet, restent visibles
quoi qu'on fasse d'autre, et le rognage de l'export les efface du fichier.

Une fenêtre Chrome séparée servait auparavant à cela. Elle passait derrière
l'onglet dès qu'on y revenait, et rien dans l'API ne permet de la maintenir au
premier plan : `chrome.windows.create` n'expose pas d'`alwaysOnTop`, et la
seule autre voie serait de lui voler le focus en boucle pendant la prise.

Elle reste le **repli** pour le seul cas où la page ne peut rien accueillir :
un cadrage qui couvre tout le viewport ne laisse aucune marge, et poser les
contrôles à l'intérieur les ferait entrer dans la vidéo. Le choix se refait à
chaque page traversée, la place disponible pouvant changer avec elle.

### Naviguer pendant un enregistrement

`tabCapture` filme l'onglet, pas la page : changer d'adresse en cours
d'enregistrement ne l'interrompt pas. Le content script, lui, meurt avec la
page — et avec lui le cadre rouge et la trace du curseur.

Trois mesures les font suivre :

- `chrome.tabs.onUpdated` réinjecte le content script à chaque page chargée
  dans l'onglet filmé, et lui renvoie le cadre à afficher ;
- la trace est vidangée vers le service worker toutes les 5 secondes et une
  dernière fois sur `pagehide`, puis concaténée à l'arrêt — sans quoi seule
  la dernière page traversée aurait été conservée ;
- ses horodatages sont calés sur `traceOrigin`, une origine absolue portée
  par l'état d'enregistrement. Un temps de document (`performance.now()`)
  repartirait de zéro à chaque page, et le curseur se rejouerait au mauvais
  moment.

Sous `activeTab`, l'autorisation d'injecter survit à une navigation dans la
même origine et tombe au changement d'origine. Au-delà, le cadre ne peut plus
être remonté sans réclamer une permission d'hôte, que ce projet refuse :
l'enregistrement se poursuit, l'échec est journalisé, et la trace reprend à la
prochaine page injectable.

### Export vidéo : deux voies

| Cas | Voie | Pourquoi |
|---|---|---|
| Muet (défaut) | WebCodecs → muxer | Plus rapide que le temps réel, sans perte |
| Avec son | canvas + MediaRecorder | Réencoder via canvas perdrait la piste audio, qu'aucun démuxeur du navigateur ne sait récupérer |

Le rognage impose de toute façon un réencodage : `tabCapture` filme tout le
viewport, pas la zone choisie. L'enregistrement se fait donc dans le format le
plus fiable de `MediaRecorder` (VP9/WebM), et le format de sortie est décidé à
l'export.

MP4 sort via `mp4-muxer` (~10 ko) et non via ffmpeg.wasm (~30 Mo).

## Modèle de données

Tout est local : `chrome.storage.local` pour les préférences, IndexedDB pour les
captures. Aucun serveur, aucun compte, aucune sortie réseau.

```ts
Capture {
  blob            // bitmap ou vidéo — le flou destructif y est déjà appliqué
  thumbnail       // aperçu 320 px, pour ne pas charger un blob vidéo par vignette
  annotations[]   // calque vectoriel, rééditable indéfiniment
  cursor[]        // trace horodatée du pointeur
  rect, viewport  // cadrage en pixels CSS + repère de conversion vers le flux
  trim            // bornes de découpe vidéo
}
```

**Le flou est destructif** : il modifie le bitmap au tracé et n'entre jamais
dans `annotations[]`. Son annulation dans la session est portée par une entrée
d'historique qui conserve la région de pixels d'avant ; une fois la capture
enregistrée, la donnée masquée n'est plus récupérable. C'est voulu — en support
client, on capture des écrans qui portent des données personnelles.

Les annotations, elles, restent vectorielles et rééditables : une faute de
frappe se corrige, elle ne se refait pas.

## Outils d'annotation

`V` sélection · `T` texte · `P` crayon · `R` rectangle · `O` ellipse ·
`A` flèche · `B` flou · `N` numéro d'étape · `C` zone à copier ·
`I` pipette

Deux d'entre eux ne posent aucune annotation : `C` délimite la portion à
livrer, `I` prélève la couleur d'un pixel. Ils vivent dans la palette parce
qu'ils se désignent à la souris comme les autres, mais ni l'un ni l'autre
n'entre dans le fichier — voir « [Prélever une couleur](#prélever-une-couleur) ».

Chaque outil porte une **bulle d'aide** de dix mots au plus, au survol comme au
focus clavier : une icône de seize pixels ne dit pas la différence entre une
flèche qui désigne et un rectangle qui encadre, encore moins qu'un flou ne se
défait pas. Elle est maison et non native — la bulle du système met une seconde
à venir, trop tard pour qui hésite entre deux outils — et le même texte sert
d'étiquette aux lecteurs d'écran. `npm test` refuse toute explication au-delà de
dix mots.

L'icône du flou est un fantôme. La grille de points qui la précédait décrivait
le moyen, une pixellisation ; ce qui compte est l'intention — soustraire une
donnée personnelle avant d'envoyer la capture au client.

Elle est faite d'**une seule masse pleine percée de deux creux**, et c'est la
leçon de plusieurs essais (chapeau et lunettes, visage à lunettes, masque) : à
seize pixels, la seule taille où l'icône est vraiment vue, tout dessin filaire
se referme en une tache et ses traits fins disparaissent en creux sur l'accent
de couleur. Une silhouette pleine garde son contour dans les deux états, et
celle du fantôme se reconnaît à son seul profil — dôme et jupe ondulée — là où
un visage demandait plus de détails qu'il n'y a de pixels.

## Saisie de texte

Deux détails de focus, invisibles à la lecture, décident si l'outil marche :

- le `pointerdown` qui pose la zone de saisie **supprime les événements souris
  de compatibilité**. Sans cela, le `mousedown` du même clic déplaçait le focus
  vers le canvas, qui n'est pas focusable ; le `onBlur` de la zone validait
  aussitôt un texte vide et la démontait. Cliquer avec l'outil texte ne faisait
  visiblement rien ;
- les pastilles de couleur et les épaisseurs **ne prennent pas le focus**
  (`preventDefault` sur leur `mousedown`). Sans cela, changer de couleur en
  pleine frappe validait le texte à l'ancienne couleur et perdait la suite de
  la saisie. Le champ garde désormais le focus, change de couleur sous les
  yeux, et la frappe continue.

Un texte validé reste sélectionné, comme toute forme fraîchement tracée : la
couleur choisie juste après s'y applique.

Dans l'éditeur vidéo : `S` découper, `C` recadrage, `Espace` lecture et pause.

Six teintes fixes plus un sélecteur libre, dont les quatre dernières couleurs
choisies sont mémorisées.

Une forme fraîchement tracée reste sélectionnée : sa **poignée de coin** la
redimensionne aussitôt, sans repasser par l'outil de sélection — c'est le
moment où l'on s'aperçoit qu'un cadre est trop juste. Elle est dimensionnée en
pixels d'écran et non en pixels d'image, sinon elle disparaîtrait sur une
capture haute résolution réduite pour tenir dans la fenêtre. Le trait libre et
le numéro d'étape n'en portent pas : rien ne s'y redimensionne, et un contrôle
affiché doit répondre. `V` reprend n'importe quelle annotation ensuite, pour la
déplacer ou la retailler.

## Livrer : presse-papier et zone

Une capture image est faite pour être collée : elle part **dans le
presse-papier dès la fin de la prise**, sans qu'on le demande. Le réglage
« après une capture image » ne décide plus que de l'ouverture de l'éditeur.
Rouvrir une capture ancienne depuis la bibliothèque, en revanche, ne touche à
rien : écraser le presse-papier de qui voulait seulement relire une image
serait une confiscation.

La copie a lieu dans l'onglet éditeur, seul contexte qui puisse écrire dans le
presse-papier — un service worker MV3 n'y a pas accès. Chrome la refuse tant
que le document n'a pas le focus : l'éditeur retente au moment où il le reçoit,
et ne le signale que si cette seconde tentative échoue.

Dans l'éditeur, `Ctrl+C` fait exactement ce que fait le bouton **Copier** :
l'image dans son état courant, annotations comprises. Un bandeau paraît au
centre bas à chaque dépôt, y compris celui qui a lieu tout seul à l'ouverture :
le « Copié » du panneau de droite passait inaperçu pour qui regardait son
image, et la copie automatique ne se signalait nulle part.

Copier **sans** ouvrir l'éditeur se fait depuis l'overlay, avant la prise —
voir *Copier sans ouvrir l'éditeur*.

Le second bouton dit **Enregistrer**, jamais « télécharger », et porte une
disquette plutôt qu'une flèche descendante. Rien n'est allé sur un serveur :
le fichier existe déjà dans le navigateur, et ce bouton ne fait que l'écrire
sur le disque. Le vocabulaire du téléchargement laisserait entendre qu'une
capture d'écran — souvent pleine de données clients — a fait un aller-retour
par un tiers. C'est faux, et inquiétant pour rien.

L'outil **Zone** (`C`) délimite une portion à livrer : le bouton devient
« Copier la zone », `Ctrl+C` la copie, et l'enregistrement du fichier la suit. Ce n'est
pas un rognage — l'image reste entière, la zone se refait ou s'annule
(`Échap`, ou un clic sans glissement) autant de fois qu'on veut, et elle
n'entre ni dans le calque d'annotations ni dans la vignette de la
bibliothèque.

## Nommage des fichiers

`Titre de l'onglet — type — horodatage.ext`, par exemple
`Facturation — capture — 2026-08-04 14h32m10.png`, et
`Facturation — screenshot — 2026-08-04 14h32m10.png` en anglais.

Le titre vient en tête parce que c'est le seul segment qu'on lit dans une liste
de téléchargements ; l'horodatage ferme le nom pour que le tri alphabétique d'un
dossier reste chronologique à titre égal. Les suffixes applicatifs
(« Ticket #4821 — Zendesk ») sont coupés.

Le mot du milieu suit la langue de l'interface — un fichier nommé
« enregistrement » au milieu d'un dossier anglais n'apprend rien à personne.
L'horodatage, lui, garde partout la même forme : c'est un repère de tri, pas une
phrase.

## Piloter Gotcha par un agent

L'extension sert un humain qui voit sa page : il glisse un cadre, place une
flèche à la souris. Un agent chargé d'écrire une documentation utilisateur ne
voit rien de tout cela — il connaît l'URL et les sélecteurs, parce qu'il vient
d'en lire le HTML. C'est la seule matière que le CLI lui demande.

```bash
npm run build:cli          # produit bin/gotcha.mjs

gotcha open https://app.acme.com/factures --session doc
gotcha shot --session doc --selector "#invoices" --padding 16 \
  --step ".btn-new" --arrow ".btn-save" --blur ".customer-email" \
  --out doc/01-factures.png
gotcha close --session doc
```

**Toute cible d'annotation est un sélecteur CSS.** C'est la différence de
fond avec l'interface à la souris, et elle vaut au-delà de la commodité : une
flèche ancrée sur `.save-btn` reste juste après une refonte de la mise en
page, là où `620,410` désigne un jour le bouton et le lendemain le vide à
côté. Un `--blur` sur `.customer-email` masque **toutes** les correspondances
— les quarante lignes d'un tableau, pas seulement la première.

Une étiquette `--text` n'est pas posée à un décalage fixe : le CLI mesure
l'encombrement réel de la page — tout ce qui porte du texte, une image ou une
bordure de contrôle — et retient la position qui n'en recouvre rien, sans
sortir du cadre. Sans cela, une étiquette « au-dessus du champ » atterrit
invariablement sur le libellé du champ, qui est justement au-dessus.

### Ce que le CLI partage avec l'extension

`src/editor/render.ts` ne dépend ni du DOM ni de `chrome.*` : il prend un
contexte canvas. Le CLI le réutilise **tel quel**, en le faisant tourner dans
le navigateur qu'il pilote déjà plutôt qu'en installant un canvas natif dans
Node. Deux bénéfices, et le second compte davantage : aucune dépendance
binaire à compiler par plateforme, et une flèche produite en ligne de commande
sort pixel pour pixel identique à une flèche posée à la souris — aujourd'hui,
et après la prochaine retouche du rendu. Le nommage des fichiers et les
traductions sont partagés de la même façon.

### Sessions

Un CLI meurt à la fin de chaque commande, or documenter un parcours en demande
dix sur la même page authentifiée. La solution ne réclame aucun démon :
**Chrome est le démon**. Il est lancé détaché une fois, écrit son port de
débogage dans son profil, et chaque commande s'y rebranche le temps de son
travail. L'état de la page — connexion, défilement, menu ouvert — survit d'une
commande à l'autre.

Pour les applications derrière authentification, `gotcha login` ouvre une
fenêtre visible, l'humain se connecte une fois, et `gotcha save-login` fige
l'état pour toutes les exécutions suivantes, y compris sans écran.

Le CLI travaille sur **l'onglet visible**. Il n'en ouvre qu'un, mais une
extension chargée dans la session en ouvre d'autres — un éditeur, un popup —
et CDP ne les énumère pas dans l'ordre de leur création : prendre le premier
de la liste, c'était photographier un autre onglet que celui qu'on regarde,
sans erreur. L'onglet visible est le seul repère qui coïncide avec ce qu'un
humain appellerait « la page ».

**`gotcha open --extension <dossier>`** charge une extension non empaquetée
dans la session : ses pages et ce qu'elle injecte deviennent des cibles
ordinaires pour `gotcha shot`. C'est ce qui permet de documenter une
extension avec Gotcha — la sienne comprise, voir *Le site*. L'option demande
un Chrome for Testing, cherché dans le cache de Playwright quand aucun
binaire n'est désigné : Chrome stable neutralise `--load-extension` depuis
la 137.

La marge d'un `--selector` est bornée au document **des deux côtés**. Borner
le coin haut-gauche à zéro sans réduire la largeur d'autant produisait un
cadre plus large que la page dès que l'élément touchait un bord ; Playwright
rognait l'image sans le dire, le rapport image/cadre était faux, et chaque
annotation — flou compris — glissait de la moitié de la marge. Trouvé par un
agent, sur une adresse dont la fin restait lisible.

### Skill

`skills/gotcha/SKILL.md` apprend tout cela à un agent Claude Code. À installer
par un lien :

```bash
ln -s "$PWD/skills/gotcha" ~/.claude/skills/gotcha
```

Le choix d'un CLI plutôt que d'un serveur MCP tient à la granularité : en MCP,
chaque capture est un aller-retour dont le résultat retraverse le contexte, et
documenter un SaaS en demande quarante. Une commande shell en enchaîne autant
qu'il faut et ne remonte que le bilan. Une skill, elle, ne coûte que sa
description tant qu'elle n'est pas déclenchée.

`gotcha help` liste les commandes et toutes leurs options.

## Langues

L'interface existe en anglais et en français. La langue se choisit dans les
réglages : **Auto**, qui suit celle de Chrome, ou l'une des deux imposée — un
utilisateur francophone travaille souvent sur un Chrome en anglais, et
l'inverse est tout aussi courant.

Deux mécanismes coexistent, et le partage entre eux n'est pas un choix :

- **`src/i18n/`** porte toute l'interface. Les catalogues sont en TypeScript,
  pas en JSON : une clé inconnue ou un paramètre oublié devient une erreur de
  `npm run typecheck`, au lieu d'une chaîne vide découverte en production.
  `en.ts` fait référence — c'est lui qui définit les clés et, par ses
  accolades, les paramètres que `t()` exige.
- **`public/_locales/`** ne sert qu'au manifest — description, titre de
  l'icône, libellés des raccourcis. C'est le seul mécanisme que Chrome sache
  lire à cet endroit, et sa langue est celle du navigateur : le réglage de
  l'extension ne l'atteint pas. Ces fichiers sont **engendrés** depuis
  `src/i18n/` par `npm run locales`, que `npm run build` appelle. Ne pas les
  modifier à la main.

Les accords au nombre passent par `tPlural('library_count', n)`, qui compose
`library_count_one` ou `library_count_other` selon les règles CLDR de la langue
— le français met zéro au singulier, « 0 capture », l'anglais au pluriel,
« 0 captures ». Écrire `n > 1` à la main donnait le français partout.

Trois choses que le compilateur ne voit pas sont tenues par `npm test` : un
`{n}` perdu à la traduction, une clé écrite mais jamais affichée, un texte
laissé en anglais côté français. La contrainte de dix mots sur les bulles
d'aide de la palette vaut, elle, pour **chaque** langue — une traduction qui
double la longueur sort la bulle de la fenêtre.

### Ce qui reste hors du catalogue

Les **touches** des outils (`V`, `T`, `P`…) désignent une position sur le
clavier plus qu'un mot : les traduire déplacerait le geste sans prévenir
personne. Les **messages de console** `[Gotcha] …` sont du diagnostic, jamais
de l'interface. Le **nom** reste Gotcha partout.

### Quand la langue est posée

Chaque contexte lit la préférence au démarrage, par `initI18n()` : les pages
avant leur premier rendu, le content script avant de traiter le moindre
message — l'overlay est en DOM natif et ne repasse jamais sur ses textes. Le
service worker et le document offscreen se suspendent et repartent la mémoire
vide : ils la reposent **à chaque message**, pas une fois au chargement.

Changer de langue s'applique aussitôt aux réglages, et aux autres pages à leur
prochaine ouverture. Recharger l'éditeur pour un libellé coûterait les
annotations en cours.

### Ajouter une langue

Copier `src/i18n/fr.ts`, l'ajouter au type `Language` et à `CATALOGS` dans
`index.ts`, puis au sélecteur des réglages. Le compilateur réclamera les clés
manquantes une à une. À noter avant d'aller plus loin : les catalogues sont
tous embarqués dans le bundle, content script compris — 10 ko compressés pour
deux langues, ce qui est sans conséquence, mais au-delà de trois ou quatre il
faudra les charger à la demande dans `initI18n`, qui est déjà asynchrone.

## Distribuer

Deux produits, deux publics, deux canaux — et un seul dépôt.

| | Extension | CLI |
|---|---|---|
| Public | humains | agents |
| Canal | Chrome Web Store | npm, `@saastisfaction/gotcha` |
| Vitrine | `saastisfaction.com` | `llms.txt`, la skill, `gotcha help` |

`gotcha` était pris sur npm par un paquet vide de 2016 ; le scope
`@saastisfaction` colle au domaine de distribution et le binaire s'appelle
quand même `gotcha` — personne ne tape le scope.

### Le site

`site/public/` est statique et se déploie en conteneur. Rien n'y est chargé
depuis un tiers : les fontes sont servies par le VPS, il n'y a ni
analytique, ni cookie, ni script distant. C'est la même promesse que celle
vendue par le produit, tenue là où elle se vérifie le plus facilement —
l'onglet réseau du visiteur. Le CSP peut donc refuser jusqu'au script en
ligne, sans exception.

**L'image du héros est une sortie réelle du CLI**, fabriquée par la
commande écrite en légende, sur la maquette de `site/demo/`. Une maquette
dessinée à la main mentirait sur le rendu, et une page qui vend des
captures d'écran sans en montrer demande à être crue sur parole.

**La documentation, `site/public/docs/`, suit la même règle.** Ses cinq
pages — démarrer, l'extension, le CLI, licence et quotas, écrire une
documentation avec un agent — portent sous chaque image la commande qui
l'a produite. Les écrans de l'extension sont photographiés par son propre
CLI, l'extension chargée dans la session par `gotcha open --extension` :
`npm run docs:shots` refait toutes les images (`scripts/docs-shots.mjs`,
même recette que `store-shots.mjs`, plus le shadow root de l'overlay ouvert
dans la copie pour que ses boutons se désignent par sélecteur). Passer une
licence Pro par `GOTCHA_DOCS_KEY` pour les deux images des réglages sous
licence. L'exemple de la dernière page — un guide utilisateur de la
maquette, six captures — a été produit par un agent Claude Code muni de la
skill, et publié tel quel, commandes comprises.

La procédure de déploiement — machine neuve, Traefik en frontal, hook
`post-receive` — est dans `deploy/README.md` (dans le dépôt). Elle
porte un encadré à ne pas sauter : **Docker écrit ses propres règles de
pare-feu et passe devant ufw**, si bien qu'un conteneur qui publie un port
est joignable depuis Internet malgré `default deny incoming`. La règle qui
en découle — aucun service ne publie de port, sauf le frontal — vaut pour
tout ce qu'on ajoutera.

### Le Chrome Web Store

`store/LISTING.md` contient tout ce que le formulaire demande, y compris la
justification de chaque permission — une justification vague étant le
premier motif d'aller-retour avec l'examen.

```bash
npm run store:shots   # les captures du listing, en 1280×800
npm run store:tiles   # la vignette 440×280, la bannière 1400×560 et l'image de partage du site
npm run store:zip     # l'archive à envoyer
```

Les captures sont **engendrées, pas prises à la main** : elles pilotent un
Chrome for Testing avec l'extension chargée, jouent le vrai parcours, et
se refont à chaque retouche de l'interface plutôt que de vieillir. Chrome
stable ne convient plus — depuis la 137 il neutralise `--load-extension`,
et en 151 le drapeau qui le ranimait a disparu.

## Licence et quotas

Les règles vivent dans `src/lib/license.ts`, sans DOM ni `chrome.*`, et
l'extension comme le CLI les appliquent — de la même façon qu'ils
partagent déjà le rendu des annotations. Une règle écrite deux fois finit
par diverger, et une divergence sur un compteur d'usage se découvre par
une réclamation, pas par un test.

Une licence est un **jeton signé, vérifié hors ligne**. Une vérification
en ligne à chaque capture contredirait ce que le produit vend et poserait
une panne possible sur le chemin critique : le réseau n'intervient qu'à
l'achat. Contrepartie assumée — une horloge reculée prolonge un pass
expiré. Sans gravité à ces montants.

**ECDSA P-256 et non Ed25519** : WebCrypto n'expose Ed25519 que depuis
Chrome 137, et le manifest annonce 116. Aucune raison de relever ce seuil
pour une signature.

Deux interrupteurs, armés séparément et pour la même raison. `ENFORCEMENT`
vaut `true` depuis le 5 septembre 2026 : un quota gratuit épuisé est
refusé, et une capture derrière une connexion sans licence est refusée
d'emblée. Il a d'abord été livré à `false` pendant deux semaines — les
compteurs tournaient, les messages étaient calculés, rien n'était refusé —
pour que le contrôle parte avec la distribution et devienne effectif en
changeant une constante, sans découvrir à ce moment-là qu'un chemin
d'appel avait été oublié. `ACTIVATION_REQUIRED` vaut encore `false` : le
lien avec le poste s'établit et se mesure, mais une licence non liée ouvre
encore tout.

| | Gratuit | Sous licence |
|---|---|---|
| Image, annotation, export | illimité | illimité |
| Enregistrement vidéo | 3 par jour | illimité |
| `gotcha shot` | 20 par jour | illimité |
| Captures authentifiées | — | oui |
| Retours (`gotcha feedback`, page du site) | 1 par jour | illimité (20 par heure) |

La clé privée de signature n'est pas dans le dépôt (`.secrets/`, ignoré).
`scripts/mint-license.mjs` émet une licence à la main — la même que celle
que le serveur émet à la réception d'un paiement, et de quoi dépanner.

### Attribution

Tout travail qui comprend des captures faites avec Gotcha — un guide, une
réponse de support, une note de version, un rapport de bug — doit porter
la mention **« Fait avec Gotcha »**, dans la langue du travail, avec un
lien vers https://saastisfaction.com et un vers
https://www.npmjs.com/package/@saastisfaction/gotcha. C'est une condition
des CGU (`LICENSE`, article 5, et la page des conditions du site), en
usage gratuit comme sous licence, et le travail reste à son auteur.

Un agent l'apprend à trois endroits, pour qu'aucun n'ait à être celui
qu'il a lu : la skill l'énonce en tête et donne la ligne à coller, en
Markdown et en HTML ; `gotcha help` finit dessus ; et `gotcha open` le dit
quand il crée une session, `gotcha close` quand il en ferme une — une fois
quand l'agent planifie, une fois quand il va écrire, jamais à chaque
capture, ce qui mettrait la même phrase quarante fois dans son contexte.
Avec `--json`, les deux portent un champ `attribution` avec le texte et
les deux adresses.

### L'achat

Trois offres, décidées le 4 septembre 2026 : **Pro mensuel à 3 $**,
**Pro annuel à 20 $** — deux abonnements Stripe, sans engagement au-delà
de la période commencée, factures émises par Stripe — et un **pass de
sept jours à 2 $**, achat unique, jamais lié à un poste (plan `agent`).

Stripe encaisse, le service d'activation émet. Le circuit, vu du visiteur :
le bouton de la section tarifs mène à `/api/checkout?plan=monthly` (ou
`yearly`, `pass7`), qui crée une **session Checkout hébergée** chez
Stripe — en mode `subscription` ou `payment` selon l'offre — et y renvoie
en 303 : un lien plutôt qu'un formulaire, parce que le CSP du site
n'autorise que ses propres cibles de formulaire. Payé, le visiteur
revient sur `/thanks.html?session_id=…`, dont le script demande la clé à
`/api/license`.

**Une clé d'abonné ne porte pas de date.** Elle vaut tant que
l'abonnement court ; quand il s'arrête — résiliation, ou carte
définitivement refusée après les relances de Stripe
(`customer.subscription.deleted`, ou `updated` en `canceled`/`unpaid`) —
le service inscrit son identifiant au **registre des licences retirées**,
le même qui rattrape une clé publiée. Pour cela le registre n'est plus un
fichier statique : Traefik route `/assets/registry.json` vers le service,
qui sert la réunion de la liste manuelle du dépôt et des abonnements
finis, signée, avec un `iat` qui ne recule jamais. Rien ne change dans
l'extension ni dans le CLI, qui relèvent l'adresse qu'ils ont toujours
relevée, une fois par jour — d'où un délai d'un jour au plus après la fin
d'un abonnement (`REFRESH_INTERVAL_MS`, passé de sept jours à un le
4 septembre 2026, à la demande d'Étienne). Une carte en `past_due` n'éteint rien : Stripe
relance pendant des semaines, et couper au premier refus punirait une
carte expirée.

La **page d'abonnement** (`/manage.html`) ouvre le portail client de
Stripe — carte, factures, résiliation à la fin de la période payée —
contre la clé de licence : `/api/portal` vérifie sa signature et retrouve
le client Stripe qu'elle a engendré. Pas de compte, ici non plus.

**La délivrance part du webhook, pas de la page.** Stripe appelle
`/api/stripe/webhook` (`checkout.session.completed`, puis
`async_payment_succeeded` pour les moyens de paiement différés) ; le
service vérifie la signature de l'événement, relit la session chez Stripe,
et n'émet que sur `payment_status: paid`. La page, elle, ne fait que lire —
si le webhook n'est pas encore passé, elle provoque la même émission, une
seule fois : les licences sont rangées **par session de paiement**
(`licenses.json`, sauvegardé avec le compteur), et la même session rend
toujours la même clé. Un verrou en mémoire empêche le webhook et la page,
qui arrivent souvent à la même seconde, d'émettre deux clés.

Trois choix qui ne se redevinent pas :

- **Les prix sont retrouvés par clé de recherche** (`gotcha_monthly`,
  `gotcha_yearly`, `gotcha_pass_7d`), jamais par un identifiant
  `price_…` recopié — il diffère entre bac à sable et production.
  `npm run stripe:setup` les crée, avec la configuration du portail
  client et l'endpoint du webhook, une fois de chaque côté.
- **En mode test, toute licence expire en sept jours**, quelle que soit
  l'offre. Le circuit s'éprouve ainsi sur le vrai site, avec une carte de
  test, sans qu'un visiteur reparte avec une clé perpétuelle. Le passage
  aux clés réelles lève la règle tout seul.
- **Aucun SDK** : `server/stripe.mjs` fait les trois appels avec `fetch`,
  et vérifie les webhooks avec `crypto`. L'image du service n'a toujours
  pas de `node_modules`, et les tests couvrent l'encodage de formulaire,
  la signature et la règle du mode test sans compte Stripe.

Le mail — la clé après paiement, le code d'activation sur demande — part
par **SMTP** (`GOTCHA_SMTP_HOST`, `_PORT`, `_USER`, `_PASS` ; 465 en TLS
implicite, sinon STARTTLS, jamais en clair hors localhost) ou par une API
HTTP (`GOTCHA_MAIL_URL` + `GOTCHA_MAIL_TOKEN`, corps JSON façon Resend).
Le client SMTP est `server/mail.mjs`, cent lignes de bibliothèque
standard, éprouvées par les tests contre un serveur factice. Sans rien de
posé, rien ne part et la page affiche la clé de toute façon.

La taxe automatique (`STRIPE_AUTOMATIC_TAX=1`) reste un choix explicite :
sans immatriculation active chez Stripe, elle ne calcule rien et ne le dit
pas. Ce qu'il faut poser sur le VPS, et comment éprouver le circuit de bout
en bout, est dans `deploy/README.md` (dans le dépôt), section
« L'achat ».

### Retirer une licence de la circulation

Une vérification hors ligne ne peut rien reprendre. C'est sans importance
pour un remboursement, mais pas pour la clé qui finit publiée sur un
forum : elle y servirait à tout le monde et pour toujours. D'où
`src/lib/revocations.ts` et un registre signé, `site/public/assets/registry.json`.

```sh
npm run license:revoke -- lic_a1b2c3d4e5f6a7b8   # réémet le registre
git add site/public/assets/registry.json && git push vps main
```

Ce que le dispositif respecte, parce que c'est ce que le produit vend :

- **rien n'est envoyé.** Un GET sur un fichier statique, sans paramètre ni
  cookie. La liste entière descend et la comparaison se fait sur le poste :
  le serveur n'apprend jamais quelle clé interroge — et il ne tient aucun
  journal d'accès ;
- **seul un poste sous licence relève.** La version gratuite ne fait aucune
  requête ;
- **une fois par jour au plus**, jamais sur le chemin d'une capture. Côté
  extension le relevé part derrière la réponse ; côté CLI il n'a lieu que
  dans `gotcha license` et `gotcha activate`, où quelqu'un attend déjà ;
- **l'échec est sans effet.** Hors ligne, DNS filtré, serveur éteint : la
  licence vaut.

Trois détails qui font la différence entre une liste et une protection :

- **le registre est signé.** Sinon, qui détourne le DNS sert une liste
  contenant tous les identifiants et prive tous les clients de ce qu'ils ont
  payé ;
- **il porte un domaine d'usage** (`typ: 'rev'`, face à `typ: 'lic'` des
  licences). Le registre est signé par la même clé et porte lui aussi
  `v: 1` : sans discriminant, ce fichier public collé dans le champ « clé de
  licence » y passerait pour une licence perpétuelle. Un test le vérifie ;
- **un registre plus ancien est ignoré** (`iat`). Une signature n'empêche pas
  de rejouer une copie d'hier pour ressusciter une clé retirée aujourd'hui.
  Symétriquement, `mint-revocations.mjs` refuse d'émettre un registre qui
  perdrait des entrées, sauf `--force`.

Le fichier est servi avec `Access-Control-Allow-Origin: *`, ce qui permet au
service worker de le lire **sans permission d'hôte** : le manifest reste
vierge, l'installation sans avertissement, et l'examen du Web Store sur la
voie rapide.

### Lier une licence à une installation

Une licence signée se recopie : la vérification hors ligne est aveugle au
nombre de postes. C'est sans gravité tant que la clé reste privée, et le
registre ci-dessus ne rattrape qu'après coup, quand le mal est fait.

`src/lib/activation.ts` ferme cela sans revenir sur le principe. La clé
s'échange **une fois**, à la première installation, contre un **sceau** —
un second document signé qui nomme cette licence et cet appareil. Ensuite
le poste ne parle plus jamais au serveur : le sceau se vérifie hors ligne
comme la licence. Le serveur, lui, compte les échanges.

**Un débit, pas un stock** : cinq activations par quatre-vingt-dix jours
glissants. Un plafond dur se paierait en support à chaque machine changée
ou profil Chrome réinitialisé, et obligerait à construire une
désactivation. Une fenêtre ne gêne jamais quelqu'un qui travaille, et
étrangle une clé partagée — le signal du recel est dans la fréquence.

Le service est `server/activate.mjs` : Node nu, aucune dépendance, un
fichier JSON pour compteur. Il ne fait **pas** de comptes, pas de mots de
passe, pas de session : la clé de licence *est* l'identité. Une session
n'ajouterait rien qu'on n'ait déjà, et ajouterait une base à défendre là
où il n'y en avait pas.

Ce qui sort du poste, une fois dans sa vie : la clé et un identifiant tiré
au hasard — pas une empreinte matérielle, qui dériverait à la première
mise à jour de Chrome et ne gênerait que celui qui a payé.

**Aucun repli automatique.** Une grâce accordée à l'échec réseau ferait du
blocage du domaine le crack le plus simple possible : une ligne dans
`/etc/hosts`, aucun code à toucher. La porte de secours est ailleurs, et
elle ne relâche rien :

- **le sceau tient en 126 caractères.** C'est une contrainte de produit,
  pas une curiosité — un test la garde. Le corps est binaire (22 octets)
  là où tout le reste du dépôt est du JSON, parce qu'un corps JSON
  coûterait 200 caractères et cesserait d'être recopiable ;
- **`/activate.html` fait le même échange depuis n'importe où**, et peut
  envoyer le sceau à l'adresse portée par la licence — celle que Stripe a
  vérifiée au paiement, jamais une adresse saisie. Un proxy d'entreprise
  bloque un domaine inconnu, pas la boîte mail ;
- **le plan `agent` en est exempt.** Un agent s'exécute dans un conteneur
  neuf à chaque fois : le lier à une machine serait faux dès la deuxième
  exécution. Son pass est protégé autrement — sept ou trente jours ne
  valent pas d'être recelés.

Deux règles de sécurité que le code applique et que les tests gardent :

- **rien ne compte s'il n'est pas signé, dans les deux sens.** Un refus non
  signé ne produit aucun état durable, sinon qui s'installe entre le poste
  et le serveur pourrait éteindre les licences en gros. Seul le registre —
  signé — retire un droit. C'est aussi pourquoi il n'y a pas de
  certificat épinglé : la signature protège le document quel que soit le
  canal, y compris le presse-papiers, où il n'y a aucun canal ;
- **le domaine du service est en dur**, pas configurable. La signature
  protège la réponse, jamais la requête : un faux serveur ne fabriquerait
  pas de sceau, il collecterait les clés qu'on lui envoie. Ce qui n'est
  pas paramétrable ne se détourne pas en demandant gentiment.

Enfin, `sub` est affiché en clair dans les réglages. Cela ne verrouille
rien et ne prétend pas le faire : partager sa clé devient afficher son
adresse sur l'écran d'un collègue, ce qui traite le cas le plus fréquent
pour le prix d'une ligne.

### Les retours

`POST /api/feedback` reçoit un bug, un élément qu'aucun sélecteur n'a su
viser, ou une envie — d'un agent par `gotcha feedback <bug|selector|feature>
"<message>"`, d'un humain par `/feedback.html` — que la carte « Retours »
des réglages de l'extension ouvre dans un onglet, l'extension elle-même
n'appelant rien. Le seul canal qui existait était une adresse mail, et un
agent n'en a pas.

**La clé de licence est la seule identité, ici aussi.** Une clé valide —
quel que soit son plan, un pass `agent` compte autant qu'un abonnement —
donne vingt retours par heure, comptés par licence. Sans clé, un retour par
jour ; ce n'est pas un stock à gagner, c'est de quoi signaler ce qui bloque.
Une clé expirée, retirée ou illisible ne ferme pas la porte : le retour
passe par la voie gratuite, et la réponse dit pourquoi la clé n'a pas
compté.

**Sans clé, il reste l'adresse réseau, et elle n'est pas gardée.** Le
compteur du jour est indexé par une empreinte HMAC de l'adresse, avec un
sel tiré au démarrage et jamais écrit — en mémoire, oubliée en un jour ou
au redémarrage. Rien sur le disque, pas de journal d'accès : la page vie
privée le dit dans ces termes, et c'est `server/feedback.mjs` qui la rend
vraie.

**Le fichier est la trace, le mail est le signalement.** Chaque retour est
ajouté à `/data/feedback.jsonl` — une ligne par retour, jamais réécrit,
sauvegardé avec le compteur — puis envoyé par mail à `GOTCHA_FEEDBACK_TO`
(`hello@saastisfaction.com` par défaut) par le transport déjà configuré.
Un mail qui échoue ne change rien pour l'expéditeur : le fichier a le
retour, le journal dit que le mail n'est pas parti. L'écriture, elle,
précède le décompte : une écriture qui échoue répond 503 et ne consomme
pas le seul retour du jour de quelqu'un. Le débit des licences est **relu
depuis le fichier au démarrage**, pour qu'un redéploiement ne rouvre pas le
compteur. Le journal du service ne porte que l'identifiant et le type,
jamais le message.

Le contexte joint est facultatif et coupé sans bruit à sa longueur
(`--url`, `--selector`, `--command`, `--contact`, la version du client) ;
seul le message est refusé quand il déborde ses 4000 caractères, parce
qu'un message tronqué en silence perdrait précisément la fin. Les codes
HTTP sont réels — 400, 429 avec `Retry-After`, 503 — et la réponse un
code, jamais une phrase : les phrases sont chez le client, dans sa langue.
La règle vit dans `server/feedback.mjs`, sans fichier ni réseau, et les
tests l'éprouvent ; le câblage est dans `activate.mjs`, le client partagé
dans `src/lib/feedback.ts` — sans le mot « licence » dans ses chaînes, pour
le jour où l'extension s'en servira sans permission d'hôte.

Ce que la skill prescrit à l'agent : regarder le tableau avant d'écrire,
signaler ce qui l'a bloqué, une fois par problème, sans donnée client, et
ne pas retenter avant le délai que dit le refus.

**Le tableau public, et le tri.** Tout retour reçu s'affiche sur
`/tickets.html` — et par `GET /api/tickets`, ou `gotcha tickets` — dès
son arrivée, par catégorie, avec sa date et le statut « reçu ». C'est de
la transparence sur le délai autant que sur la réponse. Une fois lu, le
tri écrit un **titre et un résumé publics** et une décision : `accepted`
(bug confirmé, sélecteur faisable, amélioration retenue), `done` avec la
version qui a livré, `duplicate` vers l'original, `declined` (hors
périmètre, infaisable, ou pas une amélioration — le titre dit lequel),
`needs_info` (pas reproduit). Un statut par nuance aurait été une case de
plus à cocher, pas une réponse de plus. **Le message lui-même n'est
jamais publié**, ni l'adresse, ni la licence : le premier volet promettait
« ce que vous avez tapé, et rien d'autre », et publier les messages
l'aurait trahi. Le serveur refuse d'ailleurs une décision dont le texte
public porte une adresse ou un lien (`personal`).

Les décisions vont dans `/data/triage.jsonl`, un second fichier append-only
à côté du premier : ce que les gens ont écrit reste tel quel, ce qu'on en
a décidé se relit dans l'ordre, et **la dernière ligne par ticket fait
foi** — `new` rouvre. Le tableau se recalcule à chaque lecture depuis les
deux fichiers, quelques kilo-octets sous un cache d'une minute : pas
d'état en mémoire, et un fichier restauré est vu sans redémarrage, comme
le registre des révocations. Une note interne accompagne chaque décision ;
elle ne sort jamais du service.

Le tri passe par deux routes sous **jeton d'opérateur** —
`GET /api/admin/tickets`, `POST /api/admin/triage` —, comparé en temps
constant, posé dans le même fichier de secrets que les clés Stripe. Ce
n'est pas un compte : il n'ouvre que le tri, et sans lui les routes
répondent fermées pendant que le tableau public reste servi. Sur le poste,
`npm run tickets -- list` et `npm run tickets -- decide` en font l'outil
de qui trie ; la skill de projet `.claude/skills/triage-tickets` (en
français, hors du paquet npm) donne à un agent le processus, dans l'ordre :
déjà signalé, déjà livré, hors périmètre par construction, bug reproduit,
sélecteur faisable, amélioration retenue — et lui interdit de réparer
dans la foulée : le tri qualifie, Étienne décide.

### Ce qui est nommé, et ce qui ne l'est plus

Le Web Store interdit d'obfusquer le code et autorise explicitement le
renommage. La minification efface les noms de variables mais **pas les
chaînes** : le paquet livré annonçait donc `assets/license-store-….js`, les
clés de stockage `license` et `usage`, et une trentaine de clés `license_*`.
Le premier `grep -ri licen` posait le lecteur sur le point de contrôle.

Corrigé sans rien dissimuler : `chunkFileNames` neutralise les noms de
chunks, les clés de stockage sont `profile` / `tally` / `registry`, les clés
d'interface sont préfixées `plan_`, et `Verdict.license` est devenu
`Verdict.state`. Il ne reste dans `dist/` que les bandeaux MIT de React et
les textes affichés à l'utilisateur, qui n'ont pas à mentir.

Le CLI garde ses noms explicites : npm distribue le source, `--help`
documente `GOTCHA_LICENSE_KEY`, et masquer y coûterait de la clarté sans
rien retirer à personne.

Rien de tout cela n'empêche de modifier le paquet — à qui tient le code, on
n'oppose pas du code. Le registre, lui, agit **après** : sur la clé qui
circule.

## Limites connues

- **Le son des autres applications ne peut pas être capturé.** `tabCapture`
  n'accède qu'au son de l'onglet. `getDisplayMedia({audio:true})` ne capture
  l'audio système que sous Windows et ChromeOS — sous Linux et macOS, la case
  n'existe pas. Aucun code d'extension ne contourne cette limite.
- **Rien hors du navigateur.** La portée est l'onglet, par choix : cela évite le
  sélecteur de partage natif à chaque enregistrement. La pipette suit la même
  frontière : elle prélève dans le rendu de la page, pas dans l'interface de
  Chrome ni sur le reste de l'écran.
- **La pipette prélève sur un cliché, pas en direct.** Il est pris à l'entrée
  dans le mode, et la page est retenue le temps du geste : une animation qui
  continue sous le pointeur ne s'y reflète pas. Rafraîchir en continu est hors
  de portée — `captureVisibleTab` est contingenté à deux appels par seconde.
- **Les menus natifs n'apparaissent dans aucune capture.** `<select>` déroulé,
  menu contextuel, autocomplétion : ce sont des fenêtres du système, pas du
  rendu de l'onglet. Le délai avant capture ne peut rien y changer.
- **Une zone plus haute que le viewport est refusée.** `captureVisibleTab` ne
  voit que la partie visible ; accepter produirait une image tronquée en
  silence.
- **Après une navigation vers une autre origine, le cadre rouge ne revient
  pas.** Chrome révoque alors l'octroi `activeTab`, et le projet ne demande
  aucune permission d'hôte. L'enregistrement se poursuit normalement ; seul
  le repère visuel et la trace du curseur manquent pour cette page.
- **Le GIF est plafonné à 800 px de large et 12 i/s.** Au-delà, le poids
  dépasse les limites de pièce jointe des outils de support.
- **Vulnérabilités npm `vite`/`esbuild`** : elles concernent le serveur de
  développement, pas le bundle livré. Vite 5.4.21 est la dernière de sa ligne ;
  monter en version majeure demanderait de valider CRXJS 2.7 avec.

## Structure

```
src/
├── background/   service worker : orchestration, capture image, état
├── content/      overlay de cadrage, trace du curseur (injecté à la demande)
├── offscreen/    MediaRecorder et mixage audio
├── controller/   fenêtre de contrôle d'enregistrement
├── popup/        lancement
├── editor/       éditeurs image et vidéo, rendu, historique
├── library/      bibliothèque
├── options/      réglages
├── i18n/         catalogues de traduction et fonction t()
├── lib/          types, IndexedDB, messagerie, nommage, export, couleur
│   └── encode/   extraction d'images, curseur, GIF, MP4/WebM
└── ui/           jetons de style et icônes

cli/              Gotcha en ligne de commande, pour les agents
├── index.ts      commandes
├── session.ts    navigateur détaché, reconnexion CDP
├── annotate.ts   sélecteurs CSS → rectangles → annotations
├── shot.ts       cadrage, capture, composition
└── render-page.ts  rendu exécuté dans le navigateur (réutilise editor/render)

skills/gotcha/    la skill Claude Code

public/
└── _locales/     engendré depuis src/i18n — ne pas éditer à la main
```
