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:
@@ -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
|
||||
? `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 @@
|
||||
</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}
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover"
|
||||
onclick={openClearAllModal}
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
{/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">
|
||||
|
||||
Reference in New Issue
Block a user