fix(history): replace native confirm() with inline two-click confirmation

Native window.confirm() is OS-styled and breaks the warm-amber UI
tone with an abrupt brutalist modal. The brand voice is "calm,
informative, solution-first; never blame the user", and confirm() is
none of those.

Replaced both destructive paths (Clear All, Delete N selected) with
a two-click inline pattern: first click arms the trigger and morphs
it into "Delete X? [Confirm] [Cancel]" inline pills; auto-cancels
after 4s so a user who walked away doesn't return to a primed delete
button. No new modal, no new component, no new dependency.

Both timers are cleared in onDestroy so unmounting the page mid-arm
doesn't leak.
This commit is contained in:
2026-05-07 09:21:04 +01:00
parent e857a814ad
commit a4cca58289

View File

@@ -64,8 +64,41 @@
let containerWidth = $state(0); let containerWidth = $state(0);
let scrollTop = $state(0); let scrollTop = $state(0);
// Inline two-click confirmation state for destructive bulk actions.
// First click arms the button (morphs into "Confirm" + "Cancel"), a
// second click confirms. Auto-cancels after CONFIRM_TIMEOUT_MS so a
// 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.
const CONFIRM_TIMEOUT_MS = 4000;
let clearAllArmed = $state(false);
let bulkDeleteArmed = $state(false);
let clearAllArmTimer: ReturnType<typeof setTimeout> | null = null;
let bulkDeleteArmTimer: ReturnType<typeof setTimeout> | null = null;
function armClearAll() {
clearAllArmed = true;
if (clearAllArmTimer) clearTimeout(clearAllArmTimer);
clearAllArmTimer = setTimeout(() => { clearAllArmed = false; }, CONFIRM_TIMEOUT_MS);
}
function disarmClearAll() {
clearAllArmed = false;
if (clearAllArmTimer) { clearTimeout(clearAllArmTimer); clearAllArmTimer = null; }
}
function armBulkDelete() {
bulkDeleteArmed = true;
if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer);
bulkDeleteArmTimer = setTimeout(() => { bulkDeleteArmed = false; }, CONFIRM_TIMEOUT_MS);
}
function disarmBulkDelete() {
bulkDeleteArmed = false;
if (bulkDeleteArmTimer) { clearTimeout(bulkDeleteArmTimer); bulkDeleteArmTimer = null; }
}
onDestroy(() => { onDestroy(() => {
stopPlayback(); stopPlayback();
if (clearAllArmTimer) clearTimeout(clearAllArmTimer);
if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer);
}); });
function itemHasStar(h) { function itemHasStar(h) {
@@ -229,7 +262,7 @@
}); });
async function clearAll() { async function clearAll() {
if (!confirm("Delete all history? This can't be undone.")) return; disarmClearAll();
const ids = history.map((entry) => entry.id); const ids = history.map((entry) => entry.id);
history.splice(0); history.splice(0);
expandedId = null; expandedId = null;
@@ -470,10 +503,7 @@
async function bulkDelete() { async function bulkDelete() {
if (selected.size === 0) return; if (selected.size === 0) return;
const yes = confirm( disarmBulkDelete();
`Delete ${selected.size} transcript${selected.size === 1 ? "" : "s"}?`,
);
if (!yes) return;
for (const id of selected) deleteFromHistoryById(id); for (const id of selected) deleteFromHistoryById(id);
clearSelection(); clearSelection();
} }
@@ -566,13 +596,25 @@
</button> </button>
{/if} {/if}
{#if history.length > 0} {#if history.length > 0}
{#if clearAllArmed}
<span class="text-[12px] text-text-secondary">Delete all history? This can't be undone.</span>
<button
class="btn-md rounded-lg bg-danger/10 border border-danger/30 text-danger hover:bg-danger/20"
onclick={clearAll}
>Confirm</button>
<button
class="btn-md rounded-lg text-text-tertiary hover:text-text hover:bg-hover"
onclick={disarmClearAll}
>Cancel</button>
{:else}
<button <button
class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover" class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover"
onclick={clearAll} onclick={armClearAll}
> >
Clear All Clear All
</button> </button>
{/if} {/if}
{/if}
</div> </div>
<!-- Search --> <!-- Search -->
@@ -641,15 +683,27 @@
class="text-text-tertiary hover:text-text" class="text-text-tertiary hover:text-text"
onclick={clearSelection} onclick={clearSelection}
>Clear</button> >Clear</button>
<div class="ml-auto flex gap-4"> <div class="ml-auto flex items-center gap-4">
<button <button
class="text-text hover:underline" class="text-text hover:underline"
onclick={bulkExport} onclick={bulkExport}
>Export selected</button> >Export selected</button>
{#if bulkDeleteArmed}
<span class="text-text-secondary">Delete {selected.size} {selected.size === 1 ? 'transcript' : 'transcripts'}?</span>
<button
class="text-danger font-medium hover:underline"
onclick={bulkDelete}
>Confirm</button>
<button
class="text-text-tertiary hover:text-text hover:underline"
onclick={disarmBulkDelete}
>Cancel</button>
{:else}
<button <button
class="text-danger hover:underline" class="text-danger hover:underline"
onclick={bulkDelete} onclick={armBulkDelete}
>Delete selected</button> >Delete selected</button>
{/if}
</div> </div>
</div> </div>
{/if} {/if}