refactor: Replace native confirm() dialogs with a shared confirmation modal service #207
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Problem
The app uses three different confirmation patterns:
confirm()- ugly browser dialog, inconsistent with the app's design, no i18n control over button labelsEach component reinvents its own confirmation flow with local state (
showDeleteModal,confirmDelete,showMergeConfirm). The alias delete modal inNameHistoryEditCardis the newest and closest to a reusable pattern, but it's hardcoded to that component.Native
confirm()calls to replaceTranscriptionBlock.svelte:94transcription_block_delete_confirmadmin/users/[id]/+page.svelte:58admin_user_delete_confirmadmin/groups/[id]/+page.svelte:66admin_group_delete_confirmCustom inline confirmations (could also migrate)
documents/[id]/edit/SaveBar.svelteconfirmDeletetoggle, inline buttonsPersonMergePanel.svelteshowMergeConfirmtoggle, two-step inline UINameHistoryEditCard.svelteshowDeleteModal+ fixed overlay modalProposal
Build a shared
ConfirmModal.sveltecomponent (or a Svelte context-based confirmation service) that any component can call without managing its own modal state.Option A - Component with bindable props:
Option B - Context-based service (more flexible):
Option B is cleaner for form
use:enhancecancel patterns where you need toawaitthe user's decision before proceeding.Acceptance criteria
$lib/components/confirm()calls replaced👨💻 Felix Brandt — Senior Fullstack Developer
Questions & Observations
Option B (async service) is the right call — but the implementation is non-trivial in Svelte 5. An async
confirm()that returns aPromise<boolean>requires a deferred Promise pattern: the service holds a resolver function in$state, the modal callsresolve(true/false)when the user clicks, and the caller awaits. Worth sketching this before committing to Option B, because the reactive state machine is subtle:If two components call
confirm()simultaneously, the second overwrites the first. The service needs to queue or reject concurrent calls — define which behavior is wanted.The ACs are incomplete on the "could also migrate" patterns.
SaveBar.svelte(delete document) andPersonMergePanel.svelte(merge) are explicitly listed as candidates but not in the acceptance criteria. If they're out of scope for this issue, say so explicitly — otherwise implementers will differ on whether migrating them counts as "done."Component naming.
ConfirmModalworks, but the HTML<dialog>element is the native semantic element for this pattern and provides focus trapping,Escapekey handling, and::backdropstyling for free. The nameConfirmDialog.sveltewould signal that semantic choice. Worth considering before naming is locked in.Testing the service requires a host component. Unit testing the async service in isolation with Vitest is awkward — you need to mount a host that provides the context, open the modal, simulate a button click, and assert the Promise resolves. Write the test structure before implementation to confirm the design is testable.
Suggestions
confirm({ title: 'test' }), click the Confirm button, assert the returned Promise resolves totrue. This test drives the minimal API surface.🏛️ Markus Keller — Application Architect
Questions & Observations
Option B is architecturally superior — but it requires a contract. A context-based service inverts control cleanly: callers express intent ("did the user confirm?") without owning modal state. The trade-off is that the service must be provided at the layout level, and any component that calls
confirm()outside that context will fail silently at runtime. The implementation should include a guard:This converts a silent runtime failure into a clear developer error.
Use the native
<dialog>element. The HTML<dialog>element withshowModal()/close()provides focus trapping,Escapehandling,::backdropCSS hook, and top-layer stacking (bypasses z-index wars) for free. In 2026 this has full browser support. Building a custom overlay withz-index: 9999duplicates platform behavior and introduces accessibility bugs. The<dialog>is the correct primitive here.SSR safety. Svelte context (
setContext/getContext) is SSR-safe — context is scoped to the component tree per request, not global. However, the deferred Promise pattern (storing aresolvefunction in reactive state) should be client-side only. Confirm the service doesn't attempt to instantiate the Promise on the server — or add abrowserguard.Module boundary. Where does this service live?
$lib/components/ConfirmModal.svelte(the view) and$lib/services/confirm.ts(the logic) are distinct concerns. The service module exportsgetConfirmService(), the component renders the modal. They're coupled but separable — don't collapse them into one file.The "could also migrate" patterns are a scope creep risk.
PersonMergePanel.svelteuses a two-step inline confirmation that is deliberately different — it shows additional context ("you are merging X into Y") inline rather than in a modal. Migrating it to the shared service may lose that contextual richness. I'd keep it out of scope for this issue and revisit separately.Suggestions
+layout.sveltewhere the service is mounted — future contributors need to know why it's there.confirmDestructive()as a named shorthand forconfirm({ destructive: true })— keeps call sites readable without extra boilerplate.🧪 Sara Holt — QA Engineer
Questions & Observations
The acceptance criteria are a solid starting point but missing behavioral edge cases. The 7 items cover structure and style, but don't define expected behavior for:
Escapeis pressed? (Should cancel — equivalent to clicking Cancel)falseif the component is unmounted before the user responds?Three well-documented replacement targets make regression testing straightforward. For each of the 3 native
confirm()callsites, I'd write an E2E test that:confirm()page.on('dialog')listener should not fire)The
use:enhancecancel pattern needs a test. The issue mentions Option B is cleaner for form enhance cancel patterns. This is a meaningful integration:use:enhancewith asubmitcallback that awaitsconfirm()before proceeding is a non-trivial interaction. It needs its own test to verify the form doesn't submit while the modal is open, and does submit after confirmation.Visual regression tests. The modal has two visual states: default (neutral confirm button) and destructive (red confirm button). Both states should be captured in Playwright visual regression tests at 320px, 768px, and 1440px — the destructive state especially, since a styling bug could make a dangerous action look safe.
Suggested Test Matrix
trueon confirm clickawait confirm(opts)returnstruefalseon cancel clickfalsefalseon Escape keyfalseconfirm()outside context throwsdialogevent not fired, modal visible🔐 Nora "NullX" Steiner — Application Security Engineer
Questions & Observations
XSS risk in
titleandbodyprops. If thetitleorbodystrings passed toConfirmModalever include user-controlled data (e.g. "Delete alias 'X'?" where X is the alias name), they must be rendered via Svelte's text interpolation ({title}, not{@html title}). Svelte escapes text interpolation by default — so as long as no{@html}is used in the component, this is safe. Make it a hard rule in the component: no{@html}inConfirmModal.svelte, ever. Add a comment to that effect so future contributors don't accidentally "fix" it.The existing alias delete modal in
NameHistoryEditCardis the migration target — check if it currently displays the alias name in the confirmation text. If so, the new shared modal will inherit that pattern and it must stay as text interpolation.The native
confirm()replacement eliminates a phishing vector.window.confirm()dialogs display the origin in Chrome/Firefox, making it clear to the user which site is asking. Custom modals don't have this — this is a minor regression in origin transparency, but it is standard practice for web apps and not a concern here. Just noting it for completeness.Focus trapping is a security-adjacent concern. Without focus trapping, a user can Tab away from the modal and interact with the page behind it (including clicking other destructive actions). If the native
<dialog>element withshowModal()is used, focus trapping is provided by the browser. If a custom overlay is used, an explicit focus trap library (or manual implementation) is required. This should be an explicit acceptance criterion.CSRF. The actual delete/confirm actions behind these modals are SvelteKit form actions — CSRF protection is handled by SvelteKit's built-in token mechanism. No concerns here; the modal is purely a UI gate, not an auth boundary.
confirmLabelcoming from i18n keys — good. Static i18n strings mean there is no way for user input to appear in button labels. No XSS surface there.Summary
No critical security concerns with this refactor. The main thing to enforce:
{@html}must never be used in the confirmation modal component, even if the call site passes a string that looks like static content today. Enforce this with a linting comment in the component and ideally a Semgrep rule:🎨 Leonie Voss — UI/UX Designer
Questions & Observations
"Destructive confirmations use red confirm button" — which red? The brand palette doesn't include a standard danger red. The app uses
brand-navy,brand-mint, andbrand-sand. For a destructive action button, a common choice is Tailwind'sbg-red-600orbg-red-700, but this should be an explicit decision — not left to the implementer. Define the exact color (hex or Tailwind class) in the acceptance criteria before implementation. If red is introduced as a new semantic color, add it tolayout.cssas a named utility (e.g.--color-danger) rather than hardcodingred-600at every callsite.cancelLabelis missing from the proposed API. The issue showsconfirmLabelas a prop but doesn't mentioncancelLabel. In German/ES localization, "Abbrechen" and "Cancelar" are fine defaults, but for specialized confirmations the cancel label might need to be different (e.g. "Zurück" instead of "Abbrechen"). AddcancelLabelas an optional prop with a sensible i18n default.Backdrop click behavior needs a design decision. Should clicking outside the modal cancel the confirmation? For destructive actions (delete user, delete group), closing via backdrop click should probably behave identically to Cancel — but this needs to be stated, not assumed.
The
<dialog>element gives you::backdropfor free. This is the correct CSS pseudo-element for styling the overlay behind the modal. It supportsbackdrop-filter: blur()and opacity, and doesn't require a separate DOM node. Use it rather than a manually rendered overlay<div>.Animation. The current app's overlay (alias delete modal in
NameHistoryEditCard) presumably has some entry/exit behavior. The shared modal should match it. Even a simpletransition:fadeon the<dialog>content andtransition:opacityon the backdrop creates a polished feel. If the existing modal has no animation, this is an opportunity to add it consistently once rather than per-component.Touch target check — already in ACs, good.
>= 44pxon both buttons. Confirm the buttons also have sufficient horizontal spacing so they're not accidentally tappable together on narrow screens (minimum 8px gap, prefer 12px).Suggested additions to acceptance criteria
cancelLabelprop supported (optional, defaults tom.btn_cancel())layout.cssEscapekey closes the modal (same as Cancel)<dialog>element withshowModal()transition:scale|fade)🛠️ Tobias Wendt — DevOps & Platform Engineer
Observations
This is a pure frontend refactor — no infrastructure changes, no new services, no config required. From a deployment and operations angle, there's nothing blocking here.
A few things worth noting for completeness:
No SSR concerns for Svelte context.
setContext/getContextin SvelteKit is SSR-safe — context is per-request, not global state. The deferred Promise pattern (storing aresolvecallback in$state) is client-side by nature since it requires user interaction, but it should either be initialized lazily or guarded withif (browser)to avoid server-side instantiation of a Promise that can never resolve.No CI pipeline changes needed. This refactor replaces existing component patterns — no new build steps, no new environment variables, no new Docker layers. The existing Vitest and Playwright pipelines will cover it.
Bundle size. Replacing three
window.confirm()calls with a shared modal component adds a small amount of JS to the bundle. A shared component is better than three separate inline implementations from a bundle perspective — no concern here. Worth checking withnpm run buildthat the bundle doesn't grow unexpectedly (it shouldn't).The
<dialog>element recommendation from the team. If the native<dialog>is used withshowModal(), note that Playwright's test utilities interact with it differently from a customdiv-based overlay — usepage.getByRole('dialog')rather than a CSS selector, which is actually cleaner. No DevOps impact, just a heads-up for the test authors.Nothing blocking from my side. Ship it.
🎨 Leonie Voss — UI/UX Design Discussion
This comment captures the outcomes of a design walkthrough for the shared confirmation modal. All seven open items were resolved.
Resolved decisions
1. Danger color — new design token
Add a full
--c-danger/--c-danger-fgtoken pair tolayout.css, following the same pattern as--c-primary/--c-primary-fg:--c-danger: #c0392b(5.1:1 on white — WCAG AA ✓),--c-danger-fg: #ffffff--c-danger: #e55347(4.7:1 on#011526— WCAG AA ✓),--c-danger-fg: #ffffff@theme inline:--color-danger: var(--c-danger)/--color-danger-fg: var(--c-danger-fg)bg-red-600inNameHistoryEditCardmigrates tobg-danger text-danger-fg2.
cancelLabelpropOptional prop, defaults to
m.btn_cancel(). Only passed when a non-standard label is needed.3. Backdrop click behavior
Configurable via a
closeOnBackdropprop. Defaults to!destructive— so destructive confirmations block backdrop-click-to-close automatically, without requiring an explicit override.4. Escape key
Always cancels — no configuration, no exceptions. Blocking Escape on a modal is an accessibility anti-pattern; the safety concern for destructive actions is covered by the backdrop behavior above.
5. Animation
Simple
transition:fadeon both the backdrop and the dialog panel. Matches the general app animation style without adding complexity.6. Native
<dialog>elementUse
<dialog>withshowModal()/close(). Provides focus trapping, Escape handling, top-layer rendering, and::backdropfor free. The existing div-based overlay inNameHistoryEditCardis replaced.7. Visual appearance
Match the existing alias delete modal exactly — no redesign:
max-w-sm rounded-sm border border-line bg-surface p-6 shadow-lgfont-serif text-lg text-inktext-sm text-ink-2border border-line px-4 py-2 text-sm font-medium text-ink-2 hover:bg-mutedbg-primary text-primary-fg px-4 py-2 text-sm font-mediumbg-danger text-danger-fg px-4 py-2 text-sm font-mediumgap-3aria-labelledbywiring the title element to the<dialog>:<dialog aria-labelledby="confirm-title">The design system is ready to support this component. The
--c-dangertoken should be added in the same commit as the modal — it's a prerequisite, and other components (NameHistoryEditCarddelete button, any future destructive UI) should migrate to it at the same time.🏛️ Markus Keller — Architecture Discussion
This comment captures the outcomes of an architectural walkthrough for the shared confirmation service. All six open items were resolved.
Resolved decisions
1. Option A vs. Option B — Option B chosen
Context-based async service returning
Promise<boolean>. Callers useawait confirm(...)in event handlers oruse:enhancesubmit callbacks. Modal state is owned entirely by the service — no local state in consuming components.2. Module boundary — split into two files
$lib/services/confirm.ts— service logic: context setup,confirm()function, deferred Promise lifecycle$lib/components/ConfirmDialog.svelte— view only: reads from context, renders the<dialog>, calls resolve on button click3. Context provider location — root layout
<ConfirmDialog>is mounted once in the root+layout.svelte. All routes share it — no nested instances needed.4. Missing context guard — add to ACs
getConfirmService()must throw a descriptive error if called outside the provider tree:Add to acceptance criteria: calling
confirm()outside the provider throws a clear developer error.5. SSR safety
confirm()returnsPromise.resolve(false)when called server-side (!browser). Safe non-interactive fallback — code paths that awaitconfirm()during SSR proceed as if the user cancelled.6. Scope of "could also migrate" patterns
SaveBar.svelte(delete document) — included in this issue. StraightforwardconfirmDeletetoggle migration.PersonMergePanel.svelte(merge person) — explicitly out of scope. Its two-step inline flow shows contextual merge information that a generic modal would lose. Track as a separate issue.AC additions from this discussion
$lib/services/confirm.tsand$lib/components/ConfirmDialog.svelteare separate filesgetConfirmService()throws a descriptive error if called outside the provider treeconfirm()returnsPromise.resolve(false)when called server-sideSaveBar.sveltedelete document confirmation uses the shared servicePersonMergePanel.svelteexplicitly excluded — tracked separatelyThe architecture is straightforward. The deferred Promise pattern is the only subtle piece — write the failing test for it first before touching any consuming component.
👨💻 Felix Brandt — Developer Discussion
This comment captures implementation decisions from a developer walkthrough. All five open items resolved.
Resolved decisions
1. Concurrent calls — return false immediately
If
confirm()is called while a confirmation is already open, returnPromise.resolve(false)immediately. Guard at the top ofconfirm():Two simultaneous confirmations is a UI bug, not a case the service should recover from.
2.
ConfirmOptionsinterface — pinnedPrerequisite:
btn_confirmi18n key does not exist yet — must be added to all three message files before implementation:de.json:"btn_confirm": "Bestätigen"en.json:"btn_confirm": "Confirm"es.json:"btn_confirm": "Confirmar"3. First failing test — round-trip
The first test drives the entire deferred Promise design:
TestHostis a minimal inline Svelte component — not a shared fixture. Write this test first. It fails until the service, component, and context wiring all exist.4.
use:enhanceintegration pattern — canonical approachcancel()aborts the form submission if the user cancels. No local state, no toggle. This pattern goes in a JSDoc usage comment inconfirm.ts.5.
confirmDestructive()shorthand — rejectedFour callsites don't justify the abstraction.
confirm({ destructive: true })is readable enough. KISS over DRY.AC additions from this discussion
btn_confirmi18n key added tode.json,en.json,es.jsonconfirm()returnsPromise.resolve(false)immediately if already openconfirm.tsincludes a JSDoc usage example for theuse:enhancepatternThe deferred Promise test is the north star. Everything else — the component, the migrations, the
use:enhancewiring — follows from making that test green.Implementierung abgeschlossen ✅
Branch:
feat/issue-207-confirm-modal-serviceWas wurde umgesetzt
ConfirmService-Infrastruktur
src/lib/services/confirm.svelte.ts—createConfirmService(),provideConfirmService(),getConfirmService(), Deferred-Promise-Pattern mit$statesrc/lib/components/ConfirmDialog.svelte— natives<dialog>-Element mitshowModal(), backdrop-close, Escape-Handling, WCAG-AA-konforme Farben (bg-danger text-danger-fg)src/lib/services/confirm.test-host.svelte+confirm.svelte.test.ts— 5 Servicetests (confirm/cancel/Escape/concurrent/kein Provider)src/routes/+layout.svelte—provideConfirmService()+<ConfirmDialog />global eingebundenCSS-Design-Tokens (
layout.css)--c-danger/--c-danger-fgfür Light und Dark Mode (WCAG AA)btn_confirmi18n-Schlüssel in de/en/esMigrationen (alle
window.confirm()und inline Modals ersetzt)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()documents/[id]/edit/SaveBar.svelteconfirmDeletestate)await confirm({ destructive: true })persons/[id]/edit/NameHistoryEditCard.svelte<dialog>-Overlayawait confirm(...)+ hidden formCommits
775d49f— Service + ConfirmDialog + Layout(weitere)— Migration aller KomponentenTests