From ed8ab4483259240510b5276b13719686fc06286b Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 8 Apr 2026 12:01:17 +0200 Subject: [PATCH] feat(sync): conflict visualization with restore-my-version toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes backlog C from the Phase 9 audit. The data layer has had real field-level LWW since Sprint 1, but when the server's value beat a local edit, the user had no way to know. This commit adds the missing UI piece: a toast that appears whenever applyServerChanges overwrites a non-empty local field with a strictly newer server value, with a one-click "restore my version" path. sync.ts — detection ------------------- Two new exports: - SyncConflictPayload: per-field overwrite event shape (tableName, recordId, field, wasLocal, nowServer, localTime, serverTime). - subscribeSyncConflicts(listener): in-module pub/sub. Returns an unsubscribe function. Both LWW branches in applyServerChanges (insert-as-update and the canonical update-with-fields path) now call notifyConflict() when: 1. The server time is STRICTLY greater (not equal) than the local field time → there's actually an edit window to lose 2. The local field value is non-null/undefined → user actually typed something to overwrite 3. The values are not equal (cheap JSON-string compare for objects, === for primitives) → there's a real change, not an idempotent server replay Why a custom registry instead of CustomEvent + window.dispatchEvent? The existing sync-telemetry + quota-detect helpers use window.dispatchEvent which doesn't work in node-based vitest envs (no DOM EventTarget). The conflict bus is small enough that a plain Set is simpler than polyfilling EventTarget — and the node test path matters because we need automated coverage of the detection logic. conflict-store.svelte.ts — UI state ----------------------------------- Svelte 5 $state-backed store with three responsibilities: 1. Coalescing: a SyncConflict is keyed by `${tableName}|${recordId}`, so a burst of N field-overwrites on the same record collapses into ONE toast with all affected fields underneath. The original wasLocal value is preserved across coalescing (we don't clobber the user's first typed value if a later field event arrives). 2. Auto-dismiss: each conflict has a 30s TTL after which it evicts itself. Manual dismiss trumps the timer. 3. Restore: writes wasLocal back to Dexie with a fresh updatedAt that beats the server's serverTime, plus a __fieldTimestamps patch so the field-LWW pass on the next sync round will let our value win. Deferred via setTimeout(0) so it lands AFTER applyServerChanges releases its per-table apply lock — running before the lock release would silently drop the restore (the hook suppression is per-table-set, not per-record). FIFO eviction at MAX_VISIBLE=8 keeps a bursty server from growing the visible array unbounded. SyncConflictToast.svelte — the UI --------------------------------- Mounts globally in +layout.svelte. Stacks bottom-right above the OfflineIndicator. Each toast shows: - Module label ("Aufgabe", "Notiz", "Termin", …) derived from a table-name → German label map. Unknown tables fall through to the bare table name. - Field count summary ("Feld »title«" / "3 Felder") — we deliberately do NOT render the actual values because some are encrypted blobs and decrypting them in the toast would be significant complexity for marginal UX gain. The user knows what they were just editing. - Two buttons: "Wiederherstellen" (calls conflictStore.restore) and "Behalten" (calls dismiss). Slide-in animation, dark-mode-aware styling, role="alertdialog" for accessibility. Wiring ------ data-layer-listeners.ts: - Imports installConflictListener from conflict-store - Calls it from installDataLayerListeners() right after the quota + telemetry handlers - Adds the disposeConflict() call to the cleanup return +layout.svelte: - Imports SyncConflictToast and mounts it next to SuggestionToast so it inherits the same global-overlay positioning context Tests ----- Five new integration tests in sync.test.ts cover: - Fires when server overwrites a non-empty local field with a strictly newer value - Does NOT fire when local field is null/undefined (no edit to lose) - Does NOT fire when values are equal (idempotent replay) - Fires once per overwritten field on a multi-field update - Does NOT fire on a timestamp tie (LWW lets server win silently when there's no real edit window) All 25 sync tests + 138 total data-layer tests pass. The new captureConflicts() helper subscribes via subscribeSyncConflicts() which works in the node-vitest env without needing a DOM polyfill. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lib/components/SyncConflictToast.svelte | 245 ++++++++++++++++++ .../web/src/lib/data/conflict-store.svelte.ts | 227 ++++++++++++++++ .../web/src/lib/data/data-layer-listeners.ts | 8 + apps/mana/apps/web/src/lib/data/sync.test.ts | 169 ++++++++++++ apps/mana/apps/web/src/lib/data/sync.ts | 120 +++++++++ apps/mana/apps/web/src/routes/+layout.svelte | 2 + 6 files changed, 771 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/components/SyncConflictToast.svelte create mode 100644 apps/mana/apps/web/src/lib/data/conflict-store.svelte.ts diff --git a/apps/mana/apps/web/src/lib/components/SyncConflictToast.svelte b/apps/mana/apps/web/src/lib/components/SyncConflictToast.svelte new file mode 100644 index 000000000..b35973bb5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/SyncConflictToast.svelte @@ -0,0 +1,245 @@ + + + +{#if conflicts.length > 0} +
+ {#each conflicts as conflict (conflict.id)} +
+
+ + + {labelFor(conflict.tableName)} überschrieben + + +
+

+ Eine andere Sitzung hat dein Edit auf {fieldList(conflict.fields)} überschrieben. Soll deine + Version wiederhergestellt werden? +

+
+ + +
+
+ {/each} +
+{/if} + + diff --git a/apps/mana/apps/web/src/lib/data/conflict-store.svelte.ts b/apps/mana/apps/web/src/lib/data/conflict-store.svelte.ts new file mode 100644 index 000000000..bd11a34e9 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/conflict-store.svelte.ts @@ -0,0 +1,227 @@ +/** + * Conflict Store — surfaces sync field-LWW overwrites to the user. + * + * Subscribes to the `mana-sync-conflict` CustomEvent that + * `applyServerChanges` fires whenever it overwrites a non-empty local + * field with a strictly-newer server value. Each event becomes a row + * in the visible-conflicts list, which a toast component renders so + * the user can either: + * + * - Restore (writes the original local value back with a fresh + * updatedAt that beats the server's, queues a sync push) + * - Dismiss (just removes the toast — server value stays) + * + * Coalescing + * ---------- + * Multiple conflicts on the same record collapse into ONE entry, with + * a per-field map underneath. A burst of 5 server changes hitting the + * same task only shows one toast (not five), and Restore reverts ALL + * affected fields in one transaction. The coalesce key is + * `${tableName}|${recordId}` and the dedup window is the time the + * record stays in `visible`. + * + * Auto-dismiss + * ------------ + * Each conflict expires after CONFLICT_TTL_MS (default 30s) so a long- + * running session doesn't accumulate stale toasts. Manual dismiss + * trumps the timer. + * + * Restore semantics + * ----------------- + * Restoration runs OUTSIDE the apply lock — applyServerChanges holds + * a per-table lock that suppresses pending-change tracking, and we + * want our restore write to be tracked. We defer via setTimeout(0) + * which lands after the current microtask drain, by which time the + * apply lock has been released in the finally block. + */ + +import { db } from './database'; +import { subscribeSyncConflicts, type SyncConflictPayload } from './sync'; +import { FIELD_TIMESTAMPS_KEY } from './database'; + +/** How long a conflict stays visible before auto-dismissing. */ +const CONFLICT_TTL_MS = 30_000; + +/** Cap on the visible list — older entries get evicted FIFO so a + * bursty server can't grow the array unbounded. */ +const MAX_VISIBLE = 8; + +/** A coalesced conflict — one per (tableName, recordId), with the + * per-field overwrite details merged. */ +export interface SyncConflict { + /** Stable id for the toast key — `${tableName}|${recordId}`. */ + id: string; + tableName: string; + recordId: string; + /** Per-field overwrite details. New fields from later events get + * merged in; if the same field fires twice, the latest server + * state wins (we don't try to be smarter — restore would still + * bring back the original local value from the first event). */ + fields: Record< + string, + { + wasLocal: unknown; + nowServer: unknown; + localTime: string; + serverTime: string; + } + >; + detectedAt: string; // ISO +} + +let visible = $state([]); +let timers = new Map>(); +let installed = false; + +function scheduleAutoDismiss(id: string): void { + const existing = timers.get(id); + if (existing) clearTimeout(existing); + const t = setTimeout(() => dismiss(id), CONFLICT_TTL_MS); + timers.set(id, t); +} + +function clearTimer(id: string): void { + const t = timers.get(id); + if (t) { + clearTimeout(t); + timers.delete(id); + } +} + +function ingest(payload: SyncConflictPayload): void { + const id = `${payload.tableName}|${payload.recordId}`; + const now = new Date().toISOString(); + + const existing = visible.find((c) => c.id === id); + if (existing) { + // Coalesce a new field-overwrite into the existing entry. + // Don't clobber the original wasLocal — that's the value the + // user actually typed. Only update nowServer + serverTime. + const prior = existing.fields[payload.field]; + existing.fields[payload.field] = { + wasLocal: prior ? prior.wasLocal : payload.wasLocal, + nowServer: payload.nowServer, + localTime: prior ? prior.localTime : payload.localTime, + serverTime: payload.serverTime, + }; + // Touch the timer so the toast stays visible while bursts arrive. + scheduleAutoDismiss(id); + // Force reactivity — Svelte 5 needs a new array reference for + // $derived consumers to re-render. Mutating in place leaves + // $effect blind. + visible = [...visible]; + return; + } + + const fresh: SyncConflict = { + id, + tableName: payload.tableName, + recordId: payload.recordId, + fields: { + [payload.field]: { + wasLocal: payload.wasLocal, + nowServer: payload.nowServer, + localTime: payload.localTime, + serverTime: payload.serverTime, + }, + }, + detectedAt: now, + }; + + // FIFO eviction if we're at the cap. + const next = [...visible, fresh]; + if (next.length > MAX_VISIBLE) { + const evicted = next.shift(); + if (evicted) clearTimer(evicted.id); + } + visible = next; + scheduleAutoDismiss(id); +} + +function dismiss(id: string): void { + clearTimer(id); + visible = visible.filter((c) => c.id !== id); +} + +async function restore(id: string): Promise { + const conflict = visible.find((c) => c.id === id); + if (!conflict) return; + + // Defer the actual write so we land after applyServerChanges has + // released its apply-lock for this table. setTimeout(0) is enough + // — the lock is released synchronously in the finally block, and + // the next macrotask runs after that. + await new Promise((resolve) => setTimeout(resolve, 0)); + + const now = new Date().toISOString(); + const updates: Record = { updatedAt: now }; + const ftPatch: Record = {}; + + for (const [field, info] of Object.entries(conflict.fields)) { + updates[field] = info.wasLocal; + ftPatch[field] = now; + } + + // Read the current row's __fieldTimestamps and merge our patch in + // so we don't blow away unrelated server-side timestamps. + const row = await db.table(conflict.tableName).get(conflict.recordId); + if (row) { + const existingFT = + ((row as Record)[FIELD_TIMESTAMPS_KEY] as Record) ?? {}; + updates[FIELD_TIMESTAMPS_KEY] = { ...existingFT, ...ftPatch }; + } else { + updates[FIELD_TIMESTAMPS_KEY] = ftPatch; + } + + try { + await db.table(conflict.tableName).update(conflict.recordId, updates); + } catch (err) { + console.error( + `[mana-conflict] restore failed for ${conflict.tableName}/${conflict.recordId}:`, + err + ); + // Leave the toast in place so the user knows the restore didn't + // land — they can retry or dismiss manually. + return; + } + + dismiss(id); +} + +/** Boot-time installer. Wires a single sync-conflict subscriber via + * the in-module pub/sub helper from sync.ts. The data-layer-listeners + * module calls this in the same place that installs the quota + + * telemetry listeners, so it's symmetrical with every other sync-side + * observer. */ +export function installConflictListener(): () => void { + if (installed) return () => {}; + installed = true; + + const unsubscribe = subscribeSyncConflicts((payload) => { + ingest(payload); + }); + + return () => { + unsubscribe(); + for (const t of timers.values()) clearTimeout(t); + timers.clear(); + visible = []; + installed = false; + }; +} + +/** Read-only view of the visible conflicts for the toast component. */ +export const conflictStore = { + get visible() { + return visible; + }, + dismiss, + restore, + /** Test-only: clear everything without going through the listener. */ + _resetForTesting() { + for (const t of timers.values()) clearTimeout(t); + timers.clear(); + visible = []; + installed = false; + }, +}; diff --git a/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts b/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts index 294421403..e38a6c090 100644 --- a/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts +++ b/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts @@ -22,6 +22,7 @@ import { QUOTA_EVENT, type QuotaExceededDetail } from './quota-detect'; import { cleanupTombstones } from './quota'; import { pruneActivityLog } from './activity'; import { SYNC_TELEMETRY_EVENT, type SyncTelemetryDetail } from './sync-telemetry'; +import { installConflictListener } from './conflict-store.svelte'; /** How often to run the tombstone cleanup. 24h is a comfortable cadence * given that the cutoff is 30 days — runs roughly once per app session. */ @@ -99,6 +100,12 @@ export function installDataLayerListeners(): () => void { window.addEventListener(QUOTA_EVENT, handleQuota); window.addEventListener(SYNC_TELEMETRY_EVENT, handleTelemetry); + // ─── Sync conflict listener (Backlog C) ──────────────────── + // Wires the field-LWW overwrite events into the conflict store + // that the SyncConflictToast component reads. The store handles + // coalescing, auto-dismiss, and the restore-write path. + const disposeConflict = installConflictListener(); + // ─── Periodic cleanup loop ───────────────────────────────── // Runs once on boot, then daily. Two independent jobs share the // schedule so we never have a third interval competing for the same @@ -143,5 +150,6 @@ export function installDataLayerListeners(): () => void { window.removeEventListener(QUOTA_EVENT, handleQuota); window.removeEventListener(SYNC_TELEMETRY_EVENT, handleTelemetry); window.clearInterval(cleanupTimer); + disposeConflict(); }; } diff --git a/apps/mana/apps/web/src/lib/data/sync.test.ts b/apps/mana/apps/web/src/lib/data/sync.test.ts index bb17d9f16..2ff232530 100644 --- a/apps/mana/apps/web/src/lib/data/sync.test.ts +++ b/apps/mana/apps/web/src/lib/data/sync.test.ts @@ -37,7 +37,9 @@ import { isValidSyncChange, readFieldTimestamps, applyServerChanges, + subscribeSyncConflicts, type SyncChange, + type SyncConflictPayload, } from './sync'; import { db, FIELD_TIMESTAMPS_KEY } from './database'; @@ -341,4 +343,171 @@ describe('applyServerChanges (Dexie integration)', () => { .toArray(); expect(pendingForTaskF).toEqual([]); }); + + // ─── Sync conflict event detection (Backlog C) ───────────── + + describe('sync-conflict events', () => { + // Helper: collect every conflict event fired during the body + // of `fn`, then resolve to the captured payloads. Cleans up the + // listener even on test failure. + async function captureConflicts(fn: () => Promise): Promise { + const captured: SyncConflictPayload[] = []; + // In-module pub/sub from sync.ts — works in node-vitest + + // browser without DOM EventTarget polyfills. + const unsubscribe = subscribeSyncConflicts((payload) => { + captured.push(payload); + }); + try { + await fn(); + } finally { + unsubscribe(); + } + return captured; + } + + it('fires when the server overwrites a non-empty local field with a strictly newer value', async () => { + await db.table('tasks').add({ + id: 'task-conflict-1', + title: 'my version', + priority: 'low', + isCompleted: false, + order: 0, + }); + + const conflicts = await captureConflicts(async () => { + await applyServerChanges('todo', [ + { + table: 'tasks', + id: 'task-conflict-1', + op: 'update', + fields: { + title: { value: 'their version', updatedAt: '2099-01-01T00:00:00Z' }, + }, + }, + ]); + }); + + expect(conflicts).toHaveLength(1); + expect(conflicts[0]).toMatchObject({ + tableName: 'tasks', + recordId: 'task-conflict-1', + field: 'title', + wasLocal: 'my version', + nowServer: 'their version', + }); + }); + + it('does NOT fire when the local field is null/undefined (no edit to lose)', async () => { + await db.table('tasks').add({ + id: 'task-conflict-2', + title: null, + priority: 'low', + isCompleted: false, + order: 0, + }); + + const conflicts = await captureConflicts(async () => { + await applyServerChanges('todo', [ + { + table: 'tasks', + id: 'task-conflict-2', + op: 'update', + fields: { + title: { value: 'first server title', updatedAt: '2099-01-01T00:00:00Z' }, + }, + }, + ]); + }); + + expect(conflicts).toHaveLength(0); + }); + + it('does NOT fire when the values are equal (idempotent server replay)', async () => { + await db.table('tasks').add({ + id: 'task-conflict-3', + title: 'same value', + priority: 'low', + isCompleted: false, + order: 0, + }); + + const conflicts = await captureConflicts(async () => { + await applyServerChanges('todo', [ + { + table: 'tasks', + id: 'task-conflict-3', + op: 'update', + fields: { + title: { value: 'same value', updatedAt: '2099-01-01T00:00:00Z' }, + }, + }, + ]); + }); + + expect(conflicts).toHaveLength(0); + }); + + it('fires once per overwritten field (multi-field conflicts)', async () => { + await db.table('tasks').add({ + id: 'task-conflict-4', + title: 'local title', + priority: 'low', + isCompleted: false, + order: 0, + }); + + const conflicts = await captureConflicts(async () => { + await applyServerChanges('todo', [ + { + table: 'tasks', + id: 'task-conflict-4', + op: 'update', + fields: { + title: { value: 'server title', updatedAt: '2099-01-01T00:00:00Z' }, + priority: { value: 'high', updatedAt: '2099-01-01T00:00:00Z' }, + }, + }, + ]); + }); + + expect(conflicts).toHaveLength(2); + expect(conflicts.map((c) => c.field).sort()).toEqual(['priority', 'title']); + }); + + it('does NOT fire when the server timestamp equals the local one (LWW tie)', async () => { + // Seed with a known timestamp via the insert path so we can + // match the server time exactly. + await applyServerChanges('todo', [ + { + table: 'tasks', + id: 'task-conflict-5', + op: 'insert', + data: { + id: 'task-conflict-5', + title: 'tied title', + priority: 'low', + isCompleted: false, + order: 0, + updatedAt: '2026-04-01T00:00:00Z', + }, + }, + ]); + + const conflicts = await captureConflicts(async () => { + await applyServerChanges('todo', [ + { + table: 'tasks', + id: 'task-conflict-5', + op: 'update', + fields: { + title: { value: 'changed', updatedAt: '2026-04-01T00:00:00Z' }, // exact tie + }, + }, + ]); + }); + + // Tied — LWW lets server win silently (no edit-loss to surface) + expect(conflicts).toHaveLength(0); + }); + }); }); diff --git a/apps/mana/apps/web/src/lib/data/sync.ts b/apps/mana/apps/web/src/lib/data/sync.ts index 2e5fbb5f6..98df318a8 100644 --- a/apps/mana/apps/web/src/lib/data/sync.ts +++ b/apps/mana/apps/web/src/lib/data/sync.ts @@ -142,6 +142,88 @@ export function readFieldTimestamps(record: unknown): Record { * concurrent user writes to OTHER tables continue tracking normally. * Malformed entries are dropped before any DB work happens. */ +/** + * Per-conflict event payload — emitted when applyServerChanges field-LWW + * overwrites a local field value with a strictly newer server value. + * + * `wasLocal` is the value the user had typed before the sync overwrite. + * `nowServer` is what the row holds now. The conflict store reads both + * to give the user a "restore my version" option, which writes wasLocal + * back with a fresh updatedAt so it wins on the next sync round. + * + * Conflicts are NOT raised when the local field was empty (no edit to + * lose), when the values are identical, or when the timestamps are + * exactly equal (LWW lets the server win on ties but there's nothing + * meaningful to surface — both clients agreed at the same moment). + */ +export interface SyncConflictPayload { + tableName: string; + recordId: string; + field: string; + wasLocal: unknown; + nowServer: unknown; + localTime: string; + serverTime: string; +} + +/** Identifier kept around for legacy callers + readable telemetry — + * the conflict bus is a plain in-module pub/sub (see below) so it + * works the same way in browser and node test envs. */ +export const SYNC_CONFLICT_EVENT = 'mana-sync-conflict'; + +/** Subscriber callback shape. */ +export type SyncConflictListener = (payload: SyncConflictPayload) => void; + +/** Active subscribers. Set so dedup is automatic and unsubscribe is O(1). */ +const conflictListeners = new Set(); + +/** + * Subscribe to sync-conflict events. Returns an unsubscribe function. + * + * Why a custom registry instead of CustomEvent + window.dispatchEvent? + * - Works in node-based vitest envs where `window` doesn't exist + * - No accidental coupling to the DOM EventTarget surface + * - Lighter than spinning up an EventTarget polyfill in tests + * + * The conflict-store module installs exactly one subscriber per app + * lifecycle. The test file installs a temporary one per test case + * via this same API. + */ +export function subscribeSyncConflicts(listener: SyncConflictListener): () => void { + conflictListeners.add(listener); + return () => { + conflictListeners.delete(listener); + }; +} + +function notifyConflict(payload: SyncConflictPayload): void { + // Fan out to every subscriber. Errors in one listener don't break + // the rest — sync detection is best-effort and we don't want a + // broken UI handler to corrupt the apply path. + for (const listener of conflictListeners) { + try { + listener(payload); + } catch (err) { + console.error('[mana-sync] conflict listener threw:', err); + } + } +} + +/** Cheap structural equality for sync-conflict comparison. We don't + * need a deep diff here — `===` for primitives, JSON-string compare + * for objects (including encrypted-blob strings, which compare as + * raw strings). Hot path is rare so the JSON serialise cost is fine. */ +function valuesEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a == null || b == null) return false; + if (typeof a !== typeof b) return false; + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +} + export async function applyServerChanges(appId: string, changes: unknown[]): Promise { // Reject malformed entries up-front so a single bad row from the server // can never write garbage into IndexedDB. Drops are logged once and the @@ -244,6 +326,26 @@ export async function applyServerChanges(appId: string, changes: unknown[]): Pro if (key === 'id' || key === FIELD_TIMESTAMPS_KEY) continue; const localFieldTime = localFT[key] ?? localUpdatedAt; if (recordTime >= localFieldTime) { + // Conflict signal: server STRICTLY wins (>) and the local + // field had a non-empty value that differs from the new + // one. Equal-time ties don't fire because there's no + // edit to lose. + const localValue = (existing as Record)[key]; + if ( + recordTime > localFieldTime && + localValue != null && + !valuesEqual(localValue, val) + ) { + notifyConflict({ + tableName, + recordId, + field: key, + wasLocal: localValue, + nowServer: val, + localTime: localFieldTime, + serverTime: recordTime, + }); + } updates[key] = val; newFT[key] = recordTime; } @@ -284,6 +386,24 @@ export async function applyServerChanges(appId: string, changes: unknown[]): Pro const serverTime = fc.updatedAt ?? ''; const localFieldTime = localFT[key] ?? localUpdatedAt; if (serverTime >= localFieldTime) { + // Same conflict criteria as the insert-as-update path: + // strictly newer + non-empty local + actually different. + const localValue = (existing as Record)[key]; + if ( + serverTime > localFieldTime && + localValue != null && + !valuesEqual(localValue, fc.value) + ) { + notifyConflict({ + tableName, + recordId, + field: key, + wasLocal: localValue, + nowServer: fc.value, + localTime: localFieldTime, + serverTime, + }); + } updates[key] = fc.value; newFT[key] = serverTime; } diff --git a/apps/mana/apps/web/src/routes/+layout.svelte b/apps/mana/apps/web/src/routes/+layout.svelte index 92d4a6a3c..5d1083f32 100644 --- a/apps/mana/apps/web/src/routes/+layout.svelte +++ b/apps/mana/apps/web/src/routes/+layout.svelte @@ -12,6 +12,7 @@ import SuggestionToast from '$lib/components/SuggestionToast.svelte'; import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte'; import RecoveryCodeUnlockModal from '$lib/components/RecoveryCodeUnlockModal.svelte'; + import SyncConflictToast from '$lib/components/SyncConflictToast.svelte'; import OfflineIndicator from '$lib/components/OfflineIndicator.svelte'; import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte'; @@ -106,6 +107,7 @@ {@render children()} +