익스클루시브

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로 번역되었습니다. 번역 문제 신고