익스클루시브
Soft Lock <mv-soft-lock>
여러 사람(또는 탭)이 동시에 변경할 수 있는 레코드를 위한 소프트 잠금과 충돌 경고입니다. 청구서, 티켓, 문서, 설정 페이지, 일괄 작업에 적합합니다.
문화적 레퍼런스
고스트버스터즈, 아이반 라이트먼 (1984년, 영화). 팀의 유일한 규칙은 절대 빔을 교차시키지 않는 것입니다. 두 빔이 닿으면 재앙이 일어나므로, 각자 자기 빔을 상대의 빔에서 떨어뜨려 둡니다. UI에서는 같은 레코드를 작업하는 두 사람이 그 레코드로 모여드는 두 줄기의 빔이 됩니다: 컴포넌트는 충돌하기 전에 겹침을 보이게 하고, 가장자리는 결코 닿지 않으며, 누군가는 넘겨받거나, 보기 전용으로 물러나거나, 인계를 요청하거나, 알면서도 계속 진행해야 합니다.
작동 방식
여러 사람(또는 여러 탭)이 동시에 변경할 수 있는 레코드를 위한 소프트 잠금과 충돌 경고: 인보이스, 티켓, 문서, 설정 페이지, 일괄 작업. 앱은 리소스별 접속 상태를 claim(resourceId, { user, action: "viewing" | "editing" | "running", note })로 보고하며, 보통 웹소켓에서 받아 옵니다. 같은 브라우저의 탭끼리는 내장 BroadcastChannel로 서로를 확인합니다. data-lock="invoice-4821"로 표시된 모든 요소는 다른 사람이 있을 때 차분하고 항상 텍스트로 된 표시기를 보여 주며(얼굴, “Aiko is editing this invoice · 2 min”), 같은 대상을 편집하기 시작하는 순간(첫 입력은 자동으로 claim됨) 경고 수준을 높입니다: “You’re both editing: changes may collide”와 함께 Take over(취소 가능한 mv-take-over, 상대에게 알림), Ask to hand over(앱이 요청을 전달하고 답이 돌아옴), View only(표시된 컨트롤을 비활성화했다가 정확히 복원), Edit anyway를 제공합니다. 한쪽에서 오는 상대의 가장자리와 다른 쪽에서 오는 내 가장자리, 두 개의 얇은 가장자리가 경합 중인 요소로 모여들지만 항상 서로 닿기 직전에 멈춥니다. claim은 TTL에 따라 하트비트를 보내고 오래된 claim은 스스로 만료되며, submit과 reset은 편집을 종료하고, 컴팩트 변형은 목록 행에 맞으며, 충돌은 assertive로 한 번만 안내됩니다.
| 카테고리 | 피드백 |
|---|---|
| 유형 | Web Component (<mv-soft-lock>) |
| 상태 | 안정 |
| 키트 | 정직한 시스템 상태 |
| 함께 설치되는 항목 | 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
설치
node scripts/add.mjs soft-lock --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["soft-lock"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, core/dom.js, core/element.js, components/soft-lock/soft-lock.js, components/soft-lock/soft-lock.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-soft-lock user-id="u-priya" user-name="Priya Nair">
<form data-lock="invoice-4821" data-lock-noun="invoice"><input name="amount" aria-label="Amount"></form>
</mv-soft-lock>
<!-- from your presence feed: lock.claim("invoice-4821", { user: { id: "u-aiko", name: "Aiko Tanaka" } }) -->기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<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>API
Attributes
| Name | 유형 | 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 | 유형 | 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.