From 50d0715488ac0ecf27d03487ad2926f6002021dc Mon Sep 17 00:00:00 2001 From: Jake Date: Wed, 13 May 2026 18:01:10 +0100 Subject: [PATCH] =?UTF-8?q?agent:=20code-atomiser-fix=20=E2=80=94=20type-t?= =?UTF-8?q?he-word=20DELETE=20modal=20for=20clearAll=20(Rev-2=20UI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior clearAll UX was a 4-second inline arm-confirm: one click on Clear All morphed into Confirm/Cancel pills; a second click within the window wiped every transcript. With the soft-delete backend (commit 15b74db) now under it, the data-loss class is partially closed — items land in Trash, not /dev/null — but an absent-minded double-tap still soft-deletes the entire history at once. Fix: replace the inline arm-confirm with a modal that requires the user to type the word DELETE (case-sensitive, exact) before the Confirm button activates. The single-transcript and bulk-selection delete flows keep their lighter arm-confirm pattern; only the all-at-once nuke is gated by the modal. Modal details: - autofocuses the input on open - Enter submits when the word matches - Escape closes; click-outside closes (when not in flight) - Confirm button disabled until input == DELETE - Clearing... spinner state while the SQLite soft-delete loop runs - retention policy (kept for 30 days) surfaced in the body copy - dialog role, aria-labelledby, aria-describedby, labelled input, tabindex=-1 on the card; backdrop carries role=presentation to keep the click-outside-to-close affordance out of the AT tree clearAllArmed / armClearAll / disarmClearAll and their announcement fragment are removed; bulkDeleteArmed (selection-subset) keeps the arm-confirm pattern unchanged. TODO(test): no Svelte component test framework wired in the repo (vitest not installed). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/pages/HistoryPage.svelte | 156 ++++++++++++++++++++++++------- 1 file changed, 122 insertions(+), 34 deletions(-) diff --git a/src/lib/pages/HistoryPage.svelte b/src/lib/pages/HistoryPage.svelte index 50e13f5..de7b06c 100644 --- a/src/lib/pages/HistoryPage.svelte +++ b/src/lib/pages/HistoryPage.svelte @@ -70,21 +70,45 @@ // user who walked away doesn't return to a primed delete button. // Replaces the native window.confirm() dialog, which broke the warm // UI tone with an OS-styled modal. + // + // NOTE: clearAll (delete-everything) is gated by a stronger + // type-the-word-DELETE modal — see CLEAR_ALL_CONFIRM_WORD below. + // The arm-confirm pattern is retained for bulkDelete (selection + // subset) where the failure mode is smaller. const CONFIRM_TIMEOUT_MS = 4000; - let clearAllArmed = $state(false); let bulkDeleteArmed = $state(false); - let clearAllArmTimer: ReturnType | null = null; let bulkDeleteArmTimer: ReturnType | null = null; - function armClearAll() { - clearAllArmed = true; - if (clearAllArmTimer) clearTimeout(clearAllArmTimer); - clearAllArmTimer = setTimeout(() => { clearAllArmed = false; }, CONFIRM_TIMEOUT_MS); + // Type-the-word modal state for clearAll. Replaces the 4-second + // arm-confirm because Clear All wipes every transcript at once; + // mis-clicks under the prior pattern soft-deleted the entire + // history. The user must type the word DELETE exactly + // (case-sensitive) before the Confirm button activates. + const CLEAR_ALL_CONFIRM_WORD = "DELETE"; + let clearAllModalOpen = $state(false); + let clearAllConfirmInput = $state(""); + let clearAllInputEl = $state(null); + let clearAllInProgress = $state(false); + let clearAllConfirmValid = $derived( + clearAllConfirmInput === CLEAR_ALL_CONFIRM_WORD, + ); + + function openClearAllModal() { + clearAllConfirmInput = ""; + clearAllModalOpen = true; + // Focus the input on the next tick — Svelte 5 binds run after the + // {#if} branch mounts, so a microtask is enough. + queueMicrotask(() => { + if (clearAllInputEl) clearAllInputEl.focus(); + }); } - function disarmClearAll() { - clearAllArmed = false; - if (clearAllArmTimer) { clearTimeout(clearAllArmTimer); clearAllArmTimer = null; } + + function closeClearAllModal() { + if (clearAllInProgress) return; + clearAllModalOpen = false; + clearAllConfirmInput = ""; } + function armBulkDelete() { bulkDeleteArmed = true; if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer); @@ -100,16 +124,13 @@ // this derived feeds an aria-live region near the page root so the // armed state is announced as it arms and disarms. let confirmAnnouncement = $derived( - clearAllArmed - ? "Clear all armed. Click confirm to delete all history, or cancel to dismiss." - : bulkDeleteArmed - ? `Delete ${selected.size} ${selected.size === 1 ? 'transcript' : 'transcripts'} armed. Click confirm or cancel to dismiss.` - : "" + bulkDeleteArmed + ? `Delete ${selected.size} ${selected.size === 1 ? 'transcript' : 'transcripts'} armed. Click confirm or cancel to dismiss.` + : "" ); onDestroy(() => { stopPlayback(); - if (clearAllArmTimer) clearTimeout(clearAllArmTimer); if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer); }); @@ -273,8 +294,13 @@ return items; }); + // Wipe every live transcript. Soft-delete via the SQLite backend so + // the user has a 30-day window to restore from Trash. Gated behind + // the type-the-word-DELETE modal — the prior 4-second arm-confirm + // let a single mis-click destroy the whole history. async function clearAll() { - disarmClearAll(); + if (!clearAllConfirmValid || clearAllInProgress) return; + clearAllInProgress = true; const ids = history.map((entry) => entry.id); history.splice(0); expandedId = null; @@ -294,6 +320,9 @@ `${failed} of ${ids.length} failed to delete from disk and may reappear after restart.`, ); } + clearAllInProgress = false; + clearAllModalOpen = false; + clearAllConfirmInput = ""; } function toggleExpand(id) { @@ -617,27 +646,86 @@ {/if} {#if history.length > 0} - {#if clearAllArmed} - Delete all history? This can't be undone. - - - {:else} - - {/if} + {/if} + + {#if clearAllModalOpen} + + + {/if} +