# Scénarios de test — écriture concurrente & fermeture de l'éditeur
Sessions du 2026-07-22. Tous les tests sont **manuels** (ils portent sur des
interactions UI que le harness `node:test` ne couvre pas). Rebuild obligatoire
avant de commencer : `delphi-backend\assets\BuildAssets.cmd` puis build Delphi.
Convention : **PRÉ** = état de départ, **ACTION**, **ATTENDU**. Un test qui
échoue = régression, noter le commit courant.
---
## Les 4 classes de bug
| # | Classe | Cause racine | Corrigé par |
|---|---|---|---|
| **A** | Écriture extérieure écrasée au Save | L'éditeur garde le payload chargé à l'ouverture et le re-poste **entier** ; toute écriture faite ailleurs entre-temps est perdue | `editorAbsorbChange` (d146fbe) |
| **B** | Éditeur fermé sans garde-fou | `closeSlideOver()` appelé sans condition + click-outside non filtré | `closeEditorForDeleted` (3dbce78), whitelist (e1ddc65) |
| **C** | PUT `/entries` sans `withEncryptedMeta` | Les colonnes en clair sont droppées côté serveur → le body ne contient rien de stockable et blanchit les `*_enc` | d146fbe |
| **D** | UI construite sans regarder la vue | Le menu kebab est partagé entre vues ; la corbeille n'était pas testée | d146fbe |
---
## A — Éditeur ouvert vs écriture extérieure
Préparer : 3 entrées `A`, `B`, `C`, dont `A` dans le dossier `Work` avec le tag
`old`.
### A1 · batch Add tag sur l'entrée éditée
- **PRÉ** : `A` ouverte dans l'éditeur, taper du texte dans NOTE/PASSWORD (ne pas sauver)
- **ACTION** : cocher `A` + `B` → barre de sélection → *Add tag* → `shared` → puis **Save** dans l'éditeur
- **ATTENDU** : le chip `shared` apparaît **immédiatement** dans l'éditeur ; après Save, `A` porte `old, shared` **et** la modif tapée. Le bouton Save reste visible (l'entrée était déjà dirty) mais le tag seul ne rend pas dirty.✅
### A2 · batch Add tag sur une **autre** entrée
- **PRÉ** : `A` ouverte + dirty
- **ACTION** : cocher `B` + `C` seulement → *Add tag* → `x` → Save dans l'éditeur
- **ATTENDU** : l'éditeur ne bouge pas (aucun chip ajouté), `A` conserve ses tags, `B`/`C` taguées.✅
### A3 · batch Move to folder sur l'entrée éditée
- **ACTION** : `A` ouverte + dirty → cocher `A` → *Move to folder…* → `Perso` → Save
- **ATTENDU** : le select Folder de l'éditeur bascule sur `Perso` tout seul ; après Save l'entrée est bien dans `Perso`.✅
### A4 · dossier choisi dans l'éditeur **puis** move extérieur
- **ACTION** : `A` ouverte, changer Folder → `Archive` (ne pas sauver) → cocher `A` → *Move to folder…* → `Perso` → Save
- **ATTENDU** : `Perso` gagne (action extérieure = intention la plus récente). Comportement documenté, pas un bug. ✅
### A5 · glisser une entrée sur un dossier (`moveEntryToFolder`)
- **ACTION** : `A` ouverte + dirty → glisser la ligne `A` sur `Perso` dans la sidebar → Save
- **ATTENDU** : identique à A3.✅
### A6 · glisser une **multi-sélection** sur un dossier (`moveEntriesToFolder`) ⚠️
- **ACTION** : cocher `A` + `B` → glisser sur `Perso` → recharger (F5 impossible : relancer l'app)
- **ATTENDU** : dossier changé **et** nom / username / site / tags **intacts**.
- **Historique** : ce chemin n'appelait pas `withEncryptedMeta` → il blanchissait les métadonnées chiffrées. Si des entrées ont été déplacées ainsi avant le fix, elles sont peut-être déjà vides — vérifier.✅
### A7 · suppression du dossier de l'entrée éditée
- **ACTION** : `A` (dossier `Work`) ouverte + dirty → sidebar → supprimer le dossier `Work` → Save
- **ATTENDU** : le select de l'éditeur passe à `All` ; après Save l'entrée est dans `All`, le dossier supprimé **ne réapparaît pas**.✅
### A8 · tags édités des deux côtés
- **ACTION** : `A` ouverte, ajouter le chip `mine` dans l'éditeur (ne pas sauver) → batch *Add tag* `shared` sur `A` → Save
- **ATTENDU** : `old, mine, shared` — aucun des trois perdu.✅
### A9 · favori / épingle pendant l'édition (non-régression)
- **ACTION** : `A` ouverte + dirty → cliquer ★ et 📌 sur la ligne `A` → Save
- **ATTENDU** : l'entrée reste favorite et épinglée (ces colonnes ne sont pas dans le PUT de l'éditeur).✅
### A10 · icône / pièce jointe pendant l'édition (non-régression)
- **ACTION** : `A` ouverte + dirty → Settings → *Fetch missing* favicons (ou upload d'icône) → Save
- **ATTENDU** : l'icône survit au Save (endpoint dédié, hors payload éditeur).✅
### A10b · icône rafraîchie dans l'éditeur ouvert
- **ACTION** : `A` ouverte → Settings → *Fetch missing* / *Re-fetch all* favicons
- **ATTENDU** : l'aperçu d'icône **dans l'éditeur** se met à jour tout seul (pas seulement la carte). Idem après un import en mode overwrite qui apporte une icône.✅
### A11 · sync / import pendant que l'éditeur est ouvert
- **ACTION** : `A` ouverte + dirty → lancer *Sync now* (ou importer un JSON en mode overwrite) qui met `A` à jour → Save
- **ATTENDU** : modale « Entry changed elsewhere ». **Annuler ⇒ rien n'est écrit** (la version du sync/import survit, l'éditeur reste ouvert le temps de recopier). **Overwrite ⇒** la version de l'éditeur gagne.✅
### A12 · pas de fausse alerte (non-régression d'A11)
Aucun de ces enchaînements ne doit afficher « Entry changed elsewhere » :
- **A12a** : `A` ouverte + dirty → Save → modifier encore → Save (le rechargement post-save re-cale la référence)
- **A12b** : `A` ouverte + dirty → batch *Add tag* / *Move to folder* sur `A` → Save (l'éditeur a absorbé le changement)
- **A12c** : `A` ouverte + dirty → supprimer son dossier → Save (cf. A7)
- **A12d** : `A` ouverte + dirty → *Fetch missing* favicons → Save (l'icône est hors payload éditeur)
- **A12e** : `A` ouverte + dirty → sync **sans** changement distant sur `A` → Save
### A13 · entrée supprimée ailleurs pendant l'édition
- **ACTION** : `A` ouverte + dirty → un sync distant supprime `A` (tombstone) → Save
- **ATTENDU** : pas de plantage. L'entrée n'est plus en mémoire → le contrôle A11 passe la main ; le PUT échoue proprement avec un toast d'erreur.
---
## B — Fermeture de l'éditeur & message de discard
Le message n'apparaît que si `confirmOnUnsaved` est actif **et** que l'entrée
est dirty. Tester avec `askBeforeDelete` **ON puis OFF** (Settings → Security).
### B1 · ✕ sur une **autre** ligne
- **PRÉ** : `A` ouverte + dirty
- **ACTION** : cliquer ✕ (Move to trash) sur la ligne `B`
- **ATTENDU** : `B` part à la corbeille, l'éditeur de `A` **reste ouvert** avec les modifs. Vrai dans les deux réglages de `askBeforeDelete`.
### B2 · ✕ sur la ligne éditée, `askBeforeDelete` = OFF
- **ACTION** : `A` ouverte + dirty → ✕ sur `A`
- **ATTENDU** : modale « Discard unsaved changes? ». **Annuler ⇒ rien n'est supprimé** et l'éditeur reste ouvert. Confirmer ⇒ `A` à la corbeille + éditeur fermé.
### B3 · ✕ sur la ligne éditée, `askBeforeDelete` = ON
- **ATTENDU** : **une seule** modale (« Move to trash »). Pas de double prompt.
### B4 · batch Delete incluant l'entrée éditée
- **ACTION** : `A` ouverte + dirty → cocher `A` + `B` → *Delete*
- **ATTENDU** : une seule modale (« 2 entries will be moved to trash »), puis l'éditeur se ferme.
### B5 · batch Delete **excluant** l'entrée éditée
- **ATTENDU** : l'éditeur reste ouvert, modifs intactes.
### B6 · corbeille
- **ACTION** : ouvrir une entrée de la corbeille via *Trash actions*, puis : (a) *Delete forever* dessus, (b) batch purge la contenant, (c) *Empty trash*
- **ATTENDU** : dans les trois cas l'éditeur se ferme s'il portait une entrée purgée, sinon il reste.
### B7 · boutons de la barre de sélection
- **ACTION** : `A` ouverte + dirty → cocher `B` → cliquer successivement *Move to folder…*, *Add tag*, *Clear*
- **ATTENDU** : **aucun** de ces clics ne ferme l'éditeur ni n'affiche de discard.
### B8 · bouton *Empty trash* (barre de titre)
- **ATTENDU** : idem, pas de fermeture parasite de l'éditeur.
### B9 · chemins de fermeture légitimes (non-régression)
- Esc, ✕ de l'éditeur, clic sur le fond de la grille, ouverture d'une **autre** entrée
- **ATTENDU** : message de discard à chaque fois si dirty ; annuler garde l'éditeur ouvert sur la bonne entrée.
### B10 · verrouillage (comportement assumé)
- **ACTION** : `A` ouverte + dirty → auto-lock ou *Lock vault*
- **ATTENDU** : fermeture **sans** message, modifs perdues. Volontaire : la sécurité prime.
### B11 · Esc au-dessus d'un modal ouvert par-dessus l'éditeur
- **ACTION** : `A` ouverte + dirty → ouvrir un modal → Esc → Esc
- **ATTENDU** : 1ᵉʳ Esc ferme **le modal seul**, l'éditeur reste ouvert ; 2ᵉ Esc traite l'éditeur (discard).
- Modals à passer un par un : **+ New folder** (sidebar), **Customize folder**, *Change master password*, générateur de mot de passe, cheatsheet (`?`), historique de mot de passe, palette (`Ctrl+K`), outil TOTP, quick search (`Ctrl+Shift+Q`), **reauth** (déclenché par un export), **picker d'autofill** (`Ctrl+Shift+L` avec 2 entrées qui matchent).
- **Historique** : `#folderModal` (dynamique) n'était dans aucune liste → Esc fermait l'éditeur derrière. `#reauthModal`, `#quickSearchModal`, `#autofillPickerModal` (statiques) non plus.
### B12 · le modal reste fonctionnel après un Esc
- **ACTION** : ouvrir reauth (export) → Esc → **ré-ouvrir** (relancer l'export). Idem quick search et picker d'autofill.
- **ATTENDU** : le modal se ré-affiche normalement. Un Esc ne doit jamais **supprimer** un modal statique du DOM (sinon la fonctionnalité est morte jusqu'au redémarrage).
### B13 · clic sur un toast / l'overlay d'attente
- **ACTION** : `A` ouverte + dirty → lancer une action longue (export, sync) → cliquer sur le toast ou sur le spinner
- **ATTENDU** : l'éditeur ne se ferme pas, pas de discard.
### B14 · dialogues natifs ouverts par un clic programmatique
- **ACTION** : `A` ouverte + **dirty** → (a) Settings → *Import vault* ; (b) télécharger une pièce jointe (fallback web) ; (c) *Upload icon* ; (d) joindre un fichier.
- **ATTENDU** : **seul** le dialogue natif s'ouvre. Pas de « Discard unsaved changes? » par-dessus, l'éditeur reste ouvert et modifié.
- **Historique** : le `` (et le ``) est attaché à `` puis `.click()` → le clic synthétique remontait au handler click-outside qui le prenait pour un clic dehors. Gardé par `e.isTrusted`.
---
## F — Round-trip export / import (drapeaux & provenance)
### F1 · favori + épingle survivent au restore
- **F1a — entrée supprimée puis réimportée** ✅ : épingler **et** mettre en favori une entrée → *Export vault* → supprimer l'entrée (corbeille + purge) → *Import vault*.
**ATTENDU** : elle revient **épinglée et favorite** (chemin INSERT / bulk-import).
- **F1b — import en mode écrasement** ✅ : même export, mais **sans** supprimer l'entrée. Retirer l'épingle et le favori localement, réimporter, choisir **Overwrite**.✅
**ATTENDU** : les deux drapeaux du fichier sont **réappliqués**.
- **Historique** : `pinned` n'était dans aucun container ; `favorite` y était écrit mais **jamais relu** (ni parser, ni body, ni INSERT) → les deux perdus au restore. Puis F1a a marché sans F1b : le mode écrasement passe par `PUT`, qui ignore volontairement `favorite`/`pinned` pour qu'un **sync** ne puisse jamais écraser les drapeaux locaux (device-local par design). L'import les applique donc via les endpoints de bascule dédiés, uniquement quand ils diffèrent.
### F2 · même chose depuis une auto-backup
- **ACTION** : *Backup now* → supprimer l'entrée → importer le fichier `vault-autobackup-*.json`.
- **ATTENDU** : identique à F1.
### F3 · CSV étranger → drapeaux à 0
- **ACTION** : importer un CSV sans colonnes favorite/pinned.
- **ATTENDU** : entrées importées ni épinglées ni favorites (défaut 0), pas d'erreur.
### F4 · le sync ne propage PAS les drapeaux (device-local, décidé 2026-07-11)
- **ACTION** : épingler/favoriser une entrée sur le device A → *Sync now* → *Sync now* sur le device B.
- **ATTENDU** : sur B, l'entrée **n'est pas** épinglée/favorite. Et un sync ne doit jamais **retirer** un drapeau posé localement sur B (le PUT serveur ne touche pas ces colonnes).
### F5 · icône custom : provenance préservée ✅
- **ACTION** : upload d'une icône custom → export → supprimer l'entrée → import → puis **Re-fetch all**.
- **ATTENDU** : l'icône custom revient **et** survit au Re-fetch all (le flag `icon_custom` a fait le round-trip).
---
## C — `withEncryptedMeta` sur tout write `/entries`
Test générique, à rejouer après **toute** modif touchant un body POST/PUT
`/entries` : faire l'action, **relancer l'app** (pour relire depuis SQLite),
vérifier que nom / site / username / tags s'affichent encore.
Chemins à couvrir : Save éditeur (neuf + existant), duplicate, import
(bulk + overwrite), sync (`applyRemoteSnapshot`), `addTagToEntry`,
`batchAddTag`, `moveEntryToFolder`, `moveEntriesToFolder`, `batchMoveToFolder`,
`migrateMetadataAtRest` (au unlock), rotation du master password.
Symptôme du bug : après relance, lignes au nom vide / `(no name)`.
---
## D — Vue corbeille
### D1 · menu kebab en table view
- **ACTION** : corbeille + vue table → kebab d'une ligne
- **ATTENDU** : `Restore` / `Copy…` / `Delete forever` **uniquement**. Pas de Pin / Favorite / Edit / Duplicate / Move to trash.
### D2 · vues cartes et liste
- **ATTENDU** : boutons restore + purge (déjà le cas avant), pas de drag possible.
### D3 · clic sur une ligne de corbeille
- **ATTENDU** : ouvre *Trash actions*, **jamais** l'éditeur (une entrée supprimée ne doit pas pouvoir être sauvegardée).
---
## E — Auth / recovery / mémoire (série 2026-07-25)
Ces flux touchent la dérivation de clé et l'anti-lockout : tester sur un compte
**argon2id-v2** (défaut courant). Rebuild **Delphi** requis (fixes serveur) +
BuildAssets pour le JS.
### E1 · reauth — mauvais master pw reste inline (les 4 flux) ✅
- **ACTION** : sur chacun de *Set/Change PIN*, *Enable Quick Unlock*,
*Generate recovery code*, *Export* → taper un **mauvais** master pw.
- **ATTENDU** : le modal **reste ouvert** avec « Wrong master password. Attempt
N / 5. » ; au 5ᵉ échec ou 429 → toast lockout + fermeture. **Annuler** à tout
moment = action abandonnée, rien fait.
- **Historique** : PIN / Quick Unlock / recovery fermaient + toast au 1ᵉʳ essai.
### E2 · recovery setup sur compte argon2id-v2 ✅
- **ACTION** : *Generate recovery code* → bon master pw.
- **ATTENDU** : code généré, **pas** de « invalid password ».
- **Historique** : `/recovery-key/setup` rejetait tout compte -v2 / argon2id
(vérif serveur ne connaissait que pbkdf2 / pbkdf2-sha256).
### E3 · recovery generate → annuler n'écrit rien ✅
- **PRÉ** : un code de recovery **existe déjà**.
- **ACTION** : *Regenerate code* → bon master pw → à l'affichage du code,
**Cancel** (ou Esc).
- **ATTENDU** : toast « Cancelled — no recovery code was set » ; l'**ancien**
code fonctionne toujours au redeem. Refaire avec **I saved it** → nouveau
code actif, ancien invalidé.
- **Historique** : le setup était POSTé avant l'affichage → annuler écrasait
quand même l'ancien code (chemin silencieux vers le lockout).
### E4 · recovery redeem sur compte argon2id-v2 ✅
- **ACTION** : générer un code, se déconnecter, *Use a recovery code* →
username + code.
- **ATTENDU** : déverrouillage OK, redirection vers *Change master password*.
Le coffre se déchiffre (clé dérivée avec les **bons** params Argon2 renvoyés
par le serveur).
- **Historique** : la réponse redeem n'echoait pas `argon2` → params par
défaut (correct aujourd'hui, lockout latent si params non-défaut).
### E5 · la clé du coffre n'est plus en clair dans le web storage
- **ACTION** : coffre déverrouillé → console WebView2 :
`sessionStorage.getItem('cryptoKey')`.
- **ATTENDU** : `null`. (Avant : une string base64 de 44 car = la clé AES.)
- **Bonus** : dump mémoire du renderer → `this_is_password` peut persister
(strings V8, non corrigeable) ; c'est la **clé** qu'on a retirée du storage.
### E6 · non-régression — les 4 exporteurs de clé marchent toujours
Après le retrait du cache sessionStorage, re-tester que ces flux (qui
`exportKey` la clé) fonctionnent : **login**, **Quick Unlock**, **PIN**,
**recovery redeem**, **change master password**.
### E7 · non-régression — éditeur slideover après suppression du modal mort
Le générateur, les tags et la barre de force étaient partagés. Vérifier dans
l'éditeur slideover : créer + éditer une entrée, ajouter/retirer des tags,
**Generate → Use** insère le mot de passe, la barre de force réagit.
---
## G — Vault health (série 2026-07-25)
### G1 · bouton *Fix* quand le changement d'entrée est annulé
- **ACTION** : `A` ouverte + **dirty** → aller sur *Vault health* → cliquer **Fix** sur une **autre** entrée `B` → **Cancel** au message discard.
- **ATTENDU** : **rien ne bouge**. `A` reste ouverte, son mot de passe reste **masqué**, aucun clignotement du bouton Generate.
- Puis refaire → **Discard** : `B` s'ouvre, c'est **le mot de passe de B** qui est démasqué + sélectionné, et c'est le Generate de `B` qui pulse.
- **Le focus RESTE sur le mot de passe** (~1 s après le clic, vérifier que le curseur n'a pas sauté sur le titre) : `openSlideOver` programme un focus différé de 80 ms sur `#soTitle`, neutralisé ici par `{ noFocus: true }`.
- **Historique** : `openSlideOver` ne renvoyait rien, donc `openEntryForFix` continuait après une annulation et démasquait en clair le mot de passe de l'entrée **restée ouverte**.
### G2 · *Fix* sur une entrée supprimée entre-temps
- **ACTION** : ouvrir *Vault health* → dans un autre onglet de l'app, mettre à la corbeille une entrée listée → revenir sur health (sans Recompute) → **Fix** sur cette entrée.
- **ATTENDU** : rien ne casse (l'éditeur ne s'ouvre pas, pas d'erreur JS). Avec G3 la liste devrait de toute façon être à jour.
### G3 · le tableau de bord reflète les mutations
- **ACTION** : ouvrir *Vault health* (noter le score) → revenir à la liste → faire une mutation : **import**, **sync**, **suppression**, **batch delete**, **duplicate**, **restore**, ou **changement de master pw** → rouvrir *Vault health*.
- **ATTENDU** : score et listes **recalculés** (les entrées supprimées ont disparu, les importées apparaissent) **sans** cliquer *Recompute*.
- **Historique** : le cache n'était vidé qu'au lock / au save / via *Recompute* — après un import ou une suppression le dashboard restait figé sur l'état précédent.
---
## H — Hotkey 2FA (`Ctrl+Shift+T`, série 2026-07-25)
### H1 · remplissage simple ✅
- **ACTION** : entrée avec TOTP → ouvrir son site → **cliquer le champ 2FA** → `Ctrl+Shift+T`.
- **ATTENDU** : le code à 6 chiffres est tapé dans le champ focalisé, toast « 2FA code filled: … ».
### H2 · entrée sans TOTP
- **ACTION** : fenêtre dont le titre matche une entrée **sans** secret TOTP → `Ctrl+Shift+T`.
- **ATTENDU** : « No 2FA code stored for "…" » (et **pas** le « no match » générique, ni un remplissage du mot de passe).
### H3 · picker multi-match
- **PRÉ** : 2 entrées matchent le titre, **une seule** a un TOTP.
- **ATTENDU** : le picker s'ouvre uniquement s'il reste ≥ 2 candidats **avec** TOTP ; ici il ne doit pas s'ouvrir (1 seul candidat éligible → remplissage direct). En-tête du picker : « Pick entry (2FA code) — ».
### H4 · combo configurable
- **ACTION** : Settings → Autofill → cliquer le bouton `Ctrl+Shift+T` → capturer un autre combo.
- **ATTENDU** : accepté et actif immédiatement ; **refusé** s'il duplique un des 4 autres hotkeys ; *Reset to defaults* le remet à `Ctrl+Shift+T` ; le réglage survit à un redémarrage (synced via `settings_json`).
### H5 · coffre verrouillé
- **ACTION** : verrouiller → `Ctrl+Shift+T`.
- **ATTENDU** : l'app revient au premier plan avec le focus sur le master password (même comportement que les autres hotkeys), pas de no-op silencieux.
### H7 · combo déjà pris par une autre application ✅
- **ACTION** : assigner à un hotkey un combo qu'une autre app tient déjà en global (ex. `Ctrl+Shift+Q` si Quick Assist / un lanceur l'occupe ; sinon, tester en assignant le même combo dans deux apps).
- **ATTENDU** : toast **nommant le combo refusé** — « Already used by another app: 2FA code — pick another combo » — **et** un ⚠ ambre persistant sur cette ligne de Settings, **et** une pastille ambre sur l'entrée *Settings* de la sidebar (seul indice hors du panneau). Le toast d'avertissement reste ~10 s et se ferme au clic. Les **autres** hotkeys restent fonctionnels (enregistrement best-effort, pas de tout-ou-rien).
- **ACTION 2** : fermer l'app concurrente → *Reset to defaults* (ou re-capturer le combo) → le hotkey redevient actif **et le ⚠ disparaît**.
- **Piège de test** : une app qui capture via un *hook* bas niveau (et non `RegisterHotKey`) avale la frappe sans faire échouer notre enregistrement → hotkey inerte **sans** ⚠. Utiliser AutoHotkey (`^+t::MsgBox`) ou Lightshot pour un vrai conflit.
- **Historique** : le message disait « one or both hotkeys » (faux depuis qu'il y en a 3) sans dire lequel ; et un échec sur **Quick search** ou **New entry** n'était écrit que dans le panel debug — invisible en prod, le hotkey semblait juste cassé.
### H8 · capturer un combo déjà pris par une autre app ✅
- **ACTION** : Settings → cliquer le bouton du hotkey 2FA → presser un combo qu'une autre app détient (ex. `Ctrl+Shift+T`).
- **ATTENDU** : **au relâchement** (pas après un délai), toast « No key registered after Ctrl+Shift. If you did press one, another app already owns that combo — pick a different one. » **et** le bouton reprend son libellé précédent.
- **ACTION 2** : presser un combo libre (ex. `Ctrl+Win+T`) → accepté, actif immédiatement, pas de ⚠.
- **ACTION 3** : cliquer le bouton puis ne rien presser pendant 10 s → « Hotkey capture cancelled — no key was pressed. » et le bouton se restaure (sinon la capture avalerait toutes les frappes jusqu'à Échap).
- **ACTION 4** : cliquer le bouton puis presser un combo **déjà assigné à un autre de nos slots** (ex. `Ctrl+Shift+L`) → « That combo is already used by another hotkey ». Doit fonctionner **sans** déclencher l'autofill : nos 5 hotkeys sont libérés pendant la capture.
- **Historique** : un combo tenu par `RegisterHotKey` (le nôtre **ou** celui d'une autre app) n'atteint jamais WebView2 → la capture restait figée sur « Press combo… » sans message, et la vérif de collision interne était du code mort.
### H6 · code en fin de période
- **ACTION** : presser le hotkey juste avant la fin du compte à rebours de 30 s.
- **ATTENDU** : le code tapé est celui de la période **courante** au moment de la frappe (généré à la demande, pas mis en cache).
---
## I — État de la fenêtre (maximisé, série 2026-07-25)
L'app démarre maximisée (`WindowState = wsMaximized` dans la `.fmx`).
### I1 · maximisé survit à Show desktop ✅
- **ACTION** : fenêtre **maximisée** → `Win+D` (ou *Show desktop*) → revenir à l'app (barre des tâches, tray ou Alt+Tab).
- **ATTENDU** : elle revient **maximisée**.
- **Historique** : FMX oublie l'état maximisé quand Windows minimise la fenêtre de l'extérieur, et aucun de nos chemins de restore ne tourne pour un clic sur la barre des tâches.
### I2 · normal survit aussi ✅
- **ACTION** : dé-maximiser la fenêtre → `Win+D` → revenir.
- **ATTENDU** : elle reste en **taille normale** — le correctif ne doit jamais forcer le maximise.
### I3 · maximiser / dé-maximiser reste enregistré
- **ACTION** : dé-maximiser → `Win+D` → retour (normal) → maximiser → `Win+D` → retour.
- **ATTENDU** : chaque retour respecte le **dernier geste** de l'utilisateur.
### I4 · retour depuis le tray
- **ACTION** : fenêtre maximisée → fermer (part au tray) → clic sur l'icône du tray.
- **ATTENDU** : revient maximisée (chemin distinct : `RestoreFromTray` rejoue son propre placement).
### I5 · le garde de minimisation ne reste pas bloqué (autofill → tray) ⚠️
Le chemin le plus subtil : `ExecuteAutofill` **minimise réellement** la fenêtre
(`SW_MINIMIZE`) avant de taper, ce qui lève le garde ; si elle est ensuite
**cachée** dans le tray au lieu d'être restaurée, `MINIMIZEEND` peut ne jamais
arriver.
- **ACTION** : depuis le tray, `Ctrl+Shift+Q` → choisir une entrée → laisser le
remplissage se faire (l'app repart au tray) → rouvrir l'app par l'icône du
tray → **dé-maximiser** (ou maximiser) → `Win+D` → revenir.
- **ATTENDU** : le dernier geste est respecté. S'il ne l'est pas, le garde est
resté levé et le suivi de l'état est gelé pour toute la session.
- **Variante** : même chose avec `Ctrl+Shift+L` depuis une autre application.
### I6 · le hook n'affecte pas la corbeille ni le tray (non-régression)
- **ACTION** : minimiser dans le tray (fermeture), restaurer ; `app/focus` via
le picker d'autofill multi-match ; « Lock vault » depuis le menu du tray.
- **ATTENDU** : comportements inchangés. Le hook n'écoute que **notre** process
et `MinimizeToTray` utilise `SW_HIDE`, qui n'émet aucun événement de
minimisation.
### Diagnostic
Avec `config.txt` contenant `debug=true`, `Ctrl+Shift+D` affiche le journal.
Lignes utiles : `[winstate] minimise hook installed=True` au démarrage (un
`False` signifierait que le hook Win32 ne s'installe pas → préservation
inactive) et `[winstate] re-maximising after restore` quand la correction agit.
---
## J — Quick search depuis le tray (série 2026-07-25)
Le flux le plus fragile : la fenêtre revient du tray, un modal s'ouvre, et la
fermeture doit **renvoyer l'app au tray**. Deux `cmd://` s'y enchaînent, ce qui
est précisément le piège de coalescence documenté dans CLAUDE.md.
### J1 · Esc dès l'ouverture, sans cliquer ✅
- **PRÉ** : app **dans le tray**, coffre déverrouillé.
- **ACTION** : `Ctrl+Shift+Q` → **ne rien cliquer** → `Esc` immédiatement.
- **ATTENDU** : le modal se ferme **du premier coup**, et l'app **repart au tray**.
- **Historique** : deux bugs distincts ici. (1) WebView2 n'avait pas le focus
clavier de l'OS après la restauration → aucune frappe n'atteignait la page,
Esc ne faisait rien tant qu'on n'avait pas cliqué dans le champ. (2) En mode
fill, `cancelAutofill` puis `minimizeToTray` = deux `window.location.href`
consécutifs → **coalescés**, la minimisation était perdue.
### J2 · remplissage puis retour au tray ✅ ✅
- **ACTION** : app dans le tray → `Ctrl+Shift+Q` → choisir une entrée (Enter).
- **ATTENDU** : le mot de passe est tapé dans la fenêtre cible **et** l'app
repart au tray — **une seule fois** (c'est Delphi qui minimise ici, via
`hide_after`, pas le JS : vérifier qu'il n'y a pas de double minimisation).
- **Variantes** : `Shift+Enter` (user+pwd), `Ctrl+Enter` (username seul).
### J3 · copie depuis le menu du tray ✅
- **ACTION** : menu du tray → *Quick search…* → choisir une entrée (Enter).
- **ATTENDU** : mot de passe copié, app de retour au tray, et le presse-papiers
**conserve** la valeur (`keepclip=1` — c'est le rôle du paramètre).
### J4 · app déjà visible (non-régression) ✅
- **ACTION** : app **ouverte à l'écran** → `Ctrl+Shift+Q` → `Esc`.
- **ATTENDU** : le modal se ferme et l'app **reste à l'écran** — elle ne doit
**pas** partir au tray (elle n'en venait pas). Idem si elle était seulement
minimisée dans la barre des tâches.
---
## K — Autofill : comportements intentionnels (à ne pas « corriger »)
### K1 · « Clear the field before typing » désactivé → le password est quand même remplacé
- **ACTION** : Settings → Autofill → décocher *Clear the field before typing*.
Puis remplissage complet (`Ctrl+Shift+L`, ou clic-droit dans la quick search)
sur un formulaire dont les deux champs contiennent déjà quelque chose.
- **OBSERVÉ** : le **username** n'est pas effacé (la frappe s'insère), mais
l'ancien **password** disparaît et est remplacé.
- **C'EST NORMAL** — vérifié de bout en bout : le JS envoie `&clear=0`, Delphi
met `FAutofillPendingClear := False`, et les **deux** `ClearFieldIfWanted`
(avant username et avant password) sortent immédiatement. Notre code
n'efface rien.
La cause est le **Tab** du pipeline `username → Tab → password` : arriver
dans un champ par Tab **sélectionne tout son contenu** (comportement standard
Windows / navigateur), et la frappe remplace la sélection. Le username, lui,
a été focalisé par un **clic** de l'utilisateur → curseur sans sélection →
insertion.
- **Ce que le réglage fait réellement** : empêcher l'envoi de `Ctrl+A`. Il
existe pour les terminaux / bureaux distants où `Ctrl+A` signifie « début de
ligne » ou déclenche le préfixe tmux, ce qui casserait le remplissage. Il ne
promet pas de préserver le contenu des champs.
- **Ne pas « corriger »** : désélectionner après le Tab ferait **concaténer** le
nouveau mot de passe à l'ancien — jamais souhaitable.
- **Test qui isole la cause** : `Ctrl+Shift+P` (password seul) en cliquant
directement dans le champ → pas de Tab → l'ancienne valeur **reste**.
---
## Reste à traiter (constaté pendant l'audit, non corrigé)
1. **Attachments hors garde A11.** Le contrôle « changed elsewhere » couvre le
payload de l'entrée, pas ses pièces jointes (table séparée, écrites par
leurs propres endpoints). Un sync qui remplace une pièce jointe pendant
l'édition n'est pas signalé. Faible risque : l'éditeur ne re-ship jamais
les pièces jointes existantes, donc il n'y a rien à écraser.