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.
|
// user who walked away doesn't return to a primed delete button.
|
||||||
// Replaces the native window.confirm() dialog, which broke the warm
|
// Replaces the native window.confirm() dialog, which broke the warm
|
||||||
// UI tone with an OS-styled modal.
|
// 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;
|
const CONFIRM_TIMEOUT_MS = 4000;
|
||||||
let clearAllArmed = $state(false);
|
|
||||||
let bulkDeleteArmed = $state(false);
|
let bulkDeleteArmed = $state(false);
|
||||||
let clearAllArmTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let bulkDeleteArmTimer: ReturnType<typeof setTimeout> | null = null;
|
let bulkDeleteArmTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
function armClearAll() {
|
// Type-the-word modal state for clearAll. Replaces the 4-second
|
||||||
clearAllArmed = true;
|
// arm-confirm because Clear All wipes every transcript at once;
|
||||||
if (clearAllArmTimer) clearTimeout(clearAllArmTimer);
|
// mis-clicks under the prior pattern soft-deleted the entire
|
||||||
clearAllArmTimer = setTimeout(() => { clearAllArmed = false; }, CONFIRM_TIMEOUT_MS);
|
// 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;
|
function closeClearAllModal() {
|
||||||
if (clearAllArmTimer) { clearTimeout(clearAllArmTimer); clearAllArmTimer = null; }
|
if (clearAllInProgress) return;
|
||||||
|
clearAllModalOpen = false;
|
||||||
|
clearAllConfirmInput = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function armBulkDelete() {
|
function armBulkDelete() {
|
||||||
bulkDeleteArmed = true;
|
bulkDeleteArmed = true;
|
||||||
if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer);
|
if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer);
|
||||||
@@ -100,16 +124,13 @@
|
|||||||
// this derived feeds an aria-live region near the page root so the
|
// this derived feeds an aria-live region near the page root so the
|
||||||
// armed state is announced as it arms and disarms.
|
// armed state is announced as it arms and disarms.
|
||||||
let confirmAnnouncement = $derived(
|
let confirmAnnouncement = $derived(
|
||||||
clearAllArmed
|
bulkDeleteArmed
|
||||||
? "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.`
|
? `Delete ${selected.size} ${selected.size === 1 ? 'transcript' : 'transcripts'} armed. Click confirm or cancel to dismiss.`
|
||||||
: ""
|
: ""
|
||||||
);
|
);
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
stopPlayback();
|
stopPlayback();
|
||||||
if (clearAllArmTimer) clearTimeout(clearAllArmTimer);
|
|
||||||
if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer);
|
if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -273,8 +294,13 @@
|
|||||||
return items;
|
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() {
|
async function clearAll() {
|
||||||
disarmClearAll();
|
if (!clearAllConfirmValid || clearAllInProgress) return;
|
||||||
|
clearAllInProgress = true;
|
||||||
const ids = history.map((entry) => entry.id);
|
const ids = history.map((entry) => entry.id);
|
||||||
history.splice(0);
|
history.splice(0);
|
||||||
expandedId = null;
|
expandedId = null;
|
||||||
@@ -294,6 +320,9 @@
|
|||||||
`${failed} of ${ids.length} failed to delete from disk and may reappear after restart.`,
|
`${failed} of ${ids.length} failed to delete from disk and may reappear after restart.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
clearAllInProgress = false;
|
||||||
|
clearAllModalOpen = false;
|
||||||
|
clearAllConfirmInput = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleExpand(id) {
|
function toggleExpand(id) {
|
||||||
@@ -617,27 +646,86 @@
|
|||||||
</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={armClearAll}
|
onclick={openClearAllModal}
|
||||||
>
|
>
|
||||||
Clear All
|
Clear All
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
</div>
|
</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 -->
|
<!-- Search -->
|
||||||
<div class="px-7 pb-3">
|
<div class="px-7 pb-3">
|
||||||
<Card tone="subtle">
|
<Card tone="subtle">
|
||||||
|
|||||||
Reference in New Issue
Block a user