Exclusivobeta

Stone Soup <mv-stone-soup>

Tablero de aportaciones con un objetivo común donde la aportación de cada participante sigue a la vista y el conjunto se vuelve visiblemente más que la suma de sus partes: comidas compartidas e inscripciones a eventos (“quién trae qué”), colectas para regalos de equipo, recaudación de fondos, recompensas, listas de deseos comunitarias, cobertura de turnos o la capacidad de un sprint por persona. El objetivo es un conjunto de necesidades con nombre y cantidades (needs="Mains ×3, Desserts ×2, Drinks") y/o un importe objetivo (goal, en cualquier moneda o unidad); cada aportación cae en una olla redonda como un chip de iniciales o de avatar teñido según la necesidad que cubre (empaquetado en girasol, el nivel y el borde suben con el progreso, y los huecos vacíos discontinuos muestran lo que falta) y marca esa necesidad como cubierta. Al lado, un resumen en lenguaje claro (“5 de 7 cubiertas · Aún falta: 1 postre y bebidas”), avisos amables de duplicados (“3 personas traen ensaladas (se necesitan 2), mientras aún faltan 1 plato principal y bebidas”), una lista de necesidades con reservas “Yo lo traigo” en un clic (con la opción de preguntar qué exactamente), reservas que se pueden liberar, y una lista de participantes agrupada por persona. Pasar el puntero sobre un chip, una necesidad o una persona resalta los demás elementos relacionados. Las aportaciones y liberaciones pasan por eventos cancelables cuyo waitUntil(promise) muestra un estado de guardado y deshace el cambio si falla; mv-need-met y mv-goal-met se disparan en las transiciones.

CategoríaVisualización de datos
TipoWeb Component (<mv-stone-soup>)
Estadobeta
También instalabutton, 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

Instalación

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

Agente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["stone-soup"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Archivos copiados (dependencias incluidas): 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.

Uso

Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables 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>

Referencia cultural

La sopa de piedra, Cuento popular europeo (recogido por escrito desde la década de 1720) (1720, fábula). Unos viajeros empiezan una sopa con solo una piedra en una olla, y los aldeanos curiosos añaden cada uno un ingrediente hasta que, entre todos, han preparado una comida de verdad que ninguno habría podido hacer solo. En la interfaz, un objetivo común empieza como una olla casi vacía, la aportación de cada participante cae dentro a la vista y marca una necesidad con nombre, y el tablero sigue indicando a los recién llegados lo que aún falta.

API

Attributes

NameTipoDefaultDescription
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

NameTipoDescription
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.

Esta página se tradujo con IA. Informar de un problema de traducción