Exclusivebeta
Stone Soup — <mv-stone-soup>
Shared-goal contribution board where every participant’s addition stays visible and the whole visibly becomes more than its parts: potluck and event sign-ups (“who brings what”), team gift pools, fundraising, bounties, community wish lists, shift coverage or a sprint’s capacity by person. The goal is a set of named needs with quantities (needs="Mains ×3, Desserts ×2, Drinks") and/or an amount target (goal, in any currency or unit); each contribution drops into a round pot as an initials or avatar chip tinted by the need it fills (sunflower packing, the level and rim rise with progress, dashed empty places show what is missing) and ticks the need off. Beside it, a plain-language summary (“5 of 7 covered · Still needed: 1 dessert and drinks”), gentle duplicate notices (“3 people bringing salads (2 needed), while 1 main and drinks still needed”), a need list with one-click “I’ll bring this” claims (optionally asking what exactly), releasable claims, and a contributors list grouped by person. Hovering a chip, a need or a person cross-highlights the others. Adds and releases go through cancelable events whose waitUntil(promise) shows a saving state and rolls back on failure; mv-need-met and mv-goal-met fire on transitions.
| Category | Data display |
|---|---|
| Type | Web Component (<mv-stone-soup>) |
| Status | beta |
| Also installs | button, input |
| Keywords | exclusive, 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
Install
node scripts/add.mjs stone-soup --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["stone-soup"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/stone-soup/stone-soup.js, components/stone-soup/stone-soup.css, components/button/button.css, components/input/input.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<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>Cultural reference
Stone Soup — European folk tale (recorded in print from the 1720s) (1720, fable). Travelers start a soup with nothing but a stone in a pot, and curious villagers each add one ingredient until together they have made a real meal none of them could have made alone. In the UI, a shared goal starts as an almost empty pot, each participant’s contribution drops in visibly and ticks off a named need, and the board keeps pointing newcomers to what is still missing.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
needs | string | Named 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. | |
goal | number | Amount target. Contributions with an amount add up toward it; progress averages needs coverage and the amount ratio when both exist. | |
currency | ISO 4217 code | USD | Currency used to format amounts (Intl.NumberFormat; whole amounts without decimals). |
unit | string | Formats amounts as a plain number with this unit instead of a currency ("pts", "hours", "shifts"). | |
locale | BCP 47 tag | en-US | Locale for numbers and list joining (“1 dessert, drinks, and plates”). |
me | string | The 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-avatar | URL | Avatar image for the viewer’s claims and row. | |
ask-what | boolean | “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”). | |
manage | boolean | Organizer mode: every contribution gets a release button in the contributors list, not only the viewer’s. | |
label | string | Contributions | Accessible name of the whole board (role="group"). |
data-met / data-empty / data-mode | set by the component | data-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 | Type | Description |
|---|---|---|
needs | string | 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. |
contributions | Array<{ 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. |
state | object | Read-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 }] }. |
strings | Partial<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 / label | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-contribute | Cancelable, 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-claim | The viewer’s claim was accepted (after waitUntil). detail: { contribution, need }. |
mv-release | Cancelable, before a contribution is removed. detail: { contribution, need, waitUntil(promise) }. |
mv-need-met | A need just became fully covered. detail: { need }. |
mv-goal-met | Every 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-change | After every add or release (including rollbacks). detail: { contributions, state }. |
mv-error | A waitUntil promise rejected and the change was rolled back. detail: { action: "contribute" | "release", contribution, error }. |
Content structure
| Name | Description |
|---|---|
(content) | Children stay first (a title or intro); the board is appended after them. |
CSS classes
| Name | Description |
|---|---|
mv-stone-soup-pot | The 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-need | Need 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-person | Contributors grouped by person: -avatar, -person-name, -you, -person-items, -person-amount. |
mv-stone-soup-item | Ingredient chip: -item-dot, -item-what, -item-who, -item-need, -item-amount, -item-pending, -item-x (release). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-stone-soup-1 … --mv-stone-soup-6 | accent, teal, green, amber, rose, blue (light-dark) | Need tints, assigned in order and cycled. |
--mv-stone-soup-neutral | var(--mv-fg-subtle) | Tint of free contributions (not tied to a need). |
--mv-stone-soup-met | var(--mv-success) | Rim, level and meters once the goal is met. |
--mv-stone-soup-size | 15rem | Pot 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.