エクスクルーシブ

Stone Soup <mv-stone-soup>

共通の目標への貢献ボードで、参加者一人ひとりの追加分が見えたまま残り、全体が部分の総和以上になっていく様子がわかります。持ち寄りパーティーやイベントの申し込み(「誰が何を持ってくるか」)、チームでのプレゼントの資金集め、募金、報奨金、コミュニティのウィッシュリスト、シフトの穴埋め、メンバーごとのスプリントのキャパシティなど。

文化的リファレンス

石のスープ、ヨーロッパの民話(1720年代から印刷物に記録)(1720年、寓話)。旅人たちは鍋に石だけを入れてスープを作り始め、興味を持った村人たちがそれぞれ具材を一つずつ加えていき、ついには誰一人として単独では作れなかった本物の食事ができあがります。UI では、共通の目標はほとんど空の鍋から始まり、参加者の貢献は目に見える形で投入されて名前付きのニーズを一つずつ満たし、ボードは新たな参加者にまだ足りないものを示し続けます。

仕組み

共通の目標に向けた貢献ボード。参加者一人ひとりの追加が見えたまま残り、全体が部分の総和以上になっていく様子が目に見えます:持ち寄りパーティーやイベントの参加登録(「誰が何を持ってくるか」)、チームでの贈り物の資金集め、募金、報奨金、コミュニティのウィッシュリスト、シフトの穴埋め、スプリントの担当者別キャパシティなどに使えます。目標は数量付きの名前付きニーズの集合(needs="Mains ×3, Desserts ×2, Drinks")と金額目標(goal、通貨や単位は自由)のどちらか、または両方です。各貢献は、満たしたニーズの色で染まったイニシャルまたはアバターのチップとして丸い鍋に投入され(ひまわり配置で詰められ、進捗に応じて水位と縁が上がり、破線の空き枠が不足分を示します)、そのニーズを消し込みます。その横には、平易な言葉の要約(「5 of 7 covered · Still needed: 1 dessert and drinks」)、控えめな重複通知(「3 people bringing salads (2 needed), while 1 main and drinks still needed」)、ワンクリックで「I’ll bring this」と申し出られるニーズ一覧(具体的に何を持ってくるかを尋ねることも可能)、取り消し可能な申し出、人ごとにまとめた貢献者一覧が並びます。チップ、ニーズ、人のいずれかにホバーすると、関連する他の要素が相互にハイライトされます。追加と取り消しはキャンセル可能なイベントを経由し、その waitUntil(promise) で保存中の状態を表示し、失敗時にはロールバックします。mv-need-met と mv-goal-met は状態が切り替わったときに発火します。

カテゴリーデータ表示
タイプWeb Component(<mv-stone-soup>)
ステータス安定版
同時にインストールされるものbutton, input
Keywordsexclusive, culture, sign-up, potluck, contribution, crowdfunding, fundraising, gift-pool, pledge, claim, volunteer, shift-coverage, capacity, wish-list, bounty, goal, progress, collaboration, avatars

When to use

  • A potluck, event or volunteer sign-up must show who brings what, what is still missing and let people claim a slot in one click
  • A team gift pool, fundraiser or bounty should make each contribution visible on the way to an amount target
  • Shift coverage or sprint capacity must show which roles are staffed, by whom, and where people double up
  • A community wish list should nudge newcomers toward what is missing instead of piling onto what is already covered

Avoid when

  • Progress is a single number with no individual contributions to show → use Progress instead
  • People pick among many time slots or seats with capacity and dates: that is a scheduling or booking grid, not a shared goal → use Calendar instead
  • Contributions are private (salaries, anonymous donations) and must not be attributed to a person

インストール

node scripts/add.mjs stone-soup --out ./src/marvelous

