Component contract
open: boolean— controlled. Parent opens when their save returns 409.conflictKind: ConflictKind— "version-mismatch" | "publish-in-progress" | "session-expired" | "edit-lock-held"yourChanges: ChangeSet— labeled diff of what the user did (added/changed/removed fields with values)serverState: ChangeSet— current server state for the same fieldsonChoose: (decision) => Promise<void>— fires with decision: "discard-mine" | "overwrite" | "refresh-and-merge"onCancel: () => void— closes the modal without choosing; user returns to their stale-form state to re-evaluateotherActorName?: string— when known, "Bob also edited this 30 seconds ago"; when not, "Another user changed this server-side"contextLabel: string— human-readable identifier of what's in conflict ("Event 'Acme Conference' settings", "Access type 'VIP'")allowOverwrite?: boolean— defaults to true; some surfaces (audit-critical) set false to force discard-or-merge only
Composition
ui-modal— focus-trap + esc-cancel + focus-returns-to-triggerui-data-table— for the diff display (your-value | server-value per field row)ui-status-pill— labels each row with "Changed by you" / "Changed by [other]" / "You didn't change"ui-destructive-confirmation— wraps the "overwrite" action because it discards another user's workui-toast— confirms the chosen action ("Refreshed and merged your changes")
Interaction surface
-
Modal opens after parent's save returns 409.
Title reflects conflictKind: "Someone else edited this" / "Publish in progress" / "Your session expired" / "Another organizer is editing." Body header: contextLabel + otherActorName context.
-
Diff table renders.
Per row: field name + your-value + server-value + status pill. Rows where your value matches server (no actual conflict) are hidden by default, expandable via "Show unchanged fields." Diff is field-level (not whole-document) to make decisions tractable.
-
Three primary actions.
"Discard my changes" (safe, default focus on this), "Refresh and merge" (loads server state into the form, lets user re-apply their changes manually), "Overwrite with my changes" (uses ui-destructive-confirmation; explicitly discards other user's work).
-
Cancel returns to stale form.
User can close modal to inspect their form state more carefully, then reopen the diff via parent's "Re-check conflict" CTA. This is for situations where the user wants to manually copy specific values before merging.
-
Edit-lock-held variant.
When conflictKind=edit-lock-held, the modal is mostly informational: "Bob is currently editing this. Your changes are saved as a draft and will sync when the lock releases." Diff hidden. Single CTA: "Got it" (closes modal, parent enters draft-saved state).
Failure modes
Default focus on Discard, not Overwrite
Trigger: modal opens.
Default focus is the Discard button (safest action). Inherits ui-destructive-confirmation's friction principle — destructive overwriting requires intentional movement. Harness: open modal, document.activeElement is Discard.
Overwrite uses ui-destructive-confirmation
Trigger: user clicks Overwrite.
Inner ui-destructive-confirmation modal opens: "Overwrite [contextLabel]? This discards the other user's changes." Type-to-confirm uses contextLabel as the type-target. Only confirmed overwrite calls onChoose. Harness: click overwrite, ui-destructive-confirmation visible.
Diff is field-level, not document-blob
Trigger: server state has 47 fields, only 3 actually differ from user's changes.
Diff table shows the 3 differing rows by default. Other 44 are hidden behind "Show unchanged fields (44)" expander. User isn't asked to scroll a 47-row diff to find the 3 conflicts. Harness: stub 47 fields with 3 conflicts, table shows 3 rows by default + expander.
Other-actor name surfaced when known
Trigger: server returns the other actor's display name.
Header reads "Bob (organizer) edited this 30 seconds ago." When name unavailable, falls back to "Another user." Never reveals other actor's email or other PII beyond display name. Harness: stub otherActorName, displayed; stub null, fallback shown.
Edit-lock-held minimal UI
Trigger: conflictKind=edit-lock-held.
Diff table NOT shown. Single "Got it" CTA. Body explicitly says "Your changes are saved as a draft and will sync when the lock releases." Harness: kind=edit-lock-held, diff table not visible, single CTA visible.
Session-expired routes to re-auth
Trigger: conflictKind=session-expired.
Three actions replaced by one: "Sign in again" — opens re-auth flow. After successful re-auth, parent retries the save automatically with same idempotency key. Harness: kind=session-expired, only signin CTA visible.
allowOverwrite=false hides Overwrite
Trigger: high-stakes surface (e.g., refund flow) where overwriting is forbidden by policy.
When allowOverwrite=false, Overwrite button hidden. Only Discard + Refresh-and-merge are available. Harness: allowOverwrite=false, overwrite-button not visible.
Refresh-and-merge re-fetches server state cleanly
Trigger: user clicks Refresh-and-merge.
Modal closes; parent re-fetches the current server state into the form; user's prior changes are NOT auto-applied (the user manually re-applies what they want, with full visibility). Harness: click refresh, parent's fetch fires, form reflects server state.
Re-check via parent CTA reopens with fresh diff
Trigger: user cancels modal, manually inspects form, clicks "Re-check conflict" parent CTA.
Modal reopens; parent re-fetches server state; diff updates with current server state. Useful when other user is making rapid changes. Harness: cancel + re-check, fresh diff shown.
Audit row per resolution decision
Trigger: user makes any of the 3 decisions.
onChoose's parent should write an audit row capturing the conflict + decision + actor. Especially important for overwrite (since it discards another user's work). Harness: click each of the 3 decisions in turn, 3 audit rows recorded with distinct decision values.
Modal can't open during in-flight save
Trigger: save is in flight (busy state); 409 returns; modal opens; user clicks Refresh-and-merge while ANOTHER save is somehow attempting.
Modal acquires a UI-level lock — while open, parent's submit button is disabled. Re-saving requires modal close. Harness: open + click refresh, parent's submit observed disabled until modal closes.
Accessibility
- Inherits ui-modal: role=dialog, aria-modal=true, focus-trap, esc-closes, focus-returns-to-trigger.
- Title is the modal's aria-labelledby target.
- Body description is the aria-describedby target.
- Diff table inherits ui-data-table a11y.
- Action buttons have descriptive aria-labels including the consequence ("Discard my changes to access type 'VIP'").
- Color contrast: status pills inherit ui-status-pill registry contrast.
- axe-clean ≥ serious across all conflictKind variants.
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
ui-conflict-resolution-modal | Outer dialog | data-conflict-kind attr |
ui-conflict-resolution-title | Modal title | aria-labelledby target |
ui-conflict-resolution-other-actor | "Bob edited this..." line | Visible when otherActorName provided |
ui-conflict-resolution-diff-table | Diff display | Visible for version-mismatch/publish-in-progress; hidden for edit-lock/session-expired |
ui-conflict-resolution-show-unchanged | Expander | Visible only when there are unchanged-but-different fields |
ui-conflict-resolution-discard-button | Discard CTA | Default focus target |
ui-conflict-resolution-refresh-button | Refresh-and-merge CTA | Always visible |
ui-conflict-resolution-overwrite-button | Overwrite CTA | Visible only when allowOverwrite |
ui-conflict-resolution-signin-button | Sign-in CTA | Visible only when kind=session-expired |
ui-conflict-resolution-got-it-button | Got-it CTA | Visible only when kind=edit-lock-held |
ui-conflict-resolution-cancel-button | Cancel CTA | Closes without choosing |
Agent test plan
Probe list
- default-focus-on-discard: open modal, activeElement = ui-conflict-resolution-discard-button
- overwrite-uses-destructive-confirmation: click overwrite, ui-destructive-confirmation visible
- diff-field-level-not-blob: stub 47 fields with 3 conflicts, only 3 rows visible by default
- show-unchanged-expander: click expander, all 47 rows visible
- other-actor-displayed-when-known: stub otherActorName, displayed
- other-actor-fallback: stub null, "Another user" fallback
- edit-lock-held-minimal-ui: kind=edit-lock-held, no diff table, only got-it
- session-expired-signin-only: kind=session-expired, only signin CTA
- allow-overwrite-false-hides: allowOverwrite=false, overwrite hidden
- refresh-merge-refetches: click refresh, parent fetch fires
- recheck-via-parent-reopens: cancel + re-check parent CTA, modal reopens with fresh diff
- audit-row-per-decision: each of 3 decisions, 3 audit rows
- ui-lock-during-modal-open: parent submit disabled while open
- color-contrast-status-pill: ≥ 4.5:1
- axe-clean-across-kinds: every conflictKind passes axe ≥ serious
Current consumers
| Branch | Conflict kinds used |
|---|---|
| EF-008 create-or-copy-event | version-mismatch |
| EF-011 event-info-settings | version-mismatch + publish-in-progress |
| EF-013 collaborators | version-mismatch |
| EF-015 access-types | version-mismatch + publish-in-progress |
| EF-021 ticket-quantities-windows | version-mismatch + publish-in-progress |
| EF-046 edit-guest | version-mismatch |
| EF-049 resend-invitations | version-mismatch |
| EF-050 last-contacted | version-mismatch |
| EF-074 polling-results-visualizer | edit-lock-held (organizer pause-mid-poll) |
| EF-099 zoom-via-zapier | version-mismatch |
| (implicit in many cluster-B branches) | — |