独家
Stone Soup <mv-stone-soup>
共同目标贡献看板,每位参与者的贡献都保持可见,整体明显大于各部分之和:聚餐和活动报名(“谁带什么”)、团队礼物凑份子、筹款、悬赏、社区愿望清单、排班覆盖或按人划分的迭代容量。
文化出处
《石头汤》,欧洲民间故事(自 18 世纪 20 年代起见于印刷文本)(1720 年,寓言)。几位旅人在锅里只放了一块石头就开始煮汤,好奇的村民每人加进一种食材,最终大家一起做出了一顿谁也无法独自完成的真正饭菜。在界面中,一个共同目标从一口几乎空着的锅开始,每位参与者的贡献都会清晰地落入锅中,并勾掉一项具名的需求,看板也会不断为新来的人指出还缺什么。
工作原理
共同目标贡献看板,每位参与者的贡献都清晰可见,整体明显大于各部分之和:聚餐和活动报名(“谁带什么”)、团队礼物集资、筹款、悬赏、社区心愿单、排班补位,或按人统计的冲刺容量。目标是一组带数量的具名需求(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 |
| 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
安装
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 | 类型 | 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 | 类型 | 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.