Exclusivebeta
Soft Lock — <mv-soft-lock>
Soft locking and collision warnings for records that several people (or tabs) can change at once: invoices, tickets, documents, config pages, bulk operations. The app reports presence per resource with claim(resourceId, { user, action: "viewing" | "editing" | "running", note }), typically from a websocket, and tabs of the same browser see each other through a built-in BroadcastChannel. Every element marked data-lock="invoice-4821" shows a calm, always-textual indicator when someone else is there (faces, “Aiko is editing this invoice · 2 min”), and escalates the moment you start editing the same thing (your first input is claimed automatically): “You’re both editing — changes may collide”, with Take over (cancelable mv-take-over, the other side is notified), Ask to hand over (the app relays the request, the answer comes back), View only (marked controls are disabled, then restored exactly) and Edit anyway. Two thin edges, theirs from one side and yours from the other, converge on the contested element and always stop short of each other. Claims heartbeat on a TTL and stale ones expire on their own, submit and reset end an edit, a compact variant fits list rows, and the conflict is announced assertively once.
| Category | Feedback |
|---|---|
| Type | Web Component (<mv-soft-lock>) |
| Status | beta |
| Also installs | button |
| Keywords | exclusive, culture, soft-lock, locking, presence, collaboration, conflict, collision, concurrent-editing, multi-tab, broadcast-channel, websocket, heartbeat, take-over, handover, view-only, back-office, records, bulk-operations |
When to use
- Two agents can open the same ticket, invoice or customer record and silently overwrite each other's changes
- A back office wants optimistic editing with a visible warning instead of hard locks that block people
- Bulk operations (exports, mass emails, imports) must not run twice on the same segment at the same time
- The same user often has a record open in several tabs and saves stale data from one of them
Avoid when
- The goal is to show where collaborators are inside a long document or spreadsheet, not whether a record is contested → use Footprints instead
- The page is a team presence board (available, in a meeting, away) with no shared record being edited → use Whereabouts instead
- Edits must merge character by character in real time: use a CRDT or OT editor, a soft lock only warns and arbitrates
Install
node scripts/add.mjs soft-lock --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["soft-lock"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, components/soft-lock/soft-lock.js, components/soft-lock/soft-lock.css, components/button/button.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="sl-demo" style="width:min(100%,68rem);margin-inline:auto">
<style>
#sl-demo .sl-top { display:flex; align-items:flex-end; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap; margin:0 0 1rem }
#sl-demo .sl-top h2 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
#sl-demo .sl-top p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#sl-demo .sl-layout { display:grid; grid-template-columns:minmax(0,1fr) 19rem; gap:1.25rem; align-items:start }
#sl-demo .sl-main, #sl-demo .sl-side { display:grid; gap:1.25rem; min-width:0 }
#sl-demo .sl-card { padding:1.125rem 1.25rem 1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#sl-demo .sl-head { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; margin:0 0 .875rem }
#sl-demo .sl-head h3 { margin:0; font-size:1rem; font-weight:600; letter-spacing:-.01em }
#sl-demo .sl-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#sl-demo fieldset.sl-fields { display:grid; gap:.875rem; margin:0; padding:0; border:0; min-width:0 }
#sl-demo .sl-row { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.875rem }
#sl-demo .sl-foot { display:flex; align-items:center; justify-content:flex-end; gap:.5rem; padding-top:.25rem }
#sl-demo .sl-foot .sl-saved { margin-inline-end:auto; color:var(--mv-fg-muted); font-size:.75rem }
#sl-demo .sl-bulk { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap }
#sl-demo .sl-bulk p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; max-width:34ch }
#sl-demo .sl-k { margin:0 0 .625rem; color:var(--mv-fg-muted); font-size:.6875rem; letter-spacing:.04em; text-transform:uppercase; font-weight:600 }
#sl-demo .sl-list { list-style:none; margin:0; padding:0; display:grid }
#sl-demo .sl-list li { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.125rem .75rem; align-items:baseline; padding:.625rem 0; border-top:1px solid var(--mv-border) }
#sl-demo .sl-list li:first-child { border-top:0; padding-top:0 }
#sl-demo .sl-list b { font-size:.8125rem; font-weight:600; font-variant-numeric:tabular-nums }
#sl-demo .sl-list .sl-who { grid-column:1 / -1; color:var(--mv-fg-muted); font-size:.75rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#sl-demo .sl-list .sl-amt { font-size:.8125rem; font-variant-numeric:tabular-nums }
#sl-demo .sl-list [data-lock-indicator] { grid-column:1 / -1; margin-top:.25rem; min-width:0 }
#sl-demo .sl-list [data-lock-indicator]:not(:has(.mv-soft-lock-flag:not([hidden]))) { display:none }
#sl-demo .sl-sim { display:grid; gap:.75rem }
#sl-demo .sl-sim .mv-choice { font-size:.8125rem }
#sl-demo .sl-btns { display:flex; flex-wrap:wrap; gap:.5rem }
#sl-demo .sl-log { list-style:none; margin:0; padding:0; display:grid; gap:.3125rem; font-family:var(--mv-font-mono); font-size:.6875rem; color:var(--mv-fg-muted) }
#sl-demo .sl-log b { color:var(--mv-fg); font-weight:500 }
#sl-demo .sl-sep { height:1px; background:var(--mv-border) }
#sl-demo .sl-hint { margin:0; color:var(--mv-fg-muted); font-size:.75rem }
@media (max-width:56rem) { #sl-demo .sl-layout { grid-template-columns:minmax(0,1fr) } }
@media (max-width:34rem) { #sl-demo .sl-row { grid-template-columns:minmax(0,1fr) } }
</style>
<mv-soft-lock id="sl-lock" user-id="u-priya" user-name="Priya Nair" ttl="60s">
<div class="sl-top">
<div>
<h2>Receivables</h2>
<p>Acme Freight · 6 people in Billing today</p>
</div>
<span class="mv-badge" data-variant="outline" data-shape="pill"><span class="mv-badge-dot" style="--mv-badge-dot:var(--mv-success)"></span>Live</span>
</div>
<div class="sl-layout">
<div class="sl-main">
<form class="sl-card" id="sl-invoice" data-lock="invoice-4821" data-lock-noun="invoice" novalidate>
<div class="sl-head">
<div>
<h3>Invoice INV-4821</h3>
<p>Nordvik Logistics AS · issued Sep 18, 2026</p>
</div>
<span class="mv-badge" data-variant="warning"><span class="mv-badge-dot"></span>Draft</span>
</div>
<div data-lock-indicator></div>
<fieldset class="sl-fields" data-lock-edit>
<div class="sl-row">
<div class="mv-field">
<label class="mv-label" for="sl-customer">Customer</label>
<input class="mv-input" id="sl-customer" value="Nordvik Logistics AS" autocomplete="off">
</div>
<div class="mv-field">
<label class="mv-label" for="sl-amount">Amount (USD)</label>
<input class="mv-input" id="sl-amount" value="12,480.00" inputmode="decimal" autocomplete="off">
</div>
</div>
<div class="sl-row">
<div class="mv-field">
<label class="mv-label" for="sl-due">Due date</label>
<input class="mv-input" id="sl-due" value="Oct 18, 2026" autocomplete="off">
</div>
<div class="mv-field">
<label class="mv-label" for="sl-terms">Payment terms</label>
<select class="mv-select" id="sl-terms">
<option>Net 15</option>
<option selected>Net 30</option>
<option>Net 45</option>
<option>Due on receipt</option>
</select>
</div>
</div>
<div class="mv-field">
<label class="mv-label" for="sl-notes">Notes to customer</label>
<textarea class="mv-textarea" id="sl-notes" rows="2">Split shipment: the second pallet (Oslo → Rotterdam) is billed on INV-4830.</textarea>
</div>
<div class="sl-foot">
<span class="sl-saved" id="sl-saved">Last saved Sep 23, 4:12 PM</span>
<button class="mv-button" data-variant="outline" type="reset">Discard</button>
<button class="mv-button" type="submit">Save invoice</button>
</div>
</fieldset>
</form>
<section class="sl-card" data-lock="segment-overdue" data-lock-noun="segment" aria-labelledby="sl-seg-title">
<div class="sl-head">
<div>
<h3 id="sl-seg-title">Overdue over 30 days</h3>
<p>214 accounts · $186,420.75 outstanding</p>
</div>
<span class="mv-badge" data-variant="danger">Overdue</span>
</div>
<div data-lock-indicator></div>
<div class="sl-bulk">
<p>Email a payment reminder with a pay-now link to every account in this segment.</p>
<button class="mv-button" data-variant="outline" type="button" id="sl-run">Send reminders</button>
</div>
</section>
</div>
<aside class="sl-side">
<section class="sl-card" aria-labelledby="sl-recent">
<p class="sl-k" id="sl-recent">Recent invoices</p>
<ul class="sl-list">
<li data-lock="invoice-4821" data-lock-variant="compact">
<b>INV-4821</b><span class="sl-amt">$12,480.00</span>
<span class="sl-who">Nordvik Logistics AS</span><span data-lock-indicator></span>
</li>
<li data-lock="invoice-4822" data-lock-variant="compact">
<b>INV-4822</b><span class="sl-amt">$3,215.50</span>
<span class="sl-who">Café Lumière</span><span data-lock-indicator></span>
</li>
<li data-lock="invoice-4819" data-lock-variant="compact">
<b>INV-4819</b><span class="sl-amt">$48,900.00</span>
<span class="sl-who">Sato Robotics</span><span data-lock-indicator></span>
</li>
<li data-lock="invoice-4817" data-lock-variant="compact">
<b>INV-4817</b><span class="sl-amt">$7,064.20</span>
<span class="sl-who">Brightwater Clinics</span><span data-lock-indicator></span>
</li>
</ul>
</section>
<section class="sl-card sl-sim" aria-labelledby="sl-sim-title">
<p class="sl-k" id="sl-sim-title" style="margin:0">Teammates (simulated)</p>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" data-sim="aiko" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Aiko edits INV-4821</span></span>
</label>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" data-sim="marcus" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Marcus exports the segment</span></span>
</label>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" data-sim="lena" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Lena views INV-4822</span></span>
</label>
<div class="sl-btns">
<button class="mv-button" data-variant="secondary" data-size="sm" type="button" id="sl-ask">Aiko asks for it</button>
<button class="mv-button" data-variant="secondary" data-size="sm" type="button" id="sl-grab">Aiko takes over</button>
</div>
<div class="sl-sep"></div>
<ol class="sl-log" id="sl-log" aria-label="Event log"><li>Type in the invoice to join Aiko’s edit</li></ol>
<p class="sl-hint">Open this demo in a second tab: both tabs see each other.</p>
</section>
</aside>
</div>
</mv-soft-lock>
<script type="module">
const lock = document.getElementById("sl-lock");
await customElements.whenDefined("mv-soft-lock");
const $ = (id) => document.getElementById(id);
const min = 60_000;
const people = {
aiko: { user: { id: "u-aiko", name: "Aiko Tanaka", color: "var(--mv-soft-lock-c1)" }, resource: "invoice-4821", action: "editing", ago: 2 * min },
marcus: { user: { id: "u-marcus", name: "Marcus Oyelaran", color: "var(--mv-soft-lock-c5)" }, resource: "segment-overdue", action: "running", note: "a collections export", ago: 1 * min },
lena: { user: { id: "u-lena", name: "Lena Fischer", color: "var(--mv-soft-lock-c3)" }, resource: "invoice-4822", action: "viewing", ago: 5 * min },
};
const on = new Set();
const report = (key) => {
const p = people[key];
lock.claim(p.resource, { user: p.user, action: p.action, note: p.note, since: p.since });
};
const start = (key) => {
const p = people[key];
p.since = Date.now() - (p.ago ?? 0);
p.ago = 0;
on.add(key);
report(key);
};
const stop = (key, reason = "release") => {
on.delete(key);
lock.release(people[key].resource, { user: people[key].user, reason });
const sw = document.querySelector(`#sl-demo [data-sim="${key}"]`);
if (sw) sw.checked = false;
};
// Stand-in for the websocket: every teammate heartbeats well within the 60 s TTL.
const beat = setInterval(() => {
if (!lock.isConnected) return clearInterval(beat);
for (const key of on) report(key);
}, 20_000);
for (const key of Object.keys(people)) start(key);
for (const sw of document.querySelectorAll("#sl-demo [data-sim]")) {
sw.addEventListener("change", () => (sw.checked ? start(sw.dataset.sim) : stop(sw.dataset.sim)));
}
// Event log
const log = $("sl-log");
let fresh = true;
const write = (name, text) => {
if (fresh) { log.replaceChildren(); fresh = false; }
const li = document.createElement("li");
const b = document.createElement("b");
b.textContent = name;
li.append(b, ` ${text}`);
log.prepend(li);
while (log.children.length > 5) log.lastElementChild.remove();
};
const first = (u) => u.name.split(" ")[0];
lock.addEventListener("mv-lock-conflict", (e) => write("lock-conflict", `${e.detail.resource} · with ${e.detail.others.map((c) => first(c.user)).join(", ")}`));
lock.addEventListener("mv-take-over", (e) => write("take-over", `${e.detail.resource} · from ${e.detail.from.map((c) => first(c.user)).join(", ")}`));
lock.addEventListener("mv-resolve", (e) => { if (e.detail.choice !== "take-over") write("resolve", `${e.detail.resource} · ${e.detail.choice}`); });
lock.addEventListener("mv-handover-response", (e) => write("handover-response", `${e.detail.accepted ? "handed over to" : "kept from"} ${first(e.detail.user)}`));
lock.addEventListener("mv-release", (e) => {
const { user, resource, reason, self } = e.detail;
write("release", `${resource} · ${self ? "you" : first(user)} · ${reason}`);
// A teammate who was taken over stops reporting (their app got the notice).
if (!self && reason === "taken-over") for (const [key, p] of Object.entries(people)) if (p.user.id === user.id) stop(key, "taken-over");
});
lock.addEventListener("mv-handover-request", (e) => {
const { resource, to } = e.detail;
write("handover-request", `${resource} · to ${to.map(first).join(", ")}`);
// Simulated answers: Aiko agrees, Marcus wants to finish his export.
setTimeout(() => {
for (const u of to) {
if (u.id === "u-aiko" && on.has("aiko")) stop("aiko", "handover");
if (u.id === "u-marcus" && on.has("marcus")) lock.receive(resource, { type: "handover-declined", user: people.marcus.user });
}
}, 1600);
});
// Aiko's side of the story, when you are the one editing.
$("sl-ask").addEventListener("click", () => {
if (!on.has("aiko")) start("aiko");
if (!lock.receive("invoice-4821", { type: "handover-request", user: people.aiko.user })) write("note", "type in INV-4821 first: nothing to hand over");
});
$("sl-grab").addEventListener("click", () => {
if (!lock.receive("invoice-4821", { type: "take-over", user: people.aiko.user })) return write("note", "type in INV-4821 first: nothing to take over");
const sw = document.querySelector('#sl-demo [data-sim="aiko"]');
sw.checked = true;
start("aiko");
});
// The bulk run claims the segment while it runs.
const run = $("sl-run");
let runTimer = 0;
const finish = () => {
clearTimeout(runTimer);
lock.release("segment-overdue", { reason: "release" });
run.textContent = "Send reminders";
delete run.dataset.running;
};
run.addEventListener("click", () => {
if (run.dataset.running) return finish();
lock.claim("segment-overdue", { action: "running", note: "payment reminders" });
run.textContent = "Stop sending";
run.dataset.running = "true";
clearTimeout(runTimer);
runTimer = setTimeout(finish, 20_000);
});
const form = $("sl-invoice");
form.addEventListener("submit", (e) => {
e.preventDefault();
$("sl-saved").textContent = `Saved ${new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`;
});
</script>
</div>Cultural reference
Ghostbusters — Ivan Reitman (1984, film). The team's one rule is never to cross the streams: two beams touching would be catastrophic, so each one keeps its beam away from the other's. In the UI, two people working on the same record are two streams converging on it: the component makes the overlap visible before it collides, the edges never touch, and someone has to take over, step back to view only, ask for a hand-over or knowingly go ahead.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
user-id / user-name / user-color | string | "me" / "You" / --mv-soft-lock-self | The current user. Claims made without a user are yours for this tab; another claim carrying your id is shown as “you in another tab”. |
ttl | time ("60s", "2m", "500ms", ms number) | 60s | Claim lifetime without a heartbeat (minimum 5 s). Your claims heartbeat every ttl/3 (mv-claim with heartbeat: true, and to other tabs); reports from the app must be repeated before it runs out, or they expire with mv-release reason "expired". A claim can carry its own ttl. |
channel | string | "off" | default | BroadcastChannel name shared by the tabs of the same app: claims, releases, take-overs and hand-over requests travel between tabs with no server. "off" disables it (or where BroadcastChannel is missing). |
auto | "edit" | "view" | "none" | edit | edit: your first input inside a data-lock element claims it as editing; submit and reset end the edit; removing the last element of a resource releases your claim. view: also claims viewing while an element of the resource is on the page (and steps down to viewing instead of releasing). none: only claim() and release() change your presence. |
data-lock | string (resource id) | On any descendant: the resource the element represents. Several elements may share an id (a list row and the detail form); they all show the same state. | |
data-lock-noun | string | item | Word used in the texts: “editing this invoice”, “at the top of the invoice”. |
data-lock-variant | "panel" | "compact" | panel | compact: a single short line with faces (“Aiko · editing”), for table rows and list items; no actions (they live on the panel of the same resource). |
data-lock-indicator | marker attribute | Inside a data-lock element: where the indicator goes. Otherwise it is prepended (panel) or appended (compact) to the element. | |
data-lock-edit | marker attribute | Inside a data-lock element: controls disabled while you are in view only (form controls and fieldsets get disabled, contenteditable becomes false, anything else becomes inert). Previous values are restored exactly. Do not put it on an ancestor of the indicator. | |
data-lock-streams | "off" | Hides the converging edges on that element. They are never drawn on compact elements or table parts. | |
data-lock-state | free | present | busy | conflict | overlap | view-only | request | yours | Set by the component on each data-lock element (style hook), with data-lock-others = number of other people. |
Properties
| Name | Type | Description |
|---|---|---|
user | { id, name, color?, avatar?, short? } | The current user as an object (mirrors the user-* attributes; avatar is an image URL shown in faces, short the first name used in sentences). Changing it re-keys your claims in every tab. |
claims | Claim[] | Every live claim (read-only): { resource, user, action, since, note, tab, self, expires }. |
tabId | string | This page's tab id (read-only). Send it with your own claims through the server so your other devices can tell the tabs apart. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (English defaults), with {names}, {is}, {action}, {noun}, {note}, {time}, {ago} placeholders. Keys include line, lineRunning, lineSelfTab, conflictTitle, conflictTitleMany, conflictTitleRun, conflictText, overlap, viewOnly, takenOver, tookOver, request, the action labels (takeOver, ask, viewOnlyAction, editAnyway, runAnyway, options, startEditing, askBack, handOver, keepEditing) and announce*. |
Methods
| Name | Description |
|---|---|
claim(resource, { user?, action?, since?, note?, ttl?, tab? }) | Reports presence. Without user it is yours (action defaults to "editing", heartbeats by itself, shared with your tabs, emits mv-claim). With user it is someone else's report from your server; calling it again refreshes it. since (Date or ms) is when they started; note describes a run (“a collections export”). Returns the claim. |
release(resource, { user?, tab?, reason? }) | Removes your claim, or every claim of that user (and tab) on the resource. Emits mv-release. |
sync(list, { resource? }) | Replaces every claim reported by the app (all resources, or one) with a server snapshot: [{ resource, user, action, since, note, tab }]. Missing ones are released with reason "sync"; tab claims are untouched. |
resolve(resource, choice) | Applies a conflict choice from code: "take-over", "ask", "view-only" or "edit-anyway" (the same paths as the buttons, events included). Returns false when nobody else is active. |
receive(resource, { type, user }) | Relays what someone else did about your claim: "take-over" (you switch to view only, told who took over), "handover-request" (shows Hand over / Keep editing) or "handover-declined". Returns false if it does not apply (e.g. you are not editing). Tabs of the same browser do this for you. |
respond(resource, accepted) | Answers an incoming hand-over request: true releases your edit and switches to view only, false keeps editing. Emits mv-handover-response. |
stateOf(resource) | { state, others, mine, choice } for a resource. |
Events
| Name | Description |
|---|---|
mv-lock-conflict | You and at least one other person are both editing or running the same resource (emitted once per conflict). detail: { resource, others, mine, elements }. |
mv-take-over | Cancelable, before Take over applies. detail: { resource, from (the claims being displaced), mine, elements }. Notify the others through your server here; preventDefault() refuses. Then their claims are dropped locally (their late heartbeats are ignored for 5 s) and your tabs are told. |
mv-release | A claim ended, yours or someone else's. detail: { resource, user, action, reason, self, tab }; reason: "release" | "expired" | "taken-over" | "handover" | "view-only" | "submit" | "reset" | "removed" | "leave" (page hidden or closed: send it with sendBeacon) | "sync". |
mv-claim | Your claim started, changed or heartbeated: forward it to your server. detail: { resource, claim, heartbeat }. |
mv-handover-request | Cancelable: you chose Ask to hand over. detail: { resource, to (users), elements }. Send the request; relay the answer with receive(…, { type: "handover-declined" }) or by releasing their claim. |
mv-handover-response | You answered an incoming request. detail: { resource, user (who asked), accepted }. |
mv-resolve | A conflict choice was applied. detail: { resource, choice, others }. |
mv-lock-change | The state or the people on a resource changed. detail: { resource, state, others, mine, choice }. |
Content structure
| Name | Description |
|---|---|
(content) | Any part of the app. Descendants with data-lock are tracked as they are added, changed or removed (one MutationObserver on the element). |
CSS classes
| Name | Description |
|---|---|
mv-soft-lock-flag | The indicator (role=group labelled by its sentence): data-level mirrors the state, data-variant panel | compact. Parts: -head, -faces, -face (data-self, data-more), -split, -body, -title, -icon, -text, -since, -detail, -actions, -aside. |
mv-soft-lock-streams / mv-soft-lock-stream | Decorative overlay (aria-hidden, pointer-events: none) holding the two edges, data-side="them" | "me", driven by data-level. |
mv-soft-lock-live | Visually hidden polite and assertive live regions. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-soft-lock-self | var(--mv-accent) | Your color: your face and your edge. |
--mv-soft-lock-warn | var(--mv-warning) | Tint of a conflict, a hand-over request and Edit anyway. |
--mv-soft-lock-c1 … --mv-soft-lock-c6 | six oklch hues (light-dark) | Presence colors picked per person from their id when the app gives no user.color. |
--mv-soft-lock-stroke | 2px | Thickness of the converging edges. |
--mv-soft-lock-gap | 18px | The space the two edges always keep between their tips. |
Accessibility
Presence is never conveyed by color alone: every state has a full sentence (“Aiko is editing this invoice”, with “for 2 min” for screen readers), faces are aria-hidden duplicates of that text, and the indicator is a role=group labelled by its sentence and described by its detail line. When you start editing something another person is editing, the conflict is announced once in an assertive live region with the options and where they are (“You’re both editing — changes may collide. Aiko started 2 min ago… The options are at the top of the invoice”); it is not repeated while the conflict lasts. Focus is never moved while you type: the options are real buttons at the top of the element, reachable with Shift+Tab. After a choice, focus goes where work continues: back to the field you were editing after Take over, Edit anyway or Keep editing, to Start editing after View only, to the remaining actions after Ask to hand over. Someone starting to edit a record you have open, a take-over, a hand-over request (assertive), a decline and the end of a conflict are announced; bursts of changes are joined into one message and busy announcements are throttled per resource. View only disables the marked controls natively (disabled fieldsets and controls, contenteditable=false, inert), so they leave the tab order and are announced as unavailable, then restores their previous state. The converging edges are decorative (aria-hidden, pointer-events: none). Reduced motion (OS or data-motion="reduce"): the edges appear without travelling or humming, the indicator does not slide in. Forced colors: indicator borders use CanvasText, faces and edges use CanvasText and Highlight.