エクスクルーシブ

Mast <mv-mast>

自分で課した制限やセキュリティ上重要な制限のための非対称な設定です。厳しくするのは即時ですが、緩めるにはクーリングオフ期間を待つ必要があります。

文化的リファレンス

オデュッセイア、ホメロス(紀元前 8 世紀頃、叙事詩)。オデュッセウスはセイレーンのそばを航行する間、自らをマストに縛りつけさせ、自分の懇願を無視するよう乗組員に命じます。冷静なうちに交わした約束は、誘惑の瞬間には取り消せません。UI では、落ち着いているときに設定した上限はすぐに厳しくできますが、緩められるのはクーリングオフ期間の後だけです。そのため、衝動的に求めたリスクの高い変更は待機状態となり、キャンセル可能なまま、再確認が必要になる場合もあります。

仕組み

自分で課した上限や、セキュリティ上重要な上限のための非対称な設定です。厳しくする変更は即座に適用され、緩める変更はクーリングオフ期間が明けるまで待ちます。任意のコントロール(数値フィールド、スライダー、セレクト、セグメントコントロール、ラジオ、スイッチ)をラップし、どちらの方向が安全かを safer="lower | higher | on | off" または順序付きの値リスト("private, internal, public")で指定します。安全な変更はすぐに適用され(mv-apply)、保留中の緩和をすべて取り消します。リスクの高い変更はインラインの確認(「Raise your daily limit to $250?」、理由の入力は任意または必須)を開き、その後カウントダウンリング付きの単一の保留リクエストになり、期限を en-US の絶対時刻と残り時間で表示します(「Your daily limit will rise to $250 on Sep 24, 10:12 AM · 19 h 42 min left · $100 stays in force until then · Cancel request」)。その間、有効な値は変わらず、コントロールはその値に戻ります。期限になると、リクエストは自動的に適用されるか、confirm-after を指定した場合はもう一度明示的な確認を待ちます(confirm-window の経過後に失効させることも可能)。よりリスクの高い新しいリクエストはタイマーをやり直し、より控えめなリクエストはタイマーを維持します。状態はサーバーから渡すこともできます(value、pending、until、pending-reason)。mv-request は waitUntil(promise) 付きでキャンセル可能なので、アプリはカウントダウンの開始前にリクエストを永続化できます。時刻は絶対的な期限から算出されるため、バックグラウンドのタブでもずれることはありません。

カテゴリーフォーム
タイプWeb Component(<mv-mast>)
ステータス安定版
キット破壊的な操作と機密情報を安全に
同時にインストールされるものbutton, input
Keywordsexclusive, culture, settings, limits, cooling-off, cooldown, pending-change, delay, pre-commitment, responsible-gambling, spending-limit, screen-time, security, 2fa, retention, countdown, form

When to use

  • A user raises a self-imposed spending, deposit, loss or screen-time limit and the increase must wait out a cooling-off period
  • Responsible gambling or trading rules require limit increases to be delayed and confirmed, while decreases apply immediately
  • An admin weakens a security setting (turning off 2FA, making a repository public, removing a recovery method) and a delay buys time to react
  • Lowering a data-retention or backup policy would delete data for good, so the change should only take effect after a grace period

Avoid when

  • Both directions are equally safe: a plain control that saves on change is simpler and less frustrating
  • The risky action is a one-off operation to confirm now, not a setting that stays in force → use Two-Key instead
  • The delay must hold against a determined user: the server has to enforce it, the component only presents and orchestrates it

インストール

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

