diff --git a/TEST_SCENARIOS.md b/TEST_SCENARIOS.md index 5c3a784..e4c66e2 100644 --- a/TEST_SCENARIOS.md +++ b/TEST_SCENARIOS.md @@ -162,14 +162,14 @@ plus bas. - **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 +### 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 +### 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. @@ -177,7 +177,7 @@ plus bas. - **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 +### 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). @@ -190,7 +190,13 @@ plus bas. - **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. -### B14 · dialogues natifs ouverts par un clic programmatique +### 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. +- **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 `` **sauf** l'overlay. +- **Point de vigilance** : `hideBusy()` retire l'inertie **inconditionnellement**. Une opération qui échouerait sans le rétablir laisserait une application qui semble gelée. + +### 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`. diff --git a/js/app.js b/js/app.js index dfbd7d6..8f5c124 100644 --- a/js/app.js +++ b/js/app.js @@ -5973,16 +5973,32 @@ function promptDialog(opts) { return new Promise(res => { confirmResolver = res; }); } +// The overlay stops the MOUSE — it covers the page — but not the keyboard: +// Tab still walked into the controls behind it and Space activated them. `inert` +// is the platform's answer: it removes a subtree from focus, hit-testing and +// the accessibility tree in one attribute. Applied to every body-level sibling +// except the overlay itself. +function setAppInert(on) { + Array.from(document.body.children).forEach(node => { + if (node.id === 'busyOverlay') return; + node.inert = !!on; + }); +} + // Global busy overlay for long operations (export, big file saves). function showBusy(text) { const t = $('#busyText'); if (t) t.textContent = text || 'Working…'; const o = $('#busyOverlay'); if (o) o.classList.remove('is-hidden'); + setAppInert(true); } function updateBusy(text) { const t = $('#busyText'); if (t) t.textContent = text || ''; } function hideBusy() { const o = $('#busyOverlay'); if (o) o.classList.add('is-hidden'); + // Unconditionally, whatever happened above: leaving the page inert would + // look exactly like a frozen app. + setAppInert(false); } // True while a blocking operation owns the screen. Anything that would open a // dialog has to check this: the overlay is above every modal, so a dialog