Marvelous UI MCP サーバーを使う AI エージェント:install_components({ slugs: ["stone-soup"], 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, core/motion.js, components/stone-soup/stone-soup.js, components/stone-soup/stone-soup.css。

使い方

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

<div id="ss-demo" style="width:min(100%,60rem);margin-inline:auto">
  <style>
    #ss-demo { display:grid; gap:1.25rem; align-content:start }
    #ss-demo .ss-card { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
    #ss-demo .ss-head { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:1rem 1.25rem; border-bottom:1px solid var(--mv-border) }
    #ss-demo .ss-head h3 { margin:0; font-size:1rem; font-weight:650; letter-spacing:-.01em }
    #ss-demo .ss-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #ss-demo .ss-body { padding:1.25rem }
    #ss-demo .ss-controls { display:flex; align-items:center; gap:.75rem 1.25rem; flex-wrap:wrap; padding:.75rem 1.25rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
    #ss-demo .ss-controls .mv-choice { font-size:.8125rem }
    #ss-demo .ss-log { flex:1 1 14rem; margin:0; min-height:1.25rem; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); text-align:end }
    #ss-demo .ss-row { display:grid; grid-template-columns:minmax(0,1.15fr) minmax(0,1fr); gap:1.25rem; align-items:start }
    #ss-demo .ss-actions { display:flex; gap:.5rem; flex-wrap:wrap }
    #ss-demo .ss-row mv-stone-soup { --mv-stone-soup-size: 10.5rem }
    @media (max-width:52rem) { #ss-demo .ss-row { grid-template-columns:minmax(0,1fr) } }
  </style>

  <!-- Main: a team potluck with named needs, one-click claims, "what are you bringing?" -->
  <section class="ss-card" aria-labelledby="ss-potluck-title">
    <header class="ss-head">
      <div>
        <h3 id="ss-potluck-title">Friday team potluck</h3>
        <p>Fri, Oct 16, 2026 · 12:30 PM · 4th-floor kitchen · 14 invited</p>
      </div>
      <span class="mv-badge" data-variant="secondary" data-shape="pill">Signed in as Priya Nair</span>
    </header>
    <div class="ss-body">
      <mv-stone-soup id="ss-potluck" label="Potluck sign-up" me="Priya Nair" ask-what
        needs="Mains ×3, Salads ×2, Desserts ×2, Drinks, Plates & cups"></mv-stone-soup>
    </div>
    <footer class="ss-controls">
      <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="ss-sim">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="8" r="3.5"/><path d="M3 20c.8-3.4 3.2-5 6-5s5.2 1.6 6 5M17 8v6M14 11h6"/></svg>
        A teammate signs up
      </button>
      <label class="mv-choice">
        <input type="checkbox" role="switch" class="mv-switch" id="ss-org">
        <span class="mv-choice-text"><span class="mv-choice-title">Organizer view</span></span>
      </label>
      <label class="mv-choice">
        <input type="checkbox" role="switch" class="mv-switch" id="ss-fail">
        <span class="mv-choice-text"><span class="mv-choice-title">Make saving fail</span></span>
      </label>
      <p class="ss-log" id="ss-log">Saving takes 0.5 s (fake server).</p>
    </footer>
  </section>

  <div class="ss-row">
    <!-- Amount only: a farewell gift pool -->
    <section class="ss-card" aria-labelledby="ss-gift-title">
      <header class="ss-head">
        <div>
          <h3 id="ss-gift-title">Farewell gift for Dana</h3>
          <p>Design team pool · closes Oct 9</p>
        </div>
      </header>
      <div class="ss-body">
        <mv-stone-soup id="ss-gift" label="Gift pool" me="Priya Nair" goal="400"></mv-stone-soup>
      </div>
      <footer class="ss-controls">
        <span class="ss-actions">
          <button type="button" class="mv-button" data-size="sm" data-chip="20">Chip in $20</button>
          <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-chip="40">Chip in $40</button>
        </span>
      </footer>
    </section>

    <!-- Capacity: a sprint staffed by role, in story points -->
    <section class="ss-card" aria-labelledby="ss-sprint-title">
      <header class="ss-head">
        <div>
          <h3 id="ss-sprint-title">Sprint 42 capacity</h3>
          <p>Checkout revamp · Oct 5-Oct 16</p>
        </div>
      </header>
      <div class="ss-body">
        <mv-stone-soup id="ss-sprint" label="Sprint capacity" me="Priya Nair" goal="40" unit="pts"
          needs="Backend ×2, Frontend ×2, QA"></mv-stone-soup>
      </div>
    </section>
  </div>

  <script type="module">
    const potluck = document.getElementById("ss-potluck");
    const gift = document.getElementById("ss-gift");
    const sprint = document.getElementById("ss-sprint");
    const log = document.getElementById("ss-log");
    const fail = document.getElementById("ss-fail");
    const say = (text) => { log.textContent = text; };

    potluck.needs = [
      { label: "Mains", qty: 3, hint: "e.g., Chicken tikka, lasagna" },
      { label: "Salads", qty: 2 },
      { label: "Desserts", qty: 2, hint: "e.g., Lemon tart, brownies" },
      { label: "Drinks", hint: "e.g., Iced tea, sparkling water" },
      { label: "Plates & cups", singular: "Plates & cups" },
    ];
    potluck.contributions = [
      { who: "Tomás Silva", need: "mains", what: "Bacalhau bake" },
      { who: "Aiko Tanaka", need: "mains", what: "Veggie gyoza" },
      { who: "Lena Fischer", need: "salads", what: "Greek salad" },
      { who: "Omar Haddad", need: "salads", what: "Tabbouleh" },
      { who: "Sofia Rossi", need: "salads", what: "Caesar salad" },
      { who: "Marco Bianchi", need: "desserts", what: "Tiramisu" },
    ];

    // A fake server: every add or release takes 500 ms and can be made to fail.
    const save = () => new Promise((resolve, reject) => setTimeout(() => (fail.checked ? reject(new Error("503")) : resolve()), 500));
    potluck.addEventListener("mv-contribute", (e) => {
      const c = e.detail.contribution;
      say(`Saving “${c.what || "…"}” for ${c.who}…`);
      e.detail.waitUntil(save());
    });
    potluck.addEventListener("mv-release", (e) => {
      say(`Releasing “${e.detail.contribution.what}”…`);
      e.detail.waitUntil(save());
    });
    potluck.addEventListener("mv-claim", (e) => say(`You claimed ${e.detail.need.label.toLowerCase()}: ${e.detail.contribution.what}.`));
    potluck.addEventListener("mv-need-met", (e) => say(`${e.detail.need.label} covered.`));
    potluck.addEventListener("mv-goal-met", () => say("Everything’s covered. Time to eat!"));
    potluck.addEventListener("mv-error", () => say("Server error: change rolled back."));

    const queue = [
      { who: "Kwame Mensah", need: "drinks", what: "Hibiscus iced tea" },
      { who: "Chloé Martin", need: "desserts", what: "Lemon tart" },
      { who: "Ravi Iyer", need: "mains", what: "Chana masala" },
      { who: "Hana Kim", need: "plates-cups", what: "Plates & cups" },
      { who: "Diego Álvarez", what: "Guacamole" },
      { who: "Emma Johansson", what: "Guacamole" },
    ];
    let next = 0;
    document.getElementById("ss-sim").addEventListener("click", () => {
      const item = queue[next % queue.length];
      next++;
      potluck.contribute({ ...item, id: undefined });
    });
    document.getElementById("ss-org").addEventListener("change", (e) => { potluck.manage = e.target.checked; });

    gift.contributions = [
      { who: "Noah Williams", amount: 40 },
      { who: "Mei Chen", amount: 25 },
      { who: "Lucas Moreau", amount: 50 },
      { who: "Fatima Zahra", amount: 30 },
      { who: "Jonas Berg", amount: 20 },
      { who: "Isabela Costa", amount: 35 },
      { who: "Arjun Patel", amount: 25 },
      { who: "Grace O’Neill", amount: 45 },
    ];
    for (const btn of document.querySelectorAll("#ss-demo [data-chip]")) {
      btn.addEventListener("click", () => gift.contribute({ who: "Priya Nair", amount: Number(btn.dataset.chip) }));
    }

    sprint.strings = { collected: "committed", peopleTitle: "Team", claim: "Take this role" };
    sprint.contributions = [
      { who: "Yusuf Demir", need: "backend", what: "Payments API", amount: 8 },
      { who: "Ingrid Nilsen", need: "frontend", what: "Checkout UI", amount: 10 },
      { who: "Pablo Ortega", need: "backend", what: "Order service", amount: 8 },
      { who: "Mariam Said", need: "qa", what: "Regression suite", amount: 6 },
    ];
  </script>
</div>

API

Attributes

NameタイプDefaultDescription
needsstringNamed needs, comma-separated, with an optional quantity: "Mains ×3, Desserts x2, 2 × Salads, Drinks". Ids are slugs of the labels ("plates-cups"). Set the needs property to an array for ids, singulars and hints.
goalnumberAmount target. Contributions with an amount add up toward it; progress averages needs coverage and the amount ratio when both exist.
currencyISO 4217 codeUSDCurrency used to format amounts (Intl.NumberFormat; whole amounts without decimals).
unitstringFormats amounts as a plain number with this unit instead of a currency ("pts", "hours", "shifts").
localeBCP 47 tagen-USLocale for numbers and list joining (“1 dessert, drinks, and plates”).
mestringThe viewer’s name. Contributions whose who matches it (accent and case-insensitive) are theirs: ringed in the pot, a “You” tag, releasable. Claims are made in this name ("You" when absent).
me-avatarURLAvatar image for the viewer’s claims and row.
ask-whatboolean“I’ll bring this” opens an inline field (“What are you bringing for Desserts?”, placeholder from the need’s hint) before claiming. Enter adds, Escape cancels; empty means the need’s singular (“Dessert”).
managebooleanOrganizer mode: every contribution gets a release button in the contributors list, not only the viewer’s.
labelstringContributionsAccessible name of the whole board (role="group").
data-met / data-empty / data-modeset by the componentdata-met when every need is covered and the goal reached, data-empty with no contribution, data-mode="needs | amount" (amount: no needs, the contributors list carries the story).

Properties

NameタイプDescription
needsstring | Array<{ id?, label, qty?, singular?, hint? }>Need definitions. singular overrides the naive English singular used in sentences (“1 dessert”); hint is the ask-what placeholder. Reading returns the normalized array.
contributionsArray<{ id?, who, what?, amount?, need?, qty?, avatar?, mine? }>Hydrate from your data (silent: no events, no announcements). need is a need id or label; without it, a what matching a need label (“Dessert”) is linked automatically, otherwise the item is free. qty counts toward the need (someone bringing 2 of 3 drinks). Reading returns copies with the resolved need and a pending flag.
stateobjectRead-only snapshot: { needs: [{ id, label, qty, count, remaining, covered, extra, contributions }], filled, slots, total, goal, progress (0..1), met, missing (sentence), duplicates: [{ what, count, need }] }.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (needsTitle, peopleTitle, covered, collected, toGo, over, stillNeeded, allCovered, goalReached, qty, coveredTag, extraTag, claim, release, releaseItem, you, pending, askLabel, askPlaceholder, add, cancel, duplicate, duplicateMissing, overNeed, overNeedMissing, empty, ghost, announce*…). English defaults.
goal / currency / unit / locale / me / meAvatar / askWhat / manage / labelreflectedMirror the attributes.

Methods

NameDescription
contribute({ who, what?, amount?, need?, qty?, avatar?, mine?, id? })Adds a contribution (from your realtime feed, a form, a payment callback). Emits the cancelable mv-contribute, drops the chip into the pot, announces it. Returns Promise<contribution | null> (null if vetoed or its waitUntil promise rejected).
claim(needId, { what? })The viewer claims a need (what defaults to the need’s singular). Same flow as contribute with source "claim", then mv-claim. Returns Promise<contribution | null>.
release(id)Removes a contribution: emits the cancelable mv-release, removes it optimistically, restores it if a waitUntil promise rejects. Returns Promise<boolean>.

Events

NameDescription
mv-contributeCancelable, before any add. detail: { contribution (editable draft: change what, need, amount…), need, source: "api" | "claim", waitUntil(promise) }. With waitUntil the chip shows a saving state and the add rolls back (mv-error) on rejection. preventDefault() refuses it.
mv-claimThe viewer’s claim was accepted (after waitUntil). detail: { contribution, need }.
mv-releaseCancelable, before a contribution is removed. detail: { contribution, need, waitUntil(promise) }.
mv-need-metA need just became fully covered. detail: { need }.
mv-goal-metEvery need is covered and the amount goal reached (fires on the transition, again if it is lost and regained; never on hydration). detail: { total, goal, contributions }.
mv-changeAfter every add or release (including rollbacks). detail: { contributions, state }.
mv-errorA waitUntil promise rejected and the change was rolled back. detail: { action: "contribute" | "release", contribution, error }.

Content structure

NameDescription
(content)Children stay first (a title or intro); the board is appended after them.

CSS classes

NameDescription
mv-stone-soup-potThe aria-hidden pot: -rim (SVG progress ring, -rim-track / -rim-arc), -bowl with -level, -bits holding one .mv-stone-soup-bit per contribution (data-tint, data-mine, data-pending, data-hot), per missing place (data-ghost) and an overflow “+12” (data-more). Each bit has -bit-face and a -bit-label shown on hover and arrival.
mv-stone-soup-summary-metrics (.mv-stone-soup-metric[data-kind=needs|amount|count] with -metric-value, -metric-label, -meter), -missing (data-state=open|met) and -notices (.mv-stone-soup-notice).
mv-stone-soup-needs / mv-stone-soup-needNeed list and rows (data-need, data-tint, data-state=open|covered|over): -need-mark, -need-label, -need-count, -pips, -tag, -need-items, -need-action with .mv-stone-soup-claim, .mv-stone-soup-release or the .mv-stone-soup-ask form.
mv-stone-soup-people / mv-stone-soup-personContributors grouped by person: -avatar, -person-name, -you, -person-items, -person-amount.
mv-stone-soup-itemIngredient chip: -item-dot, -item-what, -item-who, -item-need, -item-amount, -item-pending, -item-x (release).

CSS variables

NameDefaultDescription
--mv-stone-soup-1 … --mv-stone-soup-6accent, teal, green, amber, rose, blue (light-dark)Need tints, assigned in order and cycled.
--mv-stone-soup-neutralvar(--mv-fg-subtle)Tint of free contributions (not tied to a need).
--mv-stone-soup-metvar(--mv-success)Rim, level and meters once the goal is met.
--mv-stone-soup-size15remPot column width (the board stacks under 34rem of container width).

Accessibility

The board is a labelled group whose pot is aria-hidden: everything it shows exists as text. The summary states progress in words (“5 of 7 covered”, “$315 of $400 collected · $85 to go”) and lists what is still needed; duplicate notices are plain text with an icon. Needs and contributors are real lists (announced with their item counts) under visible headings, each row reading as a sentence (“Desserts, 1 of 2: Tiramisu from Marco Bianchi, I’ll bring this: Desserts”). Covered and over-subscribed needs carry a word and an icon (“Covered”, “+1 extra”), never color alone; tints are only a secondary cue. Claims and releases are native buttons with explicit names (“Release Tiramisu”); the ask-what field has an accessible label naming the need, Enter adds, Escape returns focus to the claim button. Lists are rebuilt on change but focus is kept on the same control, or moved to the logical next one (after a claim, its Release button; after a release, the claim button); saving states are aria-busy and spoken as “Saving…”. A polite live region announces each addition and release (“Kwame Mensah is bringing Hibiscus iced tea. Drinks covered.”, “Everything’s covered. Goal met!”) and an assertive one reports failed saves; hydrating contributions announces nothing. Hovering or focusing a row highlights the matching chips in the pot. Reduced motion (OS or data-motion="reduce"): chips appear in place without dropping, rippling or sliding, the level and ring jump. Forced colors: the pot switches to system colors with outlined chips and a Highlight ring.

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