agent: code-atomiser-fix — type-the-word DELETE modal for clearAll (Rev-2 UI)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 18:01:10 +01:00
parent 7aee5348bc
commit 50d0715488

View File

@@ -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<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);
// 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
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 @@
</button>
{/if}
{#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
class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover"
onclick={armClearAll}
onclick={openClearAllModal}
>
Clear All
</button>
{/if}
{/if}
</div>
<!-- Type-the-word DELETE modal for clearAll. Replaces the 4-second
inline arm-confirm; an absent-minded double-tap on the prior
Confirm pill wiped every transcript at once. Backed by the
soft-delete path so a successful Clear All still leaves a 30-day
restore window in the Trash view. -->
{#if clearAllModalOpen}
<!-- Backdrop. A keyboard user reaches the modal via Tab; the
escape-to-close handler lives on the inner card. The
non-interactive role and presentation role on the backdrop
keep it out of the assistive-tech tree as a clickable
element while still allowing the click-outside-to-close
affordance for sighted users. -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
role="presentation"
onclick={closeClearAllModal}
onkeydown={(e) => { if (e.key === 'Escape') closeClearAllModal(); }}
>
<div
class="bg-bg-card border border-border-subtle rounded-xl shadow-2xl max-w-md w-[92%] p-6"
role="dialog"
tabindex="-1"
aria-modal="true"
aria-labelledby="clear-all-title"
aria-describedby="clear-all-description"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeClearAllModal(); }}
>
<h3 id="clear-all-title" class="font-display text-[18px] text-text mb-2">
Clear all history?
</h3>
<p id="clear-all-description" class="text-[13px] text-text-secondary mb-4">
This will move every transcript ({history.length}) to Trash. Items in Trash
are kept for 30 days, then permanently deleted.
</p>
<label for="clear-all-confirm-input" class="block text-[12px] text-text-secondary mb-1.5">
Type <span class="font-mono text-text">DELETE</span> to confirm.
</label>
<input
id="clear-all-confirm-input"
bind:this={clearAllInputEl}
bind:value={clearAllConfirmInput}
class="w-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px] text-text font-mono
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
placeholder="DELETE"
autocomplete="off"
spellcheck="false"
data-no-transition
onkeydown={(e) => {
if (e.key === 'Enter' && clearAllConfirmValid && !clearAllInProgress) {
clearAll();
}
}}
/>
<div class="mt-5 flex items-center justify-end gap-2">
<button
class="btn-md rounded-lg text-text-secondary hover:text-text hover:bg-hover disabled:opacity-50"
onclick={closeClearAllModal}
disabled={clearAllInProgress}
>Cancel</button>
<button
class="btn-md rounded-lg bg-danger/10 border border-danger/30 text-danger hover:bg-danger/20
disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-danger/10"
onclick={clearAll}
disabled={!clearAllConfirmValid || clearAllInProgress}
>{clearAllInProgress ? "Clearing…" : "Clear all"}</button>
</div>
</div>
</div>
{/if}
<!-- Search -->
<div class="px-7 pb-3">
<Card tone="subtle">