# 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. ### H9 · retour au tray après un pick dans le picker ✅ - **ACTION** : app **au tray** → `Ctrl+Shift+L` depuis une autre app sur un titre à plusieurs candidats → le picker sort la fenêtre du tray → choisir une entrée. - **ATTENDU** : le remplissage se fait puis l'app **repart au tray**, comme elle est venue. - **Variantes** : match unique (pas de picker) → reste au tray ; app déjà visible → reste visible ; app **minimisée dans la barre des tâches** → n'est **pas** envoyée au tray (`not Self.Visible` est faux pour une fenêtre iconique) ; picker fermé sans choisir → l'app reste où elle est et le hotkey suivant n'hérite pas de l'état capturé. - **Historique** : seul le chemin quick-search capturait « était cachée » (`LWasHidden`) et le faisait remonter via `hide_after` ; JS n'envoie jamais ce paramètre depuis le picker d'autofill, donc l'app restait sur le bureau. Capturé désormais côté Delphi dans `BridgeAutofillRequest`, au moment du hotkey — après, le picker a déjà détruit l'information. ### 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**, **sans passer par une frame en taille normale**. - **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. - **Historique (flash)** : une fois l'état final correct, il restait un scintillement — le cadre apparaissait en taille normale puis grandissait. **C'est un bug FMX**, reproduit sur une application FMX vide : FMX perd le maximisé quand Windows minimise le formulaire de l'extérieur. Windows choisit alors `rcNormalPosition` comme cible **avant** d'animer, donc aucune correction réactive ne peut arriver à temps — trois ont été essayées et rejetées par la mesure : `ForceQueue` (un tour de boucle trop tard), appel inline dans `MINIMIZEEND` (le hook est délivré après le `WM_SIZE`), correction dans le `WM_SIZE` lui-même (l'état devient parfait, le flash reste — preuve que le problème n'était plus l'état). Écarté aussi : poser `WPF_RESTORETOMAXIMIZED`, que `GetWindowPlacement` rapporte déjà à 2 sur une fenêtre maximisée (no-op). **Statut : non corrigé, assumé.** Une cinquième piste a été tentée puis **annulée** : garer `rcNormalPosition` et le remplacer par `rcWork` du moniteur pendant que la fenêtre est en bas, pour que « restaurer en normal » vaille déjà plein écran. Ça supprimait bien le flash, mais tout `SetWindowPlacement` sur une fenêtre en cours de minimisation — ou déjà iconique — laissait **un moignon de fenêtre dans un coin de l'écran**, y compris en différant l'écriture jusqu'à `IsIconic`. Un artefact visible permanent est pire que l'animation. Reverté. **Ce qui reste** : l'état final est correct (la fenêtre revient bien maximisée), seule l'animation de restauration est laide. Ne pas rouvrir sans une vraie raison — la seule route restante est de sous-classer le `WndProc` de la fenêtre FMX pour corriger le rectangle dans `WM_WINDOWPOSCHANGING`, ce qui insère du code dans la boucle de messages d'une fenêtre qu'on ne contrôle pas, avec le tray, l'autofill et le démarrage `-tray` en aval. ### 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**. --- ## L — Démarrage avec Windows (`-tray`, série 2026-07-26) Le flag `-tray` est ce que « Start with Windows » écrit dans `HKCU\…\Run` (`"" -tray`). Pour tester sans redémarrer : `PMServer.exe -tray` depuis le dossier de sortie. **Ne pas** laisser `-tray` dans *Run → Parameters* de l'IDE, sinon chaque F9 démarre caché. ### L1 · démarrage caché, sans bouton de barre des tâches ✅ - **ACTION** : lancer avec `-tray` (l'app n'étant pas déjà lancée — sinon le mutex single-instance fait sortir la nouvelle instance immédiatement). - **ATTENDU** : **aucune fenêtre**, **aucun bouton** dans la barre des tâches, icône présente dans le tray, pas de balloon. - **Historique** : le bouton restait 1-2 s. La fenêtre du formulaire n'était en fait **jamais** visible — c'est le proxy taskbar FMX (`ApplicationHWND`) qui s'affichait, et **avant** même que `FormCreate` soit atteint, ce qui rendait inopérant tout correctif posé depuis le formulaire. Le masquer ne suffit pas non plus : il faut retirer `WS_EX_APPWINDOW` et poser `WS_EX_TOOLWINDOW`, depuis le `.dpr`, juste après `Application.Initialize`. ### L2 · le bouton revient à la restauration ✅ - **ACTION** : après L1, clic sur l'icône du tray. - **ATTENDU** : la fenêtre s'ouvre **et** le bouton réapparaît dans la barre des tâches. - **Historique** : `WS_EX_TOOLWINDOW` est permanent — sans le rétablissement (`TrayProxyShowInTaskbar` dans `RestoreWindowFromTray`) l'app tournait ensuite sans aucune entrée de barre des tâches. ### L3 · lancement normal (non-régression) ✅ - **ACTION** : lancer **sans** `-tray`. - **ATTENDU** : fenêtre visible et bouton de barre des tâches présents, comme avant. Le correctif est entièrement gardé par le flag. --- ## 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.