エクスクルーシブ

Soft Lock <mv-soft-lock>

複数の人(またはタブ)が同時に変更できるレコードのための、ソフトロックと競合の警告です。請求書、チケット、ドキュメント、設定ページ、一括操作などが対象です。

文化的リファレンス

ゴーストバスターズ、アイヴァン・ライトマン(1984年、映画)。チームの唯一のルールは「ビームを交差させるな」です。2 本のビームが触れ合えば大惨事になるため、それぞれが自分のビームを相手から離しておきます。UI では、同じレコードで作業する 2 人は、そこに向かって収束する 2 本のビームです。コンポーネントは衝突する前に重なりを可視化し、境界線同士は決して触れません。そして誰かが、引き継ぐ、閲覧のみに下がる、引き渡しを依頼する、あるいは承知のうえで続行する、のいずれかを選ぶ必要があります。

仕組み

複数の人(またはタブ)が同時に変更できるレコード向けのソフトロックと衝突警告:請求書、チケット、ドキュメント、設定ページ、一括操作など。アプリはリソースごとのプレゼンスを claim(resourceId, { user, action: "viewing" | "editing" | "running", note }) で報告し(通常は WebSocket から)、同じブラウザのタブ同士は組み込みの 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 を選べます。相手側から 1 本、自分側から 1 本の細い 2 本の境界線が、競合する要素に向かって収束し、互いに触れる手前で必ず止まります。claim は TTL に基づくハートビートで維持され、古いものは自動的に期限切れになります。submit と reset で編集が終了し、コンパクト版はリストの行に収まります。衝突は一度だけ assertive にアナウンスされます。

カテゴリーフィードバック
タイプWeb Component(<mv-soft-lock>)
ステータス安定版
キット誠実なシステム状態
同時にインストールされるものbutton
Keywordsexclusive, 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/marvelous

Marvelous 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タイプDefaultDescription
user-id / user-name / user-colorstring"me" / "You" / --mv-soft-lock-selfThe current user. Claims made without a user are yours for this tab; another claim carrying your id is shown as “you in another tab”.
ttltime ("60s", "2m", "500ms", ms number)60sClaim 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.
channelstring | "off"defaultBroadcastChannel 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"editedit: 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-lockstring (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-nounstringitemWord used in the texts: “editing this invoice”, “at the top of the invoice”.
data-lock-variant"panel" | "compact"panelcompact: 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-indicatormarker attributeInside a data-lock element: where the indicator goes. Otherwise it is prepended (panel) or appended (compact) to the element.
data-lock-editmarker attributeInside 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-statefree | present | busy | conflict | overlap | view-only | request | yoursSet 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.
claimsClaim[]Every live claim (read-only): { resource, user, action, since, note, tab, self, expires }.
tabIdstringThis page's tab id (read-only). Send it with your own claims through the server so your other devices can tell the tabs apart.
stringsPartial<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

NameDescription
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

NameDescription
mv-lock-conflictYou 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-overCancelable, 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-releaseA 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-claimYour claim started, changed or heartbeated: forward it to your server. detail: { resource, claim, heartbeat }.
mv-handover-requestCancelable: 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-responseYou answered an incoming request. detail: { resource, user (who asked), accepted }.
mv-resolveA conflict choice was applied. detail: { resource, choice, others }.
mv-lock-changeThe state or the people on a resource changed. detail: { resource, state, others, mine, choice }.

Content structure

NameDescription
(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

NameDescription
mv-soft-lock-flagThe 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-streamDecorative overlay (aria-hidden, pointer-events: none) holding the two edges, data-side="them" | "me", driven by data-level.
mv-soft-lock-liveVisually hidden polite and assertive live regions.

CSS variables

NameDefaultDescription
--mv-soft-lock-selfvar(--mv-accent)Your color: your face and your edge.
--mv-soft-lock-warnvar(--mv-warning)Tint of a conflict, a hand-over request and Edit anyway.
--mv-soft-lock-c1 … --mv-soft-lock-c6six oklch hues (light-dark)Presence colors picked per person from their id when the app gives no user.color.
--mv-soft-lock-stroke2pxThickness of the converging edges.
--mv-soft-lock-gap18pxThe 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.

このページは AI で翻訳されています。翻訳の問題を報告