refactor: replace native confirm() dialogs with shared ConfirmService modal #228
Reference in New Issue
Block a user
Delete Branch "feat/issue-207-confirm-modal-service"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #207
Summary
ConfirmService(confirm.svelte.ts) using a deferred-Promise pattern and native<dialog>element<ConfirmDialog />globally in+layout.sveltewithprovideConfirmService()window.confirm()calls and inline confirmation modals across 5 components withawait confirm({ destructive: true })--c-danger/--c-danger-fgCSS design tokens (WCAG AA light + dark)btn_confirmi18n key (de/en/es)Components migrated
TranscriptionBlock.sveltewindow.confirm()await confirm({ destructive: true })admin/users/[id]/+page.sveltewindow.confirm()inuse:enhancehandleDelete()+requestSubmit()admin/groups/[id]/+page.sveltewindow.confirm()inuse:enhancehandleDelete()+requestSubmit()SaveBar.svelteconfirmDeleteboolean toggleawait confirm({ destructive: true })NameHistoryEditCard.svelte<div>overlay modalawait confirm(...)+ hidden formTest plan
🤖 Generated with Claude Code
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
Overall this is a clean, well-structured refactor. The deferred-Promise pattern for the service is the right call and the tests are genuinely useful — not just coverage padding. A few things to tighten before merge.
Blockers
None.
Suggestions
$state(null)for a non-reactive closure variable (confirm.svelte.ts,resolveRef):resolveRefis never read reactively — it's only read synchronously insidesettle(). Using$statehere is harmless but misleading; a plainletwould be clearer and honest about the intent.import * as minConfirmDialog.svelte— every other component in this project uses namedimport { m }. This is a one-liner inconsistency that will confuse the next person who copies the pattern.Inconsistent test file suffixes — admin pages and SaveBar use
.spec.ts, butconfirm.svelte.test.tsandNameHistoryEditCard.svelte.test.tsuse.test.ts. Pick one convention and stick to it. The current vitest config probably matches both globs so it doesn't break anything, but it's cognitive noise.document.getElementById('delete-form')inSaveBar.svelte— this is an implicit coupling by DOM id that isn't visible from the component's props or interface. If someone renames that form id, SaveBar silently breaks with no type error. Abind:thisor a prop referencing the form element would make the dependency explicit.removeFormEl!.requestSubmit()non-null assertion — this is fine since it's reachable only insidecanWritecontext, but a guardif (removeFormEl)would make the intent unambiguous without the!.🏗️ Markus Keller — Application Architect
Verdict: ✅ Approved
The architectural approach here is exactly right. Cross-cutting UI concerns (confirmation modals) belong at the layout level, not scattered across individual components. This PR centralises that correctly.
What's good
Service + context pattern is sound.
provideConfirmService()in+layout.svelte+getConfirmService()in leaf components is the idiomatic Svelte 5 approach. The single<ConfirmDialog />in the layout is the correct rendering strategy — one modal instance, shared everywhere, no portal hacks needed.Browser guard is important and present.
This prevents the service from hanging during SSR. Good catch.
Concurrent call semantics are well-defined. Returning
falseimmediately for concurrent calls (instead of queuing or throwing) is a deliberate design choice that matches real-world UX expectations — you don't want two confirmation dialogs stacking.Suggestions
document.getElementById('delete-form')in SaveBar — this couples SaveBar to its parent's DOM via an undocumented convention. For a family archive app this is acceptable, but it's worth a comment explaining why it's done this way (form must be outside the sticky bar to avoid double-form-submission quirks, or similar). As the codebase grows, someone will "fix" this without understanding the intent.Error boundary for missing provider —
getConfirmService()throws a descriptive error when the context is missing. Excellent developer experience. Thetry/catchwrappinggetContext()to normalize Svelte's own throw vs undefined is a thoughtful touch.No concerns about layering or module boundaries. The service is in
$lib/services, the component in$lib/components, consumed in routes — the dependency direction is clean throughout.🧪 Sara Holt — QA Engineer & Test Strategist
Verdict: ⚠️ Approved with concerns
The test suite for this refactor is meaningfully better than what was there before. The patterns are right and the coverage hits the important paths. A few gaps.
Blockers
None.
What's good
confirm.test.host.sveltepattern — testing a context-based service by wrapping it in a minimal host component is the correct approach. Direct unit testing ofcreateConfirmService()would miss the reactive binding layer. This tests the right thing.vi.waitFor()for async confirm flows — the async confirm → settle → verify requestSubmit pattern is sound and avoids flaky timing issues. Specifically:This is the correct way to test deferred-Promise UI without fake timers.
appendedForms[]cleanup in SaveBar spec — good catch on the cross-test DOM contamination. Explicit teardown inafterEachis more reliable than hopingcleanup()handles manually appended elements.Concerns
ConfirmDialog.sveltehas no direct component tests. The service tests exercise confirm/cancel/Escape/concurrent via theTestHost, butConfirmDialog.svelteitself — the rendering, button text from i18n, destructive colour class,aria-labelledby— isn't tested in isolation. A small spec file (ConfirmDialog.svelte.spec.ts) covering "renders title", "confirm button has danger class when destructive", and "renders cancel button text" would close this gap..spec.tsvs.test.tsnaming — admin page specs and SaveBar use.spec.ts;confirm.svelte.test.tsandNameHistoryEditCard.svelte.test.tsuse.test.ts. This is a minor inconsistency but should be standardised in a follow-up.Flaky test observation: 4 pre-existing flaky tests remain (CommentThread empty state, TranscriptionBlock quote selection, TranscriptionEditView flush-on-blur, Conversations empty state). None introduced by this PR, but worth tracking in a separate issue so they don't mask real failures over time.
🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
Replacing
window.confirm()with a custom modal is a net security improvement.window.confirm()is a browser-native primitive that doesn't allow custom styling, can be blocked/suppressed by some browser configurations, and has inconsistent behaviour across environments (including headless browsers used in automated attacks). This PR eliminates that surface area.What I checked
No unsanitized user input rendered in the dialog. The
titleandbodyfields inConfirmOptionsare populated exclusively from Paraglide i18n function calls (e.g.m.doc_delete_confirm(),m.person_alias_delete_body()). These are compile-time strings, not user-controlled data. No XSS vector here.Escape key handling is correct. The
oncancelhandler on the<dialog>element callse.preventDefault()before settling to false — this prevents the browser's default dismiss behaviour (which bypasses the service state machine) from leavingresolveRefdangling:Good.
Backdrop click for destructive dialogs defaults to no-close.
closeOnBackdrop ?? !destructivemeans accidental backdrop clicks don't dismiss a destructive confirmation. This is the right default.concurrent call → resolve false immediatelyprevents a denial-of-service pattern where rapid successive clicks could queue up unbounded Promises. Clean.No blockers, no concerns.
This is a UI/UX refactor with no authentication, authorisation, data handling, or network surface changes. From a security standpoint, LGTM.
🎨 Leonie Voss — UI/UX & Accessibility
Verdict: 🚫 Changes requested
The service and component architecture are solid, but
ConfirmDialog.sveltehas four visual/UX bugs that must be fixed before merge. These aren't cosmetic — they affect usability and accessibility.Blockers
1. Dialog is not centred.
The native
<dialog>element rendered withshowModal()is positioned fixed by the browser, butmax-w-smalone does not centre it horizontally — it appears in the top-left corner. Fix:2. No backdrop / dimmed overlay.
Without a
::backdropstyle, the rest of the page is fully visible and unobscured when the modal is open. Users have no visual signal that the app is blocked waiting for input. Tailwind 4 supports thebackdrop:variant:3. Confirm button has no hover state.
There's no
hover:class on the confirm button. The cancel button hashover:bg-mutedbut the confirm button offers no feedback on hover. Fix:4. No
cursor-pointeron either button.Both buttons lack
cursor-pointer. On a desktop browser this means the cursor doesn't change to a hand over the modal buttons — inconsistent with every other interactive element in the app.What's good
aria-labelledby="confirm-title"— correct screen reader associationmin-h-[44px]on both buttons — meets WCAG 2.5.5 (44×44px touch target)<dialog>— the browser handles initial focus correctly withshowModal()🚀 Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
Pure frontend refactor — no infrastructure impact. LGTM from a platform perspective.
What I checked
No new build steps, no new environment variables, no new dependencies. The diff adds no new npm packages.
ConfirmServiceandConfirmDialogare built from existing Svelte 5 primitives and the native browser<dialog>API — zero additional bundle weight from third-party modal libraries.CI pipeline is unaffected. The
npm run testandnpm run checkcommands that run in Gitea Actions will pick up the new test files automatically. No workflow changes needed.No server-side surface area changes. This is entirely a client-side interaction pattern. No new API endpoints, no new form actions, no session or cookie changes.
Test count is healthy. 671 passing. The new tests replace old inline confirmation patterns without leaving dead code behind.
One observation (not a blocker)
The 4 pre-existing flaky tests (timeout-based) are not introduced here, but they'll eventually fire randomly in CI and cause confusion. Worth a dedicated issue to either fix or mark them as
test.skipwith a linked issue so CI stays trustworthy.Review concerns addressed — follow-up commits
All open concerns from the review have been resolved:
Leonie Voss — 4 UI blockers (
3a316bc)m-auto w-fulladded to centre the dialogbackdrop:bg-black/50for dimmed overlayhover:bg-danger/80/hover:bg-primary/80on confirm buttoncursor-pointeron both buttonsFelix Brandt — suggestions (
84378f1,0b95c90,a2d078b)resolveRefchanged from$state(null)to plainlet— it is never read reactivelyimport * as m→import { m }inConfirmDialog.svelte— consistent with the rest of the codebaseremoveFormEl!.requestSubmit()→if (confirmed && removeFormEl) { removeFormEl.requestSubmit(); }— explicit null guardSara Holt — missing ConfirmDialog tests (
d046c89)ConfirmDialog.svelte.spec.tswith 12 tests: title/body rendering, destructive vs primary button class, custom labels, settle-true/cancel, aria-labelledby, hide-after-settleTest count: 683 passing (was 671 before this PR — +12 from ConfirmDialog spec).
Not addressed (out of scope / require separate discussion):
document.getElementById('delete-form')coupling inSaveBar— valid concern but fixing it requires an API change to the parent edit page; tracked separately.spec.tsvs.test.tsnaming convention — cosmetic; would require touching many files across the repo