Marvelous UI MCP サーバーを使う AI エージェント:install_components({ slugs: ["mast"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

コピーされるファイル(依存関係を含む):tokens/tokens.css, core/base.css, components/button/button.css, components/input/input.css, core/dom.js, core/element.js, components/mast/mast.js, components/mast/mast.css。

使い方

クイックスタート、動作する最小限のマークアップ:

<mv-mast safer="lower" delay="24h">
  <label>Daily deposit limit <input type="number" value="100" min="0"></label>
</mv-mast>

基本となるマークアップです。ここから属性、data-*、CSS 変数でカスタマイズしてください:

<div id="ms-demo" style="width:min(100%,62rem);margin-inline:auto">
  <style>
    #ms-demo { display:grid; gap:1rem; align-content:start }
    #ms-demo .ms-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,24rem),1fr)); gap:1rem; align-items:start }
    #ms-demo .ms-panel { display:grid; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
    #ms-demo .ms-head { display:flex; align-items:center; gap:.75rem; padding:.875rem 1.125rem; border-bottom:1px solid var(--mv-border) }
    #ms-demo .ms-logo { display:grid; place-items:center; flex:none; width:2rem; height:2rem; border-radius:var(--mv-radius-md); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
    #ms-demo .ms-logo svg { width:1.0625rem; height:1.0625rem }
    #ms-demo .ms-title { display:grid; gap:.0625rem; min-width:0; flex:1 }
    #ms-demo .ms-title h3 { margin:0; font-size:.9375rem; letter-spacing:-.01em }
    #ms-demo .ms-title p { margin:0; color:var(--mv-fg-muted); font-size:.75rem }
    #ms-demo .ms-row { padding:1rem 1.125rem }
    #ms-demo .ms-row + .ms-row { border-top:1px solid var(--mv-border) }
    #ms-demo .ms-field { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap }
    #ms-demo .ms-copy { display:grid; gap:.125rem; min-width:0; flex:1 1 11rem }
    #ms-demo .ms-label { font-size:.875rem; font-weight:560; color:var(--mv-fg) }
    #ms-demo .ms-sub { color:var(--mv-fg-muted); font-size:.75rem }
    #ms-demo .ms-range { display:grid; gap:.5rem; flex:1 1 100% }
    #ms-demo .ms-range-top { display:flex; justify-content:space-between; align-items:baseline; gap:1rem }
    #ms-demo .ms-range output { font-size:.8125rem; font-weight:600; font-variant-numeric:tabular-nums }
    #ms-demo .ms-range input { width:100% }
    #ms-demo mv-number-field { --mv-number-field-width:9.5rem }
    #ms-demo .ms-select { width:auto; min-width:9.5rem; flex:none }
    #ms-demo .ms-log { display:grid; gap:.5rem; padding:.875rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle) }
    #ms-demo .ms-log h4 { display:flex; align-items:center; justify-content:space-between; margin:0; font-size:.75rem; font-weight:600; color:var(--mv-fg-muted); letter-spacing:.04em; text-transform:uppercase }
    #ms-demo .ms-log ol { display:grid; gap:.25rem; margin:0; padding:0; list-style:none; font:.75rem/1.45 var(--mv-font-mono); color:var(--mv-fg-muted) }
    #ms-demo .ms-log li { display:flex; gap:.625rem; min-width:0 }
    #ms-demo .ms-log time { flex:none; color:var(--mv-fg-subtle) }
    #ms-demo .ms-log b { flex:none; font-weight:600; color:var(--mv-fg) }
    #ms-demo .ms-log span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
  </style>

  <div class="ms-grid">
    <!-- Personal finance: self-imposed spending and time limits -->
    <section class="ms-panel" aria-labelledby="ms-h-play">
      <header class="ms-head">
        <span class="ms-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 17l5-5 4 4 8-8"/><path d="M15 8h5v5"/></svg></span>
        <span class="ms-title">
          <h3 id="ms-h-play">Trading limits</h3>
          <p>Harborline · limits you set for yourself</p>
        </span>
        <span class="mv-badge" data-variant="success"><span class="mv-badge-dot"></span>Protected</span>
      </header>

      <mv-mast id="ms-deposit" class="ms-row" safer="lower" delay="24h" confirm-after reason="optional" label="daily deposit limit" pending-reason="Moving savings for the quarterly rebalance">
        <div class="ms-field">
          <span class="ms-copy">
            <span class="ms-label" id="ms-deposit-l">Daily deposit limit</span>
            <span class="ms-sub">Across cards and bank transfers</span>
          </span>
          <mv-number-field id="ms-deposit-field" value="100" min="0" max="5000" step="25" format="currency" currency="USD"></mv-number-field>
        </div>
      </mv-mast>

      <mv-mast id="ms-time" class="ms-row" safer="lower" delay="20s" label="daily session time" unit="minute">
        <div class="ms-range">
          <span class="ms-range-top">
            <span class="ms-copy">
              <label class="ms-label" for="ms-time-input">Daily session time</label>
              <span class="ms-sub">Demo speed: 20-second cooling-off, applies on its own</span>
            </span>
            <output id="ms-time-out" for="ms-time-input">60 min</output>
          </span>
          <input id="ms-time-input" type="range" class="mv-slider" min="15" max="240" step="15" value="60">
        </div>
      </mv-mast>
    </section>

    <!-- Team workspace: security-sensitive settings -->
    <section class="ms-panel" aria-labelledby="ms-h-sec">
      <header class="ms-head">
        <span class="ms-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v12"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg></span>
        <span class="ms-title">
          <h3 id="ms-h-sec">Repository settings</h3>
          <p>acme-labs / design-tokens</p>
        </span>
        <span class="mv-badge" data-variant="outline">Admin</span>
      </header>

      <mv-mast id="ms-visibility" class="ms-row" safer="private, internal, public" delay="72h" confirm-after confirm-window="7d" reason="required" label="repository visibility" pending-reason="Share the tokens with every product team in the company">
        <div class="ms-field">
          <span class="ms-copy">
            <span class="ms-label" id="ms-vis-l">Visibility</span>
            <span class="ms-sub">Who can see the code and its history</span>
          </span>
          <mv-segmented aria-labelledby="ms-vis-l" value="private">
            <button value="private">Private</button>
            <button value="internal">Internal</button>
            <button value="public">Public</button>
          </mv-segmented>
        </div>
      </mv-mast>

      <mv-mast id="ms-2fa" class="ms-row" safer="on" delay="48h" label="two-factor sign-in for all members">
        <div class="ms-field">
          <span class="ms-copy">
            <label class="ms-label" for="ms-2fa-input">Require two-factor sign-in</label>
            <span class="ms-sub">Members without it lose access until they enroll</span>
          </span>
          <input id="ms-2fa-input" type="checkbox" role="switch" class="mv-switch" checked>
        </div>
      </mv-mast>

      <mv-mast id="ms-retention" class="ms-row" safer="higher" delay="7d" unit="day" label="audit log retention">
        <div class="ms-field">
          <span class="ms-copy">
            <label class="ms-label" for="ms-retention-input">Audit log retention</label>
            <span class="ms-sub">Older events are deleted for good</span>
          </span>
          <select id="ms-retention-input" class="mv-select ms-select">
            <option value="365" selected>365 days</option>
            <option value="180">180 days</option>
            <option value="90">90 days</option>
            <option value="30">30 days</option>
          </select>
        </div>
      </mv-mast>
    </section>
  </div>

  <section class="ms-log" aria-labelledby="ms-log-h">
    <h4 id="ms-log-h">Server log <span style="text-transform:none;letter-spacing:0;font-weight:500">mv-request · mv-apply · mv-cancel</span></h4>
    <ol id="ms-log" aria-live="off"></ol>
  </section>

  <script type="module">
    const root = document.getElementById("ms-demo");
    const log = document.getElementById("ms-log");
    const deposit = document.getElementById("ms-deposit");
    const visibility = document.getElementById("ms-visibility");
    const timeInput = document.getElementById("ms-time-input");
    const timeOut = document.getElementById("ms-time-out");
    const now = Date.now();
    const clock = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
    const labelOf = (el) => el.getAttribute("label");

    // State coming from the server: a raise requested earlier, and a change that finished cooling off.
    deposit.setAttribute("until", new Date(now + (19 * 60 + 42) * 60_000 + 30_000).toISOString());
    deposit.setAttribute("pending", "250");
    visibility.setAttribute("until", new Date(now - 3 * 3_600_000).toISOString());
    visibility.setAttribute("pending", "internal");

    const say = (event, text) => {
      const li = document.createElement("li");
      const t = document.createElement("time");
      t.textContent = clock();
      const b = document.createElement("b");
      b.textContent = event;
      const s = document.createElement("span");
      s.textContent = text;
      li.append(t, b, s);
      log.prepend(li);
      while (log.children.length > 4) log.lastElementChild.remove();
    };
    // The number field has no built-in label here: name its inner input after the row title.
    customElements.whenDefined("mv-number-field").then(() => setTimeout(() => {
      document.getElementById("ms-deposit-field").input?.setAttribute("aria-labelledby", "ms-deposit-l");
    }));

    say("sync", "Loaded 1 pending raise and 1 change ready to confirm.");

    const fmt = (el, v) => {
      if (el.id === "ms-deposit") return `$${v}`;
      if (el.id === "ms-time") return `${v} min`;
      if (el.id === "ms-retention") return `${v} days`;
      if (typeof v === "boolean") return v ? "on" : "off";
      return String(v);
    };

    // The app persists each request (a fake 500 ms save) before the countdown starts.
    root.addEventListener("mv-request", (e) => {
      const { value, until } = e.detail;
      const when = new Date(until).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
      e.detail.waitUntil(new Promise((r) => setTimeout(r, 500)).then(() => say("mv-request", `${labelOf(e.target)} → ${fmt(e.target, value)}, applies ${when}`)));
    });
    root.addEventListener("mv-apply", (e) => say("mv-apply", `${labelOf(e.target)} = ${fmt(e.target, e.detail.value)} (${e.detail.source})`));
    root.addEventListener("mv-cancel", (e) => say("mv-cancel", `${labelOf(e.target)}: request for ${fmt(e.target, e.detail.value)} ${e.detail.reason}`));

    // Keep the slider's readout in step with what the control shows.
    const syncTime = () => { timeOut.textContent = `${timeInput.value} min`; };
    for (const type of ["input", "change", "mv-draft", "mv-apply", "mv-request", "mv-cancel"]) {
      document.getElementById("ms-time").addEventListener(type, () => queueMicrotask(syncTime));
    }
  </script>
</div>

API

Attributes

NameタイプDefaultDescription
saferlower | higher | on | off | comma-separated values (safest first)lowerWhich way is safer. lower / higher compare numbers (a limit, a retention period), on / off a switch or checkbox, and a list orders the control's values from safest to riskiest ("private, internal, public"). Values missing from the list count as the riskiest.
delaytime ("24h", "7d", "72h", "30m", "20s", "2w", ms number)24hCooling-off period before a riskier request becomes applicable. Shown in the rule as “a 24-hour cooling-off period”.
confirm-afterbooleanAt the end of the delay the request does not apply on its own: it turns into a “ready” card that needs one more explicit confirmation (as responsible-gambling rules often require). Without it the request applies automatically (mv-apply with source "timer").
confirm-windowtimeWith confirm-after: how long a ready request stays available. After that it lapses (mv-cancel with reason "lapsed") and the value in force stays.
reasonoptional | requiredAdds a reason field to the review step. required blocks the request until a reason is typed (announced error, aria-invalid). The reason travels in mv-request and is shown on the pending card.
labelstringNoun phrase used in every sentence and announcement (“daily deposit limit” → “Your daily deposit limit will rise to $250”). Defaults to the control's label text, first letter lowercased.
currency / unit / localestringNumber formatting for lower / higher values (Intl currency code, Intl unit such as minute or day, locale; default en-US). Read from the wrapped control when it carries the same attributes (mv-number-field).
valuestringThe value in force, when your server knows better than the control's markup. Changing it later syncs the control without any event; a pending request that is no longer riskier is dropped.
pendingstringA request already stored by your server (the value asked for). Ignored if it isn't riskier than the value in force. Removing it drops the request silently.
untilISO date | epoch msWhen the pending request becomes applicable. Without it, now + delay. A past date means it is ready (confirm-after) or applies at once.
pending-reasonstringReason stored with a server-provided request, shown on the pending card.
data-mast-controlmarker attributeOn a descendant: the element to treat as the control. Otherwise the first mv-number-field, mv-slider, mv-segmented, mv-scrub-field, mv-toggle-group, mv-wheel-picker, mv-combobox, input, select or textarea inside (a radio group is taken as a whole by name).
data-state / data-safer / data-busyset by the componentdata-state: idle | draft | cooling | ready. data-safer: the resolved mode (lower, higher, on, off, list). data-busy while a request waits for its waitUntil promises.

Properties

NameタイプDescription
valuenumber | boolean | string | nullThe value in force. Set it from your server (no event).
pendingnumber | boolean | string | nullThe value asked for by the pending request, or null. Setting it creates or drops a request without emitting events (server sync).
untilnumber (ms epoch)Deadline of the pending request, or null. Accepts a Date, ISO string or epoch ms.
remainingnumber (ms)Time left before the pending request is applicable, recomputed from until on every read (read-only).
state"idle" | "draft" | "cooling" | "ready"draft: a riskier change is under review, not sent yet; cooling: a request is waiting; ready: the delay is over and a confirmation is expected (read-only).
busybooleanTrue while mv-request waits for the promises passed to waitUntil() (read-only).
formatValue(value) => stringFormats values everywhere they are shown or announced (default: Intl number formatting, the option / segment / radio text, or On / Off).
stringsPartial<Record<string, string>> & { verbs? }Overrides for every visible text and announcement (rule, ruleConfirm (rule used with confirm-after), draftText, draftAmend, draftReplace, draftConfirm, reasonLabel, reasonOptional, reasonPlaceholder, reasonMissing, keep, pendingMeta, readyText, readyLapse, reasonShown, cancel, error, summaryPending, summaryReady, announceDraft, announceRequested, announceReady, announceTightened, announceApplied, announceCanceled, announceLapsed, on, off, fallbackLabel). strings.verbs overrides the direction wording: tighten, loosen, ask, will, stays, readyTitle, now, tightened, request, confirm, keep. English defaults, placeholders such as {label}, {value}, {current}, {date}, {delay}.

Methods

NameDescription
request(value, { reason? })Changes the value through the same rule as the control: safer applies now, riskier sends a request (mv-request with source "api"). Returns Promise<boolean>.
cancelRequest()Withdraws the pending request (mv-cancel with reason "api"). Always allowed: it is the safer way.
confirm()Applies a request whose delay is over (mv-apply with source "api"). Returns false if nothing is ready or the apply was vetoed.
discard()Throws away the change under review and puts the control back to the value in force.

Events

NameDescription
mv-requestCancelable, when the user sends a riskier change. detail: { value, previous, until, delay, reason, replaces (value of the request it replaces, or null), source: "user" | "api", waitUntil(promise) }. Persist it and call waitUntil() synchronously: the button shows a busy state, a rejection keeps the review open with an error, a promise resolving to a Date or epoch ms sets the deadline from your server. preventDefault() refuses it: the review closes and the control returns to the value in force.
mv-request-errorA waitUntil promise rejected. detail: { value, error }.
mv-applyCancelable, a value takes effect. detail: { value, previous, source: "instant" (safer change) | "timer" (delay over, no confirm-after) | "confirm" (user confirmed) | "api" }. preventDefault() vetoes it: an instant change snaps back, a pending one stays ready for an explicit confirmation.
mv-cancelThe pending request is gone. detail: { value, reason: "user" | "superseded" (a safer change applied) | "lapsed" (confirm-window elapsed) | "api" }.
mv-draftA riskier change opened or updated the review (detail.value), or the review closed, whether sent, discarded or superseded (detail.value null). detail.previous is the value in force. Handy to sync UI tied to the control.

Content structure

NameDescription
(content)Your label and control, left as they are. The component appends one .mv-mast-ui block after them. Listen to mv-apply / mv-request rather than to the control's own change events: the control briefly shows riskier values while they are under review.

CSS classes

NameDescription
mv-mast-ruleThe rule next to the control (“Lowering applies immediately. Raising takes effect after a 24-hour cooling-off period.”), linked to the control with aria-describedby.
mv-mast-cardReview and request cards; data-kind="draft | cooling | ready". Parts: -head, -badge, -ring (conic countdown, --_p = elapsed 0..1), -body, -title, -text, -meta, -left, -quote, -reason, -error, -actions.
mv-mast-statusShort-lived confirmation pill after a value took effect (“Lowered to $50”).

CSS variables

NameDefaultDescription
--mv-mast-draftvar(--mv-warning)Tone of the review card (a riskier change about to be requested).
--mv-mast-pendingvar(--mv-accent)Tone of the cooling-off card and its countdown ring.
--mv-mast-readyvar(--mv-success)Tone of the ready card and of the confirmation pill.

Accessibility

The control keeps its own role, name and keyboard behavior; the component adds two ids to its aria-describedby (the focusable part: the inner input of a number field, the radiogroup of a segmented control, every radio of a group): the rule (“Lowering applies immediately. Raising takes effect after a 24-hour cooling-off period.”) and a visually hidden summary of the current request (“Change pending: your daily deposit limit will rise to $250 on Sep 24, 10:12 AM.”), so the asymmetry is known before acting and the pending state is heard on every focus. A riskier change never moves focus: the review card appears right after the control in the DOM and a polite announcement says how long it will take and where to review it; Tab reaches its reason field and buttons, Enter in the reason field sends the request and Escape anywhere in the component throws the change away and returns focus to the control. Once sent, focus moves to “Cancel request” (the result of the action) and the request is announced politely with the full deadline (“Thursday, September 24 at 10:12 AM”); readiness, instant tightening, automatic applies, cancellations and lapses are announced politely too, saving errors assertively. The countdown text has role="timer" (implicitly aria-live off) with a spoken aria-label (“19 hours 42 minutes”), so it never chatters; the ring is decorative. A required reason uses a real label, aria-invalid and an error linked with aria-describedby. While a request is being saved the button is aria-busy and aria-disabled. States are never conveyed by color alone (icon, title and words: “will rise to”, “is ready”, “stays in force”). Deadlines are rendered as <time datetime> in absolute en-US time plus the time left. Reduced motion (OS or data-motion="reduce"): cards and the pill appear without sliding. Forced colors: cards keep a visible border and the ring uses Highlight on GrayText.

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