익스클루시브

Inscribe <mv-inscribe>

레코드가 앱 어디에 나타나든 따라다니는 중요한 메모입니다.

문화적 레퍼런스

메멘토, 크리스토퍼 놀란(2000, 영화). 새로운 기억을 만들 수 없는 남자가 절대 잊어서는 안 되는 사실을 자기 몸에 문신으로 새기고 모든 사진에 글을 적어, 중요한 정보가 놓칠 수 없는 곳에서 그 대상과 함께 다니게 합니다. UI에서는 짧은 메모가 레코드의 id에 연결되어 그 레코드를 보여 주는 모든 요소에 마커로 나타나며, 중요한 메모는 해당 레코드에 대한 되돌릴 수 없는 작업 전에 반드시 읽고 확인해야 합니다.

작동 방식

앱에서 레코드가 나타나는 모든 곳을 따라다니는 중요한 메모. 엔터티 id(고객, 계정, 기기, 환자, 배송, 공급업체)별로 심각도(info, caution, critical), 작성자, 날짜, 선택적 만료일과 함께 메모를 등록하면, 범위 안에서 data-entity="…"를 가진 모든 요소(테이블 행, 아바타, @멘션, 검색 결과, 주문 헤더)에 간결한 마커가 붙습니다. 마커는 개수 옆에 놓인 버튼으로, 그 모양이 가장 높은 심각도를 나타내며(원, 삼각형, 팔각형), 확인되지 않은 critical 메모가 있는 동안에는 채워진 모양으로 표시됩니다. 나중에 추가된 요소도 자동으로 인식되고, 같은 메모는 레코드가 표시되는 모든 곳에서 동기화되며, 만료된 메모는 저절로 사라집니다. 마커에 호버하거나 포커스하면 메모를 미리 보고, 클릭하면 고정되며, 선택적 인라인 편집기(심각도, 만료일)로 추가한 내용은 해당 레코드의 모든 마커에 한 번에 반영됩니다. 보호된 액션(버튼이나 링크의 data-entity-guard)은 중요한 메모를 읽을 때까지 보류됩니다. 첫 번째 클릭은 메모를 열고, 두 번째 클릭(또는 “Acknowledge and continue”)은 확인을 기록하고 원래 클릭을 통과시킵니다. 취소 가능한 mv-inscribe-guard 이벤트와 선택적 확인 유효 기간(ack-ttl, "0" = 매번)을 제공합니다.

카테고리데이터 표시
유형Web Component (<mv-inscribe>)
상태안정
Keywordsexclusive, culture, notes, annotations, entity, crm, support, customer, warning, severity, acknowledgment, guard, confirmation, popover, badge, marker, compliance, handoff

When to use

  • A support, CRM or back-office app must surface “never forget” facts about a customer wherever that customer is shown
  • Irreversible actions on a record (delete, refund, ship, discharge) must be held until its critical notes have been read
  • The same entity appears in lists, conversations, headers and search results, and its warnings must stay in sync everywhere
  • Teams hand off records across shifts and need short, expiring notes attached to the record rather than buried in a history tab

Avoid when

  • The content is a one-off rich preview of a link or person, with no notes or severities → use Hover Card instead
  • The information is long-form documentation or a discussion thread: markers and popovers suit a few short notes per record
  • The warning concerns the whole page or session rather than one record → use Alert instead

설치

node scripts/add.mjs inscribe --out ./src/marvelous

Marvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["inscribe"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/focus.js, core/observe.js, core/position.js, components/inscribe/inscribe.js, components/inscribe/inscribe.css.

사용법

기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:

<div id="ins-demo" style="width:min(100%,76rem);margin-inline:auto">
  <style>
    #ins-demo .ins-console { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle); overflow:hidden; box-shadow:var(--mv-shadow-sm) }
    #ins-demo .ins-bar { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1.5rem; flex-wrap:wrap; padding:.875rem 1.125rem; border-bottom:1px solid var(--mv-border); background:var(--mv-surface) }
    #ins-demo .ins-bar h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
    #ins-demo .ins-bar p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.75rem }
    #ins-demo .ins-controls { display:flex; align-items:center; gap:.5rem 1.25rem; flex-wrap:wrap }
    #ins-demo .ins-controls .mv-choice { font-size:.8125rem }
    #ins-demo .ins-grid { display:grid; grid-template-columns:18.5rem minmax(0,1fr) 17.5rem; min-height:31rem }
    #ins-demo .ins-col { min-width:0; background:var(--mv-surface) }
    #ins-demo .ins-col + .ins-col { border-left:1px solid var(--mv-border) }
    #ins-demo .ins-h { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin:0; padding:.75rem 1rem; border-bottom:1px solid var(--mv-border); font-size:.75rem; font-weight:600; color:var(--mv-fg-muted); letter-spacing:.04em; text-transform:uppercase }
    #ins-demo .ins-queue ul { list-style:none; margin:0; padding:.375rem; display:grid; gap:.125rem }
    #ins-demo .ins-ticket { position:relative; display:grid; gap:.25rem; padding:.625rem .75rem; border-radius:var(--mv-radius-lg) }
    #ins-demo .ins-ticket:hover { background:var(--mv-bg-subtle) }
    #ins-demo .ins-ticket[aria-current="true"] { background:var(--mv-accent-subtle) }
    #ins-demo .ins-ticket[aria-current="true"] time { color:var(--mv-fg-muted) }
    #ins-demo .ins-t-top { display:flex; align-items:center; justify-content:space-between; gap:.5rem; font-size:.8125rem; font-weight:600 }
    #ins-demo .ins-t-top time { color:var(--mv-fg-subtle); font-size:.6875rem; font-weight:500; white-space:nowrap }
    #ins-demo .ins-subj { color:var(--mv-fg-muted); font-size:.75rem; line-height:1.35; text-decoration:none; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #ins-demo .ins-subj::after { content:""; position:absolute; inset:0; border-radius:inherit }
    #ins-demo .ins-subj:focus-visible { outline:none }
    #ins-demo .ins-ticket:has(.ins-subj:focus-visible) { box-shadow:var(--mv-focus-ring) }
    #ins-demo .ins-ticket .mv-inscribe-marker { position:relative; z-index:1 }
    #ins-demo .ins-ticket[data-new] { animation:ins-in .5s var(--mv-ease-out) }
    @keyframes ins-in { from { background:color-mix(in oklab, var(--mv-accent) 14%, transparent) } }
    #ins-demo .ins-thread { display:grid; grid-template-rows:auto minmax(0,1fr) auto }
    #ins-demo .ins-who { display:flex; align-items:center; gap:.75rem; padding:.75rem 1.125rem; border-bottom:1px solid var(--mv-border) }
    #ins-demo .ins-who b { font-size:.9375rem }
    #ins-demo .ins-who small { display:block; color:var(--mv-fg-muted); font-size:.75rem; margin-top:.0625rem }
    #ins-demo .ins-who .mv-badge { margin-left:auto }
    #ins-demo .ins-msgs { display:grid; align-content:start; gap:.875rem; padding:1rem 1.125rem }
    #ins-demo .ins-msg { display:grid; grid-template-columns:auto minmax(0,1fr); gap:.625rem }
    #ins-demo .ins-msg-body { padding:.625rem .75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface); font-size:.8125rem; line-height:1.5 }
    #ins-demo .ins-msg-body p { margin:0 }
    #ins-demo .ins-msg-meta { display:flex; gap:.5rem; align-items:baseline; margin-bottom:.25rem; font-size:.75rem }
    #ins-demo .ins-msg-meta b { font-weight:600 }
    #ins-demo .ins-msg-meta span { color:var(--mv-fg-subtle) }
    #ins-demo .ins-msg[data-internal] .ins-msg-body { border-style:dashed; background:color-mix(in oklab, var(--mv-warning) 6%, var(--mv-surface)) }
    #ins-demo .ins-mention { color:var(--mv-accent); font-weight:600 }
    #ins-demo .ins-compose { display:grid; gap:.5rem; padding:.75rem 1.125rem 1rem; border-top:1px solid var(--mv-border) }
    #ins-demo .ins-compose textarea { min-height:3.5rem; resize:none }
    #ins-demo .ins-compose div { display:flex; justify-content:flex-end; gap:.5rem }
    #ins-demo .ins-order { display:grid; align-content:start }
    #ins-demo .ins-sec { display:grid; gap:.5rem; padding:.875rem 1rem; border-bottom:1px solid var(--mv-border) }
    #ins-demo .ins-sec:last-child { border-bottom:0 }
    #ins-demo .ins-row { display:flex; align-items:center; justify-content:space-between; gap:.75rem; font-size:.8125rem }
    #ins-demo .ins-row > span:first-child { color:var(--mv-fg-muted) }
    #ins-demo .ins-row b { font-weight:600; font-variant-numeric:tabular-nums }
    #ins-demo .ins-row a { color:var(--mv-fg); font-weight:500; text-underline-offset:2px }
    #ins-demo .ins-oid { font-size:.9375rem; font-weight:650; letter-spacing:-.01em }
    #ins-demo .ins-oid small { display:block; color:var(--mv-fg-muted); font-size:.75rem; font-weight:500; letter-spacing:0; margin-top:.125rem }
    #ins-demo .ins-total { padding-top:.5rem; border-top:1px dashed var(--mv-border) }
    #ins-demo .ins-actions { display:grid; gap:.5rem }
    #ins-demo .ins-actions .mv-button { width:100% }
    #ins-demo [data-entity-guarded]::before { content:""; width:.75rem; height:.75rem; margin-right:.125rem; background:currentColor; opacity:.9; -webkit-mask:var(--ins-lock) center/contain no-repeat; mask:var(--ins-lock) center/contain no-repeat }
    #ins-demo { --ins-lock:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.5 7V5a3.5 3.5 0 0 1 7 0v2h.5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1zm1.6 0h3.8V5a1.9 1.9 0 0 0-3.8 0z'/%3E%3C/svg%3E") }
    #ins-demo .ins-foot { display:flex; align-items:center; gap:.5rem; padding:.5rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-surface); color:var(--mv-fg-muted); font-size:.75rem; min-height:2.25rem }
    #ins-demo .ins-foot b { color:var(--mv-fg); font-weight:600 }
    #ins-demo .ins-dot { width:.5rem; height:.5rem; border-radius:50%; background:var(--mv-success); flex:none }
    #ins-demo .ins-foot[data-tone="block"] .ins-dot { background:var(--mv-danger) }
    #ins-demo .ins-foot[data-tone="note"] .ins-dot { background:var(--mv-info) }
    #ins-demo .ins-hint { margin:.75rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
    @media (max-width:64rem) {
      #ins-demo .ins-grid { grid-template-columns:16rem minmax(0,1fr) }
      #ins-demo .ins-order { grid-column:1 / -1; border-left:0; border-top:1px solid var(--mv-border) }
    }
    @media (max-width:42rem) {
      #ins-demo .ins-grid { grid-template-columns:minmax(0,1fr) }
      #ins-demo .ins-col + .ins-col { border-left:0; border-top:1px solid var(--mv-border) }
    }
  </style>

  <mv-inscribe id="ins" editable author="Sam Patel">
    <div class="ins-console">
      <header class="ins-bar">
        <div>
          <h3>Support console</h3>
          <p>Billing &amp; accounts queue · signed in as Sam Patel</p>
        </div>
        <div class="ins-controls">
          <label class="mv-choice" data-control="end">
            <input type="checkbox" role="switch" class="mv-switch" id="ins-edit" checked>
            <span class="mv-choice-text"><span class="mv-choice-title">Inline editor</span></span>
          </label>
          <label class="mv-choice" data-control="end">
            <input type="checkbox" role="switch" class="mv-switch" id="ins-every">
            <span class="mv-choice-text"><span class="mv-choice-title">Acknowledge every time</span></span>
          </label>
          <button class="mv-button" data-variant="outline" data-size="sm" type="button" id="ins-new">Simulate incoming ticket</button>
        </div>
      </header>

      <div class="ins-grid">
        <section class="ins-col ins-queue" aria-labelledby="ins-q-h">
          <h4 class="ins-h" id="ins-q-h">Open tickets <span class="mv-badge" data-variant="secondary" data-shape="pill" id="ins-count">5</span></h4>
          <ul id="ins-list">
            <li class="ins-ticket" aria-current="true">
              <span class="ins-t-top"><span data-entity="cust-4821">Diana Okafor</span><time>9:42 AM</time></span>
              <a class="ins-subj" href="#ticket-4410">Refund for duplicate charge on INV-20931</a>
            </li>
            <li class="ins-ticket">
              <span class="ins-t-top"><span data-entity="cust-1377">Mateo Álvarez</span><time>9:15 AM</time></span>
              <a class="ins-subj" href="#ticket-4407">Can’t update the card on file</a>
            </li>
            <li class="ins-ticket">
              <span class="ins-t-top"><span data-entity="cust-2290">Aiko Tanaka</span><time>8:58 AM</time></span>
              <a class="ins-subj" href="#ticket-4402">CSV export drops the last column</a>
            </li>
            <li class="ins-ticket">
              <span class="ins-t-top"><span data-entity="cust-4821">Diana Okafor</span><time>Yesterday</time></span>
              <a class="ins-subj" href="#ticket-4388">Please remove our old pilot workspace</a>
            </li>
            <li class="ins-ticket">
              <span class="ins-t-top"><span data-entity="cust-3054">Lars Nygaard</span><time>Yesterday</time></span>
              <a class="ins-subj" href="#ticket-4381">Change the billing address on invoices</a>
            </li>
          </ul>
        </section>

        <section class="ins-col ins-thread" aria-label="Conversation">
          <div class="ins-who">
            <span class="mv-avatar" data-variant="tinted" style="--mv-avatar-hue:20" role="img" aria-label="Diana Okafor"><span class="mv-avatar-fallback" aria-hidden="true">DO</span></span>
            <div>
              <b data-entity="cust-4821">Diana Okafor</b>
              <small>[email protected] · Ticket #4410</small>
            </div>
            <span class="mv-badge" data-variant="danger" data-shape="pill">Urgent</span>
          </div>
          <div class="ins-msgs">
            <div class="ins-msg">
              <span class="mv-avatar" data-size="sm" data-variant="tinted" style="--mv-avatar-hue:20" aria-hidden="true"><span class="mv-avatar-fallback">DO</span></span>
              <div class="ins-msg-body">
                <div class="ins-msg-meta"><b>Diana Okafor</b><span>9:42 AM</span></div>
                <p>Hi! We were charged $1,249.00 twice for our annual renewal (INV-20931). Could you refund the duplicate? And while you’re at it, please delete all the data from our old pilot workspace.</p>
              </div>
            </div>
            <div class="ins-msg" data-internal>
              <span class="mv-avatar" data-size="sm" data-variant="tinted" style="--mv-avatar-hue:260" aria-hidden="true"><span class="mv-avatar-fallback">PR</span></span>
              <div class="ins-msg-body">
                <div class="ins-msg-meta"><b>Priya Raman</b><span>Internal note · 9:50 AM</span></div>
                <p>Refund is fine. Before anything irreversible, read the notes on <span class="ins-mention" data-entity="cust-4821">@Diana Okafor</span>. Legal still has a hold on this account.</p>
              </div>
            </div>
          </div>
          <div class="ins-compose">
            <textarea class="mv-textarea" aria-label="Reply to Diana Okafor" placeholder="Reply to Diana…"></textarea>
            <div><button class="mv-button" data-size="sm" type="button">Send reply</button></div>
          </div>
        </section>

        <aside class="ins-col ins-order" aria-label="Order details">
          <div class="ins-sec">
            <div class="ins-oid">Order A-10492<small>Paid Sep 1, 2026 · Card •••• 4417</small></div>
            <div class="ins-row"><span>Customer</span><span data-entity="cust-4821">Diana Okafor</span></div>
            <div class="ins-row"><span>Account</span><a href="#account-harborlight" data-entity="acct-harborlight">Harborlight Health</a></div>
          </div>
          <div class="ins-sec">
            <div class="ins-row"><span>Team plan · 25 seats, annual</span><b>$1,249.00</b></div>
            <div class="ins-row"><span>Duplicate charge</span><b>$1,249.00</b></div>
            <div class="ins-row ins-total"><span>Total charged</span><b>$2,498.00</b></div>
          </div>
          <div class="ins-sec ins-actions">
            <button class="mv-button" data-variant="outline" type="button" id="ins-refund">Refund $1,249.00</button>
            <button class="mv-button" data-variant="destructive" type="button" id="ins-delete" data-entity-guard="cust-4821">Delete customer data</button>
          </div>
        </aside>
      </div>

      <div class="ins-foot" id="ins-status" role="status"><span class="ins-dot" aria-hidden="true"></span><span id="ins-status-text">Every place that shows a customer carries that customer’s notes.</span></div>
    </div>
  </mv-inscribe>
  <p class="ins-hint">Hover or Tab to a marker to read the notes · Click to pin and add one · Try “Delete customer data” · Simulate a ticket: its marker attaches by itself</p>

  <script type="module">
    const ins = document.getElementById("ins");
    await customElements.whenDefined("mv-inscribe");
    const $ = (id) => document.getElementById(id);
    const now = Date.now(), hour = 36e5, day = 864e5;

    ins.entities = {
      "cust-4821": { name: "Diana Okafor", kind: "Customer" },
      "cust-1377": { name: "Mateo Álvarez", kind: "Customer" },
      "cust-2290": { name: "Aiko Tanaka", kind: "Customer" },
      "cust-3054": { name: "Lars Nygaard", kind: "Customer" },
      "acct-harborlight": { name: "Harborlight Health", kind: "Account" },
    };
    ins.notes = [
      { entity: "cust-4821", severity: "critical", author: "Hannah Weiss, Legal", date: now - 21 * day,
        text: "Legal hold LH-2291: do not delete, anonymize or export any of this customer’s data until Legal lifts the hold." },
      { entity: "cust-4821", severity: "caution", author: "Marcus Bell", date: now - 36 * day,
        text: "Severe peanut allergy: never include food in goodwill gift boxes." },
      { entity: "cust-4821", severity: "info", author: "Priya Raman", date: now - 3 * day, expires: now + 68 * day,
        text: "Executive sponsor at Harborlight Health. Escalate billing questions to Priya Raman." },
      { entity: "cust-1377", severity: "caution", author: "Payments team", date: now - 5 * day, expires: now + 20 * day,
        text: "Open chargeback on the August invoice: route refund requests to Payments before replying." },
      { entity: "cust-2290", severity: "info", author: "Leila Haddad", date: now - 50 * day,
        text: "Prefers written follow-ups to calls; usually replies within a day." },
      { entity: "acct-harborlight", severity: "caution", author: "Priya Raman", date: now - 2 * hour, expires: now + 40 * day,
        text: "Renewal under negotiation: no discounts or credits without Priya’s approval." },
    ];

    const status = (text, tone = "ok") => {
      $("ins-status-text").textContent = text;
      $("ins-status").dataset.tone = tone;
    };
    const name = (id) => ins.entities[id]?.name ?? id;

    $("ins-edit").addEventListener("change", (e) => { ins.editable = e.target.checked; });
    $("ins-every").addEventListener("change", (e) => { ins.ackTtl = e.target.checked ? "0" : null; });

    ins.addEventListener("mv-inscribe-guard", (e) => {
      const n = e.detail.notes.length;
      status(`Held “${e.detail.trigger.textContent.trim()}”: ${n} critical note${n > 1 ? "s" : ""} on ${name(e.detail.entity)} to acknowledge first.`, "block");
    });
    ins.addEventListener("mv-note-ack", (e) => {
      status(`${e.detail.by} acknowledged a ${e.detail.note.severity} note on ${name(e.detail.entity)}.`, "note");
    });
    ins.addEventListener("mv-note-add", (e) => {
      status(`${e.detail.note.author} added a ${e.detail.note.severity} note on ${name(e.detail.entity)}: it now shows everywhere this record appears.`, "note");
    });
    $("ins-delete").addEventListener("click", () => status("Deletion request queued for Diana Okafor and routed to Legal for review."));
    $("ins-refund").addEventListener("click", () => status("Refund of $1,249.00 issued to Diana Okafor’s card ending in 4417."));
    $("ins-list").addEventListener("click", (e) => { if (e.target.closest("a[href^='#']")) e.preventDefault(); });

    const incoming = [
      ["cust-4821", "Diana Okafor", "Follow-up: still seeing the duplicate charge"],
      ["cust-1377", "Mateo Álvarez", "Refund status for order A-10311?"],
      ["cust-6120", "Kofi Mensah", "How do I add a second admin?"],
    ];
    let next = 0;
    $("ins-new").addEventListener("click", () => {
      const [id, who, subject] = incoming[next++ % incoming.length];
      const li = document.createElement("li");
      li.className = "ins-ticket";
      li.dataset.new = "";
      const top = document.createElement("span");
      top.className = "ins-t-top";
      const person = document.createElement("span");
      person.dataset.entity = id;
      person.textContent = who;
      const time = document.createElement("time");
      time.textContent = "Just now";
      top.append(person, time);
      const a = document.createElement("a");
      a.className = "ins-subj";
      a.href = `#ticket-${4411 + next}`;
      a.textContent = subject;
      li.append(top, a);
      $("ins-list").prepend(li);
      $("ins-count").textContent = String($("ins-list").children.length);
      status(`New ticket from ${who}${ins.notesFor(id).length ? ": their notes came with it" : ""}.`, "note");
    });
  </script>
</div>

API

Attributes

Name유형DefaultDescription
scope"self" | "document" | CSS selectorselfWhere data-entity and data-entity-guard elements are looked for: inside the element (self), the whole document, or the first element matching the selector. Elements added later are picked up automatically.
editablebooleanfalseShows the inline editor (“Add note”: text, severity, expiry) at the bottom of the notes card, and lets data-entity-marker="always" show an empty “+” marker on records without notes.
authorstring"You"Name of the current user, used as the author of notes added in the editor and in acknowledgments.
ack-ttlduration ("0", "30m", "8h", "7d")(none)How long an acknowledgment lasts before guarded actions are held again. Empty = for the life of the page, "0" = every guarded action needs a fresh acknowledgment. Markers turn solid again when an acknowledgment lapses.
placement"bottom-start" | "top" | "right" | …bottom-startPreferred side of the notes card relative to the marker (flipped and shifted to stay on screen). Guard cards open above the action.
localeBCP 47 tagen-USLocale of dates, relative times and acknowledgment times.
data-entitystringOn any element of the scope: the entity id it represents. A marker is attached when that entity has active notes; the element also receives data-inscribed="info | caution | critical" (highest severity) for your own styling.
data-entity-namestringOn a data-entity element: human name used in labels when the entities property doesn’t provide one (falls back to the id).
data-entity-marker"none" | "always" | "before"On a data-entity element: no marker (the element still gets data-inscribed and guards still work), a marker even without notes (with editable, to add the first one), or the marker placed before the content instead of after.
data-entity-slotbooleanOn a descendant of a data-entity element: the marker is placed inside it. Otherwise the marker goes after an interactive or void host (link, button, img), into the first cell of a <tr>, or at the end of the host.
data-entity-guardentity id | emptyOn a button or link: the action is held while that entity (or, when empty, the closest data-entity ancestor’s) has unacknowledged guarding notes. While armed it carries data-entity-guard-state="armed"; while guarded, data-entity-guarded.

Properties

Name유형Description
notesNote[] | { [entityId]: Note[] }All notes. Note = { id?, entity, text, severity: "info" | "caution" | "critical", author?, date? (Date | ISO | ms, default now), expires? (idem), guard? (default: severity === "critical"), acknowledged? { by, at } }. Setting replaces everything; reading returns copies with acknowledged and expired filled in.
entities{ [entityId]: { name, kind? } | string }Names (for marker labels and the card header) and kinds (shown under the name, e.g. “Customer”).
scope / editable / author / ackTtl / placement / localereflectedMirror the attributes.

Methods

NameDescription
inscribe(entityId, note | text)Adds a note to an entity and returns it (with its generated id); every marker of that entity updates and stamps. No event (the app made the change).
erase(noteId)Removes a note. Returns false if it doesn’t exist.
notesFor(entityId, { includeExpired? })Active notes of an entity, most severe then newest first.
acknowledge(entityId, noteId?)Records an acknowledgment by author for one note, or for every guarding note of the entity. Returns how many were acknowledged. No event.
isGuarded(entityId)True while a guarded action on this entity would be held.
setEntity(entityId, { name, kind } | name)Adds or updates one entry of entities.
open(entityId, anchor?) / close()Opens the pinned notes card for an entity (anchored to its first marker by default) and moves focus into it; closes it.
refresh()Re-resolves the scope and re-scans it.

Events

NameDescription
mv-inscribe-guardA guarded action was clicked while notes still need acknowledging. detail: { entity, notes, trigger }. Cancelable: preventDefault() lets the click through untouched (e.g. for a role that is allowed to bypass).
mv-note-ackA note is being acknowledged from the card or a guarded action. detail: { entity, note, by, at, source: "card" | "guard" }. Cancelable: preventDefault() refuses the acknowledgment and the action stays held. Persist acknowledgments here.
mv-note-addA note is being added from the inline editor. detail: { entity, note, source: "editor" }. Cancelable: preventDefault() discards it (e.g. to save it server-side and call inscribe() afterwards). Notes you write count as acknowledged by you.
mv-inscribe-openThe notes card opened. detail: { entity, mode: "peek" | "pin" | "guard", anchor, notes }.
mv-inscribe-closeThe notes card closed. detail: { entity, reason }.

Content structure

NameDescription
(content)With scope="self" (default), the region whose data-entity elements are decorated. The element is display: contents; with scope="document" it can sit anywhere, empty.

CSS classes

NameDescription
mv-inscribe-markerThe marker: a <button> (or, inside another link or button, a non-focusable role="img" with data-passive). data-severity="info | caution | critical | none", data-pending while a guarding note is unacknowledged, data-inscribe-open while its card is open.
mv-inscribe-cardThe notes card (role="dialog", Popover API top layer). data-severity (highest), data-mode="peek | pin | guard". Parts: -head, -title, -sub, -guardmsg, -list, -note (data-severity, data-pending), -sev, -text, -meta, -acked, -foot, -form, -btn.

CSS variables

NameDefaultDescription
--mv-inscribe-infovar(--mv-info)Tint of info notes (circle).
--mv-inscribe-cautionvar(--mv-warning)Tint of caution notes (triangle).
--mv-inscribe-criticalvar(--mv-danger)Tint of critical notes (octagon).
--mv-inscribe-marker-size1.25remMarker height.
--mv-inscribe-width22remWidth of the notes card.

Accessibility

Each marker is a real <button type="button"> with aria-haspopup="dialog", aria-expanded and aria-controls, named in full (“3 notes on Diana Okafor, 1 critical, 1 caution, needs acknowledgment”); its visible count and shape icon are aria-hidden. Severity never relies on color: the icon shape differs (circle / triangle / octagon), the card spells it out (“Critical”), an unacknowledged critical note makes the marker solid, and forced-colors mode keeps shapes and borders with system colors. Keyboard: focusing a marker with the keyboard previews its notes without moving focus; Enter, Space or ArrowDown pins the card and moves focus into it, Tab from the marker enters the card, Tab past its last control continues to whatever follows the marker in the page, Shift+Tab returns to the marker, and Escape closes the card and returns focus. One shared card serves every marker, so on a page with many markers each one adds the card’s two or three controls to the Tab sequence while it previews (a known cost of keeping the notes one Tab away). The card is a labelled non-modal dialog (Popover API top layer, no focus trap) dismissed by Escape or an outside click. Markers are never nested inside other interactive content: inside a link or button the marker becomes a non-focusable role="img" whose label joins the control’s name, and Alt+ArrowDown on that control opens the notes (announced through aria-keyshortcuts). Guarded actions get aria-describedby pointing to a hidden description (“Requires acknowledging 1 critical note on Diana Okafor first: …”) and data-entity-guarded while held; a held click opens the card with focus on “Acknowledge and continue”, the requirement is described by aria-describedby, and Escape or Cancel returns focus to the action. Additions and acknowledgments are announced in a polite live region. Reduced motion (prefers-reduced-motion or data-motion="reduce") removes the stamp, the marker entrance and the card scale; nothing loops.

이 페이지는 AI로 번역되었습니다. 번역 문제 신고