Files
r-zakarya c74b708b39 fix(import): count the trash when deduplicating by uuid
The map was built from state.entries only, so a container entry whose uuid existed just in the trash looked fresh and inserted a second row sharing that uuid - the identity the sync engine relies on. Same blind spot as the resurrection bug in applyRemoteSnapshot.

Also documents F9 and warns that emptying the trash is the wrong way to clean duplicate uuids: the tombstone would kill the live twin on the other device.
2026-08-07 17:35:40 +01:00

707 lines
72 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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** — surtout pas « Saved ».
- **Recette locale, sans second appareil** : ouvrir `A` et la rendre dirty → dans DB Browser, app lancée, `DELETE FROM vault_entries WHERE id = <id>` puis **Write Changes** (sans ça le WAL ne publie rien) → dans l'app, déclencher un `loadEntries()` qui ne concerne pas `A` (mettre une **autre** entrée à la corbeille puis *Empty trash*) → Save.
- **Bug trouvé par ce test** : le serveur répondait **200 « Updated »** pour un `UPDATE` n'ayant touché **aucune** ligne — le client affichait « Saved » alors que rien n'était écrit. Perte de données silencieuse. `HandleUpdateEntry` teste maintenant `RowsAffected` et renvoie 404.
- **Même classe, non corrigé** : delete / restore / favorite / pin / set-icon répondent aussi OK sur un id inexistant. Assumé — aucun ne prétend avoir enregistré un travail de l'utilisateur, et le client recharge la liste juste après.
### A13b · récupérer la saisie quand l'entrée a disparu ✅
- **ACTION** : même mise en place qu'A13 → au Save, répondre **« Save as new entry »**.
- **ATTENDU** : une nouvelle entrée est créée avec le contenu de l'éditeur, le panneau se rouvre dessus et la carte clignote. *Cancel* → rien n'est écrit, l'éditeur reste ouvert avec la saisie.
- **Nouvel uuid, pas une résurrection** : le POST en génère un neuf, donc l'entrée recréée n'entre pas en conflit avec le tombstone laissé par la suppression — aucun risque de la voir re-supprimée au sync suivant, ni de ressusciter en boucle ce qu'un autre appareil a effacé volontairement.
- **Piège du déclenchement** : le serveur HTTP répond aussi **404** à toute requête qu'il refuse (verrou par token). La bascule teste donc `status === 404` **et** le message « no longer exists » — le statut seul confondrait une requête rejetée avec une entrée supprimée.
---
## 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 — l'éditeur ne se ferme pas pour rien ✅
**Prémisse corrigée** : on **ne peut pas** ouvrir une entrée de la corbeille dans
l'éditeur. `openTrashActions` (js/app.js) est un corps vide, et le menu kebab de
la corbeille n'a pas d'entrée *Edit* — les actions sont sur la carte. Une entrée
purgée n'est donc jamais celle qui est éditée, sauf par le chemin distant décrit
plus bas.
- **ACTION** : `A` ouverte + dirty (vue normale) → aller dans la corbeille → *Delete forever* sur une autre entrée, puis un batch *Delete forever*, puis *Empty trash*.
- **ATTENDU** : l'éditeur de `A` **reste ouvert** avec ses modifs dans les trois cas — la purge ne concerne jamais l'entrée éditée.
- **Cas limite, seul moyen d'y arriver** : `A` ouverte + dirty → un sync distant met `A` à la corbeille → *Empty trash* local → là `closeEditorForDeleted` doit fermer l'éditeur proprement (même famille qu'A13).
### 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.
### B15 · Esc pendant une opération bloquante ✅
- **ACTION** : `A` ouverte + dirty → lancer *Sync now* (ou un export, une rotation de mot de passe maître) → presser `Esc` pendant que l'overlay « Encrypting… » est affiché.
- **ATTENDU** : rien ne se passe. Pas de modale « Discard unsaved changes? ». Elle redevient possible une fois l'overlay parti.
- **Historique** : la modale s'ouvrait bel et bien, mais **sous** l'overlay (`z-index 200`) — visible en flou, à demi cliquable. Deux problèmes en un : l'affichage, et surtout le fait d'arbitrer une fermeture d'éditeur pendant qu'une opération écrit sur la même entrée. Garde `isBusy()` posé dans la chaîne Esc de l'éditeur, avec les autres priorités (palette, Settings, modales).
- **Non-régression** : le clic extérieur ne ferme déjà pas l'éditeur pendant l'attente — `#busyOverlay` est dans la liste blanche, et il couvre l'écran de toute façon.
- **Tester les DEUX déclencheurs** : *Sync now* **et** *Export*. Il y a **deux** gestionnaires `Esc` distincts — celui en phase de capture dédié à l'éditeur, et la chaîne globale « une surface par frappe » (confirm → palette → change-master → cheatsheet → … → éditeur). Le premier correctif n'en couvrait qu'un, et l'export passait par l'autre : le bug semblait persister alors qu'il avait été corrigé sur le chemin du sync.
### B16 · hotkeys globaux pendant une opération bloquante ✅
- **ACTION** : lancer un *Export* (ou *Sync now*) → pendant l'overlay, presser `Ctrl+Shift+A`, puis `Ctrl+Shift+Q`.
- **ATTENDU** : ni l'éditeur ni la quick search ne s'ouvrent ; toast « Busy — try again in a moment ». Tout redevient normal une fois l'overlay parti.
- **Pourquoi les hotkeys échappent aux gardes UI** : ils arrivent de Win32, **hors de la fenêtre**. L'overlay qui couvre l'application n'intercepte rien : le panneau s'ouvrait derrière lui, inaccessible.
- **Où est posé le garde** : dans `openSlideOver` et `openQuickSearchModal`, pas sur chaque déclencheur — c'est le point de passage de *tous* les appelants (hotkey, palette de commandes, bouton *Fix* du vault health). Sûr parce qu'**aucun chemin de sauvegarde n'affiche l'overlay** : rien de légitime n'ouvre l'éditeur pendant une opération bloquante.
- **ACTION 2 — autofill** : pendant l'overlay, `Ctrl+Shift+L`, `Ctrl+Shift+P`, `Ctrl+Shift+T`, avec un titre à **un seul** candidat puis à **plusieurs**.
- **ATTENDU** : rien ne se remplit, aucun picker, même toast. Le garde est dans `autofillHandleRequest`, en amont du choix match-unique / picker, donc il couvre les trois hotkeys et les deux issues. Il appelle `Bridge.cancelAutofill(false)` pour que Delphi ne garde pas la fenêtre cible capturée en attente d'un remplissage qui ne viendra jamais.
### B17 · hotkeys pendant la boîte « Enregistrer sous » de l'export ✅
- **ACTION** : lancer un *Export* → à l'apparition du dialogue natif **Enregistrer sous**, basculer sur une autre application (VS Code) → presser les hotkeys d'autofill → revenir à l'app.
- **ATTENDU** : le dialogue est toujours là, au premier plan de l'application, et répond. L'app n'est cliquable qu'à travers lui — c'est normal, une boîte native modale désactive sa fenêtre propriétaire tant qu'on n'a pas répondu.
- **Historique** : le refus « busy » appelait `cancelAutofill()`, dont le chemin Delphi fait un `SetForegroundWindow` vers la fenêtre capturée. Le dialogue passait **derrière** l'autre application, et l'app paraissait figée : bloquée par une modale devenue introuvable. `refocus=0` distingue désormais **refuser** une demande (ne rien déplacer) de **terminer** une interaction utilisateur (rendre le focus à la cible).
- **Généralisation** : un refus ne doit jamais déplacer une fenêtre. Tout nouveau garde qui annule une action doit passer par ce chemin.
- **Deuxième cause, côté Delphi** : `Ctrl+Shift+Q` et `Ctrl+Shift+A` appellent `RestoreWindowFromTray` **avant** que JS ne puisse refuser — un garde JS arrive trop tard par construction. Restaurer la fenêtre propriétaire d'une modale native laisse le dialogue en rade derrière tout le reste : l'app semble gelée alors qu'elle est simplement *désactivée* par une boîte devenue introuvable (au point qu'il fallait un utilitaire de capture d'écran pour la faire réapparaître).
- **Garde** : `NativeModalActive` teste `IsWindowEnabled` sur notre fenêtre — Windows la désactive tant qu'une modale native lui appartient. Posé en tête des **quatre** points d'entrée hotkey (quick search hotkey + menu tray, new entry, autofill). Il ne se contente pas de refuser : il **ramène le dialogue au premier plan** via `GetLastActivePopup`, ce qui est précisément ce dont l'utilisateur a besoin.
- **ATTENDU après correctif** : pendant l'« Enregistrer sous », n'importe quel hotkey **fait réapparaître le dialogue au premier plan, avec le focus**, et une **notification tray** explique que les raccourcis restent inactifs tant qu'il n'a pas répondu.
- **Piège n°1, localiser le dialogue** : `GetLastActivePopup` renvoie **le formulaire lui-même** — la boîte « Enregistrer sous » n'est pas une popup possédée par notre fenêtre. Elle vit en revanche sur notre thread avec la classe standard `#32770`, donc `EnumThreadWindows` la trouve quel que soit son propriétaire. Mesuré : sans ça le bloc de remontée ne s'exécutait jamais, silencieusement.
- **Piège n°2, le focus** : `SetForegroundWindow` est **refusé** à un processus qui n'est pas déjà devant, et le hotkey vient d'une autre application. Il faut attacher notre file d'entrées au thread de la **fenêtre de premier plan** (VS Code). Réutiliser le helper d'autofill ne marche **pas** : il attache au thread de la *cible*, or le dialogue appartient à notre propre thread — l'attache est un no-op et le dialogue remontait sans jamais recevoir le focus.
- **Vérifié au log** : le `WM_HOTKEY` **est** bien distribué pendant la boucle modale du dialogue, et `IsWindowEnabled` détecte correctement l'état. Ces deux points étaient les hypothèses coûteuses à écarter avant de chercher ailleurs.
- **Piège du message** : une **balloon tray**, pas un toast — notre fenêtre est désactivée et derrière le dialogue, rien de rendu dans la page ne serait visible. Elle suit le réglage « alertes tray » : désactivé, tu auras le focus sans le message.
### B18 · clavier pendant une opération bloquante ✅
- **ACTION** : lancer un *Sync now* (ou un export) → pendant l'overlay, presser `Tab` plusieurs fois puis `Espace` / `Entrée`.
- **ATTENDU** : rien ne se passe. Aucun bouton derrière l'overlay ne prend le focus ni ne s'active. Tout redevient normal ensuite, focus compris.
- **ACTION 2 — les raccourcis** : pendant l'overlay, `Ctrl+A` (tout sélectionner), `Ctrl+K` (palette), `j`/`k` (curseur), **`Suppr`** (suppression par lot).
- **ATTENDU** : aucun effet. Le plus important est `Suppr` : supprimer des entrées pendant qu'un sync ou une rotation écrit sur les mêmes lignes est exactement ce que l'overlay doit empêcher.
- **Historique** : l'overlay ne bloque que la **souris** — il couvre la page. Le clavier passait au travers : `Tab` entrait dans les contrôles derrière et `Espace` les activait. Corrigé avec l'attribut natif `inert` (retire un sous-arbre du focus, du hit-testing et de l'arbre d'accessibilité), posé sur chaque enfant de `<body>` **sauf** l'overlay.
- **`inert` ne couvre pas les raccourcis** : ils sont écoutés sur le **document**, donc ils ne traversent aucun élément et rien dans l'arbre ne peut les arrêter. Il a fallu un garde `isBusy()` explicite sur chacun — même famille que les hotkeys Win32, qui arrivent eux de l'extérieur de la fenêtre.
- **Point de vigilance** : `hideBusy()` retire l'inertie **inconditionnellement**. Une opération qui échouerait sans le rétablir laisserait une application qui semble gelée.
- **`inert` ne suffit pas seul** : il retire les contrôles du focus, mais **pas** le défilement. `Espace`, `Page haut/bas`, `Début/Fin` et les flèches font défiler le document **sans aucun élément focalisé** — la page bougeait encore sous l'overlay. Ces touches sont donc avalées en phase de capture tant que l'opération tourne.
- **Vérifier aussi le défilement** : pendant l'overlay, `Espace` et `Page bas` ne doivent rien bouger ; après, le défilement clavier doit refonctionner.
### B19 · cliquer dans le vide ferme l'éditeur, y compris près des barres ✅
- **ACTION** : `A` ouverte → cliquer dans l'**espace vide** à droite du titre « All items », puis dans l'espace vide de la barre de pagination.
- **ATTENDU** : l'éditeur se ferme (avec la modale de discard s'il est *dirty*), comme n'importe quel clic hors du panneau.
- **ATTENDU (non-régression)** : les **commandes** de ces barres ne ferment toujours pas l'éditeur — boutons de page, sélecteur `25 / page`, tri, barre de sélection, *Empty trash*.
- **Historique** : `.content-header` et `.pagination` étaient en liste blanche **en entier**, pour éviter que la barre de sélection et *Empty trash* ne ferment l'éditeur. Effet de bord : leurs grandes zones vides devenaient inertes, et un clic à côté du titre ne faisait plus rien. La liste blanche ne retient plus que les contrôles.
### 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 `<input type=file>` (et le `<a download>`) est attaché à `<body>` 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.
- **La présence du fichier ne prouve RIEN** : si le mot de passe de backup remontait vide de la pref, le conteneur serait produit quand même — chiffré avec une chaîne vide, donc parfaitement valide et impossible à ouvrir avec le vrai mot de passe. Le seul critère est que l'**import le déchiffre** : la modale doit lister le bon nombre d'entrées avec leurs sites et identifiants réels.
- **Pourquoi ce test compte depuis 2026-08-02** : c'est le seul chemin qui lit `autoBackupPwd` depuis la pref DPAPI. Il casserait si `loadAutoBackupConfig(true)` n'était pas passé là où le chiffrement a lieu (cf. E9).
### 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.
### F9 · la corbeille voyage dans les sauvegardes ✅
- **ACTION** : vault avec par exemple 4 entrées vivantes et 2 en corbeille → *Export vault* → *Clear vault* → réimporter.
- **ATTENDU** : état **identique** — 4 dans la liste, 2 en corbeille. Le compteur *Trash* est juste sans avoir à ouvrir la vue.
- **`deleted_at` est préservé**, pas réinitialisé : sinon chaque entrée restaurée repartirait pour un délai d'auto-purge complet, et une entrée supprimée il y a 29 jours recommencerait à zéro. D'où son ajout à l'écho de `GET /entries`.
- **Deuxième test, celui qui a trouvé le bug** : mettre une entrée à la corbeille → exporter → réimporter **sans rien vider**. L'entrée doit être comptée dans les « déjà présentes » (*Overwrite* / *Skip*), **jamais** ajoutée.
La déduplication de l'import ne construisait sa table que depuis `state.entries` : une entrée dont l'`uuid` n'existait **que** dans la corbeille passait pour nouvelle et créait une **seconde ligne partageant son uuid** — l'identité sur laquelle repose toute la sync. Même angle mort que F8.
- **Ne PAS vider la corbeille pour nettoyer des doublons d'uuid** : *Empty trash* et *Delete forever* écrivent un tombstone portant cet `uuid`, et l'autre appareil supprimerait alors le jumeau vivant. Retirer les lignes en trop directement en base.
- **Le sync, lui, n'emporte pas la corbeille** — voir F8 et `buildSyncSnapshot`.
### F8 · mettre à la corbeille puis synchroniser ne duplique pas ✅
- **ACTION** : synchroniser une première fois (l'instantané distant contient les entrées) → mettre **plusieurs** entrées à la corbeille → **Sync now**.
- **ATTENDU** : les entrées restent en corbeille, et **rien n'est recréé** dans la liste vivante. Le résumé ne doit pas annoncer `pulled N new`.
- **Le bug** : 30 entrées mises à la corbeille puis un sync → 30 **doublons vivants** à côté des originaux en corbeille, soit deux lignes partageant un même `uuid`.
- **Cause** : la corbeille est une suppression **douce**, sans tombstone (seul le hard-delete en écrit un), et les entrées supprimées sont absentes de `state.entries`. Le merge voyait donc leurs uuid comme « absents en local » et les repostait comme des entrées neuves.
- **Correctif** : `applyRemoteSnapshot` charge aussi la corbeille et saute tout uuid qui s'y trouve. La décision locale de mettre à la corbeille l'emporte ; c'est le **vidage** de la corbeille qui écrit le tombstone et propage la suppression.
- **Nettoyage si le bug a déjà frappé** : les doublons vivants sont à supprimer à la main. Vérifier aussi l'autre appareil, où ils ont pu être poussés.
### 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).
### F7 · arbitrage last-write-wins du sync ✅
- **PRÉ** : sync configuré (un serveur WebDAV local suffit : `python -m wsgidav.server.server_cli --host=127.0.0.1 --port=8080 --root=<dir> --auth=anonymous`, URL pointant un **fichier**, ex. `http://127.0.0.1:8080/vault-sync.json`).
- **ACTION** : sync (le distant porte `aa`) → changer le username en `C`, Save → fermer l'app → reculer `updated_at` de l'entrée sous la date du distant, directement en base → fermer la base → relancer → **sync sans ouvrir l'entrée**.
- **ATTENDU** : le username revient à `aa`, toast `1 updated`. L'inverse (`updated_at` local plus récent) garde `C`.
- **Piège n°1 — ne rien toucher entre les deux** : ouvrir l'entrée et sauver quoi que ce soit émet un PUT, et **tout PUT rebumpe `updated_at`**. Le recul manuel est alors annulé juste avant le sync et le local regagne : le test s'invalide lui-même.
- **Piège n°2** : c'est bien `updated_at` qui arbitre ([app.sync.js:423](js/app.sync.js:423)), pas `created_at`.
- **Corollaire** : quand le distant gagne, la branche PUT réécrit **toute** l'entrée. Une modification locale non poussée est donc perdue, pas fusionnée champ par champ — c'est le principe du last-write-wins par entrée.
- **Éditeur ouvert pendant le sync** : il ne se rafraîchit **pas** en direct, délibérément (ça écraserait la saisie en cours). Le garde-fou est au Save — modale « Entry changed elsewhere », cf. A11. Fermer sans sauver puis rouvrir montre la version issue du merge.
### F6 · champ Notes multiligne — la forme survit ✅
- **ACTION** : sur un **login**, *Add note* → saisir plusieurs lignes → Save → **rouvrir** l'entrée.
- **ATTENDU** : toujours multiligne et **masqué** ; l'œil révèle. Le contenu conserve ses retours à la ligne.
- **ACTION 2** : dupliquer l'entrée, puis export → import. **ACTION 3** : *Sync now* (aller-retour vérifié — pousser, supprimer localement, re-synchroniser pour faire revenir l'entrée du distant).
- **Trois choses à vérifier séparément** : le contenu avec ses retours à la ligne, la clé `multiline` (rouvre en zone de texte), la clé `is_secret` (masqué, œil présent). Une perte se produirait à l'**ouverture** sur le device receveur, pas pendant le transfert — la sync porte `custom_fields` comme un blob opaque.
- **ATTENDU** : le champ reste multiligne partout — rien à ajouter dans ces chemins, il voyage dans `custom_fields`.
- **Pourquoi ce test existe** : les champs personnalisés sont **reconstruits clé par clé** à deux endroits, la normalisation à l'ouverture et la reconstruction au Save, et chacun **jette ce qu'il ne nomme pas**. `multiline` est donc porté explicitement dans les deux ; sans ça le champ revient sur une ligne à la première réouverture (le contenu survit, mais illisible). Même piège que `options` pour les combobox.
- **Choix de conception** : pas de colonne `notes_enc` dédiée. Le corps d'une entrée *note* **est** `encrypted_password` ; un vrai champ notes aurait voulu une nouvelle colonne chiffrée et les 7 points de passage de CLAUDE.md. `custom_fields` est déjà porté par la rotation, la duplication, l'export/import et la sync.
---
## 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
### D4 · les cibles de dépôt n'acceptent que nos glissers ✅
- **ACTION** : dans la **corbeille** (vue tableau), sélectionner du texte dans une cellule et le glisser sur un dossier, sur *(no folder)*, sur un tag, sur *Trash*.
- **ATTENDU** : **aucune** cible ne s'allume. Les lignes de corbeille ne sont pas déplaçables (`draggable=false`), mais glisser du **texte** reste possible partout dans le navigateur.
- **Historique** : les cibles s'allumaient sur n'importe quel glisser puis lisaient `text/plain` pour y trouver des identifiants — le texte d'une cellule donnait `NaN`, filtré, donc rien ne se passait : une invitation à déposer suivie d'aucune action. Nos glissers d'entrées portent désormais leur propre type `application/x-pm-entries` (comme les dossiers avec `application/x-pm-folder`), et les quatre cibles le vérifient **avant** de s'allumer.
- **Non-régression** : glisser une carte ou une ligne (hors corbeille) sur un dossier, sur *(no folder)*, sur un tag et sur *Trash* fonctionne toujours, sélection multiple comprise.
### D5 · cocher une carte ne bouge ni la grille ni le défilement ✅
- **ACTION** : liste longue → **défiler vers le bas** → cocher une carte (clic sur la case, ou `Ctrl`+clic).
- **ATTENDU** : la position de défilement **ne bouge pas**, et la grille ne se décale pas verticalement. La case se coche, la barre de sélection apparaît.
- **Tester les trois vues** : cartes, **liste** et **tableau**. Le décalage a survécu un premier correctif en vues liste et tableau seulement.
- **Deux causes distinctes, corrigées ensemble** : (1) la barre de sélection dépassait la hauteur réservée par `.content-header` et poussait toute la grille. Fixer la hauteur de la **barre** n'a pas suffi : `min-height` sur l'en-tête n'est qu'un plancher, n'importe quel enfant plus haut fait encore grandir la rangée. L'en-tête a donc une hauteur **fixe** (`62px` = 24 de padding + 38 de barre ; `54px` sur écran étroit), ce qui rend tout débordement purement visuel. (2) `toggleChecked` appelait `renderGrid()`, qui reconstruit **toutes** les cartes — le conteneur de défilement repartait donc à zéro et l'utilisateur était renvoyé en haut de la liste. Le basculement se fait désormais **sur place** sur la seule carte concernée.
- **À vérifier aussi** : le ✓ apparaît / disparaît (il est ajouté au rendu, pas par CSS), l'info-bulle bascule *Select* / *Deselect*, et le compteur de la barre est juste. Idem en vue **tableau** et en vue **liste**.
### D6 · l'espacement de l'en-tête ne change pas au défilement ✅
- **ACTION** : liste longue, dans les **trois** vues → comparer l'écart entre le titre « All items » et la barre de pagination, en haut de liste puis après avoir défilé (en-tête collé).
- **ATTENDU** : écart identique dans les deux cas, et aucune ligne visible **entre** les deux barres. En vue tableau, l'en-tête de colonnes reste garé juste sous la pagination.
- **Historique** : l'écart était une **marge** basse, et `--hdr-h` — qui dit à la pagination où se garer — est mesuré avec `offsetHeight`, **qui ignore les marges**. Une fois collée, la pagination remontait donc de 20 px. Corriger la seule mesure aurait laissé une bande **transparente** traversée par les lignes ; passer la marge en **padding** la rend à la fois mesurable et opaque.
### 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).
### E8 · le mode de déverrouillage survit au redémarrage ✅
- **ACTION** : créer un PIN → *Unlock method* = **PIN** → verrouiller (le PIN est bien demandé) → **fermer et relancer l'app**.
- **ATTENDU** : le PIN est demandé, pas le mot de passe maître.
- **Variantes** : mode `both` → mot de passe **puis** PIN au démarrage à froid ; retour en mode `pw` → mot de passe seul ; 5 PIN faux (le blob DPAPI est effacé) → relancer → retour au mot de passe, sans blocage.
- **Historique** : `unlockMode` n'existait que dans `localStorage` — vidé à **chaque** lancement, le port éphémère changeant l'origine — et dans `settings_json`, illisible avant d'être connecté. Au démarrage à froid l'écran d'auth retombait donc toujours sur `'pw'`, alors que le blob PIN était bien présent. Écrit désormais aussi en pref DPAPI (`Bridge.setPref`) et relu par `showAuth`, comme `rememberedUsername`. **Le réglage est device-local par nature** (le blob PIN est lié à ce Windows) : il reste dans `settings_json` pour l'historique, mais c'est la copie DPAPI qui fait autorité au boot.
- **Auto-correction** : si la pref dit `pin` alors que le blob n'existe plus, `applyAuthScreenMode` force `pw` — pas de verrouillage possible.
### E9 · aucun secret au repos dans la mémoire du renderer ✅
Contrôle par dump mémoire, à refaire après toute modification touchant les prefs
ou le déchiffrement.
- **Mise en scène** : rebuild complet → lancer → **Quick Unlock** → **ne rien ouvrir** (aucune entrée, aucun réglage) → dumper le processus.
- **Quel processus** : le *renderer*, pas le plus gros. Le Gestionnaire des tâches le nomme d'après l'onglet — « **WebView2 : Vault** » ; dans Process Explorer, ajouter la colonne *Command Line* et chercher `--type=renderer`. Le processus GPU est souvent 10× plus volumineux et ne contient **aucun** JavaScript : un scan qui n'y trouve rien ne prouve rien.
- **Témoin de validité** : `soState` doit apparaître (~120 en UTF-16). À 0, ce n'est pas le renderer et le test est nul.
- **ATTENDU** : `this_is_password` (mot de passe d'entrée) à **0** — le déchiffrement est à la demande — et le mot de passe d'auto-backup à **0**. Le **nom** de la pref (`autoBackupPwd`) apparaît, c'est un littéral du code source, pas sa valeur.
- **Historique** : le mot de passe d'auto-backup était présent en clair dans cette exact mise en scène, visible dans `Bridge.onPrefResult("autoBackupPwd","…")` **et** dans les tampons du protocole DevTools — donc deux processus — et il **survivait au verrouillage automatique** qui efface pourtant la clé du coffre. Cause : `loadAutoBackupConfig` / `loadSyncConfig` récupéraient les secrets alors que l'interface n'a besoin que de savoir s'ils existent. Voir `prefs/has`.
- **Limite assumée** : après un backup ou un sync **réellement exécuté**, le secret est de nouveau en mémoire — il faut bien le passer à la fonction de chiffrement. Ce qui est supprimé, c'est sa présence permanente sans qu'aucune opération ne l'ait demandée.
- **Après le test** : supprimer les dumps. Ils contiennent des secrets en clair, sans chiffrement.
- **Validation fonctionnelle obligatoire** — les **quatre** chemins qui lisent encore un secret, à repasser après toute modification de `loadAutoBackupConfig` / `loadSyncConfig` : *Backup now* **puis import du fichier produit** (le fichier seul ne prouve rien, cf. F2) · *Test connection* WebDAV **avec authentification** · *Sync now* (le compteur `updated` prouve que l'instantané distant a été **déchiffré**, pas seulement téléchargé) · le backup planifié au déverrouillage.
- **Monter un WebDAV authentifié pour le test** : `python -m wsgidav.server.server_cli -c <config.json>` avec `simple_dc.user_mapping` et **`accept_basic: true`, `accept_digest: false`** — notre client n'envoie que du Basic. Le partage ne doit plus être marqué `(anonymous)` au démarrage. Un premier 401 suivi d'un 200 authentifié dans le journal est **normal** : WinHTTP n'envoie les identifiants qu'après le défi.
- **Piège de saisie** : les champs identifiant / mot de passe n'enregistrent que sur `change` (perte du focus). Taper puis cliquer aussitôt sur *Test connection* peut lire la préférence avant son écriture — les deux passent par des navigations `cmd://`, qui se coalescent. Faire Tab avant de cliquer. **python -m wsgidav.server.server_cli -c "e:\webdav.json"**
### 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.)
- **Vérifié par le code, 2026-08-03** — la console est indisponible en prod (DevTools désactivé), mais le résultat est structurel : plus aucun `persistCryptoKey` / `restoreCryptoKey` (seul subsiste le commentaire qui documente leur retrait), et `sessionStorage` ne porte que `username`, `salt`, `kdfIterations`, `hashAlgo`, `authToken`, `csrfToken`, `argon2Params`. Ni sel ni itérations ne sont des secrets — `/login/challenge` les publie.
- **`sessionStorage.removeItem('cryptoKey')` est volontaire** ([js/app.js](js/app.js)) : il efface le blob laissé par une **ancienne** version de l'app. Ne pas le supprimer en croyant à du code mort.
- **Ne pas confondre avec E9** : celui-ci porte sur le web storage (fichiers sur disque), E9 sur la mémoire du processus. Une clé peut être absente de l'un et présente dans l'autre.
- **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.
- **Bug trouvé par ce test** : la barre restait figée sur le mot de passe précédent. Affecter `.value` en JavaScript **ne déclenche aucun événement** ; le code appelait `soDirtyCheck()` à la main, mais la barre de force écoute `input` et n'était donc jamais prévenue.
- **Correctif** : émettre `new Event('input')` sur le champ plutôt que d'appeler un gestionnaire nommé. Tous les auditeurs du champ sont couverts, y compris ceux qu'on y branchera plus tard — c'est le même piège chaque fois qu'un bouton remplit un champ à la place de l'utilisateur.
---
## 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 — **sans objet**
- **ACTION prévue** : ouvrir *Vault health* → mettre à la corbeille une entrée listée → revenir sur health sans *Recompute* → **Fix** sur cette entrée.
- **Pourquoi c'est irréalisable** : G3 a rendu ce cas impossible à mettre en scène — le cache est invalidé à **chaque** mutation, donc revenir sur le tableau de bord recalcule toujours et l'entrée supprimée n'y figure plus. Le garde reste en place dans le code (`openEntryForFix` sort si `openSlideOver` renvoie `false`), il n'est simplement plus atteignable par l'interface.
- **Tentative la plus proche, vérifiée** : cocher une entrée dans la liste (la barre de sélection apparaît) → passer en *Vault health* → *Delete* depuis cette barre. Le tableau de bord se **recalcule tout seul** et l'entrée disparaît de la liste : toujours pas de liste périmée.
- **Défaut trouvé au passage** : la sélection **survivait au changement de vue**. Visible en santé / authenticator / journal d'audit, qui n'affichent aucune case à cocher tout en gardant la barre d'actions. **Bien plus grave vers la corbeille** : les identifiants cochés désignent des entrées **vivantes**, alors que la barre de la corbeille propose *Delete forever* — on pouvait donc purger définitivement des entrées qui n'y étaient jamais entrées. La sélection est maintenant vidée dès que la vue change, quelle qu'elle soit.
### G6 · la sélection ne franchit pas les vues ✅
- **ACTION** : cocher deux entrées dans *All items* → passer à la **corbeille**.
- **ATTENDU** : plus rien de coché, barre d'actions disparue. Surtout pas *Delete forever* actif sur une sélection d'entrées vivantes.
- **Variantes** : vers un dossier, un tag, *Notes*, *Recently used*, *Vault health*, *Authenticator*, *Audit log* — même résultat partout. Et au retour dans la vue d'origine, la sélection reste vide (elle n'est pas mémorisée).
### G5 · activer / désactiver HIBP depuis la vue santé ✅
- **ACTION** : dans *Vault health*, activer la vérification HIBP → attendre la fin du scan **sans quitter la vue**.
- **ATTENDU** : la carte *Pwned* et le score se mettent à jour **tout seuls** quand le scan finit.
- **ACTION 2** : désactiver HIBP → la catégorie *Pwned* retombe à 0 immédiatement.
- **ACTION 3** : relancer un scan et **désactiver HIBP en plein milieu**.
- **ATTENDU** : la pastille de progression (en bas, à droite de la barre latérale) **disparaît aussitôt** ; plus aucune requête réseau. Elle continuait à compter après « Breach check disabled », le scan poursuivant un aller-retour réseau par entrée.
- **Historique** : le cache santé était calculé **avant** l'arrivée des résultats et rien ne l'invalidait ensuite. La vue affichait `0 pwned` pendant que les cartes de la liste portaient déjà le drapeau ; il fallait sortir et revenir pour voir `2`. Symétrique à la désactivation, qui vidait `hibpResults` en laissant le tableau de bord compter des brèches disparues. `hibpCheckAllEntries` et le toggle invalident désormais `healthCache`.
### G4 · le score bouge par paliers de 10 — comportement attendu
- **OBSERVÉ** : après correction de deux mots de passe, `30 → 40 → 50`.
- **C'EST NORMAL** : le barème est `-5` par mot de passe **faible**, `-10` par **groupe de réutilisation**, `-2` par entrée **ancienne**, `-15` par entrée **compromise**. Corriger un mot de passe partagé par deux entrées casse le groupe → `+10`.
- **Effet de bord à connaître** : chaque catégorie est **plafonnée** (`-40` faible, `-30` réutilisé, `-20` ancien, `-50` compromis). Au-delà de 8 mots de passe faibles, en corriger un ne change **rien** au score tant qu'on n'est pas repassé sous le plafond. Le plafond existe pour qu'une seule catégorie ne puisse pas écraser les autres, mais il rend les premières corrections invisibles quand le coffre en compte beaucoup.
### 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`, puis **`Ctrl+Shift+Q`**, depuis une autre application (Notepad).
- **ATTENDU** : l'app revient au premier plan **et y reste**. Pas de no-op silencieux, et **aucun clignotement** : elle ne doit pas apparaître puis rendre la main à l'application de départ.
- **Où atterrit le curseur** : sur l'**identifiant** s'il est vide, sinon sur le mot de passe — même règle pour les quatre hotkeys. La quick search forçait le mot de passe, ce qui plaçait le curseur dans le second champ lors d'un premier déverrouillage ou sans identifiant mémorisé.
- **Historique** : les deux chemins « coffre verrouillé » annulaient l'autofill avec le refocus **par défaut**, qui rend volontairement le premier plan à la fenêtre cible — juste après que Delphi ait affiché notre fenêtre. Sur `Ctrl+Shift+Q` ça se voyait comme un flash d'une frame avant le retour à Notepad ; sur `Ctrl+Shift+L` le `focusApp()` qui suit reprenait le focus, donc deux déplacements de fenêtre pour finir là où un seul suffit. Les deux passent maintenant `refocus=false` (cf. B17 : **un refus ne doit jamais déplacer une fenêtre**).
### 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é par Esc sans choisir → l'app **repart au tray** elle aussi (symétrique de quick-search, qui se cache sur Esc) 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.
### H10 · le focus revient à l'application précédente ✅
Cacher une fenêtre ne transfère le premier plan à personne (contrairement à une
minimisation). On restait donc au premier plan **en étant invisible** : le
hotkey suivant capturait **notre propre fenêtre** comme cible et tapait le mot
de passe dedans, et `Ctrl+Shift+A` ouvrait un « New entry » au titre vide (le
garde refusait, à raison, de pré-remplir avec « Password Manager »).
- **ACTION** : depuis une application X → ouvrir l'app (icône du tray, hotkey, ou lancement à froid) → fermer par la croix.
- **ATTENDU** : X reprend le focus. Puis un hotkey d'autofill depuis X doit cibler X, et `Ctrl+Shift+A` pré-remplir son titre.
- **ACTION 2** : enchaîner plusieurs allers-retours en changeant d'application entre deux — c'est **toujours la dernière réellement utilisée** qui récupère le focus.
- **Non-régression** : après un autofill (pick ou Esc), le focus reste sur la fenêtre **remplie** — ces deux chemins passent `AReturnForeground = False`, ayant déjà placé le premier plan eux-mêmes.
- **Historique — quatre tentatives, toutes tranchées par le log** : (1) rendre le focus *après* avoir caché la fenêtre — Windows n'autorise que le propriétaire courant du premier plan à le céder, l'appel était silencieusement refusé ; (2) échantillonner le premier plan au moment du besoin — il vaut alors *notre* fenêtre, la capture se rejetait elle-même et n'apprenait jamais rien (`prev=<none>` à chaque fois) ; (3) capturer au clic sur l'icône du tray — on obtient une fenêtre du **shell** (`NotifyIconOverflowWindow`, « Fenêtre de dépassement de capacité de la barre d'état système ») ; (4) consommer la valeur après usage — le premier masquage marchait, les suivants plus. **Solution** : un hook `EVENT_SYSTEM_FOREGROUND` (`WINEVENT_OUTOFCONTEXT`, aucune injection) note *chaque* changement et garde le dernier qui n'est ni à nous ni au shell ; la restitution se fait **avant** de cacher.
### H13 · pas de `Ctrl+A` après le Tab (formulaires type Reddit) ✅
- **ACTION** : `Ctrl+Shift+L` sur un formulaire où **Tab depuis le username ne mène pas au champ mot de passe** (page de connexion Reddit : le Tab tombe à côté).
- **ATTENDU** : plus de **sélection de toute la page**. Le mot de passe, lui, n'est toujours pas saisi sur ce formulaire — et ce n'est pas corrigeable : **vérifié au Tab manuel, sans autofill**, le Tab depuis le username va sur le **bouton œil** (révéler le mot de passe), que Reddit place avant le champ dans l'ordre de tabulation. On envoie des frappes sans jamais voir où le focus atterrit.
- **Contournement, seul chemin fiable sur ces formulaires** : cliquer le champ mot de passe puis `Ctrl+Shift+P` (password seul, aucun Tab, aucune supposition) — **testé sur Reddit, fonctionne**.
- **Ne pas « corriger » en envoyant deux Tab** : l'ordre de tabulation dépend de chaque site, un second Tab casserait tous les formulaires normaux.
- **Historique** : la séquence envoyait `Ctrl+A + Del` avant **chaque** champ. Après le Tab c'était doublement mauvais : redondant (arriver par Tab sélectionne déjà tout le contenu du champ, cf. K1) et destructeur quand le Tab tombe sur un lien, un bouton ou le document — `Ctrl+A` sélectionne alors la page entière et la frappe se perd. Retiré ; celui d'avant le username reste (l'utilisateur y a placé le curseur par un clic, rien n'est sélectionné).
### H11 · l'application précédente réduite reste réduite ✅
- **ACTION** : depuis X, **réduire X** dans la barre des tâches → ouvrir le coffre (icône du tray) → le fermer par la croix.
- **ATTENDU** : X **reste réduite**. Le focus part où Windows décide ; on ne rouvre pas une fenêtre que l'utilisateur a rangée.
- **Pourquoi c'est fragile** : `ForceForegroundWindow` fait `ShowWindow(SW_RESTORE)` sur une cible iconique — il est écrit pour l'autofill, où sortir la fenêtre cible est justement le but. Réutilisé tel quel pour la restitution du focus, il changeait l'état de la fenêtre d'une autre application. Garde `not IsIconic` posé côté `MinimizeToTray`, pas dans `ForceForegroundWindow` (l'autofill en a toujours besoin).
### H12 · démarrage `-tray` — l'application qui lance garde le focus ✅
- **ACTION** : depuis une console PowerShell, lancer `.\PMServer.exe -tray`.
- **ATTENDU** : le coffre démarre caché **et la console garde son focus** (rectangle actif dans la barre des tâches).
- **ACTION 2** : « Start with Windows » / redémarrage → rien ne passe au premier plan.
- **Historique** : d'abord codé « rien à rendre, on n'a rien pris ». Faux : FMX affiche son proxy de barre des tâches avant qu'on le cache, donc le premier plan **est** pris un instant, et la console lançeuse restait inactive. La valeur utile est `GStartupForeground`, capturée à l'initialisation de l'unité — avant toute fenêtre à nous. Au démarrage de Windows, le premier plan est une fenêtre du shell, que la capture filtre : rien n'est activé, ce qui est le comportement voulu là.
- **Les quatre réglages de `MinimizeToTray`** (`AReturnForeground`) : fermeture par la croix = **rend** ; quick-search copie-puis-cache = **rend** (c'est ce qui permet le Ctrl+V dans l'app d'origine) ; démarrage `-tray` = **rend** ; autofill et annulation du picker = **ne rendent pas**, seuls cas où le focus a déjà été placé volontairement (sur la fenêtre remplie).
### 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`. Notre code n'efface rien. (Depuis le
correctif H13 il n'y a de toute façon **plus qu'un seul** `ClearFieldIfWanted`,
avant le username : celui d'avant le password a été retiré.)
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`
(`"<exe>" -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.
2. **La corbeille n'est jamais exportée, et rien ne le dit.** `doExport`
parcourt `state.entries` ; la corbeille vit dans `state.trashed`, une liste
séparée — donc absente de l'export manuel, de l'auto-backup **et** du
snapshot de sync. Le choix se défend (la corbeille est une antichambre de
suppression), mais il est **silencieux** : exporter → vider le coffre →
réimporter fait disparaître définitivement les entrées en corbeille, alors
que l'utilisateur croyait faire une sauvegarde complète.
- **Correctif minimal** : une ligne dans le dialogue d'export — « la
corbeille n'est pas incluse ». Une phrase, aucun risque.
- **Correctif complet** : exporter les entrées supprimées avec leur
`deleted_at` et les réimporter en corbeille. Nouveau champ à porter de
bout en bout (parseur, `encryptImportEntry`, INSERT create **et**
bulk-import), plus le comportement à choisir quand l'entrée existe déjà
côté vivant. C'est la classe de modification aux 7 points de passage
décrite dans CLAUDE.md.
- Constaté le 2026-08-03 en testant export → *Clear vault* → import.